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
+33
View File
@@ -171,6 +171,39 @@ export async function saveBargeInEnabled(enabled: boolean): Promise<void> {
} 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<string | null> {
if (!pcmBase64) return null;
const requestId = `wakeverify_${Date.now()}_${Math.floor(Math.random() * 100000)}`;
return new Promise<string | null>((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<number> {
try {
const raw = await AsyncStorage.getItem(STT_ENDPOINT_STORAGE_KEY);