feat: Bug-Runde + 5 App/Diagnostic-Features

Bugs:
- App Mute-/Auto-Playback: onMessage-Closure hielt stale ttsDeviceEnabled/
  ttsMuted → Mute wurde ignoriert + AsyncStorage-Load kam nicht durch.
  Fix via ttsCanPlayRef (live gespiegelt) statt Closure-Variablen.
- App Zombie-Recording: toggleWakeWord hat die laufende Aufnahme nicht
  gestoppt → audioService.recordingState blieb 'recording' → normaler
  Aufnahme-Button wirkungslos. Fix: await stopRecording() vor stop().
- Porcupine robuster: BuiltInKeywords-Enum Mapping mit String-Fallback,
  errorCallback fuer Runtime-Crashes (state zurueck auf off statt
  App-Crash), mehr Logging damit man beim naechsten Issue debuggen kann.

App-Features:
- MessageText Komponente: Text ist durchgehend selektierbar, erkennt
  URLs (http/https), E-Mails, Telefonnummern und macht sie anklickbar
  (oeffnet Browser / Mail-App / Android-Dialer via Linking).
- TTS-Wiedergabegeschwindigkeit pro Geraet einstellbar (Settings ->
  "Sprechgeschwindigkeit", 0.5-2.0 in 0.1-Schritten, Default 1.0).
  Wird als speed-Param an die F5-TTS-Bridge durchgereicht.

Bridge-Durchreichen:
- ChatScreen: speed aus AsyncStorage via ttsSpeedRef, an chat/audio/
  tts_request mitgeschickt
- aria-bridge: _next_speed_override wie voice_override, an xtts_request
  weitergereicht
- f5tts-bridge: speed-Param an F5TTS.infer() durchgereicht

Diagnostic-Feature:
- Voice-Preview-Button (Play-Icon) vor dem Delete-X in der Stimmen-Liste
- Modal mit Textfeld (Default-Beispieltext wird bei jedem Oeffnen neu
  gesetzt) und Play-Button
- Server sammelt audio_pcm Frames der Preview-Anfrage, baut WAV,
  schickt base64 zurueck, Browser spielt im <audio>-Tag ab
- 60s Timeout-Safety-Net

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-25 00:24:02 +02:00
parent 2264f4e3bc
commit 190352820c
10 changed files with 439 additions and 20 deletions
+94
View File
@@ -653,6 +653,9 @@ function connectRVS(forcePlain) {
log("info", "rvs", `service_status ${svc} ${state}${model ? ` (${model})` : ""}`);
}
broadcast({ type: "service_status", payload: msg.payload });
} else if (msg.type === "audio_pcm" && msg.payload && _previewPending.size > 0) {
// PCM-Chunks einer laufenden Voice-Preview — sammeln + WAV bauen
_handlePreviewChunk(msg.payload);
} else {
log("debug", "rvs", `Nachricht: ${JSON.stringify(msg).slice(0, 150)}`);
}
@@ -1465,6 +1468,8 @@ wss.on("connection", (ws) => {
handleSaveTriggers(ws, msg.triggers || []);
} else if (msg.action === "test_tts") {
handleTestTTS(ws, msg.text || "Test");
} else if (msg.action === "preview_voice") {
handleVoicePreview(ws, msg.voice || "", msg.text || "Hallo.");
} else if (msg.action === "check_tts") {
handleCheckTTS(ws);
} else if (msg.action === "check_desktop") {
@@ -1637,6 +1642,95 @@ async function handleSaveTriggers(clientWs, triggers) {
}
// ── TTS Diagnose (XTTS) ───────────────────────────────
// ── Voice Preview ────────────────────────────────────────
// Sammelt audio_pcm Chunks einer Preview-Anfrage, baut am Ende eine WAV
// und schickt sie base64-kodiert an den Browser-Client.
//
// Map requestId → { clientWs, chunks: [Buffer], sampleRate, channels }
const _previewPending = new Map();
function _buildWavFromPcm(pcmBuf, sampleRate, channels) {
const bitsPerSample = 16;
const byteRate = sampleRate * channels * bitsPerSample / 8;
const blockAlign = channels * bitsPerSample / 8;
const dataSize = pcmBuf.length;
const header = Buffer.alloc(44);
header.write("RIFF", 0);
header.writeUInt32LE(36 + dataSize, 4);
header.write("WAVE", 8);
header.write("fmt ", 12);
header.writeUInt32LE(16, 16); // subchunk1 size
header.writeUInt16LE(1, 20); // PCM
header.writeUInt16LE(channels, 22);
header.writeUInt32LE(sampleRate, 24);
header.writeUInt32LE(byteRate, 28);
header.writeUInt16LE(blockAlign, 32);
header.writeUInt16LE(bitsPerSample, 34);
header.write("data", 36);
header.writeUInt32LE(dataSize, 40);
return Buffer.concat([header, pcmBuf]);
}
function _handlePreviewChunk(payload) {
const reqId = payload?.requestId || "";
const entry = _previewPending.get(reqId);
if (!entry) return;
if (payload.base64) {
try { entry.chunks.push(Buffer.from(payload.base64, "base64")); } catch {}
}
if (!entry.sampleRate && payload.sampleRate) entry.sampleRate = payload.sampleRate;
if (!entry.channels && payload.channels) entry.channels = payload.channels;
if (payload.final) {
_previewPending.delete(reqId);
try {
const pcm = Buffer.concat(entry.chunks);
const wav = _buildWavFromPcm(pcm, entry.sampleRate || 24000, entry.channels || 1);
const b64 = wav.toString("base64");
if (entry.clientWs && entry.clientWs.readyState === 1) {
entry.clientWs.send(JSON.stringify({
type: "voice_preview_audio",
base64: b64,
size: wav.length,
}));
}
} catch (err) {
if (entry.clientWs && entry.clientWs.readyState === 1) {
entry.clientWs.send(JSON.stringify({
type: "voice_preview_audio",
error: err.message,
}));
}
}
}
}
async function handleVoicePreview(clientWs, voice, text) {
try {
const requestId = crypto.randomUUID();
_previewPending.set(requestId, { clientWs, chunks: [], sampleRate: 0, channels: 0 });
// Timeout safety net
setTimeout(() => {
if (_previewPending.has(requestId)) {
_previewPending.delete(requestId);
if (clientWs && clientWs.readyState === 1) {
clientWs.send(JSON.stringify({
type: "voice_preview_audio",
error: "Timeout (60s) — keine Antwort vom f5tts-bridge",
}));
}
}
}, 60000);
log("info", "server", `Voice-Preview: voice="${voice}" text="${text.slice(0, 60)}"`);
sendToRVS_raw({
type: "xtts_request",
payload: { text, language: "de", requestId, voice, speed: 1.0 },
timestamp: Date.now(),
});
} catch (err) {
clientWs.send(JSON.stringify({ type: "voice_preview_audio", error: err.message }));
}
}
async function handleTestTTS(clientWs, text) {
try {
log("info", "server", `TTS-Test via XTTS: "${text}"`);