From e3a224478d3f264588315ccad1bc2ec5ac934a92 Mon Sep 17 00:00:00 2001 From: duffyduck Date: Sat, 6 Jun 2026 09:29:00 +0200 Subject: [PATCH] fix(wakeword): Mic an andere Apps freigeben (WhatsApp-Voicenote etc.) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug: ARIAs VOICE_COMMUNICATION-Lock liess WhatsApp-Sprachnachrichten, Sprach-Suchen u.ae. nur Stille aufnehmen — Android-Audio-Policy gibt der zweiten App formal Audio, liefert aber Null-Samples solange unsere Pipeline aktiv ist. Fix: AudioRecordingCallback (API 24+) registriert sich beim start() und beobachtet andere Mic-Sessions. Fremder Mic-User detected → unsere AudioRecord + Effects sofort freigeben (externallyPaused=true), WakeLock + Callback bleiben aktiv. Fremder weg → 300ms warten (Audio-Stack- Settling), nochmal pruefen, dann reaktivieren. Refactor mit drin: start()/stop() benutzen jetzt zwei Helper (acquireAndStartRecording, stopAndReleaseRecording) damit die Mic-Setup- Logik nicht zwischen start() und resumeAfterExternal() dupliziert wird. Trade-offs: - Wake-Word taub solange andere App das Mic nutzt — akzeptabel. - API < 24: kein Callback verfuegbar, alter Stand (kein Mic-Sharing). - Phone-Call kollidiert nicht mit phoneCallService.pauseForCall — beide pausieren/reaktivieren idempotent. Co-Authored-By: Claude Opus 4.7 --- .../com/ariacockpit/OpenWakeWordModule.kt | 247 +++++++++++++----- 1 file changed, 179 insertions(+), 68 deletions(-) diff --git a/android/android/app/src/main/java/com/ariacockpit/OpenWakeWordModule.kt b/android/android/app/src/main/java/com/ariacockpit/OpenWakeWordModule.kt index d77adae..5919df2 100644 --- a/android/android/app/src/main/java/com/ariacockpit/OpenWakeWordModule.kt +++ b/android/android/app/src/main/java/com/ariacockpit/OpenWakeWordModule.kt @@ -7,11 +7,16 @@ import android.Manifest import android.content.Context import android.content.pm.PackageManager import android.media.AudioFormat +import android.media.AudioManager import android.media.AudioRecord +import android.media.AudioRecordingConfiguration import android.media.MediaRecorder import android.media.audiofx.AcousticEchoCanceler import android.media.audiofx.AutomaticGainControl import android.media.audiofx.NoiseSuppressor +import android.os.Build +import android.os.Handler +import android.os.Looper import android.os.PowerManager import android.util.Log import androidx.core.content.ContextCompat @@ -104,6 +109,20 @@ class OpenWakeWordModule(reactContext: ReactApplicationContext) : ReactContextBa // Zeitpunkt des letzten startRecording — fuer STARTUP_SUPPRESSION_MS-Fenster private var recordingStartedMs: Long = 0L + // Audio-Sharing mit anderen Apps: + // Wenn z.B. WhatsApp eine Sprachnachricht aufnimmt, dann hält ARIAs + // VOICE_COMMUNICATION-Lock zwar das System nicht offiziell exklusiv, + // aber die Foreground-App bekommt nur Stille — die WhatsApp-Aufnahme + // ist tonlos. Loesung: AudioRecordingCallback hoeren, sobald eine andere + // App das Mic anfordert → unsere AudioRecord freigeben (externallyPaused=true). + // Wenn die andere App fertig ist → reaktivieren. Wakeword pausiert solange. + private var recordingCallback: AudioManager.AudioRecordingCallback? = null + @Volatile private var externallyPaused: Boolean = false + private val mainHandler: Handler by lazy { Handler(Looper.getMainLooper()) } + private val audioManager: AudioManager by lazy { + reactApplicationContext.getSystemService(Context.AUDIO_SERVICE) as AudioManager + } + /** * Initialisiert die ONNX-Sessions fuer ein bestimmtes Wake-Word. * modelName: dateiname ohne Suffix (z.B. "hey_jarvis", "alexa", "hey_mycroft", "hey_rhasspy") @@ -167,54 +186,7 @@ class OpenWakeWordModule(reactContext: ReactApplicationContext) : ReactContextBa } try { - val minBuf = AudioRecord.getMinBufferSize( - SAMPLE_RATE, - AudioFormat.CHANNEL_IN_MONO, - AudioFormat.ENCODING_PCM_16BIT, - ).coerceAtLeast(CHUNK_SAMPLES * 2 * 4) - - // VOICE_COMMUNICATION-Source: aktiviert auf den meisten Android-Geraeten - // automatisch Echo-Cancellation + Noise-Suppression. Wichtig damit - // ARIAs eigene Stimme nicht das Wake-Word triggert wenn parallel - // zur TTS-Wiedergabe gelauscht wird. - val record = AudioRecord( - MediaRecorder.AudioSource.VOICE_COMMUNICATION, - SAMPLE_RATE, - AudioFormat.CHANNEL_IN_MONO, - AudioFormat.ENCODING_PCM_16BIT, - minBuf, - ) - if (record.state != AudioRecord.STATE_INITIALIZED) { - record.release() - promise.reject("AUDIO_INIT", "AudioRecord nicht initialisiert (Mikro belegt?)") - return - } - audioRecord = record - - // Audio-Effects ZUSAETZLICH explizit aktivieren — manche Geraete - // benoetigen das, obwohl VOICE_COMMUNICATION es eigentlich schon - // mitbringt. Failure ist nicht kritisch (continue ohne Effects). - try { - if (AcousticEchoCanceler.isAvailable()) { - aec = AcousticEchoCanceler.create(record.audioSessionId)?.apply { enabled = true } - Log.i(TAG, "AEC aktiviert (enabled=${aec?.enabled})") - } - } catch (e: Exception) { Log.w(TAG, "AEC failed: ${e.message}") } - try { - if (NoiseSuppressor.isAvailable()) { - ns = NoiseSuppressor.create(record.audioSessionId)?.apply { enabled = true } - } - } catch (e: Exception) { Log.w(TAG, "NS failed: ${e.message}") } - try { - if (AutomaticGainControl.isAvailable()) { - agc = AutomaticGainControl.create(record.audioSessionId)?.apply { enabled = true } - } - } catch (e: Exception) { Log.w(TAG, "AGC failed: ${e.message}") } - - resetInferenceState() - running.set(true) - record.startRecording() - recordingStartedMs = System.currentTimeMillis() + acquireAndStartRecording() // PARTIAL_WAKE_LOCK greifen damit die CPU nicht in Doze geht und // die JS-Bridge die emit("WakeWordDetected")-Events live verarbeitet. @@ -231,10 +203,10 @@ class OpenWakeWordModule(reactContext: ReactApplicationContext) : ReactContextBa Log.w(TAG, "WakeLock acquire fehlgeschlagen: ${e.message}") } - captureThread = Thread({ captureLoop() }, "OpenWakeWordCapture").apply { - isDaemon = true - start() - } + // AudioRecordingCallback registrieren: andere Apps (WhatsApp- + // Sprachnachricht, Telefonate etc.) wollen das Mic — wir geben + // es vorruebergehend frei statt sie ins Leere recorden zu lassen. + registerRecordingCallback() Log.i(TAG, "Lauschen gestartet (model=$modelName)") promise.resolve(true) @@ -247,6 +219,75 @@ class OpenWakeWordModule(reactContext: ReactApplicationContext) : ReactContextBa } } + /** Reine AudioRecord + Effects + Capture-Thread-Acquisition. Wirft bei + * Fehler — Caller faengt + reportet. Kein WakeLock, keine Callbacks. */ + private fun acquireAndStartRecording() { + val minBuf = AudioRecord.getMinBufferSize( + SAMPLE_RATE, + AudioFormat.CHANNEL_IN_MONO, + AudioFormat.ENCODING_PCM_16BIT, + ).coerceAtLeast(CHUNK_SAMPLES * 2 * 4) + + // VOICE_COMMUNICATION-Source: aktiviert auf den meisten Android-Geraeten + // automatisch Echo-Cancellation + Noise-Suppression. Wichtig damit + // ARIAs eigene Stimme nicht das Wake-Word triggert wenn parallel + // zur TTS-Wiedergabe gelauscht wird. + val record = AudioRecord( + MediaRecorder.AudioSource.VOICE_COMMUNICATION, + SAMPLE_RATE, + AudioFormat.CHANNEL_IN_MONO, + AudioFormat.ENCODING_PCM_16BIT, + minBuf, + ) + if (record.state != AudioRecord.STATE_INITIALIZED) { + record.release() + throw IllegalStateException("AudioRecord nicht initialisiert (Mikro belegt?)") + } + audioRecord = record + + // Audio-Effects ZUSAETZLICH explizit aktivieren — manche Geraete + // benoetigen das, obwohl VOICE_COMMUNICATION es eigentlich schon + // mitbringt. Failure ist nicht kritisch (continue ohne Effects). + try { + if (AcousticEchoCanceler.isAvailable()) { + aec = AcousticEchoCanceler.create(record.audioSessionId)?.apply { enabled = true } + Log.i(TAG, "AEC aktiviert (enabled=${aec?.enabled})") + } + } catch (e: Exception) { Log.w(TAG, "AEC failed: ${e.message}") } + try { + if (NoiseSuppressor.isAvailable()) { + ns = NoiseSuppressor.create(record.audioSessionId)?.apply { enabled = true } + } + } catch (e: Exception) { Log.w(TAG, "NS failed: ${e.message}") } + try { + if (AutomaticGainControl.isAvailable()) { + agc = AutomaticGainControl.create(record.audioSessionId)?.apply { enabled = true } + } + } catch (e: Exception) { Log.w(TAG, "AGC failed: ${e.message}") } + + resetInferenceState() + running.set(true) + record.startRecording() + recordingStartedMs = System.currentTimeMillis() + + captureThread = Thread({ captureLoop() }, "OpenWakeWordCapture").apply { + isDaemon = true + start() + } + } + + /** Reine AudioRecord + Effects + Capture-Thread-Release. Sicher (catch all). + * Kein WakeLock-Release, kein Unregistrieren der Callbacks. */ + private fun stopAndReleaseRecording() { + running.set(false) + try { captureThread?.join(1500) } catch (_: InterruptedException) {} + captureThread = null + try { audioRecord?.stop() } catch (_: Exception) {} + try { audioRecord?.release() } catch (_: Exception) {} + audioRecord = null + releaseAudioEffects() + } + private fun releaseAudioEffects() { try { aec?.release() } catch (_: Exception) {} try { ns?.release() } catch (_: Exception) {} @@ -256,15 +297,9 @@ class OpenWakeWordModule(reactContext: ReactApplicationContext) : ReactContextBa @ReactMethod fun stop(promise: Promise) { - running.set(false) - try { - captureThread?.join(1500) - } catch (_: InterruptedException) {} - captureThread = null - try { audioRecord?.stop() } catch (_: Exception) {} - try { audioRecord?.release() } catch (_: Exception) {} - audioRecord = null - releaseAudioEffects() + unregisterRecordingCallback() + externallyPaused = false + stopAndReleaseRecording() releaseWakeLock() Log.i(TAG, "Lauschen gestoppt") promise.resolve(true) @@ -272,18 +307,94 @@ class OpenWakeWordModule(reactContext: ReactApplicationContext) : ReactContextBa @ReactMethod fun dispose(promise: Promise) { - running.set(false) - try { captureThread?.join(1000) } catch (_: InterruptedException) {} - captureThread = null - try { audioRecord?.stop() } catch (_: Exception) {} - try { audioRecord?.release() } catch (_: Exception) {} - audioRecord = null - releaseAudioEffects() + unregisterRecordingCallback() + externallyPaused = false + stopAndReleaseRecording() releaseWakeLock() disposeSessions() promise.resolve(true) } + // ── External-Mic-Sharing (AudioRecordingCallback) ────────────────────── + // + // Wenn eine andere App das Mic anfordert (WhatsApp-Voicenote, Telefonie, + // Sprach-Suche im Browser etc.), kriegt die zwar formal Audio — aber + // unsere VOICE_COMMUNICATION-Pipeline blockiert die naively neue Aufnahme + // mit Stille (Android-Audio-Policy). Loesung: AudioRecordingCallback + // beobachten, andere Recorder-Sessions detecten, und unsere Pipeline + // temporaer freigeben. Sobald die andere App fertig ist → reaktivieren. + // + // Effekt: Wake-Word funktioniert solange nicht — fairer Kompromiss. + + private fun registerRecordingCallback() { + if (recordingCallback != null) return + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) { + Log.i(TAG, "AudioRecordingCallback nicht verfuegbar (API < 24) — Mic-Sharing inaktiv") + return + } + val cb = object : AudioManager.AudioRecordingCallback() { + override fun onRecordingConfigChanged(configs: MutableList?) { + handleRecordingConfigChange(configs) + } + } + try { + audioManager.registerAudioRecordingCallback(cb, mainHandler) + recordingCallback = cb + Log.i(TAG, "AudioRecordingCallback registriert — beobachtet andere Mic-User") + } catch (e: Exception) { + Log.w(TAG, "registerAudioRecordingCallback failed: ${e.message}") + } + } + + private fun unregisterRecordingCallback() { + val cb = recordingCallback ?: return + try { audioManager.unregisterAudioRecordingCallback(cb) } catch (_: Exception) {} + recordingCallback = null + } + + private fun handleRecordingConfigChange(configs: MutableList?) { + if (configs == null) return + // Unsere eigene Session anhand der audioSessionId filtern. Wenn wir + // gerade keinen AudioRecord halten (externallyPaused), ist alles + // andere "extern" — dann zaehlt jeder Eintrag. + val ourSessionId = audioRecord?.audioSessionId + val externalActive = configs.any { + ourSessionId == null || it.clientAudioSessionId != ourSessionId + } + if (running.get() && externalActive) { + Log.i(TAG, "Andere App nutzt Mic — Wake-Word pausiert (configs=${configs.size})") + externallyPaused = true + stopAndReleaseRecording() + return + } + if (externallyPaused && !externalActive) { + Log.i(TAG, "Mic wieder frei — Wake-Word reaktiviert in 300ms") + // Kurze Pause: der "andere" hat eben losgelassen, Audio-Stack braucht + // ein paar ms bis VOICE_COMMUNICATION wieder sauber initialisiert. + mainHandler.postDelayed({ + if (!externallyPaused) return@postDelayed // schon resumed + // Sicherheitscheck: wenn inzwischen jemand wieder rein ist + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + val cur = audioManager.activeRecordingConfigurations + if (cur != null && cur.isNotEmpty()) { + Log.i(TAG, "Resume verworfen — anderer Mic-User noch da (${cur.size})") + return@postDelayed + } + } + externallyPaused = false + try { + acquireAndStartRecording() + Log.i(TAG, "Wake-Word nach External-Pause reaktiviert") + } catch (e: Exception) { + Log.w(TAG, "Resume nach External-Pause failed: ${e.message}") + // bleiben unten — falls anderer App das Mic doch wieder + // freigibt, feuert der Callback erneut. + externallyPaused = true + } + }, 300L) + } + } + private fun releaseWakeLock() { try { wakeLock?.takeIf { it.isHeld }?.release()