Funktioniert

This commit is contained in:
2026-06-18 17:27:42 +02:00
parent 94cbf29972
commit 569970e339
20 changed files with 2199 additions and 88 deletions

900
ui/voice-cloning.html Normal file
View File

@@ -0,0 +1,900 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Qwen3-TTS Voice Cloning</title>
<style>
:root {
color-scheme: light;
--bg: #f7f8fa;
--panel: #ffffff;
--panel-soft: #eef2f6;
--text: #17202a;
--muted: #5d6a78;
--line: #d9e0e7;
--accent: #176b5d;
--accent-strong: #0f5147;
--danger: #a63a3a;
--focus: #3478f6;
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
background: var(--bg);
color: var(--text);
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
letter-spacing: 0;
}
main {
width: min(1160px, calc(100vw - 32px));
margin: 0 auto;
padding: 28px 0 36px;
}
header {
display: flex;
align-items: end;
justify-content: space-between;
gap: 18px;
margin-bottom: 20px;
}
h1 {
margin: 0;
font-size: clamp(1.7rem, 3vw, 2.45rem);
line-height: 1.05;
font-weight: 760;
}
.status {
min-width: 170px;
padding: 9px 12px;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--panel);
color: var(--muted);
font-size: 0.92rem;
text-align: center;
}
.status[data-state="ok"] {
color: var(--accent-strong);
border-color: #9bc6bd;
background: #edf7f4;
}
.status[data-state="error"] {
color: var(--danger);
border-color: #e2b6b6;
background: #fff3f3;
}
form {
display: grid;
grid-template-columns: minmax(0, 1fr) 380px;
gap: 18px;
}
section, aside { min-width: 0; }
.panel {
border: 1px solid var(--line);
border-radius: 8px;
background: var(--panel);
}
.panel + .panel { margin-top: 18px; }
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px 16px;
border-bottom: 1px solid var(--line);
background: var(--panel-soft);
border-radius: 8px 8px 0 0;
}
h2 {
margin: 0;
font-size: 1rem;
line-height: 1.2;
}
.panel-body { padding: 16px; }
label {
display: block;
margin-bottom: 7px;
color: var(--muted);
font-size: 0.86rem;
font-weight: 650;
}
input, textarea, select {
width: 100%;
border: 1px solid var(--line);
border-radius: 7px;
background: #fff;
color: var(--text);
font: inherit;
}
input, select {
height: 40px;
padding: 0 10px;
}
textarea {
min-height: 118px;
resize: vertical;
padding: 10px;
line-height: 1.45;
}
input:focus, textarea:focus, select:focus, button:focus-visible {
outline: 2px solid var(--focus);
outline-offset: 2px;
}
.grid-2 {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
}
.field + .field, .grid-2 + .field, .field + .grid-2, .field + .actions {
margin-top: 14px;
}
.file-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 10px;
align-items: center;
}
.file-meta {
color: var(--muted);
font-size: 0.84rem;
white-space: nowrap;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
justify-content: flex-end;
}
button {
min-height: 40px;
border: 1px solid transparent;
border-radius: 7px;
padding: 0 14px;
color: #fff;
background: var(--accent);
font: inherit;
font-weight: 720;
cursor: pointer;
}
button:hover { background: var(--accent-strong); }
button.secondary {
color: var(--text);
border-color: var(--line);
background: #fff;
}
button.secondary:hover { background: #f3f6f8; }
button.danger {
color: var(--danger);
border-color: #e2b6b6;
background: #fff;
}
button.danger:hover { background: #fff3f3; }
button:disabled { cursor: wait; opacity: 0.65; }
audio {
width: 100%;
margin-top: 14px;
}
.download {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 40px;
margin-top: 12px;
border: 1px solid var(--line);
border-radius: 7px;
padding: 0 12px;
color: var(--text);
background: #fff;
font-weight: 700;
text-decoration: none;
}
.download[hidden] { display: none; }
pre {
min-height: 160px;
max-height: 360px;
overflow: auto;
margin: 0;
padding: 12px;
border: 1px solid var(--line);
border-radius: 7px;
background: #101820;
color: #d7e1ea;
font: 0.82rem/1.45 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
white-space: pre-wrap;
word-break: break-word;
}
.hidden { display: none; }
@media (max-width: 880px) {
main {
width: min(100vw - 20px, 720px);
padding-top: 16px;
}
header, form, .grid-2, .file-row {
grid-template-columns: 1fr;
}
header {
display: grid;
align-items: stretch;
}
.status { text-align: left; }
.actions { justify-content: stretch; }
.actions button { flex: 1 1 180px; }
}
</style>
</head>
<body>
<main>
<header>
<h1>Qwen3-TTS Voice Cloning</h1>
<div id="status" class="status">Bereit</div>
</header>
<form id="cloneForm">
<section>
<div class="panel">
<div class="panel-header"><h2>Quelle</h2></div>
<div class="panel-body">
<div class="field">
<label for="sourceMode">Synthesequelle</label>
<select id="sourceMode" name="sourceMode">
<option value="saved" selected>Gespeicherte Stimme</option>
<option value="design">Voice Design</option>
<option value="vector">Speaker Vector</option>
<option value="adhoc">Ad-hoc Referenz</option>
</select>
</div>
<div id="savedVoiceBox">
<div class="grid-2">
<div class="field">
<label for="savedVoice">Stimme</label>
<select id="savedVoice" name="savedVoice"></select>
</div>
<div class="field">
<label for="voiceTaskType">Task</label>
<select id="voiceTaskType" name="voiceTaskType">
<option value="Base" selected>Base</option>
<option value="CustomVoice">CustomVoice</option>
</select>
</div>
</div>
<div class="actions field">
<button id="refreshVoicesButton" class="secondary" type="button">Stimmen laden</button>
<button id="deleteVoiceButton" class="danger" type="button">Stimme loeschen</button>
</div>
</div>
<div id="voiceDesignBox" class="hidden">
<div class="grid-2">
<div class="field">
<label for="designVoice">Ausgangsstimme</label>
<select id="designVoice" name="designVoice"></select>
</div>
<div class="field">
<label for="designSeed">Seed</label>
<input id="designSeed" name="designSeed" type="number" min="0" step="1" value="42">
</div>
</div>
<div class="field">
<label for="designDescription">Beschreibung</label>
<textarea id="designDescription" name="designDescription">Eine warme, ruhige deutsche Stimme, klar artikuliert, freundlich und natuerlich.</textarea>
</div>
<div class="actions field">
<button id="savePreviewButton" class="secondary" type="button" disabled>Vorschau als Stimme speichern</button>
</div>
</div>
<div id="vectorBox" class="hidden">
<div class="grid-2">
<div class="field">
<label for="vectorSelect">Gespeicherter Vector</label>
<select id="vectorSelect" name="vectorSelect"></select>
</div>
<div class="field">
<label for="vectorName">Vector-Name</label>
<input id="vectorName" name="vectorName" value="stimme_vector_01">
</div>
</div>
<div class="field">
<label for="vectorDescription">Beschreibung</label>
<input id="vectorDescription" name="vectorDescription" value="stabiler 2048er Speaker-Vector">
</div>
<div class="field">
<label for="vectorJson">Vector JSON</label>
<textarea id="vectorJson" name="vectorJson" placeholder="[0.01, -0.02, ...]"></textarea>
</div>
<div class="actions field">
<button id="refreshVectorsButton" class="secondary" type="button">Vectoren laden</button>
<button id="saveVectorButton" type="button">Vector speichern</button>
<button id="deleteVectorButton" class="danger" type="button">Vector loeschen</button>
</div>
</div>
</div>
</div>
<div class="panel">
<div class="panel-header"><h2>Referenz speichern</h2></div>
<div class="panel-body">
<div class="grid-2">
<div class="field">
<label for="voiceName">Name</label>
<input id="voiceName" name="voiceName" value="meine_stimme">
</div>
<div class="field">
<label for="consent">Consent</label>
<input id="consent" name="consent" value="local-consent">
</div>
</div>
<div class="field">
<label for="refAudio">Referenzaudio</label>
<div class="file-row">
<input id="refAudio" name="refAudio" type="file" accept="audio/*">
<span id="fileMeta" class="file-meta">Keine Datei</span>
</div>
</div>
<div class="field">
<label for="refText">Transkript der Referenz</label>
<textarea id="refText" name="refText">Dies ist eine kurze Referenzaufnahme fuer das Klonen meiner Stimme.</textarea>
</div>
<div class="field">
<label for="speakerDescription">Beschreibung</label>
<input id="speakerDescription" name="speakerDescription" value="ruhige deutsche Stimme">
</div>
<div class="actions">
<button id="saveVoiceButton" type="button">Stimme speichern</button>
</div>
</div>
</div>
<div class="panel">
<div class="panel-header"><h2>Testausgabe</h2></div>
<div class="panel-body">
<div class="field">
<label for="inputText">Zieltext</label>
<textarea id="inputText" name="inputText" required>Hallo, dies ist ein Test der geklonten Stimme mit Qwen3-TTS.</textarea>
</div>
<div class="actions">
<button id="resetButton" class="secondary" type="button">Zuruecksetzen</button>
<button id="submitButton" type="submit">Test erzeugen</button>
</div>
</div>
</div>
</section>
<aside>
<div class="panel">
<div class="panel-header"><h2>Parameter</h2></div>
<div class="panel-body">
<div class="field">
<label for="speechEndpoint">Speech Endpoint</label>
<input id="speechEndpoint" name="speechEndpoint" value="/api/v1/audio/speech" required>
</div>
<div class="field">
<label for="voicesEndpoint">Voices Endpoint</label>
<input id="voicesEndpoint" name="voicesEndpoint" value="/api/v1/audio/voices" required>
</div>
<div class="field">
<label for="vectorsEndpoint">Vector Endpoint</label>
<input id="vectorsEndpoint" name="vectorsEndpoint" value="/api/voice-vectors" required>
</div>
<div class="field">
<label for="model">Modell</label>
<input id="model" name="model" value="Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice" required>
</div>
<div class="grid-2">
<div class="field">
<label for="language">Sprache</label>
<select id="language" name="language">
<option value="German" selected>German</option>
<option value="English">English</option>
<option value="Auto">Auto</option>
</select>
</div>
<div class="field">
<label for="speed">Tempo</label>
<input id="speed" name="speed" type="number" min="0.5" max="2" step="0.05" value="1">
</div>
</div>
<div class="field">
<label for="audioEncoding">Ad-hoc Audiofeld</label>
<select id="audioEncoding" name="audioEncoding">
<option value="data_url" selected>Data URL</option>
<option value="base64">Base64</option>
</select>
</div>
<div class="field">
<label for="instructions">Stil</label>
<textarea id="instructions" name="instructions">Sprich natuerlich, ruhig und deutlich.</textarea>
</div>
</div>
</div>
<div class="panel">
<div class="panel-header"><h2>Ergebnis</h2></div>
<div class="panel-body">
<audio id="player" controls></audio>
<a id="download" class="download" href="#" download="voice-clone-output.wav" hidden>WAV herunterladen</a>
</div>
</div>
<div class="panel">
<div class="panel-header"><h2>Log</h2></div>
<div class="panel-body"><pre id="log"></pre></div>
</div>
</aside>
</form>
</main>
<script>
const form = document.querySelector("#cloneForm");
const statusBox = document.querySelector("#status");
const submitButton = document.querySelector("#submitButton");
const saveVoiceButton = document.querySelector("#saveVoiceButton");
const refreshVoicesButton = document.querySelector("#refreshVoicesButton");
const deleteVoiceButton = document.querySelector("#deleteVoiceButton");
const savePreviewButton = document.querySelector("#savePreviewButton");
const refreshVectorsButton = document.querySelector("#refreshVectorsButton");
const saveVectorButton = document.querySelector("#saveVectorButton");
const deleteVectorButton = document.querySelector("#deleteVectorButton");
const resetButton = document.querySelector("#resetButton");
const refAudio = document.querySelector("#refAudio");
const fileMeta = document.querySelector("#fileMeta");
const sourceMode = document.querySelector("#sourceMode");
const savedVoiceBox = document.querySelector("#savedVoiceBox");
const voiceDesignBox = document.querySelector("#voiceDesignBox");
const vectorBox = document.querySelector("#vectorBox");
const savedVoice = document.querySelector("#savedVoice");
const designVoice = document.querySelector("#designVoice");
const vectorSelect = document.querySelector("#vectorSelect");
const player = document.querySelector("#player");
const download = document.querySelector("#download");
const logBox = document.querySelector("#log");
let currentObjectUrl = "";
let lastGeneratedBlob = null;
let lastGeneratedText = "";
function setStatus(text, state = "") {
statusBox.textContent = text;
statusBox.dataset.state = state;
}
function log(line) {
const time = new Date().toLocaleTimeString();
logBox.textContent += `[${time}] ${line}\n`;
logBox.scrollTop = logBox.scrollHeight;
}
function resetResult() {
if (currentObjectUrl) {
URL.revokeObjectURL(currentObjectUrl);
currentObjectUrl = "";
}
player.removeAttribute("src");
download.hidden = true;
download.removeAttribute("href");
}
function fileToDataUrl(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result));
reader.onerror = () => reject(reader.error || new Error("Datei konnte nicht gelesen werden."));
reader.readAsDataURL(file);
});
}
function setBusy(isBusy) {
submitButton.disabled = isBusy;
saveVoiceButton.disabled = isBusy;
refreshVoicesButton.disabled = isBusy;
deleteVoiceButton.disabled = isBusy;
savePreviewButton.disabled = isBusy || !lastGeneratedBlob;
refreshVectorsButton.disabled = isBusy;
saveVectorButton.disabled = isBusy;
deleteVectorButton.disabled = isBusy;
}
function fillVoiceSelect(data) {
const builtin = Array.isArray(data.voices) ? data.voices.map(String) : [];
const uploadedEntries = Array.isArray(data.uploaded_voices) ? data.uploaded_voices : [];
const uploaded = uploadedEntries
.map((item) => typeof item === "string" ? { name: item } : item)
.filter((item) => item && item.name)
.map((item) => ({ ...item, name: String(item.name) }));
const uploadedNames = new Set(uploaded.map((item) => item.name.toLowerCase()));
const entries = [
...uploaded.map((item) => ({
name: item.name,
label: `${item.name} (gespeichert, ${item.embedding_source || "audio"})`,
uploaded: true,
embedding_source: item.embedding_source || "audio"
})),
...builtin
.filter((name) => !uploadedNames.has(name.toLowerCase()))
.map((name) => ({ name, label: name, uploaded: false }))
];
savedVoice.innerHTML = "";
designVoice.innerHTML = "";
for (const item of entries) {
const option = document.createElement("option");
option.value = item.name;
option.textContent = item.label;
option.dataset.uploaded = item.uploaded ? "true" : "false";
option.dataset.embeddingSource = item.embedding_source || "";
savedVoice.append(option);
designVoice.append(option.cloneNode(true));
}
if (!entries.length) {
const option = document.createElement("option");
option.value = "";
option.textContent = "Keine Stimmen gefunden";
savedVoice.append(option);
designVoice.append(option.cloneNode(true));
}
if ([...designVoice.options].some((option) => option.value === "ryan")) designVoice.value = "ryan";
log(`Stimmen geladen: ${entries.length}`);
}
async function loadVectors() {
const endpoint = form.vectorsEndpoint.value.trim();
const response = await fetch(endpoint);
if (!response.ok) throw new Error(`GET ${endpoint} -> HTTP ${response.status}: ${await response.text()}`);
const data = await response.json();
const vectors = Array.isArray(data.vectors) ? data.vectors : [];
vectorSelect.innerHTML = "";
for (const item of vectors) {
const option = document.createElement("option");
option.value = item.name;
option.textContent = `${item.name} (${item.dim})`;
vectorSelect.append(option);
}
if (!vectors.length) {
const option = document.createElement("option");
option.value = "";
option.textContent = "Keine Vectoren";
vectorSelect.append(option);
}
log(`Vectoren geladen: ${vectors.length}`);
}
async function loadConfig() {
const response = await fetch("/api/config");
if (!response.ok) return;
const data = await response.json();
if (data.model) form.model.value = data.model;
}
async function loadVoices() {
const endpoint = form.voicesEndpoint.value.trim();
setStatus("Lade Stimmen");
const response = await fetch(endpoint);
if (!response.ok) {
throw new Error(`GET ${endpoint} -> HTTP ${response.status}: ${await response.text()}`);
}
fillVoiceSelect(await response.json());
setStatus("Stimmen geladen", "ok");
}
async function saveVoice() {
const file = refAudio.files[0];
const name = form.voiceName.value.trim();
const consent = form.consent.value.trim();
if (!file || !name || !consent) {
setStatus("Speichern unvollstaendig", "error");
return;
}
const data = new FormData();
data.append("name", name);
data.append("consent", consent);
data.append("audio_sample", file, file.name);
if (form.refText.value.trim()) data.append("ref_text", form.refText.value.trim());
if (form.speakerDescription.value.trim()) data.append("speaker_description", form.speakerDescription.value.trim());
const endpoint = form.voicesEndpoint.value.trim();
setBusy(true);
setStatus("Speichere Stimme");
try {
log(`POST ${endpoint} name=${name}`);
const response = await fetch(endpoint, { method: "POST", body: data });
const text = await response.text();
if (!response.ok) throw new Error(`HTTP ${response.status}: ${text.slice(0, 800)}`);
log(text || "Stimme gespeichert");
await loadVoices();
savedVoice.value = name;
sourceMode.value = "saved";
syncMode();
setStatus("Stimme gespeichert", "ok");
} catch (error) {
log(error && error.stack ? error.stack : String(error));
setStatus("Fehler", "error");
} finally {
setBusy(false);
}
}
async function deleteVoice() {
const name = savedVoice.value;
if (!name) return;
const endpoint = `${form.voicesEndpoint.value.trim()}/${encodeURIComponent(name)}`;
setBusy(true);
setStatus("Loesche Stimme");
try {
log(`DELETE ${endpoint}`);
const response = await fetch(endpoint, { method: "DELETE" });
const text = await response.text();
if (!response.ok) throw new Error(`HTTP ${response.status}: ${text.slice(0, 800)}`);
log(text || "Stimme geloescht");
await loadVoices();
setStatus("Stimme geloescht", "ok");
} catch (error) {
log(error && error.stack ? error.stack : String(error));
setStatus("Fehler", "error");
} finally {
setBusy(false);
}
}
async function buildSpeechPayload() {
const payload = {
model: form.model.value.trim(),
input: form.inputText.value.trim(),
language: form.language.value,
response_format: "wav",
instructions: form.instructions.value.trim(),
speed: Number(form.speed.value || 1)
};
if (sourceMode.value === "saved") {
if (!savedVoice.value) throw new Error("Keine gespeicherte Stimme ausgewaehlt.");
const selected = savedVoice.selectedOptions[0];
const isUploadedAudio = selected?.dataset.uploaded === "true" && selected?.dataset.embeddingSource === "audio";
const usesCustomVoiceModel = payload.model.includes("1.7B-CustomVoice");
if (isUploadedAudio && form.voiceTaskType.value === "Base" && usesCustomVoiceModel) {
throw new Error("Diese gespeicherte Audio-Stimme ist mit dem aktuell geladenen 1.7B-CustomVoice-Modell nicht testbar: der Server extrahiert 1024 Dimensionen, das Modell erwartet 2048. Nutze einen gespeicherten 2048er Speaker Vector oder starte ein kompatibles Base/Clone-Modell.");
}
payload.voice = savedVoice.value;
payload.task_type = form.voiceTaskType.value;
return payload;
}
if (sourceMode.value === "vector") {
if (!vectorSelect.value) throw new Error("Kein Speaker-Vector ausgewaehlt.");
return payload;
}
if (sourceMode.value === "design") {
if (!designVoice.value) throw new Error("Keine Ausgangsstimme fuer Voice Design ausgewaehlt.");
payload.voice = designVoice.value;
payload.task_type = "VoiceDesign";
payload.instructions = form.designDescription.value.trim();
const seed = Number(form.designSeed.value);
if (Number.isFinite(seed)) payload.seed = seed;
return payload;
}
const file = refAudio.files[0];
if (!file) throw new Error("Fuer Ad-hoc-Cloning fehlt Referenzaudio.");
const dataUrl = await fileToDataUrl(file);
payload.task_type = "Base";
payload.ref_audio = form.audioEncoding.value === "base64" ? dataUrl.split(",", 2)[1] : dataUrl;
payload.ref_text = form.refText.value.trim();
return payload;
}
function syncMode() {
savedVoiceBox.classList.toggle("hidden", sourceMode.value !== "saved");
voiceDesignBox.classList.toggle("hidden", sourceMode.value !== "design");
vectorBox.classList.toggle("hidden", sourceMode.value !== "vector");
if (sourceMode.value === "saved") submitButton.textContent = "Stimme testen";
else if (sourceMode.value === "design") submitButton.textContent = "Design erzeugen";
else if (sourceMode.value === "vector") submitButton.textContent = "Mit Vector erzeugen";
else submitButton.textContent = "Ad-hoc testen";
}
refAudio.addEventListener("change", () => {
const file = refAudio.files[0];
fileMeta.textContent = file ? `${(file.size / 1024 / 1024).toFixed(2)} MB` : "Keine Datei";
});
sourceMode.addEventListener("change", syncMode);
refreshVoicesButton.addEventListener("click", () => loadVoices().catch((error) => {
log(error && error.stack ? error.stack : String(error));
setStatus("Fehler", "error");
}));
saveVoiceButton.addEventListener("click", saveVoice);
deleteVoiceButton.addEventListener("click", deleteVoice);
refreshVectorsButton.addEventListener("click", () => loadVectors().catch((error) => {
log(error && error.stack ? error.stack : String(error));
setStatus("Fehler", "error");
}));
saveVectorButton.addEventListener("click", async () => {
setBusy(true);
setStatus("Speichere Vector");
try {
const embedding = JSON.parse(form.vectorJson.value.trim());
const response = await fetch(form.vectorsEndpoint.value.trim(), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: form.vectorName.value.trim(),
description: form.vectorDescription.value.trim(),
embedding
})
});
const text = await response.text();
if (!response.ok) throw new Error(`HTTP ${response.status}: ${text.slice(0, 800)}`);
log(text);
await loadVectors();
vectorSelect.value = form.vectorName.value.trim();
setStatus("Vector gespeichert", "ok");
} catch (error) {
log(error && error.stack ? error.stack : String(error));
setStatus("Fehler", "error");
} finally {
setBusy(false);
}
});
deleteVectorButton.addEventListener("click", async () => {
if (!vectorSelect.value) return;
setBusy(true);
setStatus("Loesche Vector");
try {
const endpoint = `${form.vectorsEndpoint.value.trim()}/${encodeURIComponent(vectorSelect.value)}`;
const response = await fetch(endpoint, { method: "DELETE" });
const text = await response.text();
if (!response.ok) throw new Error(`HTTP ${response.status}: ${text.slice(0, 800)}`);
log(text);
await loadVectors();
setStatus("Vector geloescht", "ok");
} catch (error) {
log(error && error.stack ? error.stack : String(error));
setStatus("Fehler", "error");
} finally {
setBusy(false);
}
});
savePreviewButton.addEventListener("click", async () => {
if (!lastGeneratedBlob) return;
const name = form.voiceName.value.trim();
const consent = form.consent.value.trim();
if (!name || !consent) {
setStatus("Name oder Consent fehlt", "error");
return;
}
const data = new FormData();
data.append("name", name);
data.append("consent", consent);
data.append("audio_sample", lastGeneratedBlob, `${name}.wav`);
data.append("ref_text", lastGeneratedText || form.inputText.value.trim());
data.append("speaker_description", form.designDescription.value.trim() || form.speakerDescription.value.trim());
const endpoint = form.voicesEndpoint.value.trim();
setBusy(true);
setStatus("Speichere Design");
try {
log(`POST ${endpoint} name=${name} aus Design-Vorschau`);
const response = await fetch(endpoint, { method: "POST", body: data });
const text = await response.text();
if (!response.ok) throw new Error(`HTTP ${response.status}: ${text.slice(0, 800)}`);
log(text || "Design als Stimme gespeichert");
await loadVoices();
savedVoice.value = name;
sourceMode.value = "saved";
syncMode();
setStatus("Design gespeichert", "ok");
} catch (error) {
log(error && error.stack ? error.stack : String(error));
setStatus("Fehler", "error");
} finally {
setBusy(false);
}
});
resetButton.addEventListener("click", () => {
form.reset();
refAudio.value = "";
fileMeta.textContent = "Keine Datei";
logBox.textContent = "";
resetResult();
lastGeneratedBlob = null;
lastGeneratedText = "";
syncMode();
setStatus("Bereit");
loadVoices().catch((error) => log(String(error)));
});
form.addEventListener("submit", async (event) => {
event.preventDefault();
resetResult();
logBox.textContent = "";
const endpoint = form.speechEndpoint.value.trim();
setBusy(true);
setStatus("Synthetisiere");
try {
const payload = await buildSpeechPayload();
log(`POST ${endpoint}`);
log(`Quelle: ${sourceMode.value === "saved" ? payload.voice : "Ad-hoc Referenz"}`);
log(`Task: ${payload.task_type}, Sprache: ${payload.language}`);
const started = performance.now();
const speechUrl = sourceMode.value === "vector"
? `${form.vectorsEndpoint.value.trim()}/${encodeURIComponent(vectorSelect.value)}/speech`
: endpoint;
const response = await fetch(speechUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
if (!response.ok) {
const text = await response.text();
throw new Error(`HTTP ${response.status}: ${text.slice(0, 800)}`);
}
const blob = await response.blob();
lastGeneratedBlob = blob;
lastGeneratedText = payload.input || "";
currentObjectUrl = URL.createObjectURL(blob);
player.src = currentObjectUrl;
download.href = currentObjectUrl;
download.hidden = false;
savePreviewButton.disabled = false;
const elapsed = ((performance.now() - started) / 1000).toFixed(2);
log(`Fertig nach ${elapsed}s, ${(blob.size / 1024).toFixed(1)} KB`);
setStatus("Fertig", "ok");
} catch (error) {
log(error && error.stack ? error.stack : String(error));
setStatus("Fehler", "error");
} finally {
setBusy(false);
}
});
syncMode();
Promise.all([loadConfig(), loadVoices(), loadVectors()]).catch((error) => {
log(error && error.stack ? error.stack : String(error));
setStatus("Fehler", "error");
});
</script>
</body>
</html>