diff --git a/android/src/screens/ChatScreen.tsx b/android/src/screens/ChatScreen.tsx index 1d1f61c..fc85c7d 100644 --- a/android/src/screens/ChatScreen.tsx +++ b/android/src/screens/ChatScreen.tsx @@ -48,6 +48,10 @@ interface ChatMessage { text: string; timestamp: number; attachments?: Attachment[]; + /** Bridge-Message-ID zur Zuordnung von TTS-Audio */ + messageId?: string; + /** Lokaler Pfad zur gecachten TTS-Audio-Datei (file://...) */ + audioPath?: string; } // --- Konstanten --- @@ -248,6 +252,7 @@ const ChatScreen: React.FC = () => { text, timestamp: ts, attachments: message.payload.attachments as Attachment[] | undefined, + messageId: (message.payload.messageId as string) || undefined, }; return capMessages([...prev, ariaMsg]); }); @@ -255,7 +260,18 @@ const ChatScreen: React.FC = () => { // TTS-Audio abspielen wenn vorhanden if (message.type === 'audio' && message.payload.base64) { - audioService.playAudio(message.payload.base64 as string); + const b64 = message.payload.base64 as string; + const refId = (message.payload.messageId as string) || ''; + audioService.playAudio(b64); + // Wenn messageId mitgeliefert wurde: Audio in Cache speichern + Pfad in Message eintragen + if (refId) { + audioService.cacheAudio(b64, refId).then(audioPath => { + if (!audioPath) return; + setMessages(prev => prev.map(m => + m.messageId === refId ? { ...m, audioPath } : m + )); + }).catch(() => {}); + } } // Thinking-Indicator Status von der Bridge @@ -620,16 +636,19 @@ const ChatScreen: React.FC = () => { {item.text} )} - {/* Play-Button fuer ARIA-Nachrichten */} + {/* Play-Button fuer ARIA-Nachrichten — Cache bevorzugt, sonst Regenerierung */} {!isUser && item.text.length > 0 && ( { - // TTS-Request an Bridge senden - rvs.send('tts_request' as any, { text: item.text, voice: '' }); + if (item.audioPath) { + audioService.playFromPath(item.audioPath); + } else { + rvs.send('tts_request' as any, { text: item.text, voice: '' }); + } }} > - {'\uD83D\uDD0A'} + {item.audioPath ? '\uD83D\uDD0A' : '\uD83D\uDD0A'} )} {time} diff --git a/android/src/services/audio.ts b/android/src/services/audio.ts index 3739715..32bea10 100644 --- a/android/src/services/audio.ts +++ b/android/src/services/audio.ts @@ -279,6 +279,46 @@ class AudioService { } } + /** Base64-Audio persistent speichern. Gibt file:// Pfad zurueck (oder leer bei Fehler). */ + async cacheAudio(base64Data: string, messageId: string): Promise { + if (!base64Data || !messageId) return ''; + try { + const dir = `${RNFS.DocumentDirectoryPath}/tts_cache`; + await RNFS.mkdir(dir).catch(() => {}); + const path = `${dir}/${messageId}.wav`; + // Wenn Datei schon existiert (z.B. XTTS Chunks) → anhaengen statt ueberschreiben + const exists = await RNFS.exists(path); + if (exists) { + // Bestehende + neue Base64 laden, zusammenkleben (fuer jetzt: ueberschreiben) + // XTTS sendet mehrere Chunks — bei mehrfacher Ueberschreibung bleibt nur der letzte + // Fuer eine echte Konkatenation muesste WAV-Header gemerged werden + await RNFS.writeFile(path, base64Data, 'base64'); + } else { + await RNFS.writeFile(path, base64Data, 'base64'); + } + return `file://${path}`; + } catch (err) { + console.warn('[Audio] cacheAudio fehlgeschlagen:', err); + return ''; + } + } + + /** Audio aus lokaler Datei (file:// Pfad) in die Queue und abspielen. */ + async playFromPath(filePath: string): Promise { + if (!filePath) return; + try { + const cleanPath = filePath.replace(/^file:\/\//, ''); + if (!(await RNFS.exists(cleanPath))) { + console.warn('[Audio] Cache-Datei existiert nicht mehr:', cleanPath); + return; + } + const b64 = await RNFS.readFile(cleanPath, 'base64'); + this.playAudio(b64); + } catch (err) { + console.warn('[Audio] playFromPath fehlgeschlagen:', err); + } + } + // Callback wenn alle Audio-Teile abgespielt sind private playbackFinishedListeners: (() => void)[] = []; @@ -437,6 +477,28 @@ class AudioService { // silent — cleanup ist best-effort } } + + /** Alte TTS-Cache-Dateien loeschen die nicht mehr referenziert sind (>30 Tage). */ + async cleanupOldTTSCache(keepMessageIds: Set, maxAgeDays = 30): Promise { + try { + const dir = `${RNFS.DocumentDirectoryPath}/tts_cache`; + if (!(await RNFS.exists(dir))) return; + const files = await RNFS.readDir(dir); + const maxAgeMs = maxAgeDays * 24 * 60 * 60 * 1000; + const now = Date.now(); + for (const f of files) { + if (!f.isFile() || !f.name.endsWith('.wav')) continue; + const messageId = f.name.replace(/\.wav$/, ''); + const age = now - (f.mtime ? f.mtime.getTime() : 0); + // Loeschen wenn: nicht mehr referenziert UND aelter als X Tage + if (!keepMessageIds.has(messageId) && age > maxAgeMs) { + await RNFS.unlink(f.path).catch(() => {}); + } + } + } catch { + // silent + } + } } // Singleton diff --git a/bridge/aria_bridge.py b/bridge/aria_bridge.py index 4355a5b..b30b368 100644 --- a/bridge/aria_bridge.py +++ b/bridge/aria_bridge.py @@ -976,6 +976,9 @@ class ARIABridge: # Stimme auswaehlen voice_name = requested_voice or self.voice_engine.select_voice(text) + # Eindeutige Message-ID fuer Audio-Cache-Zuordnung + message_id = str(uuid.uuid4()) + # Antwort an die App weiterleiten (als Chat-Nachricht) await self._send_to_rvs({ "type": "chat", @@ -983,6 +986,7 @@ class ARIABridge: "text": text, "sender": "aria", "voice": voice_name, + "messageId": message_id, }, "timestamp": int(asyncio.get_event_loop().time() * 1000), }) @@ -1018,7 +1022,7 @@ class ARIABridge: audio_b64 = base64.b64encode(audio_data).decode("ascii") await self._send_to_rvs({ "type": "audio", - "payload": {"base64": audio_b64, "mimeType": "audio/wav", "voice": voice_name}, + "payload": {"base64": audio_b64, "mimeType": "audio/wav", "voice": voice_name, "messageId": message_id}, "timestamp": int(asyncio.get_event_loop().time() * 1000), }) else: @@ -1032,6 +1036,7 @@ class ARIABridge: "base64": audio_b64, "mimeType": "audio/wav", "voice": voice_name, + "messageId": message_id, }, "timestamp": int(asyncio.get_event_loop().time() * 1000), }) diff --git a/diagnostic/index.html b/diagnostic/index.html index bcb869c..b01e22b 100644 --- a/diagnostic/index.html +++ b/diagnostic/index.html @@ -523,6 +523,30 @@ + +
+

