Compare commits

..

4 Commits

Author SHA1 Message Date
duffyduck b182ef5ed5 release: bump version to 0.1.6.5 2026-05-30 20:12:39 +02:00
duffyduck 9818dc1867 fix(app): Spotify resumed wieder nach TTS — nudgeMediaResume mit TRANSIENT
Stefan-Bug-Report: ARIA liest Nachricht vor, Spotify pausiert korrekt,
ARIA spricht durch — aber Spotify spielt danach NICHT automatisch
weiter. Sollte mit GAIN_TRANSIENT auto-resumen, tut es aber bei
manchen Spotify-Versionen/Geraeten nicht zuverlaessig.

Hintergrund: alte kickReleaseMedia() mit AUDIOFOCUS_GAIN (permanent)
war zu aggressiv (Spotify interpretierte als "user stoppte" =
Auto-Resume kaputt). Wurde entfernt. Jetzt ist das Pendel andersrum
zu weit: ohne Nudge keine Resume.

Sanfter Mittelweg: nudgeMediaResume() mit GAIN_TRANSIENT statt
GAIN-permanent. 100ms hold, abandon. Spotify bekommt Focus-Wechsel-
Hint ohne "user stopped"-Effekt.

audio.ts: nach AudioFocus.release() 50ms warten, dann nudgeMediaResume.
AudioFocusModule.kt: neue Methode + alte kickReleaseMedia bleibt mit
⚠️-Markierung fuer andere Use-Cases.

APK neu bauen erforderlich.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 20:09:55 +02:00
duffyduck 543ad3c46d fix(app): WakeLock auch im AriaPlaybackService — Pipeline-weiter Schutz
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>
2026-05-30 20:04:55 +02:00
duffyduck 408d20a087 fix(app): PARTIAL_WAKE_LOCK fuer Wake-Word — JS-Bridge bleibt im Hintergrund aktiv
Stefan-Bug-Report: Wake-Word wird im Hintergrund erkannt (Spotify
pausiert sofort), aber der Gong + Aufnahme-Start kommen erst wenn die
App in den Vordergrund geholt wird. Akku-Optimierung war bereits
deaktiviert ("Hintergrund aktiv").

Ursache: Foreground-Service haelt den App-Prozess am Leben + erlaubt
mic-Zugriff via foregroundServiceType=microphone. Aber: ohne expliziten
WakeLock kann die CPU im Doze-Mode (Display aus / Telefon idle) die
Auslieferung von DeviceEvents an die React-Native-JS-Bridge pausieren.
Folge: Native erkennt Wake-Word, ruft emit("WakeWordDetected"), aber
das Event queued sich nur — der JS-Listener (onWakeDetected → start-
Recording + playWakeReadySound) feuert erst beim naechsten JS-Tick,
und der kommt erst beim App-Resume.

Fix:
- AndroidManifest: WAKE_LOCK Permission hinzu (kein User-Prompt noetig,
  ist eine "normal" Permission).
- OpenWakeWordModule.kt: PowerManager.PARTIAL_WAKE_LOCK in start()
  acquired (8h Cap als Sicherheit), in stop() + dispose() released.
  Lock-Tag "AriaCockpit:WakeWordRecord" damit der in adb shell dumpsys
  power sichtbar ist.

Wirkung: solange Wake-Word "armed" ist, bleibt die CPU wach und die
JS-Bridge verarbeitet die Detection-Events live — Gong, Mic-Start,
ARIA-Antwort kommen ohne Foreground-Resume durch.

APK muss neu gebaut werden (native Kotlin-Aenderung).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 20:03:08 +02:00
7 changed files with 147 additions and 3 deletions
+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 10604 versionCode 10605
versionName "0.1.6.4" versionName "0.1.6.5"
// Fallback fuer Libraries mit Product Flavors // Fallback fuer Libraries mit Product Flavors
missingDimensionStrategy 'react-native-camera', 'general' missingDimensionStrategy 'react-native-camera', 'general'
} }
@@ -17,6 +17,11 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- WAKE_LOCK damit Wake-Word + JS-Bridge auch bei aus-Display und Doze
arbeiten: ohne Lock pausiert Android die CPU, Native-AudioRecord
laeuft weiter aber JS-Bridge frisst die DeviceEvents nicht mehr ->
Wake-Word wird erkannt aber callbacks feuern erst beim App-Resume. -->
<uses-permission android:name="android.permission.WAKE_LOCK" />
<application <application
android:name=".MainApplication" android:name=".MainApplication"
@@ -5,9 +5,11 @@ import android.app.NotificationChannel
import android.app.NotificationManager import android.app.NotificationManager
import android.app.PendingIntent import android.app.PendingIntent
import android.app.Service import android.app.Service
import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.Build import android.os.Build
import android.os.IBinder import android.os.IBinder
import android.os.PowerManager
import android.util.Log import android.util.Log
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
@@ -32,15 +34,26 @@ class AriaPlaybackService : Service() {
private var currentReason: String = "" 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() { override fun onCreate() {
super.onCreate() super.onCreate()
ensureNotificationChannel() ensureNotificationChannel()
acquireWakeLock()
} }
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val reason = intent?.getStringExtra(EXTRA_REASON) ?: "" val reason = intent?.getStringExtra(EXTRA_REASON) ?: ""
currentReason = reason currentReason = reason
Log.i(TAG, "Foreground-Service start/update (reason=$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 { try {
startForeground(NOTIFICATION_ID, buildNotification(reason)) startForeground(NOTIFICATION_ID, buildNotification(reason))
} catch (e: Exception) { } catch (e: Exception) {
@@ -53,10 +66,36 @@ class AriaPlaybackService : Service() {
} }
override fun onDestroy() { override fun onDestroy() {
releaseWakeLock()
Log.i(TAG, "Foreground-Service gestoppt") Log.i(TAG, "Foreground-Service gestoppt")
super.onDestroy() 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 override fun onBind(intent: Intent?): IBinder? = null
private fun ensureNotificationChannel() { private fun ensureNotificationChannel() {
@@ -131,6 +131,58 @@ class AudioFocusModule(reactContext: ReactApplicationContext) : ReactContextBase
promise.resolve(true) promise.resolve(true)
} }
/** Sanfter Spotify-Resume-Nudge: kurz USAGE_MEDIA mit TRANSIENT
* requesten und sofort abandonen. Spotify bekommt das als
* Focus-Frei-Signal und resumed automatisch — aber weil TRANSIENT
* (nicht GAIN permanent), interpretiert Spotify das NICHT als
* "user stopped" was Auto-Resume verhindert haette.
*
* Hintergrund: ARIA spricht TTS via USAGE_ASSISTANT GAIN_TRANSIENT,
* Spotify pausiert. ARIA released. Spotify SOLLTE nach
* TRANSIENT-Loss + Abandon automatisch resumen, tut es aber bei
* manchen Versionen / Geraeten nicht zuverlaessig. Dieser Nudge
* triggert den Focus-Stack-Refresh ohne den Spotify-Auto-Stop-Bug
* der alten kickReleaseMedia mit GAIN permanent.
*/
@ReactMethod
fun nudgeMediaResume(promise: Promise) {
val am = audioManager()
if (am == null) {
promise.resolve(false)
return
}
Thread {
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 nudgeListener = AudioManager.OnAudioFocusChangeListener { /* ignorieren */ }
val nudgeReq = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT)
.setAudioAttributes(attrs)
.setOnAudioFocusChangeListener(nudgeListener)
.build()
am.requestAudioFocus(nudgeReq)
Thread.sleep(100)
am.abandonAudioFocusRequest(nudgeReq)
} else {
val nudgeListener = AudioManager.OnAudioFocusChangeListener { /* ignorieren */ }
@Suppress("DEPRECATION")
am.requestAudioFocus(nudgeListener, AudioManager.STREAM_MUSIC,
AudioManager.AUDIOFOCUS_GAIN_TRANSIENT)
Thread.sleep(100)
@Suppress("DEPRECATION")
am.abandonAudioFocus(nudgeListener)
}
Log.i(TAG, "nudgeMediaResume: USAGE_MEDIA TRANSIENT request+abandon (Spotify-Resume-Trigger)")
} catch (e: Exception) {
Log.w(TAG, "nudgeMediaResume failed: ${e.message}")
}
}.start()
promise.resolve(true)
}
/** Den USAGE_MEDIA-Focus-Stack im System aufmischen, damit Spotify/YouTube /** Den USAGE_MEDIA-Focus-Stack im System aufmischen, damit Spotify/YouTube
* resumen wenn ein anderer Player (z.B. react-native-sound) seinen Focus * resumen wenn ein anderer Player (z.B. react-native-sound) seinen Focus
* nicht ordnungsgemaess released hat. Strategie: kurz selbst USAGE_MEDIA * nicht ordnungsgemaess released hat. Strategie: kurz selbst USAGE_MEDIA
@@ -140,6 +192,10 @@ class AudioFocusModule(reactContext: ReactApplicationContext) : ReactContextBase
* *
* Workaround fuer das react-native-sound-Bug: Sound.stop()/release() * Workaround fuer das react-native-sound-Bug: Sound.stop()/release()
* laesst den AudioFocusRequest haengen. * laesst den AudioFocusRequest haengen.
*
* ⚠️ ACHTUNG: nutzt AUDIOFOCUS_GAIN (permanent), Spotify kann das als
* "user-action stopp" interpretieren und Auto-Resume verhindern.
* Fuer Spotify-Resume nach TTS lieber nudgeMediaResume() nehmen (sanfter).
*/ */
@ReactMethod @ReactMethod
fun kickReleaseMedia(promise: Promise) { fun kickReleaseMedia(promise: Promise) {
@@ -4,6 +4,7 @@ import ai.onnxruntime.OnnxTensor
import ai.onnxruntime.OrtEnvironment import ai.onnxruntime.OrtEnvironment
import ai.onnxruntime.OrtSession import ai.onnxruntime.OrtSession
import android.Manifest import android.Manifest
import android.content.Context
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.media.AudioFormat import android.media.AudioFormat
import android.media.AudioRecord import android.media.AudioRecord
@@ -11,6 +12,7 @@ import android.media.MediaRecorder
import android.media.audiofx.AcousticEchoCanceler import android.media.audiofx.AcousticEchoCanceler
import android.media.audiofx.AutomaticGainControl import android.media.audiofx.AutomaticGainControl
import android.media.audiofx.NoiseSuppressor import android.media.audiofx.NoiseSuppressor
import android.os.PowerManager
import android.util.Log import android.util.Log
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import com.facebook.react.bridge.Promise import com.facebook.react.bridge.Promise
@@ -80,6 +82,13 @@ class OpenWakeWordModule(reactContext: ReactApplicationContext) : ReactContextBa
private var ns: NoiseSuppressor? = null private var ns: NoiseSuppressor? = null
private var agc: AutomaticGainControl? = null private var agc: AutomaticGainControl? = null
// PARTIAL_WAKE_LOCK damit die CPU bei aus-Display nicht in Doze geht und
// die JS-Bridge die WakeWordDetected-Events live verarbeitet (sonst
// queuen sich die Events nur und werden erst beim App-Foreground
// delivered — Stefan-Beobachtung: "Spotify pausiert, aber Gong/Aufnahme
// kommen erst wenn ich die App nach vorne hole").
private var wakeLock: PowerManager.WakeLock? = null
// Inferenz-State // Inferenz-State
private val melBuffer: ArrayList<FloatArray> = ArrayList(256) // Liste von 32-dim Frames private val melBuffer: ArrayList<FloatArray> = ArrayList(256) // Liste von 32-dim Frames
private var melProcessedIdx: Int = 0 private var melProcessedIdx: Int = 0
@@ -198,6 +207,21 @@ class OpenWakeWordModule(reactContext: ReactApplicationContext) : ReactContextBa
running.set(true) running.set(true)
record.startRecording() record.startRecording()
// PARTIAL_WAKE_LOCK greifen damit die CPU nicht in Doze geht und
// die JS-Bridge die emit("WakeWordDetected")-Events live verarbeitet.
// 8h Cap als Sicherheit gegen forgotten-release.
try {
val pm = reactApplicationContext.getSystemService(Context.POWER_SERVICE) as PowerManager
wakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK,
"AriaCockpit:WakeWordRecord").apply {
setReferenceCounted(false)
acquire(8 * 60 * 60 * 1000L)
}
Log.i(TAG, "WakeLock acquired")
} catch (e: Exception) {
Log.w(TAG, "WakeLock acquire fehlgeschlagen: ${e.message}")
}
captureThread = Thread({ captureLoop() }, "OpenWakeWordCapture").apply { captureThread = Thread({ captureLoop() }, "OpenWakeWordCapture").apply {
isDaemon = true isDaemon = true
start() start()
@@ -232,6 +256,7 @@ class OpenWakeWordModule(reactContext: ReactApplicationContext) : ReactContextBa
try { audioRecord?.release() } catch (_: Exception) {} try { audioRecord?.release() } catch (_: Exception) {}
audioRecord = null audioRecord = null
releaseAudioEffects() releaseAudioEffects()
releaseWakeLock()
Log.i(TAG, "Lauschen gestoppt") Log.i(TAG, "Lauschen gestoppt")
promise.resolve(true) promise.resolve(true)
} }
@@ -245,10 +270,21 @@ class OpenWakeWordModule(reactContext: ReactApplicationContext) : ReactContextBa
try { audioRecord?.release() } catch (_: Exception) {} try { audioRecord?.release() } catch (_: Exception) {}
audioRecord = null audioRecord = null
releaseAudioEffects() releaseAudioEffects()
releaseWakeLock()
disposeSessions() disposeSessions()
promise.resolve(true) promise.resolve(true)
} }
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
}
@ReactMethod @ReactMethod
fun isAvailable(promise: Promise) { fun isAvailable(promise: Promise) {
// Wake-Word ist immer verfuegbar (kein API-Key, alles on-device) // Wake-Word ist immer verfuegbar (kein API-Key, alles on-device)
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "aria-cockpit", "name": "aria-cockpit",
"version": "0.1.6.4", "version": "0.1.6.5",
"private": true, "private": true,
"scripts": { "scripts": {
"android": "react-native run-android", "android": "react-native run-android",
+8
View File
@@ -40,6 +40,7 @@ const { AudioFocus, PcmStreamPlayer } = NativeModules as {
AudioFocus?: { AudioFocus?: {
requestDuck: () => Promise<boolean>; requestDuck: () => Promise<boolean>;
requestExclusive: () => Promise<boolean>; requestExclusive: () => Promise<boolean>;
nudgeMediaResume: () => Promise<boolean>;
release: () => Promise<boolean>; release: () => Promise<boolean>;
kickReleaseMedia: () => Promise<boolean>; kickReleaseMedia: () => Promise<boolean>;
getMode?: () => Promise<number>; getMode?: () => Promise<number>;
@@ -332,6 +333,13 @@ class AudioService {
} }
console.log('[Audio] AudioFocus jetzt released'); console.log('[Audio] AudioFocus jetzt released');
AudioFocus?.release().catch(() => {}); AudioFocus?.release().catch(() => {});
// Spotify-Resume-Trigger: nach Abandon den USAGE_MEDIA-Focus-Stack
// mit kurzem TRANSIENT-Nudge aufmischen. Spotify resumed sonst bei
// manchen Versionen / Geraeten nicht zuverlaessig nach Auto-Loss.
// 50ms Delay damit das Abandon erst durch ist.
setTimeout(() => {
AudioFocus?.nudgeMediaResume().catch(() => {});
}, 50);
}, this.FOCUS_RELEASE_DELAY_MS); }, this.FOCUS_RELEASE_DELAY_MS);
} }