"""STT backends, transcription endpoints.""" from __future__ import annotations import asyncio import struct import uuid from pathlib import Path import requests from fastapi import APIRouter, File, Form, HTTPException, Request, UploadFile from core.config import _load_settings from core.constants import ( _WHISPER_DEFAULT, _FASTER_WHISPER_DEFAULT, _WHISPER_CPP_DEFAULT, _GROQ_STT_ENDPOINT, _NVIDIA_ASR_DEFAULT, _NVIDIA_ROUTER_DEFAULT, _MAX_UPLOAD_BYTES, _STT_REQUEST_TIMEOUT, ) from core.registry import _registry_get, TEMP_DIR from core.validation import _validate_http_url, _copy_limited from core.audio import _to_wav_16k from core.voice import _AUDIO_EXTS, _UPLOAD_EXTS router = APIRouter() # ── STT backend definitions ─────────────────────────────────────────────────── _STT_BACKEND_ALIASES = { "": "configured", "default": "configured", "whisper": "configured", "configured_whisper": "configured", "configured_stt": "configured", "parakeet": "nvidia_parakeet", "nvidia": "nvidia_parakeet", "nvidia_asr": "nvidia_parakeet", "nvidia_parakeet_asr": "nvidia_parakeet", "router": "nvidia_router", "speech_router": "nvidia_router", "nvidia_speech_router": "nvidia_router", "faster_whisper_server": "faster_whisper", "faster-whisper": "faster_whisper", "ctranslate2": "faster_whisper", "faster_w": "faster_whisper", "whisper-cpp": "whisper_cpp", "whisper_cpp_server": "whisper_cpp", "cpp": "whisper_cpp", "groq": "groq_whisper", "groq_stt": "groq_whisper", "groq-whisper": "groq_whisper", } _STT_BACKEND_METRICS: dict[str, dict] = { "configured": {"speed": "GPU / CPU", "latency": "1–5 s", "quality": "large-v3", "ram": "3 GB VRAM"}, "faster_whisper": {"speed": "~70× RT · GPU", "latency": "0.5–2 s", "quality": "large-v3", "ram": "1.5 GB VRAM"}, "whisper_cpp": {"speed": "~8–15× RT · CPU", "latency": "1–5 s", "quality": "large-v3 Q5", "ram": "~1 GB RAM"}, "groq_whisper": {"speed": "fastest cloud", "latency": "0.5–1 s", "quality": "Whisper Turbo", "ram": "cloud · 0"}, "nvidia_parakeet":{"speed": "~200× RT · GPU", "latency": "<0.3 s", "quality": "Parakeet-TDT", "ram": "~3 GB"}, "nvidia_router": {"speed": "GPU routed", "latency": "~0.5 s", "quality": "varies", "ram": "~11 GB"}, } _STT_VALID_BACKENDS = {"configured", "nvidia_parakeet", "nvidia_router", "faster_whisper", "whisper_cpp", "groq_whisper"} def _clean_stt_backend(value: str) -> str: import re original = str(value or "").strip() if original.startswith("custom:"): return original key = re.sub(r"[^a-z0-9]+", "_", original.lower()).strip("_") key = _STT_BACKEND_ALIASES.get(key, key) return key if key in _STT_VALID_BACKENDS else "configured" def _stt_backend_url(settings: dict, backend: str) -> str: backend = _clean_stt_backend(backend) if backend.startswith("custom:"): custom_id = backend[len("custom:"):] for card in settings.get("custom_engine_cards", []): if str(card.get("id", card.get("name", ""))) == custom_id: return card.get("url", "") return "" if backend == "nvidia_parakeet": return settings.get("nvidia_asr_url") or _NVIDIA_ASR_DEFAULT if backend == "nvidia_router": return settings.get("nvidia_router_url") or _NVIDIA_ROUTER_DEFAULT if backend == "faster_whisper": return settings.get("faster_whisper_url") or _FASTER_WHISPER_DEFAULT if backend == "whisper_cpp": return settings.get("whisper_cpp_url") or _WHISPER_CPP_DEFAULT if backend == "groq_whisper": return _GROQ_STT_ENDPOINT return settings.get("whisper_url") or _WHISPER_DEFAULT def _stt_backend_model(backend: str) -> str: backend = _clean_stt_backend(backend) if backend in {"nvidia_parakeet", "nvidia_router"}: return "whisper-1" if backend == "whisper_cpp": return "whisper-1" if backend == "groq_whisper": return "whisper-large-v3-turbo" return "large-v3" def _backend_port_label(url: str) -> str: from urllib.parse import urlsplit try: parts = urlsplit(url) if parts.port: return str(parts.port) except Exception: pass return "" def _stt_backend_label(backend: str, url: str) -> str: labels = { "configured": "Configured Whisper/STT", "nvidia_parakeet": "NVIDIA Parakeet ASR", "nvidia_router": "NVIDIA Speech Router", "faster_whisper": "faster-whisper (CTranslate2 GPU)", "whisper_cpp": "whisper.cpp (CPU/CUDA)", "groq_whisper": "Groq Whisper (cloud · free)", } port = _backend_port_label(url) label = labels.get(backend, backend) return f"{port} {label}" if port else label def _stt_backend_api_key(settings: dict, backend: str) -> str: if backend == "groq_whisper": return settings.get("groq_api_key", "").strip() return settings.get("whisper_api_key", "").strip() def _make_minimal_wav(duration_ms: int = 500, sample_rate: int = 16000) -> bytes: """Minimal WAV: mono 16-bit silence of given duration at given sample rate.""" num_frames = sample_rate * duration_ms // 1000 data = b"\x00\x00" * num_frames header = struct.pack( "<4sI4s4sIHHIIHH4sI", b"RIFF", 36 + len(data), b"WAVE", b"fmt ", 16, 1, 1, sample_rate, sample_rate * 2, 2, 16, b"data", len(data), ) return header + data def _stt_backend_health(url: str) -> tuple[bool, list[str]]: base = _validate_http_url(url, allow_private=True).rstrip("/") models: list[str] = [] ok = False try: r = requests.get(f"{base}/health", timeout=2) ok = r.status_code == 200 except Exception: pass try: r = requests.get(f"{base}/v1/models", timeout=3) if r.status_code == 200: ok = True payload = r.json() data = payload.get("data", []) if isinstance(payload, dict) else [] for item in data: if isinstance(item, dict) and item.get("id"): models.append(str(item["id"])) elif isinstance(item, str): models.append(item) except Exception: pass if not ok: return False, models try: wav = _make_minimal_wav() endpoint_found = False timed_out = False for path in ("/v1/audio/transcriptions", "/transcribe"): try: r = requests.post( f"{base}{path}", files={"file": ("probe.wav", wav, "audio/wav")}, data={"model": "whisper-1", "response_format": "text"}, timeout=8, ) if r.status_code in {404, 405}: continue if r.status_code == 500: try: detail = r.json().get("detail", "") audio_quality_words = ("too short", "no speech", "audio", "empty", "duration", "length", "silence") ok = bool(detail) and any(w in detail.lower() for w in audio_quality_words) except Exception: ok = False else: ok = True endpoint_found = True break except requests.exceptions.ConnectionError: ok = False endpoint_found = True break except Exception: timed_out = True if not endpoint_found and not timed_out: ok = False except Exception: pass return ok, models def _transcription_text_from_response(resp: requests.Response) -> str: try: payload = resp.json() if isinstance(payload, str): return payload.strip() if isinstance(payload, dict): for key in ("text", "transcript", "transcription"): if payload.get(key) is not None: return str(payload[key]).strip() segments = payload.get("segments") if isinstance(segments, list): return " ".join(s.get("text", "").strip() for s in segments if s.get("text")).strip() except Exception: pass return resp.text.strip() def _transcribe_audio(src: Path, settings: dict, backend: str = "configured") -> tuple[str, str]: backend = _clean_stt_backend(backend) stt_url = _validate_http_url(_stt_backend_url(settings, backend), allow_private=True).rstrip("/") stt_key = _stt_backend_api_key(settings, backend) hdrs = {"Authorization": f"Bearer {stt_key}"} if stt_key else {} model = _stt_backend_model(backend) paths = ["/v1/audio/transcriptions", "/transcribe"] if backend.startswith("custom:") else ["/v1/audio/transcriptions"] resp = None for path in paths: with src.open("rb") as f: resp = requests.post( f"{stt_url}{path}", files={"file": ("audio.wav", f, "audio/wav")}, data={"model": model, "response_format": "text"}, headers=hdrs, timeout=_STT_REQUEST_TIMEOUT, ) if resp.status_code == 404 and len(paths) > 1: continue break if resp.status_code in {400, 404, 422, 500} and model != "whisper-1": path = paths[-1] with src.open("rb") as f: resp = requests.post( f"{stt_url}{path}", files={"file": ("audio.wav", f, "audio/wav")}, data={"model": "whisper-1", "response_format": "text"}, headers=hdrs, timeout=60, ) if not resp.ok: try: body = resp.json() detail = body.get("detail") or body.get("error") or body.get("message") or str(body) except Exception: detail = resp.text[:300].strip() if not detail or detail.lower() in {"internal server error", "unknown error"}: detail = f"HTTP {resp.status_code} — backend may be misconfigured or missing CUDA support" if "'NoneType'" in detail and "'to'" in detail: detail = ("Speaker diarization failed — pyannote/speaker-diarization-3.1 requires " "a HuggingFace token. Get one at hf.co/settings/tokens and accept the " "model license at hf.co/pyannote/speaker-diarization-3.1, then add the " "token to the whisperx-gpu container env as HF_TOKEN.") raise RuntimeError(f"STT ({stt_url}): {detail}") return _transcription_text_from_response(resp), backend # ── STT routes ──────────────────────────────────────────────────────────────── @router.get("/api/stt-backends") async def stt_backends(): settings = _load_settings() items = [] seen_urls: set[tuple[str, str]] = set() ordered = ("configured", "faster_whisper", "whisper_cpp", "groq_whisper", "nvidia_parakeet", "nvidia_router") for backend in ordered: raw_url = _stt_backend_url(settings, backend) url = _validate_http_url(raw_url, allow_private=True).rstrip("/") key = (backend, url) if key in seen_urls: continue seen_urls.add(key) api_key = _stt_backend_api_key(settings, backend) if backend == "groq_whisper": ok = bool(api_key) models: list[str] = ["whisper-large-v3-turbo", "whisper-large-v3", "distil-whisper-large-v3-en"] else: ok, models = _stt_backend_health(url) items.append({ "id": backend, "label": _stt_backend_label(backend, url), "url": url, "port": _backend_port_label(url), "available": ok, "model": _stt_backend_model(backend), "models": models, "metrics": _STT_BACKEND_METRICS.get(backend, {}), }) for card in settings.get("custom_engine_cards", []): if card.get("role") not in ("stt", "stt+tts"): continue raw_url = card.get("url", "").strip() url = _validate_http_url(raw_url, allow_private=True).rstrip("/") if not url: continue card_id = "custom:" + str(card.get("id", card.get("name", ""))) if (card_id, url) in seen_urls: continue seen_urls.add((card_id, url)) ok, models = _stt_backend_health(url) port = _backend_port_label(url) label = card.get("label") or card.get("name") or "Custom STT" if port: label = f"{port} {label}" items.append({ "id": card_id, "label": label, "url": url, "port": port, "available": ok, "model": "whisper-1", "models": models, "metrics": {}, }) return {"backends": items} @router.post("/api/transcribe") async def transcribe(request: Request): data = await request.json() fid: str = data["id"] src = _registry_get(fid) if src is None or not src.exists(): raise HTTPException(404, "Audio not found") settings = _load_settings() backend = _clean_stt_backend(str(data.get("backend") or data.get("stt_backend") or "configured")) try: text, used_backend = await asyncio.to_thread(_transcribe_audio, src, settings, backend) return {"text": text, "backend": used_backend} except Exception as e: raise HTTPException(502, f"STT error ({backend}): {e}") @router.post("/api/transcribe-bytes") async def transcribe_bytes( file: UploadFile = File(...), backend: str = Form("configured"), ): """Accept raw audio upload and return transcription directly (used by hotkey daemon).""" suffix = Path(file.filename or "audio.wav").suffix.lower() or ".wav" if suffix not in _AUDIO_EXTS: raise HTTPException(400, "Unsupported audio type") tmp = TEMP_DIR / f"{uuid.uuid4().hex}_daemon{suffix}" wav_tmp = tmp try: with tmp.open("wb") as f: _copy_limited(file.file, f, _MAX_UPLOAD_BYTES) if suffix != ".wav": wav_tmp = _to_wav_16k(tmp) settings = _load_settings() stt_backend = _clean_stt_backend(backend) text, used_backend = await asyncio.to_thread(_transcribe_audio, wav_tmp, settings, stt_backend) return {"text": text, "backend": used_backend} except HTTPException: raise except Exception as e: raise HTTPException(502, f"STT error: {e}") finally: for p in {tmp, wav_tmp}: try: p.unlink(missing_ok=True) except Exception: pass