Compare commits

...

5 Commits

Author SHA1 Message Date
duffyduck 7f7db100af release: bump version to 0.0.8.4 2026-05-10 11:53:48 +02:00
duffyduck d646e9d58e fix(audio): Spotify spielt nicht mehr in der ARIA-Verarbeitungspause
Logcat-Befund: zwischen User-Aufnahme-Ende und TTS-Start liegt eine
~20s-Pause (Whisper STT + Claude + F5-TTS). In dieser Zeit hatte ARIA
keinen AudioFocus → Spotify lief munter weiter, dann pausierte beim
TTS-Start. Stefan hoerte das als 'Spotify kommt nach 20s wieder'.

Fix: ChatScreen ruft acquireConversationFocus sobald ein agent_activity-
Event mit activity != 'idle' kommt. Solange ARIA arbeitet (thinking/
tool/responding) bleibt der Focus gehalten, Spotify bleibt pausiert.
Bei onPlaybackFinished oder cancelRequest wird releaseConversationFocus
gerufen — sonst bliebe Spotify ewig stumm.

Funktioniert auch fuer reine Text-Chats (kein Wake-Word noetig).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 11:52:07 +02:00
duffyduck bef59ba134 release: bump version to 0.0.8.3 2026-05-10 11:46:26 +02:00
duffyduck dbebfd44ff fix(tts): Idle-Cutoff im PCM-Writer von 30s auf 120s
Bug-Vermutung: lange F5-TTS-Antworten reissen ab wenn die Gamebox
zwischen Saetzen >30s braucht (Modell-Wechsel, kalte GPU, ungewoehnlich
schwerer Satz). Writer-Thread brach dann mit 'Idle-Cutoff' ab und
ARIA verstummte mitten im Text.

120s deckt auch lange GPU-Pausen ab. Bei echtem Bridge-Crash brauchen
wir trotzdem irgendwann einen Cutoff damit der Foreground-Service
nicht ewig haengt.

Stefan kann ADB-Logs gerade nicht ziehen (telefoniert) — bei Bug 3
(Spotify) muessen wir noch die Native-Logs sehen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 10:37:59 +02:00
duffyduck 4d0b9e0d78 fix: dB-Range -85, Mute haert auch laufende TTS, VoIP-Anrufe + Bild-Bubble
Bug 1 — dB-Range erweitert:
VAD_SILENCE_DB_MIN von -55 auf -85 dB. Damit hat Stefan einen weiten
Regler-Spielraum wenn die adaptive Auto-Erkennung in seiner Umgebung
nicht zuverlaessig greift.

Bug 5 — Mute-Button stoppt laufende TTS nicht:
audioService bekommt jetzt einen internen _muted-Flag. handlePcmChunk
setzt silent automatisch wenn _muted true ist, playAudio kehrt frueh
zurueck. Verhindert Race zwischen User-Klick auf Mute und einem
TTS-Chunk der im selben JS-Tick ankommt (vorher: Ref-Update via
useEffect erst nach dem Re-Render → Chunks "rutschten durch"). Plus
ttsCanPlayRef wird im toggleMute-Handler synchron aktualisiert.

Bug 4 — VoIP/Messenger-Anrufe erkennen:
AudioFocusModule emittiert jetzt "AudioFocusChanged" Events mit type
"loss"/"loss_transient"/"gain". WhatsApp/Signal/Discord/etc. requestn
AudioFocus_GAIN_TRANSIENT_EXCLUSIVE wenn ein Anruf reinkommt — wir
fangen das in phoneCall.ts ab und rufen halt + pauseForCall genau
wie beim klassischen Anruf. Plus getMode() Polling-Fallback (alle 3s)
weil GAIN nicht zuverlaessig kommt wenn wir den Focus selbst released
haben — sobald AudioMode wieder NORMAL ist, resumeFromCall.

Bug 6 — Bilder als "Strich":
attachmentImage hatte width: '100%' in einer Bubble mit maxWidth: '80%'
ohne explizite Parent-Breite → RN rendert auf 0px Breite. Neue ChatImage-
Komponente nutzt Image.getSize um die echte aspectRatio zu messen + setzt
sie dynamisch. Bubble passt sich dem Bild an.

