From c25f081f9f8ae6e7e724b24d06e2d31b85fa872c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dieter=20Schl=C3=BCter?= Date: Wed, 17 Jun 2026 11:28:20 +0200 Subject: [PATCH] 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 --- .env.example | 5 +++ BEDIENUNGSANLEITUNG.md | 19 ++++++++++++ Docs/voice-assistant-architecture.md | 8 +++-- README.md | 2 +- app/config.py | 3 ++ app/providers/stt/faster_whisper.py | 46 +++++++++++++++++++++++++++- pyproject.toml | 4 +++ tests/test_endpoints_e2e.py | 13 +++++--- 8 files changed, 92 insertions(+), 8 deletions(-) diff --git a/.env.example b/.env.example index e262b9c..8dd2e81 100644 --- a/.env.example +++ b/.env.example @@ -37,6 +37,11 @@ LOCAL_LLM_BASE_URL=http://127.0.0.1:11434/v1 LOCAL_LLM_API_KEY=dummy LOCAL_LLM_MODEL=llama3.1 +# --- Lokales STT (faster-whisper; nur mit pip install -e .[local] ) --------- +FASTER_WHISPER_MODEL=base # tiny|base|small|medium|large-v3 +FASTER_WHISPER_DEVICE=auto # auto|cpu|cuda +FASTER_WHISPER_COMPUTE_TYPE=default # default|int8|float16|int8_float16 + # --- Resilienz: Fallback-Ketten (kommaseparierte Provider-Namen) ------------ # Faellt der primaere Provider aus, uebernimmt der naechste. # STT_FALLBACK=faster-whisper diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md index 6e11f4e..b525777 100644 --- a/BEDIENUNGSANLEITUNG.md +++ b/BEDIENUNGSANLEITUNG.md @@ -27,6 +27,9 @@ Kurzüberblick: [README](README.md). (`ffplay`/`aplay`/`paplay`) — auf den meisten Linux-Desktops vorhanden - **OpenRouter-API-Key** — nötig für Profile mit Cloud-KI (`hybrid`, `cloud`); für rein lokalen Betrieb (`local-dev`) nicht +- Für **lokales STT** (Provider `faster-whisper`): einmalig `pip install -e .[local]` + (lädt beim ersten Lauf ein Whisper-Modell). Für **lokales LLM**: ein laufender + Ollama-Server (`http://127.0.0.1:11434`) mit einem Modell (`ollama pull llama3.2`) - Optional: Docker ## 2. Installation @@ -213,6 +216,22 @@ curl -s -X POST $URL/api/sessions/oma-anna/route \ Profil global umschalten (Entwickler/Admin): `VA_PROFILE=local-dev make run`. +**Hybrid-Beispiel** (Aufnahme + STT + LLM **lokal**, nur TTS **remote**): +```bash +# einmalig: lokales STT installieren +pip install -e .[local] +# Server mit kleinem lokalem Ollama-Modell (muss in 'ollama list' stehen): +echo 'LOCAL_LLM_MODEL=llama3.2:latest' >> .env +make run +# in Terminal 2 — Sprech-Loop mit der Hybrid-Kombi (MOTU = plughw:5,0): +python scripts/voice_loop.py --recorder arecord --device plughw:5,0 --session hybrid \ + --stt-provider faster-whisper \ + --llm-provider local-openai-compatible \ + --tts-provider openrouter +``` +Erster Turn ist langsamer (Whisper- und Ollama-Modell laden), danach zügig. STT-Modell +und Gerät steuern `FASTER_WHISPER_MODEL`/`FASTER_WHISPER_DEVICE` in `.env`. + ## B2. Soundquelle & Ausgabe-Gerät wechseln (Mikrofon, Lautsprecher, Bluetooth, Handy) > **Wichtig — aktueller Stand:** Die Geräte-Endpunkte **im Gateway** diff --git a/Docs/voice-assistant-architecture.md b/Docs/voice-assistant-architecture.md index ca7c72c..b4df551 100644 --- a/Docs/voice-assistant-architecture.md +++ b/Docs/voice-assistant-architecture.md @@ -195,10 +195,14 @@ erkennung und Barge-in (`interrupt`)**; **Resilienz (Fallback-Ketten je Modul, In-Memory-Metriken `/api/metrics`), Tageskontingent pro Nutzer und heuristische Notfall-Eskalation**; automatisierte Tests. +**Echtes lokales STT:** `faster-whisper` (optionale Dependency `.[local]`, +CTranslate2) transkribiert real — damit ist ein Hybrid „STT+LLM lokal, TTS remote" +möglich (live verifiziert). + **Platzhalter (Gerüst):** Audio-Endpunkte (`local-default`, `bluetooth`, `mobile-ws`, `mobile-webrtc`) liefern leere Chunks — nur Auswahl/Lifecycle sind -verdrahtet, kein echtes Hardware-I/O. Lokale Provider `faster-whisper`, `piper`, -`chatterbox` sind Stubs. `transport_router.py` (Ebene 4) existiert, ist aber noch +verdrahtet, kein echtes Hardware-I/O. Lokale TTS-Provider `piper`, `chatterbox` +sind noch Stubs. `transport_router.py` (Ebene 4) existiert, ist aber noch nicht aktiv (lokal/remote trägt vorerst der Provider-Name). ## 9. Roadmap / bewusste nächste Schritte diff --git a/README.md b/README.md index bdbd698..4dcf103 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ Praktische Bedienung: [`BEDIENUNGSANLEITUNG.md`](BEDIENUNGSANLEITUNG.md). ## Features - **Pipeline mit getrennter Semantik/Sprache:** STT → Input-Cleaner → LLM → Spoken-Adapter → TTS-Normalizer → TTS -- **Provider austauschbar** über Registry (OpenRouter remote; faster-whisper/piper/chatterbox als lokale Stubs) +- **Provider austauschbar** über Registry (OpenRouter remote; lokales STT via faster-whisper `.[local]`; piper/chatterbox-TTS noch Stubs) - **Geschichtete Konfiguration** mit Profilen (`local-dev` / `hybrid` / `cloud`) - **Routing auf jeder Ebene:** Default → Profil → Nutzer → Session → Request - **Authentifizierung** (Bearer-Token) + persistente Nutzer/Sessions (SQLite) diff --git a/app/config.py b/app/config.py index 8e2e8d7..2a43dd9 100644 --- a/app/config.py +++ b/app/config.py @@ -120,6 +120,9 @@ class Settings(BaseSettings): local_llm_base_url: str = "http://127.0.0.1:11434/v1" local_llm_api_key: str = "dummy" local_llm_model: str = "llama3.1" + faster_whisper_model: str = "base" # tiny|base|small|medium|large-v3 + faster_whisper_device: str = "auto" # auto|cpu|cuda + faster_whisper_compute_type: str = "default" # default|int8|float16|int8_float16 db_path: str = str(BASE_DIR / "data" / "voice-assistant.db") admin_api_key: str = "" auth_enabled: bool = True diff --git a/app/providers/stt/faster_whisper.py b/app/providers/stt/faster_whisper.py index 16fd21c..ae9e157 100644 --- a/app/providers/stt/faster_whisper.py +++ b/app/providers/stt/faster_whisper.py @@ -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) diff --git a/pyproject.toml b/pyproject.toml index e1d220a..49311d0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,10 @@ dependencies = [ test = [ "pytest>=8.0" ] +# Lokale KI-Module (optional, schwergewichtig): lokales STT via faster-whisper. +local = [ + "faster-whisper>=1.0" +] [build-system] requires = ["setuptools>=68", "wheel"] diff --git a/tests/test_endpoints_e2e.py b/tests/test_endpoints_e2e.py index a8efd5b..f8c680a 100644 --- a/tests/test_endpoints_e2e.py +++ b/tests/test_endpoints_e2e.py @@ -75,14 +75,19 @@ def test_chat_per_request_override_and_loopback(monkeypatch): assert loopback_output().chunks[0].data == b"AUDIO" -def test_transcribe_local_provider(): +def test_transcribe_with_stub_stt(monkeypatch): + class StubSTT: + async def transcribe(self, audio_bytes, fmt, language=None): + return "erkannter text" + + monkeypatch.setitem(deps.STT_REGISTRY, "stub-stt", lambda s: StubSTT()) files = {"file": ("a.wav", b"RIFFdata", "audio/wav")} - data = {"stt_provider": "faster-whisper"} + data = {"stt_provider": "stub-stt"} resp = client.post("/api/transcribe", data=data, files=files) assert resp.status_code == 200 body = resp.json() - assert body["route"]["stt_provider"] == "faster-whisper" - assert body["trace"]["raw_transcript"] == "[local transcription placeholder]" + assert body["route"]["stt_provider"] == "stub-stt" + assert body["trace"]["raw_transcript"] == "erkannter text" def test_config_endpoint_exposes_no_secrets():