feat(voxtral): Speaker-ID portiert (nur Stefans Stimme) — E3a

Voxtral hatte 0 Speaker-Filter (mit Voxtral reagierte ARIA auf JEDE Stimme). Jetzt portiert aus der whisper-Bridge: speaker_id.py (ECAPA/speechbrain) kopiert, Einmal-Check auf die ersten 1.5s (fremde Stimme → leeres stt_endpoint reason=speaker_mismatch, kein Transcribe/Brain), voice_id_enroll/status/delete-RVS-Handler + voiceIdThreshold-config. voice-id-Volume gemountet, speechbrain in requirements. Ohne Enrollment fail-open.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-08-15 13:00:32 +02:00
co-authored by Claude Opus 4.8
parent e7da9cf9c4
commit 7bc3f827d0
5 changed files with 341 additions and 1 deletions
+107
View File
@@ -38,6 +38,8 @@ import numpy as np
import soundfile as sf
import websockets
import speaker_id # Speaker-ID (nur Stefans Stimme) — portiert aus der whisper-Bridge
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
@@ -59,6 +61,7 @@ STREAM_TRANSCRIBE_INTERVAL_MS = int(os.getenv("STREAM_TRANSCRIBE_INTERVAL_MS", "
STREAM_DEFAULT_ENDPOINT_MS = 2400
STREAM_DEFAULT_HARD_CAP_MS = 300000
STREAM_MIN_AUDIO_MS = 600
STREAM_SPEAKER_CHECK_MS = 1500 # ab so viel Audio einmalig Speaker-ID pruefen
STREAM_SESSION_TTL_S = 120
STREAM_ENERGY_WINDOW_MS = 300
STREAM_SEMANTIC_BACKUP_FACTOR = 2.0
@@ -165,6 +168,10 @@ class StreamSession:
noise_floor: float = 0.0
closed: bool = False
endpoint_sent: bool = False
# Speaker-ID Gating (einmalig auf die ersten ~1.5s der Aufnahme)
speaker_checked: bool = False
speaker_match: Optional[bool] = None
speaker_similarity: float = 0.0
class SessionManager:
@@ -259,6 +266,59 @@ class SessionManager:
else:
sess.noise_floor = 0.98 * nf + 0.02 * rms
async def _check_speaker(self, sess: StreamSession) -> None:
"""Einmalig: erste ~1.5s → Embedding → Vergleich mit Fingerprint.
Ohne Fingerprint fail-open (match=True). Bei Mismatch: Session beenden."""
sess.speaker_checked = True
head = bytes(sess.pcm_buffer[: STREAM_SPEAKER_CHECK_MS * 32])
if len(head) < speaker_id.MIN_SAMPLE_BYTES:
sess.speaker_match = True
return
try:
loop = asyncio.get_running_loop()
is_match, sim = await loop.run_in_executor(None, speaker_id.verify, head)
except Exception as exc:
logger.warning("Stream %s: speaker-check crashed (%s) — fail-open",
sess.request_id[:8], exc)
sess.speaker_match = True
return
sess.speaker_match = is_match
sess.speaker_similarity = sim
logger.info("Stream %s: speaker-check sim=%.2f%s (thr=%.2f)",
sess.request_id[:8], sim, "MATCH" if is_match else "REJECT",
speaker_id.DEFAULT_THRESHOLD)
if not is_match:
await self._finalize_speaker_mismatch(sess, sim)
async def _finalize_speaker_mismatch(self, sess: StreamSession, similarity: float) -> None:
"""Fremde Stimme: synthetisches leeres stt_endpoint (reason=speaker_mismatch),
Session droppen — kein Voxtral-Transcribe, kein Brain-Call."""
if sess.endpoint_sent:
return
sess.endpoint_sent = True
duration_s = self._buffer_ms(sess) / 1000.0
logger.info("Stream %s: speaker-mismatch (sim=%.2f) — DROP nach %.1fs",
sess.request_id[:8], similarity, duration_s)
if self._ws is not None:
payload = {
"requestId": sess.request_id,
"audioRequestId": sess.audio_request_id,
"text": "", "reason": "speaker_mismatch",
"durationS": duration_s, "sttMs": 0,
"voice": sess.voice, "speed": sess.speed,
"interrupted": sess.interrupted,
"speakerSimilarity": float(similarity),
}
if sess.location:
payload["location"] = sess.location
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": "speaker_mismatch",
})
self.drop(sess.request_id)
async def run_endpointer(self) -> None:
logger.info("Voxtral-Endpointer gestartet (adaptiver VAD, interval=%dms)",
STREAM_TRANSCRIBE_INTERVAL_MS)
@@ -286,6 +346,12 @@ class SessionManager:
return
if self._buffer_ms(sess) < STREAM_MIN_AUDIO_MS:
return
# Speaker-ID einmalig: ist es Stefans Stimme? Fremde → Session verwerfen
# (kein Transcribe, kein Brain-Call). Ohne Enrollment fail-open.
if not sess.speaker_checked and self._buffer_ms(sess) >= STREAM_SPEAKER_CHECK_MS:
await self._check_speaker(sess)
if sess.speaker_match is False:
return
# Adaptive akustische Sprach-Aktivitaet (M0.1). KEINE Live-Partials mehr:
# Voxtral-3B transkribiert den ganzen WACHSENDEN Buffer und braucht dafuer
# bei langen Aufnahmen 5-6 s — zu langsam fuer Live-Text, UND diese Latenz
@@ -377,6 +443,47 @@ async def run_loop(sessions: SessionManager) -> None:
sessions.feed_chunk(payload)
elif mtype == "stt_stream_end":
sessions.end_session(payload.get("requestId", ""))
elif mtype == "voice_id_status_request":
req_id = payload.get("requestId", "")
try:
status = speaker_id.status()
await _send(ws, "voice_id_status_response",
{"requestId": req_id, "ok": True, **status})
except Exception as exc:
await _send(ws, "voice_id_status_response",
{"requestId": req_id, "ok": False, "error": str(exc)[:200]})
elif mtype == "voice_id_enroll_request":
req_id = payload.get("requestId", "")
samples = payload.get("samples") or []
logger.info("voice_id_enroll_request: %d Samples (id=%s)", len(samples), req_id[:8])
try:
result = await asyncio.get_running_loop().run_in_executor(
None, speaker_id.enroll_from_samples, samples)
await _send(ws, "voice_id_enroll_response", {
"requestId": req_id, "ok": True,
"sample_count": result.get("sample_count", 0),
"rejected": result.get("rejected", []),
"updated_at": result.get("updated_at"),
"embedding_dim": result.get("embedding_dim"),
})
except Exception as exc:
logger.warning("voice_id_enroll failed: %s", exc)
await _send(ws, "voice_id_enroll_response",
{"requestId": req_id, "ok": False, "error": str(exc)[:300]})
elif mtype == "voice_id_delete_request":
req_id = payload.get("requestId", "")
removed = speaker_id.delete_fingerprint()
await _send(ws, "voice_id_delete_response",
{"requestId": req_id, "ok": True, "removed": removed})
elif mtype == "config":
if "voiceIdThreshold" in payload:
try:
t = float(payload.get("voiceIdThreshold", 0.5))
if 0.0 <= t <= 1.0:
speaker_id.DEFAULT_THRESHOLD = t
logger.info("[speaker-id] threshold gesetzt: %.2f", t)
except (TypeError, ValueError):
pass
except Exception as e:
logger.warning("RVS-Verbindung verloren: %s — retry in %ds", e, retry_s)
if use_tls and RVS_TLS_FALLBACK and not tls_fallback_tried: