Compare commits

..

11 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
duffyduck 0c43a18402 release: bump version to 0.0.8.2 2026-05-07 08:31:06 +02:00
duffyduck 5bdcc3c65b feat(vad): Stille-Pegel manuell in Settings + Info-Modal
Wenn die adaptive Baseline-Logik in einer Umgebung nicht zuverlaessig
greift (Stefan: "manchmal funktioniert die Stille-Erkennung nicht"),
kann der User die Schwelle jetzt manuell setzen.

Settings → Spracheingabe:
- "Stille-Pegel (dB)" mit −1/+1 Buttons + "Auf automatisch zuruecksetzen"
- Range −55 bis −15 dB, default "auto" (= adaptive Baseline)
- Info-Icon (i) oeffnet Modal mit Erklaerung:
  • dB-Skala (negativ, naeher 0 = lauter)
  • Faustregel-Pegel mit Farb-Code (−45 sensibel, −38 ausgewogen, −25 robust)
  • Klarstellung "niedrigere Zahl = sensibler"

audio.ts:
- VAD_SILENCE_DB_OVERRIDE_KEY in AsyncStorage
- loadVadSilenceDbOverride() liefert null oder Zahl
- startRecording: wenn Override gesetzt, Adaptive-Baseline uebersteuert.
  Speech-Schwelle wird auf Override + 10 dB gesetzt. Toast zeigt
  "VAD: manuell stille>-XX dB"

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 08:24:26 +02:00
duffyduck 52795530f9 fix(audio): Wake-Word-Anruf-Pause + Resume-Cooldown + Background-Mic-Order
Bug 4 — Wake-Word laeuft bei Anruf weiter:
phoneCall ruft jetzt wakeWordService.pauseForCall bei RINGING/OFFHOOK
und resumeFromCall bei IDLE. Telefonie-App belegt das Mikro waehrend
des Anrufs, openWakeWord muss daher pausieren. Pre-Call-State wird
gemerkt — armed bleibt armed, conversing degraded zu armed (sonst
landet der User nach Auflegen in einem halben Dialog).

Bug 3 — App-Resume triggert faelschlich Wake-Word:
Beim Wechsel von Background nach Foreground gibt's Audio-Pegel-Spikes
(AudioFocus-Switch, AudioTrack re-route), die openWakeWord als Wake-
Word interpretiert. Neuer Cooldown-Mechanismus: AppState-Listener im
ChatScreen ruft wakeWordService.setResumeCooldown(1500) — Detections
in der Phase werden in onWakeDetected verworfen.

Bug 1 — Background-Aufnahme klappt nicht:
acquireBackgroundAudio('rec') wird jetzt VOR audioService.startRecorder
gerufen, acquireBackgroundAudio('wake') VOR OpenWakeWord.start. Sonst
greifen Androids Background-Mic-Restrictions (ab 11+) — der Service mit
foregroundServiceType=microphone muss zum Zeitpunkt des AudioRecord-
Starts schon aktiv sein, nicht erst per state-change-Listener
asynchron danach.

Bug 2 (VAD manchmal nicht): nicht in diesem Commit, vermutlich
umgebungsabhaengig. Toast zeigt die kalibrierten Schwellen — wenn
das nochmal auftritt, schick mir die Werte.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 07:49:02 +02:00
11 changed files with 610 additions and 83 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 801 versionCode 805
versionName "0.0.8.1" 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.1", "version": "0.0.8.5",
"private": true, "private": true,
"scripts": { "scripts": {
"android": "react-native run-android", "android": "react-native run-android",
+74 -13
View File
@@ -19,6 +19,7 @@ import {
ScrollView, ScrollView,
Modal, Modal,
ToastAndroid, ToastAndroid,
AppState,
} from 'react-native'; } from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage'; import AsyncStorage from '@react-native-async-storage/async-storage';
import RNFS from 'react-native-fs'; import RNFS from 'react-native-fs';
@@ -79,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);
@@ -153,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();
@@ -193,6 +235,20 @@ const ChatScreen: React.FC = () => {
return () => { phoneCallService.stop().catch(() => {}); }; return () => { phoneCallService.stop().catch(() => {}); };
}, []); }, []);
// App-Resume: kurzer Wake-Word-Cooldown — beim Wechsel Background→Foreground
// gibt's haeufig Audio-Pegel-Spikes (AudioFocus-Switch, AudioTrack re-route)
// die openWakeWord sonst faelschlich als Wake-Word interpretiert.
useEffect(() => {
let lastState: string = AppState.currentState;
const sub = AppState.addEventListener('change', (next) => {
if (lastState !== 'active' && next === 'active') {
wakeWordService.setResumeCooldown(1500);
}
lastState = next;
});
return () => sub.remove();
}, []);
// Recording-State an Background-Service-Slot 'rec' koppeln — damit das Mikro // Recording-State an Background-Service-Slot 'rec' koppeln — damit das Mikro
// auch im Hintergrund weiter aufnehmen darf (Android killt den App-Prozess // auch im Hintergrund weiter aufnehmen darf (Android killt den App-Prozess
// sonst und die Aufnahme bricht ab). // sonst und die Aufnahme bricht ab).
@@ -214,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);
@@ -435,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
@@ -910,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) =>
@@ -923,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}
@@ -1326,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',
+144
View File
@@ -17,6 +17,7 @@ import {
Platform, Platform,
ToastAndroid, ToastAndroid,
ActivityIndicator, ActivityIndicator,
Modal,
} from 'react-native'; } from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage'; import AsyncStorage from '@react-native-async-storage/async-storage';
import RNFS from 'react-native-fs'; import RNFS from 'react-native-fs';
@@ -39,6 +40,10 @@ import {
MAX_RECORDING_MIN_SEC, MAX_RECORDING_MIN_SEC,
MAX_RECORDING_MAX_SEC, MAX_RECORDING_MAX_SEC,
MAX_RECORDING_STORAGE_KEY, MAX_RECORDING_STORAGE_KEY,
VAD_SILENCE_DB_DEFAULT,
VAD_SILENCE_DB_MIN,
VAD_SILENCE_DB_MAX,
VAD_SILENCE_DB_OVERRIDE_KEY,
TTS_SPEED_DEFAULT, TTS_SPEED_DEFAULT,
TTS_SPEED_MIN, TTS_SPEED_MIN,
TTS_SPEED_MAX, TTS_SPEED_MAX,
@@ -124,6 +129,9 @@ const SettingsScreen: React.FC = () => {
const [vadSilenceSec, setVadSilenceSec] = useState<number>(VAD_SILENCE_DEFAULT_SEC); const [vadSilenceSec, setVadSilenceSec] = useState<number>(VAD_SILENCE_DEFAULT_SEC);
const [convWindowSec, setConvWindowSec] = useState<number>(CONV_WINDOW_DEFAULT_SEC); const [convWindowSec, setConvWindowSec] = useState<number>(CONV_WINDOW_DEFAULT_SEC);
const [maxRecordingSec, setMaxRecordingSec] = useState<number>(MAX_RECORDING_DEFAULT_SEC); const [maxRecordingSec, setMaxRecordingSec] = useState<number>(MAX_RECORDING_DEFAULT_SEC);
// null = automatisch (adaptive Baseline), sonst manueller dB-Override
const [vadSilenceDb, setVadSilenceDb] = useState<number | null>(null);
const [showVadInfo, setShowVadInfo] = useState(false);
const [ttsSpeed, setTtsSpeed] = useState<number>(TTS_SPEED_DEFAULT); const [ttsSpeed, setTtsSpeed] = useState<number>(TTS_SPEED_DEFAULT);
const [wakeKeyword, setWakeKeyword] = useState<string>(DEFAULT_KEYWORD); const [wakeKeyword, setWakeKeyword] = useState<string>(DEFAULT_KEYWORD);
const [wakeStatus, setWakeStatus] = useState<string>(''); const [wakeStatus, setWakeStatus] = useState<string>('');
@@ -194,6 +202,14 @@ const SettingsScreen: React.FC = () => {
} }
} }
}); });
AsyncStorage.getItem(VAD_SILENCE_DB_OVERRIDE_KEY).then(saved => {
if (saved != null && saved !== '') {
const n = parseFloat(saved);
if (isFinite(n) && n >= VAD_SILENCE_DB_MIN && n <= VAD_SILENCE_DB_MAX) {
setVadSilenceDb(n);
}
}
});
AsyncStorage.getItem(TTS_SPEED_STORAGE_KEY).then(saved => { AsyncStorage.getItem(TTS_SPEED_STORAGE_KEY).then(saved => {
if (saved != null) { if (saved != null) {
const n = parseFloat(saved); const n = parseFloat(saved);
@@ -782,8 +798,94 @@ const SettingsScreen: React.FC = () => {
<Text style={styles.prerollButtonText}>+1m</Text> <Text style={styles.prerollButtonText}>+1m</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
<View style={{flexDirection: 'row', alignItems: 'center', marginTop: 24, gap: 8}}>
<Text style={styles.toggleLabel}>Stille-Pegel (dB)</Text>
<TouchableOpacity onPress={() => setShowVadInfo(true)} style={styles.infoBtn}>
<Text style={styles.infoBtnText}>i</Text>
</TouchableOpacity>
</View>
<Text style={styles.toggleHint}>
Welcher Mikro-Pegel als "Stille" gilt. Standard: automatisch (Baseline aus
den ersten 500ms). Manuell setzen wenn Auto nicht zuverlaessig greift.
</Text>
<View style={styles.prerollRow}>
<TouchableOpacity
style={styles.prerollButton}
onPress={() => {
const next = vadSilenceDb == null
? VAD_SILENCE_DB_DEFAULT - 1
: Math.max(VAD_SILENCE_DB_MIN, vadSilenceDb - 1);
setVadSilenceDb(next);
AsyncStorage.setItem(VAD_SILENCE_DB_OVERRIDE_KEY, String(next));
}}
>
<Text style={styles.prerollButtonText}>1</Text>
</TouchableOpacity>
<Text style={styles.prerollValue}>
{vadSilenceDb == null ? 'auto' : `${vadSilenceDb} dB`}
</Text>
<TouchableOpacity
style={styles.prerollButton}
onPress={() => {
const next = vadSilenceDb == null
? VAD_SILENCE_DB_DEFAULT + 1
: Math.min(VAD_SILENCE_DB_MAX, vadSilenceDb + 1);
setVadSilenceDb(next);
AsyncStorage.setItem(VAD_SILENCE_DB_OVERRIDE_KEY, String(next));
}}
>
<Text style={styles.prerollButtonText}>+1</Text>
</TouchableOpacity>
</View>
{vadSilenceDb != null && (
<TouchableOpacity
onPress={() => {
setVadSilenceDb(null);
AsyncStorage.removeItem(VAD_SILENCE_DB_OVERRIDE_KEY);
}}
style={{alignSelf: 'center', marginTop: 8, paddingVertical: 6, paddingHorizontal: 12}}
>
<Text style={{color: '#0096FF', fontSize: 13}}> Auf automatisch zuruecksetzen</Text>
</TouchableOpacity>
)}
</View> </View>
<Modal
visible={showVadInfo}
transparent
animationType="fade"
onRequestClose={() => setShowVadInfo(false)}
>
<View style={styles.modalOverlay}>
<View style={styles.modalCard}>
<Text style={styles.modalTitle}>Stille-Pegel (dB)</Text>
<Text style={styles.modalText}>
Lautstaerken werden in Dezibel (dB) gemessen negative Werte, je
hoeher (naeher an 0), desto lauter.{'\n\n'}
<Text style={{fontWeight: '700'}}>Standard:</Text> automatisch.
Die App misst die ersten 500ms Hintergrundpegel und setzt die
Stille-Schwelle auf Baseline + 6 dB. Funktioniert in den meisten
Umgebungen.{'\n\n'}
<Text style={{fontWeight: '700'}}>Manuell:</Text> Pegel unter dem
eingestellten Wert gilt als "Stille" Aufnahme stoppt.{'\n\n'}
<Text style={{fontWeight: '700'}}>Faustregel:</Text>{'\n'}
<Text style={{color: '#FFD60A'}}>45 dB</Text> sehr empfindlich (stoppt schnell, auch bei Atmen){'\n'}
<Text style={{color: '#34C759'}}>38 dB</Text> ausgewogen (typische Bueroumgebung){'\n'}
<Text style={{color: '#FF6B6B'}}>25 dB</Text> unempfindlich (laute Umgebung, nur klare Sprache zaehlt){'\n\n'}
<Text style={{color: '#8888AA'}}>Niedrigere Zahl (z.B. 50) = sensibler.{'\n'}
Hoehere Zahl (z.B. 20) = robuster gegen Hintergrundlaerm,
braucht aber lautere Sprache.</Text>
</Text>
<TouchableOpacity
style={[styles.connectButton, {marginTop: 16, alignSelf: 'stretch'}]}
onPress={() => setShowVadInfo(false)}
>
<Text style={styles.connectButtonText}>OK</Text>
</TouchableOpacity>
</View>
</View>
</Modal>
</>)} </>)}
{/* === Wake-Word (komplett on-device, openWakeWord) === */} {/* === Wake-Word (komplett on-device, openWakeWord) === */}
@@ -1635,6 +1737,48 @@ const styles = StyleSheet.create({
textAlign: 'center', textAlign: 'center',
}, },
infoBtn: {
width: 22,
height: 22,
borderRadius: 11,
borderWidth: 1.5,
borderColor: '#0096FF',
alignItems: 'center',
justifyContent: 'center',
},
infoBtnText: {
color: '#0096FF',
fontSize: 13,
fontWeight: '700',
fontStyle: 'italic',
lineHeight: 16,
},
modalOverlay: {
flex: 1,
backgroundColor: 'rgba(0,0,0,0.7)',
justifyContent: 'center',
alignItems: 'center',
padding: 20,
},
modalCard: {
backgroundColor: '#1E1E2E',
borderRadius: 14,
padding: 20,
maxWidth: 460,
width: '100%',
},
modalTitle: {
color: '#FFFFFF',
fontSize: 18,
fontWeight: '700',
marginBottom: 12,
},
modalText: {
color: '#E0E0F0',
fontSize: 14,
lineHeight: 20,
},
keywordChip: { keywordChip: {
backgroundColor: '#1E1E2E', backgroundColor: '#1E1E2E',
borderWidth: 1, borderWidth: 1,
+60 -5
View File
@@ -10,7 +10,7 @@ import { Platform, PermissionsAndroid, NativeModules, ToastAndroid } from 'react
import Sound from 'react-native-sound'; import Sound from 'react-native-sound';
import RNFS from 'react-native-fs'; import RNFS from 'react-native-fs';
import AsyncStorage from '@react-native-async-storage/async-storage'; import AsyncStorage from '@react-native-async-storage/async-storage';
import { stopBackgroundAudio } from './backgroundAudio'; import { acquireBackgroundAudio, releaseBackgroundAudio, stopBackgroundAudio } from './backgroundAudio';
import AudioRecorderPlayer, { import AudioRecorderPlayer, {
AudioEncoderAndroidType, AudioEncoderAndroidType,
AudioSourceAndroidType, AudioSourceAndroidType,
@@ -85,6 +85,29 @@ const VAD_SPEECH_OFFSET_DB = 12; // sicheres Speech = Baseline + 12dB
const VAD_BASELINE_SAMPLES = 5; // 5 × 100ms = 500ms Baseline const VAD_BASELINE_SAMPLES = 5; // 5 × 100ms = 500ms Baseline
const VAD_SPEECH_MIN_MS = 500; // ms Sprache bevor Aufnahme zaehlt — laenger = keine Huestler/Klopfer mehr const VAD_SPEECH_MIN_MS = 500; // ms Sprache bevor Aufnahme zaehlt — laenger = keine Huestler/Klopfer mehr
// Override fuer die Stille-Schwelle — wenn gesetzt, wird die adaptive Baseline
// ignoriert. Nuetzlich wenn die adaptive Logik in spezifischen Umgebungen
// 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 = -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';
/** Liefert den manuellen Override-Wert oder null wenn "automatisch". */
export async function loadVadSilenceDbOverride(): Promise<number | null> {
try {
const raw = await AsyncStorage.getItem(VAD_SILENCE_DB_OVERRIDE_KEY);
if (raw == null || raw === '') return null;
const n = parseFloat(raw);
if (!isFinite(n)) return null;
if (n < VAD_SILENCE_DB_MIN || n > VAD_SILENCE_DB_MAX) return null;
return n;
} catch {
return null;
}
}
// VAD-Stille (in Sekunden) — wie lange Sprechpause toleriert wird, bevor // VAD-Stille (in Sekunden) — wie lange Sprechpause toleriert wird, bevor
// die Aufnahme automatisch beendet wird. Einstellbar in den App-Settings. // die Aufnahme automatisch beendet wird. Einstellbar in den App-Settings.
export const VAD_SILENCE_DEFAULT_SEC = 2.8; export const VAD_SILENCE_DEFAULT_SEC = 2.8;
@@ -368,6 +391,12 @@ class AudioService {
this.recordingPath = `${RNFS.CachesDirectoryPath}/aria_recording_${Date.now()}.mp4`; this.recordingPath = `${RNFS.CachesDirectoryPath}/aria_recording_${Date.now()}.mp4`;
// Foreground-Service VOR dem AudioRecord starten — sonst blockt Android
// den Background-Mic-Zugriff (foregroundServiceType=microphone muss zum
// Zeitpunkt des startRecorder() schon aktiv sein, sonst greifen die
// Background-Mic-Restrictions ab Android 11+).
await acquireBackgroundAudio('rec');
// Aufnahme mit Metering starten // Aufnahme mit Metering starten
await this.recorder.startRecorder(this.recordingPath, { await this.recorder.startRecorder(this.recordingPath, {
AudioEncoderAndroid: AudioEncoderAndroidType.AAC, AudioEncoderAndroid: AudioEncoderAndroidType.AAC,
@@ -437,11 +466,22 @@ class AudioService {
this.speechDetected = false; this.speechDetected = false;
this.speechStartTime = 0; this.speechStartTime = 0;
// VAD-Adaptive zurueckgesetzt: Baseline wird in den ersten 500ms neu // VAD-Adaptive zurueckgesetzt: Baseline wird in den ersten 500ms neu
// gemessen. Bis dahin gelten die Fallback-Schwellen — die sind etwas // gemessen. Bis dahin gelten die Fallback-Schwellen.
// empfindlicher als die alten Werte (-38 statt -45 fuer Stille).
this.vadBaselineSamples = []; this.vadBaselineSamples = [];
this.vadAdaptiveSilenceDb = VAD_SILENCE_FALLBACK_DB; this.vadAdaptiveSilenceDb = VAD_SILENCE_FALLBACK_DB;
this.vadAdaptiveSpeechDb = VAD_SPEECH_FALLBACK_DB; this.vadAdaptiveSpeechDb = VAD_SPEECH_FALLBACK_DB;
// Manueller Override aus Settings — wenn gesetzt, wird die adaptive
// Baseline-Messung uebersteuert. User-Wahl gewinnt vor Auto-Magic.
const dbOverride = await loadVadSilenceDbOverride();
if (dbOverride != null) {
this.vadAdaptiveSilenceDb = dbOverride;
this.vadAdaptiveSpeechDb = dbOverride + 10; // Speech klar ueber Stille
this.vadBaselineSamples = new Array(VAD_BASELINE_SAMPLES).fill(0); // Baseline-Sammeln deaktivieren
const msg = `VAD: manuell stille>${dbOverride}dB`;
console.log('[Audio] %s', msg);
try { ToastAndroid.show(msg, ToastAndroid.SHORT); } catch {}
}
this.setState('recording'); this.setState('recording');
// Andere Apps waehrend der Aufnahme pausieren (Musik, Videos etc.) // Andere Apps waehrend der Aufnahme pausieren (Musik, Videos etc.)
@@ -570,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();
@@ -637,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 '';
@@ -897,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
+126 -43
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 {
@@ -19,6 +24,7 @@ import {
ToastAndroid, ToastAndroid,
} from 'react-native'; } from 'react-native';
import audioService from './audio'; import audioService from './audio';
import wakeWordService from './wakeword';
interface PhoneCallNative { interface PhoneCallNative {
start(): Promise<boolean>; start(): Promise<boolean>;
@@ -32,13 +38,30 @@ 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 {
const focusEmitter = new NativeEventEmitter(NativeModules.AudioFocus as any);
this.focusSubscription = focusEmitter.addListener(
'AudioFocusChanged',
(e: { type: 'loss' | 'loss_transient' | 'gain' }) => this._onFocusChanged(e.type),
);
console.log('[PhoneCall] AudioFocus-Listener aktiv (fuer VoIP-Calls)');
} catch (err: any) {
console.warn('[PhoneCall] AudioFocus-Subscription gescheitert', err?.message || err);
}
// 2. TelephonyManager-Listener — fuer klassische Mobilfunk-Anrufe
if (PhoneCall) {
try { try {
const granted = await PermissionsAndroid.request( const granted = await PermissionsAndroid.request(
PermissionsAndroid.PERMISSIONS.READ_PHONE_STATE, PermissionsAndroid.PERMISSIONS.READ_PHONE_STATE,
@@ -51,56 +74,116 @@ class PhoneCallService {
buttonNegative: 'Spaeter', buttonNegative: 'Spaeter',
}, },
); );
if (granted !== PermissionsAndroid.RESULTS.GRANTED) { if (granted === PermissionsAndroid.RESULTS.GRANTED) {
console.warn('[PhoneCall] READ_PHONE_STATE Permission abgelehnt'); const ok = await PhoneCall.start();
return false; 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);
} }
} 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; this.started = true;
console.log('[PhoneCall] Listener aktiv');
return true; return true;
} catch (err: any) {
console.warn('[PhoneCall] start gescheitert:', err?.message || err);
return false;
}
} }
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 {
if (state === this.lastState) return; if (state === this.lastState) return;
console.log('[PhoneCall] State: %s → %s', this.lastState, state); const prev = this.lastState;
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');
ToastAndroid.show( } else if (state === 'idle' && prev !== 'idle') {
state === 'ringing' ? 'Anruf — ARIA pausiert' : 'Im Gespraech — ARIA pausiert', // Wenn schon durch AudioFocus-Loss pausiert wurde, NICHT doppelt resumen.
ToastAndroid.SHORT, // Der Focus-Gain-Event triggert das Resume.
); if (!this.interruptedByFocus) {
this._resumeAfterCall('Anruf beendet — ARIA wieder aktiv');
} }
// idle: nichts automatisch — User soll nichts unbeabsichtigt re-triggern }
}
/** 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);
} }
} }
+64
View File
@@ -22,6 +22,7 @@
import { NativeEventEmitter, NativeModules, ToastAndroid } from 'react-native'; import { NativeEventEmitter, NativeModules, ToastAndroid } from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage'; import AsyncStorage from '@react-native-async-storage/async-storage';
import { acquireBackgroundAudio } from './backgroundAudio';
type WakeWordCallback = () => void; type WakeWordCallback = () => void;
type StateCallback = (state: WakeWordState) => void; type StateCallback = (state: WakeWordState) => void;
@@ -77,6 +78,14 @@ class WakeWordService {
private bargeCallbacks: WakeWordCallback[] = []; private bargeCallbacks: WakeWordCallback[] = [];
/** True solange Wake-Word parallel zu TTS aktiv ist. */ /** True solange Wake-Word parallel zu TTS aktiv ist. */
private bargeListening: boolean = false; private bargeListening: boolean = false;
/** Anruf-Pause: state wird gemerkt damit nach Auflegen wiederhergestellt wird. */
private callPaused: boolean = false;
private preCallState: WakeWordState = 'off';
/** Cooldown nach App-Resume: kurze Phase in der Wake-Word-Detections
* ignoriert werden. Beim Wechsel von Background nach Vordergrund gibt's
* oft einen Audio-Pegel-Spike (AudioFocus-Switch, AudioTrack re-route),
* der openWakeWord faelschlich triggern kann. */
private cooldownUntilMs: number = 0;
private keyword: WakeKeyword = DEFAULT_KEYWORD; private keyword: WakeKeyword = DEFAULT_KEYWORD;
private nativeReady: boolean = false; private nativeReady: boolean = false;
@@ -157,6 +166,10 @@ class WakeWordService {
/** Ohr-Button gedrueckt — startet passives Lauschen oder direkt Konversation. */ /** Ohr-Button gedrueckt — startet passives Lauschen oder direkt Konversation. */
async start(): Promise<boolean> { async start(): Promise<boolean> {
if (this.state !== 'off') return true; if (this.state !== 'off') return true;
// Foreground-Service VOR dem Mic-Zugriff hochziehen damit Background-
// Lauschen funktioniert (Android braucht foregroundServiceType=microphone
// aktiv zum Zeitpunkt des AudioRecord.startRecording).
await acquireBackgroundAudio('wake');
if (this.nativeReady && OpenWakeWord) { if (this.nativeReady && OpenWakeWord) {
try { try {
await OpenWakeWord.start(); await OpenWakeWord.start();
@@ -200,8 +213,22 @@ class WakeWordService {
this.setState('off'); this.setState('off');
} }
/** Cooldown setzen — alle Wake-Word-Detections in den naechsten ms ignorieren.
* Wird beim App-Resume gerufen weil AppState-Wechsel Audio-Spikes erzeugen
* die openWakeWord faelschlich als Trigger interpretiert. */
setResumeCooldown(ms: number = 1500): void {
this.cooldownUntilMs = Date.now() + ms;
console.log('[WakeWord] Cooldown aktiv fuer %dms', ms);
}
/** Wake-Word getriggert: Native-Modul pausieren, Konversation starten. */ /** Wake-Word getriggert: Native-Modul pausieren, Konversation starten. */
private async onWakeDetected(): Promise<void> { private async onWakeDetected(): Promise<void> {
const now = Date.now();
if (now < this.cooldownUntilMs) {
const left = this.cooldownUntilMs - now;
console.log('[WakeWord] Trigger ignoriert (Cooldown noch %dms aktiv — wahrscheinlich App-Resume-Spike)', left);
return;
}
console.log('[WakeWord] Wake-Word "%s" erkannt! (state=%s, barge=%s)', console.log('[WakeWord] Wake-Word "%s" erkannt! (state=%s, barge=%s)',
this.keyword, this.state, this.bargeListening); this.keyword, this.state, this.bargeListening);
if (this.nativeReady && OpenWakeWord) { if (this.nativeReady && OpenWakeWord) {
@@ -255,6 +282,43 @@ class WakeWordService {
console.log('[WakeWord] Barge-Listening aus'); console.log('[WakeWord] Barge-Listening aus');
} }
/** Bei eingehendem Anruf: Wake-Word + Aufnahme stoppen, Pre-Call-State
* merken. Telefonie-App belegt das Mikro waehrend des Anrufs, plus ARIA
* soll nicht in laufende Telefonate reinhoeren. */
async pauseForCall(): Promise<void> {
if (this.callPaused) return;
this.preCallState = this.state;
if (this.state === 'off') {
this.callPaused = true; // merken dass wir pausiert wurden
return;
}
this.callPaused = true;
if (this.nativeReady && OpenWakeWord) {
try { await OpenWakeWord.stop(); } catch {}
}
this.bargeListening = false;
console.log('[WakeWord] Anruf — Wake-Word pausiert (war: %s)', this.preCallState);
}
/** Nach Auflegen: Pre-Call-State wiederherstellen. Aktive Konversation
* geht zu armed zurueck (User soll nicht in einen halben Dialog springen). */
async resumeFromCall(): Promise<void> {
if (!this.callPaused) return;
const restoreTo = this.preCallState;
this.callPaused = false;
this.preCallState = 'off';
console.log('[WakeWord] Anruf zu Ende — restore state=%s', restoreTo);
if (restoreTo === 'off') return;
// Aktive Konversation war wahrscheinlich durch haltAllPlayback eh abgebrochen,
// sicher zu armed degraden.
if (restoreTo === 'conversing') this.setState('armed');
if (this.nativeReady && OpenWakeWord) {
try { await OpenWakeWord.start(); } catch (err) {
console.warn('[WakeWord] Restore-Start fehlgeschlagen:', err);
}
}
}
/** Konversation beenden — User hat im Window nichts gesagt. /** Konversation beenden — User hat im Window nichts gesagt.
* Mit Wake-Word: zurueck zu 'armed' (Listener wieder an). * Mit Wake-Word: zurueck zu 'armed' (Listener wieder an).
* Ohne: zurueck zu 'off'. * Ohne: zurueck zu 'off'.
+37
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
@@ -30,6 +63,10 @@
- [x] VAD adaptive Baseline robuster: minimum statt avg + Cap auf -50dB bis -28dB (Stille) / -40dB bis -18dB (Speech) — keine "tote" VAD-Konfiguration mehr bei lauter Umgebung oder Wake-Word-Echo - [x] VAD adaptive Baseline robuster: minimum statt avg + Cap auf -50dB bis -28dB (Stille) / -40dB bis -18dB (Speech) — keine "tote" VAD-Konfiguration mehr bei lauter Umgebung oder Wake-Word-Echo
- [x] Push-to-Talk raus, nur noch Tap-to-Talk (verhinderte Touch-Race-Probleme) - [x] Push-to-Talk raus, nur noch Tap-to-Talk (verhinderte Touch-Race-Probleme)
- [x] Manueller Mikro-Stop beendet Wake-Word-Konversation: Tap auf Mikro-Knopf waehrend conversing → audio raus + zurueck zu armed (= Wake-Word lauscht wieder, kein Auto-Mikro nach ARIAs Antwort). VAD-Auto-Stop bleibt bei Multi-Turn - [x] Manueller Mikro-Stop beendet Wake-Word-Konversation: Tap auf Mikro-Knopf waehrend conversing → audio raus + zurueck zu armed (= Wake-Word lauscht wieder, kein Auto-Mikro nach ARIAs Antwort). VAD-Auto-Stop bleibt bei Multi-Turn
- [x] **Wake-Word pausiert bei Anruf**: phoneCall ruft pauseForCall (openWakeWord.stop) bei RINGING/OFFHOOK, resumeFromCall bei IDLE. Pre-Call-State wird gemerkt — armed bleibt armed, conversing degraded zu armed (User soll nicht in halbem Dialog landen)
- [x] **App-Resume-Cooldown**: Wechsel von Background → Foreground triggert keinen falschen Wake-Word-Trigger mehr. AppState-Listener setzt 1.5s Cooldown in dem onWakeDetected-Events ignoriert werden (Audio-Pegel-Spike beim AudioFocus-Switch sonst als Wake-Word interpretiert)
- [x] Background-Mikro robust: acquireBackgroundAudio('rec'/'wake') wird jetzt VOR AudioRecord.startRecording gerufen — Foreground-Service mit foregroundServiceType=microphone muss aktiv sein bevor das Mikro greift, sonst blockiert Android ab 11+ den Background-Zugriff
- [x] **Stille-Pegel manuell setzbar** (Settings → Spracheingabe): Override-Wert in dB von -55 bis -15, default "automatisch". Info-Button mit Modal erklaert die Skala (niedriger = sensibler, hoeher = robuster gegen Hintergrundlaerm). Bei manuell gesetztem Wert wird die adaptive Baseline ignoriert
### App Features ### App Features