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
@@ -60,6 +60,11 @@ class OpenWakeWordModule(reactContext: ReactApplicationContext) : ReactContextBa
// weil der False-Positive die AudioFocus-Switch-Logik anwirft (Stefan-Bug 06/2026).
// Loesung: in dieser Phase keine Detections an JS weiterleiten.
private const val STARTUP_SUPPRESSION_MS = 600L
// PCM-Ringpuffer fuer die Wake-Wort-Bestaetigung: letzte 2s roh (16kHz
// mono s16). Bei einer Erkennung wird der Vor-Trigger-Schnipsel an JS
// gereicht und dort von Voxtral verifiziert (gegen Musik-Fehltrigger).
private const val PCM_RING_SAMPLES = 32000 // 2.0s @ 16kHz
private const val PRE_TRIGGER_SAMPLES = 24000 // 1.5s Schnipsel an JS
}
private val env: OrtEnvironment = OrtEnvironment.getEnvironment()
@@ -106,6 +111,13 @@ class OpenWakeWordModule(reactContext: ReactApplicationContext) : ReactContextBa
private val embBuffer: ArrayDeque<FloatArray> = ArrayDeque(32) // Ringpuffer letzter Embeddings
private var consecutiveAboveThreshold: Int = 0
private var lastDetectionMs: Long = 0L
// Roh-PCM-Ringpuffer (letzte ~2s) fuer die Wake-Wort-Bestaetigung. Bei einer
// Erkennung wird der Vor-Trigger-Schnipsel base64-kodiert an JS gereicht und
// dort von Voxtral verifiziert ("war das wirklich 'Computer' oder Musik?").
private val pcmRing = ShortArray(PCM_RING_SAMPLES)
private var pcmRingPos = 0
private var pcmRingFilled = false
private val pcmRingLock = Any()
// Zeitpunkt des letzten startRecording — fuer STARTUP_SUPPRESSION_MS-Fenster
private var recordingStartedMs: Long = 0L
@@ -430,6 +442,36 @@ class OpenWakeWordModule(reactContext: ReactApplicationContext) : ReactContextBa
embBuffer.clear()
consecutiveAboveThreshold = 0
lastDetectionMs = 0L
// PCM-Ring frisch: sonst koennte Alt-Audio aus dem vorigen Arm-Zyklus
// in den Bestaetigungs-Schnipsel bluten.
synchronized(pcmRingLock) { pcmRingPos = 0; pcmRingFilled = false }
}
/** Letzte ~1.5s Roh-PCM aus dem Ringpuffer als Base64 (s16le, 16kHz mono),
* fuer die Voxtral-Wake-Bestaetigung. null wenn noch zu wenig Audio da ist
* oder das Kodieren scheitert (dann macht JS fail-open weiter wie bisher). */
private fun snapshotPreTrigger(): String? {
val out: ByteArray
synchronized(pcmRingLock) {
val available = if (pcmRingFilled) PCM_RING_SAMPLES else pcmRingPos
val n = if (available < PRE_TRIGGER_SAMPLES) available else PRE_TRIGGER_SAMPLES
if (n <= 0) return null
out = ByteArray(n * 2)
var idx = (pcmRingPos - n + PCM_RING_SAMPLES) % PCM_RING_SAMPLES
for (i in 0 until n) {
val s = pcmRing[idx].toInt()
out[i * 2] = (s and 0xFF).toByte()
out[i * 2 + 1] = ((s shr 8) and 0xFF).toByte()
idx += 1
if (idx >= PCM_RING_SAMPLES) idx = 0
}
}
return try {
android.util.Base64.encodeToString(out, android.util.Base64.NO_WRAP)
} catch (e: Exception) {
Log.w(TAG, "snapshotPreTrigger base64 fehlgeschlagen: ${e.message}")
null
}
}
private fun emitDetected() {
@@ -438,8 +480,10 @@ class OpenWakeWordModule(reactContext: ReactApplicationContext) : ReactContextBa
Log.i(TAG, "Wake-Word emit unterdrueckt (sinceStart=${sinceStart}ms < ${STARTUP_SUPPRESSION_MS}ms — Mikro-Spin-up-Spike)")
return
}
val preTriggerB64 = snapshotPreTrigger()
val params = com.facebook.react.bridge.Arguments.createMap().apply {
putString("model", modelName)
if (preTriggerB64 != null) putString("preTriggerPcm", preTriggerB64)
}
try {
reactApplicationContext
@@ -466,6 +510,14 @@ class OpenWakeWordModule(reactContext: ReactApplicationContext) : ReactContextBa
read += n
}
if (!running.get()) break
// Chunk in den PCM-Ringpuffer schreiben (fuer Wake-Wort-Bestaetigung).
synchronized(pcmRingLock) {
for (i in 0 until CHUNK_SAMPLES) {
pcmRing[pcmRingPos] = buf[i]
pcmRingPos += 1
if (pcmRingPos >= PCM_RING_SAMPLES) { pcmRingPos = 0; pcmRingFilled = true }
}
}
try {
processChunk(buf)
} catch (e: Exception) {