Merge remote-tracking branch 'upstream/main' into fix/1866-engine-unavailability-reason

This commit is contained in:
Chang-Jin-Lee
2026-09-09 12:55:59 +09:00
67 changed files with 3634 additions and 194 deletions
+6
View File
@@ -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
+59 -16
View File
@@ -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,
),
+3 -4
View File
@@ -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"])
+16 -2
View File
@@ -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,
+7 -4
View File
@@ -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",
+16 -2
View File
@@ -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(
+11
View File
@@ -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
+590
View File
@@ -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",
]
+738
View File
@@ -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",
]
+10
View File
@@ -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",
}
+23 -3
View File
@@ -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),
+73 -15
View File
@@ -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",
]
+1
View File
@@ -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",
+25 -12
View File
@@ -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)
+69 -10
View File
@@ -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}")
+31 -4
View File
@@ -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__ = [
+4 -6
View File
@@ -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
+3 -1
View File
@@ -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(
+4 -2
View File
@@ -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")
+5
View File
@@ -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 {
+20 -1
View File
@@ -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,
}
+12 -3
View File
@@ -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
+6 -2
View File
@@ -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
+1
View File
@@ -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
+142
View File
@@ -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).
+2
View File
@@ -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:
+1 -1
View File
@@ -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]',
+1
View File
@@ -822,6 +822,7 @@
"routingRemote": "بعيد",
"routingUnknown": "غير معروف",
"routingEffectiveChip": "يعمل على {{device}} على هذا الجهاز",
"gpuVulkan": "Vulkan",
"routingCaveatTitle": "تم تحديد وحدة معالجة الرسومات، ولكن: {{reason}}",
"selectCpuFallback": "{{engine}}: يعمل على المعالج المركزي — {{reason}}",
"selectWithCaveat": "تم التبديل إلى {{engine}} — {{reason}}",
+1
View File
@@ -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}}",
+1
View File
@@ -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",
+1
View File
@@ -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}}",
+1
View File
@@ -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}}",
+1
View File
@@ -822,6 +822,7 @@
"routingRemote": "रिमोट",
"routingUnknown": "अज्ञात",
"routingEffectiveChip": "इस मशीन पर {{device}} पर चलता है",
"gpuVulkan": "Vulkan",
"routingCaveatTitle": "GPU चयनित, लेकिन: {{reason}}",
"selectCpuFallback": "{{engine}}: CPU पर चल रहा है — {{reason}}",
"selectWithCaveat": "{{engine}} पर स्विच किया गया — {{reason}}",
+1
View File
@@ -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}}",
+1
View File
@@ -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}}",
+1
View File
@@ -822,6 +822,7 @@
"routingRemote": "リモート",
"routingUnknown": "不明",
"routingEffectiveChip": "このマシンの {{device}} で実行されます",
"gpuVulkan": "Vulkan",
"routingCaveatTitle": "GPU が選択されましたが: {{reason}}",
"selectCpuFallback": "{{engine}}: CPU で実行中 — {{reason}}",
"selectWithCaveat": "{{engine}} に切り替えました — {{reason}}",
+1
View File
@@ -1066,6 +1066,7 @@
"routingRemote": "원격",
"routingUnknown": "알 수 없음",
"routingEffectiveChip": "이 머신의 {{device}}에서 실행됩니다.",
"gpuVulkan": "Vulkan",
"routingCaveatTitle": "GPU가 선택되었지만: {{reason}}",
"selectCpuFallback": "{{engine}}: CPU에서 실행 중 — {{reason}}",
"selectWithCaveat": "{{engine}}(으)로 전환했습니다 — {{reason}}",
+1
View File
@@ -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}}",
+1
View File
@@ -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}}",
+1
View File
@@ -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}}",
+1
View File
@@ -822,6 +822,7 @@
"routingRemote": "Удаленный",
"routingUnknown": "Неизвестный",
"routingEffectiveChip": "Работает на {{device}} на этом компьютере",
"gpuVulkan": "Vulkan",
"routingCaveatTitle": "Графический процессор выбран, но: {{reason}}",
"selectCpuFallback": "{{engine}}: работает на CPU — {{reason}}",
"selectWithCaveat": "Переключено на {{engine}} — {{reason}}",
+1
View File
@@ -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}}",
+1
View File
@@ -822,6 +822,7 @@
"routingRemote": "ระยะไกล",
"routingUnknown": "ไม่ทราบ",
"routingEffectiveChip": "ทำงานบน {{device}} บนเครื่องนี้",
"gpuVulkan": "Vulkan",
"routingCaveatTitle": "เลือก GPU แล้ว แต่: {{reason}}",
"selectCpuFallback": "{{engine}}: กำลังทำงานบน CPU — {{reason}}",
"selectWithCaveat": "สลับไปที่ {{engine}} — {{reason}}",
+1
View File
@@ -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}}",
+1
View File
@@ -822,6 +822,7 @@
"routingRemote": "Дистанційний",
"routingUnknown": "Невідомий",
"routingEffectiveChip": "Працює на {{device}} на цій машині",
"gpuVulkan": "Vulkan",
"routingCaveatTitle": "Графічний процесор вибрано, але: {{reason}}",
"selectCpuFallback": "{{engine}}: працює на CPU — {{reason}}",
"selectWithCaveat": "Перемкнено на {{engine}} — {{reason}}",
+1
View File
@@ -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}}",
+1
View File
@@ -779,6 +779,7 @@
"routingRemote": "远程",
"routingUnknown": "未知",
"routingEffectiveChip": "在本机的 {{device}} 上运行",
"gpuVulkan": "Vulkan",
"routingCaveatTitle": "已选择 GPU,但是:{{reason}}",
"curatedModelLabel": "模型",
"curatedModelAria": "{{engine}} 的模型",
+1
View File
@@ -822,6 +822,7 @@
"routingRemote": "遠端",
"routingUnknown": "未知",
"routingEffectiveChip": "在此機器上的 {{device}} 上執行",
"gpuVulkan": "Vulkan",
"routingCaveatTitle": "已選擇 GPU,但是:{{reason}}",
"selectCpuFallback": "{{engine}}:正在 CPU 上執行 — {{reason}}",
"selectWithCaveat": "已切換至 {{engine}} — {{reason}}",
+40
View File
@@ -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(
+3 -2
View File
@@ -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` +
+8
View File
@@ -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"]
+32
View File
@@ -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,
+43 -1
View File
@@ -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).
+28
View File
@@ -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
+45
View File
@@ -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]
+77 -13
View File
@@ -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
+41
View File
@@ -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,
):
+4 -5
View File
@@ -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():
+6
View File
@@ -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."""
+84
View File
@@ -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",
+53 -1
View File
@@ -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()