-

-## Create with VoiceStudio
+## Your voice. Your workflow.
-- **Clone & design voices** — use a reference recording or describe the voice you imagine.
-- **Dub video** — transcribe, translate, assign speakers, and edit timed speech.
-- **Dictate anywhere** — record, transcribe, and copy text with a floating recording widget.
-- **Tell longer stories** — create multi-voice scripts, audiobooks, and batch jobs.
-- **Choose your models** — manage speech and transcription engines, languages, and compute devices.
+| Create | Produce | Connect |
+| :--- | :--- | :--- |
+| Clone a voice or design your own | Dub videos with timed speech | Local API & MCP for agents |
+| Dictate with a floating widget | Stories, audiobooks & batch jobs | Optional remote workers |
-Start with **VoiceStudio** (default, powered by k2-fsa/OmniVoice), or choose another engine.
+Start with **VoiceStudio** (default, powered by k2-fsa/OmniVoice), or choose another engine. [Features & engine catalog](docs/feature-catalog.md).
Local workflows run on your hardware. Remote services are optional; usage analytics requires consent.
+
+Explore the workspaces · Clone, dub, design & models
+
@@ -49,6 +49,10 @@ Local workflows run on your hardware. Remote services are optional; usage analyt
Voice design
Local models
+
+
+
+
## Get started
Download from [Releases](https://github.com/debpalash/VoiceStudio/releases/latest), then follow your platform guide:
@@ -57,17 +61,21 @@ Download from [Releases](https://github.com/debpalash/VoiceStudio/releases/lates
Open **Voice cloning**, choose a voice or add a clean reference recording, enter your text, and generate. Install the required model when prompted. Hardware needs vary by engine; see [performance](docs/performance.md).
-**Run the Electron preview from source:**
+
+Run the Electron preview from source
```bash
git clone https://github.com/debpalash/VoiceStudio.git
cd VoiceStudio
bun install
-cd electron
bun run dev
```
-See [Electron setup](electron/README.md) for prerequisites and backend configuration. VoiceStudio is in active development; report bugs through [GitHub Issues](https://github.com/debpalash/VoiceStudio/issues).
+See [Electron setup](electron/README.md) for prerequisites and backend configuration.
+
+
+
+> **Electron is the primary desktop app.** The next desktop release ships Electron, with one final Tauri sunset update. Bug reports and contributions remain welcome; include the app version and whether you use Electron or Tauri.
## Documentation
@@ -78,13 +86,15 @@ See [Electron setup](electron/README.md) for prerequisites and backend configura
| Integrations | [Local API](docs/speech-platform.md) · [MCP](docs/mcp.md) · [Examples](examples/README.md) |
| Development | [Contributing](.github/CONTRIBUTING.md) · [Electron](electron/README.md) · [Changelog](CHANGELOG.md) |
-Agent skills: `npx skills add debpalash/VoiceStudio` — choose **voicestudio** for audio workflows or **oss-maintainer** for repository maintenance.
+Agent skills: `npx skills add debpalash/VoiceStudio` — choose **voicestudio** for audio workflows or **voicestudio-maintainer** for repository maintenance.
-## Support VoiceStudio
+## Sponsors
-[Ko-fi](https://ko-fi.com/debpalash) · [PayPal](https://paypal.me/palashCoder) · [Sponsor the project](SPONSORS.md) · [Partnerships](mailto:partner@voicestudio.sh)
+
-**Put your brand where people build with voice.** Explore paid placements in the app footer, integrations directory, documentation, and README. [Apply to partner](https://forms.gle/2PYCvd39hbwijzX37) or [email us](mailto:partner@voicestudio.sh).
+**Become a featured partner.** [Apply for a paid placement](https://forms.gle/2PYCvd39hbwijzX37) · [Email us](mailto:partner@voicestudio.sh)
+
+Support development: [Ko-fi](https://ko-fi.com/debpalash) · [PayPal](https://paypal.me/palashCoder) · [Sponsorship details](SPONSORS.md)
## License & responsible use
diff --git a/backend/api/routers/settings.py b/backend/api/routers/settings.py
index 15b229b8..14dc9124 100644
--- a/backend/api/routers/settings.py
+++ b/backend/api/routers/settings.py
@@ -156,7 +156,7 @@ def set_performance_profile(body: _PerformanceProfileBody):
class _TorchCompileBody(BaseModel):
- enabled: bool = Field(..., description="True to set TORCH_COMPILE_DISABLE=1 on engine subprocesses")
+ enabled: bool = Field(..., description="True to disable torch.compile (eager mode) for the engine")
def _torch_compile_state() -> dict:
@@ -170,15 +170,21 @@ def _torch_compile_state() -> dict:
@router.get("/perf/torch-compile-disabled")
def get_torch_compile_disabled():
"""Return the current torch.compile-disabled toggle + the runtime platform.
- UI uses the platform to render the toggle disabled (with an explainer)
- on non-Windows hosts, since the OOM is Windows-specific (issue #65)."""
+
+ `platform` is still reported (clients may show it), but since #2135 the
+ toggle is live on every host: it used to be rendered disabled off Windows
+ on the assumption that only #65's Windows OOM needed it, which left the
+ Linux/CUDA reporter of #2135 with no way to switch off the compile that
+ was killing their backend.
+ """
return _torch_compile_state()
@router.put("/perf/torch-compile-disabled")
def set_torch_compile_disabled(body: _TorchCompileBody):
"""Persist the toggle. Honoured by `services.engine_env.build_engine_env()`
- which injects TORCH_COMPILE_DISABLE=1 on Windows when enabled."""
+ (subprocess engines) and `services.engine_env.should_torch_compile()`
+ (in-process), on every platform since #2135."""
from services import settings_store
try:
diff --git a/backend/core/crash_diagnostics.py b/backend/core/crash_diagnostics.py
new file mode 100644
index 00000000..bf51357c
--- /dev/null
+++ b/backend/core/crash_diagnostics.py
@@ -0,0 +1,59 @@
+"""Native-crash diagnostics for the backend process (#2135).
+
+A crash inside torch/CUDA — graph capture, a driver fault, an allocator abort —
+kills the interpreter below the level any ``except`` can reach. #2135's reporter
+saw exactly that: the backend "simply exited" mid-``/generate`` with no Python
+traceback, no HTTP response, and ``ConnectionRefused`` on the next ``/health``.
+There was nothing in the logs to diagnose because nothing in Python ever ran
+again.
+
+``faulthandler`` installs handlers for the fatal signals (SIGSEGV, SIGABRT,
+SIGBUS, SIGFPE, SIGILL) that print every thread's Python stack to stderr on the
+way down. That is the difference between "the process vanished" and a named
+frame pointing at the engine call that killed it.
+
+This is strictly a diagnostic: it does not prevent the crash, and it must never
+be the reason startup fails.
+"""
+from __future__ import annotations
+
+import os
+
+_DISABLE_ENV = "OMNIVOICE_DISABLE_FAULTHANDLER"
+_TRUTHY = frozenset({"1", "true", "yes", "on"})
+
+
+def _disabled() -> bool:
+ return os.environ.get(_DISABLE_ENV, "").strip().lower() in _TRUTHY
+
+
+def enable_fault_handler(stderr=None) -> bool:
+ """Arm fatal-signal tracebacks. Returns True when armed.
+
+ Call as early as possible — before torch is imported — so a crash during
+ model load is covered too. Honours ``OMNIVOICE_DISABLE_FAULTHANDLER=1`` for
+ hosts whose outer supervisor installs its own handlers.
+
+ Args:
+ stderr: optional file object to write dumps to. Defaults to the real
+ ``sys.stderr`` (→ ``backend_err.log``). faulthandler keeps the
+ underlying fd, so the object must stay open for the process
+ lifetime.
+
+ Never raises: a frozen build with a detached stderr, or a platform without
+ the signals, degrades to "no crash dump" rather than a failed boot.
+ """
+ if _disabled():
+ return False
+ try:
+ import faulthandler
+
+ # all_threads=True: the fatal frame is routinely on a GPU-pool or
+ # compile worker, not whichever thread happens to take the signal.
+ if stderr is not None:
+ faulthandler.enable(file=stderr, all_threads=True)
+ else:
+ faulthandler.enable(all_threads=True)
+ return True
+ except Exception:
+ return False
diff --git a/backend/core/version.py b/backend/core/version.py
index ebc4138b..35e24614 100644
--- a/backend/core/version.py
+++ b/backend/core/version.py
@@ -24,7 +24,7 @@ from pathlib import Path
# tests/test_app_version.py::test_all_version_files_in_lockstep and bumped by
# release.yml's version-bump job, so it stays equal to
# pyproject/tauri.conf/Cargo/package.json.
-_FALLBACK_VERSION = "0.5.2"
+_FALLBACK_VERSION = "0.5.3"
def _fallback_version() -> str:
diff --git a/backend/engines/omnivoice_gguf/README.md b/backend/engines/omnivoice_gguf/README.md
index 0af11335..229a5e14 100644
--- a/backend/engines/omnivoice_gguf/README.md
+++ b/backend/engines/omnivoice_gguf/README.md
@@ -115,8 +115,9 @@ silently hanging on a Gatekeeper-killed spawn.
The macOS Apple Silicon Metal build compiles cleanly with `-DGGML_METAL=ON`
at the pinned `omnivoice.cpp` SHA (#2105), enabling GPU-accelerated GGUF
-voice cloning on Apple Silicon out of the box with `VoiceStudioBackend`
-remaining available as an in-process fallback.
+voice cloning when the packaged binary passes preflight and is permitted by
+macOS. Missing binaries, placeholders, or Gatekeeper rejection leave
+`VoiceStudioBackend` available as the in-process fallback.
## Smoke test
diff --git a/backend/main.py b/backend/main.py
index 11e49f29..499ae906 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -9,6 +9,13 @@ _backend_dir = os.path.dirname(os.path.abspath(__file__))
if _backend_dir not in sys.path:
sys.path.insert(0, _backend_dir)
+# #2135: arm fatal-signal tracebacks before anything heavy is imported, so a
+# native crash inside torch/CUDA leaves a named frame in backend_err.log
+# instead of a silently vanished process. See core/crash_diagnostics.py.
+from core.crash_diagnostics import enable_fault_handler # noqa: E402
+
+enable_fault_handler()
+
# PyInstaller re-executes this entry module when the frozen backend binary is
# launched. Nested operation supervisors therefore dispatch here, before math,
# logging, FastAPI, torch, or any application initialization. Source launches
diff --git a/backend/services/engine_env.py b/backend/services/engine_env.py
index 91c6f6e7..d5ff0e8b 100644
--- a/backend/services/engine_env.py
+++ b/backend/services/engine_env.py
@@ -17,7 +17,6 @@ from __future__ import annotations
import importlib.util
import logging
import os
-import sys
from typing import Optional
logger = logging.getLogger("omnivoice.engine_env")
@@ -29,6 +28,53 @@ _TORCH_COMPILE_KEY = "perf.torch_compile_disabled"
# (e.g. a brand-new architecture running through PTX forward-compat).
_FORCE_COMPILE_ENV = "OMNIVOICE_FORCE_TORCH_COMPILE"
+# #2135: the environment escape hatches that torch itself honours. `main.py`
+# sets TORCH_COMPILE_DISABLE/TORCHDYNAMO_DISABLE on win32, `build_engine_env`
+# injects TORCH_COMPILE_DISABLE into engine subprocesses, and
+# `docs/install/windows.md` tells users to export it — but the in-process gate
+# below never read them, so an operator who set the documented variable still
+# got a compiled model (and, on a cudagraph mode, a native crash they could not
+# turn off). Reading them here makes one knob mean one thing everywhere.
+_COMPILE_DISABLE_ENVS = (
+ "TORCH_COMPILE_DISABLE",
+ "TORCHDYNAMO_DISABLE",
+ "TORCHINDUCTOR_DISABLE",
+)
+
+_TRUTHY = frozenset({"1", "true", "yes", "on"})
+
+
+def _env_compile_disabled() -> Optional[str]:
+ """The name of the first set-and-truthy compile-disable env var, else None.
+
+ Mirrors torch's own reading of these variables so the app's decision and
+ torch's behaviour cannot disagree — the state the reporter in #2135 hit,
+ where the log said "torch.compile applied" while TORCH_COMPILE_DISABLE=1
+ was exported.
+ """
+ for name in _COMPILE_DISABLE_ENVS:
+ if os.environ.get(name, "").strip().lower() in _TRUTHY:
+ return name
+ return None
+
+
+def _settings_db_path() -> str:
+ """The settings DB the compile toggle is actually read from (best-effort).
+
+ Logged alongside the toggle because #2135's reporter had three
+ `omnivoice.db` files on the box and edited one the backend never opened;
+ naming the path turns "the setting doesn't work" into a one-line diagnosis.
+ """
+ try:
+ from core.config import DB_PATH
+
+ from core.scrub import scrub_text
+
+ return scrub_text(str(DB_PATH))
+ except Exception:
+ return ""
+
+
# #278: set (with a reason) the first time torch.compile — or *running* the
# compiled model — fails at runtime in this process. Once set, every later
# load in the same session goes straight to eager instead of re-tripping the
@@ -251,6 +297,15 @@ def should_torch_compile(device: str) -> bool:
"""
if device != "cuda":
return False
+ # #2135: honoured before every other gate — an explicit env opt-out is the
+ # user's most direct statement of intent, and it must hold on every
+ # platform (the reporter was on Linux, where this used to be ignored).
+ disabled_by = _env_compile_disabled()
+ if disabled_by is not None:
+ logger.info(
+ "torch.compile skipped: %s is set — using eager mode.", disabled_by,
+ )
+ return False
if importlib.util.find_spec("triton") is None:
logger.info("torch.compile skipped: Triton unavailable — using eager mode.")
return False
@@ -258,8 +313,18 @@ def should_torch_compile(device: str) -> bool:
from services import settings_store
if settings_store.get_text(_TORCH_COMPILE_KEY, "0") == "1":
- logger.info("torch.compile skipped: disabled in Settings (Performance).")
+ logger.info(
+ "torch.compile skipped: disabled in Settings (Performance) [%s].",
+ _settings_db_path(),
+ )
return False
+ # #2135: say which DB answered "not disabled". Without this the only
+ # observable outcome of a toggle that never reached the running
+ # backend is a log line saying compile was applied anyway.
+ logger.debug(
+ "torch.compile: %s not set in %s — compile remains eligible.",
+ _TORCH_COMPILE_KEY, _settings_db_path(),
+ )
except Exception:
logger.exception("should_torch_compile: settings read failed; proceeding")
if _compile_runtime_failure is not None:
@@ -328,19 +393,31 @@ def build_engine_env(
except Exception:
logger.exception("build_engine_env: token resolver failed (non-fatal)")
- # INST-12: TORCH_COMPILE_DISABLE on Windows when the user opted in.
- # The flag is a Windows-only escape hatch — torch.compile OOMs the same
- # Triton kernel cache differently on macOS/Linux, so injecting on those
- # platforms would just slow the engine for no gain. (The in-process
- # should_torch_compile() gate handles the automatic Triton-absence case;
- # the subprocess var stays user-driven by design — see test_perf_settings.)
- if sys.platform.startswith("win"):
- try:
- from services import settings_store
+ # INST-12 (#65), widened to every platform by #2135: TORCH_COMPILE_DISABLE
+ # when the user opted in. This was win32-only on the theory that
+ # torch.compile only misbehaves on Windows (no Triton wheel). #2135 is the
+ # counter-example — a Linux/CUDA host where compile crashes the engine —
+ # and a Settings toggle that silently does nothing on the user's platform
+ # is worse than no toggle at all. Cost when enabled on Linux/macOS is a
+ # slower engine, which is exactly what the user asked for by enabling it.
+ try:
+ from services import settings_store
- if settings_store.get_text(_TORCH_COMPILE_KEY, "0") == "1":
- env["TORCH_COMPILE_DISABLE"] = "1"
- except Exception:
- logger.exception("build_engine_env: torch_compile_disabled read failed")
+ if settings_store.get_text(_TORCH_COMPILE_KEY, "0") == "1":
+ env["TORCH_COMPILE_DISABLE"] = "1"
+ except Exception:
+ logger.exception("build_engine_env: torch_compile_disabled read failed")
+
+ # #2135: an env opt-out on the parent must reach the child too. Without
+ # this a user who exported TORCH_COMPILE_DISABLE=1 got an eager parent and
+ # a compiled sidecar — the inconsistency that made the flag look ignored.
+ disabled_by = _env_compile_disabled()
+ if disabled_by is not None:
+ if env.get("TORCH_COMPILE_DISABLE") != "1":
+ logger.debug(
+ "build_engine_env: %s is set — disabling torch.compile in the "
+ "engine subprocess too.", disabled_by,
+ )
+ env["TORCH_COMPILE_DISABLE"] = "1"
return env
diff --git a/backend/services/model_manager.py b/backend/services/model_manager.py
index 55b844d3..732ac9e3 100644
--- a/backend/services/model_manager.py
+++ b/backend/services/model_manager.py
@@ -1793,6 +1793,64 @@ _TORCH_COMPILE_MODE = "reduce-overhead"
# would not.
_CUDAGRAPH_COMPILE_MODES = frozenset({"reduce-overhead", "max-autotune"})
+# ── #2135: CUDA-graph capture needs Ampere or newer ─────────────────────────
+# On a Turing T4 (sm_75) the cudagraph mode above took the whole backend
+# process down on the first generate — no Python traceback, no HTTP response,
+# just a dead PID (the native capture aborts below the interpreter, so neither
+# the #278 eager fallback nor any `except` can see it). The graph *capture* is
+# the risky part, not Inductor: dropping to the non-cudagraph "default" mode
+# keeps the compiled kernels (and most of the speedup) while removing the
+# crash surface. Ampere (sm_80) is the floor because that is where the app has
+# actual passing evidence; anything older takes the conservative path.
+_CUDAGRAPH_MIN_CAPABILITY = (8, 0)
+# Escape hatch in the other direction, for operators benchmarking on old GPUs.
+_FORCE_CUDAGRAPH_ENV = "OMNIVOICE_FORCE_CUDAGRAPH"
+
+
+def _resolve_compile_mode() -> str:
+ """The ``torch.compile`` mode to use on this GPU (#2135).
+
+ Returns the configured cudagraph mode on Ampere+, and the non-cudagraph
+ ``"default"`` on older architectures where graph capture has been observed
+ to abort the process. Fails *safe* (→ "default") only when we positively
+ identify a pre-Ampere device; any probe error keeps the configured mode so
+ a weird torch build doesn't silently lose the optimization.
+ """
+ if _TORCH_COMPILE_MODE not in _CUDAGRAPH_COMPILE_MODES:
+ return _TORCH_COMPILE_MODE
+ if os.environ.get(_FORCE_CUDAGRAPH_ENV, "").strip().lower() in {"1", "true", "yes", "on"}:
+ logger.warning(
+ "%s=1 — keeping torch.compile mode %r on a GPU where CUDA-graph "
+ "capture is not known-good (#2135).",
+ _FORCE_CUDAGRAPH_ENV, _TORCH_COMPILE_MODE,
+ )
+ return _TORCH_COMPILE_MODE
+ try:
+ import torch
+
+ if not torch.cuda.is_available():
+ return _TORCH_COMPILE_MODE
+ capability = torch.cuda.get_device_capability(0)
+ except Exception:
+ logger.debug("compile-mode capability probe failed; keeping %r",
+ _TORCH_COMPILE_MODE, exc_info=True)
+ return _TORCH_COMPILE_MODE
+ if tuple(capability) >= _CUDAGRAPH_MIN_CAPABILITY:
+ return _TORCH_COMPILE_MODE
+ try:
+ device_name = torch.cuda.get_device_name(0)
+ except Exception:
+ device_name = "this GPU"
+ logger.info(
+ "torch.compile mode %r downgraded to 'default' on %s (sm_%d%d): CUDA-graph "
+ "capture below sm_%d%d has been seen to abort the backend process (#2135). "
+ "Compiled kernels are still used. Set %s=1 to override.",
+ _TORCH_COMPILE_MODE, device_name, capability[0], capability[1],
+ _CUDAGRAPH_MIN_CAPABILITY[0], _CUDAGRAPH_MIN_CAPABILITY[1],
+ _FORCE_CUDAGRAPH_ENV,
+ )
+ return "default"
+
_compiled_inference_executor: "ThreadPoolExecutor | None" = None
_compiled_inference_thread_ident: "int | None" = None
@@ -2586,8 +2644,11 @@ def _load_model_sync():
if not flashinfer_applied and should_torch_compile(device):
_set_loading("compiling", "Compiling model (torch.compile)…")
+ # #2135: resolved per-GPU — pre-Ampere drops to the
+ # non-cudagraph mode rather than risking a native abort.
+ compile_mode = _resolve_compile_mode()
try:
- _model.llm = torch.compile(_model.llm, mode=_TORCH_COMPILE_MODE)
+ _model.llm = torch.compile(_model.llm, mode=compile_mode)
except Exception as compile_exc:
# #278: compile is an optimization, never a point of
# failure — keep the eager model and remember the failure
@@ -2604,7 +2665,7 @@ def _load_model_sync():
# archs, #278). Wrap generate so that falls back to eager
# instead of failing the generation.
_install_compile_fallback(_model)
- if _TORCH_COMPILE_MODE in _CUDAGRAPH_COMPILE_MODES:
+ if compile_mode in _CUDAGRAPH_COMPILE_MODES:
# #315: reduce-overhead uses CUDA graphs, whose
# captured state is thread-local. Pin all inference to
# one dedicated thread so a later render dispatched to
@@ -2615,9 +2676,9 @@ def _load_model_sync():
logger.info(
"torch.compile mode %r uses CUDA graphs — compiled-model "
"inference pinned to a single dedicated thread (#315).",
- _TORCH_COMPILE_MODE,
+ compile_mode,
)
- logger.info("torch.compile applied.")
+ logger.info("torch.compile applied (mode=%r).", compile_mode)
except Exception as e:
logger.info("torch.compile skipped: %s", e)
diff --git a/backend/worker/capacity.py b/backend/worker/capacity.py
index 08b89628..172a586a 100644
--- a/backend/worker/capacity.py
+++ b/backend/worker/capacity.py
@@ -150,7 +150,11 @@ class WorkerCapacity:
worker_id: str
max_concurrent_tasks: int = 1
active_tasks: int = 0
- free_memory_bytes: int = 0
+ # ``None`` means the worker could not query VRAM. It is distinct from a
+ # real zero-byte reading, which means the device is completely occupied.
+ free_memory_bytes: Optional[int] = None
+ cpu_percent: Optional[float] = None
+ gpu_utilization_percent: Optional[float] = None
backend: str = ""
resident_models: set[str] = field(default_factory=set)
slots: dict[str, ModelSlot] = field(default_factory=dict)
@@ -287,6 +291,8 @@ class WorkerCapacity:
available_slots: int,
resident_models: Optional[set[str]] = None,
free_memory_bytes: Optional[int] = None,
+ cpu_percent: Optional[float] = None,
+ gpu_utilization_percent: Optional[float] = None,
now: Optional[float] = None,
) -> None:
"""Adopt a heartbeat snapshot. The worker is the source of truth for
@@ -307,6 +313,10 @@ class WorkerCapacity:
self.resident_models = set(resident_models)
if free_memory_bytes is not None:
self.free_memory_bytes = free_memory_bytes
+ if cpu_percent is not None:
+ self.cpu_percent = max(0.0, min(100.0, float(cpu_percent)))
+ if gpu_utilization_percent is not None:
+ self.gpu_utilization_percent = max(0.0, min(100.0, float(gpu_utilization_percent)))
# Parks are released on a timer, and by the worker restarting — never
# by the worker's own load report.
#
@@ -330,6 +340,9 @@ class WorkerCapacity:
"zombie_tasks": self.zombie_tasks,
"available_slots": self.available_slots,
"resident_models": sorted(self.resident_models),
+ "free_memory_bytes": self.free_memory_bytes,
+ "cpu_percent": self.cpu_percent,
+ "gpu_utilization_percent": self.gpu_utilization_percent,
}
diff --git a/backend/worker/pool.py b/backend/worker/pool.py
index b8f3b981..5cca9ba5 100644
--- a/backend/worker/pool.py
+++ b/backend/worker/pool.py
@@ -305,6 +305,8 @@ class WorkerPool:
available_slots: int,
resident_models: Optional[set[str]] = None,
free_memory_bytes: Optional[int] = None,
+ cpu_percent: Optional[float] = None,
+ gpu_utilization_percent: Optional[float] = None,
latency_ms: Optional[float] = None,
now: Optional[float] = None,
) -> Optional[ConnectedWorker]:
@@ -319,6 +321,8 @@ class WorkerPool:
available_slots=available_slots,
resident_models=resident_models,
free_memory_bytes=free_memory_bytes,
+ cpu_percent=cpu_percent,
+ gpu_utilization_percent=gpu_utilization_percent,
)
return worker
diff --git a/backend/worker/protocol/gen/worker_v1_pb2.py b/backend/worker/protocol/gen/worker_v1_pb2.py
index 27d410fb..e6a948fc 100644
--- a/backend/worker/protocol/gen/worker_v1_pb2.py
+++ b/backend/worker/protocol/gen/worker_v1_pb2.py
@@ -24,7 +24,7 @@ _sym_db = _symbol_database.Default()
-DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x0fworker_v1.proto\x12\x13omnivoice.worker.v1\"E\n\x07TaskRef\x12\x0f\n\x07task_id\x18\x01 \x01(\t\x12\x12\n\nattempt_id\x18\x02 \x01(\t\x12\x15\n\rsession_epoch\x18\x03 \x01(\x04\"A\n\x08\x45nvelope\x12\x10\n\x08sequence\x18\x01 \x01(\x04\x12\x10\n\x08trace_id\x18\x02 \x01(\t\x12\x11\n\ttenant_id\x18\x03 \x01(\t\"j\n\x05\x45rror\x12\x34\n\x0b\x65rror_class\x18\x01 \x01(\x0e\x32\x1f.omnivoice.worker.v1.ErrorClass\x12\x0c\n\x04\x63ode\x18\x02 \x01(\t\x12\x0f\n\x07message\x18\x03 \x01(\t\x12\x0c\n\x04hint\x18\x04 \x01(\t\"\x9e\x01\n\x07GpuInfo\x12\x0e\n\x06vendor\x18\x01 \x01(\t\x12\r\n\x05model\x18\x02 \x01(\t\x12\x0f\n\x07\x62\x61\x63kend\x18\x03 \x01(\t\x12\x14\n\x0cmemory_bytes\x18\x04 \x01(\x04\x12\x19\n\x11\x66ree_memory_bytes\x18\x05 \x01(\x04\x12\x16\n\x0e\x64river_version\x18\x06 \x01(\t\x12\x1a\n\x12\x63ompute_capability\x18\x07 \x01(\t\"\xaa\x01\n\x08HostInfo\x12\x10\n\x08hostname\x18\x01 \x01(\t\x12\n\n\x02os\x18\x02 \x01(\t\x12\x0c\n\x04\x61rch\x18\x03 \x01(\t\x12\x16\n\x0eworker_version\x18\x04 \x01(\t\x12\x11\n\tcpu_count\x18\x05 \x01(\r\x12\x1b\n\x13system_memory_bytes\x18\x06 \x01(\x04\x12*\n\x04gpus\x18\x07 \x03(\x0b\x32\x1c.omnivoice.worker.v1.GpuInfo\"\xc7\x02\n\x0fModelCapability\x12\x0e\n\x06\x65ngine\x18\x01 \x01(\t\x12\x10\n\x08model_id\x18\x02 \x01(\t\x12\x12\n\noperations\x18\x03 \x03(\t\x12\x11\n\tsupported\x18\x04 \x01(\x08\x12\x11\n\tinstalled\x18\x05 \x01(\x08\x12\x12\n\ndownloaded\x18\x06 \x01(\x08\x12\x10\n\x08resident\x18\x07 \x01(\x08\x12\x18\n\x10min_memory_bytes\x18\x08 \x01(\x04\x12\x11\n\tprecision\x18\t \x01(\t\x12\x1b\n\x13\x64\x65rived_concurrency\x18\n \x01(\r\x12\x14\n\x0c\x63pu_fallback\x18\x0b \x01(\x08\x12\x10\n\x08repo_ids\x18\x0c \x03(\t\x12\x14\n\x0c\x64isplay_name\x18\r \x01(\t\x12\x0f\n\x07\x62\x61\x63kend\x18\x0e \x01(\t\x12\x19\n\x11\x66ree_memory_bytes\x18\x0f \x01(\x04\"\x82\x05\n\x0fRegisterRequest\x12/\n\x08\x65nvelope\x18\x01 \x01(\x0b\x32\x1d.omnivoice.worker.v1.Envelope\x12\x1c\n\x14protocol_version_min\x18\x02 \x01(\r\x12\x1c\n\x14protocol_version_max\x18\x03 \x01(\r\x12\x18\n\x10\x65nrollment_token\x18\x04 \x01(\t\x12\x11\n\tworker_id\x18\x05 \x01(\t\x12\x12\n\npublic_key\x18\x06 \x01(\x0c\x12\x1b\n\x13\x63hallenge_signature\x18\x07 \x01(\x0c\x12\x11\n\tchallenge\x18\x08 \x01(\x0c\x12+\n\x04host\x18\t \x01(\x0b\x32\x1d.omnivoice.worker.v1.HostInfo\x12:\n\x0c\x63\x61pabilities\x18\n \x03(\x0b\x32$.omnivoice.worker.v1.ModelCapability\x12\x1c\n\x14max_concurrent_tasks\x18\x0b \x01(\r\x12/\n\tin_flight\x18\x0c \x03(\x0b\x32\x1c.omnivoice.worker.v1.TaskRef\x12\x37\n\x11\x63ompleted_unacked\x18\r \x03(\x0b\x32\x1c.omnivoice.worker.v1.TaskRef\x12\x0e\n\x06key_id\x18\x0e \x01(\t\x12\r\n\x05nonce\x18\x0f \x01(\x0c\x12@\n\x06labels\x18\x10 \x03(\x0b\x32\x30.omnivoice.worker.v1.RegisterRequest.LabelsEntry\x12\x10\n\x08\x66\x65\x61tures\x18\x11 \x03(\t\x1a-\n\x0bLabelsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"\xcd\x02\n\x10RegisterResponse\x12/\n\x08\x65nvelope\x18\x01 \x01(\x0b\x32\x1d.omnivoice.worker.v1.Envelope\x12\x11\n\tworker_id\x18\x02 \x01(\t\x12\x15\n\rsession_token\x18\x03 \x01(\t\x12\x15\n\rsession_epoch\x18\x04 \x01(\x04\x12\x18\n\x10protocol_version\x18\x05 \x01(\r\x12\x1f\n\x17session_expires_at_unix\x18\x06 \x01(\x03\x12\"\n\x1aheartbeat_interval_seconds\x18\x07 \x01(\r\x12=\n\x17\x61uthoritative_in_flight\x18\x08 \x03(\x0b\x32\x1c.omnivoice.worker.v1.TaskRef\x12)\n\x05\x65rror\x18\t \x01(\x0b\x32\x1a.omnivoice.worker.v1.Error\"\xb4\x01\n\tHeartbeat\x12/\n\x08\x65nvelope\x18\x01 \x01(\x0b\x32\x1d.omnivoice.worker.v1.Envelope\x12\x14\n\x0c\x61\x63tive_tasks\x18\x02 \x01(\r\x12\x17\n\x0f\x61vailable_slots\x18\x03 \x01(\r\x12\x17\n\x0fresident_models\x18\x04 \x03(\t\x12\x19\n\x11\x66ree_memory_bytes\x18\x05 \x01(\x04\x12\x13\n\x0b\x63pu_percent\x18\x06 \x01(\x01\"j\n\x0cTaskAccepted\x12)\n\x03ref\x18\x01 \x01(\x0b\x32\x1c.omnivoice.worker.v1.TaskRef\x12/\n\x08\x65nvelope\x18\x02 \x01(\x0b\x32\x1d.omnivoice.worker.v1.Envelope\"\x95\x01\n\x0cTaskRejected\x12)\n\x03ref\x18\x01 \x01(\x0b\x32\x1c.omnivoice.worker.v1.TaskRef\x12/\n\x08\x65nvelope\x18\x02 \x01(\x0b\x32\x1d.omnivoice.worker.v1.Envelope\x12)\n\x05\x65rror\x18\x03 \x01(\x0b\x32\x1a.omnivoice.worker.v1.Error\"\xb5\x01\n\x10TaskModelLoading\x12)\n\x03ref\x18\x01 \x01(\x0b\x32\x1c.omnivoice.worker.v1.TaskRef\x12/\n\x08\x65nvelope\x18\x02 \x01(\x0b\x32\x1d.omnivoice.worker.v1.Envelope\x12\x0e\n\x06\x65ngine\x18\x03 \x01(\t\x12\x10\n\x08progress\x18\x04 \x01(\x01\x12\x0e\n\x06\x64\x65tail\x18\x05 \x01(\t\x12\x13\n\x0b\x65ta_seconds\x18\x06 \x01(\x04\"i\n\x0bTaskStarted\x12)\n\x03ref\x18\x01 \x01(\x0b\x32\x1c.omnivoice.worker.v1.TaskRef\x12/\n\x08\x65nvelope\x18\x02 \x01(\x0b\x32\x1d.omnivoice.worker.v1.Envelope\"\xae\x01\n\x0cTaskProgress\x12)\n\x03ref\x18\x01 \x01(\x0b\x32\x1c.omnivoice.worker.v1.TaskRef\x12/\n\x08\x65nvelope\x18\x02 \x01(\x0b\x32\x1d.omnivoice.worker.v1.Envelope\x12\x10\n\x08progress\x18\x03 \x01(\x01\x12\r\n\x05stage\x18\x04 \x01(\t\x12\x0e\n\x06\x64\x65tail\x18\x05 \x01(\t\x12\x11\n\tkeepalive\x18\x06 \x01(\x08\"\xc2\x01\n\x0bUsageReport\x12\x18\n\x10\x61udio_seconds_in\x18\x01 \x01(\x01\x12\x19\n\x11\x61udio_seconds_out\x18\x02 \x01(\x01\x12\x15\n\rcharacters_in\x18\x03 \x01(\x04\x12\x14\n\x0cwall_seconds\x18\x04 \x01(\x01\x12\x13\n\x0bgpu_seconds\x18\x05 \x01(\x01\x12\x1a\n\x12model_load_seconds\x18\x06 \x01(\x01\x12\x0e\n\x06\x65ngine\x18\x07 \x01(\t\x12\x10\n\x08model_id\x18\x08 \x01(\t\"\xfb\x01\n\nTaskResult\x12)\n\x03ref\x18\x01 \x01(\x0b\x32\x1c.omnivoice.worker.v1.TaskRef\x12/\n\x08\x65nvelope\x18\x02 \x01(\x0b\x32\x1d.omnivoice.worker.v1.Envelope\x12\x16\n\x0einline_payload\x18\x03 \x01(\x0c\x12\x33\n\tartifacts\x18\x04 \x03(\x0b\x32 .omnivoice.worker.v1.ArtifactRef\x12\x13\n\x0bresult_json\x18\x05 \x01(\t\x12/\n\x05usage\x18\x06 \x01(\x0b\x32 .omnivoice.worker.v1.UsageReport\"\xc4\x01\n\nTaskFailed\x12)\n\x03ref\x18\x01 \x01(\x0b\x32\x1c.omnivoice.worker.v1.TaskRef\x12/\n\x08\x65nvelope\x18\x02 \x01(\x0b\x32\x1d.omnivoice.worker.v1.Envelope\x12)\n\x05\x65rror\x18\x03 \x01(\x0b\x32\x1a.omnivoice.worker.v1.Error\x12/\n\x05usage\x18\x04 \x01(\x0b\x32 .omnivoice.worker.v1.UsageReport\"k\n\rTaskCancelAck\x12)\n\x03ref\x18\x01 \x01(\x0b\x32\x1c.omnivoice.worker.v1.TaskRef\x12/\n\x08\x65nvelope\x18\x02 \x01(\x0b\x32\x1d.omnivoice.worker.v1.Envelope\"F\n\x04Pong\x12/\n\x08\x65nvelope\x18\x01 \x01(\x0b\x32\x1d.omnivoice.worker.v1.Envelope\x12\r\n\x05nonce\x18\x02 \x01(\x04\"\x82\x01\n\rWorkerGoodbye\x12/\n\x08\x65nvelope\x18\x01 \x01(\x0b\x32\x1d.omnivoice.worker.v1.Envelope\x12\x0e\n\x06reason\x18\x02 \x01(\t\x12\x30\n\nabandoning\x18\x03 \x03(\x0b\x32\x1c.omnivoice.worker.v1.TaskRef\"\x7f\n\x10\x43\x61pabilityUpdate\x12/\n\x08\x65nvelope\x18\x01 \x01(\x0b\x32\x1d.omnivoice.worker.v1.Envelope\x12:\n\x0c\x63\x61pabilities\x18\x02 \x03(\x0b\x32$.omnivoice.worker.v1.ModelCapability\"W\n\x10\x44ownloadProgress\x12/\n\x08\x65nvelope\x18\x01 \x01(\x0b\x32\x1d.omnivoice.worker.v1.Envelope\x12\x12\n\nevent_json\x18\x02 \x01(\t\"\xae\x06\n\rWorkerMessage\x12\x33\n\theartbeat\x18\x01 \x01(\x0b\x32\x1e.omnivoice.worker.v1.HeartbeatH\x00\x12\x35\n\x08\x61\x63\x63\x65pted\x18\x02 \x01(\x0b\x32!.omnivoice.worker.v1.TaskAcceptedH\x00\x12\x35\n\x08rejected\x18\x03 \x01(\x0b\x32!.omnivoice.worker.v1.TaskRejectedH\x00\x12>\n\rmodel_loading\x18\x04 \x01(\x0b\x32%.omnivoice.worker.v1.TaskModelLoadingH\x00\x12\x33\n\x07started\x18\x05 \x01(\x0b\x32 .omnivoice.worker.v1.TaskStartedH\x00\x12\x35\n\x08progress\x18\x06 \x01(\x0b\x32!.omnivoice.worker.v1.TaskProgressH\x00\x12\x31\n\x06result\x18\x07 \x01(\x0b\x32\x1f.omnivoice.worker.v1.TaskResultH\x00\x12\x31\n\x06\x66\x61iled\x18\x08 \x01(\x0b\x32\x1f.omnivoice.worker.v1.TaskFailedH\x00\x12\x38\n\ncancel_ack\x18\t \x01(\x0b\x32\".omnivoice.worker.v1.TaskCancelAckH\x00\x12=\n\x0c\x63\x61pabilities\x18\n \x01(\x0b\x32%.omnivoice.worker.v1.CapabilityUpdateH\x00\x12\x35\n\x07goodbye\x18\x0b \x01(\x0b\x32\".omnivoice.worker.v1.WorkerGoodbyeH\x00\x12)\n\x04pong\x18\x0c \x01(\x0b\x32\x19.omnivoice.worker.v1.PongH\x00\x12\x42\n\x11\x64ownload_progress\x18\r \x01(\x0b\x32%.omnivoice.worker.v1.DownloadProgressH\x00\x12\x38\n\x08register\x18\x0f \x01(\x0b\x32$.omnivoice.worker.v1.RegisterRequestH\x00\x42\t\n\x07payloadJ\x04\x08\x0e\x10\x0f\"\x9b\x01\n\tDeadlines\x12\x16\n\x0e\x61\x63\x63\x65pt_seconds\x18\x01 \x01(\r\x12\x1a\n\x12model_load_seconds\x18\x02 \x01(\r\x12\x19\n\x11\x65xecution_seconds\x18\x03 \x01(\r\x12\x1e\n\x16progress_lease_seconds\x18\x04 \x01(\r\x12\x1f\n\x17result_delivery_seconds\x18\x05 \x01(\r\"\xd7\x03\n\x0eTaskAssignment\x12)\n\x03ref\x18\x01 \x01(\x0b\x32\x1c.omnivoice.worker.v1.TaskRef\x12/\n\x08\x65nvelope\x18\x02 \x01(\x0b\x32\x1d.omnivoice.worker.v1.Envelope\x12\x11\n\toperation\x18\x03 \x01(\t\x12\x0e\n\x06\x65ngine\x18\x04 \x01(\t\x12\x10\n\x08model_id\x18\x05 \x01(\t\x12\x13\n\x0bparams_json\x18\x06 \x01(\t\x12\x30\n\x06inputs\x18\x07 \x03(\x0b\x32 .omnivoice.worker.v1.ArtifactRef\x12\x31\n\tdeadlines\x18\x08 \x01(\x0b\x32\x1e.omnivoice.worker.v1.Deadlines\x12\x16\n\x0epriority_class\x18\t \x01(\r\x12\x16\n\x0e\x61ttempt_number\x18\n \x01(\r\x12\x14\n\x0cmax_attempts\x18\x0b \x01(\r\x12\x43\n\x08metadata\x18\x0c \x03(\x0b\x32\x31.omnivoice.worker.v1.TaskAssignment.MetadataEntry\x1a/\n\rMetadataEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"x\n\nTaskCancel\x12)\n\x03ref\x18\x01 \x01(\x0b\x32\x1c.omnivoice.worker.v1.TaskRef\x12/\n\x08\x65nvelope\x18\x02 \x01(\x0b\x32\x1d.omnivoice.worker.v1.Envelope\x12\x0e\n\x06reason\x18\x03 \x01(\t\"n\n\x10ResultAckMessage\x12)\n\x03ref\x18\x01 \x01(\x0b\x32\x1c.omnivoice.worker.v1.TaskRef\x12/\n\x08\x65nvelope\x18\x02 \x01(\x0b\x32\x1d.omnivoice.worker.v1.Envelope\"\xa8\x01\n\x0c\x43onfigUpdate\x12/\n\x08\x65nvelope\x18\x01 \x01(\x0b\x32\x1d.omnivoice.worker.v1.Envelope\x12\"\n\x1aheartbeat_interval_seconds\x18\x02 \x01(\r\x12\x1c\n\x14max_concurrent_tasks\x18\x03 \x01(\r\x12%\n\x1dinline_result_threshold_bytes\x18\x04 \x01(\x04\"\x80\x01\n\x0ePrewarmRequest\x12/\n\x08\x65nvelope\x18\x01 \x01(\x0b\x32\x1d.omnivoice.worker.v1.Envelope\x12\x0e\n\x06\x65ngine\x18\x02 \x01(\t\x12\x10\n\x08model_id\x18\x03 \x01(\t\x12\x1b\n\x13\x64ownload_if_missing\x18\x04 \x01(\x08\"^\n\x19ModelInstallCancelRequest\x12/\n\x08\x65nvelope\x18\x01 \x01(\x0b\x32\x1d.omnivoice.worker.v1.Envelope\x12\x10\n\x08model_id\x18\x02 \x01(\t\"F\n\x04Ping\x12/\n\x08\x65nvelope\x18\x01 \x01(\x0b\x32\x1d.omnivoice.worker.v1.Envelope\x12\r\n\x05nonce\x18\x02 \x01(\x04\"h\n\x05\x44rain\x12/\n\x08\x65nvelope\x18\x01 \x01(\x0b\x32\x1d.omnivoice.worker.v1.Envelope\x12\x18\n\x10\x64\x65\x61\x64line_seconds\x18\x02 \x01(\r\x12\x14\n\x0creconnect_to\x18\x03 \x01(\t\"K\n\x08Shutdown\x12/\n\x08\x65nvelope\x18\x01 \x01(\x0b\x32\x1d.omnivoice.worker.v1.Envelope\x12\x0e\n\x06reason\x18\x02 \x01(\t\"\xca\x04\n\rServerMessage\x12\x39\n\nassignment\x18\x01 \x01(\x0b\x32#.omnivoice.worker.v1.TaskAssignmentH\x00\x12\x31\n\x06\x63\x61ncel\x18\x02 \x01(\x0b\x32\x1f.omnivoice.worker.v1.TaskCancelH\x00\x12;\n\nresult_ack\x18\x03 \x01(\x0b\x32%.omnivoice.worker.v1.ResultAckMessageH\x00\x12\x33\n\x06\x63onfig\x18\x04 \x01(\x0b\x32!.omnivoice.worker.v1.ConfigUpdateH\x00\x12)\n\x04ping\x18\x05 \x01(\x0b\x32\x19.omnivoice.worker.v1.PingH\x00\x12+\n\x05\x64rain\x18\x06 \x01(\x0b\x32\x1a.omnivoice.worker.v1.DrainH\x00\x12\x31\n\x08shutdown\x18\x07 \x01(\x0b\x32\x1d.omnivoice.worker.v1.ShutdownH\x00\x12\x36\n\x07prewarm\x18\x08 \x01(\x0b\x32#.omnivoice.worker.v1.PrewarmRequestH\x00\x12;\n\nregistered\x18\t \x01(\x0b\x32%.omnivoice.worker.v1.RegisterResponseH\x00\x12N\n\x14model_install_cancel\x18\n \x01(\x0b\x32..omnivoice.worker.v1.ModelInstallCancelRequestH\x00\x42\t\n\x07payload\"\xaa\x01\n\x0b\x41rtifactRef\x12\x13\n\x0b\x61rtifact_id\x18\x01 \x01(\t\x12\x0f\n\x07task_id\x18\x02 \x01(\t\x12\x12\n\nattempt_id\x18\x03 \x01(\t\x12\x10\n\x08\x66ilename\x18\x04 \x01(\t\x12\x14\n\x0c\x63ontent_type\x18\x05 \x01(\t\x12\x12\n\nsize_bytes\x18\x06 \x01(\x04\x12\x0e\n\x06sha256\x18\x07 \x01(\t\x12\x15\n\rsession_token\x18\x08 \x01(\t\"j\n\rArtifactChunk\x12-\n\x03ref\x18\x01 \x01(\x0b\x32 .omnivoice.worker.v1.ArtifactRef\x12\x0e\n\x06offset\x18\x02 \x01(\x04\x12\x0c\n\x04\x64\x61ta\x18\x03 \x01(\x0c\x12\x0c\n\x04last\x18\x04 \x01(\x08\"\x7f\n\x0bResultChunk\x12-\n\x03ref\x18\x01 \x01(\x0b\x32 .omnivoice.worker.v1.ArtifactRef\x12\x0e\n\x06offset\x18\x02 \x01(\x04\x12\x0c\n\x04\x64\x61ta\x18\x03 \x01(\x0c\x12\x0c\n\x04last\x18\x04 \x01(\x08\x12\x15\n\rsession_token\x18\x05 \x01(\t\"v\n\tResultAck\x12\x13\n\x0b\x61rtifact_id\x18\x01 \x01(\t\x12\x16\n\x0e\x62ytes_received\x18\x02 \x01(\x04\x12\x11\n\tcommitted\x18\x03 \x01(\x08\x12)\n\x05\x65rror\x18\x04 \x01(\x0b\x32\x1a.omnivoice.worker.v1.Error\"x\n\x0b\x41rtifactAck\x12\x13\n\x0b\x61rtifact_id\x18\x01 \x01(\t\x12\x16\n\x0e\x62ytes_received\x18\x02 \x01(\x04\x12\x11\n\tcommitted\x18\x03 \x01(\x08\x12)\n\x05\x65rror\x18\x04 \x01(\x0b\x32\x1a.omnivoice.worker.v1.Error*\xc7\x01\n\nErrorClass\x12\x1b\n\x17\x45RROR_CLASS_UNSPECIFIED\x10\x00\x12\x19\n\x15\x45RROR_CLASS_TRANSIENT\x10\x01\x12\x1a\n\x16\x45RROR_CLASS_CAPABILITY\x10\x02\x12\x18\n\x14\x45RROR_CLASS_TERMINAL\x10\x03\x12\x18\n\x14\x45RROR_CLASS_CAPACITY\x10\x04\x12\x17\n\x13\x45RROR_CLASS_TIMEOUT\x10\x05\x12\x18\n\x14\x45RROR_CLASS_PROTOCOL\x10\x06\x32\xef\x02\n\rWorkerService\x12W\n\x08Register\x12$.omnivoice.worker.v1.RegisterRequest\x1a%.omnivoice.worker.v1.RegisterResponse\x12U\n\x07\x43ontrol\x12\".omnivoice.worker.v1.WorkerMessage\x1a\".omnivoice.worker.v1.ServerMessage(\x01\x30\x01\x12R\n\x0cUploadResult\x12 .omnivoice.worker.v1.ResultChunk\x1a\x1e.omnivoice.worker.v1.ResultAck(\x01\x12Z\n\x10\x44ownloadArtifact\x12 .omnivoice.worker.v1.ArtifactRef\x1a\".omnivoice.worker.v1.ArtifactChunk0\x01\x32\x8d\x02\n\x0bNodeService\x12T\n\x06\x41ttach\x12\".omnivoice.worker.v1.ServerMessage\x1a\".omnivoice.worker.v1.WorkerMessage(\x01\x30\x01\x12S\n\x0b\x46\x65tchResult\x12 .omnivoice.worker.v1.ArtifactRef\x1a .omnivoice.worker.v1.ResultChunk0\x01\x12S\n\tPushInput\x12\".omnivoice.worker.v1.ArtifactChunk\x1a .omnivoice.worker.v1.ArtifactAck(\x01\x62\x06proto3')
+DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x0fworker_v1.proto\x12\x13omnivoice.worker.v1\"E\n\x07TaskRef\x12\x0f\n\x07task_id\x18\x01 \x01(\t\x12\x12\n\nattempt_id\x18\x02 \x01(\t\x12\x15\n\rsession_epoch\x18\x03 \x01(\x04\"A\n\x08\x45nvelope\x12\x10\n\x08sequence\x18\x01 \x01(\x04\x12\x10\n\x08trace_id\x18\x02 \x01(\t\x12\x11\n\ttenant_id\x18\x03 \x01(\t\"j\n\x05\x45rror\x12\x34\n\x0b\x65rror_class\x18\x01 \x01(\x0e\x32\x1f.omnivoice.worker.v1.ErrorClass\x12\x0c\n\x04\x63ode\x18\x02 \x01(\t\x12\x0f\n\x07message\x18\x03 \x01(\t\x12\x0c\n\x04hint\x18\x04 \x01(\t\"\x9e\x01\n\x07GpuInfo\x12\x0e\n\x06vendor\x18\x01 \x01(\t\x12\r\n\x05model\x18\x02 \x01(\t\x12\x0f\n\x07\x62\x61\x63kend\x18\x03 \x01(\t\x12\x14\n\x0cmemory_bytes\x18\x04 \x01(\x04\x12\x19\n\x11\x66ree_memory_bytes\x18\x05 \x01(\x04\x12\x16\n\x0e\x64river_version\x18\x06 \x01(\t\x12\x1a\n\x12\x63ompute_capability\x18\x07 \x01(\t\"\xaa\x01\n\x08HostInfo\x12\x10\n\x08hostname\x18\x01 \x01(\t\x12\n\n\x02os\x18\x02 \x01(\t\x12\x0c\n\x04\x61rch\x18\x03 \x01(\t\x12\x16\n\x0eworker_version\x18\x04 \x01(\t\x12\x11\n\tcpu_count\x18\x05 \x01(\r\x12\x1b\n\x13system_memory_bytes\x18\x06 \x01(\x04\x12*\n\x04gpus\x18\x07 \x03(\x0b\x32\x1c.omnivoice.worker.v1.GpuInfo\"\xc7\x02\n\x0fModelCapability\x12\x0e\n\x06\x65ngine\x18\x01 \x01(\t\x12\x10\n\x08model_id\x18\x02 \x01(\t\x12\x12\n\noperations\x18\x03 \x03(\t\x12\x11\n\tsupported\x18\x04 \x01(\x08\x12\x11\n\tinstalled\x18\x05 \x01(\x08\x12\x12\n\ndownloaded\x18\x06 \x01(\x08\x12\x10\n\x08resident\x18\x07 \x01(\x08\x12\x18\n\x10min_memory_bytes\x18\x08 \x01(\x04\x12\x11\n\tprecision\x18\t \x01(\t\x12\x1b\n\x13\x64\x65rived_concurrency\x18\n \x01(\r\x12\x14\n\x0c\x63pu_fallback\x18\x0b \x01(\x08\x12\x10\n\x08repo_ids\x18\x0c \x03(\t\x12\x14\n\x0c\x64isplay_name\x18\r \x01(\t\x12\x0f\n\x07\x62\x61\x63kend\x18\x0e \x01(\t\x12\x19\n\x11\x66ree_memory_bytes\x18\x0f \x01(\x04\"\x82\x05\n\x0fRegisterRequest\x12/\n\x08\x65nvelope\x18\x01 \x01(\x0b\x32\x1d.omnivoice.worker.v1.Envelope\x12\x1c\n\x14protocol_version_min\x18\x02 \x01(\r\x12\x1c\n\x14protocol_version_max\x18\x03 \x01(\r\x12\x18\n\x10\x65nrollment_token\x18\x04 \x01(\t\x12\x11\n\tworker_id\x18\x05 \x01(\t\x12\x12\n\npublic_key\x18\x06 \x01(\x0c\x12\x1b\n\x13\x63hallenge_signature\x18\x07 \x01(\x0c\x12\x11\n\tchallenge\x18\x08 \x01(\x0c\x12+\n\x04host\x18\t \x01(\x0b\x32\x1d.omnivoice.worker.v1.HostInfo\x12:\n\x0c\x63\x61pabilities\x18\n \x03(\x0b\x32$.omnivoice.worker.v1.ModelCapability\x12\x1c\n\x14max_concurrent_tasks\x18\x0b \x01(\r\x12/\n\tin_flight\x18\x0c \x03(\x0b\x32\x1c.omnivoice.worker.v1.TaskRef\x12\x37\n\x11\x63ompleted_unacked\x18\r \x03(\x0b\x32\x1c.omnivoice.worker.v1.TaskRef\x12\x0e\n\x06key_id\x18\x0e \x01(\t\x12\r\n\x05nonce\x18\x0f \x01(\x0c\x12@\n\x06labels\x18\x10 \x03(\x0b\x32\x30.omnivoice.worker.v1.RegisterRequest.LabelsEntry\x12\x10\n\x08\x66\x65\x61tures\x18\x11 \x03(\t\x1a-\n\x0bLabelsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"\xcd\x02\n\x10RegisterResponse\x12/\n\x08\x65nvelope\x18\x01 \x01(\x0b\x32\x1d.omnivoice.worker.v1.Envelope\x12\x11\n\tworker_id\x18\x02 \x01(\t\x12\x15\n\rsession_token\x18\x03 \x01(\t\x12\x15\n\rsession_epoch\x18\x04 \x01(\x04\x12\x18\n\x10protocol_version\x18\x05 \x01(\r\x12\x1f\n\x17session_expires_at_unix\x18\x06 \x01(\x03\x12\"\n\x1aheartbeat_interval_seconds\x18\x07 \x01(\r\x12=\n\x17\x61uthoritative_in_flight\x18\x08 \x03(\x0b\x32\x1c.omnivoice.worker.v1.TaskRef\x12)\n\x05\x65rror\x18\t \x01(\x0b\x32\x1a.omnivoice.worker.v1.Error\"\xa6\x02\n\tHeartbeat\x12/\n\x08\x65nvelope\x18\x01 \x01(\x0b\x32\x1d.omnivoice.worker.v1.Envelope\x12\x14\n\x0c\x61\x63tive_tasks\x18\x02 \x01(\r\x12\x17\n\x0f\x61vailable_slots\x18\x03 \x01(\r\x12\x17\n\x0fresident_models\x18\x04 \x03(\t\x12\x1e\n\x11\x66ree_memory_bytes\x18\x05 \x01(\x04H\x00\x88\x01\x01\x12\x18\n\x0b\x63pu_percent\x18\x06 \x01(\x01H\x01\x88\x01\x01\x12$\n\x17gpu_utilization_percent\x18\x07 \x01(\x01H\x02\x88\x01\x01\x42\x14\n\x12_free_memory_bytesB\x0e\n\x0c_cpu_percentB\x1a\n\x18_gpu_utilization_percent\"j\n\x0cTaskAccepted\x12)\n\x03ref\x18\x01 \x01(\x0b\x32\x1c.omnivoice.worker.v1.TaskRef\x12/\n\x08\x65nvelope\x18\x02 \x01(\x0b\x32\x1d.omnivoice.worker.v1.Envelope\"\x95\x01\n\x0cTaskRejected\x12)\n\x03ref\x18\x01 \x01(\x0b\x32\x1c.omnivoice.worker.v1.TaskRef\x12/\n\x08\x65nvelope\x18\x02 \x01(\x0b\x32\x1d.omnivoice.worker.v1.Envelope\x12)\n\x05\x65rror\x18\x03 \x01(\x0b\x32\x1a.omnivoice.worker.v1.Error\"\xb5\x01\n\x10TaskModelLoading\x12)\n\x03ref\x18\x01 \x01(\x0b\x32\x1c.omnivoice.worker.v1.TaskRef\x12/\n\x08\x65nvelope\x18\x02 \x01(\x0b\x32\x1d.omnivoice.worker.v1.Envelope\x12\x0e\n\x06\x65ngine\x18\x03 \x01(\t\x12\x10\n\x08progress\x18\x04 \x01(\x01\x12\x0e\n\x06\x64\x65tail\x18\x05 \x01(\t\x12\x13\n\x0b\x65ta_seconds\x18\x06 \x01(\x04\"i\n\x0bTaskStarted\x12)\n\x03ref\x18\x01 \x01(\x0b\x32\x1c.omnivoice.worker.v1.TaskRef\x12/\n\x08\x65nvelope\x18\x02 \x01(\x0b\x32\x1d.omnivoice.worker.v1.Envelope\"\xae\x01\n\x0cTaskProgress\x12)\n\x03ref\x18\x01 \x01(\x0b\x32\x1c.omnivoice.worker.v1.TaskRef\x12/\n\x08\x65nvelope\x18\x02 \x01(\x0b\x32\x1d.omnivoice.worker.v1.Envelope\x12\x10\n\x08progress\x18\x03 \x01(\x01\x12\r\n\x05stage\x18\x04 \x01(\t\x12\x0e\n\x06\x64\x65tail\x18\x05 \x01(\t\x12\x11\n\tkeepalive\x18\x06 \x01(\x08\"\xc2\x01\n\x0bUsageReport\x12\x18\n\x10\x61udio_seconds_in\x18\x01 \x01(\x01\x12\x19\n\x11\x61udio_seconds_out\x18\x02 \x01(\x01\x12\x15\n\rcharacters_in\x18\x03 \x01(\x04\x12\x14\n\x0cwall_seconds\x18\x04 \x01(\x01\x12\x13\n\x0bgpu_seconds\x18\x05 \x01(\x01\x12\x1a\n\x12model_load_seconds\x18\x06 \x01(\x01\x12\x0e\n\x06\x65ngine\x18\x07 \x01(\t\x12\x10\n\x08model_id\x18\x08 \x01(\t\"\xfb\x01\n\nTaskResult\x12)\n\x03ref\x18\x01 \x01(\x0b\x32\x1c.omnivoice.worker.v1.TaskRef\x12/\n\x08\x65nvelope\x18\x02 \x01(\x0b\x32\x1d.omnivoice.worker.v1.Envelope\x12\x16\n\x0einline_payload\x18\x03 \x01(\x0c\x12\x33\n\tartifacts\x18\x04 \x03(\x0b\x32 .omnivoice.worker.v1.ArtifactRef\x12\x13\n\x0bresult_json\x18\x05 \x01(\t\x12/\n\x05usage\x18\x06 \x01(\x0b\x32 .omnivoice.worker.v1.UsageReport\"\xc4\x01\n\nTaskFailed\x12)\n\x03ref\x18\x01 \x01(\x0b\x32\x1c.omnivoice.worker.v1.TaskRef\x12/\n\x08\x65nvelope\x18\x02 \x01(\x0b\x32\x1d.omnivoice.worker.v1.Envelope\x12)\n\x05\x65rror\x18\x03 \x01(\x0b\x32\x1a.omnivoice.worker.v1.Error\x12/\n\x05usage\x18\x04 \x01(\x0b\x32 .omnivoice.worker.v1.UsageReport\"k\n\rTaskCancelAck\x12)\n\x03ref\x18\x01 \x01(\x0b\x32\x1c.omnivoice.worker.v1.TaskRef\x12/\n\x08\x65nvelope\x18\x02 \x01(\x0b\x32\x1d.omnivoice.worker.v1.Envelope\"F\n\x04Pong\x12/\n\x08\x65nvelope\x18\x01 \x01(\x0b\x32\x1d.omnivoice.worker.v1.Envelope\x12\r\n\x05nonce\x18\x02 \x01(\x04\"\x82\x01\n\rWorkerGoodbye\x12/\n\x08\x65nvelope\x18\x01 \x01(\x0b\x32\x1d.omnivoice.worker.v1.Envelope\x12\x0e\n\x06reason\x18\x02 \x01(\t\x12\x30\n\nabandoning\x18\x03 \x03(\x0b\x32\x1c.omnivoice.worker.v1.TaskRef\"\x7f\n\x10\x43\x61pabilityUpdate\x12/\n\x08\x65nvelope\x18\x01 \x01(\x0b\x32\x1d.omnivoice.worker.v1.Envelope\x12:\n\x0c\x63\x61pabilities\x18\x02 \x03(\x0b\x32$.omnivoice.worker.v1.ModelCapability\"W\n\x10\x44ownloadProgress\x12/\n\x08\x65nvelope\x18\x01 \x01(\x0b\x32\x1d.omnivoice.worker.v1.Envelope\x12\x12\n\nevent_json\x18\x02 \x01(\t\"\xae\x06\n\rWorkerMessage\x12\x33\n\theartbeat\x18\x01 \x01(\x0b\x32\x1e.omnivoice.worker.v1.HeartbeatH\x00\x12\x35\n\x08\x61\x63\x63\x65pted\x18\x02 \x01(\x0b\x32!.omnivoice.worker.v1.TaskAcceptedH\x00\x12\x35\n\x08rejected\x18\x03 \x01(\x0b\x32!.omnivoice.worker.v1.TaskRejectedH\x00\x12>\n\rmodel_loading\x18\x04 \x01(\x0b\x32%.omnivoice.worker.v1.TaskModelLoadingH\x00\x12\x33\n\x07started\x18\x05 \x01(\x0b\x32 .omnivoice.worker.v1.TaskStartedH\x00\x12\x35\n\x08progress\x18\x06 \x01(\x0b\x32!.omnivoice.worker.v1.TaskProgressH\x00\x12\x31\n\x06result\x18\x07 \x01(\x0b\x32\x1f.omnivoice.worker.v1.TaskResultH\x00\x12\x31\n\x06\x66\x61iled\x18\x08 \x01(\x0b\x32\x1f.omnivoice.worker.v1.TaskFailedH\x00\x12\x38\n\ncancel_ack\x18\t \x01(\x0b\x32\".omnivoice.worker.v1.TaskCancelAckH\x00\x12=\n\x0c\x63\x61pabilities\x18\n \x01(\x0b\x32%.omnivoice.worker.v1.CapabilityUpdateH\x00\x12\x35\n\x07goodbye\x18\x0b \x01(\x0b\x32\".omnivoice.worker.v1.WorkerGoodbyeH\x00\x12)\n\x04pong\x18\x0c \x01(\x0b\x32\x19.omnivoice.worker.v1.PongH\x00\x12\x42\n\x11\x64ownload_progress\x18\r \x01(\x0b\x32%.omnivoice.worker.v1.DownloadProgressH\x00\x12\x38\n\x08register\x18\x0f \x01(\x0b\x32$.omnivoice.worker.v1.RegisterRequestH\x00\x42\t\n\x07payloadJ\x04\x08\x0e\x10\x0f\"\x9b\x01\n\tDeadlines\x12\x16\n\x0e\x61\x63\x63\x65pt_seconds\x18\x01 \x01(\r\x12\x1a\n\x12model_load_seconds\x18\x02 \x01(\r\x12\x19\n\x11\x65xecution_seconds\x18\x03 \x01(\r\x12\x1e\n\x16progress_lease_seconds\x18\x04 \x01(\r\x12\x1f\n\x17result_delivery_seconds\x18\x05 \x01(\r\"\xd7\x03\n\x0eTaskAssignment\x12)\n\x03ref\x18\x01 \x01(\x0b\x32\x1c.omnivoice.worker.v1.TaskRef\x12/\n\x08\x65nvelope\x18\x02 \x01(\x0b\x32\x1d.omnivoice.worker.v1.Envelope\x12\x11\n\toperation\x18\x03 \x01(\t\x12\x0e\n\x06\x65ngine\x18\x04 \x01(\t\x12\x10\n\x08model_id\x18\x05 \x01(\t\x12\x13\n\x0bparams_json\x18\x06 \x01(\t\x12\x30\n\x06inputs\x18\x07 \x03(\x0b\x32 .omnivoice.worker.v1.ArtifactRef\x12\x31\n\tdeadlines\x18\x08 \x01(\x0b\x32\x1e.omnivoice.worker.v1.Deadlines\x12\x16\n\x0epriority_class\x18\t \x01(\r\x12\x16\n\x0e\x61ttempt_number\x18\n \x01(\r\x12\x14\n\x0cmax_attempts\x18\x0b \x01(\r\x12\x43\n\x08metadata\x18\x0c \x03(\x0b\x32\x31.omnivoice.worker.v1.TaskAssignment.MetadataEntry\x1a/\n\rMetadataEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"x\n\nTaskCancel\x12)\n\x03ref\x18\x01 \x01(\x0b\x32\x1c.omnivoice.worker.v1.TaskRef\x12/\n\x08\x65nvelope\x18\x02 \x01(\x0b\x32\x1d.omnivoice.worker.v1.Envelope\x12\x0e\n\x06reason\x18\x03 \x01(\t\"n\n\x10ResultAckMessage\x12)\n\x03ref\x18\x01 \x01(\x0b\x32\x1c.omnivoice.worker.v1.TaskRef\x12/\n\x08\x65nvelope\x18\x02 \x01(\x0b\x32\x1d.omnivoice.worker.v1.Envelope\"\xa8\x01\n\x0c\x43onfigUpdate\x12/\n\x08\x65nvelope\x18\x01 \x01(\x0b\x32\x1d.omnivoice.worker.v1.Envelope\x12\"\n\x1aheartbeat_interval_seconds\x18\x02 \x01(\r\x12\x1c\n\x14max_concurrent_tasks\x18\x03 \x01(\r\x12%\n\x1dinline_result_threshold_bytes\x18\x04 \x01(\x04\"\x80\x01\n\x0ePrewarmRequest\x12/\n\x08\x65nvelope\x18\x01 \x01(\x0b\x32\x1d.omnivoice.worker.v1.Envelope\x12\x0e\n\x06\x65ngine\x18\x02 \x01(\t\x12\x10\n\x08model_id\x18\x03 \x01(\t\x12\x1b\n\x13\x64ownload_if_missing\x18\x04 \x01(\x08\"^\n\x19ModelInstallCancelRequest\x12/\n\x08\x65nvelope\x18\x01 \x01(\x0b\x32\x1d.omnivoice.worker.v1.Envelope\x12\x10\n\x08model_id\x18\x02 \x01(\t\"F\n\x04Ping\x12/\n\x08\x65nvelope\x18\x01 \x01(\x0b\x32\x1d.omnivoice.worker.v1.Envelope\x12\r\n\x05nonce\x18\x02 \x01(\x04\"h\n\x05\x44rain\x12/\n\x08\x65nvelope\x18\x01 \x01(\x0b\x32\x1d.omnivoice.worker.v1.Envelope\x12\x18\n\x10\x64\x65\x61\x64line_seconds\x18\x02 \x01(\r\x12\x14\n\x0creconnect_to\x18\x03 \x01(\t\"K\n\x08Shutdown\x12/\n\x08\x65nvelope\x18\x01 \x01(\x0b\x32\x1d.omnivoice.worker.v1.Envelope\x12\x0e\n\x06reason\x18\x02 \x01(\t\"\xca\x04\n\rServerMessage\x12\x39\n\nassignment\x18\x01 \x01(\x0b\x32#.omnivoice.worker.v1.TaskAssignmentH\x00\x12\x31\n\x06\x63\x61ncel\x18\x02 \x01(\x0b\x32\x1f.omnivoice.worker.v1.TaskCancelH\x00\x12;\n\nresult_ack\x18\x03 \x01(\x0b\x32%.omnivoice.worker.v1.ResultAckMessageH\x00\x12\x33\n\x06\x63onfig\x18\x04 \x01(\x0b\x32!.omnivoice.worker.v1.ConfigUpdateH\x00\x12)\n\x04ping\x18\x05 \x01(\x0b\x32\x19.omnivoice.worker.v1.PingH\x00\x12+\n\x05\x64rain\x18\x06 \x01(\x0b\x32\x1a.omnivoice.worker.v1.DrainH\x00\x12\x31\n\x08shutdown\x18\x07 \x01(\x0b\x32\x1d.omnivoice.worker.v1.ShutdownH\x00\x12\x36\n\x07prewarm\x18\x08 \x01(\x0b\x32#.omnivoice.worker.v1.PrewarmRequestH\x00\x12;\n\nregistered\x18\t \x01(\x0b\x32%.omnivoice.worker.v1.RegisterResponseH\x00\x12N\n\x14model_install_cancel\x18\n \x01(\x0b\x32..omnivoice.worker.v1.ModelInstallCancelRequestH\x00\x42\t\n\x07payload\"\xaa\x01\n\x0b\x41rtifactRef\x12\x13\n\x0b\x61rtifact_id\x18\x01 \x01(\t\x12\x0f\n\x07task_id\x18\x02 \x01(\t\x12\x12\n\nattempt_id\x18\x03 \x01(\t\x12\x10\n\x08\x66ilename\x18\x04 \x01(\t\x12\x14\n\x0c\x63ontent_type\x18\x05 \x01(\t\x12\x12\n\nsize_bytes\x18\x06 \x01(\x04\x12\x0e\n\x06sha256\x18\x07 \x01(\t\x12\x15\n\rsession_token\x18\x08 \x01(\t\"j\n\rArtifactChunk\x12-\n\x03ref\x18\x01 \x01(\x0b\x32 .omnivoice.worker.v1.ArtifactRef\x12\x0e\n\x06offset\x18\x02 \x01(\x04\x12\x0c\n\x04\x64\x61ta\x18\x03 \x01(\x0c\x12\x0c\n\x04last\x18\x04 \x01(\x08\"\x7f\n\x0bResultChunk\x12-\n\x03ref\x18\x01 \x01(\x0b\x32 .omnivoice.worker.v1.ArtifactRef\x12\x0e\n\x06offset\x18\x02 \x01(\x04\x12\x0c\n\x04\x64\x61ta\x18\x03 \x01(\x0c\x12\x0c\n\x04last\x18\x04 \x01(\x08\x12\x15\n\rsession_token\x18\x05 \x01(\t\"v\n\tResultAck\x12\x13\n\x0b\x61rtifact_id\x18\x01 \x01(\t\x12\x16\n\x0e\x62ytes_received\x18\x02 \x01(\x04\x12\x11\n\tcommitted\x18\x03 \x01(\x08\x12)\n\x05\x65rror\x18\x04 \x01(\x0b\x32\x1a.omnivoice.worker.v1.Error\"x\n\x0b\x41rtifactAck\x12\x13\n\x0b\x61rtifact_id\x18\x01 \x01(\t\x12\x16\n\x0e\x62ytes_received\x18\x02 \x01(\x04\x12\x11\n\tcommitted\x18\x03 \x01(\x08\x12)\n\x05\x65rror\x18\x04 \x01(\x0b\x32\x1a.omnivoice.worker.v1.Error*\xc7\x01\n\nErrorClass\x12\x1b\n\x17\x45RROR_CLASS_UNSPECIFIED\x10\x00\x12\x19\n\x15\x45RROR_CLASS_TRANSIENT\x10\x01\x12\x1a\n\x16\x45RROR_CLASS_CAPABILITY\x10\x02\x12\x18\n\x14\x45RROR_CLASS_TERMINAL\x10\x03\x12\x18\n\x14\x45RROR_CLASS_CAPACITY\x10\x04\x12\x17\n\x13\x45RROR_CLASS_TIMEOUT\x10\x05\x12\x18\n\x14\x45RROR_CLASS_PROTOCOL\x10\x06\x32\xef\x02\n\rWorkerService\x12W\n\x08Register\x12$.omnivoice.worker.v1.RegisterRequest\x1a%.omnivoice.worker.v1.RegisterResponse\x12U\n\x07\x43ontrol\x12\".omnivoice.worker.v1.WorkerMessage\x1a\".omnivoice.worker.v1.ServerMessage(\x01\x30\x01\x12R\n\x0cUploadResult\x12 .omnivoice.worker.v1.ResultChunk\x1a\x1e.omnivoice.worker.v1.ResultAck(\x01\x12Z\n\x10\x44ownloadArtifact\x12 .omnivoice.worker.v1.ArtifactRef\x1a\".omnivoice.worker.v1.ArtifactChunk0\x01\x32\x8d\x02\n\x0bNodeService\x12T\n\x06\x41ttach\x12\".omnivoice.worker.v1.ServerMessage\x1a\".omnivoice.worker.v1.WorkerMessage(\x01\x30\x01\x12S\n\x0b\x46\x65tchResult\x12 .omnivoice.worker.v1.ArtifactRef\x1a .omnivoice.worker.v1.ResultChunk0\x01\x12S\n\tPushInput\x12\".omnivoice.worker.v1.ArtifactChunk\x1a .omnivoice.worker.v1.ArtifactAck(\x01\x62\x06proto3')
_globals = globals()
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
@@ -35,8 +35,8 @@ if not _descriptor._USE_C_DESCRIPTORS:
_globals['_REGISTERREQUEST_LABELSENTRY']._serialized_options = b'8\001'
_globals['_TASKASSIGNMENT_METADATAENTRY']._loaded_options = None
_globals['_TASKASSIGNMENT_METADATAENTRY']._serialized_options = b'8\001'
- _globals['_ERRORCLASS']._serialized_start=7602
- _globals['_ERRORCLASS']._serialized_end=7801
+ _globals['_ERRORCLASS']._serialized_start=7716
+ _globals['_ERRORCLASS']._serialized_end=7915
_globals['_TASKREF']._serialized_start=40
_globals['_TASKREF']._serialized_end=109
_globals['_ENVELOPE']._serialized_start=111
@@ -56,71 +56,71 @@ if not _descriptor._USE_C_DESCRIPTORS:
_globals['_REGISTERRESPONSE']._serialized_start=1596
_globals['_REGISTERRESPONSE']._serialized_end=1929
_globals['_HEARTBEAT']._serialized_start=1932
- _globals['_HEARTBEAT']._serialized_end=2112
- _globals['_TASKACCEPTED']._serialized_start=2114
- _globals['_TASKACCEPTED']._serialized_end=2220
- _globals['_TASKREJECTED']._serialized_start=2223
- _globals['_TASKREJECTED']._serialized_end=2372
- _globals['_TASKMODELLOADING']._serialized_start=2375
- _globals['_TASKMODELLOADING']._serialized_end=2556
- _globals['_TASKSTARTED']._serialized_start=2558
- _globals['_TASKSTARTED']._serialized_end=2663
- _globals['_TASKPROGRESS']._serialized_start=2666
- _globals['_TASKPROGRESS']._serialized_end=2840
- _globals['_USAGEREPORT']._serialized_start=2843
- _globals['_USAGEREPORT']._serialized_end=3037
- _globals['_TASKRESULT']._serialized_start=3040
- _globals['_TASKRESULT']._serialized_end=3291
- _globals['_TASKFAILED']._serialized_start=3294
- _globals['_TASKFAILED']._serialized_end=3490
- _globals['_TASKCANCELACK']._serialized_start=3492
- _globals['_TASKCANCELACK']._serialized_end=3599
- _globals['_PONG']._serialized_start=3601
- _globals['_PONG']._serialized_end=3671
- _globals['_WORKERGOODBYE']._serialized_start=3674
- _globals['_WORKERGOODBYE']._serialized_end=3804
- _globals['_CAPABILITYUPDATE']._serialized_start=3806
- _globals['_CAPABILITYUPDATE']._serialized_end=3933
- _globals['_DOWNLOADPROGRESS']._serialized_start=3935
- _globals['_DOWNLOADPROGRESS']._serialized_end=4022
- _globals['_WORKERMESSAGE']._serialized_start=4025
- _globals['_WORKERMESSAGE']._serialized_end=4839
- _globals['_DEADLINES']._serialized_start=4842
- _globals['_DEADLINES']._serialized_end=4997
- _globals['_TASKASSIGNMENT']._serialized_start=5000
- _globals['_TASKASSIGNMENT']._serialized_end=5471
- _globals['_TASKASSIGNMENT_METADATAENTRY']._serialized_start=5424
- _globals['_TASKASSIGNMENT_METADATAENTRY']._serialized_end=5471
- _globals['_TASKCANCEL']._serialized_start=5473
- _globals['_TASKCANCEL']._serialized_end=5593
- _globals['_RESULTACKMESSAGE']._serialized_start=5595
- _globals['_RESULTACKMESSAGE']._serialized_end=5705
- _globals['_CONFIGUPDATE']._serialized_start=5708
- _globals['_CONFIGUPDATE']._serialized_end=5876
- _globals['_PREWARMREQUEST']._serialized_start=5879
- _globals['_PREWARMREQUEST']._serialized_end=6007
- _globals['_MODELINSTALLCANCELREQUEST']._serialized_start=6009
- _globals['_MODELINSTALLCANCELREQUEST']._serialized_end=6103
- _globals['_PING']._serialized_start=6105
- _globals['_PING']._serialized_end=6175
- _globals['_DRAIN']._serialized_start=6177
- _globals['_DRAIN']._serialized_end=6281
- _globals['_SHUTDOWN']._serialized_start=6283
- _globals['_SHUTDOWN']._serialized_end=6358
- _globals['_SERVERMESSAGE']._serialized_start=6361
- _globals['_SERVERMESSAGE']._serialized_end=6947
- _globals['_ARTIFACTREF']._serialized_start=6950
- _globals['_ARTIFACTREF']._serialized_end=7120
- _globals['_ARTIFACTCHUNK']._serialized_start=7122
- _globals['_ARTIFACTCHUNK']._serialized_end=7228
- _globals['_RESULTCHUNK']._serialized_start=7230
- _globals['_RESULTCHUNK']._serialized_end=7357
- _globals['_RESULTACK']._serialized_start=7359
- _globals['_RESULTACK']._serialized_end=7477
- _globals['_ARTIFACTACK']._serialized_start=7479
- _globals['_ARTIFACTACK']._serialized_end=7599
- _globals['_WORKERSERVICE']._serialized_start=7804
- _globals['_WORKERSERVICE']._serialized_end=8171
- _globals['_NODESERVICE']._serialized_start=8174
- _globals['_NODESERVICE']._serialized_end=8443
+ _globals['_HEARTBEAT']._serialized_end=2226
+ _globals['_TASKACCEPTED']._serialized_start=2228
+ _globals['_TASKACCEPTED']._serialized_end=2334
+ _globals['_TASKREJECTED']._serialized_start=2337
+ _globals['_TASKREJECTED']._serialized_end=2486
+ _globals['_TASKMODELLOADING']._serialized_start=2489
+ _globals['_TASKMODELLOADING']._serialized_end=2670
+ _globals['_TASKSTARTED']._serialized_start=2672
+ _globals['_TASKSTARTED']._serialized_end=2777
+ _globals['_TASKPROGRESS']._serialized_start=2780
+ _globals['_TASKPROGRESS']._serialized_end=2954
+ _globals['_USAGEREPORT']._serialized_start=2957
+ _globals['_USAGEREPORT']._serialized_end=3151
+ _globals['_TASKRESULT']._serialized_start=3154
+ _globals['_TASKRESULT']._serialized_end=3405
+ _globals['_TASKFAILED']._serialized_start=3408
+ _globals['_TASKFAILED']._serialized_end=3604
+ _globals['_TASKCANCELACK']._serialized_start=3606
+ _globals['_TASKCANCELACK']._serialized_end=3713
+ _globals['_PONG']._serialized_start=3715
+ _globals['_PONG']._serialized_end=3785
+ _globals['_WORKERGOODBYE']._serialized_start=3788
+ _globals['_WORKERGOODBYE']._serialized_end=3918
+ _globals['_CAPABILITYUPDATE']._serialized_start=3920
+ _globals['_CAPABILITYUPDATE']._serialized_end=4047
+ _globals['_DOWNLOADPROGRESS']._serialized_start=4049
+ _globals['_DOWNLOADPROGRESS']._serialized_end=4136
+ _globals['_WORKERMESSAGE']._serialized_start=4139
+ _globals['_WORKERMESSAGE']._serialized_end=4953
+ _globals['_DEADLINES']._serialized_start=4956
+ _globals['_DEADLINES']._serialized_end=5111
+ _globals['_TASKASSIGNMENT']._serialized_start=5114
+ _globals['_TASKASSIGNMENT']._serialized_end=5585
+ _globals['_TASKASSIGNMENT_METADATAENTRY']._serialized_start=5538
+ _globals['_TASKASSIGNMENT_METADATAENTRY']._serialized_end=5585
+ _globals['_TASKCANCEL']._serialized_start=5587
+ _globals['_TASKCANCEL']._serialized_end=5707
+ _globals['_RESULTACKMESSAGE']._serialized_start=5709
+ _globals['_RESULTACKMESSAGE']._serialized_end=5819
+ _globals['_CONFIGUPDATE']._serialized_start=5822
+ _globals['_CONFIGUPDATE']._serialized_end=5990
+ _globals['_PREWARMREQUEST']._serialized_start=5993
+ _globals['_PREWARMREQUEST']._serialized_end=6121
+ _globals['_MODELINSTALLCANCELREQUEST']._serialized_start=6123
+ _globals['_MODELINSTALLCANCELREQUEST']._serialized_end=6217
+ _globals['_PING']._serialized_start=6219
+ _globals['_PING']._serialized_end=6289
+ _globals['_DRAIN']._serialized_start=6291
+ _globals['_DRAIN']._serialized_end=6395
+ _globals['_SHUTDOWN']._serialized_start=6397
+ _globals['_SHUTDOWN']._serialized_end=6472
+ _globals['_SERVERMESSAGE']._serialized_start=6475
+ _globals['_SERVERMESSAGE']._serialized_end=7061
+ _globals['_ARTIFACTREF']._serialized_start=7064
+ _globals['_ARTIFACTREF']._serialized_end=7234
+ _globals['_ARTIFACTCHUNK']._serialized_start=7236
+ _globals['_ARTIFACTCHUNK']._serialized_end=7342
+ _globals['_RESULTCHUNK']._serialized_start=7344
+ _globals['_RESULTCHUNK']._serialized_end=7471
+ _globals['_RESULTACK']._serialized_start=7473
+ _globals['_RESULTACK']._serialized_end=7591
+ _globals['_ARTIFACTACK']._serialized_start=7593
+ _globals['_ARTIFACTACK']._serialized_end=7713
+ _globals['_WORKERSERVICE']._serialized_start=7918
+ _globals['_WORKERSERVICE']._serialized_end=8285
+ _globals['_NODESERVICE']._serialized_start=8288
+ _globals['_NODESERVICE']._serialized_end=8557
# @@protoc_insertion_point(module_scope)
diff --git a/backend/worker/protocol/gen/worker_v1_pb2.pyi b/backend/worker/protocol/gen/worker_v1_pb2.pyi
index a5571a53..3cddfef1 100644
--- a/backend/worker/protocol/gen/worker_v1_pb2.pyi
+++ b/backend/worker/protocol/gen/worker_v1_pb2.pyi
@@ -194,20 +194,22 @@ class RegisterResponse(_message.Message):
def __init__(self, envelope: _Optional[_Union[Envelope, _Mapping]] = ..., worker_id: _Optional[str] = ..., session_token: _Optional[str] = ..., session_epoch: _Optional[int] = ..., protocol_version: _Optional[int] = ..., session_expires_at_unix: _Optional[int] = ..., heartbeat_interval_seconds: _Optional[int] = ..., authoritative_in_flight: _Optional[_Iterable[_Union[TaskRef, _Mapping]]] = ..., error: _Optional[_Union[Error, _Mapping]] = ...) -> None: ...
class Heartbeat(_message.Message):
- __slots__ = ("envelope", "active_tasks", "available_slots", "resident_models", "free_memory_bytes", "cpu_percent")
+ __slots__ = ("envelope", "active_tasks", "available_slots", "resident_models", "free_memory_bytes", "cpu_percent", "gpu_utilization_percent")
ENVELOPE_FIELD_NUMBER: _ClassVar[int]
ACTIVE_TASKS_FIELD_NUMBER: _ClassVar[int]
AVAILABLE_SLOTS_FIELD_NUMBER: _ClassVar[int]
RESIDENT_MODELS_FIELD_NUMBER: _ClassVar[int]
FREE_MEMORY_BYTES_FIELD_NUMBER: _ClassVar[int]
CPU_PERCENT_FIELD_NUMBER: _ClassVar[int]
+ GPU_UTILIZATION_PERCENT_FIELD_NUMBER: _ClassVar[int]
envelope: Envelope
active_tasks: int
available_slots: int
resident_models: _containers.RepeatedScalarFieldContainer[str]
free_memory_bytes: int
cpu_percent: float
- def __init__(self, envelope: _Optional[_Union[Envelope, _Mapping]] = ..., active_tasks: _Optional[int] = ..., available_slots: _Optional[int] = ..., resident_models: _Optional[_Iterable[str]] = ..., free_memory_bytes: _Optional[int] = ..., cpu_percent: _Optional[float] = ...) -> None: ...
+ gpu_utilization_percent: float
+ def __init__(self, envelope: _Optional[_Union[Envelope, _Mapping]] = ..., active_tasks: _Optional[int] = ..., available_slots: _Optional[int] = ..., resident_models: _Optional[_Iterable[str]] = ..., free_memory_bytes: _Optional[int] = ..., cpu_percent: _Optional[float] = ..., gpu_utilization_percent: _Optional[float] = ...) -> None: ...
class TaskAccepted(_message.Message):
__slots__ = ("ref", "envelope")
diff --git a/backend/worker/protocol/worker_v1.proto b/backend/worker/protocol/worker_v1.proto
index b53e1a7d..87b6edd3 100644
--- a/backend/worker/protocol/worker_v1.proto
+++ b/backend/worker/protocol/worker_v1.proto
@@ -228,11 +228,9 @@ message Heartbeat {
uint32 active_tasks = 2;
uint32 available_slots = 3;
repeated string resident_models = 4;
- uint64 free_memory_bytes = 5;
- double cpu_percent = 6;
- // GPU utilisation is deliberately absent: unobtainable on Apple without
- // sudo powermetrics and absent on CUDA without a new NVML dependency.
- // Slots + queue depth are the load signals (goal_v2.md A11).
+ optional uint64 free_memory_bytes = 5;
+ optional double cpu_percent = 6;
+ optional double gpu_utilization_percent = 7;
}
message TaskAccepted { TaskRef ref = 1; Envelope envelope = 2; }
diff --git a/backend/worker/routing.py b/backend/worker/routing.py
index 3ac6961d..8978ef22 100644
--- a/backend/worker/routing.py
+++ b/backend/worker/routing.py
@@ -117,6 +117,13 @@ class Target:
latency_ms: float = 0.0
active_tasks: int = 0
max_tasks: int = 0
+ cpu_percent: Optional[float] = None
+ free_memory_bytes: Optional[int] = None
+ system_memory_bytes: int = 0
+ cpu_count: int = 0
+ gpu_name: str = ""
+ gpu_memory_bytes: int = 0
+ gpu_utilization_percent: Optional[float] = None
@property
def is_local(self) -> bool:
@@ -135,6 +142,13 @@ class Target:
"latency_ms": round(self.latency_ms, 1),
"active_tasks": self.active_tasks,
"max_tasks": self.max_tasks,
+ "cpu_percent": self.cpu_percent,
+ "free_memory_bytes": self.free_memory_bytes,
+ "system_memory_bytes": self.system_memory_bytes,
+ "cpu_count": self.cpu_count,
+ "gpu_name": self.gpu_name,
+ "gpu_memory_bytes": self.gpu_memory_bytes,
+ "gpu_utilization_percent": self.gpu_utilization_percent,
}
@@ -192,6 +206,8 @@ def list_targets(control_plane=None) -> list[Target]:
pool = getattr(control_plane, "pool", None) if control_plane.running else None
for record in enrolled:
live = pool.get(record.id) if pool is not None else None
+ host = record.host or {}
+ gpu = (host.get("gpus") or [{}])[0]
connected = live is not None and not live.stale()
available, detail = _availability(record, live, pool)
targets.append(
@@ -207,6 +223,13 @@ def list_targets(control_plane=None) -> list[Target]:
latency_ms=live.latency_ms if live else 0.0,
active_tasks=live.capacity.active_tasks if live else 0,
max_tasks=live.capacity.max_concurrent_tasks if live else 0,
+ cpu_percent=live.capacity.cpu_percent if live else None,
+ free_memory_bytes=live.capacity.free_memory_bytes if live else None,
+ system_memory_bytes=int(host.get("system_memory_bytes") or 0),
+ cpu_count=int(host.get("cpu_count") or 0),
+ gpu_name=str(gpu.get("model") or ""),
+ gpu_memory_bytes=int(gpu.get("memory_bytes") or 0),
+ gpu_utilization_percent=live.capacity.gpu_utilization_percent if live else None,
)
)
return targets
diff --git a/backend/worker/transport/client.py b/backend/worker/transport/client.py
index 4bfb3dcd..320aa79c 100644
--- a/backend/worker/transport/client.py
+++ b/backend/worker/transport/client.py
@@ -43,6 +43,8 @@ import platform
import random
import socket
import sys
+import threading
+from concurrent.futures import Future
from dataclasses import dataclass, field
from typing import Awaitable, Callable, Optional, Protocol
@@ -73,6 +75,33 @@ _FALLBACK_MODEL_LOAD_SECONDS = 1800.0
# see _oversized_result_error for why that has to be a failure and not a retry.
MAX_MESSAGE_BYTES = 8 * 1024 * 1024
+
+def _heartbeat_resources() -> tuple[Optional[float], Optional[int], Optional[float]]:
+ """Sample cheap host telemetry without making a heartbeat depend on CUDA."""
+ cpu_percent = free_memory_bytes = gpu_utilization_percent = None
+ try:
+ import psutil
+
+ cpu_percent = float(psutil.cpu_percent(interval=None))
+ except Exception:
+ logger.debug("Could not sample worker CPU usage", exc_info=True)
+ try:
+ import torch
+
+ if torch.cuda.is_available():
+ free_memory_bytes = int(torch.cuda.mem_get_info()[0])
+ except Exception:
+ logger.debug("Could not sample worker free VRAM", exc_info=True)
+ try:
+ import pynvml
+
+ pynvml.nvmlInit()
+ handle = pynvml.nvmlDeviceGetHandleByIndex(0)
+ gpu_utilization_percent = float(pynvml.nvmlDeviceGetUtilizationRates(handle).gpu)
+ except Exception:
+ logger.debug("Could not sample worker GPU usage", exc_info=True)
+ return cpu_percent, free_memory_bytes, gpu_utilization_percent
+
# Room left for result_json, the ref, and protobuf framing when a payload does
# ride inline. The inline decision is made on the payload alone, so without a
# reserve a payload sized exactly at the frame cap would overflow it.
@@ -339,6 +368,11 @@ class WorkerClient:
self._running: dict[str, asyncio.Task] = {}
self._keepalives: dict[str, asyncio.Task] = {}
self._maintenance: set[asyncio.Task] = set()
+ self._telemetry: tuple[Optional[float], Optional[int], Optional[float]] = (None, None, None)
+ # A driver query can hang indefinitely. Keep that one query owned
+ # rather than cancelling its awaiter and starting a fresh thread at
+ # every heartbeat.
+ self._telemetry_task: Optional[asyncio.Future] = None
self._prewarms: dict[str, asyncio.Task] = {}
self._prewarm_cancellations: dict[str, asyncio.Task] = {}
self._epoch = 0
@@ -683,10 +717,59 @@ class WorkerClient:
async def _heartbeat_loop(self, interval: float) -> None:
while True:
await asyncio.sleep(interval)
+ await self._refresh_telemetry()
await self._send(self.heartbeat_message())
+ async def _refresh_telemetry(self) -> None:
+ """Publish completed samples and retain one non-blocking probe.
+
+ CUDA/NVML calls may wedge in a driver. A timed ``to_thread`` await
+ only cancels the awaiter, leaving that thread alive; retaining this
+ task prevents later heartbeats from accumulating more blocked probes.
+ """
+ if not self._accepting_assignments or self._stop.is_set():
+ return
+ task = self._telemetry_task
+ if task is not None and task.done():
+ try:
+ sampled = task.result()
+ except Exception:
+ logger.debug("Could not sample worker telemetry", exc_info=True)
+ else:
+ # A partial failed sample must not erase an independent last
+ # good value. Presence on the heartbeat remains honest until
+ # that individual metric can next be measured.
+ self._telemetry = tuple(
+ current if value is None else value
+ for current, value in zip(self._telemetry, sampled)
+ )
+ self._telemetry_task = None
+
+ if self._telemetry_task is None:
+ # Read-only driver probes cannot be interrupted. Keep one across
+ # reconnects, outside assignment drain and the shared executor
+ # (whose shutdown would otherwise wait forever for a wedged driver).
+ result = Future()
+ self._telemetry_task = asyncio.wrap_future(result)
+
+ def sample() -> None:
+ try:
+ result.set_result(_heartbeat_resources())
+ except Exception:
+ logger.debug("Could not sample worker telemetry", exc_info=True)
+ result.set_result((None, None, None))
+
+ threading.Thread(
+ target=sample, name="worker-telemetry-probe", daemon=True,
+ ).start()
+
def heartbeat_message(self) -> pb.WorkerMessage:
"""Build the worker's current liveness/capacity frame."""
+ cpu_percent, free_memory_bytes, gpu_utilization_percent = self._telemetry
+ telemetry = {}
+ if cpu_percent is not None: telemetry["cpu_percent"] = cpu_percent
+ if free_memory_bytes is not None: telemetry["free_memory_bytes"] = free_memory_bytes
+ if gpu_utilization_percent is not None: telemetry["gpu_utilization_percent"] = gpu_utilization_percent
return pb.WorkerMessage(
heartbeat=pb.Heartbeat(
active_tasks=len(self._running),
@@ -694,6 +777,7 @@ class WorkerClient:
0, self.config.max_concurrent_tasks - len(self._running)
),
resident_models=self._resident_models(),
+ **telemetry,
)
)
diff --git a/backend/worker/transport/server.py b/backend/worker/transport/server.py
index 2e0d550c..a8ad72b4 100644
--- a/backend/worker/transport/server.py
+++ b/backend/worker/transport/server.py
@@ -2068,7 +2068,9 @@ class WorkerServicer(pb_grpc.WorkerServiceServicer):
active_tasks=active_tasks,
available_slots=available_slots,
resident_models=set(beat.resident_models),
- free_memory_bytes=beat.free_memory_bytes,
+ free_memory_bytes=beat.free_memory_bytes if beat.HasField("free_memory_bytes") else None,
+ cpu_percent=beat.cpu_percent if beat.HasField("cpu_percent") else None,
+ gpu_utilization_percent=beat.gpu_utilization_percent if beat.HasField("gpu_utilization_percent") else None,
)
self._queue_heartbeat_touch(session)
return
diff --git a/bin/README.md b/bin/README.md
index 52c5ace6..475909e2 100644
--- a/bin/README.md
+++ b/bin/README.md
@@ -24,8 +24,9 @@ scripts/build-omnivoice-tts.sh --platform --commit-sha <40hex>
See `.github/workflows/build-omnivoice-tts.yml` `build-omnivoice-tts` job
for the CI matrix that produces these artifacts. Apple Silicon (`macos-14`)
builds cleanly with `-DGGML_METAL=ON` at the pinned SHA (#2105), enabling
-hardware-accelerated Metal inference without falling back to the in-process
-`VoiceStudioBackend`.
+hardware-accelerated Metal inference when the packaged artifact passes binary
+preflight and macOS permits execution. Missing or blocked binaries retain the
+in-process `VoiceStudioBackend` fallback.
## Placeholder note
diff --git a/bun.lock b/bun.lock
index 7dcf89bb..b2f4f498 100644
--- a/bun.lock
+++ b/bun.lock
@@ -75,7 +75,7 @@
},
"frontend": {
"name": "omnivoice-studio",
- "version": "0.5.2",
+ "version": "0.5.3",
"dependencies": {
"@fontsource-variable/inter": "^5.3.0",
"@fontsource-variable/source-serif-4": "^5.3.0",
diff --git a/docs/RELEASING.md b/docs/RELEASING.md
index 5b10bb59..753a0832 100644
--- a/docs/RELEASING.md
+++ b/docs/RELEASING.md
@@ -71,26 +71,18 @@ git tag vX.Y.Z
git push origin vX.Y.Z
```
-The `Desktop Release` workflow fires on tag push. It builds four targets in parallel on GitHub Actions runners:
+`electron-release.yml` builds Linux x64, Windows x64, macOS arm64 and macOS
+x64 installers with updater metadata and packaged startup checks. Ordinary tag
+pushes create drafts; a tag-scoped manual dispatch with `publish=true` publishes
+after all four targets pass. Signing checks apply by default.
-| Target | Runner | Artifact |
-|---|---|---|
-| macOS Apple Silicon | macos-14 | `.dmg` + updater `.app.tar.gz` |
-| macOS Intel | macos-13 | `.dmg` + updater `.app.tar.gz` |
-| Windows x64 | windows-2022 | `.msi`, machine-wide and per-user, each with its updater `.sig` |
-| Linux x64 | ubuntu-22.04 | `.AppImage` + updater `.AppImage.sig` |
-
-Each runner signs the updater payload with the stored `TAURI_SIGNING_PRIVATE_KEY`, merges into a single `latest.json`, and attaches everything to the draft release.
-
-Workflow runtime: **~20-40 minutes** (PyInstaller + four platform builds). Follow progress at:
-`https://github.com/debpalash/VoiceStudio/actions`
-
-The release stays a draft while the platforms build. Once every platform, the
-updater-manifest repair and the uninstall scripts are done, the
-`release-notes-checksums` job writes all four platforms' checksums into the
-notes and publishes it, with no manual step. A failed platform leaves the
-release a draft, so nothing half-built goes public. Existing clients detect
-the update on their next launch.
+For the one-time transition tag, set `TAURI_SUNSET_TAG`, dispatch `release.yml`
+on that tag with `draft=true`, and wait for its final Tauri installers and signed
+updater feeds. Then dispatch `electron-release.yml` on the same tag. Automatic
+Electron builds are skipped for this tag to avoid racing the Tauri draft.
+Keep the release draft until both builds and their checks have passed.
+See [Electron transition](#electron-transition-next-desktop-release) below for
+signing requirements and the explicit owner-only unsigned exception.
## 5b. Deployment channels — all must ship (hard rule, owner-set 2026-07-16)
@@ -100,8 +92,9 @@ bug to fix immediately, not backlog.
| Channel | Source | Produced by | How to verify |
|---|---|---|---|
-| GitHub Release: installers + signed `latest.json` (**Stable** updater channel) | the `vX.Y.Z` tag | `release.yml` on tag push | Release page has dmg (arm+intel), msi (machine-wide and per-user), AppImage, `latest.json` and `latest-user.json`; body = the CHANGELOG section (not the auto-generated fallback), followed by per-platform checksums and a **Contributors** avatar strip (owner + every PR author for the tag — the `contributors-strip` job) |
-| **Preview** updater channel (rolling `preview` prerelease) | **`main` only** | `release.yml` nightly cron / manual dispatch | preview `latest.json` uses main's version when it is ahead; otherwise it advances the stable patch, then appends `-N` so it semver-sorts above stable |
+| GitHub Release: Electron installers and updater manifests | the `vX.Y.Z` tag | `electron-release.yml`, explicit publish dispatch | All four platforms, Electron manifests, SHA256SUMS.txt, versioned CHANGELOG notes; retained Tauri feeds point to the final Tauri tag |
+| Final Tauri installers and signed updater feeds | `TAURI_SUNSET_TAG` | `release.yml`, manual dispatch only | Both macOS architectures, Windows system/user installers, Linux AppImage, signed `latest.json` and `latest-user.json` |
+| Desktop preview channel | frozen during transition | no scheduled publishing | Existing preview assets remain available; new desktop previews are paused |
| GHCR CUDA image: `:X.Y.Z`, `:X.Y`, `:stable` | the tag | `docker.yml` on tag push | `docker manifest inspect ghcr.io/debpalash/omnivoice-studio:X.Y.Z` |
| GHCR ROCm image: `:X.Y.Z-rocm`, `:X.Y-rocm`, `:stable-rocm` | the tag | `docker.yml` on tag push | same, with `-rocm` suffix |
| Docker Hub mirror of **all** the above tags | the tag | `docker.yml` (gated on `DOCKERHUB_*` secrets) | tag list at hub.docker.com/r/palashdeb/omnivoice-studio/tags |
@@ -109,11 +102,8 @@ bug to fix immediately, not backlog.
| Rolling Docker previews: `:latest`, `:main`, `:rocm` | **`main` only** | `docker.yml` on every main push | tag timestamps move with main |
**Preview/RC policy:** there are no RC tags (beta cadence — see CLAUDE.md).
-The preview channel *is* the release candidate, and it **always builds from
-`main`** — the preview-gate in `release.yml` refuses `publish_preview` from
-any other branch, and the rolling Docker tags track `main` by construction.
-To get users testing a fix: merge to `main`, then cut a preview. Never a
-side-branch build.
+Rolling Docker previews always build from `main`. Desktop preview publication
+is paused during the Electron transition; never publish a side-branch preview.
## 6. Expect-to-fail-first-time on Windows and Linux
@@ -161,3 +151,49 @@ before Tauri uploads them again. A macOS retry also replaces that architecture's
versionless updater archive. Other versions, sibling platforms, and updater
manifests remain intact. Inventory or deletion permission/network failures stop
the job instead of hiding an upload collision.
+
+## Electron transition (next desktop release)
+
+Electron is the primary desktop distribution. electron-release.yml builds Linux
+x64, Windows x64, macOS arm64 and macOS x64, checks packaged startup and updater
+artifacts, then creates a draft. Publishing requires a tag-scoped manual dispatch
+with publish=true. Tag pushes never publish automatically. electron-build.yml
+remains the artifact-only rehearsal; run it before tagging.
+
+Set TAURI_SUNSET_TAG to the final Tauri version tag. Run the manual release.yml
+on that tag first; it rejects other refs. Automatic Tauri builds and scheduled
+previews are retired. Keep the transition release draft until Electron on the
+same tag completes. Electron requires the final signed latest.json and
+latest-user.json assets; subsequent releases copy those feeds without changing
+their immutable sunset payload URLs. Retain the sunset release and its assets.
+
+Write versioned CHANGELOG notes before release. Review all four platform builds,
+checksums, signing requirements and docs/electron-migration.md. Existing Electron
+artifact names and app IDs remain stable for updater compatibility. This pipeline
+ships stable releases; rolling preview publication is paused during transition.
+
+Preparation is not proof of cross-platform packaging, signing, migration, or a
+real installed update hop. Record those results before release. Keep Tauri source
+and shared assets until remaining Electron resource references are relocated.
+No tag, version bump, or publishing is authorized by workflow preparation alone.
+
+Electron signing uses ELECTRON_CSC_LINK and ELECTRON_CSC_KEY_PASSWORD secrets.
+Without them rehearsal/draft artifacts are unsigned or ad-hoc signed. Publishing
+checks macOS signing/notarization and Windows Authenticode signatures by default.
+The owner may explicitly choose the existing unsigned-release policy by dispatching
+with `allow_unsigned=true` (both dispatch and rerun actors must be the repository owner); the release notes then disclose OS trust warnings and
+unverified macOS automatic updates. Never select this exception without the owner's
+choice. Tauri's signing keys do not sign Electron packages.
+
+For the transition tag, automatic Electron release jobs are skipped. Build the
+manual Tauri sunset draft first, then dispatch Electron on the same tag after
+its signed updater feeds exist. Later tags build Electron automatically.
+
+
+If a packaging-workflow fix is needed after tagging, keep the release tag
+immutable. Merge and validate the workflow fix on main, then dispatch
+`electron-release.yml` from main with `release_tag=vX.Y.Z`. Validation and every
+packaging/release job check out that exact tag; only the workflow comes from
+main. Empty signing secrets are omitted from the builder environment so drafts
+and explicitly accepted unsigned builds do not interpret the working directory
+as a certificate. Publication still requires `publish=true` and the same guards.
diff --git a/docs/STRUCTURE.md b/docs/STRUCTURE.md
index 79e0a521..4a6934f5 100644
--- a/docs/STRUCTURE.md
+++ b/docs/STRUCTURE.md
@@ -39,7 +39,7 @@ VoiceStudio/
│ │ └── setup/ first-run wizard, model download
│ ├── core/ config, db, job queue, event bus, auth/CSRF, path security,
│ │ opt-in analytics, version, diagnostics
-│ ├── services/ 85 modules of business logic — TTS, dubbing pipeline,
+│ ├── services/ 86 modules of business logic — TTS, dubbing pipeline,
│ │ audio DSP, GPU gateway, engine routing, model lifecycle
│ ├── engines/ per-engine adapters: indextts, supertonic3, confucius4,
│ │ dots_tts, moss_tts_v15, pockettts, audiocpp,
@@ -103,7 +103,7 @@ VoiceStudio/
│
├── .agents/skills/ ⟵ canonical skill copies (vite, fastapi-python), pinned by
│ skills-lock.json — followed by path, never symlinked
-├── skills/ ⟵ skills this repo publishes (omnivoice, oss-maintainer)
+├── skills/ ⟵ skills this repo publishes (voicestudio, voicestudio-maintainer)
│
├── infra/ ⟵ edge/deploy workers (not the Docker deploy path)
│ └── install-redirect/ voicestudio.sh/install — UA-sniffing installer worker
diff --git a/docs/electron-dubbing.md b/docs/electron-dubbing.md
index 89992991..533e8f72 100644
--- a/docs/electron-dubbing.md
+++ b/docs/electron-dubbing.md
@@ -1,5 +1,11 @@
# Electron dubbing workspace
+The idle workspace includes an original/dubbed demo comparison with compact
+player controls. Sync playheads aligns positions without starting both videos.
+Sample transcript edits are retained per language while the demo is mounted;
+they do not regenerate the prerecorded audio. Edit on the dubbed card imports
+that sample video into the normal upload/transcription and editing workflow.
+
Open Dub from the cloning sidebar or command search. Upload or drop audio/video, or explicitly submit a video URL;
preparation completes before transcription starts. The editor shows source text,
editable translated text, and per-segment voice/timing controls. Translation uses
diff --git a/docs/electron-macos-shell.md b/docs/electron-macos-shell.md
new file mode 100644
index 00000000..2b754a3a
--- /dev/null
+++ b/docs/electron-macos-shell.md
@@ -0,0 +1,19 @@
+# macOS desktop shell
+
+The expanded sidebar reserves space for the native traffic lights and app name.
+The collapsed sidebar is 64 px wide, with its toggle below the traffic lights
+and its right divider beginning below the 72 px header region.
+
+Notifications appear at the top right, with space reserved before the bell.
+The notification menu opens downward and remains available while notification
+data loads. Settings uses an icon in the macOS sidebar footer; Local device
+sits beside it and opens the device and compute-target menu. The expanded
+sidebar retains the Local device label.
+
+Windows and Linux retain their existing notification and device placement.
+
+The notification control follows workspace headers in document order so their
+native drag regions cannot consume its mouse clicks. On macOS, run
+`node tests/native-bell-repro.mjs` from `electron/` against the dev renderer
+to verify a real system mouse click (requires Swift and Accessibility access).
+Browser automation alone bypasses native titlebar hit testing.
diff --git a/docs/electron-migration.md b/docs/electron-migration.md
new file mode 100644
index 00000000..c2fcf035
--- /dev/null
+++ b/docs/electron-migration.md
@@ -0,0 +1,20 @@
+# Moving to Electron
+
+The next desktop release uses Electron. Tauri receives one final sunset update;
+subsequent releases build only Electron. Existing Tauri downloads remain available.
+
+1. Find your Tauri data directory in Settings and back up the entire directory
+ while the app is closed. Keep reference audio stored outside it too.
+2. Install Electron for your platform. Keep Tauri and its data until verification.
+ Do not run both apps against the same data directory.
+3. Check Electron's configured data location before generating. Use its supported
+ storage/backend configuration to select the existing data directory.
+4. Verify voices, projects, history, and model locations. Generate a short test
+ clip before removing the old app.
+
+The shells share the backend, but shell preferences and credentials are not
+guaranteed to migrate. Recheck devices, shortcuts, theme, backend address and
+permissions. No automatic installer-to-installer migration is provided.
+
+The final Tauri updater feeds retain signed Tauri payloads at immutable URLs.
+A Tauri updater must never receive an Electron installer.
diff --git a/docs/electron-performance.md b/docs/electron-performance.md
index e370545e..2f006f01 100644
--- a/docs/electron-performance.md
+++ b/docs/electron-performance.md
@@ -1,10 +1,10 @@
# Electron compute and performance settings
-Settings > Compute device exposes the existing device override, Windows torch.compile workaround, generation time budgets, and hardware readouts.
+Settings > Compute device exposes the existing device override, the torch.compile workaround, generation time budgets, and hardware readouts.
Device choices come from the backend's detected families plus Auto. The chosen preference and currently active family are displayed separately. Environment-pinned choices are disabled, an ignored unavailable override is explained, and a changed preference shows its actual restart requirement. Failed saves keep the last confirmed state. Nothing automatically restarts the backend or changes the active model.
-The torch.compile workaround uses the same platform gate as Tauri: Windows can opt in; other platforms retain their working optimization. Generation budgets preserve separate GPU and CPU limits, validate the existing positive/21600-second range, and keep edits during refetches. An externally overridden budget reports that fact instead of implying the saved value will take effect after restart. Hardware RAM/VRAM readouts poll only while this view is mounted.
+The torch.compile workaround matches Tauri: since #2135 it is selectable on every platform, because the compile failures it works around are not Windows-only. Generation budgets preserve separate GPU and CPU limits, validate the existing positive/21600-second range, and keep edits during refetches. An externally overridden budget reports that fact instead of implying the saved value will take effect after restart. Hardware RAM/VRAM readouts poll only while this view is mounted.
During synthesis, the fixed-width primary action polls the existing model-status contract and names the active runtime phase: starting the AI runtime, loading weights, warming speech recognition, optimizing the model, generating, or receiving audio. Model-load percentage and elapsed time share the reserved status line, and the progress track switches from model loading to streamed audio delivery without moving the controls.
diff --git a/docs/feature-catalog.md b/docs/feature-catalog.md
new file mode 100644
index 00000000..120d6e7f
--- /dev/null
+++ b/docs/feature-catalog.md
@@ -0,0 +1,52 @@
+# Features and engines
+
+Engine availability depends on installed models, hardware, and configured providers.
+
+## Features
+
+- **Voice Cloning**
+- **Voice Design**
+- **Video Dubbing**
+- **Dictation Widget**
+- **Vocal Isolation**
+- **Speaker Diarization**
+- **Batch Queue**
+- **MCP Server**
+- **AI Watermark**
+- **Local-first**
+- **GPU Auto-Detect**
+- **Remote Model Downloads**
+- **Extensible**
+
+## Speech generation
+
+- **VoiceStudio** (default, powered by k2-fsa/OmniVoice)
+- omnivoice-subprocess — [Guide](engines/omnivoice-subprocess.md)
+- CosyVoice 3 — [Guide](engines/cosyvoice.md)
+- KittenTTS
+- MLX-Audio
+- VoxCPM2
+- MOSS-TTS-Nano
+- gpt-sovits
+- sherpa-onnx
+- **IndexTTS 2.5** ⚡ — [Guide](engines/indextts.md)
+- omnivoice-gguf
+- supertonic3
+- **MOSS-TTS-v1.5** — [Guide](engines/moss-tts-v15.md)
+- **dots.tts** — [Guide](engines/dots-tts.md)
+- **Confucius4-TTS** — [Guide](engines/confucius4-tts.md)
+- pockettts
+- audiocpp — [Guide](engines/audio-cpp.md)
+
+## Transcription
+
+- **WhisperX** (default)
+- Faster-Whisper
+- MLX Whisper
+- PyTorch Whisper
+- Parakeet TDT
+- Parakeet TDT v3 (MLX)
+- Moonshine
+- FunASR
+- **sherpa-onnx** (live dictation)
+- **OpenAI-compatible** ⚠️ configured server
diff --git a/docs/features.yaml b/docs/features.yaml
index dcf36b13..0b0209b7 100644
--- a/docs/features.yaml
+++ b/docs/features.yaml
@@ -1,3 +1,4 @@
+catalog: docs/feature-catalog.md
# Canonical feature inventory — the single source of truth that the daily
# docs-drift job (.github/workflows/docs-drift.yml) diffs against README.md,
# docs/, and the engine registries via scripts/check-docs-drift.py.
diff --git a/docs/hardware-notes-tesla-t4.md b/docs/hardware-notes-tesla-t4.md
index 58ba3e13..0e9d44e1 100644
--- a/docs/hardware-notes-tesla-t4.md
+++ b/docs/hardware-notes-tesla-t4.md
@@ -53,8 +53,8 @@ that the app already runs the "fast" preset unless you override it via `/generat
| dtype | `torch.float16` hardcoded for the `omnivoice` engine (`model_manager.py`) — correct for Turing (no bf16 tensor cores this generation). No env var override for this engine specifically (ASR engines have `ASR_COMPUTE_TYPE`; `dots_tts`/`indextts` have their own precision vars; `omnivoice` doesn't). |
| Attention | `sdpa`, selected automatically since `flash_attn` isn't installed (`_supports_flash_attn_2=True` is declared but the package itself is absent) — safe on T4. |
| int8 | No int8 path for this engine (ASR's CTranslate2 `int8` and `sherpa-onnx`'s int8 ONNX models are separate/unrelated). |
-| CUDA Graphs | No direct API usage in the app. Reachable indirectly via `torch.compile(mode="reduce-overhead")`, which the app attempts **by default** on this GPU (T4/sm_75 isn't in the framework's compile-exclusion list, unlike newer/Blackwell GPUs). The numbers above were measured with `TORCH_COMPILE_DISABLE=1` for a clean eager baseline. |
-| torch.compile | Attempted by default on T4 (see above) — not evaluated further here. |
+| CUDA Graphs | **Not used on T4 any more (#2135).** Reachable only indirectly via `torch.compile(mode="reduce-overhead")`, which the app used to attempt by default here — and which killed the backend process outright on the first `/generate` (no traceback, no HTTP response). The app now picks the compile mode per GPU and drops to the non-cudagraph `default` mode below sm_80. `OMNIVOICE_FORCE_CUDAGRAPH=1` restores the old behaviour for benchmarking. |
+| torch.compile | Still attempted on T4, in `default` mode — compiled Inductor kernels, no graph capture. Disable entirely with Settings → Performance → "Disable torch.compile" or `TORCH_COMPILE_DISABLE=1`. |
## VRAM
diff --git a/docs/install/linux.md b/docs/install/linux.md
index f73f9327..f54dbecf 100644
--- a/docs/install/linux.md
+++ b/docs/install/linux.md
@@ -1,5 +1,22 @@
# VoiceStudio — Install on Linux
+## Electron desktop (current)
+
+From the repository root, install Bun and uv, then run:
+
+```sh
+bun install
+bun run dev
+```
+
+Use `bun run desktop-prod` to build and launch Electron, or `bun run dist`
+to create local installers without publishing. The app manages its backend.
+See [Electron setup](../../electron/README.md) and [migration notes](../electron-migration.md).
+
+## Legacy Tauri installation and troubleshooting
+
+The instructions below apply to the sunset Tauri app and existing Tauri installers.
+
This page is self-contained: follow it top to bottom and you'll end up with a
working VoiceStudio install on a Debian / Ubuntu / Fedora / Arch host.
@@ -28,7 +45,7 @@ Everything above, plus the toolchain:
`sudo dnf install python3.11` on Fedora, or already installed on Arch.
- **Bun** — `curl -fsSL https://bun.sh/install | bash`.
- **Rust / Cargo** — `curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh` or via your package manager (e.g., `sudo apt install rustc cargo`).
- If you use rustup, reopen the shell or source `"$HOME/.cargo/env"` before running `bun run desktop-prod`.
+ If you use rustup, reopen the shell or source `"$HOME/.cargo/env"` before running `bun run tauri:desktop-prod`.
- **GTK/WebKit deps** for the Tauri shell:
```bash
@@ -66,10 +83,10 @@ git clone https://github.com/debpalash/VoiceStudio.git
cd VoiceStudio
bun install
source "$HOME/.cargo/env" # only needed in a shell opened before rustup finished
-bun desktop # development build with hot reload
+bun tauri # development build with hot reload
```
-Use `bun run desktop-prod` instead when you need to build and launch the
+Use `bun run tauri:desktop-prod` instead when you need to build and launch the
production bundle. Both commands create the Python environment via `uv`, sync
dependencies, and start the backend automatically; do not start the backend in
a second terminal.
@@ -87,7 +104,7 @@ pkg-config --exists \
&& echo "Tauri system libraries are ready"
```
-`bun desktop` also checks the native `libxdo` linker input and GStreamer's
+`bun tauri` also checks the native `libxdo` linker input and GStreamer's
`autoaudiosink` before starting. The latter is required even if you do not plan
to record: WebKitGTK 2.52 aborts its renderer when a page creates an audio
element without that plugin, which otherwise turns a running app blank. The
@@ -256,7 +273,7 @@ If you are on v0.4.0 or older, either update or build from source:
git clone https://github.com/debpalash/VoiceStudio.git
cd VoiceStudio
bun install
-bun run desktop-prod
+bun run tauri:desktop-prod
```
Tracking issues: [#62](https://github.com/debpalash/VoiceStudio/issues/62),
@@ -389,9 +406,9 @@ reinstall and left the CPU-only CUDA build in place).
**2. Environment variable (existing installs / headless / source).** Set
`OMNIVOICE_TORCH_VARIANT=rocm` before launching — the next bootstrap performs
the same ROCm reinstall. Source installs honour it too:
-`OMNIVOICE_TORCH_VARIANT=rocm bun run desktop` swaps torch right after
+`OMNIVOICE_TORCH_VARIANT=rocm bun run tauri` swaps torch right after
`uv sync` and launches the backend without re-syncing, so the wheel is not
-reverted on the next start (#1665). Without the variable, `bun run desktop`
+reverted on the next start (#1665). Without the variable, `bun run tauri`
restores the lockfile's CUDA build — a hand-swapped ROCm wheel does not
survive it. `OMNIVOICE_TORCH_INDEX=` overrides the wheel
index when you need a different ROCm version — e.g. AMD publishes newer
diff --git a/docs/install/macos.md b/docs/install/macos.md
index a1d7ac2c..5b7a772f 100644
--- a/docs/install/macos.md
+++ b/docs/install/macos.md
@@ -1,5 +1,22 @@
# VoiceStudio — Install on macOS
+## Electron desktop (current)
+
+From the repository root, install Bun and uv, then run:
+
+```sh
+bun install
+bun run dev
+```
+
+Use `bun run desktop-prod` to build and launch Electron, or `bun run dist`
+to create local installers without publishing. The app manages its backend.
+See [Electron setup](../../electron/README.md) and [migration notes](../electron-migration.md).
+
+## Legacy Tauri installation and troubleshooting
+
+The instructions below apply to the sunset Tauri app and existing Tauri installers.
+
This page is self-contained: follow it top to bottom and you'll end up with a
working VoiceStudio install on macOS (Apple Silicon).
@@ -36,7 +53,7 @@ Everything above, plus the toolchain:
- **Python 3.11+** — `brew install python@3.11` (or use `pyenv` / the system Python if you already have ≥3.11).
- **Bun** — `curl -fsSL https://bun.sh/install | bash`.
- **Rust / Cargo** — `curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh` or `brew install rust`.
- If you use rustup, reopen the terminal or source `"$HOME/.cargo/env"` before running `bun run desktop-prod`.
+ If you use rustup, reopen the terminal or source `"$HOME/.cargo/env"` before running `bun run tauri:desktop-prod`.
FFmpeg/FFprobe and yt-dlp are **not** prerequisites on any install path: the
app resolves them itself (a static build ships with the Python environment;
@@ -63,7 +80,7 @@ Or manually:
git clone https://github.com/debpalash/VoiceStudio.git
cd VoiceStudio
bun install
-bun run desktop-prod
+bun run tauri:desktop-prod
```
The first launch builds the Tauri shell, creates the Python venv via `uv`,
diff --git a/docs/install/troubleshooting.md b/docs/install/troubleshooting.md
index 543440dd..ee9701d9 100644
--- a/docs/install/troubleshooting.md
+++ b/docs/install/troubleshooting.md
@@ -248,6 +248,42 @@ peak memory footprint that exceeds free VRAM. Windows-only quirk.
**Linked issue:** [#65](https://github.com/debpalash/VoiceStudio/issues/65)
+## 5a. Backend dies on the first `/generate` (older NVIDIA GPUs, e.g. Tesla T4)
+
+**Symptom:** the backend starts fine, `/health` reports your GPU, the model
+preloads — and then the first generation request returns
+`RemoteDisconnected: Remote end closed connection without response`. Every call
+after it gets `ConnectionRefused`, because the backend process is gone. No
+Python traceback is printed.
+
+**Cause:** `torch.compile(mode="reduce-overhead")` captures CUDA graphs. On
+pre-Ampere cards (Turing sm_75 / Volta sm_70 — the Tesla T4 on Google Colab is
+the common case) that capture can abort the process from inside the native CUDA
+library. It happens below the interpreter, so no `except` in the app can catch
+it and nothing is logged.
+
+**Fix:** update — VoiceStudio now selects the compile mode per GPU and does not
+capture CUDA graphs below sm_80, so this should no longer happen. If you still
+see a crash in the generate path on any GPU, turn compilation off entirely:
+
+- **In the app:** Settings → Performance → **"Disable torch.compile"**.
+- **From the CLI / from source:** `TORCH_COMPILE_DISABLE=1` before launching.
+ This is honoured on every platform and by every engine, in-process or
+ sidecar.
+
+**Getting a traceback:** the backend now arms `faulthandler`, so a native crash
+writes the faulting thread's Python stack to `backend_err.log` on the way down.
+Include that stack when reporting — without it a native crash is unattributable.
+(`OMNIVOICE_DISABLE_FAULTHANDLER=1` turns it off.)
+
+**Extra containment:** to keep a crashing engine from taking the API down with
+it, run the engine in a killable child process — select
+**OmniVoice (subprocess-isolated)** in Settings → Engines, or
+`OMNIVOICE_TTS_BACKEND=omnivoice-subprocess`. The parent then returns an HTTP
+error and respawns the sidecar instead of dying.
+
+**Linked issue:** [#2135](https://github.com/debpalash/VoiceStudio/issues/2135)
+
## 5b. RTX 50-series (Blackwell, sm_120): backend crashes during `ml_imports`
**Symptom:** on an RTX 5070 / 5070 Ti / 5080 / 5090, the backend never becomes
diff --git a/docs/install/windows.md b/docs/install/windows.md
index 202a4039..0a359a36 100644
--- a/docs/install/windows.md
+++ b/docs/install/windows.md
@@ -1,5 +1,22 @@
# VoiceStudio — Install on Windows
+## Electron desktop (current)
+
+From the repository root, install Bun and uv, then run:
+
+```sh
+bun install
+bun run dev
+```
+
+Use `bun run desktop-prod` to build and launch Electron, or `bun run dist`
+to create local installers without publishing. The app manages its backend.
+See [Electron setup](../../electron/README.md) and [migration notes](../electron-migration.md).
+
+## Legacy Tauri installation and troubleshooting
+
+The instructions below apply to the sunset Tauri app and existing Tauri installers.
+
This page is self-contained: follow it top to bottom and you'll end up with a
working VoiceStudio install on Windows 10 / 11 (x64).
@@ -20,7 +37,7 @@ by the app itself on first launch. No toolchain needed.
Everything above, plus the toolchain:
- **Git for Windows** — `winget install --id Git.Git -e`. Needed for
- `git clone`, and it includes **Git Bash**, which `bun run desktop-prod`
+ `git clone`, and it includes **Git Bash**, which `bun run tauri:desktop-prod`
uses to run its build-and-launch script. Without it, `desktop-prod` stops
with an error telling you to install it.
- **Python 3.11+** — `winget install Python.Python.3.11` (or download from
@@ -32,7 +49,7 @@ Everything above, plus the toolchain:
- **Bun** — `powershell -c "irm bun.sh/install.ps1 | iex"`.
- **FFmpeg** — `winget install Gyan.FFmpeg`.
- **Rust / Cargo** — `winget install Rust.Rustup` or download `rustup-init.exe` from [rustup.rs](https://rustup.rs/).
- After installing Rustup, close and reopen PowerShell before running `bun run desktop-prod`.
+ After installing Rustup, close and reopen PowerShell before running `bun run tauri:desktop-prod`.
## GPU support on Windows
@@ -64,17 +81,17 @@ Or manually:
git clone https://github.com/debpalash/VoiceStudio.git
cd VoiceStudio
bun install
-bun run desktop-prod
+bun run tauri:desktop-prod
```
The first launch creates the Python venv via `uv`, syncs deps, and downloads
model weights. The splash screen shows progress.
-> **Note:** `bun run desktop-prod` runs a bash script under the hood. You can
+> **Note:** `bun run tauri:desktop-prod` runs a bash script under the hood. You can
> launch it from PowerShell or cmd as shown — it finds Git Bash automatically
> (installed with Git for Windows, see Prerequisites). If no Git Bash is
> found, it prints instructions instead of failing silently. Alternatives
-> that don't need bash: `bun run desktop` (dev mode) or the pre-built MSI
+> that don't need bash: `bun run tauri` (dev mode) or the pre-built MSI
> below.
## Install (pre-built MSI)
@@ -274,21 +291,25 @@ synthesise call. On machines with <16 GB VRAM, that compile step can OOM
failed`.
**The one-click fix:** open **Settings → Performance** in the app and toggle
-**"Disable torch.compile (Windows)"** on. That sets the
-`TORCH_COMPILE_DISABLE=1` env var on every engine subprocess VoiceStudio spawns,
-which falls back to the eager-mode kernel path. You'll lose a few percent of
-peak throughput in exchange for the engine actually loading.
+**"Disable torch.compile"** on. That sets the `TORCH_COMPILE_DISABLE=1` env var
+on every engine subprocess VoiceStudio spawns and forces the in-process engine
+to eager mode as well. You'll lose a few percent of peak throughput in exchange
+for the engine actually loading.
**From the CLI / from source:** set the env var manually before launching:
```powershell
$env:TORCH_COMPILE_DISABLE = "1"
-bun run desktop-prod
+bun run tauri:desktop-prod
```
-This setting is a no-op on macOS and Linux (the OOM is Windows-specific —
-the `torch.compile` kernel cache behaves differently on the other platforms).
-Tracking issue: [#65](https://github.com/debpalash/VoiceStudio/issues/65).
+The OOM this section describes is Windows-specific, but the toggle itself works
+on **every** platform — it used to be greyed out elsewhere, which left Linux and
+macOS users with no way to switch off a `torch.compile` that was breaking their
+engine. Tracking issues:
+[#65](https://github.com/debpalash/VoiceStudio/issues/65) (this OOM) and
+[#2135](https://github.com/debpalash/VoiceStudio/issues/2135) (the same toggle
+on Linux/CUDA).
## Hugging Face token (optional but recommended)
diff --git a/docs/media/sponsor-slot.svg b/docs/media/sponsor-slot.svg
new file mode 100644
index 00000000..0886cb6c
--- /dev/null
+++ b/docs/media/sponsor-slot.svg
@@ -0,0 +1,10 @@
+
diff --git a/docs/performance.md b/docs/performance.md
index 000496bf..0e8ad7e8 100644
--- a/docs/performance.md
+++ b/docs/performance.md
@@ -102,9 +102,20 @@ where the runtime check says it can work (a CUDA device with Triton importable
and a supported GPU architecture) and skipped automatically everywhere else —
MPS, CPU, and the typical Windows install (Triton ships no Windows wheel).
The one user-facing control is Settings → Performance → "Disable
-torch.compile" (shown on Windows), for the rare setup where a partial Triton
-install makes the probe pass but the compile attempt itself crash — see
-[Windows install notes](install/windows.md).
+torch.compile", available on every platform, for the setup where the probe
+passes but the compile attempt itself misbehaves — a partial Triton install,
+or a GPU whose compiled kernels crash the engine. Setting
+`TORCH_COMPILE_DISABLE=1` (or `TORCHDYNAMO_DISABLE=1`) in the environment does
+the same thing and is honoured by both the in-process engine and every engine
+subprocess. See [Windows install notes](install/windows.md).
+
+On CUDA the compile **mode** is chosen per GPU: Ampere (sm_80) and newer use
+`reduce-overhead`, which captures CUDA graphs; older cards (Turing/Volta, e.g.
+the Tesla T4) fall back to the plain `default` mode, because graph capture was
+observed to abort the whole backend process there
+([#2135](https://github.com/debpalash/VoiceStudio/issues/2135)). They still get
+compiled Inductor kernels. `OMNIVOICE_FORCE_CUDAGRAPH=1` restores the
+cudagraph mode if you want to benchmark it.
## Warnings before a slow generation
diff --git a/docs/remote-workers.md b/docs/remote-workers.md
index 40ef34e0..c3578ddc 100644
--- a/docs/remote-workers.md
+++ b/docs/remote-workers.md
@@ -396,3 +396,9 @@ would disrupt the machine or network, including airplane mode, simultaneous
downloads, and stopping a worker during an audiobook, are printed as exact
`MANUAL` steps and are never reported as passed automatically. A failed
precondition or automated check exits non-zero.
+
+Remote compute targets show available CPU/GPU usage and free VRAM. Unavailable
+metrics are omitted; a transient sampling failure retains the last successful
+reading. Telemetry runs off the control loop with at most one probe per worker
+client, retained across reconnects. Read-only probes never block task draining or
+shutdown; a stuck driver probe cannot accumulate more threads.
diff --git a/electron/README.md b/electron/README.md
index 3fcc1498..b3d4d29e 100644
--- a/electron/README.md
+++ b/electron/README.md
@@ -1,19 +1,11 @@
-# VoiceStudio — Electron shell (preview)
+# VoiceStudio — Electron desktop app
-An Electron rewrite of the desktop shell, built page by page. Today it ships
-**Voice cloning** only; the Tauri app in `frontend/` remains the product.
+Electron is the primary desktop app for voice cloning, stories, dubbing,
+transcription, voice design, and workflows. Tauri is retained only for its final
+sunset update; see [migration notes](../docs/electron-migration.md).
-Both shells talk to the same local FastAPI backend (`backend/`, port 3900), so
-voices, history and installed engines are shared. Nothing leaves the machine.
-
-The Electron UI follows T3 Code's styling foundation: shadcn Base UI Mira,
-Zinc light surfaces, near-black dark surfaces, blue actions, system fonts,
-compact controls, and translucent popovers/dialogs. Shared palette roles live in
-`src/renderer/src/styles/t3-theme.css`; app geometry and surface utilities live in
-`styles/globals.css`. The palette is adapted from
-[T3 Code](https://github.com/pingdotgg/t3code/blob/main/apps/web/src/index.css)
-under the MIT license (see `T3CODE-LICENSE.txt`).
-Light/dark switching is local; T3's theme editor and theme library are not included.
+The runtime supervisor manages the local FastAPI backend. Network integrations
+and remote workers require configuration; local generation stays on your machine.
## Stack
@@ -28,7 +20,7 @@ Light/dark switching is local; T3's theme editor and theme library are not inclu
## Run it
```sh
-cd electron
+# From the repository root
bun install
bun run dev # electron-vite: main + preload + renderer with HMR
```
@@ -57,7 +49,7 @@ app-relative `/api/...`:
```sh
bun run typecheck # tsgo, both projects
-bun run check # vp: format + lint + types
+bun run check:electron # types, tests, build, packaging contract
bun run test # vitest (jsdom)
bun run build # electron-vite build → out/
bun run dist # + electron-builder → release/
diff --git a/electron/electron-builder.config.mjs b/electron/electron-builder.config.mjs
index 4428d5db..2a8a3be1 100644
--- a/electron/electron-builder.config.mjs
+++ b/electron/electron-builder.config.mjs
@@ -94,13 +94,13 @@ export default {
'utf8',
).match(/NSMicrophoneUsageDescription<\/key>\s*([^<]+)<\/string>/)[1],
},
- target: [
- { target: 'dmg', arch: ['arm64', 'x64'] },
- { target: 'zip', arch: ['arm64', 'x64'] },
- ],
+ // The CLI matrix selects one architecture per runner and updater feed.
+ target: ['dmg', 'zip'],
category: 'public.app-category.productivity',
},
linux: {
+ // Linux targets rewrite ${arch} to x86_64/amd64; feeds use Node's x64.
+ artifactName: 'VoiceStudio-Electron-${version}-linux-x64.${ext}',
icon: '../frontend/src-tauri/icons/icon.png',
syncDesktopName: true,
target: ['AppImage', 'deb'],
diff --git a/electron/src/main/models-directory-authorization.test.ts b/electron/src/main/models-directory-authorization.test.ts
index 6642a4b9..c087d990 100644
--- a/electron/src/main/models-directory-authorization.test.ts
+++ b/electron/src/main/models-directory-authorization.test.ts
@@ -1,4 +1,4 @@
-import { mkdtemp, readFile, rm } from 'node:fs/promises';
+import { mkdtemp, readFile, realpath, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, expect, it } from 'vitest';
@@ -16,14 +16,14 @@ it('mints one-shot models-directory capabilities only after a writable directory
const selected = join(root, 'model cache');
const result = await authorizeModelsDirectory(dataDir, selected);
- expect(result.path).toBe(selected);
+ expect(result.path).toBe(await realpath(selected));
const payload = JSON.parse(
await readFile(join(dataDir, '.path-authorizations', `${result.authorization}.json`), 'utf8'),
);
expect(payload).toEqual({
token: result.authorization,
kind: 'models_dir',
- path: selected,
+ path: await realpath(selected),
});
});
diff --git a/electron/src/main/runtime-project.test.ts b/electron/src/main/runtime-project.test.ts
index cf9ea5da..036d80e2 100644
--- a/electron/src/main/runtime-project.test.ts
+++ b/electron/src/main/runtime-project.test.ts
@@ -101,7 +101,7 @@ describe('packaged runtime setup', () => {
'installing_deps',
'verifying',
]);
- expect(run.mock.calls).toHaveLength(3);
+ expect(run.mock.calls).toHaveLength(process.platform === 'darwin' ? 2 : 3);
expect(await runtimeReady(bundle, project)).toBe(true);
await writeFile(join(bundle, 'uv.lock'), 'updated dependencies');
expect(await runtimeReady(bundle, project)).toBe(false);
@@ -232,7 +232,7 @@ describe('packaged runtime setup', () => {
expect(fetch).not.toHaveBeenCalled();
expect(run.mock.calls[0]?.[0]).toBe(executable);
});
- it('installs and validates cuDNN 8 compatibility on a CUDA runtime', async () => {
+ it.skipIf(process.platform === 'darwin')('installs and validates cuDNN 8 compatibility on a CUDA runtime', async () => {
const { bundle, project } = await fixture();
const sitePackages = join(project, '.venv', 'Lib', 'site-packages');
const run = vi.fn(async (command: string, args: string[]) => {
@@ -279,7 +279,7 @@ describe('packaged runtime setup', () => {
expect(compatInstall?.[1]).toContain(join(sitePackages, 'cudnn8_compat'));
expect(await runtimeReady(bundle, project)).toBe(true);
});
- it('keeps a CUDA runtime incomplete when the compatibility wheel is partial', async () => {
+ it.skipIf(process.platform === 'darwin')('keeps a CUDA runtime incomplete when the compatibility wheel is partial', async () => {
const { bundle, project } = await fixture();
const sitePackages = join(project, '.venv', 'Lib', 'site-packages');
const run = vi.fn(async (command: string, args: string[]) => {
diff --git a/electron/src/renderer/src/components/app-shell/app-shell.tsx b/electron/src/renderer/src/components/app-shell/app-shell.tsx
index e5f5106c..e607d7b4 100644
--- a/electron/src/renderer/src/components/app-shell/app-shell.tsx
+++ b/electron/src/renderer/src/components/app-shell/app-shell.tsx
@@ -4,15 +4,26 @@ import { CommandPalette } from '@/components/command-palette';
import { Outlet, useRouterState } from '@tanstack/react-router';
import { BackendGate } from '../backend-gate';
import { RepairAgentDock } from './repair-agent-dock';
+import { isMac } from '../bridge';
+import { cn } from '@/lib/utils';
+import { useBackendStatus } from '@/hooks/use-backend-status';
+import { SystemNotifications } from './system-notifications';
export function AppShell() {
+ const backend = useBackendStatus();
const pathname = useRouterState({
select: (state) => state.location.pathname,
});
const settings = pathname.startsWith('/settings');
+ const macWorkspace = isMac() && !settings;
const SettingsWorkspace = pathname === '/settings/openapi' ? 'div' : 'main';
return (
-