Compare commits

..

24 Commits

Author SHA1 Message Date
duffyduck 7682a0ce58 release: bump version to 0.0.7.8 2026-05-06 22:58:20 +02:00
duffyduck 3ca834e633 fix(audio): Auto-Removal von Sprachnachrichten ohne STT-Result nach 30s
Bug: Wenn eine Aufnahme leer war, nur Wake-Word-Echo enthielt oder STT
sonstwie nichts erkannt hat, sendet die Bridge KEIN stt-Event zurueck —
die Placeholder-Bubble "Spracheingabe wird verarbeitet" blieb fuer immer
im Chat. Folge-Aufnahmen matchten dann via Substring-Fallback die ALTE
Placeholder, der echte Text landete in der falschen Bubble.

Fix: nach jedem audio-send einen 30s-Timer starten. Wenn nach Ablauf die
Bubble (per audioRequestId identifiziert) immer noch "verarbeitet" ist,
wird sie entfernt + Toast "nicht erkannt" zeigt das dem User.

So bleibt der State sauber + audioRequestId-Match auf zukuenftige
Aufnahmen findet die richtige Bubble (statt die hinterbliebene Placeholder).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 22:57:20 +02:00
duffyduck 55ef207454 release: bump version to 0.0.7.7 2026-05-06 22:52:23 +02:00
duffyduck 6651f5937d feat(audio): Wake-Word parallel zu TTS mit AcousticEchoCanceler
Du kannst jetzt "Computer" sagen waehrend ARIA noch redet — TTS
verstummt, neue Aufnahme startet. Vorher musste man warten oder
manuell den Voice-Button tappen.

Native (OpenWakeWordModule.kt):
- AudioRecord-Source von MIC auf VOICE_COMMUNICATION (aktiviert auf
  den meisten Geraeten Echo-Cancellation + Noise-Suppression)
- Zusaetzlich AcousticEchoCanceler/NoiseSuppressor/AutomaticGainControl
  explizit aktiviert wenn vorhanden — robuster auf Geraeten wo die
  VOICE_COMMUNICATION-Source die Effects nicht automatisch mitbringt
- releaseAudioEffects() im stop/dispose

JS (wakeword.ts):
- Neue API: startBargeListening / stopBargeListening — Wake-Word
  parallel aktivieren, ohne den State 'conversing' zu verlassen
- onWakeDetected unterscheidet jetzt: in 'conversing' → barge-in-
  Callback (nicht der normale wake-callback). Sonst Standard-Pfad.
- onBargeIn-Subscriber-API + isBargeListening-Getter

Lifecycle-Wiring (audio.ts + ChatScreen):
- audioService.onPlaybackStarted callback (neu)
- ChatScreen: Bei TTS-Start → wakeWord.startBargeListening
- ChatScreen: Bei TTS-Ende → wakeWord.stopBargeListening (sonst kein
  AudioRecord fuer die naechste Aufnahme)
- ChatScreen: Bei BargeIn → haltAllPlayback + cancel_request +
  150ms-Pause + neue Aufnahme starten

issue.md + README aktualisiert.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 22:50:09 +02:00
duffyduck e9e7dd804f docs: issue.md + README mit audioRequestId-Fix + Bereit-Sound aktualisiert
issue.md: drei neue Erledigt-Eintraege (Placeholder-Race per
audioRequestId, Mikro-Offen-Toast erst nach Recording-Start, Bereit-
Sound mit Toggle). Neuer Offen-Eintrag: Wake-Word parallel zu TTS
mit AcousticEchoCanceler.

README: Wake-Word-Bedienung erweitert um Ding-Dong + "🎤 sprich
jetzt"-Toast. Roadmap mit den beiden neuen Features ergaenzt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 22:44:03 +02:00
duffyduck ec9530f17f release: bump version to 0.0.7.6 2026-05-06 22:41:55 +02:00
duffyduck 97cb7be313 feat(audio): "Bereit"-Sound (Ding-Dong) wenn Mikro nach Wake-Word offen ist
Kurzer akustischer Hinweis (Airplane Ding-Dong, 20KB MP3) bei
audioService.startRecording-Erfolg im Wake-Word-Pfad — User weiss
exakt ab wann er reden darf, statt das Toast nur zu sehen.

Quelldatei: android/sounds/Airplane-ding-dong.mp2 → ffmpeg-konvertiert
zu MP3 64kbps, abgelegt in android/app/src/main/res/raw/ damit Android
sie als Resource laden kann.

Toggle in App-Settings → Wake-Word, default aktiv. Bei Aktivierung
spielt direkt eine Vorschau ab damit man weiss wie's klingt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 22:40:45 +02:00
duffyduck 77e927ffcd fix(audio): Placeholder-Race per audioRequestId + Mikro-Offen-Toast erst nach Start
Bug: Bei zwei Sprachnachrichten kurz hintereinander wurde der STT-Text
der zweiten in die Bubble der ersten geschrieben. Ursache: findIndex
matchte ueber Substring "Spracheingabe wird verarbeitet" → bei zwei
offenen Placeholders nahm er immer die ERSTE, egal welches STT-Result
gerade kam.

Fix: jede Aufnahme bekommt eine eindeutige audioRequestId, App pusht
sie in die Placeholder-Bubble + ans audio-Event. Bridge gibt sie
unveraendert ans STT-Result zurueck. App matcht primaer per ID, fallback
auf Substring (Kompatibilitaet zu alten Bridge-Versionen).

Bonus: Toast "Wake-Word erkannt" entfernt, dafuer "🎤 Mikro offen —
sprich jetzt" erst wenn audioService.startRecording wirklich erfolgreich
war. So weiss der User exakt ab wann er reden darf — vorher war der Toast
schon ~400ms vorher da.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 22:33:26 +02:00
duffyduck a9a87f12df release: bump version to 0.0.7.5 2026-05-06 22:15:49 +02:00
duffyduck 2a56ac0290 docs: issue.md + README aktualisiert mit aktuellen Features
issue.md: openWakeWord, ABI-Split, Underrun-Schutz, Conversation-Focus,
PhoneStateListener, Voice-Override-Fix, Bild+Text-Merge, Diagnostic-UI,
adaptive VAD, Max-Aufnahme konfigurierbar, Barge-In, Push-to-Talk-Refactor,
Settings-Sub-Screens, Textauswahl-Fix in Erledigt verschoben.
Porcupine-bezogene offene Bugs entfernt (Engine gewechselt). Neue Offene:
STT-Placeholder-Replacement, Custom-onnx-Upload, Pause+Resume bei Anruf.

README: Push-to-Talk-Erwaehnung raus, VAD-Beschreibung auf adaptiv +
neuen Default 5min, neue Bullets fuer Barge-In + Anruf-Pause, Roadmap
ergaenzt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 22:13:53 +02:00
duffyduck edc65ce645 feat(settings): Sub-Screen-Navigation statt langer Liste
Settings ist jetzt ein Hauptmenue mit 8 Kategorien — beim Tippen klappt
nur die gewaehlte Sektion auf, "<-Back-Button kehrt zur Uebersicht zurueck.

Gruppen:
- 🔌 Verbindung      (Server, Token, Status, Verbindungslog)
- ⚙️ Allgemein       (Betriebsmodus, GPS-Standort)
- 🎙️ Spracheingabe   (Stille-Toleranz, Aufnahmedauer)
- 👂 Wake-Word       (Wake-Word-Auswahl)
- 🔊 Sprachausgabe   (Stimmen, Pre-Roll, Geschwindigkeit)
- 📁 Speicher        (Anhang-Speicherort, Auto-Download)
- 📜 Protokoll       (Privatsphaere, Backup)
- ℹ️ Ueber           (App-Version, Update)

Implementierung absichtlich ohne react-navigation-Stack: ein einzelner
currentSection-State, conditional rendering pro Sektion. So bleibt aller
geteilte State (rvs.config, voices-Liste, Toggles) im selben Component-
Closure ohne props-drilling oder Context.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 22:11:08 +02:00
duffyduck d7efaf93b3 refactor(voice): Push-to-Talk-Pfad raus, nur Tap-to-Talk
handlePressIn/Out + onResponderGrant/Release/Terminate weg. Push-to-
Talk lief parallel zu Tap-to-Talk und triggerte je nach Touch-Timing
unkontrollierbar. Stefan kennt das Verhalten ohnehin nicht (sagt
"druecken startet, druecken stoppt") — Push-to-Talk macht UX nur
unklarer ohne Mehrwert.

isLongPress-Ref entfernt (war nur fuer Push-to-Talk-Discrimination).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 22:02:21 +02:00
duffyduck 31ff20c846 feat: Max-Aufnahmedauer konfigurierbar + Barge-In gibt aria-core Kontext
Max-Aufnahme:
Default rauf von 2 auf 5 Minuten, in den App-Settings konfigurierbar
zwischen 1 und 30 Minuten (loadMaxRecordingMs aus AsyncStorage,
Storage-Key aria_max_recording_sec). Notbremse-Verhalten bleibt:
nach Ablauf wird die Aufnahme automatisch beendet und gesendet.

Barge-In Kontext:
Wenn der User waehrend ARIA noch redet/arbeitet eine neue Sprach-
oder Text-Nachricht sendet, geht jetzt ein 'interrupted: true' Flag
mit. Bridge praefixed den Text fuer aria-core dann mit:

  "[Hinweis: Stefan hat dich gerade unterbrochen waehrend du noch
  gesprochen oder gearbeitet hast. Folgendes ist eine Korrektur,
  Ergaenzung oder ein Themenwechsel zu deiner letzten Antwort.]"

So weiss ARIA dass die neue Message KEINE eigenstaendige Folgefrage
ist sondern auf den abgebrochenen Run bezogen. Der User sieht in
seinem Chat nur den reinen Text — der Hint geht nur an aria-core.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 21:58:11 +02:00
duffyduck 406f4cb3cc fix: Textauswahl, adaptive VAD-Schwelle + Barge-In bei Sprachaufnahme
Bug 1 — Textauswahl in Bubbles ging nicht mehr:
MessageText hatte verschachtelte <Text onPress={...}> fuer Custom-Link-
Styling. Das fing die Long-Press-Geste ab, daher kein Markieren+Kopieren
mehr. Jetzt nur noch ein einzelnes <Text selectable dataDetectorType="all">,
Android macht URLs/Telefonnummern/Emails per System-Detection klickbar.

Bug 2 — VAD erkannte Stille nicht zuverlaessig (Aufnahme lief endlos):
Festwerte (-45dB Stille / -28dB Sprache) passten nicht zu jeder Umgebung.
In lauteren Raeumen lag der Hintergrundpegel ueber der Stille-Schwelle,
lastSpeechTime wurde dauerhaft aktualisiert → VAD feuerte nie, Aufnahme
lief bis 120s Max-Duration.

Jetzt adaptiv: erste 5 Mic-Samples (~500ms) bilden die Baseline; Stille-
Schwelle = baseline+6dB, Sprache-Schwelle = baseline+12dB. Toast zeigt
die kalibrierten Werte beim Aufnahmestart. Fallback auf -38dB/-22dB falls
das Mikro keine Metering-Updates liefert.

Bug 3 — Barge-In ("ach vergiss es"):
Wenn waehrend ARIAs Antwort eine neue Sprachnachricht aufgenommen wird,
wird ARIAs aktuelle Aktivitaet (TTS + thinking/tool) sofort abgebrochen
bevor die neue Message gesendet wird — wie in einem echten Gespraech wo
man den anderen unterbrechen darf.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 21:49:48 +02:00
duffyduck fa0667088a release: bump version to 0.0.7.4 2026-05-06 20:30:37 +02:00
duffyduck f55329706e debug(stt): Toasts in App + Bridge-Log fuer STT-Broadcast-Erfolg
Da kein adb-Zugriff: visuelle Debug-Pfade direkt in der App + im
Diagnostic-Bridge-Tab.

App: zwei Toasts beim Empfang eines stt-events
- "STT empfangen: ..." sobald das chat-event mit sender=stt reinkommt
- "Bubble #X ersetzt" oder "keine Placeholder → neue Bubble"

Bridge: explizites Info-Log "STT-Text an RVS broadcastet (sender=stt)"
nach erfolgreichem _send_to_rvs, "NICHT broadcastet" wenn die Methode
False lieferte (Ping fehlgeschlagen / Verbindung tot).

