Compare commits
53 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 89e3a195a3 | |||
| f023ba0ac5 | |||
| a0570ef8f7 | |||
| facde1fef7 | |||
| 38106a2096 | |||
| a476afb311 | |||
| db4c7b9b72 | |||
| 3bc490b485 | |||
| dd6d70c46e | |||
| b1eaf42fef | |||
| fb9e5dcd10 | |||
| f95e71463f | |||
| 1088bff43d | |||
| cad68db2a2 | |||
| 50b10c8ac0 | |||
| a8b586ec92 | |||
| 632e1e4fa1 | |||
| 7e12816ebd | |||
| 8f64f8fb30 | |||
| b3ff3991c4 | |||
| a4ea387c98 | |||
| 68fbf74a23 | |||
| b857f778e9 | |||
| 31aa82b68c | |||
| de8eeb69e2 | |||
| f5970ce700 | |||
| ef1a4436ca | |||
| 981779cd9e | |||
| 3dcd2ae0b4 | |||
| 2750b867a3 | |||
| f6424add6c | |||
| 2dfd21d1d0 | |||
| 9d9ddc730b | |||
| 77ccee8331 | |||
| 175dcdf225 | |||
| 1549e9cd4f | |||
| 910e74b497 | |||
| 160c5c34b6 | |||
| a6638c0108 | |||
| 43c21d3ddc | |||
| b73c6c346e | |||
| b91ddc5bdf | |||
| 7d08c06720 | |||
| f066a2a555 | |||
| b55b0e7c42 | |||
| 70f806ef80 | |||
| 0773d9496d | |||
| 1a4857ed62 | |||
| 962d814318 | |||
| 9276a92c83 | |||
| d16896c4b4 | |||
| 20050d4077 | |||
| 79760d1b2e |
@@ -13,6 +13,7 @@ import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
|
|||||||
import ChatScreen from './src/screens/ChatScreen';
|
import ChatScreen from './src/screens/ChatScreen';
|
||||||
import SettingsScreen from './src/screens/SettingsScreen';
|
import SettingsScreen from './src/screens/SettingsScreen';
|
||||||
import rvs from './src/services/rvs';
|
import rvs from './src/services/rvs';
|
||||||
|
import { initLogger } from './src/services/logger';
|
||||||
|
|
||||||
// --- Navigation ---
|
// --- Navigation ---
|
||||||
|
|
||||||
@@ -44,6 +45,10 @@ const TAB_ICONS: Record<string, { active: string; inactive: string }> = {
|
|||||||
const App: React.FC = () => {
|
const App: React.FC = () => {
|
||||||
// Beim Start: gespeicherte RVS-Konfiguration laden und verbinden
|
// Beim Start: gespeicherte RVS-Konfiguration laden und verbinden
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// Verbose-Logging-Setting laden BEVOR andere Module loslegen.
|
||||||
|
// initLogger ist async aber blockt nichts — solange er noch laueft,
|
||||||
|
// loggen wir normal (Default an), danach respektiert console.log das Setting.
|
||||||
|
initLogger().catch(() => {});
|
||||||
const initConnection = async () => {
|
const initConnection = async () => {
|
||||||
const config = await rvs.loadConfig();
|
const config = await rvs.loadConfig();
|
||||||
if (config) {
|
if (config) {
|
||||||
|
|||||||
@@ -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 807
|
versionCode 10008
|
||||||
versionName "0.0.8.7"
|
versionName "0.1.0.8"
|
||||||
// Fallback fuer Libraries mit Product Flavors
|
// Fallback fuer Libraries mit Product Flavors
|
||||||
missingDimensionStrategy 'react-native-camera', 'general'
|
missingDimensionStrategy 'react-native-camera', 'general'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,9 @@
|
|||||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||||
<!-- Anruf-State lesen damit TTS bei klingelndem Telefon pausiert -->
|
<!-- Anruf-State lesen damit TTS bei klingelndem Telefon pausiert -->
|
||||||
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
|
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
|
||||||
|
<!-- Optional: GPS-Position der Frage anhaengen (nur wenn User in Settings aktiviert) -->
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||||
<!-- Foreground-Service damit TTS auch bei minimierter App weiterlaeuft.
|
<!-- Foreground-Service damit TTS auch bei minimierter App weiterlaeuft.
|
||||||
FOREGROUND_SERVICE_MICROPHONE ist Pflicht ab Android 14 wenn der
|
FOREGROUND_SERVICE_MICROPHONE ist Pflicht ab Android 14 wenn der
|
||||||
Service waehrend des Backgrounds aufs Mikro zugreift (Wake-Word,
|
Service waehrend des Backgrounds aufs Mikro zugreift (Wake-Word,
|
||||||
|
|||||||
@@ -131,6 +131,58 @@ class AudioFocusModule(reactContext: ReactApplicationContext) : ReactContextBase
|
|||||||
promise.resolve(true)
|
promise.resolve(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Den USAGE_MEDIA-Focus-Stack im System aufmischen, damit Spotify/YouTube
|
||||||
|
* resumen wenn ein anderer Player (z.B. react-native-sound) seinen Focus
|
||||||
|
* nicht ordnungsgemaess released hat. Strategie: kurz selbst USAGE_MEDIA
|
||||||
|
* GAIN beanspruchen — das System invalidiert dabei den haengenden Stack-
|
||||||
|
* Eintrag des anderen Players — und sofort wieder abandonen. Spotify
|
||||||
|
* bekommt den Focus-Gain und resumed.
|
||||||
|
*
|
||||||
|
* Workaround fuer das react-native-sound-Bug: Sound.stop()/release()
|
||||||
|
* laesst den AudioFocusRequest haengen.
|
||||||
|
*/
|
||||||
|
@ReactMethod
|
||||||
|
fun kickReleaseMedia(promise: Promise) {
|
||||||
|
val am = audioManager()
|
||||||
|
if (am == null) {
|
||||||
|
promise.resolve(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Async laufen lassen — wir wollen einen request, Pause, dann abandon.
|
||||||
|
// Ohne Pause merkt das System (und damit Spotify) die kurze Owner-
|
||||||
|
// Wechsel oft gar nicht. 250ms reicht erfahrungsgemaess fuer den
|
||||||
|
// Focus-Stack-Refresh.
|
||||||
|
Thread {
|
||||||
|
try {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
val attrs = AudioAttributes.Builder()
|
||||||
|
.setUsage(AudioAttributes.USAGE_MEDIA)
|
||||||
|
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
|
||||||
|
.build()
|
||||||
|
val kickListener = AudioManager.OnAudioFocusChangeListener { /* ignorieren */ }
|
||||||
|
val kickReq = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN)
|
||||||
|
.setAudioAttributes(attrs)
|
||||||
|
.setOnAudioFocusChangeListener(kickListener)
|
||||||
|
.build()
|
||||||
|
am.requestAudioFocus(kickReq)
|
||||||
|
Thread.sleep(250)
|
||||||
|
am.abandonAudioFocusRequest(kickReq)
|
||||||
|
} else {
|
||||||
|
val kickListener = AudioManager.OnAudioFocusChangeListener { /* ignorieren */ }
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
am.requestAudioFocus(kickListener, AudioManager.STREAM_MUSIC, AudioManager.AUDIOFOCUS_GAIN)
|
||||||
|
Thread.sleep(250)
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
am.abandonAudioFocus(kickListener)
|
||||||
|
}
|
||||||
|
Log.i(TAG, "kickReleaseMedia: USAGE_MEDIA-Stack aufgemischt (250ms Pause)")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "kickReleaseMedia failed: ${e.message}")
|
||||||
|
}
|
||||||
|
}.start()
|
||||||
|
promise.resolve(true)
|
||||||
|
}
|
||||||
|
|
||||||
private fun release() {
|
private fun release() {
|
||||||
val am = audioManager() ?: return
|
val am = audioManager() ?: return
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import android.media.AudioAttributes
|
|||||||
import android.media.AudioFormat
|
import android.media.AudioFormat
|
||||||
import android.media.AudioManager
|
import android.media.AudioManager
|
||||||
import android.media.AudioTrack
|
import android.media.AudioTrack
|
||||||
|
import android.os.Build
|
||||||
import android.util.Base64
|
import android.util.Base64
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import com.facebook.react.bridge.Arguments
|
import com.facebook.react.bridge.Arguments
|
||||||
@@ -78,9 +79,12 @@ class PcmStreamPlayerModule(reactContext: ReactApplicationContext) : ReactContex
|
|||||||
val encoding = AudioFormat.ENCODING_PCM_16BIT
|
val encoding = AudioFormat.ENCODING_PCM_16BIT
|
||||||
val minBuf = AudioTrack.getMinBufferSize(sampleRate, channelConfig, encoding)
|
val minBuf = AudioTrack.getMinBufferSize(sampleRate, channelConfig, encoding)
|
||||||
val bytesPerSecond = sampleRate * channels * 2 // 16-bit = 2 bytes
|
val bytesPerSecond = sampleRate * channels * 2 // 16-bit = 2 bytes
|
||||||
// Buffer muss mindestens PREROLL + etwas Spielraum fassen.
|
|
||||||
val prerollTarget = (bytesPerSecond * prerollSec).toInt()
|
val prerollTarget = (bytesPerSecond * prerollSec).toInt()
|
||||||
val bufferSize = (minBuf * 32).coerceAtLeast(prerollTarget * 2)
|
// Buffer entkoppelt von Preroll — fester ~4s-Buffer. OnePlus A12
|
||||||
|
// mit USAGE_ASSISTANT laeuft AudioTrack erst ab ~3s gepufferter
|
||||||
|
// Daten an. Wir padden Kurztexte vor play() auf 3s (siehe Block
|
||||||
|
// nach mainLoop), Buffer braucht ~1s Headroom weil write() blockt.
|
||||||
|
val bufferSize = (bytesPerSecond * 4).coerceAtLeast(minBuf * 8)
|
||||||
prerollBytes = prerollTarget
|
prerollBytes = prerollTarget
|
||||||
bytesBuffered = 0
|
bytesBuffered = 0
|
||||||
playbackStarted = false
|
playbackStarted = false
|
||||||
@@ -104,7 +108,20 @@ class PcmStreamPlayerModule(reactContext: ReactApplicationContext) : ReactContex
|
|||||||
.setTransferMode(AudioTrack.MODE_STREAM)
|
.setTransferMode(AudioTrack.MODE_STREAM)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
// AudioTrack erstellen — play() wird erst aufgerufen wenn Pre-Roll erreicht.
|
// Start-Threshold runterdrehen: Default ist bufferSize/2 (= 2s bei 4s
|
||||||
|
// Buffer). AudioTrack startet sonst nicht bevor 2s im Puffer sind —
|
||||||
|
// bei kurzen TTS-Antworten (3 Worte ~ 1.4s) bleibt pos auf 0 stehen.
|
||||||
|
// 0.1s reicht damit AudioTrack sofort mit dem ersten Chunk anlaeuft.
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
|
try {
|
||||||
|
val startFrames = (sampleRate / 10).coerceAtLeast(1) // 100ms
|
||||||
|
newTrack.setStartThresholdInFrames(startFrames)
|
||||||
|
Log.i(TAG, "Start-Threshold gesetzt: ${startFrames} frames (~100ms)")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "setStartThresholdInFrames failed: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
track = newTrack
|
track = newTrack
|
||||||
queue.clear()
|
queue.clear()
|
||||||
writerShouldStop = false
|
writerShouldStop = false
|
||||||
@@ -156,15 +173,11 @@ class PcmStreamPlayerModule(reactContext: ReactApplicationContext) : ReactContex
|
|||||||
val data = queue.poll(50, java.util.concurrent.TimeUnit.MILLISECONDS)
|
val data = queue.poll(50, java.util.concurrent.TimeUnit.MILLISECONDS)
|
||||||
if (data == null) {
|
if (data == null) {
|
||||||
if (endRequested) {
|
if (endRequested) {
|
||||||
// Falls wir vor Pre-Roll enden (kurzer Text): trotzdem abspielen
|
// Falls play() noch gar nicht lief (Stream ohne data
|
||||||
|
// ueberhaupt — sehr seltene Edge-Case): jetzt anstossen
|
||||||
|
// damit das finally{}-Wait nicht endlos blockt.
|
||||||
if (!playbackStarted) {
|
if (!playbackStarted) {
|
||||||
try {
|
try { t.play(); playbackStarted = true } catch (_: Exception) {}
|
||||||
t.play()
|
|
||||||
playbackStarted = true
|
|
||||||
Log.i(TAG, "Playback gestartet VOR Pre-Roll (kurzer Text, ${bytesBuffered}B gepuffert)")
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.w(TAG, "play() fallback failed: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
break@mainLoop
|
break@mainLoop
|
||||||
}
|
}
|
||||||
@@ -196,12 +209,16 @@ class PcmStreamPlayerModule(reactContext: ReactApplicationContext) : ReactContex
|
|||||||
}
|
}
|
||||||
idleMs = 0L
|
idleMs = 0L
|
||||||
|
|
||||||
// Pre-Roll Check: play() erst wenn genug gepuffert
|
// play() beim ALLERERSTEN data-chunk aufrufen — egal wie wenig
|
||||||
if (!playbackStarted && bytesBuffered + data.size >= prerollBytes) {
|
// Daten da sind. Sonst stallt AudioTrack auf OnePlus A12 wenn
|
||||||
|
// play() erst gerufen wird nachdem der Buffer komplett gefuellt
|
||||||
|
// ist. Pre-Roll als "Vorrat aufbauen" passiert dann waehrend
|
||||||
|
// der Track schon spielt — Underrun-Schutz fuettert ggf. Stille.
|
||||||
|
if (!playbackStarted) {
|
||||||
try {
|
try {
|
||||||
t.play()
|
t.play()
|
||||||
playbackStarted = true
|
playbackStarted = true
|
||||||
Log.i(TAG, "Playback gestartet nach Pre-Roll ${bytesBuffered + data.size} Bytes")
|
Log.i(TAG, "Playback gestartet beim 1. Chunk (${bytesBuffered}B leading + ${data.size}B data)")
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.w(TAG, "play() failed: ${e.message}")
|
Log.w(TAG, "play() failed: ${e.message}")
|
||||||
}
|
}
|
||||||
@@ -237,12 +254,21 @@ class PcmStreamPlayerModule(reactContext: ReactApplicationContext) : ReactContex
|
|||||||
val totalFrames = (bytesBuffered / streamBytesPerFrame).toInt()
|
val totalFrames = (bytesBuffered / streamBytesPerFrame).toInt()
|
||||||
var lastPos = -1
|
var lastPos = -1
|
||||||
var stalledCount = 0
|
var stalledCount = 0
|
||||||
|
var retried = false
|
||||||
while (!writerShouldStop) {
|
while (!writerShouldStop) {
|
||||||
val pos = t.playbackHeadPosition
|
val pos = t.playbackHeadPosition
|
||||||
if (pos >= totalFrames) break
|
if (pos >= totalFrames) break
|
||||||
// Safety: wenn Position 2s nicht mehr vorwaerts → AudioTrack hing
|
|
||||||
if (pos == lastPos) {
|
if (pos == lastPos) {
|
||||||
stalledCount++
|
stalledCount++
|
||||||
|
// Nach 500ms Stillstand: AudioTrack-Quirk auf manchen
|
||||||
|
// Geraeten (OnePlus A12) — play() nochmal anstossen.
|
||||||
|
if (stalledCount == 10 && pos == 0 && !retried) {
|
||||||
|
retried = true
|
||||||
|
Log.w(TAG, "playback nicht angefahren — retry play()")
|
||||||
|
try { t.play() } catch (e: Exception) {
|
||||||
|
Log.w(TAG, "retry play() failed: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
if (stalledCount > 40) {
|
if (stalledCount > 40) {
|
||||||
Log.w(TAG, "playback stalled at $pos/$totalFrames — give up")
|
Log.w(TAG, "playback stalled at $pos/$totalFrames — give up")
|
||||||
break
|
break
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "aria-cockpit",
|
"name": "aria-cockpit",
|
||||||
"version": "0.0.8.7",
|
"version": "0.1.0.8",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"android": "react-native run-android",
|
"android": "react-native run-android",
|
||||||
|
|||||||
@@ -725,17 +725,23 @@ const ChatScreen: React.FC = () => {
|
|||||||
|
|
||||||
// GPS-Position holen (optional)
|
// GPS-Position holen (optional)
|
||||||
const getCurrentLocation = useCallback((): Promise<{ lat: number; lon: number } | null> => {
|
const getCurrentLocation = useCallback((): Promise<{ lat: number; lon: number } | null> => {
|
||||||
if (!gpsEnabled) return Promise.resolve(null);
|
if (!gpsEnabled) {
|
||||||
|
console.log('[GPS] gpsEnabled=false → kein Standort');
|
||||||
|
return Promise.resolve(null);
|
||||||
|
}
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
Geolocation.getCurrentPosition(
|
Geolocation.getCurrentPosition(
|
||||||
(position) => {
|
(position) => {
|
||||||
resolve({
|
const loc = {
|
||||||
lat: position.coords.latitude,
|
lat: position.coords.latitude,
|
||||||
lon: position.coords.longitude,
|
lon: position.coords.longitude,
|
||||||
});
|
};
|
||||||
|
console.log('[GPS] Position: lat=%s lon=%s', loc.lat, loc.lon);
|
||||||
|
resolve(loc);
|
||||||
},
|
},
|
||||||
(_error) => {
|
(error) => {
|
||||||
|
console.warn('[GPS] getCurrentPosition Fehler:', error?.code, error?.message);
|
||||||
resolve(null);
|
resolve(null);
|
||||||
},
|
},
|
||||||
{ enableHighAccuracy: false, timeout: 5000 },
|
{ enableHighAccuracy: false, timeout: 5000 },
|
||||||
@@ -884,6 +890,7 @@ const ChatScreen: React.FC = () => {
|
|||||||
// Alle Pending Anhaenge + Text senden
|
// Alle Pending Anhaenge + Text senden
|
||||||
const sendPendingAttachments = useCallback(async (messageText: string) => {
|
const sendPendingAttachments = useCallback(async (messageText: string) => {
|
||||||
if (pendingAttachments.length === 0) return;
|
if (pendingAttachments.length === 0) return;
|
||||||
|
console.log('[Chat] sendPendingAttachments: %d Anhang/Anhaenge', pendingAttachments.length);
|
||||||
const location = await getCurrentLocation();
|
const location = await getCurrentLocation();
|
||||||
const msgId = nextId();
|
const msgId = nextId();
|
||||||
|
|
||||||
@@ -933,6 +940,8 @@ const ChatScreen: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// An RVS senden
|
// An RVS senden
|
||||||
|
console.log('[Chat] sende file: name=%s mime=%s size=%s b64Bytes=%s',
|
||||||
|
name, mimeType, file.size, base64.length);
|
||||||
rvs.send('file', {
|
rvs.send('file', {
|
||||||
name,
|
name,
|
||||||
type: mimeType,
|
type: mimeType,
|
||||||
@@ -1029,19 +1038,24 @@ const ChatScreen: React.FC = () => {
|
|||||||
{!isUser && item.text.length > 0 && (
|
{!isUser && item.text.length > 0 && (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={styles.playButton}
|
style={styles.playButton}
|
||||||
onPress={() => {
|
onPress={async () => {
|
||||||
if (item.audioPath) {
|
// Erst lokalen Cache pruefen — audioPath kann auf eine geloeschte
|
||||||
audioService.playFromPath(item.audioPath);
|
// Datei zeigen (TTS-Cache geleert oder Auto-Cleanup). In dem Fall
|
||||||
} else {
|
// ueber RVS neu rendern lassen statt stumm zu bleiben.
|
||||||
// messageId mitschicken damit die Bridge das generierte Audio
|
const cachePath = item.audioPath?.replace(/^file:\/\//, '') || '';
|
||||||
// wieder mit der Nachricht verknuepft (fuer den naechsten Replay aus Cache)
|
const cached = cachePath ? await RNFS.exists(cachePath).catch(() => false) : false;
|
||||||
rvs.send('tts_request' as any, {
|
if (cached) {
|
||||||
text: item.text,
|
audioService.playFromPath(item.audioPath!);
|
||||||
voice: localXttsVoiceRef.current,
|
return;
|
||||||
speed: ttsSpeedRef.current,
|
|
||||||
messageId: item.messageId || '',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
// messageId mitschicken damit die Bridge das generierte Audio
|
||||||
|
// wieder mit der Nachricht verknuepft (fuer den naechsten Replay aus Cache)
|
||||||
|
rvs.send('tts_request' as any, {
|
||||||
|
text: item.text,
|
||||||
|
voice: localXttsVoiceRef.current,
|
||||||
|
speed: ttsSpeedRef.current,
|
||||||
|
messageId: item.messageId || '',
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text style={styles.playButtonText}>{'\uD83D\uDD0A'}</Text>
|
<Text style={styles.playButtonText}>{'\uD83D\uDD0A'}</Text>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
ToastAndroid,
|
ToastAndroid,
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
Modal,
|
Modal,
|
||||||
|
PermissionsAndroid,
|
||||||
} 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';
|
||||||
@@ -49,6 +50,8 @@ import {
|
|||||||
TTS_SPEED_MAX,
|
TTS_SPEED_MAX,
|
||||||
TTS_SPEED_STORAGE_KEY,
|
TTS_SPEED_STORAGE_KEY,
|
||||||
} from '../services/audio';
|
} from '../services/audio';
|
||||||
|
import audioService from '../services/audio';
|
||||||
|
import { isVerboseLogging, setVerboseLogging } from '../services/logger';
|
||||||
import {
|
import {
|
||||||
isWakeReadySoundEnabled,
|
isWakeReadySoundEnabled,
|
||||||
setWakeReadySoundEnabled,
|
setWakeReadySoundEnabled,
|
||||||
@@ -134,6 +137,8 @@ const SettingsScreen: React.FC = () => {
|
|||||||
const [vadSilenceDb, setVadSilenceDb] = useState<number | null>(null);
|
const [vadSilenceDb, setVadSilenceDb] = useState<number | null>(null);
|
||||||
const [showVadInfo, setShowVadInfo] = useState(false);
|
const [showVadInfo, setShowVadInfo] = useState(false);
|
||||||
const [apkCacheInfo, setApkCacheInfo] = useState<{count: number, totalMB: number} | null>(null);
|
const [apkCacheInfo, setApkCacheInfo] = useState<{count: number, totalMB: number} | null>(null);
|
||||||
|
const [ttsCacheInfo, setTtsCacheInfo] = useState<{count: number, totalMB: number} | null>(null);
|
||||||
|
const [verboseLogging, setVerboseLoggingState] = useState<boolean>(isVerboseLogging());
|
||||||
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>('');
|
||||||
@@ -223,6 +228,7 @@ const SettingsScreen: React.FC = () => {
|
|||||||
});
|
});
|
||||||
isWakeReadySoundEnabled().then(setWakeReadySound);
|
isWakeReadySoundEnabled().then(setWakeReadySound);
|
||||||
updateService.getApkCacheSize().then(setApkCacheInfo).catch(() => {});
|
updateService.getApkCacheSize().then(setApkCacheInfo).catch(() => {});
|
||||||
|
audioService.getTtsCacheSize().then(setTtsCacheInfo).catch(() => {});
|
||||||
AsyncStorage.getItem('aria_xtts_voice').then(saved => {
|
AsyncStorage.getItem('aria_xtts_voice').then(saved => {
|
||||||
if (saved) setXttsVoice(saved);
|
if (saved) setXttsVoice(saved);
|
||||||
});
|
});
|
||||||
@@ -457,7 +463,29 @@ const SettingsScreen: React.FC = () => {
|
|||||||
|
|
||||||
// --- GPS Toggle ---
|
// --- GPS Toggle ---
|
||||||
|
|
||||||
const handleGPSToggle = useCallback((value: boolean) => {
|
const handleGPSToggle = useCallback(async (value: boolean) => {
|
||||||
|
if (value && Platform.OS === 'android') {
|
||||||
|
try {
|
||||||
|
const granted = await PermissionsAndroid.request(
|
||||||
|
PermissionsAndroid.PERMISSIONS.ACCESS_COARSE_LOCATION,
|
||||||
|
{
|
||||||
|
title: 'ARIA — Standort an Anfragen anhaengen',
|
||||||
|
message: 'Damit ARIA bei Anfragen wie "Wo ist der naechste...?" den '
|
||||||
|
+ 'Standort kennt, darf die App den ungefaehren Standort lesen. '
|
||||||
|
+ 'Wird nur bei jeder Anfrage einmal abgerufen, nicht im Hintergrund.',
|
||||||
|
buttonPositive: 'Erlauben',
|
||||||
|
buttonNegative: 'Abbrechen',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (granted !== PermissionsAndroid.RESULTS.GRANTED) {
|
||||||
|
ToastAndroid.show('Standort-Berechtigung abgelehnt', ToastAndroid.SHORT);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[Settings] GPS-Permission Request gescheitert:', err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
setGpsEnabled(value);
|
setGpsEnabled(value);
|
||||||
AsyncStorage.setItem('aria_gps_enabled', String(value)).catch(() => {});
|
AsyncStorage.setItem('aria_gps_enabled', String(value)).catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
@@ -1228,11 +1256,65 @@ const SettingsScreen: React.FC = () => {
|
|||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{/* === TTS-Cache === */}
|
||||||
|
<Text style={[styles.sectionTitle, {marginTop: 16}]}>TTS-Cache</Text>
|
||||||
|
<View style={styles.card}>
|
||||||
|
<Text style={styles.toggleHint}>
|
||||||
|
Gespeicherte Sprachausgaben (WAV pro Antwort) — werden fuer den
|
||||||
|
Play-Button und Auto-Resume nach Anrufen genutzt. Loeschen
|
||||||
|
unterbricht keine laufende Wiedergabe, alte Antworten lassen sich
|
||||||
|
danach nur nicht mehr abspielen.
|
||||||
|
</Text>
|
||||||
|
<Text style={[styles.storageSizeText, {marginTop: 8}]}>
|
||||||
|
{ttsCacheInfo === null ? '...' :
|
||||||
|
ttsCacheInfo.count === 0 ? 'leer' :
|
||||||
|
`${ttsCacheInfo.count} WAV${ttsCacheInfo.count === 1 ? '' : 's'} · ${ttsCacheInfo.totalMB.toFixed(1)}MB`}
|
||||||
|
</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.clearButton, {marginTop: 8, backgroundColor: 'rgba(255,59,48,0.15)'}]}
|
||||||
|
onPress={async () => {
|
||||||
|
const res = await audioService.clearTtsCache();
|
||||||
|
ToastAndroid.show(
|
||||||
|
res.removed === 0
|
||||||
|
? 'TTS-Cache war schon leer'
|
||||||
|
: `${res.removed} WAV${res.removed === 1 ? '' : 's'} geloescht (${res.freedMB.toFixed(1)}MB frei)`,
|
||||||
|
ToastAndroid.SHORT,
|
||||||
|
);
|
||||||
|
const info = await audioService.getTtsCacheSize();
|
||||||
|
setTtsCacheInfo(info);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={[styles.clearButtonText, {color: '#FF3B30'}]}>TTS-Cache leeren</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
</>)}
|
</>)}
|
||||||
|
|
||||||
{/* === Logs === */}
|
{/* === Logs === */}
|
||||||
{currentSection === 'protocol' && (<>
|
{currentSection === 'protocol' && (<>
|
||||||
<Text style={styles.sectionTitle}>Protokoll</Text>
|
<Text style={styles.sectionTitle}>Protokoll</Text>
|
||||||
|
|
||||||
|
{/* Verbose-Logging-Toggle */}
|
||||||
|
<View style={styles.card}>
|
||||||
|
<View style={styles.toggleRow}>
|
||||||
|
<Text style={styles.toggleLabel}>Verbose Logging</Text>
|
||||||
|
<Switch
|
||||||
|
value={verboseLogging}
|
||||||
|
onValueChange={(v) => {
|
||||||
|
setVerboseLogging(v);
|
||||||
|
setVerboseLoggingState(v);
|
||||||
|
}}
|
||||||
|
trackColor={{ false: '#3A3A52', true: '#0096FF' }}
|
||||||
|
thumbColor={verboseLogging ? '#FFFFFF' : '#666680'}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.toggleHint}>
|
||||||
|
Wenn aus: console.log wird global stummgeschaltet (Speicher schonen).
|
||||||
|
Warnungen und Fehler bleiben immer aktiv. Bei Bedarf einschalten zum
|
||||||
|
Debuggen via adb logcat.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
<View style={styles.card}>
|
<View style={styles.card}>
|
||||||
{/* Tab-Umschalter */}
|
{/* Tab-Umschalter */}
|
||||||
<View style={styles.tabRow}>
|
<View style={styles.tabRow}>
|
||||||
|
|||||||
+224
-23
@@ -41,6 +41,8 @@ const { AudioFocus, PcmStreamPlayer } = NativeModules as {
|
|||||||
requestDuck: () => Promise<boolean>;
|
requestDuck: () => Promise<boolean>;
|
||||||
requestExclusive: () => Promise<boolean>;
|
requestExclusive: () => Promise<boolean>;
|
||||||
release: () => Promise<boolean>;
|
release: () => Promise<boolean>;
|
||||||
|
kickReleaseMedia: () => Promise<boolean>;
|
||||||
|
getMode?: () => Promise<number>;
|
||||||
};
|
};
|
||||||
PcmStreamPlayer?: {
|
PcmStreamPlayer?: {
|
||||||
start: (sampleRate: number, channels: number, prerollSeconds: number) => Promise<boolean>;
|
start: (sampleRate: number, channels: number, prerollSeconds: number) => Promise<boolean>;
|
||||||
@@ -301,6 +303,12 @@ class AudioService {
|
|||||||
console.warn('[Audio] PcmPlaybackFinished-Subscription fehlgeschlagen:', err);
|
console.warn('[Audio] PcmPlaybackFinished-Subscription fehlgeschlagen:', err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// App-Start: orphaned aria_tts_*.wav / aria_recording_*.mp4 aus dem Cache
|
||||||
|
// wegraeumen. Sammeln sich an wenn Sound mid-playback gestoppt wird (Anruf,
|
||||||
|
// Mute, Barge-In) — der completion-callback feuert dann nicht und die Datei
|
||||||
|
// bleibt liegen. 5min-Threshold damit gerade aktiv geschriebene Files sicher
|
||||||
|
// sind. cleanupOnStartup ist async, blockt den Constructor nicht.
|
||||||
|
this._cleanupStaleCacheFiles(5 * 60 * 1000).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** AudioFocus mit kleiner Verzoegerung freigeben — Spotify/YouTube
|
/** AudioFocus mit kleiner Verzoegerung freigeben — Spotify/YouTube
|
||||||
@@ -310,13 +318,19 @@ class AudioService {
|
|||||||
* unterdrueckt — der Focus bleibt fuer die ganze Konversation gehalten. */
|
* unterdrueckt — der Focus bleibt fuer die ganze Konversation gehalten. */
|
||||||
private _releaseFocusDeferred(): void {
|
private _releaseFocusDeferred(): void {
|
||||||
if (this._conversationFocusActive) {
|
if (this._conversationFocusActive) {
|
||||||
|
console.log('[Audio] _releaseFocusDeferred: Conversation aktiv → kein Release');
|
||||||
this._cancelDeferredFocusRelease();
|
this._cancelDeferredFocusRelease();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this._cancelDeferredFocusRelease();
|
this._cancelDeferredFocusRelease();
|
||||||
|
console.log('[Audio] _releaseFocusDeferred: in %dms', this.FOCUS_RELEASE_DELAY_MS);
|
||||||
this.focusReleaseTimer = setTimeout(() => {
|
this.focusReleaseTimer = setTimeout(() => {
|
||||||
this.focusReleaseTimer = null;
|
this.focusReleaseTimer = null;
|
||||||
if (this._conversationFocusActive) return;
|
if (this._conversationFocusActive) {
|
||||||
|
console.log('[Audio] Focus-Release abgebrochen (Conversation jetzt aktiv)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log('[Audio] AudioFocus jetzt released');
|
||||||
AudioFocus?.release().catch(() => {});
|
AudioFocus?.release().catch(() => {});
|
||||||
}, this.FOCUS_RELEASE_DELAY_MS);
|
}, this.FOCUS_RELEASE_DELAY_MS);
|
||||||
}
|
}
|
||||||
@@ -347,19 +361,69 @@ class AudioService {
|
|||||||
this._releaseFocusDeferred();
|
this._releaseFocusDeferred();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** TTS-Wiedergabe haart stoppen — z.B. wenn ein Anruf reinkommt.
|
/** TTS-Wiedergabe haart stoppen — z.B. fuer Barge-In. Buffer wird geleert,
|
||||||
* Released auch sofort den AudioFocus damit der Anruf-Klingelton hoerbar ist. */
|
* kein Auto-Resume. Released auch sofort den AudioFocus. */
|
||||||
haltAllPlayback(reason: string = ''): void {
|
haltAllPlayback(reason: string = ''): void {
|
||||||
console.log('[Audio] haltAllPlayback: %s', reason || '(no reason)');
|
console.log('[Audio] haltAllPlayback: %s', reason || '(no reason)');
|
||||||
this._conversationFocusActive = false;
|
this._conversationFocusActive = false;
|
||||||
this.stopPlayback();
|
this.stopPlayback();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Speziell fuer Anrufe: AudioTrack stoppen + Focus releasen, ABER pcm-
|
||||||
|
* Buffer + messageId behalten damit weitere Chunks der unterbrochenen
|
||||||
|
* Antwort weiter gesammelt werden. isFinal schreibt dann die WAV trotz
|
||||||
|
* Anruf — und resumeFromInterruption findet sie. */
|
||||||
|
pauseForCall(reason: string = ''): void {
|
||||||
|
console.log('[Audio] pauseForCall: %s', reason || '(no reason)');
|
||||||
|
this._conversationFocusActive = false;
|
||||||
|
this._pausedForCall = true;
|
||||||
|
// Queue + isPlaying ruecksetzen — sonst klemmt der naechste Play-Button
|
||||||
|
// (playAudio sieht isPlaying=true und ruft _playNext nicht mehr auf).
|
||||||
|
this.audioQueue = [];
|
||||||
|
this.isPlaying = false;
|
||||||
|
// Foreground-Service stoppen — Notification waere sonst irrefuehrend
|
||||||
|
stopBackgroundAudio().catch(() => {});
|
||||||
|
// SoundPool/RNSound (Resume-Sound, Play-Button) stoppen — nicht relevant fuer Auto-Resume
|
||||||
|
if (this.currentSound) {
|
||||||
|
try { this.currentSound.stop(); this.currentSound.release(); } catch {}
|
||||||
|
this.currentSound = null;
|
||||||
|
}
|
||||||
|
if (this.resumeSound) {
|
||||||
|
try { this.resumeSound.stop(); this.resumeSound.release(); } catch {}
|
||||||
|
this.resumeSound = null;
|
||||||
|
}
|
||||||
|
// AudioTrack hart stoppen damit nichts mehr aus dem Lautsprecher kommt.
|
||||||
|
// pcmStreamActive bleibt true, pcmBuffer/pcmMessageId BLEIBEN — damit
|
||||||
|
// weitere Chunks gesammelt werden und isFinal die WAV schreiben kann.
|
||||||
|
PcmStreamPlayer?.stop().catch(() => {});
|
||||||
|
this._cancelDeferredFocusRelease();
|
||||||
|
AudioFocus?.release().catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Anruf vorbei → weitere Chunks duerfen wieder abgespielt werden.
|
||||||
|
* resumeFromInterruption uebernimmt die Wiedergabe ab gemerkter Position. */
|
||||||
|
endCallPause(): void {
|
||||||
|
if (!this._pausedForCall) return;
|
||||||
|
this._pausedForCall = false;
|
||||||
|
console.log('[Audio] endCallPause');
|
||||||
|
}
|
||||||
|
|
||||||
/** Bei Anruf: aktuelle Wiedergabe-Position merken damit wir nach dem
|
/** Bei Anruf: aktuelle Wiedergabe-Position merken damit wir nach dem
|
||||||
* Auflegen von dort weitermachen koennen. Returnt Position in Sekunden
|
* Auflegen von dort weitermachen koennen. Returnt Position in Sekunden
|
||||||
* oder 0 wenn nichts spielte. */
|
* oder 0 wenn nichts spielte.
|
||||||
|
*
|
||||||
|
* Idempotent: bei mehrfachem Aufruf (ringing → offhook) wird die Position
|
||||||
|
* vom ersten Mal NICHT ueberschrieben. playbackStartTime laeuft stumpf
|
||||||
|
* weiter obwohl das Audio gestoppt ist — der erste Halt ist der echte. */
|
||||||
captureInterruption(): number {
|
captureInterruption(): number {
|
||||||
|
if (this.pausedMessageId) {
|
||||||
|
console.log('[Audio] captureInterruption: bereits erfasst (msgId=%s pos=%ss) — skip',
|
||||||
|
this.pausedMessageId, this.pausedPosition.toFixed(2));
|
||||||
|
return this.pausedPosition;
|
||||||
|
}
|
||||||
if (!this.playbackStartTime || !this.currentPlaybackMsgId) {
|
if (!this.playbackStartTime || !this.currentPlaybackMsgId) {
|
||||||
|
console.log('[Audio] captureInterruption: nichts spielte (startTime=%s, msgId=%s)',
|
||||||
|
this.playbackStartTime, this.currentPlaybackMsgId || '(leer)');
|
||||||
this.pausedPosition = 0;
|
this.pausedPosition = 0;
|
||||||
this.pausedMessageId = '';
|
this.pausedMessageId = '';
|
||||||
return 0;
|
return 0;
|
||||||
@@ -379,7 +443,12 @@ class AudioService {
|
|||||||
async resumeFromInterruption(maxWaitMs: number = 30000): Promise<boolean> {
|
async resumeFromInterruption(maxWaitMs: number = 30000): Promise<boolean> {
|
||||||
const msgId = this.pausedMessageId;
|
const msgId = this.pausedMessageId;
|
||||||
const position = this.pausedPosition;
|
const position = this.pausedPosition;
|
||||||
if (!msgId) return false;
|
if (!msgId) {
|
||||||
|
console.log('[Audio] resumeFromInterruption: kein gemerkter Stand — skip');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
console.log('[Audio] resumeFromInterruption: starte fuer msgId=%s pos=%ss',
|
||||||
|
msgId, position.toFixed(2));
|
||||||
this.pausedMessageId = ''; // konsumieren
|
this.pausedMessageId = ''; // konsumieren
|
||||||
const cachePath = `${RNFS.DocumentDirectoryPath}/tts_cache/${msgId}.wav`;
|
const cachePath = `${RNFS.DocumentDirectoryPath}/tts_cache/${msgId}.wav`;
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
@@ -413,6 +482,14 @@ class AudioService {
|
|||||||
this._firePlaybackStarted();
|
this._firePlaybackStarted();
|
||||||
this.isPlaying = true;
|
this.isPlaying = true;
|
||||||
this.resumeSound = sound;
|
this.resumeSound = sound;
|
||||||
|
// Tracking auch fuer den Resume-Sound aktualisieren — sonst kann
|
||||||
|
// captureInterruption bei einem zweiten Anruf die Position nicht
|
||||||
|
// mehr ermitteln (playbackStartTime waere von der ersten Wiedergabe).
|
||||||
|
const msgIdMatch = path.match(/([^/\\]+)\.wav$/i);
|
||||||
|
if (msgIdMatch) this.currentPlaybackMsgId = msgIdMatch[1];
|
||||||
|
// Virtuelle Start-Zeit so setzen, dass captureInterruption (das den
|
||||||
|
// Leading-Silence-Offset wieder abzieht) die korrekte Position liefert.
|
||||||
|
this.playbackStartTime = Date.now() - (positionSec + this.LEADING_SILENCE_SEC) * 1000;
|
||||||
console.log('[Audio] Resume von Position %ss aus %s',
|
console.log('[Audio] Resume von Position %ss aus %s',
|
||||||
positionSec.toFixed(2), path);
|
positionSec.toFixed(2), path);
|
||||||
sound.setCurrentTime(Math.max(0, positionSec));
|
sound.setCurrentTime(Math.max(0, positionSec));
|
||||||
@@ -719,8 +796,13 @@ class AudioService {
|
|||||||
if (!base64Data) return;
|
if (!base64Data) return;
|
||||||
// Mute-Flag respektieren — robust gegen Race-Conditions zwischen User-
|
// Mute-Flag respektieren — robust gegen Race-Conditions zwischen User-
|
||||||
// Klick auf Mute und einem TTS-Chunk der im selben Tick eintrifft.
|
// Klick auf Mute und einem TTS-Chunk der im selben Tick eintrifft.
|
||||||
if (this._muted) return;
|
if (this._muted) {
|
||||||
|
console.log('[Audio] playAudio: muted=true → skip');
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.audioQueue.push(base64Data);
|
this.audioQueue.push(base64Data);
|
||||||
|
console.log('[Audio] playAudio: queued (queue=%d isPlaying=%s pausedForCall=%s)',
|
||||||
|
this.audioQueue.length, this.isPlaying, this._pausedForCall);
|
||||||
if (!this.isPlaying) {
|
if (!this.isPlaying) {
|
||||||
this._playNext();
|
this._playNext();
|
||||||
}
|
}
|
||||||
@@ -786,9 +868,16 @@ class AudioService {
|
|||||||
final?: boolean;
|
final?: boolean;
|
||||||
silent?: boolean;
|
silent?: boolean;
|
||||||
}): Promise<string> {
|
}): Promise<string> {
|
||||||
|
// _stoppedMessageId: User hat diese Antwort mid-Wiedergabe gestoppt
|
||||||
|
// (Mute geklickt). Auch wenn Mute jetzt wieder aus ist, soll diese
|
||||||
|
// Antwort nicht weiterspielen. Erst eine neue messageId resetted das.
|
||||||
|
const incomingMsgId = payload.messageId || '';
|
||||||
|
const stoppedByUser = !!this._stoppedMessageId && incomingMsgId === this._stoppedMessageId;
|
||||||
// Globaler Mute-Flag uebersteuert das per-Call silent — verhindert
|
// Globaler Mute-Flag uebersteuert das per-Call silent — verhindert
|
||||||
// Race-Conditions wenn der User zwischen Chunks den Mute-Knopf drueckt.
|
// Race-Conditions wenn der User zwischen Chunks den Mute-Knopf drueckt.
|
||||||
const silent = !!payload.silent || this._muted;
|
// _pausedForCall: AudioTrack ist gestoppt waehrend Anruf — Chunks weiter
|
||||||
|
// sammeln (fuer WAV-Cache), aber NICHT in den Player schicken.
|
||||||
|
const silent = !!payload.silent || this._muted || this._pausedForCall || stoppedByUser;
|
||||||
if (!silent && !PcmStreamPlayer) {
|
if (!silent && !PcmStreamPlayer) {
|
||||||
console.warn('[Audio] PcmStreamPlayer Native Module nicht verfuegbar');
|
console.warn('[Audio] PcmStreamPlayer Native Module nicht verfuegbar');
|
||||||
return '';
|
return '';
|
||||||
@@ -829,6 +918,13 @@ class AudioService {
|
|||||||
this.pausedMessageId = '';
|
this.pausedMessageId = '';
|
||||||
this.pausedPosition = 0;
|
this.pausedPosition = 0;
|
||||||
}
|
}
|
||||||
|
// Stop-Marker zuruecksetzen wenn neue messageId — neue Antwort darf
|
||||||
|
// wieder normal abspielen, egal ob Mute zwischendurch aktiv war.
|
||||||
|
if (this._stoppedMessageId && this._stoppedMessageId !== messageId) {
|
||||||
|
console.log('[Audio] Neue Antwort (msgId=%s) — Stop-Marker fuer %s zurueckgesetzt',
|
||||||
|
messageId, this._stoppedMessageId);
|
||||||
|
this._stoppedMessageId = '';
|
||||||
|
}
|
||||||
this.pcmStreamActive = true;
|
this.pcmStreamActive = true;
|
||||||
this.pcmMessageId = messageId;
|
this.pcmMessageId = messageId;
|
||||||
this.pcmSampleRate = sampleRate;
|
this.pcmSampleRate = sampleRate;
|
||||||
@@ -946,7 +1042,10 @@ class AudioService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Audio aus lokaler Datei (file:// Pfad) in die Queue und abspielen. */
|
/** Audio aus lokaler Datei (file:// Pfad) in die Queue und abspielen.
|
||||||
|
* Setzt zusaetzlich playbackStartTime + currentPlaybackMsgId damit ein
|
||||||
|
* Anruf waehrend dieses Playbacks korrekt erfasst wird (ohne dieses
|
||||||
|
* Tracking liefert captureInterruption nichts → kein Auto-Resume). */
|
||||||
async playFromPath(filePath: string): Promise<void> {
|
async playFromPath(filePath: string): Promise<void> {
|
||||||
if (!filePath) return;
|
if (!filePath) return;
|
||||||
try {
|
try {
|
||||||
@@ -955,6 +1054,14 @@ class AudioService {
|
|||||||
console.warn('[Audio] Cache-Datei existiert nicht mehr:', cleanPath);
|
console.warn('[Audio] Cache-Datei existiert nicht mehr:', cleanPath);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Dateiname ohne .wav als messageId nehmen (egal ob UUID oder andere ID)
|
||||||
|
const fileMatch = cleanPath.match(/([^/\\]+)\.wav$/i);
|
||||||
|
const msgId = fileMatch ? fileMatch[1] : '';
|
||||||
|
console.log('[Audio] playFromPath: cleanPath=%s → msgId=%s', cleanPath, msgId || '(leer)');
|
||||||
|
if (msgId) {
|
||||||
|
this.currentPlaybackMsgId = msgId;
|
||||||
|
this.playbackStartTime = Date.now() - this.LEADING_SILENCE_SEC * 1000;
|
||||||
|
}
|
||||||
const b64 = await RNFS.readFile(cleanPath, 'base64');
|
const b64 = await RNFS.readFile(cleanPath, 'base64');
|
||||||
this.playAudio(b64);
|
this.playAudio(b64);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -983,9 +1090,15 @@ class AudioService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private _firePlaybackStarted(): void {
|
private _firePlaybackStarted(): void {
|
||||||
// Tracking fuer Auto-Resume nach Anruf-Pause
|
// Tracking fuer Auto-Resume nach Anruf-Pause: NUR setzen wenn ein
|
||||||
this.playbackStartTime = Date.now();
|
// PCM-Stream laeuft (Live-TTS). Bei Play-Button / Resume-Sound hat der
|
||||||
this.currentPlaybackMsgId = this.pcmMessageId || '';
|
// Caller (playFromPath / _playFromPathAtPosition) das Tracking schon
|
||||||
|
// korrekt mit der msgId aus dem Pfad gesetzt — sonst wuerden wir hier
|
||||||
|
// mit leerem pcmMessageId ueberschreiben.
|
||||||
|
if (this.pcmMessageId) {
|
||||||
|
this.playbackStartTime = Date.now();
|
||||||
|
this.currentPlaybackMsgId = this.pcmMessageId;
|
||||||
|
}
|
||||||
this.playbackStartedListeners.forEach(cb => {
|
this.playbackStartedListeners.forEach(cb => {
|
||||||
try { cb(); } catch (e) { console.warn('[Audio] playbackStarted listener err:', e); }
|
try { cb(); } catch (e) { console.warn('[Audio] playbackStarted listener err:', e); }
|
||||||
});
|
});
|
||||||
@@ -1038,11 +1151,13 @@ class AudioService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.currentSound = sound;
|
this.currentSound = sound;
|
||||||
|
console.log('[Audio] Sound.play startet (path=%s)', soundPath);
|
||||||
|
|
||||||
// Naechstes Audio schon vorbereiten waehrend dieses abspielt
|
// Naechstes Audio schon vorbereiten waehrend dieses abspielt
|
||||||
this._preloadNext();
|
this._preloadNext();
|
||||||
|
|
||||||
sound.play((success) => {
|
sound.play((success) => {
|
||||||
|
console.log('[Audio] Sound.play callback: success=%s queue=%d', success, this.audioQueue.length);
|
||||||
if (!success) console.warn('[Audio] Wiedergabe fehlgeschlagen');
|
if (!success) console.warn('[Audio] Wiedergabe fehlgeschlagen');
|
||||||
sound.release();
|
sound.release();
|
||||||
this.currentSound = null;
|
this.currentSound = null;
|
||||||
@@ -1074,14 +1189,43 @@ class AudioService {
|
|||||||
* — die Bridge kann einen Chunk im selben JS-Tick liefern in dem der
|
* — die Bridge kann einen Chunk im selben JS-Tick liefern in dem der
|
||||||
* User Mute geklickt hat. */
|
* User Mute geklickt hat. */
|
||||||
private _muted: boolean = false;
|
private _muted: boolean = false;
|
||||||
|
/** Anruf laeuft → Chunks werden nur in den Cache-Buffer gepusht, nicht
|
||||||
|
* abgespielt. Wird in pauseForCall gesetzt, in endCallPause/resumeFrom-
|
||||||
|
* Interruption zurueckgenommen. */
|
||||||
|
private _pausedForCall: boolean = false;
|
||||||
|
/** Wenn der User mid-Wiedergabe Mute drueckt: messageId der ABGEBROCHENEN
|
||||||
|
* Antwort merken. Folge-Chunks dieser msgId werden silent ignoriert, auch
|
||||||
|
* wenn der User Mute wieder ausschaltet — kein "Resume mid-Antwort". Eine
|
||||||
|
* NEUE messageId resetted das, dann spielt's wieder normal. */
|
||||||
|
private _stoppedMessageId: string = '';
|
||||||
setMuted(muted: boolean): void {
|
setMuted(muted: boolean): void {
|
||||||
|
console.log('[Audio] setMuted: %s (currentSound=%s pcmStreamActive=%s)',
|
||||||
|
muted, this.currentSound ? 'aktiv' : 'null', this.pcmStreamActive);
|
||||||
this._muted = muted;
|
this._muted = muted;
|
||||||
if (muted) this.stopPlayback();
|
if (muted) {
|
||||||
|
// Aktuell laufende Antwort als "verworfen" markieren — nachfolgende
|
||||||
|
// chunks dieser msgId werden silent gehalten auch wenn der User Mute
|
||||||
|
// gleich wieder ausschaltet. Erst eine NEUE Antwort darf wieder reden.
|
||||||
|
const activeMsgId = this.pcmMessageId || this.currentPlaybackMsgId;
|
||||||
|
if (activeMsgId) {
|
||||||
|
this._stoppedMessageId = activeMsgId;
|
||||||
|
console.log('[Audio] Antwort %s als gestoppt markiert', activeMsgId);
|
||||||
|
}
|
||||||
|
this.stopPlayback();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
isMuted(): boolean { return this._muted; }
|
isMuted(): boolean { return this._muted; }
|
||||||
|
|
||||||
/** Laufende Wiedergabe stoppen + Queue leeren */
|
/** Laufende Wiedergabe stoppen + Queue leeren */
|
||||||
stopPlayback(): void {
|
stopPlayback(): void {
|
||||||
|
// Idempotent: wenn nichts mehr aktiv ist, NICHT noch einen Focus-Release/
|
||||||
|
// Kick-Cycle anstossen — Re-Renders triggern setMuted oft mehrfach hinter-
|
||||||
|
// einander, und jeder weitere Kick lässt Spotify nochmal kurz pausieren.
|
||||||
|
const hasAnything = !!(this.currentSound || this.resumeSound || this.preloadedSound
|
||||||
|
|| this.pcmStreamActive || this.audioQueue.length || this.isPlaying);
|
||||||
|
if (!hasAnything) return;
|
||||||
|
console.log('[Audio] stopPlayback: currentSound=%s queue=%d pcm=%s',
|
||||||
|
this.currentSound ? 'aktiv' : 'null', this.audioQueue.length, this.pcmStreamActive);
|
||||||
// Foreground-Service auch stoppen — sonst bleibt die Notification haengen
|
// Foreground-Service auch stoppen — sonst bleibt die Notification haengen
|
||||||
// wenn Wiedergabe abgebrochen wird (Anruf, Cancel, Barge-In).
|
// wenn Wiedergabe abgebrochen wird (Anruf, Cancel, Barge-In).
|
||||||
stopBackgroundAudio().catch(() => {});
|
stopBackgroundAudio().catch(() => {});
|
||||||
@@ -1092,23 +1236,33 @@ class AudioService {
|
|||||||
this.currentSound.release();
|
this.currentSound.release();
|
||||||
this.currentSound = null;
|
this.currentSound = null;
|
||||||
}
|
}
|
||||||
|
if (this.resumeSound) {
|
||||||
|
this.resumeSound.stop();
|
||||||
|
this.resumeSound.release();
|
||||||
|
this.resumeSound = null;
|
||||||
|
}
|
||||||
if (this.preloadedSound) {
|
if (this.preloadedSound) {
|
||||||
this.preloadedSound.release();
|
this.preloadedSound.release();
|
||||||
this.preloadedSound = null;
|
this.preloadedSound = null;
|
||||||
if (this.preloadedPath) RNFS.unlink(this.preloadedPath).catch(() => {});
|
if (this.preloadedPath) RNFS.unlink(this.preloadedPath).catch(() => {});
|
||||||
this.preloadedPath = '';
|
this.preloadedPath = '';
|
||||||
}
|
}
|
||||||
// PCM-Stream ebenfalls hart stoppen (Cancel/Abbruch)
|
// PCM-Stream ebenfalls hart stoppen (Cancel/Abbruch).
|
||||||
if (this.pcmStreamActive) {
|
// pcmStreamActive wird beim isFinal-Chunk schon false gesetzt — der
|
||||||
PcmStreamPlayer?.stop().catch(() => {});
|
// AudioTrack spielt aber noch sekundenlang aus seinem Buffer ab. Daher
|
||||||
this.pcmStreamActive = false;
|
// IMMER stop() aufrufen, ohne den Flag zu pruefen (ist idempotent).
|
||||||
this.pcmBuffer = [];
|
PcmStreamPlayer?.stop().catch(() => {});
|
||||||
this.pcmBytesCollected = 0;
|
this.pcmStreamActive = false;
|
||||||
this.pcmMessageId = '';
|
this.pcmBuffer = [];
|
||||||
}
|
this.pcmBytesCollected = 0;
|
||||||
|
this.pcmMessageId = '';
|
||||||
// Audio-Focus sofort freigeben — User hat explizit abgebrochen
|
// Audio-Focus sofort freigeben — User hat explizit abgebrochen
|
||||||
this._cancelDeferredFocusRelease();
|
this._cancelDeferredFocusRelease();
|
||||||
AudioFocus?.release().catch(() => {});
|
AudioFocus?.release().catch(() => {});
|
||||||
|
// Focus-Stack immer aufmischen — bei aelteren Nachrichten die ueber
|
||||||
|
// tts_request (PCM-Stream) re-rendert wurden, bleibt Spotify ohne den
|
||||||
|
// Kick auch pausiert. Kostet nichts, deckt beide Pfade ab.
|
||||||
|
AudioFocus?.kickReleaseMedia?.().catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Status & Callbacks ---
|
// --- Status & Callbacks ---
|
||||||
@@ -1148,19 +1302,29 @@ class AudioService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Alte Aufnahme- und TTS-Files aus dem Cache loeschen (>30s alt). */
|
/** Alte Aufnahme- und TTS-Files aus dem Cache loeschen.
|
||||||
private async _cleanupStaleCacheFiles(): Promise<void> {
|
* Default 30s — verwendet beim Mikro-Start (kurze Lebensdauer reicht).
|
||||||
|
* App-Start nutzt 5min damit gerade aktive Files nicht erwischt werden. */
|
||||||
|
private async _cleanupStaleCacheFiles(maxAgeMs: number = 30000): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const files = await RNFS.readDir(RNFS.CachesDirectoryPath);
|
const files = await RNFS.readDir(RNFS.CachesDirectoryPath);
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
let removed = 0;
|
||||||
|
let freedBytes = 0;
|
||||||
for (const f of files) {
|
for (const f of files) {
|
||||||
if (!f.isFile()) continue;
|
if (!f.isFile()) continue;
|
||||||
if (!f.name.startsWith('aria_recording_') && !f.name.startsWith('aria_tts_')) continue;
|
if (!f.name.startsWith('aria_recording_') && !f.name.startsWith('aria_tts_')) continue;
|
||||||
const age = now - (f.mtime ? f.mtime.getTime() : 0);
|
const age = now - (f.mtime ? f.mtime.getTime() : 0);
|
||||||
if (age > 30000) {
|
if (age > maxAgeMs) {
|
||||||
|
freedBytes += parseInt(f.size as any, 10) || 0;
|
||||||
await RNFS.unlink(f.path).catch(() => {});
|
await RNFS.unlink(f.path).catch(() => {});
|
||||||
|
removed += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (removed > 0) {
|
||||||
|
console.log('[Audio] Cache-Cleanup: %d Files entfernt, %.1fMB freigegeben',
|
||||||
|
removed, freedBytes / 1024 / 1024);
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// silent — cleanup ist best-effort
|
// silent — cleanup ist best-effort
|
||||||
}
|
}
|
||||||
@@ -1187,6 +1351,43 @@ class AudioService {
|
|||||||
// silent
|
// silent
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Aktuelle Groesse des TTS-Caches. */
|
||||||
|
async getTtsCacheSize(): Promise<{ count: number; totalMB: number }> {
|
||||||
|
let count = 0;
|
||||||
|
let total = 0;
|
||||||
|
try {
|
||||||
|
const dir = `${RNFS.DocumentDirectoryPath}/tts_cache`;
|
||||||
|
if (await RNFS.exists(dir)) {
|
||||||
|
const files = await RNFS.readDir(dir);
|
||||||
|
for (const f of files) {
|
||||||
|
if (!f.isFile() || !f.name.endsWith('.wav')) continue;
|
||||||
|
count += 1;
|
||||||
|
total += parseInt(f.size as any, 10) || 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
return { count, totalMB: total / 1024 / 1024 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** TTS-Cache komplett leeren (Settings-Button). */
|
||||||
|
async clearTtsCache(): Promise<{ removed: number; freedMB: number }> {
|
||||||
|
let removed = 0;
|
||||||
|
let freed = 0;
|
||||||
|
try {
|
||||||
|
const dir = `${RNFS.DocumentDirectoryPath}/tts_cache`;
|
||||||
|
if (!(await RNFS.exists(dir))) return { removed: 0, freedMB: 0 };
|
||||||
|
const files = await RNFS.readDir(dir);
|
||||||
|
for (const f of files) {
|
||||||
|
if (!f.isFile() || !f.name.endsWith('.wav')) continue;
|
||||||
|
const size = parseInt(f.size as any, 10) || 0;
|
||||||
|
await RNFS.unlink(f.path).catch(() => {});
|
||||||
|
removed += 1;
|
||||||
|
freed += size;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
return { removed, freedMB: freed / 1024 / 1024 };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Singleton
|
// Singleton
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
/**
|
||||||
|
* Verbose-Logging-Toggle: console.log laesst sich global stummschalten.
|
||||||
|
* console.warn/console.error bleiben immer an — Fehler will man immer sehen.
|
||||||
|
*
|
||||||
|
* Default: an (true). Toggle ueber Settings → Protokoll → Verbose Logging.
|
||||||
|
* Beim Start wird der gespeicherte Wert geladen, vorher loggen wir normal.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
|
|
||||||
|
export const VERBOSE_LOGGING_KEY = 'aria_verbose_logging';
|
||||||
|
|
||||||
|
// Original-console.log retten, damit wir die Wrapper jederzeit wieder
|
||||||
|
// "scharf" stellen koennen (sonst waere ein Toggle-an nach -aus tot).
|
||||||
|
const originalLog = console.log.bind(console);
|
||||||
|
const noop = () => {};
|
||||||
|
|
||||||
|
let _verbose = true;
|
||||||
|
|
||||||
|
function applyState(): void {
|
||||||
|
console.log = _verbose ? originalLog : noop;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wert aus AsyncStorage laden und anwenden. Beim App-Start aufrufen. */
|
||||||
|
export async function initLogger(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const v = await AsyncStorage.getItem(VERBOSE_LOGGING_KEY);
|
||||||
|
_verbose = v !== 'false'; // default: true
|
||||||
|
} catch {}
|
||||||
|
applyState();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isVerboseLogging(): boolean {
|
||||||
|
return _verbose;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setVerboseLogging(verbose: boolean): void {
|
||||||
|
_verbose = verbose;
|
||||||
|
applyState();
|
||||||
|
AsyncStorage.setItem(VERBOSE_LOGGING_KEY, String(verbose)).catch(() => {});
|
||||||
|
}
|
||||||
@@ -189,22 +189,32 @@ class PhoneCallService {
|
|||||||
private _haltForCall(toast: string): void {
|
private _haltForCall(toast: string): void {
|
||||||
// Position merken bevor wir den Stream killen — fuer Auto-Resume.
|
// Position merken bevor wir den Stream killen — fuer Auto-Resume.
|
||||||
audioService.captureInterruption();
|
audioService.captureInterruption();
|
||||||
audioService.haltAllPlayback(toast);
|
// pauseForCall (statt haltAllPlayback): pcmBuffer + messageId bleiben,
|
||||||
|
// weitere Chunks werden weiter gesammelt damit isFinal die WAV schreibt.
|
||||||
|
audioService.pauseForCall(toast);
|
||||||
wakeWordService.pauseForCall().catch(() => {});
|
wakeWordService.pauseForCall().catch(() => {});
|
||||||
ToastAndroid.show(toast, ToastAndroid.SHORT);
|
ToastAndroid.show(toast, ToastAndroid.SHORT);
|
||||||
}
|
}
|
||||||
|
|
||||||
private _resumeAfterCall(toast: string): void {
|
private _resumeAfterCall(toast: string): void {
|
||||||
|
// Anruf-Pause aufheben — neue Chunks duerfen wieder direkt abgespielt
|
||||||
|
// werden (falls die Bridge mid-Anruf isFinal noch nicht geschickt hat).
|
||||||
|
audioService.endCallPause();
|
||||||
wakeWordService.resumeFromCall().catch(() => {});
|
wakeWordService.resumeFromCall().catch(() => {});
|
||||||
ToastAndroid.show(toast, ToastAndroid.SHORT);
|
ToastAndroid.show(toast, ToastAndroid.SHORT);
|
||||||
// Auto-Resume: ab gemerkter Position weiterspielen wenn ARIA vor dem
|
// 800ms warten bevor Auto-Resume — sonst kollidiert ARIA's neuer Focus-
|
||||||
// Anruf gerade redete. Wartet bis zu 30s auf den WAV-Cache (falls
|
// Request mit Spotify's Auto-Resume nach Anruf-Ende. System haengt nach
|
||||||
// final-Marker erst nach dem Anruf-Ende kam).
|
// dem Auflegen noch im IN_CALL-Mode-Uebergang, Spotify schaut auf Focus-
|
||||||
audioService.resumeFromInterruption(30000).then(ok => {
|
// Gain und wuerde sofort wieder LOSS sehen → bleibt pausiert.
|
||||||
if (ok) {
|
// Mit Delay: Spotify resumed kurz, dann pausiert ARIA wieder ordnungs-
|
||||||
console.log('[PhoneCall] Auto-Resume von gemerkter Position gestartet');
|
// gemaess. Wenn ARIA nichts pending hat, bleibt Spotify einfach an.
|
||||||
}
|
setTimeout(() => {
|
||||||
}).catch(() => {});
|
audioService.resumeFromInterruption(30000).then(ok => {
|
||||||
|
if (ok) {
|
||||||
|
console.log('[PhoneCall] Auto-Resume von gemerkter Position gestartet');
|
||||||
|
}
|
||||||
|
}).catch(() => {});
|
||||||
|
}, 800);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+59
-4
@@ -677,7 +677,10 @@ class ARIABridge:
|
|||||||
while self.running:
|
while self.running:
|
||||||
try:
|
try:
|
||||||
logger.info("[core] Verbinde: %s", self.ws_url)
|
logger.info("[core] Verbinde: %s", self.ws_url)
|
||||||
async with websockets.connect(self.ws_url) as ws:
|
# max_size=50MB damit grosse Bilder/Voice-Uploads durchgehen.
|
||||||
|
# Python-websockets Default ist nur 1 MiB → 5MB JPEG sprengt
|
||||||
|
# das Limit, Connection wird silent gedroppt.
|
||||||
|
async with websockets.connect(self.ws_url, max_size=50 * 1024 * 1024) as ws:
|
||||||
# OpenClaw Handshake durchfuehren
|
# OpenClaw Handshake durchfuehren
|
||||||
if not await self._openclaw_handshake(ws):
|
if not await self._openclaw_handshake(ws):
|
||||||
logger.error("[core] Handshake fehlgeschlagen — Reconnect")
|
logger.error("[core] Handshake fehlgeschlagen — Reconnect")
|
||||||
@@ -783,13 +786,29 @@ class ARIABridge:
|
|||||||
await self._emit_activity("idle", "")
|
await self._emit_activity("idle", "")
|
||||||
if not text:
|
if not text:
|
||||||
logger.warning("[core] chat final ohne Text: %s", json.dumps(payload)[:200])
|
logger.warning("[core] chat final ohne Text: %s", json.dumps(payload)[:200])
|
||||||
|
# App+Diagnostic informieren statt stumm — sonst wartet die
|
||||||
|
# UI ewig auf eine Antwort die nicht kommt. Passiert z.B.
|
||||||
|
# wenn Claude-Vision das Bild ablehnt (leere Antwort)
|
||||||
|
# oder die Antwort nur aus Tool-Calls ohne Final-Text bestand.
|
||||||
|
await self._send_to_rvs({
|
||||||
|
"type": "chat",
|
||||||
|
"payload": {
|
||||||
|
"text": "[Hinweis] Antwort ohne Text — moeglicherweise Bild zu gross fuer Vision-API oder reine Tool-Ausfuehrung.",
|
||||||
|
"sender": "aria",
|
||||||
|
},
|
||||||
|
"timestamp": int(asyncio.get_event_loop().time() * 1000),
|
||||||
|
})
|
||||||
return
|
return
|
||||||
logger.info("[core] Antwort: '%s'", text[:80])
|
logger.info("[core] Antwort: '%s'", text[:80])
|
||||||
await self._process_core_response(text, payload)
|
await self._process_core_response(text, payload)
|
||||||
return
|
return
|
||||||
|
|
||||||
if state == "error":
|
if state == "error":
|
||||||
error = payload.get("error", "Unbekannt")
|
# OpenClaw nutzt errorMessage statt error bei state=error.
|
||||||
|
error = (payload.get("error")
|
||||||
|
or payload.get("errorMessage")
|
||||||
|
or payload.get("message")
|
||||||
|
or "Unbekannt")
|
||||||
logger.error("[core] Chat-Fehler: %s", error)
|
logger.error("[core] Chat-Fehler: %s", error)
|
||||||
self._last_chat_final_at = asyncio.get_event_loop().time()
|
self._last_chat_final_at = asyncio.get_event_loop().time()
|
||||||
await self._emit_activity("idle", "")
|
await self._emit_activity("idle", "")
|
||||||
@@ -825,7 +844,12 @@ class ARIABridge:
|
|||||||
return
|
return
|
||||||
|
|
||||||
if event_name == "chat:error":
|
if event_name == "chat:error":
|
||||||
error = payload.get("error", payload.get("message", "Unbekannt"))
|
# OpenClaw legt den echten Text manchmal in errorMessage ab
|
||||||
|
# (state=error). Vorher wurde nur error/message gechecked → "Unbekannt".
|
||||||
|
error = (payload.get("error")
|
||||||
|
or payload.get("errorMessage")
|
||||||
|
or payload.get("message")
|
||||||
|
or "Unbekannt")
|
||||||
logger.error("[core] Chat-Fehler (legacy): %s", error)
|
logger.error("[core] Chat-Fehler (legacy): %s", error)
|
||||||
await self._send_to_rvs({
|
await self._send_to_rvs({
|
||||||
"type": "chat",
|
"type": "chat",
|
||||||
@@ -1141,7 +1165,8 @@ class ARIABridge:
|
|||||||
try:
|
try:
|
||||||
url = f"{current_url}?token={self.rvs_token}"
|
url = f"{current_url}?token={self.rvs_token}"
|
||||||
logger.info("[rvs] Verbinde: %s", current_url)
|
logger.info("[rvs] Verbinde: %s", current_url)
|
||||||
async with websockets.connect(url) as ws:
|
# max_size=50MB (siehe core-Connect oben — gleicher Grund).
|
||||||
|
async with websockets.connect(url, max_size=50 * 1024 * 1024) as ws:
|
||||||
self.ws_rvs = ws
|
self.ws_rvs = ws
|
||||||
retry_delay = 2
|
retry_delay = 2
|
||||||
logger.info("[rvs] Verbunden — warte auf App-Nachrichten")
|
logger.info("[rvs] Verbunden — warte auf App-Nachrichten")
|
||||||
@@ -1461,6 +1486,31 @@ class ARIABridge:
|
|||||||
size_kb = len(file_b64) // 1365
|
size_kb = len(file_b64) // 1365
|
||||||
logger.info("[rvs] Datei gespeichert: %s (%dKB)", file_path, size_kb)
|
logger.info("[rvs] Datei gespeichert: %s (%dKB)", file_path, size_kb)
|
||||||
|
|
||||||
|
# Pixel-Bilder fuer Claude-Vision shrinken wenn > 2 MB. SVG/PDF/ZIP
|
||||||
|
# bleiben unangetastet (Vision laeuft eh nur auf Raster-Formaten).
|
||||||
|
CLAUDE_VISION_FORMATS = ("image/jpeg", "image/jpg", "image/png", "image/webp", "image/gif")
|
||||||
|
if file_type.lower() in CLAUDE_VISION_FORMATS:
|
||||||
|
file_size_bytes = os.path.getsize(file_path)
|
||||||
|
if file_size_bytes > 2 * 1024 * 1024:
|
||||||
|
try:
|
||||||
|
from PIL import Image
|
||||||
|
with Image.open(file_path) as img:
|
||||||
|
orig_w, orig_h = img.size
|
||||||
|
# Anthropic-Empfehlung: max 1568px lange Seite. RGB-Konvertierung
|
||||||
|
# falls RGBA/Palette (JPEG braucht RGB).
|
||||||
|
img.thumbnail((1568, 1568), Image.Resampling.LANCZOS)
|
||||||
|
if img.mode in ("RGBA", "P"):
|
||||||
|
img = img.convert("RGB")
|
||||||
|
img.save(file_path, "JPEG", quality=85, optimize=True)
|
||||||
|
new_size_bytes = os.path.getsize(file_path)
|
||||||
|
logger.info("[rvs] Bild verkleinert: %dx%d → %dx%d, %.1fMB → %.1fMB",
|
||||||
|
orig_w, orig_h, img.size[0], img.size[1],
|
||||||
|
file_size_bytes / 1024 / 1024,
|
||||||
|
new_size_bytes / 1024 / 1024)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("[rvs] Bild-Resize fehlgeschlagen (%s) — Original wird genutzt: %s",
|
||||||
|
file_name, e)
|
||||||
|
|
||||||
# In Pending-Queue + Flush-Timer (anti-spam Buffering)
|
# In Pending-Queue + Flush-Timer (anti-spam Buffering)
|
||||||
self._pending_files.append((file_path, file_name, file_type, size_kb, int(width or 0), int(height or 0)))
|
self._pending_files.append((file_path, file_name, file_type, size_kb, int(width or 0), int(height or 0)))
|
||||||
if self._pending_files_flush_task and not self._pending_files_flush_task.done():
|
if self._pending_files_flush_task and not self._pending_files_flush_task.done():
|
||||||
@@ -1634,6 +1684,11 @@ class ARIABridge:
|
|||||||
}
|
}
|
||||||
if audio_request_id:
|
if audio_request_id:
|
||||||
stt_payload["audioRequestId"] = audio_request_id
|
stt_payload["audioRequestId"] = audio_request_id
|
||||||
|
# GPS aus dem Original-Audio-Payload mitgeben — Diagnostic
|
||||||
|
# zeigt sie sonst nicht an (App sendet location nur einmal,
|
||||||
|
# die im audio-Payload). Reine Anzeige-Information.
|
||||||
|
if location:
|
||||||
|
stt_payload["location"] = location
|
||||||
ok = await self._send_to_rvs({
|
ok = await self._send_to_rvs({
|
||||||
"type": "chat",
|
"type": "chat",
|
||||||
"payload": stt_payload,
|
"payload": stt_payload,
|
||||||
|
|||||||
@@ -16,3 +16,6 @@ sounddevice
|
|||||||
|
|
||||||
# Wake-Word Erkennung
|
# Wake-Word Erkennung
|
||||||
openwakeword
|
openwakeword
|
||||||
|
|
||||||
|
# Bild-Resizing (zu grosse Pixel-Bilder shrinken bevor Claude-Vision sie sieht — 5MB-Limit)
|
||||||
|
Pillow
|
||||||
|
|||||||
@@ -85,6 +85,10 @@ Wichtige Mechanismen:
|
|||||||
- [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] **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] 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
|
- [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
|
||||||
|
- [x] **Kurze TTS-Texte (1-3 Worte) spielen jetzt ab** — auf OnePlus A12 stallte AudioTrack mit `pos=0` weil der Default-Start-Threshold `bufferSize/2` (= 2s) bei kurzen Streams nie ueberschritten wurde. Fix: `setStartThresholdInFrames(100ms)` direkt nach dem Track-Build (API 31+). Buffer auf 4s entkoppelt von Pre-Roll, `play()` wird beim allerersten data-chunk gerufen
|
||||||
|
- [x] **Mute-Button stoppt jetzt auch laufenden PCM-Stream** — `pcmStreamActive` wurde beim isFinal-Chunk schon false gesetzt, der AudioTrack spielte aber noch sekundenlang aus seinem Buffer. `stopPlayback()` uebersprang darum `PcmStreamPlayer.stop()`. Fix: stop() immer rufen (ist idempotent), kein Flag-Check mehr
|
||||||
|
- [x] **GPS-Permission im Manifest + Runtime-Request** beim Settings-Toggle — vorher fehlten ACCESS_COARSE_LOCATION / ACCESS_FINE_LOCATION komplett. `Geolocation.getCurrentPosition` schlug lautlos fehl, App sendete nie ein location-Feld
|
||||||
|
- [x] **GPS-Position auch im STT-Payload an Diagnostic** — die App sendet location einmal im audio-Payload. Die Bridge nutzte sie zwar (ging in aria-core's Kontext rein), reichte sie aber nicht im STT-broadcast an Diagnostic durch. Diagnostic zeigte darum bei Spracheingaben nie den GPS-Block, obwohl der "GPS einblenden"-Toggle aktiv war
|
||||||
|
|
||||||
### App Features
|
### App Features
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user