Merge pull request #1 from velixio/feat/worker-protocol-v1

Remote GPU workers: run every GPU operation on the machine you pick
This commit is contained in:
velixio
2026-08-11 18:50:49 +05:30
committed by GitHub
140 changed files with 29815 additions and 479 deletions
+9
View File
@@ -395,3 +395,12 @@ jobs:
env:
HF_HUB_OFFLINE: "1" # same no-silent-downloads guard as the main pytest job
HF_HUB_CACHE: ${{ runner.temp }}/pockettts-empty-hf-cache
# Artifact commits depend on native Windows rename/replace semantics;
# Linux emulation cannot exercise sharing rules or path parsing.
- name: Remote-worker artifact paths (Windows)
if: runner.os == 'Windows' && matrix.backend_supported
run: uv run pytest tests/test_worker_upload_server.py tests/test_worker_server_integrity.py -q --tb=short
env:
HF_HUB_OFFLINE: "1"
HF_HUB_CACHE: ${{ runner.temp }}/worker-artifact-empty-hf-cache
+4
View File
@@ -154,3 +154,7 @@ playwright-report/
# probe — generated HTML reports
tests/probe/reports/
# Local architecture/planning scratch (goal docs, review briefs, council
# reports). Working notes for whoever is driving a change, not a repo artifact.
remote/
+12
View File
@@ -17,6 +17,7 @@ the frozen-backend fallback mirror it for their toolchains.
- RTX 40-series GPUs are used again instead of being sent to the CPU
- A warning before a slow generation, rather than after a five-minute wait
- The watermark can be turned off in Settings, as the docs always said
- Your other GPU can take the work now — send individual jobs to a second machine, opt-in
- Workspace tabs in the title bar, if you prefer them to the icon rail (#1412)
- macOS support now matches what the app actually delivers
- Linux AppImage: a blank white window on rolling distros (Mesa 26.1+) now starts normally
@@ -25,6 +26,8 @@ the frozen-backend fallback mirror it for their toolchains.
### Changed
- Remote GPU workers render audiobooks chapter by chapter, with automatic per-chapter local fallback and one combined notice if the worker drops out. (#1478)
- Remote GPU workers can now run a job to completion: long renders no longer die at two minutes, a worker that drops and reconnects mid-render keeps its work, and a timed-out job no longer takes the worker offline for good. Placing a job still needs the development-only `POST /workers/tasks`; wiring the app's own Synthesize button to it comes next.
- VoiceStudio now uses one waveform-and-spark mark across the title bar, About screen, README, browser favicon, and every desktop/platform icon. (#1487)
- PocketTTS now asks you to review its code license, model license and gated-access conditions before first use, and explains how to unlock the model instead of showing a raw download failure — thanks @paoloantinori! (#1442)
- The repository moved to github.com/debpalash/VoiceStudio. Every link in the app, docs and scripts now points there; GitHub redirects the old URLs, and the Docker image paths, the app bundle identifier and your data folder are all deliberately unchanged. (#1394)
@@ -35,6 +38,8 @@ the frozen-backend fallback mirror it for their toolchains.
### Added
- Remote GPU model downloads now use the normal Models install flow and show per-worker progress. (#1478)
- Settings → System → **Remote workers** sends individual jobs to GPUs on your other machines while everything else stays here. Off by default; each machine is added with a single-use token and approved before any audio reaches it. See [docs/remote-workers.md](docs/remote-workers.md).
- IndexTTS 2.5 is available as a pinned one-click sidecar with five-language dubbing, expressive cloning, and backward-compatible IndexTTS-2 support. (#1482) — thanks @marwanlhabti5-coder!
- Voice recording now offers microphone and channel selection with a live input-level meter on every desktop platform. (#1481)
- Settings → Appearance → **Navigation style** switches the workspace switcher between the icon rail down the window edge and browser-style tabs across the title bar. Both offer the same workspaces; the choice sticks across launches, and the rail stays the default. Tab labels fold down to icons when the title bar runs out of room — the workspace you're in keeps its name. (#1412)
@@ -49,6 +54,13 @@ the frozen-backend fallback mirror it for their toolchains.
### Fixed
- Remote workers that lack required task inputs, progress leases, or model-download commands are now refused visibly instead of returning wrong audio or hanging. (#1478)
- Remote GPU workers now synthesize a dub's fresh segments as one coarse job with live progress and cancellation; fitting, assembly and RVC remain local. (#1478)
- Gallery voice previews now fall back to a local render when a downloaded clip cannot be decoded, instead of failing silently. (#1478)
- A second VoiceStudio instance can no longer silently share the remote-worker port; it keeps running locally and explains how to resolve the conflict. (#1478)
- Remote GPU jobs stay pinned to the selected worker across retries and restarts, stop when their caller leaves, and cannot return from cancellation as completed. (#1478)
- Remote GPU model labels now survive registration, legacy blank model IDs share one capacity slot, long jobs retain bounded leases, and idle cleanup cannot evict a live local render. (#1478)
- Remote GPU jobs now stop before dispatch when that worker lacks the model, offer the download there, and refresh scheduling as soon as it finishes. (#1478)
- Restored the pre-release version to 0.4.2 while the next release remains in preparation. (#1488)
- Large multi-language dubbing batches now use compact searchable language and track managers instead of overflowing the editor. (#1492)
- Multi-language dubbing now translates, edits, generates, retains, and exports every selected language, and its language picker stays visible at viewport edges. (#1486)
+1
View File
@@ -93,6 +93,7 @@ Three flagships, five more headliners, and a dozen under the fold.
- 🛡️ **AI Watermark** — AudioSeal (Meta): invisible, survives compression.
- 🔬 **Diagnostics** — self-check suite, error journal, scrubbed diagnostic bundles.
- ⚡ **GPU Auto-Detect** — CUDA · MPS · ROCm (Linux, opt-in) · CPU; ≤8 GB VRAM auto-offloads.
- 📥 **Remote Model Downloads** — install pinned model weights on the selected worker with live progress.
- 🧭 **Engine routing** — preflight GPU check per engine; no silent CPU fallback.
- 🧩 **Extensible** — subclass `TTSBackend`, add any engine in ~50 lines.
- 🎒 **Portable personas** — export voices as `.ovsvoice` bundles: identity + watermark.
+10
View File
@@ -41,6 +41,16 @@ hiddenimports = [
# even though pyproject.toml ships the package. Guarded by
# tests/test_socks_proxy.py.
'socksio',
# Remote GPU workers (backend/worker/). The feature is opt-in, so every
# import of it is deliberately deferred to the moment it is switched on —
# inside `lifespan` and inside `ControlPlane.start()`. That keeps the cost
# off users who never enable it, but it also means a frozen build has no
# static import chain to follow, so the modules must be named here or the
# feature raises ModuleNotFoundError only in the installers.
'grpc', 'grpc.aio',
'worker.service', 'worker.agent',
'worker.transport.server', 'worker.transport.client',
'worker.protocol.gen.worker_v1_pb2', 'worker.protocol.gen.worker_v1_pb2_grpc',
# Core
'uuid', 'asyncio',
+155 -18
View File
@@ -15,6 +15,13 @@ Design notes
* Previews are cached on disk keyed by a hash of (instruct, language), so two
archetypes that resolve to the same voice share a cache file and the cold
render only happens once per distinct voice.
* That same key names the pre-rendered clips in the opt-in voice gallery
(``services.gallery``), which is consulted BEFORE the engine so a fresh
install can hear voices before the 2.4 GB checkpoint finishes downloading.
Gallery files win over a local render of the same key but only for
``/preview``. ``/use`` always renders locally: the WAV it keeps in
``VOICES_DIR`` is the reference audio a cloned voice is built from, and a
downloaded MP3 must never become that.
"""
from __future__ import annotations
@@ -26,11 +33,12 @@ import uuid
from pathlib import Path
from typing import Optional
from fastapi import APIRouter, HTTPException, Query
from fastapi import APIRouter, Body, HTTPException, Query
from fastapi.responses import FileResponse
from core import archetypes
from core.config import OUTPUTS_DIR, VOICES_DIR
from services import gallery
logger = logging.getLogger("omnivoice.archetypes")
@@ -214,6 +222,49 @@ async def _render_archetype_wav(a: dict, out_path: Path) -> None:
_safe_torchaudio_save(str(out_path), audio_tensor, model.sampling_rate)
def _no_voice_model_downloaded() -> bool:
"""True only on a *positive* "no TTS weights on this machine" answer.
Fails open on purpose: the cache probes are best-effort (a user-managed
clone outside the HF layout is invisible to them), and telling someone with
a working engine to go download a model is worse than saying nothing. Only
a catalog we could read, with not one TTS repo cached, earns the offline
message.
"""
try:
from api.routers.setup.models import get_model_catalog, is_cached
tts = [m for m in get_model_catalog().all if m.get("role") == "TTS"]
return bool(tts) and not any(is_cached(m["repo_id"]) for m in tts)
except Exception:
return False
def _preview_source(a: dict) -> tuple[str, str]:
"""Which path ``/preview`` will take for *a*, and what to tell the user.
Replaces the old "see Settings → Logs → Backend" advice, which asked a user
who wanted to hear a voice to go read a log file. The three states that
actually differ are: we already have the audio (gallery), we can make it
(render say so, it takes a moment), and we can neither fetch nor make it
(no model the one state with an action attached).
"""
key = _preview_key(a)
if gallery.cached_preview(key) is not None:
return "gallery", (
"Pre-rendered preview from the voice gallery — a fixed reference "
"rendering, not a render from your current engine."
)
if (_PREVIEW_DIR / f"{key}.wav").exists():
return "cached", ""
if _no_voice_model_downloaded():
return "no_model", (
"You're offline and no voice model is downloaded yet — "
"Settings → Models → Download."
)
return "rendering", "Rendering this preview on your machine — it may take a moment."
# ── Read endpoints (no model) ─────────────────────────────────────────────────
# NOTE: declare the literal `/archetypes/categories` before `/archetypes/{id}`
# so it isn't swallowed by the path-parameter route.
@@ -223,6 +274,37 @@ def list_categories():
return archetypes.categories()
# ── Voice-gallery (pre-rendered previews) ─────────────────────────────────────
# Declared above `/archetypes/{archetype_id}` for the same reason as
# `/categories`: keep literal paths out of the path-parameter route's reach.
@router.get("/archetypes/previews/status")
def preview_gallery_status():
"""Consent state, coverage and freshness for the Settings line."""
return gallery.status()
@router.put("/archetypes/previews")
async def set_preview_gallery(enabled: bool = Body(..., embed=True)):
"""Turn pre-rendered previews on or off.
Turning it ON is the user's explicit yes to an outbound call, and is the
only thing that ever starts one there is no on-install background fetch.
The featured set is pulled right here so the yes has a visible effect;
failures are silent by design (``fetch_featured`` swallows them) and leave
previews rendering locally.
"""
state = gallery.set_enabled(enabled)
if enabled:
state = await gallery.fetch_featured()
return state
@router.post("/archetypes/previews/check")
async def check_preview_gallery():
"""Manual "check now" — bypasses the 24 h throttle, never the signature."""
return await gallery.check_for_updates(force=True)
@router.get("/archetypes")
def list_archetypes_endpoint(
q: Optional[str] = None,
@@ -262,33 +344,77 @@ def get_archetype_endpoint(archetype_id: str):
# ── Render endpoints (model-gated) ────────────────────────────────────────────
@router.get("/archetypes/{archetype_id}/preview/state")
def preview_archetype_state(archetype_id: str):
"""Where the next ``/preview`` for this archetype would come from.
Touches neither the model nor the network, so a picker can label a voice
("may take a moment", "download a model first") *before* it commits to a
request that may take 40 seconds or fail.
"""
a = archetypes.get_archetype(archetype_id)
if a is None:
raise HTTPException(status_code=404, detail="Archetype not found")
source, message = _preview_source(a)
return {"source": source, "message": message}
@router.get("/archetypes/{archetype_id}/preview")
async def preview_archetype(archetype_id: str):
"""Serve a short preview clip — pre-rendered if cached, else render once."""
async def preview_archetype(
archetype_id: str,
local: bool = Query(False, description="Bypass gallery audio after a client decode failure"),
):
"""Serve a short preview clip — from the gallery, the cache, or the engine."""
a = archetypes.get_archetype(archetype_id)
if a is None:
raise HTTPException(status_code=404, detail="Archetype not found")
cache_path = _PREVIEW_DIR / f"{_preview_key(a)}.wav"
key = _preview_key(a)
# Gallery first, and only for /preview: these bytes are audio we can prove
# the provenance of, so they beat a local render of the same key. A miss
# (offline, disabled, key not published) is silent — we just render.
gallery_path = None if local else gallery.cached_preview(key)
if gallery_path is None and not local:
gallery_path = await gallery.fetch_preview(key)
if gallery_path is not None:
# Nothing else in the app polls, so the daily refresh hangs off the
# request that proves previews are being used. Fire-and-forget.
gallery.maybe_refresh_in_background()
return FileResponse(
str(gallery_path),
media_type="audio/mpeg",
headers={"Cache-Control": "no-cache",
"X-OmniVoice-Preview-Source": "gallery"},
)
cache_path = _PREVIEW_DIR / f"{key}.wav"
if not cache_path.exists():
try:
await _render_archetype_wav(a, cache_path)
except Exception as e: # model missing / OOM / inference failure
logger.error("Archetype preview render failed", exc_info=True)
raise HTTPException(
status_code=503,
detail=(
"Couldn't render a preview right now — the voice engine is "
f"unavailable. See Settings → Logs → Backend. Error: {e}"
),
)
# Two different failures, two different answers. Without a model
# there is nothing to read in a log — there is something to do.
if _no_voice_model_downloaded():
detail = (
"You're offline and no voice model is downloaded yet — "
"Settings → Models → Download. (Or turn on pre-rendered "
"voice previews in Settings → Models.)"
)
else:
detail = (
"Couldn't render a preview right now — the voice engine "
f"reported: {e}"
)
raise HTTPException(status_code=503, detail=detail)
# no-cache (not no-store): the URL is stable but its bytes change when an
# archetype's preview is re-rendered, so force the client to revalidate
# against the ETag instead of serving a stale cached clip indefinitely.
return FileResponse(
str(cache_path),
media_type="audio/wav",
headers={"Cache-Control": "no-cache"},
headers={"Cache-Control": "no-cache",
"X-OmniVoice-Preview-Source": "local"},
)
@@ -300,6 +426,11 @@ async def use_archetype(archetype_id: str, name: Optional[str] = Query(None)):
preview) and inserts a ``voice_profiles`` row carrying the archetype's
instruct + language. The profile then shows up everywhere voices are
picked (Dub / Generate / Clone).
Never sourced from the voice gallery, no matter how cheap that would be:
this WAV lands in ``VOICES_DIR`` as the profile's reference audio, so a
downloaded, lossily-encoded MP3 would silently become the sample every
future clone of this voice is built from. It renders locally or it fails.
"""
a = archetypes.get_archetype(archetype_id)
if a is None:
@@ -330,13 +461,19 @@ async def use_archetype(archetype_id: str, name: Optional[str] = Query(None)):
await _render_archetype_wav(a, audio_path)
except Exception as e:
logger.error("Archetype 'use' render failed", exc_info=True)
raise HTTPException(
status_code=503,
detail=(
# Same actionable/diagnostic split as /preview — minus the gallery
# suggestion, which cannot help here.
if _no_voice_model_downloaded():
detail = (
"Creating a voice needs the voice model — no voice model is "
"downloaded yet. Settings → Models → Download."
)
else:
detail = (
"Couldn't create a voice from this archetype — the voice engine "
f"is unavailable. See Settings → Logs → Backend. Error: {e}"
),
)
f"reported: {e}"
)
raise HTTPException(status_code=503, detail=detail)
profile_name = (name or a["name"]).strip() or a["name"]
try:
+102 -21
View File
@@ -25,6 +25,7 @@ import json
import logging
import os
import re
import shutil
import uuid
from collections.abc import Awaitable, Callable
@@ -681,6 +682,87 @@ def _render_chapter_cached(chapter, synth, sr, engine_id, resolve, cache_dir, le
"cached": seg_cache.hits}
def _remote_chapter_call(chapter, *, engine_id, default_voice, voice_map,
language, lexicon, opts, cache_dir):
"""Build one opaque remote chapter task without loading a local TTS model."""
import hashlib
from services import gpu_gateway
from services.text_normalization import normalize_for_tts
from services.watermark import is_enabled as watermark_enabled
rows, voices, refs = [], [], []
for span in chapter.spans:
profile_id = _map_span_voice(span.voice_id, default_voice, voice_map)
voice = _resolve_voice(profile_id)
rows.append({
"text": normalize_for_tts(span.text, language),
"pause_ms_after": span.pause_ms_after,
"speed": getattr(span, "speed", None),
})
refs.append(voice.get("ref_audio"))
voices.append({
"ref_text": voice.get("ref_text"), "instruct": voice.get("instruct"),
"seed": voice.get("seed"),
})
params = {
"spans": rows, "voices": voices, "ref_audio": refs,
"language": language, "lexicon": lexicon,
"expressive": opts.to_manifest(), "watermark": bool(watermark_enabled()),
}
signature = hashlib.sha256(json.dumps(params, sort_keys=True, default=str).encode()).hexdigest()
wav_path = os.path.join(cache_dir, f"remote-{signature}.wav")
def decode(result):
import soundfile as sf
if not os.path.exists(wav_path):
partial = f"{wav_path}.part"
shutil.copyfile(result.path, partial)
os.replace(partial, wav_path)
info = sf.info(wav_path)
return wav_path, float(info.duration), False, None
return gpu_gateway.RemoteCall(
engine=engine_id, operation="audiobook", params=params,
idempotency_key=f"audiobook:{signature}", decode=decode,
), wav_path
async def _run_chapter(chapter, *, operation="audiobook", decision, job, default_voice, language, opts,
voice_map, lexicon, cache_dir):
"""Run one chapter through the gateway; local preparation stays lazy."""
from services import gpu_gateway
from services.tts_backend import active_backend_id
engine_id = active_backend_id()
remote, remote_cache = _remote_chapter_call(
chapter, engine_id=engine_id, default_voice=default_voice,
voice_map=voice_map, language=language, lexicon=lexicon,
opts=opts, cache_dir=cache_dir,
)
if decision.remote and os.path.exists(remote_cache):
import soundfile as sf
info = sf.info(remote_cache)
return remote_cache, float(info.duration), True, None
async def prepare_local():
synth, sr, resolve, local_engine = await _prepare_synth(
default_voice, language=language, opts=opts, voice_map=voice_map
)
return gpu_gateway.LocalCall(
fn=lambda: _render_chapter_cached(
chapter, synth, sr, local_engine, resolve, cache_dir, lexicon,
language, opts, voice_map,
),
what="Audiobook chapter",
)
return await gpu_gateway.run(
operation, local=gpu_gateway.LocalCall(prepare=prepare_local),
remote=remote, decision=decision, job=job,
)
class AudiobookPreviewRequest(ExpressiveMixin):
text: str
chapter_index: int = 0
@@ -700,7 +782,7 @@ async def audiobook_preview(req: AudiobookPreviewRequest) -> dict:
cache (the later full render reuses it) and a re-preview is instant.
"""
from core.config import OUTPUTS_DIR
from services.model_manager import _gpu_pool
from services import gpu_gateway
plan = parse_audiobook_script(req.text, default_voice=req.default_voice)
if not plan.chapters:
@@ -714,16 +796,11 @@ async def audiobook_preview(req: AudiobookPreviewRequest) -> dict:
os.makedirs(cache_dir, exist_ok=True)
resolved_lang = _resolve_default_language(req.language, req.default_voice)
opts = _expressive_opts(req)
synth, sr, resolve, engine_id = await _prepare_synth(
req.default_voice,
language=resolved_lang,
opts=opts,
voice_map=req.voice_map,
)
loop = asyncio.get_running_loop()
wav_path, dur, was_cached, _seg_stats = await loop.run_in_executor(
_gpu_pool, _render_chapter_cached, chapter, synth, sr, engine_id, resolve, cache_dir,
req.lexicon, resolved_lang, opts, req.voice_map,
decision = gpu_gateway.decide("audiobook")
wav_path, dur, was_cached, _seg_stats = await _run_chapter(
chapter, decision=decision, job=None, default_voice=req.default_voice,
language=resolved_lang, opts=opts, voice_map=req.voice_map,
lexicon=req.lexicon, cache_dir=cache_dir,
)
return {
"output": os.path.relpath(wav_path, OUTPUTS_DIR), # served via /audio
@@ -762,7 +839,7 @@ async def _render_longform_sse(
from core.config import OUTPUTS_DIR
from core.failure import build_failure, build_failure_event
from services.ffmpeg_utils import find_ffmpeg, run_ffmpeg
from services.model_manager import _gpu_pool
from services import gpu_gateway
opts = opts or ExpressiveOptions()
@@ -837,13 +914,11 @@ async def _render_longform_sse(
cache_dir = os.path.join(OUTPUTS_DIR, "longform_cache")
os.makedirs(cache_dir, exist_ok=True)
prune_cache_dir(cache_dir) # bound disk before this job adds its chapters
loop = asyncio.get_running_loop()
try:
resolved_lang = _resolve_default_language(language, default_voice)
synth, sr, resolve, engine_id = await _prepare_synth(
default_voice, language=resolved_lang, opts=opts, voice_map=voice_map
)
operation = "audiobook" if job_type == "audiobook" else "longform"
decision = gpu_gateway.decide(operation)
chapter_run = gpu_gateway.JobRun(operation)
total = len(plan.chapters)
chapter_files: list[str] = []
@@ -877,10 +952,11 @@ async def _render_longform_sse(
interrupted = True
break
try:
wav_path, dur, was_cached, seg_stats = await loop.run_in_executor(
_gpu_pool, _render_chapter_cached,
chapter, synth, sr, engine_id, resolve, cache_dir, lexicon,
resolved_lang, opts, voice_map,
wav_path, dur, was_cached, seg_stats = await _run_chapter(
chapter, operation=operation, decision=decision, job=chapter_run,
default_voice=default_voice, language=resolved_lang,
opts=opts, voice_map=voice_map, lexicon=lexicon,
cache_dir=cache_dir,
)
except Exception as e: # isolate a bad chapter — keep going
logger.warning("[%s] chapter %d (%s) failed to render",
@@ -918,6 +994,11 @@ async def _render_longform_sse(
ev["cached_segments"] = seg_stats["cached"]
yield _emit(ev)
route_notice = chapter_run.notice()
if route_notice is not None:
yield _emit({"type": "routing_notice", "status": route_notice[0],
"reason": route_notice[1]})
if interrupted:
logger.info("[%s] client disconnected — stopped after %d/%d chapters",
job_id, len(chapter_files), total)
+212 -26
View File
@@ -4,6 +4,8 @@ import json
import logging
import time
import asyncio
import shutil
import zipfile
import torch
import torchaudio
from fastapi import APIRouter, HTTPException
@@ -13,7 +15,8 @@ from core.config import DUB_DIR, VOICES_DIR, dub_seg_path
from core.tasks import task_manager
from schemas.requests import DubRequest
from services.model_manager import _gpu_pool, run_on_gpu_pool_guarded
from services.tts_backend import resolve_generation_backend
from services.tts_backend import resolve_generation_backend, active_backend_id
from services import gpu_gateway
from services.audio_dsp import apply_mastering, normalize_audio, apply_effects_chain, get_effect_chain
from services.audio_io import atomic_save_wav, _safe_torchaudio_save
from services.ffmpeg_utils import (
@@ -45,6 +48,38 @@ logger = logging.getLogger("omnivoice.dub")
MAX_STRETCH_RATIO = 1.8
def _prepare_oom_retry(error: Exception, *, execution_target: str) -> bool:
"""Prepare one *local* low-step retry after a genuine device OOM.
The cache being flushed must belong to the device that raised the error.
A remote worker owns its own recovery policy; flushing this process's CUDA
cache after a remote failure both stalls the wrong GPU and can evict an
unrelated local job. Keep this guard at the retry chokepoint so a future
``dub_segments`` producer cannot accidentally inherit the old behaviour.
Returns ``False`` for non-OOM errors. Remote OOMs are deliberately raised
unchanged: the worker may classify/retry them, but this process must not.
"""
is_oom = (
isinstance(error, torch.cuda.OutOfMemoryError)
or "out of memory" in str(error).lower()
or "CUDA error" in str(error)
)
if not is_oom:
return False
if execution_target != "local":
raise error
import gc
gc.collect()
if torch.cuda.is_available():
torch.cuda.empty_cache()
elif hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
torch.mps.empty_cache()
return True
def _underrun_min_rate() -> float:
"""Floor for the underrun fill (audio slowed toward its slot, never below
this rate). Default 0.85 stays natural-sounding; OMNIVOICE_UNDERRUN_MIN_RATE=1.0
@@ -317,6 +352,75 @@ def resolve_consistent_ref(job: dict, speaker_key: str, memo: dict | None = None
return ref
def _remote_voice(job: dict, profile_id: str | None, seg_id, voice_match: str,
memo: dict) -> tuple[str | None, str | None, bool, str | None, int | None]:
"""Resolve a dub binding without touching the TTS model."""
ref_audio = ref_text = instruct = None
seed = None
single_use = False
if profile_id and profile_id.startswith("auto-seg:"):
sid = profile_id[len("auto-seg:"):]
info = (job.get("segment_clones") or {}).get(sid)
shared = False
if voice_match == "consistent" and sid == str(seg_id):
key = _speaker_key_for_segment(job, sid)
alternate = resolve_consistent_ref(job, key, memo) if key else None
if alternate:
info = alternate
shared = True
if info:
ref_audio, ref_text = info.get("ref_audio"), info.get("ref_text")
single_use = not shared
elif profile_id and profile_id.startswith("auto:"):
key = profile_id[len("auto:"):]
if voice_match == "consistent":
info = resolve_consistent_ref(job, key, memo)
else:
info = ((job.get("segment_clones") or {}).get(str(seg_id))
or _find_speaker_clone(job.get("speaker_clones") or {}, key))
single_use = str(seg_id) in (job.get("segment_clones") or {})
if info:
ref_audio, ref_text = info.get("ref_audio"), info.get("ref_text")
elif profile_id:
with db_conn() as conn:
row = conn.execute("SELECT * FROM voice_profiles WHERE id=?", (profile_id,)).fetchone()
if row:
seed = row["seed"]
if row["is_locked"] and row["locked_audio_path"]:
ref_audio = os.path.join(VOICES_DIR, row["locked_audio_path"])
ref_text = row["ref_text"]
elif row["instruct"] and not row["is_locked"]:
try:
vd_states = row["vd_states"]
except (KeyError, IndexError):
vd_states = None
instruct = heal_design_instruct(row["instruct"], vd_states)
else:
ref_audio = os.path.join(VOICES_DIR, row["ref_audio_path"])
ref_text = row["ref_text"]
return ref_audio, ref_text, single_use, instruct, seed
def _decode_remote_dub(result: gpu_gateway.RemoteResult) -> dict[int, str]:
"""Extract the worker bundle into a task-scoped directory, path-safely."""
target = os.path.join(DUB_DIR, ".remote", result.task_id)
os.makedirs(target, exist_ok=True)
paths: dict[int, str] = {}
with zipfile.ZipFile(result.path) as archive:
for member in archive.infolist():
match = re.fullmatch(r"segments/(\d+)\.wav", member.filename)
if not match:
raise ValueError(f"unexpected dub artifact member: {member.filename}")
index = int(match.group(1))
destination = os.path.join(target, f"{index}.wav")
partial = f"{destination}.part"
with archive.open(member) as source, open(partial, "wb") as output:
shutil.copyfileobj(source, output)
os.replace(partial, destination)
paths[index] = destination
return paths
router = APIRouter()
@router.post("/dub/generate/{job_id}")
@@ -488,6 +592,7 @@ async def dub_generate(job_id: str, req: DubRequest):
# every segment of that speaker for the whole run.
voice_match = (req.voice_match or "per_line").lower()
_consistent_ref_memo: dict = {}
remote_audio: dict[int, str] = {}
# Strategy-transition guard: smart_fit re-mixes the *natural-rate*
# per-segment WAVs from disk. If the previous run used strict_slot,
# the on-disk WAVs are slot-squeezed ("slotted") — reusing them would
@@ -528,6 +633,86 @@ async def dub_generate(job_id: str, req: DubRequest):
_t_cache = 0.0
_t_tts = 0.0
# One coarse remote lease for every segment that actually needs fresh
# synthesis. Assembly, fitting and the separately-pooled RVC pass stay
# here; the worker returns a single verified bundle of segment WAVs.
decision = gpu_gateway.decide("dub_segments")
if decision.remote:
remote_rows: list[dict] = []
remote_refs: list[str | None] = []
for i, seg in enumerate(req.segments):
seg_id = seg_ids[i] if i < len(seg_ids) else f"seg_{i}"
if (regen_only is not None and seg_id not in regen_only) or not seg.text.strip():
continue
ref_audio, ref_text, ref_single_use, profile_instruct, seed = _remote_voice(
job, seg.profile_id or None, seg_id, voice_match, _consistent_ref_memo
)
ref_audio = warn_if_ref_missing(
ref_audio, job_id=job_id, seg_id=seg_id, where="remote dub render"
)
seg_instruct = seg.instruct or req.instruct or profile_instruct
seg_speed = seg.speed if seg.speed is not None else req.speed
if seg.direction and seg.direction.strip():
try:
from services.director import parse as _parse_direction
direction = _parse_direction(seg.direction)
extra = direction.instruct_prompt()
if extra:
seg_instruct = f"{seg_instruct}, {extra}" if seg_instruct else extra
bias = direction.rate_bias()
if bias and abs(bias - 1.0) > 0.01 and strategy == "strict_slot":
seg_speed = (seg_speed or 1.0) * bias
except Exception:
logger.debug("direction parse skipped for remote segment %s", seg_id,
exc_info=True)
remote_rows.append({
"index": i, "text": seg.text,
"language": seg.target_lang or req.language,
"ref_text": ref_text, "ref_single_use": ref_single_use,
"instruct": seg_instruct,
"duration": (seg.end - seg.start) if strategy == "strict_slot" else None,
"num_step": 8 if req.preview else req.num_step,
"guidance_scale": req.guidance_scale, "speed": seg_speed,
"effect_preset": seg.effect_preset or "broadcast",
"seed": seed,
# RVC changes the waveform locally after TTS, so that path
# is marked at the existing post-RVC chokepoint below.
"watermark": not rvc_is_enabled(),
})
remote_refs.append(ref_audio)
if remote_rows:
states: asyncio.Queue = asyncio.Queue()
call = gpu_gateway.RemoteCall(
engine=active_backend_id(), operation="dub_segments",
params={"segments": remote_rows, "ref_audio": remote_refs},
decode=_decode_remote_dub,
)
dub_run = gpu_gateway.JobRun("dub_segments")
run = asyncio.create_task(gpu_gateway.run(
"dub_segments", local=gpu_gateway.LocalCall(fn=lambda: {}),
remote=call, decision=decision, job=dub_run,
on_state=states.put_nowait,
))
while not run.done():
if task_manager.is_cancelled(task_id):
run.cancel()
try:
await run
except asyncio.CancelledError:
pass
yield f"data: {json.dumps({'type': 'cancelled', 'segments_processed': 0})}\n\n"
return
try:
state = await asyncio.wait_for(states.get(), timeout=0.25)
except asyncio.TimeoutError:
continue
fraction = float(state.get("progress") or 0.0)
yield f"data: {json.dumps({'type': 'progress', 'current': round(fraction * total, 2), 'total': total, 'text': state.get('stage') or state.get('phase')})}\n\n"
remote_audio = await run
notice = dub_run.notice()
if notice is not None:
yield f"data: {json.dumps({'type': 'routing_notice', 'status': notice[0], 'reason': notice[1]})}\n\n"
for i, seg in enumerate(req.segments):
seg_id = seg_ids[i] if i < len(seg_ids) else f"seg_{i}"
@@ -536,7 +721,8 @@ async def dub_generate(job_id: str, req: DubRequest):
yield f"data: {json.dumps({'type': 'cancelled', 'segments_processed': i})}\n\n"
return
yield f"data: {json.dumps({'type': 'progress', 'current': i, 'total': total, 'text': seg.text[:50]})}\n\n"
if not remote_audio:
yield f"data: {json.dumps({'type': 'progress', 'current': i, 'total': total, 'text': seg.text[:50]})}\n\n"
seg_duration = seg.end - seg.start
if seg_duration <= 0.05 or not seg.text.strip():
@@ -612,7 +798,8 @@ async def dub_generate(job_id: str, req: DubRequest):
sync_scores.append(1.0)
continue
def _gen(text, lang, instruct_str, dur_s, nstep, cfg, spd, profile_id, effect_preset):
def _gen(text, lang, instruct_str, dur_s, nstep, cfg, spd, profile_id, effect_preset,
*, execution_target="local"):
# Normalize once at the segment's text→engine choke point
# (covers the OOM-retry generate below too, which reuses this
# closure's `text`). Pref-gated, idempotent, never raises.
@@ -782,19 +969,7 @@ async def dub_generate(job_id: str, req: DubRequest):
)
return normalize_audio(mastered_audio, target_dBFS=-2.0)
except Exception as e:
is_oom = (
isinstance(e, torch.cuda.OutOfMemoryError)
or "out of memory" in str(e).lower()
or "CUDA error" in str(e)
)
import gc
gc.collect()
if torch.cuda.is_available():
torch.cuda.empty_cache()
elif hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
torch.mps.empty_cache()
if not is_oom:
if not _prepare_oom_retry(e, execution_target=execution_target):
raise
retry_steps = min(nstep, 8)
@@ -900,14 +1075,24 @@ async def dub_generate(job_id: str, req: DubRequest):
# Budget from the shared length-scaled helper (#1190): a long
# dub segment used to die on the flat 300s even after v0.3.22.
from services.model_manager import generate_timeout_s
audio_tensor = await run_on_gpu_pool_guarded(
lambda: _gen(
seg.text, seg_lang, seg_instruct, _dur_for_tts,
_num_step, req.guidance_scale, seg_speed, seg_profile, seg_effect_preset,
),
what="Dub generate",
timeout=generate_timeout_s(seg.text),
)
if i in remote_audio:
audio_tensor, remote_sr = torchaudio.load(remote_audio[i])
try:
os.unlink(remote_audio[i])
except OSError:
pass
if remote_sr != backend.sample_rate:
import torchaudio.functional as AF
audio_tensor = AF.resample(audio_tensor, remote_sr, backend.sample_rate)
else:
audio_tensor = await run_on_gpu_pool_guarded(
lambda: _gen(
seg.text, seg_lang, seg_instruct, _dur_for_tts,
_num_step, req.guidance_scale, seg_speed, seg_profile, seg_effect_preset,
),
what="Dub generate",
timeout=generate_timeout_s(seg.text),
)
_t_tts += time.perf_counter() - _t_tts_0
# Check abort immediately after GPU work completes
@@ -996,8 +1181,9 @@ async def dub_generate(job_id: str, req: DubRequest):
# no double-mark. Cached-reuse audio is already marked;
# silence/zero slots carry no speech to mark, so neither is
# re-watermarked.
audio_tensor = mark_synthetic(audio_tensor, backend.sample_rate,
context="dub_generate.segment")
if i not in remote_audio or rvc_is_enabled():
audio_tensor = mark_synthetic(audio_tensor, backend.sample_rate,
context="dub_generate.segment")
seg_wav_path = _seg_lang_path(seg_id)
try:
+8 -15
View File
@@ -281,22 +281,15 @@ def uninstall_sidecar_engine(engine_id: str):
# repeated health checks don't spawn a new SubprocessBackend (each spawn
# allocates a sidecar venv probe + atexit hook). The cache is keyed by
# class to survive registry-sandbox tests that rebind ids transiently.
_ENGINE_INSTANCES: dict[type, object] = {}
#
# It now lives in services.tts_backend — the worker executor needs the same
# warm instances and cannot import an API router without inverting the
# layering. This name is the SAME dict object, kept so the existing consumers
# (engine_memory eviction, model_lifecycle inventory/unload) go on working
# unchanged; rebinding it here would fork the cache in two.
_ENGINE_INSTANCES: dict[type, object] = tts_backend._ENGINE_INSTANCES
def _get_engine_instance(cls):
"""Return a cached singleton instance of ``cls``.
SubprocessBackend's ``__init__`` registers an atexit shutdown hook,
so re-instantiating per request would leak handler entries (and on
real engines, additional sidecar processes the first time the lock
is acquired). One instance per process is the right move.
"""
inst = _ENGINE_INSTANCES.get(cls)
if inst is None:
inst = cls()
_ENGINE_INSTANCES[cls] = inst
return inst
_get_engine_instance = tts_backend.get_engine_instance
def _resolve_engine_class(engine_id: str):
+487 -115
View File
@@ -851,6 +851,7 @@ def _persist_profile_ref_text(profile_id: str, ref_text: str) -> None:
async def _finalize_generation(
audio_tensor, sample_rate, *, text, history_mode, ref_audio_path,
language, instruct, resolved_profile_id, used_seed, start_time,
already_marked=False,
):
"""Shared tail of a successful generation: watermark → save WAV →
history row (self-healing) retention prune event emit.
@@ -860,6 +861,12 @@ async def _finalize_generation(
watermark, filename, history row, retention behavior is identical
regardless of how the audio was delivered to the client.
``already_marked`` is for audio that arrives provenance-marked: a remote
worker marks at the tensor stage before it encodes (with ``force=True``,
so the *requesting* user's preference governs, not the GPU owner's), and
embedding a second AudioSeal payload over the first degrades detection of
both. The take users keep carries exactly one whole-take mark either way.
Returns ``(watermarked_tensor, meta)`` where ``meta`` carries
``id`` / ``filename`` / ``duration`` / ``gen_time``.
"""
@@ -874,13 +881,14 @@ async def _finalize_generation(
# Dispatched to the dedicated watermark pool, not the GPU pool (#1190):
# AudioSeal embedding is CPU work that holds no VRAM, so occupying a GPU
# worker with it only delays the next generate on 1-worker hosts.
from services.watermark import mark_synthetic
from services.model_manager import get_watermark_pool
audio_tensor = await loop.run_in_executor(
get_watermark_pool(),
functools.partial(mark_synthetic, audio_tensor, sample_rate,
context="generate.finalize"),
)
if not already_marked:
from services.watermark import mark_synthetic
from services.model_manager import get_watermark_pool
audio_tensor = await loop.run_in_executor(
get_watermark_pool(),
functools.partial(mark_synthetic, audio_tensor, sample_rate,
context="generate.finalize"),
)
gen_time = round(time.time() - start_time, 2)
audio_id = str(uuid.uuid4())[:8]
@@ -962,6 +970,144 @@ def _pcm16_b64(wav_tensor) -> str:
return base64.b64encode(pcm.cpu().numpy().tobytes()).decode("ascii")
# ── Remote GPU: this route is the producer the scheduler never had ─────────
#
# Picking a remote worker used to change a badge and nothing else — every
# render still ran on this machine, which is the whole reported bug. The
# decision is taken ONCE per request, through `services/gpu_gateway.py`, and
# BEFORE anything local is loaded — for two reasons that are not
# interchangeable: a render bound for the user's 4090 must not first pull a
# multi-GB model into this machine's RAM, and it must not be refused by a gate
# that asked whether THIS host has the accelerator the engine needs (a
# CUDA-only engine on a Mac control plane is exactly the case remote workers
# exist for).
_REMOTE_OP = "tts"
# The gateway's coarse phase → the sentence a user reads while someone else's
# GPU works. A five-minute remote render otherwise shows the same bare spinner
# as a local one, with no way to tell "queued behind another task" from
# "downloading 5 GB of weights" from "actually generating".
_REMOTE_PHASE_LABELS = {
"queued": "queued on {target}",
"loading": "loading model on {target}",
"running": "generating on {target}",
"uploading": "receiving audio from {target}",
}
class _LocalDecision:
"""Stand-in for ``worker.routing.Decision`` meaning "run here".
Used only when the gateway cannot be imported at all, so a build without
it still renders instead of 500-ing.
"""
remote = False
worker_id = None
label = "Local"
reason = ""
_LOCAL_DECISION = _LocalDecision()
def _routing_decision():
"""Local or remote for this request — resolved once, never re-asked.
Asked once because the target is user-settable at any moment: a decision
that flipped between prewarm and dispatch would either warm an engine
nothing will use or dispatch remotely after paying a local cold load.
"""
try:
from services import gpu_gateway
return gpu_gateway.decide(_REMOTE_OP)
except Exception: # noqa: BLE001 — routing is advisory; local always works
logger.debug("remote routing unavailable; running locally", exc_info=True)
return _LOCAL_DECISION
def _remote_only_local_call(target_label, reason=""):
"""The local branch of a render whose local half was deliberately skipped.
``gpu_gateway.run`` always takes a local callable it is where rule 1
(pre-dispatch unavailability) lands. But this route skips every local
preparation step once the decision is remote, precisely so a job bound for
the 4090 does not first load gigabytes here, so there is no local render
left to fall back to.
The causes rule 1 actually covers worker offline, disabled, not
approved, breaker open, remote workers switched off are already answered
by ``decide()`` BEFORE that skip, and come back as a local decision with a
named reason. What is left is the narrow window where dispatch itself is
refused (a full queue, a task dropped between submit and wait). Saying so
and offering the local re-run is honest; silently returning nothing is not.
"""
from services.gpu_gateway import RemoteJobFailed
def _refuse():
raise RemoteJobFailed(
reason or f"{target_label} could not take this render",
worker_label=target_label,
code="REMOTE_NOT_DISPATCHED",
hint="Run it on this machine instead, or pick another GPU.",
)
return _refuse
def _remote_progress_frame(state, target):
"""One gateway ``on_state`` payload → the NDJSON event the UI renders."""
phase = str((state or {}).get("phase") or "running")
try:
pct = max(0, min(100, round(float((state or {}).get("progress") or 0.0) * 100)))
except (TypeError, ValueError):
pct = 0
detail = _REMOTE_PHASE_LABELS.get(phase, _REMOTE_PHASE_LABELS["running"])
detail = detail.format(target=target)
if phase == "running" and pct:
detail = f"{detail} ({pct}%)"
return {
"type": "progress", "stage": phase, "percent": pct,
"target": target, "detail": detail,
}
def _apply_routing_headers(headers, engine_notice, decision):
"""Say where this render ran, on the notice channel that already exists.
``X-OmniVoice-Routing`` / ``-Routing-Reason`` are already set for the #21
engine routing gate and already consumed as a de-duped one-time toast, so
"this ran on gpu2" and "your 4090 was asleep, this ran here" travel the
same wire rather than inventing a second one.
The engine notice wins on a local render: "the engine fell back to CPU"
explains the slowness the user is looking at, while the worker notice for
a local render is the quieter of the two. A remote render has no engine
notice at all that gate answers for THIS host, and this host did nothing.
"""
from services.engine_routing import header_safe_reason
notice = engine_notice
if decision is not None:
try:
from services.gpu_gateway import notice_for
worker_notice = notice_for(decision)
except Exception: # noqa: BLE001 — a notice must never fail a render
worker_notice = None
if worker_notice and (getattr(decision, "remote", False) or not notice):
notice = worker_notice
if not notice:
return headers
headers["X-OmniVoice-Routing"] = notice[0]
safe = header_safe_reason(notice[1]) if notice[1] else ""
if safe:
headers["X-OmniVoice-Routing-Reason"] = safe
return headers
@router.post("/generate")
async def generate_speech(
text: str = Form(...),
@@ -1038,70 +1184,85 @@ async def generate_speech(
from core.run_sentinel import touch_activity
touch_activity("generate", engine_id)
# Single-active-engine memory discipline: hand back any OTHER resident TTS
# engine's model before loading this one, so switching engines (or a
# per-request engine= override, which bypasses /engines/select entirely)
# doesn't stack two multi-GB models in memory — the accumulation behind the
# 16 GB-Mac OOM deaths. No-op when nothing else is resident, so steady-state
# single-engine use pays nothing. Opt out: OMNIVOICE_SINGLE_ENGINE_RESIDENT=0.
from services.engine_memory import evict_other_tts_engines
await evict_other_tts_engines(engine_id)
# Non-blocking breadcrumb: if free memory is already low before this load,
# log it. A later OOM kill (the 16 GB-Mac class) then has a trail pointing
# at the load that tipped it, instead of a silent process death. Never
# blocks — the OS can reclaim cache, and a hard refuse would brick
# legitimate loads.
try:
from services.memory_budget import log_if_low
log_if_low(f"TTS load ({engine_id})")
except Exception:
pass
# VRAM eviction runs in get_model()'s warm-return path now, so every native
# TTS generate (this route, WS TTS, dub, batch, audiobook) is covered.
# ── Where does this render run? Asked once, here, because every line
# between this point and the dispatch below is preparation of THIS
# machine's GPU — model eviction, a multi-GB load, a host-capability gate.
# None of it applies to a render that belongs on the user's other box, and
# running it anyway is how "I selected gpu2" ended up meaning "the Mac did
# the work after loading the model twice".
_decision = _routing_decision()
_remote = bool(getattr(_decision, "remote", False))
_target_label = getattr(_decision, "label", "") or "the chosen worker"
_model = None
_backend = None
if backend_cls is OmniVoiceBackend:
# VoiceStudio keeps its native path: it carries the full advanced
# parameter surface (t_shift, layer/position/class controls) that the
# generic adapter protocol doesn't. Byte-identical to the old behavior.
_model = await get_model()
else:
try:
ok, msg = backend_cls.is_available()
except Exception as exc:
ok, msg = False, f"{type(exc).__name__}: {exc}"
if not ok:
raise HTTPException(
status_code=400,
detail=f"TTS engine '{engine_id}' is not available: {_mask_hf_tokens(msg)}",
)
# Reuse the per-process instance cache shared with the engine
# health-check route so weights load once, not per request.
from api.routers.engines import _get_engine_instance
_backend = _get_engine_instance(backend_cls)
# ── Routing gate (#21 — no silent CPU fallback). Computed ONCE per request
# (host caps are constant; the per-request engine= override bypasses the
# /engines/select gate, so this is the only place it's enforced for synth).
from core.device_caps import detect_host_caps
from services.engine_routing import resolve_routing, routing_notice
# The engine's declared VRAM floor (#1226) — used by the routing gate and,
# below, to let a generate TIMEOUT name the same shortfall. Resolved once:
# every other job on this GPU pool (reference transcribe, assemble) leaves
# it at 0, so only TTS generates can get the under-provisioned wording.
_engine_min_vram_gb = getattr(backend_cls, "min_vram_gb", 0.0)
_routing = resolve_routing(
getattr(backend_cls, "gpu_compat", ("cpu",)), detect_host_caps(),
_engine_min_vram_gb,
)
if _routing["routing_status"] == "unavailable":
# The engine needs an accelerator this host lacks and has no CPU path.
raise HTTPException(status_code=400, detail=_routing["routing_reason"])
_routing_notice = routing_notice(_routing) # (status, reason) or None
_routing_notice = None
if not _remote:
# Single-active-engine memory discipline: hand back any OTHER resident
# TTS engine's model before loading this one, so switching engines (or
# a per-request engine= override, which bypasses /engines/select
# entirely) doesn't stack two multi-GB models in memory — the
# accumulation behind the 16 GB-Mac OOM deaths. No-op when nothing else
# is resident, so steady-state single-engine use pays nothing. Opt out:
# OMNIVOICE_SINGLE_ENGINE_RESIDENT=0.
from services.engine_memory import evict_other_tts_engines
await evict_other_tts_engines(engine_id)
# Non-blocking breadcrumb: if free memory is already low before this
# load, log it. A later OOM kill (the 16 GB-Mac class) then has a trail
# pointing at the load that tipped it, instead of a silent process
# death. Never blocks — the OS can reclaim cache, and a hard refuse
# would brick legitimate loads.
try:
from services.memory_budget import log_if_low
log_if_low(f"TTS load ({engine_id})")
except Exception:
pass
# VRAM eviction runs in get_model()'s warm-return path now, so every
# native TTS generate (this route, WS TTS, dub, batch, audiobook) is
# covered.
if backend_cls is OmniVoiceBackend:
# VoiceStudio keeps its native path: it carries the full advanced
# parameter surface (t_shift, layer/position/class controls) that
# the generic adapter protocol doesn't. Byte-identical behavior.
_model = await get_model()
else:
try:
ok, msg = backend_cls.is_available()
except Exception as exc:
ok, msg = False, f"{type(exc).__name__}: {exc}"
if not ok:
raise HTTPException(
status_code=400,
detail=f"TTS engine '{engine_id}' is not available: {_mask_hf_tokens(msg)}",
)
# Reuse the per-process instance cache shared with the engine
# health-check route so weights load once, not per request.
from api.routers.engines import _get_engine_instance
_backend = _get_engine_instance(backend_cls)
# ── Routing gate (#21 — no silent CPU fallback). Computed ONCE per
# request (host caps are constant; the per-request engine= override
# bypasses the /engines/select gate, so this is the only place it's
# enforced for synth). Local only, and deliberately: it asks what THIS
# host can accelerate, and a remote render is precisely the case where
# that answer is none of the question — a CUDA-only engine sent to a
# 4090 from a Mac control plane would be refused by a gate describing
# a machine that is about to do nothing.
from core.device_caps import detect_host_caps
from services.engine_routing import resolve_routing, routing_notice
_routing = resolve_routing(
getattr(backend_cls, "gpu_compat", ("cpu",)), detect_host_caps(),
_engine_min_vram_gb,
)
if _routing["routing_status"] == "unavailable":
# The engine needs an accelerator this host lacks and has no CPU path.
raise HTTPException(status_code=400, detail=_routing["routing_reason"])
_routing_notice = routing_notice(_routing) # (status, reason) or None
# ── #1033/#1037: warm the engine under the LOAD budget, not the generate
# budget. A cold adapter lazily loads (and possibly downloads multi-GB
@@ -1111,18 +1272,17 @@ async def generate_speech(
# measured it: 0% GPU util for the full 300s). Model loading gets its own,
# larger budget (OMNIVOICE_MODEL_LOAD_TIMEOUT, default 1200s) — the same
# split get_model() already has for the native engine. Once warm, this is
# a no-op per request.
# a no-op per request. A remote render gets the same two-phase split from
# the worker, under the assignment's own model-load deadline.
if _backend is not None:
from services.model_manager import _model_load_timeout
from services import gpu_gateway
try:
await run_on_gpu_pool_guarded(
_backend.ensure_ready,
what=f"TTS engine '{engine_id}' model load",
timeout=_model_load_timeout(),
await gpu_gateway.prewarm(
_REMOTE_OP, backend=_backend, engine=engine_id, decision=_decision,
)
# Builtin TimeoutError base, not GpuJobTimeoutError — reload-proof
# class identity (see the twin catch in openai_compat.py).
except TimeoutError as exc:
except (TimeoutError, gpu_gateway.ModelLoadTimeout) as exc:
logger.warning("engine load exceeded the model-load budget: %s", exc)
raise HTTPException(
status_code=503,
@@ -1308,6 +1468,74 @@ async def generate_speech(
start_time = time.time()
# ── The remote assignment ───────────────────────────────────────────────
# Built even for a local render (it costs a dict) so the gateway owns the
# branch rather than this route owning two of them.
#
# The worker runs the ENTIRE render as one op — sentence split, per-chunk
# generate at ``seed + i``, crossfaded concat, effect chain, provenance
# mark — because dispatching a chunk at a time would pay a round trip, a
# progress lease and a slot per sentence against a worker whose
# concurrency defaults to 1. So every knob that shapes the local render has
# to be on the wire: a missing one is not an error, it is remote audio that
# quietly differs from local audio (no sentence splitting, no per-chunk
# seed variation, no crossfade).
from services import gpu_gateway
from services.watermark import is_enabled as _watermark_enabled
_remote_params = {
"text": text,
"language": None if (language and language.lower() == "auto") else language,
"ref_audio": ref_audio_path,
"ref_text": ref_text,
"instruct": instruct,
"duration": duration,
"speed": speed,
"num_step": num_step,
"guidance_scale": guidance_scale,
"denoise": denoise,
"postprocess_output": postprocess_output,
"t_shift": t_shift,
"layer_penalty_factor": layer_penalty_factor,
"position_temperature": position_temperature,
"class_temperature": class_temperature,
"seed": used_seed,
"max_chunk_chars": max_chunk_chars,
"crossfade_ms": crossfade_ms,
"effect_preset": effect_preset,
# The requesting user's provenance preference, not the GPU owner's.
"watermark": bool(_watermark_enabled()),
}
_remote_call = gpu_gateway.RemoteCall(
engine=engine_id, operation=_REMOTE_OP, params=_remote_params,
)
async def _render_on_worker(on_state=None):
"""One whole render on the chosen worker → ``(tensor, sample_rate)``.
The audio comes back already effect-chained and provenance-marked: the
worker mirrors the local order (split generate concat effects
mark) so a remote take and a local take of the same request differ
only in which GPU produced them.
"""
waveform, sample_rate = await gpu_gateway.run(
_REMOTE_OP,
local=gpu_gateway.LocalCall(
_remote_only_local_call(_target_label),
what="TTS generate",
timeout=_generate_timeout_s(text),
min_vram_gb=_engine_min_vram_gb,
),
remote=_remote_call,
decision=_decision,
on_state=on_state,
)
if getattr(waveform, "ndim", 2) == 1:
# `_safe_torchaudio_save` and the local paths deal in
# (channels, samples); a mono artifact reads back flat.
waveform = waveform.unsqueeze(0)
return waveform, sample_rate
# ── Streaming preview (feat: streaming-tts-preview) ─────────────────────
# Long scripts used to mean staring at a spinner until the ENTIRE render
# finished. With stream=true the existing text chunks (the Wave 1.2
@@ -1322,6 +1550,116 @@ async def generate_speech(
# seed / normalization) already ran, so per-chunk jobs spend the generate
# budget on generation only — and each chunk gets its own budget, so a
# long script can't time out merely for being long.
if stream and _remote:
# ── Remote: the streaming PREVIEW is off, the render still streams ──
# Progressive playback needs per-chunk dispatch, and per-chunk dispatch
# to a worker means a round trip, a progress lease and a slot for every
# sentence, serialised by a default concurrency of 1. So the render
# goes as ONE op and there is no first chunk to play early.
#
# The NDJSON channel stays open anyway, because the desktop UI asks for
# it whenever auto-play is on — which is the default. Answering with
# the classic WAV shape here would make the client fall back to a
# LOCAL re-render, i.e. exactly the bug this phase exists to fix: the
# user picks gpu2, clicks Synthesize, and their laptop does the work.
# What flows down it instead is coarse progress from the worker, then
# the finished take as a single chunk.
_remote_headers = _apply_routing_headers(
{"X-Seed": str(used_seed) if used_seed is not None else "",
"Cache-Control": "no-cache"},
None, _decision,
)
_progress_q: asyncio.Queue = asyncio.Queue()
def _push_progress(event):
# Called from the control plane's own loop; never let a progress
# frame break a render that is otherwise going fine.
try:
_progress_q.put_nowait(dict(event or {}))
except Exception: # noqa: BLE001
logger.debug("dropped a remote progress frame", exc_info=True)
async def _remote_stream_events():
import json
def _line(obj) -> bytes:
return (json.dumps(obj, separators=(",", ":")) + "\n").encode("utf-8")
render = asyncio.ensure_future(_render_on_worker(_push_progress))
try:
# Relay progress until the render settles, then flush whatever
# arrived in the gap so the last "generating (98%)" is not lost.
while not render.done():
getter = asyncio.ensure_future(_progress_q.get())
done, _pending = await asyncio.wait(
{render, getter}, return_when=asyncio.FIRST_COMPLETED
)
if getter in done:
yield _line(_remote_progress_frame(getter.result(), _target_label))
continue
getter.cancel()
while not _progress_q.empty():
yield _line(_remote_progress_frame(_progress_q.get_nowait(),
_target_label))
audio_tensor, sample_rate = await render
yield _line({
"type": "start", "sample_rate": sample_rate, "channels": 1,
"format": "pcm16", "total_chunks": 1, "crossfade_ms": 0,
"seed": used_seed,
})
# No second provenance mark: the worker marked at the tensor
# stage before encoding, with the requesting user's preference
# forced, and stacking a second AudioSeal payload over the
# first degrades detection of both.
yield _line({"type": "chunk", "seq": 0, "pcm": _pcm16_b64(audio_tensor)})
_, meta = await _finalize_generation(
audio_tensor, sample_rate, text=text, history_mode=history_mode,
ref_audio_path=ref_audio_path, language=language,
instruct=instruct, resolved_profile_id=resolved_profile_id,
used_seed=used_seed, start_time=start_time, already_marked=True,
)
# #1330's dropped-chunk warning has no remote carrier yet: the
# gateway hands back audio, not the worker's render metadata.
# Reported as a cross-stream gap rather than faked as zero.
yield _line({
"type": "done", "id": meta["id"], "audio_path": meta["filename"],
"duration": meta["duration"], "gen_time": meta["gen_time"],
"seed": used_seed, "sample_rate": sample_rate,
})
except (asyncio.CancelledError, GeneratorExit):
# The user hit stop, or the request was abandoned. Cancelling
# the render is what tells the worker to release its slot —
# otherwise the 4090 keeps rendering audio nobody will hear,
# holding what is often its only slot until the lease lapses.
render.cancel()
raise
except ValueError:
logger.error("Remote generation request rejected")
from core.public_errors import stream_failure
yield _line({"type": "error", **stream_failure("invalid_request")})
except Exception:
# Mid-job remote failure is NOT quietly redone here: the client
# treats a retryable error as "surface it", so the user decides
# whether to spend the same minutes again on this machine.
logger.error("Remote generation failed", exc_info=True)
from core.public_errors import stream_failure
yield _line({"type": "error", **stream_failure("generation_failed")})
finally:
if not render.done():
render.cancel()
if cleanup_ref and ref_audio_path:
with contextlib.suppress(OSError):
os.remove(ref_audio_path)
return StreamingResponse(
_remote_stream_events(),
media_type="application/x-ndjson",
headers=_remote_headers,
)
if stream:
from omnivoice.utils.text import parse_pause_markers
from services.chunked_tts import split_text_into_chunks
@@ -1425,7 +1763,7 @@ async def generate_speech(
_backend, text, language, ref_audio_path, ref_text,
instruct, duration, num_step, guidance_scale, speed,
denoise, postprocess_output, used_seed, effect_preset,
max_chunk_chars, crossfade_ms,
max_chunk_chars, crossfade_ms, dropped_sink=_dropped_sink,
),
what="TTS generate",
min_vram_gb=_engine_min_vram_gb,
@@ -1441,7 +1779,7 @@ async def generate_speech(
t_shift, denoise, postprocess_output,
layer_penalty_factor, position_temperature,
class_temperature, used_seed, effect_preset,
max_chunk_chars, crossfade_ms,
max_chunk_chars, crossfade_ms, dropped_sink=_dropped_sink,
),
what="TTS generate",
min_vram_gb=_engine_min_vram_gb,
@@ -1562,18 +1900,13 @@ async def generate_speech(
with contextlib.suppress(OSError):
os.remove(ref_audio_path)
_stream_headers = {
# Routing notice (#21): known before the stream starts, so it rides the
# same headers the classic path uses — and now also carries "your
# chosen worker was unavailable, this ran here".
_stream_headers = _apply_routing_headers({
"X-Seed": str(used_seed) if used_seed is not None else "",
"Cache-Control": "no-cache",
}
# Routing notice (#21): known before the stream starts, so it rides the
# same headers the classic path uses.
if _routing_notice:
from services.engine_routing import header_safe_reason
_stream_headers["X-OmniVoice-Routing"] = _routing_notice[0]
_hr = header_safe_reason(_routing_notice[1])
if _hr:
_stream_headers["X-OmniVoice-Routing-Reason"] = _hr
}, _routing_notice, _decision)
return StreamingResponse(
_stream_events(),
media_type="application/x-ndjson",
@@ -1586,47 +1919,54 @@ async def generate_speech(
# so. A warning in a log the user never opens is a record of the bug, not a
# fix for it.
_dropped_text: list = []
_already_marked = False
try:
if _backend is not None:
# Bounded + pool-reset on hang so a wedged generate can't starve the
# GPU pool and brick the backend ("can't reach backend", #730 class).
audio_tensor = await run_on_gpu_pool_guarded(
functools.partial(
if _remote:
# One op, one worker, the whole render — including the chunk loop.
audio_tensor, sample_rate = await _render_on_worker()
_already_marked = True
else:
# The gateway owns the dispatch on both branches. Locally it still
# lands in run_on_gpu_pool_guarded, so the #730 bound + pool reset
# that keeps a wedged generate from bricking the backend is
# unchanged.
if _backend is not None:
_local_render = functools.partial(
_run_backend_inference,
_backend, text, language, ref_audio_path, ref_text, instruct,
duration, num_step, guidance_scale, speed, denoise,
postprocess_output, used_seed, effect_preset,
max_chunk_chars, crossfade_ms, dropped_sink=_dropped_text,
),
what="TTS generate",
min_vram_gb=_engine_min_vram_gb,
timeout=_generate_timeout_s(text),
)
# Read after generation: engines with lazy model loading report
# their real rate only once weights are up.
sample_rate = _backend.sample_rate
else:
audio_tensor = await run_on_gpu_pool_guarded(
functools.partial(
)
else:
_local_render = functools.partial(
_run_inference,
_model, text, language, ref_audio_path, ref_text, instruct, duration,
num_step, guidance_scale, speed, t_shift, denoise,
postprocess_output, layer_penalty_factor, position_temperature,
class_temperature, used_seed, effect_preset,
max_chunk_chars, crossfade_ms, dropped_sink=_dropped_text,
)
audio_tensor = await gpu_gateway.run(
_REMOTE_OP,
local=gpu_gateway.LocalCall(
_local_render, what="TTS generate",
timeout=_generate_timeout_s(text),
min_vram_gb=_engine_min_vram_gb,
),
what="TTS generate",
min_vram_gb=_engine_min_vram_gb,
timeout=_generate_timeout_s(text),
decision=_decision,
)
sample_rate = _model.sampling_rate
# Read after generation: engines with lazy model loading report
# their real rate only once weights are up.
sample_rate = (_backend.sample_rate if _backend is not None
else _model.sampling_rate)
# Watermark → save → history → prune → emit, shared with the streaming
# path (see _finalize_generation) so both flows produce identical takes.
audio_tensor, _meta = await _finalize_generation(
audio_tensor, sample_rate, text=text, history_mode=history_mode,
ref_audio_path=ref_audio_path, language=language, instruct=instruct,
resolved_profile_id=resolved_profile_id, used_seed=used_seed,
start_time=start_time,
start_time=start_time, already_marked=_already_marked,
)
audio_id = _meta["id"]
audio_filename = _meta["filename"]
@@ -1660,14 +2000,10 @@ async def generate_speech(
_lost = header_safe_reason(" | ".join(t for t in _dropped_text if t))
if _lost:
_resp_headers["X-OmniVoice-Dropped-Text"] = _lost
# Routing notice (#21): cpu_fallback or accelerated-with-caveat only;
# the WAV body is binary so the header channel is the carrier.
if _routing_notice:
from services.engine_routing import header_safe_reason
_resp_headers["X-OmniVoice-Routing"] = _routing_notice[0]
_hr = header_safe_reason(_routing_notice[1])
if _hr:
_resp_headers["X-OmniVoice-Routing-Reason"] = _hr
# Routing notice (#21): cpu_fallback, accelerated-with-caveat, or the
# machine this render ran on. The WAV body is binary so the header
# channel is the carrier.
_apply_routing_headers(_resp_headers, _routing_notice, _decision)
return StreamingResponse(
_stream_wav(),
media_type="audio/wav",
@@ -1675,6 +2011,42 @@ async def generate_speech(
)
except HTTPException:
raise
except gpu_gateway.ModelNotDownloaded as e:
size_bytes = None
try:
from api.routers.setup.models import KNOWN_MODELS
sizes = [m.get("size_gb") for m in KNOWN_MODELS if m.get("repo_id") in e.repo_ids]
if sizes and all(size is not None for size in sizes):
size_bytes = int(sum(float(size) for size in sizes) * 1024**3)
except Exception:
pass
raise HTTPException(status_code=409, detail={
"error": "model_not_downloaded",
"message": str(e),
"engine": e.engine,
"repo_ids": e.repo_ids,
"size_bytes": size_bytes,
"target": e.target,
"target_label": e.target_label,
"downloadable": e.downloadable,
}) from e
except gpu_gateway.RemoteJobFailed as e:
# Rule 2 of the fallback policy: a single-shot interactive render that
# failed ON the worker is reported, not silently redone here. Minutes
# already went somewhere else, the user is watching, and quietly
# re-rendering on the slower machine turns a 20-second wait into a
# four-minute one with no explanation. The header names the target so
# the client can offer "run it on this machine instead" — a resubmit
# the user chose, with a wait they were told about.
logger.error("Remote generate failed on %s: %s", _target_label, e)
raise HTTPException(
status_code=503,
detail=f"{e} {e.hint or 'Run it on this machine instead, or pick another GPU.'}",
headers={"X-OmniVoice-Retryable": "true",
"X-OmniVoice-Routing": "remote_failed",
"Retry-After": "10"},
) from e
except GpuPoolBusyError as e:
# Saturation, not failure (#1190): the job never started, so the caller
# can retry the identical request. Retry-After + the retryable marker
+19 -1
View File
@@ -301,12 +301,14 @@ def _validate_snapshot_has_weights(repo_id: str, snapshot_path: str) -> None:
@router.get("/setup/download-stream")
async def setup_download_stream():
async def setup_download_stream(target: str | None = None):
"""SSE: forward every HuggingFace download tqdm update as a JSON event."""
queue: asyncio.Queue = asyncio.Queue(maxsize=512)
loop = asyncio.get_running_loop()
def listener(event):
if target and event.get("target", "local") != target:
return
try:
loop.call_soon_threadsafe(_safe_put, queue, event)
except RuntimeError:
@@ -340,6 +342,7 @@ async def setup_download_stream():
class InstallModelRequest(BaseModel):
repo_id: str
target: str | None = None
@@ -389,6 +392,19 @@ async def install_model(req: InstallModelRequest):
+ ", ".join(m["repo_id"] for m in KNOWN_MODELS)
),
)
target = (req.target or "").strip()
if target != "local":
from services import gpu_gateway # noqa: PLC0415
from worker import routing # noqa: PLC0415
decision = routing.decide()
if target and (not decision.remote or decision.worker_id != target):
raise HTTPException(status_code=409, detail="The selected GPU target changed; try again.")
if decision.remote:
try:
return await gpu_gateway.download(req.repo_id, decision=decision)
except gpu_gateway.GatewayError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
# Cooldown guard — don't retry if the same model just failed.
import time as _time_check
_sweep_cooldowns(_time_check.time()) # bound the dict (MM2-06)
@@ -410,6 +426,7 @@ async def install_model(req: InstallModelRequest):
def _do():
token = hf_progress.current_repo_id.set(req.repo_id)
target_token = hf_progress.current_target.set("local")
_cancelled.discard(req.repo_id) # clear any stale cancel from a prior run
hf_progress.emit({
"repo_id": req.repo_id,
@@ -664,6 +681,7 @@ async def install_model(req: InstallModelRequest):
_cancelled.discard(req.repo_id)
download_aggregator.finish(req.repo_id)
hf_progress.current_repo_id.reset(token)
hf_progress.current_target.reset(target_token)
with _active_installs_lock:
_active_installs.discard(req.repo_id)
+48 -13
View File
@@ -90,6 +90,19 @@ def get_model_catalog() -> ModelCatalog:
# ── Platform Detection ─────────────────────────────────────────────────────
def _target_host() -> dict | None:
"""Selected remote worker host, or None when the catalog targets local."""
try:
from worker import routing, service # noqa: PLC0415
decision = routing.decide()
plane = service.control_plane
live = plane.pool.get(decision.worker_id) if decision.remote and plane.pool else None
return dict(live.record.host or {}) if live is not None else None
except Exception:
return None
def _current_platform_tags() -> list[str]:
"""Return platform tags that the current host supports.
@@ -100,6 +113,25 @@ def _current_platform_tags() -> list[str]:
``rocm`` (AMD HIP builds), and ``cpu`` (no GPU acceleration at all
Apple Silicon is NOT tagged cpu; it curates via ``darwin-arm64``).
"""
target = _target_host()
if target is not None:
target_os = {"windows": "win32", "darwin": "darwin"}.get(
str(target.get("os") or "").lower(), "linux"
)
arch = str(target.get("arch") or "").lower()
arch = {"amd64": "x86_64", "aarch64": "arm64"}.get(arch, arch)
tags = [target_os, f"{target_os}-{arch}"]
backend = ""
if target.get("gpus"):
backend = str(target["gpus"][0].get("backend") or "").lower()
if backend:
tags.append(backend)
if backend == "rocm":
tags.append("cuda")
if not backend and not (target_os == "darwin" and arch == "arm64"):
tags.append("cpu")
return tags
tags = [sys.platform]
arch = _platform.machine()
tags.append(f"{sys.platform}-{arch}")
@@ -455,7 +487,9 @@ def list_models():
Uses a 10 s response cache to avoid repeated ``scan_cache_dir()`` disk
walks when the frontend polls.
"""
cached_response = _cached("models")
platform_tags = _current_platform_tags()
cache_key = "models:" + ",".join(sorted(platform_tags))
cached_response = _cached(cache_key)
if cached_response is not None:
return cached_response
@@ -476,7 +510,7 @@ def list_models():
cached_by_repo = _scan_cache_on_disk()
out = []
host_tags = set(_current_platform_tags())
host_tags = set(platform_tags)
for m in KNOWN_MODELS:
cached = cached_by_repo.get(m["repo_id"])
on_disk = cached is not None and cached["size_on_disk"] > 0
@@ -503,9 +537,9 @@ def list_models():
# BEFORE an "Install all" overruns the disk (pairs with the per-install
# disk_space_error guard in setup/download.py).
"disk_free_gb": round(disk_free_bytes() / _GIB, 1),
"platform_tags": _current_platform_tags(),
"platform_tags": platform_tags,
}
_set_cache("models", response)
_set_cache(cache_key, response)
return response
@@ -518,18 +552,19 @@ def recommendations():
TTS model is required; the ASR picks here are the optional "best for your
system" set the wizard and Settings surface for on-demand install.
"""
is_mac_arm = sys.platform == "darwin" and _platform.machine() == "arm64"
is_mac_intel = sys.platform == "darwin" and _platform.machine() == "x86_64"
is_linux = sys.platform.startswith("linux")
is_windows = sys.platform == "win32"
tags = set(_current_platform_tags())
target_os = "darwin" if "darwin" in tags else "win32" if "win32" in tags else "linux"
target_arch = next((tag.split("-", 1)[1] for tag in tags if tag.startswith(target_os + "-")), _platform.machine())
is_mac_arm = target_os == "darwin" and target_arch == "arm64"
is_mac_intel = target_os == "darwin" and target_arch == "x86_64"
is_linux = target_os == "linux"
is_windows = target_os == "win32"
has_cuda = "cuda" in tags and "rocm" not in tags
has_rocm = "rocm" in tags
# Device label — used as the card title.
if is_mac_arm:
device_label = f"Apple Silicon ({_platform.machine()})"
device_label = f"Apple Silicon ({target_arch})"
elif is_mac_intel:
device_label = "macOS Intel (x86_64)"
elif is_windows:
@@ -537,7 +572,7 @@ def recommendations():
elif is_linux:
device_label = "Linux x64" + (" + CUDA" if has_cuda else " + ROCm" if has_rocm else "")
else:
device_label = f"{sys.platform} / {_platform.machine()}"
device_label = f"{target_os} / {target_arch}"
# Curated preset for this host, in catalog order (required entries lead).
curated = [
@@ -607,8 +642,8 @@ def recommendations():
return {
"device": {
"os": sys.platform,
"arch": _platform.machine(),
"os": target_os,
"arch": target_arch,
"is_mac_arm": is_mac_arm,
"is_mac_intel": is_mac_intel,
"is_linux": is_linux,
+42
View File
@@ -62,6 +62,11 @@ async def ws_tts(websocket: WebSocket):
await websocket.accept()
logger.info("TTS streaming WebSocket connected")
# Said once per socket, not once per utterance: a conversational client
# sends many requests down one connection and a repeated notice would be
# noise. See `_announce_local_only`.
announced_local_only = False
try:
while True:
# Wait for a text request from the client
@@ -83,6 +88,43 @@ async def ws_tts(websocket: WebSocket):
t0 = time.perf_counter()
text = data["text"]
# Remote GPU: this socket stays on this machine, and says so.
#
# /generate's port trades progressive playback for the remote
# render — the classic path was always a single wait, so spending
# it on a faster GPU is a straight win. This route is the opposite
# shape: it exists to put audio in the user's ear before the
# sentence has finished synthesizing, and sending each utterance to
# a worker would pay queue admission, a round trip and cold-load
# risk per utterance, for the one surface where latency IS the
# feature.
#
# Silence would be worse than the limitation: the header badge
# would read "gpu2" while this machine does 100% of the work, the
# same class of lie the op-aware picker exists to stop. Said once
# per socket — a conversational client sends many requests down one
# connection — and BEFORE engine resolution, so an engine that
# cannot load still tells the user where it would have run.
if not announced_local_only:
announced_local_only = True
try:
from worker import routing as worker_routing
target = worker_routing.decide(op="tts")
except Exception: # noqa: BLE001 — advisory; never break audio
target = None
if target is not None and target.remote:
from core.scrub import scrub_text as _scrub
await websocket.send_json({
"type": "routing",
"status": "local_stream",
"reason": _scrub(
f"{target.label} is your GPU target, but live "
f"streaming runs on this machine"
),
})
try:
# Resolve engine
from services.tts_backend import (
+345
View File
@@ -0,0 +1,345 @@
"""Remote worker management API.
Deliberately small. The council's warning about the original design was that
seven strategies times three execution modes times priorities times weights
times per-model concurrency is a configuration surface nobody can test and
every knob is a compatibility promise forever. So this exposes what a user
actually needs to run their other GPU: see workers, add one, name it, prefer
one, pause one, remove one.
Two things here are not conveniences and must not be softened:
* **Consent is explicit and per worker.** Audio, reference voices, and text
leave the machine for a worker, so each one is approved individually. There
is no global "trust all workers".
* **A token is shown exactly once.** Only its hash is stored, so it cannot be
re-displayed which is the point.
One endpoint here is not part of that surface: `POST /workers/tasks` submits a
single task and waits for it, and exists only because the scheduler otherwise
has no caller at all outside the tests. It is marked dev-only everywhere it
appears and is replaced by the GPU gateway.
"""
from __future__ import annotations
import asyncio
import logging
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel, Field
from api.dependencies import require_loopback
from worker import registry, routing, service
logger = logging.getLogger("omnivoice.worker")
# How often an awaiting request checks whether its caller is still there.
# Starlette does not cancel a handler when the client hangs up, so polling is
# the only way the "cancel what nobody is waiting for" rule can fire before
# the task's own deadline does.
_DISCONNECT_POLL_SECONDS = 1.0
# Management is loopback-only: these endpoints mint join tokens and revoke
# machines, so they follow the same rule as the app's other privileged routes.
router = APIRouter(prefix="/workers", tags=["workers"], dependencies=[Depends(require_loopback)])
class EnableRequest(BaseModel):
enabled: bool
class EnrollRequest(BaseModel):
label: str = Field("", max_length=120)
endpoint: str = Field("", max_length=256)
ttl_seconds: int = Field(900, ge=60, le=24 * 3600)
class TargetRequest(BaseModel):
"""`local`, or the id of an enrolled worker."""
target: str = Field(..., max_length=64)
class WorkerUpdate(BaseModel):
name: str | None = Field(None, max_length=120)
enabled: bool | None = None
priority: int | None = Field(None, ge=0, le=100)
class SubmitTaskRequest(BaseModel):
"""One unit of work for a remote worker. **Dev only** — see `submit_task`."""
engine: str = Field(..., max_length=64)
operation: str = Field("tts", max_length=32)
model_id: str = Field("", max_length=128)
params: dict = Field(default_factory=dict)
# Mandatory, and deliberately without a default: the sweeper fails a task
# on its deadline only while it is QUEUED, so one submitted without a
# deadline while no worker is online waits forever with nothing left in
# the system that would ever time it out.
deadline_seconds: float = Field(..., gt=0, le=6 * 3600)
idempotency_key: str | None = Field(None, max_length=128)
class _ClientGone(Exception):
"""The caller hung up while its task was still running."""
class _WaitExpired(Exception):
"""The task did not reach a terminal state inside its deadline."""
@router.get("")
def list_workers() -> dict:
"""Everything the workers panel renders, in one call."""
return service.control_plane.snapshot()
@router.get("/target")
def get_target(op: str = "") -> dict:
"""What the GPU picker shows: the choice, the resolved answer, the options.
`active` is the same answer the generation path uses, so the badge cannot
claim work goes somewhere the router will not send it. Pass `op` for the
surface being rendered omitting it answers for the target as a whole,
which is what the picker's own menu asks.
"""
return routing.status(op=op.strip() or None)
@router.post("/target")
def set_target(request: TargetRequest) -> dict:
"""Choose where work runs. Exactly one target is active at a time."""
chosen = request.target.strip() or routing.LOCAL
if chosen != routing.LOCAL:
worker = registry.get(chosen)
if worker is None or worker.revoked:
raise HTTPException(status_code=404, detail="No such worker.")
routing.set_target_id(chosen)
return routing.status()
@router.post("/enabled")
async def set_enabled(request: EnableRequest) -> dict:
"""Turn the feature on or off.
Off means off: the control plane stops, the listening socket closes, and
the app is exactly what it was before the toggle existed.
"""
service.set_remote_workers_enabled(request.enabled)
if request.enabled:
try:
await service.control_plane.start()
except Exception as exc:
service.control_plane.startup_error = str(exc)
raise HTTPException(status_code=409, detail=str(exc)) from exc
else:
await service.control_plane.stop()
return service.control_plane.snapshot()
@router.post("/enrollments")
def create_enrollment(request: EnrollRequest) -> dict:
"""Mint a single-use join token.
The plaintext is returned once and never stored the response is the only
time it exists outside the worker that redeems it.
"""
if not service.control_plane.running:
raise HTTPException(
status_code=409,
detail="Remote workers are turned off. Enable them in Settings → System → Remote workers first.",
)
token = service.control_plane.create_enrollment(
endpoint=request.endpoint, label=request.label, ttl_seconds=request.ttl_seconds
)
return {
"token": token.encode(),
"endpoint": token.endpoint,
"fingerprint": token.cert_fingerprint,
"expires_at": token.expires_at,
"shown_once": True,
}
@router.patch("/{worker_id}")
def update_worker(worker_id: str, request: WorkerUpdate) -> dict:
worker = registry.get(worker_id)
if worker is None:
raise HTTPException(status_code=404, detail="No such worker.")
if request.name is not None:
registry.rename(worker_id, request.name)
if request.enabled is not None:
registry.set_enabled(worker_id, request.enabled)
if request.priority is not None:
registry.set_priority(worker_id, request.priority)
updated = registry.get(worker_id)
# Keep the live copy in step, so the scheduler and its logs do not go on
# using the name or priority this worker had when it connected.
if updated is not None and service.control_plane.running:
service.control_plane.pool.refresh_record(updated)
return updated.to_dict() if updated else {}
@router.post("/{worker_id}/consent")
def grant_consent(worker_id: str) -> dict:
"""Record the user's explicit yes to sending their audio to this machine."""
if registry.get(worker_id) is None:
raise HTTPException(status_code=404, detail="No such worker.")
registry.grant_consent(worker_id)
worker = registry.get(worker_id)
return worker.to_dict() if worker else {}
@router.post("/{worker_id}/resume")
def clear_breaker(worker_id: str) -> dict:
"""Clear a paused worker's circuit breakers.
The user fixed the machine and knows it a breaker with no manual clear is
the quarantine trap the reputation system had.
"""
if not service.control_plane.running:
raise HTTPException(status_code=409, detail="Remote workers are turned off.")
breakers = service.control_plane.pool.breakers
for breaker in breakers.open_breakers(worker_id):
breaker.force_close()
return {"ok": True}
@router.delete("/{worker_id}")
def revoke_worker(worker_id: str) -> dict:
"""Remove a worker — which means revoke its key, not hide the row.
Its in-flight work is released so it can be retried elsewhere rather than
waiting out a lease on a machine that will never answer again.
"""
if registry.get(worker_id) is None:
raise HTTPException(status_code=404, detail="No such worker.")
registry.revoke(worker_id)
if service.control_plane.running:
service.control_plane.scheduler.on_disconnected(worker_id)
service.control_plane.pool.breakers.forget_worker(worker_id)
return {"ok": True, "revoked": worker_id}
@router.get("/tasks")
def list_tasks(limit: int = 50) -> dict:
"""Recent remote tasks, for the queue view."""
if not service.control_plane.running:
return {"tasks": [], "queue_depth": 0}
from worker import task_store # noqa: PLC0415
return {
"queue_depth": service.control_plane.scheduler.queue_depth,
"tasks": [t.to_dict() for t in task_store.list_tasks(limit=min(200, max(1, limit)))],
}
@router.post("/tasks")
async def submit_task(request: Request, body: SubmitTaskRequest) -> dict:
"""Run one task on a remote worker and wait for it. **DEV ONLY.**
This is the producer the remote pipeline never had: until it existed the
scheduler had no caller outside the test suite, so picking a remote GPU
changed the badge and nothing else every job still ran locally. It is
the smallest thing that makes remote execution observable end to end, not
the shipping surface: the GPU gateway takes over routing real generation
and this endpoint goes with it.
Loopback-only and behind the same opt-in as the rest of the feature, so a
user who never enabled remote workers cannot reach it at all.
"""
from worker.lifecycle import TaskState # noqa: PLC0415
from worker.scheduler import QueueFull, SchedulerStopped # noqa: PLC0415
if not service.remote_workers_enabled() or not service.control_plane.running:
raise HTTPException(status_code=409, detail="Remote workers are turned off.")
if not routing.supports_operation(body.operation):
raise HTTPException(
status_code=400,
detail=f"'{body.operation}' does not run on a remote worker yet.",
)
scheduler = service.control_plane.scheduler
try:
task = scheduler.submit(
operation=body.operation,
engine=body.engine,
model_id=body.model_id,
params=body.params,
idempotency_key=body.idempotency_key or None,
deadline_seconds=body.deadline_seconds,
pinned_worker_id=routing.decide().worker_id or None,
)
except QueueFull as exc:
raise HTTPException(status_code=429, detail=str(exc)) from exc
settled = None
reason = "the request was interrupted"
try:
settled = await _await_terminal(
request, scheduler, task.task_id, timeout=body.deadline_seconds
)
except _ClientGone:
reason = "the client disconnected"
raise HTTPException(status_code=499, detail="The client stopped waiting.") from None
except _WaitExpired:
reason = "the task passed its deadline"
raise HTTPException(
status_code=504,
detail=f"The task did not finish within {body.deadline_seconds:g}s.",
) from None
except SchedulerStopped as exc:
# Deliberately no cancel: the worker was never told to stop and may
# still be rendering, so claiming the task is cancelled would be a
# statement about someone else's GPU that we cannot make.
reason = None
raise HTTPException(status_code=503, detail=str(exc)) from None
finally:
# Nothing else will stop it: a worker holds its slot — often its only
# one — until the control plane says otherwise, and the sweeper only
# enforces deadlines on tasks that are still queued. Swallowed because
# a failure here would replace the caller's real error with a 500.
if settled is None and reason is not None:
try:
await service.control_plane.cancel(task.task_id, reason=reason)
except Exception:
logger.exception("Could not cancel abandoned remote task %s", task.task_id)
payload = settled.to_dict()
if settled.state is TaskState.COMPLETED:
return payload
# A failure that answered 200 would be indistinguishable from success to
# anything that does not read `state` — which is the whole point of this
# endpoint existing before the gateway does.
raise HTTPException(
status_code=409 if settled.state is TaskState.CANCELLED else 502, detail=payload
)
async def _await_terminal(request: Request, scheduler, task_id: str, *, timeout: float):
"""Wait for a terminal task, giving up if the caller does first."""
waiter = asyncio.ensure_future(scheduler.wait(task_id, timeout=timeout))
while True:
done, _pending = await asyncio.wait({waiter}, timeout=_DISCONNECT_POLL_SECONDS)
if done:
try:
settled = waiter.result()
except (asyncio.TimeoutError, TimeoutError) as exc:
raise _WaitExpired() from exc
if settled is None or not settled.state.terminal:
raise _WaitExpired()
return settled
if await request.is_disconnected():
waiter.cancel()
raise _ClientGone()
@router.post("/tasks/{task_id}/cancel")
async def cancel_task(task_id: str) -> dict:
if not service.control_plane.running:
raise HTTPException(status_code=409, detail="Remote workers are turned off.")
cancelled = await service.control_plane.cancel(task_id, reason="cancelled by user")
if not cancelled:
raise HTTPException(status_code=404, detail="No such active task.")
return {"ok": True}
+18
View File
@@ -242,6 +242,24 @@ models:
size_gb: 0.08
curated_on: [all]
- repo_id: "openbmb/VoxCPM2"
label: "VoxCPM2 (30 languages, voice cloning and design)"
role: TTS
size_gb: 5.0
curated_on: [cuda]
- repo_id: "FunAudioLLM/Fun-CosyVoice3-0.5B-2512"
label: "CosyVoice 3 0.5B (multilingual zero-shot)"
role: TTS
size_gb: 9.8
curated_on: [cuda]
- repo_id: "lj1995/GPT-SoVITS"
label: "GPT-SoVITS pretrained weights"
role: TTS
size_gb: 2.0
curated_on: [cuda]
# ── mlx-audio engines (Apple Silicon only) ────────────────────────────
- repo_id: "mlx-community/Kokoro-82M-bf16"
+102
View File
@@ -176,6 +176,108 @@ _BASE_SCHEMA = """
created_at REAL
);
CREATE INDEX IF NOT EXISTS idx_pron_lang ON pronunciation_entries(language);
-- Remote GPU workers (docs/remote-workers.md). Opt-in: an install with no
-- remote workers never writes a row here and behaves exactly as before.
--
-- `public_key` is the worker's identity — a server-assigned id is a name,
-- not proof, so every reconnect is verified against this key. Revocation
-- is a persisted fact (not in-memory state) precisely so a restart of the
-- control plane cannot silently readmit a worker the user removed.
CREATE TABLE IF NOT EXISTS remote_workers (
id TEXT PRIMARY KEY,
name TEXT NOT NULL DEFAULT '',
key_id TEXT NOT NULL,
public_key BLOB NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
revoked INTEGER NOT NULL DEFAULT 0,
revoked_at REAL,
priority INTEGER NOT NULL DEFAULT 50,
endpoint TEXT NOT NULL DEFAULT '',
host_json TEXT NOT NULL DEFAULT '{}',
capabilities_json TEXT NOT NULL DEFAULT '[]',
max_concurrent_tasks INTEGER NOT NULL DEFAULT 1,
-- Bumped on every successful (re)connect. Messages stamped with an
-- older epoch are from a session we have already replaced.
session_epoch INTEGER NOT NULL DEFAULT 0,
consent_granted_at REAL,
created_at REAL NOT NULL,
last_seen_at REAL
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_remote_workers_key ON remote_workers(key_id);
-- Single-use join tokens. Only the hash is stored: the plaintext exists
-- once, in the dialog that shows it.
CREATE TABLE IF NOT EXISTS remote_worker_enrollments (
token_id TEXT PRIMARY KEY,
secret_hash TEXT NOT NULL,
endpoint TEXT NOT NULL DEFAULT '',
cert_fingerprint TEXT NOT NULL DEFAULT '',
label TEXT NOT NULL DEFAULT '',
created_at REAL NOT NULL,
expires_at REAL NOT NULL,
used_at REAL,
used_by_worker TEXT
);
-- Tasks dispatched to remote workers. Unlike the local `jobs` table (whose
-- startup sweep marks anything in-flight as failed), these must SURVIVE a
-- control-plane restart: the desktop app quits while a remote GPU keeps
-- rendering, and the worker is the source of truth for what is still
-- running. Reconciliation on reconnect rebuilds live state from here.
CREATE TABLE IF NOT EXISTS remote_tasks (
id TEXT PRIMARY KEY,
-- Client-supplied; deduplicates client retries before the worker
-- protocol is involved at all.
idempotency_key TEXT,
operation TEXT NOT NULL,
engine TEXT NOT NULL DEFAULT '',
model_id TEXT NOT NULL DEFAULT '',
params_json TEXT NOT NULL DEFAULT '{}',
priority INTEGER NOT NULL DEFAULT 0,
state TEXT NOT NULL DEFAULT 'queued',
max_attempts INTEGER NOT NULL DEFAULT 3,
excluded_json TEXT NOT NULL DEFAULT '[]',
error_json TEXT,
-- Written BEFORE RESULT_ACK is sent. If the server dies between
-- receiving a result and acknowledging it, the worker redelivers and
-- this row is what makes the second delivery a no-op instead of a
-- silently lost multi-minute render.
result_ref TEXT,
result_json TEXT,
project_id TEXT,
created_at REAL NOT NULL,
updated_at REAL NOT NULL,
deadline_at REAL,
-- Deliberate additive-reconcile exception to the alembic rule: remote
-- task recovery must work in bundled installs where alembic may be
-- unavailable, and this nullable affinity column is additive-only.
pinned_worker_id TEXT,
finished_at REAL
);
CREATE INDEX IF NOT EXISTS idx_remote_tasks_state ON remote_tasks(state, priority, created_at);
CREATE UNIQUE INDEX IF NOT EXISTS idx_remote_tasks_idem ON remote_tasks(idempotency_key)
WHERE idempotency_key IS NOT NULL;
CREATE TABLE IF NOT EXISTS remote_task_attempts (
id TEXT PRIMARY KEY,
task_id TEXT NOT NULL,
worker_id TEXT NOT NULL,
session_epoch INTEGER NOT NULL DEFAULT 0,
attempt_number INTEGER NOT NULL DEFAULT 1,
state TEXT NOT NULL DEFAULT 'assigned',
progress REAL NOT NULL DEFAULT 0,
stage TEXT NOT NULL DEFAULT '',
error_json TEXT,
created_at REAL NOT NULL,
accepted_at REAL,
started_at REAL,
finished_at REAL,
lease_expires_at REAL,
grace_expires_at REAL
);
CREATE INDEX IF NOT EXISTS idx_remote_attempts_task ON remote_task_attempts(task_id);
CREATE INDEX IF NOT EXISTS idx_remote_attempts_worker ON remote_task_attempts(worker_id, state);
"""
# Only tables/columns this module is allowed to ALTER. Prevents SQL injection via
+28
View File
@@ -52,6 +52,34 @@ _REDACTED_VALUE = "***REDACTED***"
# One-line "what to do" per docs-taxonomy key. Keys mirror error_docs_map's
# taxonomy; the docs URL itself stays owned by error_docs_map.
_HINTS: dict[str, str] = {
"WORKER_AT_CAPACITY": "Wait for a running job on that worker to finish, or choose another available worker and retry.",
"MODEL_NOT_INSTALLED": "Install or enable this engine on the worker machine, then refresh its capabilities and retry.",
"MODEL_NOT_DOWNLOADED": "Open Models, install this model on the selected worker, then retry when the download completes.",
"INSUFFICIENT_MEMORY": "Choose a worker with more free GPU memory, unload another model there, or use a smaller model and retry.",
"OPERATION_UNSUPPORTED": "Choose a worker whose capability list includes this operation, or run the job locally.",
"ACCEPT_TIMEOUT": "Check that the worker is responsive and not overloaded, then reconnect it and retry.",
"MODEL_LOAD_TIMEOUT": "Check the worker's model download and load status, then retry after the model is ready.",
"EXECUTION_TIMEOUT": "Check the worker for a stalled engine or GPU error, restart that engine if needed, then retry.",
"PROGRESS_LEASE_EXPIRED": "Check the worker connection and engine log, reconnect or restart the worker, then retry the job.",
"RESULT_DELIVERY_TIMEOUT": "Check the connection and free disk space on both machines, then reconnect the worker and retry.",
"INPUT_FETCH_TIMEOUT": "Check the connection to the worker and retry; keep both machines awake until the reference file finishes transferring.",
"INPUT_FETCH_FAILED": "Check that the source file still exists and both machines are connected, then submit the job again.",
"RESULT_UPLOAD_FAILED": "Check the worker connection and free disk space on this machine, reconnect the worker, then retry.",
"WORKER_FAILED": "Open the selected worker's log for the underlying error, fix it there, then reconnect and retry.",
"SESSION_EXPIRED": "Reconnect the worker; if it cannot renew its session, remove it and enroll it again.",
"STALE_EPOCH": "Reconnect the worker so it receives the current session, then retry the job.",
"STALE_ATTEMPT": "Refresh the job state and retry only if the current attempt has not already completed elsewhere.",
"UPGRADE_REQUIRED": "Update VoiceStudio on the machine named in the error, then reconnect the worker.",
"WORKER_REVOKED": "Add the worker again from Settings → System → Remote workers to create a new trusted enrollment.",
"AUTH_FAILED": "Remove this worker, generate a new enrollment token, and add it again.",
"INVALID_TASK_PARAMS": "Review the job inputs, correct the invalid or missing value named in the error, and submit it again.",
"MODEL_REF_REJECTED": "Select a model from VoiceStudio's catalog on that worker instead of a path or custom model reference.",
"RESULT_TOO_LARGE": "Shorten or split the job so each result is smaller, then render the parts separately.",
"ARTIFACT_TOO_LARGE": "Shorten or split the job so each uploaded artifact is smaller, then render the parts separately.",
"OFFSET_MISMATCH": "Reconnect the worker and retry the upload from the byte count reported by the control plane.",
"SIZE_MISMATCH": "Reconnect the worker and retry the result upload; if it repeats, restart the worker before rerendering.",
"DIGEST_MISMATCH": "Retry the result upload; if it repeats, check the worker's disk and network for corruption, then rerender.",
"UPLOAD_INCOMPLETE": "Reconnect the worker and resume the result upload from the byte count reported by the control plane.",
"PKG_RESOURCES_MISSING": "Run `uv pip install --reinstall 'setuptools>=75,<80'` in the backend venv (a plain install is skipped when setuptools' metadata is present but its pkg_resources files were removed by antivirus). Restart after.",
"GATEKEEPER_QUARANTINE": "Clear the macOS quarantine flag (xattr -cr the app), then reopen.",
"APPIMAGE_WEBKIT_WHITESCREEN": "Launch with WEBKIT_DISABLE_DMABUF_RENDERER=1 set.",
+9
View File
@@ -10,8 +10,13 @@ from __future__ import annotations
import ntpath
import os
import re
from pathlib import Path
_WINDOWS_RESERVED_NAMES = frozenset({"CON", "PRN", "AUX", "NUL"}) | frozenset(
f"{prefix}{number}" for prefix in ("COM", "LPT") for number in range(1, 10)
)
class UnsafePath(ValueError):
"""Raised when a path crosses its allowed filesystem boundary."""
@@ -28,6 +33,10 @@ def safe_filename(value: object) -> str:
or os.path.isabs(name)
or ntpath.isabs(name)
or ntpath.basename(name) != name
or name.endswith((" ", "."))
or re.search(r"[\x00-\x1f]", name)
or name.split(".", 1)[0].upper() in _WINDOWS_RESERVED_NAMES
or len(name.encode("utf-8")) > 240
):
raise UnsafePath("expected a bare filename")
return name
+32
View File
@@ -752,6 +752,23 @@ async def lifespan(app: FastAPI):
)
if mcp_mounted:
logger.info("MCP server mounted at /mcp")
# Remote GPU workers (opt-in). Starts nothing — no socket, no certificate,
# no background loop — unless the user turned the feature on, so an install
# that never touches it is byte-for-byte the app it was before.
try:
from worker import service as worker_service
await worker_service.start_if_enabled()
except Exception:
logger.exception("Remote worker startup failed (continuing without it)")
# The other side of the same feature: on a machine running in worker mode,
# connect out to its control plane and start taking work.
try:
from worker import agent as worker_agent
await worker_agent.start_if_worker_mode()
except Exception:
logger.exception("Worker agent startup failed (continuing without it)")
# Startup finished — disarm the hang watchdog before serving (#632).
if _watchdog_armed:
try:
@@ -760,6 +777,19 @@ async def lifespan(app: FastAPI):
except Exception:
pass
yield
# Stop accepting remote work early in shutdown: a worker that reconnects
# to a half-torn-down control plane is worse than one that simply finds it
# gone and backs off.
try:
from worker import agent as worker_agent
await worker_agent.stop()
except Exception:
logger.exception("Worker agent shutdown failed")
try:
from worker import service as worker_service
await worker_service.stop()
except Exception:
logger.exception("Remote worker shutdown failed")
# ── Graceful shutdown (SIGTERM from Tauri, Ctrl+C, etc.) ────────────
logger.info("Shutdown: cleaning up…")
# FIRST: flip model_manager into shutdown mode, so a model load that is
@@ -1271,7 +1301,9 @@ app.include_router(pronunciation.router) # Expressive-TTS Spec 01: pronunciatio
app.include_router(settings_router.router) # Phase 1 AUTH-03 endpoints
app.include_router(media_tools_router.router) # Settings → Audio tools + wizard media-engine self-heal
from api.routers import mcp_bindings as _mcp_bindings_router # noqa: E402
from api.routers import workers as workers_router # noqa: E402
app.include_router(_mcp_bindings_router.router) # Wave 2.2 per-agent voice bindings
app.include_router(workers_router.router) # Remote GPU workers (opt-in)
# ── Mount the MCP server (Wave 2.2) ───────────────────────────────────────
# FastMCP's Streamable-HTTP app is sub-mounted at /mcp; its session manager is
+695
View File
@@ -0,0 +1,695 @@
"""Pre-rendered voice previews — the download client for the voice gallery.
A fresh install can hear nothing until the 2.4 GB TTS checkpoint lands
(``config/models.yaml``), because every archetype preview is synthesized on
demand on the GPU. This module fetches previews that were rendered once, ahead
of time, by ``scripts/render_gallery.py`` and published as a release of the
``omnivoice-gallery`` repo, so the voice picker works on first run and stops
burning a cold model load per voice afterwards.
Trust
=====
``manifest.json`` is signed with the **existing Tauri release key** and verified
against :data:`UPDATER_PUBKEY` the same public key the updater already carries
in ``frontend/src-tauri/tauri.conf.json``, kept in lockstep by
``tests/test_gallery_previews.py``. Signature verification is the *only* thing
that makes the per-file SHA-256 digests meaningful, so a manifest that fails it
is discarded outright (including a manifest already on disk: it is re-verified
on every load, not trusted because it was trusted once). No new key, no new
infrastructure, no second trust root.
Consent the gallery is OPT-IN
===============================
Downloading previews is a new outbound call, and CLAUDE.md's local-first
guarantee is that nothing leaves the machine without an explicit yes. "It fails
silently offline and can be turned off" is not consent, so there is **no
on-install background fetch**: :func:`is_enabled` is false until the user turns
the gallery on in Settings, and every network entry point in this module is a
no-op while it is off. Turning it on is the yes, and it is what schedules the
featured-set download. With the gallery off or unreachable previews render
locally exactly as they always have, which is the whole app remaining functional
with everything declined.
Fixed reference renderings
==========================
The preview key is ``sha256(instruct|language)[:16]`` (``archetypes.py``), which
is derived from the archetype *definition* and says nothing about which engine
produced the audio. Gallery files are therefore a **fixed reference rendering**
of each voice the engine that rendered them is recorded in the manifest
(``engine`` / ``engine_version``) and surfaced in Settings, not encoded in the
key. Because of that they live in their own directory and never mix with the
user's local renders under ``OUTPUTS_DIR/archetype_previews``, which share the
same key and follow whichever engine is active. On collision the gallery wins:
it is the rendering we can prove the provenance of.
They are also, deliberately, never reference audio. ``/archetypes/{id}/use``
renders locally, always a downloaded MP3 must not become the sample a user's
cloned voice is built from.
"""
from __future__ import annotations
import asyncio
import base64
import hashlib
import json
import logging
import os
import re
import tarfile
import tempfile
from pathlib import Path
from typing import Any, Optional
from core.config import DATA_DIR
from core.path_security import UnsafePath, resolve_within, safe_filename
from worker.clock import resolve as _now
logger = logging.getLogger("omnivoice.preview_gallery")
#: Manifest schema this client understands. A manifest declaring anything else
#: is ignored rather than guessed at — an old build must not act on a layout it
#: was not written against, and the signature proves nothing about semantics.
SCHEMA_VERSION = 1
#: Minisign public key of the Tauri release signing key, verbatim from
#: ``frontend/src-tauri/tauri.conf.json`` (plugins.updater.pubkey). Duplicated
#: rather than read from the config because the frozen backend does not ship
#: tauri.conf.json; the ratchet test keeps the two byte-identical.
UPDATER_PUBKEY = (
"dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDhFMDQ1QkZCQ0I4RDlCQkYKUl"
"dTL200M0wrMXNFamdPSGF3VkUzVjBRY1FFOE0yTkxSMVZKNUowL2wyZEw2OG1TWXNLMDlSeTQK"
)
_DEFAULT_BASE_URL = (
"https://github.com/debpalash/omnivoice-gallery/releases/latest/download"
)
_MANIFEST_NAME = "manifest.json"
_SIGNATURE_NAME = "manifest.json.minisig"
_FEATURED_NAME = "featured.tar.gz"
#: Once a day, per the plan's "updates refresh silently" — not per launch.
UPDATE_INTERVAL_S = 24 * 3600
# Every response is read under a hard byte cap: the far end is trusted only
# after a signature check, and the signature check itself needs a bounded read
# to happen at all. Sized off the real artifacts (1126 entries ≈ 300 kB of
# manifest; a 64 kbps mono preview of a sample script ≈ 100 kB) with room to
# grow, so a hostile or broken endpoint cannot fill the user's disk.
_MAX_MANIFEST_BYTES = 8 << 20
_MAX_SIGNATURE_BYTES = 4 << 10
_MAX_PREVIEW_BYTES = 4 << 20
_MAX_FEATURED_BYTES = 64 << 20
_KEY_RE = re.compile(r"^[0-9a-f]{16}$")
_MEMBER_RE = re.compile(r"^(?:\./)?(?:previews/)?([0-9a-f]{16})\.mp3$")
# Background work (manifest refresh, featured tarball) can afford to wait; an
# on-demand fetch is blocking a user who clicked play, and every second past a
# couple is worse than just rendering the preview locally.
_HTTP_TIMEOUT_S = 30.0
ON_DEMAND_TIMEOUT_S = 8.0
class GalleryError(RuntimeError):
"""The gallery answered, and what it said cannot be trusted or used."""
# ── Layout ───────────────────────────────────────────────────────────────────
def gallery_root() -> Path:
"""Directory holding the verified manifest, its signature, and the MP3s."""
return Path(DATA_DIR) / "voice_gallery_previews"
def _previews_dir() -> Path:
return gallery_root() / "previews"
def preview_path(key: str) -> Path:
"""Filesystem path for a preview key, or raise if the key is not a key.
Keys arrive from a manifest and from tar member names both remote so
they are validated as bare 16-hex before they are allowed near a path, and
then contained under the previews directory anyway.
"""
if not isinstance(key, str) or not _KEY_RE.match(key):
raise UnsafePath("preview key must be 16 lowercase hex characters")
root = _previews_dir()
root.mkdir(parents=True, exist_ok=True)
return resolve_within(root, safe_filename(f"{key}.mp3"))
def cached_preview(key: str) -> Optional[Path]:
"""The on-disk gallery preview for *key*, or ``None``.
Never raises: this sits on the preview request path, where an unusable
gallery must degrade to a local render rather than fail the request.
"""
try:
path = preview_path(key)
except (UnsafePath, OSError):
return None
try:
return path if path.is_file() and path.stat().st_size > 0 else None
except OSError:
return None
# ── State ────────────────────────────────────────────────────────────────────
def _state_path() -> Path:
return gallery_root() / "state.json"
def load_state() -> dict:
"""Persisted client state: consent, last check, ETag, last error."""
try:
raw = _state_path().read_text(encoding="utf-8")
state = json.loads(raw)
if isinstance(state, dict) and state.get("schema") == SCHEMA_VERSION:
return state
except (OSError, ValueError):
pass
return {"schema": SCHEMA_VERSION, "enabled": False}
def _save_state(state: dict) -> None:
state["schema"] = SCHEMA_VERSION
_atomic_write(_state_path(), json.dumps(state, indent=2).encode("utf-8"))
def is_enabled() -> bool:
"""True once the user has said yes to downloading previews."""
return bool(load_state().get("enabled"))
def set_enabled(enabled: bool) -> dict:
"""Record the user's consent decision. Returns the new status."""
state = load_state()
state["enabled"] = bool(enabled)
_save_state(state)
return status()
# ── Signature verification ───────────────────────────────────────────────────
def _decode_minisign_pubkey(pubkey_b64: str) -> tuple[bytes, bytes, bytes]:
"""Return ``(algorithm, key_id, raw_key)`` from a Tauri-style pubkey.
Tauri stores the base64 of the whole two-line minisign ``.pub`` *file*, so
unwrap that first and decode the payload line.
"""
try:
text = base64.b64decode(pubkey_b64.encode("ascii"), validate=True).decode("utf-8")
except (ValueError, UnicodeDecodeError) as exc:
raise GalleryError("updater public key is not decodable") from exc
lines = [ln.strip() for ln in text.splitlines() if ln.strip()]
if not lines:
raise GalleryError("updater public key is empty")
try:
raw = base64.b64decode(lines[-1].encode("ascii"), validate=True)
except ValueError as exc:
raise GalleryError("updater public key payload is not base64") from exc
if len(raw) != 42:
raise GalleryError("updater public key has the wrong length")
return raw[:2], raw[2:10], raw[10:]
def _parse_minisig(signature: str) -> tuple[bytes, bytes, bytes, bytes, bytes]:
"""Parse a minisign signature file.
Returns ``(algorithm, key_id, signature, trusted_comment, global_signature)``.
Accepts the raw file text and the base64-of-the-file form Tauri publishes in
``latest.json``, because both spellings of "the sig for this artifact" exist
in this project already.
"""
text = signature.strip()
if "untrusted comment:" not in text:
try:
text = base64.b64decode(text.encode("ascii"), validate=True).decode("utf-8")
except (ValueError, UnicodeDecodeError) as exc:
raise GalleryError("signature is neither minisign text nor base64") from exc
lines = [ln.rstrip("\r") for ln in text.strip().splitlines()]
payload = [ln for ln in lines if ln and not ln.startswith("untrusted comment:")]
trusted = ""
body: list[str] = []
for line in payload:
if line.startswith("trusted comment:"):
trusted = line[len("trusted comment:"):].lstrip()
continue
body.append(line.strip())
if len(body) < 1:
raise GalleryError("signature file carries no signature line")
try:
raw = base64.b64decode(body[0].encode("ascii"), validate=True)
global_sig = base64.b64decode(body[1].encode("ascii"), validate=True) if len(body) > 1 else b""
except ValueError as exc:
raise GalleryError("signature payload is not base64") from exc
if len(raw) != 74:
raise GalleryError("signature has the wrong length")
return raw[:2], raw[2:10], raw[10:], trusted.encode("utf-8"), global_sig
def verify_manifest(raw: bytes, signature: str, *, pubkey: Optional[str] = None) -> dict:
"""Verify *signature* over *raw* and return the parsed manifest.
Raises :class:`GalleryError` on anything short of a full verification
wrong key id, wrong algorithm, bad signature, unparsable JSON, unknown
schema. Callers treat that as "there is no gallery", never as a warning.
*pubkey* is resolved at call time, not bound as a default: a default
argument would freeze the module constant at import and make the trust root
un-substitutable including for the tests that prove rejection works.
"""
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
key_algo, key_id, raw_key = _decode_minisign_pubkey(pubkey or UPDATER_PUBKEY)
sig_algo, sig_key_id, sig, trusted_comment, global_sig = _parse_minisig(signature)
if sig_key_id != key_id:
raise GalleryError("signature was made by a different key")
if sig_algo not in (b"Ed", b"ED"):
raise GalleryError("unsupported signature algorithm")
if key_algo == b"ED" and sig_algo != b"ED":
raise GalleryError("signature algorithm is weaker than the key allows")
# minisign's two algorithms differ only in what is signed: "Ed" signs the
# content, "ED" signs its BLAKE2b-512 digest (so a huge artifact needn't be
# buffered by the signer). The key declares the maximum; the signature
# declares which was used.
signed = hashlib.blake2b(raw, digest_size=64).digest() if sig_algo == b"ED" else raw
key = Ed25519PublicKey.from_public_bytes(raw_key)
try:
key.verify(sig, signed)
except InvalidSignature as exc:
raise GalleryError("manifest signature does not verify") from exc
if global_sig:
# The trusted comment is only trustworthy because of this second
# signature over signature||comment; skipping it is how minisign
# implementations end up honouring an attacker-chosen comment.
try:
key.verify(global_sig, sig + trusted_comment)
except InvalidSignature as exc:
raise GalleryError("trusted comment signature does not verify") from exc
try:
manifest = json.loads(raw.decode("utf-8"))
except (ValueError, UnicodeDecodeError) as exc:
raise GalleryError("manifest is not valid JSON") from exc
if not isinstance(manifest, dict) or manifest.get("schema") != SCHEMA_VERSION:
raise GalleryError("manifest schema is not supported by this build")
previews = manifest.get("previews")
if not isinstance(previews, dict):
raise GalleryError("manifest carries no previews table")
for key_name, entry in previews.items():
if not _KEY_RE.match(str(key_name)) or not isinstance(entry, dict):
raise GalleryError("manifest contains a malformed preview key")
if not _is_sha256(entry.get("sha256")) or not isinstance(entry.get("bytes"), int):
raise GalleryError("manifest contains a malformed preview entry")
return manifest
def _is_sha256(value: Any) -> bool:
return isinstance(value, str) and len(value) == 64 and all(
c in "0123456789abcdef" for c in value
)
# ── Manifest on disk ─────────────────────────────────────────────────────────
def load_manifest() -> Optional[dict]:
"""The stored manifest, re-verified against the pubkey. ``None`` if absent.
Re-verifying on every load (rather than trusting the file because it was
verified when written) means tampering with ``omnivoice_data`` after the
fact buys nothing, and costs one Ed25519 check per call.
"""
root = gallery_root()
try:
raw = (root / _MANIFEST_NAME).read_bytes()
signature = (root / _SIGNATURE_NAME).read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError):
return None
try:
return verify_manifest(raw, signature)
except GalleryError as exc:
logger.warning("Stored gallery manifest rejected (%s) — ignoring it", exc)
return None
def _store_manifest(raw: bytes, signature: str) -> None:
root = gallery_root()
root.mkdir(parents=True, exist_ok=True)
_atomic_write(root / _MANIFEST_NAME, raw)
_atomic_write(root / _SIGNATURE_NAME, signature.encode("utf-8"))
# ── HTTP ─────────────────────────────────────────────────────────────────────
def base_url() -> str:
"""Where previews are published. Overridable for self-hosting and tests."""
return (os.environ.get("OMNIVOICE_GALLERY_URL") or _DEFAULT_BASE_URL).rstrip("/")
def _client(client=None):
"""The shared httpx client, unless a caller (or a test) supplied one."""
if client is not None:
return client
from api.http_client import get_http_client
return get_http_client()
async def _fetch(client, url: str, limit: int, headers: Optional[dict] = None,
timeout: float = _HTTP_TIMEOUT_S):
"""GET *url*, streaming under a hard byte cap.
Returns ``(status_code, headers, body)``; body is ``b""`` for 304. Raises
:class:`GalleryError` when the response exceeds *limit* a
Content-Length-free chunked response would otherwise be unbounded.
"""
import httpx
async with client.stream(
"GET", url, headers=headers or {}, timeout=timeout,
follow_redirects=True,
) as response:
if response.status_code == 304:
return 304, response.headers, b""
if response.status_code != 200:
raise GalleryError(f"gallery returned HTTP {response.status_code}")
chunks: list[bytes] = []
total = 0
try:
async for chunk in response.aiter_bytes():
total += len(chunk)
if total > limit:
raise GalleryError("gallery response exceeded its size cap")
chunks.append(chunk)
except httpx.HTTPError as exc:
raise GalleryError(f"gallery transfer failed: {type(exc).__name__}") from exc
return 200, response.headers, b"".join(chunks)
def _quiet(exc: BaseException) -> None:
"""Log a network-shaped failure without surfacing it.
Offline is the expected state, not an error: the caller falls back to a
local render and the user is told nothing.
"""
logger.debug("Voice gallery unreachable (%s: %s)", type(exc).__name__, exc)
# ── Update check ─────────────────────────────────────────────────────────────
async def check_for_updates(
*, force: bool = False, client=None, now: Optional[float] = None
) -> dict:
"""Refresh the manifest at most once a day and re-fetch changed previews.
Only previews **already cached** are re-fetched: the preview key is derived
from the archetype definition, so a re-render keeps its key and changes only
its bytes, which makes the per-file SHA-256 the thing the updater diffs on.
Bulk-fetching every changed key would turn a silent background refresh into
a 1126-file download nobody asked for.
"""
state = load_state()
if not state.get("enabled"):
return status(now=now)
ts = _now(now)
# "Never checked" is `last_checked` absent, not zero: `or 0` would make an
# injected clock near the epoch look like a check that just happened, and
# silently skip the very first refresh.
last_checked = state.get("last_checked")
if not force and last_checked is not None and ts - float(last_checked) < UPDATE_INTERVAL_S:
return status(now=now)
http_client = _client(client)
headers = {}
etag = state.get("etag")
if etag and load_manifest() is not None:
headers["If-None-Match"] = etag
try:
code, resp_headers, raw = await _fetch(
http_client, f"{base_url()}/{_MANIFEST_NAME}", _MAX_MANIFEST_BYTES, headers,
)
if code == 304:
state["last_checked"] = ts
state.pop("last_error", None)
_save_state(state)
return status(now=now)
_, _, signature_raw = await _fetch(
http_client, f"{base_url()}/{_SIGNATURE_NAME}", _MAX_SIGNATURE_BYTES,
)
manifest = verify_manifest(raw, signature_raw.decode("utf-8", "replace"))
except GalleryError as exc:
# A signature failure is not a transient network hiccup — it is the one
# state the user should be able to see, so record it. The app still
# works: previews render locally.
logger.warning("Voice gallery update rejected: %s", exc)
state["last_checked"] = ts
state["last_error"] = str(exc)
_save_state(state)
return status(now=now)
except Exception as exc: # offline, DNS, TLS, timeout — all expected
_quiet(exc)
state["last_checked"] = ts
_save_state(state)
return status(now=now)
previous = load_manifest() or {}
_store_manifest(raw, signature_raw.decode("utf-8", "replace"))
state["last_checked"] = ts
state["etag"] = resp_headers.get("etag") or state.get("etag")
state.pop("last_error", None)
_save_state(state)
old = previous.get("previews") or {}
refreshed = 0
for key, entry in (manifest.get("previews") or {}).items():
if cached_preview(key) is None:
continue
if (old.get(key) or {}).get("sha256") == entry.get("sha256"):
continue
if await _download_preview(http_client, key, entry) is not None:
refreshed += 1
out = status(now=now)
out["refreshed"] = refreshed
return out
_refresh_task: Optional["asyncio.Task"] = None
def maybe_refresh_in_background() -> None:
"""Kick a throttled update check without making the caller wait for it.
The 24 h throttle only means something if something asks, and nothing else
in the app polls there is no background scheduler to hang this on. Serving
a preview is the honest trigger: it is the moment previews matter, and the
check is a no-op on all but the first request of the day. The task handle is
held module-level because a bare ``create_task`` result can be garbage
collected mid-flight.
"""
global _refresh_task
if not is_enabled() or (_refresh_task is not None and not _refresh_task.done()):
return
try:
loop = asyncio.get_running_loop()
except RuntimeError: # sync context (CLI, tests) — nothing to schedule onto
return
_refresh_task = loop.create_task(check_for_updates())
# ── Per-file fetch ───────────────────────────────────────────────────────────
async def _download_preview(client, key: str, entry: dict,
timeout: float = _HTTP_TIMEOUT_S) -> Optional[Path]:
"""Fetch one preview and commit it only if it matches the signed digest."""
try:
path = preview_path(key)
filename = safe_filename(entry.get("filename") or f"{key}.mp3")
limit = min(_MAX_PREVIEW_BYTES, max(int(entry.get("bytes") or 0), 1))
_, _, body = await _fetch(client, f"{base_url()}/previews/{filename}", limit,
timeout=timeout)
_commit_preview(path, body, entry)
return path
except (GalleryError, UnsafePath, OSError, ValueError) as exc:
logger.debug("Gallery preview %s not fetched (%s)", key[:8], exc)
return None
except Exception as exc:
_quiet(exc)
return None
def _commit_preview(path: Path, body: bytes, entry: dict) -> None:
"""Write *body* to *path* iff it is exactly the bytes the manifest signed."""
if len(body) != int(entry["bytes"]):
raise GalleryError("preview length does not match the manifest")
if hashlib.sha256(body).hexdigest() != entry["sha256"]:
raise GalleryError("preview digest does not match the manifest")
_atomic_write(path, body)
async def fetch_preview(
key: str, *, client=None, now: Optional[float] = None
) -> Optional[Path]:
"""Fetch a single preview on demand. ``None`` whenever that can't happen.
Silent by contract offline, disabled, and unknown-key all look the same to
the caller, which then renders locally.
"""
if not is_enabled():
return None
cached = cached_preview(key)
if cached is not None:
return cached
manifest = load_manifest()
if manifest is None:
await check_for_updates(client=client, now=now)
manifest = load_manifest()
if manifest is None:
return None
entry = (manifest.get("previews") or {}).get(key)
if not isinstance(entry, dict):
return None
http_client = _client(client)
return await _download_preview(http_client, key, entry, ON_DEMAND_TIMEOUT_S)
# ── Featured set ─────────────────────────────────────────────────────────────
async def fetch_featured(
*, client=None, now: Optional[float] = None, force: bool = False
) -> dict:
"""Download the 51 featured previews as one tarball.
One request instead of 51: the featured set is what the voice picker opens
on, so it is the only bulk fetch this client performs and it happens only
after the user has enabled the gallery.
"""
if not is_enabled():
return status(now=now)
manifest = load_manifest()
if manifest is None or force:
await check_for_updates(force=True, client=client, now=now)
manifest = load_manifest()
if manifest is None:
return status(now=now)
featured = manifest.get("featured")
if not isinstance(featured, dict) or not _is_sha256(featured.get("sha256")):
return status(now=now)
http_client = _client(client)
try:
limit = min(_MAX_FEATURED_BYTES, max(int(featured.get("bytes") or 0), 1))
name = safe_filename(featured.get("filename") or _FEATURED_NAME)
_, _, body = await _fetch(http_client, f"{base_url()}/{name}", limit)
if hashlib.sha256(body).hexdigest() != featured["sha256"]:
raise GalleryError("featured tarball digest does not match the manifest")
extracted = _extract_featured(body, manifest)
except GalleryError as exc:
logger.warning("Featured preview set rejected: %s", exc)
return status(now=now)
except Exception as exc:
_quiet(exc)
return status(now=now)
out = status(now=now)
out["fetched"] = extracted
return out
def _extract_featured(body: bytes, manifest: dict) -> int:
"""Unpack the featured tarball member-by-member, verifying each file.
Nothing is handed to ``TarFile.extract``: member names are matched against
the key grammar, only regular files are read, and every member's bytes must
match the digest the signed manifest carries for that key. A tarball is a
filesystem-write primitive, and this one arrives over the network.
"""
previews = manifest.get("previews") or {}
written = 0
with tempfile.TemporaryDirectory() as tmp:
archive = Path(tmp) / "featured.tar.gz"
archive.write_bytes(body)
with tarfile.open(archive, "r:gz") as tar:
for member in tar:
match = _MEMBER_RE.match(member.name)
if match is None or not member.isfile():
logger.debug("Skipping gallery tar member %r", member.name[:64])
continue
key = match.group(1)
entry = previews.get(key)
if not isinstance(entry, dict) or member.size != int(entry.get("bytes") or -1):
continue
handle = tar.extractfile(member)
if handle is None:
continue
data = handle.read(_MAX_PREVIEW_BYTES + 1)
try:
_commit_preview(preview_path(key), data, entry)
except (GalleryError, UnsafePath, OSError):
continue
written += 1
return written
# ── Status ───────────────────────────────────────────────────────────────────
def status(*, now: Optional[float] = None) -> dict:
"""What Settings shows: consent, coverage, freshness, and provenance.
Counts are reported as "featured set cached" / "N extra voices", never as a
raw fraction of 1126 a number nobody can act on.
"""
state = load_state()
manifest = load_manifest()
previews = (manifest or {}).get("previews") or {}
featured_keys = [k for k, e in previews.items() if isinstance(e, dict) and e.get("featured")]
cached_keys = _cached_keys()
featured_cached = sum(1 for k in featured_keys if k in cached_keys)
return {
"enabled": bool(state.get("enabled")),
"available": manifest is not None,
"featured_total": len(featured_keys),
"featured_cached": featured_cached,
"cached": len(cached_keys),
"last_checked": state.get("last_checked"),
"last_error": state.get("last_error"),
"engine": (manifest or {}).get("engine"),
"engine_version": (manifest or {}).get("engine_version"),
"generated_at": (manifest or {}).get("generated_at"),
"checked_seconds_ago": (
max(0.0, _now(now) - float(state["last_checked"]))
if state.get("last_checked") else None
),
}
def _cached_keys() -> set[str]:
try:
return {
p.stem for p in _previews_dir().iterdir()
if p.suffix == ".mp3" and _KEY_RE.match(p.stem)
}
except OSError:
return set()
# ── Utilities ────────────────────────────────────────────────────────────────
def _atomic_write(path: Path, data: bytes) -> None:
"""Write via a sibling temp file + replace so no reader sees a half file."""
path.parent.mkdir(parents=True, exist_ok=True)
fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix=".gallery-", suffix=".part")
try:
with os.fdopen(fd, "wb") as fh:
fh.write(data)
fh.flush()
os.fsync(fh.fileno())
os.replace(tmp, path)
except BaseException:
with __import__("contextlib").suppress(OSError):
os.unlink(tmp)
raise
+964
View File
@@ -0,0 +1,964 @@
"""One door to a GPU — this machine's, or the one the user picked.
Every GPU call in this app used to be the same three lines inlined at ~30 call
sites: resolve a backend, ``run_on_gpu_pool_guarded`` it, translate the pool's
exceptions into an HTTP answer. That shape has exactly one destination baked
into it, so "run this on my 4090" could never be more than a badge. This module
is the seam that makes the destination a *parameter*:
decision = gpu_gateway.decide("tts") # local, or the chosen worker
await gpu_gateway.prewarm("tts", backend=b, decision=decision)
audio = await gpu_gateway.run("tts", local=..., remote=..., decision=decision)
Four calls, and each of them answers for both targets:
* :func:`prewarm` the model-load budget
* :func:`run` the generate budget
* :func:`status` supported / installed / downloaded / resident
* :func:`download` fetching weights
Design decisions that are load-bearing, and why they are not obvious:
**``prewarm`` and ``run`` are separate calls.** Collapsing them loses the
two-phase split documented at ``tts_backend.ensure_ready`` (#1033/#1037): a cold
adapter that loads lazily inside ``generate()`` spends the *generate* budget on
a multi-GB download and dies with "too heavy for the available compute". The
protocol mirrors the same split (``TaskModelLoading`` and
``Deadlines.model_load_seconds``), so keeping the two calls apart is what lets
one policy serve both targets.
**The local branch calls ``run_on_gpu_pool_guarded``; the remote branch does
not.** That function means "submit a zero-arg blocking callable to the local
thread pool". Its error taxonomy (``GpuPoolBusyError`` / ``GpuJobTimeoutError``,
plus a pool ``reset()``) describes local saturation, and its ``started.set()``
handshake is a second timeout regime that would race the attempt ``Deadlines``.
A remote job is bounded by the lease and the phase budgets instead.
**This is not a ``RemoteBackend(TTSBackend)``.** ``generate()`` is synchronous
and returns a tensor, so a remote implementation would block a pool thread on
an async round-trip *while holding a GPU-pool slot* a hard deadlock at
``OMNIVOICE_GPU_WORKERS=1``, which is the default on the machines that most
want to offload. It would also cover none of the non-TTS GPU work.
**Admission control lives here.** ``check_gpu_admission`` reads *local* pool
stats; called unconditionally it would answer 429 "the local GPU pool is
saturated" while the remote 4090 sat idle. It runs on the local branch only.
**Fallback is three rules, not one** (see ``worker/routing.py``'s header):
1. *Pre-dispatch* unavailability the worker is offline, disabled, paused,
the queue is full, or nothing ever accepted the task runs locally,
quietly, with the named reason. Nothing ran remotely, so nothing is lost.
2. *Mid-job* failure on a single-shot interactive op raises
:class:`RemoteJobFailed`. Silently redoing minutes of work on the slower
machine, with no explanation, is not a kindness.
3. *Multi-unit* jobs (audiobook chapters, batches) pass a :class:`JobRun`;
after N consecutive remote failures the job latches local for the rest of
its units and reports **one** aggregated notice, instead of 160 identical
error rows because a 4090 went to sleep at chapter 40.
"""
from __future__ import annotations
import asyncio
import logging
import os
from dataclasses import dataclass, field
from typing import Any, Callable, Optional
from worker import routing
from worker.routing import LOCAL, Decision
logger = logging.getLogger("omnivoice.gateway")
# How often the awaiting coroutine samples a remote task to report coarse
# progress. Polling rather than `scheduler.on_change`: that listener list has
# no unregister (scheduler.py), so one subscription per job would leak for the
# life of the process.
_POLL_SECONDS = 0.5
# Consecutive remote failures a multi-unit job tolerates before it stops trying
# the remote worker. One is a blip (a dropped stream, a worker restart); two in
# a row is a machine that has gone away, and the remaining 160 chapters should
# not each pay a full deadline to discover that.
_MULTI_UNIT_FAILURE_LIMIT = 2
# Coarse phases the UI can render for a remote job. `workers.py`'s task view is
# poll-only, so without these a five-minute remote render shows the same bare
# spinner as a local one and looks wedged.
PHASE_QUEUED = "queued"
PHASE_LOADING = "loading"
PHASE_RUNNING = "running"
PHASE_UPLOADING = "uploading"
# ── Errors ─────────────────────────────────────────────────────────────────
class GatewayError(RuntimeError):
"""Base for every error this module raises on its own behalf."""
class ModelLoadTimeout(GatewayError):
"""A local engine did not finish loading inside the model-load budget."""
class ModelNotDownloaded(GatewayError):
"""The selected worker positively reported that required weights are absent."""
def __init__(
self, *, engine: str, repo_ids: list[str], target: str, target_label: str,
downloadable: bool = True,
):
super().__init__(f"This model is not downloaded on {target_label}.")
self.engine = engine
self.repo_ids = repo_ids
self.target = target
self.target_label = target_label
self.downloadable = downloadable
class RemoteJobFailed(GatewayError):
"""Remote work started and then failed. Rule 2: this is not a fallback.
Carries what a caller needs to offer "Run locally instead" the same
request with ``target=local`` rather than a bare 500.
"""
def __init__(
self,
message: str,
*,
worker_label: str = "",
task_id: str = "",
code: str = "",
hint: str = "",
) -> None:
super().__init__(message)
self.worker_label = worker_label
self.task_id = task_id
self.code = code
self.hint = hint
# Nothing about this failure implicates the local machine, so a retry
# here is genuinely likely to work. Callers surface it as one click.
self.retry_local = True
class RemoteUnsupported(GatewayError):
"""Asked for something the remote path cannot do yet.
Deliberately not a quiet local fallback: downloading weights onto *this*
machine when the user asked for them on the 4090 is not the same operation,
and pretending it is leaves the remote box exactly as unprepared as before.
"""
class _NotDispatched(Exception):
"""Internal: the remote target never started the work. Rule 1 applies."""
def __init__(self, reason: str) -> None:
super().__init__(reason)
self.reason = reason
# ── Call descriptions ──────────────────────────────────────────────────────
@dataclass(frozen=True)
class LocalCall:
"""The local branch: a zero-arg blocking callable for the GPU pool.
``fn`` must take no arguments wrap with ``functools.partial``, exactly as
``run_on_gpu_pool_guarded`` already requires.
"""
fn: Optional[Callable[[], Any]] = None
what: str = "GPU job"
timeout: Optional[float] = None
queue_timeout: Optional[float] = None
# The engine's declared VRAM floor; only shapes the timeout message.
min_vram_gb: float = 0.0
# Some remote-first callers cannot construct the local callable without
# loading the very model they are trying to offload. Prepare it only when
# routing/fallback actually selects this machine.
prepare: Optional[Callable[[], Any]] = None
@dataclass(frozen=True)
class RemoteResult:
"""A committed remote result, as this side holds it.
The artifact is a path inside the control plane's own artifact directory —
minted from the attempt record, never from anything on the wire.
"""
task_id: str
worker_id: str
worker_label: str
path: Optional[str] = None
meta: dict = field(default_factory=dict)
def read(self) -> bytes:
if not self.path:
raise RemoteJobFailed(
f"{self.worker_label or 'The worker'} reported success but sent no audio.",
worker_label=self.worker_label,
task_id=self.task_id,
code="RESULT_MISSING",
)
with open(self.path, "rb") as handle:
return handle.read()
@dataclass(frozen=True)
class RemoteCall:
"""The remote branch: one task for the scheduler, and how to read it back.
``decode`` converts the committed artifact into whatever the local branch
returns, so ``run`` has one return type regardless of where the work ran.
Left unset for ``tts``/``clone`` it defaults to :func:`decode_audio_artifact`
the audio ops are the only ones with a remote producer today, and a
caller that has to branch on the target has gained nothing from this module.
"""
engine: str
params: dict = field(default_factory=dict)
operation: str = "tts"
# Stable, opaque, engine-scoped ("indextts:default") — never a repo id or a
# path. Empty means "any model this engine advertises".
model_id: str = ""
deadline_seconds: Optional[float] = None
idempotency_key: Optional[str] = None
decode: Optional[Callable[[RemoteResult], Any]] = None
# ── Multi-unit jobs (rule 3) ───────────────────────────────────────────────
class JobRun:
"""State for a job made of many units, so rule 3 can be applied once.
Created per audiobook / batch / dub run and passed to every ``run`` call it
makes. It counts *consecutive* remote failures an intermittent blip
should not permanently demote a working worker and latches local once the
limit is reached, because the alternative is paying a full remote deadline
per remaining unit to rediscover the same dead machine.
"""
def __init__(self, op: str, *, limit: int = _MULTI_UNIT_FAILURE_LIMIT) -> None:
self.op = op
self.limit = max(1, int(limit))
self.consecutive_failures = 0
self.remote_failures = 0
self.local_units = 0
self.remote_units = 0
self.latched_local = False
self.worker_label = ""
self.last_reason = ""
def record_success(self) -> None:
self.consecutive_failures = 0
self.remote_units += 1
def record_failure(self, reason: str, *, worker_label: str = "") -> bool:
"""Charge one failed unit. ``True`` if this unit may fall back locally.
Always true today: the first failed unit already falls back rather than
failing the row, and the counter decides whether *later* units still try
the worker at all.
"""
self.consecutive_failures += 1
self.remote_failures += 1
self.last_reason = reason
self.worker_label = worker_label or self.worker_label
if self.consecutive_failures >= self.limit:
self.latched_local = True
return True
def record_local(self) -> None:
self.local_units += 1
def notice(self) -> Optional[tuple[str, str]]:
"""The single aggregated notice for the whole job, or ``None``."""
if not self.remote_failures:
return None
who = self.worker_label or "the remote worker"
if self.latched_local:
return (
"local_fallback",
f"{who} failed {self.remote_failures} time(s) "
f"({self.last_reason}) — the rest of this job ran locally.",
)
return (
"local_fallback",
f"{self.remote_failures} item(s) ran locally after {who} failed "
f"({self.last_reason}).",
)
# ── Routing ────────────────────────────────────────────────────────────────
def decide(op: str, *, control_plane=None) -> Decision:
"""Where this job runs, decided **once**.
Callers pass the result to ``prewarm`` and ``run`` rather than calling this
per step: the target is user-settable at any moment, and a decision that
flipped between the two would either warm an engine nothing will use or
dispatch remotely after paying a local cold load.
"""
return routing.decide(control_plane, op=op)
def notice_for(decision: Decision) -> Optional[tuple[str, str]]:
"""``(status, reason)`` for the ``X-OmniVoice-Routing`` header channel.
Same two-tuple shape ``engine_routing.routing_notice`` produces, so the
existing header plumbing and the de-duped toast at ``routingNotice.js``
carry it without a second channel. ``None`` when there is nothing to say
a user who chose Local does not need to be told their audio ran locally.
"""
if decision.remote:
return ("remote", f"running on {decision.label}")
if decision.reason and decision.reason != "chosen":
return ("local_fallback", decision.reason)
return None
# ── Prewarm: the model-load budget ─────────────────────────────────────────
async def prewarm(
op: str,
*,
backend=None,
engine: str = "",
decision: Optional[Decision] = None,
timeout: Optional[float] = None,
executor=None,
control_plane=None,
) -> Decision:
"""Make the model ready under the LOAD budget, on whichever GPU will run it.
Returns the decision so the caller can hand the *same* one to :func:`run`.
On the remote branch this deliberately does nothing local. Warming an
engine here before dispatching to another machine costs minutes and VRAM on
the box that is not doing the work; the worker loads under its own
``model_load_seconds`` and reports ``TaskModelLoading`` while it does, which
is the whole point of the phase existing on the wire.
"""
decision = decision or decide(op, control_plane=control_plane)
if decision.remote:
await preflight(engine, decision, control_plane=control_plane)
plane = _plane(control_plane)
if engine and plane is not None and getattr(plane, "servicer", None) is not None:
await plane.servicer.prewarm(decision.worker_id, engine=engine)
return decision
if backend is None:
# The native model path warms itself through `model_manager.get_model`;
# there is nothing engine-shaped to call.
return decision
call = LocalCall(
backend.ensure_ready,
what=f"TTS engine '{engine or getattr(backend, 'engine_id', '') or 'model'}' model load",
timeout=timeout if timeout is not None else _model_load_timeout(),
)
try:
await _run_local(call, executor=executor)
except TimeoutError as exc:
# Builtin TimeoutError, not GpuJobTimeoutError — reload-proof class
# identity, the same catch generation.py and openai_compat.py use.
# GpuPoolBusyError is a TimeoutError too and means the opposite thing
# (the load never started, nothing was spent, retry as-is); it is
# identified by its `retry_after` rather than by class, so a module
# reload cannot turn saturation into a bogus load timeout.
if hasattr(exc, "retry_after"):
raise
raise ModelLoadTimeout(
f"{call.what} did not finish within its budget. The first load of an "
f"engine can include a multi-GB download; check the connection or set "
f"a Hugging Face mirror in Settings, then retry."
) from exc
return decision
# ── Run: the generate budget ───────────────────────────────────────────────
async def run(
op: str,
*,
local: LocalCall,
remote: Optional[RemoteCall] = None,
decision: Optional[Decision] = None,
job: Optional[JobRun] = None,
admit: bool = False,
on_state: Optional[Callable[[dict], None]] = None,
executor=None,
control_plane=None,
) -> Any:
"""Run one unit of GPU work, here or on the chosen worker.
``local`` is always required it is both the local branch and the landing
ground for rules 1 and 3, and a gateway that could not run anything locally
would turn every offline worker into a failed request.
"""
if job is not None and job.latched_local:
decision = Decision(remote=False, reason=job.last_reason or "the remote worker failed")
decision = decision or decide(op, control_plane=control_plane)
if decision.remote and remote is not None:
try:
value = await _run_remote(
remote, decision, on_state=on_state, control_plane=control_plane
)
except _NotDispatched as exc:
# Rule 1. Nothing ran remotely, so this is the quiet fallback the
# picker already promises — no compute was spent anywhere.
logger.info("Remote dispatch declined (%s); running locally", exc.reason)
if job is not None:
job.record_local()
return await _run_local(local, admit=admit, executor=executor)
except RemoteJobFailed as exc:
# Rules 2 and 3. Work started on the worker and did not finish.
if job is None:
raise
job.record_failure(str(exc), worker_label=exc.worker_label)
logger.warning(
"Remote unit failed on %s (%s); running this unit locally",
exc.worker_label or "the worker", exc,
)
job.record_local()
return await _run_local(local, admit=admit, executor=executor)
if job is not None:
job.record_success()
return value
if job is not None:
job.record_local()
return await _run_local(local, admit=admit, executor=executor)
async def _run_local(call: LocalCall, *, admit: bool = False, executor=None) -> Any:
"""The local branch: admission, then the guarded pool."""
if call.prepare is not None:
prepared = await call.prepare()
if not isinstance(prepared, LocalCall):
raise TypeError("LocalCall.prepare must return a LocalCall")
return await _run_local(prepared, admit=admit, executor=executor)
if call.fn is None:
raise TypeError("LocalCall requires fn or prepare")
from services.model_manager import ( # noqa: PLC0415 — torch lives down here
check_gpu_admission,
run_on_gpu_pool_guarded,
)
if admit:
# Only ever on this branch: these are local pool statistics, and a 429
# about local saturation while the remote GPU idles is a lie.
check_gpu_admission(what=call.what, executor=executor)
return await run_on_gpu_pool_guarded(
call.fn,
what=call.what,
timeout=call.timeout,
queue_timeout=call.queue_timeout,
min_vram_gb=call.min_vram_gb,
executor=executor,
)
async def _run_remote(
call: RemoteCall,
decision: Decision,
*,
on_state: Optional[Callable[[dict], None]] = None,
control_plane=None,
) -> Any:
"""The remote branch: submit, await, decode.
Raises ``_NotDispatched`` while nothing has run yet (rule 1) and
:class:`RemoteJobFailed` once a worker accepted the work (rules 2/3). That
boundary is the whole fallback policy: "no compute was spent" is the only
honest licence to silently redo the job somewhere else.
"""
from worker.scheduler import QueueFull, SchedulerStopped # noqa: PLC0415
plane = _plane(control_plane)
if plane is None or not getattr(plane, "running", False):
raise _NotDispatched("remote workers are turned off")
scheduler = plane.scheduler
if scheduler is None:
raise _NotDispatched("the control plane has no scheduler")
await preflight(call.engine, decision, call.model_id, control_plane=plane)
params = dict(call.params or {})
deadline = call.deadline_seconds
if deadline is None:
deadline = _default_deadline(call.operation, params.get("text"))
try:
task = scheduler.submit(
operation=call.operation,
engine=call.engine,
model_id=call.model_id,
params=params,
idempotency_key=call.idempotency_key,
deadline_seconds=deadline,
pinned_worker_id=decision.worker_id,
)
except QueueFull as exc:
raise _NotDispatched(str(exc)) from exc
_emit(on_state, {"phase": PHASE_QUEUED, "progress": 0.0, "stage": "",
"worker": decision.label, "task_id": task.task_id})
try:
settled = await _await_task(
scheduler, task.task_id, timeout=deadline,
on_state=on_state, label=decision.label,
control_plane=plane,
)
except KeyError as exc:
# The scheduler no longer holds the task (purged, or restored into a
# different instance). Nothing ran here, so rule 1 applies.
raise _NotDispatched("the remote task was dropped before it ran") from exc
except SchedulerStopped as exc:
raise _classify(scheduler.get(task.task_id) or task, str(exc), decision) from exc
except TimeoutError as exc:
raise _classify(
scheduler.get(task.task_id) or task,
f"it did not finish within {float(deadline):g}s",
decision,
) from exc
return _decode(call, settled, decision)
async def preflight(
engine: str,
decision: Decision,
model_id: str = "",
*,
control_plane=None,
) -> None:
"""Refuse a positively absent remote model before scheduler admission.
``downloaded`` predates ``repo_ids`` on the wire. A worker in the
protocol compatibility window can therefore prove absence while lacking
the newer field that names the download. Recover catalog ids for that
case; an absent/unknown ``downloaded`` fact still fails open.
"""
if not engine:
return
target = await status(engine, decision=decision, control_plane=control_plane)
for cap in target["models"]:
if model_id and cap.get("model_id") not in (model_id, "", None):
continue
if cap.get("downloaded") is False:
repo_ids = list(cap.get("repo_ids") or [])
if not repo_ids:
from worker.capabilities import repo_ids_for # noqa: PLC0415
repo_ids = repo_ids_for({"id": engine})
if not repo_ids:
# Positive absence without a safe catalog target is actionable
# only as "cannot run"; never invent a path or reject an
# opaque/user-managed installation.
return
from services.sidecar_install import SPECS # noqa: PLC0415
sidecar_repos = {s.weights_repo_id for s in SPECS.values()}
raise ModelNotDownloaded(
engine=engine,
repo_ids=repo_ids,
target=decision.worker_id,
target_label=decision.label,
downloadable=not any(repo in sidecar_repos for repo in repo_ids),
)
async def _await_task(
scheduler, task_id: str, *, timeout: float, on_state, label: str, control_plane=None
):
"""Await a terminal task, reporting coarse progress, cancelling if we leave.
Every exit that is not a terminal task cancels the remote task, because a
worker holds its slot often its only one until this side says
otherwise, and the sweeper only enforces deadlines on tasks that are still
queued. Without this, ``useTTS.js``'s AbortController abandons the request
while the 4090 keeps rendering audio nobody will ever read.
The one exception is shutdown: ``SchedulerStopped`` means this side is
quitting, the worker was never told to stop and may still be rendering, so
recording a cancellation would be a claim about someone else's GPU that we
are in no position to make.
"""
from worker.scheduler import SchedulerStopped # noqa: PLC0415
waiter = asyncio.ensure_future(scheduler.wait(task_id, timeout=timeout))
last: Optional[tuple] = None
try:
while True:
done, _pending = await asyncio.wait({waiter}, timeout=_POLL_SECONDS)
if not done:
last = _report(scheduler, task_id, on_state, label, last)
continue
# Raises here for a deadline or a shutdown; both are handled below.
return waiter.result()
except SchedulerStopped:
waiter.cancel()
raise
except asyncio.CancelledError:
waiter.cancel()
await _cancel(control_plane, scheduler, task_id, "the client stopped waiting")
raise
except BaseException:
waiter.cancel()
await _cancel(control_plane, scheduler, task_id, "the task passed its deadline")
raise
def _report(scheduler, task_id: str, on_state, label: str, last: Optional[tuple]):
"""Emit a coarse phase when it changes. Never raises."""
if on_state is None:
return last
try:
task = scheduler.get(task_id)
except Exception:
return last
if task is None:
return last
attempt = task.active_attempt
phase = _PHASES.get(getattr(task.state, "value", ""), PHASE_QUEUED)
progress = round(float(getattr(attempt, "progress", 0.0) or 0.0), 2)
stage = getattr(attempt, "stage", "") or ""
current = (phase, progress, stage)
if current == last:
return last
_emit(on_state, {"phase": phase, "progress": progress, "stage": stage,
"worker": label, "task_id": task_id})
return current
_PHASES = {
"queued": PHASE_QUEUED,
"assigned": PHASE_QUEUED,
"accepted": PHASE_QUEUED,
"model_loading": PHASE_LOADING,
"running": PHASE_RUNNING,
"result_uploading": PHASE_UPLOADING,
}
def _emit(on_state, payload: dict) -> None:
if on_state is None:
return
try:
on_state(payload)
except Exception:
logger.debug("Remote progress listener failed", exc_info=True)
async def _cancel(control_plane, scheduler, task_id: str, reason: str) -> None:
try:
if control_plane is not None and hasattr(control_plane, "cancel"):
await control_plane.cancel(task_id, reason=reason)
else:
scheduler.cancel(task_id, reason=reason)
except Exception:
logger.exception("Could not cancel abandoned remote task %s", task_id)
def _decode(call: RemoteCall, task, decision: Decision) -> Any:
"""Turn a settled task into the local branch's return value, or fail."""
state = getattr(task.state, "value", str(task.state))
if state != "completed":
raise _classify(task, _reason(task), decision)
result = RemoteResult(
task_id=task.task_id,
worker_id=decision.worker_id or "",
worker_label=decision.label,
path=task.result_ref,
meta={"engine": task.engine, "model_id": task.model_id},
)
if result.path is None or not os.path.exists(result.path):
# Completed with nothing to read. Treated as a mid-job failure, not a
# quiet fallback: the worker spent the compute, and a caller told
# "nothing ran" would be misled about where its minutes went.
raise RemoteJobFailed(
f"{decision.label} finished the job but its audio did not arrive.",
worker_label=decision.label,
task_id=task.task_id,
code="RESULT_MISSING",
hint="Run it locally instead, or check the worker's connection.",
)
decoder = call.decode or (
decode_audio_artifact if call.operation in ("tts", "clone") else None
)
if decoder is None:
return result
try:
return decoder(result)
except RemoteJobFailed:
raise
except Exception as exc: # noqa: BLE001 — any decode failure is one class
# A truncated or unreadable artifact is a mid-job failure, not a quiet
# fallback: the compute happened, and a multi-unit job must be able to
# count it against the worker like any other.
raise RemoteJobFailed(
f"{decision.label} returned audio this app could not read: {exc}",
worker_label=decision.label,
task_id=task.task_id,
code="RESULT_UNREADABLE",
) from exc
def _classify(task, reason: str, decision: Decision):
"""``_NotDispatched`` while nothing ran; ``RemoteJobFailed`` once it did."""
error = getattr(task, "error", None)
code = getattr(error, "code", "") or ""
# An explicit target is a user choice, not permission to leak onto local
# compute when that machine is asleep. Preserve the scheduler's named
# pinned verdict even though no worker accepted the attempt.
if not _work_started(task) and not code.startswith("PINNED_WORKER_"):
return _NotDispatched(reason)
return RemoteJobFailed(
f"{decision.label} did not finish this job: {reason}",
worker_label=decision.label,
task_id=task.task_id,
code=code,
hint=getattr(error, "hint", "") or "",
)
def _work_started(task) -> bool:
"""Did any worker actually accept this task?
``accepted_at`` rather than "an attempt exists": an assignment that was
rejected for capacity, or that died in a dispatch race before the worker
answered, cost nothing anywhere and is exactly the case rule 1 exists for.
"""
for attempt in getattr(task, "attempts", []) or []:
if getattr(attempt, "accepted_at", None) is not None:
return True
if getattr(attempt, "started_at", None) is not None:
return True
return False
def _reason(task) -> str:
error = getattr(task, "error", None)
message = getattr(error, "message", "") if error is not None else ""
if message:
return message
state = getattr(task.state, "value", str(task.state))
return {
"cancelled": "the task was cancelled",
"timeout": "the task passed its deadline",
}.get(state, "the task failed")
def decode_audio_artifact(result: RemoteResult):
"""``(waveform, sample_rate)`` from a remote WAV artifact.
The local branch returns a tensor plus the engine's ``sample_rate``; this
returns the same pair read from the artifact's own header rather than from
an assumed 24 kHz VoxCPM2 renders at 48 kHz, and guessing plays it back
at half speed.
"""
import io # noqa: PLC0415
import soundfile as sf # noqa: PLC0415
data, sample_rate = sf.read(io.BytesIO(result.read()), dtype="float32", always_2d=False)
try:
import torch # noqa: PLC0415
waveform = torch.from_numpy(data)
except Exception: # noqa: BLE001 — a torch-less host still gets its audio
waveform = data
return waveform, int(sample_rate)
# ── Status: what each target can actually run ──────────────────────────────
async def status(
engine: Optional[str] = None,
*,
decision: Optional[Decision] = None,
op: str = "tts",
control_plane=None,
) -> dict:
"""The four facts per model — supported / installed / downloaded / resident
for whichever machine would run the work.
One shape for both targets, from one producer: ``worker.capabilities``
already derives them from ``tts_backend`` for the local host, and a remote
worker reports the same records through ``Register``. Asking the local
engine layer about a remote machine is how a UI ends up offering an engine
that only exists here.
"""
decision = decision or decide(op, control_plane=control_plane)
if not decision.remote:
return {
"target": LOCAL,
"remote": False,
"label": decision.label,
"reason": decision.reason,
"models": _filtered(_local_capabilities(), engine),
}
plane = _plane(control_plane)
worker = None
pool = getattr(plane, "pool", None) if plane is not None else None
if pool is not None:
worker = pool.get(decision.worker_id)
if worker is None:
# Reachability changed between decide() and here.
return {
"target": decision.worker_id or LOCAL,
"remote": False,
"label": decision.label,
"reason": "the chosen worker is not connected",
"models": _filtered(_local_capabilities(), engine),
}
return {
"target": decision.worker_id,
"remote": True,
"label": decision.label,
"reason": decision.reason,
"models": _filtered(list(worker.record.capabilities or []), engine),
}
def _local_capabilities() -> list[dict]:
from worker import capabilities # noqa: PLC0415
return capabilities.discover(include_unavailable=True)
def _filtered(models: list[dict], engine: Optional[str]) -> list[dict]:
if not engine:
return models
return [m for m in models if m.get("engine") == engine]
# ── Download: weights, onto the machine that needs them ────────────────────
async def download(
repo_id: str,
*,
decision: Optional[Decision] = None,
op: str = "tts",
control_plane=None,
) -> dict:
"""Fetch a catalog model onto the target machine.
Remote downloads are not implemented yet, and this refuses rather than
falling back: downloading onto *this* machine when the user asked for the
weights on the 4090 leaves the remote box exactly as unprepared, having
reported success.
"""
decision = decision or decide(op, control_plane=control_plane)
if decision.remote:
plane = _plane(control_plane)
if plane is None or getattr(plane, "servicer", None) is None:
raise RemoteUnsupported(f"{decision.label} is not connected.")
live = plane.pool.get(decision.worker_id) if plane.pool is not None else None
capability = next(
(
cap for cap in (live.record.capabilities if live is not None else [])
if repo_id in (cap.get("repo_ids") or [])
),
None,
)
if capability is None:
raise GatewayError(f"Unknown model for {decision.label}: {repo_id!r}.")
# Managed sidecars currently fetch mutable source HEAD before installing
# editable code. Do not make that supply-chain path remotely triggerable.
from services.sidecar_install import SPECS # noqa: PLC0415
if any(spec.weights_repo_id == repo_id for spec in SPECS.values()):
raise GatewayError(
f"{repo_id!r} must be installed directly on {decision.label}; "
"remote sidecar installation is disabled."
)
sent = await plane.servicer.prewarm(
decision.worker_id,
engine=str(capability.get("engine") or ""),
model_id=str(capability.get("model_id") or ""),
download_if_missing=True,
)
if not sent:
raise RemoteUnsupported(f"{decision.label} is not connected.")
return {"status": "started", "repo_id": repo_id, "target": decision.worker_id}
from api.routers.setup.download import ( # noqa: PLC0415
InstallModelRequest,
install_model,
)
from api.routers.setup.models import KNOWN_MODELS # noqa: PLC0415
if repo_id not in {m.get("repo_id") for m in KNOWN_MODELS}:
# The wire and the UI both carry catalog ids only; anything else is a
# path by another name, and paths are what the protocol forbids.
raise GatewayError(f"Unknown model: {repo_id!r}.")
return await install_model(InstallModelRequest(repo_id=repo_id, target="local"))
# ── Plumbing ───────────────────────────────────────────────────────────────
def _plane(control_plane=None):
if control_plane is not None:
return control_plane
try:
from worker.service import control_plane as default_plane # noqa: PLC0415
return default_plane
except Exception:
logger.debug("No control plane available", exc_info=True)
return None
def _default_deadline(operation: str, text: Optional[str]) -> float:
"""Worst-case wall time for one attempt, from the shared deadline policy.
Same budget the assignment itself carries, so the awaiting side cannot give
up on a worker that is still inside the time this side granted it.
"""
from worker import deadlines # noqa: PLC0415
return float(deadlines.for_task(operation, text=text).total_seconds)
def _model_load_timeout() -> float:
from services.model_manager import _model_load_timeout as resolve # noqa: PLC0415
return float(resolve())
__all__ = [
"GatewayError",
"ModelNotDownloaded",
"JobRun",
"LOCAL",
"LocalCall",
"ModelLoadTimeout",
"RemoteCall",
"RemoteJobFailed",
"RemoteResult",
"RemoteUnsupported",
"decide",
"decode_audio_artifact",
"download",
"notice_for",
"preflight",
"prewarm",
"run",
"status",
]
+3
View File
@@ -40,6 +40,9 @@ CURATED_REVISIONS: dict[str, str] = {
"pyannote/speaker-diarization-3.1": "84fd25912480287da0247647c3d2b4853cb3ee5d",
"OpenMOSS-Team/MOSS-TTS-Nano-100M": "44502f80dbf9743528fa921cc544d662c685ebec",
"KittenML/kitten-tts-mini-0.8": "c02725660cea441db4c383af69f1f26f5cd00947",
"openbmb/VoxCPM2": "bffb3df5a29440629464e5e839f4d214c8714c3d",
"FunAudioLLM/Fun-CosyVoice3-0.5B-2512": "29e01c4e8d000f4bcd70751be16fa94bf3d85a18",
"lj1995/GPT-SoVITS": "336b2ec4e8d4ac74740798dd40af44e74659ecaf",
"mlx-community/Kokoro-82M-bf16": "a71e4d38b236d968966a2002c4c895dbd12b1c3c",
"mlx-community/csm-1b-8bit": "fcf0cc857eade3615a60f30722cf5197d4f88406",
"mlx-community/Qwen3-TTS-12Hz-1.7B-VoiceDesign-4bit": "5c390979e4b93af5f2932f90742ca99c7dd04687",
+122
View File
@@ -22,8 +22,10 @@ import logging
import os
import re
import threading
import time
from abc import ABC, abstractmethod
from collections import OrderedDict
from contextlib import contextmanager
from typing import Optional
import torch
@@ -2403,6 +2405,126 @@ def get_active_tts_backend(*, model=None) -> TTSBackend:
return _active_instance
# ── Shared engine-instance cache ──────────────────────────────────────────
#
# One instance per engine class for the lifetime of the process. It lived in
# ``api/routers/engines.py`` until the worker needed it too: a worker executing
# a remote assignment must reuse the same warm engine the local generate path
# uses, and importing an API router from ``worker/`` would invert the layering
# (``worker/executor.py`` is a translator over ``services/``). The router now
# aliases this dict, so every consumer that already reaches for
# ``engines._ENGINE_INSTANCES`` — engine_memory's eviction, model_lifecycle's
# inventory and unload — keeps operating on the one true cache.
#
# Keyed by CLASS, not by engine id, because registry-sandbox tests rebind ids
# transiently; ``get_engine_instance_for`` resolves an id through
# ``get_backend_class`` so callers can key by id without the cache doing so.
_ENGINE_INSTANCES: dict[type, object] = {}
# Last use, on the monotonic clock — a wall clock would make an NTP step or a
# laptop resume look like a ten-minute idle and unload a model mid-job.
_ENGINE_LAST_USED: dict[type, float] = {}
# How many jobs are inside an engine right now. A long generation touches the
# cache once at the start, so on elapsed time alone a 40-minute dub looks
# exactly like an abandoned model — and the sweep would unload it out from
# under the thread rendering it.
_ENGINE_IN_USE: dict[type, int] = {}
#: How long an engine may sit unused before its weights are handed back.
ENGINE_IDLE_UNLOAD_SECONDS = 600.0
@contextmanager
def engine_in_use(instance, *, now: Optional[float] = None):
"""Hold an engine against the idle sweep for the duration of one job.
Leaving on the exit stamp rather than the entry one makes "idle" mean
"idle since the work finished", which is the only reading under which the
ten-minute window measures what it claims to.
"""
cls = type(instance)
_ENGINE_IN_USE[cls] = _ENGINE_IN_USE.get(cls, 0) + 1
try:
yield instance
finally:
remaining = _ENGINE_IN_USE.get(cls, 1) - 1
if remaining > 0:
_ENGINE_IN_USE[cls] = remaining
else:
_ENGINE_IN_USE.pop(cls, None)
_ENGINE_LAST_USED[cls] = time.monotonic() if now is None else float(now)
def get_engine_instance(cls, *, now: Optional[float] = None):
"""Return the cached singleton instance of ``cls``, creating it once.
``SubprocessBackend.__init__`` registers an atexit shutdown hook, so
re-instantiating per call would leak handler entries and on real engines,
an extra sidecar process the first time the lock is acquired. One instance
per process is the right move.
"""
inst = _ENGINE_INSTANCES.get(cls)
if inst is None:
inst = cls()
_ENGINE_INSTANCES[cls] = inst
_ENGINE_LAST_USED[cls] = time.monotonic() if now is None else float(now)
return inst
def get_engine_instance_for(engine_id: str, *, now: Optional[float] = None):
"""Cached instance of the TTS engine registered under ``engine_id``.
Deliberately NOT :func:`get_active_tts_backend`: that resolves
``active_backend_id()``, i.e. *this machine's* Settings preference. On a
remote worker that would run whatever the worker's owner happens to prefer
while the control plane's slots, breaker history and result metadata are
keyed to the engine it believes ran wrong audio, silently.
"""
return get_engine_instance(get_backend_class(engine_id), now=now)
def release_idle_engines(
idle_seconds: float = ENGINE_IDLE_UNLOAD_SECONDS,
*,
now: Optional[float] = None,
) -> list[str]:
"""Unload and drop every cached engine unused for ``idle_seconds``.
Least-recently-used first, so a sweep cut short by a raising ``unload()``
has already freed the coldest engine. Never raises: a stuck unload must not
take down the loop that called it. Returns the engine ids released.
"""
stamp = time.monotonic() if now is None else float(now)
# Entries other consumers popped straight out of the cache (engine_memory's
# eviction, model_lifecycle's unload) would otherwise pin a stale class.
for cls in [c for c in _ENGINE_LAST_USED if c not in _ENGINE_INSTANCES]:
_ENGINE_LAST_USED.pop(cls, None)
released: list[str] = []
coldest_first = sorted(_ENGINE_INSTANCES, key=lambda c: _ENGINE_LAST_USED.get(c, 0.0))
for cls in coldest_first:
if _ENGINE_IN_USE.get(cls):
continue
# An instance put here by some other path has no timestamp; start its
# clock now rather than leaving it resident forever.
last_used = _ENGINE_LAST_USED.setdefault(cls, stamp)
if stamp - last_used < idle_seconds:
continue
inst = _ENGINE_INSTANCES.pop(cls, None)
_ENGINE_LAST_USED.pop(cls, None)
engine_id = getattr(cls, "id", cls.__name__)
try:
if inst is not None:
inst.unload()
except Exception as exc: # noqa: BLE001
logger.warning("idle unload: %s.unload() raised: %s", engine_id, exc)
released.append(engine_id)
if released:
logger.info("Released %d idle engine(s): %s", len(released), ", ".join(released))
return released
# ── Shared generation-time engine resolution (issue #312 class) ───────────
#
# dub_generate.py and batch.py used to call services.model_manager.get_model()
+15 -9
View File
@@ -46,10 +46,12 @@ class DownloadAggregator:
self,
repo_id: str,
*,
target: str = "local",
total_bytes: Optional[int] = None,
files_total: Optional[int] = None,
) -> None:
self.repo_id = repo_id
self.target = target
self.total_bytes = total_bytes
# byte bars keyed by an opaque per-bar key (id of the tqdm instance):
# key -> (downloaded, total)
@@ -117,6 +119,7 @@ class DownloadAggregator:
eta = (total - bytes_done) / rate
return {
"repo_id": self.repo_id,
"target": self.target,
"phase": "aggregate",
"bytes_done": bytes_done,
"total_bytes": total,
@@ -128,7 +131,7 @@ class DownloadAggregator:
# ── registry of active per-repo aggregators ────────────────────────────────
_aggregators: dict[str, DownloadAggregator] = {}
_aggregators: dict[tuple[str, str], DownloadAggregator] = {}
_registry_lock = threading.Lock()
_sink_installed = False
@@ -136,23 +139,26 @@ _sink_installed = False
def start(
repo_id: str,
*,
target: str = "local",
total_bytes: Optional[int] = None,
files_total: Optional[int] = None,
) -> DownloadAggregator:
"""Begin (or reset) aggregation for a repo. Called by the preflight."""
agg = DownloadAggregator(repo_id, total_bytes=total_bytes, files_total=files_total)
agg = DownloadAggregator(
repo_id, target=target, total_bytes=total_bytes, files_total=files_total
)
with _registry_lock:
_aggregators[repo_id] = agg
_aggregators[(target, repo_id)] = agg
return agg
def complete(repo_id: str) -> None:
def complete(repo_id: str, *, target: str = "local") -> None:
"""Flush a finished download to 100% (FDL-06). Under Xet the per-file byte
bars never increment `n` or close through our tqdm, so byte-level progress
is unobservable mid-download; this credits the full preflight total on
success so the overall bar lands exactly on done. Emits one final
un-throttled aggregate event."""
agg = _get(repo_id)
agg = _get(repo_id, target)
if agg is None:
return
with agg._lock:
@@ -175,14 +181,14 @@ def complete(repo_id: str) -> None:
pass
def finish(repo_id: str) -> None:
def finish(repo_id: str, *, target: str = "local") -> None:
with _registry_lock:
_aggregators.pop(repo_id, None)
_aggregators.pop((target, repo_id), None)
def _get(repo_id: str) -> Optional[DownloadAggregator]:
def _get(repo_id: str, target: str = "local") -> Optional[DownloadAggregator]:
with _registry_lock:
return _aggregators.get(repo_id)
return _aggregators.get((target, repo_id))
def feed(repo_id, key, unit, downloaded, total, complete) -> None:
+5
View File
@@ -32,6 +32,9 @@ logger = logging.getLogger("omnivoice.hf_progress")
current_repo_id: contextvars.ContextVar[Optional[str]] = contextvars.ContextVar(
"omnivoice_hf_progress_repo_id", default=None,
)
current_target: contextvars.ContextVar[str] = contextvars.ContextVar(
"omnivoice_hf_progress_target", default="local",
)
# Event shape forwarded to listeners. Typed loosely on purpose — SSE encodes
# it as JSON so consumers read the dict directly.
@@ -99,6 +102,8 @@ def _emit(event: ProgressEvent) -> None:
rid = current_repo_id.get()
if rid is not None and "repo_id" not in event:
event = {**event, "repo_id": rid}
if "target" not in event:
event = {**event, "target": current_target.get()}
with _listener_lock:
listeners = list(_listeners.values())
for cb in listeners:
+11
View File
@@ -0,0 +1,11 @@
"""Distributed worker support — protocol v1 domain core.
Scope note: this package holds the *domain* of the worker protocol the task
lifecycle, deadline policy, capacity derivation, failure attribution, circuit
breaking, worker identity, and worker persistence. It deliberately contains no
network I/O and imports no gRPC: every rule in here is unit-testable without a
socket, and the transport layer (added later) is a thin adapter over it.
The wire contract lives in ``protocol/worker_v1.proto`` and is the only artifact
shared with the future Go control plane. See ``docs/remote-workers.md``.
"""
+291
View File
@@ -0,0 +1,291 @@
"""Worker mode — the other half of the feature.
A worker is the ordinary backend with this agent running alongside it. That is
the whole point of not writing a slim agent: the engines, sidecar venvs, model
downloads, and VRAM budgeting the executor needs are already there.
The interesting problem here is bootstrapping trust. The control plane has a
self-signed certificate, so the worker has nothing to validate it against
except the fingerprint baked into the enrollment token. So on first contact the
worker fetches the certificate the server presents, checks it against that
fingerprint, and only then uses it as the *sole* trusted root for every later
connection. Trust on first use, with the token as the anchor that makes the
"first use" safe.
If the fingerprint does not match, the agent stops. It does not warn and
continue: a mismatch is precisely the attack pinning exists to catch.
"""
from __future__ import annotations
import asyncio
import logging
import os
import ssl
from typing import Optional
logger = logging.getLogger("omnivoice.worker")
# How often the idle-engine sweep runs. Well under the ten-minute idle
# threshold it enforces, so a model is released promptly after it goes cold
# rather than up to a full interval later.
IDLE_SWEEP_INTERVAL_SECONDS = 60.0
def worker_mode_enabled() -> bool:
return (os.environ.get("OMNIVOICE_WORKER_MODE") or "").strip().lower() in (
"1",
"true",
"yes",
"on",
)
def _paths() -> dict[str, str]:
from worker.service import paths # noqa: PLC0415
locations = paths()
locations["pinned_cert"] = os.path.join(locations["root"], "control-plane.pinned.crt")
# The server-assigned id, remembered so a restarted worker can prove who it
# is. The challenge signature binds to this id, so a worker that forgets it
# cannot authenticate with the key it already enrolled.
locations["worker_id"] = os.path.join(locations["root"], "worker-id")
return locations
def load_worker_id(path: str) -> str:
try:
with open(path, encoding="utf-8") as fh:
return fh.read().strip()
except (FileNotFoundError, PermissionError):
return ""
def save_worker_id(path: str, worker_id: str) -> None:
if not worker_id:
return
os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)
with open(path, "w", encoding="utf-8") as fh:
fh.write(worker_id)
def fetch_server_certificate(endpoint: str, *, timeout: float = 10.0) -> bytes:
"""Retrieve the certificate the control plane presents, unvalidated.
Unvalidated on purpose and safe only because the caller immediately checks
it against the token's fingerprint — this is the fetch half of pin-on-first-
use, not a trust decision.
"""
host, _, port = endpoint.rpartition(":")
if not host:
raise ValueError(f"Endpoint must be host:port — got {endpoint!r}")
context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
context.check_hostname = False
context.verify_mode = ssl.CERT_NONE
with ssl.create_connection((host, int(port)), timeout=timeout) as raw:
with context.wrap_socket(raw, server_hostname=host) as tls:
der = tls.getpeercert(binary_form=True)
if not der:
raise ConnectionError("The control plane presented no certificate.")
return ssl.DER_cert_to_PEM_cert(der).encode("ascii")
def pin_certificate(token_text: str, *, cert_path: Optional[str] = None) -> tuple[str, bytes]:
"""Resolve a token into (endpoint, trusted certificate), pinning on first use.
Raises ``ValueError`` when the presented certificate does not match the
token. There is deliberately no override.
"""
from worker.identity import EnrollmentToken # noqa: PLC0415
from worker.transport.client import verify_pin # noqa: PLC0415
token = EnrollmentToken.decode(token_text)
if token.expired():
raise ValueError("This enrollment token has expired. Generate a new one.")
certificate = fetch_server_certificate(token.endpoint)
if not verify_pin(certificate, token.cert_fingerprint):
raise ValueError(
"The control plane's certificate does not match this enrollment token. "
"Stop — this is what the token's fingerprint exists to catch. Generate a "
"fresh token on the control plane and try again."
)
path = cert_path or _paths()["pinned_cert"]
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "wb") as fh:
fh.write(certificate)
return token.endpoint, certificate
class WorkerAgent:
"""Keeps this machine connected to a control plane and running its work."""
def __init__(self) -> None:
self._task: Optional[asyncio.Task] = None
self._idle_sweep: Optional[asyncio.Task] = None
self._client = None
@property
def running(self) -> bool:
return self._task is not None and not self._task.done()
async def start(self, *, token_text: str = "", endpoint: str = "") -> None:
from worker import capabilities # noqa: PLC0415
from worker.executor import TaskExecutor # noqa: PLC0415
from worker.identity import load_or_create_worker_key # noqa: PLC0415
from worker.transport.client import ( # noqa: PLC0415
WorkerClient,
WorkerConfig,
describe_host,
)
if self.running:
return
locations = _paths()
os.makedirs(locations["root"], exist_ok=True)
# Generated once and never transmitted; this is the worker's identity
# for the life of the machine.
keypair = load_or_create_worker_key(locations["worker_key"])
token_text = token_text or (os.environ.get("OMNIVOICE_WORKER_TOKEN") or "").strip()
if token_text:
endpoint, certificate = await asyncio.to_thread(pin_certificate, token_text)
else:
# Already enrolled: reuse the certificate pinned at join time.
try:
with open(locations["pinned_cert"], "rb") as fh:
certificate = fh.read()
except (FileNotFoundError, PermissionError) as exc:
raise RuntimeError(
"This machine has not been enrolled yet. Generate a token on the "
"control plane (Settings → System → Remote workers) and start with "
"OMNIVOICE_WORKER_TOKEN set."
) from exc
endpoint = endpoint or (os.environ.get("OMNIVOICE_WORKER_ENDPOINT") or "").strip()
if not endpoint:
raise RuntimeError(
"Set OMNIVOICE_WORKER_ENDPOINT to the control plane's host:port, or "
"start with a fresh OMNIVOICE_WORKER_TOKEN."
)
# Unavailable engines are reported too, so the control plane can tell
# "this worker has no such engine" from "it has it but the weights
# aren't downloaded" — a row that never arrives can only look like the
# former, and the download-first flow has nothing to offer.
discovered = capabilities.discover(include_unavailable=True)
host = describe_host()
host["gpus"] = capabilities.describe_gpus()
config = WorkerConfig(
endpoint=endpoint,
cert_fingerprint="",
certificate_pem=certificate,
keypair=keypair,
worker_id=load_worker_id(locations["worker_id"]),
enrollment_token=token_text,
max_concurrent_tasks=capabilities.max_concurrent_tasks(discovered),
capabilities=discovered,
host=host,
)
# No reporters here on purpose: the client injects a pair bound to each
# assignment's ref (``execute(assignment, on_progress=…,
# on_model_loading=…)``), which is the only way a multi-slot worker can
# say which task a progress fraction belongs to. Those reports are what
# renews the server's progress lease.
executor = TaskExecutor()
self._client = WorkerClient(
config,
execute=executor.execute,
# Re-probed on every reconnect so a model loaded (or evicted) since
# the last connection is reported honestly rather than from a
# snapshot taken at startup.
capability_probe=lambda: capabilities.discover(include_unavailable=True),
on_registered=lambda wid: save_worker_id(locations["worker_id"], wid),
)
self._task = asyncio.create_task(self._client.run_forever(), name="worker-agent")
self._idle_sweep = asyncio.create_task(
self._unload_idle_engines(), name="worker-idle-unload"
)
logger.info(
"Worker agent connecting to %s with %d engine(s)", endpoint, len(discovered)
)
# ── Idle unloading ────────────────────────────────────────────────────
async def _unload_idle_engines(self) -> None:
"""Hand back engines this worker has not used for ten minutes.
Only in worker mode: a machine lending its GPU is usually not the one
its owner is sitting at, so holding several GB of weights against a
task that may never come is pure cost. Local behaviour is unchanged
nothing sweeps the cache unless this agent is running.
"""
from services import tts_backend # noqa: PLC0415
while True:
await asyncio.sleep(IDLE_SWEEP_INTERVAL_SECONDS)
try:
# This process serves the desktop user as well as remote
# assignments. Local work runs through the shared GPU pool but
# does not enter the worker executor's per-engine guard.
from services import model_manager # noqa: PLC0415
local = model_manager.gpu_pool_stats()
if local.get("running", 0) or local.get("queued", 0):
continue
# unload() frees device caches and reaps sidecars — blocking,
# so it must not run on the loop that answers heartbeats.
released = await asyncio.to_thread(tts_backend.release_idle_engines)
if released and self._client is not None:
await self._client.refresh_capabilities()
except asyncio.CancelledError:
raise
except Exception:
logger.exception("Idle engine sweep failed (the worker continues)")
async def stop(self) -> None:
if self._client is not None:
await self._client.stop()
for attribute in ("_task", "_idle_sweep"):
task = getattr(self, attribute)
if task is not None:
task.cancel()
await asyncio.gather(task, return_exceptions=True)
setattr(self, attribute, None)
self._client = None
agent = WorkerAgent()
async def start_if_worker_mode() -> None:
"""Called from the app lifespan on the worker machine.
Never fatal: a machine that cannot reach its control plane is still a
perfectly good OmniVoice install for the person sitting at it.
"""
if not worker_mode_enabled():
return
try:
await agent.start()
except Exception:
logger.exception("Worker agent failed to start (the app continues normally)")
async def stop() -> None:
try:
await agent.stop()
except Exception:
logger.exception("Worker agent failed to stop cleanly")
__all__ = [
"WorkerAgent",
"agent",
"fetch_server_certificate",
"pin_certificate",
"start_if_worker_mode",
"stop",
"worker_mode_enabled",
]
+300
View File
@@ -0,0 +1,300 @@
"""Circuit breaking and failure attribution.
This replaces the goal doc's reliability-score / penalty-decay / quarantine /
probation machinery, which the council found actively harmful at the scale this
feature actually ships at (one user, one to three of their own machines):
* There is no "test/low-risk workload" in a TTS product, so a quarantined
worker had no defined path back quarantine was effectively permanent.
* "Connection failure → larger penalty" is backwards on the networks this is
designed for. Home Wi-Fi drops. Penalising that quarantines every consumer
worker within a day.
* A demoted worker receives less work, so it produces fewer samples, so its
score stays low. The recovery path starves itself.
A breaker has none of those failure modes because it does not accumulate an
opinion it counts *consecutive* failures, and one success clears it. It is
also explainable in the UI, which a tuned score never is: "paused after 3
failures, retrying in 60s" versus "reliability 62%".
Two things make it safe:
**Attribution before penalty.** Most failures are not the worker's fault. A
worker declining work because it is full is doing its job. A 4 GB card refusing
a 6 GB engine is a capability mismatch. A network partition that takes out the
whole fleet is an infrastructure event. None of these open a breaker.
**Per (worker, model).** A model that OOMs on an M2 must not stop that machine
from serving the engines it handles fine.
"""
from __future__ import annotations
import enum
import time
from dataclasses import dataclass, field
from typing import Optional
from worker.clock import resolve
from worker.errors import ErrorClass, WorkerError
# Consecutive charged failures before the breaker opens.
_FAILURE_THRESHOLD = 3
# Cooldown before a probe is allowed, and the ceiling for repeated trips.
# Escalating cooldown means a genuinely broken worker stops being retried every
# minute, while a one-off blip costs a minute of availability.
_BASE_COOLDOWN_SECONDS = 60.0
_MAX_COOLDOWN_SECONDS = 30 * 60.0
# Successes required in HALF_OPEN before the breaker closes.
_PROBE_SUCCESSES = 1
# Fleet-wide failure detection: if this fraction of known workers fails inside
# the window, it is an infrastructure event, not a fleet of bad GPUs. Charging
# them all is how one network blip quarantines everything and the ensuing retry
# wave overloads whatever survived.
_MASS_FAILURE_FRACTION = 0.5
_MASS_FAILURE_WINDOW_SECONDS = 60.0
_MASS_FAILURE_MIN_WORKERS = 3
class Attribution(str, enum.Enum):
"""Who is responsible for a failure."""
# Counts against the worker.
WORKER = "worker"
# Real failure, nobody's fault locally — do not charge.
NEUTRAL = "neutral"
# Fleet-wide event. Suppress penalties entirely.
INFRA = "infra"
class BreakerState(str, enum.Enum):
CLOSED = "closed"
OPEN = "open"
HALF_OPEN = "half_open"
def attribute(error: WorkerError, *, mass_failure: bool = False) -> Attribution:
"""Decide whether a failure is chargeable.
Neutral by construction: capacity rejections, capability mismatches,
protocol/auth problems, user cancellation, and anything happening during a
detected fleet-wide event.
"""
if mass_failure:
return Attribution.INFRA
if error.error_class in (ErrorClass.CAPACITY, ErrorClass.CAPABILITY):
return Attribution.NEUTRAL
if error.error_class is ErrorClass.PROTOCOL:
return Attribution.NEUTRAL
if error.code in _NEUTRAL_CODES:
return Attribution.NEUTRAL
if not error.charges_worker:
return Attribution.NEUTRAL
return Attribution.WORKER
# Codes that describe something other than a misbehaving worker even though
# their class would otherwise be chargeable.
_NEUTRAL_CODES = frozenset(
{
"CANCELLED", # the user changed their mind
"WORKER_DISCONNECTED", # unknown outcome, not a failure
"SERVER_RESTART", # our fault
"TASK_DEADLINE_EXCEEDED", # the task waited too long, often in queue
"WORKER_DRAINING", # planned maintenance
}
)
@dataclass
class Breaker:
"""Breaker for one (worker, model) pair."""
worker_id: str
model_key: str
state: BreakerState = BreakerState.CLOSED
consecutive_failures: int = 0
trips: int = 0
opened_at: Optional[float] = None
retry_at: Optional[float] = None
probe_successes: int = 0
last_error: Optional[WorkerError] = None
def allows(self, *, now: Optional[float] = None) -> bool:
"""May the scheduler send work through this breaker right now?"""
stamp = resolve(now)
if self.state is BreakerState.CLOSED:
return True
if self.state is BreakerState.OPEN:
if self.retry_at is not None and stamp >= self.retry_at:
# Cooldown elapsed — allow exactly one probe through.
self.state = BreakerState.HALF_OPEN
self.probe_successes = 0
return True
return False
# HALF_OPEN: one probe at a time.
return True
def record_success(self, *, now: Optional[float] = None) -> None:
self.consecutive_failures = 0
self.last_error = None
if self.state is BreakerState.HALF_OPEN:
self.probe_successes += 1
if self.probe_successes >= _PROBE_SUCCESSES:
self._close()
elif self.state is BreakerState.OPEN:
# A result arriving from an open breaker (a straggler committing
# after the trip) is still proof the worker works.
self._close()
def record_failure(
self, error: WorkerError, *, attribution: Attribution, now: Optional[float] = None
) -> bool:
"""Record a failure. Returns True if the breaker opened as a result."""
if attribution is not Attribution.WORKER:
return False
stamp = resolve(now)
self.last_error = error
if self.state is BreakerState.HALF_OPEN:
# The probe failed — straight back to open, with a longer cooldown.
self._open(stamp)
return True
self.consecutive_failures += 1
if self.consecutive_failures >= _FAILURE_THRESHOLD:
self._open(stamp)
return True
return False
def force_close(self) -> None:
"""Operator override — the user fixed the machine and knows it."""
self._close()
self.trips = 0
def _open(self, now: float) -> None:
self.state = BreakerState.OPEN
self.trips += 1
self.opened_at = now
cooldown = min(_MAX_COOLDOWN_SECONDS, _BASE_COOLDOWN_SECONDS * (2 ** (self.trips - 1)))
self.retry_at = now + cooldown
self.consecutive_failures = 0
self.probe_successes = 0
def _close(self) -> None:
self.state = BreakerState.CLOSED
self.consecutive_failures = 0
self.probe_successes = 0
self.opened_at = None
self.retry_at = None
def describe(self, *, now: Optional[float] = None) -> str:
"""One line for the UI. A breaker the user cannot understand is worse
than no breaker at all."""
if self.state is BreakerState.CLOSED:
return "OK"
if self.state is BreakerState.HALF_OPEN:
return "Testing recovery with the next task"
remaining = max(0, int((self.retry_at or 0) - resolve(now)))
reason = self.last_error.message if self.last_error else "repeated failures"
return f"Paused after {_FAILURE_THRESHOLD} failures ({reason}) — retrying in {remaining}s"
def to_dict(self, *, now: Optional[float] = None) -> dict:
return {
"worker_id": self.worker_id,
"model_key": self.model_key,
"state": self.state.value,
"trips": self.trips,
"retry_at": self.retry_at,
"summary": self.describe(now=now),
}
class BreakerRegistry:
"""All breakers for all workers, plus fleet-wide event detection.
Session-scoped by design: OSS control planes restart constantly (the app
quits), and carrying a cooldown across a restart would mean a user who
restarts to fix a problem still cannot use their GPU. The hosted control
plane persists this instead.
"""
def __init__(self) -> None:
self._breakers: dict[tuple[str, str], Breaker] = {}
self._recent_failures: list[tuple[float, str]] = []
self._known_workers: set[str] = set()
def note_worker(self, worker_id: str) -> None:
self._known_workers.add(worker_id)
def forget_worker(self, worker_id: str) -> None:
self._known_workers.discard(worker_id)
for key in [k for k in self._breakers if k[0] == worker_id]:
self._breakers.pop(key, None)
def get(self, worker_id: str, model_key: str) -> Breaker:
key = (worker_id, model_key)
breaker = self._breakers.get(key)
if breaker is None:
breaker = Breaker(worker_id=worker_id, model_key=model_key)
self._breakers[key] = breaker
return breaker
def allows(self, worker_id: str, model_key: str, *, now: Optional[float] = None) -> bool:
return self.get(worker_id, model_key).allows(now=now)
def record_success(self, worker_id: str, model_key: str, *, now: Optional[float] = None) -> None:
self.get(worker_id, model_key).record_success(now=now)
def record_failure(
self,
worker_id: str,
model_key: str,
error: WorkerError,
*,
now: Optional[float] = None,
) -> tuple[Attribution, bool]:
"""Attribute and record one failure.
Returns ``(attribution, opened)``.
"""
stamp = resolve(now)
self._record_recent(worker_id, stamp)
mass = self._mass_failure(now=stamp)
attribution = attribute(error, mass_failure=mass)
opened = self.get(worker_id, model_key).record_failure(
error, attribution=attribution, now=stamp
)
return attribution, opened
def open_breakers(self, worker_id: str, *, now: Optional[float] = None) -> list[Breaker]:
return [
b
for (wid, _), b in self._breakers.items()
if wid == worker_id and not b.allows(now=now)
]
def _record_recent(self, worker_id: str, now: float) -> None:
cutoff = now - _MASS_FAILURE_WINDOW_SECONDS
self._recent_failures = [(t, w) for t, w in self._recent_failures if t >= cutoff]
self._recent_failures.append((now, worker_id))
def _mass_failure(self, *, now: float) -> bool:
"""Are we watching an infrastructure event rather than bad workers?"""
if len(self._known_workers) < _MASS_FAILURE_MIN_WORKERS:
return False
cutoff = now - _MASS_FAILURE_WINDOW_SECONDS
failing = {w for t, w in self._recent_failures if t >= cutoff}
return len(failing) / max(1, len(self._known_workers)) >= _MASS_FAILURE_FRACTION
def snapshot(self, *, now: Optional[float] = None) -> list[dict]:
return [b.to_dict(now=now) for b in self._breakers.values()]
__all__ = [
"Attribution",
"Breaker",
"BreakerRegistry",
"BreakerState",
"attribute",
]
+301
View File
@@ -0,0 +1,301 @@
"""Capability discovery on a worker.
What the scheduler needs is not "which engines exist" but four separate facts
per model, because they have wildly different consequences:
* **supported** this engine could run on this host at all
* **installed** its sidecar venv is actually present
* **downloaded** its weights are on disk (otherwise the first task pays a
download, which can be twenty minutes)
* **resident** it is loaded in VRAM right now, which is the difference
between eight seconds and several minutes
Collapsing those into one boolean is how a scheduler sends a task to a worker
that then spends a quarter of an hour fetching a model, blows its deadline, and
gets penalised for it.
Everything here is derived from what the app already knows
``tts_backend.list_backends()`` and ``device_caps`` rather than a second,
divergent notion of what a worker can do.
"""
from __future__ import annotations
import logging
from typing import Optional
logger = logging.getLogger("omnivoice.worker")
# gpu_compat families that mean "this would run on the CPU here", which is
# supported but emphatically not accelerated.
_CPU_ONLY = {"cpu"}
def _free_memory_bytes(caps) -> int:
vram_gb = float(getattr(caps, "vram_gb", 0) or 0)
return int(vram_gb * 1024**3)
def discover(*, include_unavailable: bool = False) -> list[dict]:
"""Enumerate this host's TTS capabilities in protocol shape.
Never raises: a worker that cannot introspect one engine must still report
the others, exactly as ``list_backends`` guarantees locally.
"""
try:
from core.device_caps import detect_host_caps # noqa: PLC0415
from services import tts_backend # noqa: PLC0415
except Exception:
logger.exception("Capability discovery failed to import the engine layer")
return []
try:
caps = detect_host_caps()
except Exception:
logger.exception("Host capability probe failed")
caps = None
try:
backends = tts_backend.list_backends()
except Exception:
logger.exception("Engine enumeration failed")
return []
free_bytes = _free_memory_bytes(caps) if caps is not None else 0
family = getattr(caps, "family", "") if caps is not None else ""
resident = _resident_engine_ids()
discovered: list[dict] = []
for entry in backends:
available = bool(entry.get("available"))
if not available and not include_unavailable:
continue
engine_id = entry.get("id") or ""
routing = entry.get("routing_status") or ""
gpu_compat = set(entry.get("gpu_compat") or [])
repo_ids = repo_ids_for(entry)
downloaded = _downloaded(repo_ids)
discovered.append(
{
"engine": engine_id,
"model_id": model_id_for(entry),
# The human label, kept OUT of model_id. Free to change with
# any UI copy edit; nothing keys off it.
"display_name": entry.get("display_name") or engine_id,
"operations": _operations_for(entry),
"supported": routing != "unavailable",
# A subprocess engine is only usable once its venv exists, and
# `available` already reflects that probe.
"installed": available,
# `available` implies the engine can start; weights are fetched
# on first use, which the load-phase deadline covers.
"downloaded": downloaded,
# Empty means this engine is not installable through the HF
# catalog. It remains runnable when installed: sidecars and
# user-managed engines legitimately install another way.
"repo_ids": repo_ids,
"resident": engine_id in resident,
"min_memory_bytes": int(float(entry.get("min_vram_gb") or 0) * 1024**3),
"precision": "",
"backend": entry.get("effective_device") or family,
"free_memory_bytes": free_bytes,
# Capability is not acceleration: an engine present but routed
# to the CPU here should not be preferred for GPU work.
"cpu_fallback": routing in ("cpu_fallback", "cpu_only")
or (gpu_compat and gpu_compat <= _CPU_ONLY),
}
)
return discovered
def repo_ids_for(entry: dict) -> list[str]:
"""Catalog repositories used by one engine; an empty answer is unknown.
Unknown deliberately stays unknown. User-managed clones and engines whose
loaders do not expose a repository must keep working (fail-open).
"""
engine_id = entry.get("id") or ""
if engine_id == "omnivoice":
return ["k2-fsa/OmniVoice"]
fixed_repos = {
"voxcpm2": "openbmb/VoxCPM2",
"cosyvoice": "FunAudioLLM/Fun-CosyVoice3-0.5B-2512",
"gpt-sovits": "lj1995/GPT-SoVITS",
}
if engine_id in fixed_repos:
return [fixed_repos[engine_id]]
if engine_id == "mlx-audio":
active = entry.get("active_model_id") or "kokoro"
for model in entry.get("curated_models") or []:
if model.get("key") == active and model.get("repo_id"):
return [model["repo_id"]]
try:
from services.sidecar_install import _user_managed_dir, get_spec # noqa: PLC0415
spec = get_spec(engine_id)
if spec is not None and spec.weights_repo_id:
# A user-managed clone is intentionally opaque: its weights may be
# valid outside both the managed checkout and HF cache layout.
if _user_managed_dir(spec) is not None:
return []
return [spec.weights_repo_id]
except Exception:
logger.debug("Sidecar repository probe failed for %s", engine_id, exc_info=True)
return []
def _downloaded(repo_ids: list[str]) -> bool:
"""Positive absence only: uncertainty and non-HF installs proceed."""
if not repo_ids:
return True
try:
from api.routers.setup.models import ( # noqa: PLC0415
KNOWN_MODELS,
cache_is_complete,
is_cached,
)
by_id = {m.get("repo_id"): m for m in KNOWN_MODELS}
for repo_id in repo_ids:
try:
from services.sidecar_install import SPECS, _weights_present # noqa: PLC0415
managed = next((s for s in SPECS.values() if s.weights_repo_id == repo_id), None)
if managed is not None:
if not _weights_present(managed):
return False
continue
except Exception:
# Cannot prove sidecar absence; compatibility wins.
return True
if not is_cached(repo_id):
return False
meta = by_id.get(repo_id, {"repo_id": repo_id})
if not cache_is_complete(meta):
return False
return True
except Exception:
logger.debug("Model cache probe was inconclusive", exc_info=True)
return True
def model_id_for(entry: dict) -> str:
"""The stable, opaque, engine-scoped identifier for a backend's model.
``<engine_id>:<model_key>`` ``indextts:default``, ``mlx-audio:kokoro``.
Three things it deliberately is not:
* **Not a display name.** It was one, and it keys circuit breakers,
per-model slots and residency (``capacity.py``) and is persisted in
``capabilities_json`` and on the task row. A UI copy edit renaming
"IndexTTS 2" would have orphaned that history.
* **Not a HuggingFace repo id or any path.** The wire carries ``engine``
plus this closed identifier and never a repo path, so the worker
resolves weights from its own catalog and there is nothing to validate
on arrival.
* **Not engine-global.** The engine prefix keeps it unique fleet-wide,
so a breaker or slot keyed on ``model_id`` alone cannot collide across
two engines that both call their model "base".
``default`` covers the one-model-per-engine case. mlx-audio multiplexes
curated models behind one id (#981) and ``list_backends`` already reports
which one is configured, so its key rides here a different curated model
genuinely is a different model to schedule and to keep resident.
"""
engine_id = entry.get("id") or ""
model_key = entry.get("active_model_id") or "default"
return f"{engine_id}:{model_key}"
def _operations_for(entry: dict) -> list[str]:
"""Which task kinds this engine can serve.
Cloning is the one genuine split an engine that cannot clone must never
be handed a clone task, and ``supports_cloning`` is ``None`` when the
answer depends on the loaded model, which we treat as "no" rather than
risk a task that fails at the last moment.
"""
# Audiobook chapters use the same TTS engine, but are advertised as their
# own schedulable operation so an older worker cannot accept a task whose
# chapter assembler it does not implement.
operations = ["audiobook", "dub_segments", "tts"]
if entry.get("supports_cloning") is True:
operations.append("clone")
return operations
def _resident_engine_ids() -> set[str]:
"""Which engines are loaded right now.
Best effort by design: residency changes underneath us (idle unloading is
normal), so this is a hint for scheduling, never a guarantee. The worker's
own accept/reject remains authoritative.
"""
try:
from services import model_manager # noqa: PLC0415
except Exception:
return set()
for attribute in ("resident_engine_ids", "loaded_engine_ids"):
probe = getattr(model_manager, attribute, None)
if callable(probe):
try:
return set(probe() or ())
except Exception:
logger.debug("Residency probe %s failed", attribute, exc_info=True)
# Fall back to the module's cached active engine, if it exposes one.
active = getattr(model_manager, "_ACTIVE_TTS_ID", None)
return {active} if isinstance(active, str) and active else set()
def describe_gpus() -> list[dict]:
"""This host's accelerators, for the worker list in the UI."""
try:
from core.device_caps import detect_host_caps # noqa: PLC0415
caps = detect_host_caps()
except Exception:
return []
if caps is None:
return []
family = getattr(caps, "family", "") or ""
return [
{
"vendor": _vendor_for(family),
"model": getattr(caps, "device_name", "") or "",
"backend": family,
"memory_bytes": _free_memory_bytes(caps),
"free_memory_bytes": _free_memory_bytes(caps),
"driver_version": getattr(caps, "driver", "") or "",
}
]
def _vendor_for(family: str) -> str:
return {
"cuda": "nvidia",
"rocm": "amd",
"mps": "apple",
"mlx": "apple",
"xpu": "intel",
}.get(family, "")
def max_concurrent_tasks(capabilities: Optional[list[dict]] = None) -> int:
"""How many tasks this worker will accept at once.
Defaults to one, matching the local GPU queue's deliberate single lane —
the serialisation that exists because concurrent jobs OOM'd VRAM and hit
posix_spawn EAGAIN on macOS. A worker may advertise more only when every
capability it reports independently derived more.
"""
caps = capabilities if capabilities is not None else discover()
if not caps:
return 1
derived = [int(c.get("derived_concurrency") or 0) for c in caps]
positive = [d for d in derived if d > 0]
return min(positive) if positive else 1
__all__ = [
"describe_gpus", "discover", "max_concurrent_tasks", "model_id_for", "repo_ids_for"
]
+308
View File
@@ -0,0 +1,308 @@
"""Worker capacity — derived, never declared.
The original goal doc let a user configure "Whisper: concurrency = 4, large TTS
model: concurrency = 1". This repo's own history says that is unsafe:
* compiled inference is pinned to a single thread because torch.compile's
cudagraph state is thread-local (#315) — a second concurrent job on a
compiled model produces *silently corrupted audio*, with no exception to
catch and nothing for a reliability score to detect;
* two concurrent clone jobs on an 8 GB card produced a sticky CUDA
illegal-memory-access that aborted the whole process (#567), which is why
``gpu_queue`` is a deliberately serial single lane;
* Apple's unified memory means "GPU memory" is shared with everything else
the user is running, so a number that was safe at configuration time is not
safe at execution time.
So capacity is computed from what the machine has *right now*, clamped by
device family, and the worker's own accept/reject is authoritative — the
scheduler's view is only ever advisory.
One more rule the repo learned the hard way: a timed-out GPU job cannot be
killed. The thread keeps the device until it finishes on its own
(``_ResilientGpuPool.reset()`` reclaims nothing). So a timeout must NOT return
the slot; the slot stays occupied by a zombie until the worker confirms the
thread exited. Returning it early is how a worker gets overcommitted into an
OOM.
But a park with no way out is just as wrong as no park at all: a worker whose
only slot is parked never gets another assignment, so it never produces the
confirmation that would release it, and it heartbeats "idle, 1 free" forever
while the scheduler considers it full. Two bounded exits, neither of which
trusts the worker's own accounting (which counts asyncio tasks, not GPU
threads, and so reports a parked slot as free the moment the task object is
gone):
* a **TTL** sized from the budget the stuck job was given past that, its
thread is either finished or wedged beyond anything we can wait out;
* **reconciliation** against the worker's reported ceiling — if the worker
is running as many tasks as it says it can hold, the park is protecting
nothing, because the overcommit it exists to prevent has already happened.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from typing import Optional
from worker.clock import resolve
logger = logging.getLogger("omnivoice.worker")
# Per-job VRAM budget. Mirrors the figure model_manager uses when sizing its
# GPU worker pool; deliberately conservative because exceeding it does not
# degrade gracefully, it aborts the process.
_VRAM_PER_JOB_BYTES = 5 * 1024**3
# Device families where concurrency above 1 is never derived:
# mps/mlx — unified memory, shared with the user's other apps
# cpu — oversubscription just thrashes
_ALWAYS_SERIAL = frozenset({"mps", "mlx", "cpu", ""})
# Absolute ceiling regardless of how much memory a card reports. Beyond this
# the bottleneck stops being VRAM and starts being scheduler overhead and
# host-side I/O contention.
_MAX_DERIVED = 4
# Bounds on how long a parked slot is held. The caller passes the timed-out
# job's own execution budget — the longest its thread can still legitimately be
# running — and these clamp a nonsensical one: never so short that the park
# stops meaning anything, never so long that a single timeout costs the user a
# worker for the rest of the session.
_MIN_ZOMBIE_TTL_SECONDS = 60.0
_MAX_ZOMBIE_TTL_SECONDS = 3600.0
_DEFAULT_ZOMBIE_TTL_SECONDS = 600.0
def _bounded_ttl(seconds: Optional[float]) -> float:
if not seconds or seconds <= 0:
return _DEFAULT_ZOMBIE_TTL_SECONDS
return min(_MAX_ZOMBIE_TTL_SECONDS, max(_MIN_ZOMBIE_TTL_SECONDS, float(seconds)))
def derive_concurrency(
*,
backend: str,
free_memory_bytes: int,
min_model_bytes: int = 0,
compiled: bool = False,
) -> int:
"""How many jobs of this model may run at once on this worker.
Returns 0 when the model cannot run here at all a capability mismatch,
which the scheduler must treat as "send it elsewhere", never as a worker
fault.
"""
family = (backend or "").strip().lower()
if min_model_bytes and free_memory_bytes < min_model_bytes:
return 0
if compiled:
# Thread-affinity pinning (#315). One job, always.
return 1
if family in _ALWAYS_SERIAL:
return 1 if (not min_model_bytes or free_memory_bytes >= min_model_bytes) else 0
budget = max(min_model_bytes, _VRAM_PER_JOB_BYTES)
if budget <= 0:
return 1
return max(1, min(_MAX_DERIVED, int(free_memory_bytes // budget)))
@dataclass
class ModelSlot:
"""Capacity bookkeeping for one (worker, model) pair."""
engine: str
model_id: str
derived_concurrency: int = 1
active: int = 0
# Slots held by jobs that timed out but whose GPU thread has not exited.
# Not available, not counted as active work, not returnable until the
# worker says the thread is gone or the park times out. Stored as the
# absolute reclaim time of each park rather than a bare count: a count has
# no way to answer "has this one waited long enough", and a count kept
# beside a list of deadlines is two records of one fact that drift.
zombie_expiries: list[float] = field(default_factory=list)
@property
def zombie(self) -> int:
return len(self.zombie_expiries)
@property
def available(self) -> int:
return max(0, self.derived_concurrency - self.active - self.zombie)
@dataclass
class WorkerCapacity:
"""Live capacity snapshot for one worker.
Absolute values only never deltas. Per-stream FIFO does not survive a
reconnect, so a delta that arrives out of order corrupts the count
permanently.
"""
worker_id: str
max_concurrent_tasks: int = 1
active_tasks: int = 0
free_memory_bytes: int = 0
backend: str = ""
resident_models: set[str] = field(default_factory=set)
slots: dict[str, ModelSlot] = field(default_factory=dict)
@staticmethod
def slot_key(engine: str, model_id: str) -> str:
return f"{engine}:{model_id}"
@property
def zombie_tasks(self) -> int:
"""Derived from the slots, never counted separately: a worker-wide
counter maintained alongside per-slot ones is how a double release used
to invent a zombie that no slot owned and nothing could ever reap."""
return sum(slot.zombie for slot in self.slots.values())
@property
def available_slots(self) -> int:
"""Worker-wide availability. The binding constraint is whichever of
the worker-wide and per-model caps is smaller they are not
independent, because every model draws on the same VRAM."""
return max(0, self.max_concurrent_tasks - self.active_tasks - self.zombie_tasks)
def slot_for(self, engine: str, model_id: str) -> Optional[ModelSlot]:
exact = self.slots.get(self.slot_key(engine, model_id))
if exact is not None or not model_id:
return exact
# Protocol-v1 workers may advertise only an engine (model_id="").
# That row is an engine-wide wildcard, not a second pool of capacity.
return self.slots.get(self.slot_key(engine, ""))
def can_accept(self, engine: str, model_id: str) -> bool:
if self.available_slots <= 0:
return False
slot = self.slot_for(engine, model_id)
if slot is None:
# Unknown model on a worker with room: the worker decides. Its
# reject is authoritative and penalty-free.
return True
return slot.available > 0
def is_resident(self, engine: str, model_id: str) -> bool:
"""Warm models are the dominant latency term — 8s versus minutes."""
return self.slot_key(engine, model_id) in self.resident_models or model_id in self.resident_models
def reserve(self, engine: str, model_id: str) -> None:
self.active_tasks += 1
slot = self.slot_for(engine, model_id)
if slot is None:
slot = self.slots.setdefault(
self.slot_key(engine, model_id), ModelSlot(engine=engine, model_id=model_id)
)
slot.active += 1
def release(
self,
engine: str,
model_id: str,
*,
zombie: bool = False,
zombie_ttl_seconds: Optional[float] = None,
now: Optional[float] = None,
) -> bool:
"""Return a slot. ``zombie=True`` parks it instead: the task is over
but the GPU thread is not, so the capacity is still gone.
Returns whether a slot was actually returned. A release for a slot that
holds nothing is a bug in the caller (a double release), and answering
it with a decrement would invent worker-wide capacity out of nothing
so it is refused and reported rather than absorbed.
"""
slot = self.slot_for(engine, model_id)
if slot is None or slot.active <= 0:
return False
slot.active -= 1
if self.active_tasks > 0:
self.active_tasks -= 1
if zombie:
slot.zombie_expiries.append(resolve(now) + _bounded_ttl(zombie_ttl_seconds))
return True
def reap_zombie(self, engine: str, model_id: str) -> None:
"""The worker confirmed the stuck thread exited. Capacity returns."""
slot = self.slot_for(engine, model_id)
if slot is not None and slot.zombie_expiries:
# Oldest first: parks are indistinguishable, so releasing the one
# that has waited longest is the only ordering that cannot starve.
slot.zombie_expiries.pop(0)
def expire_zombies(self, *, now: Optional[float] = None) -> int:
"""Reclaim parks whose TTL ran out. Returns how many came back.
The backstop that keeps a timeout from costing a worker permanently:
the confirmation ``reap_zombie`` waits for is a message the current
protocol has no way to send, so without this the only exit is a
reconnect.
"""
stamp = resolve(now)
reaped = 0
for slot in self.slots.values():
keep = [t for t in slot.zombie_expiries if t > stamp]
reaped += len(slot.zombie_expiries) - len(keep)
slot.zombie_expiries = keep
if reaped:
logger.info(
"Reclaimed %d parked slot(s) on worker %s — the stuck thread's own "
"budget has run out",
reaped,
self.worker_id,
)
return reaped
def apply_snapshot(
self,
*,
active_tasks: int,
available_slots: int,
resident_models: Optional[set[str]] = None,
free_memory_bytes: Optional[int] = None,
now: Optional[float] = None,
) -> None:
"""Adopt a heartbeat snapshot. The worker is the source of truth for
what it is actually running."""
self.active_tasks = max(0, active_tasks)
reported_ceiling = self.active_tasks + max(0, available_slots)
if reported_ceiling > 0:
# Adopted, not merely grown. The worker computes this as its own
# ``max_concurrent_tasks``, so a ceiling we refuse to lower is one
# we keep dispatching against after the worker has told us it can
# no longer honour it — the overcommit this module exists to avoid.
self.max_concurrent_tasks = reported_ceiling
if resident_models is not None:
self.resident_models = set(resident_models)
if free_memory_bytes is not None:
self.free_memory_bytes = free_memory_bytes
# Parks are released on a timer, and by the worker restarting — never
# by the worker's own load report.
#
# Reconciling them against ``active_tasks`` looks reasonable and is
# exactly backwards: a park exists because a timed-out GPU thread
# cannot be killed and the worker therefore cannot account for it. At
# ``max_concurrent_tasks == 1`` the only task such a worker can report
# IS the wedged one, so "busy" would drop the park and the next idle
# heartbeat would hand the slot out with the thread still running —
# the overcommit-into-OOM this module exists to prevent (#730/#1190).
# The two safe signals are already covered: ``expire_zombies`` above
# bounds the park by TTL, and a reconnect builds a fresh capacity
# record (pool.py), because a restarted process has no live threads.
self.expire_zombies(now=now)
def to_dict(self) -> dict:
return {
"worker_id": self.worker_id,
"max_concurrent_tasks": self.max_concurrent_tasks,
"active_tasks": self.active_tasks,
"zombie_tasks": self.zombie_tasks,
"available_slots": self.available_slots,
"resident_models": sorted(self.resident_models),
}
__all__ = ["ModelSlot", "WorkerCapacity", "derive_concurrency"]
+22
View File
@@ -0,0 +1,22 @@
"""Injectable clock.
Every deadline, lease, grace window, and cooldown in this package takes an
optional ``now``. The obvious spelling ``now or time.time()`` is a trap:
``0.0`` is falsy, so a caller that pins time at the epoch silently gets the
wall clock instead. That makes tests lie (they pass while measuring real time)
and would make any future replay or simulation harness quietly wrong.
One helper, used everywhere, so the mistake cannot recur.
"""
from __future__ import annotations
import time
from typing import Optional
def resolve(now: Optional[float] = None) -> float:
"""Return ``now`` when supplied — including ``0.0`` — else the wall clock."""
return time.time() if now is None else float(now)
__all__ = ["resolve"]
+192
View File
@@ -0,0 +1,192 @@
"""Deadline policy for remote tasks.
The original goal doc proposed 2s to accept, 30s to execute, 35s to deliver.
Every one of those numbers is wrong by one to two orders of magnitude for this
product: ``model_manager`` budgets 300s of *execution* scaled by input length,
allows a cold load up to 1800s beyond that, and takes 30s just to spawn an
engine sidecar. A dub runs for minutes; an audiobook for hours. Fixed
second-scale deadlines would mass-kill healthy work.
Two ideas replace them, and both already exist locally this module only
carries them across the wire:
1. **Phases, not one clock.** Accepting is fast (seconds). Loading a cold model
is slow (minutes) and gets its own bounded budget. Executing is scaled to
the input. Delivering a result is a transfer, not compute.
2. **Progress leases, not wall clocks.** ``model_manager`` already treats a job
as wedged only when it is *silent*, not when it is slow
(``MODEL_LOAD_HEARTBEAT_GRACE_S``). The same rule governs remote attempts: a
worker that keeps reporting progress keeps its lease.
All values are **relative durations computed by the server**. Worker wall
clocks are untrusted they skew, and a laptop that slept has a clock that
jumped.
"""
from __future__ import annotations
import enum
import os
from dataclasses import dataclass
from typing import Optional
# Mirrors of model_manager's env knobs. Duplicated as *fallbacks* only: the
# real values are read from model_manager when it is importable (the control
# plane may run in a process that never loads torch). test_worker_deadlines.py
# asserts the two agree, so a change there cannot silently drift from here.
_GENERATE_TIMEOUT_S = float(os.environ.get("OMNIVOICE_GENERATE_TIMEOUT_S", "300.0"))
_MODEL_LOAD_EXTRA_S = float(os.environ.get("OMNIVOICE_MODEL_LOAD_TIMEOUT_S", "1800.0"))
_HEARTBEAT_GRACE_S = float(os.environ.get("OMNIVOICE_MODEL_LOAD_HEARTBEAT_GRACE_S", "30.0"))
# Free character allowance before the execution budget starts scaling, and the
# characters-per-second it scales at. Mirrors model_manager.generate_timeout_s.
_FREE_CHARS = 1200
_CHARS_PER_SECOND = 40.0
# How long a worker has to say "yes" to an assignment. Generous next to the
# original 2s because a busy worker may be mid-inference with the GIL held, but
# still short: silence here means the assignment fell into a void.
_ACCEPT_SECONDS = 20
# Time to push a finished artifact. Consumer uplinks are slow and dub outputs
# are large, so this is sized for "a few hundred MB on a bad connection".
_RESULT_DELIVERY_SECONDS = 900
# How long to wait for a vanished worker before giving up on its attempt. This
# is the window that makes duplicate execution avoidable: reconnect inside it
# with a finished result and the result simply commits.
_DEFAULT_GRACE_SECONDS = 45
class Operation(str, enum.Enum):
"""Deadline classes. Inference duration varies by orders of magnitude
across these, so one timeout scheme cannot serve them all."""
DICTATION = "dictation"
TTS = "tts"
CLONE = "clone"
ASR = "asr"
DUB = "dub"
AUDIOBOOK = "audiobook"
@classmethod
def coerce(cls, value: str) -> "Operation":
try:
return cls(value)
except ValueError:
return cls.TTS
# Per-operation multipliers over the base execution budget, plus the grace
# window used when a worker running that operation disconnects. Long jobs get a
# longer grace: losing a 40-minute dub to a 45-second network blip and redoing
# it from zero is the expensive mistake.
_PROFILE: dict[Operation, tuple[float, int]] = {
Operation.DICTATION: (0.5, 75),
Operation.ASR: (2.0, 75),
Operation.TTS: (1.0, 75),
Operation.CLONE: (2.0, 75),
Operation.DUB: (12.0, 90),
Operation.AUDIOBOOK: (24.0, 90),
}
@dataclass(frozen=True)
class Deadlines:
"""Relative budgets for one attempt, in seconds."""
accept_seconds: int
model_load_seconds: int
execution_seconds: int
progress_lease_seconds: int
result_delivery_seconds: int
grace_seconds: int
@property
def total_seconds(self) -> int:
"""Worst-case wall time for a single attempt that never stalls."""
return (
self.accept_seconds
+ self.model_load_seconds
+ self.execution_seconds
+ self.result_delivery_seconds
)
def to_dict(self) -> dict:
return {
"accept_seconds": self.accept_seconds,
"model_load_seconds": self.model_load_seconds,
"execution_seconds": self.execution_seconds,
"progress_lease_seconds": self.progress_lease_seconds,
"result_delivery_seconds": self.result_delivery_seconds,
"grace_seconds": self.grace_seconds,
}
def _base_execution_seconds(text: Optional[str]) -> float:
"""Delegate to model_manager's budget; fall back to its formula.
The lazy import keeps this module usable in a process that has no torch
the control plane schedules work it never executes.
"""
try:
from services import model_manager # noqa: PLC0415 — intentionally lazy
return float(model_manager.generate_timeout_s(text))
except Exception:
return max(
_GENERATE_TIMEOUT_S,
_GENERATE_TIMEOUT_S + max(0, len(text or "") - _FREE_CHARS) / _CHARS_PER_SECOND,
)
def for_task(
operation: str,
*,
text: Optional[str] = None,
model_resident: bool = False,
model_downloaded: bool = True,
input_seconds: float = 0.0,
) -> Deadlines:
"""Compute the deadlines for one attempt.
``model_resident`` and ``model_downloaded`` matter enormously: a warm model
is ~8s away, a cold one minutes, and one that still has to be downloaded
can take twenty. Charging a worker the same load budget in all three cases
is what turns a normal cold start into a quarantine.
"""
op = Operation.coerce(operation)
multiplier, grace = _PROFILE[op]
execution = _base_execution_seconds(text) * multiplier
# Media-length operations scale on duration, not characters.
if input_seconds > 0:
execution = max(execution, input_seconds * multiplier)
if model_resident:
model_load = 60
elif model_downloaded:
model_load = int(_MODEL_LOAD_EXTRA_S / 3)
else:
# Weights still to fetch — the full extension, which is what
# model_manager already allows for a first-use generate.
model_load = int(_MODEL_LOAD_EXTRA_S)
return Deadlines(
accept_seconds=_ACCEPT_SECONDS,
model_load_seconds=model_load,
execution_seconds=int(execution),
# Silence, not slowness, is the failure signal.
progress_lease_seconds=int(_HEARTBEAT_GRACE_S * 4),
result_delivery_seconds=_RESULT_DELIVERY_SECONDS,
grace_seconds=grace,
)
def default_grace_seconds(operation: str = "") -> int:
if not operation:
return _DEFAULT_GRACE_SECONDS
return _PROFILE[Operation.coerce(operation)][1]
__all__ = ["Deadlines", "Operation", "default_grace_seconds", "for_task"]
+207
View File
@@ -0,0 +1,207 @@
"""Worker-protocol error taxonomy.
Why this exists: ``§22`` of the original goal doc listed "retryable errors" and
"non-retryable errors" without saying who decides. Without a single classifier
every worker invents its own strings and the scheduler retries deterministic
failures around the whole fleet the "poison task" scenario, where one bad
input quarantines every machine that touches it.
The rule the scheduler needs is not "did it fail" but "would trying somewhere
else help":
TRANSIENT yes, retry elsewhere; the worker is charged for it
CAPABILITY yes, retry elsewhere; the worker is NOT charged (it simply
cannot run this model a 4 GB card refusing a 6 GB engine
is correct behaviour, not flakiness)
CAPACITY yes, immediately; never charged (the worker is doing its job)
TIMEOUT maybe, per attempt budget; charged only if the worker was
otherwise healthy
TERMINAL no. Fail the task now.
PROTOCOL no retry of the task; the *session* is what is broken.
This module maps the app's existing docs taxonomy (``core.failure.classify``)
onto those classes rather than inventing a second vocabulary, so a failure that
already has a user-facing hint keeps it when it crosses the wire.
"""
from __future__ import annotations
import enum
from dataclasses import dataclass
from typing import Optional
from core import failure
class ErrorClass(str, enum.Enum):
"""Mirrors ``ErrorClass`` in worker_v1.proto."""
TRANSIENT = "transient"
CAPABILITY = "capability"
TERMINAL = "terminal"
CAPACITY = "capacity"
TIMEOUT = "timeout"
PROTOCOL = "protocol"
@property
def retryable(self) -> bool:
"""Would assigning this task to another worker plausibly help?"""
return self in _RETRYABLE
@property
def charges_worker(self) -> bool:
"""Does this failure count against the worker's circuit breaker?
Capability and capacity failures must not: penalising a worker for
correctly declining work it cannot do is how a healthy fleet
quarantines itself (docs/remote-workers.md).
"""
return self in _CHARGEABLE
_RETRYABLE = frozenset(
{ErrorClass.TRANSIENT, ErrorClass.CAPABILITY, ErrorClass.CAPACITY, ErrorClass.TIMEOUT}
)
_CHARGEABLE = frozenset({ErrorClass.TRANSIENT, ErrorClass.TIMEOUT})
# Docs-taxonomy key → protocol class. Keys come from core.failure.classify();
# anything unmapped falls back to TRANSIENT, which is the safe default: one
# wasted retry beats permanently failing work that would have succeeded.
_TAXONOMY: dict[str, ErrorClass] = {
# Environment is broken on THIS worker — another machine may be fine.
"BROKEN_VENV": ErrorClass.CAPABILITY,
"PKG_RESOURCES_MISSING": ErrorClass.CAPABILITY,
"TRANSFORMERS_IMPORT": ErrorClass.CAPABILITY,
"MEDIA_TOOL_MISSING": ErrorClass.CAPABILITY,
"COMPUTE_TYPE_UNSUPPORTED": ErrorClass.CAPABILITY,
"GATEKEEPER_QUARANTINE": ErrorClass.CAPABILITY,
"APPIMAGE_WEBKIT_WHITESCREEN": ErrorClass.CAPABILITY,
"WINDOWS_APP_CONTROL_BLOCKED": ErrorClass.CAPABILITY,
"WINDOWS_PAGING_FILE_TOO_SMALL": ErrorClass.CAPABILITY,
"SOCKS_PROXY_SUPPORT_MISSING": ErrorClass.CAPABILITY,
# Network / cache — retry, possibly on the same worker later.
"HF_MIRROR_UNREACHABLE": ErrorClass.TRANSIENT,
"MODEL_DOWNLOAD_INTERRUPTED": ErrorClass.TRANSIENT,
"MODEL_CACHE_CORRUPT": ErrorClass.TRANSIENT,
"SSL_HANDSHAKE_FAILURE": ErrorClass.TRANSIENT,
"TLS_CONNECTION_DROPPED": ErrorClass.TRANSIENT,
"VIDEO_DOWNLOAD_NETWORK": ErrorClass.TRANSIENT,
"AUDIO_IO_FAILED": ErrorClass.TRANSIENT,
"VIDEO_DOWNLOAD_OS_ERROR": ErrorClass.TRANSIENT,
"OS_INVALID_ARGUMENT": ErrorClass.TRANSIENT,
# Needs a human; no worker will do better.
"HF_AUTH_FAILED": ErrorClass.TERMINAL,
"PYANNOTE_LICENSE_REQUIRED": ErrorClass.TERMINAL,
"UNSUPPORTED_VIDEO_URL": ErrorClass.TERMINAL,
"VIDEO_DRM_PROTECTED": ErrorClass.TERMINAL,
}
# Protocol-level codes raised by the worker layer itself (no docs taxonomy).
_PROTOCOL_CODES: dict[str, ErrorClass] = {
"WORKER_AT_CAPACITY": ErrorClass.CAPACITY,
"MODEL_NOT_INSTALLED": ErrorClass.CAPABILITY,
"MODEL_NOT_DOWNLOADED": ErrorClass.CAPABILITY,
"INSUFFICIENT_MEMORY": ErrorClass.CAPABILITY,
"OPERATION_UNSUPPORTED": ErrorClass.CAPABILITY,
"ACCEPT_TIMEOUT": ErrorClass.TIMEOUT,
"MODEL_LOAD_TIMEOUT": ErrorClass.TIMEOUT,
"EXECUTION_TIMEOUT": ErrorClass.TIMEOUT,
"PROGRESS_LEASE_EXPIRED": ErrorClass.TIMEOUT,
"RESULT_DELIVERY_TIMEOUT": ErrorClass.TIMEOUT,
"INPUT_FETCH_TIMEOUT": ErrorClass.TIMEOUT,
"INPUT_FETCH_FAILED": ErrorClass.TRANSIENT,
"RESULT_UPLOAD_FAILED": ErrorClass.TRANSIENT,
"WORKER_FAILED": ErrorClass.TRANSIENT,
"SESSION_EXPIRED": ErrorClass.PROTOCOL,
"STALE_EPOCH": ErrorClass.PROTOCOL,
"STALE_ATTEMPT": ErrorClass.PROTOCOL,
"UPGRADE_REQUIRED": ErrorClass.PROTOCOL,
"WORKER_REVOKED": ErrorClass.PROTOCOL,
"AUTH_FAILED": ErrorClass.PROTOCOL,
"INVALID_TASK_PARAMS": ErrorClass.TERMINAL,
"MODEL_REF_REJECTED": ErrorClass.TERMINAL,
# Terminal, not transient: the render succeeded but is bigger than the
# stream can carry, so retrying re-renders the same oversized audio. Left
# unclassified it fell through to TRANSIENT and the task retried until it
# ran out of attempts, each one paying the full generation again.
"RESULT_TOO_LARGE": ErrorClass.TERMINAL,
"ARTIFACT_TOO_LARGE": ErrorClass.TERMINAL,
"OFFSET_MISMATCH": ErrorClass.TRANSIENT,
"SIZE_MISMATCH": ErrorClass.TRANSIENT,
"DIGEST_MISMATCH": ErrorClass.TRANSIENT,
"UPLOAD_INCOMPLETE": ErrorClass.TRANSIENT,
}
@dataclass(frozen=True)
class WorkerError:
"""A failure as it crosses the wire — already scrubbed, always actionable."""
error_class: ErrorClass
code: str
message: str
hint: str = ""
@property
def retryable(self) -> bool:
return self.error_class.retryable
@property
def charges_worker(self) -> bool:
return self.error_class.charges_worker
def to_dict(self) -> dict:
return {
"error_class": self.error_class.value,
"code": self.code,
"message": self.message,
"hint": self.hint,
"retryable": self.retryable,
}
def classify_code(code: str) -> ErrorClass:
"""Classify a protocol-level code, then fall back to the docs taxonomy."""
if code in _PROTOCOL_CODES:
return _PROTOCOL_CODES[code]
return _TAXONOMY.get(code, ErrorClass.TRANSIENT)
def from_reason(reason: str, *, code: Optional[str] = None) -> WorkerError:
"""Build a wire error from a raw failure string.
``reason`` is sanitized through ``core.failure`` before it leaves the
machine HF tokens, ``*KEY*``/``*SECRET*`` env values and home paths must
never ride the wire (docs/remote-workers.md), and the worker is a remote machine
whose logs the user may never see.
"""
safe = failure.sanitize(reason) or reason.__class__.__name__
resolved = code or failure.classify(reason) or ""
cls = classify_code(resolved) if resolved else ErrorClass.TRANSIENT
return WorkerError(
error_class=cls,
code=resolved or "UNKNOWN",
message=safe,
hint=_hint_for(resolved),
)
def from_exception(exc: BaseException, *, code: Optional[str] = None) -> WorkerError:
return from_reason(failure.describe_exception(exc), code=code)
def _hint_for(taxonomy_key: str) -> str:
"""Reuse the app's existing one-line remediation for a taxonomy key."""
if not taxonomy_key:
return ""
hints = getattr(failure, "_HINTS", {})
return hints.get(taxonomy_key, "")
__all__ = [
"ErrorClass",
"WorkerError",
"classify_code",
"from_reason",
"from_exception",
]
+783
View File
@@ -0,0 +1,783 @@
"""Runs assigned tasks on a worker, using the engines already installed there.
A worker is the ordinary backend in worker mode, not a separate slim agent.
That is deliberate: engines, their per-engine sidecar venvs, model downloading,
VRAM budgeting, and the deliberately serial GPU lane all already live in
``services/``. A second implementation would fork every one of them and drift.
So this module is a translator, not an engine. It takes a wire assignment,
calls the same code path a local generation would, and reports progress in the
terms the protocol expects.
The serial GPU gate is honoured rather than bypassed: work runs through the
same ``gpu_queue`` that protects local jobs, so a machine serving both a remote
task and its own user cannot double-book its GPU.
"""
from __future__ import annotations
import asyncio
import base64
import hashlib
import json
import logging
import os
import io
import zipfile
from typing import Any, Awaitable, Callable, Optional
from worker.errors import ErrorClass, WorkerError
logger = logging.getLogger("omnivoice.worker")
# Results at or below this ride the control stream inline; anything larger is
# uploaded separately so it cannot head-of-line block heartbeats.
INLINE_LIMIT_BYTES = 256 * 1024
# Where the control plane reports that it could not stage an input. Mirrors
# ``codec._INPUT_ERRORS_KEY``; the two are pinned together by a test rather
# than by an import, because this module must not depend on the transport.
INPUT_ERRORS_PARAM = "input_errors"
# Fetched inputs are cached by content hash, so the second clone of a voice
# transfers nothing. Bounded, because a cache with no ceiling is the same disk
# leak on the worker that unpurged artifacts were on the control plane.
INPUT_CACHE_LIMIT_BYTES = 2 * 1024 * 1024 * 1024
_FALLBACK_INPUT_FETCH_SECONDS = 600.0
# on_progress(fraction: float, stage: str)
# on_model_loading(fraction: float, detail: str)
#
# Passed per call by the transport, which binds them to the assignment's ref —
# one executor serves every slot, so a reporter installed on the instance could
# not say which task a fraction belongs to. The constructor keywords remain for
# a caller that drives the executor directly.
ProgressReporter = Callable[[float, str], Awaitable[None]]
LoadReporter = Callable[[float, str], Awaitable[None]]
# fetch_input(ref: ArtifactRef, destination: str) -> Awaitable[Any]
#
# Supplied by the transport, which owns the ``DownloadArtifact`` stream and the
# session credentials it needs. The executor decides *what* to fetch and where
# it lands; it does not know there is a network.
InputFetcher = Callable[[Any, str], Awaitable[Any]]
# Used when an assignment carries no deadlines (the HTTP mirror, and tests).
# Generous on purpose: the server lease is the real bound, and a worker-side
# timeout that fires first turns a slow-but-healthy job into a hard failure.
_FALLBACK_MODEL_LOAD_SECONDS = 1_200.0
_FALLBACK_EXECUTION_SECONDS = 1_800.0
class UnsupportedOperation(Exception):
"""The worker was handed an operation it does not implement."""
class TaskExecutor:
"""Executes protocol assignments against the local engine stack."""
def __init__(
self,
*,
on_progress: Optional[ProgressReporter] = None,
on_model_loading: Optional[LoadReporter] = None,
fetch_input: Optional[InputFetcher] = None,
input_dir: Optional[str] = None,
) -> None:
self._on_progress = on_progress
self._on_model_loading = on_model_loading
self._fetch_input = fetch_input
self._input_dir = input_dir
async def execute(
self,
assignment,
*,
on_progress: Optional[ProgressReporter] = None,
on_model_loading: Optional[LoadReporter] = None,
fetch_input: Optional[InputFetcher] = None,
) -> dict:
"""Run one assignment and return ``{"meta": {...}, "payload": bytes}``.
Raises a ``WorkerError``-carrying exception on failure so the client
reports a classified error rather than a bare string the difference
between "retry elsewhere" and "stop, this input is bad".
The reporters arrive per call, already bound to this assignment's ref
by the transport; they are what renews the server's progress lease, so
an executor that ignored them would die of apparent silence on any task
longer than the lease starting with the cold model load.
"""
operation = (assignment.operation or "tts").lower()
params = _parse_params(assignment.params_json)
params = await self._materialize_inputs(
assignment, params, fetch_input or self._fetch_input
)
handler = {
"tts": self._run_tts,
"clone": self._run_tts,
"audiobook": self._run_audiobook,
"dub_segments": self._run_dub_segments,
}.get(operation)
if handler is None:
raise TaskFailure(
WorkerError(
error_class=ErrorClass.CAPABILITY,
code="OPERATION_UNSUPPORTED",
message=f"This worker cannot run '{operation}' tasks.",
hint="Run this task locally, or use a worker that supports it.",
)
)
return await handler(
assignment,
params,
_Reporters(
on_progress or self._on_progress,
on_model_loading or self._on_model_loading,
),
)
async def _run_dub_segments(self, assignment, params: dict, report: "_Reporters") -> dict:
"""Render every requested dub line under one lease and return one bundle."""
rows = params.get("segments") or []
refs = params.get("ref_audio") or []
if not rows:
raise TaskFailure(WorkerError(
error_class=ErrorClass.TERMINAL, code="INVALID_TASK_PARAMS",
message="The dubbing task carried no segments.",
hint="Re-open the dub and try again.",
))
load_budget, run_budget = _budgets(assignment)
await report.loading(0.0, f"preparing {assignment.engine}")
backend = await self._bounded(
asyncio.to_thread(self._load_backend, assignment.engine),
timeout=load_budget, code="MODEL_LOAD_TIMEOUT", what=f"Loading '{assignment.engine}'",
)
await report.loading(1.0, "model ready")
rendered: list[tuple[int, bytes]] = []
for index, row in enumerate(rows):
row = dict(row)
row["ref_audio"] = refs[index] if index < len(refs) else None
audio = await self._bounded(
asyncio.to_thread(self._synthesize_dub_segment, backend, row),
timeout=run_budget, code="EXECUTION_TIMEOUT", what=f"Dubbing segment {index + 1}",
)
payload, _meta = await asyncio.to_thread(self._encode, audio, row, backend)
rendered.append((int(row.get("index", index)), payload))
await report.progress((index + 1) / len(rows), f"segment {index + 1} of {len(rows)}")
bundle = io.BytesIO()
with zipfile.ZipFile(bundle, "w", compression=zipfile.ZIP_STORED) as archive:
for index, payload in rendered:
archive.writestr(f"segments/{index}.wav", payload)
data = bundle.getvalue()
return {"payload": data, "meta": {
"filename": "dub-segments.zip", "content_type": "application/zip",
"segments": len(rendered), "bytes": len(data),
}}
@staticmethod
def _synthesize_dub_segment(backend, row: dict):
"""Worker-side equivalent of dubbing's text-to-engine chokepoint."""
from services.audio_dsp import apply_effects_chain, apply_mastering, get_effect_chain, normalize_audio
from services.text_normalization import normalize_for_tts
text = normalize_for_tts(row.get("text") or "", row.get("language"))
if row.get("seed") is not None:
import torch
torch.manual_seed(int(row["seed"]))
kwargs = {
"language": row.get("language") if row.get("language") != "Auto" else None,
"ref_audio": row.get("ref_audio"), "ref_text": row.get("ref_text"),
"cache_ref": not bool(row.get("ref_single_use")),
"instruct": row.get("instruct") or None, "duration": row.get("duration"),
"num_step": int(row.get("num_step") or 16),
"guidance_scale": float(row.get("guidance_scale") or 2.0),
"speed": float(row.get("speed") or 1.0), "denoise": True,
"postprocess_output": True,
}
audio = backend.generate(text=text, **kwargs)
preset = row.get("effect_preset") or "broadcast"
if preset != "raw":
if not getattr(backend, "applies_own_mastering", False):
audio = apply_mastering(audio, sample_rate=backend.sample_rate)
chain = get_effect_chain(preset)
if chain:
audio = apply_effects_chain(audio, sample_rate=backend.sample_rate, chain=chain)
audio = normalize_audio(audio, target_dBFS=-2.0)
return audio
# ── Operations ────────────────────────────────────────────────────────
async def _run_tts(self, assignment, params: dict, report: "_Reporters") -> dict:
text = (params.get("text") or "").strip()
if not text:
raise TaskFailure(
WorkerError(
error_class=ErrorClass.TERMINAL,
code="INVALID_TASK_PARAMS",
message="The task carried no text to synthesise.",
hint="This will fail on any worker — check the request.",
)
)
load_budget, run_budget = _budgets(assignment)
await report.loading(0.0, f"preparing {assignment.engine}")
backend = await self._bounded(
asyncio.to_thread(self._load_backend, assignment.engine),
timeout=load_budget,
code="MODEL_LOAD_TIMEOUT",
what=f"Loading '{assignment.engine}'",
)
await report.loading(1.0, "model ready")
await report.progress(0.05, "synthesising")
audio = await self._bounded(
asyncio.to_thread(self._synthesize, backend, text, params),
timeout=run_budget,
code="EXECUTION_TIMEOUT",
what="Synthesis",
)
await report.progress(0.9, "encoding")
payload, meta = await self._bounded(
asyncio.to_thread(self._encode, audio, params, backend),
timeout=run_budget,
code="EXECUTION_TIMEOUT",
what="Encoding",
)
await report.progress(1.0, "done")
return {"meta": meta, "payload": payload}
async def _run_audiobook(self, assignment, params: dict, report: "_Reporters") -> dict:
"""Render one chapter as one leased unit, matching local longform assembly."""
spans = params.get("spans") or []
voices = params.get("voices") or []
if not spans or len(voices) != len(spans):
raise TaskFailure(WorkerError(
error_class=ErrorClass.TERMINAL,
code="INVALID_TASK_PARAMS",
message="The audiobook task carried an invalid chapter.",
hint="Re-plan the audiobook and try again.",
))
load_budget, run_budget = _budgets(assignment)
await report.loading(0.0, f"preparing {assignment.engine}")
backend = await self._bounded(
asyncio.to_thread(self._load_backend, assignment.engine),
timeout=load_budget, code="MODEL_LOAD_TIMEOUT",
what=f"Loading '{assignment.engine}'",
)
await report.loading(1.0, "model ready")
await report.progress(0.05, "synthesising chapter")
audio = await self._bounded(
asyncio.to_thread(self._synthesize_audiobook, backend, spans, voices, params),
timeout=run_budget, code="EXECUTION_TIMEOUT", what="Audiobook chapter",
)
await report.progress(0.9, "encoding")
payload, meta = await asyncio.to_thread(self._encode, audio, params, backend)
await report.progress(1.0, "done")
return {"meta": meta, "payload": payload}
@staticmethod
def _synthesize_audiobook(backend, rows: list[dict], voices: list[dict], params: dict):
from services.audiobook import ExpressiveOptions, Span, segment_seed, synthesize_chapter
from services.tts_backend import OmniVoiceBackend
refs = params.get("ref_audio") or []
voices = [dict(voice, ref_audio=refs[i] if i < len(refs) else None)
for i, voice in enumerate(voices)]
opts = ExpressiveOptions.from_manifest(params.get("expressive"))
language = params.get("language")
extra = {
key: value for key, value in opts.to_manifest().items()
if value is not None and key not in ("seed", "vary_repeats")
}
if isinstance(backend, OmniVoiceBackend):
extra.setdefault("num_step", 32)
extra.setdefault("guidance_scale", 2.0)
for key in ("emo_vector", "emo_text", "emo_alpha"):
extra.pop(key, None)
occurrence = {"value": 0}
def synth(text, index, speed=None):
voice = voices[int(index)]
base_seed = opts.seed if opts.seed is not None else voice.get("seed")
if base_seed is not None:
import torch
nonce = occurrence["value"] if opts.vary_repeats else 0
occurrence["value"] += 1
torch.manual_seed(segment_seed(base_seed, text, nonce))
kwargs = {
"language": language,
"ref_audio": voice.get("ref_audio"),
"ref_text": voice.get("ref_text"),
"instruct": voice.get("instruct"),
"speed": float(speed) if speed else 1.0,
**extra,
}
return backend.generate(text, **kwargs)
spans = [Span(voice_id=str(i), text=row.get("text", ""),
pause_ms_after=int(row.get("pause_ms_after") or 0),
speed=row.get("speed")) for i, row in enumerate(rows)]
sample_rate = int(getattr(backend, "sample_rate", 0) or 24_000)
audio, _duration = synthesize_chapter(
spans, synth, sample_rate, lexicon=params.get("lexicon")
)
return _mark(audio, sample_rate, params)
# ── Inputs ────────────────────────────────────────────────────────────
async def _materialize_inputs(self, assignment, params: dict, fetch) -> dict:
"""Turn declared inputs into local files, then point the params at them.
The control plane sends artifact ids, never paths its own paths mean
nothing here. So a clone arrives with ``ref_audio`` set to an id, and
the audio itself only exists once this has fetched it. Getting that
wrong does not fail loudly: the engine renders in the default voice and
the user gets audio that is simply not their clone.
"""
errors = params.get(INPUT_ERRORS_PARAM)
if errors:
detail = "; ".join(str(e) for e in errors) if isinstance(errors, list) else str(errors)
raise TaskFailure(
WorkerError(
error_class=ErrorClass.TERMINAL,
code="INPUT_UNAVAILABLE",
message=f"The task's input files could not be prepared: {detail}",
hint="Check that the reference audio still exists, then try again.",
)
)
refs = [ref for ref in (getattr(assignment, "inputs", None) or []) if ref.artifact_id]
if not refs:
return params
if fetch is None:
raise TaskFailure(
WorkerError(
error_class=ErrorClass.CAPABILITY,
code="INPUT_TRANSFER_UNSUPPORTED",
message="This worker cannot fetch task inputs.",
hint="Update the worker, or run this task on a worker that can.",
)
)
_, run_budget = _budgets(assignment)
local: dict[str, str] = {}
for ref in refs:
local[ref.artifact_id] = await self._bounded(
self._fetch_one(ref, fetch),
timeout=min(run_budget, _FALLBACK_INPUT_FETCH_SECONDS),
code="INPUT_FETCH_TIMEOUT",
what=f"Fetching '{ref.filename or ref.artifact_id}'",
)
return _rewrite_params(params, local)
async def _fetch_one(self, ref, fetch) -> str:
"""The local copy of one input, downloaded only if we lack it.
Content-addressed: the name is the hash the control plane computed, so
a second clone of the same voice or a retry of this very task on this
worker costs no transfer at all.
"""
directory = self._input_dir or default_input_dir()
os.makedirs(directory, exist_ok=True)
destination = os.path.join(directory, _cache_name(ref))
if _already_held(destination, ref):
_touch(destination)
return destination
partial = f"{destination}.part"
try:
await fetch(ref, partial)
except TaskFailure:
raise
except Exception as exc:
_discard(partial)
raise TaskFailure(
WorkerError(
# Transient on purpose: an id we cannot resolve now is far
# more often a dropped stream than a permanently missing
# file, and one wasted retry beats failing real work.
error_class=ErrorClass.TRANSIENT,
code="INPUT_FETCH_FAILED",
message=f"Could not fetch '{ref.filename or ref.artifact_id}': {exc}",
hint="The control plane may have restarted; the task will be retried.",
)
) from exc
# Off the loop: hashing a source video on the event loop thread would
# stall every heartbeat this worker owes the control plane.
await asyncio.to_thread(_verify, partial, ref)
os.replace(partial, destination)
await asyncio.to_thread(_prune_input_cache, directory)
return destination
# ── Engine plumbing ───────────────────────────────────────────────────
@staticmethod
def _load_backend(engine_id: str):
"""Resolve the requested engine and make sure its weights are resident.
``engine_id`` is a registry NAME, never a path the protocol forbids
paths precisely because model loading is pickle-backed here, and a path
would be remote code execution on every worker in the fleet.
The instance comes from the process-wide cache, so the second task on
an engine costs nothing: instantiating per task made every remote job
pay a cold load, which is most of what the model-load budget and the
progress lease were being blown on.
``ensure_ready()`` is what actually spends that budget. Every adapter
loads lazily inside ``generate()``; without this the load phase is
instantaneous, the cold load happens under the execution budget, and
the two-phase split the protocol mirrors (#1033/#1037) is decorative.
"""
from services import tts_backend # noqa: PLC0415
try:
backend = tts_backend.get_engine_instance_for(engine_id)
except Exception as exc:
raise TaskFailure(
WorkerError(
error_class=ErrorClass.CAPABILITY,
code="MODEL_NOT_INSTALLED",
message=f"Engine '{engine_id}' is not available on this worker.",
hint="Install it on the worker machine, or route this task elsewhere.",
)
) from exc
try:
# Loading can mean a multi-GB download; the sweep must not decide
# halfway through that nobody wants this engine.
with tts_backend.engine_in_use(backend):
backend.ensure_ready()
except Exception as exc:
from worker import errors as worker_errors # noqa: PLC0415
raise TaskFailure(worker_errors.from_exception(exc)) from exc
return backend
@staticmethod
def _synthesize(backend, text: str, params: dict):
"""Call the engine through the same serial GPU gate local jobs use.
Held against the idle sweep for the duration: a long generation touches
the instance cache once, at the start, so on elapsed time alone it is
indistinguishable from a model nobody wants any more.
"""
from services import tts_backend # noqa: PLC0415
kwargs = {
key: params[key]
for key in (
"ref_audio",
"ref_text",
"instruct",
"language",
"duration",
"description",
"speed",
)
if params.get(key) is not None
}
try:
with tts_backend.engine_in_use(backend):
return backend.generate(text, **kwargs)
except Exception as exc:
from worker import errors as worker_errors # noqa: PLC0415
raise TaskFailure(worker_errors.from_exception(exc)) from exc
@staticmethod
def _encode(audio, params: dict, backend=None) -> tuple[bytes, dict]:
"""Mark the waveform, then turn it into wav bytes plus metadata.
The engine's own rate is the fallback, not a flat 24 kHz: VoxCPM2
renders at 48 kHz, and encoding its output as 24 kHz plays it back at
half speed.
"""
import io # noqa: PLC0415
import soundfile as sf # noqa: PLC0415
sample_rate = int(
params.get("sample_rate") or getattr(backend, "sample_rate", 0) or 24_000
)
audio = _mark(audio, sample_rate, params)
array = audio
try:
array = audio.detach().cpu().numpy()
except AttributeError:
pass
if getattr(array, "ndim", 1) > 1:
array = array.squeeze()
buffer = io.BytesIO()
sf.write(buffer, array, sample_rate, format="WAV")
payload = buffer.getvalue()
duration = float(len(array)) / sample_rate if sample_rate else 0.0
return payload, {
"sample_rate": sample_rate,
"duration_seconds": round(duration, 3),
"bytes": len(payload),
"inline": len(payload) <= INLINE_LIMIT_BYTES,
}
# ── Bounding ──────────────────────────────────────────────────────────
@staticmethod
async def _bounded(coro, *, timeout: float, code: str, what: str):
"""Run ``coro`` under the server's budget for this phase.
The worker's own bound, not a replacement for the server lease: the
lease can only notice that frames stopped arriving, while this ends the
wait on a thread that is never coming back. Both exist because either
alone leaves a hole a wedged GPU thread keeps the keepalive timer
ticking, and a dead connection stops the lease from being renewed.
"""
try:
return await asyncio.wait_for(coro, timeout=timeout)
except asyncio.TimeoutError as exc:
raise TaskFailure(
WorkerError(
error_class=ErrorClass.TIMEOUT,
code=code,
message=f"{what} exceeded the {timeout:g}s budget for this task.",
hint="Try a shorter input, or a worker with more headroom.",
)
) from exc
class _Reporters:
"""The two optional callbacks, so every call site can report unconditionally."""
__slots__ = ("_progress", "_loading")
def __init__(
self,
progress: Optional[ProgressReporter],
loading: Optional[LoadReporter],
) -> None:
self._progress = progress
self._loading = loading
async def progress(self, fraction: float, stage: str) -> None:
if self._progress is not None:
await self._progress(fraction, stage)
async def loading(self, fraction: float, detail: str) -> None:
if self._loading is not None:
await self._loading(fraction, detail)
class TaskFailure(Exception):
"""Carries a classified ``WorkerError`` across the execution boundary."""
def __init__(self, error: WorkerError) -> None:
super().__init__(error.message)
self.error = error
def _budgets(assignment) -> tuple[float, float]:
"""(model-load, execution) seconds for this assignment.
Server-computed and relative worker wall clocks are untrusted. A zero or
missing field means "the server did not state one", never "no time".
"""
deadlines = getattr(assignment, "deadlines", None)
load = float(getattr(deadlines, "model_load_seconds", 0) or 0)
run = float(getattr(deadlines, "execution_seconds", 0) or 0)
return (
load or _FALLBACK_MODEL_LOAD_SECONDS,
run or _FALLBACK_EXECUTION_SECONDS,
)
def default_input_dir() -> str:
"""Where fetched inputs are cached on this worker.
Under the app's own data directory when there is one — a worker is the
ordinary backend in worker mode and the system temp dir otherwise, so a
stripped-down install still runs instead of failing on a missing path.
"""
try:
from core.config import DATA_DIR # noqa: PLC0415
return os.path.join(str(DATA_DIR), "workers", "inputs")
except Exception: # pragma: no cover — no app data dir on this host
import tempfile # noqa: PLC0415
return os.path.join(tempfile.gettempdir(), "omnivoice-worker-inputs")
def _cache_name(ref) -> str:
"""A safe, content-addressed local name for one input.
Never the wire filename: that is remote input, and joining it onto a
directory is how a peer writes outside it. The hash the control plane sent
is the identity; the extension is kept only when it is a plain one,
because an engine that shells out to ffmpeg reads the suffix.
"""
digest = "".join(c for c in (getattr(ref, "sha256", "") or "") if c in "0123456789abcdef")
if len(digest) != 64:
digest = hashlib.sha256((ref.artifact_id or "").encode("utf-8")).hexdigest()
suffix = os.path.splitext(os.path.basename(str(getattr(ref, "filename", "") or "")))[1].lower()
if not (1 < len(suffix) <= 9 and suffix[1:].isalnum()):
suffix = ""
return f"{digest}{suffix}"
def _already_held(path: str, ref) -> bool:
"""Do we already have this exact input?
Size alone: the name is the content hash and the only writer is an atomic
rename, so a file of the right size at this name cannot be different bytes.
"""
try:
expected = int(getattr(ref, "size_bytes", 0) or 0)
return os.path.isfile(path) and (not expected or os.path.getsize(path) == expected)
except OSError: # pragma: no cover
return False
def _touch(path: str) -> None:
try:
os.utime(path, None)
except OSError: # pragma: no cover
pass
def _discard(path: str) -> None:
try:
os.remove(path)
except OSError:
pass
def _verify(path: str, ref) -> None:
"""Refuse a transfer that does not match what was announced.
A truncated reference clip does not fail it clones three seconds of
silence so the check has to happen before the file is committed.
"""
expected_size = int(getattr(ref, "size_bytes", 0) or 0)
expected_hash = (getattr(ref, "sha256", "") or "").lower()
try:
actual_size = os.path.getsize(path)
except OSError as exc:
_discard(path)
raise TaskFailure(
WorkerError(
error_class=ErrorClass.TRANSIENT,
code="INPUT_FETCH_FAILED",
message=f"The input '{ref.filename or ref.artifact_id}' did not arrive.",
hint="The task will be retried.",
)
) from exc
actual_hash = ""
if expected_hash:
digest = hashlib.sha256()
with open(path, "rb") as handle:
for block in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(block)
actual_hash = digest.hexdigest()
if (expected_size and actual_size != expected_size) or (
expected_hash and actual_hash != expected_hash
):
_discard(path)
raise TaskFailure(
WorkerError(
error_class=ErrorClass.TRANSIENT,
code="INPUT_CORRUPT",
message=f"The input '{ref.filename or ref.artifact_id}' arrived damaged.",
hint="The transfer will be retried.",
)
)
def _prune_input_cache(directory: str, limit_bytes: int = INPUT_CACHE_LIMIT_BYTES) -> None:
"""Keep the input cache under its ceiling, oldest first."""
try:
entries = []
total = 0
for name in os.listdir(directory):
path = os.path.join(directory, name)
if not os.path.isfile(path):
continue
stat = os.stat(path)
entries.append((stat.st_mtime, stat.st_size, path))
total += stat.st_size
for _mtime, size, path in sorted(entries):
if total <= limit_bytes:
break
os.remove(path)
total -= size
except OSError: # pragma: no cover — a full cache is not a failed task
logger.debug("Could not prune the worker input cache", exc_info=True)
def _rewrite_params(params: dict, local: dict[str, str]):
"""Replace every artifact id in the params with its local path."""
if isinstance(params, dict):
return {key: _rewrite_params(value, local) for key, value in params.items()}
if isinstance(params, list):
return [_rewrite_params(item, local) for item in params]
if isinstance(params, str):
return local.get(params, params)
return params
def _mark(audio, sample_rate: int, params: dict):
"""Provenance-mark synthetic audio before it is encoded (EU AI Act 50(2)).
The decision is the CONTROL PLANE user's, carried on the assignment: the
watermark pref belongs to whoever asked for the audio, not to whoever owns
the GPU that rendered it. So ``force=True`` a worker machine with the
pref switched off must not strip the mark off someone else's output.
Absent field means mark. A worker running an older control plane's
assignment has no way to learn the user's answer, and the failure that
matters here is shipping unmarked synthetic speech.
"""
if params.get("watermark") is False:
return audio
try:
from services.watermark import mark_synthetic # noqa: PLC0415
return mark_synthetic(
audio, sample_rate, context="worker.executor.tts", force=True
)
except Exception:
# mark_synthetic never raises by contract; an import failure on a
# stripped-down worker install still must not lose the audio.
logger.warning("Provenance marking unavailable on this worker", exc_info=True)
return audio
def _parse_params(raw: str) -> dict[str, Any]:
if not raw:
return {}
try:
parsed = json.loads(raw)
except ValueError:
return {}
return parsed if isinstance(parsed, dict) else {}
def encode_inline(payload: bytes) -> str:
"""Base64 for transports that cannot carry raw bytes (the HTTP mirror)."""
return base64.b64encode(payload).decode("ascii")
__all__ = [
"INLINE_LIMIT_BYTES",
"INPUT_CACHE_LIMIT_BYTES",
"INPUT_ERRORS_PARAM",
"InputFetcher",
"TaskExecutor",
"TaskFailure",
"UnsupportedOperation",
"default_input_dir",
]
+358
View File
@@ -0,0 +1,358 @@
"""Worker identity, enrollment, and session credentials.
The goal doc said workers "must be revocable" and gave them a server-assigned
Worker ID. A server-assigned ID is a *name*, not proof of anything: if
reconnecting means "present this ID and a valid key", then a stolen key lets an
attacker impersonate an existing healthy worker, receive that user's voice
recordings, and return whatever it likes inheriting the real worker's
standing while doing it. Revocation of a name you cannot verify is theatre.
So identity here is a **keypair the worker generates and never transmits**.
Enrollment binds its public key; every later connection proves possession by
signing a server challenge. Revoking a worker means refusing that public key,
which is a fact the server can actually check.
The enrollment token solves the other half trusting the *server*. The OSS
control plane is a desktop app with a self-signed certificate, so the token
carries the certificate fingerprint and the worker pins it on first connect
(the join-token pattern from k3s and Tailscale). There is deliberately no
"skip verification" mode: on a coffee-shop network that flag is the whole
attack.
Tokens are single-use, expiring, and stored hashed the plaintext exists only
in the dialog that shows it once.
"""
from __future__ import annotations
import base64
import hashlib
import hmac
import json
import os
import secrets
import time
from dataclasses import dataclass
from typing import Optional
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
Ed25519PrivateKey,
Ed25519PublicKey,
)
from worker.clock import resolve
# Credential-class prefixes. Separate namespaces so an enrollment token can
# never be mistaken for (or used as) a client API key, and so a leaked string
# is identifiable on sight.
ENROLLMENT_PREFIX = "ovw" # worker enrollment
SESSION_PREFIX = "ovs" # worker session
_TOKEN_BYTES = 32
_DEFAULT_TOKEN_TTL_SECONDS = 15 * 60
_DEFAULT_SESSION_TTL_SECONDS = 60 * 60
_CHALLENGE_BYTES = 32
def _b64(raw: bytes) -> str:
return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
def _unb64(text: str) -> bytes:
pad = "=" * (-len(text) % 4)
return base64.urlsafe_b64decode(text + pad)
def hash_secret(secret: str) -> str:
"""Hash a credential for storage.
Plain SHA-256 is correct here and bcrypt/argon2 would be cargo cult: these
are 256-bit random tokens, not user-chosen passwords, so there is no
dictionary to slow down.
"""
return hashlib.sha256(secret.encode("utf-8")).hexdigest()
def constant_time_equals(a: str, b: str) -> bool:
return hmac.compare_digest(a, b)
# ── Keypairs ───────────────────────────────────────────────────────────────
@dataclass(frozen=True)
class WorkerKeypair:
"""A worker's long-lived identity. The private half never leaves its host."""
private_key: Ed25519PrivateKey
public_key: Ed25519PublicKey
@classmethod
def generate(cls) -> "WorkerKeypair":
private = Ed25519PrivateKey.generate()
return cls(private_key=private, public_key=private.public_key())
@classmethod
def from_private_bytes(cls, raw: bytes) -> "WorkerKeypair":
private = Ed25519PrivateKey.from_private_bytes(raw)
return cls(private_key=private, public_key=private.public_key())
def private_bytes(self) -> bytes:
return self.private_key.private_bytes(
encoding=serialization.Encoding.Raw,
format=serialization.PrivateFormat.Raw,
encryption_algorithm=serialization.NoEncryption(),
)
def public_bytes(self) -> bytes:
return public_key_bytes(self.public_key)
@property
def key_id(self) -> str:
return key_id_for(self.public_bytes())
def sign(self, message: bytes) -> bytes:
return self.private_key.sign(message)
def public_key_bytes(public_key: Ed25519PublicKey) -> bytes:
return public_key.public_bytes(
encoding=serialization.Encoding.Raw,
format=serialization.PublicFormat.Raw,
)
def key_id_for(public_bytes: bytes) -> str:
"""Short stable handle for a public key — safe to log and display."""
return hashlib.sha256(public_bytes).hexdigest()[:16]
def verify_signature(public_bytes: bytes, message: bytes, signature: bytes) -> bool:
try:
Ed25519PublicKey.from_public_bytes(public_bytes).verify(signature, message)
return True
except (InvalidSignature, ValueError):
return False
# ── Challenge / response ───────────────────────────────────────────────────
def new_challenge() -> bytes:
return secrets.token_bytes(_CHALLENGE_BYTES)
def challenge_message(
*, challenge: bytes, worker_id: str, session_epoch: int, nonce: bytes
) -> bytes:
"""Bind the signature to this worker, this epoch, and this nonce.
Signing a bare random challenge would let a captured signature be replayed
against a different worker record or a stale epoch.
"""
return b"|".join(
[
b"omnivoice.worker.v1",
challenge,
worker_id.encode("utf-8"),
str(int(session_epoch)).encode("ascii"),
nonce,
]
)
# ── Enrollment tokens ──────────────────────────────────────────────────────
@dataclass(frozen=True)
class EnrollmentToken:
"""A single-use join token, shown to the user exactly once.
Carries the control server's endpoint and certificate fingerprint so the
worker can pin it the token *is* the trust anchor, which is what makes a
self-signed desktop control plane safe to connect to.
"""
token_id: str
secret: str
endpoint: str
cert_fingerprint: str
expires_at: float
def encode(self) -> str:
"""Serialize for display/copy-paste. One opaque string, prefixed."""
payload = {
"v": 1,
"id": self.token_id,
"s": self.secret,
"e": self.endpoint,
"f": self.cert_fingerprint,
"x": int(self.expires_at),
}
blob = _b64(json.dumps(payload, separators=(",", ":")).encode("utf-8"))
return f"{ENROLLMENT_PREFIX}_{blob}"
@classmethod
def decode(cls, text: str) -> "EnrollmentToken":
raw = (text or "").strip()
if not raw.startswith(f"{ENROLLMENT_PREFIX}_"):
raise ValueError("Not an OmniVoice worker enrollment token.")
try:
payload = json.loads(_unb64(raw.split("_", 1)[1]).decode("utf-8"))
except Exception as exc: # noqa: BLE001 — any malformed token is one error
raise ValueError("This enrollment token is malformed or truncated.") from exc
if int(payload.get("v", 0)) != 1:
raise ValueError("This enrollment token was made by a newer version.")
return cls(
token_id=str(payload["id"]),
secret=str(payload["s"]),
endpoint=str(payload["e"]),
cert_fingerprint=str(payload["f"]),
expires_at=float(payload["x"]),
)
def expired(self, *, now: Optional[float] = None) -> bool:
return resolve(now) > self.expires_at
@property
def secret_hash(self) -> str:
return hash_secret(self.secret)
def mint_enrollment_token(
*,
endpoint: str,
cert_fingerprint: str,
ttl_seconds: int = _DEFAULT_TOKEN_TTL_SECONDS,
now: Optional[float] = None,
) -> EnrollmentToken:
return EnrollmentToken(
token_id=secrets.token_hex(8),
secret=_b64(secrets.token_bytes(_TOKEN_BYTES)),
endpoint=endpoint,
cert_fingerprint=cert_fingerprint,
expires_at=resolve(now) + ttl_seconds,
)
def certificate_fingerprint(cert_der: bytes) -> str:
"""SHA-256 fingerprint, colon-free lowercase hex, of a DER certificate."""
return hashlib.sha256(cert_der).hexdigest()
# ── Sessions ───────────────────────────────────────────────────────────────
@dataclass(frozen=True)
class Session:
"""Short-lived credential bound to one worker and one stream.
Bound rather than bearer: a token that is only valid on the stream it was
issued on cannot be replayed from another host if it leaks into a log.
"""
token: str
worker_id: str
key_id: str
epoch: int
issued_at: float
expires_at: float
def expired(self, *, now: Optional[float] = None) -> bool:
return resolve(now) >= self.expires_at
@property
def token_hash(self) -> str:
return hash_secret(self.token)
def issue_session(
*,
worker_id: str,
key_id: str,
epoch: int,
ttl_seconds: int = _DEFAULT_SESSION_TTL_SECONDS,
now: Optional[float] = None,
) -> Session:
stamp = resolve(now)
return Session(
token=f"{SESSION_PREFIX}_{_b64(secrets.token_bytes(_TOKEN_BYTES))}",
worker_id=worker_id,
key_id=key_id,
epoch=epoch,
issued_at=stamp,
expires_at=stamp + ttl_seconds,
)
# ── Worker-side credential storage ─────────────────────────────────────────
def save_worker_key(path: str, keypair: WorkerKeypair) -> None:
"""Persist a worker's private key with 0600 permissions.
Follows the repo's existing precedent for machine-local secrets
(``core/user_env.py``): a mode-restricted file, written atomically, never
world-readable even briefly.
"""
directory = os.path.dirname(os.path.abspath(path))
os.makedirs(directory, exist_ok=True)
tmp = f"{path}.tmp"
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
try:
os.write(fd, keypair.private_bytes())
finally:
os.close(fd)
os.replace(tmp, path)
try:
os.chmod(path, 0o600)
except OSError:
# Windows and some network filesystems do not honour POSIX modes; the
# key is still in a per-user directory there.
pass
def load_worker_key(path: str) -> Optional[WorkerKeypair]:
try:
with open(path, "rb") as fh:
raw = fh.read()
except (FileNotFoundError, PermissionError):
return None
if len(raw) != 32:
return None
try:
return WorkerKeypair.from_private_bytes(raw)
except ValueError:
return None
def load_or_create_worker_key(path: str) -> WorkerKeypair:
existing = load_worker_key(path)
if existing is not None:
return existing
keypair = WorkerKeypair.generate()
save_worker_key(path, keypair)
return keypair
__all__ = [
"ENROLLMENT_PREFIX",
"SESSION_PREFIX",
"EnrollmentToken",
"Session",
"WorkerKeypair",
"certificate_fingerprint",
"challenge_message",
"constant_time_equals",
"hash_secret",
"issue_session",
"key_id_for",
"load_or_create_worker_key",
"load_worker_key",
"mint_enrollment_token",
"new_challenge",
"public_key_bytes",
"save_worker_key",
"verify_signature",
]
+680
View File
@@ -0,0 +1,680 @@
"""Task and attempt lifecycle.
The original goal doc had a contradiction the council flagged as its single
worst correctness bug: ``§10`` reassigned a task the moment a worker
disconnected, while ``§21`` described the case where that same worker had
already finished the work and lost the connection before the acknowledgement.
Following both rules at once guarantees duplicate execution two GPUs burning
on the same dub, two results racing to commit.
The fix is to stop treating a disconnect as a failure. A disconnect is an
**unknown outcome**. The distinction is carried structurally here:
* ``TaskState`` what the *task* is doing. One per task.
* ``AttemptState`` what one *try* is doing. Many per task.
A disconnected attempt does not fail; it stops renewing its lease. Only when
the lease expires (grace window) does it become ``ATTEMPT_LOST`` and free the
task to be retried. If the worker reconnects inside the window carrying a
finished result, that result commits and no second attempt was ever made.
Commit semantics: **at-least-once execution, exactly-once result commit**. The
first attempt to durably commit wins; any later duplicate is acknowledged and
discarded so the worker stops redelivering, and its losing sibling is
cancelled. This is why ``commit_result`` is idempotent on ``task_id`` rather
than on ``attempt_id``.
"""
from __future__ import annotations
import enum
import time
import uuid
from dataclasses import dataclass, field
from typing import Iterable, Optional
from worker.clock import resolve
from worker.errors import ErrorClass, WorkerError
class TaskState(str, enum.Enum):
QUEUED = "queued"
ASSIGNED = "assigned"
ACCEPTED = "accepted"
MODEL_LOADING = "model_loading"
RUNNING = "running"
RESULT_UPLOADING = "result_uploading"
COMPLETED = "completed"
FAILED = "failed"
TIMEOUT = "timeout"
CANCELLED = "cancelled"
@property
def terminal(self) -> bool:
return self in _TERMINAL_TASK_STATES
@property
def in_flight(self) -> bool:
"""Is a worker actively holding this task right now?"""
return self in _IN_FLIGHT_TASK_STATES
_TERMINAL_TASK_STATES = frozenset(
{TaskState.COMPLETED, TaskState.FAILED, TaskState.TIMEOUT, TaskState.CANCELLED}
)
_IN_FLIGHT_TASK_STATES = frozenset(
{
TaskState.ASSIGNED,
TaskState.ACCEPTED,
TaskState.MODEL_LOADING,
TaskState.RUNNING,
TaskState.RESULT_UPLOADING,
}
)
# Legal task transitions. Anything absent is a bug, not a warning: an
# unexpected transition means two code paths disagree about who owns a task.
_TASK_TRANSITIONS: dict[TaskState, frozenset[TaskState]] = {
TaskState.QUEUED: frozenset({TaskState.ASSIGNED, TaskState.CANCELLED, TaskState.TIMEOUT, TaskState.FAILED}),
# Back to QUEUED on retry — assignment timeout, rejection, or a lost attempt.
TaskState.ASSIGNED: frozenset(
{TaskState.ACCEPTED, TaskState.QUEUED, TaskState.CANCELLED, TaskState.TIMEOUT, TaskState.FAILED}
),
TaskState.ACCEPTED: frozenset(
{
TaskState.MODEL_LOADING,
TaskState.RUNNING,
TaskState.QUEUED,
TaskState.CANCELLED,
TaskState.TIMEOUT,
TaskState.FAILED,
}
),
TaskState.MODEL_LOADING: frozenset(
{TaskState.RUNNING, TaskState.QUEUED, TaskState.CANCELLED, TaskState.TIMEOUT, TaskState.FAILED}
),
TaskState.RUNNING: frozenset(
{
# Back to MODEL_LOADING: a running attempt that loads a second
# model reports it, and a model_loading frame can simply arrive
# after the started frame that overtook it. Neither is a
# disagreement about who owns the task, and raising here killed the
# whole session because the read loop had nothing to catch it.
TaskState.MODEL_LOADING,
TaskState.RESULT_UPLOADING,
TaskState.COMPLETED,
TaskState.QUEUED,
TaskState.CANCELLED,
TaskState.TIMEOUT,
TaskState.FAILED,
}
),
TaskState.RESULT_UPLOADING: frozenset(
{TaskState.COMPLETED, TaskState.QUEUED, TaskState.CANCELLED, TaskState.TIMEOUT, TaskState.FAILED}
),
TaskState.COMPLETED: frozenset(),
TaskState.FAILED: frozenset(),
TaskState.TIMEOUT: frozenset(),
TaskState.CANCELLED: frozenset(),
}
class AttemptState(str, enum.Enum):
ASSIGNED = "assigned"
ACCEPTED = "accepted"
MODEL_LOADING = "model_loading"
RUNNING = "running"
UPLOADING = "uploading"
# Result received and durably committed. Only now may the worker drop it.
COMMITTED = "committed"
REJECTED = "rejected"
FAILED = "failed"
TIMED_OUT = "timed_out"
CANCELLED = "cancelled"
# Worker vanished and the grace window expired. NOT a failure — we simply
# never learned the outcome.
LOST = "lost"
# Finished, but another attempt committed first. Ack it and drop it.
SUPERSEDED = "superseded"
@property
def terminal(self) -> bool:
return self in _TERMINAL_ATTEMPT_STATES
@property
def live(self) -> bool:
return self in _LIVE_ATTEMPT_STATES
_TERMINAL_ATTEMPT_STATES = frozenset(
{
AttemptState.COMMITTED,
AttemptState.REJECTED,
AttemptState.FAILED,
AttemptState.TIMED_OUT,
AttemptState.CANCELLED,
AttemptState.LOST,
AttemptState.SUPERSEDED,
}
)
_LIVE_ATTEMPT_STATES = frozenset(
{
AttemptState.ASSIGNED,
AttemptState.ACCEPTED,
AttemptState.MODEL_LOADING,
AttemptState.RUNNING,
AttemptState.UPLOADING,
}
)
# Attempt state → the task state it implies while it is the active attempt.
_ATTEMPT_TO_TASK: dict[AttemptState, TaskState] = {
AttemptState.ASSIGNED: TaskState.ASSIGNED,
AttemptState.ACCEPTED: TaskState.ACCEPTED,
AttemptState.MODEL_LOADING: TaskState.MODEL_LOADING,
AttemptState.RUNNING: TaskState.RUNNING,
AttemptState.UPLOADING: TaskState.RESULT_UPLOADING,
}
class LifecycleError(RuntimeError):
"""An illegal transition was attempted."""
class PriorityClass(int, enum.Enum):
"""Two classes, not four.
A single-user desktop has no fairness problem to solve, and four levels
plus aging is a tuning surface nobody can test. What actually differs is
whether a human is waiting: dictation and previews are INTERACTIVE, dubs
and audiobooks are BATCH.
"""
INTERACTIVE = 0
BATCH = 1
@dataclass
class Attempt:
"""One try of a task on one worker."""
attempt_id: str
task_id: str
worker_id: str
session_epoch: int
attempt_number: int
state: AttemptState = AttemptState.ASSIGNED
created_at: float = field(default_factory=time.time)
accepted_at: Optional[float] = None
started_at: Optional[float] = None
finished_at: Optional[float] = None
# When this attempt entered its current phase. The anchor a keepalive is
# measured against: a lease renewal that says only "still alive" may not
# push past the phase's own budget, or the budget stops existing. Not
# persisted — after a restart the phase timestamps above stand in for it,
# and a phase we cannot date is one we should not be enforcing a ceiling on.
phase_started_at: Optional[float] = None
# Progress lease: renewed by every progress/model-loading message. Liveness
# is "is it still moving", not "has the clock run out" — a 40-minute dub is
# not a hung task (docs/remote-workers.md).
lease_expires_at: Optional[float] = None
# Set when the worker's stream drops. The attempt is NOT failed yet.
disconnected_at: Optional[float] = None
grace_expires_at: Optional[float] = None
progress: float = 0.0
stage: str = ""
error: Optional[WorkerError] = None
def matches(self, *, session_epoch: Optional[int] = None) -> bool:
"""Fence check: reject messages from a superseded session."""
if session_epoch is None:
return True
return session_epoch == self.session_epoch
def renew_lease(
self, seconds: float, *, not_after: Optional[float] = None, now: Optional[float] = None
) -> None:
"""Extend the lease by ``seconds``, never past ``not_after``.
The ceiling is what separates "still alive" from "still working": a
keepalive renewal is capped at the phase's absolute budget, so a wedged
worker whose timer keeps firing still runs out, while a frame carrying
real progress renews without one.
"""
expiry = resolve(now) + seconds
if not_after is not None:
expiry = min(expiry, not_after)
self.lease_expires_at = expiry
# Progress proves the worker is alive, which clears any pending
# disconnect bookkeeping from a reconnect mid-task.
self.disconnected_at = None
self.grace_expires_at = None
@property
def phase_anchor(self) -> float:
"""When the current phase began, as well as we can date it.
Explicit ``is not None`` at every step: these are wall-clock stamps and
``0.0`` is a legitimate one (``clock.resolve`` exists for the same
reason), so an ``or`` chain would skip a pinned test clock.
"""
for stamp in (self.phase_started_at, self.started_at, self.accepted_at):
if stamp is not None:
return stamp
return self.created_at
def lease_expired(self, *, now: Optional[float] = None) -> bool:
if self.lease_expires_at is None:
return False
return resolve(now) > self.lease_expires_at
def grace_expired(self, *, now: Optional[float] = None) -> bool:
if self.grace_expires_at is None:
return False
return resolve(now) > self.grace_expires_at
def to_dict(self) -> dict:
return {
"attempt_id": self.attempt_id,
"task_id": self.task_id,
"worker_id": self.worker_id,
"attempt_number": self.attempt_number,
"state": self.state.value,
"progress": self.progress,
"stage": self.stage,
"error": self.error.to_dict() if self.error else None,
}
@dataclass
class Task:
"""A unit of inference work, independent of which worker runs it."""
task_id: str
operation: str
engine: str
model_id: str
params: dict = field(default_factory=dict)
priority: PriorityClass = PriorityClass.INTERACTIVE
# Supplied by the client so a client-side retry does not create a second
# task. Deduplication has to happen at the API boundary, before the worker
# protocol is involved at all.
idempotency_key: Optional[str] = None
state: TaskState = TaskState.QUEUED
max_attempts: int = 3
attempts: list[Attempt] = field(default_factory=list)
created_at: float = field(default_factory=time.time)
finished_at: Optional[float] = None
deadline_at: Optional[float] = None
error: Optional[WorkerError] = None
result_ref: Optional[str] = None
# An explicit routing choice is a hard affinity, not a ranking hint.
pinned_worker_id: Optional[str] = None
# Workers this task must not be sent to again: each failed attempt excludes
# its worker so a retry is genuinely a different try, not the same one.
excluded_workers: set[str] = field(default_factory=set)
# ── Queries ───────────────────────────────────────────────────────────
@property
def active_attempt(self) -> Optional[Attempt]:
for attempt in reversed(self.attempts):
if attempt.state.live:
return attempt
return None
@property
def attempt_count(self) -> int:
return len(self.attempts)
@property
def attempts_remaining(self) -> int:
# Capacity rejections and a stream disappearing before dispatch are
# advisory races, not executions. Keep their audit rows, but do not
# spend the retry budget on work that never started.
charged = sum(
1
for attempt in self.attempts
if not (
attempt.error is not None
and (
attempt.error.error_class is ErrorClass.CAPACITY
or attempt.error.code == "WORKER_UNREACHABLE"
)
)
)
return max(0, self.max_attempts - charged)
def get_attempt(self, attempt_id: str) -> Optional[Attempt]:
for attempt in self.attempts:
if attempt.attempt_id == attempt_id:
return attempt
return None
def deadline_exceeded(self, *, now: Optional[float] = None) -> bool:
if self.deadline_at is None:
return False
return resolve(now) > self.deadline_at
# ── Transitions ───────────────────────────────────────────────────────
def _set_state(self, new: TaskState, *, now: Optional[float] = None) -> None:
if new is self.state:
return
allowed = _TASK_TRANSITIONS.get(self.state, frozenset())
if new not in allowed:
raise LifecycleError(f"illegal task transition {self.state.value}{new.value}")
self.state = new
if new.terminal:
self.finished_at = resolve(now)
def assign(self, *, worker_id: str, session_epoch: int, now: Optional[float] = None) -> Attempt:
"""Create the next attempt on ``worker_id``."""
if self.state is not TaskState.QUEUED:
raise LifecycleError(f"cannot assign a task in state {self.state.value}")
if self.attempts_remaining <= 0:
raise LifecycleError("no attempts remaining")
if worker_id in self.excluded_workers:
raise LifecycleError(f"worker {worker_id} is excluded from this task")
attempt = Attempt(
attempt_id=uuid.uuid4().hex[:16],
task_id=self.task_id,
worker_id=worker_id,
session_epoch=session_epoch,
attempt_number=self.attempt_count + 1,
created_at=resolve(now),
)
self.attempts.append(attempt)
self._set_state(TaskState.ASSIGNED, now=now)
return attempt
def _advance_attempt(
self,
attempt_id: str,
new: AttemptState,
*,
session_epoch: Optional[int] = None,
now: Optional[float] = None,
) -> Attempt:
attempt = self.get_attempt(attempt_id)
if attempt is None:
raise LifecycleError(f"unknown attempt {attempt_id}")
if not attempt.matches(session_epoch=session_epoch):
raise LifecycleError("stale session epoch")
if attempt.state.terminal:
raise LifecycleError(f"attempt {attempt_id} already terminal ({attempt.state.value})")
if new is not attempt.state:
attempt.phase_started_at = resolve(now)
attempt.state = new
implied = _ATTEMPT_TO_TASK.get(new)
if implied is not None:
self._set_state(implied, now=now)
return attempt
def accept(self, attempt_id: str, **kw) -> Attempt:
attempt = self._advance_attempt(attempt_id, AttemptState.ACCEPTED, **kw)
attempt.accepted_at = resolve(kw.get("now"))
return attempt
def model_loading(self, attempt_id: str, **kw) -> Attempt:
return self._advance_attempt(attempt_id, AttemptState.MODEL_LOADING, **kw)
def start(self, attempt_id: str, **kw) -> Attempt:
attempt = self._advance_attempt(attempt_id, AttemptState.RUNNING, **kw)
attempt.started_at = resolve(kw.get("now"))
return attempt
def uploading(self, attempt_id: str, **kw) -> Attempt:
return self._advance_attempt(attempt_id, AttemptState.UPLOADING, **kw)
def commit_result(
self,
attempt_id: str,
*,
result_ref: Optional[str] = None,
session_epoch: Optional[int] = None,
now: Optional[float] = None,
) -> tuple[bool, Attempt]:
"""Durably commit an attempt's result.
Returns ``(committed, attempt)``. ``committed`` is False when another
attempt already won the caller must still acknowledge the message so
the worker stops redelivering, but must not apply the result twice.
Idempotent on the *task*: this is what makes at-least-once delivery
safe without claiming exactly-once execution.
"""
attempt = self.get_attempt(attempt_id)
if attempt is None:
raise LifecycleError(f"unknown attempt {attempt_id}")
if not attempt.matches(session_epoch=session_epoch):
raise LifecycleError("stale session epoch")
if self.state is TaskState.CANCELLED:
# Cancellation is authoritative. A worker may be unable to stop a
# native GPU call, but its late result cannot resurrect the task.
if not attempt.state.terminal:
attempt.state = AttemptState.CANCELLED
attempt.finished_at = resolve(now)
return False, attempt
if self.state is TaskState.COMPLETED:
# A duplicate. Ack-and-discard; never a second commit.
if attempt.state is not AttemptState.COMMITTED:
attempt.state = AttemptState.SUPERSEDED
attempt.finished_at = resolve(now)
return False, attempt
if attempt.state is AttemptState.COMMITTED:
return False, attempt
if attempt.state.terminal:
# Late result from an attempt we already wrote off (typically LOST
# after a grace expiry). It still wins if nothing else committed —
# the work is real and discarding it would waste a finished dub.
attempt.state = AttemptState.COMMITTED
else:
attempt.state = AttemptState.COMMITTED
attempt.finished_at = resolve(now)
attempt.progress = 1.0
self.result_ref = result_ref
# Jump straight to COMPLETED regardless of the intermediate state we
# believed we were in: the result is proof of what actually happened.
self.state = TaskState.COMPLETED
self.finished_at = attempt.finished_at
# Any sibling still running lost the race.
for other in self.attempts:
if other is attempt or other.state.terminal:
continue
other.state = AttemptState.SUPERSEDED
other.finished_at = attempt.finished_at
return True, attempt
def fail_attempt(
self,
attempt_id: str,
error: WorkerError,
*,
session_epoch: Optional[int] = None,
now: Optional[float] = None,
) -> Attempt:
"""Record an attempt failure and requeue the task if retries remain."""
attempt = self.get_attempt(attempt_id)
if attempt is None:
raise LifecycleError(f"unknown attempt {attempt_id}")
if not attempt.matches(session_epoch=session_epoch):
raise LifecycleError("stale session epoch")
if attempt.state.terminal:
return attempt
stamp = resolve(now)
attempt.error = error
attempt.finished_at = stamp
if error.error_class is ErrorClass.CAPACITY:
attempt.state = AttemptState.REJECTED
elif error.error_class is ErrorClass.TIMEOUT:
attempt.state = AttemptState.TIMED_OUT
else:
attempt.state = AttemptState.FAILED
# A worker that declined for capacity is not excluded — it was right,
# and it will have room later. Everything else gets excluded so a
# retry is a genuinely different try.
if error.error_class is not ErrorClass.CAPACITY and not self.pinned_worker_id:
self.excluded_workers.add(attempt.worker_id)
self._settle_after_attempt(error, now=stamp)
return attempt
def lose_attempt(self, attempt_id: str, *, now: Optional[float] = None) -> Attempt:
"""The grace window expired without word from the worker.
Unknown outcome, not failure: the worker is excluded (we cannot ask it
again) but it is NOT charged a breaker failure, because a home network
dropping for 60 seconds says nothing about the GPU.
"""
attempt = self.get_attempt(attempt_id)
if attempt is None:
raise LifecycleError(f"unknown attempt {attempt_id}")
if attempt.state.terminal:
return attempt
stamp = resolve(now)
attempt.state = AttemptState.LOST
attempt.finished_at = stamp
if not self.pinned_worker_id:
self.excluded_workers.add(attempt.worker_id)
self._settle_after_attempt(
WorkerError(
error_class=ErrorClass.TRANSIENT,
code="WORKER_DISCONNECTED",
message="The worker stopped responding and did not reconnect in time.",
hint="The task will be retried on another worker if one is available.",
),
now=stamp,
)
return attempt
def _settle_after_attempt(self, error: WorkerError, *, now: float) -> None:
"""Decide between retry and terminal failure after an attempt ends."""
if self.deadline_exceeded(now=now):
self.error = WorkerError(
error_class=ErrorClass.TIMEOUT,
code="TASK_DEADLINE_EXCEEDED",
message="The task ran past its overall deadline.",
hint=error.hint,
)
self._set_state(TaskState.TIMEOUT, now=now)
return
if not error.retryable or self.attempts_remaining <= 0:
self.error = (
WorkerError(
error_class=error.error_class,
code="PINNED_WORKER_EXHAUSTED",
message=f"The selected worker {self.pinned_worker_id} could not finish the task.",
hint="Wake or repair that worker, choose another GPU, or run locally.",
)
if self.pinned_worker_id and self.attempts_remaining <= 0
else error
)
self._set_state(
TaskState.TIMEOUT if error.error_class is ErrorClass.TIMEOUT else TaskState.FAILED,
now=now,
)
return
# Retryable and budget remains — back to the queue for a different worker.
self._set_state(TaskState.QUEUED, now=now)
def cancel(self, *, reason: str = "cancelled by user", now: Optional[float] = None) -> None:
if self.state.terminal:
return
stamp = resolve(now)
for attempt in self.attempts:
if not attempt.state.terminal:
attempt.state = AttemptState.CANCELLED
attempt.finished_at = stamp
self.error = WorkerError(
error_class=ErrorClass.TERMINAL, code="CANCELLED", message=reason
)
self._set_state(TaskState.CANCELLED, now=stamp)
def mark_disconnected(
self, attempt_id: str, *, grace_seconds: float, now: Optional[float] = None
) -> Optional[Attempt]:
"""The worker's stream dropped. Start the grace window; fail nothing.
This is the whole §10-vs-§21 fix in one method: we record that we have
stopped hearing from the worker, and we wait. If it reconnects with a
result, that result commits and no duplicate work ever happened.
"""
attempt = self.get_attempt(attempt_id)
if attempt is None or attempt.state.terminal:
return None
stamp = resolve(now)
attempt.disconnected_at = stamp
attempt.grace_expires_at = stamp + grace_seconds
return attempt
def to_dict(self) -> dict:
return {
"task_id": self.task_id,
"operation": self.operation,
"engine": self.engine,
"model_id": self.model_id,
"state": self.state.value,
"priority": int(self.priority),
"attempts": [a.to_dict() for a in self.attempts],
"max_attempts": self.max_attempts,
"error": self.error.to_dict() if self.error else None,
"result_ref": self.result_ref,
}
def reconcile(
task: Task,
*,
worker_id: str,
worker_in_flight: Iterable[str],
resume_lease_seconds: float,
now: Optional[float] = None,
) -> Optional[str]:
"""Reconcile one task against what a reconnecting worker claims to hold.
Returns an action for the caller: ``"resume"`` (worker is still validly
running it), ``"cancel_zombie"`` (worker is running something we have
already written off tell it to stop), or ``None`` (nothing to do).
Without this, a control-plane restart orphans every live task: the server
forgets, the worker keeps burning GPU, and the user sees a spinner that
never resolves.
``resume_lease_seconds`` is required rather than defaulted because deadline
policy belongs to the caller and because a resume that cleared the
disconnect bookkeeping without renewing the lease left the attempt holding
an expiry stamped before the outage, so the very next sweep failed the task
it had just recovered.
"""
claimed = set(worker_in_flight)
attempt = task.active_attempt
if attempt is not None and attempt.worker_id == worker_id:
if attempt.attempt_id in claimed:
# renew_lease clears disconnected_at/grace_expires_at itself.
attempt.renew_lease(resume_lease_seconds, now=now)
return "resume"
# We think it is running; the worker says otherwise. The worker is the
# source of truth for what is executing on it.
task.lose_attempt(attempt.attempt_id, now=now)
return None
for attempt_id in claimed:
known = task.get_attempt(attempt_id)
if known is None or known.state.terminal:
return "cancel_zombie"
return None
__all__ = [
"Attempt",
"AttemptState",
"LifecycleError",
"PriorityClass",
"Task",
"TaskState",
"reconcile",
]
+297
View File
@@ -0,0 +1,297 @@
"""Live worker state.
Everything here is in-memory and rebuilt from reconnection, by design: sessions,
capacity snapshots, latency, breaker state. A desktop control plane restarts
constantly, and none of this is worth persisting when the worker itself will
tell us the truth the moment it reconnects.
What the pool owns is the *current* picture who is connected, on which epoch,
with what free capacity and which models warm. What it deliberately does not
own is anything durable (``registry``) or any scheduling policy
(``scheduler``).
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from typing import Iterator, Optional
from worker.breaker import BreakerRegistry
from worker.capacity import WorkerCapacity, derive_concurrency
from worker.clock import resolve
from worker.identity import Session
from worker.registry import RemoteWorker
logger = logging.getLogger("omnivoice.worker")
# A worker that has not been heard from in this long is treated as gone even if
# the socket has not reported it. Half-open TCP through an expired CGNAT
# mapping looks identical to a healthy idle connection until you ask.
_HEARTBEAT_MISS_SECONDS = 90.0
# How many round-trip samples the median is taken over. Five at a five-second
# ping is a ~25-second view: current enough to notice a link degrading, long
# enough that one slow answer cannot move it.
_LATENCY_WINDOW = 5
@dataclass
class ConnectedWorker:
"""One live worker session."""
record: RemoteWorker
session: Session
epoch: int
capacity: WorkerCapacity
connected_at: float
last_heartbeat_at: float
latency_ms: float = 0.0
# Recent round-trip samples. A median over these rather than a running
# average, because the first sample after connect is routinely an outlier
# — the worker is still importing torch and loading models, so its event
# loop answers the ping late. One 139 ms startup spike would otherwise
# dominate an average for a minute and read as a broken link.
latency_samples: list[float] = field(default_factory=list)
# The address this worker connected FROM, as the control plane saw it.
address: str = ""
draining: bool = False
# Attempt ids this worker claims to be running. Rebuilt on every reconnect
# from its own report, never inferred.
in_flight: set[str] = field(default_factory=set)
@property
def worker_id(self) -> str:
return self.record.id
@property
def name(self) -> str:
return self.record.name
def stale(self, *, now: Optional[float] = None) -> bool:
return resolve(now) - self.last_heartbeat_at > _HEARTBEAT_MISS_SECONDS
@property
def status(self) -> str:
"""What the UI colours on: ready, busy, or gone.
Draining counts as busy rather than offline it is still finishing
work, and calling it offline would imply the results are lost.
"""
if self.stale():
return "offline"
if self.draining or self.capacity.available_slots <= 0:
return "busy"
return "ready"
def supports(self, engine: str, model_id: str, operation: str) -> bool:
"""Can this worker run this work at all?
``supported`` alone is not enough an engine whose weights are not on
disk cannot start without a download, and one that is not installed
cannot start at all. Both are capability mismatches, not failures.
"""
for cap in self.record.capabilities:
if cap.get("engine") != engine:
continue
if model_id and cap.get("model_id") not in (model_id, "", None):
continue
if operation and operation not in (cap.get("operations") or [operation]):
continue
return bool(cap.get("supported")) and bool(cap.get("installed", True))
return False
def is_warm(self, engine: str, model_id: str) -> bool:
return self.capacity.is_resident(engine, model_id)
def to_dict(self, *, now: Optional[float] = None) -> dict:
return {
**self.record.to_dict(),
"connected": True,
"draining": self.draining,
"latency_ms": round(self.latency_ms, 1),
"address": self.address,
"status": self.status,
"active_tasks": self.capacity.active_tasks,
"available_slots": self.capacity.available_slots,
"resident_models": sorted(self.capacity.resident_models),
"stale": self.stale(now=now),
}
class WorkerPool:
"""The set of workers currently connected, plus their breakers."""
def __init__(self) -> None:
self._connected: dict[str, ConnectedWorker] = {}
self.breakers = BreakerRegistry()
# ── Membership ────────────────────────────────────────────────────────
def connect(
self,
record: RemoteWorker,
*,
session: Session,
epoch: int,
max_concurrent_tasks: int = 1,
backend: str = "",
in_flight: Optional[set[str]] = None,
address: str = "",
now: Optional[float] = None,
) -> ConnectedWorker:
"""Register a live session, replacing any previous one.
Newest epoch wins, unconditionally. Two sessions for one worker is the
race that delivers two accepts for a single assignment, so the old one
is dropped rather than merged.
"""
stamp = resolve(now)
previous = self._connected.get(record.id)
if previous is not None and previous.epoch > epoch:
raise ValueError(
f"refusing to install session epoch {epoch} over newer epoch {previous.epoch}"
)
worker = ConnectedWorker(
record=record,
session=session,
epoch=epoch,
capacity=WorkerCapacity(
worker_id=record.id,
max_concurrent_tasks=max(1, max_concurrent_tasks),
backend=backend,
),
connected_at=stamp,
last_heartbeat_at=stamp,
address=address,
in_flight=set(in_flight or set()),
)
self._connected[record.id] = worker
self.breakers.note_worker(record.id)
if previous is not None:
logger.info("Worker %s reconnected (epoch %d%d)", record.name, previous.epoch, epoch)
return worker
def record_latency(self, worker_id: str, latency_ms: float) -> None:
"""Record a measured round trip and republish the median.
Median, not mean: a consumer link jitters, and a worker busy loading a
model answers late. Both produce outliers that an average carries for
a long time and a median ignores outright.
Nothing is published until a second sample arrives, so the startup
outlier is never shown the UI treats 0 as "not measured yet" and
simply omits the figure.
"""
live = self._connected.get(worker_id)
if live is None:
return
samples = live.latency_samples
samples.append(latency_ms)
del samples[:-_LATENCY_WINDOW]
if len(samples) < 2:
return
ordered = sorted(samples)
middle = len(ordered) // 2
live.latency_ms = (
ordered[middle]
if len(ordered) % 2
else (ordered[middle - 1] + ordered[middle]) / 2
)
def refresh_record(self, record: RemoteWorker) -> None:
"""Adopt an updated database row for a live worker.
The pool caches the RemoteWorker it was handed at connect time. Every
registry write rename, priority, enable makes that copy wrong until
the worker reconnects, so writers refresh it here rather than leaving
two disagreeing answers in memory.
"""
live = self._connected.get(record.id)
if live is not None:
live.record = record
def disconnect(self, worker_id: str) -> Optional[ConnectedWorker]:
return self._connected.pop(worker_id, None)
def get(self, worker_id: str) -> Optional[ConnectedWorker]:
return self._connected.get(worker_id)
def __iter__(self) -> Iterator[ConnectedWorker]:
return iter(list(self._connected.values()))
def __len__(self) -> int:
return len(self._connected)
@property
def connected_ids(self) -> set[str]:
return set(self._connected)
# ── Session validity ──────────────────────────────────────────────────
def valid_epoch(self, worker_id: str, epoch: int) -> bool:
"""Fence: is this message from the session we currently believe in?"""
worker = self._connected.get(worker_id)
return worker is not None and worker.epoch == epoch
# ── Heartbeats ────────────────────────────────────────────────────────
def heartbeat(
self,
worker_id: str,
*,
active_tasks: int,
available_slots: int,
resident_models: Optional[set[str]] = None,
free_memory_bytes: Optional[int] = None,
latency_ms: Optional[float] = None,
now: Optional[float] = None,
) -> Optional[ConnectedWorker]:
worker = self._connected.get(worker_id)
if worker is None:
return None
worker.last_heartbeat_at = resolve(now)
if latency_ms is not None:
worker.latency_ms = latency_ms
worker.capacity.apply_snapshot(
active_tasks=active_tasks,
available_slots=available_slots,
resident_models=resident_models,
free_memory_bytes=free_memory_bytes,
)
return worker
def apply_capabilities(self, worker_id: str, capabilities: list[dict]) -> None:
"""Refresh what a worker can run, and re-derive its per-model slots."""
worker = self._connected.get(worker_id)
if worker is None:
return
worker.record.capabilities = capabilities
for cap in capabilities:
key = WorkerCapacity.slot_key(cap.get("engine", ""), cap.get("model_id", ""))
slot = worker.capacity.slots.get(key)
declared = int(cap.get("derived_concurrency") or 0)
if declared <= 0:
declared = derive_concurrency(
backend=cap.get("backend", worker.capacity.backend),
free_memory_bytes=int(cap.get("free_memory_bytes") or 0),
min_model_bytes=int(cap.get("min_memory_bytes") or 0),
)
if slot is None:
from worker.capacity import ModelSlot # noqa: PLC0415 — avoids a cycle
worker.capacity.slots[key] = ModelSlot(
engine=cap.get("engine", ""),
model_id=cap.get("model_id", ""),
derived_concurrency=max(0, declared),
)
else:
slot.derived_concurrency = max(0, declared)
def stale_workers(self, *, now: Optional[float] = None) -> list[ConnectedWorker]:
return [w for w in self if w.stale(now=now)]
def snapshot(self, *, now: Optional[float] = None) -> list[dict]:
return [w.to_dict(now=now) for w in self]
__all__ = ["ConnectedWorker", "WorkerPool"]
+5
View File
@@ -0,0 +1,5 @@
"""Generated protocol stubs — DO NOT EDIT.
Regenerate with ``uv run python scripts/gen_worker_protocol.py`` after any
change to ``../worker_v1.proto``.
"""
File diff suppressed because one or more lines are too long
@@ -0,0 +1,579 @@
from google.protobuf.internal import containers as _containers
from google.protobuf.internal import enum_type_wrapper as _enum_type_wrapper
from google.protobuf import descriptor as _descriptor
from google.protobuf import message as _message
from collections.abc import Iterable as _Iterable, Mapping as _Mapping
from typing import ClassVar as _ClassVar, Optional as _Optional, Union as _Union
DESCRIPTOR: _descriptor.FileDescriptor
class ErrorClass(int, metaclass=_enum_type_wrapper.EnumTypeWrapper):
__slots__ = ()
ERROR_CLASS_UNSPECIFIED: _ClassVar[ErrorClass]
ERROR_CLASS_TRANSIENT: _ClassVar[ErrorClass]
ERROR_CLASS_CAPABILITY: _ClassVar[ErrorClass]
ERROR_CLASS_TERMINAL: _ClassVar[ErrorClass]
ERROR_CLASS_CAPACITY: _ClassVar[ErrorClass]
ERROR_CLASS_TIMEOUT: _ClassVar[ErrorClass]
ERROR_CLASS_PROTOCOL: _ClassVar[ErrorClass]
ERROR_CLASS_UNSPECIFIED: ErrorClass
ERROR_CLASS_TRANSIENT: ErrorClass
ERROR_CLASS_CAPABILITY: ErrorClass
ERROR_CLASS_TERMINAL: ErrorClass
ERROR_CLASS_CAPACITY: ErrorClass
ERROR_CLASS_TIMEOUT: ErrorClass
ERROR_CLASS_PROTOCOL: ErrorClass
class TaskRef(_message.Message):
__slots__ = ("task_id", "attempt_id", "session_epoch")
TASK_ID_FIELD_NUMBER: _ClassVar[int]
ATTEMPT_ID_FIELD_NUMBER: _ClassVar[int]
SESSION_EPOCH_FIELD_NUMBER: _ClassVar[int]
task_id: str
attempt_id: str
session_epoch: int
def __init__(self, task_id: _Optional[str] = ..., attempt_id: _Optional[str] = ..., session_epoch: _Optional[int] = ...) -> None: ...
class Envelope(_message.Message):
__slots__ = ("sequence", "trace_id", "tenant_id")
SEQUENCE_FIELD_NUMBER: _ClassVar[int]
TRACE_ID_FIELD_NUMBER: _ClassVar[int]
TENANT_ID_FIELD_NUMBER: _ClassVar[int]
sequence: int
trace_id: str
tenant_id: str
def __init__(self, sequence: _Optional[int] = ..., trace_id: _Optional[str] = ..., tenant_id: _Optional[str] = ...) -> None: ...
class Error(_message.Message):
__slots__ = ("error_class", "code", "message", "hint")
ERROR_CLASS_FIELD_NUMBER: _ClassVar[int]
CODE_FIELD_NUMBER: _ClassVar[int]
MESSAGE_FIELD_NUMBER: _ClassVar[int]
HINT_FIELD_NUMBER: _ClassVar[int]
error_class: ErrorClass
code: str
message: str
hint: str
def __init__(self, error_class: _Optional[_Union[ErrorClass, str]] = ..., code: _Optional[str] = ..., message: _Optional[str] = ..., hint: _Optional[str] = ...) -> None: ...
class GpuInfo(_message.Message):
__slots__ = ("vendor", "model", "backend", "memory_bytes", "free_memory_bytes", "driver_version", "compute_capability")
VENDOR_FIELD_NUMBER: _ClassVar[int]
MODEL_FIELD_NUMBER: _ClassVar[int]
BACKEND_FIELD_NUMBER: _ClassVar[int]
MEMORY_BYTES_FIELD_NUMBER: _ClassVar[int]
FREE_MEMORY_BYTES_FIELD_NUMBER: _ClassVar[int]
DRIVER_VERSION_FIELD_NUMBER: _ClassVar[int]
COMPUTE_CAPABILITY_FIELD_NUMBER: _ClassVar[int]
vendor: str
model: str
backend: str
memory_bytes: int
free_memory_bytes: int
driver_version: str
compute_capability: str
def __init__(self, vendor: _Optional[str] = ..., model: _Optional[str] = ..., backend: _Optional[str] = ..., memory_bytes: _Optional[int] = ..., free_memory_bytes: _Optional[int] = ..., driver_version: _Optional[str] = ..., compute_capability: _Optional[str] = ...) -> None: ...
class HostInfo(_message.Message):
__slots__ = ("hostname", "os", "arch", "worker_version", "cpu_count", "system_memory_bytes", "gpus")
HOSTNAME_FIELD_NUMBER: _ClassVar[int]
OS_FIELD_NUMBER: _ClassVar[int]
ARCH_FIELD_NUMBER: _ClassVar[int]
WORKER_VERSION_FIELD_NUMBER: _ClassVar[int]
CPU_COUNT_FIELD_NUMBER: _ClassVar[int]
SYSTEM_MEMORY_BYTES_FIELD_NUMBER: _ClassVar[int]
GPUS_FIELD_NUMBER: _ClassVar[int]
hostname: str
os: str
arch: str
worker_version: str
cpu_count: int
system_memory_bytes: int
gpus: _containers.RepeatedCompositeFieldContainer[GpuInfo]
def __init__(self, hostname: _Optional[str] = ..., os: _Optional[str] = ..., arch: _Optional[str] = ..., worker_version: _Optional[str] = ..., cpu_count: _Optional[int] = ..., system_memory_bytes: _Optional[int] = ..., gpus: _Optional[_Iterable[_Union[GpuInfo, _Mapping]]] = ...) -> None: ...
class ModelCapability(_message.Message):
__slots__ = ("engine", "model_id", "operations", "supported", "installed", "downloaded", "resident", "min_memory_bytes", "precision", "derived_concurrency", "cpu_fallback", "repo_ids", "display_name")
ENGINE_FIELD_NUMBER: _ClassVar[int]
MODEL_ID_FIELD_NUMBER: _ClassVar[int]
OPERATIONS_FIELD_NUMBER: _ClassVar[int]
SUPPORTED_FIELD_NUMBER: _ClassVar[int]
INSTALLED_FIELD_NUMBER: _ClassVar[int]
DOWNLOADED_FIELD_NUMBER: _ClassVar[int]
RESIDENT_FIELD_NUMBER: _ClassVar[int]
MIN_MEMORY_BYTES_FIELD_NUMBER: _ClassVar[int]
PRECISION_FIELD_NUMBER: _ClassVar[int]
DERIVED_CONCURRENCY_FIELD_NUMBER: _ClassVar[int]
CPU_FALLBACK_FIELD_NUMBER: _ClassVar[int]
REPO_IDS_FIELD_NUMBER: _ClassVar[int]
DISPLAY_NAME_FIELD_NUMBER: _ClassVar[int]
engine: str
model_id: str
operations: _containers.RepeatedScalarFieldContainer[str]
supported: bool
installed: bool
downloaded: bool
resident: bool
min_memory_bytes: int
precision: str
derived_concurrency: int
cpu_fallback: bool
repo_ids: _containers.RepeatedScalarFieldContainer[str]
display_name: str
def __init__(self, engine: _Optional[str] = ..., model_id: _Optional[str] = ..., operations: _Optional[_Iterable[str]] = ..., supported: _Optional[bool] = ..., installed: _Optional[bool] = ..., downloaded: _Optional[bool] = ..., resident: _Optional[bool] = ..., min_memory_bytes: _Optional[int] = ..., precision: _Optional[str] = ..., derived_concurrency: _Optional[int] = ..., cpu_fallback: _Optional[bool] = ..., repo_ids: _Optional[_Iterable[str]] = ..., display_name: _Optional[str] = ...) -> None: ...
class RegisterRequest(_message.Message):
__slots__ = ("envelope", "protocol_version_min", "protocol_version_max", "enrollment_token", "worker_id", "public_key", "challenge_signature", "challenge", "host", "capabilities", "max_concurrent_tasks", "in_flight", "completed_unacked", "key_id", "nonce", "labels", "features")
class LabelsEntry(_message.Message):
__slots__ = ("key", "value")
KEY_FIELD_NUMBER: _ClassVar[int]
VALUE_FIELD_NUMBER: _ClassVar[int]
key: str
value: str
def __init__(self, key: _Optional[str] = ..., value: _Optional[str] = ...) -> None: ...
ENVELOPE_FIELD_NUMBER: _ClassVar[int]
PROTOCOL_VERSION_MIN_FIELD_NUMBER: _ClassVar[int]
PROTOCOL_VERSION_MAX_FIELD_NUMBER: _ClassVar[int]
ENROLLMENT_TOKEN_FIELD_NUMBER: _ClassVar[int]
WORKER_ID_FIELD_NUMBER: _ClassVar[int]
PUBLIC_KEY_FIELD_NUMBER: _ClassVar[int]
CHALLENGE_SIGNATURE_FIELD_NUMBER: _ClassVar[int]
CHALLENGE_FIELD_NUMBER: _ClassVar[int]
HOST_FIELD_NUMBER: _ClassVar[int]
CAPABILITIES_FIELD_NUMBER: _ClassVar[int]
MAX_CONCURRENT_TASKS_FIELD_NUMBER: _ClassVar[int]
IN_FLIGHT_FIELD_NUMBER: _ClassVar[int]
COMPLETED_UNACKED_FIELD_NUMBER: _ClassVar[int]
KEY_ID_FIELD_NUMBER: _ClassVar[int]
NONCE_FIELD_NUMBER: _ClassVar[int]
LABELS_FIELD_NUMBER: _ClassVar[int]
FEATURES_FIELD_NUMBER: _ClassVar[int]
envelope: Envelope
protocol_version_min: int
protocol_version_max: int
enrollment_token: str
worker_id: str
public_key: bytes
challenge_signature: bytes
challenge: bytes
host: HostInfo
capabilities: _containers.RepeatedCompositeFieldContainer[ModelCapability]
max_concurrent_tasks: int
in_flight: _containers.RepeatedCompositeFieldContainer[TaskRef]
completed_unacked: _containers.RepeatedCompositeFieldContainer[TaskRef]
key_id: str
nonce: bytes
labels: _containers.ScalarMap[str, str]
features: _containers.RepeatedScalarFieldContainer[str]
def __init__(self, envelope: _Optional[_Union[Envelope, _Mapping]] = ..., protocol_version_min: _Optional[int] = ..., protocol_version_max: _Optional[int] = ..., enrollment_token: _Optional[str] = ..., worker_id: _Optional[str] = ..., public_key: _Optional[bytes] = ..., challenge_signature: _Optional[bytes] = ..., challenge: _Optional[bytes] = ..., host: _Optional[_Union[HostInfo, _Mapping]] = ..., capabilities: _Optional[_Iterable[_Union[ModelCapability, _Mapping]]] = ..., max_concurrent_tasks: _Optional[int] = ..., in_flight: _Optional[_Iterable[_Union[TaskRef, _Mapping]]] = ..., completed_unacked: _Optional[_Iterable[_Union[TaskRef, _Mapping]]] = ..., key_id: _Optional[str] = ..., nonce: _Optional[bytes] = ..., labels: _Optional[_Mapping[str, str]] = ..., features: _Optional[_Iterable[str]] = ...) -> None: ...
class RegisterResponse(_message.Message):
__slots__ = ("envelope", "worker_id", "session_token", "session_epoch", "protocol_version", "session_expires_at_unix", "heartbeat_interval_seconds", "authoritative_in_flight", "error")
ENVELOPE_FIELD_NUMBER: _ClassVar[int]
WORKER_ID_FIELD_NUMBER: _ClassVar[int]
SESSION_TOKEN_FIELD_NUMBER: _ClassVar[int]
SESSION_EPOCH_FIELD_NUMBER: _ClassVar[int]
PROTOCOL_VERSION_FIELD_NUMBER: _ClassVar[int]
SESSION_EXPIRES_AT_UNIX_FIELD_NUMBER: _ClassVar[int]
HEARTBEAT_INTERVAL_SECONDS_FIELD_NUMBER: _ClassVar[int]
AUTHORITATIVE_IN_FLIGHT_FIELD_NUMBER: _ClassVar[int]
ERROR_FIELD_NUMBER: _ClassVar[int]
envelope: Envelope
worker_id: str
session_token: str
session_epoch: int
protocol_version: int
session_expires_at_unix: int
heartbeat_interval_seconds: int
authoritative_in_flight: _containers.RepeatedCompositeFieldContainer[TaskRef]
error: Error
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")
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]
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: ...
class TaskAccepted(_message.Message):
__slots__ = ("ref", "envelope")
REF_FIELD_NUMBER: _ClassVar[int]
ENVELOPE_FIELD_NUMBER: _ClassVar[int]
ref: TaskRef
envelope: Envelope
def __init__(self, ref: _Optional[_Union[TaskRef, _Mapping]] = ..., envelope: _Optional[_Union[Envelope, _Mapping]] = ...) -> None: ...
class TaskRejected(_message.Message):
__slots__ = ("ref", "envelope", "error")
REF_FIELD_NUMBER: _ClassVar[int]
ENVELOPE_FIELD_NUMBER: _ClassVar[int]
ERROR_FIELD_NUMBER: _ClassVar[int]
ref: TaskRef
envelope: Envelope
error: Error
def __init__(self, ref: _Optional[_Union[TaskRef, _Mapping]] = ..., envelope: _Optional[_Union[Envelope, _Mapping]] = ..., error: _Optional[_Union[Error, _Mapping]] = ...) -> None: ...
class TaskModelLoading(_message.Message):
__slots__ = ("ref", "envelope", "engine", "progress", "detail", "eta_seconds")
REF_FIELD_NUMBER: _ClassVar[int]
ENVELOPE_FIELD_NUMBER: _ClassVar[int]
ENGINE_FIELD_NUMBER: _ClassVar[int]
PROGRESS_FIELD_NUMBER: _ClassVar[int]
DETAIL_FIELD_NUMBER: _ClassVar[int]
ETA_SECONDS_FIELD_NUMBER: _ClassVar[int]
ref: TaskRef
envelope: Envelope
engine: str
progress: float
detail: str
eta_seconds: int
def __init__(self, ref: _Optional[_Union[TaskRef, _Mapping]] = ..., envelope: _Optional[_Union[Envelope, _Mapping]] = ..., engine: _Optional[str] = ..., progress: _Optional[float] = ..., detail: _Optional[str] = ..., eta_seconds: _Optional[int] = ...) -> None: ...
class TaskStarted(_message.Message):
__slots__ = ("ref", "envelope")
REF_FIELD_NUMBER: _ClassVar[int]
ENVELOPE_FIELD_NUMBER: _ClassVar[int]
ref: TaskRef
envelope: Envelope
def __init__(self, ref: _Optional[_Union[TaskRef, _Mapping]] = ..., envelope: _Optional[_Union[Envelope, _Mapping]] = ...) -> None: ...
class TaskProgress(_message.Message):
__slots__ = ("ref", "envelope", "progress", "stage", "detail", "keepalive")
REF_FIELD_NUMBER: _ClassVar[int]
ENVELOPE_FIELD_NUMBER: _ClassVar[int]
PROGRESS_FIELD_NUMBER: _ClassVar[int]
STAGE_FIELD_NUMBER: _ClassVar[int]
DETAIL_FIELD_NUMBER: _ClassVar[int]
KEEPALIVE_FIELD_NUMBER: _ClassVar[int]
ref: TaskRef
envelope: Envelope
progress: float
stage: str
detail: str
keepalive: bool
def __init__(self, ref: _Optional[_Union[TaskRef, _Mapping]] = ..., envelope: _Optional[_Union[Envelope, _Mapping]] = ..., progress: _Optional[float] = ..., stage: _Optional[str] = ..., detail: _Optional[str] = ..., keepalive: _Optional[bool] = ...) -> None: ...
class UsageReport(_message.Message):
__slots__ = ("audio_seconds_in", "audio_seconds_out", "characters_in", "wall_seconds", "gpu_seconds", "model_load_seconds", "engine", "model_id")
AUDIO_SECONDS_IN_FIELD_NUMBER: _ClassVar[int]
AUDIO_SECONDS_OUT_FIELD_NUMBER: _ClassVar[int]
CHARACTERS_IN_FIELD_NUMBER: _ClassVar[int]
WALL_SECONDS_FIELD_NUMBER: _ClassVar[int]
GPU_SECONDS_FIELD_NUMBER: _ClassVar[int]
MODEL_LOAD_SECONDS_FIELD_NUMBER: _ClassVar[int]
ENGINE_FIELD_NUMBER: _ClassVar[int]
MODEL_ID_FIELD_NUMBER: _ClassVar[int]
audio_seconds_in: float
audio_seconds_out: float
characters_in: int
wall_seconds: float
gpu_seconds: float
model_load_seconds: float
engine: str
model_id: str
def __init__(self, audio_seconds_in: _Optional[float] = ..., audio_seconds_out: _Optional[float] = ..., characters_in: _Optional[int] = ..., wall_seconds: _Optional[float] = ..., gpu_seconds: _Optional[float] = ..., model_load_seconds: _Optional[float] = ..., engine: _Optional[str] = ..., model_id: _Optional[str] = ...) -> None: ...
class TaskResult(_message.Message):
__slots__ = ("ref", "envelope", "inline_payload", "artifacts", "result_json", "usage")
REF_FIELD_NUMBER: _ClassVar[int]
ENVELOPE_FIELD_NUMBER: _ClassVar[int]
INLINE_PAYLOAD_FIELD_NUMBER: _ClassVar[int]
ARTIFACTS_FIELD_NUMBER: _ClassVar[int]
RESULT_JSON_FIELD_NUMBER: _ClassVar[int]
USAGE_FIELD_NUMBER: _ClassVar[int]
ref: TaskRef
envelope: Envelope
inline_payload: bytes
artifacts: _containers.RepeatedCompositeFieldContainer[ArtifactRef]
result_json: str
usage: UsageReport
def __init__(self, ref: _Optional[_Union[TaskRef, _Mapping]] = ..., envelope: _Optional[_Union[Envelope, _Mapping]] = ..., inline_payload: _Optional[bytes] = ..., artifacts: _Optional[_Iterable[_Union[ArtifactRef, _Mapping]]] = ..., result_json: _Optional[str] = ..., usage: _Optional[_Union[UsageReport, _Mapping]] = ...) -> None: ...
class TaskFailed(_message.Message):
__slots__ = ("ref", "envelope", "error", "usage")
REF_FIELD_NUMBER: _ClassVar[int]
ENVELOPE_FIELD_NUMBER: _ClassVar[int]
ERROR_FIELD_NUMBER: _ClassVar[int]
USAGE_FIELD_NUMBER: _ClassVar[int]
ref: TaskRef
envelope: Envelope
error: Error
usage: UsageReport
def __init__(self, ref: _Optional[_Union[TaskRef, _Mapping]] = ..., envelope: _Optional[_Union[Envelope, _Mapping]] = ..., error: _Optional[_Union[Error, _Mapping]] = ..., usage: _Optional[_Union[UsageReport, _Mapping]] = ...) -> None: ...
class TaskCancelAck(_message.Message):
__slots__ = ("ref", "envelope")
REF_FIELD_NUMBER: _ClassVar[int]
ENVELOPE_FIELD_NUMBER: _ClassVar[int]
ref: TaskRef
envelope: Envelope
def __init__(self, ref: _Optional[_Union[TaskRef, _Mapping]] = ..., envelope: _Optional[_Union[Envelope, _Mapping]] = ...) -> None: ...
class Pong(_message.Message):
__slots__ = ("envelope", "nonce")
ENVELOPE_FIELD_NUMBER: _ClassVar[int]
NONCE_FIELD_NUMBER: _ClassVar[int]
envelope: Envelope
nonce: int
def __init__(self, envelope: _Optional[_Union[Envelope, _Mapping]] = ..., nonce: _Optional[int] = ...) -> None: ...
class WorkerGoodbye(_message.Message):
__slots__ = ("envelope", "reason", "abandoning")
ENVELOPE_FIELD_NUMBER: _ClassVar[int]
REASON_FIELD_NUMBER: _ClassVar[int]
ABANDONING_FIELD_NUMBER: _ClassVar[int]
envelope: Envelope
reason: str
abandoning: _containers.RepeatedCompositeFieldContainer[TaskRef]
def __init__(self, envelope: _Optional[_Union[Envelope, _Mapping]] = ..., reason: _Optional[str] = ..., abandoning: _Optional[_Iterable[_Union[TaskRef, _Mapping]]] = ...) -> None: ...
class CapabilityUpdate(_message.Message):
__slots__ = ("envelope", "capabilities")
ENVELOPE_FIELD_NUMBER: _ClassVar[int]
CAPABILITIES_FIELD_NUMBER: _ClassVar[int]
envelope: Envelope
capabilities: _containers.RepeatedCompositeFieldContainer[ModelCapability]
def __init__(self, envelope: _Optional[_Union[Envelope, _Mapping]] = ..., capabilities: _Optional[_Iterable[_Union[ModelCapability, _Mapping]]] = ...) -> None: ...
class DownloadProgress(_message.Message):
__slots__ = ("envelope", "event_json")
ENVELOPE_FIELD_NUMBER: _ClassVar[int]
EVENT_JSON_FIELD_NUMBER: _ClassVar[int]
envelope: Envelope
event_json: str
def __init__(self, envelope: _Optional[_Union[Envelope, _Mapping]] = ..., event_json: _Optional[str] = ...) -> None: ...
class WorkerMessage(_message.Message):
__slots__ = ("heartbeat", "accepted", "rejected", "model_loading", "started", "progress", "result", "failed", "cancel_ack", "capabilities", "goodbye", "pong", "download_progress")
HEARTBEAT_FIELD_NUMBER: _ClassVar[int]
ACCEPTED_FIELD_NUMBER: _ClassVar[int]
REJECTED_FIELD_NUMBER: _ClassVar[int]
MODEL_LOADING_FIELD_NUMBER: _ClassVar[int]
STARTED_FIELD_NUMBER: _ClassVar[int]
PROGRESS_FIELD_NUMBER: _ClassVar[int]
RESULT_FIELD_NUMBER: _ClassVar[int]
FAILED_FIELD_NUMBER: _ClassVar[int]
CANCEL_ACK_FIELD_NUMBER: _ClassVar[int]
CAPABILITIES_FIELD_NUMBER: _ClassVar[int]
GOODBYE_FIELD_NUMBER: _ClassVar[int]
PONG_FIELD_NUMBER: _ClassVar[int]
DOWNLOAD_PROGRESS_FIELD_NUMBER: _ClassVar[int]
heartbeat: Heartbeat
accepted: TaskAccepted
rejected: TaskRejected
model_loading: TaskModelLoading
started: TaskStarted
progress: TaskProgress
result: TaskResult
failed: TaskFailed
cancel_ack: TaskCancelAck
capabilities: CapabilityUpdate
goodbye: WorkerGoodbye
pong: Pong
download_progress: DownloadProgress
def __init__(self, heartbeat: _Optional[_Union[Heartbeat, _Mapping]] = ..., accepted: _Optional[_Union[TaskAccepted, _Mapping]] = ..., rejected: _Optional[_Union[TaskRejected, _Mapping]] = ..., model_loading: _Optional[_Union[TaskModelLoading, _Mapping]] = ..., started: _Optional[_Union[TaskStarted, _Mapping]] = ..., progress: _Optional[_Union[TaskProgress, _Mapping]] = ..., result: _Optional[_Union[TaskResult, _Mapping]] = ..., failed: _Optional[_Union[TaskFailed, _Mapping]] = ..., cancel_ack: _Optional[_Union[TaskCancelAck, _Mapping]] = ..., capabilities: _Optional[_Union[CapabilityUpdate, _Mapping]] = ..., goodbye: _Optional[_Union[WorkerGoodbye, _Mapping]] = ..., pong: _Optional[_Union[Pong, _Mapping]] = ..., download_progress: _Optional[_Union[DownloadProgress, _Mapping]] = ...) -> None: ...
class Deadlines(_message.Message):
__slots__ = ("accept_seconds", "model_load_seconds", "execution_seconds", "progress_lease_seconds", "result_delivery_seconds")
ACCEPT_SECONDS_FIELD_NUMBER: _ClassVar[int]
MODEL_LOAD_SECONDS_FIELD_NUMBER: _ClassVar[int]
EXECUTION_SECONDS_FIELD_NUMBER: _ClassVar[int]
PROGRESS_LEASE_SECONDS_FIELD_NUMBER: _ClassVar[int]
RESULT_DELIVERY_SECONDS_FIELD_NUMBER: _ClassVar[int]
accept_seconds: int
model_load_seconds: int
execution_seconds: int
progress_lease_seconds: int
result_delivery_seconds: int
def __init__(self, accept_seconds: _Optional[int] = ..., model_load_seconds: _Optional[int] = ..., execution_seconds: _Optional[int] = ..., progress_lease_seconds: _Optional[int] = ..., result_delivery_seconds: _Optional[int] = ...) -> None: ...
class TaskAssignment(_message.Message):
__slots__ = ("ref", "envelope", "operation", "engine", "model_id", "params_json", "inputs", "deadlines", "priority_class", "attempt_number", "max_attempts", "metadata")
class MetadataEntry(_message.Message):
__slots__ = ("key", "value")
KEY_FIELD_NUMBER: _ClassVar[int]
VALUE_FIELD_NUMBER: _ClassVar[int]
key: str
value: str
def __init__(self, key: _Optional[str] = ..., value: _Optional[str] = ...) -> None: ...
REF_FIELD_NUMBER: _ClassVar[int]
ENVELOPE_FIELD_NUMBER: _ClassVar[int]
OPERATION_FIELD_NUMBER: _ClassVar[int]
ENGINE_FIELD_NUMBER: _ClassVar[int]
MODEL_ID_FIELD_NUMBER: _ClassVar[int]
PARAMS_JSON_FIELD_NUMBER: _ClassVar[int]
INPUTS_FIELD_NUMBER: _ClassVar[int]
DEADLINES_FIELD_NUMBER: _ClassVar[int]
PRIORITY_CLASS_FIELD_NUMBER: _ClassVar[int]
ATTEMPT_NUMBER_FIELD_NUMBER: _ClassVar[int]
MAX_ATTEMPTS_FIELD_NUMBER: _ClassVar[int]
METADATA_FIELD_NUMBER: _ClassVar[int]
ref: TaskRef
envelope: Envelope
operation: str
engine: str
model_id: str
params_json: str
inputs: _containers.RepeatedCompositeFieldContainer[ArtifactRef]
deadlines: Deadlines
priority_class: int
attempt_number: int
max_attempts: int
metadata: _containers.ScalarMap[str, str]
def __init__(self, ref: _Optional[_Union[TaskRef, _Mapping]] = ..., envelope: _Optional[_Union[Envelope, _Mapping]] = ..., operation: _Optional[str] = ..., engine: _Optional[str] = ..., model_id: _Optional[str] = ..., params_json: _Optional[str] = ..., inputs: _Optional[_Iterable[_Union[ArtifactRef, _Mapping]]] = ..., deadlines: _Optional[_Union[Deadlines, _Mapping]] = ..., priority_class: _Optional[int] = ..., attempt_number: _Optional[int] = ..., max_attempts: _Optional[int] = ..., metadata: _Optional[_Mapping[str, str]] = ...) -> None: ...
class TaskCancel(_message.Message):
__slots__ = ("ref", "envelope", "reason")
REF_FIELD_NUMBER: _ClassVar[int]
ENVELOPE_FIELD_NUMBER: _ClassVar[int]
REASON_FIELD_NUMBER: _ClassVar[int]
ref: TaskRef
envelope: Envelope
reason: str
def __init__(self, ref: _Optional[_Union[TaskRef, _Mapping]] = ..., envelope: _Optional[_Union[Envelope, _Mapping]] = ..., reason: _Optional[str] = ...) -> None: ...
class ResultAckMessage(_message.Message):
__slots__ = ("ref", "envelope")
REF_FIELD_NUMBER: _ClassVar[int]
ENVELOPE_FIELD_NUMBER: _ClassVar[int]
ref: TaskRef
envelope: Envelope
def __init__(self, ref: _Optional[_Union[TaskRef, _Mapping]] = ..., envelope: _Optional[_Union[Envelope, _Mapping]] = ...) -> None: ...
class ConfigUpdate(_message.Message):
__slots__ = ("envelope", "heartbeat_interval_seconds", "max_concurrent_tasks", "inline_result_threshold_bytes")
ENVELOPE_FIELD_NUMBER: _ClassVar[int]
HEARTBEAT_INTERVAL_SECONDS_FIELD_NUMBER: _ClassVar[int]
MAX_CONCURRENT_TASKS_FIELD_NUMBER: _ClassVar[int]
INLINE_RESULT_THRESHOLD_BYTES_FIELD_NUMBER: _ClassVar[int]
envelope: Envelope
heartbeat_interval_seconds: int
max_concurrent_tasks: int
inline_result_threshold_bytes: int
def __init__(self, envelope: _Optional[_Union[Envelope, _Mapping]] = ..., heartbeat_interval_seconds: _Optional[int] = ..., max_concurrent_tasks: _Optional[int] = ..., inline_result_threshold_bytes: _Optional[int] = ...) -> None: ...
class PrewarmRequest(_message.Message):
__slots__ = ("envelope", "engine", "model_id", "download_if_missing")
ENVELOPE_FIELD_NUMBER: _ClassVar[int]
ENGINE_FIELD_NUMBER: _ClassVar[int]
MODEL_ID_FIELD_NUMBER: _ClassVar[int]
DOWNLOAD_IF_MISSING_FIELD_NUMBER: _ClassVar[int]
envelope: Envelope
engine: str
model_id: str
download_if_missing: bool
def __init__(self, envelope: _Optional[_Union[Envelope, _Mapping]] = ..., engine: _Optional[str] = ..., model_id: _Optional[str] = ..., download_if_missing: _Optional[bool] = ...) -> None: ...
class Ping(_message.Message):
__slots__ = ("envelope", "nonce")
ENVELOPE_FIELD_NUMBER: _ClassVar[int]
NONCE_FIELD_NUMBER: _ClassVar[int]
envelope: Envelope
nonce: int
def __init__(self, envelope: _Optional[_Union[Envelope, _Mapping]] = ..., nonce: _Optional[int] = ...) -> None: ...
class Drain(_message.Message):
__slots__ = ("envelope", "deadline_seconds", "reconnect_to")
ENVELOPE_FIELD_NUMBER: _ClassVar[int]
DEADLINE_SECONDS_FIELD_NUMBER: _ClassVar[int]
RECONNECT_TO_FIELD_NUMBER: _ClassVar[int]
envelope: Envelope
deadline_seconds: int
reconnect_to: str
def __init__(self, envelope: _Optional[_Union[Envelope, _Mapping]] = ..., deadline_seconds: _Optional[int] = ..., reconnect_to: _Optional[str] = ...) -> None: ...
class Shutdown(_message.Message):
__slots__ = ("envelope", "reason")
ENVELOPE_FIELD_NUMBER: _ClassVar[int]
REASON_FIELD_NUMBER: _ClassVar[int]
envelope: Envelope
reason: str
def __init__(self, envelope: _Optional[_Union[Envelope, _Mapping]] = ..., reason: _Optional[str] = ...) -> None: ...
class ServerMessage(_message.Message):
__slots__ = ("assignment", "cancel", "result_ack", "config", "ping", "drain", "shutdown", "prewarm")
ASSIGNMENT_FIELD_NUMBER: _ClassVar[int]
CANCEL_FIELD_NUMBER: _ClassVar[int]
RESULT_ACK_FIELD_NUMBER: _ClassVar[int]
CONFIG_FIELD_NUMBER: _ClassVar[int]
PING_FIELD_NUMBER: _ClassVar[int]
DRAIN_FIELD_NUMBER: _ClassVar[int]
SHUTDOWN_FIELD_NUMBER: _ClassVar[int]
PREWARM_FIELD_NUMBER: _ClassVar[int]
assignment: TaskAssignment
cancel: TaskCancel
result_ack: ResultAckMessage
config: ConfigUpdate
ping: Ping
drain: Drain
shutdown: Shutdown
prewarm: PrewarmRequest
def __init__(self, assignment: _Optional[_Union[TaskAssignment, _Mapping]] = ..., cancel: _Optional[_Union[TaskCancel, _Mapping]] = ..., result_ack: _Optional[_Union[ResultAckMessage, _Mapping]] = ..., config: _Optional[_Union[ConfigUpdate, _Mapping]] = ..., ping: _Optional[_Union[Ping, _Mapping]] = ..., drain: _Optional[_Union[Drain, _Mapping]] = ..., shutdown: _Optional[_Union[Shutdown, _Mapping]] = ..., prewarm: _Optional[_Union[PrewarmRequest, _Mapping]] = ...) -> None: ...
class ArtifactRef(_message.Message):
__slots__ = ("artifact_id", "task_id", "attempt_id", "filename", "content_type", "size_bytes", "sha256", "session_token")
ARTIFACT_ID_FIELD_NUMBER: _ClassVar[int]
TASK_ID_FIELD_NUMBER: _ClassVar[int]
ATTEMPT_ID_FIELD_NUMBER: _ClassVar[int]
FILENAME_FIELD_NUMBER: _ClassVar[int]
CONTENT_TYPE_FIELD_NUMBER: _ClassVar[int]
SIZE_BYTES_FIELD_NUMBER: _ClassVar[int]
SHA256_FIELD_NUMBER: _ClassVar[int]
SESSION_TOKEN_FIELD_NUMBER: _ClassVar[int]
artifact_id: str
task_id: str
attempt_id: str
filename: str
content_type: str
size_bytes: int
sha256: str
session_token: str
def __init__(self, artifact_id: _Optional[str] = ..., task_id: _Optional[str] = ..., attempt_id: _Optional[str] = ..., filename: _Optional[str] = ..., content_type: _Optional[str] = ..., size_bytes: _Optional[int] = ..., sha256: _Optional[str] = ..., session_token: _Optional[str] = ...) -> None: ...
class ArtifactChunk(_message.Message):
__slots__ = ("ref", "offset", "data", "last")
REF_FIELD_NUMBER: _ClassVar[int]
OFFSET_FIELD_NUMBER: _ClassVar[int]
DATA_FIELD_NUMBER: _ClassVar[int]
LAST_FIELD_NUMBER: _ClassVar[int]
ref: ArtifactRef
offset: int
data: bytes
last: bool
def __init__(self, ref: _Optional[_Union[ArtifactRef, _Mapping]] = ..., offset: _Optional[int] = ..., data: _Optional[bytes] = ..., last: _Optional[bool] = ...) -> None: ...
class ResultChunk(_message.Message):
__slots__ = ("ref", "offset", "data", "last", "session_token")
REF_FIELD_NUMBER: _ClassVar[int]
OFFSET_FIELD_NUMBER: _ClassVar[int]
DATA_FIELD_NUMBER: _ClassVar[int]
LAST_FIELD_NUMBER: _ClassVar[int]
SESSION_TOKEN_FIELD_NUMBER: _ClassVar[int]
ref: ArtifactRef
offset: int
data: bytes
last: bool
session_token: str
def __init__(self, ref: _Optional[_Union[ArtifactRef, _Mapping]] = ..., offset: _Optional[int] = ..., data: _Optional[bytes] = ..., last: _Optional[bool] = ..., session_token: _Optional[str] = ...) -> None: ...
class ResultAck(_message.Message):
__slots__ = ("artifact_id", "bytes_received", "committed", "error")
ARTIFACT_ID_FIELD_NUMBER: _ClassVar[int]
BYTES_RECEIVED_FIELD_NUMBER: _ClassVar[int]
COMMITTED_FIELD_NUMBER: _ClassVar[int]
ERROR_FIELD_NUMBER: _ClassVar[int]
artifact_id: str
bytes_received: int
committed: bool
error: Error
def __init__(self, artifact_id: _Optional[str] = ..., bytes_received: _Optional[int] = ..., committed: _Optional[bool] = ..., error: _Optional[_Union[Error, _Mapping]] = ...) -> None: ...
@@ -0,0 +1,236 @@
# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT!
"""Client and server classes corresponding to protobuf-defined services."""
import grpc
import warnings
from . import worker_v1_pb2 as worker__v1__pb2
GRPC_GENERATED_VERSION = '1.81.1'
GRPC_VERSION = grpc.__version__
_version_not_supported = False
try:
from grpc._utilities import first_version_is_lower
_version_not_supported = first_version_is_lower(GRPC_VERSION, GRPC_GENERATED_VERSION)
except ImportError:
_version_not_supported = True
if _version_not_supported:
raise RuntimeError(
f'The grpc package installed is at version {GRPC_VERSION},'
+ ' but the generated code in worker_v1_pb2_grpc.py depends on'
+ f' grpcio>={GRPC_GENERATED_VERSION}.'
+ f' Please upgrade your grpc module to grpcio>={GRPC_GENERATED_VERSION}'
+ f' or downgrade your generated code using grpcio-tools<={GRPC_VERSION}.'
)
class WorkerServiceStub:
"""── Service ────────────────────────────────────────────────────────────────
"""
def __init__(self, channel):
"""Constructor.
Args:
channel: A grpc.Channel.
"""
self.Register = channel.unary_unary(
'/omnivoice.worker.v1.WorkerService/Register',
request_serializer=worker__v1__pb2.RegisterRequest.SerializeToString,
response_deserializer=worker__v1__pb2.RegisterResponse.FromString,
_registered_method=True)
self.Control = channel.stream_stream(
'/omnivoice.worker.v1.WorkerService/Control',
request_serializer=worker__v1__pb2.WorkerMessage.SerializeToString,
response_deserializer=worker__v1__pb2.ServerMessage.FromString,
_registered_method=True)
self.UploadResult = channel.stream_unary(
'/omnivoice.worker.v1.WorkerService/UploadResult',
request_serializer=worker__v1__pb2.ResultChunk.SerializeToString,
response_deserializer=worker__v1__pb2.ResultAck.FromString,
_registered_method=True)
self.DownloadArtifact = channel.unary_stream(
'/omnivoice.worker.v1.WorkerService/DownloadArtifact',
request_serializer=worker__v1__pb2.ArtifactRef.SerializeToString,
response_deserializer=worker__v1__pb2.ArtifactChunk.FromString,
_registered_method=True)
class WorkerServiceServicer:
"""── Service ────────────────────────────────────────────────────────────────
"""
def Register(self, request, context):
"""Enrollment / authentication. Returns a session token and epoch.
"""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def Control(self, request_iterator, context):
"""Persistent bidirectional control stream. Small messages only.
"""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def UploadResult(self, request_iterator, context):
"""Artifact out: chunked and resumable. Result bytes never ride Control.
"""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def DownloadArtifact(self, request, context):
"""Artifact in: reference audio, source video, model inputs.
"""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def add_WorkerServiceServicer_to_server(servicer, server):
rpc_method_handlers = {
'Register': grpc.unary_unary_rpc_method_handler(
servicer.Register,
request_deserializer=worker__v1__pb2.RegisterRequest.FromString,
response_serializer=worker__v1__pb2.RegisterResponse.SerializeToString,
),
'Control': grpc.stream_stream_rpc_method_handler(
servicer.Control,
request_deserializer=worker__v1__pb2.WorkerMessage.FromString,
response_serializer=worker__v1__pb2.ServerMessage.SerializeToString,
),
'UploadResult': grpc.stream_unary_rpc_method_handler(
servicer.UploadResult,
request_deserializer=worker__v1__pb2.ResultChunk.FromString,
response_serializer=worker__v1__pb2.ResultAck.SerializeToString,
),
'DownloadArtifact': grpc.unary_stream_rpc_method_handler(
servicer.DownloadArtifact,
request_deserializer=worker__v1__pb2.ArtifactRef.FromString,
response_serializer=worker__v1__pb2.ArtifactChunk.SerializeToString,
),
}
generic_handler = grpc.method_handlers_generic_handler(
'omnivoice.worker.v1.WorkerService', rpc_method_handlers)
server.add_generic_rpc_handlers((generic_handler,))
server.add_registered_method_handlers('omnivoice.worker.v1.WorkerService', rpc_method_handlers)
# This class is part of an EXPERIMENTAL API.
class WorkerService:
"""── Service ────────────────────────────────────────────────────────────────
"""
@staticmethod
def Register(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_unary(
request,
target,
'/omnivoice.worker.v1.WorkerService/Register',
worker__v1__pb2.RegisterRequest.SerializeToString,
worker__v1__pb2.RegisterResponse.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)
@staticmethod
def Control(request_iterator,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.stream_stream(
request_iterator,
target,
'/omnivoice.worker.v1.WorkerService/Control',
worker__v1__pb2.WorkerMessage.SerializeToString,
worker__v1__pb2.ServerMessage.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)
@staticmethod
def UploadResult(request_iterator,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.stream_unary(
request_iterator,
target,
'/omnivoice.worker.v1.WorkerService/UploadResult',
worker__v1__pb2.ResultChunk.SerializeToString,
worker__v1__pb2.ResultAck.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)
@staticmethod
def DownloadArtifact(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_stream(
request,
target,
'/omnivoice.worker.v1.WorkerService/DownloadArtifact',
worker__v1__pb2.ArtifactRef.SerializeToString,
worker__v1__pb2.ArtifactChunk.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)
+430
View File
@@ -0,0 +1,430 @@
// OmniVoice worker protocol, version 1.
//
// This is the ONLY artifact shared between the OSS Python control plane and the
// future Go control plane (goal_v2.md B1). Each control plane owns its own
// scheduler; drift is contained by conformance fixtures, not by shared code.
//
// Rules of the road (goal_v2.md A5):
// * Additive-only within v1. Never renumber, never reuse a field number.
// * Version negotiation happens at Register; the server may refuse with
// UPGRADE_REQUIRED. Supported skew window is N-2.
// * The Control stream carries SMALL messages only. Artifacts (reference
// audio in, rendered audio/video out) move through UploadResult /
// DownloadArtifact. A large payload on the control stream head-of-line
// blocks heartbeats and gets its own worker declared dead mid-delivery.
// * Every task-scoped message carries (task_id, attempt_id, session_epoch).
// Both sides reject stale epochs and superseded attempts.
// * Status reports are absolute snapshots with per-session sequence numbers,
// never deltas per-stream FIFO does not survive a reconnect.
// * Fields marked "hosted" are unused in OSS but must exist from v1: adding
// them later means upgrading an entire deployed fleet.
syntax = "proto3";
package omnivoice.worker.v1;
// Service
service WorkerService {
// Enrollment / authentication. Returns a session token and epoch.
rpc Register(RegisterRequest) returns (RegisterResponse);
// Persistent bidirectional control stream. Small messages only.
rpc Control(stream WorkerMessage) returns (stream ServerMessage);
// Artifact out: chunked and resumable. Result bytes never ride Control.
rpc UploadResult(stream ResultChunk) returns (ResultAck);
// Artifact in: reference audio, source video, model inputs.
rpc DownloadArtifact(ArtifactRef) returns (stream ArtifactChunk);
}
// Common
// Stamped on every task-scoped message so superseded work can be fenced.
message TaskRef {
string task_id = 1;
string attempt_id = 2;
uint64 session_epoch = 3;
}
// Present on every message that crosses the wire. `trace_id` and `tenant_id`
// are hosted-only but reserved here: retrofitting them is a fleet upgrade.
message Envelope {
uint64 sequence = 1; // per-session, monotonic, for drop-if-stale
string trace_id = 2; // hosted
string tenant_id = 3; // hosted
}
enum ErrorClass {
ERROR_CLASS_UNSPECIFIED = 0;
// Retry on a different worker may succeed.
ERROR_CLASS_TRANSIENT = 1;
// Worker cannot run this task (missing engine, insufficient VRAM). Retry
// elsewhere; do not penalise the worker it is a capability mismatch.
ERROR_CLASS_CAPABILITY = 2;
// Task itself is bad (malformed input, unsupported language). Retrying on
// any worker fails identically fail the task, never rotate the fleet.
ERROR_CLASS_TERMINAL = 3;
// Worker is at capacity. Penalty-free; reschedule immediately.
ERROR_CLASS_CAPACITY = 4;
// Task exceeded a deadline.
ERROR_CLASS_TIMEOUT = 5;
// Protocol/auth failure.
ERROR_CLASS_PROTOCOL = 6;
}
message Error {
ErrorClass error_class = 1;
string code = 2; // stable enum-ish key, e.g. "MODEL_LOAD_TIMEOUT"
string message = 3; // human-readable, already scrubbed by the sender
string hint = 4; // actionable next step, matches the app's error ethos
}
// Registration
message GpuInfo {
string vendor = 1; // "nvidia" | "apple" | "amd" | "intel" | ""
string model = 2; // "NVIDIA GeForce RTX 4090" | "Apple M2"
string backend = 3; // "cuda" | "mps" | "mlx" | "rocm" | "cpu"
uint64 memory_bytes = 4;
uint64 free_memory_bytes = 5;
string driver_version = 6;
string compute_capability = 7; // CUDA only, e.g. "8.9"
}
message HostInfo {
string hostname = 1;
string os = 2; // "darwin" | "windows" | "linux"
string arch = 3; // "arm64" | "x86_64"
string worker_version = 4;
uint32 cpu_count = 5;
uint64 system_memory_bytes = 6;
repeated GpuInfo gpus = 7;
}
// A model's availability on a worker is FOUR distinct states, not one. The
// scheduler needs all four: `supported` says the engine could run here,
// `installed` says its venv exists, `downloaded` says weights are on disk,
// `resident` says it is in VRAM right now (goal_v2.md A3, C8).
message ModelCapability {
string engine = 1; // "indextts" | "cosyvoice" | ...
string model_id = 2;
repeated string operations = 3; // "tts" | "clone" | "asr" | "dub" | ...
bool supported = 4;
bool installed = 5;
bool downloaded = 6;
bool resident = 7;
uint64 min_memory_bytes = 8;
string precision = 9; // "fp16" | "int8" | "gguf-q4" | ...
// Derived by the worker from free memory, never configured by the user:
// a static value corrupts output under torch.compile thread affinity and
// OOMs small cards (issues #315 / #567).
uint32 derived_concurrency = 10;
// True when the engine is present but would run on CPU fallback here
// capability is not the same as acceleration.
bool cpu_fallback = 11;
// Catalog ids only, never paths. Lets the control plane offer the exact
// download required by a positive downloaded=false capability.
repeated string repo_ids = 12;
// Human-readable UI label. Never use this as a scheduling or residency key;
// unlike model_id it may change with ordinary copy edits.
string display_name = 13;
}
message RegisterRequest {
Envelope envelope = 1;
// Version negotiation. Server refuses outside its supported window.
uint32 protocol_version_min = 2;
uint32 protocol_version_max = 3;
// First contact uses a single-use enrollment token; every later connection
// proves possession of the enrolled key instead.
string enrollment_token = 4;
string worker_id = 5; // empty on first enrollment
bytes public_key = 6; // Ed25519, bound at enrollment
bytes challenge_signature = 7; // signature over the server's challenge
bytes challenge = 8;
HostInfo host = 9;
repeated ModelCapability capabilities = 10;
uint32 max_concurrent_tasks = 11;
// Reconnect reconciliation: what this worker believes it is still doing.
// Without this a control-plane restart orphans live work (goal_v2.md A7).
repeated TaskRef in_flight = 12;
repeated TaskRef completed_unacked = 13;
string key_id = 14; // which enrolled key signed this
bytes nonce = 15; // replay protection
map<string, string> labels = 16; // hosted: region, owner class, pool
// Behavioural capabilities, independent of release/version skew. A peer
// must never infer wire semantics from protocol_version alone.
repeated string features = 17;
}
message RegisterResponse {
Envelope envelope = 1;
string worker_id = 2;
string session_token = 3;
uint64 session_epoch = 4;
uint32 protocol_version = 5;
int64 session_expires_at_unix = 6;
uint32 heartbeat_interval_seconds = 7;
// Authoritative in-flight list. Anything the worker is running that is NOT
// here is a zombie and must be cancelled locally.
repeated TaskRef authoritative_in_flight = 8;
Error error = 9; // set when registration is refused
}
// Control stream: worker server
message Heartbeat {
Envelope envelope = 1;
// Absolute snapshot, never a delta.
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).
}
message TaskAccepted { TaskRef ref = 1; Envelope envelope = 2; }
message TaskRejected {
TaskRef ref = 1;
Envelope envelope = 2;
Error error = 3; // ERROR_CLASS_CAPACITY is penalty-free
}
// Cold model load is a distinct, acknowledged phase. Folding it into the
// execution deadline quarantines healthy hardware for doing normal work.
message TaskModelLoading {
TaskRef ref = 1;
Envelope envelope = 2;
string engine = 3;
double progress = 4; // 0..1, -1 when indeterminate
string detail = 5; // sub-stage, e.g. "downloading weights"
uint64 eta_seconds = 6;
}
message TaskStarted { TaskRef ref = 1; Envelope envelope = 2; }
// Renews the progress lease. Liveness is progress-based, not wall-clock
// a 40-minute dub is not a hung task.
message TaskProgress {
TaskRef ref = 1;
Envelope envelope = 2;
double progress = 3;
string stage = 4;
string detail = 5;
// True when this frame exists only to renew the lease, emitted by a timer
// rather than by the work itself. It must never overwrite progress/stage.
//
// Without this flag "slow" and "wedged" are indistinguishable: a timer on
// the event loop keeps ticking while the GPU thread is wedged (#567's
// sticky CUDA abort), so an unmarked keepalive would renew the lease of a
// task that will never finish. The server bounds a keepalive-renewed lease
// by the phase's absolute budget; a real progress frame is evidence of work
// and is not bounded that way.
bool keepalive = 6;
}
// Usage accounting rides the result from v1. Billing cannot launch behind a
// fleet upgrade (goal_v2.md B3). Unused in OSS beyond local stats.
message UsageReport {
double audio_seconds_in = 1;
double audio_seconds_out = 2;
uint64 characters_in = 3;
double wall_seconds = 4;
double gpu_seconds = 5;
double model_load_seconds = 6;
string engine = 7;
string model_id = 8;
}
message TaskResult {
TaskRef ref = 1;
Envelope envelope = 2;
// Small results may ride inline; anything above the negotiated threshold
// is uploaded via UploadResult and referenced here.
bytes inline_payload = 3;
repeated ArtifactRef artifacts = 4;
string result_json = 5; // metadata (timings, segments), never bulk audio
UsageReport usage = 6;
}
message TaskFailed {
TaskRef ref = 1;
Envelope envelope = 2;
Error error = 3;
UsageReport usage = 4; // partial work still meters
}
message TaskCancelAck { TaskRef ref = 1; Envelope envelope = 2; }
// Sent before a clean shutdown so the server can drain rather than treat the
// disconnect as a failure.
// Reply to a server Ping. The server times the round trip on its own clock,
// so no worker timestamp is trusted and the nonce ties the reply to the
// ping it answers, so a late pong cannot report a falsely low latency.
message Pong {
Envelope envelope = 1;
uint64 nonce = 2;
}
message WorkerGoodbye {
Envelope envelope = 1;
string reason = 2;
repeated TaskRef abandoning = 3;
}
message CapabilityUpdate {
Envelope envelope = 1;
repeated ModelCapability capabilities = 2;
}
// A model-install event in the same JSON shape emitted by utils.hf_progress.
// The repo is resolved on the worker from the opaque PrewarmRequest.model_id;
// no repository path or URL is accepted over the wire.
message DownloadProgress {
Envelope envelope = 1;
string event_json = 2;
}
message WorkerMessage {
oneof payload {
Heartbeat heartbeat = 1;
TaskAccepted accepted = 2;
TaskRejected rejected = 3;
TaskModelLoading model_loading = 4;
TaskStarted started = 5;
TaskProgress progress = 6;
TaskResult result = 7;
TaskFailed failed = 8;
TaskCancelAck cancel_ack = 9;
CapabilityUpdate capabilities = 10;
WorkerGoodbye goodbye = 11;
Pong pong = 12;
DownloadProgress download_progress = 13;
}
reserved 14; // future streaming frame
}
// Control stream: server worker
// All deadlines are server-computed RELATIVE durations. Worker wall clocks
// are untrusted and skew silently.
message Deadlines {
uint32 accept_seconds = 1;
uint32 model_load_seconds = 2;
uint32 execution_seconds = 3;
uint32 progress_lease_seconds = 4;
uint32 result_delivery_seconds = 5;
}
message TaskAssignment {
TaskRef ref = 1;
Envelope envelope = 2;
string operation = 3;
string engine = 4;
// Registry NAME only. Never a filesystem path or URL: model loading is
// pickle-backed in this ecosystem, so a path here is remote code execution
// on every worker in the fleet (goal_v2.md A6).
string model_id = 5;
string params_json = 6;
repeated ArtifactRef inputs = 7;
Deadlines deadlines = 8;
uint32 priority_class = 9; // 0 = interactive, 1 = batch
uint32 attempt_number = 10;
uint32 max_attempts = 11;
map<string, string> metadata = 12; // hosted: tenant, quota class
}
message TaskCancel {
TaskRef ref = 1;
Envelope envelope = 2;
string reason = 3;
}
// The result is durably committed. Only now may the worker drop its copy.
message ResultAckMessage { TaskRef ref = 1; Envelope envelope = 2; }
message ConfigUpdate {
Envelope envelope = 1;
// Enumerated keys only never code, never paths. An open-ended config
// channel is a remote-execution channel.
uint32 heartbeat_interval_seconds = 2;
uint32 max_concurrent_tasks = 3;
uint64 inline_result_threshold_bytes = 4;
}
// Pre-warm so a first task does not silently trigger a 20-minute download.
message PrewarmRequest {
Envelope envelope = 1;
string engine = 2;
string model_id = 3;
bool download_if_missing = 4;
}
message Ping { Envelope envelope = 1; uint64 nonce = 2; }
// Fleet operations: stop taking work, finish what you have, then reconnect.
message Drain {
Envelope envelope = 1;
uint32 deadline_seconds = 2;
string reconnect_to = 3; // hosted: multi-instance control plane
}
message Shutdown { Envelope envelope = 1; string reason = 2; }
message ServerMessage {
oneof payload {
TaskAssignment assignment = 1;
TaskCancel cancel = 2;
ResultAckMessage result_ack = 3;
ConfigUpdate config = 4;
Ping ping = 5;
Drain drain = 6;
Shutdown shutdown = 7;
PrewarmRequest prewarm = 8;
}
}
// Artifact transfer
message ArtifactRef {
string artifact_id = 1;
string task_id = 2;
string attempt_id = 3;
string filename = 4;
string content_type = 5;
uint64 size_bytes = 6;
string sha256 = 7;
string session_token = 8;
}
message ArtifactChunk {
ArtifactRef ref = 1;
uint64 offset = 2;
bytes data = 3;
bool last = 4;
}
message ResultChunk {
ArtifactRef ref = 1;
uint64 offset = 2; // resumable: server reports bytes already held
bytes data = 3;
bool last = 4;
string session_token = 5;
}
message ResultAck {
string artifact_id = 1;
uint64 bytes_received = 2;
bool committed = 3;
Error error = 4;
}
+417
View File
@@ -0,0 +1,417 @@
"""Persistence for remote workers and their enrollment tokens.
What is durable and what is not is a deliberate split (docs/remote-workers.md):
**Persisted** worker identities and their public keys, revocations,
per-worker configuration, and enrollment tokens. These must survive a restart
because the control plane is a desktop app that restarts constantly, and a
revocation that evaporates on quit is not a revocation.
**Not persisted** live sessions, heartbeats, latency, capacity snapshots,
breaker state. All of it is rebuilt from the reconnection itself, and a
worker is the source of truth for what it is running anyway.
Tables live in ``core/db.py:_BASE_SCHEMA`` with ``CREATE TABLE IF NOT EXISTS``,
so an existing ``omnivoice_data/`` picks them up on next open with no migration
step and no change for users who never enable the feature.
"""
from __future__ import annotations
import json
import logging
import time
import uuid
from dataclasses import dataclass, field
from typing import Optional
from core.db import db_conn
from worker.clock import resolve
from worker import identity
logger = logging.getLogger("omnivoice.worker")
# Default scheduling preference. Higher wins; equal priorities fall through to
# least-busy, which is the actual default behaviour for a homogeneous setup.
_DEFAULT_PRIORITY = 50
@dataclass
class RemoteWorker:
"""A worker as the control plane knows it between connections."""
id: str
name: str
key_id: str
public_key: bytes
enabled: bool = True
revoked: bool = False
revoked_at: Optional[float] = None
priority: int = _DEFAULT_PRIORITY
endpoint: str = ""
host: dict = field(default_factory=dict)
capabilities: list[dict] = field(default_factory=list)
max_concurrent_tasks: int = 1
session_epoch: int = 0
consent_granted_at: Optional[float] = None
created_at: float = 0.0
last_seen_at: Optional[float] = None
@property
def schedulable(self) -> bool:
"""Eligible for work at all — before any health or capacity check."""
return self.enabled and not self.revoked and self.consent_granted_at is not None
def to_dict(self) -> dict:
"""UI shape. The public key is never exposed beyond its short id."""
return {
"id": self.id,
"name": self.name,
"key_id": self.key_id,
"enabled": self.enabled,
"revoked": self.revoked,
"priority": self.priority,
"endpoint": self.endpoint,
"host": self.host,
"max_concurrent_tasks": self.max_concurrent_tasks,
"consent_granted": self.consent_granted_at is not None,
"created_at": self.created_at,
"last_seen_at": self.last_seen_at,
}
def _row_to_worker(row) -> RemoteWorker:
return RemoteWorker(
id=row["id"],
name=row["name"],
key_id=row["key_id"],
public_key=bytes(row["public_key"]),
enabled=bool(row["enabled"]),
revoked=bool(row["revoked"]),
revoked_at=row["revoked_at"],
priority=int(row["priority"]),
endpoint=row["endpoint"] or "",
host=json.loads(row["host_json"] or "{}"),
capabilities=json.loads(row["capabilities_json"] or "[]"),
max_concurrent_tasks=int(row["max_concurrent_tasks"]),
session_epoch=int(row["session_epoch"]),
consent_granted_at=row["consent_granted_at"],
created_at=float(row["created_at"]),
last_seen_at=row["last_seen_at"],
)
# ── Enrollment ─────────────────────────────────────────────────────────────
def create_enrollment(
*,
endpoint: str,
cert_fingerprint: str,
label: str = "",
ttl_seconds: int = 15 * 60,
now: Optional[float] = None,
) -> identity.EnrollmentToken:
"""Mint a join token and store only its hash."""
stamp = resolve(now)
token = identity.mint_enrollment_token(
endpoint=endpoint,
cert_fingerprint=cert_fingerprint,
ttl_seconds=ttl_seconds,
now=stamp,
)
with db_conn() as conn:
conn.execute(
"INSERT INTO remote_worker_enrollments "
"(token_id, secret_hash, endpoint, cert_fingerprint, label, created_at, expires_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?)",
(
token.token_id,
token.secret_hash,
endpoint,
cert_fingerprint,
label,
stamp,
token.expires_at,
),
)
return token
def redeem_enrollment(
token: identity.EnrollmentToken, *, worker_id: str, now: Optional[float] = None
) -> bool:
"""Consume a join token. Returns False if it is unknown, spent, or expired.
Single-use is enforced here with a conditional UPDATE rather than a
read-then-write so two workers racing the same token cannot both win.
"""
stamp = resolve(now)
with db_conn() as conn:
row = conn.execute(
"SELECT secret_hash, expires_at, used_at FROM remote_worker_enrollments WHERE token_id = ?",
(token.token_id,),
).fetchone()
if row is None:
return False
if row["used_at"] is not None:
return False
if stamp > float(row["expires_at"]):
return False
if not identity.constant_time_equals(row["secret_hash"], token.secret_hash):
return False
cur = conn.execute(
"UPDATE remote_worker_enrollments SET used_at = ?, used_by_worker = ? "
"WHERE token_id = ? AND used_at IS NULL",
(stamp, worker_id, token.token_id),
)
return cur.rowcount == 1
def purge_expired_enrollments(*, now: Optional[float] = None) -> int:
stamp = resolve(now)
with db_conn() as conn:
cur = conn.execute(
"DELETE FROM remote_worker_enrollments WHERE used_at IS NULL AND expires_at < ?",
(stamp,),
)
return cur.rowcount
# ── Workers ────────────────────────────────────────────────────────────────
def enroll_worker(
*,
name: str,
public_key: bytes,
endpoint: str = "",
host: Optional[dict] = None,
capabilities: Optional[list[dict]] = None,
max_concurrent_tasks: int = 1,
consent_granted: bool = True,
now: Optional[float] = None,
) -> RemoteWorker:
"""Register a new worker against the public key it generated.
``consent_granted`` records the user's explicit yes to sending their audio
to this machine. It is stored per worker, not globally: agreeing to use
your own desktop is not agreeing to use someone else's.
"""
stamp = resolve(now)
key_id = identity.key_id_for(public_key)
existing = get_by_key_id(key_id)
if existing is not None:
return existing
worker = RemoteWorker(
id=uuid.uuid4().hex[:12],
name=name or key_id,
key_id=key_id,
public_key=public_key,
endpoint=endpoint,
host=host or {},
capabilities=capabilities or [],
max_concurrent_tasks=max(1, int(max_concurrent_tasks)),
consent_granted_at=stamp if consent_granted else None,
created_at=stamp,
)
with db_conn() as conn:
conn.execute(
"INSERT INTO remote_workers "
"(id, name, key_id, public_key, enabled, revoked, priority, endpoint, host_json, "
" capabilities_json, max_concurrent_tasks, session_epoch, consent_granted_at, created_at) "
"VALUES (?, ?, ?, ?, 1, 0, ?, ?, ?, ?, ?, 0, ?, ?)",
(
worker.id,
worker.name,
worker.key_id,
worker.public_key,
worker.priority,
worker.endpoint,
json.dumps(worker.host),
json.dumps(worker.capabilities),
worker.max_concurrent_tasks,
worker.consent_granted_at,
worker.created_at,
),
)
logger.info("Enrolled remote worker %s (%s)", worker.name, worker.key_id)
return worker
def get(worker_id: str) -> Optional[RemoteWorker]:
with db_conn() as conn:
row = conn.execute("SELECT * FROM remote_workers WHERE id = ?", (worker_id,)).fetchone()
return _row_to_worker(row) if row else None
def get_by_key_id(key_id: str) -> Optional[RemoteWorker]:
with db_conn() as conn:
row = conn.execute("SELECT * FROM remote_workers WHERE key_id = ?", (key_id,)).fetchone()
return _row_to_worker(row) if row else None
def list_workers(*, include_revoked: bool = False) -> list[RemoteWorker]:
sql = "SELECT * FROM remote_workers"
if not include_revoked:
sql += " WHERE revoked = 0"
sql += " ORDER BY priority DESC, created_at ASC"
with db_conn() as conn:
rows = conn.execute(sql).fetchall()
return [_row_to_worker(r) for r in rows]
def begin_session(worker_id: str, *, now: Optional[float] = None) -> int:
"""Bump and return the worker's session epoch.
Every reconnect gets a new epoch, which is what lets the server drop
messages from a half-open previous stream the zombie-session race that
otherwise delivers two accepts for one assignment.
"""
stamp = resolve(now)
with db_conn() as conn:
conn.execute(
"UPDATE remote_workers SET session_epoch = session_epoch + 1, last_seen_at = ? WHERE id = ?",
(stamp, worker_id),
)
row = conn.execute(
"SELECT session_epoch FROM remote_workers WHERE id = ?", (worker_id,)
).fetchone()
return int(row["session_epoch"]) if row else 0
def touch(worker_id: str, *, now: Optional[float] = None) -> None:
with db_conn() as conn:
conn.execute(
"UPDATE remote_workers SET last_seen_at = ? WHERE id = ?", (resolve(now), worker_id)
)
def update_capabilities(
worker_id: str,
*,
capabilities: list[dict],
host: Optional[dict] = None,
max_concurrent_tasks: Optional[int] = None,
) -> None:
sets = ["capabilities_json = ?"]
params: list = [json.dumps(capabilities)]
if host is not None:
sets.append("host_json = ?")
params.append(json.dumps(host))
if max_concurrent_tasks is not None:
sets.append("max_concurrent_tasks = ?")
params.append(max(1, int(max_concurrent_tasks)))
params.append(worker_id)
with db_conn() as conn:
conn.execute(f"UPDATE remote_workers SET {', '.join(sets)} WHERE id = ?", params)
def set_enabled(worker_id: str, enabled: bool) -> None:
with db_conn() as conn:
conn.execute(
"UPDATE remote_workers SET enabled = ? WHERE id = ?", (1 if enabled else 0, worker_id)
)
def set_priority(worker_id: str, priority: int) -> None:
with db_conn() as conn:
conn.execute(
"UPDATE remote_workers SET priority = ? WHERE id = ?",
(max(0, min(100, int(priority))), worker_id),
)
def rename(worker_id: str, name: str) -> None:
with db_conn() as conn:
conn.execute("UPDATE remote_workers SET name = ? WHERE id = ?", (name, worker_id))
def revoke(worker_id: str, *, now: Optional[float] = None) -> bool:
"""Permanently refuse this worker's key.
Revocation is a tombstone, not a delete: the row stays so a reconnect with
the same key is recognised and refused rather than treated as a stranger
who could simply enroll again.
"""
stamp = resolve(now)
with db_conn() as conn:
cur = conn.execute(
"UPDATE remote_workers SET revoked = 1, revoked_at = ?, enabled = 0 WHERE id = ?",
(stamp, worker_id),
)
if cur.rowcount:
logger.info("Revoked remote worker %s", worker_id)
return bool(cur.rowcount)
def is_revoked(key_id: str) -> bool:
with db_conn() as conn:
row = conn.execute(
"SELECT revoked FROM remote_workers WHERE key_id = ?", (key_id,)
).fetchone()
return bool(row and row["revoked"])
def grant_consent(worker_id: str, *, now: Optional[float] = None) -> None:
with db_conn() as conn:
conn.execute(
"UPDATE remote_workers SET consent_granted_at = ? WHERE id = ?",
(resolve(now), worker_id),
)
def authenticate(
*,
key_id: str,
public_key: bytes,
challenge: bytes,
signature: bytes,
nonce: bytes,
session_epoch: int,
) -> Optional[RemoteWorker]:
"""Verify a reconnecting worker's possession of its enrolled key.
Returns the worker on success, ``None`` on any failure unknown key,
revoked worker, key mismatch, or bad signature. The caller must not
distinguish between these in what it tells the network.
"""
worker = get_by_key_id(key_id)
if worker is None or worker.revoked:
return None
if not identity.constant_time_equals(
identity.key_id_for(worker.public_key), identity.key_id_for(public_key)
):
return None
if worker.public_key != public_key:
return None
message = identity.challenge_message(
challenge=challenge,
worker_id=worker.id,
session_epoch=session_epoch,
nonce=nonce,
)
if not identity.verify_signature(public_key, message, signature):
return None
return worker
__all__ = [
"RemoteWorker",
"authenticate",
"begin_session",
"create_enrollment",
"enroll_worker",
"get",
"get_by_key_id",
"grant_consent",
"is_revoked",
"list_workers",
"purge_expired_enrollments",
"redeem_enrollment",
"rename",
"revoke",
"set_enabled",
"set_priority",
"touch",
"update_capabilities",
]
+346
View File
@@ -0,0 +1,346 @@
"""Where work runs: this machine, or one node the user picked.
The scheduler underneath can rank many workers, and the hosted platform will
need that. The OSS product deliberately does not expose it. Here the user
chooses a single target from a list ``Local``, or one of the machines they
enrolled and that choice is the whole policy:
GPU: [ Local ] Local
desktop-4090 192.168.0.222:2222
laptop-m2 192.168.0.31:7443
Two reasons this beats automatic selection for a desktop app. It is
predictable a user who sends a job to their 4090 can see that is where it
went, rather than discovering the scheduler preferred a laptop. And it is
explainable when it goes wrong: "your chosen worker is offline, this ran
locally" is a sentence; "least-busy ranking picked another node" is not.
Exactly one target is active at a time. Other enrolled workers may be
connected they simply receive nothing, which is what standby means.
Fallback is not one rule but three, because "it ran locally instead" is a
kindness in the first case and a lie in the others:
1. **Before dispatch** the chosen worker is offline, disabled, paused, or
gone the work runs here, quietly, with the named reason this module
produces. A user who picks their desktop and then walks over to switch it
on should get their audio, not an error about infrastructure.
2. **Mid-job, single-shot interactive work** raises instead. Silently
redoing minutes of remote work on the wrong machine is not a fallback;
the error carries a "run locally instead" that resubmits.
3. **Multi-unit jobs** (audiobook chapters, batches) fall back per unit
after N consecutive remote failures, with one aggregated notice a 4090
that goes to sleep at chapter 40 must not turn a working book into 160
identical error rows.
Only rule 1 lives here; rules 2 and 3 belong to the job surfaces that own the
work, because only they know whether a unit has already started.
Targeting is also per operation. The scheduler will place anything a worker
advertises, but a job only reaches the scheduler where this side has a
producer for it so ``decide(op=...)`` answers for the surface the user is
looking at rather than for the machine, and the badge cannot read
"gpu2 ● ready" on a tab whose work is 100% local.
Speech synthesis, chapter-at-a-time audiobook rendering, and coarse
``dub_segments`` synthesis have remote producers. Dub assembly, ASR,
diarization, translation and RVC remain local. Dictation is
intentionally local regardless of the selected target because its latency is
the feature.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from typing import Optional
logger = logging.getLogger("omnivoice.worker")
# Sentinel for "run on this machine". Not a worker id, and never confusable
# with one: worker ids are 12 hex characters.
LOCAL = "local"
# Operations with a remote producer today. Ports land one at a time, and this
# set is what keeps the picker honest about which ones have arrived.
#
# `dub` is the surface the user picks; `dub_segments` is the coarse worker op
# it dispatches (dub_generate.py). Both belong here because the GPU work does
# leave this machine — listing only the worker op would make the Dub tab read
# "Local" while a remote card renders it.
#
# Dictation is deliberately absent and should stay that way: it runs ASR per
# utterance inside a live WebSocket loop, where a round trip per utterance
# would spend the one thing that route exists for.
REMOTE_OPERATIONS = frozenset({"audiobook", "dub", "dub_segments", "tts"})
# Only for the sentence the user reads; an unknown op falls back to its id
# rather than inventing a name for it.
_OP_LABELS = {
"tts": "speech synthesis",
"clone": "voice cloning",
"dub": "dubbing",
"dub_segments": "dubbing",
"audiobook": "audiobook rendering",
"dictation": "dictation",
"asr": "transcription",
}
_SETTING_KEY = "worker_target"
@dataclass(frozen=True)
class Target:
"""One selectable entry in the GPU picker."""
id: str
label: str
endpoint: str = ""
connected: bool = False
available: bool = False
detail: str = ""
# ready | busy | offline — what the header dot is coloured on.
status: str = "offline"
latency_ms: float = 0.0
active_tasks: int = 0
max_tasks: int = 0
@property
def is_local(self) -> bool:
return self.id == LOCAL
def to_dict(self) -> dict:
return {
"id": self.id,
"label": self.label,
"endpoint": self.endpoint,
"connected": self.connected,
"available": self.available,
"detail": self.detail,
"is_local": self.is_local,
"status": self.status,
"latency_ms": round(self.latency_ms, 1),
"active_tasks": self.active_tasks,
"max_tasks": self.max_tasks,
}
def get_target_id() -> str:
"""The user's choice, or Local.
Persisted, because a target that resets to Local on every app start would
quietly send work to the wrong machine after a restart.
"""
try:
from services import settings_store # noqa: PLC0415
stored = (settings_store.get_text(_SETTING_KEY, "") or "").strip()
except Exception:
return LOCAL
return stored or LOCAL
def set_target_id(target_id: str) -> str:
"""Record the user's choice. Returns what was actually stored."""
from services import settings_store # noqa: PLC0415
chosen = (target_id or LOCAL).strip() or LOCAL
settings_store.set_text(_SETTING_KEY, chosen)
return chosen
def local_target(*, label: str = "Local") -> Target:
# This machine is reachable by definition and has no network latency to
# report — showing "0 ms" next to it would invite a false comparison.
return Target(id=LOCAL, label=label, connected=True, available=True, status="ready")
def list_targets(control_plane=None) -> list[Target]:
"""Local first, then every enrolled worker.
Revoked workers are omitted; a disabled or disconnected one is listed but
not ``available``, so the picker can show it greyed rather than pretending
the machine vanished.
"""
targets = [local_target()]
if control_plane is None:
from worker.service import control_plane as default_plane # noqa: PLC0415
control_plane = default_plane
try:
from worker import registry # noqa: PLC0415
enrolled = registry.list_workers()
except Exception:
logger.debug("Could not list enrolled workers", exc_info=True)
return targets
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
connected = live is not None and not live.stale()
available, detail = _availability(record, live, pool)
targets.append(
Target(
id=record.id,
label=record.name or record.key_id,
# The address it connected FROM beats any self-reported one.
endpoint=(live.address if live and live.address else record.endpoint),
connected=connected,
available=available,
detail=detail,
status=_status_for(record, live, available),
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,
)
)
return targets
def _status_for(record, live, available: bool) -> str:
"""ready / busy / offline — the three states the header dot colours on.
A worker that is connected but unusable for a config reason (disabled, not
approved, paused) is NOT "ready"; calling it ready would promise work can
go there when it cannot.
"""
if live is None or live.stale():
return "offline"
if available:
return live.status
return "busy" if live.status == "busy" else "offline"
def _availability(record, live, pool) -> tuple[bool, str]:
"""Can this worker take work right now, and if not, why not?
The reason is user-facing: it is what the picker shows under a greyed
entry, so it has to name something the user can act on.
"""
if record.revoked:
return False, "removed"
if not record.enabled:
return False, "disabled"
if record.consent_granted_at is None:
return False, "not approved"
if live is None:
return False, "offline"
if live.stale():
return False, "not responding"
if live.draining:
return False, "shutting down"
if pool is not None and pool.breakers.open_breakers(record.id):
return False, "paused after repeated failures"
return True, ""
@dataclass(frozen=True)
class Decision:
"""Where one job should run, and why."""
remote: bool
worker_id: Optional[str] = None
label: str = "Local"
reason: str = ""
def to_dict(self) -> dict:
return {
"remote": self.remote,
"worker_id": self.worker_id,
"label": self.label,
"reason": self.reason,
}
def supports_operation(op: Optional[str]) -> bool:
"""Does this kind of work have a remote path at all?
``None`` means "asking about the target itself, not about one job", which
is what the picker's own menu wants.
"""
return op is None or op in REMOTE_OPERATIONS
def _op_label(op: str) -> str:
return _OP_LABELS.get(op, op)
def decide(control_plane=None, *, op: Optional[str] = None) -> Decision:
"""Resolve the user's choice against what is actually reachable.
This is the single answer to "where does the next job run", used both by
the generation path and by the header badge so the badge cannot claim
something the router will not do.
``op`` narrows it to one kind of work. An unported operation is answered
before reachability is even consulted: whether the 4090 is awake is beside
the point when nothing on this side would send it a dub.
"""
target_id = get_target_id()
if target_id == LOCAL:
return Decision(remote=False, reason="chosen")
if not supports_operation(op):
return Decision(
remote=False,
reason=f"{_op_label(op)} does not run remotely yet — running locally",
)
if control_plane is None:
from worker.service import control_plane as default_plane # noqa: PLC0415
control_plane = default_plane
if not control_plane.running:
return Decision(remote=False, reason="remote workers are turned off")
for target in list_targets(control_plane):
if target.id != target_id:
continue
if target.available:
return Decision(
remote=True, worker_id=target.id, label=target.label, reason="chosen"
)
# Chosen but unusable: run locally and say which machine was skipped.
return Decision(
remote=False,
label="Local",
reason=f"{target.label} is {target.detail or 'unavailable'} — running locally",
)
# The chosen worker no longer exists (removed on another device, or the
# row was cleaned up). Fall back rather than stranding the user.
return Decision(remote=False, reason="the chosen worker no longer exists — running locally")
def status(control_plane=None, *, op: Optional[str] = None) -> dict:
"""Everything the GPU picker needs, in one call.
``op`` is echoed back so a caller that switched tabs mid-request can tell
which surface the answer describes, and ``remote_operations`` is what lets
the menu say "gpu2 · TTS only" instead of implying it takes everything.
"""
decision = decide(control_plane, op=op)
return {
"target": get_target_id(),
"op": op or "",
"active": decision.to_dict(),
"remote_operations": sorted(REMOTE_OPERATIONS),
"targets": [t.to_dict() for t in list_targets(control_plane)],
}
__all__ = [
"LOCAL",
"REMOTE_OPERATIONS",
"Decision",
"Target",
"decide",
"get_target_id",
"list_targets",
"local_target",
"set_target_id",
"status",
"supports_operation",
]
+997
View File
@@ -0,0 +1,997 @@
"""Central scheduler.
Two structural decisions the council settled, both of which shape everything
else in this module:
**One central queue.** The original design gave every worker its own queue with
its own depth and maximum size. That produces head-of-line blocking a task
committed to a busy worker waits while another sits idle and then demands
work-stealing to undo. Here the queue is central and workers hold only in-flight
slots, so a task is bound to a worker at the last possible moment.
**Filter, then strategy, then tiebreak.** The original had seven user-selectable
strategies *and* a ten-factor ranked scheduler, with no rule for how they
compose so "always use my primary" and "never use an unhealthy worker" could
each claim to be authoritative. The pipeline here is unambiguous:
1. hard filter enabled, connected, consented, capable, has capacity,
breaker closed, not excluded, not draining.
A user strategy can NEVER override these.
2. strategy priority-ordered or least-busy, over the survivors.
3. tiebreak warm model first, then lower load, then higher priority.
Model residency is in the tiebreak rather than the strategy because it is a
latency term, not a preference: a warm model is seconds away and a cold one can
be minutes.
"""
from __future__ import annotations
import asyncio
import enum
import logging
import uuid
from dataclasses import dataclass
from typing import Callable, Optional
from worker import deadlines as deadline_policy
from worker import task_store
from worker.breaker import Attribution
from worker.capacity import WorkerCapacity
from worker.clock import resolve
from worker.errors import ErrorClass, WorkerError
from worker.lifecycle import Attempt, PriorityClass, Task, TaskState, reconcile
from worker.pool import ConnectedWorker, WorkerPool
logger = logging.getLogger("omnivoice.worker")
# Bounded queue. Past this, submission is refused at the door with an
# actionable error rather than accepted and quietly timed out later.
_MAX_QUEUE_DEPTH = 200
# How often a progress report is written through to disk. Every frame would be
# a database write per second of a forty-minute dub; never writing leaves the
# persisted lease frozen at the one on_started stamped, so a restart mid-render
# reloads an attempt that expires on the next sweep. Ten seconds against a
# 120-second lease keeps the recovered value inside its own window.
_PROGRESS_PERSIST_SECONDS = 10.0
# What a restored attempt's lease is re-armed to. The clock we recover with is
# meaningless — it was ticking while we were not listening — and the worker
# cannot renew it before it reconnects, which its own backoff allows up to 60s
# for (`transport/client.py:_MAX_BACKOFF_SECONDS`). Anything shorter fails
# every healthy in-flight task on restart; anything much longer delays the
# honest verdict for work whose worker is genuinely gone.
_RESTART_REARM_SECONDS = 90.0
class Strategy(str, enum.Enum):
"""Two strategies, not seven.
``PRIORITY`` expresses primary/backup: the user's preferred machine simply
has a higher priority number. ``LEAST_BUSY`` is the default and is what
most setups actually want. Random and round-robin collapse into each other
once the eligibility filter has run, and lowest-latency is actively
misleading heartbeat round-trip is milliseconds while inference is
seconds, so it ranks on noise.
"""
LEAST_BUSY = "least_busy"
PRIORITY = "priority"
class QueueFull(RuntimeError):
"""Submission refused because the queue is at its bound."""
class NoEligibleWorker(RuntimeError):
"""No connected worker can run this task."""
def __init__(self, message: str, *, retryable: bool) -> None:
super().__init__(message)
self.retryable = retryable
class SchedulerStopped(RuntimeError):
"""The control plane shut down while a caller was awaiting a task.
Named, not a bare cancellation: the work may well still be running on the
worker, so the caller has to be able to say that rather than report a
failure that never happened.
"""
@dataclass(frozen=True)
class Assignment:
"""One task bound to one worker, ready to send."""
task: Task
attempt: Attempt
worker: ConnectedWorker
deadlines: deadline_policy.Deadlines
class Scheduler:
"""Owns the queue, the selection pipeline, and the deadline sweeper."""
def __init__(
self,
pool: WorkerPool,
*,
strategy: Strategy = Strategy.LEAST_BUSY,
max_queue_depth: int = _MAX_QUEUE_DEPTH,
persist: bool = True,
) -> None:
self.pool = pool
self.strategy = strategy
self.max_queue_depth = max_queue_depth
self._persist = persist
# Central queue: task_id → Task, insertion-ordered.
self._tasks: dict[str, Task] = {}
self._listeners: list[Callable[[str, Task], None]] = []
# Callers blocked in `wait`, task_id → one shared future. Deliberately not
# built on `on_change`: that list has no unregister, so one listener
# per await would leak for the life of the process. `shield` below
# prevents one request timeout from cancelling the shared outcome.
self._waiters: dict[str, asyncio.Future] = {}
self._waiter_counts: dict[str, int] = {}
# task_id → when its progress was last written through. Cleared with
# the task, so it cannot outlive what it describes.
self._progress_saved_at: dict[str, float] = {}
# ── Persistence seam ──────────────────────────────────────────────────
def _save(self, task: Task, *, now: Optional[float] = None) -> None:
if self._persist:
task_store.save(task, now=now)
def on_change(self, callback: Callable[[str, Task], None]) -> None:
"""Subscribe to task transitions (the UI's event feed hangs off this)."""
self._listeners.append(callback)
def _emit(self, event: str, task: Task) -> None:
for callback in self._listeners:
try:
callback(event, task)
except Exception:
logger.exception("Task listener failed for %s", event)
if task.state.terminal:
# The one funnel. Every terminal path already announces itself
# here, so hanging the await on this makes it impossible to add a
# new ending that forgets to wake the caller waiting on it.
self._resolve(task)
# ── Awaiting a result ─────────────────────────────────────────────────
async def wait(self, task_id: str, timeout: Optional[float] = None) -> Task:
"""Block until ``task_id`` reaches a terminal state, and return it.
Raises ``KeyError`` for a task this scheduler does not hold,
``TimeoutError`` when ``timeout`` elapses first (the task keeps
running the worker was never told anything), and ``SchedulerStopped``
if the control plane shuts down first.
"""
task = self._tasks.get(task_id)
if task is None:
raise KeyError(task_id)
# Checked before registering, not after: `submit` returns an existing
# task on an idempotency-key hit and `restore` adopts tasks from disk,
# so the task may have finished before anyone thought to wait for it —
# and nothing will ever emit a second terminal event for it.
if task.state.terminal:
return task
future = self._waiters.get(task_id)
if future is None:
future = asyncio.get_running_loop().create_future()
self._waiters[task_id] = future
self._waiter_counts[task_id] = self._waiter_counts.get(task_id, 0) + 1
try:
return await asyncio.wait_for(asyncio.shield(future), timeout)
finally:
remaining = self._waiter_counts.get(task_id, 1) - 1
if remaining > 0:
self._waiter_counts[task_id] = remaining
else:
self._waiter_counts.pop(task_id, None)
if not future.done() and self._waiters.get(task_id) is future:
self._waiters.pop(task_id, None)
def _resolve(self, task: Task) -> None:
"""Hand a finished task to everyone awaiting it, at most once.
Idempotent by construction. `on_failed` used to run its whole body even
when the attempt was already terminal, so one task could announce
"failed" after it had completed and a second `set_result` on a
settled future raises `InvalidStateError` inside the read loop, which
is a torn-down worker session for a message that changed nothing.
"""
self._progress_saved_at.pop(task.task_id, None)
future = self._waiters.pop(task.task_id, None)
self._waiter_counts.pop(task.task_id, None)
if future is not None and not future.done():
future.set_result(task)
def abort_waiters(self, reason: str = "The control plane stopped.") -> int:
"""Fail every outstanding waiter. Called from ``ControlPlane.stop``.
Without it, a shutdown leaves each awaiting request hanging on a future
nothing will ever complete, and the app cannot finish quitting.
"""
pending = self._waiters
self._waiters = {}
self._waiter_counts = {}
count = 0
for future in pending.values():
if not future.done():
future.set_exception(SchedulerStopped(reason))
count += 1
return count
# ── Submission ────────────────────────────────────────────────────────
def submit(
self,
*,
operation: str,
engine: str,
model_id: str,
params: Optional[dict] = None,
priority: PriorityClass = PriorityClass.INTERACTIVE,
idempotency_key: Optional[str] = None,
max_attempts: int = 3,
deadline_seconds: Optional[float] = None,
pinned_worker_id: Optional[str] = None,
now: Optional[float] = None,
) -> Task:
"""Admit a task, or refuse it at the door.
Refusing at submission is deliberate: accepting work into an unbounded
queue means the user waits, then gets a timeout that looks like their
hardware failed. A queue-full error names the real problem while they
can still act on it.
"""
stamp = resolve(now)
if idempotency_key:
for existing in self._tasks.values():
if existing.idempotency_key == idempotency_key:
return existing
if self._persist:
stored = task_store.get_by_idempotency_key(idempotency_key)
if stored is not None:
self._tasks.setdefault(stored.task_id, stored)
return stored
if self.queue_depth >= self.max_queue_depth:
raise QueueFull(
f"The remote task queue is full ({self.max_queue_depth} waiting). "
"Wait for current work to finish, or add another worker."
)
task = Task(
task_id=uuid.uuid4().hex[:16],
operation=operation,
engine=engine,
model_id=model_id,
params=params or {},
priority=priority,
idempotency_key=idempotency_key,
max_attempts=max_attempts,
created_at=stamp,
pinned_worker_id=pinned_worker_id,
)
if deadline_seconds:
task.deadline_at = stamp + deadline_seconds
self._tasks[task.task_id] = task
if self._persist:
task_store.create(task, now=stamp)
self._emit("queued", task)
return task
def adopt(self, task: Task) -> None:
"""Take ownership of a task loaded from disk after a restart."""
self._tasks[task.task_id] = task
def restore(self, *, now: Optional[float] = None) -> int:
"""Reload tasks that were live when the control plane stopped.
They are NOT failed on the way in unlike local jobs, the machine
doing the work is still running. Reconciliation decides each one's fate
when its worker reconnects.
"""
if not self._persist:
return 0
stamp = resolve(now)
restored = task_store.load_unfinished()
for task in restored:
if task.state is TaskState.QUEUED and task.deadline_at is None:
# Rows created before deadlines became mandatory would never
# be swept. Recovery gives them one bounded lifetime.
task.deadline_at = stamp + deadline_policy.for_task(
task.operation,
text=task.params.get("text"),
input_seconds=float(task.params.get("input_seconds") or 0.0),
).total_seconds
self._save(task, now=stamp)
self._tasks.setdefault(task.task_id, task)
attempt = task.active_attempt
if attempt is not None:
# A lease that expired while the app was closed says nothing
# about the worker: nobody was listening for the renewals. Give
# it a reconnect window instead, or the first sweep after
# startup kills every healthy in-flight task at once.
attempt.renew_lease(_RESTART_REARM_SECONDS, now=stamp)
if restored:
logger.info("Recovered %d in-flight remote task(s) after restart", len(restored))
return len(restored)
# ── Queue introspection ───────────────────────────────────────────────
@property
def queue_depth(self) -> int:
return sum(1 for t in self._tasks.values() if t.state is TaskState.QUEUED)
def get(self, task_id: str) -> Optional[Task]:
return self._tasks.get(task_id)
def tasks_for_worker(self, worker_id: str) -> list[Task]:
"""Live tasks this control plane believes ``worker_id`` is running.
Sent back on reconnect as the authoritative list: anything the worker
holds that is absent here is a zombie it must stop, and anything here
the worker does not claim was lost while we were apart.
"""
found = []
for task in self._tasks.values():
if task.state.terminal:
continue
attempt = task.active_attempt
if attempt is not None and attempt.worker_id == worker_id:
found.append(task)
return found
def position(self, task_id: str) -> int:
"""0-indexed place in line, or -1. Preserves the local queue's
"2 jobs ahead of you" affordance for remote work."""
target = self._tasks.get(task_id)
if target is None or target.state is not TaskState.QUEUED:
return -1
return sum(1 for t in self._queued_order() if t.task_id != task_id and self._ranks_before(t, target))
def _queued_order(self) -> list[Task]:
"""Interactive before batch, then FIFO inside each class."""
return sorted(
(t for t in self._tasks.values() if t.state is TaskState.QUEUED),
key=lambda t: (int(t.priority), t.created_at),
)
@staticmethod
def _ranks_before(a: Task, b: Task) -> bool:
return (int(a.priority), a.created_at) < (int(b.priority), b.created_at)
# ── Selection ─────────────────────────────────────────────────────────
def eligible_workers(self, task: Task, *, now: Optional[float] = None) -> list[ConnectedWorker]:
"""The hard filter. No strategy may bypass any of these."""
stamp = resolve(now)
model_key = WorkerCapacity.slot_key(task.engine, task.model_id)
eligible = []
for worker in self.pool:
if task.pinned_worker_id and worker.worker_id != task.pinned_worker_id:
continue
if not worker.record.schedulable or worker.draining:
continue
if worker.stale(now=stamp):
continue
if worker.worker_id in task.excluded_workers:
continue
if not worker.supports(task.engine, task.model_id, task.operation):
continue
if not worker.capacity.can_accept(task.engine, task.model_id):
continue
if not self.pool.breakers.allows(worker.worker_id, model_key, now=stamp):
continue
eligible.append(worker)
return eligible
def _rank(self, task: Task, workers: list[ConnectedWorker]) -> list[ConnectedWorker]:
"""Strategy, then tiebreak. Warm-model affinity is the first tiebreak
because it is worth more than any other factor here: a resident model
is seconds away, a cold one minutes."""
def tiebreak(worker: ConnectedWorker) -> tuple:
return (
0 if worker.is_warm(task.engine, task.model_id) else 1,
worker.capacity.active_tasks,
-worker.record.priority,
worker.record.created_at,
)
if self.strategy is Strategy.PRIORITY:
return sorted(workers, key=lambda w: (-w.record.priority, *tiebreak(w)))
return sorted(workers, key=lambda w: (w.capacity.active_tasks, *tiebreak(w)))
def select_worker(self, task: Task, *, now: Optional[float] = None) -> ConnectedWorker:
"""Pick the best eligible worker, or explain why there is none.
The two "nothing available" cases are deliberately distinguished: all
workers busy is a wait, no capable worker is a dead end, and telling a
user to wait for something that will never happen is the error-message
failure this project treats as a bug.
"""
stamp = resolve(now)
eligible = self.eligible_workers(task, now=stamp)
if eligible:
return self._rank(task, eligible)[0]
capable = [
w
for w in self.pool
if (not task.pinned_worker_id or w.worker_id == task.pinned_worker_id)
and w.record.schedulable and w.supports(task.engine, task.model_id, task.operation)
]
if not capable:
if task.pinned_worker_id:
# "Offline" and "here but cannot run this" are different facts,
# and answering both with the first sends the user to go wake a
# machine that is already awake — verified against a live
# worker reporting ready, 1 free slot and 3.6 ms latency while
# this raised "offline or cannot be reached".
pinned = next(
(w for w in self.pool if w.worker_id == task.pinned_worker_id), None
)
if pinned is not None and pinned.record.schedulable:
raise NoEligibleWorker(
f"The selected worker {task.pinned_worker_id} is connected but "
f"cannot run {task.engine or task.operation}. Install or download "
"it there, or choose another GPU.",
retryable=False,
)
raise NoEligibleWorker(
f"The selected worker {task.pinned_worker_id} is offline or cannot be reached.",
retryable=False,
)
raise NoEligibleWorker(
f"No connected worker can run {task.engine or task.operation}. "
"Check the worker is online and has that engine installed.",
retryable=False,
)
raise NoEligibleWorker(
"Every worker that can run this is busy or paused. The task stays queued.",
retryable=True,
)
# ── Dispatch ──────────────────────────────────────────────────────────
def next_assignment(self, *, now: Optional[float] = None) -> Optional[Assignment]:
"""Bind the highest-ranked waiting task to the best worker for it.
Returns ``None`` when nothing can be dispatched either the queue is
empty or every waiting task is blocked on capacity. A task with no
capable worker at all is failed here rather than left to age out.
"""
stamp = resolve(now)
for task in self._queued_order():
try:
worker = self.select_worker(task, now=stamp)
except NoEligibleWorker as exc:
if exc.retryable:
continue
pinned = bool(task.pinned_worker_id)
self._fail(
task,
WorkerError(
error_class=ErrorClass.CAPACITY if pinned else ErrorClass.CAPABILITY,
code="PINNED_WORKER_UNREACHABLE" if pinned else "NO_CAPABLE_WORKER",
message=str(exc),
hint=(
"Wake the selected worker, choose another GPU, or run locally."
if pinned else
"Install the engine on a worker, or run this task locally."
),
),
now=stamp,
)
continue
return self._bind(task, worker, now=stamp)
return None
def _bind(self, task: Task, worker: ConnectedWorker, *, now: float) -> Assignment:
attempt = task.assign(worker_id=worker.worker_id, session_epoch=worker.epoch, now=now)
worker.capacity.reserve(task.engine, task.model_id)
worker.in_flight.add(attempt.attempt_id)
budget = deadline_policy.for_task(
task.operation,
text=task.params.get("text"),
model_resident=worker.is_warm(task.engine, task.model_id),
model_downloaded=True,
input_seconds=float(task.params.get("input_seconds") or 0.0),
)
attempt.renew_lease(budget.accept_seconds, now=now)
self._save(task, now=now)
self._emit("assigned", task)
return Assignment(task=task, attempt=attempt, worker=worker, deadlines=budget)
# ── Worker callbacks ──────────────────────────────────────────────────
def _fenced(self, task_id: str, attempt_id: str, epoch: Optional[int]) -> Optional[Task]:
"""Resolve a task for an inbound message, dropping stale sessions."""
task = self._tasks.get(task_id)
if task is None:
return None
attempt = task.get_attempt(attempt_id)
if attempt is None:
return None
if epoch is not None and attempt.session_epoch != epoch:
logger.debug("Dropping message for %s from stale epoch %s", task_id, epoch)
return None
return task
def on_accepted(
self, task_id: str, attempt_id: str, *, epoch: Optional[int] = None, now: Optional[float] = None
) -> Optional[Task]:
task = self._fenced(task_id, attempt_id, epoch)
if task is None:
return None
stamp = resolve(now)
task.accept(attempt_id, session_epoch=epoch, now=stamp)
budget = self._budget_for(task)
task.get_attempt(attempt_id).renew_lease(budget.model_load_seconds, now=stamp)
self._save(task, now=stamp)
self._emit("accepted", task)
return task
def on_model_loading(
self,
task_id: str,
attempt_id: str,
*,
progress: float = -1.0,
detail: str = "",
epoch: Optional[int] = None,
now: Optional[float] = None,
) -> Optional[Task]:
task = self._fenced(task_id, attempt_id, epoch)
if task is None:
return None
stamp = resolve(now)
attempt = task.get_attempt(attempt_id)
if task.state is not TaskState.MODEL_LOADING:
task.model_loading(attempt_id, session_epoch=epoch, now=stamp)
attempt.stage = detail or "loading model"
# A load that is still reporting is not stuck, however long it takes.
attempt.renew_lease(self._budget_for(task).progress_lease_seconds, now=stamp)
self._save(task, now=stamp)
self._emit("model_loading", task)
return task
def on_started(
self, task_id: str, attempt_id: str, *, epoch: Optional[int] = None, now: Optional[float] = None
) -> Optional[Task]:
task = self._fenced(task_id, attempt_id, epoch)
if task is None:
return None
stamp = resolve(now)
task.start(attempt_id, session_epoch=epoch, now=stamp)
task.get_attempt(attempt_id).renew_lease(
self._budget_for(task).progress_lease_seconds, now=stamp
)
self._save(task, now=stamp)
self._emit("started", task)
return task
def on_progress(
self,
task_id: str,
attempt_id: str,
*,
progress: float,
stage: str = "",
keepalive: bool = False,
epoch: Optional[int] = None,
now: Optional[float] = None,
) -> Optional[Task]:
"""Renew an attempt's lease from a progress frame.
The single entry point for the lease, ceiling included the transport
passes the flag off the wire and never computes an expiry of its own,
so there is one place where "how long may this task stay alive" is
decided.
``keepalive`` frames are the worker's timer, not its work: they say the
process is still there while a single uninterruptible call runs. They
renew the lease but leave ``progress``/``stage`` alone, because
overwriting a real 60% with a timer's zero is a UI that goes backwards.
"""
task = self._fenced(task_id, attempt_id, epoch)
if task is None:
return None
stamp = resolve(now)
attempt = task.get_attempt(attempt_id)
budget = self._budget_for(task)
if keepalive:
attempt.renew_lease(
budget.progress_lease_seconds,
not_after=self._phase_ceiling(task, attempt, budget),
now=stamp,
)
self._persist_progress(task, now=stamp)
return task
attempt.progress = max(0.0, min(1.0, progress))
attempt.stage = stage or attempt.stage
# Evidence of actual work: renewed without a ceiling, because a task
# that keeps producing output is not wedged however long it takes.
attempt.renew_lease(budget.progress_lease_seconds, now=stamp)
self._persist_progress(task, now=stamp)
self._emit("progress", task)
return task
@staticmethod
def _phase_budget(task: Task, budget: deadline_policy.Deadlines) -> int:
"""How long the task's current phase is allowed to take in total."""
if task.state is TaskState.MODEL_LOADING:
return budget.model_load_seconds
if task.state is TaskState.RESULT_UPLOADING:
return budget.result_delivery_seconds
return budget.execution_seconds
def _phase_ceiling(
self, task: Task, attempt: Attempt, budget: deadline_policy.Deadlines
) -> float:
"""The absolute time a keepalive may not renew past.
Bounds the keepalive by the budget of the phase it is keeping alive.
Without this the keepalive would delete the last enforced bound in the
system: `Deadlines.total_seconds` has no callers, `execution_seconds`
is put on the wire and read by nobody, and a RUNNING attempt past its
task deadline is never swept the progress lease is all there is.
"""
return attempt.phase_anchor + self._phase_budget(task, budget)
def _persist_progress(self, task: Task, *, now: float) -> None:
"""Write a progress report through to disk, at most every few seconds.
The persisted lease used to be whichever one `on_started` stamped, so a
restart mid-render restored an attempt whose lease had expired minutes
ago and the first sweep failed it the healthiest task in the system
killed by the recovery meant to save it.
"""
if not self._persist:
return
last = self._progress_saved_at.get(task.task_id)
if last is not None and now - last < _PROGRESS_PERSIST_SECONDS:
return
self._progress_saved_at[task.task_id] = now
self._save(task, now=now)
def on_result(
self,
task_id: str,
attempt_id: str,
*,
result_ref: Optional[str] = None,
result: Optional[dict] = None,
epoch: Optional[int] = None,
now: Optional[float] = None,
) -> tuple[bool, Optional[Task]]:
"""Commit a result. Returns ``(committed, task)``.
The caller must acknowledge the worker in BOTH cases a duplicate
still needs its ack, or the worker redelivers forever but must only
apply the result when ``committed`` is True.
The commit is durable before this returns, which is what makes the
subsequent RESULT_ACK safe to send.
"""
stamp = resolve(now)
task = self._tasks.get(task_id)
if task is None:
# Unknown task: either purged, or this control plane restarted and
# never reloaded it. If disk says it completed, ack-and-discard.
if self._persist and task_store.is_committed(task_id):
return False, None
return False, None
attempt = task.get_attempt(attempt_id)
if attempt is None:
return False, task
if epoch is not None and attempt.session_epoch != epoch:
return False, task
committed, attempt = task.commit_result(
attempt_id, result_ref=result_ref, session_epoch=epoch, now=stamp
)
self._release_slot(task, attempt, now=stamp)
worker = self.pool.get(attempt.worker_id)
if worker is not None and committed:
self.pool.breakers.record_success(
worker.worker_id,
WorkerCapacity.slot_key(task.engine, task.model_id),
now=stamp,
)
if committed and self._persist:
task_store.commit_result(task, result_json=result, now=stamp)
elif self._persist:
self._save(task, now=stamp)
self._emit("completed" if committed else "duplicate", task)
return committed, task
def on_failed(
self,
task_id: str,
attempt_id: str,
error: WorkerError,
*,
epoch: Optional[int] = None,
now: Optional[float] = None,
) -> Optional[Task]:
task = self._fenced(task_id, attempt_id, epoch)
if task is None:
return None
stamp = resolve(now)
attempt = task.get_attempt(attempt_id)
if attempt.state.terminal:
# Already settled — a redelivered failure, or one the sweeper got
# to first. `fail_attempt` ignores it, so running the rest of this
# body would charge the breaker twice for one failure and announce
# "failed" for a task that may have completed since.
return task
worker = self.pool.get(attempt.worker_id)
model_key = WorkerCapacity.slot_key(task.engine, task.model_id)
# Computed before the attempt is settled, while it is still the active
# one the budget is derived from.
budget = self._budget_for(task)
task.fail_attempt(attempt_id, error, session_epoch=epoch, now=stamp)
if worker is not None:
# A timeout leaves a GPU thread that cannot be killed, so its slot
# is parked rather than returned (#730/#1190) — for as long as that
# thread's own budget could still be running it.
self._release_slot(
task,
attempt,
zombie=error.error_class is ErrorClass.TIMEOUT,
zombie_ttl_seconds=budget.execution_seconds,
now=stamp,
)
attribution, opened = self.pool.breakers.record_failure(
worker.worker_id, model_key, error, now=stamp
)
if opened:
logger.info(
"Circuit breaker opened for %s / %s after repeated failures",
worker.name,
model_key,
)
elif attribution is Attribution.INFRA:
logger.info("Suppressing penalty for %s — fleet-wide failure detected", worker.name)
self._save(task, now=stamp)
self._emit("failed" if task.state.terminal else "requeued", task)
return task
def on_disconnected(self, worker_id: str, *, now: Optional[float] = None) -> list[Task]:
"""A worker's stream dropped. Start grace windows; fail nothing.
This is where the duplicate-execution bug would live if a disconnect
were treated as a failure: the worker may be seconds from delivering.
"""
stamp = resolve(now)
affected: list[Task] = []
for task in self._tasks.values():
attempt = task.active_attempt
if attempt is None or attempt.worker_id != worker_id:
continue
grace = deadline_policy.default_grace_seconds(task.operation)
task.mark_disconnected(attempt.attempt_id, grace_seconds=grace, now=stamp)
affected.append(task)
self._save(task, now=stamp)
self._emit("worker_lost", task)
self.pool.disconnect(worker_id)
return affected
def on_reconnected(
self, worker_id: str, *, in_flight: set[str], now: Optional[float] = None
) -> list[str]:
"""Reconcile against what the worker says it is running.
Returns attempt ids the worker should cancel work we have already
written off, which it must stop burning a GPU on.
"""
stamp = resolve(now)
zombies: list[str] = []
for task in list(self._tasks.values()):
if task.state.terminal:
continue
action = reconcile(
task,
worker_id=worker_id,
worker_in_flight=in_flight,
# A resumed attempt has been silent for the whole outage; the
# lease it carries was stamped before it. Give it a fresh one
# or the next sweep fails the task we just recovered.
resume_lease_seconds=self._budget_for(task).progress_lease_seconds,
now=stamp,
)
if action == "cancel_zombie":
zombies.extend(
a for a in in_flight if (k := task.get_attempt(a)) and k.state.terminal
)
self._save(task, now=stamp)
return zombies
def cancel(self, task_id: str, *, reason: str = "cancelled", now: Optional[float] = None) -> bool:
task = self._tasks.get(task_id)
if task is None or task.state.terminal:
return False
stamp = resolve(now)
attempt = task.active_attempt
task.cancel(reason=reason, now=stamp)
# A live GPU call is not known to have stopped yet. Its slot remains
# parked in `in_flight` until the worker acknowledges TaskCancel.
self._save(task, now=stamp)
self._emit("cancelled", task)
return True
def on_cancel_ack(
self, task_id: str, attempt_id: str, *, epoch: Optional[int] = None,
now: Optional[float] = None,
) -> Optional[Task]:
task = self._fenced(task_id, attempt_id, epoch)
if task is None:
return None
attempt = task.get_attempt(attempt_id)
self._release_slot(task, attempt, now=now)
self._save(task, now=now)
return task
# ── Sweeper ───────────────────────────────────────────────────────────
def sweep(self, *, now: Optional[float] = None) -> list[Task]:
"""Enforce leases, grace windows, and task deadlines.
Called on a timer. Everything time-based happens here rather than being
scattered across callbacks, so there is one place to reason about what
expires and in what order.
"""
stamp = resolve(now)
changed: list[Task] = []
for worker in self.pool.stale_workers(now=stamp):
logger.info("Worker %s missed its heartbeats — treating as disconnected", worker.name)
changed.extend(self.on_disconnected(worker.worker_id, now=stamp))
for worker in self.pool:
# The park's TTL is enforced here rather than only on a heartbeat:
# the sweeper is the one loop with an injectable clock, and a
# worker whose last slot is parked is exactly the worker whose
# heartbeats we may have stopped believing.
worker.capacity.expire_zombies(now=stamp)
for task in list(self._tasks.values()):
if task.state.terminal:
continue
attempt = task.active_attempt
if attempt is not None and attempt.grace_expired(now=stamp):
task.lose_attempt(attempt.attempt_id, now=stamp)
# Parked, not returned: we never learned whether the worker's
# GPU thread stopped, and it is the same un-killable thread the
# timeout path parks for.
self._release_slot(
task,
attempt,
zombie=True,
zombie_ttl_seconds=self._budget_for(task).execution_seconds,
now=stamp,
)
changed.append(task)
self._save(task, now=stamp)
self._emit("attempt_lost", task)
continue
if attempt is not None and attempt.disconnected_at is None and attempt.lease_expired(now=stamp):
self._expire(task, attempt, now=stamp)
changed.append(task)
continue
if task.deadline_exceeded(now=stamp) and task.state is TaskState.QUEUED:
self._fail(
task,
WorkerError(
error_class=ErrorClass.TIMEOUT,
code="TASK_DEADLINE_EXCEEDED",
message="The task waited for an available worker past its deadline.",
hint="Add a worker, or run this task locally.",
),
now=stamp,
)
changed.append(task)
return changed
def _expire(self, task: Task, attempt: Attempt, *, now: float) -> None:
"""A live attempt stopped reporting — or never stopped and never finished.
Silence is the usual failure signal, but a keepalive-driven lease can
also simply reach its phase ceiling, and the two are different stories
for the user: one machine went quiet, the other is still working on
something that has run past every budget we gave it. Naming them the
same way sends the second one to a "check the worker is online" hint
for a worker that is demonstrably online.
"""
# ASSIGNED is excluded: it has no phase of its own to overrun, only an
# accept window, and silence is the only thing that can end it.
exhausted = task.state is not TaskState.ASSIGNED and now >= self._phase_ceiling(
task, attempt, self._budget_for(task)
)
code = {
TaskState.ASSIGNED: "ACCEPT_TIMEOUT",
TaskState.MODEL_LOADING: "MODEL_LOAD_TIMEOUT",
TaskState.RESULT_UPLOADING: "RESULT_DELIVERY_TIMEOUT",
}.get(task.state, "EXECUTION_TIMEOUT" if exhausted else "PROGRESS_LEASE_EXPIRED")
self.on_failed(
task.task_id,
attempt.attempt_id,
WorkerError(
error_class=ErrorClass.TIMEOUT,
code=code,
message=(
"The task ran past the time budgeted for this stage."
if exhausted
else "The worker stopped reporting progress."
),
hint="It will be retried on another worker if one is available.",
),
epoch=attempt.session_epoch,
now=now,
)
def _release_slot(
self,
task: Task,
attempt: Attempt,
*,
zombie: bool = False,
zombie_ttl_seconds: Optional[float] = None,
now: Optional[float] = None,
) -> bool:
"""Return one attempt's slot to its worker, at most once.
``in_flight`` is the record of whether this attempt still holds a slot,
and every release goes through this guard: `capacity.release` protects
its per-model counter but the worker-wide one is a plain decrement, so
two paths ending the same attempt a result racing the sweeper, a
cancel racing a late failure would hand back capacity that was never
taken and overcommit the machine.
"""
worker = self.pool.get(attempt.worker_id)
if worker is None or attempt.attempt_id not in worker.in_flight:
return False
worker.in_flight.discard(attempt.attempt_id)
return worker.capacity.release(
task.engine,
task.model_id,
zombie=zombie,
zombie_ttl_seconds=zombie_ttl_seconds,
now=now,
)
def _fail(self, task: Task, error: WorkerError, *, now: float) -> None:
task.error = error
task.state = TaskState.FAILED if error.error_class is not ErrorClass.TIMEOUT else TaskState.TIMEOUT
task.finished_at = now
self._save(task, now=now)
self._emit("failed", task)
def _budget_for(self, task: Task) -> deadline_policy.Deadlines:
attempt = task.active_attempt
worker = self.pool.get(attempt.worker_id) if attempt else None
return deadline_policy.for_task(
task.operation,
text=task.params.get("text"),
model_resident=bool(worker and worker.is_warm(task.engine, task.model_id)),
input_seconds=float(task.params.get("input_seconds") or 0.0),
)
__all__ = ["Assignment", "NoEligibleWorker", "QueueFull", "Scheduler", "Strategy"]
+354
View File
@@ -0,0 +1,354 @@
"""Lifecycle for the remote-worker feature, on both sides.
One module owns starting and stopping everything, because the feature has to be
genuinely absent when it is switched off. The local-first guarantee is not
"we do not use the network much" it is that a user who never enables this
has no listening socket, no certificate, no background loop, and an app that
behaves exactly as it did before.
So nothing here runs unless ``remote_workers_enabled()`` is true, and the gRPC
imports happen inside the start path rather than at module import.
"""
from __future__ import annotations
import asyncio
import logging
import os
from typing import Optional
from worker.clock import resolve
logger = logging.getLogger("omnivoice.worker")
# How often the scheduler enforces leases, grace windows, and deadlines.
_SWEEP_INTERVAL_SECONDS = 5.0
# How often the dispatcher looks for queued work it can place.
_DISPATCH_INTERVAL_SECONDS = 1.0
DEFAULT_PORT = 7443
def remote_workers_enabled() -> bool:
"""Opt-in gate. Off unless the user turned it on.
Checked in the environment first so a headless/server deployment can enable
it without a UI, then in settings for the desktop case.
"""
env = (os.environ.get("OMNIVOICE_REMOTE_WORKERS") or "").strip().lower()
if env in ("1", "true", "yes", "on"):
return True
if env in ("0", "false", "no", "off"):
return False
try:
from services import settings_store # noqa: PLC0415
stored = (settings_store.get_text("remote_workers_enabled", "") or "").strip().lower()
return stored in ("1", "true", "yes", "on")
except Exception:
return False
def set_remote_workers_enabled(enabled: bool) -> None:
from services import settings_store # noqa: PLC0415
settings_store.set_text("remote_workers_enabled", "true" if enabled else "false")
def control_port() -> int:
try:
return int(os.environ.get("OMNIVOICE_WORKER_PORT") or DEFAULT_PORT)
except ValueError:
return DEFAULT_PORT
def _data_dir() -> str:
try:
from core.config import DATA_DIR # noqa: PLC0415
return str(DATA_DIR)
except Exception:
return os.path.expanduser("~/.omnivoice")
def paths() -> dict[str, str]:
"""Where the feature keeps its state, all under the user's data dir."""
root = os.path.join(_data_dir(), "workers")
return {
"root": root,
"certificate": os.path.join(root, "control-plane.crt"),
"private_key": os.path.join(root, "control-plane.key"),
"worker_key": os.path.join(root, "worker.key"),
"artifacts": os.path.join(root, "artifacts"),
}
class ControlPlane:
"""Owns the scheduler, the worker pool, and the gRPC server."""
def __init__(self) -> None:
self.pool = None
self.scheduler = None
self.servicer = None
self.credentials = None
self._server = None
self._tasks: list[asyncio.Task] = []
self._started = False
self.startup_error: Optional[str] = None
# The port we actually bound, which is not necessarily the configured
# one — an enrollment token carries this, so advertising the config
# value instead hands workers an endpoint nothing is listening on.
self._port: Optional[int] = None
@property
def running(self) -> bool:
return self._started
@property
def fingerprint(self) -> str:
return self.credentials.fingerprint if self.credentials else ""
async def start(self, *, port: Optional[int] = None) -> None:
if self._started:
return
# Imported here, not at module scope: a user who never enables remote
# workers should not pay grpc's import cost at every backend start.
from worker import tls # noqa: PLC0415
from worker.pool import WorkerPool # noqa: PLC0415
from worker.scheduler import Scheduler # noqa: PLC0415
from worker.transport.server import WorkerServicer, serve # noqa: PLC0415
locations = paths()
os.makedirs(locations["root"], exist_ok=True)
self.credentials = tls.load_or_create(
locations["certificate"], locations["private_key"]
)
self.pool = WorkerPool()
self.scheduler = Scheduler(self.pool)
# Recover anything that was in flight when the app last quit. The
# workers holding those tasks may still be rendering.
self.scheduler.restore()
self.servicer = WorkerServicer(
self.scheduler,
self.pool,
artifact_dir=locations["artifacts"],
cert_fingerprint=self.credentials.fingerprint,
)
self._port = port or control_port()
try:
self._server = await serve(
self.servicer,
port=self._port,
certificate_pem=self.credentials.certificate_pem,
private_key_pem=self.credentials.private_key_pem,
)
except Exception:
self._port = None
raise
self._tasks = [
asyncio.create_task(self._sweep_loop(), name="worker-sweep"),
asyncio.create_task(self._dispatch_loop(), name="worker-dispatch"),
]
self._started = True
self.startup_error = None
logger.info("Remote worker control plane started on port %d", self._port)
async def stop(self) -> None:
for task in self._tasks:
task.cancel()
if self._tasks:
await asyncio.gather(*self._tasks, return_exceptions=True)
self._tasks = []
if self.scheduler is not None:
# Anyone awaiting a task is waiting on a future only this scheduler
# will ever complete, and the sweeper that would have timed it out
# has just been cancelled — so a shutdown would otherwise hang the
# request, and with it the app's own quit.
self.scheduler.abort_waiters()
if self._server is not None:
# A short grace so in-flight acknowledgements land; anything longer
# would delay app shutdown for work that survives anyway.
await self._server.stop(grace=2.0)
self._server = None
self._port = None
self._started = False
self.startup_error = None
async def cancel(self, task_id: str, *, reason: str = "cancelled") -> bool:
"""Cancel locally, notify the owner, and hold its slot until ACK."""
if self.scheduler is None:
return False
task = self.scheduler.get(task_id) if hasattr(self.scheduler, "get") else None
attempt = task.active_attempt if task is not None else None
if not self.scheduler.cancel(task_id, reason=reason):
return False
if attempt is not None and self.servicer is not None:
await self.servicer.cancel(
attempt.worker_id,
task_id,
attempt.attempt_id,
attempt.session_epoch,
)
return True
async def _sweep_loop(self) -> None:
while True:
await asyncio.sleep(_SWEEP_INTERVAL_SECONDS)
try:
self.scheduler.sweep()
except Exception:
logger.exception("Worker sweep failed")
async def _dispatch_loop(self) -> None:
"""Place queued work on eligible workers.
A failed hand-off is not a task failure: the stream may have dropped
between selection and send, so the attempt is returned to the queue for
the next pass rather than charged to anyone.
"""
while True:
await asyncio.sleep(_DISPATCH_INTERVAL_SECONDS)
try:
while True:
assignment = self.scheduler.next_assignment()
if assignment is None:
break
if not await self.servicer.dispatch(assignment):
from worker.errors import ErrorClass, WorkerError # noqa: PLC0415
self.scheduler.on_failed(
assignment.task.task_id,
assignment.attempt.attempt_id,
WorkerError(
error_class=ErrorClass.CAPACITY,
code="WORKER_UNREACHABLE",
message="The worker's connection dropped before the task was sent.",
),
epoch=assignment.attempt.session_epoch,
)
except Exception:
logger.exception("Worker dispatch failed")
# ── Enrollment ────────────────────────────────────────────────────────
def create_enrollment(self, *, endpoint: str = "", label: str = "", ttl_seconds: int = 900):
"""Mint a join token carrying this control plane's fingerprint."""
from worker import registry # noqa: PLC0415
return registry.create_enrollment(
endpoint=endpoint or self.default_endpoint(),
cert_fingerprint=self.fingerprint,
label=label,
ttl_seconds=ttl_seconds,
)
def default_endpoint(self) -> str:
"""A best guess at how a worker should reach us.
An IP address, not the hostname. gRPC resolves through c-ares, which
does not speak mDNS so the ``host.local`` that macOS reports (and
that Python's own resolver happily resolves) produces a token no worker
can connect with. The LAN address works on the same network and is at
least a correct starting point elsewhere.
Still only a guess: a laptop behind NAT has no address that is right
from everywhere, which is why the docs lead with a tailnet and why this
is overridable.
"""
from worker import tls # noqa: PLC0415
host = (
os.environ.get("OMNIVOICE_WORKER_ENDPOINT_HOST")
or tls.primary_ip()
or "127.0.0.1"
)
return f"{host}:{self._port or control_port()}"
def snapshot(self, *, now: Optional[float] = None) -> dict:
"""Everything the workers UI needs in one call."""
stamp = resolve(now)
if not self.running:
return {
"enabled": remote_workers_enabled(),
"running": False,
"startup_error": self.startup_error,
"workers": [],
"queue_depth": 0,
}
from worker import registry # noqa: PLC0415
# Config comes from the DATABASE, liveness from the pool — never the
# other way round. The pool holds the RemoteWorker it was handed when
# the worker connected, so reading a name or a priority from there
# serves whatever was true at connect time: rename a connected worker
# and the UI would show the old name until it reconnected.
connected = {w.worker_id: w for w in self.pool}
workers = []
for record in registry.list_workers():
entry = record.to_dict()
live = connected.get(record.id)
if live is None:
entry["connected"] = False
else:
entry.update(
{
"connected": True,
"draining": live.draining,
"latency_ms": round(live.latency_ms, 1),
"address": live.address,
"status": live.status,
"active_tasks": live.capacity.active_tasks,
"available_slots": live.capacity.available_slots,
"resident_models": sorted(live.capacity.resident_models),
"stale": live.stale(now=stamp),
}
)
entry["breakers"] = [
b.to_dict(now=stamp) for b in self.pool.breakers.open_breakers(record.id, now=stamp)
]
workers.append(entry)
return {
"enabled": True,
"running": True,
"endpoint": self.default_endpoint(),
"fingerprint": self.fingerprint,
"queue_depth": self.scheduler.queue_depth,
"workers": workers,
}
# Process-wide control plane. One per backend, created lazily.
control_plane = ControlPlane()
async def start_if_enabled() -> None:
"""Called from the app lifespan. A no-op unless the user opted in."""
if not remote_workers_enabled():
logger.debug("Remote workers are disabled; not starting the control plane.")
return
try:
await control_plane.start()
except Exception as exc:
# A failure here must never take the app down with it: the user's
# local workflow does not depend on this feature existing.
control_plane.startup_error = str(exc)
logger.exception("Remote worker control plane failed to start")
async def stop() -> None:
try:
await control_plane.stop()
except Exception:
logger.exception("Remote worker control plane failed to stop cleanly")
__all__ = [
"ControlPlane",
"DEFAULT_PORT",
"control_plane",
"control_port",
"paths",
"remote_workers_enabled",
"start_if_enabled",
"stop",
]
+639
View File
@@ -0,0 +1,639 @@
"""Durable task state for remote work.
The local ``core/job_store.py`` marks every in-flight job failed on startup,
because a local job died with the process that was running it. Remote tasks
invert that: the control plane is a desktop app the user quits at will, and the
GPU on the other machine keeps rendering regardless. So restart must *recover*
in-flight tasks, not bury them.
The one ordering rule that makes at-least-once delivery safe:
persist the result, THEN send RESULT_ACK
If the acknowledgement goes first and the server dies before writing, the
worker has been told it may drop its copy and a forty-minute dub is gone with
no error anywhere. ``commit_result`` writes inside the same transaction that
flips the task to completed, so the ack can only follow a durable fact.
"""
from __future__ import annotations
import hashlib
import json
import logging
import mimetypes
import os
import re
import shutil
import time
from typing import Iterable, Iterator, Optional
from core.db import db_conn
from core.path_security import UnsafePath, resolve_within, safe_filename
from worker.clock import resolve
from worker.errors import ErrorClass, WorkerError
from worker.lifecycle import Attempt, AttemptState, PriorityClass, Task, TaskState
logger = logging.getLogger("omnivoice.worker")
def _dump_error(error: Optional[WorkerError]) -> Optional[str]:
return json.dumps(error.to_dict()) if error else None
def _load_error(raw: Optional[str]) -> Optional[WorkerError]:
if not raw:
return None
try:
data = json.loads(raw)
return WorkerError(
error_class=ErrorClass(data["error_class"]),
code=data.get("code", "UNKNOWN"),
message=data.get("message", ""),
hint=data.get("hint", ""),
)
except Exception:
return None
def _row_to_attempt(row) -> Attempt:
attempt = Attempt(
attempt_id=row["id"],
task_id=row["task_id"],
worker_id=row["worker_id"],
session_epoch=int(row["session_epoch"]),
attempt_number=int(row["attempt_number"]),
state=AttemptState(row["state"]),
created_at=float(row["created_at"]),
)
attempt.accepted_at = row["accepted_at"]
attempt.started_at = row["started_at"]
attempt.finished_at = row["finished_at"]
attempt.lease_expires_at = row["lease_expires_at"]
attempt.grace_expires_at = row["grace_expires_at"]
attempt.progress = float(row["progress"])
attempt.stage = row["stage"] or ""
attempt.error = _load_error(row["error_json"])
return attempt
def _row_to_task(row, attempts: list[Attempt]) -> Task:
task = Task(
task_id=row["id"],
operation=row["operation"],
engine=row["engine"] or "",
model_id=row["model_id"] or "",
params=json.loads(row["params_json"] or "{}"),
priority=PriorityClass(int(row["priority"])),
idempotency_key=row["idempotency_key"],
state=TaskState(row["state"]),
max_attempts=int(row["max_attempts"]),
created_at=float(row["created_at"]),
pinned_worker_id=row["pinned_worker_id"],
)
task.attempts = sorted(attempts, key=lambda a: a.attempt_number)
task.finished_at = row["finished_at"]
task.deadline_at = row["deadline_at"]
task.error = _load_error(row["error_json"])
task.result_ref = row["result_ref"]
task.excluded_workers = set(json.loads(row["excluded_json"] or "[]"))
return task
# ── Input artifacts ────────────────────────────────────────────────────────
#
# A worker is another machine. Every file-valued parameter — reference audio
# for a clone, a source video for a dub — lives in ``VOICES_DIR`` or a tempdir
# on the *control plane*, so sending its path is sending a string that names
# nothing on the far side. That is why remote cloning could not work: the
# assignment carried ``ref_audio=/Users/…/voices/x.wav`` and the worker either
# failed to open it or, worse, rendered with the default voice.
#
# Staging copies those files into the artifact directory the control plane
# already serves over ``DownloadArtifact``, which refuses anything outside it.
# The copy is named by the SHA-256 of its contents, so cloning the same voice
# a hundred times keeps exactly one copy on disk and lets the worker's own
# cache skip the transfer entirely on every clone after the first.
INPUT_PARAM_KEYS: tuple[str, ...] = (
"ref_audio",
"reference_audio",
"prompt_audio",
"prompt_wav",
"source_audio",
"audio_path",
"source_video",
"video_path",
)
# Where staged inputs live under the artifact root, and the key under which a
# task records what was staged for it. The record is what makes the purge
# exact: an input is deletable only when no surviving task still refers to it.
INPUTS_DIRNAME = "inputs"
INPUTS_PARAM_KEY = "inputs"
_HASH_CHUNK_BYTES = 1024 * 1024
_SAFE_EXTENSION = re.compile(r"^\.[A-Za-z0-9]{1,8}$")
class InputStagingError(RuntimeError):
"""A task input could not be staged for transfer to a worker.
Raised rather than swallowed: a clone whose reference audio silently went
missing does not fail, it renders someone else's voice.
"""
def artifact_root(*, create_dir: bool = True) -> str:
"""The directory the control plane serves artifacts from.
Imported lazily: ``worker.service`` owns the layout, and a module-level
import here would tie the durable store to the lifecycle module that
starts the gRPC server.
"""
from worker.service import paths # noqa: PLC0415 — layout owner, not a dependency
root = paths()["artifacts"]
if create_dir:
os.makedirs(os.path.join(root, INPUTS_DIRNAME), exist_ok=True)
return root
def _extension(source: str) -> str:
"""The source extension when it is a plain one, else nothing.
Kept for the worker's benefit — soundfile sniffs content, but an engine
that shells out to ffmpeg reads the suffix and sanitised because the
name is about to become a filesystem path.
"""
suffix = os.path.splitext(str(source))[1]
return suffix.lower() if _SAFE_EXTENSION.match(suffix) else ""
def _digest(path: str) -> tuple[str, int]:
"""(sha256, size) read in chunks — a source video is not a bytes object."""
digest = hashlib.sha256()
size = 0
with open(path, "rb") as handle:
while True:
block = handle.read(_HASH_CHUNK_BYTES)
if not block:
break
digest.update(block)
size += len(block)
return digest.hexdigest(), size
def stage_input(source: str, *, root: Optional[str] = None, now: Optional[float] = None) -> dict:
"""Copy one input into the artifact store, keyed by its content hash.
Returns the record that ends up on the task row. ``source`` is kept in it
so a local fallback still has the original file, and stripped before the
record reaches the wire.
"""
stamp = resolve(now)
base = root or artifact_root()
try:
digest, size = _digest(source)
except OSError as exc:
raise InputStagingError(f"Could not read the task input {source!r}: {exc}") from exc
artifact_id = os.path.join(INPUTS_DIRNAME, f"{digest}{_extension(source)}")
try:
destination = resolve_within(base, artifact_id)
except UnsafePath as exc: # pragma: no cover — the id is ours, hex only
raise InputStagingError(f"Refusing to stage {source!r} outside the artifact store") from exc
try:
destination.parent.mkdir(parents=True, exist_ok=True)
# Same size at a content-addressed name means the same bytes: the only
# writer is the rename below, so a truncated file cannot exist here.
if not (destination.is_file() and destination.stat().st_size == size):
partial = destination.with_name(destination.name + ".part")
shutil.copyfile(source, partial)
os.replace(partial, destination)
# Freshness, not decoration: the purge dates an unreferenced input by
# its mtime, so re-using a staged voice has to renew it.
os.utime(destination, (stamp, stamp))
except OSError as exc:
raise InputStagingError(f"Could not stage the task input {source!r}: {exc}") from exc
filename = os.path.basename(str(source)) or f"{digest}{_extension(source)}"
return {
"artifact_id": artifact_id,
"path": str(destination),
"source": str(source),
"filename": filename,
"sha256": digest,
"size_bytes": size,
"content_type": mimetypes.guess_type(filename)[0] or "application/octet-stream",
}
def _iter_input_values(params: dict) -> Iterator[tuple[str, Optional[int], str]]:
"""``(key, index, value)`` for every parameter that could name a file."""
for key in INPUT_PARAM_KEYS:
value = params.get(key)
if isinstance(value, str):
yield key, None, value
elif isinstance(value, list):
for index, item in enumerate(value):
if isinstance(item, str):
yield key, index, item
def ensure_staged(
task: Task, *, root: Optional[str] = None, now: Optional[float] = None
) -> list[dict]:
"""Stage every file-valued parameter of *task*, once.
Idempotent by design it runs at submission (so the durable row records
what a later purge must keep) and again at dispatch (so a task built
without the store, or a scheduler running unpersisted, still gets inputs
the worker can fetch). Already-staged keys are skipped, so the second call
does no I/O.
"""
params = task.params if isinstance(task.params, dict) else {}
recorded = params.get(INPUTS_PARAM_KEY)
entries: list[dict] = [e for e in recorded if isinstance(e, dict)] if isinstance(recorded, list) else []
if root:
# A task may have been staged when it was submitted under the default
# store, then dispatched by a servicer configured with another store.
# Recorded metadata is not proof that this servicer can serve it.
refreshed: list[dict] = []
for entry in entries:
artifact_id = str(entry.get("artifact_id") or "")
try:
available = bool(artifact_id and resolve_within(root, artifact_id).is_file())
except UnsafePath:
available = False
if available:
refreshed.append(entry)
continue
source = str(entry.get("source") or "")
if source and os.path.isfile(source):
replacement = stage_input(source, root=root, now=now)
replacement.update(key=entry.get("key"), index=entry.get("index"))
refreshed.append(replacement)
else:
raise InputStagingError(
f"The staged task input {artifact_id!r} is unavailable in this artifact store."
)
entries = refreshed
params[INPUTS_PARAM_KEY] = entries
covered = {(e.get("key"), e.get("index")) for e in entries}
for key, index, value in _iter_input_values(params):
if (key, index) in covered or not value:
continue
# Not every value of these keys is a file: an engine may take a voice
# id here. Only what exists on this disk is an input.
if not os.path.isfile(value):
continue
entry = stage_input(value, root=root, now=now)
entry["key"] = key
entry["index"] = index
entries.append(entry)
covered.add((key, index))
if entries:
params[INPUTS_PARAM_KEY] = entries
task.params = params
return entries
def _referenced_artifacts(conn) -> set[str]:
"""Every staged input still named by a surviving task row."""
referenced: set[str] = set()
for row in conn.execute("SELECT params_json FROM remote_tasks").fetchall():
try:
params = json.loads(row["params_json"] or "{}")
entries = params.get(INPUTS_PARAM_KEY) or []
except (ValueError, AttributeError):
continue
for entry in entries:
if isinstance(entry, dict) and entry.get("artifact_id"):
referenced.add(str(entry["artifact_id"]))
return referenced
def purge_artifacts(
task_ids: Iterable[str], referenced: set[str], *, cutoff: float, root: Optional[str] = None
) -> int:
"""Delete the results of purged tasks and every input nothing points at.
Both directions, deliberately: results are attempt-scoped and die with
their task, while a content-hashed input is shared, so it may only go once
no surviving task refers to it *and* it is older than the same cutoff the
rows were judged by. Nothing here raises a purge that fails is a disk
that stays fuller than we wanted, not a failed request.
"""
removed = 0
try:
base = root or artifact_root(create_dir=False)
except Exception: # pragma: no cover — no data dir at all
logger.debug("No artifact root to purge", exc_info=True)
return 0
if not os.path.isdir(base):
return 0
for task_id in task_ids:
try:
path = resolve_within(base, safe_filename(task_id))
except UnsafePath:
continue
if os.path.isdir(path):
shutil.rmtree(path, ignore_errors=True)
removed += 1
inputs_dir = os.path.join(base, INPUTS_DIRNAME)
try:
names = os.listdir(inputs_dir)
except OSError:
return removed
for name in names:
artifact_id = os.path.join(INPUTS_DIRNAME, name)
if artifact_id in referenced:
continue
path = os.path.join(inputs_dir, name)
try:
if not os.path.isfile(path) or os.path.getmtime(path) >= cutoff:
continue
os.remove(path)
removed += 1
except OSError:
logger.debug("Could not purge the staged input %s", name, exc_info=True)
return removed
# ── Writes ─────────────────────────────────────────────────────────────────
def create(task: Task, *, project_id: Optional[str] = None, now: Optional[float] = None) -> Task:
"""Persist a new task.
Idempotent on ``idempotency_key``: a client that retries its HTTP request
gets the original task back rather than a second render of the same text.
``pinned_worker_id`` deliberately follows core.db's additive schema
reconciliation instead of alembic: remote recovery also runs in bundled
installs where alembic may be unavailable, and the nullable column is a
backward-compatible affinity fact rather than a data transformation.
Inputs are staged before the row is written, so the durable record names
the artifacts the task owns. Persisting first would leave a task whose
reference audio no purge can account for.
"""
stamp = resolve(now)
if task.idempotency_key:
existing = get_by_idempotency_key(task.idempotency_key)
if existing is not None:
return existing
ensure_staged(task, now=stamp)
with db_conn() as conn:
conn.execute(
"INSERT INTO remote_tasks "
"(id, idempotency_key, operation, engine, model_id, params_json, priority, state, "
" max_attempts, excluded_json, project_id, created_at, updated_at, deadline_at, pinned_worker_id) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
(
task.task_id,
task.idempotency_key,
task.operation,
task.engine,
task.model_id,
json.dumps(task.params),
int(task.priority),
task.state.value,
task.max_attempts,
json.dumps(sorted(task.excluded_workers)),
project_id,
stamp,
stamp,
task.deadline_at,
task.pinned_worker_id,
),
)
return task
def _upsert_attempts(conn, task: Task) -> None:
"""Write every attempt, inserting the ones we have not seen before.
Upsert rather than UPDATE in both writers: a blind UPDATE silently drops an
attempt whose row does not exist yet, which loses the audit trail for the
exact case that matters a task whose first persisted state is its
completion.
"""
for attempt in task.attempts:
conn.execute(
"INSERT INTO remote_task_attempts "
"(id, task_id, worker_id, session_epoch, attempt_number, state, progress, stage, "
" error_json, created_at, accepted_at, started_at, finished_at, lease_expires_at, "
" grace_expires_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) "
"ON CONFLICT(id) DO UPDATE SET state=excluded.state, progress=excluded.progress, "
" stage=excluded.stage, error_json=excluded.error_json, accepted_at=excluded.accepted_at, "
" started_at=excluded.started_at, finished_at=excluded.finished_at, "
" lease_expires_at=excluded.lease_expires_at, grace_expires_at=excluded.grace_expires_at",
(
attempt.attempt_id,
attempt.task_id,
attempt.worker_id,
attempt.session_epoch,
attempt.attempt_number,
attempt.state.value,
attempt.progress,
attempt.stage,
_dump_error(attempt.error),
attempt.created_at,
attempt.accepted_at,
attempt.started_at,
attempt.finished_at,
attempt.lease_expires_at,
attempt.grace_expires_at,
),
)
def save(task: Task, *, now: Optional[float] = None) -> None:
"""Write the whole task + attempt graph.
Deliberately a full rewrite rather than a diff: the graph is tiny, and a
partial update is how a state machine and its persistence drift apart.
"""
stamp = resolve(now)
with db_conn() as conn:
conn.execute(
"UPDATE remote_tasks SET state=?, excluded_json=?, error_json=?, result_ref=?, "
"updated_at=?, deadline_at=?, finished_at=?, pinned_worker_id=? WHERE id=?",
(
task.state.value,
json.dumps(sorted(task.excluded_workers)),
_dump_error(task.error),
task.result_ref,
stamp,
task.deadline_at,
task.finished_at,
task.pinned_worker_id,
task.task_id,
),
)
_upsert_attempts(conn, task)
def commit_result(
task: Task, *, result_json: Optional[dict] = None, now: Optional[float] = None
) -> None:
"""Durably record a completed task. Must return before RESULT_ACK is sent.
Everything lands in one transaction, so there is no window in which the
task looks complete but its result reference is missing.
"""
stamp = resolve(now)
with db_conn() as conn:
conn.execute(
"UPDATE remote_tasks SET state=?, result_ref=?, result_json=?, updated_at=?, "
"finished_at=?, error_json=NULL WHERE id=?",
(
task.state.value,
task.result_ref,
json.dumps(result_json or {}),
stamp,
task.finished_at or stamp,
task.task_id,
),
)
_upsert_attempts(conn, task)
def is_committed(task_id: str) -> bool:
"""Has this task already been durably committed?
The guard for a redelivered result after a control-plane restart: the
in-memory task graph is gone, but the fact is on disk.
"""
with db_conn() as conn:
row = conn.execute(
"SELECT state, result_ref FROM remote_tasks WHERE id = ?", (task_id,)
).fetchone()
return bool(row and row["state"] == TaskState.COMPLETED.value)
# ── Reads ──────────────────────────────────────────────────────────────────
def _attempts_for(conn, task_id: str) -> list[Attempt]:
rows = conn.execute(
"SELECT * FROM remote_task_attempts WHERE task_id = ? ORDER BY attempt_number ASC",
(task_id,),
).fetchall()
return [_row_to_attempt(r) for r in rows]
def get(task_id: str) -> Optional[Task]:
with db_conn() as conn:
row = conn.execute("SELECT * FROM remote_tasks WHERE id = ?", (task_id,)).fetchone()
if row is None:
return None
return _row_to_task(row, _attempts_for(conn, task_id))
def get_by_idempotency_key(key: str) -> Optional[Task]:
with db_conn() as conn:
row = conn.execute(
"SELECT * FROM remote_tasks WHERE idempotency_key = ?", (key,)
).fetchone()
if row is None:
return None
return _row_to_task(row, _attempts_for(conn, row["id"]))
def load_unfinished() -> list[Task]:
"""Every task that was still live when the control plane stopped.
Called at startup. These are NOT failed the workers holding them may
still be rendering, and reconciliation decides each one's fate once the
workers reconnect.
"""
live = ", ".join(f"'{s.value}'" for s in TaskState if not s.terminal)
with db_conn() as conn:
rows = conn.execute(
f"SELECT * FROM remote_tasks WHERE state IN ({live}) ORDER BY priority ASC, created_at ASC"
).fetchall()
return [_row_to_task(r, _attempts_for(conn, r["id"])) for r in rows]
def list_tasks(*, states: Optional[Iterable[TaskState]] = None, limit: int = 100) -> list[Task]:
sql = "SELECT * FROM remote_tasks"
params: list = []
if states:
placeholders = ", ".join("?" for _ in states)
sql += f" WHERE state IN ({placeholders})"
params.extend(s.value for s in states)
sql += " ORDER BY created_at DESC LIMIT ?"
params.append(limit)
with db_conn() as conn:
rows = conn.execute(sql, params).fetchall()
return [_row_to_task(r, _attempts_for(conn, r["id"])) for r in rows]
def purge_finished(
*,
older_than_seconds: float = 7 * 24 * 3600,
now: Optional[float] = None,
root: Optional[str] = None,
) -> int:
"""Drop old finished tasks — rows *and* the bytes they own.
Rows only was a leak with no ceiling: every remote render leaves a result
artifact on disk, and every remote clone leaves a copy of the reference
audio. Neither was ever deleted, so the feature grew the user's disk for
as long as they used it.
"""
cutoff = resolve(now) - older_than_seconds
terminal = ", ".join(f"'{s.value}'" for s in TaskState if s.terminal)
with db_conn() as conn:
doomed = [
row["id"]
for row in conn.execute(
f"SELECT id FROM remote_tasks WHERE state IN ({terminal}) AND finished_at < ?",
(cutoff,),
).fetchall()
]
conn.execute(
f"DELETE FROM remote_task_attempts WHERE task_id IN "
f"(SELECT id FROM remote_tasks WHERE state IN ({terminal}) AND finished_at < ?)",
(cutoff,),
)
cur = conn.execute(
f"DELETE FROM remote_tasks WHERE state IN ({terminal}) AND finished_at < ?", (cutoff,)
)
removed = cur.rowcount
# Read the survivors inside the same transaction that deleted the
# rows: an input is only unreferenced relative to what is left.
referenced = _referenced_artifacts(conn)
# Filesystem work outside the transaction — a slow rmtree must not hold
# SQLite's write lock against the dispatch loop.
purge_artifacts(doomed, referenced, cutoff=cutoff, root=root)
return removed
__all__ = [
"INPUTS_DIRNAME",
"INPUTS_PARAM_KEY",
"INPUT_PARAM_KEYS",
"InputStagingError",
"artifact_root",
"commit_result",
"create",
"ensure_staged",
"get",
"get_by_idempotency_key",
"is_committed",
"list_tasks",
"load_unfinished",
"purge_artifacts",
"purge_finished",
"save",
"stage_input",
]
+272
View File
@@ -0,0 +1,272 @@
"""TLS for a control plane that lives on someone's desktop.
The awkward fact the goal doc skipped: the OSS control server is a laptop. It
has no domain name, no publicly-valid certificate, and its IP changes. "All
remote communication must use TLS" is easy to write and, stated that way,
unimplementable which in practice means somebody adds an
``insecure_skip_verify`` flag and the whole thing becomes theatre, because on a
café network that flag *is* the attack.
So the trust anchor is the enrollment token, not the public CA system. The
control plane generates a self-signed certificate once and keeps it; the token
the user copies carries that certificate's fingerprint; the worker pins it on
first connect and refuses anything else afterwards. This is the join-token
pattern from k3s and Tailscale, and it gives a desktop the same practical
security a real CA would, without asking the user to run one.
There is deliberately no way to disable verification.
"""
from __future__ import annotations
import datetime as _dt
import ipaddress
import logging
import os
import socket
from dataclasses import dataclass
from typing import Optional
from cryptography import x509
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.x509.oid import NameOID
from worker.identity import certificate_fingerprint
logger = logging.getLogger("omnivoice.worker")
_CERT_VALID_DAYS = 825 # the CA/Browser Forum maximum; long enough to be quiet
_RENEW_WITHIN_DAYS = 30
@dataclass(frozen=True)
class ServerCredentials:
"""A control plane's certificate and its pinnable fingerprint."""
certificate_pem: bytes
private_key_pem: bytes
certificate_der: bytes
@property
def fingerprint(self) -> str:
return certificate_fingerprint(self.certificate_der)
def _san_entries(hostnames: list[str]) -> list[x509.GeneralName]:
"""Cover every address a worker might legitimately dial.
A tailnet name, a LAN hostname, and a bare IP are all normal ways to reach
a desktop, and a certificate that only names one of them fails as soon as
the user's network changes shape.
"""
entries: list[x509.GeneralName] = []
for host in hostnames:
host = (host or "").strip()
if not host:
continue
try:
entries.append(x509.IPAddress(ipaddress.ip_address(host)))
except ValueError:
entries.append(x509.DNSName(host))
if not entries:
entries.append(x509.DNSName("localhost"))
return entries
def primary_ip() -> str:
"""This host's address on the route to the outside world.
Opening a UDP socket sends no packets it only makes the kernel choose a
source address, which is exactly the one a worker on the LAN would reach us
on. Enumerating interfaces instead would leave us guessing between docker0,
a VPN, and the real NIC.
"""
probe = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
# TEST-NET-1 (RFC 5737): reserved, never routed, never contacted.
probe.connect(("192.0.2.1", 9))
return probe.getsockname()[0]
except OSError:
return ""
finally:
probe.close()
def covers(credentials: "ServerCredentials", host: str) -> bool:
"""Does this certificate actually name ``host``?
Used to regenerate when the machine's address changes — a laptop that moved
networks otherwise keeps a certificate no worker can validate.
"""
if not host:
return True
try:
certificate = x509.load_der_x509_certificate(credentials.certificate_der)
san = certificate.extensions.get_extension_for_class(
x509.SubjectAlternativeName
).value
except Exception:
return False
names = set(san.get_values_for_type(x509.DNSName))
names |= {str(ip) for ip in san.get_values_for_type(x509.IPAddress)}
return host in names
def default_hostnames() -> list[str]:
"""Best-effort local identities, deduplicated and order-stable.
The routable IP matters as much as the names: gRPC resolves through c-ares,
which does NOT speak mDNS, so a macOS ``host.local`` that Python resolves
happily is unreachable to a worker. Whatever we advertise must appear here
or TLS verification fails on the name.
"""
names = ["localhost", "127.0.0.1", "::1"]
address = primary_ip()
if address:
names.append(address)
try:
hostname = socket.gethostname()
if hostname:
names.append(hostname)
# A tailnet or mDNS name is the realistic way a worker reaches a
# laptop that has no fixed address. macOS already reports the
# hostname WITH the .local suffix, so only add it when absent —
# otherwise the SAN carries a bogus "host.local.local".
if "." not in hostname:
names.append(f"{hostname}.local")
except OSError:
pass
seen: set[str] = set()
return [n for n in names if not (n in seen or seen.add(n))]
def generate_self_signed(
*, hostnames: Optional[list[str]] = None, now: Optional[_dt.datetime] = None
) -> ServerCredentials:
"""Mint the control plane's certificate.
EC P-256 rather than RSA: far faster to generate, which matters because
this runs on first launch while the user is waiting.
"""
stamp = now or _dt.datetime.now(_dt.timezone.utc)
key = ec.generate_private_key(ec.SECP256R1())
subject = x509.Name(
[
x509.NameAttribute(NameOID.COMMON_NAME, "OmniVoice Control Plane"),
x509.NameAttribute(NameOID.ORGANIZATION_NAME, "OmniVoice Studio"),
]
)
certificate = (
x509.CertificateBuilder()
.subject_name(subject)
.issuer_name(subject)
.public_key(key.public_key())
.serial_number(x509.random_serial_number())
.not_valid_before(stamp - _dt.timedelta(minutes=5)) # tolerate clock skew
.not_valid_after(stamp + _dt.timedelta(days=_CERT_VALID_DAYS))
.add_extension(
x509.SubjectAlternativeName(_san_entries(hostnames or default_hostnames())),
critical=False,
)
.add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True)
.add_extension(
x509.ExtendedKeyUsage([x509.ExtendedKeyUsageOID.SERVER_AUTH]), critical=False
)
.sign(key, hashes.SHA256())
)
return ServerCredentials(
certificate_pem=certificate.public_bytes(serialization.Encoding.PEM),
private_key_pem=key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption(),
),
certificate_der=certificate.public_bytes(serialization.Encoding.DER),
)
def load_or_create(
cert_path: str, key_path: str, *, hostnames: Optional[list[str]] = None
) -> ServerCredentials:
"""Return the stored certificate, regenerating it if absent or expiring.
Renewing early matters more here than usual: an expired certificate on a
desktop control plane presents as "my workers all went offline" with
nothing in the UI explaining why.
"""
existing = _load(cert_path, key_path)
wanted = hostnames or default_hostnames()
# Only re-check reachability for the default set. An explicit hostname list
# is the caller's decision and must not be second-guessed against whatever
# interface this machine happens to have.
address = "" if hostnames is not None else primary_ip()
if existing is not None and not _expiring_soon(existing) and covers(existing, address):
return existing
if existing is not None:
reason = "expiring" if _expiring_soon(existing) else "no longer covers this machine's address"
logger.info("Control-plane certificate is %s — regenerating.", reason)
credentials = generate_self_signed(hostnames=wanted)
_save(cert_path, key_path, credentials)
return credentials
def _load(cert_path: str, key_path: str) -> Optional[ServerCredentials]:
try:
with open(cert_path, "rb") as fh:
cert_pem = fh.read()
with open(key_path, "rb") as fh:
key_pem = fh.read()
except (FileNotFoundError, PermissionError):
return None
try:
certificate = x509.load_pem_x509_certificate(cert_pem)
except ValueError:
return None
return ServerCredentials(
certificate_pem=cert_pem,
private_key_pem=key_pem,
certificate_der=certificate.public_bytes(serialization.Encoding.DER),
)
def _expiring_soon(credentials: ServerCredentials, *, now: Optional[_dt.datetime] = None) -> bool:
certificate = x509.load_der_x509_certificate(credentials.certificate_der)
stamp = now or _dt.datetime.now(_dt.timezone.utc)
expires = certificate.not_valid_after_utc
return (expires - stamp) < _dt.timedelta(days=_RENEW_WITHIN_DAYS)
def _save(cert_path: str, key_path: str, credentials: ServerCredentials) -> None:
os.makedirs(os.path.dirname(os.path.abspath(cert_path)), exist_ok=True)
with open(cert_path, "wb") as fh:
fh.write(credentials.certificate_pem)
# The private key gets the same 0600 treatment as worker keys.
fd = os.open(key_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
try:
os.write(fd, credentials.private_key_pem)
finally:
os.close(fd)
try:
os.chmod(key_path, 0o600)
except OSError:
pass
def pin_matches(certificate_der: bytes, expected_fingerprint: str) -> bool:
"""Constant-time-ish comparison of a presented certificate against a pin."""
import hmac # noqa: PLC0415 — trivial, keeps the module import light
return hmac.compare_digest(
certificate_fingerprint(certificate_der).lower(), (expected_fingerprint or "").lower()
)
__all__ = [
"ServerCredentials",
"covers",
"default_hostnames",
"primary_ip",
"generate_self_signed",
"load_or_create",
"pin_matches",
]
+10
View File
@@ -0,0 +1,10 @@
"""gRPC transport for the worker protocol.
A thin adapter over the domain layer, and deliberately thin: every rule about
what a message *means* lives in ``worker/`` proper, so the transport only
translates between protobuf and those objects, and can be swapped or
reimplemented (in Go, for the hosted platform) without the rules moving.
Imports of ``grpc`` are confined to this subpackage so the rest of the worker
domain stays importable in processes that never speak the protocol.
"""
File diff suppressed because it is too large Load Diff
+321
View File
@@ -0,0 +1,321 @@
"""Translation between protobuf messages and the domain objects.
Kept separate from the server and the client because both directions need it
and because it is the one place where a wire-format change shows up. Nothing
here makes decisions; it converts.
"""
from __future__ import annotations
import logging
import os
from typing import Optional
from worker.capacity import derive_concurrency
from worker.deadlines import Deadlines
from worker.errors import ErrorClass, WorkerError
from worker.lifecycle import Attempt, PriorityClass, Task
from worker.protocol.gen import worker_v1_pb2 as pb
logger = logging.getLogger("omnivoice.worker")
# Where a staging failure is reported to the worker. Named here because both
# sides of the wire read it: the executor turns it into a terminal error.
_INPUT_ERRORS_KEY = "input_errors"
# Domain ErrorClass ↔ protobuf enum. Explicit rather than by-name so renaming
# one side cannot silently change the meaning of a wire value.
_ERROR_TO_PB = {
ErrorClass.TRANSIENT: pb.ERROR_CLASS_TRANSIENT,
ErrorClass.CAPABILITY: pb.ERROR_CLASS_CAPABILITY,
ErrorClass.TERMINAL: pb.ERROR_CLASS_TERMINAL,
ErrorClass.CAPACITY: pb.ERROR_CLASS_CAPACITY,
ErrorClass.TIMEOUT: pb.ERROR_CLASS_TIMEOUT,
ErrorClass.PROTOCOL: pb.ERROR_CLASS_PROTOCOL,
}
_PB_TO_ERROR = {v: k for k, v in _ERROR_TO_PB.items()}
def error_to_pb(error: Optional[WorkerError]) -> Optional[pb.Error]:
if error is None:
return None
return pb.Error(
error_class=_ERROR_TO_PB.get(error.error_class, pb.ERROR_CLASS_TRANSIENT),
code=error.code,
message=error.message,
hint=error.hint,
)
def error_from_pb(message: Optional[pb.Error]) -> Optional[WorkerError]:
if message is None or not message.code:
return None
return WorkerError(
# Unknown/unspecified maps to TRANSIENT: one wasted retry beats
# permanently failing work a newer peer merely described differently.
error_class=_PB_TO_ERROR.get(message.error_class, ErrorClass.TRANSIENT),
code=message.code,
message=message.message,
hint=message.hint,
)
def task_ref(task_id: str, attempt_id: str, epoch: int) -> pb.TaskRef:
return pb.TaskRef(task_id=task_id, attempt_id=attempt_id, session_epoch=epoch)
def ref_for(attempt: Attempt) -> pb.TaskRef:
return task_ref(attempt.task_id, attempt.attempt_id, attempt.session_epoch)
def deadlines_to_pb(budget: Deadlines) -> pb.Deadlines:
return pb.Deadlines(
accept_seconds=budget.accept_seconds,
model_load_seconds=budget.model_load_seconds,
execution_seconds=budget.execution_seconds,
progress_lease_seconds=budget.progress_lease_seconds,
result_delivery_seconds=budget.result_delivery_seconds,
)
def assignment_to_pb(
task: Task, attempt: Attempt, budget: Deadlines, *, artifact_root: Optional[str] = None
) -> pb.TaskAssignment:
"""Build the wire assignment.
``params_json`` carries the operation's parameters opaquely; the transport
has no business knowing what a dub or a clone needs.
The one thing it cannot stay opaque about is a **path**. A parameter like
``ref_audio`` names a file on the control plane's disk, which is not a
thing on the worker's — so a clone assignment used to arrive naming a file
that did not exist, and the worker either failed to open it or rendered
the default voice. Every file-valued parameter is therefore staged into
the artifact store, declared in ``inputs`` for the worker to fetch over
``DownloadArtifact``, and replaced in ``params_json`` by its artifact id.
No local path ever crosses the wire.
"""
import json # noqa: PLC0415 — only needed on this path
entries, errors = _staged_inputs(task, artifact_root)
return pb.TaskAssignment(
ref=ref_for(attempt),
operation=task.operation,
engine=task.engine,
model_id=task.model_id,
params_json=json.dumps(remote_params(task.params, entries, errors)),
inputs=[input_ref(entry, task, attempt) for entry in entries],
deadlines=deadlines_to_pb(budget),
priority_class=int(task.priority),
attempt_number=attempt.attempt_number,
max_attempts=task.max_attempts,
)
def input_ref(entry: dict, task: Task, attempt: Attempt) -> pb.ArtifactRef:
"""One staged input as the worker will ask for it back.
``sha256`` and ``size_bytes`` are populated rather than left at their
defaults because they are what lets the worker verify the transfer and,
more usefully, recognise a reference clip it already holds.
"""
return pb.ArtifactRef(
artifact_id=str(entry.get("artifact_id") or ""),
task_id=task.task_id,
attempt_id=attempt.attempt_id,
filename=str(entry.get("filename") or ""),
content_type=str(entry.get("content_type") or ""),
size_bytes=int(entry.get("size_bytes") or 0),
sha256=str(entry.get("sha256") or ""),
)
def remote_params(params: dict, entries: list[dict], errors: list[str]) -> dict:
"""The parameters as the worker should see them.
Two rules. The staging bookkeeping (which holds control-plane paths) is
stripped. And any remaining file-valued parameter is *removed* rather than
passed through: an unstaged local path is worse than an absent one,
because absent fails loudly while a dead path can silently produce audio
in the wrong voice.
"""
from worker.task_store import INPUT_PARAM_KEYS, INPUTS_PARAM_KEY # noqa: PLC0415
remote = {
key: value
for key, value in (params or {}).items()
if key not in (INPUTS_PARAM_KEY, _INPUT_ERRORS_KEY)
}
mapped: dict[str, dict[Optional[int], str]] = {}
for entry in entries:
artifact_id = str(entry.get("artifact_id") or "")
if artifact_id:
mapped.setdefault(str(entry.get("key") or ""), {})[entry.get("index")] = artifact_id
for key in INPUT_PARAM_KEYS:
if key not in remote:
continue
by_index = mapped.get(key, {})
value = remote[key]
if isinstance(value, list):
rewritten = [
by_index.get(index, item)
for index, item in enumerate(value)
if index in by_index or not _is_local_path(item)
]
remote[key] = rewritten
elif isinstance(value, str):
if None in by_index:
remote[key] = by_index[None]
elif _is_local_path(value):
remote.pop(key)
if errors:
remote[_INPUT_ERRORS_KEY] = errors
return remote
def _is_local_path(value) -> bool:
"""Does this value name a place on this machine rather than a plain id?"""
if not isinstance(value, str) or not value:
return False
return os.path.isabs(value) or os.sep in value or "/" in value or os.path.exists(value)
def _staged_inputs(task: Task, artifact_root: Optional[str]) -> tuple[list[dict], list[str]]:
"""Stage this task's inputs, or say why they could not be staged.
A staging failure must not take down the dispatch loop, and it must not
fall back to sending the path: the assignment goes out with an explicit
error the worker turns into a terminal failure the user can read.
"""
from worker import task_store # noqa: PLC0415 — control-plane only
try:
entries = task_store.ensure_staged(task, root=artifact_root)
except task_store.InputStagingError as exc:
logger.warning("Could not stage inputs for task %s: %s", task.task_id, exc)
return [], [str(exc)]
except Exception as exc: # pragma: no cover — defensive
logger.warning("Input staging failed for task %s", task.task_id, exc_info=True)
return [], [f"Task inputs could not be prepared: {exc}"]
return [e for e in entries if e.get("artifact_id")], []
def capability_to_pb(cap: dict) -> pb.ModelCapability:
"""Convert a discovered capability.
``derived_concurrency`` is computed here when the reporter did not supply
it, never defaulted to a constant: a wrong value corrupts output under
torch.compile (#315) or aborts the process on a small card (#567).
"""
declared = int(cap.get("derived_concurrency") or 0)
if declared <= 0:
declared = derive_concurrency(
backend=str(cap.get("backend") or ""),
free_memory_bytes=int(cap.get("free_memory_bytes") or 0),
min_model_bytes=int(cap.get("min_memory_bytes") or 0),
compiled=bool(cap.get("compiled")),
)
return pb.ModelCapability(
engine=str(cap.get("engine") or ""),
model_id=str(cap.get("model_id") or ""),
operations=list(cap.get("operations") or []),
supported=bool(cap.get("supported")),
installed=bool(cap.get("installed")),
downloaded=bool(cap.get("downloaded")),
resident=bool(cap.get("resident")),
min_memory_bytes=int(cap.get("min_memory_bytes") or 0),
precision=str(cap.get("precision") or ""),
derived_concurrency=max(0, declared),
cpu_fallback=bool(cap.get("cpu_fallback")),
repo_ids=list(cap.get("repo_ids") or []),
display_name=str(cap.get("display_name") or ""),
)
def capability_from_pb(message: pb.ModelCapability) -> dict:
return {
"engine": message.engine,
"model_id": message.model_id,
"operations": list(message.operations),
"supported": message.supported,
"installed": message.installed,
"downloaded": message.downloaded,
"resident": message.resident,
"min_memory_bytes": message.min_memory_bytes,
"precision": message.precision,
"derived_concurrency": message.derived_concurrency,
"cpu_fallback": message.cpu_fallback,
"repo_ids": list(message.repo_ids),
"display_name": message.display_name,
}
def host_to_pb(host: dict) -> pb.HostInfo:
gpus = [
pb.GpuInfo(
vendor=str(g.get("vendor") or ""),
model=str(g.get("model") or ""),
backend=str(g.get("backend") or ""),
memory_bytes=int(g.get("memory_bytes") or 0),
free_memory_bytes=int(g.get("free_memory_bytes") or 0),
driver_version=str(g.get("driver_version") or ""),
compute_capability=str(g.get("compute_capability") or ""),
)
for g in (host.get("gpus") or [])
]
return pb.HostInfo(
hostname=str(host.get("hostname") or ""),
os=str(host.get("os") or ""),
arch=str(host.get("arch") or ""),
worker_version=str(host.get("worker_version") or ""),
cpu_count=int(host.get("cpu_count") or 0),
system_memory_bytes=int(host.get("system_memory_bytes") or 0),
gpus=gpus,
)
def host_from_pb(message: pb.HostInfo) -> dict:
return {
"hostname": message.hostname,
"os": message.os,
"arch": message.arch,
"worker_version": message.worker_version,
"cpu_count": message.cpu_count,
"system_memory_bytes": message.system_memory_bytes,
"gpus": [
{
"vendor": g.vendor,
"model": g.model,
"backend": g.backend,
"memory_bytes": g.memory_bytes,
"free_memory_bytes": g.free_memory_bytes,
"driver_version": g.driver_version,
"compute_capability": g.compute_capability,
}
for g in message.gpus
],
}
def priority_from_pb(value: int) -> PriorityClass:
try:
return PriorityClass(value)
except ValueError:
return PriorityClass.BATCH
__all__ = [
"assignment_to_pb",
"capability_from_pb",
"capability_to_pb",
"deadlines_to_pb",
"error_from_pb",
"error_to_pb",
"host_from_pb",
"host_to_pb",
"input_ref",
"priority_from_pb",
"remote_params",
"ref_for",
"task_ref",
]
File diff suppressed because it is too large Load Diff
+20
View File
@@ -4,6 +4,26 @@ VoiceStudio downloads models from the Hugging Face Hub on first use. This page
explains how downloads are made fast, how to read the progress, and what to do
on slow or restricted networks.
When a remote GPU is selected, the catalog is filtered and curated for that
worker's reported OS, architecture, and GPU backend—not for the control-plane
computer. Generation checks the worker's capability report before submitting a
job. If the required weights are positively known to be absent, VoiceStudio
shows “model not downloaded on &lt;worker&gt;” with a download action. The download
runs on that worker and refreshes its capabilities when it finishes; press
Generate again afterward (the interrupted job is not automatically resubmitted).
The same `POST /models/install` request targets either `local` or the selected
worker, and `/setup/download-stream` reports both with a `target` field. Progress
is tracked by `(target, repo_id)`, so simultaneous downloads of one model on two
machines remain separate. Workers receive only an opaque model identifier and
resolve the reviewed Hugging Face repository and pinned revision from their own
catalog.
Unknown or user-managed cache layouts are allowed through so existing manual
engine installs remain compatible.
Managed sidecar engines are intentionally excluded from remote installation.
Their current installer fetches mutable source before creating an editable
environment; install those directly on the worker until that source is pinned.
## Download backend: legacy LFS by default (accurate progress)
VoiceStudio ships `hf_xet` (Hugging Face's chunked, parallel, dedup transfer
+3
View File
@@ -20,6 +20,7 @@ features:
- AI Watermark
- Local-first
- GPU Auto-Detect
- Remote Model Downloads
- Extensible
# id: must exactly match the registry keys in backend/services/tts_backend.py
@@ -85,6 +86,8 @@ asr_engines:
# Doc files that must exist (the install path users are sent to).
docs:
- docs/downloading-models.md
- docs/remote-workers.md
- docs/branding.md
- docs/install/macos.md
- docs/install/windows.md
+6
View File
@@ -9,6 +9,12 @@ inference staying on the powerful machine.
> gate (share PIN, API key, dictation WebSocket, trusted networks) with the exact
> headers, params, and `401`/`403`/`429` meanings.
> Want to keep working *here* and only send individual jobs to another GPU? That
> is a different feature — see [docs/remote-workers.md](remote-workers.md). This
> page moves the whole backend (and your projects with it) to the other machine;
> remote workers keep everything local and farm out single tasks. Both are
> supported, and setting one up does not affect the other.
This is opt-in and off by default: with no API key set, the backend stays
loopback-only exactly as before.
+221
View File
@@ -0,0 +1,221 @@
# Remote GPU workers
Run OmniVoice on this machine, but hand individual jobs to GPUs on your other
machines. Results come back here.
This is **opt-in and off by default**. Until you turn it on and approve a
worker, nothing leaves your computer, no port is opened, and the app behaves
exactly as it did before.
> **Not the same as [Remote backend](remote-gpu.md).** That points this app at
> a backend running somewhere else, so the whole app — your projects, your
> voices, your history — lives on that machine. This keeps everything here and
> only sends out individual tasks. Both still work; pick whichever matches what
> you want.
---
## What you need
* OmniVoice on both machines, on versions no more than two releases apart.
* The worker machine must be able to **reach** this one over the network. Same
LAN is enough at home; across networks, a VPN such as
[Tailscale](https://tailscale.com/) is the reliable answer. The worker dials
out to the control plane, so the *worker* never needs a public address or a
forwarded port — but this machine does need to be reachable.
* The engine you want to use must be installed on the worker. A worker reports
what it actually has, and the scheduler only sends it work it can run.
## Setting it up
**1. On this machine (the one you work on):**
Settings → System → Remote workers → turn on **Use remote workers**.
The panel shows the address workers should connect to, and a **Generate token**
button.
**2. Generate an enrollment token.**
Copy it immediately. It is shown once, works once, and expires after 15
minutes — only its hash is stored here, so it cannot be shown again. If you
lose it, generate another.
**3. On the worker machine:**
Start OmniVoice in worker mode and give it the token:
```bash
OMNIVOICE_WORKER_TOKEN='ovw_…' OMNIVOICE_WORKER_MODE=1 omnivoice
```
The worker generates its own key pair on first run, presents the token once to
enroll, and proves possession of that key on every later connection. The token
is spent at that point and never used again.
**4. Approve the worker.**
It appears in the list on this machine. Approving it is what allows your audio,
reference voices, and text to be sent there — consent is recorded per worker,
because agreeing to use your own desktop is not agreeing to use whatever gets
added later.
## What you can change
| Control | What it does |
|---|---|
| Enable / disable | Stop sending new work without removing the worker |
| Preferred | Prefer this worker when several can run a task |
| Resume | Clear a paused worker after you've fixed it |
| Remove | Revoke its key — it cannot reconnect without a new token |
That is the whole surface, deliberately. **Preferred** pins new work to that
worker; if it is asleep, VoiceStudio names that worker instead of silently
sending the job elsewhere. There are no routing weights or per-model
concurrency settings: concurrency is measured from free VRAM at runtime because
a configured value silently corrupts output on compiled models and crashes
small cards.
## What runs remotely
**Speech synthesis, audiobook chapters, and dub segment synthesis.** Audiobooks
are dispatched one chapter at a time. A dub sends all fresh segments as one
coarse task and receives their WAVs in one result bundle; fitting, assembly and
RVC still run on this machine. If a remote multi-unit render fails, its local
fallback is reported once. ASR, diarization and translation also remain local. Dictation always
runs here, deliberately and permanently, because there latency *is* the
feature. The remaining operations are being ported one at a time.
The picker knows this. It resolves against the surface you are on, so a chosen
worker reads **Local** on a tab whose work has no remote path yet and names the
reason, instead of showing a green dot next to a GPU that receives nothing.
The Dictation surface states that it always uses this machine without showing
the generic "not ported yet" notice.
For protocol development, a task can also be placed by hand with
`POST /workers/tasks` — a **development-only** endpoint. It is loopback-only,
sits behind the same opt-in as everything else here, takes a mandatory
deadline, submits one task and waits for it. It is not a stable API and goes
away once generation routes itself.
## How work is placed
A task goes to a worker that is connected, approved, enabled, has the engine,
has a free slot, and is not paused. An explicitly preferred worker is a hard
choice. Without one, VoiceStudio chooses the least-busy eligible worker and
breaks ties in favour of a worker that already has the model loaded — a warm
model is seconds away where a cold one can be minutes.
Model identities are stable scheduling keys; the worker reports a separate
human-readable model name, so label changes do not split capacity or history.
If every capable worker is busy, the task waits. If **no** worker can run it at
all, it fails immediately and says so, rather than waiting for something that
will never happen.
## When things go wrong
**A worker disconnects mid-task.** Nothing is failed straight away. It has a
grace window to come back, and if it returns carrying a finished result, that
result is used — the task is never run twice just because a network blip
happened. Only when the window expires is the task retried elsewhere.
**A worker fails repeatedly.** After three consecutive failures that are
actually its fault, it is paused for a minute, then automatically given one
task to prove itself. Repeated trips back off further, up to thirty minutes.
Being busy, being asked for an engine it doesn't have, or losing its network
connection are *not* counted against it.
Long-running work sends explicit keepalive frames. They let a slow render live
past the two-minute progress lease, but cannot extend it beyond the current
phase budget when the worker is genuinely stuck.
The row tells you what happened in words — "Paused after 3 failures … retrying
in 45s" — and **Resume** clears it immediately when you've fixed the machine.
**You quit the app mid-task.** Remote work keeps running on the worker. On next
launch OmniVoice recovers those tasks and reconciles with each worker about
what is genuinely still in flight.
**Version or feature mismatch.** The protocol keeps a two-release compatibility
window, but release numbers alone do not prove that a worker understands every
additive command. Registration therefore also declares named features for task
inputs, progress leases, and remote model downloads. A worker outside the
version window, or one missing a required feature, is refused with
`UPGRADE_REQUIRED` and an update instruction before any task runs. It can never
silently render without reference audio or leave a download stuck at 0%.
Every remote failure includes a concrete next step. Capacity, missing models,
expired leases or sessions, authentication, rejected inputs, and result upload
failures are shown as named errors with advice to retry, reconnect, install the
model, free resources, or re-enroll as appropriate; they do not reach the UI
with a blank hint.
## Security
* **All traffic is TLS.** There is no way to disable verification.
* This machine generates its own certificate. The enrollment token carries that
certificate's fingerprint, and the worker pins it — so a machine on the same
café Wi-Fi cannot impersonate your control plane.
* **A worker's identity is a key it generates and never sends.** The worker ID
is a display name, not a credential; knowing it gets an attacker nothing.
* **Removing a worker revokes its key**, and that survives restarting the app.
* Idle worker sessions use TLS keepalives, so NAT mappings stay open without
the control plane mistaking its own keepalive interval for abusive traffic.
* Tasks name engines from a fixed registry, never file paths — a path here
would be remote code execution on every worker.
**What a worker can see:** to synthesise your text it has to receive that text,
and to clone a voice it has to receive the reference audio. There is no way
around that. Only add machines you control, which is why approval is per
worker and never implicit.
## Turning it off
Settings → System → Remote workers → toggle off. The listening socket closes
and the background loops stop. Your enrolled workers and their settings are
kept, so turning it back on does not mean setting everything up again.
## Environment variables
| Variable | Purpose |
|---|---|
| `OMNIVOICE_REMOTE_WORKERS` | `1`/`0` — enable without the UI (headless, Docker) |
| `OMNIVOICE_WORKER_PORT` | Control-plane port (default `7443`) |
| `OMNIVOICE_WORKER_ENDPOINT_HOST` | Override the address shown to workers |
| `OMNIVOICE_WORKER_MODE` | `1` on the worker machine |
| `OMNIVOICE_WORKER_TOKEN` | Enrollment token, first run only |
Only one VoiceStudio instance can accept remote workers on a given port. If
another instance already owns the configured port, the app continues running
with remote workers unavailable and shows the conflict in Settings. Close the
other instance, or give this one a different `OMNIVOICE_WORKER_PORT` and
restart it.
State lives under your data directory in `workers/`: the certificate and key,
the worker's own key, and received artifacts.
## Contributor acceptance check
After changing remote-worker routing or transport, run the non-destructive
hardware acceptance script from the repository root:
```bash
scripts/verify-remote-worker.sh \
--worker-id '<worker-id>' \
--ssh-target '<user@worker-host>'
```
`WORKER_ID`, `WORKER_SSH_TARGET`, `WORKER_START_COMMAND`, `VOICESTUDIO_API`,
and `WORKER_CONTROL_PORT` are equivalent environment variables. Pass
`--worker-start-command` (or its environment equivalent) when the worker does
not start with `OMNIVOICE_WORKER_MODE=1 omnivoice`; it is printed only in the
manual worker-loss procedure. The worker id is optional only when exactly one
worker is connected. The script requires an SSH target so it can verify the
worker's OS and NVIDIA GPU before accepting any result.
The check never deletes model caches or user data. It selects an engine the
worker itself reports as absent for the missing-model check. Operations that
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.
+2 -2
View File
@@ -71,8 +71,8 @@ export const listArchetypes = (filters: ArchetypeFilters = {}): Promise<Archetyp
};
/** Full URL for an archetype preview clip (use as an <audio> src). */
export const archetypePreviewUrl = (id: string): string =>
apiUrl(`/archetypes/${encodeURIComponent(id)}/preview`);
export const archetypePreviewUrl = (id: string, local = false): string =>
apiUrl(`/archetypes/${encodeURIComponent(id)}/preview${local ? '?local=true' : ''}`);
/** Materialize an archetype into a reusable voice profile. */
export const useArchetypeAsProfile = (
+298
View File
@@ -0,0 +1,298 @@
/**
* Header GPU picker where the next job runs.
*
* Exactly one target is active at a time: this machine, or one worker you
* enrolled. Other connected workers are standby and receive nothing.
*
* The badge shows the **resolved** answer, not the stored choice, and those
* differ in the case that matters: you picked your desktop, your desktop went
* to sleep, and the work is now running locally. Showing the choice there
* would be a lie every time it mattered most so the chip reads "Local" with
* the reason underneath, while the menu still shows your desktop as selected.
*
* `Local` has no rename control. It is not a machine it is this machine
* and there is nothing to name.
*
* The answer is also per operation, because a worker is not remote for
* everything: work reaches a worker only where this side has a producer for
* it, and those are ported one at a time. Without that, the badge would read
* "gpu2 ● ready" on the Dub, Audiobook and Transcripts tabs while 100% of
* that work runs here the same lie the resolved-answer rule exists to
* prevent, in a place the user cannot even see it happen.
*/
import React, { useCallback, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { Cpu, Check, ChevronDown } from 'lucide-react';
import toast from 'react-hot-toast';
import { useTranslation } from 'react-i18next';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { apiFetch } from '../api/client';
import { useAppStore } from '../store';
// Two cadences: a slow tick that just keeps status honest, and a fast one
// while work is in flight so the task count reads as live rather than lagging
// several seconds behind the thing the user is watching.
const IDLE_REFRESH_MS = 5000;
const BUSY_REFRESH_MS = 1000;
/**
* What the workspace in front of the user actually submits.
*
* A workspace that submits no GPU job of its own the launchpad, Settings,
* the gallery, OmniDrive maps to no operation, which asks about the target
* itself rather than about one job. That is exactly what the menu wants when
* you open it from anywhere to pick a machine.
*
* Ids are the control plane's (`worker/routing.py`), not the UI's: `mode` is
* a navigation id and these are units of work, so `studio` and the legacy
* `clone`/`design` modes all submit `tts`.
*/
const OP_BY_MODE = {
generate: 'tts',
studio: 'tts',
clone: 'tts',
design: 'tts',
dub: 'dub',
audiobook: 'audiobook',
stories: 'longform',
transcriptions: 'asr',
dictation: 'dictation',
};
/** ready → green, busy → amber, offline → red. */
const DOT = {
ready: 'bg-emerald-400',
busy: 'bg-amber-400',
offline: 'bg-red-400',
};
function StatusDot({ status }) {
return (
<span
aria-hidden="true"
className={`inline-block h-[6px] w-[6px] shrink-0 rounded-full ${DOT[status] || DOT.offline}`}
/>
);
}
/** Latency is only meaningful for a machine across a network. */
function latencyLabel(target) {
if (!target || target.is_local || !target.connected) return '';
const ms = target.latency_ms;
// 0 means "not measured yet", not "instantaneous" say nothing rather than
// claim a suspiciously perfect link.
if (!ms) return '';
return ms < 1 ? '<1 ms' : `${Math.round(ms)} ms`;
}
async function request(path, { body, ...opts } = {}) {
const res = await apiFetch(path, {
...opts,
...(body === undefined
? {}
: { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }),
});
const payload = await res.json().catch(() => null);
if (!res.ok) {
const detail = payload?.detail;
throw new Error(
typeof detail === 'string' ? detail : detail ? JSON.stringify(detail) : `HTTP ${res.status}`,
);
}
return payload;
}
export default function GpuTarget() {
const { t } = useTranslation();
const queryClient = useQueryClient();
const [open, setOpen] = useState(false);
// The header's status row is `overflow-hidden`, which clips an absolutely
// positioned menu the entries below the first simply vanish. Rendering
// into a portal and positioning from the button's rect escapes the clip.
const buttonRef = useRef(null);
const [anchor, setAnchor] = useState(null);
const toggle = useCallback(() => {
setOpen((wasOpen) => {
if (!wasOpen && buttonRef.current) {
const rect = buttonRef.current.getBoundingClientRect();
setAnchor({ top: rect.bottom + 4, right: window.innerWidth - rect.right });
}
return !wasOpen;
});
}, []);
// The surface being rendered, not the machine see OP_BY_MODE. Part of the
// query key so switching tabs re-resolves instead of showing the previous
// tab's answer until the next poll.
const op = OP_BY_MODE[useAppStore((s) => s.mode)] || '';
const { data } = useQuery({
queryKey: ['workers', 'target', op],
queryFn: () => request(op ? `/workers/target?op=${encodeURIComponent(op)}` : '/workers/target'),
refetchInterval: (query) => {
const t = (query.state?.data?.targets || []).find((x) => x.id === query.state?.data?.target);
return t && t.active_tasks > 0 ? BUSY_REFRESH_MS : IDLE_REFRESH_MS;
},
refetchIntervalInBackground: false,
retry: false,
});
const targets = data?.targets || [];
const active = data?.active;
const chosen = data?.target || 'local';
// Nothing to choose between: with no worker enrolled, a GPU picker is just
// clutter on a feature the user has not opted into.
if (targets.length <= 1) return null;
const chosenTarget = targets.find((x) => x.id === chosen);
const activeTarget = active?.remote
? targets.find((x) => x.id === active.worker_id)
: targets.find((x) => x.is_local);
const label = active?.remote ? active.label : t('gpu.local', { defaultValue: 'Local' });
// What a worker can be sent at all. Absent (an older control plane, or a
// response that predates op-awareness) means "don't claim anything" the
// coverage line and the unported reason simply do not render.
const remoteOps = data?.remote_operations || [];
const opLabel = (id) => t(`gpu.ops.${id}`, { defaultValue: id });
// Chosen a worker, on a surface nothing would ever send it. Not a failure
// and not the worker's fault, so it is deliberately NOT `fellBack`: no
// amber, because there is nothing wrong to warn about.
const opIsLocalOnly =
Boolean(op) && chosen !== 'local' && remoteOps.length > 0 && !remoteOps.includes(op);
const coverage = remoteOps.length
? t('gpu.coverage', {
ops: remoteOps.map(opLabel).join(', '),
defaultValue: '{{ops}} only',
})
: '';
const reason = opIsLocalOnly
? op === 'dictation'
? t('gpu.dictationLocal', { defaultValue: 'Dictation always runs on this machine' })
: t('gpu.opLocal', {
op: opLabel(op),
defaultValue: 'Local — {{op}} does not run remotely yet',
})
: active?.reason || '';
const fellBack = !active?.remote && chosen !== 'local' && !opIsLocalOnly;
// When the chosen worker is unreachable the work runs here, but the DOT must
// report the worker's state a green dot beside "Local" would hide that
// the machine you picked is down. Same on an unported surface: the machine
// is fine, and the reason line is what says why it is idle.
const dotStatus =
fellBack || opIsLocalOnly ? chosenTarget?.status || 'offline' : activeTarget?.status || 'ready';
const chipLatency = latencyLabel(active?.remote ? activeTarget : null);
const choose = async (id) => {
setOpen(false);
try {
const next = await request('/workers/target', { method: 'POST', body: { target: id } });
// POST answers for the target as a whole. Writing that into an
// op-scoped cache entry would paint "gpu2 ready" on a tab whose work
// is local until the next poll corrected it so it seeds the cache
// only where the two questions are the same one, and the invalidate
// below refreshes the rest.
if (!op) queryClient.setQueryData(['workers', 'target', ''], next);
queryClient.invalidateQueries({ queryKey: ['workers'] });
} catch (e) {
toast.error(e?.message || String(e));
}
};
return (
<div className="relative">
<button
ref={buttonRef}
type="button"
onClick={toggle}
title={reason || undefined}
aria-label={t('gpu.picker', { defaultValue: 'Where jobs run' })}
className="inline-flex items-center gap-1.5 rounded px-2 py-1 text-xs opacity-80 hover:opacity-100"
>
<Cpu size={13} />
<StatusDot status={dotStatus} />
<span className={fellBack ? 'text-amber-400' : undefined}>{label}</span>
{chipLatency && <span className="opacity-60">{chipLatency}</span>}
<ChevronDown size={11} />
</button>
{open &&
anchor &&
createPortal(
<>
<div className="fixed inset-0 z-[9998]" onClick={() => setOpen(false)} />
<div
data-slot="gpu-target-menu"
style={{ top: anchor.top, right: anchor.right }}
className="fixed z-[9999] min-w-[240px] rounded-lg border border-transparent bg-[var(--chrome-bg)] p-1 shadow-lg" >
{targets.map((target) => (
// Any enrolled worker is selectable, including an offline one:
// you pick your desktop and then go and switch it on. Routing
// already falls back locally with a reason until it answers, so
// forbidding the choice would only prevent setting it up.
<button
key={target.id}
type="button"
onClick={() => choose(target.id)}
className={`flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs hover:bg-white/5 ${
target.available ? '' : 'opacity-60'
}`}
>
<span className="w-3">{target.id === chosen ? <Check size={12} /> : null}</span>
{target.is_local ? (
<span className="w-[6px]" />
) : (
<StatusDot status={target.status} />
)}
<span className="min-w-0 flex-1">
<span className="flex items-center justify-between gap-2">
<span className="truncate">{target.label}</span>
{latencyLabel(target) && (
<span className="shrink-0 opacity-60">{latencyLabel(target)}</span>
)}
</span>
{/* The address disambiguates two machines a user named
similarly; the detail says why one cannot be picked; the
task count is what makes "busy" mean something; the
coverage is what stops the entry from implying the
machine takes everything. Coverage is dropped while the
worker is unusable "offline · TTS only" answers a
question the user is not asking yet. */}
{!target.is_local && (
<span className="block truncate opacity-60">
{target.detail
? target.detail
: [
target.active_tasks > 0
? `${target.endpoint} · ${target.active_tasks}/${target.max_tasks} ${t(
'gpu.tasks',
{ defaultValue: 'tasks' },
)}`
: target.endpoint,
coverage,
]
.filter(Boolean)
.join(' · ')}
</span>
)}
</span>
</button>
))}
{reason && (fellBack || opIsLocalOnly) && (
<p
className={`m-0 px-2 py-1 text-[11px] ${
fellBack ? 'text-amber-400' : 'opacity-60'
}`}
>
{reason}
</p>
)}
</div>
</>,
document.body,
)}
</div>
);
}
+413
View File
@@ -0,0 +1,413 @@
import React from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
vi.mock('react-hot-toast', () => ({ default: { error: vi.fn(), success: vi.fn() } }));
const { apiFetch } = vi.hoisted(() => ({ apiFetch: vi.fn() }));
vi.mock('../api/client', () => ({ apiFetch }));
// apiFetch resolves to a raw Response and does not throw on 4xx.
const respond = (body, { ok = true, status = 200 } = {}) => ({ ok, status, json: async () => body });
import toast from 'react-hot-toast';
import GpuTarget from './GpuTarget';
import { useAppStore } from '../store';
const LOCAL = { id: 'local', label: 'Local', available: true, connected: true, is_local: true };
const DESKTOP = {
id: 'w1',
label: 'desktop-4090',
endpoint: '192.168.0.222:2222',
available: true,
connected: true,
is_local: false,
};
function renderPicker() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={client}>
<GpuTarget />
</QueryClientProvider>,
);
}
beforeEach(() => {
vi.clearAllMocks();
// The picker resolves against the workspace in front of the user, so every
// test states which one it is rather than inheriting the last one's.
useAppStore.setState({ mode: 'studio' });
});
describe('GpuTarget', () => {
it('renders nothing when no worker is enrolled', async () => {
// A user who never opted in must see no change to the header.
apiFetch.mockResolvedValue(
respond({ target: 'local', active: { remote: false }, targets: [LOCAL] }),
);
const { container } = renderPicker();
await waitFor(() => expect(apiFetch).toHaveBeenCalled());
expect(container.querySelector('button')).toBeNull();
});
it('shows Local when local is chosen', async () => {
apiFetch.mockResolvedValue(
respond({ target: 'local', active: { remote: false }, targets: [LOCAL, DESKTOP] }),
);
renderPicker();
expect(await screen.findByText('Local')).toBeInTheDocument();
});
it('shows the worker name when a remote target is active', async () => {
apiFetch.mockResolvedValue(
respond({
target: 'w1',
active: { remote: true, worker_id: 'w1', label: 'desktop-4090' },
targets: [LOCAL, DESKTOP],
}),
);
renderPicker();
expect(await screen.findByText('desktop-4090')).toBeInTheDocument();
});
it('shows the RESOLVED answer, not the stored choice', async () => {
// The case that matters: you picked your desktop, it went to sleep, and
// the work is running here. Showing "desktop-4090" would be a lie exactly
// when it matters most.
apiFetch.mockResolvedValue(
respond({
target: 'w1',
active: { remote: false, label: 'Local', reason: 'desktop-4090 is offline — running locally' },
targets: [LOCAL, { ...DESKTOP, available: false, connected: false, detail: 'offline' }],
}),
);
renderPicker();
expect(await screen.findByText('Local')).toBeInTheDocument();
expect(screen.queryByText('desktop-4090')).not.toBeInTheDocument();
});
it('lists targets with their endpoint, and marks the chosen one', async () => {
apiFetch.mockResolvedValue(
respond({ target: 'local', active: { remote: false }, targets: [LOCAL, DESKTOP] }),
);
renderPicker();
fireEvent.click(await screen.findByRole('button'));
expect(await screen.findByText('desktop-4090')).toBeInTheDocument();
expect(screen.getByText('192.168.0.222:2222')).toBeInTheDocument();
});
it('lets you choose an offline worker so you can set it up first', async () => {
// You pick your desktop, then go and switch it on. Routing falls back
// locally with a reason until it answers.
apiFetch.mockResolvedValue(
respond({
target: 'local',
active: { remote: false },
targets: [LOCAL, { ...DESKTOP, available: false, connected: false, detail: 'offline' }],
}),
);
renderPicker();
fireEvent.click(await screen.findByRole('button'));
fireEvent.click(await screen.findByText('desktop-4090'));
await waitFor(() => {
const call = apiFetch.mock.calls.find(
([p, o]) => p === '/workers/target' && o?.method === 'POST',
);
expect(call).toBeTruthy();
expect(JSON.parse(call[1].body)).toEqual({ target: 'w1' });
});
});
it('shows why a target is unavailable', async () => {
apiFetch.mockResolvedValue(
respond({
target: 'local',
active: { remote: false },
targets: [LOCAL, { ...DESKTOP, available: false, detail: 'paused after repeated failures' }],
}),
);
renderPicker();
fireEvent.click(await screen.findByRole('button'));
expect(await screen.findByText(/paused after repeated failures/)).toBeInTheDocument();
});
it('sends the chosen target as real JSON', async () => {
apiFetch.mockResolvedValue(
respond({ target: 'local', active: { remote: false }, targets: [LOCAL, DESKTOP] }),
);
renderPicker();
fireEvent.click(await screen.findByRole('button'));
fireEvent.click(await screen.findByText('desktop-4090'));
await waitFor(() => {
// GET and POST share this path, so match on the method otherwise the
// polling GET is found first and carries no headers.
const call = apiFetch.mock.calls.find(
([p, o]) => p === '/workers/target' && o?.method === 'POST',
);
expect(call).toBeTruthy();
expect(call[1].headers['Content-Type']).toBe('application/json');
expect(JSON.parse(call[1].body)).toEqual({ target: 'w1' });
});
});
it('surfaces a rejection instead of failing silently', async () => {
apiFetch.mockImplementation((path) =>
Promise.resolve(
path === '/workers/target' && apiFetch.mock.calls.length > 1
? respond({ detail: 'No such worker.' }, { ok: false, status: 404 })
: respond({ target: 'local', active: { remote: false }, targets: [LOCAL, DESKTOP] }),
),
);
renderPicker();
fireEvent.click(await screen.findByRole('button'));
fireEvent.click(await screen.findByText('desktop-4090'));
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('No such worker.'));
});
it('offers no rename for Local — it is this machine, not a machine', async () => {
apiFetch.mockResolvedValue(
respond({ target: 'local', active: { remote: false }, targets: [LOCAL, DESKTOP] }),
);
renderPicker();
fireEvent.click(await screen.findByRole('button'));
expect(screen.queryByLabelText(/rename/i)).not.toBeInTheDocument();
});
// Status, latency, live tasks
const READY = { ...DESKTOP, status: 'ready', latency_ms: 12.4, active_tasks: 0, max_tasks: 2 };
it('shows the worker name and its latency in the chip', async () => {
apiFetch.mockResolvedValue(
respond({
target: 'w1',
active: { remote: true, worker_id: 'w1', label: 'desktop-4090' },
targets: [LOCAL, READY],
}),
);
renderPicker();
expect(await screen.findByText('desktop-4090')).toBeInTheDocument();
expect(screen.getByText('12 ms')).toBeInTheDocument();
});
it('never shows latency for Local — there is no network to measure', async () => {
apiFetch.mockResolvedValue(
respond({ target: 'local', active: { remote: false }, targets: [LOCAL, READY] }),
);
renderPicker();
await screen.findByText('Local');
expect(screen.queryByText(/ms$/)).not.toBeInTheDocument();
});
it('says nothing when latency has not been measured yet', async () => {
// 0 means "no sample", not "instant".
apiFetch.mockResolvedValue(
respond({
target: 'w1',
active: { remote: true, worker_id: 'w1', label: 'desktop-4090' },
targets: [LOCAL, { ...READY, latency_ms: 0 }],
}),
);
renderPicker();
await screen.findByText('desktop-4090');
expect(screen.queryByText(/ms$/)).not.toBeInTheDocument();
});
it('colours the dot green when ready, amber when busy, red when offline', async () => {
for (const [status, cls] of [
['ready', 'bg-emerald-400'],
['busy', 'bg-amber-400'],
['offline', 'bg-red-400'],
]) {
apiFetch.mockResolvedValue(
respond({
target: 'w1',
active:
status === 'offline'
? { remote: false, label: 'Local', reason: 'desktop-4090 is offline' }
: { remote: true, worker_id: 'w1', label: 'desktop-4090' },
targets: [LOCAL, { ...READY, status, connected: status !== 'offline' }],
}),
);
const { container, unmount } = renderPicker();
await waitFor(() => expect(container.querySelector(`.${cls}`)).toBeTruthy());
unmount();
vi.clearAllMocks();
}
});
it('the dot reports the CHOSEN worker even when work fell back locally', async () => {
// A green dot beside "Local" would hide that the machine you picked is down.
apiFetch.mockResolvedValue(
respond({
target: 'w1',
active: { remote: false, label: 'Local', reason: 'desktop-4090 is offline' },
targets: [LOCAL, { ...READY, status: 'offline', connected: false }],
}),
);
const { container } = renderPicker();
await screen.findByText('Local');
expect(container.querySelector('.bg-red-400')).toBeTruthy();
});
// Op awareness
//
// A worker is not remote for everything: work reaches it only where this
// side has a producer, and those are ported one at a time. Without asking
// per operation the badge reads "gpu2 ready" on the Dub tab while 100% of
// dubbing runs locally the exact lie the resolved-answer rule exists to
// prevent, in a place the user cannot see it happen.
const paths = () => apiFetch.mock.calls.filter(([, o]) => !o?.method).map(([p]) => p);
it('asks routing about the operation the current workspace submits', async () => {
useAppStore.setState({ mode: 'dub' });
apiFetch.mockResolvedValue(
respond({ target: 'w1', op: 'dub', active: { remote: false }, targets: [LOCAL, DESKTOP] }),
);
renderPicker();
await waitFor(() => expect(paths()).toContain('/workers/target?op=dub'));
});
it('asks about the target itself where the workspace submits no job', async () => {
// The launchpad renders no GPU work; the menu is being opened to pick a
// machine, not to ask about one job.
useAppStore.setState({ mode: 'launchpad' });
apiFetch.mockResolvedValue(
respond({ target: 'w1', op: '', active: { remote: false }, targets: [LOCAL, DESKTOP] }),
);
renderPicker();
await waitFor(() => expect(paths()).toContain('/workers/target'));
});
it('reads Local, in the user language, on a surface that has no remote path', async () => {
useAppStore.setState({ mode: 'dub' });
apiFetch.mockResolvedValue(
respond({
target: 'w1',
op: 'dub',
// The worker is healthy and chosen it simply receives no dubbing.
active: { remote: false, label: 'Local', reason: 'dubbing does not run remotely yet' },
remote_operations: ['tts'],
targets: [LOCAL, READY],
}),
);
renderPicker();
expect(await screen.findByText('Local')).toBeInTheDocument();
expect(screen.queryByText('desktop-4090')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button'));
// Localized here, not the control plane's English sentence.
expect(
await screen.findByText('Local — dubbing does not run remotely yet'),
).toBeInTheDocument();
});
it('does not warn in amber when the surface simply has no remote path', async () => {
// Nothing is wrong: the machine is fine and this work was never going to
// leave. Amber is reserved for "the worker you picked is down".
useAppStore.setState({ mode: 'dub' });
apiFetch.mockResolvedValue(
respond({
target: 'w1',
op: 'dub',
active: { remote: false, label: 'Local', reason: 'dubbing does not run remotely yet' },
remote_operations: ['tts'],
targets: [LOCAL, READY],
}),
);
const { container } = renderPicker();
await screen.findByText('Local');
expect(container.querySelector('.text-amber-400')).toBeNull();
});
it('labels dictation as intentionally local without an unported notice', async () => {
useAppStore.setState({ mode: 'dictation' });
apiFetch.mockResolvedValue(
respond({
target: 'w1',
op: 'dictation',
active: { remote: false, label: 'Local', reason: 'ignored server wording' },
remote_operations: ['audiobook', 'tts'],
targets: [LOCAL, READY],
}),
);
renderPicker();
await waitFor(() => expect(paths()).toContain('/workers/target?op=dictation'));
fireEvent.click(await screen.findByRole('button'));
expect(await screen.findByText('Dictation always runs on this machine')).toBeInTheDocument();
expect(screen.queryByText(/does not run remotely yet/)).not.toBeInTheDocument();
});
it('says what a worker actually takes, in the menu', async () => {
apiFetch.mockResolvedValue(
respond({
target: 'local',
op: 'tts',
active: { remote: false },
remote_operations: ['tts'],
targets: [LOCAL, READY],
}),
);
renderPicker();
fireEvent.click(await screen.findByRole('button'));
expect(await screen.findByText(/192\.168\.0\.222:2222 · TTS only/)).toBeInTheDocument();
});
it('claims no coverage when the control plane reports none', async () => {
// An older control plane answers without `remote_operations`. Saying
// "TTS only" there would be an invention, and greying the surface out
// would break a working setup.
useAppStore.setState({ mode: 'dub' });
apiFetch.mockResolvedValue(
respond({
target: 'w1',
active: { remote: true, worker_id: 'w1', label: 'desktop-4090' },
targets: [LOCAL, READY],
}),
);
renderPicker();
expect(await screen.findByText('desktop-4090')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button'));
expect(screen.queryByText(/only/i)).not.toBeInTheDocument();
});
it('shows the address and live task count for a busy worker', async () => {
apiFetch.mockResolvedValue(
respond({
target: 'w1',
active: { remote: true, worker_id: 'w1', label: 'desktop-4090' },
targets: [LOCAL, { ...READY, status: 'busy', active_tasks: 1, max_tasks: 2 }],
}),
);
renderPicker();
fireEvent.click(await screen.findByRole('button'));
expect(await screen.findByText(/192\.168\.0\.222:2222 · 1\/2 tasks/)).toBeInTheDocument();
});
});
+5
View File
@@ -1,5 +1,6 @@
import React, { useState, useRef, useEffect, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import GpuTarget from './GpuTarget';
import { createPortal } from 'react-dom';
import {
Globe,
@@ -342,6 +343,10 @@ export default function Header({
</span>
</>
)}
{/* Where the next job runs. Renders nothing until at least one
remote worker is enrolled, so a user who never opts in sees
no change to the header at all. */}
<GpuTarget />
<span className="[border-left:1px_solid_var(--chrome-border)] pl-[6px] flex items-center gap-1">
<Badge
tone={
+12 -10
View File
@@ -129,15 +129,16 @@ function formatEta(seconds) {
*/
export function reduceWizardDownloadEvent(prev, ev) {
if (!ev || !ev.repo_id) return prev;
const cur = prev[ev.repo_id] || { phase: 'active', files: {} };
const key = `${ev.target || 'local'}\u0000${ev.repo_id}`;
const cur = prev[key] || { phase: 'active', files: {} };
// Lifecycle markers gate reset; a file-level 'done' must NOT clear the repo.
if (ev.phase === 'install_start') {
return { ...prev, [ev.repo_id]: { phase: 'active', files: {} } };
return { ...prev, [key]: { phase: 'active', files: {} } };
}
// Success terminal drop the transient row.
if (ev.phase === 'install_done') {
const next = { ...prev };
delete next[ev.repo_id];
delete next[key];
return next;
}
// Error terminal KEEP the row + its message so it renders with a Retry.
@@ -147,7 +148,7 @@ export function reduceWizardDownloadEvent(prev, ev) {
if (ev.phase === 'install_error') {
return {
...prev,
[ev.repo_id]: {
[key]: {
...cur,
phase: 'install_error',
error: ev.error,
@@ -159,7 +160,7 @@ export function reduceWizardDownloadEvent(prev, ev) {
if (ev.phase === 'aggregate') {
return {
...prev,
[ev.repo_id]: {
[key]: {
...cur,
agg: {
bytesDone: ev.bytes_done || 0,
@@ -181,7 +182,7 @@ export function reduceWizardDownloadEvent(prev, ev) {
rate: ev.rate || 0,
},
};
return { ...prev, [ev.repo_id]: { ...cur, files } };
return { ...prev, [key]: { ...cur, files } };
}
/**
@@ -194,7 +195,7 @@ export function reduceWizardDownloadEvent(prev, ev) {
export function mirrorBlockedRepos(progress) {
return Object.entries(progress || {})
.filter(([, p]) => p?.phase === 'install_error' && p?.docsTopic === 'HF_MIRROR_UNREACHABLE')
.map(([repoId]) => repoId);
.map(([key]) => key.split('\u0000').at(-1));
}
// LED dot tone per row state.
@@ -294,13 +295,14 @@ export default function WizardLibrary() {
}, []);
const install = (repoId) => {
setProgress((p) => ({ ...p, [repoId]: { phase: 'active', files: {} } }));
const key = `local\u0000${repoId}`;
setProgress((p) => ({ ...p, [key]: { phase: 'active', files: {} } }));
installMutation.mutate(repoId, {
onError: (e) => {
toast.error(e?.message || 'install failed');
setProgress((p) => {
const n = { ...p };
delete n[repoId];
delete n[key];
return n;
});
},
@@ -332,7 +334,7 @@ export default function WizardLibrary() {
const tail = optionalAll.filter((m) => !isRecommendedPick(m, platformTags));
const modelRow = (m, chip, chipTone, note, chipTitle) => {
const p = progress[m.repo_id];
const p = Object.entries(progress).find(([key]) => key.endsWith(`\u0000${m.repo_id}`))?.[1];
// A failed install PERSISTS (P1-A): show the mirror-aware reason + a Retry
// instead of the row silently vanishing.
const errored = p?.phase === 'install_error';
+165 -154
View File
@@ -11,11 +11,12 @@ import { SettingsSection, SettingsInput, SETTINGS_SECTION_SURFACE } from './prim
import { askConfirm } from './native';
import { fmtBytes } from './models/format';
import { computeRowRuntime } from './models/runtime';
import { reduceModelDownloadEvent, isAutoPurgeTerminal } from './models/downloadReducer';
import { downloadKey, progressForRepo, reduceModelDownloadEvent, isAutoPurgeTerminal } from './models/downloadReducer';
import { makeModelColumns } from './models/columns';
import { groupModels } from './models/sections';
import RecoBanner from './models/RecoBanner';
import ModelSection from './models/ModelSection';
import VoicePreviewsPanel from './VoicePreviewsPanel';
/**
* Model store every known HF model, grouped by capability (TTS / ASR /
@@ -262,7 +263,10 @@ export default function ModelStoreTab({ info, modelBadge }) {
await cancelInstallModel(repoId);
setRowState((prev) => ({
...prev,
[repoId]: { ...(prev[repoId] || { files: {} }), phase: 'install_cancelled' },
[downloadKey('local', repoId)]: {
...(prev[downloadKey('local', repoId)] || { files: {} }),
phase: 'install_cancelled',
},
}));
} catch (e) {
toast.error(e.message || String(e));
@@ -275,7 +279,9 @@ export default function ModelStoreTab({ info, modelBadge }) {
(repoId) => {
setRowState((prev) => {
const next = { ...prev };
delete next[repoId];
for (const key of Object.keys(next)) {
if (key.endsWith(`\u0000${repoId}`)) delete next[key];
}
return next;
});
delete speedRef.current[repoId];
@@ -322,7 +328,7 @@ export default function ModelStoreTab({ info, modelBadge }) {
);
const getRowRuntime = React.useCallback(
(m) => computeRowRuntime(m, rowState, busy),
(m) => computeRowRuntime(m, { [m.repo_id]: progressForRepo(rowState, m.repo_id) }, busy),
[busy, rowState],
);
@@ -367,161 +373,166 @@ export default function ModelStoreTab({ info, modelBadge }) {
if (!data) return null;
return (
<section className={SETTINGS_SECTION_SURFACE} data-slot="settings-section">
<div className="flex flex-wrap items-center justify-between gap-[var(--space-3)] px-[2px] pb-[6px] pt-[2px] font-[family-name:var(--chrome-font-mono)] text-[length:var(--text-xs)] text-[var(--chrome-fg-muted)] max-[580px]:flex-col max-[580px]:items-start">
<div className="inline-flex flex-wrap items-center gap-[var(--space-2)]">
<span>
<strong className="font-semibold text-[var(--chrome-fg)]">
{fmtBytes(data.total_installed_bytes)}
</strong>
</span>
{data.disk_free_gb != null && (
<>
<span className="text-[var(--chrome-fg-dim)]">·</span>
<span title={t('models.disk_free_title')}>
{t('models.disk_free', { size: `${data.disk_free_gb} GB` })}
</span>
</>
)}
<span className="text-[var(--chrome-fg-dim)]">·</span>
<span title={data.hf_cache_dir}>
<code className="font-[family-name:var(--chrome-font-mono)] text-[length:var(--text-xs)] text-[var(--chrome-fg)]">
{data.hf_cache_dir?.replace(/^\/Users\/[^/]+/, '~')}
</code>
</span>
{info && <span className="text-[var(--chrome-fg-dim)]">·</span>}
{info && <span>{modelBadge}</span>}
{info?.fast_download?.xet_enabled && (
<>
<span className="text-[var(--chrome-fg-dim)]">·</span>
<span
className="text-[var(--chrome-accent)]"
title={
t('models.fast_download_title', {
version: info.fast_download.xet_version || 'Xet',
}) ||
`Fast downloads via Xet ${info.fast_download.xet_version || ''} — parallel chunked transfer`
}
>
{t('models.fast_download_badge') || 'fast download'}
</span>
</>
)}
</div>
<div className="inline-flex items-center gap-[var(--space-2)]">
{/* Compact HF token inline */}
{!hfTokenSet && !hfExpanded && (
<button
className="inline-flex cursor-pointer items-center gap-1 rounded-[var(--chrome-radius-pill)] [border:1px_solid_var(--chrome-border)] bg-transparent px-[var(--space-2)] py-[2px] text-[var(--chrome-fg-muted)] hover:bg-[var(--chrome-hover-bg)] hover:text-[var(--chrome-fg)]"
onClick={() => setHfExpanded(true)}
title={t('models.hf_set_title')}
>
<KeyRound size={11} /> {t('models.hf_token_btn')}
</button>
)}
{!hfTokenSet && hfExpanded && (
<div className="inline-flex items-center gap-[var(--space-2)]">
<input
type="password"
className="min-w-0 rounded-[var(--chrome-radius-pill)] [border:1px_solid_var(--chrome-border)] bg-[var(--chrome-hover-bg)] px-[var(--space-2)] py-[2px] font-[family-name:var(--chrome-font-mono)] text-[length:var(--text-xs)] text-[var(--chrome-fg)] placeholder:text-[var(--chrome-fg-dim)] focus-visible:border-[var(--chrome-accent)] focus-visible:shadow-[var(--focus-ring)] focus-visible:outline-none"
placeholder="hf_xxxxxxxxxxxx"
value={hfToken}
onChange={(e) => setHfToken(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') saveHfToken();
if (e.key === 'Escape') setHfExpanded(false);
}}
autoFocus
/>
<Button
size="sm"
variant="subtle"
onClick={saveHfToken}
disabled={hfSaving || !hfToken.trim()}
loading={hfSaving}
>
{t('common.save')}
</Button>
<a
href="#"
className="text-[var(--chrome-accent)] no-underline hover:underline"
onClick={(e) => {
e.preventDefault();
openExternal('https://huggingface.co/settings/tokens');
}}
title="Open huggingface.co/settings/tokens"
>
{t('models.get_token')}
</a>
</div>
)}
{hfTokenSet && (
<span className="inline-flex items-center gap-1 text-[var(--chrome-severity-ok)]">
<KeyRound size={10} />
<>
<section className={SETTINGS_SECTION_SURFACE} data-slot="settings-section">
<div className="flex flex-wrap items-center justify-between gap-[var(--space-3)] px-[2px] pb-[6px] pt-[2px] font-[family-name:var(--chrome-font-mono)] text-[length:var(--text-xs)] text-[var(--chrome-fg-muted)] max-[580px]:flex-col max-[580px]:items-start">
<div className="inline-flex flex-wrap items-center gap-[var(--space-2)]">
<span>
<strong className="font-semibold text-[var(--chrome-fg)]">
{fmtBytes(data.total_installed_bytes)}
</strong>
</span>
)}
<Button
variant="subtle"
size="sm"
onClick={reload}
loading={loading}
leading={<RefreshCw size={11} />}
>
{t('common.refresh')}
</Button>
{data.disk_free_gb != null && (
<>
<span className="text-[var(--chrome-fg-dim)]">·</span>
<span title={t('models.disk_free_title')}>
{t('models.disk_free', { size: `${data.disk_free_gb} GB` })}
</span>
</>
)}
<span className="text-[var(--chrome-fg-dim)]">·</span>
<span title={data.hf_cache_dir}>
<code className="font-[family-name:var(--chrome-font-mono)] text-[length:var(--text-xs)] text-[var(--chrome-fg)]">
{data.hf_cache_dir?.replace(/^\/Users\/[^/]+/, '~')}
</code>
</span>
{info && <span className="text-[var(--chrome-fg-dim)]">·</span>}
{info && <span>{modelBadge}</span>}
{info?.fast_download?.xet_enabled && (
<>
<span className="text-[var(--chrome-fg-dim)]">·</span>
<span
className="text-[var(--chrome-accent)]"
title={
t('models.fast_download_title', {
version: info.fast_download.xet_version || 'Xet',
}) ||
`Fast downloads via Xet ${info.fast_download.xet_version || ''} — parallel chunked transfer`
}
>
{t('models.fast_download_badge') || 'fast download'}
</span>
</>
)}
</div>
<div className="inline-flex items-center gap-[var(--space-2)]">
{/* Compact HF token inline */}
{!hfTokenSet && !hfExpanded && (
<button
className="inline-flex cursor-pointer items-center gap-1 rounded-[var(--chrome-radius-pill)] [border:1px_solid_var(--chrome-border)] bg-transparent px-[var(--space-2)] py-[2px] text-[var(--chrome-fg-muted)] hover:bg-[var(--chrome-hover-bg)] hover:text-[var(--chrome-fg)]"
onClick={() => setHfExpanded(true)}
title={t('models.hf_set_title')}
>
<KeyRound size={11} /> {t('models.hf_token_btn')}
</button>
)}
{!hfTokenSet && hfExpanded && (
<div className="inline-flex items-center gap-[var(--space-2)]">
<input
type="password"
className="min-w-0 rounded-[var(--chrome-radius-pill)] [border:1px_solid_var(--chrome-border)] bg-[var(--chrome-hover-bg)] px-[var(--space-2)] py-[2px] font-[family-name:var(--chrome-font-mono)] text-[length:var(--text-xs)] text-[var(--chrome-fg)] placeholder:text-[var(--chrome-fg-dim)] focus-visible:border-[var(--chrome-accent)] focus-visible:shadow-[var(--focus-ring)] focus-visible:outline-none"
placeholder="hf_xxxxxxxxxxxx"
value={hfToken}
onChange={(e) => setHfToken(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') saveHfToken();
if (e.key === 'Escape') setHfExpanded(false);
}}
autoFocus
/>
<Button
size="sm"
variant="subtle"
onClick={saveHfToken}
disabled={hfSaving || !hfToken.trim()}
loading={hfSaving}
>
{t('common.save')}
</Button>
<a
href="#"
className="text-[var(--chrome-accent)] no-underline hover:underline"
onClick={(e) => {
e.preventDefault();
openExternal('https://huggingface.co/settings/tokens');
}}
title="Open huggingface.co/settings/tokens"
>
{t('models.get_token')}
</a>
</div>
)}
{hfTokenSet && (
<span className="inline-flex items-center gap-1 text-[var(--chrome-severity-ok)]">
<KeyRound size={10} />
</span>
)}
<Button
variant="subtle"
size="sm"
onClick={reload}
loading={loading}
leading={<RefreshCw size={11} />}
>
{t('common.refresh')}
</Button>
</div>
</div>
</div>
<RecoBanner
reco={reco}
t={t}
installMutation={installMutation}
installingReco={installingReco}
setInstallingReco={setInstallingReco}
onInstallRecommended={onInstallRecommended}
onInstall={onInstall}
getRowRuntime={getRowRuntime}
diskFreeGb={data.disk_free_gb}
/>
<div className="my-[var(--space-2)] flex items-center gap-[var(--space-2)] max-[580px]:flex-col max-[580px]:items-stretch">
<SettingsInput
type="search"
className="max-w-none flex-1 text-[length:var(--text-xs)] min-w-[120px]"
placeholder={t('models.search_placeholder')}
value={query}
onChange={(e) => setQuery(e.target.value)}
aria-label={t('models.search_label')}
/>
</div>
{sections.map((group) => (
<ModelSection
key={group.key}
sectionKey={group.key}
title={MODEL_SECTION_LABEL[group.key] || group.key}
group={group}
columns={columns}
getRowRuntime={getRowRuntime}
<RecoBanner
reco={reco}
t={t}
installMutation={installMutation}
installingReco={installingReco}
setInstallingReco={setInstallingReco}
onInstallRecommended={onInstallRecommended}
onInstall={onInstall}
getRowRuntime={getRowRuntime}
diskFreeGb={data.disk_free_gb}
/>
))}
{/* Global empty state every section filtered out. Same actionable
"Clear filters" affordance the table-level empty state used to carry. */}
{sections.length === 0 && allModels.length > 0 && (
<div className="models-table__empty">
<span>{t('models.no_matches')}</span>
<Button
size="sm"
variant="subtle"
className="ml-[8px]"
onClick={() => setQuery('')}
data-testid="models-clear-filters"
>
{t('models.clear_filters')}
</Button>
<div className="my-[var(--space-2)] flex items-center gap-[var(--space-2)] max-[580px]:flex-col max-[580px]:items-stretch">
<SettingsInput
type="search"
className="max-w-none flex-1 text-[length:var(--text-xs)] min-w-[120px]"
placeholder={t('models.search_placeholder')}
value={query}
onChange={(e) => setQuery(e.target.value)}
aria-label={t('models.search_label')}
/>
</div>
)}
</section>
{sections.map((group) => (
<ModelSection
key={group.key}
sectionKey={group.key}
title={MODEL_SECTION_LABEL[group.key] || group.key}
group={group}
columns={columns}
getRowRuntime={getRowRuntime}
t={t}
/>
))}
{/* Global empty state every section filtered out. Same actionable
"Clear filters" affordance the table-level empty state used to carry. */}
{sections.length === 0 && allModels.length > 0 && (
<div className="models-table__empty">
<span>{t('models.no_matches')}</span>
<Button
size="sm"
variant="subtle"
className="ml-[8px]"
onClick={() => setQuery('')}
data-testid="models-clear-filters"
>
{t('models.clear_filters')}
</Button>
</div>
)}
</section>
{/* Downloaded previews belong next to downloaded models: same question
("what has this install fetched?"), same tab. */}
<VoicePreviewsPanel />
</>
);
}
@@ -0,0 +1,138 @@
/**
* Settings Models Voice previews.
*
* One line and two controls for the pre-rendered voice gallery: a consent
* toggle and a manual "Check now". The toggle is the *only* thing that ever
* starts an outbound fetch the backend downloads nothing until it flips, per
* the local-first guarantee and turning it on pulls the featured set so the
* yes has a visible effect.
*
* The status line deliberately reads "Featured set cached", never "51 of 1126":
* the catalog size is not a number anyone can act on. Freshness is rendered
* with Intl.RelativeTimeFormat so "2 days ago" is correct in all 21 UI
* languages without a phrase per unit.
*
* Endpoints:
* GET /archetypes/previews/status {enabled, featured_cached, featured_total, }
* PUT /archetypes/previews body {enabled}
* POST /archetypes/previews/check force a check, bypassing the 24 h throttle
*/
import React, { useCallback, useEffect, useState } from 'react';
import { AudioLines, RefreshCw } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { apiJson, apiFetch } from '../../api/client';
import { SettingsSection, SettingRow, SettingsToggle } from './primitives';
import { Button } from '../../ui';
/** "2 days ago" in the active UI language, from a seconds-ago scalar. */
function formatChecked(seconds, language) {
if (seconds == null) return null;
const units = [
['day', 86400],
['hour', 3600],
['minute', 60],
];
const rtf = new Intl.RelativeTimeFormat(language || 'en', { numeric: 'auto' });
for (const [unit, size] of units) {
if (seconds >= size) return rtf.format(-Math.floor(seconds / size), unit);
}
return rtf.format(0, 'minute');
}
export default function VoicePreviewsPanel() {
const { t, i18n } = useTranslation();
const [state, setState] = useState(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState(null);
const refresh = useCallback(async () => {
try {
setState(await apiJson('/archetypes/previews/status'));
} catch (e) {
setError(e?.message || null);
}
}, []);
useEffect(() => {
refresh();
}, [refresh]);
const send = useCallback(async (path, init) => {
setBusy(true);
setError(null);
try {
const res = await apiFetch(path, init);
setState(await res.json());
} catch (e) {
setError(e?.message || null);
} finally {
setBusy(false);
}
}, []);
const toggle = (enabled) =>
send('/archetypes/previews', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled }),
});
const enabled = Boolean(state?.enabled);
const cached = state?.featured_cached ?? 0;
const total = state?.featured_total ?? 0;
const checked = formatChecked(state?.checked_seconds_ago, i18n.language);
let line;
if (!enabled) {
line = t('models.voice_previews_off');
} else if (total > 0 && cached >= total) {
line = t('models.voice_previews_ready');
} else {
line = t('models.voice_previews_partial', { cached, total });
}
if (enabled) {
line = `${line} · ${checked ? t('models.voice_previews_checked', { when: checked }) : t('models.voice_previews_never')}`;
}
return (
<SettingsSection
icon={AudioLines}
title={t('models.voice_previews')}
description={t('models.voice_previews_desc')}
>
<SettingRow
title={t('models.voice_previews')}
subtitle={line}
control={
<div className="inline-flex items-center gap-[var(--space-3)]">
{enabled && (
<Button
size="sm"
variant="subtle"
disabled={busy}
onClick={() => send('/archetypes/previews/check', { method: 'POST' })}
data-testid="voice-previews-check"
>
<RefreshCw size={12} /> {t('models.voice_previews_check')}
</Button>
)}
<SettingsToggle
checked={enabled}
disabled={busy}
onChange={toggle}
aria-label={t('models.voice_previews')}
/>
</div>
}
/>
{(state?.last_error || error) && (
<div
className="text-[length:var(--text-xs)] text-[color:var(--chrome-fg-muted)]"
data-testid="voice-previews-error"
>
{t('models.voice_previews_rejected', { message: state?.last_error || error })}
</div>
)}
</SettingsSection>
);
}
@@ -0,0 +1,62 @@
import React from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
vi.mock('../../api/client', () => ({
apiJson: vi.fn(),
apiFetch: vi.fn(),
}));
import { apiJson, apiFetch } from '../../api/client';
import VoicePreviewsPanel from './VoicePreviewsPanel';
const OFF = { enabled: false, featured_cached: 0, featured_total: 0, checked_seconds_ago: null };
const ON = {
enabled: true,
featured_cached: 51,
featured_total: 51,
checked_seconds_ago: 2 * 86400,
};
describe('VoicePreviewsPanel', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('offers no check button until the user has opted in', async () => {
apiJson.mockResolvedValue(OFF);
render(<VoicePreviewsPanel />);
await waitFor(() => expect(apiJson).toHaveBeenCalled());
// Nothing to check when nothing may be downloaded and the line says so.
expect(screen.queryByTestId('voice-previews-check')).toBeNull();
expect(screen.getByText(/previews render on this machine/i)).toBeTruthy();
});
it('reads "featured set cached · checked 2 days ago", not a count of 1126', async () => {
apiJson.mockResolvedValue(ON);
render(<VoicePreviewsPanel />);
const line = await screen.findByText(/Featured set cached/);
expect(line.textContent).toMatch(/2 days ago/);
expect(line.textContent).not.toMatch(/1126/);
});
it('turning the toggle on is what asks the backend to download', async () => {
apiJson.mockResolvedValue(OFF);
apiFetch.mockResolvedValue({ json: async () => ON });
render(<VoicePreviewsPanel />);
await waitFor(() => expect(apiJson).toHaveBeenCalled());
fireEvent.click(screen.getByRole('switch'));
await waitFor(() => expect(apiFetch).toHaveBeenCalled());
const [path, init] = apiFetch.mock.calls[0];
expect(path).toBe('/archetypes/previews');
expect(JSON.parse(init.body)).toEqual({ enabled: true });
});
it('surfaces a rejected manifest instead of failing silently', async () => {
apiJson.mockResolvedValue({ ...ON, last_error: 'manifest signature does not verify' });
render(<VoicePreviewsPanel />);
const error = await screen.findByTestId('voice-previews-error');
expect(error.textContent).toMatch(/signature/);
});
});
@@ -0,0 +1,369 @@
/**
* Settings System Remote workers.
*
* Run inference on your other machines. Its own System entry rather than a
* section under Sharing, because the direction is opposite: everything in
* Sharing is about letting something else reach THIS machine, while this
* sends work OUT to machines you own and brings the results back.
*
* Also distinct from the Remote backend panel under Sharing: that one points
* this app at a backend running elsewhere, so the work and the data both live
* there. This keeps the app here and hands out individual tasks.
*
* Two rules the UI must not soften, because they are the feature's contract:
*
* Off means off. With the toggle off there is no listening socket, no
* certificate, and no background loop the app is what it was before.
* Every worker is consented to individually. Audio, reference voices, and
* text leave this machine for a worker, so "I trust my desktop" is not
* "I trust whatever else gets added later".
*
* The enrollment token is shown exactly once. Only its hash is stored, so
* there is no way to display it again that is the point, not a limitation.
*/
import React, { useState } from 'react';
import { Cpu, Copy, Check, Trash2, PlayCircle, Pencil } from 'lucide-react';
import toast from 'react-hot-toast';
import { useTranslation } from 'react-i18next';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { apiFetch } from '../../api/client';
import { askConfirm } from '../../utils/dialog';
import { SettingsSection, SettingRow, SettingsToggle } from './primitives';
import { Button, Badge } from '../../ui';
const REFRESH_MS = 5000;
/**
* `apiFetch` deliberately returns the raw Response and sets no Content-Type
* it preserves the call shape so FormData posts keep working. Every JSON
* caller therefore has to say so itself and parse the body, and a non-2xx is
* NOT an exception, so an unchecked call fails silently.
*
* This wrapper does all three in one place, and surfaces FastAPI's `detail`
* so the user sees "Remote workers are turned off…" instead of "500".
*/
async function request(path, { body, ...opts } = {}) {
const res = await apiFetch(path, {
...opts,
...(body === undefined
? {}
: { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }),
});
const payload = await res.json().catch(() => null);
if (!res.ok) {
const detail = payload?.detail;
throw new Error(
typeof detail === 'string' ? detail : detail ? JSON.stringify(detail) : `HTTP ${res.status}`,
);
}
return payload;
}
export default function WorkersPanel() {
const { t } = useTranslation();
const queryClient = useQueryClient();
const [token, setToken] = useState(null);
const [copied, setCopied] = useState(false);
const [busy, setBusy] = useState(false);
const { data } = useQuery({
queryKey: ['workers'],
queryFn: () => request('/workers'),
// Only poll once the feature is on: a disabled panel should not generate
// background traffic every five seconds forever.
refetchInterval: (query) => (query.state?.data?.running ? REFRESH_MS : false),
refetchIntervalInBackground: false,
});
const enabled = Boolean(data?.enabled);
const workers = data?.workers || [];
const refresh = () => queryClient.invalidateQueries({ queryKey: ['workers'] });
const setEnabled = async (next) => {
setBusy(true);
try {
await request('/workers/enabled', { method: 'POST', body: { enabled: next } });
if (!next) setToken(null);
refresh();
} catch (e) {
toast.error(e?.message || String(e));
} finally {
setBusy(false);
}
};
const createToken = async () => {
setBusy(true);
setCopied(false);
try {
setToken(await request('/workers/enrollments', { method: 'POST', body: { ttl_seconds: 900 } }));
} catch (e) {
toast.error(e?.message || String(e));
} finally {
setBusy(false);
}
};
const copyToken = async () => {
try {
await navigator.clipboard.writeText(token.token);
setCopied(true);
} catch {
toast.error(t('settings.workers_copy_failed', { defaultValue: 'Could not copy.' }));
}
};
const removeWorker = async (worker) => {
const ok = await askConfirm(
t('settings.workers_remove_confirm', {
name: worker.name,
defaultValue:
'Remove {{name}}? Its key is revoked, so it cannot reconnect without a new token.',
}),
t('settings.workers_remove_title', { defaultValue: 'Remove worker?' }),
);
if (!ok) return;
try {
await request(`/workers/${worker.id}`, { method: 'DELETE' });
refresh();
} catch (e) {
toast.error(e?.message || String(e));
}
};
const resumeWorker = async (worker) => {
try {
await request(`/workers/${worker.id}/resume`, { method: 'POST' });
refresh();
} catch (e) {
toast.error(e?.message || String(e));
}
};
const renameWorker = async (worker, name) => {
const trimmed = (name || '').trim();
// An empty name would leave the row labelled by its key id, which is not
// something a user can recognise treat it as "keep the current name".
if (!trimmed || trimmed === worker.name) return;
try {
await request(`/workers/${worker.id}`, { method: 'PATCH', body: { name: trimmed } });
refresh();
} catch (e) {
toast.error(e?.message || String(e));
}
};
const toggleWorker = async (worker) => {
try {
await request(`/workers/${worker.id}`, {
method: 'PATCH',
body: { enabled: !worker.enabled },
});
refresh();
} catch (e) {
toast.error(e?.message || String(e));
}
};
return (
<SettingsSection
icon={Cpu}
title={t('settings.workers_title', { defaultValue: 'Remote workers' })}
description={t('settings.workers_desc', {
defaultValue:
'Send individual jobs to GPUs on your other machines. Results come back here. Nothing is sent until you add a worker and approve it.',
})}
>
<SettingRow
title={t('settings.workers_enable', { defaultValue: 'Use remote workers' })}
subtitle={t('settings.workers_enable_hint', {
defaultValue:
'While this is off, no connection is accepted and nothing leaves this machine.',
})}
control={<SettingsToggle checked={enabled} disabled={busy} onChange={setEnabled} />}
/>
{enabled && !data?.running && data?.startup_error && (
<p role="alert" className="rounded-lg border border-red-500/40 bg-red-500/5 p-3 text-sm text-red-300">
{t('settings.workers_port_conflict', {
defaultValue:
'Remote workers are unavailable because another VoiceStudio instance is already accepting them on this port. Close the other instance, or set OMNIVOICE_WORKER_PORT to a different port and restart VoiceStudio.',
})}
</p>
)}
{enabled && data?.running && (
<>
<SettingRow
mono
title={t('settings.workers_endpoint', { defaultValue: 'Workers connect to' })}
subtitle={t('settings.workers_endpoint_hint', {
defaultValue:
'A worker has to be able to reach this address. On different networks, a VPN such as Tailscale is the reliable way.',
})}
control={<code>{data?.endpoint || '—'}</code>}
/>
<SettingRow
title={t('settings.workers_add', { defaultValue: 'Add a worker' })}
subtitle={t('settings.workers_add_hint', {
defaultValue:
'Generate a token, then paste it into OmniVoice on the other machine.',
})}
control={
<Button onClick={createToken} disabled={busy}>
{t('settings.workers_new_token', { defaultValue: 'Generate token' })}
</Button>
}
/>
{token && (
<div className="rounded-lg border border-amber-500/40 bg-amber-500/5 p-3">
{/* Deliberately plain visible text, not an InfoHint tooltip: a
warning the user must act on before navigating away cannot
be hidden behind a hover. */}
<p className="m-0 text-xs text-amber-300">
{t('settings.workers_token_once', {
defaultValue:
'Copy this now — it is shown only once, works only once, and expires in 15 minutes.',
})}
</p>
<div className="mt-2 flex items-center gap-2">
<code className="flex-1 break-all rounded bg-black/20 p-2 text-xs">
{token.token}
</code>
<Button variant="secondary" onClick={copyToken}>
{copied ? <Check size={14} /> : <Copy size={14} />}
{copied
? t('settings.workers_copied', { defaultValue: 'Copied' })
: t('settings.workers_copy', { defaultValue: 'Copy' })}
</Button>
</div>
</div>
)}
{workers.length === 0 ? (
<p className="py-3 text-sm opacity-70">
{t('settings.workers_none', {
defaultValue: 'No workers yet. Generate a token to add your first one.',
})}
</p>
) : (
<ul className="divide-y divide-white/10">
{workers.map((w) => (
<WorkerRow
key={w.id}
worker={w}
onRemove={() => removeWorker(w)}
onResume={() => resumeWorker(w)}
onToggle={() => toggleWorker(w)}
onRename={(name) => renameWorker(w, name)}
/>
))}
</ul>
)}
</>
)}
</SettingsSection>
);
}
export function WorkerRow({ worker, onRemove, onResume, onToggle, onRename = () => {} }) {
const { t } = useTranslation();
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(worker.name);
const paused = (worker.breakers || []).length > 0;
const commit = () => {
setEditing(false);
onRename(draft);
};
const status = !worker.enabled
? t('settings.workers_status_disabled', { defaultValue: 'Disabled' })
: paused
? t('settings.workers_status_paused', { defaultValue: 'Paused' })
: worker.connected
? t('settings.workers_status_online', { defaultValue: 'Online' })
: t('settings.workers_status_offline', { defaultValue: 'Offline' });
return (
<li className="flex flex-wrap items-center gap-3 py-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
{editing ? (
<input
autoFocus
aria-label={t('settings.workers_rename', { defaultValue: 'Rename worker' })}
className="min-w-0 flex-1 rounded bg-black/20 px-2 py-0.5 text-sm"
value={draft}
maxLength={120}
onChange={(e) => setDraft(e.target.value)}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === 'Enter') commit();
if (e.key === 'Escape') {
setDraft(worker.name);
setEditing(false);
}
}}
/>
) : (
<>
<span className="truncate font-medium">{worker.name}</span>
<button
type="button"
className="opacity-60 hover:opacity-100"
aria-label={t('settings.workers_rename', { defaultValue: 'Rename worker' })}
onClick={() => {
setDraft(worker.name);
setEditing(true);
}}
>
<Pencil size={12} />
</button>
</>
)}
<Badge tone={worker.connected && worker.enabled && !paused ? 'success' : 'neutral'}>
{status}
</Badge>
{!worker.consent_granted && (
<Badge tone="warning">
{t('settings.workers_needs_consent', { defaultValue: 'Not approved' })}
</Badge>
)}
</div>
{worker.connected && (
<p className="mt-0.5 text-xs opacity-70">
{t('settings.workers_load', {
active: worker.active_tasks ?? 0,
slots: (worker.active_tasks ?? 0) + (worker.available_slots ?? 0),
defaultValue: 'Tasks {{active}} / {{slots}}',
})}
</p>
)}
{/* The breaker summary is written to be understood: "paused after 3
failures, retrying in 60s" is actionable in a way that a
reliability percentage never is. */}
{paused && (
<p className="mt-0.5 text-xs text-amber-400">{worker.breakers[0].summary}</p>
)}
</div>
{paused && (
<Button variant="secondary" size="sm" onClick={onResume}>
<PlayCircle size={14} />
{t('settings.workers_resume', { defaultValue: 'Resume' })}
</Button>
)}
<Button variant="secondary" size="sm" onClick={onToggle}>
{worker.enabled
? t('settings.workers_disable', { defaultValue: 'Disable' })
: t('settings.workers_enable_one', { defaultValue: 'Enable' })}
</Button>
<Button variant="danger" size="sm" onClick={onRemove}>
<Trash2 size={14} />
{t('settings.workers_remove', { defaultValue: 'Remove' })}
</Button>
</li>
);
}
@@ -0,0 +1,302 @@
import React from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
vi.mock('react-hot-toast', () => ({
default: { error: vi.fn(), success: vi.fn() },
}));
const { apiFetch } = vi.hoisted(() => ({ apiFetch: vi.fn() }));
vi.mock('../../api/client', () => ({ apiFetch }));
/**
* apiFetch resolves to a raw Response it does not parse JSON and does not
* throw on 4xx. Mocking it as if it returned parsed data is what let the panel
* ship calling it wrong: the tests agreed with the mock, not with the client.
*/
const respond = (body, { ok = true, status = 200 } = {}) => ({
ok,
status,
json: async () => body,
});
const respondWith = (fn) => apiFetch.mockImplementation((...args) => Promise.resolve(fn(...args)));
const { askConfirm } = vi.hoisted(() => ({ askConfirm: vi.fn() }));
vi.mock('../../utils/dialog', () => ({ askConfirm }));
import toast from 'react-hot-toast';
import WorkersPanel, { WorkerRow } from './WorkersPanel';
function renderPanel() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={client}>
<WorkersPanel />
</QueryClientProvider>,
);
}
const WORKER = {
id: 'w1',
name: 'Desktop 4090',
enabled: true,
connected: true,
consent_granted: true,
active_tasks: 1,
available_slots: 1,
breakers: [],
};
beforeEach(() => {
vi.clearAllMocks();
askConfirm.mockResolvedValue(true);
});
describe('WorkersPanel', () => {
it('shows nothing beyond the toggle while the feature is off', async () => {
apiFetch.mockResolvedValue(respond({ enabled: false, running: false, workers: [] }));
renderPanel();
await waitFor(() => expect(apiFetch.mock.calls[0][0]).toBe('/workers'));
// The endpoint, the token button, and the worker list are all consequences
// of enabling an off feature must not advertise its surface.
expect(screen.queryByText(/Generate token/i)).not.toBeInTheDocument();
expect(screen.queryByText(/Workers connect to/i)).not.toBeInTheDocument();
});
it('reveals the endpoint and add flow once enabled', async () => {
apiFetch.mockResolvedValue(
respond({ enabled: true, running: true, endpoint: 'my-mac:7443', workers: [] }),
);
renderPanel();
expect(await screen.findByText('my-mac:7443')).toBeInTheDocument();
expect(screen.getByText(/Generate token/i)).toBeInTheDocument();
expect(screen.getByText(/No workers yet/i)).toBeInTheDocument();
});
it('explains an occupied control-plane port without exposing inactive controls', async () => {
apiFetch.mockResolvedValue(
respond({ enabled: true, running: false, startup_error: 'CONTROL_PLANE_PORT_IN_USE', workers: [] }),
);
renderPanel();
expect(await screen.findByRole('alert')).toHaveTextContent(/another VoiceStudio instance/i);
expect(screen.queryByText(/Generate token/i)).not.toBeInTheDocument();
});
it('shows the token once, with its shown-once warning', async () => {
respondWith((path) =>
path === '/workers'
? respond({ enabled: true, running: true, workers: [] })
: respond({ token: 'ovw_abc123', expires_at: 1 }),
);
renderPanel();
fireEvent.click(await screen.findByText(/Generate token/i));
expect(await screen.findByText('ovw_abc123')).toBeInTheDocument();
expect(screen.getByText(/shown only once/i)).toBeInTheDocument();
});
it('confirms before removing, because removal revokes the key', async () => {
apiFetch.mockResolvedValue(respond({ enabled: true, running: true, workers: [WORKER] }));
renderPanel();
fireEvent.click(await screen.findByText(/Remove/i));
await waitFor(() => expect(askConfirm).toHaveBeenCalled());
expect(askConfirm.mock.calls[0][0]).toMatch(/revoked/i);
await waitFor(() =>
expect(
apiFetch.mock.calls.some(([p, o]) => p === '/workers/w1' && o?.method === 'DELETE'),
).toBe(true),
);
});
it('does not remove when the confirmation is declined', async () => {
askConfirm.mockResolvedValue(false);
apiFetch.mockResolvedValue(respond({ enabled: true, running: true, workers: [WORKER] }));
renderPanel();
fireEvent.click(await screen.findByText(/Remove/i));
await waitFor(() => expect(askConfirm).toHaveBeenCalled());
expect(apiFetch.mock.calls.some(([, o]) => o?.method === 'DELETE')).toBe(false);
});
// The calls themselves
//
// apiFetch sets no Content-Type and does not parse JSON. These assert the
// wire shape rather than "a call happened", because the panel shipped a 422
// by sending a JSON string with no content type which a was-it-called
// assertion cannot see.
const jsonCall = (path) => apiFetch.mock.calls.find(([p]) => p === path)?.[1] || {};
it('sends the enable toggle as real JSON', async () => {
respondWith(() => respond({ enabled: false, running: false, workers: [] }));
renderPanel();
fireEvent.click(await screen.findByRole('switch'));
await waitFor(() => expect(jsonCall('/workers/enabled').method).toBe('POST'));
const opts = jsonCall('/workers/enabled');
expect(opts.headers['Content-Type']).toBe('application/json');
expect(JSON.parse(opts.body)).toEqual({ enabled: true });
});
it('sends the enrollment request as real JSON', async () => {
respondWith((path) =>
path === '/workers'
? respond({ enabled: true, running: true, workers: [] })
: respond({ token: 'ovw_x' }),
);
renderPanel();
fireEvent.click(await screen.findByText(/Generate token/i));
await waitFor(() => expect(jsonCall('/workers/enrollments').method).toBe('POST'));
const opts = jsonCall('/workers/enrollments');
expect(opts.headers['Content-Type']).toBe('application/json');
expect(JSON.parse(opts.body)).toEqual({ ttl_seconds: 900 });
});
it('sends the per-worker enable toggle as real JSON', async () => {
respondWith(() => respond({ enabled: true, running: true, workers: [WORKER] }));
renderPanel();
fireEvent.click(await screen.findByText('Disable'));
await waitFor(() => expect(jsonCall('/workers/w1').method).toBe('PATCH'));
const opts = jsonCall('/workers/w1');
expect(opts.headers['Content-Type']).toBe('application/json');
expect(JSON.parse(opts.body)).toEqual({ enabled: false });
});
it('clears a breaker through the resume endpoint', async () => {
respondWith(() =>
respond({
enabled: true,
running: true,
workers: [{ ...WORKER, breakers: [{ summary: 'Paused ...' }] }],
}),
);
renderPanel();
fireEvent.click(await screen.findByText(/Resume/));
await waitFor(() => expect(jsonCall('/workers/w1/resume').method).toBe('POST'));
});
it('surfaces the server reason instead of a bare status', async () => {
respondWith((path) =>
path === '/workers'
? respond({ enabled: true, running: true, workers: [] })
: respond({ detail: 'Remote workers are turned off.' }, { ok: false, status: 409 }),
);
renderPanel();
fireEvent.click(await screen.findByText(/Generate token/i));
await waitFor(() =>
expect(toast.error).toHaveBeenCalledWith('Remote workers are turned off.'),
);
});
it('renames a worker through the API', async () => {
respondWith(() => respond({ enabled: true, running: true, workers: [WORKER] }));
renderPanel();
fireEvent.click(await screen.findByLabelText(/rename worker/i));
const field = await screen.findByRole('textbox');
fireEvent.change(field, { target: { value: 'Studio 4090' } });
fireEvent.keyDown(field, { key: 'Enter' });
await waitFor(() => {
const call = apiFetch.mock.calls.find(([p, o]) => p === '/workers/w1' && o?.method === 'PATCH');
expect(call).toBeTruthy();
expect(JSON.parse(call[1].body)).toEqual({ name: 'Studio 4090' });
});
});
it('does not send an empty rename', async () => {
// An empty name would leave the row labelled by its key id, which is not
// something a user can recognise.
respondWith(() => respond({ enabled: true, running: true, workers: [WORKER] }));
renderPanel();
fireEvent.click(await screen.findByLabelText(/rename worker/i));
const field = await screen.findByRole('textbox');
fireEvent.change(field, { target: { value: ' ' } });
fireEvent.keyDown(field, { key: 'Enter' });
await waitFor(() => expect(screen.queryByRole('textbox')).not.toBeInTheDocument());
expect(apiFetch.mock.calls.some(([, o]) => o?.method === 'PATCH')).toBe(false);
});
it('escape cancels a rename', async () => {
respondWith(() => respond({ enabled: true, running: true, workers: [WORKER] }));
renderPanel();
fireEvent.click(await screen.findByLabelText(/rename worker/i));
const field = await screen.findByRole('textbox');
fireEvent.change(field, { target: { value: 'nope' } });
fireEvent.keyDown(field, { key: 'Escape' });
await waitFor(() => expect(screen.queryByRole('textbox')).not.toBeInTheDocument());
expect(apiFetch.mock.calls.some(([, o]) => o?.method === 'PATCH')).toBe(false);
});
});
describe('WorkerRow', () => {
const noop = () => {};
it('reports an online worker and its load', () => {
render(<WorkerRow worker={WORKER} onRemove={noop} onResume={noop} onToggle={noop} />);
expect(screen.getByText('Online')).toBeInTheDocument();
expect(screen.getByText(/Tasks 1 \/ 2/)).toBeInTheDocument();
});
it('surfaces the breaker reason instead of a bare percentage', () => {
render(
<WorkerRow
worker={{
...WORKER,
breakers: [{ summary: 'Paused after 3 failures (boom) — retrying in 45s' }],
}}
onRemove={noop}
onResume={noop}
onToggle={noop}
/>,
);
expect(screen.getByText('Paused')).toBeInTheDocument();
expect(screen.getByText(/retrying in 45s/)).toBeInTheDocument();
expect(screen.getByText(/Resume/)).toBeInTheDocument();
});
it('flags a worker that has not been approved', () => {
render(
<WorkerRow
worker={{ ...WORKER, consent_granted: false }}
onRemove={noop}
onResume={noop}
onToggle={noop}
/>,
);
expect(screen.getByText('Not approved')).toBeInTheDocument();
});
it('shows a disabled worker as disabled rather than offline', () => {
render(
<WorkerRow
worker={{ ...WORKER, enabled: false }}
onRemove={noop}
onResume={noop}
onToggle={noop}
/>,
);
expect(screen.getByText('Disabled')).toBeInTheDocument();
});
});
@@ -17,6 +17,15 @@
*/
const AUTO_PURGE_TERMINALS = new Set(['install_done', 'delete_done', 'install_cancelled']);
export function downloadKey(target, repoId) {
return `${target || 'local'}\u0000${repoId}`;
}
export function progressForRepo(progress, repoId, target = null) {
if (target) return progress?.[downloadKey(target, repoId)];
return Object.entries(progress || {}).find(([key]) => key.endsWith(`\u0000${repoId}`))?.[1];
}
export function isAutoPurgeTerminal(phase) {
return AUTO_PURGE_TERMINALS.has(phase);
}
@@ -32,45 +41,46 @@ export function isTerminalPhase(phase) {
*/
export function reduceModelDownloadEvent(prev, ev) {
if (!ev || !ev.repo_id) return prev;
const cur = prev[ev.repo_id] || { phase: 'active', files: {} };
const key = downloadKey(ev.target, ev.repo_id);
const cur = prev[key] || { phase: 'active', files: {} };
// Lifecycle events flip the row's phase without touching per-file accounting.
if (ev.phase === 'install_start' || ev.phase === 'delete_start') {
return { ...prev, [ev.repo_id]: { phase: ev.phase, files: {}, error: null } };
return { ...prev, [key]: { phase: ev.phase, files: {}, error: null } };
}
// Heartbeat from backend while resolving repo metadata.
if (ev.phase === 'resolving') {
return {
...prev,
[ev.repo_id]: { ...cur, phase: 'resolving', resolvingStep: ev.step || 0 },
[key]: { ...cur, phase: 'resolving', resolvingStep: ev.step || 0 },
};
}
if (ev.phase === 'install_retry') {
return {
...prev,
[ev.repo_id]: { ...cur, phase: 'install_retry', retryAttempt: ev.attempt, error: ev.error },
[key]: { ...cur, phase: 'install_retry', retryAttempt: ev.attempt, error: ev.error },
};
}
if (ev.phase === 'install_done') {
return { ...prev, [ev.repo_id]: { ...cur, phase: 'install_done' } };
return { ...prev, [key]: { ...cur, phase: 'install_done' } };
}
if (ev.phase === 'delete_done') {
return { ...prev, [ev.repo_id]: { ...cur, phase: 'delete_done' } };
return { ...prev, [key]: { ...cur, phase: 'delete_done' } };
}
// Errors carry the mirror-aware failure text (#890 core/failure.py). Keep it
// on the row — the purge effect must NOT auto-clear it (P1-A).
if (ev.phase === 'install_error') {
return { ...prev, [ev.repo_id]: { ...cur, phase: 'install_error', error: ev.error } };
return { ...prev, [key]: { ...cur, phase: 'install_error', error: ev.error } };
}
if (ev.phase === 'install_cancelled') {
return { ...prev, [ev.repo_id]: { ...cur, phase: 'install_cancelled' } };
return { ...prev, [key]: { ...cur, phase: 'install_cancelled' } };
}
// Pre-flight plan (FDL-05): accurate total/cached/remaining BEFORE bytes flow.
// Keep the current phase (usually resolving) — the plan is metadata.
if (ev.phase === 'install_plan') {
return {
...prev,
[ev.repo_id]: {
[key]: {
...cur,
plan: {
total_bytes: ev.total_bytes ?? null,
@@ -86,7 +96,7 @@ export function reduceModelDownloadEvent(prev, ev) {
if (ev.phase === 'aggregate') {
return {
...prev,
[ev.repo_id]: {
[key]: {
...cur,
phase: 'active',
agg: {
@@ -111,5 +121,5 @@ export function reduceModelDownloadEvent(prev, ev) {
rate: ev.rate || 0,
},
};
return { ...prev, [ev.repo_id]: { ...cur, phase: 'active', files } };
return { ...prev, [key]: { ...cur, phase: 'active', files } };
}
@@ -286,6 +286,26 @@ export const GROUPS = [
],
keywordKeys: ['settings.audio_tools', 'settings.ffmpeg', 'settings.audio_tools_ytdlp'],
},
{
id: 'workers',
labelKey: 'settings.workers_title',
defaultLabel: 'Remote workers',
icon: Cpu,
// Its own System entry rather than a section inside Sharing: this
// sends work OUT to machines you own, where everything under Sharing
// is about letting something else reach this one.
keywords: [
'remote workers',
'workers',
'gpu',
'second gpu',
'another machine',
'distributed',
'enrollment token',
'offload',
],
keywordKeys: ['settings.workers_title', 'settings.workers_add'],
},
{
id: 'sharing',
labelKey: 'settings.sharing',
+28 -1
View File
@@ -5,6 +5,7 @@ import { pickDesignSeed } from '../utils/seed';
import { playBlobAudio, playPing } from '../utils/media';
import {
StreamingPreviewError,
resolveRemoteTtsTarget,
streamGenerateSpeech,
supportsStreamingPreview,
} from '../utils/streamingTts';
@@ -13,6 +14,7 @@ import { CLONE_MAX_SECONDS, PRESETS } from '../utils/constants';
import { buildDesignInstruct, designModeProfileId } from '../utils/voiceInstruct';
import { toast } from 'react-hot-toast';
import { toastErrorWithReport } from '../utils/errorToast';
import { modelNotDownloadedPayload, toastModelNotDownloaded } from '../utils/modelNotDownloaded';
import { addBreadcrumb } from '../utils/breadcrumbs';
import i18next from 'i18next';
const t = i18next.t.bind(i18next);
@@ -22,6 +24,11 @@ const t = i18next.t.bind(i18next);
// session (module scope, no localStorage); resets on full reload.
let _lastRoutingStatus = null;
// Same de-dup, for "progressive playback is off because your GPU is the one
// across the room". Keyed by worker so switching machines re-announces, while
// ten renders in a row on the same worker say it once.
let _lastStreamingOffWorker = null;
/**
* Encapsulates TTS generation logic, streaming response handling,
* audio ingestion (with trim gate), and preset/tag helpers.
@@ -268,8 +275,26 @@ export default function useTTS({ selectedProfile, setSelectedProfile, loadHistor
// Any MID-stream failure falls back to the classic whole-file flow with
// no user-visible difference beyond the old wait; pre-stream HTTP errors
// (ApiError) throw straight to the shared catch, exactly like before.
//
// Remote GPU is the one case where it must NOT run: the stream is
// rendered by this process, so taking it would silently ignore the
// worker the user picked — a local render dressed as a remote one. The
// classic path below is the one that goes remote, so it wins, and the
// user is told why their progressive playback stopped rather than left
// to conclude the app got slower.
let streamed = false;
if (useAppStore.getState().autoPlayPreview && supportsStreamingPreview()) {
const wantsStreaming = useAppStore.getState().autoPlayPreview && supportsStreamingPreview();
const remoteTarget = wantsStreaming
? await resolveRemoteTtsTarget({ signal: ac.signal })
: null;
if (remoteTarget) {
const who = remoteTarget.label || remoteTarget.workerId || '';
if (who !== _lastStreamingOffWorker) {
_lastStreamingOffWorker = who;
toast(t('tts.streamingOffRemote', { label: who }), { icon: '🖥️', duration: 6000 });
}
}
if (wantsStreaming && !remoteTarget) {
try {
await streamGenerateSpeech(formData, {
signal: ac.signal,
@@ -345,6 +370,8 @@ export default function useTTS({ selectedProfile, setSelectedProfile, loadHistor
// Real generation failures get the "Report this bug" action.
if (err?.name === 'AbortError') {
toast.error(t('tts_errors.timeout'));
} else if (modelNotDownloadedPayload(err)) {
toastModelNotDownloaded(modelNotDownloadedPayload(err));
} else {
toastErrorWithReport(t('tts_errors.error_prefix', { message: err.message }), err);
}
+58 -3
View File
@@ -180,7 +180,35 @@
"history_retention_cap_hint": "اللقطات المميزة بنجمة لا تُحتسب في التنظيف · 0 = بلا حد",
"history_retention_saved": "تم حفظ حد الاحتفاظ",
"history_retention_save_failed": "تعذر الحفظ",
"history_retention_invalid": "أدخل 0 أو أكثر"
"history_retention_invalid": "أدخل 0 أو أكثر",
"workers_title": "العاملون البعيدون",
"workers_desc": "أرسل المهام فرديًا إلى وحدات معالجة الرسومات على أجهزتك الأخرى. تعود النتائج إلى هنا. لا يُرسل أي شيء حتى تضيف عاملًا وتوافق عليه.",
"workers_enable": "استخدام العاملين البعيدين",
"workers_enable_hint": "أثناء إيقاف هذا الخيار، لا يُقبل أي اتصال ولا يغادر أي شيء هذا الجهاز.",
"workers_port_conflict": "العُمال البعيدون غير متاحين لأن نسخة أخرى من VoiceStudio تستقبلهم بالفعل على هذا المنفذ. أغلق النسخة الأخرى، أو اضبط OMNIVOICE_WORKER_PORT على منفذ مختلف ثم أعد تشغيل VoiceStudio.",
"workers_endpoint": "يتصل العاملون بـ",
"workers_endpoint_hint": "يجب أن يتمكن العامل من الوصول إلى هذا العنوان. على الشبكات المختلفة، تُعد شبكة VPN مثل Tailscale الطريقة الموثوقة.",
"workers_add": "إضافة عامل",
"workers_add_hint": "أنشئ رمزًا، ثم الصقه في OmniVoice على الجهاز الآخر.",
"workers_new_token": "إنشاء رمز",
"workers_token_once": "انسخه الآن — يُعرض مرة واحدة فقط، ويعمل مرة واحدة فقط، وتنتهي صلاحيته خلال 15 دقيقة.",
"workers_copy": "نسخ",
"workers_copied": "تم النسخ",
"workers_copy_failed": "تعذّر النسخ.",
"workers_none": "لا يوجد عاملون بعد. أنشئ رمزًا لإضافة أولهم.",
"workers_load": "المهام {{active}} / {{slots}}",
"workers_needs_consent": "غير مُوافق عليه",
"workers_status_online": "متصل",
"workers_status_offline": "غير متصل",
"workers_status_paused": "متوقف مؤقتًا",
"workers_status_disabled": "معطّل",
"workers_resume": "استئناف",
"workers_disable": "تعطيل",
"workers_enable_one": "تفعيل",
"workers_remove": "إزالة",
"workers_remove_title": "إزالة العامل؟",
"workers_remove_confirm": "إزالة {{name}}؟ سيتم إبطال مفتاحه، لذا لن يتمكن من إعادة الاتصال بدون رمز جديد.",
"workers_rename": "إعادة تسمية العامل"
},
"bootstrap": {
"title": "VoiceStudio",
@@ -1791,7 +1819,16 @@
"to_download": "للتحميل",
"cached": "مخبأة",
"fast_download_badge": "تحميل سريع",
"fast_download_title": "تنزيلات سريعة عبر Xet {{version}} — نقل مجزأ ومتوازي"
"fast_download_title": "تنزيلات سريعة عبر Xet {{version}} — نقل مجزأ ومتوازي",
"voice_previews": "معاينات الأصوات",
"voice_previews_desc": "نزِّل معاينات مُجهَّزة مسبقًا لتُشغَّل الأصوات فورًا، حتى قبل اكتمال تنزيل نموذج صوتي. لا يتم تنزيل أي شيء حتى تُفعِّل هذا الخيار.",
"voice_previews_off": "معطَّل — تُنشأ المعاينات على هذا الجهاز",
"voice_previews_ready": "تم تخزين المجموعة المميزة",
"voice_previews_partial": "تم تخزين {{cached}} من أصل {{total}} من المعاينات المميزة",
"voice_previews_checked": "تم التحقق {{when}}",
"voice_previews_never": "لم يتم التحقق بعد",
"voice_previews_check": "تحقّق الآن",
"voice_previews_rejected": "تم رفض تحديث معرض الأصوات: {{message}}"
},
"enterprise_faq": {
"q_internal_tools": "هل أحتاج إلى ترخيص للأدوات الداخلية؟",
@@ -2298,7 +2335,8 @@
"droppedChunks_one": "تنبيه: جزء واحد ({{count}}) من النص لم يُنتج أي صوت، لذا هذه النسخة ينقصها. جرّب إعادة التوليد — عادةً ما تحل بذرة مختلفة المشكلة.",
"droppedChunks_other": "تنبيه: {{count}} أجزاء من النص لم تُنتج أي صوت، لذا هذه النسخة تنقصها. جرّب إعادة التوليد — عادةً ما تحل بذرة مختلفة المشكلة.",
"droppedChunksWithText_one": "تنبيه: جزء واحد ({{count}}) من النص لم يُنتج أي صوت وينقص هذه النسخة — «{{text}}». جرّب إعادة التوليد؛ عادةً ما تحل بذرة مختلفة المشكلة.",
"droppedChunksWithText_other": "تنبيه: {{count}} أجزاء من النص لم تُنتج أي صوت وتنقص هذه النسخة — «{{text}}». جرّب إعادة التوليد؛ عادةً ما تحل بذرة مختلفة المشكلة."
"droppedChunksWithText_other": "تنبيه: {{count}} أجزاء من النص لم تُنتج أي صوت وتنقص هذه النسخة — «{{text}}». جرّب إعادة التوليد؛ عادةً ما تحل بذرة مختلفة المشكلة.",
"streamingOffRemote": "المعاينة التدريجية متوقفة أثناء معالجة {{label}} — سيتم تشغيل التسجيل النهائي فور وصوله."
},
"voiceSelector": {
"engineDefault": "المحرك الافتراضي",
@@ -2357,6 +2395,7 @@
"started": "جارٍ تنزيل {{label}} — تابع التقدم في الإعدادات ← النماذج، ثم حاول مرة أخرى.",
"install_failed": "تعذّر بدء التنزيل: {{message}}"
},
"model_missing": {"message":"لم يتم تنزيل هذا النموذج على {{target}}.","download":"تنزيل النموذج","download_size":"تنزيل ({{size}})","started":"بدأ التنزيل على {{target}} — أعد التوليد بعد اكتماله.","failed":"تعذر بدء التنزيل: {{message}}","manual_worker":"ثبّت هذا المحرك مباشرةً على {{target}}."},
"backend_start_failure": {
"notice": "تعذّر تشغيل الواجهة الخلفية لـ VoiceStudio — لن يعمل أي شيء حتى تبدأ.",
"view": "عرض السبب",
@@ -2369,5 +2408,21 @@
"notice_unclean": "انتهت الجلسة السابقة دون إغلاق سليم قبل {{ago}}. غالبًا ما يكون ذلك غير ضار — دخل الجهاز في وضع السكون، أو أُغلق التطبيق قسرًا، أو توقفت آلة افتراضية/حاوية.",
"details_intro_unclean": "لم تُمسح علامة إغلاق التشغيل السابق، قبل {{ago}}. لا يستطيع VoiceStudio معرفة ما إذا كان ذلك انهيارًا أم مجرد انقطاع — فالسكون والإغلاق القسري وإيقاف الآلة الافتراضية/الحاوية تترك الأثر نفسه. إذا كان كل شيء يعمل الآن فلا حاجة لفعل شيء.",
"report_needs_log": "لم يُلتقط أي مخرجات خطأ لهذا الحدث، لذا سيكون التقرير بنقرة واحدة فارغًا. إذا كان هناك خلل فعلي، فأرفق آخر أسطر من الإعدادات → السجلات → الواجهة الخلفية في بلاغ جديد."
},
"gpu": {
"local": "محلي",
"picker": "أين تُنفَّذ المهام",
"tasks": "مهام",
"coverage": "{{ops}} فقط",
"opLocal": "محلي — {{op}} لا يعمل عن بُعد بعد",
"dictationLocal": "يعمل الإملاء دائمًا على هذا الجهاز",
"ops": {
"tts": "TTS",
"clone": "استنساخ الصوت",
"dub": "الدبلجة",
"audiobook": "معالجة الكتب الصوتية",
"longform": "سرد القصص",
"asr": "التفريغ النصي"
}
}
}
+58 -3
View File
@@ -180,7 +180,35 @@
"history_retention_cap_hint": "Markierte Takes zählen nie zum Aufräumen · 0 = unbegrenzt",
"history_retention_saved": "Aufbewahrungslimit gespeichert",
"history_retention_save_failed": "Speichern fehlgeschlagen",
"history_retention_invalid": "0 oder mehr eingeben"
"history_retention_invalid": "0 oder mehr eingeben",
"workers_title": "Remote-Worker",
"workers_desc": "Einzelne Aufträge an GPUs auf Ihren anderen Rechnern senden. Die Ergebnisse kommen hierher zurück. Es wird nichts gesendet, bevor Sie einen Worker hinzufügen und freigeben.",
"workers_enable": "Remote-Worker verwenden",
"workers_enable_hint": "Solange dies aus ist, wird keine Verbindung angenommen und nichts verlässt diesen Rechner.",
"workers_port_conflict": "Remote-Worker sind nicht verfügbar, weil eine andere VoiceStudio-Instanz sie bereits an diesem Port annimmt. Schließen Sie die andere Instanz oder setzen Sie OMNIVOICE_WORKER_PORT auf einen anderen Port und starten Sie VoiceStudio neu.",
"workers_endpoint": "Worker verbinden sich mit",
"workers_endpoint_hint": "Ein Worker muss diese Adresse erreichen können. In anderen Netzwerken ist ein VPN wie Tailscale der zuverlässige Weg.",
"workers_add": "Worker hinzufügen",
"workers_add_hint": "Token erzeugen und dann in OmniVoice auf dem anderen Rechner einfügen.",
"workers_new_token": "Token erzeugen",
"workers_token_once": "Jetzt kopieren — es wird nur einmal angezeigt, funktioniert nur einmal und läuft in 15 Minuten ab.",
"workers_copy": "Kopieren",
"workers_copied": "Kopiert",
"workers_copy_failed": "Kopieren nicht möglich.",
"workers_none": "Noch keine Worker. Erzeugen Sie ein Token, um den ersten hinzuzufügen.",
"workers_load": "Aufgaben {{active}} / {{slots}}",
"workers_needs_consent": "Nicht freigegeben",
"workers_status_online": "Online",
"workers_status_offline": "Offline",
"workers_status_paused": "Pausiert",
"workers_status_disabled": "Deaktiviert",
"workers_resume": "Fortsetzen",
"workers_disable": "Deaktivieren",
"workers_enable_one": "Aktivieren",
"workers_remove": "Entfernen",
"workers_remove_title": "Worker entfernen?",
"workers_remove_confirm": "{{name}} entfernen? Der Schlüssel wird widerrufen, eine Neuverbindung ist ohne neues Token nicht möglich.",
"workers_rename": "Worker umbenennen"
},
"bootstrap": {
"title": "VoiceStudio",
@@ -1791,7 +1819,16 @@
"to_download": "zum Herunterladen",
"cached": "zwischengespeichert",
"fast_download_badge": "schneller Download",
"fast_download_title": "Schnelle Downloads über Xet {{version}} parallele Chunk-Übertragung"
"fast_download_title": "Schnelle Downloads über Xet {{version}} parallele Chunk-Übertragung",
"voice_previews": "Stimmvorschauen",
"voice_previews_desc": "Lade vorab gerenderte Vorschauen herunter, damit Stimmen sofort abspielen schon bevor ein Sprachmodell fertig geladen ist. Es wird nichts heruntergeladen, bis du dies aktivierst.",
"voice_previews_off": "Aus Vorschauen werden auf diesem Gerät gerendert",
"voice_previews_ready": "Empfohlener Satz zwischengespeichert",
"voice_previews_partial": "{{cached}} von {{total}} empfohlenen Vorschauen zwischengespeichert",
"voice_previews_checked": "geprüft {{when}}",
"voice_previews_never": "noch nicht geprüft",
"voice_previews_check": "Jetzt prüfen",
"voice_previews_rejected": "Aktualisierung der Stimmgalerie abgelehnt: {{message}}"
},
"enterprise_faq": {
"q_internal_tools": "Benötige ich eine Lizenz für interne Tools?",
@@ -2298,7 +2335,8 @@
"droppedChunks_one": "Hinweis: {{count}} Teil deines Textes hat kein Audio erzeugt, in dieser Aufnahme fehlt er also. Generiere es neu — ein anderer Seed behebt das meistens.",
"droppedChunks_other": "Hinweis: {{count}} Teile deines Textes haben kein Audio erzeugt, in dieser Aufnahme fehlen sie also. Generiere es neu — ein anderer Seed behebt das meistens.",
"droppedChunksWithText_one": "Hinweis: {{count}} Teil deines Textes hat kein Audio erzeugt und fehlt in dieser Aufnahme — „{{text}}“. Generiere es neu; ein anderer Seed behebt das meistens.",
"droppedChunksWithText_other": "Hinweis: {{count}} Teile deines Textes haben kein Audio erzeugt und fehlen in dieser Aufnahme — „{{text}}“. Generiere es neu; ein anderer Seed behebt das meistens."
"droppedChunksWithText_other": "Hinweis: {{count}} Teile deines Textes haben kein Audio erzeugt und fehlen in dieser Aufnahme — „{{text}}“. Generiere es neu; ein anderer Seed behebt das meistens.",
"streamingOffRemote": "Die progressive Vorschau ist deaktiviert, während {{label}} rendert — die fertige Aufnahme wird abgespielt, sobald sie eintrifft."
},
"voiceSelector": {
"engineDefault": "Motorstandard",
@@ -2357,6 +2395,7 @@
"started": "{{label}} wird heruntergeladen — Fortschritt unter Einstellungen → Modelle, danach erneut versuchen.",
"install_failed": "Download konnte nicht gestartet werden: {{message}}"
},
"model_missing": {"message":"Dieses Modell wurde auf {{target}} nicht heruntergeladen.","download":"Modell herunterladen","download_size":"Herunterladen ({{size}})","started":"Download auf {{target}} gestartet — versuchen Sie danach erneut Generieren.","failed":"Download konnte nicht gestartet werden: {{message}}","manual_worker":"Installieren Sie diese Engine direkt auf {{target}}."},
"backend_start_failure": {
"notice": "Das VoiceStudio-Backend konnte nicht starten — bis dahin funktioniert nichts.",
"view": "Ursache anzeigen",
@@ -2369,5 +2408,21 @@
"notice_unclean": "Die vorherige Sitzung wurde vor {{ago}} nicht sauber beendet. Das ist oft harmlos — Ruhezustand, erzwungenes Beenden oder eine gestoppte VM/Container.",
"details_intro_unclean": "Die Abschlussmarkierung des vorherigen Laufs wurde vor {{ago}} nicht gelöscht. VoiceStudio kann nicht erkennen, ob es ein Absturz oder nur eine Unterbrechung war — Ruhezustand, erzwungenes Beenden oder das Stoppen einer VM/eines Containers hinterlassen dieselbe Spur. Wenn jetzt alles funktioniert, ist nichts zu tun.",
"report_needs_log": "Für dieses Ereignis wurde keine Fehlerausgabe erfasst, ein Ein-Klick-Report wäre leer. Falls wirklich etwas nicht stimmt, hängen Sie bitte die letzten Zeilen aus Einstellungen → Protokolle → Backend an ein neues Issue an."
},
"gpu": {
"local": "Lokal",
"picker": "Wo Aufträge laufen",
"tasks": "Aufgaben",
"coverage": "nur {{ops}}",
"opLocal": "Lokal — {{op}} läuft noch nicht auf Remote-Workern",
"dictationLocal": "Diktat läuft immer auf diesem Gerät",
"ops": {
"tts": "TTS",
"clone": "Stimmklonen",
"dub": "Synchronisation",
"audiobook": "Hörbuch-Rendering",
"longform": "Story-Vertonung",
"asr": "Transkription"
}
}
}
+58 -3
View File
@@ -748,7 +748,35 @@
"storage_clear_temp_failed": "Could not clear temporary files",
"storage_temp_cleared": "Temporary files cleared — {{freed}} freed",
"data_dir_at": "App data stored at",
"history_retention_load_failed": "Could not load the current retention limit"
"history_retention_load_failed": "Could not load the current retention limit",
"workers_title": "Remote workers",
"workers_desc": "Send individual jobs to GPUs on your other machines. Results come back here. Nothing is sent until you add a worker and approve it.",
"workers_enable": "Use remote workers",
"workers_enable_hint": "While this is off, no connection is accepted and nothing leaves this machine.",
"workers_port_conflict": "Remote workers are unavailable because another VoiceStudio instance is already accepting them on this port. Close the other instance, or set OMNIVOICE_WORKER_PORT to a different port and restart VoiceStudio.",
"workers_endpoint": "Workers connect to",
"workers_endpoint_hint": "A worker has to be able to reach this address. On different networks, a VPN such as Tailscale is the reliable way.",
"workers_add": "Add a worker",
"workers_add_hint": "Generate a token, then paste it into OmniVoice on the other machine.",
"workers_new_token": "Generate token",
"workers_token_once": "Copy this now — it is shown only once, works only once, and expires in 15 minutes.",
"workers_copy": "Copy",
"workers_copied": "Copied",
"workers_copy_failed": "Could not copy.",
"workers_none": "No workers yet. Generate a token to add your first one.",
"workers_load": "Tasks {{active}} / {{slots}}",
"workers_needs_consent": "Not approved",
"workers_status_online": "Online",
"workers_status_offline": "Offline",
"workers_status_paused": "Paused",
"workers_status_disabled": "Disabled",
"workers_resume": "Resume",
"workers_disable": "Disable",
"workers_enable_one": "Enable",
"workers_remove": "Remove",
"workers_remove_title": "Remove worker?",
"workers_remove_confirm": "Remove {{name}}? Its key is revoked, so it cannot reconnect without a new token.",
"workers_rename": "Rename worker"
},
"about": {
"app": "App",
@@ -2470,7 +2498,16 @@
"asrOpenAICompatTestUnreachable": "Could not connect — check the URL and that the server is running",
"asrOpenAICompatTestNotConfigured": "Enter a server URL first",
"asrOpenAICompatTestInvalidUrl": "The server URL must start with http:// or https://",
"asrOpenAICompatTestFailed": "Connection test failed"
"asrOpenAICompatTestFailed": "Connection test failed",
"voice_previews": "Voice previews",
"voice_previews_desc": "Download previews that were rendered ahead of time, so voices play instantly — even before a voice model finishes downloading. Nothing is downloaded until you turn this on.",
"voice_previews_off": "Off — previews render on this machine",
"voice_previews_ready": "Featured set cached",
"voice_previews_partial": "{{cached}} of {{total}} featured previews cached",
"voice_previews_checked": "checked {{when}}",
"voice_previews_never": "not checked yet",
"voice_previews_check": "Check now",
"voice_previews_rejected": "Voice gallery update rejected: {{message}}"
},
"enterprise_faq": {
"q_internal_tools": "Do I need a license for internal tools?",
@@ -2556,7 +2593,8 @@
"droppedChunks_one": "Heads up: {{count}} part of your text produced no audio, so this take is missing it. Try re-generating — a different seed usually fixes it.",
"droppedChunks_other": "Heads up: {{count}} parts of your text produced no audio, so this take is missing them. Try re-generating — a different seed usually fixes it.",
"droppedChunksWithText_one": "Heads up: {{count}} part of your text produced no audio, so this take is missing it — “{{text}}”. Try re-generating; a different seed usually fixes it.",
"droppedChunksWithText_other": "Heads up: {{count}} parts of your text produced no audio, so this take is missing them — “{{text}}”. Try re-generating; a different seed usually fixes it."
"droppedChunksWithText_other": "Heads up: {{count}} parts of your text produced no audio, so this take is missing them — “{{text}}”. Try re-generating; a different seed usually fixes it.",
"streamingOffRemote": "Progressive preview is off while {{label}} renders this — the finished take plays as soon as it lands."
},
"voiceSelector": {
"engineDefault": "Engine default",
@@ -2879,5 +2917,22 @@
"download": "Download {{label}} ({{size}} GB)",
"started": "Downloading {{label}} — watch progress in Settings → Models, then try again.",
"install_failed": "Couldn't start the download: {{message}}"
},
"model_missing": {"message":"This model is not downloaded on {{target}}.","download":"Download model","download_size":"Download ({{size}})","started":"Downloading on {{target}} — try Generate again when it finishes.","failed":"Couldn't start the download: {{message}}","manual_worker":"Install this engine directly on {{target}}."},
"gpu": {
"local": "Local",
"picker": "Where jobs run",
"tasks": "tasks",
"coverage": "{{ops}} only",
"opLocal": "Local — {{op}} does not run remotely yet",
"dictationLocal": "Dictation always runs on this machine",
"ops": {
"tts": "TTS",
"clone": "voice cloning",
"dub": "dubbing",
"audiobook": "audiobook rendering",
"longform": "story narration",
"asr": "transcription"
}
}
}
+58 -3
View File
@@ -180,7 +180,35 @@
"history_retention_cap_hint": "Las tomas destacadas nunca cuentan para la limpieza · 0 = sin límite",
"history_retention_saved": "Límite de retención guardado",
"history_retention_save_failed": "No se pudo guardar",
"history_retention_invalid": "Introduce 0 o más"
"history_retention_invalid": "Introduce 0 o más",
"workers_title": "Trabajadores remotos",
"workers_desc": "Envía trabajos individuales a las GPU de tus otros equipos. Los resultados vuelven aquí. No se envía nada hasta que añadas un trabajador y lo apruebes.",
"workers_enable": "Usar trabajadores remotos",
"workers_enable_hint": "Mientras esto esté desactivado, no se acepta ninguna conexión y nada sale de este equipo.",
"workers_port_conflict": "Los trabajadores remotos no están disponibles porque otra instancia de VoiceStudio ya los acepta en este puerto. Cierra la otra instancia o configura OMNIVOICE_WORKER_PORT con otro puerto y reinicia VoiceStudio.",
"workers_endpoint": "Los trabajadores se conectan a",
"workers_endpoint_hint": "Un trabajador debe poder alcanzar esta dirección. En redes distintas, una VPN como Tailscale es la forma fiable.",
"workers_add": "Añadir un trabajador",
"workers_add_hint": "Genera un token y pégalo en OmniVoice en el otro equipo.",
"workers_new_token": "Generar token",
"workers_token_once": "Cópialo ahora: se muestra una sola vez, funciona una sola vez y caduca en 15 minutos.",
"workers_copy": "Copiar",
"workers_copied": "Copiado",
"workers_copy_failed": "No se pudo copiar.",
"workers_none": "Aún no hay trabajadores. Genera un token para añadir el primero.",
"workers_load": "Tareas {{active}} / {{slots}}",
"workers_needs_consent": "No aprobado",
"workers_status_online": "En línea",
"workers_status_offline": "Sin conexión",
"workers_status_paused": "En pausa",
"workers_status_disabled": "Desactivado",
"workers_resume": "Reanudar",
"workers_disable": "Desactivar",
"workers_enable_one": "Activar",
"workers_remove": "Eliminar",
"workers_remove_title": "¿Eliminar el trabajador?",
"workers_remove_confirm": "¿Eliminar {{name}}? Su clave se revoca, así que no podrá reconectarse sin un token nuevo.",
"workers_rename": "Renombrar trabajador"
},
"bootstrap": {
"title": "VoiceStudio",
@@ -1791,7 +1819,16 @@
"to_download": "para descargar",
"cached": "almacenado en caché",
"fast_download_badge": "descarga rápida",
"fast_download_title": "Descargas rápidas a través de Xet {{version}}: transferencia fragmentada paralela"
"fast_download_title": "Descargas rápidas a través de Xet {{version}}: transferencia fragmentada paralela",
"voice_previews": "Vistas previas de voz",
"voice_previews_desc": "Descarga vistas previas renderizadas de antemano para que las voces suenen al instante, incluso antes de que termine de descargarse un modelo de voz. No se descarga nada hasta que actives esta opción.",
"voice_previews_off": "Desactivado: las vistas previas se generan en este equipo",
"voice_previews_ready": "Conjunto destacado en caché",
"voice_previews_partial": "{{cached}} de {{total}} vistas previas destacadas en caché",
"voice_previews_checked": "comprobado {{when}}",
"voice_previews_never": "sin comprobar aún",
"voice_previews_check": "Comprobar ahora",
"voice_previews_rejected": "Actualización de la galería de voces rechazada: {{message}}"
},
"enterprise_faq": {
"q_internal_tools": "¿Necesito una licencia para herramientas internas?",
@@ -2298,7 +2335,8 @@
"droppedChunks_one": "Aviso: {{count}} parte de tu texto no generó audio, así que falta en esta toma. Prueba a generarlo de nuevo: normalmente basta con otra semilla.",
"droppedChunks_other": "Aviso: {{count}} partes de tu texto no generaron audio, así que faltan en esta toma. Prueba a generarlo de nuevo: normalmente basta con otra semilla.",
"droppedChunksWithText_one": "Aviso: {{count}} parte de tu texto no generó audio y falta en esta toma: «{{text}}». Prueba a generarlo de nuevo; normalmente basta con otra semilla.",
"droppedChunksWithText_other": "Aviso: {{count}} partes de tu texto no generaron audio y faltan en esta toma: «{{text}}». Prueba a generarlo de nuevo; normalmente basta con otra semilla."
"droppedChunksWithText_other": "Aviso: {{count}} partes de tu texto no generaron audio y faltan en esta toma: «{{text}}». Prueba a generarlo de nuevo; normalmente basta con otra semilla.",
"streamingOffRemote": "La vista previa progresiva está desactivada mientras {{label}} genera el audio: la toma final se reproducirá en cuanto llegue."
},
"voiceSelector": {
"engineDefault": "Valor predeterminado del motor",
@@ -2357,6 +2395,7 @@
"started": "Descargando {{label}} — sigue el progreso en Ajustes → Modelos y vuelve a intentarlo.",
"install_failed": "No se pudo iniciar la descarga: {{message}}"
},
"model_missing": {"message":"Este modelo no está descargado en {{target}}.","download":"Descargar modelo","download_size":"Descargar ({{size}})","started":"Descargando en {{target}}; vuelve a pulsar Generar cuando termine.","failed":"No se pudo iniciar la descarga: {{message}}","manual_worker":"Instala este motor directamente en {{target}}."},
"backend_start_failure": {
"notice": "El backend de VoiceStudio no pudo iniciarse: nada funcionará hasta que lo haga.",
"view": "Ver el motivo",
@@ -2369,5 +2408,21 @@
"notice_unclean": "La sesión anterior terminó sin un cierre limpio hace {{ago}}. Suele ser inofensivo: el equipo se suspendió, la app se cerró a la fuerza o se detuvo una VM/contenedor.",
"details_intro_unclean": "La marca de cierre de la ejecución anterior no se borró, hace {{ago}}. VoiceStudio no puede saber si fue un fallo o solo una interrupción: suspender, forzar el cierre o detener una VM/contenedor deja el mismo rastro. Si ahora todo funciona, no hay nada que hacer.",
"report_needs_log": "No se capturó ninguna salida de error para este evento, así que un informe de un clic estaría vacío. Si algo falla de verdad, adjunta las últimas líneas de Ajustes → Registros → Backend a un nuevo issue."
},
"gpu": {
"local": "Local",
"picker": "Dónde se ejecutan los trabajos",
"tasks": "tareas",
"coverage": "solo {{ops}}",
"opLocal": "Local: {{op}} aún no se ejecuta en remoto",
"dictationLocal": "El dictado siempre se ejecuta en este equipo",
"ops": {
"tts": "TTS",
"clone": "clonación de voz",
"dub": "doblaje",
"audiobook": "renderizado de audiolibros",
"longform": "narración de historias",
"asr": "transcripción"
}
}
}
+58 -3
View File
@@ -180,7 +180,35 @@
"history_retention_cap_hint": "Les prises étoilées ne comptent jamais pour le nettoyage · 0 = illimité",
"history_retention_saved": "Limite de rétention enregistrée",
"history_retention_save_failed": "Enregistrement impossible",
"history_retention_invalid": "Saisissez 0 ou plus"
"history_retention_invalid": "Saisissez 0 ou plus",
"workers_title": "Workers distants",
"workers_desc": "Envoyez des tâches individuelles aux GPU de vos autres machines. Les résultats reviennent ici. Rien n'est envoyé tant que vous n'avez pas ajouté et approuvé un worker.",
"workers_enable": "Utiliser des workers distants",
"workers_enable_hint": "Tant que ceci est désactivé, aucune connexion n'est acceptée et rien ne quitte cette machine.",
"workers_port_conflict": "Les workers distants sont indisponibles, car une autre instance de VoiceStudio les accepte déjà sur ce port. Fermez lautre instance, ou définissez OMNIVOICE_WORKER_PORT sur un autre port puis redémarrez VoiceStudio.",
"workers_endpoint": "Les workers se connectent à",
"workers_endpoint_hint": "Un worker doit pouvoir joindre cette adresse. Sur des réseaux différents, un VPN comme Tailscale est la méthode fiable.",
"workers_add": "Ajouter un worker",
"workers_add_hint": "Générez un jeton, puis collez-le dans OmniVoice sur l'autre machine.",
"workers_new_token": "Générer un jeton",
"workers_token_once": "Copiez-le maintenant : il n'est affiché qu'une fois, ne fonctionne qu'une fois et expire dans 15 minutes.",
"workers_copy": "Copier",
"workers_copied": "Copié",
"workers_copy_failed": "Copie impossible.",
"workers_none": "Aucun worker pour l'instant. Générez un jeton pour ajouter le premier.",
"workers_load": "Tâches {{active}} / {{slots}}",
"workers_needs_consent": "Non approuvé",
"workers_status_online": "En ligne",
"workers_status_offline": "Hors ligne",
"workers_status_paused": "En pause",
"workers_status_disabled": "Désactivé",
"workers_resume": "Reprendre",
"workers_disable": "Désactiver",
"workers_enable_one": "Activer",
"workers_remove": "Supprimer",
"workers_remove_title": "Supprimer le worker ?",
"workers_remove_confirm": "Supprimer {{name}} ? Sa clé est révoquée, il ne pourra pas se reconnecter sans un nouveau jeton.",
"workers_rename": "Renommer le worker"
},
"bootstrap": {
"title": "VoiceStudio",
@@ -1791,7 +1819,16 @@
"to_download": "télécharger",
"cached": "mis en cache",
"fast_download_badge": "téléchargement rapide",
"fast_download_title": "Téléchargements rapides via Xet {{version}} — transfert fragmenté parallèle"
"fast_download_title": "Téléchargements rapides via Xet {{version}} — transfert fragmenté parallèle",
"voice_previews": "Aperçus de voix",
"voice_previews_desc": "Téléchargez des aperçus pré-rendus pour que les voix se lisent instantanément, même avant la fin du téléchargement d'un modèle vocal. Rien n'est téléchargé tant que vous n'activez pas cette option.",
"voice_previews_off": "Désactivé — les aperçus sont générés sur cet appareil",
"voice_previews_ready": "Sélection en cache",
"voice_previews_partial": "{{cached}} aperçus en vedette sur {{total}} en cache",
"voice_previews_checked": "vérifié {{when}}",
"voice_previews_never": "pas encore vérifié",
"voice_previews_check": "Vérifier maintenant",
"voice_previews_rejected": "Mise à jour de la galerie de voix refusée : {{message}}"
},
"enterprise_faq": {
"q_internal_tools": "Ai-je besoin dune licence pour les outils internes ?",
@@ -2298,7 +2335,8 @@
"droppedChunks_one": "Attention : {{count}} partie de votre texte n'a produit aucun audio, elle manque donc dans cette prise. Relancez la génération — une autre graine suffit généralement.",
"droppedChunks_other": "Attention : {{count}} parties de votre texte n'ont produit aucun audio, elles manquent donc dans cette prise. Relancez la génération — une autre graine suffit généralement.",
"droppedChunksWithText_one": "Attention : {{count}} partie de votre texte n'a produit aucun audio et manque dans cette prise — « {{text}} ». Relancez la génération ; une autre graine suffit généralement.",
"droppedChunksWithText_other": "Attention : {{count}} parties de votre texte n'ont produit aucun audio et manquent dans cette prise — « {{text}} ». Relancez la génération ; une autre graine suffit généralement."
"droppedChunksWithText_other": "Attention : {{count}} parties de votre texte n'ont produit aucun audio et manquent dans cette prise — « {{text}} ». Relancez la génération ; une autre graine suffit généralement.",
"streamingOffRemote": "L'aperçu progressif est désactivé pendant que {{label}} effectue le rendu — la prise finale sera lue dès son arrivée."
},
"voiceSelector": {
"engineDefault": "Moteur par défaut",
@@ -2357,6 +2395,7 @@
"started": "Téléchargement de {{label}} — suivez la progression dans Réglages → Modèles, puis réessayez.",
"install_failed": "Impossible de démarrer le téléchargement : {{message}}"
},
"model_missing": {"message":"Ce modèle nest pas téléchargé sur {{target}}.","download":"Télécharger le modèle","download_size":"Télécharger ({{size}})","started":"Téléchargement sur {{target}} — relancez Générer une fois terminé.","failed":"Impossible de lancer le téléchargement : {{message}}","manual_worker":"Installez ce moteur directement sur {{target}}."},
"backend_start_failure": {
"notice": "Le backend d'VoiceStudio n'a pas pu démarrer — rien ne fonctionnera tant qu'il ne démarre pas.",
"view": "Voir pourquoi",
@@ -2369,5 +2408,21 @@
"notice_unclean": "La session précédente s'est terminée sans arrêt propre il y a {{ago}}. C'est souvent bénin : mise en veille, fermeture forcée ou arrêt d'une VM/d'un conteneur.",
"details_intro_unclean": "Le marqueur d'arrêt de l'exécution précédente n'a jamais été effacé, il y a {{ago}}. VoiceStudio ne peut pas savoir s'il s'agit d'un plantage ou d'une simple interruption — mise en veille, fermeture forcée ou arrêt d'une VM/d'un conteneur laissent la même trace. Si tout fonctionne maintenant, il n'y a rien à faire.",
"report_needs_log": "Aucune sortie d'erreur n'a été capturée pour cet événement ; un rapport en un clic serait vide. Si quelque chose ne va vraiment pas, joignez les dernières lignes de Paramètres → Journaux → Backend à un nouveau ticket."
},
"gpu": {
"local": "Local",
"picker": "Où les tâches s'exécutent",
"tasks": "tâches",
"coverage": "{{ops}} uniquement",
"opLocal": "Local — {{op}} ne s'exécute pas encore à distance",
"dictationLocal": "La dictée s'exécute toujours sur cette machine",
"ops": {
"tts": "TTS",
"clone": "clonage vocal",
"dub": "doublage",
"audiobook": "rendu de livre audio",
"longform": "narration d'histoires",
"asr": "transcription"
}
}
}
+58 -3
View File
@@ -180,7 +180,35 @@
"history_retention_cap_hint": "स्टार किए गए टेक सफ़ाई में कभी नहीं गिने जाते · 0 = असीमित",
"history_retention_saved": "प्रतिधारण सीमा सहेजी गई",
"history_retention_save_failed": "सहेजा नहीं जा सका",
"history_retention_invalid": "0 या अधिक दर्ज करें"
"history_retention_invalid": "0 या अधिक दर्ज करें",
"workers_title": "रिमोट वर्कर",
"workers_desc": "अपनी दूसरी मशीनों के GPU पर अलग-अलग काम भेजें। परिणाम यहीं वापस आते हैं। जब तक आप कोई वर्कर जोड़कर उसे स्वीकृत नहीं करते, कुछ भी नहीं भेजा जाता।",
"workers_enable": "रिमोट वर्कर उपयोग करें",
"workers_enable_hint": "जब तक यह बंद है, कोई कनेक्शन स्वीकार नहीं किया जाता और इस मशीन से कुछ भी बाहर नहीं जाता।",
"workers_port_conflict": "रिमोट वर्कर उपलब्ध नहीं हैं क्योंकि VoiceStudio का दूसरा इंस्टेंस इस पोर्ट पर उन्हें पहले से स्वीकार कर रहा है। दूसरा इंस्टेंस बंद करें, या OMNIVOICE_WORKER_PORT को किसी दूसरे पोर्ट पर सेट करके VoiceStudio पुनः शुरू करें।",
"workers_endpoint": "वर्कर इससे जुड़ते हैं",
"workers_endpoint_hint": "वर्कर को यह पता एक्सेस करना आना चाहिए। अलग नेटवर्क पर Tailscale जैसा VPN भरोसेमंद तरीका है।",
"workers_add": "वर्कर जोड़ें",
"workers_add_hint": "टोकन बनाएँ, फिर उसे दूसरी मशीन पर OmniVoice में पेस्ट करें।",
"workers_new_token": "टोकन बनाएँ",
"workers_token_once": "अभी कॉपी करें — यह केवल एक बार दिखता है, केवल एक बार चलता है, और 15 मिनट में समाप्त हो जाता है।",
"workers_copy": "कॉपी करें",
"workers_copied": "कॉपी हो गया",
"workers_copy_failed": "कॉपी नहीं हो सका।",
"workers_none": "अभी कोई वर्कर नहीं। पहला जोड़ने के लिए टोकन बनाएँ।",
"workers_load": "कार्य {{active}} / {{slots}}",
"workers_needs_consent": "स्वीकृत नहीं",
"workers_status_online": "ऑनलाइन",
"workers_status_offline": "ऑफ़लाइन",
"workers_status_paused": "रोका गया",
"workers_status_disabled": "अक्षम",
"workers_resume": "फिर शुरू करें",
"workers_disable": "अक्षम करें",
"workers_enable_one": "सक्षम करें",
"workers_remove": "हटाएँ",
"workers_remove_title": "वर्कर हटाएँ?",
"workers_remove_confirm": "{{name}} को हटाएँ? इसकी कुंजी रद्द हो जाती है, इसलिए नए टोकन के बिना यह दोबारा नहीं जुड़ पाएगा।",
"workers_rename": "वर्कर का नाम बदलें"
},
"bootstrap": {
"title": "VoiceStudio",
@@ -1791,7 +1819,16 @@
"to_download": "डाउनलोड करने के लिए",
"cached": "कैश्ड",
"fast_download_badge": "तेजी से डाउनलोड",
"fast_download_title": "Xet {{version}} के माध्यम से तेज़ डाउनलोड - समानांतर खंडित स्थानांतरण"
"fast_download_title": "Xet {{version}} के माध्यम से तेज़ डाउनलोड - समानांतर खंडित स्थानांतरण",
"voice_previews": "आवाज़ के नमूने",
"voice_previews_desc": "पहले से तैयार नमूने डाउनलोड करें ताकि आवाज़ें तुरंत चलें — तब भी जब कोई वॉइस मॉडल डाउनलोड हो रहा हो। जब तक आप इसे चालू नहीं करते, कुछ भी डाउनलोड नहीं होता।",
"voice_previews_off": "बंद — नमूने इसी डिवाइस पर तैयार होते हैं",
"voice_previews_ready": "चुनिंदा सेट कैश में है",
"voice_previews_partial": "{{total}} में से {{cached}} चुनिंदा नमूने कैश में हैं",
"voice_previews_checked": "{{when}} जाँचा गया",
"voice_previews_never": "अभी तक जाँचा नहीं गया",
"voice_previews_check": "अभी जाँचें",
"voice_previews_rejected": "वॉइस गैलरी अपडेट अस्वीकृत: {{message}}"
},
"enterprise_faq": {
"q_internal_tools": "क्या मुझे आंतरिक उपकरणों के लिए लाइसेंस की आवश्यकता है?",
@@ -2298,7 +2335,8 @@
"droppedChunks_one": "ध्यान दें: आपके टेक्स्ट का {{count}} हिस्सा कोई ऑडियो नहीं बना पाया, इसलिए यह टेक उससे वंचित है। दोबारा जेनरेट करें — आमतौर पर दूसरा सीड इसे ठीक कर देता है।",
"droppedChunks_other": "ध्यान दें: आपके टेक्स्ट के {{count}} हिस्से कोई ऑडियो नहीं बना पाए, इसलिए यह टेक उनसे वंचित है। दोबारा जेनरेट करें — आमतौर पर दूसरा सीड इसे ठीक कर देता है।",
"droppedChunksWithText_one": "ध्यान दें: आपके टेक्स्ट का {{count}} हिस्सा कोई ऑडियो नहीं बना पाया और इस टेक में नहीं है — “{{text}}”। दोबारा जेनरेट करें; आमतौर पर दूसरा सीड इसे ठीक कर देता है।",
"droppedChunksWithText_other": "ध्यान दें: आपके टेक्स्ट के {{count}} हिस्से कोई ऑडियो नहीं बना पाए और इस टेक में नहीं हैं — “{{text}}”। दोबारा जेनरेट करें; आमतौर पर दूसरा सीड इसे ठीक कर देता है।"
"droppedChunksWithText_other": "ध्यान दें: आपके टेक्स्ट के {{count}} हिस्से कोई ऑडियो नहीं बना पाए और इस टेक में नहीं हैं — “{{text}}”। दोबारा जेनरेट करें; आमतौर पर दूसरा सीड इसे ठीक कर देता है।",
"streamingOffRemote": "जब तक {{label}} रेंडर कर रहा है, प्रोग्रेसिव प्रीव्यू बंद रहेगा — तैयार ऑडियो आते ही चल जाएगा।"
},
"voiceSelector": {
"engineDefault": "इंजन डिफ़ॉल्ट",
@@ -2357,6 +2395,7 @@
"started": "{{label}} डाउनलोड हो रहा है — प्रगति सेटिंग्स → मॉडल में देखें, फिर दोबारा कोशिश करें।",
"install_failed": "डाउनलोड शुरू नहीं हो सका: {{message}}"
},
"model_missing": {"message":"यह मॉडल {{target}} पर डाउनलोड नहीं है।","download":"मॉडल डाउनलोड करें","download_size":"डाउनलोड करें ({{size}})","started":"{{target}} पर डाउनलोड हो रहा है — पूरा होने पर फिर जनरेट करें।","failed":"डाउनलोड शुरू नहीं हो सका: {{message}}","manual_worker":"इस इंजन को सीधे {{target}} पर इंस्टॉल करें।"},
"backend_start_failure": {
"notice": "VoiceStudio का बैकएंड शुरू नहीं हो सका — जब तक यह शुरू न हो, कुछ भी नहीं चलेगा।",
"view": "कारण देखें",
@@ -2369,5 +2408,21 @@
"notice_unclean": "पिछला सत्र {{ago}} पहले बिना सही शटडाउन के समाप्त हुआ। यह अक्सर हानिरहित होता है — मशीन स्लीप में गई, ऐप को ज़बरदस्ती बंद किया गया, या कोई VM/कंटेनर रुक गया।",
"details_intro_unclean": "पिछले रन का शटडाउन मार्कर {{ago}} पहले कभी साफ़ नहीं हुआ। VoiceStudio यह नहीं बता सकता कि वह क्रैश था या केवल रुकावट — स्लीप, ज़बरदस्ती बंद करना या VM/कंटेनर रोकना एक जैसा निशान छोड़ता है। यदि अभी सब काम कर रहा है, तो कुछ करने की ज़रूरत नहीं।",
"report_needs_log": "इस घटना के लिए कोई त्रुटि आउटपुट कैप्चर नहीं हुआ, इसलिए एक-क्लिक रिपोर्ट खाली होगी। यदि वाकई कुछ गड़बड़ है, तो कृपया सेटिंग्स → लॉग → बैकएंड की अंतिम पंक्तियाँ नए issue में जोड़ें।"
},
"gpu": {
"local": "लोकल",
"picker": "काम कहाँ चलते हैं",
"tasks": "कार्य",
"coverage": "केवल {{ops}}",
"opLocal": "लोकल — {{op}} अभी रिमोट पर नहीं चलता",
"dictationLocal": "डिक्टेशन हमेशा इसी मशीन पर चलता है",
"ops": {
"tts": "TTS",
"clone": "वॉइस क्लोनिंग",
"dub": "डबिंग",
"audiobook": "ऑडियोबुक रेंडरिंग",
"longform": "कहानी वर्णन",
"asr": "ट्रांसक्रिप्शन"
}
}
}
+58 -3
View File
@@ -180,7 +180,35 @@
"history_retention_cap_hint": "Take berbintang tidak pernah dihitung dalam pembersihan · 0 = tanpa batas",
"history_retention_saved": "Batas penyimpanan disimpan",
"history_retention_save_failed": "Tidak dapat menyimpan",
"history_retention_invalid": "Masukkan 0 atau lebih"
"history_retention_invalid": "Masukkan 0 atau lebih",
"workers_title": "Pekerja jarak jauh",
"workers_desc": "Kirim pekerjaan satu per satu ke GPU di mesin Anda yang lain. Hasilnya kembali ke sini. Tidak ada yang dikirim sampai Anda menambahkan pekerja dan menyetujuinya.",
"workers_enable": "Gunakan pekerja jarak jauh",
"workers_enable_hint": "Selama ini mati, tidak ada koneksi yang diterima dan tidak ada yang meninggalkan mesin ini.",
"workers_port_conflict": "Worker jarak jauh tidak tersedia karena instans VoiceStudio lain sudah menerimanya di port ini. Tutup instans lain, atau atur OMNIVOICE_WORKER_PORT ke port berbeda lalu mulai ulang VoiceStudio.",
"workers_endpoint": "Pekerja terhubung ke",
"workers_endpoint_hint": "Pekerja harus dapat menjangkau alamat ini. Di jaringan berbeda, VPN seperti Tailscale adalah cara yang andal.",
"workers_add": "Tambahkan pekerja",
"workers_add_hint": "Buat token, lalu tempelkan ke OmniVoice di mesin yang lain.",
"workers_new_token": "Buat token",
"workers_token_once": "Salin sekarang — hanya ditampilkan sekali, hanya berfungsi sekali, dan kedaluwarsa dalam 15 menit.",
"workers_copy": "Salin",
"workers_copied": "Tersalin",
"workers_copy_failed": "Tidak dapat menyalin.",
"workers_none": "Belum ada pekerja. Buat token untuk menambahkan yang pertama.",
"workers_load": "Tugas {{active}} / {{slots}}",
"workers_needs_consent": "Belum disetujui",
"workers_status_online": "Daring",
"workers_status_offline": "Luring",
"workers_status_paused": "Dijeda",
"workers_status_disabled": "Dinonaktifkan",
"workers_resume": "Lanjutkan",
"workers_disable": "Nonaktifkan",
"workers_enable_one": "Aktifkan",
"workers_remove": "Hapus",
"workers_remove_title": "Hapus pekerja?",
"workers_remove_confirm": "Hapus {{name}}? Kuncinya dicabut, jadi tidak dapat terhubung kembali tanpa token baru.",
"workers_rename": "Ganti nama pekerja"
},
"bootstrap": {
"title": "VoiceStudio",
@@ -1791,7 +1819,16 @@
"to_download": "untuk mengunduh",
"cached": "di-cache",
"fast_download_badge": "unduh cepat",
"fast_download_title": "Pengunduhan cepat melalui Xet {{version}} — transfer potongan paralel"
"fast_download_title": "Pengunduhan cepat melalui Xet {{version}} — transfer potongan paralel",
"voice_previews": "Pratinjau suara",
"voice_previews_desc": "Unduh pratinjau yang sudah dirender lebih dulu agar suara langsung diputar — bahkan sebelum model suara selesai diunduh. Tidak ada yang diunduh sampai Anda mengaktifkan ini.",
"voice_previews_off": "Nonaktif — pratinjau dirender di perangkat ini",
"voice_previews_ready": "Set unggulan tersimpan",
"voice_previews_partial": "{{cached}} dari {{total}} pratinjau unggulan tersimpan",
"voice_previews_checked": "diperiksa {{when}}",
"voice_previews_never": "belum diperiksa",
"voice_previews_check": "Periksa sekarang",
"voice_previews_rejected": "Pembaruan galeri suara ditolak: {{message}}"
},
"enterprise_faq": {
"q_internal_tools": "Apakah saya memerlukan lisensi untuk alat internal?",
@@ -2298,7 +2335,8 @@
"droppedChunks_one": "Perhatian: {{count}} bagian teks Anda tidak menghasilkan audio, jadi hasil ini kehilangan bagian tersebut. Coba hasilkan ulang — seed yang berbeda biasanya memperbaikinya.",
"droppedChunks_other": "Perhatian: {{count}} bagian teks Anda tidak menghasilkan audio, jadi hasil ini kehilangan bagian-bagian tersebut. Coba hasilkan ulang — seed yang berbeda biasanya memperbaikinya.",
"droppedChunksWithText_one": "Perhatian: {{count}} bagian teks Anda tidak menghasilkan audio dan hilang dari hasil ini — “{{text}}”. Coba hasilkan ulang; seed yang berbeda biasanya memperbaikinya.",
"droppedChunksWithText_other": "Perhatian: {{count}} bagian teks Anda tidak menghasilkan audio dan hilang dari hasil ini — “{{text}}”. Coba hasilkan ulang; seed yang berbeda biasanya memperbaikinya."
"droppedChunksWithText_other": "Perhatian: {{count}} bagian teks Anda tidak menghasilkan audio dan hilang dari hasil ini — “{{text}}”. Coba hasilkan ulang; seed yang berbeda biasanya memperbaikinya.",
"streamingOffRemote": "Pratinjau progresif dimatikan selama {{label}} merender — hasil akhirnya diputar begitu tiba."
},
"voiceSelector": {
"engineDefault": "Default mesin",
@@ -2357,6 +2395,7 @@
"started": "Mengunduh {{label}} — pantau progresnya di Pengaturan → Model, lalu coba lagi.",
"install_failed": "Tidak dapat memulai unduhan: {{message}}"
},
"model_missing": {"message":"Model ini belum diunduh di {{target}}.","download":"Unduh model","download_size":"Unduh ({{size}})","started":"Mengunduh di {{target}} — coba Buat lagi setelah selesai.","failed":"Tidak dapat memulai unduhan: {{message}}","manual_worker":"Instal mesin ini langsung di {{target}}."},
"backend_start_failure": {
"notice": "Backend VoiceStudio gagal dijalankan — tidak ada yang bisa berjalan sampai backend hidup.",
"view": "Lihat alasannya",
@@ -2369,5 +2408,21 @@
"notice_unclean": "Sesi sebelumnya berakhir tanpa penutupan yang bersih {{ago}} yang lalu. Ini sering tidak berbahaya — mesin tertidur, aplikasi ditutup paksa, atau VM/kontainer berhenti.",
"details_intro_unclean": "Penanda penutupan dari sesi sebelumnya tidak pernah dibersihkan, {{ago}} yang lalu. VoiceStudio tidak dapat mengetahui apakah itu crash atau sekadar interupsi — tidur, penutupan paksa, atau menghentikan VM/kontainer meninggalkan jejak yang sama. Jika sekarang semuanya berfungsi, tidak ada yang perlu dilakukan.",
"report_needs_log": "Tidak ada keluaran kesalahan yang tertangkap untuk peristiwa ini, jadi laporan sekali klik akan kosong. Jika memang ada masalah, lampirkan baris terakhir dari Pengaturan → Log → Backend ke issue baru."
},
"gpu": {
"local": "Lokal",
"picker": "Tempat pekerjaan dijalankan",
"tasks": "tugas",
"coverage": "hanya {{ops}}",
"opLocal": "Lokal — {{op}} belum berjalan dari jarak jauh",
"dictationLocal": "Dikte selalu berjalan di mesin ini",
"ops": {
"tts": "TTS",
"clone": "kloning suara",
"dub": "sulih suara",
"audiobook": "render buku audio",
"longform": "narasi cerita",
"asr": "transkripsi"
}
}
}
+58 -3
View File
@@ -180,7 +180,35 @@
"history_retention_cap_hint": "I take con stella non contano mai per la pulizia · 0 = illimitato",
"history_retention_saved": "Limite di conservazione salvato",
"history_retention_save_failed": "Salvataggio non riuscito",
"history_retention_invalid": "Inserisci 0 o più"
"history_retention_invalid": "Inserisci 0 o più",
"workers_title": "Worker remoti",
"workers_desc": "Invia singoli lavori alle GPU dei tuoi altri computer. I risultati tornano qui. Non viene inviato nulla finché non aggiungi un worker e lo approvi.",
"workers_enable": "Usa worker remoti",
"workers_enable_hint": "Finché questa opzione è disattivata, non viene accettata alcuna connessione e nulla lascia questo computer.",
"workers_port_conflict": "I worker remoti non sono disponibili perché un'altra istanza di VoiceStudio li sta già accettando su questa porta. Chiudi l'altra istanza oppure imposta OMNIVOICE_WORKER_PORT su una porta diversa e riavvia VoiceStudio.",
"workers_endpoint": "I worker si connettono a",
"workers_endpoint_hint": "Un worker deve poter raggiungere questo indirizzo. Su reti diverse, una VPN come Tailscale è la via affidabile.",
"workers_add": "Aggiungi un worker",
"workers_add_hint": "Genera un token e incollalo in OmniVoice sull'altro computer.",
"workers_new_token": "Genera token",
"workers_token_once": "Copialo ora: viene mostrato una sola volta, funziona una sola volta e scade tra 15 minuti.",
"workers_copy": "Copia",
"workers_copied": "Copiato",
"workers_copy_failed": "Impossibile copiare.",
"workers_none": "Nessun worker. Genera un token per aggiungere il primo.",
"workers_load": "Attività {{active}} / {{slots}}",
"workers_needs_consent": "Non approvato",
"workers_status_online": "Online",
"workers_status_offline": "Offline",
"workers_status_paused": "In pausa",
"workers_status_disabled": "Disattivato",
"workers_resume": "Riprendi",
"workers_disable": "Disattiva",
"workers_enable_one": "Attiva",
"workers_remove": "Rimuovi",
"workers_remove_title": "Rimuovere il worker?",
"workers_remove_confirm": "Rimuovere {{name}}? La sua chiave viene revocata, quindi non potrà riconnettersi senza un nuovo token.",
"workers_rename": "Rinomina worker"
},
"bootstrap": {
"title": "VoiceStudio",
@@ -1791,7 +1819,16 @@
"to_download": "da scaricare",
"cached": "memorizzato nella cache",
"fast_download_badge": "download veloce",
"fast_download_title": "Download veloci tramite Xet {{version}}: trasferimento parallelo in blocchi"
"fast_download_title": "Download veloci tramite Xet {{version}}: trasferimento parallelo in blocchi",
"voice_previews": "Anteprime vocali",
"voice_previews_desc": "Scarica anteprime già generate così le voci si ascoltano subito, anche prima che un modello vocale finisca di scaricarsi. Non viene scaricato nulla finché non attivi questa opzione.",
"voice_previews_off": "Disattivato — le anteprime vengono generate su questo dispositivo",
"voice_previews_ready": "Set in evidenza in cache",
"voice_previews_partial": "{{cached}} di {{total}} anteprime in evidenza in cache",
"voice_previews_checked": "verificato {{when}}",
"voice_previews_never": "non ancora verificato",
"voice_previews_check": "Verifica ora",
"voice_previews_rejected": "Aggiornamento della galleria vocale rifiutato: {{message}}"
},
"enterprise_faq": {
"q_internal_tools": "Ho bisogno di una licenza per gli strumenti interni?",
@@ -2298,7 +2335,8 @@
"droppedChunks_one": "Attenzione: {{count}} parte del testo non ha prodotto audio, quindi manca in questa registrazione. Riprova a generare: di solito basta un seed diverso.",
"droppedChunks_other": "Attenzione: {{count}} parti del testo non hanno prodotto audio, quindi mancano in questa registrazione. Riprova a generare: di solito basta un seed diverso.",
"droppedChunksWithText_one": "Attenzione: {{count}} parte del testo non ha prodotto audio e manca in questa registrazione — «{{text}}». Riprova a generare; di solito basta un seed diverso.",
"droppedChunksWithText_other": "Attenzione: {{count}} parti del testo non hanno prodotto audio e mancano in questa registrazione — «{{text}}». Riprova a generare; di solito basta un seed diverso."
"droppedChunksWithText_other": "Attenzione: {{count}} parti del testo non hanno prodotto audio e mancano in questa registrazione — «{{text}}». Riprova a generare; di solito basta un seed diverso.",
"streamingOffRemote": "L'anteprima progressiva è disattivata mentre {{label}} esegue il rendering: la traccia finale verrà riprodotta appena arriva."
},
"voiceSelector": {
"engineDefault": "Motore difettoso",
@@ -2357,6 +2395,7 @@
"started": "Download di {{label}} in corso — segui l'avanzamento in Impostazioni → Modelli, poi riprova.",
"install_failed": "Impossibile avviare il download: {{message}}"
},
"model_missing": {"message":"Questo modello non è stato scaricato su {{target}}.","download":"Scarica modello","download_size":"Scarica ({{size}})","started":"Download avviato su {{target}} — riprova Genera al termine.","failed":"Impossibile avviare il download: {{message}}","manual_worker":"Installa questo motore direttamente su {{target}}."},
"backend_start_failure": {
"notice": "Il backend di VoiceStudio non è riuscito ad avviarsi: nulla funzionerà finché non parte.",
"view": "Vedi perché",
@@ -2369,5 +2408,21 @@
"notice_unclean": "La sessione precedente è terminata senza una chiusura pulita {{ago}} fa. Spesso è innocuo: il computer è andato in sospensione, l'app è stata chiusa forzatamente o una VM/container si è fermata.",
"details_intro_unclean": "Il marcatore di chiusura dell'esecuzione precedente non è mai stato rimosso, {{ago}} fa. VoiceStudio non può sapere se si è trattato di un crash o di una semplice interruzione: sospensione, chiusura forzata o arresto di una VM/container lasciano la stessa traccia. Se ora funziona tutto, non serve fare nulla.",
"report_needs_log": "Per questo evento non è stato catturato alcun output di errore, quindi una segnalazione con un clic sarebbe vuota. Se qualcosa non va davvero, allega le ultime righe di Impostazioni → Log → Backend a una nuova issue."
},
"gpu": {
"local": "Locale",
"picker": "Dove vengono eseguiti i lavori",
"tasks": "attività",
"coverage": "solo {{ops}}",
"opLocal": "Locale — {{op}} non viene ancora eseguito da remoto",
"dictationLocal": "La dettatura viene sempre eseguita su questo computer",
"ops": {
"tts": "TTS",
"clone": "clonazione vocale",
"dub": "doppiaggio",
"audiobook": "rendering di audiolibri",
"longform": "narrazione di storie",
"asr": "trascrizione"
}
}
}
+58 -3
View File
@@ -180,7 +180,35 @@
"history_retention_cap_hint": "スター付きテイクは整理の対象外 · 0 = 無制限",
"history_retention_saved": "保持上限を保存しました",
"history_retention_save_failed": "保存できませんでした",
"history_retention_invalid": "0以上を入力してください"
"history_retention_invalid": "0以上を入力してください",
"workers_title": "リモートワーカー",
"workers_desc": "個々のジョブを他のマシンの GPU に送ります。結果はここに戻ります。ワーカーを追加して承認するまで、何も送信されません。",
"workers_enable": "リモートワーカーを使う",
"workers_enable_hint": "これがオフの間は接続を受け付けず、このマシンから何も出ていきません。",
"workers_port_conflict": "別のVoiceStudioインスタンスがこのポートですでにリモートワーカーを受け付けているため、利用できません。もう一方のインスタンスを閉じるか、OMNIVOICE_WORKER_PORTを別のポートに設定してVoiceStudioを再起動してください。",
"workers_endpoint": "ワーカーの接続先",
"workers_endpoint_hint": "ワーカーがこのアドレスに到達できる必要があります。別のネットワークでは、Tailscale などの VPN が確実な方法です。",
"workers_add": "ワーカーを追加",
"workers_add_hint": "トークンを生成し、もう一方のマシンの OmniVoice に貼り付けます。",
"workers_new_token": "トークンを生成",
"workers_token_once": "今すぐコピーしてください。表示は一度きり、使用も一度きりで、15 分で失効します。",
"workers_copy": "コピー",
"workers_copied": "コピーしました",
"workers_copy_failed": "コピーできませんでした。",
"workers_none": "ワーカーはまだありません。トークンを生成して最初の 1 台を追加してください。",
"workers_load": "タスク {{active}} / {{slots}}",
"workers_needs_consent": "未承認",
"workers_status_online": "オンライン",
"workers_status_offline": "オフライン",
"workers_status_paused": "一時停止中",
"workers_status_disabled": "無効",
"workers_resume": "再開",
"workers_disable": "無効にする",
"workers_enable_one": "有効にする",
"workers_remove": "削除",
"workers_remove_title": "ワーカーを削除しますか?",
"workers_remove_confirm": "{{name}} を削除しますか?キーが失効するため、新しいトークンなしでは再接続できません。",
"workers_rename": "ワーカー名を変更"
},
"bootstrap": {
"title": "VoiceStudio",
@@ -1791,7 +1819,16 @@
"to_download": "ダウンロードする",
"cached": "キャッシュされた",
"fast_download_badge": "高速ダウンロード",
"fast_download_title": "Xet {{version}} による高速ダウンロード — 並列チャンク転送"
"fast_download_title": "Xet {{version}} による高速ダウンロード — 並列チャンク転送",
"voice_previews": "ボイスプレビュー",
"voice_previews_desc": "事前にレンダリングされたプレビューをダウンロードすると、音声モデルのダウンロードが終わる前でも声をすぐに再生できます。オンにするまで何もダウンロードされません。",
"voice_previews_off": "オフ — プレビューはこの端末で生成されます",
"voice_previews_ready": "おすすめセットをキャッシュ済み",
"voice_previews_partial": "おすすめプレビュー {{total}} 件中 {{cached}} 件をキャッシュ済み",
"voice_previews_checked": "{{when}}に確認",
"voice_previews_never": "未確認",
"voice_previews_check": "今すぐ確認",
"voice_previews_rejected": "ボイスギャラリーの更新を拒否しました: {{message}}"
},
"enterprise_faq": {
"q_internal_tools": "内部ツールにはライセンスが必要ですか?",
@@ -2298,7 +2335,8 @@
"droppedChunks_one": "ご注意: テキストのうち {{count}} 箇所が音声になりませんでした。このテイクにはその部分がありません。生成し直してください — 別のシードで解決することがほとんどです。",
"droppedChunks_other": "ご注意: テキストのうち {{count}} 箇所が音声になりませんでした。このテイクにはそれらの部分がありません。生成し直してください — 別のシードで解決することがほとんどです。",
"droppedChunksWithText_one": "ご注意: テキストのうち {{count}} 箇所が音声にならず、このテイクから欠けています —「{{text}}」。生成し直してください。別のシードで解決することがほとんどです。",
"droppedChunksWithText_other": "ご注意: テキストのうち {{count}} 箇所が音声にならず、このテイクから欠けています —「{{text}}」。生成し直してください。別のシードで解決することがほとんどです。"
"droppedChunksWithText_other": "ご注意: テキストのうち {{count}} 箇所が音声にならず、このテイクから欠けています —「{{text}}」。生成し直してください。別のシードで解決することがほとんどです。",
"streamingOffRemote": "{{label}} がレンダリング中はプログレッシブ再生を無効にします。完成した音声は届き次第再生されます。"
},
"voiceSelector": {
"engineDefault": "エンジンのデフォルト",
@@ -2357,6 +2395,7 @@
"started": "{{label}} をダウンロード中 — 設定 → モデルで進行状況を確認し、完了後にもう一度お試しください。",
"install_failed": "ダウンロードを開始できませんでした: {{message}}"
},
"model_missing": {"message":"このモデルは {{target}} にダウンロードされていません。","download":"モデルをダウンロード","download_size":"ダウンロード({{size}}","started":"{{target}} でダウンロード中です。完了後にもう一度生成してください。","failed":"ダウンロードを開始できませんでした:{{message}}","manual_worker":"このエンジンを {{target}} に直接インストールしてください。"},
"backend_start_failure": {
"notice": "VoiceStudio のバックエンドを起動できませんでした。起動するまで何も実行できません。",
"view": "理由を見る",
@@ -2369,5 +2408,21 @@
"notice_unclean": "前回のセッションは {{ago}}前に正常終了せずに終わりました。多くの場合は無害です — スリープ、強制終了、または VM/コンテナの停止が原因です。",
"details_intro_unclean": "前回実行の終了マーカーが {{ago}}前にクリアされないまま残っていました。VoiceStudio にはクラッシュか単なる中断かを判別できません — スリープ、強制終了、VM/コンテナの停止はいずれも同じ痕跡を残します。現在問題なく動作しているなら対応は不要です。",
"report_needs_log": "このイベントのエラー出力は記録されていないため、ワンクリック報告は空になります。実際に問題がある場合は、設定 → ログ → バックエンド の最後の行を新しい issue に添付してください。"
},
"gpu": {
"local": "ローカル",
"picker": "ジョブの実行場所",
"tasks": "タスク",
"coverage": "{{ops}} のみ",
"opLocal": "ローカル — {{op}}はまだリモートで実行できません",
"dictationLocal": "音声入力は常にこのマシンで実行されます",
"ops": {
"tts": "TTS",
"clone": "音声クローン",
"dub": "吹き替え",
"audiobook": "オーディオブック生成",
"longform": "ストーリー朗読",
"asr": "文字起こし"
}
}
}
+58 -3
View File
@@ -180,7 +180,35 @@
"history_retention_cap_hint": "별표된 테이크는 정리 대상에서 제외 · 0 = 무제한",
"history_retention_saved": "보존 한도가 저장되었습니다",
"history_retention_save_failed": "저장하지 못했습니다",
"history_retention_invalid": "0 이상을 입력하세요"
"history_retention_invalid": "0 이상을 입력하세요",
"workers_title": "원격 워커",
"workers_desc": "개별 작업을 다른 컴퓨터의 GPU로 보냅니다. 결과는 이곳으로 돌아옵니다. 워커를 추가하고 승인하기 전까지는 아무것도 전송되지 않습니다.",
"workers_enable": "원격 워커 사용",
"workers_enable_hint": "이 설정이 꺼져 있는 동안에는 연결을 받지 않으며 이 컴퓨터에서 나가는 것도 없습니다.",
"workers_port_conflict": "다른 VoiceStudio 인스턴스가 이미 이 포트에서 원격 워커 연결을 받고 있어 사용할 수 없습니다. 다른 인스턴스를 닫거나 OMNIVOICE_WORKER_PORT를 다른 포트로 설정한 뒤 VoiceStudio를 다시 시작하세요.",
"workers_endpoint": "워커 연결 주소",
"workers_endpoint_hint": "워커가 이 주소에 도달할 수 있어야 합니다. 네트워크가 다르면 Tailscale 같은 VPN이 확실한 방법입니다.",
"workers_add": "워커 추가",
"workers_add_hint": "토큰을 생성한 뒤 다른 컴퓨터의 OmniVoice에 붙여 넣으세요.",
"workers_new_token": "토큰 생성",
"workers_token_once": "지금 복사하세요 — 한 번만 표시되고, 한 번만 사용할 수 있으며, 15분 후 만료됩니다.",
"workers_copy": "복사",
"workers_copied": "복사됨",
"workers_copy_failed": "복사할 수 없습니다.",
"workers_none": "아직 워커가 없습니다. 토큰을 생성해 첫 워커를 추가하세요.",
"workers_load": "작업 {{active}} / {{slots}}",
"workers_needs_consent": "승인되지 않음",
"workers_status_online": "온라인",
"workers_status_offline": "오프라인",
"workers_status_paused": "일시 중지됨",
"workers_status_disabled": "비활성화됨",
"workers_resume": "재개",
"workers_disable": "비활성화",
"workers_enable_one": "활성화",
"workers_remove": "제거",
"workers_remove_title": "워커를 제거할까요?",
"workers_remove_confirm": "{{name}}을(를) 제거할까요? 키가 취소되어 새 토큰 없이는 다시 연결할 수 없습니다.",
"workers_rename": "워커 이름 변경"
},
"bootstrap": {
"title": "VoiceStudio",
@@ -1791,7 +1819,16 @@
"to_download": "다운로드하다",
"cached": "캐시된",
"fast_download_badge": "빠른 다운로드",
"fast_download_title": "Xet {{version}}을 통한 빠른 다운로드 — 병렬 청크 전송"
"fast_download_title": "Xet {{version}}을 통한 빠른 다운로드 — 병렬 청크 전송",
"voice_previews": "음성 미리듣기",
"voice_previews_desc": "미리 렌더링된 미리듣기를 내려받으면 음성 모델 다운로드가 끝나기 전에도 목소리를 바로 재생할 수 있습니다. 켜기 전까지는 아무것도 내려받지 않습니다.",
"voice_previews_off": "끔 — 미리듣기를 이 기기에서 생성합니다",
"voice_previews_ready": "추천 세트 캐시됨",
"voice_previews_partial": "추천 미리듣기 {{total}}개 중 {{cached}}개 캐시됨",
"voice_previews_checked": "{{when}} 확인함",
"voice_previews_never": "아직 확인하지 않음",
"voice_previews_check": "지금 확인",
"voice_previews_rejected": "음성 갤러리 업데이트가 거부되었습니다: {{message}}"
},
"enterprise_faq": {
"q_internal_tools": "내부 도구에 대한 라이선스가 필요합니까?",
@@ -2298,7 +2335,8 @@
"droppedChunks_one": "알림: 텍스트 중 {{count}}개 부분이 오디오로 생성되지 않아 이 테이크에서 빠졌습니다. 다시 생성해 보세요 — 보통 다른 시드로 해결됩니다.",
"droppedChunks_other": "알림: 텍스트 중 {{count}}개 부분이 오디오로 생성되지 않아 이 테이크에서 빠졌습니다. 다시 생성해 보세요 — 보통 다른 시드로 해결됩니다.",
"droppedChunksWithText_one": "알림: 텍스트 중 {{count}}개 부분이 오디오로 생성되지 않아 이 테이크에서 빠졌습니다 — “{{text}}”. 다시 생성해 보세요. 보통 다른 시드로 해결됩니다.",
"droppedChunksWithText_other": "알림: 텍스트 중 {{count}}개 부분이 오디오로 생성되지 않아 이 테이크에서 빠졌습니다 — “{{text}}”. 다시 생성해 보세요. 보통 다른 시드로 해결됩니다."
"droppedChunksWithText_other": "알림: 텍스트 중 {{count}}개 부분이 오디오로 생성되지 않아 이 테이크에서 빠졌습니다 — “{{text}}”. 다시 생성해 보세요. 보통 다른 시드로 해결됩니다.",
"streamingOffRemote": "{{label}}이(가) 렌더링하는 동안 점진적 미리듣기가 꺼집니다. 완성된 결과는 도착하는 즉시 재생됩니다."
},
"voiceSelector": {
"engineDefault": "엔진 기본값",
@@ -2357,6 +2395,7 @@
"started": "{{label}} 다운로드 중 — 설정 → 모델에서 진행 상황을 확인한 뒤 다시 시도하세요.",
"install_failed": "다운로드를 시작할 수 없습니다: {{message}}"
},
"model_missing": {"message":"이 모델은 {{target}}에 다운로드되지 않았습니다.","download":"모델 다운로드","download_size":"다운로드 ({{size}})","started":"{{target}}에서 다운로드 중입니다. 완료되면 다시 생성하세요.","failed":"다운로드를 시작할 수 없습니다: {{message}}","manual_worker":"이 엔진을 {{target}}에 직접 설치하세요."},
"backend_start_failure": {
"notice": "VoiceStudio 백엔드를 시작하지 못했습니다. 시작되기 전까지는 아무것도 실행할 수 없습니다.",
"view": "이유 보기",
@@ -2369,5 +2408,21 @@
"notice_unclean": "이전 세션이 {{ago}} 전에 정상 종료되지 않은 채 끝났습니다. 대부분 무해합니다 — 컴퓨터가 절전 모드로 전환되었거나, 앱이 강제 종료되었거나, VM/컨테이너가 중지된 경우입니다.",
"details_intro_unclean": "이전 실행의 종료 마커가 {{ago}} 전에 지워지지 않은 채 남아 있었습니다. VoiceStudio는 그것이 충돌인지 단순한 중단인지 알 수 없습니다 — 절전, 강제 종료, VM/컨테이너 중지는 모두 같은 흔적을 남깁니다. 지금 모든 것이 잘 작동한다면 할 일은 없습니다.",
"report_needs_log": "이 이벤트에 대해 캡처된 오류 출력이 없어 원클릭 보고서는 비어 있게 됩니다. 실제로 문제가 있다면 설정 → 로그 → 백엔드의 마지막 줄을 새 이슈에 첨부해 주세요."
},
"gpu": {
"local": "로컬",
"picker": "작업 실행 위치",
"tasks": "작업",
"coverage": "{{ops}}만",
"opLocal": "로컬 — {{op}}은(는) 아직 원격에서 실행되지 않습니다",
"dictationLocal": "받아쓰기는 항상 이 컴퓨터에서 실행됩니다",
"ops": {
"tts": "TTS",
"clone": "음성 복제",
"dub": "더빙",
"audiobook": "오디오북 렌더링",
"longform": "스토리 내레이션",
"asr": "전사"
}
}
}
+58 -3
View File
@@ -180,7 +180,35 @@
"history_retention_cap_hint": "Takes met ster tellen nooit mee voor opruimen · 0 = onbeperkt",
"history_retention_saved": "Bewaarlimiet opgeslagen",
"history_retention_save_failed": "Opslaan mislukt",
"history_retention_invalid": "Voer 0 of meer in"
"history_retention_invalid": "Voer 0 of meer in",
"workers_title": "Externe workers",
"workers_desc": "Stuur losse taken naar de GPU's van je andere machines. De resultaten komen hier terug. Er wordt niets verstuurd totdat je een worker toevoegt en goedkeurt.",
"workers_enable": "Externe workers gebruiken",
"workers_enable_hint": "Zolang dit uit staat, wordt er geen verbinding geaccepteerd en verlaat er niets deze machine.",
"workers_port_conflict": "Externe workers zijn niet beschikbaar omdat een andere VoiceStudio-instantie ze al op deze poort accepteert. Sluit de andere instantie, of stel OMNIVOICE_WORKER_PORT in op een andere poort en start VoiceStudio opnieuw.",
"workers_endpoint": "Workers verbinden met",
"workers_endpoint_hint": "Een worker moet dit adres kunnen bereiken. Op andere netwerken is een VPN zoals Tailscale de betrouwbare manier.",
"workers_add": "Worker toevoegen",
"workers_add_hint": "Genereer een token en plak die in OmniVoice op de andere machine.",
"workers_new_token": "Token genereren",
"workers_token_once": "Kopieer dit nu — het wordt maar één keer getoond, werkt maar één keer en verloopt over 15 minuten.",
"workers_copy": "Kopiëren",
"workers_copied": "Gekopieerd",
"workers_copy_failed": "Kopiëren mislukt.",
"workers_none": "Nog geen workers. Genereer een token om je eerste toe te voegen.",
"workers_load": "Taken {{active}} / {{slots}}",
"workers_needs_consent": "Niet goedgekeurd",
"workers_status_online": "Online",
"workers_status_offline": "Offline",
"workers_status_paused": "Gepauzeerd",
"workers_status_disabled": "Uitgeschakeld",
"workers_resume": "Hervatten",
"workers_disable": "Uitschakelen",
"workers_enable_one": "Inschakelen",
"workers_remove": "Verwijderen",
"workers_remove_title": "Worker verwijderen?",
"workers_remove_confirm": "{{name}} verwijderen? De sleutel wordt ingetrokken, dus opnieuw verbinden kan niet zonder nieuw token.",
"workers_rename": "Worker hernoemen"
},
"bootstrap": {
"title": "VoiceStudio",
@@ -1791,7 +1819,16 @@
"to_download": "downloaden",
"cached": "in de cache opgeslagen",
"fast_download_badge": "snel downloaden",
"fast_download_title": "Snelle downloads via Xet {{version}} — parallelle gefragmenteerde overdracht"
"fast_download_title": "Snelle downloads via Xet {{version}} — parallelle gefragmenteerde overdracht",
"voice_previews": "Stemvoorbeelden",
"voice_previews_desc": "Download vooraf gerenderde voorbeelden zodat stemmen meteen afspelen, zelfs voordat een spraakmodel klaar is met downloaden. Er wordt niets gedownload totdat je dit inschakelt.",
"voice_previews_off": "Uit — voorbeelden worden op dit apparaat gerenderd",
"voice_previews_ready": "Uitgelichte set in cache",
"voice_previews_partial": "{{cached}} van {{total}} uitgelichte voorbeelden in cache",
"voice_previews_checked": "gecontroleerd {{when}}",
"voice_previews_never": "nog niet gecontroleerd",
"voice_previews_check": "Nu controleren",
"voice_previews_rejected": "Update van de stemgalerij geweigerd: {{message}}"
},
"enterprise_faq": {
"q_internal_tools": "Heb ik een licentie nodig voor interne tools?",
@@ -2298,7 +2335,8 @@
"droppedChunks_one": "Let op: {{count}} deel van je tekst leverde geen audio op en ontbreekt dus in deze opname. Genereer opnieuw — een andere seed lost dit meestal op.",
"droppedChunks_other": "Let op: {{count}} delen van je tekst leverden geen audio op en ontbreken dus in deze opname. Genereer opnieuw — een andere seed lost dit meestal op.",
"droppedChunksWithText_one": "Let op: {{count}} deel van je tekst leverde geen audio op en ontbreekt in deze opname — “{{text}}”. Genereer opnieuw; een andere seed lost dit meestal op.",
"droppedChunksWithText_other": "Let op: {{count}} delen van je tekst leverden geen audio op en ontbreken in deze opname — “{{text}}”. Genereer opnieuw; een andere seed lost dit meestal op."
"droppedChunksWithText_other": "Let op: {{count}} delen van je tekst leverden geen audio op en ontbreken in deze opname — “{{text}}”. Genereer opnieuw; een andere seed lost dit meestal op.",
"streamingOffRemote": "De progressieve preview staat uit terwijl {{label}} rendert — de voltooide opname speelt zodra die binnen is."
},
"voiceSelector": {
"engineDefault": "Standaard motor",
@@ -2357,6 +2395,7 @@
"started": "{{label}} wordt gedownload — volg de voortgang in Instellingen → Modellen en probeer het daarna opnieuw.",
"install_failed": "Kon de download niet starten: {{message}}"
},
"model_missing": {"message":"Dit model is niet gedownload op {{target}}.","download":"Model downloaden","download_size":"Downloaden ({{size}})","started":"Downloaden op {{target}} — probeer Genereren opnieuw wanneer dit klaar is.","failed":"Download kon niet worden gestart: {{message}}","manual_worker":"Installeer deze engine rechtstreeks op {{target}}."},
"backend_start_failure": {
"notice": "De VoiceStudio-backend kon niet starten — tot die tijd werkt er niets.",
"view": "Bekijk waarom",
@@ -2369,5 +2408,21 @@
"notice_unclean": "De vorige sessie is {{ago}} geleden zonder nette afsluiting geëindigd. Dit is vaak onschuldig — de machine sliep, de app werd geforceerd gesloten of een VM/container stopte.",
"details_intro_unclean": "De afsluitmarkering van de vorige run is {{ago}} geleden nooit gewist. VoiceStudio kan niet zien of het een crash of slechts een onderbreking was — slapen, geforceerd afsluiten of het stoppen van een VM/container laat hetzelfde spoor achter. Als alles nu werkt, hoeft er niets te gebeuren.",
"report_needs_log": "Er is voor deze gebeurtenis geen foutuitvoer vastgelegd, dus een één-klik-rapport zou leeg zijn. Als er echt iets mis is, voeg dan de laatste regels van Instellingen → Logboeken → Backend toe aan een nieuw issue."
},
"gpu": {
"local": "Lokaal",
"picker": "Waar taken draaien",
"tasks": "taken",
"coverage": "alleen {{ops}}",
"opLocal": "Lokaal — {{op}} draait nog niet op afstand",
"dictationLocal": "Dicteren wordt altijd op deze machine uitgevoerd",
"ops": {
"tts": "TTS",
"clone": "stemklonen",
"dub": "nasynchronisatie",
"audiobook": "audioboeken renderen",
"longform": "verhaalvertelling",
"asr": "transcriptie"
}
}
}
+58 -3
View File
@@ -180,7 +180,35 @@
"history_retention_cap_hint": "Nagrania z gwiazdką nigdy nie liczą się do czyszczenia · 0 = bez limitu",
"history_retention_saved": "Limit przechowywania zapisany",
"history_retention_save_failed": "Nie udało się zapisać",
"history_retention_invalid": "Wpisz 0 lub więcej"
"history_retention_invalid": "Wpisz 0 lub więcej",
"workers_title": "Zdalne workery",
"workers_desc": "Wysyłaj pojedyncze zadania do GPU na innych swoich komputerach. Wyniki wracają tutaj. Nic nie zostanie wysłane, dopóki nie dodasz workera i go nie zatwierdzisz.",
"workers_enable": "Używaj zdalnych workerów",
"workers_enable_hint": "Gdy ta opcja jest wyłączona, żadne połączenie nie jest przyjmowane i nic nie opuszcza tego komputera.",
"workers_port_conflict": "Zdalne workery są niedostępne, ponieważ inna instancja VoiceStudio już przyjmuje je na tym porcie. Zamknij drugą instancję albo ustaw OMNIVOICE_WORKER_PORT na inny port i uruchom VoiceStudio ponownie.",
"workers_endpoint": "Workery łączą się z",
"workers_endpoint_hint": "Worker musi móc osiągnąć ten adres. W różnych sieciach niezawodnym sposobem jest VPN, np. Tailscale.",
"workers_add": "Dodaj workera",
"workers_add_hint": "Wygeneruj token, a następnie wklej go w OmniVoice na drugim komputerze.",
"workers_new_token": "Wygeneruj token",
"workers_token_once": "Skopiuj teraz — pokazujemy go tylko raz, działa tylko raz i wygasa po 15 minutach.",
"workers_copy": "Kopiuj",
"workers_copied": "Skopiowano",
"workers_copy_failed": "Nie udało się skopiować.",
"workers_none": "Brak workerów. Wygeneruj token, aby dodać pierwszego.",
"workers_load": "Zadania {{active}} / {{slots}}",
"workers_needs_consent": "Niezatwierdzony",
"workers_status_online": "Online",
"workers_status_offline": "Offline",
"workers_status_paused": "Wstrzymany",
"workers_status_disabled": "Wyłączony",
"workers_resume": "Wznów",
"workers_disable": "Wyłącz",
"workers_enable_one": "Włącz",
"workers_remove": "Usuń",
"workers_remove_title": "Usunąć workera?",
"workers_remove_confirm": "Usunąć {{name}}? Jego klucz zostanie unieważniony, więc nie połączy się ponownie bez nowego tokena.",
"workers_rename": "Zmień nazwę workera"
},
"bootstrap": {
"title": "VoiceStudio",
@@ -1791,7 +1819,16 @@
"to_download": "pobrać",
"cached": "buforowane",
"fast_download_badge": "szybkie pobieranie",
"fast_download_title": "Szybkie pobieranie poprzez Xet {{version}} — równoległy transfer fragmentaryczny"
"fast_download_title": "Szybkie pobieranie poprzez Xet {{version}} — równoległy transfer fragmentaryczny",
"voice_previews": "Podglądy głosów",
"voice_previews_desc": "Pobierz wcześniej wygenerowane podglądy, aby głosy odtwarzały się od razu — jeszcze zanim model głosu skończy się pobierać. Nic nie jest pobierane, dopóki tego nie włączysz.",
"voice_previews_off": "Wyłączone — podglądy są generowane na tym urządzeniu",
"voice_previews_ready": "Zestaw polecanych w pamięci podręcznej",
"voice_previews_partial": "{{cached}} z {{total}} polecanych podglądów w pamięci podręcznej",
"voice_previews_checked": "sprawdzono {{when}}",
"voice_previews_never": "jeszcze nie sprawdzono",
"voice_previews_check": "Sprawdź teraz",
"voice_previews_rejected": "Odrzucono aktualizację galerii głosów: {{message}}"
},
"enterprise_faq": {
"q_internal_tools": "Czy potrzebuję licencji na narzędzia wewnętrzne?",
@@ -2298,7 +2335,8 @@
"droppedChunks_one": "Uwaga: {{count}} fragment tekstu nie wygenerował dźwięku, więc brakuje go w tym nagraniu. Wygeneruj ponownie — inne ziarno zwykle rozwiązuje problem.",
"droppedChunks_other": "Uwaga: fragmenty tekstu ({{count}}) nie wygenerowały dźwięku, więc brakuje ich w tym nagraniu. Wygeneruj ponownie — inne ziarno zwykle rozwiązuje problem.",
"droppedChunksWithText_one": "Uwaga: {{count}} fragment tekstu nie wygenerował dźwięku i brakuje go w tym nagraniu — „{{text}}”. Wygeneruj ponownie; inne ziarno zwykle rozwiązuje problem.",
"droppedChunksWithText_other": "Uwaga: fragmenty tekstu ({{count}}) nie wygenerowały dźwięku i brakuje ich w tym nagraniu — „{{text}}”. Wygeneruj ponownie; inne ziarno zwykle rozwiązuje problem."
"droppedChunksWithText_other": "Uwaga: fragmenty tekstu ({{count}}) nie wygenerowały dźwięku i brakuje ich w tym nagraniu — „{{text}}”. Wygeneruj ponownie; inne ziarno zwykle rozwiązuje problem.",
"streamingOffRemote": "Podgląd progresywny jest wyłączony, gdy renderuje {{label}} — gotowe nagranie odtworzy się zaraz po nadejściu."
},
"voiceSelector": {
"engineDefault": "Domyślne ustawienie silnika",
@@ -2357,6 +2395,7 @@
"started": "Pobieranie {{label}} — postęp znajdziesz w Ustawienia → Modele, potem spróbuj ponownie.",
"install_failed": "Nie udało się rozpocząć pobierania: {{message}}"
},
"model_missing": {"message":"Ten model nie został pobrany na {{target}}.","download":"Pobierz model","download_size":"Pobierz ({{size}})","started":"Pobieranie na {{target}} — po zakończeniu ponów generowanie.","failed":"Nie udało się rozpocząć pobierania: {{message}}","manual_worker":"Zainstaluj ten silnik bezpośrednio na {{target}}."},
"backend_start_failure": {
"notice": "Backend VoiceStudio nie mógł się uruchomić — nic nie zadziała, dopóki nie wystartuje.",
"view": "Zobacz dlaczego",
@@ -2369,5 +2408,21 @@
"notice_unclean": "Poprzednia sesja zakończyła się bez poprawnego zamknięcia {{ago}} temu. To często niegroźne — komputer uśpiono, wymuszono zamknięcie aplikacji albo zatrzymano VM/kontener.",
"details_intro_unclean": "Znacznik zamknięcia poprzedniego uruchomienia nigdy nie został wyczyszczony, {{ago}} temu. VoiceStudio nie potrafi stwierdzić, czy to była awaria, czy tylko przerwanie — uśpienie, wymuszone zamknięcie lub zatrzymanie VM/kontenera zostawia ten sam ślad. Jeśli teraz wszystko działa, nic nie trzeba robić.",
"report_needs_log": "Dla tego zdarzenia nie przechwycono żadnych błędów, więc raport jednym kliknięciem byłby pusty. Jeśli coś naprawdę nie działa, dołącz ostatnie linie z Ustawienia → Dzienniki → Backend do nowego zgłoszenia."
},
"gpu": {
"local": "Lokalnie",
"picker": "Gdzie wykonywane są zadania",
"tasks": "zadania",
"coverage": "tylko {{ops}}",
"opLocal": "Lokalnie — {{op}} nie działa jeszcze zdalnie",
"dictationLocal": "Dyktowanie zawsze działa na tym komputerze",
"ops": {
"tts": "TTS",
"clone": "klonowanie głosu",
"dub": "dubbing",
"audiobook": "renderowanie audiobooków",
"longform": "narracja opowiadań",
"asr": "transkrypcja"
}
}
}
+58 -3
View File
@@ -180,7 +180,35 @@
"history_retention_cap_hint": "Takes com estrela nunca contam para a limpeza · 0 = ilimitado",
"history_retention_saved": "Limite de retenção salvo",
"history_retention_save_failed": "Não foi possível salvar",
"history_retention_invalid": "Digite 0 ou mais"
"history_retention_invalid": "Digite 0 ou mais",
"workers_title": "Workers remotos",
"workers_desc": "Envie trabalhos individuais para as GPUs das suas outras máquinas. Os resultados voltam para aqui. Nada é enviado até adicionar um worker e aprová-lo.",
"workers_enable": "Usar workers remotos",
"workers_enable_hint": "Enquanto isto estiver desligado, nenhuma ligação é aceite e nada sai desta máquina.",
"workers_port_conflict": "Os workers remotos não estão disponíveis porque outra instância do VoiceStudio já os aceita nesta porta. Feche a outra instância ou defina OMNIVOICE_WORKER_PORT para uma porta diferente e reinicie o VoiceStudio.",
"workers_endpoint": "Os workers ligam-se a",
"workers_endpoint_hint": "Um worker tem de conseguir alcançar este endereço. Em redes diferentes, uma VPN como o Tailscale é a forma fiável.",
"workers_add": "Adicionar um worker",
"workers_add_hint": "Gere um token e cole-o no OmniVoice na outra máquina.",
"workers_new_token": "Gerar token",
"workers_token_once": "Copie agora — é mostrado apenas uma vez, funciona apenas uma vez e expira em 15 minutos.",
"workers_copy": "Copiar",
"workers_copied": "Copiado",
"workers_copy_failed": "Não foi possível copiar.",
"workers_none": "Ainda não há workers. Gere um token para adicionar o primeiro.",
"workers_load": "Tarefas {{active}} / {{slots}}",
"workers_needs_consent": "Não aprovado",
"workers_status_online": "Online",
"workers_status_offline": "Offline",
"workers_status_paused": "Em pausa",
"workers_status_disabled": "Desativado",
"workers_resume": "Retomar",
"workers_disable": "Desativar",
"workers_enable_one": "Ativar",
"workers_remove": "Remover",
"workers_remove_title": "Remover o worker?",
"workers_remove_confirm": "Remover {{name}}? A sua chave é revogada, por isso não poderá voltar a ligar-se sem um novo token.",
"workers_rename": "Renomear worker"
},
"bootstrap": {
"title": "VoiceStudio",
@@ -1791,7 +1819,16 @@
"to_download": "baixar",
"cached": "armazenado em cache",
"fast_download_badge": "download rápido",
"fast_download_title": "Downloads rápidos via Xet {{version}} — transferência paralela em partes"
"fast_download_title": "Downloads rápidos via Xet {{version}} — transferência paralela em partes",
"voice_previews": "Prévias de voz",
"voice_previews_desc": "Baixe prévias renderizadas com antecedência para que as vozes toquem na hora, mesmo antes de um modelo de voz terminar de baixar. Nada é baixado até você ativar isto.",
"voice_previews_off": "Desativado — as prévias são geradas neste dispositivo",
"voice_previews_ready": "Conjunto em destaque em cache",
"voice_previews_partial": "{{cached}} de {{total}} prévias em destaque em cache",
"voice_previews_checked": "verificado {{when}}",
"voice_previews_never": "ainda não verificado",
"voice_previews_check": "Verificar agora",
"voice_previews_rejected": "Atualização da galeria de vozes rejeitada: {{message}}"
},
"enterprise_faq": {
"q_internal_tools": "Preciso de uma licença para ferramentas internas?",
@@ -2298,7 +2335,8 @@
"droppedChunks_one": "Atenção: {{count}} parte do seu texto não gerou áudio, então falta nesta gravação. Tente gerar de novo — outra semente costuma resolver.",
"droppedChunks_other": "Atenção: {{count}} partes do seu texto não geraram áudio, então faltam nesta gravação. Tente gerar de novo — outra semente costuma resolver.",
"droppedChunksWithText_one": "Atenção: {{count}} parte do seu texto não gerou áudio e falta nesta gravação — “{{text}}”. Tente gerar de novo; outra semente costuma resolver.",
"droppedChunksWithText_other": "Atenção: {{count}} partes do seu texto não geraram áudio e faltam nesta gravação — “{{text}}”. Tente gerar de novo; outra semente costuma resolver."
"droppedChunksWithText_other": "Atenção: {{count}} partes do seu texto não geraram áudio e faltam nesta gravação — “{{text}}”. Tente gerar de novo; outra semente costuma resolver.",
"streamingOffRemote": "A pré-visualização progressiva está desativada enquanto {{label}} renderiza — a gravação final será reproduzida assim que chegar."
},
"voiceSelector": {
"engineDefault": "Padrão do mecanismo",
@@ -2357,6 +2395,7 @@
"started": "Baixando {{label}} — acompanhe o progresso em Configurações → Modelos e tente novamente.",
"install_failed": "Não foi possível iniciar o download: {{message}}"
},
"model_missing": {"message":"Este modelo não foi baixado em {{target}}.","download":"Baixar modelo","download_size":"Baixar ({{size}})","started":"Baixando em {{target}} — tente Gerar novamente ao terminar.","failed":"Não foi possível iniciar o download: {{message}}","manual_worker":"Instale este mecanismo diretamente em {{target}}."},
"backend_start_failure": {
"notice": "O backend do VoiceStudio não conseguiu iniciar — nada funcionará até que ele inicie.",
"view": "Ver o motivo",
@@ -2369,5 +2408,21 @@
"notice_unclean": "A sessão anterior terminou sem um encerramento limpo há {{ago}}. Muitas vezes é inofensivo — o computador dormiu, o app foi encerrado à força ou uma VM/contêiner parou.",
"details_intro_unclean": "O marcador de encerramento da execução anterior nunca foi limpo, há {{ago}}. O VoiceStudio não consegue saber se foi uma falha ou apenas uma interrupção — suspender, forçar o encerramento ou parar uma VM/contêiner deixa o mesmo rastro. Se tudo funciona agora, não há nada a fazer.",
"report_needs_log": "Nenhuma saída de erro foi capturada para este evento, então um relatório de um clique ficaria vazio. Se algo estiver mesmo errado, anexe as últimas linhas de Configurações → Logs → Backend a uma nova issue."
},
"gpu": {
"local": "Local",
"picker": "Onde os trabalhos são executados",
"tasks": "tarefas",
"coverage": "apenas {{ops}}",
"opLocal": "Local — {{op}} ainda não é executado remotamente",
"dictationLocal": "O ditado é sempre executado nesta máquina",
"ops": {
"tts": "TTS",
"clone": "clonagem de voz",
"dub": "dublagem",
"audiobook": "renderização de audiolivros",
"longform": "narração de histórias",
"asr": "transcrição"
}
}
}
+58 -3
View File
@@ -180,7 +180,35 @@
"history_retention_cap_hint": "Отмеченные дубли не учитываются при очистке · 0 = без ограничений",
"history_retention_saved": "Лимит хранения сохранён",
"history_retention_save_failed": "Не удалось сохранить",
"history_retention_invalid": "Введите 0 или больше"
"history_retention_invalid": "Введите 0 или больше",
"workers_title": "Удалённые воркеры",
"workers_desc": "Отправляйте отдельные задачи на видеокарты других ваших машин. Результаты возвращаются сюда. Ничего не отправляется, пока вы не добавите воркер и не одобрите его.",
"workers_enable": "Использовать удалённые воркеры",
"workers_enable_hint": "Пока это выключено, соединения не принимаются и ничего не покидает эту машину.",
"workers_port_conflict": "Удалённые исполнители недоступны: другой экземпляр VoiceStudio уже принимает их на этом порту. Закройте другой экземпляр либо задайте другой порт в OMNIVOICE_WORKER_PORT и перезапустите VoiceStudio.",
"workers_endpoint": "Воркеры подключаются к",
"workers_endpoint_hint": "Воркер должен иметь доступ к этому адресу. В разных сетях надёжный способ — VPN, например Tailscale.",
"workers_add": "Добавить воркер",
"workers_add_hint": "Создайте токен и вставьте его в OmniVoice на другой машине.",
"workers_new_token": "Создать токен",
"workers_token_once": "Скопируйте сейчас — он показывается только один раз, работает только один раз и истекает через 15 минут.",
"workers_copy": "Копировать",
"workers_copied": "Скопировано",
"workers_copy_failed": "Не удалось скопировать.",
"workers_none": "Воркеров пока нет. Создайте токен, чтобы добавить первый.",
"workers_load": "Задачи {{active}} / {{slots}}",
"workers_needs_consent": "Не одобрен",
"workers_status_online": "В сети",
"workers_status_offline": "Не в сети",
"workers_status_paused": "Приостановлен",
"workers_status_disabled": "Отключён",
"workers_resume": "Возобновить",
"workers_disable": "Отключить",
"workers_enable_one": "Включить",
"workers_remove": "Удалить",
"workers_remove_title": "Удалить воркер?",
"workers_remove_confirm": "Удалить {{name}}? Его ключ будет отозван, поэтому без нового токена он не подключится.",
"workers_rename": "Переименовать воркер"
},
"bootstrap": {
"title": "VoiceStudio",
@@ -1791,7 +1819,16 @@
"to_download": "скачать",
"cached": "кэшированный",
"fast_download_badge": "быстрая загрузка",
"fast_download_title": "Быстрая загрузка через Xet {{version}} — параллельная поблочная передача"
"fast_download_title": "Быстрая загрузка через Xet {{version}} — параллельная поблочная передача",
"voice_previews": "Превью голосов",
"voice_previews_desc": "Загрузите заранее подготовленные превью, чтобы голоса воспроизводились мгновенно — даже до того, как голосовая модель закончит загружаться. Ничего не загружается, пока вы это не включите.",
"voice_previews_off": "Выключено — превью создаются на этом устройстве",
"voice_previews_ready": "Избранный набор в кэше",
"voice_previews_partial": "В кэше {{cached}} из {{total}} избранных превью",
"voice_previews_checked": "проверено {{when}}",
"voice_previews_never": "ещё не проверялось",
"voice_previews_check": "Проверить сейчас",
"voice_previews_rejected": "Обновление галереи голосов отклонено: {{message}}"
},
"enterprise_faq": {
"q_internal_tools": "Нужна ли мне лицензия на внутренние инструменты?",
@@ -2298,7 +2335,8 @@
"droppedChunks_one": "Внимание: {{count}} фрагмент текста не дал звука, поэтому его нет в этой записи. Попробуйте сгенерировать заново — обычно помогает другое зерно.",
"droppedChunks_other": "Внимание: фрагментов текста без звука: {{count}} — их нет в этой записи. Попробуйте сгенерировать заново, обычно помогает другое зерно.",
"droppedChunksWithText_one": "Внимание: {{count}} фрагмент текста не дал звука, и его нет в этой записи — «{{text}}». Попробуйте сгенерировать заново; обычно помогает другое зерно.",
"droppedChunksWithText_other": "Внимание: фрагментов текста без звука: {{count}} — их нет в этой записи: «{{text}}». Попробуйте сгенерировать заново; обычно помогает другое зерно."
"droppedChunksWithText_other": "Внимание: фрагментов текста без звука: {{count}} — их нет в этой записи: «{{text}}». Попробуйте сгенерировать заново; обычно помогает другое зерно.",
"streamingOffRemote": "Прогрессивное воспроизведение отключено, пока {{label}} выполняет рендеринг — готовая запись начнёт играть, как только придёт."
},
"voiceSelector": {
"engineDefault": "Двигатель по умолчанию",
@@ -2357,6 +2395,7 @@
"started": "Скачивание {{label}} — следите за прогрессом в Настройки → Модели, затем повторите попытку.",
"install_failed": "Не удалось начать загрузку: {{message}}"
},
"model_missing": {"message":"Эта модель не загружена на {{target}}.","download":"Загрузить модель","download_size":"Загрузить ({{size}})","started":"Загрузка на {{target}} началась — после завершения повторите генерацию.","failed":"Не удалось начать загрузку: {{message}}","manual_worker":"Установите этот движок непосредственно на {{target}}."},
"backend_start_failure": {
"notice": "Бэкенд VoiceStudio не смог запуститься — до этого ничего работать не будет.",
"view": "Посмотреть причину",
@@ -2369,5 +2408,21 @@
"notice_unclean": "Предыдущая сессия завершилась без корректного выхода {{ago}} назад. Часто это безобидно — компьютер уснул, приложение закрыли принудительно или остановилась ВМ/контейнер.",
"details_intro_unclean": "Маркер завершения предыдущего запуска так и не был снят, {{ago}} назад. VoiceStudio не может определить, был ли это сбой или просто прерывание — сон, принудительное закрытие или остановка ВМ/контейнера оставляют одинаковый след. Если сейчас всё работает, делать ничего не нужно.",
"report_needs_log": "Для этого события не захвачен вывод ошибок, поэтому отчёт в один клик был бы пустым. Если что-то действительно сломано, приложите последние строки из Настройки → Журналы → Бэкенд к новому issue."
},
"gpu": {
"local": "Локально",
"picker": "Где выполняются задачи",
"tasks": "задачи",
"coverage": "только {{ops}}",
"opLocal": "Локально — {{op}} пока не выполняется удалённо",
"dictationLocal": "Диктовка всегда выполняется на этом компьютере",
"ops": {
"tts": "TTS",
"clone": "клонирование голоса",
"dub": "дубляж",
"audiobook": "рендеринг аудиокниг",
"longform": "озвучивание историй",
"asr": "расшифровка"
}
}
}
+58 -3
View File
@@ -180,7 +180,35 @@
"history_retention_cap_hint": "Stjärnmärkta tagningar räknas aldrig vid rensning · 0 = obegränsat",
"history_retention_saved": "Lagringsgräns sparad",
"history_retention_save_failed": "Kunde inte spara",
"history_retention_invalid": "Ange 0 eller mer"
"history_retention_invalid": "Ange 0 eller mer",
"workers_title": "Fjärrarbetare",
"workers_desc": "Skicka enskilda jobb till GPU:er på dina andra maskiner. Resultaten kommer tillbaka hit. Ingenting skickas förrän du lägger till en arbetare och godkänner den.",
"workers_enable": "Använd fjärrarbetare",
"workers_enable_hint": "Medan detta är av accepteras ingen anslutning och ingenting lämnar den här maskinen.",
"workers_port_conflict": "Fjärrarbetare är inte tillgängliga eftersom en annan VoiceStudio-instans redan tar emot dem på den här porten. Stäng den andra instansen eller ställ in OMNIVOICE_WORKER_PORT på en annan port och starta om VoiceStudio.",
"workers_endpoint": "Arbetare ansluter till",
"workers_endpoint_hint": "En arbetare måste kunna nå den här adressen. På olika nätverk är ett VPN som Tailscale det tillförlitliga sättet.",
"workers_add": "Lägg till en arbetare",
"workers_add_hint": "Skapa en token och klistra in den i OmniVoice på den andra maskinen.",
"workers_new_token": "Skapa token",
"workers_token_once": "Kopiera nu — den visas bara en gång, fungerar bara en gång och går ut om 15 minuter.",
"workers_copy": "Kopiera",
"workers_copied": "Kopierad",
"workers_copy_failed": "Kunde inte kopiera.",
"workers_none": "Inga arbetare ännu. Skapa en token för att lägga till din första.",
"workers_load": "Uppgifter {{active}} / {{slots}}",
"workers_needs_consent": "Inte godkänd",
"workers_status_online": "Uppkopplad",
"workers_status_offline": "Frånkopplad",
"workers_status_paused": "Pausad",
"workers_status_disabled": "Inaktiverad",
"workers_resume": "Återuppta",
"workers_disable": "Inaktivera",
"workers_enable_one": "Aktivera",
"workers_remove": "Ta bort",
"workers_remove_title": "Ta bort arbetare?",
"workers_remove_confirm": "Ta bort {{name}}? Nyckeln återkallas, så den kan inte återansluta utan en ny token.",
"workers_rename": "Byt namn på arbetare"
},
"bootstrap": {
"title": "VoiceStudio",
@@ -1791,7 +1819,16 @@
"to_download": "att ladda ner",
"cached": "cachad",
"fast_download_badge": "snabb nedladdning",
"fast_download_title": "Snabba nedladdningar via Xet {{version}} — parallell överföring i bitar"
"fast_download_title": "Snabba nedladdningar via Xet {{version}} — parallell överföring i bitar",
"voice_previews": "Röstförhandsvisningar",
"voice_previews_desc": "Ladda ner förrenderade förhandsvisningar så att röster spelas upp direkt redan innan en röstmodell har laddats ner. Inget laddas ner förrän du slår på det här.",
"voice_previews_off": "Av förhandsvisningar renderas på den här datorn",
"voice_previews_ready": "Utvalda uppsättningen cachad",
"voice_previews_partial": "{{cached}} av {{total}} utvalda förhandsvisningar cachade",
"voice_previews_checked": "kontrollerat {{when}}",
"voice_previews_never": "inte kontrollerat än",
"voice_previews_check": "Kontrollera nu",
"voice_previews_rejected": "Uppdatering av röstgalleriet avvisades: {{message}}"
},
"enterprise_faq": {
"q_internal_tools": "Behöver jag en licens för interna verktyg?",
@@ -2298,7 +2335,8 @@
"droppedChunks_one": "Obs: {{count}} del av din text gav inget ljud, så den saknas i den här tagningen. Generera om — ett annat frö brukar lösa det.",
"droppedChunks_other": "Obs: {{count}} delar av din text gav inget ljud, så de saknas i den här tagningen. Generera om — ett annat frö brukar lösa det.",
"droppedChunksWithText_one": "Obs: {{count}} del av din text gav inget ljud och saknas i den här tagningen — ”{{text}}”. Generera om; ett annat frö brukar lösa det.",
"droppedChunksWithText_other": "Obs: {{count}} delar av din text gav inget ljud och saknas i den här tagningen — ”{{text}}”. Generera om; ett annat frö brukar lösa det."
"droppedChunksWithText_other": "Obs: {{count}} delar av din text gav inget ljud och saknas i den här tagningen — ”{{text}}”. Generera om; ett annat frö brukar lösa det.",
"streamingOffRemote": "Progressiv förhandsvisning är av medan {{label}} renderar — den färdiga tagningen spelas upp så snart den kommer."
},
"voiceSelector": {
"engineDefault": "Standard för motor",
@@ -2357,6 +2395,7 @@
"started": "Laddar ner {{label}} — följ förloppet i Inställningar → Modeller och försök sedan igen.",
"install_failed": "Kunde inte starta nedladdningen: {{message}}"
},
"model_missing": {"message":"Modellen har inte hämtats på {{target}}.","download":"Hämta modell","download_size":"Hämta ({{size}})","started":"Hämtar på {{target}} — försök generera igen när det är klart.","failed":"Det gick inte att starta hämtningen: {{message}}","manual_worker":"Installera denna motor direkt på {{target}}."},
"backend_start_failure": {
"notice": "VoiceStudios backend kunde inte starta — inget fungerar förrän den gör det.",
"view": "Se varför",
@@ -2369,5 +2408,21 @@
"notice_unclean": "Föregående session avslutades utan en ren avstängning för {{ago}} sedan. Det är ofta ofarligt — datorn sov, appen tvångsavslutades eller en VM/container stoppades.",
"details_intro_unclean": "Föregående körnings avstängningsmarkör rensades aldrig, för {{ago}} sedan. VoiceStudio kan inte avgöra om det var en krasch eller bara ett avbrott — vila, tvångsavslut eller att stoppa en VM/container lämnar samma spår. Om allt fungerar nu behöver inget göras.",
"report_needs_log": "Ingen felutdata fångades för denna händelse, så en ett-klicks-rapport vore tom. Om något faktiskt är fel, bifoga de sista raderna från Inställningar → Loggar → Backend i ett nytt ärende."
},
"gpu": {
"local": "Lokalt",
"picker": "Var jobben körs",
"tasks": "uppgifter",
"coverage": "endast {{ops}}",
"opLocal": "Lokalt — {{op}} körs inte på distans än",
"dictationLocal": "Diktering körs alltid på den här datorn",
"ops": {
"tts": "TTS",
"clone": "röstkloning",
"dub": "dubbning",
"audiobook": "ljudboksrendering",
"longform": "berättarröst",
"asr": "transkribering"
}
}
}
+58 -3
View File
@@ -180,7 +180,35 @@
"history_retention_cap_hint": "เทคที่ติดดาวไม่ถูกนับในการล้าง · 0 = ไม่จำกัด",
"history_retention_saved": "บันทึกขีดจำกัดการเก็บแล้ว",
"history_retention_save_failed": "บันทึกไม่สำเร็จ",
"history_retention_invalid": "กรอก 0 ขึ้นไป"
"history_retention_invalid": "กรอก 0 ขึ้นไป",
"workers_title": "ผู้ปฏิบัติงานระยะไกล",
"workers_desc": "ส่งงานแต่ละชิ้นไปยัง GPU บนเครื่องอื่นของคุณ ผลลัพธ์จะกลับมาที่นี่ จะไม่มีการส่งข้อมูลใดจนกว่าคุณจะเพิ่มผู้ปฏิบัติงานและอนุมัติ",
"workers_enable": "ใช้ผู้ปฏิบัติงานระยะไกล",
"workers_enable_hint": "ขณะที่ปิดอยู่ จะไม่รับการเชื่อมต่อใด และไม่มีสิ่งใดออกจากเครื่องนี้",
"workers_port_conflict": "ไม่สามารถใช้เครื่องทำงานระยะไกลได้ เนื่องจาก VoiceStudio อีกอินสแตนซ์กำลังรับการเชื่อมต่อที่พอร์ตนี้อยู่แล้ว ปิดอินสแตนซ์นั้น หรือตั้งค่า OMNIVOICE_WORKER_PORT เป็นพอร์ตอื่นแล้วเริ่ม VoiceStudio ใหม่",
"workers_endpoint": "ผู้ปฏิบัติงานเชื่อมต่อไปที่",
"workers_endpoint_hint": "ผู้ปฏิบัติงานต้องเข้าถึงที่อยู่นี้ได้ หากอยู่คนละเครือข่าย VPN เช่น Tailscale เป็นวิธีที่เชื่อถือได้",
"workers_add": "เพิ่มผู้ปฏิบัติงาน",
"workers_add_hint": "สร้างโทเค็น แล้ววางลงใน OmniVoice บนเครื่องอีกเครื่อง",
"workers_new_token": "สร้างโทเค็น",
"workers_token_once": "คัดลอกตอนนี้ — แสดงเพียงครั้งเดียว ใช้ได้ครั้งเดียว และหมดอายุใน 15 นาที",
"workers_copy": "คัดลอก",
"workers_copied": "คัดลอกแล้ว",
"workers_copy_failed": "คัดลอกไม่สำเร็จ",
"workers_none": "ยังไม่มีผู้ปฏิบัติงาน สร้างโทเค็นเพื่อเพิ่มเครื่องแรก",
"workers_load": "งาน {{active}} / {{slots}}",
"workers_needs_consent": "ยังไม่อนุมัติ",
"workers_status_online": "ออนไลน์",
"workers_status_offline": "ออฟไลน์",
"workers_status_paused": "หยุดชั่วคราว",
"workers_status_disabled": "ปิดใช้งาน",
"workers_resume": "ดำเนินต่อ",
"workers_disable": "ปิดใช้งาน",
"workers_enable_one": "เปิดใช้งาน",
"workers_remove": "ลบ",
"workers_remove_title": "ลบผู้ปฏิบัติงานหรือไม่",
"workers_remove_confirm": "ลบ {{name}} หรือไม่ คีย์จะถูกเพิกถอน จึงเชื่อมต่อใหม่ไม่ได้หากไม่มีโทเค็นใหม่",
"workers_rename": "เปลี่ยนชื่อผู้ปฏิบัติงาน"
},
"bootstrap": {
"title": "VoiceStudio",
@@ -1791,7 +1819,16 @@
"to_download": "เพื่อดาวน์โหลด",
"cached": "แคชไว้",
"fast_download_badge": "ดาวน์โหลดอย่างรวดเร็ว",
"fast_download_title": "ดาวน์โหลดอย่างรวดเร็วผ่าน Xet {{version}} — การถ่ายโอนแบบขนาน"
"fast_download_title": "ดาวน์โหลดอย่างรวดเร็วผ่าน Xet {{version}} — การถ่ายโอนแบบขนาน",
"voice_previews": "ตัวอย่างเสียง",
"voice_previews_desc": "ดาวน์โหลดตัวอย่างที่เรนเดอร์ไว้ล่วงหน้า เพื่อให้เสียงเล่นได้ทันที แม้ก่อนที่โมเดลเสียงจะดาวน์โหลดเสร็จ จะไม่มีการดาวน์โหลดใด ๆ จนกว่าคุณจะเปิดใช้งาน",
"voice_previews_off": "ปิด — ตัวอย่างจะถูกเรนเดอร์บนเครื่องนี้",
"voice_previews_ready": "แคชชุดแนะนำแล้ว",
"voice_previews_partial": "แคชตัวอย่างแนะนำแล้ว {{cached}} จาก {{total}}",
"voice_previews_checked": "ตรวจสอบเมื่อ {{when}}",
"voice_previews_never": "ยังไม่ได้ตรวจสอบ",
"voice_previews_check": "ตรวจสอบตอนนี้",
"voice_previews_rejected": "ปฏิเสธการอัปเดตคลังเสียง: {{message}}"
},
"enterprise_faq": {
"q_internal_tools": "ฉันจำเป็นต้องมีใบอนุญาตสำหรับเครื่องมือภายในหรือไม่?",
@@ -2298,7 +2335,8 @@
"droppedChunks_one": "โปรดทราบ: ข้อความของคุณ {{count}} ส่วนไม่ได้สร้างเสียง ไฟล์นี้จึงขาดส่วนนั้นไป ลองสร้างใหม่ — โดยปกติการเปลี่ยน seed จะแก้ได้",
"droppedChunks_other": "โปรดทราบ: ข้อความของคุณ {{count}} ส่วนไม่ได้สร้างเสียง ไฟล์นี้จึงขาดส่วนเหล่านั้นไป ลองสร้างใหม่ — โดยปกติการเปลี่ยน seed จะแก้ได้",
"droppedChunksWithText_one": "โปรดทราบ: ข้อความของคุณ {{count}} ส่วนไม่ได้สร้างเสียงและหายไปจากไฟล์นี้ — “{{text}}” ลองสร้างใหม่ โดยปกติการเปลี่ยน seed จะแก้ได้",
"droppedChunksWithText_other": "โปรดทราบ: ข้อความของคุณ {{count}} ส่วนไม่ได้สร้างเสียงและหายไปจากไฟล์นี้ — “{{text}}” ลองสร้างใหม่ โดยปกติการเปลี่ยน seed จะแก้ได้"
"droppedChunksWithText_other": "โปรดทราบ: ข้อความของคุณ {{count}} ส่วนไม่ได้สร้างเสียงและหายไปจากไฟล์นี้ — “{{text}}” ลองสร้างใหม่ โดยปกติการเปลี่ยน seed จะแก้ได้",
"streamingOffRemote": "ปิดการแสดงตัวอย่างแบบต่อเนื่องขณะที่ {{label}} กำลังเรนเดอร์ — ไฟล์ที่เสร็จแล้วจะเล่นทันทีที่มาถึง"
},
"voiceSelector": {
"engineDefault": "ค่าเริ่มต้นของเครื่องยนต์",
@@ -2357,6 +2395,7 @@
"started": "กำลังดาวน์โหลด {{label}} — ดูความคืบหน้าได้ที่ การตั้งค่า → โมเดล แล้วลองอีกครั้ง",
"install_failed": "ไม่สามารถเริ่มการดาวน์โหลดได้: {{message}}"
},
"model_missing": {"message":"ยังไม่ได้ดาวน์โหลดโมเดลนี้บน {{target}}","download":"ดาวน์โหลดโมเดล","download_size":"ดาวน์โหลด ({{size}})","started":"กำลังดาวน์โหลดบน {{target}} — เมื่อเสร็จแล้วให้สร้างอีกครั้ง","failed":"เริ่มดาวน์โหลดไม่ได้: {{message}}","manual_worker":"ติดตั้งเอนจินนี้โดยตรงบน {{target}}"},
"backend_start_failure": {
"notice": "แบ็กเอนด์ของ VoiceStudio เริ่มทำงานไม่ได้ — จะยังใช้งานอะไรไม่ได้จนกว่าจะเริ่มได้",
"view": "ดูสาเหตุ",
@@ -2369,5 +2408,21 @@
"notice_unclean": "เซสชันก่อนหน้าจบลงโดยไม่ได้ปิดอย่างถูกต้องเมื่อ {{ago}} ที่แล้ว ซึ่งมักไม่มีอันตราย — เครื่องเข้าสู่โหมดสลีป แอปถูกบังคับปิด หรือ VM/คอนเทนเนอร์หยุดทำงาน",
"details_intro_unclean": "เครื่องหมายการปิดของการทำงานครั้งก่อนไม่เคยถูกล้างเมื่อ {{ago}} ที่แล้ว VoiceStudio ไม่สามารถบอกได้ว่าเป็นการแครชหรือเพียงการขัดจังหวะ — การสลีป การบังคับปิด หรือการหยุด VM/คอนเทนเนอร์ทิ้งร่องรอยแบบเดียวกัน หากตอนนี้ทุกอย่างทำงานปกติ ก็ไม่ต้องทำอะไร",
"report_needs_log": "ไม่มีเอาต์พุตข้อผิดพลาดถูกบันทึกไว้สำหรับเหตุการณ์นี้ รายงานแบบคลิกเดียวจึงจะว่างเปล่า หากมีปัญหาจริง โปรดแนบบรรทัดสุดท้ายจาก การตั้งค่า → บันทึก → แบ็กเอนด์ ในการแจ้งปัญหาใหม่"
},
"gpu": {
"local": "ในเครื่อง",
"picker": "ตำแหน่งที่งานทำงาน",
"tasks": "งาน",
"coverage": "เฉพาะ {{ops}}",
"opLocal": "ในเครื่อง — {{op}} ยังไม่ทำงานบนเครื่องระยะไกล",
"dictationLocal": "การป้อนตามคำบอกจะทำงานบนเครื่องนี้เสมอ",
"ops": {
"tts": "TTS",
"clone": "การโคลนเสียง",
"dub": "การพากย์",
"audiobook": "การเรนเดอร์หนังสือเสียง",
"longform": "การบรรยายเรื่อง",
"asr": "การถอดเสียง"
}
}
}
+58 -3
View File
@@ -180,7 +180,35 @@
"history_retention_cap_hint": "Yıldızlı kayıtlar temizlikte sayılmaz · 0 = sınırsız",
"history_retention_saved": "Saklama sınırı kaydedildi",
"history_retention_save_failed": "Kaydedilemedi",
"history_retention_invalid": "0 veya daha büyük bir değer girin"
"history_retention_invalid": "0 veya daha büyük bir değer girin",
"workers_title": "Uzak işçiler",
"workers_desc": "Tek tek işleri diğer makinelerinizdeki GPU'lara gönderin. Sonuçlar buraya döner. Bir işçi ekleyip onaylayana kadar hiçbir şey gönderilmez.",
"workers_enable": "Uzak işçileri kullan",
"workers_enable_hint": "Bu kapalıyken hiçbir bağlantı kabul edilmez ve bu makineden hiçbir şey çıkmaz.",
"workers_port_conflict": "Başka bir VoiceStudio örneği bu bağlantı noktasında uzak çalışanları zaten kabul ettiği için uzak çalışanlar kullanılamıyor. Diğer örneği kapatın veya OMNIVOICE_WORKER_PORT değerini farklı bir bağlantı noktası yapıp VoiceStudio'yu yeniden başlatın.",
"workers_endpoint": "İşçilerin bağlanacağı adres",
"workers_endpoint_hint": "Bir işçinin bu adrese erişebilmesi gerekir. Farklı ağlarda Tailscale gibi bir VPN güvenilir yoldur.",
"workers_add": "İşçi ekle",
"workers_add_hint": "Bir belirteç oluşturun ve diğer makinedeki OmniVoice'a yapıştırın.",
"workers_new_token": "Belirteç oluştur",
"workers_token_once": "Şimdi kopyalayın — yalnızca bir kez gösterilir, yalnızca bir kez çalışır ve 15 dakika içinde sona erer.",
"workers_copy": "Kopyala",
"workers_copied": "Kopyalandı",
"workers_copy_failed": "Kopyalanamadı.",
"workers_none": "Henüz işçi yok. İlkini eklemek için bir belirteç oluşturun.",
"workers_load": "Görevler {{active}} / {{slots}}",
"workers_needs_consent": "Onaylanmadı",
"workers_status_online": "Çevrimiçi",
"workers_status_offline": "Çevrimdışı",
"workers_status_paused": "Duraklatıldı",
"workers_status_disabled": "Devre dışı",
"workers_resume": "Sürdür",
"workers_disable": "Devre dışı bırak",
"workers_enable_one": "Etkinleştir",
"workers_remove": "Kaldır",
"workers_remove_title": "İşçi kaldırılsın mı?",
"workers_remove_confirm": "{{name}} kaldırılsın mı? Anahtarı iptal edilir, bu yüzden yeni bir belirteç olmadan yeniden bağlanamaz.",
"workers_rename": "İşçiyi yeniden adlandır"
},
"bootstrap": {
"title": "VoiceStudio",
@@ -1791,7 +1819,16 @@
"to_download": "indirmek için",
"cached": "önbelleğe alınmış",
"fast_download_badge": "hızlı indirme",
"fast_download_title": "Xet {{version}} aracılığıyla hızlı indirmeler — paralel yığın halinde aktarım"
"fast_download_title": "Xet {{version}} aracılığıyla hızlı indirmeler — paralel yığın halinde aktarım",
"voice_previews": "Ses önizlemeleri",
"voice_previews_desc": "Önceden oluşturulmuş önizlemeleri indirin; böylece sesler, bir ses modeli inmeyi bitirmeden önce bile anında çalar. Bunu açana kadar hiçbir şey indirilmez.",
"voice_previews_off": "Kapalı — önizlemeler bu cihazda oluşturulur",
"voice_previews_ready": "Öne çıkan set önbellekte",
"voice_previews_partial": "{{total}} öne çıkan önizlemeden {{cached}} tanesi önbellekte",
"voice_previews_checked": "{{when}} kontrol edildi",
"voice_previews_never": "henüz kontrol edilmedi",
"voice_previews_check": "Şimdi kontrol et",
"voice_previews_rejected": "Ses galerisi güncellemesi reddedildi: {{message}}"
},
"enterprise_faq": {
"q_internal_tools": "Dahili araçlar için lisansa ihtiyacım var mı?",
@@ -2298,7 +2335,8 @@
"droppedChunks_one": "Dikkat: Metninizin {{count}} bölümü ses üretmedi, bu nedenle bu kayıtta eksik. Yeniden oluşturmayı deneyin — genellikle farklı bir tohum sorunu çözer.",
"droppedChunks_other": "Dikkat: Metninizin {{count}} bölümü ses üretmedi, bu nedenle bu kayıtta eksikler. Yeniden oluşturmayı deneyin — genellikle farklı bir tohum sorunu çözer.",
"droppedChunksWithText_one": "Dikkat: Metninizin {{count}} bölümü ses üretmedi ve bu kayıtta eksik — “{{text}}”. Yeniden oluşturmayı deneyin; genellikle farklı bir tohum sorunu çözer.",
"droppedChunksWithText_other": "Dikkat: Metninizin {{count}} bölümü ses üretmedi ve bu kayıtta eksikler — “{{text}}”. Yeniden oluşturmayı deneyin; genellikle farklı bir tohum sorunu çözer."
"droppedChunksWithText_other": "Dikkat: Metninizin {{count}} bölümü ses üretmedi ve bu kayıtta eksikler — “{{text}}”. Yeniden oluşturmayı deneyin; genellikle farklı bir tohum sorunu çözer.",
"streamingOffRemote": "{{label}} işlerken aşamalı önizleme kapalıdır — tamamlanan kayıt geldiği anda çalınır."
},
"voiceSelector": {
"engineDefault": "Motor varsayılanı",
@@ -2357,6 +2395,7 @@
"started": "{{label}} indiriliyor — ilerlemeyi Ayarlar → Modeller'den izleyin, sonra yeniden deneyin.",
"install_failed": "İndirme başlatılamadı: {{message}}"
},
"model_missing": {"message":"Bu model {{target}} üzerine indirilmemiş.","download":"Modeli indir","download_size":"İndir ({{size}})","started":"{{target}} üzerine indiriliyor — bitince yeniden Oluştur'a basın.","failed":"İndirme başlatılamadı: {{message}}","manual_worker":"Bu motoru doğrudan {{target}} üzerine kurun."},
"backend_start_failure": {
"notice": "VoiceStudio arka ucu başlatılamadı — başlayana kadar hiçbir şey çalışmaz.",
"view": "Nedenini gör",
@@ -2369,5 +2408,21 @@
"notice_unclean": "Önceki oturum {{ago}} önce düzgün kapanmadan sona erdi. Bu çoğu zaman zararsızdır — makine uykuya geçti, uygulama zorla kapatıldı veya bir VM/kapsayıcı durduruldu.",
"details_intro_unclean": "Önceki çalıştırmanın kapanış işareti {{ago}} önce hiç temizlenmedi. VoiceStudio bunun bir çökme mi yoksa yalnızca bir kesinti mi olduğunu bilemez — uyku, zorla kapatma veya bir VM/kapsayıcıyı durdurma aynı izi bırakır. Şu anda her şey çalışıyorsa yapılacak bir şey yok.",
"report_needs_log": "Bu olay için hata çıktısı yakalanmadı; tek tıkla rapor boş olurdu. Gerçekten bir sorun varsa lütfen Ayarlar → Günlükler → Arka uç'in son satırlarını yeni bir issue'ya ekleyin."
},
"gpu": {
"local": "Yerel",
"picker": "İşlerin çalıştığı yer",
"tasks": "görev",
"coverage": "yalnızca {{ops}}",
"opLocal": "Yerel — {{op}} henüz uzaktan çalışmıyor",
"dictationLocal": "Dikte her zaman bu makinede çalışır",
"ops": {
"tts": "TTS",
"clone": "ses klonlama",
"dub": "dublaj",
"audiobook": "sesli kitap işleme",
"longform": "hikâye seslendirme",
"asr": "deşifre"
}
}
}
+58 -3
View File
@@ -180,7 +180,35 @@
"history_retention_cap_hint": "Дублі із зіркою не враховуються під час очищення · 0 = без обмежень",
"history_retention_saved": "Ліміт зберігання збережено",
"history_retention_save_failed": "Не вдалося зберегти",
"history_retention_invalid": "Введіть 0 або більше"
"history_retention_invalid": "Введіть 0 або більше",
"workers_title": "Віддалені воркери",
"workers_desc": "Надсилайте окремі завдання на відеокарти інших ваших машин. Результати повертаються сюди. Нічого не надсилається, доки ви не додасте воркер і не схвалите його.",
"workers_enable": "Використовувати віддалені воркери",
"workers_enable_hint": "Поки це вимкнено, з'єднання не приймаються і ніщо не залишає цю машину.",
"workers_port_conflict": "Віддалені виконавці недоступні: інший екземпляр VoiceStudio уже приймає їх на цьому порту. Закрийте інший екземпляр або задайте інший порт у OMNIVOICE_WORKER_PORT і перезапустіть VoiceStudio.",
"workers_endpoint": "Воркери підключаються до",
"workers_endpoint_hint": "Воркер має мати доступ до цієї адреси. У різних мережах надійний спосіб — VPN, наприклад Tailscale.",
"workers_add": "Додати воркер",
"workers_add_hint": "Створіть токен і вставте його в OmniVoice на іншій машині.",
"workers_new_token": "Створити токен",
"workers_token_once": "Скопіюйте зараз — він показується лише раз, працює лише раз і спливає через 15 хвилин.",
"workers_copy": "Копіювати",
"workers_copied": "Скопійовано",
"workers_copy_failed": "Не вдалося скопіювати.",
"workers_none": "Воркерів ще немає. Створіть токен, щоб додати перший.",
"workers_load": "Завдання {{active}} / {{slots}}",
"workers_needs_consent": "Не схвалено",
"workers_status_online": "У мережі",
"workers_status_offline": "Не в мережі",
"workers_status_paused": "Призупинено",
"workers_status_disabled": "Вимкнено",
"workers_resume": "Відновити",
"workers_disable": "Вимкнути",
"workers_enable_one": "Увімкнути",
"workers_remove": "Видалити",
"workers_remove_title": "Видалити воркер?",
"workers_remove_confirm": "Видалити {{name}}? Його ключ буде відкликано, тож без нового токена він не підключиться.",
"workers_rename": "Перейменувати воркер"
},
"bootstrap": {
"title": "VoiceStudio",
@@ -1791,7 +1819,16 @@
"to_download": "завантажити",
"cached": "кешується",
"fast_download_badge": "швидке завантаження",
"fast_download_title": "Швидке завантаження через Xet {{version}} — паралельна передача фрагментів"
"fast_download_title": "Швидке завантаження через Xet {{version}} — паралельна передача фрагментів",
"voice_previews": "Зразки голосів",
"voice_previews_desc": "Завантажте заздалегідь створені зразки, щоб голоси відтворювалися миттєво — навіть до того, як голосова модель завершить завантаження. Нічого не завантажується, доки ви це не увімкнете.",
"voice_previews_off": "Вимкнено — зразки створюються на цьому пристрої",
"voice_previews_ready": "Добірку збережено в кеші",
"voice_previews_partial": "У кеші {{cached}} із {{total}} вибраних зразків",
"voice_previews_checked": "перевірено {{when}}",
"voice_previews_never": "ще не перевірялося",
"voice_previews_check": "Перевірити зараз",
"voice_previews_rejected": "Оновлення галереї голосів відхилено: {{message}}"
},
"enterprise_faq": {
"q_internal_tools": "Чи потрібна мені ліцензія на внутрішні інструменти?",
@@ -2298,7 +2335,8 @@
"droppedChunks_one": "Увага: {{count}} фрагмент тексту не дав звуку, тому його немає в цьому записі. Спробуйте згенерувати ще раз — зазвичай допомагає інше зерно.",
"droppedChunks_other": "Увага: фрагментів тексту без звуку: {{count}} — їх немає в цьому записі. Спробуйте згенерувати ще раз, зазвичай допомагає інше зерно.",
"droppedChunksWithText_one": "Увага: {{count}} фрагмент тексту не дав звуку, і його немає в цьому записі — «{{text}}». Спробуйте згенерувати ще раз; зазвичай допомагає інше зерно.",
"droppedChunksWithText_other": "Увага: фрагментів тексту без звуку: {{count}} — їх немає в цьому записі: «{{text}}». Спробуйте згенерувати ще раз; зазвичай допомагає інше зерно."
"droppedChunksWithText_other": "Увага: фрагментів тексту без звуку: {{count}} — їх немає в цьому записі: «{{text}}». Спробуйте згенерувати ще раз; зазвичай допомагає інше зерно.",
"streamingOffRemote": "Прогресивний попередній перегляд вимкнено, доки {{label}} виконує рендеринг — готовий запис відтвориться, щойно надійде."
},
"voiceSelector": {
"engineDefault": "Двигун за замовчуванням",
@@ -2357,6 +2395,7 @@
"started": "Завантаження {{label}} — стежте за прогресом у Налаштування → Моделі, потім повторіть спробу.",
"install_failed": "Не вдалося розпочати завантаження: {{message}}"
},
"model_missing": {"message":"Цю модель не завантажено на {{target}}.","download":"Завантажити модель","download_size":"Завантажити ({{size}})","started":"Завантаження на {{target}} розпочато — після завершення повторіть генерацію.","failed":"Не вдалося почати завантаження: {{message}}","manual_worker":"Установіть цей рушій безпосередньо на {{target}}."},
"backend_start_failure": {
"notice": "Бекенд VoiceStudio не зміг запуститися — доки він не працює, ніщо не працюватиме.",
"view": "Переглянути причину",
@@ -2369,5 +2408,21 @@
"notice_unclean": "Попередній сеанс завершився без коректного виходу {{ago}} тому. Часто це нешкідливо — комп'ютер заснув, застосунок закрили примусово або зупинилася ВМ/контейнер.",
"details_intro_unclean": "Позначку завершення попереднього запуску так і не було знято, {{ago}} тому. VoiceStudio не може визначити, чи це був збій, чи просто переривання — сон, примусове закриття або зупинка ВМ/контейнера залишають однаковий слід. Якщо зараз усе працює, нічого робити не потрібно.",
"report_needs_log": "Для цієї події не захоплено виводу помилок, тож звіт одним клацанням був би порожнім. Якщо щось справді не так, додайте останні рядки з Налаштування → Журнали → Бекенд до нового issue."
},
"gpu": {
"local": "Локально",
"picker": "Де виконуються завдання",
"tasks": "завдання",
"coverage": "лише {{ops}}",
"opLocal": "Локально — {{op}} поки не виконується віддалено",
"dictationLocal": "Диктування завжди виконується на цьому комп’ютері",
"ops": {
"tts": "TTS",
"clone": "клонування голосу",
"dub": "дубляж",
"audiobook": "рендеринг аудіокниг",
"longform": "озвучення історій",
"asr": "транскрибування"
}
}
}
+58 -3
View File
@@ -180,7 +180,35 @@
"history_retention_cap_hint": "Bản thu gắn sao không bao giờ bị dọn · 0 = không giới hạn",
"history_retention_saved": "Đã lưu giới hạn lưu trữ",
"history_retention_save_failed": "Không thể lưu",
"history_retention_invalid": "Nhập 0 trở lên"
"history_retention_invalid": "Nhập 0 trở lên",
"workers_title": "Máy phụ từ xa",
"workers_desc": "Gửi từng tác vụ tới GPU trên các máy khác của bạn. Kết quả sẽ quay lại đây. Không có gì được gửi đi cho đến khi bạn thêm một máy phụ và phê duyệt nó.",
"workers_enable": "Dùng máy phụ từ xa",
"workers_enable_hint": "Khi tắt, không kết nối nào được chấp nhận và không có gì rời khỏi máy này.",
"workers_port_conflict": "Worker từ xa không khả dụng vì một phiên bản VoiceStudio khác đang nhận chúng trên cổng này. Hãy đóng phiên bản kia, hoặc đặt OMNIVOICE_WORKER_PORT sang cổng khác rồi khởi động lại VoiceStudio.",
"workers_endpoint": "Máy phụ kết nối tới",
"workers_endpoint_hint": "Máy phụ phải truy cập được địa chỉ này. Trên các mạng khác nhau, VPN như Tailscale là cách đáng tin cậy.",
"workers_add": "Thêm máy phụ",
"workers_add_hint": "Tạo mã, rồi dán vào OmniVoice trên máy kia.",
"workers_new_token": "Tạo mã",
"workers_token_once": "Hãy sao chép ngay — mã chỉ hiển thị một lần, chỉ dùng được một lần và hết hạn sau 15 phút.",
"workers_copy": "Sao chép",
"workers_copied": "Đã sao chép",
"workers_copy_failed": "Không thể sao chép.",
"workers_none": "Chưa có máy phụ nào. Tạo mã để thêm máy đầu tiên.",
"workers_load": "Tác vụ {{active}} / {{slots}}",
"workers_needs_consent": "Chưa phê duyệt",
"workers_status_online": "Trực tuyến",
"workers_status_offline": "Ngoại tuyến",
"workers_status_paused": "Đã tạm dừng",
"workers_status_disabled": "Đã tắt",
"workers_resume": "Tiếp tục",
"workers_disable": "Tắt",
"workers_enable_one": "Bật",
"workers_remove": "Xóa",
"workers_remove_title": "Xóa máy phụ?",
"workers_remove_confirm": "Xóa {{name}}? Khóa của nó bị thu hồi, nên không thể kết nối lại nếu không có mã mới.",
"workers_rename": "Đổi tên máy phụ"
},
"bootstrap": {
"title": "VoiceStudio",
@@ -1791,7 +1819,16 @@
"to_download": "để tải xuống",
"cached": "được lưu vào bộ nhớ đệm",
"fast_download_badge": "tải xuống nhanh",
"fast_download_title": "Tải xuống nhanh qua Xet {{version}} — truyền tải song song"
"fast_download_title": "Tải xuống nhanh qua Xet {{version}} — truyền tải song song",
"voice_previews": "Bản nghe thử giọng",
"voice_previews_desc": "Tải các bản nghe thử đã dựng sẵn để giọng phát ngay lập tức — ngay cả trước khi mô hình giọng nói tải xong. Không có gì được tải về cho đến khi bạn bật tùy chọn này.",
"voice_previews_off": "Tắt — bản nghe thử được dựng trên máy này",
"voice_previews_ready": "Đã lưu bộ nổi bật",
"voice_previews_partial": "Đã lưu {{cached}} trong {{total}} bản nghe thử nổi bật",
"voice_previews_checked": "đã kiểm tra {{when}}",
"voice_previews_never": "chưa kiểm tra",
"voice_previews_check": "Kiểm tra ngay",
"voice_previews_rejected": "Bản cập nhật thư viện giọng bị từ chối: {{message}}"
},
"enterprise_faq": {
"q_internal_tools": "Tôi có cần giấy phép cho các công cụ nội bộ không?",
@@ -2298,7 +2335,8 @@
"droppedChunks_one": "Lưu ý: {{count}} phần trong văn bản của bạn không tạo ra âm thanh, nên bản thu này bị thiếu phần đó. Hãy thử tạo lại — thường chỉ cần một seed khác.",
"droppedChunks_other": "Lưu ý: {{count}} phần trong văn bản của bạn không tạo ra âm thanh, nên bản thu này bị thiếu các phần đó. Hãy thử tạo lại — thường chỉ cần một seed khác.",
"droppedChunksWithText_one": "Lưu ý: {{count}} phần trong văn bản của bạn không tạo ra âm thanh và bị thiếu trong bản thu này — “{{text}}”. Hãy thử tạo lại; thường chỉ cần một seed khác.",
"droppedChunksWithText_other": "Lưu ý: {{count}} phần trong văn bản của bạn không tạo ra âm thanh và bị thiếu trong bản thu này — “{{text}}”. Hãy thử tạo lại; thường chỉ cần một seed khác."
"droppedChunksWithText_other": "Lưu ý: {{count}} phần trong văn bản của bạn không tạo ra âm thanh và bị thiếu trong bản thu này — “{{text}}”. Hãy thử tạo lại; thường chỉ cần một seed khác.",
"streamingOffRemote": "Xem trước lũy tiến bị tắt trong khi {{label}} kết xuất — bản hoàn chỉnh sẽ phát ngay khi có."
},
"voiceSelector": {
"engineDefault": "Mặc định công cụ",
@@ -2357,6 +2395,7 @@
"started": "Đang tải {{label}} — theo dõi tiến trình trong Cài đặt → Mô hình, sau đó thử lại.",
"install_failed": "Không thể bắt đầu tải xuống: {{message}}"
},
"model_missing": {"message":"Mô hình này chưa được tải xuống trên {{target}}.","download":"Tải mô hình","download_size":"Tải xuống ({{size}})","started":"Đang tải trên {{target}} — hãy Tạo lại khi hoàn tất.","failed":"Không thể bắt đầu tải xuống: {{message}}","manual_worker":"Cài đặt công cụ này trực tiếp trên {{target}}."},
"backend_start_failure": {
"notice": "Backend của VoiceStudio không khởi động được — sẽ không có gì chạy cho đến khi nó khởi động.",
"view": "Xem lý do",
@@ -2369,5 +2408,21 @@
"notice_unclean": "Phiên trước đã kết thúc mà không tắt đúng cách {{ago}} trước. Điều này thường vô hại — máy ngủ, ứng dụng bị buộc đóng, hoặc VM/container bị dừng.",
"details_intro_unclean": "Dấu tắt của lần chạy trước không bao giờ được xóa, {{ago}} trước. VoiceStudio không thể biết đó là sự cố hay chỉ là gián đoạn — ngủ máy, buộc đóng hoặc dừng VM/container đều để lại dấu vết giống nhau. Nếu hiện tại mọi thứ hoạt động bình thường thì không cần làm gì.",
"report_needs_log": "Không có đầu ra lỗi nào được ghi lại cho sự kiện này, nên báo cáo một cú nhấp sẽ trống. Nếu thực sự có vấn đề, hãy đính kèm những dòng cuối của Cài đặt → Nhật ký → Backend vào issue mới."
},
"gpu": {
"local": "Cục bộ",
"picker": "Nơi tác vụ chạy",
"tasks": "tác vụ",
"coverage": "chỉ {{ops}}",
"opLocal": "Cục bộ — {{op}} chưa chạy từ xa",
"dictationLocal": "Đọc chính tả luôn chạy trên máy này",
"ops": {
"tts": "TTS",
"clone": "nhân bản giọng nói",
"dub": "lồng tiếng",
"audiobook": "kết xuất sách nói",
"longform": "kể chuyện",
"asr": "phiên âm"
}
}
}
+58 -3
View File
@@ -405,7 +405,35 @@
"history_retention_cap_hint": "已加星条目不计入清理 · 0 = 不限",
"history_retention_saved": "保留上限已保存",
"history_retention_save_failed": "保存失败",
"history_retention_invalid": "请输入 0 或更大的数"
"history_retention_invalid": "请输入 0 或更大的数",
"workers_title": "远程工作机",
"workers_desc": "把单个任务发送到你其他机器的 GPU 上,结果会回到这里。在你添加并批准一台工作机之前,不会发送任何内容。",
"workers_enable": "使用远程工作机",
"workers_enable_hint": "此项关闭时,不接受任何连接,也不会有任何内容离开本机。",
"workers_port_conflict": "远程工作节点不可用,因为另一个 VoiceStudio 实例已在此端口接受连接。请关闭另一个实例,或将 OMNIVOICE_WORKER_PORT 设为其他端口并重启 VoiceStudio。",
"workers_endpoint": "工作机连接到",
"workers_endpoint_hint": "工作机必须能访问这个地址。跨网络时,使用 Tailscale 之类的 VPN 是可靠的方式。",
"workers_add": "添加工作机",
"workers_add_hint": "生成令牌,然后粘贴到另一台机器的 OmniVoice 中。",
"workers_new_token": "生成令牌",
"workers_token_once": "请立即复制——它只显示一次、只能使用一次,并在 15 分钟后过期。",
"workers_copy": "复制",
"workers_copied": "已复制",
"workers_copy_failed": "无法复制。",
"workers_none": "还没有工作机。生成令牌以添加第一台。",
"workers_load": "任务 {{active}} / {{slots}}",
"workers_needs_consent": "未批准",
"workers_status_online": "在线",
"workers_status_offline": "离线",
"workers_status_paused": "已暂停",
"workers_status_disabled": "已停用",
"workers_resume": "恢复",
"workers_disable": "停用",
"workers_enable_one": "启用",
"workers_remove": "移除",
"workers_remove_title": "移除工作机?",
"workers_remove_confirm": "移除 {{name}}?其密钥将被吊销,没有新令牌将无法重新连接。",
"workers_rename": "重命名工作机"
},
"about": {
"app": "应用",
@@ -1798,7 +1826,16 @@
"to_download": "下载",
"cached": "缓存的",
"fast_download_badge": "快速下载",
"fast_download_title": "通过 Xet {{version}} 进行快速下载 — 并行分块传输"
"fast_download_title": "通过 Xet {{version}} 进行快速下载 — 并行分块传输",
"voice_previews": "语音试听",
"voice_previews_desc": "下载预先渲染的试听片段,让声音立即播放——甚至在语音模型下载完成之前。在你打开此项之前不会下载任何内容。",
"voice_previews_off": "已关闭 — 试听在本机渲染",
"voice_previews_ready": "精选集已缓存",
"voice_previews_partial": "已缓存 {{total}} 个精选试听中的 {{cached}} 个",
"voice_previews_checked": "{{when}}检查",
"voice_previews_never": "尚未检查",
"voice_previews_check": "立即检查",
"voice_previews_rejected": "语音库更新被拒绝:{{message}}"
},
"enterprise_faq": {
"q_internal_tools": "我需要内部工具的许可证吗?",
@@ -2305,7 +2342,8 @@
"droppedChunks_one": "请注意:文本中有 {{count}} 处没有生成音频,因此这段录音缺少该部分。请重新生成——换一个随机种子通常就能解决。",
"droppedChunks_other": "请注意:文本中有 {{count}} 处没有生成音频,因此这段录音缺少这些部分。请重新生成——换一个随机种子通常就能解决。",
"droppedChunksWithText_one": "请注意:文本中有 {{count}} 处没有生成音频,这段录音缺少该部分——“{{text}}”。请重新生成,换一个随机种子通常就能解决。",
"droppedChunksWithText_other": "请注意:文本中有 {{count}} 处没有生成音频,这段录音缺少这些部分——“{{text}}”。请重新生成,换一个随机种子通常就能解决。"
"droppedChunksWithText_other": "请注意:文本中有 {{count}} 处没有生成音频,这段录音缺少这些部分——“{{text}}”。请重新生成,换一个随机种子通常就能解决。",
"streamingOffRemote": "{{label}} 渲染期间已关闭渐进式预览,完成后会立即播放。"
},
"voiceSelector": {
"engineDefault": "引擎默认",
@@ -2364,6 +2402,7 @@
"started": "正在下载 {{label}} — 请在 设置 → 模型 中查看进度,完成后重试。",
"install_failed": "无法开始下载:{{message}}"
},
"model_missing": {"message":"此模型尚未下载到 {{target}}。","download":"下载模型","download_size":"下载({{size}}","started":"正在 {{target}} 上下载;完成后请再次生成。","failed":"无法开始下载:{{message}}","manual_worker":"请直接在 {{target}} 上安装此引擎。"},
"backend_start_failure": {
"notice": "VoiceStudio 后端未能启动 — 在它启动之前无法运行任何功能。",
"view": "查看原因",
@@ -2376,5 +2415,21 @@
"notice_unclean": "上一个会话在 {{ago}}前未正常关闭就结束了。这通常无害 —— 电脑进入了睡眠、应用被强制退出,或某个虚拟机/容器停止了。",
"details_intro_unclean": "上次运行的关闭标记在 {{ago}}前一直未被清除。VoiceStudio 无法判断那是崩溃还是仅仅被中断 —— 睡眠、强制退出或停止虚拟机/容器都会留下相同的痕迹。如果现在一切正常,则无需处理。",
"report_needs_log": "此事件没有捕获到任何错误输出,因此一键报告将是空的。如果确实有问题,请将 设置 → 日志 → 后端 的最后几行附加到新的 issue 中。"
},
"gpu": {
"local": "本机",
"picker": "任务运行位置",
"tasks": "任务",
"coverage": "仅 {{ops}}",
"opLocal": "本机 — {{op}}尚不支持远程运行",
"dictationLocal": "听写始终在此设备上运行",
"ops": {
"tts": "TTS",
"clone": "声音克隆",
"dub": "视频配音",
"audiobook": "有声书渲染",
"longform": "故事朗读",
"asr": "转写"
}
}
}
+58 -3
View File
@@ -180,7 +180,35 @@
"history_retention_cap_hint": "已加星項目不計入清理 · 0 = 不限",
"history_retention_saved": "保留上限已儲存",
"history_retention_save_failed": "儲存失敗",
"history_retention_invalid": "請輸入 0 或更大的數字"
"history_retention_invalid": "請輸入 0 或更大的數字",
"workers_title": "遠端工作機",
"workers_desc": "把個別工作送到你其他機器的 GPU 上,結果會回到這裡。在你新增並核准一台工作機之前,不會傳送任何內容。",
"workers_enable": "使用遠端工作機",
"workers_enable_hint": "此項關閉時,不接受任何連線,也不會有任何內容離開本機。",
"workers_port_conflict": "遠端工作節點無法使用,因為另一個 VoiceStudio 執行個體已在此連接埠接受連線。請關閉另一個執行個體,或將 OMNIVOICE_WORKER_PORT 設為其他連接埠並重新啟動 VoiceStudio。",
"workers_endpoint": "工作機連線至",
"workers_endpoint_hint": "工作機必須能存取這個位址。跨網路時,使用 Tailscale 之類的 VPN 是可靠的方式。",
"workers_add": "新增工作機",
"workers_add_hint": "產生權杖,然後貼到另一台機器的 OmniVoice 中。",
"workers_new_token": "產生權杖",
"workers_token_once": "請立即複製——它只顯示一次、只能使用一次,並在 15 分鐘後過期。",
"workers_copy": "複製",
"workers_copied": "已複製",
"workers_copy_failed": "無法複製。",
"workers_none": "還沒有工作機。產生權杖以新增第一台。",
"workers_load": "工作 {{active}} / {{slots}}",
"workers_needs_consent": "未核准",
"workers_status_online": "線上",
"workers_status_offline": "離線",
"workers_status_paused": "已暫停",
"workers_status_disabled": "已停用",
"workers_resume": "繼續",
"workers_disable": "停用",
"workers_enable_one": "啟用",
"workers_remove": "移除",
"workers_remove_title": "移除工作機?",
"workers_remove_confirm": "移除 {{name}}?其金鑰將被撤銷,沒有新權杖將無法重新連線。",
"workers_rename": "重新命名工作機"
},
"bootstrap": {
"title": "VoiceStudio",
@@ -1791,7 +1819,16 @@
"to_download": "下載",
"cached": "快取的",
"fast_download_badge": "快速下載",
"fast_download_title": "透過 Xet {{version}} 進行快速下載 — 平行分塊傳輸"
"fast_download_title": "透過 Xet {{version}} 進行快速下載 — 平行分塊傳輸",
"voice_previews": "語音試聽",
"voice_previews_desc": "下載預先算好的試聽片段,讓聲音立即播放——甚至在語音模型下載完成之前。在你開啟此項之前不會下載任何內容。",
"voice_previews_off": "已關閉 — 試聽在本機算繪",
"voice_previews_ready": "精選集已快取",
"voice_previews_partial": "已快取 {{total}} 個精選試聽中的 {{cached}} 個",
"voice_previews_checked": "{{when}}檢查",
"voice_previews_never": "尚未檢查",
"voice_previews_check": "立即檢查",
"voice_previews_rejected": "語音庫更新遭拒:{{message}}"
},
"enterprise_faq": {
"q_internal_tools": "我需要內部工具的授權嗎?",
@@ -2298,7 +2335,8 @@
"droppedChunks_one": "請注意:文字中有 {{count}} 處未產生音訊,因此這段錄音缺少該部分。請重新產生——換一個隨機種子通常就能解決。",
"droppedChunks_other": "請注意:文字中有 {{count}} 處未產生音訊,因此這段錄音缺少這些部分。請重新產生——換一個隨機種子通常就能解決。",
"droppedChunksWithText_one": "請注意:文字中有 {{count}} 處未產生音訊,這段錄音缺少該部分——「{{text}}」。請重新產生,換一個隨機種子通常就能解決。",
"droppedChunksWithText_other": "請注意:文字中有 {{count}} 處未產生音訊,這段錄音缺少這些部分——「{{text}}」。請重新產生,換一個隨機種子通常就能解決。"
"droppedChunksWithText_other": "請注意:文字中有 {{count}} 處未產生音訊,這段錄音缺少這些部分——「{{text}}」。請重新產生,換一個隨機種子通常就能解決。",
"streamingOffRemote": "{{label}} 算繪期間已關閉漸進式預覽,完成後會立即播放。"
},
"voiceSelector": {
"engineDefault": "引擎預設",
@@ -2357,6 +2395,7 @@
"started": "正在下載 {{label}} — 請在 設定 → 模型 中查看進度,完成後再試一次。",
"install_failed": "無法開始下載:{{message}}"
},
"model_missing": {"message":"此模型尚未下載到 {{target}}。","download":"下載模型","download_size":"下載({{size}}","started":"正在 {{target}} 上下載;完成後請再次產生。","failed":"無法開始下載:{{message}}","manual_worker":"請直接在 {{target}} 上安裝此引擎。"},
"backend_start_failure": {
"notice": "VoiceStudio 後端無法啟動 — 在它啟動之前無法執行任何功能。",
"view": "查看原因",
@@ -2369,5 +2408,21 @@
"notice_unclean": "上一個工作階段在 {{ago}}前未正常關閉就結束了。這通常無害 —— 電腦進入了睡眠、應用程式被強制結束,或某個虛擬機/容器停止了。",
"details_intro_unclean": "上次執行的關閉標記在 {{ago}}前一直未被清除。VoiceStudio 無法判斷那是當機還是僅僅被中斷 —— 睡眠、強制結束或停止虛擬機/容器都會留下相同的痕跡。如果現在一切正常,則無需處理。",
"report_needs_log": "此事件沒有擷取到任何錯誤輸出,因此一鍵回報將是空的。如果確實有問題,請將 設定 → 記錄 → 後端 的最後幾行附加到新的 issue 中。"
},
"gpu": {
"local": "本機",
"picker": "工作執行位置",
"tasks": "工作",
"coverage": "僅 {{ops}}",
"opLocal": "本機 — {{op}}尚未支援遠端執行",
"dictationLocal": "聽寫一律在此裝置上執行",
"ops": {
"tts": "TTS",
"clone": "聲音複製",
"dub": "影片配音",
"audiobook": "有聲書算繪",
"longform": "故事朗讀",
"asr": "轉錄"
}
}
}
+3
View File
@@ -25,6 +25,7 @@ import StorageUsagePanel from '../components/settings/StorageUsagePanel';
import HFMirrorPanel from '../components/settings/HFMirrorPanel';
import SharingPanel from '../components/settings/SharingPanel';
import RemoteBackendPanel from '../components/settings/RemoteBackendPanel';
import WorkersPanel from '../components/settings/WorkersPanel';
import MCPBindingsPanel from '../components/settings/MCPBindingsPanel';
import OpenApiPanel from '../components/settings/OpenApiPanel';
import PronunciationPanel from '../components/settings/PronunciationPanel';
@@ -422,6 +423,8 @@ export default function Settings() {
<MCPBindingsPanel />
</>
);
case 'workers':
return <WorkersPanel />;
case 'openapi':
return <OpenApiPanel />;
case 'credentials':
+13 -4
View File
@@ -53,7 +53,7 @@ export default function VoiceGallery() {
// detour this page used to carry its own copy of that logic). The claim
// routes to the global mini-player with the voice name as its label, and
// starting any other playback stops this one (#316).
const playUrl = async (fullUrl, id, label) => {
const playUrl = async (fullUrl, id, label, fallbackUrl = null) => {
if (playingId === id) {
stopActivePlayback(); // onDone('stopped') below resets playingId
return;
@@ -69,8 +69,15 @@ export default function VoiceGallery() {
setPlayingId(id);
await playBlobAudio(blob, {
label,
// ended / stopped / error all mean "this card is no longer playing".
onDone: () => setPlayingId((cur) => (cur === id ? null : cur)),
// A present, verified gallery file can still be undecodable. The media
// element reports that asynchronously, so retry from the backend's
// explicit local-render path here rather than leaving a silent card.
onDone: (reason) => {
setPlayingId((cur) => (cur === id ? null : cur));
if (reason === 'error' && fallbackUrl) {
void playUrl(fallbackUrl, id, label);
}
},
});
} catch (e) {
setPlayingId((cur) => (cur === id ? null : cur));
@@ -148,7 +155,9 @@ export default function VoiceGallery() {
setViewMode={setViewMode}
playingId={playingId}
loadingPreviewId={loadingPreviewId}
onPreview={(a) => playUrl(archetypePreviewUrl(a.id), a.id, a.name)}
onPreview={(a) =>
playUrl(archetypePreviewUrl(a.id), a.id, a.name, archetypePreviewUrl(a.id, true))
}
onUse={async (a) => {
try {
// eslint-disable-next-line react-hooks/rules-of-hooks -- useArchetypeAsProfile is an API call, not a React hook
@@ -0,0 +1,58 @@
import React from 'react';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
const { playBlobAudio } = vi.hoisted(() => ({ playBlobAudio: vi.fn() }));
vi.mock('../utils/media', () => ({ playBlobAudio }));
vi.mock('../utils/playback', () => ({ stopActivePlayback: vi.fn() }));
vi.mock('../api/archetypes', () => ({
archetypePreviewUrl: (id, local = false) =>
`http://backend/archetypes/${id}/preview${local ? '?local=true' : ''}`,
useArchetypeAsProfile: vi.fn(),
}));
vi.mock('../api/gallery', () => ({ previewVoiceUrl: vi.fn() }));
vi.mock('../api/client', () => ({ apiUrl: (url) => url }));
vi.mock('../store', () => ({
useAppStore: (selector) =>
selector({
galleryZone: 'archetypes', setGalleryZone: vi.fn(), archetypeFilters: {},
setArchetypeFilter: vi.fn(), resetArchetypeFilters: vi.fn(),
favoriteArchetypeIds: [], toggleFavoriteArchetype: vi.fn(),
galleryViewMode: 'grid', setGalleryViewMode: vi.fn(), setMode: vi.fn(),
setDefineMethod: vi.fn(), setPendingProfileId: vi.fn(), setInstruct: vi.fn(),
setVdStates: vi.fn(), vdStates: {},
}),
}));
vi.mock('../components/gallery/ArchetypesZone', () => ({
default: ({ onPreview }) => (
<button onClick={() => onPreview({ id: 'voice-1', name: 'Voice One' })}>Preview</button>
),
}));
vi.mock('../components/gallery/CommunityZone', () => ({ default: () => null }));
vi.mock('../components/gallery/ImportsZone', () => ({ default: () => null }));
vi.mock('../ui', () => ({ Segmented: () => null }));
import VoiceGallery from '../pages/VoiceGallery';
describe('VoiceGallery archetype preview fallback', () => {
beforeEach(() => {
vi.clearAllMocks();
global.fetch = vi.fn().mockResolvedValue({ ok: true, blob: () => Promise.resolve(new Blob()) });
});
it('retries a gallery decode error through the local-render endpoint', async () => {
playBlobAudio
.mockImplementationOnce(async (_blob, meta) => meta.onDone('error'))
.mockImplementationOnce(async () => {});
fireEvent.click(render(<VoiceGallery />).getByText('Preview'));
await waitFor(() => expect(global.fetch).toHaveBeenCalledTimes(2));
expect(global.fetch.mock.calls.map(([url]) => url)).toEqual([
'http://backend/archetypes/voice-1/preview',
'http://backend/archetypes/voice-1/preview?local=true',
]);
expect(screen.queryByText(/Preview unavailable/)).not.toBeInTheDocument();
});
});
@@ -0,0 +1,10 @@
import { describe, expect, it } from 'vitest';
import { modelNotDownloadedPayload } from '../utils/modelNotDownloaded';
describe('modelNotDownloadedPayload', () => {
it('owns only the typed self-service 409', () => {
const payload = { error: 'model_not_downloaded', target_label: 'gpu2' };
expect(modelNotDownloadedPayload({ status: 409, detail: payload })).toBe(payload);
expect(modelNotDownloadedPayload({ status: 500, detail: 'boom' })).toBeNull();
});
});
@@ -6,11 +6,13 @@ import {
reduceModelDownloadEvent,
isAutoPurgeTerminal,
isTerminalPhase,
downloadKey,
} from '../components/settings/models/downloadReducer';
import { makeModelColumns } from '../components/settings/models/columns';
const t = i18n.t.bind(i18n);
const REPO = 'org/model';
const KEY = downloadKey('local', REPO);
// P1-A: async install errors must be visible, not silently purged
@@ -20,8 +22,8 @@ describe('reduceModelDownloadEvent — install_error persistence (P1-A)', () =>
{},
{ repo_id: REPO, phase: 'install_error', error: 'boom — mirror hf-mirror.com unreachable' },
);
expect(s[REPO].phase).toBe('install_error');
expect(s[REPO].error).toBe('boom — mirror hf-mirror.com unreachable');
expect(s[KEY].phase).toBe('install_error');
expect(s[KEY].error).toBe('boom — mirror hf-mirror.com unreachable');
});
it('the error survives later unrelated events (never gets wiped)', () => {
@@ -34,8 +36,8 @@ describe('reduceModelDownloadEvent — install_error persistence (P1-A)', () =>
bytes_done: 1,
total_bytes: 2,
});
expect(s[REPO].phase).toBe('install_error');
expect(s[REPO].error).toBe('disk full');
expect(s[KEY].phase).toBe('install_error');
expect(s[KEY].error).toBe('disk full');
});
it('ignores keepalive events without a repo_id', () => {
@@ -44,6 +46,17 @@ describe('reduceModelDownloadEvent — install_error persistence (P1-A)', () =>
});
});
it('keeps simultaneous local and remote downloads of one repo separate', () => {
let s = reduceModelDownloadEvent({}, {
target: 'local', repo_id: REPO, phase: 'aggregate', bytes_done: 1, total_bytes: 10,
});
s = reduceModelDownloadEvent(s, {
target: 'gpu2', repo_id: REPO, phase: 'aggregate', bytes_done: 7, total_bytes: 10,
});
expect(s[downloadKey('local', REPO)].agg.bytes_done).toBe(1);
expect(s[downloadKey('gpu2', REPO)].agg.bytes_done).toBe(7);
});
describe('isAutoPurgeTerminal — only success terminals auto-purge (P1-A)', () => {
it('purges success terminals but NOT install_error', () => {
expect(isAutoPurgeTerminal('install_done')).toBe(true);
+43
View File
@@ -15,6 +15,7 @@ const {
streamGenerateSpeech,
createStreamingChunkPlayer,
supportsStreamingPreview,
resolveRemoteTtsTarget,
decodePcm16Base64,
peaksFromChunkList,
StreamingPreviewError,
@@ -185,6 +186,48 @@ describe('supportsStreamingPreview', () => {
});
});
// ── resolveRemoteTtsTarget ──────────────────────────────────────────────────
//
// The stream is rendered by THIS process, so a progressive preview on a
// remote target would quietly run the job on this machine after the user
// picked their 4090. This is the check that stops that, and it must fail
// open: a picker that cannot be reached must never block a local render.
describe('resolveRemoteTtsTarget', () => {
const json = (body, ok = true) => ({ ok, json: async () => body });
it('asks routing for the tts operation specifically', async () => {
apiFetch.mockResolvedValue(json({ active: { remote: false } }));
await resolveRemoteTtsTarget();
expect(apiFetch.mock.calls[0][0]).toBe('/workers/target?op=tts');
// A dead backend must fail this probe in one round trip, not stall the
// click behind the transport retry ladder.
expect(apiFetch.mock.calls[0][1]).toMatchObject({ retryTransport: false });
});
it('reports the worker when the resolved target is remote', async () => {
apiFetch.mockResolvedValue(
json({ active: { remote: true, worker_id: 'w1', label: 'desktop-4090' } }),
);
expect(await resolveRemoteTtsTarget()).toEqual({ workerId: 'w1', label: 'desktop-4090' });
});
it('answers local for a fallback decision, so streaming stays available', async () => {
apiFetch.mockResolvedValue(
json({ active: { remote: false, reason: 'desktop-4090 is offline — running locally' } }),
);
expect(await resolveRemoteTtsTarget()).toBeNull();
});
it('answers local when the endpoint errors or the backend is unreachable', async () => {
apiFetch.mockResolvedValue(json({ detail: 'nope' }, false));
expect(await resolveRemoteTtsTarget()).toBeNull();
apiFetch.mockRejectedValue(new Error('connection refused'));
expect(await resolveRemoteTtsTarget()).toBeNull();
});
});
// ── streamGenerateSpeech ────────────────────────────────────────────────────
describe('streamGenerateSpeech', () => {
@@ -3,6 +3,13 @@ import { renderHook, act } from '@testing-library/react';
import useTTS from '../hooks/useTTS';
import { useAppStore } from '../store';
import { playBlobAudio } from '../utils/media';
import {
resolveRemoteTtsTarget,
streamGenerateSpeech,
supportsStreamingPreview,
} from '../utils/streamingTts';
import { generateSpeech } from '../api/generate';
import toast from 'react-hot-toast';
// #1032: Settings Appearance "Auto-play preview" ("play the output as soon
// as a render finishes", #666/#667) only gated the WaveformPlayer preview
@@ -42,6 +49,32 @@ vi.mock('../api/generate', async (importOriginal) => {
};
});
// Streaming renders in THIS process, so with a worker selected the classic
// path is the only one that reaches it. The delivery path is chosen here, so
// this is where "did it actually go remote?" is decided.
vi.mock('../utils/streamingTts', async (importOriginal) => {
const actual = await importOriginal();
return {
...actual,
// Defaults to the real answer (jsdom has no Web Audio classic path), so
// the auto-play tests above keep exercising what they always did; the
// delivery-path tests below turn it on explicitly.
supportsStreamingPreview: vi.fn(actual.supportsStreamingPreview),
streamGenerateSpeech: vi.fn().mockResolvedValue({ id: 'x', audio_path: 'x.wav' }),
resolveRemoteTtsTarget: vi.fn().mockResolvedValue(null),
};
});
vi.mock('react-hot-toast', () => {
const fn = vi.fn();
fn.error = vi.fn();
fn.success = vi.fn();
fn.dismiss = vi.fn();
fn.loading = vi.fn();
fn.custom = vi.fn();
return { default: fn, toast: fn, Toaster: () => null };
});
const hookProps = () => ({
selectedProfile: null,
setSelectedProfile: vi.fn(),
@@ -79,3 +112,47 @@ describe('useTTS auto-play pref (#1032)', () => {
expect(playBlobAudio).not.toHaveBeenCalled();
});
});
describe('useTTS delivery path vs the chosen GPU', () => {
beforeEach(() => {
useAppStore.setState({ autoPlayPreview: true });
vi.mocked(supportsStreamingPreview).mockReturnValue(true);
vi.mocked(streamGenerateSpeech).mockClear();
vi.mocked(generateSpeech).mockClear();
vi.mocked(toast).mockClear();
vi.mocked(resolveRemoteTtsTarget).mockResolvedValue(null);
});
it('streams progressively when the work runs on this machine', async () => {
await runGenerate();
expect(streamGenerateSpeech).toHaveBeenCalledTimes(1);
expect(generateSpeech).not.toHaveBeenCalled();
});
it('takes the classic path when the resolved target is a worker', async () => {
// Streaming would have rendered here a local job wearing the badge of
// the 4090 the user picked. The classic path is the one that goes remote.
vi.mocked(resolveRemoteTtsTarget).mockResolvedValue({
workerId: 'w1',
label: 'desktop-4090',
});
await runGenerate();
expect(streamGenerateSpeech).not.toHaveBeenCalled();
expect(generateSpeech).toHaveBeenCalledTimes(1);
});
it('says why progressive playback stopped, once per worker', async () => {
// Silently dropping the feature reads as "the app got slower"; the user
// has to be able to connect it to the choice they made.
vi.mocked(resolveRemoteTtsTarget).mockResolvedValue({ workerId: 'w9', label: 'gpu2' });
await runGenerate();
await runGenerate();
const said = vi.mocked(toast).mock.calls.map(([msg]) => msg);
const notices = said.filter((m) => typeof m === 'string' && m.includes('gpu2'));
expect(notices).toHaveLength(1);
expect(notices[0]).toMatch(/gpu2/);
expect(notices[0]).not.toMatch(/streamingOffRemote/); // a real string, not the key
});
});
@@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest';
import { reduceWizardDownloadEvent, mirrorBlockedRepos } from '../components/WizardLibrary.jsx';
const REPO = 'org/model';
const KEY = `local\u0000${REPO}`;
// P1-A: the first-run wizard used to DELETE a row on install_error (never even
// reading ev.error), so a failed download vanished with no reason. It must now
@@ -14,15 +15,15 @@ describe('reduceWizardDownloadEvent — install_error persists (P1-A)', () => {
phase: 'install_error',
error: 'We couldnt connect to hf-mirror.com',
});
expect(s[REPO]).toBeDefined();
expect(s[REPO].phase).toBe('install_error');
expect(s[REPO].error).toBe('We couldnt connect to hf-mirror.com');
expect(s[KEY]).toBeDefined();
expect(s[KEY].phase).toBe('install_error');
expect(s[KEY].error).toBe('We couldnt connect to hf-mirror.com');
});
it('install_done still drops the transient row (reverts to installed flag)', () => {
let s = reduceWizardDownloadEvent({}, { repo_id: REPO, phase: 'install_start' });
s = reduceWizardDownloadEvent(s, { repo_id: REPO, phase: 'install_done' });
expect(s[REPO]).toBeUndefined();
expect(s[KEY]).toBeUndefined();
});
it('aggregate + per-file events accumulate without clearing the row', () => {
@@ -37,8 +38,8 @@ describe('reduceWizardDownloadEvent — install_error persists (P1-A)', () => {
downloaded: 10,
total: 100,
});
expect(s[REPO].agg.totalBytes).toBe(100);
expect(s[REPO].files['a.bin'].total).toBe(100);
expect(s[KEY].agg.totalBytes).toBe(100);
expect(s[KEY].files['a.bin'].total).toBe(100);
});
it('ignores keepalive events without a repo_id', () => {
@@ -54,10 +55,10 @@ describe('reduceWizardDownloadEvent — install_error persists (P1-A)', () => {
error: 'mirror down',
docs_topic: 'HF_MIRROR_UNREACHABLE',
});
expect(s[REPO].docsTopic).toBe('HF_MIRROR_UNREACHABLE');
expect(s[KEY].docsTopic).toBe('HF_MIRROR_UNREACHABLE');
// Absent docs_topic (older backend / other failures) degrades to ''.
const s2 = reduceWizardDownloadEvent({}, { repo_id: REPO, phase: 'install_error', error: 'x' });
expect(s2[REPO].docsTopic).toBe('');
expect(s2[KEY].docsTopic).toBe('');
});
});
+40
View File
@@ -0,0 +1,40 @@
import toast from 'react-hot-toast';
import i18next from 'i18next';
import { apiPost } from '../api/client';
export const MODEL_NOT_DOWNLOADED = 'model_not_downloaded';
export function modelNotDownloadedPayload(err) {
const detail = err?.detail;
return detail && typeof detail === 'object' && detail.error === MODEL_NOT_DOWNLOADED
? detail
: null;
}
export function toastModelNotDownloaded(payload) {
const t = i18next.t.bind(i18next);
const repoId = payload?.repo_ids?.[0];
const size = payload?.size_bytes;
const button = size == null
? t('model_missing.download')
: t('model_missing.download_size', { size: `${(size / 1024 ** 3).toFixed(1)} GB` });
toast.error((item) => (
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<span style={{ flex: 1 }}>
{t('model_missing.message', { target: payload.target_label })}
{payload.downloadable === false
? ` ${t('model_missing.manual_worker', { target: payload.target_label })}`
: ''}
</span>
{repoId && payload.downloadable !== false && <button type="button" className="btn-secondary" onClick={async () => {
toast.dismiss(item.id);
try {
await apiPost('/models/install', { repo_id: repoId, target: payload.target });
toast.success(t('model_missing.started', { target: payload.target_label }));
} catch (error) {
toast.error(t('model_missing.failed', { message: error?.message || String(error) }));
}
}}>{button}</button>}
</div>
), { duration: 15000 });
}
+33
View File
@@ -26,6 +26,7 @@
* falling back would only duplicate the failure).
*/
import { generateSpeech, withTtsInflight } from '../api/generate';
import { apiFetch } from '../api/client';
import { claimTrackedPlayback } from './playback';
/** True when the webview can progressively play PCM chunks (Web Audio). */
@@ -33,6 +34,38 @@ export const supportsStreamingPreview = () =>
typeof window !== 'undefined' &&
typeof (window.AudioContext || window.webkitAudioContext) === 'function';
/**
* The resolved GPU target for synthesis, when it is a remote worker.
*
* `/generate?stream=true` renders in THIS process there is no streaming
* path to a worker so a progressive preview would quietly run the job on
* this machine after the user picked their 4090. That is the one thing the
* whole picker exists to prevent, so the caller takes the classic (remote-
* capable) path instead and says why.
*
* Resolved per generate rather than once at mount, deliberately: a worker can
* go to sleep between two clicks, and this asks routing the same question the
* generation path asks, so the two cannot disagree.
*
* Any failure answers "local". Remote workers are opt-in and the endpoint is
* loopback-only; a picker that cannot be reached must never stand between a
* user and an ordinary local render, and transport retries are off so a dead
* backend fails here in one round trip instead of stalling the click.
*
* @returns {Promise<{workerId: string|null, label: string}|null>}
*/
export async function resolveRemoteTtsTarget({ signal } = {}) {
try {
const res = await apiFetch('/workers/target?op=tts', { signal, retryTransport: false });
if (!res?.ok) return null;
const active = (await res.json())?.active;
if (!active?.remote) return null;
return { workerId: active.worker_id || null, label: active.label || '' };
} catch {
return null;
}
}
/** A failure AFTER the stream started the signal to fall back to the
* classic whole-file /generate flow. */
export class StreamingPreviewError extends Error {

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