// ─── 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 = `
불러오기 실패: ${esc(e.message)}
`; return; } renderTtsVoiceList(voices); renderTtsVoiceSelect(voices); } function renderTtsVoiceList(voices) { if (!voices.length) { ttsVoiceList.innerHTML = '
등록된 보이스가 없습니다. 먼저 참조 음성을 업로드하세요.
'; return; } ttsVoiceList.innerHTML = ''; for (const v of voices) { const row = document.createElement('div'); row.className = 'voice-row'; row.innerHTML = ` ${esc(v.name)} `; 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 = '
불러오는 중...
'; 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 = `
오류: ${esc(e.message)}
`; } } function renderTtsHistoryList(records) { if (!records.length) { ttsHistoryList.innerHTML = '
처리 내역이 없습니다.
'; return; } let html = '
' + '' + ''; for (const rec of records) { const dur = rec.duration != null ? `${rec.duration.toFixed(1)}s` : '-'; html += ``; } html += '
시각백엔드언어보이스길이미리보기
${esc(fmtTimestamp(rec.id))} ${esc(rec.backend || '-')} ${esc(rec.language || '-')} ${esc(rec.voice || '-')} ${dur} ${esc(rec.text_preview || '')}
'; 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]) => `
${esc(k)}${esc(v)}
` ).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();