Compare commits

..

41 Commits

Author SHA1 Message Date
duffyduck dd6d70c46e release: bump version to 0.1.0.4 2026-05-10 16:59:15 +02:00
duffyduck b1eaf42fef fix(audio): Spotify resumed nach Mute — RNSound's haengenden Focus loesen
Logs zeigten: react-native-sound requestet beim Sound.play() einen
EIGENEN AudioFocus mit USAGE_MEDIA, released den aber bei Sound.stop()/
release() NICHT (bekanntes RN-sound-Bug). Spotify sieht den haengenden
Media-Focus → bleibt pausiert.

Workaround: Native-Methode kickReleaseMedia() macht einen request+abandon-
Cycle mit USAGE_MEDIA, das System raeumt damit den Focus-Stack auf und
Spotify bekommt sauberen GAIN-Event. stopPlayback ruft das jetzt nach
Sound.release() wenn vorher ein RNSound aktiv war.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 16:57:52 +02:00
duffyduck fb9e5dcd10 feat(logger): Verbose-Logging-Toggle in Settings → Protokoll
console.log wird global stummgeschaltet wenn aus — spart adb-logcat-
Speicher wenn alles laeuft. console.warn/error bleiben immer aktiv.
Default an.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 16:52:25 +02:00
duffyduck f95e71463f release: bump version to 0.1.0.3 2026-05-10 16:43:37 +02:00
duffyduck 1088bff43d fix(chat): Play-Button rendert neu wenn Cache-Datei weg
Vorher: Button checkte nur ob audioPath gesetzt ist — auf eine geloeschte
Cache-Datei hat aber nichts geprueft. playFromPath warntete nur und
returnte stumm. Jetzt wird VOR playFromPath die Existenz geprueft, sonst
geht's ueber tts_request an die Bridge zum Neu-Rendern.

Plus: Logs in Sound.play-Callback und _releaseFocusDeferred fuer den
"Spotify resumed nicht nach Replay"-Bug.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 16:42:38 +02:00
duffyduck cad68db2a2 release: bump version to 0.1.0.2 2026-05-10 16:38:00 +02:00
duffyduck 50b10c8ac0 feat(audio): Cache-Cleanup beim App-Start + TTS-Cache-Settings-Button
- App-Start raeumt orphane aria_tts_*.wav (>5min) aus dem Cache —
  Wiedergaben die durch Anruf/Mute/Barge-In abgebrochen wurden
  hinterliessen sonst Files, weil der completion-Callback nicht feuert.
- Neuer Settings-Button "TTS-Cache leeren" mit Live-Groessenanzeige —
  parallel zum bestehenden "Update-Cache leeren".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 16:36:01 +02:00
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
14 changed files with 524 additions and 95 deletions
+5
View File
@@ -13,6 +13,7 @@ import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import ChatScreen from './src/screens/ChatScreen'; import ChatScreen from './src/screens/ChatScreen';
import SettingsScreen from './src/screens/SettingsScreen'; import SettingsScreen from './src/screens/SettingsScreen';
import rvs from './src/services/rvs'; import rvs from './src/services/rvs';
import { initLogger } from './src/services/logger';
// --- Navigation --- // --- Navigation ---
@@ -44,6 +45,10 @@ const TAB_ICONS: Record<string, { active: string; inactive: string }> = {
const App: React.FC = () => { const App: React.FC = () => {
// Beim Start: gespeicherte RVS-Konfiguration laden und verbinden // Beim Start: gespeicherte RVS-Konfiguration laden und verbinden
useEffect(() => { useEffect(() => {
// Verbose-Logging-Setting laden BEVOR andere Module loslegen.
// initLogger ist async aber blockt nichts — solange er noch laueft,
// loggen wir normal (Default an), danach respektiert console.log das Setting.
initLogger().catch(() => {});
const initConnection = async () => { const initConnection = async () => {
const config = await rvs.loadConfig(); const config = await rvs.loadConfig();
if (config) { if (config) {
+2 -2
View File
@@ -79,8 +79,8 @@ android {
applicationId "com.ariacockpit" applicationId "com.ariacockpit"
minSdkVersion rootProject.ext.minSdkVersion minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 809 versionCode 10004
versionName "0.0.8.9" versionName "0.1.0.4"
// Fallback fuer Libraries mit Product Flavors // Fallback fuer Libraries mit Product Flavors
missingDimensionStrategy 'react-native-camera', 'general' missingDimensionStrategy 'react-native-camera', 'general'
} }
@@ -6,6 +6,9 @@
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" /> <uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<!-- Anruf-State lesen damit TTS bei klingelndem Telefon pausiert --> <!-- Anruf-State lesen damit TTS bei klingelndem Telefon pausiert -->
<uses-permission android:name="android.permission.READ_PHONE_STATE" /> <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 damit TTS auch bei minimierter App weiterlaeuft.
FOREGROUND_SERVICE_MICROPHONE ist Pflicht ab Android 14 wenn der FOREGROUND_SERVICE_MICROPHONE ist Pflicht ab Android 14 wenn der
Service waehrend des Backgrounds aufs Mikro zugreift (Wake-Word, Service waehrend des Backgrounds aufs Mikro zugreift (Wake-Word,
@@ -131,6 +131,52 @@ class AudioFocusModule(reactContext: ReactApplicationContext) : ReactContextBase
promise.resolve(true) promise.resolve(true)
} }
/** Den USAGE_MEDIA-Focus-Stack im System aufmischen, damit Spotify/YouTube
* resumen wenn ein anderer Player (z.B. react-native-sound) seinen Focus
* nicht ordnungsgemaess released hat. Strategie: kurz selbst USAGE_MEDIA
* GAIN beanspruchen — das System invalidiert dabei den haengenden Stack-
* Eintrag des anderen Players — und sofort wieder abandonen. Spotify
* bekommt den Focus-Gain und resumed.
*
* Workaround fuer das react-native-sound-Bug: Sound.stop()/release()
* laesst den AudioFocusRequest haengen.
*/
@ReactMethod
fun kickReleaseMedia(promise: Promise) {
val am = audioManager()
if (am == null) {
promise.resolve(false)
return
}
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val attrs = AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_MEDIA)
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
.build()
val kickListener = AudioManager.OnAudioFocusChangeListener { /* ignorieren */ }
val kickReq = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN)
.setAudioAttributes(attrs)
.setOnAudioFocusChangeListener(kickListener)
.build()
am.requestAudioFocus(kickReq)
am.abandonAudioFocusRequest(kickReq)
} else {
@Suppress("DEPRECATION")
val kickListener = AudioManager.OnAudioFocusChangeListener { /* ignorieren */ }
@Suppress("DEPRECATION")
am.requestAudioFocus(kickListener, AudioManager.STREAM_MUSIC, AudioManager.AUDIOFOCUS_GAIN)
@Suppress("DEPRECATION")
am.abandonAudioFocus(kickListener)
}
Log.i(TAG, "kickReleaseMedia: USAGE_MEDIA-Stack aufgemischt")
promise.resolve(true)
} catch (e: Exception) {
Log.w(TAG, "kickReleaseMedia failed: ${e.message}")
promise.resolve(false)
}
}
private fun release() { private fun release() {
val am = audioManager() ?: return val am = audioManager() ?: return
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
@@ -4,6 +4,7 @@ import android.media.AudioAttributes
import android.media.AudioFormat import android.media.AudioFormat
import android.media.AudioManager import android.media.AudioManager
import android.media.AudioTrack import android.media.AudioTrack
import android.os.Build
import android.util.Base64 import android.util.Base64
import android.util.Log import android.util.Log
import com.facebook.react.bridge.Arguments import com.facebook.react.bridge.Arguments
@@ -79,13 +80,11 @@ class PcmStreamPlayerModule(reactContext: ReactApplicationContext) : ReactContex
val minBuf = AudioTrack.getMinBufferSize(sampleRate, channelConfig, encoding) val minBuf = AudioTrack.getMinBufferSize(sampleRate, channelConfig, encoding)
val bytesPerSecond = sampleRate * channels * 2 // 16-bit = 2 bytes val bytesPerSecond = sampleRate * channels * 2 // 16-bit = 2 bytes
val prerollTarget = (bytesPerSecond * prerollSec).toInt() val prerollTarget = (bytesPerSecond * prerollSec).toInt()
// Buffer entkoppelt von Preroll — fester ~3s-Buffer reicht. Wenn er // Buffer entkoppelt von Preroll — fester ~4s-Buffer. OnePlus A12
// an Preroll gekoppelt ist (z.B. 7s bei preroll=3.5s) und nur kurz // mit USAGE_ASSISTANT laeuft AudioTrack erst ab ~3s gepufferter
// gefuettert wird, stallt AudioTrack auf manchen Geraeten (OnePlus // Daten an. Wir padden Kurztexte vor play() auf 3s (siehe Block
// Android 12: pos bleibt 0 obwohl play() lief). // nach mainLoop), Buffer braucht ~1s Headroom weil write() blockt.
// 3s damit Padding bis 2s vor play() noch Headroom hat (write() ist val bufferSize = (bytesPerSecond * 4).coerceAtLeast(minBuf * 8)
// blocking — wenn Buffer voll ist, deadlockt es vor play()).
val bufferSize = (bytesPerSecond * 3).coerceAtLeast(minBuf * 8)
prerollBytes = prerollTarget prerollBytes = prerollTarget
bytesBuffered = 0 bytesBuffered = 0
playbackStarted = false playbackStarted = false
@@ -109,7 +108,20 @@ class PcmStreamPlayerModule(reactContext: ReactApplicationContext) : ReactContex
.setTransferMode(AudioTrack.MODE_STREAM) .setTransferMode(AudioTrack.MODE_STREAM)
.build() .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 track = newTrack
queue.clear() queue.clear()
writerShouldStop = false writerShouldStop = false
@@ -161,11 +173,12 @@ class PcmStreamPlayerModule(reactContext: ReactApplicationContext) : ReactContex
val data = queue.poll(50, java.util.concurrent.TimeUnit.MILLISECONDS) val data = queue.poll(50, java.util.concurrent.TimeUnit.MILLISECONDS)
if (data == null) { if (data == null) {
if (endRequested) { if (endRequested) {
// Bei kurzem Text NICHT hier play() callen — erst nach // Falls play() noch gar nicht lief (Stream ohne data
// Trailing-Silence + Padding (siehe Block nach mainLoop), // ueberhaupt — sehr seltene Edge-Case): jetzt anstossen
// damit AudioTrack mit komplett gefuelltem Buffer startet. // damit das finally{}-Wait nicht endlos blockt.
// OnePlus A12: AudioTrack startet nicht zuverlaessig wenn if (!playbackStarted) {
// play() bei dünnem Buffer gerufen wird. try { t.play(); playbackStarted = true } catch (_: Exception) {}
}
break@mainLoop break@mainLoop
} }
// Underrun-Schutz: Stille reinfuettern wenn der AudioTrack- // Underrun-Schutz: Stille reinfuettern wenn der AudioTrack-
@@ -196,12 +209,16 @@ class PcmStreamPlayerModule(reactContext: ReactApplicationContext) : ReactContex
} }
idleMs = 0L idleMs = 0L
// Pre-Roll Check: play() erst wenn genug gepuffert // play() beim ALLERERSTEN data-chunk aufrufen — egal wie wenig
if (!playbackStarted && bytesBuffered + data.size >= prerollBytes) { // 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 { try {
t.play() t.play()
playbackStarted = true 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) { } catch (e: Exception) {
Log.w(TAG, "play() failed: ${e.message}") Log.w(TAG, "play() failed: ${e.message}")
} }
@@ -228,30 +245,6 @@ class PcmStreamPlayerModule(reactContext: ReactApplicationContext) : ReactContex
} }
bytesBuffered += silence.size bytesBuffered += silence.size
} }
// Bei kurzem Text (play() noch nicht gestartet): Buffer auf min.
// 2s padden + DANN play(). Auf OnePlus A12 startet AudioTrack
// bei einem zu duennen Buffer nicht — pos bleibt auf 0 stehen.
if (!playbackStarted && !writerShouldStop) {
val minStartBytes = bytesPerSecond * 2
if (bytesBuffered < minStartBytes) {
val padBytes = (minStartBytes - bytesBuffered.toInt()) and 0x7FFFFFFE
val pad = ByteArray(padBytes)
var padOff = 0
while (padOff < pad.size && !writerShouldStop) {
val w = t.write(pad, padOff, pad.size - padOff)
if (w <= 0) break
padOff += w
}
bytesBuffered += pad.size
}
try {
t.play()
playbackStarted = true
Log.i(TAG, "Playback gestartet (kurzer Text, ${bytesBuffered}B komplett gepuffert)")
} catch (e: Exception) {
Log.w(TAG, "play() short-text failed: ${e.message}")
}
}
} catch (e: Exception) { } catch (e: Exception) {
Log.w(TAG, "Writer-Thread Fehler: ${e.message}") Log.w(TAG, "Writer-Thread Fehler: ${e.message}")
} finally { } finally {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "aria-cockpit", "name": "aria-cockpit",
"version": "0.0.8.9", "version": "0.1.0.4",
"private": true, "private": true,
"scripts": { "scripts": {
"android": "react-native run-android", "android": "react-native run-android",
+30 -16
View File
@@ -725,17 +725,23 @@ const ChatScreen: React.FC = () => {
// GPS-Position holen (optional) // GPS-Position holen (optional)
const getCurrentLocation = useCallback((): Promise<{ lat: number; lon: number } | null> => { 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) => { return new Promise((resolve) => {
Geolocation.getCurrentPosition( Geolocation.getCurrentPosition(
(position) => { (position) => {
resolve({ const loc = {
lat: position.coords.latitude, lat: position.coords.latitude,
lon: position.coords.longitude, 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); resolve(null);
}, },
{ enableHighAccuracy: false, timeout: 5000 }, { enableHighAccuracy: false, timeout: 5000 },
@@ -884,6 +890,7 @@ const ChatScreen: React.FC = () => {
// Alle Pending Anhaenge + Text senden // Alle Pending Anhaenge + Text senden
const sendPendingAttachments = useCallback(async (messageText: string) => { const sendPendingAttachments = useCallback(async (messageText: string) => {
if (pendingAttachments.length === 0) return; if (pendingAttachments.length === 0) return;
console.log('[Chat] sendPendingAttachments: %d Anhang/Anhaenge', pendingAttachments.length);
const location = await getCurrentLocation(); const location = await getCurrentLocation();
const msgId = nextId(); const msgId = nextId();
@@ -933,6 +940,8 @@ const ChatScreen: React.FC = () => {
} }
// An RVS senden // 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', { rvs.send('file', {
name, name,
type: mimeType, type: mimeType,
@@ -1029,19 +1038,24 @@ const ChatScreen: React.FC = () => {
{!isUser && item.text.length > 0 && ( {!isUser && item.text.length > 0 && (
<TouchableOpacity <TouchableOpacity
style={styles.playButton} style={styles.playButton}
onPress={() => { onPress={async () => {
if (item.audioPath) { // Erst lokalen Cache pruefen — audioPath kann auf eine geloeschte
audioService.playFromPath(item.audioPath); // Datei zeigen (TTS-Cache geleert oder Auto-Cleanup). In dem Fall
} else { // ueber RVS neu rendern lassen statt stumm zu bleiben.
// messageId mitschicken damit die Bridge das generierte Audio const cachePath = item.audioPath?.replace(/^file:\/\//, '') || '';
// wieder mit der Nachricht verknuepft (fuer den naechsten Replay aus Cache) const cached = cachePath ? await RNFS.exists(cachePath).catch(() => false) : false;
rvs.send('tts_request' as any, { if (cached) {
text: item.text, audioService.playFromPath(item.audioPath!);
voice: localXttsVoiceRef.current, return;
speed: ttsSpeedRef.current,
messageId: item.messageId || '',
});
} }
// messageId mitschicken damit die Bridge das generierte Audio
// wieder mit der Nachricht verknuepft (fuer den naechsten Replay aus Cache)
rvs.send('tts_request' as any, {
text: item.text,
voice: localXttsVoiceRef.current,
speed: ttsSpeedRef.current,
messageId: item.messageId || '',
});
}} }}
> >
<Text style={styles.playButtonText}>{'\uD83D\uDD0A'}</Text> <Text style={styles.playButtonText}>{'\uD83D\uDD0A'}</Text>
+83 -1
View File
@@ -18,6 +18,7 @@ import {
ToastAndroid, ToastAndroid,
ActivityIndicator, ActivityIndicator,
Modal, Modal,
PermissionsAndroid,
} from 'react-native'; } from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage'; import AsyncStorage from '@react-native-async-storage/async-storage';
import RNFS from 'react-native-fs'; import RNFS from 'react-native-fs';
@@ -49,6 +50,8 @@ import {
TTS_SPEED_MAX, TTS_SPEED_MAX,
TTS_SPEED_STORAGE_KEY, TTS_SPEED_STORAGE_KEY,
} from '../services/audio'; } from '../services/audio';
import audioService from '../services/audio';
import { isVerboseLogging, setVerboseLogging } from '../services/logger';
import { import {
isWakeReadySoundEnabled, isWakeReadySoundEnabled,
setWakeReadySoundEnabled, setWakeReadySoundEnabled,
@@ -134,6 +137,8 @@ const SettingsScreen: React.FC = () => {
const [vadSilenceDb, setVadSilenceDb] = useState<number | null>(null); const [vadSilenceDb, setVadSilenceDb] = useState<number | null>(null);
const [showVadInfo, setShowVadInfo] = useState(false); const [showVadInfo, setShowVadInfo] = useState(false);
const [apkCacheInfo, setApkCacheInfo] = useState<{count: number, totalMB: number} | null>(null); const [apkCacheInfo, setApkCacheInfo] = useState<{count: number, totalMB: number} | null>(null);
const [ttsCacheInfo, setTtsCacheInfo] = useState<{count: number, totalMB: number} | null>(null);
const [verboseLogging, setVerboseLoggingState] = useState<boolean>(isVerboseLogging());
const [ttsSpeed, setTtsSpeed] = useState<number>(TTS_SPEED_DEFAULT); const [ttsSpeed, setTtsSpeed] = useState<number>(TTS_SPEED_DEFAULT);
const [wakeKeyword, setWakeKeyword] = useState<string>(DEFAULT_KEYWORD); const [wakeKeyword, setWakeKeyword] = useState<string>(DEFAULT_KEYWORD);
const [wakeStatus, setWakeStatus] = useState<string>(''); const [wakeStatus, setWakeStatus] = useState<string>('');
@@ -223,6 +228,7 @@ const SettingsScreen: React.FC = () => {
}); });
isWakeReadySoundEnabled().then(setWakeReadySound); isWakeReadySoundEnabled().then(setWakeReadySound);
updateService.getApkCacheSize().then(setApkCacheInfo).catch(() => {}); updateService.getApkCacheSize().then(setApkCacheInfo).catch(() => {});
audioService.getTtsCacheSize().then(setTtsCacheInfo).catch(() => {});
AsyncStorage.getItem('aria_xtts_voice').then(saved => { AsyncStorage.getItem('aria_xtts_voice').then(saved => {
if (saved) setXttsVoice(saved); if (saved) setXttsVoice(saved);
}); });
@@ -457,7 +463,29 @@ const SettingsScreen: React.FC = () => {
// --- GPS Toggle --- // --- 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); setGpsEnabled(value);
AsyncStorage.setItem('aria_gps_enabled', String(value)).catch(() => {}); AsyncStorage.setItem('aria_gps_enabled', String(value)).catch(() => {});
}, []); }, []);
@@ -1228,11 +1256,65 @@ const SettingsScreen: React.FC = () => {
</TouchableOpacity> </TouchableOpacity>
</View> </View>
{/* === TTS-Cache === */}
<Text style={[styles.sectionTitle, {marginTop: 16}]}>TTS-Cache</Text>
<View style={styles.card}>
<Text style={styles.toggleHint}>
Gespeicherte Sprachausgaben (WAV pro Antwort) werden fuer den
Play-Button und Auto-Resume nach Anrufen genutzt. Loeschen
unterbricht keine laufende Wiedergabe, alte Antworten lassen sich
danach nur nicht mehr abspielen.
</Text>
<Text style={[styles.storageSizeText, {marginTop: 8}]}>
{ttsCacheInfo === null ? '...' :
ttsCacheInfo.count === 0 ? 'leer' :
`${ttsCacheInfo.count} WAV${ttsCacheInfo.count === 1 ? '' : 's'} · ${ttsCacheInfo.totalMB.toFixed(1)}MB`}
</Text>
<TouchableOpacity
style={[styles.clearButton, {marginTop: 8, backgroundColor: 'rgba(255,59,48,0.15)'}]}
onPress={async () => {
const res = await audioService.clearTtsCache();
ToastAndroid.show(
res.removed === 0
? 'TTS-Cache war schon leer'
: `${res.removed} WAV${res.removed === 1 ? '' : 's'} geloescht (${res.freedMB.toFixed(1)}MB frei)`,
ToastAndroid.SHORT,
);
const info = await audioService.getTtsCacheSize();
setTtsCacheInfo(info);
}}
>
<Text style={[styles.clearButtonText, {color: '#FF3B30'}]}>TTS-Cache leeren</Text>
</TouchableOpacity>
</View>
</>)} </>)}
{/* === Logs === */} {/* === Logs === */}
{currentSection === 'protocol' && (<> {currentSection === 'protocol' && (<>
<Text style={styles.sectionTitle}>Protokoll</Text> <Text style={styles.sectionTitle}>Protokoll</Text>
{/* Verbose-Logging-Toggle */}
<View style={styles.card}>
<View style={styles.toggleRow}>
<Text style={styles.toggleLabel}>Verbose Logging</Text>
<Switch
value={verboseLogging}
onValueChange={(v) => {
setVerboseLogging(v);
setVerboseLoggingState(v);
}}
trackColor={{ false: '#3A3A52', true: '#0096FF' }}
thumbColor={verboseLogging ? '#FFFFFF' : '#666680'}
/>
</View>
<Text style={styles.toggleHint}>
Wenn aus: console.log wird global stummgeschaltet (Speicher schonen).
Warnungen und Fehler bleiben immer aktiv. Bei Bedarf einschalten zum
Debuggen via adb logcat.
</Text>
</View>
<View style={styles.card}> <View style={styles.card}>
{/* Tab-Umschalter */} {/* Tab-Umschalter */}
<View style={styles.tabRow}> <View style={styles.tabRow}>
+195 -22
View File
@@ -41,6 +41,8 @@ const { AudioFocus, PcmStreamPlayer } = NativeModules as {
requestDuck: () => Promise<boolean>; requestDuck: () => Promise<boolean>;
requestExclusive: () => Promise<boolean>; requestExclusive: () => Promise<boolean>;
release: () => Promise<boolean>; release: () => Promise<boolean>;
kickReleaseMedia: () => Promise<boolean>;
getMode?: () => Promise<number>;
}; };
PcmStreamPlayer?: { PcmStreamPlayer?: {
start: (sampleRate: number, channels: number, prerollSeconds: number) => Promise<boolean>; start: (sampleRate: number, channels: number, prerollSeconds: number) => Promise<boolean>;
@@ -301,6 +303,12 @@ class AudioService {
console.warn('[Audio] PcmPlaybackFinished-Subscription fehlgeschlagen:', err); console.warn('[Audio] PcmPlaybackFinished-Subscription fehlgeschlagen:', err);
} }
} }
// App-Start: orphaned aria_tts_*.wav / aria_recording_*.mp4 aus dem Cache
// wegraeumen. Sammeln sich an wenn Sound mid-playback gestoppt wird (Anruf,
// Mute, Barge-In) — der completion-callback feuert dann nicht und die Datei
// bleibt liegen. 5min-Threshold damit gerade aktiv geschriebene Files sicher
// sind. cleanupOnStartup ist async, blockt den Constructor nicht.
this._cleanupStaleCacheFiles(5 * 60 * 1000).catch(() => {});
} }
/** AudioFocus mit kleiner Verzoegerung freigeben — Spotify/YouTube /** AudioFocus mit kleiner Verzoegerung freigeben — Spotify/YouTube
@@ -310,13 +318,19 @@ class AudioService {
* unterdrueckt — der Focus bleibt fuer die ganze Konversation gehalten. */ * unterdrueckt — der Focus bleibt fuer die ganze Konversation gehalten. */
private _releaseFocusDeferred(): void { private _releaseFocusDeferred(): void {
if (this._conversationFocusActive) { if (this._conversationFocusActive) {
console.log('[Audio] _releaseFocusDeferred: Conversation aktiv → kein Release');
this._cancelDeferredFocusRelease(); this._cancelDeferredFocusRelease();
return; return;
} }
this._cancelDeferredFocusRelease(); this._cancelDeferredFocusRelease();
console.log('[Audio] _releaseFocusDeferred: in %dms', this.FOCUS_RELEASE_DELAY_MS);
this.focusReleaseTimer = setTimeout(() => { this.focusReleaseTimer = setTimeout(() => {
this.focusReleaseTimer = null; this.focusReleaseTimer = null;
if (this._conversationFocusActive) return; if (this._conversationFocusActive) {
console.log('[Audio] Focus-Release abgebrochen (Conversation jetzt aktiv)');
return;
}
console.log('[Audio] AudioFocus jetzt released');
AudioFocus?.release().catch(() => {}); AudioFocus?.release().catch(() => {});
}, this.FOCUS_RELEASE_DELAY_MS); }, this.FOCUS_RELEASE_DELAY_MS);
} }
@@ -347,19 +361,69 @@ class AudioService {
this._releaseFocusDeferred(); this._releaseFocusDeferred();
} }
/** TTS-Wiedergabe haart stoppen — z.B. wenn ein Anruf reinkommt. /** TTS-Wiedergabe haart stoppen — z.B. fuer Barge-In. Buffer wird geleert,
* Released auch sofort den AudioFocus damit der Anruf-Klingelton hoerbar ist. */ * kein Auto-Resume. Released auch sofort den AudioFocus. */
haltAllPlayback(reason: string = ''): void { haltAllPlayback(reason: string = ''): void {
console.log('[Audio] haltAllPlayback: %s', reason || '(no reason)'); console.log('[Audio] haltAllPlayback: %s', reason || '(no reason)');
this._conversationFocusActive = false; this._conversationFocusActive = false;
this.stopPlayback(); 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 /** Bei Anruf: aktuelle Wiedergabe-Position merken damit wir nach dem
* Auflegen von dort weitermachen koennen. Returnt Position in Sekunden * Auflegen von dort weitermachen koennen. Returnt Position in Sekunden
* oder 0 wenn nichts spielte. */ * 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 { 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) { if (!this.playbackStartTime || !this.currentPlaybackMsgId) {
console.log('[Audio] captureInterruption: nichts spielte (startTime=%s, msgId=%s)',
this.playbackStartTime, this.currentPlaybackMsgId || '(leer)');
this.pausedPosition = 0; this.pausedPosition = 0;
this.pausedMessageId = ''; this.pausedMessageId = '';
return 0; return 0;
@@ -379,7 +443,12 @@ class AudioService {
async resumeFromInterruption(maxWaitMs: number = 30000): Promise<boolean> { async resumeFromInterruption(maxWaitMs: number = 30000): Promise<boolean> {
const msgId = this.pausedMessageId; const msgId = this.pausedMessageId;
const position = this.pausedPosition; const position = this.pausedPosition;
if (!msgId) return false; 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 this.pausedMessageId = ''; // konsumieren
const cachePath = `${RNFS.DocumentDirectoryPath}/tts_cache/${msgId}.wav`; const cachePath = `${RNFS.DocumentDirectoryPath}/tts_cache/${msgId}.wav`;
const startTime = Date.now(); const startTime = Date.now();
@@ -413,6 +482,14 @@ class AudioService {
this._firePlaybackStarted(); this._firePlaybackStarted();
this.isPlaying = true; this.isPlaying = true;
this.resumeSound = sound; 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', console.log('[Audio] Resume von Position %ss aus %s',
positionSec.toFixed(2), path); positionSec.toFixed(2), path);
sound.setCurrentTime(Math.max(0, positionSec)); sound.setCurrentTime(Math.max(0, positionSec));
@@ -719,8 +796,13 @@ class AudioService {
if (!base64Data) return; if (!base64Data) return;
// Mute-Flag respektieren — robust gegen Race-Conditions zwischen User- // Mute-Flag respektieren — robust gegen Race-Conditions zwischen User-
// Klick auf Mute und einem TTS-Chunk der im selben Tick eintrifft. // Klick auf Mute und einem TTS-Chunk der im selben Tick eintrifft.
if (this._muted) return; if (this._muted) {
console.log('[Audio] playAudio: muted=true → skip');
return;
}
this.audioQueue.push(base64Data); 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) { if (!this.isPlaying) {
this._playNext(); this._playNext();
} }
@@ -788,7 +870,9 @@ class AudioService {
}): Promise<string> { }): Promise<string> {
// Globaler Mute-Flag uebersteuert das per-Call silent — verhindert // Globaler Mute-Flag uebersteuert das per-Call silent — verhindert
// Race-Conditions wenn der User zwischen Chunks den Mute-Knopf drueckt. // Race-Conditions wenn der User zwischen Chunks den Mute-Knopf drueckt.
const silent = !!payload.silent || this._muted; // _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) { if (!silent && !PcmStreamPlayer) {
console.warn('[Audio] PcmStreamPlayer Native Module nicht verfuegbar'); console.warn('[Audio] PcmStreamPlayer Native Module nicht verfuegbar');
return ''; return '';
@@ -946,7 +1030,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> { async playFromPath(filePath: string): Promise<void> {
if (!filePath) return; if (!filePath) return;
try { try {
@@ -955,6 +1042,14 @@ class AudioService {
console.warn('[Audio] Cache-Datei existiert nicht mehr:', cleanPath); console.warn('[Audio] Cache-Datei existiert nicht mehr:', cleanPath);
return; 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'); const b64 = await RNFS.readFile(cleanPath, 'base64');
this.playAudio(b64); this.playAudio(b64);
} catch (err) { } catch (err) {
@@ -983,9 +1078,15 @@ class AudioService {
} }
private _firePlaybackStarted(): void { private _firePlaybackStarted(): void {
// Tracking fuer Auto-Resume nach Anruf-Pause // Tracking fuer Auto-Resume nach Anruf-Pause: NUR setzen wenn ein
this.playbackStartTime = Date.now(); // PCM-Stream laeuft (Live-TTS). Bei Play-Button / Resume-Sound hat der
this.currentPlaybackMsgId = this.pcmMessageId || ''; // 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 => { this.playbackStartedListeners.forEach(cb => {
try { cb(); } catch (e) { console.warn('[Audio] playbackStarted listener err:', e); } try { cb(); } catch (e) { console.warn('[Audio] playbackStarted listener err:', e); }
}); });
@@ -1038,11 +1139,13 @@ class AudioService {
} }
this.currentSound = sound; this.currentSound = sound;
console.log('[Audio] Sound.play startet (path=%s)', soundPath);
// Naechstes Audio schon vorbereiten waehrend dieses abspielt // Naechstes Audio schon vorbereiten waehrend dieses abspielt
this._preloadNext(); this._preloadNext();
sound.play((success) => { sound.play((success) => {
console.log('[Audio] Sound.play callback: success=%s queue=%d', success, this.audioQueue.length);
if (!success) console.warn('[Audio] Wiedergabe fehlgeschlagen'); if (!success) console.warn('[Audio] Wiedergabe fehlgeschlagen');
sound.release(); sound.release();
this.currentSound = null; this.currentSound = null;
@@ -1074,7 +1177,13 @@ class AudioService {
* — die Bridge kann einen Chunk im selben JS-Tick liefern in dem der * — die Bridge kann einen Chunk im selben JS-Tick liefern in dem der
* User Mute geklickt hat. */ * User Mute geklickt hat. */
private _muted: boolean = false; 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 { setMuted(muted: boolean): void {
console.log('[Audio] setMuted: %s (currentSound=%s pcmStreamActive=%s)',
muted, this.currentSound ? 'aktiv' : 'null', this.pcmStreamActive);
this._muted = muted; this._muted = muted;
if (muted) this.stopPlayback(); if (muted) this.stopPlayback();
} }
@@ -1082,33 +1191,50 @@ class AudioService {
/** Laufende Wiedergabe stoppen + Queue leeren */ /** Laufende Wiedergabe stoppen + Queue leeren */
stopPlayback(): void { 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 // Foreground-Service auch stoppen — sonst bleibt die Notification haengen
// wenn Wiedergabe abgebrochen wird (Anruf, Cancel, Barge-In). // wenn Wiedergabe abgebrochen wird (Anruf, Cancel, Barge-In).
stopBackgroundAudio().catch(() => {}); stopBackgroundAudio().catch(() => {});
this.audioQueue = []; this.audioQueue = [];
this.isPlaying = false; this.isPlaying = false;
// Merken: war ein react-native-sound-Sound aktiv? Dann muessen wir nach
// release() den Focus-Stack aufmischen (RNSound-Bug: stop+release laesst
// den AudioFocusRequest haengen, Spotify resumed sonst nicht).
const hadRnSound = !!(this.currentSound || this.resumeSound || this.preloadedSound);
if (this.currentSound) { if (this.currentSound) {
this.currentSound.stop(); this.currentSound.stop();
this.currentSound.release(); this.currentSound.release();
this.currentSound = null; this.currentSound = null;
} }
if (this.resumeSound) {
this.resumeSound.stop();
this.resumeSound.release();
this.resumeSound = null;
}
if (this.preloadedSound) { if (this.preloadedSound) {
this.preloadedSound.release(); this.preloadedSound.release();
this.preloadedSound = null; this.preloadedSound = null;
if (this.preloadedPath) RNFS.unlink(this.preloadedPath).catch(() => {}); if (this.preloadedPath) RNFS.unlink(this.preloadedPath).catch(() => {});
this.preloadedPath = ''; this.preloadedPath = '';
} }
// PCM-Stream ebenfalls hart stoppen (Cancel/Abbruch) // PCM-Stream ebenfalls hart stoppen (Cancel/Abbruch).
if (this.pcmStreamActive) { // pcmStreamActive wird beim isFinal-Chunk schon false gesetzt — der
PcmStreamPlayer?.stop().catch(() => {}); // AudioTrack spielt aber noch sekundenlang aus seinem Buffer ab. Daher
this.pcmStreamActive = false; // IMMER stop() aufrufen, ohne den Flag zu pruefen (ist idempotent).
this.pcmBuffer = []; PcmStreamPlayer?.stop().catch(() => {});
this.pcmBytesCollected = 0; this.pcmStreamActive = false;
this.pcmMessageId = ''; this.pcmBuffer = [];
} this.pcmBytesCollected = 0;
this.pcmMessageId = '';
// Audio-Focus sofort freigeben — User hat explizit abgebrochen // Audio-Focus sofort freigeben — User hat explizit abgebrochen
this._cancelDeferredFocusRelease(); this._cancelDeferredFocusRelease();
AudioFocus?.release().catch(() => {}); AudioFocus?.release().catch(() => {});
if (hadRnSound) {
// RNSound's haengender USAGE_MEDIA-Focus aufloesen — sonst bleibt
// Spotify pausiert obwohl unser Focus released ist.
AudioFocus?.kickReleaseMedia?.().catch(() => {});
}
} }
// --- Status & Callbacks --- // --- Status & Callbacks ---
@@ -1148,19 +1274,29 @@ class AudioService {
} }
} }
/** Alte Aufnahme- und TTS-Files aus dem Cache loeschen (>30s alt). */ /** Alte Aufnahme- und TTS-Files aus dem Cache loeschen.
private async _cleanupStaleCacheFiles(): Promise<void> { * Default 30s — verwendet beim Mikro-Start (kurze Lebensdauer reicht).
* App-Start nutzt 5min damit gerade aktive Files nicht erwischt werden. */
private async _cleanupStaleCacheFiles(maxAgeMs: number = 30000): Promise<void> {
try { try {
const files = await RNFS.readDir(RNFS.CachesDirectoryPath); const files = await RNFS.readDir(RNFS.CachesDirectoryPath);
const now = Date.now(); const now = Date.now();
let removed = 0;
let freedBytes = 0;
for (const f of files) { for (const f of files) {
if (!f.isFile()) continue; if (!f.isFile()) continue;
if (!f.name.startsWith('aria_recording_') && !f.name.startsWith('aria_tts_')) continue; if (!f.name.startsWith('aria_recording_') && !f.name.startsWith('aria_tts_')) continue;
const age = now - (f.mtime ? f.mtime.getTime() : 0); const age = now - (f.mtime ? f.mtime.getTime() : 0);
if (age > 30000) { if (age > maxAgeMs) {
freedBytes += parseInt(f.size as any, 10) || 0;
await RNFS.unlink(f.path).catch(() => {}); await RNFS.unlink(f.path).catch(() => {});
removed += 1;
} }
} }
if (removed > 0) {
console.log('[Audio] Cache-Cleanup: %d Files entfernt, %.1fMB freigegeben',
removed, freedBytes / 1024 / 1024);
}
} catch { } catch {
// silent — cleanup ist best-effort // silent — cleanup ist best-effort
} }
@@ -1187,6 +1323,43 @@ class AudioService {
// silent // silent
} }
} }
/** Aktuelle Groesse des TTS-Caches. */
async getTtsCacheSize(): Promise<{ count: number; totalMB: number }> {
let count = 0;
let total = 0;
try {
const dir = `${RNFS.DocumentDirectoryPath}/tts_cache`;
if (await RNFS.exists(dir)) {
const files = await RNFS.readDir(dir);
for (const f of files) {
if (!f.isFile() || !f.name.endsWith('.wav')) continue;
count += 1;
total += parseInt(f.size as any, 10) || 0;
}
}
} catch {}
return { count, totalMB: total / 1024 / 1024 };
}
/** TTS-Cache komplett leeren (Settings-Button). */
async clearTtsCache(): Promise<{ removed: number; freedMB: number }> {
let removed = 0;
let freed = 0;
try {
const dir = `${RNFS.DocumentDirectoryPath}/tts_cache`;
if (!(await RNFS.exists(dir))) return { removed: 0, freedMB: 0 };
const files = await RNFS.readDir(dir);
for (const f of files) {
if (!f.isFile() || !f.name.endsWith('.wav')) continue;
const size = parseInt(f.size as any, 10) || 0;
await RNFS.unlink(f.path).catch(() => {});
removed += 1;
freed += size;
}
} catch {}
return { removed, freedMB: freed / 1024 / 1024 };
}
} }
// Singleton // Singleton
+41
View File
@@ -0,0 +1,41 @@
/**
* Verbose-Logging-Toggle: console.log laesst sich global stummschalten.
* console.warn/console.error bleiben immer an — Fehler will man immer sehen.
*
* Default: an (true). Toggle ueber Settings → Protokoll → Verbose Logging.
* Beim Start wird der gespeicherte Wert geladen, vorher loggen wir normal.
*/
import AsyncStorage from '@react-native-async-storage/async-storage';
export const VERBOSE_LOGGING_KEY = 'aria_verbose_logging';
// Original-console.log retten, damit wir die Wrapper jederzeit wieder
// "scharf" stellen koennen (sonst waere ein Toggle-an nach -aus tot).
const originalLog = console.log.bind(console);
const noop = () => {};
let _verbose = true;
function applyState(): void {
console.log = _verbose ? originalLog : noop;
}
/** Wert aus AsyncStorage laden und anwenden. Beim App-Start aufrufen. */
export async function initLogger(): Promise<void> {
try {
const v = await AsyncStorage.getItem(VERBOSE_LOGGING_KEY);
_verbose = v !== 'false'; // default: true
} catch {}
applyState();
}
export function isVerboseLogging(): boolean {
return _verbose;
}
export function setVerboseLogging(verbose: boolean): void {
_verbose = verbose;
applyState();
AsyncStorage.setItem(VERBOSE_LOGGING_KEY, String(verbose)).catch(() => {});
}
+19 -9
View File
@@ -189,22 +189,32 @@ class PhoneCallService {
private _haltForCall(toast: string): void { private _haltForCall(toast: string): void {
// Position merken bevor wir den Stream killen — fuer Auto-Resume. // Position merken bevor wir den Stream killen — fuer Auto-Resume.
audioService.captureInterruption(); audioService.captureInterruption();
audioService.haltAllPlayback(toast); // pauseForCall (statt haltAllPlayback): pcmBuffer + messageId bleiben,
// weitere Chunks werden weiter gesammelt damit isFinal die WAV schreibt.
audioService.pauseForCall(toast);
wakeWordService.pauseForCall().catch(() => {}); wakeWordService.pauseForCall().catch(() => {});
ToastAndroid.show(toast, ToastAndroid.SHORT); ToastAndroid.show(toast, ToastAndroid.SHORT);
} }
private _resumeAfterCall(toast: string): void { 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(() => {}); wakeWordService.resumeFromCall().catch(() => {});
ToastAndroid.show(toast, ToastAndroid.SHORT); ToastAndroid.show(toast, ToastAndroid.SHORT);
// Auto-Resume: ab gemerkter Position weiterspielen wenn ARIA vor dem // 800ms warten bevor Auto-Resume — sonst kollidiert ARIA's neuer Focus-
// Anruf gerade redete. Wartet bis zu 30s auf den WAV-Cache (falls // Request mit Spotify's Auto-Resume nach Anruf-Ende. System haengt nach
// final-Marker erst nach dem Anruf-Ende kam). // dem Auflegen noch im IN_CALL-Mode-Uebergang, Spotify schaut auf Focus-
audioService.resumeFromInterruption(30000).then(ok => { // Gain und wuerde sofort wieder LOSS sehen → bleibt pausiert.
if (ok) { // Mit Delay: Spotify resumed kurz, dann pausiert ARIA wieder ordnungs-
console.log('[PhoneCall] Auto-Resume von gemerkter Position gestartet'); // gemaess. Wenn ARIA nichts pending hat, bleibt Spotify einfach an.
} setTimeout(() => {
}).catch(() => {}); audioService.resumeFromInterruption(30000).then(ok => {
if (ok) {
console.log('[PhoneCall] Auto-Resume von gemerkter Position gestartet');
}
}).catch(() => {});
}, 800);
} }
} }
+59 -4
View File
@@ -677,7 +677,10 @@ class ARIABridge:
while self.running: while self.running:
try: try:
logger.info("[core] Verbinde: %s", self.ws_url) 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 # OpenClaw Handshake durchfuehren
if not await self._openclaw_handshake(ws): if not await self._openclaw_handshake(ws):
logger.error("[core] Handshake fehlgeschlagen — Reconnect") logger.error("[core] Handshake fehlgeschlagen — Reconnect")
@@ -783,13 +786,29 @@ class ARIABridge:
await self._emit_activity("idle", "") await self._emit_activity("idle", "")
if not text: if not text:
logger.warning("[core] chat final ohne Text: %s", json.dumps(payload)[:200]) 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 return
logger.info("[core] Antwort: '%s'", text[:80]) logger.info("[core] Antwort: '%s'", text[:80])
await self._process_core_response(text, payload) await self._process_core_response(text, payload)
return return
if state == "error": 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) logger.error("[core] Chat-Fehler: %s", error)
self._last_chat_final_at = asyncio.get_event_loop().time() self._last_chat_final_at = asyncio.get_event_loop().time()
await self._emit_activity("idle", "") await self._emit_activity("idle", "")
@@ -825,7 +844,12 @@ class ARIABridge:
return return
if event_name == "chat:error": 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) logger.error("[core] Chat-Fehler (legacy): %s", error)
await self._send_to_rvs({ await self._send_to_rvs({
"type": "chat", "type": "chat",
@@ -1141,7 +1165,8 @@ class ARIABridge:
try: try:
url = f"{current_url}?token={self.rvs_token}" url = f"{current_url}?token={self.rvs_token}"
logger.info("[rvs] Verbinde: %s", current_url) 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 self.ws_rvs = ws
retry_delay = 2 retry_delay = 2
logger.info("[rvs] Verbunden — warte auf App-Nachrichten") logger.info("[rvs] Verbunden — warte auf App-Nachrichten")
@@ -1461,6 +1486,31 @@ class ARIABridge:
size_kb = len(file_b64) // 1365 size_kb = len(file_b64) // 1365
logger.info("[rvs] Datei gespeichert: %s (%dKB)", file_path, size_kb) logger.info("[rvs] Datei gespeichert: %s (%dKB)", file_path, size_kb)
# 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) # 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))) 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(): if self._pending_files_flush_task and not self._pending_files_flush_task.done():
@@ -1634,6 +1684,11 @@ class ARIABridge:
} }
if audio_request_id: if audio_request_id:
stt_payload["audioRequestId"] = 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({ ok = await self._send_to_rvs({
"type": "chat", "type": "chat",
"payload": stt_payload, "payload": stt_payload,
+3
View File
@@ -16,3 +16,6 @@ sounddevice
# Wake-Word Erkennung # Wake-Word Erkennung
openwakeword openwakeword
# Bild-Resizing (zu grosse Pixel-Bilder shrinken bevor Claude-Vision sie sieht — 5MB-Limit)
Pillow
+4
View File
@@ -85,6 +85,10 @@ Wichtige Mechanismen:
- [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] **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] 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] **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 ### App Features