Compare commits

...
35 Commits
Author SHA1 Message Date
velixio e6f4c766f5 Merge remote-tracking branch 'origin/velixio' into velixio 2026-08-15 23:30:53 +05:30
velixio c4ea6a14b0 fix: require remote TTS render parity 2026-08-15 23:30:20 +05:30
velixio 751f04078d Revert "fix: preserve local-only voice generation"
This reverts commit 2926ce615a.
2026-08-15 23:25:11 +05:30
velixio 2926ce615a fix: preserve local-only voice generation 2026-08-15 23:15:40 +05:30
velixio 7e64d13739 Merge remote-tracking branch 'origin/main' into velixio 2026-08-15 22:28:00 +05:30
velixio 5151243ee4 fix: remove accent country flags 2026-08-15 22:28:00 +05:30
velixio eaee379dd5 fix: preserve seeded gallery renders in runtime adapter 2026-08-15 22:09:00 +05:30
velixio 0d81123954 fix: accept deterministic gallery seed 2026-08-15 21:31:02 +05:30
velixio ee35d2389e fix: require remote voice render parity 2026-08-15 18:49:08 +05:30
velixio 1fda5bdf96 fix: preserve voice identity on remote workers 2026-08-15 18:49:08 +05:30
Palash Debnath 2477dde688 docs: streamline README structure and specifications (#1560)
* docs: streamline README structure and specifications

* docs: address README review findings
2026-08-15 13:06:44 +00:00
velixio df2da4bb4d fix: attest immutable runtime model versions 2026-08-15 18:26:42 +05:30
velixio 37c8be6bfe Fix hosted job cancellation state 2026-08-15 15:13:46 +05:30
Palash DebnathandClaude Fable 5 48c9a3b1f8 feat(settings): compute-device override (auto / CUDA / ROCm / XPU / MPS / CPU) (#1557)
* feat(settings): compute-device override — auto | CUDA | ROCm | XPU | MPS | CPU

Auto-detect stays the default; the override kills the 'auto-detect picked
wrong' issue class. Applied at the single choke point (_probe()'s family
selection) so routing, get_best_device(), and every badge inherit it.
Resolution: OMNIVOICE_DEVICE env > Settings pick (prefs.json) > auto (#981
pattern). An override can steer, never invent hardware: a family the host
lacks is noted and ignored; cpu is always honorable. Applies at next
backend start (host caps are immutable per process — same restart contract
as the rest of the Performance tab, RestartBadge shown).

GET/PUT /api/settings/compute-device (admin-gated) reports resolved vs
applied so the panel shows restart-required truthfully and disables itself
under an env pin instead of pretending.

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

* docs(changelog): entry for the compute-device override (#1557)

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

* fix(device-override): harvest — the override reaches CT2 ASR, full i18n, honest edge states

- _ctranslate2_cuda_ok() and the ASR sidecar now gate on the probe's family,
  so a cpu pin (or ROCm host) can never hand CTranslate2 a CUDA device —
  the override reaches every CT2 loader through one shared gate
- override_ignored exposed by the API and shown by the panel (env pin naming
  a device this machine lacks: auto is in effect, restart won't change it)
- all 8 panel strings + 5 device-family labels translated into all 21
  locales; failed saves keep their error visible through the re-sync
- test isolation: cleanup drops OMNIVOICE_DEVICE before re-probing so no
  overridden caps leak into later tests; panel tests wait for loaded state
- xpu/intel search keywords; oxfmt formatting

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

* fix(device-override): round 2 — fail-safe probe fallbacks, complete i18n, combined pin state

- a broken capability probe now means CPU everywhere (CT2 gate + ASR
  sidecar) — never a torch-derived guess that would bypass a cpu pin or
  re-open #1529 on ROCm; regression test added
- env-pinned AND not-detected shows both facts in one subtitle
- device_load_failed/perf_save_failed translated into all 21 locales;
  CJK/th/vi/ar strings no longer say literal 'Auto'
- test_ctranslate2_never_gets_cuda_on_a_rocm_build pins the probe family
  (it was order-dependent on the lru_cache before)

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

* fix(test): pin the probe family in the faster-whisper OOM-fallback test

Same class as the rocm-build test: it mocked torch but not the probe the
new override gate consults first, so on a cpu-family CI host the CUDA
fallback chain under test was unreachable.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 05:08:51 +00:00
Palash DebnathandClaude Fable 5 030d5ea01f docs(engines): a guide for every engine + index; fix two engine-metadata bugs (#1556)
* docs(engines): a guide for every engine + index; fix two engine-metadata bugs

21 new pages under docs/engines/ (10 TTS, 10 ASR, index README) — every
registered engine now has one: what it's for, platform support, model env
vars, quirks with issue refs. Linked from both READMEs' engine sections.

Code fixes found while verifying facts against the registries:
- KittenTTS docstring claimed default voice 'Jasper'; the code default is
  expr-voice-2-f
- the isolated-ASR sidecar read only ASR_MODEL_FW while the download
  preflight read ASR_MODEL_FASTER — set one and the other quietly used a
  different model; both now resolve ASR_MODEL_FW-override → ASR_MODEL_FASTER
- moonshine's install hint named 'useful-moonshine', a package the backend
  never imports; now moonshine-onnx / moonshine-voice

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

* docs(changelog): entries for the engine guides + sidecar model fix (#1556)

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

* docs(engines): second-harvest fixes — TLS guidance, matrix/code alignment, CN counts

- README matrix aligned to gpu_compat (the code is the source of truth):
  CosyVoice macOS is CPU not MPS, IndexTTS and GGUF gain their real
  CUDA/CPU/MPS cells
- gpt-sovits guide: prefer https/tunnel for non-loopback servers, plaintext
  warning; first-use download guidance on both OmniVoice pages
- preflight empty-env fallback matches the sidecar (ASR_MODEL_FASTER='' no
  longer resolves a different repo)
- nano installs via uv pip; kitten log level wording; index links install
  guides incl. the Gatekeeper step; README_CN engine counts 16/11

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

* docs(readme-cn): the all-engines-local claim now excludes the remote client

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 03:55:59 +00:00
Palash DebnathandClaude Fable 5 b79ba9bd3b docs(readme): lead with download + first clone; seed benchmarks page (#1555)
* docs(readme): lead with download + first clone; seed benchmarks page

Quickstart (installers, install guides, a three-step first-clone walkthrough)
moves above What's-new/Features in both READMEs — visitors get the action
before the pitch. New docs/benchmarks.md anchors measured per-engine/device
numbers on the bench_pipeline.py harness, community-contributed, no estimates.

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

* docs(changelog): entry for the README conversion restructure (#1555)

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

* fix(bench): emit RTF + CUDA peak VRAM; guard NaN RAM; define the benchmarks schema

Bot harvest on #1555: the tts stage now prints RTF per warm measurement and
CUDA peak VRAM (None elsewhere — no made-up zeros), the stage floor refuses
unmeasurable RAM instead of sailing past a NaN comparison (FLOOR_GB=0
overrides), docs/benchmarks.md columns map 1:1 to what the harness prints,
and the download badges say they open the release page.

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

* docs(readme): link palash.dev from the maker section

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

* fix(bench): name the resolved engine, track VRAM from resolution, comment the guards

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

* docs(readme): the quick-switch gif is the hero image

The hero shows motion now; the Launchpad screenshot moves into the 0.5.0
What's-new slot so nothing appears twice.

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

* fix(bench): peak VRAM is reserved memory; adapter engines name their model

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

* fix(bench): subprocess-isolated engines report VRAM n/a, not a parent-side zero

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

* fix(bench): out-of-process detection is declarative; sherpa rows name their model

'runs_out_of_process' is now a TTSBackend attribute set by SubprocessBackend
AND omnivoice-gguf (which inherits TTSBackend directly but spawns a binary
per generate — the isinstance check missed it). Duck-typed for the same
module-purge reason as _is_subprocess_isolated. Sherpa-onnx identity comes
from _model_dir's basename when _model_id is absent.

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

* fix(bench): backends self-report model identity via TTSBackend.model_identity()

Greptile enumerated the adapter engines one at a time (mlx _model_id,
sherpa _model_dir, cosyvoice env-only) — the attribute sniffing rots per
engine. The hook fixes the class: each multi-model backend reports its
own identity, the profiler just asks.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 02:41:40 +00:00
velixio c818d235fb fix: preserve voice identity on remote workers 2026-08-15 02:42:13 +05:30
velixio 09ba4feb1c Merge remote-tracking branch 'upstream/main' 2026-08-15 01:46:29 +05:30
velixio 08791175f9 Merge branch 'feat/runtime-adapter' 2026-08-15 01:27:13 +05:30
velixio 4fa1b31eef Merge remote-tracking branch 'origin/main' into feat/runtime-adapter
# Conflicts:
#	CHANGELOG.md
#	README.md
#	backend/api/routers/archetypes.py
#	backend/tests/test_archetypes_api.py
#	frontend/src/api/types.ts
2026-08-15 00:30:48 +05:30
velixio 8654bb0225 fix: preserve gallery voice identity 2026-08-15 00:27:57 +05:30
Palash DebnathandClaude Fable 5 3d0c9605df test(shell): backend-lifecycle fault-injection harness (#1551)
* test(shell): backend-lifecycle fault-injection harness

Runs spawn_backend_and_wait/supervise_backend against REAL dying child
processes and asserts the user receives the correct NAMED diagnosis —
not merely that recovery happens. Wrong/missing explanation was 61% of
the historical "can't reach the backend" class; this rig is the
permanent regression harness for every future lifecycle fix.

Seam: OMNIVOICE_BACKEND_CMD (JSON argv or whitespace form) runs any
command as "the backend" — venv bootstrap and ffmpeg resolution are
skipped, everything else (err-log run offsets, drainer threads, env
pinning, real OS pipes, spawn-failure diagnostics) stays real. Plus
OMNIVOICE_LOG_DIR (per-test log+marker dirs, also a support tool) and
harness-only timing overrides OMNIVOICE_STARTUP_BUDGET_S /
OMNIVOICE_SUPERVISOR_POLL_MS whose production defaults are pinned by
unit tests. Lifecycle fns genericized over tauri::Runtime for the
MockRuntime app; behavior-neutral with the env unset (unit-pinned).

Scenarios (tests/backend_lifecycle.rs, scenario children = this test
binary re-invoking itself; serial by mutex + CI --test-threads=1):
- port conflict (exit 78) → the detectHints-matchable port phrasing
- generic chained traceback → root cause survives into the diagnosis
  and the crash marker
- spawn failure → spawn diagnostic reaches the user, NO bogus marker
- slow start past budget → timeout names the budget + last stderr
- post-Ready crash loop → 3 restarts announced, markers before restarts,
  "kept crashing" diagnosis naming the last exit
- SIGKILL (unix) → named as signal 9
- deliberate kill → supervisor yields silently, no marker, never Failed
- deferred-startup FATAL → the named step reaches the user, forensics,
  and the splash narration

CI: harness added to the 3-OS tauri-cross-platform matrix.

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

* chore(ci): temporary Windows loader bisect probe for the harness binary

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

* fix(shell): embed Common-Controls v6 manifest into Windows test binaries

Bisected on #1551: EVERY integration-test binary of this crate died at
load on Windows with STATUS_ENTRYPOINT_NOT_FOUND (0xc0000139) — cargo
gives test binaries no manifest, so the loader resolves comctl32 v5,
which lacks the TaskDialogIndirect entry point tauri's dialog/tray stack
imports. build.rs now embeds tests/windows-test.manifest via
rustc-link-arg-tests on Windows targets. Bisect probe removed.

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

* fix(test): scenario gate is PID-valued — the parent can't self-inject

CodeRabbit on #1551: in a parallel local `cargo test`, the parent's own
scenario_child test could observe the armed env and start playing the
backend in-process (binding the port, idling 600s). The gate value is
now the arming process's PID; a matching PID stays inert, so only the
spawned child — a different process — runs the scenario.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 18:45:27 +00:00
Palash DebnathandClaude Fable 5 bb813ff676 feat(startup): bind the socket in ~1s and narrate startup step by step (#1550)
* feat(startup): bind the socket in ~1s and narrate startup step by step

The structural fix for the "can't reach the local backend" class (~1 in 5
of every issue ever filed): uvicorn served nothing until torch import
(10-20s cold), the 30-router fan-out, an import-time DB migration, the
cuDNN preload, and alembic all finished — every slow or fragile step
rendered as an unexplained dead backend.

main.py now keeps module scope fast and defers the heavy work:
- _phase_a_build (executor thread): prefs/env restore + #963 migration,
  yt-dlp overlay, cuDNN preload, torchaudio, model_manager, router
  imports — order preserved, literal imports so PyInstaller still traces.
- _phase_a_finalize (event loop, no awaits → atomic wrt requests):
  include_router, mounts, MCP, SPA, openapi bust.
- _phase_b: the old lifespan startup body; handles on app.state so
  shutdown survives a startup that never finished.
- Eager mode (pytest / OMNIVOICE_EAGER_INIT=1) runs everything at import
  — byte-equivalent behavior for the ~100 lifespan-less TestClient sites
  and for embedders (dump_api_routes, probe boot runner opt in).

While starting: /health answers 503 with the current step, new
/startup/progress serves the full ledger (always 200), and
StartupGateMiddleware 503s everything else with the [starting] marker
(same skip-the-Report-button convention as [shutting_down]). A deferred
failure keeps import-crash semantics: traceback to stderr → shell crash
forensics, run sentinel stays uncleared, exit 1 names the failed step.

Shell: startup_progress() probe (marker-header-gated so a foreign
responder can't narrate the splash) feeds per-step log lines into the
launch poll and the supervisor's reconnect wait. --health-check absorbs
the deferred init (60→180s); --diagnose runs Phase A up front so it
still sees restored prefs. Docker HEALTHCHECK semantics unchanged
(curl -f fails on 503 exactly as it did on connection-refused).

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

* fix(startup): join the Phase A thread on shutdown; async fail-path sleep

Bot-review harvest on #1550: cancelling the deferred-startup task cannot
stop the executor thread inside Phase A's blocking imports — shutdown now
waits (bounded, only when a build started and hasn't finished) on a
thread-completion event so interpreter teardown can't race a mid-import
(#1000 class). The failure path's last-poll beat is now awaited, not
time.sleep — a blocking sleep froze the very loop that beat exists to let
serve. Also: dump_api_routes forces eager (assignment, not setdefault),
and the integration test's child gets DEVNULL instead of an undrained
pipe that could wedge a cold boot.

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

* fix(startup): close the Phase A submission race; CodeQL nits

Review finds on #1550: shutdown could sample _phase_a_started unset
while the executor callable was queued-but-not-running, skipping the
thread join. started is now set BEFORE submission, the submission is
shielded so a cancel can't strand a queued callable that would never set
_phase_a_finished, and the wrapper sets finished on every exit including
the already-built early return. Contract pinned by
test_phase_a_thread_join_contract. Plus explanatory comments on the new
bare excepts and a consistent return in the gate's websocket branch.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 15:23:02 +00:00
Palash DebnathandClaude Fable 5 bc6acec5a3 fix(shell): gate Ready on the deep health probe; pace crash-loop restarts (#1548)
* fix(shell): gate Ready on the deep health probe; pace crash-loop restarts

Two supervisor hardenings from the backend-reliability root-cause pass:

Ready now requires backend_ready() — the identity probe (/system/info
string-sniff) AND the deep probe (/profiles must 200) — at both Ready
transitions (startup poll, supervisor respawn wait). The shallow probe
alone announced a backend whose install/DB had broken underneath as up;
the UI looked alive while every real request 500'd or dead-ended on
"can't reach the backend". Death detection stays process-exit-only, so a
busy-but-alive backend is still never killed.

Supervisor respawns now back off: first respawn immediate (a one-off
crash self-heals fast), then 5s, then 15s, capped — the budget check
ends a hopeless loop, not an unbounded sleep. The pause runs behind the
already-visible "reconnecting" banner and yields within 500ms to app
quit or a deliberate retry-flow replace.

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

* fix(shell): yield backoff to a tracked replacement child, not just the flag

Greptile P1 on #1548: a completed Retry/Clean&Retry sets the deliberate-
kill flag and track_backend_child clears it — possibly both between two
500ms backoff samples, so the flag alone can be missed and the old
supervisor would free_port() the retry's healthy replacement. The dead
child we observed can never read as alive again, so a live tracked child
during backoff can only be a replacement — yield to it promptly so the
retry's spawn_backend_and_wait can claim the supervisor slot at Ready.

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

* fix(shell): backoff yields on spawn-generation change, not liveness

Second Greptile pass on #1548: a replacement child that itself exits
before the old supervisor's next 500ms sample read as "still dead" under
the liveness check, so ownership transfer was missed. The spawn
generation (bumped by every track_backend_child, never un-bumped) is
observable regardless of the replacement's fate — snapshot it at death
detection, yield the moment it changes.

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

* fix(shell): snapshot spawn generation before observing the exit

Third-pass review find: sampled after try_wait, a replacement tracked in
the gap bakes its own generation into the snapshot and the ownership
transfer is missed. Snapshot first, and re-check once more before
touching the port so the zero-backoff first respawn is covered too.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 14:41:21 +00:00
Palash DebnathandClaude Fable 5 94ba362ef2 feat(triage): crash-class recurrence report — the reliability metric (#1549)
* feat(triage): crash-class recurrence report — the reliability metric

scripts/crash_class_report.py measures the "backend died / never came
up" class (the project's #1 lifetime failure, ~1 in 5 of all issues)
filtered to reports from the current version — the definition of done
for the reliability cycle. Buckets by the bug reporter's Build-status
stamp (#1547): current / outdated / unknown (pre-deflection builds), so
deflection-miss noise never pollutes the number the work is judged on.

tests/scripts/test_crash_class_report.py pins the title→sub-class
mapping against the real historical title shapes and locks the stamp
literals to frontend/src/utils/bugReport.js so a reworded marker fails
in CI instead of silently zeroing the metric.

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

* fix(triage): --version is authoritative; loud fetch-cap warning

Bot-review harvest on #1549: with --version, the Environment Version
line now decides the bucket (extracted to pure classify_build + tests) —
a report stamped "current at filing time" during another version's
window no longer counts toward this version's recurrence. Hitting the
500-issue fetch cap now warns loudly instead of silently understating.
The stamp lockstep test asserts the full Build-status prefix.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 14:20:35 +00:00
Palash DebnathandClaude Fable 5 aabe5783f3 fix(report): offer the latest release before filing from an outdated build (#1547)
* fix(report): offer the latest release before filing from an outdated build

6 in 10 sampled "can't reach the backend" reports came from builds that
were already obsolete when filed, and were closed with "please update" —
pure triage noise. Every Report-bug affordance now funnels through
openBugReport(): on an outdated build it offers the latest release first
(with a "File anyway" escape hatch), and the report body carries a
triage-greppable "**Build status:**" line either way, so current-version
recurrence — the reliability metric — is countable separately from
stale-build reports.

Freshness sources per deployment (behavior identical, implementation per
mode): desktop reads the Rust updater's channel-aware verdict from the
store (no new network path, no CSP widening); browser/dev/Docker make one
bounded latest-release GET, only once the user has initiated the report
flow whose destination is github.com. An 'unknown' dev build stays
silent entirely — never nudged, never claimed current.

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

* fix(report): anchor version parsing; zh-TW reportBug.title in Traditional

Bot-review harvest on #1547: parseVersionTriple now rejects trailing
non-semver data (1.2.3.4, 1.2.3garbage) instead of silently reading the
leading triple into an outdated/current verdict; the pre-existing zh-TW
reportBug.title was Simplified-script — now properly Traditional.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 13:44:24 +00:00
Palash DebnathandGius 854b4852ed perf(frontend): coalesce persistence writes off input paths (#1546)
Defer and coalesce omnivoice.app and omni_ui persistence behind a 250 ms
quiet window with a 1,000 ms hard maximum, preserving storage schemas,
synchronous pending reads, legacy formats, Factory Reset semantics,
widget read-only ownership, and lifecycle (pagehide/visibilitychange)
durability. Adds scheduler, restore, reset, StrictMode, concurrent-render,
role-ownership, and migration regression tests plus an opt-in
production-bundle responsiveness harness.

Lands #1541 by @bultodepapas (maintainer landing branch; the out-of-scope
attribution-policy commit was dropped).

Co-authored-by: Gius <bultodepapas@gmail.com>
2026-08-14 13:25:36 +00:00
velixio 6111b8e4ae Merge remote-tracking branch 'origin/main' 2026-08-14 15:04:00 +05:30
velixio b1f322dde2 fix(studio): restore browser interactions 2026-08-14 02:30:23 +05:30
velixio 1a9a70509e feat(hosted): add opt-in voice adapters 2026-08-13 23:09:54 +05:30
velixio e877572c1a feat(runtime-adapter): load models before serving
A cold engine emits no execution evidence while it loads weights and compiles,
so the Gateway's attempt lease expires mid-load, the attempt is fenced, and the
next attempt pays the same cost — a loop that never produces audio. Loading
every READY model before the socket accepts work moves that cost to startup,
where preflight already expects to wait, so the first Execute begins inference
immediately. A prewarm failure is reported rather than fatal, and --no-prewarm
restores the previous behavior.
2026-08-13 21:13:13 +05:30
velixio 1f03f5632c perf(gallery): build previews concurrently and resume from disk
Rendering 1126 previews ran one clip at a time, and only the first of a
clip's five stages is on the GPU: render, then watermark embed, MP3 encode,
decode, and detection. The card idled through four CPU stages per clip.

The embed and detection are neural forward passes that ran inline on the
event loop, so they held it for the whole clip -- concurrency would have
queued behind a busy loop and bought nothing. They now go through
asyncio.to_thread, which is what makes threads the right tool here: torch
releases the GIL inside those passes, so there is no second model copy and no
IPC for the tensors. Clips then build --jobs at a time (4 by default, 1
restores the old serial behaviour) under a semaphore, because every clip in
flight holds decoded audio.

A lost watermark still stops the entire run rather than only its own clip.

--resume now also adopts MP3s already on disk. The manifest is written once,
at the end, so a run interrupted at clip 900 left 900 correct files that
--resume could not see and re-rendered every one of them. Everything an entry
needs -- sha256, byte length, duration, featured flag -- is recoverable from
the file and the catalog, so recover it.

Also add a watermark preflight. Every clip was already verified individually,
but only after the first full render, and the message blamed the bitrate when
the cause can be unrelated to audio: on a host without python3-dev, AudioSeal's
forward pass dies inside Inductor, embed_watermark catches it, and the clip is
returned unmarked. Two seconds up front, with the actual cause named. It also
warms the lazy generator/detector globals single-threaded, before --jobs fans
out.
2026-08-13 17:45:34 +05:30
velixio 4335c8c1ea test(runtime-adapter): cover the preflight contract and execute taxonomy
Fake-engine/fake-inventory tests over a real UDS gRPC server plus a fast
direct-executor path: health/capabilities shape and version identity, the
Go-preflight port passing with a READY model and failing closed without
one, only-READY-counts semantics, digest stability/sensitivity, execute
happy path (manifest checksum matches the written WAV), deadline
enforcement, cancel race with idempotent dispositions, slot exhaustion,
duplicate attempts, URL/relative handle rejection, checksum mismatch, and
the input/model-load/inference/GPU/storage failure classification.
2026-08-13 13:51:14 +05:30
velixio dcd8683f3a feat(runtime-adapter): implement the GPU-node runtime gRPC server
RuntimeAdapterService over a private Unix-domain socket (default
/run/voicestudio/runtime.sock, VOICE_STUDIO_RUNTIME_SOCKET override; no
HTTP, no TCP, no database, no outbound network):

- Health/GetCapabilities read one RuntimeContext, so runtime/adapter
  versions are identical across both calls by construction. Devices come
  from torch (CUDA per-GPU / MPS / CPU with system RAM as capacity);
  models come from the tts_backend engine registry + hf_revisions pinned
  revisions, digest-pinned via a cached sha256 snapshot digest. READY is
  explicit: probe passed, snapshot complete, digest computed — a
  loading/installed/failed model is reported truthfully, never READY.
- Execute streams started -> bounded progress -> exactly one terminal
  event, validates attempt identity, approved model digest, typed bounded
  parameters, and LOCAL absolute-path handles (URL-shaped handles are
  invalid input, never fetched), runs the engine on a worker thread,
  enforces the request deadline, and writes the output WAV atomically
  with a size/sha256/duration manifest plus raw measurements.
- Stable RTA_* failure codes map onto RuntimeFailureClass: input,
  model-load, inference, GPU-resource, local-storage, canceled, crash.
- Cancel is idempotent by attempt id (ACCEPTED / ALREADY_TERMINAL /
  NOT_FOUND) against a bounded attempt registry.
- python -m backend.runtime_adapter serves; --selfcheck starts a temp
  socket and runs a port of internal/gateway/preflight.go's checks
  against itself (verified passing on this host: 1 device, 2 ready
  digest-pinned models).
2026-08-13 13:51:03 +05:30
velixio b2f94d2bf8 feat(runtime-adapter): vendor the vssaas wire contract and committed stubs
Vendor api/proto/voicestudio/runtime/v1/runtime_adapter.proto from vssaas
byte-identically into backend/runtime_adapter/, generate the grpcio stubs
into gen/ (committed, same policy and import fixup as
backend/worker/protocol/gen/), and add the drift test that regenerates
into a tmpdir and diffs.
2026-08-13 13:37:56 +05:30
165 changed files with 13584 additions and 1209 deletions
+11
View File
@@ -271,6 +271,17 @@ jobs:
working-directory: frontend/src-tauri
run: cargo test --lib --target ${{ matrix.rust_target }} --message-format=short
# Backend-lifecycle fault-injection harness: real child processes die
# scripted deaths through the OMNIVOICE_BACKEND_CMD seam, and each
# scenario asserts the user-visible diagnosis names the actual cause
# (port conflict / traceback root cause / spawn failure / timeout /
# crash-loop exhaustion / signal 9 / deliberate replace / deferred-
# startup step). Serial: the scenarios share process-global state
# (env vars, crash store, kill-intended flag) by design.
- name: Cargo test (backend lifecycle harness)
working-directory: frontend/src-tauri
run: cargo test --test backend_lifecycle --target ${{ matrix.rust_target }} --message-format=short -- --test-threads=1
# ── 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
+26 -1
View File
@@ -6,6 +6,30 @@ The format is loosely based on [Keep a Changelog](https://keepachangelog.com/).
`frontend/package.json` is the app-version source of truth; Cargo, Python, and
the frozen-backend fallback mirror it for their toolchains.
## [Unreleased]
**Highlights**
- The backend now answers within a second of launch and narrates its startup step by step
- Reporting a bug from an outdated build now offers the latest release first
- The backend is only announced ready once it can actually serve, and crash-loop restarts now pace themselves
### Changed
- The backend binds its port immediately and reports startup progress live — `/health` answers 503-with-step and a new `/startup/progress` endpoint lists every step while PyTorch, API routes, and database migrations load in the background, so "starting at step X" is never mistakable for "dead"; the desktop splash narrates each step (#1550)
### Added
- The bug reporter notices when you're on an outdated build and offers the latest release before filing — with a "File anyway" escape hatch — and stamps a `Build status` line into every report so up-to-date reports are tellable from stale ones (#1547)
- Settings → Performance & Device gains a compute-device override (Auto / CUDA / ROCm / XPU / MPS / CPU, or `OMNIVOICE_DEVICE`) — pin the device when auto-detect picks wrong; only devices your machine actually has are offered (#1557)
### Docs
- The READMEs now lead with download buttons and a three-step first-clone walkthrough, and a new benchmarks page anchors measured per-engine/per-device numbers on the in-repo harness (#1555)
- Every engine now has its own guide — 21 new pages under docs/engines plus an index covering all 16 TTS and 11 ASR engines, linked from both READMEs (#1556)
### Fixed
- The crash-isolated ASR sidecar and its download preflight now agree on which model to load — setting the shared faster-whisper model variable applies to both variants instead of the sidecar quietly using a different one (#1556)
- "Ready" now requires the deep health probe (a working database-backed route), not just the identity probe — a backend whose install broke underneath can no longer be announced up while every real request fails (#1548)
- Supervisor restarts after repeat crashes now back off (immediate, then 5s, then 15s) instead of respawning back-to-back, so a tight crash loop can't burn the whole restart budget in seconds (#1548)
## [0.5.0] — 2026-08-13
**Highlights**
@@ -31,6 +55,7 @@ the frozen-backend fallback mirror it for their toolchains.
### Changed
- Gallery personas now preview through the local backend, retain their complete voice-design recipe, and open directly in Voice, Stories, or Audiobook. (#1542)
- Typing and large workspace edits no longer serialize and rewrite persisted documents on every input; writes are coalesced off the interaction path — thanks @bultodepapas! (#1541)
- Support amount choices now use every theme's shared card, accent and focus tokens. (#1530)
- Sponsoring, commercial licensing and getting in touch are one page now. They answered the same question between them and each used to live somewhere else, so they are three sections on a single scroll — the footer heart, the commercial-licence links and Contact all land on it, at the section you asked for. (#1522)
- Model Catalogue switches panes with tabs instead of a two-state toggle, and the Engine Compatibility Matrix's TTS / ASR / LLM switcher is now tabs too — arrow-key navigable, and each tab still shows the engine it would use. (#1522)
@@ -216,7 +241,7 @@ the frozen-backend fallback mirror it for their toolchains.
- The stdio wire protocol every engine sidecar speaks is now tested once across all nine of them, instead of against a single engine — a bug in any one sidecar's copy gets caught — thanks @paoloantinori! (#1408)
- Windows smoke tests stopped silently passing a broken ffmpeg install, and every smoke leg is now budgeted for a cold dependency install. (#1290)
- Test suites no longer leak config paths or model-manager shutdown state into one another, which had been failing unrelated pull requests. (#1269)
- The nightly preview build stopped refusing to publish its own healthy updater manifest when the macOS legs finished a few minutes ahead of the slowest one — Preview-channel users were silently left without new builds.
- The nightly preview build stopped refusing to publish its own healthy updater manifest when the macOS legs finished a few minutes ahead of the slowest one — Preview-channel users were silently left without new builds.
## [0.4.2] — 2026-07-28
+281 -447
View File
@@ -1,551 +1,385 @@
<div align="center">
<img src="docs/logo.png" alt="VoiceStudio Logo" width="120" height="120" />
<img src="docs/logo.png" alt="VoiceStudio logo" width="120" height="120" />
<h1>VoiceStudio</h1>
<p><sub><em>previously OmniVoice-Studio</em></sub></p>
<h3>Make voices. Tell stories. Keep the files. ♡</h3>
<p>Clone, design, dub, dictate, and build audiobooks in one open-source desktop studio.<br/><b>Local-first by default.</b> No subscription or usage meter. Optional online services stay opt-in.</p>
<p><sub>Previously OmniVoice-Studio</sub></p>
<h3>Local voice cloning, dubbing, dictation, and long-form audio.</h3>
<p>16 TTS engines · 11 ASR engines · 646-language catalogue · macOS, Windows, and Linux</p>
<p><strong>Local-first.</strong> No account, API key, subscription, or usage meter for the core workflow.</p>
<p>
<a href="#quickstart">Quickstart</a> ·
<a href="#install">Install</a> ·
<a href="#features">Features</a> ·
<a href="#why-voicestudio">Why VoiceStudio</a> ·
<a href="#tts-engines">Engines</a> ·
<a href="#openai-api">API</a> ·
<a href="#sponsor--donate">Donate</a> ·
<a href="#contributing">Contributing</a> ·
<a href="https://voicestudio.sh">Website</a> ·
<a href="https://voicestudio.sh/docs">Docs</a> ·
<a href="https://status.voicestudio.sh">Status</a> ·
<a href="https://discord.gg/bzQavDfVV9">Discord</a> ·
<a href="https://x.com/idebpalash">X</a> ·
<a href="#comparison">Compare</a> ·
<a href="#requirements">Requirements</a> ·
<a href="#engines">Engines</a> ·
<a href="#architecture">Architecture</a> ·
<a href="#api">API</a> ·
<a href="#documentation">Docs</a> ·
<a href="README_CN.md"><strong>简体中文</strong></a>
</p>
<p>
<a href="https://github.com/debpalash/VoiceStudio/stargazers"><img src="https://img.shields.io/github/stars/debpalash/VoiceStudio?style=flat-square&color=f59e0b" alt="Stars" /></a>
<a href="https://github.com/debpalash/VoiceStudio/stargazers"><img src="https://img.shields.io/github/stars/debpalash/VoiceStudio?style=flat-square&color=f59e0b" alt="GitHub stars" /></a>
<a href="https://github.com/debpalash/VoiceStudio/releases"><img src="https://img.shields.io/github/downloads/debpalash/VoiceStudio/total?style=flat-square&color=8b5cf6&label=downloads" alt="Total downloads" /></a>
<a href="https://github.com/debpalash/VoiceStudio/releases/latest"><img src="https://img.shields.io/github/v/release/debpalash/VoiceStudio?style=flat-square&color=10b981" alt="Release" /></a>
<a href="LICENSE"><img src="https://img.shields.io/badge/license-AGPL--3.0-blue?style=flat-square" alt="License" /></a>
<a href="https://github.com/debpalash/VoiceStudio/issues"><img src="https://img.shields.io/github/issues/debpalash/VoiceStudio?style=flat-square&color=ef4444" alt="Issues" /></a>
<a href="https://discord.gg/bzQavDfVV9"><img src="https://img.shields.io/badge/Discord-Join_Community-5865F2?style=flat-square&logo=discord&logoColor=white" alt="Discord" /></a>
<a href="https://x.com/idebpalash"><img src="https://img.shields.io/badge/X-Follow_for_updates-000000?style=flat-square&logo=x&logoColor=white" alt="Follow on X" /></a>
<a href="https://ko-fi.com/debpalash"><img src="https://img.shields.io/badge/Ko--fi-Support_Us-FF5E5B?style=flat-square&logo=ko-fi&logoColor=white" alt="Ko-fi" /></a>
<a href="https://paypal.me/palashCoder"><img src="https://img.shields.io/badge/PayPal-Donate-00457C?style=flat-square&logo=paypal&logoColor=white" alt="PayPal" /></a>
<a href="https://github.com/debpalash/VoiceStudio/releases/latest"><img src="https://img.shields.io/github/v/release/debpalash/VoiceStudio?style=flat-square&color=10b981" alt="Latest release" /></a>
<a href="LICENSE"><img src="https://img.shields.io/badge/license-AGPL--3.0-blue?style=flat-square" alt="AGPL-3.0 license" /></a>
<a href="https://discord.gg/bzQavDfVV9"><img src="https://img.shields.io/badge/Discord-Community-5865F2?style=flat-square&logo=discord&logoColor=white" alt="Discord community" /></a>
</p>
<p>
<a href="https://github.com/debpalash/VoiceStudio/releases/latest"><img src="https://img.shields.io/badge/⬇_Download-macOS_·_Windows_·_Linux-10b981?style=for-the-badge" alt="Download the latest release" /></a>
</p>
<p>
<a href="https://trendshift.io/repositories/28176?utm_source=trendshift-badge&utm_medium=badge&utm_campaign=badge-trendshift-28176" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/trendshift/repositories/28176/daily?language=Python" alt="debpalash%2FVoiceStudio | Trendshift" width="250" height="55"/></a>
<a href="https://github.com/debpalash/VoiceStudio/releases/latest"><img src="https://img.shields.io/badge/Download-macOS_·_Windows_·_Linux-10b981?style=for-the-badge" alt="Download VoiceStudio" /></a>
</p>
</div>
<br/>
<div align="center">
<img src="docs/screenshot-launchpad.png" alt="VoiceStudio — Launchpad" width="100%"/>
<img src="docs/media/0.5.0/quick-switch.gif" alt="Switching TTS engines from the VoiceStudio status bar" width="100%" />
</div>
> **Your voice is personal. Your studio should feel personal too.** VoiceStudio keeps its core workflow on your hardware: clone, design, dub, dictate, and publish in 646 languages without a subscription or usage meter. Network-backed engines and services are optional, visible choices—not hidden requirements.
> [!WARNING]
> **Active beta.** Things may break between releases — for the newest fixes, run from source. Bug reports and PRs are very welcome: [open an issue](https://github.com/debpalash/VoiceStudio/issues) or [join Discord](https://discord.gg/bzQavDfVV9).
> **Active beta.** Use the [latest release](https://github.com/debpalash/VoiceStudio/releases/latest) for stable work or `main` for current fixes. Report problems through [GitHub Issues](https://github.com/debpalash/VoiceStudio/issues).
<a id="whats-new"></a>
## At a glance
## 🆕 What's new in 0.5.0
| | VoiceStudio |
|---|---|
| **Workflows** | Voice cloning and design, video dubbing, dictation, stories, audiobooks, batch generation |
| **Language catalogue** | 646 TTS languages; actual coverage and quality depend on the selected engine |
| **Engines** | 16 TTS · 11 ASR · switch in Model Catalogue or with <kbd>Ctrl</kbd>/<kbd>Cmd</kbd>+<kbd>E</kbd> |
| **Platforms** | macOS 13.3+ on Apple Silicon · Windows 10/11 x64 · Linux x86_64 with glibc 2.39+ |
| **Compute** | CUDA · Apple Silicon MPS/MLX · ROCm on Linux · CPU · optional remote workers |
| **Interfaces** | Desktop app · local REST/SSE/WebSocket API · OpenAI-compatible audio API · MCP Server |
| **Storage** | Voices, projects, settings, and outputs stay on the machine by default |
| **License** | AGPL-3.0; optional engines keep their own model licenses |
The rename release — full notes: [v0.5.0 release](https://github.com/debpalash/VoiceStudio/releases/tag/v0.5.0) · [CHANGELOG](CHANGELOG.md).
<a id="install"></a>
- 🏷️ **A new name** — VoiceStudio (previously OmniVoice-Studio): one waveform-and-spark identity across app, docs, and installers. Your data folder, settings, and Docker image paths stay put.
- 📚 **Model Catalogue** — engines and models in one workspace: every TTS, ASR, and LLM engine with its device routing and install state; pick defaults, install or remove weights.
- ⚡ **Engine quick-switch** — change TTS/ASR/LLM engines from the status bar or anywhere with <kbd>Ctrl</kbd>/<kbd>Cmd</kbd>+<kbd>E</kbd> — ready-only choices, memory status, environment-pin protection.
- 🖧 **Remote GPU workers** — lend another machine's GPU with a join code and a QR scan; a **Compute** control picks where jobs run, and several people can share one GPU box over revocable, certificate-pinned connections.
- 🔐 **Hardened server mode** — admin actions require an API key, exchanged for short-lived scoped sessions that never sit in browser storage or WebSocket URLs.
- 💾 **Gallery voices → local profiles** — save any gallery voice as a profile of your own and use it in every picker.
- 🎤 **Dictation on Wayland** — the portal shortcut actually fires now, and the recording pill is back on every desktop.
## Install
<div align="center">
<img src="docs/media/0.5.0/quick-switch.gif" alt="Switching engines from the status bar" width="640"/>
<br/><sub>Engine quick-switch from the status bar — <kbd>Ctrl</kbd>/<kbd>Cmd</kbd>+<kbd>E</kbd> from any workspace</sub>
</div>
| Platform | Package | Guide |
|---|---|---|
| macOS 13.3+ | DMG, Apple Silicon | [Install on macOS](docs/install/macos.md) |
| Windows 10/11 | MSI, x64 | [Install on Windows](docs/install/windows.md) |
| Linux | AppImage, x86_64 with glibc 2.39+ | [Install on Linux](docs/install/linux.md) |
| Docker | CUDA, ROCm, or CPU | [Run with Docker](docs/install/docker.md) |
<br/>
Download packages from the [latest release](https://github.com/debpalash/VoiceStudio/releases/latest). First launch creates a managed Python environment and downloads the default model. Later launches reuse both.
<table>
<tr>
<td width="50%"><img src="docs/media/0.5.0/catalogue.png" alt="Model Catalogue — engines pane" width="100%"/></td>
<td width="50%"><img src="docs/media/0.5.0/gallery-save.png" alt="Saving a gallery voice as a profile" width="100%"/></td>
</tr>
<tr>
<td align="center"><sub><b>Model Catalogue</b> — every engine, its routing and install state</sub></td>
<td align="center"><sub><b>Gallery → profile</b> — keep a gallery voice as your own</sub></td>
</tr>
</table>
> [!NOTE]
> On macOS, first launch needs a one-time right-click → **Open** approval. Intel Macs cannot run the local Python backend; use a [remote backend](docs/install/macos.md) instead.
### First voice
1. Launch VoiceStudio and open **Voice Cloning**.
2. Add a clean voice sample. Three seconds works; 515 seconds usually gives a better prompt.
3. Enter text, choose a language, then select **Generate**.
### Run from source
Install the [development prerequisites](.github/CONTRIBUTING.md#development-setup), then:
```bash
git clone https://github.com/debpalash/VoiceStudio.git
cd VoiceStudio
bun install
bun run desktop
```
Use `bun run dev` for the browser UI. See [Contributing](.github/CONTRIBUTING.md) for services, tests, and platform packages.
### If setup fails
- Run **Settings → About → Run self-check** or `uv run python backend/main.py --diagnose --deep`.
- Check [install troubleshooting](docs/install/troubleshooting.md).
- Save a scrubbed diagnostic bundle from the app when opening an issue.
- For slow generation, compare [measured benchmarks](docs/benchmarks.md) and [performance settings](docs/performance.md).
<a id="features"></a>
## Features
## Features
Three flagships, five more headliners, and a dozen under the fold.
| Area | Included |
|---|---|
| **Voice Cloning** | Zero-shot synthesis from a short reference clip |
| **Voice Design** | Create a voice from age, accent, pitch, style, and delivery instructions |
| **Video Dubbing** | Transcribe, translate, preserve speakers, synthesize, and export video |
| **Stories and audiobooks** | Multi-voice scripts · EPUB/PDF import · chapter rendering · `.m4b` export |
| **Dictation Widget** | System-wide shortcut, live transcription, optional local-LLM cleanup |
| **Vocal Isolation** | Demucs speech/background separation |
| **Speaker Diarization** | Pyannote and WhisperX speaker assignment |
| **Batch Queue** | Queue large sets of audio and video jobs with per-job progress |
| **Model Catalogue** | Install, remove, select, and route TTS, ASR, and LLM models |
| **Remote Model Downloads** | Install models on enrolled remote workers with live progress |
| **GPU Auto-Detect** | CUDA, MPS, ROCm, and CPU routing with per-engine checks |
| **AI Watermark** | AudioSeal embedding and detection |
| **MCP Server** | Synthesis and transcription tools for MCP clients |
| **Diagnostics** | Self-checks, error journal, logs, and scrubbed support bundles |
| **Local-first** | Core creation stays local; network-backed features are explicit opt-ins |
| **Extensible** | Registry-based TTS, ASR, and plugin interfaces |
<table>
<tr>
<td width="33%"><img src="docs/features/clone.png" alt="Voice Cloning" width="100%"/></td>
<td width="33%"><img src="docs/features/design.png" alt="Voice Design" width="100%"/></td>
<td width="33%"><img src="docs/features/dub.png" alt="Video Dubbing" width="100%"/></td>
<td width="50%"><img src="docs/media/0.5.0/catalogue.png" alt="VoiceStudio Model Catalogue" width="100%" /></td>
<td width="50%"><img src="docs/media/0.5.0/gallery-save.png" alt="Saving a gallery voice as a local profile" width="100%" /></td>
</tr>
<tr>
<td align="center">🎙️ <b>Voice Cloning</b><br/><sub>3-sec clip → any voice · 646 languages · zero-shot</sub></td>
<td align="center">🎨 <b>Voice Design</b><br/><sub>Describe it — gender, age, accent, emotion</sub></td>
<td align="center">🎬 <b>Video Dubbing</b><br/><sub>Transcribe → translate → re-voice → MP4</sub></td>
<td align="center"><sub>Model Catalogue: engine, device, and install state</sub></td>
<td align="center"><sub>Gallery: save a shared voice as a local profile</sub></td>
</tr>
</table>
<table>
<tr>
<td align="center" width="20%">📖<br/><b>Audiobook</b><br/><sub>EPUB/PDF → .m4b, multi-voice cast</sub></td>
<td align="center" width="20%">🎭<br/><b>Stories</b><br/><sub>Multi-voice script editor</sub></td>
<td align="center" width="20%">⌨️<br/><b>Dictation Widget</b><br/><sub><kbd>⌘⇧Space</kbd> in any app</sub></td>
<td align="center" width="20%">🔐<br/><b>Local-first</b><br/><sub>Core creation stays on your machine</sub></td>
<td align="center" width="20%">🤖<br/><b>MCP Server</b><br/><sub>Use from Claude, Cursor, …</sub></td>
</tr>
</table>
<a id="comparison"></a>
<details>
<summary><b>…and 12 more</b> — catalogue, remote GPUs, isolation, diarization, batch, watermarking, and friends</summary>
## Comparison
<br/>
VoiceStudio trades managed cloud compute for local control. This is the practical difference:
- 📚 **Model Catalogue** — one workspace for every TTS/ASR/LLM engine and model: defaults, device routing, install or remove weights — and quick-switch engines from anywhere with <kbd>Ctrl</kbd>/<kbd>Cmd</kbd>+<kbd>E</kbd>.
- 🖧 **Remote GPU workers** — send jobs to GPUs on your other machines: join code + QR enrolment, Remote Model Downloads with per-worker live progress, chapter-by-chapter audiobook rendering with local fallback. Off by default; see [docs/remote-workers.md](docs/remote-workers.md).
- 🔊 **Vocal Isolation** — Demucs-powered: splits speech from music and keeps the background bed.
- 👥 **Speaker Diarization** — Pyannote + WhisperX auto-identify who said what.
- 📦 **Batch Queue** — drop 50 videos, walk away; per-job progress bars.
- 🛡️ **AI Watermark** — AudioSeal (Meta): invisible, survives compression.
- 🔬 **Diagnostics** — self-check suite, error journal, scrubbed diagnostic bundles.
- ⚡ **GPU Auto-Detect & Routing** — CUDA · MPS · ROCm (Linux, opt-in) · CPU; ≤8 GB VRAM auto-offloads; per-engine GPU preflight, no silent CPU fallback.
- 🧩 **Extensible** — subclass `TTSBackend`, add any engine in ~50 lines.
- 🎒 **Portable personas** — export voices as `.ovsvoice` bundles: identity + watermark.
- ♾️ **Unlimited TTS** — sentence-chunked generation, no length cap, streaming via WebSocket.
- 🧠 **Dictation + LLM** — local-LLM cleanup of transcripts, optional echo cancellation.
</details>
---
<a id="quickstart"></a>
## ⚡ Quickstart
<div align="center">
<a href="https://github.com/debpalash/VoiceStudio/releases/latest"><img src="https://img.shields.io/badge/macOS-DMG_(Apple_Silicon)-000?style=for-the-badge&logo=apple&logoColor=white" alt="Download macOS DMG" /></a>
<a href="https://github.com/debpalash/VoiceStudio/releases/latest"><img src="https://img.shields.io/badge/Windows-MSI_(x64)-0078D4?style=for-the-badge&logo=windows&logoColor=white" alt="Download Windows MSI" /></a>
<a href="https://github.com/debpalash/VoiceStudio/releases/latest"><img src="https://img.shields.io/badge/Linux-AppImage_(x64)-FCC624?style=for-the-badge&logo=linux&logoColor=black" alt="Download Linux AppImage" /></a>
<br/>
<sub><b>macOS:</b> first launch needs a one-time approval — right-click → <b>Open</b> (or System Settings → Privacy &amp; Security → <b>"Open Anyway"</b> on macOS 15). No Terminal needed. <a href="docs/install/macos.md#gatekeeper-quarantine">Why?</a> · <b>Intel Macs:</b> local backend unsupported (<a href="https://github.com/debpalash/VoiceStudio/issues/889">#889</a>) — <a href="docs/install/macos.md">details</a>.</sub>
</div>
**Install guide:** [🍎 macOS](docs/install/macos.md) · [🪟 Windows](docs/install/windows.md) · [🐧 Linux](docs/install/linux.md) · [🐳 Docker](docs/install/docker.md)
<details>
<summary><b>🧰 Troubleshooting · slow generation · HF tokens · restricted networks</b></summary>
<br/>
- **Something broke?** Run the self-check — **Settings → About → "Run self-check"** (or `uv run python backend/main.py --diagnose --deep`) — then the [top 10 install errors](docs/install/troubleshooting.md). **"Save diagnostic bundle"** packages scrubbed logs for a bug report.
- **Feels slow?** [docs/performance.md](docs/performance.md) — where the time goes and how to tune it.
- **Want breaths, laughter, emotion?** [docs/expressive-speech.md](docs/expressive-speech.md) — what each engine can do today.
- **HF tokens · diarization · download speed / mirrors:** [tokens](docs/setup/huggingface-token.md) · [diarization](docs/features/diarization.md) · [downloads](docs/downloading-models.md).
- **Coming from [Real-Time-Voice-Cloning](https://github.com/CorentinJ/Real-Time-Voice-Cloning)?** [Migration guide](docs/migration/real-time-voice-cloning.md).
</details>
---
<a id="why-voicestudio"></a>
## ⚖️ Why VoiceStudio
Cloud voice tools are convenient, but they put your workflow behind an account, a meter, and somebody else's infrastructure. VoiceStudio gives you a capable studio that runs on your hardware, with optional integrations when you choose them.
| | **ElevenLabs** | **VoiceStudio** |
| | **VoiceStudio** | **Typical hosted voice service** |
|---|---|---|
| **Pricing** | Subscription and usage limits | Free & open-source (AGPL-3.0) · [Commercial license](#license) for proprietary use |
| **Voice Cloning** | ✅ 3s clip | ✅ 3s clip, zero-shot |
| **Voice Design** | ✅ Gender, age | ✅ Gender, age, accent, pitch, style, dialect |
| **Audiobook / Stories** | ❌ | ✅ Full audiobook editor + multi-voice stories (EPUB/PDF import, .m4b export) |
| **Languages** | Plan/model dependent | **646** |
| **Video Dubbing** | ✅ Cloud-only | ✅ Fully local |
| **Data Privacy** | Audio is processed remotely | Core workflow runs locally; online services are explicit opt-ins |
| **API Keys** | Account required | Not needed for the local workflow |
| **GPU Support** | N/A (cloud) | CUDA · Apple Silicon · ROCm (Linux) · CPU — plus your other machines' GPUs as [remote workers](docs/remote-workers.md) |
| **Desktop App** | ❌ | ✅ macOS · Windows · Linux |
| **TTS Engines** | 1 | **16** — [full matrix](#tts-engines) |
| **ASR Engines** | 1 | **11** — [full lineup](#asr-engines) |
| **MCP Server** | ❌ | ✅ Use from Claude, Cursor, any MCP client |
| **Self-check** | ❌ | ✅ Diagnostics suite, error journal, scrubbed debug bundles |
| **Customizable** | ❌ Closed | ✅ Fork it, extend it, ship it |
| **Best fit** | Private, offline, self-hosted, or high-volume work | Fast setup without local model management |
| **Data path** | Local by default; remote features are opt-in | Audio and text are processed by the provider |
| **Cost model** | Free software; you supply the hardware | Subscription, credits, or metered API use |
| **Setup** | Install the app and model weights | Create an account and use the web app or API |
| **Performance** | Depends on your engine and hardware | Provider manages compute and scaling |
| **Offline use** | Yes, after required models are installed | Usually requires a network connection |
| **Customization** | Source, engines, models, API, and routing are open | Limited to provider options |
| **Maintenance** | You manage updates, disk, and compute | Provider manages infrastructure |
Professional-grade voice AI, minus the subscription and the cloud. Convinced? [Come build with us.](https://discord.gg/bzQavDfVV9)
<a id="requirements"></a>
---
## Requirements
## 🖥️ System Requirements
Requirements vary by engine. These values cover the default local workflow.
| | **Minimum** | **Recommended** |
|---|---|---|
| **OS** | Windows 10, macOS 13.3+ (Apple Silicon), Ubuntu 24.04+ (glibc 2.39+) | Any modern 64-bit OS |
| **OS** | Windows 10 x64 · macOS 13.3 Apple Silicon · Linux x86_64 with glibc 2.39+ | Current supported OS release |
| **RAM** | 8 GB | 16 GB+ |
| **VRAM (GPU)** | 4 GB (auto-offloads TTS to CPU) | 8 GB+ (NVIDIA RTX 3060+) |
| **Disk** | 10 GB free (models + cache) | 20 GB+ SSD |
| **Python** | 3.10+ (managed by `uv`) | 3.113.12 |
| **GPU** | Optional — CPU works | NVIDIA CUDA · Apple Silicon MPS · AMD ROCm (Linux only) |
| **Disk** | 10 GB free | 20 GB+ SSD |
| **GPU** | Optional; CPU mode is supported | NVIDIA CUDA or Apple Silicon |
| **VRAM** | 4 GB when using a GPU | 8 GB+; large optional engines need more |
| **Python from source** | 3.11+ | 3.113.12 |
> [!NOTE]
> **A GPU is optional** — the whole pipeline runs on CPU (just slower), and on ≤8 GB VRAM, TTS auto-offloads to CPU. Caveats: **AMD ROCm** is Linux-only + opt-in ([Linux](docs/install/linux.md#amd-gpu-rocm)) — Windows AMD/Ryzen AI is CPU-only ([Windows](docs/install/windows.md#gpu-support)); **macOS Intel** can't run the local backend, so point it at a remote one ([#889](https://github.com/debpalash/VoiceStudio/issues/889) · [macOS](docs/install/macos.md)).
ROCm is Linux-only and opt-in. Windows AMD/Ryzen AI uses CPU. Systems with limited VRAM offload work to CPU when required. See [performance](docs/performance.md), [benchmarks](docs/benchmarks.md), and [engine disk usage](docs/engines/disk-usage.md).
<a id="engines"></a>
## Engines
Engine support is capability-specific. Check cloning, language, platform, memory, and license before choosing one. Full setup guides: [docs/engines](docs/engines/README.md).
<a id="tts-engines"></a>
### 🗣️ TTS Engines
**16 engines, one picker.** VoiceStudio (default, 600+ languages) is always available; seven more are opt-in and auto-detected (CosyVoice 3, GPT-SoVITS, VoxCPM2, MOSS-TTS-Nano, KittenTTS, MLX-Audio, Sherpa-ONNX), plus eight lazy-installed opt-ins (IndexTTS 2.5, OmniVoice GGUF, OmniVoice subprocess, PocketTTS, Supertonic 3, MOSS-TTS-v1.5, dots.tts, Confucius4-TTS). Switch in **Model Catalogue → Engines** — or from anywhere with <kbd>Ctrl</kbd>/<kbd>Cmd</kbd>+<kbd>E</kbd>; the choice applies everywhere synthesis happens.
<details>
<summary><b>📊 The full matrix</b> — 16 engines × platform × clone/instruct × license</summary>
<br/>
### Text to speech
| Engine | Languages | Clone | Instruct | Linux | macOS ARM | Windows | License |
|--------|:---------:|:-----:|:--------:|:-----:|:---------:|:-------:|:-------:|
| **VoiceStudio** (default, powered by k2-fsa/OmniVoice) | 600+ | ✅ | ✅ | ✅ CUDA/CPU | MPS | CUDA/CPU | Built-in |
| **CosyVoice 3** | 9 + 18 dialects | ✅ | ✅ | CUDA/CPU | ✅ MPS | ✅ CUDA/CPU | Apache-2.0 |
| **GPT-SoVITS** | 5 | | — | CUDA/CPU | — | CUDA/CPU | MIT |
| **VoxCPM2** | 30 | ✅ | ✅ | ✅ CUDA/CPU | MPS | CUDA/CPU | Apache-2.0 |
| **MOSS-TTS-Nano** | 20 | | — | CUDA/CPU | CPU | CUDA/CPU | Apache-2.0 |
| **KittenTTS** | English | — | — | CPU | CPU | CPU | MIT |
| **MLX-Audio** (Kokoro, Qwen3-TTS, CSM, Dia, …) | Multi | Varies | Varies | | ✅ Native | | Varies |
| **Sherpa-ONNX** | 20+ | — | — | CUDA/CPU | CPU | CUDA/CPU | Apache-2.0 |
| **IndexTTS 2.5** ⚡ | ZH · EN · JA · ES · AR | | — | CUDA | — | CUDA | Bilibili model license¹ |
| **OmniVoice GGUF** ⚡ | 600+ | ✅ | ✅ | ✅ CPU | CPU | CPU | Built-in |
| **OmniVoice (subprocess)**² | 600+ | ✅ | ✅ | ✅ CUDA/CPU | MPS | CUDA/CPU | Built-in |
| **PocketTTS** (Kyutai) | EN · FR · DE · PT · IT · ES | | — | CPU | CPU | CPU | CC-BY-4.0 (gated |
| **Supertonic 3** ⚡ | 31 | — | — | CPU | CPU | CPU | OpenRAIL-M |
| **MOSS-TTS-v1.5** (8B) | 31 | | — | CUDA/CPU | CPU | CUDA/CPU | Apache-2.0 |
| **dots.tts** (2B) | 24 | | — | CUDA/CPU | CPU | | Apache-2.0 |
| **Confucius4-TTS** ⚡ | 14 | | — | CUDA/CPU | CPU | CUDA/CPU | Apache-2.0 |
|---|:---:|:---:|:---:|:---:|:---:|:---:|---|
| **VoiceStudio** (default, powered by k2-fsa/OmniVoice) | 600+ | Yes | Yes | CUDA/CPU | MPS | CUDA/CPU | [AGPL-3.0](LICENSE) app · [Apache-2.0](LICENSE-NOTICE.md) model |
| **CosyVoice 3** | 9 + 18 dialects | Yes | Yes | CUDA/CPU | CPU | CUDA/CPU | Apache-2.0 |
| **GPT-SoVITS** | 5 | Yes | — | CUDA/CPU | — | CUDA/CPU | MIT |
| **VoxCPM2** | 30 | Yes | Yes | CUDA/CPU | MPS | CUDA/CPU | Apache-2.0 |
| **MOSS-TTS-Nano** | 20 | Yes | — | CUDA/CPU | CPU | CUDA/CPU | Apache-2.0 |
| **KittenTTS** | English | — | — | CPU | CPU | CPU | MIT |
| **MLX-Audio** | Model-dependent | Varies | Varies | | MLX | | Varies |
| **Sherpa-ONNX** | 20+ | — | — | CUDA/CPU | CPU | CUDA/CPU | Apache-2.0 |
| **IndexTTS 2.5** ⚡ | ZH · EN · JA · ES · AR | Yes | — | CUDA/CPU | CPU | CUDA/CPU | Bilibili model license¹ |
| **OmniVoice GGUF** ⚡ | 600+ | Yes | Yes | CUDA/CPU | MPS/CPU | CUDA/CPU | [AGPL-3.0](LICENSE) app · [Apache-2.0](LICENSE-NOTICE.md) model |
| **OmniVoice (subprocess)** ⚡ | 600+ | Yes | Yes | CUDA/CPU | MPS | CUDA/CPU | [AGPL-3.0](LICENSE) app · [Apache-2.0](LICENSE-NOTICE.md) model |
| **PocketTTS** ⚡ | EN · FR · DE · PT · IT · ES | Yes | — | CPU | CPU | CPU | CC-BY-4.0, gated² |
| **Supertonic 3** ⚡ | 31 | — | — | CPU | CPU | CPU | OpenRAIL-M |
| **MOSS-TTS-v1.5** ⚡ | 31 | Yes | — | CUDA/CPU | CPU | CUDA/CPU | Apache-2.0 |
| **dots.tts** ⚡ | 24 | Yes | — | CUDA/CPU | CPU | | Apache-2.0 |
| **Confucius4-TTS** ⚡ | 14 | Yes | — | CUDA/CPU | CPU | CUDA/CPU | Apache-2.0 |
¹ IndexTTS 2.5 requires a separate written Bilibili license above 100 million
monthly active users or RMB 1 billion in annual revenue. Review its
[model license](https://huggingface.co/IndexTeam/IndexTTS-2.5/blob/main/LICENSE)
before enabling the optional sidecar.
Installed or registered on demand.
² **OmniVoice (subprocess)** is the same resident model as the default engine, run
in a crash-isolated child process: a wedged generation can be hard-killed and its
VRAM reclaimed. Opt-in for unattended synthesis and VRAM-tight MPS hosts —
[docs/engines/omnivoice-subprocess.md](docs/engines/omnivoice-subprocess.md).
¹ IndexTTS 2.5 requires a separate written Bilibili license above 100 million monthly active users or RMB 1 billion annual revenue. Review the [model license](https://huggingface.co/IndexTeam/IndexTTS-2.5/blob/main/LICENSE).
³ **PocketTTS** (Kyutai) is a fast, low-latency CPU engine with zero-shot cloning;
its gated model access and CC-BY-4.0 conditions are shown for review in-app before
first use.
² PocketTTS shows its gated-access and CC-BY-4.0 terms before first use.
GPT-SoVITS connects to `http://127.0.0.1:9880` by default. To use a server on
another machine, set `OMNIVOICE_GPTSOVITS_URL` to its credential-free
`http://` or `https://` origin and add that machine's CIDR to
`OMNIVOICE_TRUSTED_NETWORKS`; redirects and untrusted destinations are rejected.
> **CUDA** = GPU-accelerated · **MPS** = Apple Silicon Metal · **CPU** = runs everywhere, slower for large models · KittenTTS, MOSS-TTS-Nano, and PocketTTS run realtime on CPU · MLX-Audio is Apple Silicon only · ⚡ = lazy-registered (installed on first use)
>
> **Clone** matters beyond single-clip generation: Video Dubbing (and any Batch job with a pinned voice) needs reference-audio cloning to preserve speaker identity, so picking a Clone-less engine (KittenTTS, Sherpa-ONNX, Supertonic 3) as the active engine fails those jobs up front with an actionable message instead of silently falling back to VoiceStudio.
>
> **MOSS-TTS-v1.5** (8B, ~16 GB), **dots.tts** (2B, ~9 GB), and **Confucius4-TTS** are heavyweight opt-ins that run in their own isolated venv from a local clone. None claims Apple-Silicon MPS (CPU on Macs); dots.tts has no Windows path; Confucius4 wants CUDA (CPU works, ~17× realtime). Details: [MOSS-TTS-v1.5](docs/engines/moss-tts-v15.md) · [dots.tts](docs/engines/dots-tts.md) · [Confucius4-TTS](docs/engines/confucius4-tts.md).
</details>
Clone-less engines cannot preserve a reference speaker in dubbing or pinned-voice batch jobs. VoiceStudio rejects those jobs instead of silently changing engines. Heavy engines have separate memory and platform limits; check their engine guide first.
<a id="asr-engines"></a>
### 🎧 ASR Engines
### Speech to text
**11 engines** — they power dictation, video dubbing, and subtitles. **WhisperX** is the cross-platform default (~100 languages, word-level timing); the rest are opt-in and auto-detected. Switch in **Model Catalogue → Engines**. Ten run fully on-device; the eleventh (OpenAI-compatible) is an optional remote client for Qwen3-ASR or any compatible server.
| Engine | ID | Languages | Best fit |
|---|---|:---:|---|
| **WhisperX** (default) | `whisperx` | ~100 | Dubbing, subtitles, word-level timing |
| **Faster-Whisper** | `faster-whisper` | ~100 | General cross-platform transcription |
| **Faster-Whisper (isolated)** | `faster-whisper-isolated` | ~100 | Crash-isolated batch transcription |
| **MLX Whisper** | `mlx-whisper` | ~100 | Apple Silicon |
| **PyTorch Whisper** | `pytorch-whisper` | ~100 | CUDA, MPS, and CPU fallback |
| **Parakeet TDT** | `nemo-parakeet` | English + 25 EU | Fast CPU/CUDA transcription |
| **Parakeet TDT v3 (MLX)** | `parakeet-mlx` | 25 EU | Apple Silicon dictation and word timestamps |
| **Moonshine** | `moonshine` | English | Low-power, low-latency ONNX |
| **FunASR** | `funasr` | 50+ | VAD and inline diarization |
| **sherpa-onnx** (live dictation) | `sherpa-onnx-asr` | Model-dependent | Streaming CPU dictation |
| **OpenAI-compatible** ⚠️ remote | `openai-compat-asr` | Server-dependent | Qwen3-ASR or another compatible endpoint; audio leaves the machine |
<details>
<summary><b>📊 The full lineup</b> — 11 engines, what each is best at, and compute-type notes</summary>
WhisperX and Faster-Whisper retry with `int8` when efficient `float16` is unavailable. Pin `ASR_COMPUTE_TYPE=int8` or `float32` only if automatic selection still fails.
<br/>
<a id="architecture"></a>
| Engine | `OMNIVOICE_ASR_BACKEND` | Languages | Best for |
|--------|-------------------------|:---------:|----------|
| **WhisperX** (default) | `whisperx` | ~100 | Dubbing & subtitles — word-level timing via wav2vec2 forced alignment |
| **Faster-Whisper** | `faster-whisper` | ~100 | Fast transcription on Linux / macOS / Windows (CTranslate2) |
| **Faster-Whisper (isolated)** | `faster-whisper-isolated` | ~100 | Same as Faster-Whisper but crash-isolated in a subprocess — an ASR crash won't take down the app |
| **MLX Whisper** | `mlx-whisper` | ~100 | Native Apple Silicon speed (Apple MLX / Metal) |
| **PyTorch Whisper** | `pytorch-whisper` | ~100 | CUDA / CPU fallback via 🤗 Transformers (no cuDNN 8 needed) |
| **Parakeet TDT** | `nemo-parakeet` | English + 25 EU | SOTA accuracy at ~10× realtime even on CPU, auto language detection (NVIDIA NeMo, CUDA/CPU) |
| **Parakeet TDT v3 (MLX)** | `parakeet-mlx` | 25 EU | The Parakeet tier for Apple Silicon — word timestamps, ~2 GB unified memory, dictation-grade speed via MLX. Dictation prefers it automatically for its 25 European languages; other languages keep multilingual Whisper. |
| **Moonshine** | `moonshine` | English | Edge / low-latency, ONNX |
| **FunASR** | `funasr` | 50+ | All-in-one multilingual — built-in VAD + inline speaker diarization (SenseVoice) |
| **sherpa-onnx** (live dictation) | `sherpa-onnx-asr` | 25 EU + 90+ | Live, faster-than-real-time dictation — small streaming/offline ONNX models, CPU, identical on macOS / Windows / Linux. Picked per-model in **Settings → Voice**. |
| **OpenAI-compatible** ⚠️ remote | `openai-compat-asr` | Server-dependent | A path to **Qwen3-ASR** today (self-hosted server), any OpenAI-compatible transcription endpoint, or OpenAI's own API — configure + test in **Model Catalogue → Engines** (ASR tab). Audio leaves your machine to whatever server you point it at; see [docs/engines/openai-compatible-asr.md](docs/engines/openai-compatible-asr.md). |
## Architecture
> If Dubbing needs an ASR model that is not installed yet, it offers the recommended download in place, shows its progress, and retries transcription on the same job when the model is ready.
>
> **GPU without efficient float16?** On older NVIDIA GPUs (Maxwell/Pascal, GTX 16xx) or after a CTranslate2/cuDNN mismatch, the CTranslate2 ASR engines (WhisperX, Faster-Whisper) can't run `float16` and VoiceStudio automatically retries on `int8` — no config needed. If transcription still fails, pin the compute type with `ASR_COMPUTE_TYPE=int8` (or `float32` for CPU) and restart the backend.
</details>
---
## 🏗️ Architecture
A **Tauri v2** desktop shell (Rust) wraps a **React** UI and a bundled **Python/FastAPI** backend that runs as a local sidecar on `localhost:3900`. Every layer runs on your machine by default; the only network paths are the ones you opt into (remote GPU workers, a remote backend, or an OpenAI-compatible ASR endpoint).
```
┌────────────────────────────────────────────────────────────────────┐
│ Tauri v2 shell — Rust │
│ window state · global dictation hotkey · system tray · │
│ signed auto-updater (stable/preview) · single-instance · │
│ first-run bootstrap (installs uv + Python venv) · blank guard │
├────────────────────────────────────────────────────────────────────┤
│ Frontend — React + Vite │
│ Studio · Dub · Stories · Audiobook · Gallery · Catalogue · │
│ Dictation · Batch · Diagnostics — Zustand store · WS bus │
│ ▲ IPC / HTTP + WS │
├──────────────────────────┼─────────────────────────────────────────┤
│ Backend — FastAPI sidecar @ localhost:3900 │
│ 100+ REST endpoints · SSE + WebSocket streaming · │
│ SQLite + Alembic (omnivoice_data/) · OpenAI-compatible API │
├───────────┬───────────┬───────────┬───────────┬────────────────────┤
│ TTS ×16 │ ASR ×11 │ Demucs │ Pyannote │ AudioSeal │
│ clone / │ WhisperX │ vocal │ speaker │ watermark │
│ design │ +10 more │ isolation│ diariz. │ embed / detect │
├───────────┴───────────┴───────────┴───────────┴────────────────────┤
│ Engine routing — per-engine GPU preflight, no silent CPU fallback │
│ Hardware: CUDA · MPS · ROCm (Linux) · CPU (auto-detected) │
│ + optional remote GPU workers on your other machines │
└────────────────────────────────────────────────────────────────────┘
```text
Tauri v2 desktop shell (Rust)
│ IPC
React + Vite UI
│ HTTP · SSE · WebSocket on localhost:3900
FastAPI backend
├── TTS / ASR engine registries
├── dubbing / audio / long-form pipelines
├── OpenAI-compatible API and MCP server
└── SQLite + Alembic → omnivoice_data/
```
<a id="openai-api"></a>
| Layer | Path | Responsibility |
|---|---|---|
| Desktop shell | `frontend/src-tauri/` | Window lifecycle, tray, shortcuts, updater, sidecar bootstrap |
| Frontend | `frontend/src/` | React UI, Zustand state, API and event clients, i18n |
| API | `backend/api/` | REST routes, schemas, auth boundaries, streaming |
| Core services | `backend/services/` | Generation, dubbing, audio processing, persistence |
| Engines | `backend/engines/` | Isolated and optional engine adapters |
| Worker system | `backend/worker/` | Authenticated remote compute and job transport |
| Data | `omnivoice_data/` | Projects, voices, settings, logs, and SQLite state |
| Delivery | `scripts/`, `deploy/`, `.github/workflows/` | Development, packaging, containers, releases, CI |
## 🔌 OpenAI-compatible API
### Network boundary
<div align="center">
- The desktop talks to a loopback-only backend on `localhost:3900`.
- Loopback API calls need no server key. Remote access requires a share PIN or API key.
- Remote workers and OpenAI-compatible ASR are opt-in. The UI identifies when audio leaves the machine.
- Analytics is off until consent. If enabled, it sends allowlisted, content-free usage metadata—not text, audio, file names, or projects.
**Drop-in replacement for OpenAI / ElevenLabs audio.** One line — no key, no code changes:
<a id="api"></a>
## OpenAI-compatible API
Point an OpenAI-compatible audio client at the local backend:
```diff
- base_url="https://api.openai.com/v1"
+ base_url="http://localhost:3900/v1"
```
</div>
Your existing scripts, agents, and OpenAI/ElevenLabs SDK calls now run **locally** on whatever engine you have active. What the cloud can't do: `voice` takes **your own cloned-voice profile IDs**, and `model` can pin a **specific engine** per request.
| Endpoint | What it does |
| Endpoint | Purpose |
|---|---|
| `POST /v1/audio/speech` | TTS — text in; `mp3` / `opus` / `aac` / `flac` / `wav` / `pcm` out. `model`: `tts-1`/`tts-1-hd` (active engine) or a specific one (`voxcpm2`, `cosyvoice`, …). `voice`: a cloned profile ID, `default`, or an OpenAI name (`alloy`, …). `speed` supported. |
| `POST /v1/audio/transcriptions` | STT — audio file in; `json` / `text` / `verbose_json` / `srt` / `vtt` out (`verbose_json` adds word-level timings). `whisper-1` maps to your active ASR engine. |
| `GET /v1/audio/voices` | VoiceStudio extension — lists every voice profile and engine, so clients can discover your clones. |
**Speak with your own cloned voice:**
| `POST /v1/audio/speech` | TTS to `mp3`, `opus`, `aac`, `flac`, `wav`, or `pcm`; select a profile with `voice` and an engine with `model` |
| `POST /v1/audio/transcriptions` | STT to `json`, `text`, `verbose_json`, `srt`, or `vtt` |
| `GET /v1/audio/voices` | List local voice profiles and engines |
```python
from openai import OpenAI
client = OpenAI(base_url="http://localhost:3900/v1", api_key="none") # any string — nothing checks it
# Find your cloned voices: GET /v1/audio/voices lists profile IDs
client = OpenAI(base_url="http://localhost:3900/v1", api_key="local")
with client.audio.speech.with_streaming_response.create(
model="tts-1", voice="<profile-id>", input="Made on my own hardware.") as r:
r.stream_to_file("speech.wav")
# STT
print(client.audio.transcriptions.create(model="whisper-1", file=open("clip.wav", "rb")).text)
model="tts-1",
voice="<profile-id>",
input="Made on my own hardware.",
response_format="wav",
) as response:
response.stream_to_file("speech.wav")
```
Want the whole surface (100+ endpoints)? The full REST API reference is embedded in the app — **Settings → OpenAPI Reference** (Scalar-powered), or the `{}` button in the footer.
The full API reference is in **Settings → OpenAPI Reference**. For LAN, Tailscale, or proxy access, read [API authentication](docs/api-auth.md) before exposing the backend.
Calling the backend from **another machine** (LAN, Tailscale, behind a proxy)? It's loopback-only and unauthenticated by default; to reach it remotely you set a share PIN or an API key, and admin actions require the key — exchanged for short-lived scoped sessions. [docs/api-auth.md](docs/api-auth.md) covers the exact headers, query params, `401`/`403`/`429` meanings, and the `OMNIVOICE_TRUSTED_NETWORKS` exemption.
### Agent skills
### 📓 Run on Google Colab
Install the VoiceStudio skills for Claude Code, Codex, Cursor, and other [skills.sh](https://skills.sh)-compatible agents:
[![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/debpalash/VoiceStudio/blob/main/notebooks/OmniVoice_Studio_Colab.ipynb)
No local GPU? The [official notebook](notebooks/OmniVoice_Studio_Colab.ipynb) boots the full app — web UI included — on a free Colab T4, then walks the whole feature surface as a guided tour with inline playback. No tunnels, no API keys.
### 🤝 Agent Skills
Teach your coding agent to speak and listen through your local VoiceStudio — one command, works with **Claude Code, Codex, Cursor, Grok, Kimi, opencode**, and any [skills.sh](https://skills.sh)-compatible agent:
```sh
```bash
npx skills add debpalash/omnivoice-studio
```
Ships two skills: **`omnivoice`** — generate speech (including your cloned voices) and transcribe audio from any agent, free and fully offline — and **`oss-maintainer`** — the maintainer methodology this project is run with.
- `omnivoice`: synthesize speech and transcribe audio through local VoiceStudio.
- `oss-maintainer`: the repository's open-source maintenance workflow.
---
### Google Colab
<a id="roadmap"></a>
[![Open in Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/debpalash/VoiceStudio/blob/main/notebooks/OmniVoice_Studio_Colab.ipynb)
## 🗺️ Roadmap
The [notebook](notebooks/OmniVoice_Studio_Colab.ipynb) runs the app and web UI on a Colab GPU. Colab is remote compute, so uploaded audio and project data do not remain local to your machine.
What's up next (lip-sync v2, hosted demo, plugin marketplace, real-time voice changer) and the full history of everything shipped so far live in **[docs/ROADMAP.md](docs/ROADMAP.md)**.
<a id="documentation"></a>
---
## Documentation
<a id="sponsor--donate"></a>
| Need | Read |
|---|---|
| Install | [macOS](docs/install/macos.md) · [Windows](docs/install/windows.md) · [Linux](docs/install/linux.md) · [Docker](docs/install/docker.md) |
| Fix setup | [Troubleshooting](docs/install/troubleshooting.md) · [model downloads](docs/downloading-models.md) · [Hugging Face token](docs/setup/huggingface-token.md) |
| Choose an engine | [Engine guides](docs/engines/README.md) · [benchmarks](docs/benchmarks.md) · [expressive speech](docs/expressive-speech.md) |
| Tune hardware | [Performance](docs/performance.md) · [remote workers](docs/remote-workers.md) |
| Build integrations | [API auth](docs/api-auth.md) · [MCP](docs/mcp.md) · [examples](examples/README.md) |
| Build VoiceStudio | [Contributing](.github/CONTRIBUTING.md) · [engine acceptance](docs/engine-acceptance.md) |
| Track changes | [Changelog](CHANGELOG.md) · [roadmap](docs/ROADMAP.md) · [latest release](https://github.com/debpalash/VoiceStudio/releases/latest) |
| Remove everything | [Uninstall guide](docs/install/uninstall.md) |
## 💜 Sponsor / Donate
## FAQ
One developer, real AI-agent bills. If VoiceStudio is useful to you, chipping in keeps development full-time — every dollar goes straight to the bills.
<details>
<summary><strong>Does it work on Apple Silicon and Intel Macs?</strong></summary>
Apple Silicon is supported with MPS and MLX options. Intel Macs cannot run the local backend because current PyTorch wheels are unavailable; they can connect to a remote backend. See [macOS installation](docs/install/macos.md).
</details>
<details>
<summary><strong>How much VRAM do I need?</strong></summary>
A GPU is optional. Use 4 GB VRAM as the minimum for accelerated work and 8 GB+ for the default multi-stage workflow. Large optional engines can require 1216 GB or more. Check the [benchmarks](docs/benchmarks.md) and engine guide.
</details>
<details>
<summary><strong>Why does a longer reference clip not always improve the clone?</strong></summary>
Cloning is zero-shot: the clip is a prompt, not training data. Use 515 seconds of one speaker, close to the microphone, without music, noise, or reverb. Match the tone and pace you want in the output. For training, see [data preparation](docs/data_preparation.md) and [training](docs/training.md).
</details>
<details>
<summary><strong>Can I use generated audio commercially?</strong></summary>
Yes under VoiceStudio's AGPL-3.0 terms. Optional engines and model weights may use different licenses; review the selected engine's license before commercial use.
</details>
<details>
<summary><strong>Does VoiceStudio collect data?</strong></summary>
Not unless you opt in. Analytics is off by default and skipping consent keeps it off. When enabled, the app sends allowlisted, content-free usage metadata. Text, audio, file names, voices, and projects are excluded. Change this at **Settings → Privacy**.
</details>
<details>
<summary><strong>How do I remove VoiceStudio and its data?</strong></summary>
Use `scripts/uninstall.sh` on macOS/Linux or `scripts\uninstall.ps1` on Windows. Both show a dry run before deletion. See the [uninstall guide](docs/install/uninstall.md) for every path.
</details>
## Community and contributing
- [GitHub Issues](https://github.com/debpalash/VoiceStudio/issues) for reproducible bugs and feature requests.
- [Discord](https://discord.gg/bzQavDfVV9) for setup help and project discussion.
- [Good first issues](https://github.com/debpalash/VoiceStudio/labels/good%20first%20issue) for a scoped starting point.
- [Contributing guide](.github/CONTRIBUTING.md) for setup, tests, and pull requests.
## Support development
VoiceStudio is free and has no paid tier. Donations fund development and infrastructure.
[Ko-fi](https://ko-fi.com/debpalash) · [PayPal](https://paypal.me/palashCoder) · [Sponsorship details](SPONSORS.md)
## License
VoiceStudio is licensed under [AGPL-3.0](LICENSE). You may run it, modify it, use it internally, and sell generated audio. If you modify VoiceStudio and provide that modified version as a network service, AGPL requires you to offer the corresponding source under the same license. A commercial license is available for proprietary embedding; contact **VoiceStudio@palash.dev**. See [LICENSE-NOTICE.md](LICENSE-NOTICE.md) for the plain-language scope.
Optional engines and downloaded models retain their own licenses. The bundled `omnivoice/` model remains Apache-2.0 upstream.
## Acknowledgments
VoiceStudio builds on [OmniVoice](https://github.com/k2-fsa/OmniVoice), [WhisperX](https://github.com/m-bain/whisperX), [Demucs](https://github.com/facebookresearch/demucs), [Pyannote](https://github.com/pyannote/pyannote-audio), [CTranslate2](https://github.com/OpenNMT/CTranslate2), [AudioSeal](https://github.com/facebookresearch/audioseal), [Tauri](https://tauri.app), [Supertonic](https://huggingface.co/Supertone/supertonic-3), [Sherpa-ONNX](https://github.com/k2-fsa/sherpa-onnx), [GPT-SoVITS](https://github.com/RVC-Boss/GPT-SoVITS), and [PocketTTS](https://kyutai.org).
<div align="center">
<img src="https://img.shields.io/badge/raised_%2410_of_%24200-5%25-EAB308?style=for-the-badge" alt="This month's agent-bill fund: $10 / $200" />
<br/><br/>
<a href="https://ko-fi.com/debpalash"><img src="https://img.shields.io/badge/Ko--fi-Support_❤️-FF5E5B?style=for-the-badge&logo=ko-fi&logoColor=white" alt="Ko-fi" /></a>
&nbsp;&nbsp;
<a href="https://paypal.me/palashCoder"><img src="https://img.shields.io/badge/PayPal-Donate-00457C?style=for-the-badge&logo=paypal&logoColor=white" alt="PayPal" /></a>
</div>
<a id="sponsors"></a>
### 🌟 Sponsors
VoiceStudio is **free** and **AGPL-3.0** — no paid tier, no SaaS revenue. Sponsors keep development going, and in return get a logo slot here, in the app, and (for top tiers) on the project website. It's a thank-you, never a paywall. **[See tiers & become a sponsor →](SPONSORS.md)**
<div align="center">
<!-- SPONSORS:START — logo slots are filled here as sponsors come aboard; see SPONSORS.md -->
**Your logo here** — [become a sponsor](SPONSORS.md)
<!-- SPONSORS:END -->
</div>
---
## 💬 Community
<div align="center">
<a href="https://discord.gg/bzQavDfVV9"><img src="https://img.shields.io/badge/💬_Discord-Join_Community-5865F2?style=for-the-badge&logo=discord&logoColor=white" alt="Join Discord" /></a>
<a href="https://x.com/idebpalash"><img src="https://img.shields.io/badge/𝕏_Follow-for_updates-000000?style=for-the-badge&logo=x&logoColor=white" alt="Follow on X" /></a>
<br/>
<sub>Release news, setup help, GPU troubleshooting, feature votes, and showing off your dubs. We respond to setup questions within hours, not days.</sub>
</div>
---
<a id="contributing"></a>
## 🤝 Contributing
Yes please — bug fixes, new TTS engine adapters, UI improvements, docs, translations. All of it. Start with the **[Contributing Guide](.github/CONTRIBUTING.md)** (setup, code style, PR workflow), browse [good first issues](https://github.com/debpalash/VoiceStudio/labels/good%20first%20issue), or ask in [Discord](https://discord.gg/bzQavDfVV9).
---
## ❓ FAQ
<details>
<summary><b>Does it work on Apple Silicon (M1/M2/M3/M4)?</b></summary>
<br/>
Yes. MPS acceleration is auto-detected. MLX-optimized Whisper models are available for faster transcription on Apple hardware. <b>Intel Macs are not supported</b>: the app UI installs, but the local Python backend cannot run because PyTorch no longer ships Intel-Mac wheels (<a href="https://github.com/debpalash/VoiceStudio/issues/889">#889</a>) — an Intel Mac can only be used with a remote backend.
</details>
<details>
<summary><b>How much VRAM do I need?</b></summary>
<br/>
<b>4 GB minimum.</b> With ≤8 GB, the TTS model is automatically offloaded to CPU during transcription. With 8+ GB, everything runs on GPU simultaneously. No GPU at all? CPU mode works — just slower (~3× for TTS). You can also lend a GPU from another machine you own via <a href="docs/remote-workers.md">remote workers</a>.
</details>
<details>
<summary><b>What languages are supported?</b></summary>
<br/>
646 languages for TTS via the VoiceStudio model. Transcription (WhisperX) supports 99 languages. Translation coverage depends on the target language pair.
</details>
<details>
<summary><b>Why doesn't a longer reference clip sound more like me?</b></summary>
<br/>
Because VoiceStudio's cloning is <b>zero-shot</b>: your clip is a <i>prompt</i> the model conditions on — it is never trained on, and past a short window extra audio is simply unused (the dubbing pipeline targets ~8 s and hard-caps at 15 s). <b>What moves clone quality is the clip, not its length</b>: record 515 seconds of continuous natural speech, close to the mic, in a quiet room with no reverb or music, one speaker, delivered in the tone and pace you want — the clone copies your delivery, not just your timbre. Want trained-on-your-voice fidelity? That's offline fine-tuning, not an in-app button: <a href="docs/data_preparation.md">docs/data_preparation.md</a> + <a href="docs/training.md">docs/training.md</a>.
</details>
<details>
<summary><b>Can I use this commercially?</b></summary>
<br/>
<b>Yes — commercial use is free</b> under the <a href="https://www.gnu.org/licenses/agpl-3.0.html">AGPL-3.0</a>: run it, sell the audio you make, dub client videos, deploy it across your team. One obligation: if you <b>modify</b> VoiceStudio and offer the modified version to others over a network, you must share that modified source under the same terms. Embedding it in a closed-source product instead? A commercial license is available — see <a href="#license">License</a>.
</details>
<details>
<summary><b>Can I add my own TTS engine?</b></summary>
<br/>
Yes. Subclass <code>TTSBackend</code> in <code>backend/services/tts_backend.py</code> and add it to the <code>_REGISTRY</code> dictionary — ~50 lines. The sixteen built-in engines all work this way; see <a href="#tts-engines">TTS Engines</a> and <a href="docs/engine-acceptance.md">docs/engine-acceptance.md</a>.
</details>
<details>
<summary><b>Does VoiceStudio collect any data about me?</b></summary>
<br/>
<b>Not unless you explicitly say yes.</b> On first run the app <i>asks</i> — one screen, two equal-weight buttons, no pre-ticked box — and until you answer yes, VoiceStudio sends nothing: no analytics, no telemetry, no accounts, no phone-home. Skipping the question means no. Your text, audio, voices, and projects never leave your machine either way.
If you do opt in (also togglable anytime under <b>Settings → Privacy → "Help improve VoiceStudio"</b>), what's sent is anonymous, content-free usage stats: generations (engine, language, generation time, character <i>count</i>, error <i>type</i>), plus app lifecycle — an install ping, updates (version-to-version), crashes (error class and a <i>bucketed</i> uptime, never logs), error <i>types</i> (capped, deduplicated), and a single uninstall ping if you remove it. Never your text, audio, file names, or anything identifying — enforced in code by a property allowlist (<code>backend/core/analytics.py</code>), not just a promise. Every build — installer, Docker, or built from source — asks the same first-run question and stays off unless you say yes. Your own numbers live in <b>Settings → Usage</b>, computed locally, sent nowhere.
</details>
<details>
<summary><b>How do I uninstall it / remove all its data?</b></summary>
<br/>
VoiceStudio is fully local — uninstalling is just deleting the app plus the folders it wrote (model cache, Python env, your voices/projects, config). Run <code>scripts/uninstall.sh</code> (macOS/Linux) or <code>scripts\uninstall.ps1</code> (Windows) — it prints every folder with its size as a dry-run first, then deletes on <code>--yes</code>. The full per-platform path list and app-removal steps are in <a href="docs/install/uninstall.md"><b>docs/install/uninstall.md</b></a>.
</details>
---
<a id="license"></a>
## 📜 License
VoiceStudio is free and open-source software under the [**GNU Affero General Public License v3.0 (AGPL-3.0)**](https://www.gnu.org/licenses/agpl-3.0.html).
**Free for any use — including commercial and internal business use.** Run it, sell the audio you produce with it, dub your own or clients' videos, roll it out across your team — all free, no license needed. As a **network copyleft** license, AGPL adds one obligation: if you **modify** VoiceStudio and offer that modified version to others over a network, you must make the complete corresponding source of your modified version available to them under the same AGPL-3.0 terms.
A **commercial license** is available for organizations that want to embed VoiceStudio in a **closed-source or proprietary** product or service without the AGPL-3.0 copyleft obligations. **Pricing tiers coming soon.** Inquiries: **VoiceStudio@palash.dev**.
The bundled `omnivoice/` TTS model by Han Zhu remains Apache-2.0 upstream. See [`LICENSE`](LICENSE) for the full, binding terms, and [`LICENSE-NOTICE.md`](LICENSE-NOTICE.md) for the plain-language summary and scope.
---
## 🙏 Acknowledgments
VoiceStudio stands on exceptional open-source work: [OmniVoice (k2-fsa)](https://github.com/k2-fsa/OmniVoice) — the core zero-shot TTS model · [WhisperX](https://github.com/m-bain/whisperX) · [Demucs](https://github.com/facebookresearch/demucs) · [Pyannote](https://github.com/pyannote/pyannote-audio) · [CTranslate2](https://github.com/OpenNMT/CTranslate2) · [AudioSeal](https://github.com/facebookresearch/audioseal) · [Tauri](https://tauri.app) · [Supertonic](https://huggingface.co/Supertone/supertonic-3) · [Sherpa-ONNX](https://github.com/k2-fsa/sherpa-onnx) · [GPT-SoVITS](https://github.com/RVC-Boss/GPT-SoVITS) · [Kyutai PocketTTS](https://kyutai.org) — thank you.
<a id="more-from-the-maker"></a>
### 🧰 More local open-source from the maker
[**Opal** 💠](https://github.com/debpalash/Opal) — play everything: the media player for the AI era · [**memxt** 🧠](https://github.com/debpalash/memxt) — local long-term memory for coding agents. Same rule: **your data stays on your machine.**
---
<div align="center">
<br/>
If you read this far, you're our kind of person.<br/>
**[⭐ Star this repo](https://github.com/debpalash/VoiceStudio)** so others can find it too.<br/>
**[💬 Join the Discord](https://discord.gg/bzQavDfVV9)** to share what you build.<br/>
**[❤️ Support development](https://ko-fi.com/debpalash)** — fund the AI agent bills that keep VoiceStudio shipping.
<br/>
<a href="https://star-history.com/#debpalash/VoiceStudio&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=debpalash/VoiceStudio&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=debpalash/VoiceStudio&type=Date" />
<img alt="Star History" src="https://api.star-history.com/svg?repos=debpalash/VoiceStudio&type=Date&theme=dark" width="600" />
</picture>
</a>
<strong><a href="https://github.com/debpalash/VoiceStudio/releases/latest">Download VoiceStudio</a></strong> ·
<a href="https://github.com/debpalash/VoiceStudio">Star the project</a> ·
<a href="https://discord.gg/bzQavDfVV9">Join Discord</a>
</div>
+59 -52
View File
@@ -37,7 +37,7 @@
<br/>
<div align="center">
<img src="docs/screenshot-launchpad.png" alt="VoiceStudio — 启动台" width="100%"/>
<img src="docs/media/0.5.0/quick-switch.gif" alt="VoiceStudio — 从状态栏快速切换 TTS 引擎" width="100%"/>
</div>
> **声音很私人,创作空间也应该真正属于你。** VoiceStudio 的核心流程运行在你的硬件上:克隆、设计、配音、听写,并以 646 种语言创作,不需要订阅,也没有用量计费。联网引擎和服务始终是清晰可见的可选项,而不是隐藏依赖。
@@ -45,6 +45,56 @@
> [!WARNING]
> **活跃 Beta 阶段。** 各版本之间可能出现故障——如需最新修复,请从源码运行。非常欢迎 Bug 报告和 PR:[提交 Issue](https://github.com/debpalash/VoiceStudio/issues) 或 [加入 Discord](https://discord.gg/bzQavDfVV9)。
<a id="quickstart"></a>
## ⚡ 快速开始
<div align="center">
<a href="https://github.com/debpalash/VoiceStudio/releases/latest"><img src="https://img.shields.io/badge/macOS-DMG_(Apple_Silicon)-000?style=for-the-badge&logo=apple&logoColor=white" alt="下载 macOS DMG" /></a>
<a href="https://github.com/debpalash/VoiceStudio/releases/latest"><img src="https://img.shields.io/badge/Windows-MSI_(x64)-0078D4?style=for-the-badge&logo=windows&logoColor=white" alt="下载 Windows MSI" /></a>
<a href="https://github.com/debpalash/VoiceStudio/releases/latest"><img src="https://img.shields.io/badge/Linux-AppImage_(x64)-FCC624?style=for-the-badge&logo=linux&logoColor=black" alt="下载 Linux AppImage" /></a>
<br/>
<sub>三个按钮都会打开最新发布页——在资源列表中下载对应你系统的安装包。</sub><br/>
<sub><b>macOS</b>首次启动需要一次性批准——右键点击 → <b>打开</b>macOS 15 上为 系统设置 → 隐私与安全性 → <b>“仍要打开”</b>)。无需终端。<a href="docs/install/macos.md#gatekeeper-quarantine">为什么?</a> · <b>Intel Mac</b>不支持本地后端(<a href="https://github.com/debpalash/VoiceStudio/issues/889">#889</a>)——<a href="docs/install/macos.md">详情</a>。</sub>
</div>
选择你的操作系统,按指南从头到尾操作:
- 🍎 **macOS** — [docs/install/macos.md](docs/install/macos.md)
- 🪟 **Windows** — [docs/install/windows.md](docs/install/windows.md)
- 🐧 **Linux** — [docs/install/linux.md](docs/install/linux.md)
- 🐳 **Docker** — [docs/install/docker.md](docs/install/docker.md) · [Docker Hub: `palashdeb/omnivoice-studio`](https://hub.docker.com/r/palashdeb/omnivoice-studio)
**三步克隆出你的第一个声音:**
1. **安装并启动。** 首次启动会自动搭建 Python 运行环境并下载模型权重——启动画面会逐步显示进度(仅首次,需要几分钟;之后即开即用)。
2. 从启动台打开**语音克隆**,拖入任意声音的 **3 秒音频**
3. **输入一句话,点击生成。** 音频完全属于你——在你的设备上生成和保存,支持 646 种语言。
觉得慢?[docs/performance.md](docs/performance.md) 讲清了生成时间到底花在哪里、有哪些调优开关,以及“它变慢了”的三个经典原因。各引擎/设备的实测数据见 [docs/benchmarks.md](docs/benchmarks.md)。
> 正在从 **[CorentinJ/Real-Time-Voice-Cloning](https://github.com/CorentinJ/Real-Time-Voice-Cloning)**(现已归档)迁移过来?我们有专门的迁移指南:[docs/migration/real-time-voice-cloning.md](docs/migration/real-time-voice-cloning.md)。
<details>
<summary><b>🧰 卡住了?自检、Token 与受限网络</b></summary>
<br/>
先运行内置自检——在应用中打开 **设置 → 关于 → “运行自检”**,或在源码检出目录中执行
`uv run python backend/main.py --diagnose`(加 `--deep` 还会实际加载当前引擎进行测试)。然后查看
[docs/install/troubleshooting.md](docs/install/troubleshooting.md) 中排名前
10 的安装错误。运行时出错时,应用内的错误界面会直接深链到对应条目;**设置 → 关于 →
“保存诊断包”** 会把脱敏日志与自检报告打包,方便附在 Bug 报告里。
Hugging Face Token 的配置见
[docs/setup/huggingface-token.md](docs/setup/huggingface-token.md)。说话人分离相关的模型访问门槛见
[docs/features/diarization.md](docs/features/diarization.md)。下载速度、⚡ 快速下载(Xet)状态,以及受限网络 / 镜像选项见
[docs/downloading-models.md](docs/downloading-models.md)。
</details>
---
<a id="features"></a>
## ✨ 功能
@@ -112,49 +162,6 @@
---
<a id="quickstart"></a>
## ⚡ 快速开始
<div align="center">
<a href="https://github.com/debpalash/VoiceStudio/releases/latest"><img src="https://img.shields.io/badge/macOS-DMG_(Apple_Silicon)-000?style=for-the-badge&logo=apple&logoColor=white" alt="下载 macOS DMG" /></a>
<a href="https://github.com/debpalash/VoiceStudio/releases/latest"><img src="https://img.shields.io/badge/Windows-MSI_(x64)-0078D4?style=for-the-badge&logo=windows&logoColor=white" alt="下载 Windows MSI" /></a>
<a href="https://github.com/debpalash/VoiceStudio/releases/latest"><img src="https://img.shields.io/badge/Linux-AppImage_(x64)-FCC624?style=for-the-badge&logo=linux&logoColor=black" alt="下载 Linux AppImage" /></a>
<br/>
<sub><b>macOS</b>首次启动需要一次性批准——右键点击 → <b>打开</b>macOS 15 上为 系统设置 → 隐私与安全性 → <b>“仍要打开”</b>)。无需终端。<a href="docs/install/macos.md#gatekeeper-quarantine">为什么?</a> · <b>Intel Mac</b>不支持本地后端(<a href="https://github.com/debpalash/VoiceStudio/issues/889">#889</a>)——<a href="docs/install/macos.md">详情</a>。</sub>
</div>
选择你的操作系统,按指南从头到尾操作:
- 🍎 **macOS** — [docs/install/macos.md](docs/install/macos.md)
- 🪟 **Windows** — [docs/install/windows.md](docs/install/windows.md)
- 🐧 **Linux** — [docs/install/linux.md](docs/install/linux.md)
- 🐳 **Docker** — [docs/install/docker.md](docs/install/docker.md) · [Docker Hub: `palashdeb/omnivoice-studio`](https://hub.docker.com/r/palashdeb/omnivoice-studio)
觉得慢?[docs/performance.md](docs/performance.md) 讲清了生成时间到底花在哪里、有哪些调优开关,以及“它变慢了”的三个经典原因。
> 正在从 **[CorentinJ/Real-Time-Voice-Cloning](https://github.com/CorentinJ/Real-Time-Voice-Cloning)**(现已归档)迁移过来?我们有专门的迁移指南:[docs/migration/real-time-voice-cloning.md](docs/migration/real-time-voice-cloning.md)。
<details>
<summary><b>🧰 卡住了?自检、Token 与受限网络</b></summary>
<br/>
先运行内置自检——在应用中打开 **设置 → 关于 → “运行自检”**,或在源码检出目录中执行
`uv run python backend/main.py --diagnose`(加 `--deep` 还会实际加载当前引擎进行测试)。然后查看
[docs/install/troubleshooting.md](docs/install/troubleshooting.md) 中排名前
10 的安装错误。运行时出错时,应用内的错误界面会直接深链到对应条目;**设置 → 关于 →
“保存诊断包”** 会把脱敏日志与自检报告打包,方便附在 Bug 报告里。
Hugging Face Token 的配置见
[docs/setup/huggingface-token.md](docs/setup/huggingface-token.md)。说话人分离相关的模型访问门槛见
[docs/features/diarization.md](docs/features/diarization.md)。下载速度、⚡ 快速下载(Xet)状态,以及受限网络 / 镜像选项见
[docs/downloading-models.md](docs/downloading-models.md)。
</details>
---
<a id="why-voicestudio"></a>
## 💡 为什么选择 VoiceStudio
@@ -173,8 +180,8 @@ Hugging Face Token 的配置见
| **API 密钥** | 需要账号 | 本地流程不需要 |
| **GPU 支持** | 不适用(云端) | CUDA · Apple Silicon · ROCmLinux)· CPU |
| **桌面应用** | ❌ | ✅ macOS · Windows · Linux |
| **TTS 引擎** | 1 | **14** — [完整矩阵](#tts-engines) |
| **ASR 引擎** | 1 | **10** — [完整阵容](#asr-engines) |
| **TTS 引擎** | 1 | **16** — [完整矩阵](#tts-engines) |
| **ASR 引擎** | 1 | **11** — [完整阵容](#asr-engines) |
| **MCP 服务器** | ❌ | ✅ 可从 Claude、Cursor 及任何 MCP 客户端使用 |
| **自检** | ❌ | ✅ 诊断套件、错误日志、脱敏调试包 |
| **可定制** | ❌ 闭源 | ✅ 随你 Fork、扩展、发布 |
@@ -214,10 +221,10 @@ Hugging Face Token 的配置见
### 🗣️ TTS 引擎
**14 个引擎,一个选择器。** VoiceStudio(默认,支持 600+ 语言)始终可用;另有七个引擎可选装并自动检测(CosyVoice 3、GPT-SoVITS、VoxCPM2、MOSS-TTS-Nano、KittenTTS、MLX-Audio、Sherpa-ONNX),外加个按需延迟安装的重量级引擎(IndexTTS 2.5、OmniVoice GGUF、Supertonic 3、MOSS-TTS-v1.5、dots.tts、Confucius4-TTS)。在 **设置 → TTS 引擎** 中切换;所选引擎将应用于所有语音合成场景。
**16 个引擎,一个选择器。** VoiceStudio(默认,支持 600+ 语言)始终可用;另有七个引擎可选装并自动检测(CosyVoice 3、GPT-SoVITS、VoxCPM2、MOSS-TTS-Nano、KittenTTS、MLX-Audio、Sherpa-ONNX),外加个按需延迟安装的引擎(IndexTTS 2.5、OmniVoice GGUF、OmniVoice 子进程版、PocketTTS、Supertonic 3、MOSS-TTS-v1.5、dots.tts、Confucius4-TTS)。在 **设置 → TTS 引擎** 中切换;所选引擎将应用于所有语音合成场景。**每个引擎都有独立指南:[docs/engines](docs/engines/README.md)(英文)。**
<details>
<summary><b>📊 完整矩阵</b>——14 个引擎 × 平台 × 克隆/指令 × 许可证</summary>
<summary><b>📊 完整矩阵</b>——16 个引擎 × 平台 × 克隆/指令 × 许可证</summary>
<br/>
@@ -254,10 +261,10 @@ Hugging Face Token 的配置见
### 🎧 ASR 引擎
**10 个引擎**——它们驱动听写、视频配音和字幕。**WhisperX** 是跨平台的默认引擎(约 100 种语言,词级时间对齐);其余引擎均为可选装并自动检测。在 **设置 → 引擎** 中切换。个完全在本地设备上运行;第十个(OpenAI 兼容)是可选的远程客户端,可用于 Qwen3-ASR 或任何兼容的服务器。
**11 个引擎**——它们驱动听写、视频配音和字幕。**WhisperX** 是跨平台的默认引擎(约 100 种语言,词级时间对齐);其余引擎均为可选装并自动检测。在 **设置 → 引擎** 中切换。个完全在本地设备上运行;第十个(OpenAI 兼容)是可选的远程客户端,可用于 Qwen3-ASR 或任何兼容的服务器。
<details>
<summary><b>📊 完整阵容</b>——10 个引擎、各自的强项与计算类型说明</summary>
<summary><b>📊 完整阵容</b>——11 个引擎、各自的强项与计算类型说明</summary>
<br/>
@@ -274,7 +281,7 @@ Hugging Face Token 的配置见
| **sherpa-onnx**(实时听写) | `sherpa-onnx-asr` | 25 种欧洲语言 + 90+ | 实时、快于实时的听写——小体积流式/离线 ONNX 模型(Parakeet TDT v3/v2、流式 Zipformer 与 Paraformer、Whisper Tiny),CPU 运行,macOS / Windows / Linux 表现完全一致。在 **设置 → 语音** 中按模型选择。 |
| **OpenAI 兼容** ⚠️ 远程 | `openai-compat-asr` | 取决于服务器 | 当下通往 **Qwen3-ASR** 的路径(自托管服务器,无需等 transformers 支持)、任何 OpenAI 兼容的转录端点,或 OpenAI 官方 API——无需安装,在 **设置 → 引擎**(ASR 标签页)中配置并测试连接。音频会离开你的设备,发送到你指定的任何服务器;参见 [docs/engines/openai-compatible-asr.md](docs/engines/openai-compatible-asr.md)。 |
> Whisper 系列引擎覆盖约 100 种语言;**FunASR / SenseVoice** 额外提供一条多语言一体化路径,内置语音活动检测与行内说话人分离。**sherpa-onnx** 驱动实时听写的模型选择器——你边说,文字边出现。每个引擎都在本地设备上运行——无需 API 密钥,无需云端。
> Whisper 系列引擎覆盖约 100 种语言;**FunASR / SenseVoice** 额外提供一条多语言一体化路径,内置语音活动检测与行内说话人分离。**sherpa-onnx** 驱动实时听写的模型选择器——你边说,文字边出现。除可选的 OpenAI 兼容远程客户端外,所有引擎都在本地设备上运行——无需 API 密钥,无需云端。
> **GPU 不支持高效 float16** 在较老的 NVIDIA GPUMaxwell/Pascal、GTX 16xx)上,或在 CTranslate2/cuDNN 版本不匹配之后,CTranslate2 系 ASR 引擎(WhisperX、Faster-Whisper)无法运行 `float16`VoiceStudio 会自动改用 `int8` 重试——无需配置。如果转录仍然失败,可用 `ASR_COMPUTE_TYPE` 环境变量固定计算类型(逃生舱口):`ASR_COMPUTE_TYPE=int8`CPU 用 `float32`)。将其设为 `int8` 并重启后端。
@@ -574,7 +581,7 @@ VoiceStudio 站在这些杰出开源工作的肩膀上:
## 🧰 来自同一作者的更多本地开源项目
喜欢这种本地优先的理念?它是一脉相承的——同一位作者,同一条准则:**你的数据只留在你的设备上。**
喜欢这种本地优先的理念?它是一脉相承的——同一位作者,同一条准则:**你的数据只留在你的设备上。** 全部项目见 [palash.dev](https://palash.dev)。
<table>
<tr>
+33
View File
@@ -1146,6 +1146,9 @@ async def generate_speech(
# classic flow, so streaming is purely a delivery channel — engine-agnostic
# (text-level chunking, no per-engine token streaming).
stream: bool = Form(False),
# Explicit opt-in. The absence of this field preserves the local-first
# /generate contract even when an administrator configured hosted values.
hosted: bool = Form(False),
):
# #502: NFC-normalize the input text so decomposed (NFD) diacritics — common
# in pasted Vietnamese and other Latin-with-marks text — are composed to the
@@ -1156,6 +1159,36 @@ async def generate_speech(
import unicodedata
text = unicodedata.normalize("NFC", text)
if hosted:
# Hosted execution accepts only a previously, explicitly synchronized
# consent-verified profile. Never silently sync a local recording from
# a synthesis request: that would make normal offline use an upload.
if not profile_id:
raise HTTPException(status_code=422, detail="Hosted synthesis requires a synchronized voice profile.")
from services.hosted_voice_api import HostedSettings, HostedVoiceClient, HostedVoiceError
try:
settings = HostedSettings.from_environment()
except HostedVoiceError as exc:
raise HTTPException(status_code=503, detail=str(exc)) from exc
if settings is None:
raise HTTPException(status_code=409, detail="Hosted synthesis is not configured on this device.")
with db_conn() as conn:
profile = conn.execute("SELECT hosted_voice_id, language FROM voice_profiles WHERE id=?", (profile_id,)).fetchone()
if not profile:
raise HTTPException(status_code=404, detail="Voice profile not found")
if not profile["hosted_voice_id"]:
raise HTTPException(status_code=422, detail="Sync this consent-verified profile to hosted before hosted synthesis.")
client = HostedVoiceClient(settings)
try:
audio = await client.synthesize(
text=text, profile_voice_id=profile["hosted_voice_id"], language=language or profile["language"],
)
except HostedVoiceError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
finally:
await client.aclose()
return StreamingResponse(io.BytesIO(audio), media_type="audio/wav", headers={"X-OmniVoice-Execution": "hosted"})
# ── Engine resolution (issue #312) ──────────────────────────────────────
# The request runs on the engine selected in Settings (POST /engines/select,
# env var OMNIVOICE_TTS_BACKEND wins), or an explicit per-request `engine`
+45
View File
@@ -14,6 +14,7 @@ from core import event_bus
from core.personalities import get_personalities
from omnivoice.utils.voice_design import heal_design_instruct, sanitize_instruct
from core.path_security import UnsafePath, resolve_within
from services.hosted_voice_api import HostedSettings, HostedVoiceClient, HostedVoiceError
router = APIRouter()
@@ -184,6 +185,50 @@ def get_profile(profile_id: str):
return dict(row)
@router.post("/profiles/{profile_id}/hosted-sync")
async def sync_profile_to_hosted(profile_id: str):
"""Explicitly copy a consent-verified local clone to the hosted library.
This is deliberately not part of local profile creation: merely creating a
profile must never upload biometric source audio. The hosted service records
the existing spoken-consent evidence as its versioned attestation; it does
not receive the consent recording itself.
"""
try:
settings = HostedSettings.from_environment()
except HostedVoiceError as exc:
raise HTTPException(status_code=503, detail=str(exc)) from exc
if settings is None:
raise HTTPException(status_code=409, detail="Hosted voice sync is not configured on this device.")
with db_conn() as conn:
row = conn.execute(
"SELECT id, name, description, ref_text, ref_audio_path, verified_own_voice, consent_text, hosted_voice_id "
"FROM voice_profiles WHERE id=?", (profile_id,)
).fetchone()
if not row:
raise HTTPException(status_code=404, detail="Profile not found")
if row["hosted_voice_id"]:
return {"profile_id": profile_id, "hosted_voice_id": row["hosted_voice_id"], "state": "already_synced"}
if not row["verified_own_voice"] or not row["consent_text"].strip():
raise HTTPException(status_code=422, detail="Record the voice-ownership consent statement before hosted sync.")
reference_path = _voices_path(row["ref_audio_path"] or "")
if not reference_path or not os.path.isfile(reference_path):
raise HTTPException(status_code=422, detail="This profile has no local reference recording to sync.")
client = HostedVoiceClient(settings)
try:
hosted_voice_id = await client.create_voice(
name=row["name"], description=row["description"] or row["ref_text"] or "", reference_path=reference_path,
)
except HostedVoiceError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
finally:
await client.aclose()
with db_conn() as conn:
conn.execute("UPDATE voice_profiles SET hosted_voice_id=? WHERE id=? AND hosted_voice_id=''", (hosted_voice_id, profile_id))
persisted = conn.execute("SELECT hosted_voice_id FROM voice_profiles WHERE id=?", (profile_id,)).fetchone()["hosted_voice_id"]
return {"profile_id": profile_id, "hosted_voice_id": persisted, "state": "synced"}
@router.put("/profiles/{profile_id}")
def update_profile(profile_id: str, patch: ProfileUpdate):
"""Partial update — only fields set on the payload are changed."""
+77
View File
@@ -133,6 +133,83 @@ def set_torch_compile_disabled(body: _TorchCompileBody):
return _torch_compile_state()
# ── Compute-device override (Settings → Performance) ──────────────────────
class _ComputeDeviceBody(BaseModel):
value: str = Field(..., description="auto | cuda | rocm | xpu | mps | cpu")
def _compute_device_state() -> dict:
"""Everything the Performance panel needs to render the device control:
the resolved pick (env > prefs > auto), what this process actually applied
at probe time (differs after a change until restart caps are immutable
per process), what auto would pick, and which families exist here."""
from core import device_caps
caps = device_caps.detect_host_caps()
env_pin = (os.environ.get("OMNIVOICE_DEVICE") or "").strip().lower()
auto_family = next(
(f for f in ("cuda", "rocm", "xpu", "mps") if f in caps.available_families),
"cpu",
)
value = device_caps.requested_device_override()
return {
"value": value,
"applied": caps.requested_family,
"restart_required": value != caps.requested_family,
# The running process asked for a family it doesn't have (env pin on
# the wrong machine, hardware removed): auto is in effect, and a
# restart would not change that — the panel says so instead of
# pretending the pick took.
"override_ignored": (
caps.requested_family not in ("auto", caps.family)
),
"effective_family": caps.family,
"auto_family": auto_family,
"available_families": list(caps.available_families),
"env_pinned": env_pin in device_caps.DEVICE_OVERRIDE_CHOICES and env_pin != "",
"choices": list(device_caps.DEVICE_OVERRIDE_CHOICES),
}
@router.get("/compute-device")
def get_compute_device():
"""Current compute-device override state (Settings → Performance)."""
return _compute_device_state()
@router.put("/compute-device")
def set_compute_device(body: _ComputeDeviceBody):
"""Persist the compute-device pick. Applied by the capability probe at
the next backend start (host caps are immutable per process same
restart contract as the rest of the Performance tab). ``OMNIVOICE_DEVICE``
always wins over this pick; the UI shows the pin instead of pretending."""
from core import device_caps, prefs
value = (body.value or "").strip().lower()
if value not in device_caps.DEVICE_OVERRIDE_CHOICES:
raise HTTPException(
status_code=400,
detail=f"Unknown device '{value}'. Valid: {', '.join(device_caps.DEVICE_OVERRIDE_CHOICES)}",
)
caps = device_caps.detect_host_caps()
if value not in ("auto", "cpu") and value not in caps.available_families:
raise HTTPException(
status_code=400,
detail=(
f"'{value}' is not available on this host "
f"(have: {', '.join(caps.available_families)})"
),
)
try:
prefs.set_("compute_device", value)
except Exception:
logger.exception("set_compute_device failed")
raise HTTPException(status_code=500, detail="Failed to persist setting")
return _compute_device_state()
# ── Generation-history retention (Studio takes rail) ──────────────────────
+3
View File
@@ -57,6 +57,9 @@ _BASE_SCHEMA = """
consent_recorded_at REAL DEFAULT NULL,
kind TEXT DEFAULT 'clone',
vd_states TEXT DEFAULT NULL,
-- Hosted Voice ID is opt-in synchronization metadata. Local synthesis
-- never depends on it, so existing offline profiles remain useful.
hosted_voice_id TEXT DEFAULT '',
created_at REAL
);
CREATE TABLE IF NOT EXISTS generation_history (
+49
View File
@@ -374,6 +374,33 @@ class HostCaps:
probe_ok: bool = True
"""``False`` only when torch could not be imported (degraded CPU-only)."""
requested_family: str = "auto"
"""The user's compute-device override as requested — ``"auto"`` when none.
``family`` reflects what was actually honored: an override that names a
family this host doesn't have is noted and ignored, never obeyed blindly."""
#: Every value the compute-device override accepts. "auto" = today's
#: priority pick; "cpu" is always honorable (invariant: cpu is always
#: available); accelerator names are honored only when detected.
DEVICE_OVERRIDE_CHOICES: tuple[str, ...] = ("auto", "cuda", "rocm", "xpu", "mps", "cpu")
def requested_device_override() -> str:
"""The user's compute-device pick: ``OMNIVOICE_DEVICE`` env > the Settings
choice (``compute_device`` in prefs.json) > ``"auto"``. Env wins so
power-users can pin a device without the UI silently undoing it (same
resolution order as engine selection, #981). Unknown values normalize to
``"auto"`` the probe must never raise."""
try:
from core import prefs
raw = prefs.resolve("compute_device", env="OMNIVOICE_DEVICE", default="auto")
except Exception:
raw = os.environ.get("OMNIVOICE_DEVICE", "auto")
val = str(raw or "auto").strip().lower()
return val if val in DEVICE_OVERRIDE_CHOICES else "auto"
def _probe() -> HostCaps:
"""Run the probe once. Enumerates every failure branch from the spec's
@@ -386,6 +413,7 @@ def _probe() -> HostCaps:
available_families=("cpu",),
notes=("torch not importable; treating host as CPU-only",),
probe_ok=False,
requested_family=requested_device_override(),
)
notes: list[str] = []
@@ -507,6 +535,26 @@ def _probe() -> HostCaps:
# available_families: every detected accelerator + cpu, deduped, cpu last.
available: tuple[DeviceFamily, ...] = tuple(dict.fromkeys([*detected, "cpu"]))
# User override (Settings → Performance, or OMNIVOICE_DEVICE): honored
# only when the named family actually exists on this host — an override
# can steer, it cannot invent hardware. Applied here, at the single
# choke point, so routing, model loads (get_best_device delegates its
# family decision here), and every badge inherit it for free.
requested = requested_device_override()
if requested != "auto":
if requested in available:
if requested != family:
notes.append(
f"compute device pinned to '{requested}' by user override "
f"(auto would pick '{family}')"
)
family = requested # type: ignore[assignment]
else:
notes.append(
f"requested compute device '{requested}' is not available on "
f"this host (have: {', '.join(available)}) — using '{family}'"
)
return HostCaps(
family=family,
available_families=available,
@@ -515,6 +563,7 @@ def _probe() -> HostCaps:
driver=driver,
notes=tuple(notes),
probe_ok=True,
requested_family=requested,
)
+125
View File
@@ -0,0 +1,125 @@
"""Startup progress ledger — what the backend is doing before it can serve.
Why this exists: the project's #1 lifetime failure class is "can't reach the
local backend", and a large slice of it was never a dead backend at all —
just one that couldn't say "I'm starting, currently loading PyTorch" because
nothing listened until every heavy import and migration finished. main.py now
binds the socket early and defers the heavy work; this module is the shared
state the early `/health` + `/startup/progress` endpoints report from while
that work runs.
Thread-safety: the deferred init runs Phase A in an executor thread while the
event loop serves probes, so every mutation and snapshot takes the lock.
"""
from __future__ import annotations
import threading
import time
# Execution order matters only for display; the ledger records whatever order
# steps actually begin in. Keep ids stable — the desktop shell field-sniffs
# them and tests pin them.
STEPS: "dict[str, str]" = {
"env_prefs": "Restoring settings…",
"native_preload": "Preparing GPU libraries…",
"ml_imports": "Loading ML runtime (PyTorch)…",
"api_routes": "Loading API routes…",
"db_migrate": "Preparing database…",
"services_start": "Starting background services…",
}
_lock = threading.Lock()
_t0 = time.monotonic()
_current: "str | None" = None
_done: "list[tuple[str, float]]" = [] # (step_id, seconds it took)
_started_at: float = 0.0
_ready = False
_error: "dict | None" = None
def begin_step(step_id: str) -> None:
global _current, _started_at
with _lock:
_finish_current_locked()
_current = step_id
_started_at = time.monotonic()
def _finish_current_locked() -> None:
global _current
if _current is not None:
_done.append((_current, round(time.monotonic() - _started_at, 2)))
_current = None
def mark_ready() -> None:
global _ready
with _lock:
_finish_current_locked()
_ready = True
def fail(message: str) -> None:
"""Record a startup failure against the step that was running."""
global _error
with _lock:
_error = {"step": _current, "message": str(message)[:500]}
def is_ready() -> bool:
with _lock:
return _ready
def current_step() -> "tuple[str | None, str | None]":
"""(step_id, human label) of the active step, or (None, None)."""
with _lock:
if _current is None:
return None, None
return _current, STEPS.get(_current, _current)
def snapshot() -> dict:
"""The `/startup/progress` body. Always safe to call, never raises."""
with _lock:
if _error is not None:
status = "failed"
elif _ready:
status = "ready"
else:
status = "starting"
states = {sid: "pending" for sid in STEPS}
for sid, _t in _done:
states[sid] = "done"
if _current is not None:
states[_current] = "active"
if _error is not None and _error.get("step"):
states[_error["step"]] = "failed"
durations = dict(_done)
return {
"status": status,
"step": _current,
"label": STEPS.get(_current, _current) if _current else None,
"steps": [
{
"id": sid,
"label": label,
"state": states.get(sid, "pending"),
**({"t": durations[sid]} if sid in durations else {}),
}
for sid, label in STEPS.items()
],
"elapsed_s": round(time.monotonic() - _t0, 2),
"error": _error,
}
def _reset_for_tests() -> None:
global _current, _ready, _error, _started_at
with _lock:
_current = None
_done.clear()
_ready = False
_error = None
_started_at = 0.0
+19 -3
View File
@@ -85,11 +85,27 @@ def _get_model():
global _model
if _model is None:
from faster_whisper import WhisperModel
name = os.environ.get("ASR_MODEL_FW", "large-v3")
# Same weights as in-process faster-whisper: ASR_MODEL_FASTER selects
# for BOTH variants, ASR_MODEL_FW stays as a sidecar-only override.
# Before this, the sidecar read only ASR_MODEL_FW while the download
# preflight read ASR_MODEL_FASTER — set one and the other variant (or
# the preflight) quietly used a different model.
name = (
os.environ.get("ASR_MODEL_FW")
or os.environ.get("ASR_MODEL_FASTER")
or "large-v3"
)
try:
import torch
device = "cuda" if torch.cuda.is_available() else "cpu"
# The probe honors the user compute-device override and the
# ROCm/CT2 incompatibility (#1529) — the child must agree with
# the parent's device decision, not re-derive its own.
from core.device_caps import detect_host_caps
device = "cuda" if detect_host_caps().family == "cuda" else "cpu"
except Exception:
# Fail SAFE: guessing "cuda" from torch here would bypass a cpu
# override and hand CTranslate2 HIP-flavoured cuda on ROCm
# (#1529). CPU always works; say why in the sidecar log.
print("asr-sidecar: device probe failed — using cpu", file=sys.stderr, flush=True)
device = "cpu"
# Degrade fp16 → int8 rather than crash on GPUs without efficient fp16
# (older Maxwell/Pascal, GTX 16xx, CTranslate2/cuDNN mismatch) (#551).
@@ -353,6 +353,9 @@ def _make_backend_class():
display_name = "OmniVoice (GGUF, hardware-adaptive)"
gpu_compat = ("cuda", "mps", "cpu")
supports_voice_design = False
# Every generate() spawns the external binary — allocations live in
# that process, invisible to parent-side accelerator counters.
runs_out_of_process = True
# 24 kHz mono Higgs Audio v2 — same as the in-process OmniVoice.
_SAMPLE_RATE = 24_000
+626 -401
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,30 @@
"""Opt-in hosted Voice ID on local profiles.
Revision ID: 0011_hosted_voice_sync
Revises: 0010_remote_worker_schema
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0011_hosted_voice_sync"
down_revision: Union[str, None] = "0010_remote_worker_schema"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def _has_column(table: str, column: str) -> bool:
rows = op.get_bind().execute(sa.text(f"PRAGMA table_info({table})")).fetchall()
return any(row[1] == column for row in rows)
def upgrade() -> None:
if not _has_column("voice_profiles", "hosted_voice_id"):
op.add_column("voice_profiles", sa.Column("hosted_voice_id", sa.Text(), nullable=True, server_default=""))
def downgrade() -> None:
if _has_column("voice_profiles", "hosted_voice_id"):
op.drop_column("voice_profiles", "hosted_voice_id")
@@ -0,0 +1,54 @@
"""Mark materialized gallery archetypes as voice-design profiles.
Revision ID: 0012_mark_archetype_profiles_design
Revises: 0011_hosted_voice_sync
Create Date: 2026-08-15 00:00:00.000000
``POST /archetypes/{id}/use`` stores the archetype id in ``personality`` and
also stores a locally rendered identity WAV. That WAV must not make the
profile a clone: the archetype's instruct recipe is authoritative. Older
rows relied on the ``kind='clone'`` default and therefore selected the clone
generation path. This data-only migration fixes every row whose personality
is a current archetype id, leaving unrelated persona and marketplace imports
untouched.
"""
from typing import Sequence, Union
from alembic import op
from sqlalchemy import inspect
revision: str = "0012_mark_archetype_profiles_design"
down_revision: Union[str, None] = "0011_hosted_voice_sync"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
bind = op.get_bind()
inspector = inspect(bind)
if "voice_profiles" not in inspector.get_table_names():
return
columns = {column["name"] for column in inspector.get_columns("voice_profiles")}
if not {"kind", "personality"}.issubset(columns):
return
# The catalog is intentionally a value object, so checking an id against
# its current generated list is the precise provenance test. The
# parameterized update avoids treating any other personality string as an
# archetype.
from core import archetypes
archetype_ids = [item["id"] for item in archetypes.list_archetypes()]
for archetype_id in archetype_ids:
bind.exec_driver_sql(
"UPDATE voice_profiles SET kind = 'design' "
"WHERE personality = ? AND (kind IS NULL OR kind = '' OR kind = 'clone')",
(archetype_id,),
)
def downgrade() -> None:
# Do not silently convert voice-design profiles back to clones: that would
# reintroduce the generation mismatch for existing user data.
pass
+95
View File
@@ -0,0 +1,95 @@
# VoiceStudio runtime adapter
A local gRPC server implementing the vssaas GPU-node runtime contract
`voicestudio.runtime.v1.RuntimeAdapterService`, so a vssaas GPU Gateway can
drive this VoiceStudio backend as its inference runtime.
## Boundary (deliberate non-capabilities)
- Binds **only** a Unix-domain socket (default `/run/voicestudio/runtime.sock`,
override with `VOICE_STUDIO_RUNTIME_SOCKET`). No HTTP listener, no TCP.
- Never reaches PostgreSQL, customer credentials, or arbitrary network URLs.
`Execute` accepts **local file handles only** — absolute paths generated by
the Gateway; any URL-shaped or relative handle is rejected as invalid input.
- The Gateway owns leases, artifact transfer, retries, and billing. This
adapter owns approved model loading and inference only.
## Running
```sh
# serve (production socket):
VOICE_STUDIO_RUNTIME_SOCKET=/run/voicestudio/runtime.sock \
python -m backend.runtime_adapter
# self-check: starts the server on a private temp socket and validates the
# same expectations the Go preflight (cmd/runtime-adapter-preflight) enforces:
python -m backend.runtime_adapter --selfcheck
```
Environment:
| Variable | Default | Meaning |
| --- | --- | --- |
| `VOICE_STUDIO_RUNTIME_SOCKET` | `/run/voicestudio/runtime.sock` | Unix socket path (must be absolute; parent dir must exist and not be world-writable). |
| `VOICE_STUDIO_RUNTIME_SLOTS` | `1` | Concurrent execution slots per device. |
## Wire contract and generated stubs
`runtime_adapter.proto` is a **byte-identical vendored copy** of the vssaas
contract `api/proto/voicestudio/runtime/v1/runtime_adapter.proto`. Do not edit
it here; re-vendor from vssaas when the contract changes, then regenerate.
The `gen/` stubs are committed (same policy as `backend/worker/protocol/gen/`).
Regenerate with:
```sh
uv run python scripts/gen_runtime_adapter_protocol.py
```
`tests/test_runtime_adapter_gen.py` fails if the committed stubs drift from
the proto.
## Preflight expectations honoured
The Go preflight (`internal/gateway/preflight.go`) fails closed unless:
- the socket path is absolute, a real Unix socket (not a symlink), and its
parent directory is not world-writable — `server.prepare_socket` enforces
the same rules at bind time;
- `Health` returns `SERVING_STATE_READY` with nonempty runtime + adapter
versions, and `GetCapabilities` returns **identical** versions — both
handlers read the same constants, so they cannot disagree;
- at least one device with nonempty id/hardware class, nonzero VRAM and
slots, `free_slots <= total_slots`, unique ids;
- at least one model **explicitly READY** with `catalog_model_id`,
`model_version`, `model_digest`, and ≥1 precision. A loading, installed,
or failed model is reported with its true state and never as READY.
## Model identity
- `catalog_model_id` — the VoiceStudio TTS engine id (`omnivoice`,
`voxcpm2`, …) from `services.tts_backend`'s registry.
- `model_version` — an immutable catalog version comprising the installed
Hugging Face revision (40-char commit SHA) and the first 16 hex characters
of the attested snapshot digest. This creates a new catalog identity when
snapshot bytes change; it never rewrites an identity retained by a Job.
- `model_digest``sha256:<hex>` computed over the installed snapshot files
(sorted relative path + per-file SHA-256), cached next to the repo cache
keyed by (revision, file list, sizes, mtimes) so multi-GB weights are
hashed once. See `digest.py`.
## Failure taxonomy
Stable codes (prefix `RTA_`) map onto the proto's `RuntimeFailureClass`:
invalid input (`RTA_INPUT_*`), model load (`RTA_MODEL_LOAD_FAILED`),
inference (`RTA_INFERENCE_*`), GPU resource (`RTA_GPU_*`), local storage
(`RTA_STORAGE_*`), cancellation (terminal `ExecutionCanceled`), and adapter
crash (`RTA_RUNTIME_CRASH`). See `codes.py`.
## Tests
```sh
uv run pytest backend/tests/test_runtime_adapter_capabilities.py \
backend/tests/test_runtime_adapter_execute.py \
tests/test_runtime_adapter_gen.py
```
+18
View File
@@ -0,0 +1,18 @@
"""VoiceStudio runtime adapter — the vssaas GPU-node runtime boundary.
Implements ``voicestudio.runtime.v1.RuntimeAdapterService`` over a private
Unix-domain socket so a vssaas GPU Gateway can drive VoiceStudio's TTS
engines as its inference runtime. No HTTP listener, no database access, no
outbound network: the adapter reads and writes only the local file handles
each ``Execute`` request carries. See ``README.md`` in this directory.
"""
from __future__ import annotations
#: Version of this adapter layer (the gRPC boundary), independent of the app
#: version, which is reported as ``runtime_version``. Bump on any behavioral
#: change to the adapter itself.
ADAPTER_VERSION = "0.1.0"
DEFAULT_SOCKET_PATH = "/run/voicestudio/runtime.sock"
SOCKET_ENV = "VOICE_STUDIO_RUNTIME_SOCKET"
SLOTS_ENV = "VOICE_STUDIO_RUNTIME_SLOTS"
+65
View File
@@ -0,0 +1,65 @@
"""Entry point: ``python -m backend.runtime_adapter``.
Serves the runtime adapter on a private Unix-domain socket (default
``/run/voicestudio/runtime.sock``, override ``VOICE_STUDIO_RUNTIME_SOCKET``
or ``--socket``). ``--selfcheck`` instead starts the server on a temp socket
and validates the GPU Gateway preflight expectations against it.
"""
from __future__ import annotations
import argparse
import sys
from ._paths import ensure_backend_on_path
def main(argv: list[str] | None = None) -> int:
ensure_backend_on_path()
parser = argparse.ArgumentParser(
prog="backend.runtime_adapter",
description="VoiceStudio runtime adapter (vssaas GPU-node gRPC server)",
)
parser.add_argument(
"--socket",
default=None,
help="absolute Unix socket path (default: $VOICE_STUDIO_RUNTIME_SOCKET "
"or /run/voicestudio/runtime.sock)",
)
parser.add_argument(
"--selfcheck",
action="store_true",
help="start on a temp socket and validate the preflight expectations",
)
parser.add_argument(
"--timeout",
type=float,
default=10.0,
help="selfcheck RPC timeout in seconds (default: 10)",
)
parser.add_argument(
"--no-prewarm",
action="store_true",
help="serve immediately without loading models first (the first "
"execution then pays weight loading and compilation)",
)
args = parser.parse_args(argv)
if args.selfcheck:
from .selfcheck import selfcheck # noqa: PLC0415
return selfcheck(timeout_s=args.timeout)
from .production import build_runtime_context, prewarm_engines # noqa: PLC0415
from .server import resolve_socket_path, serve # noqa: PLC0415
context = build_runtime_context()
if not args.no_prewarm:
# Deliberately before the socket exists: the Gateway's preflight and
# first offer should both find a runtime that can start inference at
# once, rather than one that spends an attempt lease compiling.
prewarm_engines(context)
return serve(context, resolve_socket_path(args.socket))
if __name__ == "__main__":
sys.exit(main())
+20
View File
@@ -0,0 +1,20 @@
"""Import-path bootstrap for running outside the FastAPI app.
The backend is laid out to run with ``--app-dir backend`` (imports like
``services.tts_backend`` resolve against the ``backend/`` directory). When
the adapter is launched as ``python -m backend.runtime_adapter`` from the
repo root, ``backend/`` is a namespace package but not on ``sys.path`` so
call :func:`ensure_backend_on_path` before any ``services.*`` / ``core.*``
import. Idempotent; mirrors ``backend/tests/conftest.py``.
"""
from __future__ import annotations
import os
import sys
def ensure_backend_on_path() -> str:
backend_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if backend_dir not in sys.path:
sys.path.insert(0, backend_dir)
return backend_dir
+163
View File
@@ -0,0 +1,163 @@
"""Stable failure codes and exception classification for Execute.
The vssaas API Gateway keys retry and customer-charge policy off these codes,
so they are a wire contract: never rename an existing code, only add. Every
code maps to exactly one proto ``RuntimeFailureClass``.
"""
from __future__ import annotations
import re
from .gen import runtime_adapter_pb2 as pb2
# ── invalid approved input ────────────────────────────────────────────────
INPUT_ATTEMPT_IDENTITY = "RTA_INPUT_ATTEMPT_IDENTITY"
INPUT_ATTEMPT_DUPLICATE = "RTA_INPUT_ATTEMPT_DUPLICATE"
INPUT_MODEL_UNKNOWN = "RTA_INPUT_MODEL_UNKNOWN"
INPUT_MODEL_NOT_READY = "RTA_INPUT_MODEL_NOT_READY"
INPUT_MODEL_DIGEST_MISMATCH = "RTA_INPUT_MODEL_DIGEST_MISMATCH"
INPUT_MODEL_PRECISION = "RTA_INPUT_MODEL_PRECISION_UNSUPPORTED"
INPUT_DEVICE_UNKNOWN = "RTA_INPUT_DEVICE_UNKNOWN"
INPUT_HANDLE_INVALID = "RTA_INPUT_HANDLE_INVALID"
INPUT_ARTIFACTS_INVALID = "RTA_INPUT_ARTIFACTS_INVALID"
INPUT_CHECKSUM_MISMATCH = "RTA_INPUT_CHECKSUM_MISMATCH"
INPUT_TEXT_EMPTY = "RTA_INPUT_TEXT_EMPTY"
INPUT_TEXT_TOO_LARGE = "RTA_INPUT_TEXT_TOO_LARGE"
INPUT_TEXT_ENCODING = "RTA_INPUT_TEXT_ENCODING"
INPUT_PARAMETER_UNKNOWN = "RTA_INPUT_PARAMETER_UNKNOWN"
INPUT_PARAMETER_TYPE = "RTA_INPUT_PARAMETER_TYPE"
INPUT_PARAMETER_RANGE = "RTA_INPUT_PARAMETER_RANGE"
INPUT_DEADLINE_INVALID = "RTA_INPUT_DEADLINE_INVALID"
INPUT_REJECTED = "RTA_INPUT_REJECTED" # engine-level TTSInputError
# ── model load / inference ────────────────────────────────────────────────
MODEL_LOAD_FAILED = "RTA_MODEL_LOAD_FAILED"
MODEL_LOAD_DEADLINE = "RTA_MODEL_LOAD_DEADLINE_EXCEEDED"
INFERENCE_FAILED = "RTA_INFERENCE_FAILED"
INFERENCE_BAD_OUTPUT = "RTA_INFERENCE_BAD_OUTPUT"
INFERENCE_DEADLINE = "RTA_INFERENCE_DEADLINE_EXCEEDED"
# ── GPU resource ──────────────────────────────────────────────────────────
GPU_OUT_OF_MEMORY = "RTA_GPU_OUT_OF_MEMORY"
GPU_SLOTS_EXHAUSTED = "RTA_GPU_SLOTS_EXHAUSTED"
# ── local storage ─────────────────────────────────────────────────────────
STORAGE_READ_FAILED = "RTA_STORAGE_READ_FAILED"
STORAGE_WRITE_FAILED = "RTA_STORAGE_WRITE_FAILED"
# ── adapter crash ─────────────────────────────────────────────────────────
RUNTIME_CRASH = "RTA_RUNTIME_CRASH"
_INPUT = pb2.RUNTIME_FAILURE_CLASS_INPUT
_MODEL_LOAD = pb2.RUNTIME_FAILURE_CLASS_MODEL_LOAD
_INFERENCE = pb2.RUNTIME_FAILURE_CLASS_INFERENCE
_GPU = pb2.RUNTIME_FAILURE_CLASS_GPU_RESOURCE
_STORAGE = pb2.RUNTIME_FAILURE_CLASS_LOCAL_STORAGE
_RUNTIME = pb2.RUNTIME_FAILURE_CLASS_RUNTIME
CODE_CLASS: dict[str, int] = {
INPUT_ATTEMPT_IDENTITY: _INPUT,
INPUT_ATTEMPT_DUPLICATE: _INPUT,
INPUT_MODEL_UNKNOWN: _INPUT,
INPUT_MODEL_NOT_READY: _INPUT,
INPUT_MODEL_DIGEST_MISMATCH: _INPUT,
INPUT_MODEL_PRECISION: _INPUT,
INPUT_DEVICE_UNKNOWN: _INPUT,
INPUT_HANDLE_INVALID: _INPUT,
INPUT_ARTIFACTS_INVALID: _INPUT,
INPUT_CHECKSUM_MISMATCH: _INPUT,
INPUT_TEXT_EMPTY: _INPUT,
INPUT_TEXT_TOO_LARGE: _INPUT,
INPUT_TEXT_ENCODING: _INPUT,
INPUT_PARAMETER_UNKNOWN: _INPUT,
INPUT_PARAMETER_TYPE: _INPUT,
INPUT_PARAMETER_RANGE: _INPUT,
INPUT_DEADLINE_INVALID: _INPUT,
INPUT_REJECTED: _INPUT,
MODEL_LOAD_FAILED: _MODEL_LOAD,
MODEL_LOAD_DEADLINE: _MODEL_LOAD,
INFERENCE_FAILED: _INFERENCE,
INFERENCE_BAD_OUTPUT: _INFERENCE,
INFERENCE_DEADLINE: _INFERENCE,
GPU_OUT_OF_MEMORY: _GPU,
GPU_SLOTS_EXHAUSTED: _GPU,
STORAGE_READ_FAILED: _STORAGE,
STORAGE_WRITE_FAILED: _STORAGE,
RUNTIME_CRASH: _RUNTIME,
}
class ExecutionFailure(Exception):
"""A classified, wire-safe execution failure."""
def __init__(self, stable_code: str, safe_detail: str = ""):
if stable_code not in CODE_CLASS: # programming error, not a wire case
raise ValueError(f"unknown stable code {stable_code!r}")
super().__init__(stable_code)
self.stable_code = stable_code
self.failure_class = CODE_CLASS[stable_code]
self.safe_detail = scrub_detail(safe_detail)
_PATHISH = re.compile(r"(?:[A-Za-z]:)?[/\\][^\s'\"]+")
_MAX_DETAIL = 240
def scrub_detail(detail: str) -> str:
"""Bound and de-path a detail string before it crosses the wire.
Local handles are server-generated, but engine exceptions routinely embed
checkpoint paths, cache dirs, and home directories. None of that belongs
in an event the Gateway relays upstream.
"""
scrubbed = _PATHISH.sub("<path>", detail or "").strip()
return scrubbed[:_MAX_DETAIL]
_OOM_MARKERS = (
"out of memory",
"cuda error: out of memory",
"mps backend out of memory",
"hip out of memory",
"cublas_status_alloc_failed",
)
def _is_oom(exc: BaseException) -> bool:
if type(exc).__name__ == "OutOfMemoryError": # torch.cuda.OutOfMemoryError
return True
message = str(exc).lower()
return any(marker in message for marker in _OOM_MARKERS)
def _is_engine_input_error(exc: BaseException) -> bool:
try:
from services.tts_backend import TTSInputError # noqa: PLC0415
except Exception:
return False
return isinstance(exc, TTSInputError)
def classify_engine_error(exc: BaseException, phase: str) -> ExecutionFailure:
"""Map an engine exception to a stable failure code.
``phase`` is ``"model_load"`` or ``"synthesis"`` the phase the engine
thread was in when it raised.
"""
if isinstance(exc, ExecutionFailure):
return exc
detail = f"{type(exc).__name__}: {exc}"
if _is_oom(exc):
return ExecutionFailure(GPU_OUT_OF_MEMORY, detail)
if _is_engine_input_error(exc):
return ExecutionFailure(INPUT_REJECTED, detail)
if isinstance(exc, OSError):
return ExecutionFailure(STORAGE_READ_FAILED, detail)
if phase == "model_load":
return ExecutionFailure(MODEL_LOAD_FAILED, detail)
return ExecutionFailure(INFERENCE_FAILED, detail)
def deadline_failure(phase: str) -> ExecutionFailure:
code = MODEL_LOAD_DEADLINE if phase == "model_load" else INFERENCE_DEADLINE
return ExecutionFailure(code, "attempt deadline exceeded")
+112
View File
@@ -0,0 +1,112 @@
"""Stable digests for locally installed model snapshots.
``model_digest`` in the wire contract pins the exact bytes a READY model will
execute with. Hugging Face snapshots are symlink farms into ``blobs/``, so the
digest is computed over the *resolved* file contents: SHA-256 of the sorted
sequence ``<posix relpath>\\n<file sha256>\\n``. That is stable across hosts,
cache locations, and symlink layout, and changes whenever any weight byte or
the file set changes.
Hashing multi-GB weights on every ``GetCapabilities`` call would be absurd, so
the result is cached in a JSON sidecar keyed by a cheap fingerprint of the
file list (relpath, size, mtime_ns). Any file change invalidates the cache and
forces a full re-hash.
"""
from __future__ import annotations
import hashlib
import json
import os
from pathlib import Path
DIGEST_PREFIX = "sha256:"
_CHUNK = 1024 * 1024
def file_sha256(path: str | os.PathLike[str]) -> str:
hasher = hashlib.sha256()
with open(path, "rb") as fh:
while True:
chunk = fh.read(_CHUNK)
if not chunk:
break
hasher.update(chunk)
return hasher.hexdigest()
def _manifest(root: Path) -> list[tuple[str, int, int]]:
"""Sorted (relpath, size, mtime_ns) for every regular file under root.
Follows symlinks (HF snapshot layout); a dangling symlink raises
``FileNotFoundError`` callers treat that as an incomplete install.
"""
entries: list[tuple[str, int, int]] = []
for current, dirs, files in os.walk(root, followlinks=True):
dirs.sort()
for name in sorted(files):
path = Path(current) / name
stat = path.stat() # resolves symlinks; raises if dangling
rel = path.relative_to(root).as_posix()
entries.append((rel, stat.st_size, stat.st_mtime_ns))
entries.sort()
return entries
def _fingerprint(entries: list[tuple[str, int, int]]) -> str:
return hashlib.sha256(
json.dumps(entries, separators=(",", ":")).encode("utf-8")
).hexdigest()
def snapshot_digest(root: str | os.PathLike[str], cache_path: str | os.PathLike[str] | None = None) -> str:
"""``sha256:<hex>`` digest of the snapshot at ``root``.
Raises ``FileNotFoundError`` for a missing/empty snapshot or dangling
symlink and ``OSError`` for unreadable files callers classify those as
not-READY rather than fabricating a digest.
"""
root = Path(root)
entries = _manifest(root)
if not entries:
raise FileNotFoundError(f"empty model snapshot: {root}")
fingerprint = _fingerprint(entries)
if cache_path is not None:
cached = _read_cache(cache_path)
if cached is not None and cached.get("fingerprint") == fingerprint:
digest = cached.get("digest", "")
if isinstance(digest, str) and digest.startswith(DIGEST_PREFIX):
return digest
hasher = hashlib.sha256()
for rel, _size, _mtime in entries:
hasher.update(rel.encode("utf-8"))
hasher.update(b"\n")
hasher.update(file_sha256(root / rel).encode("ascii"))
hasher.update(b"\n")
digest = DIGEST_PREFIX + hasher.hexdigest()
if cache_path is not None:
_write_cache(cache_path, fingerprint, digest)
return digest
def _read_cache(cache_path: str | os.PathLike[str]) -> dict | None:
try:
with open(cache_path, encoding="utf-8") as fh:
data = json.load(fh)
return data if isinstance(data, dict) else None
except (OSError, ValueError):
return None
def _write_cache(cache_path: str | os.PathLike[str], fingerprint: str, digest: str) -> None:
cache_path = Path(cache_path)
payload = json.dumps({"fingerprint": fingerprint, "digest": digest})
try:
cache_path.parent.mkdir(parents=True, exist_ok=True)
temporary = cache_path.with_suffix(f".tmp-{os.getpid()}")
temporary.write_text(payload, encoding="utf-8")
os.replace(temporary, cache_path)
except OSError:
pass # cache is an optimization; the digest itself is already computed
+664
View File
@@ -0,0 +1,664 @@
"""Execute/Cancel: attempt registry, validation, and the event stream.
One ``Execute`` call is one *attempt*. The generator emits::
started progress* exactly one of completed | failed | canceled
The engine call itself (``ensure_ready`` + ``generate``) runs on a daemon
worker thread; the streaming generator polls it, emitting bounded heartbeat
progress and enforcing the request deadline and cancellation. A blocking
engine cannot be interrupted mid-kernel, so on cancel/deadline the thread is
abandoned and its result discarded the terminal event is what the Gateway
acts on, and slot accounting is released only when the thread actually exits.
The adapter never turns a customer string into a filesystem path: it touches
exactly the local handles the request carries, after validation.
"""
from __future__ import annotations
import os
import threading
import time
from collections import OrderedDict
from dataclasses import dataclass, field
from . import codes
from ._paths import ensure_backend_on_path
from .digest import file_sha256
from .gen import runtime_adapter_pb2 as pb2
from .inventory import STATE_READY
_MAX_TEXT_BYTES = 512_000
_MAX_REF_AUDIO_BYTES = 100 * 1024 * 1024
_MAX_DEADLINE_S = 24 * 3600.0
_MAX_PROGRESS_EVENTS = 512
#: Typed, bounded Execute parameters → the engine ``generate()`` kwarg of the
#: same name. Kinds: ("string", max_len) / ("integer", lo, hi) /
#: ("number", lo, hi) / ("boolean",).
PARAMETER_SPECS: dict[str, tuple] = {
"language": ("string", 32),
"ref_text": ("string", 4096),
"instruct": ("string", 2048),
"description": ("string", 2048),
"speed": ("number", 0.25, 4.0),
"guidance_scale": ("number", 0.0, 16.0),
"num_step": ("integer", 1, 128),
# Gallery reference voices persist their OSS design seed. Accept it at
# the hosted runtime boundary so a selected voice produces the same take.
"seed": ("integer", 0, 4_294_967_295),
}
# ── attempt registry ──────────────────────────────────────────────────────
@dataclass
class AttemptRecord:
job_id: str
attempt_id: str
cancel: threading.Event = field(default_factory=threading.Event)
terminal: str | None = None # "completed" | "failed" | "canceled"
class AttemptRegistry:
"""Attempt bookkeeping: admission, idempotent cancel, bounded history."""
def __init__(self, max_terminal: int = 4096):
self._lock = threading.Lock()
self._active: dict[str, AttemptRecord] = {}
self._terminal: OrderedDict[str, AttemptRecord] = OrderedDict()
self._max_terminal = max_terminal
def begin(self, job_id: str, attempt_id: str, slot_limit: int) -> AttemptRecord:
with self._lock:
if attempt_id in self._active or attempt_id in self._terminal:
raise codes.ExecutionFailure(
codes.INPUT_ATTEMPT_DUPLICATE, "attempt id already used"
)
if len(self._active) >= max(1, slot_limit):
raise codes.ExecutionFailure(
codes.GPU_SLOTS_EXHAUSTED, "no free execution slot"
)
record = AttemptRecord(job_id=job_id, attempt_id=attempt_id)
self._active[attempt_id] = record
return record
def finish(self, attempt_id: str, terminal: str) -> None:
with self._lock:
record = self._active.pop(attempt_id, None)
if record is None:
return
record.terminal = terminal
self._terminal[attempt_id] = record
while len(self._terminal) > self._max_terminal:
self._terminal.popitem(last=False)
def active_count(self) -> int:
with self._lock:
return len(self._active)
def cancel(self, job_id: str, attempt_id: str) -> int:
"""Idempotent by attempt id; returns a proto CancelDisposition."""
with self._lock:
record = self._active.get(attempt_id)
if record is not None:
if job_id and record.job_id and job_id != record.job_id:
return pb2.CANCEL_DISPOSITION_NOT_FOUND
record.cancel.set()
return pb2.CANCEL_DISPOSITION_ACCEPTED
record = self._terminal.get(attempt_id)
if record is not None:
if job_id and record.job_id and job_id != record.job_id:
return pb2.CANCEL_DISPOSITION_NOT_FOUND
return pb2.CANCEL_DISPOSITION_ALREADY_TERMINAL
return pb2.CANCEL_DISPOSITION_NOT_FOUND
# ── request validation ────────────────────────────────────────────────────
@dataclass
class ValidatedRequest:
text: str
output_handle: str
output_media_type: str
output_size_bound: int
engine_kwargs: dict
deadline_monotonic: float
catalog_model_id: str
def _validate_handle(handle: str, code: str = codes.INPUT_HANDLE_INVALID) -> str:
cleaned = (handle or "").strip()
if (
not cleaned
or "\x00" in cleaned
or "://" in cleaned
or not os.path.isabs(cleaned)
or os.path.normpath(cleaned) != cleaned
):
raise codes.ExecutionFailure(code, "local handle must be an absolute path")
return cleaned
def _read_input_file(artifact, max_bytes: int) -> bytes:
path = _validate_handle(artifact.local_handle)
try:
stat = os.lstat(path)
except OSError as exc:
raise codes.ExecutionFailure(
codes.STORAGE_READ_FAILED, f"input handle unreadable: {type(exc).__name__}"
)
import stat as stat_module # noqa: PLC0415
if not stat_module.S_ISREG(stat.st_mode):
raise codes.ExecutionFailure(
codes.INPUT_HANDLE_INVALID, "input handle must be a regular file"
)
bound = max_bytes
if 0 < artifact.expected_size_bytes <= max_bytes:
bound = artifact.expected_size_bytes
if stat.st_size > bound:
raise codes.ExecutionFailure(
codes.INPUT_TEXT_TOO_LARGE, "input exceeds its size bound"
)
try:
with open(path, "rb") as fh:
data = fh.read(bound + 1)
except OSError as exc:
raise codes.ExecutionFailure(
codes.STORAGE_READ_FAILED, f"input read failed: {type(exc).__name__}"
)
if len(data) > bound:
raise codes.ExecutionFailure(
codes.INPUT_TEXT_TOO_LARGE, "input exceeds its size bound"
)
expected = (artifact.expected_sha256 or "").strip().lower().removeprefix("sha256:")
if expected:
import hashlib # noqa: PLC0415
if hashlib.sha256(data).hexdigest() != expected:
raise codes.ExecutionFailure(
codes.INPUT_CHECKSUM_MISMATCH, "input checksum mismatch"
)
return data
def _typed_parameter(name: str, value) -> object:
spec = PARAMETER_SPECS.get(name)
if spec is None:
raise codes.ExecutionFailure(
codes.INPUT_PARAMETER_UNKNOWN, f"unknown parameter {name!r}"
)
kind = spec[0]
which = value.WhichOneof("value")
if kind == "string":
if which != "string_value":
raise codes.ExecutionFailure(
codes.INPUT_PARAMETER_TYPE, f"parameter {name!r} must be a string"
)
text = value.string_value
if len(text) > spec[1]:
raise codes.ExecutionFailure(
codes.INPUT_PARAMETER_RANGE, f"parameter {name!r} too long"
)
return text
if kind == "integer":
if which != "integer_value":
raise codes.ExecutionFailure(
codes.INPUT_PARAMETER_TYPE, f"parameter {name!r} must be an integer"
)
number = value.integer_value
if not spec[1] <= number <= spec[2]:
raise codes.ExecutionFailure(
codes.INPUT_PARAMETER_RANGE, f"parameter {name!r} out of range"
)
return int(number)
if kind == "number":
if which == "number_value":
number = value.number_value
elif which == "integer_value":
number = float(value.integer_value)
else:
raise codes.ExecutionFailure(
codes.INPUT_PARAMETER_TYPE, f"parameter {name!r} must be a number"
)
if not spec[1] <= number <= spec[2]:
raise codes.ExecutionFailure(
codes.INPUT_PARAMETER_RANGE, f"parameter {name!r} out of range"
)
return float(number)
if which != "boolean_value":
raise codes.ExecutionFailure(
codes.INPUT_PARAMETER_TYPE, f"parameter {name!r} must be a boolean"
)
return bool(value.boolean_value)
# ── the executor ──────────────────────────────────────────────────────────
class Executor:
"""Validates and runs attempts against an inventory + engine provider."""
def __init__(
self,
inventory,
engine_provider,
registry: AttemptRegistry,
*,
slot_limit: int = 1,
progress_interval: float = 0.5,
poll_interval: float = 0.02,
clock=time.monotonic,
):
self._inventory = inventory
self._engine_provider = engine_provider
self._registry = registry
self._slot_limit = max(1, slot_limit)
self._progress_interval = progress_interval
self._poll_interval = poll_interval
self._clock = clock
# -- validation ----------------------------------------------------
def _validate(self, request) -> ValidatedRequest:
now_ms = int(time.time() * 1000)
if request.deadline_unix_ms <= now_ms:
raise codes.ExecutionFailure(
codes.INPUT_DEADLINE_INVALID, "deadline is not in the future"
)
budget_s = min((request.deadline_unix_ms - now_ms) / 1000.0, _MAX_DEADLINE_S)
model = self._validate_model(request.model)
self._validate_device(request.device_id)
text_artifact, ref_artifact = self._split_inputs(request.inputs)
output = self._single_output(request.outputs)
output_handle = _validate_handle(output.local_handle)
parent = os.path.dirname(output_handle)
if not os.path.isdir(parent):
raise codes.ExecutionFailure(
codes.INPUT_HANDLE_INVALID, "output handle directory does not exist"
)
raw = _read_input_file(text_artifact, _MAX_TEXT_BYTES)
try:
text = raw.decode("utf-8").strip()
except UnicodeDecodeError:
raise codes.ExecutionFailure(
codes.INPUT_TEXT_ENCODING, "input text is not valid UTF-8"
)
if not text:
raise codes.ExecutionFailure(codes.INPUT_TEXT_EMPTY, "input text is empty")
engine_kwargs: dict = {}
for name in sorted(request.parameters):
engine_kwargs[name] = _typed_parameter(name, request.parameters[name])
if ref_artifact is not None:
_read_input_file(ref_artifact, _MAX_REF_AUDIO_BYTES) # existence/bounds/checksum
engine_kwargs["ref_audio"] = _validate_handle(ref_artifact.local_handle)
return ValidatedRequest(
text=text,
output_handle=output_handle,
output_media_type=output.media_type or "audio/wav",
output_size_bound=int(output.expected_size_bytes),
engine_kwargs=engine_kwargs,
deadline_monotonic=self._clock() + budget_s,
catalog_model_id=request.model.catalog_model_id,
)
def _validate_model(self, spec):
wanted = (spec.catalog_model_id or "").strip()
if not wanted:
raise codes.ExecutionFailure(
codes.INPUT_MODEL_UNKNOWN, "catalog model id is required"
)
matches = [
model
for model in self._inventory.models()
if model.catalog_model_id == wanted
]
if not matches:
raise codes.ExecutionFailure(codes.INPUT_MODEL_UNKNOWN, "model not present")
model = matches[0]
if model.state != STATE_READY:
raise codes.ExecutionFailure(
codes.INPUT_MODEL_NOT_READY, "model is not READY"
)
if spec.model_version and spec.model_version != model.model_version:
raise codes.ExecutionFailure(
codes.INPUT_MODEL_UNKNOWN, "model version mismatch"
)
if not spec.model_digest or spec.model_digest != model.model_digest:
raise codes.ExecutionFailure(
codes.INPUT_MODEL_DIGEST_MISMATCH, "approved model digest mismatch"
)
if spec.precision and spec.precision not in model.precisions:
raise codes.ExecutionFailure(
codes.INPUT_MODEL_PRECISION, "precision not offered by this model"
)
return model
def _validate_device(self, device_id: str) -> None:
wanted = (device_id or "").strip()
if not wanted:
raise codes.ExecutionFailure(
codes.INPUT_DEVICE_UNKNOWN, "device id is required"
)
known = {device.device_id for device in self._inventory.devices()}
if wanted not in known:
raise codes.ExecutionFailure(
codes.INPUT_DEVICE_UNKNOWN, "device id not in inventory"
)
@staticmethod
def _split_inputs(inputs):
text_artifacts, audio_artifacts = [], []
for artifact in inputs:
if artifact.operation != pb2.LOCAL_ARTIFACT_OPERATION_READ:
raise codes.ExecutionFailure(
codes.INPUT_ARTIFACTS_INVALID, "inputs must be READ artifacts"
)
media = artifact.media_type or ""
if media.startswith("audio/"):
audio_artifacts.append(artifact)
elif media == "" or media.startswith("text/"):
text_artifacts.append(artifact)
else:
raise codes.ExecutionFailure(
codes.INPUT_ARTIFACTS_INVALID, f"unsupported input media {media!r}"
)
if len(text_artifacts) != 1 or len(audio_artifacts) > 1:
raise codes.ExecutionFailure(
codes.INPUT_ARTIFACTS_INVALID,
"tts needs exactly one text input and at most one reference audio",
)
return text_artifacts[0], (audio_artifacts[0] if audio_artifacts else None)
@staticmethod
def _single_output(outputs):
if len(outputs) != 1:
raise codes.ExecutionFailure(
codes.INPUT_ARTIFACTS_INVALID, "tts needs exactly one output artifact"
)
output = outputs[0]
if output.operation != pb2.LOCAL_ARTIFACT_OPERATION_WRITE:
raise codes.ExecutionFailure(
codes.INPUT_ARTIFACTS_INVALID, "output must be a WRITE artifact"
)
media = output.media_type or ""
if media and not media.startswith("audio/"):
raise codes.ExecutionFailure(
codes.INPUT_ARTIFACTS_INVALID, f"unsupported output media {media!r}"
)
return output
# -- execution -----------------------------------------------------
def execute(self, request, grpc_context=None):
"""Generator of ``pb2.ExecuteResponse``. Never raises for a
classified failure failures become terminal events."""
session = _Session(self, request)
return session.run(grpc_context)
class _Session:
def __init__(self, executor: Executor, request):
self._x = executor
self.request = request
self.job_id = request.job_id
self.attempt_id = request.attempt_id
self.sequence = 0
self.phase = "model_load"
self.terminal_sent = False
self.chars = 0
self.gpu_ms = 0
self.cpu_ms = 0
self.output_audio_ms = 0
# event builders ---------------------------------------------------
def _event(self, **payload):
self.sequence += 1
return pb2.ExecuteResponse(
event=pb2.ExecutionEvent(
job_id=self.job_id,
attempt_id=self.attempt_id,
sequence=self.sequence,
observed_at_unix_ms=int(time.time() * 1000),
**payload,
)
)
def _measurements(self):
return pb2.RuntimeMeasurements(
normalized_input_characters=self.chars,
output_audio_ms=self.output_audio_ms,
gpu_execution_ms=self.gpu_ms,
cpu_execution_ms=self.cpu_ms,
)
def _failed(self, failure: codes.ExecutionFailure):
self.terminal_sent = True
return self._event(
failed=pb2.ExecutionFailed(
failure_class=failure.failure_class,
stable_code=failure.stable_code,
safe_detail=failure.safe_detail,
measurements=self._measurements(),
)
)
def _canceled(self):
self.terminal_sent = True
return self._event(
canceled=pb2.ExecutionCanceled(measurements=self._measurements())
)
# main flow --------------------------------------------------------
def run(self, grpc_context):
if not self.attempt_id.strip() or not self.job_id.strip():
yield self._failed(
codes.ExecutionFailure(
codes.INPUT_ATTEMPT_IDENTITY, "job and attempt ids are required"
)
)
return
registry = self._x._registry
try:
record = registry.begin(self.job_id, self.attempt_id, self._x._slot_limit)
except codes.ExecutionFailure as failure:
yield self._failed(failure)
return
try:
yield from self._run_admitted(record, grpc_context)
finally:
terminal = "canceled"
if self.terminal_sent:
terminal = self._terminal_kind or "failed"
registry.finish(self.attempt_id, terminal)
_terminal_kind: str | None = None
def _run_admitted(self, record, grpc_context):
try:
validated = self._x._validate(self.request)
except codes.ExecutionFailure as failure:
self._terminal_kind = "failed"
yield self._failed(failure)
return
except Exception as exc: # adapter bug — still a classified event
self._terminal_kind = "failed"
yield self._failed(
codes.ExecutionFailure(codes.RUNTIME_CRASH, f"{type(exc).__name__}")
)
return
self.chars = len(validated.text)
yield self._event(started=pb2.ExecutionStarted())
worker = _EngineWorker(self._x._engine_provider, validated, self)
worker.start()
clock = self._x._clock
next_progress = clock() + self._x._progress_interval
progress_events = 0
while not worker.done.wait(self._x._poll_interval):
if record.cancel.is_set() or (
grpc_context is not None and not grpc_context.is_active()
):
self._terminal_kind = "canceled"
yield self._canceled()
return
now = clock()
if now >= validated.deadline_monotonic:
self._terminal_kind = "failed"
yield self._failed(codes.deadline_failure(self.phase))
return
if now >= next_progress and progress_events < _MAX_PROGRESS_EVENTS:
progress_events += 1
next_progress = now + self._x._progress_interval
permille = 100 if self.phase == "model_load" else 550
yield self._event(
progress=pb2.ExecutionProgress(
progress_permille=permille, stage_code=self.phase
)
)
if record.cancel.is_set():
self._terminal_kind = "canceled"
yield self._canceled()
return
if worker.error is not None:
self._terminal_kind = "failed"
yield self._failed(codes.classify_engine_error(worker.error, worker.phase))
return
try:
manifest = self._write_output(worker, validated)
except codes.ExecutionFailure as failure:
self._terminal_kind = "failed"
yield self._failed(failure)
return
self._terminal_kind = "completed"
self.terminal_sent = True
yield self._event(
completed=pb2.ExecutionCompleted(
outputs=[manifest], measurements=self._measurements()
)
)
def _write_output(self, worker, validated: ValidatedRequest):
ensure_backend_on_path()
tensor = worker.result
sample_rate = worker.sample_rate
if tensor is None or not hasattr(tensor, "numel") or tensor.numel() == 0:
raise codes.ExecutionFailure(
codes.INFERENCE_BAD_OUTPUT, "engine returned no audio"
)
if not isinstance(sample_rate, int) or sample_rate <= 0:
raise codes.ExecutionFailure(
codes.INFERENCE_BAD_OUTPUT, "engine reported no sample rate"
)
try:
from services.audio_io import atomic_save_wav # noqa: PLC0415
atomic_save_wav(validated.output_handle, tensor.detach().cpu(), sample_rate)
except codes.ExecutionFailure:
raise
except Exception as exc:
raise codes.ExecutionFailure(
codes.STORAGE_WRITE_FAILED, f"{type(exc).__name__}: {exc}"
)
try:
size = os.stat(validated.output_handle).st_size
sha = file_sha256(validated.output_handle)
except OSError as exc:
raise codes.ExecutionFailure(
codes.STORAGE_WRITE_FAILED, f"{type(exc).__name__}"
)
if 0 < validated.output_size_bound < size:
raise codes.ExecutionFailure(
codes.STORAGE_WRITE_FAILED, "output exceeds its size bound"
)
samples = tensor.numel() if tensor.dim() == 1 else tensor.shape[-1]
self.output_audio_ms = int(samples * 1000 / sample_rate)
return pb2.LocalArtifactManifest(
artifact_id=self.request.outputs[0].artifact_id,
local_handle=validated.output_handle,
size_bytes=size,
sha256=sha,
media_type=validated.output_media_type,
duration_ms=self.output_audio_ms,
)
class _EngineWorker:
"""Runs the engine on a daemon thread, recording phase and timings."""
def __init__(self, engine_provider, validated: ValidatedRequest, session: _Session):
self._engine_provider = engine_provider
self._validated = validated
self._session = session
self.done = threading.Event()
self.error: BaseException | None = None
self.result = None
self.sample_rate: int | None = None
self.phase = "model_load"
def start(self) -> None:
thread = threading.Thread(
target=self._run,
name=f"runtime-adapter-attempt-{self._session.attempt_id}",
daemon=True,
)
thread.start()
@staticmethod
def _synthesize(engine, text: str, params: dict):
"""Use the same seeded native path as OSS Gallery and ovnode workers."""
from services import tts_backend # noqa: PLC0415
if isinstance(engine, tts_backend.OmniVoiceBackend):
from api.routers.generation import _run_inference # noqa: PLC0415
with tts_backend.engine_in_use(engine):
return _run_inference(
engine._model, text, params.get("language"),
params.get("ref_audio"), params.get("ref_text"),
params.get("instruct"), params.get("duration"),
params.get("num_step", 16), params.get("guidance_scale", 2.0),
params.get("speed", 1.0), params.get("t_shift"),
params.get("denoise", True), params.get("postprocess_output", True),
params.get("layer_penalty_factor"),
params.get("position_temperature"),
params.get("class_temperature"), params.get("seed"),
)
return engine.generate(text, **params)
def _run(self) -> None:
wall_start = time.monotonic()
cpu_start = time.process_time()
try:
engine = self._engine_provider(self._validated.catalog_model_id)
ensure_ready = getattr(engine, "ensure_ready", None)
if callable(ensure_ready):
ensure_ready()
self.phase = "synthesis"
self._session.phase = "synthesis"
synth_start = time.monotonic()
self.result = self._synthesize(engine, self._validated.text, self._validated.engine_kwargs)
rate = getattr(engine, "sample_rate", None)
self.sample_rate = int(rate) if isinstance(rate, (int, float)) and rate else None
self._session.gpu_ms = int((time.monotonic() - synth_start) * 1000)
except BaseException as exc: # classified later, never lost
self.error = exc
finally:
self._session.cpu_ms = int((time.process_time() - cpu_start) * 1000)
if self._session.gpu_ms == 0 and self.error is None:
self._session.gpu_ms = int((time.monotonic() - wall_start) * 1000)
self.done.set()
+5
View File
@@ -0,0 +1,5 @@
"""Generated protocol stubs — DO NOT EDIT.
Regenerate with ``uv run python scripts/gen_runtime_adapter_protocol.py``
after any change to ``../runtime_adapter.proto``.
"""
File diff suppressed because one or more lines are too long
@@ -0,0 +1,330 @@
from google.protobuf.internal import containers as _containers
from google.protobuf.internal import enum_type_wrapper as _enum_type_wrapper
from google.protobuf import descriptor as _descriptor
from google.protobuf import message as _message
from collections.abc import Iterable as _Iterable, Mapping as _Mapping
from typing import ClassVar as _ClassVar, Optional as _Optional, Union as _Union
DESCRIPTOR: _descriptor.FileDescriptor
class ServingState(int, metaclass=_enum_type_wrapper.EnumTypeWrapper):
__slots__ = ()
SERVING_STATE_UNSPECIFIED: _ClassVar[ServingState]
SERVING_STATE_READY: _ClassVar[ServingState]
SERVING_STATE_DEGRADED: _ClassVar[ServingState]
SERVING_STATE_UNHEALTHY: _ClassVar[ServingState]
class RuntimeModelState(int, metaclass=_enum_type_wrapper.EnumTypeWrapper):
__slots__ = ()
RUNTIME_MODEL_STATE_UNSPECIFIED: _ClassVar[RuntimeModelState]
RUNTIME_MODEL_STATE_INSTALLED: _ClassVar[RuntimeModelState]
RUNTIME_MODEL_STATE_LOADING: _ClassVar[RuntimeModelState]
RUNTIME_MODEL_STATE_READY: _ClassVar[RuntimeModelState]
RUNTIME_MODEL_STATE_FAILED: _ClassVar[RuntimeModelState]
class LocalArtifactOperation(int, metaclass=_enum_type_wrapper.EnumTypeWrapper):
__slots__ = ()
LOCAL_ARTIFACT_OPERATION_UNSPECIFIED: _ClassVar[LocalArtifactOperation]
LOCAL_ARTIFACT_OPERATION_READ: _ClassVar[LocalArtifactOperation]
LOCAL_ARTIFACT_OPERATION_WRITE: _ClassVar[LocalArtifactOperation]
class RuntimeFailureClass(int, metaclass=_enum_type_wrapper.EnumTypeWrapper):
__slots__ = ()
RUNTIME_FAILURE_CLASS_UNSPECIFIED: _ClassVar[RuntimeFailureClass]
RUNTIME_FAILURE_CLASS_INPUT: _ClassVar[RuntimeFailureClass]
RUNTIME_FAILURE_CLASS_MODEL_LOAD: _ClassVar[RuntimeFailureClass]
RUNTIME_FAILURE_CLASS_INFERENCE: _ClassVar[RuntimeFailureClass]
RUNTIME_FAILURE_CLASS_GPU_RESOURCE: _ClassVar[RuntimeFailureClass]
RUNTIME_FAILURE_CLASS_LOCAL_STORAGE: _ClassVar[RuntimeFailureClass]
RUNTIME_FAILURE_CLASS_RUNTIME: _ClassVar[RuntimeFailureClass]
RUNTIME_FAILURE_CLASS_CANCELED: _ClassVar[RuntimeFailureClass]
class CancelDisposition(int, metaclass=_enum_type_wrapper.EnumTypeWrapper):
__slots__ = ()
CANCEL_DISPOSITION_UNSPECIFIED: _ClassVar[CancelDisposition]
CANCEL_DISPOSITION_ACCEPTED: _ClassVar[CancelDisposition]
CANCEL_DISPOSITION_ALREADY_TERMINAL: _ClassVar[CancelDisposition]
CANCEL_DISPOSITION_NOT_FOUND: _ClassVar[CancelDisposition]
SERVING_STATE_UNSPECIFIED: ServingState
SERVING_STATE_READY: ServingState
SERVING_STATE_DEGRADED: ServingState
SERVING_STATE_UNHEALTHY: ServingState
RUNTIME_MODEL_STATE_UNSPECIFIED: RuntimeModelState
RUNTIME_MODEL_STATE_INSTALLED: RuntimeModelState
RUNTIME_MODEL_STATE_LOADING: RuntimeModelState
RUNTIME_MODEL_STATE_READY: RuntimeModelState
RUNTIME_MODEL_STATE_FAILED: RuntimeModelState
LOCAL_ARTIFACT_OPERATION_UNSPECIFIED: LocalArtifactOperation
LOCAL_ARTIFACT_OPERATION_READ: LocalArtifactOperation
LOCAL_ARTIFACT_OPERATION_WRITE: LocalArtifactOperation
RUNTIME_FAILURE_CLASS_UNSPECIFIED: RuntimeFailureClass
RUNTIME_FAILURE_CLASS_INPUT: RuntimeFailureClass
RUNTIME_FAILURE_CLASS_MODEL_LOAD: RuntimeFailureClass
RUNTIME_FAILURE_CLASS_INFERENCE: RuntimeFailureClass
RUNTIME_FAILURE_CLASS_GPU_RESOURCE: RuntimeFailureClass
RUNTIME_FAILURE_CLASS_LOCAL_STORAGE: RuntimeFailureClass
RUNTIME_FAILURE_CLASS_RUNTIME: RuntimeFailureClass
RUNTIME_FAILURE_CLASS_CANCELED: RuntimeFailureClass
CANCEL_DISPOSITION_UNSPECIFIED: CancelDisposition
CANCEL_DISPOSITION_ACCEPTED: CancelDisposition
CANCEL_DISPOSITION_ALREADY_TERMINAL: CancelDisposition
CANCEL_DISPOSITION_NOT_FOUND: CancelDisposition
class ExecuteResponse(_message.Message):
__slots__ = ("event",)
EVENT_FIELD_NUMBER: _ClassVar[int]
event: ExecutionEvent
def __init__(self, event: _Optional[_Union[ExecutionEvent, _Mapping]] = ...) -> None: ...
class HealthRequest(_message.Message):
__slots__ = ()
def __init__(self) -> None: ...
class HealthResponse(_message.Message):
__slots__ = ("state", "runtime_version", "adapter_version", "health_flags")
STATE_FIELD_NUMBER: _ClassVar[int]
RUNTIME_VERSION_FIELD_NUMBER: _ClassVar[int]
ADAPTER_VERSION_FIELD_NUMBER: _ClassVar[int]
HEALTH_FLAGS_FIELD_NUMBER: _ClassVar[int]
state: ServingState
runtime_version: str
adapter_version: str
health_flags: _containers.RepeatedScalarFieldContainer[str]
def __init__(self, state: _Optional[_Union[ServingState, str]] = ..., runtime_version: _Optional[str] = ..., adapter_version: _Optional[str] = ..., health_flags: _Optional[_Iterable[str]] = ...) -> None: ...
class GetCapabilitiesRequest(_message.Message):
__slots__ = ()
def __init__(self) -> None: ...
class GetCapabilitiesResponse(_message.Message):
__slots__ = ("runtime_version", "adapter_version", "devices", "models")
RUNTIME_VERSION_FIELD_NUMBER: _ClassVar[int]
ADAPTER_VERSION_FIELD_NUMBER: _ClassVar[int]
DEVICES_FIELD_NUMBER: _ClassVar[int]
MODELS_FIELD_NUMBER: _ClassVar[int]
runtime_version: str
adapter_version: str
devices: _containers.RepeatedCompositeFieldContainer[RuntimeDevice]
models: _containers.RepeatedCompositeFieldContainer[RuntimeModel]
def __init__(self, runtime_version: _Optional[str] = ..., adapter_version: _Optional[str] = ..., devices: _Optional[_Iterable[_Union[RuntimeDevice, _Mapping]]] = ..., models: _Optional[_Iterable[_Union[RuntimeModel, _Mapping]]] = ...) -> None: ...
class RuntimeDevice(_message.Message):
__slots__ = ("device_id", "hardware_class", "total_vram_bytes", "total_slots", "free_slots")
DEVICE_ID_FIELD_NUMBER: _ClassVar[int]
HARDWARE_CLASS_FIELD_NUMBER: _ClassVar[int]
TOTAL_VRAM_BYTES_FIELD_NUMBER: _ClassVar[int]
TOTAL_SLOTS_FIELD_NUMBER: _ClassVar[int]
FREE_SLOTS_FIELD_NUMBER: _ClassVar[int]
device_id: str
hardware_class: str
total_vram_bytes: int
total_slots: int
free_slots: int
def __init__(self, device_id: _Optional[str] = ..., hardware_class: _Optional[str] = ..., total_vram_bytes: _Optional[int] = ..., total_slots: _Optional[int] = ..., free_slots: _Optional[int] = ...) -> None: ...
class RuntimeModel(_message.Message):
__slots__ = ("catalog_model_id", "model_version", "model_digest", "precisions", "features", "state")
CATALOG_MODEL_ID_FIELD_NUMBER: _ClassVar[int]
MODEL_VERSION_FIELD_NUMBER: _ClassVar[int]
MODEL_DIGEST_FIELD_NUMBER: _ClassVar[int]
PRECISIONS_FIELD_NUMBER: _ClassVar[int]
FEATURES_FIELD_NUMBER: _ClassVar[int]
STATE_FIELD_NUMBER: _ClassVar[int]
catalog_model_id: str
model_version: str
model_digest: str
precisions: _containers.RepeatedScalarFieldContainer[str]
features: _containers.RepeatedScalarFieldContainer[str]
state: RuntimeModelState
def __init__(self, catalog_model_id: _Optional[str] = ..., model_version: _Optional[str] = ..., model_digest: _Optional[str] = ..., precisions: _Optional[_Iterable[str]] = ..., features: _Optional[_Iterable[str]] = ..., state: _Optional[_Union[RuntimeModelState, str]] = ...) -> None: ...
class ExecuteRequest(_message.Message):
__slots__ = ("job_id", "attempt_id", "device_id", "slot_id", "model", "parameters", "inputs", "outputs", "deadline_unix_ms", "maximum_preview_bytes")
class ParametersEntry(_message.Message):
__slots__ = ("key", "value")
KEY_FIELD_NUMBER: _ClassVar[int]
VALUE_FIELD_NUMBER: _ClassVar[int]
key: str
value: ParameterValue
def __init__(self, key: _Optional[str] = ..., value: _Optional[_Union[ParameterValue, _Mapping]] = ...) -> None: ...
JOB_ID_FIELD_NUMBER: _ClassVar[int]
ATTEMPT_ID_FIELD_NUMBER: _ClassVar[int]
DEVICE_ID_FIELD_NUMBER: _ClassVar[int]
SLOT_ID_FIELD_NUMBER: _ClassVar[int]
MODEL_FIELD_NUMBER: _ClassVar[int]
PARAMETERS_FIELD_NUMBER: _ClassVar[int]
INPUTS_FIELD_NUMBER: _ClassVar[int]
OUTPUTS_FIELD_NUMBER: _ClassVar[int]
DEADLINE_UNIX_MS_FIELD_NUMBER: _ClassVar[int]
MAXIMUM_PREVIEW_BYTES_FIELD_NUMBER: _ClassVar[int]
job_id: str
attempt_id: str
device_id: str
slot_id: str
model: ModelSpec
parameters: _containers.MessageMap[str, ParameterValue]
inputs: _containers.RepeatedCompositeFieldContainer[LocalArtifact]
outputs: _containers.RepeatedCompositeFieldContainer[LocalArtifact]
deadline_unix_ms: int
maximum_preview_bytes: int
def __init__(self, job_id: _Optional[str] = ..., attempt_id: _Optional[str] = ..., device_id: _Optional[str] = ..., slot_id: _Optional[str] = ..., model: _Optional[_Union[ModelSpec, _Mapping]] = ..., parameters: _Optional[_Mapping[str, ParameterValue]] = ..., inputs: _Optional[_Iterable[_Union[LocalArtifact, _Mapping]]] = ..., outputs: _Optional[_Iterable[_Union[LocalArtifact, _Mapping]]] = ..., deadline_unix_ms: _Optional[int] = ..., maximum_preview_bytes: _Optional[int] = ...) -> None: ...
class ModelSpec(_message.Message):
__slots__ = ("catalog_model_id", "model_version", "model_digest", "precision")
CATALOG_MODEL_ID_FIELD_NUMBER: _ClassVar[int]
MODEL_VERSION_FIELD_NUMBER: _ClassVar[int]
MODEL_DIGEST_FIELD_NUMBER: _ClassVar[int]
PRECISION_FIELD_NUMBER: _ClassVar[int]
catalog_model_id: str
model_version: str
model_digest: str
precision: str
def __init__(self, catalog_model_id: _Optional[str] = ..., model_version: _Optional[str] = ..., model_digest: _Optional[str] = ..., precision: _Optional[str] = ...) -> None: ...
class ParameterValue(_message.Message):
__slots__ = ("string_value", "integer_value", "number_value", "boolean_value")
STRING_VALUE_FIELD_NUMBER: _ClassVar[int]
INTEGER_VALUE_FIELD_NUMBER: _ClassVar[int]
NUMBER_VALUE_FIELD_NUMBER: _ClassVar[int]
BOOLEAN_VALUE_FIELD_NUMBER: _ClassVar[int]
string_value: str
integer_value: int
number_value: float
boolean_value: bool
def __init__(self, string_value: _Optional[str] = ..., integer_value: _Optional[int] = ..., number_value: _Optional[float] = ..., boolean_value: _Optional[bool] = ...) -> None: ...
class LocalArtifact(_message.Message):
__slots__ = ("artifact_id", "local_handle", "operation", "expected_size_bytes", "expected_sha256", "media_type")
ARTIFACT_ID_FIELD_NUMBER: _ClassVar[int]
LOCAL_HANDLE_FIELD_NUMBER: _ClassVar[int]
OPERATION_FIELD_NUMBER: _ClassVar[int]
EXPECTED_SIZE_BYTES_FIELD_NUMBER: _ClassVar[int]
EXPECTED_SHA256_FIELD_NUMBER: _ClassVar[int]
MEDIA_TYPE_FIELD_NUMBER: _ClassVar[int]
artifact_id: str
local_handle: str
operation: LocalArtifactOperation
expected_size_bytes: int
expected_sha256: str
media_type: str
def __init__(self, artifact_id: _Optional[str] = ..., local_handle: _Optional[str] = ..., operation: _Optional[_Union[LocalArtifactOperation, str]] = ..., expected_size_bytes: _Optional[int] = ..., expected_sha256: _Optional[str] = ..., media_type: _Optional[str] = ...) -> None: ...
class ExecutionEvent(_message.Message):
__slots__ = ("job_id", "attempt_id", "sequence", "observed_at_unix_ms", "started", "progress", "preview", "completed", "failed", "canceled")
JOB_ID_FIELD_NUMBER: _ClassVar[int]
ATTEMPT_ID_FIELD_NUMBER: _ClassVar[int]
SEQUENCE_FIELD_NUMBER: _ClassVar[int]
OBSERVED_AT_UNIX_MS_FIELD_NUMBER: _ClassVar[int]
STARTED_FIELD_NUMBER: _ClassVar[int]
PROGRESS_FIELD_NUMBER: _ClassVar[int]
PREVIEW_FIELD_NUMBER: _ClassVar[int]
COMPLETED_FIELD_NUMBER: _ClassVar[int]
FAILED_FIELD_NUMBER: _ClassVar[int]
CANCELED_FIELD_NUMBER: _ClassVar[int]
job_id: str
attempt_id: str
sequence: int
observed_at_unix_ms: int
started: ExecutionStarted
progress: ExecutionProgress
preview: PreviewChunk
completed: ExecutionCompleted
failed: ExecutionFailed
canceled: ExecutionCanceled
def __init__(self, job_id: _Optional[str] = ..., attempt_id: _Optional[str] = ..., sequence: _Optional[int] = ..., observed_at_unix_ms: _Optional[int] = ..., started: _Optional[_Union[ExecutionStarted, _Mapping]] = ..., progress: _Optional[_Union[ExecutionProgress, _Mapping]] = ..., preview: _Optional[_Union[PreviewChunk, _Mapping]] = ..., completed: _Optional[_Union[ExecutionCompleted, _Mapping]] = ..., failed: _Optional[_Union[ExecutionFailed, _Mapping]] = ..., canceled: _Optional[_Union[ExecutionCanceled, _Mapping]] = ...) -> None: ...
class ExecutionStarted(_message.Message):
__slots__ = ()
def __init__(self) -> None: ...
class ExecutionProgress(_message.Message):
__slots__ = ("progress_permille", "stage_code")
PROGRESS_PERMILLE_FIELD_NUMBER: _ClassVar[int]
STAGE_CODE_FIELD_NUMBER: _ClassVar[int]
progress_permille: int
stage_code: str
def __init__(self, progress_permille: _Optional[int] = ..., stage_code: _Optional[str] = ...) -> None: ...
class PreviewChunk(_message.Message):
__slots__ = ("sequence", "media_type", "data")
SEQUENCE_FIELD_NUMBER: _ClassVar[int]
MEDIA_TYPE_FIELD_NUMBER: _ClassVar[int]
DATA_FIELD_NUMBER: _ClassVar[int]
sequence: int
media_type: str
data: bytes
def __init__(self, sequence: _Optional[int] = ..., media_type: _Optional[str] = ..., data: _Optional[bytes] = ...) -> None: ...
class ExecutionCompleted(_message.Message):
__slots__ = ("outputs", "measurements")
OUTPUTS_FIELD_NUMBER: _ClassVar[int]
MEASUREMENTS_FIELD_NUMBER: _ClassVar[int]
outputs: _containers.RepeatedCompositeFieldContainer[LocalArtifactManifest]
measurements: RuntimeMeasurements
def __init__(self, outputs: _Optional[_Iterable[_Union[LocalArtifactManifest, _Mapping]]] = ..., measurements: _Optional[_Union[RuntimeMeasurements, _Mapping]] = ...) -> None: ...
class LocalArtifactManifest(_message.Message):
__slots__ = ("artifact_id", "local_handle", "size_bytes", "sha256", "media_type", "duration_ms")
ARTIFACT_ID_FIELD_NUMBER: _ClassVar[int]
LOCAL_HANDLE_FIELD_NUMBER: _ClassVar[int]
SIZE_BYTES_FIELD_NUMBER: _ClassVar[int]
SHA256_FIELD_NUMBER: _ClassVar[int]
MEDIA_TYPE_FIELD_NUMBER: _ClassVar[int]
DURATION_MS_FIELD_NUMBER: _ClassVar[int]
artifact_id: str
local_handle: str
size_bytes: int
sha256: str
media_type: str
duration_ms: int
def __init__(self, artifact_id: _Optional[str] = ..., local_handle: _Optional[str] = ..., size_bytes: _Optional[int] = ..., sha256: _Optional[str] = ..., media_type: _Optional[str] = ..., duration_ms: _Optional[int] = ...) -> None: ...
class ExecutionFailed(_message.Message):
__slots__ = ("failure_class", "stable_code", "safe_detail", "measurements")
FAILURE_CLASS_FIELD_NUMBER: _ClassVar[int]
STABLE_CODE_FIELD_NUMBER: _ClassVar[int]
SAFE_DETAIL_FIELD_NUMBER: _ClassVar[int]
MEASUREMENTS_FIELD_NUMBER: _ClassVar[int]
failure_class: RuntimeFailureClass
stable_code: str
safe_detail: str
measurements: RuntimeMeasurements
def __init__(self, failure_class: _Optional[_Union[RuntimeFailureClass, str]] = ..., stable_code: _Optional[str] = ..., safe_detail: _Optional[str] = ..., measurements: _Optional[_Union[RuntimeMeasurements, _Mapping]] = ...) -> None: ...
class ExecutionCanceled(_message.Message):
__slots__ = ("measurements",)
MEASUREMENTS_FIELD_NUMBER: _ClassVar[int]
measurements: RuntimeMeasurements
def __init__(self, measurements: _Optional[_Union[RuntimeMeasurements, _Mapping]] = ...) -> None: ...
class RuntimeMeasurements(_message.Message):
__slots__ = ("normalized_input_characters", "input_audio_ms", "output_audio_ms", "gpu_execution_ms", "cpu_execution_ms")
NORMALIZED_INPUT_CHARACTERS_FIELD_NUMBER: _ClassVar[int]
INPUT_AUDIO_MS_FIELD_NUMBER: _ClassVar[int]
OUTPUT_AUDIO_MS_FIELD_NUMBER: _ClassVar[int]
GPU_EXECUTION_MS_FIELD_NUMBER: _ClassVar[int]
CPU_EXECUTION_MS_FIELD_NUMBER: _ClassVar[int]
normalized_input_characters: int
input_audio_ms: int
output_audio_ms: int
gpu_execution_ms: int
cpu_execution_ms: int
def __init__(self, normalized_input_characters: _Optional[int] = ..., input_audio_ms: _Optional[int] = ..., output_audio_ms: _Optional[int] = ..., gpu_execution_ms: _Optional[int] = ..., cpu_execution_ms: _Optional[int] = ...) -> None: ...
class CancelRequest(_message.Message):
__slots__ = ("job_id", "attempt_id", "reason_code", "deadline_unix_ms")
JOB_ID_FIELD_NUMBER: _ClassVar[int]
ATTEMPT_ID_FIELD_NUMBER: _ClassVar[int]
REASON_CODE_FIELD_NUMBER: _ClassVar[int]
DEADLINE_UNIX_MS_FIELD_NUMBER: _ClassVar[int]
job_id: str
attempt_id: str
reason_code: str
deadline_unix_ms: int
def __init__(self, job_id: _Optional[str] = ..., attempt_id: _Optional[str] = ..., reason_code: _Optional[str] = ..., deadline_unix_ms: _Optional[int] = ...) -> None: ...
class CancelResponse(_message.Message):
__slots__ = ("disposition",)
DISPOSITION_FIELD_NUMBER: _ClassVar[int]
disposition: CancelDisposition
def __init__(self, disposition: _Optional[_Union[CancelDisposition, str]] = ...) -> None: ...
@@ -0,0 +1,229 @@
# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT!
"""Client and server classes corresponding to protobuf-defined services."""
import grpc
import warnings
from . import runtime_adapter_pb2 as runtime__adapter__pb2
GRPC_GENERATED_VERSION = '1.81.1'
GRPC_VERSION = grpc.__version__
_version_not_supported = False
try:
from grpc._utilities import first_version_is_lower
_version_not_supported = first_version_is_lower(GRPC_VERSION, GRPC_GENERATED_VERSION)
except ImportError:
_version_not_supported = True
if _version_not_supported:
raise RuntimeError(
f'The grpc package installed is at version {GRPC_VERSION},'
+ ' but the generated code in runtime_adapter_pb2_grpc.py depends on'
+ f' grpcio>={GRPC_GENERATED_VERSION}.'
+ f' Please upgrade your grpc module to grpcio>={GRPC_GENERATED_VERSION}'
+ f' or downgrade your generated code using grpcio-tools<={GRPC_VERSION}.'
)
class RuntimeAdapterServiceStub:
"""RuntimeAdapterService is local to a GPU Node and is never publicly exposed.
"""
def __init__(self, channel):
"""Constructor.
Args:
channel: A grpc.Channel.
"""
self.Health = channel.unary_unary(
'/voicestudio.runtime.v1.RuntimeAdapterService/Health',
request_serializer=runtime__adapter__pb2.HealthRequest.SerializeToString,
response_deserializer=runtime__adapter__pb2.HealthResponse.FromString,
_registered_method=True)
self.GetCapabilities = channel.unary_unary(
'/voicestudio.runtime.v1.RuntimeAdapterService/GetCapabilities',
request_serializer=runtime__adapter__pb2.GetCapabilitiesRequest.SerializeToString,
response_deserializer=runtime__adapter__pb2.GetCapabilitiesResponse.FromString,
_registered_method=True)
self.Execute = channel.unary_stream(
'/voicestudio.runtime.v1.RuntimeAdapterService/Execute',
request_serializer=runtime__adapter__pb2.ExecuteRequest.SerializeToString,
response_deserializer=runtime__adapter__pb2.ExecuteResponse.FromString,
_registered_method=True)
self.Cancel = channel.unary_unary(
'/voicestudio.runtime.v1.RuntimeAdapterService/Cancel',
request_serializer=runtime__adapter__pb2.CancelRequest.SerializeToString,
response_deserializer=runtime__adapter__pb2.CancelResponse.FromString,
_registered_method=True)
class RuntimeAdapterServiceServicer:
"""RuntimeAdapterService is local to a GPU Node and is never publicly exposed.
"""
def Health(self, request, context):
"""Missing associated documentation comment in .proto file."""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def GetCapabilities(self, request, context):
"""Missing associated documentation comment in .proto file."""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def Execute(self, request, context):
"""Missing associated documentation comment in .proto file."""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def Cancel(self, request, context):
"""Missing associated documentation comment in .proto file."""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def add_RuntimeAdapterServiceServicer_to_server(servicer, server):
rpc_method_handlers = {
'Health': grpc.unary_unary_rpc_method_handler(
servicer.Health,
request_deserializer=runtime__adapter__pb2.HealthRequest.FromString,
response_serializer=runtime__adapter__pb2.HealthResponse.SerializeToString,
),
'GetCapabilities': grpc.unary_unary_rpc_method_handler(
servicer.GetCapabilities,
request_deserializer=runtime__adapter__pb2.GetCapabilitiesRequest.FromString,
response_serializer=runtime__adapter__pb2.GetCapabilitiesResponse.SerializeToString,
),
'Execute': grpc.unary_stream_rpc_method_handler(
servicer.Execute,
request_deserializer=runtime__adapter__pb2.ExecuteRequest.FromString,
response_serializer=runtime__adapter__pb2.ExecuteResponse.SerializeToString,
),
'Cancel': grpc.unary_unary_rpc_method_handler(
servicer.Cancel,
request_deserializer=runtime__adapter__pb2.CancelRequest.FromString,
response_serializer=runtime__adapter__pb2.CancelResponse.SerializeToString,
),
}
generic_handler = grpc.method_handlers_generic_handler(
'voicestudio.runtime.v1.RuntimeAdapterService', rpc_method_handlers)
server.add_generic_rpc_handlers((generic_handler,))
server.add_registered_method_handlers('voicestudio.runtime.v1.RuntimeAdapterService', rpc_method_handlers)
# This class is part of an EXPERIMENTAL API.
class RuntimeAdapterService:
"""RuntimeAdapterService is local to a GPU Node and is never publicly exposed.
"""
@staticmethod
def Health(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_unary(
request,
target,
'/voicestudio.runtime.v1.RuntimeAdapterService/Health',
runtime__adapter__pb2.HealthRequest.SerializeToString,
runtime__adapter__pb2.HealthResponse.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)
@staticmethod
def GetCapabilities(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_unary(
request,
target,
'/voicestudio.runtime.v1.RuntimeAdapterService/GetCapabilities',
runtime__adapter__pb2.GetCapabilitiesRequest.SerializeToString,
runtime__adapter__pb2.GetCapabilitiesResponse.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)
@staticmethod
def Execute(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_stream(
request,
target,
'/voicestudio.runtime.v1.RuntimeAdapterService/Execute',
runtime__adapter__pb2.ExecuteRequest.SerializeToString,
runtime__adapter__pb2.ExecuteResponse.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)
@staticmethod
def Cancel(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_unary(
request,
target,
'/voicestudio.runtime.v1.RuntimeAdapterService/Cancel',
runtime__adapter__pb2.CancelRequest.SerializeToString,
runtime__adapter__pb2.CancelResponse.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)
+318
View File
@@ -0,0 +1,318 @@
"""Device and model inventory reported through Health/GetCapabilities.
The server is written against the small protocol at the top of this module so
tests can substitute fakes; :class:`ProductionInventory` is the real thing,
wired to ``services.tts_backend``'s engine registry, ``services.hf_revisions``
pinned revisions, and :mod:`runtime_adapter.digest`.
State rules (mirrors the Go preflight's expectations):
- READY is **explicit**: engine registered, availability probe passed, the
pinned snapshot fully present on disk, and a digest computed. Anything
less is INSTALLED / LOADING / FAILED never READY.
- A loading or failed model is still listed (with its true state) so the
Gateway can observe it; only READY models are schedulable.
"""
from __future__ import annotations
import os
import threading
import time
from dataclasses import dataclass, field
from . import SLOTS_ENV
from ._paths import ensure_backend_on_path
from .digest import snapshot_digest
STATE_INSTALLED = "installed"
STATE_LOADING = "loading"
STATE_READY = "ready"
STATE_FAILED = "failed"
@dataclass(frozen=True)
class DeviceInfo:
device_id: str
hardware_class: str
total_vram_bytes: int
total_slots: int
free_slots: int
@dataclass(frozen=True)
class ModelInfo:
catalog_model_id: str
model_version: str
model_digest: str
precisions: tuple[str, ...] = ()
features: tuple[str, ...] = ()
state: str = STATE_INSTALLED
#: Engines this adapter can attest as digest-pinned models: TTS engine id →
#: curated Hugging Face repo (must be pinned in ``services.hf_revisions``).
#: Engines without a single pinned weights repo (external API servers,
#: multi-model muxes) are deliberately absent — they cannot be digest-pinned.
ENGINE_MODEL_REPOS: dict[str, str] = {
"omnivoice": "k2-fsa/OmniVoice",
"voxcpm2": "openbmb/VoxCPM2",
"moss-tts-nano": "OpenMOSS-Team/MOSS-TTS-Nano-100M",
"kittentts": "KittenML/kitten-tts-mini-0.8",
"cosyvoice": "FunAudioLLM/Fun-CosyVoice3-0.5B-2512",
"moss-tts-v15": "OpenMOSS-Team/MOSS-TTS-v1.5",
}
def catalog_model_version(revision: str, model_digest: str) -> str:
"""Return the immutable catalog version for an attested model snapshot.
A Hugging Face revision names source history, not necessarily the exact
snapshot bytes installed on a node. The catalog version therefore carries
a short, deterministic digest suffix. A changed snapshot becomes a new
catalog identity instead of mutating an identity retained by Jobs.
"""
digest = model_digest.removeprefix("sha256:")
if len(revision) != 40 or len(digest) != 64:
raise ValueError("model identity requires a SHA revision and SHA-256 digest")
return f"{revision}+sha256-{digest[:16]}"
def slots_per_device(default: int = 1) -> int:
raw = os.environ.get(SLOTS_ENV, "").strip()
try:
value = int(raw) if raw else default
except ValueError:
return default
return max(1, min(value, 64))
@dataclass
class ProductionInventory:
"""Real host inventory. All heavy imports happen inside methods.
``models()`` is memoized for ``model_ttl_s`` under a lock: the first call
hashes every installed snapshot (minutes for multi-GB weights, then cached
in the on-disk digest sidecar), and Health + GetCapabilities arrive
back-to-back. Call :meth:`warm` before serving so the first RPC never
pays the hashing cost inside its deadline.
"""
slots: int = field(default_factory=slots_per_device)
model_ttl_s: float = 15.0
def __post_init__(self):
self._model_lock = threading.Lock()
self._model_cache: list[ModelInfo] | None = None
self._model_cache_at = 0.0
def warm(self) -> None:
self.models()
def devices(self, busy_slots: int = 0) -> list[DeviceInfo]:
ensure_backend_on_path()
devices = self._accelerators() or [self._cpu_device()]
return [self._with_slots(device, busy_slots) for device in devices]
def _with_slots(self, device: DeviceInfo, busy_slots: int) -> DeviceInfo:
free = max(0, min(device.total_slots - busy_slots, device.total_slots))
return DeviceInfo(
device_id=device.device_id,
hardware_class=device.hardware_class,
total_vram_bytes=device.total_vram_bytes,
total_slots=device.total_slots,
free_slots=free,
)
def _accelerators(self) -> list[DeviceInfo]:
try:
import torch # noqa: PLC0415
except Exception:
return []
found: list[DeviceInfo] = []
try:
if torch.cuda.is_available():
for index in range(torch.cuda.device_count()):
props = torch.cuda.get_device_properties(index)
found.append(
DeviceInfo(
device_id=f"cuda:{index}",
hardware_class=torch.cuda.get_device_name(index),
total_vram_bytes=int(props.total_memory),
total_slots=self.slots,
free_slots=self.slots,
)
)
return found
except Exception:
pass
try:
if getattr(torch.backends, "mps", None) and torch.backends.mps.is_available():
vram = 0
recommended = getattr(torch.mps, "recommended_max_memory", None)
if callable(recommended):
try:
vram = int(recommended())
except Exception:
vram = 0
if vram <= 0:
vram = _system_memory_bytes()
return [
DeviceInfo(
device_id="mps:0",
hardware_class="apple-silicon-mps",
total_vram_bytes=vram,
total_slots=self.slots,
free_slots=self.slots,
)
]
except Exception:
pass
return []
def _cpu_device(self) -> DeviceInfo:
# A CPU-only node is a valid (slow) execution device. total_vram_bytes
# carries system memory so the Gateway's ">0" validity check reflects
# real capacity rather than a made-up constant.
import platform # noqa: PLC0415
return DeviceInfo(
device_id="cpu:0",
hardware_class=platform.processor() or platform.machine() or "cpu",
total_vram_bytes=_system_memory_bytes(),
total_slots=self.slots,
free_slots=self.slots,
)
def models(self) -> list[ModelInfo]:
with self._model_lock:
now = time.monotonic()
if (
self._model_cache is not None
and now - self._model_cache_at < self.model_ttl_s
):
return list(self._model_cache)
self._model_cache = self._scan_models()
self._model_cache_at = time.monotonic()
return list(self._model_cache)
def _scan_models(self) -> list[ModelInfo]:
ensure_backend_on_path()
from services.hf_cache_repair import repo_cache_dir # noqa: PLC0415
from services.hf_revisions import installed_revision # noqa: PLC0415
from services.tts_backend import get_backend_class # noqa: PLC0415
models: list[ModelInfo] = []
for engine_id, repo_id in sorted(ENGINE_MODEL_REPOS.items()):
try:
backend_cls = get_backend_class(engine_id)
except Exception:
continue # engine not registered in this build
repo_dir = repo_cache_dir(repo_id)
try:
revision = installed_revision(repo_id, os.path.dirname(repo_dir))
except ValueError:
continue # repo not in the curated catalog — cannot attest
snapshot = os.path.join(repo_dir, "snapshots", revision)
if not os.path.isdir(snapshot):
continue # weights not installed at the pinned revision
models.append(
self._model_state(engine_id, backend_cls, repo_dir, revision, snapshot)
)
return models
def _model_state(
self, engine_id: str, backend_cls, repo_dir: str, revision: str, snapshot: str
) -> ModelInfo:
base = ModelInfo(
catalog_model_id=engine_id,
model_version=revision,
model_digest="",
precisions=self._precisions(backend_cls),
features=self._features(backend_cls),
)
try:
ok, _message = backend_cls.is_available()
except Exception:
return _replace_state(base, STATE_FAILED)
if not ok:
return _replace_state(base, STATE_INSTALLED)
if _snapshot_incomplete(repo_dir, snapshot):
return _replace_state(base, STATE_LOADING)
try:
model_digest = snapshot_digest(
snapshot,
cache_path=os.path.join(repo_dir, f"voicestudio-digest-{revision}.json"),
)
except OSError:
return _replace_state(base, STATE_LOADING)
return ModelInfo(
catalog_model_id=base.catalog_model_id,
model_version=catalog_model_version(base.model_version, model_digest),
model_digest=model_digest,
precisions=base.precisions,
features=base.features,
state=STATE_READY,
)
def _precisions(self, backend_cls) -> tuple[str, ...]:
# Advisory execution precisions. fp32 always works; fp16 is offered
# when the engine targets an accelerator this host actually has.
compat = tuple(getattr(backend_cls, "gpu_compat", ("cpu",)))
try:
from core.device_caps import detect_host_caps # noqa: PLC0415
family = detect_host_caps().family
except Exception:
family = "cpu"
if family != "cpu" and family in compat:
return ("fp16", "fp32")
return ("fp32",)
def _features(self, backend_cls) -> tuple[str, ...]:
features = ["tts"]
if getattr(backend_cls, "supports_cloning", False) is True:
features.append("voice_clone")
if getattr(backend_cls, "supports_voice_design", False):
features.append("voice_design")
if getattr(backend_cls, "supports_emotion", False):
features.append("emotion")
return tuple(features)
def _replace_state(model: ModelInfo, state: str) -> ModelInfo:
return ModelInfo(
catalog_model_id=model.catalog_model_id,
model_version=model.model_version,
model_digest=model.model_digest,
precisions=model.precisions,
features=model.features,
state=state,
)
def _snapshot_incomplete(repo_dir: str, snapshot: str) -> bool:
"""A download in flight leaves ``*.incomplete`` blobs or dangling links."""
blobs = os.path.join(repo_dir, "blobs")
try:
if any(name.endswith(".incomplete") for name in os.listdir(blobs)):
return True
except OSError:
pass
for current, _dirs, files in os.walk(snapshot):
for name in files:
path = os.path.join(current, name)
if not os.path.exists(path): # dangling symlink
return True
return False
def _system_memory_bytes() -> int:
try:
import psutil # noqa: PLC0415
return int(psutil.virtual_memory().total)
except Exception:
try:
return os.sysconf("SC_PAGE_SIZE") * os.sysconf("SC_PHYS_PAGES")
except (ValueError, OSError, AttributeError):
return 1 # still nonzero: the preflight requires > 0
+63
View File
@@ -0,0 +1,63 @@
"""Wires the adapter to the real VoiceStudio backend.
Kept separate from ``server.py`` so tests can build a
:class:`~runtime_adapter.server.RuntimeContext` from fakes without importing
torch or the engine registry.
"""
from __future__ import annotations
import sys
from . import ADAPTER_VERSION
from ._paths import ensure_backend_on_path
from .inventory import ProductionInventory, slots_per_device
from .server import RuntimeContext
def production_engine_provider(catalog_model_id: str):
"""Resolve a READY catalog model id to its cached engine instance."""
ensure_backend_on_path()
from services.tts_backend import get_engine_instance_for # noqa: PLC0415
return get_engine_instance_for(catalog_model_id)
def build_runtime_context() -> RuntimeContext:
ensure_backend_on_path()
from core.version import APP_VERSION # noqa: PLC0415
slots = slots_per_device()
return RuntimeContext(
runtime_version=APP_VERSION,
adapter_version=ADAPTER_VERSION,
inventory=ProductionInventory(slots=slots),
engine_provider=production_engine_provider,
slot_limit=slots,
)
def prewarm_engines(context: RuntimeContext) -> None:
"""Load and compile every READY model before the socket accepts work.
The GPU Gateway leases an attempt for a bounded window and renews it from
execution evidence. A cold engine produces no evidence: weight loading and
torch compilation can run for minutes emitting nothing, so the lease
expires mid-load, the attempt is fenced, the Job requeues, and the next
attempt pays the same cost a loop that never yields audio.
Paying that cost once at startup, before the adapter is reachable, means
the first real Execute begins inference immediately. Preflight already
refuses a runtime with no READY model, so a failure here is reported and
the model is dropped from the advertised set rather than being offered as
schedulable capacity the node cannot actually serve promptly.
"""
ensure_backend_on_path()
for model in context.inventory.models():
if model.state != "ready":
continue
try:
context.engine_provider(model.catalog_model_id)
except Exception as error: # noqa: BLE001 - reported, never fatal
print(
f"runtime adapter: prewarm of {model.catalog_model_id} failed: {error}",
file=sys.stderr,
)
@@ -0,0 +1,199 @@
syntax = "proto3";
package voicestudio.runtime.v1;
option go_package = "github.com/velixio/vssaas/api/gen/runtime/v1;runtimev1";
// RuntimeAdapterService is local to a GPU Node and is never publicly exposed.
service RuntimeAdapterService {
rpc Health(HealthRequest) returns (HealthResponse);
rpc GetCapabilities(GetCapabilitiesRequest) returns (GetCapabilitiesResponse);
rpc Execute(ExecuteRequest) returns (stream ExecuteResponse);
rpc Cancel(CancelRequest) returns (CancelResponse);
}
message ExecuteResponse { ExecutionEvent event = 1; }
message HealthRequest {}
message HealthResponse {
ServingState state = 1;
string runtime_version = 2;
string adapter_version = 3;
repeated string health_flags = 4;
}
enum ServingState {
SERVING_STATE_UNSPECIFIED = 0;
SERVING_STATE_READY = 1;
SERVING_STATE_DEGRADED = 2;
SERVING_STATE_UNHEALTHY = 3;
}
message GetCapabilitiesRequest {}
message GetCapabilitiesResponse {
string runtime_version = 1;
string adapter_version = 2;
repeated RuntimeDevice devices = 3;
repeated RuntimeModel models = 4;
}
message RuntimeDevice {
string device_id = 1;
string hardware_class = 2;
uint64 total_vram_bytes = 3;
uint32 total_slots = 4;
uint32 free_slots = 5;
}
message RuntimeModel {
string catalog_model_id = 1;
string model_version = 2;
string model_digest = 3;
repeated string precisions = 4;
repeated string features = 5;
RuntimeModelState state = 6;
}
enum RuntimeModelState {
RUNTIME_MODEL_STATE_UNSPECIFIED = 0;
RUNTIME_MODEL_STATE_INSTALLED = 1;
RUNTIME_MODEL_STATE_LOADING = 2;
RUNTIME_MODEL_STATE_READY = 3;
RUNTIME_MODEL_STATE_FAILED = 4;
}
message ExecuteRequest {
string job_id = 1;
string attempt_id = 2;
string device_id = 3;
string slot_id = 4;
ModelSpec model = 5;
map<string, ParameterValue> parameters = 6;
repeated LocalArtifact inputs = 7;
repeated LocalArtifact outputs = 8;
int64 deadline_unix_ms = 9;
uint32 maximum_preview_bytes = 10;
}
message ModelSpec {
string catalog_model_id = 1;
string model_version = 2;
string model_digest = 3;
string precision = 4;
}
message ParameterValue {
oneof value {
string string_value = 1;
int64 integer_value = 2;
double number_value = 3;
bool boolean_value = 4;
}
}
message LocalArtifact {
string artifact_id = 1;
string local_handle = 2;
LocalArtifactOperation operation = 3;
uint64 expected_size_bytes = 4;
string expected_sha256 = 5;
string media_type = 6;
}
enum LocalArtifactOperation {
LOCAL_ARTIFACT_OPERATION_UNSPECIFIED = 0;
LOCAL_ARTIFACT_OPERATION_READ = 1;
LOCAL_ARTIFACT_OPERATION_WRITE = 2;
}
message ExecutionEvent {
string job_id = 1;
string attempt_id = 2;
uint64 sequence = 3;
int64 observed_at_unix_ms = 4;
oneof payload {
ExecutionStarted started = 10;
ExecutionProgress progress = 11;
PreviewChunk preview = 12;
ExecutionCompleted completed = 13;
ExecutionFailed failed = 14;
ExecutionCanceled canceled = 15;
}
}
message ExecutionStarted {}
message ExecutionProgress {
uint32 progress_permille = 1;
string stage_code = 2;
}
message PreviewChunk {
uint64 sequence = 1;
string media_type = 2;
bytes data = 3;
}
message ExecutionCompleted {
repeated LocalArtifactManifest outputs = 1;
RuntimeMeasurements measurements = 2;
}
message LocalArtifactManifest {
string artifact_id = 1;
string local_handle = 2;
uint64 size_bytes = 3;
string sha256 = 4;
string media_type = 5;
uint64 duration_ms = 6;
}
message ExecutionFailed {
RuntimeFailureClass failure_class = 1;
string stable_code = 2;
string safe_detail = 3;
RuntimeMeasurements measurements = 4;
}
message ExecutionCanceled {
RuntimeMeasurements measurements = 1;
}
enum RuntimeFailureClass {
RUNTIME_FAILURE_CLASS_UNSPECIFIED = 0;
RUNTIME_FAILURE_CLASS_INPUT = 1;
RUNTIME_FAILURE_CLASS_MODEL_LOAD = 2;
RUNTIME_FAILURE_CLASS_INFERENCE = 3;
RUNTIME_FAILURE_CLASS_GPU_RESOURCE = 4;
RUNTIME_FAILURE_CLASS_LOCAL_STORAGE = 5;
RUNTIME_FAILURE_CLASS_RUNTIME = 6;
RUNTIME_FAILURE_CLASS_CANCELED = 7;
}
message RuntimeMeasurements {
uint64 normalized_input_characters = 1;
uint64 input_audio_ms = 2;
uint64 output_audio_ms = 3;
uint64 gpu_execution_ms = 4;
uint64 cpu_execution_ms = 5;
}
message CancelRequest {
string job_id = 1;
string attempt_id = 2;
string reason_code = 3;
int64 deadline_unix_ms = 4;
}
message CancelResponse {
CancelDisposition disposition = 1;
}
enum CancelDisposition {
CANCEL_DISPOSITION_UNSPECIFIED = 0;
CANCEL_DISPOSITION_ACCEPTED = 1;
CANCEL_DISPOSITION_ALREADY_TERMINAL = 2;
CANCEL_DISPOSITION_NOT_FOUND = 3;
}
+168
View File
@@ -0,0 +1,168 @@
"""``--selfcheck``: validate the Go preflight's expectations against ourselves.
Starts the server on a private temp socket, then runs a Python port of
``internal/gateway/preflight.go``'s checks over the wire: socket-path safety,
READY health with version evidence, identical versions across Health and
GetCapabilities, valid unique devices, and at least one explicitly READY,
digest-pinned model with a version and precisions. Prints only a bounded
readiness summary (never handles, paths, or credentials) and exits nonzero on
any failed expectation the same fail-closed behavior a node deployment gets
from ``cmd/runtime-adapter-preflight``.
"""
from __future__ import annotations
import os
import stat as stat_module
import tempfile
from dataclasses import dataclass
import grpc
from .gen import runtime_adapter_pb2 as pb2
from .gen import runtime_adapter_pb2_grpc as pb2_grpc
_MAX_UINT32 = 2**32 - 1
class PreflightError(Exception):
"""One failed preflight expectation, with a bounded message."""
@dataclass(frozen=True)
class PreflightSummary:
socket_path: str
runtime_version: str
adapter_version: str
device_count: int
ready_model_count: int
total_slots: int
free_slots: int
def render(self) -> str:
return (
f"runtime={self.runtime_version} adapter={self.adapter_version} "
f"devices={self.device_count} ready_models={self.ready_model_count} "
f"slots={self.free_slots}/{self.total_slots}"
)
def validate_socket_file(socket_path: str) -> None:
if not socket_path or not os.path.isabs(socket_path):
raise PreflightError("socket path must be absolute")
info = os.lstat(socket_path)
if stat_module.S_ISLNK(info.st_mode) or not stat_module.S_ISSOCK(info.st_mode):
raise PreflightError("endpoint must be a local Unix socket")
parent = os.stat(os.path.dirname(socket_path))
if not stat_module.S_ISDIR(parent.st_mode) or parent.st_mode & 0o002:
raise PreflightError("socket directory is unsafe")
def run_preflight(socket_path: str, timeout_s: float = 10.0) -> PreflightSummary:
"""Port of ``PreflightRuntime`` + ``validateRuntimeCapabilities``."""
validate_socket_file(socket_path)
with grpc.insecure_channel(f"unix:{socket_path}") as channel:
stub = pb2_grpc.RuntimeAdapterServiceStub(channel)
try:
health = stub.Health(pb2.HealthRequest(), timeout=timeout_s)
except grpc.RpcError as exc:
raise PreflightError(f"health call failed: {exc.code().name}")
if (
health.state != pb2.SERVING_STATE_READY
or not health.runtime_version.strip()
or not health.adapter_version.strip()
):
raise PreflightError("runtime is not ready with versioned adapter evidence")
try:
caps = stub.GetCapabilities(pb2.GetCapabilitiesRequest(), timeout=timeout_s)
except grpc.RpcError as exc:
raise PreflightError(f"capabilities call failed: {exc.code().name}")
return _validate_capabilities(socket_path, health, caps)
def _validate_capabilities(socket_path, health, caps) -> PreflightSummary:
if not caps.runtime_version.strip() or not caps.adapter_version.strip():
raise PreflightError("capabilities lack version evidence")
if (
caps.runtime_version != health.runtime_version
or caps.adapter_version != health.adapter_version
):
raise PreflightError("health and capabilities versions disagree")
if not caps.devices:
raise PreflightError("no execution devices reported")
total_slots = free_slots = 0
seen_devices: set[str] = set()
for device in caps.devices:
if (
not device.device_id.strip()
or not device.hardware_class.strip()
or device.total_vram_bytes == 0
or device.total_slots == 0
or device.free_slots > device.total_slots
):
raise PreflightError("invalid execution device reported")
if device.device_id in seen_devices:
raise PreflightError("duplicate execution device reported")
seen_devices.add(device.device_id)
if (
total_slots + device.total_slots > _MAX_UINT32
or free_slots + device.free_slots > _MAX_UINT32
):
raise PreflightError("slot total overflows protocol limit")
total_slots += device.total_slots
free_slots += device.free_slots
ready = 0
seen_models: set[tuple[str, str, str]] = set()
for model in caps.models:
if model.state != pb2.RUNTIME_MODEL_STATE_READY:
continue
if (
not model.catalog_model_id.strip()
or not model.model_version.strip()
or not model.model_digest.strip()
or not model.precisions
):
raise PreflightError("invalid ready model reported")
identity = (model.catalog_model_id, model.model_version, model.model_digest)
if identity in seen_models:
raise PreflightError("duplicate ready model reported")
seen_models.add(identity)
ready += 1
if ready == 0:
raise PreflightError("no ready model reported")
return PreflightSummary(
socket_path=socket_path,
runtime_version=health.runtime_version,
adapter_version=health.adapter_version,
device_count=len(caps.devices),
ready_model_count=ready,
total_slots=total_slots,
free_slots=free_slots,
)
def selfcheck(timeout_s: float = 10.0) -> int:
"""Start the production server on a temp socket and preflight it."""
from .production import build_runtime_context # noqa: PLC0415
from .server import create_server # noqa: PLC0415
context = build_runtime_context()
warm = getattr(context.inventory, "warm", None)
if callable(warm):
print("selfcheck: warming model inventory (first run hashes weights)…")
warm()
# Short prefix: macOS caps Unix-socket paths at 103 characters and the
# default macOS tempdir is already ~60 characters deep.
with tempfile.TemporaryDirectory(prefix="vs-rta-") as tmp:
os.chmod(tmp, 0o700)
socket_path = os.path.join(tmp, "runtime.sock")
server = create_server(context, socket_path)
server.start()
try:
summary = run_preflight(socket_path, timeout_s=timeout_s)
except PreflightError as failure:
print(f"selfcheck: FAIL: {failure}")
return 1
finally:
server.stop(grace=2).wait()
print(f"selfcheck: OK: {summary.render()}")
return 0
+208
View File
@@ -0,0 +1,208 @@
"""The gRPC server: Unix-domain socket only, no HTTP, no TCP.
``Health`` and ``GetCapabilities`` read the same version constants from one
:class:`RuntimeContext`, so the "identical versions" preflight expectation
holds by construction. Socket-path safety mirrors the Go preflight's checks
(absolute path, no symlink, parent directory not world-writable) at bind time
so an unsafe deployment fails closed on our side too.
"""
from __future__ import annotations
import os
import stat as stat_module
import threading
from concurrent import futures
from dataclasses import dataclass, field
import grpc
from . import ADAPTER_VERSION, DEFAULT_SOCKET_PATH, SOCKET_ENV
from .executor import AttemptRegistry, Executor
from .gen import runtime_adapter_pb2 as pb2
from .gen import runtime_adapter_pb2_grpc as pb2_grpc
from .inventory import (
STATE_FAILED,
STATE_INSTALLED,
STATE_LOADING,
STATE_READY,
)
_MODEL_STATE_TO_PB = {
STATE_INSTALLED: pb2.RUNTIME_MODEL_STATE_INSTALLED,
STATE_LOADING: pb2.RUNTIME_MODEL_STATE_LOADING,
STATE_READY: pb2.RUNTIME_MODEL_STATE_READY,
STATE_FAILED: pb2.RUNTIME_MODEL_STATE_FAILED,
}
@dataclass
class RuntimeContext:
"""Everything the servicer needs; tests build it from fakes."""
runtime_version: str
inventory: object
engine_provider: object
adapter_version: str = ADAPTER_VERSION
slot_limit: int = 1
progress_interval: float = 0.5
poll_interval: float = 0.02
registry: AttemptRegistry = field(default_factory=AttemptRegistry)
def executor(self) -> Executor:
return Executor(
self.inventory,
self.engine_provider,
self.registry,
slot_limit=self.slot_limit,
progress_interval=self.progress_interval,
poll_interval=self.poll_interval,
)
class RuntimeAdapterServicer(pb2_grpc.RuntimeAdapterServiceServicer):
def __init__(self, context: RuntimeContext):
self._context = context
self._executor = context.executor()
def Health(self, request, grpc_context):
flags: list[str] = []
state = pb2.SERVING_STATE_READY
try:
devices = self._context.inventory.devices(
busy_slots=self._context.registry.active_count()
)
models = self._context.inventory.models()
except Exception:
return pb2.HealthResponse(
state=pb2.SERVING_STATE_UNHEALTHY,
runtime_version=self._context.runtime_version,
adapter_version=self._context.adapter_version,
health_flags=["inventory-error"],
)
if not devices:
state = pb2.SERVING_STATE_UNHEALTHY
flags.append("no-device")
if not any(model.state == STATE_READY for model in models):
state = max(state, pb2.SERVING_STATE_DEGRADED)
flags.append("no-ready-model")
return pb2.HealthResponse(
state=state,
runtime_version=self._context.runtime_version,
adapter_version=self._context.adapter_version,
health_flags=flags,
)
def GetCapabilities(self, request, grpc_context):
busy = self._context.registry.active_count()
response = pb2.GetCapabilitiesResponse(
runtime_version=self._context.runtime_version,
adapter_version=self._context.adapter_version,
)
for device in self._context.inventory.devices(busy_slots=busy):
response.devices.append(
pb2.RuntimeDevice(
device_id=device.device_id,
hardware_class=device.hardware_class,
total_vram_bytes=device.total_vram_bytes,
total_slots=device.total_slots,
free_slots=device.free_slots,
)
)
for model in self._context.inventory.models():
response.models.append(
pb2.RuntimeModel(
catalog_model_id=model.catalog_model_id,
model_version=model.model_version,
model_digest=model.model_digest,
precisions=list(model.precisions),
features=list(model.features),
state=_MODEL_STATE_TO_PB.get(
model.state, pb2.RUNTIME_MODEL_STATE_UNSPECIFIED
),
)
)
return response
def Execute(self, request, grpc_context):
yield from self._executor.execute(request, grpc_context)
def Cancel(self, request, grpc_context):
disposition = self._context.registry.cancel(request.job_id, request.attempt_id)
return pb2.CancelResponse(disposition=disposition)
def resolve_socket_path(explicit: str | None = None) -> str:
return (
(explicit or "").strip()
or os.environ.get(SOCKET_ENV, "").strip()
or DEFAULT_SOCKET_PATH
)
def prepare_socket(socket_path: str) -> str:
"""Fail closed on any unsafe socket placement; remove only a stale socket."""
if not socket_path or not os.path.isabs(socket_path):
raise ValueError("runtime socket path must be absolute")
parent = os.path.dirname(socket_path)
try:
parent_stat = os.stat(parent)
except OSError as exc:
raise ValueError(f"runtime socket directory is missing: {exc}") from exc
if not stat_module.S_ISDIR(parent_stat.st_mode) or parent_stat.st_mode & 0o002:
raise ValueError("runtime socket directory is unsafe (world-writable?)")
try:
existing = os.lstat(socket_path)
except FileNotFoundError:
return socket_path
if stat_module.S_ISSOCK(existing.st_mode):
os.unlink(socket_path) # stale socket from a previous run
return socket_path
raise ValueError("runtime socket path exists and is not a socket")
def create_server(
context: RuntimeContext, socket_path: str, *, max_workers: int | None = None
) -> grpc.Server:
prepare_socket(socket_path)
workers = max_workers or max(8, context.slot_limit * 2 + 4)
server = grpc.server(
futures.ThreadPoolExecutor(
max_workers=workers, thread_name_prefix="runtime-adapter"
)
)
pb2_grpc.add_RuntimeAdapterServiceServicer_to_server(
RuntimeAdapterServicer(context), server
)
bound = server.add_insecure_port(f"unix:{socket_path}")
if bound == 0:
raise RuntimeError("failed to bind the runtime adapter socket")
return server
def serve(context: RuntimeContext, socket_path: str) -> int:
"""Run until SIGINT/SIGTERM. Returns a process exit code."""
import signal # noqa: PLC0415
warm = getattr(context.inventory, "warm", None)
if callable(warm):
warm() # hash installed snapshots before the socket exists
server = create_server(context, socket_path)
server.start()
try:
os.chmod(socket_path, 0o660) # gateway runs under the same service identity
except OSError:
pass
stop = threading.Event()
def _stop(_signum, _frame):
stop.set()
signal.signal(signal.SIGTERM, _stop)
signal.signal(signal.SIGINT, _stop)
stop.wait()
server.stop(grace=10).wait()
try:
os.unlink(socket_path)
except OSError:
pass
return 0
+28 -4
View File
@@ -2325,7 +2325,7 @@ _INSTALL_HINTS: dict[str, str] = {
"mac-ARM source installs since 0.3.22. Parakeet TDT v3 on the GPU via "
"MLX: 25 European languages, word timestamps, ~2 GB unified memory.)"
),
"moonshine": "pip install useful-moonshine (edge/CPU-optimized ASR)",
"moonshine": "uv pip install moonshine-onnx (or moonshine-voice; edge/CPU-optimized ASR)",
"funasr": "pip install funasr (SenseVoiceSmall + FSMN-VAD; CUDA or CPU)",
"sherpa-onnx-asr": "uv add sherpa-onnx (ONNX live dictation; CPU, cross-platform)",
"openai-compat-asr": (
@@ -2495,7 +2495,23 @@ def _ctranslate2_cuda_ok() -> bool:
CUDA runtime version" — the #1529 report, an AMD RX 7900 XTX in the
:rocm Docker image. Real CUDA only; ROCm hosts take the CPU path here
(auto-detect prefers pytorch-whisper there, which does use HIP).
Also honors the user compute-device override (Settings Performance /
``OMNIVOICE_DEVICE``): a host pinned to cpu (or any non-cuda family)
must not hand CTranslate2 a CUDA device the probe applies the
override, so gating on its family covers every CT2 loader at once.
"""
try:
from core.device_caps import detect_host_caps
if detect_host_caps().family != "cuda":
return False
except Exception: # noqa: BLE001 — fail SAFE, not fast
# Without a working probe we can't know whether an override or a
# ROCm build is in play — guessing "cuda" from torch here is exactly
# the #1529 crash. CPU always works.
logger.warning("device probe failed — CTranslate2 taking the CPU path", exc_info=True)
return False
return _cuda_reported_available() and not _rocm_torch()
@@ -3120,10 +3136,18 @@ def _offline_asr_repo(backend_id: str | None = None) -> str | None:
bid = backend_id or active_backend_id()
if bid == "whisperx":
return _fw_repo(os.environ.get("ASR_MODEL_WHISPERX", "large-v3"))
if bid in ("faster-whisper", "faster-whisper-isolated"):
# The crash-isolated sidecar loads the SAME CT2 weights as in-process
# faster-whisper (it reuses the ASR_MODEL_FASTER selection).
if bid == "faster-whisper":
return _fw_repo(os.environ.get("ASR_MODEL_FASTER", _FASTER_WHISPER_DEFAULT))
if bid == "faster-whisper-isolated":
# Mirror the sidecar's own resolution (_asr_sidecar/main.py):
# ASR_MODEL_FW is a sidecar-only override, otherwise the shared
# ASR_MODEL_FASTER selection applies — so the preflight can never
# download a different repo than the sidecar will load.
return _fw_repo(
os.environ.get("ASR_MODEL_FW")
or os.environ.get("ASR_MODEL_FASTER")
or _FASTER_WHISPER_DEFAULT
)
if bid == "mlx-whisper":
return os.environ.get("ASR_MODEL", _MLX_MODEL_DEFAULT)
if bid == "parakeet-mlx":
+145
View File
@@ -0,0 +1,145 @@
"""Opt-in adapter from OSS profiles/generation to the hosted v1 contract.
Local VoiceStudio never calls this module unless the caller explicitly requests
``hosted`` execution *and* all VSS_HOSTED_* settings are present. It stages
text/reference bytes as hosted Artifacts, creates a consent-backed Voice, and
uses durable Jobs; no local path, source recording URL, or plaintext text is
sent in a Job snapshot.
"""
from __future__ import annotations
import asyncio
import hashlib
import os
import time
import uuid
from dataclasses import dataclass
from pathlib import Path
import httpx
class HostedVoiceError(RuntimeError):
"""A safe, user-actionable hosted adapter failure."""
@dataclass(frozen=True)
class HostedSettings:
base_url: str
token: str
project_id: str
model_id: str
model_version: str
base_voice_id: str
consent_text_version: str
@classmethod
def from_environment(cls) -> "HostedSettings | None":
values = {
name: os.environ.get(name, "").strip()
for name in (
"VSS_HOSTED_API_BASE", "VSS_HOSTED_API_TOKEN",
"VSS_HOSTED_PROJECT_ID", "VSS_HOSTED_MODEL_ID",
"VSS_HOSTED_MODEL_VERSION", "VSS_HOSTED_BASE_VOICE_ID",
)
}
if not any(values.values()):
return None
missing = [name for name, value in values.items() if not value]
if missing:
raise HostedVoiceError("Hosted execution is incomplete; configure " + ", ".join(missing) + ".")
base_url = values["VSS_HOSTED_API_BASE"].rstrip("/")
if not base_url.startswith(("http://", "https://")):
raise HostedVoiceError("VSS_HOSTED_API_BASE must be an http(s) URL.")
return cls(
base_url=base_url, token=values["VSS_HOSTED_API_TOKEN"],
project_id=values["VSS_HOSTED_PROJECT_ID"], model_id=values["VSS_HOSTED_MODEL_ID"],
model_version=values["VSS_HOSTED_MODEL_VERSION"], base_voice_id=values["VSS_HOSTED_BASE_VOICE_ID"],
consent_text_version=os.environ.get("VSS_HOSTED_CONSENT_TEXT_VERSION", "oss-spoken-consent-v1").strip() or "oss-spoken-consent-v1",
)
class HostedVoiceClient:
def __init__(self, settings: HostedSettings, client: httpx.AsyncClient | None = None):
self.settings = settings
self.client = client or httpx.AsyncClient(base_url=settings.base_url, timeout=60)
self._owns_client = client is None
async def aclose(self) -> None:
if self._owns_client:
await self.client.aclose()
def _headers(self, *, idempotency: bool = False) -> dict[str, str]:
headers = {"Authorization": f"Bearer {self.settings.token}"}
if idempotency:
headers["Idempotency-Key"] = str(uuid.uuid4())
return headers
async def _request(self, method: str, path: str, *, json: dict | None = None, headers: dict | None = None) -> httpx.Response:
response = await self.client.request(method, path, json=json, headers=headers)
if response.is_error:
detail = "hosted service rejected the request"
try:
body = response.json()
detail = body.get("error", {}).get("message") or body.get("detail") or detail
except ValueError:
pass
raise HostedVoiceError(f"Hosted request failed ({response.status_code}): {detail}")
return response
async def upload_artifact(self, *, purpose: str, media_type: str, payload: bytes) -> str:
digest = hashlib.sha256(payload).hexdigest()
grant = (await self._request("POST", "/v1/artifacts/upload-authorizations", json={
"project_id": self.settings.project_id, "purpose": purpose, "media_type": media_type,
"size_bytes": len(payload), "sha256": digest,
}, headers=self._headers())).json()
put_headers = {k: v for k, v in (grant.get("required_headers") or {}).items() if k.lower() not in {"host", "content-length"}}
put_headers.setdefault("Content-Type", media_type)
response = await self.client.request(grant.get("method", "PUT"), grant["url"], content=payload, headers=put_headers)
if response.is_error:
raise HostedVoiceError(f"Hosted Artifact upload failed ({response.status_code}).")
await self._request("POST", f"/v1/artifacts/{grant['artifact_id']}/complete", json={"size_bytes": len(payload), "sha256": digest}, headers=self._headers())
return grant["artifact_id"]
async def create_voice(self, *, name: str, description: str, reference_path: str) -> str:
payload = Path(reference_path).read_bytes()
if not payload:
raise HostedVoiceError("The reference recording is empty.")
suffix = Path(reference_path).suffix.lower()
media_type = {".wav": "audio/wav", ".mp3": "audio/mpeg", ".flac": "audio/flac"}.get(suffix, "audio/wav")
reference_id = await self.upload_artifact(purpose="reference_audio", media_type=media_type, payload=payload)
voice = await self._request("POST", "/v1/voices", json={
"project_id": self.settings.project_id, "display_name": name, "description": description[:1024],
"reference_audio_artifact_id": reference_id,
"consent": {"attestation_text_version": self.settings.consent_text_version},
}, headers=self._headers(idempotency=True))
return voice.json()["id"]
async def synthesize(self, *, text: str, profile_voice_id: str, language: str | None = None) -> bytes:
text_artifact = await self.upload_artifact(purpose="input", media_type="text/plain", payload=text.encode("utf-8"))
configuration = {"voice_id": self.settings.base_voice_id, "voice_reference_id": profile_voice_id, "output_format": "wav"}
if language and language != "Auto":
configuration["language"] = language
job = await self._request("POST", "/v1/jobs", json={
"project_id": self.settings.project_id, "workflow": "tts",
"model": {"id": self.settings.model_id, "version": self.settings.model_version},
"input": {"text_artifact_id": text_artifact}, "configuration": configuration,
}, headers=self._headers(idempotency=True))
job_id = job.json()["job_id"]
deadline = time.monotonic() + 15 * 60
while time.monotonic() < deadline:
view = (await self._request("GET", f"/v1/jobs/{job_id}", headers=self._headers())).json()
if view.get("state") == "succeeded":
outputs = view.get("output_artifact_ids") or []
if not outputs:
raise HostedVoiceError("Hosted synthesis completed without audio output.")
grant = (await self._request("POST", f"/v1/artifacts/{outputs[0]}/download-authorization", headers=self._headers())).json()
audio = await self.client.request(grant.get("method", "GET"), grant["url"])
if audio.is_error:
raise HostedVoiceError("Hosted synthesis output could not be downloaded.")
return audio.content
if view.get("state") in {"failed", "canceled"}:
raise HostedVoiceError("Hosted synthesis did not complete successfully.")
await asyncio.sleep(0.5)
raise HostedVoiceError("Hosted synthesis timed out waiting for its durable Job.")
+4
View File
@@ -363,6 +363,10 @@ class SubprocessBackend(TTSBackend):
# A duck-typed marker survives that.
_is_subprocess_isolated: bool = True
# Generation happens in the sidecar: parent-side accelerator counters
# can't see its allocations (see TTSBackend.runs_out_of_process).
runs_out_of_process: bool = True
# Default sample rate; subclasses override.
_DEFAULT_SAMPLE_RATE = 24000
+39 -5
View File
@@ -300,6 +300,23 @@ class TTSBackend(ABC):
#: 0 means "no meaningful floor" (CPU-class engines) and never warns.
min_vram_gb: float = 0.0
#: True when generation allocates in ANOTHER process — a dedicated-venv
#: sidecar (SubprocessBackend) or a spawned binary (omnivoice-gguf).
#: Parent-process accelerator counters cannot see those allocations, so
#: profilers/diagnostics must not attribute the parent's VRAM numbers to
#: the engine. Duck-typed (attribute, not issubclass) for the same
#: module-purge reason as `_is_subprocess_isolated`.
runs_out_of_process: bool = False
def model_identity(self) -> Optional[str]:
"""Which concrete model this backend would run, for adapter engines
that host several very different models behind one backend id
(mlx-audio, sherpa-onnx, cosyvoice). None means the engine id
already names the model. Profilers and diagnostics use this to
label results without it, Kokoro-under-mlx and Dia-under-mlx
rows are indistinguishable."""
return None
@abstractmethod
def generate(
self,
@@ -1075,7 +1092,8 @@ class KittenTTSBackend(TTSBackend):
- English only
- Much faster + much smaller install
Preset voice is chosen via `extras["voice"]` (defaults to "Jasper"). Any
Preset voice is chosen via `extras["voice"]` (defaults to DEFAULT_VOICE,
"expr-voice-2-f"). Any
`ref_audio` / `instruct` / `language` arg is ignored with a log line so
the common call-site doesn't need to know which engine it's talking to.
"""
@@ -1384,6 +1402,9 @@ class MLXAudioBackend(TTSBackend):
def sample_rate(self) -> int:
return self._sr
def model_identity(self) -> Optional[str]:
return self._model_id
@property
def supported_languages(self) -> list[str]:
# Per-model; Kokoro supports 8, Qwen3 ~4, Kugel 24. Return "multi"
@@ -1571,6 +1592,18 @@ class CosyVoiceBackend(TTSBackend):
def supported_languages(self) -> list[str]:
return ["zh", "en", "ja", "ko", "yue", "de", "es", "fr", "it", "ru"]
@staticmethod
def _resolved_model_dir() -> str:
return os.environ.get(
"OMNIVOICE_COSYVOICE_MODEL",
"pretrained_models/Fun-CosyVoice3-0.5B",
)
def model_identity(self) -> Optional[str]:
# v1/v2/v3 all live behind the one "cosyvoice" id — the directory
# basename is the only thing that tells the models apart.
return os.path.basename(os.path.normpath(self._resolved_model_dir()))
def _ensure_loaded(self):
if self._model is not None:
return
@@ -1578,10 +1611,7 @@ class CosyVoiceBackend(TTSBackend):
if not ok:
raise RuntimeError(f"CosyVoice unavailable: {msg}")
from cosyvoice.cli.cosyvoice import AutoModel # type: ignore[import-not-found]
model_dir = os.environ.get(
"OMNIVOICE_COSYVOICE_MODEL",
"pretrained_models/Fun-CosyVoice3-0.5B",
)
model_dir = self._resolved_model_dir()
logger.info("Loading CosyVoice from %s", model_dir)
self._model = AutoModel(model_dir=model_dir)
@@ -1814,6 +1844,10 @@ class SherpaOnnxBackend(TTSBackend):
self._tts = None
self._model_dir = os.environ.get("OMNIVOICE_SHERPA_MODEL", "")
def model_identity(self) -> Optional[str]:
model_dir = (self._model_dir or "").strip()
return os.path.basename(os.path.normpath(model_dir)) if model_dir else None
@classmethod
def is_available(cls) -> tuple[bool, str]:
try:
+205
View File
@@ -0,0 +1,205 @@
"""Shared fakes and harness for the runtime-adapter tests.
Not a test module (no ``test_`` prefix): imported by
``test_runtime_adapter_capabilities.py`` and
``test_runtime_adapter_execute.py``.
"""
from __future__ import annotations
import contextlib
import hashlib
import os
import shutil
import tempfile
import threading
import time
import grpc
from runtime_adapter.gen import runtime_adapter_pb2 as pb2
from runtime_adapter.gen import runtime_adapter_pb2_grpc as pb2_grpc
from runtime_adapter.inventory import (
STATE_READY,
DeviceInfo,
ModelInfo,
)
from runtime_adapter.server import RuntimeContext, create_server
READY_MODEL = ModelInfo(
catalog_model_id="fake-tts",
model_version="a" * 40,
model_digest="sha256:" + "b" * 64,
precisions=("fp32",),
features=("tts",),
state=STATE_READY,
)
DEVICE = DeviceInfo(
device_id="cpu:0",
hardware_class="test-cpu",
total_vram_bytes=8 * 1024**3,
total_slots=1,
free_slots=1,
)
class FakeInventory:
def __init__(self, models=None, devices=None):
self._models = list(models) if models is not None else [READY_MODEL]
self._devices = list(devices) if devices is not None else [DEVICE]
def devices(self, busy_slots: int = 0):
return [
DeviceInfo(
device_id=d.device_id,
hardware_class=d.hardware_class,
total_vram_bytes=d.total_vram_bytes,
total_slots=d.total_slots,
free_slots=max(0, d.total_slots - busy_slots),
)
for d in self._devices
]
def models(self):
return list(self._models)
class FakeEngine:
"""Half a second of silence at 24 kHz, instantly."""
sample_rate = 24000
def __init__(self):
self.generate_calls = []
def ensure_ready(self):
pass
def generate(self, text, **kw):
import torch
self.generate_calls.append((text, kw))
return torch.zeros(1, 12000)
class SlowEngine(FakeEngine):
"""Sleeps through generate in small slices so tests stay responsive."""
def __init__(self, seconds: float = 10.0):
super().__init__()
self.seconds = seconds
self.started = threading.Event()
def generate(self, text, **kw):
self.started.set()
deadline = time.monotonic() + self.seconds
while time.monotonic() < deadline:
time.sleep(0.01)
return super().generate(text, **kw)
class FailingEngine(FakeEngine):
def __init__(self, exc: BaseException, phase: str = "synthesis"):
super().__init__()
self._exc = exc
self._phase = phase
def ensure_ready(self):
if self._phase == "model_load":
raise self._exc
def generate(self, text, **kw):
raise self._exc
def make_context(engine=None, inventory=None, **kw) -> RuntimeContext:
engine = engine if engine is not None else FakeEngine()
engines = {READY_MODEL.catalog_model_id: engine}
kw.setdefault("progress_interval", 0.05)
kw.setdefault("poll_interval", 0.005)
return RuntimeContext(
runtime_version="1.2.3-test",
inventory=inventory if inventory is not None else FakeInventory(),
engine_provider=lambda model_id: engines[model_id],
**kw,
)
@contextlib.contextmanager
def serve_over_socket(context: RuntimeContext, tmp_path=None):
# A pytest tmp_path routinely exceeds the 103-character Unix-socket path
# limit on macOS, so the socket gets its own short private tempdir.
socket_dir = tempfile.mkdtemp(prefix="vs-rta-")
socket_path = os.path.join(socket_dir, "runtime.sock")
server = create_server(context, socket_path)
server.start()
channel = grpc.insecure_channel(f"unix:{socket_path}")
try:
yield pb2_grpc.RuntimeAdapterServiceStub(channel), socket_path
finally:
channel.close()
server.stop(grace=0).wait()
shutil.rmtree(socket_dir, ignore_errors=True)
def make_execute_request(
tmp_path,
text: str = "hello runtime",
*,
attempt_id: str = "attempt-1",
job_id: str = "job-1",
model: ModelInfo = READY_MODEL,
device_id: str = "cpu:0",
deadline_in_s: float = 30.0,
parameters: dict | None = None,
input_sha256: str | None = None,
input_handle: str | None = None,
output_handle: str | None = None,
) -> pb2.ExecuteRequest:
if input_handle is None:
input_path = tmp_path / "input.txt"
input_path.write_text(text, encoding="utf-8")
input_handle = str(input_path)
if input_sha256 is None and text is not None:
input_sha256 = hashlib.sha256(text.encode("utf-8")).hexdigest()
if output_handle is None:
output_handle = str(tmp_path / "output.wav")
return pb2.ExecuteRequest(
job_id=job_id,
attempt_id=attempt_id,
device_id=device_id,
slot_id="slot-0",
model=pb2.ModelSpec(
catalog_model_id=model.catalog_model_id,
model_version=model.model_version,
model_digest=model.model_digest,
precision="fp32",
),
parameters=parameters or {},
inputs=[
pb2.LocalArtifact(
artifact_id="in-1",
local_handle=input_handle,
operation=pb2.LOCAL_ARTIFACT_OPERATION_READ,
expected_sha256=input_sha256 or "",
media_type="text/plain",
)
],
outputs=[
pb2.LocalArtifact(
artifact_id="out-1",
local_handle=output_handle,
operation=pb2.LOCAL_ARTIFACT_OPERATION_WRITE,
media_type="audio/wav",
)
],
deadline_unix_ms=int((time.time() + deadline_in_s) * 1000),
maximum_preview_bytes=0,
)
def terminal_of(events):
last = events[-1].event
kind = last.WhichOneof("payload")
assert kind in ("completed", "failed", "canceled"), kind
return kind, last
+10
View File
@@ -125,6 +125,16 @@ def test_faster_whisper_float16_unsupported_falls_back_to_int8(monkeypatch):
)
monkeypatch.setitem(sys.modules, "torch", fake_torch)
# The compute-device override gate consults the capability probe before
# the torch mock above — pin it to a CUDA family so the fallback chain
# under test is reachable on a cpu-only CI host.
from core.device_caps import HostCaps
monkeypatch.setattr(
"core.device_caps.detect_host_caps",
lambda: HostCaps(family="cuda", available_families=("cuda", "cpu")),
)
be = FasterWhisperBackend()
be._ensure_model()
+71
View File
@@ -0,0 +1,71 @@
import asyncio
import httpx
import pytest
from services.hosted_voice_api import HostedSettings, HostedVoiceClient, HostedVoiceError
_NAMES = (
"VSS_HOSTED_API_BASE", "VSS_HOSTED_API_TOKEN", "VSS_HOSTED_PROJECT_ID",
"VSS_HOSTED_MODEL_ID", "VSS_HOSTED_MODEL_VERSION", "VSS_HOSTED_BASE_VOICE_ID",
)
def test_hosted_adapter_is_disabled_without_configuration(monkeypatch):
for name in _NAMES:
monkeypatch.delenv(name, raising=False)
assert HostedSettings.from_environment() is None
def test_hosted_adapter_refuses_partial_configuration(monkeypatch):
for name in _NAMES:
monkeypatch.delenv(name, raising=False)
monkeypatch.setenv("VSS_HOSTED_API_BASE", "http://127.0.0.1:8080")
with pytest.raises(HostedVoiceError, match="VSS_HOSTED_API_TOKEN"):
HostedSettings.from_environment()
def test_hosted_adapter_requires_http_endpoint(monkeypatch):
values = {
"VSS_HOSTED_API_BASE": "not-a-url",
"VSS_HOSTED_API_TOKEN": "token",
"VSS_HOSTED_PROJECT_ID": "project",
"VSS_HOSTED_MODEL_ID": "model",
"VSS_HOSTED_MODEL_VERSION": "v1",
"VSS_HOSTED_BASE_VOICE_ID": "base",
}
for name, value in values.items():
monkeypatch.setenv(name, value)
with pytest.raises(HostedVoiceError, match="http"):
HostedSettings.from_environment()
def test_create_voice_uses_artifact_grants_then_canonical_voice_resource(tmp_path):
reference = tmp_path / "reference.wav"
reference.write_bytes(b"reference-audio")
settings = HostedSettings("https://api.test", "token", "project", "model", "v1", "base", "oss-spoken-consent-v1")
requests = []
def handler(request):
requests.append(request)
if request.url.path == "/v1/artifacts/upload-authorizations":
return httpx.Response(200, json={"artifact_id": "artifact-ref", "method": "PUT", "url": "https://objects.test/ref", "required_headers": {}})
if request.url.host == "objects.test":
return httpx.Response(200)
if request.url.path == "/v1/artifacts/artifact-ref/complete":
return httpx.Response(200, json={})
if request.url.path == "/v1/voices":
return httpx.Response(201, json={"id": "hosted-voice"})
return httpx.Response(404)
async def create():
client = HostedVoiceClient(settings, httpx.AsyncClient(base_url=settings.base_url, transport=httpx.MockTransport(handler)))
return await client.create_voice(name="Local profile", description="description", reference_path=str(reference))
assert asyncio.run(create()) == "hosted-voice"
voice_request = next(request for request in requests if request.url.path == "/v1/voices")
body = __import__("json").loads(voice_request.content)
assert body["project_id"] == "project"
assert body["reference_audio_artifact_id"] == "artifact-ref"
assert body["consent"]["attestation_text_version"] == "oss-spoken-consent-v1"
@@ -0,0 +1,171 @@
"""Health/GetCapabilities shape, preflight parity, digest stability.
Mirrors what ``internal/gateway/preflight.go`` in vssaas enforces: READY
health with version evidence, identical versions across both calls, valid
unique devices, and only explicitly-READY models counting as schedulable.
"""
from __future__ import annotations
import os
import sys
import pytest
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from _runtime_adapter_helpers import ( # noqa: E402
DEVICE,
READY_MODEL,
FakeInventory,
make_context,
serve_over_socket,
)
from runtime_adapter.digest import file_sha256, snapshot_digest
from runtime_adapter.gen import runtime_adapter_pb2 as pb2
from runtime_adapter.inventory import (
STATE_FAILED,
STATE_INSTALLED,
STATE_LOADING,
ModelInfo,
catalog_model_version,
)
from runtime_adapter.selfcheck import PreflightError, run_preflight
from runtime_adapter.server import prepare_socket
def _model(state, model_id="other-model", digest="sha256:" + "c" * 64):
return ModelInfo(
catalog_model_id=model_id,
model_version="d" * 40,
model_digest=digest,
precisions=("fp32",),
features=("tts",),
state=state,
)
def test_health_and_capabilities_versions_are_identical_and_ready(tmp_path):
with serve_over_socket(make_context(), tmp_path) as (stub, _):
health = stub.Health(pb2.HealthRequest(), timeout=5)
caps = stub.GetCapabilities(pb2.GetCapabilitiesRequest(), timeout=5)
assert health.state == pb2.SERVING_STATE_READY
assert health.runtime_version == "1.2.3-test"
assert health.adapter_version.strip()
assert caps.runtime_version == health.runtime_version
assert caps.adapter_version == health.adapter_version
def test_capabilities_report_device_and_ready_model_evidence(tmp_path):
inventory = FakeInventory(
models=[
READY_MODEL,
_model(STATE_LOADING, "loading-model"),
_model(STATE_FAILED, "failed-model"),
_model(STATE_INSTALLED, "installed-model"),
]
)
with serve_over_socket(make_context(inventory=inventory), tmp_path) as (stub, _):
caps = stub.GetCapabilities(pb2.GetCapabilitiesRequest(), timeout=5)
[device] = caps.devices
assert device.device_id == DEVICE.device_id
assert device.hardware_class == DEVICE.hardware_class
assert device.total_vram_bytes > 0
assert 0 < device.free_slots <= device.total_slots
by_id = {model.catalog_model_id: model for model in caps.models}
ready = by_id[READY_MODEL.catalog_model_id]
assert ready.state == pb2.RUNTIME_MODEL_STATE_READY
assert ready.model_version.startswith("d" * 40 + "+sha256-")
assert ready.model_digest.startswith("sha256:")
assert list(ready.precisions)
# A loading/failed/installed model is reported truthfully, never READY.
assert by_id["loading-model"].state == pb2.RUNTIME_MODEL_STATE_LOADING
assert by_id["failed-model"].state == pb2.RUNTIME_MODEL_STATE_FAILED
assert by_id["installed-model"].state == pb2.RUNTIME_MODEL_STATE_INSTALLED
def test_preflight_port_passes_against_a_ready_server(tmp_path):
inventory = FakeInventory(models=[READY_MODEL, _model(STATE_LOADING)])
with serve_over_socket(make_context(inventory=inventory), tmp_path) as (
stub,
socket_path,
):
summary = run_preflight(socket_path, timeout_s=5)
assert summary.ready_model_count == 1 # the loading model must not count
assert summary.device_count == 1
assert summary.runtime_version == "1.2.3-test"
assert summary.total_slots == 1
def test_preflight_fails_closed_without_a_ready_model(tmp_path):
inventory = FakeInventory(models=[_model(STATE_LOADING)])
with serve_over_socket(make_context(inventory=inventory), tmp_path) as (
stub,
socket_path,
):
health = stub.Health(pb2.HealthRequest(), timeout=5)
assert health.state == pb2.SERVING_STATE_DEGRADED
assert "no-ready-model" in health.health_flags
with pytest.raises(PreflightError):
run_preflight(socket_path, timeout_s=5)
def test_prepare_socket_rejects_unsafe_paths(tmp_path):
with pytest.raises(ValueError):
prepare_socket("relative/socket.sock")
regular = tmp_path / "not-a-socket"
regular.write_text("x")
with pytest.raises(ValueError):
prepare_socket(str(regular))
missing_parent = tmp_path / "nope" / "runtime.sock"
with pytest.raises(ValueError):
prepare_socket(str(missing_parent))
def test_snapshot_digest_is_stable_and_content_sensitive(tmp_path):
snapshot = tmp_path / "snapshots" / "rev"
snapshot.mkdir(parents=True)
(snapshot / "weights.bin").write_bytes(b"\x01\x02\x03")
(snapshot / "config.json").write_text("{}")
cache = tmp_path / "digest-cache.json"
first = snapshot_digest(snapshot, cache_path=cache)
second = snapshot_digest(snapshot, cache_path=cache) # served from cache
assert first == second
assert first.startswith("sha256:")
assert cache.exists()
# Any byte change must change the digest (cache invalidated by mtime/size).
(snapshot / "weights.bin").write_bytes(b"\x01\x02\x04")
assert snapshot_digest(snapshot, cache_path=cache) != first
with pytest.raises(FileNotFoundError):
snapshot_digest(tmp_path / "empty-none")
def test_catalog_model_version_changes_when_attested_snapshot_changes():
revision = "d" * 40
first = catalog_model_version(revision, "sha256:" + "a" * 64)
second = catalog_model_version(revision, "sha256:" + "b" * 64)
assert first.startswith(revision + "+sha256-")
assert first != second
def test_file_sha256_matches_hashlib(tmp_path):
import hashlib
payload = b"runtime adapter"
path = tmp_path / "f.bin"
path.write_bytes(payload)
assert file_sha256(path) == hashlib.sha256(payload).hexdigest()
def test_socket_file_is_private_to_the_node(tmp_path):
import stat
with serve_over_socket(make_context(), tmp_path) as (_stub, socket_path):
mode = os.lstat(socket_path).st_mode
assert stat.S_ISSOCK(mode)
@@ -0,0 +1,347 @@
"""Execute/Cancel: happy path, deadline, cancel race, failure taxonomy."""
from __future__ import annotations
import hashlib
import os
import sys
import threading
import time
import pytest
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from _runtime_adapter_helpers import ( # noqa: E402
READY_MODEL,
FailingEngine,
FakeInventory,
SlowEngine,
make_context,
make_execute_request,
serve_over_socket,
terminal_of,
)
from runtime_adapter import codes
from runtime_adapter.gen import runtime_adapter_pb2 as pb2
from runtime_adapter.inventory import STATE_INSTALLED, ModelInfo
def _run_direct(context, request):
"""Drive the executor without a live gRPC server (fast path for taxonomy)."""
return list(context.executor().execute(request, None))
def _failure(events):
kind, last = terminal_of(events)
assert kind == "failed", f"expected failed terminal, got {kind}"
return last.failed
# ── happy path ────────────────────────────────────────────────────────────
def test_execute_happy_path_streams_and_writes_the_manifest(tmp_path):
context = make_context()
request = make_execute_request(tmp_path, text="hello runtime")
with serve_over_socket(context, tmp_path) as (stub, _):
events = list(stub.Execute(request, timeout=30))
payloads = [event.event.WhichOneof("payload") for event in events]
assert payloads[0] == "started"
assert payloads[-1] == "completed"
assert all(kind == "progress" for kind in payloads[1:-1])
sequences = [event.event.sequence for event in events]
assert sequences == sorted(sequences)
assert all(event.event.attempt_id == "attempt-1" for event in events)
completed = events[-1].event.completed
[manifest] = completed.outputs
output_path = tmp_path / "output.wav"
assert manifest.local_handle == str(output_path)
assert output_path.stat().st_size == manifest.size_bytes > 0
assert manifest.sha256 == hashlib.sha256(output_path.read_bytes()).hexdigest()
assert manifest.media_type == "audio/wav"
assert manifest.duration_ms == 500 # 12000 samples at 24 kHz
measurements = completed.measurements
assert measurements.normalized_input_characters == len("hello runtime")
assert measurements.output_audio_ms == 500
def test_execute_passes_typed_parameters_to_the_engine(tmp_path):
from _runtime_adapter_helpers import FakeEngine
engine = FakeEngine()
context = make_context(engine=engine)
request = make_execute_request(
tmp_path,
parameters={
"speed": pb2.ParameterValue(number_value=1.5),
"language": pb2.ParameterValue(string_value="en"),
"num_step": pb2.ParameterValue(integer_value=8),
},
)
events = _run_direct(context, request)
assert terminal_of(events)[0] == "completed"
[(text, kwargs)] = engine.generate_calls
assert text == "hello runtime"
assert kwargs == {"speed": 1.5, "language": "en", "num_step": 8}
def test_execute_passes_seed_to_the_engine(tmp_path):
"""Hosted Gallery defaults must retain the OSS deterministic seed."""
from _runtime_adapter_helpers import FakeEngine
engine = FakeEngine()
context = make_context(engine=engine)
request = make_execute_request(
tmp_path,
parameters={"seed": pb2.ParameterValue(integer_value=42)},
)
events = _run_direct(context, request)
assert terminal_of(events)[0] == "completed"
[(_, kwargs)] = engine.generate_calls
assert kwargs == {"seed": 42}
# ── deadline ──────────────────────────────────────────────────────────────
def test_deadline_is_enforced_with_a_stable_code(tmp_path):
context = make_context(engine=SlowEngine(seconds=30))
request = make_execute_request(tmp_path, deadline_in_s=0.4)
start = time.monotonic()
events = _run_direct(context, request)
elapsed = time.monotonic() - start
failed = _failure(events)
assert failed.stable_code in (codes.INFERENCE_DEADLINE, codes.MODEL_LOAD_DEADLINE)
assert failed.failure_class in (
pb2.RUNTIME_FAILURE_CLASS_INFERENCE,
pb2.RUNTIME_FAILURE_CLASS_MODEL_LOAD,
)
assert elapsed < 5, "terminal event must arrive promptly after the deadline"
def test_deadline_in_the_past_is_invalid_input(tmp_path):
context = make_context()
request = make_execute_request(tmp_path)
request.deadline_unix_ms = int(time.time() * 1000) - 1000
failed = _failure(_run_direct(context, request))
assert failed.stable_code == codes.INPUT_DEADLINE_INVALID
assert failed.failure_class == pb2.RUNTIME_FAILURE_CLASS_INPUT
# ── cancel ────────────────────────────────────────────────────────────────
def test_cancel_race_yields_canceled_terminal_and_idempotent_dispositions(tmp_path):
engine = SlowEngine(seconds=30)
context = make_context(engine=engine)
request = make_execute_request(tmp_path)
with serve_over_socket(context, tmp_path) as (stub, _):
stream = stub.Execute(request, timeout=30)
first = next(stream)
assert first.event.WhichOneof("payload") == "started"
assert engine.started.wait(5), "engine must be mid-generate for the race"
cancel = pb2.CancelRequest(job_id="job-1", attempt_id="attempt-1")
assert stub.Cancel(cancel, timeout=5).disposition == (
pb2.CANCEL_DISPOSITION_ACCEPTED
)
# Idempotent while still running.
assert stub.Cancel(cancel, timeout=5).disposition == (
pb2.CANCEL_DISPOSITION_ACCEPTED
)
events = [first, *stream]
kind, last = terminal_of(events)
assert kind == "canceled"
assert last.canceled.HasField("measurements")
# After the terminal event the same cancel is ALREADY_TERMINAL …
assert stub.Cancel(cancel, timeout=5).disposition == (
pb2.CANCEL_DISPOSITION_ALREADY_TERMINAL
)
# … and an unknown attempt is NOT_FOUND.
unknown = pb2.CancelRequest(job_id="job-1", attempt_id="nope")
assert stub.Cancel(unknown, timeout=5).disposition == (
pb2.CANCEL_DISPOSITION_NOT_FOUND
)
def test_cancel_before_any_execute_is_not_found(tmp_path):
with serve_over_socket(make_context(), tmp_path) as (stub, _):
response = stub.Cancel(
pb2.CancelRequest(job_id="j", attempt_id="never-ran"), timeout=5
)
assert response.disposition == pb2.CANCEL_DISPOSITION_NOT_FOUND
# ── failure classification ────────────────────────────────────────────────
def test_model_load_failure_is_classified(tmp_path):
engine = FailingEngine(RuntimeError("weights corrupted"), phase="model_load")
failed = _failure(
_run_direct(make_context(engine=engine), make_execute_request(tmp_path))
)
assert failed.stable_code == codes.MODEL_LOAD_FAILED
assert failed.failure_class == pb2.RUNTIME_FAILURE_CLASS_MODEL_LOAD
def test_inference_failure_is_classified(tmp_path):
engine = FailingEngine(ValueError("synthesis exploded"))
failed = _failure(
_run_direct(make_context(engine=engine), make_execute_request(tmp_path))
)
assert failed.stable_code == codes.INFERENCE_FAILED
assert failed.failure_class == pb2.RUNTIME_FAILURE_CLASS_INFERENCE
def test_gpu_oom_is_classified_as_gpu_resource(tmp_path):
engine = FailingEngine(RuntimeError("CUDA out of memory. Tried to allocate…"))
failed = _failure(
_run_direct(make_context(engine=engine), make_execute_request(tmp_path))
)
assert failed.stable_code == codes.GPU_OUT_OF_MEMORY
assert failed.failure_class == pb2.RUNTIME_FAILURE_CLASS_GPU_RESOURCE
def test_engine_input_rejection_is_invalid_input(tmp_path):
from services.tts_backend import TTSInputError
engine = FailingEngine(TTSInputError("text too long for this engine"))
failed = _failure(
_run_direct(make_context(engine=engine), make_execute_request(tmp_path))
)
assert failed.stable_code == codes.INPUT_REJECTED
assert failed.failure_class == pb2.RUNTIME_FAILURE_CLASS_INPUT
def test_url_handles_are_rejected_never_fetched(tmp_path):
request = make_execute_request(
tmp_path, input_handle="https://evil.example/input.txt", input_sha256=""
)
failed = _failure(_run_direct(make_context(), request))
assert failed.stable_code == codes.INPUT_HANDLE_INVALID
assert failed.failure_class == pb2.RUNTIME_FAILURE_CLASS_INPUT
def test_relative_output_handle_is_rejected(tmp_path):
request = make_execute_request(tmp_path, output_handle="relative/out.wav")
failed = _failure(_run_direct(make_context(), request))
assert failed.stable_code == codes.INPUT_HANDLE_INVALID
def test_model_digest_mismatch_is_rejected(tmp_path):
request = make_execute_request(tmp_path)
request.model.model_digest = "sha256:" + "f" * 64
failed = _failure(_run_direct(make_context(), request))
assert failed.stable_code == codes.INPUT_MODEL_DIGEST_MISMATCH
def test_non_ready_model_is_rejected(tmp_path):
installed = ModelInfo(
catalog_model_id=READY_MODEL.catalog_model_id,
model_version=READY_MODEL.model_version,
model_digest=READY_MODEL.model_digest,
precisions=READY_MODEL.precisions,
features=READY_MODEL.features,
state=STATE_INSTALLED,
)
context = make_context(inventory=FakeInventory(models=[installed]))
failed = _failure(_run_direct(context, make_execute_request(tmp_path)))
assert failed.stable_code == codes.INPUT_MODEL_NOT_READY
def test_unknown_model_is_rejected(tmp_path):
request = make_execute_request(tmp_path)
request.model.catalog_model_id = "who-dis"
failed = _failure(_run_direct(make_context(), request))
assert failed.stable_code == codes.INPUT_MODEL_UNKNOWN
def test_unknown_and_out_of_range_parameters_are_rejected(tmp_path):
unknown = make_execute_request(
tmp_path,
parameters={"exfiltrate": pb2.ParameterValue(string_value="x")},
)
assert _failure(_run_direct(make_context(), unknown)).stable_code == (
codes.INPUT_PARAMETER_UNKNOWN
)
out_of_range = make_execute_request(
tmp_path,
attempt_id="attempt-2",
parameters={"speed": pb2.ParameterValue(number_value=99.0)},
)
assert _failure(_run_direct(make_context(), out_of_range)).stable_code == (
codes.INPUT_PARAMETER_RANGE
)
def test_input_checksum_mismatch_is_rejected(tmp_path):
request = make_execute_request(tmp_path, input_sha256="0" * 64)
failed = _failure(_run_direct(make_context(), request))
assert failed.stable_code == codes.INPUT_CHECKSUM_MISMATCH
def test_empty_text_is_rejected(tmp_path):
request = make_execute_request(tmp_path, text=" ")
failed = _failure(_run_direct(make_context(), request))
assert failed.stable_code == codes.INPUT_TEXT_EMPTY
def test_unwritable_output_directory_is_local_storage(tmp_path):
locked = tmp_path / "locked"
locked.mkdir()
request = make_execute_request(tmp_path, output_handle=str(locked / "out.wav"))
locked.chmod(0o500)
try:
failed = _failure(_run_direct(make_context(), request))
finally:
locked.chmod(0o700)
assert failed.stable_code == codes.STORAGE_WRITE_FAILED
assert failed.failure_class == pb2.RUNTIME_FAILURE_CLASS_LOCAL_STORAGE
def test_duplicate_attempt_id_is_rejected(tmp_path):
context = make_context()
executor = context.executor()
first = make_execute_request(tmp_path)
assert terminal_of(list(executor.execute(first, None)))[0] == "completed"
duplicate = make_execute_request(tmp_path)
events = list(executor.execute(duplicate, None))
failed = _failure(events)
assert failed.stable_code == codes.INPUT_ATTEMPT_DUPLICATE
def test_slot_exhaustion_is_gpu_resource(tmp_path):
engine = SlowEngine(seconds=30)
context = make_context(engine=engine, slot_limit=1)
executor = context.executor()
hog = make_execute_request(tmp_path, attempt_id="hog")
hog_events = []
hog_thread = threading.Thread(
target=lambda: hog_events.extend(executor.execute(hog, None)), daemon=True
)
hog_thread.start()
assert engine.started.wait(5)
try:
crowded = make_execute_request(tmp_path, attempt_id="crowded")
failed = _failure(list(executor.execute(crowded, None)))
assert failed.stable_code == codes.GPU_SLOTS_EXHAUSTED
assert failed.failure_class == pb2.RUNTIME_FAILURE_CLASS_GPU_RESOURCE
finally:
context.registry.cancel("job-1", "hog")
hog_thread.join(timeout=10)
assert terminal_of(hog_events)[0] == "canceled"
def test_safe_detail_never_carries_local_paths(tmp_path):
engine = FailingEngine(RuntimeError(f"failed loading {tmp_path}/weights.bin"))
failed = _failure(
_run_direct(make_context(engine=engine), make_execute_request(tmp_path))
)
assert str(tmp_path) not in failed.safe_detail
assert "<path>" in failed.safe_detail
+40 -15
View File
@@ -460,30 +460,55 @@ class TaskExecutor:
@staticmethod
def _synthesize(backend, text: str, params: dict):
"""Call the engine through the same serial GPU gate local jobs use.
"""Render through the same seeded pipeline as local ``/generate``.
Held against the idle sweep for the duration: a long generation touches
the instance cache once, at the start, so on elapsed time alone it is
indistinguishable from a model nobody wants any more.
Do not reduce this to ``backend.generate()``. The control plane sends
a complete render contract (pinned gallery seed, synthetic reference,
quality controls, chunking, effects); calling the adapter directly
silently turns a selected gallery voice into a fresh random take.
"""
from services import tts_backend # noqa: PLC0415
from api.routers.generation import _run_backend_inference, _run_inference # noqa: PLC0415
kwargs = {
key: params[key]
for key in (
"ref_audio",
"ref_text",
"instruct",
"language",
"duration",
"description",
"speed",
)
if params.get(key) is not None
}
language = params.get("language")
ref_audio = params.get("ref_audio")
ref_text = params.get("ref_text")
instruct = params.get("instruct")
duration = params.get("duration")
num_step = params.get("num_step", 16)
guidance_scale = params.get("guidance_scale", 2.0)
speed = params.get("speed", 1.0)
denoise = params.get("denoise", True)
postprocess_output = params.get("postprocess_output", True)
used_seed = params.get("seed")
effect_preset = params.get("effect_preset", "broadcast")
max_chunk_chars = params.get("max_chunk_chars")
crossfade_ms = params.get("crossfade_ms")
try:
with tts_backend.engine_in_use(backend):
return backend.generate(text, **kwargs)
if isinstance(backend, tts_backend.OmniVoiceBackend):
# The OSS default engine has an extended native surface;
# preserving it is required for a gallery preview and a
# GPU-worker take to share the same voice identity.
return _run_inference(
backend._model, text, language, ref_audio, ref_text,
instruct, duration, num_step, guidance_scale, speed,
params.get("t_shift"), denoise, postprocess_output,
params.get("layer_penalty_factor"),
params.get("position_temperature"),
params.get("class_temperature"), used_seed,
effect_preset, max_chunk_chars, crossfade_ms,
)
return _run_backend_inference(
backend, text, language, ref_audio, ref_text, instruct,
duration, num_step, guidance_scale, speed, denoise,
postprocess_output, used_seed, effect_preset,
max_chunk_chars, crossfade_ms,
)
except Exception as exc:
from worker import errors as worker_errors # noqa: PLC0415
+4
View File
@@ -57,6 +57,10 @@ REQUIRED_FEATURES = frozenset({
"task_progress_v1",
"task_inputs_v1",
"remote_model_download_v1",
# A generic backend.generate() call accepts the same wire shape but drops
# profile conditioning controls. Require the canonical worker render path
# so an older peer cannot successfully return a different voice.
"remote_tts_render_v1",
})
+56
View File
@@ -0,0 +1,56 @@
# Benchmarks
Measured numbers per engine and device — how long a generation actually
takes on real hardware. Every number here is produced by the in-repo
harness, on named hardware, at a named version; nothing is estimated.
## How numbers are measured
```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 # just the TTS stage
```
`scripts/bench_pipeline.py` profiles each pipeline stage one at a time,
memory-safely: it refuses to start a stage without enough free RAM and
unloads models between stages. See [performance.md](performance.md) for
what each stage spends its time on.
The `tts` stage emits the two values this table collects:
- **RTF** (real-time factor) — seconds of compute per second of generated
audio, printed next to each warm measurement. RTF < 1 means faster than
real time. Use the **short line (warm)** RTF for the table.
- **Peak VRAM** — printed on CUDA only. MPS is unified memory and CPU has
no VRAM; subprocess-isolated engines allocate outside the harness's view
(it prints `n/a` for them). Leave the column blank in all those cases.
## Results
No verified rows yet — this table fills from maintainer runs and community
submissions.
| Engine | Device | RTF (warm) | Peak VRAM (GB) | App version | Source |
|---|---|---|---|---|---|
| _none yet — contribute yours below_ | | | | | |
Column meanings: **Engine** — the TTS engine the harness resolved (printed
at stage start). **Device** — one string naming what ran the model, e.g.
`RTX 3060 12 GB`, `Apple M2 Pro`, `Ryzen 7 5800X (CPU)`. **RTF (warm)**
the short-line warm RTF from the harness. **Peak VRAM** — the harness's
CUDA peak, blank on MPS/CPU. **App version** — from `Settings → About`.
**Source** — a link to the PR that added the row.
## Contributing a row
1. Run the harness on an otherwise-idle machine (app stopped) and copy its
summary table.
2. Open a PR adding one row using the column meanings above, and paste the
raw harness output into the PR description — that PR link becomes the
row's **Source**.
3. One row per engine+device pair; a newer app version replaces the old row.
Numbers from different machines aren't directly comparable — that's fine.
The point is honest expectations ("this engine on this class of GPU ≈ this
fast"), not a leaderboard.
+61
View File
@@ -0,0 +1,61 @@
# Engine guides
One page per engine: what it's for, what it needs, how to enable it, and its
quirks. Select engines in **Model Catalogue → Engines** (or quick-switch with
<kbd>Ctrl</kbd>/<kbd>Cmd</kbd>+<kbd>E</kbd>), or pin one with
`OMNIVOICE_TTS_BACKEND` / `OMNIVOICE_ASR_BACKEND`.
The compute device (CUDA/ROCm/MPS/CPU) is auto-detected; pin it under
**Settings → Performance & Device** (or `OMNIVOICE_DEVICE`) if auto-detect
picks wrong — see [performance](../performance.md).
Measured speed/VRAM numbers live in [benchmarks](../benchmarks.md); what each
engine can do expressively in [expressive-speech](../expressive-speech.md);
sidecar disk footprints in [disk-usage](disk-usage.md); the bar a new engine
must clear in [engine-acceptance](../engine-acceptance.md).
New to VoiceStudio? Install the app first — [macOS](../install/macos.md)
(first launch needs the one-time right-click → **Open** Gatekeeper
approval), [Windows](../install/windows.md), [Linux](../install/linux.md),
[Docker](../install/docker.md).
## Text-to-speech
| Engine | Guide | Runs on | Cloning | Enabled by |
|---|---|---|---|---|
| VoiceStudio (OmniVoice) — **default** | [omnivoice](omnivoice.md) | CUDA · MPS · CPU | ✅ | installed by default |
| VoxCPM2 | [voxcpm2](voxcpm2.md) | CUDA · MPS · CPU | ✅ + voice design | `pip install "voxcpm>=2.0.3"` |
| MOSS-TTS-Nano | [moss-tts-nano](moss-tts-nano.md) | CUDA · CPU | ✅ (ref only) | clone + `uv pip install -e .` |
| KittenTTS | [kittentts](kittentts.md) | CPU | — (8 preset voices) | `pip install kittentts` |
| MLX-Audio (Kokoro, CSM, Dia, …) | [mlx-audio](mlx-audio.md) | Apple Silicon | model-dependent | `pip install mlx-audio` |
| CosyVoice 3 | [cosyvoice](cosyvoice.md) | CUDA · CPU | ✅ | clone + requirements |
| GPT-SoVITS | [gpt-sovits](gpt-sovits.md) | external server | ✅ | its own API server |
| Sherpa-ONNX | [sherpa-onnx](sherpa-onnx.md) | CUDA · CPU | — | `pip install sherpa-onnx` + model dir |
| IndexTTS 2.5 | [indextts](indextts.md) | CUDA · CPU | ✅ + emotion | one-click sidecar install |
| OmniVoice GGUF | [omnivoice-gguf](omnivoice-gguf.md) | CUDA · MPS · CPU | ✅ | bundled binary |
| Supertonic-3 | [supertonic3](supertonic3.md) | CPU | — (7 preset voices) | `uv sync --extra supertonic` + license |
| MOSS-TTS-v1.5 (8B) | [moss-tts-v15](moss-tts-v15.md) | CUDA · CPU | ✅ | clone + env var |
| dots.tts (2B) | [dots-tts](dots-tts.md) | CUDA · CPU (not Windows) | ✅ | clone + env var |
| OmniVoice (subprocess) | [omnivoice-subprocess](omnivoice-subprocess.md) | CUDA · MPS · CPU | ✅ | opt-in pick, no install |
| PocketTTS (Kyutai) | [pockettts](pockettts.md) | CPU (not Intel Mac) | ✅ | `uv sync --extra pockettts` + license |
| Confucius4-TTS | [confucius4-tts](confucius4-tts.md) | CUDA · CPU | ✅ | clone + env var |
## Speech-to-text
| Engine | Guide | Runs on | Best at | Enabled by |
|---|---|---|---|---|
| WhisperX | [whisperx](whisperx.md) | CUDA · CPU | dubbing (word timestamps + diarization) | installed by default |
| Faster-Whisper | [faster-whisper](faster-whisper.md) | CUDA · CPU | general transcription | installed by default |
| Faster-Whisper (isolated) | [faster-whisper-isolated](faster-whisper-isolated.md) | CUDA · CPU | unattended batches | opt-in pick |
| MLX Whisper | [mlx-whisper](mlx-whisper.md) | Apple Silicon | Mac default | `pip install mlx-whisper` |
| PyTorch Whisper | [pytorch-whisper](pytorch-whisper.md) | CUDA · MPS · CPU | ROCm hosts | installed by default |
| Parakeet TDT (NeMo) | [nemo-parakeet](nemo-parakeet.md) | CUDA · CPU | 25 languages, fast CPU | separate venv (never the app's) |
| Parakeet TDT (MLX) | [parakeet-mlx](parakeet-mlx.md) | Apple Silicon | dictation, 25 EU languages | default on mac-ARM source installs |
| Moonshine | [moonshine](moonshine.md) | CPU | edge/low-power, no timestamps | `pip install` (see guide) |
| FunASR (SenseVoice) | [funasr](funasr.md) | CUDA · CPU | 50+ languages, inline diarization | `pip install funasr` |
| Sherpa-ONNX dictation | [sherpa-onnx-asr](sherpa-onnx-asr.md) | CPU | live streaming dictation | curated model download |
| OpenAI-compatible (remote) | [openai-compatible-asr](openai-compatible-asr.md) | network | offloading to a server (audio leaves the machine) | Model Catalogue |
Speaker diarization is not an engine registry of its own — the dub pipeline
uses pyannote (HF-gated; see [diarization](../features/diarization.md)) and
FunASR can diarize inline with its `cam++` speaker model.
+61
View File
@@ -0,0 +1,61 @@
# VoiceStudio — Faster-Whisper (Crash-Isolated) Engine
The same CTranslate2 Whisper engine as [faster-whisper](faster-whisper.md),
run in a **separate child process** ("sidecar"). CTranslate2's GPU teardown
can segfault — the endemic faster-whisper crash — and a hung or crashed
transcribe in-process takes the whole backend down with it. Isolated, the
child can crash or be force-killed to reclaim a hung transcribe and its VRAM
while the backend stays up
([#730](https://github.com/debpalash/VoiceStudio/issues/730)).
There is nothing extra to install: the sidecar reuses the app's own venv —
only the process boundary is new.
## Selecting it
- **Model Catalogue → Engines**, ASR tab → **Use** on the crash-isolated row, or
- pin it with `OMNIVOICE_ASR_BACKEND=faster-whisper-isolated`.
It is never picked by auto-detect — it's an explicit opt-in escape hatch.
## Best at
- **Long batch runs** where one bad file must not kill the backend.
- Machines where in-process faster-whisper has crashed or hung before:
a sidecar crash fails only that job, and the next transcribe respawns a
fresh sidecar automatically.
## Platform support
Same as faster-whisper: CUDA float16 or CPU int8 on macOS, Windows, and
Linux. The sidecar picks cuda/cpu itself and walks the same
float16 → int8_float16 → int8 degrade chain on GPUs without efficient fp16
([#551](https://github.com/debpalash/VoiceStudio/issues/551)).
## Model selection
- `ASR_MODEL_FASTER` — the shared model selection, same as the in-process
engine: set it once and both variants load the same weights.
- `ASR_MODEL_FW` — optional sidecar-only override; when set it wins over
`ASR_MODEL_FASTER` for this engine. Default `large-v3`.
- `ASR_COMPUTE_TYPE` — optional: pin the sidecar to one CTranslate2 compute
type instead of the automatic degrade chain.
Weights download on first load — see
[downloading-models](../downloading-models.md).
## Trade-offs and quirks
- **Slightly slower per call** than in-process faster-whisper (IPC overhead);
the model stays warm inside the sidecar between calls, so the cost is per
request, not per chunk of audio.
- Word timestamps are Whisper-native (±100300 ms) — no forced alignment.
For dubbing lip-sync, use [whisperx](whisperx.md) or
[mlx-whisper](mlx-whisper.md).
- If the sidecar dies mid-transcription the job fails with a clear
"sidecar crashed" error and the backend stays up — retry to respawn.
- **cuDNN 8 is still required on CUDA** — same CTranslate2 requirement as the
in-process engine. It's checked up front so a missing cuDNN 8 shows as
"unavailable" in Model Catalogue → Engines instead of a sidecar that
silently fails every transcribe
([#1371](https://github.com/debpalash/VoiceStudio/issues/1371)).
+70
View File
@@ -0,0 +1,70 @@
# VoiceStudio — Faster-Whisper Engine
Faster-Whisper runs Whisper on CTranslate2 — the same transcription core
WhisperX uses, **without** the wav2vec2 forced-alignment pass. It's the safe
cross-platform fallback when whisperx isn't installed, and the capture/dictation
fallback on non-Apple machines.
## Selecting it
- **Model Catalogue → Engines**, ASR tab → **Use** on the Faster-Whisper row, or
- pin it with `OMNIVOICE_ASR_BACKEND=faster-whisper`.
Auto-detect only picks it when [whisperx](whisperx.md) is unavailable.
## Best at
- **Subtitles, dictation buffers, and batch transcription** where Whisper's
native word timing (±100300 ms) is good enough.
- For dubbing lip-sync, prefer [whisperx](whisperx.md) (or
[mlx-whisper](mlx-whisper.md) on Apple Silicon) — their forced alignment is
an order of magnitude tighter on word boundaries.
## Platform support
- **CUDA** — float16, with automatic degradation (below).
- **CPU** — int8 on macOS, Windows, and Linux.
- **Apple Silicon GPU / ROCm** — not supported: CTranslate2 has no Metal or
HIP build, so those hosts run on CPU
([#1529](https://github.com/debpalash/VoiceStudio/issues/1529)); auto-detect
routes them to mlx-whisper / pytorch-whisper instead.
## Model selection
`ASR_MODEL_FASTER` — default `Systran/faster-whisper-large-v3`. Accepts the
size aliases (`tiny``large-v3`, `distil-large-v3`) or any CTranslate2
Whisper repo on HF. Weights download on first load — see
[downloading-models](../downloading-models.md).
Segments are cleaned up by faster-whisper's built-in Silero VAD before
transcription.
## Degradation chains
- GPUs without efficient fp16 (older Maxwell/Pascal, GTX 16xx, or a
CTranslate2/cuDNN mismatch) fail at model construction with a compute-type
error; the engine walks float16 → int8_float16 → int8 instead of failing
every chunk ([#551](https://github.com/debpalash/VoiceStudio/issues/551)).
- A CUDA out-of-memory falls back to CPU (slower, same model and accuracy) —
flushing the resident TTS model frees VRAM for GPU-speed ASR
([#255](https://github.com/debpalash/VoiceStudio/issues/255)).
## Quirks
- **cuDNN 8 required on CUDA** — a missing cuDNN 8 would fast-fail the whole
process, so the engine checks up front and reports itself unavailable
instead ([#1371](https://github.com/debpalash/VoiceStudio/issues/1371)).
pytorch-whisper covers that case on torch's bundled cuDNN 9.
- On some hardened Linux kernels the CTranslate2 native library is rejected
with "cannot enable executable stack" (an OSError, not an ImportError) —
reported as unavailable rather than crashing engine selection
([#692](https://github.com/debpalash/VoiceStudio/issues/692)).
- CTranslate2's GPU teardown can rarely segfault the process at unload. If
you hit that, switch to the crash-isolated variant —
[faster-whisper-isolated](faster-whisper-isolated.md)
([#730](https://github.com/debpalash/VoiceStudio/issues/730)).
- Transcribes are time-bounded: `OMNIVOICE_TRANSCRIBE_CHUNK_TIMEOUT_S`
(default 120 s per dub chunk) and `OMNIVOICE_ASR_TRANSCRIBE_TIMEOUT_S`
(default 300 s whole-file).
Speed comparisons across engines live in [performance](../performance.md).
+62
View File
@@ -0,0 +1,62 @@
# VoiceStudio — FunASR (SenseVoice) Engine
FunASR drives Alibaba's SenseVoiceSmall with FSMN-VAD: an all-in-one
multilingual pipeline — transcription with punctuation and inverse text
normalization across **50+ languages**, plus optional **inline speaker
diarization** via the cam++ speaker model. It's the opt-in alternative to
WhisperX ([#182](https://github.com/debpalash/VoiceStudio/issues/182));
WhisperX remains the cross-platform default.
## Selecting it
- Install it into the app venv: `uv pip install funasr`.
- Then **Model Catalogue → Engines**, ASR tab → **Use** on the FunASR row, or
`OMNIVOICE_ASR_BACKEND=funasr`.
Auto-detect never picks it; it's an explicit opt-in.
## Best at
- **Multi-speaker transcription without any HuggingFace token.** This is the
only ASR engine with diarization built in: cam++ labels each sentence
(`Speaker 1`, `Speaker 2`, ...) in the same pass — no gated pyannote
model, no license click-through. Compare
[diarization](../features/diarization.md) for the pyannote/WhisperX route
and what each buys you.
- **Broad language coverage** beyond Whisper's strongest languages, with
punctuation included.
## Not suited for
- **Lip-sync dubbing** — FunASR returns sentence-level timestamps, not
word-level ones. Use [whisperx](whisperx.md) /
[mlx-whisper](mlx-whisper.md) when word timing matters.
## Platform support
CUDA or CPU, on macOS, Windows, and Linux.
## Model selection
| Variable | Default | Role |
| --- | --- | --- |
| `ASR_MODEL_FUNASR` | `iic/SenseVoiceSmall` | main ASR model |
| `ASR_FUNASR_VAD` | `fsmn-vad` | VAD segmentation model |
| `ASR_FUNASR_SPK` | `cam++` | speaker model; set to empty (`ASR_FUNASR_SPK=`) to disable diarization and use the dub pipeline's pyannote/heuristic path instead |
Weights download on first load (through FunASR's own model hub) — see
[downloading-models](../downloading-models.md).
## Quirks
- With the speaker model enabled, long recordings are transcribed in **one
call** and split by FunASR's internal VAD — cam++ assigns speaker cluster
IDs per call, so this is what keeps "Speaker 1" meaning the same person
across the whole file.
- The engine runs with `spk_mode="vad_segment"`: FunASR 1.3.1's default
(`punc_segment`) requires a separate punctuation model and crashes when
SenseVoice is loaded without one.
- SenseVoice's rich-token markup (language/emotion/event tags around the
text) is stripped from the output automatically.
- Language detection is automatic (`language: auto`); the detected language
is reported per file.
+78
View File
@@ -0,0 +1,78 @@
# VoiceStudio — GPT-SoVITS Engine
GPT-SoVITS (RVC-Boss) is one of the most popular open-source voice-cloning
systems (57k+ GitHub stars, MIT-licensed). It does zero-shot and few-shot
cloning with excellent naturalness in Chinese, English, Japanese, Cantonese,
and Korean, and it is very fast (RTF ~0.014 on suitable hardware).
Unlike VoiceStudio's other engines, GPT-SoVITS does not run inside the app.
It ships as a standalone API server, and VoiceStudio connects to it over
HTTP.
## When to pick it
- You already run (or want to run) a GPT-SoVITS server, e.g. with few-shot
fine-tuned voices.
- You need fast, natural cloning in zh/en/ja/yue/ko.
## Setup
1. Install and start the GPT-SoVITS API server (upstream project):
```bash
cd GPT-SoVITS
python api_v2.py -a 127.0.0.1 -p 9880 -c GPT_SoVITS/configs/tts_infer.yaml
```
2. Select the engine via **Model Catalogue → Engines** or
`OMNIVOICE_TTS_BACKEND=gpt-sovits`.
VoiceStudio marks the engine available only when the server responds
(2-second reachability probe).
## Configuration
| Variable | Default | Meaning |
| --- | --- | --- |
| `OMNIVOICE_GPTSOVITS_URL` | `http://127.0.0.1:9880` | API server URL |
| `OMNIVOICE_TRUSTED_NETWORKS` | (unset) | Required to allow a non-loopback server |
**Remote servers:** by default VoiceStudio only talks to loopback addresses
— part of the local-first guarantee. To point at a server on another
machine (e.g. a GPU box on your LAN), add its network to
`OMNIVOICE_TRUSTED_NETWORKS`; otherwise the connection is refused as an
untrusted endpoint.
Prefer `https://` (or a private tunnel such as Tailscale/WireGuard) for any
non-loopback server: with plain `http://` the text you synthesize and the
audio that comes back cross the network unencrypted. VoiceStudio does not
disable certificate verification, so a TLS endpoint needs a certificate the
system trusts.
## Behaviour notes
- Output is 32 kHz mono (server output is resampled if needed).
- Cloning passes your reference clip path and optional transcript to the
server; the reference path must be readable **by the server process**, so
remote servers need the clip on their own filesystem.
- Speed control is forwarded as the server's `speed_factor`.
- The GPU is whatever the GPT-SoVITS server itself uses (CUDA preferred);
VoiceStudio's side is just an HTTP client.
## Known limits
- Five languages only; for broader coverage use
[OmniVoice](omnivoice.md) ([languages.md](../languages.md)).
- No voice design; server availability is your responsibility — if the
server stops, generations fail with a "server not reachable" error.
## Troubleshooting
- "GPT-SoVITS server not reachable": start the server with the command
above, or fix `OMNIVOICE_GPTSOVITS_URL`.
- "endpoint is outside loopback or OMNIVOICE_TRUSTED_NETWORKS": see
Configuration above.
- Other issues: [install/troubleshooting.md](../install/troubleshooting.md).
See also: [benchmarks.md](../benchmarks.md),
[expressive-speech.md](../expressive-speech.md).
+75
View File
@@ -0,0 +1,75 @@
# VoiceStudio — KittenTTS Engine
KittenTTS (KittenML) is the lightweight English "flash" tier: a 2580 MB
ONNX model with 8 preset voices that runs realtime on any CPU — no torch, no
CUDA, no GPU of any kind. Use it when you just need quick English narration
(voiceovers, demo reads, short phrases) with no reference sample.
## When to pick it
- English-only content where speed and a tiny install matter more than
cloning.
- Machines with no usable GPU.
The trade-off against [OmniVoice](omnivoice.md): no voice cloning, English
only — but a much faster and much smaller install.
## Setup
```bash
pip install kittentts
```
Then select the engine via **Model Catalogue → Engines** or
`OMNIVOICE_TTS_BACKEND=kittentts`.
## Voices
Eight preset voices, four male/female pairs:
```text
expr-voice-2-m expr-voice-2-f (default: expr-voice-2-f)
expr-voice-3-m expr-voice-3-f
expr-voice-4-m expr-voice-4-f
expr-voice-5-m expr-voice-5-f
```
An unknown voice id logs an info message and falls back to the default.
## Model selection
| Variable | Default | Meaning |
| --- | --- | --- |
| `OMNIVOICE_KITTENTTS_MODEL` | `KittenML/kitten-tts-mini-0.8` | HuggingFace checkpoint to load |
The ~80 MB model downloads from HuggingFace on first use (retried once on a
flaky connection). See [downloading-models.md](../downloading-models.md).
## Behaviour notes
- Output is 24 kHz mono.
- CPU-only by design — the ONNX graph has no CUDA/MPS path.
- Non-English `language` values are ignored with a log line pointing at
OmniVoice; reference audio is likewise ignored (no cloning).
- **Long-input hardening
([#1173](https://github.com/debpalash/VoiceStudio/issues/1173)):** the
shipped ONNX graph has a hard 512-token cap, and phonemization can expand
text massively (digits especially). VoiceStudio pre-measures every chunk
with the model's own tokenizer and splits oversized chunks at word
boundaries, so long or digit-heavy inputs no longer abort inside
onnxruntime with an opaque "invalid expand shape" error.
## Known limits
- English only; no cloning, no voice design, no emotion controls
(see [expressive-speech.md](../expressive-speech.md)).
- Preset voices only — speed is the one knob.
## Troubleshooting
- Engine unavailable: `pip install kittentts` into VoiceStudio's Python
environment and restart.
- Other issues: [install/troubleshooting.md](../install/troubleshooting.md).
See also: [benchmarks.md](../benchmarks.md),
[disk usage](disk-usage.md).
+76
View File
@@ -0,0 +1,76 @@
# VoiceStudio — MLX-Audio Engine (Apple Silicon)
MLX-Audio (Blaizzy/mlx-audio) wraps 14+ TTS engines — Kokoro, CSM, Dia,
Qwen3-TTS, Chatterbox, MeloTTS, OuteTTS, and more — behind a single adapter
that runs on Apple's MLX framework. It is **Apple Silicon only**: the engine
is not shipped on Linux, Windows, or Intel Macs, and a stray wheel on those
platforms never reports as available
([#390](https://github.com/debpalash/VoiceStudio/issues/390)).
## When to pick it
- You're on an M-series Mac and want small, fast models tuned for it.
- You want one of the specific hosted models (Kokoro for small multilingual,
CSM for cloning, Qwen3-TTS for voice design, Dia for dialogue, …).
## Setup
```bash
pip install mlx-audio
```
Then select the engine via **Model Catalogue → Engines** or
`OMNIVOICE_TTS_BACKEND=mlx-audio`.
## Model selection
One backend hosts many models. The curated set:
| Key | Model | Niche |
| --- | --- | --- |
| `kokoro` (default) | `mlx-community/Kokoro-82M-bf16` | small multilingual |
| `csm` | `mlx-community/csm-1b-8bit` | voice cloning |
| `qwen3-tts` | `mlx-community/Qwen3-TTS-12Hz-1.7B-VoiceDesign-4bit` | voice design |
| `dia` | `mlx-community/Dia-1.6B` | dialogue |
| `chatterbox` | `mlx-community/Chatterbox-TTS-4bit` | expressive |
| `melotts` | `mlx-community/MeloTTS-English-v3-MLX` | lightweight VITS |
| `outetts` | `mlx-community/Llama-OuteTTS-1.0-1B-4bit` | LM-based |
Pick a model in the **Model Catalogue → Engines** curated picker
([#981](https://github.com/debpalash/VoiceStudio/issues/981)) or set
`OMNIVOICE_MLX_AUDIO_MODEL` to either a curated key (`kokoro`) or any full
HF repo id. The env var overrides the persisted UI choice.
## Behaviour notes
- Output is 24 kHz mono for most hosted models.
- **Cloning works only with the `csm` model** — it is the only curated model
confirmed to accept a reference clip. Other models silently ignore
reference audio, so the engine reports cloning support only when CSM is
selected (dub/batch jobs gate on this).
- Voice design (text description → voice) is available through the
Qwen3-TTS VoiceDesign model.
- Language support is per-model (Kokoro ~8 languages, others vary). An
unsupported language for Kokoro produces a clear error naming what it
does support ([#977](https://github.com/debpalash/VoiceStudio/issues/977))
— leave language on Auto or switch to a multilingual engine.
## Platform notes
This engine is exempt from cross-platform parity as a platform-only
capability behind explicit opt-in: it exists only where Apple's MLX runtime
exists. On any other platform the engine picker shows it unavailable with
the reason.
## Troubleshooting
- Unavailable on an M-series Mac: `pip install mlx-audio` into
VoiceStudio's Python environment; in a packaged app build, MLX's native
libraries may fail to load — the engine reports unavailable rather than
crashing.
- Other issues: [install/troubleshooting.md](../install/troubleshooting.md).
See also: [benchmarks.md](../benchmarks.md),
[languages.md](../languages.md),
[downloading-models.md](../downloading-models.md),
[disk usage](disk-usage.md).
+57
View File
@@ -0,0 +1,57 @@
# VoiceStudio — MLX Whisper Engine
MLX Whisper runs Whisper on the Apple Silicon GPU via MLX. It exists because
CTranslate2 (whisperx / faster-whisper) has **no Metal build** — on a Mac
those engines transcribe on the CPU no matter what GPU is present. Measured
on an M2 with whisper-large-v3, one 30 s dub chunk: **90.4 s on WhisperX
(CPU) vs 20.5 s on MLX (GPU)** — which is why auto-detect picks MLX Whisper
on every Apple Silicon machine
([#1127](https://github.com/debpalash/VoiceStudio/issues/1127)).
## Selecting it
- Nothing to do on Apple Silicon — auto-detect prefers it there.
- Or explicitly: **Model Catalogue → Engines**, ASR tab → **Use**, or
`OMNIVOICE_ASR_BACKEND=mlx-whisper`.
## Best at
- **Dubbing on a Mac** — it layers the same wav2vec2 forced alignment
WhisperX uses on top of the GPU transcription, so word timing (±1030 ms)
and therefore lip-sync accuracy are unchanged. Same model, same alignment,
~4x the speed.
- **Dictation/capture** — the capture path automatically swaps in
`mlx-community/whisper-large-v3-turbo` (~5x faster than large-v3) unless a
sherpa dictation model or [parakeet-mlx](parakeet-mlx.md) is preferred.
## Platform support
**Apple Silicon only.** A shared platform gate refuses Linux, Windows, and
Intel Macs before any package import, so a stray `mlx-whisper` wheel on the
wrong platform never reports itself available
([#390](https://github.com/debpalash/VoiceStudio/issues/390)). All other
platforms use the CUDA/CPU engines instead.
## Model selection
- `ASR_MODEL` — default `mlx-community/whisper-large-v3-mlx`. Any MLX-format
Whisper repo works. Weights download on first load — see
[downloading-models](../downloading-models.md).
- `OMNIVOICE_ALIGN_DEVICE` — force the wav2vec2 aligner's device. The aligner
runs on MPS when it can and falls back to CPU; languages without a bundled
aligner (~20 major languages have one) keep Whisper's native word
timestamps.
## Quirks
- Audio is decoded through VoiceStudio's validated ffmpeg rather than the
bare `ffmpeg` PATH lookup mlx-whisper would do on its own — a clean
from-source install with no system ffmpeg works fine
([#479](https://github.com/debpalash/VoiceStudio/issues/479)).
- The model is warmed into unified memory in the background, so the first
transcribe after startup doesn't pay the load cost.
- In a packaged app, a native MLX library that fails to load is reported as
"unavailable" (with fallback to another engine) rather than crashing the
engine list.
Speed comparisons across engines live in [performance](../performance.md).
+48
View File
@@ -0,0 +1,48 @@
# VoiceStudio — Moonshine Engine
Moonshine is an edge-optimized ASR family built for CPU-only machines.
Unlike Whisper it processes variable-length audio (no padding everything to
30 s), which keeps latency low on short clips — sub-200 ms class on capture
buffers. It's the lightest local option for quick transcription on hardware
where even int8 whisper-large is too slow.
## Selecting it
- Install one of the runtimes into the app venv:
`uv pip install moonshine-onnx` (lighter, tried first) or
`moonshine-voice`.
- Then **Model Catalogue → Engines**, ASR tab → **Use** on the Moonshine row,
or `OMNIVOICE_ASR_BACKEND=moonshine`.
Auto-detect never picks it; it's an explicit opt-in.
## Best at
- **Quick notes and short-clip transcription on low-power CPU machines.**
- Environments where a sub-1 GB footprint matters more than word timing or
language coverage.
## Not suited for
- **Dubbing.** Output is plain text as a **single segment spanning the whole
file — no word or segment timestamps** — so there's nothing for lip-sync
or subtitle timing to work with. Use a Whisper-family engine or
[sherpa-onnx-asr](sherpa-onnx-asr.md) for those jobs.
- Multilingual work: results report English; for broad language coverage use
[whisperx](whisperx.md) or [funasr](funasr.md).
## Platform support
CPU only, by design — macOS, Windows, and Linux. It claims no GPU.
## Model selection
`ASR_MODEL_MOONSHINE` — default `moonshine/base`. Weights download on first
load — see [downloading-models](../downloading-models.md).
## Quirks
- The engine tries `moonshine_onnx` first and falls back to
`moonshine_voice` — installing either one is enough.
- Segment bounds are synthesized from the audio duration (start 0, end =
file length), since the model reports none.
+78
View File
@@ -0,0 +1,78 @@
# VoiceStudio — MOSS-TTS-Nano Engine
MOSS-TTS-Nano (OpenMOSS) is the low-resource, broad-language pick: a
100M-parameter autoregressive codec LM that runs realtime on a 4-core CPU —
no GPU required — with native 48 kHz output and 20 languages under an
Apache-2.0 license. It fills the "runs on a fanless laptop" tier while still
covering languages like Arabic, Hebrew, Persian, Korean, and Turkish.
## When to pick it
- CPU-only or low-power hardware, but you still need cloning and non-English
coverage.
- Your language is among: Chinese, English, German, Spanish, French,
Japanese, Italian, Hebrew, Korean, Russian, Persian, Arabic, Polish,
Portuguese, Czech, Danish, Swedish, Hungarian, Greek, Turkish.
## Setup
The package is **not on PyPI** — install it from the upstream repo into
VoiceStudio's Python environment:
```bash
git clone https://github.com/OpenMOSS/MOSS-TTS-Nano.git
cd MOSS-TTS-Nano
uv pip install -e .
```
Then select the engine via **Model Catalogue → Engines** or
`OMNIVOICE_TTS_BACKEND=moss-tts-nano`.
## Model selection
| Variable | Default | Meaning |
| --- | --- | --- |
| `OMNIVOICE_MOSS_TTS_MODEL` | `OpenMOSS-Team/MOSS-TTS-Nano` | HuggingFace checkpoint to load |
The first use downloads the weights (retried once on a truncated download).
See [downloading-models.md](../downloading-models.md).
## Behaviour notes
- **Cloning is reference-only**: pass a reference clip. Style instructions,
preset speakers, and speed control are not supported and are silently
ignored, so mixed-engine call sites keep working.
- The model emits 48 kHz stereo; VoiceStudio downmixes to mono, matching the
rest of the pipeline (the dub mixer treats TTS output as mono per
segment).
- Runs on CPU or CUDA.
## Upstream is unpinned
The upstream repo is installed straight from git with no pinned release, and
the model class it exports has changed before
([#1287](https://github.com/debpalash/VoiceStudio/issues/1287)). VoiceStudio
therefore verifies that a usable model class actually exists — not just that
the package imports — before reporting the engine as ready. If the engine
shows unavailable with a "does not expose a usable model class" message,
pull the latest upstream and re-run `uv pip install -e .`, or open an issue
with the version you have.
## Known limits
- No voice design, no instruct, no speed control — cloning from a reference
clip only.
- Quality sits below the large engines; see
[benchmarks.md](../benchmarks.md).
## Troubleshooting
- "moss_tts_nano package not installed": run the clone + `uv pip install -e .`
steps above.
- Entry-point errors after an upstream update: see "Upstream is unpinned"
above.
- General issues: [install/troubleshooting.md](../install/troubleshooting.md).
See also: [languages.md](../languages.md),
[expressive-speech.md](../expressive-speech.md),
[disk usage](disk-usage.md).
+58
View File
@@ -0,0 +1,58 @@
# VoiceStudio — Parakeet TDT (NVIDIA NeMo) Engine
NVIDIA's Parakeet TDT via the NeMo toolkit: a FastConformer encoder with a
Token-and-Duration Transducer decoder. It beats Whisper large-v3 on English
benchmarks (~6% WER) and supports **25 (mostly European) languages** with
automatic language detection. The 0.6B model is fast even on CPU — measured
RTF 0.080.23 on an Apple Silicon M2 CPU (2026-07-02), ~20x faster than
faster-whisper large-v3 int8 on the same host.
## Do not install NeMo into the app venv
`nemo_toolkit`'s ASR extras pin `transformers>=4.57,<4.58`, which conflicts
with VoiceStudio's own `transformers>=5.3` requirement and **will break the
backend** (ImportError on startup) if installed into the shared venv. There
is currently no safe in-app install path for this engine; in-app isolation
is tracked separately.
If you want the Parakeet models without a separate environment, use these
instead — same model family, no NeMo dependency:
- **Apple Silicon:** [parakeet-mlx](parakeet-mlx.md) (installed by default on
mac-ARM source installs).
- **Any platform, CPU:** [sherpa-onnx-asr](sherpa-onnx-asr.md) — its default
dictation model is an int8 ONNX export of Parakeet TDT v3.
## Selecting it
Only meaningful if you've set up `nemo_toolkit[asr]` in a **separate,
dedicated Python environment** that runs the backend:
- **Model Catalogue → Engines**, ASR tab → **Use** on the Parakeet TDT row, or
- `OMNIVOICE_ASR_BACKEND=nemo-parakeet`.
Auto-detect never picks it; it's an explicit opt-in.
## Best at
- **English and European-language transcription** where WER matters more
than word-level subtitle timing.
- **CPU-only hosts** — faster than realtime without any GPU.
## Platform support
CUDA or CPU (the old hard CUDA gate was removed — see the RTF numbers
above). Availability is a pure dependency check on `nemo.collections.asr`.
## Model selection
`ASR_MODEL_NEMO` — default `nvidia/parakeet-tdt-0.6b-v3`. Weights download
on first load — see [downloading-models](../downloading-models.md).
## Quirks
- Output is a **single segment** for the whole file (NeMo doesn't VAD-split
like Whisper), with word timestamps when the model exposes them — fine for
dictation and plain transcripts, not ideal for long-form subtitles.
- The detected language isn't exposed cleanly by NeMo, so results report
`en` regardless of the actual (auto-detected) language.
+83
View File
@@ -0,0 +1,83 @@
# VoiceStudio — OmniVoice GGUF Engine
OmniVoice GGUF runs the same OmniVoice model as the [default
engine](omnivoice.md), but through a bundled native binary
(`bin/omnivoice-tts-<platform>`) loading quantized GGUF weights. It is
hardware-adaptive: a probe picks the quantization that fits your machine, so
small GPUs and CPU-only hosts get a working OmniVoice instead of a paging,
timing-out one.
## When to pick it
- Your GPU is below the default engine's 6 GB VRAM floor.
- CPU-only machines that still want OmniVoice's voice and language coverage.
- You want generation isolated in a separate process (a crash or leak never
takes the app down — each generation spawns the binary fresh).
## Quantization selection
Weights come from the `Serveurperso/OmniVoice-GGUF` HuggingFace repo, pinned
to an exact revision. The hardware probe selects:
| Hardware | Quant | Approx. VRAM use |
| --- | --- | --- |
| 12 GB+ VRAM | BF16 | ~1.6 GB (quality-first) |
| 412 GB VRAM | Q8_0 | ~945 MB (recommended balance) |
| 14 GB VRAM | Q4_K_M | ~659 MB (minimal footprint) |
| CPU-only | Q4_K_M | RAM-bound, latency-tolerable |
You can override the selection from Settings; overrides are allow-listed
against the same table (an F32 reference quant, ~3.2 GB, is override-only).
## Setup
Nothing to install: installer and CI builds bundle the binary for your
platform. Select the engine via **Model Catalogue → Engines** or
`OMNIVOICE_TTS_BACKEND=omnivoice-gguf`. The quant weights download on first
use (see [downloading-models.md](../downloading-models.md)) — install them
ahead of time from **Model Catalogue → Models** if you want the first
generation to be quick; a long first render is the download, not a hang.
**Source checkouts:** the repo ships zero-byte placeholders in `bin/` — real
binaries come from CI or the installer. The engine detects a placeholder and
reports unavailable with instructions
([#1172](https://github.com/debpalash/VoiceStudio/issues/1172)) instead of
failing at spawn time; build one with
`scripts/build-omnivoice-tts.sh --platform <slug>` or use the default
in-process engine.
## Integrity and self-healing
Before reporting ready, the engine:
- verifies the binary against the SHA-256 manifest (`bin/checksums.sha256`);
- detects macOS Gatekeeper quarantine and prints the exact
`xattr -cr '/Applications/VoiceStudio.app'` fix;
- restores a missing execute bit (a git clone or zip extract on POSIX can
drop `+x`, which used to surface as a permission error mislabeled as
out-of-memory — [#437](https://github.com/debpalash/VoiceStudio/issues/437)).
The chmod runs only after the SHA check confirms it's the right file.
## Behaviour notes
- Output is 24 kHz mono — same model, same rate as in-process OmniVoice.
- Cloning from a reference clip (with optional transcript) and style
instructions are supported; no voice design.
- Same multilingual surface as OmniVoice ([languages.md](../languages.md)).
- Because generation runs in another process, the app's own GPU counters
don't see its allocations — diagnostics label it accordingly.
| Variable | Default | Meaning |
| --- | --- | --- |
| `OMNIVOICE_GGUF_GENERATE_TIMEOUT_S` | (generous built-in) | Per-generation timeout for the spawned binary |
## Troubleshooting
- "GGUF binary missing": this build doesn't bundle the runtime for your
platform — use the default engine.
- Checksum mismatch or quarantine messages: follow the printed fix, or
reinstall.
- Other issues: [install/troubleshooting.md](../install/troubleshooting.md).
See also: [benchmarks.md](../benchmarks.md),
[performance.md](../performance.md), [disk usage](disk-usage.md).
+74
View File
@@ -0,0 +1,74 @@
# VoiceStudio — OmniVoice Engine (default)
OmniVoice (k2-fsa/OmniVoice) is VoiceStudio's default TTS engine — the one a
fresh install uses without any configuration. It does zero-shot voice cloning
across 600+ languages and outputs 24 kHz mono audio. Voice cloning, dubbing,
and dictation all run on it out of the box.
## When to pick it
- You want cloning plus the broadest language coverage (see
[languages.md](../languages.md)).
- You have a GPU (CUDA or Apple Silicon MPS) with ~6 GB VRAM or more.
- You just installed VoiceStudio — it's already selected.
For low-VRAM or CPU-only machines, the
[OmniVoice GGUF](omnivoice-gguf.md) variant runs the same model through a
quantized native binary with a much smaller memory footprint.
## Requirements
- Runs on CUDA, MPS (Apple Silicon), or CPU — auto-detected.
- Recommended VRAM floor: **6 GB** on a dedicated GPU. This is the only
engine with a measured floor: on 4 GB cards (GTX 1650 Ti, Quadro P2000 —
issues [#1226](https://github.com/debpalash/VoiceStudio/issues/1226) /
[#1222](https://github.com/debpalash/VoiceStudio/issues/1222)) the driver
pages to system RAM and a render that should take seconds runs for minutes
until the compute budget kills it. The UI warns before you wait; nothing
hard-blocks, since short inputs can still fit.
- No extra install — the model ships with the app and downloads its weights
on first use (see [downloading-models.md](../downloading-models.md)).
## Selecting the engine
OmniVoice is the default, so normally there is nothing to do. If you switched
away and want it back:
- **Model Catalogue → Engines**, or
- set `OMNIVOICE_TTS_BACKEND=omnivoice`.
The env var overrides the persisted UI choice.
## Behaviour notes
- Weights load lazily on first use and are shared with the rest of the app
(dubbing, dictation) — the model is never double-loaded.
- On CUDA the model runs fp16 with `torch.compile`; a speech recognizer is
co-loaded for the cloning path.
- Output is 24 kHz mono; the shared mastering chain (highpass + compressor)
is tuned for this rate and applied automatically.
- Cloning takes a short reference clip (`ref_audio`); an optional transcript
of the clip improves conditioning.
## Known limits
- No voice design from a text description — use [VoxCPM2](voxcpm2.md) for
that.
- Below the 6 GB VRAM floor, expect very slow renders or budget timeouts;
prefer [OmniVoice GGUF](omnivoice-gguf.md) or a CPU engine such as
[PocketTTS](pockettts.md).
## Troubleshooting
- "Too heavy for the available compute" on a small GPU: see the VRAM floor
above — switch to OmniVoice GGUF or close other GPU apps.
- First generation is slow: the first call downloads multi-GB weights. To
keep the first render quick, install the model ahead of time from
**Model Catalogue → Models** — a long first generate is almost always the
download, not a hang.
- General install issues: [install/troubleshooting.md](../install/troubleshooting.md).
See also: [benchmarks.md](../benchmarks.md),
[performance.md](../performance.md),
[expressive-speech.md](../expressive-speech.md),
[disk usage](disk-usage.md).
+58
View File
@@ -0,0 +1,58 @@
# VoiceStudio — Parakeet TDT v3 (MLX) Engine
NVIDIA's Parakeet TDT v3 on the Apple Silicon GPU, via the small pure-Python
`parakeet-mlx` package. It gives Macs the Parakeet tier CUDA/CPU users get
through NeMo or sherpa-onnx: **25 European languages**, word timestamps from
the TDT decoder itself (no wav2vec2 alignment pass needed), ~1.2 GB download,
~2 GB unified memory, dictation-grade speed on the GPU.
Unlike [nemo-parakeet](nemo-parakeet.md) it needs no `nemo_toolkit` (whose
transformers pin conflicts with the app's) — it is **installed by default on
Apple Silicon source installs since 0.3.22**.
## Selecting it
- **Model Catalogue → Engines**, ASR tab → **Use** on the Parakeet TDT v3
(MLX) row, or `OMNIVOICE_ASR_BACKEND=parakeet-mlx`.
- **Dictation prefers it automatically**: once the model weights are
installed (Model Catalogue → Models — the auto-pick never triggers a
download), live dictation/capture uses it whenever your system language is
one of the 25 covered European languages. Other languages keep the
multilingual Whisper engine, so dictation coverage never regresses.
## Best at
- **Live dictation on a Mac** — TDT decoding is fast enough for the capture
path, at Parakeet's better-than-Whisper English WER.
- **European-language transcription** with word timestamps at a fraction of
whisper-large-v3's memory and compute.
For languages outside the 25 (CJK, Arabic, ...), use
[mlx-whisper](mlx-whisper.md) instead.
## Platform support
**Apple Silicon only** — the same shared MLX platform gate as mlx-whisper
refuses Linux, Windows, and Intel Macs before any import
([#390](https://github.com/debpalash/VoiceStudio/issues/390)). It runs on the
unified-memory GPU; there is no CPU tier.
## Model selection
`ASR_MODEL_PARAKEET_MLX` — default `mlx-community/parakeet-tdt-0.6b-v3`.
Weights download on first load — see
[downloading-models](../downloading-models.md).
## Quirks
- Long files are processed in 120 s chunks internally to bound unified-memory
use; short dictation buffers and dub chunks are unaffected.
- Parakeet v3 auto-detects among its 25 languages but doesn't expose the
pick, so the reported language is the one you requested (or none) — it is
never hardcoded to English.
- Word timestamps are merged from the decoder's subword tokens — good for
subtitles and dictation; for lip-sync-critical dubbing the wav2vec2-aligned
engines ([mlx-whisper](mlx-whisper.md), [whisperx](whisperx.md)) remain the
accuracy tier.
Speed comparisons across engines live in [performance](../performance.md).
+82
View File
@@ -0,0 +1,82 @@
# VoiceStudio — PocketTTS Engine
PocketTTS (kyutai-labs/pocket-tts, 100M parameters) is the fastest-CPU-render
pick: small, low-latency, CPU-only, with zero-shot voice cloning from a
reference clip. It covers six languages — English, French, German,
Portuguese, Italian, Spanish — with one model per language, and measures
roughly 89x real-time on an Apple M3 Pro.
It complements the quality engines: where they fall back to CPU, PocketTTS
is built for it. CPU-only is deliberate — upstream observes no GPU speedup
for this model.
## When to pick it
- CPU-only machines that need fast rendering *and* voice cloning.
- Latency-sensitive use (dictation-style, short utterances) in one of the
six languages.
## Setup
1. Install the optional dependency:
```bash
uv sync --extra pockettts
```
(Or enable it from **Model Catalogue → Engines**.)
2. **Accept the license in-app**
([#1306](https://github.com/debpalash/VoiceStudio/issues/1306)). The code
is MIT and the weights are CC-BY-4.0, but the weights are **gated on
HuggingFace** behind an access agreement with an acceptable-use clause.
VoiceStudio surfaces this before first use: the engine stays unavailable
until you review and accept in **Model Catalogue → Engines → PocketTTS**.
You also need HuggingFace access to the gated repo (see
[downloading-models.md](../downloading-models.md) for token setup).
3. Select the engine via **Model Catalogue → Engines** or
`OMNIVOICE_TTS_BACKEND=pockettts`.
## Platform notes
- Works on Linux, Windows, macOS Apple Silicon — CPU only everywhere.
- **Not available on Intel Macs**: the required PyTorch version has no
macOS x86_64 wheel. The engine reports this plainly instead of failing
mid-install.
## Behaviour notes
- Output is 24 kHz mono.
- Six languages, one model per language, chosen by the `language` you
request; cloning takes a short reference clip.
- Runs in a crash-isolated sidecar process (parent Python environment): a
wedged generation is hard-killed by a watchdog and its memory reclaimed —
something an in-process engine cannot do.
- The first use downloads the gated weights; the sidecar heartbeats
progress during the download so the watchdog doesn't fire.
| Variable | Default | Meaning |
| --- | --- | --- |
| `OMNIVOICE_POCKETTTS_RECV_TIMEOUT_S` | `600` | Sidecar response deadline in seconds (min 30; cold loads download weights) |
## Known limits
- No voice design, no emotion controls
(see [expressive-speech.md](../expressive-speech.md)).
- Six languages only — for broader coverage use
[OmniVoice](omnivoice.md) ([languages.md](../languages.md)).
- Revoking the license acceptance takes effect immediately, without a
restart — subsequent generations refuse.
## Troubleshooting
- "pocket_tts package not installed": run the `uv sync` above.
- "license not accepted": open **Model Catalogue → Engines → PocketTTS**
and review/accept.
- Timeouts on a slow connection: raise
`OMNIVOICE_POCKETTTS_RECV_TIMEOUT_S` for the first (download-heavy) run.
- Other issues: [install/troubleshooting.md](../install/troubleshooting.md).
See also: [benchmarks.md](../benchmarks.md),
[performance.md](../performance.md), [disk usage](disk-usage.md).
+61
View File
@@ -0,0 +1,61 @@
# VoiceStudio — PyTorch Whisper Engine
Whisper through the plain `transformers` pipeline, riding torch itself. No
extra install — transformers ships with the app — and because it runs on
torch's own stack (including torch's bundled cuDNN 9), it works on machines
where the CTranslate2 engines can't load. It is also the engine that
genuinely uses **AMD ROCm** GPUs, so auto-detect picks it on ROCm hosts
([#1529](https://github.com/debpalash/VoiceStudio/issues/1529)).
## Selecting it
- **Model Catalogue → Engines**, ASR tab → **Use** on the PyTorch Whisper
row, or `OMNIVOICE_ASR_BACKEND=pytorch-whisper`.
- Auto-detect picks it on ROCm, and as the last resort everywhere else.
## Best at
- **ROCm dubbing/transcription** — the only Whisper engine that uses the HIP
GPU (CTranslate2 has no HIP build, MLX is Apple-only).
- **Rescue engine** when whisperx/faster-whisper can't load — e.g. the
missing-cuDNN-8 case
([#255](https://github.com/debpalash/VoiceStudio/issues/255)) — since it
needs neither CTranslate2 nor cuDNN 8.
For lip-sync-grade word timing prefer [whisperx](whisperx.md) or
[mlx-whisper](mlx-whisper.md); this engine returns the pipeline's own word
timestamps.
## Platform support
CUDA, Apple Silicon (MPS), ROCm (HIP), and CPU — wherever torch runs, on
macOS, Windows, and Linux.
## Model selection
`OMNIVOICE_PYTORCH_ASR_MODEL` — default `openai/whisper-large-v3-turbo`. Any
transformers-format Whisper repo works. Weights download on first load — see
[downloading-models](../downloading-models.md).
## VRAM preflight
whisper-large-v3-turbo needs roughly 3.2 GiB before generation adds its
workspace; loading it onto a nearly-full card "succeeds" and then the first
transcribe OOMs with zero segments. So on CUDA the engine checks free VRAM
against a 5 GB budget before loading and uses the CPU instead when the card
is too full (flush the TTS model to restore GPU-speed ASR). Disable with
`OMNIVOICE_ASR_VRAM_PREFLIGHT=0`.
## Quirks
- If the pipeline fails to import (`AutoFeatureExtractor` errors), the cause
is either an incomplete transformers install or a torch/torchvision
version mismatch — the error message names the exact reinstall command;
the trio has to move together at the pinned versions
([#549](https://github.com/debpalash/VoiceStudio/issues/549),
[#1376](https://github.com/debpalash/VoiceStudio/issues/1376)).
- Transcribes are time-bounded like every local engine:
`OMNIVOICE_TRANSCRIBE_CHUNK_TIMEOUT_S` (default 120 s per dub chunk),
`OMNIVOICE_ASR_TRANSCRIBE_TIMEOUT_S` (default 300 s whole-file).
Speed comparisons across engines live in [performance](../performance.md).
+71
View File
@@ -0,0 +1,71 @@
# VoiceStudio — Sherpa-ONNX Dictation Engine
The k2-fsa/sherpa-onnx ONNX runtime as a **live dictation** engine: small
int8 models that transcribe faster than realtime on CPU, with identical
behavior on macOS (arm64 + x86_64), Windows, and Linux — no CUDA dependency.
Streaming models emit partial text frame-by-frame as you speak; offline
models re-transcribe a growing buffer on a short cadence, so you see live
partials either way.
## Selecting it
- Ensure `sherpa-onnx` is installed (`uv add sherpa-onnx` on source installs).
- Pick a dictation model in the app (Model Catalogue → Models lists the
curated set below), or **Model Catalogue → Engines**, ASR tab → **Use**, or
pin `OMNIVOICE_ASR_BACKEND=sherpa-onnx-asr`.
- `OMNIVOICE_SHERPA_ASR_MODEL` selects the model — default
`sherpa-parakeet-tdt-v3`.
## Best at
- **Live dictation on CPU** — the whole point of this engine. Fast partials,
automatic endpointing on silence, no GPU required.
- It also honors the regular offline `transcribe` contract, so any of its
models can transcribe a file — plain text, single segment, no word
timestamps, which makes it a dictation/notes tool rather than a dubbing
engine.
## The 7 curated models
| Id | Type | Languages | Download |
| --- | --- | --- | --- |
| `sherpa-parakeet-tdt-v3` (default) | offline | 25 European languages | 0.67 GB |
| `sherpa-parakeet-tdt-v2` | offline | English | 0.66 GB |
| `sherpa-zipformer-bilingual-zh-en` | streaming | Chinese + English | 0.20 GB |
| `sherpa-paraformer-bilingual-zh-en` | streaming | Chinese + English | 0.24 GB |
| `sherpa-zipformer-en-20m` | streaming | English | 0.044 GB |
| `sherpa-zipformer-zh-14m` | streaming | Chinese | 0.025 GB |
| `sherpa-whisper-tiny` | offline | 90+ languages (auto-detect) | 0.104 GB |
Sizes are measured on-disk download sizes. Weights are int8 ONNX checkpoints
that download on first use through the same HF cache as everything else —
see [downloading-models](../downloading-models.md). Peak RAM for the 0.6B
Parakeets is noticeably higher than their download size (onnxruntime's arena
allocator holds onto freed blocks).
## Platform support
CPU on every platform, by the strict cross-platform default-parity rule.
`OMNIVOICE_SHERPA_ASR_PROVIDER` can override the ONNX provider on a verified
GPU build, but the default never diverges.
## Tuning
- `OMNIVOICE_SHERPA_ASR_THREADS` — decode threads (default 2; the 0.6B
Parakeets automatically use up to 4 when the host has the cores, so decode
keeps ahead of the speaker).
- `OMNIVOICE_DICTATION_ENDPOINT_R1` / `OMNIVOICE_DICTATION_ENDPOINT_R2`
streaming endpoint rules in seconds (defaults 1.0 / 0.6: text commits
~0.6 s after you stop speaking). Applied without a restart.
## Quirks
- The recognizer is **pre-warmed in the background** so the first dictation
session doesn't pay the 1.32.5 s ONNX session load
([#888](https://github.com/debpalash/VoiceStudio/issues/888)); it's then
shared warm across sessions.
- On Apple Silicon, installing the [parakeet-mlx](parakeet-mlx.md) model
makes dictation prefer the GPU Parakeet automatically for the 25 covered
languages; an explicitly selected sherpa model still wins.
- The offline `transcribe` path reports `language: auto` — per-file language
detection is only meaningful for the Whisper Tiny model.
+75
View File
@@ -0,0 +1,75 @@
# VoiceStudio — Sherpa-ONNX Engine
Sherpa-ONNX (k2-fsa/sherpa-onnx) is a unified C++ ONNX runtime that wraps
20+ TTS model families (VITS, MeloTTS, Piper, Kokoro, Matcha, and more)
behind one API, with pre-built wheels for Linux, Windows, and macOS (x86 and
ARM). You bring the model: point VoiceStudio at any downloaded sherpa-onnx
TTS model directory.
## When to pick it
- You want a specific community model (e.g. a Piper or VITS voice for your
language) that no other engine hosts.
- You need a dependable CPU engine with optional CUDA acceleration.
## Setup
1. Install the runtime:
```bash
pip install sherpa-onnx
```
2. Download a TTS model from the
[sherpa-onnx releases](https://github.com/k2-fsa/sherpa-onnx/releases)
and unpack it somewhere permanent.
3. Point VoiceStudio at the model directory and restart:
```bash
export OMNIVOICE_SHERPA_MODEL=/path/to/model-dir
```
4. Select the engine via **Model Catalogue → Engines** or
`OMNIVOICE_TTS_BACKEND=sherpa-onnx`.
The directory must contain `model.onnx` and `tokens.txt`. Sherpa-ONNX ships
no bundled default model, so the engine reports unavailable — with the
reason — until `OMNIVOICE_SHERPA_MODEL` points at a valid directory. (Before
this gate, selecting the engine unconfigured produced a failure mislabeled
as out-of-memory —
[#919](https://github.com/debpalash/VoiceStudio/issues/919).)
## Configuration
| Variable | Default | Meaning |
| --- | --- | --- |
| `OMNIVOICE_SHERPA_MODEL` | (unset) | Directory containing `model.onnx` + `tokens.txt` |
## Behaviour notes
- Output defaults to 22.05 kHz (the VITS default); once a model is loaded,
its own sample rate is used.
- CPU is the universal baseline; the CUDA onnxruntime provider is available
on Linux/Windows installs.
- **No cloning**: voices come from the model itself. Multi-speaker VITS
models select a voice by numeric speaker id; speed is supported.
- Languages depend entirely on the model you download.
## Known limits
- One model at a time — switching models means changing
`OMNIVOICE_SHERPA_MODEL` and restarting.
- No voice design, no reference-audio cloning, no emotion controls
(see [expressive-speech.md](../expressive-speech.md)).
## Troubleshooting
- "OMNIVOICE_SHERPA_MODEL not set" / "No model.onnx in …": follow Setup
above — the variable must point at the *unpacked* model directory, not
the archive.
- Other issues: [install/troubleshooting.md](../install/troubleshooting.md).
See also: [benchmarks.md](../benchmarks.md),
[languages.md](../languages.md),
[disk usage](disk-usage.md).
+76
View File
@@ -0,0 +1,76 @@
# VoiceStudio — Supertonic-3 Engine
Supertonic-3 (Supertone Inc.) is a ~99M-parameter ONNX TTS engine covering
31 languages with 7 preset voices at native 44.1 kHz. It is CPU-only by
design — pure ONNX Runtime on the CPU execution provider, with no CUDA or
MPS path in the upstream SDK — and runs in its own sidecar process so
crashes and cold init never block the rest of VoiceStudio.
## When to pick it
- Broad language coverage on machines with no usable GPU.
- Preset-voice narration at a higher sample rate than the default engine.
## Setup
1. Install the optional dependency into VoiceStudio's environment:
```bash
uv sync --extra supertonic
```
(Or enable it from **Model Catalogue → Engines**, which installs the
pinned `supertonic` wheel for you.)
2. **Accept the license in-app.** First use is gated behind an explicit
acceptance dialog: the inference SDK is MIT, but the model weights are
**OpenRAIL-M**, which carries use restrictions. The engine stays
unavailable until you review and accept in **Model Catalogue → Engines →
Supertonic-3**.
3. Select the engine via **Model Catalogue → Engines** or
`OMNIVOICE_TTS_BACKEND=supertonic3`.
The first synthesis cold-downloads ~400 MB of model weights, pinned to an
exact HuggingFace revision SHA so the bytes match what the SDK was validated
against. See [downloading-models.md](../downloading-models.md).
## Voices
Seven preset voices are surfaced: `M1` (default), `M3`, `M4`, `M5`, `F3`,
`F4`, `F5`. The SDK itself accepts the full `M1``M5` / `F1``F5` set if a
caller passes one explicitly; unknown ids fall back to the default with a
log line.
## Behaviour notes
- Output is 44.1 kHz mono.
- Runs as a long-lived sidecar in the parent Python environment (its
dependencies — onnxruntime, numpy, soundfile — already match
VoiceStudio's pins); subsequent calls reuse the warm ONNX session.
- `speed` is clamped to 0.72.0; quality steps clamp to 512.
- Language is an ISO 639-1 code; Auto engages the SDK's multilingual
fallback.
## Known limits
- **No cloning and no voice design** — preset voices only. Dub/batch jobs
that need cloning won't select it.
- CPU-only: hardware acceleration is a property of the upstream SDK, not a
VoiceStudio limitation.
- OpenRAIL-M weights are not covered by VoiceStudio's blanket
commercial-use statement — review the model license terms in the
acceptance dialog.
## Troubleshooting
- "supertonic package not installed": run the `uv sync` above or enable
from the Model Catalogue.
- "license not accepted": open **Model Catalogue → Engines → Supertonic-3**
and accept.
- Other issues: [install/troubleshooting.md](../install/troubleshooting.md).
See also: [benchmarks.md](../benchmarks.md),
[languages.md](../languages.md),
[expressive-speech.md](../expressive-speech.md),
[disk usage](disk-usage.md).
+76
View File
@@ -0,0 +1,76 @@
# VoiceStudio — VoxCPM2 Engine
VoxCPM2 (OpenBMB) is the studio-quality option: native 48 kHz output,
zero-shot voice cloning, and — uniquely among VoiceStudio's engines —
**voice design**: creating a synthetic voice from a text description
("young female, warm tone, British accent") with no reference audio at all.
## When to pick it
- You want voice design without a reference clip.
- You want the highest output sample rate (48 kHz vs OmniVoice's 24 kHz).
- Your language is among its 30 supported languages: Arabic, Burmese,
Chinese, Danish, Dutch, English, Finnish, French, German, Greek, Hebrew,
Hindi, Indonesian, Italian, Japanese, Khmer, Korean, Lao, Malay,
Norwegian, Polish, Portuguese, Russian, Spanish, Swahili, Swedish,
Tagalog, Thai, Turkish, Vietnamese.
## Requirements
- Python ≥ 3.10, PyTorch ≥ 2.5.
- CUDA ≥ 12 recommended for full speed; MPS (Apple Silicon) and CPU also
work.
## Setup
Install the package into VoiceStudio's Python environment:
```bash
pip install "voxcpm>=2.0.3"
```
That is a version **floor**, not a pin — an older install still works, but
the engine logs an upgrade hint at load time. Then select the engine via
**Model Catalogue → Engines** or `OMNIVOICE_TTS_BACKEND=voxcpm2`.
## Model selection
| Variable | Default | Meaning |
| --- | --- | --- |
| `OMNIVOICE_VOXCPM_MODEL` | `openbmb/VoxCPM2` | HuggingFace checkpoint to load |
The first use downloads a multi-GB checkpoint from HuggingFace. A download
interrupted near the end used to abort the load outright
([#1224](https://github.com/debpalash/VoiceStudio/issues/1224)); the load is
now retried once with a fresh client. See
[downloading-models.md](../downloading-models.md).
## Behaviour notes
- **Voice design:** provide a description and no reference audio.
- **Cloning:** the reference clip is prepared before use (edge-silence trim
and length cap) so dead air in a raw clip doesn't condition the output; on
any prep problem the raw clip is used as-is.
- **Style instructions** are passed as an inline prefix to the text.
- VoxCPM2 emits mastered, studio-grade audio, so VoiceStudio **skips its
shared mastering chain** (which is tuned for 24 kHz engines) — only benign
loudness normalization applies.
- A trailing-silence guard trims long near-silent tails from generations,
keeping a short natural tail.
## Known limits
- Slower than the lightweight CPU engines — see
[benchmarks.md](../benchmarks.md) and [performance.md](../performance.md).
- Language coverage is 30 languages; for anything else use the default
[OmniVoice](omnivoice.md) engine ([languages.md](../languages.md)).
## Troubleshooting
- Engine shows unavailable: the `voxcpm` package isn't installed — run the
`pip install` above and restart VoiceStudio.
- Repeated first-download failures: check connectivity/HF access, then see
[install/troubleshooting.md](../install/troubleshooting.md).
See also: [expressive-speech.md](../expressive-speech.md),
[disk usage](disk-usage.md).
+80
View File
@@ -0,0 +1,80 @@
# VoiceStudio — WhisperX Engine
WhisperX is the default ASR engine on CUDA and plain-CPU hosts: faster-whisper
(CTranslate2) transcription plus a **wav2vec2 forced-alignment** pass that
snaps word boundaries to ±1030 ms (Whisper's own timestamps are ±100300 ms).
That word timing is what dubbing lip-sync depends on, which is why auto-detect
prefers it wherever CTranslate2 can use the GPU.
## Selecting it
- **Model Catalogue → Engines**, ASR tab → **Use** on the WhisperX row, or
- pin it with `OMNIVOICE_ASR_BACKEND=whisperx` (the env var always wins over
the Settings pick; with neither set, auto-detect chooses per-hardware).
## Best at
- **Dubbing** — the forced alignment is the accuracy tier lip-sync needs.
- **Batch transcription** with word-level subtitles.
- Multi-speaker work: it pairs with pyannote speaker diarization — see
[diarization](../features/diarization.md).
## Platform support
| Host | What happens |
| --- | --- |
| NVIDIA CUDA | GPU, float16 (degrades automatically, see below) |
| CPU (any OS) | int8 — works, but slow for large-v3 |
| Apple Silicon | CPU only — CTranslate2 has no Metal build, so auto-detect prefers [mlx-whisper](mlx-whisper.md) there ([#1127](https://github.com/debpalash/VoiceStudio/issues/1127)) |
| AMD ROCm | CPU only — CTranslate2 has no HIP build, so auto-detect prefers [pytorch-whisper](pytorch-whisper.md) there ([#1529](https://github.com/debpalash/VoiceStudio/issues/1529)) |
## Model selection
- `ASR_MODEL_WHISPERX` — default `large-v3`. Accepts the usual size aliases
(`tiny``large-v3`, `distil-large-v3`) or a full HF repo id. Weights
download on first load — see [downloading-models](../downloading-models.md).
- `OMNIVOICE_ALIGN_DEVICE` — force the wav2vec2 aligner's device. Aligners
exist for ~20 major languages; other languages keep Whisper's native word
timestamps instead of failing.
## VRAM preflight and degradation
Loading fp16 large-v3 onto a nearly-full 8 GB card dies as a *native* CUDA
abort — no Python exception, the whole backend goes down
([#723](https://github.com/debpalash/VoiceStudio/issues/723)). So before every
load the engine checks free VRAM against per-compute-type budgets
(float16 5.0 GB, int8_float16 3.5 GB, int8 3.0 GB, scaled down for smaller
models) and degrades the compute type — or falls to CPU int8 — instead of
starting a load that would kill the process. Disable with
`OMNIVOICE_ASR_VRAM_PREFLIGHT=0`.
Two more fallback chains run at load time:
- GPUs without efficient fp16 (older Maxwell/Pascal, GTX 16xx) raise a
compute-type error — the engine retries int8_float16, then int8
([#551](https://github.com/debpalash/VoiceStudio/issues/551)).
- A genuine CUDA OOM retries on CPU int8, so dubbing still completes
(slower, same model and accuracy).
## Quirks
- **cuDNN 8 required on CUDA.** CTranslate2 links cuDNN 8; if it's missing the
process fast-fails with no traceback, so the engine is reported unavailable
up front and selection falls through to pytorch-whisper, which uses torch's
own cuDNN 9 ([#1371](https://github.com/debpalash/VoiceStudio/issues/1371)).
- On some hardened Linux kernels CTranslate2's native library is rejected with
"cannot enable executable stack" — reported as unavailable, not a crash
([#692](https://github.com/debpalash/VoiceStudio/issues/692)).
- A partially-installed environment (interrupted sync, antivirus quarantine)
can break WhisperX's deep import chain (whisperx → pyannote →
lightning_fabric). The engine is then reported unavailable with a repair
hint — reinstall, or `uv sync --reinstall` on a source checkout
([#1185](https://github.com/debpalash/VoiceStudio/issues/1185)).
- Audio is decoded through VoiceStudio's validated ffmpeg, not a bare `ffmpeg`
PATH lookup ([#479](https://github.com/debpalash/VoiceStudio/issues/479)).
- Transcribes are time-bounded: each dub chunk by
`OMNIVOICE_TRANSCRIBE_CHUNK_TIMEOUT_S` (default 120 s), whole files by
`OMNIVOICE_ASR_TRANSCRIBE_TIMEOUT_S` (default 300 s). Raise them for very
long files on slow hardware.
Speed comparisons across engines live in [performance](../performance.md).
+29
View File
@@ -0,0 +1,29 @@
# Hosted Voice integration
VoiceStudio remains local-first. `/profiles` and `/generate` keep their local
SQLite and on-device synthesis behaviour unless a caller explicitly asks for a
hosted operation. No profile or generation is uploaded merely because hosted
configuration exists.
To enable the optional adapter, configure the backend environment:
```text
VSS_HOSTED_API_BASE=http://127.0.0.1:8080
VSS_HOSTED_API_TOKEN=<scoped API credential>
VSS_HOSTED_PROJECT_ID=<hosted project id>
VSS_HOSTED_MODEL_ID=<approved TTS model id>
VSS_HOSTED_MODEL_VERSION=<approved model version>
VSS_HOSTED_BASE_VOICE_ID=<model-approved base voice>
VSS_HOSTED_CONSENT_TEXT_VERSION=oss-spoken-consent-v1
```
First record ownership consent in the local profile UI, then explicitly call
`POST /profiles/{profile_id}/hosted-sync`. The adapter uploads the reference
recording through hosted Artifact grants and creates a consent-backed
`/v1/voices` record; it never sends a local path or a consent recording. The
returned hosted ID is stored only as local synchronization metadata.
Call `POST /generate` with `hosted=true` and that synchronized `profile_id` to
use the hosted durable `/v1/jobs` path. The adapter stages text as an Artifact,
polls the durable Job, and downloads the result only through a temporary grant.
Without `hosted=true`, `/generate` stays entirely on-device.
+7
View File
@@ -214,6 +214,13 @@ Two paths are worth persisting across container restarts:
The running version is now shown in **Settings → About → Version** (read live
from the backend), so the web UI no longer displays a dash in Docker.
- **Checking which version is running:** `docker exec <container> python3 -c "import importlib.metadata; print(importlib.metadata.version('omnivoice'))"`, or hit the `/health` endpoint — it returns `{"status": "ok", "device": ..., "version": "0.3.x"}`. Use the container name listed by `docker compose ps` (or `omnivoice` for the `docker run` examples).
- **Watching startup:** the port answers within about a second of container
start, but heavy initialization (PyTorch, API routes, database migration)
continues in the background. During that window `/health` returns **503**
with the current step, and `GET /startup/progress` returns the full
step-by-step ledger (`status`, current `step`/`label`, per-step states) —
useful when a start seems slow and you want to see where it actually is.
The Docker `HEALTHCHECK` flips healthy only once `/health` is 200.
- **"Loopback origin required" errors (and a blank version):** the desktop
build restricts the `/system/*` and `/api/settings/*` routes to a loopback
origin, but Docker's NAT makes every request look non-loopback, so the gate
+4
View File
@@ -80,6 +80,7 @@ None of them are required — the defaults are chosen for the common case.
| Variable | Default | What it does |
|---|---|---|
| `OMNIVOICE_DEVICE` | `auto` | Pin the compute device (`cuda` / `rocm` / `xpu` / `mps` / `cpu`) instead of auto-detect. Same control lives in **Settings → Performance & Device** (the env var wins over the UI pick). Honored only for devices the host actually has — a family that isn't detected is noted and ignored, never obeyed blindly. Applies at the next backend start. |
| `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.5 etc.). |
| `OMNIVOICE_LLM_CONCURRENCY` | `6` | Parallel LLM translation calls during a dub. Raise for a fast API endpoint, lower if your provider rate-limits. |
@@ -229,6 +230,9 @@ 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.
Measured results per engine/device — and how to contribute yours — live in
[benchmarks.md](benchmarks.md).
## Things that look like knobs but aren't
- **Deleting and re-adding a voice** doesn't speed anything up; the reference
+13 -2
View File
@@ -153,6 +153,15 @@ fallback is reported once. ASR, diarization and translation also remain local. D
runs here, deliberately and permanently, because there latency *is* the
feature. The remaining operations are being ported one at a time.
### Voice identity parity
For TTS, the worker receives the complete local rendering contract: the voice
profile's reference audio and transcript, its pinned seed, model quality
controls, text chunking/crossfade settings, and output effect preset. The
worker runs the same native or generic rendering pipeline as local
`/generate`; selecting a gallery voice therefore does not turn it into a new
random voice merely because it was rendered on another GPU.
The picker knows this. It resolves against the surface you are on, so a chosen
worker reads **Local** on a tab whose work has no remote path yet and names the
reason, instead of showing a green dot next to a GPU that receives nothing. The
@@ -210,10 +219,12 @@ what is genuinely still in flight.
**Version or feature mismatch.** The protocol keeps a two-release compatibility
window, but release numbers alone do not prove that a worker understands every
additive command. Registration therefore also declares named features for task
inputs, progress leases, and remote model downloads. A worker outside the
inputs, progress leases, remote model downloads, and the voice-identity render
pipeline. A worker outside the
version window, or one missing a required feature, is refused with
`UPGRADE_REQUIRED` and an update instruction before any task runs. It can never
silently render without reference audio or leave a download stuck at 0%.
silently render without reference audio, substitute a different voice, or leave
a download stuck at 0%.
Every remote failure includes a concrete next step. Capacity, missing models,
expired leases or sessions, authentication, rejected inputs, and result upload
@@ -0,0 +1,479 @@
# Frontend Responsiveness: Persistence Write-Amplification Remediation Plan
| Field | Decision |
| --- | --- |
| Status | Implemented in draft PR #1541; CI and review pending |
| Target | One focused frontend PR |
| Priority | P1 responsiveness and data-safety hardening |
| Risk | Medium: persistence timing changes, persisted formats do not |
| Dependencies | None |
| Rollback | Revert the PR; the existing keys and schemas remain readable |
## Executive decision
The first optimization PR should remove synchronous JSON serialization and `localStorage` writes from high-frequency interaction paths. It should preserve the existing `omnivoice.app` and `omni_ui` contracts, coalesce each burst to the latest value, flush within a bounded window, and prevent deferred writes from undoing Factory Reset.
This is the best first change because it addresses a measured, cross-workspace bottleneck without combining it with a storage migration, backend change, or `App.jsx` rewrite. Incremental-dub scheduling, transactional undo, and workspace decomposition remain separate follow-ups with their own evidence and rollback boundaries.
## Evidence and diagnosis
### Static path
Two independent persistence paths run on the browser main thread:
1. Every Zustand `set` invokes the persist middleware. The middleware runs `partialize`, serializes the complete persisted projection, and calls synchronous `localStorage.setItem('omnivoice.app', ...)`, even when the mutation only changes transient state.
2. `useAppData` has a broad effect that serializes and writes `omni_ui` whenever text, dub segments, transcript, tracks, history, or a related preference changes.
The resulting hot path is:
`input -> store update -> render/effects -> full projection -> JSON.stringify -> localStorage.setItem`
The cost scales with document size rather than with the small field the user changed. `localStorage` is synchronous, so both serialization and the physical write compete with the next frame.
### Local runtime baseline
The following measurements are diagnostic baselines from commit `3e3189d04d2d6dba69b4dd07fefc8725b9c94af6`, not portable CI thresholds. Each scenario performs 20 UI-scale interactions; raw storage timing excludes `JSON.stringify`, so it is a lower bound. Two unrelated contact-key writes were excluded from the target-key counts but included in the aggregate raw timing.
| Fixture | Writes to target keys | Input-to-next-frame | Raw `setItem` time |
| --- | ---: | ---: | ---: |
| Small local state | 40 `omnivoice.app` + 20 `omni_ui` | 13.9 ms average, 18.9 ms max | 1.9 ms |
| 1,800 dub segments + 400 story tracks | 40 + 20 | 23.6 ms average, 39.0 ms max | 50.7 ms |
| 3,000 dub segments + 3,000 story tracks | 40 + 20 | Repeated 56-114 ms long tasks | 809 ms |
Representative serialized sizes were approximately 156 KB for `omnivoice.app` and 1.5 MB for `omni_ui`. A direct text-edit probe also produced one write to each key for each change.
### Baseline verification
- Baseline commit: `3e3189d04d2d6dba69b4dd07fefc8725b9c94af6`.
- `bun run test -- src/utils/prefKeys.test.js src/test/omniUiSchema.test.js src/test/dubStepRestoreClamp.test.js src/store/uiScaleMigration.test.ts src/test/dubPerLangTranslations.test.jsx src/test/dubVoiceMatchRequest.test.jsx` passes: 6 files, 35 tests.
- The production build passes. The main application chunk is approximately 381.82 KB minified / 116.27 KB gzip.
- `backend/api/routers/mcp_bindings.py` is not implicated: its list handler is a thin delegation, and the bindings panel already loads bindings and profiles concurrently.
- The large Settings/OpenAPI chunk is lazy and is not the interaction-time bottleneck targeted here.
## Goal
For a rapid sequence of edits, perform no JSON serialization or physical storage write in the originating interaction task and persist only the newest value after the burst, while retaining synchronous hydration and the current recovery formats.
## Scope
### In scope
- One shared, typed, coalescing JSON writer for browser `localStorage`.
- A Zustand-compatible structured storage adapter that defers serialization itself.
- Deferred `omni_ui` persistence with its exact current field set.
- Trailing flush, maximum-wait flush, and page-lifecycle flush.
- Single-writer protection for the standalone Tauri capture widget.
- Factory Reset cancellation so pending values cannot recreate deleted keys.
- Deterministic unit/integration tests, a before/after browser trace, and an Unreleased changelog entry.
### Explicitly out of scope
- IndexedDB, workers, new storage keys, schema changes, or a Zustand version bump.
- Removing duplicated fields from `omni_ui` or changing restore precedence.
- Backend/API/database changes, including MCP bindings.
- Debouncing `/tools/incremental` in this PR.
- Changing undo/redo semantics or snapshot representation.
- Splitting stores, decomposing `App.jsx`, or moving workspace imports.
- New dependencies, user-visible strings, locale files, or an app version bump.
- Hardware-sensitive timing assertions in CI.
## Compatibility and safety invariants
The implementation must preserve all of the following:
| Contract | Required invariant |
| --- | --- |
| Zustand key | `omnivoice.app` |
| Zustand envelope | `{ state, version: 7 }`, serialized with normal `JSON.stringify` semantics |
| Zustand projection | Existing `partialize` fields and transient-field stripping remain semantically unchanged |
| Zustand migration | Existing v1-v7 migration behavior remains unchanged |
| Legacy recovery key | `omni_ui` |
| Legacy recovery shape | Exact current field names, omission behavior, and `sanitizeOmniUi` restore path |
| Hydration | Synchronous; no loading gate or async race is introduced |
| Durability | When serialization/storage succeeds and the browser runs timers, a dirty key is attempted within 1,000 ms of its first unflushed change |
| Lifecycle | `pagehide` and hidden-document events attempt pending values; both events together cause at most one physical write per unchanged generation |
| Reset | A removed preference key cannot be recreated by old or newly queued work before the reset reload |
| Desktop windows | Persistence starts in an unknown/read-only role; the resolved main webview is activated as the only writer and the standalone widget stays read-only |
| Privacy | Logs may contain a key and error name, never persisted user content |
| Platform parity | Same default behavior on macOS, Windows, Linux, browser, and Docker |
Direct consumers such as `utils/donationMoments.js`, E2E state seeding, long-form recovery, and the preference-key registry must continue to parse the existing envelope without changes. The donation opt-out's primary `omnivoice.donate.optOut` flag remains an immediate, separate write; add a compatibility assertion that its immediate behavior and the flushed legacy-envelope fallback both remain valid.
Concurrent browser/Docker tabs are explicitly not promoted to a coordinated multi-writer system in this PR. They retain unsupported last-physical-writer-wins behavior. The PR description must state that boundary; adding cross-tab revisions or `BroadcastChannel` arbitration would be a separate data-consistency design.
## Proposed design
### 1. Shared coalescing writer
Create `frontend/src/utils/coalescedJsonStorage.ts` with an injectable core and one application singleton. The public contract should be small:
| API | Contract |
| --- | --- |
| `queueJsonWrite(key, readLatestValue)` | Mark `key` dirty and replace its lazy provider; return a generation-bound disposer that can cancel only this registration |
| `createZustandJsonStorage()` | Return a `PersistStorage` adapter whose `getItem` is synchronous and whose `setItem` queues the structured `StorageValue` |
| `flushPendingWrites()` | Synchronously serialize and attempt every pending write; return a summary for tests/diagnostics |
| `discardPendingWrites(predicate?)` | Cancel timers and pending values matching a key predicate |
| `suspendJsonWrites(predicate)` | Discard matching work and reject later matching queues until the returned resume callback is used |
| `configurePersistenceRole(role)` | Resolve the singleton from initial `unknown` to `main` or `readonly`; activate staged main work or discard all staged widget work |
| Adapter `removeItem(key)` | Cancel/stage-remove that key before raw removal; propagate main-window removal errors; remain inert in a read-only widget |
| `installPersistenceLifecycleFlush()` | Install the singleton listener pair once for the main bootstrap owner; cleanup is idempotent and reserved for tests/HMR teardown |
Required scheduling semantics:
- Quiet delay: 250 ms after the latest value for a key.
- Hard maximum: 1,000 ms from the first unflushed value for that key; continuous input must not starve persistence.
- Last scheduled value wins.
- The queued provider is evaluated on the JavaScript thread only at flush, so the value serialized is the latest application value at flush time rather than a deep-cloned event-time object.
- The quiet timer resets on replacement; the maximum timer does not.
- A successful maximum flush starts a new window for later updates.
- Use standard timers. Do not make `requestIdleCallback` part of the correctness path; availability differs across the supported webviews.
- Do not wrap `createJSONStorage`. It stringifies before calling the adapter and would leave the main cost inside the interaction path.
Flush behavior:
1. Read the latest provider and serialize only at flush time.
2. Compare the serialized value with the currently durable raw value and skip an identical physical write.
3. Call `setItem` once at most for each dirty key in that flush.
4. Mark the entry clean only after a successful write or confirmed identical value.
5. Ensure an old timer cannot commit after a newer value, cancellation, or removal.
`getItem` must evaluate and return the latest pending structured value when one exists; otherwise it must synchronously parse the durable raw value. This keeps explicit Zustand `rehydrate()` calls internally consistent without changing cold-start hydration.
The lazy-provider contract avoids copying a 1.5 MB document on every input. Task 0 must audit every persisted nested container for in-place mutation. React/Zustand setters are expected to publish replacements; any isolated violation must be fixed or explicitly converted to a safe value provider before wiring this scheduler. If the audit reveals a broad mutable-data convention, stop and redesign this PR rather than hiding a state-model refactor inside it. A deterministic test must pin current-at-flush semantics: mutate/replace the provider's source without serializing, then flush and verify the current value is written.
### 2. Failure semantics
- `JSON.stringify` or storage failures must not escape through a Zustand setter, React effect, or lifecycle event.
- A serialization failure discards that invalid value after a warning; a later valid update can proceed.
- Every flush attempt clears both timers first.
- A quota/security/write failure leaves the previous durable blob untouched and keeps the newest value dirty, but disarms automatic retry. A later queue starts a fresh 250/1,000 ms window; an explicit/lifecycle flush attempts it once. Advancing timers alone must not create a retry loop.
- A multi-key flush is isolated per key: successful keys become clean; a failed key remains dirty; retrying the failed key must not rewrite successful siblings.
- Warn once per key/operation/error class to avoid console floods.
- Never log the value, text, segment data, or serialized payload.
- Adapter `removeItem` and Factory Reset remain truthful: cancel pending work first, then allow a main-window raw removal failure to reach the caller.
- The 1,000 ms durability statement applies only when the browser schedules the timer and storage succeeds. Timer throttling, quota denial, a crashed process, or a failed lifecycle write cannot be promised durable; these cases are observable and non-crashing.
### 3. Main-window ownership
The Tauri widget imports the same Zustand store in a separate webview and calls setters for runtime dictation state. Today those transient setters can persist an older projection over the main window's current preferences.
Do not duplicate widget detection inside the storage utility. `detectIsWidget()` already resolves the initialization marker, Tauri `getCurrentWindow().label`, and legacy development URL. `bootstrapApp()` must pass that exact resolved result to `configurePersistenceRole()` before React renders.
The singleton begins in `unknown`: hydration reads work, but writes/removals can only be staged and no timer, serialization, or raw mutation may run. Resolving `main` replays only the latest staged operation per key and starts its 250/1,000 ms clocks at activation; time spent awaiting role detection does not count against a window in which writing was forbidden. Resolving `readonly` discards staged work and makes both `setItem` and `removeItem` inert. This is necessary because the store is statically imported before asynchronous window detection completes. The in-page browser capture pill shares the main document and remains writable.
Tests that import the store without `bootstrapApp()` must use an isolated writer or explicitly configure `main` in setup and reset role, staged work, suspensions, timers, and listeners in teardown. Existing migration tests must clear scheduler state before seeding raw fixtures; otherwise a staged pending value can mask the fixture during `persist.rehydrate()`.
### 4. Lifecycle ownership
After `detectIsWidget()` resolves, `bootstrapApp()` should configure the role and install lifecycle flushing before rendering only for the main window. Bootstrap is the sole production owner; an isolated writer instance or explicit teardown resets listeners in tests.
- Flush on `pagehide`.
- Flush on `visibilitychange` only when `document.visibilityState === 'hidden'`.
- Do not add `beforeunload`; it is unnecessary and can interfere with back/forward caching.
- Lifecycle flush uses the same generation/cancellation checks as timer flushes. If hidden visibility and `pagehide` both fire, the second invocation observes a clean generation and performs no second serialization/write.
### 5. Zustand integration
In `frontend/src/store/index.ts`:
- Replace `createJSONStorage(() => localStorage)` with the structured coalescing adapter.
- Preserve `name`, `partialize`, `version: 7`, and `migrate` semantically unchanged.
- Keep the long-form projection and removal of `generating`/`audioUrl` intact.
- Do not add `text`, dub segments, or other legacy recovery fields to this key.
This PR deliberately leaves `partialize` synchronous. If post-change profiling shows its `storyTracks.map(...)` is still material, optimize projection scheduling in a separate change rather than replacing hydration and migration machinery here.
### 6. `omni_ui` integration
In `frontend/src/hooks/useAppData.js`:
- Build the same recovery object with the same property order and values.
- Replace direct `JSON.stringify` + `localStorage.setItem` with a lazy `queueJsonWrite('omni_ui', readLatestOmniUi)` provider.
- Keep synchronous parsing, `sanitizeOmniUi`, legacy `clone`/`design` handling, and dub-step clamping unchanged.
- Add an explicit `omniUiRestoreComplete` readiness state. The initial persistence effect must queue nothing; the restore effect sets all recovered values and flips readiness in the same batch, and the subsequent render supplies the first writable value.
- Prove an immediate lifecycle event between the initial effects and the restored render cannot persist defaults.
- Feed a lazy latest-value provider to the writer and invoke its generation-bound disposer in effect cleanup. An obsolete StrictMode/unmounted effect may cancel only its own registration, never a newer mount's provider. Do not deep-clone at queue time; the immutability audit and current-at-flush contract above define ownership.
### 7. Factory Reset integration
In `clearLocalPreferences`:
1. Suspend and discard every pending key for which `isPrefKey(key)` is true.
2. Enumerate and remove durable preference keys exactly as today.
3. Preserve connection credentials and user-data keys exactly as today.
The suspension lasts for the remainder of the successful reset session, because background store activity can occur during the 400 ms before reload. Wrap the entire enumerate-and-remove transaction, including `length`, `key()`, and key filtering/access, so any failure resumes writes before rethrowing. This prevents the existing reset error path from leaving persistence silently disabled. This ordering is mandatory: a stale timer, a new post-reset store update, or the later `pagehide` could otherwise resurrect `omnivoice.app` or `omni_ui` after deletion. Tests that simulate a successful reset without a real reload must explicitly reset the isolated writer afterward.
## File-level change budget
| File | Change |
| --- | --- |
| `frontend/src/utils/coalescedJsonStorage.ts` | New lazy scheduler, Zustand adapter, role configuration, suspension, and lifecycle ownership |
| `frontend/src/utils/coalescedJsonStorage.test.ts` | New deterministic scheduler/failure/lifecycle/widget tests |
| `frontend/src/store/index.ts` | Swap storage adapter only; preserve projection and migrations |
| `frontend/src/store/persistenceScheduling.test.ts` | New Zustand envelope, coalescing, hydration, and long-form projection tests |
| `frontend/src/hooks/useAppData.js` | Gate restore readiness and queue the existing `omni_ui` value provider |
| `frontend/src/hooks/useAppData.persistence.test.jsx` | New restore and burst-write integration tests |
| `frontend/src/main-app.jsx` | Configure the resolved window role, then install main-only lifecycle flushing |
| `frontend/src/main-app.test.jsx` | Extend label/marker/URL role-order coverage |
| `frontend/src/utils/prefKeys.js` | Suspend pending and future preference writes across successful reset |
| `frontend/src/utils/prefKeys.test.js` | Add no-resurrection coverage |
| `frontend/src/utils/donationMoments.test.js` | Preserve immediate primary opt-out and flushed legacy fallback behavior |
| `frontend/e2e-perf/responsiveness.spec.ts` | Add opt-in production-bundle fixture, route mocks, instrumentation, and JSON artifact; no wall-clock CI assertions |
| `frontend/playwright.perf.config.ts` | Add cross-platform production-preview benchmark config derived from the existing prod smoke config |
| `CHANGELOG.md` | One Unreleased performance/fix line once the PR number exists |
No backend, locale, package manifest, lockfile, or persisted-schema file should change.
## Implementation sequence
### Task 0: Freeze the current contracts
- [ ] Record the parent commit SHA and rerun the browser baseline with identical fixtures.
- [ ] Add characterization assertions for the exact Zustand envelope, version, legacy snapshot keys, direct readers, and reset key registry; these must pass before production changes.
- [ ] Add integration assertions for burst write counts and initial-default overwrite behavior; these must fail on the current immediate writer for the expected reason.
- [ ] Confirm existing direct readers (`donationMoments`, E2E helpers) against the frozen fixture.
- [ ] Audit the persisted Zustand projection and every `omni_ui` nested value for in-place mutation. Record the search paths in the PR; resolve any hit before adopting lazy providers.
- [ ] Keep the current 35 targeted tests green while adding fail-before cases.
Exit condition: characterization tests pass; behavioral integration tests fail only because writes are immediate/repeated or startup persistence is ungated. Scheduler-specific unit tests are introduced with the new utility rather than pretending to fail before their seam exists.
### Task 1: Implement the storage primitive
- [ ] Implement per-key quiet and maximum timers with injected clock/storage/serializer dependencies.
- [ ] Make value materialization and serialization lazy and deduplicate against the durable raw string.
- [ ] Implement synchronous pending/durable reads.
- [ ] Implement generation-bound provider disposers plus flush, discard, and removal guards.
- [ ] Implement predicate-based suspension for destructive reset windows.
- [ ] Define failed attempts as timer-disarmed; a later queue starts a new maximum window.
- [ ] Isolate partial failures across multiple dirty keys.
- [ ] Recover from throwing providers, durable reads, malformed JSON, and raw writes without poisoning later valid operations.
- [ ] Deduplicate warnings and prove no value, serialized payload, or error message containing user content is logged.
- [ ] Contain and deduplicate errors without logging payloads.
- [ ] Add `unknown -> main|readonly` role configuration; unknown work cannot reach raw storage.
- [ ] Make adapter removal obey cancellation, role, and error-propagation contracts.
- [ ] Add single-owner lifecycle installation and idempotent teardown.
- [ ] Add a full isolated-writer reset hook for tests: role, staged operations, suspensions, timers, listeners, and warning registry.
Exit condition: all utility tests pass without importing React or the application store.
### Task 2: Wire Zustand without changing its contract
- [ ] Replace `createJSONStorage` with the structured adapter.
- [ ] Keep `partialize`, `version`, and `migrate` unchanged except for any mechanical key constant extraction needed by tests.
- [ ] Prove that 100 rapid transient updates cause zero synchronous serializations/writes and at most one trailing write.
- [ ] Prove the final JSON contains the latest persisted update and `{ version: 7 }`.
- [ ] Prove `persist.clearStorage()` cannot be undone by timers/lifecycle and is inert in the widget role.
- [ ] Update raw-seeded migration tests to reset pending/staged writer state before `rehydrate()`.
- [ ] Prove long-form fields round-trip while `generating` and `audioUrl` remain excluded.
- [ ] Prove v6-to-v7 and older accepted fixtures still hydrate synchronously.
Exit condition: existing store migration tests plus the new scheduling suite pass.
### Task 3: Wire `omni_ui`
- [ ] Extract snapshot construction only if needed for a precise shape test; do not redesign ownership.
- [ ] Add the restore-complete state gate, then queue a latest-value provider rather than serializing in the effect.
- [ ] Add a seeded-restore test proving the initial defaults never become the durable winner.
- [ ] Dispatch lifecycle flush before the post-restore render and prove it writes no defaults.
- [ ] Add a burst test proving the latest text and dub segment data win after one write.
- [ ] Cover StrictMode double effects plus unmount/remount before the quiet timer; no obsolete provider may win.
- [ ] Re-run schema, legacy-mode, and restored-dub-step tests unchanged.
Exit condition: a reload after explicit flush restores a deep-equal latest snapshot through `sanitizeOmniUi`.
### Task 4: Close lifecycle and reset races
- [ ] Configure the exact `detectIsWidget()` result before render, then install main-window lifecycle flushing.
- [ ] Prove marker, Tauri-label-only, and legacy-URL detection; pre-role setters cannot leak from a widget.
- [ ] Prove unknown-role set→remove ends removed, remove→set activates the set, and the 1-second clock starts at main-role activation.
- [ ] Prove duplicate installation does not duplicate listeners and teardown removes the exact callbacks.
- [ ] Prove hidden visibility plus `pagehide` produce at most one serialization/write for an unchanged pending generation.
- [ ] Suspend pending and future preference values before Factory Reset removal.
- [ ] Queue another store update, advance every fake timer, and dispatch lifecycle events after reset; both target keys must remain absent.
- [ ] Prove raw removal and enumeration/access failures resume normal persistence before propagating the error.
- [ ] Prove preserved connection/data keys remain untouched.
- [ ] Prove standalone-widget setters and `persist.clearStorage()` cannot mutate durable state, while main-window operations still work.
Exit condition: neither stale timers, lifecycle events, StrictMode, nor the widget can overwrite newer or deliberately removed durable state.
### Task 5: Verify and document
- [ ] Run targeted tests during iteration.
- [ ] Run frontend typecheck, lint, format check, full Vitest, build, and production-bundle smoke.
- [ ] Run the repository's backend suites offline before landing, despite no backend diff, because they are merge gates.
- [ ] Check in the opt-in Playwright benchmark with deterministic fixture generation and JSON output.
- [ ] Run an alternating parent/implementation/parent (A/B/A) benchmark sequence with five repeats per leg; repeat if same-commit variance exceeds 5%.
- [ ] Attach counts, payload sizes, p50/p95/max interaction latency, and long-task evidence to the PR.
- [ ] Open the draft PR to obtain its number, then add/amend the Unreleased changelog line before requesting review.
Exit condition: deterministic acceptance criteria pass; build/merge gates are green; browser timing is attached as reproducible decision evidence rather than a hardware-sensitive CI gate.
## Required deterministic tests
| Scenario | Required result |
| --- | --- |
| 100 replacements in one burst | 0 synchronous provider/serializer/write calls; 1 trailing write with value 100 |
| Lazy provider source changes before flush | Current-at-flush value is written; no deep clone or serialization occurred while queueing |
| Obsolete provider disposer | Cancels only its generation; it cannot cancel a newer provider for the same key |
| Continuous updates beyond 1 second | A maximum-wait flush occurs; later updates start a new window |
| Identical durable value | Serialization may occur at flush; physical `setItem` is skipped |
| Explicit `getItem` before flush | Latest pending structured value is returned synchronously |
| Hidden document followed by `pagehide` | At most one serialization/write for the unchanged pending generation |
| Cancel/remove followed by all timers | Deleted key stays absent |
| Zustand `persist.clearStorage()` | Pending/staged key is cancelled; timer/lifecycle cannot resurrect it |
| Successful reset followed by a new store update | Matching writes remain suspended and deleted keys stay absent until reload |
| Failed reset removal | Error propagates and write suspension is released |
| Reset enumeration/access failure | Error propagates and write suspension is released |
| Serialization error | Caller does not throw; invalid entry does not poison a later valid update |
| Provider throws | Caller/lifecycle does not crash; invalid entry is discarded and a later valid provider succeeds |
| Durable `getItem` throws | Hydration falls back to defaults without crashing; a later valid queue can persist |
| Malformed durable JSON | Hydration follows the current safe fallback/migration behavior and later persistence repairs it |
| Quota/security error | Caller does not throw; old durable value remains; timers do not retry; one later queue starts one new window |
| Two-key partial failure | Successful key stays clean; failed key alone retries later |
| Unknown staged set→remove / remove→set | Only the final operation activates on `main`; its clocks start at activation |
| Unknown/standalone-widget update and removal | Reads work; no raw mutation before role resolution or after read-only resolution |
| Duplicate lifecycle installation/teardown | One listener set; exact callbacks are removed once |
| Zustand transient burst | At most one `omnivoice.app` write and unchanged v7 envelope |
| Legacy recovery burst | At most one `omni_ui` write with latest text/segments |
| Seeded initial recovery | Defaults never overwrite restored state, including immediate lifecycle and StrictMode/unmount races |
| Factory Reset race | Both pending target keys remain absent after timers and lifecycle events |
| Donation opt-out compatibility | Primary opt-out remains immediately visible; flushed v7 legacy fallback remains readable |
| Repeated warning | One warning per key/operation/error class; no value, serialized payload, or content-bearing error message appears |
Do not use elapsed milliseconds as Vitest pass/fail assertions. Use fake timers and call counts for CI; use browser traces for performance evidence.
## Verification commands
Run targeted tests while iterating:
```powershell
cd frontend
bun run test -- src/utils/coalescedJsonStorage.test.ts src/store/persistenceScheduling.test.ts src/hooks/useAppData.persistence.test.jsx src/main-app.test.jsx src/utils/prefKeys.test.js src/utils/donationMoments.test.js src/test/omniUiSchema.test.js src/test/dubStepRestoreClamp.test.js src/store/uiScaleMigration.test.ts
```
Run the frontend landing gate:
```powershell
cd frontend
bun run typecheck:ci
bun run lint
bun run format:check
bun run test
bun run test:prod-bundle
bun run test:legacy
```
`test:prod-bundle` already performs the production build before its smoke test, so a separate `bun run build` would only duplicate work. Run it separately only when build output is needed during iteration.
Run the backend CI-equivalent suites from the repository root with a genuinely empty Hugging Face cache:
```powershell
$previousOffline = $env:HF_HUB_OFFLINE
$previousCache = $env:HF_HUB_CACHE
$tempRoot = [IO.Path]::GetFullPath([IO.Path]::GetTempPath())
$emptyHfCache = [IO.Path]::GetFullPath((Join-Path $tempRoot ("omnivoice-hf-empty-" + [guid]::NewGuid())))
if (-not $emptyHfCache.StartsWith($tempRoot, [StringComparison]::OrdinalIgnoreCase)) { throw 'Unsafe cache path' }
New-Item -ItemType Directory -Path $emptyHfCache | Out-Null
try {
if (@(Get-ChildItem -LiteralPath $emptyHfCache -Force).Count -ne 0) { throw 'HF cache is not empty' }
$env:HF_HUB_OFFLINE = '1'
$env:HF_HUB_CACHE = $emptyHfCache
uv run --no-sync pytest tests/ -q --tb=short
if ($LASTEXITCODE -ne 0) { throw "tests/ failed with exit code $LASTEXITCODE" }
uv run --no-sync pytest backend/tests/ -q --tb=short
if ($LASTEXITCODE -ne 0) { throw "backend/tests/ failed with exit code $LASTEXITCODE" }
} finally {
$env:HF_HUB_OFFLINE = $previousOffline
$env:HF_HUB_CACHE = $previousCache
Remove-Item -LiteralPath $emptyHfCache -Recurse -Force
}
```
These are repository landing gates, not evidence that the frontend optimization itself works. The unique cache and restored environment prevent a populated developer cache or leaked shell state from masking failures.
## Browser validation protocol
Check in `frontend/e2e-perf/responsiveness.spec.ts` and `frontend/playwright.perf.config.ts` as a non-CI production-bundle benchmark harness. Keeping it outside `e2e-prod/` ensures the existing production-smoke CI command cannot discover this manual benchmark. The config must mirror `playwright.prod.config.ts`: build the real `dist/`, serve it with `vite preview` on a dedicated strict port, honor `PLAYWRIGHT_CHROMIUM`, use `/usr/bin/chromium` only when it exists, and otherwise fall back to Playwright's bundled browser. It must not use the dev-server E2E config.
The spec must generate fixtures from fixed seeds, install all required API/WebSocket route mocks or a deterministic bootstrap bypass before navigation, and make no assumption that a backend is running on port 3900. It must use `page.addInitScript` before application code to wrap target-key storage writes and `PerformanceObserver`, drive selectors rather than arbitrary sleeps, and emit machine-readable JSON under Playwright's `test-results` directory. It asserts final state and observable deterministic write counts, but it does not assert elapsed milliseconds or claim to observe serializer task identity. The injected Vitest scheduler tests own the stronger “no provider/serializer execution in the originating task” assertion.
Run it with:
```powershell
cd frontend
node ./node_modules/@playwright/test/cli.js test --config=playwright.perf.config.ts responsiveness.spec.ts --repeat-each=5 --reporter=line
```
Run `bun install --frozen-lockfile` first. The command above is verified from `frontend/` to resolve the installed Playwright 1.61.0 CLI by exact package path; do not replace it with `bun x playwright` or a global `bun run` shim, which can select another Playwright version, fetch a package, or even resolve a stale Windows shim. If `PLAYWRIGHT_CHROMIUM` is unset and no supported system Chromium exists, install the pinned browser once with `node ./node_modules/@playwright/test/cli.js install chromium`. This adds no project dependency, and the dedicated config provides the cross-platform executable fallback. The config owns port 4174 and never reuses an existing listener, so a stale preview fails loudly and every successful run tears down the exact server it started.
Use the same browser version, build mode, machine power state, and fixture on both commits.
1. Instrument target-key `setItem` count, serialized byte length, and call duration before the app loads.
2. Observe long tasks and event-to-next-`requestAnimationFrame` latency.
3. Seed 1,800 dub segments and 400 story tracks using the current v7/legacy formats.
4. Run 20 UI-scale updates 25 ms apart, keeping the complete burst below the hard maximum.
5. Run 20 Studio text updates under the same cadence.
6. End each burst, wait 1,250 ms, and verify the durable latest values by parsing both keys.
7. Run A/B/A (parent, implementation, parent), five repeats per leg; compare median p95 and retain every JSON artifact.
8. Run the 3,000/3,000 fixture once as a diagnostic stress case, not as a product limit.
Deterministic merge gates:
- A sub-1-second 20-event burst produces no more than one physical write per target key after the burst: at least a 96% reduction from the measured 60 target writes.
- Injected utility/integration tests prove no target-key provider, serialization, or write executes in the originating input task; the browser harness independently verifies observable physical writes.
- Both parsed durable values contain the final interaction's state.
Manual decision thresholds, not CI merge gates:
- Target at least 20% lower median p95 input-to-frame latency on the representative fixture.
- Target no more than 5% median-p95 regression on the small fixture.
- Expect no greater-than-50-ms task during the interaction burst with persistence work in its trace stack.
- If either target is missed or same-commit A/A variance exceeds 5%, treat the timing as inconclusive, attach the raw artifacts, and re-profile. Do not widen this PR merely to manufacture a favorable number.
## Acceptance criteria
The PR is ready for review only when all are true:
- [ ] Existing keys, field sets, JSON envelope, version, migrations, and restore behavior are unchanged.
- [ ] One burst yields at most one trailing write per dirty key and the newest value wins.
- [ ] Normal continuous input schedules an attempt within 1 second; failure and timer-throttling limits are documented accurately.
- [ ] With healthy storage, orderly hide/navigation flushes synchronously and a hard process termination can lose at most the scheduled unflushed window; failure/throttling exceptions are documented.
- [ ] Factory Reset cannot be undone by pending work.
- [ ] Unknown-role work cannot reach raw storage, and the standalone widget cannot write or remove main-window preferences.
- [ ] Storage failures cannot crash input handling and never leak user content to logs.
- [ ] Deterministic tests meet merge gates; the checked-in A/B/A benchmark and raw timing artifacts are attached as non-CI decision evidence.
- [ ] Frontend and backend merge gates pass.
- [ ] No dependency, lockfile, locale, backend, persisted-version, or package-version change is present.
- [ ] The PR remains reviewable as one persistence concern; no opportunistic refactor is included.
## Risks and mitigations
| Risk | Mitigation |
| --- | --- |
| Up to the scheduled window of edits lost on a hard process kill | 250 ms quiet flush, 1,000 ms maximum attempt, hidden/pagehide flush; disclose timer/storage limitations |
| Pending or newly queued write recreates reset data | Suspend by `isPrefKey` before raw removal; post-reset update + timer + lifecycle regression test |
| Widget flushes stale main-window state | Resolve the existing detector before render; unknown cannot write; widget set/remove operations stay read-only |
| Older timer overwrites a newer value | Per-key generation token and last-value-wins tests |
| Mutable data changes before deferred serialization | Lazy current-value provider plus a documented mutation audit; never claim event-time snapshot semantics |
| Quota or disabled storage breaks the UI | Contain write errors, preserve the previous durable blob, disarm timers, retry only on later activity/explicit flush |
| Concurrent browser tabs overwrite each other | Keep the unsupported last-physical-writer boundary explicit; do not add an incomplete conflict protocol here |
| Trailing flush is still expensive for pathological documents | Measure it; do not hide it. Escalate to document storage/worker design in a separate PR if representative flush exceeds the budget |
| Middleware contract accidentally changes | Exact envelope/fixture tests plus existing migration and direct-reader suites |
| Lifecycle listeners duplicate in development/tests | One production owner, isolated test instances, idempotent teardown, and duplicate-install test |
| Timing benchmark flakes in CI | Keep wall-clock evidence informational/manual; gate deterministic operation counts |
## Rollback plan
No data rollback or migration is required. Reverting the adapter wiring restores immediate writes, and both old and new builds read the same `omnivoice.app` v7 envelope and `omni_ui` object. If a release-only issue appears, revert the PR rather than introducing a second persistence mode or format.
## Follow-up queue
These are intentionally not part of the first PR:
1. **Incremental dub scheduling.** Add a 300 ms debounce, pass `AbortController.signal` through `apiPost`, use a monotonic request revision, cancel outside Dub, and prove one request per burst plus stale-response rejection.
2. **Transactional dub undo.** Profile `pushUndo`, which currently stringifies the complete segment array per edit and retains up to 50 snapshots. If material, group edits by segment/field and focus or idle boundary while preserving one-step undo behavior.
3. **Workspace isolation.** Profile React commits after persistence remediation; then extract one workspace at a time, moving heavy hooks/imports behind lazy boundaries. Source length and selector count alone are not success metrics.
4. **Document storage migration.** Consider IndexedDB or a worker only if representative post-PR flushes remain over budget. That work requires an independent migration, downgrade, reset, quota, and async-hydration design.
Each follow-up must begin from a fresh trace. None should be pulled into this PR merely because it is nearby.
@@ -131,7 +131,9 @@ Gallery tab has two zones (top toggle):
- `GET /archetypes/{id}/preview` — serve pre-rendered WAV if present; else render
via the voice-design engine and cache to disk keyed by instruct hash.
- `POST /archetypes/{id}/use` — render a sample → create a `voice_profile`
(rendered WAV as `ref_audio`, archetype `instruct`/`language`) → return profile id.
with `kind='design'` (the rendered WAV is an identity sample; the
archetype `instruct`/`language` and deterministic render seed remain
authoritative) → return profile id.
- Register in `backend/main.py` alongside the other routers.
- **Preview cache**`OUTPUTS_DIR/archetype_previews/<hash>.wav`, served via a new
static mount or `FileResponse`. Pre-rendered featured WAVs live under
+529
View File
@@ -0,0 +1,529 @@
import { expect, test, type Page, type TestInfo } from '@playwright/test';
import { writeFile } from 'node:fs/promises';
const APP_STORE_KEY = 'omnivoice.app';
const OMNI_UI_KEY = 'omni_ui';
const TARGET_KEYS = [APP_STORE_KEY, OMNI_UI_KEY] as const;
const UPDATE_COUNT = 20;
const UPDATE_INTERVAL_MS = 25;
const TRAILING_FLUSH_SETTLE_MS = 1_250;
type TargetKey = (typeof TARGET_KEYS)[number];
interface PhysicalWrite {
phase: string;
key: TargetKey;
atMs: number;
bytes: number;
durationMs: number;
}
interface LongTaskSample {
phase: string;
atMs: number;
durationMs: number;
}
interface InputFrameSample {
phase: string;
target: 'ui-scale' | 'studio-text';
atMs: number;
durationMs: number;
}
interface BrowserMetrics {
phase: string;
writes: PhysicalWrite[];
longTasks: LongTaskSample[];
inputToNextRaf: InputFrameSample[];
}
declare global {
interface Window {
__OV_WINDOW__?: string;
__OMNIVOICE_API_BASE__?: string;
__ovResponsivenessMetrics?: BrowserMetrics;
__ovSetResponsivenessPhase?: (phase: string) => void;
}
}
function makeStoryTracks() {
return Array.from({ length: 400 }, (_, index) => ({
id: index + 1,
character: index % 2 === 0 ? 'narrator' : 'guest',
text: `Story track ${index.toString().padStart(3, '0')} ${'narration '.repeat(8)}`,
profileId: null,
emotion: index % 3 === 0 ? 'warm' : null,
speed: 1,
}));
}
function makeDubSegments() {
return Array.from({ length: 1_800 }, (_, index) => ({
id: `segment-${index.toString().padStart(4, '0')}`,
start: index * 2.5,
end: index * 2.5 + 2.25,
speaker: index % 2 === 0 ? 'SPEAKER_00' : 'SPEAKER_01',
text_original: `Original line ${index} ${'source '.repeat(7)}`,
text: `Translated line ${index} ${'target '.repeat(7)}`,
profile_id: null,
direction: '',
}));
}
function persistedFixtures() {
return {
app: {
state: {
mode: 'settings',
defineMethod: 'audio',
uiScale: 1,
uiScaleConfigured: true,
navStyle: 'rail',
locale: 'en',
localeChosen: true,
langPromptSeen: true,
storyTracks: makeStoryTracks(),
},
version: 7,
},
omniUi: {
uiScale: 1,
text: 'Seeded studio text',
mode: 'settings',
defineMethod: 'audio',
vdStates: {
Gender: 'Auto',
Age: 'Auto',
Pitch: 'Auto',
Style: 'Auto',
EnglishAccent: 'Auto',
ChineseDialect: 'Auto',
},
language: 'Auto',
isSidebarCollapsed: false,
sidebarTab: 'projects',
dubJobId: 'responsiveness-fixture',
dubFilename: 'responsiveness-fixture.mp4',
dubDuration: 4_500,
dubSegments: makeDubSegments(),
dubLang: 'English',
dubLangCode: 'en',
dubTracks: [],
dubStep: 'editing',
dubTranscript: '',
exportTracks: {},
preserveBg: true,
defaultTrack: 'dialogue',
exportHistory: [],
speed: 1,
steps: 16,
cfg: 2,
denoise: true,
showOverrides: false,
},
};
}
async function installDeterministicBrowserState(page: Page): Promise<Set<string>> {
const fixtures = persistedFixtures();
const unexpectedRequests = new Set<string>();
await page.addInitScript(
({ appKey, omniUiKey, app, omniUi }) => {
// Fix window identity and API routing before any application module runs.
window.__OV_WINDOW__ = 'main';
window.__OMNIVOICE_API_BASE__ = window.location.origin;
// Seed through the native method so fixture setup is not counted as an
// application write. Both payloads intentionally match production schema.
const nativeSetItem = Storage.prototype.setItem;
nativeSetItem.call(localStorage, appKey, JSON.stringify(app));
nativeSetItem.call(localStorage, omniUiKey, JSON.stringify(omniUi));
nativeSetItem.call(localStorage, 'omnivoice.settings.category', 'appearance');
const targetKeys = new Set([appKey, omniUiKey]);
const metrics: BrowserMetrics = {
phase: 'startup',
writes: [],
longTasks: [],
inputToNextRaf: [],
};
window.__ovResponsivenessMetrics = metrics;
window.__ovSetResponsivenessPhase = (phase) => {
metrics.phase = phase;
};
Storage.prototype.setItem = function setItem(key: string, value: string): void {
const startedAt = performance.now();
try {
nativeSetItem.call(this, key, value);
} finally {
if (targetKeys.has(key)) {
const durationMs = performance.now() - startedAt;
metrics.writes.push({
phase: metrics.phase,
key: key as TargetKey,
atMs: startedAt,
// Encode after the native call so byte accounting is excluded
// from the measured physical-storage duration.
bytes: new TextEncoder().encode(value).byteLength,
durationMs,
});
}
}
};
document.addEventListener(
'input',
(event) => {
const target = event.target;
if (!(target instanceof HTMLElement)) return;
const sampleTarget = target.matches('.appearance-panel input[type="range"]')
? 'ui-scale'
: target.matches('textarea.studio-script-input')
? 'studio-text'
: null;
if (!sampleTarget) return;
const startedAt = performance.now();
requestAnimationFrame(() => {
metrics.inputToNextRaf.push({
phase: metrics.phase,
target: sampleTarget,
atMs: startedAt,
durationMs: performance.now() - startedAt,
});
});
},
true,
);
if (
'PerformanceObserver' in window &&
PerformanceObserver.supportedEntryTypes?.includes('longtask')
) {
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
metrics.longTasks.push({
phase: metrics.phase,
atMs: entry.startTime,
durationMs: entry.duration,
});
}
});
observer.observe({ type: 'longtask', buffered: true });
}
// Keep the realtime hook deterministic and fully local while preserving
// the handler and EventTarget surfaces used by capture/realtime clients.
class DeterministicWebSocket extends EventTarget {
static readonly CONNECTING = 0;
static readonly OPEN = 1;
static readonly CLOSING = 2;
static readonly CLOSED = 3;
readonly url: string;
readyState = DeterministicWebSocket.CONNECTING;
onopen: ((event: Event) => void) | null = null;
onmessage: ((event: MessageEvent) => void) | null = null;
onerror: ((event: Event) => void) | null = null;
onclose: ((event: CloseEvent) => void) | null = null;
constructor(url: string | URL) {
super();
this.url = String(url);
queueMicrotask(() => {
if (this.readyState !== DeterministicWebSocket.CONNECTING) return;
this.readyState = DeterministicWebSocket.OPEN;
const event = new Event('open');
this.dispatchEvent(event);
this.onopen?.(event);
});
}
send(): void {}
close(): void {
if (this.readyState === DeterministicWebSocket.CLOSED) return;
this.readyState = DeterministicWebSocket.CLOSED;
const event = new CloseEvent('close', { code: 1000, wasClean: true });
this.dispatchEvent(event);
this.onclose?.(event);
}
}
Object.defineProperty(window, 'WebSocket', {
configurable: true,
writable: true,
value: DeterministicWebSocket,
});
},
{
appKey: APP_STORE_KEY,
omniUiKey: OMNI_UI_KEY,
app: fixtures.app,
omniUi: fixtures.omniUi,
},
);
// Production resolves API calls to the preview origin. Fulfil every
// fetch/XHR deterministically, while allowing HTML, chunks, fonts and CSS to
// come from the real production bundle under test.
await page.route('**/*', async (route) => {
const request = route.request();
if (!['fetch', 'xhr'].includes(request.resourceType())) {
await route.continue();
return;
}
const path = new URL(request.url()).pathname;
const responseByPath: Record<string, unknown> = {
'/health': { status: 'ok' },
'/setup/status': {
models_ready: true,
missing: [],
hf_cache_dir: '/deterministic/models',
disk_free_gb: 100,
min_free_gb: 1,
enough_disk: true,
},
'/model/status': { status: 'idle', sub_stage: null, detail: '', error: null, progress: null },
'/profiles': [],
'/personalities': [],
'/history': [],
'/dub/history': [],
'/projects': [],
'/export/history': [],
'/engines': {
tts: { active: null, backends: [] },
asr: { active: null, backends: [] },
llm: { active: null, backends: [] },
},
'/sysinfo': { cpu: 0, ram: 0, total_ram: 32, vram: 0, gpu_active: false },
'/system/info': { platform: 'benchmark', device: 'deterministic' },
'/system/notifications': { notifications: [] },
'/system/last-run-crash': { record: null, acknowledged: false },
'/system/logs': { path: '', exists: false, lines: [] },
'/system/logs/tauri': { path: '', exists: false, lines: [] },
'/system/network/state': { enabled: false },
'/dictation/prefs': {
enabled: false,
mode: 'toggle',
model_id: 'sherpa-parakeet-tdt-v3',
},
'/workers': { enabled: false, running: false, workers: [] },
'/workers/target': {
target: 'local',
active: { remote: false },
targets: [
{ id: 'local', label: 'Local', is_local: true, status: 'ready', available: true },
],
},
'/api/settings/analytics': { available: false, prompted: true, opted_in: false },
'/donation_progress.json': {
raised: 10,
goal: 200,
currency: 'USD',
sponsorCount: 1,
updated: '2026-06-17',
},
};
const responseBody = responseByPath[path];
if (responseBody === undefined) {
unexpectedRequests.add(`${request.method()} ${path}`);
await route.fulfill({
status: 501,
contentType: 'application/json',
headers: { 'x-omnivoice-backend': '1' },
body: JSON.stringify({ detail: 'Unhandled deterministic benchmark route' }),
});
return;
}
await route.fulfill({
status: 200,
contentType: 'application/json',
headers: { 'x-omnivoice-backend': '1' },
body: JSON.stringify(responseBody),
});
});
return unexpectedRequests;
}
async function setPhase(page: Page, phase: string): Promise<void> {
await page.evaluate((nextPhase) => window.__ovSetResponsivenessPhase?.(nextPhase), phase);
}
async function driveNativeInputBurst(
page: Page,
selector: string,
values: string[],
): Promise<void> {
await page.locator(selector).evaluate(
async (node, burst) => {
const element = node as HTMLInputElement | HTMLTextAreaElement;
const prototype =
element instanceof HTMLTextAreaElement
? HTMLTextAreaElement.prototype
: HTMLInputElement.prototype;
const nativeValueSetter = Object.getOwnPropertyDescriptor(prototype, 'value')?.set;
if (!nativeValueSetter) throw new Error(`No native value setter for ${element.tagName}`);
await new Promise<void>((resolve) => {
// Schedule against one common origin. Measuring UI work must not add
// another 25 ms after every handler and accidentally turn a 475 ms
// burst into a >1 s stream that rightfully crosses the max-flush gate.
burst.values.forEach((value, index) => {
setTimeout(() => {
nativeValueSetter.call(element, value);
element.dispatchEvent(new Event('input', { bubbles: true, composed: true }));
if (index === burst.values.length - 1) resolve();
}, index * burst.intervalMs);
});
});
},
{ values, intervalMs: UPDATE_INTERVAL_MS },
);
}
async function readDurableValues(page: Page) {
return page.evaluate(
({ appKey, omniUiKey }) => ({
app: JSON.parse(localStorage.getItem(appKey) || 'null'),
omniUi: JSON.parse(localStorage.getItem(omniUiKey) || 'null'),
}),
{ appKey: APP_STORE_KEY, omniUiKey: OMNI_UI_KEY },
);
}
function writesFor(metrics: BrowserMetrics, phase: string, key: TargetKey): PhysicalWrite[] {
return metrics.writes.filter((write) => write.phase === phase && write.key === key);
}
async function writeReport(testInfo: TestInfo, report: unknown): Promise<void> {
const artifactPath = testInfo.outputPath('responsiveness.json');
await writeFile(artifactPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8');
await testInfo.attach('responsiveness.json', {
path: artifactPath,
contentType: 'application/json',
});
}
test('coalesces large-state persistence during rapid UI input', async ({ page }, testInfo) => {
const unexpectedRequests = await installDeterministicBrowserState(page);
await page.goto('/', { waitUntil: 'domcontentloaded' });
const listenerProbe = await page.evaluate(async () => {
const socket = new WebSocket('ws://benchmark.invalid');
let onceCalls = 0;
let removedCalls = 0;
const removedListener = () => {
removedCalls += 1;
};
socket.addEventListener(
'open',
() => {
onceCalls += 1;
},
{ once: true },
);
socket.addEventListener('open', removedListener);
socket.removeEventListener('open', removedListener);
await Promise.resolve();
socket.dispatchEvent(new Event('open'));
socket.close();
return { onceCalls, removedCalls };
});
expect(listenerProbe).toEqual({ onceCalls: 1, removedCalls: 0 });
const scaleSelector = '.appearance-panel input[type="range"]';
await expect(page.locator(scaleSelector)).toBeVisible();
// Let startup restoration and its trailing persistence window fully settle;
// subsequent records are phase-labelled and attributable to one burst.
await page.waitForTimeout(TRAILING_FLUSH_SETTLE_MS);
const scaleValues = Array.from({ length: UPDATE_COUNT }, (_, index) =>
(0.65 + index * 0.05).toFixed(2),
);
const finalScale = Number(scaleValues.at(-1));
await setPhase(page, 'ui-scale');
await driveNativeInputBurst(page, scaleSelector, scaleValues);
await page.waitForTimeout(TRAILING_FLUSH_SETTLE_MS);
const afterScale = await readDurableValues(page);
expect(afterScale.app?.state?.uiScale).toBe(finalScale);
expect(afterScale.omniUi?.uiScale).toBe(finalScale);
// Navigate through the real production UI. Waiting before phase assignment
// prevents the navigation write from being counted as a text-input write.
await setPhase(page, 'navigation');
await page.locator('.nav-rail button[aria-label="Voice"]').click();
const textSelector = 'textarea.studio-script-input';
await expect(page.locator(textSelector)).toBeVisible();
await page.waitForTimeout(TRAILING_FLUSH_SETTLE_MS);
const textValues = Array.from(
{ length: UPDATE_COUNT },
(_, index) => `responsiveness-${index.toString().padStart(2, '0')}-${'voice '.repeat(8)}`,
);
const finalText = textValues.at(-1);
await setPhase(page, 'studio-text');
await driveNativeInputBurst(page, textSelector, textValues);
await page.waitForTimeout(TRAILING_FLUSH_SETTLE_MS);
const durable = await readDurableValues(page);
const metrics = await page.evaluate(() => window.__ovResponsivenessMetrics as BrowserMetrics);
const report = {
schemaVersion: 1,
fixture: { appStoreVersion: 7, storyTracks: 400, dubSegments: 1_800 },
burst: { updates: UPDATE_COUNT, requestedIntervalMs: UPDATE_INTERVAL_MS },
durable: {
appUiScale: durable.app?.state?.uiScale,
omniUiScale: durable.omniUi?.uiScale,
omniUiText: durable.omniUi?.text,
},
phases: {
uiScale: {
writes: Object.fromEntries(
TARGET_KEYS.map((key) => [key, writesFor(metrics, 'ui-scale', key)]),
),
inputToNextRaf: metrics.inputToNextRaf.filter((sample) => sample.phase === 'ui-scale'),
longTasks: metrics.longTasks.filter((sample) => sample.phase === 'ui-scale'),
},
studioText: {
writes: Object.fromEntries(
TARGET_KEYS.map((key) => [key, writesFor(metrics, 'studio-text', key)]),
),
inputToNextRaf: metrics.inputToNextRaf.filter((sample) => sample.phase === 'studio-text'),
longTasks: metrics.longTasks.filter((sample) => sample.phase === 'studio-text'),
},
},
startup: {
writes: metrics.writes.filter((write) => write.phase === 'startup'),
longTasks: metrics.longTasks.filter((sample) => sample.phase === 'startup'),
},
network: { unexpectedRequests: [...unexpectedRequests].sort() },
};
await writeReport(testInfo, report);
expect(durable.omniUi?.text).toBe(finalText);
expect([...unexpectedRequests].sort(), 'every fetch/XHR must have an explicit fixture').toEqual(
[],
);
expect(metrics.inputToNextRaf.filter((sample) => sample.phase === 'ui-scale')).toHaveLength(
UPDATE_COUNT,
);
expect(metrics.inputToNextRaf.filter((sample) => sample.phase === 'studio-text')).toHaveLength(
UPDATE_COUNT,
);
for (const phase of ['ui-scale', 'studio-text']) {
for (const key of TARGET_KEYS) {
expect(
writesFor(metrics, phase, key).length,
`${phase} should physically write ${key} no more than once`,
).toBeLessThanOrEqual(1);
}
}
});
+47
View File
@@ -0,0 +1,47 @@
import { defineConfig, devices } from '@playwright/test';
import { existsSync } from 'node:fs';
// Opt-in production-bundle responsiveness benchmark. Keep it separate from
// playwright.prod.config.ts: the smoke suite is a CI correctness gate, while
// this harness records machine-dependent timing diagnostics for local review.
const PORT = Number(process.env.E2E_PERF_PORT || 4174);
// An explicit browser wins; Linux CI/dev containers commonly provide a system
// Chromium; contributors on Windows/macOS fall back to Playwright's bundle.
const SYSTEM_CHROMIUM = '/usr/bin/chromium';
const browserPath =
process.env.PLAYWRIGHT_CHROMIUM || (existsSync(SYSTEM_CHROMIUM) ? SYSTEM_CHROMIUM : undefined);
export default defineConfig({
testDir: './e2e-perf',
testMatch: 'responsiveness.spec.ts',
timeout: 120_000,
expect: { timeout: 15_000 },
fullyParallel: false,
// `--repeat-each=5` is a variance sample, not five independent load tests.
// Keep repeats serial so they do not contend with each other or distort the
// input/long-task evidence on high-core development machines.
workers: 1,
retries: 0,
reporter: [['list']],
outputDir: 'test-results/responsiveness',
use: {
baseURL: `http://localhost:${PORT}`,
headless: true,
trace: 'retain-on-failure',
...(browserPath ? { launchOptions: { executablePath: browserPath } } : {}),
},
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
webServer: {
// Playwright launches through the platform shell. Invoke the repo-pinned
// Vite binary directly so Windows does not depend on whichever global Bun
// shim happens to precede the checked-in toolchain on PATH.
command: `node ./node_modules/vite/bin/vite.js build && node ./node_modules/vite/bin/vite.js preview --port ${PORT} --strictPort`,
url: `http://localhost:${PORT}`,
// Always own the production preview used for a measurement. Reusing an
// arbitrary listener can benchmark stale dist bytes and leaves teardown
// ownership ambiguous; a stale 4174 listener should fail loudly instead.
reuseExistingServer: false,
timeout: 180_000,
},
});
+3
View File
@@ -100,3 +100,6 @@ zbus = "5.16"
# Scoped-reset tests build real directory trees to prove the delete guard only
# ever removes paths inside a validated OmniVoice root.
tempfile = "3"
# MockRuntime app for the backend-lifecycle fault-injection harness
# (tests/backend_lifecycle.rs) — feature-unifies onto the main dep.
tauri = { version = "2.11.0", features = ["test"] }
+16
View File
@@ -44,5 +44,21 @@ fn main() {
ensure_sidecar_placeholder("uv");
ensure_sidecar_placeholder("ffmpeg");
ensure_sidecar_placeholder("ffprobe");
// Windows test binaries need the Common-Controls v6 manifest that
// tauri-build embeds into the app binary but cargo gives tests none of:
// without it the loader resolves comctl32 v5 (no TaskDialogIndirect —
// imported by tauri's dialog/tray stack) and every integration-test
// binary dies at load with STATUS_ENTRYPOINT_NOT_FOUND (0xc0000139).
// See tests/windows-test.manifest.
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows") {
let manifest = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| ".".into()))
.join("tests")
.join("windows-test.manifest");
println!("cargo:rerun-if-changed={}", manifest.display());
println!("cargo:rustc-link-arg-tests=/MANIFEST:EMBED");
println!("cargo:rustc-link-arg-tests=/MANIFESTINPUT:{}", manifest.display());
}
tauri_build::build();
}
+240 -29
View File
@@ -100,6 +100,52 @@ pub fn backend_deep_healthy(port: u16) -> bool {
}
}
/// Readiness = identity AND capability. The shallow probe proves the
/// responder is OUR backend; the deep probe proves it can actually serve a
/// DB-backed route. Declaring Ready on the shallow probe alone announced a
/// backend whose install/DB was broken underneath as up — the UI looked
/// alive while every real request 500'd or dead-ended on "can't reach the
/// backend". Both Ready transitions (startup poll, supervisor respawn wait)
/// gate on this; the supervisor's DEATH detection stays process-exit-only
/// (`try_wait`), so a busy-but-alive backend is still never killed.
pub fn backend_ready(port: u16) -> bool {
backend_healthy(port) && backend_deep_healthy(port)
}
/// Startup progress from the backend's early-bind `/startup/progress`
/// endpoint: `(status, step, label)`, e.g. `("starting", "ml_imports",
/// "Loading ML runtime (PyTorch)…")`. `None` when nothing answers, when the
/// responder lacks the `x-omnivoice-backend` marker header (a foreign
/// process on our port must not narrate our splash), or on an old backend
/// without the endpoint — callers fall back to the legacy probes.
pub fn startup_progress(port: u16) -> Option<(String, String, String)> {
let url = format!("http://127.0.0.1:{}/startup/progress", port);
let resp = raw_http_get(&url, Duration::from_millis(800)).ok()?;
if parse_http_status(&resp) != Some(200) {
return None;
}
let head_end = resp.find("\r\n\r\n").unwrap_or(resp.len());
if !resp[..head_end].to_ascii_lowercase().contains("x-omnivoice-backend") {
return None;
}
let body = &resp[resp.find("\r\n\r\n").map(|i| i + 4).unwrap_or(0)..];
let status = parse_json_string_field(body, "status")?;
let step = parse_json_string_field(body, "step").unwrap_or_default();
let label = parse_json_string_field(body, "label").unwrap_or_default();
Some((status, step, label))
}
/// First `"key": "value"` string field in a JSON body — same dependency-free
/// sniffing style as `parse_app_version`. `None` for absent or non-string
/// (e.g. `null`) values.
fn parse_json_string_field(body: &str, key: &str) -> Option<String> {
let needle = format!("\"{key}\"");
let rest = &body[body.find(&needle)? + needle.len()..];
let rest = rest[rest.find(':')? + 1..].trim_start();
let rest = rest.strip_prefix('"')?;
Some(rest[..rest.find('"')?].to_string())
}
/// Status code from a raw HTTP response ("HTTP/1.1 200 OK" → 200).
fn parse_http_status(response: &str) -> Option<u16> {
let line = response.lines().next()?;
@@ -243,6 +289,16 @@ pub fn kill_orphan_on_port(port: u16) {
// ── Log paths ─────────────────────────────────────────────────────────────
pub fn backend_log_path() -> PathBuf {
// Support/test override: point logs (and the crash-marker store, which
// derives from this path) somewhere explicit. The fault-injection
// harness gives every scenario its own tempdir through this.
if let Ok(dir) = std::env::var("OMNIVOICE_LOG_DIR") {
if !dir.trim().is_empty() {
let log_dir = PathBuf::from(dir);
let _ = fs::create_dir_all(&log_dir);
return log_dir.join("backend.log");
}
}
let log_dir = if cfg!(target_os = "macos") {
let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
PathBuf::from(home).join("Library/Logs/OmniVoice")
@@ -471,6 +527,29 @@ fn analytics_env(baked_token: Option<&str>, baked_host: Option<&str>) -> Vec<(St
out
}
/// Parse the `OMNIVOICE_BACKEND_CMD` override: a JSON array (`["prog","a"]`)
/// when it starts with `[` — the form the harness uses, so paths with spaces
/// survive — else whitespace-split. `None` for unset/empty/unparseable.
pub fn parse_backend_cmd_override(raw: &str) -> Option<Vec<String>> {
let raw = raw.trim();
if raw.is_empty() {
return None;
}
let argv: Vec<String> = if raw.starts_with('[') {
serde_json::from_str(raw).ok()?
} else {
raw.split_whitespace().map(str::to_string).collect()
};
if argv.is_empty() || argv[0].trim().is_empty() {
return None;
}
Some(argv)
}
fn backend_cmd_override() -> Option<Vec<String>> {
parse_backend_cmd_override(&std::env::var("OMNIVOICE_BACKEND_CMD").ok()?)
}
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");
@@ -480,12 +559,22 @@ pub fn spawn_backend<R: tauri::Runtime>(app: &tauri::AppHandle<R>, progress: Opt
err_path.display(),
);
let (python, backend_dir) = match ensure_venv_ready(app, progress) {
Some(x) => x,
None => {
log::error!("Venv bootstrap failed — backend not started");
return None;
}
// Fault-injection / QA seam: OMNIVOICE_BACKEND_CMD runs the given argv
// as "the backend". Venv bootstrap and ffmpeg resolution are skipped
// (they can install toolchains or touch the network); everything else —
// the err-log run offset, the drainer threads, env pinning, real OS
// pipes, the spawn-failure diagnostic — stays exactly real, which is
// the point: the lifecycle harness exercises genuine process deaths.
let cmd_override = backend_cmd_override();
let (python, backend_dir) = match cmd_override {
Some(ref argv) => (PathBuf::from(&argv[0]), PathBuf::new()),
None => match ensure_venv_ready(app, progress) {
Some(x) => x,
None => {
log::error!("Venv bootstrap failed — backend not started");
return None;
}
},
};
if let Some(p) = progress {
@@ -564,18 +653,20 @@ pub fn spawn_backend<R: tauri::Runtime>(app: &tauri::AppHandle<R>, progress: Opt
}
// 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()));
}
if let Some(ffprobe_path) = resolve_ffprobe(app, &app_data) {
let ffprobe_str: String = ffprobe_path.to_string_lossy().into();
env.push(("FFPROBE_PATH".into(), ffprobe_str.clone()));
// Issue #76: OMNIVOICE_FFPROBE_PATH is the canonical name going
// forward — explicit, namespaced, and unambiguously the path of a
// file (not a PATH-style command name). FFPROBE_PATH stays for
// backward compat with prior backend releases.
env.push(("OMNIVOICE_FFPROBE_PATH".into(), ffprobe_str));
if cmd_override.is_none() {
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()));
}
if let Some(ffprobe_path) = resolve_ffprobe(app, &app_data) {
let ffprobe_str: String = ffprobe_path.to_string_lossy().into();
env.push(("FFPROBE_PATH".into(), ffprobe_str.clone()));
// Issue #76: OMNIVOICE_FFPROBE_PATH is the canonical name going
// forward — explicit, namespaced, and unambiguously the path of a
// file (not a PATH-style command name). FFPROBE_PATH stays for
// backward compat with prior backend releases.
env.push(("OMNIVOICE_FFPROBE_PATH".into(), ffprobe_str));
}
}
let mut cmd = Command::new(&python);
cmd.env_remove("PYTHONHOME").env_remove("PYTHONPATH").env_remove("LD_LIBRARY_PATH");
@@ -594,18 +685,25 @@ pub fn spawn_backend<R: tauri::Runtime>(app: &tauri::AppHandle<R>, progress: Opt
// nvidia-smi probe already uses (setup.rs).
cmd.creation_flags(0x0800_0000 | 0x0000_0200);
}
match cmd_override {
Some(ref argv) => {
cmd.args(&argv[1..]);
}
None => {
cmd.args([
"-m",
"uvicorn",
"main:app",
"--app-dir",
backend_dir.to_string_lossy().as_ref(),
"--host",
"127.0.0.1",
"--port",
&backend_port().to_string(),
]);
}
}
let mut child = match cmd
.args([
"-m",
"uvicorn",
"main:app",
"--app-dir",
backend_dir.to_string_lossy().as_ref(),
"--host",
"127.0.0.1",
"--port",
&backend_port().to_string(),
])
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
@@ -742,6 +840,119 @@ mod tests {
std::env::remove_var("OMNIVOICE_INSTALL_CHANNEL");
}
/// Loopback responder for the /startup/progress probe tests.
fn spawn_progress_stub(with_marker: bool, body: &'static str) -> u16 {
use std::io::{Read, Write};
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
let port = listener.local_addr().unwrap().port();
std::thread::spawn(move || {
for stream in listener.incoming() {
let Ok(mut stream) = stream else { break };
let mut buf = [0u8; 512];
let _ = stream.read(&mut buf);
let marker = if with_marker {
"x-omnivoice-backend: 0.0.0\r\n"
} else {
""
};
let resp = format!(
"HTTP/1.1 200 OK\r\n{marker}Content-Length: {}\r\n\r\n{body}",
body.len()
);
let _ = stream.write_all(resp.as_bytes());
}
});
port
}
/// Loopback HTTP responder for the probe tests: answers `/system/info`
/// with a genuine-looking backend body and `/profiles` with the given
/// status — the exact shape of a zombie whose install/DB broke while
/// `/system/info` kept answering from memory.
fn spawn_probe_stub(profiles_status: u16) -> u16 {
use std::io::{Read, Write};
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
let port = listener.local_addr().unwrap().port();
std::thread::spawn(move || {
for stream in listener.incoming() {
let Ok(mut stream) = stream else { break };
let mut buf = [0u8; 512];
let n = stream.read(&mut buf).unwrap_or(0);
let req = String::from_utf8_lossy(&buf[..n]);
let resp = if req.starts_with("GET /system/info") {
"HTTP/1.1 200 OK\r\nContent-Length: 19\r\n\r\n{\"data_dir\": \"/x\"}\n".to_string()
} else {
format!("HTTP/1.1 {profiles_status} X\r\nContent-Length: 2\r\n\r\n[]")
};
let _ = stream.write_all(resp.as_bytes());
}
});
port
}
#[test]
fn backend_cmd_override_parses_json_and_whitespace_forms() {
// JSON form (the harness's): paths with spaces survive.
assert_eq!(
parse_backend_cmd_override(r#"["/tmp/my dir/prog", "arg1"]"#),
Some(vec!["/tmp/my dir/prog".into(), "arg1".into()])
);
// Whitespace form (manual QA): OMNIVOICE_BACKEND_CMD="/bin/false x".
assert_eq!(
parse_backend_cmd_override("/bin/false x"),
Some(vec!["/bin/false".into(), "x".into()])
);
// Unset/empty/garbage never activates the seam — production behavior
// is byte-identical without the env var.
assert_eq!(parse_backend_cmd_override(""), None);
assert_eq!(parse_backend_cmd_override(" "), None);
assert_eq!(parse_backend_cmd_override("[not json"), None);
assert_eq!(parse_backend_cmd_override("[]"), None);
assert_eq!(parse_backend_cmd_override(r#"[""]"#), None);
}
#[test]
fn startup_progress_parses_fields_and_requires_the_marker() {
const BODY: &str =
r#"{"status": "starting", "step": "ml_imports", "label": "Loading ML runtime (PyTorch)…", "error": null}"#;
// Marker present → the tuple the poll loops narrate from.
let port = spawn_progress_stub(true, BODY);
assert_eq!(
startup_progress(port),
Some((
"starting".into(),
"ml_imports".into(),
"Loading ML runtime (PyTorch)…".into()
))
);
// No marker header → a foreign responder must not narrate our splash.
let foreign = spawn_progress_stub(false, BODY);
assert_eq!(startup_progress(foreign), None);
// Ready body with null step/label → status still parses, step empty.
let ready = spawn_progress_stub(true, r#"{"status": "ready", "step": null, "label": null}"#);
assert_eq!(startup_progress(ready), Some(("ready".into(), String::new(), String::new())));
// Nothing listening → None (old backend / dead port fall back).
assert_eq!(startup_progress(1), None);
}
#[test]
fn ready_requires_the_deep_probe_not_just_identity() {
// Regression for the shallow-Ready class: a backend that identifies
// itself on /system/info but 500s a DB-backed route must NOT be
// announced Ready — that zombie looked alive while every real
// request dead-ended on "can't reach the backend".
let broken = spawn_probe_stub(500);
assert!(backend_healthy(broken), "identity probe should pass");
assert!(!backend_deep_healthy(broken), "deep probe must fail on 500");
assert!(!backend_ready(broken), "Ready must gate on the deep probe");
let ok = spawn_probe_stub(200);
assert!(backend_ready(ok), "identity + working DB route is Ready");
// Nothing listening at all: no probe passes.
assert!(!backend_ready(1)); // port 1 — never bindable by us
}
#[test]
fn spawn_failure_diagnostic_surfaces_path_error_and_hint() {
let err = io::Error::new(io::ErrorKind::NotFound, "No such file or directory");
+185 -12
View File
@@ -293,14 +293,20 @@ pub fn respawn_backend(
/// the venv — is removed and the bootstrap re-runs once, recreating it through
/// the normal `CreatingVenv` / `InstallingDeps` setup path instead of
/// surfacing the same dead-end failure on every retry.
pub fn spawn_backend_and_wait(app: &tauri::AppHandle, stage_handle: &Arc<Mutex<BootstrapStage>>) {
pub fn spawn_backend_and_wait<R: tauri::Runtime>(app: &tauri::AppHandle<R>, stage_handle: &Arc<Mutex<BootstrapStage>>) {
let mut venv_heal_attempted = false;
'bootstrap: loop {
let child = crate::backend::spawn_backend(app, Some(stage_handle));
track_backend_child(app, child);
let start = std::time::Instant::now();
while start.elapsed() < Duration::from_secs(300) {
if crate::backend::backend_healthy(backend_port()) {
// Early-bind narration: the backend answers /startup/progress within
// ~1s of spawn, long before it is Ready — surface each step change
// as a log line so the splash shows "Loading ML runtime (PyTorch)…"
// instead of a silent 300s wait. An old backend (no endpoint) yields
// None and the wait looks exactly as it did before.
let mut last_step = String::new();
while start.elapsed() < startup_budget() {
if crate::backend::backend_ready(backend_port()) {
set_stage(stage_handle, BootstrapStage::Ready);
// #567/#570/#571: once Ready, keep watching the backend child
// and respawn it if it dies mid-session, so a crash self-heals
@@ -441,13 +447,25 @@ pub fn spawn_backend_and_wait(app: &tauri::AppHandle, stage_handle: &Arc<Mutex<B
set_stage(stage_handle, BootstrapStage::Failed { message: msg });
return;
}
if let Some((status, step, label)) =
crate::backend::startup_progress(backend_port())
{
if status == "starting" && !step.is_empty() && step != last_step {
last_step = step;
emit_log(app, "starting_backend", &format!("Startup: {label}"));
}
}
std::thread::sleep(Duration::from_millis(500));
}
let err_tail = crate::backend::read_error_log_tail_for_run(20);
let msg = if err_tail.is_empty() {
"Backend did not respond within 300 s".to_string()
format!("Backend did not respond within {} s", startup_budget().as_secs())
} else {
format!("Backend did not respond within 300 s. Last stderr output:\n{}", err_tail)
format!(
"Backend did not respond within {} s. Last stderr output:\n{}",
startup_budget().as_secs(),
err_tail
)
};
set_stage(stage_handle, BootstrapStage::Failed { message: msg });
return;
@@ -476,6 +494,15 @@ static SUPERVISOR_ACTIVE: AtomicBool = AtomicBool::new(false);
/// moment a fresh child is spawned and tracked (`track_backend_child`).
static BACKEND_KILL_INTENDED: AtomicBool = AtomicBool::new(false);
/// Bumped every time `track_backend_child` installs a new child. The
/// supervisor snapshots it when it observes a death; a change during its
/// backoff pause means ANOTHER flow (Retry / Clean & Retry) spawned and
/// tracked a replacement — ownership has transferred, whether or not that
/// replacement is still alive when sampled (the flag and a liveness check
/// can both be missed inside one 500ms window; the generation cannot).
static BACKEND_SPAWN_GENERATION: std::sync::atomic::AtomicU64 =
std::sync::atomic::AtomicU64::new(0);
pub fn set_backend_kill_intended(value: bool) {
BACKEND_KILL_INTENDED.store(value, Ordering::SeqCst);
}
@@ -499,7 +526,7 @@ const CRASH_STDERR_TAIL_LINES: usize = 40;
const MAX_RESTARTS: usize = 3;
const RESTART_WINDOW: Duration = Duration::from_secs(600);
fn app_is_quitting(app: &tauri::AppHandle) -> bool {
fn app_is_quitting<R: tauri::Runtime>(app: &tauri::AppHandle<R>) -> bool {
app.try_state::<AppFlags>()
.map(|f| f.quitting.load(Ordering::SeqCst))
.unwrap_or(false)
@@ -508,7 +535,7 @@ fn app_is_quitting(app: &tauri::AppHandle) -> bool {
/// Store the freshly spawned backend child (and its spawn time, for the crash
/// marker's `uptime_s`), and re-arm the death watchers: any deliberate-kill
/// window ends the moment a new child is tracked.
fn track_backend_child(app: &tauri::AppHandle, child: Option<std::process::Child>) {
fn track_backend_child<R: tauri::Runtime>(app: &tauri::AppHandle<R>, child: Option<std::process::Child>) {
let state = app.state::<BackendState>();
if let Ok(mut guard) = state.process.lock() {
*guard = child;
@@ -516,11 +543,12 @@ fn track_backend_child(app: &tauri::AppHandle, child: Option<std::process::Child
if let Ok(mut spawned) = state.spawned_at.lock() {
*spawned = Some(Instant::now());
}
BACKEND_SPAWN_GENERATION.fetch_add(1, Ordering::SeqCst);
set_backend_kill_intended(false);
}
/// Seconds since the tracked backend child was spawned (0 when unknown).
fn backend_uptime_s(app: &tauri::AppHandle) -> u64 {
fn backend_uptime_s<R: tauri::Runtime>(app: &tauri::AppHandle<R>) -> u64 {
app.try_state::<BackendState>()
.and_then(|s| s.spawned_at.lock().ok().and_then(|g| *g))
.map(|t| t.elapsed().as_secs())
@@ -530,7 +558,7 @@ fn backend_uptime_s(app: &tauri::AppHandle) -> u64 {
/// Returns `Some(BackendExit)` if the tracked backend child has exited,
/// `None` if it is still running (or none is tracked — which we never treat as
/// a death to respawn, to avoid fighting a deliberate teardown).
fn backend_child_exit(app: &tauri::AppHandle) -> Option<BackendExit> {
fn backend_child_exit<R: tauri::Runtime>(app: &tauri::AppHandle<R>) -> Option<BackendExit> {
let state = app.try_state::<BackendState>()?;
let mut guard = state.process.lock().ok()?;
match guard.as_mut() {
@@ -543,6 +571,30 @@ fn backend_child_exit(app: &tauri::AppHandle) -> Option<BackendExit> {
}
}
/// How long the launch poll waits for the backend to become Ready before
/// declaring Failed. 300s in production; `OMNIVOICE_STARTUP_BUDGET_S`
/// exists for the fault-injection harness (a slow-start scenario must not
/// sleep five minutes in CI) and for support triage on pathological disks.
fn startup_budget() -> Duration {
std::env::var("OMNIVOICE_STARTUP_BUDGET_S")
.ok()
.and_then(|v| v.trim().parse::<u64>().ok())
.filter(|&s| s > 0)
.map(Duration::from_secs)
.unwrap_or(Duration::from_secs(300))
}
/// The supervisor's death-detection poll interval. 2s in production;
/// `OMNIVOICE_SUPERVISOR_POLL_MS` shrinks it for the harness only.
fn supervisor_poll() -> Duration {
std::env::var("OMNIVOICE_SUPERVISOR_POLL_MS")
.ok()
.and_then(|v| v.trim().parse::<u64>().ok())
.filter(|&ms| ms > 0)
.map(Duration::from_millis)
.unwrap_or(Duration::from_secs(2))
}
/// Drop restart timestamps older than `RESTART_WINDOW` and report whether the
/// remaining count has hit the cap. Pure so the backoff policy is unit-tested
/// without spawning real processes.
@@ -551,19 +603,40 @@ fn restart_budget_exhausted(times: &mut Vec<Instant>, now: Instant) -> bool {
times.len() >= MAX_RESTARTS
}
/// Escalating pause before a respawn, keyed on how many restarts already
/// happened inside `RESTART_WINDOW`. The FIRST respawn stays immediate (a
/// one-off crash should self-heal fast); repeat deaths get breathing room so
/// a tight crash loop doesn't burn the whole 3-in-600s budget in seconds —
/// back-to-back torch-import storms are exactly what pushes a
/// memory-pressured machine over the edge again. Pure for unit testing.
fn restart_backoff_delay(recent_restarts: usize) -> Duration {
match recent_restarts {
0 => Duration::ZERO,
1 => Duration::from_secs(5),
_ => Duration::from_secs(15),
}
}
/// After the backend is Ready, watch its process and respawn it on an
/// unexpected exit. Runs on the (otherwise-returning) bootstrap thread and
/// stops the instant the app is quitting so it never resurrects the backend
/// during shutdown. Death is detected only via a *confirmed process exit*
/// (`try_wait`), never a slow health probe, so a busy-but-alive backend is
/// never killed.
fn supervise_backend(app: &tauri::AppHandle, stage_handle: &Arc<Mutex<BootstrapStage>>) {
fn supervise_backend<R: tauri::Runtime>(app: &tauri::AppHandle<R>, stage_handle: &Arc<Mutex<BootstrapStage>>) {
let mut restart_times: Vec<Instant> = Vec::new();
loop {
std::thread::sleep(Duration::from_secs(2));
std::thread::sleep(supervisor_poll());
if app_is_quitting(app) {
return;
}
// Snapshot the spawn generation BEFORE observing the exit: sampled
// after, a replacement tracked in the gap between `try_wait` and the
// load would be baked into the snapshot and the transfer missed
// (third-pass review find). Sampled before, any tracking that
// happens from here on — even one whose child we are about to see
// exit — reads as a generation change and yields.
let observed_generation = BACKEND_SPAWN_GENERATION.load(Ordering::SeqCst);
let exit = match backend_child_exit(app) {
Some(exit) => exit,
None => continue, // still running
@@ -604,6 +677,10 @@ fn supervise_backend(app: &tauri::AppHandle, stage_handle: &Arc<Mutex<BootstrapS
set_stage(stage_handle, BootstrapStage::Failed { message: msg });
return;
}
// Backoff BEFORE this restart is recorded: `restart_times` was just
// pruned to the window, so its length is the number of recent
// respawns already attempted.
let backoff = restart_backoff_delay(restart_times.len());
restart_times.push(Instant::now());
log::warn!("Backend process exited unexpectedly ({exit_info}) — restarting it (#567)");
emit_log(app, "starting_backend", "Backend stopped unexpectedly — restarting it automatically");
@@ -611,6 +688,51 @@ fn supervise_backend(app: &tauri::AppHandle, stage_handle: &Arc<Mutex<BootstrapS
// poll has already stopped post-Ready, so the stage alone won't show).
let _ = app.emit("backend-restarting", exit_info.clone());
set_stage(stage_handle, BootstrapStage::StartingBackend);
// The banner is already up, so the pause reads as "reconnecting", not
// as a hang. Chunked so quitting (or a deliberate retry-flow kill,
// which owns the respawn) is honored within 500 ms.
if !backoff.is_zero() {
log::info!(
"Backend died {} time(s) in the last {} min — waiting {}s before respawning",
restart_times.len(),
RESTART_WINDOW.as_secs() / 60,
backoff.as_secs()
);
let waited = Instant::now();
while waited.elapsed() < backoff {
if app_is_quitting(app) {
return;
}
if backend_kill_intended() {
log::info!("Deliberate replace during restart backoff — supervisor yielding");
return;
}
// A completed Retry/Clean&Retry sets the deliberate-kill flag
// and then `track_backend_child` CLEARS it — possibly both
// between two of these samples, so the flag alone can be
// missed. The durable tell is the spawn GENERATION: it bumps
// when a replacement is tracked and never un-bumps, so it is
// observed even if the replacement has itself already exited
// by the time we sample. Yield promptly (not at backoff end)
// so the retry's own spawn_backend_and_wait can claim the
// supervisor slot at Ready — and so we never free_port() a
// replacement out from under the flow that owns it.
if BACKEND_SPAWN_GENERATION.load(Ordering::SeqCst) != observed_generation {
log::info!(
"A replacement backend was tracked during restart backoff — supervisor yielding"
);
return;
}
std::thread::sleep(Duration::from_millis(500));
}
}
// Last look before touching the port — covers the zero-backoff first
// respawn (which never enters the pause loop) and the tail of the
// pause itself. After this point we own the respawn.
if BACKEND_SPAWN_GENERATION.load(Ordering::SeqCst) != observed_generation {
log::info!("A replacement backend was tracked — supervisor yielding to its flow");
return;
}
// Clear any orphan still holding the port before the respawn. #1223:
// if it can't be cleared, respawning just reproduces the bind failure
// — stop and say so rather than burning a restart attempt.
@@ -642,11 +764,12 @@ fn supervise_backend(app: &tauri::AppHandle, stage_handle: &Arc<Mutex<BootstrapS
// Wait (bounded) for the respawn to become healthy. If it dies again
// immediately, bail early so the next loop counts it toward the cap.
let start = Instant::now();
let mut last_step = String::new();
while start.elapsed() < Duration::from_secs(120) {
if app_is_quitting(app) {
return;
}
if crate::backend::backend_healthy(backend_port()) {
if crate::backend::backend_ready(backend_port()) {
set_stage(stage_handle, BootstrapStage::Ready);
let _ = app.emit("backend-restored", ());
log::info!("Backend restarted and healthy again");
@@ -655,6 +778,16 @@ fn supervise_backend(app: &tauri::AppHandle, stage_handle: &Arc<Mutex<BootstrapS
if backend_child_exit(app).is_some() {
break;
}
// Same early-bind narration as the launch poll: name the startup
// step in the reconnecting window instead of a silent wait.
if let Some((status, step, label)) =
crate::backend::startup_progress(backend_port())
{
if status == "starting" && !step.is_empty() && step != last_step {
last_step = step;
emit_log(app, "starting_backend", &format!("Startup: {label}"));
}
}
std::thread::sleep(Duration::from_millis(500));
}
}
@@ -2024,6 +2157,46 @@ mod tests {
assert!(aged.is_empty(), "stale timestamps should have been dropped");
}
/// Env-mutating tests in THIS module serialize on their own lock (cargo
/// runs tests in threads; the harness binary has its own).
static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
#[test]
fn timing_overrides_default_to_production_values() {
// The env overrides exist for the fault-injection harness only —
// production timing must not drift when they are unset.
let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
std::env::remove_var("OMNIVOICE_STARTUP_BUDGET_S");
std::env::remove_var("OMNIVOICE_SUPERVISOR_POLL_MS");
assert_eq!(startup_budget(), Duration::from_secs(300));
assert_eq!(supervisor_poll(), Duration::from_secs(2));
// Zero/garbage never yields a degenerate loop.
std::env::set_var("OMNIVOICE_STARTUP_BUDGET_S", "0");
std::env::set_var("OMNIVOICE_SUPERVISOR_POLL_MS", "abc");
assert_eq!(startup_budget(), Duration::from_secs(300));
assert_eq!(supervisor_poll(), Duration::from_secs(2));
std::env::set_var("OMNIVOICE_STARTUP_BUDGET_S", "6");
assert_eq!(startup_budget(), Duration::from_secs(6));
std::env::remove_var("OMNIVOICE_STARTUP_BUDGET_S");
std::env::remove_var("OMNIVOICE_SUPERVISOR_POLL_MS");
}
#[test]
fn restart_backoff_escalates_but_first_respawn_is_immediate() {
// A one-off crash self-heals with zero added latency; repeat deaths
// inside the window get an escalating pause so a tight crash loop
// can't burn the whole 3-in-600s budget in seconds.
assert_eq!(restart_backoff_delay(0), Duration::ZERO);
assert_eq!(restart_backoff_delay(1), Duration::from_secs(5));
assert_eq!(restart_backoff_delay(2), Duration::from_secs(15));
// Monotonic, and capped rather than unbounded — the budget check is
// what ends a hopeless loop, not an ever-growing sleep.
assert_eq!(restart_backoff_delay(50), Duration::from_secs(15));
for n in 0..10 {
assert!(restart_backoff_delay(n) <= restart_backoff_delay(n + 1));
}
}
#[test]
fn torch_download_failure_is_detected_for_targeted_help() {
// #569: the cu128 torch wheel host (and a torch-named download/fetch
@@ -0,0 +1,549 @@
//! Backend-lifecycle fault-injection harness.
//!
//! Runs `spawn_backend_and_wait` / `supervise_backend` against REAL dying
//! child processes (via the `OMNIVOICE_BACKEND_CMD` seam) and asserts the
//! user receives the CORRECT NAMED DIAGNOSIS — not merely that recovery
//! happened. Diagnosis quality is the bar: 61% of the historical "can't
//! reach the backend" class was closed undiagnosed.
//!
//! The scenario "backend" is this test binary re-invoking itself
//! (`scenario_child`), so exit codes, Unix signals, and pipe-close ordering
//! are the genuine OS articles on all three platforms — no system python,
//! no mocks of the behaviors under test.
//!
//! Every test mutates process-global state (env vars, the crash store, the
//! kill-intended flag), so they hold one mutex AND CI runs this binary with
//! `--test-threads=1`.
use std::io::{Read, Write};
use std::sync::atomic::AtomicBool;
use std::sync::{Arc, Mutex, MutexGuard};
use std::time::{Duration, Instant};
use tauri::Listener;
use tauri::Manager;
use app_lib::bootstrap::{
spawn_backend_and_wait, BootstrapStage, BootstrapState, LogPayload,
set_backend_kill_intended,
};
use app_lib::{AppFlags, BackendState, CaptureDispatchState};
static HARNESS: Mutex<()> = Mutex::new(());
// ── Scenario child ────────────────────────────────────────────────────────
/// Not a real test: when `OMNIVOICE_SCENARIO` is set, this plays the backend
/// — optionally serving minimal HTTP on `OMNIVOICE_PORT`, printing a stderr
/// script, then dying the scripted death. A no-op in a normal test pass.
#[test]
fn scenario_child() {
// The gate value is the PID of the process that ARMED the scenario (the
// parent harness). The parent's own libtest also runs this test — in a
// parallel local `cargo test` it could observe the armed env and start
// fault-injecting itself (binding the port, idling 600s). Only a
// DIFFERENT process — the spawned child — may play the backend.
match std::env::var("OMNIVOICE_SCENARIO") {
Ok(v) if v.parse::<u32>() == Ok(std::process::id()) => return, // the parent itself
Ok(_) => {}
Err(_) => return,
}
let get = |k: &str| std::env::var(k).unwrap_or_default();
let get_ms = |k: &str| get(k).parse::<u64>().ok();
if let Some(delay) = get_ms("OMNIVOICE_SCENARIO_START_DELAY_MS") {
std::thread::sleep(Duration::from_millis(delay));
}
// Serve /system/info + /profiles (the two probes behind backend_ready)
// and /startup/progress (marker-stamped) for the given window; 0 = serve
// forever.
if let Some(serve_ms) = get_ms("OMNIVOICE_SCENARIO_SERVE_MS") {
let port: u16 = get("OMNIVOICE_PORT").parse().expect("OMNIVOICE_PORT");
let progress_only = get("OMNIVOICE_SCENARIO_PROGRESS_ONLY") == "1";
let listener = std::net::TcpListener::bind(("127.0.0.1", port)).expect("bind scenario port");
listener.set_nonblocking(true).unwrap();
let deadline = if serve_ms == 0 {
None
} else {
Some(Instant::now() + Duration::from_millis(serve_ms))
};
loop {
if let Some(d) = deadline {
if Instant::now() >= d {
break;
}
}
match listener.accept() {
Ok((mut stream, _)) => {
let mut buf = [0u8; 512];
let _ = stream.set_read_timeout(Some(Duration::from_millis(200)));
let n = stream.read(&mut buf).unwrap_or(0);
let req = String::from_utf8_lossy(&buf[..n]);
let resp = if req.starts_with("GET /startup/progress") {
let body = r#"{"status": "starting", "step": "ml_imports", "label": "Loading ML runtime (PyTorch)_"}"#;
format!(
"HTTP/1.1 200 OK\r\nx-omnivoice-backend: 0.0.0\r\nContent-Length: {}\r\n\r\n{}",
body.len(), body
)
} else if progress_only {
"HTTP/1.1 503 X\r\nContent-Length: 0\r\n\r\n".to_string()
} else if req.starts_with("GET /system/info") {
let body = r#"{"data_dir": "/x", "app_version": "0.0.0"}"#;
format!("HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n{}", body.len(), body)
} else {
"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\n[]".to_string()
};
let _ = stream.write_all(resp.as_bytes());
}
Err(_) => std::thread::sleep(Duration::from_millis(20)),
}
}
}
let stderr_script = get("OMNIVOICE_SCENARIO_STDERR");
if !stderr_script.is_empty() {
// \n-encoded so a multi-line traceback fits in one env var.
eprintln!("{}", stderr_script.replace("\\n", "\n"));
let _ = std::io::stderr().flush();
// Let the shell's drainer thread pull the pipe before death.
std::thread::sleep(Duration::from_millis(150));
}
#[cfg(unix)]
if get("OMNIVOICE_SCENARIO_SIGNAL") == "9" {
unsafe { libc::raise(libc::SIGKILL) };
}
if let Some(code) = get_ms("OMNIVOICE_SCENARIO_EXIT") {
std::process::exit(code as i32);
}
// Scripted to serve forever / be killed externally: idle out.
std::thread::sleep(Duration::from_secs(600));
}
// ── Harness plumbing ──────────────────────────────────────────────────────
struct Scenario<'a> {
stderr: &'a str,
exit: Option<i32>,
signal9: bool,
serve_ms: Option<u64>,
progress_only: bool,
}
impl Default for Scenario<'_> {
fn default() -> Self {
Scenario { stderr: "", exit: None, signal9: false, serve_ms: None, progress_only: false }
}
}
const SCENARIO_ENV: &[&str] = &[
"OMNIVOICE_SCENARIO",
"OMNIVOICE_SCENARIO_STDERR",
"OMNIVOICE_SCENARIO_EXIT",
"OMNIVOICE_SCENARIO_SIGNAL",
"OMNIVOICE_SCENARIO_SERVE_MS",
"OMNIVOICE_SCENARIO_PROGRESS_ONLY",
"OMNIVOICE_SCENARIO_START_DELAY_MS",
"OMNIVOICE_BACKEND_CMD",
"OMNIVOICE_LOG_DIR",
"OMNIVOICE_PORT",
"OMNIVOICE_STARTUP_BUDGET_S",
"OMNIVOICE_SUPERVISOR_POLL_MS",
];
struct TestApp {
app: tauri::App<tauri::test::MockRuntime>,
stage: Arc<Mutex<BootstrapStage>>,
logs: Arc<Mutex<Vec<LogPayload>>>,
_logdir: tempfile::TempDir,
_guard: MutexGuard<'static, ()>,
}
impl TestApp {
fn new(scenario: &Scenario) -> Self {
let guard = HARNESS.lock().unwrap_or_else(|e| e.into_inner());
for k in SCENARIO_ENV {
std::env::remove_var(k);
}
// Reset the retry-flow flag a previous scenario may have left set.
set_backend_kill_intended(false);
let logdir = tempfile::tempdir().expect("logdir");
std::env::set_var("OMNIVOICE_LOG_DIR", logdir.path());
// Fresh ephemeral port per scenario.
let port = {
let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
l.local_addr().unwrap().port()
};
std::env::set_var("OMNIVOICE_PORT", port.to_string());
std::env::set_var("OMNIVOICE_STARTUP_BUDGET_S", "6");
std::env::set_var("OMNIVOICE_SUPERVISOR_POLL_MS", "100");
let exe = std::env::current_exe().expect("current_exe");
std::env::set_var(
"OMNIVOICE_BACKEND_CMD",
serde_json::to_string(&[
exe.to_string_lossy().as_ref(),
"scenario_child",
"--exact",
"--nocapture",
])
.unwrap(),
);
// Armed with OUR pid: the in-process scenario_child test sees its own
// pid and stays inert; only the spawned child (a different pid) runs.
std::env::set_var("OMNIVOICE_SCENARIO", std::process::id().to_string());
if !scenario.stderr.is_empty() {
std::env::set_var("OMNIVOICE_SCENARIO_STDERR", scenario.stderr);
}
if let Some(code) = scenario.exit {
std::env::set_var("OMNIVOICE_SCENARIO_EXIT", code.to_string());
}
if scenario.signal9 {
std::env::set_var("OMNIVOICE_SCENARIO_SIGNAL", "9");
}
if let Some(ms) = scenario.serve_ms {
std::env::set_var("OMNIVOICE_SCENARIO_SERVE_MS", ms.to_string());
}
if scenario.progress_only {
std::env::set_var("OMNIVOICE_SCENARIO_PROGRESS_ONLY", "1");
}
let app = tauri::test::mock_builder()
.build(tauri::test::mock_context(tauri::test::noop_assets()))
.expect("mock app");
app.manage(BackendState { process: Mutex::new(None), spawned_at: Mutex::new(None) });
app.manage(AppFlags {
quitting: AtomicBool::new(false),
dictating: AtomicBool::new(false),
capture: Mutex::new(CaptureDispatchState { ready: false, pending: None }),
});
let stage = Arc::new(Mutex::new(BootstrapStage::Checking));
let logs: Arc<Mutex<Vec<LogPayload>>> = Arc::new(Mutex::new(Vec::new()));
app.manage(BootstrapState { stage: stage.clone(), logs: logs.clone() });
TestApp { app, stage, logs, _logdir: logdir, _guard: guard }
}
fn handle(&self) -> tauri::AppHandle<tauri::test::MockRuntime> {
self.app.handle().clone()
}
/// Run the bootstrap on a thread; the returned closure joins it with a
/// hard timeout so a wiring regression fails red instead of hanging CI.
fn run_bootstrap(&self) -> std::thread::JoinHandle<()> {
let handle = self.handle();
let stage = self.stage.clone();
std::thread::spawn(move || spawn_backend_and_wait(&handle, &stage))
}
fn stage_snapshot(&self) -> BootstrapStage {
self.stage.lock().unwrap_or_else(|e| e.into_inner()).clone()
}
fn failed_message(&self) -> Option<String> {
match self.stage_snapshot() {
BootstrapStage::Failed { message } => Some(message),
_ => None,
}
}
fn markers(&self) -> app_lib::crash::CrashStore {
app_lib::crash::load_store_from(&app_lib::crash::markers_path())
}
fn record_events(&self, name: &'static str) -> Arc<Mutex<Vec<String>>> {
let seen: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
let seen2 = seen.clone();
self.app.handle().listen(name, move |_ev| {
seen2.lock().unwrap_or_else(|e| e.into_inner()).push(name.to_string());
});
seen
}
fn kill_tracked_child(&self) {
let state = self.app.state::<BackendState>();
let guard = state.process.lock();
if let Ok(mut guard) = guard {
if let Some(child) = guard.as_mut() {
let _ = child.kill();
let _ = child.wait();
}
}
}
fn quit(&self) {
self.app
.state::<AppFlags>()
.quitting
.store(true, std::sync::atomic::Ordering::SeqCst);
}
}
impl Drop for TestApp {
fn drop(&mut self) {
self.quit(); // stop any still-running supervisor loop promptly
self.kill_tracked_child();
for k in SCENARIO_ENV {
std::env::remove_var(k);
}
set_backend_kill_intended(false);
}
}
fn wait_until(timeout: Duration, mut pred: impl FnMut() -> bool) -> bool {
let start = Instant::now();
while start.elapsed() < timeout {
if pred() {
return true;
}
std::thread::sleep(Duration::from_millis(100));
}
false
}
fn join_with_timeout(h: std::thread::JoinHandle<()>, timeout: Duration, what: &str) {
let start = Instant::now();
while !h.is_finished() {
assert!(
start.elapsed() < timeout,
"{what}: bootstrap thread still running after {timeout:?} — a lifecycle \
regression is hanging instead of diagnosing"
);
std::thread::sleep(Duration::from_millis(100));
}
let _ = h.join();
}
// ── Scenarios ─────────────────────────────────────────────────────────────
/// S1 — the backend exits EXIT_PORT_IN_USE: the user must read a port
/// conflict (in the exact phrasing BootstrapSplash.detectHints localizes),
/// not a traceback whose one meaningful line is an OS-translated errno.
#[test]
fn port_conflict_is_named_as_a_port_conflict() {
let t = TestApp::new(&Scenario {
stderr: "FATAL: port is already in use",
exit: Some(app_lib::backend::EXIT_PORT_IN_USE),
..Default::default()
});
let h = t.run_bootstrap();
join_with_timeout(h, Duration::from_secs(30), "port conflict");
let msg = t.failed_message().expect("stage must be Failed");
assert!(
msg.contains("is already in use, so the backend could not"),
"diagnosis must carry the detectHints-matchable port phrasing, got: {msg}"
);
let store = t.markers();
assert_eq!(store.markers.len(), 1, "one real death → one marker");
assert_eq!(store.markers.last().unwrap().exit_code, Some(app_lib::backend::EXIT_PORT_IN_USE));
}
/// S3 — generic startup traceback: the Failed message must carry the stderr
/// tail INCLUDING the chained-traceback root cause, and the marker must
/// record the death's shape.
#[test]
fn generic_traceback_surfaces_the_root_cause() {
let t = TestApp::new(&Scenario {
stderr: "Traceback (most recent call last):\\n File \"main.py\", line 1\\nImportError: libcublas.so.12: cannot open shared object file\\n\\nThe above exception was the direct cause of the following exception:\\n\\nTraceback (most recent call last):\\n File \"wrapper.py\", line 9\\nRuntimeError: failed to initialize CUDA backend",
exit: Some(1),
..Default::default()
});
let h = t.run_bootstrap();
join_with_timeout(h, Duration::from_secs(30), "generic traceback");
let msg = t.failed_message().expect("stage must be Failed");
assert!(msg.contains("Backend process exited"), "got: {msg}");
assert!(
msg.contains("libcublas.so.12"),
"the root-cause line must survive into the diagnosis, got: {msg}"
);
let store = t.markers();
assert_eq!(store.markers.len(), 1);
let m = store.markers.last().unwrap();
assert_eq!(m.exit_code, Some(1));
assert!(m.last_stderr.contains("Traceback"), "marker carries the evidence");
assert!(m.last_stderr.contains("libcublas.so.12"));
}
/// S4 — spawn failure (the program does not exist): the spawn diagnostic
/// must reach the user, and NO crash marker is written — nothing ever ran.
#[test]
fn spawn_failure_diagnoses_and_writes_no_bogus_marker() {
let t = TestApp::new(&Scenario::default());
// Point the seam at a program that cannot exist.
let missing = t._logdir.path().join("no-such-backend");
std::env::set_var(
"OMNIVOICE_BACKEND_CMD",
serde_json::to_string(&[missing.to_string_lossy().as_ref()]).unwrap(),
);
let h = t.run_bootstrap();
join_with_timeout(h, Duration::from_secs(30), "spawn failure");
let msg = t.failed_message().expect("stage must be Failed");
assert!(
msg.contains("Failed to launch the backend process"),
"spawn_failure_diagnostic must reach the user, got: {msg}"
);
assert_eq!(
t.markers().markers.len(),
0,
"never-started is not a crash — no marker may be written"
);
}
/// S5 — slow start past the budget: the timeout diagnosis must name the
/// budget and carry the last stderr, and no death marker exists (the
/// process is alive, just slow).
#[test]
fn slow_start_times_out_with_the_last_stderr() {
let t = TestApp::new(&Scenario {
stderr: "Loading checkpoint shards_ 10%",
serve_ms: None,
..Default::default()
});
// The child prints, then idles far past the 6s budget without serving.
let h = t.run_bootstrap();
join_with_timeout(h, Duration::from_secs(60), "slow start");
let msg = t.failed_message().expect("stage must be Failed");
assert!(msg.contains("did not respond within 6 s"), "got: {msg}");
assert!(
msg.contains("Loading checkpoint shards"),
"the last stderr must ride along so triage sees WHERE it was, got: {msg}"
);
assert_eq!(t.markers().markers.len(), 0, "no death → no marker");
}
/// S6 — post-Ready crash loop: markers are recorded BEFORE each restart,
/// restarts are announced, and budget exhaustion lands on a Failed message
/// naming the pattern and the last exit.
#[test]
fn crash_loop_exhausts_the_budget_with_a_named_diagnosis() {
let t = TestApp::new(&Scenario {
stderr: "RuntimeError: CUDA error: out of memory",
exit: Some(1),
serve_ms: Some(1500),
..Default::default()
});
let restarts = t.record_events("backend-restarting");
let gave_up = t.record_events("backend-restart-failed");
let h = t.run_bootstrap();
assert!(
wait_until(Duration::from_secs(20), || matches!(
t.stage_snapshot(),
BootstrapStage::Ready | BootstrapStage::StartingBackend | BootstrapStage::Failed { .. }
)),
"backend never reached Ready"
);
join_with_timeout(h, Duration::from_secs(120), "crash loop");
let msg = t.failed_message().expect("budget exhaustion must land on Failed");
assert!(msg.contains("kept crashing"), "got: {msg}");
assert!(msg.contains("exit code 1"), "the last death must be named, got: {msg}");
assert_eq!(restarts.lock().unwrap().len(), 3, "3 respawns before giving up");
assert_eq!(gave_up.lock().unwrap().len(), 1);
let store = t.markers();
assert!(
!store.markers.is_empty(),
"every real death records forensics BEFORE the restart decision"
);
assert!(
store.markers.iter().all(|m| m.exit_code == Some(1)),
"markers carry the actual exit"
);
assert!(
store.markers.last().unwrap().last_stderr.contains("out of memory"),
"the OOM evidence must be in the marker"
);
}
/// S7 (unix) — SIGKILL (the OS OOM killer's signature): the death must be
/// named as signal 9, not exit-code noise.
#[cfg(unix)]
#[test]
fn sigkill_is_named_as_signal_nine() {
let t = TestApp::new(&Scenario {
signal9: true,
serve_ms: Some(1500),
..Default::default()
});
let h = t.run_bootstrap();
join_with_timeout(h, Duration::from_secs(120), "sigkill loop");
let msg = t.failed_message().expect("stage must be Failed");
assert!(msg.contains("signal 9"), "signal deaths must be named, got: {msg}");
let store = t.markers();
let m = store.markers.last().expect("marker written");
assert_eq!(m.exit_code, None);
assert_eq!(m.signal, Some(9));
}
/// S8 — a deliberate kill (Retry/Clean&Retry owns the respawn): the
/// supervisor must yield silently — no crash marker, no restart, the stage
/// never Failed.
#[test]
fn deliberate_kill_yields_without_a_crash_marker() {
let t = TestApp::new(&Scenario {
serve_ms: Some(0), // serve forever
..Default::default()
});
let restarts = t.record_events("backend-restarting");
let h = t.run_bootstrap();
assert!(
wait_until(Duration::from_secs(20), || matches!(
t.stage_snapshot(),
BootstrapStage::Ready
)),
"backend never reached Ready"
);
let before = t.markers().markers.len();
set_backend_kill_intended(true);
t.kill_tracked_child();
join_with_timeout(h, Duration::from_secs(30), "deliberate kill");
assert_eq!(t.markers().markers.len(), before, "no marker for an intentional kill");
assert_eq!(restarts.lock().unwrap().len(), 0, "no respawn — the retry flow owns it");
assert!(
matches!(t.stage_snapshot(), BootstrapStage::Ready),
"the stage must never flip to Failed for a deliberate replace"
);
}
/// S9 — early-bind narration + a deferred-startup FATAL: the splash log
/// narrates the step the backend reported, and when it dies the named step
/// reaches both the user-facing diagnosis and the crash forensics.
#[test]
fn deferred_startup_failure_names_the_step() {
let t = TestApp::new(&Scenario {
stderr: "Traceback (most recent call last):\\n File \"main.py\"\\nImportError: torch\\nFATAL: backend startup failed during 'ml_imports': ImportError: torch",
exit: Some(1),
serve_ms: Some(1500),
progress_only: true, // /startup/progress answers; health probes do not
..Default::default()
});
let h = t.run_bootstrap();
join_with_timeout(h, Duration::from_secs(60), "deferred FATAL");
let msg = t.failed_message().expect("stage must be Failed");
assert!(
msg.contains("FATAL: backend startup failed during 'ml_imports'"),
"the named step must reach the user, got: {msg}"
);
let store = t.markers();
assert!(store
.markers
.last()
.expect("marker written")
.last_stderr
.contains("failed during 'ml_imports'"));
let logs = t.logs.lock().unwrap_or_else(|e| e.into_inner());
assert!(
logs.iter().any(|l| l.line.contains("Startup: Loading ML runtime")),
"the launch poll must narrate the step the backend reported; logs: {:?}",
logs.iter().map(|l| &l.line).collect::<Vec<_>>()
);
}
@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<!-- Manifest embedded into TEST binaries on Windows (build.rs,
rustc-link-arg-tests). tauri-build embeds the app's manifest into the
application binary, but cargo test binaries get none — so the loader
resolves comctl32 v5, which lacks the TaskDialogIndirect entry point
tauri's dialog/tray stack imports, and every integration-test binary
dies at load with STATUS_ENTRYPOINT_NOT_FOUND (0xc0000139) before a
single test runs. Declaring the Common-Controls v6 dependency here is
the documented remedy. -->
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
<dependency>
<dependentAssembly>
<assemblyIdentity
type="win32"
name="Microsoft.Windows.Common-Controls"
version="6.0.0.0"
processorArchitecture="*"
publicKeyToken="6595b64144ccf1df"
language="*"
/>
</dependentAssembly>
</dependency>
</assembly>
+18 -52
View File
@@ -109,6 +109,7 @@ import { clearDubHistory as apiClearDubHistory } from './api/dub';
import { isTauri, doubleClickMaximize, fileToMediaUrl, playBlobAudio } from './utils/media';
import { browserDownload } from './utils/download';
import { downloadMedia } from './utils/mediaDownload';
import { installDesktopInteractionGuards } from './utils/desktopInteractions';
import { checkForUpdate, fetchAppVersion } from './utils/updater';
import { syncChannel } from './utils/channelControl';
import i18n from './i18n';
@@ -411,6 +412,8 @@ function App() {
insertTag,
applyPreset,
handleGenerate,
cancelGeneration,
cancelAllPendingJobs,
} = useTTS({ selectedProfile, setSelectedProfile, loadHistory, profiles });
const handleSaveProfile = () => _handleSaveProfile(refAudio, refText, instruct, language);
@@ -748,58 +751,19 @@ function App() {
// DESKTOP NATIVE INTEGRATION
useEffect(() => {
if (typeof window === 'undefined') return;
// 1. Prevent default right-click to hide web nature
const handleContextMenu = (e) => {
// allow on inputs/textareas for copy/paste
if (['INPUT', 'TEXTAREA'].includes(e.target.tagName)) return;
e.preventDefault();
};
// 2. Prevent keyboard quicks (reload, zoom, print)
const handleKeyDown = (e) => {
if (!e.metaKey && !e.ctrlKey) return;
if (['r', 'p', '=', '-', '+'].includes(e.key.toLowerCase())) {
e.preventDefault();
}
};
// 3. Prevent pinch-to-zoom
const handleWheel = (e) => {
if (e.ctrlKey) e.preventDefault();
};
// 4. Global Drag and drop for seamless native feeling
const handleDrop = (e) => {
e.preventDefault();
const file = e.dataTransfer?.files[0];
if (!file) return;
const isVideo = file.name.match(/\.(mp4|mov|mkv|webm|avi)$/i);
const isAudio = file.name.match(/\.(mp3|wav|flac|m4a|ogg)$/i);
if (isVideo || isAudio) {
setMode('dub');
setDubVideoFile(file);
fileToMediaUrl(file, null).then((urls) => setDubLocalBlobUrl(urls));
setDubFilename(file.name);
setDubStep('idle');
}
};
const handleDragOver = (e) => e.preventDefault();
window.addEventListener('contextmenu', handleContextMenu);
window.addEventListener('keydown', handleKeyDown);
window.addEventListener('wheel', handleWheel, { passive: false });
window.addEventListener('drop', handleDrop);
window.addEventListener('dragover', handleDragOver);
return () => {
window.removeEventListener('contextmenu', handleContextMenu);
window.removeEventListener('keydown', handleKeyDown);
window.removeEventListener('wheel', handleWheel);
window.removeEventListener('drop', handleDrop);
window.removeEventListener('dragover', handleDragOver);
};
return installDesktopInteractionGuards({
onDrop: (file) => {
const isVideo = file.name.match(/\.(mp4|mov|mkv|webm|avi)$/i);
const isAudio = file.name.match(/\.(mp3|wav|flac|m4a|ogg)$/i);
if (isVideo || isAudio) {
setMode('dub');
setDubVideoFile(file);
fileToMediaUrl(file, null).then((urls) => setDubLocalBlobUrl(urls));
setDubFilename(file.name);
setDubStep('idle');
}
},
});
}, []);
// KEYBOARD SHORTCUTS
@@ -1755,6 +1719,8 @@ function App() {
handleSaveProfile={handleSaveProfile}
handleSaveDesignProfile={handleSaveDesignProfile}
handleGenerate={handleGenerate}
cancelGeneration={cancelGeneration}
cancelAllPendingJobs={cancelAllPendingJobs}
startRecording={startRecording}
stopRecording={stopRecording}
ingestRefAudio={ingestRefAudio}
+9
View File
@@ -33,6 +33,15 @@ describe('_resolveApiBase', () => {
expect(_resolveApiBase({ VITE_API_PORT: '4000' }, win)).toBe('http://127.0.0.1:4000');
});
it('does not send a development UI back to itself when VITE_API_PORT matches its port', () => {
const win = {
location: { origin: 'http://127.0.0.1:3000', hostname: '127.0.0.1', port: '3000' },
};
expect(_resolveApiBase({ DEV: true, VITE_API_PORT: '3000' }, win)).toBe(
'http://127.0.0.1:3900',
);
});
it('runtime window.__OMNIVOICE_API_BASE__ wins over everything (Docker prebuilt-image override)', () => {
const win = {
__TAURI__: {},
+10 -1
View File
@@ -46,7 +46,16 @@ export const LS_API_KEY = LEGACY_API_KEY_STORAGE_KEY;
// Pure + exported for unit testing — takes env + window so tests don't need to
// re-import the module or stub import.meta.env.
export function _resolveApiBase(env: any, win: any): string {
const port = env?.VITE_API_PORT || '3900';
const defaultPort = '3900';
// A port override is useful for a deliberately moved backend, but pointing
// it at Vite itself can only return the SPA's 404 page. This commonly
// happens when a developer moves the UI to :3000 and copies that value into
// both variables. Preserve explicit API URLs (which may name a real proxy),
// while making the port-only configuration recover to the local backend.
const requestedPort = String(env?.VITE_API_PORT || defaultPort);
const port = env?.DEV && requestedPort === String(win?.location?.port || '')
? defaultPort
: requestedPort;
// Explicit override, in precedence order:
// 1. localStorage ov_backend_url — the user's explicit "Remote backend"
// setting (Wave 2.3). Beats everything: it's the one override a
+13
View File
@@ -55,6 +55,19 @@ export async function generateSpeech(
}
}
// Hosted builds replace this module and provide tenant-scoped durable Job
// cancellation. Local VoiceStudio has no durable hosted Job queue, so its
// equivalent is intentionally a no-op rather than a cloud dependency.
export async function cancelPendingHostedJobs(): Promise<number> {
return 0;
}
// The local backend has no durable hosted Job to cancel. Returning false lets
// the caller stop its local request directly.
export async function cancelActiveHostedJob(_signal: AbortSignal): Promise<boolean> {
return false;
}
export async function listHistory(): Promise<unknown> {
return apiJson('/history');
}
+2
View File
@@ -182,6 +182,8 @@ export interface Profile {
ref_audio?: string;
ref_text?: string;
instruct?: string;
/** Deterministic identity seed for a designed profile. */
seed?: number | null;
vd_states?: string | null;
description?: string;
created_at?: string;
@@ -11,8 +11,7 @@ import {
hasCrashEvidence,
isSentinelMarker,
} from '../utils/backendCrash';
import { openExternal } from '../api/external';
import { buildBugReportUrl } from '../utils/bugReport';
import { openBugReport } from '../utils/bugReport';
/**
* BackendCrashNotice the honest half of #941.
@@ -129,13 +128,11 @@ export default function BackendCrashNotice() {
// A sentinel report must not claim a crash in its title
// the marker's whole point is that it cannot know
// (CodeRabbit on #1380). The evidence still rides along.
await openExternal(
await buildBugReportUrl({
title: sentinel
? '[Crash] Backend ended uncleanly (previous run)'
: `[Crash] Backend died (${exit})`,
}),
);
await openBugReport({
title: sentinel
? '[Crash] Backend ended uncleanly (previous run)'
: `[Crash] Backend died (${exit})`,
});
} catch (e) {
// Same class as BackendStartFailureNotice (#1177): a Report
// click that silently does nothing reads as a broken button.
@@ -15,7 +15,7 @@ vi.mock('../utils/backendCrash', async (importOriginal) => {
};
});
vi.mock('../utils/bugReport', () => ({
buildBugReportUrl: vi.fn().mockResolvedValue('https://example.test/issues/new'),
openBugReport: vi.fn().mockResolvedValue(undefined),
}));
vi.mock('../api/external', () => ({
openExternal: vi.fn().mockResolvedValue(undefined),
@@ -138,9 +138,9 @@ describe('BackendCrashNotice — sentinel evidence gate (#1375)', () => {
// The report's TITLE must not claim a death the sentinel cannot attest to
// "Backend died (process ended uncleanly )" states as fact what the
// marker only suspects.
const { buildBugReportUrl } = await import('../utils/bugReport');
await waitFor(() => expect(buildBugReportUrl).toHaveBeenCalled());
const { title } = buildBugReportUrl.mock.calls[0][0];
const { openBugReport } = await import('../utils/bugReport');
await waitFor(() => expect(openBugReport).toHaveBeenCalled());
const { title } = openBugReport.mock.calls[0][0];
expect(title).toMatch(/ended uncleanly/);
expect(title).not.toMatch(/died/);
});
@@ -3,8 +3,7 @@ import { useTranslation } from 'react-i18next';
import { AlertTriangle, X } from 'lucide-react';
import toast from 'react-hot-toast';
import { Button, Dialog } from '../ui';
import { openExternal } from '../api/external';
import { buildBugReportUrl } from '../utils/bugReport';
import { openBugReport } from '../utils/bugReport';
import { detectHints, isUnrecoverableFailure } from './BootstrapSplash';
/**
@@ -101,12 +100,10 @@ export default function BackendStartFailureNotice() {
// buildBugReportUrl scrubs the Error text again and attaches
// the environment block, so the report arrives WITH the
// evidence and WITHOUT the user's home path.
await openExternal(
await buildBugReportUrl({
title: '[Backend] Backend failed to start',
error: new Error(message),
}),
);
await openBugReport({
title: '[Backend] Backend failed to start',
error: new Error(message),
});
} catch (e) {
// Never fail silently: the user clicked Report and must be
// told it didn't open, plus the fallback that still works
@@ -1,15 +1,14 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import BackendStartFailureNotice from './BackendStartFailureNotice';
import { buildBugReportUrl } from '../utils/bugReport';
import { openExternal } from '../api/external';
import { openBugReport } from '../utils/bugReport';
import toast from 'react-hot-toast';
// #1177: the shell's `Failed { message }` diagnosis must reach the user AFTER
// the bootstrap splash is gone the window in which a start failure used to
// collapse into the evidence-free "Can't reach the local VoiceStudio backend".
vi.mock('../utils/bugReport', () => ({
buildBugReportUrl: vi.fn().mockResolvedValue('https://example.test/issues/new'),
openBugReport: vi.fn().mockResolvedValue(undefined),
}));
vi.mock('../api/external', () => ({
openExternal: vi.fn().mockResolvedValue(undefined),
@@ -70,16 +69,15 @@ describe('BackendStartFailureNotice', () => {
fireEvent.click(await screen.findByRole('button', { name: /see why/i }));
fireEvent.click(await screen.findByRole('button', { name: /report/i }));
await waitFor(() => expect(buildBugReportUrl).toHaveBeenCalled());
await waitFor(() => expect(openBugReport).toHaveBeenCalled());
// The evidence rides along on the report, not just on screen.
expect(buildBugReportUrl.mock.calls[0][0].error.message).toContain('ModuleNotFoundError');
expect(openExternal).toHaveBeenCalledWith('https://example.test/issues/new');
expect(openBugReport.mock.calls[0][0].error.message).toContain('ModuleNotFoundError');
});
// A Report click that silently does nothing reads as a broken button the
// user is left with no idea whether anything was sent.
it('tells the user when the report cannot be opened', async () => {
buildBugReportUrl.mockRejectedValueOnce(new Error('no browser'));
openBugReport.mockRejectedValueOnce(new Error('no browser'));
render(<BackendStartFailureNotice />);
emit(DIAGNOSIS);
fireEvent.click(await screen.findByRole('button', { name: /see why/i }));
+2 -2
View File
@@ -3,7 +3,7 @@ import { AlertCircle, BookOpen, Bug, RefreshCw, Search } from 'lucide-react';
import i18next from 'i18next';
import { classifyError, openDocsFor } from '../utils/errorDocsMap';
import { openExternal } from '../api/external';
import { buildBugReportUrl, buildIssueSearchUrl } from '../utils/bugReport';
import { buildIssueSearchUrl, openBugReport } from '../utils/bugReport';
import { Button } from '../ui';
export default class ErrorBoundary extends React.Component {
@@ -42,7 +42,7 @@ export default class ErrorBoundary extends React.Component {
// Prefilled GitHub Issues URL with the scrubbed error attached the
// user reviews everything on github.com before anything is submitted.
try {
await openExternal(await buildBugReportUrl({ error: this.state.error }));
await openBugReport({ error: this.state.error });
} catch (err) {
console.warn('[ErrorBoundary] report failed', err);
}
+2 -3
View File
@@ -16,8 +16,7 @@
import { useState } from 'react';
import { Bug } from 'lucide-react';
import { Button } from '../ui';
import { openExternal } from '../api/external';
import { buildBugReportUrl } from '../utils/bugReport';
import { openBugReport } from '../utils/bugReport';
import { useTranslation } from 'react-i18next';
export default function ReportBugButton({ size = 'sm', variant = 'subtle', label, error }) {
@@ -28,7 +27,7 @@ export default function ReportBugButton({ size = 'sm', variant = 'subtle', label
const handleClick = async () => {
setBuilding(true);
try {
await openExternal(await buildBugReportUrl({ error }));
await openBugReport({ error });
} finally {
setBuilding(false);
}
+13 -4
View File
@@ -48,6 +48,8 @@ export default function ActionBar({
outputPlaying,
isGenerating,
handleGenerate,
cancelGeneration,
cancelAllPendingJobs,
generationTime,
wasGeneratingRef,
}) {
@@ -265,13 +267,12 @@ export default function ActionBar({
<Button
variant="primary"
block
loading={isGenerating}
onClick={handleGenerate}
leading={!isGenerating && <Play size={14} />}
onClick={isGenerating ? cancelGeneration : handleGenerate}
leading={isGenerating ? <Square size={14} /> : <Play size={14} />}
className="mt-[6px]"
>
{isGenerating
? t('clone.synthesizing', { seconds: generationTime })
? 'Cancel job'
: t('clone.synthesize')}
</Button>
)}
@@ -283,6 +284,14 @@ export default function ActionBar({
className="mt-[6px]"
/>
)}
<Button
variant="ghost"
block
onClick={cancelAllPendingJobs}
className="mt-[4px]"
>
Cancel all pending jobs
</Button>
{/* 10x P4 a11y (spec §3): persistent polite live region screen
readers hear generation start AND finish in-workspace, without
relying on the FloatingPill. sr-only keeps it out of the
@@ -3,7 +3,6 @@ import { BookOpen, Ellipsis, Headphones, Loader, Play, Star, UserPlus, Wand2 } f
import { Menu } from '../../ui';
import {
ArchetypeAvatar,
AccentFlag,
NowPlaying,
USE_CASE_COLOR,
} from '../../utils/archetypeIcons';
@@ -91,8 +90,7 @@ export default function ArchetypeCard({
{hasChips && (
<div className="flex flex-wrap items-center gap-[5px]">
{accentLabel && (
<span className="inline-flex items-center gap-[5px] pl-[5px] pr-[8px] py-[2px] rounded-[7px] bg-[var(--color-bg-elev-2)] text-[var(--color-fg-muted)] text-[0.64rem] leading-[1.6]">
<AccentFlag accent={a.facets.accent} lang={a.language} size={14} />
<span className="inline-flex items-center px-[8px] py-[2px] rounded-[7px] bg-[var(--color-bg-elev-2)] text-[var(--color-fg-muted)] text-[0.64rem] leading-[1.6]">
{accentLabel}
</span>
)}
@@ -133,4 +133,35 @@ describe('ArchetypeCard accessibility', () => {
fireEvent.click(await screen.findByRole('menuitem', { name: 'Set as Audiobook default' }));
expect(onUseAsAudiobookDefault).toHaveBeenCalledWith(archetype);
});
it('labels an accent without representing it with a country flag', () => {
const { container } = render(
<ArchetypeCard
a={{
id: 'librarian',
name: 'The Librarian',
language: 'English',
use_case: 'narration',
facets: {
gender: 'female',
age: 'middle aged',
pitch: 'low pitch',
accent: 'british accent',
},
attrs: {},
}}
t={t}
isFavorite={false}
isPlaying={false}
isLoadingPreview={false}
onPreview={vi.fn()}
onUse={vi.fn()}
onDesign={vi.fn()}
onToggleFavorite={vi.fn()}
/>,
);
expect(screen.getByText('British')).toBeInTheDocument();
expect(container.querySelector('.accent-flag')).toBeNull();
});
});
@@ -0,0 +1,164 @@
/**
* Settings Performance: the compute-device override.
*
* Lets the user pin which device family the backend uses (auto / CUDA /
* ROCm / XPU / MPS / CPU) instead of trusting auto-detect the fix for the
* "auto-detect picked wrong" issue class. Options are limited to families
* that actually exist on this host (plus Auto and CPU, which always do);
* the pick applies at the next backend start, same restart contract as the
* rest of this tab. `OMNIVOICE_DEVICE` pins the value and disables the
* control rather than pretending the UI choice would win.
*
* Endpoints:
* GET /api/settings/compute-device
* {value, applied, restart_required, effective_family, auto_family,
* available_families, env_pinned, choices}
* PUT /api/settings/compute-device body {"value": "auto"|family}
*/
import React, { useCallback, useEffect, useState } from 'react';
import { MonitorCog } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { apiJson, apiFetch } from '../../api/client';
import { Select } from '../../ui';
import { SettingsSection, SettingRow } from './primitives';
import RestartBadge from './RestartBadge';
// English fallbacks; the rendered label comes from the locale files
// (settings.device_family_*) so localized builds stay localized.
const FAMILY_FALLBACKS = {
cuda: 'NVIDIA GPU (CUDA)',
rocm: 'AMD GPU (ROCm)',
xpu: 'Intel GPU (XPU)',
mps: 'Apple GPU (MPS)',
cpu: 'CPU',
};
export default function ComputeDevicePanel() {
const { t } = useTranslation();
const [state, setState] = useState(null);
const [saving, setSaving] = useState(false);
const [error, setError] = useState(null);
const refresh = useCallback(async () => {
setError(null);
try {
setState(await apiJson('/api/settings/compute-device'));
} catch (e) {
setError(
e?.message ||
t('settings.device_load_failed', { defaultValue: 'Failed to load device setting' }),
);
}
}, [t]);
useEffect(() => {
refresh();
}, [refresh]);
const onChange = async (e) => {
const value = e.target.value;
setSaving(true);
setError(null);
try {
const res = await apiFetch('/api/settings/compute-device', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ value }),
});
const body = await res.json().catch(() => null);
if (body?.value) setState(body);
else refresh();
} catch (err) {
setError(
err?.message || t('settings.perf_save_failed', { defaultValue: 'Failed to save setting' }),
);
// Re-sync so the UI never shows a pick that didn't persist but keep
// the save error visible (refresh() would clear it).
try {
setState(await apiJson('/api/settings/compute-device'));
} catch {
/* the save error already on screen covers this */
}
} finally {
setSaving(false);
}
};
const label = t('settings.compute_device', { defaultValue: 'Compute device' });
const families = state?.available_families || [];
const familyLabel = (f) =>
t(`settings.device_family_${f}`, { defaultValue: FAMILY_FALLBACKS[f] || f });
return (
<SettingsSection
icon={MonitorCog}
title={t('settings.compute_device_title', { defaultValue: 'Compute device' })}
description={t('settings.compute_device_desc', {
defaultValue: 'Which device the backend runs models on. Auto is right for almost everyone.',
})}
>
{error && (
<div className="perfpanel__error" role="alert">
{error}
</div>
)}
<SettingRow
title={
<>
{label}
<RestartBadge />
</>
}
subtitle={(() => {
const pinned = state?.env_pinned
? t('settings.compute_device_env_pinned', {
defaultValue: 'Pinned by the OMNIVOICE_DEVICE environment variable',
})
: null;
const ignored = state?.override_ignored
? t('settings.compute_device_ignored', {
defaultValue: 'That device was not detected on this machine — Auto is in effect',
})
: null;
// An env pin naming absent hardware needs BOTH facts: why the
// control is disabled, and that the pin is not actually in effect.
if (pinned && ignored) return `${pinned} · ${ignored}`;
if (pinned) return pinned;
if (ignored) return ignored;
return state?.restart_required
? t('settings.compute_device_restart', {
defaultValue: 'Takes effect after the app restarts',
})
: undefined;
})()}
note={t('settings.compute_device_note', {
defaultValue:
'Only devices detected on this machine are listed. CPU always works; pinning a device never invents hardware.',
})}
control={
<Select
size="sm"
value={state?.value ?? 'auto'}
onChange={onChange}
disabled={!state || saving || state?.env_pinned}
aria-label={label}
data-testid="compute-device-select"
>
<option value="auto">
{t('settings.compute_device_auto', {
defaultValue: 'Auto (recommended)',
})}
{state?.auto_family ? `${familyLabel(state.auto_family)}` : ''}
</option>
{families.map((f) => (
<option key={f} value={f}>
{familyLabel(f)}
</option>
))}
</Select>
}
/>
</SettingsSection>
);
}
@@ -0,0 +1,104 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import React from 'react';
function mockFetchSequence(...responses) {
const fn = vi.fn();
for (const r of responses) {
fn.mockResolvedValueOnce({
ok: r.status >= 200 && r.status < 300,
status: r.status,
json: async () => r.body,
text: async () => JSON.stringify(r.body),
});
}
return fn;
}
import ComputeDevicePanel from './ComputeDevicePanel';
const CUDA_HOST = {
value: 'auto',
applied: 'auto',
restart_required: false,
effective_family: 'cuda',
auto_family: 'cuda',
available_families: ['cuda', 'cpu'],
env_pinned: false,
choices: ['auto', 'cuda', 'rocm', 'xpu', 'mps', 'cpu'],
};
describe('ComputeDevicePanel', () => {
beforeEach(() => {
vi.restoreAllMocks();
});
it('offers only the detected families plus Auto', async () => {
global.fetch = mockFetchSequence({ status: 200, body: CUDA_HOST });
render(<ComputeDevicePanel />);
// Wait for the LOADED state (3 options), not just the select it
// renders disabled with only Auto before the GET resolves.
await waitFor(() => expect(screen.getByTestId('compute-device-select').options.length).toBe(3));
const options = [...screen.getByTestId('compute-device-select').options].map((o) => o.value);
// No mps/rocm/xpu on a CUDA host an override can steer, not invent.
expect(options).toEqual(['auto', 'cuda', 'cpu']);
});
it('changing the pick PUTs the value and shows the restart note', async () => {
const fetchMock = mockFetchSequence(
{ status: 200, body: CUDA_HOST }, // initial GET
{
status: 200,
body: { ...CUDA_HOST, value: 'cpu', restart_required: true },
}, // PUT echo
);
global.fetch = fetchMock;
render(<ComputeDevicePanel />);
await waitFor(() => expect(screen.getByTestId('compute-device-select').options.length).toBe(3));
fireEvent.change(screen.getByTestId('compute-device-select'), { target: { value: 'cpu' } });
await waitFor(() => {
const put = fetchMock.mock.calls.find(([_u, opts]) => opts && opts.method === 'PUT');
expect(put).toBeTruthy();
expect(put[0]).toMatch(/\/api\/settings\/compute-device$/);
expect(JSON.parse(put[1].body)).toEqual({ value: 'cpu' });
});
expect(screen.getByText(/after the app restarts/i)).toBeInTheDocument();
});
it('an OMNIVOICE_DEVICE pin disables the control and says so', async () => {
global.fetch = mockFetchSequence({
status: 200,
body: { ...CUDA_HOST, value: 'cpu', applied: 'cpu', env_pinned: true },
});
render(<ComputeDevicePanel />);
await waitFor(() => {
expect(screen.getByTestId('compute-device-select')).toBeDisabled();
});
expect(screen.getByText(/OMNIVOICE_DEVICE/)).toBeInTheDocument();
});
it('re-syncs from the server when the PUT fails, keeping the error visible', async () => {
const fetchMock = mockFetchSequence(
{ status: 200, body: CUDA_HOST }, // initial GET
{ status: 500, body: { detail: 'nope' } }, // PUT fails
{ status: 200, body: CUDA_HOST }, // re-sync GET
);
global.fetch = fetchMock;
render(<ComputeDevicePanel />);
await waitFor(() => expect(screen.getByTestId('compute-device-select').options.length).toBe(3));
fireEvent.change(screen.getByTestId('compute-device-select'), { target: { value: 'cpu' } });
await waitFor(() => {
// Three calls: GET, failed PUT, re-sync GET the select ends on the
// server's truth (auto), never a pick that didn't persist.
expect(fetchMock.mock.calls.length).toBe(3);
});
expect(screen.getByTestId('compute-device-select')).toHaveValue('auto');
// The save error must survive the re-sync a silent snap-back reads
// as "the app ignored me".
expect(screen.getByRole('alert')).toBeInTheDocument();
});
});
@@ -18,6 +18,7 @@ import { Badge } from '../../ui';
import { SettingsSection } from './primitives';
import Row from './Row';
import PerformancePanel from './PerformancePanel';
import ComputeDevicePanel from './ComputeDevicePanel';
export default function PerformanceDeviceTab() {
const { t } = useTranslation();
@@ -29,6 +30,8 @@ export default function PerformanceDeviceTab() {
<>
<PerformancePanel />
<ComputeDevicePanel />
<SettingsSection
icon={Gauge}
title={t('settings.device', { defaultValue: 'Device & compute' })}
@@ -196,6 +196,12 @@ export const GROUPS = [
'vram',
'compute',
'platform',
'cuda',
'rocm',
'mps',
'cpu',
'xpu',
'intel',
],
},
{
@@ -67,6 +67,7 @@ describe('restart flag ↔ RestartBadge lockstep', () => {
'RemoteBackendPanel.jsx': 'sharing',
'AudioToolsPanel.jsx': 'audio-tools',
'PerformancePanel.jsx': 'performance',
'ComputeDevicePanel.jsx': 'performance',
};
const panelsUsingRestartBadge = fs
+66 -34
View File
@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { useState, useEffect, useLayoutEffect, useCallback, useRef } from 'react';
import { useAppStore } from '../store';
import { listProfiles } from '../api/profiles';
import { listHistory } from '../api/generate';
@@ -10,6 +10,7 @@ import { useModelStatus } from '../api/hooks';
import useRealtimeEvents from './useRealtimeEvents';
import { mergeDescribedAttrs } from '../utils/voiceInstruct';
import { sanitizeOmniUi } from '../utils/omniUiSchema';
import { queueJsonWrite } from '../utils/coalescedJsonStorage';
/**
* Encapsulates all data-loading effects, localStorage persistence,
@@ -24,6 +25,7 @@ import { sanitizeOmniUi } from '../utils/omniUiSchema';
// reinstall doesn't clear the webview's localStorage). Only settled states
// come back.
const STABLE_DUB_STEPS = new Set(['idle', 'editing', 'done']);
export const OMNI_UI_KEY = 'omni_ui';
/** Clamp a persisted dubStep to a state that is valid after a cold start.
* Stable steps pass through; transient (and unknown/corrupt) values fall
@@ -89,6 +91,45 @@ export default function useAppData() {
const [studioProjects, setStudioProjects] = useState([]);
const [exportHistory, setExportHistory] = useState([]);
const [showOverrides, setShowOverrides] = useState(false);
const [omniUiRestoreComplete, setOmniUiRestoreComplete] = useState(false);
const omniUiWriteDisposerRef = useRef(null);
const omniUiSnapshotRef = useRef(null);
const omniUiSnapshot = {
uiScale,
text,
mode,
defineMethod,
vdStates,
language,
isSidebarCollapsed,
sidebarTab,
dubJobId,
dubFilename,
dubDuration,
dubSegments,
dubLang,
dubLangCode,
dubTracks,
dubStep,
dubTranscript,
exportTracks,
preserveBg,
defaultTrack,
exportHistory,
speed,
steps,
cfg,
denoise,
showOverrides,
};
// Only expose committed state to the deferred writer. Publishing during
// render would let a timer or lifecycle flush observe a concurrent render
// that React later abandons. Layout effects run before the passive effect
// that registers the provider, without copying any nested document data.
useLayoutEffect(() => {
omniUiSnapshotRef.current = omniUiSnapshot;
});
// ── Model status (TanStack Query) ──
// Sysinfo lives in Header (the only consumer) so its 5s poll doesn't
@@ -196,7 +237,7 @@ export default function useAppData() {
// 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') || '{}'));
const saved = sanitizeOmniUi(JSON.parse(localStorage.getItem(OMNI_UI_KEY) || '{}'));
if (saved.uiScale) setUiScale(saved.uiScale);
if (saved.text) setText(saved.text);
// Legacy shim (voice-studio-unification P4): the old 'clone'/'design'
@@ -240,7 +281,14 @@ export default function useAppData() {
if (saved.cfg) setCfg(saved.cfg);
if (saved.denoise !== undefined) setDenoise(saved.denoise);
if (saved.showOverrides !== undefined) setShowOverrides(saved.showOverrides);
} catch (e) {}
} catch (e) {
// Preserve the existing fail-open recovery behavior: malformed or
// inaccessible legacy state falls back to the initialized defaults.
} finally {
// The initial persistence effect closes over `false` and therefore
// cannot flush defaults. The restored render becomes the first writer.
setOmniUiRestoreComplete(true);
}
return () => {
cancelled = true;
};
@@ -248,38 +296,14 @@ export default function useAppData() {
// ── Persist to localStorage ──
useEffect(() => {
localStorage.setItem(
'omni_ui',
JSON.stringify({
uiScale,
text,
mode,
defineMethod,
vdStates,
language,
isSidebarCollapsed,
sidebarTab,
dubJobId,
dubFilename,
dubDuration,
dubSegments,
dubLang,
dubLangCode,
dubTracks,
dubStep,
dubTranscript,
exportTracks,
preserveBg,
defaultTrack,
exportHistory,
speed,
steps,
cfg,
denoise,
showOverrides,
}),
);
if (!omniUiRestoreComplete) return undefined;
// Replacements must retain the scheduler's original maximum-wait window.
// React runs a dependency effect's cleanup before every new setup, so the
// generation disposer is intentionally reserved for a true unmount below.
omniUiWriteDisposerRef.current = queueJsonWrite(OMNI_UI_KEY, () => omniUiSnapshotRef.current);
return undefined;
}, [
omniUiRestoreComplete,
uiScale,
text,
mode,
@@ -308,6 +332,14 @@ export default function useAppData() {
showOverrides,
]);
useEffect(
() => () => {
omniUiWriteDisposerRef.current?.();
omniUiWriteDisposerRef.current = null;
},
[],
);
return {
profiles,
history,
@@ -0,0 +1,342 @@
import React, { StrictMode, Suspense } from 'react';
import { act, cleanup, render, renderHook } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const persistenceProbe = vi.hoisted(() => ({
flushOnNextOmniQueue: false,
providerReads: 0,
queuedProviders: [],
disposers: [],
materializedValues: [],
}));
// Keep the real scheduler in this integration suite. The wrapper adds one
// deterministic test seam: forcing the first omni_ui registration to flush
// synchronously catches an initial-default provider before React can replace
// it with the restored render's provider.
vi.mock('../utils/coalescedJsonStorage', async (importOriginal) => {
const actual = await importOriginal();
return {
...actual,
queueJsonWrite(key, readLatestValue) {
const trackedProvider = () => {
persistenceProbe.providerReads += 1;
const value = readLatestValue();
persistenceProbe.materializedValues.push(value);
return value;
};
const disposeActual = actual.queueJsonWrite(key, trackedProvider);
const dispose = vi.fn(disposeActual);
persistenceProbe.queuedProviders.push({ key, provider: trackedProvider });
persistenceProbe.disposers.push(dispose);
if (key === 'omni_ui' && persistenceProbe.flushOnNextOmniQueue) {
persistenceProbe.flushOnNextOmniQueue = false;
actual.flushPendingWrites();
}
return dispose;
},
};
});
const systemApi = vi.hoisted(() => ({ modelStatus: vi.fn() }));
vi.mock('../api/system', () => systemApi);
vi.mock('../api/hooks', () => ({
useModelStatus: () => ({ data: { status: 'idle' } }),
}));
vi.mock('./useRealtimeEvents', () => ({ default: vi.fn() }));
import useAppData from './useAppData';
import { useAppStore } from '../store';
import {
configurePersistenceRole,
discardPendingWrites,
flushPendingWrites,
resetCoalescedJsonStorageForTests,
} from '../utils/coalescedJsonStorage';
const initialStoreState = useAppStore.getInitialState();
const OMNI_UI_KEYS = [
'uiScale',
'text',
'mode',
'defineMethod',
'vdStates',
'language',
'isSidebarCollapsed',
'sidebarTab',
'dubJobId',
'dubFilename',
'dubDuration',
'dubSegments',
'dubLang',
'dubLangCode',
'dubTracks',
'dubStep',
'dubTranscript',
'exportTracks',
'preserveBg',
'defaultTrack',
'exportHistory',
'speed',
'steps',
'cfg',
'denoise',
'showOverrides',
];
function resetProbe() {
persistenceProbe.flushOnNextOmniQueue = false;
persistenceProbe.providerReads = 0;
persistenceProbe.queuedProviders.length = 0;
persistenceProbe.disposers.length = 0;
persistenceProbe.materializedValues.length = 0;
}
function seedOmniUi(value) {
localStorage.setItem('omni_ui', JSON.stringify(value));
}
function watchStorageWrites() {
return vi.spyOn(localStorage, 'setItem');
}
function omniWrites(setItemSpy) {
return setItemSpy.mock.calls
.filter(([key]) => key === 'omni_ui')
.map(([, raw]) => JSON.parse(raw));
}
beforeEach(() => {
vi.useFakeTimers();
resetCoalescedJsonStorageForTests();
configurePersistenceRole('main');
useAppStore.setState(initialStoreState, true);
useAppStore.setState({
text: 'initial default that must never win',
mode: 'studio',
dubSegments: [],
dubStep: 'idle',
});
discardPendingWrites();
localStorage.clear();
resetProbe();
// Keep the backend-readiness loop parked without scheduling retries or
// allowing unrelated list responses to update the hook after a test ends.
systemApi.modelStatus.mockReset().mockImplementation(() => new Promise(() => {}));
});
afterEach(() => {
cleanup();
resetCoalescedJsonStorageForTests();
localStorage.clear();
vi.useRealTimers();
vi.restoreAllMocks();
});
describe('useAppData omni_ui persistence', () => {
it('never exposes initial defaults to a lifecycle flush while restoring seeded state', () => {
seedOmniUi({
uiScale: 1.15,
text: 'restored script',
mode: 'dub',
defineMethod: 'design',
language: 'Spanish',
isSidebarCollapsed: true,
sidebarTab: 'projects',
dubJobId: 'job-restored',
dubFilename: 'clip.mp4',
dubDuration: 42,
dubSegments: [{ id: '1', text: 'Hola', start: 0, end: 2 }],
dubLang: 'Spanish',
dubLangCode: 'es',
dubTracks: ['es'],
dubStep: 'generating',
dubTranscript: 'Hola',
exportTracks: { original: true, es: true },
preserveBg: false,
defaultTrack: 'es',
exportHistory: [{ id: 'export-1' }],
speed: 1.2,
steps: 24,
cfg: 2.5,
denoise: false,
showOverrides: true,
});
const setItemSpy = watchStorageWrites();
setItemSpy.mockClear();
persistenceProbe.flushOnNextOmniQueue = true;
const { result } = renderHook(() => useAppData());
const writes = omniWrites(setItemSpy);
expect(writes).toHaveLength(1);
expect(writes[0]).toMatchObject({
text: 'restored script',
mode: 'dub',
dubJobId: 'job-restored',
dubStep: 'editing',
exportHistory: [{ id: 'export-1' }],
showOverrides: true,
});
expect(writes[0].dubSegments).toEqual([
{ id: '1', text: 'Hola', text_original: 'Hola', start: 0, end: 2 },
]);
expect(result.current.showOverrides).toBe(true);
expect(Object.keys(persistenceProbe.materializedValues[0])).toEqual(OMNI_UI_KEYS);
expect(persistenceProbe.queuedProviders[0].key).toBe('omni_ui');
});
it('keeps serialization and physical writes out of a burst and flushes only the latest value', () => {
const setItemSpy = watchStorageWrites();
renderHook(() => useAppData());
flushPendingWrites();
setItemSpy.mockClear();
persistenceProbe.providerReads = 0;
persistenceProbe.materializedValues.length = 0;
for (let index = 1; index <= 20; index += 1) {
act(() => {
useAppStore.getState().setText(`draft-${index}`);
useAppStore
.getState()
.setDubSegments([
{ id: '1', text: `segment-${index}`, text_original: 'source', start: 0, end: 1 },
]);
});
}
expect(persistenceProbe.providerReads).toBe(0);
expect(omniWrites(setItemSpy)).toHaveLength(0);
act(() => {
vi.advanceTimersByTime(249);
});
expect(omniWrites(setItemSpy)).toHaveLength(0);
act(() => {
vi.advanceTimersByTime(1);
});
const writes = omniWrites(setItemSpy);
expect(writes).toHaveLength(1);
expect(writes[0].text).toBe('draft-20');
expect(writes[0].dubSegments[0].text).toBe('segment-20');
expect(persistenceProbe.providerReads).toBe(1);
});
it('preserves the original hard deadline during continuous edits', () => {
const setItemSpy = watchStorageWrites();
renderHook(() => useAppData());
flushPendingWrites();
setItemSpy.mockClear();
persistenceProbe.providerReads = 0;
act(() => {
useAppStore.getState().setText('continuous-1');
});
for (let index = 2; index <= 5; index += 1) {
act(() => {
vi.advanceTimersByTime(200);
useAppStore.getState().setText(`continuous-${index}`);
});
}
// Every edit arrived before the 250 ms quiet delay. The maximum timer
// still belongs to the window opened at t=0 and must not be restarted by
// React's dependency-effect cleanup/re-registration cycle.
act(() => {
vi.advanceTimersByTime(199);
});
expect(omniWrites(setItemSpy)).toHaveLength(0);
expect(persistenceProbe.providerReads).toBe(0);
act(() => {
vi.advanceTimersByTime(1);
});
const writes = omniWrites(setItemSpy);
expect(writes).toHaveLength(1);
expect(writes[0].text).toBe('continuous-5');
expect(persistenceProbe.providerReads).toBe(1);
});
it('never persists state from a render that React abandons', () => {
let suspendNextRender = false;
const neverSettles = new Promise(() => {});
function ConcurrentHarness() {
useAppData();
if (suspendNextRender) throw neverSettles;
return null;
}
render(
<Suspense fallback={null}>
<ConcurrentHarness />
</Suspense>,
);
act(() => {
flushPendingWrites();
useAppStore.getState().setText('last committed script');
});
// The store notification starts a render which suspends before commit.
// A render-time ref assignment exposed this value to the already-pending
// provider even though React never published it to the UI.
suspendNextRender = true;
act(() => {
useAppStore.getState().setText('abandoned candidate');
});
act(() => {
flushPendingWrites();
});
expect(JSON.parse(localStorage.getItem('omni_ui')).text).toBe('last committed script');
});
it('uses generation-bound cleanup across StrictMode updates and unmount', () => {
const setItemSpy = watchStorageWrites();
const { unmount } = renderHook(() => useAppData(), { wrapper: StrictMode });
expect(persistenceProbe.disposers.length).toBeGreaterThan(0);
const obsoleteDisposer = persistenceProbe.disposers.at(-1);
act(() => {
useAppStore.getState().setText('newer provider');
});
// Dependency changes replace the provider without disposing the prior
// registration; disposing here would restart the scheduler's hard window.
expect(obsoleteDisposer).not.toHaveBeenCalled();
// A repeated/stale cleanup must not cancel the replacement registration.
obsoleteDisposer();
act(() => {
flushPendingWrites();
});
expect(omniWrites(setItemSpy).at(-1)?.text).toBe('newer provider');
setItemSpy.mockClear();
act(() => {
useAppStore.getState().setText('cancel on unmount');
});
const activeDisposer = persistenceProbe.disposers.at(-1);
unmount();
expect(activeDisposer).toHaveBeenCalledOnce();
act(() => {
vi.runAllTimers();
flushPendingWrites();
});
expect(omniWrites(setItemSpy)).toHaveLength(0);
});
it('completes restore readiness after malformed legacy JSON', () => {
localStorage.setItem('omni_ui', '{malformed');
const setItemSpy = watchStorageWrites();
setItemSpy.mockClear();
expect(() => renderHook(() => useAppData())).not.toThrow();
act(() => {
vi.advanceTimersByTime(250);
});
const writes = omniWrites(setItemSpy);
expect(writes).toHaveLength(1);
expect(writes[0].text).toBe('initial default that must never win');
});
});
+19 -1
View File
@@ -41,6 +41,8 @@ export default function useProfiles({ loadHistory, loadProfiles }) {
const setLanguage = useAppStore((s) => s.setLanguage);
const setVdStates = useAppStore((s) => s.setVdStates);
const setDefineMethod = useAppStore((s) => s.setDefineMethod);
const setDesignSeed = useAppStore((s) => s.setDesignSeed);
const setKeepSeed = useAppStore((s) => s.setKeepSeed);
const language = useAppStore((s) => s.language);
const mode = useAppStore((s) => s.mode);
const steps = useAppStore((s) => s.steps);
@@ -102,6 +104,14 @@ export default function useProfiles({ loadHistory, loadProfiles }) {
// The profile's kind picks the "Define voice" method implicitly: design
// profiles open the design controls, everything else the audio path.
setDefineMethod(profile.kind === 'design' ? 'design' : 'audio');
// Gallery archetypes render their identity sample with the profile's
// stored seed. Reuse it when that profile is selected: otherwise the
// Design workspace sends a fresh random seed and the same archetype
// visibly drifts away from its gallery voice on every generation.
if (profile.kind === 'design' && Number.isInteger(profile.seed)) {
setDesignSeed(profile.seed);
setKeepSeed(true);
}
// Design profiles (0005) carry their category picks — restore the sliders
// so selecting one makes it re-editable, not just re-usable.
if (profile.kind === 'design') {
@@ -132,7 +142,15 @@ export default function useProfiles({ loadHistory, loadProfiles }) {
setInstruct(profile.instruct || '');
}
},
[setRefText, setInstruct, setLanguage, setVdStates, setDefineMethod],
[
setRefText,
setInstruct,
setLanguage,
setVdStates,
setDefineMethod,
setDesignSeed,
setKeepSeed,
],
);
/** Save the current design (vd_states + instruct) as a reusable profile.

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