diff --git a/.gitignore b/.gitignore index c725f37..2dddaee 100644 --- a/.gitignore +++ b/.gitignore @@ -36,6 +36,7 @@ android/local.properties android/package-lock.json *.apk *.aab +rvs/updates/*.apk # ── Tauri / Desktop Build ─────────────────────── desktop/src-tauri/target/ diff --git a/android/src/screens/ChatScreen.tsx b/android/src/screens/ChatScreen.tsx index 56d13bf..63ce67b 100644 --- a/android/src/screens/ChatScreen.tsx +++ b/android/src/screens/ChatScreen.tsx @@ -23,6 +23,7 @@ import RNFS from 'react-native-fs'; import rvs, { RVSMessage, ConnectionState } from '../services/rvs'; import audioService from '../services/audio'; import wakeWordService from '../services/wakeword'; +import updateService from '../services/updater'; import VoiceButton from '../components/VoiceButton'; import FileUpload, { FileData } from '../components/FileUpload'; import CameraUpload, { PhotoData } from '../components/CameraUpload'; @@ -262,6 +263,16 @@ const ChatScreen: React.FC = () => { }; }, []); + // Auto-Update: Bei App-Start pruefen + useEffect(() => { + const unsubUpdate = updateService.onUpdateAvailable((info) => { + updateService.promptUpdate(info); + }); + // Nach 5s pruefen (RVS muss erst verbunden sein) + const timer = setTimeout(() => updateService.checkForUpdate(), 5000); + return () => { unsubUpdate(); clearTimeout(timer); }; + }, []); + // Wake Word: "ARIA" Erkennung → Auto-Aufnahme starten useEffect(() => { const unsubWake = wakeWordService.onWakeWord(async () => { diff --git a/android/src/services/rvs.ts b/android/src/services/rvs.ts index 518dc47..2e50650 100644 --- a/android/src/services/rvs.ts +++ b/android/src/services/rvs.ts @@ -12,7 +12,7 @@ import AsyncStorage from '@react-native-async-storage/async-storage'; export type ConnectionState = 'connecting' | 'connected' | 'disconnected'; -export type MessageType = 'chat' | 'audio' | 'file' | 'location' | 'mode' | 'log' | 'event'; +export type MessageType = 'chat' | 'audio' | 'file' | 'location' | 'mode' | 'log' | 'event' | 'update_available' | string; export interface RVSMessage { type: MessageType; diff --git a/android/src/services/updater.ts b/android/src/services/updater.ts new file mode 100644 index 0000000..90e79a3 --- /dev/null +++ b/android/src/services/updater.ts @@ -0,0 +1,149 @@ +/** + * Auto-Update Service — prueft und installiert App-Updates via RVS + * + * Flow: + * 1. App sendet "update_check" mit aktueller Version an RVS + * 2. RVS vergleicht → sendet "update_available" mit Download-URL + * 3. App zeigt Benachrichtigung → User bestaetigt → Download + Install + */ + +import { Alert, Linking, Platform } from 'react-native'; +import RNFS from 'react-native-fs'; +import rvs, { RVSMessage } from './rvs'; + +// Aktuelle App-Version (aus package.json via Build) +const APP_VERSION = '0.0.2.3'; // TODO: aus nativer Build-Config lesen + +type UpdateCallback = (info: UpdateInfo) => void; + +export interface UpdateInfo { + version: string; + downloadUrl: string; + size: number; +} + +class UpdateService { + private listeners: UpdateCallback[] = []; + private checking = false; + private downloading = false; + + constructor() { + // Auf update_available Nachrichten lauschen + rvs.onMessage((msg: RVSMessage) => { + if (msg.type === 'update_available' as any) { + const info: UpdateInfo = { + version: (msg.payload.version as string) || '', + downloadUrl: (msg.payload.downloadUrl as string) || '', + size: (msg.payload.size as number) || 0, + }; + if (info.version && this.isNewer(info.version)) { + console.log(`[Update] Neue Version verfuegbar: ${info.version} (aktuell: ${APP_VERSION})`); + this.listeners.forEach(cb => cb(info)); + } + } + }); + } + + /** Bei App-Start Update pruefen */ + checkForUpdate(): void { + if (this.checking) return; + this.checking = true; + + console.log(`[Update] Pruefe auf Updates (aktuell: ${APP_VERSION})`); + rvs.send('update_check' as any, { version: APP_VERSION }); + + setTimeout(() => { this.checking = false; }, 10000); + } + + /** Callback registrieren */ + onUpdateAvailable(callback: UpdateCallback): () => void { + this.listeners.push(callback); + return () => { + this.listeners = this.listeners.filter(cb => cb !== callback); + }; + } + + /** Update-Dialog anzeigen */ + promptUpdate(info: UpdateInfo): void { + const sizeMB = (info.size / 1024 / 1024).toFixed(1); + Alert.alert( + 'ARIA Update verfuegbar', + `Version ${info.version} (${sizeMB} MB)\n\nAktuell: ${APP_VERSION}\n\nJetzt herunterladen und installieren?`, + [ + { text: 'Spaeter', style: 'cancel' }, + { + text: 'Installieren', + onPress: () => this.downloadAndInstall(info), + }, + ], + ); + } + + /** APK ueber WebSocket herunterladen und installieren */ + async downloadAndInstall(info: UpdateInfo): Promise { + if (this.downloading) return; + this.downloading = true; + + try { + console.log(`[Update] Fordere APK v${info.version} an...`); + Alert.alert('Download gestartet', `Version ${info.version} wird ueber RVS heruntergeladen...`); + + // APK ueber WebSocket anfordern + rvs.send('update_download' as any, {}); + + // Auf update_data warten (einmalig) + const apkData = await new Promise<{base64: string, fileName: string}>((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error('Download-Timeout (60s)')), 60000); + const unsub = rvs.onMessage((msg: RVSMessage) => { + if ((msg.type as string) === 'update_data') { + clearTimeout(timeout); + unsub(); + if (msg.payload.error) { + reject(new Error(msg.payload.error as string)); + } else { + resolve({ + base64: msg.payload.base64 as string, + fileName: msg.payload.fileName as string || `ARIA-${info.version}.apk`, + }); + } + } + }); + }); + + // Base64 als APK-Datei speichern + const destPath = `${RNFS.CachesDirectoryPath}/${apkData.fileName}`; + await RNFS.writeFile(destPath, apkData.base64, 'base64'); + const fileSize = await RNFS.stat(destPath); + console.log(`[Update] APK gespeichert: ${destPath} (${(parseInt(fileSize.size) / 1024 / 1024).toFixed(1)}MB)`); + + // APK installieren (oeffnet Android-Installer) + if (Platform.OS === 'android') { + await Linking.openURL(`file://${destPath}`); + } + } catch (err: any) { + console.error(`[Update] Fehler: ${err.message}`); + Alert.alert('Update fehlgeschlagen', err.message); + } finally { + this.downloading = false; + } + } + + /** Versionsvergleich */ + private isNewer(remote: string): boolean { + const r = remote.split('.').map(Number); + const l = APP_VERSION.split('.').map(Number); + for (let i = 0; i < Math.max(r.length, l.length); i++) { + const diff = (r[i] || 0) - (l[i] || 0); + if (diff > 0) return true; + if (diff < 0) return false; + } + return false; + } + + getCurrentVersion(): string { + return APP_VERSION; + } +} + +const updateService = new UpdateService(); +export default updateService; diff --git a/bridge/aria_bridge.py b/bridge/aria_bridge.py index 933a72a..315e0f4 100644 --- a/bridge/aria_bridge.py +++ b/bridge/aria_bridge.py @@ -503,6 +503,8 @@ class ARIABridge: "thorsten": vc.get("speedThorsten", 1.0), } self.tts_enabled = vc.get("ttsEnabled", True) + self.tts_engine_type = vc.get("ttsEngine", "piper") + self.xtts_voice = vc.get("xttsVoice", "") logger.info("Voice-Config geladen: %s", vc) except Exception as e: logger.warning("Voice-Config laden fehlgeschlagen: %s", e) @@ -846,17 +848,47 @@ class ARIABridge: # TTS-Audio rendern und an die App senden (wenn Modus es erlaubt) if getattr(self, 'tts_enabled', True) and should_speak(self.current_mode, is_critical): - audio_data = self.voice_engine.synthesize(text, voice_name) - if audio_data: - 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, - }, - "timestamp": int(asyncio.get_event_loop().time() * 1000), + tts_engine = getattr(self, 'tts_engine_type', 'piper') + + if tts_engine == "xtts": + # XTTS: Request ueber RVS an Gaming-PC senden + xtts_voice = getattr(self, 'xtts_voice', '') + try: + await self._send_to_rvs({ + "type": "xtts_request", + "payload": { + "text": text, + "voice": xtts_voice, + "language": "de", + "requestId": str(uuid.uuid4()), + }, + "timestamp": int(asyncio.get_event_loop().time() * 1000), + }) + logger.info("[core] XTTS-Request gesendet (%s): '%s'", xtts_voice or "default", text[:60]) + except Exception as e: + logger.warning("[core] XTTS-Request fehlgeschlagen: %s — Fallback auf Piper", e) + # Fallback auf Piper + audio_data = self.voice_engine.synthesize(text, voice_name) + if audio_data: + 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}, + "timestamp": int(asyncio.get_event_loop().time() * 1000), + }) + else: + # Piper: Lokal rendern + audio_data = self.voice_engine.synthesize(text, voice_name) + if audio_data: + 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, + }, + "timestamp": int(asyncio.get_event_loop().time() * 1000), }) logger.info("[core] TTS-Audio gesendet: %d bytes (%s)", len(audio_data), voice_name) @@ -1014,6 +1046,26 @@ class ARIABridge: if sender in ("aria", "stt"): return + elif msg_type == "xtts_response": + # XTTS-Audio vom Gaming-PC empfangen → an App weiterleiten + audio_b64 = payload.get("base64", "") + error = payload.get("error", "") + if error: + logger.warning("[rvs] XTTS Fehler: %s", error) + return + if audio_b64: + logger.info("[rvs] XTTS-Audio empfangen: %dKB", len(audio_b64) // 1365) + await self._send_to_rvs({ + "type": "audio", + "payload": { + "base64": audio_b64, + "mimeType": payload.get("mimeType", "audio/wav"), + "voice": payload.get("voice", "xtts"), + }, + "timestamp": int(asyncio.get_event_loop().time() * 1000), + }) + return + elif msg_type == "tts_request": # App fordert TTS-Audio fuer einen Text an (Play-Button) text = payload.get("text", "") @@ -1057,6 +1109,14 @@ class ARIABridge: self.tts_enabled = bool(payload["ttsEnabled"]) logger.info("[rvs] TTS %s", "aktiviert" if self.tts_enabled else "deaktiviert") changed = True + if "ttsEngine" in payload: + self.tts_engine_type = payload["ttsEngine"] + logger.info("[rvs] TTS-Engine: %s", self.tts_engine_type) + changed = True + if "xttsVoice" in payload: + self.xtts_voice = payload["xttsVoice"] + logger.info("[rvs] XTTS-Stimme: %s", self.xtts_voice) + changed = True if "speedRamona" in payload: self.voice_engine.speech_speed["ramona"] = max(0.3, min(2.0, float(payload["speedRamona"]))) logger.info("[rvs] Speed Ramona: %.1f", self.voice_engine.speech_speed["ramona"]) @@ -1073,6 +1133,8 @@ class ARIABridge: "defaultVoice": self.voice_engine.default_voice, "highlightVoice": self.voice_engine.highlight_voice, "ttsEnabled": getattr(self, "tts_enabled", True), + "ttsEngine": getattr(self, "tts_engine_type", "piper"), + "xttsVoice": getattr(self, "xtts_voice", ""), "speedRamona": self.voice_engine.speech_speed.get("ramona", 1.0), "speedThorsten": self.voice_engine.speech_speed.get("thorsten", 1.0), } diff --git a/diagnostic/index.html b/diagnostic/index.html index cd8f1bb..a79a365 100644 --- a/diagnostic/index.html +++ b/diagnostic/index.html @@ -401,6 +401,17 @@

Sprachausgabe

+ +
+ + +
+ + +
+ + + +
+ + +
+
Stimme klonen
+
+ Lade ein oder mehrere Audio-Samples hoch (WAV/MP3, min. 6-10 Sekunden). + Mehrere Dateien werden automatisch zusammengefuegt. +
+
+ +
+
+ +
+
+ +
+
+
+ + +
+ XTTS-Server: Nicht verbunden (starte xtts/ auf dem Gaming-PC) +
+
@@ -665,6 +712,27 @@ return; } + if (msg.type === 'xtts_voices_list') { + const select = document.getElementById('diag-xtts-voice'); + // Behalte erste Option (Default) + while (select.options.length > 1) select.remove(1); + for (const v of (msg.payload?.voices || [])) { + const opt = document.createElement('option'); + opt.value = v.name; + opt.textContent = `${v.name} (${(v.size / 1024).toFixed(0)}KB)`; + select.appendChild(opt); + } + document.getElementById('xtts-status').textContent = `XTTS: ${msg.payload?.voices?.length || 0} Stimme(n) verfuegbar`; + document.getElementById('xtts-status').style.color = '#34C759'; + return; + } + if (msg.type === 'xtts_voice_saved') { + document.getElementById('xtts-clone-status').textContent = `Stimme "${msg.payload?.name}" gespeichert!`; + document.getElementById('xtts-clone-status').style.color = '#34C759'; + loadXTTSVoices(); // Liste neu laden + return; + } + if (msg.type === 'voice_config') { document.getElementById('diag-default-voice').value = msg.defaultVoice || 'ramona'; document.getElementById('diag-highlight-voice').value = msg.highlightVoice || 'thorsten'; @@ -675,6 +743,9 @@ document.getElementById('speed-ramona-label').textContent = sr + 'x'; document.getElementById('diag-speed-thorsten').value = st; document.getElementById('speed-thorsten-label').textContent = st + 'x'; + document.getElementById('diag-tts-engine').value = msg.ttsEngine || 'piper'; + document.getElementById('diag-xtts-voice').value = msg.xttsVoice || ''; + toggleXTTSPanel(); return; } @@ -1167,6 +1238,44 @@ }, 120000); } + // ── XTTS Panel ───────────────────────────── + function toggleXTTSPanel() { + const engine = document.getElementById('diag-tts-engine').value; + document.getElementById('piper-panel').style.display = engine === 'piper' ? 'block' : 'none'; + document.getElementById('xtts-panel').style.display = engine === 'xtts' ? 'block' : 'none'; + if (engine === 'xtts') loadXTTSVoices(); + } + + function loadXTTSVoices() { + sendToRVS_raw({ type: 'xtts_list_voices', payload: {}, timestamp: Date.now() }); + } + + async function uploadVoiceSamples() { + const name = document.getElementById('xtts-clone-name').value.trim(); + const files = document.getElementById('xtts-clone-files').files; + if (!name) { alert('Bitte einen Namen eingeben'); return; } + if (!files || files.length === 0) { alert('Bitte Audio-Dateien auswaehlen'); return; } + + document.getElementById('xtts-clone-status').textContent = `Lade ${files.length} Datei(en) hoch...`; + + const samples = []; + for (const file of files) { + const buffer = await file.arrayBuffer(); + const base64 = btoa(String.fromCharCode(...new Uint8Array(buffer))); + samples.push({ base64, name: file.name, size: file.size }); + } + + const totalSize = samples.reduce((s, f) => s + f.size, 0); + document.getElementById('xtts-clone-status').textContent = + `Sende ${samples.length} Sample(s) (${(totalSize / 1024).toFixed(0)}KB) an XTTS-Server...`; + + sendToRVS_raw({ + type: 'voice_upload', + payload: { name, samples }, + timestamp: Date.now(), + }); + } + // ── Abbrechen ────────────────────────────── function cancelRequest() { send({ action: 'cancel_request' }); @@ -1181,7 +1290,9 @@ const ttsEnabled = document.getElementById('diag-tts-enabled').checked; const speedRamona = parseFloat(document.getElementById('diag-speed-ramona').value); const speedThorsten = parseFloat(document.getElementById('diag-speed-thorsten').value); - send({ action: 'send_voice_config', defaultVoice, highlightVoice, ttsEnabled, speedRamona, speedThorsten }); + const ttsEngine = document.getElementById('diag-tts-engine').value; + const xttsVoice = document.getElementById('diag-xtts-voice').value; + send({ action: 'send_voice_config', defaultVoice, highlightVoice, ttsEnabled, speedRamona, speedThorsten, ttsEngine, xttsVoice }); } // ── Highlight-Trigger ──────────────────────── diff --git a/diagnostic/server.js b/diagnostic/server.js index 53ea47e..5835205 100644 --- a/diagnostic/server.js +++ b/diagnostic/server.js @@ -1173,6 +1173,8 @@ wss.on("connection", (ws) => { defaultVoice: msg.defaultVoice || "ramona", highlightVoice: msg.highlightVoice || "thorsten", ttsEnabled: msg.ttsEnabled !== false, + ttsEngine: msg.ttsEngine || "piper", + xttsVoice: msg.xttsVoice || "", speedRamona: msg.speedRamona || 1.0, speedThorsten: msg.speedThorsten || 1.0, }; diff --git a/release.sh b/release.sh index e915378..fec49c2 100755 --- a/release.sh +++ b/release.sh @@ -170,6 +170,22 @@ else exit 1 fi +# ── Auto-Update: APK auf RVS-Server kopieren ─ +RVS_UPDATE_HOST="${RVS_UPDATE_HOST:-}" +if [ -n "$RVS_UPDATE_HOST" ]; then + echo -e "${GREEN}[6/6] APK auf RVS-Server kopieren (Auto-Update)...${NC}" + scp "$APK_PATH" "${RVS_UPDATE_HOST}:~/ARIA-AGENT/rvs/updates/${APK_NAME}" 2>/dev/null + if [ $? -eq 0 ]; then + echo -e " ${GREEN}✓${NC} APK auf RVS-Server kopiert — Apps werden benachrichtigt" + else + echo -e " ${YELLOW}APK konnte nicht auf RVS kopiert werden (RVS_UPDATE_HOST=$RVS_UPDATE_HOST)${NC}" + echo -e " ${YELLOW}Manuell: scp $APK_PATH $RVS_UPDATE_HOST:~/ARIA-AGENT/rvs/updates/${APK_NAME}${NC}" + fi +else + echo -e "${YELLOW}Auto-Update uebersprungen (RVS_UPDATE_HOST nicht gesetzt)${NC}" + echo -e "${YELLOW}Setze RVS_UPDATE_HOST in .env fuer automatische Verteilung${NC}" +fi + # ── Fertig ──────────────────────────────────── echo "" echo -e "${GREEN}╔═══════════════════════════════════════════════════╗${NC}" @@ -177,4 +193,5 @@ echo -e "${GREEN}║ Release $TAG ist live!$(printf '%*s' $((27 - ${#TAG})) '' echo -e "${GREEN}╠═══════════════════════════════════════════════════╣${NC}" echo -e "${GREEN}║${NC} $GITEA_URL/$GITEA_REPO/releases/tag/$TAG" echo -e "${GREEN}║${NC} APK: $APK_NAME ($APK_SIZE)" +echo -e "${GREEN}║${NC} Auto-Update: ${RVS_UPDATE_HOST:-nicht konfiguriert}" echo -e "${GREEN}╚═══════════════════════════════════════════════════╝${NC}" diff --git a/rvs/docker-compose.yml b/rvs/docker-compose.yml index ebfc739..3001959 100644 --- a/rvs/docker-compose.yml +++ b/rvs/docker-compose.yml @@ -4,5 +4,7 @@ services: ports: - "${RVS_PORT:-443}:3000" restart: always + volumes: + - ./updates:/updates # APK-Dateien fuer Auto-Update environment: - MAX_SESSIONS=10 diff --git a/rvs/server.js b/rvs/server.js index c27c7ab..f80ef79 100644 --- a/rvs/server.js +++ b/rvs/server.js @@ -1,15 +1,21 @@ "use strict"; const { WebSocketServer } = require("ws"); +const fs = require("fs"); +const path = require("path"); // ── Konfiguration aus Umgebungsvariablen ──────────────────────────── const PORT = parseInt(process.env.PORT || "3000", 10); const MAX_SESSIONS = parseInt(process.env.MAX_SESSIONS || "10", 10); +const UPDATES_DIR = process.env.UPDATES_DIR || "/updates"; +// Kein Polling — APK wird manuell per git pull bereitgestellt // Erlaubte Nachrichtentypen — alles andere wird verworfen const ALLOWED_TYPES = new Set([ "chat", "audio", "file", "location", "mode", "log", "event", "heartbeat", "file_request", "file_response", "file_saved", "stt_result", "config", "tts_request", + "xtts_request", "xtts_response", "xtts_list_voices", "xtts_voices_list", "voice_upload", "xtts_voice_saved", + "update_check", "update_available", "update_download", "update_data", ]); // Token-Raum: token -> { clients: Set } @@ -46,6 +52,9 @@ const wss = new WebSocketServer({ port: PORT }); wss.on("listening", () => { log(`RVS läuft auf Port ${PORT} | Max Sessions: ${MAX_SESSIONS}`); + // Beim Start pruefen ob eine APK da ist + const apkInfo = getLatestAPK(); + if (apkInfo) log(`APK bereit: v${apkInfo.version} (${(fs.statSync(apkInfo.path).size / 1024 / 1024).toFixed(1)}MB)`); }); wss.on("connection", (ws, req) => { @@ -107,6 +116,52 @@ function registerClient(ws, token) { return; } + // Update-Check: direkt an den anfragenden Client antworten (nicht relay'en) + if (msg.type === "update_check") { + const clientVersion = msg.payload?.version || "0.0.0.0"; + const apkInfo = getLatestAPK(); + if (apkInfo && compareVersions(apkInfo.version, clientVersion) > 0) { + ws.send(JSON.stringify({ + type: "update_available", + payload: { + version: apkInfo.version, + downloadUrl: `/update/latest.apk`, + size: fs.statSync(apkInfo.path).size, + }, + timestamp: Date.now(), + })); + } + return; + } + + // Update-Download: APK als Base64 ueber WebSocket senden + if (msg.type === "update_download") { + const apkInfo = getLatestAPK(); + if (!apkInfo) { + ws.send(JSON.stringify({ type: "update_data", payload: { error: "Keine APK verfuegbar" }, timestamp: Date.now() })); + return; + } + try { + const data = fs.readFileSync(apkInfo.path); + const base64 = data.toString("base64"); + const sizeMB = (data.length / 1024 / 1024).toFixed(1); + log(`APK sende: v${apkInfo.version} (${sizeMB}MB) an Client`); + ws.send(JSON.stringify({ + type: "update_data", + payload: { + version: apkInfo.version, + base64, + size: data.length, + fileName: `ARIA-v${apkInfo.version}.apk`, + }, + timestamp: Date.now(), + })); + } catch (err) { + ws.send(JSON.stringify({ type: "update_data", payload: { error: err.message }, timestamp: Date.now() })); + } + return; + } + // An alle anderen Clients im Raum weiterleiten for (const client of room.clients) { if (client !== ws && client.readyState === 1) { @@ -167,6 +222,63 @@ wss.on("close", () => { clearInterval(cleanup); }); +// ── Auto-Update: APK-Erkennung + Push ────────────────────────────── + +let latestVersion = null; + +function getLatestAPK() { + try { + if (!fs.existsSync(UPDATES_DIR)) return null; + const files = fs.readdirSync(UPDATES_DIR) + .filter(f => f.endsWith(".apk")) + .map(f => { + // ARIA-v0.0.2.3.apk oder ARIA-Cockpit-release.apk + const match = f.match(/(\d+\.\d+\.\d+[\.\d]*)/); + return { file: f, path: path.join(UPDATES_DIR, f), version: match ? match[1] : null }; + }) + .filter(f => f.version) + .sort((a, b) => compareVersions(b.version, a.version)); // Neueste zuerst + + return files[0] || null; + } catch { + return null; + } +} + +function compareVersions(a, b) { + const pa = a.split(".").map(Number); + const pb = b.split(".").map(Number); + for (let i = 0; i < Math.max(pa.length, pb.length); i++) { + const diff = (pa[i] || 0) - (pb[i] || 0); + if (diff !== 0) return diff; + } + return 0; +} + +function notifyClientsAboutUpdate(apkInfo) { + const msg = JSON.stringify({ + type: "update_available", + payload: { + version: apkInfo.version, + downloadUrl: `/update/latest.apk`, + size: fs.statSync(apkInfo.path).size, + }, + timestamp: Date.now(), + }); + + // An alle Clients in allen Rooms senden + for (const [, room] of rooms) { + for (const client of room.clients) { + if (client.readyState === 1) { + client.send(msg); + } + } + } + log(`Update-Benachrichtigung gesendet: v${apkInfo.version} (${rooms.size} Raum/Raeume)`); +} + +// Kein Polling — Update-Check passiert on-demand (update_check Message von App) + // ── Sauberes Herunterfahren ───────────────────────────────────────── process.on("SIGTERM", () => { diff --git a/rvs/updates/.gitkeep b/rvs/updates/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/xtts/.env.example b/xtts/.env.example new file mode 100644 index 0000000..6c284a6 --- /dev/null +++ b/xtts/.env.example @@ -0,0 +1,11 @@ +# ════════════════════════════════════════════════ +# ARIA XTTS v2 — Konfiguration +# Kopieren nach .env und anpassen +# ════════════════════════════════════════════════ + +# RVS Verbindung (gleiche Daten wie auf der ARIA-VM) +RVS_HOST=mobil.hacker-net.de +RVS_PORT=444 +RVS_TLS=true +RVS_TLS_FALLBACK=true +RVS_TOKEN=dein_token_hier diff --git a/xtts/Dockerfile b/xtts/Dockerfile new file mode 100644 index 0000000..6c79bc5 --- /dev/null +++ b/xtts/Dockerfile @@ -0,0 +1,5 @@ +FROM node:22-alpine +WORKDIR /app +COPY bridge.js package.json ./ +RUN npm install --production +CMD ["node", "bridge.js"] diff --git a/xtts/bridge.js b/xtts/bridge.js new file mode 100644 index 0000000..8128192 --- /dev/null +++ b/xtts/bridge.js @@ -0,0 +1,268 @@ +/** + * ARIA XTTS Bridge — Verbindet XTTS v2 Server mit dem RVS + * + * Empfaengt tts_request ueber RVS → rendert Audio via XTTS API → sendet zurueck + * Empfaengt voice_upload → speichert Voice-Sample fuer Cloning + * Empfaengt xtts_list_voices → listet verfuegbare Stimmen + */ + +const WebSocket = require("ws"); +const http = require("http"); +const https = require("https"); +const fs = require("fs"); +const path = require("path"); + +const XTTS_API_URL = process.env.XTTS_API_URL || "http://xtts:8000"; +const RVS_HOST = process.env.RVS_HOST || ""; +const RVS_PORT = process.env.RVS_PORT || "443"; +const RVS_TLS = process.env.RVS_TLS || "true"; +const RVS_TLS_FALLBACK = process.env.RVS_TLS_FALLBACK || "true"; +const RVS_TOKEN = process.env.RVS_TOKEN || ""; +const VOICES_DIR = "/voices"; + +function log(msg) { + console.log(`[${new Date().toISOString()}] ${msg}`); +} + +// ── RVS Verbindung ────────────────────────────────── + +let rvsWs = null; +let retryDelay = 2; + +function connectRVS(forcePlain) { + if (!RVS_HOST || !RVS_TOKEN) { + log("RVS nicht konfiguriert — beende"); + process.exit(1); + } + + const useTls = RVS_TLS === "true" && !forcePlain; + const proto = useTls ? "wss" : "ws"; + const url = `${proto}://${RVS_HOST}:${RVS_PORT}?token=${RVS_TOKEN}`; + + log(`Verbinde zu RVS: ${proto}://${RVS_HOST}:${RVS_PORT}`); + + const ws = new WebSocket(url); + + ws.on("open", () => { + log("RVS verbunden — warte auf TTS-Requests"); + rvsWs = ws; + retryDelay = 2; + + // Keepalive + setInterval(() => { + if (ws.readyState === WebSocket.OPEN) { + ws.ping(); + ws.send(JSON.stringify({ type: "heartbeat", timestamp: Date.now() })); + } + }, 25000); + }); + + ws.on("message", async (raw) => { + try { + const msg = JSON.parse(raw.toString()); + + if (msg.type === "xtts_request") { + await handleTTSRequest(msg.payload); + } else if (msg.type === "voice_upload") { + await handleVoiceUpload(msg.payload); + } else if (msg.type === "xtts_list_voices") { + await handleListVoices(); + } + } catch (err) { + log(`Fehler: ${err.message}`); + } + }); + + ws.on("close", () => { + log("RVS Verbindung geschlossen"); + rvsWs = null; + setTimeout(() => connectRVS(), Math.min(retryDelay * 1000, 30000)); + retryDelay = Math.min(retryDelay * 2, 30); + }); + + ws.on("error", (err) => { + log(`RVS Fehler: ${err.message}`); + if (useTls && RVS_TLS_FALLBACK === "true") { + log("TLS fehlgeschlagen — Fallback auf ws://"); + ws.removeAllListeners(); + try { ws.close(); } catch (_) {} + connectRVS(true); + } + }); +} + +// ── TTS Request Handler ───────────────────────────── + +async function handleTTSRequest(payload) { + const { text, voice, requestId, language } = payload; + if (!text) return; + + log(`TTS-Request: "${text.slice(0, 60)}..." (voice: ${voice || "default"}, lang: ${language || "de"})`); + + try { + // Voice-Sample Pfad bestimmen + const voiceSample = voice ? path.join(VOICES_DIR, `${voice}.wav`) : null; + const hasCustomVoice = voiceSample && fs.existsSync(voiceSample); + + // XTTS API aufrufen + const audioBuffer = await callXTTSAPI(text, language || "de", hasCustomVoice ? voiceSample : null); + + if (audioBuffer && audioBuffer.length > 100) { + const base64 = audioBuffer.toString("base64"); + log(`TTS fertig: ${audioBuffer.length} bytes (${(audioBuffer.length / 1024).toFixed(0)}KB)`); + + sendToRVS({ + type: "xtts_response", + payload: { + requestId: requestId || "", + base64, + mimeType: "audio/wav", + voice: voice || "default", + engine: "xtts", + }, + timestamp: Date.now(), + }); + } else { + log("TTS: Leeres Audio erhalten"); + sendToRVS({ + type: "xtts_response", + payload: { requestId, error: "Leeres Audio" }, + timestamp: Date.now(), + }); + } + } catch (err) { + log(`TTS Fehler: ${err.message}`); + sendToRVS({ + type: "xtts_response", + payload: { requestId, error: err.message }, + timestamp: Date.now(), + }); + } +} + +function callXTTSAPI(text, language, speakerWav) { + return new Promise((resolve, reject) => { + const body = JSON.stringify({ + text, + language, + speaker_wav: speakerWav || "", + }); + + const url = new URL(`${XTTS_API_URL}/tts_to_audio/`); + const options = { + hostname: url.hostname, + port: url.port, + path: url.pathname, + method: "POST", + headers: { + "Content-Type": "application/json", + "Content-Length": Buffer.byteLength(body), + }, + timeout: 60000, + }; + + const req = http.request(options, (res) => { + const chunks = []; + res.on("data", (chunk) => chunks.push(chunk)); + res.on("end", () => { + if (res.statusCode === 200) { + resolve(Buffer.concat(chunks)); + } else { + reject(new Error(`XTTS API HTTP ${res.statusCode}: ${Buffer.concat(chunks).toString().slice(0, 200)}`)); + } + }); + }); + + req.on("error", reject); + req.on("timeout", () => { req.destroy(); reject(new Error("XTTS API Timeout (60s)")); }); + req.write(body); + req.end(); + }); +} + +// ── Voice Upload Handler ──────────────────────────── + +async function handleVoiceUpload(payload) { + const { name, samples } = payload; + if (!name || !samples || !Array.isArray(samples) || samples.length === 0) { + log("Voice Upload: Ungueltige Daten"); + return; + } + + log(`Voice Upload: "${name}" (${samples.length} Samples)`); + + try { + // Alle Samples zusammenfuegen + const buffers = samples.map(s => Buffer.from(s.base64, "base64")); + const combined = Buffer.concat(buffers); + + // Als WAV speichern + fs.mkdirSync(VOICES_DIR, { recursive: true }); + const filePath = path.join(VOICES_DIR, `${name.replace(/[^a-zA-Z0-9_-]/g, "_")}.wav`); + fs.writeFileSync(filePath, combined); + + log(`Voice gespeichert: ${filePath} (${(combined.length / 1024).toFixed(0)}KB)`); + + sendToRVS({ + type: "xtts_voice_saved", + payload: { name, size: combined.length, path: filePath }, + timestamp: Date.now(), + }); + } catch (err) { + log(`Voice Upload Fehler: ${err.message}`); + } +} + +// ── Voice List Handler ────────────────────────────── + +async function handleListVoices() { + try { + const files = fs.existsSync(VOICES_DIR) + ? fs.readdirSync(VOICES_DIR).filter(f => f.endsWith(".wav")) + : []; + + const voices = files.map(f => ({ + name: path.basename(f, ".wav"), + file: f, + size: fs.statSync(path.join(VOICES_DIR, f)).size, + })); + + log(`Stimmen: ${voices.length} verfuegbar`); + + sendToRVS({ + type: "xtts_voices_list", + payload: { voices }, + timestamp: Date.now(), + }); + } catch (err) { + log(`Stimmen-Liste Fehler: ${err.message}`); + } +} + +// ── RVS senden ────────────────────────────────────── + +function sendToRVS(msg) { + if (rvsWs && rvsWs.readyState === WebSocket.OPEN) { + rvsWs.send(JSON.stringify(msg)); + } +} + +// ── Start ─────────────────────────────────────────── + +log("ARIA XTTS Bridge startet..."); +log(`XTTS API: ${XTTS_API_URL}`); +log(`RVS: ${RVS_HOST}:${RVS_PORT}`); + +// Warten bis XTTS API erreichbar ist +function waitForXTTS(callback, attempts) { + if (attempts <= 0) { log("XTTS API nicht erreichbar — starte trotzdem"); callback(); return; } + http.get(`${XTTS_API_URL}/docs`, (res) => { + log("XTTS API erreichbar"); + callback(); + }).on("error", () => { + log(`XTTS API noch nicht bereit — warte (${attempts} Versuche uebrig)...`); + setTimeout(() => waitForXTTS(callback, attempts - 1), 5000); + }); +} + +waitForXTTS(() => connectRVS(), 24); // Max 2min warten diff --git a/xtts/docker-compose.yml b/xtts/docker-compose.yml new file mode 100644 index 0000000..ad1aaae --- /dev/null +++ b/xtts/docker-compose.yml @@ -0,0 +1,54 @@ +# ════════════════════════════════════════════════ +# ARIA XTTS v2 — GPU TTS Server +# Laeuft auf dem Gaming-PC (RTX 3060) +# Verbindet sich zum RVS fuer TTS-Requests +# ════════════════════════════════════════════════ +# +# Voraussetzungen: +# - Docker Desktop mit WSL2 +# - NVIDIA Container Toolkit +# - .env mit RVS-Verbindungsdaten +# +# Start: docker compose up -d +# Test: curl http://localhost:8000/docs +# ════════════════════════════════════════════════ + +services: + + # ─── XTTS v2 API Server (GPU) ───────────────── + xtts: + image: ghcr.io/daswer123/xtts-api-server:latest + container_name: aria-xtts + deploy: + resources: + reservations: + devices: + - driver: nvidia + count: 1 + capabilities: [gpu] + ports: + - "8000:8000" + volumes: + - xtts-models:/root/.local/share/tts # Model-Cache (~2GB) + - ./voices:/voices # Custom Voice Samples + environment: + - COQUI_TOS_AGREED=1 + restart: unless-stopped + + # ─── XTTS Bridge (verbindet zu RVS) ─────────── + xtts-bridge: + build: . + container_name: aria-xtts-bridge + depends_on: + - xtts + environment: + - XTTS_API_URL=http://xtts:8000 + - RVS_HOST=${RVS_HOST} + - RVS_PORT=${RVS_PORT:-443} + - RVS_TLS=${RVS_TLS:-true} + - RVS_TLS_FALLBACK=${RVS_TLS_FALLBACK:-true} + - RVS_TOKEN=${RVS_TOKEN} + restart: unless-stopped + +volumes: + xtts-models: diff --git a/xtts/package.json b/xtts/package.json new file mode 100644 index 0000000..d4b8188 --- /dev/null +++ b/xtts/package.json @@ -0,0 +1,8 @@ +{ + "name": "aria-xtts-bridge", + "version": "1.0.0", + "private": true, + "dependencies": { + "ws": "^8.16.0" + } +}