Files
unios-tts/app/webui.html
T
Shaman 82c306ff69 init
2026-10-01 07:38:01 +03:00

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>