Compare commits

..

55 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
15 changed files with 937 additions and 155 deletions
+31 -3
View File
@@ -384,7 +384,7 @@ API-Endpoint fuer andere Services: `GET http://localhost:3001/api/session`
- **VAD (Voice Activity Detection)**: Adaptive Schwelle (Baseline aus ersten 500ms Mic-Pegel + 6dB Offset). Konfigurierbare Stille-Toleranz (1.08.0s, Default 2.8s) bevor Auto-Stop greift. Max-Aufnahme einstellbar (130 min, Default 5 min)
- **Barge-In**: Wenn du waehrend ARIAs Antwort eine neue Sprach-/Text-Nachricht reinschickst, wird sie unterbrochen + bekommt den Hint "das ist eine Korrektur"
- **Wake-Word waehrend TTS**: Du kannst "Computer" sagen waehrend ARIA noch redet — AcousticEchoCanceler verhindert dass ARIAs eigene Stimme das Wake-Word triggert
- **Anruf-Pause**: TTS verstummt automatisch wenn das Telefon klingelt (READ_PHONE_STATE Permission)
- **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
@@ -510,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)
```
@@ -844,7 +870,9 @@ docker exec aria-core ssh aria-wohnung hostname
- [x] Audio-Pause statt Ducking (TRANSIENT statt MAY_DUCK) + release-Timing fix
- [x] VAD-Stille-Toleranz einstellbar (1-8s) + adaptive Mikro-Baseline + Max-Aufnahme einstellbar (1-30 min)
- [x] Barge-In: User kann ARIA waehrend Antwort unterbrechen, aria-core bekommt Kontext-Hint
- [x] Anruf-Pause: TTS verstummt bei eingehendem Anruf (PhoneStateListener)
- [x] 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
+2 -2
View File
@@ -79,8 +79,8 @@ android {
applicationId "com.ariacockpit"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 802
versionName "0.0.8.2"
versionCode 10001
versionName "0.1.0.1"
// Fallback fuer Libraries mit Product Flavors
missingDimensionStrategy 'react-native-camera', 'general'
}
@@ -6,6 +6,9 @@
<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,
@@ -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) {}
}
@@ -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
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "aria-cockpit",
"version": "0.0.8.2",
"version": "0.1.0.1",
"private": true,
"scripts": {
"android": "react-native run-android",
+72 -17
View File
@@ -80,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);
@@ -154,7 +193,9 @@ const ChatScreen: React.FC = () => {
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();
@@ -229,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);
@@ -450,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
@@ -678,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 },
@@ -837,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();
@@ -886,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,
@@ -925,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) =>
@@ -938,7 +992,6 @@ const ChatScreen: React.FC = () => {
));
}}
/>
</TouchableOpacity>
) : att.type === 'image' && !att.uri ? (
<TouchableOpacity
style={styles.attachmentFile}
@@ -1341,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',
+58 -1
View File
@@ -18,6 +18,7 @@ import {
ToastAndroid,
ActivityIndicator,
Modal,
PermissionsAndroid,
} from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
import RNFS from 'react-native-fs';
@@ -63,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`;
@@ -132,6 +134,7 @@ const SettingsScreen: React.FC = () => {
// 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>('');
@@ -220,6 +223,7 @@ const SettingsScreen: React.FC = () => {
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);
});
@@ -454,7 +458,29 @@ 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);
AsyncStorage.setItem('aria_gps_enabled', String(value)).catch(() => {});
}, []);
@@ -1194,6 +1220,37 @@ 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 === */}
+265 -21
View File
@@ -6,7 +6,7 @@
* Nutzt react-native-audio-recorder-player fuer Aufnahme.
*/
import { Platform, PermissionsAndroid, NativeModules, ToastAndroid } from 'react-native';
import { Platform, PermissionsAndroid, NativeModules, ToastAndroid, NativeEventEmitter } from 'react-native';
import Sound from 'react-native-sound';
import RNFS from 'react-native-fs';
import AsyncStorage from '@react-native-async-storage/async-storage';
@@ -90,7 +90,7 @@ const VAD_SPEECH_MIN_MS = 500; // ms Sprache bevor Aufnahme zaehlt — l
// 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 = -55; // sehr empfindlich, fast jeder Pegel ist "Sprache"
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';
@@ -269,9 +269,38 @@ class AudioService {
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
@@ -318,14 +347,155 @@ class AudioService {
this._releaseFocusDeferred();
}
/** TTS-Wiedergabe haart stoppen — z.B. wenn ein Anruf reinkommt.
* Released auch sofort den AudioFocus damit der Anruf-Klingelton hoerbar ist. */
/** 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
@@ -610,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();
}
@@ -677,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 '';
@@ -703,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;
@@ -737,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;
@@ -817,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 {
@@ -826,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) {
@@ -854,6 +1064,15 @@ class AudioService {
}
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); }
});
@@ -937,8 +1156,27 @@ 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(() => {});
@@ -949,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(() => {});
+165 -61
View File
@@ -1,14 +1,19 @@
/**
* PhoneCall-Service — pausiert die TTS-Wiedergabe wenn das Telefon klingelt
* oder ein Anruf laeuft. Native-Bindung an PhoneCallModule.kt.
* PhoneCall-Service — pausiert ARIA bei Telefonaten:
*
* Bei "ringing" oder "offhook" wird audioService.haltAllPlayback() gerufen —
* ARIA verstummt sofort. Nach dem Auflegen passiert nichts automatisch
* (Audio kommt nicht zurueck), der User muesste die Antwort manuell
* nochmal anfordern (Play-Button auf der Nachricht).
* 1. Klassischer Mobilfunk-Anruf via TelephonyManager (PhoneCallModule.kt)
* Status: idle / ringing / offhook
*
* Permission READ_PHONE_STATE muss vom Nutzer einmalig erteilt werden —
* wenn nicht, failed start() leise und der Rest funktioniert wie bisher.
* 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 {
@@ -33,61 +38,76 @@ 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 || !PhoneCall) return false;
if (Platform.OS !== 'android') return false;
if (this.started || Platform.OS !== 'android') return false;
// Runtime-Permission holen (nur einmal noetig)
// 1. AudioFocus-Listener IMMER registrieren — fangs VoIP-Calls (WhatsApp,
// Signal, Discord etc.) abdecken, brauchen keine Permission.
try {
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',
},
const focusEmitter = new NativeEventEmitter(NativeModules.AudioFocus as any);
this.focusSubscription = focusEmitter.addListener(
'AudioFocusChanged',
(e: { type: 'loss' | 'loss_transient' | 'gain' }) => this._onFocusChanged(e.type),
);
if (granted !== PermissionsAndroid.RESULTS.GRANTED) {
console.warn('[PhoneCall] READ_PHONE_STATE Permission abgelehnt');
return false;
}
} catch (err) {
console.warn('[PhoneCall] Permission-Anfrage gescheitert', err);
console.log('[PhoneCall] AudioFocus-Listener aktiv (fuer VoIP-Calls)');
} catch (err: any) {
console.warn('[PhoneCall] AudioFocus-Subscription gescheitert', err?.message || err);
}
try {
const ok = await PhoneCall.start();
if (!ok) {
console.warn('[PhoneCall] Native start() lieferte false (Permission?)');
return false;
// 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);
}
const emitter = new NativeEventEmitter(NativeModules.PhoneCall as any);
this.subscription = emitter.addListener('PhoneCallStateChanged', (e: { state: PhoneState }) => {
this._onStateChanged(e.state);
});
this.started = true;
console.log('[PhoneCall] Listener aktiv');
return true;
} catch (err: any) {
console.warn('[PhoneCall] start gescheitert:', err?.message || err);
return false;
}
this.started = true;
return true;
}
async stop(): Promise<void> {
if (!this.started || !PhoneCall) return;
try {
this.subscription?.remove();
this.subscription = null;
await PhoneCall.stop();
} catch {}
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 {
@@ -96,22 +116,106 @@ class PhoneCallService {
console.log('[PhoneCall] State: %s → %s', prev, state);
this.lastState = state;
if (state === 'ringing' || state === 'offhook') {
audioService.haltAllPlayback(`Telefon-State: ${state}`);
// Wake-Word + Aufnahme pausieren: Telefonie-App belegt das Mikro
// waehrend des Anrufs, plus ARIA soll nicht im Telefonat zuhoeren.
wakeWordService.pauseForCall().catch(() => {});
ToastAndroid.show(
state === 'ringing' ? 'Anruf — ARIA pausiert' : 'Im Gespraech — ARIA pausiert',
ToastAndroid.SHORT,
);
this._haltForCall(state === 'ringing' ? 'Anruf — ARIA pausiert' : 'Im Gespraech — ARIA pausiert');
} else if (state === 'idle' && prev !== 'idle') {
// Auflegen: Wake-Word reaktivieren wenn vor dem Anruf aktiv war.
// TTS kommt nicht automatisch zurueck (Stream weg) — User kann
// ARIAs letzte Antwort per Play-Button nochmal hoeren.
wakeWordService.resumeFromCall().catch(() => {});
ToastAndroid.show('Anruf beendet — ARIA wieder aktiv', ToastAndroid.SHORT);
// 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();
+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 */
+59 -4
View File
@@ -677,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")
@@ -783,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", "")
@@ -825,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",
@@ -1141,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")
@@ -1461,6 +1486,31 @@ class ARIABridge:
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():
@@ -1634,6 +1684,11 @@ class ARIABridge:
}
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": stt_payload,
+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
+29 -2
View File
@@ -278,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>
@@ -1004,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') {
@@ -1395,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 '';
@@ -1434,7 +1448,18 @@
ttsBlock = `<div style="margin-top:6px;padding:4px 8px;background:rgba(0,150,255,0.08);border-left:2px solid #0096FF;font-size:11px;color:#88AACC;"><span style="color:#0096FF;font-weight:bold;">TTS:</span> ${escapeHtml(ttsText)}</div>`;
}
}
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' });
@@ -2451,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) {
+61 -1
View File
@@ -1,5 +1,56 @@
# 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
@@ -34,6 +85,10 @@
- [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
@@ -108,6 +163,12 @@
- [x] **Wake-Word komplett on-device via openWakeWord (ONNX Runtime)** — Porcupine raus, kein API-Key/keine Lizenzgebuehren mehr. Mitgelieferte Keywords: hey_jarvis, computer, alexa, hey_mycroft, hey_rhasspy
- [x] 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
@@ -132,7 +193,6 @@
### App Features
- [ ] Chat-History zuverlaessiger laden (AsyncStorage Race Condition)
- [ ] Custom-Wake-Word-Upload via Diagnostic (eigene .onnx-Files ohne App-Rebuild)
- [ ] Pause+Resume bei Anruf: aktuell wird der TTS-Stream bei Klingeln hart gestoppt, schoener waere Pause + Resume nach Auflegen
### Architektur
- [ ] Bilder: Claude Vision direkt nutzen (aktuell nur Dateipfad an ARIA)