901 lines
32 KiB
HTML
901 lines
32 KiB
HTML
|
|
<!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>
|