feat: voice_preload/voice_ready — Feedback wenn neue Stimme geladen ist

XTTS-Bridge:
  - empfaengt neuen voice_preload Type, rendert stumm "ja." fuer die Stimme
    via TTS-Queue (damit kein Konflikt mit echtem TTS)
  - horcht zusaetzlich auf config-Broadcasts: wenn Diagnostic global die
    Stimme wechselt, wird auto-preloaded
  - broadcastet voice_ready mit Dauer (loadMs) oder error

RVS: voice_preload + voice_ready zur ALLOWED_TYPES-Liste.

App (SettingsScreen): beim Wechsel senden wir voice_preload, zeigen einen
Spinner in der Voice-Row und einen Toast mit "Stimme X bereit (Ns)".
App (ChatScreen): Toast auch hier — falls User gerade nicht in Settings ist.

Diagnostic (server+UI): voice_ready wird an Browser durchgereicht, ein
Status-Text unter dem Voice-Dropdown zeigt "wird geladen" → "bereit".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-23 10:24:08 +02:00
parent c8881f9e4d
commit 9cbea27455
6 changed files with 156 additions and 3 deletions
+73
View File
@@ -69,6 +69,18 @@ function connectRVS(forcePlain) {
await handleListVoices();
} else if (msg.type === "xtts_delete_voice") {
await handleDeleteVoice(msg.payload);
} else if (msg.type === "voice_preload") {
await handleVoicePreload(msg.payload);
} else if (msg.type === "config") {
// Diagnostic hat globale Voice gewechselt → Preload damit der naechste
// Render ohne Ladewartezeit startet + alle Clients "voice_ready" sehen
const v = msg.payload && msg.payload.xttsVoice;
if (v && v !== lastDiagnosticVoice) {
lastDiagnosticVoice = v;
await handleVoicePreload({ voice: v, source: "diagnostic" });
} else if (!v) {
lastDiagnosticVoice = "";
}
}
} catch (err) {
log(`Fehler: ${err.message}`);
@@ -120,6 +132,10 @@ function applyFadeIn(base64Pcm, sampleRate, channels, fadeMs) {
// interleaved PCM-Chunks aus zwei Rendern → klingt wie Chaos.
let ttsQueue = Promise.resolve();
// Merkt sich die letzte in Diagnostic gewaehlte Voice, damit wir nicht bei jedem
// config-Broadcast (auch ohne Aenderung) einen Preload triggern.
let lastDiagnosticVoice = "";
function handleTTSRequest(payload) {
ttsQueue = ttsQueue.then(() => _runTTSRequest(payload)).catch(err => {
log(`TTS-Queue Fehler: ${err.message}`);
@@ -470,6 +486,63 @@ async function handleDeleteVoice(payload) {
// ── Voice List Handler ──────────────────────────────
/**
* Preload einer Stimme — rendert stumm ein kurzes Dummy-Audio, damit XTTS
* die Speaker-Latents laedt und der naechste echte Request ohne Wartezeit
* loslegen kann. Broadcastet "voice_ready" wenn fertig (oder mit error).
*/
async function handleVoicePreload(payload) {
const voice = (payload && payload.voice) || "";
const source = (payload && payload.source) || "unknown";
const requestId = (payload && payload.requestId) || "";
log(`Voice-Preload angefordert: "${voice}" (source=${source})`);
try {
let speakerName = "";
if (voice) {
const voiceFilePath = path.join(VOICES_DIR, `${voice}.wav`);
if (!fs.existsSync(voiceFilePath)) {
sendToRVS({
type: "voice_ready",
payload: { voice, requestId, error: "voice-file-not-found" },
timestamp: Date.now(),
});
log(`Preload abgebrochen: ${voiceFilePath} existiert nicht`);
return;
}
speakerName = voice;
}
// Dummy-Request via Queue — damit sich Preload nicht mit echtem TTS ueberholt.
const t0 = Date.now();
await new Promise((resolve, reject) => {
ttsQueue = ttsQueue.then(async () => {
try {
await streamXTTSAsPCM("ja.", "de", speakerName, () => {});
resolve();
} catch (err) {
reject(err);
}
}).catch(reject);
});
const ms = Date.now() - t0;
log(`Voice "${voice || "default"}" geladen in ${ms}ms`);
sendToRVS({
type: "voice_ready",
payload: { voice, requestId, loadMs: ms },
timestamp: Date.now(),
});
} catch (err) {
log(`Voice-Preload Fehler: ${err.message}`);
sendToRVS({
type: "voice_ready",
payload: { voice, requestId, error: err.message.slice(0, 200) },
timestamp: Date.now(),
});
}
}
async function handleListVoices() {
try {
const files = fs.existsSync(VOICES_DIR)