feat(wake): Voxtral-Bestätigungsstufe gegen Musik-Fehltrigger

openWakeWord triggert oft auf Musik (Pet Shop Boys "West End Girls" & Co.). Neu:
nach dem Trigger prüft Voxtral den Vor-Trigger-Audio ("war das wirklich das
Wake-Wort oder nur Musik?") — erst bei Bestätigung Gong + Mikro, sonst still
verworfen + re-arm. Kostet ~0,5-1s vor dem Gong.

- Native (OpenWakeWordModule.kt): 2s Roh-PCM-Ringpuffer; bei Erkennung wird der
  1,5s-Vor-Trigger-Schnipsel base64 (s16le 16kHz) ans WakeWordDetected-Event
  gehängt (preTriggerPcm).
- Bridge (voxtral): neuer One-Shot-Handler stt_transcribe_blob → Silero + Voxtral
  → stt_transcribe_result. Musik/Rauschen → leerer Text.
- App: audio.transcribeBlob() schickt den Schnipsel, wakeword.confirmWake() prüft
  ob das distinktive Keyword (>=4 Zeichen) im Transkript steht. Gate sitzt in
  onWakeDetected VOR Gong/Dialog. Setting "Wake-Wort per Voxtral bestätigen"
  (default aus, opt-in).

FAIL-OPEN durchgängig: kein preTriggerPcm (altes APK) / Timeout / Fehler / VAD
weg → Wake läuft normal durch. Nie schlechter als heute. NATIVER TEIL UNGETESTET
(kein Gerät hier) — braucht Build + Test auf dem Handy.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-08-16 21:37:15 +02:00
co-authored by Claude Opus 4.8
parent a3650bb0e4
commit 49ea172617
5 changed files with 222 additions and 6 deletions
+24
View File
@@ -700,6 +700,30 @@ async def run_loop(sessions: SessionManager) -> None:
sessions.feed_chunk(payload)
elif mtype == "stt_stream_end":
sessions.end_session(payload.get("requestId", ""))
elif mtype == "stt_transcribe_blob":
# One-Shot-Transkription eines PCM-Schnipsels (kein Live-
# Stream) — fuer die Wake-Wort-Bestaetigung: die App schickt
# den Vor-Trigger-Audio, wir sagen was gesagt wurde, die App
# prueft ob "Computer" drin ist. Silero vorgeschaltet:
# Musik/Rauschen → leerer Text (nicht bestaetigt).
req_id = payload.get("requestId", "")
try:
pcm = base64.b64decode(payload.get("pcm", ""))
audio = pcm_s16le_to_float32(pcm)
segs = _speech_segments(audio)
if segs is not None and len(segs) == 0:
text = ""
else:
text = (await sessions.runner.transcribe(
audio, payload.get("language", "de"))).strip()
logger.info("stt_transcribe_blob (%.1fs) → %r",
audio.size / 16000.0, text[:60])
await _send(ws, "stt_transcribe_result",
{"requestId": req_id, "text": text})
except Exception as exc:
logger.warning("stt_transcribe_blob fehlgeschlagen: %s", exc)
await _send(ws, "stt_transcribe_result",
{"requestId": req_id, "text": "", "error": str(exc)[:200]})
elif mtype == "voice_id_status_request":
req_id = payload.get("requestId", "")
try: