feat(speaker-id): Gating als bewusster Schalter — Default AUS (fail-open)

Stefans Repro "nichts geht mehr" kam NICHT von den Marker/Guard-Aenderungen,
sondern von der Speaker-ID: die Bridge-Logs zeigten fast durchgehend
"stt_endpoint mit leerem Text — ignoriert (reason=speaker_mismatch)" — ein aus
einem kaputten Enroll (AAC-als-PCM) entstandener Muell-Fingerprint hat Stefans
EIGENE Stimme abgelehnt und damit die komplette STT lahmgelegt.

Fix: Speaker-ID-Gating ist jetzt ein expliziter Schalter, Default AUS. Bei aus
laeuft die Pruefung GAR NICHT (fail-open, alle Stimmen durch) — ein schlechter
Enroll kann nie wieder alles abwuergen. Damit ist Stefans Problem schon durch den
Voxtral-Rebuild geloest (kein Loeschen noetig, der Check greift einfach nicht).

- voxtral + whisper bridge: SPEAKER_ID_ENABLED (Default False, ENV VOICE_ID_ENABLED),
  _check_speaker faellt bei aus sofort fail-open zurueck; config-Broadcast
  voiceIdEnabled setzt es zur Laufzeit.
- diagnostic: Schalter "Nur meine Stimme" in der Voice-ID-Sektion (Default aus,
  Hinweis: erst an wenn enrollt), broadcastet + persistiert voiceIdEnabled.

Reihenfolge lt. Stefan: erst Konversation sauber, dann Multi-Person/nur-ich.

