From fec8aa977b39d1e0ba5d1a87e9237972b0f92cfc Mon Sep 17 00:00:00 2001 From: duffyduck Date: Sun, 3 May 2026 21:44:58 +0200 Subject: [PATCH] feat(audio): TTS pausiert bei Anruf + Conversation-Focus haelt Spotify durchgehend gepaust MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug 1a — Anruf-Pause: Neues PhoneCallModule.kt nutzt TelephonyCallback (API 31+) bzw. PhoneStateListener (Pre-12) um auf RINGING/OFFHOOK/IDLE zu reagieren. Bei Klingeln/Gespraech ruft phoneCall.ts → audioService.haltAllPlayback, ARIA verstummt sofort. READ_PHONE_STATE Permission wird beim ersten Start angefragt; ohne Permission failt der Listener leise. Bug 1b — Spotify-Resume: AudioFocus wird jetzt an den Conversation-Lifecycle gekoppelt statt an einzelne Streams. Solange wakeWordState 'conversing' ist, blockt acquireConversationFocus() jeden per-Stream-Release. Erst beim Wechsel auf 'armed'/'off' darf der Focus tatsaechlich freigegeben werden. Verhindert das "Spotify kommt nach 10s wieder hoch"-Phaenomen auch ueber Render-Pausen + zwischen mehreren ARIA-Antworten hinweg. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../android/app/src/main/AndroidManifest.xml | 2 + .../java/com/ariacockpit/MainApplication.kt | 1 + .../java/com/ariacockpit/PhoneCallModule.kt | 126 ++++++++++++++++++ .../java/com/ariacockpit/PhoneCallPackage.kt | 16 +++ android/src/screens/ChatScreen.tsx | 14 ++ android/src/services/audio.ts | 42 +++++- android/src/services/phoneCall.ts | 108 +++++++++++++++ 7 files changed, 308 insertions(+), 1 deletion(-) create mode 100644 android/android/app/src/main/java/com/ariacockpit/PhoneCallModule.kt create mode 100644 android/android/app/src/main/java/com/ariacockpit/PhoneCallPackage.kt create mode 100644 android/src/services/phoneCall.ts diff --git a/android/android/app/src/main/AndroidManifest.xml b/android/android/app/src/main/AndroidManifest.xml index b287d8a..9ff5098 100644 --- a/android/android/app/src/main/AndroidManifest.xml +++ b/android/android/app/src/main/AndroidManifest.xml @@ -4,6 +4,8 @@ + + = Build.VERSION_CODES.S) { + val cb = object : TelephonyCallback(), TelephonyCallback.CallStateListener { + override fun onCallStateChanged(state: Int) { + handleStateChange(state) + } + } + tm.registerTelephonyCallback(reactApplicationContext.mainExecutor, cb) + modernCallback = cb + } else { + @Suppress("DEPRECATION") + val l = object : PhoneStateListener() { + override fun onCallStateChanged(state: Int, phoneNumber: String?) { + handleStateChange(state) + } + } + @Suppress("DEPRECATION") + tm.listen(l, PhoneStateListener.LISTEN_CALL_STATE) + legacyListener = l + } + Log.i(TAG, "PhoneCall-Listener aktiv") + promise.resolve(true) + } catch (e: Exception) { + Log.e(TAG, "start fehlgeschlagen", e) + promise.reject("START_FAILED", e.message ?: "Unbekannter Fehler", e) + } + } + + @ReactMethod + fun stop(promise: Promise) { + try { + val tm = telephonyManager + if (tm != null) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + (modernCallback as? TelephonyCallback)?.let { tm.unregisterTelephonyCallback(it) } + modernCallback = null + } else { + @Suppress("DEPRECATION") + legacyListener?.let { tm.listen(it, PhoneStateListener.LISTEN_NONE) } + legacyListener = null + } + } + telephonyManager = null + lastState = TelephonyManager.CALL_STATE_IDLE + promise.resolve(true) + } catch (e: Exception) { + promise.reject("STOP_FAILED", e.message ?: "") + } + } + + private fun handleStateChange(state: Int) { + if (state == lastState) return + lastState = state + val name = when (state) { + TelephonyManager.CALL_STATE_RINGING -> "ringing" + TelephonyManager.CALL_STATE_OFFHOOK -> "offhook" + TelephonyManager.CALL_STATE_IDLE -> "idle" + else -> return + } + Log.i(TAG, "Telefon-State: $name") + val params = Arguments.createMap().apply { putString("state", name) } + try { + reactApplicationContext.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java) + .emit("PhoneCallStateChanged", params) + } catch (e: Exception) { + Log.w(TAG, "Event-emit fehlgeschlagen: ${e.message}") + } + } + + @ReactMethod fun addListener(eventName: String) {} + @ReactMethod fun removeListeners(count: Int) {} +} diff --git a/android/android/app/src/main/java/com/ariacockpit/PhoneCallPackage.kt b/android/android/app/src/main/java/com/ariacockpit/PhoneCallPackage.kt new file mode 100644 index 0000000..7fe6a01 --- /dev/null +++ b/android/android/app/src/main/java/com/ariacockpit/PhoneCallPackage.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 PhoneCallPackage : ReactPackage { + override fun createNativeModules(reactContext: ReactApplicationContext): List { + return listOf(PhoneCallModule(reactContext)) + } + + override fun createViewManagers(reactContext: ReactApplicationContext): List> { + return emptyList() + } +} diff --git a/android/src/screens/ChatScreen.tsx b/android/src/screens/ChatScreen.tsx index be62d97..5be0fe6 100644 --- a/android/src/screens/ChatScreen.tsx +++ b/android/src/screens/ChatScreen.tsx @@ -25,6 +25,7 @@ import RNFS from 'react-native-fs'; import rvs, { RVSMessage, ConnectionState } from '../services/rvs'; import audioService from '../services/audio'; import wakeWordService from '../services/wakeword'; +import phoneCallService from '../services/phoneCall'; import updateService from '../services/updater'; import VoiceButton from '../components/VoiceButton'; import FileUpload, { FileData } from '../components/FileUpload'; @@ -159,10 +160,23 @@ const ChatScreen: React.FC = () => { const unsub = wakeWordService.onStateChange((s) => { setWakeWordState(s); setWakeWordActive(s !== 'off'); + // Conversation-Focus an Wake-Word-State koppeln: solange wir aktiv im + // Dialog sind, soll Spotify dauerhaft gepaust bleiben (auch ueber + // Render-Pausen + zwischen Antworten hinweg). Sobald wir zurueck nach + // 'armed' oder 'off' fallen, darf Spotify wieder. + if (s === 'conversing') audioService.acquireConversationFocus(); + else audioService.releaseConversationFocus(); }); return () => unsub(); }, []); + // Anruf-Erkennung: TTS pausieren wenn das Telefon klingelt + useEffect(() => { + phoneCallService.start().catch(err => + console.warn('[Chat] phoneCall.start fehlgeschlagen', err)); + return () => { phoneCallService.stop().catch(() => {}); }; + }, []); + // ttsCanPlayRef live aktuell halten — Closure in onMessage unten liest // darueber statt direkt ttsDeviceEnabled/ttsMuted (sonst stale). useEffect(() => { diff --git a/android/src/services/audio.ts b/android/src/services/audio.ts index e10a570..bd197c0 100644 --- a/android/src/services/audio.ts +++ b/android/src/services/audio.ts @@ -198,6 +198,12 @@ class AudioService { private focusReleaseTimer: ReturnType | null = null; private readonly FOCUS_RELEASE_DELAY_MS = 800; + // Conversation-Mode: solange aktiv (Wake-Word Status 'conversing' ODER + // wir wissen "ARIA spricht gerade in einem Multi-Turn-Dialog"), halten wir + // den AudioFocus DAUERHAFT. Der per-Stream-Release wird unterdrueckt, + // damit Spotify nicht in Render-Pausen oder zwischen Antworten zurueckkehrt. + private _conversationFocusActive: boolean = false; + // VAD State private vadEnabled: boolean = false; private lastSpeechTime: number = 0; @@ -214,11 +220,18 @@ class AudioService { /** AudioFocus mit kleiner Verzoegerung freigeben — Spotify/YouTube * springen sonst im Gap zwischen zwei TTS-Streams (oder wenn ARIA - * eine zweite Antwort direkt hinterherschickt) kurz wieder an. */ + * eine zweite Antwort direkt hinterherschickt) kurz wieder an. + * Im Conversation-Mode (Wake-Word conversing) wird das Release komplett + * unterdrueckt — der Focus bleibt fuer die ganze Konversation gehalten. */ private _releaseFocusDeferred(): void { + if (this._conversationFocusActive) { + this._cancelDeferredFocusRelease(); + return; + } this._cancelDeferredFocusRelease(); this.focusReleaseTimer = setTimeout(() => { this.focusReleaseTimer = null; + if (this._conversationFocusActive) return; AudioFocus?.release().catch(() => {}); }, this.FOCUS_RELEASE_DELAY_MS); } @@ -230,6 +243,33 @@ class AudioService { } } + /** Conversation-Mode beginnt → AudioFocus dauerhaft halten (Spotify bleibt + * pausiert). Idempotent: mehrfaches Aufrufen ist sicher. */ + acquireConversationFocus(): void { + if (this._conversationFocusActive) return; + this._conversationFocusActive = true; + this._cancelDeferredFocusRelease(); + console.log('[Audio] Conversation-Focus aktiv (Spotify bleibt gepaust)'); + AudioFocus?.requestDuck().catch(() => {}); + } + + /** Conversation-Mode endet → Focus darf wieder freigegeben werden + * (verzoegert, damit eine direkt folgende Antwort nichts kaputtmacht). */ + releaseConversationFocus(): void { + if (!this._conversationFocusActive) return; + this._conversationFocusActive = false; + console.log('[Audio] Conversation-Focus inaktiv'); + this._releaseFocusDeferred(); + } + + /** TTS-Wiedergabe haart stoppen — z.B. wenn ein Anruf reinkommt. + * Released auch sofort den AudioFocus damit der Anruf-Klingelton hoerbar ist. */ + haltAllPlayback(reason: string = ''): void { + console.log('[Audio] haltAllPlayback: %s', reason || '(no reason)'); + this._conversationFocusActive = false; + this.stopPlayback(); + } + // --- Berechtigungen --- async requestMicrophonePermission(): Promise { diff --git a/android/src/services/phoneCall.ts b/android/src/services/phoneCall.ts new file mode 100644 index 0000000..b5adba9 --- /dev/null +++ b/android/src/services/phoneCall.ts @@ -0,0 +1,108 @@ +/** + * PhoneCall-Service — pausiert die TTS-Wiedergabe wenn das Telefon klingelt + * oder ein Anruf laeuft. Native-Bindung an PhoneCallModule.kt. + * + * Bei "ringing" oder "offhook" wird audioService.haltAllPlayback() gerufen — + * ARIA verstummt sofort. Nach dem Auflegen passiert nichts automatisch + * (Audio kommt nicht zurueck), der User muesste die Antwort manuell + * nochmal anfordern (Play-Button auf der Nachricht). + * + * Permission READ_PHONE_STATE muss vom Nutzer einmalig erteilt werden — + * wenn nicht, failed start() leise und der Rest funktioniert wie bisher. + */ + +import { + NativeEventEmitter, + NativeModules, + PermissionsAndroid, + Platform, + ToastAndroid, +} from 'react-native'; +import audioService from './audio'; + +interface PhoneCallNative { + start(): Promise; + stop(): Promise; +} + +const { PhoneCall } = NativeModules as { PhoneCall?: PhoneCallNative }; + +type PhoneState = 'idle' | 'ringing' | 'offhook'; + +class PhoneCallService { + private started: boolean = false; + private subscription: { remove: () => void } | null = null; + private lastState: PhoneState = 'idle'; + + async start(): Promise { + if (this.started || !PhoneCall) return false; + if (Platform.OS !== 'android') return false; + + // Runtime-Permission holen (nur einmal noetig) + try { + const granted = await PermissionsAndroid.request( + PermissionsAndroid.PERMISSIONS.READ_PHONE_STATE, + { + title: 'ARIA Cockpit — Anruf-Erkennung', + message: 'Damit ARIA bei einem eingehenden Anruf nicht weiterredet, ' + + 'darf die App den Anruf-Status sehen (Klingeln/Aktiv/Aufgelegt). ' + + 'Es werden keine Anrufdaten gelesen oder gespeichert.', + buttonPositive: 'Erlauben', + buttonNegative: 'Spaeter', + }, + ); + if (granted !== PermissionsAndroid.RESULTS.GRANTED) { + console.warn('[PhoneCall] READ_PHONE_STATE Permission abgelehnt'); + return false; + } + } catch (err) { + console.warn('[PhoneCall] Permission-Anfrage gescheitert', err); + } + + try { + const ok = await PhoneCall.start(); + if (!ok) { + console.warn('[PhoneCall] Native start() lieferte false (Permission?)'); + return false; + } + const emitter = new NativeEventEmitter(NativeModules.PhoneCall as any); + this.subscription = emitter.addListener('PhoneCallStateChanged', (e: { state: PhoneState }) => { + this._onStateChanged(e.state); + }); + this.started = true; + console.log('[PhoneCall] Listener aktiv'); + return true; + } catch (err: any) { + console.warn('[PhoneCall] start gescheitert:', err?.message || err); + return false; + } + } + + async stop(): Promise { + if (!this.started || !PhoneCall) return; + try { + this.subscription?.remove(); + this.subscription = null; + await PhoneCall.stop(); + } catch {} + this.started = false; + this.lastState = 'idle'; + } + + private _onStateChanged(state: PhoneState): void { + if (state === this.lastState) return; + console.log('[PhoneCall] State: %s → %s', this.lastState, state); + this.lastState = state; + if (state === 'ringing' || state === 'offhook') { + audioService.haltAllPlayback(`Telefon-State: ${state}`); + ToastAndroid.show( + state === 'ringing' ? 'Anruf — ARIA pausiert' : 'Im Gespraech — ARIA pausiert', + ToastAndroid.SHORT, + ); + } + // idle: nichts automatisch — User soll nichts unbeabsichtigt re-triggern + } +} + +const phoneCallService = new PhoneCallService(); +export default phoneCallService;