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