Merge remote-tracking branch 'upstream/main' into fix/1866-engine-unavailability-reason
This commit is contained in:
@@ -22,6 +22,8 @@ the frozen-backend fallback mirror it for their toolchains.
|
||||
|
||||
- Voice cloning now starts with a clear upload-or-record choice, reveals recording and reference details only when needed, and keeps sampling controls under Production Overrides (#1817)
|
||||
- The first-run welcome line uses an instruction accepted by OmniVoice and VoiceDesign engines (#1861) — thanks @psiberfunk!
|
||||
- audio.cpp joins the engine lineup as an opt-in CPU backend for Breeze-TTS-2 (English + Chinese, clone + voice design, explicit Model Catalogue install, no Python venv) (#1891)
|
||||
- audio.cpp uses installed native CUDA, HIP, Metal, and Vulkan providers and preserves device routing across remote workers (#1926)
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -48,6 +50,8 @@ the frozen-backend fallback mirror it for their toolchains.
|
||||
|
||||
### Docs
|
||||
|
||||
- audio.cpp (Breeze-TTS-2) is now a documented opt-in engine: prebuilt binary install, explicit GGUF download, voice modes, and the weights' research/non-commercial terms (#1891)
|
||||
|
||||
### Fixed
|
||||
|
||||
- An unavailable engine's row now links to that engine's guide, so the generic "check installation and configuration" message has somewhere to send you (#1866) — thanks @psiberfunk!
|
||||
@@ -93,6 +97,8 @@ the frozen-backend fallback mirror it for their toolchains.
|
||||
|
||||
|
||||
- Fast macOS process exits no longer turn a completed shutdown into a permission error (#1809)
|
||||
- Model Catalogue engine rows stack into one column on narrow shells instead of clipping actions off-screen (#1891)
|
||||
|
||||
|
||||
## [0.5.2] — 2026-09-02
|
||||
|
||||
|
||||
@@ -804,7 +804,14 @@ def _oom_friendly_reraise(e):
|
||||
) from e
|
||||
|
||||
|
||||
def _generate_timeout_s(text: str, *, execution_device=None, min_vram_gb=0.0) -> float:
|
||||
def _generate_timeout_s(
|
||||
text: str,
|
||||
*,
|
||||
execution_device=None,
|
||||
min_vram_gb=0.0,
|
||||
hardware_family=None,
|
||||
vram_gb=None,
|
||||
) -> float:
|
||||
"""Wall-clock budget for one generate, scaled to the request.
|
||||
|
||||
Thin alias for the canonical helper, which moved to
|
||||
@@ -819,7 +826,11 @@ def _generate_timeout_s(text: str, *, execution_device=None, min_vram_gb=0.0) ->
|
||||
"""
|
||||
from services.model_manager import generate_timeout_s
|
||||
return generate_timeout_s(
|
||||
text, execution_device=execution_device, min_vram_gb=min_vram_gb,
|
||||
text,
|
||||
execution_device=execution_device,
|
||||
min_vram_gb=min_vram_gb,
|
||||
hardware_family=hardware_family,
|
||||
vram_gb=vram_gb,
|
||||
)
|
||||
|
||||
|
||||
@@ -1441,6 +1452,8 @@ async def generate_speech(
|
||||
# local fallback call's timeout device-neutral so the closure is valid
|
||||
# without pretending the control plane describes the remote worker.
|
||||
_routing = {"effective_device": None}
|
||||
_routing_hardware_family = None
|
||||
_routing_vram_gb = None
|
||||
|
||||
if not _remote:
|
||||
# Single-active-engine memory discipline: hand back any OTHER resident
|
||||
@@ -1497,11 +1510,16 @@ async def generate_speech(
|
||||
# 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,
|
||||
from services.engine_routing import (
|
||||
routing_notice,
|
||||
runtime_compute_profile_async,
|
||||
)
|
||||
_routing = await runtime_compute_profile_async(
|
||||
backend_cls, detect_host_caps()
|
||||
)
|
||||
_engine_min_vram_gb = _routing["min_vram_gb"]
|
||||
_routing_hardware_family = _routing.get("runtime_hardware_family")
|
||||
_routing_vram_gb = _routing.get("runtime_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"])
|
||||
@@ -1725,8 +1743,13 @@ async def generate_speech(
|
||||
local=gpu_gateway.LocalCall(
|
||||
_remote_only_local_call(_target_label),
|
||||
what="TTS generate",
|
||||
timeout=_generate_timeout_s(text, execution_device=_routing["effective_device"],
|
||||
min_vram_gb=_engine_min_vram_gb),
|
||||
timeout=_generate_timeout_s(
|
||||
text,
|
||||
execution_device=_routing["effective_device"],
|
||||
min_vram_gb=_engine_min_vram_gb,
|
||||
hardware_family=_routing_hardware_family,
|
||||
vram_gb=_routing_vram_gb,
|
||||
),
|
||||
min_vram_gb=_engine_min_vram_gb,
|
||||
),
|
||||
remote=_remote_call,
|
||||
@@ -2020,8 +2043,13 @@ async def generate_speech(
|
||||
),
|
||||
what="TTS generate",
|
||||
min_vram_gb=_engine_min_vram_gb,
|
||||
timeout=_generate_timeout_s(text, execution_device=_routing["effective_device"],
|
||||
min_vram_gb=_engine_min_vram_gb),
|
||||
timeout=_generate_timeout_s(
|
||||
text,
|
||||
execution_device=_routing["effective_device"],
|
||||
min_vram_gb=_engine_min_vram_gb,
|
||||
hardware_family=_routing_hardware_family,
|
||||
vram_gb=_routing_vram_gb,
|
||||
),
|
||||
on_abandon=release,
|
||||
)
|
||||
)
|
||||
@@ -2041,8 +2069,13 @@ async def generate_speech(
|
||||
),
|
||||
what="TTS generate",
|
||||
min_vram_gb=_engine_min_vram_gb,
|
||||
timeout=_generate_timeout_s(text, execution_device=_routing["effective_device"],
|
||||
min_vram_gb=_engine_min_vram_gb),
|
||||
timeout=_generate_timeout_s(
|
||||
text,
|
||||
execution_device=_routing["effective_device"],
|
||||
min_vram_gb=_engine_min_vram_gb,
|
||||
hardware_family=_routing_hardware_family,
|
||||
vram_gb=_routing_vram_gb,
|
||||
),
|
||||
on_abandon=release,
|
||||
)
|
||||
)
|
||||
@@ -2082,8 +2115,13 @@ async def generate_speech(
|
||||
# Budget scaled to THIS chunk (#1190) — the flat
|
||||
# 300s here is what made long streamed renders fail
|
||||
# even after the v0.3.22 scaled budget shipped.
|
||||
timeout=_generate_timeout_s(chunk_text, execution_device=_routing["effective_device"],
|
||||
min_vram_gb=_engine_min_vram_gb),
|
||||
timeout=_generate_timeout_s(
|
||||
chunk_text,
|
||||
execution_device=_routing["effective_device"],
|
||||
min_vram_gb=_engine_min_vram_gb,
|
||||
hardware_family=_routing_hardware_family,
|
||||
vram_gb=_routing_vram_gb,
|
||||
),
|
||||
on_abandon=release,
|
||||
)
|
||||
)
|
||||
@@ -2243,8 +2281,13 @@ async def generate_speech(
|
||||
_REMOTE_OP,
|
||||
local=gpu_gateway.LocalCall(
|
||||
_local_render, what="TTS generate",
|
||||
timeout=_generate_timeout_s(text, execution_device=_routing["effective_device"],
|
||||
min_vram_gb=_engine_min_vram_gb),
|
||||
timeout=_generate_timeout_s(
|
||||
text,
|
||||
execution_device=_routing["effective_device"],
|
||||
min_vram_gb=_engine_min_vram_gb,
|
||||
hardware_family=_routing_hardware_family,
|
||||
vram_gb=_routing_vram_gb,
|
||||
),
|
||||
min_vram_gb=_engine_min_vram_gb,
|
||||
on_abandon=release,
|
||||
),
|
||||
|
||||
@@ -325,10 +325,9 @@ async def create_speech(req: SpeechRequest):
|
||||
|
||||
# Routing gate (#21 — no silent CPU fallback), identical to REST /generate.
|
||||
from core.device_caps import detect_host_caps
|
||||
from services.engine_routing import resolve_routing, routing_notice
|
||||
_routing = resolve_routing(
|
||||
getattr(backend, "gpu_compat", ("cpu",)), detect_host_caps(),
|
||||
getattr(backend, "min_vram_gb", 0.0),
|
||||
from services.engine_routing import routing_notice, runtime_compute_profile_async
|
||||
_routing = await runtime_compute_profile_async(
|
||||
backend, detect_host_caps()
|
||||
)
|
||||
if _routing["routing_status"] == "unavailable":
|
||||
raise HTTPException(status_code=400, detail=_routing["routing_reason"])
|
||||
|
||||
@@ -385,7 +385,11 @@ def _is_retryable_download_error(exc: BaseException) -> bool:
|
||||
async def install_model(req: InstallModelRequest):
|
||||
"""Download one HF repo snapshot; progress goes through the shared
|
||||
``/setup/download-stream`` SSE feed."""
|
||||
if req.repo_id not in [m["repo_id"] for m in KNOWN_MODELS]:
|
||||
model_spec = next(
|
||||
(model for model in KNOWN_MODELS if model["repo_id"] == req.repo_id),
|
||||
None,
|
||||
)
|
||||
if model_spec is None:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=(
|
||||
@@ -393,6 +397,7 @@ async def install_model(req: InstallModelRequest):
|
||||
+ ", ".join(m["repo_id"] for m in KNOWN_MODELS)
|
||||
),
|
||||
)
|
||||
allow_patterns = list(model_spec.get("allow_patterns") or []) or None
|
||||
target = (req.target or "").strip()
|
||||
if target != "local":
|
||||
from services import gpu_gateway # noqa: PLC0415
|
||||
@@ -450,6 +455,8 @@ async def install_model(req: InstallModelRequest):
|
||||
"revision": revision_for(req.repo_id),
|
||||
"max_workers": _download_max_workers(),
|
||||
}
|
||||
if allow_patterns:
|
||||
dl_kwargs["allow_patterns"] = allow_patterns
|
||||
_tqdm_cls = hf_progress.tracked_tqdm_class()
|
||||
if _tqdm_cls is not None:
|
||||
dl_kwargs["tqdm_class"] = _tqdm_cls
|
||||
@@ -493,6 +500,8 @@ async def install_model(req: InstallModelRequest):
|
||||
"revision": dl_kwargs["revision"],
|
||||
"dry_run": True,
|
||||
}
|
||||
if allow_patterns:
|
||||
_preflight_kwargs["allow_patterns"] = allow_patterns
|
||||
if _endpoint:
|
||||
_preflight_kwargs["endpoint"] = _endpoint
|
||||
try:
|
||||
@@ -559,7 +568,12 @@ async def install_model(req: InstallModelRequest):
|
||||
# snapshot_download — the accelerator can never compromise a
|
||||
# correct install.
|
||||
_snapshot_path = None
|
||||
if _attempt == 1 and _segmented_enabled() and not _xet_active():
|
||||
if (
|
||||
_attempt == 1
|
||||
and not allow_patterns
|
||||
and _segmented_enabled()
|
||||
and not _xet_active()
|
||||
):
|
||||
try:
|
||||
_snapshot_path = _segmented_snapshot(
|
||||
req.repo_id,
|
||||
|
||||
@@ -174,11 +174,14 @@ async def ws_tts(websocket: WebSocket):
|
||||
# close on `unavailable`, a one-time `routing` frame on
|
||||
# cpu_fallback / accelerated-with-caveat (before any audio).
|
||||
from core.device_caps import detect_host_caps
|
||||
from services.engine_routing import resolve_routing, routing_notice
|
||||
from services.engine_routing import (
|
||||
routing_notice,
|
||||
runtime_compute_profile_async,
|
||||
)
|
||||
from core.scrub import scrub_text
|
||||
_routing = resolve_routing(
|
||||
getattr(backend, "gpu_compat", ("cpu",)), detect_host_caps(),
|
||||
getattr(backend, "min_vram_gb", 0.0))
|
||||
_routing = await runtime_compute_profile_async(
|
||||
backend, detect_host_caps()
|
||||
)
|
||||
if _routing["routing_status"] == "unavailable":
|
||||
await websocket.send_json({
|
||||
"type": "error",
|
||||
|
||||
@@ -307,6 +307,16 @@ async def convert_speech(
|
||||
GpuPoolBusyError,
|
||||
run_on_gpu_pool_guarded,
|
||||
)
|
||||
from core.device_caps import detect_host_caps
|
||||
from services.engine_routing import runtime_compute_profile_async
|
||||
compute_profile = await runtime_compute_profile_async(
|
||||
backend, detect_host_caps()
|
||||
)
|
||||
if compute_profile["routing_status"] == "unavailable":
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=compute_profile["routing_reason"],
|
||||
)
|
||||
|
||||
start_time = time.time()
|
||||
_render = functools.partial(
|
||||
@@ -324,9 +334,13 @@ async def convert_speech(
|
||||
_render,
|
||||
what="Voice convert",
|
||||
timeout=_generate_timeout_s(
|
||||
text, min_vram_gb=getattr(type(backend), "min_vram_gb", 0.0),
|
||||
text,
|
||||
execution_device=compute_profile["effective_device"],
|
||||
min_vram_gb=compute_profile["min_vram_gb"],
|
||||
hardware_family=compute_profile.get("runtime_hardware_family"),
|
||||
vram_gb=compute_profile.get("runtime_vram_gb"),
|
||||
),
|
||||
min_vram_gb=getattr(type(backend), "min_vram_gb", 0.0),
|
||||
min_vram_gb=compute_profile["min_vram_gb"],
|
||||
)
|
||||
except GpuPoolBusyError as e:
|
||||
raise HTTPException(
|
||||
|
||||
@@ -28,6 +28,9 @@
|
||||
# their own (weights live in referenced sub-repos). Such
|
||||
# a cache is legitimately tiny, so the truncated-download
|
||||
# (weights-missing) detector must NOT flag it incomplete.
|
||||
# allow_patterns (optional) — restrict installation to these repository paths.
|
||||
# Use for multi-package repos so an explicit install
|
||||
# never downloads unrelated model variants.
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
models:
|
||||
@@ -40,6 +43,14 @@ models:
|
||||
required: true
|
||||
curated_on: [all]
|
||||
|
||||
- repo_id: "audio-cpp/audio.cpp-gguf"
|
||||
label: "Breeze-TTS-2 Q8_0 for audio.cpp (English + Chinese, clone + design)"
|
||||
role: TTS
|
||||
size_gb: 4.73
|
||||
allow_patterns:
|
||||
- "Breeze-TTS-2-GGUF/breeze-tts-2-q8_0.gguf"
|
||||
note: "Optional audio.cpp model. Research/non-commercial weights and self-hosted outputs; install only after reviewing the license."
|
||||
|
||||
# ── ASR (optional — curated per platform) ─────────────────────────────
|
||||
# No ASR model is required to boot: TTS-only installs work. Dubbing,
|
||||
# dictation, and clone-reference transcription prompt for the curated
|
||||
|
||||
@@ -0,0 +1,590 @@
|
||||
"""audio.cpp TTS backend — Breeze-TTS-2 via a managed native server.
|
||||
|
||||
audio.cpp (0xShug0/audio.cpp) is a pure-C++ ggml runtime: prebuilt
|
||||
``audiocpp_server`` binaries for Windows/macOS/Linux, no Python venv, no
|
||||
``transformers`` pin — so this engine needs neither the venv-isolation
|
||||
(``engines.dots_tts``) nor the per-generate CLI-spawn (``engines
|
||||
.omnivoice_gguf``) patterns. The parent instead:
|
||||
|
||||
1. resolves the binary + GGUF model (``bootstrap.py``),
|
||||
2. spawns ONE long-lived ``audiocpp_server`` on 127.0.0.1 (lazy model load,
|
||||
so model memory is only held after the first generate), and
|
||||
3. speaks its OpenAI-style ``POST /v1/audio/speech`` per generate.
|
||||
|
||||
v1 serves the ``breeze_tts`` family only (Breeze-TTS-2, en+zh, voice clone
|
||||
+ voice design + voice direction). The server is task-agnostic on the
|
||||
speech route — reference-audio presence selects clone/direction vs design —
|
||||
so a single ``task: tts`` model entry covers all three modes.
|
||||
|
||||
License honesty: Breeze-TTS-2 weights (``BreezeBlue/Breeze-TTS-2`` and the
|
||||
audio.cpp GGUF repack) are RESEARCH AND NON-COMMERCIAL ONLY
|
||||
(``BreezeBlue Research and Non-Commercial License``); only the audio.cpp
|
||||
code is Apache-2.0. There is no in-tree acceptance dialog for this engine
|
||||
yet (settings ``/license`` allow-list), so the restriction is surfaced in
|
||||
the display name, the install hint, and ``docs/engines/audio-cpp.md`` —
|
||||
not silently.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import atexit
|
||||
import base64
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
|
||||
# Used only for stream constants; spawn_owned performs the process launch.
|
||||
import subprocess # nosec B404
|
||||
import threading
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from core.contained_subprocess import spawn_owned
|
||||
from services.tts_backend import TTSBackend, TTSInputError
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import torch
|
||||
|
||||
logger = logging.getLogger("omnivoice.audiocpp")
|
||||
|
||||
#: Engine id in the TTS registry.
|
||||
ENGINE_ID = "audiocpp"
|
||||
|
||||
#: How long to wait for ``/health`` after spawning the server (first spawn
|
||||
#: extracts nothing heavy — the model loads lazily on first generate).
|
||||
_HEALTH_TIMEOUT_S = 120.0
|
||||
|
||||
#: Finish the inner HTTP request before the canonical generation guard can
|
||||
#: abandon its worker thread. This leaves enough time to terminate the owned
|
||||
#: native process and release its model memory synchronously.
|
||||
_TERMINATE_GRACE_S = 5.0
|
||||
_TERMINATE_KILL_S = 5.0
|
||||
_GENERATE_TIMEOUT_MARGIN_S = (
|
||||
_TERMINATE_GRACE_S + _TERMINATE_KILL_S + 5.0
|
||||
)
|
||||
|
||||
|
||||
# ── pure request/config builders (unit-tested, no I/O) ──────────────────────
|
||||
|
||||
|
||||
def _cpu_thread_count() -> int:
|
||||
"""Use up to 16 physical cores, with a stdlib fallback."""
|
||||
try:
|
||||
import psutil
|
||||
|
||||
cores = psutil.cpu_count(logical=False)
|
||||
except (ImportError, OSError):
|
||||
cores = None
|
||||
return min(16, max(1, cores or os.cpu_count() or 1))
|
||||
|
||||
|
||||
def _device_min_vram_gb(device) -> float:
|
||||
"""Dedicated-memory comfort floor for one discovered native device."""
|
||||
return 6.0 if (
|
||||
device
|
||||
and device.kind == "GPU"
|
||||
and (
|
||||
device.backend == "vulkan"
|
||||
or device.hardware_family in {"cuda", "rocm"}
|
||||
)
|
||||
) else 0.0
|
||||
|
||||
|
||||
def build_server_config(
|
||||
*, model_id: str, family: str, model_path: str, port: int,
|
||||
backend: str = "cpu", device: int = 0,
|
||||
execution_target: str | None = None,
|
||||
) -> dict:
|
||||
"""``server.json`` dict for the managed ``audiocpp_server``.
|
||||
|
||||
``lazy_load`` defers the ~4.73 GiB GGUF load to the first generate;
|
||||
``max_loaded_models: 1`` bounds residency to the one model we serve.
|
||||
"""
|
||||
return {
|
||||
"host": "127.0.0.1",
|
||||
"port": port,
|
||||
"backend": backend,
|
||||
"device": device,
|
||||
# The pinned CPU runtime scales strongly through 16 workers while
|
||||
# producing byte-identical audio.
|
||||
"threads": _cpu_thread_count()
|
||||
if (execution_target or backend) == "cpu" else 1,
|
||||
"lazy_load": True,
|
||||
"max_loaded_models": 1,
|
||||
"models": [
|
||||
{
|
||||
"id": model_id,
|
||||
"family": family,
|
||||
"path": model_path,
|
||||
"task": "tts",
|
||||
"mode": "offline",
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def build_speech_payload(
|
||||
*, model_id: str, text: str, ref_audio: str | None = None,
|
||||
ref_text: str | None = None, instructions: str | None = None,
|
||||
guidance_scale: float | None = None, seed: int | None = None,
|
||||
) -> dict:
|
||||
"""``POST /v1/audio/speech`` JSON body.
|
||||
|
||||
Field spellings verified against ``app/server/runtime.cpp``
|
||||
(``build_speech_request``): ``instructions`` (plural, OpenAI spelling)
|
||||
feeds the ``instruction`` request option; ``reference_text`` and
|
||||
``guidance_scale``/``seed`` pass through top-level; ``voice_ref`` takes
|
||||
a ``{"type": "path", ...}`` object so the reference stays on disk
|
||||
(the 5 MiB base64 cap never bites). ``response_format: json`` returns
|
||||
the WAV base64-in-JSON — one round trip, no binary framing.
|
||||
"""
|
||||
payload: dict[str, Any] = {
|
||||
"model": model_id,
|
||||
"input": text,
|
||||
"response_format": "json",
|
||||
}
|
||||
if instructions:
|
||||
payload["instructions"] = instructions
|
||||
if ref_audio:
|
||||
payload["voice_ref"] = {"type": "path", "path": str(ref_audio)}
|
||||
if ref_text:
|
||||
payload["reference_text"] = ref_text
|
||||
if guidance_scale is not None:
|
||||
payload["guidance_scale"] = float(guidance_scale)
|
||||
if seed is not None:
|
||||
payload["seed"] = int(seed)
|
||||
return payload
|
||||
|
||||
|
||||
def decode_speech_json(obj: dict) -> tuple[int, object]:
|
||||
"""``(sample_rate, mono float32 numpy)`` from a ``response_format=json``
|
||||
speech body. Raises ``ValueError`` on a server error payload."""
|
||||
if not isinstance(obj, dict):
|
||||
raise TypeError(f"audio.cpp speech reply is not JSON: {obj!r:.120}")
|
||||
if "audio" not in obj:
|
||||
raise ValueError(f"audio.cpp speech failed: {obj.get('error', obj)!r:.300}")
|
||||
import numpy as np
|
||||
import soundfile as sf
|
||||
|
||||
wav_bytes = base64.b64decode(obj["audio"])
|
||||
wav, sr = sf.read(io.BytesIO(wav_bytes), dtype="float32", always_2d=False)
|
||||
wav = np.asarray(wav, dtype=np.float32)
|
||||
if wav.ndim > 1:
|
||||
wav = wav.mean(axis=-1)
|
||||
return int(sr), wav
|
||||
|
||||
|
||||
# ── backend ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class AudioCPPBackend(TTSBackend):
|
||||
"""Breeze-TTS-2 through a parent-managed ``audiocpp_server``."""
|
||||
|
||||
id = ENGINE_ID
|
||||
display_name = (
|
||||
"audio.cpp · Breeze-TTS-2 (native GGUF, en+zh, clone+design; "
|
||||
"weights research/non-commercial)"
|
||||
)
|
||||
supports_voice_design = True
|
||||
applies_own_mastering = True # model-decoded 24 kHz studio output
|
||||
gpu_compat = ("cpu",)
|
||||
runs_out_of_process = True
|
||||
# Same marker SubprocessBackend sets: this engine lives in another OS
|
||||
# process. Consumers only branch the matrix label and the self-test
|
||||
# route (spawn-and-ping instead of in-process synth) — both correct
|
||||
# here; nothing assumes the stdio protocol from it.
|
||||
_is_subprocess_isolated = True
|
||||
_DEFAULT_SAMPLE_RATE = 24000 # Breeze-TTS-2 native rate
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._proc: Any | None = None
|
||||
self._port: int | None = None
|
||||
self._server_model_id: str | None = None
|
||||
self._sr = self._DEFAULT_SAMPLE_RATE
|
||||
self._lock = threading.RLock()
|
||||
self._server_json: Path | None = None
|
||||
self._selection = None
|
||||
self._device = None
|
||||
self._provider = None
|
||||
|
||||
# ── availability ────────────────────────────────────────────────────
|
||||
|
||||
@classmethod
|
||||
def is_available(cls) -> tuple[bool, str]:
|
||||
from engines.audiocpp import bootstrap
|
||||
|
||||
try:
|
||||
bootstrap.resolve_server_binary()
|
||||
bootstrap.resolve_model_file()
|
||||
except RuntimeError as exc:
|
||||
return False, str(exc)
|
||||
return True, "ready"
|
||||
|
||||
@classmethod
|
||||
def runtime_compute_profile(cls, caps) -> dict:
|
||||
from dataclasses import replace
|
||||
|
||||
from engines.audiocpp import bootstrap
|
||||
from services.engine_routing import low_vram_caveat
|
||||
|
||||
try:
|
||||
selection = bootstrap.resolve_compute_selection(caps)
|
||||
targets = bootstrap.runtime_targets()
|
||||
except RuntimeError as exc:
|
||||
return {
|
||||
"gpu_compat": cls.gpu_compat,
|
||||
"min_vram_gb": 0.0,
|
||||
"effective_device": "cpu",
|
||||
"routing_status": "unavailable",
|
||||
"routing_reason": str(exc),
|
||||
"runtime_backend": None,
|
||||
"runtime_device_index": None,
|
||||
"runtime_device_name": None,
|
||||
"runtime_hardware_family": None,
|
||||
"runtime_vram_gb": None,
|
||||
"runtime_device_verified": False,
|
||||
}
|
||||
selected = selection.device
|
||||
accelerated = selected.target != "cpu"
|
||||
min_vram_gb = _device_min_vram_gb(selected)
|
||||
dedicated = min_vram_gb > 0
|
||||
reason = selection.fallback_reason
|
||||
if accelerated and dedicated and reason is None:
|
||||
selected_caps = replace(
|
||||
caps,
|
||||
device_name=selected.name,
|
||||
vram_gb=selection.verified_vram_gb,
|
||||
)
|
||||
reason = low_vram_caveat(
|
||||
selected_caps,
|
||||
min_vram_gb,
|
||||
family=selected.hardware_family,
|
||||
vram_gb=selection.verified_vram_gb,
|
||||
)
|
||||
status = "accelerated" if accelerated else (
|
||||
"cpu_fallback" if selection.fallback_reason else "cpu_only"
|
||||
)
|
||||
return {
|
||||
"gpu_compat": targets,
|
||||
"min_vram_gb": min_vram_gb,
|
||||
"effective_device": selected.target,
|
||||
"routing_status": status,
|
||||
"routing_reason": reason,
|
||||
"runtime_backend": selected.backend,
|
||||
"runtime_device_index": selected.index,
|
||||
"runtime_device_name": selected.name,
|
||||
"runtime_hardware_family": selected.hardware_family,
|
||||
"runtime_vram_gb": selection.verified_vram_gb,
|
||||
"runtime_device_verified": selection.verified_vram_gb > 0,
|
||||
}
|
||||
|
||||
# ── TTSBackend protocol ─────────────────────────────────────────────
|
||||
|
||||
@property
|
||||
def sample_rate(self) -> int:
|
||||
return self._sr
|
||||
|
||||
@property
|
||||
def supported_languages(self) -> list[str]:
|
||||
return ["en", "zh"]
|
||||
|
||||
def model_identity(self) -> str | None:
|
||||
from engines.audiocpp import bootstrap
|
||||
|
||||
return f"{bootstrap.FAMILY}/{bootstrap.package_filename()}"
|
||||
|
||||
# ── server lifecycle ────────────────────────────────────────────────
|
||||
|
||||
def _base_url(self) -> str:
|
||||
return f"http://127.0.0.1:{self._port}"
|
||||
|
||||
def _ensure_loaded(self) -> None:
|
||||
"""Spawn the server (once) and wait for ``/health``. Idempotent."""
|
||||
with self._lock:
|
||||
if self._proc is not None and self._proc.poll() is None:
|
||||
return
|
||||
self._proc = None # stale handle — respawn below
|
||||
from engines.audiocpp import bootstrap
|
||||
|
||||
binary = bootstrap.resolve_server_binary()
|
||||
selection = bootstrap.resolve_compute_selection()
|
||||
model_file = bootstrap.resolve_model_file()
|
||||
self._port = bootstrap.server_port()
|
||||
# The random model id is a per-launch challenge. Before sending
|
||||
# speech text or a reference path, _verify_server_identity asks
|
||||
# /v1/models to prove this is the child configured by this process,
|
||||
# not an unrelated listener that pre-bound the loopback port.
|
||||
self._server_model_id = f"{bootstrap.MODEL_ID}-{secrets.token_hex(16)}"
|
||||
config = build_server_config(
|
||||
model_id=self._server_model_id,
|
||||
family=bootstrap.FAMILY,
|
||||
model_path=str(model_file),
|
||||
port=self._port,
|
||||
backend=selection.device.backend,
|
||||
device=selection.device.index,
|
||||
execution_target=selection.device.target,
|
||||
)
|
||||
self._selection = selection
|
||||
self._device = selection.device.target
|
||||
self._provider = selection.device.backend
|
||||
from core.config import DATA_DIR
|
||||
|
||||
workdir = Path(str(DATA_DIR)) / "audiocpp"
|
||||
workdir.mkdir(parents=True, exist_ok=True)
|
||||
self._server_json = workdir / "server.json"
|
||||
flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC
|
||||
config_fd = os.open(self._server_json, flags, 0o600)
|
||||
try:
|
||||
if os.name != "nt":
|
||||
os.fchmod(config_fd, 0o600)
|
||||
with os.fdopen(config_fd, "w", encoding="utf-8") as config_fh:
|
||||
config_fd = -1
|
||||
json.dump(config, config_fh, indent=2)
|
||||
finally:
|
||||
if config_fd >= 0:
|
||||
os.close(config_fd)
|
||||
log_path = workdir / "server.log"
|
||||
logger.info(
|
||||
"audio.cpp: starting %s (backend=%s, device=%d, port=%d, model=%s)",
|
||||
binary.name, selection.device.backend, selection.device.index,
|
||||
self._port, model_file.name,
|
||||
)
|
||||
with open(log_path, "ab") as log_fh:
|
||||
self._proc = spawn_owned(
|
||||
[str(binary), "--config", str(self._server_json)],
|
||||
stdout=log_fh,
|
||||
stderr=subprocess.STDOUT,
|
||||
stdin=subprocess.DEVNULL,
|
||||
)
|
||||
atexit.register(self._terminate_server)
|
||||
self._wait_for_health()
|
||||
|
||||
def _wait_for_health(self) -> None:
|
||||
if self._proc is None or self._port is None:
|
||||
raise RuntimeError("managed audio.cpp server was not started")
|
||||
deadline = time.monotonic() + _HEALTH_TIMEOUT_S
|
||||
last_err = "unknown"
|
||||
url = self._base_url() + "/health"
|
||||
while time.monotonic() < deadline:
|
||||
if self._proc.poll() is not None:
|
||||
raise RuntimeError(
|
||||
"audiocpp_server exited during startup "
|
||||
f"(code {self._proc.returncode}). See the server log next "
|
||||
"to server.json under the app data audiocpp/ directory — "
|
||||
"the managed port may already be in use."
|
||||
)
|
||||
try:
|
||||
# ``url`` is always the hard-coded loopback host plus a
|
||||
# validated integer port; arbitrary schemes are impossible.
|
||||
with urllib.request.urlopen(url, timeout=5) as resp: # nosec B310
|
||||
if resp.status == 200:
|
||||
self._verify_server_identity()
|
||||
if self._proc.poll() is None:
|
||||
logger.info(
|
||||
"audio.cpp: managed server is healthy on loopback"
|
||||
)
|
||||
return
|
||||
last_err = f"HTTP {resp.status}"
|
||||
except Exception as exc: # noqa: BLE001 — still starting; retry
|
||||
last_err = f"{type(exc).__name__}: {exc}"
|
||||
time.sleep(1.0)
|
||||
self._terminate_server()
|
||||
raise RuntimeError(
|
||||
f"audiocpp_server did not become healthy within "
|
||||
f"{_HEALTH_TIMEOUT_S:.0f}s (last: {last_err})."
|
||||
)
|
||||
|
||||
def _get_json(self, path: str, timeout: float = 5.0) -> dict:
|
||||
"""GET one loopback JSON endpoint without sending request content."""
|
||||
if self._port is None:
|
||||
raise RuntimeError("managed audio.cpp server port is missing")
|
||||
req = urllib.request.Request(self._base_url() + path, method="GET")
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp: # nosec B310
|
||||
obj = json.loads(resp.read().decode("utf-8"))
|
||||
if not isinstance(obj, dict):
|
||||
raise TypeError("audio.cpp returned an invalid JSON response")
|
||||
return obj
|
||||
|
||||
def _verify_server_identity(self) -> None:
|
||||
"""Prove the loopback listener owns this launch's random model id."""
|
||||
if self._proc is None or self._proc.poll() is not None:
|
||||
raise RuntimeError("managed audio.cpp server is not running")
|
||||
expected = self._server_model_id
|
||||
if not expected:
|
||||
raise RuntimeError("managed audio.cpp server identity is missing")
|
||||
obj = self._get_json("/v1/models")
|
||||
data = obj.get("data", [])
|
||||
if not isinstance(data, list):
|
||||
raise TypeError("managed audio.cpp server identity is invalid")
|
||||
model_ids = {
|
||||
item.get("id") for item in data
|
||||
if isinstance(item, dict)
|
||||
}
|
||||
if expected not in model_ids or self._proc.poll() is not None:
|
||||
raise RuntimeError(
|
||||
"loopback listener did not prove managed audio.cpp ownership"
|
||||
)
|
||||
|
||||
def _post_json(self, path: str, payload: dict, timeout: float) -> dict:
|
||||
"""Verify child ownership, then POST JSON to the managed server."""
|
||||
if self._port is None:
|
||||
raise RuntimeError("managed audio.cpp server port is missing")
|
||||
self._verify_server_identity()
|
||||
body = json.dumps(payload).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
self._base_url() + path,
|
||||
data=body,
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
# ``req`` targets only ``_base_url()`` (127.0.0.1 + validated
|
||||
# integer port), never a caller-provided URL.
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp: # nosec B310
|
||||
return json.loads(resp.read().decode("utf-8"))
|
||||
except urllib.error.HTTPError as exc:
|
||||
detail = exc.read().decode("utf-8", errors="replace")[:500]
|
||||
raise RuntimeError(
|
||||
f"audio.cpp {path} failed (HTTP {exc.code}): {detail}"
|
||||
) from exc
|
||||
except urllib.error.URLError as exc:
|
||||
if isinstance(exc.reason, TimeoutError):
|
||||
raise TimeoutError("audio.cpp request timed out") from exc
|
||||
raise
|
||||
|
||||
def _terminate_server(self) -> None:
|
||||
proc, self._proc = self._proc, None
|
||||
self._server_model_id = None
|
||||
if proc is None:
|
||||
return
|
||||
try:
|
||||
proc.terminate()
|
||||
proc.wait(timeout=_TERMINATE_GRACE_S)
|
||||
except Exception: # noqa: BLE001 — kill as last resort, never raise
|
||||
try:
|
||||
proc.kill()
|
||||
proc.wait(timeout=_TERMINATE_KILL_S)
|
||||
except Exception as exc: # noqa: BLE001 — process is already failing
|
||||
logger.debug("audio.cpp: final server kill failed: %s", exc)
|
||||
|
||||
# ── generate ────────────────────────────────────────────────────────
|
||||
|
||||
def generate(self, text: str, **kw) -> torch.Tensor:
|
||||
import torch
|
||||
from services.model_manager import (
|
||||
GENERATE_PROGRESS_GRACE_S,
|
||||
generate_timeout_s,
|
||||
report_generate_progress,
|
||||
)
|
||||
|
||||
if not text or not text.strip():
|
||||
raise TTSInputError(
|
||||
"audio.cpp: the input contains no speakable text — "
|
||||
"send at least one word."
|
||||
)
|
||||
ref_audio = kw.get("ref_audio")
|
||||
ref_text = kw.get("ref_text")
|
||||
if ref_text and not ref_audio:
|
||||
logger.info(
|
||||
"audio.cpp: ref_text supplied without ref_audio; ignoring."
|
||||
)
|
||||
ref_text = None
|
||||
|
||||
# Voice design: our `description=` (no ref) and voice direction
|
||||
# (`instruct=` + ref) both ride the server's `instructions` field —
|
||||
# verified spelling against app/server/runtime.cpp.
|
||||
instruct = kw.get("instruct") or kw.get("description") or None
|
||||
|
||||
language = kw.get("language")
|
||||
if language and str(language).strip().lower() not in {
|
||||
"auto", "en", "english", "zh", "chinese",
|
||||
}:
|
||||
logger.info(
|
||||
"audio.cpp (Breeze-TTS-2) is en+zh only; ignoring "
|
||||
"language=%r.", language,
|
||||
)
|
||||
if kw.get("speed", 1.0) != 1.0:
|
||||
logger.info("audio.cpp: speed is not supported; ignoring.")
|
||||
|
||||
request_started = time.monotonic()
|
||||
with self._lock:
|
||||
self._ensure_loaded()
|
||||
selected = self._selection.device if self._selection else None
|
||||
min_vram_gb = _device_min_vram_gb(selected)
|
||||
request_budget = generate_timeout_s(
|
||||
text,
|
||||
execution_device=selected.target if selected else "cpu",
|
||||
min_vram_gb=min_vram_gb,
|
||||
hardware_family=selected.hardware_family if selected else None,
|
||||
vram_gb=self._selection.verified_vram_gb
|
||||
if self._selection else 0.0,
|
||||
)
|
||||
if not self._server_model_id:
|
||||
raise RuntimeError("managed audio.cpp server identity is missing")
|
||||
payload = build_speech_payload(
|
||||
model_id=self._server_model_id,
|
||||
text=text,
|
||||
ref_audio=str(ref_audio) if ref_audio else None,
|
||||
ref_text=ref_text,
|
||||
instructions=instruct,
|
||||
guidance_scale=kw.get("guidance_scale", 1.0),
|
||||
seed=kw.get("seed"),
|
||||
)
|
||||
# Device discovery and server startup can consume part of the soft
|
||||
# budget. This fresh synthesis lease gives the lazy model load and
|
||||
# request a bounded window. The inner request always expires early
|
||||
# enough to reap the owned server before the outer guard abandons us.
|
||||
report_generate_progress()
|
||||
soft_remaining = request_budget - (time.monotonic() - request_started)
|
||||
timeout = (
|
||||
max(soft_remaining, GENERATE_PROGRESS_GRACE_S)
|
||||
- _GENERATE_TIMEOUT_MARGIN_S
|
||||
)
|
||||
if timeout <= 0:
|
||||
self._terminate_server()
|
||||
raise TimeoutError(
|
||||
"audio.cpp startup exhausted the generation time budget"
|
||||
)
|
||||
try:
|
||||
obj = self._post_json(
|
||||
"/v1/audio/speech", payload, timeout=timeout,
|
||||
)
|
||||
except TimeoutError:
|
||||
self._terminate_server()
|
||||
raise RuntimeError(
|
||||
"audio.cpp generation timed out; its managed server was reset"
|
||||
) from None
|
||||
sr, wav_np = decode_speech_json(obj)
|
||||
self._sr = sr
|
||||
wav = torch.from_numpy(wav_np).float()
|
||||
if wav.ndim == 0:
|
||||
raise RuntimeError("audio.cpp produced empty audio")
|
||||
return wav.unsqueeze(0)
|
||||
|
||||
# ── lifecycle ───────────────────────────────────────────────────────
|
||||
|
||||
def unload(self) -> None:
|
||||
"""Free the model server-side, then stop it. Idempotent."""
|
||||
with self._lock:
|
||||
if self._port is not None and self._proc is not None \
|
||||
and self._proc.poll() is None:
|
||||
try:
|
||||
self._post_json("/v1/tasks/unload_all_models", {}, timeout=30)
|
||||
except Exception as exc: # noqa: BLE001 — best effort
|
||||
logger.warning("audio.cpp: server unload failed: %s", exc)
|
||||
self._port = None
|
||||
self._terminate_server()
|
||||
super().unload()
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ENGINE_ID",
|
||||
"AudioCPPBackend",
|
||||
"build_server_config",
|
||||
"build_speech_payload",
|
||||
"decode_speech_json",
|
||||
]
|
||||
@@ -0,0 +1,738 @@
|
||||
"""audio.cpp binary probe + model resolution.
|
||||
|
||||
audio.cpp (0xShug0/audio.cpp) is a pure-C++ ggml inference engine with
|
||||
prebuilt release binaries — no Python venv, no ``transformers`` pin, so
|
||||
none of the dependency-isolation machinery in ``engines._venv_probe`` or
|
||||
``services.subprocess_backend`` applies. The parent instead:
|
||||
|
||||
1. locates a user-installed ``audiocpp_server`` (env var, user dir, or this
|
||||
package's ``bin/``), and
|
||||
2. resolves an explicitly installed GGUF model file from a direct path or
|
||||
the shared Hugging Face cache.
|
||||
|
||||
Probe order for the server binary (existing installs win, zero migration):
|
||||
|
||||
1. ``${OMNIVOICE_AUDIOCPP_BIN}`` — absolute path to the binary itself.
|
||||
2. ``${OMNIVOICE_AUDIOCPP_DIR}/audiocpp_server[.exe]`` — a user-managed
|
||||
install dir (e.g. an extracted release zip, or a self-built tree).
|
||||
3. ``backend/engines/audiocpp/bin/audiocpp_server[.exe]`` — an explicitly
|
||||
installed local copy.
|
||||
|
||||
``is_installed()`` is a cheap file-existence check — no spawn, no network.
|
||||
VoiceStudio never downloads executable code for this engine.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import errno
|
||||
import functools
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
import subprocess # nosec B404 -- fixed argv probes a user-selected executable
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger("omnivoice.audiocpp.bootstrap")
|
||||
|
||||
#: Pinned audio.cpp release. BreezeTTS-2 support landed in 0.7.2 — older
|
||||
#: binaries have no ``breeze_tts`` family, so the floor is also the pin.
|
||||
VERSION = "v0.7.2"
|
||||
|
||||
#: GitHub repo serving the prebuilt binaries.
|
||||
GH_REPO = "0xShug0/audio.cpp"
|
||||
|
||||
#: HuggingFace repo serving the GGUF model packages (not gated).
|
||||
HF_MODEL_REPO = "audio-cpp/audio.cpp-gguf"
|
||||
|
||||
# Immutable repository revision used for the v0.7.2 Breeze-TTS-2 package.
|
||||
# Pinning prevents a later upstream file replacement from silently changing
|
||||
# the model exercised by this backend.
|
||||
HF_MODEL_REVISION = "dc6fecccc2b0c6bdda0a8b2f38fa61394fee0b9c"
|
||||
|
||||
#: Model id used in the generated ``server.json`` and in speech requests.
|
||||
MODEL_ID = "breeze-tts-2"
|
||||
|
||||
#: audio.cpp family name for BreezeTTS 2 (``--family`` / server ``family``).
|
||||
FAMILY = "breeze_tts"
|
||||
|
||||
#: GGUF package directory inside :data:`HF_MODEL_REPO`.
|
||||
PACKAGE_DIR = "Breeze-TTS-2-GGUF"
|
||||
|
||||
#: Default package (Q8_0, the upstream-recommended GGUF). ``bf16`` is
|
||||
#: available via ``OMNIVOICE_AUDIOCPP_PACKAGE``.
|
||||
DEFAULT_PACKAGE = "breeze-tts-2-q8_0.gguf"
|
||||
|
||||
#: Env var pointing directly at the ``audiocpp_server`` binary.
|
||||
BIN_ENV = "OMNIVOICE_AUDIOCPP_BIN"
|
||||
|
||||
#: Env var pointing at a directory containing ``audiocpp_server``.
|
||||
DIR_ENV = "OMNIVOICE_AUDIOCPP_DIR"
|
||||
|
||||
#: Env var overriding the GGUF package filename (e.g. the bf16 package).
|
||||
PACKAGE_ENV = "OMNIVOICE_AUDIOCPP_PACKAGE"
|
||||
|
||||
#: Optional advanced overrides for a binary that exposes several runtimes or
|
||||
#: devices. Device indices are local to the selected backend registry.
|
||||
BACKEND_ENV = "OMNIVOICE_AUDIOCPP_BACKEND"
|
||||
DEVICE_ENV = "OMNIVOICE_AUDIOCPP_DEVICE"
|
||||
|
||||
#: Env var overriding the loopback port the managed server binds.
|
||||
PORT_ENV = "OMNIVOICE_AUDIOCPP_PORT"
|
||||
|
||||
#: Default loopback port. High and engine-specific to avoid clashing with
|
||||
#: the app itself or a user-run ``audiocpp_server`` (default 8080).
|
||||
DEFAULT_PORT = 17860
|
||||
|
||||
#: This package's owned binary dir (probe 3).
|
||||
_PKG_BIN_DIR: Path = Path(__file__).parent / "bin"
|
||||
|
||||
# Recommended (asset filename, sha256) per platform slug, from the v0.7.2
|
||||
# release. Windows and Linux use the vendor-neutral Vulkan build, which also
|
||||
# exposes the native CPU backend. Upstream publishes the macOS builds under
|
||||
# the Metal package name. No linux-aarch64 prebuilt exists in v0.7.2.
|
||||
_ASSETS: dict[str, tuple[str, str]] = {
|
||||
"windows-x64": (
|
||||
"audio-v0.7.2-bin-windows-x64-vulkan.zip",
|
||||
"15b8232eae740e21e507d87f827a89966de9451b085a45932d9e214e032962c1",
|
||||
),
|
||||
"linux-x64": (
|
||||
"audio-v0.7.2-bin-ubuntu-x64-vulkan.tar.gz",
|
||||
"fee1f978cee76453cf17f00196554bc2ee294645739538af0726a143b6a69a23",
|
||||
),
|
||||
"darwin-arm64": (
|
||||
"audio-v0.7.2-bin-macos-arm64-metal.tar.gz",
|
||||
"c01e4f82971bedbe341697e63a9cebd5a5d1f72d5a9bcb51a3191f95ddab7a95",
|
||||
),
|
||||
"darwin-x64": (
|
||||
"audio-v0.7.2-bin-macos-x64-metal.tar.gz",
|
||||
"3862270f33439077225324169313f727064f727305b54d8ce920244d75ddcc24",
|
||||
),
|
||||
}
|
||||
|
||||
#: Binary filename per platform.
|
||||
_BINARY_NAMES = {"windows-x64": "audiocpp_server.exe"}
|
||||
|
||||
_REGISTRY_BACKENDS = {
|
||||
"CPU": "cpu",
|
||||
"CUDA": "cuda",
|
||||
"MUSA": "cuda",
|
||||
"HIP": "hip",
|
||||
"ROCm": "hip",
|
||||
"Vulkan": "vulkan",
|
||||
"Metal": "metal",
|
||||
"MTL": "metal",
|
||||
}
|
||||
_BACKEND_ALIASES = {
|
||||
"cpu": "cpu",
|
||||
"cuda": "cuda",
|
||||
"hip": "hip",
|
||||
"rocm": "hip",
|
||||
"vulkan": "vulkan",
|
||||
"metal": "metal",
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AudioCPPDevice:
|
||||
"""One immutable device from audio.cpp's backend-local registry."""
|
||||
|
||||
registry: str
|
||||
backend: str
|
||||
index: int
|
||||
name: str
|
||||
kind: str
|
||||
target: str
|
||||
hardware_family: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AudioCPPSelection:
|
||||
"""The runtime/device chosen for the next managed server."""
|
||||
|
||||
device: AudioCPPDevice
|
||||
fallback_reason: str | None = None
|
||||
verified_vram_gb: float = 0.0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _ProbeOutcome:
|
||||
devices: tuple[AudioCPPDevice, ...] = ()
|
||||
error: str | None = None
|
||||
|
||||
|
||||
def _cpu_probe_fallback(error: RuntimeError) -> AudioCPPSelection:
|
||||
"""A usable automatic fallback when native device discovery fails."""
|
||||
return AudioCPPSelection(
|
||||
AudioCPPDevice(
|
||||
registry="CPU",
|
||||
backend="cpu",
|
||||
index=0,
|
||||
name="Host CPU",
|
||||
kind="CPU",
|
||||
target="cpu",
|
||||
hardware_family="cpu",
|
||||
),
|
||||
f"{error}; running on CPU",
|
||||
)
|
||||
|
||||
|
||||
def _vulkan_hardware_family(name: str) -> str:
|
||||
low = name.casefold()
|
||||
if any(token in low for token in ("nvidia", "geforce", "quadro", "tesla")):
|
||||
return "cuda"
|
||||
if any(token in low for token in ("amd", "radeon")):
|
||||
return "rocm"
|
||||
if any(token in low for token in ("intel", "arc ")):
|
||||
return "xpu"
|
||||
return "vulkan"
|
||||
|
||||
|
||||
def _device_families(registry: str, name: str, kind: str) -> tuple[str, str]:
|
||||
# Software adapters such as Vulkan llvmpipe may be listed by a GPU
|
||||
# registry but still execute on the CPU. Keep their runtime backend for
|
||||
# explicit overrides while reporting and routing them as CPU work.
|
||||
if kind == "CPU":
|
||||
return "cpu", "cpu"
|
||||
if registry in {"CUDA", "MUSA"}:
|
||||
return "cuda", "cuda"
|
||||
if registry in {"HIP", "ROCm"}:
|
||||
return "rocm", "rocm"
|
||||
if registry in {"Metal", "MTL"}:
|
||||
return "mps", "mps"
|
||||
if registry == "Vulkan":
|
||||
return "vulkan", _vulkan_hardware_family(name)
|
||||
return "cpu", "cpu"
|
||||
|
||||
|
||||
def parse_device_list(output: str) -> tuple[AudioCPPDevice, ...]:
|
||||
"""Parse the stable stdout contract of ``--list-devices``.
|
||||
|
||||
Backend diagnostics are emitted on stderr and deliberately never enter
|
||||
this parser. Unknown future registries are ignored; malformed entries for
|
||||
a registry we understand fail closed instead of selecting the wrong GPU.
|
||||
"""
|
||||
devices: list[AudioCPPDevice] = []
|
||||
seen: set[tuple[str, int]] = set()
|
||||
for raw in str(output or "").splitlines():
|
||||
line = raw.strip()
|
||||
registry, colon, detail = line.partition(":")
|
||||
if not colon or registry not in _REGISTRY_BACKENDS:
|
||||
continue
|
||||
index_text, space, remainder = detail.strip().partition(" ")
|
||||
if not space or not index_text.isascii() or not index_text.isdecimal():
|
||||
raise RuntimeError(
|
||||
f"malformed audio.cpp {registry} device entry"
|
||||
)
|
||||
index = int(index_text)
|
||||
remainder = remainder.strip()
|
||||
kind_start = remainder.rfind("[")
|
||||
if kind_start < 0 or not remainder.endswith("]"):
|
||||
raise RuntimeError(
|
||||
f"malformed audio.cpp {registry} device entry"
|
||||
)
|
||||
name_field = remainder[:kind_start].strip()
|
||||
if name_field:
|
||||
if len(name_field) < 2 or name_field[0] != '"' or name_field[-1] != '"':
|
||||
raise RuntimeError(
|
||||
f"malformed audio.cpp {registry} device entry"
|
||||
)
|
||||
name = name_field[1:-1]
|
||||
else:
|
||||
name = ""
|
||||
kind = remainder[kind_start + 1:-1].strip().upper()
|
||||
if kind not in {"CPU", "GPU", "IGPU", "ACCEL", "META"}:
|
||||
raise RuntimeError("unknown audio.cpp device kind")
|
||||
# Registry aliases such as HIP/ROCm share one backend-local index
|
||||
# namespace and therefore cannot safely describe different devices.
|
||||
key = (_REGISTRY_BACKENDS[registry], index)
|
||||
if key in seen:
|
||||
raise RuntimeError(
|
||||
f"duplicate audio.cpp device entry: {registry}:{index}"
|
||||
)
|
||||
seen.add(key)
|
||||
target, hardware_family = _device_families(registry, name, kind)
|
||||
devices.append(AudioCPPDevice(
|
||||
registry=registry,
|
||||
backend=_REGISTRY_BACKENDS[registry],
|
||||
index=index,
|
||||
name=name,
|
||||
kind=kind,
|
||||
target=target,
|
||||
hardware_family=hardware_family,
|
||||
))
|
||||
if not devices:
|
||||
raise RuntimeError("audio.cpp reported no recognized compute devices")
|
||||
return tuple(devices)
|
||||
|
||||
|
||||
def _platform_slug() -> str:
|
||||
system = sys.platform
|
||||
machine = platform.machine().lower()
|
||||
if system == "win32":
|
||||
return "windows-x64"
|
||||
if system == "darwin":
|
||||
return "darwin-arm64" if machine in ("arm64", "aarch64") else "darwin-x64"
|
||||
if machine in ("x86_64", "amd64"):
|
||||
return "linux-x64"
|
||||
return f"linux-{machine}"
|
||||
|
||||
|
||||
def binary_name(slug: str | None = None) -> str:
|
||||
"""``audiocpp_server`` filename for ``slug`` (``.exe`` on Windows)."""
|
||||
return _BINARY_NAMES.get(slug or _platform_slug(), "audiocpp_server")
|
||||
|
||||
|
||||
def _probe_paths() -> list[Path]:
|
||||
out: list[Path] = []
|
||||
direct = os.environ.get(BIN_ENV, "").strip()
|
||||
if direct:
|
||||
out.append(Path(direct))
|
||||
user_dir = os.environ.get(DIR_ENV, "").strip()
|
||||
if user_dir:
|
||||
out.append(Path(user_dir) / binary_name())
|
||||
out.append(_PKG_BIN_DIR / binary_name())
|
||||
return out
|
||||
|
||||
|
||||
def is_installed() -> bool:
|
||||
"""Cheap precedence-aware check for a usable server binary."""
|
||||
try:
|
||||
resolve_server_binary()
|
||||
except RuntimeError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def resolve_server_binary() -> Path:
|
||||
"""Resolve the ``audiocpp_server`` binary. Raises ``RuntimeError`` with
|
||||
install instructions when none is found."""
|
||||
for cand in _probe_paths():
|
||||
if cand.is_file():
|
||||
if os.name == "nt" or os.access(cand, os.X_OK):
|
||||
return cand
|
||||
raise RuntimeError(
|
||||
"audiocpp_server is not executable. Run `chmod +x "
|
||||
"audiocpp_server` on the configured binary, then restart "
|
||||
"VoiceStudio. See docs/engines/audio-cpp.md."
|
||||
)
|
||||
slug = _platform_slug()
|
||||
asset = _ASSETS.get(slug)
|
||||
if asset is None:
|
||||
raise RuntimeError(
|
||||
f"audio.cpp ships no prebuilt binary for this platform ({slug}). "
|
||||
"Build from https://github.com/0xShug0/audio.cpp and set "
|
||||
f"{BIN_ENV} to your audiocpp_server binary. See "
|
||||
"docs/engines/audio-cpp.md."
|
||||
)
|
||||
raise RuntimeError(
|
||||
"audiocpp_server not found. Download "
|
||||
f"https://github.com/{GH_REPO}/releases/download/{VERSION}/{asset[0]} "
|
||||
f"(SHA-256 {asset[1]}), verify and extract it, and set {BIN_ENV} to the "
|
||||
"audiocpp_server binary (or "
|
||||
f"{DIR_ENV} to its directory). See docs/engines/audio-cpp.md."
|
||||
)
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=4)
|
||||
def _probe_device_outcome(binary: str) -> _ProbeOutcome:
|
||||
try:
|
||||
proc = subprocess.run( # nosec B603 -- executable is the resolved engine binary
|
||||
[binary, "--list-devices"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
check=False,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
return _ProbeOutcome(
|
||||
error="audiocpp_server device discovery timed out after 10 seconds"
|
||||
)
|
||||
except OSError as exc:
|
||||
return _ProbeOutcome(
|
||||
error=(
|
||||
"audiocpp_server device discovery could not start: "
|
||||
f"{type(exc).__name__}"
|
||||
)
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
return _ProbeOutcome(
|
||||
error=(
|
||||
"audiocpp_server device discovery failed "
|
||||
f"(code {proc.returncode}). Check the audio.cpp server log "
|
||||
"for details."
|
||||
)
|
||||
)
|
||||
try:
|
||||
return _ProbeOutcome(devices=parse_device_list(proc.stdout))
|
||||
except RuntimeError as exc:
|
||||
return _ProbeOutcome(error=str(exc))
|
||||
|
||||
|
||||
def _probe_devices(binary: str) -> tuple[AudioCPPDevice, ...]:
|
||||
outcome = _probe_device_outcome(binary)
|
||||
if outcome.error:
|
||||
raise RuntimeError(outcome.error)
|
||||
return outcome.devices
|
||||
|
||||
|
||||
def probe_devices() -> tuple[AudioCPPDevice, ...]:
|
||||
"""Return the installed binary's devices without loading a model."""
|
||||
return _probe_devices(str(resolve_server_binary()))
|
||||
|
||||
|
||||
def _priority(device: AudioCPPDevice) -> tuple[int, int]:
|
||||
if device.kind == "META":
|
||||
# Tensor-parallel meta devices are valid explicit targets, but their
|
||||
# resource footprint is not safe to choose implicitly over CPU.
|
||||
rank = 8
|
||||
elif device.backend != "cpu" and device.kind == "CPU":
|
||||
# Native CPU is the predictable fallback. Software adapters remain
|
||||
# available to an explicit backend override but never win auto mode.
|
||||
rank = 7
|
||||
elif device.backend == "cuda":
|
||||
rank = 0
|
||||
elif device.backend == "hip":
|
||||
rank = 1
|
||||
elif device.backend == "metal":
|
||||
rank = 2
|
||||
elif device.backend == "vulkan" and device.kind == "GPU":
|
||||
rank = 3
|
||||
elif device.backend == "vulkan" and device.kind in {"IGPU", "ACCEL"}:
|
||||
rank = 4
|
||||
elif device.backend == "cpu":
|
||||
rank = 6
|
||||
else:
|
||||
rank = 5
|
||||
return rank, device.index
|
||||
|
||||
|
||||
def select_device(
|
||||
devices: tuple[AudioCPPDevice, ...],
|
||||
*,
|
||||
requested_family: str = "auto",
|
||||
backend_override: str | None = None,
|
||||
device_override: int | None = None,
|
||||
preferred_name: str = "",
|
||||
) -> AudioCPPSelection:
|
||||
"""Resolve one device with explicit overrides and discrete-GPU priority."""
|
||||
if backend_override:
|
||||
normalized = _BACKEND_ALIASES.get(backend_override.strip().lower())
|
||||
if normalized is None:
|
||||
valid = ", ".join(_BACKEND_ALIASES)
|
||||
raise RuntimeError(
|
||||
f"unknown audio.cpp backend '{backend_override}' (valid: {valid})"
|
||||
)
|
||||
candidates = [device for device in devices if device.backend == normalized]
|
||||
if device_override is not None:
|
||||
candidates = [
|
||||
device for device in candidates if device.index == device_override
|
||||
]
|
||||
if not candidates:
|
||||
suffix = "" if device_override is None else f" device {device_override}"
|
||||
available = ", ".join(
|
||||
f"{device.backend}:{device.index}" for device in devices
|
||||
)
|
||||
raise RuntimeError(
|
||||
f"audio.cpp backend '{backend_override}'{suffix} is unavailable "
|
||||
f"(available: {available})"
|
||||
)
|
||||
# An explicit runtime request should still prefer a compute device to
|
||||
# a software adapter when no backend-local index was supplied. META is
|
||||
# valid here because the user explicitly chose this registry.
|
||||
return AudioCPPSelection(min(
|
||||
candidates,
|
||||
key=lambda device: (device.kind == "CPU", _priority(device)),
|
||||
))
|
||||
|
||||
if device_override is not None:
|
||||
raise RuntimeError(
|
||||
f"{DEVICE_ENV} requires {BACKEND_ENV} because device indices are "
|
||||
"backend-local"
|
||||
)
|
||||
|
||||
family = (requested_family or "auto").strip().lower()
|
||||
if family != "auto":
|
||||
candidates = [
|
||||
device for device in devices if device.hardware_family == family
|
||||
]
|
||||
if candidates:
|
||||
preferred = preferred_name.casefold().strip()
|
||||
if preferred:
|
||||
named = [
|
||||
device for device in candidates
|
||||
if device.name
|
||||
and (
|
||||
preferred in device.name.casefold()
|
||||
or device.name.casefold() in preferred
|
||||
)
|
||||
]
|
||||
if named:
|
||||
candidates = named
|
||||
return AudioCPPSelection(min(candidates, key=_priority))
|
||||
cpu = [device for device in devices if device.backend == "cpu"]
|
||||
if cpu:
|
||||
return AudioCPPSelection(
|
||||
min(cpu, key=_priority),
|
||||
f"requested {family.upper()} device is not exposed by the "
|
||||
"installed audio.cpp binary; running on CPU",
|
||||
)
|
||||
raise RuntimeError(
|
||||
f"requested {family.upper()} device is not exposed by the "
|
||||
"installed audio.cpp binary"
|
||||
)
|
||||
|
||||
return AudioCPPSelection(min(devices, key=_priority))
|
||||
|
||||
|
||||
def resolve_compute_selection(caps=None) -> AudioCPPSelection:
|
||||
"""Select the runtime from engine env overrides, Settings, then auto."""
|
||||
backend_override = os.environ.get(BACKEND_ENV, "").strip() or None
|
||||
raw_device = os.environ.get(DEVICE_ENV, "").strip()
|
||||
device_override: int | None = None
|
||||
if raw_device:
|
||||
try:
|
||||
device_override = int(raw_device)
|
||||
except ValueError as exc:
|
||||
raise RuntimeError(
|
||||
f"{DEVICE_ENV} must be a non-negative integer"
|
||||
) from exc
|
||||
if device_override < 0:
|
||||
raise RuntimeError(f"{DEVICE_ENV} must be a non-negative integer")
|
||||
|
||||
if caps is None:
|
||||
from core.device_caps import detect_host_caps
|
||||
|
||||
caps = detect_host_caps()
|
||||
requested = getattr(caps, "requested_family", "auto") or "auto"
|
||||
try:
|
||||
devices = probe_devices()
|
||||
except RuntimeError as exc:
|
||||
if backend_override or raw_device or requested != "auto":
|
||||
raise
|
||||
return _cpu_probe_fallback(exc)
|
||||
selection = select_device(
|
||||
devices,
|
||||
requested_family=requested,
|
||||
backend_override=backend_override,
|
||||
device_override=device_override,
|
||||
preferred_name=getattr(caps, "device_name", "") or "",
|
||||
)
|
||||
# HostCaps measures the preferred accelerator's device 0. Reuse that VRAM
|
||||
# only when the selected native registry has exactly one device with the
|
||||
# same normalized name. Multi-GPU peers with identical names stay unknown.
|
||||
selected_name = " ".join(selection.device.name.casefold().split())
|
||||
host_name = " ".join(
|
||||
str(getattr(caps, "device_name", "") or "").casefold().split()
|
||||
)
|
||||
peers = [
|
||||
device for device in devices
|
||||
if device.backend == selection.device.backend
|
||||
and " ".join(device.name.casefold().split()) == host_name
|
||||
]
|
||||
if (
|
||||
selected_name
|
||||
and selected_name == host_name
|
||||
and len(peers) == 1
|
||||
and float(getattr(caps, "vram_gb", 0.0) or 0.0) > 0
|
||||
):
|
||||
return AudioCPPSelection(
|
||||
selection.device,
|
||||
selection.fallback_reason,
|
||||
float(caps.vram_gb),
|
||||
)
|
||||
return selection
|
||||
|
||||
|
||||
def runtime_targets(devices: tuple[AudioCPPDevice, ...] | None = None) -> tuple[str, ...]:
|
||||
"""Actual compute backends compiled into the selected binary."""
|
||||
if devices is not None:
|
||||
found = devices
|
||||
else:
|
||||
try:
|
||||
found = probe_devices()
|
||||
except RuntimeError:
|
||||
if (
|
||||
os.environ.get(BACKEND_ENV, "").strip()
|
||||
or os.environ.get(DEVICE_ENV, "").strip()
|
||||
):
|
||||
raise
|
||||
return ("cpu",)
|
||||
ordered: list[str] = []
|
||||
for device in sorted(found, key=_priority):
|
||||
if device.target not in ordered:
|
||||
ordered.append(device.target)
|
||||
return tuple(ordered)
|
||||
|
||||
|
||||
def invalidate() -> None:
|
||||
"""Forget cached binary capability discovery after an install change."""
|
||||
_probe_device_outcome.cache_clear()
|
||||
|
||||
|
||||
def default_asset() -> tuple[str, str] | None:
|
||||
"""``(filename, sha256)`` of the release asset for this host, or None
|
||||
when upstream ships no prebuilt for it."""
|
||||
return _ASSETS.get(_platform_slug())
|
||||
|
||||
|
||||
def server_port() -> int:
|
||||
"""Loopback port for the managed server (env override or default)."""
|
||||
raw = os.environ.get(PORT_ENV, "").strip()
|
||||
if raw:
|
||||
try:
|
||||
port = int(raw)
|
||||
if 1 <= port <= 65535:
|
||||
return port
|
||||
logger.warning("Ignoring %s=%r: out of range.", PORT_ENV, raw)
|
||||
except ValueError:
|
||||
logger.warning("Ignoring %s=%r: not a number.", PORT_ENV, raw)
|
||||
return DEFAULT_PORT
|
||||
|
||||
|
||||
def package_filename() -> str:
|
||||
"""GGUF package filename (env override or the Q8_0 default)."""
|
||||
return os.environ.get(PACKAGE_ENV, "").strip() or DEFAULT_PACKAGE
|
||||
|
||||
|
||||
def _materialize_gguf_cache_path(model_file: Path) -> Path:
|
||||
"""Return a real ``.gguf`` path when the HF snapshot is a symlink.
|
||||
|
||||
audio.cpp canonicalizes model paths before inspecting the suffix. The
|
||||
Hugging Face cache points the friendly ``.gguf`` snapshot name at an
|
||||
extensionless content-addressed blob, so passing that symlink makes the
|
||||
server reject a valid model. A hard link beside the snapshot keeps the
|
||||
required suffix without copying a multi-gigabyte model or escaping the
|
||||
snapshot's cleanup lifecycle.
|
||||
"""
|
||||
resolved = model_file.resolve()
|
||||
if resolved.suffix.lower() == ".gguf":
|
||||
return model_file
|
||||
if model_file.suffix.lower() != ".gguf":
|
||||
raise RuntimeError(f"audio.cpp model must be a .gguf file: {model_file}")
|
||||
|
||||
def _link(alias: Path) -> Path:
|
||||
for attempt in range(2):
|
||||
try:
|
||||
os.link(resolved, alias)
|
||||
except FileExistsError:
|
||||
if (
|
||||
not alias.is_symlink()
|
||||
and alias.is_file()
|
||||
and os.path.samefile(resolved, alias)
|
||||
):
|
||||
return alias
|
||||
if attempt == 0 and alias.is_symlink():
|
||||
alias.unlink()
|
||||
continue
|
||||
raise RuntimeError(
|
||||
f"audio.cpp model alias points at a different file: {alias}"
|
||||
) from None
|
||||
return alias
|
||||
raise RuntimeError(f"audio.cpp model alias could not be created: {alias}")
|
||||
|
||||
alias = model_file.with_name(
|
||||
f".{model_file.stem}-{HF_MODEL_REVISION[:12]}.audiocpp.gguf"
|
||||
)
|
||||
try:
|
||||
return _link(alias)
|
||||
except OSError as exc:
|
||||
if exc.errno == errno.EXDEV:
|
||||
# An explicit symlink may live on a different filesystem from its
|
||||
# target. Put the suffix-preserving hard link beside the resolved
|
||||
# file so no multi-gigabyte copy is needed.
|
||||
target_alias = resolved.with_name(
|
||||
f".{resolved.name}-{HF_MODEL_REVISION[:12]}.audiocpp.gguf"
|
||||
)
|
||||
try:
|
||||
return _link(target_alias)
|
||||
except OSError as target_exc:
|
||||
exc = target_exc
|
||||
raise RuntimeError(
|
||||
"audio.cpp cannot materialize the Hugging Face cache symlink as "
|
||||
f"a .gguf hard link: {exc}"
|
||||
) from exc
|
||||
|
||||
|
||||
def resolve_model_file() -> Path:
|
||||
"""Resolve an explicitly installed Breeze-TTS-2 GGUF file.
|
||||
|
||||
An explicit ``OMNIVOICE_AUDIOCPP_MODEL`` path wins (file or directory
|
||||
containing the package file). Otherwise only the local Hugging Face cache
|
||||
is inspected. Downloads must be started explicitly from Model Catalogue →
|
||||
Models, so generation can never silently transfer the 4.73 GiB package.
|
||||
"""
|
||||
override = os.environ.get("OMNIVOICE_AUDIOCPP_MODEL", "").strip()
|
||||
if override:
|
||||
cand = Path(override)
|
||||
if cand.is_file():
|
||||
return _materialize_gguf_cache_path(cand)
|
||||
if cand.is_dir():
|
||||
inner = cand / package_filename()
|
||||
if inner.is_file():
|
||||
return _materialize_gguf_cache_path(inner)
|
||||
raise RuntimeError(
|
||||
f"OMNIVOICE_AUDIOCPP_MODEL={override} is not a GGUF file or a "
|
||||
"directory containing one."
|
||||
)
|
||||
|
||||
from huggingface_hub import snapshot_download
|
||||
from huggingface_hub.utils import LocalEntryNotFoundError
|
||||
|
||||
try:
|
||||
cached = Path(
|
||||
snapshot_download(
|
||||
repo_id=HF_MODEL_REPO,
|
||||
# Full immutable commit SHA declared above; Bandit cannot follow
|
||||
# the module constant through this call.
|
||||
revision=HF_MODEL_REVISION, # nosec B615
|
||||
allow_patterns=[f"{PACKAGE_DIR}/{package_filename()}"],
|
||||
local_files_only=True,
|
||||
)
|
||||
)
|
||||
except (LocalEntryNotFoundError, OSError) as exc:
|
||||
raise RuntimeError(
|
||||
"Breeze-TTS-2 is not installed. Install the audio.cpp Breeze-TTS-2 "
|
||||
"model from Model Catalogue → Models, or set "
|
||||
"OMNIVOICE_AUDIOCPP_MODEL to an existing GGUF file."
|
||||
) from exc
|
||||
model_file = cached / PACKAGE_DIR / package_filename()
|
||||
if not model_file.is_file():
|
||||
raise RuntimeError(
|
||||
f"Breeze-TTS-2 package {package_filename()} is not completely "
|
||||
"installed. Reinstall it from Model Catalogue → Models."
|
||||
)
|
||||
return _materialize_gguf_cache_path(model_file)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AudioCPPDevice",
|
||||
"AudioCPPSelection",
|
||||
"BACKEND_ENV",
|
||||
"BIN_ENV",
|
||||
"DEFAULT_PACKAGE",
|
||||
"DEFAULT_PORT",
|
||||
"DEVICE_ENV",
|
||||
"DIR_ENV",
|
||||
"FAMILY",
|
||||
"HF_MODEL_REPO",
|
||||
"HF_MODEL_REVISION",
|
||||
"MODEL_ID",
|
||||
"PACKAGE_DIR",
|
||||
"PACKAGE_ENV",
|
||||
"PORT_ENV",
|
||||
"VERSION",
|
||||
"_materialize_gguf_cache_path",
|
||||
"binary_name",
|
||||
"default_asset",
|
||||
"invalidate",
|
||||
"is_installed",
|
||||
"package_filename",
|
||||
"parse_device_list",
|
||||
"probe_devices",
|
||||
"resolve_compute_selection",
|
||||
"resolve_model_file",
|
||||
"resolve_server_binary",
|
||||
"runtime_targets",
|
||||
"server_port",
|
||||
]
|
||||
@@ -33,10 +33,20 @@ _ESTIMATES: dict[str, dict] = {
|
||||
"destination": "hf_model_cache",
|
||||
"deduplication": None,
|
||||
},
|
||||
"audiocpp": {
|
||||
"package_download_bytes": None,
|
||||
"unique_installed_bytes": None,
|
||||
"potentially_shared_bytes": None,
|
||||
"temporary_free_bytes": None,
|
||||
"confidence": "estimated",
|
||||
"destination": "hf_model_cache",
|
||||
"deduplication": None,
|
||||
},
|
||||
}
|
||||
_MODEL_REPOS = {
|
||||
"omnivoice": "k2-fsa/OmniVoice",
|
||||
"kittentts": "KittenML/kitten-tts-mini-0.8",
|
||||
"audiocpp": "audio-cpp/audio.cpp-gguf",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -88,14 +88,34 @@ def snapshot(
|
||||
evidence_state = "loaded"
|
||||
if isolated and provider is None and actual_device is None:
|
||||
evidence_state = "subprocess_loaded_provider_unreported"
|
||||
from core.scrub import scrub_text
|
||||
|
||||
runtime_device_name = routing.get("runtime_device_name")
|
||||
device_name = (
|
||||
getattr(caps, "device_name", "")
|
||||
if runtime_device_name is None
|
||||
else runtime_device_name
|
||||
)
|
||||
public_device_name = scrub_text(device_name)[:256]
|
||||
return {
|
||||
"implementation_variant": f"{engine_cls.__module__}.{engine_cls.__name__}",
|
||||
"declared_device_families": list(getattr(engine_cls, "gpu_compat", ("cpu",))),
|
||||
"declared_device_families": list(
|
||||
routing.get("gpu_compat", getattr(engine_cls, "gpu_compat", ("cpu",)))
|
||||
),
|
||||
"evidence_state": evidence_state,
|
||||
"actual_execution_provider": provider,
|
||||
"actual_execution_device": actual_device,
|
||||
"gpu_name": getattr(caps, "device_name", "") or None,
|
||||
"gpu_architecture": _gpu_architecture(getattr(caps, "family", "cpu")),
|
||||
"gpu_name": public_device_name or None,
|
||||
"gpu_architecture": None
|
||||
if (
|
||||
routing.get("runtime_hardware_family")
|
||||
and not routing.get("runtime_device_verified")
|
||||
)
|
||||
else _gpu_architecture(
|
||||
routing.get("runtime_hardware_family")
|
||||
or getattr(caps, "family", "cpu")
|
||||
),
|
||||
"runtime_vram_gb": routing.get("runtime_vram_gb"),
|
||||
"precision_or_quantization": precision,
|
||||
"cpu_fallback_reason": runtime_fallback_reason or (routing.get("routing_reason") if fallback else None),
|
||||
"cpu_fallback_stage": runtime_fallback_stage or ("routing_preflight" if fallback else None),
|
||||
|
||||
@@ -14,6 +14,7 @@ carry a home path.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Literal, TypedDict
|
||||
|
||||
from core.device_caps import (
|
||||
@@ -31,7 +32,44 @@ class RoutingResult(TypedDict):
|
||||
routing_reason: str | None # raw, pre-scrub
|
||||
|
||||
|
||||
def under_provisioned_vram(caps: HostCaps, min_vram_gb: float = 0.0) -> bool:
|
||||
def runtime_compute_profile(engine_or_cls, caps: HostCaps) -> dict:
|
||||
"""Return one engine's runtime-aware compute contract.
|
||||
|
||||
Native executables may discover providers independently of PyTorch. They
|
||||
override ``runtime_compute_profile``; all existing engines retain the
|
||||
exact static routing contract.
|
||||
"""
|
||||
hook = getattr(engine_or_cls, "runtime_compute_profile", None)
|
||||
if callable(hook):
|
||||
return hook(caps)
|
||||
cls = engine_or_cls if isinstance(engine_or_cls, type) else type(engine_or_cls)
|
||||
compat = tuple(getattr(cls, "gpu_compat", ("cpu",)))
|
||||
floor = float(getattr(cls, "min_vram_gb", 0.0) or 0.0)
|
||||
return {
|
||||
"gpu_compat": compat,
|
||||
"min_vram_gb": floor,
|
||||
**resolve_routing(compat, caps, floor),
|
||||
"runtime_backend": None,
|
||||
"runtime_device_index": None,
|
||||
"runtime_device_name": None,
|
||||
"runtime_hardware_family": None,
|
||||
"runtime_vram_gb": None,
|
||||
"runtime_device_verified": None,
|
||||
}
|
||||
|
||||
|
||||
async def runtime_compute_profile_async(engine_or_cls, caps: HostCaps) -> dict:
|
||||
"""Resolve runtime compute metadata without blocking the event loop."""
|
||||
return await asyncio.to_thread(runtime_compute_profile, engine_or_cls, caps)
|
||||
|
||||
|
||||
def under_provisioned_vram(
|
||||
caps: HostCaps,
|
||||
min_vram_gb: float = 0.0,
|
||||
*,
|
||||
family: str | None = None,
|
||||
vram_gb: float | None = None,
|
||||
) -> bool:
|
||||
"""Is this host's DEDICATED VRAM below the engine's declared floor?
|
||||
|
||||
The one definition of "under-provisioned", shared by everything that acts
|
||||
@@ -40,7 +78,8 @@ def under_provisioned_vram(caps: HostCaps, min_vram_gb: float = 0.0) -> bool:
|
||||
.generate_timeout_s``). It was written out inline in each of them, which is
|
||||
how the budget came to disagree with the warning printed next to it.
|
||||
|
||||
Dedicated-VRAM families ONLY. On MPS, ``HostCaps.vram_gb`` is a heuristic
|
||||
Dedicated-VRAM families ONLY. CUDA, ROCm, XPU, and a native Vulkan device
|
||||
report dedicated memory. On MPS, ``HostCaps.vram_gb`` is a heuristic
|
||||
(system RAM / 2, see device_caps) for a UNIFIED memory pool; comparing it
|
||||
against a floor measured on discrete CUDA hardware would tell every 8 GB Mac
|
||||
its 4 GB "VRAM" is too small for an engine that runs fine there. A VRAM
|
||||
@@ -49,10 +88,35 @@ def under_provisioned_vram(caps: HostCaps, min_vram_gb: float = 0.0) -> bool:
|
||||
"""
|
||||
if not min_vram_gb or min_vram_gb <= 0:
|
||||
return False
|
||||
if getattr(caps, "family", None) not in ("cuda", "rocm"):
|
||||
if (family or getattr(caps, "family", None)) not in (
|
||||
"cuda", "rocm", "xpu", "vulkan",
|
||||
):
|
||||
return False
|
||||
vram_gb = float(getattr(caps, "vram_gb", 0.0) or 0.0)
|
||||
return 0 < vram_gb < float(min_vram_gb)
|
||||
raw_vram_gb = getattr(caps, "vram_gb", 0.0) if vram_gb is None else vram_gb
|
||||
available_vram_gb = float(raw_vram_gb or 0.0)
|
||||
return 0 < available_vram_gb < float(min_vram_gb)
|
||||
|
||||
|
||||
def low_vram_caveat(
|
||||
caps: HostCaps,
|
||||
min_vram_gb: float = 0.0,
|
||||
*,
|
||||
family: str | None = None,
|
||||
vram_gb: float | None = None,
|
||||
) -> str | None:
|
||||
"""User-facing advisory for a known under-provisioned dedicated GPU."""
|
||||
if not under_provisioned_vram(
|
||||
caps, min_vram_gb, family=family, vram_gb=vram_gb,
|
||||
):
|
||||
return None
|
||||
device = caps.device_name or (family or caps.family).upper()
|
||||
available_vram_gb = caps.vram_gb if vram_gb is None else vram_gb
|
||||
return (
|
||||
f"{device} has {available_vram_gb:.1f} GB VRAM; this engine wants about "
|
||||
f"{min_vram_gb:.0f} GB. It will run, but expect slow generations "
|
||||
f"that may time out. Unload other models before generating, keep "
|
||||
f"the text short, or pick a lighter engine."
|
||||
)
|
||||
|
||||
|
||||
def _caveat(caps: HostCaps, min_vram_gb: float = 0.0) -> str | None:
|
||||
@@ -75,15 +139,7 @@ def _caveat(caps: HostCaps, min_vram_gb: float = 0.0) -> str | None:
|
||||
for note in caps.notes:
|
||||
if KERNEL_RISK_MARKER in note:
|
||||
return f"{caps.family.upper()} selected, but: {note}"
|
||||
if under_provisioned_vram(caps, min_vram_gb):
|
||||
device = caps.device_name or caps.family.upper()
|
||||
return (
|
||||
f"{device} has {caps.vram_gb:.1f} GB VRAM; this engine wants about "
|
||||
f"{min_vram_gb:.0f} GB. It will run, but expect slow generations "
|
||||
f"that may time out. Unload other models before generating, keep "
|
||||
f"the text short, or pick a lighter engine."
|
||||
)
|
||||
return None
|
||||
return low_vram_caveat(caps, min_vram_gb)
|
||||
|
||||
|
||||
def resolve_routing(
|
||||
@@ -223,5 +279,7 @@ def routing_fields(
|
||||
|
||||
__all__ = [
|
||||
"RoutingStatus", "RoutingResult", "resolve_routing", "routing_fields",
|
||||
"routing_notice", "header_safe_reason", "under_provisioned_vram",
|
||||
"routing_notice", "header_safe_reason", "low_vram_caveat",
|
||||
"runtime_compute_profile", "runtime_compute_profile_async",
|
||||
"under_provisioned_vram",
|
||||
]
|
||||
|
||||
@@ -15,6 +15,7 @@ _SHA = re.compile(r"[0-9a-f]{40}\Z")
|
||||
CURATED_REVISIONS: dict[str, str] = {
|
||||
"facebook/nllb-200-distilled-600M": "f8d333a098d19b4fd9a8b18f94170487ad3f821d",
|
||||
"k2-fsa/OmniVoice": "c5fdb5ccb189668d56333f77ba2629f4cd7535f4",
|
||||
"audio-cpp/audio.cpp-gguf": "dc6fecccc2b0c6bdda0a8b2f38fa61394fee0b9c",
|
||||
"Systran/faster-whisper-large-v3": "edaa852ec7e145841d8ffdb056a99866b5f0a478",
|
||||
"mlx-community/whisper-large-v3-mlx": "49e6aa286ad60c14352c404340ded53710378a11",
|
||||
"mlx-community/whisper-large-v3-turbo": "a4aaeec0636e6fef84abdcbe3544cb2bf7e9f6fb",
|
||||
|
||||
@@ -525,7 +525,8 @@ class GpuPoolBusyError(TimeoutError):
|
||||
|
||||
def generate_timeout_s(
|
||||
text: "str | None", *, engine: object = None, execution_device: "str | None" = None,
|
||||
min_vram_gb: float = 0.0,
|
||||
min_vram_gb: float = 0.0, hardware_family: "str | None" = None,
|
||||
vram_gb: "float | None" = None,
|
||||
) -> float:
|
||||
"""THE wall-clock execution budget for one synthesis job, scaled to input.
|
||||
|
||||
@@ -555,7 +556,10 @@ def generate_timeout_s(
|
||||
the card. Only the budget ignored it. So an under-provisioned accelerator
|
||||
now floors at the CPU budget — the class of hardware it actually performs
|
||||
like. ``min_vram_gb`` is the engine's declared floor; callers that pass
|
||||
``engine`` get it read off the engine automatically.
|
||||
``engine`` get it read off the engine automatically. Native runtimes pass
|
||||
an explicit ``vram_gb=0`` when their dedicated-memory probe failed; that
|
||||
unknown capacity gets the same conservative CPU-class budget without
|
||||
claiming the card is under-provisioned in user-facing diagnostics.
|
||||
"""
|
||||
base = GPU_JOB_TIMEOUT_S
|
||||
try:
|
||||
@@ -565,14 +569,12 @@ def generate_timeout_s(
|
||||
if not min_vram_gb and engine is not None:
|
||||
min_vram_gb = float(getattr(engine, "min_vram_gb", 0.0) or 0.0)
|
||||
if execution_device is None and engine is not None:
|
||||
from services.engine_routing import resolve_routing
|
||||
compat = getattr(engine, "gpu_compat", None)
|
||||
if compat is None:
|
||||
compat = getattr(type(engine), "gpu_compat", (family, "cpu"))
|
||||
if tuple(compat) == ("cpu",):
|
||||
family = "cpu"
|
||||
else:
|
||||
family = resolve_routing(compat, caps, min_vram_gb)["effective_device"]
|
||||
from services.engine_routing import runtime_compute_profile
|
||||
profile = runtime_compute_profile(engine, caps)
|
||||
family = profile["effective_device"]
|
||||
min_vram_gb = profile["min_vram_gb"]
|
||||
hardware_family = profile.get("runtime_hardware_family")
|
||||
vram_gb = profile.get("runtime_vram_gb")
|
||||
universal_override = (
|
||||
_GENERATE_TIMEOUT_EXPLICIT
|
||||
or GPU_JOB_TIMEOUT_S != _CONFIGURED_GPU_JOB_TIMEOUT_S
|
||||
@@ -586,10 +588,21 @@ def generate_timeout_s(
|
||||
)
|
||||
if family == "cpu" and (cpu_explicit or not universal_override):
|
||||
base = CPU_JOB_TIMEOUT_S
|
||||
elif not universal_override and family in ("cuda", "rocm"):
|
||||
elif not universal_override and family in (
|
||||
"cuda", "rocm", "vulkan", "xpu",
|
||||
):
|
||||
from services.engine_routing import under_provisioned_vram
|
||||
|
||||
if under_provisioned_vram(caps, min_vram_gb):
|
||||
runtime_family = hardware_family or family
|
||||
unknown_dedicated_vram = (
|
||||
min_vram_gb > 0
|
||||
and runtime_family in ("cuda", "rocm", "xpu", "vulkan")
|
||||
and vram_gb is not None
|
||||
and float(vram_gb or 0.0) <= 0
|
||||
)
|
||||
if unknown_dedicated_vram or under_provisioned_vram(
|
||||
caps, min_vram_gb, family=hardware_family, vram_gb=vram_gb,
|
||||
):
|
||||
# `max`, never a plain assignment: an operator who raised the
|
||||
# accelerated budget above the CPU one must not have it cut.
|
||||
base = max(base, CPU_JOB_TIMEOUT_S)
|
||||
|
||||
@@ -300,6 +300,31 @@ class TTSBackend(ABC):
|
||||
#: 0 means "no meaningful floor" (CPU-class engines) and never warns.
|
||||
min_vram_gb: float = 0.0
|
||||
|
||||
@classmethod
|
||||
def runtime_compute_profile(cls, caps) -> dict:
|
||||
"""Resolved compute metadata for this engine on the current host.
|
||||
|
||||
Most engines have one implementation whose static declarations are
|
||||
sufficient. Native adapters may override this single hook when the
|
||||
installed executable determines both the available runtimes and the
|
||||
device actually selected.
|
||||
"""
|
||||
from services.engine_routing import resolve_routing
|
||||
|
||||
gpu_compat = tuple(getattr(cls, "gpu_compat", ("cpu",)))
|
||||
min_vram_gb = float(getattr(cls, "min_vram_gb", 0.0) or 0.0)
|
||||
return {
|
||||
"gpu_compat": gpu_compat,
|
||||
"min_vram_gb": min_vram_gb,
|
||||
**resolve_routing(gpu_compat, caps, min_vram_gb),
|
||||
"runtime_backend": None,
|
||||
"runtime_device_index": None,
|
||||
"runtime_device_name": None,
|
||||
"runtime_hardware_family": None,
|
||||
"runtime_vram_gb": None,
|
||||
"runtime_device_verified": None,
|
||||
}
|
||||
|
||||
#: True when generation allocates in ANOTHER process — a dedicated-venv
|
||||
#: sidecar (SubprocessBackend) or a spawned binary (omnivoice-gguf).
|
||||
#: Parent-process accelerator counters cannot see those allocations, so
|
||||
@@ -2235,6 +2260,13 @@ _LAZY_REGISTRY: dict[str, tuple[str, str]] = {
|
||||
# 2026-07-02 (CPU, Apple Silicon; 22.05 kHz output). Gated behind
|
||||
# OMNIVOICE_CONFUCIUS4_TTS_DIR so it's inert until enabled.
|
||||
"confucius4-tts": ("engines.confucius4", "Confucius4Backend"),
|
||||
# audio.cpp (0xShug0/audio.cpp) — pure-C++ ggml runtime, no Python venv.
|
||||
# v1 serves Breeze-TTS-2 (en+zh, clone+design) through a parent-managed
|
||||
# audiocpp_server over loopback HTTP. Gated behind a server binary
|
||||
# (OMNIVOICE_AUDIOCPP_BIN) so it's inert until enabled. Lazy for the
|
||||
# same import-cycle reason as the entries above (engines.audiocpp
|
||||
# imports services.tts_backend for TTSBackend).
|
||||
"audiocpp": ("engines.audiocpp", "AudioCPPBackend"),
|
||||
}
|
||||
|
||||
|
||||
@@ -2339,6 +2371,7 @@ _INSTALL_HINTS: dict[str, str] = {
|
||||
"moss-tts-v15": "git clone OpenMOSS/MOSS-TTS + set OMNIVOICE_MOSS_TTS_V15_DIR (own venv, transformers==5.0; 8B, ~16 GB weights; CUDA/ROCm/XPU/NPU/CPU, no MPS; Apache-2.0)",
|
||||
"dots-tts": "git clone rednote-hilab/dots.tts + set OMNIVOICE_DOTS_TTS_DIR (own venv, transformers==4.57; 2B, ~9 GB weights; CUDA/CPU, Linux/macOS only — no Windows; Apache-2.0)",
|
||||
"confucius4-tts":"git clone netease-youdao/Confucius4-TTS + set OMNIVOICE_CONFUCIUS4_TTS_DIR (own Python 3.10 venv; 14-lang cross-lingual zero-shot clone; ~5 GB weights auto-download; CUDA/ROCm/XPU/NPU/CPU, no MPS; Apache-2.0)",
|
||||
"audiocpp": "download the matching audio.cpp v0.7.2 prebuilt + set OMNIVOICE_AUDIOCPP_BIN, then explicitly install Breeze-TTS-2 in Model Catalogue → Models (native CPU/Vulkan/CUDA/Metal GGUF server, no Python; en+zh clone+design; ~4.73 GiB; weights research/non-commercial only)",
|
||||
}
|
||||
|
||||
|
||||
@@ -2454,7 +2487,7 @@ def list_backends() -> list[dict]:
|
||||
"one_click_install": bool, # services.sidecar_install can provision it in-app
|
||||
"last_error": Optional[str], # cached most-recent failure
|
||||
"isolation_mode": "in-process" | "subprocess",
|
||||
"gpu_compat": list[str], # subset of {cuda, rocm, mps, xpu, npu, cpu}
|
||||
"gpu_compat": list[str], # subset of {cuda, rocm, mps, vulkan, xpu, npu, cpu}
|
||||
"supports_cloning": Optional[bool], # True/False from the class attr; None when
|
||||
# model-dependent (property, e.g. mlx-audio)
|
||||
"effective_device": str, # device this engine uses on THIS host
|
||||
@@ -2484,7 +2517,6 @@ def list_backends() -> list[dict]:
|
||||
from core.device_caps import detect_host_caps
|
||||
from services.engine_disk_usage import disk_summary_for
|
||||
from services.engine_evidence import snapshot as execution_snapshot
|
||||
from services.engine_routing import routing_fields
|
||||
caps = detect_host_caps()
|
||||
installable = _sidecar_installable_ids()
|
||||
|
||||
@@ -2510,14 +2542,40 @@ def list_backends() -> list[dict]:
|
||||
isolation = "subprocess"
|
||||
else:
|
||||
isolation = "in-process"
|
||||
gpu_compat = getattr(cls, "gpu_compat", ("cpu",))
|
||||
from services.engine_routing import resolve_routing, runtime_compute_profile
|
||||
try:
|
||||
profile = runtime_compute_profile(cls, caps)
|
||||
except Exception:
|
||||
# Runtime-aware native probes remain optional metadata. A broken
|
||||
# provider probe must not take down the engine picker, especially
|
||||
# when availability already explains a missing binary or model.
|
||||
compat = tuple(getattr(cls, "gpu_compat", ("cpu",)))
|
||||
floor = float(getattr(cls, "min_vram_gb", 0.0) or 0.0)
|
||||
profile = {
|
||||
"gpu_compat": compat,
|
||||
"min_vram_gb": floor,
|
||||
**resolve_routing(compat, caps, floor),
|
||||
"runtime_backend": None,
|
||||
"runtime_device_index": None,
|
||||
"runtime_device_name": None,
|
||||
"runtime_hardware_family": None,
|
||||
"runtime_vram_gb": None,
|
||||
"runtime_device_verified": None,
|
||||
}
|
||||
gpu_compat = profile["gpu_compat"]
|
||||
# Cloning capability: same descriptor guard as
|
||||
# cloning_capable_engine_ids() — a class-level getattr on a *property*
|
||||
# (mlx-audio: capability depends on the picked model) returns the
|
||||
# descriptor, not a bool, so report None (= model-dependent) there
|
||||
# instead of an always-truthy false positive.
|
||||
_clone = getattr(cls, "supports_cloning", True)
|
||||
routing = routing_fields(gpu_compat, caps, getattr(cls, "min_vram_gb", 0.0))
|
||||
from core.scrub import scrub_text
|
||||
routing = {
|
||||
"effective_device": profile["effective_device"],
|
||||
"routing_status": profile["routing_status"],
|
||||
"routing_reason": scrub_text(profile["routing_reason"])
|
||||
if profile["routing_reason"] else None,
|
||||
}
|
||||
loaded_instance = None
|
||||
if _active_instance_id == bid:
|
||||
loaded_instance = _active_instance
|
||||
@@ -2551,7 +2609,7 @@ def list_backends() -> list[dict]:
|
||||
"isolation_mode": isolation,
|
||||
"gpu_compat": list(gpu_compat),
|
||||
# effective_device / routing_status / routing_reason (scrubbed):
|
||||
"min_vram_gb": getattr(cls, "min_vram_gb", 0.0) or None,
|
||||
"min_vram_gb": profile["min_vram_gb"] or None,
|
||||
# effective_device / routing_status / routing_reason (scrubbed);
|
||||
# the reason now also carries the under-provisioned-GPU caveat.
|
||||
**routing,
|
||||
@@ -2559,7 +2617,7 @@ def list_backends() -> list[dict]:
|
||||
engine_id=bid,
|
||||
engine_cls=cls,
|
||||
instance=loaded_instance,
|
||||
routing=routing,
|
||||
routing={**profile, **routing},
|
||||
caps=caps,
|
||||
),
|
||||
})
|
||||
@@ -2981,10 +3039,9 @@ async def resolve_generation_backend(
|
||||
raise ValueError(f"TTS engine '{engine_id}' is not available: {_mask_hf_tokens(msg)}")
|
||||
|
||||
from core.device_caps import detect_host_caps
|
||||
from services.engine_routing import resolve_routing
|
||||
routing = resolve_routing(
|
||||
getattr(backend_cls, "gpu_compat", ("cpu",)), detect_host_caps(),
|
||||
getattr(backend_cls, "min_vram_gb", 0.0),
|
||||
from services.engine_routing import runtime_compute_profile_async
|
||||
routing = await runtime_compute_profile_async(
|
||||
backend_cls, detect_host_caps()
|
||||
)
|
||||
if routing["routing_status"] == "unavailable":
|
||||
raise ValueError(routing["routing_reason"])
|
||||
@@ -3022,4 +3079,6 @@ def __getattr__(name: str): # pragma: no cover - exercised via tests
|
||||
return _REGISTRY[name if name in _REGISTRY else None]
|
||||
if name == "IndexTTS2Backend":
|
||||
return _REGISTRY["indextts2"]
|
||||
if name == "AudioCPPBackend":
|
||||
return _REGISTRY["audiocpp"]
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
|
||||
@@ -23,6 +23,8 @@ from __future__ import annotations
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from worker.capacity import derive_concurrency
|
||||
|
||||
logger = logging.getLogger("omnivoice.worker")
|
||||
|
||||
# gpu_compat families that mean "this would run on the CPU here", which is
|
||||
@@ -74,6 +76,12 @@ def discover(*, include_unavailable: bool = False) -> list[dict]:
|
||||
gpu_compat = set(entry.get("gpu_compat") or [])
|
||||
repo_ids = repo_ids_for(entry)
|
||||
downloaded = _downloaded(repo_ids)
|
||||
runtime_vram_gb = (entry.get("execution_evidence") or {}).get(
|
||||
"runtime_vram_gb"
|
||||
)
|
||||
engine_free_bytes = free_bytes if runtime_vram_gb is None else int(
|
||||
float(runtime_vram_gb or 0.0) * 1024**3
|
||||
)
|
||||
discovered.append(
|
||||
{
|
||||
"engine": engine_id,
|
||||
@@ -97,7 +105,17 @@ def discover(*, include_unavailable: bool = False) -> list[dict]:
|
||||
"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,
|
||||
"free_memory_bytes": engine_free_bytes,
|
||||
# A native provider that works independently of torch may not
|
||||
# expose memory telemetry. Unknown capacity still gets one
|
||||
# serial slot; zero must not turn a working Vulkan engine into
|
||||
# an unschedulable capability.
|
||||
"derived_concurrency": 1
|
||||
if (
|
||||
runtime_vram_gb is not None
|
||||
and float(runtime_vram_gb or 0.0) <= 0
|
||||
and routing == "accelerated"
|
||||
) else 0,
|
||||
# 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")
|
||||
@@ -291,9 +309,18 @@ def max_concurrent_tasks(capabilities: Optional[list[dict]] = None) -> int:
|
||||
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
|
||||
derived: list[int] = []
|
||||
for cap in caps:
|
||||
concurrency = int(cap.get("derived_concurrency") or 0)
|
||||
if concurrency <= 0:
|
||||
concurrency = 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")),
|
||||
)
|
||||
derived.append(concurrency)
|
||||
return min(derived)
|
||||
|
||||
|
||||
__all__ = [
|
||||
|
||||
@@ -97,18 +97,16 @@ def derive_concurrency(
|
||||
) -> 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.
|
||||
Under-provisioned accelerators remain usable with one serial slot and the
|
||||
longer CPU-class deadline. Zero memory means telemetry is unknown, not
|
||||
that the engine cannot run.
|
||||
"""
|
||||
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
|
||||
return 1
|
||||
budget = max(min_model_bytes, _VRAM_PER_JOB_BYTES)
|
||||
if budget <= 0:
|
||||
return 1
|
||||
|
||||
@@ -143,7 +143,9 @@ def _base_execution_seconds(
|
||||
control plane, and its hardware is not the worker's.
|
||||
"""
|
||||
target_device = str(execution_device or "cpu").lower()
|
||||
if target_device not in {"cpu", "cuda", "mps", "mlx", "directml", "rocm", "xpu"}:
|
||||
if target_device not in {
|
||||
"cpu", "cuda", "mps", "mlx", "directml", "rocm", "vulkan", "xpu",
|
||||
}:
|
||||
target_device = "cpu"
|
||||
if under_provisioned and target_device != "cpu":
|
||||
return max(
|
||||
|
||||
@@ -34,7 +34,7 @@ _HEARTBEAT_MISS_SECONDS = 90.0
|
||||
# enough that one slow answer cannot move it.
|
||||
_LATENCY_WINDOW = 5
|
||||
_KNOWN_EXECUTION_DEVICES = frozenset(
|
||||
{"cpu", "cuda", "mps", "mlx", "directml", "rocm", "xpu"}
|
||||
{"cpu", "cuda", "mps", "mlx", "directml", "rocm", "vulkan", "xpu"}
|
||||
)
|
||||
|
||||
|
||||
@@ -150,7 +150,9 @@ class ConnectedWorker:
|
||||
cap = self._capability_for(engine, model_id, operation)
|
||||
if cap is None or cap.get("cpu_fallback"):
|
||||
return False
|
||||
if str(cap.get("backend") or "").lower() not in ("cuda", "rocm"):
|
||||
if str(cap.get("backend") or "").lower() not in (
|
||||
"cuda", "rocm", "vulkan",
|
||||
):
|
||||
return False
|
||||
floor = int(cap.get("min_memory_bytes") or 0)
|
||||
have = int(cap.get("free_memory_bytes") or 0)
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -93,7 +93,7 @@ class HostInfo(_message.Message):
|
||||
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")
|
||||
__slots__ = ("engine", "model_id", "operations", "supported", "installed", "downloaded", "resident", "min_memory_bytes", "precision", "derived_concurrency", "cpu_fallback", "repo_ids", "display_name", "backend", "free_memory_bytes")
|
||||
ENGINE_FIELD_NUMBER: _ClassVar[int]
|
||||
MODEL_ID_FIELD_NUMBER: _ClassVar[int]
|
||||
OPERATIONS_FIELD_NUMBER: _ClassVar[int]
|
||||
@@ -107,6 +107,8 @@ class ModelCapability(_message.Message):
|
||||
CPU_FALLBACK_FIELD_NUMBER: _ClassVar[int]
|
||||
REPO_IDS_FIELD_NUMBER: _ClassVar[int]
|
||||
DISPLAY_NAME_FIELD_NUMBER: _ClassVar[int]
|
||||
BACKEND_FIELD_NUMBER: _ClassVar[int]
|
||||
FREE_MEMORY_BYTES_FIELD_NUMBER: _ClassVar[int]
|
||||
engine: str
|
||||
model_id: str
|
||||
operations: _containers.RepeatedScalarFieldContainer[str]
|
||||
@@ -120,7 +122,9 @@ class ModelCapability(_message.Message):
|
||||
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: ...
|
||||
backend: str
|
||||
free_memory_bytes: int
|
||||
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] = ..., backend: _Optional[str] = ..., free_memory_bytes: _Optional[int] = ...) -> 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")
|
||||
|
||||
@@ -168,6 +168,11 @@ message ModelCapability {
|
||||
// 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;
|
||||
// Per-engine runtime routing. Native engines can select a provider that is
|
||||
// independent of the worker's global torch device.
|
||||
string backend = 14;
|
||||
// Memory measured for that exact selected provider/device; zero is unknown.
|
||||
uint64 free_memory_bytes = 15;
|
||||
}
|
||||
|
||||
message RegisterRequest {
|
||||
|
||||
@@ -229,10 +229,27 @@ def capability_to_pb(cap: dict) -> pb.ModelCapability:
|
||||
cpu_fallback=bool(cap.get("cpu_fallback")),
|
||||
repo_ids=list(cap.get("repo_ids") or []),
|
||||
display_name=str(cap.get("display_name") or ""),
|
||||
backend=str(cap.get("backend") or ""),
|
||||
free_memory_bytes=int(cap.get("free_memory_bytes") or 0),
|
||||
)
|
||||
|
||||
|
||||
def capability_from_pb(message: pb.ModelCapability) -> dict:
|
||||
def capability_from_pb(
|
||||
message: pb.ModelCapability, *, fallback_backend: str = ""
|
||||
) -> dict:
|
||||
"""Decode a capability, including protocol-v2 peers from before backend.
|
||||
|
||||
``backend`` was added to the existing protocol-v2 message, so an older
|
||||
peer legitimately sends its protobuf default (the empty string). The
|
||||
host-level GPU backend is the only compatible execution-device signal in
|
||||
that payload. A capability explicitly marked as a CPU fallback must stay
|
||||
on CPU even when its host also has a GPU.
|
||||
"""
|
||||
backend = str(message.backend or "").strip().lower()
|
||||
if message.cpu_fallback:
|
||||
backend = "cpu"
|
||||
elif not backend:
|
||||
backend = str(fallback_backend or "").strip().lower()
|
||||
return {
|
||||
"engine": message.engine,
|
||||
"model_id": message.model_id,
|
||||
@@ -249,6 +266,8 @@ def capability_from_pb(message: pb.ModelCapability) -> dict:
|
||||
"cpu_fallback": message.cpu_fallback,
|
||||
"repo_ids": list(message.repo_ids),
|
||||
"display_name": message.display_name,
|
||||
"backend": backend,
|
||||
"free_memory_bytes": message.free_memory_bytes,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -851,10 +851,12 @@ class WorkerServicer(pb_grpc.WorkerServiceServicer):
|
||||
epoch: int,
|
||||
) -> pb.RegisterResponse:
|
||||
session = identity.issue_session(worker_id=worker.id, key_id=worker.key_id, epoch=epoch)
|
||||
capabilities = [codec.capability_from_pb(c) for c in request.capabilities]
|
||||
host = codec.host_from_pb(request.host)
|
||||
|
||||
backend = host["gpus"][0].get("backend", "") if host.get("gpus") else ""
|
||||
capabilities = [
|
||||
codec.capability_from_pb(c, fallback_backend=backend)
|
||||
for c in request.capabilities
|
||||
]
|
||||
claimed_refs = {
|
||||
ref.attempt_id: codec.task_ref(
|
||||
ref.task_id, ref.attempt_id, ref.session_epoch
|
||||
@@ -2044,7 +2046,14 @@ class WorkerServicer(pb_grpc.WorkerServiceServicer):
|
||||
session.worker_id,
|
||||
)
|
||||
return
|
||||
caps = [codec.capability_from_pb(c) for c in update.capabilities]
|
||||
worker = self.pool.get(session.worker_id)
|
||||
fallback_backend = (
|
||||
worker.capacity.backend if worker is not None else ""
|
||||
)
|
||||
caps = [
|
||||
codec.capability_from_pb(c, fallback_backend=fallback_backend)
|
||||
for c in update.capabilities
|
||||
]
|
||||
self._queue_capability_update(session, caps)
|
||||
return
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ Every engine in the tree owns at least one job. A job has exactly one holder.
|
||||
| Fastest CPU render / lowest latency | *open — see #1306* |
|
||||
| Best Chinese/Japanese expressiveness | `cosyvoice`, `indextts2` |
|
||||
| CPU-realtime English, tiny footprint | `kittentts`, `supertonic3` |
|
||||
| Bilingual reference-free voice design and direction | `audiocpp` |
|
||||
| Best transcription accuracy | `whisperx`, `faster-whisper` |
|
||||
| Fastest Apple-Silicon transcription | `parakeet-mlx`, `mlx-whisper` |
|
||||
| Crash isolation for transcription | `faster-whisper-isolated` |
|
||||
@@ -35,8 +36,11 @@ which is a property of the bar, not a judgement of the contributor.
|
||||
does not cover it. Latency, language, hardware envelope or quality tier —
|
||||
something a user would choose it *for*.
|
||||
2. **Licence clean for commercial use.** Model weights *and* code. No
|
||||
research-only weights, no ambiguous provenance. This is the one that most
|
||||
often ends a proposal, so check it first.
|
||||
research-only weights or ambiguous provenance. The single approved
|
||||
exception is `audiocpp` with Breeze-TTS-2 research/non-commercial weights,
|
||||
approved by the owner on 2026-09-08 for its requested bilingual voice-design
|
||||
and direction workflow. It remains opt-in and discloses the restriction
|
||||
before selection and download; `@debpalash` is its named steward.
|
||||
3. **Every platform, or explicitly opt-in.** macOS (Apple Silicon and Intel),
|
||||
Windows, Linux. A CPU path is required — an engine that only runs on one
|
||||
accelerator is fine, but it must degrade rather than break, and a
|
||||
|
||||
@@ -45,6 +45,7 @@ approval), [Windows](../install/windows.md), [Linux](../install/linux.md),
|
||||
| OmniVoice (subprocess) | [omnivoice-subprocess](omnivoice-subprocess.md) | CUDA · MPS · CPU | ✅ | opt-in pick, no install |
|
||||
| PocketTTS (Kyutai) | [pockettts](pockettts.md) | CPU (not Intel Mac) | ✅ | `uv sync --extra pockettts` + license |
|
||||
| Confucius4-TTS | [confucius4-tts](confucius4-tts.md) | CUDA · CPU | ✅ | clone + env var |
|
||||
| audio.cpp (Breeze-TTS-2) | [audio-cpp](audio-cpp.md) | CPU + Vulkan/Metal/CUDA/HIP/ROCm where compiled | ✅ + voice design | prebuilt binary + env var (weights research/non-commercial) |
|
||||
|
||||
## Speech-to-text
|
||||
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
# VoiceStudio — audio.cpp Engine (Breeze-TTS-2)
|
||||
|
||||
[audio.cpp](https://github.com/0xShug0/audio.cpp) is a pure-C++ ggml audio
|
||||
inference framework with prebuilt binaries for Windows, macOS, and Linux and
|
||||
no Python dependency. VoiceStudio discovers the compute providers compiled
|
||||
into the installed binary and selects the best available device. VoiceStudio drives its
|
||||
`audiocpp_server` over loopback HTTP — v1 serves the **`breeze_tts`**
|
||||
family: **Breeze-TTS-2** (BreezeBlue, 3B params, English + Chinese, voice
|
||||
clone + voice design + voice direction, 24 kHz).
|
||||
|
||||
> **Opt-in, and never a default.** Select `audiocpp` explicitly in **Model
|
||||
> Catalogue → Engines** (or `OMNIVOICE_TTS_BACKEND=audiocpp`).
|
||||
|
||||
## License — read before enabling
|
||||
|
||||
- **audio.cpp code:** Apache-2.0.
|
||||
- **Breeze-TTS-2 weights** (upstream `BreezeBlue/Breeze-TTS-2` and the
|
||||
`audio-cpp/audio.cpp-gguf` GGUF repack): **research and non-commercial
|
||||
use only** under the [BreezeBlue Research and Non-Commercial License](
|
||||
https://huggingface.co/BreezeBlue/Breeze-TTS-2/blob/main/LICENSE).
|
||||
Self-hosted outputs inherit the restriction; a BreezeBlue paid
|
||||
subscription covers hosted-platform outputs only, not this engine.
|
||||
|
||||
## Platform support
|
||||
|
||||
| Host | Binary | Compute |
|
||||
|---|---|---|
|
||||
| Windows x64 | CPU, Vulkan, CUDA 12.4/13.3 prebuilts | CPU, Vulkan, CUDA |
|
||||
| Linux x64 | CPU and Vulkan prebuilts | CPU, Vulkan; CUDA/ROCm from a self-build |
|
||||
| macOS arm64 | upstream Metal prebuilt | Metal + CPU |
|
||||
| macOS x64 | upstream `metal` archive (Metal disabled by upstream) | CPU |
|
||||
| Linux aarch64 | none upstream | unavailable in v1 |
|
||||
|
||||
VoiceStudio runs `audiocpp_server --list-devices` once per installed binary,
|
||||
prefers CUDA, ROCm, Metal, then a discrete Vulkan GPU, and keeps CPU as a safe
|
||||
fallback. Device numbers are local to each backend registry. The Q8_0 GGUF is
|
||||
approximately 4.73 GiB, plus runtime memory.
|
||||
|
||||
Allow about 6 GB of dedicated VRAM for CUDA, HIP, or a discrete GPU exposed
|
||||
through Vulkan. Metal and Vulkan integrated GPUs use unified-memory handling
|
||||
instead of that dedicated-VRAM floor.
|
||||
|
||||
## Install
|
||||
|
||||
1. Download the v0.7.2 prebuilt for your platform from
|
||||
[audio.cpp releases](https://github.com/0xShug0/audio.cpp/releases/tag/v0.7.2)
|
||||
and extract it. Use the Vulkan archive on Windows or Linux for broad GPU
|
||||
support, the CPU archive when Vulkan is unavailable, or the matching CUDA
|
||||
archive on Windows for NVIDIA. A Windows CUDA install needs both the
|
||||
`bin-…-cuda…` and matching `cudart-…-cuda…` archives extracted into the
|
||||
same directory, as required by upstream. VoiceStudio does not download
|
||||
executable code for this engine. Linux archives do not preserve the
|
||||
executable bit, so run `chmod +x audiocpp_server` after extracting one.
|
||||
|
||||
Verify the archive before extracting it. The pinned SHA-256 checksums are:
|
||||
|
||||
| Archive | SHA-256 |
|
||||
|---|---|
|
||||
| `audio-v0.7.2-bin-windows-x64-cpu-portable.zip` | `0b1f4bd78c5226ee3fa0eb24d95d603a429439cdf5dab45872d44a87412dd8c1` |
|
||||
| `audio-v0.7.2-bin-windows-x64-vulkan.zip` | `15b8232eae740e21e507d87f827a89966de9451b085a45932d9e214e032962c1` |
|
||||
| `audio-v0.7.2-bin-windows-x64-cuda12.4.zip` | `06c426095008022a2984ff1c75de4c9fab463c4201c0ff0a5dc4e14043f52326` |
|
||||
| `audio-v0.7.2-cudart-windows-x64-cuda12.4.zip` | `7115be4d462817ad293f7932a8ac436d51023128e6728af09bba92a85593f393` |
|
||||
| `audio-v0.7.2-bin-windows-x64-cuda13.3.zip` | `f975fec52745807b8c787e826c110acc3424a45156092455e1635604b68ec832` |
|
||||
| `audio-v0.7.2-cudart-windows-x64-cuda13.3.zip` | `9b508f702636a9cdf3bf4dd8e75a86c20a0b87bdc39ca07e714c82f748efc1fa` |
|
||||
| `audio-v0.7.2-bin-ubuntu-x64-cpu.tar.gz` | `6f5e43dd7b80e8ddf688ef84b411fadcd1f934d2c83963178bc4e2d9c4f07736` |
|
||||
| `audio-v0.7.2-bin-ubuntu-x64-vulkan.tar.gz` | `fee1f978cee76453cf17f00196554bc2ee294645739538af0726a143b6a69a23` |
|
||||
| `audio-v0.7.2-bin-macos-arm64-metal.tar.gz` | `c01e4f82971bedbe341697e63a9cebd5a5d1f72d5a9bcb51a3191f95ddab7a95` |
|
||||
| `audio-v0.7.2-bin-macos-x64-metal.tar.gz` | `3862270f33439077225324169313f727064f727305b54d8ce920244d75ddcc24` |
|
||||
|
||||
Run `sha256sum <archive>` on Linux, `shasum -a 256 <archive>` on macOS,
|
||||
or `Get-FileHash <archive> -Algorithm SHA256` in PowerShell and compare the
|
||||
complete result with the table.
|
||||
2. Set `OMNIVOICE_AUDIOCPP_BIN` to the `audiocpp_server` binary
|
||||
(`audiocpp_server.exe` on Windows):
|
||||
|
||||
```bash
|
||||
# macOS / Linux
|
||||
echo 'export OMNIVOICE_AUDIOCPP_BIN=$HOME/apps/audio.cpp/audiocpp_server' >> ~/.zshrc
|
||||
source ~/.zshrc
|
||||
```
|
||||
|
||||
Alternatively set `OMNIVOICE_AUDIOCPP_DIR` to the directory containing it.
|
||||
3. Restart VoiceStudio, open **Model Catalogue → Models**, find
|
||||
**Breeze-TTS-2 Q8_0 for audio.cpp**, review its research/non-commercial
|
||||
license note, and click **Install**. Generation never starts this ~4.73 GiB
|
||||
download automatically.
|
||||
4. Pick `audiocpp` in **Model Catalogue → Engines**. The server starts
|
||||
lazily on first generate (`server.json` + `server.log` live under the app
|
||||
data `audiocpp/` directory).
|
||||
|
||||
## Voice modes
|
||||
|
||||
All three go through the one speech endpoint — reference presence selects:
|
||||
|
||||
- **Clone:** `ref_audio` + `ref_text` (exact transcript, as upstream).
|
||||
- **Direction:** `ref_audio` + `ref_text` + `instruct`
|
||||
(e.g. "Speak slowly with a restrained, serious tone").
|
||||
- **Design:** `description` (or `instruct`) with no `ref_audio`
|
||||
(e.g. "A warm, thoughtful young woman…"). Upstream strengthens
|
||||
instruction-following with `guidance_scale` ≈ 4.
|
||||
|
||||
## Optional env knobs
|
||||
|
||||
| Variable | Default | Purpose |
|
||||
|----------|---------|---------|
|
||||
| `OMNIVOICE_AUDIOCPP_BIN` | — | Absolute path to `audiocpp_server`. |
|
||||
| `OMNIVOICE_AUDIOCPP_DIR` | — | Directory containing `audiocpp_server`. |
|
||||
| `OMNIVOICE_AUDIOCPP_MODEL` | Model Catalogue cache | GGUF file or directory override. |
|
||||
| `OMNIVOICE_AUDIOCPP_PACKAGE` | `breeze-tts-2-q8_0.gguf` | Package filename (`…-bf16.gguf` for full precision). |
|
||||
| `OMNIVOICE_AUDIOCPP_PORT` | `17860` | Loopback port. |
|
||||
| `OMNIVOICE_AUDIOCPP_BACKEND` | Settings, then auto | Exact runtime: `cuda`, `hip`/`rocm`, `vulkan`, `metal`, or `cpu`. |
|
||||
| `OMNIVOICE_AUDIOCPP_DEVICE` | best device | Backend-local non-negative device index; requires `OMNIVOICE_AUDIOCPP_BACKEND`. |
|
||||
|
||||
The audio.cpp overrides take precedence over the global Settings compute
|
||||
choice. A global CUDA/ROCm choice can match an NVIDIA/AMD GPU exposed through
|
||||
Vulkan. An unavailable explicit audio.cpp override is an error; an unavailable
|
||||
global preference falls back to CPU and is shown as a routing fallback.
|
||||
|
||||
## Common errors
|
||||
|
||||
### `audiocpp_server not found ...`
|
||||
|
||||
The binary isn't installed. Follow **Install** — the message carries the
|
||||
exact release URL and SHA for your platform.
|
||||
|
||||
### `audiocpp_server exited during startup ...`
|
||||
|
||||
The managed loopback port may be taken. Check `server.log` next to
|
||||
`server.json` in the app data `audiocpp/` directory, or set a different
|
||||
`OMNIVOICE_AUDIOCPP_PORT` and restart VoiceStudio.
|
||||
|
||||
### `Breeze-TTS-2 ... not installed` or `package ... not completely installed`
|
||||
|
||||
Install the model from **Model Catalogue → Models**. If an interrupted install
|
||||
left it incomplete, use **Reinstall** there. If the error persists after a
|
||||
complete reinstall, file an issue with the package listing.
|
||||
|
||||
---
|
||||
|
||||
audio.cpp runs as a managed native server (no Python venv, no
|
||||
`transformers` conflict). Only the downloaded GGUF counts toward
|
||||
[sidecar disk usage](disk-usage.md).
|
||||
@@ -60,6 +60,8 @@ tts_engines:
|
||||
readme: "**Confucius4-TTS**"
|
||||
doc: docs/engines/confucius4-tts.md
|
||||
- id: pockettts
|
||||
- id: audiocpp
|
||||
doc: docs/engines/audio-cpp.md
|
||||
|
||||
# Same contract against backend/services/asr_backend.py _REGISTRY.
|
||||
asr_engines:
|
||||
|
||||
@@ -18,7 +18,7 @@ export type EngineFamily = 'tts' | 'asr' | 'llm';
|
||||
// (`effective_device` / `routing_status` / `routing_reason`). They stay
|
||||
// optional so the matrix still renders a legacy/older payload that omits them
|
||||
// (it gates with `??` / `?.length` and suppresses the routing badge).
|
||||
type GPUTarget = 'cuda' | 'mps' | 'rocm' | 'xpu' | 'cpu';
|
||||
type GPUTarget = 'cuda' | 'mps' | 'rocm' | 'vulkan' | 'xpu' | 'cpu';
|
||||
// Where an engine actually runs on THIS host. `network` is LLM-only (remote).
|
||||
type EffectiveDevice = GPUTarget | 'network';
|
||||
// `n/a` is LLM-only; resolve_routing only ever returns the first four.
|
||||
|
||||
@@ -142,6 +142,8 @@ const CHIP_DEVICE = {
|
||||
cuda: 'text-[#76b900] border-[color:color-mix(in_srgb,#76b900_45%,transparent)] bg-[color:color-mix(in_srgb,#76b900_10%,transparent)]',
|
||||
mps: 'text-[#b8b8b8] border-[color:color-mix(in_srgb,#b8b8b8_45%,transparent)] bg-[color:color-mix(in_srgb,#b8b8b8_10%,transparent)]',
|
||||
rocm: 'text-[#ed1c24] border-[color:color-mix(in_srgb,#ed1c24_45%,transparent)] bg-[color:color-mix(in_srgb,#ed1c24_10%,transparent)]',
|
||||
vulkan:
|
||||
'text-[#b62e3b] border-[color:color-mix(in_srgb,#b62e3b_45%,transparent)] bg-[color:color-mix(in_srgb,#b62e3b_10%,transparent)]',
|
||||
xpu: 'text-[#0071c5] border-[color:color-mix(in_srgb,#0071c5_45%,transparent)] bg-[color:color-mix(in_srgb,#0071c5_10%,transparent)]',
|
||||
cpu: 'text-[color:var(--chrome-fg-muted,#888)] border-[color:var(--chrome-border-strong,rgba(255,255,255,0.18))] bg-transparent',
|
||||
};
|
||||
@@ -187,9 +189,11 @@ const ROW_GRID =
|
||||
// groups instead of carrying the Settings panel's compressed tracks across a
|
||||
// large canvas. The wider action track also lets its controls wrap naturally
|
||||
// without clipping. Collapse earlier than the compact matrix because these
|
||||
// tracks deliberately have larger minimums.
|
||||
// tracks deliberately have larger minimums. `[&>*]:min-w-0` lets every cell
|
||||
// shrink below content size so mid-width shells squeeze instead of clipping;
|
||||
// phones (<=640px) stack via the `catalogue-row` @container tier in index.css.
|
||||
const CATALOGUE_ROW_GRID =
|
||||
'catalogue-row-grid grid items-center gap-x-[16px] px-[16px] ' +
|
||||
'catalogue-row-grid grid items-center gap-x-[16px] px-[16px] [&>*]:min-w-0 ' +
|
||||
'grid-cols-[minmax(300px,1.45fr)_128px_minmax(230px,1fr)_112px_minmax(292px,auto)] ' +
|
||||
'@max-[1230px]/catalogue-shell:grid-cols-[max-content_max-content_minmax(0,1fr)_max-content]';
|
||||
// Per-cell placement for the collapsed (narrow) layout.
|
||||
@@ -1179,7 +1183,7 @@ export default function EngineCompatibilityMatrix({
|
||||
<div
|
||||
role="cell"
|
||||
className={cn(
|
||||
'engine-matrix__cell engine-matrix__cell--gpu flex min-w-0 flex-col justify-center gap-[4px]',
|
||||
'engine-matrix__cell engine-matrix__cell--gpu flex min-w-0 max-w-full flex-col justify-center gap-[4px]',
|
||||
catalogueLayout ? 'overflow-visible' : 'overflow-hidden',
|
||||
cellNarrow.gpu,
|
||||
)}
|
||||
@@ -1196,6 +1200,10 @@ export default function EngineCompatibilityMatrix({
|
||||
b.routing_status &&
|
||||
b.routing_status !== 'unavailable' &&
|
||||
g === b.effective_device;
|
||||
const deviceLabel =
|
||||
g === 'vulkan'
|
||||
? t('engines.gpuVulkan')
|
||||
: GPU_LABEL[g] || g.toUpperCase();
|
||||
return (
|
||||
<span
|
||||
key={g}
|
||||
@@ -1203,12 +1211,12 @@ export default function EngineCompatibilityMatrix({
|
||||
title={
|
||||
isEffective
|
||||
? t('engines.routingEffectiveChip', {
|
||||
device: GPU_LABEL[g] || g,
|
||||
device: deviceLabel,
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{GPU_LABEL[g] || g.toUpperCase()}
|
||||
{deviceLabel}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
@@ -1284,7 +1292,7 @@ export default function EngineCompatibilityMatrix({
|
||||
<div
|
||||
role="cell"
|
||||
className={cn(
|
||||
'engine-matrix__cell engine-matrix__cell--actions flex h-full max-h-full flex-wrap content-center items-center justify-end justify-self-end',
|
||||
'engine-matrix__cell engine-matrix__cell--actions flex h-full max-h-full min-w-0 max-w-full flex-wrap content-center items-center justify-end justify-self-end',
|
||||
catalogueLayout
|
||||
? 'gap-[8px] overflow-visible py-[8px]'
|
||||
: 'gap-[4px] overflow-hidden py-[4px]',
|
||||
|
||||
@@ -822,6 +822,7 @@
|
||||
"routingRemote": "بعيد",
|
||||
"routingUnknown": "غير معروف",
|
||||
"routingEffectiveChip": "يعمل على {{device}} على هذا الجهاز",
|
||||
"gpuVulkan": "Vulkan",
|
||||
"routingCaveatTitle": "تم تحديد وحدة معالجة الرسومات، ولكن: {{reason}}",
|
||||
"selectCpuFallback": "{{engine}}: يعمل على المعالج المركزي — {{reason}}",
|
||||
"selectWithCaveat": "تم التبديل إلى {{engine}} — {{reason}}",
|
||||
|
||||
@@ -822,6 +822,7 @@
|
||||
"routingRemote": "Fernbedienung",
|
||||
"routingUnknown": "Unbekannt",
|
||||
"routingEffectiveChip": "Läuft unter {{device}} auf diesem Computer",
|
||||
"gpuVulkan": "Vulkan",
|
||||
"routingCaveatTitle": "GPU ausgewählt, aber: {{reason}}",
|
||||
"selectCpuFallback": "{{engine}}: läuft auf der CPU — {{reason}}",
|
||||
"selectWithCaveat": "Zu {{engine}} gewechselt — {{reason}}",
|
||||
|
||||
@@ -2163,6 +2163,7 @@
|
||||
"routingRemote": "Remote",
|
||||
"routingUnknown": "Unknown",
|
||||
"routingEffectiveChip": "Runs on {{device}} on this machine",
|
||||
"gpuVulkan": "Vulkan",
|
||||
"routingCaveatTitle": "GPU selected, but: {{reason}}",
|
||||
"selectCpuFallback": "{{engine}}: running on CPU — {{reason}}",
|
||||
"curatedModelLabel": "Model",
|
||||
|
||||
@@ -822,6 +822,7 @@
|
||||
"routingRemote": "Remoto",
|
||||
"routingUnknown": "Desconocido",
|
||||
"routingEffectiveChip": "Se ejecuta en {{device}} en esta máquina",
|
||||
"gpuVulkan": "Vulkan",
|
||||
"routingCaveatTitle": "GPU seleccionada, pero: {{reason}}",
|
||||
"selectCpuFallback": "{{engine}}: ejecutándose en la CPU — {{reason}}",
|
||||
"selectWithCaveat": "Se cambió a {{engine}}: {{reason}}",
|
||||
|
||||
@@ -822,6 +822,7 @@
|
||||
"routingRemote": "À distance",
|
||||
"routingUnknown": "Inconnu",
|
||||
"routingEffectiveChip": "Fonctionne le {{device}} sur cette machine",
|
||||
"gpuVulkan": "Vulkan",
|
||||
"routingCaveatTitle": "GPU sélectionné, mais : {{reason}}",
|
||||
"selectCpuFallback": "{{engine}} : fonctionne sur le processeur — {{reason}}",
|
||||
"selectWithCaveat": "Basculé vers {{engine}} — {{reason}}",
|
||||
|
||||
@@ -822,6 +822,7 @@
|
||||
"routingRemote": "रिमोट",
|
||||
"routingUnknown": "अज्ञात",
|
||||
"routingEffectiveChip": "इस मशीन पर {{device}} पर चलता है",
|
||||
"gpuVulkan": "Vulkan",
|
||||
"routingCaveatTitle": "GPU चयनित, लेकिन: {{reason}}",
|
||||
"selectCpuFallback": "{{engine}}: CPU पर चल रहा है — {{reason}}",
|
||||
"selectWithCaveat": "{{engine}} पर स्विच किया गया — {{reason}}",
|
||||
|
||||
@@ -822,6 +822,7 @@
|
||||
"routingRemote": "Terpencil",
|
||||
"routingUnknown": "Tidak diketahui",
|
||||
"routingEffectiveChip": "Berjalan pada {{device}} di mesin ini",
|
||||
"gpuVulkan": "Vulkan",
|
||||
"routingCaveatTitle": "GPU dipilih, tetapi: {{reason}}",
|
||||
"selectCpuFallback": "{{engine}}: berjalan di CPU — {{reason}}",
|
||||
"selectWithCaveat": "Beralih ke {{engine}} — {{reason}}",
|
||||
|
||||
@@ -822,6 +822,7 @@
|
||||
"routingRemote": "Remoto",
|
||||
"routingUnknown": "Sconosciuto",
|
||||
"routingEffectiveChip": "Funziona su {{device}} su questa macchina",
|
||||
"gpuVulkan": "Vulkan",
|
||||
"routingCaveatTitle": "GPU selezionata, ma: {{reason}}",
|
||||
"selectCpuFallback": "{{engine}}: in esecuzione sulla CPU — {{reason}}",
|
||||
"selectWithCaveat": "Passato a {{engine}} — {{reason}}",
|
||||
|
||||
@@ -822,6 +822,7 @@
|
||||
"routingRemote": "リモート",
|
||||
"routingUnknown": "不明",
|
||||
"routingEffectiveChip": "このマシンの {{device}} で実行されます",
|
||||
"gpuVulkan": "Vulkan",
|
||||
"routingCaveatTitle": "GPU が選択されましたが: {{reason}}",
|
||||
"selectCpuFallback": "{{engine}}: CPU で実行中 — {{reason}}",
|
||||
"selectWithCaveat": "{{engine}} に切り替えました — {{reason}}",
|
||||
|
||||
@@ -1066,6 +1066,7 @@
|
||||
"routingRemote": "원격",
|
||||
"routingUnknown": "알 수 없음",
|
||||
"routingEffectiveChip": "이 머신의 {{device}}에서 실행됩니다.",
|
||||
"gpuVulkan": "Vulkan",
|
||||
"routingCaveatTitle": "GPU가 선택되었지만: {{reason}}",
|
||||
"selectCpuFallback": "{{engine}}: CPU에서 실행 중 — {{reason}}",
|
||||
"selectWithCaveat": "{{engine}}(으)로 전환했습니다 — {{reason}}",
|
||||
|
||||
@@ -822,6 +822,7 @@
|
||||
"routingRemote": "Op afstand",
|
||||
"routingUnknown": "Onbekend",
|
||||
"routingEffectiveChip": "Draait op {{device}} op deze machine",
|
||||
"gpuVulkan": "Vulkan",
|
||||
"routingCaveatTitle": "GPU geselecteerd, maar: {{reason}}",
|
||||
"selectCpuFallback": "{{engine}}: draait op de CPU — {{reason}}",
|
||||
"selectWithCaveat": "Overgeschakeld naar {{engine}} — {{reason}}",
|
||||
|
||||
@@ -822,6 +822,7 @@
|
||||
"routingRemote": "Zdalny",
|
||||
"routingUnknown": "Nieznany",
|
||||
"routingEffectiveChip": "Działa na {{device}} na tym komputerze",
|
||||
"gpuVulkan": "Vulkan",
|
||||
"routingCaveatTitle": "Wybrano procesor graficzny, ale: {{reason}}",
|
||||
"selectCpuFallback": "{{engine}}: działa na CPU — {{reason}}",
|
||||
"selectWithCaveat": "Przełączono na {{engine}} — {{reason}}",
|
||||
|
||||
@@ -822,6 +822,7 @@
|
||||
"routingRemote": "Remoto",
|
||||
"routingUnknown": "Desconhecido",
|
||||
"routingEffectiveChip": "Funciona em {{device}} nesta máquina",
|
||||
"gpuVulkan": "Vulkan",
|
||||
"routingCaveatTitle": "GPU selecionada, mas: {{reason}}",
|
||||
"selectCpuFallback": "{{engine}}: em execução na CPU — {{reason}}",
|
||||
"selectWithCaveat": "Alterado para {{engine}} — {{reason}}",
|
||||
|
||||
@@ -822,6 +822,7 @@
|
||||
"routingRemote": "Удаленный",
|
||||
"routingUnknown": "Неизвестный",
|
||||
"routingEffectiveChip": "Работает на {{device}} на этом компьютере",
|
||||
"gpuVulkan": "Vulkan",
|
||||
"routingCaveatTitle": "Графический процессор выбран, но: {{reason}}",
|
||||
"selectCpuFallback": "{{engine}}: работает на CPU — {{reason}}",
|
||||
"selectWithCaveat": "Переключено на {{engine}} — {{reason}}",
|
||||
|
||||
@@ -822,6 +822,7 @@
|
||||
"routingRemote": "Fjärrkontroll",
|
||||
"routingUnknown": "Okänd",
|
||||
"routingEffectiveChip": "Körs på {{device}} på den här maskinen",
|
||||
"gpuVulkan": "Vulkan",
|
||||
"routingCaveatTitle": "GPU vald, men: {{reason}}",
|
||||
"selectCpuFallback": "{{engine}}: körs på CPU — {{reason}}",
|
||||
"selectWithCaveat": "Bytte till {{engine}} — {{reason}}",
|
||||
|
||||
@@ -822,6 +822,7 @@
|
||||
"routingRemote": "ระยะไกล",
|
||||
"routingUnknown": "ไม่ทราบ",
|
||||
"routingEffectiveChip": "ทำงานบน {{device}} บนเครื่องนี้",
|
||||
"gpuVulkan": "Vulkan",
|
||||
"routingCaveatTitle": "เลือก GPU แล้ว แต่: {{reason}}",
|
||||
"selectCpuFallback": "{{engine}}: กำลังทำงานบน CPU — {{reason}}",
|
||||
"selectWithCaveat": "สลับไปที่ {{engine}} — {{reason}}",
|
||||
|
||||
@@ -822,6 +822,7 @@
|
||||
"routingRemote": "Uzaktan",
|
||||
"routingUnknown": "Bilinmiyor",
|
||||
"routingEffectiveChip": "Bu makinede {{device}} tarihinde çalışıyor",
|
||||
"gpuVulkan": "Vulkan",
|
||||
"routingCaveatTitle": "GPU seçildi, ancak: {{reason}}",
|
||||
"selectCpuFallback": "{{engine}}: CPU üzerinde çalışıyor — {{reason}}",
|
||||
"selectWithCaveat": "{{engine}} motoruna geçildi — {{reason}}",
|
||||
|
||||
@@ -822,6 +822,7 @@
|
||||
"routingRemote": "Дистанційний",
|
||||
"routingUnknown": "Невідомий",
|
||||
"routingEffectiveChip": "Працює на {{device}} на цій машині",
|
||||
"gpuVulkan": "Vulkan",
|
||||
"routingCaveatTitle": "Графічний процесор вибрано, але: {{reason}}",
|
||||
"selectCpuFallback": "{{engine}}: працює на CPU — {{reason}}",
|
||||
"selectWithCaveat": "Перемкнено на {{engine}} — {{reason}}",
|
||||
|
||||
@@ -822,6 +822,7 @@
|
||||
"routingRemote": "Từ xa",
|
||||
"routingUnknown": "Không xác định",
|
||||
"routingEffectiveChip": "Chạy trên {{device}} trên máy này",
|
||||
"gpuVulkan": "Vulkan",
|
||||
"routingCaveatTitle": "Đã chọn GPU nhưng: {{reason}}",
|
||||
"selectCpuFallback": "{{engine}}: đang chạy trên CPU — {{reason}}",
|
||||
"selectWithCaveat": "Đã chuyển sang {{engine}} — {{reason}}",
|
||||
|
||||
@@ -779,6 +779,7 @@
|
||||
"routingRemote": "远程",
|
||||
"routingUnknown": "未知",
|
||||
"routingEffectiveChip": "在本机的 {{device}} 上运行",
|
||||
"gpuVulkan": "Vulkan",
|
||||
"routingCaveatTitle": "已选择 GPU,但是:{{reason}}",
|
||||
"curatedModelLabel": "模型",
|
||||
"curatedModelAria": "{{engine}} 的模型",
|
||||
|
||||
@@ -822,6 +822,7 @@
|
||||
"routingRemote": "遠端",
|
||||
"routingUnknown": "未知",
|
||||
"routingEffectiveChip": "在此機器上的 {{device}} 上執行",
|
||||
"gpuVulkan": "Vulkan",
|
||||
"routingCaveatTitle": "已選擇 GPU,但是:{{reason}}",
|
||||
"selectCpuFallback": "{{engine}}:正在 CPU 上執行 — {{reason}}",
|
||||
"selectWithCaveat": "已切換至 {{engine}} — {{reason}}",
|
||||
|
||||
@@ -7179,3 +7179,43 @@ button.dub-stepper__action:focus-visible {
|
||||
max-height: 260px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Model Catalogue engines list — phone tier. The Tailwind collapse at 1230px
|
||||
keeps four columns (status / gpu / isolation / actions side by side), which
|
||||
still overflows ~360px shells: the actions cell alone is 250px+ of buttons
|
||||
and the table clips horizontal overflow. Below 640px stack each catalogue
|
||||
row into one column — identity first, meta chips next, actions last and
|
||||
left-aligned so every control stays reachable without sideways scrolling.
|
||||
Unlayered, so it wins over the layered grid utilities deterministically. */
|
||||
@container catalogue-shell (max-width: 640px) {
|
||||
.engine-matrix__table .catalogue-row-grid.catalogue-row {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
row-gap: 8px;
|
||||
align-items: start;
|
||||
padding-inline: 12px;
|
||||
}
|
||||
.engine-matrix__table .catalogue-row > [role='cell'] {
|
||||
grid-column: 1 / -1;
|
||||
max-width: 100%;
|
||||
}
|
||||
.engine-matrix__table .catalogue-row > .engine-matrix__cell--name {
|
||||
grid-row: 1;
|
||||
}
|
||||
.engine-matrix__table .catalogue-row > .engine-matrix__cell--status {
|
||||
grid-row: 2;
|
||||
justify-self: start;
|
||||
}
|
||||
.engine-matrix__table .catalogue-row > .engine-matrix__cell--gpu {
|
||||
grid-row: 3;
|
||||
justify-self: start;
|
||||
}
|
||||
.engine-matrix__table .catalogue-row > .engine-matrix__cell--isolation {
|
||||
grid-row: 4;
|
||||
justify-self: start;
|
||||
}
|
||||
.engine-matrix__table .catalogue-row > .engine-matrix__cell--actions {
|
||||
grid-row: 5;
|
||||
justify-self: stretch;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import React from 'react';
|
||||
import i18next from 'i18next';
|
||||
import { I18nextProvider } from 'react-i18next';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
|
||||
@@ -486,6 +488,43 @@ describe('EngineCompatibilityMatrix', () => {
|
||||
expect(within(row).getByText('MPS').classList.contains('is-effective')).toBe(false);
|
||||
});
|
||||
|
||||
it('uses the active locale for the Vulkan chip and effective-device tooltip', async () => {
|
||||
const localizedI18n = i18next.createInstance();
|
||||
await localizedI18n.init({
|
||||
lng: 'es',
|
||||
fallbackLng: false,
|
||||
resources: {
|
||||
es: {
|
||||
translation: {
|
||||
engines: {
|
||||
gpuVulkan: 'Vulkan localizada',
|
||||
routingEffectiveChip: 'Se ejecuta en {{device}}',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const response = routingResponse();
|
||||
response.tts.backends[0].gpu_compat = ['vulkan'];
|
||||
response.tts.backends[0].effective_device = 'vulkan';
|
||||
|
||||
render(
|
||||
<I18nextProvider i18n={localizedI18n}>
|
||||
<EngineCompatibilityMatrix
|
||||
family="tts"
|
||||
apiListEngines={vi.fn().mockResolvedValue(response)}
|
||||
apiGetEngineHealth={vi.fn()}
|
||||
/>
|
||||
</I18nextProvider>,
|
||||
);
|
||||
|
||||
const row = (await screen.findByText('Accel TTS')).closest('[role="row"]');
|
||||
expect(within(row).getByText('Vulkan localizada')).toHaveAttribute(
|
||||
'title',
|
||||
'Se ejecuta en Vulkan localizada',
|
||||
);
|
||||
});
|
||||
|
||||
it('shows a "CPU fallback" badge for a cpu_fallback engine', async () => {
|
||||
const apiListEngines = vi.fn().mockResolvedValue(routingResponse());
|
||||
render(
|
||||
@@ -1401,6 +1440,42 @@ describe('EngineCompatibilityMatrix', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('exposes the phone-tier stacking hooks so narrow shells never clip Catalogue rows', async () => {
|
||||
// jsdom cannot evaluate @container queries, so this guards the contract
|
||||
// the index.css phone tier depends on: every catalogue row carries the
|
||||
// `catalogue-row` marker, each of the five cells carries its
|
||||
// `engine-matrix__cell--*` modifier, the grid lets children shrink below
|
||||
// content size, and the actions cell wraps instead of forcing overflow.
|
||||
render(
|
||||
<EngineCompatibilityMatrix
|
||||
family="tts"
|
||||
catalogueLayout
|
||||
apiListEngines={vi.fn().mockResolvedValue(makeEnginesResponse())}
|
||||
apiGetEngineHealth={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
await screen.findByText('OmniVoice (test)');
|
||||
|
||||
for (const row of document.querySelectorAll('[data-engine-id]')) {
|
||||
expect(row).toHaveClass('catalogue-row-grid');
|
||||
expect(row).toHaveClass('catalogue-row');
|
||||
expect(row.className).toContain('[&>*]:min-w-0');
|
||||
for (const modifier of [
|
||||
'engine-matrix__cell--name',
|
||||
'engine-matrix__cell--status',
|
||||
'engine-matrix__cell--gpu',
|
||||
'engine-matrix__cell--isolation',
|
||||
'engine-matrix__cell--actions',
|
||||
]) {
|
||||
expect(row.querySelector(`.${modifier}`)).not.toBeNull();
|
||||
}
|
||||
const actions = row.querySelector('.engine-matrix__cell--actions');
|
||||
expect(actions.className).toMatch(/\bflex-wrap\b/);
|
||||
expect(actions).toHaveClass('min-w-0');
|
||||
expect(actions).toHaveClass('max-w-full');
|
||||
}
|
||||
});
|
||||
|
||||
it('header and every row share identical grid column tracks', async () => {
|
||||
const apiListEngines = vi.fn().mockResolvedValue(makeEnginesResponse());
|
||||
render(
|
||||
|
||||
@@ -39,14 +39,15 @@ function okResponse(rec: LastRunCrashRecord | null, acknowledged = false) {
|
||||
|
||||
describe('_adaptLastRunCrash — run-sentinel record → CrashMarker shape', () => {
|
||||
it('maps the record so the existing crash UI can render it', () => {
|
||||
const marker = _adaptLastRunCrash(record(), false);
|
||||
const sourceRecord = record();
|
||||
const marker = _adaptLastRunCrash(sourceRecord, false);
|
||||
expect(marker.exit_code).toBeNull();
|
||||
expect(marker.signal).toBeNull();
|
||||
// describeCrashExit falls through to exit_desc on a null code+signal.
|
||||
expect(describeCrashExit(marker)).toBe('process ended uncleanly (previous run)');
|
||||
expect(marker.backend_version).toBe('0.3.23');
|
||||
expect(marker.uptime_s).toBe(510);
|
||||
expect(marker.ts).toBe(record().detected_at);
|
||||
expect(marker.ts).toBe(sourceRecord.detected_at);
|
||||
expect(marker.acknowledged).toBe(false);
|
||||
// The "stderr" evidence carries the last activity + the scrubbed log tail.
|
||||
expect(marker.last_stderr).toContain('last activity before the death: transcribe (dub)');
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -140,6 +140,40 @@ def test_list_backends_resilient(registry_sandbox, caplog):
|
||||
)
|
||||
|
||||
|
||||
def test_unavailable_backend_can_report_installed_runtime_targets(
|
||||
registry_sandbox,
|
||||
):
|
||||
class RuntimeKnownButModelMissing(HealthyInProcessBackend):
|
||||
id = "runtime-known"
|
||||
display_name = "Runtime known"
|
||||
|
||||
@classmethod
|
||||
def is_available(cls):
|
||||
return False, "model missing"
|
||||
|
||||
@classmethod
|
||||
def runtime_compute_profile(cls, caps):
|
||||
return {
|
||||
"gpu_compat": ("vulkan", "cpu"),
|
||||
"min_vram_gb": 6.0,
|
||||
"effective_device": "vulkan",
|
||||
"routing_status": "accelerated",
|
||||
"routing_reason": None,
|
||||
"runtime_backend": "vulkan",
|
||||
"runtime_device_index": 1,
|
||||
"runtime_device_name": "Test GPU",
|
||||
}
|
||||
|
||||
registry_sandbox["runtime-known"] = RuntimeKnownButModelMissing
|
||||
entry = next(
|
||||
item for item in list_backends() if item["id"] == "runtime-known"
|
||||
)
|
||||
|
||||
assert entry["available"] is False
|
||||
assert entry["gpu_compat"] == ["vulkan", "cpu"]
|
||||
assert entry["effective_device"] == "vulkan"
|
||||
|
||||
|
||||
def test_list_backends_shape(registry_sandbox):
|
||||
"""Every entry must contain exactly the documented keys — no more, no
|
||||
less — EXCEPT mlx-audio, which also carries `curated_models` +
|
||||
|
||||
@@ -31,6 +31,14 @@ def test_lightweight_optional_engine_has_weight_estimate_not_fake_package_zero(m
|
||||
assert usage["estimate"]["package_download_bytes"] is None
|
||||
|
||||
|
||||
def test_audiocpp_exposes_its_filtered_model_download_size(monkeypatch, disk_modules):
|
||||
engine_disk_usage, _ = disk_modules
|
||||
monkeypatch.setattr(engine_disk_usage, "_measure_model_cache", lambda _engine_id: None)
|
||||
usage = engine_disk_usage.disk_usage_for("audiocpp")
|
||||
assert usage["estimate"]["model_download_bytes"] == round(4.73 * 1024**3)
|
||||
assert usage["estimate"]["package_download_bytes"] is None
|
||||
|
||||
|
||||
def test_separate_torch_sidecar_uses_installer_build_metadata(monkeypatch, tmp_path, disk_modules):
|
||||
engine_disk_usage, SPECS = disk_modules
|
||||
spec = SPECS["indextts2"]
|
||||
|
||||
@@ -57,6 +57,38 @@ def test_loaded_rocm_torch_engine_reports_actual_device():
|
||||
assert evidence["gpu_name"] == "AMD Radeon RX 6700 XT"
|
||||
|
||||
|
||||
def test_runtime_native_device_name_is_scrubbed_and_capped():
|
||||
routing = {
|
||||
"routing_status": "accelerated",
|
||||
"routing_reason": None,
|
||||
"runtime_device_name": (
|
||||
"/home/alice/adapter hf_abcdefghijklmnopqrstuvwxyz1234567890 "
|
||||
+ "x" * 300
|
||||
),
|
||||
"runtime_hardware_family": "cuda",
|
||||
}
|
||||
|
||||
evidence = _snap(_TorchEngine, routing)
|
||||
|
||||
assert "/home/alice" not in evidence["gpu_name"]
|
||||
assert "hf_abcdefghijklmnopqrstuvwxyz1234567890" not in evidence["gpu_name"]
|
||||
assert len(evidence["gpu_name"]) == 256
|
||||
assert evidence["gpu_architecture"] is None
|
||||
|
||||
|
||||
def test_explicitly_empty_runtime_device_name_does_not_report_another_adapter():
|
||||
evidence = _snap(
|
||||
_TorchEngine,
|
||||
{
|
||||
"routing_status": "accelerated",
|
||||
"routing_reason": None,
|
||||
"runtime_device_name": "",
|
||||
},
|
||||
)
|
||||
|
||||
assert evidence["gpu_name"] is None
|
||||
|
||||
|
||||
def test_faster_whisper_cpu_fallback_names_reason_and_stage():
|
||||
evidence = _snap(
|
||||
_FasterWhisper,
|
||||
|
||||
@@ -6,11 +6,14 @@ from spec §2, plus the cross-OS determinism + never-emits-"n/a" guarantees.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import threading
|
||||
|
||||
from core.device_caps import DIRECTML_MARKER, KERNEL_RISK_MARKER, HostCaps
|
||||
from services.engine_routing import (
|
||||
header_safe_reason,
|
||||
resolve_routing,
|
||||
routing_notice,
|
||||
header_safe_reason,
|
||||
)
|
||||
|
||||
|
||||
@@ -142,6 +145,45 @@ def test_deterministic_across_calls():
|
||||
assert resolve_routing(("cuda", "cpu"), caps) == resolve_routing(("cuda", "cpu"), caps)
|
||||
|
||||
|
||||
def test_runtime_compute_profile_async_keeps_event_loop_responsive():
|
||||
from services.engine_routing import runtime_compute_profile_async
|
||||
|
||||
release = threading.Event()
|
||||
finished = threading.Event()
|
||||
event_loop_progressed = asyncio.Event()
|
||||
order: list[str] = []
|
||||
|
||||
class BlockingRuntimeProfile:
|
||||
@classmethod
|
||||
def runtime_compute_profile(cls, caps):
|
||||
try:
|
||||
assert release.wait(timeout=1.0)
|
||||
order.append("profile")
|
||||
return {"marker": caps.family}
|
||||
finally:
|
||||
finished.set()
|
||||
|
||||
async def release_after_event_loop_progress():
|
||||
order.append("event_loop")
|
||||
event_loop_progressed.set()
|
||||
release.set()
|
||||
|
||||
loop = asyncio.new_event_loop()
|
||||
try:
|
||||
profile_task = loop.create_task(
|
||||
runtime_compute_profile_async(BlockingRuntimeProfile, _caps("cpu")),
|
||||
)
|
||||
release_task = loop.create_task(release_after_event_loop_progress())
|
||||
loop.run_until_complete(event_loop_progressed.wait())
|
||||
loop.run_until_complete(release_task)
|
||||
assert finished.wait(timeout=1.0)
|
||||
|
||||
assert loop.run_until_complete(profile_task) == {"marker": "cpu"}
|
||||
assert order == ["event_loop", "profile"]
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
|
||||
def test_reason_str_for_fallback_and_unavailable_none_for_clean():
|
||||
# A genuine fallback (multi-target engine lacking the host accel) carries a
|
||||
# reason; a cpu-native ("cpu",) engine is neutral (covered above).
|
||||
|
||||
@@ -105,6 +105,34 @@ def test_cpu_host_gets_bounded_ten_minute_generate_budget(model_manager, monkeyp
|
||||
assert model_manager.generate_timeout_s("A short CPU render") == 600.0
|
||||
|
||||
|
||||
def test_router_timeout_alias_preserves_native_device_metadata(
|
||||
model_manager, monkeypatch,
|
||||
):
|
||||
from api.routers.generation import _generate_timeout_s
|
||||
|
||||
captured = {}
|
||||
|
||||
def fake_timeout(text, **kwargs):
|
||||
captured.update(kwargs)
|
||||
return 600.0
|
||||
|
||||
monkeypatch.setattr(model_manager, "generate_timeout_s", fake_timeout)
|
||||
|
||||
assert _generate_timeout_s(
|
||||
"test",
|
||||
execution_device="vulkan",
|
||||
min_vram_gb=6.0,
|
||||
hardware_family="cuda",
|
||||
vram_gb=4.0,
|
||||
) == 600.0
|
||||
assert captured == {
|
||||
"execution_device": "vulkan",
|
||||
"min_vram_gb": 6.0,
|
||||
"hardware_family": "cuda",
|
||||
"vram_gb": 4.0,
|
||||
}
|
||||
|
||||
|
||||
def test_accelerated_host_keeps_five_minute_generate_budget(model_manager, monkeypatch):
|
||||
import types
|
||||
import core.device_caps as caps
|
||||
|
||||
@@ -162,3 +162,48 @@ def test_install_preflight_download_and_repair_marker_share_revision(models_mod,
|
||||
assert calls[0]["dry_run"] is True
|
||||
assert "dry_run" not in calls[1]
|
||||
assert remembered and remembered[0][0:2] == (repo_id, expected)
|
||||
|
||||
|
||||
def test_install_honors_catalog_allow_patterns(models_mod, monkeypatch, tmp_path):
|
||||
"""A multi-package repo downloads only the model variant shown in its row."""
|
||||
download = importlib.import_module("api.routers.setup.download")
|
||||
import asyncio
|
||||
import huggingface_hub
|
||||
from services import hf_revisions
|
||||
|
||||
repo_id = "audio-cpp/audio.cpp-gguf"
|
||||
spec = next(m for m in download.KNOWN_MODELS if m["repo_id"] == repo_id)
|
||||
expected_patterns = spec["allow_patterns"]
|
||||
calls = []
|
||||
|
||||
def fake_snapshot(**kwargs):
|
||||
calls.append(kwargs)
|
||||
return [] if kwargs.get("dry_run") else str(tmp_path)
|
||||
|
||||
def segmented_must_not_run(*_args, **_kwargs):
|
||||
raise AssertionError("filtered model installs must skip whole-repo segmented download")
|
||||
|
||||
monkeypatch.setattr(huggingface_hub, "snapshot_download", fake_snapshot)
|
||||
monkeypatch.setattr(download, "compute_plan", lambda _plan: {
|
||||
"total_bytes": 1, "cached_bytes": 0, "to_download_bytes": 1,
|
||||
"n_files": 1, "n_cached": 0,
|
||||
})
|
||||
monkeypatch.setattr(download, "disk_space_error", lambda *_a, **_k: None)
|
||||
monkeypatch.setattr(download, "_segmented_enabled", lambda: True)
|
||||
monkeypatch.setattr(download, "_xet_active", lambda: False)
|
||||
monkeypatch.setattr(download, "_segmented_snapshot", segmented_must_not_run)
|
||||
monkeypatch.setattr(download, "_validate_snapshot_has_weights", lambda *_a: None)
|
||||
monkeypatch.setattr(hf_revisions, "remember_revision", lambda *_args: None)
|
||||
|
||||
async def run_install():
|
||||
await download.install_model(download.InstallModelRequest(repo_id=repo_id))
|
||||
pending = [t for t in asyncio.all_tasks() if t is not asyncio.current_task()]
|
||||
if pending:
|
||||
await asyncio.gather(*pending)
|
||||
|
||||
asyncio.run(run_install())
|
||||
|
||||
assert len(calls) == 2
|
||||
assert all(call["allow_patterns"] == expected_patterns for call in calls)
|
||||
assert calls[0]["dry_run"] is True
|
||||
assert "dry_run" not in calls[1]
|
||||
|
||||
@@ -115,6 +115,43 @@ def test_the_whole_class_not_just_cuda(on_host, floor):
|
||||
) == 600.0
|
||||
|
||||
|
||||
@pytest.mark.parametrize("family", ["xpu", "vulkan"])
|
||||
def test_other_discrete_gpu_families_get_the_cpu_budget(
|
||||
family, on_host, floor,
|
||||
):
|
||||
mm = on_host(_gpu(4.0, family=family, name="Discrete GPU"))
|
||||
assert mm.generate_timeout_s(
|
||||
"A short render",
|
||||
execution_device="vulkan",
|
||||
min_vram_gb=floor,
|
||||
hardware_family=family,
|
||||
) == 600.0
|
||||
|
||||
|
||||
def test_xpu_runtime_reaches_dedicated_vram_budget_guard(on_host, floor):
|
||||
mm = on_host(_gpu(4.0, family="xpu", name="Intel Arc"))
|
||||
assert mm.generate_timeout_s(
|
||||
"A short render", execution_device="xpu", min_vram_gb=floor,
|
||||
) == 600.0
|
||||
|
||||
|
||||
def test_vulkan_on_a_small_dedicated_gpu_gets_the_cpu_budget(on_host, floor):
|
||||
mm = on_host(_gpu(4.0))
|
||||
assert mm.generate_timeout_s(
|
||||
"A short render", execution_device="vulkan", min_vram_gb=floor,
|
||||
) == 600.0
|
||||
|
||||
|
||||
def test_native_hardware_family_overrides_global_cpu_preference(on_host, floor):
|
||||
mm = on_host(_gpu(4.0, family="cpu", name="NVIDIA GTX 1650"))
|
||||
assert mm.generate_timeout_s(
|
||||
"A short render",
|
||||
execution_device="vulkan",
|
||||
min_vram_gb=floor,
|
||||
hardware_family="cuda",
|
||||
) == 600.0
|
||||
|
||||
|
||||
# ── the boundaries it must not cross ─────────────────────────────────────
|
||||
|
||||
|
||||
@@ -149,6 +186,25 @@ def test_a_failed_vram_probe_does_not_guess(on_host, floor):
|
||||
) == 300.0
|
||||
|
||||
|
||||
def test_native_runtime_with_unknown_dedicated_vram_gets_cpu_budget(
|
||||
on_host, floor,
|
||||
):
|
||||
"""A native runtime's explicit zero means its own VRAM probe failed.
|
||||
|
||||
Keep the warning quiet because capacity is unknown, but allow enough time
|
||||
for a device that may page to system memory instead of assuming fast-GPU
|
||||
performance.
|
||||
"""
|
||||
mm = on_host(_gpu(24.0, name="NVIDIA RTX 4090"))
|
||||
assert mm.generate_timeout_s(
|
||||
"A short render",
|
||||
execution_device="vulkan",
|
||||
min_vram_gb=floor,
|
||||
hardware_family="cuda",
|
||||
vram_gb=0.0,
|
||||
) == 600.0
|
||||
|
||||
|
||||
def test_a_cpu_fallback_render_is_unaffected(on_host, floor):
|
||||
"""Routing already sent this one to the CPU; it gets the CPU budget by the
|
||||
device branch, and the VRAM branch must not double-apply."""
|
||||
@@ -239,22 +295,24 @@ def _worker(*, backend: str = "cuda", vram_gb: float = 4.0,
|
||||
from worker.capacity import WorkerCapacity
|
||||
from worker.pool import ConnectedWorker
|
||||
from worker.registry import RemoteWorker
|
||||
from worker.transport.codec import capability_from_pb, capability_to_pb
|
||||
|
||||
gb = 1024 ** 3
|
||||
capability = capability_from_pb(capability_to_pb({
|
||||
"engine": "omnivoice",
|
||||
"model_id": "OmniVoice",
|
||||
"operations": ["tts"],
|
||||
"supported": True,
|
||||
"installed": True,
|
||||
"downloaded": True,
|
||||
"backend": backend,
|
||||
"cpu_fallback": cpu_fallback,
|
||||
"min_memory_bytes": int(floor_gb * gb),
|
||||
"free_memory_bytes": int(vram_gb * gb),
|
||||
}))
|
||||
record = RemoteWorker(
|
||||
id="w1", name="w1", key_id="key-w1", public_key=b"\x00" * 32, priority=50,
|
||||
capabilities=[{
|
||||
"engine": "omnivoice",
|
||||
"model_id": "OmniVoice",
|
||||
"operations": ["tts"],
|
||||
"supported": True,
|
||||
"installed": True,
|
||||
"downloaded": True,
|
||||
"backend": backend,
|
||||
"cpu_fallback": cpu_fallback,
|
||||
"min_memory_bytes": int(floor_gb * gb),
|
||||
"free_memory_bytes": int(vram_gb * gb),
|
||||
}],
|
||||
capabilities=[capability],
|
||||
consent_granted_at=1.0, created_at=1.0,
|
||||
)
|
||||
return ConnectedWorker(
|
||||
@@ -270,6 +328,7 @@ def _worker(*, backend: str = "cuda", vram_gb: float = 4.0,
|
||||
[
|
||||
({}, True), # 4 GB card, 6 GB engine
|
||||
({"backend": "rocm"}, True), # whole class
|
||||
({"backend": "vulkan"}, True), # native Vulkan wrapper
|
||||
({"vram_gb": 24.0}, False), # big card
|
||||
({"floor_gb": 0.0}, False), # engine declares no floor
|
||||
({"vram_gb": 0.0}, False), # probe failed on the worker
|
||||
@@ -282,6 +341,11 @@ def test_the_worker_decides_from_the_figures_it_advertises(kwargs, expected):
|
||||
assert w.under_provisioned("omnivoice", "OmniVoice", "tts") is expected
|
||||
|
||||
|
||||
def test_worker_preserves_vulkan_as_the_execution_device():
|
||||
w = _worker(backend="vulkan")
|
||||
assert w.execution_device("omnivoice", "OmniVoice", "tts") == "vulkan"
|
||||
|
||||
|
||||
def test_an_unknown_capability_is_never_called_under_provisioned():
|
||||
w = _worker()
|
||||
assert w.under_provisioned("some-other-engine", "", "tts") is False
|
||||
@@ -310,7 +374,7 @@ def test_a_healthy_remote_worker_is_unchanged():
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("device", ["cuda", "rocm"])
|
||||
@pytest.mark.parametrize("device", ["cuda", "rocm", "vulkan"])
|
||||
def test_the_task_deadline_still_covers_the_raised_execution_budget(device):
|
||||
"""`gpu_gateway._default_deadline` is computed before a worker is bound, so
|
||||
it cannot know the card. It already asks for the CPU budget (no
|
||||
|
||||
@@ -213,6 +213,47 @@ def test_convert_happy_path(client, monkeypatch, clone_profile):
|
||||
assert row["profile_id"] == clone_profile
|
||||
|
||||
|
||||
def test_convert_rejects_an_unavailable_runtime_profile(
|
||||
client, monkeypatch, clone_profile,
|
||||
):
|
||||
base = _make_fake_engine(f"fake-unavailable-{uuid.uuid4().hex[:6]}")
|
||||
|
||||
class UnavailableRuntime(base):
|
||||
@classmethod
|
||||
def runtime_compute_profile(cls, caps):
|
||||
return {
|
||||
"gpu_compat": ("cpu",),
|
||||
"min_vram_gb": 0.0,
|
||||
"effective_device": "cpu",
|
||||
"routing_status": "unavailable",
|
||||
"routing_reason": "configured native device is unavailable",
|
||||
"runtime_hardware_family": None,
|
||||
"runtime_vram_gb": None,
|
||||
}
|
||||
|
||||
asr = _FakeASR({"text": "hello there"})
|
||||
_wire_stubs(monkeypatch, engine_cls=UnavailableRuntime, asr=asr)
|
||||
backend = UnavailableRuntime()
|
||||
|
||||
async def resolve_backend(**_kwargs):
|
||||
return backend
|
||||
|
||||
monkeypatch.setattr(
|
||||
_tts_mod(), "resolve_generation_backend", resolve_backend,
|
||||
)
|
||||
|
||||
async def transcribe_source(*_args, **_kwargs):
|
||||
return {"text": "hello there", "segments": []}
|
||||
|
||||
monkeypatch.setattr(_vc_mod(), "_transcribe_source", transcribe_source)
|
||||
|
||||
res = _post_convert(client, clone_profile, match_duration="0")
|
||||
|
||||
assert res.status_code == 400
|
||||
assert res.json()["detail"] == "configured native device is unavailable"
|
||||
assert UnavailableRuntime.calls == []
|
||||
|
||||
|
||||
def test_convert_transcribes_blank_profile_reference_and_persists(
|
||||
client, monkeypatch, transcriptless_profile,
|
||||
):
|
||||
|
||||
@@ -37,11 +37,10 @@ def test_concurrency_is_capped_regardless_of_card_size():
|
||||
assert derive_concurrency(backend="cuda", free_memory_bytes=200 * GB) == 4
|
||||
|
||||
|
||||
def test_model_that_does_not_fit_returns_zero():
|
||||
"""A 4 GB card refusing a 6 GB engine is correct behaviour (#1226), and the
|
||||
scheduler must read it as 'send it elsewhere', never as a worker fault."""
|
||||
assert derive_concurrency(backend="cuda", free_memory_bytes=4 * GB, min_model_bytes=6 * GB) == 0
|
||||
assert derive_concurrency(backend="mps", free_memory_bytes=4 * GB, min_model_bytes=6 * GB) == 0
|
||||
def test_under_provisioned_model_keeps_one_advisory_slot():
|
||||
"""The VRAM floor changes the deadline; it does not disable the engine."""
|
||||
assert derive_concurrency(backend="cuda", free_memory_bytes=4 * GB, min_model_bytes=6 * GB) == 1
|
||||
assert derive_concurrency(backend="mps", free_memory_bytes=4 * GB, min_model_bytes=6 * GB) == 1
|
||||
|
||||
|
||||
def test_large_model_reduces_derived_concurrency():
|
||||
|
||||
@@ -280,6 +280,12 @@ def test_concurrency_is_reported_as_derived(proto):
|
||||
assert "derived_concurrency" in _message_body(proto, "ModelCapability")
|
||||
|
||||
|
||||
def test_capability_carries_per_engine_routing_and_memory(proto):
|
||||
body = _message_body(proto, "ModelCapability")
|
||||
assert "string backend" in body
|
||||
assert "uint64 free_memory_bytes" in body
|
||||
|
||||
|
||||
def test_heartbeat_does_not_promise_gpu_utilisation(proto):
|
||||
"""Unobtainable on Apple without sudo powermetrics and absent on CUDA
|
||||
without a new NVML dependency. Slots and queue depth are the load signal."""
|
||||
|
||||
@@ -191,6 +191,90 @@ def test_discovery_reports_the_four_states(monkeypatch):
|
||||
assert "clone" in entry["operations"]
|
||||
|
||||
|
||||
def test_unknown_native_gpu_memory_keeps_one_serial_worker_slot(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"services.tts_backend.list_backends",
|
||||
lambda: [{
|
||||
"id": "audiocpp",
|
||||
"available": True,
|
||||
"routing_status": "accelerated",
|
||||
"gpu_compat": ["vulkan", "cpu"],
|
||||
"effective_device": "vulkan",
|
||||
"min_vram_gb": 6.0,
|
||||
"execution_evidence": {"runtime_vram_gb": 0.0},
|
||||
}],
|
||||
)
|
||||
|
||||
entry = capabilities.discover()[0]
|
||||
|
||||
assert entry["free_memory_bytes"] == 0
|
||||
assert entry["derived_concurrency"] == 1
|
||||
|
||||
|
||||
def test_unknown_native_memory_keeps_mixed_worker_serial(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"services.tts_backend.list_backends",
|
||||
lambda: [{
|
||||
"id": "unknown-memory",
|
||||
"available": True,
|
||||
"routing_status": "accelerated",
|
||||
"gpu_compat": ["cuda"],
|
||||
"effective_device": "cuda",
|
||||
"execution_evidence": {"runtime_vram_gb": 0.0},
|
||||
}],
|
||||
)
|
||||
|
||||
unknown_memory = capabilities.discover()[0]
|
||||
high_capacity = {"derived_concurrency": 4}
|
||||
|
||||
assert unknown_memory["derived_concurrency"] == 1
|
||||
assert capabilities.max_concurrent_tasks(
|
||||
[unknown_memory, high_capacity]
|
||||
) == 1
|
||||
|
||||
|
||||
def test_static_gpu_profile_derives_known_memory_before_aggregation(
|
||||
monkeypatch,
|
||||
):
|
||||
free_bytes = 24 * 1024**3
|
||||
derive_calls = []
|
||||
|
||||
def derive_for_static_gpu(**kwargs):
|
||||
derive_calls.append(kwargs)
|
||||
return 3
|
||||
|
||||
monkeypatch.setattr(capabilities, "_free_memory_bytes", lambda _caps: free_bytes)
|
||||
monkeypatch.setattr(capabilities, "derive_concurrency", derive_for_static_gpu)
|
||||
monkeypatch.setattr(
|
||||
"services.tts_backend.list_backends",
|
||||
lambda: [{
|
||||
"id": "static-cuda",
|
||||
"available": True,
|
||||
"routing_status": "accelerated",
|
||||
"gpu_compat": ["cuda"],
|
||||
"effective_device": "cuda",
|
||||
"min_vram_gb": 5.0,
|
||||
"execution_evidence": {"runtime_vram_gb": None},
|
||||
}],
|
||||
)
|
||||
|
||||
static_gpu = capabilities.discover()[0]
|
||||
|
||||
assert static_gpu["derived_concurrency"] == 0
|
||||
assert static_gpu["free_memory_bytes"] == free_bytes
|
||||
assert capabilities.max_concurrent_tasks(
|
||||
[static_gpu, {"derived_concurrency": 4}]
|
||||
) == 3
|
||||
assert derive_calls == [
|
||||
{
|
||||
"backend": "cuda",
|
||||
"free_memory_bytes": free_bytes,
|
||||
"min_model_bytes": 5 * 1024**3,
|
||||
"compiled": False,
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_cpu_fallback_is_reported_because_capability_is_not_acceleration(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"services.tts_backend.list_backends",
|
||||
|
||||
@@ -317,12 +317,19 @@ def test_apple_capability_stays_serial():
|
||||
|
||||
|
||||
def test_capability_round_trips():
|
||||
original = {**_capabilities(resident=True)[0], "display_name": "IndexTTS 2"}
|
||||
original = {
|
||||
**_capabilities(resident=True)[0],
|
||||
"display_name": "IndexTTS 2",
|
||||
"backend": "vulkan",
|
||||
"free_memory_bytes": 4 * 1024**3,
|
||||
}
|
||||
restored = codec.capability_from_pb(codec.capability_to_pb(original))
|
||||
assert restored["engine"] == original["engine"]
|
||||
assert restored["resident"] is True
|
||||
assert restored["installed"] is True
|
||||
assert restored["display_name"] == "IndexTTS 2"
|
||||
assert restored["backend"] == "vulkan"
|
||||
assert restored["free_memory_bytes"] == 4 * 1024**3
|
||||
|
||||
|
||||
def test_legacy_capability_without_display_name_still_decodes():
|
||||
@@ -333,6 +340,51 @@ def test_legacy_capability_without_display_name_still_decodes():
|
||||
assert restored["display_name"] == ""
|
||||
|
||||
|
||||
def test_protocol_v2_capability_without_backend_inherits_worker_backend():
|
||||
"""Pre-backend protocol-v2 peers must retain their GPU routing."""
|
||||
restored = codec.capability_from_pb(
|
||||
pb.ModelCapability(
|
||||
engine=ENGINE,
|
||||
model_id=MODEL,
|
||||
operations=[OP],
|
||||
supported=True,
|
||||
installed=True,
|
||||
),
|
||||
fallback_backend="vulkan",
|
||||
)
|
||||
pool = WorkerPool()
|
||||
record = registry.RemoteWorker(
|
||||
id="legacy-v2",
|
||||
name="legacy-v2",
|
||||
key_id="legacy-key",
|
||||
public_key=b"0" * 32,
|
||||
capabilities=[restored],
|
||||
)
|
||||
session = identity.Session(
|
||||
token="legacy-token",
|
||||
worker_id=record.id,
|
||||
key_id=record.key_id,
|
||||
epoch=1,
|
||||
issued_at=1.0,
|
||||
expires_at=10_000.0,
|
||||
)
|
||||
worker = pool.connect(
|
||||
record, session=session, epoch=1, backend="vulkan", now=1.0
|
||||
)
|
||||
|
||||
assert restored["backend"] == "vulkan"
|
||||
assert worker.execution_device(ENGINE, MODEL, OP) == "vulkan"
|
||||
|
||||
|
||||
def test_protocol_v2_cpu_fallback_does_not_inherit_worker_gpu():
|
||||
restored = codec.capability_from_pb(
|
||||
pb.ModelCapability(engine=ENGINE, model_id=MODEL, cpu_fallback=True),
|
||||
fallback_backend="cuda",
|
||||
)
|
||||
|
||||
assert restored["backend"] == "cpu"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_is_sent_and_ack_releases_the_parked_slot(tmp_path):
|
||||
pool = WorkerPool()
|
||||
|
||||
Reference in New Issue
Block a user