Compare commits

...

15 Commits

Author SHA1 Message Date
duffyduck b1ccf29295 release: bump version to 0.0.7.1 2026-05-03 22:11:08 +02:00
duffyduck 4cd9faece2 release: bump version to 0.0.7.0 2026-05-03 21:59:38 +02:00
duffyduck fec8aa977b feat(audio): TTS pausiert bei Anruf + Conversation-Focus haelt Spotify durchgehend gepaust
Bug 1a — Anruf-Pause:
Neues PhoneCallModule.kt nutzt TelephonyCallback (API 31+) bzw.
PhoneStateListener (Pre-12) um auf RINGING/OFFHOOK/IDLE zu reagieren.
Bei Klingeln/Gespraech ruft phoneCall.ts → audioService.haltAllPlayback,
ARIA verstummt sofort. READ_PHONE_STATE Permission wird beim ersten
Start angefragt; ohne Permission failt der Listener leise.

Bug 1b — Spotify-Resume:
AudioFocus wird jetzt an den Conversation-Lifecycle gekoppelt statt an
einzelne Streams. Solange wakeWordState 'conversing' ist, blockt
acquireConversationFocus() jeden per-Stream-Release. Erst beim Wechsel
auf 'armed'/'off' darf der Focus tatsaechlich freigegeben werden.
Verhindert das "Spotify kommt nach 10s wieder hoch"-Phaenomen auch
ueber Render-Pausen + zwischen mehreren ARIA-Antworten hinweg.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 21:44:58 +02:00
duffyduck 20123de827 fix: Sprachnachricht-Bubble defensiv + Bild+Text als eine Anfrage
Bug 2: STT-Result schreibt jetzt eine neue User-Bubble wenn keine
Placeholder im State gefunden wird (statt das Update zu verwerfen).
Schuetzt vor Race-Conditions zwischen audio-send und State-Updates,
damit der gesprochene Text immer im Chat erscheint.

Bug 3: Bild + Text wurden als zwei getrennte Events ('file' + 'chat')
gesendet, jeder triggerte einen eigenen send_to_core. ARIA antwortete
zweimal — einmal "warte auf Anweisung" beim Bild, dann nochmal auf
den Text. Bridge buffert jetzt eingehende file-Events 800ms; kommt in
dem Fenster ein chat, werden alle Files + Text zu einer einzigen
aria-core-Nachricht gemerged. Kein chat → Files alleine wie bisher.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 21:40:15 +02:00
duffyduck 8761d1a1b7 release: bump version to 0.0.6.9 2026-05-01 00:08:08 +02:00
duffyduck abc5b971f4 fix(voice): Stimmen-Wechsel greift wieder — Override bleibt bis naechster Chat-Event
Bug: Voice-Override wurde nach der ersten ARIA-Antwort konsumiert.
Eine ARIA-Antwort triggert aber oft mehrere TTS-Calls (Tool-Use →
Zwischenmeldung → finale Antwort). Der erste nutzte die neue Stimme,
alle folgenden fielen auf self.xtts_voice (= alte Voice aus
voice_config.json) zurueck. Die App schickt nie ein config-Update,
daher blieb voice_config.json fuer immer auf der alten Stimme.

Neue Semantik:
- chat-/audio-Event mit voice="X" → Override="X", gilt fuer alle
  folgenden TTS-Calls bis zum naechsten chat-Event
- chat-Event mit voice="" → Override geloescht, fallback auf
  Default-Voice (voice_config.json / Diagnostic)
- chat-Event ohne voice-Field → Override unveraendert

Audio-Send in ChatScreen.tsx (Push-to-Talk-Pfad) gab voice/speed
gar nicht mit; jetzt konsistent mit dem Tap-to-Talk-Pfad.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 20:04:19 +02:00
duffyduck b588dd7e3b release: bump version to 0.0.6.8 2026-04-26 13:26:00 +02:00
duffyduck 309df9d851 fix(wake-word): Embedding-Output ist rank-4, nicht rank-2 — Trigger funktioniert jetzt
Hauptursache warum kein Wake-Word je triggerte: das Google-Speech-
Embedding-Modell liefert (1,1,1,96), nicht (1,96). Der Cast
`as Array<FloatArray>` warf eine ClassCastException, die vom try/catch
geschluckt wurde — Pipeline lief still ins Leere.

Zusaetzlich:
- WW-Input-Frame-Count wird jetzt aus den Modell-Metadaten gelesen
  (variiert pro Keyword; hey_jarvis=16, computer_v2evtl. anders)
- "Computer" als Wake-Word erweitert (Community-Modell aus
  fwartner/home-assistant-wakewords-collection)

"ARIA" als Wake-Word: gibt's nicht fertig trainiert. Muesste ueber
das openWakeWord Colab-Notebook trainiert werden (~1h auf gratis-GPU).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 13:24:47 +02:00
duffyduck f2e643d1fb fix(app): Underrun-Schutz im PcmStreamPlayer — Spotify resumed nicht mehr nach 10s
Wenn die Bridge zwischen zwei Saetzen rendert (1-2s pro Satz auf der
Gamebox-RTX 3060), kommen keine neuen PCM-Chunks rein und der AudioTrack-
Buffer laeuft leer. Spotify hat eine eigene Heuristik die nach ~10s
"stummer Lücke" eigenmaechtig die Wiedergabe wiederaufnimmt — auch wenn
wir den AudioFocus formal noch halten.

Fix: Writer-Thread fuettert Stille rein wenn der Puffer unter ~100ms
faellt (~50ms pro Refill-Tick alle 50ms). AudioTrack bleibt damit
durchgehend aktiv, andere Apps respektieren weiterhin den Fokus.

Bonus: 30s-Idle-Cutoff falls die Bridge crashed und kein final-Marker
mehr kommt — sonst wuerde der Writer-Thread ewig Stille fuettern.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 13:18:25 +02:00
duffyduck 6ac374621c release: bump version to 0.0.6.7 2026-04-26 13:08:13 +02:00
duffyduck efbd306597 build(android): ABI-Split auf arm64-v8a — APK von 136 MB auf ~35 MB
Mit ONNX Runtime fuer das Wake-Word kommen Native-Libs fuer alle 4
Architekturen rein (arm64-v8a, armeabi-v7a, x86, x86_64). Das
sprengt sowohl den Gitea-Upload (nginx-Limit) als auch unnoetig die
Auto-Update-Downloads aufs Phone. Per ABI-Split jetzt nur noch
arm64-v8a — deckt jedes Android-Phone seit 2017 ab.

build.sh greift den neuen APK-Pfad (app-arm64-v8a-release.apk),
faellt auf app-release.apk zurueck falls die Splits in build.gradle
deaktiviert werden.

