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>
294 lines
11 KiB
JavaScript
294 lines
11 KiB
JavaScript
// ─── Config ────────────────────────────────────────────────────────────────
|
|
|
|
let TTS_CONFIG = null;
|
|
|
|
async function loadTtsConfig() {
|
|
try {
|
|
const r = await fetch('/tts/config');
|
|
TTS_CONFIG = await r.json();
|
|
} catch (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();
|