App-Onboarding (QR-Code)

+
+ RVS-Credentials als QR-Code — App scannt, keine manuelle Eingabe. + Enthaelt Host, Port, TLS-Flag und Token. +
+
+
+
+ QR-Code wird geladen... +
+
+
Achtung
+ Dieser QR enthaelt den RVS-Token im Klartext — zeige ihn niemandem, + speichere keine Screenshots davon in unsicheren Cloud-Diensten. + +
+
+
+
+

Highlight-Trigger

@@ -1441,6 +1465,48 @@ send({ action: 'send_voice_config', defaultVoice, highlightVoice, ttsEnabled, speedRamona, speedThorsten, ttsEngine, xttsVoice, whisperModel }); } + // ── App-Onboarding QR-Code ──────────────────── + let qrLibReady = false; + function ensureQRLib() { + return new Promise((resolve) => { + if (qrLibReady || window.qrcode) { qrLibReady = true; resolve(); return; } + const s = document.createElement('script'); + s.src = 'https://cdn.jsdelivr.net/npm/qrcode-generator@1.4.4/qrcode.min.js'; + s.onload = () => { qrLibReady = true; resolve(); }; + s.onerror = () => resolve(); // silent fail + document.head.appendChild(s); + }); + } + + async function loadOnboardingQR() { + const box = document.getElementById('onboarding-qr'); + box.textContent = 'Lade...'; + try { + await ensureQRLib(); + if (!window.qrcode) throw new Error('QR-Library nicht geladen'); + const resp = await fetch('/api/onboarding'); + const cfg = await resp.json(); + if (!cfg.rvsHost || !cfg.rvsToken) { + box.innerHTML = '
RVS nicht konfiguriert (ENV Variablen fehlen)
'; + return; + } + // Format kompatibel mit android/src/components/QRScanner.tsx parseQRData() + const payload = JSON.stringify({ + host: cfg.rvsHost, + port: Number(cfg.rvsPort) || 443, + tls: cfg.rvsTLS !== false, + token: cfg.rvsToken, + }); + const qr = window.qrcode(0, 'M'); + qr.addData(payload); + qr.make(); + box.innerHTML = qr.createImgTag(6, 4); + box.querySelector('img').style.cssText = 'background:#fff;padding:8px;border-radius:4px;display:block;'; + } catch (e) { + box.innerHTML = `
Fehler: ${e.message}
`; + } + } + // ── Highlight-Trigger ──────────────────────── function loadHighlightTriggers() { send({ action: 'get_triggers' }); @@ -1921,10 +1987,11 @@ document.querySelectorAll('.main-nav-btn').forEach(b => { if (b.textContent.trim().toLowerCase().includes(tab === 'main' ? 'main' : 'einstellung')) b.classList.add('active'); }); - // Einstellungen: Config + Trigger laden + // Einstellungen: Config + Trigger + QR laden if (tab === 'settings') { loadHighlightTriggers(); send({ action: 'get_voice_config' }); + loadOnboardingQR(); } } diff --git a/diagnostic/server.js b/diagnostic/server.js index c9ab61b..57ae6c8 100644 --- a/diagnostic/server.js +++ b/diagnostic/server.js @@ -1169,6 +1169,15 @@ const server = http.createServer((req, res) => { } else if (req.url === "/api/session") { res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ sessionKey: activeSessionKey })); + } else if (req.url === "/api/onboarding") { + // RVS-Credentials fuer QR-Code App-Onboarding + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ + rvsHost: RVS_HOST, + rvsPort: RVS_PORT, + rvsTLS: RVS_TLS === "true" || RVS_TLS === true, + rvsToken: RVS_TOKEN, + })); } else if (req.url === "/api/cancel" && req.method === "POST") { log("warn", "server", "HTTP /api/cancel — Cancel-Request (von Bridge)"); pendingMessageTime = 0;