feat(app): Wake-Word komplett on-device via openWakeWord (ONNX)
Picovoice/Porcupine raus — neuer Stack ist openWakeWord (Apache 2.0, on-device, ONNX Runtime). Kein API-Key, keine Lizenzgebuehren, Audio verlaesst das Geraet nicht. Eigene Wake-Words sind via openWakeWord- Notebook gratis trainierbar. Pipeline (alles im OpenWakeWordModule.kt): 1. AudioRecord 16kHz mono int16 in 1280-Sample-Chunks (80ms) 2. melspectrogram.onnx → 32-mel Frames (mel/10 + 2 wie in Python) 3. embedding_model.onnx, 76-Frame Sliding Window (stride 8) → 96-dim 4. hey_jarvis.onnx (oder anderes Keyword) auf letzten 16 Embeddings 5. Sigmoid-Score, threshold/patience/debounce-Filter 6. RN-Event "WakeWordDetected" raus Mitgelieferte Modelle in assets/openwakeword/: hey_jarvis (Default), alexa, hey_mycroft, hey_rhasspy. Externe Service-API (start/stop/ configure/onWakeWord/...) bleibt identisch — ChatScreen unveraendert. build.gradle: com.microsoft.onnxruntime:onnxruntime-android:1.17.1 package.json: @picovoice/porcupine-react-native + voice-processor raus SettingsScreen: AccessKey-Feld weg, neue Keyword-Liste mit Labels README: Wake-Word-Sektion komplett umgeschrieben (kein Picovoice mehr) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
a4d3449e3a
commit
55cfb752a2
76
README.md
76
README.md
|
|
@ -380,7 +380,7 @@ API-Endpoint fuer andere Services: `GET http://localhost:3001/api/session`
|
||||||
- Text-Chat mit ARIA
|
- Text-Chat mit ARIA
|
||||||
- **Sprachaufnahme**: Push-to-Talk (halten) oder Tap-to-Talk (tippen, Auto-Stop bei Stille)
|
- **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
|
- **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.
|
- **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
|
- **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.
|
- **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)
|
- GPS-Position (optional)
|
||||||
- QR-Code Scanner fuer Token-Pairing
|
- 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
|
Wake-Word-Erkennung laeuft komplett **on-device** ueber [openWakeWord](https://github.com/dscripka/openWakeWord)
|
||||||
fuer die Erkennung. Picovoice bietet aktuell einen **7-Tage Free Trial** ohne Kreditkarte
|
mit ONNX Runtime — kein API-Key, kein Cloud-Roundtrip, kein Cent Lizenzgebuehren,
|
||||||
und ohne Auto-Renewal an, danach kostenpflichtig (siehe [picovoice.ai/pricing](https://picovoice.ai/pricing)).
|
und das Audio verlaesst das Geraet nie.
|
||||||
Wer das Wake-Word ueberspringen will: der Ohr-Button funktioniert auch ohne AccessKey
|
|
||||||
(Direkt-Aufnahme statt passivem Lauschen — siehe unten).
|
|
||||||
|
|
||||||
**1) AccessKey holen** (einmalig, ~2 Minuten):
|
**Mitgelieferte Wake-Words** (ONNX-Dateien in `android/android/app/src/main/assets/openwakeword/`):
|
||||||
|
- `Hey Jarvis` (Default)
|
||||||
1. Auf [console.picovoice.ai](https://console.picovoice.ai) registrieren (Email + Passwort, keine Kreditkarte fuer den Trial).
|
- `Alexa`
|
||||||
2. Nach dem Login auf dem Dashboard → **AccessKey** kopieren (langer Base64-String).
|
- `Hey Mycroft`
|
||||||
|
- `Hey Rhasspy`
|
||||||
**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.)*
|
|
||||||
|
|
||||||
**Bedienung:**
|
**Bedienung:**
|
||||||
|
- App → **Einstellungen** → **Wake-Word** → gewuenschtes Keyword waehlen → **Speichern + Aktivieren**
|
||||||
- **Ohr-Button (👂)** in der Statusleiste tippen → Wake-Word ist scharf, App hoert passiv mit
|
- **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 👂
|
- Nach jeder ARIA-Antwort oeffnet sich das Mikro nochmal — Stille → zurueck zu 👂
|
||||||
- Erneut tippen → Ohr aus (🔇)
|
- Erneut tippen → Ohr aus (🔇)
|
||||||
|
|
||||||
**Ohne AccessKey:** Der Ohr-Button startet stattdessen die Direkt-Aufnahme (Mikro
|
**Eigene Wake-Words trainieren** (gratis, ~30 Min):
|
||||||
ist sofort aktiv, kein passives Lauschen). Auch ein gueltiger Modus, nur halt ohne
|
|
||||||
"Hands-free" via Schluesselwort.
|
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)
|
### 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.
|
- **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).
|
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.
|
- **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
|
- **Wake-Word in der App nur eingebaute Keywords**: `Hey Jarvis`, `Alexa`, `Hey Mycroft`,
|
||||||
sofort, eigene Wake-Words (`.ppn` aus der Picovoice Console) muessen aktuell noch manuell
|
`Hey Rhasspy` funktionieren sofort, eigene Wake-Words muessen aktuell noch als
|
||||||
ins App-Bundle. Die Upload-UI in Diagnostic ist Phase 2.
|
`.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.
|
- **Audio-Format**: App nimmt AAC/MP4 auf, Bridge konvertiert via FFmpeg zu 16kHz PCM.
|
||||||
- **RVS Zombie-Connections**: WebSocket-Verbindungen sterben gelegentlich ohne Fehlermeldung.
|
- **RVS Zombie-Connections**: WebSocket-Verbindungen sterben gelegentlich ohne Fehlermeldung.
|
||||||
Bridge hat Ping-Check (5s), Diagnostic nutzt frische Verbindungen pro Request.
|
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] Audio-Pause statt Ducking (TRANSIENT statt MAY_DUCK) + release-Timing fix
|
||||||
- [x] VAD-Stille-Toleranz und Max-Aufnahme einstellbar (1-8s, 120s)
|
- [x] VAD-Stille-Toleranz und Max-Aufnahme einstellbar (1-8s, 120s)
|
||||||
- [x] Disk-Voll Banner in Diagnostic mit copy-baren Cleanup-Befehlen
|
- [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
|
### Phase 2 — ARIA wird produktiv
|
||||||
|
|
||||||
|
|
@ -861,5 +857,5 @@ docker exec aria-core ssh aria-wohnung hostname
|
||||||
- [ ] STARFACE Telefonie-Skill
|
- [ ] STARFACE Telefonie-Skill
|
||||||
- [ ] Desktop Client (Tauri)
|
- [ ] Desktop Client (Tauri)
|
||||||
- [ ] bKVM Remote IT-Support
|
- [ ] 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)
|
- [ ] Claude Vision direkt (Bildanalyse ohne Dateipfad-Umweg)
|
||||||
|
|
|
||||||
|
|
@ -111,6 +111,9 @@ dependencies {
|
||||||
implementation("com.facebook.react:react-android")
|
implementation("com.facebook.react:react-android")
|
||||||
implementation("com.facebook.react:flipper-integration")
|
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()) {
|
if (hermesEnabled.toBoolean()) {
|
||||||
implementation("com.facebook.react:hermes-android")
|
implementation("com.facebook.react:hermes-android")
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -21,6 +21,7 @@ class MainApplication : Application(), ReactApplication {
|
||||||
add(ApkInstallerPackage())
|
add(ApkInstallerPackage())
|
||||||
add(AudioFocusPackage())
|
add(AudioFocusPackage())
|
||||||
add(PcmStreamPlayerPackage())
|
add(PcmStreamPlayerPackage())
|
||||||
|
add(OpenWakeWordPackage())
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getJSMainModuleName(): String = "index"
|
override fun getJSMainModuleName(): String = "index"
|
||||||
|
|
|
||||||
|
|
@ -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<FloatArray> = ArrayList(256) // Liste von 32-dim Frames
|
||||||
|
private var melProcessedIdx: Int = 0
|
||||||
|
private val embBuffer: ArrayDeque<FloatArray> = 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<Array<Array<FloatArray>>>
|
||||||
|
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<FloatArray>
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
val embArr = embOut as Array<FloatArray>
|
||||||
|
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<FloatArray>(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<FloatArray>
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
val score = (wwOut as Array<FloatArray>)[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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<NativeModule> {
|
||||||
|
return listOf(OpenWakeWordModule(reactContext))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> {
|
||||||
|
return emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -24,9 +24,7 @@
|
||||||
"react-native-camera-kit": "^13.0.0",
|
"react-native-camera-kit": "^13.0.0",
|
||||||
"@react-native-async-storage/async-storage": "^1.21.0",
|
"@react-native-async-storage/async-storage": "^1.21.0",
|
||||||
"react-native-fs": "^2.20.0",
|
"react-native-fs": "^2.20.0",
|
||||||
"react-native-audio-recorder-player": "^3.6.7",
|
"react-native-audio-recorder-player": "^3.6.7"
|
||||||
"@picovoice/porcupine-react-native": "3.0.5",
|
|
||||||
"@picovoice/react-native-voice-processor": "1.2.3"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"typescript": "^5.3.3",
|
"typescript": "^5.3.3",
|
||||||
|
|
|
||||||
|
|
@ -41,9 +41,9 @@ import {
|
||||||
TTS_SPEED_STORAGE_KEY,
|
TTS_SPEED_STORAGE_KEY,
|
||||||
} from '../services/audio';
|
} from '../services/audio';
|
||||||
import wakeWordService, {
|
import wakeWordService, {
|
||||||
BUILTIN_KEYWORDS,
|
WAKE_KEYWORDS,
|
||||||
|
KEYWORD_LABELS,
|
||||||
DEFAULT_KEYWORD,
|
DEFAULT_KEYWORD,
|
||||||
WAKE_ACCESS_KEY_STORAGE,
|
|
||||||
WAKE_KEYWORD_STORAGE,
|
WAKE_KEYWORD_STORAGE,
|
||||||
} from '../services/wakeword';
|
} from '../services/wakeword';
|
||||||
import ModeSelector from '../components/ModeSelector';
|
import ModeSelector from '../components/ModeSelector';
|
||||||
|
|
@ -103,8 +103,6 @@ const SettingsScreen: React.FC = () => {
|
||||||
const [vadSilenceSec, setVadSilenceSec] = useState<number>(VAD_SILENCE_DEFAULT_SEC);
|
const [vadSilenceSec, setVadSilenceSec] = useState<number>(VAD_SILENCE_DEFAULT_SEC);
|
||||||
const [convWindowSec, setConvWindowSec] = useState<number>(CONV_WINDOW_DEFAULT_SEC);
|
const [convWindowSec, setConvWindowSec] = useState<number>(CONV_WINDOW_DEFAULT_SEC);
|
||||||
const [ttsSpeed, setTtsSpeed] = useState<number>(TTS_SPEED_DEFAULT);
|
const [ttsSpeed, setTtsSpeed] = useState<number>(TTS_SPEED_DEFAULT);
|
||||||
const [wakeAccessKey, setWakeAccessKey] = useState<string>('');
|
|
||||||
const [wakeAccessKeyVisible, setWakeAccessKeyVisible] = useState(false);
|
|
||||||
const [wakeKeyword, setWakeKeyword] = useState<string>(DEFAULT_KEYWORD);
|
const [wakeKeyword, setWakeKeyword] = useState<string>(DEFAULT_KEYWORD);
|
||||||
const [wakeStatus, setWakeStatus] = useState<string>('');
|
const [wakeStatus, setWakeStatus] = useState<string>('');
|
||||||
const [editingPath, setEditingPath] = useState(false);
|
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);
|
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 => {
|
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 => {
|
AsyncStorage.getItem('aria_xtts_voice').then(saved => {
|
||||||
if (saved) setXttsVoice(saved);
|
if (saved) setXttsVoice(saved);
|
||||||
|
|
@ -678,44 +673,23 @@ const SettingsScreen: React.FC = () => {
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* === Wake-Word (geraetelokal) === */}
|
{/* === Wake-Word (komplett on-device, openWakeWord) === */}
|
||||||
<Text style={styles.sectionTitle}>Wake-Word</Text>
|
<Text style={styles.sectionTitle}>Wake-Word</Text>
|
||||||
<View style={styles.card}>
|
<View style={styles.card}>
|
||||||
<Text style={styles.toggleHint}>
|
<Text style={styles.toggleHint}>
|
||||||
Wenn ein Picovoice-Access-Key eingetragen ist, hoert die App passiv
|
Lokale Erkennung via openWakeWord (ONNX, on-device). Kein API-Key,
|
||||||
auf das gewaehlte Wake-Word — du kannst dich mit anderen unterhalten,
|
kein Cloud-Roundtrip — Audio verlaesst das Geraet nicht. Wenn das Ohr
|
||||||
Musik laufen lassen und mit "{wakeKeyword}" eine Konversation mit
|
aktiv ist, hoerst du normal mit; sagst du das Wake-Word, startet eine
|
||||||
ARIA starten. Ohne Key oder bei Fehlschlag startet das Ohr direkt
|
Konversation mit ARIA.
|
||||||
eine Konversation (klassischer Modus).
|
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Text style={[styles.toggleLabel, {marginTop: 16}]}>Picovoice Access Key</Text>
|
|
||||||
<View style={{flexDirection: 'row', alignItems: 'center', gap: 8, marginTop: 6}}>
|
|
||||||
<TextInput
|
|
||||||
style={[styles.input, {flex: 1}]}
|
|
||||||
value={wakeAccessKey}
|
|
||||||
onChangeText={setWakeAccessKey}
|
|
||||||
placeholder="kostenlos auf console.picovoice.ai"
|
|
||||||
placeholderTextColor="#666680"
|
|
||||||
secureTextEntry={!wakeAccessKeyVisible}
|
|
||||||
autoCapitalize="none"
|
|
||||||
autoCorrect={false}
|
|
||||||
/>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => setWakeAccessKeyVisible(v => !v)}
|
|
||||||
style={{padding: 8}}
|
|
||||||
>
|
|
||||||
<Text style={{fontSize: 18}}>{wakeAccessKeyVisible ? '🙈' : '👁'}</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<Text style={[styles.toggleLabel, {marginTop: 16}]}>Wake-Word</Text>
|
<Text style={[styles.toggleLabel, {marginTop: 16}]}>Wake-Word</Text>
|
||||||
<Text style={styles.toggleHint}>
|
<Text style={styles.toggleHint}>
|
||||||
Built-In: sofort verwendbar. "ARIA" als Custom-Keyword kommt spaeter
|
Eigene Wake-Words via openWakeWord-Notebook trainierbar (gratis).
|
||||||
ueber Diagnostic-Upload.
|
Custom-Upload ueber Diagnostic kommt in einer spaeteren Version.
|
||||||
</Text>
|
</Text>
|
||||||
<View style={{flexDirection: 'row', flexWrap: 'wrap', gap: 6, marginTop: 8}}>
|
<View style={{flexDirection: 'row', flexWrap: 'wrap', gap: 6, marginTop: 8}}>
|
||||||
{BUILTIN_KEYWORDS.map(kw => (
|
{WAKE_KEYWORDS.map(kw => (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
key={kw}
|
key={kw}
|
||||||
style={[
|
style={[
|
||||||
|
|
@ -728,7 +702,7 @@ const SettingsScreen: React.FC = () => {
|
||||||
styles.keywordChipText,
|
styles.keywordChipText,
|
||||||
wakeKeyword === kw && styles.keywordChipTextActive,
|
wakeKeyword === kw && styles.keywordChipTextActive,
|
||||||
]}>
|
]}>
|
||||||
{kw}
|
{KEYWORD_LABELS[kw]}
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
))}
|
))}
|
||||||
|
|
@ -740,8 +714,8 @@ const SettingsScreen: React.FC = () => {
|
||||||
onPress={async () => {
|
onPress={async () => {
|
||||||
setWakeStatus('Initialisiere...');
|
setWakeStatus('Initialisiere...');
|
||||||
try {
|
try {
|
||||||
const ok = await wakeWordService.configure(wakeAccessKey, wakeKeyword);
|
const ok = await wakeWordService.configure(wakeKeyword);
|
||||||
setWakeStatus(ok ? `✅ "${wakeKeyword}" bereit` : '❌ Fehlgeschlagen — Access Key pruefen');
|
setWakeStatus(ok ? `✅ "${KEYWORD_LABELS[wakeKeyword as keyof typeof KEYWORD_LABELS]}" bereit` : '❌ Init-Fehler — Logs pruefen');
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setWakeStatus('❌ ' + String(err?.message || err).slice(0, 80));
|
setWakeStatus('❌ ' + String(err?.message || err).slice(0, 80));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,142 +1,138 @@
|
||||||
/**
|
/**
|
||||||
* Gespraechsmodus / Wake Word Service
|
* 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:
|
* Drei Zustaende:
|
||||||
* off — Ohr aus, nichts laeuft
|
* off — Ohr aus, nichts laeuft
|
||||||
* armed — Ohr aktiv, Porcupine hoert passiv auf das Wake-Word.
|
* armed — Ohr aktiv, openWakeWord hoert passiv auf das Wake-Word.
|
||||||
* Das Mikro ist von Porcupine belegt; AudioRecorder ist aus.
|
* Das Mikro ist von OpenWakeWord belegt; AudioRecorder ist aus.
|
||||||
* conversing — Wake-Word getriggert (oder Ohr-Tap ohne Wake-Word):
|
* conversing — Wake-Word getriggert (oder Ohr-Tap manuell):
|
||||||
* aktive Konversation. Porcupine pausiert (gibt Mikro frei),
|
* aktive Konversation. OpenWakeWord pausiert (gibt Mikro frei),
|
||||||
* AudioRecorder uebernimmt fuer die Aufnahme.
|
* AudioRecorder uebernimmt fuer die Aufnahme.
|
||||||
* Nach jeder ARIA-Antwort oeffnet das Mikro fuer X Sekunden
|
* Nach jeder ARIA-Antwort oeffnet das Mikro fuer X Sekunden
|
||||||
* (Conversation-Window). Stille im Fenster → zurueck zu armed.
|
* (Conversation-Window). Stille im Fenster → zurueck zu armed.
|
||||||
*
|
*
|
||||||
* Wake-Word fallback: ist kein Picovoice-Access-Key gesetzt, geht 'start'
|
* Faellt das Native-Modul aus (alte App-Version, ONNX-Init-Fehler), geht
|
||||||
* direkt in 'conversing' (klassischer Gespraechsmodus). 'endConversation'
|
* 'start' direkt in 'conversing' (klassischer Direkt-Aufnahme-Modus).
|
||||||
* geht dann nach 'off' statt 'armed'.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { NativeEventEmitter, NativeModules, ToastAndroid } from 'react-native';
|
||||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
import { ToastAndroid } from 'react-native';
|
|
||||||
|
|
||||||
type WakeWordCallback = () => void;
|
type WakeWordCallback = () => void;
|
||||||
type StateCallback = (state: WakeWordState) => void;
|
type StateCallback = (state: WakeWordState) => void;
|
||||||
|
|
||||||
export type WakeWordState = 'off' | 'armed' | 'conversing';
|
export type WakeWordState = 'off' | 'armed' | 'conversing';
|
||||||
|
|
||||||
export const WAKE_ACCESS_KEY_STORAGE = 'aria_wake_access_key';
|
|
||||||
export const WAKE_KEYWORD_STORAGE = 'aria_wake_keyword';
|
export const WAKE_KEYWORD_STORAGE = 'aria_wake_keyword';
|
||||||
|
|
||||||
/** Built-In Keywords von Picovoice — pre-trained, sofort einsetzbar.
|
/** Verfuegbare Wake-Words — entsprechen den .onnx Dateien in
|
||||||
* Custom Keywords (z.B. "ARIA") brauchen ein .ppn File aus der Picovoice
|
* android/app/src/main/assets/openwakeword/. Custom-Keywords (eigenes
|
||||||
* Console — wird spaeter ueber Diagnostic uploadbar. */
|
* Training via openwakeword Notebook) muessen aktuell als Asset eingebaut
|
||||||
export const BUILTIN_KEYWORDS = [
|
* werden — Diagnostic-Upload ist Phase 2. */
|
||||||
'jarvis',
|
export const WAKE_KEYWORDS = [
|
||||||
'computer',
|
'hey_jarvis',
|
||||||
'picovoice',
|
|
||||||
'porcupine',
|
|
||||||
'bumblebee',
|
|
||||||
'terminator',
|
|
||||||
'alexa',
|
'alexa',
|
||||||
'hey google',
|
'hey_mycroft',
|
||||||
'ok google',
|
'hey_rhasspy',
|
||||||
'hey siri',
|
|
||||||
] as const;
|
] as const;
|
||||||
export type BuiltinKeyword = typeof BUILTIN_KEYWORDS[number];
|
export type WakeKeyword = typeof WAKE_KEYWORDS[number];
|
||||||
export const DEFAULT_KEYWORD: BuiltinKeyword = 'jarvis';
|
export const DEFAULT_KEYWORD: WakeKeyword = 'hey_jarvis';
|
||||||
|
|
||||||
|
/** Hilfs-Mapping fuer die Anzeige im UI. */
|
||||||
|
export const KEYWORD_LABELS: Record<WakeKeyword, string> = {
|
||||||
|
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<boolean>;
|
||||||
|
start(): Promise<boolean>;
|
||||||
|
stop(): Promise<boolean>;
|
||||||
|
dispose(): Promise<boolean>;
|
||||||
|
isAvailable(): Promise<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { OpenWakeWord } = NativeModules as { OpenWakeWord?: OpenWakeWordModule };
|
||||||
|
|
||||||
class WakeWordService {
|
class WakeWordService {
|
||||||
private state: WakeWordState = 'off';
|
private state: WakeWordState = 'off';
|
||||||
private wakeCallbacks: WakeWordCallback[] = [];
|
private wakeCallbacks: WakeWordCallback[] = [];
|
||||||
private stateCallbacks: StateCallback[] = [];
|
private stateCallbacks: StateCallback[] = [];
|
||||||
|
|
||||||
// Picovoice Manager (lazy, da Native Module nicht in jedem Build verfuegbar ist)
|
private keyword: WakeKeyword = DEFAULT_KEYWORD;
|
||||||
private porcupine: any = null;
|
private nativeReady: boolean = false;
|
||||||
private accessKey: string = '';
|
|
||||||
private keyword: string = DEFAULT_KEYWORD;
|
|
||||||
private initInProgress: Promise<boolean> | null = null;
|
private initInProgress: Promise<boolean> | 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<void> {
|
async loadFromStorage(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const k = await AsyncStorage.getItem(WAKE_ACCESS_KEY_STORAGE);
|
|
||||||
const w = await AsyncStorage.getItem(WAKE_KEYWORD_STORAGE);
|
const w = await AsyncStorage.getItem(WAKE_KEYWORD_STORAGE);
|
||||||
this.accessKey = (k || '').trim();
|
const wt = (w || DEFAULT_KEYWORD).trim() as WakeKeyword;
|
||||||
this.keyword = (w || DEFAULT_KEYWORD).trim();
|
this.keyword = (WAKE_KEYWORDS as readonly string[]).includes(wt) ? wt : DEFAULT_KEYWORD;
|
||||||
if (this.accessKey) {
|
await this.initNative();
|
||||||
// Vorinitialisieren — wirft sich nicht durch wenn etwas fehlt
|
|
||||||
await this.initPorcupine();
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('[WakeWord] loadFromStorage', err);
|
console.warn('[WakeWord] loadFromStorage', err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Settings-Wechsel — neuer Key oder Keyword. Re-Init Porcupine. */
|
/** Settings-Wechsel: anderes Wake-Word. Re-Init des Native-Moduls. */
|
||||||
async configure(accessKey: string, keyword: string): Promise<boolean> {
|
async configure(keyword: string): Promise<boolean> {
|
||||||
this.accessKey = (accessKey || '').trim();
|
const next: WakeKeyword = (WAKE_KEYWORDS as readonly string[]).includes(keyword)
|
||||||
this.keyword = (keyword || DEFAULT_KEYWORD).trim();
|
? (keyword as WakeKeyword)
|
||||||
await AsyncStorage.setItem(WAKE_ACCESS_KEY_STORAGE, this.accessKey);
|
: DEFAULT_KEYWORD;
|
||||||
await AsyncStorage.setItem(WAKE_KEYWORD_STORAGE, this.keyword);
|
this.keyword = next;
|
||||||
|
await AsyncStorage.setItem(WAKE_KEYWORD_STORAGE, next);
|
||||||
|
|
||||||
// Laufende Instanz stoppen
|
// Laufende Instanz stoppen + neu initialisieren
|
||||||
await this.disposePorcupine();
|
await this.disposeNative();
|
||||||
if (!this.accessKey) {
|
const ok = await this.initNative();
|
||||||
console.warn('[WakeWord] configure: kein Access Key gesetzt');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Neu initialisieren
|
|
||||||
const ok = await this.initPorcupine();
|
|
||||||
if (!ok) {
|
if (!ok) {
|
||||||
ToastAndroid.show(
|
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,
|
ToastAndroid.LONG,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return ok;
|
return ok;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async initPorcupine(): Promise<boolean> {
|
private async initNative(): Promise<boolean> {
|
||||||
|
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;
|
if (this.initInProgress) return this.initInProgress;
|
||||||
this.initInProgress = (async () => {
|
this.initInProgress = (async () => {
|
||||||
try {
|
try {
|
||||||
const porcupineRN = require('@picovoice/porcupine-react-native');
|
await OpenWakeWord.init(this.keyword, DEFAULT_THRESHOLD, DEFAULT_PATIENCE, DEFAULT_DEBOUNCE_MS);
|
||||||
const { PorcupineManager, BuiltInKeywords } = porcupineRN;
|
// Subscribe nur einmal
|
||||||
// Manche Porcupine-Versionen wollen das BuiltInKeywords-Enum (Objekt
|
if (!this.eventSub) {
|
||||||
// mit keys wie JARVIS, COMPUTER, HEY_GOOGLE), andere akzeptieren
|
const emitter = new NativeEventEmitter(NativeModules.OpenWakeWord);
|
||||||
// den String direkt. Mappen mit Fallback auf String:
|
this.eventSub = emitter.addListener('WakeWordDetected', () => {
|
||||||
const enumKey = this.keyword.toUpperCase().replace(/\s+/g, '_');
|
console.log('[WakeWord] Native Detection-Event empfangen');
|
||||||
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);
|
|
||||||
this.onWakeDetected().catch(err =>
|
this.onWakeDetected().catch(err =>
|
||||||
console.warn('[WakeWord] onWakeDetected crashed:', 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)
|
this.nativeReady = true;
|
||||||
(error: any) => {
|
console.log('[WakeWord] Init OK (model=%s)', this.keyword);
|
||||||
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');
|
|
||||||
return true;
|
return true;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.warn('[WakeWord] Porcupine init fehlgeschlagen:', err?.message || err);
|
console.warn('[WakeWord] Init fehlgeschlagen:', err?.message || err);
|
||||||
console.warn('[WakeWord] err details:', JSON.stringify({
|
this.nativeReady = false;
|
||||||
name: err?.name, code: err?.code, stack: err?.stack?.slice(0, 200),
|
|
||||||
}));
|
|
||||||
this.porcupine = null;
|
|
||||||
return false;
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
this.initInProgress = null;
|
this.initInProgress = null;
|
||||||
|
|
@ -145,27 +141,24 @@ class WakeWordService {
|
||||||
return this.initInProgress;
|
return this.initInProgress;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async disposePorcupine() {
|
private async disposeNative(): Promise<void> {
|
||||||
if (this.porcupine) {
|
if (!OpenWakeWord) return;
|
||||||
try { await this.porcupine.stop(); } catch {}
|
try { await OpenWakeWord.dispose(); } catch {}
|
||||||
try { await this.porcupine.delete(); } catch {}
|
this.nativeReady = false;
|
||||||
this.porcupine = null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Ohr-Button gedrueckt — startet passives Lauschen oder direkt Konversation. */
|
/** Ohr-Button gedrueckt — startet passives Lauschen oder direkt Konversation. */
|
||||||
async start(): Promise<boolean> {
|
async start(): Promise<boolean> {
|
||||||
if (this.state !== 'off') return true;
|
if (this.state !== 'off') return true;
|
||||||
if (this.porcupine) {
|
if (this.nativeReady && OpenWakeWord) {
|
||||||
// Passives Lauschen via Porcupine
|
|
||||||
try {
|
try {
|
||||||
await this.porcupine.start();
|
await OpenWakeWord.start();
|
||||||
console.log('[WakeWord] armed — warte auf Wake Word "%s"', this.keyword);
|
console.log('[WakeWord] armed — warte auf "%s"', this.keyword);
|
||||||
ToastAndroid.show(`Lausche auf "${this.keyword}"`, ToastAndroid.SHORT);
|
ToastAndroid.show(`Lausche auf "${KEYWORD_LABELS[this.keyword]}"`, ToastAndroid.SHORT);
|
||||||
this.setState('armed');
|
this.setState('armed');
|
||||||
return true;
|
return true;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.warn('[WakeWord] Porcupine start fehlgeschlagen — Fallback Direkt-Konversation:',
|
console.warn('[WakeWord] start fehlgeschlagen — Fallback Direkt-Aufnahme:',
|
||||||
err?.message || err);
|
err?.message || err);
|
||||||
ToastAndroid.show(
|
ToastAndroid.show(
|
||||||
`Wake-Word-Start failed: ${err?.message || err}`,
|
`Wake-Word-Start failed: ${err?.message || err}`,
|
||||||
|
|
@ -173,14 +166,13 @@ class WakeWordService {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Kein Porcupine init → User explicit informieren
|
console.warn('[WakeWord] Native-Modul nicht bereit — Direkt-Aufnahme-Fallback');
|
||||||
console.warn('[WakeWord] Porcupine nicht initialisiert — Access Key fehlt? Fallback Direkt-Aufnahme');
|
|
||||||
ToastAndroid.show(
|
ToastAndroid.show(
|
||||||
'Wake-Word nicht aktiv — direkte Aufnahme startet (Mikro hoert mit)',
|
'Wake-Word nicht aktiv — direkte Aufnahme startet (Mikro hoert mit)',
|
||||||
ToastAndroid.LONG,
|
ToastAndroid.LONG,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// Fallback: direkt in die Konversation (Mikro AKTIV, nicht passive)
|
// Fallback: direkt in Konversation
|
||||||
console.log('[WakeWord] Direkt-Aufnahme startet (kein Wake-Word)');
|
console.log('[WakeWord] Direkt-Aufnahme startet (kein Wake-Word)');
|
||||||
this.setState('conversing');
|
this.setState('conversing');
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
|
@ -194,21 +186,20 @@ class WakeWordService {
|
||||||
/** Komplett ausschalten (Ohr abschalten) */
|
/** Komplett ausschalten (Ohr abschalten) */
|
||||||
async stop(): Promise<void> {
|
async stop(): Promise<void> {
|
||||||
console.log('[WakeWord] Ohr deaktiviert');
|
console.log('[WakeWord] Ohr deaktiviert');
|
||||||
if (this.porcupine) {
|
if (this.nativeReady && OpenWakeWord) {
|
||||||
try { await this.porcupine.stop(); } catch {}
|
try { await OpenWakeWord.stop(); } catch {}
|
||||||
}
|
}
|
||||||
this.setState('off');
|
this.setState('off');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Wake-Word getriggert: Porcupine pausieren, Konversation starten. */
|
/** Wake-Word getriggert: Native-Modul pausieren, Konversation starten. */
|
||||||
private async onWakeDetected(): Promise<void> {
|
private async onWakeDetected(): Promise<void> {
|
||||||
console.log('[WakeWord] Wake-Word "%s" erkannt!', this.keyword);
|
console.log('[WakeWord] Wake-Word "%s" erkannt!', this.keyword);
|
||||||
ToastAndroid.show(`Wake-Word "${this.keyword}" erkannt — sprich jetzt`, ToastAndroid.SHORT);
|
ToastAndroid.show(`Wake-Word "${KEYWORD_LABELS[this.keyword]}" erkannt — sprich jetzt`, ToastAndroid.SHORT);
|
||||||
if (this.porcupine) {
|
if (this.nativeReady && OpenWakeWord) {
|
||||||
try { await this.porcupine.stop(); } catch {}
|
try { await OpenWakeWord.stop(); } catch {}
|
||||||
}
|
}
|
||||||
this.setState('conversing');
|
this.setState('conversing');
|
||||||
// kurz warten damit Mikrofon frei ist
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (this.state === 'conversing') {
|
if (this.state === 'conversing') {
|
||||||
this.wakeCallbacks.forEach(cb => cb());
|
this.wakeCallbacks.forEach(cb => cb());
|
||||||
|
|
@ -217,16 +208,16 @@ class WakeWordService {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Konversation beenden — User hat im Window nichts gesagt.
|
/** 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'.
|
* Ohne: zurueck zu 'off'.
|
||||||
*/
|
*/
|
||||||
async endConversation(): Promise<void> {
|
async endConversation(): Promise<void> {
|
||||||
if (this.state !== 'conversing') return;
|
if (this.state !== 'conversing') return;
|
||||||
if (this.porcupine && this.accessKey) {
|
if (this.nativeReady && OpenWakeWord) {
|
||||||
try {
|
try {
|
||||||
await this.porcupine.start();
|
await OpenWakeWord.start();
|
||||||
console.log('[WakeWord] Konversation zu Ende — zurueck zu armed');
|
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');
|
this.setState('armed');
|
||||||
return;
|
return;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -259,10 +250,10 @@ class WakeWordService {
|
||||||
}
|
}
|
||||||
|
|
||||||
hasWakeWord(): boolean {
|
hasWakeWord(): boolean {
|
||||||
return !!this.porcupine;
|
return this.nativeReady;
|
||||||
}
|
}
|
||||||
|
|
||||||
getKeyword(): string {
|
getKeyword(): WakeKeyword {
|
||||||
return this.keyword;
|
return this.keyword;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue