Compare commits

..

No commits in common. "main" and "v0.0.6.8" have entirely different histories.

10 changed files with 88 additions and 455 deletions

View File

@ -79,8 +79,8 @@ android {
applicationId "com.ariacockpit"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 701
versionName "0.0.7.1"
versionCode 608
versionName "0.0.6.8"
// Fallback fuer Libraries mit Product Flavors
missingDimensionStrategy 'react-native-camera', 'general'
}

View File

@ -4,8 +4,6 @@
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<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" />
<application
android:name=".MainApplication"

View File

@ -22,7 +22,6 @@ class MainApplication : Application(), ReactApplication {
add(AudioFocusPackage())
add(PcmStreamPlayerPackage())
add(OpenWakeWordPackage())
add(PhoneCallPackage())
}
override fun getJSMainModuleName(): String = "index"

View File

@ -1,126 +0,0 @@
package com.ariacockpit
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import android.telephony.PhoneStateListener
import android.telephony.TelephonyCallback
import android.telephony.TelephonyManager
import android.util.Log
import androidx.core.content.ContextCompat
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactContextBaseJavaModule
import com.facebook.react.bridge.ReactMethod
import com.facebook.react.modules.core.DeviceEventManagerModule
/**
* Lauscht auf Anruf-Statusaenderungen wenn das Telefon klingelt oder ein
* Anruf laeuft, sendet das Modul ein "PhoneCallStateChanged"-Event an JS.
*
* JS-Side stoppt dann die TTS-Wiedergabe damit ARIA nicht mitten ins Gespraech
* weiterredet. Ohne READ_PHONE_STATE-Permission failt start() leise der Rest
* der App funktioniert wie bisher.
*
* State-Strings: "idle" | "ringing" | "offhook"
*/
class PhoneCallModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) {
override fun getName() = "PhoneCall"
companion object { private const val TAG = "PhoneCall" }
private var telephonyManager: TelephonyManager? = null
private var legacyListener: PhoneStateListener? = null
private var modernCallback: Any? = null // TelephonyCallback ab API 31
private var lastState: Int = TelephonyManager.CALL_STATE_IDLE
@ReactMethod
fun start(promise: Promise) {
try {
val perm = ContextCompat.checkSelfPermission(reactApplicationContext, Manifest.permission.READ_PHONE_STATE)
if (perm != PackageManager.PERMISSION_GRANTED) {
Log.w(TAG, "READ_PHONE_STATE Permission fehlt — Anruf-Erkennung inaktiv")
promise.resolve(false)
return
}
val tm = reactApplicationContext.getSystemService(Context.TELEPHONY_SERVICE) as? TelephonyManager
if (tm == null) {
Log.w(TAG, "TelephonyManager nicht verfuegbar")
promise.resolve(false)
return
}
telephonyManager = tm
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val cb = object : TelephonyCallback(), TelephonyCallback.CallStateListener {
override fun onCallStateChanged(state: Int) {
handleStateChange(state)
}
}
tm.registerTelephonyCallback(reactApplicationContext.mainExecutor, cb)
modernCallback = cb
} else {
@Suppress("DEPRECATION")
val l = object : PhoneStateListener() {
override fun onCallStateChanged(state: Int, phoneNumber: String?) {
handleStateChange(state)
}
}
@Suppress("DEPRECATION")
tm.listen(l, PhoneStateListener.LISTEN_CALL_STATE)
legacyListener = l
}
Log.i(TAG, "PhoneCall-Listener aktiv")
promise.resolve(true)
} catch (e: Exception) {
Log.e(TAG, "start fehlgeschlagen", e)
promise.reject("START_FAILED", e.message ?: "Unbekannter Fehler", e)
}
}
@ReactMethod
fun stop(promise: Promise) {
try {
val tm = telephonyManager
if (tm != null) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
(modernCallback as? TelephonyCallback)?.let { tm.unregisterTelephonyCallback(it) }
modernCallback = null
} else {
@Suppress("DEPRECATION")
legacyListener?.let { tm.listen(it, PhoneStateListener.LISTEN_NONE) }
legacyListener = null
}
}
telephonyManager = null
lastState = TelephonyManager.CALL_STATE_IDLE
promise.resolve(true)
} catch (e: Exception) {
promise.reject("STOP_FAILED", e.message ?: "")
}
}
private fun handleStateChange(state: Int) {
if (state == lastState) return
lastState = state
val name = when (state) {
TelephonyManager.CALL_STATE_RINGING -> "ringing"
TelephonyManager.CALL_STATE_OFFHOOK -> "offhook"
TelephonyManager.CALL_STATE_IDLE -> "idle"
else -> return
}
Log.i(TAG, "Telefon-State: $name")
val params = Arguments.createMap().apply { putString("state", name) }
try {
reactApplicationContext.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
.emit("PhoneCallStateChanged", params)
} catch (e: Exception) {
Log.w(TAG, "Event-emit fehlgeschlagen: ${e.message}")
}
}
@ReactMethod fun addListener(eventName: String) {}
@ReactMethod fun removeListeners(count: Int) {}
}

View File

@ -1,16 +0,0 @@
package com.ariacockpit
import com.facebook.react.ReactPackage
import com.facebook.react.bridge.NativeModule
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.uimanager.ViewManager
class PhoneCallPackage : ReactPackage {
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> {
return listOf(PhoneCallModule(reactContext))
}
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> {
return emptyList()
}
}

View File

@ -1,6 +1,6 @@
{
"name": "aria-cockpit",
"version": "0.0.7.1",
"version": "0.0.6.8",
"private": true,
"scripts": {
"android": "react-native run-android",

View File

@ -25,7 +25,6 @@ import RNFS from 'react-native-fs';
import rvs, { RVSMessage, ConnectionState } from '../services/rvs';
import audioService from '../services/audio';
import wakeWordService from '../services/wakeword';
import phoneCallService from '../services/phoneCall';
import updateService from '../services/updater';
import VoiceButton from '../components/VoiceButton';
import FileUpload, { FileData } from '../components/FileUpload';
@ -160,23 +159,10 @@ const ChatScreen: React.FC = () => {
const unsub = wakeWordService.onStateChange((s) => {
setWakeWordState(s);
setWakeWordActive(s !== 'off');
// Conversation-Focus an Wake-Word-State koppeln: solange wir aktiv im
// Dialog sind, soll Spotify dauerhaft gepaust bleiben (auch ueber
// Render-Pausen + zwischen Antworten hinweg). Sobald wir zurueck nach
// 'armed' oder 'off' fallen, darf Spotify wieder.
if (s === 'conversing') audioService.acquireConversationFocus();
else audioService.releaseConversationFocus();
});
return () => unsub();
}, []);
// Anruf-Erkennung: TTS pausieren wenn das Telefon klingelt
useEffect(() => {
phoneCallService.start().catch(err =>
console.warn('[Chat] phoneCall.start fehlgeschlagen', err));
return () => { phoneCallService.stop().catch(() => {}); };
}, []);
// ttsCanPlayRef live aktuell halten — Closure in onMessage unten liest
// darueber statt direkt ttsDeviceEnabled/ttsMuted (sonst stale).
useEffect(() => {
@ -295,22 +281,9 @@ const ChatScreen: React.FC = () => {
const idx = prev.findIndex(m =>
m.sender === 'user' && m.text.includes('Spracheingabe wird verarbeitet')
);
const newText = `\uD83C\uDFA4 ${sttText}`;
if (idx < 0) {
// Defensiv: wenn keine Placeholder im State (z.B. weil sie nie
// hinzugefuegt wurde oder schon durch ein anderes Update verloren
// ging), die Sprachnachricht trotzdem als neue Bubble einfuegen.
// Sonst kommt ARIAs Antwort ohne sichtbare User-Nachricht.
return capMessages([...prev, {
id: nextId(),
sender: 'user',
text: newText,
timestamp: message.timestamp,
attachments: [{ type: 'audio', name: 'Sprachaufnahme' }],
}]);
}
if (idx < 0) return prev;
const next = prev.slice();
next[idx] = { ...next[idx], text: newText };
next[idx] = { ...next[idx], text: `\uD83C\uDFA4 ${sttText}` };
return next;
});
}
@ -646,8 +619,6 @@ const ChatScreen: React.FC = () => {
base64: result.base64,
durationMs: result.durationMs,
mimeType: result.mimeType,
voice: localXttsVoiceRef.current,
speed: ttsSpeedRef.current,
...(location && { location }),
});
}, [getCurrentLocation]);