Naechster Test:
- Sprachnachricht aufnehmen
- Toast erscheint? → STT-Event kommt in App an, Bug ist im findIndex
- Toast erscheint nicht? → Diagnostic Bridge-Tab pruefen ob das Log
  "STT-Text an RVS broadcastet" steht

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 20:29:26 +02:00
duffyduck 6c7fd1d0e3 release: bump version to 0.0.7.3 2026-05-06 20:12:01 +02:00
duffyduck 9d8db111ac release: bump version to 0.0.7.2 2026-05-05 14:51:18 +02:00
duffyduck 482cb6ace3 fix(compose): $(find ...) muss in compose-command zu $$(find ...) escaped werden
docker-compose interpretiert $( als Variable-Interpolation-Pattern und
warf "Invalid interpolation format". Die anderen $$DIST-Stellen waren
schon korrekt escaped, nur das command-substitution fehlte.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 14:24:31 +02:00
duffyduck 69c1c49a7d fix(diagnostic+app): Chat-UI bubblig, mehrzeilig + persistente RVS + STT-Logs
Diagnostic-UI:
- chat-msg ist jetzt eine richtige Bubble (border-radius 14px, Schatten,
  flex-Layout statt margin-Hack, Tail-Radius zur Sender-Seite hin).
- Eingabefelder (haupt + Vollbild) jetzt textarea mit Auto-Resize.
  Enter sendet, Shift+Enter macht neue Zeile.
- white-space: pre-wrap behaelt Zeilenumbrueche aus dem Text bei.

Diagnostic-Server:
- sendToRVS_raw nutzt jetzt die persistente rvsWs statt fuer jedes Send
  eine frische Verbindung aufzubauen. Der frische-WS-Pfad hatte Race-
  Probleme (WS schloss bevor RVS broadcasten konnte → User-Nachrichten
  von Diagnostic kamen nicht in der App an). Frische WS bleibt als
  Fallback wenn die persistente gerade tot ist.

App:
- console.log am Anfang des chat-handlers + im STT-Result-Handler mit
  findIndex-Result und Placeholder-Count. Bei nicht-erkanntem STT-Text
  liefert `adb logcat -s ReactNativeJS:V` jetzt direkt den Befund:
  kommt das Event ueberhaupt an, findet er die Placeholder?

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 14:19:16 +02:00
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
23 changed files with 1165 additions and 233 deletions
+14 -4
View File
@@ -378,10 +378,13 @@ API-Endpoint fuer andere Services: `GET http://localhost:3001/api/session`
### Features ### Features
- Text-Chat mit ARIA - Text-Chat mit ARIA
- **Sprachaufnahme**: Push-to-Talk (halten) oder Tap-to-Talk (tippen, Auto-Stop bei Stille) - **Sprachaufnahme**: Tap-to-Talk (tippen startet, tippen stoppt, Auto-Stop bei Stille via VAD)
- **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. - **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)**: Adaptive Schwelle (Baseline aus ersten 500ms Mic-Pegel + 6dB Offset). Konfigurierbare Stille-Toleranz (1.08.0s, Default 2.8s) bevor Auto-Stop greift. Max-Aufnahme einstellbar (130 min, Default 5 min)
- **Barge-In**: Wenn du waehrend ARIAs Antwort eine neue Sprach-/Text-Nachricht reinschickst, wird sie unterbrochen + bekommt den Hint "das ist eine Korrektur"
- **Wake-Word waehrend TTS**: Du kannst "Computer" sagen waehrend ARIA noch redet — AcousticEchoCanceler verhindert dass ARIAs eigene Stimme das Wake-Word triggert
- **Anruf-Pause**: TTS verstummt automatisch wenn das Telefon klingelt (READ_PHONE_STATE Permission)
- **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.
- **"ARIA denkt..." Indicator**: Zeigt live den Status vom Core (Denken, Tool, Schreiben) + Abbrechen-Button - **"ARIA denkt..." Indicator**: Zeigt live den Status vom Core (Denken, Tool, Schreiben) + Abbrechen-Button
@@ -415,7 +418,7 @@ Community-Modelle stammen aus [fwartner/home-assistant-wakewords-collection](htt
**Bedienung:** **Bedienung:**
- App → **Einstellungen****Wake-Word** → gewuenschtes Keyword waehlen → **Speichern + Aktivieren** - 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 🎙️, Konversation laeuft - Wake-Word sagen → Symbol wechselt auf 🎙️, **Bereit-Sound** (Ding-Dong, optional in Settings) + Toast "🎤 sprich jetzt" sobald das Mikro wirklich offen ist
- 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 (🔇)
@@ -840,7 +843,14 @@ docker exec aria-core ssh aria-wohnung hostname
- [x] Whisper STT auf die Gamebox ausgelagert (CUDA float16, fast Echtzeit) - [x] Whisper STT auf die Gamebox ausgelagert (CUDA float16, fast Echtzeit)
- [x] **F5-TTS ersetzt XTTS** — bessere Voice-Cloning-Qualitaet, Whisper-auto-transkribierter Referenz-Text - [x] **F5-TTS ersetzt XTTS** — bessere Voice-Cloning-Qualitaet, Whisper-auto-transkribierter Referenz-Text
- [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 einstellbar (1-8s) + adaptive Mikro-Baseline + Max-Aufnahme einstellbar (1-30 min)
- [x] Barge-In: User kann ARIA waehrend Antwort unterbrechen, aria-core bekommt Kontext-Hint
- [x] Anruf-Pause: TTS verstummt bei eingehendem Anruf (PhoneStateListener)
- [x] Settings-Sub-Screens: 8 Kategorien statt langer Liste
- [x] APK ABI-Split arm64-v8a: 35 MB statt 136 MB
- [x] Sprachnachrichten-Bubble: audioRequestId statt Substring-Match — keine vertauschten Bubbles mehr bei parallelen Aufnahmen
- [x] Bereit-Sound (Airplane Ding-Dong) wenn Mikro nach Wake-Word offen ist — akustische Bestaetigung, in Settings abschaltbar
- [x] Wake-Word parallel zu TTS mit AcousticEchoCanceler — "Computer" sagen waehrend ARIA spricht stoppt sie und oeffnet Mikro
- [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 - [x] Wake-Word on-device via openWakeWord (ONNX Runtime, kein API-Key) + State-Icon
+2 -2
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 609 versionCode 708
versionName "0.0.6.9" versionName "0.0.7.8"
// Fallback fuer Libraries mit Product Flavors // Fallback fuer Libraries mit Product Flavors
missingDimensionStrategy 'react-native-camera', 'general' missingDimensionStrategy 'react-native-camera', 'general'
} }
@@ -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"
@@ -22,6 +22,7 @@ class MainApplication : Application(), ReactApplication {
add(AudioFocusPackage()) add(AudioFocusPackage())
add(PcmStreamPlayerPackage()) add(PcmStreamPlayerPackage())
add(OpenWakeWordPackage()) add(OpenWakeWordPackage())
add(PhoneCallPackage())
} }
override fun getJSMainModuleName(): String = "index" override fun getJSMainModuleName(): String = "index"
@@ -8,6 +8,9 @@ import android.content.pm.PackageManager
import android.media.AudioFormat import android.media.AudioFormat
import android.media.AudioRecord import android.media.AudioRecord
import android.media.MediaRecorder import android.media.MediaRecorder
import android.media.audiofx.AcousticEchoCanceler
import android.media.audiofx.AutomaticGainControl
import android.media.audiofx.NoiseSuppressor
import android.util.Log import android.util.Log
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import com.facebook.react.bridge.Promise import com.facebook.react.bridge.Promise
@@ -70,6 +73,13 @@ class OpenWakeWordModule(reactContext: ReactApplicationContext) : ReactContextBa
private val running = AtomicBoolean(false) private val running = AtomicBoolean(false)
private var captureThread: Thread? = null private var captureThread: Thread? = null
// Audio-Effects: Echo-Cancellation (gegen ARIAs eigene TTS-Stimme die sonst
// das Wake-Word triggern wuerde) + Noise-Suppression. Per VOICE_COMMUNICATION
// Audio-Source schon vorhanden, aber explizites Aktivieren ist robuster.
private var aec: AcousticEchoCanceler? = null
private var ns: NoiseSuppressor? = null
private var agc: AutomaticGainControl? = null
// Inferenz-State // Inferenz-State
private val melBuffer: ArrayList<FloatArray> = ArrayList(256) // Liste von 32-dim Frames private val melBuffer: ArrayList<FloatArray> = ArrayList(256) // Liste von 32-dim Frames
private var melProcessedIdx: Int = 0 private var melProcessedIdx: Int = 0
@@ -146,8 +156,12 @@ class OpenWakeWordModule(reactContext: ReactApplicationContext) : ReactContextBa
AudioFormat.ENCODING_PCM_16BIT, AudioFormat.ENCODING_PCM_16BIT,
).coerceAtLeast(CHUNK_SAMPLES * 2 * 4) ).coerceAtLeast(CHUNK_SAMPLES * 2 * 4)
// VOICE_COMMUNICATION-Source: aktiviert auf den meisten Android-Geraeten
// automatisch Echo-Cancellation + Noise-Suppression. Wichtig damit
// ARIAs eigene Stimme nicht das Wake-Word triggert wenn parallel
// zur TTS-Wiedergabe gelauscht wird.
val record = AudioRecord( val record = AudioRecord(
MediaRecorder.AudioSource.MIC, MediaRecorder.AudioSource.VOICE_COMMUNICATION,
SAMPLE_RATE, SAMPLE_RATE,
AudioFormat.CHANNEL_IN_MONO, AudioFormat.CHANNEL_IN_MONO,
AudioFormat.ENCODING_PCM_16BIT, AudioFormat.ENCODING_PCM_16BIT,
@@ -159,6 +173,27 @@ class OpenWakeWordModule(reactContext: ReactApplicationContext) : ReactContextBa
return return
} }
audioRecord = record audioRecord = record
// Audio-Effects ZUSAETZLICH explizit aktivieren — manche Geraete
// benoetigen das, obwohl VOICE_COMMUNICATION es eigentlich schon
// mitbringt. Failure ist nicht kritisch (continue ohne Effects).
try {
if (AcousticEchoCanceler.isAvailable()) {
aec = AcousticEchoCanceler.create(record.audioSessionId)?.apply { enabled = true }
Log.i(TAG, "AEC aktiviert (enabled=${aec?.enabled})")
}
} catch (e: Exception) { Log.w(TAG, "AEC failed: ${e.message}") }
try {
if (NoiseSuppressor.isAvailable()) {
ns = NoiseSuppressor.create(record.audioSessionId)?.apply { enabled = true }
}
} catch (e: Exception) { Log.w(TAG, "NS failed: ${e.message}") }
try {
if (AutomaticGainControl.isAvailable()) {
agc = AutomaticGainControl.create(record.audioSessionId)?.apply { enabled = true }
}
} catch (e: Exception) { Log.w(TAG, "AGC failed: ${e.message}") }
resetInferenceState() resetInferenceState()
running.set(true) running.set(true)
record.startRecording() record.startRecording()
@@ -179,6 +214,13 @@ class OpenWakeWordModule(reactContext: ReactApplicationContext) : ReactContextBa
} }
} }
private fun releaseAudioEffects() {
try { aec?.release() } catch (_: Exception) {}
try { ns?.release() } catch (_: Exception) {}
try { agc?.release() } catch (_: Exception) {}
aec = null; ns = null; agc = null
}
@ReactMethod @ReactMethod
fun stop(promise: Promise) { fun stop(promise: Promise) {
running.set(false) running.set(false)
@@ -189,6 +231,7 @@ class OpenWakeWordModule(reactContext: ReactApplicationContext) : ReactContextBa
try { audioRecord?.stop() } catch (_: Exception) {} try { audioRecord?.stop() } catch (_: Exception) {}
try { audioRecord?.release() } catch (_: Exception) {} try { audioRecord?.release() } catch (_: Exception) {}
audioRecord = null audioRecord = null
releaseAudioEffects()
Log.i(TAG, "Lauschen gestoppt") Log.i(TAG, "Lauschen gestoppt")
promise.resolve(true) promise.resolve(true)
} }
@@ -201,6 +244,7 @@ class OpenWakeWordModule(reactContext: ReactApplicationContext) : ReactContextBa
try { audioRecord?.stop() } catch (_: Exception) {} try { audioRecord?.stop() } catch (_: Exception) {}
try { audioRecord?.release() } catch (_: Exception) {} try { audioRecord?.release() } catch (_: Exception) {}
audioRecord = null audioRecord = null
releaseAudioEffects()
disposeSessions() disposeSessions()
promise.resolve(true) promise.resolve(true)
} }
@@ -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) {}
}
@@ -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()
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "aria-cockpit", "name": "aria-cockpit",
"version": "0.0.6.9", "version": "0.0.7.8",
"private": true, "private": true,
"scripts": { "scripts": {
"android": "react-native run-android", "android": "react-native run-android",
Binary file not shown.
+8 -87
View File
@@ -1,68 +1,14 @@
/** /**
* MessageText — rendert Chat-Text mit Auto-Linkifizierung: * MessageText — selektierbarer Chat-Text mit Android-Auto-Linkifizierung.
* - http(s)://... → tippbar, oeffnet im Browser
* - mailto: oder plain E-Mail → tippbar, oeffnet Mail-App
* - Telefonnummern → tippbar, oeffnet Android-Dialer
* *
* Text ist durchgaengig markierbar/kopierbar (selectable). * Wir nutzen Androids dataDetectorType="all" (System macht Phone/URL/Email
* automatisch klickbar) und ein einzelnes <Text selectable> ohne nested
* <Text> mit eigenem onPress. Nested Text mit onPress fingen die Long-Press-
* Geste ab, damit war Markieren+Kopieren defekt.
*/ */
import React from 'react'; import React from 'react';
import { Text, Linking, TextStyle, StyleProp } from 'react-native'; import { Text, TextStyle, StyleProp } from 'react-native';
// Regex kombiniert URL | Email | Telefonnummer.
// Gruppenreihenfolge ist wichtig fuer die Erkennung unten.
//
// URL: http://... oder https://... bis zum ersten Whitespace / Anfuehrungszeichen.
// Email: simpler Standard-Match (kein RFC-kompatibel aber gut genug).
// Telefon: internationale Form (+49..., 0049..., 0176...), darf Leerzeichen
// / Bindestriche / Schraegstriche / Klammern enthalten, mindestens 7
// Ziffern insgesamt. Vermeidet banale Zahlen (Uhrzeiten, Datum).
const LINK_REGEX = new RegExp(
'(https?:\\/\\/[^\\s<>"]+)' + // 1: URL
'|([A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,})' + // 2: Email
'|((?:\\+|00)\\d[\\d\\s()\\-\\/]{6,}\\d|0\\d{2,4}[\\s\\/\\-]?[\\d\\s\\-\\/]{5,}\\d)', // 3: Telefon
'g',
);
const LINK_STYLE = { color: '#0096FF', textDecorationLine: 'underline' } as TextStyle;
interface Segment {
text: string;
kind: 'text' | 'url' | 'email' | 'phone';
}
function tokenize(raw: string): Segment[] {
const out: Segment[] = [];
let lastEnd = 0;
LINK_REGEX.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = LINK_REGEX.exec(raw)) !== null) {
if (m.index > lastEnd) {
out.push({ text: raw.slice(lastEnd, m.index), kind: 'text' });
}
if (m[1]) out.push({ text: m[1], kind: 'url' });
else if (m[2]) out.push({ text: m[2], kind: 'email' });
else if (m[3]) out.push({ text: m[3], kind: 'phone' });
lastEnd = LINK_REGEX.lastIndex;
}
if (lastEnd < raw.length) out.push({ text: raw.slice(lastEnd), kind: 'text' });
return out;
}
function onPress(seg: Segment) {
try {
if (seg.kind === 'url') {
Linking.openURL(seg.text);
} else if (seg.kind === 'email') {
Linking.openURL(`mailto:${seg.text}`);
} else if (seg.kind === 'phone') {
// Android-Dialer erwartet tel:-Schema ohne Leerzeichen/Bindestriche
const clean = seg.text.replace(/[\s\-\/()]/g, '');
Linking.openURL(`tel:${clean}`);
}
} catch {}
}
interface Props { interface Props {
text: string; text: string;
@@ -70,34 +16,9 @@ interface Props {
} }
const MessageText: React.FC<Props> = ({ text, style }) => { const MessageText: React.FC<Props> = ({ text, style }) => {
const segments = React.useMemo(() => tokenize(text), [text]);
return ( return (
<Text <Text style={style} selectable dataDetectorType="all">
style={style} {text}
selectable
// dataDetectorType ist Android-only und macht Phone/URL/Email zusaetzlich
// ueber System-Detection klickbar — als Fallback falls unsere Regex-
// Tokens nicht passen.
dataDetectorType="all"
>
{segments.map((seg, i) => {
if (seg.kind === 'text') {
return <Text key={i} selectable>{seg.text}</Text>;
}
return (
<Text
key={i}
selectable
style={LINK_STYLE}
onPress={() => onPress(seg)}
// Long-Press soll an den Parent durch fuer Selection
onLongPress={undefined}
suppressHighlighting={false}
>
{seg.text}
</Text>
);
})}
</Text> </Text>
); );
}; };
-27
View File
@@ -44,7 +44,6 @@ const VoiceButton: React.FC<VoiceButtonProps> = ({
const [meterDb, setMeterDb] = useState(-160); const [meterDb, setMeterDb] = useState(-160);
const pulseAnim = useRef(new Animated.Value(1)).current; const pulseAnim = useRef(new Animated.Value(1)).current;
const durationTimer = useRef<ReturnType<typeof setInterval> | null>(null); const durationTimer = useRef<ReturnType<typeof setInterval> | null>(null);
const isLongPress = useRef(false);
// Puls-Animation starten/stoppen // Puls-Animation starten/stoppen
useEffect(() => { useEffect(() => {
@@ -117,31 +116,10 @@ const VoiceButton: React.FC<VoiceButtonProps> = ({
if (disabled || isRecording) return; if (disabled || isRecording) return;
const started = await audioService.startRecording(true); // autoStop = true const started = await audioService.startRecording(true); // autoStop = true
if (started) { if (started) {
isLongPress.current = false;
setIsRecording(true); setIsRecording(true);
} }
}, [disabled, isRecording]); }, [disabled, isRecording]);
// Push-to-Talk: Lang druecken
const handlePressIn = async () => {
if (disabled || isRecording) return;
isLongPress.current = true;
const started = await audioService.startRecording(false); // kein autoStop
if (started) {
setIsRecording(true);
}
};
const handlePressOut = async () => {
if (!isRecording || !isLongPress.current) return;
isLongPress.current = false;
setIsRecording(false);
const result = await audioService.stopRecording();
if (result && result.durationMs > 300) {
onRecordingComplete(result);
}
};
// Tap-to-Talk: Einmal tippen startet mit Auto-Stop. // Tap-to-Talk: Einmal tippen startet mit Auto-Stop.
// Guard gegen Doppel-Tap während asyncer Start/Stop. // Guard gegen Doppel-Tap während asyncer Start/Stop.
const tapBusy = useRef(false); const tapBusy = useRef(false);
@@ -162,7 +140,6 @@ const VoiceButton: React.FC<VoiceButtonProps> = ({
// Aufnahme mit Auto-Stop starten // Aufnahme mit Auto-Stop starten
const started = await audioService.startRecording(true); const started = await audioService.startRecording(true);
if (started) { if (started) {
isLongPress.current = false;
setIsRecording(true); setIsRecording(true);
} }
} }
@@ -201,10 +178,6 @@ const VoiceButton: React.FC<VoiceButtonProps> = ({
isRecording && styles.buttonOuterRecording, isRecording && styles.buttonOuterRecording,
{ transform: [{ scale: pulseAnim }] }, { transform: [{ scale: pulseAnim }] },
]} ]}
onStartShouldSetResponder={() => true}
onResponderGrant={handlePressIn}
onResponderRelease={handlePressOut}
onResponderTerminate={handlePressOut}
> >
<TouchableOpacity <TouchableOpacity
activeOpacity={0.8} activeOpacity={0.8}
+156 -15
View File
@@ -25,6 +25,8 @@ 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 { playWakeReadySound } from '../services/wakeReadySound';
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';
@@ -54,6 +56,10 @@ interface ChatMessage {
messageId?: string; messageId?: string;
/** Lokaler Pfad zur gecachten TTS-Audio-Datei (file://...) */ /** Lokaler Pfad zur gecachten TTS-Audio-Datei (file://...) */
audioPath?: string; audioPath?: string;
/** Korrelations-ID fuer Sprachnachrichten — wird mit dem STT-Result zurueck-
* gespiegelt damit wir die EXAKT richtige Placeholder-Bubble ersetzen,
* auch wenn mehrere Aufnahmen parallel offen sind. */
audioRequestId?: string;
} }
// --- Konstanten --- // --- Konstanten ---
@@ -159,10 +165,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(() => {
@@ -269,6 +288,8 @@ const ChatScreen: React.FC = () => {
if (message.type === 'chat') { if (message.type === 'chat') {
const sender = (message.payload.sender as string) || ''; const sender = (message.payload.sender as string) || '';
const dbgText = ((message.payload.text as string) || '').slice(0, 60);
console.log('[Chat] chat-event sender=%s text=%s', sender || '(none)', dbgText);
// STT-Ergebnis: Transkribierten Text in die Sprach-Bubble schreiben. // STT-Ergebnis: Transkribierten Text in die Sprach-Bubble schreiben.
// WICHTIG: Nur die ERSTE noch unaufgeloeste Aufnahme matchen — sonst // WICHTIG: Nur die ERSTE noch unaufgeloeste Aufnahme matchen — sonst
@@ -276,17 +297,42 @@ const ChatScreen: React.FC = () => {
// den gleichen Text bekommen (Bug: zweite Antwort ueberschreibt erste). // den gleichen Text bekommen (Bug: zweite Antwort ueberschreibt erste).
if (sender === 'stt') { if (sender === 'stt') {
const sttText = (message.payload.text as string) || ''; const sttText = (message.payload.text as string) || '';
if (sttText) { const sttAudioReqId = (message.payload.audioRequestId as string) || '';
setMessages(prev => { if (!sttText) {
const idx = prev.findIndex(m => return;
m.sender === 'user' && m.text.includes('Spracheingabe wird verarbeitet')
);
if (idx < 0) return prev;
const next = prev.slice();
next[idx] = { ...next[idx], text: `\uD83C\uDFA4 ${sttText}` };
return next;
});
} }
setMessages(prev => {
const newText = `\uD83C\uDFA4 ${sttText}`;
// Primaer: matche per audioRequestId (eindeutig pro Aufnahme).
// So gibt's keine Verwechslung wenn zwei Audios kurz hintereinander
// gesendet wurden und ihre STT-Results ueberlappen.
if (sttAudioReqId) {
const idxById = prev.findIndex(m => m.audioRequestId === sttAudioReqId);
if (idxById >= 0) {
const next = prev.slice();
next[idxById] = { ...next[idxById], text: newText };
return next;
}
}
// Fallback: alte Bridge-Version ohne audioRequestId \u2014 match per Substring,
// nimmt die ERSTE noch unaufgeloeste Placeholder.
const idx = prev.findIndex(m =>
m.sender === 'user' && m.text.includes('Spracheingabe wird verarbeitet')
);
if (idx >= 0) {
const next = prev.slice();
next[idx] = { ...next[idx], text: newText };
return next;
}
// Letzter Fallback: gar keine Placeholder \u2192 neue Bubble einfuegen
return capMessages([...prev, {
id: nextId(),
sender: 'user',
text: newText,
timestamp: message.timestamp,
attachments: [{ type: 'audio', name: 'Sprachaufnahme' }],
}]);
});
return; return;
} }
@@ -448,7 +494,14 @@ const ChatScreen: React.FC = () => {
// Conversation-Window: User hat X Sekunden um anzufangen, sonst Konversation aus // Conversation-Window: User hat X Sekunden um anzufangen, sonst Konversation aus
const windowMs = await loadConvWindowMs(); const windowMs = await loadConvWindowMs();
const started = await audioService.startRecording(true, windowMs); const started = await audioService.startRecording(true, windowMs);
if (!started) { if (started) {
// Erst JETZT signalisieren dass das Mikro wirklich offen ist —
// vorher war's noch in der Init-Phase. So weiss der User exakt
// ab wann er reden kann. "Bereit"-Sound (Ding-Dong) ist optional
// ueber Settings → Wake-Word abschaltbar.
ToastAndroid.show('🎤 Mikro offen — sprich jetzt', ToastAndroid.SHORT);
playWakeReadySound().catch(() => {});
} else {
// Mikrofon nicht verfuegbar, naechsten Versuch // Mikrofon nicht verfuegbar, naechsten Versuch
wakeWordService.resume(); wakeWordService.resume();
} }
@@ -459,13 +512,17 @@ const ChatScreen: React.FC = () => {
const result = await audioService.stopRecording(); const result = await audioService.stopRecording();
if (result && result.durationMs > 500) { if (result && result.durationMs > 500) {
// User hat im Fenster gesprochen → Sprachnachricht senden // User hat im Fenster gesprochen → Sprachnachricht senden
// Barge-In: laufende ARIA-Aktivitaet abbrechen wenn welche da ist.
const wasInterrupted = interruptAriaIfBusy();
const location = await getCurrentLocation(); const location = await getCurrentLocation();
const audioRequestId = `audio_${Date.now()}_${Math.floor(Math.random() * 100000)}`;
const userMsg: ChatMessage = { const userMsg: ChatMessage = {
id: nextId(), id: nextId(),
sender: 'user', sender: 'user',
text: '🎙 Spracheingabe wird verarbeitet...', text: '🎙 Spracheingabe wird verarbeitet...',
timestamp: Date.now(), timestamp: Date.now(),
attachments: [{ type: 'audio', name: 'Sprachaufnahme' }], attachments: [{ type: 'audio', name: 'Sprachaufnahme' }],
audioRequestId,
}; };
setMessages(prev => capMessages([...prev, userMsg])); setMessages(prev => capMessages([...prev, userMsg]));
rvs.send('audio', { rvs.send('audio', {
@@ -474,8 +531,11 @@ const ChatScreen: React.FC = () => {
mimeType: result.mimeType, mimeType: result.mimeType,
voice: localXttsVoiceRef.current, voice: localXttsVoiceRef.current,
speed: ttsSpeedRef.current, speed: ttsSpeedRef.current,
interrupted: wasInterrupted,
audioRequestId,
...(location && { location }), ...(location && { location }),
}); });
scheduleStaleAudioCleanup(audioRequestId);
// resume() wird durch onPlaybackFinished nach ARIAs Antwort getriggert. // resume() wird durch onPlaybackFinished nach ARIAs Antwort getriggert.
} else { } else {
// Kein Speech im Window → Konversation beenden (Ohr geht aus oder // Kein Speech im Window → Konversation beenden (Ohr geht aus oder
@@ -486,9 +546,43 @@ const ChatScreen: React.FC = () => {
} }
}); });
// Barge-In via Wake-Word: User sagt "Computer" waehrend ARIA spricht.
// Wake-Word-Service hat bei TTS-Start parallel zu lauschen begonnen
// (mit AcousticEchoCanceler damit ARIAs eigene Stimme nicht triggert).
const unsubBarge = wakeWordService.onBargeIn(async () => {
console.log('[Chat] Barge-In via Wake-Word — TTS abbrechen + neue Aufnahme');
audioService.haltAllPlayback('barge-in via wake-word');
setAgentActivity({ activity: 'idle', tool: '' });
rvs.send('cancel_request' as any, {});
// Kurze Pause damit halt durchgreift, dann neue Aufnahme starten
await new Promise(r => setTimeout(r, 150));
const windowMs = await loadConvWindowMs();
const started = await audioService.startRecording(true, windowMs);
if (started) {
ToastAndroid.show('🎤 Mikro offen — sprich jetzt', ToastAndroid.SHORT);
playWakeReadySound().catch(() => {});
}
});
// TTS-Lifecycle: solange ARIA spricht und Wake-Word verfuegbar ist,
// parallel mitlauschen — User kann "Computer" sagen statt manuell tappen.
const unsubTtsStart = audioService.onPlaybackStarted(() => {
if (wakeWordService.isConversing() && wakeWordService.hasWakeWord()) {
wakeWordService.startBargeListening().catch(() => {});
}
});
const unsubTtsEnd = audioService.onPlaybackFinished(() => {
// Vor naechster Aufnahme: barge-listening aus damit der AudioRecorder
// das Mikro greifen kann.
wakeWordService.stopBargeListening().catch(() => {});
});
return () => { return () => {
unsubWake(); unsubWake();
unsubSilence(); unsubSilence();
unsubBarge();
unsubTtsStart();
unsubTtsEnd();
}; };
}, [wakeWordActive]); }, [wakeWordActive]);
@@ -563,6 +657,25 @@ const ChatScreen: React.FC = () => {
// --- Nachricht senden --- // --- Nachricht senden ---
// Aufraeumen von "verarbeitet"-Placeholder die nie ein STT-Result bekommen
// haben (leere Aufnahme, Wake-Word-Echo, STT-Fehler etc). Nach 30s werden
// sie automatisch entfernt damit nicht-erkannte Aufnahmen nicht den State
// verstopfen + naechste echte Aufnahmen die richtige Bubble ersetzen koennen.
const scheduleStaleAudioCleanup = useCallback((audioRequestId: string) => {
setTimeout(() => {
setMessages(prev => {
const idx = prev.findIndex(m =>
m.audioRequestId === audioRequestId &&
m.text.includes('Spracheingabe wird verarbeitet')
);
if (idx < 0) return prev;
console.log('[Chat] Sprachnachricht ohne STT-Result entfernt: %s', audioRequestId);
ToastAndroid.show('Sprachnachricht nicht erkannt — entfernt', ToastAndroid.SHORT);
return prev.filter((_, i) => i !== idx);
});
}, 30000);
}, []);
const sendTextMessage = useCallback(async () => { const sendTextMessage = useCallback(async () => {
const text = inputText.trim(); const text = inputText.trim();
@@ -576,6 +689,8 @@ const ChatScreen: React.FC = () => {
setInputText(''); setInputText('');
// Barge-In: laufende ARIA-Aktivitaet abbrechen wenn welche da ist.
const wasInterrupted = interruptAriaIfBusy();
const location = await getCurrentLocation(); const location = await getCurrentLocation();
const userMsg: ChatMessage = { const userMsg: ChatMessage = {
@@ -586,16 +701,17 @@ const ChatScreen: React.FC = () => {
}; };
setMessages(prev => capMessages([...prev, userMsg])); setMessages(prev => capMessages([...prev, userMsg]));
console.log('[Chat] sende mit voice=%s speed=%s', console.log('[Chat] sende mit voice=%s speed=%s interrupted=%s',
localXttsVoiceRef.current || '(default)', ttsSpeedRef.current); localXttsVoiceRef.current || '(default)', ttsSpeedRef.current, wasInterrupted);
// An RVS senden — mit geraetelokaler Voice (Bridge nutzt sie fuer die Antwort) // An RVS senden — mit geraetelokaler Voice (Bridge nutzt sie fuer die Antwort)
rvs.send('chat', { rvs.send('chat', {
text, text,
voice: localXttsVoiceRef.current, voice: localXttsVoiceRef.current,
speed: ttsSpeedRef.current, speed: ttsSpeedRef.current,
interrupted: wasInterrupted,
...(location && { location }), ...(location && { location }),
}); });
}, [inputText, getCurrentLocation, pendingAttachments, sendPendingAttachments]); }, [inputText, getCurrentLocation, pendingAttachments, sendPendingAttachments, interruptAriaIfBusy]);
// Anfrage abbrechen — sofort lokalen Indicator weg, Bridge triggert doctor --fix // Anfrage abbrechen — sofort lokalen Indicator weg, Bridge triggert doctor --fix
const cancelRequest = useCallback(() => { const cancelRequest = useCallback(() => {
@@ -603,15 +719,37 @@ const ChatScreen: React.FC = () => {
rvs.send('cancel_request' as any, {}); rvs.send('cancel_request' as any, {});
}, []); }, []);
// Barge-In: wenn der User waehrend ARIA arbeitet/spricht eine neue Sprach-
// Nachricht aufnimmt, alte Aktivitaet sofort abbrechen — TTS verstummen,
// aria-core-Run via cancel_request abbrechen. So kann man "ach vergiss es,
// mach lieber X" sagen wie in einem echten Gespraech.
const interruptAriaIfBusy = useCallback(() => {
const speaking = audioService.isPlayingAudio();
const thinking = agentActivity.activity !== 'idle';
if (!speaking && !thinking) return false;
console.log('[Chat] Barge-In: speaking=%s thinking=%s — interrupting ARIA',
speaking, thinking);
if (speaking) audioService.haltAllPlayback('user spricht (barge-in)');
if (thinking) {
setAgentActivity({ activity: 'idle', tool: '' });
rvs.send('cancel_request' as any, {});
}
return true;
}, [agentActivity]);
// Sprachaufnahme abgeschlossen // Sprachaufnahme abgeschlossen
const handleVoiceRecording = useCallback(async (result: RecordingResult) => { const handleVoiceRecording = useCallback(async (result: RecordingResult) => {
// Barge-In: laufende ARIA-Aktivitaet abbrechen falls aktiv.
const wasInterrupted = interruptAriaIfBusy();
const location = await getCurrentLocation(); const location = await getCurrentLocation();
const audioRequestId = `audio_${Date.now()}_${Math.floor(Math.random() * 100000)}`;
const userMsg: ChatMessage = { const userMsg: ChatMessage = {
id: nextId(), id: nextId(),
sender: 'user', sender: 'user',
text: '🎙 Spracheingabe wird verarbeitet...', text: '🎙 Spracheingabe wird verarbeitet...',
timestamp: Date.now(), timestamp: Date.now(),
audioRequestId,
}; };
setMessages(prev => capMessages([...prev, userMsg])); setMessages(prev => capMessages([...prev, userMsg]));
@@ -621,9 +759,12 @@ const ChatScreen: React.FC = () => {
mimeType: result.mimeType, mimeType: result.mimeType,
voice: localXttsVoiceRef.current, voice: localXttsVoiceRef.current,
speed: ttsSpeedRef.current, speed: ttsSpeedRef.current,
interrupted: wasInterrupted,
audioRequestId,
...(location && { location }), ...(location && { location }),
}); });
}, [getCurrentLocation]); scheduleStaleAudioCleanup(audioRequestId);
}, [getCurrentLocation, interruptAriaIfBusy, scheduleStaleAudioCleanup]);
// Datei auswaehlen → zur Pending-Liste hinzufuegen // Datei auswaehlen → zur Pending-Liste hinzufuegen
const handleFileSelected = useCallback(async (file: FileData) => { const handleFileSelected = useCallback(async (file: FileData) => {
+195
View File
@@ -35,11 +35,20 @@ import {
CONV_WINDOW_MIN_SEC, CONV_WINDOW_MIN_SEC,
CONV_WINDOW_MAX_SEC, CONV_WINDOW_MAX_SEC,
CONV_WINDOW_STORAGE_KEY, CONV_WINDOW_STORAGE_KEY,
MAX_RECORDING_DEFAULT_SEC,
MAX_RECORDING_MIN_SEC,
MAX_RECORDING_MAX_SEC,
MAX_RECORDING_STORAGE_KEY,
TTS_SPEED_DEFAULT, TTS_SPEED_DEFAULT,
TTS_SPEED_MIN, TTS_SPEED_MIN,
TTS_SPEED_MAX, TTS_SPEED_MAX,
TTS_SPEED_STORAGE_KEY, TTS_SPEED_STORAGE_KEY,
} from '../services/audio'; } from '../services/audio';
import {
isWakeReadySoundEnabled,
setWakeReadySoundEnabled,
playWakeReadySound,
} from '../services/wakeReadySound';
import wakeWordService, { import wakeWordService, {
WAKE_KEYWORDS, WAKE_KEYWORDS,
KEYWORD_LABELS, KEYWORD_LABELS,
@@ -72,6 +81,18 @@ interface EventEntry {
type LogTab = 'live' | 'events'; type LogTab = 'live' | 'events';
// Settings-Sub-Screens. Reihenfolge im Hauptmenue.
const SETTINGS_SECTIONS = [
{ id: 'connection', icon: '🔌', label: 'Verbindung', desc: 'Server, Token, Status, Verbindungslog' },
{ id: 'general', icon: '⚙️', label: 'Allgemein', desc: 'Betriebsmodus, GPS-Standort' },
{ id: 'voice_input', icon: '🎙️', label: 'Spracheingabe', desc: 'Stille-Toleranz, Aufnahmedauer' },
{ id: 'wake_word', icon: '👂', label: 'Wake-Word', desc: 'Wake-Word-Auswahl' },
{ id: 'voice_output', icon: '🔊', label: 'Sprachausgabe', desc: 'Stimmen, Pre-Roll, Geschwindigkeit' },
{ id: 'storage', icon: '📁', label: 'Speicher', desc: 'Anhang-Speicherort, Auto-Download' },
{ id: 'protocol', icon: '📜', label: 'Protokoll', desc: 'Privatsphaere, Backup' },
{ id: 'about', icon: '️', label: 'Ueber', desc: 'App-Version, Update' },
] as const;
// Container-Farben fuer Live-Logs // Container-Farben fuer Live-Logs
const SOURCE_COLORS: Record<string, string> = { const SOURCE_COLORS: Record<string, string> = {
'aria-core': '#4A9EFF', // Blau 'aria-core': '#4A9EFF', // Blau
@@ -102,15 +123,21 @@ const SettingsScreen: React.FC = () => {
const [ttsPrerollSec, setTtsPrerollSec] = useState<number>(TTS_PREROLL_DEFAULT_SEC); const [ttsPrerollSec, setTtsPrerollSec] = useState<number>(TTS_PREROLL_DEFAULT_SEC);
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 [maxRecordingSec, setMaxRecordingSec] = useState<number>(MAX_RECORDING_DEFAULT_SEC);
const [ttsSpeed, setTtsSpeed] = useState<number>(TTS_SPEED_DEFAULT); const [ttsSpeed, setTtsSpeed] = useState<number>(TTS_SPEED_DEFAULT);
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 [wakeReadySound, setWakeReadySound] = useState<boolean>(true);
const [editingPath, setEditingPath] = useState(false); const [editingPath, setEditingPath] = useState(false);
const [xttsVoice, setXttsVoice] = useState(''); const [xttsVoice, setXttsVoice] = useState('');
const [loadingVoice, setLoadingVoice] = useState<string | null>(null); const [loadingVoice, setLoadingVoice] = useState<string | null>(null);
const [availableVoices, setAvailableVoices] = useState<Array<{name: string, size: number}>>([]); const [availableVoices, setAvailableVoices] = useState<Array<{name: string, size: number}>>([]);
const [voiceCloneVisible, setVoiceCloneVisible] = useState(false); const [voiceCloneVisible, setVoiceCloneVisible] = useState(false);
const [tempPath, setTempPath] = useState(''); const [tempPath, setTempPath] = useState('');
// Sub-Screen Navigation: null = Hauptmenue, sonst eine der Section-IDs.
// So bleibt aller geteilte State im selben Component-Closure und wir
// brauchen keine react-navigation-Stack-Setup.
const [currentSection, setCurrentSection] = useState<string | null>(null);
let logIdCounter = 0; let logIdCounter = 0;
@@ -156,6 +183,14 @@ const SettingsScreen: React.FC = () => {
} }
} }
}); });
AsyncStorage.getItem(MAX_RECORDING_STORAGE_KEY).then(saved => {
if (saved != null) {
const n = parseFloat(saved);
if (isFinite(n) && n >= MAX_RECORDING_MIN_SEC && n <= MAX_RECORDING_MAX_SEC) {
setMaxRecordingSec(n);
}
}
});
AsyncStorage.getItem(TTS_SPEED_STORAGE_KEY).then(saved => { AsyncStorage.getItem(TTS_SPEED_STORAGE_KEY).then(saved => {
if (saved != null) { if (saved != null) {
const n = parseFloat(saved); const n = parseFloat(saved);
@@ -165,6 +200,7 @@ const SettingsScreen: React.FC = () => {
AsyncStorage.getItem(WAKE_KEYWORD_STORAGE).then(saved => { AsyncStorage.getItem(WAKE_KEYWORD_STORAGE).then(saved => {
if (saved && (WAKE_KEYWORDS as readonly string[]).includes(saved)) setWakeKeyword(saved); if (saved && (WAKE_KEYWORDS as readonly string[]).includes(saved)) setWakeKeyword(saved);
}); });
isWakeReadySoundEnabled().then(setWakeReadySound);
AsyncStorage.getItem('aria_xtts_voice').then(saved => { AsyncStorage.getItem('aria_xtts_voice').then(saved => {
if (saved) setXttsVoice(saved); if (saved) setXttsVoice(saved);
}); });
@@ -480,7 +516,39 @@ const SettingsScreen: React.FC = () => {
/> />
<ScrollView style={styles.container} contentContainerStyle={styles.content}> <ScrollView style={styles.container} contentContainerStyle={styles.content}>
{currentSection === null && (
<>
{SETTINGS_SECTIONS.map(s => (
<TouchableOpacity
key={s.id}
style={styles.menuItem}
onPress={() => setCurrentSection(s.id)}
>
<Text style={styles.menuItemIcon}>{s.icon}</Text>
<View style={styles.menuItemTextWrap}>
<Text style={styles.menuItemLabel}>{s.label}</Text>
<Text style={styles.menuItemDesc}>{s.desc}</Text>
</View>
<Text style={styles.menuItemChevron}></Text>
</TouchableOpacity>
))}
</>
)}
{currentSection !== null && (
<TouchableOpacity
style={styles.subScreenHeader}
onPress={() => setCurrentSection(null)}
>
<Text style={styles.subScreenBack}></Text>
<Text style={styles.subScreenTitle}>
{SETTINGS_SECTIONS.find(s => s.id === currentSection)?.label || ''}
</Text>
</TouchableOpacity>
)}
{/* === Verbindung === */} {/* === Verbindung === */}
{currentSection === 'connection' && (<>
<Text style={styles.sectionTitle}>Verbindung</Text> <Text style={styles.sectionTitle}>Verbindung</Text>
<View style={styles.card}> <View style={styles.card}>
{/* Status-Anzeige */} {/* Status-Anzeige */}
@@ -577,8 +645,10 @@ const SettingsScreen: React.FC = () => {
<Text style={styles.clearButtonText}>Log l{'\u00F6'}schen</Text> <Text style={styles.clearButtonText}>Log l{'\u00F6'}schen</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</>)}
{/* === Modus === */} {/* === Modus === */}
{currentSection === 'general' && (<>
<Text style={styles.sectionTitle}>Betriebsmodus</Text> <Text style={styles.sectionTitle}>Betriebsmodus</Text>
<View style={styles.card}> <View style={styles.card}>
<ModeSelector currentModeId={currentMode} onModeChange={handleModeChange} /> <ModeSelector currentModeId={currentMode} onModeChange={handleModeChange} />
@@ -602,8 +672,10 @@ const SettingsScreen: React.FC = () => {
/> />
</View> </View>
</View> </View>
</>)}
{/* === Spracheingabe (geraetelokal) === */} {/* === Spracheingabe (geraetelokal) === */}
{currentSection === 'voice_input' && (<>
<Text style={styles.sectionTitle}>Spracheingabe</Text> <Text style={styles.sectionTitle}>Spracheingabe</Text>
<View style={styles.card}> <View style={styles.card}>
<Text style={styles.toggleLabel}>Stille-Toleranz</Text> <Text style={styles.toggleLabel}>Stille-Toleranz</Text>
@@ -671,9 +743,44 @@ const SettingsScreen: React.FC = () => {
<Text style={styles.prerollButtonText}>+1</Text> <Text style={styles.prerollButtonText}>+1</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
<Text style={[styles.toggleLabel, {marginTop: 24}]}>Maximale Aufnahmedauer</Text>
<Text style={styles.toggleHint}>
Notbremse: nach so vielen Minuten wird die Aufnahme automatisch beendet,
auch wenn keine Stille erkannt wurde. Nuetzlich fuer lange Erklaerungen
oder Diktate. Default: {Math.round(MAX_RECORDING_DEFAULT_SEC / 60)} Min, max {Math.round(MAX_RECORDING_MAX_SEC / 60)} Min.
</Text>
<View style={styles.prerollRow}>
<TouchableOpacity
style={styles.prerollButton}
onPress={() => {
const next = Math.max(MAX_RECORDING_MIN_SEC, maxRecordingSec - 60);
setMaxRecordingSec(next);
AsyncStorage.setItem(MAX_RECORDING_STORAGE_KEY, String(next));
}}
disabled={maxRecordingSec <= MAX_RECORDING_MIN_SEC}
>
<Text style={styles.prerollButtonText}>1m</Text>
</TouchableOpacity>
<Text style={styles.prerollValue}>{Math.round(maxRecordingSec / 60)} min</Text>
<TouchableOpacity
style={styles.prerollButton}
onPress={() => {
const next = Math.min(MAX_RECORDING_MAX_SEC, maxRecordingSec + 60);
setMaxRecordingSec(next);
AsyncStorage.setItem(MAX_RECORDING_STORAGE_KEY, String(next));
}}
disabled={maxRecordingSec >= MAX_RECORDING_MAX_SEC}
>
<Text style={styles.prerollButtonText}>+1m</Text>
</TouchableOpacity>
</View>
</View> </View>
</>)}
{/* === Wake-Word (komplett on-device, openWakeWord) === */} {/* === Wake-Word (komplett on-device, openWakeWord) === */}
{currentSection === 'wake_word' && (<>
<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}>
@@ -728,9 +835,36 @@ const SettingsScreen: React.FC = () => {
{!!wakeStatus && ( {!!wakeStatus && (
<Text style={{marginTop: 8, fontSize: 12, color: '#8888AA'}}>{wakeStatus}</Text> <Text style={{marginTop: 8, fontSize: 12, color: '#8888AA'}}>{wakeStatus}</Text>
)} )}
<View style={[styles.toggleRow, {marginTop: 20, borderTopWidth: 1, borderTopColor: '#1E1E2E', paddingTop: 16}]}>
<View style={styles.toggleInfo}>
<Text style={styles.toggleLabel}>Bereit-Sound abspielen</Text>
<Text style={styles.toggleHint}>
Kurzer Ding-Dong wenn das Mikro nach Wake-Word offen ist —
akustische Bestaetigung dass du jetzt sprechen darfst.
</Text>
</View>
<Switch
value={wakeReadySound}
onValueChange={async (val) => {
setWakeReadySound(val);
await setWakeReadySoundEnabled(val);
if (val) {
// Direkt eine Vorschau abspielen damit der User weiss wie's klingt.
// playWakeReadySound checked das gerade gesetzte Flag — wenn val=true,
// wird abgespielt; bei false bleibt es still.
setTimeout(() => playWakeReadySound().catch(() => {}), 150);
}
}}
trackColor={{ false: '#2A2A3E', true: '#0096FF' }}
thumbColor={wakeReadySound ? '#FFFFFF' : '#666680'}
/>
</View>
</View> </View>
</>)}
{/* === Sprachausgabe (geraetelokal) === */} {/* === Sprachausgabe (geraetelokal) === */}
{currentSection === 'voice_output' && (<>
<Text style={styles.sectionTitle}>Sprachausgabe</Text> <Text style={styles.sectionTitle}>Sprachausgabe</Text>
<View style={styles.card}> <View style={styles.card}>
<View style={styles.toggleRow}> <View style={styles.toggleRow}>
@@ -873,7 +1007,10 @@ const SettingsScreen: React.FC = () => {
)} )}
</View> </View>
</>)}
{/* === Speicher === */} {/* === Speicher === */}
{currentSection === 'storage' && (<>
<Text style={styles.sectionTitle}>Anhang-Speicher</Text> <Text style={styles.sectionTitle}>Anhang-Speicher</Text>
<View style={styles.card}> <View style={styles.card}>
<View style={styles.toggleRow}> <View style={styles.toggleRow}>
@@ -948,7 +1085,10 @@ const SettingsScreen: React.FC = () => {
)} )}
</View> </View>
</>)}
{/* === Logs === */} {/* === Logs === */}
{currentSection === 'protocol' && (<>
<Text style={styles.sectionTitle}>Protokoll</Text> <Text style={styles.sectionTitle}>Protokoll</Text>
<View style={styles.card}> <View style={styles.card}>
{/* Tab-Umschalter */} {/* Tab-Umschalter */}
@@ -1027,8 +1167,10 @@ const SettingsScreen: React.FC = () => {
<Text style={styles.clearButtonText}>Protokoll l\u00F6schen</Text> <Text style={styles.clearButtonText}>Protokoll l\u00F6schen</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</>)}
{/* === About === */} {/* === About === */}
{currentSection === 'about' && (<>
<Text style={styles.sectionTitle}>{'\u00DC'}ber</Text> <Text style={styles.sectionTitle}>{'\u00DC'}ber</Text>
<View style={styles.card}> <View style={styles.card}>
<Text style={styles.aboutTitle}>ARIA Cockpit</Text> <Text style={styles.aboutTitle}>ARIA Cockpit</Text>
@@ -1048,6 +1190,7 @@ const SettingsScreen: React.FC = () => {
<Text style={styles.connectButtonText}>Auf Updates pr{'\u00FC'}fen</Text> <Text style={styles.connectButtonText}>Auf Updates pr{'\u00FC'}fen</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</>)}
{/* Platz am Ende */} {/* Platz am Ende */}
<View style={styles.bottomSpacer} /> <View style={styles.bottomSpacer} />
@@ -1076,6 +1219,58 @@ const styles = StyleSheet.create({
marginBottom: 8, marginBottom: 8,
marginLeft: 4, marginLeft: 4,
}, },
menuItem: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#1E1E2E',
borderRadius: 10,
paddingVertical: 14,
paddingHorizontal: 14,
marginBottom: 8,
},
menuItemIcon: {
fontSize: 22,
marginRight: 14,
width: 28,
textAlign: 'center',
},
menuItemTextWrap: {
flex: 1,
},
menuItemLabel: {
color: '#FFFFFF',
fontSize: 16,
fontWeight: '600',
},
menuItemDesc: {
color: '#8888AA',
fontSize: 12,
marginTop: 2,
},
menuItemChevron: {
color: '#8888AA',
fontSize: 24,
fontWeight: '300',
marginLeft: 8,
},
subScreenHeader: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 8,
marginBottom: 8,
},
subScreenBack: {
color: '#0096FF',
fontSize: 32,
fontWeight: '300',
marginRight: 12,
lineHeight: 36,
},
subScreenTitle: {
color: '#FFFFFF',
fontSize: 20,
fontWeight: '700',
},
card: { card: {
backgroundColor: '#12122A', backgroundColor: '#12122A',
borderRadius: 14, borderRadius: 14,
+135 -13
View File
@@ -6,7 +6,7 @@
* Nutzt react-native-audio-recorder-player fuer Aufnahme. * Nutzt react-native-audio-recorder-player fuer Aufnahme.
*/ */
import { Platform, PermissionsAndroid, NativeModules } from 'react-native'; import { Platform, PermissionsAndroid, NativeModules, ToastAndroid } from 'react-native';
import Sound from 'react-native-sound'; import Sound from 'react-native-sound';
import RNFS from 'react-native-fs'; import RNFS from 'react-native-fs';
import AsyncStorage from '@react-native-async-storage/async-storage'; import AsyncStorage from '@react-native-async-storage/async-storage';
@@ -72,9 +72,16 @@ const AUDIO_SAMPLE_RATE = 16000;
const AUDIO_CHANNELS = 1; const AUDIO_CHANNELS = 1;
const AUDIO_ENCODING = 'audio/wav'; const AUDIO_ENCODING = 'audio/wav';
// VAD (Voice Activity Detection) — Stille-Erkennung // VAD (Voice Activity Detection) — Stille-Erkennung.
const VAD_SILENCE_THRESHOLD_DB = -45; // dB unter dem als "Stille" gilt // Fallback-Werte falls die adaptive Baseline-Messung fehlschlaegt (z.B. weil
const VAD_SPEECH_THRESHOLD_DB = -28; // dB ueber dem als "Sprache" gilt (Sprach-Gate) — hoeher = weniger Umgebungsgeraeusche // das Mikro keine metering-Updates liefert). Adaptive Werte werden zur
// Laufzeit aus den ersten BASELINE_SAMPLES gemessen und auf baseline+offset
// gesetzt — funktioniert in lauten wie leisen Umgebungen.
const VAD_SILENCE_FALLBACK_DB = -38; // Fallback Stille-Schwelle
const VAD_SPEECH_FALLBACK_DB = -22; // Fallback Sprach-Schwelle
const VAD_SILENCE_OFFSET_DB = 6; // Sprache = Baseline + 6dB
const VAD_SPEECH_OFFSET_DB = 12; // sicheres Speech = Baseline + 12dB
const VAD_BASELINE_SAMPLES = 5; // 5 × 100ms = 500ms Baseline
const VAD_SPEECH_MIN_MS = 500; // ms Sprache bevor Aufnahme zaehlt — laenger = keine Huestler/Klopfer mehr const VAD_SPEECH_MIN_MS = 500; // ms Sprache bevor Aufnahme zaehlt — laenger = keine Huestler/Klopfer mehr
// VAD-Stille (in Sekunden) — wie lange Sprechpause toleriert wird, bevor // VAD-Stille (in Sekunden) — wie lange Sprechpause toleriert wird, bevor
@@ -138,7 +145,24 @@ async function loadVadSilenceMs(): Promise<number> {
// Max-Dauer einer Aufnahme (Notbremse gegen Runaway-Loops). Auf 2 Minuten // Max-Dauer einer Aufnahme (Notbremse gegen Runaway-Loops). Auf 2 Minuten
// hochgezogen damit auch laengere Erklaerungen durchgehen. // hochgezogen damit auch laengere Erklaerungen durchgehen.
const MAX_RECORDING_MS = 120000; // Default 5 Minuten — konfigurierbar in den App-Settings (1-30 Minuten).
export const MAX_RECORDING_DEFAULT_SEC = 300;
export const MAX_RECORDING_MIN_SEC = 60;
export const MAX_RECORDING_MAX_SEC = 1800;
export const MAX_RECORDING_STORAGE_KEY = 'aria_max_recording_sec';
export async function loadMaxRecordingMs(): Promise<number> {
try {
const raw = await AsyncStorage.getItem(MAX_RECORDING_STORAGE_KEY);
if (raw != null) {
const n = parseFloat(raw);
if (isFinite(n) && n >= MAX_RECORDING_MIN_SEC && n <= MAX_RECORDING_MAX_SEC) {
return Math.round(n * 1000);
}
}
} catch {}
return MAX_RECORDING_DEFAULT_SEC * 1000;
}
// Pre-Roll: Wie lange Audio im AudioTrack-Buffer liegt bevor play() startet. // Pre-Roll: Wie lange Audio im AudioTrack-Buffer liegt bevor play() startet.
// Einstellbar via Diagnostic/Settings (Key: aria_tts_preroll_sec). // Einstellbar via Diagnostic/Settings (Key: aria_tts_preroll_sec).
@@ -198,6 +222,12 @@ class AudioService {
private focusReleaseTimer: ReturnType<typeof setTimeout> | null = null; private focusReleaseTimer: ReturnType<typeof setTimeout> | null = null;
private readonly FOCUS_RELEASE_DELAY_MS = 800; 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;
@@ -206,6 +236,14 @@ class AudioService {
// Latch damit der Silence-Callback pro Aufnahme genau einmal feuert // Latch damit der Silence-Callback pro Aufnahme genau einmal feuert
private silenceFired: boolean = false; private silenceFired: boolean = false;
private noSpeechTimer: ReturnType<typeof setTimeout> | null = null; private noSpeechTimer: ReturnType<typeof setTimeout> | null = null;
// Adaptive Schwellen — werden in den ersten 500ms aus dem Mikro-Pegel
// gemessen. baseline = avg dB der ersten 5 Samples, dann:
// silence = baseline + VAD_SILENCE_OFFSET_DB (6dB ueber ambient)
// speech = baseline + VAD_SPEECH_OFFSET_DB (12dB ueber ambient = klares Reden)
// Funktioniert sowohl im stillen Buero als auch im lauten Cafe.
private vadBaselineSamples: number[] = [];
private vadAdaptiveSilenceDb: number = VAD_SILENCE_FALLBACK_DB;
private vadAdaptiveSpeechDb: number = VAD_SPEECH_FALLBACK_DB;
constructor() { constructor() {
this.recorder = new AudioRecorderPlayer(); this.recorder = new AudioRecorderPlayer();
@@ -214,11 +252,18 @@ class AudioService {
/** AudioFocus mit kleiner Verzoegerung freigeben — Spotify/YouTube /** AudioFocus mit kleiner Verzoegerung freigeben — Spotify/YouTube
* springen sonst im Gap zwischen zwei TTS-Streams (oder wenn ARIA * springen sonst im Gap zwischen zwei TTS-Streams (oder wenn ARIA
* eine zweite Antwort direkt hinterherschickt) kurz wieder an. */ * 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 { private _releaseFocusDeferred(): void {
if (this._conversationFocusActive) {
this._cancelDeferredFocusRelease();
return;
}
this._cancelDeferredFocusRelease(); this._cancelDeferredFocusRelease();
this.focusReleaseTimer = setTimeout(() => { this.focusReleaseTimer = setTimeout(() => {
this.focusReleaseTimer = null; this.focusReleaseTimer = null;
if (this._conversationFocusActive) return;
AudioFocus?.release().catch(() => {}); AudioFocus?.release().catch(() => {});
}, this.FOCUS_RELEASE_DELAY_MS); }, this.FOCUS_RELEASE_DELAY_MS);
} }
@@ -230,6 +275,41 @@ class AudioService {
} }
} }
/** 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();
}
/** True wenn ARIA gerade was abspielt — egal ob WAV-Queue oder PCM-Stream.
* Nuetzlich fuer "Barge-In": wenn der User spricht waehrend ARIA spricht,
* soll die ARIA-Wiedergabe abgebrochen + die neue User-Message verarbeitet
* werden ("ach vergiss es, mach lieber X"). */
isPlayingAudio(): boolean {
return this.isPlaying || this.pcmStreamActive;
}
// --- Berechtigungen --- // --- Berechtigungen ---
async requestMicrophonePermission(): Promise<boolean> { async requestMicrophonePermission(): Promise<boolean> {
@@ -301,8 +381,25 @@ class AudioService {
const db = e.currentMetering ?? -160; const db = e.currentMetering ?? -160;
this.meterListeners.forEach(cb => cb(db)); this.meterListeners.forEach(cb => cb(db));
// Adaptive Baseline: erste 5 Samples (~500ms) sammeln, dann Schwellen
// anpassen. -160 (kein Metering) ignorieren — sonst wird die Baseline
// sinnlos niedrig.
if (this.vadBaselineSamples.length < VAD_BASELINE_SAMPLES) {
if (db > -100) {
this.vadBaselineSamples.push(db);
if (this.vadBaselineSamples.length === VAD_BASELINE_SAMPLES) {
const avg = this.vadBaselineSamples.reduce((a, b) => a + b, 0) / VAD_BASELINE_SAMPLES;
this.vadAdaptiveSilenceDb = avg + VAD_SILENCE_OFFSET_DB;
this.vadAdaptiveSpeechDb = avg + VAD_SPEECH_OFFSET_DB;
const msg = `VAD: ambient=${avg.toFixed(0)}dB stille>${this.vadAdaptiveSilenceDb.toFixed(0)}dB`;
console.log('[Audio] %s speech>%s', msg, this.vadAdaptiveSpeechDb.toFixed(1));
try { ToastAndroid.show(msg, ToastAndroid.SHORT); } catch {}
}
}
}
// Sprach-Gate: Erkennen ob tatsaechlich gesprochen wird // Sprach-Gate: Erkennen ob tatsaechlich gesprochen wird
if (db > VAD_SPEECH_THRESHOLD_DB) { if (db > this.vadAdaptiveSpeechDb) {
if (!this.speechDetected && this.speechStartTime === 0) { if (!this.speechDetected && this.speechStartTime === 0) {
this.speechStartTime = Date.now(); this.speechStartTime = Date.now();
} }
@@ -317,7 +414,7 @@ class AudioService {
// VAD: Stille erkennen (nur wenn Sprache erkannt wurde) // VAD: Stille erkennen (nur wenn Sprache erkannt wurde)
if (this.vadEnabled) { if (this.vadEnabled) {
if (db > VAD_SILENCE_THRESHOLD_DB) { if (db > this.vadAdaptiveSilenceDb) {
this.lastSpeechTime = Date.now(); this.lastSpeechTime = Date.now();
} }
} }
@@ -327,6 +424,12 @@ class AudioService {
this.lastSpeechTime = Date.now(); this.lastSpeechTime = Date.now();
this.speechDetected = false; this.speechDetected = false;
this.speechStartTime = 0; this.speechStartTime = 0;
// VAD-Adaptive zurueckgesetzt: Baseline wird in den ersten 500ms neu
// gemessen. Bis dahin gelten die Fallback-Schwellen — die sind etwas
// empfindlicher als die alten Werte (-38 statt -45 fuer Stille).
this.vadBaselineSamples = [];
this.vadAdaptiveSilenceDb = VAD_SILENCE_FALLBACK_DB;
this.vadAdaptiveSpeechDb = VAD_SPEECH_FALLBACK_DB;
this.setState('recording'); this.setState('recording');
// Andere Apps waehrend der Aufnahme pausieren (Musik, Videos etc.) // Andere Apps waehrend der Aufnahme pausieren (Musik, Videos etc.)
@@ -354,18 +457,19 @@ class AudioService {
}; };
if (autoStop) { if (autoStop) {
const vadSilenceMs = await loadVadSilenceMs(); const vadSilenceMs = await loadVadSilenceMs();
const maxRecordingMs = await loadMaxRecordingMs();
console.log('[Audio] startRecording: autoStop=true, VAD-Stille=%dms, MAX=%dms', console.log('[Audio] startRecording: autoStop=true, VAD-Stille=%dms, MAX=%dms',
vadSilenceMs, MAX_RECORDING_MS); vadSilenceMs, maxRecordingMs);
this.vadTimer = setInterval(() => { this.vadTimer = setInterval(() => {
const silenceDuration = Date.now() - this.lastSpeechTime; const silenceDuration = Date.now() - this.lastSpeechTime;
if (silenceDuration >= vadSilenceMs) { if (silenceDuration >= vadSilenceMs) {
fireSilenceOnce(`VAD ${silenceDuration}ms Stille (Schwelle=${vadSilenceMs}ms)`); fireSilenceOnce(`VAD ${silenceDuration}ms Stille (Schwelle=${vadSilenceMs}ms)`);
} }
}, 200); }, 200);
// Notbremse: Nach MAX_RECORDING_MS zwangsweise stoppen // Notbremse: Nach maxRecordingMs zwangsweise stoppen
this.maxDurationTimer = setTimeout(() => { this.maxDurationTimer = setTimeout(() => {
fireSilenceOnce(`Max-Dauer ${MAX_RECORDING_MS}ms`); fireSilenceOnce(`Max-Dauer ${maxRecordingMs}ms`);
}, MAX_RECORDING_MS); }, maxRecordingMs);
} }
// Conversation-Window: Wenn der User innerhalb noSpeechTimeoutMs nicht // Conversation-Window: Wenn der User innerhalb noSpeechTimeoutMs nicht
@@ -564,6 +668,7 @@ class AudioService {
} }
this._cancelDeferredFocusRelease(); this._cancelDeferredFocusRelease();
AudioFocus?.requestDuck().catch(() => {}); AudioFocus?.requestDuck().catch(() => {});
this._firePlaybackStarted();
} }
} }
@@ -678,6 +783,7 @@ class AudioService {
// Callback wenn alle Audio-Teile abgespielt sind // Callback wenn alle Audio-Teile abgespielt sind
private playbackFinishedListeners: (() => void)[] = []; private playbackFinishedListeners: (() => void)[] = [];
private playbackStartedListeners: (() => void)[] = [];
onPlaybackFinished(callback: () => void): () => void { onPlaybackFinished(callback: () => void): () => void {
this.playbackFinishedListeners.push(callback); this.playbackFinishedListeners.push(callback);
@@ -686,6 +792,21 @@ class AudioService {
}; };
} }
/** Callback wenn ARIAs TTS-Wiedergabe startet — fuer Wake-Word-parallel-
* Listening waehrend ARIA spricht (Barge-In via "Computer" sagen). */
onPlaybackStarted(callback: () => void): () => void {
this.playbackStartedListeners.push(callback);
return () => {
this.playbackStartedListeners = this.playbackStartedListeners.filter(cb => cb !== callback);
};
}
private _firePlaybackStarted(): void {
this.playbackStartedListeners.forEach(cb => {
try { cb(); } catch (e) { console.warn('[Audio] playbackStarted listener err:', e); }
});
}
/** Naechstes Audio aus der Queue abspielen */ /** Naechstes Audio aus der Queue abspielen */
private async _playNext(): Promise<void> { private async _playNext(): Promise<void> {
if (this.audioQueue.length === 0) { if (this.audioQueue.length === 0) {
@@ -698,10 +819,11 @@ class AudioService {
return; return;
} }
// Beim ersten Playback-Start: andere Apps ducken // Beim ersten Playback-Start: andere Apps ducken + Listener informieren
if (!this.isPlaying) { if (!this.isPlaying) {
this._cancelDeferredFocusRelease(); this._cancelDeferredFocusRelease();
AudioFocus?.requestDuck().catch(() => {}); AudioFocus?.requestDuck().catch(() => {});
this._firePlaybackStarted();
} }
this.isPlaying = true; this.isPlaying = true;
+108
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;
+71
View File
@@ -0,0 +1,71 @@
/**
* Spielt einen kurzen "Bereit"-Sound (Airplane Ding-Dong) wenn das Mikrofon
* nach Wake-Word-Erkennung wirklich offen ist. Datei liegt in
* android/app/src/main/res/raw/wake_ready_sound.mp3 — wird ueber Android's
* Resource-System per react-native-sound abgespielt.
*
* Toggle: AsyncStorage-Key 'aria_wake_ready_sound_enabled' (default true).
*/
import Sound from 'react-native-sound';
import AsyncStorage from '@react-native-async-storage/async-storage';
export const WAKE_READY_SOUND_STORAGE_KEY = 'aria_wake_ready_sound_enabled';
Sound.setCategory('Playback', false);
let cachedSound: Sound | null = null;
let cachedFailed = false;
function getSound(): Promise<Sound | null> {
if (cachedFailed) return Promise.resolve(null);
if (cachedSound) return Promise.resolve(cachedSound);
return new Promise(resolve => {
const s = new Sound('wake_ready_sound', Sound.MAIN_BUNDLE, (err) => {
if (err) {
console.warn('[WakeReadySound] Konnte nicht geladen werden:', err);
cachedFailed = true;
resolve(null);
return;
}
cachedSound = s;
resolve(s);
});
});
}
/** True wenn der User den "Bereit"-Sound aktiviert hat. Default: true. */
export async function isWakeReadySoundEnabled(): Promise<boolean> {
try {
const raw = await AsyncStorage.getItem(WAKE_READY_SOUND_STORAGE_KEY);
if (raw === null) return true; // Default an
return raw === 'true';
} catch {
return true;
}
}
export async function setWakeReadySoundEnabled(enabled: boolean): Promise<void> {
try {
await AsyncStorage.setItem(WAKE_READY_SOUND_STORAGE_KEY, String(enabled));
} catch {}
}
/** Spielt den Bereit-Sound einmal ab — non-blocking. Wenn der User ihn
* in den Settings deaktiviert hat oder die Datei nicht ladbar ist,
* passiert einfach nichts. */
export async function playWakeReadySound(): Promise<void> {
if (!(await isWakeReadySoundEnabled())) return;
const s = await getSound();
if (!s) return;
try {
s.stop(() => {
s.setCurrentTime(0);
s.play((success) => {
if (!success) console.warn('[WakeReadySound] Wiedergabe fehlgeschlagen');
});
});
} catch (e) {
console.warn('[WakeReadySound] play() Exception:', e);
}
}
+61 -2
View File
@@ -72,6 +72,11 @@ class WakeWordService {
private state: WakeWordState = 'off'; private state: WakeWordState = 'off';
private wakeCallbacks: WakeWordCallback[] = []; private wakeCallbacks: WakeWordCallback[] = [];
private stateCallbacks: StateCallback[] = []; private stateCallbacks: StateCallback[] = [];
/** Barge-In-Callbacks: feuern wenn Wake-Word WAEHREND ARIA spricht erkannt
* wird. ChatScreen reagiert mit TTS-stop + neuer Aufnahme. */
private bargeCallbacks: WakeWordCallback[] = [];
/** True solange Wake-Word parallel zu TTS aktiv ist. */
private bargeListening: boolean = false;
private keyword: WakeKeyword = DEFAULT_KEYWORD; private keyword: WakeKeyword = DEFAULT_KEYWORD;
private nativeReady: boolean = false; private nativeReady: boolean = false;
@@ -191,16 +196,28 @@ class WakeWordService {
if (this.nativeReady && OpenWakeWord) { if (this.nativeReady && OpenWakeWord) {
try { await OpenWakeWord.stop(); } catch {} try { await OpenWakeWord.stop(); } catch {}
} }
this.bargeListening = false;
this.setState('off'); this.setState('off');
} }
/** Wake-Word getriggert: Native-Modul 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! (state=%s, barge=%s)',
ToastAndroid.show(`Wake-Word "${KEYWORD_LABELS[this.keyword]}" erkannt — sprich jetzt`, ToastAndroid.SHORT); this.keyword, this.state, this.bargeListening);
if (this.nativeReady && OpenWakeWord) { if (this.nativeReady && OpenWakeWord) {
try { await OpenWakeWord.stop(); } catch {} try { await OpenWakeWord.stop(); } catch {}
} }
this.bargeListening = false;
// Wenn wir bereits in 'conversing' sind und der Trigger waehrend ARIAs TTS
// kam (Barge-In via Wake-Word), feuern wir einen separaten Callback damit
// ChatScreen das TTS abbrechen + neue Aufnahme starten kann. Sonst normal.
if (this.state === 'conversing') {
this.bargeCallbacks.forEach(cb => {
try { cb(); } catch (e) { console.warn('[WakeWord] barge cb err:', e); }
});
// Kein erneutes setState — wir bleiben in 'conversing'.
return;
}
this.setState('conversing'); this.setState('conversing');
setTimeout(() => { setTimeout(() => {
if (this.state === 'conversing') { if (this.state === 'conversing') {
@@ -209,6 +226,35 @@ class WakeWordService {
}, 200); }, 200);
} }
/** Wake-Word PARALLEL zur TTS-Wiedergabe lauschen lassen — User kann
* "Computer" sagen waehrend ARIA noch redet, AcousticEchoCanceler im
* Native-Modul verhindert dass ARIAs eigene Stimme triggert.
* Voraussetzung: AudioRecorder muss frei sein (Recording aus). Wenn der
* AudioRecorder gerade laeuft, hat der Vorrang — Wake-Word geht nicht. */
async startBargeListening(): Promise<void> {
if (!this.nativeReady || !OpenWakeWord) return;
if (this.state !== 'conversing') return;
if (this.bargeListening) return;
try {
await OpenWakeWord.start();
this.bargeListening = true;
console.log('[WakeWord] Barge-Listening aktiv (parallel zu TTS)');
} catch (err) {
console.warn('[WakeWord] Barge-Listening start fehlgeschlagen:', err);
}
}
/** Barge-Listening wieder aus — z.B. wenn der AudioRecorder fuer die
* naechste Aufnahme das Mikro braucht. */
async stopBargeListening(): Promise<void> {
if (!this.bargeListening) return;
if (this.nativeReady && OpenWakeWord) {
try { await OpenWakeWord.stop(); } catch {}
}
this.bargeListening = false;
console.log('[WakeWord] Barge-Listening aus');
}
/** Konversation beenden — User hat im Window nichts gesagt. /** Konversation beenden — User hat im Window nichts gesagt.
* Mit Wake-Word: zurueck zu 'armed' (Listener wieder an). * Mit Wake-Word: zurueck zu 'armed' (Listener wieder an).
* Ohne: zurueck zu 'off'. * Ohne: zurueck zu 'off'.
@@ -268,6 +314,19 @@ class WakeWordService {
}; };
} }
/** Subscribe auf Barge-In-Events: Wake-Word erkannt waehrend ARIA noch
* spricht. ChatScreen sollte dann TTS abbrechen + neue Aufnahme starten. */
onBargeIn(callback: WakeWordCallback): () => void {
this.bargeCallbacks.push(callback);
return () => {
this.bargeCallbacks = this.bargeCallbacks.filter(cb => cb !== callback);
};
}
isBargeListening(): boolean {
return this.bargeListening;
}
onStateChange(callback: StateCallback): () => void { onStateChange(callback: StateCallback): () => void {
this.stateCallbacks.push(callback); this.stateCallbacks.push(callback);
return () => { return () => {
+146 -61
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.
@@ -1019,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:
@@ -1181,8 +1235,24 @@ class ARIABridge:
except (TypeError, ValueError): except (TypeError, ValueError):
self._next_speed_override = None self._next_speed_override = None
if text: if text:
logger.info("[rvs] App-Chat: '%s'", text[:80]) interrupted = bool(payload.get("interrupted", False))
await self.send_to_core(text, source="app") # Wenn Files gerade gepuffert sind (Bild + Text gleichzeitig
# 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:
core_text = (
f"[Hinweis: Stefan hat dich gerade unterbrochen waehrend du noch "
f"gesprochen oder gearbeitet hast. Folgendes ist eine Korrektur, "
f"Ergaenzung oder ein Themenwechsel zu deiner letzten Antwort.] "
f"{text}"
if interrupted else text
)
logger.info("[rvs] App-Chat%s: '%s'",
" [BARGE-IN]" if interrupted else "", text[:80])
await self.send_to_core(core_text, source="app" + (" [barge-in]" if interrupted else ""))
return return
if msg_type == "cancel_request": if msg_type == "cancel_request":
@@ -1341,70 +1411,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)
@@ -1455,9 +1509,13 @@ class ARIABridge:
self._next_speed_override = speed if 0.1 <= speed <= 5.0 else None self._next_speed_override = speed if 0.1 <= speed <= 5.0 else None
except (TypeError, ValueError): except (TypeError, ValueError):
self._next_speed_override = None self._next_speed_override = None
logger.info("[rvs] Audio empfangen: %s, %dms, %dKB", interrupted = bool(payload.get("interrupted", False))
mime_type, duration_ms, len(audio_b64) // 1365) audio_request_id = payload.get("audioRequestId", "") or ""
asyncio.create_task(self._process_app_audio(audio_b64, mime_type)) logger.info("[rvs] Audio empfangen: %s, %dms, %dKB%s%s",
mime_type, duration_ms, len(audio_b64) // 1365,
" [BARGE-IN]" if interrupted else "",
f" reqId={audio_request_id[:16]}" if audio_request_id else "")
asyncio.create_task(self._process_app_audio(audio_b64, mime_type, interrupted, audio_request_id))
elif msg_type == "stt_response": elif msg_type == "stt_response":
# Antwort der whisper-bridge auf unseren stt_request # Antwort der whisper-bridge auf unseren stt_request
@@ -1513,8 +1571,19 @@ class ARIABridge:
_STT_REMOTE_TIMEOUT_READY_S = 45.0 _STT_REMOTE_TIMEOUT_READY_S = 45.0
_STT_REMOTE_TIMEOUT_LOADING_S = 300.0 _STT_REMOTE_TIMEOUT_LOADING_S = 300.0
async def _process_app_audio(self, audio_b64: str, mime_type: str) -> None: async def _process_app_audio(self, audio_b64: str, mime_type: str,
"""App-Audio → STT → aria-core. Primaer via whisper-bridge (RVS), Fallback lokal.""" interrupted: bool = False,
audio_request_id: str = "") -> None:
"""App-Audio → STT → aria-core. Primaer via whisper-bridge (RVS), Fallback lokal.
interrupted=True wenn der User waehrend ARIA noch sprach/dachte aufgenommen hat
(Barge-In). Wird als Hinweis-Praefix an aria-core mitgegeben damit ARIA die
Korrektur/Unterbrechung in den Kontext einordnen kann statt als reine
Folgefrage zu behandeln.
audio_request_id: Korrelations-ID die die App im audio-Event mitschickt — wird
unveraendert ans STT-Result zurueckgegeben damit die App die EXAKT richtige
'wird verarbeitet'-Bubble ersetzen kann (auch bei mehreren parallelen Aufnahmen)."""
# Erst Remote versuchen # Erst Remote versuchen
text = await self._stt_remote(audio_b64, mime_type) text = await self._stt_remote(audio_b64, mime_type)
if text is None: if text is None:
@@ -1526,19 +1595,35 @@ class ARIABridge:
if text.strip(): if text.strip():
logger.info("[rvs] STT Ergebnis: '%s'", text[:80]) logger.info("[rvs] STT Ergebnis: '%s'", text[:80])
# Barge-In-Hinweis: gibt ARIA den Kontext dass sie unterbrochen wurde
# und dies eine Korrektur/Aenderung der vorherigen Anweisung sein kann.
core_text = (
f"[Hinweis: Stefan hat dich gerade unterbrochen waehrend du noch "
f"gesprochen oder gearbeitet hast. Folgendes ist eine Korrektur, "
f"Ergaenzung oder ein Themenwechsel zu deiner letzten Antwort.] "
f"{text}"
if interrupted else text
)
# ERST an aria-core senden (wichtigster Schritt) # ERST an aria-core senden (wichtigster Schritt)
await self.send_to_core(text, source="app-voice") await self.send_to_core(core_text, source="app-voice" + (" [barge-in]" if interrupted else ""))
# STT-Text an RVS senden (fuer Anzeige in App + Diagnostic) # STT-Text an RVS senden (fuer Anzeige in App + Diagnostic)
# sender="stt" damit Bridge es ignoriert (kein Loop) # sender="stt" damit Bridge es ignoriert (kein Loop)
try: try:
await self._send_to_rvs({ stt_payload = {
"text": text,
"sender": "stt",
}
if audio_request_id:
stt_payload["audioRequestId"] = audio_request_id
ok = await self._send_to_rvs({
"type": "chat", "type": "chat",
"payload": { "payload": stt_payload,
"text": text,
"sender": "stt",
},
"timestamp": int(asyncio.get_event_loop().time() * 1000), "timestamp": int(asyncio.get_event_loop().time() * 1000),
}) })
if ok:
logger.info("[rvs] STT-Text an RVS broadcastet (sender=stt)")
else:
logger.warning("[rvs] STT-Text NICHT broadcastet — _send_to_rvs lieferte False")
except Exception as e: except Exception as e:
logger.warning("[rvs] STT-Text konnte nicht an RVS gesendet werden: %s", e) logger.warning("[rvs] STT-Text konnte nicht an RVS gesendet werden: %s", e)
else: else:
+40 -16
View File
@@ -63,24 +63,35 @@
.log-entry.pipeline-sep { color: #333; margin: 6px 0 2px; } .log-entry.pipeline-sep { color: #333; margin: 6px 0 2px; }
.chat-box { background: #080810; border: 1px solid #1E1E2E; border-radius: 6px; .chat-box { background: #080810; border: 1px solid #1E1E2E; border-radius: 6px;
min-height: 120px; max-height: 250px; overflow-y: auto; padding: 8px; margin-bottom: 8px; } min-height: 120px; max-height: 250px; overflow-y: auto;
.chat-msg { margin-bottom: 6px; padding: 6px 10px; border-radius: 6px; font-size: 13px; line-height: 1.5; word-wrap: break-word; } padding: 12px; margin-bottom: 8px; display: flex; flex-direction: column; gap: 8px; }
.chat-msg.sent { background: #0096FF; color: #fff; margin-left: 20%; text-align: right; } .chat-msg { padding: 10px 14px; border-radius: 14px; font-size: 14px; line-height: 1.5;
.chat-msg.received { background: #1E1E2E; margin-right: 20%; } word-wrap: break-word; max-width: 80%; white-space: pre-wrap;
.chat-msg.error { background: #3B1010; color: #FF6B6B; } box-shadow: 0 1px 2px rgba(0,0,0,0.4); }
.chat-msg .meta { font-size: 10px; color: rgba(255,255,255,0.4); margin-top: 2px; } .chat-msg.sent { background: #0096FF; color: #fff; align-self: flex-end;
border-bottom-right-radius: 4px; }
.chat-msg.received { background: #1E1E2E; color: #E8E8F0; align-self: flex-start;
border-bottom-left-radius: 4px; }
.chat-msg.error { background: #3B1010; color: #FF6B6B; align-self: flex-start; }
.chat-msg .meta { font-size: 10px; color: rgba(255,255,255,0.4); margin-top: 4px;
display: block; }
.chat-msg a { color: #66BBFF; text-decoration: underline; } .chat-msg a { color: #66BBFF; text-decoration: underline; }
.chat-msg.sent a { color: #CCEEFF; } .chat-msg.sent a { color: #CCEEFF; }
.chat-msg .chat-media { max-width: 100%; max-height: 200px; border-radius: 4px; margin-top: 4px; cursor: pointer; display: block; } .chat-msg .chat-media { max-width: 100%; max-height: 200px; border-radius: 8px;
margin-top: 6px; cursor: pointer; display: block; }
.chat-msg .chat-media:hover { opacity: 0.85; } .chat-msg .chat-media:hover { opacity: 0.85; }
.lightbox-overlay { display:none; position:fixed; top:0; left:0; right:0; bottom:0; background:rgba(0,0,0,0.92); .lightbox-overlay { display:none; position:fixed; top:0; left:0; right:0; bottom:0; background:rgba(0,0,0,0.92);
z-index:2000; justify-content:center; align-items:center; cursor:pointer; } z-index:2000; justify-content:center; align-items:center; cursor:pointer; }
.lightbox-overlay.open { display:flex; } .lightbox-overlay.open { display:flex; }
.lightbox-overlay img, .lightbox-overlay video { max-width:95vw; max-height:95vh; border-radius:8px; } .lightbox-overlay img, .lightbox-overlay video { max-width:95vw; max-height:95vh; border-radius:8px; }
.input-row { display: flex; gap: 6px; } .input-row { display: flex; gap: 6px; align-items: flex-end; }
.input-row input { flex: 1; background: #1E1E2E; border: 1px solid #333; border-radius: 6px; .input-row input, .input-row textarea {
padding: 8px 12px; color: #E0E0F0; font-family: inherit; font-size: 13px; } flex: 1; background: #1E1E2E; border: 1px solid #333; border-radius: 6px;
padding: 8px 12px; color: #E0E0F0; font-family: inherit; font-size: 13px;
}
.input-row textarea { resize: none; min-height: 38px; max-height: 200px; line-height: 1.4;
overflow-y: auto; }
/* Terminal Modal */ /* Terminal Modal */
.modal-overlay { display:none; position:fixed; top:0; left:0; right:0; bottom:0; background:rgba(0,0,0,0.85); .modal-overlay { display:none; position:fixed; top:0; left:0; right:0; bottom:0; background:rgba(0,0,0,0.85);
@@ -282,7 +293,7 @@
&#x1F4CE; &#x1F4CE;
<input type="file" id="diag-file-input" multiple accept="image/*,application/pdf,.doc,.docx,.txt" style="display:none;" onchange="handleDiagFileSelect(this.files)"> <input type="file" id="diag-file-input" multiple accept="image/*,application/pdf,.doc,.docx,.txt" style="display:none;" onchange="handleDiagFileSelect(this.files)">
</label> </label>
<input type="text" id="chat-input" placeholder="Nachricht an ARIA..." onpaste="handleDiagPaste(event)"> <textarea id="chat-input" placeholder="Nachricht an ARIA... (Enter sendet, Shift+Enter neue Zeile)" rows="2" onpaste="handleDiagPaste(event)" oninput="autoResizeTextarea(this)"></textarea>
<button class="btn" id="btn-gw" onclick="testGateway()">Gateway senden</button> <button class="btn" id="btn-gw" onclick="testGateway()">Gateway senden</button>
<button class="btn" id="btn-rvs" onclick="testRVS()">Via RVS senden</button> <button class="btn" id="btn-rvs" onclick="testRVS()">Via RVS senden</button>
</div> </div>
@@ -300,7 +311,7 @@
<span style="animation:pulse 1s infinite;">&#x1F4AD;</span> <span id="thinking-text-fs">ARIA denkt...</span> <span style="animation:pulse 1s infinite;">&#x1F4AD;</span> <span id="thinking-text-fs">ARIA denkt...</span>
</div> </div>
<div class="input-row" style="margin-top:8px;"> <div class="input-row" style="margin-top:8px;">
<input type="text" id="chat-input-fs" placeholder="Nachricht an ARIA..." onkeydown="if(event.key==='Enter'){testRVSFS();event.preventDefault();}"> <textarea id="chat-input-fs" placeholder="Nachricht an ARIA... (Enter sendet, Shift+Enter neue Zeile)" rows="2" oninput="autoResizeTextarea(this)"></textarea>
<button class="btn" onclick="testGatewayFS()">Gateway senden</button> <button class="btn" onclick="testGatewayFS()">Gateway senden</button>
<button class="btn" onclick="testRVSFS()">Via RVS senden</button> <button class="btn" onclick="testRVSFS()">Via RVS senden</button>
</div> </div>
@@ -2069,10 +2080,23 @@
return str.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); return str.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
} }
// Enter-Taste sendet via Gateway // Auto-Resize fuer Textarea — wuchst mit dem Inhalt bis zum max-height
document.getElementById('chat-input').addEventListener('keydown', (e) => { function autoResizeTextarea(el) {
if (e.key === 'Enter') testRVS(); el.style.height = 'auto';
}); el.style.height = Math.min(el.scrollHeight, 200) + 'px';
}
// Enter sendet, Shift+Enter macht neue Zeile (chat-Standard).
function chatInputKeydown(e, sendFn) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendFn();
// Textarea zurueck auf 2 rows setzen
e.target.style.height = 'auto';
}
}
document.getElementById('chat-input').addEventListener('keydown', (e) => chatInputKeydown(e, testRVS));
document.getElementById('chat-input-fs').addEventListener('keydown', (e) => chatInputKeydown(e, testRVSFS));
// Escape schliesst Lightbox // Escape schliesst Lightbox
document.addEventListener('keydown', (e) => { document.addEventListener('keydown', (e) => {
+14 -1
View File
@@ -716,11 +716,24 @@ function sendToRVS_withResponse(sendType, sendPayload, expectType, clientWs) {
function sendToRVS_raw(msgObj) { function sendToRVS_raw(msgObj) {
if (!RVS_HOST || !RVS_TOKEN) return; if (!RVS_HOST || !RVS_TOKEN) return;
const payload = JSON.stringify(msgObj);
// Persistente Connection bevorzugen — die ist garantiert connected
// und wird vom RVS direkt an alle anderen Clients (App, Bridge) broadcastet.
// Frische Connections hatten Race-Probleme: die WS war nach dem send manchmal
// schon zu, bevor RVS broadcasten konnte → App-Nachrichten verloren.
if (rvsWs && rvsWs.readyState === WebSocket.OPEN) {
try {
rvsWs.send(payload);
return;
} catch (err) {
log("warn", "rvs", `persistente Verbindung send failed (${err.message}) — Fallback frische WS`);
}
}
const proto = RVS_TLS === "true" ? "wss" : "ws"; const proto = RVS_TLS === "true" ? "wss" : "ws";
const url = `${proto}://${RVS_HOST}:${RVS_PORT}?token=${RVS_TOKEN}`; const url = `${proto}://${RVS_HOST}:${RVS_PORT}?token=${RVS_TOKEN}`;
const freshWs = new WebSocket(url); const freshWs = new WebSocket(url);
freshWs.on("open", () => { freshWs.on("open", () => {
freshWs.send(JSON.stringify(msgObj)); freshWs.send(payload);
setTimeout(() => { try { freshWs.close(); } catch (_) {} }, 5000); setTimeout(() => { try { freshWs.close(); } catch (_) {} }, 5000);
}); });
freshWs.on("error", () => {}); freshWs.on("error", () => {});
+1 -1
View File
@@ -9,7 +9,7 @@ services:
command: >- command: >-
sh -c "apk add --no-cache openssh-client bash curl && sh -c "apk add --no-cache openssh-client bash curl &&
npm install -g @anthropic-ai/claude-code claude-max-api-proxy && npm install -g @anthropic-ai/claude-code claude-max-api-proxy &&
DIST=$(find /usr/local/lib -path '*/claude-max-api-proxy/dist' -type d | head -1) && DIST=$$(find /usr/local/lib -path '*/claude-max-api-proxy/dist' -type d | head -1) &&
sed -i 's/startServer({ port })/startServer({ port, host: process.env.HOST || \"127.0.0.1\" })/' $$DIST/server/standalone.js && sed -i 's/startServer({ port })/startServer({ port, host: process.env.HOST || \"127.0.0.1\" })/' $$DIST/server/standalone.js &&
sed -i 's/if (model\.includes/if ((model||\"claude-sonnet-4\").includes/g' $$DIST/adapter/cli-to-openai.js && sed -i 's/if (model\.includes/if ((model||\"claude-sonnet-4\").includes/g' $$DIST/adapter/cli-to-openai.js &&
sed -i '1i\\function _t(c){return typeof c===\"string\"?c:Array.isArray(c)?c.filter(function(b){return b.type===\"text\"}).map(function(b){return b.text||\"\"}).join(\"\"):String(c)}' $$DIST/adapter/openai-to-cli.js && sed -i '1i\\function _t(c){return typeof c===\"string\"?c:Array.isArray(c)?c.filter(function(b){return b.type===\"text\"}).map(function(b){return b.text||\"\"}).join(\"\"):String(c)}' $$DIST/adapter/openai-to-cli.js &&
+23 -2
View File
@@ -87,16 +87,37 @@
- [x] App Text-Rendering: Nachrichten selektierbar + Autolink fuer URLs/E-Mails/Telefonnummern (Browser/Mail/Dialer) - [x] App Text-Rendering: Nachrichten selektierbar + Autolink fuer URLs/E-Mails/Telefonnummern (Browser/Mail/Dialer)
- [x] TTS-Wiedergabegeschwindigkeit pro Geraet einstellbar (Settings → 0.5-2.0x in 0.1-Schritten, Default 1.0) - [x] TTS-Wiedergabegeschwindigkeit pro Geraet einstellbar (Settings → 0.5-2.0x in 0.1-Schritten, Default 1.0)
- [x] Diagnostic: Voice-Preview-Modal (Play-Icon vor Delete-X, Textfeld mit Default, WAV im Browser abspielen) - [x] Diagnostic: Voice-Preview-Modal (Play-Icon vor Delete-X, Textfeld mit Default, WAV im Browser abspielen)
- [x] **Wake-Word komplett on-device via openWakeWord (ONNX Runtime)** — Porcupine raus, kein API-Key/keine Lizenzgebuehren mehr. Mitgelieferte Keywords: hey_jarvis, computer, alexa, hey_mycroft, hey_rhasspy
- [x] Wake-Word Embedding rank-4 Fix (Pipeline-Bug der das Triggern verhinderte) + Frame-Count aus Modell-Metadaten lesen
- [x] APK ABI-Split auf arm64-v8a — von ~136 MB auf ~35 MB, Auto-Update-Downloads aufs Phone deutlich kleiner
- [x] PCM-Underrun-Schutz: Stille-Fill in Render-Pausen verhindert Spotify-Auto-Resume nach 10s Stillstand
- [x] Conversation-Focus-Lifecycle: AudioFocus haengt am Wake-Word-State 'conversing' statt an einzelnen Streams — Spotify bleibt durchgehend gepaust, auch zwischen mehreren Antworten
- [x] PhoneStateListener: TTS pausiert bei eingehendem Anruf (READ_PHONE_STATE Permission)
- [x] Voice-Override behaelt Stimme ueber alle TTS-Calls einer Antwort (vorher: nach erstem TTS-Call zurueck auf Default)
- [x] Sprachnachricht-Bubble defensiv: STT-Result fuegt neue Bubble hinzu wenn Placeholder fehlt (Race-Schutz)
- [x] Bild + Text als EINE Anfrage: Bridge buffert files 800ms, merged mit folgendem chat-Text zu einem send_to_core (statt zwei getrennten ARIA-Antworten)
- [x] Diagnostic-Chat: bubblige Formatierung, mehrzeiliges Eingabefeld (textarea, Enter sendet, Shift+Enter neue Zeile)
- [x] Diagnostic→App: persistente RVS-Connection statt frische pro Send (Race-Probleme mit Zombie-WS geloest)
- [x] Adaptive VAD-Schwelle: Baseline aus den ersten 500ms Mic-Pegel, Stille = baseline+6dB / Sprache = baseline+12dB. Funktioniert in lauten wie leisen Umgebungen
- [x] Max-Aufnahmedauer konfigurierbar in Settings (1-30 min, Default 5 min) — laengere Diktate moeglich
- [x] Barge-In: User kann ARIA waehrend Antwort/Tool-Use unterbrechen, alte Aktivitaet wird abgebrochen, Bridge gibt aria-core einen Kontext-Hint dass es eine Korrektur ist
- [x] Push-to-Talk raus, nur noch Tap-to-Talk (verhinderte Touch-Race-Probleme)
- [x] Settings-Sub-Screens: 8 Kategorien (Verbindung, Allgemein, Spracheingabe, Wake-Word, Sprachausgabe, Speicher, Protokoll, Ueber) statt langer Liste
- [x] Textauswahl in Bubbles wieder funktional (nested Text+onPress raus, dataDetectorType="all" macht Links automatisch klickbar)
- [x] **Placeholder-Race bei parallelen Sprachnachrichten geloest**: jede Aufnahme bekommt eine eindeutige audioRequestId, Bridge gibt sie ans STT-Result zurueck — App matcht jetzt punktgenau die richtige Bubble statt per Substring "Spracheingabe wird verarbeitet"
- [x] Mikro-Offen-Toast "🎤 sprich jetzt" erscheint erst wenn audioService.startRecording wirklich erfolgreich war (statt ~400ms vorher beim Wake-Word-Detect)
- [x] **Bereit-Sound (Airplane Ding-Dong) wenn Mikro nach Wake-Word offen** — akustische Bestaetigung statt nur Toast. Toggle in Settings → Wake-Word, default aktiv
- [x] **Wake-Word parallel zu TTS** mit AcousticEchoCanceler: User sagt "Computer" waehrend ARIA spricht → TTS verstummt sofort, neue Aufnahme startet. Native AEC verhindert dass ARIAs eigene Stimme das Wake-Word triggert. Audio-Source ist VOICE_COMMUNICATION + zusaetzlich AEC/NS/AGC-Effekte aktiviert
## Offen ## Offen
### Bugs ### Bugs
- [ ] App: Wake-Word "jarvis" triggert nicht zuverlaessig (Porcupine-Debugging via ADB-Logcat ausstehend)
- [ ] App: Stuerzt beim Lauschen ab, eventuell bei Nebengeraeuschen (Porcupine + Mic-Race, errorCallback haelt's jetzt zurueck — Dauertest ausstehend)
### App Features ### App Features
- [ ] Chat-History zuverlaessiger laden (AsyncStorage Race Condition) - [ ] Chat-History zuverlaessiger laden (AsyncStorage Race Condition)
- [ ] Background Audio Service (TTS auch bei minimierter App) - [ ] Background Audio Service (TTS auch bei minimierter App)
- [ ] Custom-Wake-Word-Upload via Diagnostic (eigene .onnx-Files ohne App-Rebuild)
- [ ] Pause+Resume bei Anruf: aktuell wird der TTS-Stream bei Klingeln hart gestoppt, schoener waere Pause + Resume nach Auflegen
### Architektur ### Architektur
- [ ] Bilder: Claude Vision direkt nutzen (aktuell nur Dateipfad an ARIA) - [ ] Bilder: Claude Vision direkt nutzen (aktuell nur Dateipfad an ARIA)