Compare commits

..

8 Commits

Author SHA1 Message Date
duffyduck 1a982c0d45 release: bump version to 0.0.8.5 2026-05-10 12:01:46 +02:00
duffyduck dfba5ceb1f docs: Audio-Verhaltens-Tabelle in issue.md + README
Definiert klar wann Spotify pausiert und wann nicht — als Referenz
fuer kuenftige Bug-Reports. Aktueller Zustand nach den Audio-Fixes:
Spotify pausiert nur waehrend User-Aufnahme + TTS-Wiedergabe, nicht
waehrend ARIAs Denkphase.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 12:00:41 +02:00
duffyduck 1a6f633836 fix(audio): rollback agentActivity-Conversation-Focus, Spotify pausiert NUR bei TTS
Der vorige Commit (acquireConversationFocus bei agentActivity != idle) war zu
aggressiv — Spotify pausierte schon waehrend 'ARIA denkt/schreibt' und das
zugehoerige release greift nicht zuverlaessig (Race mit nachfolgenden
agent_activity-Events). Stefan: 'spotify resumet nicht mehr, hoert schon
beim ARIA-denkt-Passus auf zu spielen'.

Erwartetes Verhalten:
- Aufnahme: AudioFocus → Spotify pausiert (~5s)
- ARIA denkt/schreibt (~20s): kein Focus → Spotify spielt weiter
- TTS: AudioFocus per requestDuck → Spotify pausiert
- TTS-Ende: deferred release nach 800ms → Spotify resumed

