feat(voice): zentraler Stimmen-Store + Auto-Provisioning der f5tts-Flotte
Stimmen lagen bisher nur pro Box (xtts/voices/). Jetzt haelt der Diagnostic-
Server sie zentral als /shared/voices/{name}.tar.gz und versorgt jede f5tts-Box
automatisch.
- diagnostic/server.js (Bibliothekar):
* zentraler Store + Helfer (list/read/write/delete tar.gz)
* reconcileVoices() beim worker_hello einer f5tts-Box: push fehlende zentrale
Stimmen (xtts_import_voice, targetInstance) + pull box-eigene, zentral
fehlende (xtts_export_voice) → seedet den Store aus der ersten Box
* autonome RVS-Listener: xtts_voice_saved → zentraler Ingest (auch App-Uploads);
xtts_voice_exported (nur eigene requestId) → in Store schreiben;
xtts_delete_voice → zentrale Kopie mitloeschen
* Delete-Action loescht zentrale Kopie direkt mit
- xtts/f5tts/bridge.py: worker_hello traegt jetzt voices:[names] (ohne
default_ref) fuer den Abgleich; handle_export_voice echot requestId zurueck
(Korrelation zentral vs. browser-initiiert)
Wiederverwendet den vorhandenen export/import-tar.gz-Transport + Stage-3-
targetInstance-Filter. Playback (preview_voice) und Lastrouting (freieste Box)
existierten bereits. Keine App-/aria-bridge-Aenderung.
Deploy: diagnostic + f5tts-Boxen neu bauen.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+88
-2
@@ -508,6 +508,64 @@ function broadcastWorkers() {
|
|||||||
broadcast({ type: "worker_update", workers: workerList() });
|
broadcast({ type: "worker_update", workers: workerList() });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Voice-Flotte: zentraler Stimmen-Store + Auto-Provisioning ──────
|
||||||
|
// Der Diagnostic-Server ist der "Stimmen-Bibliothekar": Stimmen liegen
|
||||||
|
// zentral als /shared/voices/{name}.tar.gz (das f5tts-Export-Artefakt =
|
||||||
|
// wav+txt). Meldet sich eine f5tts-Box (worker_hello), gleichen wir ab und
|
||||||
|
// schieben ihr fehlende Stimmen (xtts_import_voice, targetInstance) bzw. ziehen
|
||||||
|
// bei ihr vorhandene, zentral fehlende Stimmen (xtts_export_voice) in den Store.
|
||||||
|
const CENTRAL_VOICES_DIR = "/shared/voices";
|
||||||
|
const centralExportPending = new Map(); // requestId -> name (unsere eigenen Export-Anfragen)
|
||||||
|
|
||||||
|
function ensureCentralVoicesDir() {
|
||||||
|
try { fs.mkdirSync(CENTRAL_VOICES_DIR, { recursive: true }); } catch (_) {}
|
||||||
|
}
|
||||||
|
function centralVoiceNames() {
|
||||||
|
ensureCentralVoicesDir();
|
||||||
|
try {
|
||||||
|
return fs.readdirSync(CENTRAL_VOICES_DIR)
|
||||||
|
.filter(f => f.endsWith(".tar.gz"))
|
||||||
|
.map(f => f.slice(0, -7));
|
||||||
|
} catch (_) { return []; }
|
||||||
|
}
|
||||||
|
function readCentralVoiceB64(name) {
|
||||||
|
try { return fs.readFileSync(`${CENTRAL_VOICES_DIR}/${name}.tar.gz`).toString("base64"); }
|
||||||
|
catch (_) { return null; }
|
||||||
|
}
|
||||||
|
function writeCentralVoice(name, dataB64) {
|
||||||
|
ensureCentralVoicesDir();
|
||||||
|
try { fs.writeFileSync(`${CENTRAL_VOICES_DIR}/${name}.tar.gz`, Buffer.from(dataB64, "base64")); return true; }
|
||||||
|
catch (e) { log("warn", "voice", `zentral schreiben ${name} fehlgeschlagen: ${e.message}`); return false; }
|
||||||
|
}
|
||||||
|
function deleteCentralVoice(name) {
|
||||||
|
try { fs.unlinkSync(`${CENTRAL_VOICES_DIR}/${name}.tar.gz`); log("info", "voice", `zentral geloescht: ${name}`); }
|
||||||
|
catch (_) {}
|
||||||
|
}
|
||||||
|
function requestCentralExport(name) {
|
||||||
|
// Broadcast-Export-Anfrage; die Box, die die Stimme hat, antwortet. Wir
|
||||||
|
// korrelieren die Antwort ueber requestId (nur unsere eigenen verarbeiten).
|
||||||
|
const requestId = "central_" + Date.now() + "_" + Math.random().toString(36).slice(2, 8);
|
||||||
|
centralExportPending.set(requestId, name);
|
||||||
|
setTimeout(() => centralExportPending.delete(requestId), 30000);
|
||||||
|
sendToRVS_raw({ type: "xtts_export_voice", payload: { name, requestId }, timestamp: Date.now() });
|
||||||
|
}
|
||||||
|
function provisionVoiceToInstance(name, instanceId) {
|
||||||
|
const data = readCentralVoiceB64(name);
|
||||||
|
if (!data) return;
|
||||||
|
sendToRVS_raw({ type: "xtts_import_voice",
|
||||||
|
payload: { name, data, targetInstance: instanceId }, timestamp: Date.now() });
|
||||||
|
log("info", "voice", `provisioniere '${name}' → ${instanceId}`);
|
||||||
|
}
|
||||||
|
// Abgleich beim worker_hello einer f5tts-Box: push (zentral→Box) + pull (Box→zentral, seed).
|
||||||
|
function reconcileVoices(instanceId, boxVoices) {
|
||||||
|
const central = centralVoiceNames();
|
||||||
|
const boxSet = new Set(Array.isArray(boxVoices) ? boxVoices : []);
|
||||||
|
const centralSet = new Set(central);
|
||||||
|
for (const name of central) if (!boxSet.has(name)) provisionVoiceToInstance(name, instanceId);
|
||||||
|
for (const name of boxSet) if (!centralSet.has(name)) requestCentralExport(name);
|
||||||
|
log("info", "voice", `reconcile ${instanceId}: box=${boxSet.size} central=${central.length}`);
|
||||||
|
}
|
||||||
|
|
||||||
// ── OpenClaw Gateway Verbindung ─────────────────────────
|
// ── OpenClaw Gateway Verbindung ─────────────────────────
|
||||||
|
|
||||||
async function connectGateway() {
|
async function connectGateway() {
|
||||||
@@ -964,6 +1022,8 @@ function connectRVS(forcePlain) {
|
|||||||
busy: !!prev.busy, last_seen: Date.now(),
|
busy: !!prev.busy, last_seen: Date.now(),
|
||||||
});
|
});
|
||||||
broadcastWorkers();
|
broadcastWorkers();
|
||||||
|
// Voice-Flotte: f5tts-Box gerade online → Stimmen abgleichen/provisionieren.
|
||||||
|
if ((p.service || "") === "f5tts") reconcileVoices(p.instanceId, p.voices);
|
||||||
}
|
}
|
||||||
} else if (msg.type === "worker_ping") {
|
} else if (msg.type === "worker_ping") {
|
||||||
// Heartbeat eines Workers (traegt busy-Status).
|
// Heartbeat eines Workers (traegt busy-Status).
|
||||||
@@ -979,6 +1039,30 @@ function connectRVS(forcePlain) {
|
|||||||
w.last_seen = Date.now();
|
w.last_seen = Date.now();
|
||||||
broadcastWorkers();
|
broadcastWorkers();
|
||||||
}
|
}
|
||||||
|
} else if (msg.type === "xtts_voice_saved") {
|
||||||
|
// Neue Stimme (App- ODER Diagnostic-Upload, via RVS-Broadcast) → zentral
|
||||||
|
// sichern. Online-Boxen haben sie durch den voice_upload-Broadcast schon;
|
||||||
|
// der zentrale Store macht sie persistent + fuer spaeter joinende Boxen
|
||||||
|
// verfuegbar (die holt dann reconcileVoices ab).
|
||||||
|
const p = msg.payload || {};
|
||||||
|
if (p.name && !p.error) {
|
||||||
|
log("info", "voice", `Stimme '${p.name}' gespeichert → zentraler Ingest`);
|
||||||
|
requestCentralExport(p.name);
|
||||||
|
}
|
||||||
|
} else if (msg.type === "xtts_voice_exported") {
|
||||||
|
// Antwort auf eine UNSERER zentralen Export-Anfragen (requestId-Match) →
|
||||||
|
// in den zentralen Store schreiben. Browser-initiierte Exports tragen
|
||||||
|
// keinen centralExportPending-requestId und werden hier ignoriert.
|
||||||
|
const p = msg.payload || {};
|
||||||
|
const rid = p.requestId || "";
|
||||||
|
if (rid && centralExportPending.has(rid)) {
|
||||||
|
centralExportPending.delete(rid);
|
||||||
|
if (p.ok && p.name && p.data) writeCentralVoice(p.name, p.data);
|
||||||
|
}
|
||||||
|
} else if (msg.type === "xtts_delete_voice") {
|
||||||
|
// App-initiierter Delete (Broadcast) → zentrale Kopie mitloeschen.
|
||||||
|
const p = msg.payload || {};
|
||||||
|
if (p.name) deleteCentralVoice(p.name);
|
||||||
} else if (msg.type === "agent_activity") {
|
} else if (msg.type === "agent_activity") {
|
||||||
// Bridge meldet "ARIA denkt/schreibt/tool" oder "idle" — an Browser
|
// Bridge meldet "ARIA denkt/schreibt/tool" oder "idle" — an Browser
|
||||||
// weiterreichen, damit der Thinking-Indikator im Chat erscheint.
|
// weiterreichen, damit der Thinking-Indikator im Chat erscheint.
|
||||||
@@ -2660,9 +2744,11 @@ wss.on("connection", (ws) => {
|
|||||||
// tar.gz (base64) an XTTS-Bridge schicken — die packt aus
|
// tar.gz (base64) an XTTS-Bridge schicken — die packt aus
|
||||||
sendToRVS_withResponse("xtts_import_voice", { name: msg.name, data: msg.data }, "xtts_voice_imported", ws);
|
sendToRVS_withResponse("xtts_import_voice", { name: msg.name, data: msg.data }, "xtts_voice_imported", ws);
|
||||||
} else if (msg.action === "xtts_delete_voice") {
|
} else if (msg.action === "xtts_delete_voice") {
|
||||||
// Weiterleiten an XTTS-Bridge, die antwortet mit neuer Liste
|
// Weiterleiten an alle f5tts-Boxen (Broadcast) + zentrale Kopie loeschen.
|
||||||
|
// (Der eigene Broadcast kommt nicht zu uns zurueck, daher hier direkt.)
|
||||||
sendToRVS_raw({ type: "xtts_delete_voice", payload: { name: msg.name }, timestamp: Date.now() });
|
sendToRVS_raw({ type: "xtts_delete_voice", payload: { name: msg.name }, timestamp: Date.now() });
|
||||||
log("info", "server", `Voice-Delete '${msg.name}' an XTTS-Bridge gesendet`);
|
if (msg.name) deleteCentralVoice(msg.name);
|
||||||
|
log("info", "server", `Voice-Delete '${msg.name}' an f5tts-Boxen + zentral geloescht`);
|
||||||
} else if (msg.action === "delete_chat_message") {
|
} else if (msg.action === "delete_chat_message") {
|
||||||
// Bubble loeschen — Bridge raeumt chat_backup.jsonl + Brain-conversation
|
// Bubble loeschen — Bridge raeumt chat_backup.jsonl + Brain-conversation
|
||||||
// + broadcastet chat_message_deleted via RVS.
|
// + broadcastet chat_message_deleted via RVS.
|
||||||
|
|||||||
+20
-4
@@ -675,6 +675,18 @@ async def handle_voice_upload(ws, payload: dict) -> None:
|
|||||||
await _send(ws, "xtts_voice_saved", {"name": name, "error": str(e)[:200]})
|
await _send(ws, "xtts_voice_saved", {"name": name, "error": str(e)[:200]})
|
||||||
|
|
||||||
|
|
||||||
|
def _local_voice_names() -> list:
|
||||||
|
"""Namen der lokal vorhandenen Nutzer-Stimmen (ohne den Box-lokalen
|
||||||
|
default_ref-Fallback) — fuer die Provisioning-Reconciliation im worker_hello."""
|
||||||
|
names = []
|
||||||
|
if VOICES_DIR.exists():
|
||||||
|
for wav in sorted(VOICES_DIR.glob("*.wav")):
|
||||||
|
if wav.stem == "default_ref":
|
||||||
|
continue
|
||||||
|
names.append(wav.stem)
|
||||||
|
return names
|
||||||
|
|
||||||
|
|
||||||
async def handle_list_voices(ws) -> None:
|
async def handle_list_voices(ws) -> None:
|
||||||
try:
|
try:
|
||||||
voices = []
|
voices = []
|
||||||
@@ -711,13 +723,16 @@ async def handle_delete_voice(ws, payload: dict) -> None:
|
|||||||
async def handle_export_voice(ws, payload: dict) -> None:
|
async def handle_export_voice(ws, payload: dict) -> None:
|
||||||
"""Packt eine Stimme (.wav + .txt) als tar.gz und sendet sie base64 zurueck."""
|
"""Packt eine Stimme (.wav + .txt) als tar.gz und sendet sie base64 zurueck."""
|
||||||
name = (payload.get("name") or "").strip()
|
name = (payload.get("name") or "").strip()
|
||||||
|
# requestId aus dem Request zuruueckspiegeln — der Diagnostic-Bibliothekar
|
||||||
|
# korreliert damit zentrale Exports gegen browser-initiierte.
|
||||||
|
req_id = payload.get("requestId", "") or ""
|
||||||
if not name:
|
if not name:
|
||||||
await _send(ws, "xtts_voice_exported", {"ok": False, "error": "name fehlt"})
|
await _send(ws, "xtts_voice_exported", {"ok": False, "requestId": req_id, "error": "name fehlt"})
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
wav, txt = voice_paths(name)
|
wav, txt = voice_paths(name)
|
||||||
if not wav.exists():
|
if not wav.exists():
|
||||||
await _send(ws, "xtts_voice_exported", {"ok": False, "name": name, "error": "Stimme nicht gefunden"})
|
await _send(ws, "xtts_voice_exported", {"ok": False, "requestId": req_id, "name": name, "error": "Stimme nicht gefunden"})
|
||||||
return
|
return
|
||||||
import io, tarfile
|
import io, tarfile
|
||||||
buf = io.BytesIO()
|
buf = io.BytesIO()
|
||||||
@@ -727,10 +742,10 @@ async def handle_export_voice(ws, payload: dict) -> None:
|
|||||||
tar.add(txt, arcname=txt.name)
|
tar.add(txt, arcname=txt.name)
|
||||||
data = base64.b64encode(buf.getvalue()).decode("ascii")
|
data = base64.b64encode(buf.getvalue()).decode("ascii")
|
||||||
logger.info("Voice exportiert: %s (%d KB tar.gz)", name, len(buf.getvalue()) // 1024)
|
logger.info("Voice exportiert: %s (%d KB tar.gz)", name, len(buf.getvalue()) // 1024)
|
||||||
await _send(ws, "xtts_voice_exported", {"ok": True, "name": name, "data": data})
|
await _send(ws, "xtts_voice_exported", {"ok": True, "requestId": req_id, "name": name, "data": data})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("handle_export_voice Fehler")
|
logger.exception("handle_export_voice Fehler")
|
||||||
await _send(ws, "xtts_voice_exported", {"ok": False, "name": name, "error": str(e)[:200]})
|
await _send(ws, "xtts_voice_exported", {"ok": False, "requestId": req_id, "name": name, "error": str(e)[:200]})
|
||||||
|
|
||||||
|
|
||||||
async def handle_import_voice(ws, payload: dict) -> None:
|
async def handle_import_voice(ws, payload: dict) -> None:
|
||||||
@@ -827,6 +842,7 @@ async def _worker_register(ws, *, model: str = "", busy_fn=None) -> None:
|
|||||||
await _send(ws, "worker_hello", {
|
await _send(ws, "worker_hello", {
|
||||||
"instanceId": INSTANCE_ID, "service": WORKER_SERVICE,
|
"instanceId": INSTANCE_ID, "service": WORKER_SERVICE,
|
||||||
"node": NODE_NAME, "gpus": GPU_IDS, "model": model,
|
"node": NODE_NAME, "gpus": GPU_IDS, "model": model,
|
||||||
|
"voices": _local_voice_names(), # fuer Voice-Provisioning-Reconciliation
|
||||||
})
|
})
|
||||||
while True:
|
while True:
|
||||||
await asyncio.sleep(WORKER_PING_INTERVAL_S)
|
await asyncio.sleep(WORKER_PING_INTERVAL_S)
|
||||||
|
|||||||
Reference in New Issue
Block a user