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:
@@ -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
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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}
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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``.
|
||||
"""
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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"
|
||||
]
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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"]
|
||||
@@ -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)
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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"]
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
@@ -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
@@ -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 <worker>” 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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={
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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": "التفريغ النصي"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 l’autre 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 d’une 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 n’est 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "ट्रांसक्रिप्शन"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "文字起こし"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "전사"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "расшифровка"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "การถอดเสียง"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "транскрибування"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "转写"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "轉錄"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 couldn’t connect to hf-mirror.com',
|
||||
});
|
||||
expect(s[REPO]).toBeDefined();
|
||||
expect(s[REPO].phase).toBe('install_error');
|
||||
expect(s[REPO].error).toBe('We couldn’t connect to hf-mirror.com');
|
||||
expect(s[KEY]).toBeDefined();
|
||||
expect(s[KEY].phase).toBe('install_error');
|
||||
expect(s[KEY].error).toBe('We couldn’t 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('');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user