View File

@ -198,12 +198,6 @@ class AudioService {
private focusReleaseTimer: ReturnType<typeof setTimeout> | null = null;
private readonly FOCUS_RELEASE_DELAY_MS = 800;
// Conversation-Mode: solange aktiv (Wake-Word Status 'conversing' ODER
// wir wissen "ARIA spricht gerade in einem Multi-Turn-Dialog"), halten wir
// den AudioFocus DAUERHAFT. Der per-Stream-Release wird unterdrueckt,
// damit Spotify nicht in Render-Pausen oder zwischen Antworten zurueckkehrt.
private _conversationFocusActive: boolean = false;
// VAD State
private vadEnabled: boolean = false;
private lastSpeechTime: number = 0;
@ -220,18 +214,11 @@ class AudioService {
/** AudioFocus mit kleiner Verzoegerung freigeben Spotify/YouTube
* springen sonst im Gap zwischen zwei TTS-Streams (oder wenn ARIA
* eine zweite Antwort direkt hinterherschickt) kurz wieder an.
* Im Conversation-Mode (Wake-Word conversing) wird das Release komplett
* unterdrueckt der Focus bleibt fuer die ganze Konversation gehalten. */
* eine zweite Antwort direkt hinterherschickt) kurz wieder an. */
private _releaseFocusDeferred(): void {
if (this._conversationFocusActive) {
this._cancelDeferredFocusRelease();
return;
}
this._cancelDeferredFocusRelease();
this.focusReleaseTimer = setTimeout(() => {
this.focusReleaseTimer = null;
if (this._conversationFocusActive) return;
AudioFocus?.release().catch(() => {});
}, this.FOCUS_RELEASE_DELAY_MS);
}
@ -243,33 +230,6 @@ class AudioService {
}
}
/** Conversation-Mode beginnt AudioFocus dauerhaft halten (Spotify bleibt
* pausiert). Idempotent: mehrfaches Aufrufen ist sicher. */
acquireConversationFocus(): void {
if (this._conversationFocusActive) return;
this._conversationFocusActive = true;
this._cancelDeferredFocusRelease();
console.log('[Audio] Conversation-Focus aktiv (Spotify bleibt gepaust)');
AudioFocus?.requestDuck().catch(() => {});
}
/** Conversation-Mode endet Focus darf wieder freigegeben werden
* (verzoegert, damit eine direkt folgende Antwort nichts kaputtmacht). */
releaseConversationFocus(): void {
if (!this._conversationFocusActive) return;
this._conversationFocusActive = false;
console.log('[Audio] Conversation-Focus inaktiv');
this._releaseFocusDeferred();
}
/** TTS-Wiedergabe haart stoppen z.B. wenn ein Anruf reinkommt.
* Released auch sofort den AudioFocus damit der Anruf-Klingelton hoerbar ist. */
haltAllPlayback(reason: string = ''): void {
console.log('[Audio] haltAllPlayback: %s', reason || '(no reason)');
this._conversationFocusActive = false;
this.stopPlayback();
}
// --- Berechtigungen ---
async requestMicrophonePermission(): Promise<boolean> {

View File

@ -1,108 +0,0 @@
/**
* PhoneCall-Service pausiert die TTS-Wiedergabe wenn das Telefon klingelt
* oder ein Anruf laeuft. Native-Bindung an PhoneCallModule.kt.
*
* Bei "ringing" oder "offhook" wird audioService.haltAllPlayback() gerufen
* ARIA verstummt sofort. Nach dem Auflegen passiert nichts automatisch
* (Audio kommt nicht zurueck), der User muesste die Antwort manuell
* nochmal anfordern (Play-Button auf der Nachricht).
*
* Permission READ_PHONE_STATE muss vom Nutzer einmalig erteilt werden
* wenn nicht, failed start() leise und der Rest funktioniert wie bisher.
*/
import {
NativeEventEmitter,
NativeModules,
PermissionsAndroid,
Platform,
ToastAndroid,
} from 'react-native';
import audioService from './audio';
interface PhoneCallNative {
start(): Promise<boolean>;
stop(): Promise<boolean>;
}
const { PhoneCall } = NativeModules as { PhoneCall?: PhoneCallNative };
type PhoneState = 'idle' | 'ringing' | 'offhook';
class PhoneCallService {
private started: boolean = false;
private subscription: { remove: () => void } | null = null;
private lastState: PhoneState = 'idle';
async start(): Promise<boolean> {
if (this.started || !PhoneCall) return false;
if (Platform.OS !== 'android') return false;
// Runtime-Permission holen (nur einmal noetig)
try {
const granted = await PermissionsAndroid.request(
PermissionsAndroid.PERMISSIONS.READ_PHONE_STATE,
{
title: 'ARIA Cockpit — Anruf-Erkennung',
message: 'Damit ARIA bei einem eingehenden Anruf nicht weiterredet, '
+ 'darf die App den Anruf-Status sehen (Klingeln/Aktiv/Aufgelegt). '
+ 'Es werden keine Anrufdaten gelesen oder gespeichert.',
buttonPositive: 'Erlauben',
buttonNegative: 'Spaeter',
},
);
if (granted !== PermissionsAndroid.RESULTS.GRANTED) {
console.warn('[PhoneCall] READ_PHONE_STATE Permission abgelehnt');
return false;
}
} catch (err) {
console.warn('[PhoneCall] Permission-Anfrage gescheitert', err);
}
try {
const ok = await PhoneCall.start();
if (!ok) {
console.warn('[PhoneCall] Native start() lieferte false (Permission?)');
return false;
}
const emitter = new NativeEventEmitter(NativeModules.PhoneCall as any);
this.subscription = emitter.addListener('PhoneCallStateChanged', (e: { state: PhoneState }) => {
this._onStateChanged(e.state);
});
this.started = true;
console.log('[PhoneCall] Listener aktiv');
return true;
} catch (err: any) {
console.warn('[PhoneCall] start gescheitert:', err?.message || err);
return false;
}
}
async stop(): Promise<void> {
if (!this.started || !PhoneCall) return;
try {
this.subscription?.remove();
this.subscription = null;
await PhoneCall.stop();
} catch {}
this.started = false;
this.lastState = 'idle';
}
private _onStateChanged(state: PhoneState): void {
if (state === this.lastState) return;
console.log('[PhoneCall] State: %s → %s', this.lastState, state);
this.lastState = state;
if (state === 'ringing' || state === 'offhook') {
audioService.haltAllPlayback(`Telefon-State: ${state}`);
ToastAndroid.show(
state === 'ringing' ? 'Anruf — ARIA pausiert' : 'Im Gespraech — ARIA pausiert',
ToastAndroid.SHORT,
);
}
// idle: nichts automatisch — User soll nichts unbeabsichtigt re-triggern
}
}
const phoneCallService = new PhoneCallService();
export default phoneCallService;

View File

@ -551,15 +551,6 @@ class ARIABridge:
# Beeinflusst das Timeout fuer stt_request — bei "loading" warten wir laenger,
# weil das Modell beim ersten Request noch ~1-2 Min runtergeladen werden kann.
self._remote_stt_ready: bool = False
# Pending Files: wenn die App ein Bild + Text gleichzeitig schickt, kommen
# zwei separate RVS-Events ('file' und 'chat') — wir buffern die Files
# kurz und mergen sie mit dem nachfolgenden Chat-Text zu einer einzigen
# Anfrage an aria-core. Sonst antwortet ARIA zweimal (einmal "warte auf
# Anweisung" beim file, einmal auf den Chat-Text).
# Liste von Tuples: (file_path, name, file_type, size_kb, width, height)
self._pending_files: list[tuple[str, str, str, int, int, int]] = []
self._pending_files_flush_task: Optional[asyncio.Task] = None
self._PENDING_FILES_WINDOW_SEC: float = 0.8
def initialize(self) -> None:
"""Initialisiert alle Komponenten.
@ -916,13 +907,18 @@ class ARIABridge:
logger.info("[core] TTS unterdrueckt (Modus: %s)", self.current_mode.config.name)
return
# Voice bestimmen: App-Override (gesetzt durch letzten chat-Event) > globale
# Default-Voice. Der Override wird NICHT pro Antwort verbraucht — sonst nutzt
# eine Multi-Turn-Antwort von ARIA (Tool-Use + finale Antwort) ab dem zweiten
# TTS-Call wieder die alte Default-Stimme. Der Override bleibt gueltig bis
# zum naechsten chat-Event, wo er entweder ueberschrieben oder geloescht wird.
# Voice bestimmen: App-Override fuer diesen Request > globale Default-Voice
xtts_voice = self._next_voice_override or getattr(self, 'xtts_voice', '')
# Override verbrauchen (gilt nur fuer genau diese naechste Antwort)
if self._next_voice_override:
logger.info("[core] Nutze Voice-Override: %s", self._next_voice_override)
self._next_voice_override = None
# Speed ebenfalls aus App-Override nehmen (fallback 1.0)
xtts_speed = self._next_speed_override or 1.0
if self._next_speed_override:
logger.info("[core] Nutze Speed-Override: %.2fx", self._next_speed_override)
self._next_speed_override = None
tts_text = tts_text_preview or text
if not tts_text:
@ -1028,51 +1024,6 @@ class ARIABridge:
except Exception as e:
logger.debug("[session] Diagnostic nicht erreichbar (%s) — nutze '%s'", e, self._session_key)
def _build_pending_files_message(self, user_text: str) -> str:
"""Baut eine Anweisung an aria-core aus den gepufferten Files + optionalem
User-Text. user_text leer 'warte auf Anweisung'-Variante."""
parts: list[str] = []
for fp, name, ftype, kb, w, h in self._pending_files:
dim = f" {w}x{h}px" if (w and h) else ""
kind = "Bild" if ftype.startswith("image/") else "Datei"
parts.append(f"- {kind}: {name}{dim} ({ftype}, {kb}KB) liegt unter {fp}")
files_summary = "\n".join(parts)
n = len(self._pending_files)
anhang = "Anhang" if n == 1 else "Anhaenge"
if user_text:
return (f"Stefan hat dir {n} {anhang} geschickt:\n{files_summary}\n\n"
f"Er sagt dazu: \"{user_text}\"")
return (f"Stefan hat dir {n} {anhang} geschickt:\n{files_summary}\n\n"
f"Warte auf seine Anweisung was du damit tun sollst.")
async def _flush_pending_files_after(self, delay: float) -> None:
"""Wenn nach `delay`s kein chat-Text gekommen ist: Files alleine an
aria-core senden ('warte auf Anweisung'-Variante)."""
try:
await asyncio.sleep(delay)
except asyncio.CancelledError:
return
if not self._pending_files:
return
text = self._build_pending_files_message("")
self._pending_files = []
self._pending_files_flush_task = None
await self.send_to_core(text, source="app-file")
async def _flush_pending_files_with_text(self, user_text: str) -> bool:
"""Wenn ein chat-Text reinkommt waehrend Files gepuffert sind:
Files + Text zu einer einzigen aria-core-Nachricht mergen.
Returns True wenn gemerged wurde (Caller soll dann nicht nochmal senden)."""
if not self._pending_files:
return False
if self._pending_files_flush_task and not self._pending_files_flush_task.done():
self._pending_files_flush_task.cancel()
self._pending_files_flush_task = None
text = self._build_pending_files_message(user_text)
self._pending_files = []
await self.send_to_core(text, source="app-file+chat")
return True
async def send_to_core(self, text: str, source: str = "bridge") -> None:
"""Sendet Text an aria-core (OpenClaw chat.send Protokoll)."""
if self.ws_core is None:
@ -1218,32 +1169,21 @@ class ARIABridge:
if sender in ("aria", "stt"):
return
text = payload.get("text", "")
# Voice-Override fuer Folgenachrichten setzen — gilt bis zum naechsten
# chat-Event. Leerer String "" = explizit Default-Voice (override loeschen).
# Field nicht gesendet = vorherigen Override unveraendert lassen (z.B. wenn
# cancel_request oder anderer Service die App umgeht).
if "voice" in payload:
voice_override = payload.get("voice", "") or ""
self._next_voice_override = voice_override or None
logger.info("[rvs] Voice fuer Antworten: %s",
self._next_voice_override or "(Default)")
# Voice-Override fuer die naechste ARIA-Antwort merken
voice_override = payload.get("voice", "")
if voice_override:
self._next_voice_override = voice_override
logger.info("[rvs] Voice-Override fuer naechste Antwort: %s", voice_override)
# Speed-Override (TTS-Wiedergabegeschwindigkeit, pro Geraet)
if "speed" in payload:
try:
speed = float(payload.get("speed", 0) or 0)
self._next_speed_override = speed if 0.1 <= speed <= 5.0 else None
except (TypeError, ValueError):
self._next_speed_override = None
try:
speed = float(payload.get("speed", 0) or 0)
if 0.1 <= speed <= 5.0:
self._next_speed_override = speed
except (TypeError, ValueError):
pass
if text:
# Wenn Files gerade gepuffert sind (Bild + Text gleichzeitig
# gesendet), mergen wir sie zu einer einzigen Anfrage statt
# zwei separater send_to_core-Calls.
merged = await self._flush_pending_files_with_text(text)
if merged:
logger.info("[rvs] App-Chat (mit Anhaengen): '%s'", text[:80])
else:
logger.info("[rvs] App-Chat: '%s'", text[:80])
await self.send_to_core(text, source="app")
logger.info("[rvs] App-Chat: '%s'", text[:80])
await self.send_to_core(text, source="app")
return
if msg_type == "cancel_request":
@ -1402,54 +1342,70 @@ class ARIABridge:
await self.ws_core.send(raw_message)
elif msg_type == "file":
# Datei von der App: speichern + zu Pending-Queue hinzufuegen.
# Wird mit dem nachfolgenden chat-Event (innerhalb PENDING_FILES_WINDOW)
# zu einer einzigen aria-core-Anfrage gemerged. Sonst antwortet ARIA
# zweimal: einmal "warte auf Anweisung" beim file, einmal auf den Chat.
# Datei von der App → als Text-Nachricht an aria-core
file_name = payload.get("name", "unbekannt")
file_type = payload.get("type", "")
file_b64 = payload.get("base64", "")
file_size = payload.get("size", 0)
width = payload.get("width", 0)
height = payload.get("height", 0)
logger.info("[rvs] Datei empfangen: %s (%s, %dKB)",
file_name, file_type, len(file_b64) // 1365 if file_b64 else 0)
# Shared Volume: /shared/ ist in Bridge UND aria-core gemountet
SHARED_DIR = "/shared/uploads"
os.makedirs(SHARED_DIR, exist_ok=True)
if not file_b64:
text = f"Stefan hat eine Datei gesendet ({file_name}, {file_type}) aber die Daten sind leer angekommen."
await self.send_to_core(text, source="app-file")
return
if file_type.startswith("image/"):
if file_b64 and file_type.startswith("image/"):
# Bild in Shared Volume speichern
ext = ".jpg" if "jpeg" in file_type or "jpg" in file_type else ".png"
safe_name = f"img_{int(asyncio.get_event_loop().time())}_{file_name.replace('/', '_')}"
file_path = os.path.join(SHARED_DIR, safe_name if safe_name.endswith(ext) else safe_name + ext)
else:
with open(file_path, "wb") as f:
f.write(base64.b64decode(file_b64))
size_kb = len(file_b64) // 1365
logger.info("[rvs] Bild gespeichert: %s (%dKB)", file_path, size_kb)
# ERST an aria-core senden (wichtigster Schritt)
text = (f"Stefan hat dir ein Bild geschickt: {file_name}"
f"{f' ({width}x{height}px)' if width else ''}"
f", {size_kb}KB."
f" Das Bild liegt unter: {file_path}"
f" Warte auf Stefans Anweisung was du damit tun sollst.")
await self.send_to_core(text, source="app-file")
# Dann App informieren (optional, darf nicht crashen)
try:
await self._send_to_rvs({
"type": "file_saved",
"payload": {"name": file_name, "serverPath": file_path, "mimeType": file_type},
"timestamp": int(asyncio.get_event_loop().time() * 1000),
})
except Exception as e:
logger.warning("[rvs] file_saved konnte nicht an App gesendet werden: %s", e)
elif file_b64:
# Andere Datei in Shared Volume speichern
safe_name = f"file_{int(asyncio.get_event_loop().time())}_{file_name.replace('/', '_')}"
file_path = os.path.join(SHARED_DIR, safe_name)
with open(file_path, "wb") as f:
f.write(base64.b64decode(file_b64))
size_kb = len(file_b64) // 1365
logger.info("[rvs] Datei gespeichert: %s (%dKB)", file_path, size_kb)
# 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():
self._pending_files_flush_task.cancel()
self._pending_files_flush_task = asyncio.create_task(
self._flush_pending_files_after(self._PENDING_FILES_WINDOW_SEC)
)
try:
await self._send_to_rvs({
"type": "file_saved",
"payload": {"name": file_name, "serverPath": file_path, "mimeType": file_type},
"timestamp": int(asyncio.get_event_loop().time() * 1000),
})
except Exception as e:
logger.warning("[rvs] file_saved konnte nicht an App gesendet werden: %s", e)
with open(file_path, "wb") as f:
f.write(base64.b64decode(file_b64))
size_kb = len(file_b64) // 1365
logger.info("[rvs] Datei gespeichert: %s (%dKB)", file_path, size_kb)
# ERST an aria-core senden
text = (f"Stefan hat dir eine Datei geschickt: {file_name}"
f" ({file_type}, {size_kb}KB)."
f" Die Datei liegt unter: {file_path}"
f" Warte auf Stefans Anweisung was du damit tun sollst.")
await self.send_to_core(text, source="app-file")
try:
await self._send_to_rvs({
"type": "file_saved",
"payload": {"name": file_name, "serverPath": file_path, "mimeType": file_type},
"timestamp": int(asyncio.get_event_loop().time() * 1000),
})
except Exception as e:
logger.warning("[rvs] file_saved konnte nicht an App gesendet werden: %s", e)
else:
text = f"Stefan hat eine Datei gesendet ({file_name}, {file_type}) aber die Daten sind leer angekommen."
await self.send_to_core(text, source="app-file")
elif msg_type == "file_request":
# App fordert eine Datei an (Re-Download nach Cache-Leerung)
@ -1488,18 +1444,17 @@ class ARIABridge:
if not audio_b64:
logger.warning("[rvs] Audio ohne Daten empfangen")
return
# Voice-Override fuer Folgenachrichten — gleiche Semantik wie beim chat-Event.
if "voice" in payload:
voice_override = payload.get("voice", "") or ""
self._next_voice_override = voice_override or None
logger.info("[rvs] Voice fuer Antworten (via Audio): %s",
self._next_voice_override or "(Default)")
if "speed" in payload:
try:
speed = float(payload.get("speed", 0) or 0)
self._next_speed_override = speed if 0.1 <= speed <= 5.0 else None
except (TypeError, ValueError):
self._next_speed_override = None
# Voice-Override fuer die kommende ARIA-Antwort (App-lokal gewaehlt)
voice_override = payload.get("voice", "")
if voice_override:
self._next_voice_override = voice_override
logger.info("[rvs] Voice-Override (via Audio): %s", voice_override)
try:
speed = float(payload.get("speed", 0) or 0)
if 0.1 <= speed <= 5.0:
self._next_speed_override = speed
except (TypeError, ValueError):
pass
logger.info("[rvs] Audio empfangen: %s, %dms, %dKB",
mime_type, duration_ms, len(audio_b64) // 1365)
asyncio.create_task(self._process_app_audio(audio_b64, mime_type))