224 lines
8.8 KiB
HTML
224 lines
8.8 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Silero TTS Test</title>
|
|
<style>
|
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #1a1a2e; color: #e0e0e0; min-height: 100vh; padding: 2rem; }
|
|
h1 { font-size: 1.4rem; margin-bottom: 1.5rem; color: #a0c4ff; }
|
|
.container { max-width: 720px; margin: 0 auto; }
|
|
.controls { display: flex; flex-direction: column; gap: 0.8rem; margin-bottom: 1.2rem; }
|
|
textarea { width: 100%; min-height: 100px; padding: 0.7rem; border: 1px solid #333; border-radius: 6px; background: #16213e; color: #e0e0e0; font-size: 0.95rem; resize: vertical; }
|
|
textarea:focus { outline: none; border-color: #a0c4ff; }
|
|
.row { display: flex; gap: 0.8rem; align-items: center; flex-wrap: wrap; }
|
|
.row label { font-size: 0.85rem; color: #888; }
|
|
select { padding: 0.4rem 0.6rem; border: 1px solid #333; border-radius: 4px; background: #16213e; color: #e0e0e0; font-size: 0.9rem; }
|
|
button { padding: 0.5rem 1.2rem; border: none; border-radius: 4px; background: #a0c4ff; color: #1a1a2e; font-weight: 600; cursor: pointer; font-size: 0.9rem; }
|
|
button:hover { background: #7db4f0; }
|
|
button:disabled { background: #555; color: #999; cursor: not-allowed; }
|
|
.btn-download { background: #4ecca3; }
|
|
.btn-download:hover { background: #3db892; }
|
|
.result { margin-top: 1.2rem; padding: 1rem; background: #16213e; border-radius: 8px; border: 1px solid #333; display: none; }
|
|
.result.visible { display: block; }
|
|
.meta { display: flex; gap: 1rem; flex-wrap: wrap; margin-bottom: 0.8rem; font-size: 0.85rem; }
|
|
.meta .badge { background: #0f3460; padding: 0.2rem 0.5rem; border-radius: 3px; }
|
|
.meta .lang { background: #e94560; color: #fff; font-weight: 600; }
|
|
.normalized { font-size: 0.85rem; color: #aaa; margin-bottom: 0.8rem; word-break: break-word; }
|
|
.normalized strong { color: #ccc; }
|
|
audio { width: 100%; margin: 0.5rem 0; }
|
|
.actions { display: flex; gap: 0.6rem; margin-top: 0.5rem; }
|
|
.history { margin-top: 1.5rem; }
|
|
.history h2 { font-size: 1rem; color: #888; margin-bottom: 0.6rem; }
|
|
.history table { width: 100%; border-collapse: collapse; font-size: 0.82rem; }
|
|
.history th { text-align: left; color: #666; padding: 0.3rem 0.5rem; border-bottom: 1px solid #333; }
|
|
.history td { padding: 0.3rem 0.5rem; border-bottom: 1px solid #222; }
|
|
.history td.truncate { max-width: 250px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
.spinner { display: inline-block; width: 14px; height: 14px; border: 2px solid #555; border-top-color: #a0c4ff; border-radius: 50%; animation: spin 0.6s linear infinite; margin-right: 0.4rem; vertical-align: middle; }
|
|
@keyframes spin { to { transform: rotate(360deg); } }
|
|
.status { font-size: 0.85rem; color: #888; min-height: 1.2em; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="container">
|
|
<h1>Silero TTS Test</h1>
|
|
<div class="controls">
|
|
<textarea id="input" placeholder="Enter text..."></textarea>
|
|
<div class="row">
|
|
<label>Model</label>
|
|
<select id="model"></select>
|
|
<label>Voice</label>
|
|
<select id="voice"></select>
|
|
<label>Format</label>
|
|
<select id="format">
|
|
<option value="mp3">mp3</option>
|
|
<option value="wav">wav</option>
|
|
<option value="opus">opus</option>
|
|
<option value="aac">aac</option>
|
|
<option value="flac">flac</option>
|
|
</select>
|
|
<label>Rate</label>
|
|
<select id="rate">
|
|
<option value="">normal</option>
|
|
<option value="x-slow">x-slow</option>
|
|
<option value="slow">slow</option>
|
|
<option value="medium">medium</option>
|
|
<option value="fast">fast</option>
|
|
<option value="x-fast">x-fast</option>
|
|
</select>
|
|
<label>Pitch</label>
|
|
<select id="pitch">
|
|
<option value="">normal</option>
|
|
<option value="x-low">x-low</option>
|
|
<option value="low">low</option>
|
|
<option value="medium">medium</option>
|
|
<option value="high">high</option>
|
|
<option value="x-high">x-high</option>
|
|
</select>
|
|
<button id="generate">Generate</button>
|
|
</div>
|
|
<div class="status" id="status"></div>
|
|
</div>
|
|
<div class="result" id="result">
|
|
<div class="meta">
|
|
<span class="badge lang" id="r-lang"></span>
|
|
<span class="badge" id="r-model"></span>
|
|
<span class="badge" id="r-voice"></span>
|
|
<span class="badge" id="r-speaker"></span>
|
|
<span class="badge" id="r-segs"></span>
|
|
<span class="badge" id="r-time"></span>
|
|
</div>
|
|
<div class="normalized"><strong>Normalized:</strong> <span id="r-text"></span></div>
|
|
<audio id="r-audio" controls></audio>
|
|
<div class="actions">
|
|
<button class="btn-download" id="r-download">Download</button>
|
|
</div>
|
|
</div>
|
|
<div class="history" id="history-section" style="display:none">
|
|
<h2>History</h2>
|
|
<table>
|
|
<thead><tr><th>Time</th><th>Lang</th><th>Voice</th><th>Speaker</th><th>Text</th><th>Time</th></tr></thead>
|
|
<tbody id="history-body"></tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
<script>
|
|
const $ = id => document.getElementById(id);
|
|
const history = [];
|
|
|
|
async function loadModels() {
|
|
const res = await fetch('/webui/models');
|
|
const data = await res.json();
|
|
const sel = $('model');
|
|
sel.innerHTML = '';
|
|
for (const m of data.models) {
|
|
const opt = document.createElement('option');
|
|
opt.value = m; opt.textContent = m;
|
|
sel.appendChild(opt);
|
|
}
|
|
}
|
|
|
|
async function loadVoices() {
|
|
const res = await fetch('/webui/voices');
|
|
const data = await res.json();
|
|
const sel = $('voice');
|
|
sel.innerHTML = '';
|
|
for (const v of data.voices) {
|
|
const opt = document.createElement('option');
|
|
opt.value = v; opt.textContent = v;
|
|
if (v === 'default') opt.selected = true;
|
|
sel.appendChild(opt);
|
|
}
|
|
}
|
|
|
|
function setLoading(loading) {
|
|
$('generate').disabled = loading;
|
|
$('status').innerHTML = loading ? '<span class="spinner"></span>Generating...' : '';
|
|
}
|
|
|
|
function base64ToBlob(b64, format) {
|
|
const mimeMap = {mp3:'audio/mpeg', wav:'audio/wav', opus:'audio/opus', aac:'audio/aac', flac:'audio/flac', pcm:'audio/pcm'};
|
|
const binary = atob(b64);
|
|
const array = new Uint8Array(binary.length);
|
|
for (let i = 0; i < binary.length; i++) array[i] = binary.charCodeAt(i);
|
|
return new Blob([array], { type: mimeMap[format] || 'application/octet-stream' });
|
|
}
|
|
|
|
function addToHistory(entry) {
|
|
history.unshift(entry);
|
|
$('history-section').style.display = '';
|
|
const tbody = $('history-body');
|
|
const tr = document.createElement('tr');
|
|
tr.innerHTML = `<td>${entry.time}</td><td>${entry.lang}</td><td>${entry.voice}</td><td>${entry.speaker}</td><td class="truncate">${entry.text}</td><td>${entry.duration}</td>`;
|
|
tbody.prepend(tr);
|
|
}
|
|
|
|
async function generate() {
|
|
const text = $('input').value;
|
|
if (!text.trim()) return;
|
|
setLoading(true);
|
|
$('result').classList.remove('visible');
|
|
try {
|
|
const res = await fetch('/webui/speech', {
|
|
method: 'POST',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify({
|
|
input: text,
|
|
model: $('model').value,
|
|
voice: $('voice').value,
|
|
response_format: $('format').value,
|
|
rate: $('rate').value || undefined,
|
|
pitch: $('pitch').value || undefined
|
|
})
|
|
});
|
|
if (!res.ok) {
|
|
const err = await res.json().catch(() => ({error:{message:'request failed'}}));
|
|
$('status').textContent = 'Error: ' + (err.error?.message || res.statusText);
|
|
return;
|
|
}
|
|
const data = await res.json();
|
|
const blob = base64ToBlob(data.audio, data.format);
|
|
const url = URL.createObjectURL(blob);
|
|
$('r-audio').src = url;
|
|
$('r-lang').textContent = data.language;
|
|
$('r-model').textContent = data.model;
|
|
$('r-voice').textContent = 'voice: ' + data.voice;
|
|
$('r-speaker').textContent = 'speaker: ' + data.speaker;
|
|
$('r-segs').textContent = 'Segs: ' + data.segments;
|
|
$('r-time').textContent = data.synthesis_time_ms + 'ms';
|
|
$('r-text').textContent = data.normalized_text;
|
|
$('r-download').onclick = () => {
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = 'tts_' + Date.now() + '.' + data.format;
|
|
a.click();
|
|
};
|
|
$('result').classList.add('visible');
|
|
const now = new Date();
|
|
addToHistory({
|
|
time: now.toLocaleTimeString(),
|
|
lang: data.language,
|
|
voice: data.voice,
|
|
speaker: data.speaker,
|
|
text: text.length > 40 ? text.slice(0, 40) + '...' : text,
|
|
duration: data.synthesis_time_ms + 'ms'
|
|
});
|
|
$('r-audio').play().catch(() => {});
|
|
} catch (e) {
|
|
$('status').textContent = 'Error: ' + e.message;
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
|
|
$('generate').addEventListener('click', generate);
|
|
$('input').addEventListener('keydown', e => {
|
|
if (e.ctrlKey && e.key === 'Enter') { e.preventDefault(); generate(); }
|
|
});
|
|
loadModels();
|
|
loadVoices();
|
|
</script>
|
|
</body>
|
|
</html>
|