feat(stt): echtes lokales STT via faster-whisper (optional .[local])

- FasterWhisperProvider implementiert (CTranslate2): Modell prozessweit gecacht
  (lru_cache), Transkription in asyncio.to_thread; robuster CPU-Fallback wenn
  GPU/compute_type nicht verfuegbar
- Config: FASTER_WHISPER_MODEL/DEVICE/COMPUTE_TYPE (Defaults base/auto/default)
- pyproject: optionales Extra [local] = faster-whisper
- Test: transcribe-Endpunkt nutzt jetzt einen Stub-STT (kein Platzhalter mehr)
- Doku: Voraussetzungen, Hybrid-Beispiel (STT+LLM lokal, TTS remote), Architektur/README

Live verifiziert: TTS->WAV->faster-whisper transkribiert korrekt (de); voller Hybrid
ueber /ws/voice (faster-whisper + Ollama llama3.2 + OpenRouter-TTS) funktioniert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-06-17 11:28:20 +02:00
commit c25f081f9f
8 changed files with 92 additions and 8 deletions

View file

@ -1,5 +1,49 @@
"""Lokaler STT-Provider auf Basis von faster-whisper (CTranslate2).
Optionale Dependency: `pip install -e .[local]`. Das Whisper-Modell wird beim
ersten Aufruf geladen (und ggf. heruntergeladen) und prozessweit zwischengespeichert.
Die Transkription ist CPU/GPU-lastig und laeuft daher in einem Thread, damit der
Event-Loop frei bleibt.
"""
import asyncio
import io
from functools import lru_cache
from app.config import settings
from app.providers.stt.base import STTProvider
@lru_cache(maxsize=2)
def _load_model(model_size: str, device: str, compute_type: str):
try:
from faster_whisper import WhisperModel
except ModuleNotFoundError as exc: # pragma: no cover - haengt von Installation ab
raise RuntimeError(
"faster-whisper ist nicht installiert. Installieren mit: pip install -e .[local]"
) from exc
try:
return WhisperModel(model_size, device=device, compute_type=compute_type)
except Exception:
# GPU/Compute-Type nicht verfuegbar -> robuster CPU-Fallback (int8).
if device != "cpu":
return WhisperModel(model_size, device="cpu", compute_type="int8")
raise
class FasterWhisperProvider(STTProvider):
def __init__(self, model_size: str | None = None, device: str | None = None,
compute_type: str | None = None):
self.model_size = model_size or settings.faster_whisper_model
self.device = device or settings.faster_whisper_device
self.compute_type = compute_type or settings.faster_whisper_compute_type
def _transcribe_sync(self, audio_bytes: bytes, language: str | None) -> str:
model = _load_model(self.model_size, self.device, self.compute_type)
segments, _info = model.transcribe(io.BytesIO(audio_bytes), language=language)
return "".join(segment.text for segment in segments).strip()
async def transcribe(self, audio_bytes: bytes, fmt: str, language: str | None = None) -> str:
return "[local transcription placeholder]"
if not audio_bytes:
raise ValueError("STT input audio is empty")
return await asyncio.to_thread(self._transcribe_sync, audio_bytes, language)