Deploy: docker compose up -d --build voxtral-bridge; aria-diagnostic neu starten.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-08-16 01:00:58 +02:00
co-authored by Claude Opus 4.8
parent 0e9adeee5c
commit a8ff73f93d
4 changed files with 64 additions and 1 deletions
+19 -1
View File
@@ -788,6 +788,18 @@
<div id="voice-id-status" style="font-size:13px;color:#E0E0F0;margin-bottom:10px;"> <div id="voice-id-status" style="font-size:13px;color:#E0E0F0;margin-bottom:10px;">
Status wird geladen... Status wird geladen...
</div> </div>
<div style="display:flex;align-items:center;gap:12px;margin-bottom:8px;">
<label style="color:#8888AA;font-size:12px;min-width:130px;">Nur meine Stimme:</label>
<label style="display:flex;align-items:center;gap:8px;cursor:pointer;flex:1;">
<input type="checkbox" id="diag-voice-id-enabled" onchange="sendVoiceConfig()">
<span style="color:#E0E0F0;font-size:12px;">Speaker-ID-Prüfung aktiv</span>
</label>
</div>
<div style="font-size:10px;color:#555570;margin-bottom:12px;">
AUS (Default) = alle Stimmen kommen durch (fail-open). AN = nur der enrollte
Sprecher wird ans Brain geleitet, fremde Stimmen werden verworfen. Erst
einschalten wenn ein Fingerprint eingelernt ist — sonst hört ARIA niemanden.
</div>
<div style="display:flex;align-items:center;gap:12px;margin-bottom:8px;"> <div style="display:flex;align-items:center;gap:12px;margin-bottom:8px;">
<label style="color:#8888AA;font-size:12px;min-width:130px;">Match-Threshold:</label> <label style="color:#8888AA;font-size:12px;min-width:130px;">Match-Threshold:</label>
<input type="range" id="diag-voice-id-threshold" min="0.30" max="0.70" step="0.05" value="0.50" <input type="range" id="diag-voice-id-threshold" min="0.30" max="0.70" step="0.05" value="0.50"
@@ -1899,6 +1911,11 @@
if (slider) slider.value = msg.voiceIdThreshold; if (slider) slider.value = msg.voiceIdThreshold;
if (display) display.textContent = Number(msg.voiceIdThreshold).toFixed(2); if (display) display.textContent = Number(msg.voiceIdThreshold).toFixed(2);
} }
// Speaker-ID Gating-Schalter wiederherstellen (Default aus)
{
const cb = document.getElementById('diag-voice-id-enabled');
if (cb) cb.checked = !!msg.voiceIdEnabled;
}
return; return;
} }
@@ -3600,13 +3617,14 @@
const huggingfaceToken = document.getElementById('diag-flux-hf-token')?.value; const huggingfaceToken = document.getElementById('diag-flux-hf-token')?.value;
const voiceIdThresholdRaw = document.getElementById('diag-voice-id-threshold')?.value; const voiceIdThresholdRaw = document.getElementById('diag-voice-id-threshold')?.value;
const voiceIdThreshold = voiceIdThresholdRaw ? parseFloat(voiceIdThresholdRaw) : undefined; const voiceIdThreshold = voiceIdThresholdRaw ? parseFloat(voiceIdThresholdRaw) : undefined;
const voiceIdEnabled = document.getElementById('diag-voice-id-enabled')?.checked;
send({ send({
action: 'send_voice_config', action: 'send_voice_config',
ttsEnabled, xttsVoice, whisperModel, ttsEnabled, xttsVoice, whisperModel,
f5ttsModel, f5ttsCkptFile, f5ttsVocabFile, f5ttsModel, f5ttsCkptFile, f5ttsVocabFile,
f5ttsCfgStrength, f5ttsNfeStep, f5ttsCfgStrength, f5ttsNfeStep,
fluxDefaultModel, fluxKeywordRaw, fluxKeywordSwitch, huggingfaceToken, fluxDefaultModel, fluxKeywordRaw, fluxKeywordSwitch, huggingfaceToken,
voiceIdThreshold, voiceIdThreshold, voiceIdEnabled,
}); });
const statusEl = document.getElementById('voice-status'); const statusEl = document.getElementById('voice-status');
if (statusEl && xttsVoice) { if (statusEl && xttsVoice) {
+6
View File
@@ -2681,6 +2681,12 @@ wss.on("connection", (ws) => {
const t = parseFloat(msg.voiceIdThreshold); const t = parseFloat(msg.voiceIdThreshold);
if (t >= 0.0 && t <= 1.0) voiceConfig.voiceIdThreshold = t; if (t >= 0.0 && t <= 1.0) voiceConfig.voiceIdThreshold = t;
} }
// Speaker-ID Gating an/aus ("nur meine Stimme"). Default aus (fail-open) —
// bewusster Schalter. voxtral/whisper-bridge lesen voiceIdEnabled aus dem
// config-Broadcast; aus = gar keine Pruefung.
if (msg.voiceIdEnabled !== undefined) {
voiceConfig.voiceIdEnabled = !!msg.voiceIdEnabled;
}
try { try {
fs.mkdirSync("/shared/config", { recursive: true }); fs.mkdirSync("/shared/config", { recursive: true });
fs.writeFileSync("/shared/config/voice_config.json", JSON.stringify(voiceConfig, null, 2)); fs.writeFileSync("/shared/config/voice_config.json", JSON.stringify(voiceConfig, null, 2));
+20
View File
@@ -74,6 +74,18 @@ STREAM_VOICE_RMS_MAX = 0.020
# (Voxtral halluziniert aus Fast-Nichts sonst einen Fuellsatz). 2 ≈ 400ms. # (Voxtral halluziniert aus Fast-Nichts sonst einen Fuellsatz). 2 ≈ 400ms.
STREAM_MIN_VOICED_FRAMES = int(os.getenv("STREAM_MIN_VOICED_FRAMES", "2")) STREAM_MIN_VOICED_FRAMES = int(os.getenv("STREAM_MIN_VOICED_FRAMES", "2"))
# 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-
# Broadcast (voiceIdEnabled, aus dem Diagnostic) zur Laufzeit gesetzt. Kann per ENV
# vorbelegt werden.
SPEAKER_ID_ENABLED = os.getenv("VOICE_ID_ENABLED", "false").lower() in ("1", "true", "yes")
def _set_speaker_id_enabled(val: bool) -> None:
global SPEAKER_ID_ENABLED
SPEAKER_ID_ENABLED = bool(val)
def pcm_s16le_to_float32(data: bytes) -> np.ndarray: def pcm_s16le_to_float32(data: bytes) -> np.ndarray:
if not data: if not data:
@@ -282,6 +294,10 @@ class SessionManager:
"""Einmalig: erste ~1.5s → Embedding → Vergleich mit Fingerprint. """Einmalig: erste ~1.5s → Embedding → Vergleich mit Fingerprint.
Ohne Fingerprint fail-open (match=True). Bei Mismatch: Session beenden.""" Ohne Fingerprint fail-open (match=True). Bei Mismatch: Session beenden."""
sess.speaker_checked = True sess.speaker_checked = True
# Schalter aus (Default) → gar keine Pruefung, alles durchlassen.
if not SPEAKER_ID_ENABLED:
sess.speaker_match = True
return
head = bytes(sess.pcm_buffer[: STREAM_SPEAKER_CHECK_MS * 32]) head = bytes(sess.pcm_buffer[: STREAM_SPEAKER_CHECK_MS * 32])
if len(head) < speaker_id.MIN_SAMPLE_BYTES: if len(head) < speaker_id.MIN_SAMPLE_BYTES:
sess.speaker_match = True sess.speaker_match = True
@@ -535,6 +551,10 @@ async def run_loop(sessions: SessionManager) -> None:
logger.info("[speaker-id] threshold gesetzt: %.2f", t) logger.info("[speaker-id] threshold gesetzt: %.2f", t)
except (TypeError, ValueError): except (TypeError, ValueError):
pass pass
if "voiceIdEnabled" in payload:
_set_speaker_id_enabled(payload.get("voiceIdEnabled"))
logger.info("[speaker-id] Gating %s (voiceIdEnabled)",
"AN" if SPEAKER_ID_ENABLED else "AUS")
except Exception as e: except Exception as e:
logger.warning("RVS-Verbindung verloren: %s — retry in %ds", e, retry_s) logger.warning("RVS-Verbindung verloren: %s — retry in %ds", e, retry_s)
if use_tls and RVS_TLS_FALLBACK and not tls_fallback_tried: if use_tls and RVS_TLS_FALLBACK and not tls_fallback_tried:
+19
View File
@@ -86,6 +86,16 @@ STREAM_VOICE_FACTOR = 2.5 # Sprache = noise_floor * Faktor
STREAM_VOICE_RMS_MIN = 0.005 # Untergrenze (stiller Raum: nicht auf 0 kollabieren) STREAM_VOICE_RMS_MIN = 0.005 # Untergrenze (stiller Raum: nicht auf 0 kollabieren)
STREAM_VOICE_RMS_MAX = 0.020 # Obergrenze (lautes Auto: Sprache nie ganz aussperren) STREAM_VOICE_RMS_MAX = 0.020 # Obergrenze (lautes Auto: Sprache nie ganz aussperren)
STREAM_VOICE_RMS_THRESHOLD = 0.012 # Legacy-Konstante (nicht mehr im Cut-Pfad genutzt) STREAM_VOICE_RMS_THRESHOLD = 0.012 # Legacy-Konstante (nicht mehr im Cut-Pfad genutzt)
# Speaker-ID Gating global an/aus. DEFAULT AUS (fail-open) — bewusster Schalter
# ("nur meine Stimme"), kein Automatismus: ein schlechter Enroll darf nie die STT
# lahmlegen. Wird per config-Broadcast (voiceIdEnabled) zur Laufzeit gesetzt.
SPEAKER_ID_ENABLED = os.getenv("VOICE_ID_ENABLED", "false").lower() in ("1", "true", "yes")
def _set_speaker_id_enabled(val: bool) -> None:
global SPEAKER_ID_ENABLED
SPEAKER_ID_ENABLED = bool(val)
# Rein-semantischer Backstop: wenn die Energie NIE faellt (laute Umgebung, # Rein-semantischer Backstop: wenn die Energie NIE faellt (laute Umgebung,
# z.B. Auto), endpointen wir trotzdem — aber erst nach diesem Faktor x # z.B. Auto), endpointen wir trotzdem — aber erst nach diesem Faktor x
# endpoint_ms, damit normales Sprechen mit Pausen nicht abgeschnitten wird. # endpoint_ms, damit normales Sprechen mit Pausen nicht abgeschnitten wird.
@@ -467,6 +477,11 @@ class SessionManager:
Ohne Fingerprint → fail-open (match=True). Bei mismatch wird die Ohne Fingerprint → fail-open (match=True). Bei mismatch wird die
Session sofort beendet mit synthetischem stt_endpoint.""" Session sofort beendet mit synthetischem stt_endpoint."""
sess.speaker_checked = True sess.speaker_checked = True
# Schalter aus (Default) → gar keine Pruefung, alles durchlassen.
if not SPEAKER_ID_ENABLED:
sess.speaker_match = True
sess.speaker_similarity = 0.0
return
# Erste ~1.5s aus dem Buffer entnehmen (16kHz * 2 byte/sample = 32 bytes/ms) # Erste ~1.5s aus dem Buffer entnehmen (16kHz * 2 byte/sample = 32 bytes/ms)
head_bytes = bytes(sess.pcm_buffer[: STREAM_SPEAKER_CHECK_MS * 32]) head_bytes = bytes(sess.pcm_buffer[: STREAM_SPEAKER_CHECK_MS * 32])
if len(head_bytes) < speaker_id.MIN_SAMPLE_BYTES: if len(head_bytes) < speaker_id.MIN_SAMPLE_BYTES:
@@ -975,6 +990,10 @@ async def run_loop(runner: WhisperRunner, sessions: SessionManager) -> None:
logger.info("[speaker-id] threshold gesetzt: %.2f", t) logger.info("[speaker-id] threshold gesetzt: %.2f", t)
except (TypeError, ValueError): except (TypeError, ValueError):
pass pass
if "voiceIdEnabled" in payload:
_set_speaker_id_enabled(payload.get("voiceIdEnabled"))
logger.info("[speaker-id] Gating %s (voiceIdEnabled)",
"AN" if SPEAKER_ID_ENABLED else "AUS")
if "whisperDebugLog" in payload: if "whisperDebugLog" in payload:
global _DEBUG_LOG_TO_BRIDGE global _DEBUG_LOG_TO_BRIDGE
old = _DEBUG_LOG_TO_BRIDGE old = _DEBUG_LOG_TO_BRIDGE