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:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user