Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -79,8 +79,8 @@ android {
|
||||
applicationId "com.ariacockpit"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 902
|
||||
versionName "0.0.9.2"
|
||||
versionCode 10000
|
||||
versionName "0.1.0.0"
|
||||
// Fallback fuer Libraries mit Product Flavors
|
||||
missingDimensionStrategy 'react-native-camera', 'general'
|
||||
}
|
||||
|
||||
@@ -6,6 +6,9 @@
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
<!-- Anruf-State lesen damit TTS bei klingelndem Telefon pausiert -->
|
||||
<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_MICROPHONE ist Pflicht ab Android 14 wenn der
|
||||
Service waehrend des Backgrounds aufs Mikro zugreift (Wake-Word,
|
||||
|
||||
@@ -4,6 +4,7 @@ import android.media.AudioAttributes
|
||||
import android.media.AudioFormat
|
||||
import android.media.AudioManager
|
||||
import android.media.AudioTrack
|
||||
import android.os.Build
|
||||
import android.util.Base64
|
||||
import android.util.Log
|
||||
import com.facebook.react.bridge.Arguments
|
||||
@@ -107,7 +108,20 @@ class PcmStreamPlayerModule(reactContext: ReactApplicationContext) : ReactContex
|
||||
.setTransferMode(AudioTrack.MODE_STREAM)
|
||||
.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
|
||||
queue.clear()
|
||||
writerShouldStop = false
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aria-cockpit",
|
||||
"version": "0.0.9.2",
|
||||
"version": "0.1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"android": "react-native run-android",
|
||||
|
||||
@@ -725,17 +725,23 @@ const ChatScreen: React.FC = () => {
|
||||
|
||||
// GPS-Position holen (optional)
|
||||
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) => {
|
||||
Geolocation.getCurrentPosition(
|
||||
(position) => {
|
||||
resolve({
|
||||
const loc = {
|
||||
lat: position.coords.latitude,
|
||||
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);
|
||||
},
|
||||
{ enableHighAccuracy: false, timeout: 5000 },
|
||||
@@ -884,6 +890,7 @@ const ChatScreen: React.FC = () => {
|
||||
// Alle Pending Anhaenge + Text senden
|
||||
const sendPendingAttachments = useCallback(async (messageText: string) => {
|
||||
if (pendingAttachments.length === 0) return;
|
||||
console.log('[Chat] sendPendingAttachments: %d Anhang/Anhaenge', pendingAttachments.length);
|
||||
const location = await getCurrentLocation();
|
||||
const msgId = nextId();
|
||||
|
||||
@@ -933,6 +940,8 @@ const ChatScreen: React.FC = () => {
|
||||
}
|
||||
|
||||
// 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', {
|
||||
name,
|
||||
type: mimeType,
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
ToastAndroid,
|
||||
ActivityIndicator,
|
||||
Modal,
|
||||
PermissionsAndroid,
|
||||
} from 'react-native';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import RNFS from 'react-native-fs';
|
||||
@@ -457,7 +458,29 @@ const SettingsScreen: React.FC = () => {
|
||||
|
||||
// --- 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);
|
||||
AsyncStorage.setItem('aria_gps_enabled', String(value)).catch(() => {});
|
||||
}, []);
|
||||
|
||||
@@ -347,19 +347,65 @@ class AudioService {
|
||||
this._releaseFocusDeferred();
|
||||
}
|
||||
|
||||
/** TTS-Wiedergabe haart stoppen — z.B. wenn ein Anruf reinkommt.
|
||||
* Released auch sofort den AudioFocus damit der Anruf-Klingelton hoerbar ist. */
|
||||
/** TTS-Wiedergabe haart stoppen — z.B. fuer Barge-In. Buffer wird geleert,
|
||||
* kein Auto-Resume. Released auch sofort den AudioFocus. */
|
||||
haltAllPlayback(reason: string = ''): void {
|
||||
console.log('[Audio] haltAllPlayback: %s', reason || '(no reason)');
|
||||
this._conversationFocusActive = false;
|
||||
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;
|
||||
// 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
|
||||
* 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 {
|
||||
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) {
|
||||
console.log('[Audio] captureInterruption: nichts spielte (startTime=%s, msgId=%s)',
|
||||
this.playbackStartTime, this.currentPlaybackMsgId || '(leer)');
|
||||
this.pausedPosition = 0;
|
||||
this.pausedMessageId = '';
|
||||
return 0;
|
||||
@@ -379,7 +425,12 @@ class AudioService {
|
||||
async resumeFromInterruption(maxWaitMs: number = 30000): Promise<boolean> {
|
||||
const msgId = this.pausedMessageId;
|
||||
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
|
||||
const cachePath = `${RNFS.DocumentDirectoryPath}/tts_cache/${msgId}.wav`;
|
||||
const startTime = Date.now();
|
||||
@@ -413,6 +464,14 @@ class AudioService {
|
||||
this._firePlaybackStarted();
|
||||
this.isPlaying = true;
|
||||
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',
|
||||
positionSec.toFixed(2), path);
|
||||
sound.setCurrentTime(Math.max(0, positionSec));
|
||||
@@ -788,7 +847,9 @@ class AudioService {
|
||||
}): Promise<string> {
|
||||
// Globaler Mute-Flag uebersteuert das per-Call silent — verhindert
|
||||
// Race-Conditions wenn der User zwischen Chunks den Mute-Knopf drueckt.
|
||||
const silent = !!payload.silent || this._muted;
|
||||
// _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;
|
||||
if (!silent && !PcmStreamPlayer) {
|
||||
console.warn('[Audio] PcmStreamPlayer Native Module nicht verfuegbar');
|
||||
return '';
|
||||
@@ -946,7 +1007,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> {
|
||||
if (!filePath) return;
|
||||
try {
|
||||
@@ -955,6 +1019,14 @@ class AudioService {
|
||||
console.warn('[Audio] Cache-Datei existiert nicht mehr:', cleanPath);
|
||||
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');
|
||||
this.playAudio(b64);
|
||||
} catch (err) {
|
||||
@@ -983,9 +1055,15 @@ class AudioService {
|
||||
}
|
||||
|
||||
private _firePlaybackStarted(): void {
|
||||
// Tracking fuer Auto-Resume nach Anruf-Pause
|
||||
this.playbackStartTime = Date.now();
|
||||
this.currentPlaybackMsgId = this.pcmMessageId || '';
|
||||
// Tracking fuer Auto-Resume nach Anruf-Pause: NUR setzen wenn ein
|
||||
// PCM-Stream laeuft (Live-TTS). Bei Play-Button / Resume-Sound hat der
|
||||
// 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 => {
|
||||
try { cb(); } catch (e) { console.warn('[Audio] playbackStarted listener err:', e); }
|
||||
});
|
||||
@@ -1074,7 +1152,13 @@ class AudioService {
|
||||
* — die Bridge kann einen Chunk im selben JS-Tick liefern in dem der
|
||||
* User Mute geklickt hat. */
|
||||
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;
|
||||
setMuted(muted: boolean): void {
|
||||
console.log('[Audio] setMuted: %s (currentSound=%s pcmStreamActive=%s)',
|
||||
muted, this.currentSound ? 'aktiv' : 'null', this.pcmStreamActive);
|
||||
this._muted = muted;
|
||||
if (muted) this.stopPlayback();
|
||||
}
|
||||
@@ -1082,6 +1166,8 @@ class AudioService {
|
||||
|
||||
/** Laufende Wiedergabe stoppen + Queue leeren */
|
||||
stopPlayback(): void {
|
||||
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
|
||||
// wenn Wiedergabe abgebrochen wird (Anruf, Cancel, Barge-In).
|
||||
stopBackgroundAudio().catch(() => {});
|
||||
@@ -1092,6 +1178,11 @@ class AudioService {
|
||||
this.currentSound.release();
|
||||
this.currentSound = null;
|
||||
}
|
||||
if (this.resumeSound) {
|
||||
this.resumeSound.stop();
|
||||
this.resumeSound.release();
|
||||
this.resumeSound = null;
|
||||
}
|
||||
if (this.preloadedSound) {
|
||||
this.preloadedSound.release();
|
||||
this.preloadedSound = null;
|
||||
|
||||
@@ -189,22 +189,32 @@ class PhoneCallService {
|
||||
private _haltForCall(toast: string): void {
|
||||
// Position merken bevor wir den Stream killen — fuer Auto-Resume.
|
||||
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(() => {});
|
||||
ToastAndroid.show(toast, ToastAndroid.SHORT);
|
||||
}
|
||||
|
||||
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(() => {});
|
||||
ToastAndroid.show(toast, ToastAndroid.SHORT);
|
||||
// Auto-Resume: ab gemerkter Position weiterspielen wenn ARIA vor dem
|
||||
// Anruf gerade redete. Wartet bis zu 30s auf den WAV-Cache (falls
|
||||
// final-Marker erst nach dem Anruf-Ende kam).
|
||||
audioService.resumeFromInterruption(30000).then(ok => {
|
||||
if (ok) {
|
||||
console.log('[PhoneCall] Auto-Resume von gemerkter Position gestartet');
|
||||
}
|
||||
}).catch(() => {});
|
||||
// 800ms warten bevor Auto-Resume — sonst kollidiert ARIA's neuer Focus-
|
||||
// Request mit Spotify's Auto-Resume nach Anruf-Ende. System haengt nach
|
||||
// dem Auflegen noch im IN_CALL-Mode-Uebergang, Spotify schaut auf Focus-
|
||||
// Gain und wuerde sofort wieder LOSS sehen → bleibt pausiert.
|
||||
// Mit Delay: Spotify resumed kurz, dann pausiert ARIA wieder ordnungs-
|
||||
// gemaess. Wenn ARIA nichts pending hat, bleibt Spotify einfach an.
|
||||
setTimeout(() => {
|
||||
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:
|
||||
try:
|
||||
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
|
||||
if not await self._openclaw_handshake(ws):
|
||||
logger.error("[core] Handshake fehlgeschlagen — Reconnect")
|
||||
@@ -783,13 +786,29 @@ class ARIABridge:
|
||||
await self._emit_activity("idle", "")
|
||||
if not text:
|
||||
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
|
||||
logger.info("[core] Antwort: '%s'", text[:80])
|
||||
await self._process_core_response(text, payload)
|
||||
return
|
||||
|
||||
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)
|
||||
self._last_chat_final_at = asyncio.get_event_loop().time()
|
||||
await self._emit_activity("idle", "")
|
||||
@@ -825,7 +844,12 @@ class ARIABridge:
|
||||
return
|
||||
|
||||
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)
|
||||
await self._send_to_rvs({
|
||||
"type": "chat",
|
||||
@@ -1141,7 +1165,8 @@ class ARIABridge:
|
||||
try:
|
||||
url = f"{current_url}?token={self.rvs_token}"
|
||||
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
|
||||
retry_delay = 2
|
||||
logger.info("[rvs] Verbunden — warte auf App-Nachrichten")
|
||||
@@ -1461,6 +1486,31 @@ class ARIABridge:
|
||||
size_kb = len(file_b64) // 1365
|
||||
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)
|
||||
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():
|
||||
@@ -1634,6 +1684,11 @@ class ARIABridge:
|
||||
}
|
||||
if 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({
|
||||
"type": "chat",
|
||||
"payload": stt_payload,
|
||||
|
||||
@@ -16,3 +16,6 @@ sounddevice
|
||||
|
||||
# Wake-Word Erkennung
|
||||
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] 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] **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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user