feat(voxtral): Silero VAD — echte Sprach-Erkennung gegen generische Phantome + Musik
Der Muster-Filter fängt nur spezifische Artefakte; generische Silence-Phantome
("Ich bin ein guter Mann", "Die Serie wurde in 13 Episoden gedreht") kann ein
Text-Regex prinzipiell nicht vorhersehen. Lösung an der Wurzel: Silero VAD
(neuronal, ~2MB) entscheidet am AUDIO, ob überhaupt echte Sprache drin ist.
Im _finalize vor dem Transkribieren: kein Speech-Segment → no-speech, gar nicht
erst transkribieren → kein Phantom. Trennt zuverlässig Sprache von Stille /
Rauschen / MUSIK (löst damit auch den Musik-False-Positive). Bei Speech wird auf
die Sprach-Spanne getrimmt (Stille-Ränder weg → weniger Edge-Halluzination).
FAIL-OPEN: Import/Load/Inferenz-Fehler → normal transkribieren (die STT darf nie
komplett sterben — Speaker-ID-Lektion). Alles env-tunebar: SILERO_VAD_ENABLED,
_THRESHOLD (0.5), _MIN_SPEECH_MS (150), _PAD_MS (200). Modell im pip-Paket
gebündelt, kein Runtime-Download.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user