Compare commits

...

24 Commits

Author SHA1 Message Date
duffyduck 9276a92c83 release: bump version to 0.0.8.9 2026-05-10 14:20:43 +02:00
duffyduck d16896c4b4 fix(audio): kurze TTS-Texte — play() erst NACH Buffer-Fuellung mit Padding
Auf OnePlus A12 startet AudioTrack nicht zuverlaessig wenn play() bei
duennem Buffer gerufen wird (pos blieb 0/34112 trotz 71KB Daten + Retry).

Neue Reihenfolge bei kurzem Stream:
1. Daten in Buffer schreiben (mainLoop)
2. Trailing-Silence (0.3s)
3. Padding bis min. 2s gepuffert
4. DANN erst play()

Buffer auf 3s erhoeht damit blockingem write() noch Headroom bleibt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 14:19:45 +02:00
duffyduck 20050d4077 release: bump version to 0.0.8.8 2026-05-10 14:12:59 +02:00
duffyduck 79760d1b2e fix(audio): kurze TTS-Texte spielen wieder ab — AudioTrack-Buffer entkoppelt von Preroll
OnePlus A12 stallte bei kurzem Text mit pos=0/34112: 336KB Buffer fuer
3.5s Preroll, aber nur 68KB Daten drin → AudioTrack faehrt nicht an.

Fix: Buffer fest auf ~2s, plus play()-Retry bei pos=0 nach 500ms.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 14:11:53 +02:00
duffyduck 13f1103604 release: bump version to 0.0.8.7 2026-05-10 14:00:29 +02:00
duffyduck 73b7a76ea8 fix(phone-call): kein VoIP-Toast bei Play-Button — AudioMode pruefen
Stefan: 'Möchte ich mir playbacks anhören egal welches kommt die toast
nachricht voip anruf und danach aria wieder aktiv'.

Ursache: AUDIOFOCUS_LOSS feuert bei jedem Audio-Player-Wechsel
(Spotify, andere Apps, sogar unsere eigenen Sound-Calls). Wir
interpretierten das blind als VoIP-Anruf.

Fix: vor dem Halt fragen wir AudioFocus.getMode() ab — nur wenn
mode == 2 (IN_CALL) oder 3 (IN_COMMUNICATION) ist's wirklich ein
Anruf. Bei NORMAL (0) wird der Loss ignoriert.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 13:43:40 +02:00
duffyduck 17f3d8870e release: bump version to 0.0.8.6 2026-05-10 12:50:35 +02:00
duffyduck 4feaacc7e4 feat(update): APK-Cache robuster + manueller 'Update-Cache leeren' Button
Stefan: 'app blaeht sich auf durch heruntergeladene Update-Versionen'.

updater.ts:
- cleanupOldApks durchsucht jetzt 4 Pfade (Caches, Documents, ExternalCaches,
  ExternalDir) statt nur CachesDirectoryPath
- Public gemacht + returnt {removed, freedMB}
- getApkCacheSize() neu — listet count + totalMB

SettingsScreen → Speicher:
- Neue Sektion 'Update-Cache' mit Live-Groessenanzeige
- Button 'Update-Cache leeren' triggert cleanup + Toast mit Ergebnis
- Beim Mount wird die Groesse einmal geladen

Auto-Cleanup laeuft weiterhin beim App-Start + vor jedem Download —
der Button ist fuer den Notfall (haengender Download, alte Pfade,
defekte APKs).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 12:49:22 +02:00
duffyduck af7b2674f3 docs: Auto-Resume + Edge-Cases in issue.md + README
issue.md: Audio-Tabelle erweitert um 'neue Frage waehrend Anruf' und
'Anruf vorbei nach neuer Frage'. Mechanismen-Liste ergaenzt mit
'Audio-Ausgabe waehrend Telefonat' (state-change Logik) und 'neue
Frage verwirft pending Resume'. Drei neue Erledigt-Eintraege fuer
VoIP, Auto-Resume und PcmPlaybackFinished-Event.

README: kompakte Audio-Tabelle ergaenzt + Roadmap zwei neue Bullets.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 12:41:34 +02:00
duffyduck 97442198ec fix(audio): neue ARIA-Antwort verwirft pending Auto-Resume
Stefans Edge-Case: waehrend des Telefonats stellt der User eine neue
Text-Frage. Die neue ARIA-Antwort startet sofort (offhook→offhook
loest keinen halt aus). Vorher haette resumeFromInterruption nach
Anruf-Ende noch die ALTE Antwort (die unterbrochen wurde) ab
Position spielen wollen — Konflikt mit der neuen Antwort.

Fix: in _handlePcmChunkImpl beim Wechsel zu einer neuen messageId:
- laufenden resumeSound stoppen
- pausedMessageId = '' wenn != neue messageId

Damit gewinnt immer die neueste Antwort.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 12:39:33 +02:00
duffyduck e3e841f2ab feat(audio): Auto-Resume nach Anruf ab der gemerkten Position
Stefans Idee: Position beim Halt merken (Date.now() - playbackStart -
leadingSilence), nach dem Auflegen ab da weitermachen. Wenn der Cache
noch nicht komplett ist (final-Marker kam waehrend Anruf), warten wir
bis zu 30s auf das WAV — meistens ist's schon da weil das Telefonat
laenger als die Antwort dauerte.

audio.ts:
- captureInterruption(): merkt position + messageId, returnt Sekunden
- resumeFromInterruption(maxWaitMs): wartet auf WAV-Cache, lädt mit
  Sound, setCurrentTime(position), play
- Tracking-Felder: playbackStartTime, currentPlaybackMsgId, pausedX

phoneCall.ts:
- _haltForCall ruft captureInterruption() VOR haltAllPlayback
- _resumeAfterCall triggert resumeFromInterruption(30s)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 12:37:35 +02:00
duffyduck 33185de42b fix(audio): AudioFocus erst beim NATIVEN Playback-Finished-Event released
Logcat-Befund:
12:22:54.860 — final-Chunk + Cache geschrieben
12:22:55.402 — abandonAudioFocus (~0.5s spaeter)
12:22:55     — Spotify resumed (Atlas: TotalTime 93s)
12:23:27.064 — Playback fertig (32s spaeter!)

→ ARIA spricht 32s parallel zu Spotify weil end() viel zu frueh
returnt. Stefans 'Spotify resumed obwohl ARIA noch redet'.

Fix:
- PcmStreamPlayerModule emittiert 'PcmPlaybackFinished' RN-Event nach
  dem finally{}-Block im Writer-Thread (= AudioTrack hat alle Samples
  wirklich durchgespielt)
- audioService subscribed im constructor → ruft erst dann
  _releaseFocusDeferred()
- _handlePcmChunkImpl bei isFinal triggert NICHT mehr direkt das
  Release — nur die playbackFinished-Listener (UI-Logic)

So bleibt Spotify pausiert bis ARIA tatsaechlich fertig ist, egal
wie viel Audio im AudioTrack-Buffer wartet.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 12:29:55 +02:00
duffyduck dbe547d4ea feat(diagnostic): GPS-Position als Debug-Block in Chat einblendbar
Toggle 'GPS-Position einblenden' rechts neben 'TTS-Text einblenden'.
Wenn aktiv und ein chat-Event hat ein location-Feld, erscheint unter
der Bubble ein gruener Block mit lat/lon — Klick oeffnet OpenStreetMap
am Punkt.

Nur Diagnostic, keine Anzeige in der App. Der Block taucht nur bei
User-/STT-/Diagnostic-Nachrichten auf (sender != aria), weil aria-core
sich nicht selbst lokalisiert.

Toggle-State wird in localStorage persistiert (aria-show-gps-debug).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 12:10:34 +02:00
duffyduck 1a982c0d45 release: bump version to 0.0.8.5 2026-05-10 12:01:46 +02:00
duffyduck dfba5ceb1f docs: Audio-Verhaltens-Tabelle in issue.md + README
Definiert klar wann Spotify pausiert und wann nicht — als Referenz
fuer kuenftige Bug-Reports. Aktueller Zustand nach den Audio-Fixes:
Spotify pausiert nur waehrend User-Aufnahme + TTS-Wiedergabe, nicht
waehrend ARIAs Denkphase.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 12:00:41 +02:00
duffyduck 1a6f633836 fix(audio): rollback agentActivity-Conversation-Focus, Spotify pausiert NUR bei TTS
Der vorige Commit (acquireConversationFocus bei agentActivity != idle) war zu
aggressiv — Spotify pausierte schon waehrend 'ARIA denkt/schreibt' und das
zugehoerige release greift nicht zuverlaessig (Race mit nachfolgenden
agent_activity-Events). Stefan: 'spotify resumet nicht mehr, hoert schon
beim ARIA-denkt-Passus auf zu spielen'.

Erwartetes Verhalten:
- Aufnahme: AudioFocus → Spotify pausiert (~5s)
- ARIA denkt/schreibt (~20s): kein Focus → Spotify spielt weiter
- TTS: AudioFocus per requestDuck → Spotify pausiert
- TTS-Ende: deferred release nach 800ms → Spotify resumed

Underrun-Schutz im PcmStreamPlayer haelt Spotify durchgehend gepaust
solange TTS rendert (auch in den GPU-Pausen zwischen Saetzen).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 11:59:13 +02:00
duffyduck 7f7db100af release: bump version to 0.0.8.4 2026-05-10 11:53:48 +02:00
duffyduck d646e9d58e fix(audio): Spotify spielt nicht mehr in der ARIA-Verarbeitungspause
Logcat-Befund: zwischen User-Aufnahme-Ende und TTS-Start liegt eine
~20s-Pause (Whisper STT + Claude + F5-TTS). In dieser Zeit hatte ARIA
keinen AudioFocus → Spotify lief munter weiter, dann pausierte beim
TTS-Start. Stefan hoerte das als 'Spotify kommt nach 20s wieder'.

Fix: ChatScreen ruft acquireConversationFocus sobald ein agent_activity-
Event mit activity != 'idle' kommt. Solange ARIA arbeitet (thinking/
tool/responding) bleibt der Focus gehalten, Spotify bleibt pausiert.
Bei onPlaybackFinished oder cancelRequest wird releaseConversationFocus
gerufen — sonst bliebe Spotify ewig stumm.

Funktioniert auch fuer reine Text-Chats (kein Wake-Word noetig).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 11:52:07 +02:00
duffyduck bef59ba134 release: bump version to 0.0.8.3 2026-05-10 11:46:26 +02:00
duffyduck dbebfd44ff fix(tts): Idle-Cutoff im PCM-Writer von 30s auf 120s
Bug-Vermutung: lange F5-TTS-Antworten reissen ab wenn die Gamebox
zwischen Saetzen >30s braucht (Modell-Wechsel, kalte GPU, ungewoehnlich
schwerer Satz). Writer-Thread brach dann mit 'Idle-Cutoff' ab und
ARIA verstummte mitten im Text.

120s deckt auch lange GPU-Pausen ab. Bei echtem Bridge-Crash brauchen
wir trotzdem irgendwann einen Cutoff damit der Foreground-Service
nicht ewig haengt.

Stefan kann ADB-Logs gerade nicht ziehen (telefoniert) — bei Bug 3
(Spotify) muessen wir noch die Native-Logs sehen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 10:37:59 +02:00
duffyduck 4d0b9e0d78 fix: dB-Range -85, Mute haert auch laufende TTS, VoIP-Anrufe + Bild-Bubble
Bug 1 — dB-Range erweitert:
VAD_SILENCE_DB_MIN von -55 auf -85 dB. Damit hat Stefan einen weiten
Regler-Spielraum wenn die adaptive Auto-Erkennung in seiner Umgebung
nicht zuverlaessig greift.

Bug 5 — Mute-Button stoppt laufende TTS nicht:
audioService bekommt jetzt einen internen _muted-Flag. handlePcmChunk
setzt silent automatisch wenn _muted true ist, playAudio kehrt frueh
zurueck. Verhindert Race zwischen User-Klick auf Mute und einem
TTS-Chunk der im selben JS-Tick ankommt (vorher: Ref-Update via
useEffect erst nach dem Re-Render → Chunks "rutschten durch"). Plus
ttsCanPlayRef wird im toggleMute-Handler synchron aktualisiert.

Bug 4 — VoIP/Messenger-Anrufe erkennen:
AudioFocusModule emittiert jetzt "AudioFocusChanged" Events mit type
"loss"/"loss_transient"/"gain". WhatsApp/Signal/Discord/etc. requestn
AudioFocus_GAIN_TRANSIENT_EXCLUSIVE wenn ein Anruf reinkommt — wir
fangen das in phoneCall.ts ab und rufen halt + pauseForCall genau
wie beim klassischen Anruf. Plus getMode() Polling-Fallback (alle 3s)
weil GAIN nicht zuverlaessig kommt wenn wir den Focus selbst released
haben — sobald AudioMode wieder NORMAL ist, resumeFromCall.

Bug 6 — Bilder als "Strich":
attachmentImage hatte width: '100%' in einer Bubble mit maxWidth: '80%'
ohne explizite Parent-Breite → RN rendert auf 0px Breite. Neue ChatImage-
Komponente nutzt Image.getSize um die echte aspectRatio zu messen + setzt
sie dynamisch. Bubble passt sich dem Bild an.

Bugs 2 (lange Texte mid-cutoff) + 3 (Spotify resumed) — brauchen ADB-Logs.
ADB-WLAN ueber 192.168.177.22:5555 schlaegt fehl (refused) — bei Android
11+ braucht's Wireless-Debugging-Pairing-Code. Stefan kann den nennen
sobald er soweit ist.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 10:28:52 +02:00
duffyduck 0c43a18402 release: bump version to 0.0.8.2 2026-05-07 08:31:06 +02:00
duffyduck 5bdcc3c65b feat(vad): Stille-Pegel manuell in Settings + Info-Modal
Wenn die adaptive Baseline-Logik in einer Umgebung nicht zuverlaessig
greift (Stefan: "manchmal funktioniert die Stille-Erkennung nicht"),
kann der User die Schwelle jetzt manuell setzen.

Settings → Spracheingabe:
- "Stille-Pegel (dB)" mit −1/+1 Buttons + "Auf automatisch zuruecksetzen"
- Range −55 bis −15 dB, default "auto" (= adaptive Baseline)
- Info-Icon (i) oeffnet Modal mit Erklaerung:
  • dB-Skala (negativ, naeher 0 = lauter)
  • Faustregel-Pegel mit Farb-Code (−45 sensibel, −38 ausgewogen, −25 robust)
  • Klarstellung "niedrigere Zahl = sensibler"

audio.ts:
- VAD_SILENCE_DB_OVERRIDE_KEY in AsyncStorage
- loadVadSilenceDbOverride() liefert null oder Zahl
- startRecording: wenn Override gesetzt, Adaptive-Baseline uebersteuert.
  Speech-Schwelle wird auf Override + 10 dB gesetzt. Toast zeigt
  "VAD: manuell stille>-XX dB"

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 08:24:26 +02:00
duffyduck 52795530f9 fix(audio): Wake-Word-Anruf-Pause + Resume-Cooldown + Background-Mic-Order
Bug 4 — Wake-Word laeuft bei Anruf weiter:
phoneCall ruft jetzt wakeWordService.pauseForCall bei RINGING/OFFHOOK
und resumeFromCall bei IDLE. Telefonie-App belegt das Mikro waehrend
des Anrufs, openWakeWord muss daher pausieren. Pre-Call-State wird
gemerkt — armed bleibt armed, conversing degraded zu armed (sonst
landet der User nach Auflegen in einem halben Dialog).

Bug 3 — App-Resume triggert faelschlich Wake-Word:
Beim Wechsel von Background nach Foreground gibt's Audio-Pegel-Spikes
(AudioFocus-Switch, AudioTrack re-route), die openWakeWord als Wake-
Word interpretiert. Neuer Cooldown-Mechanismus: AppState-Listener im
ChatScreen ruft wakeWordService.setResumeCooldown(1500) — Detections
in der Phase werden in onWakeDetected verworfen.

Bug 1 — Background-Aufnahme klappt nicht:
acquireBackgroundAudio('rec') wird jetzt VOR audioService.startRecorder
gerufen, acquireBackgroundAudio('wake') VOR OpenWakeWord.start. Sonst
greifen Androids Background-Mic-Restrictions (ab 11+) — der Service mit
foregroundServiceType=microphone muss zum Zeitpunkt des AudioRecord-
Starts schon aktiv sein, nicht erst per state-change-Listener
asynchron danach.

Bug 2 (VAD manchmal nicht): nicht in diesem Commit, vermutlich
umgebungsabhaengig. Toast zeigt die kalibrierten Schwellen — wenn
das nochmal auftritt, schick mir die Werte.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 07:49:02 +02:00
13 changed files with 987 additions and 129 deletions
+31 -3
View File
@@ -384,7 +384,7 @@ API-Endpoint fuer andere Services: `GET http://localhost:3001/api/session`
- **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) - **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" - **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 - **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) - **Anruf-Pause + Auto-Resume**: TTS verstummt bei klassischem Anruf oder VoIP-Call (WhatsApp/Signal/Discord). Nach dem Auflegen geht ARIA von der **genauen Stelle** weiter wo sie unterbrochen wurde — die App misst die Position vom Wiedergabe-Anfang und nutzt den WAV-Cache der Antwort
- **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
@@ -510,10 +510,36 @@ Der Update-Flow:
App (Mikrofon) → AAC/MP4 Aufnahme → Base64 → RVS → Bridge App (Mikrofon) → AAC/MP4 Aufnahme → Base64 → RVS → Bridge
Bridge: FFmpeg (16kHz PCM) → Whisper STT → Text → aria-core Bridge: FFmpeg (16kHz PCM) → Whisper STT → Text → aria-core
Bridge: STT-Ergebnis → RVS → App (Placeholder wird durch transkribierten Text ersetzt) Bridge: STT-Ergebnis → RVS → App (Placeholder wird durch transkribierten Text ersetzt)
aria-core → Antwort → Bridge → XTTS (Gaming-PC) → PCM-Stream → RVS → App aria-core → Antwort → Bridge → F5-TTS (Gaming-PC) → PCM-Stream → RVS → App
App: AudioTrack MODE_STREAM (nahtlos), Cache als WAV pro Message App: AudioTrack MODE_STREAM (nahtlos), Cache als WAV pro Message
``` ```
### Audio-Verhalten in der App
| Phase | Andere App (Spotify) | ARIA-Mikro |
|------------------------------|----------------------|-------------------------|
| Idle / Ohr aus | spielt frei | aus |
| Wake-Word lauscht (armed) | spielt frei | passiv (openWakeWord) |
| User-Aufnahme laeuft | pausiert (EXCLUSIVE) | Recording |
| Aufnahme zu Ende | resumed | aus |
| ARIA denkt/schreibt (~20s) | spielt frei | aus |
| TTS startet | pausiert (DUCK) | aus (oder barge) |
| TTS spielt (auch GPU-Pausen) | bleibt pausiert | barge wenn Wake-Word |
| TTS zu Ende | nach 800ms resumed | (Conversation-Window) |
| Eingehender Anruf (auch VoIP)| — | Mikro pausiert |
| Anruf vorbei (Auto-Resume) | pausiert wieder | aus |
| Neue Frage waehrend Anruf | — | (Resume verworfen) |
Mechanismen: Underrun-Schutz im PcmStreamPlayer (Stille-Fill in Render-
Pausen), Conversation-Focus bei Wake-Word, Foreground-Service mit
mediaPlayback|microphone, Anruf-Erkennung ueber TelephonyManager +
AudioFocus-Loss-Listener mit Polling-Fallback (VoIP). Bei Anruf wird
die Wiedergabe-Position gemerkt — nach dem Auflegen spielt ARIA ab
der genauen Stelle weiter (oder verwirft das wenn der User waehrend
des Telefonats per Text eine neue Frage gestellt hat). PcmPlayback-
Finished-Event vom Native sorgt dafuer dass Spotify erst pausiert
bleibt bis ARIA wirklich verstummt ist.
### Datei-Pipeline (Bilder & Anhaenge) ### Datei-Pipeline (Bilder & Anhaenge)
``` ```
@@ -844,7 +870,9 @@ docker exec aria-core ssh aria-wohnung hostname
- [x] Audio-Pause statt Ducking (TRANSIENT statt MAY_DUCK) + release-Timing fix - [x] Audio-Pause statt Ducking (TRANSIENT statt MAY_DUCK) + release-Timing fix
- [x] VAD-Stille-Toleranz einstellbar (1-8s) + adaptive Mikro-Baseline + Max-Aufnahme einstellbar (1-30 min) - [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] Barge-In: User kann ARIA waehrend Antwort unterbrechen, aria-core bekommt Kontext-Hint
- [x] Anruf-Pause: TTS verstummt bei eingehendem Anruf (PhoneStateListener) - [x] Anruf-Pause + Auto-Resume: TTS verstummt bei Anruf, faehrt nach Auflegen ab der gemerkten Position fort (Date.now()-Tracking + WAV-Cache der Antwort)
- [x] PcmPlaybackFinished-Event: AudioFocus wird erst released wenn AudioTrack wirklich durch ist — kein Spotify-mid-TTS mehr
- [x] Edge-Case: neue Frage waehrend Telefonat verwirft pending Auto-Resume, neueste Antwort gewinnt
- [x] Settings-Sub-Screens: 8 Kategorien statt langer Liste - [x] Settings-Sub-Screens: 8 Kategorien statt langer Liste
- [x] APK ABI-Split arm64-v8a: 35 MB statt 136 MB - [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] Sprachnachrichten-Bubble: audioRequestId statt Substring-Match — keine vertauschten Bubbles mehr bei parallelen Aufnahmen
+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 801 versionCode 809
versionName "0.0.8.1" versionName "0.0.8.9"
// Fallback fuer Libraries mit Product Flavors // Fallback fuer Libraries mit Product Flavors
missingDimensionStrategy 'react-native-camera', 'general' missingDimensionStrategy 'react-native-camera', 'general'
} }
@@ -5,26 +5,71 @@ import android.media.AudioAttributes
import android.media.AudioFocusRequest import android.media.AudioFocusRequest
import android.media.AudioManager import android.media.AudioManager
import android.os.Build import android.os.Build
import android.util.Log
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.Promise import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactContextBaseJavaModule import com.facebook.react.bridge.ReactContextBaseJavaModule
import com.facebook.react.bridge.ReactMethod import com.facebook.react.bridge.ReactMethod
import com.facebook.react.modules.core.DeviceEventManagerModule
/** /**
* Steuert Audio-Focus fuer Ducking/Muten anderer Apps. * Steuert Audio-Focus fuer Ducking/Muten anderer Apps + emittiert Loss-Events
* an JS damit ARIA bei VoIP-Anrufen (WhatsApp/Signal/Discord/...) aufhoert
* zu sprechen — diese Anrufe gehen nicht ueber TelephonyManager, sondern
* requestn AudioFocus_GAIN_TRANSIENT_EXCLUSIVE was wir hier mitbekommen.
* *
* - requestDuck() → andere Apps werden leiser (ARIA spricht TTS) * - requestDuck() → andere Apps werden leiser (ARIA spricht TTS)
* - requestExclusive() → andere Apps werden pausiert (Mikrofon-Aufnahme) * - requestExclusive() → andere Apps werden pausiert (Mikrofon-Aufnahme)
* - release() → Focus abgeben, andere Apps duerfen wieder * - release() → Focus abgeben, andere Apps duerfen wieder
*
* Events:
* - "AudioFocusChanged" mit type:
* "loss" — endgueltiger Verlust (Anruf, andere App permanent)
* "loss_transient" — vorruebergehender Verlust (kurze Unterbrechung)
* "gain" — Fokus zurueck
*/ */
class AudioFocusModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) { class AudioFocusModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) {
override fun getName() = "AudioFocus" override fun getName() = "AudioFocus"
companion object { private const val TAG = "AudioFocus" }
private var currentRequest: AudioFocusRequest? = null private var currentRequest: AudioFocusRequest? = null
private fun audioManager(): AudioManager? = private fun audioManager(): AudioManager? =
reactApplicationContext.getSystemService(Context.AUDIO_SERVICE) as? AudioManager reactApplicationContext.getSystemService(Context.AUDIO_SERVICE) as? AudioManager
private fun emitFocusChange(type: String) {
try {
val params = Arguments.createMap().apply { putString("type", type) }
reactApplicationContext.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
.emit("AudioFocusChanged", params)
} catch (e: Exception) {
Log.w(TAG, "emit failed: ${e.message}")
}
}
private val focusListener = AudioManager.OnAudioFocusChangeListener { focusChange ->
when (focusChange) {
AudioManager.AUDIOFOCUS_LOSS -> {
Log.i(TAG, "AUDIOFOCUS_LOSS (z.B. Anruf, anderer Player permanent)")
emitFocusChange("loss")
}
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> {
Log.i(TAG, "AUDIOFOCUS_LOSS_TRANSIENT (kurze Unterbrechung)")
emitFocusChange("loss_transient")
}
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> {
// Notification-Sound o.ae. — wir ignorieren das, ARIA macht weiter
Log.d(TAG, "AUDIOFOCUS_LOSS_CAN_DUCK ignoriert")
}
AudioManager.AUDIOFOCUS_GAIN -> {
Log.i(TAG, "AUDIOFOCUS_GAIN")
emitFocusChange("gain")
}
}
}
private fun requestFocus(durationHint: Int, usage: Int, promise: Promise) { private fun requestFocus(durationHint: Int, usage: Int, promise: Promise) {
val am = audioManager() val am = audioManager()
if (am == null) { if (am == null) {
@@ -41,13 +86,13 @@ class AudioFocusModule(reactContext: ReactApplicationContext) : ReactContextBase
.build() .build()
val req = AudioFocusRequest.Builder(durationHint) val req = AudioFocusRequest.Builder(durationHint)
.setAudioAttributes(attrs) .setAudioAttributes(attrs)
.setOnAudioFocusChangeListener { /* kein Callback noetig */ } .setOnAudioFocusChangeListener(focusListener)
.build() .build()
currentRequest = req currentRequest = req
am.requestAudioFocus(req) am.requestAudioFocus(req)
} else { } else {
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
am.requestAudioFocus(null, AudioManager.STREAM_MUSIC, durationHint) am.requestAudioFocus(focusListener, AudioManager.STREAM_MUSIC, durationHint)
} }
promise.resolve(result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED) promise.resolve(result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED)
@@ -92,8 +137,24 @@ class AudioFocusModule(reactContext: ReactApplicationContext) : ReactContextBase
currentRequest?.let { am.abandonAudioFocusRequest(it) } currentRequest?.let { am.abandonAudioFocusRequest(it) }
} else { } else {
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
am.abandonAudioFocus(null) am.abandonAudioFocus(focusListener)
} }
currentRequest = null currentRequest = null
} }
/** Aktueller Audio-Mode: NORMAL=0, IN_CALL=2, IN_COMMUNICATION=3, CALL_SCREENING=4.
* IN_COMMUNICATION ist der typische VoIP-Anruf-Mode (WhatsApp, Signal, etc.) —
* kann gepollt werden um zu erkennen wann der Anruf vorbei ist (zurueck NORMAL). */
@ReactMethod
fun getMode(promise: Promise) {
val am = audioManager()
if (am == null) {
promise.resolve(0)
return
}
promise.resolve(am.mode)
}
@ReactMethod fun addListener(eventName: String) {}
@ReactMethod fun removeListeners(count: Int) {}
} }
@@ -6,10 +6,12 @@ import android.media.AudioManager
import android.media.AudioTrack import android.media.AudioTrack
import android.util.Base64 import android.util.Base64
import android.util.Log import android.util.Log
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.Promise import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactContextBaseJavaModule import com.facebook.react.bridge.ReactContextBaseJavaModule
import com.facebook.react.bridge.ReactMethod import com.facebook.react.bridge.ReactMethod
import com.facebook.react.modules.core.DeviceEventManagerModule
import java.util.concurrent.LinkedBlockingQueue import java.util.concurrent.LinkedBlockingQueue
/** /**
@@ -76,9 +78,14 @@ class PcmStreamPlayerModule(reactContext: ReactApplicationContext) : ReactContex
val encoding = AudioFormat.ENCODING_PCM_16BIT val encoding = AudioFormat.ENCODING_PCM_16BIT
val minBuf = AudioTrack.getMinBufferSize(sampleRate, channelConfig, encoding) val minBuf = AudioTrack.getMinBufferSize(sampleRate, channelConfig, encoding)
val bytesPerSecond = sampleRate * channels * 2 // 16-bit = 2 bytes val bytesPerSecond = sampleRate * channels * 2 // 16-bit = 2 bytes
// Buffer muss mindestens PREROLL + etwas Spielraum fassen.
val prerollTarget = (bytesPerSecond * prerollSec).toInt() val prerollTarget = (bytesPerSecond * prerollSec).toInt()
val bufferSize = (minBuf * 32).coerceAtLeast(prerollTarget * 2) // Buffer entkoppelt von Preroll — fester ~3s-Buffer reicht. Wenn er
// an Preroll gekoppelt ist (z.B. 7s bei preroll=3.5s) und nur kurz
// gefuettert wird, stallt AudioTrack auf manchen Geraeten (OnePlus
// Android 12: pos bleibt 0 obwohl play() lief).
// 3s damit Padding bis 2s vor play() noch Headroom hat (write() ist
// blocking — wenn Buffer voll ist, deadlockt es vor play()).
val bufferSize = (bytesPerSecond * 3).coerceAtLeast(minBuf * 8)
prerollBytes = prerollTarget prerollBytes = prerollTarget
bytesBuffered = 0 bytesBuffered = 0
playbackStarted = false playbackStarted = false
@@ -137,10 +144,12 @@ class PcmStreamPlayerModule(reactContext: ReactApplicationContext) : ReactContex
Log.w(TAG, "play() sofort failed: ${e.message}") Log.w(TAG, "play() sofort failed: ${e.message}")
} }
} }
// Idle-Cutoff: wenn endRequested NICHT kam aber 30s nichts mehr // Idle-Cutoff: wenn endRequested NICHT kam aber lange nichts mehr
// reinkommt, brechen wir ab (Bridge-Crash, verlorener final). // reinkommt, brechen wir ab (Bridge-Crash, verlorener final).
// 120s damit lange F5-TTS-Render-Pausen zwischen Saetzen (z.B. bei
// Modell-Wechsel oder kalter GPU) nicht den Stream abreissen.
var idleMs = 0L var idleMs = 0L
val maxIdleMs = 30_000L val maxIdleMs = 120_000L
// Zielpufferfuellung — unter diesem Wasserstand fuettern wir // Zielpufferfuellung — unter diesem Wasserstand fuettern wir
// Stille rein damit AudioTrack nicht underrunt waehrend die // Stille rein damit AudioTrack nicht underrunt waehrend die
// Bridge den naechsten Satz rendert. Spotify/YouTube reagieren // Bridge den naechsten Satz rendert. Spotify/YouTube reagieren
@@ -152,16 +161,11 @@ class PcmStreamPlayerModule(reactContext: ReactApplicationContext) : ReactContex
val data = queue.poll(50, java.util.concurrent.TimeUnit.MILLISECONDS) val data = queue.poll(50, java.util.concurrent.TimeUnit.MILLISECONDS)
if (data == null) { if (data == null) {
if (endRequested) { if (endRequested) {
// Falls wir vor Pre-Roll enden (kurzer Text): trotzdem abspielen // Bei kurzem Text NICHT hier play() callen — erst nach
if (!playbackStarted) { // Trailing-Silence + Padding (siehe Block nach mainLoop),
try { // damit AudioTrack mit komplett gefuelltem Buffer startet.
t.play() // OnePlus A12: AudioTrack startet nicht zuverlaessig wenn
playbackStarted = true // play() bei dünnem Buffer gerufen wird.
Log.i(TAG, "Playback gestartet VOR Pre-Roll (kurzer Text, ${bytesBuffered}B gepuffert)")
} catch (e: Exception) {
Log.w(TAG, "play() fallback failed: ${e.message}")
}
}
break@mainLoop break@mainLoop
} }
// Underrun-Schutz: Stille reinfuettern wenn der AudioTrack- // Underrun-Schutz: Stille reinfuettern wenn der AudioTrack-
@@ -224,6 +228,30 @@ class PcmStreamPlayerModule(reactContext: ReactApplicationContext) : ReactContex
} }
bytesBuffered += silence.size bytesBuffered += silence.size
} }
// Bei kurzem Text (play() noch nicht gestartet): Buffer auf min.
// 2s padden + DANN play(). Auf OnePlus A12 startet AudioTrack
// bei einem zu duennen Buffer nicht — pos bleibt auf 0 stehen.
if (!playbackStarted && !writerShouldStop) {
val minStartBytes = bytesPerSecond * 2
if (bytesBuffered < minStartBytes) {
val padBytes = (minStartBytes - bytesBuffered.toInt()) and 0x7FFFFFFE
val pad = ByteArray(padBytes)
var padOff = 0
while (padOff < pad.size && !writerShouldStop) {
val w = t.write(pad, padOff, pad.size - padOff)
if (w <= 0) break
padOff += w
}
bytesBuffered += pad.size
}
try {
t.play()
playbackStarted = true
Log.i(TAG, "Playback gestartet (kurzer Text, ${bytesBuffered}B komplett gepuffert)")
} catch (e: Exception) {
Log.w(TAG, "play() short-text failed: ${e.message}")
}
}
} catch (e: Exception) { } catch (e: Exception) {
Log.w(TAG, "Writer-Thread Fehler: ${e.message}") Log.w(TAG, "Writer-Thread Fehler: ${e.message}")
} finally { } finally {
@@ -233,12 +261,21 @@ class PcmStreamPlayerModule(reactContext: ReactApplicationContext) : ReactContex
val totalFrames = (bytesBuffered / streamBytesPerFrame).toInt() val totalFrames = (bytesBuffered / streamBytesPerFrame).toInt()
var lastPos = -1 var lastPos = -1
var stalledCount = 0 var stalledCount = 0
var retried = false
while (!writerShouldStop) { while (!writerShouldStop) {
val pos = t.playbackHeadPosition val pos = t.playbackHeadPosition
if (pos >= totalFrames) break if (pos >= totalFrames) break
// Safety: wenn Position 2s nicht mehr vorwaerts → AudioTrack hing
if (pos == lastPos) { if (pos == lastPos) {
stalledCount++ stalledCount++
// Nach 500ms Stillstand: AudioTrack-Quirk auf manchen
// Geraeten (OnePlus A12) — play() nochmal anstossen.
if (stalledCount == 10 && pos == 0 && !retried) {
retried = true
Log.w(TAG, "playback nicht angefahren — retry play()")
try { t.play() } catch (e: Exception) {
Log.w(TAG, "retry play() failed: ${e.message}")
}
}
if (stalledCount > 40) { if (stalledCount > 40) {
Log.w(TAG, "playback stalled at $pos/$totalFrames — give up") Log.w(TAG, "playback stalled at $pos/$totalFrames — give up")
break break
@@ -253,6 +290,17 @@ class PcmStreamPlayerModule(reactContext: ReactApplicationContext) : ReactContex
} catch (_: Exception) {} } catch (_: Exception) {}
try { t.stop() } catch (_: Exception) {} try { t.stop() } catch (_: Exception) {}
try { t.release() } catch (_: Exception) {} try { t.release() } catch (_: Exception) {}
// RN-Event: AudioTrack ist wirklich durch (alle Samples gespielt).
// JS released erst JETZT den AudioFocus — sonst spielt Spotify
// beim end()-Cap waehrend ARIA noch redet (15s+ je nach Buffer).
try {
val params = Arguments.createMap()
reactApplicationContext
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
.emit("PcmPlaybackFinished", params)
} catch (e: Exception) {
Log.w(TAG, "PlaybackFinished emit failed: ${e.message}")
}
} }
}, "PcmStreamWriter").apply { start() } }, "PcmStreamWriter").apply { start() }
@@ -309,6 +357,9 @@ class PcmStreamPlayerModule(reactContext: ReactApplicationContext) : ReactContex
promise.resolve(true) promise.resolve(true)
} }
@ReactMethod fun addListener(eventName: String) {}
@ReactMethod fun removeListeners(count: Int) {}
private fun stopInternal() { private fun stopInternal() {
writerShouldStop = true writerShouldStop = true
endRequested = true endRequested = true
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "aria-cockpit", "name": "aria-cockpit",
"version": "0.0.8.1", "version": "0.0.8.9",
"private": true, "private": true,
"scripts": { "scripts": {
"android": "react-native run-android", "android": "react-native run-android",
+74 -13
View File
@@ -19,6 +19,7 @@ import {
ScrollView, ScrollView,
Modal, Modal,
ToastAndroid, ToastAndroid,
AppState,
} from 'react-native'; } from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage'; import AsyncStorage from '@react-native-async-storage/async-storage';
import RNFS from 'react-native-fs'; import RNFS from 'react-native-fs';
@@ -79,6 +80,45 @@ const capMessages = (msgs: ChatMessage[]): ChatMessage[] =>
const DEFAULT_ATTACHMENT_DIR = `${RNFS.DocumentDirectoryPath}/chat_attachments`; const DEFAULT_ATTACHMENT_DIR = `${RNFS.DocumentDirectoryPath}/chat_attachments`;
const STORAGE_PATH_KEY = 'aria_attachment_storage_path'; const STORAGE_PATH_KEY = 'aria_attachment_storage_path';
/** Image-Vorschau in der Chat-Bubble. Misst die echte Bild-Dimension via
* Image.getSize + setzt aspectRatio dynamisch — dadurch passt sich die
* Bubble ans Bild an (kein "Strich" mehr bei breiten oder hohen Bildern). */
const CHAT_IMAGE_STYLE = {
width: 260,
borderRadius: 8,
marginBottom: 6,
backgroundColor: '#0D0D1A',
} as const;
const ChatImage: React.FC<{
uri: string;
onPress: () => void;
onError: () => void;
}> = ({ uri, onPress, onError }) => {
const [aspectRatio, setAspectRatio] = useState<number>(4 / 3);
useEffect(() => {
let cancelled = false;
Image.getSize(uri, (w, h) => {
if (!cancelled && w > 0 && h > 0) {
// Aspect-Ratio capen damit sehr lange Panorama-Bilder oder hohe
// Screenshot-Streifen die Bubble nicht sprengen
const r = Math.max(0.5, Math.min(2.5, w / h));
setAspectRatio(r);
}
}, () => {});
return () => { cancelled = true; };
}, [uri]);
return (
<TouchableOpacity onPress={onPress} activeOpacity={0.8}>
<Image
source={{ uri }}
style={[CHAT_IMAGE_STYLE, { aspectRatio }]}
resizeMode="cover"
onError={onError}
/>
</TouchableOpacity>
);
};
async function getAttachmentDir(): Promise<string> { async function getAttachmentDir(): Promise<string> {
try { try {
const saved = await AsyncStorage.getItem(STORAGE_PATH_KEY); const saved = await AsyncStorage.getItem(STORAGE_PATH_KEY);
@@ -153,7 +193,9 @@ const ChatScreen: React.FC = () => {
const enabled = await AsyncStorage.getItem('aria_tts_enabled'); const enabled = await AsyncStorage.getItem('aria_tts_enabled');
setTtsDeviceEnabled(enabled !== 'false'); // default true setTtsDeviceEnabled(enabled !== 'false'); // default true
const muted = await AsyncStorage.getItem('aria_tts_muted'); const muted = await AsyncStorage.getItem('aria_tts_muted');
setTtsMuted(muted === 'true'); // default false const isMuted = muted === 'true';
setTtsMuted(isMuted); // default false
audioService.setMuted(isMuted); // service-internen Flag synchronisieren
const voice = await AsyncStorage.getItem('aria_xtts_voice'); const voice = await AsyncStorage.getItem('aria_xtts_voice');
localXttsVoiceRef.current = voice || ''; localXttsVoiceRef.current = voice || '';
ttsSpeedRef.current = await loadTtsSpeed(); ttsSpeedRef.current = await loadTtsSpeed();
@@ -193,6 +235,20 @@ const ChatScreen: React.FC = () => {
return () => { phoneCallService.stop().catch(() => {}); }; return () => { phoneCallService.stop().catch(() => {}); };
}, []); }, []);
// App-Resume: kurzer Wake-Word-Cooldown — beim Wechsel Background→Foreground
// gibt's haeufig Audio-Pegel-Spikes (AudioFocus-Switch, AudioTrack re-route)
// die openWakeWord sonst faelschlich als Wake-Word interpretiert.
useEffect(() => {
let lastState: string = AppState.currentState;
const sub = AppState.addEventListener('change', (next) => {
if (lastState !== 'active' && next === 'active') {
wakeWordService.setResumeCooldown(1500);
}
lastState = next;
});
return () => sub.remove();
}, []);
// Recording-State an Background-Service-Slot 'rec' koppeln — damit das Mikro // Recording-State an Background-Service-Slot 'rec' koppeln — damit das Mikro
// auch im Hintergrund weiter aufnehmen darf (Android killt den App-Prozess // auch im Hintergrund weiter aufnehmen darf (Android killt den App-Prozess
// sonst und die Aufnahme bricht ab). // sonst und die Aufnahme bricht ab).
@@ -214,11 +270,15 @@ const ChatScreen: React.FC = () => {
setTtsMuted(prev => { setTtsMuted(prev => {
const next = !prev; const next = !prev;
AsyncStorage.setItem('aria_tts_muted', String(next)); AsyncStorage.setItem('aria_tts_muted', String(next));
// Bei Muten sofort laufende Wiedergabe stoppen // Ref synchron updaten — sonst kommen noch Chunks im selben Tick
if (next) audioService.stopPlayback(); // mit canPlay=true durch (Race vor dem useEffect-Update).
ttsCanPlayRef.current = ttsDeviceEnabled && !next;
// Globalen Mute-Flag im audioService setzen — uebersteuert auch
// payload.silent in handlePcmChunk und stoppt laufende Wiedergabe.
audioService.setMuted(next);
return next; return next;
}); });
}, []); }, [ttsDeviceEnabled]);
// Chat-Verlauf aus AsyncStorage laden // Chat-Verlauf aus AsyncStorage laden
const isInitialLoad = useRef(true); const isInitialLoad = useRef(true);
@@ -435,6 +495,8 @@ const ChatScreen: React.FC = () => {
const activity = (message.payload.activity as string) || 'idle'; const activity = (message.payload.activity as string) || 'idle';
const tool = (message.payload.tool as string) || ''; const tool = (message.payload.tool as string) || '';
setAgentActivity({ activity, tool }); setAgentActivity({ activity, tool });
// Spotify darf waehrend "ARIA denkt/schreibt" weiterspielen — pausiert
// nur wenn TTS startet (dann acquired _firePlaybackStarted den Focus).
} }
// Voice-Config aus Diagnostic — setzt die lokale App-Stimme auf den // Voice-Config aus Diagnostic — setzt die lokale App-Stimme auf den
@@ -910,11 +972,9 @@ const ChatScreen: React.FC = () => {
{item.attachments?.map((att, idx) => ( {item.attachments?.map((att, idx) => (
<View key={idx}> <View key={idx}>
{att.type === 'image' && att.uri ? ( {att.type === 'image' && att.uri ? (
<TouchableOpacity onPress={() => setFullscreenImage(att.uri || null)} activeOpacity={0.8}> <ChatImage
<Image uri={att.uri}
source={{ uri: att.uri }} onPress={() => setFullscreenImage(att.uri || null)}
style={styles.attachmentImage}
resizeMode="cover"
onError={() => { onError={() => {
setMessages(prev => prev.map(m => setMessages(prev => prev.map(m =>
m.id === item.id ? { ...m, attachments: m.attachments?.map((a, i) => m.id === item.id ? { ...m, attachments: m.attachments?.map((a, i) =>
@@ -923,7 +983,6 @@ const ChatScreen: React.FC = () => {
)); ));
}} }}
/> />
</TouchableOpacity>
) : att.type === 'image' && !att.uri ? ( ) : att.type === 'image' && !att.uri ? (
<TouchableOpacity <TouchableOpacity
style={styles.attachmentFile} style={styles.attachmentFile}
@@ -1326,9 +1385,11 @@ const styles = StyleSheet.create({
color: '#E0E0F0', color: '#E0E0F0',
}, },
attachmentImage: { attachmentImage: {
width: '100%', // Feste Breite + dynamische aspectRatio (in ChatImage gesetzt) damit die
minHeight: 200, // Bubble sich ans Bild anpasst. Mit width: '100%' ohne explizite Parent-
maxHeight: 400, // Breite wuerde RN das Bild auf 0px schrumpfen → "Strich".
width: 260,
aspectRatio: 4 / 3,
borderRadius: 8, borderRadius: 8,
marginBottom: 6, marginBottom: 6,
backgroundColor: '#0D0D1A', backgroundColor: '#0D0D1A',
+178
View File
@@ -17,6 +17,7 @@ import {
Platform, Platform,
ToastAndroid, ToastAndroid,
ActivityIndicator, ActivityIndicator,
Modal,
} from 'react-native'; } from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage'; import AsyncStorage from '@react-native-async-storage/async-storage';
import RNFS from 'react-native-fs'; import RNFS from 'react-native-fs';
@@ -39,6 +40,10 @@ import {
MAX_RECORDING_MIN_SEC, MAX_RECORDING_MIN_SEC,
MAX_RECORDING_MAX_SEC, MAX_RECORDING_MAX_SEC,
MAX_RECORDING_STORAGE_KEY, MAX_RECORDING_STORAGE_KEY,
VAD_SILENCE_DB_DEFAULT,
VAD_SILENCE_DB_MIN,
VAD_SILENCE_DB_MAX,
VAD_SILENCE_DB_OVERRIDE_KEY,
TTS_SPEED_DEFAULT, TTS_SPEED_DEFAULT,
TTS_SPEED_MIN, TTS_SPEED_MIN,
TTS_SPEED_MAX, TTS_SPEED_MAX,
@@ -58,6 +63,7 @@ import wakeWordService, {
import ModeSelector from '../components/ModeSelector'; import ModeSelector from '../components/ModeSelector';
import QRScanner from '../components/QRScanner'; import QRScanner from '../components/QRScanner';
import VoiceCloneModal from '../components/VoiceCloneModal'; import VoiceCloneModal from '../components/VoiceCloneModal';
import updateService from '../services/updater';
const STORAGE_PATH_KEY = 'aria_attachment_storage_path'; const STORAGE_PATH_KEY = 'aria_attachment_storage_path';
const DEFAULT_STORAGE_PATH = `${RNFS.DocumentDirectoryPath}/chat_attachments`; const DEFAULT_STORAGE_PATH = `${RNFS.DocumentDirectoryPath}/chat_attachments`;
@@ -124,6 +130,10 @@ const SettingsScreen: React.FC = () => {
const [vadSilenceSec, setVadSilenceSec] = useState<number>(VAD_SILENCE_DEFAULT_SEC); const [vadSilenceSec, setVadSilenceSec] = useState<number>(VAD_SILENCE_DEFAULT_SEC);
const [convWindowSec, setConvWindowSec] = useState<number>(CONV_WINDOW_DEFAULT_SEC); const [convWindowSec, setConvWindowSec] = useState<number>(CONV_WINDOW_DEFAULT_SEC);
const [maxRecordingSec, setMaxRecordingSec] = useState<number>(MAX_RECORDING_DEFAULT_SEC); const [maxRecordingSec, setMaxRecordingSec] = useState<number>(MAX_RECORDING_DEFAULT_SEC);
// null = automatisch (adaptive Baseline), sonst manueller dB-Override
const [vadSilenceDb, setVadSilenceDb] = useState<number | null>(null);
const [showVadInfo, setShowVadInfo] = useState(false);
const [apkCacheInfo, setApkCacheInfo] = useState<{count: number, totalMB: number} | null>(null);
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>('');
@@ -194,6 +204,14 @@ const SettingsScreen: React.FC = () => {
} }
} }
}); });
AsyncStorage.getItem(VAD_SILENCE_DB_OVERRIDE_KEY).then(saved => {
if (saved != null && saved !== '') {
const n = parseFloat(saved);
if (isFinite(n) && n >= VAD_SILENCE_DB_MIN && n <= VAD_SILENCE_DB_MAX) {
setVadSilenceDb(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);
@@ -204,6 +222,7 @@ const SettingsScreen: React.FC = () => {
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); isWakeReadySoundEnabled().then(setWakeReadySound);
updateService.getApkCacheSize().then(setApkCacheInfo).catch(() => {});
AsyncStorage.getItem('aria_xtts_voice').then(saved => { AsyncStorage.getItem('aria_xtts_voice').then(saved => {
if (saved) setXttsVoice(saved); if (saved) setXttsVoice(saved);
}); });
@@ -782,8 +801,94 @@ const SettingsScreen: React.FC = () => {
<Text style={styles.prerollButtonText}>+1m</Text> <Text style={styles.prerollButtonText}>+1m</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
<View style={{flexDirection: 'row', alignItems: 'center', marginTop: 24, gap: 8}}>
<Text style={styles.toggleLabel}>Stille-Pegel (dB)</Text>
<TouchableOpacity onPress={() => setShowVadInfo(true)} style={styles.infoBtn}>
<Text style={styles.infoBtnText}>i</Text>
</TouchableOpacity>
</View>
<Text style={styles.toggleHint}>
Welcher Mikro-Pegel als "Stille" gilt. Standard: automatisch (Baseline aus
den ersten 500ms). Manuell setzen wenn Auto nicht zuverlaessig greift.
</Text>
<View style={styles.prerollRow}>
<TouchableOpacity
style={styles.prerollButton}
onPress={() => {
const next = vadSilenceDb == null
? VAD_SILENCE_DB_DEFAULT - 1
: Math.max(VAD_SILENCE_DB_MIN, vadSilenceDb - 1);
setVadSilenceDb(next);
AsyncStorage.setItem(VAD_SILENCE_DB_OVERRIDE_KEY, String(next));
}}
>
<Text style={styles.prerollButtonText}>1</Text>
</TouchableOpacity>
<Text style={styles.prerollValue}>
{vadSilenceDb == null ? 'auto' : `${vadSilenceDb} dB`}
</Text>
<TouchableOpacity
style={styles.prerollButton}
onPress={() => {
const next = vadSilenceDb == null
? VAD_SILENCE_DB_DEFAULT + 1
: Math.min(VAD_SILENCE_DB_MAX, vadSilenceDb + 1);
setVadSilenceDb(next);
AsyncStorage.setItem(VAD_SILENCE_DB_OVERRIDE_KEY, String(next));
}}
>
<Text style={styles.prerollButtonText}>+1</Text>
</TouchableOpacity>
</View>
{vadSilenceDb != null && (
<TouchableOpacity
onPress={() => {
setVadSilenceDb(null);
AsyncStorage.removeItem(VAD_SILENCE_DB_OVERRIDE_KEY);
}}
style={{alignSelf: 'center', marginTop: 8, paddingVertical: 6, paddingHorizontal: 12}}
>
<Text style={{color: '#0096FF', fontSize: 13}}> Auf automatisch zuruecksetzen</Text>
</TouchableOpacity>
)}
</View> </View>
<Modal
visible={showVadInfo}
transparent
animationType="fade"
onRequestClose={() => setShowVadInfo(false)}
>
<View style={styles.modalOverlay}>
<View style={styles.modalCard}>
<Text style={styles.modalTitle}>Stille-Pegel (dB)</Text>
<Text style={styles.modalText}>
Lautstaerken werden in Dezibel (dB) gemessen negative Werte, je
hoeher (naeher an 0), desto lauter.{'\n\n'}
<Text style={{fontWeight: '700'}}>Standard:</Text> automatisch.
Die App misst die ersten 500ms Hintergrundpegel und setzt die
Stille-Schwelle auf Baseline + 6 dB. Funktioniert in den meisten
Umgebungen.{'\n\n'}
<Text style={{fontWeight: '700'}}>Manuell:</Text> Pegel unter dem
eingestellten Wert gilt als "Stille" Aufnahme stoppt.{'\n\n'}
<Text style={{fontWeight: '700'}}>Faustregel:</Text>{'\n'}
<Text style={{color: '#FFD60A'}}>45 dB</Text> sehr empfindlich (stoppt schnell, auch bei Atmen){'\n'}
<Text style={{color: '#34C759'}}>38 dB</Text> ausgewogen (typische Bueroumgebung){'\n'}
<Text style={{color: '#FF6B6B'}}>25 dB</Text> unempfindlich (laute Umgebung, nur klare Sprache zaehlt){'\n\n'}
<Text style={{color: '#8888AA'}}>Niedrigere Zahl (z.B. 50) = sensibler.{'\n'}
Hoehere Zahl (z.B. 20) = robuster gegen Hintergrundlaerm,
braucht aber lautere Sprache.</Text>
</Text>
<TouchableOpacity
style={[styles.connectButton, {marginTop: 16, alignSelf: 'stretch'}]}
onPress={() => setShowVadInfo(false)}
>
<Text style={styles.connectButtonText}>OK</Text>
</TouchableOpacity>
</View>
</View>
</Modal>
</>)} </>)}
{/* === Wake-Word (komplett on-device, openWakeWord) === */} {/* === Wake-Word (komplett on-device, openWakeWord) === */}
@@ -1092,6 +1197,37 @@ const SettingsScreen: React.FC = () => {
)} )}
</View> </View>
{/* === Update-Cache === */}
<Text style={[styles.sectionTitle, {marginTop: 16}]}>Update-Cache</Text>
<View style={styles.card}>
<Text style={styles.toggleHint}>
Heruntergeladene APK-Dateien fuer App-Updates. Werden automatisch
beim App-Start und vor jedem neuen Download geloescht der Button
ist fuer den Notfall (z.B. wenn ein Download haengen geblieben ist).
</Text>
<Text style={[styles.storageSizeText, {marginTop: 8}]}>
{apkCacheInfo === null ? '...' :
apkCacheInfo.count === 0 ? 'leer' :
`${apkCacheInfo.count} APK${apkCacheInfo.count === 1 ? '' : 's'} · ${apkCacheInfo.totalMB.toFixed(1)}MB`}
</Text>
<TouchableOpacity
style={[styles.clearButton, {marginTop: 8, backgroundColor: 'rgba(255,59,48,0.15)'}]}
onPress={async () => {
const res = await updateService.cleanupOldApks();
ToastAndroid.show(
res.removed === 0
? 'Update-Cache war schon leer'
: `${res.removed} APK${res.removed === 1 ? '' : 's'} geloescht (${res.freedMB.toFixed(1)}MB frei)`,
ToastAndroid.SHORT,
);
const info = await updateService.getApkCacheSize();
setApkCacheInfo(info);
}}
>
<Text style={[styles.clearButtonText, {color: '#FF3B30'}]}>Update-Cache leeren</Text>
</TouchableOpacity>
</View>
</>)} </>)}
{/* === Logs === */} {/* === Logs === */}
@@ -1635,6 +1771,48 @@ const styles = StyleSheet.create({
textAlign: 'center', textAlign: 'center',
}, },
infoBtn: {
width: 22,
height: 22,
borderRadius: 11,
borderWidth: 1.5,
borderColor: '#0096FF',
alignItems: 'center',
justifyContent: 'center',
},
infoBtnText: {
color: '#0096FF',
fontSize: 13,
fontWeight: '700',
fontStyle: 'italic',
lineHeight: 16,
},
modalOverlay: {
flex: 1,
backgroundColor: 'rgba(0,0,0,0.7)',
justifyContent: 'center',
alignItems: 'center',
padding: 20,
},
modalCard: {
backgroundColor: '#1E1E2E',
borderRadius: 14,
padding: 20,
maxWidth: 460,
width: '100%',
},
modalTitle: {
color: '#FFFFFF',
fontSize: 18,
fontWeight: '700',
marginBottom: 12,
},
modalText: {
color: '#E0E0F0',
fontSize: 14,
lineHeight: 20,
},
keywordChip: { keywordChip: {
backgroundColor: '#1E1E2E', backgroundColor: '#1E1E2E',
borderWidth: 1, borderWidth: 1,
+195 -12
View File
@@ -6,11 +6,11 @@
* Nutzt react-native-audio-recorder-player fuer Aufnahme. * Nutzt react-native-audio-recorder-player fuer Aufnahme.
*/ */
import { Platform, PermissionsAndroid, NativeModules, ToastAndroid } from 'react-native'; import { Platform, PermissionsAndroid, NativeModules, ToastAndroid, NativeEventEmitter } 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';
import { stopBackgroundAudio } from './backgroundAudio'; import { acquireBackgroundAudio, releaseBackgroundAudio, stopBackgroundAudio } from './backgroundAudio';
import AudioRecorderPlayer, { import AudioRecorderPlayer, {
AudioEncoderAndroidType, AudioEncoderAndroidType,
AudioSourceAndroidType, AudioSourceAndroidType,
@@ -85,6 +85,29 @@ const VAD_SPEECH_OFFSET_DB = 12; // sicheres Speech = Baseline + 12dB
const VAD_BASELINE_SAMPLES = 5; // 5 × 100ms = 500ms Baseline 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
// Override fuer die Stille-Schwelle — wenn gesetzt, wird die adaptive Baseline
// ignoriert. Nuetzlich wenn die adaptive Logik in spezifischen Umgebungen
// nicht zuverlaessig greift. Range -55..-15 dB. Speech-Schwelle wird auf
// override+10 dB gesetzt (Speech muss klar lauter als Stille sein).
export const VAD_SILENCE_DB_DEFAULT = -38; // wenn User Manuell-Modus waehlt
export const VAD_SILENCE_DB_MIN = -85; // extrem empfindlich, praktisch alles gilt als Sprache
export const VAD_SILENCE_DB_MAX = -15; // sehr unempfindlich, nur lautes Reden gilt
export const VAD_SILENCE_DB_OVERRIDE_KEY = 'aria_vad_silence_db_override';
/** Liefert den manuellen Override-Wert oder null wenn "automatisch". */
export async function loadVadSilenceDbOverride(): Promise<number | null> {
try {
const raw = await AsyncStorage.getItem(VAD_SILENCE_DB_OVERRIDE_KEY);
if (raw == null || raw === '') return null;
const n = parseFloat(raw);
if (!isFinite(n)) return null;
if (n < VAD_SILENCE_DB_MIN || n > VAD_SILENCE_DB_MAX) return null;
return n;
} catch {
return null;
}
}
// VAD-Stille (in Sekunden) — wie lange Sprechpause toleriert wird, bevor // VAD-Stille (in Sekunden) — wie lange Sprechpause toleriert wird, bevor
// die Aufnahme automatisch beendet wird. Einstellbar in den App-Settings. // die Aufnahme automatisch beendet wird. Einstellbar in den App-Settings.
export const VAD_SILENCE_DEFAULT_SEC = 2.8; export const VAD_SILENCE_DEFAULT_SEC = 2.8;
@@ -246,9 +269,38 @@ class AudioService {
private vadAdaptiveSilenceDb: number = VAD_SILENCE_FALLBACK_DB; private vadAdaptiveSilenceDb: number = VAD_SILENCE_FALLBACK_DB;
private vadAdaptiveSpeechDb: number = VAD_SPEECH_FALLBACK_DB; private vadAdaptiveSpeechDb: number = VAD_SPEECH_FALLBACK_DB;
// Interruption-Tracking fuer Auto-Resume nach Anruf:
// - playbackStartTime: ms-Timestamp wenn AudioTrack tatsaechlich anfing
// abzuspielen (= _firePlaybackStarted)
// - currentPlaybackMsgId: welche Antwort lief gerade
// - pausedPosition / pausedMessageId: bei captureInterruption gemerkt
private playbackStartTime: number = 0;
private currentPlaybackMsgId: string = '';
private pausedPosition: number = 0; // Sekunden in der Audio-Datei
private pausedMessageId: string = '';
private resumeSound: Sound | null = null; // halten damit GC nicht zuschlaegt
// Leading-Silence wird im Native vor den Chunks geschrieben — beim
// Position-Berechnen vom playbackStarted abziehen
private readonly LEADING_SILENCE_SEC = 0.3;
constructor() { constructor() {
this.recorder = new AudioRecorderPlayer(); this.recorder = new AudioRecorderPlayer();
this.recorder.setSubscriptionDuration(0.1); // 100ms Metering-Updates this.recorder.setSubscriptionDuration(0.1); // 100ms Metering-Updates
// Native Event: AudioTrack hat alle Samples wirklich durchgespielt (nach
// dem finally{}-Block im Writer-Thread). ERST jetzt darf AudioFocus
// freigegeben werden — sonst spielt Spotify schon waehrend ARIA noch
// redet (PcmStreamPlayer.end() returnt mit 15s-Cap viel zu frueh).
if (PcmStreamPlayer) {
try {
const emitter = new NativeEventEmitter(NativeModules.PcmStreamPlayer as any);
emitter.addListener('PcmPlaybackFinished', () => {
console.log('[Audio] PcmPlaybackFinished — Focus jetzt freigeben');
this._releaseFocusDeferred();
});
} catch (err) {
console.warn('[Audio] PcmPlaybackFinished-Subscription fehlgeschlagen:', err);
}
}
} }
/** AudioFocus mit kleiner Verzoegerung freigeben — Spotify/YouTube /** AudioFocus mit kleiner Verzoegerung freigeben — Spotify/YouTube
@@ -303,6 +355,84 @@ class AudioService {
this.stopPlayback(); this.stopPlayback();
} }
/** Bei Anruf: aktuelle Wiedergabe-Position merken damit wir nach dem
* Auflegen von dort weitermachen koennen. Returnt Position in Sekunden
* oder 0 wenn nichts spielte. */
captureInterruption(): number {
if (!this.playbackStartTime || !this.currentPlaybackMsgId) {
this.pausedPosition = 0;
this.pausedMessageId = '';
return 0;
}
const elapsedMs = Date.now() - this.playbackStartTime;
const positionSec = Math.max(0, elapsedMs / 1000 - this.LEADING_SILENCE_SEC);
this.pausedPosition = positionSec;
this.pausedMessageId = this.currentPlaybackMsgId;
console.log('[Audio] captureInterruption: msgId=%s pos=%ss',
this.pausedMessageId, positionSec.toFixed(2));
return positionSec;
}
/** Nach Anruf-Ende: ab gemerkter Position weiterspielen. Wenn Cache noch
* nicht geschrieben (final kam waehrend Anruf vielleicht doch nicht),
* warten bis maxWaitMs und dann probieren. Returnt true wenn gestartet. */
async resumeFromInterruption(maxWaitMs: number = 30000): Promise<boolean> {
const msgId = this.pausedMessageId;
const position = this.pausedPosition;
if (!msgId) return false;
this.pausedMessageId = ''; // konsumieren
const cachePath = `${RNFS.DocumentDirectoryPath}/tts_cache/${msgId}.wav`;
const startTime = Date.now();
while (Date.now() - startTime < maxWaitMs) {
try {
if (await RNFS.exists(cachePath)) {
return await this._playFromPathAtPosition(cachePath, position);
}
} catch {}
await new Promise(r => setTimeout(r, 500));
}
console.warn('[Audio] resumeFromInterruption: WAV %s nicht binnen %dms verfuegbar',
msgId, maxWaitMs);
return false;
}
private async _playFromPathAtPosition(path: string, positionSec: number): Promise<boolean> {
try {
// Bestehende laufende Wiedergabe abbrechen damit wir sauber starten
if (this.resumeSound) {
try { this.resumeSound.stop(); this.resumeSound.release(); } catch {}
this.resumeSound = null;
}
const sound = await new Promise<Sound>((resolve, reject) => {
const s = new Sound(path.replace(/^file:\/\//, ''), '', (err) =>
err ? reject(err) : resolve(s));
});
// Audio-Focus anfordern damit Spotify pausiert
this._cancelDeferredFocusRelease();
AudioFocus?.requestDuck().catch(() => {});
this._firePlaybackStarted();
this.isPlaying = true;
this.resumeSound = sound;
console.log('[Audio] Resume von Position %ss aus %s',
positionSec.toFixed(2), path);
sound.setCurrentTime(Math.max(0, positionSec));
sound.play((success) => {
if (!success) console.warn('[Audio] Resume-Wiedergabe fehlgeschlagen');
try { sound.release(); } catch {}
if (this.resumeSound === sound) this.resumeSound = null;
this.isPlaying = false;
this.playbackFinishedListeners.forEach(cb => {
try { cb(); } catch (e) { console.warn('[Audio] cb err:', e); }
});
this._releaseFocusDeferred();
});
return true;
} catch (err: any) {
console.warn('[Audio] _playFromPathAtPosition fehlgeschlagen:', err?.message || err);
return false;
}
}
/** True wenn ARIA gerade was abspielt — egal ob WAV-Queue oder PCM-Stream. /** True wenn ARIA gerade was abspielt — egal ob WAV-Queue oder PCM-Stream.
* Nuetzlich fuer "Barge-In": wenn der User spricht waehrend ARIA spricht, * Nuetzlich fuer "Barge-In": wenn der User spricht waehrend ARIA spricht,
* soll die ARIA-Wiedergabe abgebrochen + die neue User-Message verarbeitet * soll die ARIA-Wiedergabe abgebrochen + die neue User-Message verarbeitet
@@ -368,6 +498,12 @@ class AudioService {
this.recordingPath = `${RNFS.CachesDirectoryPath}/aria_recording_${Date.now()}.mp4`; this.recordingPath = `${RNFS.CachesDirectoryPath}/aria_recording_${Date.now()}.mp4`;
// Foreground-Service VOR dem AudioRecord starten — sonst blockt Android
// den Background-Mic-Zugriff (foregroundServiceType=microphone muss zum
// Zeitpunkt des startRecorder() schon aktiv sein, sonst greifen die
// Background-Mic-Restrictions ab Android 11+).
await acquireBackgroundAudio('rec');
// Aufnahme mit Metering starten // Aufnahme mit Metering starten
await this.recorder.startRecorder(this.recordingPath, { await this.recorder.startRecorder(this.recordingPath, {
AudioEncoderAndroid: AudioEncoderAndroidType.AAC, AudioEncoderAndroid: AudioEncoderAndroidType.AAC,
@@ -437,11 +573,22 @@ class AudioService {
this.speechDetected = false; this.speechDetected = false;
this.speechStartTime = 0; this.speechStartTime = 0;
// VAD-Adaptive zurueckgesetzt: Baseline wird in den ersten 500ms neu // VAD-Adaptive zurueckgesetzt: Baseline wird in den ersten 500ms neu
// gemessen. Bis dahin gelten die Fallback-Schwellen — die sind etwas // gemessen. Bis dahin gelten die Fallback-Schwellen.
// empfindlicher als die alten Werte (-38 statt -45 fuer Stille).
this.vadBaselineSamples = []; this.vadBaselineSamples = [];
this.vadAdaptiveSilenceDb = VAD_SILENCE_FALLBACK_DB; this.vadAdaptiveSilenceDb = VAD_SILENCE_FALLBACK_DB;
this.vadAdaptiveSpeechDb = VAD_SPEECH_FALLBACK_DB; this.vadAdaptiveSpeechDb = VAD_SPEECH_FALLBACK_DB;
// Manueller Override aus Settings — wenn gesetzt, wird die adaptive
// Baseline-Messung uebersteuert. User-Wahl gewinnt vor Auto-Magic.
const dbOverride = await loadVadSilenceDbOverride();
if (dbOverride != null) {
this.vadAdaptiveSilenceDb = dbOverride;
this.vadAdaptiveSpeechDb = dbOverride + 10; // Speech klar ueber Stille
this.vadBaselineSamples = new Array(VAD_BASELINE_SAMPLES).fill(0); // Baseline-Sammeln deaktivieren
const msg = `VAD: manuell stille>${dbOverride}dB`;
console.log('[Audio] %s', msg);
try { ToastAndroid.show(msg, ToastAndroid.SHORT); } catch {}
}
this.setState('recording'); this.setState('recording');
// Andere Apps waehrend der Aufnahme pausieren (Musik, Videos etc.) // Andere Apps waehrend der Aufnahme pausieren (Musik, Videos etc.)
@@ -570,7 +717,9 @@ class AudioService {
/** Base64-kodiertes Audio in die Queue stellen und abspielen */ /** Base64-kodiertes Audio in die Queue stellen und abspielen */
async playAudio(base64Data: string): Promise<void> { async playAudio(base64Data: string): Promise<void> {
if (!base64Data) return; if (!base64Data) return;
// Mute-Flag respektieren — robust gegen Race-Conditions zwischen User-
// Klick auf Mute und einem TTS-Chunk der im selben Tick eintrifft.
if (this._muted) return;
this.audioQueue.push(base64Data); this.audioQueue.push(base64Data);
if (!this.isPlaying) { if (!this.isPlaying) {
this._playNext(); this._playNext();
@@ -637,7 +786,9 @@ class AudioService {
final?: boolean; final?: boolean;
silent?: boolean; silent?: boolean;
}): Promise<string> { }): Promise<string> {
const silent = !!payload.silent; // Globaler Mute-Flag uebersteuert das per-Call silent — verhindert
// Race-Conditions wenn der User zwischen Chunks den Mute-Knopf drueckt.
const silent = !!payload.silent || this._muted;
if (!silent && !PcmStreamPlayer) { if (!silent && !PcmStreamPlayer) {
console.warn('[Audio] PcmStreamPlayer Native Module nicht verfuegbar'); console.warn('[Audio] PcmStreamPlayer Native Module nicht verfuegbar');
return ''; return '';
@@ -663,6 +814,21 @@ class AudioService {
this.pcmBuffer = []; this.pcmBuffer = [];
this.pcmBytesCollected = 0; this.pcmBytesCollected = 0;
} }
// Resume-Sound stoppen falls noch aktiv (User hat nach Anruf eine
// neue Frage gestellt — die alte interruptierte Antwort ist obsolet).
if (this.resumeSound) {
try { this.resumeSound.stop(); this.resumeSound.release(); } catch {}
this.resumeSound = null;
}
// Pending Auto-Resume verwerfen wenn die neue Antwort eine andere
// messageId hat. Sonst spielt nach 30s-Wartezeit der Resume die
// ueberholte Antwort ab.
if (this.pausedMessageId && this.pausedMessageId !== messageId) {
console.log('[Audio] Neue TTS-Antwort (msgId=%s) — Auto-Resume fuer %s verworfen',
messageId, this.pausedMessageId);
this.pausedMessageId = '';
this.pausedPosition = 0;
}
this.pcmStreamActive = true; this.pcmStreamActive = true;
this.pcmMessageId = messageId; this.pcmMessageId = messageId;
this.pcmSampleRate = sampleRate; this.pcmSampleRate = sampleRate;
@@ -697,13 +863,16 @@ class AudioService {
if (isFinal) { if (isFinal) {
if (!silent) { if (!silent) {
// end() resolved jetzt erst wenn der native Writer-Thread fertig // end() signalisiert dem Writer "keine weiteren Chunks". Aber WIR
// ist (alle Samples ausgespielt) — danach AudioFocus verzoegert // releasen den AudioFocus NICHT hier — der writer braucht u.U. noch
// freigeben, damit Spotify/YouTube nicht im Mikro-Gap zwischen zwei // 30+ Sekunden bis der Buffer wirklich abgespielt ist. Den release
// ARIA-Antworten wieder hochdrehen. Wenn ein neuer Stream innerhalb // triggert das native Event "PcmPlaybackFinished" wenn AudioTrack
// FOCUS_RELEASE_DELAY_MS startet, wird das Release abgebrochen. // wirklich am Ende ist (siehe ensurePlaybackFinishedListener).
try { await PcmStreamPlayer!.end(); } catch {} try { await PcmStreamPlayer!.end(); } catch {}
this._releaseFocusDeferred(); // playbackFinished-Listener informieren (UI-Logik)
this.playbackFinishedListeners.forEach(cb => {
try { cb(); } catch (e) { console.warn('[Audio] playbackFinished cb err:', e); }
});
} }
this.pcmStreamActive = false; this.pcmStreamActive = false;
@@ -814,6 +983,9 @@ class AudioService {
} }
private _firePlaybackStarted(): void { private _firePlaybackStarted(): void {
// Tracking fuer Auto-Resume nach Anruf-Pause
this.playbackStartTime = Date.now();
this.currentPlaybackMsgId = this.pcmMessageId || '';
this.playbackStartedListeners.forEach(cb => { this.playbackStartedListeners.forEach(cb => {
try { cb(); } catch (e) { console.warn('[Audio] playbackStarted listener err:', e); } try { cb(); } catch (e) { console.warn('[Audio] playbackStarted listener err:', e); }
}); });
@@ -897,6 +1069,17 @@ class AudioService {
} }
} }
/** Mute: alle eingehenden TTS-Chunks/WAVs werden ignoriert bis wieder
* unmuted. Robuster als ein React-Ref weil hier kein Re-Render-Race ist
* — die Bridge kann einen Chunk im selben JS-Tick liefern in dem der
* User Mute geklickt hat. */
private _muted: boolean = false;
setMuted(muted: boolean): void {
this._muted = muted;
if (muted) this.stopPlayback();
}
isMuted(): boolean { return this._muted; }
/** Laufende Wiedergabe stoppen + Queue leeren */ /** Laufende Wiedergabe stoppen + Queue leeren */
stopPlayback(): void { stopPlayback(): void {
// Foreground-Service auch stoppen — sonst bleibt die Notification haengen // Foreground-Service auch stoppen — sonst bleibt die Notification haengen
+159 -55
View File
@@ -1,14 +1,19 @@
/** /**
* PhoneCall-Service — pausiert die TTS-Wiedergabe wenn das Telefon klingelt * PhoneCall-Service — pausiert ARIA bei Telefonaten:
* oder ein Anruf laeuft. Native-Bindung an PhoneCallModule.kt.
* *
* Bei "ringing" oder "offhook" wird audioService.haltAllPlayback() gerufen — * 1. Klassischer Mobilfunk-Anruf via TelephonyManager (PhoneCallModule.kt)
* ARIA verstummt sofort. Nach dem Auflegen passiert nichts automatisch * Status: idle / ringing / offhook
* (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 — * 2. VoIP-Anrufe (WhatsApp, Signal, Discord, Telegram, Teams, ...) via
* wenn nicht, failed start() leise und der Rest funktioniert wie bisher. * AudioFocus-Loss-Event (AudioFocusModule.kt). Diese Apps requestn
* AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE wenn ein Anruf reinkommt — wir
* bekommen ein "loss" Event und reagieren genauso wie auf RINGING.
*
* In beiden Faellen wird audioService.haltAllPlayback() + wakeWordService.
* pauseForCall() gerufen. Bei call-end (idle / focus-gain) → resumeFromCall.
*
* Permission READ_PHONE_STATE ist nur fuer Pfad 1 noetig — Pfad 2 braucht
* keine extra Berechtigung weil unser eigener AudioFocus-Listener feuert.
*/ */
import { import {
@@ -19,6 +24,7 @@ import {
ToastAndroid, ToastAndroid,
} from 'react-native'; } from 'react-native';
import audioService from './audio'; import audioService from './audio';
import wakeWordService from './wakeword';
interface PhoneCallNative { interface PhoneCallNative {
start(): Promise<boolean>; start(): Promise<boolean>;
@@ -32,75 +38,173 @@ type PhoneState = 'idle' | 'ringing' | 'offhook';
class PhoneCallService { class PhoneCallService {
private started: boolean = false; private started: boolean = false;
private subscription: { remove: () => void } | null = null; private subscription: { remove: () => void } | null = null;
private focusSubscription: { remove: () => void } | null = null;
private lastState: PhoneState = 'idle'; private lastState: PhoneState = 'idle';
/** Damit Resume nach VoIP-Loss nicht doppelt feuert wenn auch
* TelephonyManager-IDLE-Event kommt. */
private interruptedByFocus: boolean = false;
async start(): Promise<boolean> { async start(): Promise<boolean> {
if (this.started || !PhoneCall) return false; if (this.started || Platform.OS !== 'android') return false;
if (Platform.OS !== 'android') return false;
// Runtime-Permission holen (nur einmal noetig) // 1. AudioFocus-Listener IMMER registrieren — fangs VoIP-Calls (WhatsApp,
// Signal, Discord etc.) abdecken, brauchen keine Permission.
try { try {
const granted = await PermissionsAndroid.request( const focusEmitter = new NativeEventEmitter(NativeModules.AudioFocus as any);
PermissionsAndroid.PERMISSIONS.READ_PHONE_STATE, this.focusSubscription = focusEmitter.addListener(
{ 'AudioFocusChanged',
title: 'ARIA Cockpit — Anruf-Erkennung', (e: { type: 'loss' | 'loss_transient' | 'gain' }) => this._onFocusChanged(e.type),
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.log('[PhoneCall] AudioFocus-Listener aktiv (fuer VoIP-Calls)');
console.warn('[PhoneCall] READ_PHONE_STATE Permission abgelehnt'); } catch (err: any) {
return false; console.warn('[PhoneCall] AudioFocus-Subscription gescheitert', err?.message || err);
}
} catch (err) {
console.warn('[PhoneCall] Permission-Anfrage gescheitert', err);
} }
try { // 2. TelephonyManager-Listener — fuer klassische Mobilfunk-Anrufe
const ok = await PhoneCall.start(); if (PhoneCall) {
if (!ok) { try {
console.warn('[PhoneCall] Native start() lieferte false (Permission?)'); const granted = await PermissionsAndroid.request(
return false; 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) {
const ok = await PhoneCall.start();
if (ok) {
const emitter = new NativeEventEmitter(NativeModules.PhoneCall as any);
this.subscription = emitter.addListener(
'PhoneCallStateChanged',
(e: { state: PhoneState }) => this._onStateChanged(e.state),
);
console.log('[PhoneCall] TelephonyManager-Listener aktiv');
}
} else {
console.warn('[PhoneCall] READ_PHONE_STATE abgelehnt — VoIP-Calls werden trotzdem ueber AudioFocus erkannt');
}
} catch (err: any) {
console.warn('[PhoneCall] TelephonyManager-Setup gescheitert:', err?.message || err);
} }
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;
} }
this.started = true;
return true;
} }
async stop(): Promise<void> { async stop(): Promise<void> {
if (!this.started || !PhoneCall) return; if (!this.started) return;
try { try { this.subscription?.remove(); } catch {}
this.subscription?.remove(); try { this.focusSubscription?.remove(); } catch {}
this.subscription = null; this.subscription = null;
await PhoneCall.stop(); this.focusSubscription = null;
} catch {} if (PhoneCall) {
try { await PhoneCall.stop(); } catch {}
}
this.started = false; this.started = false;
this.lastState = 'idle'; this.lastState = 'idle';
this.interruptedByFocus = false;
} }
private _onStateChanged(state: PhoneState): void { private _onStateChanged(state: PhoneState): void {
if (state === this.lastState) return; if (state === this.lastState) return;
console.log('[PhoneCall] State: %s → %s', this.lastState, state); const prev = this.lastState;
console.log('[PhoneCall] State: %s → %s', prev, state);
this.lastState = state; this.lastState = state;
if (state === 'ringing' || state === 'offhook') { if (state === 'ringing' || state === 'offhook') {
audioService.haltAllPlayback(`Telefon-State: ${state}`); this._haltForCall(state === 'ringing' ? 'Anruf — ARIA pausiert' : 'Im Gespraech — ARIA pausiert');
ToastAndroid.show( } else if (state === 'idle' && prev !== 'idle') {
state === 'ringing' ? 'Anruf — ARIA pausiert' : 'Im Gespraech — ARIA pausiert', // Wenn schon durch AudioFocus-Loss pausiert wurde, NICHT doppelt resumen.
ToastAndroid.SHORT, // Der Focus-Gain-Event triggert das Resume.
); if (!this.interruptedByFocus) {
this._resumeAfterCall('Anruf beendet — ARIA wieder aktiv');
}
} }
// idle: nichts automatisch — User soll nichts unbeabsichtigt re-triggern }
/** AudioFocus-Loss = irgendeine andere App hat den Focus uebernommen.
* Das passiert bei VoIP-Anrufen (was wir wollen) ABER auch bei normalen
* Audio-Playern (anderer Player startet, Notification-Sound, sogar
* unsere eigenen Sound-Calls beim Play-Button). Daher checken wir den
* AudioMode — nur IN_CALL (2) oder IN_COMMUNICATION (3) zaehlt als Anruf. */
private async _onFocusChanged(type: 'loss' | 'loss_transient' | 'gain'): Promise<void> {
if (type === 'loss' || type === 'loss_transient') {
// Schon durch klassischen TelephonyManager pausiert? Dann nichts doppeln.
if (this.lastState === 'ringing' || this.lastState === 'offhook') return;
// Mode pruefen — nur echte Anrufe behandeln.
let mode = -1;
try { mode = await (NativeModules.AudioFocus as any)?.getMode?.(); } catch {}
if (mode !== 2 && mode !== 3) {
// NORMAL-Mode → kein Anruf (Stefan hat z.B. Play-Button gedrueckt
// oder Spotify hat sich neu reingedraengelt). Keine Toasts.
console.log('[PhoneCall] FOCUS_LOSS ignoriert (AudioMode=%d, kein Call)', mode);
return;
}
this.interruptedByFocus = true;
this._haltForCall('Anruf erkannt (VoIP) — ARIA pausiert');
// Pollen, weil GAIN nicht zuverlaessig kommt (wir releasen den Focus
// selbst beim halt → kein automatischer GAIN). AudioMode != IN_COMMUNICATION
// = Call vorbei.
this._startVoipResumePoll();
} else if (type === 'gain') {
if (this.interruptedByFocus) {
this.interruptedByFocus = false;
this._stopVoipResumePoll();
this._resumeAfterCall('Audio frei — ARIA wieder aktiv');
}
}
}
/** Polling-Fallback: alle 3s checken ob AudioMode wieder NORMAL ist. */
private voipPollTimer: ReturnType<typeof setInterval> | null = null;
private _startVoipResumePoll(): void {
if (this.voipPollTimer) return;
this.voipPollTimer = setInterval(async () => {
if (!this.interruptedByFocus) {
this._stopVoipResumePoll();
return;
}
try {
const mode = await (NativeModules.AudioFocus as any)?.getMode?.();
// 0 = MODE_NORMAL — Call ist vorbei
if (typeof mode === 'number' && mode === 0) {
this.interruptedByFocus = false;
this._stopVoipResumePoll();
this._resumeAfterCall('Anruf beendet — ARIA wieder aktiv');
}
} catch {}
}, 3000);
}
private _stopVoipResumePoll(): void {
if (this.voipPollTimer) {
clearInterval(this.voipPollTimer);
this.voipPollTimer = null;
}
}
private _haltForCall(toast: string): void {
// Position merken bevor wir den Stream killen — fuer Auto-Resume.
audioService.captureInterruption();
audioService.haltAllPlayback(toast);
wakeWordService.pauseForCall().catch(() => {});
ToastAndroid.show(toast, ToastAndroid.SHORT);
}
private _resumeAfterCall(toast: string): void {
wakeWordService.resumeFromCall().catch(() => {});
ToastAndroid.show(toast, ToastAndroid.SHORT);
// Auto-Resume: ab gemerkter Position weiterspielen wenn ARIA vor dem
// Anruf gerade redete. Wartet bis zu 30s auf den WAV-Cache (falls
// final-Marker erst nach dem Anruf-Ende kam).
audioService.resumeFromInterruption(30000).then(ok => {
if (ok) {
console.log('[PhoneCall] Auto-Resume von gemerkter Position gestartet');
}
}).catch(() => {});
} }
} }
+62 -21
View File
@@ -50,28 +50,69 @@ class UpdateService {
}); });
} }
/** Raeumt alte heruntergeladene APK-Dateien aus dem Cache auf. */ /** Sucht ueberall wo .apk-Dateien herumliegen koennten. */
private async cleanupOldApks(): Promise<void> { private async _apkSearchDirs(): Promise<string[]> {
try { const dirs = [RNFS.CachesDirectoryPath, RNFS.DocumentDirectoryPath];
const files = await RNFS.readDir(RNFS.CachesDirectoryPath); if ((RNFS as any).ExternalCachesDirectoryPath) {
const apks = files.filter(f => /\.apk$/i.test(f.name)); dirs.push((RNFS as any).ExternalCachesDirectoryPath);
let freed = 0;
for (const f of apks) {
try {
const size = parseInt(f.size as any, 10) || 0;
await RNFS.unlink(f.path);
freed += size;
console.log(`[Update] Alte APK geloescht: ${f.name} (${(size / 1024 / 1024).toFixed(1)}MB)`);
} catch (err: any) {
console.warn(`[Update] APK-Loeschen fehlgeschlagen: ${f.name} (${err?.message || err})`);
}
}
if (apks.length > 0) {
console.log(`[Update] Cleanup fertig: ${apks.length} APKs entfernt, ${(freed / 1024 / 1024).toFixed(1)}MB freigegeben`);
}
} catch (err: any) {
console.warn(`[Update] Cleanup-Fehler: ${err?.message || err}`);
} }
if (RNFS.ExternalDirectoryPath) {
dirs.push(RNFS.ExternalDirectoryPath);
}
return dirs;
}
/** Raeumt alte heruntergeladene APK-Dateien aus den App-Verzeichnissen auf.
* Public damit Settings den Button "Update-Cache leeren" benutzen kann. */
async cleanupOldApks(keepCurrentName?: string): Promise<{ removed: number; freedMB: number }> {
const dirs = await this._apkSearchDirs();
let removed = 0;
let freed = 0;
for (const dir of dirs) {
try {
if (!(await RNFS.exists(dir))) continue;
const files = await RNFS.readDir(dir);
const apks = files.filter(f => /\.apk$/i.test(f.name));
for (const f of apks) {
if (keepCurrentName && f.name === keepCurrentName) continue;
try {
const size = parseInt(f.size as any, 10) || 0;
await RNFS.unlink(f.path);
removed += 1;
freed += size;
console.log(`[Update] APK geloescht: ${f.path} (${(size / 1024 / 1024).toFixed(1)}MB)`);
} catch (err: any) {
console.warn(`[Update] APK-Loeschen fehlgeschlagen: ${f.path} (${err?.message || err})`);
}
}
} catch (err: any) {
console.warn(`[Update] Cleanup-Fehler in ${dir}: ${err?.message || err}`);
}
}
const freedMB = freed / 1024 / 1024;
if (removed > 0) {
console.log(`[Update] Cleanup fertig: ${removed} APK${removed === 1 ? '' : 's'} entfernt, ${freedMB.toFixed(1)}MB freigegeben`);
}
return { removed, freedMB };
}
/** Aktuelle Groesse aller APK-Dateien in den App-Verzeichnissen (in MB). */
async getApkCacheSize(): Promise<{ count: number; totalMB: number }> {
const dirs = await this._apkSearchDirs();
let count = 0;
let total = 0;
for (const dir of dirs) {
try {
if (!(await RNFS.exists(dir))) continue;
const files = await RNFS.readDir(dir);
for (const f of files) {
if (!f.isFile() || !/\.apk$/i.test(f.name)) continue;
count += 1;
total += parseInt(f.size as any, 10) || 0;
}
} catch {}
}
return { count, totalMB: total / 1024 / 1024 };
} }
/** Bei App-Start Update pruefen */ /** Bei App-Start Update pruefen */
+64
View File
@@ -22,6 +22,7 @@
import { NativeEventEmitter, NativeModules, ToastAndroid } from 'react-native'; import { NativeEventEmitter, NativeModules, ToastAndroid } from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage'; import AsyncStorage from '@react-native-async-storage/async-storage';
import { acquireBackgroundAudio } from './backgroundAudio';
type WakeWordCallback = () => void; type WakeWordCallback = () => void;
type StateCallback = (state: WakeWordState) => void; type StateCallback = (state: WakeWordState) => void;
@@ -77,6 +78,14 @@ class WakeWordService {
private bargeCallbacks: WakeWordCallback[] = []; private bargeCallbacks: WakeWordCallback[] = [];
/** True solange Wake-Word parallel zu TTS aktiv ist. */ /** True solange Wake-Word parallel zu TTS aktiv ist. */
private bargeListening: boolean = false; private bargeListening: boolean = false;
/** Anruf-Pause: state wird gemerkt damit nach Auflegen wiederhergestellt wird. */
private callPaused: boolean = false;
private preCallState: WakeWordState = 'off';
/** Cooldown nach App-Resume: kurze Phase in der Wake-Word-Detections
* ignoriert werden. Beim Wechsel von Background nach Vordergrund gibt's
* oft einen Audio-Pegel-Spike (AudioFocus-Switch, AudioTrack re-route),
* der openWakeWord faelschlich triggern kann. */
private cooldownUntilMs: number = 0;
private keyword: WakeKeyword = DEFAULT_KEYWORD; private keyword: WakeKeyword = DEFAULT_KEYWORD;
private nativeReady: boolean = false; private nativeReady: boolean = false;
@@ -157,6 +166,10 @@ class WakeWordService {
/** Ohr-Button gedrueckt — startet passives Lauschen oder direkt Konversation. */ /** Ohr-Button gedrueckt — startet passives Lauschen oder direkt Konversation. */
async start(): Promise<boolean> { async start(): Promise<boolean> {
if (this.state !== 'off') return true; if (this.state !== 'off') return true;
// Foreground-Service VOR dem Mic-Zugriff hochziehen damit Background-
// Lauschen funktioniert (Android braucht foregroundServiceType=microphone
// aktiv zum Zeitpunkt des AudioRecord.startRecording).
await acquireBackgroundAudio('wake');
if (this.nativeReady && OpenWakeWord) { if (this.nativeReady && OpenWakeWord) {
try { try {
await OpenWakeWord.start(); await OpenWakeWord.start();
@@ -200,8 +213,22 @@ class WakeWordService {
this.setState('off'); this.setState('off');
} }
/** Cooldown setzen — alle Wake-Word-Detections in den naechsten ms ignorieren.
* Wird beim App-Resume gerufen weil AppState-Wechsel Audio-Spikes erzeugen
* die openWakeWord faelschlich als Trigger interpretiert. */
setResumeCooldown(ms: number = 1500): void {
this.cooldownUntilMs = Date.now() + ms;
console.log('[WakeWord] Cooldown aktiv fuer %dms', ms);
}
/** 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> {
const now = Date.now();
if (now < this.cooldownUntilMs) {
const left = this.cooldownUntilMs - now;
console.log('[WakeWord] Trigger ignoriert (Cooldown noch %dms aktiv — wahrscheinlich App-Resume-Spike)', left);
return;
}
console.log('[WakeWord] Wake-Word "%s" erkannt! (state=%s, barge=%s)', console.log('[WakeWord] Wake-Word "%s" erkannt! (state=%s, barge=%s)',
this.keyword, this.state, this.bargeListening); this.keyword, this.state, this.bargeListening);
if (this.nativeReady && OpenWakeWord) { if (this.nativeReady && OpenWakeWord) {
@@ -255,6 +282,43 @@ class WakeWordService {
console.log('[WakeWord] Barge-Listening aus'); console.log('[WakeWord] Barge-Listening aus');
} }
/** Bei eingehendem Anruf: Wake-Word + Aufnahme stoppen, Pre-Call-State
* merken. Telefonie-App belegt das Mikro waehrend des Anrufs, plus ARIA
* soll nicht in laufende Telefonate reinhoeren. */
async pauseForCall(): Promise<void> {
if (this.callPaused) return;
this.preCallState = this.state;
if (this.state === 'off') {
this.callPaused = true; // merken dass wir pausiert wurden
return;
}
this.callPaused = true;
if (this.nativeReady && OpenWakeWord) {
try { await OpenWakeWord.stop(); } catch {}
}
this.bargeListening = false;
console.log('[WakeWord] Anruf — Wake-Word pausiert (war: %s)', this.preCallState);
}
/** Nach Auflegen: Pre-Call-State wiederherstellen. Aktive Konversation
* geht zu armed zurueck (User soll nicht in einen halben Dialog springen). */
async resumeFromCall(): Promise<void> {
if (!this.callPaused) return;
const restoreTo = this.preCallState;
this.callPaused = false;
this.preCallState = 'off';
console.log('[WakeWord] Anruf zu Ende — restore state=%s', restoreTo);
if (restoreTo === 'off') return;
// Aktive Konversation war wahrscheinlich durch haltAllPlayback eh abgebrochen,
// sicher zu armed degraden.
if (restoreTo === 'conversing') this.setState('armed');
if (this.nativeReady && OpenWakeWord) {
try { await OpenWakeWord.start(); } catch (err) {
console.warn('[WakeWord] Restore-Start fehlgeschlagen:', err);
}
}
}
/** 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'.
+29 -2
View File
@@ -278,6 +278,10 @@
<input type="checkbox" id="tts-debug-toggle" onchange="toggleTtsDebug()" style="margin-right:4px;vertical-align:middle;"> <input type="checkbox" id="tts-debug-toggle" onchange="toggleTtsDebug()" style="margin-right:4px;vertical-align:middle;">
TTS-Text einblenden TTS-Text einblenden
</label> </label>
<label style="color:#8888AA;font-size:11px;cursor:pointer;">
<input type="checkbox" id="gps-debug-toggle" onchange="toggleGpsDebug()" style="margin-right:4px;vertical-align:middle;">
GPS-Position einblenden
</label>
<button class="btn secondary" onclick="toggleChatFullscreen()" id="btn-chat-fs" style="padding:4px 10px;font-size:11px;">Vollbild</button> <button class="btn secondary" onclick="toggleChatFullscreen()" id="btn-chat-fs" style="padding:4px 10px;font-size:11px;">Vollbild</button>
</div> </div>
</div> </div>
@@ -1004,7 +1008,7 @@
if (sender === 'aria') return; if (sender === 'aria') return;
const chatType = 'sent'; const chatType = 'sent';
const label = sender === 'stt' ? '\uD83C\uDFA4 Spracheingabe' : `via RVS (${sender})`; const label = sender === 'stt' ? '\uD83C\uDFA4 Spracheingabe' : `via RVS (${sender})`;
addChat(chatType, p.text || '?', label); addChat(chatType, p.text || '?', label, { location: p.location });
return; return;
} }
if (msg.type === 'proxy_result') { if (msg.type === 'proxy_result') {
@@ -1395,6 +1399,16 @@
if (el) el.checked = showTtsDebug; if (el) el.checked = showTtsDebug;
} }
// Debug-Toggle: GPS-Position unter User-Nachrichten einblenden (nur Diagnostic).
// App zeigt's bewusst nicht — die Position geht nur an aria-core.
let showGpsDebug = localStorage.getItem('aria-show-gps-debug') === '1';
function toggleGpsDebug() {
showGpsDebug = !showGpsDebug;
localStorage.setItem('aria-show-gps-debug', showGpsDebug ? '1' : '0');
const el = document.getElementById('gps-debug-toggle');
if (el) el.checked = showGpsDebug;
}
// Minimal-JS-Port von clean_text_for_tts() (Bridge) — reine Anzeige // Minimal-JS-Port von clean_text_for_tts() (Bridge) — reine Anzeige
function previewTtsText(text) { function previewTtsText(text) {
if (!text) return ''; if (!text) return '';
@@ -1434,7 +1448,18 @@
ttsBlock = `<div style="margin-top:6px;padding:4px 8px;background:rgba(0,150,255,0.08);border-left:2px solid #0096FF;font-size:11px;color:#88AACC;"><span style="color:#0096FF;font-weight:bold;">TTS:</span> ${escapeHtml(ttsText)}</div>`; ttsBlock = `<div style="margin-top:6px;padding:4px 8px;background:rgba(0,150,255,0.08);border-left:2px solid #0096FF;font-size:11px;color:#88AACC;"><span style="color:#0096FF;font-weight:bold;">TTS:</span> ${escapeHtml(ttsText)}</div>`;
} }
} }
const html = `${linked}${ttsBlock}<div class="meta">${escapeHtml(meta)}${new Date().toLocaleTimeString('de-DE')}</div>`; // Optional: GPS-Position als Block unter User-Nachrichten (nur Diagnostic)
let gpsBlock = '';
if (showGpsDebug && options && options.location) {
const loc = options.location;
const lat = typeof loc.lat === 'number' ? loc.lat.toFixed(6) : '?';
const lon = typeof loc.lon === 'number' ? loc.lon.toFixed(6) : (typeof loc.lng === 'number' ? loc.lng.toFixed(6) : '?');
if (lat !== '?' && lon !== '?') {
const mapLink = `https://www.openstreetmap.org/?mlat=${lat}&mlon=${lon}#map=16/${lat}/${lon}`;
gpsBlock = `<div style="margin-top:6px;padding:4px 8px;background:rgba(52,199,89,0.08);border-left:2px solid #34C759;font-size:11px;color:#88BB99;"><span style="color:#34C759;font-weight:bold;">📍 GPS:</span> <a href="${mapLink}" target="_blank" rel="noopener" style="color:#88BB99;text-decoration:underline;">${lat}, ${lon}</a></div>`;
}
}
const html = `${linked}${ttsBlock}${gpsBlock}<div class="meta">${escapeHtml(meta)}${new Date().toLocaleTimeString('de-DE')}</div>`;
// Thinking-Indikator ausblenden bei neuer Nachricht // Thinking-Indikator ausblenden bei neuer Nachricht
updateThinkingIndicator({ activity: 'idle' }); updateThinkingIndicator({ activity: 'idle' });
@@ -2451,6 +2476,8 @@
// Toggle-Checkbox initial korrekt setzen // Toggle-Checkbox initial korrekt setzen
const ttsToggleEl = document.getElementById('tts-debug-toggle'); const ttsToggleEl = document.getElementById('tts-debug-toggle');
if (ttsToggleEl) ttsToggleEl.checked = showTtsDebug; if (ttsToggleEl) ttsToggleEl.checked = showTtsDebug;
const gpsToggleEl = document.getElementById('gps-debug-toggle');
if (gpsToggleEl) gpsToggleEl.checked = showGpsDebug;
// Disk-Space Banner aktualisieren (wird vom Server via disk_status gepusht) // Disk-Space Banner aktualisieren (wird vom Server via disk_status gepusht)
function updateDiskBanner(status) { function updateDiskBanner(status) {
+61 -1
View File
@@ -1,5 +1,56 @@
# ARIA Issues & Features # ARIA Issues & Features
## Audio-Verhalten in der App
So sollte die App in den verschiedenen Phasen mit fremden Audio-Apps
(Spotify, YouTube, Podcasts etc.) und dem eigenen Mikro umgehen.
Wenn was anders ist, ist's ein Bug.
| Phase | Andere App (Spotify) | ARIA-Mikro | Hintergrund-Service |
|------------------------------|----------------------|---------------------|---------------------|
| Idle / Ohr aus | spielt frei | aus | aus |
| Wake-Word lauscht (armed) | spielt frei | passiv (openWakeWord) | aktiv ('wake') |
| User-Aufnahme laeuft | pausiert (EXCLUSIVE) | Recording | aktiv ('rec') |
| Aufnahme zu Ende | resumed | aus | (rec released) |
| ARIA denkt/schreibt (~20s) | spielt frei | aus | (kein Slot) |
| TTS startet | pausiert (DUCK) | aus (oder barge) | aktiv ('tts') |
| TTS spielt (auch GPU-Pausen) | bleibt pausiert | barge wenn Wake-Word| aktiv |
| TTS zu Ende | nach 800ms resumed | (Conversation-Window)| (tts released) |
| Eingehender Anruf (auch VoIP)| — | Mikro pausiert | aus |
| Anruf vorbei | — | Mikro wieder armed | aktiv ('wake') |
| Anruf vorbei (Auto-Resume) | pausiert wieder | aus | aktiv ('tts') |
| Neue Frage waehrend Anruf | — | Mikro pausiert | (rec waehrend Anruf nicht) |
| Anruf vorbei nach neuer Frage | (siehe TTS-Phasen) | (siehe TTS-Phasen) | (tts gewinnt, alter Resume verworfen) |
Wichtige Mechanismen:
- **Underrun-Schutz** im PcmStreamPlayer fuettert Stille rein wenn die
Bridge in Render-Pausen liefert — Spotify bleibt durchgehend pausiert,
auch zwischen den Saetzen einer langen Antwort.
- **Conversation-Focus** (nur bei Wake-Word 'conversing') haelt den
AudioFocus dauerhaft. Bei reinem Tap-to-Talk oder Text-Chat greift's
nicht — Spotify darf in der Denk-Phase ruhig weiterspielen.
- **Foreground-Service** (mediaPlayback|microphone) haelt App-Prozess
am Leben damit TTS/Mikro/Wake-Word auch bei minimierter App weiter-
laufen. Notification zeigt aktuellen Status ("ARIA spricht/hoert
zu/bereit").
- **Anruf-Erkennung** ueber TelephonyManager (klassisch) + AudioFocus-
Loss-Listener mit Polling-Fallback (VoIP wie WhatsApp/Signal/Discord).
- **Auto-Resume nach Anruf**: beim Halt wird die Wiedergabe-Position
gemerkt (Date.now() - playbackStart - leadingSilence). Nach Auflegen
wartet die App bis zu 30s auf den WAV-Cache und spielt dann ab der
gemerkten Position weiter. Wenn das Telefonat länger als die Antwort
dauerte, ist der Cache schon fertig — instant Resume.
- **Neue Frage waehrend Anruf** (Text-Chat geht trotz Telefonat): die
neue Antwort ueberschreibt den pending Resume. _handlePcmChunkImpl
stoppt einen ggf. laufenden resumeSound und setzt pausedMessageId
zurueck wenn die neue Stream-messageId abweicht. Die letzte Antwort
gewinnt immer.
- **Audio-Ausgabe trotz aktivem Telefonat**: ARIA antwortet auch waehrend
eines Telefonats per Lautsprecher (Telefon-Audio geht ueber separaten
Stream zur Gegenseite). haltAllPlayback wird nur beim STATE-WECHSEL
ringing/offhook gerufen — wenn der Anruf schon laeuft (offhook→offhook),
triggert eine neue Frage keinen Halt mehr.
## Erledigt ## Erledigt
### Bugs / Fixes ### Bugs / Fixes
@@ -30,6 +81,10 @@
- [x] VAD adaptive Baseline robuster: minimum statt avg + Cap auf -50dB bis -28dB (Stille) / -40dB bis -18dB (Speech) — keine "tote" VAD-Konfiguration mehr bei lauter Umgebung oder Wake-Word-Echo - [x] VAD adaptive Baseline robuster: minimum statt avg + Cap auf -50dB bis -28dB (Stille) / -40dB bis -18dB (Speech) — keine "tote" VAD-Konfiguration mehr bei lauter Umgebung oder Wake-Word-Echo
- [x] Push-to-Talk raus, nur noch Tap-to-Talk (verhinderte Touch-Race-Probleme) - [x] Push-to-Talk raus, nur noch Tap-to-Talk (verhinderte Touch-Race-Probleme)
- [x] Manueller Mikro-Stop beendet Wake-Word-Konversation: Tap auf Mikro-Knopf waehrend conversing → audio raus + zurueck zu armed (= Wake-Word lauscht wieder, kein Auto-Mikro nach ARIAs Antwort). VAD-Auto-Stop bleibt bei Multi-Turn - [x] Manueller Mikro-Stop beendet Wake-Word-Konversation: Tap auf Mikro-Knopf waehrend conversing → audio raus + zurueck zu armed (= Wake-Word lauscht wieder, kein Auto-Mikro nach ARIAs Antwort). VAD-Auto-Stop bleibt bei Multi-Turn
- [x] **Wake-Word pausiert bei Anruf**: phoneCall ruft pauseForCall (openWakeWord.stop) bei RINGING/OFFHOOK, resumeFromCall bei IDLE. Pre-Call-State wird gemerkt — armed bleibt armed, conversing degraded zu armed (User soll nicht in halbem Dialog landen)
- [x] **App-Resume-Cooldown**: Wechsel von Background → Foreground triggert keinen falschen Wake-Word-Trigger mehr. AppState-Listener setzt 1.5s Cooldown in dem onWakeDetected-Events ignoriert werden (Audio-Pegel-Spike beim AudioFocus-Switch sonst als Wake-Word interpretiert)
- [x] Background-Mikro robust: acquireBackgroundAudio('rec'/'wake') wird jetzt VOR AudioRecord.startRecording gerufen — Foreground-Service mit foregroundServiceType=microphone muss aktiv sein bevor das Mikro greift, sonst blockiert Android ab 11+ den Background-Zugriff
- [x] **Stille-Pegel manuell setzbar** (Settings → Spracheingabe): Override-Wert in dB von -55 bis -15, default "automatisch". Info-Button mit Modal erklaert die Skala (niedriger = sensibler, hoeher = robuster gegen Hintergrundlaerm). Bei manuell gesetztem Wert wird die adaptive Baseline ignoriert
### App Features ### App Features
@@ -104,6 +159,12 @@
- [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 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] APK ABI-Split auf arm64-v8a — von ~136 MB auf ~35 MB, Auto-Update-Downloads aufs Phone deutlich kleiner - [x] APK ABI-Split auf arm64-v8a — von ~136 MB auf ~35 MB, Auto-Update-Downloads aufs Phone deutlich kleiner
- [x] PhoneStateListener: TTS pausiert bei eingehendem Anruf (READ_PHONE_STATE Permission) - [x] PhoneStateListener: TTS pausiert bei eingehendem Anruf (READ_PHONE_STATE Permission)
- [x] **VoIP-Anrufe** (WhatsApp/Signal/Discord/Teams) erkannt via AudioFocus-Loss-Listener + getMode-Polling-Fallback (alle 3s)
- [x] **Auto-Resume nach Anruf**: ARIAs unterbrochene Antwort spielt nach dem Auflegen ab der gemerkten Position weiter (Date.now()-Tracking + WAV-Cache, 30s-Wartezeit auf final-Marker bei kurzem Telefonat)
- [x] **Neue Frage waehrend Telefonat** ueberschreibt pending Auto-Resume — letzte Antwort gewinnt, alter resumeSound wird gestoppt
- [x] **Audio-Ausgabe waehrend aktivem Telefonat** funktioniert (haltAllPlayback nur bei state-Wechsel idle→ringing/offhook, nicht bei offhook→offhook)
- [x] **PcmPlaybackFinished-Event** im Native: AudioFocus wird erst released wenn AudioTrack wirklich durch ist (vorher: end()-Cap nach 0.5s → Spotify spielte 32s parallel zu ARIA)
- [x] **APK-Cache-Cleanup robuster**: durchsucht jetzt CachesDirectoryPath + DocumentDirectoryPath + ExternalCachesDirectoryPath + ExternalDirectoryPath statt nur Caches. Plus manueller Button "Update-Cache leeren" in Settings → Speicher mit Live-Anzeige der aktuellen Groesse
- [x] Diagnostic-Chat: bubblige Formatierung, mehrzeiliges Eingabefeld (textarea, Enter sendet, Shift+Enter neue Zeile) - [x] Diagnostic-Chat: bubblige Formatierung, mehrzeiliges Eingabefeld (textarea, Enter sendet, Shift+Enter neue Zeile)
- [x] Adaptive VAD-Schwelle: Baseline aus den ersten 500ms Mic-Pegel, Stille = baseline+6dB / Sprache = baseline+12dB - [x] Adaptive VAD-Schwelle: Baseline aus den ersten 500ms Mic-Pegel, Stille = baseline+6dB / Sprache = baseline+12dB
- [x] Max-Aufnahmedauer konfigurierbar in Settings (1-30 min, Default 5 min) — laengere Diktate moeglich - [x] Max-Aufnahmedauer konfigurierbar in Settings (1-30 min, Default 5 min) — laengere Diktate moeglich
@@ -128,7 +189,6 @@
### App Features ### App Features
- [ ] Chat-History zuverlaessiger laden (AsyncStorage Race Condition) - [ ] Chat-History zuverlaessiger laden (AsyncStorage Race Condition)
- [ ] Custom-Wake-Word-Upload via Diagnostic (eigene .onnx-Files ohne App-Rebuild) - [ ] 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)