diff --git a/README.md b/README.md index 7d6c71c..7e7b9dd 100644 --- a/README.md +++ b/README.md @@ -380,7 +380,7 @@ API-Endpoint fuer andere Services: `GET http://localhost:3001/api/session` - Text-Chat mit ARIA - **Sprachaufnahme**: Push-to-Talk (halten) oder Tap-to-Talk (tippen, Auto-Stop bei Stille) - **Gespraechsmodus** (Ohr-Button): Nach jeder ARIA-Antwort startet automatisch die Aufnahme — wie ein natuerliches Gespraech hin und her -- **Wake-Word** (optional, Picovoice Porcupine on-device): "Jarvis", "Computer" usw. — Mikrofon hoert passiv mit, Konversation startet beim Schluesselwort. Eigene Wake-Words ueber die Picovoice Console moeglich. Ohne API-Key faellt der Ohr-Button auf Direkt-Aufnahme zurueck. +- **Wake-Word** (on-device, openWakeWord ONNX): "Hey Jarvis", "Alexa", "Hey Mycroft", "Hey Rhasspy" — Mikrofon hoert passiv mit, Konversation startet beim Schluesselwort. Komplett on-device via ONNX Runtime, kein API-Key, kein Cloud-Roundtrip, Audio verlaesst das Geraet nicht. - **VAD (Voice Activity Detection)**: Konfigurierbare Stille-Toleranz (1.0–8.0s, Default 2.8s) bevor Auto-Stop greift. Max-Aufnahme 120s. - **Speech Gate**: Aufnahme wird verworfen wenn keine Sprache erkannt - **STT (Speech-to-Text)**: 16kHz mono → Bridge → Gamebox-Whisper (CUDA) → Text im Chat. Fast in Echtzeit. @@ -399,48 +399,43 @@ API-Endpoint fuer andere Services: `GET http://localhost:3001/api/session` - GPS-Position (optional) - QR-Code Scanner fuer Token-Pairing -### Wake-Word einrichten (Picovoice Porcupine) +### Wake-Word (openWakeWord, on-device) -Das Wake-Word laeuft komplett **on-device** in der App — kein Audio verlaesst dein Telefon -fuer die Erkennung. Picovoice bietet aktuell einen **7-Tage Free Trial** ohne Kreditkarte -und ohne Auto-Renewal an, danach kostenpflichtig (siehe [picovoice.ai/pricing](https://picovoice.ai/pricing)). -Wer das Wake-Word ueberspringen will: der Ohr-Button funktioniert auch ohne AccessKey -(Direkt-Aufnahme statt passivem Lauschen — siehe unten). +Wake-Word-Erkennung laeuft komplett **on-device** ueber [openWakeWord](https://github.com/dscripka/openWakeWord) +mit ONNX Runtime — kein API-Key, kein Cloud-Roundtrip, kein Cent Lizenzgebuehren, +und das Audio verlaesst das Geraet nie. -**1) AccessKey holen** (einmalig, ~2 Minuten): - -1. Auf [console.picovoice.ai](https://console.picovoice.ai) registrieren (Email + Passwort, keine Kreditkarte fuer den Trial). -2. Nach dem Login auf dem Dashboard → **AccessKey** kopieren (langer Base64-String). - -**2) AccessKey in der App eintragen:** - -- App → **Einstellungen** → Abschnitt **Wake-Word** -- AccessKey einfuegen, **Keyword** auswaehlen (Default: `jarvis`) -- Speichern → die App initialisiert Porcupine automatisch - -**Eingebaute Keywords** (sofort verfuegbar, kein Training noetig): -`jarvis`, `computer`, `picovoice`, `porcupine`, `bumblebee`, `terminator`, -`alexa`, `hey google`, `ok google`, `hey siri` - -**3) Eigenes Wake-Word erstellen** ("ARIA", "Hey Stefan", was du willst): - -1. [console.picovoice.ai](https://console.picovoice.ai) → **Porcupine** → **Train Wake Word** -2. Wort eingeben (z.B. `ARIA`), Sprache `German` waehlen, Plattform `Android` -3. **Train** druecken — Picovoice trainiert das Modell in ~1–2 Minuten -4. Die fertige `.ppn`-Datei runterladen -5. *(Custom-Upload in der App ist Phase 2 — aktuell nur eingebaute Keywords. - `.ppn`-Dateien koennen schon manuell ins App-Bundle gelegt werden, die UI - dafuer kommt mit dem naechsten Diagnostic-Update.)* +**Mitgelieferte Wake-Words** (ONNX-Dateien in `android/android/app/src/main/assets/openwakeword/`): +- `Hey Jarvis` (Default) +- `Alexa` +- `Hey Mycroft` +- `Hey Rhasspy` **Bedienung:** +- App → **Einstellungen** → **Wake-Word** → gewuenschtes Keyword waehlen → **Speichern + Aktivieren** - **Ohr-Button (👂)** in der Statusleiste tippen → Wake-Word ist scharf, App hoert passiv mit -- Wake-Word sagen → Symbol wechselt auf 🎙️, normale Konversation laeuft +- Wake-Word sagen → Symbol wechselt auf 🎙️, Konversation laeuft - Nach jeder ARIA-Antwort oeffnet sich das Mikro nochmal — Stille → zurueck zu 👂 - Erneut tippen → Ohr aus (🔇) -**Ohne AccessKey:** Der Ohr-Button startet stattdessen die Direkt-Aufnahme (Mikro -ist sofort aktiv, kein passives Lauschen). Auch ein gueltiger Modus, nur halt ohne -"Hands-free" via Schluesselwort. +**Eigene Wake-Words trainieren** (gratis, ~30 Min): + +1. openWakeWord Trainings-Notebook auf Colab oeffnen (Link im + [openWakeWord Repo](https://github.com/dscripka/openWakeWord) unter "Training Custom Models") +2. Wake-Word-Phrase eingeben (z.B. "ARIA", "Hey Stefan"), Notebook ausfuehren — + das Notebook generiert synthetische Trainings-Beispiele und trainiert das Modell. +3. Resultierende `.onnx`-Datei runterladen +4. Datei in `android/android/app/src/main/assets/openwakeword/` ablegen +5. In `android/src/services/wakeword.ts` den Dateinamen (ohne `.onnx`) zur + `WAKE_KEYWORDS`-Liste hinzufuegen +6. APK neu bauen + +*(Diagnostic-Upload fuer Custom-`.onnx` ohne Rebuild kommt spaeter.)* + +**Tuning** (in [wakeword.ts](android/src/services/wakeword.ts)): +- `DEFAULT_THRESHOLD = 0.5` — Score-Schwelle (raise auf 0.6–0.7 bei False-Positives) +- `DEFAULT_PATIENCE = 2` — wie viele Frames ueber Threshold noetig +- `DEFAULT_DEBOUNCE_MS = 1500` — Mindestabstand zwischen zwei Triggern ### Ersteinrichtung (Dev-Maschine, einmalig) @@ -788,9 +783,10 @@ docker exec aria-core ssh aria-wohnung hostname - **Proxy Cold Start**: Jede Nachricht spawnt einen neuen `claude --print` Prozess. Dadurch ist ARIA langsamer als die direkte Claude CLI. Timeout ist auf 900s (15 Min). - **Kein Streaming zur App**: Die App zeigt erst die fertige Antwort, keine Streaming-Tokens. -- **Wake-Word in der App nur eingebaute Keywords**: `jarvis`, `computer` etc. funktionieren - sofort, eigene Wake-Words (`.ppn` aus der Picovoice Console) muessen aktuell noch manuell - ins App-Bundle. Die Upload-UI in Diagnostic ist Phase 2. +- **Wake-Word in der App nur eingebaute Keywords**: `Hey Jarvis`, `Alexa`, `Hey Mycroft`, + `Hey Rhasspy` funktionieren sofort, eigene Wake-Words muessen aktuell noch als + `.onnx`-Datei ins App-Bundle gelegt + zur Liste in `wakeword.ts` hinzugefuegt werden. + Die Diagnostic-Upload-UI ist Phase 2. - **Audio-Format**: App nimmt AAC/MP4 auf, Bridge konvertiert via FFmpeg zu 16kHz PCM. - **RVS Zombie-Connections**: WebSocket-Verbindungen sterben gelegentlich ohne Fehlermeldung. Bridge hat Ping-Check (5s), Diagnostic nutzt frische Verbindungen pro Request. @@ -845,7 +841,7 @@ docker exec aria-core ssh aria-wohnung hostname - [x] Audio-Pause statt Ducking (TRANSIENT statt MAY_DUCK) + release-Timing fix - [x] VAD-Stille-Toleranz und Max-Aufnahme einstellbar (1-8s, 120s) - [x] Disk-Voll Banner in Diagnostic mit copy-baren Cleanup-Befehlen -- [x] Porcupine Wake-Word on-device in der App (eingebaute Keywords + State-Icon) +- [x] Wake-Word on-device via openWakeWord (ONNX Runtime, kein API-Key) + State-Icon ### Phase 2 — ARIA wird produktiv @@ -861,5 +857,5 @@ docker exec aria-core ssh aria-wohnung hostname - [ ] STARFACE Telefonie-Skill - [ ] Desktop Client (Tauri) - [ ] bKVM Remote IT-Support -- [ ] Custom-`.ppn`-Upload fuer Wake-Word ueber Diagnostic (eigene Trigger-Worte) +- [ ] Custom-`.onnx`-Upload fuer Wake-Word ueber Diagnostic (ohne App-Rebuild) - [ ] Claude Vision direkt (Bildanalyse ohne Dateipfad-Umweg) diff --git a/android/android/app/build.gradle b/android/android/app/build.gradle index 643a7f4..d7ef861 100644 --- a/android/android/app/build.gradle +++ b/android/android/app/build.gradle @@ -111,6 +111,9 @@ dependencies { implementation("com.facebook.react:react-android") implementation("com.facebook.react:flipper-integration") + // ONNX Runtime fuer on-device Wake-Word (openWakeWord ONNX-Modelle in assets/openwakeword/) + implementation("com.microsoft.onnxruntime:onnxruntime-android:1.17.1") + if (hermesEnabled.toBoolean()) { implementation("com.facebook.react:hermes-android") } else { diff --git a/android/android/app/src/main/assets/openwakeword/alexa.onnx b/android/android/app/src/main/assets/openwakeword/alexa.onnx new file mode 100644 index 0000000..984ec2c Binary files /dev/null and b/android/android/app/src/main/assets/openwakeword/alexa.onnx differ diff --git a/android/android/app/src/main/assets/openwakeword/embedding_model.onnx b/android/android/app/src/main/assets/openwakeword/embedding_model.onnx new file mode 100644 index 0000000..afde53e Binary files /dev/null and b/android/android/app/src/main/assets/openwakeword/embedding_model.onnx differ diff --git a/android/android/app/src/main/assets/openwakeword/hey_jarvis.onnx b/android/android/app/src/main/assets/openwakeword/hey_jarvis.onnx new file mode 100644 index 0000000..371aa73 Binary files /dev/null and b/android/android/app/src/main/assets/openwakeword/hey_jarvis.onnx differ diff --git a/android/android/app/src/main/assets/openwakeword/hey_mycroft.onnx b/android/android/app/src/main/assets/openwakeword/hey_mycroft.onnx new file mode 100644 index 0000000..7305ca4 Binary files /dev/null and b/android/android/app/src/main/assets/openwakeword/hey_mycroft.onnx differ diff --git a/android/android/app/src/main/assets/openwakeword/hey_rhasspy.onnx b/android/android/app/src/main/assets/openwakeword/hey_rhasspy.onnx new file mode 100644 index 0000000..914d4f8 Binary files /dev/null and b/android/android/app/src/main/assets/openwakeword/hey_rhasspy.onnx differ diff --git a/android/android/app/src/main/assets/openwakeword/melspectrogram.onnx b/android/android/app/src/main/assets/openwakeword/melspectrogram.onnx new file mode 100644 index 0000000..a3a6035 Binary files /dev/null and b/android/android/app/src/main/assets/openwakeword/melspectrogram.onnx differ diff --git a/android/android/app/src/main/java/com/ariacockpit/MainApplication.kt b/android/android/app/src/main/java/com/ariacockpit/MainApplication.kt index b2d0c47..d6bebcc 100644 --- a/android/android/app/src/main/java/com/ariacockpit/MainApplication.kt +++ b/android/android/app/src/main/java/com/ariacockpit/MainApplication.kt @@ -21,6 +21,7 @@ class MainApplication : Application(), ReactApplication { add(ApkInstallerPackage()) add(AudioFocusPackage()) add(PcmStreamPlayerPackage()) + add(OpenWakeWordPackage()) } override fun getJSMainModuleName(): String = "index" diff --git a/android/android/app/src/main/java/com/ariacockpit/OpenWakeWordModule.kt b/android/android/app/src/main/java/com/ariacockpit/OpenWakeWordModule.kt new file mode 100644 index 0000000..50b456f --- /dev/null +++ b/android/android/app/src/main/java/com/ariacockpit/OpenWakeWordModule.kt @@ -0,0 +1,357 @@ +package com.ariacockpit + +import ai.onnxruntime.OnnxTensor +import ai.onnxruntime.OrtEnvironment +import ai.onnxruntime.OrtSession +import android.Manifest +import android.content.pm.PackageManager +import android.media.AudioFormat +import android.media.AudioRecord +import android.media.MediaRecorder +import android.util.Log +import androidx.core.content.ContextCompat +import com.facebook.react.bridge.Promise +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.ReactContextBaseJavaModule +import com.facebook.react.bridge.ReactMethod +import com.facebook.react.modules.core.DeviceEventManagerModule +import java.nio.FloatBuffer +import java.util.concurrent.atomic.AtomicBoolean + +/** + * Wake-Word Erkennung on-device via openWakeWord (https://github.com/dscripka/openWakeWord). + * + * Drei-stufige ONNX Pipeline: + * 1. Audio (16kHz mono int16, 1280-Sample-Chunks) → Melspectrogram → 32-mel Frames + * 2. 76 Mel-Frames Sliding Window (stride 8) → Speech-Embedding → 96-dim Vektor + * 3. Letzte 16 Embeddings (~1.28s Kontext) → Wake-Word-Klassifikator → Sigmoid-Score + * + * Modelle liegen in assets/openwakeword/ (mel + embedding shared, plus pro Keyword + * ein eigenes .onnx). Erkennung feuert nach `patience` aufeinanderfolgenden + * Frames ueber `threshold` und unterdrueckt Wiederholungen fuer `debounceMs`. + * + * Emittiert "WakeWordDetected" als RN-Event wenn ein Trigger erkannt wurde. + */ +class OpenWakeWordModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) { + override fun getName() = "OpenWakeWord" + + companion object { + private const val TAG = "OpenWakeWord" + private const val SAMPLE_RATE = 16000 + private const val CHUNK_SAMPLES = 1280 // 80ms @ 16kHz + private const val MEL_FRAMES_PER_EMBEDDING = 76 // Embedding-Fenster + private const val EMBEDDING_STRIDE = 8 // Slide um 8 Mel-Frames + private const val EMBEDDING_DIM = 96 + private const val WW_INPUT_FRAMES = 16 // 16 Embeddings = ~1.28s + private const val MEL_BINS = 32 + } + + private val env: OrtEnvironment = OrtEnvironment.getEnvironment() + private var melSession: OrtSession? = null + private var embSession: OrtSession? = null + private var wwSession: OrtSession? = null + + private var melInputName: String = "input" + private var embInputName: String = "input_1" + private var wwInputName: String = "input" + + // Konfiguration + private var threshold: Float = 0.5f + private var patience: Int = 2 + private var debounceMs: Long = 1500 + private var modelName: String = "hey_jarvis" + + // Audio-Capture-Thread + private var audioRecord: AudioRecord? = null + private val running = AtomicBoolean(false) + private var captureThread: Thread? = null + + // Inferenz-State + private val melBuffer: ArrayList = ArrayList(256) // Liste von 32-dim Frames + private var melProcessedIdx: Int = 0 + private val embBuffer: ArrayDeque = ArrayDeque(32) // Ringpuffer letzter Embeddings + private var consecutiveAboveThreshold: Int = 0 + private var lastDetectionMs: Long = 0L + + /** + * Initialisiert die ONNX-Sessions fuer ein bestimmtes Wake-Word. + * modelName: dateiname ohne Suffix (z.B. "hey_jarvis", "alexa", "hey_mycroft", "hey_rhasspy") + */ + @ReactMethod + fun init(modelName: String, threshold: Double, patience: Int, debounceMs: Int, promise: Promise) { + try { + disposeSessions() + this.modelName = modelName + this.threshold = threshold.toFloat() + this.patience = patience.coerceAtLeast(1) + this.debounceMs = debounceMs.toLong() + + val ctx = reactApplicationContext + val melBytes = ctx.assets.open("openwakeword/melspectrogram.onnx").use { it.readBytes() } + val embBytes = ctx.assets.open("openwakeword/embedding_model.onnx").use { it.readBytes() } + val wwBytes = ctx.assets.open("openwakeword/$modelName.onnx").use { it.readBytes() } + + val opts = OrtSession.SessionOptions() + melSession = env.createSession(melBytes, opts) + embSession = env.createSession(embBytes, opts) + wwSession = env.createSession(wwBytes, opts) + + melInputName = melSession!!.inputNames.first() + embInputName = embSession!!.inputNames.first() + wwInputName = wwSession!!.inputNames.first() + + Log.i(TAG, "Init OK: model=$modelName threshold=$threshold patience=$patience " + + "debounce=${debounceMs}ms (inputs: mel=$melInputName emb=$embInputName ww=$wwInputName)") + promise.resolve(true) + } catch (e: Exception) { + Log.e(TAG, "Init fehlgeschlagen: ${e.message}", e) + disposeSessions() + promise.reject("INIT_FAILED", e.message ?: "Unbekannter Fehler", e) + } + } + + @ReactMethod + fun start(promise: Promise) { + if (running.get()) { + promise.resolve(true) + return + } + if (melSession == null || embSession == null || wwSession == null) { + promise.reject("NOT_INITIALIZED", "init() muss vor start() aufgerufen werden") + return + } + // Berechtigung pruefen — der App-Code holt die ueblicherweise schon vorher, + // aber wir bestehen hier explizit darauf damit AudioRecord nicht stumm + // failt. + val perm = ContextCompat.checkSelfPermission(reactApplicationContext, Manifest.permission.RECORD_AUDIO) + if (perm != PackageManager.PERMISSION_GRANTED) { + promise.reject("NO_MIC_PERMISSION", "RECORD_AUDIO Permission fehlt") + return + } + + try { + val minBuf = AudioRecord.getMinBufferSize( + SAMPLE_RATE, + AudioFormat.CHANNEL_IN_MONO, + AudioFormat.ENCODING_PCM_16BIT, + ).coerceAtLeast(CHUNK_SAMPLES * 2 * 4) + + val record = AudioRecord( + MediaRecorder.AudioSource.MIC, + SAMPLE_RATE, + AudioFormat.CHANNEL_IN_MONO, + AudioFormat.ENCODING_PCM_16BIT, + minBuf, + ) + if (record.state != AudioRecord.STATE_INITIALIZED) { + record.release() + promise.reject("AUDIO_INIT", "AudioRecord nicht initialisiert (Mikro belegt?)") + return + } + audioRecord = record + resetInferenceState() + running.set(true) + record.startRecording() + + captureThread = Thread({ captureLoop() }, "OpenWakeWordCapture").apply { + isDaemon = true + start() + } + + Log.i(TAG, "Lauschen gestartet (model=$modelName)") + promise.resolve(true) + } catch (e: Exception) { + Log.e(TAG, "start fehlgeschlagen", e) + running.set(false) + audioRecord?.release() + audioRecord = null + promise.reject("START_FAILED", e.message ?: "Unbekannter Fehler", e) + } + } + + @ReactMethod + fun stop(promise: Promise) { + running.set(false) + try { + captureThread?.join(1500) + } catch (_: InterruptedException) {} + captureThread = null + try { audioRecord?.stop() } catch (_: Exception) {} + try { audioRecord?.release() } catch (_: Exception) {} + audioRecord = null + Log.i(TAG, "Lauschen gestoppt") + promise.resolve(true) + } + + @ReactMethod + fun dispose(promise: Promise) { + running.set(false) + try { captureThread?.join(1000) } catch (_: InterruptedException) {} + captureThread = null + try { audioRecord?.stop() } catch (_: Exception) {} + try { audioRecord?.release() } catch (_: Exception) {} + audioRecord = null + disposeSessions() + promise.resolve(true) + } + + @ReactMethod + fun isAvailable(promise: Promise) { + // Wake-Word ist immer verfuegbar (kein API-Key, alles on-device) + promise.resolve(true) + } + + // RN-Event-Subscriptions — RN-Konvention, sonst Warnung im Debug-Build + @ReactMethod fun addListener(eventName: String) {} + @ReactMethod fun removeListeners(count: Int) {} + + private fun disposeSessions() { + try { melSession?.close() } catch (_: Exception) {} + try { embSession?.close() } catch (_: Exception) {} + try { wwSession?.close() } catch (_: Exception) {} + melSession = null + embSession = null + wwSession = null + } + + private fun resetInferenceState() { + melBuffer.clear() + melProcessedIdx = 0 + embBuffer.clear() + consecutiveAboveThreshold = 0 + lastDetectionMs = 0L + } + + private fun emitDetected() { + val params = com.facebook.react.bridge.Arguments.createMap().apply { + putString("model", modelName) + } + try { + reactApplicationContext + .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java) + .emit("WakeWordDetected", params) + } catch (e: Exception) { + Log.w(TAG, "emit fehlgeschlagen: ${e.message}") + } + } + + private fun captureLoop() { + val buf = ShortArray(CHUNK_SAMPLES) + val record = audioRecord ?: return + Log.i(TAG, "Capture-Loop gestartet") + while (running.get()) { + var read = 0 + while (read < CHUNK_SAMPLES && running.get()) { + val n = record.read(buf, read, CHUNK_SAMPLES - read) + if (n <= 0) { + Log.w(TAG, "AudioRecord.read returned $n — Loop ende") + running.set(false) + return + } + read += n + } + if (!running.get()) break + try { + processChunk(buf) + } catch (e: Exception) { + Log.w(TAG, "processChunk: ${e.message}") + } + } + Log.i(TAG, "Capture-Loop beendet") + } + + /** Verarbeitet einen 1280-Sample int16 Audio-Chunk. */ + private fun processChunk(audio: ShortArray) { + // 1) Audio → mel (output (1, 1, frames, 32)) + val floats = FloatArray(audio.size) { audio[it].toFloat() } + val melTensor = OnnxTensor.createTensor( + env, + FloatBuffer.wrap(floats), + longArrayOf(1L, audio.size.toLong()), + ) + val melResult = melSession!!.run(mapOf(melInputName to melTensor)) + val melOut = melResult.get(0).value + melTensor.close() + @Suppress("UNCHECKED_CAST") + val mel4 = melOut as Array>> + val frames = mel4[0][0] + // openWakeWord wendet `mel/10 + 2` an, bevor es ans Embedding-Modell geht + for (frame in frames) { + val scaled = FloatArray(frame.size) { frame[it] / 10f + 2f } + melBuffer.add(scaled) + } + melResult.close() + + // 2) Sliding window: alle vollstaendigen 76-Frame-Fenster verarbeiten + while (melBuffer.size >= melProcessedIdx + MEL_FRAMES_PER_EMBEDDING) { + val flat = FloatArray(MEL_FRAMES_PER_EMBEDDING * MEL_BINS) + var pos = 0 + for (i in 0 until MEL_FRAMES_PER_EMBEDDING) { + val src = melBuffer[melProcessedIdx + i] + System.arraycopy(src, 0, flat, pos, MEL_BINS) + pos += MEL_BINS + } + val embIn = OnnxTensor.createTensor( + env, + FloatBuffer.wrap(flat), + longArrayOf(1L, MEL_FRAMES_PER_EMBEDDING.toLong(), MEL_BINS.toLong(), 1L), + ) + val embRes = embSession!!.run(mapOf(embInputName to embIn)) + val embOut = embRes.get(0).value + embIn.close() + // Erwartete Output-Form: (1, 96) → Array + @Suppress("UNCHECKED_CAST") + val embArr = embOut as Array + embBuffer.addLast(embArr[0].copyOf()) + while (embBuffer.size > WW_INPUT_FRAMES) embBuffer.removeFirst() + embRes.close() + + melProcessedIdx += EMBEDDING_STRIDE + } + // Mel-Buffer trimmen — verhindert Memory-Wachstum + if (melProcessedIdx > MEL_FRAMES_PER_EMBEDDING) { + val keepFrom = melProcessedIdx - MEL_FRAMES_PER_EMBEDDING + val newList = ArrayList(melBuffer.size - keepFrom) + for (i in keepFrom until melBuffer.size) newList.add(melBuffer[i]) + melBuffer.clear() + melBuffer.addAll(newList) + melProcessedIdx = MEL_FRAMES_PER_EMBEDDING + } + + // 3) Klassifikation — sobald wir 16 Embeddings haben + if (embBuffer.size < WW_INPUT_FRAMES) return + val flatEmb = FloatArray(WW_INPUT_FRAMES * EMBEDDING_DIM) + var p = 0 + for (e in embBuffer) { + System.arraycopy(e, 0, flatEmb, p, EMBEDDING_DIM) + p += EMBEDDING_DIM + } + val wwIn = OnnxTensor.createTensor( + env, + FloatBuffer.wrap(flatEmb), + longArrayOf(1L, WW_INPUT_FRAMES.toLong(), EMBEDDING_DIM.toLong()), + ) + val wwRes = wwSession!!.run(mapOf(wwInputName to wwIn)) + val wwOut = wwRes.get(0).value + wwIn.close() + // Erwartete Output-Form: (1, 1) → Array + @Suppress("UNCHECKED_CAST") + val score = (wwOut as Array)[0][0] + wwRes.close() + + if (score >= threshold) { + consecutiveAboveThreshold++ + if (consecutiveAboveThreshold >= patience) { + val now = System.currentTimeMillis() + if (now - lastDetectionMs >= debounceMs) { + lastDetectionMs = now + consecutiveAboveThreshold = 0 + Log.i(TAG, "Wake-Word erkannt! score=$score model=$modelName") + emitDetected() + } + } + } else { + consecutiveAboveThreshold = 0 + } + } +} diff --git a/android/android/app/src/main/java/com/ariacockpit/OpenWakeWordPackage.kt b/android/android/app/src/main/java/com/ariacockpit/OpenWakeWordPackage.kt new file mode 100644 index 0000000..94572d5 --- /dev/null +++ b/android/android/app/src/main/java/com/ariacockpit/OpenWakeWordPackage.kt @@ -0,0 +1,16 @@ +package com.ariacockpit + +import com.facebook.react.ReactPackage +import com.facebook.react.bridge.NativeModule +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.uimanager.ViewManager + +class OpenWakeWordPackage : ReactPackage { + override fun createNativeModules(reactContext: ReactApplicationContext): List { + return listOf(OpenWakeWordModule(reactContext)) + } + + override fun createViewManagers(reactContext: ReactApplicationContext): List> { + return emptyList() + } +} diff --git a/android/package.json b/android/package.json index 60d5fea..acb53d1 100644 --- a/android/package.json +++ b/android/package.json @@ -24,9 +24,7 @@ "react-native-camera-kit": "^13.0.0", "@react-native-async-storage/async-storage": "^1.21.0", "react-native-fs": "^2.20.0", - "react-native-audio-recorder-player": "^3.6.7", - "@picovoice/porcupine-react-native": "3.0.5", - "@picovoice/react-native-voice-processor": "1.2.3" + "react-native-audio-recorder-player": "^3.6.7" }, "devDependencies": { "typescript": "^5.3.3", diff --git a/android/src/screens/SettingsScreen.tsx b/android/src/screens/SettingsScreen.tsx index 3d91db6..20da438 100644 --- a/android/src/screens/SettingsScreen.tsx +++ b/android/src/screens/SettingsScreen.tsx @@ -41,9 +41,9 @@ import { TTS_SPEED_STORAGE_KEY, } from '../services/audio'; import wakeWordService, { - BUILTIN_KEYWORDS, + WAKE_KEYWORDS, + KEYWORD_LABELS, DEFAULT_KEYWORD, - WAKE_ACCESS_KEY_STORAGE, WAKE_KEYWORD_STORAGE, } from '../services/wakeword'; import ModeSelector from '../components/ModeSelector'; @@ -103,8 +103,6 @@ const SettingsScreen: React.FC = () => { const [vadSilenceSec, setVadSilenceSec] = useState(VAD_SILENCE_DEFAULT_SEC); const [convWindowSec, setConvWindowSec] = useState(CONV_WINDOW_DEFAULT_SEC); const [ttsSpeed, setTtsSpeed] = useState(TTS_SPEED_DEFAULT); - const [wakeAccessKey, setWakeAccessKey] = useState(''); - const [wakeAccessKeyVisible, setWakeAccessKeyVisible] = useState(false); const [wakeKeyword, setWakeKeyword] = useState(DEFAULT_KEYWORD); const [wakeStatus, setWakeStatus] = useState(''); const [editingPath, setEditingPath] = useState(false); @@ -164,11 +162,8 @@ const SettingsScreen: React.FC = () => { if (isFinite(n) && n >= TTS_SPEED_MIN && n <= TTS_SPEED_MAX) setTtsSpeed(n); } }); - AsyncStorage.getItem(WAKE_ACCESS_KEY_STORAGE).then(saved => { - if (saved) setWakeAccessKey(saved); - }); AsyncStorage.getItem(WAKE_KEYWORD_STORAGE).then(saved => { - if (saved) setWakeKeyword(saved); + if (saved && (WAKE_KEYWORDS as readonly string[]).includes(saved)) setWakeKeyword(saved); }); AsyncStorage.getItem('aria_xtts_voice').then(saved => { if (saved) setXttsVoice(saved); @@ -678,44 +673,23 @@ const SettingsScreen: React.FC = () => { - {/* === Wake-Word (geraetelokal) === */} + {/* === Wake-Word (komplett on-device, openWakeWord) === */} Wake-Word - Wenn ein Picovoice-Access-Key eingetragen ist, hoert die App passiv - auf das gewaehlte Wake-Word — du kannst dich mit anderen unterhalten, - Musik laufen lassen und mit "{wakeKeyword}" eine Konversation mit - ARIA starten. Ohne Key oder bei Fehlschlag startet das Ohr direkt - eine Konversation (klassischer Modus). + Lokale Erkennung via openWakeWord (ONNX, on-device). Kein API-Key, + kein Cloud-Roundtrip — Audio verlaesst das Geraet nicht. Wenn das Ohr + aktiv ist, hoerst du normal mit; sagst du das Wake-Word, startet eine + Konversation mit ARIA. - Picovoice Access Key - - - setWakeAccessKeyVisible(v => !v)} - style={{padding: 8}} - > - {wakeAccessKeyVisible ? '🙈' : '👁'} - - - Wake-Word - Built-In: sofort verwendbar. "ARIA" als Custom-Keyword kommt spaeter - ueber Diagnostic-Upload. + Eigene Wake-Words via openWakeWord-Notebook trainierbar (gratis). + Custom-Upload ueber Diagnostic kommt in einer spaeteren Version. - {BUILTIN_KEYWORDS.map(kw => ( + {WAKE_KEYWORDS.map(kw => ( { styles.keywordChipText, wakeKeyword === kw && styles.keywordChipTextActive, ]}> - {kw} + {KEYWORD_LABELS[kw]} ))} @@ -740,8 +714,8 @@ const SettingsScreen: React.FC = () => { onPress={async () => { setWakeStatus('Initialisiere...'); try { - const ok = await wakeWordService.configure(wakeAccessKey, wakeKeyword); - setWakeStatus(ok ? `✅ "${wakeKeyword}" bereit` : '❌ Fehlgeschlagen — Access Key pruefen'); + const ok = await wakeWordService.configure(wakeKeyword); + setWakeStatus(ok ? `✅ "${KEYWORD_LABELS[wakeKeyword as keyof typeof KEYWORD_LABELS]}" bereit` : '❌ Init-Fehler — Logs pruefen'); } catch (err: any) { setWakeStatus('❌ ' + String(err?.message || err).slice(0, 80)); } diff --git a/android/src/services/wakeword.ts b/android/src/services/wakeword.ts index bd5fa71..e25d18f 100644 --- a/android/src/services/wakeword.ts +++ b/android/src/services/wakeword.ts @@ -1,142 +1,138 @@ /** * Gespraechsmodus / Wake Word Service * + * Wake-Word-Engine: openWakeWord (https://github.com/dscripka/openWakeWord), + * komplett on-device via ONNX Runtime in Native-Kotlin (siehe + * OpenWakeWordModule.kt + assets/openwakeword/). Kein API-Key, kein Cloud- + * Roundtrip, kein Cent Lizenzgebuehren. + * * Drei Zustaende: * off — Ohr aus, nichts laeuft - * armed — Ohr aktiv, Porcupine hoert passiv auf das Wake-Word. - * Das Mikro ist von Porcupine belegt; AudioRecorder ist aus. - * conversing — Wake-Word getriggert (oder Ohr-Tap ohne Wake-Word): - * aktive Konversation. Porcupine pausiert (gibt Mikro frei), + * armed — Ohr aktiv, openWakeWord hoert passiv auf das Wake-Word. + * Das Mikro ist von OpenWakeWord belegt; AudioRecorder ist aus. + * conversing — Wake-Word getriggert (oder Ohr-Tap manuell): + * aktive Konversation. OpenWakeWord pausiert (gibt Mikro frei), * AudioRecorder uebernimmt fuer die Aufnahme. * Nach jeder ARIA-Antwort oeffnet das Mikro fuer X Sekunden * (Conversation-Window). Stille im Fenster → zurueck zu armed. * - * Wake-Word fallback: ist kein Picovoice-Access-Key gesetzt, geht 'start' - * direkt in 'conversing' (klassischer Gespraechsmodus). 'endConversation' - * geht dann nach 'off' statt 'armed'. + * Faellt das Native-Modul aus (alte App-Version, ONNX-Init-Fehler), geht + * 'start' direkt in 'conversing' (klassischer Direkt-Aufnahme-Modus). */ +import { NativeEventEmitter, NativeModules, ToastAndroid } from 'react-native'; import AsyncStorage from '@react-native-async-storage/async-storage'; -import { ToastAndroid } from 'react-native'; type WakeWordCallback = () => void; type StateCallback = (state: WakeWordState) => void; export type WakeWordState = 'off' | 'armed' | 'conversing'; -export const WAKE_ACCESS_KEY_STORAGE = 'aria_wake_access_key'; export const WAKE_KEYWORD_STORAGE = 'aria_wake_keyword'; -/** Built-In Keywords von Picovoice — pre-trained, sofort einsetzbar. - * Custom Keywords (z.B. "ARIA") brauchen ein .ppn File aus der Picovoice - * Console — wird spaeter ueber Diagnostic uploadbar. */ -export const BUILTIN_KEYWORDS = [ - 'jarvis', - 'computer', - 'picovoice', - 'porcupine', - 'bumblebee', - 'terminator', +/** 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 + * werden — Diagnostic-Upload ist Phase 2. */ +export const WAKE_KEYWORDS = [ + 'hey_jarvis', 'alexa', - 'hey google', - 'ok google', - 'hey siri', + 'hey_mycroft', + 'hey_rhasspy', ] as const; -export type BuiltinKeyword = typeof BUILTIN_KEYWORDS[number]; -export const DEFAULT_KEYWORD: BuiltinKeyword = 'jarvis'; +export type WakeKeyword = typeof WAKE_KEYWORDS[number]; +export const DEFAULT_KEYWORD: WakeKeyword = 'hey_jarvis'; + +/** Hilfs-Mapping fuer die Anzeige im UI. */ +export const KEYWORD_LABELS: Record = { + hey_jarvis: 'Hey Jarvis', + alexa: 'Alexa', + hey_mycroft: 'Hey Mycroft', + hey_rhasspy: 'Hey Rhasspy', +}; + +// Detection-Tuning — kann in Settings spaeter konfigurierbar werden. +const DEFAULT_THRESHOLD = 0.5; +const DEFAULT_PATIENCE = 2; +const DEFAULT_DEBOUNCE_MS = 1500; + +interface OpenWakeWordModule { + init(modelName: string, threshold: number, patience: number, debounceMs: number): Promise; + start(): Promise; + stop(): Promise; + dispose(): Promise; + isAvailable(): Promise; +} + +const { OpenWakeWord } = NativeModules as { OpenWakeWord?: OpenWakeWordModule }; class WakeWordService { private state: WakeWordState = 'off'; private wakeCallbacks: WakeWordCallback[] = []; private stateCallbacks: StateCallback[] = []; - // Picovoice Manager (lazy, da Native Module nicht in jedem Build verfuegbar ist) - private porcupine: any = null; - private accessKey: string = ''; - private keyword: string = DEFAULT_KEYWORD; + private keyword: WakeKeyword = DEFAULT_KEYWORD; + private nativeReady: boolean = false; private initInProgress: Promise | null = null; + private eventSub: { remove: () => void } | null = null; - /** Beim App-Start aufrufen — laedt Settings, baut Porcupine wenn Key da ist. */ + /** Beim App-Start aufrufen — laedt Settings, baut Native-Modul. */ async loadFromStorage(): Promise { try { - const k = await AsyncStorage.getItem(WAKE_ACCESS_KEY_STORAGE); const w = await AsyncStorage.getItem(WAKE_KEYWORD_STORAGE); - this.accessKey = (k || '').trim(); - this.keyword = (w || DEFAULT_KEYWORD).trim(); - if (this.accessKey) { - // Vorinitialisieren — wirft sich nicht durch wenn etwas fehlt - await this.initPorcupine(); - } + const wt = (w || DEFAULT_KEYWORD).trim() as WakeKeyword; + this.keyword = (WAKE_KEYWORDS as readonly string[]).includes(wt) ? wt : DEFAULT_KEYWORD; + await this.initNative(); } catch (err) { console.warn('[WakeWord] loadFromStorage', err); } } - /** Settings-Wechsel — neuer Key oder Keyword. Re-Init Porcupine. */ - async configure(accessKey: string, keyword: string): Promise { - this.accessKey = (accessKey || '').trim(); - this.keyword = (keyword || DEFAULT_KEYWORD).trim(); - await AsyncStorage.setItem(WAKE_ACCESS_KEY_STORAGE, this.accessKey); - await AsyncStorage.setItem(WAKE_KEYWORD_STORAGE, this.keyword); + /** Settings-Wechsel: anderes Wake-Word. Re-Init des Native-Moduls. */ + async configure(keyword: string): Promise { + const next: WakeKeyword = (WAKE_KEYWORDS as readonly string[]).includes(keyword) + ? (keyword as WakeKeyword) + : DEFAULT_KEYWORD; + this.keyword = next; + await AsyncStorage.setItem(WAKE_KEYWORD_STORAGE, next); - // Laufende Instanz stoppen - await this.disposePorcupine(); - if (!this.accessKey) { - console.warn('[WakeWord] configure: kein Access Key gesetzt'); - return false; - } - - // Neu initialisieren - const ok = await this.initPorcupine(); + // Laufende Instanz stoppen + neu initialisieren + await this.disposeNative(); + const ok = await this.initNative(); if (!ok) { ToastAndroid.show( - `Wake-Word "${this.keyword}" konnte nicht initialisiert werden — Logs pruefen`, + `Wake-Word "${KEYWORD_LABELS[next]}" konnte nicht initialisiert werden — Logs pruefen`, ToastAndroid.LONG, ); } return ok; } - private async initPorcupine(): Promise { + private async initNative(): Promise { + if (!OpenWakeWord) { + console.warn('[WakeWord] OpenWakeWord Native-Modul nicht verfuegbar — Direkt-Aufnahme-Fallback aktiv'); + this.nativeReady = false; + return false; + } if (this.initInProgress) return this.initInProgress; this.initInProgress = (async () => { try { - const porcupineRN = require('@picovoice/porcupine-react-native'); - const { PorcupineManager, BuiltInKeywords } = porcupineRN; - // Manche Porcupine-Versionen wollen das BuiltInKeywords-Enum (Objekt - // mit keys wie JARVIS, COMPUTER, HEY_GOOGLE), andere akzeptieren - // den String direkt. Mappen mit Fallback auf String: - const enumKey = this.keyword.toUpperCase().replace(/\s+/g, '_'); - const kw = (BuiltInKeywords && BuiltInKeywords[enumKey]) || this.keyword; - console.log('[WakeWord] Porcupine init: keyword=%s (resolved=%s)', - this.keyword, typeof kw === 'string' ? kw : '[enum]'); - this.porcupine = await PorcupineManager.fromBuiltInKeywords( - this.accessKey, - [kw], - (keywordIndex: number) => { - console.log('[WakeWord] Porcupine callback fired (index=%d)', keywordIndex); + await OpenWakeWord.init(this.keyword, DEFAULT_THRESHOLD, DEFAULT_PATIENCE, DEFAULT_DEBOUNCE_MS); + // Subscribe nur einmal + if (!this.eventSub) { + const emitter = new NativeEventEmitter(NativeModules.OpenWakeWord); + this.eventSub = emitter.addListener('WakeWordDetected', () => { + console.log('[WakeWord] Native Detection-Event empfangen'); this.onWakeDetected().catch(err => console.warn('[WakeWord] onWakeDetected crashed:', err)); - }, - // Error handler (wenn Porcupine im Background-Thread crashed, - // z.B. beim Audio-Engine-Konflikt mit audio-recorder-player) - (error: any) => { - console.warn('[WakeWord] Porcupine runtime error:', error?.message || error); - // Nicht in Loop crashen — state zurueck auf off damit der User - // mit dem Aufnahme-Button wieder normal arbeiten kann - this.setState('off'); - this.disposePorcupine().catch(() => {}); - }, - ); - console.log('[WakeWord] Porcupine init OK (keyword=%s, manager=%s)', - this.keyword, this.porcupine ? 'created' : 'NULL'); + }); + } + this.nativeReady = true; + console.log('[WakeWord] Init OK (model=%s)', this.keyword); return true; } catch (err: any) { - console.warn('[WakeWord] Porcupine init fehlgeschlagen:', err?.message || err); - console.warn('[WakeWord] err details:', JSON.stringify({ - name: err?.name, code: err?.code, stack: err?.stack?.slice(0, 200), - })); - this.porcupine = null; + console.warn('[WakeWord] Init fehlgeschlagen:', err?.message || err); + this.nativeReady = false; return false; } finally { this.initInProgress = null; @@ -145,27 +141,24 @@ class WakeWordService { return this.initInProgress; } - private async disposePorcupine() { - if (this.porcupine) { - try { await this.porcupine.stop(); } catch {} - try { await this.porcupine.delete(); } catch {} - this.porcupine = null; - } + private async disposeNative(): Promise { + if (!OpenWakeWord) return; + try { await OpenWakeWord.dispose(); } catch {} + this.nativeReady = false; } /** Ohr-Button gedrueckt — startet passives Lauschen oder direkt Konversation. */ async start(): Promise { if (this.state !== 'off') return true; - if (this.porcupine) { - // Passives Lauschen via Porcupine + if (this.nativeReady && OpenWakeWord) { try { - await this.porcupine.start(); - console.log('[WakeWord] armed — warte auf Wake Word "%s"', this.keyword); - ToastAndroid.show(`Lausche auf "${this.keyword}"`, ToastAndroid.SHORT); + await OpenWakeWord.start(); + console.log('[WakeWord] armed — warte auf "%s"', this.keyword); + ToastAndroid.show(`Lausche auf "${KEYWORD_LABELS[this.keyword]}"`, ToastAndroid.SHORT); this.setState('armed'); return true; } catch (err: any) { - console.warn('[WakeWord] Porcupine start fehlgeschlagen — Fallback Direkt-Konversation:', + console.warn('[WakeWord] start fehlgeschlagen — Fallback Direkt-Aufnahme:', err?.message || err); ToastAndroid.show( `Wake-Word-Start failed: ${err?.message || err}`, @@ -173,14 +166,13 @@ class WakeWordService { ); } } else { - // Kein Porcupine init → User explicit informieren - console.warn('[WakeWord] Porcupine nicht initialisiert — Access Key fehlt? Fallback Direkt-Aufnahme'); + console.warn('[WakeWord] Native-Modul nicht bereit — Direkt-Aufnahme-Fallback'); ToastAndroid.show( 'Wake-Word nicht aktiv — direkte Aufnahme startet (Mikro hoert mit)', ToastAndroid.LONG, ); } - // Fallback: direkt in die Konversation (Mikro AKTIV, nicht passive) + // Fallback: direkt in Konversation console.log('[WakeWord] Direkt-Aufnahme startet (kein Wake-Word)'); this.setState('conversing'); setTimeout(() => { @@ -194,21 +186,20 @@ class WakeWordService { /** Komplett ausschalten (Ohr abschalten) */ async stop(): Promise { console.log('[WakeWord] Ohr deaktiviert'); - if (this.porcupine) { - try { await this.porcupine.stop(); } catch {} + if (this.nativeReady && OpenWakeWord) { + try { await OpenWakeWord.stop(); } catch {} } this.setState('off'); } - /** Wake-Word getriggert: Porcupine pausieren, Konversation starten. */ + /** Wake-Word getriggert: Native-Modul pausieren, Konversation starten. */ private async onWakeDetected(): Promise { console.log('[WakeWord] Wake-Word "%s" erkannt!', this.keyword); - ToastAndroid.show(`Wake-Word "${this.keyword}" erkannt — sprich jetzt`, ToastAndroid.SHORT); - if (this.porcupine) { - try { await this.porcupine.stop(); } catch {} + ToastAndroid.show(`Wake-Word "${KEYWORD_LABELS[this.keyword]}" erkannt — sprich jetzt`, ToastAndroid.SHORT); + if (this.nativeReady && OpenWakeWord) { + try { await OpenWakeWord.stop(); } catch {} } this.setState('conversing'); - // kurz warten damit Mikrofon frei ist setTimeout(() => { if (this.state === 'conversing') { this.wakeCallbacks.forEach(cb => cb()); @@ -217,16 +208,16 @@ class WakeWordService { } /** Konversation beenden — User hat im Window nichts gesagt. - * Mit Wake-Word: zurueck zu 'armed' (Porcupine wieder an). + * Mit Wake-Word: zurueck zu 'armed' (Listener wieder an). * Ohne: zurueck zu 'off'. */ async endConversation(): Promise { if (this.state !== 'conversing') return; - if (this.porcupine && this.accessKey) { + if (this.nativeReady && OpenWakeWord) { try { - await this.porcupine.start(); + await OpenWakeWord.start(); console.log('[WakeWord] Konversation zu Ende — zurueck zu armed'); - ToastAndroid.show(`Lausche wieder auf "${this.keyword}"`, ToastAndroid.SHORT); + ToastAndroid.show(`Lausche wieder auf "${KEYWORD_LABELS[this.keyword]}"`, ToastAndroid.SHORT); this.setState('armed'); return; } catch (err) { @@ -259,10 +250,10 @@ class WakeWordService { } hasWakeWord(): boolean { - return !!this.porcupine; + return this.nativeReady; } - getKeyword(): string { + getKeyword(): WakeKeyword { return this.keyword; }