543ad3c46d
Stefan-Ergaenzung: nach Wake-Word muss Aufnahme, Senden und ARIA-
Antwort + TTS auch im Hintergrund klappen, und danach soll das ganze
wieder von vorne als Konversations-Schleife laufen.
Vorher hielt nur OpenWakeWordModule einen WakeLock (commit 408d20a).
Sobald Wake-Word erkannt wurde, ruft die JS-Seite OpenWakeWord.stop()
fuer das Mic-Handover an audioService.startRecording() — und der
WakeLock wurde released. Mid-Aufnahme konnte die CPU dann in Doze
gehen, Audio-Chunks erreichten die JS-Bridge nicht zuverlaessig.
Fix: AriaPlaybackService haelt selbst einen PARTIAL_WAKE_LOCK,
solange der Foreground-Service aktiv ist. acquireBackgroundAudio()
in der JS-Seite haelt den Service ueber alle Pipeline-Schritte
(wake → rec → tts → wake) durchgehend — damit ist der WakeLock
ueber die ganze Konversations-Schleife durchgehend aktiv.
Doppelter Schutz (WakeLock auch im OpenWakeWordModule) bleibt drin
als defense in depth — beide haben setReferenceCounted(false), also
keine doppel-buchhaltung, einfach robuster gegen einzeln-failende
acquires.
APK neu bauen erforderlich (native Kotlin-Aenderung).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
148 lines
5.8 KiB
Kotlin
148 lines
5.8 KiB
Kotlin
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.Context
|
|
import android.content.Intent
|
|
import android.os.Build
|
|
import android.os.IBinder
|
|
import android.os.PowerManager
|
|
import android.util.Log
|
|
import androidx.core.app.NotificationCompat
|
|
|
|
/**
|
|
* Foreground-Service der den App-Prozess waehrend TTS-Wiedergabe am Leben
|
|
* haelt — Android killt sonst den Prozess sobald die App im Hintergrund ist
|
|
* und ARIA verstummt mitten im Satz.
|
|
*
|
|
* Notification ist persistent (ongoing) waehrend der Service laeuft.
|
|
* Tap auf die Notification bringt MainActivity zurueck nach vorne.
|
|
*
|
|
* foregroundServiceType="mediaPlayback" ist Pflicht ab Android 14, sonst
|
|
* wirft startForeground() eine SecurityException.
|
|
*/
|
|
class AriaPlaybackService : Service() {
|
|
companion object {
|
|
private const val TAG = "AriaPlaybackService"
|
|
private const val CHANNEL_ID = "aria_playback"
|
|
private const val NOTIFICATION_ID = 1042
|
|
const val EXTRA_REASON = "reason" // "tts" | "wake" | "rec" | ""
|
|
}
|
|
|
|
private var currentReason: String = ""
|
|
|
|
// PARTIAL_WAKE_LOCK haelt die CPU wach solange der Foreground-Service
|
|
// aktiv ist. Damit bleibt die JS-Bridge im Doze ansprechbar und die
|
|
// gesamte Sprach-Pipeline (Wake → Aufnahme → POST → ARIA → TTS → wieder
|
|
// Wake) laeuft durchgehend im Hintergrund. Ein einziger Lock fuer den
|
|
// ganzen Foreground-Cycle, nicht pro Sub-Modul.
|
|
private var wakeLock: PowerManager.WakeLock? = null
|
|
|
|
override fun onCreate() {
|
|
super.onCreate()
|
|
ensureNotificationChannel()
|
|
acquireWakeLock()
|
|
}
|
|
|
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
|
val reason = intent?.getStringExtra(EXTRA_REASON) ?: ""
|
|
currentReason = reason
|
|
Log.i(TAG, "Foreground-Service start/update (reason=$reason)")
|
|
// Falls der Lock zwischendurch released wurde (z.B. nach onCreate-
|
|
// race oder OS-quirk), hier sicherheits-halber erneut anfordern.
|
|
acquireWakeLock()
|
|
try {
|
|
startForeground(NOTIFICATION_ID, buildNotification(reason))
|
|
} catch (e: Exception) {
|
|
Log.e(TAG, "startForeground fehlgeschlagen", e)
|
|
stopSelf()
|
|
}
|
|
// START_NOT_STICKY: wenn Android den Service killt, NICHT automatisch
|
|
// wieder starten — die App entscheidet wann der Service noetig ist.
|
|
return START_NOT_STICKY
|
|
}
|
|
|
|
override fun onDestroy() {
|
|
releaseWakeLock()
|
|
Log.i(TAG, "Foreground-Service gestoppt")
|
|
super.onDestroy()
|
|
}
|
|
|
|
private fun acquireWakeLock() {
|
|
if (wakeLock?.isHeld == true) return
|
|
try {
|
|
val pm = getSystemService(Context.POWER_SERVICE) as PowerManager
|
|
wakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK,
|
|
"AriaCockpit:Pipeline").apply {
|
|
setReferenceCounted(false)
|
|
acquire(8 * 60 * 60 * 1000L) // 8h Sicherheits-Cap
|
|
}
|
|
Log.i(TAG, "WakeLock acquired (CPU bleibt wach im Hintergrund)")
|
|
} catch (e: Exception) {
|
|
Log.w(TAG, "WakeLock acquire fehlgeschlagen: ${e.message}")
|
|
}
|
|
}
|
|
|
|
private fun releaseWakeLock() {
|
|
try {
|
|
wakeLock?.takeIf { it.isHeld }?.release()
|
|
if (wakeLock != null) Log.i(TAG, "WakeLock released")
|
|
} catch (e: Exception) {
|
|
Log.w(TAG, "WakeLock release fehlgeschlagen: ${e.message}")
|
|
}
|
|
wakeLock = null
|
|
}
|
|
|
|
override fun onBind(intent: Intent?): IBinder? = null
|
|
|
|
private fun ensureNotificationChannel() {
|
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
val nm = getSystemService(NotificationManager::class.java) ?: return
|
|
if (nm.getNotificationChannel(CHANNEL_ID) == null) {
|
|
val channel = NotificationChannel(
|
|
CHANNEL_ID,
|
|
"ARIA Audio-Wiedergabe",
|
|
NotificationManager.IMPORTANCE_LOW,
|
|
).apply {
|
|
description = "Notification waehrend ARIA spricht (haelt die App im Hintergrund am Leben)"
|
|
setShowBadge(false)
|
|
}
|
|
nm.createNotificationChannel(channel)
|
|
}
|
|
}
|
|
}
|
|
|
|
private fun buildNotification(reason: String): Notification {
|
|
val launchIntent = Intent(this, MainActivity::class.java).apply {
|
|
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
|
}
|
|
val pendingFlags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M)
|
|
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
|
else
|
|
PendingIntent.FLAG_UPDATE_CURRENT
|
|
val pendingIntent = PendingIntent.getActivity(this, 0, launchIntent, pendingFlags)
|
|
|
|
val (title, body) = when (reason) {
|
|
"tts" -> "ARIA spricht" to "Antwort wird abgespielt — antippen oeffnet die App"
|
|
"rec" -> "ARIA hoert zu" to "Sprachaufnahme laeuft — antippen oeffnet die App"
|
|
"wake" -> "ARIA bereit" to "Wake-Word lauscht passiv — antippen oeffnet die App"
|
|
else -> "ARIA aktiv" to "Hintergrund-Modus — antippen oeffnet die App"
|
|
}
|
|
|
|
return NotificationCompat.Builder(this, CHANNEL_ID)
|
|
.setContentTitle(title)
|
|
.setContentText(body)
|
|
.setSmallIcon(R.mipmap.ic_launcher)
|
|
.setContentIntent(pendingIntent)
|
|
.setOngoing(true)
|
|
.setShowWhen(false)
|
|
.setPriority(NotificationCompat.PRIORITY_LOW)
|
|
.setCategory(NotificationCompat.CATEGORY_SERVICE)
|
|
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
|
.build()
|
|
}
|
|
}
|