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:
+80
-7
@@ -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}
|
||||
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user