From 49ea172617073bdad5a8596377b51e1ec8cd3833 Mon Sep 17 00:00:00 2001 From: duffyduck Date: Sun, 16 Aug 2026 21:37:15 +0200 Subject: [PATCH] =?UTF-8?q?feat(wake):=20Voxtral-Best=C3=A4tigungsstufe=20?= =?UTF-8?q?gegen=20Musik-Fehltrigger?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../com/ariacockpit/OpenWakeWordModule.kt | 52 +++++++++++ android/src/screens/SettingsScreen.tsx | 27 ++++++ android/src/services/audio.ts | 33 +++++++ android/src/services/wakeword.ts | 92 +++++++++++++++++-- xtts/voxtral/bridge.py | 24 +++++ 5 files changed, 222 insertions(+), 6 deletions(-) diff --git a/android/android/app/src/main/java/com/ariacockpit/OpenWakeWordModule.kt b/android/android/app/src/main/java/com/ariacockpit/OpenWakeWordModule.kt index 0fab6fd..94a8054 100644 --- a/android/android/app/src/main/java/com/ariacockpit/OpenWakeWordModule.kt +++ b/android/android/app/src/main/java/com/ariacockpit/OpenWakeWordModule.kt @@ -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 = 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) { diff --git a/android/src/screens/SettingsScreen.tsx b/android/src/screens/SettingsScreen.tsx index c563ce5..dc1a01c 100644 --- a/android/src/screens/SettingsScreen.tsx +++ b/android/src/screens/SettingsScreen.tsx @@ -114,6 +114,8 @@ import wakeWordService, { saveWakeThreshold, loadBgWakeEnabled, saveBgWakeEnabled, + loadWakeConfirmEnabled, + saveWakeConfirmEnabled, } from '../services/wakeword'; import ModeSelector from '../components/ModeSelector'; import QRScanner from '../components/QRScanner'; @@ -216,6 +218,8 @@ const SettingsScreen: React.FC = () => { const [wakeThreshold, setWakeThreshold] = useState(WAKE_THRESHOLD_DEFAULT); // Hintergrund-Wake: auch bei gesperrtem Bildschirm auf das Wake-Wort hoeren. Default aus. const [bgWake, setBgWake] = useState(false); + // Wake-Wort per Voxtral bestaetigen (gegen Musik-Fehltrigger). Default aus. + const [wakeConfirm, setWakeConfirm] = useState(false); const [editingPath, setEditingPath] = useState(false); const [xttsVoice, setXttsVoice] = useState(''); const [loadingVoice, setLoadingVoice] = useState(null); @@ -341,6 +345,7 @@ const SettingsScreen: React.FC = () => { isWakeReadySoundEnabled().then(setWakeReadySound); loadWakeThreshold().then(setWakeThreshold).catch(() => {}); loadBgWakeEnabled().then(setBgWake).catch(() => {}); + loadWakeConfirmEnabled().then(setWakeConfirm).catch(() => {}); updateService.getApkCacheSize().then(setApkCacheInfo).catch(() => {}); audioService.getTtsCacheSize().then(setTtsCacheInfo).catch(() => {}); AsyncStorage.getItem('aria_xtts_voice').then(saved => { @@ -1921,6 +1926,28 @@ const SettingsScreen: React.FC = () => { /> + + + Wake-Wort per Voxtral bestätigen + + Gegen Musik-Fehltrigger: nach „{KEYWORD_LABELS[wakeKeyword as keyof typeof KEYWORD_LABELS] || wakeKeyword}" + prüft Voxtral kurz nach, ob's wirklich das Wake-Wort war (oder nur + Musik/TV) — erst dann Gong + Mikro. Kostet ~0,5–1 s Extra vor dem + Gong. Empfohlen zusammen mit Hintergrund-Zuhören. + + + { + setWakeConfirm(val); + saveWakeConfirmEnabled(val).catch(() => {}); + wakeWordService.setWakeConfirmEnabled(val); + }} + trackColor={{ false: '#2A2A3E', true: '#0096FF' }} + thumbColor={wakeConfirm ? '#FFFFFF' : '#666680'} + /> + + Weiterreden nach der Antwort Nach einer gesprochenen ARIA-Antwort geht das Mikro auf — du kannst ohne diff --git a/android/src/services/audio.ts b/android/src/services/audio.ts index d0cb973..3f38f6a 100644 --- a/android/src/services/audio.ts +++ b/android/src/services/audio.ts @@ -171,6 +171,39 @@ export async function saveBargeInEnabled(enabled: boolean): Promise { } catch {} } +/** One-Shot-Transkription eines PCM-Schnipsels (base64, s16le 16kHz mono) via + * Voxtral — fuer die Wake-Wort-Bestaetigung. Schickt stt_transcribe_blob und + * wartet auf stt_transcribe_result (matching requestId) mit Timeout. + * Rueckgabe: Text (evtl. '') bei Antwort, oder null bei Timeout/Fehler → + * Aufrufer macht dann fail-open (Wake normal durchlassen). */ +export async function transcribeBlob(pcmBase64: string, timeoutMs = 2500): Promise { + if (!pcmBase64) return null; + const requestId = `wakeverify_${Date.now()}_${Math.floor(Math.random() * 100000)}`; + return new Promise((resolve) => { + let done = false; + let unsub: (() => void) | null = null; + const timer = setTimeout(() => finish(null), timeoutMs); + function finish(val: string | null) { + if (done) return; + done = true; + try { unsub && unsub(); } catch {} + clearTimeout(timer); + resolve(val); + } + try { + unsub = rvs.onMessage((msg: any) => { + if (msg?.type !== 'stt_transcribe_result') return; + const p = (msg as any).payload || {}; + if (String(p.requestId || '') !== requestId) return; + finish(typeof p.text === 'string' ? p.text : ''); + }); + rvs.send('stt_transcribe_blob' as any, { requestId, pcm: pcmBase64, language: 'de' }); + } catch { + finish(null); + } + }); +} + export async function loadSttEndpointMs(): Promise { try { const raw = await AsyncStorage.getItem(STT_ENDPOINT_STORAGE_KEY); diff --git a/android/src/services/wakeword.ts b/android/src/services/wakeword.ts index 6961d1f..775df48 100644 --- a/android/src/services/wakeword.ts +++ b/android/src/services/wakeword.ts @@ -87,6 +87,27 @@ export async function saveBgWakeEnabled(enabled: boolean): Promise { } catch {} } +// Wake-Wort-Bestaetigung: nach einem openWakeWord-Trigger den Vor-Trigger-Audio +// von Voxtral gegenpruefen lassen ("war das wirklich 'Computer' oder Musik?"). +// Killt Musik-Fehltrigger (z.B. Pet Shop Boys), kostet ~0.5-1s Extra-Latenz pro +// Wake. Default AUS (opt-in), fail-open. Braucht das native preTriggerPcm im +// Event (neueres APK) — ohne das macht die App normal weiter. +export const WAKE_CONFIRM_STORAGE_KEY = 'aria_wake_confirm_enabled'; + +export async function loadWakeConfirmEnabled(): Promise { + try { + return (await AsyncStorage.getItem(WAKE_CONFIRM_STORAGE_KEY)) === 'true'; + } catch { + return false; + } +} + +export async function saveWakeConfirmEnabled(enabled: boolean): Promise { + try { + await AsyncStorage.setItem(WAKE_CONFIRM_STORAGE_KEY, String(enabled)); + } catch {} +} + /** Verfuegbare Wake-Words — entsprechen den .onnx Dateien in * android/app/src/main/assets/openwakeword/. Custom-Keywords (eigenes * Training via openwakeword Notebook) muessen aktuell als Asset eingebaut @@ -159,6 +180,9 @@ class WakeWordService { * Bildschirm. Default false. Wird beim Arm aus AsyncStorage geladen und * bei Aenderung in den Einstellungen via setBgWakeEnabled() aktualisiert. */ private bgWakeEnabled: boolean = false; + /** Wake-Wort per Voxtral bestaetigen (gegen Musik-Fehltrigger)? Default false. + * Wird beim Arm geladen + per setWakeConfirmEnabled aus den Einstellungen. */ + private wakeConfirmEnabled: boolean = false; /** Re-Entry-Guard fuer onWakeDetected: native kann mehrere * WakeWordDetected-Events emitten BEVOR OpenWakeWord.stop() in JS * resolved (Bridge-Queue + Doze-Backlog). Mit dem Flag wird das zweite @@ -241,15 +265,19 @@ class WakeWordService { try { const threshold = await loadWakeThreshold(); this.bgWakeEnabled = await loadBgWakeEnabled(); - console.log('[WakeWord] init mit threshold=%s, bgWake=%s', threshold, this.bgWakeEnabled); + this.wakeConfirmEnabled = await loadWakeConfirmEnabled(); + console.log('[WakeWord] init mit threshold=%s, bgWake=%s, confirm=%s', + threshold, this.bgWakeEnabled, this.wakeConfirmEnabled); await OpenWakeWord.init(this.keyword, threshold, DEFAULT_PATIENCE, DEFAULT_DEBOUNCE_MS); // Subscribe nur einmal if (!this.eventSub) { const emitter = new NativeEventEmitter(NativeModules.OpenWakeWord); - this.eventSub = emitter.addListener('WakeWordDetected', () => { + this.eventSub = emitter.addListener('WakeWordDetected', (payload: any) => { console.log('[WakeWord] Native Detection-Event empfangen'); - this.onWakeDetected().catch(err => - console.warn('[WakeWord] onWakeDetected crashed:', err)); + // payload.preTriggerPcm (base64 s16le 16kHz) fuer die Bestaetigung — + // nur in neueren APKs vorhanden; ohne = fail-open (kein Verify). + this.onWakeDetected(payload && payload.preTriggerPcm ? String(payload.preTriggerPcm) : null) + .catch(err => console.warn('[WakeWord] onWakeDetected crashed:', err)); }); } this.nativeReady = true; @@ -348,6 +376,12 @@ class WakeWordService { console.log('[WakeWord] Hintergrund-Wake = %s', enabled); } + /** Wake-Wort-Bestaetigung (Voxtral) ein/aus (aus den Einstellungen). */ + setWakeConfirmEnabled(enabled: boolean): void { + this.wakeConfirmEnabled = enabled; + console.log('[WakeWord] Wake-Bestaetigung = %s', enabled); + } + /** Ist Hintergrund-Wake an? Steuert u.a. ob der Konversationsmodus auch im * Hintergrund weiterlaeuft (sonst: im Hintergrund direkt zurueck aufs Wake-Word). */ isBgWakeEnabled(): boolean { @@ -364,8 +398,10 @@ class WakeWordService { console.log('[WakeWord] App im Vordergrund — Cooldown 1s aktiv'); } - /** Wake-Word getriggert: Native-Modul pausieren, Konversation starten. */ - private async onWakeDetected(): Promise { + /** Wake-Word getriggert: Native-Modul pausieren, Konversation starten. + * preTriggerPcm: base64 s16le 16kHz Vor-Trigger-Audio fuer die Bestaetigung + * (null = nicht verfuegbar → keine Bestaetigung, normal weiter). */ + private async onWakeDetected(preTriggerPcm: string | null = null): Promise { if (this.inBackground && !this.bgWakeEnabled) { console.log('[WakeWord] Trigger ignoriert (App im Hintergrund, Hintergrund-Wake aus)'); import('./logger').then(m => m.reportAppDebug('wake.detect', 'ignored: app in background (bg-wake off)')).catch(()=>{}); @@ -411,6 +447,22 @@ class WakeWordService { // Kein erneutes setState — wir bleiben in 'conversing'. return; } + // Wake-Wort-Bestaetigung (gegen Musik-Fehltrigger): den Vor-Trigger-Schnipsel + // von Voxtral gegenpruefen. Bestaetigt → weiter (Gong + Mikro). Verworfen + // (Musik/Rauschen, kein "Computer") → kein Dialog, kein Gong, re-arm. Fail- + // open: ohne PCM / bei Timeout/Fehler laeuft es normal durch. + if (this.wakeConfirmEnabled && preTriggerPcm) { + const confirmed = await this.confirmWake(preTriggerPcm); + if (!confirmed) { + this.detectionInProgress = false; + if (this.nativeReady && OpenWakeWord) { + try { await OpenWakeWord.start(); } catch (e) { + console.warn('[WakeWord] re-arm nach verworfener Bestaetigung failed:', e); + } + } + return; + } + } this.setState('conversing'); // Direkt feuern — KEIN setTimeout. Im Hintergrund (Display aus) parkt // Android den JS-Thread; ein setTimeout(200ms) kann dann Minuten lang @@ -424,6 +476,34 @@ class WakeWordService { }); } + /** Voxtral-Bestaetigung des Vor-Trigger-Schnipsels. true = Wake-Wort erkannt + * (oder fail-open bei Timeout/Fehler), false = Musik/Rauschen → verwerfen. */ + private async confirmWake(pcm: string): Promise { + try { + const audio = await import('./audio'); + const text = await audio.transcribeBlob(pcm); + if (text === null) { + console.log('[WakeWord] Bestaetigung: Timeout/Fehler → fail-open (durchlassen)'); + return true; + } + const norm = text.toLowerCase(); + // Distinktive Wake-Wort-Bestandteile (>= 4 Zeichen; 'hey' o.ae. rausfiltern, + // taucht sonst in Song-Texten auf und wuerde faelschlich bestaetigen). + const kwWords = this.keyword.toLowerCase().replace(/_/g, ' ') + .split(/\s+/).filter(w => w.length >= 4); + if (kwWords.length === 0) return true; // zu kurzes Keyword → nicht pruefbar + const ok = kwWords.some(w => norm.includes(w)); + console.log('[WakeWord] Bestaetigung: text=%o kw=%o → %s', + text, kwWords, ok ? 'BESTAETIGT' : 'verworfen (Musik-FP?)'); + import('./logger').then(m => m.reportAppDebug('wake.confirm', + `text="${text.slice(0, 40)}" kw=${kwWords.join('|')} → ${ok ? 'ok' : 'reject'}`)).catch(() => {}); + return ok; + } catch (e) { + console.warn('[WakeWord] confirmWake err → fail-open:', e); + return true; + } + } + /** Wake-Word PARALLEL zur TTS-Wiedergabe lauschen lassen — User kann * "Computer" sagen waehrend ARIA noch redet, AcousticEchoCanceler im * Native-Modul verhindert dass ARIAs eigene Stimme triggert. diff --git a/xtts/voxtral/bridge.py b/xtts/voxtral/bridge.py index 9a2bf3f..13c514b 100644 --- a/xtts/voxtral/bridge.py +++ b/xtts/voxtral/bridge.py @@ -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: