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:
2026-04-19 16:16:25 +02:00
parent 8b0a72dc9b
commit b203503fd8
5 changed files with 169 additions and 7 deletions
+62
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