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) ----------
|
# --- Konfiguration (alles über Env überschreibbar; Defaults = Box-Stand) ----------
|
||||||
PORT = int(os.environ.get("VOICE_PORT", "8650"))
|
PORT = int(os.environ.get("VOICE_PORT", "8650"))
|
||||||
# STT
|
# 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_DEVICE = os.environ.get("VOICE_STT_DEVICE", "cpu")
|
||||||
STT_COMPUTE = os.environ.get("VOICE_STT_COMPUTE", "int8") # int8=CPU-schonend
|
STT_COMPUTE = os.environ.get("VOICE_STT_COMPUTE", "int8") # int8=CPU-schonend
|
||||||
STT_LANG = os.environ.get("VOICE_STT_LANG", "de")
|
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.
|
# 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
|
# 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.
|
# piper-phonemize auf neueren Linux-Systemen keine Wheels hat. Das Binary bringt espeak-ng mit.
|
||||||
@@ -113,7 +119,7 @@ def stt_model():
|
|||||||
return _stt
|
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.
|
# PyAV (in faster-whisper) dekodiert webm/opus/ogg/wav am robustesten aus einer Datei.
|
||||||
with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tf:
|
with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tf:
|
||||||
tf.write(audio_bytes)
|
tf.write(audio_bytes)
|
||||||
@@ -130,6 +136,65 @@ def transcribe(audio_bytes: bytes, suffix: str, language: str) -> str:
|
|||||||
pass
|
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)
|
# TTS — Piper (offizielles Binary; Stimme = ONNX-Datei + .json daneben)
|
||||||
# =================================================================================
|
# =================================================================================
|
||||||
@@ -352,8 +417,13 @@ def edge_list() -> list[dict]:
|
|||||||
async def lifespan(_app: FastAPI):
|
async def lifespan(_app: FastAPI):
|
||||||
logging.basicConfig(level=logging.INFO)
|
logging.basicConfig(level=logging.INFO)
|
||||||
try:
|
try:
|
||||||
stt_model() # STT beim Start vorwärmen (Modell aus HF-Cache laden)
|
# Nur die konfigurierte Engine vorwärmen — whisper bleibt als Fallback lazy
|
||||||
log.info("Voice-Sidecar bereit (STT '%s', Piper-Dir %s).", STT_MODEL, VOICES_DIR)
|
# (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:
|
except Exception:
|
||||||
log.exception("STT-Vorwärmen fehlgeschlagen (Dienst läuft, /health meldet Detail).")
|
log.exception("STT-Vorwärmen fehlgeschlagen (Dienst läuft, /health meldet Detail).")
|
||||||
yield
|
yield
|
||||||
@@ -372,7 +442,9 @@ class TTSIn(BaseModel):
|
|||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
def health() -> dict:
|
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"],
|
"engines": ["elevenlabs", "edge"],
|
||||||
"elevenlabs_key": bool(_eleven_key())}
|
"elevenlabs_key": bool(_eleven_key())}
|
||||||
|
|
||||||
@@ -385,12 +457,13 @@ def voices() -> dict:
|
|||||||
|
|
||||||
|
|
||||||
@app.post("/stt")
|
@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()
|
data = await audio.read()
|
||||||
if not data:
|
if not data:
|
||||||
raise HTTPException(400, "Leeres Audio.")
|
raise HTTPException(400, "Leeres Audio.")
|
||||||
suffix = Path(audio.filename or "rec.webm").suffix or ".webm"
|
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}
|
return {"text": text}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -8,3 +8,6 @@ soundfile
|
|||||||
numpy
|
numpy
|
||||||
faster-whisper
|
faster-whisper
|
||||||
edge-tts
|
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