versionCode 606 / versionName 0.0.6.6 (vom Linter mitgehoben).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 13:04:32 +02:00
duffyduck 4454613a98 release: bump version to 0.0.6.6 2026-04-26 12:59:26 +02:00
duffyduck 55cfb752a2 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>
2026-04-26 12:56:33 +02:00
duffyduck a4d3449e3a release: bump version to 0.0.6.5 2026-04-25 22:51:00 +02:00
duffyduck 44d2c6b4fe fix(app): Spotify-Bounce zwischen ARIA-Antworten + Wake-Word-Doku
AudioFocus wird jetzt mit 800ms Verzoegerung freigegeben — wenn ARIA
direkt eine zweite Antwort hinterherschickt oder das Recording ins TTS
uebergeht, wird das Release abgebrochen. Spotify/YouTube haben damit
keine Mikro-Sekunden-Luecke mehr zum Hochkommen waehrend ARIA spricht.

README: neue Sektion zur Wake-Word-Einrichtung mit Picovoice
(7-Tage-Trial, Console-Link, Anleitung fuer eigene Keywords) und
veraltete Wake-Word-Limitation entfernt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 22:49:45 +02:00
24 changed files with 1114 additions and 256 deletions

View File

@ -380,6 +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** (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.08.0s, Default 2.8s) bevor Auto-Stop greift. Max-Aufnahme 120s. - **VAD (Voice Activity Detection)**: Konfigurierbare Stille-Toleranz (1.08.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.
@ -398,6 +399,45 @@ 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 (openWakeWord, on-device)
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.
**Mitgelieferte Wake-Words** (ONNX-Dateien in `android/android/app/src/main/assets/openwakeword/`):
- `Hey Jarvis` (Default, openWakeWord-Original)
- `Computer` (Star-Trek-Style, Community-Modell)
- `Alexa`, `Hey Mycroft`, `Hey Rhasspy` (openWakeWord-Originale)
Community-Modelle stammen aus [fwartner/home-assistant-wakewords-collection](https://github.com/fwartner/home-assistant-wakewords-collection).
**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 🎙️, Konversation laeuft
- Nach jeder ARIA-Antwort oeffnet sich das Mikro nochmal — Stille → zurueck zu 👂
- Erneut tippen → Ohr aus (🔇)
**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.60.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)
```bash ```bash
@ -744,8 +784,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 nur auf VM**: Die Bridge hoert auf "ARIA" ueber das lokale Mikrofon der VM. - **Wake-Word in der App nur eingebaute Keywords**: `Hey Jarvis`, `Alexa`, `Hey Mycroft`,
In der App gibt es Energy-basierte Erkennung (Phase 1). On-device "ARIA"-Keyword (Porcupine) ist Phase 2. `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. - **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.
@ -800,6 +842,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] Wake-Word on-device via openWakeWord (ONNX Runtime, kein API-Key) + State-Icon
### Phase 2 — ARIA wird produktiv ### Phase 2 — ARIA wird produktiv
@ -815,5 +858,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
- [ ] Porcupine Wake Word (on-device "ARIA" in der App) - [ ] 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)

View File

@ -79,8 +79,8 @@ android {
applicationId "com.ariacockpit" applicationId "com.ariacockpit"
minSdkVersion rootProject.ext.minSdkVersion minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 604 versionCode 701
versionName "0.0.6.4" versionName "0.0.7.1"
// Fallback fuer Libraries mit Product Flavors // Fallback fuer Libraries mit Product Flavors
missingDimensionStrategy 'react-native-camera', 'general' missingDimensionStrategy 'react-native-camera', 'general'
} }
@ -104,6 +104,19 @@ android {
proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro" proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro"
} }
} }
// ABI-Split: nur arm64-v8a (jedes Android-Phone seit ~2017). Bringt die
// APK von ~136 MB auf ~35 MB relevant weil ONNX Runtime + die anderen
// Native-Libs sonst pro Architektur dazukommen. Wer 32-bit oder Emulator
// braucht, kann hier "armeabi-v7a", "x86_64" etc. ergaenzen.
splits {
abi {
enable true
reset()
include "arm64-v8a"
universalApk false
}
}
} }
dependencies { dependencies {
@ -111,6 +124,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 {

View File

@ -4,6 +4,8 @@
<uses-permission android:name="android.permission.CAMERA" /> <uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" /> <uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" /> <uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<!-- Anruf-State lesen damit TTS bei klingelndem Telefon pausiert -->
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
<application <application
android:name=".MainApplication" android:name=".MainApplication"

View File

@ -21,6 +21,8 @@ class MainApplication : Application(), ReactApplication {
add(ApkInstallerPackage()) add(ApkInstallerPackage())
add(AudioFocusPackage()) add(AudioFocusPackage())
add(PcmStreamPlayerPackage()) add(PcmStreamPlayerPackage())
add(OpenWakeWordPackage())
add(PhoneCallPackage())
} }
override fun getJSMainModuleName(): String = "index" override fun getJSMainModuleName(): String = "index"

View File

@ -0,0 +1,369 @@
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 MEL_BINS = 32
private const val DEFAULT_WW_INPUT_FRAMES = 16 // Fallback wenn Modell-Metadata fehlt
}
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"
// Anzahl Embedding-Frames die der Wake-Word-Klassifikator pro Inferenz erwartet —
// hey_jarvis hat 16, andere Community-Modelle koennen abweichen (z.B. 28).
// Wird beim init() aus den Modell-Metadaten gelesen.
private var wwInputFrames: Int = DEFAULT_WW_INPUT_FRAMES
// 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()
// WW-Input-Frame-Count aus dem Modell lesen — variiert pro Keyword.
// Erwartete Form: (1, N, 96), N steht in der Modell-Metadaten.
val wwInputInfo = wwSession!!.inputInfo[wwInputName]
val wwShape = (wwInputInfo?.info as? ai.onnxruntime.TensorInfo)?.shape
wwInputFrames = wwShape?.getOrNull(1)?.toInt()?.takeIf { it > 0 } ?: DEFAULT_WW_INPUT_FRAMES
Log.i(TAG, "Init OK: model=$modelName wwFrames=$wwInputFrames 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, 1, 1, 96) — rank-4, NICHT (1, 96).
// Die Google-Embedding-Pipeline behaelt extra Dimensionen.
@Suppress("UNCHECKED_CAST")
val embArr = embOut as Array<Array<Array<FloatArray>>>
embBuffer.addLast(embArr[0][0][0].copyOf())
while (embBuffer.size > wwInputFrames) 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 < wwInputFrames) return
val flatEmb = FloatArray(wwInputFrames * EMBEDDING_DIM)
var p = 0
// Letzte wwInputFrames Embeddings nehmen (embBuffer ist auf wwInputFrames begrenzt)
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, wwInputFrames.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
}
}
}

View File

@ -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()
}
}

View File

@ -137,6 +137,17 @@ class PcmStreamPlayerModule(reactContext: ReactApplicationContext) : ReactContex
Log.w(TAG, "play() sofort failed: ${e.message}") Log.w(TAG, "play() sofort failed: ${e.message}")
} }
} }
// Idle-Cutoff: wenn endRequested NICHT kam aber 30s nichts mehr
// reinkommt, brechen wir ab (Bridge-Crash, verlorener final).
var idleMs = 0L
val maxIdleMs = 30_000L
// Zielpufferfuellung — unter diesem Wasserstand fuettern wir
// Stille rein damit AudioTrack nicht underrunt waehrend die
// Bridge den naechsten Satz rendert. Spotify/YouTube reagieren
// sonst mit eigenmaechtiger Wiederaufnahme nach ~10s Stille.
val underrunGuardFrames = sampleRate / 10 // ~100ms
val silenceFillFrames = sampleRate / 20 // ~50ms pro Refill
mainLoop@ while (!writerShouldStop) { mainLoop@ while (!writerShouldStop) {
val data = queue.poll(50, java.util.concurrent.TimeUnit.MILLISECONDS) val data = queue.poll(50, java.util.concurrent.TimeUnit.MILLISECONDS)
if (data == null) { if (data == null) {
@ -153,8 +164,33 @@ class PcmStreamPlayerModule(reactContext: ReactApplicationContext) : ReactContex
} }
break@mainLoop break@mainLoop
} }
// Underrun-Schutz: Stille reinfuettern wenn der AudioTrack-
// Puffer leerzulaufen droht. Spotify resumed sonst nach
// ~10s Pause auf eigene Faust, obwohl wir den Fokus halten.
if (playbackStarted) {
val framesWritten = bytesBuffered / streamBytesPerFrame
val framesPlayed = t.playbackHeadPosition.toLong()
val framesInBuffer = framesWritten - framesPlayed
if (framesInBuffer < underrunGuardFrames) {
val fillBytes = silenceFillFrames * streamBytesPerFrame
val silence = ByteArray(fillBytes)
var silOff = 0
while (silOff < silence.size && !writerShouldStop) {
val w = t.write(silence, silOff, silence.size - silOff)
if (w <= 0) break
silOff += w
}
bytesBuffered += silence.size
}
}
idleMs += 50L
if (idleMs >= maxIdleMs) {
Log.w(TAG, "Idle-Cutoff: ${maxIdleMs}ms keine Daten — Stream wird beendet")
break@mainLoop
}
continue@mainLoop continue@mainLoop
} }
idleMs = 0L
// Pre-Roll Check: play() erst wenn genug gepuffert // Pre-Roll Check: play() erst wenn genug gepuffert
if (!playbackStarted && bytesBuffered + data.size >= prerollBytes) { if (!playbackStarted && bytesBuffered + data.size >= prerollBytes) {

View File

@ -0,0 +1,126 @@
package com.ariacockpit
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import android.telephony.PhoneStateListener
import android.telephony.TelephonyCallback
import android.telephony.TelephonyManager
import android.util.Log
import androidx.core.content.ContextCompat
import com.facebook.react.bridge.Arguments
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
/**
* Lauscht auf Anruf-Statusaenderungen wenn das Telefon klingelt oder ein
* Anruf laeuft, sendet das Modul ein "PhoneCallStateChanged"-Event an JS.
*
* JS-Side stoppt dann die TTS-Wiedergabe damit ARIA nicht mitten ins Gespraech
* weiterredet. Ohne READ_PHONE_STATE-Permission failt start() leise der Rest
* der App funktioniert wie bisher.
*
* State-Strings: "idle" | "ringing" | "offhook"
*/
class PhoneCallModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) {
override fun getName() = "PhoneCall"
companion object { private const val TAG = "PhoneCall" }
private var telephonyManager: TelephonyManager? = null
private var legacyListener: PhoneStateListener? = null
private var modernCallback: Any? = null // TelephonyCallback ab API 31
private var lastState: Int = TelephonyManager.CALL_STATE_IDLE
@ReactMethod
fun start(promise: Promise) {
try {
val perm = ContextCompat.checkSelfPermission(reactApplicationContext, Manifest.permission.READ_PHONE_STATE)
if (perm != PackageManager.PERMISSION_GRANTED) {
Log.w(TAG, "READ_PHONE_STATE Permission fehlt — Anruf-Erkennung inaktiv")
promise.resolve(false)
return
}
val tm = reactApplicationContext.getSystemService(Context.TELEPHONY_SERVICE) as? TelephonyManager
if (tm == null) {
Log.w(TAG, "TelephonyManager nicht verfuegbar")
promise.resolve(false)
return
}
telephonyManager = tm
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val cb = object : TelephonyCallback(), TelephonyCallback.CallStateListener {
override fun onCallStateChanged(state: Int) {
handleStateChange(state)
}
}
tm.registerTelephonyCallback(reactApplicationContext.mainExecutor, cb)
modernCallback = cb
} else {
@Suppress("DEPRECATION")
val l = object : PhoneStateListener() {
override fun onCallStateChanged(state: Int, phoneNumber: String?) {
handleStateChange(state)
}
}
@Suppress("DEPRECATION")
tm.listen(l, PhoneStateListener.LISTEN_CALL_STATE)
legacyListener = l
}
Log.i(TAG, "PhoneCall-Listener aktiv")
promise.resolve(true)
} catch (e: Exception) {
Log.e(TAG, "start fehlgeschlagen", e)
promise.reject("START_FAILED", e.message ?: "Unbekannter Fehler", e)
}
}
@ReactMethod
fun stop(promise: Promise) {
try {
val tm = telephonyManager
if (tm != null) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
(modernCallback as? TelephonyCallback)?.let { tm.unregisterTelephonyCallback(it) }
modernCallback = null
} else {
@Suppress("DEPRECATION")
legacyListener?.let { tm.listen(it, PhoneStateListener.LISTEN_NONE) }
legacyListener = null
}
}
telephonyManager = null
lastState = TelephonyManager.CALL_STATE_IDLE
promise.resolve(true)
} catch (e: Exception) {
promise.reject("STOP_FAILED", e.message ?: "")
}
}
private fun handleStateChange(state: Int) {
if (state == lastState) return
lastState = state
val name = when (state) {
TelephonyManager.CALL_STATE_RINGING -> "ringing"
TelephonyManager.CALL_STATE_OFFHOOK -> "offhook"
TelephonyManager.CALL_STATE_IDLE -> "idle"
else -> return
}
Log.i(TAG, "Telefon-State: $name")
val params = Arguments.createMap().apply { putString("state", name) }
try {
reactApplicationContext.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
.emit("PhoneCallStateChanged", params)
} catch (e: Exception) {
Log.w(TAG, "Event-emit fehlgeschlagen: ${e.message}")
}
}
@ReactMethod fun addListener(eventName: String) {}
@ReactMethod fun removeListeners(count: Int) {}
}

View File

@ -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 PhoneCallPackage : ReactPackage {
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> {
return listOf(PhoneCallModule(reactContext))
}
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> {
return emptyList()
}
}

View File

@ -167,10 +167,23 @@ export CI=true
if [ "$MODE" = "debug" ]; then if [ "$MODE" = "debug" ]; then
./gradlew assembleDebug ./gradlew assembleDebug
APK_PATH="app/build/outputs/apk/debug/app-debug.apk" OUT_DIR="app/build/outputs/apk/debug"
else else
./gradlew assembleRelease ./gradlew assembleRelease
APK_PATH="app/build/outputs/apk/release/app-release.apk" OUT_DIR="app/build/outputs/apk/release"
fi
# Mit ABI-Splits heisst die APK z.B. app-arm64-v8a-release.apk statt
# app-release.apk. arm64-v8a-Variante zuerst probieren (das ist unser
# Standard), Universal-APK als Fallback falls Splits deaktiviert sind.
if [ -f "$OUT_DIR/app-arm64-v8a-${MODE}.apk" ]; then
APK_PATH="$OUT_DIR/app-arm64-v8a-${MODE}.apk"
elif [ -f "$OUT_DIR/app-${MODE}.apk" ]; then
APK_PATH="$OUT_DIR/app-${MODE}.apk"
else
echo -e "${RED}Keine passende APK in $OUT_DIR gefunden${NC}"
cd ..
exit 1
fi fi
cd .. cd ..

View File

@ -1,6 +1,6 @@
{ {
"name": "aria-cockpit", "name": "aria-cockpit",
"version": "0.0.6.4", "version": "0.0.7.1",
"private": true, "private": true,
"scripts": { "scripts": {
"android": "react-native run-android", "android": "react-native run-android",
@ -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",

View File

@ -25,6 +25,7 @@ import RNFS from 'react-native-fs';
import rvs, { RVSMessage, ConnectionState } from '../services/rvs'; import rvs, { RVSMessage, ConnectionState } from '../services/rvs';
import audioService from '../services/audio'; import audioService from '../services/audio';
import wakeWordService from '../services/wakeword'; import wakeWordService from '../services/wakeword';
import phoneCallService from '../services/phoneCall';
import updateService from '../services/updater'; import updateService from '../services/updater';
import VoiceButton from '../components/VoiceButton'; import VoiceButton from '../components/VoiceButton';
import FileUpload, { FileData } from '../components/FileUpload'; import FileUpload, { FileData } from '../components/FileUpload';
@ -159,10 +160,23 @@ const ChatScreen: React.FC = () => {
const unsub = wakeWordService.onStateChange((s) => { const unsub = wakeWordService.onStateChange((s) => {
setWakeWordState(s); setWakeWordState(s);
setWakeWordActive(s !== 'off'); setWakeWordActive(s !== 'off');
// Conversation-Focus an Wake-Word-State koppeln: solange wir aktiv im
// Dialog sind, soll Spotify dauerhaft gepaust bleiben (auch ueber
// Render-Pausen + zwischen Antworten hinweg). Sobald wir zurueck nach
// 'armed' oder 'off' fallen, darf Spotify wieder.
if (s === 'conversing') audioService.acquireConversationFocus();
else audioService.releaseConversationFocus();
}); });
return () => unsub(); return () => unsub();
}, []); }, []);
// Anruf-Erkennung: TTS pausieren wenn das Telefon klingelt
useEffect(() => {
phoneCallService.start().catch(err =>
console.warn('[Chat] phoneCall.start fehlgeschlagen', err));
return () => { phoneCallService.stop().catch(() => {}); };
}, []);
// ttsCanPlayRef live aktuell halten — Closure in onMessage unten liest // ttsCanPlayRef live aktuell halten — Closure in onMessage unten liest
// darueber statt direkt ttsDeviceEnabled/ttsMuted (sonst stale). // darueber statt direkt ttsDeviceEnabled/ttsMuted (sonst stale).
useEffect(() => { useEffect(() => {
@ -281,9 +295,22 @@ const ChatScreen: React.FC = () => {
const idx = prev.findIndex(m => const idx = prev.findIndex(m =>
m.sender === 'user' && m.text.includes('Spracheingabe wird verarbeitet') m.sender === 'user' && m.text.includes('Spracheingabe wird verarbeitet')
); );
if (idx < 0) return prev; const newText = `\uD83C\uDFA4 ${sttText}`;
if (idx < 0) {
// Defensiv: wenn keine Placeholder im State (z.B. weil sie nie
// hinzugefuegt wurde oder schon durch ein anderes Update verloren
// ging), die Sprachnachricht trotzdem als neue Bubble einfuegen.
// Sonst kommt ARIAs Antwort ohne sichtbare User-Nachricht.
return capMessages([...prev, {
id: nextId(),
sender: 'user',
text: newText,
timestamp: message.timestamp,
attachments: [{ type: 'audio', name: 'Sprachaufnahme' }],
}]);
}
const next = prev.slice(); const next = prev.slice();
next[idx] = { ...next[idx], text: `\uD83C\uDFA4 ${sttText}` }; next[idx] = { ...next[idx], text: newText };
return next; return next;
}); });
} }
@ -619,6 +646,8 @@ const ChatScreen: React.FC = () => {
base64: result.base64, base64: result.base64,
durationMs: result.durationMs, durationMs: result.durationMs,
mimeType: result.mimeType, mimeType: result.mimeType,
voice: localXttsVoiceRef.current,
speed: ttsSpeedRef.current,
...(location && { location }), ...(location && { location }),
}); });
}, [getCurrentLocation]); }, [getCurrentLocation]);

View File

@ -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));
} }

View File

@ -191,6 +191,19 @@ class AudioService {
private pcmBytesCollected: number = 0; private pcmBytesCollected: number = 0;
private readonly PCM_MAX_CACHE_BYTES = 30 * 1024 * 1024; // 30MB private readonly PCM_MAX_CACHE_BYTES = 30 * 1024 * 1024; // 30MB
// AudioFocus wird verzoegert freigegeben — wenn ARIA eine zweite Antwort
// direkt hinterherschickt (oder ein neuer Stream startet), bleibt Spotify
// pausiert. Ohne diese Verzoegerung springt Spotify im Mikro-Sekunden-Gap
// zwischen zwei Streams kurz wieder an.
private focusReleaseTimer: ReturnType<typeof setTimeout> | null = null;
private readonly FOCUS_RELEASE_DELAY_MS = 800;
// Conversation-Mode: solange aktiv (Wake-Word Status 'conversing' ODER
// wir wissen "ARIA spricht gerade in einem Multi-Turn-Dialog"), halten wir
// den AudioFocus DAUERHAFT. Der per-Stream-Release wird unterdrueckt,
// damit Spotify nicht in Render-Pausen oder zwischen Antworten zurueckkehrt.
private _conversationFocusActive: boolean = false;
// VAD State // VAD State
private vadEnabled: boolean = false; private vadEnabled: boolean = false;
private lastSpeechTime: number = 0; private lastSpeechTime: number = 0;
@ -205,6 +218,58 @@ class AudioService {
this.recorder.setSubscriptionDuration(0.1); // 100ms Metering-Updates this.recorder.setSubscriptionDuration(0.1); // 100ms Metering-Updates
} }
/** AudioFocus mit kleiner Verzoegerung freigeben Spotify/YouTube
* springen sonst im Gap zwischen zwei TTS-Streams (oder wenn ARIA
* eine zweite Antwort direkt hinterherschickt) kurz wieder an.
* Im Conversation-Mode (Wake-Word conversing) wird das Release komplett
* unterdrueckt der Focus bleibt fuer die ganze Konversation gehalten. */
private _releaseFocusDeferred(): void {
if (this._conversationFocusActive) {
this._cancelDeferredFocusRelease();
return;
}
this._cancelDeferredFocusRelease();
this.focusReleaseTimer = setTimeout(() => {
this.focusReleaseTimer = null;
if (this._conversationFocusActive) return;
AudioFocus?.release().catch(() => {});
}, this.FOCUS_RELEASE_DELAY_MS);
}
private _cancelDeferredFocusRelease(): void {
if (this.focusReleaseTimer) {
clearTimeout(this.focusReleaseTimer);
this.focusReleaseTimer = null;
}
}
/** Conversation-Mode beginnt AudioFocus dauerhaft halten (Spotify bleibt
* pausiert). Idempotent: mehrfaches Aufrufen ist sicher. */
acquireConversationFocus(): void {
if (this._conversationFocusActive) return;
this._conversationFocusActive = true;
this._cancelDeferredFocusRelease();
console.log('[Audio] Conversation-Focus aktiv (Spotify bleibt gepaust)');
AudioFocus?.requestDuck().catch(() => {});
}
/** Conversation-Mode endet Focus darf wieder freigegeben werden
* (verzoegert, damit eine direkt folgende Antwort nichts kaputtmacht). */
releaseConversationFocus(): void {
if (!this._conversationFocusActive) return;
this._conversationFocusActive = false;
console.log('[Audio] Conversation-Focus inaktiv');
this._releaseFocusDeferred();
}
/** TTS-Wiedergabe haart stoppen z.B. wenn ein Anruf reinkommt.
* Released auch sofort den AudioFocus damit der Anruf-Klingelton hoerbar ist. */
haltAllPlayback(reason: string = ''): void {
console.log('[Audio] haltAllPlayback: %s', reason || '(no reason)');
this._conversationFocusActive = false;
this.stopPlayback();
}
// --- Berechtigungen --- // --- Berechtigungen ---
async requestMicrophonePermission(): Promise<boolean> { async requestMicrophonePermission(): Promise<boolean> {
@ -305,6 +370,7 @@ class AudioService {
this.setState('recording'); this.setState('recording');
// Andere Apps waehrend der Aufnahme pausieren (Musik, Videos etc.) // Andere Apps waehrend der Aufnahme pausieren (Musik, Videos etc.)
this._cancelDeferredFocusRelease();
AudioFocus?.requestExclusive().catch(() => {}); AudioFocus?.requestExclusive().catch(() => {});
// VAD aktivieren — Stille-Dauer aus AsyncStorage (Settings-konfigurierbar). // VAD aktivieren — Stille-Dauer aus AsyncStorage (Settings-konfigurierbar).
@ -387,8 +453,9 @@ class AudioService {
await this.recorder.stopRecorder(); await this.recorder.stopRecorder();
this.recorder.removeRecordBackListener(); this.recorder.removeRecordBackListener();
// Audio-Focus freigeben — andere Apps duerfen wieder // Audio-Focus verzoegert freigeben — gleich kommt die TTS-Antwort,
AudioFocus?.release().catch(() => {}); // im Gap soll Spotify nicht hochkommen.
this._releaseFocusDeferred();
const durationMs = Date.now() - this.recordingStartTime; const durationMs = Date.now() - this.recordingStartTime;
const hadSpeech = this.speechDetected; const hadSpeech = this.speechDetected;
@ -535,6 +602,7 @@ class AudioService {
this.pcmStreamActive = false; this.pcmStreamActive = false;
return ''; return '';
} }
this._cancelDeferredFocusRelease();
AudioFocus?.requestDuck().catch(() => {}); AudioFocus?.requestDuck().catch(() => {});
} }
} }
@ -553,11 +621,12 @@ class AudioService {
if (isFinal) { if (isFinal) {
if (!silent) { if (!silent) {
// end() resolved jetzt erst wenn der native Writer-Thread fertig // end() resolved jetzt erst wenn der native Writer-Thread fertig
// ist (alle Samples ausgespielt) — danach erst AudioFocus freigeben, // ist (alle Samples ausgespielt) — danach AudioFocus verzoegert
// damit Spotify/YouTube nicht waehrend des Pre-Roll-Ausklangs // freigeben, damit Spotify/YouTube nicht im Mikro-Gap zwischen zwei
// wieder aufdrehen. // ARIA-Antworten wieder hochdrehen. Wenn ein neuer Stream innerhalb
// FOCUS_RELEASE_DELAY_MS startet, wird das Release abgebrochen.
try { await PcmStreamPlayer!.end(); } catch {} try { await PcmStreamPlayer!.end(); } catch {}
AudioFocus?.release().catch(() => {}); this._releaseFocusDeferred();
} }
this.pcmStreamActive = false; this.pcmStreamActive = false;
@ -661,8 +730,9 @@ class AudioService {
private async _playNext(): Promise<void> { private async _playNext(): Promise<void> {
if (this.audioQueue.length === 0) { if (this.audioQueue.length === 0) {
this.isPlaying = false; this.isPlaying = false;
// Audio-Focus abgeben → andere Apps volle Lautstaerke // Audio-Focus verzoegert abgeben → wenn gleich noch eine Antwort kommt,
AudioFocus?.release().catch(() => {}); // bleibt Spotify pausiert.
this._releaseFocusDeferred();
// Alle Audio-Teile abgespielt → Listener benachrichtigen // Alle Audio-Teile abgespielt → Listener benachrichtigen
this.playbackFinishedListeners.forEach(cb => cb()); this.playbackFinishedListeners.forEach(cb => cb());
return; return;
@ -670,6 +740,7 @@ class AudioService {
// Beim ersten Playback-Start: andere Apps ducken // Beim ersten Playback-Start: andere Apps ducken
if (!this.isPlaying) { if (!this.isPlaying) {
this._cancelDeferredFocusRelease();
AudioFocus?.requestDuck().catch(() => {}); AudioFocus?.requestDuck().catch(() => {});
} }
this.isPlaying = true; this.isPlaying = true;
@ -755,7 +826,8 @@ class AudioService {
this.pcmBytesCollected = 0; this.pcmBytesCollected = 0;
this.pcmMessageId = ''; this.pcmMessageId = '';
} }
// Audio-Focus freigeben // Audio-Focus sofort freigeben — User hat explizit abgebrochen
this._cancelDeferredFocusRelease();
AudioFocus?.release().catch(() => {}); AudioFocus?.release().catch(() => {});
} }

View File

@ -0,0 +1,108 @@
/**
* PhoneCall-Service pausiert die TTS-Wiedergabe wenn das Telefon klingelt
* oder ein Anruf laeuft. Native-Bindung an PhoneCallModule.kt.
*
* Bei "ringing" oder "offhook" wird audioService.haltAllPlayback() gerufen
* ARIA verstummt sofort. Nach dem Auflegen passiert nichts automatisch
* (Audio kommt nicht zurueck), der User muesste die Antwort manuell
* nochmal anfordern (Play-Button auf der Nachricht).
*
* Permission READ_PHONE_STATE muss vom Nutzer einmalig erteilt werden
* wenn nicht, failed start() leise und der Rest funktioniert wie bisher.
*/
import {
NativeEventEmitter,
NativeModules,
PermissionsAndroid,
Platform,
ToastAndroid,
} from 'react-native';
import audioService from './audio';
interface PhoneCallNative {
start(): Promise<boolean>;
stop(): Promise<boolean>;
}
const { PhoneCall } = NativeModules as { PhoneCall?: PhoneCallNative };
type PhoneState = 'idle' | 'ringing' | 'offhook';
class PhoneCallService {
private started: boolean = false;
private subscription: { remove: () => void } | null = null;
private lastState: PhoneState = 'idle';
async start(): Promise<boolean> {
if (this.started || !PhoneCall) return false;
if (Platform.OS !== 'android') return false;
// Runtime-Permission holen (nur einmal noetig)
try {
const granted = await PermissionsAndroid.request(
PermissionsAndroid.PERMISSIONS.READ_PHONE_STATE,
{
title: 'ARIA Cockpit — Anruf-Erkennung',
message: 'Damit ARIA bei einem eingehenden Anruf nicht weiterredet, '
+ 'darf die App den Anruf-Status sehen (Klingeln/Aktiv/Aufgelegt). '
+ 'Es werden keine Anrufdaten gelesen oder gespeichert.',
buttonPositive: 'Erlauben',
buttonNegative: 'Spaeter',
},
);
if (granted !== PermissionsAndroid.RESULTS.GRANTED) {
console.warn('[PhoneCall] READ_PHONE_STATE Permission abgelehnt');
return false;
}
} catch (err) {
console.warn('[PhoneCall] Permission-Anfrage gescheitert', err);
}
try {
const ok = await PhoneCall.start();
if (!ok) {
console.warn('[PhoneCall] Native start() lieferte false (Permission?)');
return false;
}
const emitter = new NativeEventEmitter(NativeModules.PhoneCall as any);
this.subscription = emitter.addListener('PhoneCallStateChanged', (e: { state: PhoneState }) => {
this._onStateChanged(e.state);
});
this.started = true;
console.log('[PhoneCall] Listener aktiv');
return true;
} catch (err: any) {
console.warn('[PhoneCall] start gescheitert:', err?.message || err);
return false;
}
}
async stop(): Promise<void> {
if (!this.started || !PhoneCall) return;
try {
this.subscription?.remove();
this.subscription = null;
await PhoneCall.stop();
} catch {}
this.started = false;
this.lastState = 'idle';
}
private _onStateChanged(state: PhoneState): void {
if (state === this.lastState) return;
console.log('[PhoneCall] State: %s → %s', this.lastState, state);
this.lastState = state;
if (state === 'ringing' || state === 'offhook') {
audioService.haltAllPlayback(`Telefon-State: ${state}`);
ToastAndroid.show(
state === 'ringing' ? 'Anruf — ARIA pausiert' : 'Im Gespraech — ARIA pausiert',
ToastAndroid.SHORT,
);
}
// idle: nichts automatisch — User soll nichts unbeabsichtigt re-triggern
}
}
const phoneCallService = new PhoneCallService();
export default phoneCallService;

View File

@ -1,142 +1,140 @@
/** /**
* 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 = [
'hey_jarvis',
'computer', 'computer',
'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',
computer: 'Computer',
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 +143,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 +168,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 +188,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 +210,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 +252,10 @@ class WakeWordService {
} }
hasWakeWord(): boolean { hasWakeWord(): boolean {
return !!this.porcupine; return this.nativeReady;
} }
getKeyword(): string { getKeyword(): WakeKeyword {
return this.keyword; return this.keyword;
} }

View File

@ -551,6 +551,15 @@ class ARIABridge:
# Beeinflusst das Timeout fuer stt_request — bei "loading" warten wir laenger, # Beeinflusst das Timeout fuer stt_request — bei "loading" warten wir laenger,
# weil das Modell beim ersten Request noch ~1-2 Min runtergeladen werden kann. # weil das Modell beim ersten Request noch ~1-2 Min runtergeladen werden kann.
self._remote_stt_ready: bool = False self._remote_stt_ready: bool = False
# Pending Files: wenn die App ein Bild + Text gleichzeitig schickt, kommen
# zwei separate RVS-Events ('file' und 'chat') — wir buffern die Files
# kurz und mergen sie mit dem nachfolgenden Chat-Text zu einer einzigen
# Anfrage an aria-core. Sonst antwortet ARIA zweimal (einmal "warte auf
# Anweisung" beim file, einmal auf den Chat-Text).
# Liste von Tuples: (file_path, name, file_type, size_kb, width, height)
self._pending_files: list[tuple[str, str, str, int, int, int]] = []
self._pending_files_flush_task: Optional[asyncio.Task] = None
self._PENDING_FILES_WINDOW_SEC: float = 0.8
def initialize(self) -> None: def initialize(self) -> None:
"""Initialisiert alle Komponenten. """Initialisiert alle Komponenten.
@ -907,18 +916,13 @@ class ARIABridge:
logger.info("[core] TTS unterdrueckt (Modus: %s)", self.current_mode.config.name) logger.info("[core] TTS unterdrueckt (Modus: %s)", self.current_mode.config.name)
return return
# Voice bestimmen: App-Override fuer diesen Request > globale Default-Voice # Voice bestimmen: App-Override (gesetzt durch letzten chat-Event) > globale
# Default-Voice. Der Override wird NICHT pro Antwort verbraucht — sonst nutzt
# eine Multi-Turn-Antwort von ARIA (Tool-Use + finale Antwort) ab dem zweiten
# TTS-Call wieder die alte Default-Stimme. Der Override bleibt gueltig bis
# zum naechsten chat-Event, wo er entweder ueberschrieben oder geloescht wird.
xtts_voice = self._next_voice_override or getattr(self, 'xtts_voice', '') xtts_voice = self._next_voice_override or getattr(self, 'xtts_voice', '')
# Override verbrauchen (gilt nur fuer genau diese naechste Antwort)
if self._next_voice_override:
logger.info("[core] Nutze Voice-Override: %s", self._next_voice_override)
self._next_voice_override = None
# Speed ebenfalls aus App-Override nehmen (fallback 1.0)
xtts_speed = self._next_speed_override or 1.0 xtts_speed = self._next_speed_override or 1.0
if self._next_speed_override:
logger.info("[core] Nutze Speed-Override: %.2fx", self._next_speed_override)
self._next_speed_override = None
tts_text = tts_text_preview or text tts_text = tts_text_preview or text
if not tts_text: if not tts_text:
@ -1024,6 +1028,51 @@ class ARIABridge:
except Exception as e: except Exception as e:
logger.debug("[session] Diagnostic nicht erreichbar (%s) — nutze '%s'", e, self._session_key) logger.debug("[session] Diagnostic nicht erreichbar (%s) — nutze '%s'", e, self._session_key)
def _build_pending_files_message(self, user_text: str) -> str:
"""Baut eine Anweisung an aria-core aus den gepufferten Files + optionalem
User-Text. user_text leer 'warte auf Anweisung'-Variante."""
parts: list[str] = []
for fp, name, ftype, kb, w, h in self._pending_files:
dim = f" {w}x{h}px" if (w and h) else ""
kind = "Bild" if ftype.startswith("image/") else "Datei"
parts.append(f"- {kind}: {name}{dim} ({ftype}, {kb}KB) liegt unter {fp}")
files_summary = "\n".join(parts)
n = len(self._pending_files)
anhang = "Anhang" if n == 1 else "Anhaenge"
if user_text:
return (f"Stefan hat dir {n} {anhang} geschickt:\n{files_summary}\n\n"
f"Er sagt dazu: \"{user_text}\"")
return (f"Stefan hat dir {n} {anhang} geschickt:\n{files_summary}\n\n"
f"Warte auf seine Anweisung was du damit tun sollst.")
async def _flush_pending_files_after(self, delay: float) -> None:
"""Wenn nach `delay`s kein chat-Text gekommen ist: Files alleine an
aria-core senden ('warte auf Anweisung'-Variante)."""
try:
await asyncio.sleep(delay)
except asyncio.CancelledError:
return
if not self._pending_files:
return
text = self._build_pending_files_message("")
self._pending_files = []
self._pending_files_flush_task = None
await self.send_to_core(text, source="app-file")
async def _flush_pending_files_with_text(self, user_text: str) -> bool:
"""Wenn ein chat-Text reinkommt waehrend Files gepuffert sind:
Files + Text zu einer einzigen aria-core-Nachricht mergen.
Returns True wenn gemerged wurde (Caller soll dann nicht nochmal senden)."""
if not self._pending_files:
return False
if self._pending_files_flush_task and not self._pending_files_flush_task.done():
self._pending_files_flush_task.cancel()
self._pending_files_flush_task = None
text = self._build_pending_files_message(user_text)
self._pending_files = []
await self.send_to_core(text, source="app-file+chat")
return True
async def send_to_core(self, text: str, source: str = "bridge") -> None: async def send_to_core(self, text: str, source: str = "bridge") -> None:
"""Sendet Text an aria-core (OpenClaw chat.send Protokoll).""" """Sendet Text an aria-core (OpenClaw chat.send Protokoll)."""
if self.ws_core is None: if self.ws_core is None:
@ -1169,21 +1218,32 @@ class ARIABridge:
if sender in ("aria", "stt"): if sender in ("aria", "stt"):
return return
text = payload.get("text", "") text = payload.get("text", "")
# Voice-Override fuer die naechste ARIA-Antwort merken # Voice-Override fuer Folgenachrichten setzen — gilt bis zum naechsten
voice_override = payload.get("voice", "") # chat-Event. Leerer String "" = explizit Default-Voice (override loeschen).
if voice_override: # Field nicht gesendet = vorherigen Override unveraendert lassen (z.B. wenn
self._next_voice_override = voice_override # cancel_request oder anderer Service die App umgeht).
logger.info("[rvs] Voice-Override fuer naechste Antwort: %s", voice_override) if "voice" in payload:
voice_override = payload.get("voice", "") or ""
self._next_voice_override = voice_override or None
logger.info("[rvs] Voice fuer Antworten: %s",
self._next_voice_override or "(Default)")
# Speed-Override (TTS-Wiedergabegeschwindigkeit, pro Geraet) # Speed-Override (TTS-Wiedergabegeschwindigkeit, pro Geraet)
try: if "speed" in payload:
speed = float(payload.get("speed", 0) or 0) try:
if 0.1 <= speed <= 5.0: speed = float(payload.get("speed", 0) or 0)
self._next_speed_override = speed self._next_speed_override = speed if 0.1 <= speed <= 5.0 else None
except (TypeError, ValueError): except (TypeError, ValueError):
pass self._next_speed_override = None
if text: if text:
logger.info("[rvs] App-Chat: '%s'", text[:80]) # Wenn Files gerade gepuffert sind (Bild + Text gleichzeitig
await self.send_to_core(text, source="app") # gesendet), mergen wir sie zu einer einzigen Anfrage statt
# zwei separater send_to_core-Calls.
merged = await self._flush_pending_files_with_text(text)
if merged:
logger.info("[rvs] App-Chat (mit Anhaengen): '%s'", text[:80])
else:
logger.info("[rvs] App-Chat: '%s'", text[:80])
await self.send_to_core(text, source="app")
return return
if msg_type == "cancel_request": if msg_type == "cancel_request":
@ -1342,70 +1402,54 @@ class ARIABridge:
await self.ws_core.send(raw_message) await self.ws_core.send(raw_message)
elif msg_type == "file": elif msg_type == "file":
# Datei von der App → als Text-Nachricht an aria-core # Datei von der App: speichern + zu Pending-Queue hinzufuegen.
# Wird mit dem nachfolgenden chat-Event (innerhalb PENDING_FILES_WINDOW)
# zu einer einzigen aria-core-Anfrage gemerged. Sonst antwortet ARIA
# zweimal: einmal "warte auf Anweisung" beim file, einmal auf den Chat.
file_name = payload.get("name", "unbekannt") file_name = payload.get("name", "unbekannt")
file_type = payload.get("type", "") file_type = payload.get("type", "")
file_b64 = payload.get("base64", "") file_b64 = payload.get("base64", "")
file_size = payload.get("size", 0)
width = payload.get("width", 0) width = payload.get("width", 0)
height = payload.get("height", 0) height = payload.get("height", 0)
logger.info("[rvs] Datei empfangen: %s (%s, %dKB)", logger.info("[rvs] Datei empfangen: %s (%s, %dKB)",
file_name, file_type, len(file_b64) // 1365 if file_b64 else 0) file_name, file_type, len(file_b64) // 1365 if file_b64 else 0)
# Shared Volume: /shared/ ist in Bridge UND aria-core gemountet
SHARED_DIR = "/shared/uploads" SHARED_DIR = "/shared/uploads"
os.makedirs(SHARED_DIR, exist_ok=True) os.makedirs(SHARED_DIR, exist_ok=True)
if file_b64 and file_type.startswith("image/"): if not file_b64:
# Bild in Shared Volume speichern text = f"Stefan hat eine Datei gesendet ({file_name}, {file_type}) aber die Daten sind leer angekommen."
await self.send_to_core(text, source="app-file")
return
if file_type.startswith("image/"):
ext = ".jpg" if "jpeg" in file_type or "jpg" in file_type else ".png" ext = ".jpg" if "jpeg" in file_type or "jpg" in file_type else ".png"
safe_name = f"img_{int(asyncio.get_event_loop().time())}_{file_name.replace('/', '_')}" safe_name = f"img_{int(asyncio.get_event_loop().time())}_{file_name.replace('/', '_')}"
file_path = os.path.join(SHARED_DIR, safe_name if safe_name.endswith(ext) else safe_name + ext) file_path = os.path.join(SHARED_DIR, safe_name if safe_name.endswith(ext) else safe_name + ext)
with open(file_path, "wb") as f: else:
f.write(base64.b64decode(file_b64))
size_kb = len(file_b64) // 1365
logger.info("[rvs] Bild gespeichert: %s (%dKB)", file_path, size_kb)
# ERST an aria-core senden (wichtigster Schritt)
text = (f"Stefan hat dir ein Bild geschickt: {file_name}"
f"{f' ({width}x{height}px)' if width else ''}"
f", {size_kb}KB."
f" Das Bild liegt unter: {file_path}"
f" Warte auf Stefans Anweisung was du damit tun sollst.")
await self.send_to_core(text, source="app-file")
# Dann App informieren (optional, darf nicht crashen)
try:
await self._send_to_rvs({
"type": "file_saved",
"payload": {"name": file_name, "serverPath": file_path, "mimeType": file_type},
"timestamp": int(asyncio.get_event_loop().time() * 1000),
})
except Exception as e:
logger.warning("[rvs] file_saved konnte nicht an App gesendet werden: %s", e)
elif file_b64:
# Andere Datei in Shared Volume speichern
safe_name = f"file_{int(asyncio.get_event_loop().time())}_{file_name.replace('/', '_')}" safe_name = f"file_{int(asyncio.get_event_loop().time())}_{file_name.replace('/', '_')}"
file_path = os.path.join(SHARED_DIR, safe_name) file_path = os.path.join(SHARED_DIR, safe_name)
with open(file_path, "wb") as f: with open(file_path, "wb") as f:
f.write(base64.b64decode(file_b64)) f.write(base64.b64decode(file_b64))
size_kb = len(file_b64) // 1365 size_kb = len(file_b64) // 1365
logger.info("[rvs] Datei gespeichert: %s (%dKB)", file_path, size_kb) logger.info("[rvs] Datei gespeichert: %s (%dKB)", file_path, size_kb)
# ERST an aria-core senden
text = (f"Stefan hat dir eine Datei geschickt: {file_name}" # In Pending-Queue + Flush-Timer (anti-spam Buffering)
f" ({file_type}, {size_kb}KB)." self._pending_files.append((file_path, file_name, file_type, size_kb, int(width or 0), int(height or 0)))
f" Die Datei liegt unter: {file_path}" if self._pending_files_flush_task and not self._pending_files_flush_task.done():
f" Warte auf Stefans Anweisung was du damit tun sollst.") self._pending_files_flush_task.cancel()
await self.send_to_core(text, source="app-file") self._pending_files_flush_task = asyncio.create_task(
try: self._flush_pending_files_after(self._PENDING_FILES_WINDOW_SEC)
await self._send_to_rvs({ )
"type": "file_saved",
"payload": {"name": file_name, "serverPath": file_path, "mimeType": file_type}, try:
"timestamp": int(asyncio.get_event_loop().time() * 1000), await self._send_to_rvs({
}) "type": "file_saved",
except Exception as e: "payload": {"name": file_name, "serverPath": file_path, "mimeType": file_type},
logger.warning("[rvs] file_saved konnte nicht an App gesendet werden: %s", e) "timestamp": int(asyncio.get_event_loop().time() * 1000),
else: })
text = f"Stefan hat eine Datei gesendet ({file_name}, {file_type}) aber die Daten sind leer angekommen." except Exception as e:
await self.send_to_core(text, source="app-file") logger.warning("[rvs] file_saved konnte nicht an App gesendet werden: %s", e)
elif msg_type == "file_request": elif msg_type == "file_request":
# App fordert eine Datei an (Re-Download nach Cache-Leerung) # App fordert eine Datei an (Re-Download nach Cache-Leerung)
@ -1444,17 +1488,18 @@ class ARIABridge:
if not audio_b64: if not audio_b64:
logger.warning("[rvs] Audio ohne Daten empfangen") logger.warning("[rvs] Audio ohne Daten empfangen")
return return
# Voice-Override fuer die kommende ARIA-Antwort (App-lokal gewaehlt) # Voice-Override fuer Folgenachrichten — gleiche Semantik wie beim chat-Event.
voice_override = payload.get("voice", "") if "voice" in payload:
if voice_override: voice_override = payload.get("voice", "") or ""
self._next_voice_override = voice_override self._next_voice_override = voice_override or None
logger.info("[rvs] Voice-Override (via Audio): %s", voice_override) logger.info("[rvs] Voice fuer Antworten (via Audio): %s",
try: self._next_voice_override or "(Default)")
speed = float(payload.get("speed", 0) or 0) if "speed" in payload:
if 0.1 <= speed <= 5.0: try:
self._next_speed_override = speed speed = float(payload.get("speed", 0) or 0)
except (TypeError, ValueError): self._next_speed_override = speed if 0.1 <= speed <= 5.0 else None
pass except (TypeError, ValueError):
self._next_speed_override = None
logger.info("[rvs] Audio empfangen: %s, %dms, %dKB", logger.info("[rvs] Audio empfangen: %s, %dms, %dKB",
mime_type, duration_ms, len(audio_b64) // 1365) mime_type, duration_ms, len(audio_b64) // 1365)
asyncio.create_task(self._process_app_audio(audio_b64, mime_type)) asyncio.create_task(self._process_app_audio(audio_b64, mime_type))