TTS-Slots (Piper CPU, Qwen3-TTS auf Ada), M4000 außer Betrieb, Engine-Grenzen im Placement (ADR-0006)
ci / test (push) Successful in 24s
ci / test (push) Successful in 24s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
"""Hilfen für Audio (WAV-Dauer, Real-Time-Factor)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import wave
|
||||
|
||||
|
||||
def wav_duration(data: bytes) -> float:
|
||||
"""Dauer einer WAV-Datei in Sekunden; 0.0 wenn keine gültige WAV."""
|
||||
try:
|
||||
with wave.open(io.BytesIO(data)) as w:
|
||||
rate = w.getframerate()
|
||||
return w.getnframes() / rate if rate else 0.0
|
||||
except (wave.Error, EOFError):
|
||||
return 0.0
|
||||
|
||||
|
||||
def rtf(seconds_compute: float, seconds_audio: float) -> float | None:
|
||||
"""Real-Time-Factor: Rechenzeit ÷ Audiolänge (< 1 = schneller als Echtzeit)."""
|
||||
return seconds_compute / seconds_audio if seconds_audio > 0 else None
|
||||
@@ -0,0 +1,38 @@
|
||||
"""Benchmarks, deren Ergebnisse in Katalog/Registry zurückfließen (Kalibrierung am Deployment-Tag)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from statistics import median
|
||||
from typing import Any
|
||||
|
||||
from .audio import rtf, wav_duration
|
||||
from .llm import OpenAIChatClient
|
||||
|
||||
DEFAULT_TEXT = (
|
||||
"Guten Tag. Dies ist ein Test der Sprachausgabe im Cluster. Wir messen, wie lange die Berechnung "
|
||||
"im Verhältnis zur Länge des erzeugten Audios dauert. Zahlen wie 1.250,75 Euro und Daten wie der "
|
||||
"3. Oktober 2026 sollen korrekt gesprochen werden."
|
||||
)
|
||||
|
||||
|
||||
def bench_tts(url: str, model: str, *, api_key: str = "", runs: int = 3, text: str | None = None,
|
||||
client: OpenAIChatClient | None = None) -> dict[str, Any]:
|
||||
client = client or OpenAIChatClient(url, api_key, timeout=600)
|
||||
text = text or DEFAULT_TEXT
|
||||
results = []
|
||||
errors = []
|
||||
for _ in range(runs):
|
||||
start = time.monotonic()
|
||||
try:
|
||||
audio = client.speech(model, text) # type: ignore[attr-defined]
|
||||
except Exception as exc: # noqa: BLE001
|
||||
errors.append(str(exc))
|
||||
continue
|
||||
elapsed = time.monotonic() - start
|
||||
duration = wav_duration(audio)
|
||||
results.append({"seconds": round(elapsed, 2), "audio_seconds": round(duration, 2),
|
||||
"rtf": round(rtf(elapsed, duration), 3) if duration else None})
|
||||
rtfs = [r["rtf"] for r in results if r["rtf"] is not None]
|
||||
return {"model": model, "chars": len(text), "runs": results, "errors": errors,
|
||||
"rtf_median": round(median(rtfs), 3) if rtfs else None}
|
||||
@@ -8,6 +8,7 @@ from dataclasses import dataclass
|
||||
|
||||
from prometheus_client import Gauge
|
||||
|
||||
from .audio import wav_duration
|
||||
from .embeddings import Embedder
|
||||
from .llm import ChatClient, LLMError
|
||||
from .registry import Registry, Slot
|
||||
@@ -51,7 +52,12 @@ class CanaryProber:
|
||||
def probe(self, slot: Slot) -> ProbeResult:
|
||||
start = time.monotonic()
|
||||
try:
|
||||
if slot.spec.kind == "embedding":
|
||||
if slot.spec.kind == "tts":
|
||||
speech = getattr(self.chat, "speech", None)
|
||||
if speech is None:
|
||||
return ProbeResult(slot.name, True, True, 0.0, "kein Speech-Client")
|
||||
ok = wav_duration(speech(slot.spec.litellm_model, "Test.")) >= 0.2
|
||||
elif slot.spec.kind == "embedding":
|
||||
embedder = self.embedder_for.get(slot.name)
|
||||
if embedder is None:
|
||||
return ProbeResult(slot.name, True, True, 0.0, "kein Embedder konfiguriert")
|
||||
|
||||
@@ -30,6 +30,19 @@ def main(argv: list[str] | None = None) -> int:
|
||||
p_plan.add_argument("--inventory", required=True)
|
||||
p_plan.add_argument("--catalog", default="catalog")
|
||||
|
||||
p_tts = sub.add_parser("tts-serve", help="OpenAI-kompatiblen TTS-Dienst (Piper o. ä.) starten")
|
||||
p_tts.add_argument("--host", default="0.0.0.0")
|
||||
p_tts.add_argument("--port", type=int, default=8090)
|
||||
|
||||
p_bench = sub.add_parser("bench", help="Benchmarks für den Deployment-Tag")
|
||||
bench_sub = p_bench.add_subparsers(dest="bench_cmd", required=True)
|
||||
p_bt = bench_sub.add_parser("tts", help="Real-Time-Factor eines TTS-Endpunkts messen")
|
||||
p_bt.add_argument("--url", required=True, help="Basis-URL, z. B. http://litellm:4000 oder http://tts:8090")
|
||||
p_bt.add_argument("--model", default="tts-de")
|
||||
p_bt.add_argument("--api-key", default="")
|
||||
p_bt.add_argument("--runs", type=int, default=3)
|
||||
p_bt.add_argument("--text", default=None)
|
||||
|
||||
p_reg = sub.add_parser("registry", help="Registry")
|
||||
reg_sub = p_reg.add_subparsers(dest="reg_cmd", required=True)
|
||||
reg_sub.add_parser("export", help="effektiven Stand (inkl. Laufzeitzustand) als YAML ausgeben")
|
||||
@@ -42,6 +55,19 @@ def main(argv: list[str] | None = None) -> int:
|
||||
uvicorn.run("knaster.api:create_app", factory=True, host=args.host, port=args.port)
|
||||
return 0
|
||||
|
||||
if args.cmd == "tts-serve":
|
||||
import uvicorn
|
||||
|
||||
uvicorn.run("knaster.tts_server:create_tts_app", factory=True, host=args.host, port=args.port)
|
||||
return 0
|
||||
|
||||
if args.cmd == "bench":
|
||||
from .bench import bench_tts
|
||||
|
||||
result = bench_tts(args.url, args.model, api_key=args.api_key, runs=args.runs, text=args.text)
|
||||
print(yaml.safe_dump(result, sort_keys=False, allow_unicode=True))
|
||||
return 0 if result["runs"] else 1
|
||||
|
||||
if args.cmd == "validate":
|
||||
from .validate import validate_repo
|
||||
|
||||
|
||||
@@ -11,9 +11,17 @@ from ..util import load_yaml
|
||||
from .profile import GPU, NodeProfile
|
||||
|
||||
|
||||
class EngineSpec(BaseModel):
|
||||
"""Laufzeit-Anforderungen, die für alle Modelle einer Engine gelten."""
|
||||
|
||||
name: str
|
||||
min_cc: float = 0.0
|
||||
note: str = ""
|
||||
|
||||
|
||||
class ModelSpec(BaseModel):
|
||||
name: str
|
||||
kind: Literal["llm", "embedding", "reranker"]
|
||||
kind: Literal["llm", "embedding", "reranker", "tts"]
|
||||
vram_gb: float
|
||||
min_cc: float = 5.0
|
||||
engines: list[str]
|
||||
@@ -41,6 +49,7 @@ class RoleSpec(BaseModel):
|
||||
kinds: list[str] = Field(default_factory=list)
|
||||
cpu_fallback: bool = False
|
||||
optional: bool = False
|
||||
share_with: str | None = None # teilt sich die GPU einer bereits platzierten Rolle (z. B. TTS auf gpu-flex)
|
||||
slots: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
@@ -50,7 +59,7 @@ class Assignment(BaseModel):
|
||||
gpu_index: int | None = None
|
||||
gpu_name: str | None = None
|
||||
vram_gb: float | None = None
|
||||
mode: Literal["gpu", "cpu", "cpu_fallback"]
|
||||
mode: Literal["gpu", "shared", "cpu", "cpu_fallback"]
|
||||
models_fitting: list[str] = Field(default_factory=list)
|
||||
slots: list[str] = Field(default_factory=list)
|
||||
|
||||
@@ -65,15 +74,25 @@ class PlacementPlan(BaseModel):
|
||||
assignments: list[Assignment] = Field(default_factory=list)
|
||||
unplaced: list[Unplaced] = Field(default_factory=list)
|
||||
free_gpus: list[str] = Field(default_factory=list)
|
||||
disabled_gpus: list[str] = Field(default_factory=list)
|
||||
|
||||
def roles_on(self, node: str) -> list[str]:
|
||||
return [a.role for a in self.assignments if a.node == node]
|
||||
|
||||
|
||||
def load_catalog(directory: str | Path) -> tuple[list[RoleSpec], list[ModelSpec]]:
|
||||
"""Lädt Rollen und Modelle. Engine-Grenzen (z. B. PyTorch nicht auf Maxwell/Pascal) fließen in min_cc ein."""
|
||||
d = Path(directory)
|
||||
data = load_yaml(d / "models.yaml")
|
||||
engines = {k: EngineSpec(name=k, **v) for k, v in (data.get("engines") or {}).items()}
|
||||
models = []
|
||||
for name, spec in (data.get("models") or {}).items():
|
||||
model = ModelSpec(name=name, **spec)
|
||||
floors = [engines[e].min_cc for e in model.engines if e in engines]
|
||||
if floors:
|
||||
model.min_cc = max(model.min_cc, min(floors))
|
||||
models.append(model)
|
||||
roles = [RoleSpec(name=k, **v) for k, v in (load_yaml(d / "roles.yaml").get("roles") or {}).items()]
|
||||
models = [ModelSpec(name=k, **v) for k, v in (load_yaml(d / "models.yaml").get("models") or {}).items()]
|
||||
return roles, models
|
||||
|
||||
|
||||
@@ -105,11 +124,39 @@ def _fitting_models(role: RoleSpec, models: list[ModelSpec], gpu: GPU | None) ->
|
||||
|
||||
def plan(nodes: list[NodeProfile], roles: list[RoleSpec], models: list[ModelSpec]) -> PlacementPlan:
|
||||
result = PlacementPlan()
|
||||
free: list[tuple[NodeProfile, GPU]] = [(n, g) for n in nodes for g in n.gpus]
|
||||
free: list[tuple[NodeProfile, GPU]] = [(n, g) for n in nodes for g in n.gpus if not g.disabled]
|
||||
result.disabled_gpus = [f"{n.name}/gpu{g.index} {g.name}" + (f" – {g.note}" if g.note else "")
|
||||
for n in nodes for g in n.gpus if g.disabled]
|
||||
cpu_load: dict[str, int] = {n.name: 0 for n in nodes}
|
||||
|
||||
for role in sorted(roles, key=lambda r: (r.priority, r.name)):
|
||||
placed = 0
|
||||
if role.share_with and not role.needs.gpu:
|
||||
host = next((a for a in result.assignments if a.role == role.share_with), None)
|
||||
if host is None:
|
||||
result.unplaced.append(Unplaced(role=role.name, reason=f"Rolle {role.share_with} nicht platziert",
|
||||
optional=role.optional))
|
||||
else:
|
||||
cpu_load[host.node] += 1
|
||||
result.assignments.append(Assignment(role=role.name, node=host.node, mode="cpu",
|
||||
models_fitting=_fitting_models(role, models, None),
|
||||
slots=role.slots))
|
||||
continue
|
||||
if role.share_with:
|
||||
host = next((a for a in result.assignments if a.role == role.share_with and a.mode == "gpu"), None)
|
||||
gpu = None
|
||||
if host is not None:
|
||||
node = next(n for n in nodes if n.name == host.node)
|
||||
gpu = next(g for g in node.gpus if g.index == host.gpu_index)
|
||||
fitting = _fitting_models(role, models, gpu) if gpu is not None else []
|
||||
if gpu is not None and fitting:
|
||||
result.assignments.append(Assignment(
|
||||
role=role.name, node=host.node, gpu_index=gpu.index, gpu_name=gpu.name, vram_gb=gpu.memory_gb,
|
||||
mode="shared", models_fitting=fitting, slots=role.slots))
|
||||
else:
|
||||
reason = f"Rolle {role.share_with} nicht platziert" if gpu is None else "kein passendes Modell"
|
||||
result.unplaced.append(Unplaced(role=role.name, reason=reason, optional=role.optional))
|
||||
continue
|
||||
if role.needs.gpu:
|
||||
used_nodes: set[str] = set()
|
||||
for _ in range(role.count):
|
||||
|
||||
@@ -20,6 +20,8 @@ class GPU(BaseModel):
|
||||
memory_gb: float
|
||||
compute_cap: float
|
||||
driver: str | None = None
|
||||
disabled: bool = False # bewusst außer Betrieb (z. B. Strom sparen) – Placement ignoriert die Karte
|
||||
note: str = ""
|
||||
|
||||
|
||||
class Disk(BaseModel):
|
||||
|
||||
@@ -69,3 +69,19 @@ class OpenAIChatClient:
|
||||
usage=data.get("usage") or {},
|
||||
model=data.get("model"),
|
||||
)
|
||||
|
||||
|
||||
def speech(self, model: str, text: str, *, voice: str = "default", response_format: str = "wav") -> bytes:
|
||||
"""OpenAI-kompatibles TTS (`/v1/audio/speech`), z. B. über LiteLLM."""
|
||||
body = {"model": model, "input": text, "voice": voice, "response_format": response_format}
|
||||
try:
|
||||
resp = self.client.post(f"{self.base_url}/v1/audio/speech", json=body, headers=self.headers)
|
||||
except httpx.HTTPError as exc:
|
||||
raise EndpointError(f"{type(exc).__name__}: {exc}") from exc
|
||||
if resp.status_code >= 400:
|
||||
raise EndpointError(f"HTTP {resp.status_code}: {resp.text[:300]}")
|
||||
return resp.content
|
||||
|
||||
|
||||
class SpeechClient(Protocol):
|
||||
def speech(self, model: str, text: str, *, voice: str = "default", response_format: str = "wav") -> bytes: ...
|
||||
|
||||
@@ -25,7 +25,7 @@ class SlotSpec(BaseModel):
|
||||
effective_context: int | None = None
|
||||
supports_lora: bool = False
|
||||
fallbacks: list[str] = Field(default_factory=list)
|
||||
kind: Literal["chat", "embedding", "rerank"] = "chat"
|
||||
kind: Literal["chat", "embedding", "rerank", "tts"] = "chat"
|
||||
canary: bool = True # vom Prober geprüft (für tier 0 standardmäßig aus, siehe canary.py)
|
||||
ops: dict[str, str] = Field(default_factory=dict) # docker_host, container, pve_node, vmid → Healer-Labels
|
||||
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
"""Schlanker OpenAI-kompatibler TTS-Dienst (`/v1/audio/speech`) für CPU-Engines wie Piper.
|
||||
|
||||
Der Befehl ist als Vorlage konfigurierbar, weil sich die Piper-CLI zwischen Versionen unterscheidet:
|
||||
KNASTER_TTS_CMD Standard: "piper -m {model} -f {out}" (Text kommt über stdin)
|
||||
KNASTER_TTS_VOICES "thorsten=/voices/de_DE-thorsten-medium.onnx,kerstin=/voices/de_DE-kerstin-low.onnx"
|
||||
KNASTER_TTS_DEFAULT_VOICE Name der Standardstimme (sonst die erste)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shlex
|
||||
import subprocess
|
||||
import tempfile
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI, Header, HTTPException, Response
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
DEFAULT_CMD = "piper -m {model} -f {out}"
|
||||
MAX_CHARS = 5000
|
||||
|
||||
|
||||
class SpeechIn(BaseModel):
|
||||
model: str = "tts-de"
|
||||
input: str = Field(min_length=1, max_length=MAX_CHARS)
|
||||
voice: str = "default"
|
||||
response_format: str = "wav"
|
||||
|
||||
|
||||
class CommandBackend:
|
||||
def __init__(self, voices: dict[str, str], default_voice: str | None = None, cmd: str = DEFAULT_CMD,
|
||||
timeout: float = 120.0, max_parallel: int = 2):
|
||||
if not voices:
|
||||
raise ValueError("Mindestens eine Stimme konfigurieren")
|
||||
self.voices = voices
|
||||
self.default_voice = default_voice or next(iter(voices))
|
||||
self.cmd = cmd
|
||||
self.timeout = timeout
|
||||
self._slots = threading.Semaphore(max_parallel) # CPU nicht überbuchen
|
||||
|
||||
@classmethod
|
||||
def from_env(cls) -> CommandBackend:
|
||||
raw = os.environ.get("KNASTER_TTS_VOICES", "")
|
||||
voices = dict(item.split("=", 1) for item in raw.split(",") if "=" in item)
|
||||
return cls(voices, os.environ.get("KNASTER_TTS_DEFAULT_VOICE") or None,
|
||||
os.environ.get("KNASTER_TTS_CMD", DEFAULT_CMD),
|
||||
max_parallel=int(os.environ.get("KNASTER_TTS_PARALLEL", "2")))
|
||||
|
||||
def synthesize(self, text: str, voice: str = "default") -> bytes:
|
||||
name = self.default_voice if voice in ("default", "", None) else voice
|
||||
if name not in self.voices:
|
||||
raise KeyError(f"Stimme {name} unbekannt (verfügbar: {', '.join(self.voices)})")
|
||||
with self._slots, tempfile.TemporaryDirectory() as tmp:
|
||||
out = Path(tmp) / "out.wav"
|
||||
args = [part.format(model=self.voices[name], out=str(out)) for part in shlex.split(self.cmd)]
|
||||
proc = subprocess.run(args, input=text.encode("utf-8"), capture_output=True, timeout=self.timeout)
|
||||
if proc.returncode != 0 or not out.exists():
|
||||
raise RuntimeError(f"TTS-Befehl fehlgeschlagen ({proc.returncode}): "
|
||||
f"{proc.stderr.decode('utf-8', 'replace')[-300:]}")
|
||||
return out.read_bytes()
|
||||
|
||||
|
||||
def create_tts_app(backend: CommandBackend | None = None, token: str | None = None) -> FastAPI:
|
||||
backend = backend or CommandBackend.from_env()
|
||||
token = os.environ.get("KNASTER_TTS_TOKEN", "") if token is None else token
|
||||
app = FastAPI(title="knaster-tts")
|
||||
|
||||
@app.get("/health")
|
||||
def health() -> dict:
|
||||
return {"status": "ok", "voices": list(backend.voices), "default": backend.default_voice}
|
||||
|
||||
@app.post("/v1/audio/speech")
|
||||
def speech(body: SpeechIn, authorization: str | None = Header(default=None)) -> Response:
|
||||
if token and authorization != f"Bearer {token}":
|
||||
raise HTTPException(401, "unauthorized")
|
||||
if body.response_format != "wav":
|
||||
raise HTTPException(400, "nur response_format=wav")
|
||||
try:
|
||||
audio = backend.synthesize(body.input, body.voice)
|
||||
except KeyError as exc:
|
||||
raise HTTPException(400, str(exc)) from exc
|
||||
except (RuntimeError, subprocess.TimeoutExpired) as exc:
|
||||
raise HTTPException(502, str(exc)) from exc
|
||||
return Response(audio, media_type="audio/wav")
|
||||
|
||||
return app
|
||||
Reference in New Issue
Block a user