feat: QR-Code Onboarding + TTS-Audio-Cache im Filesystem

QR-Code Onboarding
- Diagnostic: GET /api/onboarding gibt RVS-Credentials zurueck
- Einstellungen-UI: neue Sektion mit QR-Code (qrcode-generator via CDN)
- Format kompatibel mit bestehendem QRScanner.parseQRData (host/port/tls/token)
- App-SettingsScreen hatte QR-Scanner bereits — funktioniert out of the box
- Warnhinweis zu Token im Klartext

TTS-Audio-Cache
- Bridge: jede ARIA-Chat-Nachricht bekommt eine messageId (UUID)
  Audio-Payload wird mit messageId verknuepft (Piper-Pfade)
- ChatScreen: messageId + audioPath in ChatMessage Interface
- audioService.cacheAudio(): speichert Base64 in DocumentDirectory/tts_cache/<id>.wav
- audioService.playFromPath(): spielt aus Cache ohne Regenerierung
- Play-Button: wenn audioPath gesetzt → aus Cache, sonst tts_request
- cleanupOldTTSCache(): alte unreferenzierte WAVs (>30 Tage) weg
- Persistiert via AsyncStorage — ueberlebt App-Restart

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
duffyduck 2026-04-19 16:16:25 +02:00
parent 8b0a72dc9b
commit b203503fd8
5 changed files with 169 additions and 7 deletions

View File

@ -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}
</Text>
)}
{/* Play-Button fuer ARIA-Nachrichten */}
{/* Play-Button fuer ARIA-Nachrichten — Cache bevorzugt, sonst Regenerierung */}
{!isUser && item.text.length > 0 && (
<TouchableOpacity
style={styles.playButton}
onPress={() => {
// 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: '' });
}
}}
>
<Text style={styles.playButtonText}>{'\uD83D\uDD0A'}</Text>
<Text style={styles.playButtonText}>{item.audioPath ? '\uD83D\uDD0A' : '\uD83D\uDD0A'}</Text>
</TouchableOpacity>
)}
<Text style={styles.timestamp}>{time}</Text>

View File

@ -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<string> {
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<void> {
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<string>, maxAgeDays = 30): Promise<void> {
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

View File

@ -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),
})

View File

@ -523,6 +523,30 @@
</div>
</div>
<!-- App-Onboarding via QR-Code -->
<div class="settings-section">
<h2>App-Onboarding (QR-Code)</h2>
<div style="font-size:11px;color:#8888AA;margin-bottom:8px;">
RVS-Credentials als QR-Code — App scannt, keine manuelle Eingabe.
Enthaelt Host, Port, TLS-Flag und Token.
</div>
<div class="card" style="max-width:500px;">
<div style="display:flex;gap:12px;align-items:flex-start;">
<div id="onboarding-qr" style="width:220px;height:220px;background:#1E1E2E;border-radius:6px;display:flex;align-items:center;justify-content:center;color:#555570;font-size:11px;text-align:center;padding:8px;">
QR-Code wird geladen...
</div>
<div style="flex:1;font-size:11px;color:#8888AA;line-height:1.5;">
<div style="color:#FF9500;font-weight:bold;margin-bottom:4px;">Achtung</div>
Dieser QR enthaelt den RVS-Token im Klartext — zeige ihn niemandem,
speichere keine Screenshots davon in unsicheren Cloud-Diensten.
<button class="btn" onclick="loadOnboardingQR()" style="margin-top:10px;width:100%;">
QR neu generieren
</button>
</div>
</div>
</div>
</div>
<!-- Highlight-Trigger -->
<div class="settings-section">
<h2>Highlight-Trigger</h2>
@ -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 = '<div style="color:#FF6B6B;">RVS nicht konfiguriert (ENV Variablen fehlen)</div>';
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 = `<div style="color:#FF6B6B;">Fehler: ${e.message}</div>`;
}
}
// ── 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();
}
}

View File

@ -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;