Bugs 2 (lange Texte mid-cutoff) + 3 (Spotify resumed) — brauchen ADB-Logs.
ADB-WLAN ueber 192.168.177.22:5555 schlaegt fehl (refused) — bei Android
11+ braucht's Wireless-Debugging-Pairing-Code. Stefan kann den nennen
sobald er soweit ist.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 10:28:52 +02:00
7 changed files with 295 additions and 86 deletions
+2 -2
View File
@@ -79,8 +79,8 @@ android {
applicationId "com.ariacockpit"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 802
versionName "0.0.8.2"
versionCode 804
versionName "0.0.8.4"
// Fallback fuer Libraries mit Product Flavors
missingDimensionStrategy 'react-native-camera', 'general'
}
@@ -5,26 +5,71 @@ import android.media.AudioAttributes
import android.media.AudioFocusRequest
import android.media.AudioManager
import android.os.Build
import android.util.Log
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
/**
* Steuert Audio-Focus fuer Ducking/Muten anderer Apps.
* Steuert Audio-Focus fuer Ducking/Muten anderer Apps + emittiert Loss-Events
* an JS damit ARIA bei VoIP-Anrufen (WhatsApp/Signal/Discord/...) aufhoert
* zu sprechen — diese Anrufe gehen nicht ueber TelephonyManager, sondern
* requestn AudioFocus_GAIN_TRANSIENT_EXCLUSIVE was wir hier mitbekommen.
*
* - requestDuck() → andere Apps werden leiser (ARIA spricht TTS)
* - requestExclusive() → andere Apps werden pausiert (Mikrofon-Aufnahme)
* - release() → Focus abgeben, andere Apps duerfen wieder
*
* Events:
* - "AudioFocusChanged" mit type:
* "loss" — endgueltiger Verlust (Anruf, andere App permanent)
* "loss_transient" — vorruebergehender Verlust (kurze Unterbrechung)
* "gain" — Fokus zurueck
*/
class AudioFocusModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) {
override fun getName() = "AudioFocus"
companion object { private const val TAG = "AudioFocus" }
private var currentRequest: AudioFocusRequest? = null
private fun audioManager(): AudioManager? =
reactApplicationContext.getSystemService(Context.AUDIO_SERVICE) as? AudioManager
private fun emitFocusChange(type: String) {
try {
val params = Arguments.createMap().apply { putString("type", type) }
reactApplicationContext.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
.emit("AudioFocusChanged", params)
} catch (e: Exception) {
Log.w(TAG, "emit failed: ${e.message}")
}
}
private val focusListener = AudioManager.OnAudioFocusChangeListener { focusChange ->
when (focusChange) {
AudioManager.AUDIOFOCUS_LOSS -> {
Log.i(TAG, "AUDIOFOCUS_LOSS (z.B. Anruf, anderer Player permanent)")
emitFocusChange("loss")
}
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> {
Log.i(TAG, "AUDIOFOCUS_LOSS_TRANSIENT (kurze Unterbrechung)")
emitFocusChange("loss_transient")
}
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> {
// Notification-Sound o.ae. — wir ignorieren das, ARIA macht weiter
Log.d(TAG, "AUDIOFOCUS_LOSS_CAN_DUCK ignoriert")
}
AudioManager.AUDIOFOCUS_GAIN -> {
Log.i(TAG, "AUDIOFOCUS_GAIN")
emitFocusChange("gain")
}
}
}
private fun requestFocus(durationHint: Int, usage: Int, promise: Promise) {
val am = audioManager()
if (am == null) {
@@ -41,13 +86,13 @@ class AudioFocusModule(reactContext: ReactApplicationContext) : ReactContextBase
.build()
val req = AudioFocusRequest.Builder(durationHint)
.setAudioAttributes(attrs)
.setOnAudioFocusChangeListener { /* kein Callback noetig */ }
.setOnAudioFocusChangeListener(focusListener)
.build()
currentRequest = req
am.requestAudioFocus(req)
} else {
@Suppress("DEPRECATION")
am.requestAudioFocus(null, AudioManager.STREAM_MUSIC, durationHint)
am.requestAudioFocus(focusListener, AudioManager.STREAM_MUSIC, durationHint)
}
promise.resolve(result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED)
@@ -92,8 +137,24 @@ class AudioFocusModule(reactContext: ReactApplicationContext) : ReactContextBase
currentRequest?.let { am.abandonAudioFocusRequest(it) }
} else {
@Suppress("DEPRECATION")
am.abandonAudioFocus(null)
am.abandonAudioFocus(focusListener)
}
currentRequest = null
}
/** Aktueller Audio-Mode: NORMAL=0, IN_CALL=2, IN_COMMUNICATION=3, CALL_SCREENING=4.
* IN_COMMUNICATION ist der typische VoIP-Anruf-Mode (WhatsApp, Signal, etc.) —
* kann gepollt werden um zu erkennen wann der Anruf vorbei ist (zurueck NORMAL). */
@ReactMethod
fun getMode(promise: Promise) {
val am = audioManager()
if (am == null) {
promise.resolve(0)
return
}
promise.resolve(am.mode)
}
@ReactMethod fun addListener(eventName: String) {}
@ReactMethod fun removeListeners(count: Int) {}
}
@@ -137,10 +137,12 @@ class PcmStreamPlayerModule(reactContext: ReactApplicationContext) : ReactContex
Log.w(TAG, "play() sofort failed: ${e.message}")
}
}
// Idle-Cutoff: wenn endRequested NICHT kam aber 30s nichts mehr
// Idle-Cutoff: wenn endRequested NICHT kam aber lange nichts mehr
// reinkommt, brechen wir ab (Bridge-Crash, verlorener final).
// 120s damit lange F5-TTS-Render-Pausen zwischen Saetzen (z.B. bei
// Modell-Wechsel oder kalter GPU) nicht den Stream abreissen.
var idleMs = 0L
val maxIdleMs = 30_000L
val maxIdleMs = 120_000L
// Zielpufferfuellung — unter diesem Wasserstand fuettern wir
// Stille rein damit AudioTrack nicht underrunt waehrend die
// Bridge den naechsten Satz rendert. Spotify/YouTube reagieren
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "aria-cockpit",
"version": "0.0.8.2",
"version": "0.0.8.4",
"private": true,
"scripts": {
"android": "react-native run-android",
+71 -13
View File
@@ -80,6 +80,45 @@ const capMessages = (msgs: ChatMessage[]): ChatMessage[] =>
const DEFAULT_ATTACHMENT_DIR = `${RNFS.DocumentDirectoryPath}/chat_attachments`;
const STORAGE_PATH_KEY = 'aria_attachment_storage_path';
/** Image-Vorschau in der Chat-Bubble. Misst die echte Bild-Dimension via
* Image.getSize + setzt aspectRatio dynamisch — dadurch passt sich die
* Bubble ans Bild an (kein "Strich" mehr bei breiten oder hohen Bildern). */
const CHAT_IMAGE_STYLE = {
width: 260,
borderRadius: 8,
marginBottom: 6,
backgroundColor: '#0D0D1A',
} as const;
const ChatImage: React.FC<{
uri: string;
onPress: () => void;
onError: () => void;
}> = ({ uri, onPress, onError }) => {
const [aspectRatio, setAspectRatio] = useState<number>(4 / 3);
useEffect(() => {
let cancelled = false;
Image.getSize(uri, (w, h) => {
if (!cancelled && w > 0 && h > 0) {
// Aspect-Ratio capen damit sehr lange Panorama-Bilder oder hohe
// Screenshot-Streifen die Bubble nicht sprengen
const r = Math.max(0.5, Math.min(2.5, w / h));
setAspectRatio(r);
}
}, () => {});
return () => { cancelled = true; };
}, [uri]);
return (
<TouchableOpacity onPress={onPress} activeOpacity={0.8}>
<Image
source={{ uri }}
style={[CHAT_IMAGE_STYLE, { aspectRatio }]}
resizeMode="cover"
onError={onError}
/>
</TouchableOpacity>
);
};
async function getAttachmentDir(): Promise<string> {
try {
const saved = await AsyncStorage.getItem(STORAGE_PATH_KEY);
@@ -154,7 +193,9 @@ const ChatScreen: React.FC = () => {
const enabled = await AsyncStorage.getItem('aria_tts_enabled');
setTtsDeviceEnabled(enabled !== 'false'); // default true
const muted = await AsyncStorage.getItem('aria_tts_muted');
setTtsMuted(muted === 'true'); // default false
const isMuted = muted === 'true';
setTtsMuted(isMuted); // default false
audioService.setMuted(isMuted); // service-internen Flag synchronisieren
const voice = await AsyncStorage.getItem('aria_xtts_voice');
localXttsVoiceRef.current = voice || '';
ttsSpeedRef.current = await loadTtsSpeed();
@@ -229,11 +270,15 @@ const ChatScreen: React.FC = () => {
setTtsMuted(prev => {
const next = !prev;
AsyncStorage.setItem('aria_tts_muted', String(next));
// Bei Muten sofort laufende Wiedergabe stoppen
if (next) audioService.stopPlayback();
// Ref synchron updaten — sonst kommen noch Chunks im selben Tick
// mit canPlay=true durch (Race vor dem useEffect-Update).
ttsCanPlayRef.current = ttsDeviceEnabled && !next;
// Globalen Mute-Flag im audioService setzen — uebersteuert auch
// payload.silent in handlePcmChunk und stoppt laufende Wiedergabe.
audioService.setMuted(next);
return next;
});
}, []);
}, [ttsDeviceEnabled]);
// Chat-Verlauf aus AsyncStorage laden
const isInitialLoad = useRef(true);
@@ -450,6 +495,13 @@ const ChatScreen: React.FC = () => {
const activity = (message.payload.activity as string) || 'idle';
const tool = (message.payload.tool as string) || '';
setAgentActivity({ activity, tool });
// Solange ARIA arbeitet (thinking/tool/responding) den Conversation-
// Focus halten — sonst spielt Spotify in der ~20s-Verarbeitungspause
// zwischen User-Aufnahme-Ende und TTS-Start wieder. Bei 'idle' wird
// der Focus nur dann released wenn auch kein TTS mehr aktiv ist.
if (activity !== 'idle') {
audioService.acquireConversationFocus();
}
}
// Voice-Config aus Diagnostic — setzt die lokale App-Stimme auf den
@@ -613,6 +665,10 @@ const ChatScreen: React.FC = () => {
});
const unsubTtsEnd = audioService.onPlaybackFinished(() => {
releaseBackgroundAudio('tts').catch(() => {});
// ARIAs Antwort komplett vorgelesen → Conversation-Focus freigeben damit
// Spotify wieder darf. Vorher (waehrend agentActivity != idle) hat das
// acquireConversationFocus den Focus durchgehend gehalten.
audioService.releaseConversationFocus();
// Vor naechster Aufnahme: barge-listening aus damit der AudioRecorder
// das Mikro greifen kann.
wakeWordService.stopBargeListening().catch(() => {});
@@ -762,6 +818,9 @@ const ChatScreen: React.FC = () => {
const cancelRequest = useCallback(() => {
setAgentActivity({ activity: 'idle', tool: '' });
rvs.send('cancel_request' as any, {});
// Conversation-Focus freigeben — es kommt keine TTS-Antwort mehr,
// sonst bliebe Spotify ewig pausiert.
audioService.releaseConversationFocus();
}, []);
// Barge-In: wenn der User waehrend ARIA arbeitet/spricht eine neue Sprach-
@@ -925,11 +984,9 @@ const ChatScreen: React.FC = () => {
{item.attachments?.map((att, idx) => (
<View key={idx}>
{att.type === 'image' && att.uri ? (
<TouchableOpacity onPress={() => setFullscreenImage(att.uri || null)} activeOpacity={0.8}>
<Image
source={{ uri: att.uri }}
style={styles.attachmentImage}
resizeMode="cover"
<ChatImage
uri={att.uri}
onPress={() => setFullscreenImage(att.uri || null)}
onError={() => {
setMessages(prev => prev.map(m =>
m.id === item.id ? { ...m, attachments: m.attachments?.map((a, i) =>
@@ -938,7 +995,6 @@ const ChatScreen: React.FC = () => {
));
}}
/>
</TouchableOpacity>
) : att.type === 'image' && !att.uri ? (
<TouchableOpacity
style={styles.attachmentFile}
@@ -1341,9 +1397,11 @@ const styles = StyleSheet.create({
color: '#E0E0F0',
},
attachmentImage: {
width: '100%',
minHeight: 200,
maxHeight: 400,
// Feste Breite + dynamische aspectRatio (in ChatImage gesetzt) damit die
// Bubble sich ans Bild anpasst. Mit width: '100%' ohne explizite Parent-
// Breite wuerde RN das Bild auf 0px schrumpfen → "Strich".
width: 260,
aspectRatio: 4 / 3,
borderRadius: 8,
marginBottom: 6,
backgroundColor: '#0D0D1A',
+18 -3
View File
@@ -90,7 +90,7 @@ const VAD_SPEECH_MIN_MS = 500; // ms Sprache bevor Aufnahme zaehlt — l
// nicht zuverlaessig greift. Range -55..-15 dB. Speech-Schwelle wird auf
// override+10 dB gesetzt (Speech muss klar lauter als Stille sein).
export const VAD_SILENCE_DB_DEFAULT = -38; // wenn User Manuell-Modus waehlt
export const VAD_SILENCE_DB_MIN = -55; // sehr empfindlich, fast jeder Pegel ist "Sprache"
export const VAD_SILENCE_DB_MIN = -85; // extrem empfindlich, praktisch alles gilt als Sprache
export const VAD_SILENCE_DB_MAX = -15; // sehr unempfindlich, nur lautes Reden gilt
export const VAD_SILENCE_DB_OVERRIDE_KEY = 'aria_vad_silence_db_override';
@@ -610,7 +610,9 @@ class AudioService {
/** Base64-kodiertes Audio in die Queue stellen und abspielen */
async playAudio(base64Data: string): Promise<void> {
if (!base64Data) return;
// Mute-Flag respektieren — robust gegen Race-Conditions zwischen User-
// Klick auf Mute und einem TTS-Chunk der im selben Tick eintrifft.
if (this._muted) return;
this.audioQueue.push(base64Data);
if (!this.isPlaying) {
this._playNext();
@@ -677,7 +679,9 @@ class AudioService {
final?: boolean;
silent?: boolean;
}): Promise<string> {
const silent = !!payload.silent;
// Globaler Mute-Flag uebersteuert das per-Call silent — verhindert
// Race-Conditions wenn der User zwischen Chunks den Mute-Knopf drueckt.
const silent = !!payload.silent || this._muted;
if (!silent && !PcmStreamPlayer) {
console.warn('[Audio] PcmStreamPlayer Native Module nicht verfuegbar');
return '';
@@ -937,6 +941,17 @@ class AudioService {
}
}
/** Mute: alle eingehenden TTS-Chunks/WAVs werden ignoriert bis wieder
* unmuted. Robuster als ein React-Ref weil hier kein Re-Render-Race ist
* — die Bridge kann einen Chunk im selben JS-Tick liefern in dem der
* User Mute geklickt hat. */
private _muted: boolean = false;
setMuted(muted: boolean): void {
this._muted = muted;
if (muted) this.stopPlayback();
}
isMuted(): boolean { return this._muted; }
/** Laufende Wiedergabe stoppen + Queue leeren */
stopPlayback(): void {
// Foreground-Service auch stoppen — sonst bleibt die Notification haengen
+134 -61
View File
@@ -1,14 +1,19 @@
/**
* PhoneCall-Service — pausiert die TTS-Wiedergabe wenn das Telefon klingelt
* oder ein Anruf laeuft. Native-Bindung an PhoneCallModule.kt.
* PhoneCall-Service — pausiert ARIA bei Telefonaten:
*
* 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).
* 1. Klassischer Mobilfunk-Anruf via TelephonyManager (PhoneCallModule.kt)
* Status: idle / ringing / offhook
*
* Permission READ_PHONE_STATE muss vom Nutzer einmalig erteilt werden —
* wenn nicht, failed start() leise und der Rest funktioniert wie bisher.
* 2. VoIP-Anrufe (WhatsApp, Signal, Discord, Telegram, Teams, ...) via
* AudioFocus-Loss-Event (AudioFocusModule.kt). Diese Apps requestn
* AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE wenn ein Anruf reinkommt — wir
* bekommen ein "loss" Event und reagieren genauso wie auf RINGING.
*
* In beiden Faellen wird audioService.haltAllPlayback() + wakeWordService.
* pauseForCall() gerufen. Bei call-end (idle / focus-gain) → resumeFromCall.
*
* Permission READ_PHONE_STATE ist nur fuer Pfad 1 noetig — Pfad 2 braucht
* keine extra Berechtigung weil unser eigener AudioFocus-Listener feuert.
*/
import {
@@ -33,61 +38,76 @@ type PhoneState = 'idle' | 'ringing' | 'offhook';
class PhoneCallService {
private started: boolean = false;
private subscription: { remove: () => void } | null = null;
private focusSubscription: { remove: () => void } | null = null;
private lastState: PhoneState = 'idle';
/** Damit Resume nach VoIP-Loss nicht doppelt feuert wenn auch
* TelephonyManager-IDLE-Event kommt. */
private interruptedByFocus: boolean = false;
async start(): Promise<boolean> {
if (this.started || !PhoneCall) return false;
if (Platform.OS !== 'android') return false;
if (this.started || Platform.OS !== 'android') return false;
// Runtime-Permission holen (nur einmal noetig)
// 1. AudioFocus-Listener IMMER registrieren — fangs VoIP-Calls (WhatsApp,
// Signal, Discord etc.) abdecken, brauchen keine Permission.
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',
},
const focusEmitter = new NativeEventEmitter(NativeModules.AudioFocus as any);
this.focusSubscription = focusEmitter.addListener(
'AudioFocusChanged',
(e: { type: 'loss' | 'loss_transient' | 'gain' }) => this._onFocusChanged(e.type),
);
if (granted !== PermissionsAndroid.RESULTS.GRANTED) {
console.warn('[PhoneCall] READ_PHONE_STATE Permission abgelehnt');
return false;
}
} catch (err) {
console.warn('[PhoneCall] Permission-Anfrage gescheitert', err);
console.log('[PhoneCall] AudioFocus-Listener aktiv (fuer VoIP-Calls)');
} catch (err: any) {
console.warn('[PhoneCall] AudioFocus-Subscription gescheitert', err?.message || err);
}
try {
const ok = await PhoneCall.start();
if (!ok) {
console.warn('[PhoneCall] Native start() lieferte false (Permission?)');
return false;
// 2. TelephonyManager-Listener — fuer klassische Mobilfunk-Anrufe
if (PhoneCall) {
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) {
const ok = await PhoneCall.start();
if (ok) {
const emitter = new NativeEventEmitter(NativeModules.PhoneCall as any);
this.subscription = emitter.addListener(
'PhoneCallStateChanged',
(e: { state: PhoneState }) => this._onStateChanged(e.state),
);
console.log('[PhoneCall] TelephonyManager-Listener aktiv');
}
} else {
console.warn('[PhoneCall] READ_PHONE_STATE abgelehnt — VoIP-Calls werden trotzdem ueber AudioFocus erkannt');
}
} catch (err: any) {
console.warn('[PhoneCall] TelephonyManager-Setup gescheitert:', err?.message || err);
}
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;
}
this.started = true;
return true;
}
async stop(): Promise<void> {
if (!this.started || !PhoneCall) return;
try {
this.subscription?.remove();
this.subscription = null;
await PhoneCall.stop();
} catch {}
if (!this.started) return;
try { this.subscription?.remove(); } catch {}
try { this.focusSubscription?.remove(); } catch {}
this.subscription = null;
this.focusSubscription = null;
if (PhoneCall) {
try { await PhoneCall.stop(); } catch {}
}
this.started = false;
this.lastState = 'idle';
this.interruptedByFocus = false;
}
private _onStateChanged(state: PhoneState): void {
@@ -96,22 +116,75 @@ class PhoneCallService {
console.log('[PhoneCall] State: %s → %s', prev, state);
this.lastState = state;
if (state === 'ringing' || state === 'offhook') {
audioService.haltAllPlayback(`Telefon-State: ${state}`);
// Wake-Word + Aufnahme pausieren: Telefonie-App belegt das Mikro
// waehrend des Anrufs, plus ARIA soll nicht im Telefonat zuhoeren.
wakeWordService.pauseForCall().catch(() => {});
ToastAndroid.show(
state === 'ringing' ? 'Anruf — ARIA pausiert' : 'Im Gespraech — ARIA pausiert',
ToastAndroid.SHORT,
);
this._haltForCall(state === 'ringing' ? 'Anruf — ARIA pausiert' : 'Im Gespraech — ARIA pausiert');
} else if (state === 'idle' && prev !== 'idle') {
// Auflegen: Wake-Word reaktivieren wenn vor dem Anruf aktiv war.
// TTS kommt nicht automatisch zurueck (Stream weg) — User kann
// ARIAs letzte Antwort per Play-Button nochmal hoeren.
wakeWordService.resumeFromCall().catch(() => {});
ToastAndroid.show('Anruf beendet — ARIA wieder aktiv', ToastAndroid.SHORT);
// Wenn schon durch AudioFocus-Loss pausiert wurde, NICHT doppelt resumen.
// Der Focus-Gain-Event triggert das Resume.
if (!this.interruptedByFocus) {
this._resumeAfterCall('Anruf beendet — ARIA wieder aktiv');
}
}
}
/** AudioFocus-Loss = irgendeine andere App hat das Mikro/die Audio-Pipeline
* uebernommen — typisch VoIP-Apps bei eingehendem Anruf, aber auch System-
* Voice-Assistants etc. */
private _onFocusChanged(type: 'loss' | 'loss_transient' | 'gain'): void {
if (type === 'loss' || type === 'loss_transient') {
// Schon durch klassischen TelephonyManager pausiert? Dann nichts doppeln.
if (this.lastState === 'ringing' || this.lastState === 'offhook') return;
this.interruptedByFocus = true;
this._haltForCall('Anruf erkannt (VoIP) — ARIA pausiert');
// Pollen, weil GAIN nicht zuverlaessig kommt (wir releasen den Focus
// selbst beim halt → kein automatischer GAIN). AudioMode != IN_COMMUNICATION
// = Call vorbei.
this._startVoipResumePoll();
} else if (type === 'gain') {
if (this.interruptedByFocus) {
this.interruptedByFocus = false;
this._stopVoipResumePoll();
this._resumeAfterCall('Audio frei — ARIA wieder aktiv');
}
}
}
/** Polling-Fallback: alle 3s checken ob AudioMode wieder NORMAL ist. */
private voipPollTimer: ReturnType<typeof setInterval> | null = null;
private _startVoipResumePoll(): void {
if (this.voipPollTimer) return;
this.voipPollTimer = setInterval(async () => {
if (!this.interruptedByFocus) {
this._stopVoipResumePoll();
return;
}
try {
const mode = await (NativeModules.AudioFocus as any)?.getMode?.();
// 0 = MODE_NORMAL — Call ist vorbei
if (typeof mode === 'number' && mode === 0) {
this.interruptedByFocus = false;
this._stopVoipResumePoll();
this._resumeAfterCall('Anruf beendet — ARIA wieder aktiv');
}
} catch {}
}, 3000);
}
private _stopVoipResumePoll(): void {
if (this.voipPollTimer) {
clearInterval(this.voipPollTimer);
this.voipPollTimer = null;
}
}
private _haltForCall(toast: string): void {
audioService.haltAllPlayback(toast);
wakeWordService.pauseForCall().catch(() => {});
ToastAndroid.show(toast, ToastAndroid.SHORT);
}
private _resumeAfterCall(toast: string): void {
wakeWordService.resumeFromCall().catch(() => {});
ToastAndroid.show(toast, ToastAndroid.SHORT);
}
}
const phoneCallService = new PhoneCallService();