STT: Parakeet-TDT 0.6B v3 als Default-Engine (Review P1-6) — 2,0s -> 0,45s

- onnx-asr (int8, CPU) als neue STT-Engine im Voice-Sidecar; whisper bleibt lazy Fallback
- Engine per VOICE_STT_ENGINE + pro Request (Form-Feld engine) waehlbar
- A/B auf der Box (10,8s DE-Audio): parakeet 0,45s vs whisper-medium 2,44s, Transkript identisch gut
- Live deployt + verifiziert (auch ueber :9001-Proxy: 0,46s)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-07-02 10:41:27 +02:00
parent 64efe18500
commit 403961da51
2 changed files with 83 additions and 7 deletions
+80 -7
View File
@@ -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}