diff --git a/voice_service/app.py b/voice_service/app.py index 6138e8e..f54937b 100644 --- a/voice_service/app.py +++ b/voice_service/app.py @@ -39,10 +39,16 @@ log = logging.getLogger("voice_service") # --- Konfiguration (alles über Env überschreibbar; Defaults = Box-Stand) ---------- PORT = int(os.environ.get("VOICE_PORT", "8650")) # STT -STT_MODEL = os.environ.get("VOICE_STT_MODEL", "medium") # base|small|medium|large-v3 +# Engine: parakeet (Default, ~10× schneller + bessere Deutsch-WER als whisper-medium, +# Review 2026-07-02) | whisper (Fallback). Pro Request via Form-Feld `engine` überschreibbar. +STT_ENGINE = os.environ.get("VOICE_STT_ENGINE", "parakeet") +STT_MODEL = os.environ.get("VOICE_STT_MODEL", "medium") # whisper: base|small|medium|large-v3 STT_DEVICE = os.environ.get("VOICE_STT_DEVICE", "cpu") STT_COMPUTE = os.environ.get("VOICE_STT_COMPUTE", "int8") # int8=CPU-schonend STT_LANG = os.environ.get("VOICE_STT_LANG", "de") +# Parakeet-TDT 0.6B v3 (25 EU-Sprachen inkl. DE, Auto-Language) via onnx-asr. +PARAKEET_MODEL = os.environ.get("VOICE_PARAKEET_MODEL", "nemo-parakeet-tdt-0.6b-v3") +PARAKEET_QUANT = os.environ.get("VOICE_PARAKEET_QUANT", "int8") # ""=fp32 # Piper (Default-TTS): Verzeichnis mit *.onnx (+ *.onnx.json) Stimmen + das Piper-Binary. # Wir nutzen das offizielle Binary (statt des piper-tts-PyPI-Pakets), weil dessen Abhängigkeit # piper-phonemize auf neueren Linux-Systemen keine Wheels hat. Das Binary bringt espeak-ng mit. @@ -113,7 +119,7 @@ def stt_model(): return _stt -def transcribe(audio_bytes: bytes, suffix: str, language: str) -> str: +def transcribe_whisper(audio_bytes: bytes, suffix: str, language: str) -> str: # PyAV (in faster-whisper) dekodiert webm/opus/ogg/wav am robustesten aus einer Datei. with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tf: tf.write(audio_bytes) @@ -130,6 +136,65 @@ def transcribe(audio_bytes: bytes, suffix: str, language: str) -> str: pass +# --- Parakeet-TDT (onnx-asr, lazy Singleton) -------------------------------------- +_parakeet = None +_parakeet_failed = False + + +def parakeet_model(): + global _parakeet, _parakeet_failed + if _parakeet is None and not _parakeet_failed: + try: + import onnx_asr + kwargs = {"quantization": PARAKEET_QUANT} if PARAKEET_QUANT else {} + log.info("Lade Parakeet '%s' (quant=%s) …", PARAKEET_MODEL, PARAKEET_QUANT or "fp32") + _parakeet = onnx_asr.load_model(PARAKEET_MODEL, **kwargs) + except Exception: + # int8-Variante kann je nach Release fehlen → einmal fp32 probieren, dann aufgeben. + try: + import onnx_asr + log.warning("Parakeet quant=%s nicht ladbar, versuche fp32", PARAKEET_QUANT, exc_info=True) + _parakeet = onnx_asr.load_model(PARAKEET_MODEL) + except Exception: + _parakeet_failed = True + log.exception("Parakeet nicht verfügbar — STT fällt auf whisper zurück.") + return _parakeet + + +def transcribe_parakeet(audio_bytes: bytes, suffix: str) -> str | None: + """None = Engine nicht verfügbar (Aufrufer fällt auf whisper zurück). + Parakeet v3 erkennt die Sprache selbst; onnx-asr liest nur WAV → Browser-webm/opus + erst via PyAV (steckt in faster-whisper) auf 16 kHz mono dekodieren.""" + model = parakeet_model() + if model is None: + return None + import soundfile as sf + from faster_whisper.audio import decode_audio + with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tf: + tf.write(audio_bytes) + src = tf.name + wav = src + ".wav" + try: + arr = decode_audio(src, sampling_rate=16000) + sf.write(wav, arr, 16000, subtype="PCM_16") + return (model.recognize(wav) or "").strip() + finally: + for p in (src, wav): + try: + os.unlink(p) + except OSError: + pass + + +def transcribe(audio_bytes: bytes, suffix: str, language: str, engine: str = "") -> str: + eng = (engine or STT_ENGINE).strip().lower() + if eng == "parakeet": + text = transcribe_parakeet(audio_bytes, suffix) + if text is not None: + return text + return transcribe_whisper(audio_bytes, suffix, language) + + # ================================================================================= # TTS — Piper (offizielles Binary; Stimme = ONNX-Datei + .json daneben) # ================================================================================= @@ -352,8 +417,13 @@ def edge_list() -> list[dict]: async def lifespan(_app: FastAPI): logging.basicConfig(level=logging.INFO) try: - stt_model() # STT beim Start vorwärmen (Modell aus HF-Cache laden) - log.info("Voice-Sidecar bereit (STT '%s', Piper-Dir %s).", STT_MODEL, VOICES_DIR) + # Nur die konfigurierte Engine vorwärmen — whisper bleibt als Fallback lazy + # (spart ~1,5 GB RAM, solange Parakeet läuft). + if STT_ENGINE == "parakeet" and parakeet_model() is not None: + log.info("Voice-Sidecar bereit (STT parakeet '%s', Piper-Dir %s).", PARAKEET_MODEL, VOICES_DIR) + else: + stt_model() + log.info("Voice-Sidecar bereit (STT whisper '%s', Piper-Dir %s).", STT_MODEL, VOICES_DIR) except Exception: log.exception("STT-Vorwärmen fehlgeschlagen (Dienst läuft, /health meldet Detail).") yield @@ -372,7 +442,9 @@ class TTSIn(BaseModel): @app.get("/health") def health() -> dict: - return {"ok": True, "stt_model": STT_MODEL, + stt_active = "parakeet" if (STT_ENGINE == "parakeet" and not _parakeet_failed) else "whisper" + return {"ok": True, "stt_engine": stt_active, + "stt_model": PARAKEET_MODEL if stt_active == "parakeet" else STT_MODEL, "engines": ["elevenlabs", "edge"], "elevenlabs_key": bool(_eleven_key())} @@ -385,12 +457,13 @@ def voices() -> dict: @app.post("/stt") -async def stt(audio: UploadFile = File(...), language: str = Form(default="")) -> dict: +async def stt(audio: UploadFile = File(...), language: str = Form(default=""), + engine: str = Form(default="")) -> dict: data = await audio.read() if not data: raise HTTPException(400, "Leeres Audio.") suffix = Path(audio.filename or "rec.webm").suffix or ".webm" - text = transcribe(data, suffix, language or STT_LANG) + text = transcribe(data, suffix, language or STT_LANG, engine) return {"text": text} diff --git a/voice_service/requirements.txt b/voice_service/requirements.txt index 61ac483..e9946c9 100644 --- a/voice_service/requirements.txt +++ b/voice_service/requirements.txt @@ -8,3 +8,6 @@ soundfile numpy faster-whisper edge-tts +# STT-Default seit Review 2026-07-02: Parakeet-TDT 0.6B v3 (DE-WER besser + ~10x schneller +# als whisper-medium auf CPU, via onnx-asr). whisper bleibt als Fallback installiert. +onnx-asr[cpu,hub]