feat(audio): TTS pausiert bei Anruf + Conversation-Focus haelt Spotify durchgehend gepaust
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) <noreply@anthropic.com>
This commit is contained in:
parent
20123de827
commit
fec8aa977b
|
|
@ -4,6 +4,8 @@
|
||||||
<uses-permission android:name="android.permission.CAMERA" />
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||||
|
<!-- Anruf-State lesen damit TTS bei klingelndem Telefon pausiert -->
|
||||||
|
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".MainApplication"
|
android:name=".MainApplication"
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ class MainApplication : Application(), ReactApplication {
|
||||||
add(AudioFocusPackage())
|
add(AudioFocusPackage())
|
||||||
add(PcmStreamPlayerPackage())
|
add(PcmStreamPlayerPackage())
|
||||||
add(OpenWakeWordPackage())
|
add(OpenWakeWordPackage())
|
||||||
|
add(PhoneCallPackage())
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getJSMainModuleName(): String = "index"
|
override fun getJSMainModuleName(): String = "index"
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,126 @@
|
||||||
|
package com.ariacockpit
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Build
|
||||||
|
import android.telephony.PhoneStateListener
|
||||||
|
import android.telephony.TelephonyCallback
|
||||||
|
import android.telephony.TelephonyManager
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import com.facebook.react.bridge.Arguments
|
||||||
|
import com.facebook.react.bridge.Promise
|
||||||
|
import com.facebook.react.bridge.ReactApplicationContext
|
||||||
|
import com.facebook.react.bridge.ReactContextBaseJavaModule
|
||||||
|
import com.facebook.react.bridge.ReactMethod
|
||||||
|
import com.facebook.react.modules.core.DeviceEventManagerModule
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lauscht auf Anruf-Statusaenderungen — wenn das Telefon klingelt oder ein
|
||||||
|
* Anruf laeuft, sendet das Modul ein "PhoneCallStateChanged"-Event an JS.
|
||||||
|
*
|
||||||
|
* JS-Side stoppt dann die TTS-Wiedergabe damit ARIA nicht mitten ins Gespraech
|
||||||
|
* weiterredet. Ohne READ_PHONE_STATE-Permission failt start() leise — der Rest
|
||||||
|
* der App funktioniert wie bisher.
|
||||||
|
*
|
||||||
|
* State-Strings: "idle" | "ringing" | "offhook"
|
||||||
|
*/
|
||||||
|
class PhoneCallModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) {
|
||||||
|
override fun getName() = "PhoneCall"
|
||||||
|
|
||||||
|
companion object { private const val TAG = "PhoneCall" }
|
||||||
|
|
||||||
|
private var telephonyManager: TelephonyManager? = null
|
||||||
|
private var legacyListener: PhoneStateListener? = null
|
||||||
|
private var modernCallback: Any? = null // TelephonyCallback ab API 31
|
||||||
|
private var lastState: Int = TelephonyManager.CALL_STATE_IDLE
|
||||||
|
|
||||||
|
@ReactMethod
|
||||||
|
fun start(promise: Promise) {
|
||||||
|
try {
|
||||||
|
val perm = ContextCompat.checkSelfPermission(reactApplicationContext, Manifest.permission.READ_PHONE_STATE)
|
||||||
|
if (perm != PackageManager.PERMISSION_GRANTED) {
|
||||||
|
Log.w(TAG, "READ_PHONE_STATE Permission fehlt — Anruf-Erkennung inaktiv")
|
||||||
|
promise.resolve(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val tm = reactApplicationContext.getSystemService(Context.TELEPHONY_SERVICE) as? TelephonyManager
|
||||||
|
if (tm == null) {
|
||||||
|
Log.w(TAG, "TelephonyManager nicht verfuegbar")
|
||||||
|
promise.resolve(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
telephonyManager = tm
|
||||||
|
|
||||||
|
if (Build.VERSION.SDK_INT >= 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) {}
|
||||||
|
}
|
||||||
|
|
@ -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<NativeModule> {
|
||||||
|
return listOf(PhoneCallModule(reactContext))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> {
|
||||||
|
return emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -25,6 +25,7 @@ import RNFS from 'react-native-fs';
|
||||||
import rvs, { RVSMessage, ConnectionState } from '../services/rvs';
|
import rvs, { RVSMessage, ConnectionState } from '../services/rvs';
|
||||||
import audioService from '../services/audio';
|
import audioService from '../services/audio';
|
||||||
import wakeWordService from '../services/wakeword';
|
import wakeWordService from '../services/wakeword';
|
||||||
|
import phoneCallService from '../services/phoneCall';
|
||||||
import updateService from '../services/updater';
|
import updateService from '../services/updater';
|
||||||
import VoiceButton from '../components/VoiceButton';
|
import VoiceButton from '../components/VoiceButton';
|
||||||
import FileUpload, { FileData } from '../components/FileUpload';
|
import FileUpload, { FileData } from '../components/FileUpload';
|
||||||
|
|
@ -159,10 +160,23 @@ const ChatScreen: React.FC = () => {
|
||||||
const unsub = wakeWordService.onStateChange((s) => {
|
const unsub = wakeWordService.onStateChange((s) => {
|
||||||
setWakeWordState(s);
|
setWakeWordState(s);
|
||||||
setWakeWordActive(s !== 'off');
|
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();
|
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
|
// ttsCanPlayRef live aktuell halten — Closure in onMessage unten liest
|
||||||
// darueber statt direkt ttsDeviceEnabled/ttsMuted (sonst stale).
|
// darueber statt direkt ttsDeviceEnabled/ttsMuted (sonst stale).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
|
||||||
|
|
@ -198,6 +198,12 @@ class AudioService {
|
||||||
private focusReleaseTimer: ReturnType<typeof setTimeout> | null = null;
|
private focusReleaseTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
private readonly FOCUS_RELEASE_DELAY_MS = 800;
|
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
|
// VAD State
|
||||||
private vadEnabled: boolean = false;
|
private vadEnabled: boolean = false;
|
||||||
private lastSpeechTime: number = 0;
|
private lastSpeechTime: number = 0;
|
||||||
|
|
@ -214,11 +220,18 @@ class AudioService {
|
||||||
|
|
||||||
/** AudioFocus mit kleiner Verzoegerung freigeben — Spotify/YouTube
|
/** AudioFocus mit kleiner Verzoegerung freigeben — Spotify/YouTube
|
||||||
* springen sonst im Gap zwischen zwei TTS-Streams (oder wenn ARIA
|
* 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 {
|
private _releaseFocusDeferred(): void {
|
||||||
|
if (this._conversationFocusActive) {
|
||||||
|
this._cancelDeferredFocusRelease();
|
||||||
|
return;
|
||||||
|
}
|
||||||
this._cancelDeferredFocusRelease();
|
this._cancelDeferredFocusRelease();
|
||||||
this.focusReleaseTimer = setTimeout(() => {
|
this.focusReleaseTimer = setTimeout(() => {
|
||||||
this.focusReleaseTimer = null;
|
this.focusReleaseTimer = null;
|
||||||
|
if (this._conversationFocusActive) return;
|
||||||
AudioFocus?.release().catch(() => {});
|
AudioFocus?.release().catch(() => {});
|
||||||
}, this.FOCUS_RELEASE_DELAY_MS);
|
}, 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 ---
|
// --- Berechtigungen ---
|
||||||
|
|
||||||
async requestMicrophonePermission(): Promise<boolean> {
|
async requestMicrophonePermission(): Promise<boolean> {
|
||||||
|
|
|
||||||
|
|
@ -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<boolean>;
|
||||||
|
stop(): Promise<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<boolean> {
|
||||||
|
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<void> {
|
||||||
|
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;
|
||||||
Loading…
Reference in New Issue