Add XTTS-v2 as first TTS backend, extensible for more
Mirrors the ASR backend pattern exactly: a BACKENDS dict in
tts/router.py maps a backend name to its worker URL, so adding the
next backend is just a new worker file + venv + supervisord entry +
one dict line. XTTS-v2 runs in its own venv (--system-site-packages,
inherits base image torch/CUDA) as a new supervisord program on
port 8005.
XTTS is zero-shot voice cloning, so a reference-voice library was
added (/tts/voices CRUD, stored under /srv/tts/voices) — synthesis
requires picking a previously uploaded voice. Results and model
cache live under /srv/tts/{results,models-cache}, new quadlet
volumes, owned by the same 983:983 user as the existing /srv/asr
dirs.
Fixed two environment issues uncovered while getting XTTS to
actually run inside the container (non-root user, root-built venvs):
- coqui-tts only pins transformers>=4.57 with no ceiling, so pip
installed an incompatible 5.x; pinned to the last 4.x release.
- HOME defaults to /app (owned by root) for the container's runtime
user, so numba/matplotlib/torch cache writes failed; HOME is now
forced to /tmp in all three workers (faster_whisper, qwen3, xtts)
before any of those libraries get imported.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,15 +1,293 @@
|
||||
// TTS tab is a structural placeholder until a synthesis backend is wired in.
|
||||
// This just confirms the /tts router is reachable; the UI itself is a static
|
||||
// "coming soon" card (see index.html).
|
||||
// ─── Config ────────────────────────────────────────────────────────────────
|
||||
|
||||
let TTS_CONFIG = null;
|
||||
|
||||
async function loadTtsConfig() {
|
||||
try {
|
||||
const r = await fetch('/tts/config');
|
||||
const cfg = await r.json();
|
||||
console.log('[tts] config', cfg);
|
||||
TTS_CONFIG = await r.json();
|
||||
} catch (e) {
|
||||
console.warn('[tts] config fetch failed', e);
|
||||
TTS_CONFIG = {
|
||||
default_backend: 'xtts',
|
||||
default_language: 'ko',
|
||||
backends: { xtts: { languages: ['ko', 'en'] } },
|
||||
};
|
||||
}
|
||||
populateTtsBackends();
|
||||
}
|
||||
|
||||
const ttsBackendSel = document.getElementById('tts-backend');
|
||||
const ttsLanguageSel = document.getElementById('tts-language');
|
||||
const ttsVoiceSel = document.getElementById('tts-voice-select');
|
||||
|
||||
function populateTtsBackends() {
|
||||
if (!TTS_CONFIG) return;
|
||||
ttsBackendSel.innerHTML = '';
|
||||
for (const name of Object.keys(TTS_CONFIG.backends)) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = name;
|
||||
opt.textContent = name;
|
||||
if (name === TTS_CONFIG.default_backend) opt.selected = true;
|
||||
ttsBackendSel.appendChild(opt);
|
||||
}
|
||||
if (!ttsBackendSel.value && ttsBackendSel.options.length) {
|
||||
ttsBackendSel.selectedIndex = 0;
|
||||
}
|
||||
populateTtsLanguages();
|
||||
}
|
||||
|
||||
function populateTtsLanguages() {
|
||||
if (!TTS_CONFIG) return;
|
||||
const backend = TTS_CONFIG.backends[ttsBackendSel.value];
|
||||
const languages = backend?.languages || [];
|
||||
ttsLanguageSel.innerHTML = '';
|
||||
for (const lang of languages) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = lang;
|
||||
opt.textContent = lang;
|
||||
if (lang === TTS_CONFIG.default_language) opt.selected = true;
|
||||
ttsLanguageSel.appendChild(opt);
|
||||
}
|
||||
if (!ttsLanguageSel.value && languages.length) ttsLanguageSel.value = languages[0];
|
||||
}
|
||||
|
||||
ttsBackendSel.addEventListener('change', populateTtsLanguages);
|
||||
|
||||
// ─── Voices ────────────────────────────────────────────────────────────────
|
||||
|
||||
const ttsVoiceList = document.getElementById('tts-voice-list');
|
||||
|
||||
async function loadTtsVoices() {
|
||||
let voices = [];
|
||||
try {
|
||||
const r = await fetch('/tts/voices');
|
||||
voices = await r.json();
|
||||
} catch (e) {
|
||||
ttsVoiceList.innerHTML = `<div class="muted small">불러오기 실패: ${esc(e.message)}</div>`;
|
||||
return;
|
||||
}
|
||||
renderTtsVoiceList(voices);
|
||||
renderTtsVoiceSelect(voices);
|
||||
}
|
||||
|
||||
function renderTtsVoiceList(voices) {
|
||||
if (!voices.length) {
|
||||
ttsVoiceList.innerHTML = '<div class="muted small">등록된 보이스가 없습니다. 먼저 참조 음성을 업로드하세요.</div>';
|
||||
return;
|
||||
}
|
||||
ttsVoiceList.innerHTML = '';
|
||||
for (const v of voices) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'voice-row';
|
||||
row.innerHTML = `
|
||||
<span class="voice-name">${esc(v.name)}</span>
|
||||
<audio controls src="/tts/voices/${encodeURIComponent(v.name)}/audio"></audio>
|
||||
<button type="button" class="btn-danger voice-delete-btn" data-name="${esc(v.name)}">삭제</button>
|
||||
`;
|
||||
ttsVoiceList.appendChild(row);
|
||||
}
|
||||
ttsVoiceList.querySelectorAll('.voice-delete-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => deleteTtsVoice(btn.dataset.name));
|
||||
});
|
||||
}
|
||||
|
||||
function renderTtsVoiceSelect(voices) {
|
||||
const prev = ttsVoiceSel.value;
|
||||
ttsVoiceSel.innerHTML = '';
|
||||
for (const v of voices) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = v.name;
|
||||
opt.textContent = v.name;
|
||||
ttsVoiceSel.appendChild(opt);
|
||||
}
|
||||
if (voices.some(v => v.name === prev)) ttsVoiceSel.value = prev;
|
||||
}
|
||||
|
||||
async function deleteTtsVoice(name) {
|
||||
if (!confirm(`보이스 '${name}'을 삭제하시겠습니까?`)) return;
|
||||
try {
|
||||
const r = await fetch(`/tts/voices/${encodeURIComponent(name)}`, { method: 'DELETE' });
|
||||
if (!r.ok) throw new Error(await r.text());
|
||||
await loadTtsVoices();
|
||||
} catch (e) {
|
||||
alert(`삭제 실패: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('tts-voice-upload-btn').addEventListener('click', async () => {
|
||||
const nameInput = document.getElementById('tts-voice-name');
|
||||
const fileInput = document.getElementById('tts-voice-file');
|
||||
const name = nameInput.value.trim();
|
||||
if (!name) { alert('보이스 이름을 입력하세요.'); return; }
|
||||
if (!fileInput.files.length) { alert('참조 음성 파일을 선택하세요.'); return; }
|
||||
|
||||
const fd = new FormData();
|
||||
fd.set('name', name);
|
||||
fd.set('file', fileInput.files[0]);
|
||||
|
||||
try {
|
||||
const r = await fetch('/tts/voices', { method: 'POST', body: fd });
|
||||
if (!r.ok) throw new Error(await r.text());
|
||||
nameInput.value = '';
|
||||
fileInput.value = '';
|
||||
await loadTtsVoices();
|
||||
} catch (e) {
|
||||
alert(`업로드 실패: ${e.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Synthesize ──────────────────────────────────────────────────────────────
|
||||
|
||||
const ttsStatus = document.getElementById('tts-status');
|
||||
const ttsResultSection = document.getElementById('tts-result-section');
|
||||
const ttsResultAudio = document.getElementById('tts-result-audio');
|
||||
let ttsLastAudioUrl = null;
|
||||
|
||||
document.getElementById('tts-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const text = document.getElementById('tts-text').value.trim();
|
||||
if (!text) { ttsStatus.textContent = '텍스트를 입력하세요.'; return; }
|
||||
if (!ttsVoiceSel.value) { ttsStatus.textContent = '보이스를 먼저 등록하세요.'; return; }
|
||||
|
||||
const fd = new FormData();
|
||||
fd.set('text', text);
|
||||
fd.set('backend', ttsBackendSel.value);
|
||||
fd.set('language', ttsLanguageSel.value);
|
||||
fd.set('voice', ttsVoiceSel.value);
|
||||
|
||||
ttsStatus.textContent = '합성 요청 중... (첫 호출은 모델 로딩 때문에 오래 걸릴 수 있습니다)';
|
||||
document.getElementById('tts-submit-btn').disabled = true;
|
||||
ttsResultSection.style.display = 'none';
|
||||
|
||||
try {
|
||||
const resp = await fetch('/tts/synthesize', { method: 'POST', body: fd });
|
||||
const payload = await resp.json();
|
||||
if (!resp.ok) throw new Error(payload.detail || JSON.stringify(payload));
|
||||
ttsLastAudioUrl = payload.audio_url;
|
||||
ttsResultAudio.src = payload.audio_url;
|
||||
ttsResultSection.style.display = '';
|
||||
const dur = payload.duration ? ` ${payload.duration.toFixed(1)}s` : '';
|
||||
ttsStatus.textContent = `완료${dur}`;
|
||||
} catch (err) {
|
||||
ttsStatus.textContent = `실패: ${err.message}`;
|
||||
} finally {
|
||||
document.getElementById('tts-submit-btn').disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('tts-dl-btn').addEventListener('click', () => {
|
||||
if (!ttsLastAudioUrl) return;
|
||||
const a = document.createElement('a');
|
||||
a.href = ttsLastAudioUrl;
|
||||
a.download = 'speech.wav';
|
||||
document.body.appendChild(a); a.click(); a.remove();
|
||||
});
|
||||
|
||||
// ─── History ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const ttsHistoryList = document.getElementById('tts-history-list');
|
||||
const ttsHistoryDetail = document.getElementById('tts-history-detail');
|
||||
let ttsCurrentRecord = null;
|
||||
|
||||
async function loadTtsHistory() {
|
||||
ttsHistoryList.innerHTML = '<div class="muted" style="padding:16px">불러오는 중...</div>';
|
||||
ttsHistoryDetail.style.display = 'none';
|
||||
ttsHistoryList.style.display = '';
|
||||
try {
|
||||
const r = await fetch('/tts/history');
|
||||
const records = await r.json();
|
||||
renderTtsHistoryList(records);
|
||||
} catch (e) {
|
||||
ttsHistoryList.innerHTML = `<div class="muted" style="padding:16px">오류: ${esc(e.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderTtsHistoryList(records) {
|
||||
if (!records.length) {
|
||||
ttsHistoryList.innerHTML = '<div class="muted" style="padding:16px">처리 내역이 없습니다.</div>';
|
||||
return;
|
||||
}
|
||||
let html = '<div class="history-table-wrap"><table class="history-table"><thead><tr>'
|
||||
+ '<th>시각</th><th>백엔드</th><th>언어</th><th>보이스</th><th>길이</th><th>미리보기</th><th></th>'
|
||||
+ '</tr></thead><tbody>';
|
||||
for (const rec of records) {
|
||||
const dur = rec.duration != null ? `${rec.duration.toFixed(1)}s` : '-';
|
||||
html += `<tr class="history-row" data-id="${esc(rec.id)}">
|
||||
<td class="mono">${esc(fmtTimestamp(rec.id))}</td>
|
||||
<td><span class="badge-backend">${esc(rec.backend || '-')}</span></td>
|
||||
<td>${esc(rec.language || '-')}</td>
|
||||
<td>${esc(rec.voice || '-')}</td>
|
||||
<td>${dur}</td>
|
||||
<td class="preview-cell">${esc(rec.text_preview || '')}</td>
|
||||
<td><button class="btn-view" data-id="${esc(rec.id)}">보기</button></td>
|
||||
</tr>`;
|
||||
}
|
||||
html += '</tbody></table></div>';
|
||||
ttsHistoryList.innerHTML = html;
|
||||
ttsHistoryList.querySelectorAll('.btn-view').forEach(btn => {
|
||||
btn.addEventListener('click', () => openTtsHistoryDetail(btn.dataset.id));
|
||||
});
|
||||
}
|
||||
|
||||
async function openTtsHistoryDetail(id) {
|
||||
try {
|
||||
const r = await fetch(`/tts/history/${id}`);
|
||||
if (!r.ok) throw new Error(await r.text());
|
||||
ttsCurrentRecord = await r.json();
|
||||
ttsCurrentRecord._id = id;
|
||||
renderTtsDetail(ttsCurrentRecord);
|
||||
ttsHistoryList.style.display = 'none';
|
||||
ttsHistoryDetail.style.display = '';
|
||||
} catch (e) {
|
||||
alert(`불러오기 실패: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function renderTtsDetail(data) {
|
||||
document.getElementById('tts-detail-title').textContent = fmtTimestamp(data._id || '');
|
||||
|
||||
const meta = document.getElementById('tts-detail-meta');
|
||||
const fields = [
|
||||
['백엔드', data.backend || '-'],
|
||||
['언어', data.language || '-'],
|
||||
['보이스', data.voice || '-'],
|
||||
['길이', data.duration != null ? `${data.duration.toFixed(1)}s` : '-'],
|
||||
['처리시각', fmtTimestamp(data._timestamp || data._id || '')],
|
||||
];
|
||||
meta.innerHTML = fields.map(([k, v]) =>
|
||||
`<div class="meta-item"><span class="meta-key">${esc(k)}</span><span class="meta-val">${esc(v)}</span></div>`
|
||||
).join('');
|
||||
|
||||
const audioName = (data.audio_file || '').split('/').pop();
|
||||
document.getElementById('tts-detail-audio').src = audioName ? `/tts/audio/${encodeURIComponent(audioName)}` : '';
|
||||
document.getElementById('tts-detail-text').value = data.text || '';
|
||||
}
|
||||
|
||||
document.getElementById('tts-history-back-btn').addEventListener('click', () => {
|
||||
ttsHistoryDetail.style.display = 'none';
|
||||
ttsHistoryList.style.display = '';
|
||||
});
|
||||
|
||||
document.getElementById('tts-history-refresh-btn').addEventListener('click', loadTtsHistory);
|
||||
|
||||
document.getElementById('tts-detail-delete-btn').addEventListener('click', async () => {
|
||||
if (!ttsCurrentRecord || !ttsCurrentRecord._id) return;
|
||||
if (!confirm('이 내역을 삭제하시겠습니까? 생성된 오디오도 함께 삭제됩니다.')) return;
|
||||
try {
|
||||
const r = await fetch(`/tts/history/${ttsCurrentRecord._id}`, { method: 'DELETE' });
|
||||
if (!r.ok) throw new Error(await r.text());
|
||||
ttsHistoryDetail.style.display = 'none';
|
||||
ttsHistoryList.style.display = '';
|
||||
await loadTtsHistory();
|
||||
} catch (e) {
|
||||
alert(`삭제 실패: ${e.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Refresh history whenever its tab is opened
|
||||
document.querySelector('[data-target="tab-tts-history"]').addEventListener('click', loadTtsHistory);
|
||||
|
||||
// ─── Init ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
loadTtsConfig();
|
||||
loadTtsVoices();
|
||||
|
||||
Reference in New Issue
Block a user