diff --git a/backend/routers/voice.py b/backend/routers/voice.py index 4f49713..72a7ceb 100644 --- a/backend/routers/voice.py +++ b/backend/routers/voice.py @@ -11,6 +11,7 @@ LAN-only (kein Token in der 2.0-Phase), wie die übrigen MC2-Endpoints. import logging import os +import time import httpx from fastapi import APIRouter, File, Form, HTTPException, UploadFile @@ -18,6 +19,7 @@ from fastapi.responses import Response, StreamingResponse from pydantic import BaseModel from config import HERMES_API_KEY, HERMES_API_MODEL, HERMES_API_URL, LLAMA_SWAP_URL, VOICE_SERVICE_URL +from services.voice_metrics import Timer, get_metrics, record_stage # Per-Stage-Latenz (C2) # Injection-Schutz (Stufe 0): guard.py liegt im mcp/-Verzeichnis. Per Pfad laden (eigene MC2-Venv). import sys as _sys @@ -86,6 +88,13 @@ class ChatIn(BaseModel): images: list[str] = [] # optionale Bildschirm-Sicht: ein data:-URL je Monitor (Lucys „Augen") +@router.get("/voice/metrics") +def voice_metrics() -> dict: + """Per-Stage-Latenz (STT/Vision/Chat-TTFB/TTS) — rollende Statistik, macht die Voice-Pipeline + messbar (C2). Anzeige im Frontend-Overhaul (E).""" + return get_metrics() + + @router.get("/voice/health") def voice_health() -> dict: """Erreichbarkeit des Voice-Sidecars + ob der Hermes-API-Key gesetzt ist.""" @@ -118,7 +127,8 @@ async def voice_stt(audio: UploadFile = File(...), language: str = Form(default= files = {"audio": (audio.filename or "rec.webm", data, audio.content_type or "audio/webm")} try: async with httpx.AsyncClient(timeout=_TIMEOUT) as client: - r = await client.post(f"{VOICE_SERVICE_URL}/stt", files=files, data={"language": language}) + with Timer("stt"): + r = await client.post(f"{VOICE_SERVICE_URL}/stt", files=files, data={"language": language}) r.raise_for_status() return r.json() except httpx.HTTPError as exc: @@ -166,7 +176,8 @@ async def voice_tts(body: TTSIn) -> Response: """Text → Sprache (Proxy auf Sidecar /tts), liefert WAV-Bytes.""" try: async with httpx.AsyncClient(timeout=_TIMEOUT) as client: - r = await client.post(f"{VOICE_SERVICE_URL}/tts", json=body.model_dump()) + with Timer("tts"): + r = await client.post(f"{VOICE_SERVICE_URL}/tts", json=body.model_dump()) r.raise_for_status() return Response(content=r.content, media_type=r.headers.get("content-type", "audio/wav")) except httpx.HTTPError as exc: @@ -190,7 +201,8 @@ async def voice_chat(body: ChatIn) -> StreamingResponse: if imgs: # Bildschirm-Sicht: erst das Vision-Modell die Monitore beschreiben lassen, dann die Beschreibung # als TEXT-Kontext an Hermes (Lucy antwortet mit vollem Hirn/Gedächtnis, sieht via besserem VL-Modell). - desc = await _describe_images(imgs, body.text) + with Timer("vision"): + desc = await _describe_images(imgs, body.text) if desc: safe_desc = wrap_untrusted(desc, "BILDSCHIRM") user_text = f"[Bildschirm-Sicht — das ist gerade auf dem/den Schirm(en) zu sehen:\n{safe_desc}\n]\n\n{body.text}" @@ -204,6 +216,8 @@ async def voice_chat(body: ChatIn) -> StreamingResponse: headers["X-Hermes-Session-Key"] = body.session_key async def gen(): + t0 = time.perf_counter() + first = True try: async with httpx.AsyncClient(timeout=httpx.Timeout(None, connect=5.0)) as client: async with client.stream( @@ -214,6 +228,9 @@ async def voice_chat(body: ChatIn) -> StreamingResponse: yield f"data: {{\"error\": \"Hermes {r.status_code}: {detail}\"}}\n\n".encode() return async for chunk in r.aiter_raw(): + if first: # Time-To-First-Byte des Hermes-Streams (gefühlte Lucy-Latenz) + record_stage("chat_ttfb", (time.perf_counter() - t0) * 1000.0) + first = False yield chunk except httpx.HTTPError as exc: yield f"data: {{\"error\": \"Verbindung zu Hermes fehlgeschlagen: {exc}\"}}\n\n".encode() diff --git a/backend/services/voice_metrics.py b/backend/services/voice_metrics.py new file mode 100644 index 0000000..758b12b --- /dev/null +++ b/backend/services/voice_metrics.py @@ -0,0 +1,68 @@ +""" +Per-Stage-Latenz-Metriken für die Voice/Lucy-Pipeline. + +Misst die Server-seitige Dauer jeder Stufe (STT, Vision-Beschreibung, Chat-TTFB, TTS) und hält +rollende Statistiken (avg/p50/p95/last) im Speicher. Macht aus Latenz-VERMUTUNGEN gemessene Fakten +— die eigentliche Voraussetzung, um gezielt zu optimieren (Stufe 5/C2 des Reviews). Anzeige im +Frontend-Overhaul (E) analog zur TokenPerformanceCard. + +In-Memory + thread-safe (keine Datei-I/O — Latenz-Telemetrie ist transient, Restart = Reset). +""" + +import threading +import time +from collections import deque + +_LOCK = threading.Lock() +_MAX = 200 +_STAGES: dict[str, deque] = {} + +# Bekannte Stufen (für stabile UI-Reihenfolge); unbekannte werden trotzdem erfasst. +STAGES = ("stt", "vision", "chat_ttfb", "tts") + + +def record_stage(stage: str, ms: float) -> None: + """Eine gemessene Stage-Dauer (ms) verbuchen. No-op bei negativen Werten.""" + if ms is None or ms < 0: + return + with _LOCK: + dq = _STAGES.get(stage) + if dq is None: + dq = _STAGES[stage] = deque(maxlen=_MAX) + dq.append(float(ms)) + + +class Timer: + """Context-Manager: misst die verstrichene Zeit und verbucht sie auf `stage`. + Funktioniert um `await`-Aufrufe herum (enter → await → exit).""" + + def __init__(self, stage: str) -> None: + self.stage = stage + self._t0 = 0.0 + + def __enter__(self) -> "Timer": + self._t0 = time.perf_counter() + return self + + def __exit__(self, *exc) -> None: + record_stage(self.stage, (time.perf_counter() - self._t0) * 1000.0) + + +def _summary(vals: list[float]) -> dict: + if not vals: + return {"count": 0} + s = sorted(vals) + n = len(s) + return { + "count": n, + "avg_ms": round(sum(s) / n, 1), + "p50_ms": round(s[n // 2], 1), + "p95_ms": round(s[min(n - 1, int(n * 0.95))], 1), + "last_ms": round(vals[-1], 1), + } + + +def get_metrics() -> dict: + """Rollende Zusammenfassung je Stufe.""" + with _LOCK: + return {stage: _summary(list(dq)) for stage, dq in _STAGES.items()}