diff --git a/.env.example b/.env.example index ce07d4d..56e69c5 100644 --- a/.env.example +++ b/.env.example @@ -66,6 +66,13 @@ LOCAL_LLM_MODEL=va_llm # LOCAL_LLM_TEMPERATURE=0.3 # LOCAL_LLM_SYSTEM_PROMPT=Du bist ein gesprochener Sprachassistent. Antworte kurz ... +# --- GPU-Sichtbarkeit -------------------------------------------------------- +# auto = erkennen (respektiert vorhandenes CUDA_VISIBLE_DEVICES) +# none = keine GPU erzwingen (CPU); 0,1,2 = nur diese Indizes +# Wird beim Start an CUDA_VISIBLE_DEVICES gekoppelt -> steuert auch, welche +# GPU-only-Menuepunkte (z. B. "Hohe Qualitaet"/Chatterbox) eingeblendet werden. +GPUS=auto + # --- Lokales STT (faster-whisper; nur mit pip install -e .[local] ) --------- FASTER_WHISPER_MODEL=base # tiny|base|small|medium|large-v3 FASTER_WHISPER_DEVICE=auto # auto|cpu|cuda diff --git a/app/admin_llm.py b/app/admin_llm.py index 76f72a6..444eeb2 100644 --- a/app/admin_llm.py +++ b/app/admin_llm.py @@ -14,6 +14,7 @@ import subprocess from pathlib import Path from app.config import settings +from app.capabilities import detect_physical_gpus ALLOWED_BACKENDS = {"ollama", "llamacpp"} _SCRIPT = Path(__file__).resolve().parent.parent / "scripts" / "llm-server" / "switch-llm.sh" @@ -74,28 +75,6 @@ async def _ollama_loaded() -> tuple[bool, list[dict]]: return True, models -async def _gpus() -> list[dict]: - out = await _run([ - "nvidia-smi", - "--query-gpu=index,memory.used,memory.total", - "--format=csv,noheader,nounits", - ]) - if out is None: - return [] - gpus: list[dict] = [] - for ln in out.splitlines(): - parts = [p.strip() for p in ln.split(",")] - if len(parts) == 3 and parts[0].isdigit(): - used, total = int(parts[1]), int(parts[2]) - gpus.append({ - "index": int(parts[0]), - "used_mib": used, - "total_mib": total, - "percent": round(used / total * 100) if total else 0, - }) - return gpus - - async def _gateway_service_active() -> bool: out = await _run(["systemctl", "is-active", "voice-assistant.service"], timeout=4.0) return bool(out and out.strip() == "active") @@ -108,7 +87,7 @@ async def llm_status() -> dict: llamacpp, (ollama_reachable, ollama_models), gpus, gw = await asyncio.gather( _llamacpp_running(), _ollama_loaded(), - _gpus(), + detect_physical_gpus(), _gateway_service_active(), ) return { diff --git a/app/api/config.py b/app/api/config.py index 1d3b430..68f69e7 100644 --- a/app/api/config.py +++ b/app/api/config.py @@ -1,7 +1,11 @@ +import asyncio +import time + import httpx from fastapi import APIRouter from app.config import settings, active_profile +from app.capabilities import resolve_gpus from app.providers.tts.cartesia import _UNSUPPORTED_LANGS as _CARTESIA_UNSUPPORTED from app.dependencies import ( resolve_route, @@ -13,6 +17,13 @@ from app.dependencies import ( router = APIRouter() +# /api/config probt externe Dienste (u. a. einen KOSTENPFLICHTIGEN OpenRouter-TTS-Aufruf). +# Daher serverseitig kurz cachen: das Frontend ruft den Endpunkt mehrfach (Laden + Menü-Öffnen). +_CACHE: dict | None = None +_CACHE_TS: float = 0.0 +_CACHE_TTL = 30.0 +_CACHE_LOCK = asyncio.Lock() + async def _chatterbox_reachable() -> bool: try: @@ -62,16 +73,31 @@ async def _openrouter_tts_available() -> bool: return False -@router.get("/config") -async def get_config(): - """Zeigt aktives Profil, aufgeloeste Default-Route und verfuegbare Bausteine.""" +async def _build_config() -> dict: + """Baut die /api/config-Antwort (eine Quelle der Wahrheit für die Menü-Sichtbarkeit).""" route = resolve_route() audio_router = get_audio_router() - chatterbox_ok, openrouter_tts_ok, cartesia_ok = await _chatterbox_reachable(), await _openrouter_tts_available(), await _cartesia_available() + chatterbox_ok, openrouter_tts_ok, cartesia_ok, gpus = ( + await _chatterbox_reachable(), + await _openrouter_tts_available(), + await _cartesia_available(), + await resolve_gpus(), + ) + # Fertige Sichtbarkeits-Entscheidung je TTS-Provider -> Frontend dupliziert keine Regeln. + # "device" ist client_only: nur das Frontend kann "Mobilgerät?" beantworten (isMobile()). + # "piper" bleibt der garantierte, immer sichtbare Default (Menü nie leer). + tts_menu = [ + {"provider": "piper", "visible": True, "client_only": False}, + {"provider": "chatterbox", "visible": chatterbox_ok, "client_only": False}, + {"provider": "cartesia", "visible": cartesia_ok, "client_only": False}, + {"provider": "openrouter", "visible": openrouter_tts_ok, "client_only": False}, + {"provider": "device", "visible": True, "client_only": True}, + ] return { "profile": active_profile(), "app_env": settings.app_env, "default_route": route.as_dict(), + "tts_menu": tts_menu, "available": { "stt_providers": sorted(STT_REGISTRY), "llm_providers": sorted(LLM_REGISTRY), @@ -82,8 +108,23 @@ async def get_config(): "openrouter_tts_available": openrouter_tts_ok, "cartesia_available": cartesia_ok, "cartesia_unsupported_languages": sorted(_CARTESIA_UNSUPPORTED), + "gpus": gpus, }, "secrets": { "openrouter_api_key_set": bool(settings.openrouter_api_key.strip()), }, } + + +@router.get("/config") +async def get_config(): + """Zeigt aktives Profil, aufgeloeste Default-Route und verfuegbare Bausteine (gecacht).""" + global _CACHE, _CACHE_TS + if _CACHE is not None and (time.monotonic() - _CACHE_TS) < _CACHE_TTL: + return _CACHE + async with _CACHE_LOCK: + if _CACHE is not None and (time.monotonic() - _CACHE_TS) < _CACHE_TTL: + return _CACHE + _CACHE = await _build_config() + _CACHE_TS = time.monotonic() + return _CACHE diff --git a/app/capabilities.py b/app/capabilities.py new file mode 100644 index 0000000..b347395 --- /dev/null +++ b/app/capabilities.py @@ -0,0 +1,80 @@ +"""Laufzeit-Fähigkeiten der Hardware (GPU-Erkennung) — eine Quelle für Admin + /api/config. + +Die GPU-Maske `settings.gpus` wird beim Start an `CUDA_VISIBLE_DEVICES` gekoppelt +(`app/config.py::_apply_gpu_visibility`). Hier wird nur **erkannt** und nach Maske +**aufgelöst**; CUDA_VISIBLE_DEVICES wird zusätzlich respektiert, damit „angezeigt" = +„real nutzbar" gilt. +""" + +from __future__ import annotations + +import asyncio +import os +import shutil + +from app.config import settings, _parse_gpu_indices + + +async def detect_physical_gpus() -> list[dict]: + """Physisch vorhandene NVIDIA-GPUs via nvidia-smi. [] wenn kein Tool/keine GPU/Fehler.""" + if not shutil.which("nvidia-smi"): + return [] + try: + proc = await asyncio.create_subprocess_exec( + "nvidia-smi", + "--query-gpu=index,name,memory.used,memory.total", + "--format=csv,noheader,nounits", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.DEVNULL, + ) + out, _ = await asyncio.wait_for(proc.communicate(), timeout=6.0) + if proc.returncode != 0: + return [] + except (asyncio.TimeoutError, OSError): + return [] + gpus: list[dict] = [] + for ln in out.decode("utf-8", "replace").splitlines(): + parts = [p.strip() for p in ln.split(",")] + if len(parts) == 4 and parts[0].isdigit(): + used, total = int(parts[2]), int(parts[3]) + gpus.append({ + "index": int(parts[0]), + "name": parts[1], + "used_mib": used, + "total_mib": total, + "percent": round(used / total * 100) if total else 0, + }) + return gpus + + +def _cuda_visible_mask() -> set[int] | None: + """Indizes aus CUDA_VISIBLE_DEVICES; None = keine Einschränkung, set() = explizit keine.""" + raw = os.environ.get("CUDA_VISIBLE_DEVICES") + if raw is None: + return None + if raw.strip() == "": + return set() # leer = keine GPU für CUDA-Anwendungen + return set(_parse_gpu_indices(raw)) + + +async def resolve_gpus() -> list[dict]: + """Sichtbare/nutzbare GPUs = physisch ∩ settings.gpus-Maske ∩ CUDA_VISIBLE_DEVICES.""" + mode = (settings.gpus or "auto").strip().lower() + if mode in ("none", "off", "false", "-1", ""): + return [] + physical = await detect_physical_gpus() + explicit = None if mode == "auto" else set(_parse_gpu_indices(settings.gpus)) + cuda = _cuda_visible_mask() + visible: list[dict] = [] + for g in physical: + idx = g["index"] + if explicit is not None and idx not in explicit: + continue + if cuda is not None and idx not in cuda: + continue + visible.append(g) + return visible + + +async def gpu_available() -> bool: + return len(await resolve_gpus()) > 0 diff --git a/app/config.py b/app/config.py index 4f8c6cb..13adf33 100644 --- a/app/config.py +++ b/app/config.py @@ -135,6 +135,11 @@ class Settings(BaseSettings): faster_whisper_device: str = "auto" # auto|cpu|cuda faster_whisper_compute_type: str = "int8" # default|int8|float16|int8_float16 stt_force_language: bool = True # STT mit fester Zielsprache (robust); False = Auto-Detect + Übersetzung + # GPU-Sichtbarkeit: "auto" = erkennen (respektiert vorhandenes CUDA_VISIBLE_DEVICES), + # "none"/leer = keine GPU erzwingen, "0,1,2" = nur diese Indizes. Wird beim Start an + # CUDA_VISIBLE_DEVICES gekoppelt (siehe _apply_gpu_visibility), damit das, was das Menü + # anzeigt, auch real von torch/faster-whisper genutzt wird. + gpus: str = "auto" # --- Lokales TTS (piper) ------------------------------------------------- piper_bin: str = "piper" # Pfad/Name des piper-Binaries piper_voices_dir: str = str(Path.home() / ".local" / "share" / "piper" / "voices") @@ -234,3 +239,29 @@ class Settings(BaseSettings): settings = Settings() + + +def _parse_gpu_indices(value: str) -> list[int]: + """'0,2' -> [0, 2]; leer/ungültig -> [].""" + return sorted({int(t) for t in (value or "").split(",") if t.strip().isdigit()}) + + +def _apply_gpu_visibility() -> None: + """Koppelt settings.gpus an CUDA_VISIBLE_DEVICES, BEVOR torch/CUDA initialisiert. + + - "auto": nichts erzwingen (ein bereits gesetztes CUDA_VISIBLE_DEVICES bleibt gültig). + - "none"/leer/"-1": CUDA_VISIBLE_DEVICES="" -> faster-whisper "auto" fällt auf CPU. + - "0,1,...": genau diese Indizes sichtbar machen (explizit gewinnt). + """ + mode = (settings.gpus or "auto").strip().lower() + if mode == "auto": + return + if mode in ("none", "off", "false", "-1", ""): + os.environ["CUDA_VISIBLE_DEVICES"] = "" + return + indices = _parse_gpu_indices(settings.gpus) + if indices: + os.environ["CUDA_VISIBLE_DEVICES"] = ",".join(str(i) for i in indices) + + +_apply_gpu_visibility() diff --git a/app/web/app.js b/app/web/app.js index c72294f..35800b9 100644 --- a/app/web/app.js +++ b/app/web/app.js @@ -84,8 +84,8 @@ function loadVoices() { _voices = TTS_SUPPORTED ? speechSynthesis.getVoices() : if (TTS_SUPPORTED) { loadVoices(); // Stimmen laden asynchron -> bei Eintreffen Preset-Zustand neu bewerten (entgrauen). - speechSynthesis.addEventListener("voiceschanged", () => { loadVoices(); refreshDevicePreset(); }); - setTimeout(() => refreshDevicePreset(), 1500); // Backstop, falls kein voiceschanged kommt + speechSynthesis.addEventListener("voiceschanged", () => { loadVoices(); applyTtsVisibility(); }); + setTimeout(() => applyTtsVisibility(), 1500); // Backstop, falls kein voiceschanged kommt } // Die Web Speech API kennt KEIN Standard-Geschlechtsfeld an einer Stimme -> Heuristik @@ -237,75 +237,61 @@ function applyPlaybackChoice(serverProvider) { } else if (!ttsSel.value) { ttsSel.value = "piper"; // Fallback: Server } - refreshDevicePreset(); // graut „Im Gerät" aus, wenn keine Stimmen + syncPresetUI + applyTtsVisibility(); // blendet Provider passend ein/aus + syncPresetUI } -// Graut „Im Gerät" aus, wenn keine Sprachstimmen vorhanden sind; entgraut automatisch, -// sobald welche da sind. Kein Aufzwingen: device wird nur (wieder) gewählt, wenn es die -// gespeicherte Präferenz ist. Ohne Stimmen NICHT auf der deaktivierten Option sitzen bleiben. -function refreshDevicePreset() { - const btn = document.querySelector('.tts-preset[data-tts="device"]'); - if (!btn || !ttsSel) return; - const ready = deviceVoicesReady(); - btn.disabled = !ready; - btn.classList.toggle("opacity-50", !ready); - btn.classList.toggle("cursor-not-allowed", !ready); - const sub = btn.querySelector(".tts-sub"); - if (sub) sub.textContent = ready +// ── TTS-Menü: Sichtbarkeit der Provider (eine Quelle: Backend /api/config) ── +// Regeln liegen im Backend (tts_menu); hier nur rendern + die client-only-Regel +// „Im Gerät nur auf Mobilgeräten" (isMobile). Ausgeblendet, nicht ausgegraut. +let _ttsMenu = null; // zuletzt geladenes tts_menu von /api/config +const TTS_DEFAULT = "piper"; // garantiert sichtbarer Fallback -> Menü nie leer + +function _ttsVisible(provider) { + if (provider === "device") return TTS_SUPPORTED && isMobile(); // nur mobil + if (provider === TTS_DEFAULT) return true; // garantierter Default + if (!_ttsMenu) return true; // vor dem Laden nichts verstecken + const e = _ttsMenu.find((m) => m.provider === provider); + return e ? e.visible !== false : true; +} + +// Blendet Provider-Buttons (und die zugehörigen