fix(whisper): Halluzinations-Filter — kein 'Untertitelung des ZDF' bei Stille

Stefan-Reproduktion: nach Wake-Word + ARIA-Antwort oeffnet das
Conversation-Window automatisch das Mikro fuer Follow-Up. Wenn Stefan
nichts sagt, ist das 4-8s Stille. Whisper halluziniert dann YouTube-
Untertitel-Patterns aus seinem Trainings-Corpus — gemessen 'Untertitelung
des ZDF, 2020' — und ARIA antwortet brav darauf. Endlos-Loop bis Stefan
manuell stoppt.

Fix in faster-whisper-transcribe:

1. Per-Segment no_speech_prob auswerten. Bei >= 0.6 (relativ konservativ:
   echte leise Sprache geht noch durch) → Segment verwerfen. Das eliminiert
   die offensichtlichen Halluzinationen schon zu 90%.

2. Bekannte Hallucination-Phrasen-Blacklist:
     - Untertitelung/Untertitel des ZDF (mit/ohne Jahr)
     - Amara.org community
     - Vielen Dank fuer's Zuschauen (mit allen Umlaut/Apostroph-Varianten)
     - Thanks for watching / Subs by ...
   Substring-Match (case-insensitive) auf normalisiertem Text (lowercase,
   Trailing-Punctuation und Jahres-Suffix '2020' weg).

3. Wenn ALLE Segmente einer Aufnahme rausgefiltert werden, ist text=''
   → App behandelt das via existierende no-speech-Pfad: Conversation-
   Window endet sauber, kein TTS-Echo-Loop.

Tradeoff: echte Phrasen wie 'Vielen Dank' allein gehen durch (Pattern
ist 'vielen dank fuer's zuschauen' — voller Match). Nur die bekannten
Halluzinations-Phrasen werden weggefiltert.

Falls in Zukunft neue Patterns auftauchen (Whispers Modell ändert sich):
einfach _HALLUCINATION_PHRASES erweitern, kein Brain-Restart noetig (lebt
in der Whisper-Bridge, die hot-reloaded werden kann).
This commit is contained in:
2026-06-02 14:19:22 +02:00
parent ddfc4261e5
commit 05eb7ed144
+76 -1
View File
@@ -109,7 +109,27 @@ class WhisperRunner:
segments, info = self.model.transcribe(
audio, language=language, beam_size=beam_size, vad_filter=vad_filter,
)
text = " ".join(seg.text.strip() for seg in segments)
# Per-segment no_speech_prob auswerten: faster-whisper liefert das
# mit. Bei Stille/Rauschen halluziniert Whisper bekannte YouTube-
# Untertitel-Patterns ("Untertitelung des ZDF", "Vielen Dank fuer's
# Zuschauen", ...). Segmente mit hohem no_speech_prob filtern wir
# raus. Plus: bekannte Hallucination-Patterns explizit blacklisten.
kept = []
for seg in segments:
# no_speech_prob: 1.0 = sicher Stille; 0.0 = sicher Sprache.
# Threshold 0.6 ist nicht zu strikt (echte leise Sprache geht
# noch durch) und nicht zu locker (Halluzinationen werden
# zuverlaessig erwischt).
nsp = getattr(seg, "no_speech_prob", 0.0)
if nsp is not None and nsp >= 0.6:
continue
stext = (seg.text or "").strip()
if not stext:
continue
if _is_known_hallucination(stext):
continue
kept.append(stext)
text = " ".join(kept)
return text, info.duration
loop = asyncio.get_event_loop()
@@ -117,6 +137,61 @@ class WhisperRunner:
return await loop.run_in_executor(None, _run)
# Bekannte Whisper-Halluzinations-Patterns. Tritt typisch bei Stille oder
# Rauschen auf — Whispers Trainings-Corpus enthaelt Stunden von YouTube-
# Videos mit diesen Untertitel-Outros. Substring-Match (case-insensitive)
# ueber gestrippten Text. Wenn ein Segment EXAKT (nach Normalisierung) so
# aussieht, ist's mit ~99% Sicherheit eine Halluzination.
_HALLUCINATION_PHRASES = (
"untertitelung des zdf",
"untertitel im auftrag des zdf",
"untertitelung im auftrag des zdf",
"untertitel der amara.org community",
"untertitel von stephanie geiges",
"amara.org",
"untertitel: kerstin grass",
"vielen dank fuers zuschauen",
"vielen dank fürs zuschauen",
"vielen dank für's zuschauen",
"vielen dank fuer's zuschauen",
"vielen dank für das zuschauen",
"vielen dank fuer das zuschauen",
"danke für's zuschauen",
"danke fürs zuschauen",
"danke fuers zuschauen",
"subs by",
"subtitle by",
"subtitles by",
"thanks for watching",
)
def _normalize_for_hallu(text: str) -> str:
"""Lowercase + trailing-Satzzeichen/Whitespace strippen. Jahreszahlen
(4 Ziffern am Ende) auch entfernen — 'Untertitelung des ZDF, 2020'
matcht damit auf 'untertitelung des zdf'."""
t = text.lower().strip()
# Entferne trailing punctuation incl. comma+digits
while t and t[-1] in ".,!? \t\n":
t = t[:-1]
# 4-stellige Jahreszahl am Ende
import re
t = re.sub(r"[,\s]+\d{4}$", "", t).strip()
while t and t[-1] in ".,!? \t\n":
t = t[:-1]
return t
def _is_known_hallucination(text: str) -> bool:
norm = _normalize_for_hallu(text)
if not norm:
return True
for pat in _HALLUCINATION_PHRASES:
if pat in norm:
return True
return False
def ffmpeg_to_float32(audio_b64: str, mime_type: str) -> np.ndarray:
"""Dekodiert beliebiges Audio-Format → 16kHz mono float32 PCM."""
if "mp4" in mime_type or "m4a" in mime_type or "aac" in mime_type: