Compare commits

...
38 Commits
Author SHA1 Message Date
0d4eb0f269 release: freeze v0.3.22 — version bump, lockfiles, changelog (#1150)
The dubbing release. package.json (source of truth) + the three mirrors
(Cargo.toml, pyproject.toml, version.py fallback) to 0.3.22; uv.lock +
Cargo.lock refreshed; CHANGELOG's Unreleased section (29 entries) becomes
## [0.3.22] — 2026-07-14 with the headline, split Added blocks merged.

Gates on the frozen content, all green before any version mutation:
backend 3033 + 204, frontend 1253, format, lockstep 6/6.

Co-authored-by: mergetest <nizam4103@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 03:04:17 +05:30
018cdcb47f fix(dub): hydrate partial translations on tab switch; dialect guard moves into the store (#1149)
* fix(dub): hydrate partial translations on tab switch; dialect guard moves into the store

Review round on #1148, both findings real:

- Greptile P1 "missing translations leave mixed text": the in-browser
  translations map can be PARTIAL (tracks generated before per-language
  persistence, partial regens); the non-destructive switch then left those
  rows in the previous language under a single-language preview. New
  GET /dub/segments-text/{job}?lang= exposes segments_i18n (the
  authoritative per-language map every generate rebuilds); the tab click
  hydrates only the gap rows, failure-silent, and skips stale responses if
  the user switched again mid-fetch.
- CodeRabbit "clear stale dialect": the dropdown paths each cleared a
  non-matching dubDialect by hand; the guard now lives inside
  switchDubLangCode so every caller (dropdown, multi-language loop, preview
  tabs, future ones) inherits it. Matching dialects survive.

Tests: endpoint (i18n map served, never-generated track -> empty map, legacy
job -> empty map), hydration (stored rows swap instantly, missing row
hydrates from the mock backend and is cached into translations), dialect
guard (cleared on mismatch, kept on match). Suites: dub sweep 262, frontend
1253, both green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(api): register /dub/segments-text in the route-inventory snapshot

The inventory guard caught the new endpoint exactly as designed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: mergetest <nizam4103@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 02:11:43 +05:30
22a513a404 fix(dub): Export-step language tabs switch the transcript segments too (#1148)
Owner request with screenshot: the Original/Bengali/German/… pills above a
finished dub only swapped the preview VIDEO; the segment list kept showing
the last generated/edited language — German audio playing over Bengali text.

The pills now also route through switchDubLangCode (the P1.2 user-driven
language switch: outgoing text snapshotted into translations[prev], incoming
swapped in, non-destructive when no saved entry exists) plus setDubLang —
exactly what the language dropdown and the multi-language generate loop
already do, so fingerprint/staleness semantics are identical. The Original
pill deliberately leaves the editing language untouched: there is no
'original' editing language, and every row already renders the original
line under its translation.

Tests: clicking the German pill swaps segment text to the stored German
translation, snapshots the outgoing Bengali, and sets dubLangCode; the
Original pill leaves dubLangCode alone. Fail-before verified (wiring
stashed → text swap test fails). Full frontend suite: 1251 passed.

Co-authored-by: mergetest <nizam4103@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 01:39:29 +05:30
9ecb810946 fix(shell): version-gate crash markers, pin the WebView repair contract, run the Rust suite in CI (#1145)
* fix(shell): version-gate crash markers, pin the WebView repair contract, run the Rust suite in CI

Two deferred items from the recurrence audit, plus the CI gap that made
them possible:

- crash.rs: a persisted "backend crashed" marker now only surfaces for
  the release that wrote it. After an upgrade, markers from the previous
  version (quite possibly the build whose crash the upgrade fixed) are
  ignored and pruned on read instead of resurfacing unacknowledged as if
  the new build had crashed. backend_version gains #[serde(default)] so
  legacy version-less markers still deserialize — as "", which the gate
  treats as stale by design. Preview stamps (X.Y.Z-N) count as their
  release.

- commands.rs: the #879 WebView2 cache repair's filesystem half is
  extracted into clear_webview_cache_at() (paths + retry policy as
  parameters, zero behavior change) and its contract is pinned by tests:
  no marker → nothing touched; marker consumed first, unconditionally
  (one-shot — a failing repair can never loop across launches); missing
  cache is success; a locked cache is retried then abandoned with a log,
  never bricking startup.

- ci.yml: the Tauri shell check only ran `cargo check`, which neither
  compiles nor runs #[cfg(test)] code — so the shell's ~90 unit tests
  (crash.rs, reset.rs, bootstrap.rs, …) never executed anywhere in CI.
  `cargo test --lib` now runs them natively on all three OSes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(shell): crash-notice read path is strictly read-only — a prune-save there could destroy a fresh marker

Greptile's P1 is real, and hotter than stated: get_last_backend_crash is
not just a startup check — streamDropError (#1119) polls it every second
for 8 s after a stream drops, which is exactly when the death watcher is
inside record_crash's load→push→save. The previous commit's read path did
load→prune→save when stale-version markers existed (the post-upgrade
state), so a poll could load the pre-crash snapshot, lose the race, and
save over the freshly recorded marker — silently deleting the only
evidence of the crash it was being polled to find.

Smallest fix: reads never write. The read path (extracted as
read_notice_from(path, version) so the contract is testable) filters
stale-version markers in memory only; disk pruning stays on the write
paths (record_crash, acknowledge_backend_crash), where load-modify-save
already existed pre-PR and is paced by a crash or a user click rather
than a 1 Hz poll. Regression test pins the file as byte-identical across
reads, stale markers filtered and current ones surfacing as before.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: mergetest <nizam4103@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 01:24:10 +05:30
283ef36b13 feat(dub): Voice match toggle — per-line prosody vs one consistent reference per speaker (#1147)
* feat(dub): Voice match toggle — per-line prosody vs one consistent reference per speaker

Owner report: "still 4 segments different in voice as they are 4 times done
from each segment?" — Wave 3.2 clones each dub line from a reference cut from
its OWN source audio (great prosody match), but the voice IDENTITY drifts
line to line, and heuristic-diarized jobs have no pooled speaker clones to
anchor it. The precedence was hardcoded; now it's a per-dub-job setting.

DubRequest.voice_match:
- "per_line" (DEFAULT, unchanged): segment clip preferred, speaker clone
  fallback — byte-identical to the previous behaviour.
- "consistent": ONE reference per speaker for the whole dub. `auto:` bindings
  use the pooled speaker clone; when none exists (heuristic diarization skips
  extraction entirely — the key case) a deterministic pick among that
  speaker's segment clips (longest ≥3 s, tie-break lowest segment id) is
  reused for every line. Server-default self `auto-seg:` bindings join the
  pick (they're what prepare stamps on heuristic jobs — the Voice dropdown
  can't even render them, so no user choice is overridden); explicit CROSS
  auto-seg bindings still honour their clip. The shared pick is multi-use,
  so it stays warm in the clone-prompt cache (#1132 cache_ref semantics) at
  both the main generate and the OOM-retry call site.

voice_match is part of the segment fingerprint when non-default (mixed in
like track_lang, so all stored hashes keep their values): flipping the toggle
marks segments stale instead of letting "Regen changed" splice mixed-identity
voices (#281 class). The client sends the mode on both /tools/incremental
recompute paths.

UI: a compact Voice-match Segmented control next to the Timing picker in the
dub panel, persisted in the prefs slice; labels + tooltips in all 21 locales.

Tests: resolution through the real dub_generate path for both modes (incl.
the 4-segment heuristic job unifying on one ref — fail-before/pass-after),
pick determinism + tie-breaks, schema validation, fingerprint semantics, and
frontend store→request wiring.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(changelog): Voice match toggle entry under Unreleased (#1147)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: mergetest <nizam4103@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 01:08:50 +05:30
3aa2ef285a fix(engines): bundle en_core_web_sm — no mid-generation GitHub download (#1146)
* fix(engines): bundle en_core_web_sm — no mid-generation GitHub download

Post-merge review finding on #1144 (valid): with pip present, misaki/spaCy's
first-use auto-download WORKS now — meaning the first English MLX-Audio
generation performs a raw GitHub release download that (a) bypasses the app's
entire HF-mirror/endpoint system (restricted-network users have no recourse
mid-generation) and (b) fails offline. Local-first says default features
shouldn't spring surprise outbound requests at generation time.

en_core_web_sm-3.8.0 is now a pinned URL dependency in pyproject/uv.lock
(~12 MB wheel): it arrives at install/update time via the normal dependency
flow (where network failures are visible and retried), survives drift-sync
by construction, and spacy.util.is_package() finds it so misaki never
triggers its downloader at all. The #1143 containment stays as the backstop
for any other CLI-shaped dependency.

Also clarifies the venv test per review: pytest's interpreter IS the
uv-synced venv in CI and the packaged app, so find_spec verifies the lock;
the test now also pins the bundled model.

Validated: uv sync --frozen clean; en_core_web_sm importable.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(engines): direct-URL dependency + frozen-bundle collection (review)

Two of three review P1s were real:

- Docker build break: `uv add` wrote a bare "en-core-web-sm" dependency with
  the URL only in [tool.uv.sources] — Docker's `uv pip install --system .`
  reads project metadata only, would resolve the bare name against PyPI
  (where spaCy models don't exist), and the image build fails. Now a direct
  "name @ url" dependency, the same form kittentts has always used, so every
  installer (uv sync, pip, Docker) sees the same source. Re-locked;
  uv sync --frozen clean.
- Frozen bundle: backend.spec ships mlx_audio, whose Kokoro path loads
  en_core_web_sm DYNAMICALLY (spacy.load by name) — PyInstaller never sees
  the import, so a frozen build would hit misaki's downloader at first
  English generation. collect_all('en_core_web_sm') added inside the
  mac-ARM block (plain data package, no nanobind hazard — the reason
  collect_all is banned for mlx itself doesn't apply).

Declined with precedent: "hard-coded GitHub URL breaks restricted networks"
— kittentts has shipped as exactly this GitHub-release URL form in the same
dependency list since it was added; install-time GitHub fetches are the
project's accepted pattern (the bootstrap's gh-proxy mirror exists for
restricted networks), unlike mid-generation fetches, which this PR removes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: mergetest <nizam4103@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 00:48:23 +05:30
db12b94145 fix(engines): ship pip in the managed venv — the #1133 root trigger (#1144)
The containment fix (#1143) makes a CLI-shaped dependency's sys.exit
survivable; this removes the reason it fired at all. mlx-audio's Kokoro
phonemizer (misaki) auto-downloads en_core_web_sm via spacy.cli.download,
which shells out to `python -m pip install <url>` — and uv-managed venvs
ship no pip, so the download always failed.

Why a real dependency instead of installing pip (or the model) ad-hoc at
engine load: the updater's drift sync reconciles the venv against the
lockfile (#1029/#1030, --inexact), so anything outside the lock is stripped
on the next update — the failure would quietly return after every release.
pip in pyproject/uv.lock survives sync by construction.

Validated against all lock consumers: uv sync --frozen clean; Docker's
`uv pip install --system .` reads pyproject; version-lockstep test reads
only the version field. Regression test asserts pip is importable in the
managed env.

Co-authored-by: mergetest <nizam4103@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 00:25:46 +05:30
5302170688 fix(engines): contain SystemExit at the pool boundary — a CLI-shaped dependency killed the backend (#1133) (#1143)
* fix(engines): contain SystemExit at the pool boundary — a CLI-shaped dependency killed the backend (#1133)

Auto-report #1133 (8GB M1, v0.3.21, engine mlx-audio, exit code 1 at 21s
uptime) carried the whole story in its stderr tail: mlx-audio's Kokoro
pipeline uses misaki's G2P, whose __init__ runs spacy.cli.download() IN
PROCESS when en_core_web_sm is missing. spaCy's downloader is written as a
CLI: with no pip in the venv (uv-managed venvs ship none), its error printer
calls sys.exit(1). SystemExit is not an Exception, so every except Exception
on the path waved it through; it rode the executor future into the event
loop, where uvicorn treats SystemExit as "shut down" — backend dead.

Class fix, not a spacy special-case: _contain_system_exit() wraps every
callable dispatched through run_on_gpu_pool_guarded (all engine loads AND
generates funnel through it, #1033) and asr_backend.run_transcribe_guarded,
converting SystemExit into a RuntimeError that names the real failure mode.
Any engine dependency written as a CLI is now covered on both the TTS and
ASR sides.

Not done here (follow-up candidates): pre-provisioning en_core_web_sm for
the Kokoro/mlx-audio path so the download never triggers, and/or shipping
pip into the managed venv. Both are provisioning decisions; this PR makes
the failure survivable and honest first.

Tests: SystemExit from a pool job -> RuntimeError naming SystemExit(code),
executor still usable afterwards; same for the transcribe guard. Both fail
with the containment reverted. Full suite: 3016 passed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(engines): containment helper moves to a leaf module (CodeQL cyclic-import)

utils/containment is stdlib-only, so model_manager and asr_backend both
import it at module top with no cycle — the call-time back-import CodeQL
flagged is gone.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: mergetest <nizam4103@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 00:12:23 +05:30
780ff1f6cf fix(tts): Vietnamese consistency — Voice vs Audiobook divergences (#1142)
* fix(tts): Vietnamese consistency — Voice vs Audiobook divergences (#1139)

Three root causes behind "Vietnamese Voice generation is inconsistent
compared to Audiobook":

1. Numbers: num2words' vi cardinals are wrong for 2001-2099 (misused
   "lẻ": 2024 → "hai nghìn lẻ hai mươi bốn") and vi has no year form, so
   normalization mangled the years the engine used to read natively.
   Vietnamese now keeps its digits, and _num2words_lang's display-name
   path now gates on _NUM2WORDS_LANGS like the ISO path (the loophole
   that let "Vietnamese" bypass the vetting "vi" would have failed).

2. Seed: the longform resolver fetched a profile's pinned seed but only
   the cache signature ever used it — book renders ran unseeded. Both
   longform synth wrappers now seed torch per segment via the new pure
   segment_seed(base_seed, text) helper (crc32-decorrelated, order- and
   cache-independent, mirroring /generate's used_seed + i).

3. Quality preset: the audiobook synth inherited num_step=32 /
   guidance_scale=2.0 from model-config defaults by accident of
   omission while /generate defaults to 16 — the main audible gap.
   Now explicit (LONGFORM_NUM_STEP / LONGFORM_GUIDANCE_SCALE), pinned
   by a test so upstream default drift can't silently change books.
   The Voice-page fast default (16) is deliberately unchanged.

Also (issue part 3): the finished audiobook's player + Download link
lived in component useState and evaporated on tab switch — the last
render's filename is now store-backed and persisted.

Regression tests fail-before/pass-after (verified by stashing the fix):
vi digit passthrough + vetted-set gate invariant; segment_seed +
seeding in both synth branches + explicit preset kwargs; lastOutput
store round-trip. Full backend suite 3004 passed; frontend 1237 passed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(ui): loadProject clears lastOutput; document longform seeding contracts (review)

Review-bot findings on #1142, evaluated:

- FIXED (Greptile P1 "Output Escapes Its Project" + CodeRabbit):
  loadProject now resets lastOutput like newProject already did, so
  loading project B never presents A's finished render as B's output.
  Regression test added (set lastOutput → loadProject → cleared).

- REFUTED (P1 "Global RNG Races Between Workers"): the exposure is
  identical to /generate's existing #526 seeding — generation.py calls
  torch.manual_seed on the same global RNG inside the same GPU pool,
  and has since that PR. The pool is 1 worker on MPS/CPU and small-VRAM
  CUDA (model_manager._pick_gpu_workers), where determinism is strict;
  a >1-worker CUDA pool is best-effort for BOTH paths. A race-free fix
  means threading a per-call torch.Generator through the model's
  samplers app-wide (covering /generate too) — out of scope for this
  PR and pointless to do one-sided. Contract now documented on
  _seed_segment_rng.

- REFUTED (P2 "Repeated Text Reuses One Seed"): identical takes for
  identical repeated lines is the pipeline's shipped semantic — the
  content-addressed SegmentCache (segment_cache_key hashes text +
  voice sig, not position) already replays one WAV for every identical
  span — and seeding only activates when the user pinned a seed, i.e.
  asked for reproducibility. Position-based keys would shift every
  later span's seed on a one-paragraph insert, breaking the
  cache-independent partial re-render guarantee. Documented on
  segment_seed.

- DECLINED (P2 "Persisted Filename Can Outlive File"): longform
  outputs in OUTPUTS_DIR are not auto-pruned (prune_cache_dir bounds
  only longform_cache), so a dangling name requires manual deletion;
  auto-clearing on an <audio> error would instead wipe a valid link
  whenever the backend is briefly down at mount. Projects → Audiobooks
  stays the authoritative library.

Also rebased onto main past #1141 (CHANGELOG resolved keeping both
Unreleased→Fixed entries, this PR's on top).

Affected suites: 264 passed; frontend format clean, 1245 tests passed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: mergetest <nizam4103@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 23:42:36 +05:30
d6f24dafd5 feat(hardening): six recurrence guards from the closed-issue-history audit (#1141)
* feat(hardening): six recurrence guards from the closed-issue-history audit

An agent audit swept every closed issue, clustered the error classes, and
checked each for fix + regression test + upgrade/reinstall survival. Six of
the "fixed but fragile" gaps are closed here; each guard has a regression
test in tests/test_recurrence_hardening.py (9 tests).

1. Evict-then-load (class 1, ~90 issues): a plain TTS load on a tight
   unified-memory box could still be OS-killed — the dub path frees memory
   before ASR loads (#1119) but nothing did before a TTS load.
   _make_room_before_tts_load() releases the idle capture-ASR model, clone
   prompts, and allocator caches when free RAM < the unified headroom.
   Deliberately NOT admission control: the #1111 decision (advisory-only,
   never refuse a load on an estimate) stands; this only does earlier what
   idle reclaim does later, and roomy machines skip it entirely.
2. Honest SIGKILL attribution (class 1): crashCauseHint() says "the OS ran
   out of memory (RAM)" for signal 9 instead of guessing VRAM on machines
   that have none. VRAM guidance kept for real GPU aborts (signal 6 etc.).
3. Clone-kind save sanitize (class 3, recurred 3x): the server-side instruct
   heal was gated to design-kind; a clone profile saved by any bypassing
   client could persist prose that 400s on every use. profiles.py now
   sanitizes both kinds at the single choke point.
4. Stale user_env validation (class 5): ~/.config/omnivoice/env is inherited
   verbatim by reinstalls; path-valued keys (OMNIVOICE_CACHE_DIR/DATA_DIR)
   that don't exist and can't be created are dropped for the run with a loud
   log line (file untouched — replugging the drive restores the setting).
   The two #480 precedence tests updated to use creatable paths (they test
   precedence, not path validity).
5. omni_ui schema guard (class 6): sanitizeOmniUi() whitelists + shape-checks
   every persisted field before restore — one malformed field used to throw
   mid-restore and silently discard everything after it, and every future
   field re-opened the #1067 class. Includes a lockstep test failing when
   useAppData reads a field missing from the schema.
6. safe_replace EXDEV helper (class 7): os.replace across devices raises
   EXDEV (the Windows D:-drive Errno 18/22 class); utils/fsops.safe_replace
   degrades to copy+fsync+replace. Adopted at the two cross-directory movers
   (log rotation, persona restore); temp-sibling writers stay on os.replace.
Plus: the generate timeout scales with text length (class 4's 503 wave —
   +1s per 40 chars past the first 1200, env floor respected), so long texts
   on slow hardware stop dying at exactly 300s with a "set an env var" remedy.

Deliberately NOT done, with reasons:
- ASR auto-promotion to the crash-isolated engine after a wedge: the code
  records an explicit owner rule against silent engine switching
  (asr_backend.py "we never switch engines automatically") — flagged to the
  owner instead of overridden.
- Rust items (webview cache-clear unit test, crash-marker versioning across
  updates): deferred to their own PR — the local cargo target was reclaimed
  for disk space, so they can't be verified locally right now.

Full suite: 2999 backend + 1243 frontend.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(changelog): correct PR ref to #1141

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(hardening): review round — reclaim at the shared load boundary, write-probe path validation

Both Greptile P1s were real:

- "Startup preload skips reclaim": _make_room_before_tts_load() ran only in
  get_model(); preload_model() calls _load_model_with_timeout() directly, so
  a memory-tight machine was protected on demand loads but could still be
  OS-killed during the startup preload — the exact window the guard exists
  for. The reclaim now lives in _load_model_with_timeout(), the boundary both
  callers share.
- "Read-only paths pass validation": an existing directory on a read-only
  mount passes makedirs+isdir but fails on first real use, so the stale
  setting survived validation only to break downloads later. The check now
  probes actual write capability (create+delete a probe file). New test with
  a chmod-0o500 dir (skipped under root, where the probe cannot fail).
- CodeQL: the two intentional best-effort excepts in fsops.py now carry
  their explanatory comments.

Full suite: 3000 passed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: mergetest <nizam4103@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 23:19:58 +05:30
dc3527ab05 fix(dub): stereo, full-band music bed — separate the HQ extraction, pin the mix to stereo (#1138)
* fix(dub): stereo, full-band music bed — separate the HQ extraction, pin the mix to stereo

Owner asked for a channels/Hz/samples comparison of a dub against its
original to tune generation toward the source. The measurements found a
class, not a knob:

  L/R correlation: original 0.754, dub 1.000 (mono in a stereo container)
  stereo width (S/M): 0.375 vs 0.003
  LUFS: -17.8 vs -17.2 (already fine)

Two stacked causes:

1. INGEST: Demucs separated audio.wav — the 16 kHz MONO extraction made for
   ASR. The music bed therefore inherited mono AND an 8 kHz bandwidth
   ceiling at its source (Demucs upsamples to 44.1 kHz internally, so the
   stems LOOKED like 44.1k stereo files while carrying neither). Ingest now
   extracts a second full-quality file (44.1 kHz stereo, pcm_s16le) just for
   separation; ASR keeps its 16 kHz mono file; Demucs cost is ~unchanged
   (it resampled to 44.1 kHz internally either way). Best-effort: if the HQ
   extraction fails, separation falls back to the ASR file — exactly the old
   behavior. The stem-move path follows the input's basename.

2. MIX: amix negotiates ONE channel layout across inputs, and the
   synthesized voice is mono — so even a true-stereo bed was collapsed at
   the mix. bed_mix_filter now pins BOTH legs to stereo
   (aformat=channel_layouts=stereo); upmixing the mono voice duplicates it
   dead-center, which is where dubbed dialogue belongs anyway.

Verified with real ffmpeg: the new graph preserves a stereo bed's width
through the mix (and the ingest test pins that demucs receives audio_hq.wav
with -ac 2 -ar 44100 while ASR keeps -ac 1 -ar 16000). Both tests fail with
their half of the fix reverted. Full suite: 2989 passed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(dub): pre-HQ stem caches are not reused (review)

Greptile P1, real: the content-hash cache restores a previous job's stems for
the same video and skips Demucs — so every video processed BEFORE the
HQ-extraction change would keep its 16 kHz-mono-derived bed forever, and the
fix would never apply to exactly the videos users re-upload to hear the
difference. find_cached_job now requires the audio_hq.wav marker in the
cached job dir; older candidates are skipped with a log line and separation
reruns once at full quality. Regression test covers both directions of the
gate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: mergetest <nizam4103@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 21:04:06 +05:30
a4d9d9f128 feat(dub): underrun fill — short dubbed lines are slowed toward their slot instead of leaving dead air (#1137)
* feat(dub): underrun fill — short dubbed lines are slowed toward their slot instead of leaving dead air

The dub pipeline has always handled audio that is too LONG for its slot
(atempo compression, Smart Fit's audio/video split, trims). Audio that is too
SHORT was start-aligned and abandoned — and that is the common case, not the
corner: translations routinely speak faster than the source delivery.
Measured on a real 4-segment dub, 8.8 of 18.7 seconds of original speech time
had no dubbed voice. What fills those holes is the separated bed's
under-speech residue (37% of the original energy, measured), so the user
hears them as BOTH "little silences" AND "the music is numbed" — and sees
them as lip-sync failure, since the mouth keeps moving after the dub stopped.

The fill: when a line's natural duration covers less than UNDERRUN_TOLERANCE
(95%) of its slot, slow it toward the slot with the same pitch-preserving
atempo pipe the compression path uses, bounded at min_audio_rate (default
0.85x — comfortably natural; atempo handles <1 natively). Wired into both
fitting strategies:

- fit_planner._fit_one: need < 1 now resolves to audio_rate=max(need, floor),
  status "audio_slowed" — planner stays a pure function; golden fixtures
  regenerated per their own instructions (10 substantive lines: five
  underrun segments across four scenarios flip to audio_slowed@0.85).
- dub_generate smart_fit branch: applies the rate in both directions (the
  target formula was already direction-agnostic).
- dub_generate strict_slot branch: mirror of its compression arm.
- stretch_video and concise strategies deliberately untouched (natural-rate
  by design / never-intervene by design).

OMNIVOICE_UNDERRUN_MIN_RATE overrides the floor (1.0 disables; clamped to
atempo's sane range). The per-segment fit badge shows "slowed N.NNx" with a
tooltip, translated in all 21 locales.

Tests: planner contracts (fill bounded by floor, tolerance zone untouched,
disable switch, empty-audio guard), the flipped unit/golden/integration
expectations updated with the rationale, and the existing smart_fit
integration test now exercises the fill through the real mix loop (its seg0
comes out audio_slowed@0.85 end to end). Full suite: 2987 backend + 1236
frontend.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(dub): strict-slot slow-downs report themselves honestly (review); ru pitch wording

Review round on #1137:

- Greptile P1 "slowdown reports fits" — REAL: the strict_slot underrun fill
  fell through to the unconditional {"status": "fits"} entry, so a slowed
  segment's badge hid the applied rate (and compression_applied mislabeled
  it). The branch now emits {"status": "audio_slowed", "audio_rate": …} like
  the smart_fit path — same honesty contract everywhere.
- Greptile P1 "padded audio hides underruns" — REFUTED with evidence: nothing
  pads strict-slot audio before the check (_load_entry_wav returns the
  natural-length WAV; only error/silence slots are slot-sized, and those are
  synthetic silence by design). On-disk segment WAVs measure both shorter and
  longer than their slots, which pre-padding would make impossible.
- CodeRabbit: Russian tooltip now says "высота тона сохранена" (pitch), not
  "высота сохранена" (height).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: mergetest <nizam4103@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 19:09:02 +05:30
a7efaa24f7 fix(dub): background bed no longer plays quiet and muffled — cancel amix normalization, mix at 48 kHz (#1136)
* fix(dub): background bed no longer plays quiet and muffled — cancel amix normalization, mix at 48 kHz

Reported live: "background music is so much not like the original." Two
stacked fidelity bugs in every bed-mix site, measured with real ffmpeg on a
real dub job:

1. LEVEL — ffmpeg's amix NORMALIZES its inputs, so the per-site weight
   strings meant "favor dialogue slightly" but actually played the music bed
   at ~57% of its original level (batch.py stacked an explicit volume=0.15
   under the same normalization, leaving its bed near 8%).
2. BANDWIDTH — the voice track is synthesized at 24 kHz and amix negotiates
   one common rate, so the 44.1 kHz bed was silently downsampled to 24 kHz:
   everything above 12 kHz (cymbals, air, brightness) vanished.

Six call sites carried six hand-rolled variants of the same filter string
(dub_export x5, batch x1) with inconsistent input ordering — the same
copy-divergence pattern that orphaned the clone-prompt cache (#1130). They now
share one builder, services.ffmpeg_utils.bed_mix_filter(): both inputs
resampled to 48 kHz before the mix, a compensating volume multiply that
cancels amix's normalization exactly (the weights ARE the absolute gains: bed
0.9, voice 1.1), and a transparent peak limiter for the rare summed peak that
full-scale mixing makes possible.

Measured A/B on the reporting user's job (bed vs bed-through-mix, silent
voice): 57% -> 90% of original level, 24 kHz -> 48 kHz output. The remaining
-0.9 dB is deliberate dialogue headroom, one constant to change if policy
shifts.

Tests: the export command must carry the resample + compensation + limiter
(fails on the old strings), builder label-uniqueness for multi-track graphs,
and the existing export suites unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(dub): amix renormalizes when a stream ends — disable normalization instead of compensating for it

Greptile P1 on this PR, confirmed real by measurement: amix's normalization is
DYNAMIC — it rescales the remaining inputs whenever one ends. The previous
commit cancelled it with a constant post-mix multiply, which is exact only
while both streams are active; once the (even marginally shorter) voice track
ends, the bed's internal scale jumps to 1.0 and the fixed multiply BOOSTS the
tail music into the limiter. Measured on the real job with a deliberately
short voice: bed at 90% while the voice runs, 189% after it ends. The original
A/B used equal-length streams, which is why this never showed.

Fix: amix normalize=0 (a plain sum) with per-input volume gains — levels are
exact for the whole timeline regardless of stream lifetimes. Same measurement
now: 90% / 90%.

normalize= arrived in ffmpeg 5.x, and system-ffmpeg users can be older, where
an unknown option rejects the whole graph (= no export at all). The builder
probes `ffmpeg -h filter=amix` once per process and falls back to the
compensated form on legacy builds — its tail quirk is the lesser evil next to
a failed export, and every bundled/imageio tier ships 7.x.

Tests: both paths pinned (normalize=0 + per-input gains on modern; the
compensation multiply on legacy), probe monkeypatched per test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(dub): anchor the amix monkeypatches to the call chain — module aliases miss under random order

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: mergetest <nizam4103@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 16:28:14 +05:30
46141c8e5e fix(dub): a rate-limited polish pass no longer skips fitting, fails the UI, or ignores Retry-After (#1135)
* fix(dub): a rate-limited polish pass no longer skips fitting, fails the UI, or ignores Retry-After

Observed live (owner's Bengali dub, 4 segments): every cinematic reflect call
429'd against a free-tier OpenRouter model and the UI declared "4/4 segment(s)
failed" over a translate that succeeded. Root-causing that surfaced a class,
not a message bug:

The cinematic reflect/adapt chain is OPTIONAL polish — on any failure the
segment keeps its literal translation and is fully usable. But every such
degradation (no-llm, reflect/adapt errors, adapt-diverged, wrong-script,
cinematic-budget) was reported under the same "error" key as real translation
failures. Three consumers took that at face value:

  1. useDubWorkflow counted the rows as failed -> the red N/N toast;
  2. _stamp_predicted_rate_ratio and _stamp_duration_plan skipped them ->
     no rate badges, no fits/tight/impossible verdicts;
  3. _apply_fit_pass and the condense pass skipped them -> overlong lines went
     to synthesis unfitted and came out audibly time-compressed at mix. This
     is a direct contributor to "later segments got worse" in rate-limited
     Cinematic dubs.

Split the vocabulary: "error" now means the row has no usable text (base
translation failed); optional-pass fallbacks ride a separate "degraded" key.
Downstream filters keep gating on "error" only, so degraded rows flow through
every fitting pass. The UI shows an amber "translated, polish skipped
(<reason>)" toast and a mild row tooltip instead of a red failure, and editing
a row clears the stale annotation.

And the retry that makes most of this moot: _chat now honors a 429's
Retry-After once (capped at 30s, jittered so the 6-wide segment fan-out does
not re-stampede the same window). OpenRouter's free pool says "Retry-After: 2"
- giving up instantly turned a two-second wait into a whole failed pass.

Tests: producer contract (every cinematic fallback returns degraded, never
error - 5 updated + retained), consumer contract (degraded rows still get
rate-ratio prediction and duration plans; error rows stay excluded), and the
retry (honors small Retry-After with jitter, caps absurd ones, one retry only,
non-429s never retry). Full suite: 2981 backend + 1236 frontend.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(changelog): correct PR ref to #1135

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(dub): review round — localize the degraded strings, un-suppress the mixed toast, clear stale annotations on edit

Three review findings, all valid:

- Localization parity (Greptile): the two new user-facing keys existed only in
  en.json. Every other key in these namespaces is translated in all 21
  locales, so the fallback-to-English behavior would have been a regression of
  the repo's parity convention. Both keys now translated in all 20 non-en
  locales, inserted beside their siblings.
- Mixed responses suppressed the degraded story (Greptile): when a translate
  returned both real failures and degraded rows, only the red failure toast
  fired. The degraded warning now fires alongside it — real failures don't
  erase what happened to the rows that succeeded plainly.
- Ordinary edits kept stale annotations (CodeRabbit): the restore path cleared
  translate_error/translate_degraded but a normal text edit didn't, so a row
  kept wearing "polish pass skipped" over words the user had just written.
  Editing the text now clears both annotations.

Frontend suite: 1236 passed; i18n probe green across all 21 locales.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: mergetest <nizam4103@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 16:13:41 +05:30
58c6f37252 perf(dub): single-use per-segment refs no longer evict the prompts a dub reuses; add docs/performance.md (#1132)
* perf(dub): single-use per-segment refs no longer evict the prompts a dub reuses; add docs/performance.md

The scan-resistance fix:

A dub cuts a distinct reference clip per segment (Wave 3.2 / #486 — each line
clones its own source delivery) and falls back to the per-speaker clone for
segments under 3 s. Both paths flow through the voice-clone prompt cache — an
LRU of 8. Streaming hundreds of one-shot per-segment clips through that LRU
evicts the per-speaker and locked-profile prompts that every fallback segment
reuses, so the speaker ref was re-encoded (~0.4 s each, measured with
scripts/bench_pipeline.py) again and again across the render.

Note what this deliberately does NOT do: the bench's "166 misses vs 2 speakers"
framing suggested keying refs per speaker — but per-segment refs are the
intentional prosody-matching feature, and the re-transcription behind them is
the #1004 correctness fix. Their encode cost is the price of the feature, not
waste. The waste was only the eviction side-effect, and that's what this
removes: _get_clone_prompt(store=False) still reads the cache (a hit is free)
but never inserts, and the dub loop marks exactly the segment-scoped refs
(auto-seg: bindings and auto: bindings resolved to a segment clip) as
single-use. Per-speaker, locked-profile, and preview refs cache as before.

cache_ref is popped in generate_with_cached_ref before the model call — the
model's generate() has an explicit signature and would TypeError — and unknown
engines ignore it (**kw adapters).

The doc:

docs/performance.md is the first performance documentation in the repo — none
of the ~15 perf env vars appeared anywhere in docs/, the Performance panel's
only control is Windows-only, and slowness reports (#1032) arrived as mysteries
instead of settings checks. Covers the three classic causes of "it got slow",
where generation/dub time goes, every knob with defaults and warnings (raising
OMNIVOICE_GPU_WORKERS on a small GPU is the #567 crash, not a speedup), platform
notes, and how to run the bench so reports carry numbers. Linked from README's
install section.

Tests: store=False semantics (encodes, never inserts, still reads), the flood
scenario end to end (a speaker prompt stays warm through 3x the cache cap of
one-shots), and the pop contract (cache_ref never reaches the model). Full
suite: 2974 passed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs,dub: review round — qualify the per-file cache claim; note the OOM-retry tradeoff

- CodeRabbit: docs/performance.md's "the reference encode is cached per file"
  now carves out the dub's per-line clips (single-use by design — nothing for
  a cache to save).
- Greptile P2 (OOM retry re-encodes a single-use ref): acknowledged in a code
  comment as deliberate — caching the retry's ref would reintroduce the
  eviction this flag prevents, to optimize a path that only runs after an OOM
  already cost seconds.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(performance): probe-based torch.compile wording; honest accelerator + cache claims (review)

Greptile's repeated OOM-retry finding is deliberately skipped: retaining the
prompt across the retry would require passing prompt objects through the
adapter protocol (backend.generate takes paths), to save 0.4s on a path that
only runs after an OOM already cost seconds — the tradeoff is documented at
the call site.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: mergetest <nizam4103@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 14:36:06 +05:30
3383ee9a94 fix(engines): an Install click during the mount status probe was silently dropped (#1131)
* fix(engines): an Install click during the mount status probe was silently dropped

refreshInstall serializes status requests per engine so a slow backend can't
land responses out of order (an old 'running' overwriting a newer 'succeeded'
would restart the poller forever). But the guard dropped ALL overlapping
callers, including the one that must never be dropped: the Install click's
first status refresh. If the click landed while the mount-time re-attach probe
still held the slot, the refresh returned null, the state kept the pre-install
'idle' snapshot, the 1.5s poller (which only watches 'running' jobs) never
started, and the progress panel never appeared — no error, no retry. The
backend install DID start; the UI just never showed it.

Fast machines win the race almost every time, which is why this surfaced as a
rare CI-only failure of "a failed job renders the error with its remediation
and offers Retry" (observed on #1130's run, a PR with zero frontend changes).

The inflight guard now maps id -> the in-flight promise; advisory callers (the
poller, the probe) still drop on overlap, but the click passes force: true and
waits the in-flight request out before fetching its own fresh snapshot —
strictly ordered, never dropped.

The regression test holds the mount probe open with a gated promise, clicks
during the window, and only then releases the probe — deterministic where the
CI flake was scheduler-luck. Fails before the fix (panel never renders, 3s
timeout), passes after. Full frontend suite: 1234 passed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(engines): bound the forced wait, serialize rapid clicks, reject stale responses by epoch

Round-2 review findings on #1131, both real:

- Greptile P1 "forced waiters break serialization": two rapid Install clicks
  waking from the SAME awaited probe both proceeded without re-checking the
  slot — two concurrent requests, out-of-order responses possible again. The
  forced wait is now a loop that re-checks the map after every await.
- Greptile P1 "install inherits probe stall": a wedged probe (no abort signal)
  made the forced click wait forever — trading "silently dropped" for
  "silently stuck". The wait is now bounded (FORCE_WAIT_TIMEOUT_MS, 5s), and a
  per-engine request EPOCH makes proceeding safe: a response may only be
  applied if no newer request started since it was issued, so the wedged
  request's eventual stale response is discarded instead of clobbering the
  fresh 'running' snapshot. The epoch is now the actual ordering guarantee;
  the inflight slot is just throttling.
- CodeQL js/missing-await on `=== req`: intentional promise-identity compare,
  restructured to compare a plain { promise } wrapper object so the alert
  class can't fire.

Both new tests fail against the round-1 fix (maxActive=2; panel never appears
after a 5s fake-timer advance) and pass now. Frontend suite: 1236 passed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(engines): clear the losing race leg's 5s timer (review)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: mergetest <nizam4103@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 14:22:11 +05:30
c613a65435 perf(tts,dub): the reference clip was re-encoded on every chunk; the dub loaded a 3 GB model to throw it away (#1130)
* perf(tts,dub): the reference clip was re-encoded on every chunk; the dub loaded a 3 GB model to throw it away

Two independent pieces of pure waste on the generate path, both measured with
scripts/bench_pipeline.py on a 16 GB M2 (a reference encode costs 0.40s).

1. The voice-clone prompt cache was built, then orphaned.

#427/#473 added a bounded LRU that encodes a reference clip once and reuses it,
because "every cloned generation re-encodes the reference audio from scratch".
It was wired into OmniVoiceBackend — the *adapter* path. But /generate for the
default engine forks to the *native* model path (that fork predates the cache,
#324) and passed ref_audio=<path> straight through, so the codec encoder re-ran
the reference on every model.generate() call: once per text chunk, once per
pause-span, once per audiobook segment, and once per request.

That perf PR has therefore only ever sped up /v1/audio/speech. The Generate
button never touched it.

Every native call site now goes through one helper (generate_with_cached_ref) so
the rule lives in a single place: chunked /generate, its streaming twin (#1088),
the [pause] stitcher (#276), and the audiobook renderer. Saving is
0.40s x (calls - 1): ~3.6s on a 10-chunk text, ~66s on a 166-segment audiobook.

Same-class bug found in the same cache: /v1/audio/speech accepts
preprocess_prompt, but the adapter dropped it before it reached the model AND
the cache key omitted it — so the flag was silently ignored, and honoring it
without keying on it would have served (and poisoned) the wrong prompt. Both
fixed together.

2. A dub loaded the TTS core just to free it again.

The transcribe preflight called get_model() — pulling in the ~3 GB TTS model —
for one reason: to read a preloaded `_asr_pipe` off it. That attribute only
exists under OMNIVOICE_PRELOAD_TTS_ASR, which is off by default. So every dub
loaded the model, harvested None, had offload_tts_for_asr() free it 60 lines
later (on unified memory that is a full UNLOAD, #1119), and then cold-reloaded
the same model in dub_generate (~8s). Load -> unload -> reload, for an attribute
that was always None. It now loads only when there is something to harvest.

Also fixes a latent NameError: asr_on_vocals was assigned only inside the
model-loaded branch but read from _gen_body, so an early preflight bail raised
NameError instead of the real error.

Tests: the existing cache tests passed the whole time the cache was dead, because
they test the cache in isolation with a stub model. The new tests assert the
wiring instead — that a real render encodes the reference ONCE regardless of how
many generate calls it takes. All four encode-count tests fail before this change
and pass after; the dub tests likewise.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(changelog): the reference re-encode and the dub's throwaway model load (#1130)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(memory): unloading the TTS model must drop its cached reference prompts too

Follow-on to the cache wiring in this PR, and a real gap it opened.

clear_clone_prompt_cache() was called from exactly one place:
OmniVoiceBackend.unload(). That was sufficient while the prompt cache was
adapter-only — but the native /generate path now populates it, and the native
path unloads through model_manager (idle_worker, _offload_unified_memory), not
through the adapter. So cached prompt tensors would have survived an unload.

That directly undercuts #1119: on unified memory offload_tts_for_asr() sets
model = None precisely to hand the RAM to the ASR model. Prompts left behind sit
in the memory the unload was trying to reclaim. The tensors are small (integer
codes, not waveforms), so this is hygiene rather than a leak — but "unload means
unload" is the whole point of that change, and the next thing cached here might
not be small.

model_manager.release_tts_side_caches() is now called wherever the global model
is dropped. Best-effort by construction: cache hygiene must never be able to
break an unload, because a failed unload is how the backend gets OOM-killed.

The test binds services.tts_backend at CALL time, not import time: several suites
purge sys.modules["services.*"] for DB isolation (test_model_load_timeout,
test_model_manager_preload), so a module-level alias goes stale mid-run and the
assertion would inspect a different module's cache than the code under test just
filled. Production already imports it at call time.

Full suite: 2968 passed, in both deterministic and random order.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(tts): keep the prompt cache best-effort, and stop the unload hook closing an import cycle

Three review findings, all real.

1. Greptile P1 — the shared helper dropped the inline fallback.

OmniVoiceBackend.generate() has always caught a failure from
generate(voice_clone_prompt=...) and retried with the inline ref, so the cache
stays a pure latency optimization. generate_with_cached_ref did not: a model that
rejected a precomputed prompt would have turned a working /generate, streaming
render, or audiobook job into a hard error. Moving the native path onto the cache
would then have made it LESS robust than before it was cached at all.

The helper now carries that fallback, and OmniVoiceBackend delegates to it
instead of keeping a second copy. Two subtly-diverging copies of this logic is
precisely how the cache ended up wired into the adapter and nowhere else; there
is now exactly one.

2. CodeQL — cyclic import.

release_tts_side_caches() imported services.tts_backend, which already imports
model_manager: a real cycle, not a false positive. A registration hook fixed the
cycle but replaced it with a worse problem — the hook runs at import time and
pulls model_manager (and core.config) in earlier than before, which perturbs
DATA_DIR binding and broke test_longform_jobs::test_route_handler_returns_jobs_envelope
in the full suite (passed in isolation, failed in order — caught locally, not in CI).

It now reaches the module through sys.modules instead: no import, no cycle, no
import-time side effect. And it is the more correct expression of the invariant
anyway — a module that was never imported has no cache to clear.

3. CodeRabbit — the audiobook and streaming call sites had no encode-count test.
Added one for the audiobook synth path (the worst case: hundreds of segments on
one voice).

New tests fail before their respective fixes: stripping the try/except from the
helper fails the prompt-rejection test.

Full suite: 2970 passed, deterministic and random order.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: mergetest <nizam4103@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 12:55:32 +05:30
bec916c348 perf(bench): a memory-safe profiler for the pipeline — so "make it faster" stops being a guess (#1129)
Every performance question this week ("can we batch by cores?", "why is dubbing
slow?") was answerable only by measuring, and twice the intuitive answer was wrong:

  * Concurrency on Apple Silicon buys NOTHING. Measured, 4 segments:
        1 worker  19.3s | 2 workers 20.7s (0.93x) | 3 workers 19.2s (1.00x)
    One GPU, already saturated — extra workers interleave. Scaling the GPU pool by
    free RAM (the "intelligent batching" that sounds obviously right) would have
    added OOM risk on a 16 GB box for zero throughput. _pick_gpu_workers()'s
    hardcoded `MPS -> 1` is correct, and now provably so.

  * The clone-prompt cache misses on every segment (a dub writes one reference per
    segment: 166 distinct keys, cache can never hit). That looked like the dub's
    hidden cost. It is 0.40s/segment — ~2% — and it is not even waste: each
    reference is genuinely different audio, and encoding it is the *feature*
    (per-line prosody). Dropping to per-speaker refs would save ~65s/dub and cost
    quality. Not a free win; not taken.

What actually dominates is TTS itself, which scales with text length (3.2s for a
short line, 8.7s for a 2.5x longer one) and is GPU-bound on a GPU that one
inference already fills.

The profiler is deliberately gentle with memory, because a profiler that OOMs the
machine reproduces the very bug class it exists to fix (#1119): stages run one at a
time, models are unloaded between them, a stage is SKIPPED if free RAM is under the
floor rather than starting a load the OS would kill, and each measurement is a fixed
small number of passes — no looping to convergence.

Co-authored-by: mergetest <nizam4103@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 05:28:14 +05:30
95b7c6f652 fix(asr): Apple Silicon dubbing ran Whisper on the CPU — the ~4x dub slowdown, the abandoned chunks, and the fictional ETA (#1128)
* fix(asr): Apple Silicon dubbing ran Whisper on the CPU — pick the engine by hardware

_auto_detect() probed WhisperX first, unconditionally, with no device check. WhisperX
is always installed, so it always won — and WhisperX (like faster-whisper) is CTranslate2,
which has NO Metal backend. On every Mac, dub transcription therefore ran whisper-large-v3
on the CPU while the GPU sat idle. The MPS branch below it was unreachable in practice.

Measured on an M2, one 30 s dub chunk of large-v3:

    WhisperX (CPU)            90.4 s   <- 3x SLOWER than realtime
    MLX (GPU)                 20.5 s
    MLX (GPU) + forced align  20.3 s   <- ~4.4x faster, identical word timings

That is a 16-minute video taking ~48 minutes and looking like a hang. It is also why the
slowest chunks exceeded OMNIVOICE_TRANSCRIBE_CHUNK_TIMEOUT_S (120 s) and were ABANDONED
after 2 attempts (chunks 33/34 in the reported run), losing transcript outright — while
#730's advice blamed a "VRAM-starved GPU" on a machine with no VRAM.

The engine pick is now device-aware: Apple Silicon gets MLX, everywhere else is unchanged
(on CUDA, WhisperX already uses the GPU and remains the default).

Crucially this does NOT buy speed with lip-sync accuracy. WhisperX's value is its wav2vec2
forced alignment (±10-30 ms word boundaries vs Whisper's ±100-300 ms), and dub lip-sync
depends on it. Alignment takes *plain segments*, so it is independent of whichever engine
produced them: it is extracted into forced_align() and layered on MLX's GPU transcript.
Verified — the boundaries agree with WhisperX's (multiple: 0.62 vs 0.71; different: 1.57 vs
1.55) and every word carries timing. Alignment prefers MPS (20.3 s vs 28.4 s, byte-identical
timings) and falls back to CPU rather than silently dropping to loose timestamps.

Also fixes the ETA, which was pure fiction: TranscribeOverlay estimated
`ceil(duration/60)*3 + 8` seconds — an assumption of ~20x-realtime transcription. For a
16-minute video it predicted 56 s against a real ~48 min, then clamped to "~0s remaining"
with the bar frozen at 95% for the rest of the job. The backend already streams a real
progress fraction (dub_core.py emits `progress` on every `segments` event) and the UI simply
ignored it. It now extrapolates from the observed rate, and shows nothing until it has a
rate to extrapolate from.

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

* style: oxfmt

Formatting only — no behaviour change. CI's format:check gate (not run locally
before the push) rejected the two new/edited dub files.

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

---------

Co-authored-by: mergetest <nizam4103@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 01:20:00 +05:30
42c0a0d1dd fix(analytics): the backend never received the token — its half was dead in every build (#1126)
core/analytics.py reads POSTHOG_PROJECT_TOKEN from its own environment at RUNTIME,
but the backend runs on the *user's* machine, where nothing sets it. So in a shipped
build token_configured() was false forever: every backend event — including the
speech_generated capture — was silently dropped, no matter what secret CI held. Only
the frontend half ever worked, and nothing would have told us.

The token is really a build input. release.yml already passes the POSTHOG_PROJECT_TOKEN
secret to the tauri-action step as VITE_POSTHOG_KEY, and that step compiles the Rust
shell as well as the frontend bundle — so option_env! bakes it into the shell on exactly
the builds that ship it, and spawn_backend() hands it to the child process.

The guarantees are unchanged and now pinned by tests:
  - no token baked in (every source build) => nothing passed => no destination => the
    backend cannot transmit, and the toggle isn't offered;
  - a real process env var still wins, so a dev can point a local run at their own project;
  - consent remains a separate gate (prefs, default off) — a destination alone sends nothing.

Hardened against silent recurrence, since this failure mode is invisible: build.rs gets
rerun-if-env-changed (option_env! is compile-time, so a cached build would otherwise keep
the token it first saw), and tests assert the whole chain — release.yml still passes the
secret, backend.rs still bakes it, build.rs still busts the cache.

Also fixes a CHANGELOG contradiction that would have shipped in the release notes: the
Usage-panel entry still claimed PostHog "was proposed and rejected ... there is no
analytics service, no token", directly under two entries announcing it.

Co-authored-by: mergetest <nizam4103@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 19:58:31 +05:30
2d9ebe350a feat(analytics): wire posthog-js — consent-gated, autocapture OFF (#1123)
* feat(analytics): wire posthog-js — consent-gated, autocapture OFF

The owner supplied the standard snippet:

    posthog.init(TOKEN, { api_host, defaults: '2026-05-30' })

Shipping that verbatim would have broken the guarantee we just made, twice:

1. It initialises AT MODULE LOAD — it starts tracking every user before they
   have consented to anything. The README now says "OmniVoice sends nothing out
   of the box"; this would have made that false on the very next release.
   Analytics is therefore started ONLY after the user opts in (Settings →
   Privacy), and the stored consent is what restores it at launch.

2. posthog-js AUTOCAPTURES by default, and `defaults: '2026-05-30'` turns that
   on. Autocapture sends the text content of the DOM elements a user interacts
   with. In THIS app the DOM holds the script they are about to synthesise,
   their voice names and their file names — exactly the content we promise never
   leaves the machine. It is explicitly disabled, along with session recording
   (which records the screen) and pageview capture.

utils/analytics.ts: hardenedConfig() — autocapture false, disable_session_recording
true, capture_pageview/pageleave false, mask_all_text + mask_all_element_attributes
as defence in depth, and opt_out_capturing_by_default so init alone can never
capture. Events pass sanitizeProps(), mirroring the backend allowlist: a key not
on it is DROPPED and long strings refused, so a future caller cannot leak content
by adding a field. Backend down / no consent / no destination → stays off.

The token is taken from VITE_POSTHOG_KEY at BUILD time and is never committed —
a token-shaped literal trips the secret scanner and is a bad habit regardless.
release.yml injects it from a repo secret; the backend already reads
POSTHOG_PROJECT_TOKEN the same way. No token => no destination => the Privacy
toggle isn't offered and nothing can be sent, which is the right default for a
source build. A test fails if a phc_ literal is ever committed to that file.

posthog-js added to frontend/package.json; root bun.lock regenerated and
`bun install --frozen-lockfile` verified (the Docker gate).

11 tests: autocapture/session-recording/pageview off, starts opted-out, allowlist
drops text+paths+names, long strings refused, consent honoured in all three
failure directions, and no token literal in source. Frontend suite 1229 passed.

* test(analytics): guard the committed-token rule in the suite, not just in the scanner

The frontend typecheck failed on the guard I added: it reached for `node:fs`,
which has no type definitions in the frontend tsconfig (and would have been
cwd-dependent at runtime anyway). Wrong layer.

Source-scanning guards in this repo are Python tests (test_no_hardcoded_cjk,
test_no_literal_borders), so this one moves there — and gets strictly stronger
in the process: it scans every tracked file rather than analytics.ts alone, and
matches a PostHog key by SHAPE (phc_[A-Za-z0-9]{20,}), so a *different* key
can't slip through where the old test only knew about the one gitleaks caught.

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

---------

Co-authored-by: mergetest <nizam4103@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 19:04:58 +05:30
Palash Debnathandmergetest e9477870c8 fix(memory): on unified memory, "offload" must mean UNLOAD — the 16 GB dub OOM (#1119) (#1122)
offload_tts_for_asr() exists to make room before WhisperX large-v3 (~3 GB) loads
for a dub. On CUDA it moves the TTS model to CPU. On Apple Silicon it did
NOTHING — an early return with the comment "MPS / CPU / DirectML don't benefit
from manual offloading".

That reasoning is right about the STRATEGY and wrong about the CONCLUSION. On
unified memory, moving a model "to CPU" frees nothing, because it is the same
physical RAM. But that means the fix is to RELEASE the model — not to skip
making room altogether.

Measured on a 16 GB M2, at the moment a dub begins:
    TTS model resident      3,107 MB
    backend footprint       4,170 MB
    free RAM                 4.17 GB
large-v3 then wants ~3 GB of that, alongside the app and macOS. The OS kills the
backend mid-transcription, and the stream "drops before emitting any segments".

On a unified-memory host the TTS model is now actually released when free RAM is
below a headroom threshold (default 6 GB, OMNIVOICE_UNIFIED_OFFLOAD_HEADROOM_GB),
and left warm when there's room — so a roomy machine pays no reload. get_model()
lazily reloads it on the next generation, so restore is correctly a no-op. The
CUDA path is untouched.

Verified end to end in a real process: model loaded → offload_tts_for_asr() →
`mm.model is None` and free RAM recovered. Previously it returned immediately and
freed nothing.

6 tests (releases when tight / stays warm when roomy / restore is a no-op /
no model is a no-op / a failing probe never aborts the dub / CUDA path unchanged).

This is a CAUSE, not another error-message fix.

Refs #1119 #1113

Co-authored-by: mergetest <nizam4103@gmail.com>
2026-07-12 18:33:36 +05:30
Palash Debnathandmergetest fd935ab699 fix(dub): stop losing the race for the crash marker — the stream-drop guess is back (#1119) (#1121)
Reported on v0.3.21, which ALREADY had the #1098 fix. The user still got
"Transcribe stream dropped… Likely ASR backend failed to load" — the guess that
fix was supposed to retire.

Why: streamDropError() consults the crash marker before falling back to the
guess, but it asked exactly ONCE, at the instant the stream dropped. The shell
learns of a dead backend from a ~2 s poll — it must notice the child exit and
write the marker. So the check raced that poll and lost: no marker yet ⇒ "no
crash" ⇒ fall back to the guess, even when the backend had just died.

That is precisely the race #1102 fixed for apiFetch. This path never got it — I
fixed the symptom in one place and left the identical bug in the other.

streamDropError now polls for the marker across a short window (8 s, 1 s apart)
before believing there was no crash, so a late-arriving marker is found and the
user gets the real cause — exit code + captured stderr, one click from the crash
notice — instead of a guess. Outside the Tauri shell there is no marker to wait
for, so it asks once and returns immediately (no 8 s stall for a browser/Docker
user). Injectable sleep/clock so the race is directly unit-testable.

3 new tests (a LATE marker is found, not missed / no marker ever still yields the
caller message / no shell asks exactly once). Frontend suite 1218 passed.

Fixes #1119

Co-authored-by: mergetest <nizam4103@gmail.com>
2026-07-12 18:19:37 +05:30
Palash Debnathandmergetest ea7a7b39b4 feat(privacy): opt-in analytics — hardened, off by default, enforced by code (#1120)
The rejected PR #1110 had a genuinely careful PII-free event design, but shipped
three things a local-first app can't: exception autocapture ON (raw tracebacks —
home paths, and in this codebase HF tokens out of exception messages — bypassing
core.failure.sanitize() entirely), no user consent or disclosure, and 3,069 lines
of PostHog wizard scaffolding. This is the same capability with those fixed.

core/analytics.py, three rules, each enforced and tested rather than promised:

1. OFF unless the user says yes. TWO gates must both be true: a build-provided
   POSTHOG_PROJECT_TOKEN *and* the user's analytics_enabled pref, default False.
   A default install transmits nothing, so "nothing leaves your machine" stays
   literally true for everyone who doesn't opt in. A broken prefs file fails
   CLOSED. OMNIVOICE_ANALYTICS_DISABLED=1 is a hard kill switch above both.
   Withdrawing consent tears the client down immediately — no restart.

2. NO exception autocapture. Explicitly disabled; a test asserts the constructor
   arg, because the SDK's default is the leak.

3. Metadata ONLY, by allowlist. Every property passes sanitize_properties(),
   which DROPS any key not on _ALLOWED_PROPS and refuses long strings — so no
   future caller can leak a take's text, a path, or a voice name by adding a
   field. text_length is the LENGTH; the text itself has no way through.

The person id is a random per-install UUID — not hardware, hostname, or username.

UI: Settings → Privacy → "Help improve OmniVoice" states in the panel exactly
what is sent, exactly what never is, and that it can be turned off — rather than
burying it in a policy. No destination in the build (any source build) → the
toggle isn't shown, because an inert switch would be a lie.

Docs: README FAQ answers "does OmniVoice collect any data about me?" honestly.

Also fixed a bug I'd introduced in my own wiring: the generation event referenced
variables not in scope, and the call site's bare `except: pass` swallowed the
NameError — so the event would have silently never fired. The call site now logs.

12 tests (default-off / opt-in without token still can't transmit / both gates /
kill switch / consent withdrawal / prefs failure fails closed / allowlist drops
text+paths+names / long strings refused / autocapture OFF / never raises /
random install id). Backend 2936 passed; frontend 1211 passed.

Refs #1110

Co-authored-by: mergetest <nizam4103@gmail.com>
2026-07-12 18:04:07 +05:30
Palash Debnathandmergetest 810b62e62d fix(api): an alive-but-unresponsive backend now says so, instead of "it stopped" (#1113) (#1117)
A v0.3.21 user hit "Can't reach the local OmniVoice backend — it may still be
starting up, or it stopped" — on the release that was supposed to end that class.

Reading the report tells us what happened WITHOUT reproducing it: they got the
generic message, not the crash story. On 0.3.21 apiFetch consults the crash
marker, and a real process death always writes one. No marker ⇒ the backend did
not die. And the shell was still reporting `ready` ⇒ the process was alive.

So both halves of that sentence were false: it had not stopped, and it was not
starting. It was ALIVE and not answering — a job wedged holding the engine
(troubleshooting §14: a generate/transcribe too heavy for the available memory
starves the worker). Telling that user to "restart the app" is the wrong advice
for a stuck job, and it buries the real cause.

When the reconcile window expires and the shell STILL says `ready`, we now know
the process is running, so say that: name the wedged-job cause, point at
Settings → Logs → Backend for what it was last doing, and at a smaller
model/engine as the usual fix. The genuine "stopped or starting" message stays
for the case where the shell has no idea (no shell — browser/Docker), and the
crash story still wins whenever a marker exists.

This does not claim to stop the wedge — it stops the app from lying about it,
and gives the next reporter the right words.

2 new tests; frontend suite 1213 passed.

Refs #1113

Co-authored-by: mergetest <nizam4103@gmail.com>
2026-07-12 17:49:19 +05:30
Palash Debnathandmergetest 07db3415fd fix(bootstrap): stop clobbering the real failure reason with "never started" (#1112) (#1116)
An Intel-Mac user got "Backend process exited (never started) — no error output
captured", and reported that Retry and Clean & Retry did nothing at all. Both
symptoms have one cause, and it destroys EVERY precise bootstrap diagnosis — not
just the Intel one.

ensure_venv_ready() diagnoses the real reason a start failed (Intel Macs can't
run the backend — PyTorch ships no macOS x86_64 wheels, #889; a failed uv sync;
a blocked GitHub) and records it via fail() as Failed{that reason}. It then
returns None, spawn_backend returns None, and spawn_backend_and_wait — seeing no
child — OVERWROTE the stage with the generic "Backend process exited (never
started) — no error output captured". The honest cause was written and
immediately bulldozed.

Which also explains the dead buttons: the UI's hint matcher keys off the
specific message text, so with it gone the Intel hint ("retrying can never
help") never fired. The user was offered a Retry that re-failed identically
every time, looking like the button did nothing.

- bootstrap.rs: already_diagnosed() — a caller that knows the CAUSE outranks one
  that only knows the SYMPTOM. When the stage is already Failed, the spawn
  watcher keeps it. A real exec failure still forms the generic message (it
  writes its diagnostic to backend_err.log and leaves the stage un-Failed), and
  a genuine post-start crash is untouched.
- BootstrapSplash: isUnrecoverableFailure() — an Intel Mac can never be retried
  into working, so don't offer the dead end; say so instead. Keyed off the same
  hint the matcher produces, so the two can't drift.

3 Rust tests + 2 frontend tests. Rust 81 passed; frontend 1213 passed.

Fixes #1112

Co-authored-by: mergetest <nizam4103@gmail.com>
2026-07-12 17:39:13 +05:30
1de41d57ee feat(firstrun): show the app version beside the app name on all three first-run screens (#1115)
The version was already in the Models & Engines masthead, but on Setup and
Installing it was buried in a footer as "OVS · v0.3.x" — the two screens a user
is most likely to screenshot when something goes wrong during install. Move it up
beside the app name on both, so all three acts of the first run (setup →
installing → models & engines) carry the same masthead and any screenshot
identifies the build at a glance. The footers keep their real content (the
download total on Setup); the duplicate version line is gone.

Frontend suite 1211 passed (incl. the css-token guard, which is what catches a
token that doesn't exist and silently renders nothing).

Co-authored-by: mergetest <nizam4103@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 17:29:38 +05:30
0e2c00a403 feat(privacy): Settings → Usage — local-only insights instead of cloud analytics (#1114)
* feat(privacy): Settings → Usage — local-only insights, the answer to cloud analytics

A PostHog integration was proposed and rejected (PR #1110, closed): sending
usage events to a third-party endpoint would break the one promise this product
is built on — nothing leaves your machine — and local-first is the reason people
choose it over ElevenLabs. But the question analytics was meant to answer ("how
am I using this?") is a fair one, so answer it locally.

services/local_stats.py aggregates the history the app has ALREADY written to
the user's own SQLite DB: takes, audio produced, compute time, starred, active
days, voices/dubs/projects/exports, and distributions by mode and language.
GET /stats/usage serves it over loopback; Settings → Usage renders it.

The three properties that stop this becoming telemetry by accident:
  - READ-ONLY. No new table, column, or event stream. Delete the feature and not
    one byte of stored data changes.
  - NO CONTENT. Counts and totals only — the `text` column of a take is never
    read and never returned; no paths, no ids, no person. Pinned by a test that
    asserts the payload contains no take text, no /Users/ path, no row id.
  - NO NETWORK. There is no client, no endpoint, no token. It has no way to send
    anything anywhere.
The panel states the guarantee in the UI, because a privacy promise the user
can't see isn't worth much.

Route added to the API-surface snapshot (the inventory guard caught it, as
designed — one line: GET /stats/usage).

4 backend tests (aggregation / never-leaks-content / empty install / missing
table degrades to 0) + 4 frontend tests. Backend suite 2924 passed; lint,
format, typecheck clean.

Closes the analytics question opened by #1110.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(settings): use the real --chrome-fg-dim token in UsageTab (css-token guard)

cssTokens.test.js is a frontend guard that every var(--…) a component
references actually exists — an undefined custom property with no fallback is an
invalid declaration, so the style silently does nothing. UsageTab referenced
--chrome-fg-subtle, which doesn't exist; the dim sub-label token is
--chrome-fg-dim (what the other settings panels use).

My miss: I ran the full BACKEND suite but only the two new frontend test files,
so this guard never ran locally. Full frontend suite now green (1211 passed).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: mergetest <nizam4103@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 17:15:10 +05:30
cbcea41fb6 feat(memory): honest /model/loaded accounting + a free-memory budget probe (#1111)
Two gaps the model-management investigation surfaced, now closed.

1. /model/loaded reported only the OmniVoice core, so a resident second engine
   (mlx-audio, cosyvoice, …) and the warm dictation ASR were INVISIBLE — the
   memory picture looked ~2 GB lighter than reality on exactly the boxes that
   OOM. list_loaded() now enumerates the in-process engine instances (from the
   generate path's cache) and the capture ASR singleton too, and adds a
   `system` block: free/total RAM (and free VRAM on a dedicated GPU) plus a
   low-memory advisory. Verified live: after an mlx-audio generate the panel
   shows `engine:mlx-audio` and `system: {ram_available_gb, ram_total_gb}`,
   where before it showed nothing.

2. services/memory_budget.py: available_memory() reads FREE memory now (device
   caps only reports total, once per process) — free system RAM via psutil,
   free VRAM via torch.cuda.mem_get_info on a dedicated GPU; on MPS the RAM
   figure is what matters (unified memory). low_memory_warning() returns an
   advisory below a headroom threshold (OMNIVOICE_LOW_MEMORY_HEADROOM_GB,
   default 2). The generate path calls log_if_low() before a load, so a later
   OOM kill leaves a breadcrumb pointing at the load that tipped it instead of
   a silent death.

Advisory only — nothing is blocked: the OS reclaims cache, and refusing a load
on an estimate would brick machines that would cope. The single-active-engine
eviction (#1105) is what actually reclaims room; this makes the picture honest
and leaves forensics.

6 new unit tests (threshold logic / VRAM-precedence / never-raises); frontend
LoadedModelsResponse typed for the new `system` field + id shapes. Backend
suite 2918 passed; typecheck clean.

Co-authored-by: mergetest <nizam4103@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 16:11:42 +05:30
Palash Debnathandmergetest 4e5d795832 fix(uninstall,storage): remove the saved-env leftover; count sidecar engines in disk usage (#1108)
Two recon findings from the reset work, fixed properly (whole class + tests +
docs), plus the destructive reset path is now exercised end-to-end.

1. ~/.config/omnivoice/env survived every uninstall. The app persists the
   model-cache location (and a possible HF_TOKEN) there via
   backend/core/user_env.py, but the in-app "Remove all data" (uninstall.rs),
   uninstall.sh, and uninstall.ps1 all walked past it — so a reinstall silently
   inherited the old file and redirected downloads to a maybe-deleted location.
   All three now remove it. It's the same expanduser("~/.config/omnivoice/env")
   path on every OS, so the Windows script uses %USERPROFILE%\.config\omnivoice.
   is_recognizably_ours accepts it (contains "omnivoice"); docs tables updated.

2. Disk usage measured the wrong engines dir. storage_report.default_engines_dir()
   returned backend/engines (built-in engine *modules*, no venvs), while sidecar
   installs live in DATA_DIR/engines/<id>. So a multi-GB IndexTTS-2 install was
   invisible in the engine-venv category and rolled into data/"other". Now points
   at DATA_DIR/engines and sizes the WHOLE install (venv + checkout + weights),
   with the data category claiming that subtree so it isn't double-counted.

Reset hardening: extracted purge_scopes() as a pure fs function (no AppHandle),
so the actual delete loop runs in tests against a real on-disk install tree —
"everything" wipes the install but spares the venv/foreign temp/sibling folders,
a settings reset keeps content+config+models, and a poisoned data_dir="$HOME"
deletes NOTHING. This is the live drive-through of the destructive path, minus
the GUI.

Also: gitignore the node_modules symlink form (the directory rule node_modules/
never matched a worktree symlink, so it kept slipping into commits).

Tests: Rust 78 (6 new), storage_report 20 (2 new incl. once-not-twice count +
default-dir guard), frontend 1207, i18n probe green, format+lint clean.

Co-authored-by: mergetest <nizam4103@gmail.com>
2026-07-12 15:39:07 +05:30
4d2fdacd33 release: freeze v0.3.21 — version bump, lockfiles, changelog (#1107)
package.json (single source of truth) + the three toolchain mirrors to 0.3.21;
Cargo.lock + uv.lock regenerated (version lines only). CHANGELOG [Unreleased]
renamed to [0.3.21] — 2026-07-12, "the memory release", sections merged into
house style (one Added, one Fixed).

Ships the 16 GB OOM class fixes end to end: idle-release the dictation ASR
(#1104) + one TTS engine resident at a time (#1105), plus the scoped
Settings → Storage reset/uninstall pair (#1089/#1099/#1100) and the release-
asset-split workflow fix (#1106) so this tag uploads to a single release.

test_app_version.py lockstep: 6 passed. bun install --frozen-lockfile: clean.

Co-authored-by: mergetest <nizam4103@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 15:24:07 +05:30
8750b91522 feat(memory): one TTS engine resident at a time — stop stacking models on 16 GB (#1105)
Measured on a 16 GB M2: a generate on omnivoice (~2.8 GB core) followed by a
generate on mlx-audio left BOTH resident (footprint 3.9 → 4.3 GB) because the
OmniVoice core lives in model_manager.model while every other engine lives in
engines._ENGINE_INSTANCES — two caches that never coordinated, and the latter
was never unloaded. That accumulation is a direct contributor to the memory
pressure behind the "Can't reach the local backend" OOM deaths.

- services/engine_memory.py: evict_other_tts_engines(keep_id) unloads every
  OTHER resident TTS engine before the incoming one loads — spans both stores
  (the OmniVoice core under its async lock, and the instance cache). No-op when
  nothing else is resident, so steady-state single-engine use pays nothing; only
  a real switch evicts. Default on; OMNIVOICE_SINGLE_ENGINE_RESIDENT=0 to keep
  several warm. Wired into the /generate path right after the engine resolves.
- TTSBackend.unload() (the ABC default) now actually frees the held model: it
  clears _MODEL_ATTRS (_model/_tts) and empties the device cache. Every
  in-process engine but OmniVoice previously inherited a NO-OP unload(), so an
  engine switch dropped the instance ref but left its model for GC with the GPU
  cache un-emptied. One change fixes all of them and is future-proof.
- FasterWhisperBackend.unload() cleared self._asr — an attribute it never
  assigns — so its model in self._model was never freed. Fixed.

Live-verified: omnivoice → mlx-audio now DROPS footprint 2300 → 1541 MB (core
evicted) instead of climbing to 4305 with both resident. 7 new unit tests
(eviction spans both stores / keeps the active engine / no-op when disabled /
a failing unload doesn't abort the sweep / ABC unload frees + is idempotent),
order-independent. Backend suite green.

Refs the 16 GB OOM class (#1076 #1092 #1093 #1101)

Co-authored-by: mergetest <nizam4103@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 15:05:11 +05:30
7705343386 fix(release): attach uninstall scripts with gh release upload, not a 2nd softprops publish (#1106)
v0.3.20 shipped with ONLY the Linux AppImage — the macOS dmg and Windows msi
were missing from the published release. Root cause: the uninstall-scripts job
(added in #1097) ran softprops/action-gh-release@v2 as a SECOND publish for the
tag, which raced tauri-action's per-matrix draft and split the platform
installers across two releases (a draft holding mac/windows, a published one
holding linux + checksums + the scripts). The updater manifest split too — each
release's latest.json covered only its half of the platforms.

Fix: attach the scripts with `gh release upload <tag> … --clobber` (which adds
assets to the EXISTING release and can never create a second one) instead of
softprops. `needs: [build]` guarantees the release exists first; --clobber keeps
a re-run idempotent.

(v0.3.20 itself was already repaired by hand — the mac/windows bundles were
re-attached from the draft, the latest.json manifests merged into one covering
all 8 platform keys, and the stray draft deleted. This prevents recurrence.)

Co-authored-by: mergetest <nizam4103@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 15:04:31 +05:30
94093605eb feat(settings): factory reset gets scopes — preferences, settings, assets, everything (#1100)
* feat(settings): factory reset gets scopes — preferences, settings, assets, everything

Factory reset did exactly one thing: clear localStorage. The only other option
was "Remove all data", which deletes the Python env and quits. Between "forget
my theme" and "wipe the machine" sat every reset a user actually needs — drop a
corrupt model download, remove a wedged sidecar engine, put the settings back
without losing a single voice — and none of them existed.

Settings → Storage → "Reset & remove" now offers four tiers (UI preferences /
all settings / downloaded assets & models / everything OmniVoice did) plus a
per-scope checklist. Every scope shows its real on-disk size, and the number on
the confirm button is exactly what gets freed.

Why the shell and not the backend: a loaded model memory-maps its weights out of
the HF cache (locked on Windows while mapped), and ensure_dirs() runs at import,
so a backend cannot delete voices/ or outputs/ and still write to them. reset.rs
stops the backend, deletes, and starts it again — and that restart is also the
repair: the fresh process re-runs ensure_dirs() and alembic, so a removed
database comes back empty rather than missing. retry_bootstrap's respawn path is
extracted to bootstrap::respawn_backend so both callers share one implementation.

Deliberate scope choices:
- "Everything" stops short of the managed Python env, so a reset hands back a
  working app on the first-run screen. The env is the uninstaller's business.
- A settings reset keeps the storage locations (config.json, the user env file).
  Clearing the model-cache pointer would strand gigabytes at a path the app no
  longer looks in — install shape is not a preference.
- content deletes the DB with the media: rows without files is how you get a
  library full of broken entries.
- The shared HF cache is flagged as shared only when it IS — computed, so Windows
  and portable installs (app-private cache) get no caveat they don't need.

Safety: nothing is removed unless it sits inside a validated root — one carrying
an OmniVoice-owned path component OR holding an OmniVoice signature file, which
is what lets a custom data dir on an external volume be cleared while a mis-set
data_dir: "/" is refused. Voices/projects/audio need the word typed.

9 Rust tests (guard, scope composition, shared-cache computation) + 14 frontend
(planning purity, typed confirm, disk-vs-frontend split, shared warning).
Border utilities follow the design guard (tests/test_no_literal_borders.py).

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

* feat(settings): give the Storage panels a design — proportional sizes, live totals, real tokens

"Remove all data" listed four folders as a flat run of text: a 7.5 GB model cache
and a 391-byte config file rendered at identical visual weight, so the one thing
worth seeing — where the space actually went — was the one thing you couldn't.
And the 391 B folder said "0 KB", which reads as "nothing here".

- New shared StorageTargetRow, used by BOTH destructive panels so they read as
  one system: icon, label, dimmed path (truncated, full text on hover), size, and
  a proportional bar showing that row's share of what will be freed. Unticked
  rows claim none of the bar — the bars must sum to what the button promises.
- The shared HF cache moves OUT of the confirm dialog into its own "Optional"
  row with the checkbox and the caveat in the list. Ticking it now moves the
  running total in front of the user, instead of springing a different number on
  them at the point of no return. The dialog lists exactly what is going.
- One byte formatter for both panels (settings/bytes.js). models/format.fmtBytes
  floors at kilobytes, hence "0 KB"; it stays where it is for the model store.

Real fix underneath: three of the tokens these panels styled with DO NOT EXIST
(--chrome-fg-subtle, --chrome-bg-raised, --color-warning). An undefined var()
makes the declaration invalid, the browser drops it, and the element silently
inherits — which is why the paths that were meant to recede rendered at full body
weight. That is a whole class of bug that fails invisibly, so it gets a guard:
src/test/cssTokens.test.js fails on any var(--token) in JSX not defined in a
stylesheet, with runtime-injected tokens (Radix, inline-style hues) allowlisted
by reason. Six pre-existing offenders elsewhere in the app are recorded as
known-broken and ratcheted so the list can only shrink — they are real bugs, but
each is a visual change that wants its own review.

Frontend suite 1196 → 1205 (6 UninstallPanel component tests incl. the live
total and the bar proportions; 3 token-guard tests, verified fail-before).

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

* fix: green CI + finish the token sweep + snapshot the panels

Three things on top of the redesign:

1. CI was red on tests/probe/test_probe_i18n.py — removing the eight dead
   `factory_reset_*` keys from en.json orphaned them in all 20 other locales
   (the probe forbids a non-en key absent from en). Removed them everywhere.
   This guard scans locales at pytest time; a frontend-only run never sees it.

2. Finished the undefined-token sweep instead of grandfathering it. Six bare
   `var(--token)` references resolved to nothing; the only genuinely undefined,
   fallback-less one in shipping panels was `--chrome-input-bg` (input fields
   AND progress-bar tracks AND skeletons across StoragePanel, StorageUsagePanel,
   HistoryRetentionPanel, ModelStoreTab — tracks were rendering with no
   background at all). Repointed to --chrome-hover-bg. The rest
   (--chrome-menu-bg, --chrome-bg-inset, --border, --input-bg, --muted) already
   carry `var(--x, fallback)`, which is valid CSS. So cssTokens.test.js now
   checks only the BARE form and ships with zero exceptions — no known-broken
   ratchet, because there is nothing left broken.

3. Registered both Storage panels in the visual-regression harness (a Tauri
   `invoke` stub added to providers.jsx alongside the existing fetch stub) and
   committed baselines across all three themes. This is how I actually looked at
   the redesign: the bars render proportional (the 720 KB voices row fills, the
   391 B row is a sliver), the shared-cache row sits in its own Optional group,
   and every token now resolves in default/midnight/catppuccin. `_forceAdvanced`
   on ResetPanel opens the checklist for the snapshot; no effect on the toggle.

Full backend suite 2897 passed (incl. the i18n probe). Frontend 1205.

* style: oxfmt the new panels and specs

Format-check is a CI gate (oxfmt --check); the new files weren't run through
oxfmt --write. No behavior change.

* chore: stop tracking the node_modules symlink

A worktree-local symlink slipped past .gitignore (which lists node_modules/ —
the directory form — so it never matched the symlink file). Removed from the
index; the symlink stays on disk for local test runs.

---------

Co-authored-by: mergetest <nizam4103@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 15:00:13 +05:30
7e03b84d3b fix(memory): idle-release the dictation ASR — the real cause of the 16 GB OOM deaths (#1104)
Four "Can't reach the local OmniVoice backend" reports (#1076/#1092/#1093/#1101)
all died at the same moment: during a generate, on a 16 GB machine. Measuring it
(phys_footprint, not RSS — RSS badly undercounts MPS unified memory) showed the
generate was never the problem: it costs ~116 MB. The problem is the BASELINE —
the backend sits at ~6.2 GB *idle*: TTS 3.8 GB plus ~2 GB of warm capture ASR.

The TTS model has always been idle-unloaded (model_manager.idle_worker). The
capture/dictation ASR singleton never was — one dictation warmed it and it
stayed resident for the life of the process. So the app dutifully freed 3.8 GB
of TTS while silently holding 2 GB of ASR forever, and on a 16 GB Mac that
baseline plus the app plus macOS is enough for the OS to kill the backend
mid-generate. That death surfaces as the "can't reach the backend" error — the
class #1102 made honest and this fixes at the source.

- asr_backend.release_idle_capture_backend(idle_s): unloads the warm capture
  singleton once it's been unused that long; no-op under a live lease, when
  nothing's loaded, or when recently used; never raises (idle_worker calls it
  on a loop).
- capture_lease(): pins the singleton for a live dictation session's whole life
  (the stream holds the backend without re-resolving it, so the reaper must not
  unload the model mid-sentence); wrapped around both sherpa handlers in
  capture_ws. Releasing restarts the idle clock.
- Both capture getters stamp _touch_capture() so any handout resets the clock.
- idle_worker runs the reaper each tick with the same idle timeout the TTS model
  uses, then free_vram().

Cost: a ~1.4 s model re-warm on the next dictation after a full idle timeout —
the same bargain the TTS model already makes. 8 new unit tests (releases when
idle / never while leased / never when recently used / lease released on raise /
nested refcount / failing unload still drops the ref / no-op when empty).
Backend suite 2905 passed; verified live against the running backend.

Fixes the crash class behind #1076 #1092 #1093 #1101

Co-authored-by: mergetest <nizam4103@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 14:38:14 +05:30
b0c692d26c release: freeze v0.3.20 — version bump, lockfiles, changelog (#1103)
package.json (single source of truth) + the three toolchain mirrors to 0.3.20;
Cargo.lock + uv.lock regenerated (version lines only). CHANGELOG [Unreleased]
renamed to [0.3.20] — 2026-07-12 with the release headline, sections merged into
house style (one Added, one Fixed).

Ships the #1101 stale-"ready" race fix (0.3.19 users are hitting it today), the
in-app uninstaller (#1099), and the Linux/Windows backend-log-dir fix.

test_app_version.py lockstep: 6 passed. bun install --frozen-lockfile: clean.

Co-authored-by: mergetest <nizam4103@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 14:12:47 +05:30
662339b50a fix(api): don't believe a stale "ready" — close the #1094 race that #1101 hit (#1102)
A 0.3.19 user still got "Can't reach the local OmniVoice backend" (#1101), on
the very release that was supposed to end that class. The fix had a hole.

apiFetch asks the shell whether a start/restart is in progress before erroring.
But the shell's stage is a 2-SECOND POLL, not a live probe: when the backend dies
mid-generate, supervise_backend needs up to ~2 s to notice the child exit, write
the crash marker, and set_stage(StartingBackend). apiFetch asked exactly ONCE, at
the end of the ~2.9 s transport cascade — so it very often still read `Ready` and
fell straight through to the generic toast. Worse, the crash marker usually
wasn't written yet either, so even the honest crash story (#941) was missed and
the user got the vague message.

The bug was trusting `ready` as authoritative. A transport failure CONTRADICTS
it: if the backend were reachable, the fetch would have succeeded. So `ready` is
now treated as a STALE belief — we keep retrying across a bounded reconciliation
window (12 s), re-asking each time, which lets the supervisor catch up and flip
to `starting` (→ the long wait + the restarting banner) and gives the crash
marker time to land so the error can name the real cause. `failed` (the shell
gave up) and `unknown` (no shell — browser/Docker) still error immediately, so a
genuinely dead backend is as prompt as before.

The reporter's trace is the signature: generate:start (design) →
generate:stream-fallback → the toast, on a 16 GB M1/MPS box — i.e. the backend
process died under memory pressure during generation, which is the underlying
crash this now surfaces honestly instead of guessing at.

Regression test reproduces it against the shipped 0.3.19 logic (fails) and passes
on the fix. Frontend 1184 passed; backend 2897 passed.

Refs #1101

Co-authored-by: mergetest <nizam4103@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 13:57:10 +05:30
0421be966e feat(settings): in-app uninstall — Settings → Storage → Remove all data (#1089) (#1099)
* feat(settings): in-app uninstall — Settings → Storage → "Remove all data"

The v0.3.19 uninstaller was a SCRIPT, which never reaches the people who need
it: anyone who installed the .dmg / .msi / AppImage has no repo to run
scripts/uninstall.sh from — exactly the reporter in #1089, an AppImage user.
"Where is uninstall in the app?" had no answer. Now it does.

New Tauri commands (uninstall.rs):
- uninstall_scan  — every folder this install owns, with real sizes, resolved
  through the same setup.rs helpers the app itself uses, so custom + portable
  locations are cleaned instead of the defaults being assumed.
- uninstall_purge — stops the backend (marking the kill intentional so the #567
  supervisor doesn't respawn one into the directories being deleted), removes
  the folders, and lets the UI quit the app: the Python env it runs on is gone,
  so there is nothing to return to.

This lives in the Rust shell, not the backend, because the biggest thing to
remove is the managed Python environment and the backend is RUNNING FROM IT — a
process can't delete its own interpreter (and Windows locks the files).

Safety: every path must pass is_recognizably_ours() before any remove_dir_all —
absolute, not `/` or $HOME, and carrying an OmniVoice-owned component (unit
tested both ways). The shared Hugging Face cache is reported separately and is
OPT-IN behind its own checkbox with the caveat spelled out: it's the standard HF
cache other ML tools share, so sweeping it up silently would delete models this
app never downloaded. Deleting voices/projects is irreversible, so the confirm
requires TYPING the word, not just a click.

Also fixes a real bug in what shipped in v0.3.19: the scripts and docs missed
where the BACKEND writes its logs — ~/.local/state/OmniVoice on Linux and
%LOCALAPPDATA%\OmniVoice\Logs on Windows (backend_log_path(), backend.rs) — so
every Linux/Windows uninstall left a stray log dir behind. Covered now in the
scripts, the docs, and the in-app scan.

And the scripts now ship as release assets, so cleanup is possible without
launching the app at all.

Rust: 2 new guard tests. Frontend: 6 new tests (the size on the confirm button
must equal what actually gets deleted); suite 1182 passed. Docs synced.

Refs #1089

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(settings): drop token border utilities from UninstallPanel (design guard)

tests/test_no_literal_borders.py::test_no_token_border_utilities_in_jsx is a
backend guard that scans JSX — so a frontend-only test run misses it. It forbids
`border-[var(--chrome-border)]` structural utilities: the app-wide border removal
converted every panel/row frame away from them, and they render a stray hairline
the moment the token doesn't resolve transparent.

Row dividers → spacing + an alternating `--chrome-hover-bg` tint; the opt-in
checkbox card → a background tint; the confirm input → the sanctioned arbitrary
`[border:1px_solid_var(--chrome-border)]` property form the other settings inputs
already use (explicitly not flagged by the guard).

Guard green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: mergetest <nizam4103@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 13:35:17 +05:30
174 changed files with 11000 additions and 623 deletions
+11 -1
View File
@@ -135,7 +135,8 @@ jobs:
# Catches platform-specific Rust regressions on PR (cfg(target_os=...)
# gates, missing Windows/macOS deps, etc.) without spending the 15+ min
# per-platform that a full `tauri build` takes. `cargo check` is the
# lightest gate that exercises type-checking + linking for each target.
# lightest gate that exercises type-checking + linking for each target,
# and `cargo test --lib` runs the shell's unit tests natively on each OS.
# Full bundling stays in release.yml on tag push.
tauri-cross-platform:
name: Tauri shell check (${{ matrix.label }})
@@ -211,6 +212,15 @@ jobs:
working-directory: frontend/src-tauri
run: cargo check --target ${{ matrix.rust_target }} --message-format=short
# `cargo check` never compiles #[cfg(test)] code, so without this the
# shell's unit tests (crash.rs, reset.rs, commands.rs, …) neither build
# nor run anywhere in CI. --lib scopes it to the unit tests; each
# matrix target equals its host triple, so the test binary runs
# natively. Codegen is warmed by the rust-cache above.
- name: Cargo test (Tauri shell unit tests)
working-directory: frontend/src-tauri
run: cargo test --lib --target ${{ matrix.rust_target }} --message-format=short
# ── Cross-platform Python runtime smoke (Phase 0 GATE-02) ───────────────
# Loads the frozen tests/fixtures/omnivoice_data/ fixture and boots the
# FastAPI app in-process via TestClient on macOS/Windows/Linux. Catches
+34
View File
@@ -510,6 +510,12 @@ jobs:
uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Analytics destination, injected at BUILD time (never committed — a
# token-shaped literal in the repo trips the secret scanner, and the
# frontend bundle is where a publishable client key belongs). Absent =>
# the build has no destination, the Privacy toggle isn't offered, and
# nothing can be sent. Analytics still requires the user to opt in.
VITE_POSTHOG_KEY: ${{ secrets.POSTHOG_PROJECT_TOKEN }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
# macOS Apple signing (#134 / #72) is configured by the preceding
@@ -718,6 +724,34 @@ jobs:
files: ${{ steps.checksums.outputs.checksums_file }}
fail_on_unmatched_files: true
# ── Uninstall scripts as release assets (#1089) ───────────────────────────
# The in-app uninstaller (Settings → Storage → Remove all data) is the primary
# path, but a user who wants to clean up WITHOUT launching the app — or after
# already deleting it — has no repo to run scripts/uninstall.sh from. Ship the
# two scripts alongside the installers so they're one download away.
#
# MUST use `gh release upload` (attach to the EXISTING release), NOT
# softprops/action-gh-release: a second softprops publish races tauri-action's
# per-matrix draft and splits the platform installers across two releases for
# the tag (v0.3.20 shipped with only the Linux AppImage that way). `needs:
# [build]` guarantees the release already exists; `--clobber` makes a re-run
# idempotent. This can never create a second release.
uninstall-scripts:
needs: [build]
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-22.04
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- name: Attach uninstall scripts to the existing release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh release upload "${{ github.ref_name }}" \
scripts/uninstall.sh scripts/uninstall.ps1 \
--clobber --repo "${{ github.repository }}"
# ── Auto-generated preview release notes ──────────────────────────────────
# tauri-action publishes the rolling `preview` release with the plain
# changelog-fallback body ("Auto-generated release for main…"). Replace it
+1
View File
@@ -18,6 +18,7 @@ build/
# Node / Turborepo / Tauri
# ─────────────────────────────────────────────────────────────────────────
node_modules/
node_modules
.turbo/
bun.lockb
frontend/src-tauri/target/
+104
View File
@@ -6,6 +6,110 @@ The format is loosely based on [Keep a Changelog](https://keepachangelog.com/).
Versions track the desktop app (`tauri.conf.json` + `frontend/src-tauri/Cargo.toml`).
The bundled TTS model package (`pyproject.toml`) is versioned independently.
## [0.3.22] — 2026-07-14
The dubbing release. Dubbed videos stop sounding like a compromise: the music keeps its stereo width and full frequency range, short lines no longer leave dead air while the mouth keeps moving, one speaker stays one voice, and the language tabs finally switch the transcript with the audio. Underneath it, the memory fixes that ended the "can't reach the local backend" era on 16 GB machines ship at last — plus a sweep of never-again hardening drawn from an audit of every bug this project has ever closed.
### Added
- **A "Voice match" toggle for dubbing — keep one steady voice per speaker.** Each dubbed line clones from a snippet of its own original audio, which matches the delivery beautifully but can make the *voice itself* drift from line to line — most audibly on videos where speaker detection ran in fallback mode ("still 4 segments different in voice", as one report put it). A new control next to the Timing picker chooses: **Per line** (the default, unchanged) for the best per-line delivery match, or **Consistent** to clone every line of a speaker from one shared reference — the speaker's pooled sample, or the best single clip when none exists — for a steady identity across the whole dub. Flipping it honestly marks segments as needing regeneration, and the shared reference is encoded once and reused, not re-studied per line. (#1147)
- **A performance guide, at last.** [docs/performance.md](docs/performance.md) explains where generation and dubbing time actually goes, the three classic causes of "it got slow" (an empty Transcript field on a voice profile chief among them), every tuning knob the backend reads — none of which were documented anywhere — and which settings to leave alone (raising `OMNIVOICE_GPU_WORKERS` on a small GPU is how you get the crash the default exists to prevent). Includes how to run the built-in profiler so a slowness report can carry numbers instead of vibes.
- **In-app analytics is now wired end to end — and still off until you say yes.** The frontend analytics SDK is only ever started *after* you opt in (Settings → Privacy), never at app launch, so a default install still transmits nothing. Two of the SDK's defaults are explicitly disabled because they would be actively harmful here: **autocapture**, which sends the text content of whatever you click — in this app, the script you are about to synthesise, your voice names, your file names — and **session recording**, which records the screen. Events carry metadata only, filtered through the same allowlist as the backend, so no future change can leak your content by adding a field.
- **Opt-in analytics — off by default, and it can't lie to you.** OmniVoice still sends **nothing** out of the box: no accounts, no telemetry, no phone-home, and your text, audio, voices, and projects never leave your machine regardless of what you choose. There is now one toggle in **Settings → Privacy → "Help improve OmniVoice"**, **off unless you turn it on**. If you do, it sends anonymous usage stats — which engine and language you used, how long a generation took, how many *characters* the text had (a number, not the text), and the *type* of any error. It never sends the text you type, your audio, your file names, your voice names, or anything identifying you. That isn't a promise in a policy: an **allowlist in the code** drops any property that isn't on it, so a future change can't leak content by accident, and crash tracebacks are deliberately **not** auto-captured (they can carry file paths and tokens). Turning it off stops everything immediately. Builds from source have no analytics destination at all and don't even show the toggle.
- **Settings → Usage: see what you've made, counted entirely on your own machine.** Takes generated, audio produced, voices, days used, and a breakdown by mode and language — all computed from the history already in your own database. It collects nothing new, stores nothing new, and transmits nothing anywhere, no matter what you've chosen under Settings → Privacy: this panel is *yours*, it works with analytics switched off, and it never phones home. If you want to know what you've been making, the answer shouldn't require sending it to anyone.
- **The memory panel now tells the whole truth.** `Settings → Models` (and `GET /model/loaded`) used to report only the OmniVoice core model — a resident second engine like MLX-Audio, or the warm dictation model, was invisible, so the memory picture looked ~2 GB lighter than reality. It now lists every resident model (in-process engines and the dictation ASR included) and adds a system block with free/total RAM (and free VRAM on a dedicated GPU) plus a low-memory warning. On top of that, a load that starts while memory is already low leaves a breadcrumb in the backend log, so a subsequent out-of-memory kill points at the load that tipped it instead of dying silently. Advisory only — nothing is blocked (the OS can reclaim memory, and refusing a load on an estimate would brick machines that would actually cope). Tune the threshold with `OMNIVOICE_LOW_MEMORY_HEADROOM_GB` (default 2).
### Fixed
- **Switching preview languages can't leave a mixed-language transcript.** Follow-up to the tab/transcript sync: if a track's translations were only partially stored in the browser (older projects, partial regenerations), switching tabs could show German audio with a few rows still in the previous language. Missing rows now hydrate from the app's own per-language store on the backend — and a picked regional dialect is automatically cleared when you switch to a language it doesn't belong to, wherever the switch comes from. (#1149)
- **The Export step's language tabs now switch the transcript too.** Clicking Bengali/German/Hindi… above the finished dub swapped the *video* but left the segment list showing whichever language you generated last — German audio over Bengali text. The tabs now also swap every segment's text to that language (through the same per-language store the language picker uses, so nothing is lost when you switch back); the Original tab keeps your editing language as-is, since each row already shows the original line beneath its translation. (#1148)
- **A "backend crashed" notice can no longer outlive the update that fixed the crash — and the desktop shell's self-repair paths are now pinned by tests that CI actually runs.** Crash notices now record which app version wrote them, and a notice left behind by an older version is ignored and cleaned up after you upgrade instead of resurfacing as if the new build had crashed. The Windows blank-window repair (the one-click WebView cache fix after a BSOD) also gets regression tests pinning its safety contract — one attempt per request, never touches anything unasked, never blocks startup on a locked cache — and CI now runs the desktop shell's entire Rust unit-test suite on macOS, Windows, and Linux, which it previously never executed at all. (#1145)
- **The MLX-Audio phonemizer's language model now ships with the app environment instead of being fetched mid-generation.** Follow-up to the pip fix: with the installer present, the first English MLX-Audio generation would auto-download a small model straight from GitHub — an outbound request that bypasses the app's mirror system (a problem on restricted networks) and fails offline. The model is now a pinned dependency of the managed environment: it arrives at install/update time through the normal dependency flow, and first generation works fully offline. (#1146)
- **The MLX-Audio engine's first English generation no longer trips over a missing installer.** Its phonemizer auto-downloads a small language model on first use by shelling out to `pip` — which the app's managed Python environment didn't include, so the download always failed (and before the recent containment fix, took the whole backend down with it, #1133). `pip` now ships as a real dependency of the managed environment, so it survives app updates too — anything installed ad-hoc would have been stripped by the updater's environment sync, quietly re-breaking this after every release. (#1144)
- **A voice engine's helper library can no longer shut down the whole backend.** One user's backend died 21 seconds after starting (#1133): the MLX-Audio engine's phonemizer tries to auto-download a language model on first use, the downloader is written as a command-line tool, and on failure it calls "exit the program" — which, running inside the backend, exited *the backend*. Any engine dependency written that way could do this. Exits are now contained at the engine-dispatch boundary and turned into a normal, explained error ("an engine dependency failed to auto-install something — see the log"), for TTS and transcription alike. The app keeps running; the failed request tells you what actually happened. (#1143)
- **Vietnamese years read like Vietnamese again.** A recent release started spelling out numbers before synthesis, and its Vietnamese number library turns out to be wrong for exactly the numbers people say most — years ("2024" became *"hai nghìn lẻ hai mươi bốn"*, which no Vietnamese speaker says). The voice model has always pronounced Vietnamese digits correctly on its own, so Vietnamese text now keeps its digits — the same conservative rule that already protected Vietnamese decimals. Also closes the loophole that made this depend on spelling: picking "Vietnamese" from the language list behaved differently from the code "vi". (#1139)
- **A voice profile's pinned seed now pins Audiobook renders too.** Locking a take (or a designed voice) stores a seed so the voice performs reproducibly — and the Voice page honors it, but Audiobook/Stories renders quietly ignored it and rolled fresh randomness for every segment. Book renders with a pinned-seed profile are now deterministic end to end, matching the Voice page. And the audiobook renderer's higher generation quality (32 decoding steps — the model's own quality preset, vs. the Voice page's fast default of 16) is now pinned explicitly in code rather than inherited by accident, so it can't silently change; that steps gap is also *why* Audiobook sounds steadier than Voice at default settings — move the Voice page's Steps slider to 32 for the same quality. (#1139)
- **A finished audiobook's Download button stops vanishing.** The player and Download link for a completed book lived only in the page's temporary state — switch tabs once and they were gone, which read as "no way to export at all" (the file was still on disk, and in Projects → Audiobooks). The last finished render now survives tab switches and reloads, right where the book was made. (#1139)
- **Six recurrence guards from a full audit of the project's issue history — aimed at "this bug can never come back, even after an update or reinstall."** (1) Before loading the voice model on a memory-tight machine, the app now *first releases* things it already reclaims on idle (the warm dictation model, allocator caches) — the missing half of the 16 GB OOM-kill fix; roomy machines pay nothing. (2) When the operating system force-kills the backend for running out of RAM, the crash notice now says exactly that instead of blaming "VRAM" on machines that have none. (3) Saving a *cloned* voice with free-form text in its delivery field can no longer persist a profile that errors on every future generation — the server now sanitizes all profile kinds, closing a hole that had been re-exploited three times through different clients. (4) A reinstall that inherits an old settings file pointing at an unplugged drive or deleted folder no longer sends downloads into the void — dead paths are ignored for the run with a clear log line. (5) Locally-saved UI state is now schema-checked as a whole on restore, so one corrupted field can't silently discard everything after it (the general form of the "app got empty" fix). (6) File moves across drives (Windows D:-drive installs) get a dedicated safe-move helper, so the next code path that renames across devices degrades gracefully instead of failing with `[Errno 18]`. Long texts also get a generation time budget that scales with their length instead of a fixed five minutes. (#1141)
- **Dubbed videos get their stereo back — and the music's full frequency range.** A/B-measuring a dub against its original showed the dubbed audio was **mono in a stereo container** (channel correlation 1.000 vs the original's 0.754) — the entire stereo image of the music, gone. Two causes, both fixed: the separation step was being fed the **16 kHz mono** file extracted for transcription — so the music bed inherited mono *and* an 8 kHz ceiling at the source — and the mixer then let the mono voice drag the whole mix down to mono. Ingest now makes a second, full-quality stereo extraction (44.1 kHz) just for separation, transcription keeps its mono file, and the mixer pins both sides to stereo with the voice dead-center where dubbed dialogue belongs. Loudness already matched the original (17.2 vs 17.8 LUFS, measured); now the width and brightness do too. (#1138)
- **Dubbed lines that finish early no longer leave dead air — they now speak at the pace of the scene.** Translations routinely come out shorter than the original delivery, and the dub used to just stop early: measured on a real dub, **8.8 of 18.7 seconds of speech time had no voice at all** — the mouth kept moving on screen over the thin residue the vocal separation leaves behind, which reads as silence and as "the music got quiet". Short lines are now gently slowed toward their time slot (pitch preserved, never below 0.85× — comfortably natural), so speech covers the speaking time the way the original did. This also does most of the work people expect from "lip sync": the voice now starts *and ends* with the mouth. Near-full lines are left untouched, the per-segment badge shows the applied rate, and `OMNIVOICE_UNDERRUN_MIN_RATE=1.0` turns the fill off. (#1137)
- **The dub's background music no longer comes out quiet and muffled.** Every dub export mixes your synthesized voice over the video's separated music/ambience bed — and that mix had two fidelity bugs stacked on top of each other. The mixer *normalizes* its inputs, so the weights meant to gently favor dialogue actually played the music at **~57% of its original level** (measured); and because the voice track is synthesized at 24 kHz, the mixer silently pulled the 44.1 kHz music down to 24 kHz — deleting everything above 12 kHz: cymbals, brightness, air. The batch pipeline was harsher still, pinning the bed near 8%. All six mix sites now share one filter that resamples both sides up to 48 kHz, cancels the normalization so the music plays at **90% of its true level** (a hair of headroom keeps dialogue legible), and adds a transparent peak limiter. Measured on a real dub: bed level 57% → 90%, bandwidth 12 kHz → 24 kHz. (#1136)
- **A rate-limited translation polish pass no longer sabotages the dub — or lies about it.** The Cinematic quality mode runs an optional critique-and-rewrite pass after translating. When that pass hit a rate limit (free-tier LLM endpoints throttle hard), three bad things happened at once: the app reported **"N/N segment(s) failed"** in red over a translate that had actually succeeded; the affected segments were **silently skipped by the speech-rate fit pass and duration planner** — so overlong lines went to synthesis unfitted and came out audibly time-compressed; and the two-second "retry shortly" hint the provider sent was ignored. All three are fixed: a rate-limited call now waits out the provider's own `Retry-After` (bounded, once) and usually just succeeds; a segment that still misses the polish keeps its plain translation, **stays in every downstream fitting pass**, and is reported honestly — "translated, polish skipped" as a warning with the reason, not a failure. Rows that really failed still say so. (#1135)
- **Dubbing kept re-studying the same speaker's voice, hundreds of times per video.** Each dubbed line clones from a clip of its own source audio (that's what makes deliveries match), and lines too short to clone from fall back to a per-speaker sample. But the app's memory for already-studied voices only holds 8 — and a long dub streams *hundreds* of one-shot per-line clips through it, each pushing out the per-speaker samples that every other line needs. Result: the speaker sample was re-studied (~0.4 s, measured) over and over. One-shot clips are now studied without displacing anything, so the per-speaker samples stay warm for the whole dub. Nothing about the audio changes — same clips, same voices, less repeated work. (#1132)
- **Clicking "Install" on an engine right after opening Settings could silently do nothing.** When the Engines page opens, it quietly checks each installable engine for an in-flight install to re-attach to. If you clicked Install while that check was still running, your click's status update was thrown away to keep requests orderly — so no progress panel, no error, no retry, just nothing (the install itself *did* start in the background; the UI simply never showed it). Fast machines usually won the race, which is why this mostly showed up as a once-in-a-while CI test failure. The Install click's update can no longer be dropped — it politely waits out the startup check instead. (#1131)
- **Cloning re-listened to your reference clip for every chunk of text — now it listens once.** Before OmniVoice can speak in a cloned voice it has to *encode* the reference clip you gave it. That encode was being redone on **every single piece of the job**: long text is split into chunks, and each chunk re-encoded the same reference from scratch; so did each `[pause]` span, and each chapter segment of an audiobook. A cache to prevent exactly this was written a while back — and then quietly bypassed on the path the Generate button actually takes, so for several releases it only ever helped the API. It's now wired into every path. Measured on an M2, one encode costs **0.4 seconds**, so this gives back roughly **34 seconds on a long paragraph** and **about a minute on a 166-segment audiobook** — the same voice, the same audio out, just without listening to your reference clip 166 times. As a bonus, `preprocess_prompt` on the OpenAI-compatible endpoint now actually does something; it was being accepted and silently discarded. (#1130)
- **Dubbing loaded the 3 GB voice model, threw it away, and loaded it again.** Before transcribing, a dub pulled the entire voice model into memory to read a single setting off it — one that is empty unless you've turned on an off-by-default flag. So it loaded ~3 GB, found nothing, released it a moment later (on Apple Silicon that's a *full* unload), and then had to load the very same model again from cold when it was time to actually speak. Every dub paid for that round trip — roughly **8 seconds**, plus the memory churn on exactly the 16 GB machines where memory pressure is the problem. It now only loads the model when there's genuinely something to read. (#1130)
- **The backend stopped holding the voice model hostage while it loads the transcription model — the 16 GB dub crash.** Before transcribing a dub, OmniVoice makes room by setting the TTS model aside. On an NVIDIA GPU it did. On **Apple Silicon it did nothing at all** — the code bailed out with "unified memory doesn't benefit from offloading". That was half right and wholly wrong: on unified memory, *moving* a model to "CPU" frees nothing (it's the same RAM), but the answer is to **release** it, not to skip the step. So a 16 GB Mac went into a dub holding the ~3 GB voice model, then loaded a ~3 GB transcription model on top of it — measured here: 4.1 GB free before, and large-v3 needs 3 — and the operating system killed the backend mid-transcription. That's the dub that "dropped before emitting any segments". The voice model is now genuinely released when memory is tight (and left alone when it isn't, so a roomy machine pays nothing); it reloads by itself on your next generation. (#1119)
- **Dubbing on a Mac was transcribing on the CPU — with the GPU sitting idle.** OmniVoice picked its transcription engine without ever looking at your hardware: WhisperX won every time, and WhisperX (like faster-whisper) is built on CTranslate2, which **has no Metal backend at all**. So on Apple Silicon it ran whisper-large-v3 on the *processor*. Measured on an M2, one 30-second chunk: **90 seconds on the CPU versus 20 on the GPU** — slower than realtime, which turned a 16-minute video into a ~48-minute transcribe that looked exactly like a hang. Worse, the slowest chunks blew past the 2-minute per-chunk timeout and were **abandoned entirely**, so the transcript came back with pieces missing and the app blamed a "VRAM-starved GPU" — on a machine that has no VRAM. Apple Silicon now uses MLX, which runs the **same** whisper-large-v3 on the GPU, roughly **4x faster**. Word timing is unchanged: the wav2vec2 forced alignment that lip-sync depends on (±10-30 ms, versus Whisper's own ±100-300 ms) is layered on top exactly as before. Same model, same alignment, four times the speed. Nothing changes on NVIDIA or Linux, where WhisperX already used the GPU. (#1127)
- **The transcribe screen invented its ETA, and the number was a fiction.** It assumed transcription runs at ~20x realtime — true on a fast GPU — and predicted from the video's length alone. For a 16-minute video it promised **56 seconds**. Once reality overran the guess it pinned itself at "~0s remaining" with the bar frozen at 95%, and sat there for the next three quarters of an hour. It now reports the *real* fraction of the audio transcribed and extrapolates the time left from the speed it can actually observe — so it is right on a fast machine and a slow one, and says nothing at all until it has something true to say. (#1127)
- **Analytics you switched on would have stayed half-dead.** The backend half of the new opt-in analytics read its destination from an environment variable that nothing on your machine ever set — so in a shipped build it could never send anything, silently, no matter what you chose. Only the frontend half worked. The destination is now baked into the desktop shell at build time and handed to the backend when it starts, so "on" means on. Nothing else changes: it stays off until you opt in, builds from source still have no destination at all, and the property allowlist still decides what may leave. (#1123)
- **A dub that dies mid-transcription still guessed at the cause.** v0.3.20 taught it to check the crash report before blaming the ASR model — but it checked *instantly*, the moment the stream dropped, and the desktop shell needs about two seconds to notice the backend died and write that report. So it kept looking too early, finding nothing, and falling back to the same old guess ("Likely ASR backend failed to load") even when the backend had in fact just crashed. It now waits for the shell to catch up, so you get the real cause — exit code and error output — instead of a guess. (#1119)
## [0.3.21] — 2026-07-12
The memory release. The reason the app kept saying "Can't reach the local backend" on 16 GB machines was never really the network — the backend was quietly running out of memory and getting killed. This release fixes that at the source: the models it holds now get out of each other's way. Plus the uninstaller and factory reset grew into a proper Settings → Storage pair.
### Added
- **Factory reset grew up: Settings → Storage → "Reset & remove".** It used to do exactly one thing — clear your UI preferences — while the only other option was deleting everything and starting over. Between "forget my theme" and "wipe the machine" sat every reset people actually needed. Now there are four one-click tiers — **UI preferences**, **all settings**, **downloaded assets & models**, and **everything OmniVoice did** — plus a per-item checklist if you want to drop just the model weights, just a wedged sidecar engine, or just the history. Every option shows its **real size on disk before you commit**, and the number on the button is exactly what gets freed. Deleting voices, projects or audio asks you to type `DELETE`; nothing irreversible happens on a single click. "Everything" deliberately stops short of the Python environment, so you land on a working first-run screen rather than a rebuild — the app stops its engine, deletes, and starts it again for you. On macOS and Linux the model cache is the **shared** Hugging Face cache, so it's its own checkbox and says so; on Windows and portable installs it's OmniVoice's own, and the app doesn't pretend otherwise.
- **The Storage panels got a design.** "Remove all data" and "Reset & remove" listed folders as a flat run of text, so a 7.5 GB model cache and a 391-byte config file carried exactly the same visual weight — the one thing you actually wanted to see (where the space went) was the one thing you couldn't. Every row now has an icon, a dimmed path, and a **proportional bar showing its share of what will be freed**, so the big one looks big. The shared Hugging Face cache is promoted out of the confirm dialog into its own "Optional" row with a checkbox, so ticking it moves the running total **in front of you** instead of springing a different number on you at the point of no return, and the dialog now lists exactly what is about to go.
### Fixed
- **Switching TTS engines no longer stacks their models in memory.** Using a second engine in a session (or a per-request engine override) loaded its model *on top of* the first one's, because the OmniVoice core model and the other engines live in two separate caches that never coordinated — measured on a 16 GB M2, an `omnivoice``mlx-audio` switch left the machine holding both (footprint 3.9 GB → 4.3 GB, the ~2.8 GB core never freed). That accumulation is a direct contributor to the memory pressure behind the "Can't reach the local backend" OOM deaths. Now only one TTS engine's model stays resident: resolving an engine hands back every *other* resident engine first (the same `omnivoice → mlx-audio` switch now drops to ~1.5 GB). Steady-state single-engine use is unaffected; an A/B switch pays a re-load on the way back (~8 s for the OmniVoice core, ~12 s for the lighter engines). Opt out with `OMNIVOICE_SINGLE_ENGINE_RESIDENT=0` if you have RAM to keep several warm. Two underlying leaks are fixed as part of this: every in-process TTS engine's `unload()` now actually frees its model and empties the device cache (previously all but OmniVoice were silent no-ops), and `faster-whisper`'s `unload()` cleared the wrong attribute so its model was never released.
- **The backend no longer sits on ~2 GB of idle dictation model — the real reason it was being killed on 16 GB Macs.** Four reports of *"Can't reach the local OmniVoice backend"* (#1076, #1092, #1093, #1101) all died at the same moment: during a generate, on a 16 GB machine. Measuring it showed the generate was never the problem — it costs about 116 MB. The problem was the **baseline**: the backend sat at **~6.2 GB even while idle**. The TTS model has always been unloaded after an idle timeout, but the speech-recognition model used for dictation never was — so once you dictated a single time, ~2 GB stayed resident for as long as the app ran. On a 16 GB Mac, that plus the app, macOS, and your other programs is enough for the system to run out of memory and kill the backend, which surfaced as the "can't reach the backend" error. Dictation's model now gets the same idle release the TTS model already had, handing that memory back. The only cost is a ~1.4-second re-warm on your next dictation after a long pause, and a live dictation session is pinned so nothing is ever unloaded mid-sentence.
- **Folder sizes under 1 KB displayed as "0 KB".** The uninstall panel's `391 B` config folder rendered as `0 KB` — which reads as "nothing here" for a folder that very much exists. The Storage panels now share one byte formatter that can say `391 B`.
- **Some styling silently did nothing.** A handful of components referenced CSS custom properties that were never defined (`--chrome-fg-subtle`, `--chrome-bg-raised`, `--color-warning`). An undefined `var()` makes the whole declaration invalid, so the browser drops it and the element quietly inherits — the dimmed folder paths in the Storage panels weren't dimmed at all. Fixed in those panels, and a new guard (`frontend/src/test/cssTokens.test.js`) fails on any bare `var(--token)` in JSX that isn't defined in a stylesheet or documented as runtime-injected, so a typo can't ship as invisible styling again.
- **Uninstalling now removes the saved-environment file it used to leave behind.** OmniVoice keeps a small `~/.config/omnivoice/env` file (the model-cache location you chose, and any saved Hugging Face token). Every uninstall path — the in-app "Remove all data", `scripts/uninstall.sh`, and `scripts/uninstall.ps1` — walked right past it, so a later reinstall silently picked the *old* file back up and redirected its downloads to a location you may have long since deleted. All three now list and remove it (it's the same `~/.config/omnivoice` path on every OS, Windows included), and the per-platform tables in `docs/install/uninstall.md` document it.
- **Disk usage now counts installed sidecar engines instead of hiding them.** Settings → Storage measured engine venvs in `backend/engines` — the built-in engine *code*, which has no venvs — so a multi-GB IndexTTS-2 install (which actually lives in `DATA_DIR/engines/<id>`) was invisible in the engine row and quietly rolled into the data dir's "other" subtotal. The report now points at the real install location and sizes the **whole** install (venv + checkout + weights), counted once, so "IndexTTS-2 — 6.2 GB" shows up where you'd look for it.
## [0.3.20] — 2026-07-12
The follow-through release. v0.3.19 promised that "Can't reach the local OmniVoice backend" would stop firing while the backend was merely restarting — and then a user hit it anyway, on 0.3.19, because the fix had a race in it. That's closed properly here. Uninstalling also stopped being a thing only maintainers could do: it's now a button in the app, where the person who asked for it can actually reach it.
### Added
- **Uninstall is now in the app: Settings → Storage → "Remove all data".** The v0.3.19 uninstaller was a *script* — which never reached the people who needed it, since anyone who installed the .dmg / .msi / AppImage has no repo to run it from (exactly the case in #1089). The app now lists every folder this install owns with its real size, deletes them behind a typed confirmation, and quits. The **downloaded model weights are a separate, opt-in checkbox**, because that's the standard Hugging Face cache shared with other AI tools on your machine — removing it can delete models OmniVoice never downloaded. Custom and portable install locations are honored, and nothing outside OmniVoice's own folders can be touched. The scripts now also ship as **release assets**, so you can clean up without launching the app at all. (#1089)
### Fixed
- **"Can't reach the local OmniVoice backend" could still fire on 0.3.19 — the fix had a hole.** The app asks the desktop shell whether a start/restart is in progress before showing that error, but the shell learns of a dead backend from a **2-second poll**: when the backend dies mid-generation, the supervisor needs a moment to notice it, record the crash, and flip its state to "restarting". The app was asking **once**, ~3 seconds in — often still hearing "everything's fine" — and dead-ending on the generic toast anyway. A failed connection *contradicts* "everything's fine", so that answer is now treated as stale rather than authoritative: the app keeps retrying briefly, letting the shell catch up, which turns the failure into the "backend is restarting — hang tight" banner (and gives the crash report time to be written, so you get the real cause instead of a guess). A shell that has genuinely given up, or no shell at all, still errors immediately. (#1101)
- **The uninstaller was leaving the backend's log folder behind on Linux and Windows.** It cleaned the app-data, config, and Python-env folders but missed where the backend actually writes `backend.log` / `backend_err.log``~/.local/state/OmniVoice` on Linux and `%LOCALAPPDATA%\OmniVoice\Logs` on Windows. Both the scripts and the documented path lists now cover them. (#1089)
## [0.3.19] — 2026-07-12
The honesty release. Every error in here was already *technically* true and practically useless — so this round went after the lies the app tells when something goes wrong. "Can't reach the local OmniVoice backend" no longer fires while the backend is simply still starting; a dead Hugging Face mirror no longer strands the setup wizard with advice it can't follow; and a dub that dies mid-transcription now names the actual cause instead of guessing at it. Alongside that: generated speech starts playing on the *first* chunk instead of the last, and there's finally a real uninstaller.
+12
View File
@@ -203,6 +203,8 @@ Pick your OS and follow the guide end-to-end:
- 🐧 **Linux** — [docs/install/linux.md](docs/install/linux.md)
- 🐳 **Docker** — [docs/install/docker.md](docs/install/docker.md) · [Docker Hub: `palashdeb/omnivoice-studio`](https://hub.docker.com/r/palashdeb/omnivoice-studio)
Feels slow? [docs/performance.md](docs/performance.md) covers where generation time actually goes, the tuning knobs, and the three classic causes of "it got slow".
> Coming from **[CorentinJ/Real-Time-Voice-Cloning](https://github.com/CorentinJ/Real-Time-Voice-Cloning)** (now archived)? There's a dedicated migration guide: [docs/migration/real-time-voice-cloning.md](docs/migration/real-time-voice-cloning.md).
<details>
@@ -582,6 +584,16 @@ Yes. MPS acceleration is auto-detected. MLX-optimized Whisper models are availab
Yes. OmniVoice uses a <b>built-in backend registry</b>. To add an engine in ~50 lines, subclass <code>TTSBackend</code> in <code>backend/services/tts_backend.py</code> and add it to the <code>_REGISTRY</code> dictionary. Fourteen engines are built in: OmniVoice, CosyVoice 3, GPT-SoVITS, MLX-Audio (14+ sub-engines), VoxCPM2, MOSS-TTS-Nano, KittenTTS, Sherpa-ONNX, plus lazy-registered IndexTTS 2, OmniVoice GGUF, Supertonic 3, MOSS-TTS-v1.5, dots.tts, and Confucius4-TTS. See the <a href="#tts-engines">TTS Engines</a> section for details.
</details>
<details>
<summary><b>Does OmniVoice collect any data about me?</b></summary>
<br/>
<b>Not unless you switch it on.</b> Out of the box OmniVoice sends nothing — no analytics, no telemetry, no accounts, no phone-home. Your text, your audio, your voices, and your projects never leave your machine, and that is true whatever you choose here.
There is one <b>opt-in</b> toggle in <b>Settings → Privacy → "Help improve OmniVoice"</b>, which is <b>off by default</b>. If you turn it on, the app sends anonymous usage stats: which engine and language you used, how long a generation took, how many <i>characters</i> the text had (a number, not the text), and the <i>type</i> of any error. It <b>never</b> sends the text you type, your audio, your file names, your voice names, or anything identifying you — enforced in code by a property allowlist, not just a promise (<code>backend/core/analytics.py</code>). Crash tracebacks are deliberately <b>not</b> auto-captured, because they can contain file paths and tokens. You can turn it off again at any time.
Builds from source have no analytics destination at all — the toggle isn't even shown, and nothing can be sent.<br/><br/>Want to see your own numbers instead? <b>Settings → Usage</b> shows them, computed entirely on your machine and sent nowhere.
</details>
<details>
<summary><b>How do I uninstall it / remove all its data?</b></summary>
<br/>
+10
View File
@@ -95,6 +95,16 @@ if IS_MAC_ARM:
'mlx_audio.tts.models', 'mlx_audio.tts.generate',
'mlx_audio.stt', 'mlx_audio.codec',
]
# Kokoro's phonemizer (misaki) loads the spaCy model en_core_web_sm
# DYNAMICALLY (spacy.load by name), so PyInstaller never sees the import —
# a frozen build without it would hit misaki's in-process downloader at
# first English generation (#1133 class; contained since #1143, but the
# generation still degrades). It's a plain data-heavy package with no
# nanobind involvement, so collect_all is safe here (unlike mlx itself).
_sm_datas, _sm_bins, _sm_hidden = collect_all('en_core_web_sm')
datas += _sm_datas
binaries += _sm_bins
hiddenimports += _sm_hidden
# Note: we deliberately DON'T enumerate mlx submodules here. Any variant of
# `collect_submodules('mlx')` or `collect_all('mlx')` — even filtered to
+50 -3
View File
@@ -244,6 +244,44 @@ def _resolve_default_language(language: str | None, default_voice: str | None) -
return None
#: Longform renders run at the model's documented quality preset (#1139).
#: This used to be an accident of omission — the synth wrappers below passed
#: no num_step/guidance_scale, silently inheriting OmniVoiceGenerationConfig's
#: defaults (32 / 2.0) while interactive /generate defaults to num_step=16 —
#: and users correctly heard audiobooks as more stable than the Voice page.
#: Named constants make the divergence a documented decision (a book is a
#: cached batch job: quality beats latency) and pin book quality against any
#: upstream config-default drift.
LONGFORM_NUM_STEP = 32
LONGFORM_GUIDANCE_SCALE = 2.0
def _seed_segment_rng(base_seed, text: str) -> None:
"""Apply a profile's pinned seed to this synth call (#1139).
``_resolve_voice`` has always fetched the profile ``seed`` but only the
cache signature ever used it; generation itself ran unseeded, so a locked
take's pinned seed silently did nothing here while /generate honored it.
No pinned seed no-op (fresh-render variety unchanged).
Concurrency contract: this seeds the process-global torch RNG, exactly
like /generate's #526 seeding (generation.py's ``torch.manual_seed`` in
``_run_inference``/``_run_backend_inference``, same GPU pool). Both are
strictly deterministic wherever the pool has one worker the default on
MPS/CPU and small-VRAM CUDA (model_manager._pick_gpu_workers) and
best-effort when a >1-worker CUDA pool runs another seeded job in the
same window. Making that window race-free requires threading a per-call
torch.Generator through the model's samplers app-wide; if that lands, it
must cover /generate and here together, not one path.
"""
if base_seed is None:
return
import torch
from services.audiobook import segment_seed
torch.manual_seed(segment_seed(base_seed, text))
def _build_synth(default_voice: str | None, language: str | None = None) -> dict:
"""Describe how to synthesize for the active TTS engine.
@@ -279,6 +317,7 @@ def _build_synth(default_voice: str | None, language: str | None = None) -> dict
def synth(text, voice_id, speed=None):
v = resolve(voice_id)
_seed_segment_rng(v.get("seed"), text)
return backend.generate(
text, language=language, ref_audio=v["ref_audio"],
ref_text=v["ref_text"], instruct=v["instruct"], duration=None,
@@ -300,11 +339,19 @@ async def _prepare_synth(default_voice: str | None, language: str | None = None)
model = await info["get_model"]()
sr = getattr(model, "sampling_rate", 24000)
from services.tts_backend import generate_with_cached_ref
def synth(text, voice_id, speed=None):
v = resolve(voice_id)
return model.generate(
text=text, language=lang, ref_audio=v["ref_audio"],
ref_text=v["ref_text"], instruct=v["instruct"], duration=None,
_seed_segment_rng(v.get("seed"), text)
# A book is the worst case for the re-encode this avoids: hundreds of
# segments, one voice. The reference is encoded on the first segment
# and reused for every one after it.
return generate_with_cached_ref(
model, ref_audio=v["ref_audio"], ref_text=v["ref_text"],
text=text, language=lang, instruct=v["instruct"], duration=None,
num_step=LONGFORM_NUM_STEP,
guidance_scale=LONGFORM_GUIDANCE_SCALE,
speed=float(speed) if speed else 1.0,
)[0]
return synth, sr, resolve, engine_id
+2 -2
View File
@@ -107,7 +107,7 @@ async def _run_batch_pipeline(job_id: str, job: dict):
_set_progress(job, "extract", 0)
audio_path = os.path.join(batch_dir, "audio.wav")
from services.ffmpeg_utils import find_ffmpeg
from services.ffmpeg_utils import bed_mix_filter, find_ffmpeg
ffmpeg = find_ffmpeg()
def _extract():
@@ -385,7 +385,7 @@ async def _run_batch_pipeline(job_id: str, job: dict):
"-i", video_path,
"-i", track_path,
"-filter_complex",
"[0:a]volume=0.15[bg];[1:a]volume=1.0[dub];[bg][dub]amix=inputs=2:duration=first[out]",
bed_mix_filter("0:a", "1:a", out="out", duration="first"),
"-map", "0:v", "-map", "[out]",
"-c:v", "copy", "-c:a", "aac", "-b:a", "192k",
"-shortest", output_path],
+12 -5
View File
@@ -158,13 +158,20 @@ async def ws_transcribe(websocket: WebSocket):
# legacy Whisper/WebM path, byte-for-byte unchanged.
spec = _select_sherpa_spec(websocket)
if spec is not None:
from services.asr_backend import SherpaDictationBackend
from services.asr_backend import SherpaDictationBackend, capture_lease
ok, _reason = SherpaDictationBackend.is_available()
if ok:
if spec.streaming:
await _run_sherpa_streaming(websocket, spec)
else:
await _run_sherpa_offline(websocket, spec)
# A live session holds the shared capture backend for its whole
# lifetime without ever re-resolving it, so the idle reaper
# (#1101 class) must not unload the model out from under it — even
# if the user leaves the mic open, silent, past the idle timeout.
# The lease pins it for exactly this window and restarts the idle
# clock on the way out.
with capture_lease():
if spec.streaming:
await _run_sherpa_streaming(websocket, spec)
else:
await _run_sherpa_offline(websocket, spec)
return
# sherpa not installed → fall through to the legacy path so the user
# still gets dictation (just not live partials).
+34 -14
View File
@@ -15,7 +15,7 @@ from core.config import PREVIEW_DIR
from core.tasks import task_manager
from core import event_bus
from schemas.requests import DubIngestUrlRequest
from services.model_manager import get_model, _gpu_pool, _cpu_pool, get_diarization_pipeline, offload_tts_for_asr, restore_tts_after_asr
from services.model_manager import get_model, _gpu_pool, _cpu_pool, get_diarization_pipeline, offload_tts_for_asr, restore_tts_after_asr, should_preload_tts_asr
from services.asr_backend import ASRTimeoutError, reset_pool_after_wedge, run_transcribe_guarded
from services.audio_io import _safe_soundfile_write
from services.ffmpeg_utils import find_ffmpeg
@@ -433,22 +433,37 @@ async def dub_transcribe_stream(
asr_audio_target: Optional[str] = None
_asr_backend = None
scene_cuts: list = []
# Defaulted here, not just inside the preflight block below: it is read from
# _gen_body (separated_vocals=), so a preflight that bails early would
# otherwise leave it unbound and raise NameError instead of the real error.
asr_on_vocals = False
if not job:
preflight_error = "Job not found. It may have been cleaned up or was never created."
else:
# Guard the model load: if it raises, the SSE stream would otherwise die
# before emitting any event, and the UI shows a misleading generic
# "stream dropped" message instead of the real cause (issue #255).
try:
_model = await get_model()
except Exception as e:
logger.exception("transcribe preflight: model load failed (job=%s)", job_id)
from core.failure import build_failure
f = build_failure(e, stage="transcribe-preflight", include_diagnostic=False)
preflight_error = f["reason"] + (f"{f['hint']}" if f.get("hint") else "")
_model = None
if _model is not None:
# The TTS core model is loaded here for exactly one reason: to harvest a
# preloaded `_asr_pipe` off it (passed to get_active_asr_backend below).
# That attribute is only ever set by OmniVoice.from_pretrained under
# OMNIVOICE_PRELOAD_TTS_ASR, which is off by default — so in the default
# config this loaded ~3 GB, harvested None, and then offload_tts_for_asr()
# freed it again 60 lines below. On unified memory that offload is a full
# UNLOAD (#1119), so dub_generate later cold-reloaded the same model (~8s).
# Every dub paid load → unload → reload for an attribute that was always
# None. Load it only when there is actually something to harvest.
_model = None
if should_preload_tts_asr():
# Guard the model load: if it raises, the SSE stream would otherwise die
# before emitting any event, and the UI shows a misleading generic
# "stream dropped" message instead of the real cause (issue #255).
try:
_model = await get_model()
except Exception as e:
logger.exception("transcribe preflight: model load failed (job=%s)", job_id)
from core.failure import build_failure
f = build_failure(e, stage="transcribe-preflight", include_diagnostic=False)
preflight_error = f["reason"] + (f"{f['hint']}" if f.get("hint") else "")
_model = None
if preflight_error is None:
asr_audio_target = job.get("vocals_path")
if not asr_audio_target or not os.path.exists(asr_audio_target):
asr_audio_target = job.get("audio_path")
@@ -1161,7 +1176,12 @@ async def dub_transcribe(job_id: str, num_speakers: Optional[int] = None):
job = _get_job(job_id)
if not job:
raise HTTPException(status_code=404, detail="Job not found")
_model = await get_model()
# Same as the streaming preflight: the only use of the TTS core here is the
# last-resort `_model._asr_pipe` fallback below, which exists solely under
# OMNIVOICE_PRELOAD_TTS_ASR — and when it is off, that branch raises "fallback
# is not preloaded" anyway. Loading ~3 GB to reach a None attribute (and then
# having offload_tts_for_asr free it) was pure cost.
_model = await get_model() if should_preload_tts_asr() else None
def _transcribe():
+30 -14
View File
@@ -13,7 +13,7 @@ from fastapi.responses import FileResponse, StreamingResponse
from core.config import DUB_DIR, dub_seg_path
from core.tasks import task_manager
from api.routers.dub_core import _get_job
from services.ffmpeg_utils import find_ffmpeg, run_ffmpeg
from services.ffmpeg_utils import bed_mix_filter, find_ffmpeg, run_ffmpeg
from services.video_retime import (
DRIFT_TOLERANCE_S,
RetimeError,
@@ -170,6 +170,25 @@ async def dub_list_tracks(job_id: str):
return {"tracks": job.get("dubbed_tracks", {})}
@router.get("/dub/segments-text/{job_id}")
async def dub_segments_text(job_id: str, lang: str = Query(...)):
"""Per-segment texts for one generated track: ``{"texts": {segKey: text}}``.
Backing store is ``job["segments_i18n"]`` (P1.2) the authoritative
per-language map every generate rebuilds. The Export preview tabs use it
to hydrate segments whose in-browser ``translations[lang]`` entry is
missing (tracks generated before per-language persistence, partial
regens), so switching the preview language can't leave a mixed-language
transcript. Empty map when the job predates segments_i18n or the track
was never generated the client keeps whatever it has.
"""
job = _get_job(job_id)
if not job:
raise HTTPException(status_code=404, detail="Job not found")
i18n = job.get("segments_i18n") or {}
return {"texts": i18n.get(lang) or {}}
def _segments_for_lang(job: dict, lang: "str | None") -> list:
"""Job segments with `text` overlaid from ``job["segments_i18n"][lang]``.
@@ -393,7 +412,7 @@ def _build_audio_export_cmd(
# Mix the dubbed voice over the original background bed (same weights
# as the video mux path) so ambience/music is preserved.
cmd += ["-i", bg_path, "-filter_complex",
"[0:a][1:a]amix=inputs=2:duration=longest:dropout_transition=2:weights=1.2 0.8[aout]",
bed_mix_filter("1:a", "0:a"),
"-map", "[aout]"]
cmd += codec
cmd.append(out_path)
@@ -662,12 +681,11 @@ async def dub_download(
if bg_idx is not None:
for i, t in enumerate(tracks_to_process):
out_label = f"[aout{i}]"
chain = f"[{bg_idx}:a][{t['idx']}:a]amix=inputs=2:duration=longest:dropout_transition=2:weights=0.8 1.2"
if apad_dur:
chain += f",apad=whole_dur={apad_dur:.4f}"
filter_parts.append(chain + out_label)
t["out_label"] = out_label
tail = f",apad=whole_dur={apad_dur:.4f}" if apad_dur else ""
filter_parts.append(bed_mix_filter(
f"{bg_idx}:a", f"{t['idx']}:a", out=f"aout{i}", tail=tail, uniq=str(i),
))
t["out_label"] = f"[aout{i}]"
for t in tracks_to_process:
cmd += ["-map", t["out_label"]]
elif apad_dur:
@@ -998,10 +1016,8 @@ async def dub_preview_video(
audio_map = f"{track_idx}:a:0"
if bg_idx is not None:
chain = f"[{bg_idx}:a][{track_idx}:a]amix=inputs=2:duration=longest:dropout_transition=2:weights=0.8 1.2"
if apad_dur:
chain += f",apad=whole_dur={apad_dur:.4f}"
filter_parts.append(chain + "[aout]")
tail = f",apad=whole_dur={apad_dur:.4f}" if apad_dur else ""
filter_parts.append(bed_mix_filter(f"{bg_idx}:a", f"{track_idx}:a", tail=tail))
audio_map = "[aout]"
elif apad_dur:
filter_parts.append(f"[{track_idx}:a]apad=whole_dur={apad_dur:.4f}[aout]")
@@ -1330,7 +1346,7 @@ async def dub_download_audio(job_id: str, lang: str = Query(None), preserve_bg:
final_audio_path = os.path.join(exports_dir, f"mixed_dub_{lang_label}_{stamp}.wav")
cmd = [
ffmpeg, "-i", bg_audio, "-i", wav_path,
"-filter_complex", "[0:a][1:a]amix=inputs=2:duration=longest:dropout_transition=2:weights=0.8 1.2[aout]",
"-filter_complex", bed_mix_filter("0:a", "1:a"),
"-map", "[aout]", "-c:a", "pcm_s16le", "-y", final_audio_path
]
try:
@@ -1559,7 +1575,7 @@ async def dub_download_mp3(job_id: str, lang: str = Query(None), preserve_bg: bo
mixed_path = os.path.join(exports_dir, f"mixed_mp3_{lang_label}_{stamp}.wav")
cmd_mix = [
ffmpeg, "-i", bg_audio, "-i", wav_path,
"-filter_complex", "[0:a][1:a]amix=inputs=2:duration=longest:dropout_transition=2:weights=0.8 1.2[aout]",
"-filter_complex", bed_mix_filter("0:a", "1:a"),
"-map", "[aout]", "-c:a", "pcm_s16le", "-y", mixed_path
]
try:
+226 -32
View File
@@ -1,4 +1,5 @@
import os
import re
import json
import logging
import time
@@ -26,7 +27,7 @@ from services.ffmpeg_utils import (
)
from services.rvc import apply_rvc, is_enabled as rvc_is_enabled
from services.incremental import segment_fingerprint, fit_fingerprint
from services.fit_planner import FitParams, plan_fit
from services.fit_planner import UNDERRUN_TOLERANCE, FitParams, plan_fit
from services.watermark import embed_watermark
from api.routers.dub_core import _get_job, _save_job
from omnivoice.utils.voice_design import heal_design_instruct
@@ -41,6 +42,17 @@ logger = logging.getLogger("omnivoice.dub")
# in services/speech_rate.py, gap absorption below) keeps us under this
# in practice — this is only a guard rail.
MAX_STRETCH_RATIO = 1.8
def _underrun_min_rate() -> float:
"""Floor for the underrun fill (audio slowed toward its slot, never below
this rate). Default 0.85 stays natural-sounding; OMNIVOICE_UNDERRUN_MIN_RATE=1.0
disables the fill. Clamped to atempo's per-stage sane range."""
try:
v = float(os.environ.get("OMNIVOICE_UNDERRUN_MIN_RATE", "0.85"))
except ValueError:
v = 0.85
return min(1.0, max(0.5, v))
# How far a too-long segment is allowed to bleed into the silent gap
# before the next segment. Buys headroom on languages with higher
# information density (Bengali, Hindi, Arabic…) without the audio
@@ -145,6 +157,99 @@ def _legacy_seg_cache_ok(job: dict, lang_code: str) -> bool:
return not any(lc != lang_code for lc in tracks)
# ── voice_match="consistent" resolution ─────────────────────────────────────
# Owner report: "still 4 segments different in voice". Per-segment refs (Wave
# 3.2) clone each line from a clip of its own source audio — best prosody
# match, but the voice IDENTITY drifts line to line, and heuristic-diarized
# jobs have no pooled speaker clones to anchor it. `voice_match="consistent"`
# resolves every segment of a speaker to ONE reference: the per-speaker clone
# when it exists, otherwise a deterministic pick among that speaker's own
# per-segment clips.
# Below ~3 s zero-shot prompt-priming gets unstable, so prefer clips at or
# above it when choosing the one shared reference.
CONSISTENT_MIN_REF_S = 3.0
def _speaker_key_matches(speaker_id: str, key: str) -> bool:
"""Same matching rule the `auto:` branch has always used: the safe-name
slug first (`auto_profile_id`), the raw speaker id as fallback."""
return speaker_id.lower().replace(" ", "_") == key or speaker_id == key
def _find_speaker_clone(clones: dict, key: str):
for spk, info in (clones or {}).items():
if _speaker_key_matches(spk, key):
return info
return None
def _seg_id_order(sid: str):
"""Sort key for the tie-break: numeric suffix when there is one (so
'seg_2' < 'seg_10'), plain string ordering otherwise. Deterministic for
any id shape."""
m = re.search(r"(\d+)$", sid)
return (0, int(m.group(1)), sid) if m else (1, 0, sid)
def _speaker_key_for_segment(job: dict, sid) -> str | None:
"""The `auto:`-style key of the speaker that owns segment `sid`, from the
job's diarized segment rows. None when the segment is unknown (the caller
then keeps per-line behaviour for it best effort, never a crash)."""
for row in job.get("segments") or []:
if isinstance(row, dict) and str(row.get("id", "")) == str(sid):
spk = row.get("speaker_id") or "Speaker 1"
return spk.lower().replace(" ", "_")
return None
def resolve_consistent_ref(job: dict, speaker_key: str, memo: dict | None = None):
"""ONE clone reference for every segment of `speaker_key`.
Preference order:
1. the pooled per-speaker clone (job["speaker_clones"]) same lookup
the per-line path uses as its fallback;
2. no speaker clone (heuristic diarization skips extraction entirely
the key case): a deterministic pick among that speaker's per-segment
clips: longest clip 3 s, tie-break lowest segment id. Clips all
shorter than 3 s degrade to "longest overall", same tie-break.
Returns the clone info dict ({"ref_audio", "ref_text", ...}) or None.
Pure function of the job dict; `memo` (keyed by speaker_key) just avoids
rescanning per segment the pick is deterministic with or without it.
"""
if memo is not None and speaker_key in memo:
return memo[speaker_key]
ref = _find_speaker_clone(job.get("speaker_clones") or {}, speaker_key)
if ref is None:
seg_clones = job.get("segment_clones") or {}
candidates = []
for row in job.get("segments") or []:
if not isinstance(row, dict):
continue
spk = row.get("speaker_id") or "Speaker 1"
if not _speaker_key_matches(spk, speaker_key):
continue
sid = str(row.get("id", ""))
info = seg_clones.get(sid)
if info and info.get("ref_audio"):
candidates.append((sid, info))
if candidates:
usable = [
c for c in candidates
if float(c[1].get("duration") or 0.0) >= CONSISTENT_MIN_REF_S
] or candidates
usable.sort(
key=lambda c: (-float(c[1].get("duration") or 0.0), _seg_id_order(c[0]))
)
ref = usable[0][1]
if memo is not None:
memo[speaker_key] = ref
return ref
router = APIRouter()
@router.post("/dub/generate/{job_id}")
@@ -311,6 +416,11 @@ async def dub_generate(job_id: str, req: DubRequest):
regen_only = set(req.regen_only or []) if req.regen_only is not None else None
seg_ids = req.segment_ids or []
strategy = (req.timing_strategy or "concise").lower()
# Voice-identity mode (see DubRequest.voice_match). The memo makes the
# "consistent" pick once per speaker and hands the SAME reference to
# every segment of that speaker for the whole run.
voice_match = (req.voice_match or "per_line").lower()
_consistent_ref_memo: dict = {}
# Strategy-transition guard: smart_fit re-mixes the *natural-rate*
# per-segment WAVs from disk. If the previous run used strict_slot,
# the on-disk WAVs are slot-squeezed ("slotted") — reusing them would
@@ -445,6 +555,13 @@ async def dub_generate(job_id: str, req: DubRequest):
ref_audio = None
ref_text = None
used_seed = None
# Per-segment refs are a distinct file per segment, each used
# exactly once in this render — telling the prompt cache to
# store them would evict the per-speaker / locked-profile
# prompts that every OTHER segment reuses (LRU of 8 vs
# potentially hundreds of segment clips). cache_ref=False =
# "encode it, don't let it displace anything".
ref_single_use = False
# Auto-clones extracted from the source video during prepare
# (see services/speaker_clone.py) live at job["speaker_clones"]
@@ -455,37 +572,69 @@ async def dub_generate(job_id: str, req: DubRequest):
if profile_id and profile_id.startswith("auto-seg:"):
sid = profile_id[len("auto-seg:"):]
info = (job.get("segment_clones") or {}).get(sid)
if info:
# voice_match="consistent": an auto-seg binding to the
# segment's OWN id is the server default from prepare —
# heuristic diarization skips speaker-clone extraction, so
# every long line gets `auto-seg:{its own id}` (see
# dub_core's assignment loop). That's not a user choice
# (the Voice dropdown can't even render auto-seg ids), so
# swap it for the speaker's ONE consistent reference. A
# CROSS binding (sid != this segment) can only come from an
# explicit request — honour its clip unchanged.
_consistent_alt = None
if voice_match == "consistent" and sid == str(seg_id):
_spk_key = _speaker_key_for_segment(job, sid)
if _spk_key:
_consistent_alt = resolve_consistent_ref(
job, _spk_key, _consistent_ref_memo
)
if _consistent_alt:
ref_audio = _consistent_alt.get("ref_audio")
ref_text = _consistent_alt.get("ref_text")
# Shared by every segment of the speaker → multi-use;
# keep it warm in the prompt cache (#1132 semantics).
elif info:
ref_audio = info.get("ref_audio")
ref_text = info.get("ref_text")
ref_single_use = True
profile_id = None # prevent the voice_profiles lookup below
elif profile_id and profile_id.startswith("auto:"):
# #486: an `auto:{speaker}` binding still prefers THIS
# segment's own per-segment ref when one exists (cut from
# this line's source audio → matches its prosody), falling
# back to the per-speaker clone otherwise. This keeps the
# Wave 3.2 per-segment-ref quality win while letting every
# segment carry the UI-visible `auto:` id the dub editor's
# Voice dropdown can actually render ("From Video →
# Speaker N"). `seg_id` is closed over from the per-segment
# loop below.
seg_ref = (job.get("segment_clones") or {}).get(str(seg_id))
if seg_ref:
ref_audio = seg_ref.get("ref_audio")
ref_text = seg_ref.get("ref_text")
else:
key = profile_id[len("auto:"):]
clones = job.get("speaker_clones") or {}
# Match by the safe-name key first, fall back to speaker_id.
auto = None
for spk, info in clones.items():
if spk.lower().replace(" ", "_") == key or spk == key:
auto = info
break
key = profile_id[len("auto:"):]
if voice_match == "consistent":
# ONE reference per speaker for the whole dub: the
# pooled per-speaker clone, else the deterministic
# segment-clip pick (heuristic-diarized jobs have no
# speaker_clones at all — the key case). Multi-use by
# construction → ref_single_use stays False so the
# prompt cache keeps it warm across segments (#1132).
auto = resolve_consistent_ref(job, key, _consistent_ref_memo)
if auto:
ref_audio = auto.get("ref_audio")
ref_text = auto.get("ref_text")
else:
# per_line (DEFAULT) — #486: an `auto:{speaker}`
# binding still prefers THIS segment's own per-segment
# ref when one exists (cut from this line's source
# audio → matches its prosody), falling back to the
# per-speaker clone otherwise. This keeps the Wave 3.2
# per-segment-ref quality win while letting every
# segment carry the UI-visible `auto:` id the dub
# editor's Voice dropdown can actually render ("From
# Video → Speaker N"). `seg_id` is closed over from
# the per-segment loop below.
seg_ref = (job.get("segment_clones") or {}).get(str(seg_id))
if seg_ref:
ref_audio = seg_ref.get("ref_audio")
ref_text = seg_ref.get("ref_text")
ref_single_use = True
else:
auto = _find_speaker_clone(
job.get("speaker_clones") or {}, key
)
if auto:
ref_audio = auto.get("ref_audio")
ref_text = auto.get("ref_text")
profile_id = None # prevent the voice_profiles lookup below
if profile_id:
@@ -517,6 +666,7 @@ async def dub_generate(job_id: str, req: DubRequest):
audio_out = backend.generate(
text=text, language=lang if lang != "Auto" else None,
ref_audio=ref_audio, ref_text=ref_text,
cache_ref=not ref_single_use,
instruct=instruct_str if instruct_str else None,
duration=dur_s, num_step=nstep, guidance_scale=cfg,
speed=spd, denoise=True, postprocess_output=True,
@@ -561,9 +711,15 @@ async def dub_generate(job_id: str, req: DubRequest):
nstep, retry_steps,
)
try:
# An OOM retry on a single-use ref pays the reference
# encode a second time (~0.4s) — deliberate: caching it
# would reintroduce the eviction this flag exists to
# prevent, to optimize a path that only runs after an
# OOM already cost seconds.
audio_out = backend.generate(
text=text, language=lang if lang != "Auto" else None,
ref_audio=ref_audio, ref_text=ref_text,
cache_ref=not ref_single_use,
instruct=instruct_str if instruct_str else None,
duration=dur_s, num_step=retry_steps, guidance_scale=cfg,
speed=spd, denoise=True, postprocess_output=True,
@@ -710,7 +866,7 @@ async def dub_generate(job_id: str, req: DubRequest):
"speed": getattr(seg, "speed", None),
"direction": getattr(seg, "direction", None),
"effect_preset": getattr(seg, "effect_preset", None),
}, track_lang=lang_code)
}, track_lang=lang_code, voice_match=voice_match)
except Exception as e:
logger.debug("seg fingerprint skipped for %s: %s", seg_id, e)
@@ -868,6 +1024,7 @@ async def dub_generate(job_id: str, req: DubRequest):
video_slow_cap=float(getattr(_fo, "video_slow_cap", None) or _fit_defaults.video_slow_cap),
gap_guard_s=float(_fo.gap_guard_s) if _fo is not None and _fo.gap_guard_s is not None else _fit_defaults.gap_guard_s,
allow_video_retime=bool(_fo.allow_video_retime) if _fo is not None and _fo.allow_video_retime is not None else _fit_defaults.allow_video_retime,
min_audio_rate=_underrun_min_rate(),
)
_seg_order = job.get("seg_order") or []
fit_plan = plan_fit(
@@ -942,7 +1099,10 @@ async def dub_generate(job_id: str, req: DubRequest):
# chunk) is persisted below for the export pipeline.
sf = fit_plan.segments[i]
place_at = sf.new_start
if sf.audio_rate > 1.0 + 1e-6 and wl > 0:
# Both directions: >1 compresses an overrun, <1 slows an
# underrun toward the slot (the "hole" fix — a dub that
# finishes early leaves the mouth moving over near-silence).
if abs(sf.audio_rate - 1.0) > 1e-6 and wl > 0:
target = max(1, int(round(wl / sf.audio_rate)))
try:
adjusted = await _pitch_preserving_stretch(
@@ -969,7 +1129,7 @@ async def dub_generate(job_id: str, req: DubRequest):
wl = adjusted.shape[-1]
# Truthful per-segment verdict for the UI badge.
entry = {"status": sf.status}
if sf.audio_rate > 1.0 + 1e-6:
if abs(sf.audio_rate - 1.0) > 1e-6:
entry["audio_rate"] = round(sf.audio_rate, 3)
if sf.video_ratio > 1.0 + 1e-6:
entry["video_ratio"] = round(sf.video_ratio, 3)
@@ -1016,6 +1176,7 @@ async def dub_generate(job_id: str, req: DubRequest):
# keep passing.
place_at = start
effective_end = end
slowed_rate = None
if i + 1 < len(all_segment_wavs):
next_start = all_segment_wavs[i + 1][0]
gap = next_start - end
@@ -1056,11 +1217,44 @@ async def dub_generate(job_id: str, req: DubRequest):
else: # "trim"
adjusted = adjusted[..., :slot_samples]
wl = adjusted.shape[-1]
fit_status.append({
"status": "fits",
"compression_applied": (slot_fit == "time_stretch"
and wl != int(natural_dur * sr)),
})
elif (
slot_fit == "time_stretch"
and slot_samples > 0
and wl > 0
and wl < slot_samples * UNDERRUN_TOLERANCE
and _underrun_min_rate() < 1.0 - 1e-6
):
# Underrun fill (mirror of the compression above): the
# dub finished early, leaving the on-screen mouth moving
# over the thin under-speech bed residue — perceived as
# dead air. Slow toward the slot, never below the floor.
rate = max(wl / slot_samples, _underrun_min_rate())
target = min(slot_samples, int(round(wl / rate)))
try:
adjusted = await _pitch_preserving_stretch(
adjusted, target, sr,
)
slowed_rate = rate
except Exception as e:
logger.warning(
"underrun fill failed for seg %d (%.2f×), "
"keeping natural rate: %s", i, rate, e,
)
wl = adjusted.shape[-1]
# Truthful verdict: a slowed segment says so (and by how
# much) instead of hiding behind "fits" — the same honesty
# contract the smart_fit branch keeps.
if slowed_rate is not None:
fit_status.append({
"status": "audio_slowed",
"audio_rate": round(slowed_rate, 3),
})
else:
fit_status.append({
"status": "fits",
"compression_applied": (slot_fit == "time_stretch"
and wl != int(natural_dur * sr)),
})
# Common: short fades to avoid pops, then mix into disk-backed audio.
fade_ms = 15
+9
View File
@@ -1031,6 +1031,15 @@ async def _maybe_cinematic(translated, req, src_lang, loop, *, already_llm=False
"literal": r["literal"],
"critique": r.get("critique", ""),
}
# `degraded` ≠ `error`: a degraded row fell back to its literal text
# (reflect/adapt skipped — rate limit, budget, divergence) but is fully
# usable, so the fit pass, condense pass, and duration planning below
# must still run on it. Marking these `error` used to (a) skip all
# three passes — overlong lines then hit heavy time-compression at mix,
# audibly degrading the dub — and (b) make the UI report "N/N segments
# failed" for a translate that succeeded.
if r.get("degraded"):
out["degraded"] = r["degraded"]
if r.get("error"):
out["error"] = r["error"]
merged.append(out)
+82 -28
View File
@@ -439,6 +439,22 @@ def _oom_friendly_reraise(e):
) from e
def _generate_timeout_s(text: str) -> float:
"""Wall-clock budget for one generate, scaled to the request.
The fixed OMNIVOICE_GENERATE_TIMEOUT_S (300s) was sized for typical
requests on a GPU a legitimately long text on a slow CPU box times out
with the exact user-facing 503 the audit flagged as a recurring class
(#1033/#1037 wave), and the remedy was "go set an env var". Scale the
budget with input size instead: the floor stays the configured value, and
long inputs get 1 extra second per 40 characters generous enough for
CPU-class hardware, still bounded (a wedged job is caught in minutes, not
hours). An explicit OMNIVOICE_GENERATE_TIMEOUT_S remains the floor/knob.
"""
from services.model_manager import GPU_JOB_TIMEOUT_S
return max(GPU_JOB_TIMEOUT_S, GPU_JOB_TIMEOUT_S + (max(0, len(text or "") - 1200) / 40.0))
def _run_inference(
model, text, language, ref_audio_path, ref_text, instruct, duration,
num_step, guidance_scale, speed, t_shift, denoise,
@@ -459,6 +475,18 @@ def _run_inference(
sr = model.sampling_rate if hasattr(model, 'sampling_rate') else 24000
from services.tts_backend import generate_with_cached_ref
def _gen(gen_text, gen_duration):
"""One generate call for this request's voice, reference encoded once."""
return generate_with_cached_ref(
model, ref_audio=ref_audio_path, ref_text=ref_text,
text=gen_text, language=language, instruct=instruct,
duration=gen_duration, num_step=num_step,
guidance_scale=guidance_scale, speed=speed, denoise=denoise,
postprocess_output=postprocess_output, **kwargs
)
# Inline [pause Nms] markers (issue #276): split the text and stitch
# silence between independently-synthesized spans. Fully opt-in — text
# without a marker takes the unchanged single-shot path below.
@@ -470,13 +498,7 @@ def _run_inference(
def _gen_span(span_text):
# Per-span duration is left to the model; an explicit overall
# `duration` can't be meaningfully split across spans.
return model.generate(
text=span_text, language=language, ref_audio=ref_audio_path,
ref_text=ref_text, instruct=instruct, duration=None,
num_step=num_step, guidance_scale=guidance_scale, speed=speed,
denoise=denoise, postprocess_output=postprocess_output,
**kwargs
)[0]
return _gen(span_text, None)[0]
audio_out = _render_with_pauses(_gen_span, segments, sr)
else:
# Wave 1.2: long text is split at sentence boundaries and the
@@ -497,23 +519,10 @@ def _run_inference(
# correlated RNG artifacts across chunk boundaries.
if used_seed is not None:
torch.manual_seed(used_seed + i)
parts.append(model.generate(
text=chunk_text, language=language, ref_audio=ref_audio_path,
ref_text=ref_text, instruct=instruct, duration=None,
num_step=num_step, guidance_scale=guidance_scale, speed=speed,
denoise=denoise, postprocess_output=postprocess_output,
**kwargs
)[0])
parts.append(_gen(chunk_text, None)[0])
audio_out = concatenate_audio_chunks(parts, sr, _xfade_ms)
else:
audios = model.generate(
text=text, language=language, ref_audio=ref_audio_path,
ref_text=ref_text, instruct=instruct, duration=duration,
num_step=num_step, guidance_scale=guidance_scale, speed=speed,
denoise=denoise, postprocess_output=postprocess_output,
**kwargs
)
audio_out = audios[0]
audio_out = _gen(text, duration)[0]
# Apply DSP effect preset. The OmniVoice model never masters its own
# output, so mastering always runs here (unchanged behavior).
@@ -692,6 +701,25 @@ async def _finalize_generation(
logger.warning("history retention prune failed (non-fatal): %s", e)
event_bus.emit("generation_history", {"action": "created", "id": audio_id})
# Opt-in analytics (core/analytics.py): no-op unless the user turned it on.
# Metadata only — text_length is the LENGTH of the text, never the text; the
# allowlist in analytics.sanitize_properties() enforces that regardless.
try:
from core.analytics import capture as _ph
_ph("speech_generated", {
"mode": history_mode,
"language": language or "auto",
"duration_seconds": audio_dur,
"gen_time_seconds": gen_time,
"text_length": len(text or ""), # the LENGTH. never the text.
"has_profile": bool(resolved_profile_id),
})
except Exception: # noqa: BLE001 — analytics may never break a generation…
# …but it must not fail SILENTLY either: a typo'd variable here would
# otherwise mean the event simply never fires and nobody ever knows.
logger.warning("analytics: speech_generated capture failed", exc_info=True)
return audio_tensor, {
"id": audio_id,
"filename": audio_filename,
@@ -783,6 +811,27 @@ async def generate_speech(
),
)
# Single-active-engine memory discipline: hand back any OTHER resident TTS
# engine's model before loading this one, so switching engines (or a
# per-request engine= override, which bypasses /engines/select entirely)
# doesn't stack two multi-GB models in memory — the accumulation behind the
# 16 GB-Mac OOM deaths. No-op when nothing else is resident, so steady-state
# single-engine use pays nothing. Opt out: OMNIVOICE_SINGLE_ENGINE_RESIDENT=0.
from services.engine_memory import evict_other_tts_engines
await evict_other_tts_engines(engine_id)
# Non-blocking breadcrumb: if free memory is already low before this load,
# log it. A later OOM kill (the 16 GB-Mac class) then has a trail pointing
# at the load that tipped it, instead of a silent process death. Never
# blocks — the OS can reclaim cache, and a hard refuse would brick
# legitimate loads.
try:
from services.memory_budget import log_if_low
log_if_low(f"TTS load ({engine_id})")
except Exception:
pass
_model = None
_backend = None
if backend_cls is OmniVoiceBackend:
@@ -1068,12 +1117,15 @@ async def generate_speech(
if layer_penalty_factor is not None: kwargs["layer_penalty_factor"] = layer_penalty_factor
if position_temperature is not None: kwargs["position_temperature"] = position_temperature
if class_temperature is not None: kwargs["class_temperature"] = class_temperature
raw = _model.generate(
text=chunk_text, language=language, ref_audio=ref_audio_path,
ref_text=ref_text, instruct=instruct, duration=None,
num_step=num_step, guidance_scale=guidance_scale, speed=speed,
denoise=denoise, postprocess_output=postprocess_output,
**kwargs
# Same cached-reference path as _run_inference: chunk 0 encodes
# the reference, chunks 1..N hit the cache instead of re-encoding.
from services.tts_backend import generate_with_cached_ref
raw = generate_with_cached_ref(
_model, ref_audio=ref_audio_path, ref_text=ref_text,
text=chunk_text, language=language, instruct=instruct,
duration=None, num_step=num_step,
guidance_scale=guidance_scale, speed=speed, denoise=denoise,
postprocess_output=postprocess_output, **kwargs
)[0]
sr = _model.sampling_rate if hasattr(_model, "sampling_rate") else 24000
skip = False
@@ -1227,6 +1279,7 @@ async def generate_speech(
max_chunk_chars, crossfade_ms,
),
what="TTS generate",
timeout=_generate_timeout_s(text),
)
# Read after generation: engines with lazy model loading report
# their real rate only once weights are up.
@@ -1242,6 +1295,7 @@ async def generate_speech(
max_chunk_chars, crossfade_ms,
),
what="TTS generate",
timeout=_generate_timeout_s(text),
)
sample_rate = _model.sampling_rate
# Watermark → save → history → prune → emit, shared with the streaming
+3 -1
View File
@@ -16,6 +16,8 @@ import asyncio
import functools
import logging
import os
from utils.fsops import safe_replace
import time
import uuid
@@ -276,7 +278,7 @@ def _rename_for_new_id(written: list[str], new_id: str) -> list[str]:
new_base = new_id + base[8:]
new_path = os.path.join(d, new_base)
try:
os.replace(p, new_path)
safe_replace(p, new_path)
out.append(new_path)
except OSError:
out.append(p)
+8
View File
@@ -95,6 +95,14 @@ async def create_profile(
# rebuild the tags from vd_states — so the row is always generation-safe
# regardless of which frontend build saved it.
instruct = heal_design_instruct(instruct, parsed)
else:
# Clone-kind saves get the same server-side choke point (audit finding:
# this class — "Unsupported instruct items" 400s on every later use —
# recurred THREE times via clients that bypassed the frontend filter,
# and the save-time heal above was gated to design-kind). A clone
# profile has no vd_states to rebuild from, so this is sanitize-only:
# valid tags survive, prose/"[object Object]" is dropped.
instruct = sanitize_instruct(instruct)
profile_id = str(uuid.uuid4())[:8]
+31
View File
@@ -1001,3 +1001,34 @@ def get_db_backup_state():
"count": len(db_backup.list_backups(DB_PATH)),
"keep": db_backup.KEEP_BACKUPS,
}
# ── Opt-in product analytics (hardened; default OFF) ───────────────────────
# Local-first means silence is not consent: analytics runs only when the user
# explicitly turns it on AND the build ships a destination token. See
# core/analytics.py for the three rules (opt-in, no exception autocapture,
# allowlisted metadata only).
class _AnalyticsBody(BaseModel):
enabled: bool = Field(..., description="User's explicit choice. Default is OFF.")
@router.get("/analytics")
def get_analytics():
from core import analytics
return {
"enabled": analytics.enabled(),
"opted_in": analytics.user_opted_in(),
# False for source builds / any build with no token: analytics can never
# run, so the UI can say so instead of offering a toggle that does nothing.
"available": analytics.token_configured(),
}
@router.put("/analytics")
def set_analytics(body: _AnalyticsBody):
from core import analytics
analytics.set_opted_in(body.enabled)
return get_analytics()
+15
View File
@@ -1081,3 +1081,18 @@ async def tailscale_enable():
@router.post("/system/tailscale/disable")
async def tailscale_disable():
return _tailscale.serve_disable()
# ── Local-only usage insights (the alternative to cloud analytics) ──────────
# PostHog was proposed and rejected (PR #1110): a third-party telemetry endpoint
# breaks the product's headline promise — nothing leaves your machine. This
# answers the same question ("how am I using this?") by aggregating the history
# the app has ALREADY written to the user's own database. It collects nothing
# new, stores nothing new, and transmits nothing anywhere: the only consumer is
# the user's own UI over loopback. Read-only, content-free (counts and totals,
# never the text of a take).
@router.get("/stats/usage")
def stats_usage():
from services.local_stats import usage_summary
return usage_summary()
+6
View File
@@ -81,6 +81,11 @@ class IncrementalReq(BaseModel):
# scoped to that language (pass that language's stored hashes alongside);
# omitted → legacy language-agnostic hashing, kept for old callers.
lang: Optional[str] = None
# Voice-identity mode the client will generate with (DubRequest.voice_match).
# Only "consistent" changes the hash (per_line/omitted == legacy), so
# flipping the Voice-match toggle marks every segment stale — the audio
# really would come out with a different reference (#281 class).
voice_match: Optional[str] = None
@router.post("/tools/incremental")
@@ -89,6 +94,7 @@ def plan_incremental(req: IncrementalReq):
req.segments,
stored_hashes=req.stored_hashes or {},
track_lang=req.lang,
voice_match=req.voice_match,
)
+197
View File
@@ -0,0 +1,197 @@
"""Opt-in product analytics — hardened.
OmniVoice is local-first, so analytics here is held to a higher bar than the
usual SDK drop-in. Three rules, each enforced in code below and pinned by tests:
1. **Off unless the user says yes.** Two independent gates must BOTH be true:
a build-provided ``POSTHOG_PROJECT_TOKEN`` *and* the user's explicit
``analytics_enabled`` preference, which defaults to **False**. A default
install transmits nothing, so the product's promise holds out of the box.
``OMNIVOICE_ANALYTICS_DISABLED=1`` is a hard kill switch that outranks both.
2. **No exception autocapture, ever.** The obvious SDK default
(``enable_exception_autocapture=True``) ships raw tracebacks which carry
absolute paths (``/Users/<name>/``), and in this codebase can carry Hugging
Face tokens and model paths straight out of exception messages. That would
bypass ``core.failure.sanitize()``, the redaction this project already runs on
every error surface. It is explicitly disabled.
3. **Metadata only, enforced by allowlist.** Every event property is filtered
through ``_ALLOWED_PROPS``. A key that isn't on the list is *dropped*, not
trusted so no future caller can leak the text of a take, a file path, or a
voice name by adding a field. Counts, durations, ids of *engines* (not users),
and booleans are all that can get through.
The person id is a random UUID minted per installation. It is not derived from
hardware, hostname, username, or anything else identifying it exists only to
tell "same install" from "different install".
"""
from __future__ import annotations
import atexit
import logging
import os
import uuid
from typing import Any, Optional
logger = logging.getLogger("omnivoice.analytics")
_client = None
_client_key: Optional[str] = None # the (token, host) the live client was built for
_KILL_SWITCH = "OMNIVOICE_ANALYTICS_DISABLED"
_OFF_VALUES = {"1", "true", "yes", "on"}
#: The ONLY property keys that may leave this machine. Anything else is dropped.
#: Deliberately conservative: no free text, no paths, no names, no ids of user
#: content. Add here only after asking "could this ever hold something the user
#: typed, recorded, or named?" — if yes, it doesn't belong.
_ALLOWED_PROPS: frozenset[str] = frozenset({
"engine_id", # which TTS/ASR engine (our identifier, not the user's)
"language", # e.g. "en" / "auto"
"mode", # clone | design
"kind", # profile kind
"source", # upload | url
"input_type", # video | audio
"effect_preset",
"error_type", # exception CLASS name only — never the message
"duration_seconds",
"gen_time_seconds",
"text_length", # the LENGTH of the text. never the text.
"has_profile",
"stream",
"app_version",
"platform",
})
#: A string property longer than this is refused outright — a belt-and-braces
#: guard so a stray free-text value can't ride in on an allowlisted key.
_MAX_STR_LEN = 64
def _kill_switched() -> bool:
return (os.environ.get(_KILL_SWITCH, "") or "").strip().lower() in _OFF_VALUES
def user_opted_in() -> bool:
"""The user's explicit choice. Default **False** — silence is not consent."""
try:
from core import prefs
return bool(prefs.get("analytics_enabled", False))
except Exception: # noqa: BLE001 — a broken prefs file must not enable tracking
return False
def set_opted_in(enabled: bool) -> None:
"""Persist the user's choice and rebuild/tear down the client immediately, so
the toggle takes effect without a restart."""
from core import prefs
prefs.set_("analytics_enabled", bool(enabled))
if not enabled:
shutdown()
def token_configured() -> bool:
"""Whether this BUILD ships an analytics destination at all. When false,
analytics can never run no matter what the user chooses which is the case
for anyone building from source."""
return bool((os.environ.get("POSTHOG_PROJECT_TOKEN", "") or "").strip())
def enabled() -> bool:
"""The single source of truth: BOTH gates true, and not kill-switched."""
return (not _kill_switched()) and token_configured() and user_opted_in()
def _get_client():
"""Lazily build the client, but only while `enabled()`. Rebuilt if the token
or host changes; torn down the moment consent is withdrawn."""
global _client, _client_key
if not enabled():
if _client is not None:
shutdown()
return None
token = os.environ["POSTHOG_PROJECT_TOKEN"].strip()
host = (os.environ.get("POSTHOG_HOST") or "https://eu.i.posthog.com").strip()
key = f"{token}@{host}"
if _client is not None and _client_key == key:
return _client
try:
from posthog import Posthog
_client = Posthog(
token,
host=host,
# RULE 2. Tracebacks carry home paths and can carry HF tokens; they
# would bypass core.failure.sanitize() entirely. Never turn this on.
enable_exception_autocapture=False,
)
_client_key = key
atexit.register(shutdown)
logger.info("Analytics enabled by user opt-in (host=%s).", host)
except Exception as e: # noqa: BLE001 — analytics must never break the app
logger.warning("Analytics client unavailable: %s", e)
_client, _client_key = None, None
return _client
def shutdown() -> None:
"""Flush and drop the client. Safe to call repeatedly."""
global _client, _client_key
if _client is not None:
try:
_client.shutdown()
except Exception: # noqa: BLE001
logger.debug("analytics shutdown error (non-fatal)", exc_info=True)
_client, _client_key = None, None
def installation_id() -> str:
"""A random per-installation UUID. NOT derived from hardware, hostname, or
username it only distinguishes one install from another."""
from core import prefs
iid = prefs.get("installation_id")
if not iid:
iid = str(uuid.uuid4())
try:
prefs.set_("installation_id", iid)
except Exception: # noqa: BLE001
logger.debug("could not persist installation_id (non-fatal)", exc_info=True)
return str(iid)
def sanitize_properties(properties: Optional[dict]) -> dict:
"""RULE 3. Drop every key not on the allowlist, and refuse long strings.
Pure + exported so the guarantee is directly testable: this is what stops a
future caller from leaking a take's text, a file path, or a voice name."""
out: dict[str, Any] = {}
for k, v in (properties or {}).items():
if k not in _ALLOWED_PROPS:
continue
if isinstance(v, str) and len(v) > _MAX_STR_LEN:
continue
if isinstance(v, (str, int, float, bool)) or v is None:
out[k] = v
return out
def capture(event: str, properties: Optional[dict] = None) -> None:
"""Record one product event. A no-op unless the user opted in. Never raises."""
try:
client = _get_client()
if client is None:
return
client.capture(
event,
distinct_id=installation_id(),
properties=sanitize_properties(properties),
)
except Exception as e: # noqa: BLE001 — analytics may never break a feature
logger.debug("analytics capture failed (%s): %s", event, e)
+40
View File
@@ -106,4 +106,44 @@ def load_into_environ(path: Optional[str] = None) -> bool:
except ImportError:
return False
dotenv.load_dotenv(path, override=True)
_drop_invalid_path_keys()
return True
#: Path-valued keys this file can persist. A reinstall that skipped uninstall
#: inherits the old file unconditionally — including e.g. an OMNIVOICE_CACHE_DIR
#: pointing at an unplugged drive or a deleted folder. Exporting a dead path
#: sends every model download/lookup somewhere that cannot exist and the app
#: looks broken out of the box (the audit's "reinstall inherits stale durable
#: state" gap). Validate after load: a directory that exists or can be created
#: is honored; anything else is dropped for THIS run with a loud log line (the
#: file itself is left alone — plugging the drive back in restores the setting).
_PATH_KEYS = ("OMNIVOICE_CACHE_DIR", "OMNIVOICE_DATA_DIR")
def _drop_invalid_path_keys() -> None:
import logging
logger = logging.getLogger("omnivoice.user_env")
for key in _PATH_KEYS:
val = os.environ.get(key)
if not val:
continue
try:
os.makedirs(val, exist_ok=True)
# Existing-but-read-only (an external mount, a permissions accident)
# passes isdir yet fails on first real use — probe actual write
# capability, not just existence (review finding).
probe = os.path.join(val, f".omnivoice-write-probe-{os.getpid()}")
with open(probe, "w") as f:
f.write("ok")
os.remove(probe)
usable = True
except OSError:
usable = False
if not usable:
logger.warning(
"%s from the saved env file points at an unusable path (%s) — "
"ignoring it for this run and falling back to the default "
"location. Fix or clear it in Settings → Models.", key, val,
)
os.environ.pop(key, None)
+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.3.19"
_FALLBACK_VERSION = "0.3.22"
def _fallback_version() -> str:
+2 -1
View File
@@ -222,7 +222,8 @@ class _WindowsSafeRotatingFileHandler(RotatingFileHandler):
dfn = self.rotation_filename("%s.%d" % (self.baseFilename, i + 1))
if os.path.exists(sfn):
try:
os.replace(sfn, dfn)
from utils.fsops import safe_replace
safe_replace(sfn, dfn)
except OSError as e:
_log.warning("log rotation rename failed: %s", e)
dfn = self.rotation_filename(self.baseFilename + ".1")
+15
View File
@@ -110,6 +110,21 @@ class DubRequest(BaseModel):
# fields default server-side to fit_planner.FitParams values.
fit_options: Optional[FitOptions] = None
# Voice-identity control for auto-clone bindings (owner report: each dub
# line clones from a reference cut from ITS OWN source audio — great
# prosody match, but the voice identity drifts line to line, and
# heuristic-diarized jobs have no pooled speaker clones to anchor it).
# "per_line" — Wave 3.2 behaviour, DEFAULT: an `auto:` binding prefers
# this segment's own clip, per-speaker clone as fallback.
# "consistent" — ONE reference per speaker for the whole dub: the pooled
# per-speaker clone, or — when none exists (heuristic
# diarization skips speaker-clone extraction entirely) —
# a deterministic pick among that speaker's segment clips
# (longest clip ≥3 s, tie-break lowest segment id),
# reused for every segment. Explicit `auto-seg:` cross
# bindings still honour their clip.
voice_match: Optional[Literal["per_line", "consistent"]] = "per_line"
class TranslateSegment(BaseModel):
id: str
text: str
+226 -44
View File
@@ -27,7 +27,11 @@ import asyncio
import logging
import os
import re
import contextlib
import threading
import time
from utils.containment import contain_system_exit
from abc import ABC, abstractmethod
from collections import OrderedDict
from typing import Optional
@@ -161,7 +165,9 @@ async def run_transcribe_guarded(executor, fn, *, what: str = "ASR",
get the bound + actionable error.
"""
loop = asyncio.get_running_loop()
fut = loop.run_in_executor(executor, fn)
# Same SystemExit containment as the TTS pool (#1133 class): an ASR
# dependency written as a CLI must not be able to shut the backend down.
fut = loop.run_in_executor(executor, contain_system_exit(fn, what))
try:
result = await asyncio.wait_for(fut, timeout=timeout)
except asyncio.TimeoutError:
@@ -380,6 +386,93 @@ def _harden_speechbrain_lazy_imports() -> None:
logger.debug("speechbrain LazyModule guard hardened for cross-platform inspect.py check")
#: wav2vec2 aligners, keyed by (language, device). Shared across backends: the
#: aligner is independent of whatever produced the segments, so MLX (which
#: transcribes on the GPU) reuses exactly the aligner WhisperX would have used.
_ALIGN_CACHE: dict[tuple[str, str], object] = {}
#: Forced alignment is torch/wav2vec2 (not CTranslate2), so unlike Whisper itself
#: it *can* run on MPS — measured on an M2: 20.3 s vs 28.4 s for a 30 s chunk, with
#: byte-identical word timings. So MPS is preferred, but torchaudio's MPS coverage
#: is uneven across aligner models, and a failure here would silently cost us the
#: ±10-30 ms timing that lip-sync depends on. Hence: try MPS, fall back to **CPU**,
#: and only then give up and keep Whisper's own looser timestamps.
_ALIGN_DEVICE_ENV = "OMNIVOICE_ALIGN_DEVICE"
def load_align_model(language_code: str, device: str):
"""Lazy-load (and cache) the wav2vec2 aligner for a language.
Returns ``(model, metadata)``, or ``None`` when no aligner exists for the
language WhisperX bundles them for ~20 major languages only, and the
caller then keeps Whisper's own (looser) word timestamps."""
key = (language_code, device)
if key in _ALIGN_CACHE:
return _ALIGN_CACHE[key]
try:
import whisperx
model, metadata = whisperx.load_align_model(
language_code=language_code, device=device,
)
_ALIGN_CACHE[key] = (model, metadata)
except Exception as e: # noqa: BLE001 — missing aligner is normal, not fatal
logger.info(
"no wav2vec2 aligner for language=%r (%s); "
"falling back to Whisper's native word timestamps",
language_code, e,
)
_ALIGN_CACHE[key] = None
return _ALIGN_CACHE[key]
def forced_align(segments: list, audio, language_code: str, device: str | None = None) -> list:
"""Snap word boundaries to the audio with wav2vec2 forced alignment.
This is what buys the dub pipeline its ±10-30 ms word timing (vs Whisper's
own ±100-300 ms), and lip-sync quality depends on it. It takes *plain
segments*, so it is deliberately independent of which engine transcribed
them which is what lets the MLX backend transcribe on the GPU and still
get WhisperX-grade timing.
Returns the aligned segments, or the originals unchanged if alignment isn't
available (no aligner for the language, whisperx not installed, or the
alignment itself failed). Never raises: worse timing beats no transcript.
"""
if not segments:
return segments
pinned = device or os.environ.get(_ALIGN_DEVICE_ENV)
if pinned:
devices = [pinned]
elif _mps_available():
devices = ["mps", "cpu"] # fast path, then the always-works path
else:
devices = ["cpu"]
for i, dev in enumerate(devices):
align = load_align_model(language_code, dev)
if align is None:
return segments # no aligner for this language — not a device problem
model_a, metadata = align
try:
import whisperx
result = whisperx.align(
segments, model_a, metadata, audio, dev, return_char_alignments=False,
)
return result.get("segments", segments)
except Exception as e: # noqa: BLE001
last = i == len(devices) - 1
if last:
logger.warning(
"forced alignment failed on %s: %s — using native word timestamps", dev, e,
)
return segments
logger.info("forced alignment failed on %s (%s) — retrying on %s", dev, e, devices[i + 1])
return segments
class WhisperXBackend(ASRBackend):
id = "whisperx"
display_name = "WhisperX (faster-whisper + wav2vec2 forced alignment)"
@@ -746,23 +839,7 @@ class WhisperXBackend(ASRBackend):
"""Lazy-load the wav2vec2 alignment model for this language. WhisperX
bundles aligners for ~20 major languages; for the others we fall back
to faster-whisper's native word timestamps (already in result)."""
if language_code in self._align_cache:
return self._align_cache[language_code]
import whisperx
try:
model, metadata = whisperx.load_align_model(
language_code=language_code, device=self._device,
)
self._align_cache[language_code] = (model, metadata)
return model, metadata
except Exception as e:
logger.info(
"whisperx: no alignment model for language=%r (%s); "
"falling back to Whisper's native word timestamps",
language_code, e,
)
self._align_cache[language_code] = None
return None
return load_align_model(language_code, self._device)
def transcribe(self, audio_path: str, *, word_timestamps: bool = True) -> dict:
import whisperx # used for whisperx.align() below
@@ -990,7 +1067,12 @@ class FasterWhisperBackend(ASRBackend):
return out
def unload(self) -> None:
self._asr = None
# #memory: this cleared self._asr — an attribute FasterWhisperBackend
# never assigns — so the actual model in self._model was never freed and
# a warm faster-whisper stayed resident for the life of the process.
# Clear the real handle so the model is released.
self._model = None
self._asr = None # harmless if a subclass ever used it; keeps idempotence
import gc
gc.collect()
try:
@@ -1050,10 +1132,25 @@ class MLXWhisperBackend(ASRBackend):
path_or_hf_repo=self._model_name,
word_timestamps=word_timestamps,
)
# Forced alignment, same as WhisperX (#1127). On Apple Silicon this
# backend replaces WhisperX for dubbing — CTranslate2 has no Metal
# build, so WhisperX transcribes on the CPU while this runs the *same*
# whisper-large-v3 on the GPU. But lip-sync accuracy depends on
# wav2vec2 word boundaries, not just on being fast, so we keep them:
# Whisper's own timestamps are ±100-300 ms, the aligner's are ±10-30 ms.
# Degrades gracefully — a language with no aligner keeps MLX's native
# word timings rather than failing.
if word_timestamps and result.get("segments"):
result["segments"] = forced_align(
result["segments"],
_decode_audio_16k_mono(audio_path),
result.get("language", "en"),
)
# Normalise to the `chunks` shape the rest of the pipeline expects.
if "segments" in result and "chunks" not in result:
if "segments" in result:
result["chunks"] = [
{"text": seg["text"], "timestamp": (seg["start"], seg["end"])}
{"text": seg.get("text", ""),
"timestamp": (seg.get("start"), seg.get("end"))}
for seg in result["segments"]
]
return result
@@ -2070,35 +2167,50 @@ def _probe_available(cls) -> bool:
return False
def _auto_detect() -> str:
"""Pick the best available ASR engine for the current hardware.
def _mps_available() -> bool:
try:
import torch
Preference order:
1. whisperx faster-whisper transcription + wav2vec2 forced
alignment (±10-30 ms word timing). Best for the
dub pipeline because lip-sync quality depends on
word-boundary accuracy.
2. faster-whisper transcription only (no forced alignment). Slightly
looser word boundaries but strictly faster; safe
fallback when whisperx isn't installed.
3. mlx-whisper mac-ARM speedup if installed (~10-20% latency win
vs faster-whisper int8 on Apple Silicon for
large-v3). Optional; faster-whisper remains the
baseline so we don't diverge mac-only behaviour.
4. pytorch-whisper last resort; requires the TTS model to be loaded
so it can reuse `_asr_pipe`.
return bool(hasattr(torch.backends, "mps") and torch.backends.mps.is_available())
except Exception: # noqa: BLE001 — no torch / no MPS
return False
def _auto_detect() -> str:
"""Pick the best available ASR engine **for this hardware**.
The order used to be whisperx-first, unconditionally and that quietly cost
Apple Silicon users a 4.4x slowdown on every dub (#1127). WhisperX and
faster-whisper are CTranslate2, which has **no Metal backend**: on a Mac they
transcribe on the *CPU*, no matter what GPU is sitting there. Measured on an
M2, one 30 s dub chunk, whisper-large-v3: **90.4 s on WhisperX (CPU) vs 20.5 s
on MLX (GPU)** 3x slower than realtime, which is how a 16-minute video turned
into a ~48-minute transcribe and looked like a hang.
So the pick is device-aware:
1. mlx-whisper **Apple Silicon only.** Runs the *same* whisper-large-v3
on the GPU, and we layer WhisperX's wav2vec2 forced
alignment on top (see MLXWhisperBackend.transcribe), so
word timing and therefore lip-sync is unchanged.
Same model, same alignment, ~4x the speed.
2. whisperx everywhere else: faster-whisper + wav2vec2 forced
alignment (±10-30 ms word timing). On CUDA it uses the
GPU, so it remains the right default there.
3. faster-whisper transcription only (no forced alignment); safe fallback
when whisperx isn't installed.
4. pytorch-whisper last resort; requires the TTS model to be loaded so it
can reuse `_asr_pipe`.
Auto-detect only. An explicit ``OMNIVOICE_ASR_BACKEND`` or the ``asr_backend``
pref still wins, so anyone who pinned an engine keeps it.
"""
if _mps_available() and _probe_available(MLXWhisperBackend):
return "mlx-whisper"
if _probe_available(WhisperXBackend):
return "whisperx"
if _probe_available(FasterWhisperBackend):
return "faster-whisper"
try:
import torch
if hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
if _probe_available(MLXWhisperBackend):
return "mlx-whisper"
except Exception:
pass
return "pytorch-whisper"
@@ -2238,6 +2350,74 @@ _capture_backend_key: str | None = None
# check-then-build must be atomic to avoid two threads each building a model.
_capture_backend_lock = threading.Lock()
# ── Idle release of the warm capture/dictation ASR (#1101 class) ────────────
#
# The TTS model has always been idle-unloaded (model_manager.idle_worker), but
# the capture ASR singleton above was not: once you dictated even once, its
# model stayed resident for the life of the process. Measured on a 16 GB M2:
# the backend sits at ~6.2 GB idle — TTS 3.8 GB plus ~2 GB of warm ASR — while
# an actual generate costs only ~116 MB on top. That baseline, not any spike, is
# what pushes a 16 GB machine into memory pressure until the OS kills the
# backend mid-generate — the death behind #1076/#1092/#1093/#1101. Freeing
# 3.8 GB of TTS while silently holding 2 GB of ASR forever was the asymmetry.
#
# Reclaiming it costs a model re-warm on the next dictation (~1.4 s for
# mlx-whisper turbo) and only after a full idle timeout — the same bargain the
# TTS model already makes.
_capture_last_used: float = 0.0
# Live dictation streams hold the singleton for the WHOLE session while calling
# nothing that would refresh `_capture_last_used`, so a long session could have
# its model unloaded mid-sentence. A lease pins it for exactly that window.
_capture_leases: int = 0
def _touch_capture() -> None:
"""Mark the capture backend as used now (resets its idle clock)."""
global _capture_last_used
_capture_last_used = time.monotonic()
@contextlib.contextmanager
def capture_lease():
"""Pin the warm capture backend for the duration of a live session, so the
idle reaper can never unload the model out from under an open dictation
stream. Releasing the lease restarts the idle clock."""
global _capture_leases
with _capture_backend_lock:
_capture_leases += 1
try:
yield
finally:
with _capture_backend_lock:
_capture_leases = max(0, _capture_leases - 1)
_touch_capture()
def release_idle_capture_backend(idle_s: float, *, now: float | None = None) -> bool:
"""Unload the warm capture/dictation ASR once it has gone unused for
``idle_s`` seconds. Returns True when a model was actually released.
No-ops while a live session holds a lease, when nothing is loaded, or when
the model was used recently. Never raises a failed unload must not take
the idle worker down with it."""
global _capture_backend, _capture_backend_key
now = time.monotonic() if now is None else now
with _capture_backend_lock:
if _capture_backend is None or _capture_leases > 0:
return False
if now - _capture_last_used < idle_s:
return False
backend, _capture_backend, _capture_backend_key = _capture_backend, None, None
try:
backend.unload()
except Exception: # noqa: BLE001 — a stuck unload must not kill idle_worker
logger.warning("capture ASR unload failed", exc_info=True)
logger.info(
"Idle timeout reached. Unloading capture ASR (%s) to free memory.",
type(backend).__name__,
)
return True
def get_sherpa_dictation_backend(model_id: str) -> "SherpaDictationBackend":
"""Return a shared, warm-cached :class:`SherpaDictationBackend` for
@@ -2252,6 +2432,7 @@ def get_sherpa_dictation_backend(model_id: str) -> "SherpaDictationBackend":
:func:`get_capture_asr_backend`. Thread-safe: the recognizer is shared;
each session creates its own decode stream (see capture_ws)."""
global _capture_backend, _capture_backend_key
_touch_capture() # any handout resets the idle clock
with _capture_backend_lock:
if (isinstance(_capture_backend, SherpaDictationBackend)
and _capture_backend_key == model_id):
@@ -2299,6 +2480,7 @@ def get_capture_asr_backend() -> ASRBackend:
"""
global _capture_backend, _capture_backend_key
_touch_capture() # any handout resets the idle clock (#1101 class)
# Atomic resolve+build so the preload thread and a WS session (which may
# call get_sherpa_dictation_backend concurrently) can't both build a model.
with _capture_backend_lock:
+27
View File
@@ -22,9 +22,36 @@ ingestion, the streaming synth job + UI are deferred follow-ups.
from __future__ import annotations
import zlib
from dataclasses import dataclass, field
from typing import Callable, Optional
def segment_seed(base_seed: int, text: str) -> int:
"""Deterministic RNG seed for one longform synthesis call (#1139).
A voice profile's pinned ``seed`` (locked takes, design profiles) makes
``/generate`` reproducible, but the longform path used to fetch the seed
and never apply it book renders were unseeded, so a profile pinned for
consistency still drifted between fresh renders. Deriving the per-call
seed from ``base_seed`` + a CRC of the chunk text mirrors ``/generate``'s
per-chunk decorrelation (``used_seed + i``) while staying order- and
cache-independent: a partially cached chapter re-renders its missing
segments with the exact seeds a full render would have used. Pure
torch-free so the router's synth wrappers stay unit-testable.
Text-keyed on purpose: identical repeated lines get identical takes.
That is already the longform pipeline's shipped semantic — the
content-addressed SegmentCache (longform_render.segment_cache_key hashes
text + voice sig, not position) replays one WAV for every identical span
and it only applies when the user pinned a seed, i.e. asked for
reproducibility. Position-based keys would break it: inserting one
paragraph would shift every later span's seed, so a partial re-render
after an edit would no longer match the original render.
"""
return (int(base_seed) + zlib.crc32(text.encode("utf-8"))) % (2**31)
@dataclass
class Span:
"""One contiguous run of text in a single voice, plus trailing silence.
+45 -2
View File
@@ -135,6 +135,19 @@ def find_cached_job(content_hash: str, exclude_job_id: str) -> Optional[dict]:
vocals = job.get("vocals_path") or os.path.join(cached_dir, "vocals.wav")
if not os.path.isfile(vocals):
continue
# Separation-quality gate: stems produced before the HQ-extraction
# change were separated from the 16 kHz MONO ASR file — a mono,
# 8 kHz-ceiling music bed. audio_hq.wav in the cached job dir is the
# marker that its stems came from the full-quality stereo extraction;
# without it, reusing the cache would silently keep serving the
# narrow-band mono bed forever for that video. Re-separating once is
# the better deal.
if not os.path.isfile(os.path.join(cached_dir, "audio_hq.wav")):
logger.info(
"cache candidate %s has pre-HQ (mono/16k-derived) stems — "
"skipping reuse so separation reruns at full quality", row["id"],
)
continue
return {
"job_dir": cached_dir,
"job_id": row["id"],
@@ -827,6 +840,31 @@ async def ingest_pipeline(
if p.returncode != 0:
msg = (stderr.decode(errors="replace") or f"ffmpeg returned exit code {p.returncode}").strip()[:500]
raise Exception(msg)
# Second, FULL-QUALITY extraction for source separation. audio.wav
# is deliberately 16 kHz mono — that's what ASR wants — but Demucs
# used to separate that same file, so the music bed inherited mono
# (stereo image destroyed: L/R correlation 1.000 vs the original's
# 0.754, measured) and an 8 kHz ceiling (nothing real above half
# the ASR rate — the bed's "muffled" sound at its source). Demucs
# resamples to 44.1 kHz internally either way, so separating the
# stereo original costs about the same and returns a true-stereo,
# full-band bed. Best-effort: on failure Demucs falls back to the
# ASR file, which is exactly the old behavior.
audio_hq_path = os.path.join(job_dir, "audio_hq.wav")
try:
p_hq, _, stderr_hq = await run_proc([
ffmpeg, "-i", video_path, "-vn", "-acodec", "pcm_s16le",
"-ar", "44100", "-ac", "2", audio_hq_path, "-y",
])
if p_hq.returncode != 0 or not os.path.exists(audio_hq_path):
logger.warning(
"HQ audio extraction failed (rc=%s) — separation falls "
"back to the 16k mono ASR file", p_hq.returncode,
)
audio_hq_path = None
except Exception as e_hq: # noqa: BLE001 — quality upgrade, never fatal
logger.warning("HQ audio extraction errored (%s) — falling back", e_hq)
audio_hq_path = None
except asyncio.CancelledError:
raise
except Exception as e:
@@ -914,7 +952,7 @@ async def ingest_pipeline(
try:
demucs_cmd = [sys.executable, "-m", "demucs.separate",
"--two-stems", "vocals", "-n", "htdemucs", "-d", get_best_device(),
audio_path, "-o", job_dir]
audio_hq_path or audio_path, "-o", job_dir]
rc = -1
stderr_full = b""
last_pct = -1
@@ -934,7 +972,12 @@ async def ingest_pipeline(
rc, stderr_full = evt[1], evt[2]
if rc != 0:
raise Exception(stderr_full.decode(errors="replace")[:500])
demucs_out = os.path.join(job_dir, "htdemucs", "audio")
# Stems land under the INPUT's basename ("audio_hq" when the
# full-quality extraction succeeded, "audio" on its fallback).
demucs_out = os.path.join(
job_dir, "htdemucs",
os.path.splitext(os.path.basename(audio_hq_path or audio_path))[0],
)
if os.path.exists(os.path.join(demucs_out, "vocals.wav")):
shutil.move(os.path.join(demucs_out, "vocals.wav"), vocals_path)
shutil.move(os.path.join(demucs_out, "no_vocals.wav"), no_vocals_path)
+99
View File
@@ -0,0 +1,99 @@
"""Single-active-TTS-engine memory discipline.
Only one TTS engine's model stays resident at a time. When the generate path
resolves an engine, every *other* resident engine is unloaded first so the
previous engine's model is handed back instead of stacking in memory until GC.
Why this matters (measured on a 16 GB M2): a generate on ``omnivoice`` leaves
its ~2.8 GB core model resident; a subsequent generate on ``mlx-audio`` loaded
that engine's model **on top** (footprint 3.9 GB → 4.3 GB, both resident),
because the two live in different caches with no coordination the core in
``model_manager.model``, the rest in ``engines._ENGINE_INSTANCES`` (which was
never unloaded). That accumulation is the baseline that pushes a 16 GB machine
into the memory pressure behind the "Can't reach the local backend" OOM deaths.
Default on. Opt out with ``OMNIVOICE_SINGLE_ENGINE_RESIDENT=0`` on machines with
RAM to spare (keeping several engines warm avoids the reload latency on an A/B
switch ~8 s for the OmniVoice core, ~12 s for the lighter engines).
"""
from __future__ import annotations
import logging
import os
logger = logging.getLogger("omnivoice.engine_memory")
_OFF = {"0", "false", "no", "off"}
def single_engine_resident() -> bool:
"""Whether the one-engine-at-a-time policy is active (default True)."""
return (os.environ.get("OMNIVOICE_SINGLE_ENGINE_RESIDENT", "1").strip().lower()
not in _OFF)
def _evict_instance_cache(keep_cls) -> list[str]:
"""Unload + drop every cached engine instance except ``keep_cls``.
Operates on the per-request instance cache the generate path shares with the
engine health route (``engines._ENGINE_INSTANCES``). Each engine's
``unload()`` frees its heavy model (the ABC default clears ``_MODEL_ATTRS``
and empties the device cache; subprocess engines reap their sidecar). Never
raises a stuck unload must not block the generation that triggered it."""
evicted: list[str] = []
try:
from api.routers.engines import _ENGINE_INSTANCES
except Exception: # pragma: no cover — router import should always succeed
return evicted
for cls, inst in list(_ENGINE_INSTANCES.items()):
if cls is keep_cls:
continue
try:
inst.unload()
except Exception: # noqa: BLE001
logger.warning("evict: %s.unload() failed", getattr(cls, "id", cls.__name__),
exc_info=True)
_ENGINE_INSTANCES.pop(cls, None)
evicted.append(getattr(cls, "id", cls.__name__))
return evicted
async def evict_other_tts_engines(keep_id: str) -> list[str]:
"""Unload every resident TTS engine except ``keep_id`` and return their ids.
Spans both stores a TTS model can live in: the OmniVoice core singleton
(``model_manager.model``, freed under its async lock when we're switching
*away* from it) and the generic engine instance cache. A no-op when the
policy is off or nothing else is resident, so steady-state single-engine use
pays nothing only an actual switch evicts. Never raises."""
if not single_engine_resident():
return []
evicted: list[str] = []
# The OmniVoice core singleton — only when the incoming engine isn't it.
if keep_id != "omnivoice":
try:
import services.model_manager as mm
async with mm._model_lock:
if mm.model is not None:
mm.model = None
mm.free_vram()
evicted.append("omnivoice")
except Exception: # noqa: BLE001
logger.warning("evict: OmniVoice core unload failed", exc_info=True)
# Every other in-process / sidecar engine instance.
keep_cls = None
try:
from services.tts_backend import get_backend_class
keep_cls = get_backend_class(keep_id)
except Exception: # noqa: BLE001 — unknown id → evict all cached instances
keep_cls = None
evicted.extend(_evict_instance_cache(keep_cls))
if evicted:
logger.info("single-engine eviction: freed %s (keeping %s)", evicted, keep_id)
return evicted
+110
View File
@@ -15,6 +15,116 @@ logger = logging.getLogger("omnivoice.api")
_FFMPEG_SEMAPHORE: "asyncio.Semaphore | None" = None
_FFMPEG_CONCURRENCY = 2
# ── Background-bed mixing (dub voice over the separated no_vocals stem) ──────
#
# Every dub export mixes the synthesized voice track over the original video's
# separated background (music/ambience). Two fidelity bugs lived in the old
# per-site `amix` strings, and they are exactly what "the background music
# doesn't sound like the original" reports describe:
#
# 1. LEVEL — `amix` NORMALIZES: each input is scaled by weight/sum(weights).
# The old `weights=0.8 1.2` therefore played the music bed at 40% of its
# original level (8 dB) and the voice at 60%. (batch.py was worse still:
# an explicit volume=0.15 plus amix's ÷2 left the bed at 7.5%.) We keep
# amix for its duration/dropout semantics but multiply the mix by
# sum(weights) afterwards, which cancels the normalization exactly — the
# weights below ARE the absolute gains.
# 2. BANDWIDTH — the voice track is synthesized at 24 kHz and amix
# negotiates one common rate, so the 44.1/48 kHz bed was silently
# downsampled to 24 kHz: everything above 12 kHz (cymbals, air,
# brightness) vanished from the music. Both inputs are now explicitly
# resampled to 48 kHz before the mix, so the bed keeps its top end.
#
# Bed at 0.9 dB (0.9×) keeps the music essentially at the original level
# while letting dialogue sit just above it; the limiter transparently catches
# the rare summed peak that now can exceed full scale (the old normalization
# made clipping impossible by making everything quiet).
BED_MIX_SAMPLE_RATE = 48000
BED_GAIN = 0.9
VOICE_GAIN = 1.1
# Whether the resolved ffmpeg's amix supports `normalize` (added in 5.x).
# Probed once per process; None = not probed yet.
_AMIX_NORMALIZE: "bool | None" = None
def _amix_supports_normalize() -> bool:
"""True when the resolved ffmpeg's ``amix`` accepts ``normalize=0``.
Matters because amix's normalization is DYNAMIC: it rescales whenever an
input ends. A constant post-mix compensation is therefore only exact while
both streams are active after the (usually marginally shorter) voice
stream ends, the bed's internal scale jumps from w/sum to 1.0 and a fixed
multiply would BOOST the tail music into the limiter. ``normalize=0``
turns amix into a plain sum, immune to stream-end rescaling. Old system
ffmpegs (<5) lack the option and would reject the whole graph, so probe
once and fall back to the compensated form there (its tail quirk is the
lesser evil next to a failed export).
"""
global _AMIX_NORMALIZE
if _AMIX_NORMALIZE is None:
supported = False
try:
ff = find_ffmpeg()
if ff:
res = subprocess.run(
[ff, "-hide_banner", "-h", "filter=amix"],
capture_output=True, timeout=10, check=False,
)
supported = b"normalize" in (res.stdout or b"")
except Exception as e: # noqa: BLE001 — a probe failure must not break exports
logger.debug("amix normalize probe failed: %s", e)
_AMIX_NORMALIZE = supported
return _AMIX_NORMALIZE
def bed_mix_filter(
bed_in: str,
voice_in: str,
*,
out: str = "aout",
duration: str = "longest",
tail: str = "",
uniq: str = "",
) -> str:
"""One ffmpeg filter chain mixing `voice_in` over `bed_in` at original level.
`bed_in`/`voice_in` are filtergraph input labels ("0:a", "1:a", ); `out`
is the output label (without brackets). `tail` appends extra filters after
the gain stage (e.g. ",apad=whole_dur=…"). `uniq` disambiguates internal
labels when several chains share one filtergraph.
"""
b, v = f"bmb{uniq}", f"bmv{uniq}"
# Both legs are forced to STEREO before amix. The synthesized voice is
# mono, and amix negotiates one common layout for all inputs — without
# this, the negotiation collapsed the stereo music bed to mono (measured
# on a real dub: L/R correlation 1.000 vs the original's 0.754 — the
# entire stereo image gone). Upmixing the mono voice duplicates it into
# both channels (dead center, where dubbed dialogue belongs) so the bed
# keeps its width.
stereo = "aformat=channel_layouts=stereo"
if _amix_supports_normalize():
# Gains applied per input, amix reduced to a plain sum: levels are
# exact for the whole timeline, including after either stream ends.
return (
f"[{bed_in}]aresample={BED_MIX_SAMPLE_RATE},{stereo},volume={BED_GAIN:g}[{b}];"
f"[{voice_in}]aresample={BED_MIX_SAMPLE_RATE},{stereo},volume={VOICE_GAIN:g}[{v}];"
f"[{b}][{v}]amix=inputs=2:duration={duration}:dropout_transition=2:"
f"normalize=0,alimiter=level=false:limit=0.98{tail}[{out}]"
)
# Legacy ffmpeg (<5, no `normalize`): cancel amix's normalization with a
# compensating multiply. Exact while both streams run; if one ends early
# the tail is over-boosted into the limiter until the graph ends — a known
# quirk accepted only on old ffmpeg, where the alternative is no export.
total = BED_GAIN + VOICE_GAIN
return (
f"[{bed_in}]aresample={BED_MIX_SAMPLE_RATE},{stereo}[{b}];"
f"[{voice_in}]aresample={BED_MIX_SAMPLE_RATE},{stereo}[{v}];"
f"[{b}][{v}]amix=inputs=2:duration={duration}:dropout_transition=2:"
f"weights={BED_GAIN:g} {VOICE_GAIN:g},volume={total:g},"
f"alimiter=level=false:limit=0.98{tail}[{out}]"
)
def _get_semaphore() -> asyncio.Semaphore:
global _FFMPEG_SEMAPHORE
+21 -1
View File
@@ -45,6 +45,16 @@ from dataclasses import dataclass, field
# garbled stream no DSP can rescue.
MAX_AUDIO_RATE_HARD = 1.8
# Underrun fill: a dubbed line that finishes well before its slot leaves a
# hole — on screen the mouth keeps moving while the dub has gone quiet, and
# what the listener hears in the hole is the thin under-speech residue of the
# separated background (measured at ~37% of the original's energy), which
# reads as dead air. Translations routinely run shorter than the source
# delivery (measured live: 8.8s of holes across 18.7s of speech), so this is
# the common case, not a corner. Slots filled to within this fraction are
# left alone — a <5% hole is imperceptible and not worth an ffmpeg pass.
UNDERRUN_TOLERANCE = 0.95
_EPS = 1e-9
@@ -61,6 +71,10 @@ class FitParams:
video_slow_cap: float = 2.0
gap_guard_s: float = 0.05
allow_video_retime: bool = True
# Underrun fill floor: a segment shorter than its slot is slowed toward it
# (pitch-preserving), never below this rate — 0.85× stays comfortably
# natural-sounding. 1.0 disables the fill entirely.
min_audio_rate: float = 0.85
@dataclass
@@ -68,7 +82,7 @@ class SegmentFit:
"""Planner verdict for one segment."""
index: int
seg_id: str
audio_rate: float # ≥ 1.0 — pitch-preserving speed-up applied to TTS audio
audio_rate: float # pitch-preserving rate: >1 speeds up (fit), <1 slows down (fill)
video_ratio: float # ≥ 1.0 — setpts slow-down applied to the video chunk
new_start: float # placement on the fitted (possibly longer) timeline
new_end: float # end of the video chunk on the fitted timeline
@@ -97,6 +111,12 @@ class FitPlan:
def _fit_one(need: float, params: FitParams) -> tuple[float, float, str]:
"""Resolve one segment's need ratio into (audio_rate, video_ratio, status)."""
if need <= 1.0 + _EPS:
# Underrun fill: slow the audio toward the slot so the dub keeps
# speaking while the on-screen mouth does. Bounded by min_audio_rate;
# near-full slots (within UNDERRUN_TOLERANCE) and degenerate needs
# (empty audio) stay untouched.
if need > _EPS and need < UNDERRUN_TOLERANCE and params.min_audio_rate < 1.0 - _EPS:
return max(need, params.min_audio_rate), 1.0, "audio_slowed"
return 1.0, 1.0, "fits"
if need <= params.max_audio_only_rate + _EPS:
return need, 1.0, "audio_stretched"
+20 -2
View File
@@ -49,7 +49,9 @@ def _canon_value(field: str, value):
return value
def segment_fingerprint(seg: dict, track_lang: str | None = None) -> str:
def segment_fingerprint(
seg: dict, track_lang: str | None = None, voice_match: str | None = None
) -> str:
"""Deterministic hash of the inputs that actually affect TTS output.
Any change to `_GEN_INPUT_FIELDS` flips the hash and the segment becomes
@@ -71,10 +73,21 @@ def segment_fingerprint(seg: dict, track_lang: str | None = None) -> str:
a legacy hash therefore never matches a lang-scoped fingerprint and the
segment reads as stale the safe direction (one clean regen, never a
wrong-language splice).
``voice_match`` is the job-level voice-identity mode (DubRequest.voice_match).
"consistent" resolves `auto:`/default `auto-seg:` bindings to a different
reference than "per_line" does, so audio rendered under one mode must not
vouch for the other flipping the toggle has to mark segments stale, or
"Regen changed" would splice mixed-identity voices (#281 class). Same
back-compat trick as ``track_lang``: only mixed in when NON-DEFAULT, so
every hash stored by previous builds (and by per_line runs) keeps its
value and per_line stays byte-identical to the pre-toggle behaviour.
"""
payload = {k: _canon_value(k, seg.get(k)) for k in _GEN_INPUT_FIELDS}
if track_lang:
payload["track_lang"] = str(track_lang)
if voice_match and voice_match != "per_line":
payload["voice_match"] = str(voice_match)
blob = json.dumps(payload, sort_keys=True, ensure_ascii=False)
return hashlib.sha1(blob.encode("utf-8"), usedforsecurity=False).hexdigest()[:16]
@@ -133,6 +146,7 @@ def plan_incremental(
*,
stored_hashes: dict[str, str] | None = None,
track_lang: str | None = None,
voice_match: str | None = None,
) -> dict:
"""Return `{stale, fresh, total, fingerprints}` where:
@@ -153,6 +167,10 @@ def plan_incremental(
the active track, never against whatever language was generated last.
Must match the language the generate run hashed with, or every segment
reads stale (#281 parity class).
`voice_match` must likewise match the mode the generate run hashed with
(send the store's current voice-match mode); omitted/`"per_line"` hashes
identically to legacy calls.
"""
stored = stored_hashes or {}
stale: list[str] = []
@@ -162,7 +180,7 @@ def plan_incremental(
sid = str(seg.get("id", ""))
if not sid:
continue
fp = segment_fingerprint(seg, track_lang=track_lang)
fp = segment_fingerprint(seg, track_lang=track_lang, voice_match=voice_match)
fingerprints[sid] = fp
prev = stored.get(sid)
if prev == fp:
+125
View File
@@ -0,0 +1,125 @@
"""Local-only usage insights — the privacy-preserving answer to "how am I using this?".
The alternative to cloud analytics (PostHog was proposed and rejected, PR #1110):
this collects **nothing new** and transmits **nothing anywhere**. It simply
aggregates the rows the app has *already* written to the user's own SQLite
database in the course of doing its job generation history, voice profiles,
dubs, exports and hands back counts and totals for the user's own eyes.
Design rules, so this can never become telemetry by accident:
- **Read-only.** No new tables, no new columns, no new event stream. If the
feature were deleted tomorrow, not one byte of stored data would change.
- **No content.** Only aggregates (counts, sums, distributions over engine and
language). The `text` column of a take is never read, never returned. Nothing
here identifies a person, a file path, or what was said.
- **No network.** There is no client, no endpoint, no token. The data reaches
exactly one place: the local HTTP response to the user's own UI.
That keeps the product's headline promise intact — *nothing leaves your
machine* while still answering the question analytics was meant to answer.
"""
from __future__ import annotations
import logging
import time
from typing import Any
from core.db import db_conn
logger = logging.getLogger("omnivoice.local_stats")
def _scalar(conn, sql: str, default: Any = 0) -> Any:
"""One aggregate value, or `default` when the table/column doesn't exist yet
(a fresh install, or a DB predating a migration). Never raises an insights
panel must not 500 because one table is missing."""
try:
row = conn.execute(sql).fetchone()
except Exception: # noqa: BLE001 — missing table/column on an older DB
return default
if not row or row[0] is None:
return default
return row[0]
def _distribution(conn, sql: str) -> list[dict]:
"""`[{"name": …, "count": n}, …]`, biggest first. Empty on any error."""
try:
rows = conn.execute(sql).fetchall()
except Exception: # noqa: BLE001
return []
out = []
for r in rows:
name = r[0]
if name is None or str(name).strip() == "":
name = "unknown"
out.append({"name": str(name), "count": int(r[1])})
return out
def usage_summary() -> dict:
"""Aggregate the user's own local history. Never raises.
Returns counts/totals only no text, no paths, no identifiers. Safe to
render, safe to ignore, and impossible to turn into telemetry: it has no
way to send anything anywhere."""
with db_conn() as conn:
takes = int(_scalar(conn, "SELECT COUNT(*) FROM generation_history"))
audio_seconds = float(
_scalar(conn, "SELECT SUM(duration_seconds) FROM generation_history", 0.0)
)
compute_seconds = float(
_scalar(conn, "SELECT SUM(generation_time) FROM generation_history", 0.0)
)
starred = int(
_scalar(conn, "SELECT COUNT(*) FROM generation_history WHERE COALESCE(starred,0)=1")
)
first_at = _scalar(conn, "SELECT MIN(created_at) FROM generation_history", None)
last_at = _scalar(conn, "SELECT MAX(created_at) FROM generation_history", None)
by_mode = _distribution(
conn,
"SELECT mode, COUNT(*) FROM generation_history "
"GROUP BY mode ORDER BY COUNT(*) DESC",
)
by_language = _distribution(
conn,
"SELECT language, COUNT(*) FROM generation_history "
"GROUP BY language ORDER BY COUNT(*) DESC LIMIT 12",
)
voices = int(_scalar(conn, "SELECT COUNT(*) FROM voice_profiles"))
dubs = int(_scalar(conn, "SELECT COUNT(*) FROM dub_history"))
projects = int(_scalar(conn, "SELECT COUNT(*) FROM studio_projects"))
exports = int(_scalar(conn, "SELECT COUNT(*) FROM export_history"))
# Distinct local days with at least one take — an honest "how often do I
# actually use this", without storing or transmitting a usage timeline.
active_days = int(
_scalar(
conn,
"SELECT COUNT(DISTINCT DATE(created_at, 'unixepoch', 'localtime')) "
"FROM generation_history",
)
)
return {
"takes": takes,
"starred": starred,
"audio_seconds": round(audio_seconds, 1),
"compute_seconds": round(compute_seconds, 1),
"active_days": active_days,
"first_at": first_at,
"last_at": last_at,
"by_mode": by_mode,
"by_language": by_language,
"voices": voices,
"dubs": dubs,
"projects": projects,
"exports": exports,
# Stated in the payload itself so the guarantee travels with the data
# and any future consumer sees it.
"local_only": True,
"generated_at": time.time(),
}
+94
View File
@@ -0,0 +1,94 @@
"""Free-memory probe + a non-blocking low-memory advisory.
The device-caps probe (core.device_caps) reports *total* memory, resolved once
per process. Load decisions need *free* memory at the moment of loading and on
Apple Silicon the number that matters is free **system RAM**, because MPS uses
unified memory (there is no separate VRAM pool). This module fills that gap.
Deliberately advisory, never blocking: a hard "refuse to load" on an estimate
would brick legitimate loads on machines that would actually cope (the estimate
can't know a model's true resident size ahead of time, and the OS can reclaim
cache under pressure). Instead it surfaces a warning so the UI and logs can say
"you're low on memory" and the single-active-engine eviction
(services.engine_memory) is what actually reclaims room before a load.
Stdlib + psutil (already a runtime dep). Never raises.
"""
from __future__ import annotations
import logging
import os
from typing import Optional
logger = logging.getLogger("omnivoice.memory_budget")
# Below this much free RAM, a heavy model load is at real risk of tipping the
# machine into the OOM-kill territory behind the 16 GB-Mac "Can't reach the
# backend" reports. Tunable for smaller/larger boxes.
_LOW_RAM_HEADROOM_GB = float(os.environ.get("OMNIVOICE_LOW_MEMORY_HEADROOM_GB", "2.0"))
def available_memory() -> dict:
"""Free/total memory right now. Never raises; fields absent when unknown.
Always includes system RAM (``ram_available_gb`` / ``ram_total_gb``). On a
CUDA/ROCm host also includes GPU VRAM (``vram_free_gb`` / ``vram_total_gb``)
from ``torch.cuda.mem_get_info``. On MPS the relevant figure is system RAM
(unified memory), so no separate VRAM fields are reported."""
out: dict = {}
try:
import psutil
vm = psutil.virtual_memory()
out["ram_available_gb"] = round(vm.available / (1024 ** 3), 2)
out["ram_total_gb"] = round(vm.total / (1024 ** 3), 2)
except Exception: # noqa: BLE001 — psutil missing/failed: RAM unknown, not fatal
pass
try:
torch = __import__("torch")
if torch.cuda.is_available():
free, total = torch.cuda.mem_get_info()
out["vram_free_gb"] = round(free / (1024 ** 3), 2)
out["vram_total_gb"] = round(total / (1024 ** 3), 2)
except Exception: # noqa: BLE001 — no CUDA / probe failed
pass
return out
def low_memory_warning(headroom_gb: float = _LOW_RAM_HEADROOM_GB) -> Optional[str]:
"""A one-line advisory when free memory is below ``headroom_gb``, else None.
Checks free VRAM on a dedicated-GPU host, otherwise free system RAM (the
figure that matters on MPS/CPU). Pure given ``available_memory`` output
``_format`` does the wording so the threshold logic is unit-testable."""
return _format(available_memory(), headroom_gb)
def _format(mem: dict, headroom_gb: float) -> Optional[str]:
vram = mem.get("vram_free_gb")
if vram is not None:
if vram < headroom_gb:
return (
f"Low GPU memory: {vram:.1f} GB free. Loading another model may "
"run out of VRAM — unload one you're not using (Settings → "
"Models), or switch to a smaller engine."
)
return None
ram = mem.get("ram_available_gb")
if ram is not None and ram < headroom_gb:
return (
f"Low memory: {ram:.1f} GB free. Loading a large model here risks the "
"backend being killed by the OS — close some apps, or unload a model "
"you're not using (Settings → Models)."
)
return None
def log_if_low(context: str, headroom_gb: float = _LOW_RAM_HEADROOM_GB) -> Optional[str]:
"""Log (once, at WARNING) and return the advisory when memory is low before
a heavy operation named by ``context``. Non-blocking the caller proceeds
regardless; this is forensics, so a later OOM death has a breadcrumb."""
msg = low_memory_warning(headroom_gb)
if msg:
logger.warning("%s: %s", context, msg)
return msg
+64 -1
View File
@@ -133,7 +133,70 @@ def list_loaded() -> dict:
except Exception:
pass
return {"models": models, "count": len(models)}
# 5. In-process engine instances that hold a model (mlx-audio, cosyvoice,
# voxcpm2, kittentts, …). These live in the generate path's instance
# cache, separate from the OmniVoice core above — and were INVISIBLE here
# until now, so a resident non-OmniVoice engine (up to a few GB) didn't
# show in the panel at all. Report each that currently holds a model.
# VRAM isn't self-reported by these engines → 0 (unmeasured), same
# convention as a CPU/uninstrumented sidecar. Enumeration is best-effort.
try:
from api.routers.engines import _ENGINE_INSTANCES
from services.tts_backend import OmniVoiceBackend
for cls, inst in list(_ENGINE_INSTANCES.items()):
if cls is OmniVoiceBackend:
continue # the shared core is already section 1 (mm.model)
if not any(getattr(inst, a, None) is not None
for a in getattr(inst, "_MODEL_ATTRS", ("_model", "_tts"))):
continue # instance exists but hasn't loaded its weights
eid = getattr(cls, "id", cls.__name__)
models.append({
"id": f"engine:{eid}",
"name": getattr(inst, "display_name", None) or f"{eid} (engine)",
"checkpoint": eid,
"device": get_best_device(),
"vram_mb": 0, # not self-reported by in-process engines
"unloadable": True,
**_tts_attribution(eid, active_tts),
})
except Exception:
pass
# 6. The warm capture/dictation ASR singleton — resident until idle-released
# (#1101 class). Held separately from the co-loaded WhisperX ASR above.
try:
import services.asr_backend as ab
cap = getattr(ab, "_capture_backend", None)
if cap is not None:
models.append({
"id": "capture-asr",
"name": f"{type(cap).__name__} (dictation)",
"checkpoint": getattr(ab, "_capture_backend_key", None) or type(cap).__name__,
"device": get_best_device(),
"vram_mb": 0,
"unloadable": True,
"note": "released after the idle timeout",
})
except Exception:
pass
# System memory snapshot — free/total RAM (and VRAM on a dedicated GPU) plus
# a low-memory advisory, so the panel can show pressure instead of leaving
# the 16 GB-Mac OOM class invisible until the backend dies.
system: dict = {}
try:
from services.memory_budget import available_memory, low_memory_warning
system = available_memory()
warn = low_memory_warning()
if warn:
system["warning"] = warn
except Exception:
pass
return {"models": models, "count": len(models), "system": system}
async def unload(model_id: str) -> dict:
+147 -3
View File
@@ -1,10 +1,13 @@
import os
import sys
import time
import asyncio
import logging
import threading
from concurrent.futures import ThreadPoolExecutor, Executor
from utils.containment import contain_system_exit
# ── Lazy imports ─────────────────────────────────────────────────────
# torch and OmniVoice are heavy (~2-3s import on Apple Silicon).
# Deferring them until first use cuts cold start from ~4s to ~1.5s,
@@ -235,7 +238,7 @@ async def run_on_gpu_pool_guarded(fn, *, what: str = "GPU job",
"""
loop = asyncio.get_running_loop()
ex = executor if executor is not None else _get_gpu_pool()
fut = loop.run_in_executor(ex, fn)
fut = loop.run_in_executor(ex, contain_system_exit(fn, what))
try:
return await asyncio.wait_for(fut, timeout=timeout)
except asyncio.TimeoutError:
@@ -1124,7 +1127,13 @@ async def _load_model_with_timeout():
Raises RuntimeError on timeout (and resets the poisoned pool) so callers
surface an actionable error instead of hanging indefinitely.
This is the shared load boundary for BOTH get_model() and the startup
preload_model() the memory reclaim must live here, or a memory-tight
machine gets protected on demand loads but OS-killed during the startup
preload (review finding on the original placement in get_model()).
"""
_make_room_before_tts_load()
loop = asyncio.get_running_loop()
timeout = _model_load_timeout()
try:
@@ -1155,6 +1164,41 @@ async def get_model():
return model
def _make_room_before_tts_load() -> None:
"""Evict-then-load: free what we already own before a tight TTS load.
The audit's top gap: on a 16 GB unified-memory box a plain TTS load could
still be OS-killed the dub path frees memory before *ASR* loads
(offload_tts_for_asr, #1119), but nothing freed memory before a *TTS*
load, and a warm dictation model (~2 GB) is routinely the difference.
Deliberately NOT admission control: refusing a load on an estimate would
brick machines that would actually cope (the #1111 decision — advisory
only). This only releases things the app already reclaims on idle anyway
(the capture-ASR model, engine instances, allocator caches), just *now*
instead of after the idle timeout and only when free memory is actually
tight, so a roomy machine pays nothing.
"""
try:
from services.memory_budget import available_memory
free_gb = (available_memory() or {}).get("ram_available_gb")
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 "
"models first.", free_gb,
)
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 block the load
logger.debug("capture-ASR pre-load release failed", exc_info=True)
release_tts_side_caches()
free_vram()
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:
"""True when ``checkpoint`` is loadable with NO network: an existing local
directory, or a COMPLETE HF cache snapshot. ``snapshot_download(...,
@@ -1273,11 +1317,60 @@ async def idle_worker():
torch = _lazy_torch()
while True:
await asyncio.sleep(30)
idle_timeout = _resolve_idle_timeout()
async with _model_lock:
if model is not None and time.time() - _last_used > _resolve_idle_timeout():
if model is not None and time.time() - _last_used > idle_timeout:
logger.info("Idle timeout reached. Unloading OmniVoice model to free VRAM.")
model = None
release_tts_side_caches()
free_vram()
# The capture/dictation ASR was never idle-released — so once a user
# dictated, its model stayed resident for the life of the process while
# the TTS model dutifully freed its 3.8 GB. On a 16 GB Mac that left the
# backend sitting at ~6.2 GB idle, which is what tipped it into the
# memory pressure that gets it killed mid-generate (#1076/#1092/#1093/
# #1101). Give it the same bargain the TTS model already makes. Held
# off while a live dictation stream has a lease, so nothing is unloaded
# mid-sentence.
try:
from services.asr_backend import release_idle_capture_backend
if release_idle_capture_backend(idle_timeout):
free_vram()
except Exception: # noqa: BLE001 — the reaper must never kill idle_worker
logger.warning("idle capture-ASR release failed", exc_info=True)
def release_tts_side_caches():
"""Drop caches keyed to the TTS model, for when the model itself is released.
The voice-clone prompt cache (services.tts_backend) holds encoded reference
tensors belonging to *this* model instance. If the model is unloaded but the
prompts survive, an "unload" no longer means unload (#1119) — they sit in the
very memory the unload was reclaiming (``_offload_unified_memory`` drops the
model precisely to hand that RAM to the ASR model).
Previously only ``OmniVoiceBackend.unload()`` cleared them, which sufficed
while the cache was adapter-only. The native ``/generate`` path now populates
it too, and that path unloads through *here*, never through the adapter.
Reached through ``sys.modules`` rather than an import, deliberately:
``tts_backend`` already imports this module, so importing it back would close
a real cycle and doing it at *import* time (e.g. a registration hook) drags
``core.config`` in earlier than it is today, which perturbs DATA_DIR binding.
A plain lookup has neither problem, and is exactly right besides: if the module
was never imported, it has no cache to clear.
Best-effort by construction cache hygiene must never be able to break an
unload, because a failed unload is how the backend gets OOM-killed.
"""
mod = sys.modules.get("services.tts_backend")
if mod is None:
return
try:
mod.clear_clone_prompt_cache()
except Exception: # noqa: BLE001
logger.debug("clone-prompt cache clear failed during unload", exc_info=True)
def free_vram():
"""Release cached GPU memory on any accelerator (CUDA, MPS, XPU)."""
@@ -1302,6 +1395,42 @@ def _has_dedicated_vram():
return False
# Free RAM below which the TTS model is released before ASR loads on a
# unified-memory machine. WhisperX large-v3 needs ~3 GB plus VAD and overhead,
# so a box with less than this much headroom cannot hold both — and on a Mac the
# loser is the whole backend process (the OS kills it). Tunable for bigger boxes.
_UNIFIED_OFFLOAD_HEADROOM_GB = float(
os.environ.get("OMNIVOICE_UNIFIED_OFFLOAD_HEADROOM_GB", "6.0")
)
def _offload_unified_memory() -> bool:
"""Release the TTS model on a unified-memory host when RAM is tight.
Returns True when the model was actually released. Never raises a failure
to make room must not abort the transcription that asked for it."""
global model
try:
from services.memory_budget import available_memory
free_gb = available_memory().get("ram_available_gb")
if free_gb is not None and free_gb > _UNIFIED_OFFLOAD_HEADROOM_GB:
return False # plenty of room — keep the model warm, pay no reload
logger.info(
"Unified memory tight (%s GB free) — releasing the TTS model so ASR has room "
"(it reloads on the next generation).",
"unknown" if free_gb is None else f"{free_gb:.1f}",
)
model = None
release_tts_side_caches()
free_vram()
return True
except Exception as e: # noqa: BLE001
logger.warning("unified-memory TTS offload failed (continuing): %s", e)
return False
def offload_tts_for_asr():
"""Move TTS model to CPU to free VRAM for ASR (WhisperX large-v3).
@@ -1317,7 +1446,18 @@ def offload_tts_for_asr():
if model is None:
return
if not _has_dedicated_vram():
return # MPS / CPU / DirectML don't benefit from manual offloading
# UNIFIED MEMORY (Apple Silicon / CPU). Moving the model "to CPU" frees
# nothing here — it is the same physical RAM — which is why this used to
# bail out entirely. But the conclusion was wrong: the fix on unified
# memory isn't to MOVE the model, it's to RELEASE it.
#
# Holding the ~3.8 GB TTS model resident while WhisperX large-v3 (~3 GB)
# loads on top of it is what gets the backend OOM-killed mid-dub on a
# 16 GB Mac (#1119) — the transcribe stream just dies. Unload it and the
# room is real. get_model() lazily reloads on the next TTS use, so the
# only cost is that reload, and only when memory was actually tight.
_offload_unified_memory()
return
try:
# Check if there's enough free VRAM to skip offloading
if torch.cuda.is_available():
@@ -1342,6 +1482,10 @@ def restore_tts_after_asr():
if model is None:
return
if not _has_dedicated_vram():
# Nothing to restore on unified memory: offload UNLOADED the model, and
# get_model() reloads it lazily on the next TTS call. Reloading it here
# would just re-occupy the RAM we freed, right when the dub still has
# translation and synthesis ahead of it.
return
try:
device = get_best_device()
+36 -5
View File
@@ -47,8 +47,29 @@ _GB = 1024 ** 3
def default_engines_dir() -> str:
"""``backend/engines`` — where per-engine venvs live (`<id>/.venv`)."""
return str(Path(__file__).resolve().parents[1] / "engines")
"""``DATA_DIR/engines`` — where sidecar engine installs (IndexTTS-2 & friends)
keep their per-engine venv (`<id>/.venv`) and weights.
Not ``backend/engines`` (the built-in engine *modules*, which share the app
venv and have no `.venv` of their own): that dir is import-time code, and a
sidecar install never lands there. Pointing the report at it meant the
engine-venv category always measured an empty tree while a real multi-GB
IndexTTS-2 install silently rolled up into the data dir's "other" subtotal.
Mirrors ``backend/services/sidecar_install.py`` (`DATA_DIR/engines/<id>`).
"""
from core.config import DATA_DIR
return str(Path(DATA_DIR) / "engines")
def _engines_child_name(engines_dir: str, data_dir: str) -> str | None:
"""Basename of ``engines_dir`` when it is a direct child of ``data_dir`` —
so the data category can skip it and not double-count what the engine-venv
category already measures. ``None`` when engines live elsewhere."""
parent = os.path.dirname(os.path.normpath(engines_dir))
if os.path.normpath(parent) == os.path.normpath(data_dir):
return os.path.basename(os.path.normpath(engines_dir))
return None
def default_app_venv() -> str | None:
@@ -249,6 +270,12 @@ def build_report(
children: list[dict] = []
claimed: set[str] = set()
# When sidecar engines live under DATA_DIR/engines, the engine-venv category
# below owns that subtree — claim it here so it isn't also swept into "other".
engines_child = _engines_child_name(engines_dir, data_dir)
if engines_child:
claimed.add(engines_child)
for name in _DATA_CHILD_DIRS:
p = os.path.join(data_dir, name)
size, ok, err = _dir_size(p, deadline)
@@ -319,10 +346,14 @@ def build_report(
except OSError:
engine_dirs = []
for edir in engine_dirs:
venv_dir = os.path.join(edir, ".venv")
if not os.path.isdir(venv_dir):
# A sidecar install is the venv PLUS a git checkout PLUS multi-GB weights
# (`checkpoints/`) — measure the whole `<id>` dir, not just `.venv`, or the
# weights (usually the bulk) go uncounted now that the data category no
# longer sweeps this subtree into "other". Only real installs have a venv,
# so that gate still skips a bare/interrupted dir.
if not os.path.isdir(os.path.join(edir, ".venv")):
continue
size, ok, err = _dir_size(venv_dir, deadline)
size, ok, err = _dir_size(edir, deadline)
venv_total += size
venv_complete = venv_complete and ok
venv_err = venv_err or err
+18 -4
View File
@@ -106,6 +106,9 @@ _FULL_NAME_TO_CODE = {
"hebrew": "he",
"persian": "fa",
"azerbaijani": "az",
# "vietnamese" → "vi" kept for documentation, but vi is deliberately
# absent from _NUM2WORDS_LANGS (see the note there): the membership gate
# in _num2words_lang makes this entry inert, so Vietnamese keeps digits.
"vietnamese": "vi",
"kazakh": "kz",
"standard arabic": "ar",
@@ -117,10 +120,15 @@ _ISO_ALIASES = {"kk": "kz"}
# Locales verified against the pinned num2words (cardinal + basic rendering).
# zh/ja/ko/th are deliberately absent: unsegmented scripts where injecting
# space-delimited words is wrong, and their engines read digits natively.
# vi is absent too (#1139): num2words' Vietnamese cardinals misuse "lẻ" for
# 2001-2099 ("hai nghìn lẻ hai mươi bốn" for 2024 — "lẻ" is only valid before
# a lone units digit) and there is no to="year" form, so years read wrong;
# the engine pronounces Vietnamese digits natively, so digits pass through —
# the same conservative rule that already excludes vi from _DECIMAL_LANGS.
_NUM2WORDS_LANGS = frozenset({
"en", "de", "es", "fr", "it", "pt", "nl", "ru", "uk", "pl", "tr", "cs",
"da", "fi", "sv", "no", "ro", "hu", "id", "lt", "lv", "sl", "sr", "ar",
"he", "fa", "az", "vi", "kz",
"he", "fa", "az", "kz",
})
# Locales whose num2words decimal rendering was vetted ("drei Komma fünf",
@@ -147,7 +155,13 @@ _ISO_CODE_RE = re.compile(r"^([a-z]{2,3})(?:[-_]|$)")
def _num2words_lang(language: Optional[str]) -> Optional[str]:
"""Resolve a request language (display name or ISO-ish code) to a
num2words locale, or ``None`` when digits should be left alone."""
num2words locale, or ``None`` when digits should be left alone.
Both lookup paths gate on ``_NUM2WORDS_LANGS`` the vetted set is the
single authority. Display names used to bypass it (#1139: "Vietnamese"
reached num2words while "vi" wouldn't have), so an unvetted locale could
mangle numbers depending on how the caller spelled the language.
"""
if not language:
return None
s = str(language).strip().lower()
@@ -155,7 +169,7 @@ def _num2words_lang(language: Optional[str]) -> Optional[str]:
return None
code = _FULL_NAME_TO_CODE.get(s)
if code:
return code
return code if code in _NUM2WORDS_LANGS else None
m = _ISO_CODE_RE.match(s)
if m:
c = _ISO_ALIASES.get(m.group(1), m.group(1))
@@ -436,7 +450,7 @@ def _numbers_to_words(text: str, lang: str) -> str:
if len(raw) == 4 and 1500 <= n <= 2099:
# Bare 4-digit numbers in this range read as years
# ("nineteen eighty-four"); fall back to cardinal where the
# locale has no year form (sv, vi).
# locale has no year form (sv).
try:
return num2words(n, lang=lang, to="year")
except Exception: # noqa: BLE001
+68 -17
View File
@@ -38,6 +38,8 @@ from __future__ import annotations
import asyncio
import logging
import os
import random
import time
from typing import Iterable, Optional
logger = logging.getLogger("omnivoice.translator")
@@ -270,18 +272,62 @@ def _glossary_text(glossary: Iterable[dict] | None) -> str:
)
#: Longest Retry-After we'll honor with an in-place wait. Anything above this
#: means "the provider is down for a while" — fail fast and let the segment
#: degrade to its literal translation instead of stalling the whole dub.
_RETRY_AFTER_CAP_S = 30.0
def _retry_after_seconds(exc) -> float | None:
"""Retry-After from a rate-limit error, or None when this isn't a 429.
Providers frequently 429 with a *tiny* hint (OpenRouter's free pool says
"Retry-After: 2"); giving up instantly on those turned a two-second wait
into a whole failed reflect pass 6 segments fire concurrently, so one
throttle window used to take out every segment at once. Defensive on
purpose: the exception shape differs across openai-lib versions and
OpenAI-compatible servers, and a parsing surprise must never break the
caller's own error handling.
"""
try:
if getattr(exc, "status_code", None) != 429:
return None
headers = getattr(getattr(exc, "response", None), "headers", None) or {}
raw = headers.get("retry-after") or headers.get("Retry-After")
seconds = float(raw) if raw is not None else 2.0
return max(0.5, min(seconds, _RETRY_AFTER_CAP_S))
except Exception: # noqa: BLE001 — a weird header is not worth a crash
return None
def _chat(client, *, system: str, user: str) -> str:
"""One-shot chat completion. Raises on failure."""
res = client.chat.completions.create(
model=_llm_model(),
timeout=_llm_timeout(),
temperature=0.2, # pinned like the Fast path — default 1.0 drifts/invents
messages=[
{"role": "system", "content": system},
{"role": "user", "content": user},
],
)
return (res.choices[0].message.content or "").strip()
"""One-shot chat completion. Raises on failure.
One polite retry on a rate limit: when the provider sends a 429 with a
bounded Retry-After, wait it out once (plus jitter so the 6-wide
concurrent segment fan-out doesn't re-stampede the same window) and try
again. A second 429 propagates the caller degrades to the literal text.
"""
attempts = 0
while True:
try:
res = client.chat.completions.create(
model=_llm_model(),
timeout=_llm_timeout(),
temperature=0.2, # pinned like the Fast path — default 1.0 drifts/invents
messages=[
{"role": "system", "content": system},
{"role": "user", "content": user},
],
)
return (res.choices[0].message.content or "").strip()
except Exception as e: # noqa: BLE001 — re-raised unless a retryable 429
wait = _retry_after_seconds(e)
if wait is None or attempts >= 1:
raise
attempts += 1
logger.info("LLM rate-limited; honoring Retry-After=%.1fs (one retry)", wait)
time.sleep(wait + random.uniform(0.1, 1.0))
# ── Public API ──────────────────────────────────────────────────────────────
@@ -320,7 +366,7 @@ def cinematic_refine_sync(
client = _llm_client()
if client is None:
return {**result_ok, "error": "no-llm"}
return {**result_ok, "degraded": "no-llm"}
glossary_preamble = _glossary_text(glossary)
@@ -357,7 +403,7 @@ def cinematic_refine_sync(
critique = _chat(client, system=_with_preamble(_REFLECT_PROMPT), user=reflect_user)
except Exception as e:
logger.warning("cinematic reflect failed: %s", e)
return {**result_ok, "error": f"reflect: {e}"}
return {**result_ok, "degraded": f"reflect: {e}"}
# Step 3 — adapt
try:
@@ -373,7 +419,7 @@ def cinematic_refine_sync(
"text": literal_text,
"literal": literal_text,
"critique": critique,
"error": f"adapt: {e}",
"degraded": f"adapt: {e}",
}
final = (adapted or "").strip() or literal_text
@@ -396,8 +442,8 @@ def cinematic_refine_sync(
"text": literal_text,
"literal": literal_text,
"critique": critique,
"error": (f"adapt-wrong-script:{target_lang}" if wrong_script
else "adapt-diverged"),
"degraded": (f"adapt-wrong-script:{target_lang}" if wrong_script
else "adapt-diverged"),
}
return {
"text": final,
@@ -478,6 +524,11 @@ async def cinematic_refine_many(
logger.warning("cinematic segment %s failed: %s", sid, e)
else:
task.cancel() # stop awaiting; the executor thread is abandoned (#730 pattern)
# "degraded", not "error": the literal translation is used, so the
# segment is fully usable — downstream passes (speech-rate fit,
# duration planning) must still run on it, and the UI must not count
# it as a failed segment. `error` is reserved for rows with no usable
# text at all (the base translation itself failed).
out.append({"id": sid, "text": lit, "literal": lit, "critique": "",
"error": "cinematic-budget"})
"degraded": "cinematic-budget"})
return out
+120 -30
View File
@@ -249,13 +249,37 @@ class TTSBackend(ABC):
# `torch.cuda.empty_cache()` / `torch.mps.empty_cache()`).
# • Safe to call before the first generate(): a backend that never
# loaded has nothing to release.
def unload(self) -> None:
"""Release any GPU memory and file handles held by this backend.
# Attribute(s) that hold this backend's heavy model, cleared by the default
# unload(). Every in-process engine loads its weights lazily into one of
# these in `_ensure_loaded()`; the next generate() re-runs that loader. An
# engine that holds its model elsewhere (or nowhere — e.g. an external HTTP
# server) overrides `unload()` or leaves these unset. OmniVoice overrides
# entirely (it drives the shared model_manager singleton).
_MODEL_ATTRS: tuple[str, ...] = ("_model", "_tts")
Called by the registry on engine switch and on app shutdown. Default
is a no-op so engines that haven't migrated keep working; per-engine
overrides arrive in Phase 2 (see ROADMAP.md). Must be idempotent.
def unload(self) -> None:
"""Release the heavy model this backend holds, and free device caches.
Called by the registry on engine switch, by the single-active-engine
eviction (services.engine_memory), and on app shutdown. Clears each of
``_MODEL_ATTRS`` that is set on this instance, then empties the device
cache so switching engines actually hands the memory back instead of
leaving the old model resident until GC (the 16 GB-Mac OOM class). The
next generate() lazily reloads. Idempotent and safe before first load:
a backend that never loaded has every attr already None/absent.
"""
freed = False
for attr in self._MODEL_ATTRS:
if getattr(self, attr, None) is not None:
setattr(self, attr, None)
freed = True
if freed:
try:
from services.model_manager import free_vram
free_vram()
except Exception: # noqa: BLE001 — unload must never raise (idempotent contract)
pass
return None
@@ -277,19 +301,36 @@ _prompt_cache: "OrderedDict[tuple, object]" = OrderedDict()
_prompt_cache_lock = threading.Lock()
def _clone_prompt_key(ref_audio: str, ref_text):
def _clone_prompt_key(ref_audio: str, ref_text, preprocess_prompt: bool = True):
try:
mtime = os.path.getmtime(ref_audio)
except OSError:
mtime = 0.0
return (os.path.abspath(ref_audio), mtime, ref_text or "")
# preprocess_prompt is part of the key: it changes the encoded prompt
# (silence removal + trimming + ref-text punctuation, omnivoice.py:675/722),
# so a False request must not be served a True-encoded prompt — or poison
# the cache for the True callers. /generate never sets it (always the True
# default); /v1/audio/speech exposes it.
return (os.path.abspath(ref_audio), mtime, ref_text or "", bool(preprocess_prompt))
def _get_clone_prompt(model, ref_audio: str, ref_text):
"""Return a cached/precomputed ``VoiceClonePrompt`` for (ref_audio, ref_text),
or ``None`` to fall back to the inline ref path. Never raises."""
def _get_clone_prompt(
model, ref_audio: str, ref_text, preprocess_prompt: bool = True, *,
store: bool = True,
):
"""Return a cached/precomputed ``VoiceClonePrompt`` for
(ref_audio, ref_text, preprocess_prompt), or ``None`` to fall back to the
inline ref path. Never raises.
``store=False`` still *reads* the cache (a hit is free) but never inserts:
it exists for single-use references a dub's per-segment ref clips are each
a distinct file used exactly once, and inserting a stream of them into an
LRU of 8 evicts the per-speaker and locked-profile prompts that ARE reused.
Every short segment falling back to its speaker ref then re-encodes it
(~0.4 s each, measured). Scan-resistance, not a second cache policy.
"""
try:
key = _clone_prompt_key(ref_audio, ref_text)
key = _clone_prompt_key(ref_audio, ref_text, preprocess_prompt)
except Exception:
return None
with _prompt_cache_lock:
@@ -298,12 +339,16 @@ def _get_clone_prompt(model, ref_audio: str, ref_text):
_prompt_cache.move_to_end(key)
return hit
try:
# Encode outside the lock (slow); default preprocess matches the inline
# ref_audio path's preprocessing.
prompt = model.create_voice_clone_prompt(ref_audio, ref_text=ref_text)
# Encode outside the lock (slow). Mirrors exactly what generate() would
# do inline for this ref (omnivoice.py:964-978), so output is identical.
prompt = model.create_voice_clone_prompt(
ref_audio, ref_text=ref_text, preprocess_prompt=preprocess_prompt
)
except Exception as e: # noqa: BLE001 — fall back, never break synthesis
logger.warning("voice-clone prompt precompute failed; using inline ref: %s", e)
return None
if not store:
return prompt
with _prompt_cache_lock:
_prompt_cache[key] = prompt
_prompt_cache.move_to_end(key)
@@ -312,6 +357,46 @@ def _get_clone_prompt(model, ref_audio: str, ref_text):
return prompt
def generate_with_cached_ref(model, *, ref_audio, ref_text, **gen_kw):
"""``model.generate()`` with the reference clip encoded once, not once per call.
The native (non-adapter) callers of the OmniVoice model ``/generate`` and its
streaming twin, and the audiobook/long-form renderer used to pass
``ref_audio=<path>`` straight through, so the codec encoder re-ran the reference
on **every generate call**: once per chunk, per pause-span, and per audiobook
segment, not merely once per request. The prompt cache below (#427/#473) existed
the whole time but only ``OmniVoiceBackend`` (the adapter path) ever called it,
and the default engine doesn't take that path.
This is the one place that knows the rule, so it can't be re-broken piecemeal:
``voice_clone_prompt`` and ``ref_audio``/``ref_text`` are **mutually exclusive**
pass both and the model warns and ignores the latter (omnivoice.py:957).
The cache is **best-effort, never load-bearing**: if the prompt can't be built,
or the model rejects the one we built, we fall back to the inline reference and
synthesize exactly as before. A latency optimization must never be able to turn
a generation that would have succeeded into an error.
"""
# cache_ref=False marks a single-use reference (a dub's per-segment clips):
# look the cache up, but never insert — see _get_clone_prompt(store=). MUST
# be popped: the model's generate() has an explicit signature and would
# TypeError on an unknown kwarg.
cache_ref = bool(gen_kw.pop("cache_ref", True))
# Stays in gen_kw too: the model needs it on the inline branch, and it is inert
# on the prompt branch (that prompt is already encoded).
preprocess_prompt = bool(gen_kw.get("preprocess_prompt", True))
prompt = (
_get_clone_prompt(model, ref_audio, ref_text, preprocess_prompt, store=cache_ref)
if ref_audio else None
)
if prompt is not None:
try:
return model.generate(voice_clone_prompt=prompt, **gen_kw)
except Exception as e: # noqa: BLE001 — fall back to the inline ref
logger.warning("voice_clone_prompt generate failed; retrying inline ref: %s", e)
return model.generate(ref_audio=ref_audio, ref_text=ref_text, **gen_kw)
def clear_clone_prompt_cache() -> None:
"""Drop all cached voice-clone prompts (frees their tensors). Called on model
unload so a flush/engine-switch doesn't strand VRAM."""
@@ -319,6 +404,13 @@ def clear_clone_prompt_cache() -> None:
_prompt_cache.clear()
# NB: model_manager.release_tts_side_caches() calls clear_clone_prompt_cache()
# above whenever it drops the TTS model — the prompts belong to that model
# instance and an "unload" that leaves them behind isn't an unload (#1119). It
# reaches this module through sys.modules rather than importing it, so there is
# no import cycle and no import-time side effect here.
class OmniVoiceBackend(TTSBackend):
"""Wraps `omnivoice.models.omnivoice.OmniVoice`. Zero behaviour change.
@@ -390,22 +482,20 @@ class OmniVoiceBackend(TTSBackend):
denoise=kw.get("denoise", True),
postprocess_output=kw.get("postprocess_output", True),
)
# #427: when cloning from a reference file, reuse a cached voice-clone
# prompt so the reference isn't re-encoded every call. Any failure in the
# prompt path falls back to the inline ref — output is identical either
# way (the model documents the two as equivalent); this only saves the
# repeated encode. The design/instruct path (no ref_audio) is untouched.
audios = None
if ref_audio:
prompt = _get_clone_prompt(self._model, ref_audio, ref_text)
if prompt is not None:
try:
audios = self._model.generate(voice_clone_prompt=prompt, **gen_kw)
except Exception as e: # noqa: BLE001 — fall back to the inline ref
logger.warning("voice_clone_prompt generate failed; retrying inline ref: %s", e)
audios = None
if audios is None:
audios = self._model.generate(ref_audio=ref_audio, ref_text=ref_text, **gen_kw)
# /v1/audio/speech exposes preprocess_prompt (openai_compat.py) and it
# used to be dropped on the floor here — the API accepted it and gen_kw
# never carried it, so it silently did nothing.
gen_kw["preprocess_prompt"] = bool(kw.get("preprocess_prompt", True))
# Single-use reference hint (dub per-segment clips) — see
# generate_with_cached_ref, which pops it before the model sees it.
gen_kw["cache_ref"] = bool(kw.get("cache_ref", True))
# The cached-reference path lives in generate_with_cached_ref, shared with
# the native callers. Deliberately NOT a second copy: this logic living in
# one place here and a subtly different one there is exactly how the cache
# came to be wired into the adapter and nowhere else.
audios = generate_with_cached_ref(
self._model, ref_audio=ref_audio, ref_text=ref_text, **gen_kw
)
return audios[0]
def unload(self) -> None:
+32
View File
@@ -0,0 +1,32 @@
"""Process-survival containment for engine/library code.
Leaf module (stdlib-only) so both services.model_manager and
services.asr_backend can import it at module top without a cycle.
"""
from __future__ import annotations
def contain_system_exit(fn, what: str):
"""Wrap a pool job so library code calling ``sys.exit()`` cannot kill the app.
Real case (#1133): mlx-audio's Kokoro pipeline uses misaki's G2P, which
runs ``spacy.cli.download()`` IN-PROCESS on first use; spaCy's CLI error
printer responds to a missing pip (uv-managed venvs ship none) with
``sys.exit(1)``. ``except Exception`` never catches SystemExit, so it rode
the executor future into the event loop where uvicorn treats SystemExit
as "shut down", killing the whole backend 21 s after start. Any engine
dependency written as a CLI can do this; containing it at the dispatch
boundary covers every load, generate, and transcribe.
"""
def wrapped():
try:
return fn()
except SystemExit as e: # noqa: PERF203 — the whole point
raise RuntimeError(
f"{what}: engine code tried to exit the process "
f"(SystemExit {e.code}) — contained. This usually means an "
f"engine dependency failed to auto-install something (e.g. a "
f"spaCy model needing pip); see the backend log above this "
f"line for the real error."
) from e
return wrapped
+51
View File
@@ -0,0 +1,51 @@
"""Cross-device-safe filesystem primitives.
``os.replace`` is atomic only within one filesystem; across devices it raises
``OSError(EXDEV)`` (surfacing to Windows users as ``[Errno 18]`` / ``[Errno 22]``
in past issue reports the D:-drive/relocated-models class, #763/#479). Every
current call site derives its temp file from the destination directory, which
keeps same-device semantics but nothing *enforced* that, and the next writer
that stages in ``%TEMP%`` and renames into a user-relocated data/models dir on
another drive reintroduces the whole class. This helper is the enforcement
point: replace when possible, degrade to copy+fsync+replace when the OS says
the two paths live on different devices.
"""
from __future__ import annotations
import errno
import os
import shutil
def safe_replace(src: str, dst: str) -> None:
"""``os.replace`` with a cross-device fallback.
Same-device: identical to ``os.replace`` (atomic). Cross-device (EXDEV):
copy to a temp sibling of ``dst`` (same device as the destination), fsync,
then atomically replace and remove ``src``. Not atomic *end-to-end*
across devices (impossible), but the destination itself still only ever
transitions atomically from old content to complete new content.
"""
try:
os.replace(src, dst)
return
except OSError as e:
if e.errno != errno.EXDEV:
raise
tmp = f"{dst}.xdev-tmp-{os.getpid()}"
try:
shutil.copyfile(src, tmp)
with open(tmp, "rb+") as f:
f.flush()
os.fsync(f.fileno())
os.replace(tmp, dst)
finally:
try:
if os.path.exists(tmp):
os.remove(tmp)
except OSError:
pass # best-effort temp cleanup; the replace above already landed or raised
try:
os.remove(src)
except OSError:
pass # src may be gone already (another EXDEV fallback won the race)
+22 -1
View File
@@ -16,7 +16,7 @@
},
"frontend": {
"name": "omnivoice-studio",
"version": "0.3.11",
"version": "0.3.21",
"dependencies": {
"@fontsource-variable/inter": "^5.2.8",
"@fontsource-variable/source-serif-4": "^5.2.9",
@@ -47,6 +47,7 @@
"i18next": "^26.3.1",
"i18next-browser-languagedetector": "^8.2.1",
"lucide-react": "^1.18.0",
"posthog-js": "^1.399.2",
"qrcode": "^1.5.4",
"react": "^19.2.7",
"react-dom": "^19.2.7",
@@ -437,6 +438,10 @@
"@playwright/test": ["@playwright/test@1.61.0", "", { "dependencies": { "playwright": "1.61.0" }, "bin": { "playwright": "cli.js" } }, "sha512-cKA5B6lpFEMyMGjxF54QihfYpB4FkEGH+qZhtArDEG+wezQAJY8Pq6C7T1SjWz+FFzt3TbyoXBQYk/0292TdJA=="],
"@posthog/core": ["@posthog/core@1.40.2", "", { "dependencies": { "@posthog/types": "^1.393.0" } }, "sha512-H12j7O9iHGvpK9t2ko8W4pvfbV1pBDxrsWC1LA6yp2RhzwvC4T3sWhu+AekDQJSRSrJEWlB0t/Ueq9QhPSq7FQ=="],
"@posthog/types": ["@posthog/types@1.393.0", "", {}, "sha512-vzWeEJZ7ERQhFRoQYaP5jzN1JvIu46UJyHXsuv+dTGW2r3sMgREOhNxXLZjmFHwZ8/FOHQoyqqQmXTCXZSfMSg=="],
"@quansync/fs": ["@quansync/fs@1.0.0", "", { "dependencies": { "quansync": "^1.0.0" } }, "sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ=="],
"@radix-ui/number": ["@radix-ui/number@1.1.2", "", {}, "sha512-ceTwaxc4I5IOi97DgCotl3pqiyRGvffcc0oOsE2dQYaJOFIDsDt4VWG6xEbg1QePv9QWausCEIppud/tJ1wNig=="],
@@ -733,6 +738,8 @@
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
"@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
"@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
"@types/web-bluetooth": ["@types/web-bluetooth@0.0.21", "", {}, "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA=="],
@@ -861,6 +868,8 @@
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
"core-js": ["core-js@3.49.0", "", {}, "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg=="],
"country-flag-icons": ["country-flag-icons@1.6.17", "", {}, "sha512-Nmik0289ZVZSI3c7mJR/amg6DyY7Z59b0sTFSKayeX72mHfPzCPJygwJs2pYgQULzuAyWeCUgwAJ+Dq8OR+JFw=="],
"crelt": ["crelt@1.0.7", "", {}, "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA=="],
@@ -905,6 +914,8 @@
"dom-accessibility-api": ["dom-accessibility-api@0.6.3", "", {}, "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="],
"dompurify": ["dompurify@3.4.12", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg=="],
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
"electron-to-chromium": ["electron-to-chromium@1.5.334", "", {}, "sha512-mgjZAz7Jyx1SRCwEpy9wefDS7GvNPazLthHg8eQMJ76wBdGQQDW33TCrUTvQ4wzpmOrv2zrFoD3oNufMdyMpog=="],
@@ -967,6 +978,8 @@
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"fflate": ["fflate@0.4.8", "", {}, "sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA=="],
"figures": ["figures@6.1.0", "", { "dependencies": { "is-unicode-supported": "^2.0.0" } }, "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg=="],
"file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="],
@@ -1367,6 +1380,10 @@
"postcss": ["postcss@8.5.15", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A=="],
"posthog-js": ["posthog-js@1.399.2", "", { "dependencies": { "@posthog/core": "^1.40.1", "@posthog/types": "^1.393.0", "core-js": "^3.49.0", "dompurify": "^3.3.2", "fflate": "^0.4.8", "preact": "^10.29.3", "query-selector-shadow-dom": "^1.0.1", "web-vitals": "^5.3.0" } }, "sha512-xcvrGEgUYtIVcWPRlVfc/NkMo0IP9nwnM/dzJIAzjDOSewkdDk/9T4Vz1+gooEhXdQCPfG44jSK95mVJsoySqA=="],
"preact": ["preact@10.29.7", "", { "peerDependencies": { "preact-render-to-string": ">=5" }, "optionalPeers": ["preact-render-to-string"] }, "sha512-DCHYrK/B10yUD3ZjLfhZ3WIE/9Vf9VFUODcRE2dRomTYDpJk6z6L9wecSfhfE6M9ZTHUdyQkoC46arIDhEV84Q=="],
"prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
"pretty-format": ["pretty-format@27.5.1", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="],
@@ -1383,6 +1400,8 @@
"quansync": ["quansync@1.0.0", "", {}, "sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA=="],
"query-selector-shadow-dom": ["query-selector-shadow-dom@1.0.1", "", {}, "sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw=="],
"radix-vue": ["radix-vue@1.9.17", "", { "dependencies": { "@floating-ui/dom": "^1.6.7", "@floating-ui/vue": "^1.1.0", "@internationalized/date": "^3.5.4", "@internationalized/number": "^3.5.3", "@tanstack/vue-virtual": "^3.8.1", "@vueuse/core": "^10.11.0", "@vueuse/shared": "^10.11.0", "aria-hidden": "^1.2.4", "defu": "^6.1.4", "fast-deep-equal": "^3.1.3", "nanoid": "^5.0.7" }, "peerDependencies": { "vue": ">= 3.2.0" } }, "sha512-mVCu7I2vXt1L2IUYHTt0sZMz7s1K2ZtqKeTIxG3yC5mMFfLBG4FtE1FDeRMpDd+Hhg/ybi9+iXmAP1ISREndoQ=="],
"react": ["react@19.2.7", "", {}, "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ=="],
@@ -1623,6 +1642,8 @@
"web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="],
"web-vitals": ["web-vitals@5.3.0", "", {}, "sha512-q6LWsLatGYZp5VGBIOvbTj6JBV2nOmC8KvWztXBmwJcfFAzhwKwbOxhUH306XY3CcaZDUlSmSuNPBsCn0bFu+g=="],
"web-worker": ["web-worker@1.5.0", "", {}, "sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw=="],
"webidl-conversions": ["webidl-conversions@8.0.1", "", {}, "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ=="],
+3 -1
View File
@@ -442,7 +442,9 @@ call.
> the last error output). Use **Report this bug** from that notice — the crash
> evidence is attached to the prefilled GitHub issue automatically, with home
> paths scrubbed. The raw markers live next to the backend logs in
> `backend_crash_markers.json`.
> `backend_crash_markers.json`. Markers are per-version: after you update the
> app, notices recorded by the previous version are cleaned up rather than
> resurfacing — the update may well have fixed that crash.
## 14b. "Can't reach the local OmniVoice backend" flashing during startup or an automatic restart
+54 -4
View File
@@ -9,7 +9,52 @@ and ships a script that finds and removes them for you (with a dry-run first).
> cache** (the Hugging Face weights — several GB) and the **managed Python
> environment** (`project/.venv` — a few GB). Everything else is small.
## The one-command uninstaller (recommended)
## In the app (easiest — no repo needed)
**Settings → Storage → Remove all data.** It lists every folder this install
owns with its real size, lets you opt in (separately) to the shared Hugging Face
model cache, asks you to type `DELETE`, then removes everything and quits.
This is the right path if you installed the **.dmg / .msi / AppImage** — you
don't have the repo, so the script below isn't available to you.
> **You may not need to uninstall.** Right above it, **Reset & remove** does the
> same job at any scale you like — and leaves you with a working app instead of
> no app. See [Resetting](#resetting-instead-of-uninstalling) below.
## Resetting instead of uninstalling
**Settings → Storage → Reset & remove** puts part — or all — of OmniVoice back to
how it shipped, without removing the app. Every option shows its real size before
you commit, and the app restarts itself when it's done.
| Option | What it removes | What it keeps |
| --- | --- | --- |
| **UI preferences only** | Theme, layout, language, dub settings | Everything on disk |
| **All settings** | The above, plus saved settings on disk (engine choices, voice defaults) | Voices, projects, audio, models |
| **Downloaded assets & models** | Model weights, sidecar engines, audio tools, caches | Everything you made |
| **Everything OmniVoice did** | All of the above, plus voices, projects, generated audio, history, logs | The app itself, and the Python environment it runs on |
"Choose exactly what to remove" opens the same list as individual checkboxes, so
you can drop just the model weights, just a wedged sidecar engine, or just the
history — whatever is actually wrong.
Two things it deliberately does **not** touch:
- **Your storage locations.** If you pointed OmniVoice at a custom data or model
directory, a settings reset keeps that pointer. Clearing it would strand
gigabytes of already-downloaded weights at a path the app no longer looks in.
- **The managed Python environment.** "Everything OmniVoice did" still leaves you
with a working app that restarts on the first-run screen. If you want the
interpreter gone too, that's **Remove all data** — the section above.
The shared Hugging Face model cache is called out separately wherever it applies:
on macOS and Linux it's the standard cache other AI tools use too, so removing it
may delete models OmniVoice never downloaded. (On Windows, and in portable
installs, the cache is OmniVoice's own — there's nothing to share, and the app
says so.)
## The one-command uninstaller (from a clone)
From a clone or the source tarball:
@@ -53,6 +98,7 @@ Four kinds of data, in up to four locations:
~/Library/Application Support/com.debpalash.omnivoice-studio/ ← config.json + the managed Python env (project/.venv)
~/Library/Logs/OmniVoice/ ← backend logs (backend.log, backend_err.log)
~/Library/Logs/com.debpalash.omnivoice-studio/ ← desktop-shell log (tauri.log)
~/.config/omnivoice/ ← saved env file (cache location, HF token)
~/.cache/huggingface/ ← model weights (shared HF cache — see caveat)
```
@@ -60,7 +106,9 @@ Four kinds of data, in up to four locations:
```
~/.omnivoice/ ← app data (voices, projects, omnivoice.db, outputs, omnivoice.log)
~/.local/share/com.debpalash.omnivoice-studio/ ← config.json, logs, AND the managed Python env (project/.venv)
~/.local/share/com.debpalash.omnivoice-studio/ ← config.json, shell logs, AND the managed Python env (project/.venv)
~/.local/state/OmniVoice/ ← backend logs (backend.log, backend_err.log)
~/.config/omnivoice/ ← saved env file (cache location, HF token)
~/.cache/huggingface/ ← model weights (shared HF cache — see caveat)
```
@@ -68,7 +116,9 @@ Four kinds of data, in up to four locations:
```
%APPDATA%\OmniVoice\ ← app data (voices, projects, omnivoice.db, outputs, omnivoice.log)
%LOCALAPPDATA%\com.debpalash.omnivoice-studio\ ← config.json, logs, AND the managed Python env (project\.venv)
%LOCALAPPDATA%\com.debpalash.omnivoice-studio\ ← config.json, shell logs, AND the managed Python env (project\.venv)
%LOCALAPPDATA%\OmniVoice\Logs\ ← backend logs (backend.log, backend_err.log)
%USERPROFILE%\.config\omnivoice\ ← saved env file (cache location, HF token)
%LOCALAPPDATA%\OmniVoice\hf_cache\ ← model weights (OmniVoice uses a short path here to dodge MAX_PATH)
```
@@ -98,7 +148,7 @@ model paths don't hit the 260-character `MAX_PATH` limit.
## Remove the app itself
The script above clears the **data**; removing the installed **app** is the
The steps above clear the **data**; removing the installed **app** is the
normal per-platform step:
- **macOS:** drag **OmniVoice Studio.app** from `/Applications` to the Trash.
+110
View File
@@ -0,0 +1,110 @@
# Performance guide
Where the time goes when OmniVoice feels slow, what you can tune, and what you
should leave alone. Everything here applies to the current release; numbers
marked "measured" come from `scripts/bench_pipeline.py` on a 16 GB Apple
Silicon M2 — your hardware will differ, but the *ratios* hold.
## First: the three classic causes of "it got slow"
Before touching any knob, check these — they account for most slowness reports:
1. **A voice profile with an empty Transcript field.** Cloning needs the
reference clip's transcript. If the profile doesn't have one, the app
transcribes the clip — since v0.3.15 that happens **once** and is saved onto
the profile, but a profile that somehow keeps an empty transcript (e.g.
imported data) pays an ASR pass per generation. Open the voice's editor and
confirm the Transcript box shows text.
2. **The first generation after a (re)start is always the slowest.** Model
weights load lazily (~8 s), CUDA builds torch.compile kernels, Apple Silicon
warms Metal kernels. Judge speed from the *second* generation onward.
3. **Memory pressure.** On a 16 GB unified-memory machine, a browser with 40
tabs next to a dub means the OS pages the model in and out — or kills the
backend outright ("Can't reach the local backend"). Check Settings →
Models for what's resident, and Settings → Performance for free RAM.
## What a generation actually spends time on
For a cloned voice, one generation is: encode the reference clip (~0.4 s,
measured; cached after the first use for the voices you reuse — a dub's
per-line clips are each used once, so there's nothing for a cache to save
there) → synthesize (the bulk; scales with output length) → post-process
(mastering, watermark; fractions of a second). Long texts are split into
chunks synthesized sequentially — time scales roughly linearly with text
length.
For a dub, the stages are: audio extraction + vocal separation (one-time,
minutes for long videos) → transcription (on the best accelerator available —
Apple Silicon uses MLX since v0.3.21, NVIDIA uses CUDA; CPU-only installs fall
back to the processor) → translation (parallel, 6 concurrent requests for LLM
providers) → per-segment synthesis (sequential, the bulk of the time) →
mixing and export (mostly stream-copied, fast).
## Knobs you can actually turn
All of these are environment variables read by the backend at start. Set them
in `~/.config/omnivoice/env` (created by the installer) or your shell profile.
None of them are required — the defaults are chosen for the common case.
| Variable | Default | What it does |
|---|---|---|
| `OMNIVOICE_IDLE_TIMEOUT_S` | `900` | Seconds of idle before the TTS model unloads to free memory. Raise it (e.g. `3600`) if you generate in bursts and dislike the ~8 s reload; lower it on tight-memory machines. |
| `OMNIVOICE_SIDECAR_IDLE_TIMEOUT_S` | `300` | Same idea for sidecar engines (IndexTTS-2 etc.). |
| `OMNIVOICE_LLM_CONCURRENCY` | `6` | Parallel LLM translation calls during a dub. Raise for a fast API endpoint, lower if your provider rate-limits. |
| `OMNIVOICE_GPU_WORKERS` | auto | Concurrent generations on the GPU. Auto-sized from free VRAM (1 worker per 5 GB, max 4); MPS and CPU always get 1. **Do not raise this on ≤10 GB cards or Apple Silicon** — two concurrent jobs over-committing VRAM is exactly the crash class (#567) the auto-sizing exists to prevent. |
| `OMNIVOICE_CPU_POOL` | `min(8, cores)` | Thread pool for CPU-side work (translation dispatch, audio I/O). |
| `OMNIVOICE_SINGLE_ENGINE_RESIDENT` | `1` | Keep only one TTS engine in memory at a time. Set `0` on 32 GB+ machines to keep several engines warm across switches. |
| `OMNIVOICE_UNIFIED_OFFLOAD_HEADROOM_GB` | `6` | On unified memory (Apple Silicon): if free RAM is below this when a dub needs the transcription model, the TTS model is fully released first (it reloads on the next generation). Raise to be more aggressive about freeing, lower on 32 GB+ machines to avoid the reload. |
| `OMNIVOICE_INDEXTTS_FP16` | `1` | IndexTTS half-precision. Leave on. |
| `OMNIVOICE_ASR_VRAM_PREFLIGHT` | `1` | Downgrade transcription precision instead of crashing when VRAM is short (CUDA). Leave on. |
| `OMNIVOICE_GENERATE_TIMEOUT_S` | `300` | Abandon a generation after this many seconds. Raise for very long single generations on slow hardware. |
**torch.compile** is probe-based, not platform-based: it's attempted only
where the runtime check says it can work (a CUDA device with Triton importable
and a supported GPU architecture) and skipped automatically everywhere else —
MPS, CPU, and the typical Windows install (Triton ships no Windows wheel).
The one user-facing control is Settings → Performance → "Disable
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).
## Platform notes
- **Apple Silicon**: everything runs on the GPU via MPS/MLX. One generation at
a time by design — unified memory means TTS and ASR compete for the same
RAM, and the app actively unloads one to make room for the other on 16 GB
machines. More RAM directly improves dub throughput (fewer unload/reload
cycles).
- **NVIDIA**: fp16 + torch.compile on by default. ≥16 GB VRAM parallelizes up
to 3-4 concurrent generations (API/batch workloads); ≤10 GB deliberately
serializes.
- **CPU-only**: expect ~2x slower than MPS, more against CUDA. Prefer the
smaller/faster engines (see Settings → Engines) and short reference clips.
## Measuring instead of guessing
`scripts/bench_pipeline.py` (repo checkouts) profiles each stage one at a
time, memory-safely — it refuses to start a stage without enough free RAM,
and unloads models between stages:
```bash
# stop the app first — a running backend holds a model and skews numbers
uv run python scripts/bench_pipeline.py # everything
uv run python scripts/bench_pipeline.py tts clone # just these stages
```
If you report a performance issue, pasting its table (plus your platform and
RAM/VRAM) turns a guessing game into a bisect.
## Things that look like knobs but aren't
- **Deleting and re-adding a voice** doesn't speed anything up; the reference
encode is cached per file for voices you reuse. (A dub's per-line reference
clips are the deliberate exception — each is a distinct clip used once, so
there's nothing for a cache to save.)
- **Killing the backend between generations** makes everything slower — you
pay the model load every time. The idle timeout already frees memory when
it's genuinely idle.
- **`OMNIVOICE_PRELOAD_TTS_ASR`** exists for a legacy in-process Whisper
fallback; enabling it costs memory on every start and speeds up nothing on
a default install.
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "omnivoice-studio",
"version": "0.3.19",
"version": "0.3.22",
"private": true,
"license": "AGPL-3.0-only",
"type": "module",
@@ -55,6 +55,7 @@
"i18next": "^26.3.1",
"i18next-browser-languagedetector": "^8.2.1",
"lucide-react": "^1.18.0",
"posthog-js": "^1.399.2",
"qrcode": "^1.5.4",
"react": "^19.2.7",
"react-dom": "^19.2.7",
+2 -1
View File
@@ -2941,7 +2941,7 @@ dependencies = [
[[package]]
name = "omnivoice-studio"
version = "0.3.19"
version = "0.3.22"
dependencies = [
"arboard",
"dirs-next",
@@ -2965,6 +2965,7 @@ dependencies = [
"tauri-plugin-single-instance",
"tauri-plugin-updater",
"tauri-plugin-window-state",
"tempfile",
"ureq",
"walkdir",
"webkit2gtk",
+6 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "omnivoice-studio"
version = "0.3.19"
version = "0.3.22"
description = "OmniVoice Studio AI voice cloning & dubbing desktop app"
authors = ["Debpalash"]
license = "AGPL-3.0-only"
@@ -84,3 +84,8 @@ libc = "0.2"
[target.'cfg(target_os = "linux")'.dependencies]
webkit2gtk = "2.0"
[dev-dependencies]
# Scoped-reset tests build real directory trees to prove the delete guard only
# ever removes paths inside a validated OmniVoice root.
tempfile = "3"
+7
View File
@@ -34,6 +34,13 @@ fn ensure_sidecar_placeholder(name: &str) {
}
fn main() {
// backend.rs bakes the analytics destination in with option_env!, which cargo
// resolves at COMPILE time — so without these, a cached build would keep the
// token it was first compiled with (in practice: none), and the secret would
// appear to be ignored. Tell cargo the build depends on them.
println!("cargo:rerun-if-env-changed=VITE_POSTHOG_KEY");
println!("cargo:rerun-if-env-changed=VITE_POSTHOG_HOST");
ensure_sidecar_placeholder("uv");
ensure_sidecar_placeholder("ffmpeg");
ensure_sidecar_placeholder("ffprobe");
+83
View File
@@ -278,6 +278,42 @@ fn spawn_failure_diagnostic(python: &Path, err: &std::io::Error) -> String {
// ── Spawn the backend via the bootstrapped venv Python ────────────────────
/// Env the spawned backend needs in order to have an analytics destination at all.
///
/// `core/analytics.py` reads `POSTHOG_PROJECT_TOKEN` from its own environment at
/// RUNTIME — but the backend runs on the *user's* machine, where nothing sets it.
/// Without this, `token_configured()` is false forever and every backend event is
/// dead code in every shipped build, no matter what secret CI holds.
///
/// The token is really a *build* input. release.yml passes the
/// `POSTHOG_PROJECT_TOKEN` secret to the tauri-action step as `VITE_POSTHOG_KEY`,
/// and that step compiles this binary as well as the frontend bundle — so
/// `option_env!` bakes it in on exactly the builds that ship it, and we hand it to
/// the child process here.
///
/// Two properties this preserves, both load-bearing:
/// * **No token baked in (every source build) => nothing is passed** => the
/// backend has no destination and analytics can never run. Correct default.
/// * **A real process env var wins**, so a developer can point a local run at
/// their own PostHog project without recompiling.
///
/// This only supplies a *destination*. Consent is a separate gate the backend
/// checks in prefs (default off) — a token alone never causes a single event.
fn analytics_env(baked_token: Option<&str>, baked_host: Option<&str>) -> Vec<(String, String)> {
let mut out = Vec::new();
let mut pass = |name: &str, baked: Option<&str>| {
if std::env::var(name).is_ok() {
return; // caller's environment wins
}
if let Some(v) = baked.map(str::trim).filter(|v| !v.is_empty()) {
out.push((name.to_string(), v.to_string()));
}
};
pass("POSTHOG_PROJECT_TOKEN", baked_token);
pass("POSTHOG_HOST", baked_host);
out
}
pub fn spawn_backend<R: tauri::Runtime>(app: &tauri::AppHandle<R>, progress: Option<&Arc<Mutex<BootstrapStage>>>) -> Option<Child> {
let log_path = backend_log_path();
let err_path = log_path.with_file_name("backend_err.log");
@@ -337,6 +373,8 @@ pub fn spawn_backend<R: tauri::Runtime>(app: &tauri::AppHandle<R>, progress: Opt
env.push(("OMNIVOICE_CACHE_DIR".into(), models_dir.to_string_lossy().into()));
}
}
// Analytics destination (#1123) — see analytics_env() below for why.
env.extend(analytics_env(option_env!("VITE_POSTHOG_KEY"), option_env!("VITE_POSTHOG_HOST")));
let app_data = app.path().app_local_data_dir().unwrap_or_default();
if let Some(ffmpeg_path) = resolve_ffmpeg(app, &app_data) {
env.push(("FFMPEG_PATH".into(), ffmpeg_path.to_string_lossy().into()));
@@ -430,6 +468,51 @@ mod tests {
use super::*;
use std::io;
// #1123 shipped backend analytics that could never run: core/analytics.py reads
// POSTHOG_PROJECT_TOKEN from the runtime environment, and nothing on the user's
// machine ever set it. These pin the wiring that fixes it — and, just as
// importantly, pin that a build with no token stays silent.
/// The env-var tests below mutate process-global state; keep them off each
/// other's toes (cargo runs tests in threads by default).
static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
#[test]
fn a_baked_token_reaches_the_spawned_backend() {
let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
std::env::remove_var("POSTHOG_PROJECT_TOKEN");
std::env::remove_var("POSTHOG_HOST");
let env = analytics_env(Some("phc_baked"), Some("https://eu.i.posthog.com"));
// Without this the backend has no destination and every event is dropped.
assert!(env.contains(&("POSTHOG_PROJECT_TOKEN".into(), "phc_baked".into())));
assert!(env
.contains(&("POSTHOG_HOST".into(), "https://eu.i.posthog.com".into())));
}
#[test]
fn a_source_build_passes_no_destination_at_all() {
let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
std::env::remove_var("POSTHOG_PROJECT_TOKEN");
std::env::remove_var("POSTHOG_HOST");
// No secret at compile time (anyone building from source), and the empty
// string CI hands over when the secret is simply absent.
assert!(analytics_env(None, None).is_empty());
assert!(analytics_env(Some(""), Some(" ")).is_empty());
}
#[test]
fn the_process_environment_beats_the_baked_token() {
let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
std::env::set_var("POSTHOG_PROJECT_TOKEN", "phc_developers_own_project");
let env = analytics_env(Some("phc_baked"), None);
// Don't override what the caller deliberately set — the child inherits it.
assert!(env.iter().all(|(k, _)| k != "POSTHOG_PROJECT_TOKEN"));
std::env::remove_var("POSTHOG_PROJECT_TOKEN");
}
#[test]
fn spawn_failure_diagnostic_surfaces_path_error_and_hint() {
let err = io::Error::new(io::ErrorKind::NotFound, "No such file or directory");
+97 -3
View File
@@ -54,6 +54,20 @@ pub fn set_stage(state: &Arc<Mutex<BootstrapStage>>, stage: BootstrapStage) {
}
}
/// True when the stage already carries a `Failed` diagnosis.
///
/// The venv bootstrap (`ensure_venv_ready`) records the REAL reason a start
/// failed — "Intel Macs can't run the local AI backend", a `uv sync` error, a
/// blocked GitHub — through `fail()`, which sets exactly this. The spawn watcher
/// must not then bulldoze it with the generic "never started" (#1112): a caller
/// that already knows the cause outranks one that only knows the symptom.
pub fn already_diagnosed(state: &Arc<Mutex<BootstrapStage>>) -> bool {
state
.lock()
.map(|g| matches!(*g, BootstrapStage::Failed { .. }))
.unwrap_or(false)
}
// ── Splash log + byte-progress event channel ─────────────────────────────
#[derive(Clone, Serialize)]
@@ -132,13 +146,26 @@ pub fn get_bootstrap_logs(state: tauri::State<'_, BootstrapState>) -> Vec<LogPay
#[tauri::command]
pub fn retry_bootstrap(app: tauri::AppHandle, state: tauri::State<'_, BootstrapState>) {
if let Ok(mut guard) = state.stage.lock() {
respawn_backend(app, state.stage.clone(), state.logs.clone());
}
/// Take the port back and bring a healthy backend up on it, from scratch if
/// need be. Shared by the Retry button and by a scoped reset (`reset.rs`), which
/// deletes data out from under a stopped backend and needs the *same* recovery
/// afterwards — a fresh process that re-runs `ensure_dirs()` and alembic, so a
/// wiped database comes back empty rather than missing.
pub fn respawn_backend(
app: tauri::AppHandle,
stage: Arc<Mutex<BootstrapStage>>,
logs: Arc<Mutex<Vec<LogPayload>>>,
) {
if let Ok(mut guard) = stage.lock() {
*guard = BootstrapStage::Checking;
}
if let Ok(mut logs) = state.logs.lock() {
if let Ok(mut logs) = logs.lock() {
logs.clear();
}
let stage_handle = state.stage.clone();
let stage_handle = stage;
std::thread::spawn(move || {
let skip_spawn = std::env::var("TAURI_SKIP_BACKEND").is_ok();
if skip_spawn {
@@ -304,6 +331,27 @@ pub fn spawn_backend_and_wait(app: &tauri::AppHandle, stage_handle: &Arc<Mutex<B
);
}
}
// #1112: when the backend NEVER started, `ensure_venv_ready` has
// usually already diagnosed exactly why — Intel Mac unsupported,
// a failed `uv sync`, a blocked GitHub — and recorded it via
// `fail()` as a Failed stage carrying that reason. Overwriting it
// here with the generic "never started — no error output captured"
// destroyed every precise diagnosis: the user saw a message with
// no cause, and the UI's hint matcher (which keys off the specific
// text — e.g. the Intel-Mac hint) could never fire, so they were
// offered a Retry that can never work. Keep the specific reason.
//
// A REAL spawn failure (exec error) is unaffected: it writes its
// diagnostic to backend_err.log and leaves the stage un-Failed, so
// the message below still forms with that tail. Likewise a genuine
// crash after a successful start (stage is Ready/StartingBackend).
if already_diagnosed(stage_handle) {
log::error!(
"Backend never started ({}) — keeping the specific failure already diagnosed",
exit_info
);
return;
}
let msg = if err_tail.is_empty() {
format!("Backend process exited ({}) — no error output captured", exit_info)
} else {
@@ -2214,3 +2262,49 @@ mod tests {
let _ = fs::remove_dir_all(&venv_dir);
}
}
#[cfg(test)]
mod failure_preservation_tests {
use super::*;
fn stage(s: BootstrapStage) -> Arc<Mutex<BootstrapStage>> {
Arc::new(Mutex::new(s))
}
/// #1112: the venv bootstrap diagnoses the REAL reason (Intel Mac, uv sync
/// failure, blocked GitHub) and records it as Failed. The spawn watcher, on
/// seeing "no child ever started", must NOT replace that with the generic
/// "never started — no error output captured": doing so left the user with a
/// causeless message AND stopped the UI's hint matcher (which keys off the
/// specific text) from ever firing, so they were offered a Retry that could
/// never work.
#[test]
fn a_specific_failure_is_recognised_as_already_diagnosed() {
let s = stage(BootstrapStage::Failed {
message: INTEL_MAC_UNSUPPORTED_MSG.to_string(),
});
assert!(already_diagnosed(&s));
}
#[test]
fn a_non_failed_stage_is_not_diagnosed_so_the_generic_message_still_forms() {
// A real crash after a successful start, or a raw exec failure: nobody
// diagnosed it, so the spawn watcher's message is the only one there is.
for st in [
BootstrapStage::Checking,
BootstrapStage::StartingBackend,
BootstrapStage::Ready,
BootstrapStage::InstallingDeps,
] {
assert!(!already_diagnosed(&stage(st)));
}
}
/// The Intel-Mac message must keep the exact wording the frontend hint
/// matcher greps for — if this drifts, the user silently loses the only
/// hint that tells them retrying is pointless.
#[test]
fn intel_mac_message_matches_what_the_ui_hint_matcher_greps_for() {
assert!(INTEL_MAC_UNSUPPORTED_MSG.contains("Intel Macs can't run the local AI backend"));
}
}
+101 -7
View File
@@ -1,7 +1,7 @@
//! Tauri IPC commands: sysinfo, logs, HF cache, paste, tray, quit, dictation shortcut.
use std::fs;
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use std::sync::atomic::Ordering;
use std::time::Duration;
@@ -553,6 +553,11 @@ pub fn save_text_file(path: String, contents: String) -> Result<(), String> {
const CLEAR_WEBVIEW_MARKER: &str = ".clear-webview-cache";
const WEBVIEW_CACHE_DIR: &str = "EBWebView";
/// Retry budget for step 2: `app.restart()` spawns the new process before the
/// old one has fully exited, so its WebView2 children may still hold locks on
/// the profile — 20 × 500 ms rides out that handoff.
const CLEAR_WEBVIEW_ATTEMPTS: u32 = 20;
const CLEAR_WEBVIEW_RETRY_DELAY: Duration = Duration::from_millis(500);
/// (marker file, cache dir) under the pre-app local data dir. Mirrors
/// `config::config_path_pre_app()` — `%LOCALAPPDATA%\<identifier>` on
@@ -592,18 +597,30 @@ pub fn clear_webview_cache_if_marked() {
let Some((marker, cache)) = webview_cache_paths() else {
return;
};
clear_webview_cache_at(&marker, &cache, CLEAR_WEBVIEW_ATTEMPTS, CLEAR_WEBVIEW_RETRY_DELAY);
}
/// Filesystem half of [`clear_webview_cache_if_marked`], parameterized over
/// paths and retry policy so the contract is unit-testable on every platform
/// (the wrapper above is Windows-gated and pins the real paths/policy).
/// Contract, pinned by `webview_cache_repair_tests`:
/// - no marker → nothing is touched;
/// - the marker is consumed FIRST, unconditionally — one-shot, so a failing
/// repair can never loop across launches;
/// - a missing cache dir is success; a locked one is retried, then given up
/// on with an error log — startup is never bricked over a failed repair.
fn clear_webview_cache_at(marker: &Path, cache: &Path, attempts: u32, retry_delay: Duration) {
if !marker.exists() {
return;
}
let _ = fs::remove_file(&marker);
let _ = fs::remove_file(marker);
if !cache.exists() {
return;
}
// `app.restart()` spawns the new process before the old one has fully
// exited, so its WebView2 children may still hold locks — retry briefly.
const ATTEMPTS: u32 = 20;
for attempt in 1..=ATTEMPTS {
match fs::remove_dir_all(&cache) {
for attempt in 1..=attempts {
match fs::remove_dir_all(cache) {
Ok(()) => {
log::warn!(
"cleared WebView2 profile cache at {} (attempt {attempt}) — issue #879 repair",
@@ -612,9 +629,9 @@ pub fn clear_webview_cache_if_marked() {
return;
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return,
Err(e) if attempt < ATTEMPTS => {
Err(e) if attempt < attempts => {
log::debug!("WebView2 cache still locked ({e}) — retrying");
std::thread::sleep(Duration::from_millis(500));
std::thread::sleep(retry_delay);
}
Err(e) => {
// Never brick startup over a failed repair: WebView2 rebuilds
@@ -628,6 +645,83 @@ pub fn clear_webview_cache_if_marked() {
}
}
#[cfg(test)]
mod webview_cache_repair_tests {
use super::clear_webview_cache_at;
use std::fs;
use std::path::{Path, PathBuf};
use std::time::Duration;
/// Tests must not sleep 20 × 500 ms — the retry policy is a parameter.
const FEW: u32 = 3;
const NO_WAIT: Duration = Duration::from_millis(1);
/// Marker file + cache dir (with nested content, like a real profile)
/// under a fresh temp dir.
fn seed(dir: &Path) -> (PathBuf, PathBuf) {
let marker = dir.join(super::CLEAR_WEBVIEW_MARKER);
let cache = dir.join(super::WEBVIEW_CACHE_DIR);
fs::write(&marker, b"test").unwrap();
fs::create_dir_all(cache.join("Default/Cache")).unwrap();
fs::write(cache.join("Default/Cache/data_0"), b"x").unwrap();
(marker, cache)
}
#[test]
fn marker_present_clears_cache_and_consumes_marker_once() {
let dir = tempfile::tempdir().unwrap();
let (marker, cache) = seed(dir.path());
clear_webview_cache_at(&marker, &cache, FEW, NO_WAIT);
assert!(!cache.exists(), "cache dir must be removed");
assert!(!marker.exists(), "marker must be consumed");
// One-shot: with the marker gone, a rebuilt cache is left alone.
fs::create_dir_all(cache.join("Default")).unwrap();
clear_webview_cache_at(&marker, &cache, FEW, NO_WAIT);
assert!(cache.exists(), "second call without a marker is a no-op");
}
#[test]
fn no_marker_touches_nothing() {
let dir = tempfile::tempdir().unwrap();
let (marker, cache) = seed(dir.path());
fs::remove_file(&marker).unwrap();
clear_webview_cache_at(&marker, &cache, FEW, NO_WAIT);
assert!(
cache.join("Default/Cache/data_0").exists(),
"without a marker the cache must be untouched"
);
}
#[test]
fn missing_cache_dir_still_consumes_marker_and_returns() {
let dir = tempfile::tempdir().unwrap();
let marker = dir.path().join(super::CLEAR_WEBVIEW_MARKER);
let cache = dir.path().join(super::WEBVIEW_CACHE_DIR);
fs::write(&marker, b"test").unwrap();
clear_webview_cache_at(&marker, &cache, FEW, NO_WAIT);
assert!(!marker.exists(), "marker consumed even with nothing to clear");
}
/// A cache that can't be deleted (Windows: WebView2 file locks; simulated
/// here with a write-protected dir) must never panic or brick startup —
/// and the marker is STILL consumed, so the failure can't loop across
/// launches.
#[cfg(unix)]
#[test]
fn locked_cache_never_panics_and_marker_is_still_consumed() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let (marker, cache) = seed(dir.path());
// Deny writes on the cache dir so its entries can't be unlinked.
fs::set_permissions(&cache, fs::Permissions::from_mode(0o555)).unwrap();
clear_webview_cache_at(&marker, &cache, FEW, NO_WAIT);
assert!(!marker.exists(), "one-shot: marker consumed even on failure");
assert!(cache.exists(), "a locked cache survives the failed repair");
// Restore permissions so TempDir can clean up.
fs::set_permissions(&cache, fs::Permissions::from_mode(0o755)).unwrap();
}
}
#[cfg(test)]
mod paste_error_tests {
use super::{kind_err, CLIPBOARD_RESTORE_DELAY};
+164 -4
View File
@@ -17,6 +17,14 @@
//! Only the last [`MAX_MARKERS`] crashes are kept. Acknowledgment is a
//! persisted timestamp (not deletion!) so viewing the crash details doesn't
//! destroy the evidence a subsequent bug report needs.
//!
//! Markers are **version-gated**: each records the app version that wrote it,
//! and markers from a different release than the running build are ignored on
//! read — an unacknowledged "backend crashed" notice must not resurface after
//! the upgrade that may well have fixed the crash. Stale markers are pruned
//! from disk by the WRITE paths only ([`record_crash`], the ack command):
//! the read path must never write, because it is polled concurrently with
//! the death watchers (see [`get_last_backend_crash`]).
use std::fs;
use std::path::{Path, PathBuf};
@@ -78,7 +86,15 @@ pub struct CrashMarker {
pub signal: Option<i32>,
/// Human-readable `ExitStatus` display ("exit status: 134", …).
pub exit_desc: String,
/// App/backend version (lockstep per the versioning rule).
/// App/backend version (lockstep per the versioning rule) that recorded
/// this marker. `#[serde(default)]` so a legacy marker written before
/// this field was version-gated still deserializes (as `""`) instead of
/// discarding the whole store — and `""` never matches the running
/// version, so legacy markers are treated as stale. That's the safe
/// default: a marker of unknown provenance may predate the running
/// build, and a stale post-upgrade crash notice is exactly the bug the
/// gate exists to prevent.
#[serde(default)]
pub backend_version: String,
/// Seconds the backend had been running when it died.
pub uptime_s: u64,
@@ -113,6 +129,35 @@ pub fn newest_with_ack(store: &CrashStore) -> Option<(CrashMarker, bool)> {
store.markers.first().map(|m| (m.clone(), m.ts <= store.acked_ts))
}
// ── Version gating ─────────────────────────────────────────────────────────
/// The release part of a version — `"0.3.22-7"` (preview stamp) → `"0.3.22"`.
fn base_version(version: &str) -> &str {
version.split(['-', '+']).next().unwrap_or(version)
}
/// Whether a marker written by `marker_version` is still current news for an
/// app running `current_version`. Preview builds stamp `X.Y.Z-N` onto the
/// same release, so only the base version has to match. A legacy marker with
/// no recorded version deserializes as `""` and never matches — stale by
/// design (see the `backend_version` field docs).
fn same_release(marker_version: &str, current_version: &str) -> bool {
!marker_version.is_empty() && base_version(marker_version) == base_version(current_version)
}
/// Drop markers recorded by a different release than `current_version` —
/// after an upgrade they describe a build the user no longer runs (quite
/// possibly the build whose crash the upgrade fixed), so neither the crash
/// notice nor the bug-report prefill should surface them. Returns whether
/// anything was dropped. Pure, like [`push_marker`], so the policy is
/// unit-tested without the filesystem. Only WRITE paths may persist the
/// pruned store — see [`read_notice_from`] for why the read path must not.
pub fn prune_stale_versions(store: &mut CrashStore, current_version: &str) -> bool {
let before = store.markers.len();
store.markers.retain(|m| same_release(&m.backend_version, current_version));
store.markers.len() != before
}
// ── Persistence ────────────────────────────────────────────────────────────
/// The marker store lives next to the backend logs (same rationale: it's
@@ -172,6 +217,10 @@ pub fn record_crash(marker: CrashMarker) {
);
let path = markers_path();
let mut store = load_store_from(&path);
// A fresh crash also retires markers from older releases: the version
// gate below would never surface them again, and they shouldn't occupy
// rotation slots the current release's evidence needs.
prune_stale_versions(&mut store, env!("CARGO_PKG_VERSION"));
push_marker(&mut store, marker);
save_store_to(&path, &store);
}
@@ -187,12 +236,33 @@ pub struct CrashNotice {
pub acknowledged: bool,
}
/// Read half of [`get_last_backend_crash`], parameterized over path/version
/// so the read-only contract is unit-testable.
///
/// STRICTLY READ-ONLY — stale-version markers are filtered in memory, never
/// pruned to disk here. The frontend polls this command every second for 8 s
/// after a stream drops (#1119's `streamDropError`) — i.e. exactly while the
/// death watcher may be inside `record_crash`'s load→push→save. A
/// load→prune→save here could interleave with that write and clobber the
/// fresh marker with our older snapshot, destroying the only evidence of the
/// crash (Greptile P1 on #1145). Disk cleanup of stale markers happens on
/// the write paths instead ([`record_crash`], `acknowledge_backend_crash`),
/// where a crash-vs-ack collision was already the pre-existing (rare,
/// user-paced) exposure.
pub fn read_notice_from(path: &Path, current_version: &str) -> Option<CrashNotice> {
let mut store = load_store_from(path);
prune_stale_versions(&mut store, current_version);
newest_with_ack(&store).map(|(marker, acknowledged)| CrashNotice { marker, acknowledged })
}
/// Newest backend crash marker, or null when the backend has never crashed.
/// `acknowledged` tells the UI whether the user already viewed/dismissed it.
/// Markers from a different release than this build are ignored, so one
/// stale unacknowledged crash can't resurface after an upgrade (recurrence
/// audit follow-up to #941).
#[tauri::command]
pub fn get_last_backend_crash() -> Option<CrashNotice> {
let store = load_store_from(&markers_path());
newest_with_ack(&store).map(|(marker, acknowledged)| CrashNotice { marker, acknowledged })
read_notice_from(&markers_path(), env!("CARGO_PKG_VERSION"))
}
/// Mark the newest crash as seen. Deliberately does NOT delete the marker —
@@ -201,12 +271,18 @@ pub fn get_last_backend_crash() -> Option<CrashNotice> {
pub fn acknowledge_backend_crash() {
let path = markers_path();
let mut store = load_store_from(&path);
// Same gate as the read path, so the ack lands on the marker the user
// actually saw — never on a stale one from a previous release.
let mut dirty = prune_stale_versions(&mut store, env!("CARGO_PKG_VERSION"));
if let Some(newest_ts) = store.markers.first().map(|m| m.ts) {
if store.acked_ts < newest_ts {
store.acked_ts = newest_ts;
save_store_to(&path, &store);
dirty = true;
}
}
if dirty {
save_store_to(&path, &store);
}
}
#[cfg(test)]
@@ -291,6 +367,90 @@ mod tests {
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn same_release_matches_previews_and_rejects_versionless() {
// Preview builds stamp X.Y.Z-N (run number) on the same release — a
// crash under 0.3.22-7 is current news for 0.3.22 and 0.3.22-9 alike.
assert!(same_release("0.3.22-7", "0.3.22"));
assert!(same_release("0.3.22", "0.3.22-9"));
assert!(same_release("0.3.22+meta", "0.3.22"));
assert!(!same_release("0.3.21", "0.3.22"), "older release is stale");
assert!(!same_release("", "0.3.22"), "no recorded version = stale");
}
#[test]
fn current_release_markers_survive_the_version_gate() {
let mut store = CrashStore::default();
push_marker(&mut store, marker(100)); // backend_version "0.0.0-test"
assert!(
!prune_stale_versions(&mut store, "0.0.0"),
"same release (modulo preview stamp) → nothing pruned"
);
let (m, acked) = newest_with_ack(&store).expect("current-release marker surfaces");
assert_eq!(m.ts, 100);
assert!(!acked);
}
#[test]
fn different_release_markers_are_ignored_and_pruned() {
// The post-upgrade scenario: an unacknowledged crash from the build
// the user just upgraded away from must not surface as if the new
// build had crashed.
let mut store = CrashStore::default();
push_marker(&mut store, marker(100)); // "0.0.0-test" — the old build
let mut current = marker(50);
current.backend_version = "9.9.9".into();
push_marker(&mut store, current);
assert!(prune_stale_versions(&mut store, "9.9.9"), "stale marker dropped");
let kept: Vec<&str> = store.markers.iter().map(|m| m.backend_version.as_str()).collect();
assert_eq!(kept, vec!["9.9.9"], "only the running release's evidence remains");
}
#[test]
fn legacy_versionless_markers_deserialize_and_are_stale() {
// A marker JSON with no backend_version at all must (a) not wedge
// deserialization of the whole store and (b) never surface: with no
// provenance it may predate the running build, and a stale
// post-upgrade crash notice is exactly the bug the gate prevents.
let json = r#"{"acked_ts":0,"markers":[{"ts":1,"exit_code":1,"signal":null,"exit_desc":"exit status: 1","uptime_s":5,"last_stderr":""}]}"#;
let mut store: CrashStore = serde_json::from_str(json).expect("legacy shape still loads");
assert_eq!(store.markers[0].backend_version, "", "serde default fills the gap");
assert!(prune_stale_versions(&mut store, "0.3.22"));
assert!(newest_with_ack(&store).is_none(), "legacy marker never surfaces");
}
#[test]
fn the_read_path_filters_stale_markers_without_touching_the_file() {
// Greptile P1 on #1145: the frontend polls get_last_backend_crash
// every second while the death watcher may be mid-record_crash. If
// the read path persisted its prune, that save could interleave with
// the watcher's and clobber the brand-new marker with an older
// snapshot. Contract: reading filters in memory and NEVER writes.
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("backend_crash_markers.json");
let mut store = CrashStore::default();
push_marker(&mut store, marker(100)); // "0.0.0-test" — a stale release
save_store_to(&path, &store);
let before = fs::read(&path).unwrap();
// Stale marker is invisible to the notice…
assert!(read_notice_from(&path, "9.9.9").is_none());
// …but the file is byte-identical: the read left the store alone, so
// a marker recorded concurrently could not have been overwritten.
assert_eq!(fs::read(&path).unwrap(), before, "read path must not write");
// And a current-release marker still surfaces over the stale one.
let mut current = marker(200);
current.backend_version = "9.9.9".into();
push_marker(&mut store, current);
save_store_to(&path, &store);
let before = fs::read(&path).unwrap();
let notice = read_notice_from(&path, "9.9.9").expect("current marker surfaces");
assert_eq!(notice.marker.ts, 200);
assert!(!notice.acknowledged);
assert_eq!(fs::read(&path).unwrap(), before, "read path must not write");
}
#[test]
fn backend_exit_labels_code_signal_and_unknown() {
let coded = BackendExit { code: Some(-1073740791), signal: None, description: "x".into() };
+6
View File
@@ -14,6 +14,8 @@ pub mod tools;
pub mod backend;
pub mod commands;
pub mod crash;
pub mod reset;
pub mod uninstall;
pub mod updater_channel;
use std::process::Child;
@@ -400,6 +402,10 @@ pub fn run() {
commands::clear_webview_cache_and_relaunch,
crash::get_last_backend_crash,
crash::acknowledge_backend_crash,
uninstall::uninstall_scan,
uninstall::uninstall_purge,
reset::reset_scan,
reset::reset_purge,
])
.setup(move |app| {
app.handle().plugin(tauri_plugin_dialog::init())?;
+695
View File
@@ -0,0 +1,695 @@
//! Scoped reset — Settings → Storage → "Reset & remove".
//!
//! Factory reset used to mean one thing: clear `localStorage`. That is the
//! *smallest* useful reset and it was the only one, so a user whose install had
//! gone wrong in any deeper way (a half-downloaded model, a wedged sidecar
//! engine, settings they could no longer find) had exactly two options — live
//! with it, or delete everything and start over. This module fills the gap with
//! a scope registry: every distinct thing OmniVoice writes to disk, sized, and
//! individually removable.
//!
//! **Why the shell and not the backend.** Two reasons the backend cannot do
//! this to itself:
//! 1. A loaded model memory-maps its weights straight out of the HF cache. On
//! Windows those files are locked while mapped, so "delete the models" from
//! inside the process that mapped them simply fails.
//! 2. `ensure_dirs()` runs at *import* time (backend/core/config.py). Delete
//! `voices/` or `outputs/` under a live backend and nothing recreates them;
//! every subsequent write lands in a missing directory.
//! Plus the in-memory strays: `_dub_jobs`, batch `_jobs`, the media-tools
//! version cache, the 5-minute storage-report cache — all of them would keep
//! pointing at paths that no longer exist.
//!
//! So the shell stops the backend, deletes, and starts it again. That restart is
//! also what *repairs* the wipe: the fresh process re-runs `ensure_dirs()` and
//! alembic, so a removed database comes back empty rather than missing. This is
//! the same reason `uninstall.rs` lives here — but uninstall quits afterwards,
//! and a reset must leave the user with a working app.
//!
//! Safety: every target is resolved from the same single source of truth the
//! rest of the app uses (`setup::{resolved,default}_{data,models}_dir`,
//! `backend::backend_log_path`), and nothing is removed unless it sits inside a
//! *validated* root — one that either carries an OmniVoice-owned path component
//! or holds an actual OmniVoice signature file. A custom data dir on an external
//! volume passes on the signature; a mis-set `data_dir: "/"` passes on neither.
use std::fs;
use std::path::{Path, PathBuf};
use serde::Serialize;
use tauri::Manager;
use crate::bootstrap::BootstrapState;
use crate::{backend_port, AppFlags};
/// Every scope the UI can offer. Two of them (`ui_prefs`, `history`) own no
/// files — they are listed here so the frontend has one registry to render, but
/// they are cleared frontend-side (localStorage / the history DELETE endpoints)
/// and never reach `reset_purge`.
pub const FRONTEND_SCOPES: [&str; 2] = ["ui_prefs", "history"];
/// Scopes that delete files, in the order they are removed.
pub const DISK_SCOPES: [&str; 7] = [
"settings", "content", "engines", "tools", "models", "caches", "logs",
];
#[derive(Serialize, Clone, Debug)]
pub struct ResetScope {
/// Stable id the UI keys off.
pub key: String,
/// Concrete paths this scope would remove (empty for frontend-only scopes).
pub paths: Vec<String>,
pub size_bytes: u64,
pub exists: bool,
/// True only for the Hugging Face cache when it lives OUTSIDE our own tree —
/// i.e. the standard `~/.cache/huggingface` other ML tools share. On Windows
/// (and in a portable install) the cache is app-private, so this is false and
/// the UI shows no scary caveat it doesn't need to.
pub shared: bool,
/// Frontend-only scopes need no backend bounce; disk scopes always do.
pub needs_restart: bool,
}
#[derive(Serialize, Clone, Debug, Default)]
pub struct ResetReport {
pub removed: Vec<String>,
/// Paths that existed but could not be removed (locked, permissions).
pub failed: Vec<String>,
/// Paths the safety guard rejected — a bug or a corrupt config, never routine.
pub refused: Vec<String>,
pub freed_bytes: u64,
/// True when the backend was stopped and re-launched.
pub restarted: bool,
}
/// The four directories every scope is carved out of. Kept as a plain struct so
/// target resolution is pure and unit-testable without an `AppHandle`.
#[derive(Clone, Debug)]
pub struct Roots {
pub data: PathBuf,
pub models: PathBuf,
/// The backend's own log dir — outside DATA_DIR on every platform.
pub logs: Option<PathBuf>,
pub temp: PathBuf,
}
/// Recursive size. Symlinks are never followed: the HF cache is a forest of
/// symlinks into `blobs/`, and following them would count the same bytes twice
/// (and could wander clean out of the tree).
fn dir_size(path: &Path) -> u64 {
if !path.exists() {
return 0;
}
if path.is_file() {
return fs::symlink_metadata(path).map(|m| m.len()).unwrap_or(0);
}
walkdir::WalkDir::new(path)
.follow_links(false)
.into_iter()
.flatten()
.filter(|e| e.file_type().is_file())
.filter_map(|e| e.metadata().ok())
.map(|m| m.len())
.sum()
}
/// Children of `dir` whose file name starts with `prefix`. This is how the
/// SQLite trio (`omnivoice.db`, `-wal`, `-shm`) and the rolling logs
/// (`omnivoice.log`, `.log.1`, …) are caught without a glob crate — and why the
/// expansion is re-run at purge time rather than trusting the scan: WAL siblings
/// come and go while the backend is still alive.
fn prefixed_children(dir: &Path, prefix: &str) -> Vec<PathBuf> {
let Ok(entries) = fs::read_dir(dir) else {
return vec![];
};
let mut out: Vec<PathBuf> = entries
.flatten()
.filter(|e| {
e.file_name()
.to_str()
.map(|n| n.starts_with(prefix))
.unwrap_or(false)
})
.map(|e| e.path())
.collect();
out.sort();
out
}
/// True when the model cache sits inside our own tree, so wiping it cannot touch
/// another tool's downloads: Windows redirects it to
/// `%LOCALAPPDATA%\OmniVoice\hf_cache` (MAX_PATH), and a portable install keeps
/// it under `<portable>/data/models`. Anywhere else it is the shared HF cache.
pub fn models_are_shared(models: &Path, data: &Path) -> bool {
if models.starts_with(data) {
return false;
}
let owned = ["omnivoice", ".omnivoice"];
let app_private = models
.components()
.filter_map(|c| c.as_os_str().to_str())
.any(|c| owned.iter().any(|o| c.eq_ignore_ascii_case(o)));
!app_private
}
/// Does this directory actually look like OmniVoice's? Used to clear a *custom*
/// data or model dir — one the user pointed us at, whose path carries no
/// OmniVoice-ish name — without also clearing whatever else a mis-configured
/// path might point at. Presence of our own files is the proof of ownership.
fn has_app_signature(dir: &Path) -> bool {
for marker in ["omnivoice.db", "prefs.json", "voices", "outputs", "engines"] {
if dir.join(marker).exists() {
return true;
}
}
// A Hugging Face cache root: `hub/` or a `models--org--name` snapshot dir.
if dir.join("hub").is_dir() {
return true;
}
fs::read_dir(dir)
.map(|entries| {
entries.flatten().any(|e| {
e.file_name()
.to_str()
.map(|n| n.starts_with("models--"))
.unwrap_or(false)
})
})
.unwrap_or(false)
}
/// A root may only be deleted out of if it is absolute, is not `/` or `$HOME`,
/// is not a bare top-level directory, and is recognizably ours — by name, or by
/// the files it contains. This is the backstop between a corrupt `config.json`
/// and `remove_dir_all`.
pub fn is_valid_root(root: &Path, home: Option<&Path>) -> bool {
if !root.is_absolute() || root.parent().is_none() {
return false;
}
if home == Some(root) {
return false;
}
// "/Users" or "C:\" — never a data dir, always a catastrophe.
if root.components().count() < 3 {
return false;
}
crate::uninstall::is_recognizably_ours(root, home) || has_app_signature(root)
}
/// Files and directories a scope owns. Pure: `Roots` in, paths out. Paths that
/// do not exist are included — the caller filters — so the scan can report an
/// empty scope rather than silently omitting it.
pub fn scope_targets(key: &str, roots: &Roots) -> Vec<PathBuf> {
let data = &roots.data;
match key {
// prefs.json only. The user's *storage locations* (config.json, the
// ~/.config/omnivoice/env file) are deliberately NOT reset: they are
// install-shape choices, not preferences, and clearing the model-cache
// pointer would strand gigabytes of already-downloaded weights at a path
// the app no longer looks in. Same principle as PRESERVED_KEYS on the
// frontend (the remote-backend URL survives a preference reset).
"settings" => vec![data.join("prefs.json")],
// Everything the user made. The database goes with it — history, voice
// profiles, projects, glossary and pronunciation entries all live in it,
// and half-deleting it (rows without files) is how you get a library full
// of broken entries. A fresh backend recreates the schema via alembic.
"content" => {
let mut v = vec![
data.join("voices"),
data.join("outputs"),
data.join("dub_jobs"),
data.join("batch"),
data.join("preview"),
];
v.extend(prefixed_children(data, "omnivoice.db"));
v
}
// Sidecar engine installs (IndexTTS-2 & friends): a git checkout, a venv
// and multi-GB weights each, under DATA_DIR/engines/<id>.
"engines" => vec![data.join("engines")],
// Checksum-pinned ffmpeg/ffprobe/yt-dlp binaries the app fetched itself.
"tools" => vec![data.join("media_tools")],
"models" => vec![roots.models.clone()],
"caches" => {
let mut v = vec![data.join("gallery_cache"), data.join("gallery_sources.json")];
// Scratch dirs the app leaves in the OS temp dir. The `omnivoice`
// name prefix IS the guard here — these live outside every root.
v.extend(prefixed_children(&roots.temp, "omnivoice"));
v
}
"logs" => {
let mut v = vec![
data.join("crash_log.txt"),
data.join("error_journal.jsonl"),
];
v.extend(prefixed_children(data, "omnivoice.log"));
if let Some(logs) = &roots.logs {
v.push(logs.clone());
}
v
}
_ => vec![],
}
}
/// Is this target safe to remove? It must sit inside a validated root — or, for
/// the OS temp scratch dirs which live outside every root, be a direct child of
/// the temp dir carrying our name prefix.
fn target_allowed(path: &Path, roots: &Roots, home: Option<&Path>) -> bool {
if path.parent() == Some(roots.temp.as_path()) {
return path
.file_name()
.and_then(|n| n.to_str())
.map(|n| n.starts_with("omnivoice"))
.unwrap_or(false);
}
for root in [Some(&roots.data), Some(&roots.models), roots.logs.as_ref()]
.into_iter()
.flatten()
{
if path.starts_with(root) && is_valid_root(root, home) {
return true;
}
}
false
}
fn roots_for(app: &tauri::AppHandle) -> Roots {
Roots {
data: crate::setup::resolved_data_dir(app).unwrap_or_else(crate::setup::default_data_dir),
models: crate::setup::resolved_models_dir(app)
.unwrap_or_else(crate::setup::default_models_dir),
logs: crate::backend::backend_log_path()
.parent()
.map(|p| p.to_path_buf()),
temp: std::env::temp_dir(),
}
}
/// Every scope with its real size — what the confirmation UI renders. Sizes are
/// what make this honest: "Reset everything" next to a number the user can check
/// against the disk beats a wall of adjectives.
#[tauri::command]
pub async fn reset_scan(app: tauri::AppHandle) -> Vec<ResetScope> {
tauri::async_runtime::spawn_blocking(move || {
let roots = roots_for(&app);
let shared = models_are_shared(&roots.models, &roots.data);
let mut out = Vec::new();
for key in FRONTEND_SCOPES {
out.push(ResetScope {
key: key.to_string(),
paths: vec![],
size_bytes: 0,
exists: true,
shared: false,
needs_restart: false,
});
}
for key in DISK_SCOPES {
let targets = scope_targets(key, &roots);
let present: Vec<&PathBuf> = targets.iter().filter(|p| p.exists()).collect();
out.push(ResetScope {
key: key.to_string(),
size_bytes: present.iter().map(|p| dir_size(p)).sum(),
exists: !present.is_empty(),
paths: present.iter().map(|p| p.to_string_lossy().to_string()).collect(),
shared: key == "models" && shared,
needs_restart: true,
});
}
out
})
.await
.unwrap_or_default()
}
/// The destructive core: delete every target of every wanted scope, guarding
/// each path against the validated roots. Pure over the filesystem — no
/// `AppHandle`, no backend — so it can be exercised end-to-end against a real
/// on-disk OmniVoice tree in a test. `reset_purge` is this plus stop-backend
/// before and restart-backend after.
///
/// `wanted` is assumed already filtered to `DISK_SCOPES`; unknown names yield no
/// targets and are harmless.
pub fn purge_scopes(roots: &Roots, wanted: &[String], home: Option<&Path>) -> ResetReport {
let mut report = ResetReport::default();
for key in DISK_SCOPES.iter().filter(|k| wanted.iter().any(|w| w == *k)) {
for path in scope_targets(key, roots) {
if !path.exists() {
continue;
}
if !target_allowed(&path, roots, home) {
log::warn!("reset: refusing to delete unrecognized path {}", path.display());
report.refused.push(path.to_string_lossy().to_string());
continue;
}
let size = dir_size(&path);
let outcome = if path.is_dir() {
fs::remove_dir_all(&path)
} else {
fs::remove_file(&path)
};
match outcome {
Ok(()) => {
log::info!("reset[{key}]: removed {}", path.display());
report.freed_bytes += size;
report.removed.push(path.to_string_lossy().to_string());
}
Err(e) => {
log::error!("reset[{key}]: failed to remove {}: {e}", path.display());
report.failed.push(path.to_string_lossy().to_string());
}
}
}
}
report
}
/// Delete the selected scopes, then bring the backend back.
///
/// Unknown or frontend-only scope names are ignored rather than erroring: the
/// frontend sends one list for the whole reset, and `ui_prefs` / `history` are
/// its own to handle.
#[tauri::command]
pub async fn reset_purge(app: tauri::AppHandle, scopes: Vec<String>) -> Result<ResetReport, String> {
let wanted: Vec<String> = scopes
.into_iter()
.filter(|s| DISK_SCOPES.contains(&s.as_str()))
.collect();
let mut report = ResetReport::default();
if wanted.is_empty() {
return Ok(report);
}
// Stop the backend first. `set_backend_kill_intended` tells the #941/#567
// supervisor this death is deliberate, so it neither writes a crash marker
// nor races us by respawning a backend into the directories we are deleting.
// Note we do NOT set `flags.quitting` — that is the uninstall path, and it
// would stop us from starting the backend again at the end.
crate::bootstrap::set_backend_kill_intended(true);
crate::backend::kill_orphan_on_port(backend_port());
let purge_app = app.clone();
let mut report = tauri::async_runtime::spawn_blocking(move || {
// Give the process a moment to actually exit and drop its file handles;
// on Windows a mapped weights file stays locked until it does.
std::thread::sleep(std::time::Duration::from_millis(600));
let roots = roots_for(&purge_app);
let home = dirs_next::home_dir();
purge_scopes(&roots, &wanted, home.as_deref())
})
.await
.map_err(|e| format!("reset failed: {e}"))?;
// Back up. The fresh backend re-runs ensure_dirs() and alembic, so a deleted
// database returns empty instead of missing. If the app is on its way out
// anyway, don't fight the shutdown.
let flags = app.state::<AppFlags>();
if !flags.quitting.load(std::sync::atomic::Ordering::SeqCst) {
let state = app.state::<BootstrapState>();
crate::bootstrap::respawn_backend(app.clone(), state.stage.clone(), state.logs.clone());
report.restarted = true;
} else {
crate::bootstrap::set_backend_kill_intended(false);
}
Ok(report)
}
#[cfg(test)]
mod tests {
use super::*;
fn roots(tmp: &Path) -> Roots {
Roots {
data: tmp.join("OmniVoice"),
models: tmp.join(".cache/huggingface"),
logs: Some(tmp.join("Logs/OmniVoice")),
temp: tmp.join("tmp"),
}
}
#[test]
fn content_scope_takes_the_database_with_the_media() {
let dir = tempfile::tempdir().unwrap();
let r = roots(dir.path());
fs::create_dir_all(&r.data).unwrap();
for f in ["omnivoice.db", "omnivoice.db-wal", "omnivoice.db-shm"] {
fs::write(r.data.join(f), b"x").unwrap();
}
let targets = scope_targets("content", &r);
// Rows and files go together, or the library fills with broken entries.
for expect in ["voices", "outputs", "dub_jobs", "omnivoice.db", "omnivoice.db-wal"] {
assert!(
targets.iter().any(|p| p.ends_with(expect)),
"content scope must cover {expect}"
);
}
}
#[test]
fn settings_scope_spares_the_storage_locations() {
let dir = tempfile::tempdir().unwrap();
let r = roots(dir.path());
let targets = scope_targets("settings", &r);
assert_eq!(targets, vec![r.data.join("prefs.json")]);
// Resetting preferences must never move the model cache: config.json and
// the user env file are install shape, not preference.
assert!(!targets.iter().any(|p| p.ends_with("config.json")));
}
#[test]
fn logs_scope_reaches_the_backend_log_dir_outside_data() {
let dir = tempfile::tempdir().unwrap();
let r = roots(dir.path());
let targets = scope_targets("logs", &r);
assert!(targets.iter().any(|p| Some(p.as_path()) == r.logs.as_deref()));
assert!(targets.iter().any(|p| p.ends_with("crash_log.txt")));
}
#[test]
fn unknown_scope_is_inert() {
let dir = tempfile::tempdir().unwrap();
assert!(scope_targets("rm -rf /", &roots(dir.path())).is_empty());
assert!(scope_targets("ui_prefs", &roots(dir.path())).is_empty());
}
#[test]
fn hf_cache_is_shared_only_when_it_sits_outside_our_tree() {
// macOS / Linux: the standard cache, shared with every other HF tool.
assert!(models_are_shared(
Path::new("/Users/me/.cache/huggingface"),
Path::new("/Users/me/Library/Application Support/OmniVoice")
));
// Windows: redirected into our own dir to dodge MAX_PATH → app-private.
assert!(!models_are_shared(
Path::new("C:/Users/me/AppData/Local/OmniVoice/hf_cache"),
Path::new("C:/Users/me/AppData/Roaming/OmniVoice")
));
// Portable: models live under the portable data dir → app-private.
assert!(!models_are_shared(
Path::new("/Volumes/USB/OmniVoiceStudio/data/models"),
Path::new("/Volumes/USB/OmniVoiceStudio/data")
));
}
#[test]
fn a_custom_data_dir_qualifies_on_its_contents_not_its_name() {
let dir = tempfile::tempdir().unwrap();
let custom = dir.path().join("my stuff");
fs::create_dir_all(&custom).unwrap();
// Nothing OmniVoice-ish in the name and no signature yet → refuse.
assert!(!is_valid_root(&custom, None));
// The app's own database is proof enough that this dir is ours.
fs::write(custom.join("omnivoice.db"), b"x").unwrap();
assert!(is_valid_root(&custom, None));
}
#[test]
fn never_root_never_home_never_a_top_level_dir() {
let home = PathBuf::from("/Users/someone");
assert!(!is_valid_root(Path::new("/"), Some(&home)));
assert!(!is_valid_root(&home, Some(&home)));
assert!(!is_valid_root(Path::new("/Users"), Some(&home)));
assert!(!is_valid_root(Path::new("relative/omnivoice"), None));
}
#[test]
fn targets_outside_every_root_are_rejected() {
let dir = tempfile::tempdir().unwrap();
let r = roots(dir.path());
fs::create_dir_all(&r.data).unwrap();
fs::write(r.data.join("omnivoice.db"), b"x").unwrap();
assert!(target_allowed(&r.data.join("voices"), &r, None));
// A path that is not under data, models, or logs — the guard's whole job.
assert!(!target_allowed(Path::new("/etc/passwd"), &r, None));
assert!(!target_allowed(&dir.path().join("Documents"), &r, None));
}
#[test]
fn temp_scratch_is_guarded_by_its_name_prefix() {
let dir = tempfile::tempdir().unwrap();
let r = roots(dir.path());
fs::create_dir_all(&r.temp).unwrap();
assert!(target_allowed(&r.temp.join("omnivoice_dub_42"), &r, None));
// Somebody else's scratch dir in the same temp root.
assert!(!target_allowed(&r.temp.join("com.apple.something"), &r, None));
}
// ── End-to-end deletion against a real on-disk tree ───────────────────────
//
// Everything above tests target RESOLUTION; these run the actual
// `fs::remove_*` loop against a filesystem that looks like a real install, so
// the destructive path is exercised for real (not mocked) before it ever
// touches a user's machine.
/// Build a tree that mirrors a lived-in OmniVoice install and return `Roots`.
fn seed_install(base: &Path) -> Roots {
let data = base.join("OmniVoice");
let models = base.join(".cache").join("huggingface");
let logs = base.join("Logs").join("OmniVoice");
let temp = base.join("tmp");
let mk = |p: &Path| fs::create_dir_all(p).unwrap();
let touch = |p: PathBuf, n: usize| {
fs::create_dir_all(p.parent().unwrap()).unwrap();
fs::write(p, vec![b'x'; n]).unwrap();
};
// content
touch(data.join("voices").join("alice.wav"), 4096);
touch(data.join("outputs").join("take1.wav"), 8192);
touch(data.join("dub_jobs").join("job1").join("seg_0.wav"), 2048);
touch(data.join("preview").join("p.wav"), 512);
for f in ["omnivoice.db", "omnivoice.db-wal", "omnivoice.db-shm"] {
touch(data.join(f), 1024);
}
// settings + install-shape files that must SURVIVE a settings reset
touch(data.join("prefs.json"), 200);
touch(data.join("config.json"), 100);
// engines / tools / caches / logs
touch(data.join("engines").join("indextts2").join(".venv").join("pyvenv.cfg"), 64);
touch(data.join("media_tools").join("ffbin-abc").join("ffmpeg"), 4096);
touch(data.join("gallery_cache").join("thumb.png"), 256);
touch(data.join("gallery_sources.json"), 64);
touch(data.join("crash_log.txt"), 128);
touch(data.join("error_journal.jsonl"), 128);
touch(data.join("omnivoice.log"), 512);
touch(data.join("omnivoice.log.1"), 512);
mk(&logs);
touch(logs.join("backend.log"), 256);
// models (shared HF cache)
touch(models.join("hub").join("models--org--x").join("snapshot").join("w.bin"), 16384);
// temp scratch — ours and a stranger's
touch(temp.join("omnivoice_scratch").join("f"), 128);
touch(temp.join("com.apple.keep").join("f"), 128);
Roots { data, models, logs: Some(logs), temp }
}
#[test]
fn everything_scope_wipes_the_install_but_leaves_the_python_env_and_foreign_files() {
let dir = tempfile::tempdir().unwrap();
let roots = seed_install(dir.path());
// A neighbour dir the user also keeps under the same parent, and the
// managed Python env that a RESET (unlike uninstall) must never remove.
let neighbour = dir.path().join("Documents");
fs::create_dir_all(neighbour.join("thesis")).unwrap();
let env = dir.path().join("com.debpalash.omnivoice-studio");
fs::create_dir_all(env.join("project").join(".venv")).unwrap();
// "Everything OmniVoice did" minus the frontend-only scopes.
let wanted: Vec<String> =
["settings", "content", "engines", "tools", "models", "caches", "logs"]
.iter()
.map(|s| s.to_string())
.collect();
let report = purge_scopes(&roots, &wanted, dir.path().to_str().map(Path::new));
// The install is gone…
for gone in [
roots.data.join("voices"),
roots.data.join("outputs"),
roots.data.join("omnivoice.db"),
roots.data.join("omnivoice.db-wal"),
roots.data.join("prefs.json"),
roots.data.join("engines"),
roots.data.join("media_tools"),
roots.data.join("gallery_cache"),
roots.data.join("crash_log.txt"),
roots.data.join("omnivoice.log"),
roots.models.clone(),
roots.logs.clone().unwrap(),
roots.temp.join("omnivoice_scratch"),
] {
assert!(!gone.exists(), "should have been removed: {}", gone.display());
}
// …but the Python env, a stranger's temp dir, and the user's neighbour
// folder are untouched.
assert!(env.join("project").join(".venv").exists(), "reset must not touch the venv");
assert!(roots.temp.join("com.apple.keep").exists(), "another app's scratch is off-limits");
assert!(neighbour.join("thesis").exists(), "a sibling user folder must survive");
assert!(report.refused.is_empty(), "nothing legitimate should be refused: {:?}", report.refused);
assert!(report.failed.is_empty(), "no deletion should fail: {:?}", report.failed);
assert!(report.freed_bytes > 16_000, "freed byte count should reflect the models blob");
}
#[test]
fn settings_reset_keeps_content_the_env_pointer_and_the_models() {
let dir = tempfile::tempdir().unwrap();
let roots = seed_install(dir.path());
let report = purge_scopes(&roots, &["settings".to_string()], dir.path().to_str().map(Path::new));
// prefs.json is gone; everything that is data or install-shape stays.
assert!(!roots.data.join("prefs.json").exists());
assert!(roots.data.join("config.json").exists(), "storage-location choice must survive");
assert!(roots.data.join("voices").join("alice.wav").exists(), "voices are not a preference");
assert!(roots.data.join("omnivoice.db").exists(), "the database is not a preference");
assert!(roots.models.join("hub").exists(), "a settings reset must not delete model weights");
assert_eq!(report.removed.len(), 1);
}
#[test]
fn a_poisoned_data_dir_pointing_at_home_deletes_nothing() {
// If config.json were corrupted to data_dir="$HOME", every target resolves
// under $HOME and the guard must refuse the lot rather than wipe it.
let dir = tempfile::tempdir().unwrap();
let home = dir.path().to_path_buf();
fs::create_dir_all(home.join("Pictures")).unwrap();
let roots = Roots {
data: home.clone(),
models: home.join(".cache/huggingface"),
logs: Some(home.join("Logs/OmniVoice")),
temp: home.join("tmp"),
};
// Make the resolved targets exist so only the guard stands between them
// and deletion.
fs::create_dir_all(home.join("voices")).unwrap();
fs::write(home.join("prefs.json"), b"x").unwrap();
let report = purge_scopes(
&roots,
&["settings".to_string(), "content".to_string()],
Some(home.as_path()),
);
assert!(home.join("Pictures").exists(), "$HOME contents must be untouched");
assert!(home.join("prefs.json").exists(), "guard must refuse a data dir that IS $HOME");
assert!(report.removed.is_empty());
assert!(!report.refused.is_empty(), "the refusal must be recorded, not silent");
}
}
+256
View File
@@ -0,0 +1,256 @@
//! In-app uninstall — "remove all OmniVoice data" (#1089).
//!
//! Why this lives in the Rust shell and not the backend: the biggest thing to
//! remove is the **managed Python environment**, and the backend is *running
//! from it*. A process cannot delete its own interpreter out from under itself
//! (and on Windows the files are locked while it lives). The shell owns the
//! backend's lifetime, so it can stop it, delete everything, and exit.
//!
//! Paths come from the same single source of truth the rest of the app uses —
//! `setup::{resolved_data_dir, default_data_dir, env_root, resolved_models_dir,
//! default_models_dir}` and `backend::backend_log_path()` — so a custom or
//! portable install is cleaned correctly instead of the defaults being assumed.
//!
//! Safety: nothing is deleted that doesn't pass `is_recognizably_ours()` (an
//! absolute path, not `/` or `$HOME`, carrying an OmniVoice-owned component).
//! The shared Hugging Face cache is reported separately and is **opt-in** — it
//! is the standard HF cache other ML tools share, so sweeping it up silently
//! would delete models this app never downloaded.
use std::fs;
use std::path::{Path, PathBuf};
use serde::Serialize;
use crate::{backend_port, AppFlags};
#[derive(Serialize, Clone, Debug)]
pub struct UninstallTarget {
/// Stable id the UI keys off: "data" | "env" | "logs" | "models".
pub key: String,
pub path: String,
pub size_bytes: u64,
pub exists: bool,
/// True for the shared Hugging Face cache — opt-in, never removed by default.
pub shared: bool,
}
#[derive(Serialize, Clone, Debug)]
pub struct UninstallReport {
pub removed: Vec<String>,
pub failed: Vec<String>,
pub freed_bytes: u64,
}
/// Recursive size of a directory. Symlinks are NOT followed: the HF cache is a
/// forest of symlinks into `blobs/`, and following them would count the same
/// bytes many times over (and could wander outside the tree entirely).
fn dir_size(path: &Path) -> u64 {
if !path.exists() {
return 0;
}
walkdir::WalkDir::new(path)
.follow_links(false)
.into_iter()
.flatten()
.filter(|e| e.file_type().is_file())
.filter_map(|e| e.metadata().ok())
.map(|m| m.len())
.sum()
}
/// The backend's own log directory (`backend.log` / `backend_err.log`).
/// `backend_log_path()` returns the FILE; we remove the directory it lives in,
/// which is OmniVoice-owned on every platform:
/// macOS ~/Library/Logs/OmniVoice
/// Windows %LOCALAPPDATA%\OmniVoice\Logs
/// Linux ~/.local/state/OmniVoice
fn backend_log_dir() -> Option<PathBuf> {
crate::backend::backend_log_path()
.parent()
.map(|p| p.to_path_buf())
}
/// A last-resort guard before any `remove_dir_all`. A path only qualifies if it
/// is absolute, has a parent (never `/`), is not the home directory itself, and
/// carries a component this app actually owns. Pure — unit-tested below.
pub fn is_recognizably_ours(path: &Path, home: Option<&Path>) -> bool {
if !path.is_absolute() || path.parent().is_none() {
return false;
}
if let Some(home) = home {
if path == home {
return false;
}
}
const OWNED: [&str; 5] = [
"OmniVoice",
"omnivoice",
".omnivoice",
"com.debpalash.omnivoice-studio",
"huggingface",
];
path.components()
.filter_map(|c| c.as_os_str().to_str())
.any(|c| OWNED.iter().any(|o| c.eq_ignore_ascii_case(o)))
}
fn target(key: &str, path: PathBuf, shared: bool) -> UninstallTarget {
let exists = path.exists();
UninstallTarget {
key: key.to_string(),
size_bytes: if exists { dir_size(&path) } else { 0 },
path: path.to_string_lossy().to_string(),
exists,
shared,
}
}
/// Every folder this install owns, with sizes — what the confirmation UI shows.
/// Honors custom + portable locations via the shared resolvers.
#[tauri::command]
pub fn uninstall_scan(app: tauri::AppHandle) -> Vec<UninstallTarget> {
let data = crate::setup::resolved_data_dir(&app).unwrap_or_else(crate::setup::default_data_dir);
let env = crate::setup::env_root(&app);
let models =
crate::setup::resolved_models_dir(&app).unwrap_or_else(crate::setup::default_models_dir);
let mut out = vec![
// Voices, projects, DB, generated audio, the backend's rolling log.
target("data", data, false),
// config.json + the managed Python env (project/.venv) — the multi-GB one.
target("env", env, false),
];
if let Some(logs) = backend_log_dir() {
out.push(target("logs", logs, false));
}
// The durable per-user env file (backend/core/user_env.py). It persists the
// model-cache location (and can hold HF_TOKEN); leaving it behind silently
// redirected a fresh reinstall's cache to the old spot. Same path on every
// OS (expanduser("~/.config/omnivoice/env")), so it sits under neither the
// data nor the config dir above.
if let Some(user_env) = user_env_dir() {
if user_env.exists() {
out.push(target("userenv", user_env, false));
}
}
// Shared with every other huggingface_hub tool on this machine → opt-in.
out.push(target("models", models, true));
out
}
/// `~/.config/omnivoice` — the directory holding the durable per-user env file.
/// Mirrors `backend/core/user_env.py::USER_ENV_PATH`, which uses `expanduser`
/// on every platform, so this is `%USERPROFILE%\.config\omnivoice` on Windows.
fn user_env_dir() -> Option<PathBuf> {
dirs_next::home_dir().map(|h| h.join(".config").join("omnivoice"))
}
/// Stop the backend and delete the scanned folders. `include_models` opts into
/// the shared Hugging Face cache. Returns what was removed; the caller quits the
/// app afterwards (the Python env it runs on is gone, so there is nothing to
/// return to).
#[tauri::command]
pub fn uninstall_purge(
app: tauri::AppHandle,
include_models: bool,
flags: tauri::State<'_, AppFlags>,
) -> Result<UninstallReport, String> {
// Mark the app as quitting BEFORE the backend dies, so the #567 supervisor
// treats the death as intentional and doesn't respawn a backend into the
// very directories we are about to delete.
flags
.quitting
.store(true, std::sync::atomic::Ordering::SeqCst);
crate::bootstrap::set_backend_kill_intended(true);
crate::backend::kill_orphan_on_port(backend_port());
std::thread::sleep(std::time::Duration::from_millis(600));
let home = dirs_next::home_dir();
let mut report = UninstallReport {
removed: vec![],
failed: vec![],
freed_bytes: 0,
};
for t in uninstall_scan(app.clone()) {
if !t.exists {
continue;
}
if t.shared && !include_models {
continue; // the shared HF cache stays unless explicitly opted in
}
let path = PathBuf::from(&t.path);
if !is_recognizably_ours(&path, home.as_deref()) {
log::warn!("uninstall: refusing to delete unrecognized path {}", t.path);
report.failed.push(t.path);
continue;
}
match fs::remove_dir_all(&path) {
Ok(()) => {
log::info!("uninstall: removed {}", t.path);
report.freed_bytes += t.size_bytes;
report.removed.push(t.path);
}
Err(e) => {
log::error!("uninstall: failed to remove {}: {}", t.path, e);
report.failed.push(t.path);
}
}
}
Ok(report)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn refuses_root_home_and_foreign_paths() {
let home = PathBuf::from("/Users/someone");
// Never the filesystem root or the home dir itself.
assert!(!is_recognizably_ours(Path::new("/"), Some(&home)));
assert!(!is_recognizably_ours(&home, Some(&home)));
// Never a path we don't own, even under home.
assert!(!is_recognizably_ours(
Path::new("/Users/someone/Documents"),
Some(&home)
));
// Never a relative path.
assert!(!is_recognizably_ours(Path::new("relative/omnivoice"), None));
}
#[test]
fn accepts_the_real_targets_on_every_platform() {
let home = PathBuf::from("/Users/someone");
for p in [
"/Users/someone/Library/Application Support/OmniVoice",
"/Users/someone/Library/Application Support/com.debpalash.omnivoice-studio",
"/Users/someone/Library/Logs/OmniVoice",
"/Users/someone/.omnivoice",
"/Users/someone/.local/state/OmniVoice",
"/Users/someone/.local/share/com.debpalash.omnivoice-studio",
"/Users/someone/.cache/huggingface",
// The durable per-user env dir — must clear the same guard as the rest.
"/Users/someone/.config/omnivoice",
"C:\\Users\\someone\\AppData\\Roaming\\OmniVoice",
] {
let path = PathBuf::from(p);
// Windows-style paths aren't absolute on unix; only assert the ones that are.
if path.is_absolute() {
assert!(
is_recognizably_ours(&path, Some(&home)),
"should accept {p}"
);
}
}
}
#[test]
fn user_env_dir_is_under_dot_config_and_recognizably_ours() {
// The leftover that used to silently redirect a reinstall's model cache.
let dir = user_env_dir().expect("home dir resolves in test env");
assert!(dir.ends_with(".config/omnivoice"));
assert!(is_recognizably_ours(&dir, dirs_next::home_dir().as_deref()));
}
}
+12 -1
View File
@@ -44,6 +44,7 @@ import ErrorBoundary from './components/ErrorBoundary';
import FloatingPill from './components/FloatingPill';
import GlobalAudioPlayer from './components/GlobalAudioPlayer';
import BackendCrashNotice from './components/BackendCrashNotice';
import { initAnalyticsFromConsent } from './utils/analytics';
import BackendRestartBanner from './components/BackendRestartBanner';
// RemoteAuthGate is mounted at the true outermost provider in main-app.jsx so
// it covers all app states (setup check / wizard / bootstrap), not just the
@@ -76,7 +77,7 @@ import {
} from './utils/constants';
import { LANG_CODES } from './utils/languages';
import { restoreProjectExtras } from './utils/projectState';
import { API, apiFetch } from './api/client';
import { API, apiFetch, apiJson } from './api/client';
import { flushMemory as apiFlushMemory } from './api/system';
import {
saveProject as apiSaveProject,
@@ -105,6 +106,14 @@ function App() {
// normal app shell, so the user sees real progress instead of a hung UI.
const { stage: bootstrapStage, message: bootstrapMessage } = useBootstrapStage();
// Analytics is OFF until the user opts in (Settings Privacy). We never call
// posthog.init() at load that would track people before they consented, and
// would make the app's own "sends nothing out of the box" promise false. Ask
// the backend for the stored consent, and only then start it.
useEffect(() => {
initAnalyticsFromConsent(() => apiJson('/api/settings/analytics'));
}, []);
// UI navigation state now lives in the Zustand `uiSlice` (Phase 2.2).
// Mode + uiScale + sidebar-collapsed persist across reloads automatically
// via the store's `partialize`; active project / voice ids stay transient.
@@ -483,6 +492,7 @@ function App() {
setShowTranscript,
setPreviewAudios,
transcribeElapsed,
transcribeProgress,
handleDubUpload: _handleDubUpload,
handleDubIngestUrl,
handleDubAbort,
@@ -1494,6 +1504,7 @@ function App() {
dubVideoFile={dubVideoFile}
dubLocalBlobUrl={dubLocalBlobUrl}
transcribeElapsed={transcribeElapsed}
transcribeProgress={transcribeProgress}
translateProvider={translateProvider}
setTranslateProvider={setTranslateProvider}
onGlossaryChange={setGlossaryTerms}
+54 -6
View File
@@ -133,6 +133,24 @@ const TRANSPORT_RETRY_BACKOFF_MS = [400, 900, 1600];
const RESTART_WAIT_INTERVAL_MS = 1500;
const STARTUP_GRACE_MS = 120_000;
// #1101: the shell's stage is a 2-second POLL, not a live probe. When the
// backend dies mid-generate, `supervise_backend` needs up to ~2 s to notice the
// exit, record the crash marker, and flip the stage to "starting" — so a single
// check at the end of the ~2.9 s cascade very often still sees `ready` and we
// dead-ended on the generic "Can't reach the backend" anyway. That was the hole
// in the #1094 fix, reported against 0.3.19.
//
// A transport failure CONTRADICTS `ready`: if the shell believed the backend
// were reachable, the fetch would have succeeded. So `ready` is treated as a
// STALE belief, not an authority — we keep retrying across this reconciliation
// window, re-asking each time, which lets a death the supervisor hasn't noticed
// yet turn into "starting" (→ the long wait + banner) and gives the crash marker
// time to be written so the error can tell the honest story instead of guessing.
// Only `failed` (the shell gave up) or `unknown` (no shell — browser/Docker)
// still errors immediately.
const RECONCILE_MS = 12_000;
const RECONCILE_INTERVAL_MS = 1000;
export async function apiFetch(path: string, opts: RequestInit = {}): Promise<Response> {
const pin = typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('ov_pin') : null;
const key = _apiKey();
@@ -147,6 +165,11 @@ export async function apiFetch(path: string, opts: RequestInit = {}): Promise<Re
: opts;
const signal = finalOpts.signal as AbortSignal | null | undefined;
let lastDetail = '';
// The shell's last word on the backend. When it still says `ready` after we've
// exhausted the reconcile window, the process is demonstrably ALIVE and simply
// not answering — a different failure from "it stopped", and it deserves a
// different sentence (#1113).
let lastStage = 'unknown';
const startedAt = Date.now();
for (let attempt = 0; ; attempt++) {
if (signal?.aborted) throw new DOMException('Aborted', 'AbortError');
@@ -170,20 +193,29 @@ export async function apiFetch(path: string, opts: RequestInit = {}): Promise<Re
// The short cascade is exhausted, but the desktop shell may KNOW the
// backend is mid-start/restart (a real one takes 1020+ s — torch
// import — not 2.9 s). Keep waiting exactly as long as the shell says
// "starting", bounded by STARTUP_GRACE_MS; 'failed'/'unknown' (no
// shell, or the shell gave up) falls through to the error below so a
// truly dead backend still surfaces promptly.
if (Date.now() - startedAt < STARTUP_GRACE_MS) {
let stage = 'unknown';
// "starting", bounded by STARTUP_GRACE_MS.
const elapsed = Date.now() - startedAt;
if (elapsed < STARTUP_GRACE_MS) {
try {
stage = await backendLifecycleStage();
lastStage = await backendLifecycleStage();
} catch {
/* never let the lifecycle probe mask the real transport error */
}
const stage = lastStage;
if (stage === 'starting') {
await new Promise((r) => setTimeout(r, RESTART_WAIT_INTERVAL_MS));
continue;
}
// `ready` while the transport is failing is a contradiction — the
// shell's 2 s poll simply hasn't caught up with a backend that just
// died (#1101). Don't believe it yet: keep retrying briefly so the
// supervisor can notice, flip to "starting", and write the crash
// marker. 'failed'/'unknown' fall through and error now, so a shell
// that gave up — or no shell at all — still surfaces promptly.
if (stage === 'ready' && elapsed < RECONCILE_MS) {
await new Promise((r) => setTimeout(r, RECONCILE_INTERVAL_MS));
continue;
}
}
// #941: if the desktop shell recorded an unacknowledged backend crash,
// tell the honest story instead of the vague "can't reach" — and let
@@ -207,6 +239,22 @@ export async function apiFetch(path: string, opts: RequestInit = {}): Promise<Re
{ status: 0, detail: lastDetail },
);
}
// #1113: no crash was recorded AND the shell still reports the backend as
// running — so it did NOT stop; it is alive and has stopped answering.
// Telling this user "it may still be starting up, or it stopped" is simply
// false, and it sends them to restart the app when the real cause is a
// wedged job holding the worker (a heavy generate/transcribe on a small
// GPU). Name what actually happened and point at the thing that fixes it.
if (lastStage === 'ready') {
throw new ApiError(
'The local OmniVoice backend is running but stopped responding. This usually means a ' +
'job (a generation or a transcription) is stuck holding the engine — often a model ' +
'too heavy for the available memory on this machine. Check Settings → Logs → Backend ' +
'for the last thing it was doing; a smaller model or engine (Settings → Models) is ' +
'the usual fix. Restarting the app clears it for now.',
{ status: 0, detail: lastDetail },
);
}
throw new ApiError(
"Can't reach the local OmniVoice backend — it may still be starting up, or it stopped. " +
'Wait a few seconds and try again; if it persists, restart the app (or check Settings → Logs → Backend).',
+11
View File
@@ -121,6 +121,17 @@ export interface DubTrackInfo {
/** Per-track metadata (duration, timing strategy, ) keyed by language code.
* Backs the track-pill tooltips; the store only carries the track codes. */
/** Per-segment texts for one generated track ({segKey: text}, may be empty). */
export async function dubSegmentsText(
jobId: string,
lang: string,
): Promise<Record<string, string>> {
const res = await apiJson<{ texts?: Record<string, string> }>(
`/dub/segments-text/${encodeURIComponent(jobId)}?lang=${encodeURIComponent(lang)}`,
);
return res?.texts || {};
}
export async function dubListTracks(jobId: string): Promise<Record<string, DubTrackInfo>> {
const res = await apiJson<{ tracks?: Record<string, DubTrackInfo> }>(
`/dub/tracks/${encodeURIComponent(jobId)}`,
+13 -1
View File
@@ -94,7 +94,7 @@ export async function modelStatus(): Promise<ModelStatus> {
* `engine_id`/`is_active_engine` attribute TTS-family entries to an engine
* (a model can stay resident after the user switches engines). */
export interface LoadedModel {
id: string; // 'tts' | 'asr' | 'diarization' | 'sidecar:<engine>'
id: string; // 'tts' | 'asr' | 'diarization' | 'sidecar:<e>' | 'engine:<e>' | 'capture-asr'
name: string;
checkpoint: string;
device: string;
@@ -105,9 +105,21 @@ export interface LoadedModel {
is_active_engine?: boolean | null;
}
/** Free/total memory snapshot from GET /model/loaded. RAM is always present;
* VRAM fields appear only on a dedicated-GPU host; `warning` is a low-memory
* advisory string when free memory is below the headroom threshold. */
export interface SystemMemory {
ram_available_gb?: number;
ram_total_gb?: number;
vram_free_gb?: number;
vram_total_gb?: number;
warning?: string;
}
export interface LoadedModelsResponse {
models: LoadedModel[];
count: number;
system?: SystemMemory;
}
export async function listLoadedModels(): Promise<LoadedModelsResponse> {
+52 -21
View File
@@ -151,6 +151,16 @@ function detectHints(message, logs) {
return hints;
}
/** Failures no retry can ever fix offering a Retry button for these is the
* dead end #1112 reported ("clicking the buttons does nothing"): the bootstrap
* re-fails identically every time. Today that's the Intel Mac (#889): PyTorch
* ships no macOS x86_64 wheels, so the dependency set can never resolve there.
* Keyed off the same hint the matcher produces, so the two can't drift.
* Pure + exported for tests. */
export function isUnrecoverableFailure(message, logs = []) {
return detectHints(message, logs).includes('bootstrap.hint_intel_mac');
}
function formatEta(seconds) {
if (!Number.isFinite(seconds) || seconds <= 0) return '';
if (seconds < 60) return '<1m';
@@ -370,6 +380,8 @@ export function BootstrapSplash({ stage, message }) {
const label = t(`bootstrap.${stage}`, STAGE_LABEL[stage]);
const stepIndex = Math.max(0, STEPS.indexOf(stage));
const isFailed = stage === 'failed';
// Retrying an Intel-Mac install can never succeed don't offer the dead end.
const isUnrecoverable = isFailed && isUnrecoverableFailure(message, logs);
const [logs, setLogs] = useState([]);
const [logsOpen, setLogsOpen] = useState(true);
const [copied, setCopied] = useState(false);
@@ -565,9 +577,17 @@ export function BootstrapSplash({ stage, message }) {
<JourneyRail t={t} />
<div className="mt-2 flex flex-wrap items-end justify-between gap-6">
<div className="min-w-0">
<h1 className="m-0 font-serif text-[clamp(1.6rem,3vw,2.2rem)] font-semibold leading-tight tracking-tight">
{t('bootstrap.title', 'OmniVoice Studio')}
</h1>
{/* Version rides beside the app name same masthead across all
three first-run acts (setup install models & engines), so a
screenshot from any of them identifies the build. */}
<div className="flex flex-wrap items-baseline gap-2.5">
<h1 className="m-0 font-serif text-[clamp(1.6rem,3vw,2.2rem)] font-semibold leading-tight tracking-tight">
{t('bootstrap.title', 'OmniVoice Studio')}
</h1>
<span className="font-mono text-[0.62rem] tracking-[0.14em] text-fg-subtle">
v{APP_VERSION}
</span>
</div>
<p className="mt-1.5 text-sm leading-snug text-fg-muted" aria-live="polite">
{label}
</p>
@@ -642,18 +662,35 @@ export function BootstrapSplash({ stage, message }) {
</ul>
</div>
<div className="flex items-center justify-end gap-2">
<Button
variant="ghost"
size="sm"
onClick={handleCleanRetry}
disabled={retrying}
leading={<Brush size={12} />}
>
{t('bootstrap.clean_retry', 'Clean & Retry')}
</Button>
<Button variant="primary" onClick={handleRetry} disabled={retrying}>
{retrying ? t('bootstrap.retrying', 'Retrying…') : t('bootstrap.retry', 'Retry')}
</Button>
{/* #1112: some failures can NEVER be retried away an Intel Mac
has no PyTorch wheels, so every retry re-fails identically and
the buttons just look broken ("clicking them does nothing").
Say so plainly and don't offer the dead end. */}
{isUnrecoverable ? (
<span className="text-sm text-fg-muted">
{t(
'bootstrap.unrecoverable',
'Retrying cannot fix this — see the guidance above.',
)}
</span>
) : (
<>
<Button
variant="ghost"
size="sm"
onClick={handleCleanRetry}
disabled={retrying}
leading={<Brush size={12} />}
>
{t('bootstrap.clean_retry', 'Clean & Retry')}
</Button>
<Button variant="primary" onClick={handleRetry} disabled={retrying}>
{retrying
? t('bootstrap.retrying', 'Retrying…')
: t('bootstrap.retry', 'Retry')}
</Button>
</>
)}
</div>
</section>
) : (
@@ -765,12 +802,6 @@ export function BootstrapSplash({ stage, message }) {
</pre>
)}
</section>
<footer className="mt-auto pt-2">
<span className="font-mono text-[0.62rem] tracking-[0.14em] text-fg-subtle">
OVS&thinsp;·&thinsp;v{APP_VERSION}
</span>
</footer>
</div>
</div>
);
+17 -5
View File
@@ -127,6 +127,16 @@ function DubSegmentRow({
label: t('segment.fit_stretched', { ratio: r.toFixed(2) }),
title: t('segment.fit_stretched_title', { ratio: r.toFixed(2) }),
};
} else if (fitStatus.status === 'audio_slowed') {
// Underrun fill: the line ran shorter than its slot and was slowed
// (pitch-preserved) toward it so speech covers the on-screen mouth time.
const r = fitStatus.audio_rate || 1.0;
fitBadge = {
color: '#83a598',
Icon: Circle,
label: t('segment.fit_slowed', { ratio: r.toFixed(2) }),
title: t('segment.fit_slowed_title', { ratio: r.toFixed(2) }),
};
}
} else if (seg.sync_ratio !== undefined) {
const r = seg.sync_ratio;
@@ -352,11 +362,13 @@ function DubSegmentRow({
title={
seg.translate_error
? t('segment.translate_error_title', { error: seg.translate_error })
: overBudget
? t('segment.budget_title', {
pct: Math.round((seg.text.length / seg.text_original.length) * 100),
})
: t('segment.text_title')
: seg.translate_degraded
? t('segment.translate_degraded_title', { reason: seg.translate_degraded })
: overBudget
? t('segment.budget_title', {
pct: Math.round((seg.text.length / seg.text_original.length) * 100),
})
: t('segment.text_title')
}
style={
overBudget
@@ -141,6 +141,13 @@ const ROUTING_BADGE = {
const TEST_COOLDOWN_MS = 5000;
// How long a forced (Install-click) status refresh will wait for an already
// in-flight request to settle before proceeding anyway. A wedged request has
// no abort signal, so without a bound the click would trade "silently
// dropped" for "silently stuck"; the per-engine epoch makes proceeding safe.
// Exported for the regression test (fake timers).
export const FORCE_WAIT_TIMEOUT_MS = 5000;
// Strict two-line row geometry
// One shared grid template on the header row AND every engine row identical
// fixed tracks are what keep the STATUS / GPU COMPAT / ISOLATION / ACTIONS
@@ -409,25 +416,69 @@ export default function EngineCompatibilityMatrix({
// At most ONE in-flight status request per engine otherwise a slow
// backend lets responses land out of order (an old 'running' snapshot
// overwriting a newer 'succeeded' would restart the poller forever).
const installInflightRef = useRef(new Set());
// Maps id { promise } for the in-flight request so a must-not-drop
// caller can wait it out instead of being dropped (see `force` below).
const installInflightRef = useRef(new Map());
// Monotonic per-engine request epoch: a response may only be applied if no
// NEWER request has started since it was issued. This is the actual
// ordering guarantee the inflight slot above is just throttling so even
// a request that settles arbitrarily late (wedged backend, transport
// retries) can never overwrite a fresher snapshot with a stale one.
const installReqEpochRef = useRef({});
// Consecutive poll failures per engine after a few in a row the backend
// is gone, so drop the stale snapshot instead of showing "Installing"
// (and hammering the endpoint) indefinitely.
const installPollFailuresRef = useRef({});
const refreshInstall = useCallback(
async (id) => {
if (installInflightRef.current.has(id)) return null; // serialize per engine
installInflightRef.current.add(id);
try {
async (id, { force = false } = {}) => {
// Advisory callers (the 1.5s poller, the mount re-attach probe) drop on
// overlap throttling. But the Install click's refresh must NOT be
// droppable: if it lands while the mount probe is still awaiting,
// dropping it leaves the pre-install 'idle' snapshot in place, the
// poller (which only watches 'running' jobs) never starts, and the
// progress panel silently never appears. So a forced caller waits until
// it owns the per-engine slot re-checking the map after every await,
// because two rapid forced clicks waking from the SAME await would
// otherwise both proceed and race each other. The wait is bounded: a
// wedged probe (no abort signal, transport retries) must not turn
// "silently dropped" into "silently stuck" on timeout we proceed, and
// the epoch check below makes the wedged request's late response
// harmless.
let inflight = installInflightRef.current.get(id);
while (inflight) {
if (!force) return null;
let waitTimer;
const timedOut = await Promise.race([
inflight.promise.then(
() => false,
() => false, // the in-flight caller counted its own failure
),
new Promise((resolve) => {
waitTimer = setTimeout(() => resolve(true), FORCE_WAIT_TIMEOUT_MS);
}),
]);
clearTimeout(waitTimer); // don't leak the losing leg's 5s timer
if (timedOut) break;
inflight = installInflightRef.current.get(id);
}
const epoch = (installReqEpochRef.current[id] = (installReqEpochRef.current[id] || 0) + 1);
const entry = { promise: null };
entry.promise = (async () => {
const st = await apiInstallStatus(id);
installPollFailuresRef.current[id] = 0;
setInstallByEngine((prev) => ({ ...prev, [id]: st }));
if (installReqEpochRef.current[id] === epoch) {
setInstallByEngine((prev) => ({ ...prev, [id]: st }));
}
return st;
})();
installInflightRef.current.set(id, entry);
try {
return await entry.promise;
} catch {
const n = (installPollFailuresRef.current[id] || 0) + 1;
installPollFailuresRef.current[id] = n;
if (n >= 4) {
if (n >= 4 && installReqEpochRef.current[id] === epoch) {
installPollFailuresRef.current[id] = 0;
setInstallByEngine((prev) => {
const { [id]: _stale, ...rest } = prev;
@@ -436,7 +487,9 @@ export default function EngineCompatibilityMatrix({
}
return null; // advisory polling errors never break the matrix
} finally {
installInflightRef.current.delete(id);
if (installInflightRef.current.get(id) === entry) {
installInflightRef.current.delete(id);
}
}
},
[apiInstallStatus],
@@ -451,7 +504,9 @@ export default function EngineCompatibilityMatrix({
reload();
return;
}
const st = await refreshInstall(id);
// force: this snapshot must never be dropped by the overlap guard
// it's what makes the progress panel appear at all.
const st = await refreshInstall(id, { force: true });
// A repair-only rerun can finish before this first status snapshot
// the poller below only watches 'running' jobs, so reload here too.
if (st?.job?.state === 'succeeded') reload();
+13 -7
View File
@@ -446,9 +446,17 @@ export default function FirstRunSetup() {
<JourneyRail active="setup" t={t} />
<div className="mt-2 flex flex-wrap items-end justify-between gap-6">
<div className="min-w-0">
<h1 className="m-0 font-serif text-[clamp(1.6rem,3vw,2.2rem)] font-semibold leading-tight tracking-tight">
{t('firstrun.title', 'Set up OmniVoice Studio')}
</h1>
{/* Version rides beside the app name same masthead across all
three first-run acts (setup install models & engines), so
a screenshot from any of them identifies the build. */}
<div className="flex flex-wrap items-baseline gap-2.5">
<h1 className="m-0 font-serif text-[clamp(1.6rem,3vw,2.2rem)] font-semibold leading-tight tracking-tight">
{t('firstrun.title', 'Set up OmniVoice Studio')}
</h1>
<span className="font-mono text-[0.62rem] tracking-[0.14em] text-fg-subtle">
v{APP_VERSION}
</span>
</div>
<p className="mt-1.5 max-w-[58ch] text-sm leading-snug text-fg-muted">
{t(
'firstrun.subtitle',
@@ -789,11 +797,9 @@ export default function FirstRunSetup() {
</p>
)}
<div className="flex flex-wrap items-center justify-between gap-4">
{/* Version moved up beside the app name (masthead) the footer now
carries only the download total. */}
<span className="inline-flex flex-wrap items-baseline gap-2 text-xs tabular-nums text-fg-muted">
<span className="whitespace-nowrap font-mono text-[0.62rem] tracking-[0.14em] text-fg-subtle">
OVS&thinsp;·&thinsp;v{APP_VERSION}
</span>
<span aria-hidden="true"></span>
{t('firstrun.total_required', {
size: fmtGB(combinedNeed),
defaultValue: 'Total disk needed: ~{{size}} (one-time download on first use)',
+62 -1
View File
@@ -23,6 +23,7 @@ import { LANG_CODES } from '../../utils/languages';
import ALL_LANGUAGES from '../../languages.json';
import { POPULAR_LANGS, PRESETS } from '../../utils/constants';
import { dialectOptionsFor, dialectLabel, dialectMatchesLang } from '../../api/dialects';
import { dubSegmentsText } from '../../api/dub';
import { copyText } from '../../utils/copyText';
import { openExternal } from '../../api/external';
import { TRANSLATION_ENGINES_DOCS } from '../../utils/errorDocsMap';
@@ -197,6 +198,45 @@ export default function DubLeftColumn({
return parts.length ? parts.join(' · ') : undefined;
};
async function hydrateMissingTranslations(code) {
const st = useAppStore.getState();
const jobId = st.dubJobId;
if (!jobId) return;
const missing = st.dubSegments.some(
(seg) =>
!(
seg.translations &&
typeof seg.translations[code] === 'string' &&
seg.translations[code].trim()
),
);
if (!missing) return;
try {
const texts = await dubSegmentsText(jobId, code);
if (!texts || !Object.keys(texts).length) return;
const cur = useAppStore.getState();
if (cur.dubLangCode !== code) return; // user already switched again
cur.setDubSegments(
cur.dubSegments.map((seg, i) => {
const key = seg.id != null ? String(seg.id) : String(i);
const incoming = texts[key];
const has =
seg.translations &&
typeof seg.translations[code] === 'string' &&
seg.translations[code].trim();
if (has || typeof incoming !== 'string' || !incoming.trim()) return seg;
return {
...seg,
text: incoming,
translations: { ...seg.translations, [code]: incoming },
};
}),
);
} catch {
/* advisory — rows keep their previous-language text, as before */
}
}
return (
<div className="studio-panel dub-panel-col">
{hasDubbedTrack && (
@@ -223,7 +263,27 @@ export default function DubLeftColumn({
role="radio"
aria-checked={previewMode === code}
className={`dub-lang-pill ${previewMode === code ? 'is-active' : ''}`}
onClick={() => setPreviewMode(code)}
onClick={() => {
setPreviewMode(code);
// The transcript/segment list follows the previewed track:
// swap segment texts to this language's saved translations
// (the P1.2 per-language store non-destructive, exactly
// what the language dropdown and multi-language generate
// already do). Without this, previewing German played
// German audio over, say, Bengali segment text.
const st = useAppStore.getState();
st.setDubLang(label);
st.switchDubLangCode(code);
// Review finding (#1148): the in-browser translations map
// can be PARTIAL (tracks generated before per-language
// persistence, partial regens) the non-destructive switch
// then leaves those rows in the previous language, a
// mixed-language transcript under a single-language track.
// Hydrate the gaps from the backend's authoritative
// segments_i18n store. Failure-silent: no data the rows
// keep what they had, exactly the pre-hydration behavior.
hydrateMissingTranslations(code);
}}
title={trackTooltip(code)}
>
{label}
@@ -747,6 +807,7 @@ export default function DubLeftColumn({
...s,
text: s.text_original || s.text,
translate_error: undefined,
translate_degraded: undefined,
})),
)
}
@@ -33,6 +33,8 @@ export default function DubRightColumn({
dubTracks,
timingStrategy,
setTimingStrategy,
voiceMatch,
setVoiceMatch,
dubTranscript,
showTranscript,
setShowTranscript,
@@ -159,6 +161,28 @@ export default function DubRightColumn({
]}
/>
</div>
{/* Voice match whether each line clones from its own source clip
(best prosody, identity may drift) or every line of a speaker
shares ONE reference (steady identity). */}
<div className={OUT_ROW} title={t('dub.voice_match_title')}>
<span className={OUT_TITLE}>{t('dub.voice_match')}</span>
<Segmented
value={voiceMatch}
onChange={setVoiceMatch}
items={[
{
value: 'per_line',
label: t('dub.voice_match_per_line'),
title: t('dub.voice_match_per_line_title'),
},
{
value: 'consistent',
label: t('dub.voice_match_consistent'),
title: t('dub.voice_match_consistent_title'),
},
]}
/>
</div>
</div>
{dubTranscript && (
@@ -45,6 +45,7 @@ export default function IdleSkeleton({
dubPrepProgress,
handleDubAbort,
transcribeElapsed,
transcribeProgress,
dubDuration,
dubNumSpeakers,
setDubNumSpeakers,
@@ -182,6 +183,7 @@ export default function IdleSkeleton({
) : dubStep === 'transcribing' ? (
<TranscribeOverlay
elapsed={transcribeElapsed}
progress={transcribeProgress}
duration={dubDuration}
onAbort={handleDubAbort}
/>
@@ -272,6 +274,7 @@ export default function IdleSkeleton({
<div className="flex-1 flex flex-col items-center justify-center min-h-0">
<TranscribeOverlay
elapsed={transcribeElapsed}
progress={transcribeProgress}
duration={dubDuration}
onAbort={handleDubAbort}
/>
@@ -2,14 +2,42 @@ import { useTranslation } from 'react-i18next';
import { Loader, Square } from 'lucide-react';
import { Button, Progress } from '../../ui';
/** Seconds → "45s" / "3m 20s". */
function fmtRemaining(seconds) {
const s = Math.max(0, Math.round(seconds));
if (s < 60) return `${s}s`;
const m = Math.floor(s / 60);
const rem = s % 60;
return rem ? `${m}m ${rem}s` : `${m}m`;
}
/**
* TranscribeOverlay Whisper progress + ETA while transcribing.
*
* The ETA used to be invented from the video's duration alone:
*
* const est = Math.max(10, Math.ceil(duration / 60) * 3 + 8);
*
* i.e. "3 seconds per minute of video" an assumption of ~20x-realtime
* transcription. That is roughly true on a CUDA GPU and wildly false on a CPU
* (#1127: WhisperX on Apple Silicon runs at ~0.33x realtime). For a 16-minute
* video it predicted 56 seconds; the real answer was ~48 minutes. Past the 56 s
* mark `est - elapsed` clamped to zero, so it displayed "~0s remaining" and a
* progress bar frozen at 95% for the next three quarters of an hour.
*
* So we no longer guess. `progress` is the real fraction of chunks the backend
* has actually finished, and the ETA is extrapolated from the rate we are
* *observing* which is self-correcting and hardware-agnostic. Until the first
* chunk lands there is no rate to extrapolate from, and we say nothing rather
* than inventing a number.
*/
function TranscribeOverlay({ elapsed, duration, onAbort }) {
function TranscribeOverlay({ elapsed, duration, progress = 0, onAbort }) {
const { t } = useTranslation();
const est = duration > 0 ? Math.max(10, Math.ceil(duration / 60) * 3 + 8) : 0;
const mm = Math.floor(elapsed / 60);
const ss = String(elapsed % 60).padStart(2, '0');
const pct = progress > 0 ? Math.min(99, Math.round(progress * 100)) : null;
// rate = progress / elapsed => remaining = (1 - progress) / rate
const remaining = progress > 0.01 && elapsed > 0 ? (elapsed / progress) * (1 - progress) : null;
return (
<div className="flex flex-col items-center gap-[var(--space-5)] w-full">
<div className="flex items-center gap-[var(--space-4)]">
@@ -20,16 +48,19 @@ function TranscribeOverlay({ elapsed, duration, onAbort }) {
<span>
{mm}:{ss} {t('dub.elapsed')}
</span>
{est > 0 && (
{pct != null && <span>{pct}%</span>}
{remaining != null && (
<span>
~{Math.max(0, est - elapsed)}
~{fmtRemaining(remaining)}
{t('dub.remaining')}
</span>
)}
</div>
{duration > 0 && (
<div className="w-[80%] max-w-[340px]">
<Progress value={Math.min(95, (elapsed / est) * 100)} tone="brand" size="sm" />
{/* value=null => Progress renders indeterminate; that is exactly right
before the first chunk lands, when we genuinely don't know yet. */}
<Progress value={pct} tone="brand" size="sm" />
</div>
)}
<Button variant="danger" size="sm" onClick={onAbort} leading={<Square size={11} />}>
@@ -0,0 +1,122 @@
/**
* Settings Privacy "Help improve OmniVoice" the opt-in analytics control.
*
* Local-first means **silence is not consent**: this is OFF until the user turns
* it on, so a default install still transmits nothing. The panel tells the truth
* in the UI rather than burying it in a policy nobody opens:
*
* - exactly what IS sent (counts, durations, which engine, error TYPE),
* - exactly what is NEVER sent (the text you type, your audio, filenames,
* voice names, and any identity),
* - and that it can be turned off again at any time.
*
* When the build ships no analytics destination (any source build), the toggle
* is not offered at all an inert switch would be a lie. See
* backend/core/analytics.py for the enforcement (allowlist + no exception
* autocapture) that makes the promises above true rather than aspirational.
*/
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import toast from 'react-hot-toast';
import { Check, X } from 'lucide-react';
import { apiJson, apiFetch } from '../../api/client';
import { enableAnalytics, disableAnalytics } from '../../utils/analytics';
import { SettingRow, SettingsToggle } from './primitives';
export default function AnalyticsOptIn() {
const { t } = useTranslation();
const [state, setState] = useState(null); // { enabled, opted_in, available }
const [busy, setBusy] = useState(false);
useEffect(() => {
let alive = true;
apiJson('/api/settings/analytics')
.then((d) => alive && setState(d))
.catch(() => {
/* backend down — just don't render the control */
});
return () => {
alive = false;
};
}, []);
const toggle = async (next) => {
setBusy(true);
try {
const d = await apiFetch('/api/settings/analytics', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: next }),
}).then((r) => r.json());
setState(d);
// Consent gates the FRONTEND SDK too: posthog-js is only initialised once
// the user says yes, and torn down the moment they say no. It is never
// started at app load that would track people before they consented.
if (next) await enableAnalytics();
else disableAnalytics();
toast.success(
next
? t('privacy.analytics_on', { defaultValue: 'Thanks — anonymous usage stats are on.' })
: t('privacy.analytics_off', { defaultValue: 'Analytics off. Nothing is sent.' }),
);
} catch (e) {
toast.error(e?.message || String(e));
} finally {
setBusy(false);
}
};
// No destination in this build (e.g. running from source) an inert toggle
// would be dishonest. Say nothing rather than offer a switch that does nothing.
if (!state?.available) return null;
const Item = ({ ok, children }) => (
<li className="flex items-start gap-[var(--space-2)]">
{ok ? (
<Check size={13} className="mt-[3px] shrink-0 text-[var(--chrome-accent)]" />
) : (
<X size={13} className="mt-[3px] shrink-0 text-[var(--chrome-fg-muted)]" />
)}
<span>{children}</span>
</li>
);
return (
<>
<SettingRow
title={t('privacy.analytics_title', { defaultValue: 'Help improve OmniVoice' })}
subtitle={t('privacy.analytics_subtitle', {
defaultValue: 'Off by default. Anonymous usage stats — never your content.',
})}
control={
<SettingsToggle
checked={!!state.opted_in}
disabled={busy}
onChange={toggle}
aria-label={t('privacy.analytics_title', { defaultValue: 'Help improve OmniVoice' })}
data-testid="analytics-optin"
/>
}
/>
<ul className="m-0 mb-[var(--space-4)] list-none p-0 [font-family:var(--font-sans)] text-[length:var(--text-sm)] leading-[1.7] text-[var(--chrome-fg-muted)]">
<Item ok>
{t('privacy.analytics_sends', {
defaultValue:
'Sent: which engine and language you used, how long a generation took, how many characters (a number, not the text), and the type of any error.',
})}
</Item>
<Item>
{t('privacy.analytics_never', {
defaultValue:
'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.',
})}
</Item>
<Item>
{t('privacy.analytics_off_anytime', {
defaultValue: 'You can turn this off again at any time, and nothing further is sent.',
})}
</Item>
</ul>
</>
);
}
@@ -117,7 +117,7 @@ export default function HistoryRetentionPanel() {
control={
<div className="flex items-center gap-[var(--space-3)]">
<input
className="box-border w-[110px] rounded-[var(--chrome-radius-pill)] [border:1px_solid_var(--chrome-border)] bg-[var(--chrome-input-bg)] px-[var(--space-3)] py-[var(--space-2)] font-[family-name:var(--chrome-font-mono)] text-[length:var(--text-base)] text-[var(--chrome-fg)] focus-visible:border-[var(--chrome-accent)] focus-visible:shadow-[var(--focus-ring)] focus-visible:outline-none"
className="box-border w-[110px] rounded-[var(--chrome-radius-pill)] [border:1px_solid_var(--chrome-border)] bg-[var(--chrome-hover-bg)] px-[var(--space-3)] py-[var(--space-2)] font-[family-name:var(--chrome-font-mono)] text-[length:var(--text-base)] text-[var(--chrome-fg)] focus-visible:border-[var(--chrome-accent)] focus-visible:shadow-[var(--focus-ring)] focus-visible:outline-none"
type="number"
min="0"
step="1"
@@ -475,7 +475,7 @@ export default function ModelStoreTab({ info, modelBadge }) {
<div className="inline-flex items-center gap-[var(--space-2)]">
<input
type="password"
className="min-w-0 rounded-[var(--chrome-radius-pill)] [border:1px_solid_var(--chrome-border)] bg-[var(--chrome-input-bg)] px-[var(--space-2)] py-[2px] font-[family-name:var(--chrome-font-mono)] text-[length:var(--text-xs)] text-[var(--chrome-fg)] placeholder:text-[var(--chrome-fg-dim)] focus-visible:border-[var(--chrome-accent)] focus-visible:shadow-[var(--focus-ring)] focus-visible:outline-none"
className="min-w-0 rounded-[var(--chrome-radius-pill)] [border:1px_solid_var(--chrome-border)] bg-[var(--chrome-hover-bg)] px-[var(--space-2)] py-[2px] font-[family-name:var(--chrome-font-mono)] text-[length:var(--text-xs)] text-[var(--chrome-fg)] placeholder:text-[var(--chrome-fg-dim)] focus-visible:border-[var(--chrome-accent)] focus-visible:shadow-[var(--focus-ring)] focus-visible:outline-none"
placeholder="hf_xxxxxxxxxxxx"
value={hfToken}
onChange={(e) => setHfToken(e.target.value)}
@@ -5,6 +5,7 @@ import { Badge, Button } from '../../ui';
import { useAppStore } from '../../store';
import { SettingsSection } from './primitives';
import Row from './Row';
import AnalyticsOptIn from './AnalyticsOptIn';
// 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
@@ -75,6 +76,10 @@ export default function PrivacyTab({ info }) {
</Badge>
}
/>
{/* 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. */}
<AnalyticsOptIn />
</SettingsSection>
);
}
@@ -0,0 +1,509 @@
/**
* Settings Storage "Reset & remove".
*
* Factory reset used to do exactly one thing clear localStorage while the
* only other option was deleting everything and starting over. Between "forget
* my theme" and "wipe the machine" sat every reset a user actually needs: drop a
* corrupt model download, remove a wedged sidecar engine, put the settings back
* without losing a single voice. This panel is that middle ground: four presets
* for the common cases, and a per-scope checklist for everything else.
*
* Three rules it keeps:
* - **The number is the truth.** Every scope shows its real on-disk size, and
* the confirm button shows the sum of what is actually ticked. A reset that
* says "14.2 GB" frees 14.2 GB.
* - **The shared model cache is never swept up silently.** On macOS and Linux
* the Hugging Face cache is shared with every other ML tool on the machine,
* so it is its own checkbox and says so. (On Windows and in portable installs
* the cache is app-private the shell computes that, and the caveat is not
* shown when it does not apply.)
* - **Nothing irreversible happens on one click.** Removing voices, projects or
* models needs the word typed.
*
* Disk scopes are executed by the Rust shell (`reset.rs`), which stops the
* backend, deletes, and starts it again a running backend cannot delete the
* weights it has mapped into memory, nor recreate the directories it lost. The
* two scopes that own no files are handled here: UI preferences (localStorage)
* and history (the DELETE endpoints, which take rows and audio together).
*
* Outside the Tauri shell (browser / Docker) there is no local install to clear,
* so only the preferences tier is offered.
*/
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
RotateCcw,
AlertTriangle,
ChevronRight,
Palette,
SlidersHorizontal,
History,
Folder,
Boxes,
Wrench,
Database,
Archive,
ScrollText,
} from 'lucide-react';
import toast from 'react-hot-toast';
import { Button, Dialog } from '../../ui';
import { SettingsSection } from './primitives';
import { fmtBytes } from './bytes';
import StorageTargetRow from './StorageTargetRow';
import { clearLocalPreferences } from '../../utils/prefKeys';
import { clearHistory } from '../../api/generate';
import { clearDubHistory } from '../../api/dub';
const inTauri = () => typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window;
/** Scopes that own no files — cleared in the browser, never sent to the shell. */
export const FRONTEND_SCOPES = ['ui_prefs', 'history'];
/** Deleting these cannot be undone, so they gate the typed confirmation. */
export const IRREVERSIBLE_SCOPES = ['content'];
/** One glyph per scope, so a nine-row list can be scanned instead of read. */
const SCOPE_ICONS = {
ui_prefs: Palette,
settings: SlidersHorizontal,
history: History,
content: Folder,
engines: Boxes,
tools: Wrench,
models: Database,
caches: Archive,
logs: ScrollText,
};
/** Render order. Cheapest and safest first, so the list reads as an escalation. */
export const SCOPE_ORDER = [
'ui_prefs',
'settings',
'history',
'content',
'engines',
'tools',
'models',
'caches',
'logs',
];
/**
* The four one-click tiers. `everything` deliberately stops short of the managed
* Python environment: that is the interpreter the app runs on, and rebuilding it
* is a multi-GB download. A reset should hand back a working app on the far
* side "Remove all data" below is the door out of that.
*/
export const PRESETS = {
ui: ['ui_prefs'],
settings: ['ui_prefs', 'settings'],
assets: ['models', 'engines', 'tools', 'caches'],
everything: ['ui_prefs', 'settings', 'content', 'engines', 'tools', 'models', 'caches', 'logs'],
};
/** Bytes the reset will actually free — the sum of exactly what is ticked. */
export function selectedBytes(scopes, selected) {
return (scopes || [])
.filter((s) => selected.includes(s.key) && s.exists)
.reduce((sum, s) => sum + (s.size_bytes || 0), 0);
}
/** Typing the word is required the moment something unrecoverable is in scope. */
export function needsTypedConfirm(selected) {
return selected.some((k) => IRREVERSIBLE_SCOPES.includes(k));
}
/**
* Split a selection into the work each half of the app is responsible for.
* `content` wipes the whole database, so an explicit `history` tick alongside it
* would be a redundant round-trip against rows that are about to be deleted.
*/
export function plan(selected) {
const disk = selected.filter((k) => !FRONTEND_SCOPES.includes(k));
return {
disk,
prefs: selected.includes('ui_prefs'),
history: selected.includes('history') && !selected.includes('content'),
restart: disk.length > 0,
};
}
/** The preset whose scope set matches the current ticks exactly, if any. */
export function matchingPreset(selected) {
const same = (a, b) =>
a.length === b.length && [...a].sort().every((v, i) => [...b].sort()[i] === v);
return Object.keys(PRESETS).find((p) => same(PRESETS[p], selected)) || null;
}
// `_forceAdvanced` starts the "choose exactly what to remove" list expanded
// used only by the visual-regression harness so a snapshot shows the full row
// treatment. It has no effect on the real toggle.
export default function ResetPanel({ _forceAdvanced = false } = {}) {
const { t } = useTranslation();
const [scopes, setScopes] = useState(null);
const [selected, setSelected] = useState(PRESETS.ui);
const [advanced, setAdvanced] = useState(_forceAdvanced);
const [open, setOpen] = useState(false);
const [typed, setTyped] = useState('');
const [busy, setBusy] = useState(false);
const CONFIRM_WORD = t('settings.reset_confirm_word', { defaultValue: 'DELETE' });
const scan = useCallback(async () => {
if (!inTauri()) return;
try {
const { invoke } = await import('@tauri-apps/api/core');
setScopes(await invoke('reset_scan'));
} catch (e) {
console.warn('[ResetPanel] scan failed', e);
}
}, []);
useEffect(() => {
scan();
}, [scan]);
const byKey = useMemo(() => Object.fromEntries((scopes || []).map((s) => [s.key, s])), [scopes]);
const willFree = selectedBytes(scopes, selected);
const sharedModels = byKey.models?.shared && selected.includes('models');
const typedOk = !needsTypedConfirm(selected) || typed.trim().toUpperCase() === CONFIRM_WORD;
const toggle = (key) =>
setSelected((cur) => (cur.includes(key) ? cur.filter((k) => k !== key) : [...cur, key]));
const run = async () => {
const steps = plan(selected);
setBusy(true);
try {
// History first: its endpoints take the DB rows and their audio together,
// and they need a backend that is still alive to do it.
if (steps.history) {
await Promise.all([clearHistory(), clearDubHistory()]);
}
if (steps.disk.length) {
const { invoke } = await import('@tauri-apps/api/core');
const report = await invoke('reset_purge', { scopes: steps.disk });
if (report?.refused?.length || report?.failed?.length) {
toast.error(
t('settings.reset_partial', {
defaultValue: 'Some items could not be removed: {{paths}}',
paths: [...(report.refused || []), ...(report.failed || [])].join(', '),
}),
{ duration: 10000 },
);
}
}
// Preferences last: the reload below is what makes them take effect, and if
// anything above threw we would rather not have wiped them for nothing.
if (steps.prefs) clearLocalPreferences();
toast.success(
steps.restart
? t('settings.reset_done_restart', {
defaultValue: 'Reset complete — restarting OmniVoice…',
})
: t('settings.reset_done', { defaultValue: 'Reset complete — reloading…' }),
);
setOpen(false);
// The backend is coming back up behind us; the bootstrap splash and the
// reconnecting banner (#1094) own that wait, so all this has to do is get
// the UI back to a clean slate.
setTimeout(() => window.location.reload(), 400);
} catch (e) {
setBusy(false);
toast.error(
t('settings.reset_failed', {
defaultValue: 'Reset failed: {{message}}',
message: e?.message || String(e),
}),
);
}
};
const LABELS = {
ui_prefs: t('settings.reset_scope_ui_prefs', {
defaultValue: 'UI preferences — theme, language, layout, dub settings',
}),
settings: t('settings.reset_scope_settings', {
defaultValue: 'App settings — engine choices, voice defaults, saved options',
}),
history: t('settings.reset_scope_history', {
defaultValue: 'Generation & dub history, with their audio',
}),
content: t('settings.reset_scope_content', {
defaultValue: 'Voices, projects, generated audio, and the app database',
}),
engines: t('settings.reset_scope_engines', {
defaultValue: 'Installed sidecar engines (IndexTTS-2 and friends)',
}),
tools: t('settings.reset_scope_tools', {
defaultValue: 'Downloaded audio tools (ffmpeg, ffprobe, yt-dlp)',
}),
models: t('settings.reset_scope_models', {
defaultValue: 'Downloaded model weights',
}),
caches: t('settings.reset_scope_caches', {
defaultValue: 'Caches and temporary files',
}),
logs: t('settings.reset_scope_logs', { defaultValue: 'Logs and crash reports' }),
};
const TIERS = [
{
id: 'ui',
label: t('settings.reset_tier_ui', { defaultValue: 'UI preferences only' }),
hint: t('settings.reset_tier_ui_hint', {
defaultValue: 'Theme, layout and dub knobs go back to defaults. Nothing on disk changes.',
}),
},
{
id: 'settings',
label: t('settings.reset_tier_settings', { defaultValue: 'All settings' }),
hint: t('settings.reset_tier_settings_hint', {
defaultValue:
'Every preference, in the app and on disk. Your voices, projects and models are untouched.',
}),
},
{
id: 'assets',
label: t('settings.reset_tier_assets', { defaultValue: 'Downloaded assets & models' }),
hint: t('settings.reset_tier_assets_hint', {
defaultValue:
'Model weights, sidecar engines, audio tools and caches. Everything you made stays. They re-download when next needed.',
}),
},
{
id: 'everything',
label: t('settings.reset_tier_everything', { defaultValue: 'Everything OmniVoice did' }),
hint: t('settings.reset_tier_everything_hint', {
defaultValue:
'Back to a fresh install: settings, voices, projects, audio, models, engines, logs. The app restarts on the first-run screen.',
}),
},
];
// No shell no local install to clear. Preferences are all we own here.
const shellless = !inTauri();
const activePreset = matchingPreset(selected);
return (
<>
<SettingsSection
icon={RotateCcw}
title={t('settings.reset', { defaultValue: 'Reset & remove' })}
description={t('settings.reset_desc', {
defaultValue: 'Put part — or all — of OmniVoice back to how it shipped.',
})}
>
{shellless ? (
<p className="m-0 mb-[var(--space-4)] [font-family:var(--font-sans)] text-[length:var(--text-md)] leading-[1.6] text-[var(--chrome-fg-muted)]">
{t('settings.reset_body_web', {
defaultValue:
'Clears locally-saved preferences (theme, language, dub settings) and reloads. Your voices, projects and generated audio are not affected.',
})}
</p>
) : (
<>
<div className="mb-[var(--space-4)] flex flex-col gap-[var(--space-2)]">
{TIERS.map((tier) => {
const size = selectedBytes(scopes, PRESETS[tier.id]);
const on = activePreset === tier.id;
return (
<label
key={tier.id}
className={`flex cursor-pointer items-start gap-[var(--space-3)] rounded-[var(--radius-md)] p-[var(--space-3)] ${
on ? 'bg-[var(--chrome-accent-bg)]' : 'bg-[var(--chrome-hover-bg)]'
}`}
>
<input
type="radio"
name="reset-tier"
checked={on}
onChange={() => setSelected(PRESETS[tier.id])}
data-testid={`reset-tier-${tier.id}`}
className="mt-1"
/>
<span className="min-w-0 flex-1">
<span className="flex items-baseline justify-between gap-[var(--space-3)]">
<span className="[font-family:var(--font-sans)] text-[length:var(--text-md)] text-[var(--chrome-fg)]">
{tier.label}
</span>
<span className="shrink-0 [font-family:var(--font-mono)] text-[length:var(--text-sm)] tabular-nums text-[var(--chrome-fg-muted)]">
{tier.id === 'ui' ? '—' : fmtBytes(size)}
</span>
</span>
<span className="block [font-family:var(--font-sans)] text-[length:var(--text-sm)] leading-[1.5] text-[var(--chrome-fg-muted)]">
{tier.hint}
</span>
</span>
</label>
);
})}
</div>
<button
type="button"
onClick={() => setAdvanced((v) => !v)}
data-testid="reset-advanced-toggle"
className="mb-[var(--space-3)] flex items-center gap-[var(--space-1)] border-0 bg-transparent p-0 [font-family:var(--font-sans)] text-[length:var(--text-sm)] text-[var(--chrome-fg-muted)] hover:text-[var(--chrome-fg)]"
>
<ChevronRight
size={13}
className={advanced ? 'rotate-90 transition-transform' : 'transition-transform'}
/>
{t('settings.reset_advanced', { defaultValue: 'Choose exactly what to remove' })}
</button>
{advanced && (
<div
className="mb-[var(--space-4)] flex flex-col gap-[var(--space-2)]"
data-testid="reset-advanced"
>
{SCOPE_ORDER.map((key) => {
const s = byKey[key];
const fileScope = !FRONTEND_SCOPES.includes(key);
return (
<StorageTargetRow
key={key}
icon={SCOPE_ICONS[key]}
label={LABELS[key]}
hint={
s?.shared
? t('settings.reset_models_shared', {
defaultValue:
'Shared Hugging Face cache — may hold models other AI tools downloaded.',
})
: undefined
}
size={fileScope ? (s?.size_bytes ?? 0) : undefined}
share={willFree > 0 && fileScope ? (s?.size_bytes ?? 0) / willFree : 0}
checked={selected.includes(key)}
onToggle={() => toggle(key)}
disabled={Boolean(s && !s.exists && fileScope)}
warn={Boolean(s?.shared)}
testId={`reset-scope-${key}`}
/>
);
})}
</div>
)}
</>
)}
<Button
variant="danger"
size="md"
leading={<RotateCcw size={13} />}
disabled={selected.length === 0}
onClick={() => {
setTyped('');
setOpen(true);
}}
data-testid="factory-reset-open"
>
{t('settings.reset', { defaultValue: 'Reset & remove' })}
</Button>
</SettingsSection>
<Dialog
open={open}
onClose={() => !busy && setOpen(false)}
title={t('settings.reset_confirm_title', { defaultValue: 'Reset OmniVoice?' })}
size="md"
footer={
<>
<Button variant="ghost" size="sm" disabled={busy} onClick={() => setOpen(false)}>
{t('common.cancel', { defaultValue: 'Cancel' })}
</Button>
<Button
variant="danger"
size="sm"
loading={busy}
disabled={busy || !typedOk}
onClick={run}
data-testid="factory-reset-confirm"
>
{plan(selected).restart
? t('settings.reset_confirm_restart', {
defaultValue: 'Remove {{size}} and restart',
size: fmtBytes(willFree),
})
: t('settings.reset_confirm', { defaultValue: 'Reset and reload' })}
</Button>
</>
}
>
<div className="flex flex-col gap-[var(--space-4)]">
<ul className="m-0 list-none p-0" data-testid="reset-summary">
{selected.map((key) => (
<li
key={key}
className="flex items-baseline justify-between gap-[var(--space-3)] py-[var(--space-1)] [font-family:var(--font-sans)] text-[length:var(--text-md)] text-[var(--chrome-fg)]"
>
<span>{LABELS[key]}</span>
<span className="shrink-0 [font-family:var(--font-mono)] text-[length:var(--text-sm)] tabular-nums text-[var(--chrome-fg-muted)]">
{FRONTEND_SCOPES.includes(key) ? '—' : fmtBytes(byKey[key]?.size_bytes ?? 0)}
</span>
</li>
))}
</ul>
{sharedModels && (
<p className="m-0 flex items-start gap-[var(--space-3)] [font-family:var(--font-sans)] text-[length:var(--text-sm)] leading-[1.6] text-[var(--chrome-fg-muted)]">
<AlertTriangle
size={16}
className="mt-1 shrink-0 text-[var(--chrome-severity-warn)]"
/>
<span data-testid="reset-shared-warning">
{t('settings.reset_models_shared_warning', {
defaultValue:
'The model cache is the standard Hugging Face cache, shared with other AI tools on this machine — removing it may delete models OmniVoice never downloaded. Everything OmniVoice needs will download again on next use.',
})}
</span>
</p>
)}
{needsTypedConfirm(selected) && (
<>
<p className="m-0 flex items-start gap-[var(--space-3)] [font-family:var(--font-sans)] text-[length:var(--text-md)] leading-[1.6] text-[var(--chrome-fg)]">
<AlertTriangle size={16} className="mt-1 shrink-0 text-[var(--color-danger)]" />
<span>
{t('settings.reset_irreversible', {
defaultValue:
'Your voice profiles, projects and generated audio will be permanently deleted. This cannot be undone.',
})}
</span>
</p>
<label className="flex flex-col gap-[var(--space-2)]">
<span className="[font-family:var(--font-sans)] text-[length:var(--text-sm)] text-[var(--chrome-fg-muted)]">
{t('settings.reset_type_to_confirm', {
defaultValue: 'Type {{word}} to confirm:',
word: CONFIRM_WORD,
})}
</span>
<input
type="text"
value={typed}
onChange={(e) => setTyped(e.target.value)}
autoComplete="off"
spellCheck="false"
data-testid="reset-type-confirm"
className="rounded-[var(--radius-md)] [border:1px_solid_var(--chrome-border)] bg-[var(--chrome-hover-bg)] px-[var(--space-3)] py-[var(--space-2)] [font-family:var(--font-mono)] text-[length:var(--text-md)] text-[var(--chrome-fg)] focus:outline-none"
/>
</label>
</>
)}
{plan(selected).restart && (
<p className="m-0 [font-family:var(--font-sans)] text-[length:var(--text-sm)] leading-[1.6] text-[var(--chrome-fg-muted)]">
{t('settings.reset_restart_note', {
defaultValue:
'OmniVoice will restart its engine to finish. This takes a few seconds.',
})}
</p>
)}
</div>
</Dialog>
</>
);
}
@@ -0,0 +1,246 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import React from 'react';
import ResetPanel, {
PRESETS,
plan,
selectedBytes,
needsTypedConfirm,
matchingPreset,
} from './ResetPanel';
const invoke = vi.fn();
vi.mock('@tauri-apps/api/core', () => ({ invoke: (...a) => invoke(...a) }));
const clearHistory = vi.fn(async () => ({ ok: true }));
const clearDubHistory = vi.fn(async () => ({ ok: true }));
vi.mock('../../api/generate', () => ({ clearHistory: (...a) => clearHistory(...a) }));
vi.mock('../../api/dub', () => ({ clearDubHistory: (...a) => clearDubHistory(...a) }));
/** What `reset_scan` returns from the shell: every scope, with its real size. */
const SCOPES = [
{ key: 'ui_prefs', paths: [], size_bytes: 0, exists: true, shared: false, needs_restart: false },
{ key: 'history', paths: [], size_bytes: 0, exists: true, shared: false, needs_restart: false },
{
key: 'settings',
paths: ['/d/prefs.json'],
size_bytes: 4_096,
exists: true,
shared: false,
needs_restart: true,
},
{
key: 'content',
paths: ['/d/voices'],
size_bytes: 5 * 1024 ** 3,
exists: true,
shared: false,
needs_restart: true,
},
{
key: 'engines',
paths: ['/d/engines'],
size_bytes: 2 * 1024 ** 3,
exists: true,
shared: false,
needs_restart: true,
},
{
key: 'tools',
paths: ['/d/media_tools'],
size_bytes: 100 * 1024 ** 2,
exists: true,
shared: false,
needs_restart: true,
},
{
key: 'models',
paths: ['/hf'],
size_bytes: 14 * 1024 ** 3,
exists: true,
shared: true,
needs_restart: true,
},
{
key: 'caches',
paths: ['/d/gallery_cache'],
size_bytes: 10 * 1024 ** 2,
exists: true,
shared: false,
needs_restart: true,
},
{
key: 'logs',
paths: ['/d/omnivoice.log'],
size_bytes: 1024,
exists: true,
shared: false,
needs_restart: true,
},
];
const byKey = Object.fromEntries(SCOPES.map((s) => [s.key, s]));
describe('reset planning', () => {
it('never sends a frontend-only scope to the shell', () => {
// ui_prefs is localStorage and history is a DB endpoint the Rust purge has
// no idea what either means, and passing them would be a silent no-op at best.
const steps = plan(['ui_prefs', 'history', 'models']);
expect(steps.disk).toEqual(['models']);
expect(steps.prefs).toBe(true);
expect(steps.history).toBe(true);
});
it('skips the history endpoints when the whole database is going anyway', () => {
// `content` deletes omnivoice.db outright, so clearing history rows first is a
// pointless round-trip against records that are about to cease to exist.
const steps = plan(['history', 'content']);
expect(steps.history).toBe(false);
expect(steps.disk).toEqual(['content']);
});
it('only needs a backend restart when something on disk is being deleted', () => {
expect(plan(['ui_prefs']).restart).toBe(false);
expect(plan(['history']).restart).toBe(false);
expect(plan(['ui_prefs', 'settings']).restart).toBe(true);
});
it('demands the typed word for anything unrecoverable, and not otherwise', () => {
expect(needsTypedConfirm(['content'])).toBe(true);
expect(needsTypedConfirm(PRESETS.everything)).toBe(true);
// Models are a big download, but they are only a download they come back.
expect(needsTypedConfirm(PRESETS.assets)).toBe(false);
expect(needsTypedConfirm(PRESETS.settings)).toBe(false);
});
it('counts exactly what is ticked, so the button number is the truth', () => {
expect(selectedBytes(SCOPES, ['settings'])).toBe(4_096);
expect(selectedBytes(SCOPES, PRESETS.assets)).toBe(
byKey.models.size_bytes +
byKey.engines.size_bytes +
byKey.tools.size_bytes +
byKey.caches.size_bytes,
);
// Scopes that own no files contribute nothing.
expect(selectedBytes(SCOPES, ['ui_prefs', 'history'])).toBe(0);
expect(selectedBytes(SCOPES, [])).toBe(0);
expect(selectedBytes(undefined, ['models'])).toBe(0);
});
it('leaves the user something that still runs: no preset removes the Python env', () => {
// The owner's call "Everything" means a fresh install, not a reinstall. The
// interpreter the backend runs on is the uninstaller's business, not reset's.
for (const scopes of Object.values(PRESETS)) {
expect(scopes).not.toContain('env');
}
expect(PRESETS.assets).not.toContain('content');
});
it('recognises the tier a selection corresponds to', () => {
expect(matchingPreset(['ui_prefs'])).toBe('ui');
expect(matchingPreset([...PRESETS.everything].reverse())).toBe('everything');
expect(matchingPreset(['models'])).toBeNull();
});
});
describe('ResetPanel', () => {
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
window.__TAURI_INTERNALS__ = {};
invoke.mockImplementation(async (cmd) => {
if (cmd === 'reset_scan') return SCOPES;
if (cmd === 'reset_purge')
return { removed: [], failed: [], refused: [], freed_bytes: 0, restarted: true };
return null;
});
});
const openDialog = async () => {
render(<ResetPanel />);
await waitFor(() => expect(invoke).toHaveBeenCalledWith('reset_scan'));
fireEvent.click(screen.getByTestId('factory-reset-open'));
await screen.findByTestId('factory-reset-confirm');
};
it('defaults to the least destructive tier', async () => {
render(<ResetPanel />);
await waitFor(() => expect(screen.getByTestId('reset-tier-ui')).toBeChecked());
expect(screen.getByTestId('reset-tier-everything')).not.toBeChecked();
});
it('will not delete voices and projects on a single click', async () => {
render(<ResetPanel />);
await waitFor(() => expect(screen.getByTestId('reset-tier-everything')).toBeInTheDocument());
fireEvent.click(screen.getByTestId('reset-tier-everything'));
fireEvent.click(screen.getByTestId('factory-reset-open'));
const confirm = await screen.findByTestId('factory-reset-confirm');
expect(confirm).toBeDisabled();
fireEvent.change(screen.getByTestId('reset-type-confirm'), { target: { value: 'delete' } });
await waitFor(() => expect(confirm).toBeEnabled());
});
it('sends only disk scopes to the shell and clears preferences here', async () => {
localStorage.setItem('omnivoice.app', '{"state":{}}');
localStorage.setItem('omni_transcriptions', '[{"text":"note"}]');
render(<ResetPanel />);
await waitFor(() => expect(screen.getByTestId('reset-tier-settings')).toBeInTheDocument());
fireEvent.click(screen.getByTestId('reset-tier-settings')); // ui_prefs + settings
fireEvent.click(screen.getByTestId('factory-reset-open'));
fireEvent.click(await screen.findByTestId('factory-reset-confirm'));
await waitFor(() =>
expect(invoke).toHaveBeenCalledWith('reset_purge', { scopes: ['settings'] }),
);
// The zustand blob goes; dictation history user data, not a preference stays.
expect(localStorage.getItem('omnivoice.app')).toBeNull();
expect(localStorage.getItem('omni_transcriptions')).toBe('[{"text":"note"}]');
});
it('clears history through the API without bouncing the backend', async () => {
await openDialog();
fireEvent.click(screen.getByTestId('reset-advanced-toggle'));
fireEvent.click(await screen.findByTestId('reset-scope-history'));
fireEvent.click(screen.getByTestId('reset-scope-ui_prefs')); // untick, leaving history alone
fireEvent.click(screen.getByTestId('factory-reset-confirm'));
await waitFor(() => expect(clearHistory).toHaveBeenCalled());
expect(clearDubHistory).toHaveBeenCalled();
// Nothing on disk was touched, so there is nothing to restart for.
expect(invoke).not.toHaveBeenCalledWith('reset_purge', expect.anything());
});
it('warns before sweeping up a model cache other tools share', async () => {
render(<ResetPanel />);
await waitFor(() => expect(screen.getByTestId('reset-tier-assets')).toBeInTheDocument());
fireEvent.click(screen.getByTestId('reset-tier-assets'));
fireEvent.click(screen.getByTestId('factory-reset-open'));
expect(await screen.findByTestId('reset-shared-warning')).toBeInTheDocument();
});
it('stays silent about sharing when the cache is app-private (Windows, portable)', async () => {
invoke.mockImplementation(async (cmd) =>
cmd === 'reset_scan'
? SCOPES.map((s) => (s.key === 'models' ? { ...s, shared: false } : s))
: { removed: [], failed: [], refused: [], freed_bytes: 0, restarted: true },
);
render(<ResetPanel />);
await waitFor(() => expect(screen.getByTestId('reset-tier-assets')).toBeInTheDocument());
fireEvent.click(screen.getByTestId('reset-tier-assets'));
fireEvent.click(screen.getByTestId('factory-reset-open'));
await screen.findByTestId('factory-reset-confirm');
expect(screen.queryByTestId('reset-shared-warning')).not.toBeInTheDocument();
});
it('outside the desktop shell, offers preferences only', async () => {
delete window.__TAURI_INTERNALS__;
render(<ResetPanel />);
expect(screen.queryByTestId('reset-tier-everything')).not.toBeInTheDocument();
expect(screen.getByTestId('factory-reset-open')).toBeInTheDocument();
expect(invoke).not.toHaveBeenCalled();
});
});
@@ -110,7 +110,7 @@ export default function StoragePanel() {
control={
<div className="flex w-full flex-wrap items-center gap-[var(--space-3)]">
<input
className="box-border min-w-0 max-w-[520px] flex-[1_1_280px] rounded-[var(--chrome-radius-pill)] [border:1px_solid_var(--chrome-border)] bg-[var(--chrome-input-bg)] px-[var(--space-3)] py-[var(--space-2)] font-[family-name:var(--chrome-font-mono)] text-[length:var(--text-base)] text-[var(--chrome-fg)] placeholder:text-[var(--chrome-fg-dim)] focus-visible:border-[var(--chrome-accent)] focus-visible:shadow-[var(--focus-ring)] focus-visible:outline-none"
className="box-border min-w-0 max-w-[520px] flex-[1_1_280px] rounded-[var(--chrome-radius-pill)] [border:1px_solid_var(--chrome-border)] bg-[var(--chrome-hover-bg)] px-[var(--space-3)] py-[var(--space-2)] font-[family-name:var(--chrome-font-mono)] text-[length:var(--text-base)] text-[var(--chrome-fg)] placeholder:text-[var(--chrome-fg-dim)] focus-visible:border-[var(--chrome-accent)] focus-visible:shadow-[var(--focus-ring)] focus-visible:outline-none"
type="text"
value={input}
placeholder={def || '~/.cache/huggingface'}
+18 -85
View File
@@ -2,52 +2,33 @@
* Settings Storage (System group).
*
* Shows where OmniVoice keeps its data and outputs (read-only, from systemInfo,
* each with an Open-folder affordance via the /export/reveal endpoint) and a
* "Factory reset" action that clears every locally-persisted UI preference
* the full registry in utils/prefKeys.js, not just the zustand blob behind a
* confirm Dialog, then reloads.
* each with an Open-folder affordance via the /export/reveal endpoint), then the
* two destructive affordances, in escalating order:
*
* NOTE: the models *cache* directory lives in the Models category (StoragePanel)
* this category is about the app's own data/outputs paths and a clean-slate
* reset of UI prefs. Factory reset only touches localStorage prefs; it never
* deletes the user's voices, projects, or outputs on disk, and never wipes the
* remote-backend connection or dictation history (see prefKeys.PRESERVED_KEYS).
* ResetPanel scoped reset. Anything from "forget my theme" to "back to a
* fresh install", per-scope, with real sizes. Leaves a working
* app behind: the shell restarts the backend afterwards.
* UninstallPanel the door out (#1089). Deletes everything including the
* managed Python environment, then quits.
*
* NOTE: the models *cache* directory lives in the Models category (StoragePanel).
*/
import React, { useState } from 'react';
import { FolderOpen, HardDrive, RotateCcw } from 'lucide-react';
import React from 'react';
import { FolderOpen, HardDrive } from 'lucide-react';
import toast from 'react-hot-toast';
import { useTranslation } from 'react-i18next';
import { useSystemInfo } from '../../api/hooks';
import { exportReveal } from '../../api/exports';
import { clearLocalPreferences } from '../../utils/prefKeys';
import { Button, Dialog } from '../../ui';
import { Button } from '../../ui';
import { SettingsSection } from './primitives';
import Row from './Row';
import HistoryRetentionPanel from './HistoryRetentionPanel';
import ResetPanel from './ResetPanel';
import UninstallPanel from './UninstallPanel';
export default function StorageTab() {
const { t } = useTranslation();
const { data: info } = useSystemInfo();
const [confirmOpen, setConfirmOpen] = useState(false);
const factoryReset = () => {
try {
clearLocalPreferences();
toast.success(
t('settings.factory_reset_done', { defaultValue: 'Preferences cleared — reloading…' }),
);
setConfirmOpen(false);
// Reload so the store rehydrates from defaults across the whole app.
setTimeout(() => window.location.reload(), 350);
} catch (e) {
toast.error(
t('settings.factory_reset_failed', {
defaultValue: 'Reset failed: {{message}}',
message: e?.message || e,
}),
);
}
};
const openFolder = async (path) => {
try {
@@ -103,59 +84,11 @@ export default function StorageTab() {
<HistoryRetentionPanel />
<SettingsSection
icon={RotateCcw}
title={t('settings.factory_reset', { defaultValue: 'Factory reset' })}
description={t('settings.factory_reset_desc', {
defaultValue:
'Reset all in-app preferences to their defaults. Your files stay untouched.',
})}
>
<p className="m-0 mb-[var(--space-4)] [font-family:var(--font-sans)] text-[length:var(--text-md)] leading-[1.6] text-[var(--chrome-fg-muted)]">
{t('settings.factory_reset_body', {
defaultValue:
'Clears locally-saved settings (theme, language, dub knobs, gallery favorites, and other UI preferences). It does NOT delete your voices, projects, or generated audio on disk.',
})}
</p>
<Button
variant="danger"
size="md"
leading={<RotateCcw size={13} />}
onClick={() => setConfirmOpen(true)}
data-testid="factory-reset-open"
>
{t('settings.factory_reset', { defaultValue: 'Factory reset' })}
</Button>
</SettingsSection>
{/* Scoped reset: preferences → settings → assets → everything. */}
<ResetPanel />
<Dialog
open={confirmOpen}
onClose={() => setConfirmOpen(false)}
title={t('settings.factory_reset_confirm_title', { defaultValue: 'Reset preferences?' })}
size="sm"
footer={
<>
<Button variant="ghost" size="sm" onClick={() => setConfirmOpen(false)}>
{t('common.cancel', { defaultValue: 'Cancel' })}
</Button>
<Button
variant="danger"
size="sm"
onClick={factoryReset}
data-testid="factory-reset-confirm"
>
{t('settings.factory_reset_confirm', { defaultValue: 'Reset and reload' })}
</Button>
</>
}
>
<p className="m-0 [font-family:var(--font-sans)] text-[length:var(--text-md)] leading-[1.6] text-[var(--chrome-fg)]">
{t('settings.factory_reset_confirm_body', {
defaultValue:
'This clears all saved UI preferences and reloads the app. Your voices, projects, and outputs on disk are not affected. Continue?',
})}
</p>
</Dialog>
{/* The door out (#1089): everything, including the Python env, then quit. */}
<UninstallPanel />
</>
);
}
@@ -0,0 +1,134 @@
/**
* One line item in a destructive Storage panel used by both "Reset & remove"
* and "Remove all data", so the two read as one system rather than two lists
* that happen to sit next to each other.
*
* The design problem it solves: a flat list of sizes is unreadable. A 7.5 GB
* model cache and a 391 B config file rendered at the same visual weight, so the
* one number that actually matters *where the space went* was the one thing
* you couldn't see. Each row now carries a **proportional bar**: its share of the
* total being removed. The big one looks big.
*
* Rows are either informational (uninstall lists what it will delete) or
* selectable (reset lets you pick). Pass `onToggle` to get a checkbox; leave it
* off for a plain row.
*/
import { useTranslation } from 'react-i18next';
import { fmtBytes } from './bytes';
/**
* @param {object} props
* @param {Function} props.icon lucide icon component
* @param {string} props.label what this is, in the user's words
* @param {string} [props.hint] a second line a caveat, not a repeat of the label
* @param {string} [props.path] the folder on disk (dimmed; truncates, full text on hover)
* @param {number} [props.size] bytes; omit for scopes that own no files
* @param {number} [props.share] 01, this row's fraction of the total. Drives the bar.
* @param {boolean} [props.checked]
* @param {Function} [props.onToggle] present the row is selectable
* @param {boolean} [props.disabled] nothing here to remove
* @param {boolean} [props.warn] tint the bar as a caution (the shared model cache)
*/
export default function StorageTargetRow({
icon: Icon,
label,
hint,
path,
size,
share = 0,
checked = false,
onToggle,
disabled = false,
warn = false,
testId,
}) {
const { t } = useTranslation();
const selectable = typeof onToggle === 'function';
// An unticked row still shows its size, but claims none of the bar: the bars
// must add up to what the button says it will free, or they are lying.
const filled = selectable && !checked ? 0 : Math.max(0, Math.min(1, share));
const Wrapper = selectable ? 'label' : 'div';
return (
<Wrapper
// A selectable row's handle is its checkbox; an informational row has none,
// so the id lands on the row itself. Either way `testId` addresses the thing
// a test would actually interact with.
data-testid={selectable ? undefined : testId}
className={`flex items-start gap-[var(--space-3)] rounded-[var(--radius-md)] p-[var(--space-3)] ${
selectable && !disabled ? 'cursor-pointer' : ''
} ${checked ? 'bg-[var(--chrome-accent-bg)]' : 'bg-[var(--chrome-hover-bg)]'} ${
disabled ? 'opacity-50' : ''
}`}
>
{selectable && (
<input
type="checkbox"
checked={checked}
onChange={onToggle}
disabled={disabled}
data-testid={testId}
className="mt-[3px]"
/>
)}
<span
className={`mt-[1px] flex h-6 w-6 shrink-0 items-center justify-center rounded-[var(--radius-sm)] ${
checked ? 'text-[var(--chrome-accent)]' : 'text-[var(--chrome-fg-muted)]'
}`}
aria-hidden="true"
>
{Icon && <Icon size={15} />}
</span>
<span className="flex min-w-0 flex-1 flex-col gap-[var(--space-1)]">
<span className="flex items-baseline justify-between gap-[var(--space-3)]">
<span className="[font-family:var(--font-sans)] text-[length:var(--text-md)] text-[var(--chrome-fg)]">
{label}
</span>
<span className="shrink-0 [font-family:var(--font-mono)] text-[length:var(--text-sm)] tabular-nums text-[var(--chrome-fg-muted)]">
{Number.isFinite(size) ? fmtBytes(size) : '—'}
</span>
</span>
{/* Share of the total. Hidden when there is nothing to show, so rows that
own no files (UI preferences) don't render an eternally empty track. */}
{Number.isFinite(size) && size > 0 && (
<span
className="block h-[3px] w-full overflow-hidden rounded-[var(--radius-pill)] bg-[var(--chrome-hover-bg)]"
role="presentation"
data-testid={testId ? `${testId}-bar` : undefined}
>
<span
className={`block h-full rounded-[var(--radius-pill)] ${
warn ? 'bg-[var(--chrome-severity-warn)]' : 'bg-[var(--chrome-accent)]'
}`}
style={{ width: `${Math.round(filled * 100)}%` }}
/>
</span>
)}
{hint && (
<span className="[font-family:var(--font-sans)] text-[length:var(--text-xs)] leading-[1.5] text-[var(--chrome-fg-muted)]">
{hint}
</span>
)}
{path && (
<span
title={path}
className="block truncate [font-family:var(--font-mono)] text-[length:var(--text-xs)] text-[var(--chrome-fg-dim)]"
>
{path}
</span>
)}
{disabled && (
<span className="[font-family:var(--font-sans)] text-[length:var(--text-xs)] text-[var(--chrome-fg-dim)]">
{t('settings.storage_target_empty', { defaultValue: 'Nothing to remove' })}
</span>
)}
</span>
</Wrapper>
);
}
@@ -90,7 +90,7 @@ function ProportionBar({ value, max }) {
const pct = max > 0 ? Math.max(value > 0 ? 1.5 : 0, (value / max) * 100) : 0;
return (
<div
className="h-[4px] w-full overflow-hidden rounded-[var(--chrome-radius-pill)] bg-[var(--chrome-input-bg)]"
className="h-[4px] w-full overflow-hidden rounded-[var(--chrome-radius-pill)] bg-[var(--chrome-hover-bg)]"
aria-hidden="true"
>
<div
@@ -290,7 +290,7 @@ export default function StorageUsagePanel() {
{[0, 1, 2, 3].map((i) => (
<div
key={i}
className="mb-[var(--space-3)] h-[36px] animate-pulse rounded-[var(--chrome-radius-pill)] bg-[var(--chrome-input-bg)]"
className="mb-[var(--space-3)] h-[36px] animate-pulse rounded-[var(--chrome-radius-pill)] bg-[var(--chrome-hover-bg)]"
/>
))}
</div>
@@ -313,7 +313,7 @@ export default function StorageUsagePanel() {
})}
</span>
</div>
<div className="h-[6px] w-full overflow-hidden rounded-[var(--chrome-radius-pill)] bg-[var(--chrome-input-bg)]">
<div className="h-[6px] w-full overflow-hidden rounded-[var(--chrome-radius-pill)] bg-[var(--chrome-hover-bg)]">
<div
className="h-full rounded-[var(--chrome-radius-pill)]"
style={{
@@ -0,0 +1,309 @@
/**
* Settings Storage "Remove all data" (#1089).
*
* The in-app half of the uninstaller. A user who installed the .dmg / .msi /
* AppImage has no repo, so `scripts/uninstall.sh` never reaches them this is
* the affordance they actually have. It asks the desktop shell for every folder
* this install owns (honoring custom + portable locations), shows each with its
* real size, and deletes them behind a typed confirmation.
*
* Two deliberate choices:
* - The **shared Hugging Face cache is opt-in**, on its own checkbox with the
* caveat spelled out: it's the standard HF cache other ML tools use, so
* removing it can delete models OmniVoice never downloaded.
* - The confirmation requires **typing the word**, not just a click. This
* deletes voice profiles and projects that cannot be recovered.
*
* After the purge the app quits: the Python environment it runs on is gone, so
* there is nothing to return to. Removing the app *binary* is a per-platform
* step we link out to (docs/install/uninstall.md).
*
* Outside the Tauri shell (browser/Docker) there is no local install to remove,
* so this renders nothing.
*/
import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Trash2,
AlertTriangle,
Folder,
Package,
ScrollText,
Database,
KeyRound,
} from 'lucide-react';
import toast from 'react-hot-toast';
import { Button, Dialog } from '../../ui';
import { SettingsSection } from './primitives';
import StorageTargetRow from './StorageTargetRow';
import { fmtBytes } from './bytes';
const inTauri = () => typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window;
// Re-exported: this was the panel's own helper before the Storage panels shared
// one formatter, and it is imported by name in the tests.
export { fmtBytes };
/** Bytes the purge will actually free, given the opt-in on the shared cache.
* Pure + exported: the number shown on the button must match what gets deleted. */
export function freedBytes(targets, includeModels) {
return (targets || [])
.filter((t) => t.exists && (!t.shared || includeModels))
.reduce((sum, t) => sum + (t.size_bytes || 0), 0);
}
const ICONS = {
data: Folder,
env: Package,
logs: ScrollText,
userenv: KeyRound,
models: Database,
};
export default function UninstallPanel() {
const { t } = useTranslation();
const [targets, setTargets] = useState(null);
const [open, setOpen] = useState(false);
const [includeModels, setIncludeModels] = useState(false);
const [typed, setTyped] = useState('');
const [busy, setBusy] = useState(false);
const CONFIRM_WORD = t('settings.uninstall_confirm_word', { defaultValue: 'DELETE' });
const scan = useCallback(async () => {
if (!inTauri()) return;
try {
const { invoke } = await import('@tauri-apps/api/core');
setTargets(await invoke('uninstall_scan'));
} catch (e) {
console.warn('[UninstallPanel] scan failed', e);
}
}, []);
useEffect(() => {
scan();
}, [scan]);
const purge = async () => {
setBusy(true);
try {
const { invoke } = await import('@tauri-apps/api/core');
const report = await invoke('uninstall_purge', { includeModels });
if (report?.failed?.length) {
toast.error(
t('settings.uninstall_partial', {
defaultValue: 'Some folders could not be removed: {{paths}}',
paths: report.failed.join(', '),
}),
{ duration: 10000 },
);
}
// The Python env we run on is gone quit rather than pretend to carry on.
await invoke('quit_app').catch(() => {});
} catch (e) {
setBusy(false);
toast.error(
t('settings.uninstall_failed', {
defaultValue: 'Could not remove the data: {{message}}',
message: e?.message || String(e),
}),
);
}
};
if (!inTauri()) return null;
const present = (targets || []).filter((x) => x.exists);
const models = present.find((x) => x.shared);
const owned = present.filter((x) => !x.shared);
const willFree = freedBytes(targets, includeModels);
const LABELS = {
data: t('settings.uninstall_target_data', {
defaultValue: 'Voices, projects, generated audio, history',
}),
env: t('settings.uninstall_target_env', {
defaultValue: 'Settings + the managed Python environment',
}),
logs: t('settings.uninstall_target_logs', { defaultValue: 'Logs' }),
userenv: t('settings.uninstall_target_userenv', {
defaultValue: 'Saved environment (cache location, tokens)',
}),
models: t('settings.uninstall_target_models', {
defaultValue: 'Downloaded model weights (shared Hugging Face cache)',
}),
};
return (
<>
<SettingsSection
icon={Trash2}
title={t('settings.uninstall', { defaultValue: 'Remove all data' })}
description={t('settings.uninstall_desc', {
defaultValue: 'Delete everything OmniVoice has written to this machine, then quit.',
})}
>
<p className="m-0 mb-[var(--space-4)] [font-family:var(--font-sans)] text-[length:var(--text-md)] leading-[1.6] text-[var(--chrome-fg-muted)]">
{t('settings.uninstall_body', {
defaultValue:
'OmniVoice is fully local, so uninstalling is just deleting the folders it wrote. This removes your voice profiles, projects, and generated audio permanently — there is no undo. Removing the app itself is a separate step.',
})}
</p>
{owned.length > 0 && (
<div className="mb-[var(--space-4)] flex flex-col gap-[var(--space-2)]">
{owned.map((tg) => (
<StorageTargetRow
key={tg.key}
icon={ICONS[tg.key]}
label={LABELS[tg.key] || tg.key}
path={tg.path}
size={tg.size_bytes}
share={willFree > 0 ? tg.size_bytes / willFree : 0}
testId={`uninstall-target-${tg.key}`}
/>
))}
{/* The shared cache is a different KIND of thing, so it gets its own
group and its own checkbox here, in the list, not buried in the
confirm dialog, so the total on the button moves when you tick it. */}
{models && (
<>
<span className="mt-[var(--space-2)] [font-family:var(--font-sans)] text-[length:var(--text-xs)] uppercase tracking-[var(--chrome-label-track)] text-[var(--chrome-fg-dim)]">
{t('settings.uninstall_optional_group', { defaultValue: 'Optional' })}
</span>
<StorageTargetRow
icon={ICONS.models}
label={LABELS.models}
hint={t('settings.uninstall_models_caveat', {
defaultValue:
'The standard Hugging Face cache, shared with other AI tools on this machine — removing it may delete models OmniVoice never downloaded. Anything OmniVoice needs downloads again.',
})}
path={models.path}
size={models.size_bytes}
share={willFree > 0 ? models.size_bytes / willFree : 0}
checked={includeModels}
onToggle={(e) => setIncludeModels(e.target.checked)}
warn
testId="uninstall-include-models"
/>
</>
)}
<p className="m-0 mt-[var(--space-2)] [font-family:var(--font-sans)] text-[length:var(--text-sm)] text-[var(--chrome-fg-muted)]">
{t('settings.uninstall_total', {
defaultValue: '{{count}} locations · {{size}} will be freed',
count: present.filter((x) => !x.shared || includeModels).length,
size: fmtBytes(willFree),
})}
</p>
</div>
)}
<Button
variant="danger"
size="md"
leading={<Trash2 size={13} />}
onClick={() => {
setTyped('');
setOpen(true);
}}
data-testid="uninstall-open"
>
{t('settings.uninstall', { defaultValue: 'Remove all data' })}
</Button>
</SettingsSection>
<Dialog
open={open}
onClose={() => !busy && setOpen(false)}
title={t('settings.uninstall_confirm_title', {
defaultValue: 'Remove all OmniVoice data?',
})}
size="md"
footer={
<>
<Button variant="ghost" size="sm" disabled={busy} onClick={() => setOpen(false)}>
{t('common.cancel', { defaultValue: 'Cancel' })}
</Button>
<Button
variant="danger"
size="sm"
loading={busy}
disabled={busy || typed.trim().toUpperCase() !== CONFIRM_WORD}
onClick={purge}
data-testid="uninstall-confirm"
>
{t('settings.uninstall_confirm', {
defaultValue: 'Delete {{size}} and quit',
size: fmtBytes(willFree),
})}
</Button>
</>
}
>
<div className="flex flex-col gap-[var(--space-4)]">
<p className="m-0 flex items-start gap-[var(--space-3)] [font-family:var(--font-sans)] text-[length:var(--text-md)] leading-[1.6] text-[var(--chrome-fg)]">
<AlertTriangle size={16} className="mt-1 shrink-0 text-[var(--color-danger)]" />
<span>
{t('settings.uninstall_confirm_body', {
defaultValue:
'Your voice profiles, projects, and generated audio will be permanently deleted. This cannot be undone.',
})}
</span>
</p>
{/* What is actually going, at the moment of no return. The opt-in for
the shared cache lives in the list behind this dialog asking twice
invites the user to skim, and this is the screen to read. */}
<ul className="m-0 list-none p-0" data-testid="uninstall-summary">
{present
.filter((x) => !x.shared || includeModels)
.map((tg) => (
<li
key={tg.key}
className="flex items-baseline justify-between gap-[var(--space-3)] py-[var(--space-1)] [font-family:var(--font-sans)] text-[length:var(--text-md)] text-[var(--chrome-fg)]"
>
<span>{LABELS[tg.key] || tg.key}</span>
<span className="shrink-0 [font-family:var(--font-mono)] text-[length:var(--text-sm)] tabular-nums text-[var(--chrome-fg-muted)]">
{fmtBytes(tg.size_bytes)}
</span>
</li>
))}
</ul>
{models && includeModels && (
<p className="m-0 flex items-start gap-[var(--space-3)] [font-family:var(--font-sans)] text-[length:var(--text-sm)] leading-[1.6] text-[var(--chrome-fg-muted)]">
<AlertTriangle
size={16}
className="mt-1 shrink-0 text-[var(--chrome-severity-warn)]"
/>
<span data-testid="uninstall-models-warning">
{t('settings.uninstall_models_warning', {
defaultValue:
'This includes the shared Hugging Face cache ({{size}}) — models other AI tools downloaded may go with it.',
size: fmtBytes(models.size_bytes),
})}
</span>
</p>
)}
<label className="flex flex-col gap-[var(--space-2)]">
<span className="[font-family:var(--font-sans)] text-[length:var(--text-sm)] text-[var(--chrome-fg-muted)]">
{t('settings.uninstall_type_to_confirm', {
defaultValue: 'Type {{word}} to confirm:',
word: CONFIRM_WORD,
})}
</span>
<input
type="text"
value={typed}
onChange={(e) => setTyped(e.target.value)}
autoComplete="off"
spellCheck="false"
data-testid="uninstall-type-confirm"
className="rounded-[var(--radius-md)] [border:1px_solid_var(--chrome-border)] bg-[var(--chrome-hover-bg)] px-[var(--space-3)] py-[var(--space-2)] [font-family:var(--font-mono)] text-[length:var(--text-md)] text-[var(--chrome-fg)] focus:outline-none"
/>
</label>
</div>
</Dialog>
</>
);
}
@@ -0,0 +1,111 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import React from 'react';
import UninstallPanel from './UninstallPanel';
const invoke = vi.fn();
vi.mock('@tauri-apps/api/core', () => ({ invoke: (...a) => invoke(...a) }));
const TARGETS = [
{
key: 'data',
path: '/u/Library/Application Support/OmniVoice',
size_bytes: 720 * 1024,
exists: true,
shared: false,
},
{
key: 'env',
path: '/u/Library/Application Support/com.debpalash.omnivoice-studio',
size_bytes: 391,
exists: true,
shared: false,
},
{ key: 'logs', path: '/u/Library/Logs/OmniVoice', size_bytes: 4096, exists: true, shared: false },
{
key: 'models',
path: '/u/.cache/huggingface',
size_bytes: 7.5 * 1024 ** 3,
exists: true,
shared: true,
},
];
describe('UninstallPanel', () => {
beforeEach(() => {
vi.clearAllMocks();
window.__TAURI_INTERNALS__ = {};
invoke.mockImplementation(async (cmd) => (cmd === 'uninstall_scan' ? TARGETS : null));
});
const ready = async () => {
render(<UninstallPanel />);
await waitFor(() => expect(screen.getByTestId('uninstall-target-data')).toBeInTheDocument());
};
it('renders a 391-byte folder as bytes, not as "0 KB"', async () => {
// The old formatter floored at KB, so the config folder read as empty.
await ready();
expect(screen.getByText('391 B')).toBeInTheDocument();
expect(screen.getByText('720 KB')).toBeInTheDocument();
});
it('leaves the shared model cache out of the total until it is ticked', async () => {
await ready();
// 720 KB + 391 B + 4 KB the 7.5 GB cache is opt-in and must not be counted.
expect(screen.getByText(/3 locations/)).toBeInTheDocument();
expect(screen.getByText(/724 KB will be freed/)).toBeInTheDocument();
fireEvent.click(screen.getByTestId('uninstall-include-models'));
// Ticking it moves the total *in the panel* the number is live, not a
// surprise sprung on the user in the confirm dialog.
await waitFor(() => expect(screen.getByText(/4 locations/)).toBeInTheDocument());
expect(screen.getByText(/7\.5 GB will be freed/)).toBeInTheDocument();
});
it('gives each folder a bar sized to its share of what will be freed', async () => {
await ready();
const bar = (key) => screen.getByTestId(`uninstall-target-${key}-bar`).firstChild;
// data is ~99% of the 724 KB being freed; env (391 B) is a sliver.
expect(parseInt(bar('data').style.width, 10)).toBeGreaterThan(90);
expect(parseInt(bar('env').style.width, 10)).toBeLessThan(5);
});
it('the confirm dialog lists exactly what is going, and warns only when the shared cache is in', async () => {
await ready();
fireEvent.click(screen.getByTestId('uninstall-open'));
const summary = await screen.findByTestId('uninstall-summary');
expect(summary.children).toHaveLength(3); // the cache is not ticked
expect(screen.queryByTestId('uninstall-models-warning')).not.toBeInTheDocument();
fireEvent.click(screen.getByTestId('uninstall-confirm')); // still gated on the typed word
expect(invoke).not.toHaveBeenCalledWith('uninstall_purge', expect.anything());
});
it('warns in the dialog once the shared cache is included', async () => {
await ready();
fireEvent.click(screen.getByTestId('uninstall-include-models'));
fireEvent.click(screen.getByTestId('uninstall-open'));
expect(await screen.findByTestId('uninstall-models-warning')).toBeInTheDocument();
expect(screen.getByTestId('uninstall-summary').children).toHaveLength(4);
});
it('will not purge until DELETE is typed', async () => {
await ready();
fireEvent.click(screen.getByTestId('uninstall-open'));
const confirm = await screen.findByTestId('uninstall-confirm');
expect(confirm).toBeDisabled();
fireEvent.change(screen.getByTestId('uninstall-type-confirm'), { target: { value: 'DELETE' } });
await waitFor(() => expect(confirm).toBeEnabled());
fireEvent.click(confirm);
await waitFor(() =>
expect(invoke).toHaveBeenCalledWith('uninstall_purge', { includeModels: false }),
);
});
});
@@ -0,0 +1,180 @@
/**
* Settings Usage local-only insights.
*
* The privacy-preserving answer to "how am I using this?" and the deliberate
* alternative to cloud analytics (a PostHog integration was proposed and
* rejected, PR #1110, because a third-party telemetry endpoint would break the
* product's headline promise that nothing leaves your machine).
*
* Everything here is computed from the history the app has ALREADY written to
* your own database in the course of doing its job. It collects nothing new,
* stores nothing new, and sends nothing anywhere: the numbers are aggregates
* (counts and totals never the text of a take, never a file path), fetched
* over loopback from your own backend. The panel says so plainly, because a
* privacy guarantee the user can't see isn't worth much.
*/
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { BarChart3, ShieldCheck } from 'lucide-react';
import { apiJson } from '../../api/client';
import { SettingsSection } from './primitives';
/** "2 h 14 m" / "3 m 20 s" / "45 s". Pure + exported for tests. */
export function fmtDuration(seconds) {
const s = Math.max(0, Math.round(Number(seconds) || 0));
if (s < 60) return `${s} s`;
const m = Math.floor(s / 60);
if (m < 60) return `${m} m ${s % 60} s`;
const h = Math.floor(m / 60);
return `${h} h ${m % 60} m`;
}
/** Local date of a unix timestamp, or null. Pure + exported for tests. */
export function fmtDate(ts) {
if (!ts) return null;
try {
return new Date(ts * 1000).toLocaleDateString();
} catch {
return null;
}
}
function Stat({ label, value, sub }) {
return (
<div className="flex flex-col gap-[var(--space-1)] rounded-[var(--radius-md)] bg-[var(--chrome-hover-bg)] px-[var(--space-4)] py-[var(--space-3)]">
<span className="[font-family:var(--font-mono)] text-[length:var(--text-lg)] tabular-nums text-[var(--chrome-fg)]">
{value}
</span>
<span className="[font-family:var(--font-sans)] text-[length:var(--text-sm)] text-[var(--chrome-fg-muted)]">
{label}
</span>
{sub && (
<span className="[font-family:var(--font-sans)] text-[length:var(--text-xs)] text-[var(--chrome-fg-dim)]">
{sub}
</span>
)}
</div>
);
}
function Bars({ title, rows }) {
if (!rows?.length) return null;
const max = Math.max(...rows.map((r) => r.count), 1);
return (
<div className="flex flex-col gap-[var(--space-2)]">
<span className="[font-family:var(--font-sans)] text-[length:var(--text-sm)] text-[var(--chrome-fg-muted)]">
{title}
</span>
{rows.map((r) => (
<div key={r.name} className="flex items-center gap-[var(--space-3)]">
<span className="w-[92px] shrink-0 truncate [font-family:var(--font-sans)] text-[length:var(--text-sm)] text-[var(--chrome-fg)]">
{r.name}
</span>
<span className="h-[6px] flex-1 overflow-hidden rounded-full bg-[var(--chrome-hover-bg)]">
<span
className="block h-full rounded-full bg-[var(--chrome-accent)]"
style={{ width: `${Math.round((r.count / max) * 100)}%` }}
/>
</span>
<span className="w-[36px] shrink-0 text-right [font-family:var(--font-mono)] text-[length:var(--text-sm)] tabular-nums text-[var(--chrome-fg-muted)]">
{r.count}
</span>
</div>
))}
</div>
);
}
export default function UsageTab() {
const { t } = useTranslation();
const [s, setS] = useState(null);
const [err, setErr] = useState(null);
useEffect(() => {
let alive = true;
apiJson('/stats/usage')
.then((d) => alive && setS(d))
.catch((e) => alive && setErr(e?.message || String(e)));
return () => {
alive = false;
};
}, []);
const since = fmtDate(s?.first_at);
return (
<SettingsSection
icon={BarChart3}
title={t('settings.usage', { defaultValue: 'Usage' })}
description={t('settings.usage_desc', {
defaultValue: 'What you have made with OmniVoice — counted on your own machine.',
})}
>
{/* The guarantee, stated where the user can actually see it. */}
<p className="m-0 mb-[var(--space-4)] flex items-start gap-[var(--space-2)] [font-family:var(--font-sans)] text-[length:var(--text-sm)] leading-[1.6] text-[var(--chrome-fg-muted)]">
<ShieldCheck size={14} className="mt-[3px] shrink-0 text-[var(--chrome-accent)]" />
<span>
{t('settings.usage_privacy', {
defaultValue:
'These numbers are counted from your own history, on this machine, and are never sent anywhere. OmniVoice has no analytics service — nothing here leaves your computer.',
})}
</span>
</p>
{err && (
<p className="m-0 [font-family:var(--font-sans)] text-[length:var(--text-sm)] text-[var(--chrome-severity-err)]">
{err}
</p>
)}
{s && s.takes === 0 && (
<p className="m-0 [font-family:var(--font-sans)] text-[length:var(--text-md)] text-[var(--chrome-fg-muted)]">
{t('settings.usage_empty', {
defaultValue:
"You haven't generated anything yet — make something and it'll show up here.",
})}
</p>
)}
{s && s.takes > 0 && (
<div className="flex flex-col gap-[var(--space-5)]">
<div className="grid grid-cols-[repeat(auto-fill,minmax(150px,1fr))] gap-[var(--space-3)]">
<Stat
label={t('settings.usage_takes', { defaultValue: 'Takes generated' })}
value={s.takes}
sub={
since
? t('settings.usage_since', { defaultValue: 'since {{date}}', date: since })
: null
}
/>
<Stat
label={t('settings.usage_audio', { defaultValue: 'Audio produced' })}
value={fmtDuration(s.audio_seconds)}
/>
<Stat label={t('settings.usage_voices', { defaultValue: 'Voices' })} value={s.voices} />
<Stat
label={t('settings.usage_active_days', { defaultValue: 'Days used' })}
value={s.active_days}
/>
{s.dubs > 0 && (
<Stat label={t('settings.usage_dubs', { defaultValue: 'Dubs' })} value={s.dubs} />
)}
{s.starred > 0 && (
<Stat
label={t('settings.usage_starred', { defaultValue: 'Starred takes' })}
value={s.starred}
/>
)}
</div>
<Bars title={t('settings.usage_by_mode', { defaultValue: 'By mode' })} rows={s.by_mode} />
<Bars
title={t('settings.usage_by_language', { defaultValue: 'By language' })}
rows={s.by_language}
/>
</div>
)}
</SettingsSection>
);
}
+21
View File
@@ -0,0 +1,21 @@
/**
* Byte formatting for the Storage panels.
*
* Deliberately not `models/format.fmtBytes`: that one floors at kilobytes
* (`Math.round(n / 1024)` + " KB"), so a 391-byte config file renders as
* "0 KB" which reads as "nothing here" for a folder that very much exists.
* These panels list real folders and must be able to say "391 B".
*/
/** "1.4 GB" / "820 MB" / "12 KB" / "391 B". Pure. */
export function fmtBytes(bytes) {
if (!Number.isFinite(bytes) || bytes <= 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let n = bytes;
let i = 0;
while (n >= 1024 && i < units.length - 1) {
n /= 1024;
i += 1;
}
return `${n < 10 && i > 0 ? n.toFixed(1) : Math.round(n)} ${units[i]}`;
}
@@ -35,6 +35,7 @@ import {
FileText,
Info,
Braces,
BarChart3,
} from 'lucide-react';
/** Sidebar groups, in display order. `labelKey` resolves via i18n. */
@@ -180,6 +181,22 @@ export const GROUPS = [
'platform',
],
},
{
id: 'usage',
labelKey: 'settings.usage',
defaultLabel: 'Usage',
icon: BarChart3,
keywords: [
'usage',
'stats',
'statistics',
'insights',
'analytics',
'history',
'how much',
'privacy',
],
},
{
id: 'storage',
labelKey: 'settings.storage',
@@ -189,8 +206,15 @@ export const GROUPS = [
'storage',
'data directory',
'outputs directory',
// "factory reset" is what users search for even though the feature is
// now the broader "Reset & remove" keep the old name findable.
'factory reset',
'reset',
'wipe',
'delete models',
'uninstall',
'remove all data',
'start over',
'disk usage',
'free space',
'disk space',
@@ -199,7 +223,7 @@ export const GROUPS = [
'temp files',
'clear logs',
],
keywordKeys: ['settings.storage_usage', 'settings.factory_reset'],
keywordKeys: ['settings.storage_usage', 'settings.reset', 'settings.uninstall'],
},
{
id: 'network',
+6 -1
View File
@@ -9,6 +9,7 @@ import { modelStatus as apiModelStatus } from '../api/system';
import { useModelStatus } from '../api/hooks';
import useRealtimeEvents from './useRealtimeEvents';
import { mergeDescribedAttrs } from '../utils/voiceInstruct';
import { sanitizeOmniUi } from '../utils/omniUiSchema';
/**
* Encapsulates all data-loading effects, localStorage persistence,
@@ -179,7 +180,11 @@ export default function useAppData() {
loadAll();
// Restore local UI state
try {
const saved = JSON.parse(localStorage.getItem('omni_ui') || '{}');
// Whitelist + shape-check every persisted field (audit: the #1067 class
// was healed per-field; this closes it generically — malformed values
// are dropped up front instead of throwing mid-restore and silently
// discarding every field after the bad one).
const saved = sanitizeOmniUi(JSON.parse(localStorage.getItem('omni_ui') || '{}'));
if (saved.uiScale) setUiScale(saved.uiScale);
if (saved.text) setText(saved.text);
// Legacy shim (voice-studio-unification P4): the old 'clone'/'design'
+44 -1
View File
@@ -87,6 +87,7 @@ export default function useDubWorkflow({
const condenseSuggest = useAppStore((s) => s.condenseSuggest);
const timingStrategy = useAppStore((s) => s.timingStrategy);
const fitOptions = useAppStore((s) => s.fitOptions);
const voiceMatch = useAppStore((s) => s.voiceMatch);
const glossaryTerms = useAppStore((s) => s.glossaryTerms);
const dubDialect = useAppStore((s) => s.dubDialect);
@@ -95,6 +96,12 @@ export default function useDubWorkflow({
const [previewAudios, setPreviewAudios] = useState({});
const [transcribeStart, setTranscribeStart] = useState(null);
const [transcribeElapsed, setTranscribeElapsed] = useState(0);
// Real fraction of chunks transcribed, straight from the backend's `segments`
// events. The overlay used to *invent* an ETA from the video's duration
// instead (#1127) — it assumed ~20x-realtime transcription, which is roughly
// true on a CUDA GPU and 50x wrong on a CPU, so it showed "~0s remaining" for
// 45 minutes. A measured fraction is the only thing that can't lie.
const [transcribeProgress, setTranscribeProgress] = useState(0);
const dubAbortCtrlRef = useRef(null);
const dubClientJobIdRef = useRef(null);
@@ -127,6 +134,7 @@ export default function useDubWorkflow({
useEffect(() => {
if (!transcribeStart) {
setTranscribeElapsed(0);
setTranscribeProgress(0);
return;
}
const iv = setInterval(
@@ -174,6 +182,9 @@ export default function useDubWorkflow({
text_original: s.text_original || s.text || '',
}));
setDubSegments((prev) => [...prev, ...incoming]);
if (typeof m.progress === 'number' && m.progress > 0) {
setTranscribeProgress(Math.min(1, m.progress));
}
} catch (err) {
/* ignore parse errors */
}
@@ -754,9 +765,15 @@ export default function useDubWorkflow({
});
const translatedMap = {};
const errors = [];
const degraded = [];
(data.translated || []).forEach((t) => {
translatedMap[t.id] = t;
if (t.error) errors.push({ id: t.id, error: t.error });
// Degraded ≠ failed: the segment translated fine but the cinematic
// polish pass was skipped (rate limit, budget, divergent reply) and
// the literal text is in use. Counting these as errors used to show
// "4/4 segment(s) failed" over a translate that succeeded.
else if (t.degraded) degraded.push({ id: t.id, reason: t.degraded });
});
setDubSegments((prev) =>
prev.map((s) => {
@@ -772,6 +789,7 @@ export default function useDubWorkflow({
// instead of destroying the previous language's work.
...(gotText ? { translations: { ...s.translations, [targetLang]: hit.text } } : {}),
translate_error: hit.error || undefined,
translate_degraded: hit.degraded || undefined,
translate_literal: hit.literal || undefined,
translate_critique: hit.critique || undefined,
// Carry over the predicted compression ratio so the per-row
@@ -818,7 +836,24 @@ export default function useDubWorkflow({
}),
{ duration: 6000 },
);
} else {
}
if (degraded.length) {
// Some segments missed the polish pass but translated fine — a
// warning with the honest story, not a red "failed" over a success.
// Fires ALONGSIDE the error toast when a response carries both:
// real failures shouldn't erase the story of the rows that
// succeeded plainly.
const unique = [...new Set(degraded.map((d) => d.reason))];
toast(
t('dub_workflow.translate_degraded', {
count: degraded.length,
totalCount: data.translated.length,
reason: unique[0].slice(0, 120),
}),
{ icon: '⚠️', duration: 8000 },
);
}
if (!errors.length && !degraded.length) {
const qLabel =
data.quality_used === 'cinematic' ? t('dub_workflow.translated_cinematic_suffix') : '';
toast.success(
@@ -892,6 +927,9 @@ export default function useDubWorkflow({
speed,
preview,
timing_strategy: timingStrategy || 'concise',
// Voice-identity mode for auto-clone bindings (per_line default =
// unchanged behaviour; consistent = one reference per speaker).
voice_match: voiceMatch || 'per_line',
// Smart Fit knob overrides — only when the user customised them;
// otherwise the backend's canonical defaults apply.
...(timingStrategy === 'smart_fit' && fitOptions ? { fit_options: fitOptions } : {}),
@@ -965,6 +1003,9 @@ export default function useDubWorkflow({
...segmentGenInputs(s),
})),
lang: genLang,
// Must match the mode this generate ran with — it's
// part of the fingerprint when non-default (#281).
voice_match: voiceMatch || 'per_line',
});
setLastGenFingerprints(plan.fingerprints || {}, genLang);
} catch (err) {
@@ -1016,6 +1057,7 @@ export default function useDubWorkflow({
dubStep,
timingStrategy,
fitOptions,
voiceMatch,
setDubStep,
setDubProgress,
setDubError,
@@ -1049,6 +1091,7 @@ export default function useDubWorkflow({
previewAudios,
setPreviewAudios,
transcribeElapsed,
transcribeProgress,
handleDubUpload,
handleDubIngestUrl,
handleDubAbort,
+17 -1
View File
@@ -65,6 +65,14 @@ export default function useSegmentEditing() {
if (field === 'text' && lang) {
next.translations = { ...s.translations, [lang]: value };
}
if (field === 'text') {
// The user rewrote the line — the machine-translation annotations
// ("translation error", "polish pass skipped") describe text that
// no longer exists. Leaving them makes the row wear a stale badge
// over human-authored words.
next.translate_error = undefined;
next.translate_degraded = undefined;
}
return next;
}),
);
@@ -118,6 +126,7 @@ export default function useSegmentEditing() {
text: restored,
...(lang ? { translations: { ...s.translations, [lang]: restored } } : {}),
translate_error: undefined,
translate_degraded: undefined,
};
}),
);
@@ -287,6 +296,9 @@ export default function useSegmentEditing() {
setFingerprintsByLang((prev) => ({ ...prev, [key]: map || {} }));
}, []);
const [incrementalPlan, setIncrementalPlan] = useState(null);
// Subscribed (not getState()) so the plan effect in App.jsx re-fires when
// the Voice-match toggle flips — the badge refreshes to "N stale" at once.
const voiceMatch = useAppStore((s) => s.voiceMatch);
const recomputeIncremental = useCallback(async () => {
if (!dubSegments.length || !Object.keys(lastGenFingerprints).length) {
@@ -302,12 +314,16 @@ export default function useSegmentEditing() {
segments: dubSegments.map((s) => ({ id: String(s.id), ...segmentGenInputs(s) })),
stored_hashes: lastGenFingerprints,
lang: dubLangCode,
// Voice-match mode is part of the fingerprint when non-default, so
// flipping the toggle honestly reports every segment stale — the
// audio really would render from a different reference (#281 class).
voice_match: voiceMatch || 'per_line',
});
setIncrementalPlan({ stale: res.stale, fresh: res.fresh });
} catch (e) {
console.warn('incremental plan failed', e);
}
}, [dubSegments, lastGenFingerprints, dubLangCode]);
}, [dubSegments, lastGenFingerprints, dubLangCode, voiceMatch]);
return {
// Undo/Redo
+11 -9
View File
@@ -159,14 +159,6 @@
"header_live_stats": "عرض مقاييس النظام المباشرة في الرأس",
"header_live_stats_desc": "يضيف شاشة RAM / CPU / VRAM مباشرة إلى الشريط العلوي (يتم إيقاف تشغيله افتراضيًا).",
"storage_desc": "حيث يحتفظ OmniVoice ببياناتك ومخرجاتك.",
"factory_reset": "إعادة ضبط المصنع",
"factory_reset_desc": "إعادة تعيين جميع التفضيلات داخل التطبيق إلى إعداداتها الافتراضية. ملفاتك تبقى دون تغيير.",
"factory_reset_body": "مسح الإعدادات المحفوظة محليًا (السمة، واللغة، ومقابض الدبلجة، ومفضلات المعرض، وتفضيلات واجهة المستخدم الأخرى). لا يحذف أصواتك أو مشاريعك أو الصوت الذي تم إنشاؤه على القرص.",
"factory_reset_confirm_title": "هل تريد إعادة ضبط التفضيلات؟",
"factory_reset_confirm": "إعادة تعيين وإعادة تحميل",
"factory_reset_confirm_body": "يؤدي هذا إلى مسح جميع تفضيلات واجهة المستخدم المحفوظة وإعادة تحميل التطبيق. لن تتأثر أصواتك ومشاريعك ومخرجاتك الموجودة على القرص. يكمل؟",
"factory_reset_done": "تم مسح التفضيلات — جارٍ إعادة التحميل...",
"factory_reset_failed": "فشلت إعادة التعيين",
"history_retention": "سجل التوليد",
"history_retention_desc": "عدد اللقطات المحتفظ بها قبل تنظيف الأقدم.",
"history_retention_help": "بعد كل عملية توليد، تُحذف أقدم اللقطات غير المميزة بنجمة التي تتجاوز هذا الحد مع ملفاتها الصوتية. اللقطات المميزة بنجمة تبقى دائمًا. 0 = الاحتفاظ بالكل.",
@@ -821,6 +813,12 @@
"burn_subs_title": "قم بعرض الترجمات مباشرة في دفق الفيديو MP4 (hardsubs). يستخدم تنسيق الترجمة المزدوجة عند تشغيل الترجمة المزدوجة.",
"timing_smart_fit": "ملاءمة ذكية",
"timing_smart_fit_title": "يوزّع المواءمة: يسرّع الصوت قليلاً (مع الحفاظ على طبقة الصوت، حتى 1.5×) ويبطئ ذلك المقطع من الفيديو قليلاً (حتى 2×) لتتسع الدبلجة بإيقاعها الطبيعي. ما يتجاوز الحدود يُقصّ ويُميّز بعلامة. يعيد التصدير ترميز الفيديو.",
"voice_match": "مطابقة الصوت:",
"voice_match_title": "مطابقة الصوت — هل يُستنسخ كل سطر مدبلج من مقطعه المصدر الخاص، أم تتشارك كل أسطر المتحدث مرجعًا واحدًا.",
"voice_match_per_line": "لكل سطر",
"voice_match_per_line_title": "يُستنسخ كل سطر من مقطع صوته المصدر الخاص. أفضل تطابق في الأداء لكل سطر، لكن هوية الصوت قد تنحرف من سطر إلى آخر.",
"voice_match_consistent": "متّسق",
"voice_match_consistent_title": "تُستنسخ كل أسطر المتحدث من مرجع واحد مشترك (نسخة صوت المتحدث، أو أفضل مقطع منفرد عند عدم وجودها). هوية صوت أكثر ثباتًا عبر الدبلجة كاملة.",
"default_track": "المسار الافتراضي:",
"original_track": "أصلي",
"selected_dub": "{{code}} (الدبلجة المحددة)",
@@ -921,6 +919,7 @@
"rate_title": "تناسب معدل الكلام: {{ratio}}× بالنسبة إلى الفتحة{{error}}",
"speaker_id": "معرف المتحدث",
"translate_error_title": "خطأ في الترجمة: {{error}}",
"translate_degraded_title": "تمت الترجمة (مباشرة) — تم تخطي خطوة الصقل: {{reason}}",
"budget_title": "النص هو {{pct}}% من النص الأصلي — فكر في سرعة أعلى أو صياغة أقصر",
"text_title": "Ctrl+D للتقسيم عند المؤشر · Ctrl+M للدمج مع التالي",
"orig_label": "أصل",
@@ -947,6 +946,8 @@
"fit_overflows_title": "كان النص المترجم أطول من الفتحة الأصلية بمقدار {{seconds}}s. كان الصوت مقصوصًا بشدة. قم بتقصير النص أو قم بتبديل التوقيت إلى \"تمدد الفيديو\".",
"fit_stretched": "فيديو {{ratio}}×",
"fit_stretched_title": "وضع الفيديو الممتد: تم إبطاء فيديو هذا المقطع إلى {{ratio}}× ليناسب الصوت الطبيعي المدبلج.",
"fit_slowed": "أُبطئ {{ratio}}×",
"fit_slowed_title": "السطر المدبلج أقصر من فترته — أُبطئ {{ratio}}× (مع الحفاظ على طبقة الصوت) ليغطي الكلام مدة الحديث الأصلية بدل ترك فراغ",
"fit_compressed_title": "يمثل صوت تحويل النص إلى كلام (TTS) {{pct}}% من الفتحة - وهو مضغوط بشدة.",
"fit_audio_title": "الصوت مناسب داخل الفتحة.",
"fit_ratio_title": "يمثل صوت تحويل النص إلى كلام (TTS) {{pct}}% من الفتحة.",
@@ -1736,6 +1737,7 @@
"cinematic_no_llm": "تحتاج الجودة السينمائية إلى شهادة LLM - قم بتعيين TRANSLATE_BASE_URL + TRANSLATE_API_KEY (تعمل Ollama محليًا). العودة إلى سريع.",
"dialect_not_applied": "تحتاج اللهجة المحددة إلى LLM لتطبيقها — بدّل المحرك إلى OpenAI/Ollama أو اختر جودة Cinematic.",
"translate_errors": "{{errorCount}}/{{totalCount}} فشل المقطع (المقاطع): {{firstError}}",
"translate_degraded": "تمت ترجمة جميع المقاطع ({{totalCount}}) — استخدم {{count}} منها الترجمة المباشرة لأن خطوة الصقل تم تخطيها ({{reason}})",
"translated_segments": "تمت ترجمة {{count}} مقطع (مقاطع) → {{lang}}",
"translated_cinematic_suffix": "(سينمائي)",
"translation_failed": "فشلت الترجمة: {{message}}",
@@ -2147,4 +2149,4 @@
"website": "موقع الكتروني",
"website_desc": "المزيد عن المشروع والصانع."
}
}
}
+11 -9
View File
@@ -159,14 +159,6 @@
"header_live_stats": "Live-Systemmetriken in der Kopfzeile anzeigen",
"header_live_stats_desc": "Fügt der oberen Leiste einen Live-RAM-/CPU-/VRAM-Monitor hinzu (standardmäßig deaktiviert).",
"storage_desc": "Wo OmniVoice Ihre Daten und Ausgaben speichert.",
"factory_reset": "Werksreset",
"factory_reset_desc": "Setzen Sie alle In-App-Einstellungen auf ihre Standardeinstellungen zurück. Ihre Dateien bleiben unberührt.",
"factory_reset_body": "Löscht lokal gespeicherte Einstellungen (Thema, Sprache, Dub-Regler, Galerie-Favoriten und andere UI-Einstellungen). Es löscht NICHT Ihre Stimmen, Projekte oder generierten Audiodaten auf der Festplatte.",
"factory_reset_confirm_title": "Einstellungen zurücksetzen?",
"factory_reset_confirm": "Zurücksetzen und neu laden",
"factory_reset_confirm_body": "Dadurch werden alle gespeicherten Benutzeroberflächeneinstellungen gelöscht und die App neu geladen. Ihre Stimmen, Projekte und Ausgaben auf der Festplatte sind nicht betroffen. Weitermachen?",
"factory_reset_done": "Einstellungen gelöscht wird neu geladen…",
"factory_reset_failed": "Zurücksetzen fehlgeschlagen",
"history_retention": "Generierungsverlauf",
"history_retention_desc": "Wie viele Takes behalten werden, bevor die ältesten aufgeräumt werden.",
"history_retention_help": "Nach jeder Generierung werden die ältesten nicht markierten Takes über diesem Limit samt Audiodateien entfernt. Markierte Takes bleiben immer erhalten. 0 = alles behalten.",
@@ -821,6 +813,12 @@
"burn_subs_title": "Rendern Sie Untertitel direkt in den MP4-Videostream (Hardsubs). Verwendet das Dual-Untertitelformat, wenn Dual-Untertitel aktiviert ist.",
"timing_smart_fit": "Intelligente Anpassung",
"timing_smart_fit_title": "Teilt die Anpassung auf: Das Audio wird leicht beschleunigt (tonhöhenerhaltend, bis 1,5×) und das Videosegment leicht verlangsamt (bis 2×), damit Sprache in natürlichem Tempo passt. Was darüber hinausgeht, wird gekürzt und markiert. Der Export codiert das Video neu.",
"voice_match": "Stimmabgleich:",
"voice_match_title": "Stimmabgleich — ob jede synchronisierte Zeile aus ihrem eigenen Quellclip geklont wird oder alle Zeilen eines Sprechers eine gemeinsame Referenz teilen.",
"voice_match_per_line": "Pro Zeile",
"voice_match_per_line_title": "Jede Zeile wird aus einem Clip ihres eigenen Quelltons geklont. Beste Prosodie pro Zeile, aber die Stimmidentität kann von Zeile zu Zeile driften.",
"voice_match_consistent": "Konsistent",
"voice_match_consistent_title": "Alle Zeilen eines Sprechers werden aus einer gemeinsamen Referenz geklont (Sprecher-Klon oder bester Einzelclip, wenn keiner existiert). Stabilere Stimmidentität über die gesamte Synchronisation.",
"default_track": "Standardspur:",
"original_track": "Original",
"selected_dub": "{{code}} (Ausgewählter Dub)",
@@ -921,6 +919,7 @@
"rate_title": "Sprachgeschwindigkeitsanpassung: {{ratio}}× relativ zum Slot{{error}}",
"speaker_id": "Sprecher-ID",
"translate_error_title": "Übersetzungsfehler: {{error}}",
"translate_degraded_title": "Übersetzt (einfach) — der Feinschliff wurde übersprungen: {{reason}}",
"budget_title": "Der Text besteht zu {{pct}} % aus dem Original erwägen Sie eine höhere Geschwindigkeit oder eine kürzere Formulierung",
"text_title": "Strg+D zum Teilen am Cursor · Strg+M zum Zusammenführen mit dem nächsten",
"orig_label": "orig",
@@ -947,6 +946,8 @@
"fit_overflows_title": "Der übersetzte Text war um {{seconds}}s länger als der ursprüngliche Text. Der Ton war stark gekürzt; kürzen Sie den Text oder stellen Sie das Timing auf „Video dehnen“ um.",
"fit_stretched": "Video {{ratio}}×",
"fit_stretched_title": "Modus „Video dehnen“: Das Video dieses Segments wurde auf {{ratio}}× verlangsamt, um dem natürlichen Dub-Audio zu entsprechen.",
"fit_slowed": "verlangsamt {{ratio}}×",
"fit_slowed_title": "Die synchronisierte Zeile war kürzer als ihr Zeitfenster — {{ratio}}× verlangsamt (Tonhöhe erhalten), damit die Sprache die originale Sprechzeit abdeckt statt ein Loch zu lassen",
"fit_compressed_title": "TTS-Audio macht {{pct}} % des Slots aus stark komprimiert.",
"fit_audio_title": "Audio passt in den Steckplatz.",
"fit_ratio_title": "TTS-Audio macht {{pct}} % des Slots aus.",
@@ -1736,6 +1737,7 @@
"cinematic_no_llm": "Für filmische Qualität ist ein LLM erforderlich setzen Sie TRANSLATE_BASE_URL + TRANSLATE_API_KEY (Ollama funktioniert lokal). Zurückgreifen auf Fast.",
"dialect_not_applied": "Der gewählte Dialekt benötigt ein LLM — wechsle die Engine auf OpenAI/Ollama oder wähle die Cinematic-Qualität.",
"translate_errors": "{{errorCount}}/{{totalCount}} Segment(e) fehlgeschlagen: {{firstError}}",
"translate_degraded": "Alle {{totalCount}} Segment(e) übersetzt — {{count}} verwenden die einfache Übersetzung, da der Feinschliff übersprungen wurde ({{reason}})",
"translated_segments": "{{count}} Segment(e) → {{lang}} übersetzt",
"translated_cinematic_suffix": "(Filmisch)",
"translation_failed": "Übersetzung fehlgeschlagen: {{message}}",
@@ -2147,4 +2149,4 @@
"website": "Website",
"website_desc": "Mehr über das Projekt und den Macher."
}
}
}
+86 -9
View File
@@ -553,6 +553,19 @@
"translation": "Translation",
"network": "Network",
"performance": "Performance & Device",
"usage": "Usage",
"usage_desc": "What you have made with OmniVoice — counted on your own machine.",
"usage_privacy": "These numbers are counted from your own history, on this machine, and are never sent anywhere. OmniVoice has no analytics service — nothing here leaves your computer.",
"usage_empty": "You haven't generated anything yet — make something and it'll show up here.",
"usage_takes": "Takes generated",
"usage_since": "since {{date}}",
"usage_audio": "Audio produced",
"usage_voices": "Voices",
"usage_active_days": "Days used",
"usage_dubs": "Dubs",
"usage_starred": "Starred takes",
"usage_by_mode": "By mode",
"usage_by_language": "By language",
"storage": "Storage",
"device": "Device & compute",
"device_desc": "Live hardware and backend readouts.",
@@ -577,14 +590,60 @@
"header_live_stats": "Show live system metrics in header",
"header_live_stats_desc": "Adds a live RAM / CPU / VRAM monitor to the top bar (off by default).",
"storage_desc": "Where OmniVoice keeps your data and outputs.",
"factory_reset": "Factory reset",
"factory_reset_desc": "Reset all in-app preferences to their defaults. Your files stay untouched.",
"factory_reset_body": "Clears locally-saved settings (theme, language, dub knobs, gallery favorites, and other UI preferences). It does NOT delete your voices, projects, or generated audio on disk.",
"factory_reset_confirm_title": "Reset preferences?",
"factory_reset_confirm": "Reset and reload",
"factory_reset_confirm_body": "This clears all saved UI preferences and reloads the app. Your voices, projects, and outputs on disk are not affected. Continue?",
"factory_reset_done": "Preferences cleared — reloading…",
"factory_reset_failed": "Reset failed: {{message}}",
"uninstall": "Remove all data",
"uninstall_desc": "Delete everything OmniVoice has written to this machine, then quit.",
"uninstall_body": "OmniVoice is fully local, so uninstalling is just deleting the folders it wrote. This removes your voice profiles, projects, and generated audio permanently — there is no undo. Removing the app itself is a separate step.",
"uninstall_target_data": "Voices, projects, generated audio, history",
"uninstall_target_env": "Settings + the managed Python environment",
"uninstall_target_logs": "Logs",
"uninstall_target_userenv": "Saved environment (cache location, tokens)",
"uninstall_target_models": "Downloaded model weights (shared Hugging Face cache)",
"uninstall_confirm_title": "Remove all OmniVoice data?",
"uninstall_confirm_body": "Your voice profiles, projects, and generated audio will be permanently deleted. This cannot be undone.",
"uninstall_type_to_confirm": "Type {{word}} to confirm:",
"uninstall_confirm_word": "DELETE",
"uninstall_confirm": "Delete {{size}} and quit",
"uninstall_partial": "Some folders could not be removed: {{paths}}",
"uninstall_failed": "Could not remove the data: {{message}}",
"uninstall_optional_group": "Optional",
"uninstall_models_caveat": "The standard Hugging Face cache, shared with other AI tools on this machine — removing it may delete models OmniVoice never downloaded. Anything OmniVoice needs downloads again.",
"uninstall_models_warning": "This includes the shared Hugging Face cache ({{size}}) — models other AI tools downloaded may go with it.",
"uninstall_total": "{{count}} locations · {{size}} will be freed",
"storage_target_empty": "Nothing to remove",
"reset": "Reset & remove",
"reset_desc": "Put part — or all — of OmniVoice back to how it shipped.",
"reset_body_web": "Clears locally-saved preferences (theme, language, dub settings) and reloads. Your voices, projects and generated audio are not affected.",
"reset_tier_ui": "UI preferences only",
"reset_tier_ui_hint": "Theme, layout and dub knobs go back to defaults. Nothing on disk changes.",
"reset_tier_settings": "All settings",
"reset_tier_settings_hint": "Every preference, in the app and on disk. Your voices, projects and models are untouched.",
"reset_tier_assets": "Downloaded assets & models",
"reset_tier_assets_hint": "Model weights, sidecar engines, audio tools and caches. Everything you made stays. They re-download when next needed.",
"reset_tier_everything": "Everything OmniVoice did",
"reset_tier_everything_hint": "Back to a fresh install: settings, voices, projects, audio, models, engines, logs. The app restarts on the first-run screen.",
"reset_advanced": "Choose exactly what to remove",
"reset_scope_ui_prefs": "UI preferences — theme, language, layout, dub settings",
"reset_scope_settings": "App settings — engine choices, voice defaults, saved options",
"reset_scope_history": "Generation & dub history, with their audio",
"reset_scope_content": "Voices, projects, generated audio, and the app database",
"reset_scope_engines": "Installed sidecar engines (IndexTTS-2 and friends)",
"reset_scope_tools": "Downloaded audio tools (ffmpeg, ffprobe, yt-dlp)",
"reset_scope_models": "Downloaded model weights",
"reset_scope_caches": "Caches and temporary files",
"reset_scope_logs": "Logs and crash reports",
"reset_models_shared": "Shared Hugging Face cache — may hold models other AI tools downloaded.",
"reset_models_shared_warning": "The model cache is the standard Hugging Face cache, shared with other AI tools on this machine — removing it may delete models OmniVoice never downloaded. Everything OmniVoice needs will download again on next use.",
"reset_confirm_title": "Reset OmniVoice?",
"reset_confirm": "Reset and reload",
"reset_confirm_restart": "Remove {{size}} and restart",
"reset_confirm_word": "DELETE",
"reset_type_to_confirm": "Type {{word}} to confirm:",
"reset_irreversible": "Your voice profiles, projects and generated audio will be permanently deleted. This cannot be undone.",
"reset_restart_note": "OmniVoice will restart its engine to finish. This takes a few seconds.",
"reset_done": "Reset complete — reloading…",
"reset_done_restart": "Reset complete — restarting OmniVoice…",
"reset_partial": "Some items could not be removed: {{paths}}",
"reset_failed": "Reset failed: {{message}}",
"storage_usage": "Disk usage",
"storage_usage_desc": "What OmniVoice stores on this machine, and how much space is left.",
"storage_refresh": "Refresh",
@@ -697,7 +756,14 @@
"translator_offline": "Offline translator",
"translator_unknown": "Unknown",
"change_translator": "Change translator",
"no_tracking": "None — no tracking"
"no_tracking": "None — no tracking",
"analytics_title": "Help improve OmniVoice",
"analytics_subtitle": "Off by default. Anonymous usage stats — never your content.",
"analytics_sends": "Sent: which engine and language you used, how long a generation took, how many characters (a number, not the text), and the type of any error.",
"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."
},
"credentials": {
"title": "Credentials",
@@ -1010,6 +1076,12 @@
"burn_subs_title": "Render subtitles directly into the MP4 video stream (hardsubs). Uses the dual-subtitle format when Dual subtitles is on.",
"timing_smart_fit": "Smart Fit",
"timing_smart_fit_title": "Splits the difference: slightly speeds up the audio (pitch preserved, up to 1.5×) and slightly slows down that segment of the video (up to 2×) so natural-rate speech fits. Anything beyond the caps is trimmed and flagged. Export re-encodes the video.",
"voice_match": "Voice match:",
"voice_match_title": "Voice match — whether each dubbed line clones from its own source clip or every line of a speaker shares one reference.",
"voice_match_per_line": "Per line",
"voice_match_per_line_title": "Each line clones from a clip of its own source audio. Best per-line prosody match, but the voice identity can drift from line to line.",
"voice_match_consistent": "Consistent",
"voice_match_consistent_title": "Every line of a speaker clones from one shared reference (the speaker clone, or the best single clip when none exists). Steadier voice identity across the whole dub.",
"timing_concise": "Concise",
"timing_stretch_video": "Stretch Video",
"timing_strict_slot": "Strict slot",
@@ -1106,6 +1178,7 @@
"sync_label": "Sync: {{pct}}%",
"rate_title": "Speech-rate fit: {{ratio}}× relative to slot{{error}}",
"speaker_id": "Speaker ID",
"translate_degraded_title": "Translated (plain) — the polish pass was skipped: {{reason}}",
"translate_error_title": "Translation error: {{error}}",
"budget_title": "Text is {{pct}}% of original — consider higher speed or shorter phrasing",
"text_title": "Ctrl+D to split at cursor · Ctrl+M to merge with next",
@@ -1135,6 +1208,8 @@
"fit_overflows_title": "Translated text was longer than the original slot by {{seconds}}s. The audio was hard-trimmed; shorten the text or switch Timing to \"Stretch Video\".",
"fit_stretched": "Video {{ratio}}×",
"fit_stretched_title": "Stretch Video mode: this segment's video was slowed to {{ratio}}× to fit the natural dub audio.",
"fit_slowed": "slowed {{ratio}}×",
"fit_slowed_title": "The dubbed line ran shorter than its slot — slowed {{ratio}}× (pitch preserved) so speech covers the original speaking time instead of leaving a hole",
"fit_compressed_title": "TTS audio is {{pct}}% of the slot — heavily compressed.",
"fit_audio_title": "Audio fit inside the slot.",
"fit_ratio_title": "TTS audio is {{pct}}% of the slot.",
@@ -1612,6 +1687,7 @@
"failed": "Setup failed",
"what_to_try": "What to try:",
"retry": "Retry",
"unrecoverable": "Retrying cannot fix this — see the guidance above.",
"clean_retry": "Clean & Retry",
"clean_retry_confirm": "This will delete the cached Python environment and re-download all dependencies (~5-10 min). Continue?",
"hide_logs": "Hide logs",
@@ -2289,6 +2365,7 @@
"cinematic_no_llm": "Cinematic quality needs an LLM. Configure one in Settings → Credentials → LLM endpoint (Ollama runs locally, no key needed). Using Fast quality for now.",
"dialect_not_applied": "The selected dialect needs an LLM to apply. Switch the translation engine to OpenAI/Ollama, or configure an LLM in Settings → Credentials → LLM endpoint.",
"translate_errors": "{{errorCount}}/{{totalCount}} segment(s) failed: {{firstError}}",
"translate_degraded": "All {{totalCount}} segment(s) translated — {{count}} used the plain translation because the polish pass was skipped ({{reason}})",
"translated_segments": "Translated {{count}} segment(s) → {{lang}}",
"translated_cinematic_suffix": " (Cinematic)",
"translation_failed": "Translation failed: {{message}}",
+11 -9
View File
@@ -159,14 +159,6 @@
"header_live_stats": "Mostrar métricas del sistema en vivo en el encabezado",
"header_live_stats_desc": "Agrega un monitor de RAM/CPU/VRAM en vivo a la barra superior (desactivado de forma predeterminada).",
"storage_desc": "Donde OmniVoice guarda sus datos y resultados.",
"factory_reset": "Restablecimiento de fábrica",
"factory_reset_desc": "Restablezca todas las preferencias de la aplicación a sus valores predeterminados. Tus archivos permanecen intactos.",
"factory_reset_body": "Borra las configuraciones guardadas localmente (tema, idioma, botones de doblaje, favoritos de la galería y otras preferencias de la interfaz de usuario). NO elimina sus voces, proyectos o audio generado en el disco.",
"factory_reset_confirm_title": "¿Restablecer preferencias?",
"factory_reset_confirm": "Reiniciar y recargar",
"factory_reset_confirm_body": "Esto borra todas las preferencias de UI guardadas y recarga la aplicación. Sus voces, proyectos y salidas en disco no se ven afectados. ¿Continuar?",
"factory_reset_done": "Preferencias borradas: recargando...",
"factory_reset_failed": "Error al restablecer",
"history_retention": "Historial de generación",
"history_retention_desc": "Cuántas tomas conservar antes de limpiar las más antiguas.",
"history_retention_help": "Tras cada generación, las tomas más antiguas sin estrella que superen este límite se eliminan junto con sus archivos de audio. Las tomas destacadas siempre se conservan. 0 = conservar todo.",
@@ -821,6 +813,12 @@
"burn_subs_title": "Renderice subtítulos directamente en la transmisión de video MP4 (hardsubs). Utiliza el formato de subtítulos duales cuando los subtítulos duales están activados.",
"timing_smart_fit": "Ajuste inteligente",
"timing_smart_fit_title": "Reparte el ajuste: acelera ligeramente el audio (conservando el tono, hasta 1,5×) y ralentiza ligeramente ese segmento del vídeo (hasta 2×) para que la voz a ritmo natural quepa. Lo que supere los límites se recorta y se señala. La exportación recodifica el vídeo.",
"voice_match": "Coincidencia de voz:",
"voice_match_title": "Coincidencia de voz: si cada línea doblada se clona desde su propio clip de origen o todas las líneas de un hablante comparten una sola referencia.",
"voice_match_per_line": "Por línea",
"voice_match_per_line_title": "Cada línea se clona desde un clip de su propio audio original. La mejor prosodia por línea, pero la identidad de la voz puede variar de línea a línea.",
"voice_match_consistent": "Consistente",
"voice_match_consistent_title": "Todas las líneas de un hablante se clonan desde una referencia compartida (el clon del hablante o el mejor clip individual si no existe). Identidad de voz más estable en todo el doblaje.",
"default_track": "Pista predeterminada:",
"original_track": "Originales",
"selected_dub": "{{code}} (Doblaje seleccionado)",
@@ -921,6 +919,7 @@
"rate_title": "Ajuste de la velocidad del habla: {{ratio}}× en relación con la ranura{{error}}",
"speaker_id": "Identificación del orador",
"translate_error_title": "Error de traducción: {{error}}",
"translate_degraded_title": "Traducido (simple) — se omitió el pulido: {{reason}}",
"budget_title": "El texto es {{pct}}% del original; considere una mayor velocidad o una redacción más corta",
"text_title": "Ctrl+D para dividir en el cursor · Ctrl+M para fusionar con el siguiente",
"orig_label": "origen",
@@ -947,6 +946,8 @@
"fit_overflows_title": "El texto traducido era más largo que el espacio original en {{seconds}}s. El audio estaba muy recortado; acorte el texto o cambie el tiempo a \"Estirar vídeo\".",
"fit_stretched": "Vídeo {{ratio}}×",
"fit_stretched_title": "Modo Stretch Video: el video de este segmento se ralentizó a {{ratio}}× para adaptarse al audio doblado natural.",
"fit_slowed": "ralentizado {{ratio}}×",
"fit_slowed_title": "La línea doblada duró menos que su intervalo — se ralentizó {{ratio}}× (tono preservado) para que el habla cubra el tiempo original en vez de dejar un hueco",
"fit_compressed_title": "El audio TTS es el {{pct}}% de la ranura: muy comprimido.",
"fit_audio_title": "El audio encaja dentro de la ranura.",
"fit_ratio_title": "El audio TTS es el {{pct}}% de la ranura.",
@@ -1736,6 +1737,7 @@
"cinematic_no_llm": "La calidad cinematográfica necesita un LLM: configure TRANSLATE_BASE_URL + TRANSLATE_API_KEY (Ollama funciona localmente). Volviendo a Fast.",
"dialect_not_applied": "El dialecto seleccionado necesita un LLM para aplicarse — cambia el motor a OpenAI/Ollama o elige la calidad Cinematic.",
"translate_errors": "{{errorCount}}/{{totalCount}} segmento(s) fallidos: {{firstError}}",
"translate_degraded": "Los {{totalCount}} segmento(s) se tradujeron — {{count}} usan la traducción simple porque se omitió el pulido ({{reason}})",
"translated_segments": "{{count}} segmento(s) traducido(s) → {{lang}}",
"translated_cinematic_suffix": "(Cinemático)",
"translation_failed": "Error de traducción: {{message}}",
@@ -2147,4 +2149,4 @@
"website": "Sitio web",
"website_desc": "Más sobre el proyecto y el creador."
}
}
}
+11 -9
View File
@@ -159,14 +159,6 @@
"header_live_stats": "Afficher les métriques du système en direct dans l'en-tête",
"header_live_stats_desc": "Ajoute un moniteur RAM / CPU / VRAM en direct à la barre supérieure (désactivé par défaut).",
"storage_desc": "Où OmniVoice conserve vos données et sorties.",
"factory_reset": "Réinitialisation d'usine",
"factory_reset_desc": "Réinitialisez toutes les préférences de l'application à leurs valeurs par défaut. Vos fichiers restent intacts.",
"factory_reset_body": "Efface les paramètres enregistrés localement (thème, langue, boutons de doublage, favoris de la galerie et autres préférences de l'interface utilisateur). Il ne supprime PAS vos voix, projets ou audio générés sur le disque.",
"factory_reset_confirm_title": "Réinitialiser les préférences ?",
"factory_reset_confirm": "Réinitialiser et recharger",
"factory_reset_confirm_body": "Cela efface toutes les préférences d'interface utilisateur enregistrées et recharge l'application. Vos voix, projets et sorties sur disque ne sont pas affectés. Continuer?",
"factory_reset_done": "Préférences effacées — rechargement…",
"factory_reset_failed": "Échec de la réinitialisation",
"history_retention": "Historique de génération",
"history_retention_desc": "Nombre de prises à conserver avant de nettoyer les plus anciennes.",
"history_retention_help": "Après chaque génération, les prises les plus anciennes sans étoile au-delà de cette limite sont supprimées avec leurs fichiers audio. Les prises étoilées sont toujours conservées. 0 = tout conserver.",
@@ -821,6 +813,12 @@
"burn_subs_title": "Rendu des sous-titres directement dans le flux vidéo MP4 (hardsubs). Utilise le format double sous-titre lorsque les sous-titres doubles sont activés.",
"timing_smart_fit": "Ajustement intelligent",
"timing_smart_fit_title": "Répartit l'effort : accélère légèrement l'audio (hauteur préservée, jusqu'à 1,5×) et ralentit légèrement ce segment de la vidéo (jusqu'à 2×) pour que la voix à débit naturel tienne. Tout dépassement est coupé et signalé. L'export réencode la vidéo.",
"voice_match": "Correspondance de voix :",
"voice_match_title": "Correspondance de voix — chaque ligne doublée est clonée depuis son propre extrait source, ou toutes les lignes d'un locuteur partagent une référence unique.",
"voice_match_per_line": "Par ligne",
"voice_match_per_line_title": "Chaque ligne est clonée depuis un extrait de son propre audio source. Meilleure prosodie par ligne, mais l'identité de la voix peut dériver d'une ligne à l'autre.",
"voice_match_consistent": "Cohérente",
"voice_match_consistent_title": "Toutes les lignes d'un locuteur sont clonées depuis une référence partagée (le clone du locuteur, ou le meilleur extrait unique à défaut). Identité vocale plus stable sur tout le doublage.",
"default_track": "Piste par défaut :",
"original_track": "Originale",
"selected_dub": "{{code}} (doublage sélectionné)",
@@ -921,6 +919,7 @@
"rate_title": "Ajustement du débit vocal : {{ratio}}× par rapport à l'emplacement{{error}}",
"speaker_id": "ID du haut-parleur",
"translate_error_title": "Erreur de traduction : {{error}}",
"translate_degraded_title": "Traduit (brut) — la passe de polissage a été ignorée : {{reason}}",
"budget_title": "Le texte représente {{pct}} % de l'original  envisagez une vitesse plus élevée ou une formulation plus courte",
"text_title": "Ctrl+D pour diviser au niveau du curseur · Ctrl+M pour fusionner avec le suivant",
"orig_label": "orig",
@@ -947,6 +946,8 @@
"fit_overflows_title": "Le texte traduit était plus long que l'emplacement d'origine de {{seconds}}s. Laudio était dur ; raccourcissez le texte ou réglez le timing sur « Étirer la vidéo ».",
"fit_stretched": "Vidéo {{ratio}}×",
"fit_stretched_title": "Mode vidéo extensible : la vidéo de ce segment a été ralentie à {{ratio}}× pour s'adapter à l'audio naturel du doublage.",
"fit_slowed": "ralenti {{ratio}}×",
"fit_slowed_title": "La réplique doublée était plus courte que son créneau — ralentie {{ratio}}× (hauteur préservée) pour couvrir le temps de parole d'origine au lieu de laisser un trou",
"fit_compressed_title": "L'audio TTS représente {{pct}}% de l'emplacement — fortement compressé.",
"fit_audio_title": "L'audio s'adapte à l'intérieur de la fente.",
"fit_ratio_title": "L'audio TTS représente {{pct}}% de l'emplacement.",
@@ -1736,6 +1737,7 @@
"cinematic_no_llm": "La qualité cinématographique nécessite un LLM — définissez TRANSLATE_BASE_URL + TRANSLATE_API_KEY (Ollama fonctionne localement). Revenir à Fast.",
"dialect_not_applied": "Le dialecte sélectionné nécessite un LLM — passez le moteur sur OpenAI/Ollama ou choisissez la qualité Cinematic.",
"translate_errors": "Échec du ou des segments {{errorCount}}/{{totalCount}} : {{firstError}}",
"translate_degraded": "Les {{totalCount}} segment(s) ont été traduits — {{count}} utilisent la traduction brute car la passe de polissage a été ignorée ({{reason}})",
"translated_segments": "Segment(s) {{count}} traduit(s) → {{lang}}",
"translated_cinematic_suffix": "(Cinématique)",
"translation_failed": "Échec de la traduction : {{message}}",
@@ -2147,4 +2149,4 @@
"website": "Site Web",
"website_desc": "En savoir plus sur le projet et le créateur."
}
}
}
+11 -9
View File
@@ -159,14 +159,6 @@
"header_live_stats": "हेडर में लाइव सिस्टम मेट्रिक्स दिखाएं",
"header_live_stats_desc": "शीर्ष बार में एक लाइव रैम/सीपीयू/वीआरएएम मॉनिटर जोड़ता है (डिफ़ॉल्ट रूप से बंद)।",
"storage_desc": "जहां ओमनीवॉइस आपका डेटा और आउटपुट रखता है।",
"factory_reset": "फ़ैक्टरी रीसेट",
"factory_reset_desc": "सभी इन-ऐप प्राथमिकताओं को उनके डिफ़ॉल्ट पर रीसेट करें। आपकी फ़ाइलें अछूती रहती हैं.",
"factory_reset_body": "स्थानीय रूप से सहेजी गई सेटिंग्स (थीम, भाषा, डब नॉब्स, गैलरी पसंदीदा और अन्य यूआई प्राथमिकताएं) साफ़ करता है। यह आपकी आवाज़ों, प्रोजेक्टों या डिस्क पर उत्पन्न ऑडियो को नहीं हटाता है।",
"factory_reset_confirm_title": "प्राथमिकताएँ रीसेट करें?",
"factory_reset_confirm": "रीसेट करें और पुनः लोड करें",
"factory_reset_confirm_body": "इससे सभी सहेजी गई यूआई प्राथमिकताएँ साफ़ हो जाती हैं और ऐप पुनः लोड हो जाता है। डिस्क पर आपकी आवाज़ें, प्रोजेक्ट और आउटपुट प्रभावित नहीं होंगे। जारी रखना?",
"factory_reset_done": "प्राथमिकताएँ साफ़ की गईं - पुनः लोड हो रहा है...",
"factory_reset_failed": "रीसेट विफल रहा",
"history_retention": "जनरेशन इतिहास",
"history_retention_desc": "सबसे पुराने टेक साफ़ करने से पहले कितने टेक रखें।",
"history_retention_help": "हर जनरेशन के बाद, इस सीमा से अधिक सबसे पुराने बिना-स्टार टेक उनकी ऑडियो फ़ाइलों सहित हटा दिए जाते हैं। स्टार किए गए टेक हमेशा रखे जाते हैं। 0 = सब रखें।",
@@ -821,6 +813,12 @@
"burn_subs_title": "उपशीर्षक को सीधे MP4 वीडियो स्ट्रीम (हार्डसब) में प्रस्तुत करें। दोहरे उपशीर्षक चालू होने पर दोहरे उपशीर्षक प्रारूप का उपयोग करता है।",
"timing_smart_fit": "स्मार्ट फ़िट",
"timing_smart_fit_title": "दोनों में संतुलन: ऑडियो को थोड़ा तेज़ करता है (पिच बरकरार, अधिकतम 1.5×) और वीडियो के उस हिस्से को थोड़ा धीमा करता है (अधिकतम 2×), ताकि स्वाभाविक गति की आवाज़ फिट हो जाए। सीमा से अधिक हिस्सा काटकर चिह्नित किया जाता है। निर्यात में वीडियो फिर से एन्कोड होता है।",
"voice_match": "आवाज़ मिलान:",
"voice_match_title": "आवाज़ मिलान — हर डब लाइन अपनी ही स्रोत क्लिप से क्लोन हो या एक वक्ता की सभी लाइनें एक साझा संदर्भ इस्तेमाल करें।",
"voice_match_per_line": "प्रति पंक्ति",
"voice_match_per_line_title": "हर लाइन अपने ही स्रोत ऑडियो की क्लिप से क्लोन होती है। हर लाइन का लहजा सबसे अच्छा मिलता है, पर आवाज़ की पहचान लाइन-दर-लाइन बदल सकती है।",
"voice_match_consistent": "एकरूप",
"voice_match_consistent_title": "एक वक्ता की सभी लाइनें एक साझा संदर्भ से क्लोन होती हैं (वक्ता क्लोन, या न होने पर सबसे अच्छी एक क्लिप)। पूरे डब में आवाज़ की पहचान अधिक स्थिर रहती है।",
"default_track": "डिफ़ॉल्ट ट्रैक:",
"original_track": "मौलिक",
"selected_dub": "{{code}} (चयनित डब)",
@@ -921,6 +919,7 @@
"rate_title": "वाक्-दर फिट: {{ratio}}× स्लॉट के सापेक्ष{{error}}",
"speaker_id": "स्पीकर आईडी",
"translate_error_title": "अनुवाद त्रुटि: {{error}}",
"translate_degraded_title": "अनुवादित (सादा) — परिष्करण चरण छोड़ा गया: {{reason}}",
"budget_title": "पाठ मूल का {{pct}}% है - उच्च गति या छोटे वाक्यांश पर विचार करें",
"text_title": "कर्सर पर विभाजित करने के लिए Ctrl+D · अगले के साथ विलय करने के लिए Ctrl+M",
"orig_label": "मूल",
@@ -947,6 +946,8 @@
"fit_overflows_title": "अनुवादित पाठ मूल स्लॉट से {{seconds}}s अधिक लंबा था। ऑडियो को हार्ड-ट्रिम किया गया था; टेक्स्ट को छोटा करें या टाइमिंग को \"स्ट्रेच वीडियो\" पर स्विच करें।",
"fit_stretched": "वीडियो {{ratio}}×",
"fit_stretched_title": "स्ट्रेच वीडियो मोड: प्राकृतिक डब ऑडियो में फिट होने के लिए इस सेगमेंट के वीडियो को {{ratio}}× तक धीमा कर दिया गया था।",
"fit_slowed": "धीमा {{ratio}}×",
"fit_slowed_title": "डब की गई पंक्ति अपने स्लॉट से छोटी थी — {{ratio}}× धीमा किया गया (पिच सुरक्षित) ताकि वाणी मूल बोलने के समय को ढक ले और खाली जगह न छूटे",
"fit_compressed_title": "टीटीएस ऑडियो स्लॉट का {{pct}}% है - अत्यधिक संपीड़ित।",
"fit_audio_title": "ऑडियो स्लॉट के अंदर फ़िट हो जाता है।",
"fit_ratio_title": "टीटीएस ऑडियो स्लॉट का {{pct}}% है।",
@@ -1736,6 +1737,7 @@
"cinematic_no_llm": "सिनेमाई गुणवत्ता के लिए एलएलएम की आवश्यकता होती है - सेट TRANSLATE_BASE_URL + TRANSLATE_API_KEY (ओलामा स्थानीय स्तर पर काम करता है)। तेजी से वापस गिरना।",
"dialect_not_applied": "चुनी गई बोली लागू करने के लिए LLM चाहिए — इंजन को OpenAI/Ollama पर बदलें या Cinematic गुणवत्ता चुनें।",
"translate_errors": "{{errorCount}}/{{totalCount}} खंड विफल: {{firstError}}",
"translate_degraded": "सभी {{totalCount}} खंड अनुवादित — {{count}} में सादा अनुवाद उपयोग हुआ क्योंकि परिष्करण चरण छोड़ दिया गया ({{reason}})",
"translated_segments": "अनूदित {{count}} खंड → {{lang}}",
"translated_cinematic_suffix": "(सिनेमाई)",
"translation_failed": "अनुवाद विफल: {{message}}",
@@ -2147,4 +2149,4 @@
"website": "वेबसाइट",
"website_desc": "परियोजना और निर्माता के बारे में अधिक जानकारी."
}
}
}
+11 -9
View File
@@ -159,14 +159,6 @@
"header_live_stats": "Tampilkan metrik sistem langsung di header",
"header_live_stats_desc": "Menambahkan monitor RAM/CPU/VRAM langsung ke bilah atas (dinonaktifkan secara default).",
"storage_desc": "Tempat OmniVoice menyimpan data dan keluaran Anda.",
"factory_reset": "Reset pabrik",
"factory_reset_desc": "Reset semua preferensi dalam aplikasi ke defaultnya. File Anda tetap tidak tersentuh.",
"factory_reset_body": "Menghapus pengaturan yang disimpan secara lokal (tema, bahasa, kenop sulih suara, favorit galeri, dan preferensi UI lainnya). Itu TIDAK menghapus suara, proyek, atau audio yang dihasilkan pada disk.",
"factory_reset_confirm_title": "Setel ulang preferensi?",
"factory_reset_confirm": "Setel ulang dan muat ulang",
"factory_reset_confirm_body": "Ini menghapus semua preferensi UI yang tersimpan dan memuat ulang aplikasi. Suara, proyek, dan keluaran Anda pada disk tidak terpengaruh. Melanjutkan?",
"factory_reset_done": "Preferensi dihapus — memuat ulang…",
"factory_reset_failed": "Penyetelan ulang gagal",
"history_retention": "Riwayat pembuatan",
"history_retention_desc": "Berapa banyak take yang disimpan sebelum yang tertua dibersihkan.",
"history_retention_help": "Setelah setiap pembuatan, take tertua tanpa bintang yang melebihi batas ini dihapus beserta berkas audionya. Take berbintang selalu disimpan. 0 = simpan semua.",
@@ -821,6 +813,12 @@
"burn_subs_title": "Render subtitle langsung ke aliran video MP4 (hardsubs). Menggunakan format subtitle ganda saat Subtitle ganda aktif.",
"timing_smart_fit": "Penyesuaian Cerdas",
"timing_smart_fit_title": "Membagi beban: sedikit mempercepat audio (nada dipertahankan, hingga 1,5×) dan sedikit memperlambat segmen video itu (hingga 2×) agar suara dengan tempo alami muat. Kelebihan di luar batas dipangkas dan ditandai. Ekspor mengenkode ulang video.",
"voice_match": "Pencocokan suara:",
"voice_match_title": "Pencocokan suara — apakah setiap baris sulih suara dikloning dari klip sumbernya sendiri atau semua baris satu pembicara memakai satu referensi bersama.",
"voice_match_per_line": "Per baris",
"voice_match_per_line_title": "Setiap baris dikloning dari klip audio sumbernya sendiri. Prosodi per baris paling cocok, tetapi identitas suara bisa bergeser antarbaris.",
"voice_match_consistent": "Konsisten",
"voice_match_consistent_title": "Semua baris satu pembicara dikloning dari satu referensi bersama (klon pembicara, atau satu klip terbaik jika tidak ada). Identitas suara lebih stabil di seluruh sulih suara.",
"default_track": "Lagu Bawaan:",
"original_track": "Asli",
"selected_dub": "{{code}} (Suara yang Dipilih)",
@@ -921,6 +919,7 @@
"rate_title": "Kesesuaian kecepatan bicara: {{ratio}}× relatif terhadap slot{{error}}",
"speaker_id": "ID Pembicara",
"translate_error_title": "Kesalahan terjemahan: {{error}}",
"translate_degraded_title": "Diterjemahkan (biasa) — tahap penyempurnaan dilewati: {{reason}}",
"budget_title": "Teks {{pct}}% dari aslinya — pertimbangkan kecepatan yang lebih tinggi atau frasa yang lebih pendek",
"text_title": "Ctrl+D untuk memisahkan kursor · Ctrl+M untuk menggabungkan dengan yang berikutnya",
"orig_label": "asal",
@@ -947,6 +946,8 @@
"fit_overflows_title": "Teks yang diterjemahkan lebih panjang dari slot aslinya sebanyak {{seconds}}s. Audionya terpotong dengan keras; persingkat teks atau alihkan Timing ke \"Regangkan Video\".",
"fit_stretched": "Video {{ratio}}×",
"fit_stretched_title": "Mode Peregangan Video: video segmen ini diperlambat ke {{ratio}}× agar sesuai dengan audio sulih suara alami.",
"fit_slowed": "diperlambat {{ratio}}×",
"fit_slowed_title": "Baris sulih suara lebih pendek dari slotnya — diperlambat {{ratio}}× (nada dipertahankan) agar ucapan menutupi waktu bicara asli, bukan meninggalkan celah",
"fit_compressed_title": "Audio TTS menempati {{pct}}% dari slot — sangat terkompresi.",
"fit_audio_title": "Audio pas di dalam slot.",
"fit_ratio_title": "Audio TTS adalah {{pct}}% dari slot.",
@@ -1736,6 +1737,7 @@
"cinematic_no_llm": "Kualitas sinematik memerlukan LLM — setel TRANSLATE_BASE_URL + TRANSLATE_API_KEY (Ollama berfungsi secara lokal). Kembali ke Fast.",
"dialect_not_applied": "Dialek terpilih memerlukan LLM — ganti Engine ke OpenAI/Ollama atau pilih kualitas Cinematic.",
"translate_errors": "{{errorCount}}/{{totalCount}} segmen gagal: {{firstError}}",
"translate_degraded": "Semua {{totalCount}} segmen diterjemahkan — {{count}} memakai terjemahan biasa karena tahap penyempurnaan dilewati ({{reason}})",
"translated_segments": "Diterjemahkan {{count}} segmen → {{lang}}",
"translated_cinematic_suffix": "(Sinematik)",
"translation_failed": "Terjemahan gagal: {{message}}",
@@ -2147,4 +2149,4 @@
"website": "Situs web",
"website_desc": "Lebih lanjut tentang proyek dan pembuatnya."
}
}
}
+11 -9
View File
@@ -159,14 +159,6 @@
"header_live_stats": "Mostra le metriche del sistema in tempo reale nell'intestazione",
"header_live_stats_desc": "Aggiunge un monitor RAM/CPU/VRAM live alla barra superiore (disattivato per impostazione predefinita).",
"storage_desc": "Dove OmniVoice conserva i tuoi dati e i tuoi output.",
"factory_reset": "Ripristino delle impostazioni di fabbrica",
"factory_reset_desc": "Ripristina tutte le preferenze in-app ai valori predefiniti. I tuoi file rimangono intatti.",
"factory_reset_body": "Cancella le impostazioni salvate localmente (tema, lingua, manopole dub, preferiti della galleria e altre preferenze dell'interfaccia utente). NON elimina le tue voci, i tuoi progetti o l'audio generato sul disco.",
"factory_reset_confirm_title": "Reimpostare le preferenze?",
"factory_reset_confirm": "Reimposta e ricarica",
"factory_reset_confirm_body": "Ciò cancella tutte le preferenze dell'interfaccia utente salvate e ricarica l'app. Le tue voci, progetti e output su disco non sono interessati. Continuare?",
"factory_reset_done": "Preferenze cancellate: ricaricamento in corso...",
"factory_reset_failed": "Reimpostazione non riuscita",
"history_retention": "Cronologia generazioni",
"history_retention_desc": "Quanti take conservare prima di ripulire i più vecchi.",
"history_retention_help": "Dopo ogni generazione, i take più vecchi senza stella oltre questo limite vengono rimossi insieme ai file audio. I take con stella vengono sempre conservati. 0 = conserva tutto.",
@@ -821,6 +813,12 @@
"burn_subs_title": "Rendi i sottotitoli direttamente nel flusso video MP4 (hardsubs). Utilizza il formato doppio sottotitolo quando è attivo il doppio sottotitolo.",
"timing_smart_fit": "Adattamento intelligente",
"timing_smart_fit_title": "Divide il compito: accelera leggermente l'audio (tono preservato, fino a 1,5×) e rallenta leggermente quel segmento del video (fino a 2×) così il parlato a ritmo naturale ci sta. L'eccedenza viene tagliata e segnalata. L'esportazione ricodifica il video.",
"voice_match": "Corrispondenza voce:",
"voice_match_title": "Corrispondenza voce — se ogni battuta doppiata viene clonata dal proprio clip sorgente o tutte le battute di un parlante condividono un unico riferimento.",
"voice_match_per_line": "Per battuta",
"voice_match_per_line_title": "Ogni battuta viene clonata da un clip del proprio audio sorgente. Migliore prosodia per battuta, ma l'identità della voce può variare da battuta a battuta.",
"voice_match_consistent": "Coerente",
"voice_match_consistent_title": "Tutte le battute di un parlante vengono clonate da un riferimento condiviso (il clone del parlante o il miglior clip singolo se assente). Identità vocale più stabile in tutto il doppiaggio.",
"default_track": "Traccia predefinita:",
"original_track": "Originale",
"selected_dub": "{{code}} (duplicazione selezionata)",
@@ -921,6 +919,7 @@
"rate_title": "Adattamento della velocità della parola: {{ratio}}× relativo allo slot{{error}}",
"speaker_id": "ID dell'oratore",
"translate_error_title": "Errore di traduzione: {{error}}",
"translate_degraded_title": "Tradotto (semplice) — rifinitura saltata: {{reason}}",
"budget_title": "Il testo è il {{pct}}% dell'originale: considera una velocità maggiore o una frase più breve",
"text_title": "Ctrl+D per dividere in corrispondenza del cursore · Ctrl+M per unire con il successivo",
"orig_label": "orig",
@@ -947,6 +946,8 @@
"fit_overflows_title": "Il testo tradotto era più lungo dello spazio originale di {{seconds}}s. L'audio è stato ridotto al minimo; accorcia il testo o imposta il timing su \"Stretch Video\".",
"fit_stretched": "Video {{ratio}}×",
"fit_stretched_title": "Modalità Stretch Video: il video di questo segmento è stato rallentato a {{ratio}}× per adattarsi all'audio naturale del doppiaggio.",
"fit_slowed": "rallentato {{ratio}}×",
"fit_slowed_title": "La battuta doppiata era più corta del suo intervallo — rallentata {{ratio}}× (tonalità preservata) così il parlato copre il tempo originale invece di lasciare un vuoto",
"fit_compressed_title": "L'audio TTS è il {{pct}}% dello slot: fortemente compresso.",
"fit_audio_title": "L'audio si inserisce all'interno dello slot.",
"fit_ratio_title": "L'audio TTS è il {{pct}}% dello slot.",
@@ -1736,6 +1737,7 @@
"cinematic_no_llm": "La qualità cinematografica richiede un LLM: imposta TRANSLATE_BASE_URL + TRANSLATE_API_KEY (Ollama funziona localmente). Ritornando a Fast.",
"dialect_not_applied": "Il dialetto selezionato richiede un LLM — imposta il motore su OpenAI/Ollama o scegli la qualità Cinematic.",
"translate_errors": "Segmento/i {{errorCount}}/{{totalCount}} non riuscito: {{firstError}}",
"translate_degraded": "Tutti i {{totalCount}} segmenti tradotti — {{count}} usano la traduzione semplice perché la rifinitura è stata saltata ({{reason}})",
"translated_segments": "Segmento/i {{count}} tradotto/i → {{lang}}",
"translated_cinematic_suffix": "(Cinematologico)",
"translation_failed": "Traduzione non riuscita: {{message}}",
@@ -2147,4 +2149,4 @@
"website": "Sito web",
"website_desc": "Maggiori informazioni sul progetto e sul produttore."
}
}
}
+11 -9
View File
@@ -159,14 +159,6 @@
"header_live_stats": "ライブシステムメトリクスをヘッダーに表示",
"header_live_stats_desc": "ライブ RAM / CPU / VRAM モニターをトップバーに追加します (デフォルトではオフ)。",
"storage_desc": "OmniVoice がデータと出力を保管する場所。",
"factory_reset": "工場出荷時設定にリセット",
"factory_reset_desc": "すべてのアプリ内設定をデフォルトにリセットします。ファイルはそのまま残ります。",
"factory_reset_body": "ローカルに保存された設定 (テーマ、言語、ダブノブ、ギャラリーのお気に入り、その他の UI 設定) をクリアします。ディスク上の音声、プロジェクト、生成されたオーディオは削除されません。",
"factory_reset_confirm_title": "設定をリセットしますか?",
"factory_reset_confirm": "リセットしてリロードする",
"factory_reset_confirm_body": "これにより、保存されている UI 設定がすべてクリアされ、アプリがリロードされます。ディスク上の音声、プロジェクト、出力は影響を受けません。続く?",
"factory_reset_done": "設定がクリアされました — 再読み込み中…",
"factory_reset_failed": "リセットに失敗しました",
"history_retention": "生成履歴",
"history_retention_desc": "古いテイクを整理するまでに保持するテイク数。",
"history_retention_help": "生成のたびに、この上限を超えた古いスターなしテイクは音声ファイルごと削除されます。スター付きテイクは常に保持されます。0 = すべて保持。",
@@ -821,6 +813,12 @@
"burn_subs_title": "字幕を MP4 ビデオ ストリームに直接レンダリングします (ハードサブ)。デュアル字幕がオンの場合は、デュアル字幕形式を使用します。",
"timing_smart_fit": "スマートフィット",
"timing_smart_fit_title": "両方を少しずつ調整します。音声をわずかに速め(ピッチ維持、最大1.5倍)、そのセグメントの映像をわずかに遅くする(最大2倍)ことで、自然な話速のまま収めます。上限を超えた分はトリミングされ、フラグが付きます。書き出し時に映像を再エンコードします。",
"voice_match": "声の一致:",
"voice_match_title": "声の一致 — 各セリフを自身のソースクリップからクローンするか、話者の全セリフで1つの参照を共有するか。",
"voice_match_per_line": "行ごと",
"voice_match_per_line_title": "各セリフを自身のソース音声のクリップからクローンします。行ごとの抑揚は最も合いますが、声の同一性が行ごとにぶれることがあります。",
"voice_match_consistent": "一貫",
"voice_match_consistent_title": "話者の全セリフを1つの共有参照(話者クローン、なければ最良の1クリップ)からクローンします。吹き替え全体で声の同一性が安定します。",
"default_track": "デフォルトのトラック:",
"original_track": "オリジナル",
"selected_dub": "{{code}} (選択されたダブ)",
@@ -921,6 +919,7 @@
"rate_title": "音声速度の適合: {{ratio}}× (スロット{{error}} に対して)",
"speaker_id": "スピーカーID",
"translate_error_title": "翻訳エラー: {{error}}",
"translate_degraded_title": "翻訳済み(通常)— 仕上げ処理はスキップされました: {{reason}}",
"budget_title": "テキストはオリジナルの {{pct}}% です — 高速化または短い表現を検討してください",
"text_title": "Ctrl+D でカーソル位置で分割、Ctrl+M で次のカーソルとマージ",
"orig_label": "元の",
@@ -947,6 +946,8 @@
"fit_overflows_title": "翻訳されたテキストが元のスロットより {{seconds}}s 長くなりました。音声は大幅にトリミングされました。テキストを短くするか、タイミングを「ビデオをストレッチ」に切り替えてください。",
"fit_stretched": "ビデオ {{ratio}}×",
"fit_stretched_title": "ストレッチ ビデオ モード: このセグメントのビデオは、自然なダブ オーディオに合わせて {{ratio}}× まで遅くされました。",
"fit_slowed": "減速 {{ratio}}×",
"fit_slowed_title": "吹き替えの台詞が元の時間より短かったため {{ratio}}× 減速(ピッチ維持)— 空白を残さず元の発話時間をカバーします",
"fit_compressed_title": "TTS オーディオはスロットの {{pct}}% であり、高度に圧縮されています。",
"fit_audio_title": "オーディオはスロット内に収まります。",
"fit_ratio_title": "TTS オーディオはスロットの {{pct}}% です。",
@@ -1736,6 +1737,7 @@
"cinematic_no_llm": "映画のような品質には LLM が必要です — TRANSLATE_BASE_URL + TRANSLATE_API_KEY を設定します (Ollama はローカルで動作します)。 Fast に戻ります。",
"dialect_not_applied": "選択した方言の適用には LLM が必要です — エンジンを OpenAI/Ollama に切り替えるか、Cinematic 品質を選んでください。",
"translate_errors": "{{errorCount}}/{{totalCount}} セグメントが失敗しました: {{firstError}}",
"translate_degraded": "全 {{totalCount}} セグメントを翻訳しました — {{count}} 件は仕上げ処理がスキップされたため通常の翻訳を使用しています({{reason}}",
"translated_segments": "{{count}} セグメント → {{lang}} を翻訳しました",
"translated_cinematic_suffix": "(映画的)",
"translation_failed": "翻訳に失敗しました: {{message}}",
@@ -2147,4 +2149,4 @@
"website": "ウェブサイト",
"website_desc": "プロジェクトとメーカーについて詳しく説明します。"
}
}
}
+11 -9
View File
@@ -159,14 +159,6 @@
"header_live_stats": "헤더에 실시간 시스템 측정항목 표시",
"header_live_stats_desc": "상단 표시줄에 라이브 RAM/CPU/VRAM 모니터를 추가합니다(기본적으로 꺼져 있음).",
"storage_desc": "OmniVoice가 데이터와 출력을 보관하는 곳입니다.",
"factory_reset": "공장 초기화",
"factory_reset_desc": "모든 인앱 환경설정을 기본값으로 재설정합니다. 귀하의 파일은 그대로 유지됩니다.",
"factory_reset_body": "로컬에 저장된 설정(테마, 언어, 더빙 노브, 갤러리 즐겨찾기 및 기타 UI 기본 설정)을 지웁니다. 디스크에 있는 음성, 프로젝트 또는 생성된 오디오는 삭제되지 않습니다.",
"factory_reset_confirm_title": "환경설정을 재설정하시겠습니까?",
"factory_reset_confirm": "재설정 및 새로고침",
"factory_reset_confirm_body": "This clears all saved UI preferences and reloads the app. Your voices, projects, and outputs on disk are not affected. 계속하다?",
"factory_reset_done": "환경설정이 삭제되었습니다 — 새로고침 중…",
"factory_reset_failed": "재설정 실패",
"history_retention": "생성 기록",
"history_retention_desc": "오래된 테이크를 정리하기 전에 유지할 테이크 수입니다.",
"history_retention_help": "생성할 때마다 이 한도를 초과한 오래된 별표 없는 테이크가 오디오 파일과 함께 삭제됩니다. 별표된 테이크는 항상 유지됩니다. 0 = 모두 유지.",
@@ -821,6 +813,12 @@
"burn_subs_title": "자막을 MP4 비디오 스트림(하드 서브)으로 직접 렌더링합니다. 듀얼 자막이 켜져 있을 때 듀얼 자막 형식을 사용합니다.",
"timing_smart_fit": "스마트 핏",
"timing_smart_fit_title": "양쪽을 조금씩 조정합니다. 오디오를 약간 빠르게(피치 유지, 최대 1.5배) 하고 해당 구간의 영상을 약간 느리게(최대 2배) 하여 자연스러운 속도의 음성을 맞춥니다. 한도를 넘는 부분은 잘리고 표시됩니다. 내보낼 때 영상을 다시 인코딩합니다.",
"voice_match": "음성 일치:",
"voice_match_title": "음성 일치 — 각 더빙 대사를 자신의 원본 클립에서 복제할지, 한 화자의 모든 대사가 하나의 참조를 공유할지 정합니다.",
"voice_match_per_line": "대사별",
"voice_match_per_line_title": "각 대사를 자신의 원본 오디오 클립에서 복제합니다. 대사별 억양은 가장 잘 맞지만 목소리 정체성이 대사마다 달라질 수 있습니다.",
"voice_match_consistent": "일관",
"voice_match_consistent_title": "한 화자의 모든 대사를 하나의 공유 참조(화자 클론, 없으면 최적의 단일 클립)에서 복제합니다. 더빙 전체에서 목소리가 더 안정적입니다.",
"default_track": "기본 트랙:",
"original_track": "원본",
"selected_dub": "{{code}}(선택된 더빙)",
@@ -921,6 +919,7 @@
"rate_title": "음성 속도 맞춤: 슬롯에 상대적인 {{ratio}}×{{error}}",
"speaker_id": "스피커 ID",
"translate_error_title": "번역 오류: {{error}}",
"translate_degraded_title": "번역됨 (기본) — 다듬기 단계를 건너뜀: {{reason}}",
"budget_title": "텍스트가 원본의 {{pct}}%입니다. 더 빠른 속도나 더 짧은 문구를 고려하세요.",
"text_title": "커서에서 분할하려면 Ctrl+D · 다음 항목으로 병합하려면 Ctrl+M",
"orig_label": "원본",
@@ -947,6 +946,8 @@
"fit_overflows_title": "번역된 텍스트가 원래 슬롯보다 {{seconds}}s 길었습니다. 오디오가 잘 다듬어졌습니다. 텍스트를 줄이거나 타이밍을 \"비디오 늘이기\"로 전환하세요.",
"fit_stretched": "비디오 {{ratio}}×",
"fit_stretched_title": "비디오 늘이기 모드: 자연스러운 더빙 오디오에 맞추기 위해 이 세그먼트의 비디오가 {{ratio}}× 속도로 느려졌습니다.",
"fit_slowed": "감속 {{ratio}}×",
"fit_slowed_title": "더빙된 대사가 슬롯보다 짧아 {{ratio}}× 감속(음높이 유지) — 공백 대신 원래 말하는 시간을 채웁니다",
"fit_compressed_title": "TTS 오디오는 슬롯의 {{pct}}%입니다. — 심하게 압축되었습니다.",
"fit_audio_title": "오디오는 슬롯 안에 맞습니다.",
"fit_ratio_title": "TTS 오디오는 슬롯의 {{pct}}%입니다.",
@@ -1736,6 +1737,7 @@
"cinematic_no_llm": "영화 품질에는 LLM이 필요합니다. TRANSLATE_BASE_URL + TRANSLATE_API_KEY를 설정하세요(Ollama는 로컬에서 작동함). Fast로 다시 돌아갑니다.",
"dialect_not_applied": "선택한 방언을 적용하려면 LLM이 필요합니다 — 엔진을 OpenAI/Ollama로 바꾸거나 Cinematic 품질을 선택하세요.",
"translate_errors": "{{errorCount}}/{{totalCount}} 세그먼트 실패: {{firstError}}",
"translate_degraded": "{{totalCount}}개 세그먼트 모두 번역됨 — {{count}}개는 다듬기 단계가 건너뛰어져 기본 번역을 사용합니다 ({{reason}})",
"translated_segments": "번역된 {{count}} 세그먼트 → {{lang}}",
"translated_cinematic_suffix": "(영화)",
"translation_failed": "번역 실패: {{message}}",
@@ -2147,4 +2149,4 @@
"website": "웹사이트",
"website_desc": "프로젝트와 제작자에 대해 자세히 알아보세요."
}
}
}
+11 -9
View File
@@ -159,14 +159,6 @@
"header_live_stats": "Toon live systeemstatistieken in de koptekst",
"header_live_stats_desc": "Voegt een live RAM/CPU/VRAM-monitor toe aan de bovenste balk (standaard uitgeschakeld).",
"storage_desc": "Waar OmniVoice uw gegevens en output bewaart.",
"factory_reset": "Fabrieksreset",
"factory_reset_desc": "Zet alle in-app-voorkeuren terug naar hun standaardwaarden. Uw bestanden blijven onaangeroerd.",
"factory_reset_body": "Wist lokaal opgeslagen instellingen (thema, taal, kopieerknoppen, galerijfavorieten en andere UI-voorkeuren). Het verwijdert NIET uw stemmen, projecten of gegenereerde audio op schijf.",
"factory_reset_confirm_title": "Voorkeuren opnieuw instellen?",
"factory_reset_confirm": "Resetten en opnieuw laden",
"factory_reset_confirm_body": "Hiermee worden alle opgeslagen UI-voorkeuren gewist en wordt de app opnieuw geladen. Uw stemmen, projecten en outputs op schijf worden niet beïnvloed. Doorgaan?",
"factory_reset_done": "Voorkeuren gewist — herladen…",
"factory_reset_failed": "Resetten mislukt",
"history_retention": "Generatiegeschiedenis",
"history_retention_desc": "Hoeveel takes bewaard blijven voordat de oudste worden opgeruimd.",
"history_retention_help": "Na elke generatie worden de oudste takes zonder ster boven deze limiet verwijderd, samen met hun audiobestanden. Takes met ster blijven altijd bewaard. 0 = alles bewaren.",
@@ -821,6 +813,12 @@
"burn_subs_title": "Geef ondertitels rechtstreeks weer in de MP4-videostream (hardsubs). Gebruikt het formaat met dubbele ondertiteling wanneer dubbele ondertiteling is ingeschakeld.",
"timing_smart_fit": "Slimme aanpassing",
"timing_smart_fit_title": "Verdeelt de aanpassing: versnelt de audio licht (toonhoogte behouden, tot 1,5×) en vertraagt dat videosegment licht (tot 2×) zodat spraak op natuurlijk tempo past. Wat daarbuiten valt wordt afgekapt en gemarkeerd. Exporteren hercodeert de video.",
"voice_match": "Stemovereenkomst:",
"voice_match_title": "Stemovereenkomst — of elke gedubde regel wordt gekloond uit zijn eigen bronclip of alle regels van een spreker één referentie delen.",
"voice_match_per_line": "Per regel",
"voice_match_per_line_title": "Elke regel wordt gekloond uit een clip van zijn eigen bronaudio. Beste prosodie per regel, maar de stemidentiteit kan per regel verschuiven.",
"voice_match_consistent": "Consistent",
"voice_match_consistent_title": "Alle regels van een spreker worden gekloond uit één gedeelde referentie (de sprekerkloon, of de beste losse clip als die ontbreekt). Stabielere stemidentiteit door de hele dub.",
"default_track": "Standaardnummer:",
"original_track": "Origineel",
"selected_dub": "{{code}} (geselecteerde kopie)",
@@ -921,6 +919,7 @@
"rate_title": "Passing op spraaksnelheid: {{ratio}}× relatief aan slot{{error}}",
"speaker_id": "Luidspreker-ID",
"translate_error_title": "Translation error: {{error}}",
"translate_degraded_title": "Vertaald (gewoon) — polijststap overgeslagen: {{reason}}",
"budget_title": "De tekst is {{pct}}% van het origineel. Overweeg een hogere snelheid of kortere formulering",
"text_title": "Ctrl+D om te splitsen bij de cursor · Ctrl+M om samen te voegen met de volgende",
"orig_label": "oorsprong",
@@ -947,6 +946,8 @@
"fit_overflows_title": "De vertaalde tekst was {{seconds}}s langer dan de oorspronkelijke tekst. De audio was hard getrimd; verkort de tekst of schakel Timing naar \"Video uitrekken\".",
"fit_stretched": "Video {{ratio}}×",
"fit_stretched_title": "Stretch Video-modus: de video van dit segment is vertraagd tot {{ratio}}× om te passen bij de natuurlijke dub-audio.",
"fit_slowed": "vertraagd {{ratio}}×",
"fit_slowed_title": "De nagesynchroniseerde zin was korter dan zijn slot — {{ratio}}× vertraagd (toonhoogte behouden) zodat de spraak de originele spreektijd dekt in plaats van een gat te laten",
"fit_compressed_title": "TTS-audio is {{pct}}% van de sleuf zwaar gecomprimeerd.",
"fit_audio_title": "Audio past in de sleuf.",
"fit_ratio_title": "TTS-audio is {{pct}}% van de sleuf.",
@@ -1736,6 +1737,7 @@
"cinematic_no_llm": "Filmische kwaliteit heeft een LLM nodig: stel TRANSLATE_BASE_URL + TRANSLATE_API_KEY in (Ollama werkt lokaal). Terugvallend op Snel.",
"dialect_not_applied": "Het gekozen dialect heeft een LLM nodig — zet de engine op OpenAI/Ollama of kies Cinematic-kwaliteit.",
"translate_errors": "{{errorCount}}/{{totalCount}} segment(en) mislukt: {{firstError}}",
"translate_degraded": "Alle {{totalCount}} segment(en) vertaald — {{count}} gebruiken de gewone vertaling omdat de polijststap is overgeslagen ({{reason}})",
"translated_segments": "Vertaald {{count}} segment(en) → {{lang}}",
"translated_cinematic_suffix": "(filmisch)",
"translation_failed": "Vertaling mislukt: {{message}}",
@@ -2147,4 +2149,4 @@
"website": "Website",
"website_desc": "Meer over het project en de maker."
}
}
}
+11 -9
View File
@@ -159,14 +159,6 @@
"header_live_stats": "Pokaż aktualne dane systemowe w nagłówku",
"header_live_stats_desc": "Dodaje bieżący monitor RAM/CPU/VRAM do górnego paska (domyślnie wyłączony).",
"storage_desc": "Miejsce, w którym OmniVoice przechowuje Twoje dane i wyniki.",
"factory_reset": "Reset do ustawień fabrycznych",
"factory_reset_desc": "Zresetuj wszystkie preferencje w aplikacji do wartości domyślnych. Twoje pliki pozostają nietknięte.",
"factory_reset_body": "Czyści ustawienia zapisane lokalnie (motyw, język, pokrętła dub, ulubione galerie i inne preferencje interfejsu użytkownika). NIE usuwa Twoich głosów, projektów ani wygenerowanego dźwięku na dysku.",
"factory_reset_confirm_title": "Zresetować preferencje?",
"factory_reset_confirm": "Zresetuj i załaduj ponownie",
"factory_reset_confirm_body": "Spowoduje to usunięcie wszystkich zapisanych preferencji interfejsu użytkownika i ponowne załadowanie aplikacji. Nie ma to wpływu na Twoje głosy, projekty i wyjścia na dysku. Kontynuować?",
"factory_reset_done": "Preferencje wyczyszczone — ładuję ponownie…",
"factory_reset_failed": "Resetowanie nie powiodło się",
"history_retention": "Historia generowania",
"history_retention_desc": "Ile nagrań zachować, zanim najstarsze zostaną wyczyszczone.",
"history_retention_help": "Po każdym generowaniu najstarsze nagrania bez gwiazdki powyżej tego limitu są usuwane wraz z plikami audio. Nagrania z gwiazdką są zawsze zachowywane. 0 = zachowaj wszystko.",
@@ -821,6 +813,12 @@
"burn_subs_title": "Renderuj napisy bezpośrednio do strumienia wideo MP4 (hardsub). Używa formatu podwójnych napisów, gdy opcja Podwójne napisy jest włączona.",
"timing_smart_fit": "Inteligentne dopasowanie",
"timing_smart_fit_title": "Dzieli dopasowanie: lekko przyspiesza dźwięk (z zachowaniem wysokości tonu, do 1,5×) i lekko spowalnia dany fragment wideo (do 2×), aby mowa w naturalnym tempie się zmieściła. Nadmiar jest przycinany i oznaczany. Eksport ponownie koduje wideo.",
"voice_match": "Dopasowanie głosu:",
"voice_match_title": "Dopasowanie głosu — czy każda dubbingowana kwestia jest klonowana z własnego klipu źródłowego, czy wszystkie kwestie mówcy dzielą jedno odniesienie.",
"voice_match_per_line": "Na kwestię",
"voice_match_per_line_title": "Każda kwestia jest klonowana z klipu własnego dźwięku źródłowego. Najlepsza prozodia w obrębie kwestii, ale tożsamość głosu może dryfować między kwestiami.",
"voice_match_consistent": "Spójny",
"voice_match_consistent_title": "Wszystkie kwestie mówcy są klonowane z jednego wspólnego odniesienia (klon mówcy lub najlepszy pojedynczy klip, gdy go brak). Stabilniejsza tożsamość głosu w całym dubbingu.",
"default_track": "Domyślny utwór:",
"original_track": "Oryginał",
"selected_dub": "{{code}} (wybrany dubbing)",
@@ -921,6 +919,7 @@
"rate_title": "Dopasowanie szybkości mowy: {{ratio}}× względem szczeliny{{error}}",
"speaker_id": "Identyfikator głośnika",
"translate_error_title": "Błąd w tłumaczeniu: {{error}}",
"translate_degraded_title": "Przetłumaczono (zwykłe) — pominięto szlifowanie: {{reason}}",
"budget_title": "Tekst ma {{pct}}% oryginału — rozważ większą prędkość lub krótsze frazowanie",
"text_title": "Ctrl+D, aby podzielić przy kursorze · Ctrl+M, aby połączyć z następnym",
"orig_label": "oryg",
@@ -947,6 +946,8 @@
"fit_overflows_title": "Przetłumaczony tekst był dłuższy niż oryginalny fragment o {{seconds}}s. Dźwięk został mocno przycięty; skróć tekst lub przełącz synchronizację na „Rozciągnij wideo”.",
"fit_stretched": "Wideo {{ratio}}×",
"fit_stretched_title": "Tryb rozciągania wideo: wideo tego segmentu zostało spowolnione do {{ratio}}×, aby dopasować je do naturalnego dźwięku dubowego.",
"fit_slowed": "spowolniono {{ratio}}×",
"fit_slowed_title": "Zdubbingowana kwestia była krótsza niż jej slot — spowolniona {{ratio}}× (z zachowaniem wysokości), by mowa pokryła oryginalny czas wypowiedzi zamiast zostawiać dziurę",
"fit_compressed_title": "Dźwięk TTS stanowi {{pct}}% szczeliny — jest mocno skompresowany.",
"fit_audio_title": "Dźwięk mieści się w gnieździe.",
"fit_ratio_title": "Dźwięk TTS zajmuje {{pct}}% szczeliny.",
@@ -1736,6 +1737,7 @@
"cinematic_no_llm": "Jakość kinowa wymaga LLM — ustaw TRANSLATE_BASE_URL + TRANSLATE_API_KEY (Ollama działa lokalnie). Wracając do Fasta.",
"dialect_not_applied": "Wybrany dialekt wymaga LLM — przełącz silnik na OpenAI/Ollama lub wybierz jakość Cinematic.",
"translate_errors": "{{errorCount}}/{{totalCount}} segment(y) nie powiodły się: {{firstError}}",
"translate_degraded": "Przetłumaczono wszystkie {{totalCount}} segmenty — {{count}} używa zwykłego tłumaczenia, bo pominięto etap szlifowania ({{reason}})",
"translated_segments": "Przetłumaczone segmenty {{count}} → {{lang}}",
"translated_cinematic_suffix": "(Kinowy)",
"translation_failed": "Tłumaczenie nie powiodło się: {{message}}",
@@ -2147,4 +2149,4 @@
"website": "Strona internetowa",
"website_desc": "Więcej o projekcie i twórcy."
}
}
}
+11 -9
View File
@@ -159,14 +159,6 @@
"header_live_stats": "Mostrar métricas do sistema ao vivo no cabeçalho",
"header_live_stats_desc": "Adiciona um monitor RAM/CPU/VRAM ativo à barra superior (desativado por padrão).",
"storage_desc": "Onde OmniVoice mantém seus dados e resultados.",
"factory_reset": "Redefinição de fábrica",
"factory_reset_desc": "Redefina todas as preferências do aplicativo para os padrões. Seus arquivos permanecem intactos.",
"factory_reset_body": "Limpa configurações salvas localmente (tema, idioma, botões de dublagem, favoritos da galeria e outras preferências da interface do usuário). NÃO exclui suas vozes, projetos ou áudio gerado no disco.",
"factory_reset_confirm_title": "Redefinir preferências?",
"factory_reset_confirm": "Redefinir e recarregar",
"factory_reset_confirm_body": "Isso limpa todas as preferências de UI salvas e recarrega o aplicativo. Suas vozes, projetos e saídas em disco não são afetados. Continuar?",
"factory_reset_done": "Preferências limpas — recarregando…",
"factory_reset_failed": "Falha na redefinição",
"history_retention": "Histórico de geração",
"history_retention_desc": "Quantas takes manter antes de limpar as mais antigas.",
"history_retention_help": "Após cada geração, as takes mais antigas sem estrela acima deste limite são removidas junto com seus arquivos de áudio. Takes com estrela são sempre mantidas. 0 = manter tudo.",
@@ -821,6 +813,12 @@
"burn_subs_title": "Renderize legendas diretamente no stream de vídeo MP4 (hardsubs). Usa o formato de legenda dupla quando Legendas duplas está ativado.",
"timing_smart_fit": "Ajuste inteligente",
"timing_smart_fit_title": "Divide o ajuste: acelera ligeiramente o áudio (tom preservado, até 1,5×) e desacelera ligeiramente esse trecho do vídeo (até 2×) para que a fala em ritmo natural caiba. O que passar dos limites é cortado e sinalizado. A exportação recodifica o vídeo.",
"voice_match": "Correspondência de voz:",
"voice_match_title": "Correspondência de voz — se cada fala dublada é clonada do seu próprio clipe de origem ou todas as falas de um falante compartilham uma única referência.",
"voice_match_per_line": "Por fala",
"voice_match_per_line_title": "Cada fala é clonada de um clipe do seu próprio áudio de origem. Melhor prosódia por fala, mas a identidade da voz pode variar de fala em fala.",
"voice_match_consistent": "Consistente",
"voice_match_consistent_title": "Todas as falas de um falante são clonadas de uma referência compartilhada (o clone do falante ou o melhor clipe único quando não houver). Identidade de voz mais estável em toda a dublagem.",
"default_track": "Faixa padrão:",
"original_track": "Originais",
"selected_dub": "{{code}} (dublagem selecionada)",
@@ -921,6 +919,7 @@
"rate_title": "Ajuste da taxa de fala: {{ratio}}× em relação ao slot{{error}}",
"speaker_id": "ID do palestrante",
"translate_error_title": "Erro de tradução: {{error}}",
"translate_degraded_title": "Traduzido (simples) — o polimento foi ignorado: {{reason}}",
"budget_title": "O texto é {{pct}}% do original considere velocidade mais alta ou fraseado mais curto",
"text_title": "Ctrl+D para dividir no cursor · Ctrl+M para mesclar com o próximo",
"orig_label": "original",
@@ -947,6 +946,8 @@
"fit_overflows_title": "O texto traduzido era maior que o espaço original em {{seconds}}s. O áudio foi cortado; encurte o texto ou mude o tempo para \"Alongar vídeo\".",
"fit_stretched": "Vídeo {{ratio}}×",
"fit_stretched_title": "Modo Stretch Video: o vídeo deste segmento foi desacelerado para {{ratio}}× para se ajustar ao áudio de dublagem natural.",
"fit_slowed": "desacelerado {{ratio}}×",
"fit_slowed_title": "A fala dublada ficou mais curta que o seu intervalo — desacelerada {{ratio}}× (tom preservado) para cobrir o tempo de fala original em vez de deixar um buraco",
"fit_compressed_title": "O áudio TTS é {{pct}}% do slot fortemente compactado.",
"fit_audio_title": "O áudio cabe dentro do slot.",
"fit_ratio_title": "O áudio TTS é {{pct}}% do slot.",
@@ -1736,6 +1737,7 @@
"cinematic_no_llm": "A qualidade cinematográfica precisa de um LLM - defina TRANSLATE_BASE_URL + TRANSLATE_API_KEY (Ollama funciona localmente). Voltando ao Fast.",
"dialect_not_applied": "O dialeto selecionado precisa de um LLM — mude o motor para OpenAI/Ollama ou escolha a qualidade Cinematic.",
"translate_errors": "{{errorCount}}/{{totalCount}} segmento(s) falhou: {{firstError}}",
"translate_degraded": "Todos os {{totalCount}} segmento(s) traduzidos — {{count}} usam a tradução simples porque o polimento foi ignorado ({{reason}})",
"translated_segments": "Segmento(s) {{count}} traduzido(s) → {{lang}}",
"translated_cinematic_suffix": "(Cinemático)",
"translation_failed": "Falha na tradução: {{message}}",
@@ -2147,4 +2149,4 @@
"website": "Site",
"website_desc": "Mais sobre o projeto e o criador."
}
}
}
+11 -9
View File
@@ -159,14 +159,6 @@
"header_live_stats": "Показывать метрики системы в реальном времени в заголовке",
"header_live_stats_desc": "Добавляет монитор оперативной памяти/ЦП/ВОЗУ на верхнюю панель (по умолчанию отключен).",
"storage_desc": "Где OmniVoice хранит ваши данные и выходные данные.",
"factory_reset": "Сброс к заводским настройкам",
"factory_reset_desc": "Сбросьте все настройки приложения до значений по умолчанию. Ваши файлы остаются нетронутыми.",
"factory_reset_body": "Очищает локально сохраненные настройки (тема, язык, кнопки дублирования, избранное галереи и другие настройки пользовательского интерфейса). Он НЕ удаляет ваши голоса, проекты или созданный звук на диске.",
"factory_reset_confirm_title": "Сбросить настройки?",
"factory_reset_confirm": "Сброс и перезагрузка",
"factory_reset_confirm_body": "Это очистит все сохраненные настройки пользовательского интерфейса и перезагрузит приложение. Ваши голоса, проекты и материалы на диске не будут затронуты. Продолжать?",
"factory_reset_done": "Настройки очищены — перезагрузка…",
"factory_reset_failed": "Сбросить не удалось",
"history_retention": "История генераций",
"history_retention_desc": "Сколько дублей хранить, прежде чем удалять самые старые.",
"history_retention_help": "После каждой генерации самые старые неотмеченные дубли сверх этого лимита удаляются вместе с аудиофайлами. Отмеченные дубли сохраняются всегда. 0 = хранить всё.",
@@ -821,6 +813,12 @@
"burn_subs_title": "Рендеринг субтитров непосредственно в видеопоток MP4 (жесткие субтитры). Использует формат двойных субтитров, когда двойные субтитры включены.",
"timing_smart_fit": "Умная подгонка",
"timing_smart_fit_title": "Делит подгонку пополам: слегка ускоряет аудио (с сохранением высоты тона, до 1,5×) и слегка замедляет этот фрагмент видео (до 2×), чтобы речь в естественном темпе поместилась. Остаток сверх лимитов обрезается и помечается. Экспорт перекодирует видео.",
"voice_match": "Совпадение голоса:",
"voice_match_title": "Совпадение голоса — клонировать каждую реплику из её собственного исходного фрагмента или все реплики одного говорящего используют одну общую референс-запись.",
"voice_match_per_line": "Построчно",
"voice_match_per_line_title": "Каждая реплика клонируется из фрагмента её собственного исходного звука. Лучшая просодия для каждой реплики, но идентичность голоса может плыть от реплики к реплике.",
"voice_match_consistent": "Единый",
"voice_match_consistent_title": "Все реплики говорящего клонируются из одной общей референс-записи (клон говорящего или лучший одиночный фрагмент, если клона нет). Более стабильный голос на протяжении всего дубляжа.",
"default_track": "Трек по умолчанию:",
"original_track": "Оригинал",
"selected_dub": "{{code}} (Избранный дубляж)",
@@ -921,6 +919,7 @@
"rate_title": "Соответствие скорости речи: {{ratio}}× относительно слота {{error}}",
"speaker_id": "Идентификатор докладчика",
"translate_error_title": "Ошибка перевода: {{error}}",
"translate_degraded_title": "Переведено (просто) — этап доводки пропущен: {{reason}}",
"budget_title": "Текст составляет {{pct}} % от оригинала. Рассмотрите возможность более быстрой или более короткой формулировки.",
"text_title": "Ctrl+D, чтобы разделить курсор · Ctrl+M, чтобы объединить со следующим",
"orig_label": "оригинал",
@@ -947,6 +946,8 @@
"fit_overflows_title": "Переведенный текст был длиннее исходного слота на {{seconds}}s. Звук был жестко обрезан; сократите текст или переключите время на «Растянуть видео».",
"fit_stretched": "Видео {{ratio}}×",
"fit_stretched_title": "Режим растягивания видео: видео этого сегмента было замедлено до {{ratio}}×, чтобы соответствовать естественному звуку дубляжа.",
"fit_slowed": "замедлено {{ratio}}×",
"fit_slowed_title": "Дублированная реплика короче своего интервала — замедлена {{ratio}}× (высота тона сохранена), чтобы речь покрывала исходное время, а не оставляла провал",
"fit_compressed_title": "Звук TTS занимает {{pct}}% слота и сильно сжат.",
"fit_audio_title": "Аудио поместилось внутри слота.",
"fit_ratio_title": "Звук TTS занимает {{pct}}% слота.",
@@ -1736,6 +1737,7 @@
"cinematic_no_llm": "Для кинематографического качества требуется LLM — установите TRANSLATE_BASE_URL + TRANSLATE_API_KEY (Ollama работает локально). Возвращаемся к Фасту.",
"dialect_not_applied": "Для выбранного диалекта нужен LLM — переключите движок на OpenAI/Ollama или выберите качество Cinematic.",
"translate_errors": "Сегмент(ы) {{errorCount}}/{{totalCount}} не удалось: {{firstError}}",
"translate_degraded": "Все {{totalCount}} сегмент(ов) переведены — {{count}} используют простой перевод, так как этап доводки был пропущен ({{reason}})",
"translated_segments": "Переведено сегмент(ов) {{count}} → {{lang}}",
"translated_cinematic_suffix": "(Кинематографический)",
"translation_failed": "Перевод не выполнен: {{message}}",
@@ -2147,4 +2149,4 @@
"website": "Веб-сайт",
"website_desc": "Подробнее о проекте и создателе."
}
}
}

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