Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d4eb0f269 | ||
|
|
018cdcb47f | ||
|
|
22a513a404 | ||
|
|
9ecb810946 | ||
|
|
283ef36b13 | ||
|
|
3aa2ef285a | ||
|
|
db12b94145 | ||
|
|
5302170688 | ||
|
|
780ff1f6cf | ||
|
|
d6f24dafd5 | ||
|
|
dc3527ab05 | ||
|
|
a4d9d9f128 | ||
|
|
a7efaa24f7 | ||
|
|
46141c8e5e | ||
|
|
58c6f37252 | ||
|
|
3383ee9a94 | ||
|
|
c613a65435 | ||
|
|
bec916c348 | ||
|
|
95b7c6f652 | ||
|
|
42c0a0d1dd | ||
|
|
2d9ebe350a | ||
|
|
e9477870c8 | ||
|
|
fd935ab699 | ||
|
|
ea7a7b39b4 | ||
|
|
810b62e62d | ||
|
|
07db3415fd | ||
|
|
1de41d57ee | ||
|
|
0e2c00a403 | ||
|
|
cbcea41fb6 | ||
|
|
4e5d795832 |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -18,6 +18,7 @@ build/
|
||||
# Node / Turborepo / Tauri
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
node_modules/
|
||||
node_modules
|
||||
.turbo/
|
||||
bun.lockb
|
||||
frontend/src-tauri/target/
|
||||
|
||||
@@ -6,6 +6,72 @@ 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 **3–4 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.
|
||||
@@ -26,6 +92,10 @@ The memory release. The reason the app kept saying "Can't reach the local backen
|
||||
|
||||
- **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.
|
||||
|
||||
@@ -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/>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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():
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
@@ -792,6 +820,18 @@ async def generate_speech(
|
||||
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:
|
||||
@@ -1077,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
|
||||
@@ -1236,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.
|
||||
@@ -1251,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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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.21"
|
||||
_FALLBACK_VERSION = "0.3.22"
|
||||
|
||||
|
||||
def _fallback_version() -> str:
|
||||
|
||||
+2
-1
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
+148
-43
@@ -30,6 +30,8 @@ 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
|
||||
@@ -163,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:
|
||||
@@ -382,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)"
|
||||
@@ -748,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
|
||||
@@ -1057,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
|
||||
@@ -2077,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"
|
||||
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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(...,
|
||||
@@ -1278,6 +1322,7 @@ async def idle_worker():
|
||||
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
|
||||
@@ -1295,6 +1340,38 @@ async def idle_worker():
|
||||
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)."""
|
||||
torch = _lazy_torch()
|
||||
@@ -1318,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).
|
||||
|
||||
@@ -1333,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():
|
||||
@@ -1358,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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -301,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:
|
||||
@@ -322,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)
|
||||
@@ -336,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."""
|
||||
@@ -343,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.
|
||||
|
||||
@@ -414,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:
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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=="],
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -98,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)
|
||||
```
|
||||
|
||||
@@ -107,6 +108,7 @@ 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, 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)
|
||||
```
|
||||
|
||||
@@ -116,6 +118,7 @@ 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, 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)
|
||||
```
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "omnivoice-studio",
|
||||
"version": "0.3.21",
|
||||
"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",
|
||||
|
||||
Generated
+1
-1
@@ -2941,7 +2941,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "omnivoice-studio"
|
||||
version = "0.3.21"
|
||||
version = "0.3.22"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"dirs-next",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "omnivoice-studio"
|
||||
version = "0.3.21"
|
||||
version = "0.3.22"
|
||||
description = "OmniVoice Studio – AI voice cloning & dubbing desktop app"
|
||||
authors = ["Debpalash"]
|
||||
license = "AGPL-3.0-only"
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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)]
|
||||
@@ -317,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 {
|
||||
@@ -2227,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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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() };
|
||||
|
||||
+189
-31
@@ -332,6 +332,48 @@ pub async fn reset_scan(app: tauri::AppHandle) -> Vec<ResetScope> {
|
||||
.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
|
||||
@@ -365,37 +407,7 @@ pub async fn reset_purge(app: tauri::AppHandle, scopes: Vec<String>) -> Result<R
|
||||
|
||||
let roots = roots_for(&purge_app);
|
||||
let home = dirs_next::home_dir();
|
||||
|
||||
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.as_deref()) {
|
||||
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
|
||||
purge_scopes(&roots, &wanted, home.as_deref())
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("reset failed: {e}"))?;
|
||||
@@ -534,4 +546,150 @@ mod tests {
|
||||
// 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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,11 +124,28 @@ pub fn uninstall_scan(app: tauri::AppHandle) -> Vec<UninstallTarget> {
|
||||
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
|
||||
@@ -214,6 +231,8 @@ mod tests {
|
||||
"/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);
|
||||
@@ -226,4 +245,12 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[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
@@ -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}
|
||||
|
||||
@@ -165,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');
|
||||
@@ -191,12 +196,12 @@ export async function apiFetch(path: string, opts: RequestInit = {}): Promise<Re
|
||||
// "starting", bounded by STARTUP_GRACE_MS.
|
||||
const elapsed = Date.now() - startedAt;
|
||||
if (elapsed < STARTUP_GRACE_MS) {
|
||||
let stage = 'unknown';
|
||||
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;
|
||||
@@ -234,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).',
|
||||
|
||||
@@ -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)}`,
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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 · v{APP_VERSION}
|
||||
</span>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 · 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)',
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -23,7 +23,15 @@
|
||||
*/
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Trash2, AlertTriangle, Folder, Package, ScrollText, Database } from 'lucide-react';
|
||||
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';
|
||||
@@ -44,7 +52,13 @@ export function freedBytes(targets, includeModels) {
|
||||
.reduce((sum, t) => sum + (t.size_bytes || 0), 0);
|
||||
}
|
||||
|
||||
const ICONS = { data: Folder, env: Package, logs: ScrollText, models: Database };
|
||||
const ICONS = {
|
||||
data: Folder,
|
||||
env: Package,
|
||||
logs: ScrollText,
|
||||
userenv: KeyRound,
|
||||
models: Database,
|
||||
};
|
||||
|
||||
export default function UninstallPanel() {
|
||||
const { t } = useTranslation();
|
||||
@@ -111,6 +125,9 @@ export default function UninstallPanel() {
|
||||
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)',
|
||||
}),
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -813,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}} (الدبلجة المحددة)",
|
||||
@@ -913,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": "أصل",
|
||||
@@ -939,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}}% من الفتحة.",
|
||||
@@ -1728,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}}",
|
||||
|
||||
@@ -813,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)",
|
||||
@@ -913,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",
|
||||
@@ -939,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.",
|
||||
@@ -1728,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}}",
|
||||
|
||||
@@ -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.",
|
||||
@@ -583,6 +596,7 @@
|
||||
"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.",
|
||||
@@ -742,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",
|
||||
@@ -1055,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",
|
||||
@@ -1151,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",
|
||||
@@ -1180,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.",
|
||||
@@ -1657,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",
|
||||
@@ -2334,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}}",
|
||||
|
||||
@@ -813,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)",
|
||||
@@ -913,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",
|
||||
@@ -939,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.",
|
||||
@@ -1728,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}}",
|
||||
|
||||
@@ -813,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é)",
|
||||
@@ -913,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",
|
||||
@@ -939,6 +946,8 @@
|
||||
"fit_overflows_title": "Le texte traduit était plus long que l'emplacement d'origine de {{seconds}}s. L’audio é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.",
|
||||
@@ -1728,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}}",
|
||||
|
||||
@@ -813,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}} (चयनित डब)",
|
||||
@@ -913,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": "मूल",
|
||||
@@ -939,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}}% है।",
|
||||
@@ -1728,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}}",
|
||||
|
||||
@@ -813,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)",
|
||||
@@ -913,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",
|
||||
@@ -939,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.",
|
||||
@@ -1728,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}}",
|
||||
|
||||
@@ -813,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)",
|
||||
@@ -913,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",
|
||||
@@ -939,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.",
|
||||
@@ -1728,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}}",
|
||||
|
||||
@@ -813,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}} (選択されたダブ)",
|
||||
@@ -913,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": "元の",
|
||||
@@ -939,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}}% です。",
|
||||
@@ -1728,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}}",
|
||||
|
||||
@@ -813,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}}(선택된 더빙)",
|
||||
@@ -913,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": "원본",
|
||||
@@ -939,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}}%입니다.",
|
||||
@@ -1728,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}}",
|
||||
|
||||
@@ -813,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)",
|
||||
@@ -913,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",
|
||||
@@ -939,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.",
|
||||
@@ -1728,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}}",
|
||||
|
||||
@@ -813,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)",
|
||||
@@ -913,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",
|
||||
@@ -939,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.",
|
||||
@@ -1728,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}}",
|
||||
|
||||
@@ -813,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)",
|
||||
@@ -913,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",
|
||||
@@ -939,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.",
|
||||
@@ -1728,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}}",
|
||||
|
||||
@@ -813,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}} (Избранный дубляж)",
|
||||
@@ -913,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": "оригинал",
|
||||
@@ -939,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}}% слота.",
|
||||
@@ -1728,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}}",
|
||||
|
||||
@@ -813,6 +813,12 @@
|
||||
"burn_subs_title": "Återge undertexter direkt i MP4-videoströmmen (hardsubs). Använder formatet med dubbla undertexter när dubbla undertexter är på.",
|
||||
"timing_smart_fit": "Smart anpassning",
|
||||
"timing_smart_fit_title": "Delar upp anpassningen: snabbar upp ljudet något (tonhöjd bevaras, upp till 1,5×) och saktar ner det videosegmentet något (upp till 2×) så att tal i naturligt tempo får plats. Det som ändå inte ryms klipps och flaggas. Export omkodar videon.",
|
||||
"voice_match": "Röstmatchning:",
|
||||
"voice_match_title": "Röstmatchning — om varje dubbad rad klonas från sitt eget källklipp eller om alla rader från en talare delar en gemensam referens.",
|
||||
"voice_match_per_line": "Per rad",
|
||||
"voice_match_per_line_title": "Varje rad klonas från ett klipp av sitt eget källjud. Bäst prosodi per rad, men röstidentiteten kan glida mellan rader.",
|
||||
"voice_match_consistent": "Konsekvent",
|
||||
"voice_match_consistent_title": "Alla rader från en talare klonas från en gemensam referens (talarklonen, eller det bästa enskilda klippet om ingen finns). Stabilare röstidentitet genom hela dubbningen.",
|
||||
"default_track": "Standardspår:",
|
||||
"original_track": "Original",
|
||||
"selected_dub": "{{code}} (vald dub)",
|
||||
@@ -913,6 +919,7 @@
|
||||
"rate_title": "Talhastighetsanpassning: {{ratio}}× i förhållande till plats{{error}}",
|
||||
"speaker_id": "Högtalar-ID",
|
||||
"translate_error_title": "Översättningsfel: {{error}}",
|
||||
"translate_degraded_title": "Översatt (enkel) — putsningssteget hoppades över: {{reason}}",
|
||||
"budget_title": "Texten är {{pct}}% av originalet — överväg högre hastighet eller kortare frasering",
|
||||
"text_title": "Ctrl+D för att dela vid markören · Ctrl+M för att slå samman med nästa",
|
||||
"orig_label": "ursprung",
|
||||
@@ -939,6 +946,8 @@
|
||||
"fit_overflows_title": "Den översatta texten var längre än den ursprungliga platsen med {{seconds}}s. Ljudet var hårt trimmat; förkorta texten eller ändra Timing till \"Stretch Video\".",
|
||||
"fit_stretched": "Video {{ratio}}×",
|
||||
"fit_stretched_title": "Stretch Video-läge: detta segments video saktades ner till {{ratio}}× för att passa det naturliga dubbljudet.",
|
||||
"fit_slowed": "saktad {{ratio}}×",
|
||||
"fit_slowed_title": "Den dubbade repliken var kortare än sin lucka — saktad {{ratio}}× (tonhöjd bevarad) så talet täcker den ursprungliga taltiden i stället för att lämna ett hål",
|
||||
"fit_compressed_title": "TTS-ljud är {{pct}}% av kortplatsen — kraftigt komprimerat.",
|
||||
"fit_audio_title": "Ljudet passar in i öppningen.",
|
||||
"fit_ratio_title": "TTS-ljud är {{pct}}% av kortplatsen.",
|
||||
@@ -1728,6 +1737,7 @@
|
||||
"cinematic_no_llm": "Filmkvalitet behöver en LLM — ställ in TRANSLATE_BASE_URL + TRANSLATE_API_KEY (Ollama fungerar lokalt). Faller tillbaka till Fast.",
|
||||
"dialect_not_applied": "Den valda dialekten kräver en LLM — byt motor till OpenAI/Ollama eller välj Cinematic-kvalitet.",
|
||||
"translate_errors": "{{errorCount}}/{{totalCount}} segment(er) misslyckades: {{firstError}}",
|
||||
"translate_degraded": "Alla {{totalCount}} segment översatta — {{count}} använder den enkla översättningen eftersom putsningssteget hoppades över ({{reason}})",
|
||||
"translated_segments": "Översatta {{count}} segment(er) → {{lang}}",
|
||||
"translated_cinematic_suffix": "(Cinematic)",
|
||||
"translation_failed": "Översättning misslyckades: {{message}}",
|
||||
|
||||
@@ -813,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}} (พากย์ที่เลือก)",
|
||||
@@ -913,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": "ต้นฉบับ",
|
||||
@@ -939,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}}% ของช่อง",
|
||||
@@ -1728,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}}",
|
||||
|
||||
@@ -813,6 +813,12 @@
|
||||
"burn_subs_title": "Altyazıları doğrudan MP4 video akışına (hardsub'lar) aktarın. Çift altyazı açıkken çift altyazı formatını kullanır.",
|
||||
"timing_smart_fit": "Akıllı Sığdırma",
|
||||
"timing_smart_fit_title": "Yükü paylaştırır: sesi hafifçe hızlandırır (perde korunur, en fazla 1,5×) ve videonun o bölümünü hafifçe yavaşlatır (en fazla 2×), böylece doğal hızdaki konuşma sığar. Sınırları aşan kısım kırpılır ve işaretlenir. Dışa aktarma videoyu yeniden kodlar.",
|
||||
"voice_match": "Ses eşleşmesi:",
|
||||
"voice_match_title": "Ses eşleşmesi — her dublaj satırı kendi kaynak klibinden mi klonlansın, yoksa bir konuşmacının tüm satırları tek bir referansı mı paylaşsın.",
|
||||
"voice_match_per_line": "Satır başına",
|
||||
"voice_match_per_line_title": "Her satır kendi kaynak sesinin klibinden klonlanır. Satır başına en iyi prozodi, ancak ses kimliği satırdan satıra kayabilir.",
|
||||
"voice_match_consistent": "Tutarlı",
|
||||
"voice_match_consistent_title": "Bir konuşmacının tüm satırları tek bir ortak referanstan klonlanır (konuşmacı klonu, yoksa en iyi tek klip). Tüm dublajda daha kararlı bir ses kimliği.",
|
||||
"default_track": "Varsayılan Parça:",
|
||||
"original_track": "Orijinal",
|
||||
"selected_dub": "{{code}} (Seçilmiş Dub)",
|
||||
@@ -913,6 +919,7 @@
|
||||
"rate_title": "Konuşma hızı uyumu: {{ratio}}× yuvaya göre{{error}}",
|
||||
"speaker_id": "Hoparlör Kimliği",
|
||||
"translate_error_title": "Çeviri hatası: {{error}}",
|
||||
"translate_degraded_title": "Çevrildi (düz) — cilalama adımı atlandı: {{reason}}",
|
||||
"budget_title": "Metin orijinalin %{{pct}}'si kadardır; daha yüksek hız veya daha kısa ifadeler kullanmayı düşünün",
|
||||
"text_title": "İmleçte bölmek için Ctrl+D · Sonrakiyle birleştirmek için Ctrl+M",
|
||||
"orig_label": "köken",
|
||||
@@ -939,6 +946,8 @@
|
||||
"fit_overflows_title": "Çevrilen metin orijinal alandan {{seconds}}s daha uzundu. Ses sert bir şekilde kesilmişti; metni kısaltın veya Zamanlamayı \"Videoyu Uzat\" olarak değiştirin.",
|
||||
"fit_stretched": "Video {{ratio}}×",
|
||||
"fit_stretched_title": "Uzatılmış Video modu: Bu bölümün videosu, doğal dub sese uyacak şekilde {{ratio}}× değerine yavaşlatıldı.",
|
||||
"fit_slowed": "yavaşlatıldı {{ratio}}×",
|
||||
"fit_slowed_title": "Dublajlı replik zaman aralığından kısa kaldı — {{ratio}}× yavaşlatıldı (perde korundu), böylece konuşma boşluk bırakmak yerine orijinal konuşma süresini kaplıyor",
|
||||
"fit_compressed_title": "TTS sesi yuvanın %{{pct}}'si kadardır ve yoğun şekilde sıkıştırılmıştır.",
|
||||
"fit_audio_title": "Ses yuvanın içine sığar.",
|
||||
"fit_ratio_title": "TTS sesi yuvanın %{{pct}} kadarıdır.",
|
||||
@@ -1728,6 +1737,7 @@
|
||||
"cinematic_no_llm": "Sinematik kalitenin bir LLM'ye ihtiyacı vardır - TRANSLATE_BASE_URL + TRANSLATE_API_KEY'i ayarlayın (Ollama yerel olarak çalışır). Hızlı'ya geri dönüyorum.",
|
||||
"dialect_not_applied": "Seçilen lehçe için LLM gerekir — motoru OpenAI/Ollama yapın veya Cinematic kalitesini seçin.",
|
||||
"translate_errors": "{{errorCount}}/{{totalCount}} segment(ler)i başarısız oldu: {{firstError}}",
|
||||
"translate_degraded": "{{totalCount}} segmentin tümü çevrildi — {{count}} tanesi cilalama adımı atlandığı için düz çeviriyi kullanıyor ({{reason}})",
|
||||
"translated_segments": "{{count}} segment(ler) çevrildi → {{lang}}",
|
||||
"translated_cinematic_suffix": "(Sinematik)",
|
||||
"translation_failed": "Çeviri başarısız oldu: {{message}}",
|
||||
|
||||
@@ -813,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}} (вибраний дубляж)",
|
||||
@@ -913,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": "ориг",
|
||||
@@ -939,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}}% слота.",
|
||||
@@ -1728,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}}",
|
||||
|
||||
@@ -813,6 +813,12 @@
|
||||
"burn_subs_title": "Hiển thị phụ đề trực tiếp vào luồng video MP4 (hardsub). Sử dụng định dạng phụ đề kép khi phụ đề kép được bật.",
|
||||
"timing_smart_fit": "Khớp thông minh",
|
||||
"timing_smart_fit_title": "Chia đôi gánh nặng: tăng nhẹ tốc độ âm thanh (giữ cao độ, tối đa 1,5×) và làm chậm nhẹ đoạn video đó (tối đa 2×) để lời thoại ở nhịp tự nhiên vừa khít. Phần vượt giới hạn sẽ bị cắt và được đánh dấu. Xuất sẽ mã hóa lại video.",
|
||||
"voice_match": "Khớp giọng:",
|
||||
"voice_match_title": "Khớp giọng — mỗi câu lồng tiếng được nhân bản từ đoạn nguồn của chính nó, hay mọi câu của một người nói dùng chung một tham chiếu.",
|
||||
"voice_match_per_line": "Theo câu",
|
||||
"voice_match_per_line_title": "Mỗi câu được nhân bản từ đoạn âm thanh nguồn của chính nó. Ngữ điệu khớp nhất theo từng câu, nhưng bản sắc giọng có thể trôi giữa các câu.",
|
||||
"voice_match_consistent": "Nhất quán",
|
||||
"voice_match_consistent_title": "Mọi câu của một người nói được nhân bản từ một tham chiếu chung (bản sao giọng người nói, hoặc đoạn đơn tốt nhất nếu không có). Bản sắc giọng ổn định hơn trong toàn bộ bản lồng tiếng.",
|
||||
"default_track": "Bản nhạc mặc định:",
|
||||
"original_track": "Bản gốc",
|
||||
"selected_dub": "{{code}} (Lồng tiếng được chọn)",
|
||||
@@ -913,6 +919,7 @@
|
||||
"rate_title": "Tốc độ nói phù hợp: {{ratio}}× so với vị trí{{error}}",
|
||||
"speaker_id": "ID người nói",
|
||||
"translate_error_title": "Lỗi dịch thuật: {{error}}",
|
||||
"translate_degraded_title": "Đã dịch (thô) — bước trau chuốt bị bỏ qua: {{reason}}",
|
||||
"budget_title": "Văn bản chiếm {{pct}}% so với bản gốc — hãy cân nhắc tốc độ cao hơn hoặc cụm từ ngắn hơn",
|
||||
"text_title": "Ctrl+D để phân chia tại con trỏ · Ctrl+M để hợp nhất với phần tiếp theo",
|
||||
"orig_label": "nguồn gốc",
|
||||
@@ -939,6 +946,8 @@
|
||||
"fit_overflows_title": "Văn bản được dịch dài hơn vị trí ban đầu {{seconds}}s. Âm thanh đã được cắt bớt cứng; rút ngắn văn bản hoặc chuyển Thời gian thành \"Căng video\".",
|
||||
"fit_stretched": "Video {{ratio}}×",
|
||||
"fit_stretched_title": "Chế độ Kéo dài video: video của phân đoạn này được giảm tốc độ xuống {{ratio}}× để phù hợp với âm thanh lồng tiếng tự nhiên.",
|
||||
"fit_slowed": "làm chậm {{ratio}}×",
|
||||
"fit_slowed_title": "Câu lồng tiếng ngắn hơn khung thời gian — làm chậm {{ratio}}× (giữ cao độ) để lời nói phủ hết thời gian thoại gốc thay vì để khoảng trống",
|
||||
"fit_compressed_title": "Âm thanh TTS chiếm {{pct}}% dung lượng khe — bị nén rất nhiều.",
|
||||
"fit_audio_title": "Âm thanh vừa vặn bên trong khe cắm.",
|
||||
"fit_ratio_title": "Âm thanh TTS chiếm {{pct}}% dung lượng khe.",
|
||||
@@ -1728,6 +1737,7 @@
|
||||
"cinematic_no_llm": "Chất lượng điện ảnh cần có LLM — đặt TRANSLATE_BASE_URL + TRANSLATE_API_KEY (Ollama hoạt động cục bộ). Trở lại Nhanh.",
|
||||
"dialect_not_applied": "Phương ngữ đã chọn cần LLM để áp dụng — chuyển Engine sang OpenAI/Ollama hoặc chọn chất lượng Cinematic.",
|
||||
"translate_errors": "{{errorCount}}/{{totalCount}} phân đoạn không thành công: {{firstError}}",
|
||||
"translate_degraded": "Đã dịch toàn bộ {{totalCount}} phân đoạn — {{count}} dùng bản dịch thô vì bước trau chuốt bị bỏ qua ({{reason}})",
|
||||
"translated_segments": "Đã dịch _V_0__ phân đoạn → {{lang}}",
|
||||
"translated_cinematic_suffix": "(Điện ảnh)",
|
||||
"translation_failed": "Dịch không thành công: {{message}}",
|
||||
|
||||
@@ -772,6 +772,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}}(选定配音)",
|
||||
@@ -872,6 +878,7 @@
|
||||
"rate_title": "语速适配:{{ratio}}× relative to slot{{error}}",
|
||||
"speaker_id": "说话人 ID",
|
||||
"translate_error_title": "翻译错误:{{error}}",
|
||||
"translate_degraded_title": "已翻译(直译)— 润色步骤被跳过:{{reason}}",
|
||||
"budget_title": "文本长度为原文的 {{pct}}% — 考虑加快语速或缩短措辞",
|
||||
"text_title": "Ctrl+D 在光标处拆分 · Ctrl+M 与下一段合并",
|
||||
"orig_label": "原文",
|
||||
@@ -898,6 +905,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}}%。",
|
||||
@@ -1735,6 +1744,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}}",
|
||||
|
||||
@@ -813,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}}(精選配音)",
|
||||
@@ -913,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": "原始",
|
||||
@@ -939,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}}%。",
|
||||
@@ -1728,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}}",
|
||||
|
||||
@@ -54,7 +54,11 @@ export default function AudiobookTab({ profiles = [] }) {
|
||||
const [planLoading, setPlanLoading] = useState(false);
|
||||
const [generating, setGenerating] = useState(false);
|
||||
const [progress, setProgress] = useState(null); // {current,total,title,assembling}
|
||||
const [output, setOutput] = useState('');
|
||||
// Store-backed (#1139): the finished render's filename used to be component
|
||||
// useState, so the player + Download link vanished on the first tab switch —
|
||||
// users reported "no way to export". It now survives tab switches/reloads.
|
||||
const output = useAppStore((s) => s.lastOutput);
|
||||
const setOutput = useAppStore((s) => s.setLastOutput);
|
||||
const [error, setError] = useState('');
|
||||
const [done, setDone] = useState(null); // {cached_chapters, failed_chapters}
|
||||
const [chapterPrev, setChapterPrev] = useState({}); // index → {url, loading}
|
||||
|
||||
@@ -23,6 +23,7 @@ export default function DubTab(props) {
|
||||
dubVideoFile,
|
||||
dubLocalBlobUrl,
|
||||
transcribeElapsed,
|
||||
transcribeProgress,
|
||||
translateProvider,
|
||||
setTranslateProvider,
|
||||
showTranscript,
|
||||
@@ -147,6 +148,8 @@ export default function DubTab(props) {
|
||||
const setBurnSubs = useAppStore((s) => s.setBurnSubs);
|
||||
const timingStrategy = useAppStore((s) => s.timingStrategy);
|
||||
const setTimingStrategy = useAppStore((s) => s.setTimingStrategy);
|
||||
const voiceMatch = useAppStore((s) => s.voiceMatch);
|
||||
const setVoiceMatch = useAppStore((s) => s.setVoiceMatch);
|
||||
|
||||
const showIdleSkeleton = !(
|
||||
dubJobId &&
|
||||
@@ -537,6 +540,7 @@ export default function DubTab(props) {
|
||||
dubPrepProgress={dubPrepProgress}
|
||||
handleDubAbort={handleDubAbort}
|
||||
transcribeElapsed={transcribeElapsed}
|
||||
transcribeProgress={transcribeProgress}
|
||||
dubDuration={dubDuration}
|
||||
dubNumSpeakers={dubNumSpeakers}
|
||||
setDubNumSpeakers={setDubNumSpeakers}
|
||||
@@ -659,6 +663,8 @@ export default function DubTab(props) {
|
||||
dubTracks={dubTracks}
|
||||
timingStrategy={timingStrategy}
|
||||
setTimingStrategy={setTimingStrategy}
|
||||
voiceMatch={voiceMatch}
|
||||
setVoiceMatch={setVoiceMatch}
|
||||
dubTranscript={dubTranscript}
|
||||
showTranscript={showTranscript}
|
||||
setShowTranscript={setShowTranscript}
|
||||
|
||||
@@ -20,6 +20,7 @@ import VoicePanel from '../components/settings/VoicePanel';
|
||||
import AppearancePanel from '../components/settings/AppearancePanel';
|
||||
import StoragePanel from '../components/settings/StoragePanel';
|
||||
import StorageTab from '../components/settings/StorageTab';
|
||||
import UsageTab from '../components/settings/UsageTab';
|
||||
import StorageUsagePanel from '../components/settings/StorageUsagePanel';
|
||||
import HFMirrorPanel from '../components/settings/HFMirrorPanel';
|
||||
import SharingPanel from '../components/settings/SharingPanel';
|
||||
@@ -393,6 +394,12 @@ export default function Settings() {
|
||||
return <TranslationTab />;
|
||||
case 'performance':
|
||||
return <PerformanceDeviceTab />;
|
||||
case 'usage':
|
||||
return (
|
||||
<>
|
||||
<UsageTab />
|
||||
</>
|
||||
);
|
||||
case 'storage':
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -307,7 +307,19 @@ export const createDubSlice: StateCreator<DubSlice, [], [], DubSlice> = (set, ge
|
||||
...(typeof incoming === 'string' && incoming.trim() ? { text: incoming } : {}),
|
||||
};
|
||||
});
|
||||
return { dubLangCode: code, dubSegments };
|
||||
// The dropdown paths each cleared a stale dialect by hand; doing it
|
||||
// here means EVERY caller (dropdown, multi-language loop, the Export
|
||||
// preview tabs) inherits the guard. Same predicate as
|
||||
// api/dialects.dialectMatchesLang, inlined to keep the store slice
|
||||
// dependency-free: a dialect "es-AR" only survives a switch to a
|
||||
// language whose base code it extends.
|
||||
const base = code.toLowerCase().split('-')[0];
|
||||
const dialectStillValid = !!s.dubDialect && s.dubDialect.toLowerCase().startsWith(`${base}-`);
|
||||
return {
|
||||
dubLangCode: code,
|
||||
dubSegments,
|
||||
...(dialectStillValid ? {} : { dubDialect: '' }),
|
||||
};
|
||||
}),
|
||||
setDubNumSpeakers: (v) => set((s) => ({ dubNumSpeakers: resolve(v, s.dubNumSpeakers) })),
|
||||
setDubDialect: (v) => set((s) => ({ dubDialect: resolve(v, s.dubDialect) })),
|
||||
|
||||
@@ -93,6 +93,7 @@ export const useAppStore = create<AppStore>()(
|
||||
showHeaderLiveStats: s.showHeaderLiveStats,
|
||||
timingStrategy: s.timingStrategy,
|
||||
fitOptions: s.fitOptions,
|
||||
voiceMatch: s.voiceMatch,
|
||||
// "What's new" affordance (feat/safe-updates) — remembering which
|
||||
// version's notes were seen only works if it survives restarts.
|
||||
whatsNewSeenVersion: s.whatsNewSeenVersion,
|
||||
@@ -145,6 +146,10 @@ export const useAppStore = create<AppStore>()(
|
||||
outputFormat: s.outputFormat,
|
||||
loudness: s.loudness,
|
||||
defaultVoice: s.defaultVoice,
|
||||
// Server filename of the last finished longform render (#1139) — a
|
||||
// plain /audio path (never a blob: URL), so rehydrating it is safe
|
||||
// and keeps the finished book's Download affordance reachable.
|
||||
lastOutput: s.lastOutput,
|
||||
projectMode: s.projectMode,
|
||||
// Donation prompt state (#007) — persist everything EXCEPT
|
||||
// `shownThisSession` so the ≤1/session cap resets on every launch.
|
||||
|
||||
@@ -86,6 +86,27 @@ describe('longformSlice — long-form fields', () => {
|
||||
expect(get().projectMode).toBe('stories');
|
||||
});
|
||||
|
||||
it('lastOutput survives as store state and clears on newProject (#1139)', () => {
|
||||
// The finished render's filename used to be AudiobookTab useState — the
|
||||
// Download affordance evaporated on the first tab switch.
|
||||
const { get } = harness();
|
||||
expect(get().lastOutput).toBe('');
|
||||
get().setLastOutput('audiobook_abc123.m4b');
|
||||
expect(get().lastOutput).toBe('audiobook_abc123.m4b');
|
||||
get().newProject('audiobook');
|
||||
expect(get().lastOutput).toBe(''); // a new book doesn't show the old file
|
||||
});
|
||||
|
||||
it('loadProject clears lastOutput — no cross-project leak (#1139 review)', () => {
|
||||
const { get } = harness();
|
||||
get().setScript('# Book B');
|
||||
get().saveProject('B');
|
||||
const idB = get().currentProjectId;
|
||||
get().setLastOutput('audiobook_from_a.m4b'); // pretend A rendered meanwhile
|
||||
get().loadProject(idB);
|
||||
expect(get().lastOutput).toBe(''); // loading B never presents A's render
|
||||
});
|
||||
|
||||
it('setProjectMeta MERGES; setLexicon REPLACES; setOutputPrefs merges', () => {
|
||||
const { get } = harness();
|
||||
get().setProjectMeta({ title: 'The Crown' });
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user