Files
speech/app/ui/assets/js/tts.js
du5t acdf098c41 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>
2026-06-18 18:46:45 +09:00

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();