Underrun-Schutz im PcmStreamPlayer haelt Spotify durchgehend gepaust
solange TTS rendert (auch in den GPU-Pausen zwischen Saetzen).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 11:59:13 +02:00
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
9 changed files with 337 additions and 87 deletions
+21 -1
View File
@@ -510,10 +510,30 @@ Der Update-Flow:
App (Mikrofon) → AAC/MP4 Aufnahme → Base64 → RVS → Bridge App (Mikrofon) → AAC/MP4 Aufnahme → Base64 → RVS → Bridge
Bridge: FFmpeg (16kHz PCM) → Whisper STT → Text → aria-core Bridge: FFmpeg (16kHz PCM) → Whisper STT → Text → aria-core
Bridge: STT-Ergebnis → RVS → App (Placeholder wird durch transkribierten Text ersetzt) Bridge: STT-Ergebnis → RVS → App (Placeholder wird durch transkribierten Text ersetzt)
aria-core → Antwort → Bridge → XTTS (Gaming-PC) → PCM-Stream → RVS → App aria-core → Antwort → Bridge → F5-TTS (Gaming-PC) → PCM-Stream → RVS → App
App: AudioTrack MODE_STREAM (nahtlos), Cache als WAV pro Message App: AudioTrack MODE_STREAM (nahtlos), Cache als WAV pro Message
``` ```
### Audio-Verhalten in der App
| Phase | Andere App (Spotify) | ARIA-Mikro |
|------------------------------|----------------------|-------------------------|
| Idle / Ohr aus | spielt frei | aus |
| Wake-Word lauscht (armed) | spielt frei | passiv (openWakeWord) |
| User-Aufnahme laeuft | pausiert (EXCLUSIVE) | Recording |
| Aufnahme zu Ende | resumed | aus |
| ARIA denkt/schreibt (~20s) | spielt frei | aus |
| TTS startet | pausiert (DUCK) | aus (oder barge) |
| TTS spielt (auch GPU-Pausen) | bleibt pausiert | barge wenn Wake-Word |
| TTS zu Ende | nach 800ms resumed | (Conversation-Window) |
| Eingehender Anruf (auch VoIP)| — | Mikro pausiert |
| Anruf vorbei | — | Mikro wieder armed |
Mechanismen: Underrun-Schutz im PcmStreamPlayer (Stille-Fill in Render-
Pausen), Conversation-Focus bei Wake-Word, Foreground-Service mit
mediaPlayback|microphone, Anruf-Erkennung ueber TelephonyManager +
AudioFocus-Loss-Listener mit Polling-Fallback (VoIP).
### Datei-Pipeline (Bilder & Anhaenge) ### Datei-Pipeline (Bilder & Anhaenge)
``` ```
+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 802 versionCode 805
versionName "0.0.8.2" versionName "0.0.8.5"
// Fallback fuer Libraries mit Product Flavors // Fallback fuer Libraries mit Product Flavors
missingDimensionStrategy 'react-native-camera', 'general' missingDimensionStrategy 'react-native-camera', 'general'
} }
@@ -5,26 +5,71 @@ import android.media.AudioAttributes
import android.media.AudioFocusRequest import android.media.AudioFocusRequest
import android.media.AudioManager import android.media.AudioManager
import android.os.Build 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.Promise
import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactContextBaseJavaModule import com.facebook.react.bridge.ReactContextBaseJavaModule
import com.facebook.react.bridge.ReactMethod 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) * - requestDuck() → andere Apps werden leiser (ARIA spricht TTS)
* - requestExclusive() → andere Apps werden pausiert (Mikrofon-Aufnahme) * - requestExclusive() → andere Apps werden pausiert (Mikrofon-Aufnahme)
* - release() → Focus abgeben, andere Apps duerfen wieder * - 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) { class AudioFocusModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) {
override fun getName() = "AudioFocus" override fun getName() = "AudioFocus"
companion object { private const val TAG = "AudioFocus" }
private var currentRequest: AudioFocusRequest? = null private var currentRequest: AudioFocusRequest? = null
private fun audioManager(): AudioManager? = private fun audioManager(): AudioManager? =
reactApplicationContext.getSystemService(Context.AUDIO_SERVICE) as? 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) { private fun requestFocus(durationHint: Int, usage: Int, promise: Promise) {
val am = audioManager() val am = audioManager()
if (am == null) { if (am == null) {
@@ -41,13 +86,13 @@ class AudioFocusModule(reactContext: ReactApplicationContext) : ReactContextBase
.build() .build()
val req = AudioFocusRequest.Builder(durationHint) val req = AudioFocusRequest.Builder(durationHint)
.setAudioAttributes(attrs) .setAudioAttributes(attrs)
.setOnAudioFocusChangeListener { /* kein Callback noetig */ } .setOnAudioFocusChangeListener(focusListener)
.build() .build()
currentRequest = req currentRequest = req
am.requestAudioFocus(req) am.requestAudioFocus(req)
} else { } else {
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
am.requestAudioFocus(null, AudioManager.STREAM_MUSIC, durationHint) am.requestAudioFocus(focusListener, AudioManager.STREAM_MUSIC, durationHint)
} }
promise.resolve(result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED) promise.resolve(result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED)
@@ -92,8 +137,24 @@ class AudioFocusModule(reactContext: ReactApplicationContext) : ReactContextBase
currentRequest?.let { am.abandonAudioFocusRequest(it) } currentRequest?.let { am.abandonAudioFocusRequest(it) }
} else { } else {
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
am.abandonAudioFocus(null) am.abandonAudioFocus(focusListener)
} }
currentRequest = null 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}") 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). // 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 var idleMs = 0L
val maxIdleMs = 30_000L val maxIdleMs = 120_000L
// Zielpufferfuellung — unter diesem Wasserstand fuettern wir // Zielpufferfuellung — unter diesem Wasserstand fuettern wir
// Stille rein damit AudioTrack nicht underrunt waehrend die // Stille rein damit AudioTrack nicht underrunt waehrend die
// Bridge den naechsten Satz rendert. Spotify/YouTube reagieren // Bridge den naechsten Satz rendert. Spotify/YouTube reagieren
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "aria-cockpit", "name": "aria-cockpit",
"version": "0.0.8.2", "version": "0.0.8.5",
"private": true, "private": true,
"scripts": { "scripts": {
"android": "react-native run-android", "android": "react-native run-android",
+59 -13
View File
@@ -80,6 +80,45 @@ const capMessages = (msgs: ChatMessage[]): ChatMessage[] =>
const DEFAULT_ATTACHMENT_DIR = `${RNFS.DocumentDirectoryPath}/chat_attachments`; const DEFAULT_ATTACHMENT_DIR = `${RNFS.DocumentDirectoryPath}/chat_attachments`;
const STORAGE_PATH_KEY = 'aria_attachment_storage_path'; 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> { async function getAttachmentDir(): Promise<string> {
try { try {
const saved = await AsyncStorage.getItem(STORAGE_PATH_KEY); const saved = await AsyncStorage.getItem(STORAGE_PATH_KEY);
@@ -154,7 +193,9 @@ const ChatScreen: React.FC = () => {
const enabled = await AsyncStorage.getItem('aria_tts_enabled'); const enabled = await AsyncStorage.getItem('aria_tts_enabled');
setTtsDeviceEnabled(enabled !== 'false'); // default true setTtsDeviceEnabled(enabled !== 'false'); // default true
const muted = await AsyncStorage.getItem('aria_tts_muted'); 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'); const voice = await AsyncStorage.getItem('aria_xtts_voice');
localXttsVoiceRef.current = voice || ''; localXttsVoiceRef.current = voice || '';
ttsSpeedRef.current = await loadTtsSpeed(); ttsSpeedRef.current = await loadTtsSpeed();
@@ -229,11 +270,15 @@ const ChatScreen: React.FC = () => {
setTtsMuted(prev => { setTtsMuted(prev => {
const next = !prev; const next = !prev;
AsyncStorage.setItem('aria_tts_muted', String(next)); AsyncStorage.setItem('aria_tts_muted', String(next));
// Bei Muten sofort laufende Wiedergabe stoppen // Ref synchron updaten — sonst kommen noch Chunks im selben Tick
if (next) audioService.stopPlayback(); // 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; return next;
}); });
}, []); }, [ttsDeviceEnabled]);
// Chat-Verlauf aus AsyncStorage laden // Chat-Verlauf aus AsyncStorage laden
const isInitialLoad = useRef(true); const isInitialLoad = useRef(true);
@@ -450,6 +495,8 @@ const ChatScreen: React.FC = () => {
const activity = (message.payload.activity as string) || 'idle'; const activity = (message.payload.activity as string) || 'idle';
const tool = (message.payload.tool as string) || ''; const tool = (message.payload.tool as string) || '';
setAgentActivity({ activity, tool }); setAgentActivity({ activity, tool });
// Spotify darf waehrend "ARIA denkt/schreibt" weiterspielen — pausiert
// nur wenn TTS startet (dann acquired _firePlaybackStarted den Focus).
} }
// Voice-Config aus Diagnostic — setzt die lokale App-Stimme auf den // Voice-Config aus Diagnostic — setzt die lokale App-Stimme auf den
@@ -925,11 +972,9 @@ const ChatScreen: React.FC = () => {
{item.attachments?.map((att, idx) => ( {item.attachments?.map((att, idx) => (
<View key={idx}> <View key={idx}>
{att.type === 'image' && att.uri ? ( {att.type === 'image' && att.uri ? (
<TouchableOpacity onPress={() => setFullscreenImage(att.uri || null)} activeOpacity={0.8}> <ChatImage
<Image uri={att.uri}
source={{ uri: att.uri }} onPress={() => setFullscreenImage(att.uri || null)}
style={styles.attachmentImage}
resizeMode="cover"
onError={() => { onError={() => {
setMessages(prev => prev.map(m => setMessages(prev => prev.map(m =>
m.id === item.id ? { ...m, attachments: m.attachments?.map((a, i) => m.id === item.id ? { ...m, attachments: m.attachments?.map((a, i) =>
@@ -938,7 +983,6 @@ const ChatScreen: React.FC = () => {
)); ));
}} }}
/> />
</TouchableOpacity>
) : att.type === 'image' && !att.uri ? ( ) : att.type === 'image' && !att.uri ? (
<TouchableOpacity <TouchableOpacity
style={styles.attachmentFile} style={styles.attachmentFile}
@@ -1341,9 +1385,11 @@ const styles = StyleSheet.create({
color: '#E0E0F0', color: '#E0E0F0',
}, },
attachmentImage: { attachmentImage: {
width: '100%', // Feste Breite + dynamische aspectRatio (in ChatImage gesetzt) damit die
minHeight: 200, // Bubble sich ans Bild anpasst. Mit width: '100%' ohne explizite Parent-
maxHeight: 400, // Breite wuerde RN das Bild auf 0px schrumpfen → "Strich".
width: 260,
aspectRatio: 4 / 3,
borderRadius: 8, borderRadius: 8,
marginBottom: 6, marginBottom: 6,
backgroundColor: '#0D0D1A', 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 // nicht zuverlaessig greift. Range -55..-15 dB. Speech-Schwelle wird auf
// override+10 dB gesetzt (Speech muss klar lauter als Stille sein). // 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_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_MAX = -15; // sehr unempfindlich, nur lautes Reden gilt
export const VAD_SILENCE_DB_OVERRIDE_KEY = 'aria_vad_silence_db_override'; 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 */ /** Base64-kodiertes Audio in die Queue stellen und abspielen */
async playAudio(base64Data: string): Promise<void> { async playAudio(base64Data: string): Promise<void> {
if (!base64Data) return; 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); this.audioQueue.push(base64Data);
if (!this.isPlaying) { if (!this.isPlaying) {
this._playNext(); this._playNext();
@@ -677,7 +679,9 @@ class AudioService {
final?: boolean; final?: boolean;
silent?: boolean; silent?: boolean;
}): Promise<string> { }): 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) { if (!silent && !PcmStreamPlayer) {
console.warn('[Audio] PcmStreamPlayer Native Module nicht verfuegbar'); console.warn('[Audio] PcmStreamPlayer Native Module nicht verfuegbar');
return ''; 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 */ /** Laufende Wiedergabe stoppen + Queue leeren */
stopPlayback(): void { stopPlayback(): void {
// Foreground-Service auch stoppen — sonst bleibt die Notification haengen // 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 * PhoneCall-Service — pausiert ARIA bei Telefonaten:
* oder ein Anruf laeuft. Native-Bindung an PhoneCallModule.kt.
* *
* Bei "ringing" oder "offhook" wird audioService.haltAllPlayback() gerufen — * 1. Klassischer Mobilfunk-Anruf via TelephonyManager (PhoneCallModule.kt)
* ARIA verstummt sofort. Nach dem Auflegen passiert nichts automatisch * Status: idle / ringing / offhook
* (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 — * 2. VoIP-Anrufe (WhatsApp, Signal, Discord, Telegram, Teams, ...) via
* wenn nicht, failed start() leise und der Rest funktioniert wie bisher. * 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 { import {
@@ -33,61 +38,76 @@ type PhoneState = 'idle' | 'ringing' | 'offhook';
class PhoneCallService { class PhoneCallService {
private started: boolean = false; private started: boolean = false;
private subscription: { remove: () => void } | null = null; private subscription: { remove: () => void } | null = null;
private focusSubscription: { remove: () => void } | null = null;
private lastState: PhoneState = 'idle'; 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> { async start(): Promise<boolean> {
if (this.started || !PhoneCall) return false; if (this.started || Platform.OS !== 'android') return false;
if (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 { try {
const granted = await PermissionsAndroid.request( const focusEmitter = new NativeEventEmitter(NativeModules.AudioFocus as any);
PermissionsAndroid.PERMISSIONS.READ_PHONE_STATE, this.focusSubscription = focusEmitter.addListener(
{ 'AudioFocusChanged',
title: 'ARIA Cockpit — Anruf-Erkennung', (e: { type: 'loss' | 'loss_transient' | 'gain' }) => this._onFocusChanged(e.type),
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.log('[PhoneCall] AudioFocus-Listener aktiv (fuer VoIP-Calls)');
console.warn('[PhoneCall] READ_PHONE_STATE Permission abgelehnt'); } catch (err: any) {
return false; console.warn('[PhoneCall] AudioFocus-Subscription gescheitert', err?.message || err);
}
} catch (err) {
console.warn('[PhoneCall] Permission-Anfrage gescheitert', err);
} }
try { // 2. TelephonyManager-Listener — fuer klassische Mobilfunk-Anrufe
const ok = await PhoneCall.start(); if (PhoneCall) {
if (!ok) { try {
console.warn('[PhoneCall] Native start() lieferte false (Permission?)'); const granted = await PermissionsAndroid.request(
return false; 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> { async stop(): Promise<void> {
if (!this.started || !PhoneCall) return; if (!this.started) return;
try { try { this.subscription?.remove(); } catch {}
this.subscription?.remove(); try { this.focusSubscription?.remove(); } catch {}
this.subscription = null; this.subscription = null;
await PhoneCall.stop(); this.focusSubscription = null;
} catch {} if (PhoneCall) {
try { await PhoneCall.stop(); } catch {}
}
this.started = false; this.started = false;
this.lastState = 'idle'; this.lastState = 'idle';
this.interruptedByFocus = false;
} }
private _onStateChanged(state: PhoneState): void { private _onStateChanged(state: PhoneState): void {
@@ -96,22 +116,75 @@ class PhoneCallService {
console.log('[PhoneCall] State: %s → %s', prev, state); console.log('[PhoneCall] State: %s → %s', prev, state);
this.lastState = state; this.lastState = state;
if (state === 'ringing' || state === 'offhook') { if (state === 'ringing' || state === 'offhook') {
audioService.haltAllPlayback(`Telefon-State: ${state}`); this._haltForCall(state === 'ringing' ? 'Anruf — ARIA pausiert' : 'Im Gespraech — ARIA pausiert');
// 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,
);
} else if (state === 'idle' && prev !== 'idle') { } else if (state === 'idle' && prev !== 'idle') {
// Auflegen: Wake-Word reaktivieren wenn vor dem Anruf aktiv war. // Wenn schon durch AudioFocus-Loss pausiert wurde, NICHT doppelt resumen.
// TTS kommt nicht automatisch zurueck (Stream weg) — User kann // Der Focus-Gain-Event triggert das Resume.
// ARIAs letzte Antwort per Play-Button nochmal hoeren. if (!this.interruptedByFocus) {
wakeWordService.resumeFromCall().catch(() => {}); this._resumeAfterCall('Anruf beendet — ARIA wieder aktiv');
ToastAndroid.show('Anruf beendet — ARIA wieder aktiv', ToastAndroid.SHORT); }
} }
} }
/** 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(); const phoneCallService = new PhoneCallService();
+33
View File
@@ -1,5 +1,38 @@
# ARIA Issues & Features # ARIA Issues & Features
## Audio-Verhalten in der App
So sollte die App in den verschiedenen Phasen mit fremden Audio-Apps
(Spotify, YouTube, Podcasts etc.) und dem eigenen Mikro umgehen.
Wenn was anders ist, ist's ein Bug.
| Phase | Andere App (Spotify) | ARIA-Mikro | Hintergrund-Service |
|------------------------------|----------------------|---------------------|---------------------|
| Idle / Ohr aus | spielt frei | aus | aus |
| Wake-Word lauscht (armed) | spielt frei | passiv (openWakeWord) | aktiv ('wake') |
| User-Aufnahme laeuft | pausiert (EXCLUSIVE) | Recording | aktiv ('rec') |
| Aufnahme zu Ende | resumed | aus | (rec released) |
| ARIA denkt/schreibt (~20s) | spielt frei | aus | (kein Slot) |
| TTS startet | pausiert (DUCK) | aus (oder barge) | aktiv ('tts') |
| TTS spielt (auch GPU-Pausen) | bleibt pausiert | barge wenn Wake-Word| aktiv |
| TTS zu Ende | nach 800ms resumed | (Conversation-Window)| (tts released) |
| Eingehender Anruf (auch VoIP)| — | Mikro pausiert | aus |
| Anruf vorbei | — | Mikro wieder armed | aktiv ('wake') |
Wichtige Mechanismen:
- **Underrun-Schutz** im PcmStreamPlayer fuettert Stille rein wenn die
Bridge in Render-Pausen liefert — Spotify bleibt durchgehend pausiert,
auch zwischen den Saetzen einer langen Antwort.
- **Conversation-Focus** (nur bei Wake-Word 'conversing') haelt den
AudioFocus dauerhaft. Bei reinem Tap-to-Talk oder Text-Chat greift's
nicht — Spotify darf in der Denk-Phase ruhig weiterspielen.
- **Foreground-Service** (mediaPlayback|microphone) haelt App-Prozess
am Leben damit TTS/Mikro/Wake-Word auch bei minimierter App weiter-
laufen. Notification zeigt aktuellen Status ("ARIA spricht/hoert
zu/bereit").
- **Anruf-Erkennung** ueber TelephonyManager (klassisch) + AudioFocus-
Loss-Listener mit Polling-Fallback (VoIP wie WhatsApp/Signal/Discord).
## Erledigt ## Erledigt
### Bugs / Fixes ### Bugs / Fixes