feat(stt): bessere Spracherkennung — small-Modell, VAD, feste Sprache

Gegen "Blödsinn", v. a. an Handys:
1) Modell-Default base -> small + compute_type int8 (Benchmark auf der VM:
   base patzt schon bei sauberem Audio "an Dienstag und 10 Uhr", small korrekt
   "am Dienstag um 10 Uhr"; ~4 s/6-s-Clip, gecacht).
2) faster_whisper.transcribe mit vad_filter=True (+min_silence 500ms) und
   condition_on_previous_text=False -> weniger Stille-/Kontext-Halluzinationen.
3) stt_force_language (Default True): WS-Pfad gibt die feste Zielsprache vor
   statt Auto-Detect -> robust bei kurzen/verrauschten Clips. Auto-Detect +
   Fremdsprachen-Übersetzung jetzt opt-in (stt_force_language=False).

Tests: Config-Defaults + VAD/Language-Durchreichung; test_ws-Übersetzung auf
opt-in umgestellt. Suite 253 grün.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-06-25 19:36:17 +02:00
commit f30b9b56c7
5 changed files with 61 additions and 12 deletions

View file

@ -212,15 +212,21 @@ async def _voice_turn(websocket, store, user, session_id, audio, fmt, options):
return
try:
# Gesprochene Sprache automatisch erkennen (egal welche). Weicht sie von der
# Zielsprache ab, wird die Anfrage in die Zielsprache übersetzt — so erscheint
# und verarbeitet das System sie immer in der eingestellten Sprache.
transcript, detected = await orchestrator.stt.transcribe_detect(
audio, fmt=fmt, language=None
)
if (transcript and transcript.strip() and detected
and detected.lower() != (route.language or "").lower()):
transcript = await orchestrator.translate(transcript, route.language)
if settings.stt_force_language:
# Feste Zielsprache statt Auto-Detect -> robuster bei kurzen/verrauschten
# (Handy-)Clips; verhindert "falsche Sprache erkannt = Blödsinn".
transcript, _ = await orchestrator.stt.transcribe_detect(
audio, fmt=fmt, language=route.language
)
else:
# Gesprochene Sprache automatisch erkennen; weicht sie von der Zielsprache
# ab, wird in die Zielsprache übersetzt (Fremdsprachen-Diktat).
transcript, detected = await orchestrator.stt.transcribe_detect(
audio, fmt=fmt, language=None
)
if (transcript and transcript.strip() and detected
and detected.lower() != (route.language or "").lower()):
transcript = await orchestrator.translate(transcript, route.language)
effective_voice = voice_for_route(route.tts_provider, route.language, route.voice_gender)
except Exception as exc:
await websocket.send_json({"type": "error", "status": 502, "detail": str(exc)})

View file

@ -131,9 +131,10 @@ class Settings(BaseSettings):
local_llm_max_tokens: int = 0 # 0 = serverseitiges Limit (-n)
local_llm_temperature: float = 0.3
local_llm_top_p: float = 0.9 # Nucleus-Sampling (0.01.0)
faster_whisper_model: str = "base" # tiny|base|small|medium|large-v3
faster_whisper_model: str = "small" # 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
faster_whisper_compute_type: str = "int8" # default|int8|float16|int8_float16
stt_force_language: bool = True # STT mit fester Zielsprache (robust); False = Auto-Detect + Übersetzung
# --- Lokales TTS (piper) -------------------------------------------------
piper_bin: str = "piper" # Pfad/Name des piper-Binaries
piper_voices_dir: str = str(Path.home() / ".local" / "share" / "piper" / "voices")

View file

@ -42,7 +42,13 @@ class FasterWhisperProvider(STTProvider):
self, audio_bytes: bytes, language: str | None
) -> tuple[str, str | None]:
model = _load_model(self.model_size, self.device, self.compute_type)
segments, info = model.transcribe(io.BytesIO(audio_bytes), language=language)
segments, info = model.transcribe(
io.BytesIO(audio_bytes),
language=language,
vad_filter=True, # Stille/Rauschen entfernen -> weniger Halluzinationen
vad_parameters={"min_silence_duration_ms": 500},
condition_on_previous_text=False, # kurze Befehle: kein Kontext-Drift
)
text = "".join(segment.text for segment in segments).strip()
detected = getattr(info, "language", None)
return text, detected

34
tests/test_stt_quality.py Normal file
View file

@ -0,0 +1,34 @@
import app.providers.stt.faster_whisper as fw
from app.config import settings
def test_stt_config_defaults():
assert settings.faster_whisper_model == "small"
assert settings.faster_whisper_compute_type == "int8"
assert settings.stt_force_language is True
class _Seg:
def __init__(self, text):
self.text = text
class _Info:
language = "de"
def test_transcribe_passes_vad_and_language(monkeypatch):
captured = {}
class _FakeModel:
def transcribe(self, audio, **kw):
captured.update(kw)
return [_Seg("hallo welt")], _Info()
monkeypatch.setattr(fw, "_load_model", lambda *a, **k: _FakeModel())
prov = fw.FasterWhisperProvider(model_size="small", device="cpu", compute_type="int8")
text, detected = prov._transcribe_sync(b"AAAA", "de")
assert text == "hallo welt" and detected == "de"
assert captured["language"] == "de"
assert captured["vad_filter"] is True
assert captured["condition_on_previous_text"] is False

View file

@ -179,6 +179,8 @@ def test_ws_voice_translates_foreign_language_to_target(monkeypatch):
monkeypatch.setitem(deps.STT_REGISTRY, "ss", lambda s: StubSTT())
monkeypatch.setitem(deps.LLM_REGISTRY, "ll", lambda s: StubLLM())
monkeypatch.setitem(deps.TTS_REGISTRY, "tt", lambda s: StubTTS())
# Übersetzungs-Pfad ist opt-in: feste Sprache abschalten -> Auto-Detect + Übersetzung.
monkeypatch.setattr("app.config.settings.stt_force_language", False)
opts = {"stt_provider": "ss", "llm_provider": "ll", "tts_provider": "tt",
"output_endpoint": "loopback", "language": "de"}
with client.websocket_connect("/ws/voice") as ws: