Compare commits

..

94 Commits

Author SHA1 Message Date
duffyduck a8b586ec92 release: bump version to 0.1.0.1 2026-05-10 16:30:14 +02:00
duffyduck 632e1e4fa1 fix(audio): pauseForCall setzt isPlaying zurueck — Playback nach Anruf nicht mehr tot
pauseForCall stoppte zwar currentSound + setzte ihn auf null, hat aber
isPlaying=true gelassen. Folge: nach dem Anruf war jeder weitere Play-
Button-Klick wirkungslos, weil playAudio bei isPlaying=true den
_playNext-Pfad ueberspringt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 16:28:42 +02:00
duffyduck 7e12816ebd release: bump version to 0.1.0.0 2026-05-10 16:22:08 +02:00
duffyduck 8f64f8fb30 fix(phone): 800ms-Delay vor Auto-Resume — Spotify kommt zum Atmen
Wenn ARIA's Resume-Pfad direkt nach Anruf-Ende den AudioFocus requestet,
kollidiert das mit Spotify's eigenem Auto-Resume. System haengt noch im
IN_CALL-Mode-Uebergang, Spotify sieht "Loss → Loss" und bleibt pausiert
statt kurz zu resumen.

Mit 800ms-Delay: Spotify schafft den Resume-Schritt, dann pausiert ARIA
wieder ordnungsgemaess. Wenn ARIA nichts pending hatte, bleibt Spotify
einfach weiter an.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 16:20:48 +02:00
duffyduck b3ff3991c4 feat(bridge): Bilder >2MB serverseitig auf 1568px verkleinern
Claude-Vision-API hat ~5MB Base64-Limit. Stefan's 4MB Foto via
Buroklammer (DocumentPicker) sprengte das, Claude lieferte leere
Antwort zurueck. Galerie-Pfad ging weil react-native-image-picker
schon clientseitig komprimiert.

Bridge resized jetzt JPEG/PNG/WebP/GIF >2MB auf max 1568px lange
Seite (Anthropic-Empfehlung), JPEG q=85. SVG, PDF, ZIP, Office-Docs
bleiben unangetastet — die laufen ueber Tools, nicht Vision.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 16:15:05 +02:00
duffyduck a4ea387c98 fix(bridge): "chat final ohne Text" wird sichtbar an App gemeldet
Wenn Claude-Vision das Bild silent ablehnt (z.B. zu gross), kommt
phase=end ohne Crash, aber chat:final ohne text. Bridge ignorierte das
nur mit Warning — App wartete ewig auf Antwort. Jetzt kommt eine
Hinweis-Bubble damit der User weiss dass was schief lief.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 16:11:14 +02:00
duffyduck 68fbf74a23 fix(bridge): chat:error liest auch errorMessage — kein "Unbekannt" mehr
OpenClaw legt bei state=error den Text in errorMessage statt error.
Bridge ignorierte das und meldete generisches "[Fehler] Unbekannt" an
App + Diagnostic — der echte Text ("Process exited with code 1" etc)
ging nur in die Container-Logs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 16:07:20 +02:00
duffyduck b857f778e9 release: bump version to 0.0.9.9 2026-05-10 15:56:53 +02:00
duffyduck 31aa82b68c debug+fix(audio): Mute-Logs + resumeSound auch in stopPlayback stoppen
stopPlayback stoppte bisher nur currentSound, nicht resumeSound — wenn
nach einem Anruf der Auto-Resume laeuft und der User Mute drueckt, bleibt
der Resume-Sound weiter spielen.

Plus Logs in setMuted/stopPlayback um zu sehen warum Stefans Mute beim
Replay nicht greift.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 15:55:51 +02:00
duffyduck de8eeb69e2 release: bump version to 0.0.9.8 2026-05-10 15:46:36 +02:00
duffyduck f5970ce700 fix(audio): _firePlaybackStarted ueberschrieb playFromPath-Tracking mit leerem pcmMessageId
Logs zeigten: playFromPath setzt currentPlaybackMsgId='db710ff3-...', 9s
spaeter beim Anruf war captureInterruption msgId=(leer). Ursache:
_firePlaybackStarted setzt currentPlaybackMsgId blind aus pcmMessageId —
das ist beim Play-Button leer.

Jetzt nur noch setzen wenn ein PCM-Stream laeuft. Play-Button und Resume-
Sound setzen ihr Tracking selber im Caller.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 15:44:28 +02:00
duffyduck ef1a4436ca fix(bridge): WebSocket max_size auf 50MB — grosse Bilder/Uploads gehen wieder
Python websockets Default-Limit ist nur 1 MiB. Stefan's 4MB JPEG (5.8MB als
Base64) sprengte das, Bridge-Verbindung wurde silent gedroppt. App sah
nichts, Diagnostic kriegte kein file_saved, ARIA reagierte nicht — Kamera-
Bilder waren klein genug (<1MB) und gingen darum durch.

f5tts/whisper-bridges hatten max_size=50MB schon drin, nur aria_bridge
hatte's an beiden websockets.connect-Stellen vergessen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 15:42:48 +02:00
duffyduck 981779cd9e release: bump version to 0.0.9.7 2026-05-10 15:37:45 +02:00
duffyduck 3dcd2ae0b4 fix(audio): msgId-Regex liberaler — auch nicht-UUID-Dateinamen werden erkannt
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 15:36:43 +02:00
duffyduck 2750b867a3 release: bump version to 0.0.9.6 2026-05-10 15:29:03 +02:00
duffyduck f6424add6c debug(chat): Logs fuer Anhang-Send-Pipeline
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 15:27:24 +02:00
duffyduck 2dfd21d1d0 fix(audio): Play-Button setzt jetzt auch Wiedergabe-Tracking — Anruf-Test via Playback funktioniert
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 15:22:21 +02:00
duffyduck 9d9ddc730b debug+fix(audio): mehr Anruf-Logs + Tracking auch beim Resume-Sound
Im Test 2 (zweiter Anruf in derselben Antwort) kam weder captureInterruption
noch resumeFromInterruption als Log — beide returnen frueh ohne Hinweis warum.
Jetzt loggen sie auch den Skip-Pfad damit man sieht ob's der idempotent-Guard
oder fehlende playbackStartTime ist.

Plus: _playFromPathAtPosition aktualisiert jetzt currentPlaybackMsgId und
playbackStartTime — sonst stehen die auf den Werten der ersten TTS-Wiedergabe
und ein zweiter Anruf-captureInterruption wuerde mit veraltetem Stand laufen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 15:20:43 +02:00
duffyduck 77ccee8331 release: bump version to 0.0.9.5 2026-05-10 15:13:38 +02:00
duffyduck 175dcdf225 fix(audio): Auto-Resume nach Anruf — pcmBuffer bleibt erhalten
Logs zeigten "WAV nicht binnen 30000ms verfuegbar" — pcmBuffer wurde von
haltAllPlayback geleert, isFinal schrieb daher eine leere WAV (oder gar
keine, weil pcmMessageId leer war).

Neue Methode pauseForCall (statt haltAllPlayback im Anruf-Pfad):
- AudioTrack stoppt + AudioFocus release (Spotify resumed)
- pcmBuffer + pcmMessageId BLEIBEN — Bridge-Chunks werden weiter gesammelt
- _pausedForCall macht weitere Chunks "silent" (kein writeChunk, nur Cache)
- isFinal schreibt WAV trotz Anruf → resumeFromInterruption findet sie

Plus captureInterruption idempotent gemacht: ringing→offhook ueberschreibt
die Position vom ersten Halt nicht mehr (Date.now-Tracking laeuft stumpf
weiter obwohl Audio gestoppt ist — der erste Halt ist die echte Position).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 15:11:46 +02:00
duffyduck 1549e9cd4f docs(issue): vier neue Fixes der Debug-Session festgehalten
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 15:01:37 +02:00
duffyduck 910e74b497 fix(bridge): GPS-Position auch im STT-chat-Payload an Diagnostic mitgeben
Die App sendet location einmal im audio-Payload. Die Bridge kannte sie
zwar (ging in aria-core's Kontext rein), reichte sie aber nicht im STT-
broadcast an die Diagnostic durch. Diagnostic zeigte darum bei Sprach-
eingaben nie den GPS-Block.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 14:59:09 +02:00
duffyduck 160c5c34b6 release: bump version to 0.0.9.4 2026-05-10 14:54:45 +02:00
duffyduck a6638c0108 debug(gps): Logs fuer Standort-Abfrage und Permission-Fehler
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 14:53:32 +02:00
duffyduck 43c21d3ddc release: bump version to 0.0.9.3 2026-05-10 14:48:35 +02:00
duffyduck b73c6c346e fix(gps): Standort-Permission anfordern — sonst sendet App nie eine Position
Im Manifest fehlte ACCESS_COARSE/FINE_LOCATION komplett, und der
Settings-Toggle requestete keine Runtime-Permission. Geolocation
.getCurrentPosition() schlug darum lautlos fehl, App sendete nie ein
location-Feld → Diagnostic konnte nichts anzeigen, auch wenn der
Diagnostic-eigene "GPS einblenden"-Toggle aktiv war.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 14:47:35 +02:00
duffyduck b91ddc5bdf fix(audio): AudioTrack-Start-Threshold auf 100ms — kurze TTS startet jetzt
ENDLICH die Wurzel: AudioTrack hat seit API 31 setStartThresholdInFrames(),
default ist bufferSize/2. Bei 4s-Buffer = 2s Threshold — Track wartet bis
2s im Buffer sind, sonst startet play() nie wirklich (pos bleibt 0).

Bei 3 Worten (~1.4s) kommt's nie ueber die Schwelle. Threshold runter
auf 100ms (2400 Frames @ 24kHz) — Track laeuft sofort mit erstem Chunk an.

Erklaert auch warum genau ab 9 Worten (~3s+) der Pre-Roll-Pfad lief: dann
wurde die 2s-Schwelle ueberschritten.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 14:45:05 +02:00
duffyduck 7d08c06720 release: bump version to 0.0.9.2 2026-05-10 14:40:35 +02:00
duffyduck f066a2a555 fix(audio): Mute-Button stoppt jetzt auch laufenden PCM-Stream
pcmStreamActive wurde beim isFinal-Chunk schon auf false gesetzt, der
AudioTrack spielte aber noch aus seinem Buffer (kann sekundenlang sein).
stopPlayback() uebersprang darum PcmStreamPlayer.stop() — ARIA redete
weiter obwohl Spotify schon resumed war.

Fix: stop() immer rufen, der Flag-Check faellt weg (ist eh idempotent).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 14:39:27 +02:00
duffyduck b55b0e7c42 fix(audio): play() beim 1. Chunk — kurze Texte stallen nicht mehr
Logs zeigten: Pre-Roll-Pfad (play() WAEHREND chunks reinkommen) lief
immer sauber, Kurz-Text-Pfad (play() NACHDEM Buffer komplett gefuellt
ist) stallte immer — egal mit wie viel Daten oder welchem USAGE-Tag.

Fix: play() beim allerersten data-chunk callen, kein Pre-Roll-Threshold
mehr. AudioTrack ist sofort im PLAYING-State, weitere chunks/trailing
fliessen parallel ab. Padding-Block nach mainLoop entfaellt komplett.

USAGE_MEDIA wieder auf USAGE_ASSISTANT zurueck — war nicht die Ursache.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 14:38:02 +02:00
duffyduck 70f806ef80 release: bump version to 0.0.9.1 2026-05-10 14:32:35 +02:00
duffyduck 0773d9496d fix(audio): AudioTrack auf USAGE_MEDIA — USAGE_ASSISTANT stallt auf OnePlus A12
Letzter Test zeigte: 163456B im Buffer mit play()-nach-Padding stallt
(pos=0), aber 170048B im Pre-Roll-Pfad startet einwandfrei. Differenz
nur 4% Daten — kein Buffer-Threshold-Problem, sondern AudioTrack-Quirk
mit USAGE_ASSISTANT bei "voller Buffer, dann play()".

USAGE_MEDIA ist robuster, AudioFocus laeuft eh separat ueber das
AudioFocusModule.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 14:31:23 +02:00
duffyduck 1a4857ed62 release: bump version to 0.0.9.0 2026-05-10 14:26:41 +02:00
duffyduck 962d814318 fix(audio): kurze TTS — Padding auf 3s erhoeht (OnePlus A12 Hard-Threshold)
Test mit 96000B (2s) Padding zeigte: AudioTrack stallt immer noch mit
pos=0/48000. Ab 8 Worten (~2.5s) geht's — der Hard-Threshold liegt also
zwischen 2s und 3s. Padding auf 3s, Buffer auf 4s.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 14:25:43 +02:00
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
duffyduck 2eb0b4df90 release: bump version to 0.0.8.1 2026-05-06 23:49:36 +02:00
duffyduck 0c18090351 chore: Highlight-Trigger raus + letzte Piper-Reste aufgeraeumt
Highlight-Trigger:
- diagnostic/index.html: Settings-Sektion + Trigger-Liste-Handler raus
- diagnostic/server.js: get_triggers / save_triggers Action-Handler +
  TRIGGERS_FILE Konstante + handleGetTriggers/handleSaveTriggers Funktionen weg
- README.md: highlight_triggers.json aus dem Datenverzeichnis-Diagram entfernt

Die Auswertung war seit Piper-Removal eh tot — die Datei wurde nur noch
geschrieben aber nirgends gelesen.

Piper-Reste:
- bridge/aria_bridge.py: Modul-Docstring auf F5-TTS aktualisiert,
  Ramona/Thorsten-Erwaehnungen raus, Inline-Kommentar zu "Komponenten
  TTS" gefixt
- aria-data/config/AGENT.md: Stimmen-Tabelle (Ramona/Thorsten) durch
  Hinweis auf F5-TTS Voice-Cloning ersetzt
- aria-data/config/BOOTSTRAP.md: gleiche Tabelle weg, Bridge-Beschreibung
  auf "orchestriert STT/TTS via Gamebox-Bridges" geaendert

Erledigt-Eintraege in issue.md + README markiert (historisch erhalten).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 23:47:55 +02:00
duffyduck d6b54d3247 feat(audio): Background-Service auch fuer Wake-Word + Aufnahme + Doku-Split
Erweitert den Foreground-Service um den microphone-Type damit nicht nur
TTS, sondern auch Wake-Word-Lauschen und aktive Aufnahmen weiterlaufen
wenn die App im Hintergrund ist.

Slot-System (backgroundAudio.ts):
- 'tts'  : ARIA spricht
- 'rec'  : Aufnahme laeuft
- 'wake' : Wake-Word lauscht passiv (Ohr aktiv)
Mehrere Slots koennen unabhaengig acquired/released werden, der Service
laeuft solange mindestens einer aktiv ist. Notification-Text passt sich
dynamisch an den hoechstprioren Slot an (tts > rec > wake).

Wiring (ChatScreen):
- onPlaybackStarted/Finished → 'tts' Slot
- audioService.onStateChange (recording) → 'rec' Slot
- wakeWordService.onStateChange (off→armed/conversing) → 'wake' Slot

AndroidManifest:
- foregroundServiceType="mediaPlayback|microphone" (Pflicht ab Android 14
  fuer Background-Mic-Zugriff)
- FOREGROUND_SERVICE_MICROPHONE Permission

Doku:
- issue.md Erledigt-Sektion in "Bugs / Fixes", "App Features" und
  "Infrastruktur" gesplittet
- README: Background-Service-Beschreibung erweitert

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 23:43:24 +02:00
duffyduck ead28cf09a feat(audio): Foreground-Service haelt TTS am Leben bei minimierter App
ARIAs Antwort wird jetzt auch dann fertig vorgelesen wenn der User die
App im Hintergrund schickt. Vorher hat Android den Prozess kurz nach
dem Minimieren eingefroren — TTS verstummte mitten im Satz.

Native:
- AriaPlaybackService.kt: Service mit foregroundServiceType=mediaPlayback,
  zeigt persistente Notification "ARIA spricht — antippen oeffnet die App"
  (channel low-priority, ongoing, tap → MainActivity)
- BackgroundAudioModule.kt: RN-Bridge mit start()/stop()
- AndroidManifest: FOREGROUND_SERVICE + FOREGROUND_SERVICE_MEDIA_PLAYBACK
  + POST_NOTIFICATIONS Permissions, Service deklariert

JS:
- backgroundAudio.ts: idempotenter Wrapper (active-Flag verhindert
  doppelte start/stop calls)
- ChatScreen onPlaybackStarted → startBackgroundAudio
- ChatScreen onPlaybackFinished → stopBackgroundAudio
- audio.ts stopPlayback ruft auch stopBackgroundAudio damit die
  Notification bei Cancel/Barge-In/Anruf nicht haengen bleibt

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 23:37:46 +02:00
duffyduck f682aad4ff fix(wake-word): manueller Mikro-Stop beendet Konversation, zurueck zu armed
Bug: Nach Wake-Word "Computer" → conversing → User drueckt manuell den
Mikro-Button um zu stoppen → Audio wird gesendet, aber state bleibt
'conversing'. Nach ARIAs Antwort oeffnet sich automatisch wieder das
Mikro fuer Multi-Turn — obwohl der User explizit den Knopf gedrueckt
hat um zu signalisieren "ich bin fertig".

Fix: Im handleVoiceRecording (= manueller Stop ueber VoiceButton) wird
nach dem Send wakeWordService.endConversation() gerufen wenn aktuell
in conversing-State. Das setzt zurueck auf 'armed' und startet
openWakeWord wieder fuer passives Lauschen. ARIAs Antwort kommt durch,
TTS spielt, aber resume() ist dann no-op weil state schon 'armed'.

Bei VAD-Auto-Stop (silence-callback im Wake-Word-Pfad) bleibt das
Multi-Turn-Verhalten unveraendert — das ist die "natuerliche" Pause
und passt zum Konversations-Modus.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 23:33:31 +02:00
duffyduck e0c1a4bcd5 feat: GPS-Position bei jeder Nachricht an aria-core (still, nur bei Bedarf)
App: GPS-Toggle in Settings → Allgemein → Standort wird jetzt korrekt
in AsyncStorage persistiert (key: aria_gps_enabled). ChatScreen pollt
den Wert mit den anderen Settings im 2s-Intervall.

Bridge: chat/audio-Handler nutzen jetzt einen gemeinsamen _build_core_text
Helper, der je nach Kontext einen Hint vorschaltet:
- Barge-In ("[Hinweis: Stefan hat dich unterbrochen ...]")
- GPS    ("[Stefans aktuelle GPS-Position: lat, lon. Nutze die nur wenn
          die Frage sich auf seinen Standort bezieht. Erwaehne sie nicht
          von dir aus, ausser er fragt explizit danach.]")

ARIA weiss bei "wo bin ich?" / "Wetter hier?" automatisch was zu tun ist
— bei normalen Fragen kommt die Position aber nicht ungefragt vor. Der
User sieht im Chat-Verlauf nichts von der GPS-Info, nur ARIAs Antwort
kann darauf eingehen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 23:29:34 +02:00
duffyduck a648dad96d release: bump version to 0.0.8.0 2026-05-06 23:06:22 +02:00
duffyduck da5579038e fix(vad): adaptive Baseline robuster — minimum + Cap-Bereich
Bug: Wenn beim Aufnahmestart sofort gesprochen wurde (z.B. Wake-Word-
Echo noch im Mikro) ODER der Hintergrund vorruebergehend laut war,
verschob die avg-basierte Baseline die Stille-Schwelle so weit nach
oben, dass normale Hintergrundgeraeusche dauerhaft als "Sprache"
zaehlten — VAD feuerte nie, Aufnahme lief unendlich.

Fix:
- Baseline = MINIMUM der 5 Samples statt Mittelwert (ruhigster Moment)
- Cap auf sinnvollen Bereich:
  - Silence-Schwelle: -50dB bis -28dB (vorher unbegrenzt)
  - Speech-Schwelle:  -40dB bis -18dB
- Erweitertes Log: zeigt sowohl raw als auch geclamp-te Werte

Damit gibt's keine "tote" VAD-Konfiguration mehr — selbst wenn die
Baseline-Messung Schrott ist, bleiben die Schwellen praktikabel.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 23:05:08 +02:00
duffyduck 4ba48940b9 release: bump version to 0.0.7.9 2026-05-06 23:00:32 +02:00
duffyduck 568ef9ed10 fix(audio): STT-Cleanup-Timeout skaliert mit Aufnahmedauer
Der pauschale 30s-Timeout vom Vorgaenger-Commit haette bei einer
5-Minuten-Aufnahme schon getriggert waehrend Whisper noch transkribiert
(Whisper braucht auf der Gamebox-GPU grob real-time/5, plus Bridge-
Roundtrip).

Neue Formel: 60s Buffer + 1x Aufnahmedauer.
- 5s Aufnahme → 65s Wait
- 5min Aufnahme → 6 min Wait
- 30min Aufnahme → 31 min Wait

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

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

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

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

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

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

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

issue.md + README aktualisiert.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 20:04:19 +02:00
33 changed files with 2756 additions and 474 deletions
+48 -8
View File
@@ -378,10 +378,13 @@ API-Endpoint fuer andere Services: `GET http://localhost:3001/api/session`
### Features
- Text-Chat mit ARIA
- **Sprachaufnahme**: Push-to-Talk (halten) oder Tap-to-Talk (tippen, Auto-Stop bei Stille)
- **Sprachaufnahme**: Tap-to-Talk (tippen startet, tippen stoppt, Auto-Stop bei Stille via VAD)
- **Gespraechsmodus** (Ohr-Button): Nach jeder ARIA-Antwort startet automatisch die Aufnahme — wie ein natuerliches Gespraech hin und her
- **Wake-Word** (on-device, openWakeWord ONNX): "Hey Jarvis", "Alexa", "Hey Mycroft", "Hey Rhasspy" — Mikrofon hoert passiv mit, Konversation startet beim Schluesselwort. Komplett on-device via ONNX Runtime, kein API-Key, kein Cloud-Roundtrip, Audio verlaesst das Geraet nicht.
- **VAD (Voice Activity Detection)**: Konfigurierbare Stille-Toleranz (1.08.0s, Default 2.8s) bevor Auto-Stop greift. Max-Aufnahme 120s.
- **VAD (Voice Activity Detection)**: Adaptive Schwelle (Baseline aus ersten 500ms Mic-Pegel + 6dB Offset). Konfigurierbare Stille-Toleranz (1.08.0s, Default 2.8s) bevor Auto-Stop greift. Max-Aufnahme einstellbar (130 min, Default 5 min)
- **Barge-In**: Wenn du waehrend ARIAs Antwort eine neue Sprach-/Text-Nachricht reinschickst, wird sie unterbrochen + bekommt den Hint "das ist eine Korrektur"
- **Wake-Word waehrend TTS**: Du kannst "Computer" sagen waehrend ARIA noch redet — AcousticEchoCanceler verhindert dass ARIAs eigene Stimme das Wake-Word triggert
- **Anruf-Pause + 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
- **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
@@ -415,7 +418,7 @@ Community-Modelle stammen aus [fwartner/home-assistant-wakewords-collection](htt
**Bedienung:**
- App → **Einstellungen****Wake-Word** → gewuenschtes Keyword waehlen → **Speichern + Aktivieren**
- **Ohr-Button (👂)** in der Statusleiste tippen → Wake-Word ist scharf, App hoert passiv mit
- Wake-Word sagen → Symbol wechselt auf 🎙️, Konversation laeuft
- Wake-Word sagen → Symbol wechselt auf 🎙️, **Bereit-Sound** (Ding-Dong, optional in Settings) + Toast "🎤 sprich jetzt" sobald das Mikro wirklich offen ist
- Nach jeder ARIA-Antwort oeffnet sich das Mikro nochmal — Stille → zurueck zu 👂
- Erneut tippen → Ohr aus (🔇)
@@ -507,10 +510,36 @@ Der Update-Flow:
App (Mikrofon) → AAC/MP4 Aufnahme → Base64 → RVS → Bridge
Bridge: FFmpeg (16kHz PCM) → Whisper STT → Text → aria-core
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
```
### 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)
```
@@ -565,8 +594,7 @@ aria-data/
│ └── diag-state/ ← Diagnostic persistenter State
│ (im Shared Volume /shared/config/):
│ ├── voice_config.json ← TTS-Einstellungen (Stimme, Speed, Engine)
│ ├── highlight_triggers.json ← Highlight-Trigger Woerter
│ ├── voice_config.json ← TTS-Einstellungen (Stimme, Speed, F5-TTS-Tuning)
│ └── chat_backup.jsonl ← Nachrichten-Backup (on-the-fly)
└── ssh/ ← SSH Keys fuer VM-Zugriff
@@ -813,7 +841,7 @@ docker exec aria-core ssh aria-wohnung hostname
- [x] SSH-Zugriff auf VM (aria-wohnung)
- [x] Diagnostic Web-UI + Einstellungen
- [x] Session-Verwaltung + Chat-History
- [x] Stimmen-Einstellungen (Ramona/Thorsten, Speed, Highlight-Trigger) — durch XTTS v2 Voice Cloning ersetzt
- [x] Stimmen-Einstellungen (frueher Piper Ramona/Thorsten, Highlight-Trigger) — durch XTTS, dann F5-TTS Voice Cloning ersetzt
- [x] Piper komplett entfernt — nur noch XTTS v2 als TTS (Gaming-PC)
- [x] Streaming TTS: PCM-Chunks direkt in AudioTrack, nahtlose Wiedergabe
- [x] TTS satzweise fuer lange Texte
@@ -840,7 +868,19 @@ docker exec aria-core ssh aria-wohnung hostname
- [x] Whisper STT auf die Gamebox ausgelagert (CUDA float16, fast Echtzeit)
- [x] **F5-TTS ersetzt XTTS** — bessere Voice-Cloning-Qualitaet, Whisper-auto-transkribierter Referenz-Text
- [x] Audio-Pause statt Ducking (TRANSIENT statt MAY_DUCK) + release-Timing fix
- [x] VAD-Stille-Toleranz und Max-Aufnahme einstellbar (1-8s, 120s)
- [x] VAD-Stille-Toleranz einstellbar (1-8s) + adaptive Mikro-Baseline + Max-Aufnahme einstellbar (1-30 min)
- [x] Barge-In: User kann ARIA waehrend Antwort unterbrechen, aria-core bekommt Kontext-Hint
- [x] Anruf-Pause + 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] APK ABI-Split arm64-v8a: 35 MB statt 136 MB
- [x] Sprachnachrichten-Bubble: audioRequestId statt Substring-Match — keine vertauschten Bubbles mehr bei parallelen Aufnahmen
- [x] Bereit-Sound (Airplane Ding-Dong) wenn Mikro nach Wake-Word offen ist — akustische Bestaetigung, in Settings abschaltbar
- [x] Wake-Word parallel zu TTS mit AcousticEchoCanceler — "Computer" sagen waehrend ARIA spricht stoppt sie und oeffnet Mikro
- [x] GPS-Position mit Nachrichten mitsenden (Toggle in Settings) — ARIA nutzt sie nur bei standortbezogenen Fragen, im Chat sichtbar nur in ihrer Antwort
- [x] Sprachnachrichten ohne STT-Result werden nach Timeout automatisch entfernt (skaliert mit Aufnahmedauer)
- [x] Background Audio Service: TTS, Wake-Word-Lauschen + Aufnahme laufen auch bei minimierter App weiter (Foreground-Service mit mediaPlayback|microphone, dynamische Notification)
- [x] Disk-Voll Banner in Diagnostic mit copy-baren Cleanup-Befehlen
- [x] Wake-Word on-device via openWakeWord (ONNX Runtime, kein API-Key) + State-Icon
+2 -2
View File
@@ -79,8 +79,8 @@ android {
applicationId "com.ariacockpit"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 608
versionName "0.0.6.8"
versionCode 10001
versionName "0.1.0.1"
// Fallback fuer Libraries mit Product Flavors
missingDimensionStrategy 'react-native-camera', 'general'
}
@@ -4,6 +4,19 @@
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<!-- Anruf-State lesen damit TTS bei klingelndem Telefon pausiert -->
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
<!-- Optional: GPS-Position der Frage anhaengen (nur wenn User in Settings aktiviert) -->
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<!-- Foreground-Service damit TTS auch bei minimierter App weiterlaeuft.
FOREGROUND_SERVICE_MICROPHONE ist Pflicht ab Android 14 wenn der
Service waehrend des Backgrounds aufs Mikro zugreift (Wake-Word,
Aufnahme im Gespraechsmodus). -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application
android:name=".MainApplication"
@@ -35,5 +48,10 @@
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
<service
android:name=".AriaPlaybackService"
android:exported="false"
android:foregroundServiceType="mediaPlayback|microphone" />
</application>
</manifest>
@@ -0,0 +1,108 @@
package com.ariacockpit
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Intent
import android.os.Build
import android.os.IBinder
import android.util.Log
import androidx.core.app.NotificationCompat
/**
* Foreground-Service der den App-Prozess waehrend TTS-Wiedergabe am Leben
* haelt — Android killt sonst den Prozess sobald die App im Hintergrund ist
* und ARIA verstummt mitten im Satz.
*
* Notification ist persistent (ongoing) waehrend der Service laeuft.
* Tap auf die Notification bringt MainActivity zurueck nach vorne.
*
* foregroundServiceType="mediaPlayback" ist Pflicht ab Android 14, sonst
* wirft startForeground() eine SecurityException.
*/
class AriaPlaybackService : Service() {
companion object {
private const val TAG = "AriaPlaybackService"
private const val CHANNEL_ID = "aria_playback"
private const val NOTIFICATION_ID = 1042
const val EXTRA_REASON = "reason" // "tts" | "wake" | "rec" | ""
}
private var currentReason: String = ""
override fun onCreate() {
super.onCreate()
ensureNotificationChannel()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val reason = intent?.getStringExtra(EXTRA_REASON) ?: ""
currentReason = reason
Log.i(TAG, "Foreground-Service start/update (reason=$reason)")
try {
startForeground(NOTIFICATION_ID, buildNotification(reason))
} catch (e: Exception) {
Log.e(TAG, "startForeground fehlgeschlagen", e)
stopSelf()
}
// START_NOT_STICKY: wenn Android den Service killt, NICHT automatisch
// wieder starten — die App entscheidet wann der Service noetig ist.
return START_NOT_STICKY
}
override fun onDestroy() {
Log.i(TAG, "Foreground-Service gestoppt")
super.onDestroy()
}
override fun onBind(intent: Intent?): IBinder? = null
private fun ensureNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val nm = getSystemService(NotificationManager::class.java) ?: return
if (nm.getNotificationChannel(CHANNEL_ID) == null) {
val channel = NotificationChannel(
CHANNEL_ID,
"ARIA Audio-Wiedergabe",
NotificationManager.IMPORTANCE_LOW,
).apply {
description = "Notification waehrend ARIA spricht (haelt die App im Hintergrund am Leben)"
setShowBadge(false)
}
nm.createNotificationChannel(channel)
}
}
}
private fun buildNotification(reason: String): Notification {
val launchIntent = Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
}
val pendingFlags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M)
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
else
PendingIntent.FLAG_UPDATE_CURRENT
val pendingIntent = PendingIntent.getActivity(this, 0, launchIntent, pendingFlags)
val (title, body) = when (reason) {
"tts" -> "ARIA spricht" to "Antwort wird abgespielt — antippen oeffnet die App"
"rec" -> "ARIA hoert zu" to "Sprachaufnahme laeuft — antippen oeffnet die App"
"wake" -> "ARIA bereit" to "Wake-Word lauscht passiv — antippen oeffnet die App"
else -> "ARIA aktiv" to "Hintergrund-Modus — antippen oeffnet die App"
}
return NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle(title)
.setContentText(body)
.setSmallIcon(R.mipmap.ic_launcher)
.setContentIntent(pendingIntent)
.setOngoing(true)
.setShowWhen(false)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setCategory(NotificationCompat.CATEGORY_SERVICE)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.build()
}
}
@@ -5,26 +5,71 @@ import android.media.AudioAttributes
import android.media.AudioFocusRequest
import android.media.AudioManager
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.ReactApplicationContext
import com.facebook.react.bridge.ReactContextBaseJavaModule
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)
* - requestExclusive() → andere Apps werden pausiert (Mikrofon-Aufnahme)
* - 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) {
override fun getName() = "AudioFocus"
companion object { private const val TAG = "AudioFocus" }
private var currentRequest: AudioFocusRequest? = null
private fun audioManager(): 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) {
val am = audioManager()
if (am == null) {
@@ -41,13 +86,13 @@ class AudioFocusModule(reactContext: ReactApplicationContext) : ReactContextBase
.build()
val req = AudioFocusRequest.Builder(durationHint)
.setAudioAttributes(attrs)
.setOnAudioFocusChangeListener { /* kein Callback noetig */ }
.setOnAudioFocusChangeListener(focusListener)
.build()
currentRequest = req
am.requestAudioFocus(req)
} else {
@Suppress("DEPRECATION")
am.requestAudioFocus(null, AudioManager.STREAM_MUSIC, durationHint)
am.requestAudioFocus(focusListener, AudioManager.STREAM_MUSIC, durationHint)
}
promise.resolve(result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED)
@@ -92,8 +137,24 @@ class AudioFocusModule(reactContext: ReactApplicationContext) : ReactContextBase
currentRequest?.let { am.abandonAudioFocusRequest(it) }
} else {
@Suppress("DEPRECATION")
am.abandonAudioFocus(null)
am.abandonAudioFocus(focusListener)
}
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) {}
}
@@ -0,0 +1,59 @@
package com.ariacockpit
import android.content.Intent
import android.os.Build
import android.util.Log
import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactContextBaseJavaModule
import com.facebook.react.bridge.ReactMethod
/**
* RN-Bridge fuer den AriaPlaybackService.
*
* Wird vom JS waehrend einer TTS-Wiedergabe gestartet damit Android den
* App-Prozess nicht killt wenn die App im Hintergrund ist (= ARIA spricht
* weiter, auch wenn Stefan die App minimiert hat).
*
* Service stoppt entweder explizit per stop() oder wird von Android
* mitgekillt wenn der Prozess weg ist (was bei Foreground-Service nur
* passiert wenn der User die App force-stopped).
*/
class BackgroundAudioModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) {
override fun getName() = "BackgroundAudio"
companion object { private const val TAG = "BackgroundAudio" }
@ReactMethod
fun start(reason: String, promise: Promise) {
try {
val ctx = reactApplicationContext
val intent = Intent(ctx, AriaPlaybackService::class.java)
intent.putExtra(AriaPlaybackService.EXTRA_REASON, reason ?: "")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
ctx.startForegroundService(intent)
} else {
ctx.startService(intent)
}
promise.resolve(true)
} catch (e: Exception) {
Log.w(TAG, "start fehlgeschlagen: ${e.message}")
promise.reject("START_FAILED", e.message ?: "Unbekannter Fehler", e)
}
}
@ReactMethod
fun stop(promise: Promise) {
try {
val ctx = reactApplicationContext
ctx.stopService(Intent(ctx, AriaPlaybackService::class.java))
promise.resolve(true)
} catch (e: Exception) {
Log.w(TAG, "stop fehlgeschlagen: ${e.message}")
promise.reject("STOP_FAILED", e.message ?: "Unbekannter Fehler", e)
}
}
@ReactMethod fun addListener(eventName: String) {}
@ReactMethod fun removeListeners(count: Int) {}
}
@@ -0,0 +1,16 @@
package com.ariacockpit
import com.facebook.react.ReactPackage
import com.facebook.react.bridge.NativeModule
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.uimanager.ViewManager
class BackgroundAudioPackage : ReactPackage {
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> {
return listOf(BackgroundAudioModule(reactContext))
}
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> {
return emptyList()
}
}
@@ -22,6 +22,8 @@ class MainApplication : Application(), ReactApplication {
add(AudioFocusPackage())
add(PcmStreamPlayerPackage())
add(OpenWakeWordPackage())
add(PhoneCallPackage())
add(BackgroundAudioPackage())
}
override fun getJSMainModuleName(): String = "index"
@@ -8,6 +8,9 @@ import android.content.pm.PackageManager
import android.media.AudioFormat
import android.media.AudioRecord
import android.media.MediaRecorder
import android.media.audiofx.AcousticEchoCanceler
import android.media.audiofx.AutomaticGainControl
import android.media.audiofx.NoiseSuppressor
import android.util.Log
import androidx.core.content.ContextCompat
import com.facebook.react.bridge.Promise
@@ -70,6 +73,13 @@ class OpenWakeWordModule(reactContext: ReactApplicationContext) : ReactContextBa
private val running = AtomicBoolean(false)
private var captureThread: Thread? = null
// Audio-Effects: Echo-Cancellation (gegen ARIAs eigene TTS-Stimme die sonst
// das Wake-Word triggern wuerde) + Noise-Suppression. Per VOICE_COMMUNICATION
// Audio-Source schon vorhanden, aber explizites Aktivieren ist robuster.
private var aec: AcousticEchoCanceler? = null
private var ns: NoiseSuppressor? = null
private var agc: AutomaticGainControl? = null
// Inferenz-State
private val melBuffer: ArrayList<FloatArray> = ArrayList(256) // Liste von 32-dim Frames
private var melProcessedIdx: Int = 0
@@ -146,8 +156,12 @@ class OpenWakeWordModule(reactContext: ReactApplicationContext) : ReactContextBa
AudioFormat.ENCODING_PCM_16BIT,
).coerceAtLeast(CHUNK_SAMPLES * 2 * 4)
// VOICE_COMMUNICATION-Source: aktiviert auf den meisten Android-Geraeten
// automatisch Echo-Cancellation + Noise-Suppression. Wichtig damit
// ARIAs eigene Stimme nicht das Wake-Word triggert wenn parallel
// zur TTS-Wiedergabe gelauscht wird.
val record = AudioRecord(
MediaRecorder.AudioSource.MIC,
MediaRecorder.AudioSource.VOICE_COMMUNICATION,
SAMPLE_RATE,
AudioFormat.CHANNEL_IN_MONO,
AudioFormat.ENCODING_PCM_16BIT,
@@ -159,6 +173,27 @@ class OpenWakeWordModule(reactContext: ReactApplicationContext) : ReactContextBa
return
}
audioRecord = record
// Audio-Effects ZUSAETZLICH explizit aktivieren — manche Geraete
// benoetigen das, obwohl VOICE_COMMUNICATION es eigentlich schon
// mitbringt. Failure ist nicht kritisch (continue ohne Effects).
try {
if (AcousticEchoCanceler.isAvailable()) {
aec = AcousticEchoCanceler.create(record.audioSessionId)?.apply { enabled = true }
Log.i(TAG, "AEC aktiviert (enabled=${aec?.enabled})")
}
} catch (e: Exception) { Log.w(TAG, "AEC failed: ${e.message}") }
try {
if (NoiseSuppressor.isAvailable()) {
ns = NoiseSuppressor.create(record.audioSessionId)?.apply { enabled = true }
}
} catch (e: Exception) { Log.w(TAG, "NS failed: ${e.message}") }
try {
if (AutomaticGainControl.isAvailable()) {
agc = AutomaticGainControl.create(record.audioSessionId)?.apply { enabled = true }
}
} catch (e: Exception) { Log.w(TAG, "AGC failed: ${e.message}") }
resetInferenceState()
running.set(true)
record.startRecording()
@@ -179,6 +214,13 @@ class OpenWakeWordModule(reactContext: ReactApplicationContext) : ReactContextBa
}
}
private fun releaseAudioEffects() {
try { aec?.release() } catch (_: Exception) {}
try { ns?.release() } catch (_: Exception) {}
try { agc?.release() } catch (_: Exception) {}
aec = null; ns = null; agc = null
}
@ReactMethod
fun stop(promise: Promise) {
running.set(false)
@@ -189,6 +231,7 @@ class OpenWakeWordModule(reactContext: ReactApplicationContext) : ReactContextBa
try { audioRecord?.stop() } catch (_: Exception) {}
try { audioRecord?.release() } catch (_: Exception) {}
audioRecord = null
releaseAudioEffects()
Log.i(TAG, "Lauschen gestoppt")
promise.resolve(true)
}
@@ -201,6 +244,7 @@ class OpenWakeWordModule(reactContext: ReactApplicationContext) : ReactContextBa
try { audioRecord?.stop() } catch (_: Exception) {}
try { audioRecord?.release() } catch (_: Exception) {}
audioRecord = null
releaseAudioEffects()
disposeSessions()
promise.resolve(true)
}
@@ -4,12 +4,15 @@ import android.media.AudioAttributes
import android.media.AudioFormat
import android.media.AudioManager
import android.media.AudioTrack
import android.os.Build
import android.util.Base64
import android.util.Log
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactContextBaseJavaModule
import com.facebook.react.bridge.ReactMethod
import com.facebook.react.modules.core.DeviceEventManagerModule
import java.util.concurrent.LinkedBlockingQueue
/**
@@ -76,9 +79,12 @@ class PcmStreamPlayerModule(reactContext: ReactApplicationContext) : ReactContex
val encoding = AudioFormat.ENCODING_PCM_16BIT
val minBuf = AudioTrack.getMinBufferSize(sampleRate, channelConfig, encoding)
val bytesPerSecond = sampleRate * channels * 2 // 16-bit = 2 bytes
// Buffer muss mindestens PREROLL + etwas Spielraum fassen.
val prerollTarget = (bytesPerSecond * prerollSec).toInt()
val bufferSize = (minBuf * 32).coerceAtLeast(prerollTarget * 2)
// Buffer entkoppelt von Preroll — fester ~4s-Buffer. OnePlus A12
// mit USAGE_ASSISTANT laeuft AudioTrack erst ab ~3s gepufferter
// Daten an. Wir padden Kurztexte vor play() auf 3s (siehe Block
// nach mainLoop), Buffer braucht ~1s Headroom weil write() blockt.
val bufferSize = (bytesPerSecond * 4).coerceAtLeast(minBuf * 8)
prerollBytes = prerollTarget
bytesBuffered = 0
playbackStarted = false
@@ -102,7 +108,20 @@ class PcmStreamPlayerModule(reactContext: ReactApplicationContext) : ReactContex
.setTransferMode(AudioTrack.MODE_STREAM)
.build()
// AudioTrack erstellen — play() wird erst aufgerufen wenn Pre-Roll erreicht.
// Start-Threshold runterdrehen: Default ist bufferSize/2 (= 2s bei 4s
// Buffer). AudioTrack startet sonst nicht bevor 2s im Puffer sind —
// bei kurzen TTS-Antworten (3 Worte ~ 1.4s) bleibt pos auf 0 stehen.
// 0.1s reicht damit AudioTrack sofort mit dem ersten Chunk anlaeuft.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
try {
val startFrames = (sampleRate / 10).coerceAtLeast(1) // 100ms
newTrack.setStartThresholdInFrames(startFrames)
Log.i(TAG, "Start-Threshold gesetzt: ${startFrames} frames (~100ms)")
} catch (e: Exception) {
Log.w(TAG, "setStartThresholdInFrames failed: ${e.message}")
}
}
track = newTrack
queue.clear()
writerShouldStop = false
@@ -137,10 +156,12 @@ class PcmStreamPlayerModule(reactContext: ReactApplicationContext) : ReactContex
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).
// 120s damit lange F5-TTS-Render-Pausen zwischen Saetzen (z.B. bei
// Modell-Wechsel oder kalter GPU) nicht den Stream abreissen.
var idleMs = 0L
val maxIdleMs = 30_000L
val maxIdleMs = 120_000L
// Zielpufferfuellung — unter diesem Wasserstand fuettern wir
// Stille rein damit AudioTrack nicht underrunt waehrend die
// Bridge den naechsten Satz rendert. Spotify/YouTube reagieren
@@ -152,15 +173,11 @@ class PcmStreamPlayerModule(reactContext: ReactApplicationContext) : ReactContex
val data = queue.poll(50, java.util.concurrent.TimeUnit.MILLISECONDS)
if (data == null) {
if (endRequested) {
// Falls wir vor Pre-Roll enden (kurzer Text): trotzdem abspielen
// Falls play() noch gar nicht lief (Stream ohne data
// ueberhaupt — sehr seltene Edge-Case): jetzt anstossen
// damit das finally{}-Wait nicht endlos blockt.
if (!playbackStarted) {
try {
t.play()
playbackStarted = true
Log.i(TAG, "Playback gestartet VOR Pre-Roll (kurzer Text, ${bytesBuffered}B gepuffert)")
} catch (e: Exception) {
Log.w(TAG, "play() fallback failed: ${e.message}")
}
try { t.play(); playbackStarted = true } catch (_: Exception) {}
}
break@mainLoop
}
@@ -192,12 +209,16 @@ class PcmStreamPlayerModule(reactContext: ReactApplicationContext) : ReactContex
}
idleMs = 0L
// Pre-Roll Check: play() erst wenn genug gepuffert
if (!playbackStarted && bytesBuffered + data.size >= prerollBytes) {
// play() beim ALLERERSTEN data-chunk aufrufen — egal wie wenig
// Daten da sind. Sonst stallt AudioTrack auf OnePlus A12 wenn
// play() erst gerufen wird nachdem der Buffer komplett gefuellt
// ist. Pre-Roll als "Vorrat aufbauen" passiert dann waehrend
// der Track schon spielt — Underrun-Schutz fuettert ggf. Stille.
if (!playbackStarted) {
try {
t.play()
playbackStarted = true
Log.i(TAG, "Playback gestartet nach Pre-Roll ${bytesBuffered + data.size} Bytes")
Log.i(TAG, "Playback gestartet beim 1. Chunk (${bytesBuffered}B leading + ${data.size}B data)")
} catch (e: Exception) {
Log.w(TAG, "play() failed: ${e.message}")
}
@@ -233,12 +254,21 @@ class PcmStreamPlayerModule(reactContext: ReactApplicationContext) : ReactContex
val totalFrames = (bytesBuffered / streamBytesPerFrame).toInt()
var lastPos = -1
var stalledCount = 0
var retried = false
while (!writerShouldStop) {
val pos = t.playbackHeadPosition
if (pos >= totalFrames) break
// Safety: wenn Position 2s nicht mehr vorwaerts → AudioTrack hing
if (pos == lastPos) {
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) {
Log.w(TAG, "playback stalled at $pos/$totalFrames — give up")
break
@@ -253,6 +283,17 @@ class PcmStreamPlayerModule(reactContext: ReactApplicationContext) : ReactContex
} catch (_: Exception) {}
try { t.stop() } 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() }
@@ -309,6 +350,9 @@ class PcmStreamPlayerModule(reactContext: ReactApplicationContext) : ReactContex
promise.resolve(true)
}
@ReactMethod fun addListener(eventName: String) {}
@ReactMethod fun removeListeners(count: Int) {}
private fun stopInternal() {
writerShouldStop = true
endRequested = true
@@ -0,0 +1,126 @@
package com.ariacockpit
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import android.telephony.PhoneStateListener
import android.telephony.TelephonyCallback
import android.telephony.TelephonyManager
import android.util.Log
import androidx.core.content.ContextCompat
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactContextBaseJavaModule
import com.facebook.react.bridge.ReactMethod
import com.facebook.react.modules.core.DeviceEventManagerModule
/**
* Lauscht auf Anruf-Statusaenderungen — wenn das Telefon klingelt oder ein
* Anruf laeuft, sendet das Modul ein "PhoneCallStateChanged"-Event an JS.
*
* JS-Side stoppt dann die TTS-Wiedergabe damit ARIA nicht mitten ins Gespraech
* weiterredet. Ohne READ_PHONE_STATE-Permission failt start() leise — der Rest
* der App funktioniert wie bisher.
*
* State-Strings: "idle" | "ringing" | "offhook"
*/
class PhoneCallModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) {
override fun getName() = "PhoneCall"
companion object { private const val TAG = "PhoneCall" }
private var telephonyManager: TelephonyManager? = null
private var legacyListener: PhoneStateListener? = null
private var modernCallback: Any? = null // TelephonyCallback ab API 31
private var lastState: Int = TelephonyManager.CALL_STATE_IDLE
@ReactMethod
fun start(promise: Promise) {
try {
val perm = ContextCompat.checkSelfPermission(reactApplicationContext, Manifest.permission.READ_PHONE_STATE)
if (perm != PackageManager.PERMISSION_GRANTED) {
Log.w(TAG, "READ_PHONE_STATE Permission fehlt — Anruf-Erkennung inaktiv")
promise.resolve(false)
return
}
val tm = reactApplicationContext.getSystemService(Context.TELEPHONY_SERVICE) as? TelephonyManager
if (tm == null) {
Log.w(TAG, "TelephonyManager nicht verfuegbar")
promise.resolve(false)
return
}
telephonyManager = tm
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val cb = object : TelephonyCallback(), TelephonyCallback.CallStateListener {
override fun onCallStateChanged(state: Int) {
handleStateChange(state)
}
}
tm.registerTelephonyCallback(reactApplicationContext.mainExecutor, cb)
modernCallback = cb
} else {
@Suppress("DEPRECATION")
val l = object : PhoneStateListener() {
override fun onCallStateChanged(state: Int, phoneNumber: String?) {
handleStateChange(state)
}
}
@Suppress("DEPRECATION")
tm.listen(l, PhoneStateListener.LISTEN_CALL_STATE)
legacyListener = l
}
Log.i(TAG, "PhoneCall-Listener aktiv")
promise.resolve(true)
} catch (e: Exception) {
Log.e(TAG, "start fehlgeschlagen", e)
promise.reject("START_FAILED", e.message ?: "Unbekannter Fehler", e)
}
}
@ReactMethod
fun stop(promise: Promise) {
try {
val tm = telephonyManager
if (tm != null) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
(modernCallback as? TelephonyCallback)?.let { tm.unregisterTelephonyCallback(it) }
modernCallback = null
} else {
@Suppress("DEPRECATION")
legacyListener?.let { tm.listen(it, PhoneStateListener.LISTEN_NONE) }
legacyListener = null
}
}
telephonyManager = null
lastState = TelephonyManager.CALL_STATE_IDLE
promise.resolve(true)
} catch (e: Exception) {
promise.reject("STOP_FAILED", e.message ?: "")
}
}
private fun handleStateChange(state: Int) {
if (state == lastState) return
lastState = state
val name = when (state) {
TelephonyManager.CALL_STATE_RINGING -> "ringing"
TelephonyManager.CALL_STATE_OFFHOOK -> "offhook"
TelephonyManager.CALL_STATE_IDLE -> "idle"
else -> return
}
Log.i(TAG, "Telefon-State: $name")
val params = Arguments.createMap().apply { putString("state", name) }
try {
reactApplicationContext.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
.emit("PhoneCallStateChanged", params)
} catch (e: Exception) {
Log.w(TAG, "Event-emit fehlgeschlagen: ${e.message}")
}
}
@ReactMethod fun addListener(eventName: String) {}
@ReactMethod fun removeListeners(count: Int) {}
}
@@ -0,0 +1,16 @@
package com.ariacockpit
import com.facebook.react.ReactPackage
import com.facebook.react.bridge.NativeModule
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.uimanager.ViewManager
class PhoneCallPackage : ReactPackage {
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> {
return listOf(PhoneCallModule(reactContext))
}
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> {
return emptyList()
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "aria-cockpit",
"version": "0.0.6.8",
"version": "0.1.0.1",
"private": true,
"scripts": {
"android": "react-native run-android",
Binary file not shown.
+8 -87
View File
@@ -1,68 +1,14 @@
/**
* MessageText — rendert Chat-Text mit Auto-Linkifizierung:
* - http(s)://... → tippbar, oeffnet im Browser
* - mailto: oder plain E-Mail → tippbar, oeffnet Mail-App
* - Telefonnummern → tippbar, oeffnet Android-Dialer
* MessageText — selektierbarer Chat-Text mit Android-Auto-Linkifizierung.
*
* Text ist durchgaengig markierbar/kopierbar (selectable).
* Wir nutzen Androids dataDetectorType="all" (System macht Phone/URL/Email
* automatisch klickbar) und ein einzelnes <Text selectable> ohne nested
* <Text> mit eigenem onPress. Nested Text mit onPress fingen die Long-Press-
* Geste ab, damit war Markieren+Kopieren defekt.
*/
import React from 'react';
import { Text, Linking, TextStyle, StyleProp } from 'react-native';
// Regex kombiniert URL | Email | Telefonnummer.
// Gruppenreihenfolge ist wichtig fuer die Erkennung unten.
//
// URL: http://... oder https://... bis zum ersten Whitespace / Anfuehrungszeichen.
// Email: simpler Standard-Match (kein RFC-kompatibel aber gut genug).
// Telefon: internationale Form (+49..., 0049..., 0176...), darf Leerzeichen
// / Bindestriche / Schraegstriche / Klammern enthalten, mindestens 7
// Ziffern insgesamt. Vermeidet banale Zahlen (Uhrzeiten, Datum).
const LINK_REGEX = new RegExp(
'(https?:\\/\\/[^\\s<>"]+)' + // 1: URL
'|([A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,})' + // 2: Email
'|((?:\\+|00)\\d[\\d\\s()\\-\\/]{6,}\\d|0\\d{2,4}[\\s\\/\\-]?[\\d\\s\\-\\/]{5,}\\d)', // 3: Telefon
'g',
);
const LINK_STYLE = { color: '#0096FF', textDecorationLine: 'underline' } as TextStyle;
interface Segment {
text: string;
kind: 'text' | 'url' | 'email' | 'phone';
}
function tokenize(raw: string): Segment[] {
const out: Segment[] = [];
let lastEnd = 0;
LINK_REGEX.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = LINK_REGEX.exec(raw)) !== null) {
if (m.index > lastEnd) {
out.push({ text: raw.slice(lastEnd, m.index), kind: 'text' });
}
if (m[1]) out.push({ text: m[1], kind: 'url' });
else if (m[2]) out.push({ text: m[2], kind: 'email' });
else if (m[3]) out.push({ text: m[3], kind: 'phone' });
lastEnd = LINK_REGEX.lastIndex;
}
if (lastEnd < raw.length) out.push({ text: raw.slice(lastEnd), kind: 'text' });
return out;
}
function onPress(seg: Segment) {
try {
if (seg.kind === 'url') {
Linking.openURL(seg.text);
} else if (seg.kind === 'email') {
Linking.openURL(`mailto:${seg.text}`);
} else if (seg.kind === 'phone') {
// Android-Dialer erwartet tel:-Schema ohne Leerzeichen/Bindestriche
const clean = seg.text.replace(/[\s\-\/()]/g, '');
Linking.openURL(`tel:${clean}`);
}
} catch {}
}
import { Text, TextStyle, StyleProp } from 'react-native';
interface Props {
text: string;
@@ -70,34 +16,9 @@ interface Props {
}
const MessageText: React.FC<Props> = ({ text, style }) => {
const segments = React.useMemo(() => tokenize(text), [text]);
return (
<Text
style={style}
selectable
// dataDetectorType ist Android-only und macht Phone/URL/Email zusaetzlich
// ueber System-Detection klickbar — als Fallback falls unsere Regex-
// Tokens nicht passen.
dataDetectorType="all"
>
{segments.map((seg, i) => {
if (seg.kind === 'text') {
return <Text key={i} selectable>{seg.text}</Text>;
}
return (
<Text
key={i}
selectable
style={LINK_STYLE}
onPress={() => onPress(seg)}
// Long-Press soll an den Parent durch fuer Selection
onLongPress={undefined}
suppressHighlighting={false}
>
{seg.text}
</Text>
);
})}
<Text style={style} selectable dataDetectorType="all">
{text}
</Text>
);
};
-27
View File
@@ -44,7 +44,6 @@ const VoiceButton: React.FC<VoiceButtonProps> = ({
const [meterDb, setMeterDb] = useState(-160);
const pulseAnim = useRef(new Animated.Value(1)).current;
const durationTimer = useRef<ReturnType<typeof setInterval> | null>(null);
const isLongPress = useRef(false);
// Puls-Animation starten/stoppen
useEffect(() => {
@@ -117,31 +116,10 @@ const VoiceButton: React.FC<VoiceButtonProps> = ({
if (disabled || isRecording) return;
const started = await audioService.startRecording(true); // autoStop = true
if (started) {
isLongPress.current = false;
setIsRecording(true);
}
}, [disabled, isRecording]);
// Push-to-Talk: Lang druecken
const handlePressIn = async () => {
if (disabled || isRecording) return;
isLongPress.current = true;
const started = await audioService.startRecording(false); // kein autoStop
if (started) {
setIsRecording(true);
}
};
const handlePressOut = async () => {
if (!isRecording || !isLongPress.current) return;
isLongPress.current = false;
setIsRecording(false);
const result = await audioService.stopRecording();
if (result && result.durationMs > 300) {
onRecordingComplete(result);
}
};
// Tap-to-Talk: Einmal tippen startet mit Auto-Stop.
// Guard gegen Doppel-Tap während asyncer Start/Stop.
const tapBusy = useRef(false);
@@ -162,7 +140,6 @@ const VoiceButton: React.FC<VoiceButtonProps> = ({
// Aufnahme mit Auto-Stop starten
const started = await audioService.startRecording(true);
if (started) {
isLongPress.current = false;
setIsRecording(true);
}
}
@@ -201,10 +178,6 @@ const VoiceButton: React.FC<VoiceButtonProps> = ({
isRecording && styles.buttonOuterRecording,
{ transform: [{ scale: pulseAnim }] },
]}
onStartShouldSetResponder={() => true}
onResponderGrant={handlePressIn}
onResponderRelease={handlePressOut}
onResponderTerminate={handlePressOut}
>
<TouchableOpacity
activeOpacity={0.8}
+291 -37
View File
@@ -19,12 +19,19 @@ import {
ScrollView,
Modal,
ToastAndroid,
AppState,
} from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
import RNFS from 'react-native-fs';
import rvs, { RVSMessage, ConnectionState } from '../services/rvs';
import audioService from '../services/audio';
import wakeWordService from '../services/wakeword';
import phoneCallService from '../services/phoneCall';
import { playWakeReadySound } from '../services/wakeReadySound';
import {
acquireBackgroundAudio,
releaseBackgroundAudio,
} from '../services/backgroundAudio';
import updateService from '../services/updater';
import VoiceButton from '../components/VoiceButton';
import FileUpload, { FileData } from '../components/FileUpload';
@@ -54,6 +61,10 @@ interface ChatMessage {
messageId?: string;
/** Lokaler Pfad zur gecachten TTS-Audio-Datei (file://...) */
audioPath?: string;
/** Korrelations-ID fuer Sprachnachrichten — wird mit dem STT-Result zurueck-
* gespiegelt damit wir die EXAKT richtige Placeholder-Bubble ersetzen,
* auch wenn mehrere Aufnahmen parallel offen sind. */
audioRequestId?: string;
}
// --- Konstanten ---
@@ -69,6 +80,45 @@ const capMessages = (msgs: ChatMessage[]): ChatMessage[] =>
const DEFAULT_ATTACHMENT_DIR = `${RNFS.DocumentDirectoryPath}/chat_attachments`;
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> {
try {
const saved = await AsyncStorage.getItem(STORAGE_PATH_KEY);
@@ -136,20 +186,24 @@ const ChatScreen: React.FC = () => {
return `msg_${Date.now()}_${messageIdCounter.current}`;
};
// TTS-Settings beim Mount + bei Screen-Fokus neu laden (damit Settings-Toggle sofort greift)
// TTS- + GPS-Settings beim Mount + alle 2s neu laden (damit Settings-Toggle
// sofort greift, ohne Context- oder Event-System)
useEffect(() => {
const loadTtsSettings = async () => {
const loadSettings = async () => {
const enabled = await AsyncStorage.getItem('aria_tts_enabled');
setTtsDeviceEnabled(enabled !== 'false'); // default true
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');
localXttsVoiceRef.current = voice || '';
ttsSpeedRef.current = await loadTtsSpeed();
const gps = await AsyncStorage.getItem('aria_gps_enabled');
setGpsEnabled(gps === 'true');
};
loadTtsSettings();
// Poll alle 2s um Settings-Aenderung mitzubekommen (einfache Loesung ohne Context)
const interval = setInterval(loadTtsSettings, 2000);
loadSettings();
const interval = setInterval(loadSettings, 2000);
return () => clearInterval(interval);
}, []);
@@ -159,6 +213,49 @@ const ChatScreen: React.FC = () => {
const unsub = wakeWordService.onStateChange((s) => {
setWakeWordState(s);
setWakeWordActive(s !== 'off');
// Conversation-Focus an Wake-Word-State koppeln: solange wir aktiv im
// Dialog sind, soll Spotify dauerhaft gepaust bleiben (auch ueber
// Render-Pausen + zwischen Antworten hinweg). Sobald wir zurueck nach
// 'armed' oder 'off' fallen, darf Spotify wieder.
if (s === 'conversing') audioService.acquireConversationFocus();
else audioService.releaseConversationFocus();
// Foreground-Service-Slot 'wake' — solange das Ohr ueberhaupt aktiv ist
// (armed oder conversing), soll der App-Prozess im Hintergrund am Leben
// bleiben damit Mikro-Lauschen + Aufnahme weiterlaufen.
if (s !== 'off') acquireBackgroundAudio('wake').catch(() => {});
else releaseBackgroundAudio('wake').catch(() => {});
});
return () => unsub();
}, []);
// Anruf-Erkennung: TTS pausieren wenn das Telefon klingelt
useEffect(() => {
phoneCallService.start().catch(err =>
console.warn('[Chat] phoneCall.start fehlgeschlagen', err));
return () => { phoneCallService.stop().catch(() => {}); };
}, []);
// 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
// auch im Hintergrund weiter aufnehmen darf (Android killt den App-Prozess
// sonst und die Aufnahme bricht ab).
useEffect(() => {
const unsub = audioService.onStateChange((s) => {
if (s === 'recording') acquireBackgroundAudio('rec').catch(() => {});
else releaseBackgroundAudio('rec').catch(() => {});
});
return () => unsub();
}, []);
@@ -173,11 +270,15 @@ const ChatScreen: React.FC = () => {
setTtsMuted(prev => {
const next = !prev;
AsyncStorage.setItem('aria_tts_muted', String(next));
// Bei Muten sofort laufende Wiedergabe stoppen
if (next) audioService.stopPlayback();
// Ref synchron updaten — sonst kommen noch Chunks im selben Tick
// 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;
});
}, []);
}, [ttsDeviceEnabled]);
// Chat-Verlauf aus AsyncStorage laden
const isInitialLoad = useRef(true);
@@ -269,6 +370,8 @@ const ChatScreen: React.FC = () => {
if (message.type === 'chat') {
const sender = (message.payload.sender as string) || '';
const dbgText = ((message.payload.text as string) || '').slice(0, 60);
console.log('[Chat] chat-event sender=%s text=%s', sender || '(none)', dbgText);
// STT-Ergebnis: Transkribierten Text in die Sprach-Bubble schreiben.
// WICHTIG: Nur die ERSTE noch unaufgeloeste Aufnahme matchen — sonst
@@ -276,17 +379,42 @@ const ChatScreen: React.FC = () => {
// den gleichen Text bekommen (Bug: zweite Antwort ueberschreibt erste).
if (sender === 'stt') {
const sttText = (message.payload.text as string) || '';
if (sttText) {
setMessages(prev => {
const idx = prev.findIndex(m =>
m.sender === 'user' && m.text.includes('Spracheingabe wird verarbeitet')
);
if (idx < 0) return prev;
const next = prev.slice();
next[idx] = { ...next[idx], text: `\uD83C\uDFA4 ${sttText}` };
return next;
});
const sttAudioReqId = (message.payload.audioRequestId as string) || '';
if (!sttText) {
return;
}
setMessages(prev => {
const newText = `\uD83C\uDFA4 ${sttText}`;
// Primaer: matche per audioRequestId (eindeutig pro Aufnahme).
// So gibt's keine Verwechslung wenn zwei Audios kurz hintereinander
// gesendet wurden und ihre STT-Results ueberlappen.
if (sttAudioReqId) {
const idxById = prev.findIndex(m => m.audioRequestId === sttAudioReqId);
if (idxById >= 0) {
const next = prev.slice();
next[idxById] = { ...next[idxById], text: newText };
return next;
}
}
// Fallback: alte Bridge-Version ohne audioRequestId \u2014 match per Substring,
// nimmt die ERSTE noch unaufgeloeste Placeholder.
const idx = prev.findIndex(m =>
m.sender === 'user' && m.text.includes('Spracheingabe wird verarbeitet')
);
if (idx >= 0) {
const next = prev.slice();
next[idx] = { ...next[idx], text: newText };
return next;
}
// Letzter Fallback: gar keine Placeholder \u2192 neue Bubble einfuegen
return capMessages([...prev, {
id: nextId(),
sender: 'user',
text: newText,
timestamp: message.timestamp,
attachments: [{ type: 'audio', name: 'Sprachaufnahme' }],
}]);
});
return;
}
@@ -367,6 +495,8 @@ const ChatScreen: React.FC = () => {
const activity = (message.payload.activity as string) || 'idle';
const tool = (message.payload.tool as string) || '';
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
@@ -448,7 +578,14 @@ const ChatScreen: React.FC = () => {
// Conversation-Window: User hat X Sekunden um anzufangen, sonst Konversation aus
const windowMs = await loadConvWindowMs();
const started = await audioService.startRecording(true, windowMs);
if (!started) {
if (started) {
// Erst JETZT signalisieren dass das Mikro wirklich offen ist —
// vorher war's noch in der Init-Phase. So weiss der User exakt
// ab wann er reden kann. "Bereit"-Sound (Ding-Dong) ist optional
// ueber Settings → Wake-Word abschaltbar.
ToastAndroid.show('🎤 Mikro offen — sprich jetzt', ToastAndroid.SHORT);
playWakeReadySound().catch(() => {});
} else {
// Mikrofon nicht verfuegbar, naechsten Versuch
wakeWordService.resume();
}
@@ -459,13 +596,17 @@ const ChatScreen: React.FC = () => {
const result = await audioService.stopRecording();
if (result && result.durationMs > 500) {
// User hat im Fenster gesprochen → Sprachnachricht senden
// Barge-In: laufende ARIA-Aktivitaet abbrechen wenn welche da ist.
const wasInterrupted = interruptAriaIfBusy();
const location = await getCurrentLocation();
const audioRequestId = `audio_${Date.now()}_${Math.floor(Math.random() * 100000)}`;
const userMsg: ChatMessage = {
id: nextId(),
sender: 'user',
text: '🎙 Spracheingabe wird verarbeitet...',
timestamp: Date.now(),
attachments: [{ type: 'audio', name: 'Sprachaufnahme' }],
audioRequestId,
};
setMessages(prev => capMessages([...prev, userMsg]));
rvs.send('audio', {
@@ -474,8 +615,11 @@ const ChatScreen: React.FC = () => {
mimeType: result.mimeType,
voice: localXttsVoiceRef.current,
speed: ttsSpeedRef.current,
interrupted: wasInterrupted,
audioRequestId,
...(location && { location }),
});
scheduleStaleAudioCleanup(audioRequestId, result.durationMs);
// resume() wird durch onPlaybackFinished nach ARIAs Antwort getriggert.
} else {
// Kein Speech im Window → Konversation beenden (Ohr geht aus oder
@@ -486,9 +630,47 @@ const ChatScreen: React.FC = () => {
}
});
// Barge-In via Wake-Word: User sagt "Computer" waehrend ARIA spricht.
// Wake-Word-Service hat bei TTS-Start parallel zu lauschen begonnen
// (mit AcousticEchoCanceler damit ARIAs eigene Stimme nicht triggert).
const unsubBarge = wakeWordService.onBargeIn(async () => {
console.log('[Chat] Barge-In via Wake-Word — TTS abbrechen + neue Aufnahme');
audioService.haltAllPlayback('barge-in via wake-word');
setAgentActivity({ activity: 'idle', tool: '' });
rvs.send('cancel_request' as any, {});
// Kurze Pause damit halt durchgreift, dann neue Aufnahme starten
await new Promise(r => setTimeout(r, 150));
const windowMs = await loadConvWindowMs();
const started = await audioService.startRecording(true, windowMs);
if (started) {
ToastAndroid.show('🎤 Mikro offen — sprich jetzt', ToastAndroid.SHORT);
playWakeReadySound().catch(() => {});
}
});
// TTS-Lifecycle: solange ARIA spricht und Wake-Word verfuegbar ist,
// parallel mitlauschen — User kann "Computer" sagen statt manuell tappen.
// PLUS: Foreground-Service-Slot 'tts' belegen damit Android den App-
// Prozess nicht killt wenn die App im Hintergrund ist.
const unsubTtsStart = audioService.onPlaybackStarted(() => {
acquireBackgroundAudio('tts').catch(() => {});
if (wakeWordService.isConversing() && wakeWordService.hasWakeWord()) {
wakeWordService.startBargeListening().catch(() => {});
}
});
const unsubTtsEnd = audioService.onPlaybackFinished(() => {
releaseBackgroundAudio('tts').catch(() => {});
// Vor naechster Aufnahme: barge-listening aus damit der AudioRecorder
// das Mikro greifen kann.
wakeWordService.stopBargeListening().catch(() => {});
});
return () => {
unsubWake();
unsubSilence();
unsubBarge();
unsubTtsStart();
unsubTtsEnd();
};
}, [wakeWordActive]);
@@ -543,17 +725,23 @@ const ChatScreen: React.FC = () => {
// GPS-Position holen (optional)
const getCurrentLocation = useCallback((): Promise<{ lat: number; lon: number } | null> => {
if (!gpsEnabled) return Promise.resolve(null);
if (!gpsEnabled) {
console.log('[GPS] gpsEnabled=false → kein Standort');
return Promise.resolve(null);
}
return new Promise((resolve) => {
Geolocation.getCurrentPosition(
(position) => {
resolve({
const loc = {
lat: position.coords.latitude,
lon: position.coords.longitude,
});
};
console.log('[GPS] Position: lat=%s lon=%s', loc.lat, loc.lon);
resolve(loc);
},
(_error) => {
(error) => {
console.warn('[GPS] getCurrentPosition Fehler:', error?.code, error?.message);
resolve(null);
},
{ enableHighAccuracy: false, timeout: 5000 },
@@ -563,6 +751,29 @@ const ChatScreen: React.FC = () => {
// --- Nachricht senden ---
// Aufraeumen von "verarbeitet"-Placeholder die nie ein STT-Result bekommen
// haben (leere Aufnahme, Wake-Word-Echo, STT-Fehler etc). Timeout skaliert
// mit der Aufnahmedauer — Whisper braucht auf der Gamebox grob real-time/5,
// plus Bridge-Roundtrip + Network. Formel: 60s Buffer + 1x Aufnahmedauer.
// Bei 5min Aufnahme = 6 min Wait, bei 5s Aufnahme = 65s. Sicher genug damit
// langsame STTs nicht versehentlich aufgeraeumt werden.
const scheduleStaleAudioCleanup = useCallback((audioRequestId: string, recordingMs: number) => {
const timeoutMs = 60000 + recordingMs;
setTimeout(() => {
setMessages(prev => {
const idx = prev.findIndex(m =>
m.audioRequestId === audioRequestId &&
m.text.includes('Spracheingabe wird verarbeitet')
);
if (idx < 0) return prev;
console.log('[Chat] Sprachnachricht ohne STT-Result nach %dms entfernt: %s',
timeoutMs, audioRequestId);
ToastAndroid.show('Sprachnachricht nicht erkannt — entfernt', ToastAndroid.SHORT);
return prev.filter((_, i) => i !== idx);
});
}, timeoutMs);
}, []);
const sendTextMessage = useCallback(async () => {
const text = inputText.trim();
@@ -576,6 +787,8 @@ const ChatScreen: React.FC = () => {
setInputText('');
// Barge-In: laufende ARIA-Aktivitaet abbrechen wenn welche da ist.
const wasInterrupted = interruptAriaIfBusy();
const location = await getCurrentLocation();
const userMsg: ChatMessage = {
@@ -586,16 +799,17 @@ const ChatScreen: React.FC = () => {
};
setMessages(prev => capMessages([...prev, userMsg]));
console.log('[Chat] sende mit voice=%s speed=%s',
localXttsVoiceRef.current || '(default)', ttsSpeedRef.current);
console.log('[Chat] sende mit voice=%s speed=%s interrupted=%s',
localXttsVoiceRef.current || '(default)', ttsSpeedRef.current, wasInterrupted);
// An RVS senden — mit geraetelokaler Voice (Bridge nutzt sie fuer die Antwort)
rvs.send('chat', {
text,
voice: localXttsVoiceRef.current,
speed: ttsSpeedRef.current,
interrupted: wasInterrupted,
...(location && { location }),
});
}, [inputText, getCurrentLocation, pendingAttachments, sendPendingAttachments]);
}, [inputText, getCurrentLocation, pendingAttachments, sendPendingAttachments, interruptAriaIfBusy]);
// Anfrage abbrechen — sofort lokalen Indicator weg, Bridge triggert doctor --fix
const cancelRequest = useCallback(() => {
@@ -603,15 +817,37 @@ const ChatScreen: React.FC = () => {
rvs.send('cancel_request' as any, {});
}, []);
// Barge-In: wenn der User waehrend ARIA arbeitet/spricht eine neue Sprach-
// Nachricht aufnimmt, alte Aktivitaet sofort abbrechen — TTS verstummen,
// aria-core-Run via cancel_request abbrechen. So kann man "ach vergiss es,
// mach lieber X" sagen wie in einem echten Gespraech.
const interruptAriaIfBusy = useCallback(() => {
const speaking = audioService.isPlayingAudio();
const thinking = agentActivity.activity !== 'idle';
if (!speaking && !thinking) return false;
console.log('[Chat] Barge-In: speaking=%s thinking=%s — interrupting ARIA',
speaking, thinking);
if (speaking) audioService.haltAllPlayback('user spricht (barge-in)');
if (thinking) {
setAgentActivity({ activity: 'idle', tool: '' });
rvs.send('cancel_request' as any, {});
}
return true;
}, [agentActivity]);
// Sprachaufnahme abgeschlossen
const handleVoiceRecording = useCallback(async (result: RecordingResult) => {
// Barge-In: laufende ARIA-Aktivitaet abbrechen falls aktiv.
const wasInterrupted = interruptAriaIfBusy();
const location = await getCurrentLocation();
const audioRequestId = `audio_${Date.now()}_${Math.floor(Math.random() * 100000)}`;
const userMsg: ChatMessage = {
id: nextId(),
sender: 'user',
text: '🎙 Spracheingabe wird verarbeitet...',
timestamp: Date.now(),
audioRequestId,
};
setMessages(prev => capMessages([...prev, userMsg]));
@@ -619,9 +855,25 @@ const ChatScreen: React.FC = () => {
base64: result.base64,
durationMs: result.durationMs,
mimeType: result.mimeType,
voice: localXttsVoiceRef.current,
speed: ttsSpeedRef.current,
interrupted: wasInterrupted,
audioRequestId,
...(location && { location }),
});
}, [getCurrentLocation]);
scheduleStaleAudioCleanup(audioRequestId, result.durationMs);
// Manueller Mikro-Stop waehrend Wake-Word-Konversation: User hat explizit
// den Knopf gedrueckt → er moechte nicht in den automatischen Multi-Turn-
// Modus, sondern nach ARIAs Antwort zurueck zu passivem Wake-Word-Lauschen.
// Bei VAD-Auto-Stop (Wake-Word-Pfad) laeuft das ueber den silence-callback
// und endet mit resume() — der manuelle Stop hier ist der "ich bin fertig"-
// Knopf.
if (wakeWordService.isConversing()) {
console.log('[Chat] Manueller Stop in Konversation → endConversation, zurueck zu armed');
await wakeWordService.endConversation();
}
}, [getCurrentLocation, interruptAriaIfBusy, scheduleStaleAudioCleanup]);
// Datei auswaehlen → zur Pending-Liste hinzufuegen
const handleFileSelected = useCallback(async (file: FileData) => {
@@ -638,6 +890,7 @@ const ChatScreen: React.FC = () => {
// Alle Pending Anhaenge + Text senden
const sendPendingAttachments = useCallback(async (messageText: string) => {
if (pendingAttachments.length === 0) return;
console.log('[Chat] sendPendingAttachments: %d Anhang/Anhaenge', pendingAttachments.length);
const location = await getCurrentLocation();
const msgId = nextId();
@@ -687,6 +940,8 @@ const ChatScreen: React.FC = () => {
}
// An RVS senden
console.log('[Chat] sende file: name=%s mime=%s size=%s b64Bytes=%s',
name, mimeType, file.size, base64.length);
rvs.send('file', {
name,
type: mimeType,
@@ -726,11 +981,9 @@ const ChatScreen: React.FC = () => {
{item.attachments?.map((att, idx) => (
<View key={idx}>
{att.type === 'image' && att.uri ? (
<TouchableOpacity onPress={() => setFullscreenImage(att.uri || null)} activeOpacity={0.8}>
<Image
source={{ uri: att.uri }}
style={styles.attachmentImage}
resizeMode="cover"
<ChatImage
uri={att.uri}
onPress={() => setFullscreenImage(att.uri || null)}
onError={() => {
setMessages(prev => prev.map(m =>
m.id === item.id ? { ...m, attachments: m.attachments?.map((a, i) =>
@@ -739,7 +992,6 @@ const ChatScreen: React.FC = () => {
));
}}
/>
</TouchableOpacity>
) : att.type === 'image' && !att.uri ? (
<TouchableOpacity
style={styles.attachmentFile}
@@ -1142,9 +1394,11 @@ const styles = StyleSheet.create({
color: '#E0E0F0',
},
attachmentImage: {
width: '100%',
minHeight: 200,
maxHeight: 400,
// Feste Breite + dynamische aspectRatio (in ChatImage gesetzt) damit die
// Bubble sich ans Bild anpasst. Mit width: '100%' ohne explizite Parent-
// Breite wuerde RN das Bild auf 0px schrumpfen → "Strich".
width: 260,
aspectRatio: 4 / 3,
borderRadius: 8,
marginBottom: 6,
backgroundColor: '#0D0D1A',
+406 -3
View File
@@ -17,6 +17,8 @@ import {
Platform,
ToastAndroid,
ActivityIndicator,
Modal,
PermissionsAndroid,
} from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
import RNFS from 'react-native-fs';
@@ -35,11 +37,24 @@ import {
CONV_WINDOW_MIN_SEC,
CONV_WINDOW_MAX_SEC,
CONV_WINDOW_STORAGE_KEY,
MAX_RECORDING_DEFAULT_SEC,
MAX_RECORDING_MIN_SEC,
MAX_RECORDING_MAX_SEC,
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_MIN,
TTS_SPEED_MAX,
TTS_SPEED_STORAGE_KEY,
} from '../services/audio';
import {
isWakeReadySoundEnabled,
setWakeReadySoundEnabled,
playWakeReadySound,
} from '../services/wakeReadySound';
import wakeWordService, {
WAKE_KEYWORDS,
KEYWORD_LABELS,
@@ -49,6 +64,7 @@ import wakeWordService, {
import ModeSelector from '../components/ModeSelector';
import QRScanner from '../components/QRScanner';
import VoiceCloneModal from '../components/VoiceCloneModal';
import updateService from '../services/updater';
const STORAGE_PATH_KEY = 'aria_attachment_storage_path';
const DEFAULT_STORAGE_PATH = `${RNFS.DocumentDirectoryPath}/chat_attachments`;
@@ -72,6 +88,18 @@ interface EventEntry {
type LogTab = 'live' | 'events';
// Settings-Sub-Screens. Reihenfolge im Hauptmenue.
const SETTINGS_SECTIONS = [
{ id: 'connection', icon: '🔌', label: 'Verbindung', desc: 'Server, Token, Status, Verbindungslog' },
{ id: 'general', icon: '⚙️', label: 'Allgemein', desc: 'Betriebsmodus, GPS-Standort' },
{ id: 'voice_input', icon: '🎙️', label: 'Spracheingabe', desc: 'Stille-Toleranz, Aufnahmedauer' },
{ id: 'wake_word', icon: '👂', label: 'Wake-Word', desc: 'Wake-Word-Auswahl' },
{ id: 'voice_output', icon: '🔊', label: 'Sprachausgabe', desc: 'Stimmen, Pre-Roll, Geschwindigkeit' },
{ id: 'storage', icon: '📁', label: 'Speicher', desc: 'Anhang-Speicherort, Auto-Download' },
{ id: 'protocol', icon: '📜', label: 'Protokoll', desc: 'Privatsphaere, Backup' },
{ id: 'about', icon: '️', label: 'Ueber', desc: 'App-Version, Update' },
] as const;
// Container-Farben fuer Live-Logs
const SOURCE_COLORS: Record<string, string> = {
'aria-core': '#4A9EFF', // Blau
@@ -102,15 +130,25 @@ const SettingsScreen: React.FC = () => {
const [ttsPrerollSec, setTtsPrerollSec] = useState<number>(TTS_PREROLL_DEFAULT_SEC);
const [vadSilenceSec, setVadSilenceSec] = useState<number>(VAD_SILENCE_DEFAULT_SEC);
const [convWindowSec, setConvWindowSec] = useState<number>(CONV_WINDOW_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 [wakeKeyword, setWakeKeyword] = useState<string>(DEFAULT_KEYWORD);
const [wakeStatus, setWakeStatus] = useState<string>('');
const [wakeReadySound, setWakeReadySound] = useState<boolean>(true);
const [editingPath, setEditingPath] = useState(false);
const [xttsVoice, setXttsVoice] = useState('');
const [loadingVoice, setLoadingVoice] = useState<string | null>(null);
const [availableVoices, setAvailableVoices] = useState<Array<{name: string, size: number}>>([]);
const [voiceCloneVisible, setVoiceCloneVisible] = useState(false);
const [tempPath, setTempPath] = useState('');
// Sub-Screen Navigation: null = Hauptmenue, sonst eine der Section-IDs.
// So bleibt aller geteilte State im selben Component-Closure und wir
// brauchen keine react-navigation-Stack-Setup.
const [currentSection, setCurrentSection] = useState<string | null>(null);
let logIdCounter = 0;
@@ -132,6 +170,9 @@ const SettingsScreen: React.FC = () => {
AsyncStorage.getItem('aria_tts_enabled').then(saved => {
if (saved !== null) setTtsEnabled(saved === 'true');
});
AsyncStorage.getItem('aria_gps_enabled').then(saved => {
if (saved !== null) setGpsEnabled(saved === 'true');
});
AsyncStorage.getItem(TTS_PREROLL_STORAGE_KEY).then(saved => {
if (saved != null) {
const n = parseFloat(saved);
@@ -156,6 +197,22 @@ const SettingsScreen: React.FC = () => {
}
}
});
AsyncStorage.getItem(MAX_RECORDING_STORAGE_KEY).then(saved => {
if (saved != null) {
const n = parseFloat(saved);
if (isFinite(n) && n >= MAX_RECORDING_MIN_SEC && n <= MAX_RECORDING_MAX_SEC) {
setMaxRecordingSec(n);
}
}
});
AsyncStorage.getItem(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 => {
if (saved != null) {
const n = parseFloat(saved);
@@ -165,6 +222,8 @@ const SettingsScreen: React.FC = () => {
AsyncStorage.getItem(WAKE_KEYWORD_STORAGE).then(saved => {
if (saved && (WAKE_KEYWORDS as readonly string[]).includes(saved)) setWakeKeyword(saved);
});
isWakeReadySoundEnabled().then(setWakeReadySound);
updateService.getApkCacheSize().then(setApkCacheInfo).catch(() => {});
AsyncStorage.getItem('aria_xtts_voice').then(saved => {
if (saved) setXttsVoice(saved);
});
@@ -399,9 +458,31 @@ const SettingsScreen: React.FC = () => {
// --- GPS Toggle ---
const handleGPSToggle = useCallback((value: boolean) => {
const handleGPSToggle = useCallback(async (value: boolean) => {
if (value && Platform.OS === 'android') {
try {
const granted = await PermissionsAndroid.request(
PermissionsAndroid.PERMISSIONS.ACCESS_COARSE_LOCATION,
{
title: 'ARIA — Standort an Anfragen anhaengen',
message: 'Damit ARIA bei Anfragen wie "Wo ist der naechste...?" den '
+ 'Standort kennt, darf die App den ungefaehren Standort lesen. '
+ 'Wird nur bei jeder Anfrage einmal abgerufen, nicht im Hintergrund.',
buttonPositive: 'Erlauben',
buttonNegative: 'Abbrechen',
},
);
if (granted !== PermissionsAndroid.RESULTS.GRANTED) {
ToastAndroid.show('Standort-Berechtigung abgelehnt', ToastAndroid.SHORT);
return;
}
} catch (err) {
console.warn('[Settings] GPS-Permission Request gescheitert:', err);
return;
}
}
setGpsEnabled(value);
// In Produktion: Wert in AsyncStorage persistieren
AsyncStorage.setItem('aria_gps_enabled', String(value)).catch(() => {});
}, []);
// --- XTTS Voice ---
@@ -480,7 +561,39 @@ const SettingsScreen: React.FC = () => {
/>
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
{currentSection === null && (
<>
{SETTINGS_SECTIONS.map(s => (
<TouchableOpacity
key={s.id}
style={styles.menuItem}
onPress={() => setCurrentSection(s.id)}
>
<Text style={styles.menuItemIcon}>{s.icon}</Text>
<View style={styles.menuItemTextWrap}>
<Text style={styles.menuItemLabel}>{s.label}</Text>
<Text style={styles.menuItemDesc}>{s.desc}</Text>
</View>
<Text style={styles.menuItemChevron}></Text>
</TouchableOpacity>
))}
</>
)}
{currentSection !== null && (
<TouchableOpacity
style={styles.subScreenHeader}
onPress={() => setCurrentSection(null)}
>
<Text style={styles.subScreenBack}></Text>
<Text style={styles.subScreenTitle}>
{SETTINGS_SECTIONS.find(s => s.id === currentSection)?.label || ''}
</Text>
</TouchableOpacity>
)}
{/* === Verbindung === */}
{currentSection === 'connection' && (<>
<Text style={styles.sectionTitle}>Verbindung</Text>
<View style={styles.card}>
{/* Status-Anzeige */}
@@ -577,8 +690,10 @@ const SettingsScreen: React.FC = () => {
<Text style={styles.clearButtonText}>Log l{'\u00F6'}schen</Text>
</TouchableOpacity>
</View>
</>)}
{/* === Modus === */}
{currentSection === 'general' && (<>
<Text style={styles.sectionTitle}>Betriebsmodus</Text>
<View style={styles.card}>
<ModeSelector currentModeId={currentMode} onModeChange={handleModeChange} />
@@ -591,7 +706,11 @@ const SettingsScreen: React.FC = () => {
<View style={styles.toggleInfo}>
<Text style={styles.toggleLabel}>GPS-Position mitsenden</Text>
<Text style={styles.toggleHint}>
Standort wird automatisch an Nachrichten angehaengt
Position (lat/lon) wird mit jeder Nachricht an ARIA mitgeschickt.
Sie sieht's nur intern und nutzt es bei standortbezogenen Fragen
("wo bin ich?", "Wetter hier?"), erwaehnt es sonst nicht.
Im Chat-Verlauf bleibt die Bubble unveraendert — nur ARIAs
Antwort kann darauf eingehen.
</Text>
</View>
<Switch
@@ -602,8 +721,10 @@ const SettingsScreen: React.FC = () => {
/>
</View>
</View>
</>)}
{/* === Spracheingabe (geraetelokal) === */}
{currentSection === 'voice_input' && (<>
<Text style={styles.sectionTitle}>Spracheingabe</Text>
<View style={styles.card}>
<Text style={styles.toggleLabel}>Stille-Toleranz</Text>
@@ -671,9 +792,130 @@ const SettingsScreen: React.FC = () => {
<Text style={styles.prerollButtonText}>+1</Text>
</TouchableOpacity>
</View>
<Text style={[styles.toggleLabel, {marginTop: 24}]}>Maximale Aufnahmedauer</Text>
<Text style={styles.toggleHint}>
Notbremse: nach so vielen Minuten wird die Aufnahme automatisch beendet,
auch wenn keine Stille erkannt wurde. Nuetzlich fuer lange Erklaerungen
oder Diktate. Default: {Math.round(MAX_RECORDING_DEFAULT_SEC / 60)} Min, max {Math.round(MAX_RECORDING_MAX_SEC / 60)} Min.
</Text>
<View style={styles.prerollRow}>
<TouchableOpacity
style={styles.prerollButton}
onPress={() => {
const next = Math.max(MAX_RECORDING_MIN_SEC, maxRecordingSec - 60);
setMaxRecordingSec(next);
AsyncStorage.setItem(MAX_RECORDING_STORAGE_KEY, String(next));
}}
disabled={maxRecordingSec <= MAX_RECORDING_MIN_SEC}
>
<Text style={styles.prerollButtonText}>1m</Text>
</TouchableOpacity>
<Text style={styles.prerollValue}>{Math.round(maxRecordingSec / 60)} min</Text>
<TouchableOpacity
style={styles.prerollButton}
onPress={() => {
const next = Math.min(MAX_RECORDING_MAX_SEC, maxRecordingSec + 60);
setMaxRecordingSec(next);
AsyncStorage.setItem(MAX_RECORDING_STORAGE_KEY, String(next));
}}
disabled={maxRecordingSec >= MAX_RECORDING_MAX_SEC}
>
<Text style={styles.prerollButtonText}>+1m</Text>
</TouchableOpacity>
</View>
<View 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>
<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) === */}
{currentSection === 'wake_word' && (<>
<Text style={styles.sectionTitle}>Wake-Word</Text>
<View style={styles.card}>
<Text style={styles.toggleHint}>
@@ -728,9 +970,36 @@ const SettingsScreen: React.FC = () => {
{!!wakeStatus && (
<Text style={{marginTop: 8, fontSize: 12, color: '#8888AA'}}>{wakeStatus}</Text>
)}
<View style={[styles.toggleRow, {marginTop: 20, borderTopWidth: 1, borderTopColor: '#1E1E2E', paddingTop: 16}]}>
<View style={styles.toggleInfo}>
<Text style={styles.toggleLabel}>Bereit-Sound abspielen</Text>
<Text style={styles.toggleHint}>
Kurzer Ding-Dong wenn das Mikro nach Wake-Word offen ist
akustische Bestaetigung dass du jetzt sprechen darfst.
</Text>
</View>
<Switch
value={wakeReadySound}
onValueChange={async (val) => {
setWakeReadySound(val);
await setWakeReadySoundEnabled(val);
if (val) {
// Direkt eine Vorschau abspielen damit der User weiss wie's klingt.
// playWakeReadySound checked das gerade gesetzte Flag — wenn val=true,
// wird abgespielt; bei false bleibt es still.
setTimeout(() => playWakeReadySound().catch(() => {}), 150);
}
}}
trackColor={{ false: '#2A2A3E', true: '#0096FF' }}
thumbColor={wakeReadySound ? '#FFFFFF' : '#666680'}
/>
</View>
</View>
</>)}
{/* === Sprachausgabe (geraetelokal) === */}
{currentSection === 'voice_output' && (<>
<Text style={styles.sectionTitle}>Sprachausgabe</Text>
<View style={styles.card}>
<View style={styles.toggleRow}>
@@ -873,7 +1142,10 @@ const SettingsScreen: React.FC = () => {
)}
</View>
</>)}
{/* === Speicher === */}
{currentSection === 'storage' && (<>
<Text style={styles.sectionTitle}>Anhang-Speicher</Text>
<View style={styles.card}>
<View style={styles.toggleRow}>
@@ -948,7 +1220,41 @@ const SettingsScreen: React.FC = () => {
)}
</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 === */}
{currentSection === 'protocol' && (<>
<Text style={styles.sectionTitle}>Protokoll</Text>
<View style={styles.card}>
{/* Tab-Umschalter */}
@@ -1027,8 +1333,10 @@ const SettingsScreen: React.FC = () => {
<Text style={styles.clearButtonText}>Protokoll l\u00F6schen</Text>
</TouchableOpacity>
</View>
</>)}
{/* === About === */}
{currentSection === 'about' && (<>
<Text style={styles.sectionTitle}>{'\u00DC'}ber</Text>
<View style={styles.card}>
<Text style={styles.aboutTitle}>ARIA Cockpit</Text>
@@ -1048,6 +1356,7 @@ const SettingsScreen: React.FC = () => {
<Text style={styles.connectButtonText}>Auf Updates pr{'\u00FC'}fen</Text>
</TouchableOpacity>
</View>
</>)}
{/* Platz am Ende */}
<View style={styles.bottomSpacer} />
@@ -1076,6 +1385,58 @@ const styles = StyleSheet.create({
marginBottom: 8,
marginLeft: 4,
},
menuItem: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#1E1E2E',
borderRadius: 10,
paddingVertical: 14,
paddingHorizontal: 14,
marginBottom: 8,
},
menuItemIcon: {
fontSize: 22,
marginRight: 14,
width: 28,
textAlign: 'center',
},
menuItemTextWrap: {
flex: 1,
},
menuItemLabel: {
color: '#FFFFFF',
fontSize: 16,
fontWeight: '600',
},
menuItemDesc: {
color: '#8888AA',
fontSize: 12,
marginTop: 2,
},
menuItemChevron: {
color: '#8888AA',
fontSize: 24,
fontWeight: '300',
marginLeft: 8,
},
subScreenHeader: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 8,
marginBottom: 8,
},
subScreenBack: {
color: '#0096FF',
fontSize: 32,
fontWeight: '300',
marginRight: 12,
lineHeight: 36,
},
subScreenTitle: {
color: '#FFFFFF',
fontSize: 20,
fontWeight: '700',
},
card: {
backgroundColor: '#12122A',
borderRadius: 14,
@@ -1433,6 +1794,48 @@ const styles = StyleSheet.create({
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: {
backgroundColor: '#1E1E2E',
borderWidth: 1,
+451 -30
View File
@@ -6,10 +6,11 @@
* Nutzt react-native-audio-recorder-player fuer Aufnahme.
*/
import { Platform, PermissionsAndroid, NativeModules } from 'react-native';
import { Platform, PermissionsAndroid, NativeModules, ToastAndroid, NativeEventEmitter } from 'react-native';
import Sound from 'react-native-sound';
import RNFS from 'react-native-fs';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { acquireBackgroundAudio, releaseBackgroundAudio, stopBackgroundAudio } from './backgroundAudio';
import AudioRecorderPlayer, {
AudioEncoderAndroidType,
AudioSourceAndroidType,
@@ -72,11 +73,41 @@ const AUDIO_SAMPLE_RATE = 16000;
const AUDIO_CHANNELS = 1;
const AUDIO_ENCODING = 'audio/wav';
// VAD (Voice Activity Detection) — Stille-Erkennung
const VAD_SILENCE_THRESHOLD_DB = -45; // dB unter dem als "Stille" gilt
const VAD_SPEECH_THRESHOLD_DB = -28; // dB ueber dem als "Sprache" gilt (Sprach-Gate) — hoeher = weniger Umgebungsgeraeusche
// VAD (Voice Activity Detection) — Stille-Erkennung.
// Fallback-Werte falls die adaptive Baseline-Messung fehlschlaegt (z.B. weil
// das Mikro keine metering-Updates liefert). Adaptive Werte werden zur
// Laufzeit aus den ersten BASELINE_SAMPLES gemessen und auf baseline+offset
// gesetzt — funktioniert in lauten wie leisen Umgebungen.
const VAD_SILENCE_FALLBACK_DB = -38; // Fallback Stille-Schwelle
const VAD_SPEECH_FALLBACK_DB = -22; // Fallback Sprach-Schwelle
const VAD_SILENCE_OFFSET_DB = 6; // Sprache = Baseline + 6dB
const VAD_SPEECH_OFFSET_DB = 12; // sicheres Speech = Baseline + 12dB
const VAD_BASELINE_SAMPLES = 5; // 5 × 100ms = 500ms Baseline
const VAD_SPEECH_MIN_MS = 500; // ms Sprache bevor Aufnahme zaehlt — laenger = keine Huestler/Klopfer mehr
// 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
// die Aufnahme automatisch beendet wird. Einstellbar in den App-Settings.
export const VAD_SILENCE_DEFAULT_SEC = 2.8;
@@ -138,7 +169,24 @@ async function loadVadSilenceMs(): Promise<number> {
// Max-Dauer einer Aufnahme (Notbremse gegen Runaway-Loops). Auf 2 Minuten
// hochgezogen damit auch laengere Erklaerungen durchgehen.
const MAX_RECORDING_MS = 120000;
// Default 5 Minuten — konfigurierbar in den App-Settings (1-30 Minuten).
export const MAX_RECORDING_DEFAULT_SEC = 300;
export const MAX_RECORDING_MIN_SEC = 60;
export const MAX_RECORDING_MAX_SEC = 1800;
export const MAX_RECORDING_STORAGE_KEY = 'aria_max_recording_sec';
export async function loadMaxRecordingMs(): Promise<number> {
try {
const raw = await AsyncStorage.getItem(MAX_RECORDING_STORAGE_KEY);
if (raw != null) {
const n = parseFloat(raw);
if (isFinite(n) && n >= MAX_RECORDING_MIN_SEC && n <= MAX_RECORDING_MAX_SEC) {
return Math.round(n * 1000);
}
}
} catch {}
return MAX_RECORDING_DEFAULT_SEC * 1000;
}
// Pre-Roll: Wie lange Audio im AudioTrack-Buffer liegt bevor play() startet.
// Einstellbar via Diagnostic/Settings (Key: aria_tts_preroll_sec).
@@ -198,6 +246,12 @@ class AudioService {
private focusReleaseTimer: ReturnType<typeof setTimeout> | null = null;
private readonly FOCUS_RELEASE_DELAY_MS = 800;
// Conversation-Mode: solange aktiv (Wake-Word Status 'conversing' ODER
// wir wissen "ARIA spricht gerade in einem Multi-Turn-Dialog"), halten wir
// den AudioFocus DAUERHAFT. Der per-Stream-Release wird unterdrueckt,
// damit Spotify nicht in Render-Pausen oder zwischen Antworten zurueckkehrt.
private _conversationFocusActive: boolean = false;
// VAD State
private vadEnabled: boolean = false;
private lastSpeechTime: number = 0;
@@ -206,19 +260,63 @@ class AudioService {
// Latch damit der Silence-Callback pro Aufnahme genau einmal feuert
private silenceFired: boolean = false;
private noSpeechTimer: ReturnType<typeof setTimeout> | null = null;
// Adaptive Schwellen — werden in den ersten 500ms aus dem Mikro-Pegel
// gemessen. baseline = avg dB der ersten 5 Samples, dann:
// silence = baseline + VAD_SILENCE_OFFSET_DB (6dB ueber ambient)
// speech = baseline + VAD_SPEECH_OFFSET_DB (12dB ueber ambient = klares Reden)
// Funktioniert sowohl im stillen Buero als auch im lauten Cafe.
private vadBaselineSamples: number[] = [];
private vadAdaptiveSilenceDb: number = VAD_SILENCE_FALLBACK_DB;
private vadAdaptiveSpeechDb: number = VAD_SPEECH_FALLBACK_DB;
// 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() {
this.recorder = new AudioRecorderPlayer();
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
* springen sonst im Gap zwischen zwei TTS-Streams (oder wenn ARIA
* eine zweite Antwort direkt hinterherschickt) kurz wieder an. */
* eine zweite Antwort direkt hinterherschickt) kurz wieder an.
* Im Conversation-Mode (Wake-Word conversing) wird das Release komplett
* unterdrueckt — der Focus bleibt fuer die ganze Konversation gehalten. */
private _releaseFocusDeferred(): void {
if (this._conversationFocusActive) {
this._cancelDeferredFocusRelease();
return;
}
this._cancelDeferredFocusRelease();
this.focusReleaseTimer = setTimeout(() => {
this.focusReleaseTimer = null;
if (this._conversationFocusActive) return;
AudioFocus?.release().catch(() => {});
}, this.FOCUS_RELEASE_DELAY_MS);
}
@@ -230,6 +328,182 @@ class AudioService {
}
}
/** Conversation-Mode beginnt → AudioFocus dauerhaft halten (Spotify bleibt
* pausiert). Idempotent: mehrfaches Aufrufen ist sicher. */
acquireConversationFocus(): void {
if (this._conversationFocusActive) return;
this._conversationFocusActive = true;
this._cancelDeferredFocusRelease();
console.log('[Audio] Conversation-Focus aktiv (Spotify bleibt gepaust)');
AudioFocus?.requestDuck().catch(() => {});
}
/** Conversation-Mode endet → Focus darf wieder freigegeben werden
* (verzoegert, damit eine direkt folgende Antwort nichts kaputtmacht). */
releaseConversationFocus(): void {
if (!this._conversationFocusActive) return;
this._conversationFocusActive = false;
console.log('[Audio] Conversation-Focus inaktiv');
this._releaseFocusDeferred();
}
/** TTS-Wiedergabe haart stoppen — z.B. fuer Barge-In. Buffer wird geleert,
* kein Auto-Resume. Released auch sofort den AudioFocus. */
haltAllPlayback(reason: string = ''): void {
console.log('[Audio] haltAllPlayback: %s', reason || '(no reason)');
this._conversationFocusActive = false;
this.stopPlayback();
}
/** Speziell fuer Anrufe: AudioTrack stoppen + Focus releasen, ABER pcm-
* Buffer + messageId behalten damit weitere Chunks der unterbrochenen
* Antwort weiter gesammelt werden. isFinal schreibt dann die WAV trotz
* Anruf — und resumeFromInterruption findet sie. */
pauseForCall(reason: string = ''): void {
console.log('[Audio] pauseForCall: %s', reason || '(no reason)');
this._conversationFocusActive = false;
this._pausedForCall = true;
// Queue + isPlaying ruecksetzen — sonst klemmt der naechste Play-Button
// (playAudio sieht isPlaying=true und ruft _playNext nicht mehr auf).
this.audioQueue = [];
this.isPlaying = false;
// Foreground-Service stoppen — Notification waere sonst irrefuehrend
stopBackgroundAudio().catch(() => {});
// SoundPool/RNSound (Resume-Sound, Play-Button) stoppen — nicht relevant fuer Auto-Resume
if (this.currentSound) {
try { this.currentSound.stop(); this.currentSound.release(); } catch {}
this.currentSound = null;
}
if (this.resumeSound) {
try { this.resumeSound.stop(); this.resumeSound.release(); } catch {}
this.resumeSound = null;
}
// AudioTrack hart stoppen damit nichts mehr aus dem Lautsprecher kommt.
// pcmStreamActive bleibt true, pcmBuffer/pcmMessageId BLEIBEN — damit
// weitere Chunks gesammelt werden und isFinal die WAV schreiben kann.
PcmStreamPlayer?.stop().catch(() => {});
this._cancelDeferredFocusRelease();
AudioFocus?.release().catch(() => {});
}
/** Anruf vorbei → weitere Chunks duerfen wieder abgespielt werden.
* resumeFromInterruption uebernimmt die Wiedergabe ab gemerkter Position. */
endCallPause(): void {
if (!this._pausedForCall) return;
this._pausedForCall = false;
console.log('[Audio] endCallPause');
}
/** Bei Anruf: aktuelle Wiedergabe-Position merken damit wir nach dem
* Auflegen von dort weitermachen koennen. Returnt Position in Sekunden
* oder 0 wenn nichts spielte.
*
* Idempotent: bei mehrfachem Aufruf (ringing → offhook) wird die Position
* vom ersten Mal NICHT ueberschrieben. playbackStartTime laeuft stumpf
* weiter obwohl das Audio gestoppt ist — der erste Halt ist der echte. */
captureInterruption(): number {
if (this.pausedMessageId) {
console.log('[Audio] captureInterruption: bereits erfasst (msgId=%s pos=%ss) — skip',
this.pausedMessageId, this.pausedPosition.toFixed(2));
return this.pausedPosition;
}
if (!this.playbackStartTime || !this.currentPlaybackMsgId) {
console.log('[Audio] captureInterruption: nichts spielte (startTime=%s, msgId=%s)',
this.playbackStartTime, this.currentPlaybackMsgId || '(leer)');
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) {
console.log('[Audio] resumeFromInterruption: kein gemerkter Stand — skip');
return false;
}
console.log('[Audio] resumeFromInterruption: starte fuer msgId=%s pos=%ss',
msgId, position.toFixed(2));
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;
// Tracking auch fuer den Resume-Sound aktualisieren — sonst kann
// captureInterruption bei einem zweiten Anruf die Position nicht
// mehr ermitteln (playbackStartTime waere von der ersten Wiedergabe).
const msgIdMatch = path.match(/([^/\\]+)\.wav$/i);
if (msgIdMatch) this.currentPlaybackMsgId = msgIdMatch[1];
// Virtuelle Start-Zeit so setzen, dass captureInterruption (das den
// Leading-Silence-Offset wieder abzieht) die korrekte Position liefert.
this.playbackStartTime = Date.now() - (positionSec + this.LEADING_SILENCE_SEC) * 1000;
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.
* Nuetzlich fuer "Barge-In": wenn der User spricht waehrend ARIA spricht,
* soll die ARIA-Wiedergabe abgebrochen + die neue User-Message verarbeitet
* werden ("ach vergiss es, mach lieber X"). */
isPlayingAudio(): boolean {
return this.isPlaying || this.pcmStreamActive;
}
// --- Berechtigungen ---
async requestMicrophonePermission(): Promise<boolean> {
@@ -287,6 +561,12 @@ class AudioService {
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
await this.recorder.startRecorder(this.recordingPath, {
AudioEncoderAndroid: AudioEncoderAndroidType.AAC,
@@ -301,8 +581,36 @@ class AudioService {
const db = e.currentMetering ?? -160;
this.meterListeners.forEach(cb => cb(db));
// Adaptive Baseline: erste 5 Samples (~500ms) sammeln, dann Schwellen
// anpassen. -160 (kein Metering) ignorieren — sonst wird die Baseline
// sinnlos niedrig.
if (this.vadBaselineSamples.length < VAD_BASELINE_SAMPLES) {
if (db > -100) {
this.vadBaselineSamples.push(db);
if (this.vadBaselineSamples.length === VAD_BASELINE_SAMPLES) {
// Minimum statt Mittelwert: robust gegen Spike-Samples (z.B. wenn
// der User direkt nach Wake-Word sofort spricht oder das Wake-Word-
// Echo noch im Mikro ist). Min ist der ruhigste Moment.
const lowest = Math.min(...this.vadBaselineSamples);
const rawSilence = lowest + VAD_SILENCE_OFFSET_DB;
const rawSpeech = lowest + VAD_SPEECH_OFFSET_DB;
// Cap auf einen vernuenftigen Bereich:
// - Silence-Schwelle nicht ueber -28dB (sonst zaehlt Hintergrund-
// geraeusch dauerhaft als "Sprache" → VAD feuert nie)
// - Silence-Schwelle nicht unter -50dB (sonst zu strikt)
this.vadAdaptiveSilenceDb = Math.max(-50, Math.min(rawSilence, -28));
this.vadAdaptiveSpeechDb = Math.max(-40, Math.min(rawSpeech, -18));
const msg = `VAD: ambient=${lowest.toFixed(0)}dB stille>${this.vadAdaptiveSilenceDb.toFixed(0)}dB`;
console.log('[Audio] %s speech>%s (raw silence=%s speech=%s)',
msg, this.vadAdaptiveSpeechDb.toFixed(1),
rawSilence.toFixed(1), rawSpeech.toFixed(1));
try { ToastAndroid.show(msg, ToastAndroid.SHORT); } catch {}
}
}
}
// Sprach-Gate: Erkennen ob tatsaechlich gesprochen wird
if (db > VAD_SPEECH_THRESHOLD_DB) {
if (db > this.vadAdaptiveSpeechDb) {
if (!this.speechDetected && this.speechStartTime === 0) {
this.speechStartTime = Date.now();
}
@@ -317,7 +625,7 @@ class AudioService {
// VAD: Stille erkennen (nur wenn Sprache erkannt wurde)
if (this.vadEnabled) {
if (db > VAD_SILENCE_THRESHOLD_DB) {
if (db > this.vadAdaptiveSilenceDb) {
this.lastSpeechTime = Date.now();
}
}
@@ -327,6 +635,23 @@ class AudioService {
this.lastSpeechTime = Date.now();
this.speechDetected = false;
this.speechStartTime = 0;
// VAD-Adaptive zurueckgesetzt: Baseline wird in den ersten 500ms neu
// gemessen. Bis dahin gelten die Fallback-Schwellen.
this.vadBaselineSamples = [];
this.vadAdaptiveSilenceDb = VAD_SILENCE_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');
// Andere Apps waehrend der Aufnahme pausieren (Musik, Videos etc.)
@@ -354,18 +679,19 @@ class AudioService {
};
if (autoStop) {
const vadSilenceMs = await loadVadSilenceMs();
const maxRecordingMs = await loadMaxRecordingMs();
console.log('[Audio] startRecording: autoStop=true, VAD-Stille=%dms, MAX=%dms',
vadSilenceMs, MAX_RECORDING_MS);
vadSilenceMs, maxRecordingMs);
this.vadTimer = setInterval(() => {
const silenceDuration = Date.now() - this.lastSpeechTime;
if (silenceDuration >= vadSilenceMs) {
fireSilenceOnce(`VAD ${silenceDuration}ms Stille (Schwelle=${vadSilenceMs}ms)`);
}
}, 200);
// Notbremse: Nach MAX_RECORDING_MS zwangsweise stoppen
// Notbremse: Nach maxRecordingMs zwangsweise stoppen
this.maxDurationTimer = setTimeout(() => {
fireSilenceOnce(`Max-Dauer ${MAX_RECORDING_MS}ms`);
}, MAX_RECORDING_MS);
fireSilenceOnce(`Max-Dauer ${maxRecordingMs}ms`);
}, maxRecordingMs);
}
// Conversation-Window: Wenn der User innerhalb noSpeechTimeoutMs nicht
@@ -454,8 +780,15 @@ class AudioService {
/** Base64-kodiertes Audio in die Queue stellen und abspielen */
async playAudio(base64Data: string): Promise<void> {
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) {
console.log('[Audio] playAudio: muted=true → skip');
return;
}
this.audioQueue.push(base64Data);
console.log('[Audio] playAudio: queued (queue=%d isPlaying=%s pausedForCall=%s)',
this.audioQueue.length, this.isPlaying, this._pausedForCall);
if (!this.isPlaying) {
this._playNext();
}
@@ -521,7 +854,11 @@ class AudioService {
final?: boolean;
silent?: boolean;
}): 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.
// _pausedForCall: AudioTrack ist gestoppt waehrend Anruf — Chunks weiter
// sammeln (fuer WAV-Cache), aber NICHT in den Player schicken.
const silent = !!payload.silent || this._muted || this._pausedForCall;
if (!silent && !PcmStreamPlayer) {
console.warn('[Audio] PcmStreamPlayer Native Module nicht verfuegbar');
return '';
@@ -547,6 +884,21 @@ class AudioService {
this.pcmBuffer = [];
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.pcmMessageId = messageId;
this.pcmSampleRate = sampleRate;
@@ -564,6 +916,7 @@ class AudioService {
}
this._cancelDeferredFocusRelease();
AudioFocus?.requestDuck().catch(() => {});
this._firePlaybackStarted();
}
}
@@ -580,13 +933,16 @@ class AudioService {
if (isFinal) {
if (!silent) {
// end() resolved jetzt erst wenn der native Writer-Thread fertig
// ist (alle Samples ausgespielt) — danach AudioFocus verzoegert
// freigeben, damit Spotify/YouTube nicht im Mikro-Gap zwischen zwei
// ARIA-Antworten wieder hochdrehen. Wenn ein neuer Stream innerhalb
// FOCUS_RELEASE_DELAY_MS startet, wird das Release abgebrochen.
// end() signalisiert dem Writer "keine weiteren Chunks". Aber WIR
// releasen den AudioFocus NICHT hier — der writer braucht u.U. noch
// 30+ Sekunden bis der Buffer wirklich abgespielt ist. Den release
// triggert das native Event "PcmPlaybackFinished" wenn AudioTrack
// wirklich am Ende ist (siehe ensurePlaybackFinishedListener).
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;
@@ -660,7 +1016,10 @@ class AudioService {
}
}
/** Audio aus lokaler Datei (file:// Pfad) in die Queue und abspielen. */
/** Audio aus lokaler Datei (file:// Pfad) in die Queue und abspielen.
* Setzt zusaetzlich playbackStartTime + currentPlaybackMsgId damit ein
* Anruf waehrend dieses Playbacks korrekt erfasst wird (ohne dieses
* Tracking liefert captureInterruption nichts → kein Auto-Resume). */
async playFromPath(filePath: string): Promise<void> {
if (!filePath) return;
try {
@@ -669,6 +1028,14 @@ class AudioService {
console.warn('[Audio] Cache-Datei existiert nicht mehr:', cleanPath);
return;
}
// Dateiname ohne .wav als messageId nehmen (egal ob UUID oder andere ID)
const fileMatch = cleanPath.match(/([^/\\]+)\.wav$/i);
const msgId = fileMatch ? fileMatch[1] : '';
console.log('[Audio] playFromPath: cleanPath=%s → msgId=%s', cleanPath, msgId || '(leer)');
if (msgId) {
this.currentPlaybackMsgId = msgId;
this.playbackStartTime = Date.now() - this.LEADING_SILENCE_SEC * 1000;
}
const b64 = await RNFS.readFile(cleanPath, 'base64');
this.playAudio(b64);
} catch (err) {
@@ -678,6 +1045,7 @@ class AudioService {
// Callback wenn alle Audio-Teile abgespielt sind
private playbackFinishedListeners: (() => void)[] = [];
private playbackStartedListeners: (() => void)[] = [];
onPlaybackFinished(callback: () => void): () => void {
this.playbackFinishedListeners.push(callback);
@@ -686,6 +1054,30 @@ class AudioService {
};
}
/** Callback wenn ARIAs TTS-Wiedergabe startet — fuer Wake-Word-parallel-
* Listening waehrend ARIA spricht (Barge-In via "Computer" sagen). */
onPlaybackStarted(callback: () => void): () => void {
this.playbackStartedListeners.push(callback);
return () => {
this.playbackStartedListeners = this.playbackStartedListeners.filter(cb => cb !== callback);
};
}
private _firePlaybackStarted(): void {
// Tracking fuer Auto-Resume nach Anruf-Pause: NUR setzen wenn ein
// PCM-Stream laeuft (Live-TTS). Bei Play-Button / Resume-Sound hat der
// Caller (playFromPath / _playFromPathAtPosition) das Tracking schon
// korrekt mit der msgId aus dem Pfad gesetzt — sonst wuerden wir hier
// mit leerem pcmMessageId ueberschreiben.
if (this.pcmMessageId) {
this.playbackStartTime = Date.now();
this.currentPlaybackMsgId = this.pcmMessageId;
}
this.playbackStartedListeners.forEach(cb => {
try { cb(); } catch (e) { console.warn('[Audio] playbackStarted listener err:', e); }
});
}
/** Naechstes Audio aus der Queue abspielen */
private async _playNext(): Promise<void> {
if (this.audioQueue.length === 0) {
@@ -698,10 +1090,11 @@ class AudioService {
return;
}
// Beim ersten Playback-Start: andere Apps ducken
// Beim ersten Playback-Start: andere Apps ducken + Listener informieren
if (!this.isPlaying) {
this._cancelDeferredFocusRelease();
AudioFocus?.requestDuck().catch(() => {});
this._firePlaybackStarted();
}
this.isPlaying = true;
@@ -763,8 +1156,30 @@ 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;
/** Anruf laeuft → Chunks werden nur in den Cache-Buffer gepusht, nicht
* abgespielt. Wird in pauseForCall gesetzt, in endCallPause/resumeFrom-
* Interruption zurueckgenommen. */
private _pausedForCall: boolean = false;
setMuted(muted: boolean): void {
console.log('[Audio] setMuted: %s (currentSound=%s pcmStreamActive=%s)',
muted, this.currentSound ? 'aktiv' : 'null', this.pcmStreamActive);
this._muted = muted;
if (muted) this.stopPlayback();
}
isMuted(): boolean { return this._muted; }
/** Laufende Wiedergabe stoppen + Queue leeren */
stopPlayback(): void {
console.log('[Audio] stopPlayback: currentSound=%s queue=%d pcm=%s',
this.currentSound ? 'aktiv' : 'null', this.audioQueue.length, this.pcmStreamActive);
// Foreground-Service auch stoppen — sonst bleibt die Notification haengen
// wenn Wiedergabe abgebrochen wird (Anruf, Cancel, Barge-In).
stopBackgroundAudio().catch(() => {});
this.audioQueue = [];
this.isPlaying = false;
if (this.currentSound) {
@@ -772,20 +1187,26 @@ class AudioService {
this.currentSound.release();
this.currentSound = null;
}
if (this.resumeSound) {
this.resumeSound.stop();
this.resumeSound.release();
this.resumeSound = null;
}
if (this.preloadedSound) {
this.preloadedSound.release();
this.preloadedSound = null;
if (this.preloadedPath) RNFS.unlink(this.preloadedPath).catch(() => {});
this.preloadedPath = '';
}
// PCM-Stream ebenfalls hart stoppen (Cancel/Abbruch)
if (this.pcmStreamActive) {
PcmStreamPlayer?.stop().catch(() => {});
this.pcmStreamActive = false;
this.pcmBuffer = [];
this.pcmBytesCollected = 0;
this.pcmMessageId = '';
}
// PCM-Stream ebenfalls hart stoppen (Cancel/Abbruch).
// pcmStreamActive wird beim isFinal-Chunk schon false gesetzt — der
// AudioTrack spielt aber noch sekundenlang aus seinem Buffer ab. Daher
// IMMER stop() aufrufen, ohne den Flag zu pruefen (ist idempotent).
PcmStreamPlayer?.stop().catch(() => {});
this.pcmStreamActive = false;
this.pcmBuffer = [];
this.pcmBytesCollected = 0;
this.pcmMessageId = '';
// Audio-Focus sofort freigeben — User hat explizit abgebrochen
this._cancelDeferredFocusRelease();
AudioFocus?.release().catch(() => {});
+76
View File
@@ -0,0 +1,76 @@
/**
* Background-Audio: ARIAs TTS, Mic-Aufnahme und Wake-Word-Lauschen sollen
* auch bei minimierter App weiterlaufen. Wir starten dafuer einen Foreground-
* Service mit foregroundServiceType=mediaPlayback|microphone, der eine
* persistente Notification zeigt waehrend irgendein Audio-Slot aktiv ist.
*
* Mehrere Komponenten koennen den Service unabhaengig "halten":
* - 'tts' : ARIA spricht
* - 'rec' : Aufnahme laeuft
* - 'wake' : Wake-Word lauscht passiv (Ohr aktiv)
*
* Solange mindestens ein Slot aktiv ist, laeuft der Service. Wenn alle
* Slots leer sind, wird er gestoppt. Der Notification-Text passt sich an
* den hoechstprioren Slot an (tts > rec > wake).
*/
import { NativeModules } from 'react-native';
interface BackgroundAudioNative {
start(reason: string): Promise<boolean>;
stop(): Promise<boolean>;
}
const { BackgroundAudio } = NativeModules as { BackgroundAudio?: BackgroundAudioNative };
type Slot = 'tts' | 'rec' | 'wake';
const slots = new Set<Slot>();
// Prioritaet fuer den Notification-Text — hoechste zuerst.
const PRIORITY: Slot[] = ['tts', 'rec', 'wake'];
function topReason(): string {
for (const s of PRIORITY) {
if (slots.has(s)) return s;
}
return '';
}
async function applyState(): Promise<void> {
if (!BackgroundAudio) return;
if (slots.size === 0) {
try { await BackgroundAudio.stop(); } catch {}
console.log('[BackgroundAudio] Service gestoppt (keine Slots)');
return;
}
const reason = topReason();
try {
await BackgroundAudio.start(reason);
console.log('[BackgroundAudio] Service aktiv (slot=%s, slots=%s)',
reason, [...slots].join('+'));
} catch (err: any) {
console.warn('[BackgroundAudio] start fehlgeschlagen:', err?.message || err);
}
}
export async function acquireBackgroundAudio(slot: Slot): Promise<void> {
if (slots.has(slot)) return;
slots.add(slot);
await applyState();
}
export async function releaseBackgroundAudio(slot: Slot): Promise<void> {
if (!slots.has(slot)) return;
slots.delete(slot);
await applyState();
}
export function backgroundAudioActive(): boolean {
return slots.size > 0;
}
// --- Legacy API (nur tts-Slot) — fuer Aufruf-Sites die noch nichts vom Slot-
// system wissen. Mappt auf den 'tts'-Slot. ---
export const startBackgroundAudio = () => acquireBackgroundAudio('tts');
export const stopBackgroundAudio = () => releaseBackgroundAudio('tts');
+222
View File
@@ -0,0 +1,222 @@
/**
* PhoneCall-Service — pausiert ARIA bei Telefonaten:
*
* 1. Klassischer Mobilfunk-Anruf via TelephonyManager (PhoneCallModule.kt)
* Status: idle / ringing / offhook
*
* 2. VoIP-Anrufe (WhatsApp, Signal, Discord, Telegram, Teams, ...) via
* 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 {
NativeEventEmitter,
NativeModules,
PermissionsAndroid,
Platform,
ToastAndroid,
} from 'react-native';
import audioService from './audio';
import wakeWordService from './wakeword';
interface PhoneCallNative {
start(): Promise<boolean>;
stop(): Promise<boolean>;
}
const { PhoneCall } = NativeModules as { PhoneCall?: PhoneCallNative };
type PhoneState = 'idle' | 'ringing' | 'offhook';
class PhoneCallService {
private started: boolean = false;
private subscription: { remove: () => void } | null = null;
private focusSubscription: { remove: () => void } | null = null;
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> {
if (this.started || Platform.OS !== 'android') return false;
// 1. AudioFocus-Listener IMMER registrieren — fangs VoIP-Calls (WhatsApp,
// Signal, Discord etc.) abdecken, brauchen keine Permission.
try {
const focusEmitter = new NativeEventEmitter(NativeModules.AudioFocus as any);
this.focusSubscription = focusEmitter.addListener(
'AudioFocusChanged',
(e: { type: 'loss' | 'loss_transient' | 'gain' }) => this._onFocusChanged(e.type),
);
console.log('[PhoneCall] AudioFocus-Listener aktiv (fuer VoIP-Calls)');
} catch (err: any) {
console.warn('[PhoneCall] AudioFocus-Subscription gescheitert', err?.message || err);
}
// 2. TelephonyManager-Listener — fuer klassische Mobilfunk-Anrufe
if (PhoneCall) {
try {
const granted = await PermissionsAndroid.request(
PermissionsAndroid.PERMISSIONS.READ_PHONE_STATE,
{
title: 'ARIA Cockpit — Anruf-Erkennung',
message: 'Damit ARIA bei einem eingehenden Anruf nicht weiterredet, '
+ 'darf die App den Anruf-Status sehen (Klingeln/Aktiv/Aufgelegt). '
+ 'Es werden keine Anrufdaten gelesen oder gespeichert.',
buttonPositive: 'Erlauben',
buttonNegative: 'Spaeter',
},
);
if (granted === PermissionsAndroid.RESULTS.GRANTED) {
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);
}
}
this.started = true;
return true;
}
async stop(): Promise<void> {
if (!this.started) return;
try { this.subscription?.remove(); } catch {}
try { this.focusSubscription?.remove(); } catch {}
this.subscription = null;
this.focusSubscription = null;
if (PhoneCall) {
try { await PhoneCall.stop(); } catch {}
}
this.started = false;
this.lastState = 'idle';
this.interruptedByFocus = false;
}
private _onStateChanged(state: PhoneState): void {
if (state === this.lastState) return;
const prev = this.lastState;
console.log('[PhoneCall] State: %s → %s', prev, state);
this.lastState = state;
if (state === 'ringing' || state === 'offhook') {
this._haltForCall(state === 'ringing' ? 'Anruf — ARIA pausiert' : 'Im Gespraech — ARIA pausiert');
} else if (state === 'idle' && prev !== 'idle') {
// Wenn schon durch AudioFocus-Loss pausiert wurde, NICHT doppelt resumen.
// Der Focus-Gain-Event triggert das Resume.
if (!this.interruptedByFocus) {
this._resumeAfterCall('Anruf beendet — ARIA wieder aktiv');
}
}
}
/** 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();
// pauseForCall (statt haltAllPlayback): pcmBuffer + messageId bleiben,
// weitere Chunks werden weiter gesammelt damit isFinal die WAV schreibt.
audioService.pauseForCall(toast);
wakeWordService.pauseForCall().catch(() => {});
ToastAndroid.show(toast, ToastAndroid.SHORT);
}
private _resumeAfterCall(toast: string): void {
// Anruf-Pause aufheben — neue Chunks duerfen wieder direkt abgespielt
// werden (falls die Bridge mid-Anruf isFinal noch nicht geschickt hat).
audioService.endCallPause();
wakeWordService.resumeFromCall().catch(() => {});
ToastAndroid.show(toast, ToastAndroid.SHORT);
// 800ms warten bevor Auto-Resume — sonst kollidiert ARIA's neuer Focus-
// Request mit Spotify's Auto-Resume nach Anruf-Ende. System haengt nach
// dem Auflegen noch im IN_CALL-Mode-Uebergang, Spotify schaut auf Focus-
// Gain und wuerde sofort wieder LOSS sehen → bleibt pausiert.
// Mit Delay: Spotify resumed kurz, dann pausiert ARIA wieder ordnungs-
// gemaess. Wenn ARIA nichts pending hat, bleibt Spotify einfach an.
setTimeout(() => {
audioService.resumeFromInterruption(30000).then(ok => {
if (ok) {
console.log('[PhoneCall] Auto-Resume von gemerkter Position gestartet');
}
}).catch(() => {});
}, 800);
}
}
const phoneCallService = new PhoneCallService();
export default phoneCallService;
+62 -21
View File
@@ -50,28 +50,69 @@ class UpdateService {
});
}
/** Raeumt alte heruntergeladene APK-Dateien aus dem Cache auf. */
private async cleanupOldApks(): Promise<void> {
try {
const files = await RNFS.readDir(RNFS.CachesDirectoryPath);
const apks = files.filter(f => /\.apk$/i.test(f.name));
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}`);
/** Sucht ueberall wo .apk-Dateien herumliegen koennten. */
private async _apkSearchDirs(): Promise<string[]> {
const dirs = [RNFS.CachesDirectoryPath, RNFS.DocumentDirectoryPath];
if ((RNFS as any).ExternalCachesDirectoryPath) {
dirs.push((RNFS as any).ExternalCachesDirectoryPath);
}
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 */
+71
View File
@@ -0,0 +1,71 @@
/**
* Spielt einen kurzen "Bereit"-Sound (Airplane Ding-Dong) wenn das Mikrofon
* nach Wake-Word-Erkennung wirklich offen ist. Datei liegt in
* android/app/src/main/res/raw/wake_ready_sound.mp3 — wird ueber Android's
* Resource-System per react-native-sound abgespielt.
*
* Toggle: AsyncStorage-Key 'aria_wake_ready_sound_enabled' (default true).
*/
import Sound from 'react-native-sound';
import AsyncStorage from '@react-native-async-storage/async-storage';
export const WAKE_READY_SOUND_STORAGE_KEY = 'aria_wake_ready_sound_enabled';
Sound.setCategory('Playback', false);
let cachedSound: Sound | null = null;
let cachedFailed = false;
function getSound(): Promise<Sound | null> {
if (cachedFailed) return Promise.resolve(null);
if (cachedSound) return Promise.resolve(cachedSound);
return new Promise(resolve => {
const s = new Sound('wake_ready_sound', Sound.MAIN_BUNDLE, (err) => {
if (err) {
console.warn('[WakeReadySound] Konnte nicht geladen werden:', err);
cachedFailed = true;
resolve(null);
return;
}
cachedSound = s;
resolve(s);
});
});
}
/** True wenn der User den "Bereit"-Sound aktiviert hat. Default: true. */
export async function isWakeReadySoundEnabled(): Promise<boolean> {
try {
const raw = await AsyncStorage.getItem(WAKE_READY_SOUND_STORAGE_KEY);
if (raw === null) return true; // Default an
return raw === 'true';
} catch {
return true;
}
}
export async function setWakeReadySoundEnabled(enabled: boolean): Promise<void> {
try {
await AsyncStorage.setItem(WAKE_READY_SOUND_STORAGE_KEY, String(enabled));
} catch {}
}
/** Spielt den Bereit-Sound einmal ab — non-blocking. Wenn der User ihn
* in den Settings deaktiviert hat oder die Datei nicht ladbar ist,
* passiert einfach nichts. */
export async function playWakeReadySound(): Promise<void> {
if (!(await isWakeReadySoundEnabled())) return;
const s = await getSound();
if (!s) return;
try {
s.stop(() => {
s.setCurrentTime(0);
s.play((success) => {
if (!success) console.warn('[WakeReadySound] Wiedergabe fehlgeschlagen');
});
});
} catch (e) {
console.warn('[WakeReadySound] play() Exception:', e);
}
}
+125 -2
View File
@@ -22,6 +22,7 @@
import { NativeEventEmitter, NativeModules, ToastAndroid } from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { acquireBackgroundAudio } from './backgroundAudio';
type WakeWordCallback = () => void;
type StateCallback = (state: WakeWordState) => void;
@@ -72,6 +73,19 @@ class WakeWordService {
private state: WakeWordState = 'off';
private wakeCallbacks: WakeWordCallback[] = [];
private stateCallbacks: StateCallback[] = [];
/** Barge-In-Callbacks: feuern wenn Wake-Word WAEHREND ARIA spricht erkannt
* wird. ChatScreen reagiert mit TTS-stop + neuer Aufnahme. */
private bargeCallbacks: WakeWordCallback[] = [];
/** True solange Wake-Word parallel zu TTS aktiv ist. */
private bargeListening: boolean = false;
/** 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 nativeReady: boolean = false;
@@ -152,6 +166,10 @@ class WakeWordService {
/** Ohr-Button gedrueckt — startet passives Lauschen oder direkt Konversation. */
async start(): Promise<boolean> {
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) {
try {
await OpenWakeWord.start();
@@ -191,16 +209,42 @@ class WakeWordService {
if (this.nativeReady && OpenWakeWord) {
try { await OpenWakeWord.stop(); } catch {}
}
this.bargeListening = false;
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. */
private async onWakeDetected(): Promise<void> {
console.log('[WakeWord] Wake-Word "%s" erkannt!', this.keyword);
ToastAndroid.show(`Wake-Word "${KEYWORD_LABELS[this.keyword]}" erkannt — sprich jetzt`, ToastAndroid.SHORT);
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)',
this.keyword, this.state, this.bargeListening);
if (this.nativeReady && OpenWakeWord) {
try { await OpenWakeWord.stop(); } catch {}
}
this.bargeListening = false;
// Wenn wir bereits in 'conversing' sind und der Trigger waehrend ARIAs TTS
// kam (Barge-In via Wake-Word), feuern wir einen separaten Callback damit
// ChatScreen das TTS abbrechen + neue Aufnahme starten kann. Sonst normal.
if (this.state === 'conversing') {
this.bargeCallbacks.forEach(cb => {
try { cb(); } catch (e) { console.warn('[WakeWord] barge cb err:', e); }
});
// Kein erneutes setState — wir bleiben in 'conversing'.
return;
}
this.setState('conversing');
setTimeout(() => {
if (this.state === 'conversing') {
@@ -209,6 +253,72 @@ class WakeWordService {
}, 200);
}
/** Wake-Word PARALLEL zur TTS-Wiedergabe lauschen lassen — User kann
* "Computer" sagen waehrend ARIA noch redet, AcousticEchoCanceler im
* Native-Modul verhindert dass ARIAs eigene Stimme triggert.
* Voraussetzung: AudioRecorder muss frei sein (Recording aus). Wenn der
* AudioRecorder gerade laeuft, hat der Vorrang — Wake-Word geht nicht. */
async startBargeListening(): Promise<void> {
if (!this.nativeReady || !OpenWakeWord) return;
if (this.state !== 'conversing') return;
if (this.bargeListening) return;
try {
await OpenWakeWord.start();
this.bargeListening = true;
console.log('[WakeWord] Barge-Listening aktiv (parallel zu TTS)');
} catch (err) {
console.warn('[WakeWord] Barge-Listening start fehlgeschlagen:', err);
}
}
/** Barge-Listening wieder aus — z.B. wenn der AudioRecorder fuer die
* naechste Aufnahme das Mikro braucht. */
async stopBargeListening(): Promise<void> {
if (!this.bargeListening) return;
if (this.nativeReady && OpenWakeWord) {
try { await OpenWakeWord.stop(); } catch {}
}
this.bargeListening = false;
console.log('[WakeWord] Barge-Listening aus');
}
/** 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.
* Mit Wake-Word: zurueck zu 'armed' (Listener wieder an).
* Ohne: zurueck zu 'off'.
@@ -268,6 +378,19 @@ class WakeWordService {
};
}
/** Subscribe auf Barge-In-Events: Wake-Word erkannt waehrend ARIA noch
* spricht. ChatScreen sollte dann TTS abbrechen + neue Aufnahme starten. */
onBargeIn(callback: WakeWordCallback): () => void {
this.bargeCallbacks.push(callback);
return () => {
this.bargeCallbacks = this.bargeCallbacks.filter(cb => cb !== callback);
};
}
isBargeListening(): boolean {
return this.bargeListening;
}
onStateChange(callback: StateCallback): () => void {
this.stateCallbacks.push(callback);
return () => {
+3 -10
View File
@@ -54,13 +54,6 @@ Fuer Web-Anfragen: **WebFetch** oder **Bash mit curl**. Niemals sagen "ich habe
## Stimme
| Stimme | Modell | Wann |
|--------|--------|------|
| **Ramona** (weiblich) | `de_DE-ramona-low` | Alltag, Antworten, Gespraeche (Standard) |
| **Thorsten** (maennlich, tief) | `de_DE-thorsten-high` | Epische Momente, Alarme, besondere Ereignisse |
**Thorsten spricht bei:**
- Build erfolgreich deployed
- Ticket geloest / Aufgabe abgeschlossen
- Kritischer Alarm (Server down, Sicherheitswarnung)
- Wenn Stefan sagt "So soll es sein"
TTS laeuft ueber F5-TTS (Voice Cloning, Gaming-PC). Stefan kann eigene
Stimmen aus Audio-Samples klonen (Diagnostic → Stimmen → Stimme klonen)
und in App + Diagnostic auswaehlen.
+3 -5
View File
@@ -80,10 +80,8 @@ Wenn ein Tool nicht klappt, probiere die Alternative. Niemals sagen "ich habe ke
## Stimme
| Stimme | Modell | Wann |
|--------|--------|------|
| **Ramona** (weiblich) | `de_DE-ramona-low` | Alltag, Antworten, Gespraeche (Standard) |
| **Thorsten** (maennlich, tief) | `de_DE-thorsten-high` | Epische Momente, Alarme, besondere Ereignisse |
TTS laeuft ueber F5-TTS auf der Gamebox (Voice Cloning). Stefan kann
eigene Stimmen aus Audio-Samples klonen und in App/Diagnostic auswaehlen.
## Gedaechtnis (Memory)
@@ -147,4 +145,4 @@ Danach den Eintrag in `memory/MEMORY.md` (Index) verlinken.
### Netzwerk
- **aria-net:** Internes Docker-Netz (proxy, aria-core)
- **RVS:** Rendezvous-Server im Rechenzentrum — Relay fuer die Android-App
- **Bridge:** Voice Bridge (Whisper STT + Piper TTS) — teilt Netzwerk mit aria-core
- **Bridge:** Voice Bridge (orchestriert STT/TTS via Gamebox-Bridges) — teilt Netzwerk mit aria-core
+265 -106
View File
@@ -1,17 +1,13 @@
"""
ARIA Voice Bridge — Hauptmodul.
Verbindet die Android App (via RVS) mit ARIA-Core und bietet
lokale Spracheingabe (Wake-Word + Whisper STT) und Sprachausgabe (Piper TTS).
Verbindet die Android App (via RVS) mit ARIA-Core. Spracheingabe laeuft
ueber die whisper-bridge (Gamebox, faster-whisper auf CUDA), Sprachausgabe
ueber die f5tts-bridge (Voice Cloning, satzweises PCM-Streaming).
Nachrichtenfluss:
App → RVS → Bridge → aria-core
aria-core → Bridge → RVS → App
→ Lautsprecher (TTS)
Stimmen:
- Ramona (de_DE-ramona-low) — Alltag, Gespraeche
- Thorsten (de_DE-thorsten-high) — epische Momente, Alarme
aria-core → Bridge → f5tts-bridge → PCM → RVS → App
"""
from __future__ import annotations
@@ -493,7 +489,7 @@ class ARIABridge:
self.current_mode = self._load_persisted_mode()
self.running = False
# Komponenten (TTS: immer XTTS remote, Piper wurde entfernt)
# Komponenten (TTS: F5-TTS remote auf der Gamebox, lokales TTS wurde entfernt)
self.tts_enabled = True
self.xtts_voice = ""
self._f5tts_config: dict = {}
@@ -551,6 +547,15 @@ class ARIABridge:
# Beeinflusst das Timeout fuer stt_request — bei "loading" warten wir laenger,
# weil das Modell beim ersten Request noch ~1-2 Min runtergeladen werden kann.
self._remote_stt_ready: bool = False
# Pending Files: wenn die App ein Bild + Text gleichzeitig schickt, kommen
# zwei separate RVS-Events ('file' und 'chat') — wir buffern die Files
# kurz und mergen sie mit dem nachfolgenden Chat-Text zu einer einzigen
# Anfrage an aria-core. Sonst antwortet ARIA zweimal (einmal "warte auf
# Anweisung" beim file, einmal auf den Chat-Text).
# Liste von Tuples: (file_path, name, file_type, size_kb, width, height)
self._pending_files: list[tuple[str, str, str, int, int, int]] = []
self._pending_files_flush_task: Optional[asyncio.Task] = None
self._PENDING_FILES_WINDOW_SEC: float = 0.8
def initialize(self) -> None:
"""Initialisiert alle Komponenten.
@@ -672,7 +677,10 @@ class ARIABridge:
while self.running:
try:
logger.info("[core] Verbinde: %s", self.ws_url)
async with websockets.connect(self.ws_url) as ws:
# max_size=50MB damit grosse Bilder/Voice-Uploads durchgehen.
# Python-websockets Default ist nur 1 MiB → 5MB JPEG sprengt
# das Limit, Connection wird silent gedroppt.
async with websockets.connect(self.ws_url, max_size=50 * 1024 * 1024) as ws:
# OpenClaw Handshake durchfuehren
if not await self._openclaw_handshake(ws):
logger.error("[core] Handshake fehlgeschlagen — Reconnect")
@@ -778,13 +786,29 @@ class ARIABridge:
await self._emit_activity("idle", "")
if not text:
logger.warning("[core] chat final ohne Text: %s", json.dumps(payload)[:200])
# App+Diagnostic informieren statt stumm — sonst wartet die
# UI ewig auf eine Antwort die nicht kommt. Passiert z.B.
# wenn Claude-Vision das Bild ablehnt (leere Antwort)
# oder die Antwort nur aus Tool-Calls ohne Final-Text bestand.
await self._send_to_rvs({
"type": "chat",
"payload": {
"text": "[Hinweis] Antwort ohne Text — moeglicherweise Bild zu gross fuer Vision-API oder reine Tool-Ausfuehrung.",
"sender": "aria",
},
"timestamp": int(asyncio.get_event_loop().time() * 1000),
})
return
logger.info("[core] Antwort: '%s'", text[:80])
await self._process_core_response(text, payload)
return
if state == "error":
error = payload.get("error", "Unbekannt")
# OpenClaw nutzt errorMessage statt error bei state=error.
error = (payload.get("error")
or payload.get("errorMessage")
or payload.get("message")
or "Unbekannt")
logger.error("[core] Chat-Fehler: %s", error)
self._last_chat_final_at = asyncio.get_event_loop().time()
await self._emit_activity("idle", "")
@@ -820,7 +844,12 @@ class ARIABridge:
return
if event_name == "chat:error":
error = payload.get("error", payload.get("message", "Unbekannt"))
# OpenClaw legt den echten Text manchmal in errorMessage ab
# (state=error). Vorher wurde nur error/message gechecked → "Unbekannt".
error = (payload.get("error")
or payload.get("errorMessage")
or payload.get("message")
or "Unbekannt")
logger.error("[core] Chat-Fehler (legacy): %s", error)
await self._send_to_rvs({
"type": "chat",
@@ -907,18 +936,13 @@ class ARIABridge:
logger.info("[core] TTS unterdrueckt (Modus: %s)", self.current_mode.config.name)
return
# Voice bestimmen: App-Override fuer diesen Request > globale Default-Voice
# Voice bestimmen: App-Override (gesetzt durch letzten chat-Event) > globale
# Default-Voice. Der Override wird NICHT pro Antwort verbraucht — sonst nutzt
# eine Multi-Turn-Antwort von ARIA (Tool-Use + finale Antwort) ab dem zweiten
# TTS-Call wieder die alte Default-Stimme. Der Override bleibt gueltig bis
# zum naechsten chat-Event, wo er entweder ueberschrieben oder geloescht wird.
xtts_voice = self._next_voice_override or getattr(self, 'xtts_voice', '')
# Override verbrauchen (gilt nur fuer genau diese naechste Antwort)
if self._next_voice_override:
logger.info("[core] Nutze Voice-Override: %s", self._next_voice_override)
self._next_voice_override = None
# Speed ebenfalls aus App-Override nehmen (fallback 1.0)
xtts_speed = self._next_speed_override or 1.0
if self._next_speed_override:
logger.info("[core] Nutze Speed-Override: %.2fx", self._next_speed_override)
self._next_speed_override = None
tts_text = tts_text_preview or text
if not tts_text:
@@ -1024,6 +1048,76 @@ class ARIABridge:
except Exception as e:
logger.debug("[session] Diagnostic nicht erreichbar (%s) — nutze '%s'", e, self._session_key)
def _build_core_text(self, text: str, interrupted: bool = False,
location: Optional[dict] = None) -> str:
"""Baut den Text fuer aria-core mit allen relevanten Hints (Barge-In,
GPS-Position). Hints sind in eckigen Klammern, der eigentliche User-
Text folgt unverandert."""
parts: list[str] = []
if interrupted:
parts.append(
"[Hinweis: Stefan hat dich gerade unterbrochen waehrend du noch "
"gesprochen oder gearbeitet hast. Folgendes ist eine Korrektur, "
"Ergaenzung oder ein Themenwechsel zu deiner letzten Antwort.]"
)
if location and isinstance(location, dict):
lat = location.get("lat")
lon = location.get("lon") or location.get("lng")
if lat is not None and lon is not None:
parts.append(
f"[Stefans aktuelle GPS-Position: {float(lat):.6f}, {float(lon):.6f}. "
f"Nutze die nur wenn die Frage sich auf seinen Standort bezieht. "
f"Erwaehne sie nicht von dir aus, ausser er fragt explizit danach.]"
)
if parts:
return " ".join(parts) + " " + text
return text
def _build_pending_files_message(self, user_text: str) -> str:
"""Baut eine Anweisung an aria-core aus den gepufferten Files + optionalem
User-Text. user_text leer → 'warte auf Anweisung'-Variante."""
parts: list[str] = []
for fp, name, ftype, kb, w, h in self._pending_files:
dim = f" {w}x{h}px" if (w and h) else ""
kind = "Bild" if ftype.startswith("image/") else "Datei"
parts.append(f"- {kind}: {name}{dim} ({ftype}, {kb}KB) liegt unter {fp}")
files_summary = "\n".join(parts)
n = len(self._pending_files)
anhang = "Anhang" if n == 1 else "Anhaenge"
if user_text:
return (f"Stefan hat dir {n} {anhang} geschickt:\n{files_summary}\n\n"
f"Er sagt dazu: \"{user_text}\"")
return (f"Stefan hat dir {n} {anhang} geschickt:\n{files_summary}\n\n"
f"Warte auf seine Anweisung was du damit tun sollst.")
async def _flush_pending_files_after(self, delay: float) -> None:
"""Wenn nach `delay`s kein chat-Text gekommen ist: Files alleine an
aria-core senden ('warte auf Anweisung'-Variante)."""
try:
await asyncio.sleep(delay)
except asyncio.CancelledError:
return
if not self._pending_files:
return
text = self._build_pending_files_message("")
self._pending_files = []
self._pending_files_flush_task = None
await self.send_to_core(text, source="app-file")
async def _flush_pending_files_with_text(self, user_text: str) -> bool:
"""Wenn ein chat-Text reinkommt waehrend Files gepuffert sind:
Files + Text zu einer einzigen aria-core-Nachricht mergen.
Returns True wenn gemerged wurde (Caller soll dann nicht nochmal senden)."""
if not self._pending_files:
return False
if self._pending_files_flush_task and not self._pending_files_flush_task.done():
self._pending_files_flush_task.cancel()
self._pending_files_flush_task = None
text = self._build_pending_files_message(user_text)
self._pending_files = []
await self.send_to_core(text, source="app-file+chat")
return True
async def send_to_core(self, text: str, source: str = "bridge") -> None:
"""Sendet Text an aria-core (OpenClaw chat.send Protokoll)."""
if self.ws_core is None:
@@ -1071,7 +1165,8 @@ class ARIABridge:
try:
url = f"{current_url}?token={self.rvs_token}"
logger.info("[rvs] Verbinde: %s", current_url)
async with websockets.connect(url) as ws:
# max_size=50MB (siehe core-Connect oben — gleicher Grund).
async with websockets.connect(url, max_size=50 * 1024 * 1024) as ws:
self.ws_rvs = ws
retry_delay = 2
logger.info("[rvs] Verbunden — warte auf App-Nachrichten")
@@ -1169,21 +1264,38 @@ class ARIABridge:
if sender in ("aria", "stt"):
return
text = payload.get("text", "")
# Voice-Override fuer die naechste ARIA-Antwort merken
voice_override = payload.get("voice", "")
if voice_override:
self._next_voice_override = voice_override
logger.info("[rvs] Voice-Override fuer naechste Antwort: %s", voice_override)
# Voice-Override fuer Folgenachrichten setzen — gilt bis zum naechsten
# chat-Event. Leerer String "" = explizit Default-Voice (override loeschen).
# Field nicht gesendet = vorherigen Override unveraendert lassen (z.B. wenn
# cancel_request oder anderer Service die App umgeht).
if "voice" in payload:
voice_override = payload.get("voice", "") or ""
self._next_voice_override = voice_override or None
logger.info("[rvs] Voice fuer Antworten: %s",
self._next_voice_override or "(Default)")
# Speed-Override (TTS-Wiedergabegeschwindigkeit, pro Geraet)
try:
speed = float(payload.get("speed", 0) or 0)
if 0.1 <= speed <= 5.0:
self._next_speed_override = speed
except (TypeError, ValueError):
pass
if "speed" in payload:
try:
speed = float(payload.get("speed", 0) or 0)
self._next_speed_override = speed if 0.1 <= speed <= 5.0 else None
except (TypeError, ValueError):
self._next_speed_override = None
if text:
logger.info("[rvs] App-Chat: '%s'", text[:80])
await self.send_to_core(text, source="app")
interrupted = bool(payload.get("interrupted", False))
location = payload.get("location") or None
# Wenn Files gerade gepuffert sind (Bild + Text gleichzeitig
# gesendet), mergen wir sie zu einer einzigen Anfrage statt
# zwei separater send_to_core-Calls.
merged = await self._flush_pending_files_with_text(text)
if merged:
logger.info("[rvs] App-Chat (mit Anhaengen): '%s'", text[:80])
else:
core_text = self._build_core_text(text, interrupted, location)
logger.info("[rvs] App-Chat%s%s: '%s'",
" [BARGE-IN]" if interrupted else "",
" [GPS]" if location else "",
text[:80])
await self.send_to_core(core_text, source="app" + (" [barge-in]" if interrupted else ""))
return
if msg_type == "cancel_request":
@@ -1342,70 +1454,79 @@ class ARIABridge:
await self.ws_core.send(raw_message)
elif msg_type == "file":
# Datei von der App → als Text-Nachricht an aria-core
# Datei von der App: speichern + zu Pending-Queue hinzufuegen.
# Wird mit dem nachfolgenden chat-Event (innerhalb PENDING_FILES_WINDOW)
# zu einer einzigen aria-core-Anfrage gemerged. Sonst antwortet ARIA
# zweimal: einmal "warte auf Anweisung" beim file, einmal auf den Chat.
file_name = payload.get("name", "unbekannt")
file_type = payload.get("type", "")
file_b64 = payload.get("base64", "")
file_size = payload.get("size", 0)
width = payload.get("width", 0)
height = payload.get("height", 0)
logger.info("[rvs] Datei empfangen: %s (%s, %dKB)",
file_name, file_type, len(file_b64) // 1365 if file_b64 else 0)
# Shared Volume: /shared/ ist in Bridge UND aria-core gemountet
SHARED_DIR = "/shared/uploads"
os.makedirs(SHARED_DIR, exist_ok=True)
if file_b64 and file_type.startswith("image/"):
# Bild in Shared Volume speichern
if not file_b64:
text = f"Stefan hat eine Datei gesendet ({file_name}, {file_type}) aber die Daten sind leer angekommen."
await self.send_to_core(text, source="app-file")
return
if file_type.startswith("image/"):
ext = ".jpg" if "jpeg" in file_type or "jpg" in file_type else ".png"
safe_name = f"img_{int(asyncio.get_event_loop().time())}_{file_name.replace('/', '_')}"
file_path = os.path.join(SHARED_DIR, safe_name if safe_name.endswith(ext) else safe_name + ext)
with open(file_path, "wb") as f:
f.write(base64.b64decode(file_b64))
size_kb = len(file_b64) // 1365
logger.info("[rvs] Bild gespeichert: %s (%dKB)", file_path, size_kb)
# ERST an aria-core senden (wichtigster Schritt)
text = (f"Stefan hat dir ein Bild geschickt: {file_name}"
f"{f' ({width}x{height}px)' if width else ''}"
f", {size_kb}KB."
f" Das Bild liegt unter: {file_path}"
f" Warte auf Stefans Anweisung was du damit tun sollst.")
await self.send_to_core(text, source="app-file")
# Dann App informieren (optional, darf nicht crashen)
try:
await self._send_to_rvs({
"type": "file_saved",
"payload": {"name": file_name, "serverPath": file_path, "mimeType": file_type},
"timestamp": int(asyncio.get_event_loop().time() * 1000),
})
except Exception as e:
logger.warning("[rvs] file_saved konnte nicht an App gesendet werden: %s", e)
elif file_b64:
# Andere Datei in Shared Volume speichern
else:
safe_name = f"file_{int(asyncio.get_event_loop().time())}_{file_name.replace('/', '_')}"
file_path = os.path.join(SHARED_DIR, safe_name)
with open(file_path, "wb") as f:
f.write(base64.b64decode(file_b64))
size_kb = len(file_b64) // 1365
logger.info("[rvs] Datei gespeichert: %s (%dKB)", file_path, size_kb)
# ERST an aria-core senden
text = (f"Stefan hat dir eine Datei geschickt: {file_name}"
f" ({file_type}, {size_kb}KB)."
f" Die Datei liegt unter: {file_path}"
f" Warte auf Stefans Anweisung was du damit tun sollst.")
await self.send_to_core(text, source="app-file")
try:
await self._send_to_rvs({
"type": "file_saved",
"payload": {"name": file_name, "serverPath": file_path, "mimeType": file_type},
"timestamp": int(asyncio.get_event_loop().time() * 1000),
})
except Exception as e:
logger.warning("[rvs] file_saved konnte nicht an App gesendet werden: %s", e)
else:
text = f"Stefan hat eine Datei gesendet ({file_name}, {file_type}) aber die Daten sind leer angekommen."
await self.send_to_core(text, source="app-file")
with open(file_path, "wb") as f:
f.write(base64.b64decode(file_b64))
size_kb = len(file_b64) // 1365
logger.info("[rvs] Datei gespeichert: %s (%dKB)", file_path, size_kb)
# Pixel-Bilder fuer Claude-Vision shrinken wenn > 2 MB. SVG/PDF/ZIP
# bleiben unangetastet (Vision laeuft eh nur auf Raster-Formaten).
CLAUDE_VISION_FORMATS = ("image/jpeg", "image/jpg", "image/png", "image/webp", "image/gif")
if file_type.lower() in CLAUDE_VISION_FORMATS:
file_size_bytes = os.path.getsize(file_path)
if file_size_bytes > 2 * 1024 * 1024:
try:
from PIL import Image
with Image.open(file_path) as img:
orig_w, orig_h = img.size
# Anthropic-Empfehlung: max 1568px lange Seite. RGB-Konvertierung
# falls RGBA/Palette (JPEG braucht RGB).
img.thumbnail((1568, 1568), Image.Resampling.LANCZOS)
if img.mode in ("RGBA", "P"):
img = img.convert("RGB")
img.save(file_path, "JPEG", quality=85, optimize=True)
new_size_bytes = os.path.getsize(file_path)
logger.info("[rvs] Bild verkleinert: %dx%d%dx%d, %.1fMB → %.1fMB",
orig_w, orig_h, img.size[0], img.size[1],
file_size_bytes / 1024 / 1024,
new_size_bytes / 1024 / 1024)
except Exception as e:
logger.warning("[rvs] Bild-Resize fehlgeschlagen (%s) — Original wird genutzt: %s",
file_name, e)
# In Pending-Queue + Flush-Timer (anti-spam Buffering)
self._pending_files.append((file_path, file_name, file_type, size_kb, int(width or 0), int(height or 0)))
if self._pending_files_flush_task and not self._pending_files_flush_task.done():
self._pending_files_flush_task.cancel()
self._pending_files_flush_task = asyncio.create_task(
self._flush_pending_files_after(self._PENDING_FILES_WINDOW_SEC)
)
try:
await self._send_to_rvs({
"type": "file_saved",
"payload": {"name": file_name, "serverPath": file_path, "mimeType": file_type},
"timestamp": int(asyncio.get_event_loop().time() * 1000),
})
except Exception as e:
logger.warning("[rvs] file_saved konnte nicht an App gesendet werden: %s", e)
elif msg_type == "file_request":
# App fordert eine Datei an (Re-Download nach Cache-Leerung)
@@ -1444,20 +1565,28 @@ class ARIABridge:
if not audio_b64:
logger.warning("[rvs] Audio ohne Daten empfangen")
return
# Voice-Override fuer die kommende ARIA-Antwort (App-lokal gewaehlt)
voice_override = payload.get("voice", "")
if voice_override:
self._next_voice_override = voice_override
logger.info("[rvs] Voice-Override (via Audio): %s", voice_override)
try:
speed = float(payload.get("speed", 0) or 0)
if 0.1 <= speed <= 5.0:
self._next_speed_override = speed
except (TypeError, ValueError):
pass
logger.info("[rvs] Audio empfangen: %s, %dms, %dKB",
mime_type, duration_ms, len(audio_b64) // 1365)
asyncio.create_task(self._process_app_audio(audio_b64, mime_type))
# Voice-Override fuer Folgenachrichten — gleiche Semantik wie beim chat-Event.
if "voice" in payload:
voice_override = payload.get("voice", "") or ""
self._next_voice_override = voice_override or None
logger.info("[rvs] Voice fuer Antworten (via Audio): %s",
self._next_voice_override or "(Default)")
if "speed" in payload:
try:
speed = float(payload.get("speed", 0) or 0)
self._next_speed_override = speed if 0.1 <= speed <= 5.0 else None
except (TypeError, ValueError):
self._next_speed_override = None
interrupted = bool(payload.get("interrupted", False))
audio_request_id = payload.get("audioRequestId", "") or ""
location = payload.get("location") or None
logger.info("[rvs] Audio empfangen: %s, %dms, %dKB%s%s%s",
mime_type, duration_ms, len(audio_b64) // 1365,
" [BARGE-IN]" if interrupted else "",
" [GPS]" if location else "",
f" reqId={audio_request_id[:16]}" if audio_request_id else "")
asyncio.create_task(self._process_app_audio(
audio_b64, mime_type, interrupted, audio_request_id, location))
elif msg_type == "stt_response":
# Antwort der whisper-bridge auf unseren stt_request
@@ -1513,8 +1642,23 @@ class ARIABridge:
_STT_REMOTE_TIMEOUT_READY_S = 45.0
_STT_REMOTE_TIMEOUT_LOADING_S = 300.0
async def _process_app_audio(self, audio_b64: str, mime_type: str) -> None:
"""App-Audio → STT → aria-core. Primaer via whisper-bridge (RVS), Fallback lokal."""
async def _process_app_audio(self, audio_b64: str, mime_type: str,
interrupted: bool = False,
audio_request_id: str = "",
location: Optional[dict] = None) -> None:
"""App-Audio → STT → aria-core. Primaer via whisper-bridge (RVS), Fallback lokal.
interrupted=True wenn der User waehrend ARIA noch sprach/dachte aufgenommen hat
(Barge-In). Wird als Hinweis-Praefix an aria-core mitgegeben damit ARIA die
Korrektur/Unterbrechung in den Kontext einordnen kann statt als reine
Folgefrage zu behandeln.
audio_request_id: Korrelations-ID die die App im audio-Event mitschickt — wird
unveraendert ans STT-Result zurueckgegeben damit die App die EXAKT richtige
'wird verarbeitet'-Bubble ersetzen kann (auch bei mehreren parallelen Aufnahmen).
location: Optional GPS-Position {lat, lon} — wird als Hinweis-Praefix mitgegeben
damit ARIA bei standortbezogenen Fragen sie nutzen kann."""
# Erst Remote versuchen
text = await self._stt_remote(audio_b64, mime_type)
if text is None:
@@ -1526,19 +1670,34 @@ class ARIABridge:
if text.strip():
logger.info("[rvs] STT Ergebnis: '%s'", text[:80])
# Hints (Barge-In, GPS) als Praefix vorschalten — gemeinsamer Helper
# mit dem chat-Pfad damit das Verhalten konsistent ist.
core_text = self._build_core_text(text, interrupted, location)
# ERST an aria-core senden (wichtigster Schritt)
await self.send_to_core(text, source="app-voice")
await self.send_to_core(core_text, source="app-voice" + (" [barge-in]" if interrupted else ""))
# STT-Text an RVS senden (fuer Anzeige in App + Diagnostic)
# sender="stt" damit Bridge es ignoriert (kein Loop)
try:
await self._send_to_rvs({
stt_payload = {
"text": text,
"sender": "stt",
}
if audio_request_id:
stt_payload["audioRequestId"] = audio_request_id
# GPS aus dem Original-Audio-Payload mitgeben — Diagnostic
# zeigt sie sonst nicht an (App sendet location nur einmal,
# die im audio-Payload). Reine Anzeige-Information.
if location:
stt_payload["location"] = location
ok = await self._send_to_rvs({
"type": "chat",
"payload": {
"text": text,
"sender": "stt",
},
"payload": stt_payload,
"timestamp": int(asyncio.get_event_loop().time() * 1000),
})
if ok:
logger.info("[rvs] STT-Text an RVS broadcastet (sender=stt)")
else:
logger.warning("[rvs] STT-Text NICHT broadcastet — _send_to_rvs lieferte False")
except Exception as e:
logger.warning("[rvs] STT-Text konnte nicht an RVS gesendet werden: %s", e)
else:
+3
View File
@@ -16,3 +16,6 @@ sounddevice
# Wake-Word Erkennung
openwakeword
# Bild-Resizing (zu grosse Pixel-Bilder shrinken bevor Claude-Vision sie sieht — 5MB-Limit)
Pillow
+70 -60
View File
@@ -63,24 +63,35 @@
.log-entry.pipeline-sep { color: #333; margin: 6px 0 2px; }
.chat-box { background: #080810; border: 1px solid #1E1E2E; border-radius: 6px;
min-height: 120px; max-height: 250px; overflow-y: auto; padding: 8px; margin-bottom: 8px; }
.chat-msg { margin-bottom: 6px; padding: 6px 10px; border-radius: 6px; font-size: 13px; line-height: 1.5; word-wrap: break-word; }
.chat-msg.sent { background: #0096FF; color: #fff; margin-left: 20%; text-align: right; }
.chat-msg.received { background: #1E1E2E; margin-right: 20%; }
.chat-msg.error { background: #3B1010; color: #FF6B6B; }
.chat-msg .meta { font-size: 10px; color: rgba(255,255,255,0.4); margin-top: 2px; }
min-height: 120px; max-height: 250px; overflow-y: auto;
padding: 12px; margin-bottom: 8px; display: flex; flex-direction: column; gap: 8px; }
.chat-msg { padding: 10px 14px; border-radius: 14px; font-size: 14px; line-height: 1.5;
word-wrap: break-word; max-width: 80%; white-space: pre-wrap;
box-shadow: 0 1px 2px rgba(0,0,0,0.4); }
.chat-msg.sent { background: #0096FF; color: #fff; align-self: flex-end;
border-bottom-right-radius: 4px; }
.chat-msg.received { background: #1E1E2E; color: #E8E8F0; align-self: flex-start;
border-bottom-left-radius: 4px; }
.chat-msg.error { background: #3B1010; color: #FF6B6B; align-self: flex-start; }
.chat-msg .meta { font-size: 10px; color: rgba(255,255,255,0.4); margin-top: 4px;
display: block; }
.chat-msg a { color: #66BBFF; text-decoration: underline; }
.chat-msg.sent a { color: #CCEEFF; }
.chat-msg .chat-media { max-width: 100%; max-height: 200px; border-radius: 4px; margin-top: 4px; cursor: pointer; display: block; }
.chat-msg .chat-media { max-width: 100%; max-height: 200px; border-radius: 8px;
margin-top: 6px; cursor: pointer; display: block; }
.chat-msg .chat-media:hover { opacity: 0.85; }
.lightbox-overlay { display:none; position:fixed; top:0; left:0; right:0; bottom:0; background:rgba(0,0,0,0.92);
z-index:2000; justify-content:center; align-items:center; cursor:pointer; }
.lightbox-overlay.open { display:flex; }
.lightbox-overlay img, .lightbox-overlay video { max-width:95vw; max-height:95vh; border-radius:8px; }
.input-row { display: flex; gap: 6px; }
.input-row input { flex: 1; background: #1E1E2E; border: 1px solid #333; border-radius: 6px;
padding: 8px 12px; color: #E0E0F0; font-family: inherit; font-size: 13px; }
.input-row { display: flex; gap: 6px; align-items: flex-end; }
.input-row input, .input-row textarea {
flex: 1; background: #1E1E2E; border: 1px solid #333; border-radius: 6px;
padding: 8px 12px; color: #E0E0F0; font-family: inherit; font-size: 13px;
}
.input-row textarea { resize: none; min-height: 38px; max-height: 200px; line-height: 1.4;
overflow-y: auto; }
/* Terminal Modal */
.modal-overlay { display:none; position:fixed; top:0; left:0; right:0; bottom:0; background:rgba(0,0,0,0.85);
@@ -267,6 +278,10 @@
<input type="checkbox" id="tts-debug-toggle" onchange="toggleTtsDebug()" style="margin-right:4px;vertical-align:middle;">
TTS-Text einblenden
</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>
</div>
</div>
@@ -282,7 +297,7 @@
&#x1F4CE;
<input type="file" id="diag-file-input" multiple accept="image/*,application/pdf,.doc,.docx,.txt" style="display:none;" onchange="handleDiagFileSelect(this.files)">
</label>
<input type="text" id="chat-input" placeholder="Nachricht an ARIA..." onpaste="handleDiagPaste(event)">
<textarea id="chat-input" placeholder="Nachricht an ARIA... (Enter sendet, Shift+Enter neue Zeile)" rows="2" onpaste="handleDiagPaste(event)" oninput="autoResizeTextarea(this)"></textarea>
<button class="btn" id="btn-gw" onclick="testGateway()">Gateway senden</button>
<button class="btn" id="btn-rvs" onclick="testRVS()">Via RVS senden</button>
</div>
@@ -300,7 +315,7 @@
<span style="animation:pulse 1s infinite;">&#x1F4AD;</span> <span id="thinking-text-fs">ARIA denkt...</span>
</div>
<div class="input-row" style="margin-top:8px;">
<input type="text" id="chat-input-fs" placeholder="Nachricht an ARIA..." onkeydown="if(event.key==='Enter'){testRVSFS();event.preventDefault();}">
<textarea id="chat-input-fs" placeholder="Nachricht an ARIA... (Enter sendet, Shift+Enter neue Zeile)" rows="2" oninput="autoResizeTextarea(this)"></textarea>
<button class="btn" onclick="testGatewayFS()">Gateway senden</button>
<button class="btn" onclick="testRVSFS()">Via RVS senden</button>
</div>
@@ -654,24 +669,6 @@
</div>
</div>
<!-- Highlight-Trigger -->
<div class="settings-section">
<h2>Highlight-Trigger</h2>
<div style="font-size:11px;color:#8888AA;margin-bottom:8px;">
Woerter die automatisch die Highlight-Stimme (Thorsten) ausloesen.
Eines pro Zeile. Aenderungen werden in der Bridge gespeichert.
</div>
<div class="card" style="max-width:500px;">
<textarea id="highlight-triggers" rows="8" style="width:100%;box-sizing:border-box;background:#1E1E2E;border:1px solid #2A2A3E;border-radius:6px;padding:8px;color:#fff;font-size:13px;font-family:monospace;resize:vertical;"
placeholder="Lade..."></textarea>
<div style="display:flex;gap:8px;margin-top:8px;">
<button class="btn" onclick="saveHighlightTriggers()" style="flex:1;">Speichern</button>
<button class="btn secondary" onclick="loadHighlightTriggers()" style="flex:1;">Neu laden</button>
</div>
<div id="trigger-status" style="font-size:11px;color:#555570;margin-top:6px;"></div>
</div>
</div>
<!-- Tool-Berechtigungen -->
<div class="settings-section">
<h2>Tool-Berechtigungen</h2>
@@ -945,14 +942,6 @@
return;
}
if (msg.type === 'trigger_list') {
const textarea = document.getElementById('highlight-triggers');
textarea.value = (msg.triggers || []).join('\n');
document.getElementById('trigger-status').textContent = msg.triggers.length + ' Trigger geladen';
document.getElementById('trigger-status').style.color = '#8888AA';
return;
}
if (msg.type === 'service_status') {
updateServiceStatus(msg.payload || {});
return;
@@ -1019,7 +1008,7 @@
if (sender === 'aria') return;
const chatType = 'sent';
const label = sender === 'stt' ? '\uD83C\uDFA4 Spracheingabe' : `via RVS (${sender})`;
addChat(chatType, p.text || '?', label);
addChat(chatType, p.text || '?', label, { location: p.location });
return;
}
if (msg.type === 'proxy_result') {
@@ -1410,6 +1399,16 @@
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
function previewTtsText(text) {
if (!text) return '';
@@ -1449,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>`;
}
}
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
updateThinkingIndicator({ activity: 'idle' });
@@ -1947,20 +1957,6 @@
}
}
// ── Highlight-Trigger ────────────────────────
function loadHighlightTriggers() {
send({ action: 'get_triggers' });
}
function saveHighlightTriggers() {
const text = document.getElementById('highlight-triggers').value;
const triggers = text.split('\n').map(t => t.trim()).filter(t => t.length > 0);
send({ action: 'save_triggers', triggers });
document.getElementById('trigger-status').textContent = 'Gespeichert (' + triggers.length + ' Trigger)';
document.getElementById('trigger-status').style.color = '#34C759';
}
// Beim Tab-Wechsel zu Einstellungen: Trigger laden
const origSwitchMainTab = typeof switchMainTab === 'function' ? switchMainTab : null;
// ── Modus-Wechsel ────────────────────────────
// Kanonische IDs (matchen bridge/modes.py canonical_id + android ModeSelector)
const MODE_LABELS = { normal: 'Normal', nicht_stoeren: 'Nicht stoeren', fluester: 'Fluestern', hangar: 'Hangar', gaming: 'Gaming' };
@@ -2069,10 +2065,23 @@
return str.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
// Enter-Taste sendet via Gateway
document.getElementById('chat-input').addEventListener('keydown', (e) => {
if (e.key === 'Enter') testRVS();
});
// Auto-Resize fuer Textarea — wuchst mit dem Inhalt bis zum max-height
function autoResizeTextarea(el) {
el.style.height = 'auto';
el.style.height = Math.min(el.scrollHeight, 200) + 'px';
}
// Enter sendet, Shift+Enter macht neue Zeile (chat-Standard).
function chatInputKeydown(e, sendFn) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendFn();
// Textarea zurueck auf 2 rows setzen
e.target.style.height = 'auto';
}
}
document.getElementById('chat-input').addEventListener('keydown', (e) => chatInputKeydown(e, testRVS));
document.getElementById('chat-input-fs').addEventListener('keydown', (e) => chatInputKeydown(e, testRVSFS));
// Escape schliesst Lightbox
document.addEventListener('keydown', (e) => {
@@ -2432,9 +2441,8 @@
document.querySelectorAll('.main-nav-btn').forEach(b => {
if (b.textContent.trim().toLowerCase().includes(tab === 'main' ? 'main' : 'einstellung')) b.classList.add('active');
});
// Einstellungen: Config + Trigger + QR laden
// Einstellungen: Config + QR laden
if (tab === 'settings') {
loadHighlightTriggers();
send({ action: 'get_voice_config' });
loadRuntimeConfig();
loadOnboardingQR();
@@ -2468,6 +2476,8 @@
// Toggle-Checkbox initial korrekt setzen
const ttsToggleEl = document.getElementById('tts-debug-toggle');
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)
function updateDiskBanner(status) {
+14 -30
View File
@@ -716,11 +716,24 @@ function sendToRVS_withResponse(sendType, sendPayload, expectType, clientWs) {
function sendToRVS_raw(msgObj) {
if (!RVS_HOST || !RVS_TOKEN) return;
const payload = JSON.stringify(msgObj);
// Persistente Connection bevorzugen — die ist garantiert connected
// und wird vom RVS direkt an alle anderen Clients (App, Bridge) broadcastet.
// Frische Connections hatten Race-Probleme: die WS war nach dem send manchmal
// schon zu, bevor RVS broadcasten konnte → App-Nachrichten verloren.
if (rvsWs && rvsWs.readyState === WebSocket.OPEN) {
try {
rvsWs.send(payload);
return;
} catch (err) {
log("warn", "rvs", `persistente Verbindung send failed (${err.message}) — Fallback frische WS`);
}
}
const proto = RVS_TLS === "true" ? "wss" : "ws";
const url = `${proto}://${RVS_HOST}:${RVS_PORT}?token=${RVS_TOKEN}`;
const freshWs = new WebSocket(url);
freshWs.on("open", () => {
freshWs.send(JSON.stringify(msgObj));
freshWs.send(payload);
setTimeout(() => { try { freshWs.close(); } catch (_) {} }, 5000);
});
freshWs.on("error", () => {});
@@ -1462,10 +1475,6 @@ wss.on("connection", (ws) => {
} catch {}
sendToRVS_raw({ type: "config", payload: voiceConfig, timestamp: Date.now() });
log("info", "server", `Voice-Config gespeichert: xttsVoice=${voiceConfig.xttsVoice || "default"}, whisper=${voiceConfig.whisperModel || "-"}`);
} else if (msg.action === "get_triggers") {
handleGetTriggers(ws);
} else if (msg.action === "save_triggers") {
handleSaveTriggers(ws, msg.triggers || []);
} else if (msg.action === "test_tts") {
handleTestTTS(ws, msg.text || "Test");
} else if (msg.action === "preview_voice") {
@@ -1616,31 +1625,6 @@ function handleGetVoiceConfig(clientWs) {
}
}
// ── Highlight-Trigger (legacy UI — wird nicht mehr ausgewertet seit Piper raus) ─
const TRIGGERS_FILE = "/shared/config/highlight_triggers.json";
async function handleGetTriggers(clientWs) {
try {
const triggers = fs.existsSync(TRIGGERS_FILE)
? JSON.parse(fs.readFileSync(TRIGGERS_FILE, "utf-8"))
: [];
clientWs.send(JSON.stringify({ type: "trigger_list", triggers }));
} catch (err) {
clientWs.send(JSON.stringify({ type: "trigger_list", triggers: [], error: err.message }));
}
}
async function handleSaveTriggers(clientWs, triggers) {
try {
fs.mkdirSync("/shared/config", { recursive: true });
fs.writeFileSync(TRIGGERS_FILE, JSON.stringify(triggers, null, 2));
log("info", "server", `${triggers.length} Highlight-Trigger gespeichert`);
clientWs.send(JSON.stringify({ type: "trigger_list", triggers }));
} catch (err) {
log("error", "server", `Trigger speichern fehlgeschlagen: ${err.message}`);
}
}
// ── TTS Diagnose (XTTS) ───────────────────────────────
// ── Voice Preview ────────────────────────────────────────
// Sammelt audio_pcm Chunks einer Preview-Anfrage, baut am Ende eine WAV
+1 -1
View File
@@ -9,7 +9,7 @@ services:
command: >-
sh -c "apk add --no-cache openssh-client bash curl &&
npm install -g @anthropic-ai/claude-code claude-max-api-proxy &&
DIST=$(find /usr/local/lib -path '*/claude-max-api-proxy/dist' -type d | head -1) &&
DIST=$$(find /usr/local/lib -path '*/claude-max-api-proxy/dist' -type d | head -1) &&
sed -i 's/startServer({ port })/startServer({ port, host: process.env.HOST || \"127.0.0.1\" })/' $$DIST/server/standalone.js &&
sed -i 's/if (model\.includes/if ((model||\"claude-sonnet-4\").includes/g' $$DIST/adapter/cli-to-openai.js &&
sed -i '1i\\function _t(c){return typeof c===\"string\"?c:Array.isArray(c)?c.filter(function(b){return b.type===\"text\"}).map(function(b){return b.text||\"\"}).join(\"\"):String(c)}' $$DIST/adapter/openai-to-cli.js &&
+118 -22
View File
@@ -1,7 +1,97 @@
# 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
### Bugs / Fixes
- [x] Diagnostic: "ARIA denkt..." bleibt nicht mehr stehen
- [x] App: "ARIA denkt..." Indicator + Abbrechen-Button (Bridge spiegelt agent_activity via RVS)
- [x] Textnachrichten werden von ARIA beantwortet (Bridge chat handler fix)
- [x] Voice-Auswahl funktioniert wieder: speaker_wav als Basename statt Pfad fuer daswer123 local-Mode
- [x] Diagnostic-Voice-Wechsel resettet alle App-lokalen Voice-Overrides via type "config"
- [x] Streaming TTS Stop-Race: Writer wartet auf playbackHeadPosition vor stop()/release() — keine abgeschnittenen Saetze mehr
- [x] App: Audioausgabe hoert nicht mehr mitten im Satz auf (playbackHeadPosition wait + Stop-Race fix)
- [x] AudioFocus.release wartet auf echten Playback-Ende — kein Volume-Hochfahren mehr mid-Antwort
- [x] App Mute-/Auto-Playback-Bug: Closure-Bug geloest (ttsCanPlayRef live-gespiegelt, nicht mehr stale)
- [x] App Zombie-Recording: Ohr-aus kill laufende Aufnahme damit der Aufnahme-Button weiter funktioniert
- [x] Whisper transkribiert Voice-Uploads nicht mehr mit hardcoded "small" — aktuelles Modell wird behalten, kein unnoetiger Modell-Swap
- [x] RVS/WebSocket maxPayload 50MB: voice_upload mit WAV als base64 sprengt kein Frame-Limit mehr
- [x] Wake-Word Embedding rank-4 Fix (Pipeline-Bug der das Triggern verhinderte) + Frame-Count aus Modell-Metadaten lesen
- [x] PCM-Underrun-Schutz: Stille-Fill in Render-Pausen verhindert Spotify-Auto-Resume nach 10s Stillstand
- [x] Conversation-Focus-Lifecycle: AudioFocus haengt am Wake-Word-State 'conversing' statt an einzelnen Streams — Spotify bleibt durchgehend gepaust, auch zwischen mehreren Antworten
- [x] Voice-Override behaelt Stimme ueber alle TTS-Calls einer Antwort (vorher: nach erstem TTS-Call zurueck auf Default)
- [x] Sprachnachricht-Bubble defensiv: STT-Result fuegt neue Bubble hinzu wenn Placeholder fehlt (Race-Schutz)
- [x] Bild + Text als EINE Anfrage: Bridge buffert files 800ms, merged mit folgendem chat-Text zu einem send_to_core (statt zwei getrennten ARIA-Antworten)
- [x] Diagnostic→App: persistente RVS-Connection statt frische pro Send (Race-Probleme mit Zombie-WS geloest)
- [x] Textauswahl in Bubbles wieder funktional (nested Text+onPress raus, dataDetectorType="all" macht Links automatisch klickbar)
- [x] **Placeholder-Race bei parallelen Sprachnachrichten geloest**: jede Aufnahme bekommt eine eindeutige audioRequestId, Bridge gibt sie ans STT-Result zurueck — App matcht jetzt punktgenau die richtige Bubble statt per Substring
- [x] Mikro-Offen-Toast "🎤 sprich jetzt" erscheint erst wenn audioService.startRecording wirklich erfolgreich war (statt ~400ms vorher beim Wake-Word-Detect)
- [x] Sprachnachrichten ohne STT-Result werden nach 60s+Aufnahmedauer automatisch entfernt (sicher genug fuer 5-30min-Aufnahmen, schnell genug fuer leere Wake-Word-Echos)
- [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] 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
- [x] **Kurze TTS-Texte (1-3 Worte) spielen jetzt ab** — auf OnePlus A12 stallte AudioTrack mit `pos=0` weil der Default-Start-Threshold `bufferSize/2` (= 2s) bei kurzen Streams nie ueberschritten wurde. Fix: `setStartThresholdInFrames(100ms)` direkt nach dem Track-Build (API 31+). Buffer auf 4s entkoppelt von Pre-Roll, `play()` wird beim allerersten data-chunk gerufen
- [x] **Mute-Button stoppt jetzt auch laufenden PCM-Stream**`pcmStreamActive` wurde beim isFinal-Chunk schon false gesetzt, der AudioTrack spielte aber noch sekundenlang aus seinem Buffer. `stopPlayback()` uebersprang darum `PcmStreamPlayer.stop()`. Fix: stop() immer rufen (ist idempotent), kein Flag-Check mehr
- [x] **GPS-Permission im Manifest + Runtime-Request** beim Settings-Toggle — vorher fehlten ACCESS_COARSE_LOCATION / ACCESS_FINE_LOCATION komplett. `Geolocation.getCurrentPosition` schlug lautlos fehl, App sendete nie ein location-Feld
- [x] **GPS-Position auch im STT-Payload an Diagnostic** — die App sendet location einmal im audio-Payload. Die Bridge nutzte sie zwar (ging in aria-core's Kontext rein), reichte sie aber nicht im STT-broadcast an Diagnostic durch. Diagnostic zeigte darum bei Spracheingaben nie den GPS-Block, obwohl der "GPS einblenden"-Toggle aktiv war
### App Features
- [x] Bildupload funktioniert (Shared Volume /shared/uploads/)
- [x] Sprachnachrichten werden als Text angezeigt (STT → Chat-Bubble)
- [x] Cache leeren + Auto-Download von Anhaengen
@@ -11,13 +101,9 @@
- [x] Ohr-Button → Gespraechsmodus (Auto-Aufnahme nach ARIA-Antwort)
- [x] Play-Button in ARIA-Nachrichten fuer Sprachwiedergabe
- [x] Chat-Suche in der App (Lupe in Statusleiste)
- [x] Watchdog mit Container-Restart (2min Warnung → 5min doctor --fix → 8min Restart)
- [x] Abbrechen-Button im Diagnostic Chat
- [x] Nachrichten Backup on-the-fly (/shared/config/chat_backup.jsonl)
- [x] Grosse Nachrichten satzweise aufteilen fuer TTS
- [x] RVS Nachrichten vom Smartphone gehen durch
- [x] Stimmen-Einstellungen (Ramona/Thorsten, Speed pro Stimme — durch XTTS/F5-TTS ersetzt)
- [x] Highlight-Trigger konfigurierbar in Diagnostic
- [x] Highlight-Trigger konfigurierbar in Diagnostic (spaeter komplett entfernt — war Piper-Relikt)
- [x] XTTS v2 Integration (Gaming-PC, GPU, Voice Cloning) — durch F5-TTS ersetzt
- [x] XTTS Voice Cloning (Audio-Samples hochladen, eigene Stimme)
- [x] TTS Engine waehlbar (Piper/XTTS) — Piper raus, XTTS raus, jetzt nur F5-TTS
@@ -25,16 +111,12 @@
- [x] Auto-Update: APK-Installation via FileProvider
- [x] Auto-Update: "Auf Updates pruefen" Button in App-Einstellungen
- [x] Audio-Queue (sequentielle Wiedergabe, kein Ueberlappen)
- [x] Textnachrichten werden von ARIA beantwortet (Bridge chat handler fix)
- [x] Mehrere Anhaenge + Text vor dem Senden (Pending-Vorschau)
- [x] Paste-Support fuer Bilder in Diagnostic Chat
- [x] Markdown-Bereinigung fuer TTS (fett, kursiv, code, links, etc.)
- [x] SSH Volume read-write fuer Proxy (kein -F Workaround mehr)
- [x] Diagnostic: Sessions als Markdown exportieren (Download-Button)
- [x] Speech Gate: Aufnahme wird verworfen wenn keine Sprache erkannt
- [x] Session-Persistenz: Gewaehlte Session bleibt ueber Container-Restarts erhalten
- [x] Diagnostic: "ARIA denkt..." bleibt nicht mehr stehen
- [x] App: "ARIA denkt..." Indicator + Abbrechen-Button (Bridge spiegelt agent_activity via RVS)
- [x] Whisper STT: Model-Auswahl in Diagnostic (tiny/base/small/medium/large-v3), Hot-Reload
- [x] App: Audio-Aufnahme explizit 16kHz mono (spart Resample, optimal fuer Whisper)
- [x] Streaming TTS: PCM-Stream → AudioTrack MODE_STREAM, keine WAV-Gaps
@@ -51,14 +133,11 @@
- [x] Disk-Voll Banner in Diagnostic: rotes Overlay + copy-baren Cleanup-Befehlen (safe + aggressiv)
- [x] cleanup.sh: kombinierter Docker-Aufraeum-Befehl (safe / --full)
- [x] Streaming TTS Pre-Roll: AudioTrack play() startet erst wenn 2.5s gepuffert sind
- [x] Streaming TTS Stop-Race: Writer wartet auf playbackHeadPosition vor stop()/release() — keine abgeschnittenen Saetze mehr
- [x] Leading-Silence (200ms) am Stream-Anfang — AudioTrack faehrt sauber an
- [x] Pre-Roll-Buffer einstellbar in App-Settings (1.0-6.0s, Default 3.5s)
- [x] Fade-In auf erstem PCM-Chunk (120ms) — versteckt XTTS/F5-TTS Warmup-Glitches
- [x] Decimal-zu-Worte fuer TTS (0.1 → null komma eins, mit IP-Schutz-Lookahead)
- [x] Generic Acronym-Buchstabieren (XTTS → X T T S, USB → U S B, ueber expliziter Liste)
- [x] Voice-Auswahl funktioniert wieder: speaker_wav als Basename statt Pfad fuer daswer123 local-Mode
- [x] Diagnostic-Voice-Wechsel resettet alle App-lokalen Voice-Overrides via type "config"
- [x] voice_preload/voice_ready: Stille Mini-Render bei Voice-Wechsel + Toast/Status "bereit"
- [x] Whisper STT auf die Gamebox ausgelagert (faster-whisper CUDA, float16) — neuer aria-whisper-bridge Container
- [x] aria-bridge: STT primaer remote (Gamebox), Fallback lokal nach 45s Timeout
@@ -66,37 +145,54 @@
- [x] **F5-TTS ersetzt XTTS komplett** — neuer aria-f5tts-bridge Container, Voice Cloning, satzweises Streaming
- [x] Voice-Upload mit Whisper-Auto-Transkription — User muss keinen Referenz-Text eintippen
- [x] Audio-Pause statt Ducking: Spotify/YouTube pausieren komplett waehrend TTS (TRANSIENT statt MAY_DUCK)
- [x] AudioFocus.release wartet auf echten Playback-Ende — kein Volume-Hochfahren mehr mid-Antwort
- [x] VAD-Stille einstellbar in App-Settings (1.0-8.0s, Default 2.8s)
- [x] MAX_RECORDING auf 120s — laengere Erklaerungen moeglich
- [x] App: Audioausgabe hoert nicht mehr mitten im Satz auf (playbackHeadPosition wait + Stop-Race fix)
- [x] F5-TTS: Referenz-WAV-Preprocessing — Loudness-Normalisierung -16 LUFS + Silence-Trim + 10s Clip fuer konsistente Cloning-Quali
- [x] F5-TTS: deutsches Fine-Tune (aihpi/F5-TTS-German, Vocos-Variante) via hf:// Pfad in Diagnostic konfigurierbar
- [x] Whisper transkribiert Voice-Uploads nicht mehr mit hardcoded "small" — aktuelles Modell wird behalten, kein unnoetiger Modell-Swap
- [x] RVS/WebSocket maxPayload 50MB: voice_upload mit WAV als base64 sprengt kein Frame-Limit mehr
- [x] Dynamischer STT-Timeout in aria-bridge: 300s waehrend whisper-bridge 'loading', 45s wenn 'ready'
- [x] service_status Broadcasts: f5tts/whisper melden Lade-Status, Banner in Diagnostic (unten rechts) + App (oben)
- [x] config_request Pattern: Bridges fragen beim Connect die aktuelle Voice-Config an, aria-bridge antwortet
- [x] F5-TTS Tuning via Diagnostic (Modell-ID, Checkpoint, cfg_strength, nfe_step) statt ENV-Vars — Hot-Reload bei Modell-Wechsel
- [x] Conversation-Window: Gespraechsmodus endet nach X Sekunden Stille (1.0-20.0s, Default 8s, einstellbar in Settings)
- [x] Porcupine Wake-Word-Integration in der App (Built-In Keywords + Custom spaeter, per Geraet einstellbar)
- [x] Porcupine Wake-Word-Integration in der App (durch openWakeWord ersetzt)
- [x] HF-Cache als Bind-Mount statt Docker Volume — kein .vhdx-Bloat auf Docker Desktop / Windows
- [x] cleanup-windows.ps1 / .bat: VHDX-Cleanup via diskpart (ohne Hyper-V) mit Self-Elevation
- [x] App Mute-/Auto-Playback-Bug: Closure-Bug geloest (ttsCanPlayRef live-gespiegelt, nicht mehr stale)
- [x] App Zombie-Recording: Ohr-aus kill laufende Aufnahme damit der Aufnahme-Button weiter funktioniert
- [x] App Text-Rendering: Nachrichten selektierbar + Autolink fuer URLs/E-Mails/Telefonnummern (Browser/Mail/Dialer)
- [x] TTS-Wiedergabegeschwindigkeit pro Geraet einstellbar (Settings → 0.5-2.0x in 0.1-Schritten, Default 1.0)
- [x] Diagnostic: Voice-Preview-Modal (Play-Icon vor Delete-X, Textfeld mit Default, WAV im Browser abspielen)
- [x] **Wake-Word komplett on-device via openWakeWord (ONNX Runtime)** — Porcupine raus, kein API-Key/keine Lizenzgebuehren mehr. Mitgelieferte Keywords: hey_jarvis, computer, alexa, hey_mycroft, hey_rhasspy
- [x] 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] **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] 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] Barge-In: User kann ARIA waehrend Antwort/Tool-Use unterbrechen, alte Aktivitaet wird abgebrochen, Bridge gibt aria-core einen Kontext-Hint dass es eine Korrektur ist
- [x] Settings-Sub-Screens: 8 Kategorien (Verbindung, Allgemein, Spracheingabe, Wake-Word, Sprachausgabe, Speicher, Protokoll, Ueber) statt langer Liste
- [x] **Bereit-Sound (Airplane Ding-Dong) wenn Mikro nach Wake-Word offen** — akustische Bestaetigung statt nur Toast. Toggle in Settings → Wake-Word, default aktiv
- [x] **Wake-Word parallel zu TTS** mit AcousticEchoCanceler: User sagt "Computer" waehrend ARIA spricht → TTS verstummt sofort, neue Aufnahme startet
- [x] **GPS-Position mitsenden**: Toggle in Settings → Allgemein → Standort, persistiert in AsyncStorage. Wenn aktiv wird lat/lon mit jeder chat/audio-Message mitgegeben. Bridge prefixed den Text fuer aria-core mit GPS-Hint (mit Anweisung dass die Position nur bei Bedarf erwaehnt wird)
- [x] **Background Audio Service**: TTS, Wake-Word-Lauschen UND Aufnahme laufen auch bei minimierter App weiter. Foreground-Service mit foregroundServiceType=mediaPlayback|microphone, persistente Notification mit dynamischem Text ("ARIA spricht" / "ARIA hoert zu" / "ARIA bereit")
### Infrastruktur
- [x] Watchdog mit Container-Restart (2min Warnung → 5min doctor --fix → 8min Restart)
- [x] Nachrichten Backup on-the-fly (/shared/config/chat_backup.jsonl)
- [x] RVS Nachrichten vom Smartphone gehen durch
- [x] SSH Volume read-write fuer Proxy (kein -F Workaround mehr)
## Offen
### Bugs
- [ ] App: Wake-Word "jarvis" triggert nicht zuverlaessig (Porcupine-Debugging via ADB-Logcat ausstehend)
- [ ] App: Stuerzt beim Lauschen ab, eventuell bei Nebengeraeuschen (Porcupine + Mic-Race, errorCallback haelt's jetzt zurueck — Dauertest ausstehend)
### App Features
- [ ] Chat-History zuverlaessiger laden (AsyncStorage Race Condition)
- [ ] Background Audio Service (TTS auch bei minimierter App)
- [ ] Custom-Wake-Word-Upload via Diagnostic (eigene .onnx-Files ohne App-Rebuild)
### Architektur
- [ ] Bilder: Claude Vision direkt nutzen (aktuell nur Dateipfad an ARIA)