Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| db4c7b9b72 | |||
| 3bc490b485 | |||
| dd6d70c46e | |||
| b1eaf42fef | |||
| fb9e5dcd10 | |||
| f95e71463f | |||
| 1088bff43d | |||
| cad68db2a2 | |||
| 50b10c8ac0 | |||
| a8b586ec92 | |||
| 632e1e4fa1 | |||
| 7e12816ebd | |||
| 8f64f8fb30 | |||
| b3ff3991c4 | |||
| a4ea387c98 | |||
| 68fbf74a23 | |||
| b857f778e9 | |||
| 31aa82b68c | |||
| de8eeb69e2 | |||
| f5970ce700 | |||
| ef1a4436ca | |||
| 981779cd9e | |||
| 3dcd2ae0b4 |
@@ -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 906
|
versionCode 10005
|
||||||
versionName "0.0.9.6"
|
versionName "0.1.0.5"
|
||||||
// Fallback fuer Libraries mit Product Flavors
|
// Fallback fuer Libraries mit Product Flavors
|
||||||
missingDimensionStrategy 'react-native-camera', 'general'
|
missingDimensionStrategy 'react-native-camera', 'general'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -131,6 +131,52 @@ 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
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
am.abandonAudioFocusRequest(kickReq)
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
val kickListener = AudioManager.OnAudioFocusChangeListener { /* ignorieren */ }
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
am.requestAudioFocus(kickListener, AudioManager.STREAM_MUSIC, AudioManager.AUDIOFOCUS_GAIN)
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
am.abandonAudioFocus(kickListener)
|
||||||
|
}
|
||||||
|
Log.i(TAG, "kickReleaseMedia: USAGE_MEDIA-Stack aufgemischt")
|
||||||
|
promise.resolve(true)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "kickReleaseMedia failed: ${e.message}")
|
||||||
|
promise.resolve(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "aria-cockpit",
|
"name": "aria-cockpit",
|
||||||
"version": "0.0.9.6",
|
"version": "0.1.0.5",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"android": "react-native run-android",
|
"android": "react-native run-android",
|
||||||
|
|||||||
@@ -1038,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>
|
||||||
|
|||||||
@@ -50,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,
|
||||||
@@ -135,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>('');
|
||||||
@@ -224,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);
|
||||||
});
|
});
|
||||||
@@ -1251,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}>
|
||||||
|
|||||||
+117
-13
@@ -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);
|
||||||
}
|
}
|
||||||
@@ -363,6 +377,10 @@ class AudioService {
|
|||||||
console.log('[Audio] pauseForCall: %s', reason || '(no reason)');
|
console.log('[Audio] pauseForCall: %s', reason || '(no reason)');
|
||||||
this._conversationFocusActive = false;
|
this._conversationFocusActive = false;
|
||||||
this._pausedForCall = true;
|
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
|
// Foreground-Service stoppen — Notification waere sonst irrefuehrend
|
||||||
stopBackgroundAudio().catch(() => {});
|
stopBackgroundAudio().catch(() => {});
|
||||||
// SoundPool/RNSound (Resume-Sound, Play-Button) stoppen — nicht relevant fuer Auto-Resume
|
// SoundPool/RNSound (Resume-Sound, Play-Button) stoppen — nicht relevant fuer Auto-Resume
|
||||||
@@ -467,7 +485,7 @@ class AudioService {
|
|||||||
// Tracking auch fuer den Resume-Sound aktualisieren — sonst kann
|
// Tracking auch fuer den Resume-Sound aktualisieren — sonst kann
|
||||||
// captureInterruption bei einem zweiten Anruf die Position nicht
|
// captureInterruption bei einem zweiten Anruf die Position nicht
|
||||||
// mehr ermitteln (playbackStartTime waere von der ersten Wiedergabe).
|
// mehr ermitteln (playbackStartTime waere von der ersten Wiedergabe).
|
||||||
const msgIdMatch = path.match(/([0-9a-f-]+)\.wav$/i);
|
const msgIdMatch = path.match(/([^/\\]+)\.wav$/i);
|
||||||
if (msgIdMatch) this.currentPlaybackMsgId = msgIdMatch[1];
|
if (msgIdMatch) this.currentPlaybackMsgId = msgIdMatch[1];
|
||||||
// Virtuelle Start-Zeit so setzen, dass captureInterruption (das den
|
// Virtuelle Start-Zeit so setzen, dass captureInterruption (das den
|
||||||
// Leading-Silence-Offset wieder abzieht) die korrekte Position liefert.
|
// Leading-Silence-Offset wieder abzieht) die korrekte Position liefert.
|
||||||
@@ -778,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();
|
||||||
}
|
}
|
||||||
@@ -1019,10 +1042,12 @@ class AudioService {
|
|||||||
console.warn('[Audio] Cache-Datei existiert nicht mehr:', cleanPath);
|
console.warn('[Audio] Cache-Datei existiert nicht mehr:', cleanPath);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const msgIdMatch = cleanPath.match(/([0-9a-f-]+)\.wav$/i);
|
// Dateiname ohne .wav als messageId nehmen (egal ob UUID oder andere ID)
|
||||||
if (msgIdMatch) {
|
const fileMatch = cleanPath.match(/([^/\\]+)\.wav$/i);
|
||||||
this.currentPlaybackMsgId = msgIdMatch[1];
|
const msgId = fileMatch ? fileMatch[1] : '';
|
||||||
// Start-Zeit so setzen dass elapsed = LEADING (≈ 0 nach captureInterruption-Berechnung)
|
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;
|
this.playbackStartTime = Date.now() - this.LEADING_SILENCE_SEC * 1000;
|
||||||
}
|
}
|
||||||
const b64 = await RNFS.readFile(cleanPath, 'base64');
|
const b64 = await RNFS.readFile(cleanPath, 'base64');
|
||||||
@@ -1053,9 +1078,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); }
|
||||||
});
|
});
|
||||||
@@ -1108,11 +1139,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;
|
||||||
@@ -1149,6 +1182,8 @@ class AudioService {
|
|||||||
* Interruption zurueckgenommen. */
|
* Interruption zurueckgenommen. */
|
||||||
private _pausedForCall: boolean = false;
|
private _pausedForCall: boolean = false;
|
||||||
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) this.stopPlayback();
|
||||||
}
|
}
|
||||||
@@ -1156,16 +1191,33 @@ class AudioService {
|
|||||||
|
|
||||||
/** 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(() => {});
|
||||||
this.audioQueue = [];
|
this.audioQueue = [];
|
||||||
this.isPlaying = false;
|
this.isPlaying = false;
|
||||||
|
// Merken: war ein react-native-sound-Sound aktiv? Dann muessen wir nach
|
||||||
|
// release() den Focus-Stack aufmischen (RNSound-Bug: stop+release laesst
|
||||||
|
// den AudioFocusRequest haengen, Spotify resumed sonst nicht).
|
||||||
|
const hadRnSound = !!(this.currentSound || this.resumeSound || this.preloadedSound);
|
||||||
if (this.currentSound) {
|
if (this.currentSound) {
|
||||||
this.currentSound.stop();
|
this.currentSound.stop();
|
||||||
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;
|
||||||
@@ -1184,6 +1236,11 @@ class AudioService {
|
|||||||
// 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(() => {});
|
||||||
|
if (hadRnSound) {
|
||||||
|
// RNSound's haengender USAGE_MEDIA-Focus aufloesen — sonst bleibt
|
||||||
|
// Spotify pausiert obwohl unser Focus released ist.
|
||||||
|
AudioFocus?.kickReleaseMedia?.().catch(() => {});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Status & Callbacks ---
|
// --- Status & Callbacks ---
|
||||||
@@ -1223,19 +1280,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
|
||||||
}
|
}
|
||||||
@@ -1262,6 +1329,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(() => {});
|
||||||
|
}
|
||||||
@@ -202,14 +202,19 @@ class PhoneCallService {
|
|||||||
audioService.endCallPause();
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+54
-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():
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user