|
|
|
@@ -94,6 +94,24 @@ _HALLUCINATION_RE = re.compile(
|
|
|
|
|
re.IGNORECASE,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Kollabiert unmittelbar wiederholte Phrasen (Voxtral-Repetition-Loop) auf EINE
|
|
|
|
|
# Kopie. Zweites Netz hinter no_repeat_ngram in der Generation. Phrase 5-80 Zeichen,
|
|
|
|
|
# 3+ mal hintereinander → eine. Kurze legitime Doppelungen ('ja ja', 'sehr sehr')
|
|
|
|
|
# bleiben (Unit < 5 Zeichen bzw. < 3 Wiederholungen).
|
|
|
|
|
_REPEAT_RE = re.compile(r"(.{5,80}?)(?:\s*\1){2,}", re.IGNORECASE | re.DOTALL)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _collapse_repetitions(text: str) -> str:
|
|
|
|
|
if not text:
|
|
|
|
|
return text
|
|
|
|
|
out = text
|
|
|
|
|
for _ in range(3): # mehrfach fuer verschachtelte/ungleiche Loops
|
|
|
|
|
new = _REPEAT_RE.sub(r"\1", out)
|
|
|
|
|
if new == out:
|
|
|
|
|
break
|
|
|
|
|
out = new
|
|
|
|
|
return out.strip()
|
|
|
|
|
|
|
|
|
|
# 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-
|
|
|
|
@@ -162,7 +180,20 @@ class VoxtralRunner:
|
|
|
|
|
with torch.no_grad():
|
|
|
|
|
# hoch genug fuer lange Diktate (stoppt eh am EOS); 512 hat
|
|
|
|
|
# mehrminutige Aufnahmen abgeschnitten.
|
|
|
|
|
outputs = model.generate(**inputs, max_new_tokens=4096)
|
|
|
|
|
# Repetition-Bremse: Voxtral kippt bei Stille/Rauschen am Ende
|
|
|
|
|
# gern in eine Schleife und wiederholt einen Satz zig-mal
|
|
|
|
|
# ("Vergiss das, das ist nur... Vergiss das, das ist nur..."
|
|
|
|
|
# x15). no_repeat_ngram_size=4 laesst die ERSTE echte Nennung
|
|
|
|
|
# durch, verbietet aber die exakte 4-Gramm-Wiederholung → Loop
|
|
|
|
|
# bricht ab; repetition_penalty daempft zusaetzlich. Beides mild,
|
|
|
|
|
# damit normale Sprache (auch mal ein doppeltes Wort) unberuehrt
|
|
|
|
|
# bleibt.
|
|
|
|
|
outputs = model.generate(
|
|
|
|
|
**inputs,
|
|
|
|
|
max_new_tokens=4096,
|
|
|
|
|
no_repeat_ngram_size=4,
|
|
|
|
|
repetition_penalty=1.15,
|
|
|
|
|
)
|
|
|
|
|
trimmed = outputs[:, inputs.input_ids.shape[1]:]
|
|
|
|
|
text = proc.batch_decode(trimmed, skip_special_tokens=True)
|
|
|
|
|
return (text[0] if text else "").strip()
|
|
|
|
@@ -428,6 +459,17 @@ class SessionManager:
|
|
|
|
|
})
|
|
|
|
|
else:
|
|
|
|
|
self._update_noise_floor(sess, rms)
|
|
|
|
|
# No-Speech-Timeout: wurde die GANZE Zeit KEINE Stimme erkannt
|
|
|
|
|
# (last_voice_at==0), feuert der normale Endpoint unten NIE — der braucht
|
|
|
|
|
# last_voice_at>0. Ohne das bleibt ein reines Stille-Fenster offen bis
|
|
|
|
|
# Hardcap/manuellem Stop → genau Stefans Repro: "die Stille-Ende wird nie
|
|
|
|
|
# erreicht, stop ich selbst ist es weg". Nach endpoint_ms Stille ab Start
|
|
|
|
|
# schliessen wir das Fenster selbst als no-speech (leer, lautlos, zurueck
|
|
|
|
|
# aufs Wake-Word). voiced_frames==0 → _finalize verwirft ohne Transkript,
|
|
|
|
|
# also KEIN Phantom.
|
|
|
|
|
if sess.last_voice_at == 0 and (now - sess.started_at) * 1000.0 >= sess.endpoint_ms:
|
|
|
|
|
await self._finalize(sess, "no_speech")
|
|
|
|
|
return
|
|
|
|
|
# Endpoint: hat der User schon gesprochen UND ist es seit endpoint_ms still?
|
|
|
|
|
if sess.last_voice_at > 0 and (now - sess.last_voice_at) * 1000.0 >= sess.endpoint_ms:
|
|
|
|
|
await self._finalize(sess, "endpoint")
|
|
|
|
@@ -438,14 +480,21 @@ class SessionManager:
|
|
|
|
|
sess.endpoint_sent = True
|
|
|
|
|
# Halluzinations-Guard: zu wenig echte Stimme (Stille / kurzer Blip im
|
|
|
|
|
# Passiv-/Wake-Fenster) → NICHT transkribieren. Voxtral (wie Whisper) baut
|
|
|
|
|
# aus Fast-Nichts gern einen Fuellsatz ("keine Ahnung" o.ae.), der dann als
|
|
|
|
|
# PHANTOM-Nachricht ans Brain geht und das Gespraech entgleisen laesst
|
|
|
|
|
# (Stefans Repro: "kam Nachricht von mir, obwohl ich nichts sagte"). Leeres
|
|
|
|
|
# Endpoint = no-speech → App re-armt still. Der manuelle Stop (stream_end)
|
|
|
|
|
# ist ausgenommen: dort hat der User bewusst gesprochen (kurze Woerter ok).
|
|
|
|
|
if reason != "stream_end" and sess.voiced_frames < STREAM_MIN_VOICED_FRAMES:
|
|
|
|
|
# aus Fast-Nichts gern einen Fuellsatz ("Die Stadt hat eine Flaeche von
|
|
|
|
|
# 1,5 km2"), der dann als PHANTOM-Nachricht ans Brain geht und das Gespraech
|
|
|
|
|
# entgleisen laesst (Stefans Repro: "kam Nachricht von mir, obwohl ich
|
|
|
|
|
# nichts sagte"). Leeres Endpoint = no-speech → App re-armt still.
|
|
|
|
|
#
|
|
|
|
|
# WICHTIG (aus dem ai-box-Log gelernt): die Phantome kommen mit
|
|
|
|
|
# reason=stream_end — Passiv-/Wake-Fenster enden AUCH per stream_end, wenn
|
|
|
|
|
# sie auf Stille zumachen. stream_end ist also NICHT gleich "manueller Stop".
|
|
|
|
|
# Deshalb greift der Guard jetzt auch bei stream_end, aber mit niedrigerer
|
|
|
|
|
# Schwelle (voiced==0 = gar keine Stimme), damit ein kurzes bewusstes Wort
|
|
|
|
|
# ('ja', 'stopp') am Aufnahme-Button noch durchgeht, echte Stille aber nicht.
|
|
|
|
|
_min_voiced = STREAM_MIN_VOICED_FRAMES if reason != "stream_end" else 1
|
|
|
|
|
if sess.voiced_frames < _min_voiced:
|
|
|
|
|
logger.info("Stream %s: no-speech (voiced_frames=%d<%d, reason=%s) — leeres Endpoint",
|
|
|
|
|
sess.request_id[:8], sess.voiced_frames, STREAM_MIN_VOICED_FRAMES, reason)
|
|
|
|
|
sess.request_id[:8], sess.voiced_frames, _min_voiced, reason)
|
|
|
|
|
if self._ws is not None:
|
|
|
|
|
nospeech = {"requestId": sess.request_id,
|
|
|
|
|
"audioRequestId": sess.audio_request_id,
|
|
|
|
@@ -467,17 +516,27 @@ class SessionManager:
|
|
|
|
|
final_text = sess.last_partial
|
|
|
|
|
stt_ms = int((time.time() - t0) * 1000)
|
|
|
|
|
duration_s = audio.size / 16000.0
|
|
|
|
|
# Repetition-Loop einkassieren, falls trotz no_repeat_ngram was durchkam.
|
|
|
|
|
_collapsed = _collapse_repetitions(final_text)
|
|
|
|
|
if _collapsed != final_text:
|
|
|
|
|
logger.info("Stream %s: Repetition-Loop kollabiert (%d→%d Zeichen)",
|
|
|
|
|
sess.request_id[:8], len(final_text), len(_collapsed))
|
|
|
|
|
final_text = _collapsed
|
|
|
|
|
logger.info("Stream %s: FINAL (reason=%s, %.1fs, %dms): %r",
|
|
|
|
|
sess.request_id[:8], reason, duration_s, stt_ms, final_text[:120])
|
|
|
|
|
|
|
|
|
|
# Halluzinations-Filter (2. Netz): leeres/Artefakt-Transkript im borderline-
|
|
|
|
|
# Band → als no-speech verwerfen statt ein Phantom ("Die Stadt hat eine
|
|
|
|
|
# Flaeche von 1,5 km2") ans Brain zu schicken. Manueller Stop (stream_end)
|
|
|
|
|
# ist ausgenommen (bewusst gesprochen, auch kurze Woerter zaehlen).
|
|
|
|
|
# Flaeche von 1,5 km2") ans Brain zu schicken. Gilt fuer ALLE reasons inkl.
|
|
|
|
|
# stream_end (dort kamen die realen Phantome!) — aber das borderline-Band
|
|
|
|
|
# (wenig voiced_frames) schuetzt echte, klar gesprochene Eingaben: eine echte
|
|
|
|
|
# Geografie-FRAGE hat normale Stimm-Energie (voiced_frames >> Schwelle) und
|
|
|
|
|
# geht durch; das Phantom aus Stille hat ~0 und wird verworfen. Ein leeres
|
|
|
|
|
# Transkript wird immer verworfen (nichts gesagt = nichts senden).
|
|
|
|
|
_clean = final_text.strip(" .,!?…-\t\n\r")
|
|
|
|
|
_borderline = sess.voiced_frames < STREAM_HALLUC_GUARD_FRAMES
|
|
|
|
|
_is_phantom = (not _clean) or (_borderline and bool(_HALLUCINATION_RE.search(final_text)))
|
|
|
|
|
if reason != "stream_end" and _is_phantom:
|
|
|
|
|
if _is_phantom:
|
|
|
|
|
logger.info("Stream %s: Halluzination verworfen (voiced_frames=%d<%d, %.1fs, text=%r)",
|
|
|
|
|
sess.request_id[:8], sess.voiced_frames, STREAM_HALLUC_GUARD_FRAMES,
|
|
|
|
|
duration_s, final_text[:80])
|
|
|
|
|