diff --git a/xtts/voxtral/bridge.py b/xtts/voxtral/bridge.py index 7f7e165..9a2bf3f 100644 --- a/xtts/voxtral/bridge.py +++ b/xtts/voxtral/bridge.py @@ -112,6 +112,55 @@ def _collapse_repetitions(text: str) -> str: out = new return out.strip() + +# ── Silero VAD: echte Sprach-Erkennung VOR dem Transkribieren ────────────── +# Der Muster-Filter oben kennt nur spezifische Artefakte. Generische Phantome +# ("Ich bin ein guter Mann" aus Fast-Stille) kann ein Text-Regex nicht fangen — +# aber ein VAD schon, weil es am AUDIO entscheidet, nicht am Text. Silero trennt +# Sprache zuverlaessig von Stille / Rauschen / MUSIK. Kein Speech-Segment → +# no-speech → nicht transkribieren → kein Phantom (und Musik/Instrumental fliegt +# gleich mit raus). +# FAIL-OPEN: klappt das VAD nicht (Import/Load/Inferenz), wird trotzdem normal +# transkribiert. Die STT darf NIE komplett sterben (Speaker-ID-Lektion). +SILERO_VAD_ENABLED = os.getenv("SILERO_VAD_ENABLED", "true").lower() in ("1", "true", "yes") +SILERO_VAD_THRESHOLD = float(os.getenv("SILERO_VAD_THRESHOLD", "0.5")) +SILERO_MIN_SPEECH_MS = int(os.getenv("SILERO_MIN_SPEECH_MS", "150")) +SILERO_PAD_MS = int(os.getenv("SILERO_PAD_MS", "200")) + +_vad_state = {"model": None, "get_ts": None, "failed": False} + + +def _speech_segments(audio_f32): + """Silero-VAD-Sprachsegmente (Liste von {start,end} Sample-Indizes) im + 16kHz-float32-Audio. Rueckgabe: + [] → kein Speech (Stille/Rauschen/Musik) → Phantom-Verdacht, verwerfen. + [...] → Speech vorhanden. + None → VAD nicht verfuegbar → fail-open (Aufrufer transkribiert normal).""" + if not SILERO_VAD_ENABLED or _vad_state["failed"]: + return None + if _vad_state["model"] is None: + try: + from silero_vad import load_silero_vad, get_speech_timestamps + _vad_state["model"] = load_silero_vad() + _vad_state["get_ts"] = get_speech_timestamps + logger.info("Silero VAD geladen (threshold=%.2f, min_speech=%dms)", + SILERO_VAD_THRESHOLD, SILERO_MIN_SPEECH_MS) + except Exception: + logger.exception("Silero VAD Laden fehlgeschlagen — dauerhaft aus (fail-open)") + _vad_state["failed"] = True + return None + try: + import torch as _torch + segs = _vad_state["get_ts"]( + _torch.from_numpy(audio_f32), _vad_state["model"], + sampling_rate=16000, threshold=SILERO_VAD_THRESHOLD, + min_speech_duration_ms=SILERO_MIN_SPEECH_MS, + ) + return segs or [] + except Exception: + logger.exception("Silero VAD Inferenz fehlgeschlagen — dieser Turn fail-open") + return None + # Speaker-ID Gating global an/aus. DEFAULT AUS (fail-open) — die "nur meine Stimme"- # Pruefung ist ein BEWUSSTER Schalter, kein Automatismus: ein einziger schlechter # Enroll darf nie die ganze STT lahmlegen (genau das ist passiert). Wird per config- @@ -474,6 +523,21 @@ class SessionManager: if sess.last_voice_at > 0 and (now - sess.last_voice_at) * 1000.0 >= sess.endpoint_ms: await self._finalize(sess, "endpoint") + async def _emit_no_speech(self, sess: "StreamSession", reason_label: str) -> None: + """Leeres no-speech-Endpoint senden + Session droppen (kein Transkript). + App re-armt still, zurueck aufs Wake-Word.""" + if self._ws is not None: + payload = {"requestId": sess.request_id, + "audioRequestId": sess.audio_request_id, + "text": "", "reason": reason_label, + "durationS": 0.0, "sttMs": 0} + await _send(self._ws, "stt_endpoint", payload) + await _send(self._ws, "stt_stream_done", { + "requestId": sess.request_id, + "audioRequestId": sess.audio_request_id, + "text": "", "reason": reason_label}) + self.drop(sess.request_id) + async def _finalize(self, sess: StreamSession, reason: str) -> None: if sess.endpoint_sent: return @@ -508,6 +572,28 @@ class SessionManager: self.drop(sess.request_id) return audio = pcm_s16le_to_float32(bytes(sess.pcm_buffer)) + + # Silero VAD: ist ueberhaupt echte Sprache im Audio? Das entscheidet am + # AUDIO, nicht am Text — faengt also generische Phantome ("Ich bin ein + # guter Mann") UND Musik/Rauschen, die der Muster-Filter nicht kennt. + # Kein Speech-Segment → no-speech, gar nicht erst transkribieren. + # fail-open: segs=None (VAD nicht verfuegbar) → normal weiter. + segs = _speech_segments(audio) + if segs is not None and len(segs) == 0: + logger.info("Stream %s: Silero VAD — keine Sprache (%.1fs, reason=%s) → no-speech", + sess.request_id[:8], audio.size / 16000.0, reason) + await self._emit_no_speech(sess, f"vad_no_speech:{reason}") + return + if segs: + # Auf die Sprach-Spanne trimmen (Stille-Raender weg → Voxtral + # halluziniert an den Enden weniger). Kleiner Pad gegen abgeschnittene + # leise Wort-Anfaenge/-Enden. + pad = int(SILERO_PAD_MS / 1000.0 * 16000) + s0 = max(0, segs[0]["start"] - pad) + s1 = min(int(audio.size), segs[-1]["end"] + pad) + if s1 > s0 and (s1 - s0) < audio.size: + audio = audio[s0:s1] + t0 = time.time() try: final_text = (await self.runner.transcribe(audio, sess.language)).strip() diff --git a/xtts/voxtral/requirements.txt b/xtts/voxtral/requirements.txt index 7006b2f..ef60d8e 100644 --- a/xtts/voxtral/requirements.txt +++ b/xtts/voxtral/requirements.txt @@ -4,6 +4,7 @@ transformers>=4.54 mistral-common[audio]>=1.8.1 accelerate>=0.30 speechbrain>=1.0 # Speaker-ID (ECAPA-TDNN) — nur Stefans Stimme +silero-vad>=5.1 # neuronales VAD: echte Sprache vs Stille/Rauschen/Musik soundfile>=0.12 librosa>=0.10 # VoxtralProcessor.load_audio_as nutzt librosa zum WAV-Laden numpy>=1.24