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:
2026-09-19 01:32:06 +02:00
co-authored by Claude Opus 4.8
parent a773832bca
commit 0f122a1ad7
2 changed files with 108 additions and 6 deletions
+20 -4
View File
@@ -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]})
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:
try:
voices = []
@@ -711,13 +723,16 @@ async def handle_delete_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."""
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:
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
try:
wav, txt = voice_paths(name)
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
import io, tarfile
buf = io.BytesIO()
@@ -727,10 +742,10 @@ async def handle_export_voice(ws, payload: dict) -> None:
tar.add(txt, arcname=txt.name)
data = base64.b64encode(buf.getvalue()).decode("ascii")
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:
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:
@@ -827,6 +842,7 @@ async def _worker_register(ws, *, model: str = "", busy_fn=None) -> None:
await _send(ws, "worker_hello", {
"instanceId": INSTANCE_ID, "service": WORKER_SERVICE,
"node": NODE_NAME, "gpus": GPU_IDS, "model": model,
"voices": _local_voice_names(), # fuer Voice-Provisioning-Reconciliation
})
while True:
await asyncio.sleep(WORKER_PING_INTERVAL_S)