diff --git a/README.md b/README.md index 463d7a6..c2fc41b 100644 --- a/README.md +++ b/README.md @@ -853,6 +853,7 @@ docker exec aria-core ssh aria-wohnung hostname - [x] Wake-Word parallel zu TTS mit AcousticEchoCanceler — "Computer" sagen waehrend ARIA spricht stoppt sie und oeffnet Mikro - [x] GPS-Position mit Nachrichten mitsenden (Toggle in Settings) — ARIA nutzt sie nur bei standortbezogenen Fragen, im Chat sichtbar nur in ihrer Antwort - [x] Sprachnachrichten ohne STT-Result werden nach Timeout automatisch entfernt (skaliert mit Aufnahmedauer) +- [x] Background Audio Service: TTS laeuft auch bei minimierter App weiter (Foreground-Service mit MediaPlayback-Notification) - [x] Disk-Voll Banner in Diagnostic mit copy-baren Cleanup-Befehlen - [x] Wake-Word on-device via openWakeWord (ONNX Runtime, kein API-Key) + State-Icon diff --git a/android/android/app/src/main/AndroidManifest.xml b/android/android/app/src/main/AndroidManifest.xml index 9ff5098..ae06edb 100644 --- a/android/android/app/src/main/AndroidManifest.xml +++ b/android/android/app/src/main/AndroidManifest.xml @@ -6,6 +6,10 @@ + + + + + + diff --git a/android/android/app/src/main/java/com/ariacockpit/AriaPlaybackService.kt b/android/android/app/src/main/java/com/ariacockpit/AriaPlaybackService.kt new file mode 100644 index 0000000..f02addf --- /dev/null +++ b/android/android/app/src/main/java/com/ariacockpit/AriaPlaybackService.kt @@ -0,0 +1,96 @@ +package com.ariacockpit + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.Intent +import android.os.Build +import android.os.IBinder +import android.util.Log +import androidx.core.app.NotificationCompat + +/** + * Foreground-Service der den App-Prozess waehrend TTS-Wiedergabe am Leben + * haelt — Android killt sonst den Prozess sobald die App im Hintergrund ist + * und ARIA verstummt mitten im Satz. + * + * Notification ist persistent (ongoing) waehrend der Service laeuft. + * Tap auf die Notification bringt MainActivity zurueck nach vorne. + * + * foregroundServiceType="mediaPlayback" ist Pflicht ab Android 14, sonst + * wirft startForeground() eine SecurityException. + */ +class AriaPlaybackService : Service() { + companion object { + private const val TAG = "AriaPlaybackService" + private const val CHANNEL_ID = "aria_playback" + private const val NOTIFICATION_ID = 1042 + } + + override fun onCreate() { + super.onCreate() + ensureNotificationChannel() + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + Log.i(TAG, "Foreground-Service gestartet") + try { + startForeground(NOTIFICATION_ID, buildNotification()) + } catch (e: Exception) { + Log.e(TAG, "startForeground fehlgeschlagen", e) + stopSelf() + } + // START_NOT_STICKY: wenn Android den Service killt, NICHT automatisch + // wieder starten — die App entscheidet wann der Service noetig ist. + return START_NOT_STICKY + } + + override fun onDestroy() { + Log.i(TAG, "Foreground-Service gestoppt") + super.onDestroy() + } + + override fun onBind(intent: Intent?): IBinder? = null + + private fun ensureNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val nm = getSystemService(NotificationManager::class.java) ?: return + if (nm.getNotificationChannel(CHANNEL_ID) == null) { + val channel = NotificationChannel( + CHANNEL_ID, + "ARIA Audio-Wiedergabe", + NotificationManager.IMPORTANCE_LOW, + ).apply { + description = "Notification waehrend ARIA spricht (haelt die App im Hintergrund am Leben)" + setShowBadge(false) + } + nm.createNotificationChannel(channel) + } + } + } + + private fun buildNotification(): Notification { + val launchIntent = Intent(this, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP + } + val pendingFlags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + else + PendingIntent.FLAG_UPDATE_CURRENT + val pendingIntent = PendingIntent.getActivity(this, 0, launchIntent, pendingFlags) + + return NotificationCompat.Builder(this, CHANNEL_ID) + .setContentTitle("ARIA spricht") + .setContentText("Antwort wird abgespielt — antippen oeffnet die App") + .setSmallIcon(R.mipmap.ic_launcher) + .setContentIntent(pendingIntent) + .setOngoing(true) + .setShowWhen(false) + .setPriority(NotificationCompat.PRIORITY_LOW) + .setCategory(NotificationCompat.CATEGORY_SERVICE) + .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + .build() + } +} diff --git a/android/android/app/src/main/java/com/ariacockpit/BackgroundAudioModule.kt b/android/android/app/src/main/java/com/ariacockpit/BackgroundAudioModule.kt new file mode 100644 index 0000000..2361527 --- /dev/null +++ b/android/android/app/src/main/java/com/ariacockpit/BackgroundAudioModule.kt @@ -0,0 +1,58 @@ +package com.ariacockpit + +import android.content.Intent +import android.os.Build +import android.util.Log +import com.facebook.react.bridge.Promise +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.ReactContextBaseJavaModule +import com.facebook.react.bridge.ReactMethod + +/** + * RN-Bridge fuer den AriaPlaybackService. + * + * Wird vom JS waehrend einer TTS-Wiedergabe gestartet damit Android den + * App-Prozess nicht killt wenn die App im Hintergrund ist (= ARIA spricht + * weiter, auch wenn Stefan die App minimiert hat). + * + * Service stoppt entweder explizit per stop() oder wird von Android + * mitgekillt wenn der Prozess weg ist (was bei Foreground-Service nur + * passiert wenn der User die App force-stopped). + */ +class BackgroundAudioModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) { + override fun getName() = "BackgroundAudio" + + companion object { private const val TAG = "BackgroundAudio" } + + @ReactMethod + fun start(promise: Promise) { + try { + val ctx = reactApplicationContext + val intent = Intent(ctx, AriaPlaybackService::class.java) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + ctx.startForegroundService(intent) + } else { + ctx.startService(intent) + } + promise.resolve(true) + } catch (e: Exception) { + Log.w(TAG, "start fehlgeschlagen: ${e.message}") + promise.reject("START_FAILED", e.message ?: "Unbekannter Fehler", e) + } + } + + @ReactMethod + fun stop(promise: Promise) { + try { + val ctx = reactApplicationContext + ctx.stopService(Intent(ctx, AriaPlaybackService::class.java)) + promise.resolve(true) + } catch (e: Exception) { + Log.w(TAG, "stop fehlgeschlagen: ${e.message}") + promise.reject("STOP_FAILED", e.message ?: "Unbekannter Fehler", e) + } + } + + @ReactMethod fun addListener(eventName: String) {} + @ReactMethod fun removeListeners(count: Int) {} +} diff --git a/android/android/app/src/main/java/com/ariacockpit/BackgroundAudioPackage.kt b/android/android/app/src/main/java/com/ariacockpit/BackgroundAudioPackage.kt new file mode 100644 index 0000000..3115632 --- /dev/null +++ b/android/android/app/src/main/java/com/ariacockpit/BackgroundAudioPackage.kt @@ -0,0 +1,16 @@ +package com.ariacockpit + +import com.facebook.react.ReactPackage +import com.facebook.react.bridge.NativeModule +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.uimanager.ViewManager + +class BackgroundAudioPackage : ReactPackage { + override fun createNativeModules(reactContext: ReactApplicationContext): List { + return listOf(BackgroundAudioModule(reactContext)) + } + + override fun createViewManagers(reactContext: ReactApplicationContext): List> { + return emptyList() + } +} diff --git a/android/android/app/src/main/java/com/ariacockpit/MainApplication.kt b/android/android/app/src/main/java/com/ariacockpit/MainApplication.kt index 070130a..1b484d0 100644 --- a/android/android/app/src/main/java/com/ariacockpit/MainApplication.kt +++ b/android/android/app/src/main/java/com/ariacockpit/MainApplication.kt @@ -23,6 +23,7 @@ class MainApplication : Application(), ReactApplication { add(PcmStreamPlayerPackage()) add(OpenWakeWordPackage()) add(PhoneCallPackage()) + add(BackgroundAudioPackage()) } override fun getJSMainModuleName(): String = "index" diff --git a/android/src/screens/ChatScreen.tsx b/android/src/screens/ChatScreen.tsx index e891b53..cda9626 100644 --- a/android/src/screens/ChatScreen.tsx +++ b/android/src/screens/ChatScreen.tsx @@ -27,6 +27,7 @@ import audioService from '../services/audio'; import wakeWordService from '../services/wakeword'; import phoneCallService from '../services/phoneCall'; import { playWakeReadySound } from '../services/wakeReadySound'; +import { startBackgroundAudio, stopBackgroundAudio } from '../services/backgroundAudio'; import updateService from '../services/updater'; import VoiceButton from '../components/VoiceButton'; import FileUpload, { FileData } from '../components/FileUpload'; @@ -568,12 +569,16 @@ const ChatScreen: React.FC = () => { // TTS-Lifecycle: solange ARIA spricht und Wake-Word verfuegbar ist, // parallel mitlauschen — User kann "Computer" sagen statt manuell tappen. + // PLUS: Foreground-Service starten damit Android den App-Prozess nicht + // killt wenn die App im Hintergrund ist (TTS waere sonst mitten im Satz weg). const unsubTtsStart = audioService.onPlaybackStarted(() => { + startBackgroundAudio().catch(() => {}); if (wakeWordService.isConversing() && wakeWordService.hasWakeWord()) { wakeWordService.startBargeListening().catch(() => {}); } }); const unsubTtsEnd = audioService.onPlaybackFinished(() => { + stopBackgroundAudio().catch(() => {}); // Vor naechster Aufnahme: barge-listening aus damit der AudioRecorder // das Mikro greifen kann. wakeWordService.stopBargeListening().catch(() => {}); diff --git a/android/src/services/audio.ts b/android/src/services/audio.ts index 59db530..8c891d9 100644 --- a/android/src/services/audio.ts +++ b/android/src/services/audio.ts @@ -10,6 +10,7 @@ import { Platform, PermissionsAndroid, NativeModules, ToastAndroid } from 'react import Sound from 'react-native-sound'; import RNFS from 'react-native-fs'; import AsyncStorage from '@react-native-async-storage/async-storage'; +import { stopBackgroundAudio } from './backgroundAudio'; import AudioRecorderPlayer, { AudioEncoderAndroidType, AudioSourceAndroidType, @@ -898,6 +899,9 @@ class AudioService { /** Laufende Wiedergabe stoppen + Queue leeren */ stopPlayback(): void { + // Foreground-Service auch stoppen — sonst bleibt die Notification haengen + // wenn Wiedergabe abgebrochen wird (Anruf, Cancel, Barge-In). + stopBackgroundAudio().catch(() => {}); this.audioQueue = []; this.isPlaying = false; if (this.currentSound) { diff --git a/android/src/services/backgroundAudio.ts b/android/src/services/backgroundAudio.ts new file mode 100644 index 0000000..59b1393 --- /dev/null +++ b/android/src/services/backgroundAudio.ts @@ -0,0 +1,45 @@ +/** + * Background-Audio: ARIAs TTS soll auch bei minimierter App weiterlaufen. + * Wir starten dafuer einen Foreground-Service mit foregroundServiceType= + * mediaPlayback, der eine persistente Notification zeigt waehrend ARIA spricht. + * + * API ist intentional simpel — start() vor TTS-Wiedergabe, stop() danach. + * Idempotent: mehrfaches start/stop ist sicher. + */ + +import { NativeModules } from 'react-native'; + +interface BackgroundAudioNative { + start(): Promise; + stop(): Promise; +} + +const { BackgroundAudio } = NativeModules as { BackgroundAudio?: BackgroundAudioNative }; + +let active = false; + +export async function startBackgroundAudio(): Promise { + if (active || !BackgroundAudio) return; + try { + await BackgroundAudio.start(); + active = true; + console.log('[BackgroundAudio] Foreground-Service gestartet'); + } catch (err: any) { + console.warn('[BackgroundAudio] start fehlgeschlagen:', err?.message || err); + } +} + +export async function stopBackgroundAudio(): Promise { + if (!active || !BackgroundAudio) return; + try { + await BackgroundAudio.stop(); + active = false; + console.log('[BackgroundAudio] Foreground-Service gestoppt'); + } catch (err: any) { + console.warn('[BackgroundAudio] stop fehlgeschlagen:', err?.message || err); + } +} + +export function isBackgroundAudioActive(): boolean { + return active; +} diff --git a/issue.md b/issue.md index 1e6eea2..1a44f7f 100644 --- a/issue.md +++ b/issue.md @@ -109,6 +109,8 @@ - [x] **Bereit-Sound (Airplane Ding-Dong) wenn Mikro nach Wake-Word offen** — akustische Bestaetigung statt nur Toast. Toggle in Settings → Wake-Word, default aktiv - [x] **Wake-Word parallel zu TTS** mit AcousticEchoCanceler: User sagt "Computer" waehrend ARIA spricht → TTS verstummt sofort, neue Aufnahme startet. Native AEC verhindert dass ARIAs eigene Stimme das Wake-Word triggert. Audio-Source ist VOICE_COMMUNICATION + zusaetzlich AEC/NS/AGC-Effekte aktiviert - [x] **GPS-Position mitsenden**: Toggle in Settings → Allgemein → Standort, persistiert in AsyncStorage, ChatScreen pollt den Wert. Wenn aktiv wird lat/lon mit jeder chat/audio-Message mitgegeben. Bridge prefixed den Text fuer aria-core mit GPS-Hint (mit Anweisung dass die Position nur bei Bedarf erwaehnt wird, nicht automatisch). Im App-Chat sieht man die Position nicht, nur ARIAs Antwort kann darauf eingehen +- [x] **Background Audio Service**: TTS laeuft auch bei minimierter App weiter. Foreground-Service mit foregroundServiceType=mediaPlayback haelt den Prozess am Leben, persistente Notification "ARIA spricht — antippen oeffnet die App". Service startet bei TTS-Beginn, stoppt bei Ende oder Cancel/Barge-In/Anruf +- [x] Manueller Mikro-Stop beendet Wake-Word-Konversation: Tap auf Mikro-Knopf waehrend conversing → audio raus + zurueck zu armed (= Wake-Word lauscht wieder, kein Auto-Mikro nach ARIAs Antwort). VAD-Auto-Stop bleibt bei Multi-Turn - [x] Sprachnachrichten ohne STT-Result werden nach 60s+Aufnahmedauer automatisch entfernt (sicher genug fuer 5-30min-Aufnahmen, schnell genug fuer leere Wake-Word-Echos) - [x] VAD adaptive Baseline robuster: minimum statt avg + Cap auf -50dB bis -28dB (Stille) / -40dB bis -18dB (Speech) — keine "tote" VAD-Konfiguration mehr bei lauter Umgebung oder Wake-Word-Echo @@ -118,7 +120,6 @@ ### App Features - [ ] Chat-History zuverlaessiger laden (AsyncStorage Race Condition) -- [ ] Background Audio Service (TTS auch bei minimierter App) - [ ] Custom-Wake-Word-Upload via Diagnostic (eigene .onnx-Files ohne App-Rebuild) - [ ] Pause+Resume bei Anruf: aktuell wird der TTS-Stream bei Klingeln hart gestoppt, schoener waere Pause + Resume nach Auflegen