const $ = (id) => document.getElementById(id);
const els = {
text: $("text"),
charCount: $("charCount"),
engine: $("engine"),
engineInfo: $("engineInfo"),
language: $("language"),
speaker: $("speaker"),
speed: $("speed"),
speedVal: $("speedVal"),
wordGap: $("wordGap"),
wordGapVal: $("wordGapVal"),
sentGap: $("sentGap"),
sentGapVal: $("sentGapVal"),
pitch: $("pitch"),
pitchVal: $("pitchVal"),
generate: $("generate"),
btnText: document.querySelector(".btn-text"),
spinner: document.querySelector(".spinner"),
result: $("result"),
audio: $("audio"),
download: $("download"),
error: $("error"),
deviceChip: $("deviceChip"),
};
let engines = [];
let lastUrl = null;
function currentEngine() {
return engines.find((e) => e.id === els.engine.value);
}
function currentLanguage() {
const eng = currentEngine();
if (!eng) return null;
return eng.languages.find((l) => l.code === els.language.value);
}
function updateCharCount() {
els.charCount.textContent = els.text.value.length;
}
function populateSpeakers() {
const lang = currentLanguage();
els.speaker.innerHTML = "";
if (!lang) return;
lang.speakers.forEach((s) => {
const opt = document.createElement("option");
opt.value = s.id;
opt.textContent = s.label;
els.speaker.appendChild(opt);
});
}
function populateLanguages() {
const eng = currentEngine();
els.language.innerHTML = "";
if (!eng) return;
eng.languages.forEach((l) => {
const opt = document.createElement("option");
opt.value = l.code;
opt.textContent = l.label;
els.language.appendChild(opt);
});
if (eng.default_language) els.language.value = eng.default_language;
populateSpeakers();
updateEngineInfo();
}
function updateEngineInfo() {
const eng = currentEngine();
if (!eng) {
els.engineInfo.textContent = "";
return;
}
const nc = /NC|BY-NC|비상업/i.test(eng.license || "");
const gpu = eng.uses_gpu
? "GPU"
: 'CPU';
const supports = eng.supports || {};
const ctrl = [
supports.speed ? "글자속도" : null,
supports.word_gap ? "단어간격" : null,
supports.sentence_gap ? "문장간격" : null,
supports.pitch ? "피치" : null,
]
.filter(Boolean)
.join(" · ");
els.engineInfo.innerHTML =
`${eng.notes || ""} ` +
`${eng.license || "?"} ` +
`· ${gpu}` +
(ctrl ? ` · 조절: ${ctrl}` : "");
// 지원하지 않는 컨트롤 비활성화 (플래그가 명시적으로 false면 끔)
els.speed.disabled = supports.speed === false;
els.pitch.disabled = supports.pitch === false;
els.wordGap.disabled = supports.word_gap !== true;
els.sentGap.disabled = supports.sentence_gap !== true;
}
async function loadEngines() {
try {
const res = await fetch("/api/engines");
const data = await res.json();
engines = data.engines || [];
els.engine.innerHTML = "";
engines.forEach((e) => {
const opt = document.createElement("option");
opt.value = e.id;
opt.textContent = e.label;
els.engine.appendChild(opt);
});
if (data.default) els.engine.value = data.default;
populateLanguages();
} catch (e) {
showError("엔진 목록을 불러오지 못했습니다.");
}
}
async function loadHealth() {
try {
const res = await fetch("/api/health");
const d = await res.json();
if (d.cuda) {
els.deviceChip.textContent = `GPU 가속: ${d.gpu}`;
els.deviceChip.classList.add("gpu");
} else {
els.deviceChip.textContent = "CPU 모드로 동작 중";
}
} catch (e) {
els.deviceChip.textContent = "";
}
}
function showError(msg) {
els.error.textContent = msg;
els.error.hidden = false;
}
function clearError() {
els.error.hidden = true;
}
function setLoading(on) {
els.generate.disabled = on;
els.spinner.hidden = !on;
els.btnText.textContent = on ? "생성 중…" : "음성 생성";
}
async function generate() {
clearError();
const text = els.text.value.trim();
if (!text) {
showError("읽을 내용을 입력해 주세요.");
return;
}
setLoading(true);
try {
const res = await fetch("/api/tts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
text,
engine: els.engine.value,
language: els.language.value,
speaker: els.speaker.value,
speed: parseFloat(els.speed.value),
pitch: parseFloat(els.pitch.value),
word_gap: parseFloat(els.wordGap.value),
sentence_gap: parseFloat(els.sentGap.value),
}),
});
if (!res.ok) {
let detail = "음성 생성에 실패했습니다.";
try {
const err = await res.json();
if (err.detail) detail = err.detail;
} catch (_) {}
throw new Error(detail);
}
const blob = await res.blob();
if (lastUrl) URL.revokeObjectURL(lastUrl);
lastUrl = URL.createObjectURL(blob);
els.audio.src = lastUrl;
els.download.href = lastUrl;
els.result.hidden = false;
els.audio.play().catch(() => {});
} catch (e) {
showError(e.message || "오류가 발생했습니다.");
} finally {
setLoading(false);
}
}
els.text.addEventListener("input", updateCharCount);
els.engine.addEventListener("change", populateLanguages);
els.language.addEventListener("change", populateSpeakers);
els.speed.addEventListener("input", () => {
els.speedVal.textContent = parseFloat(els.speed.value).toFixed(2) + "x";
});
els.wordGap.addEventListener("input", () => {
els.wordGapVal.textContent = Math.round(parseFloat(els.wordGap.value) * 1000) + " ms";
});
els.sentGap.addEventListener("input", () => {
els.sentGapVal.textContent = parseFloat(els.sentGap.value).toFixed(2) + " s";
});
els.pitch.addEventListener("input", () => {
const v = parseInt(els.pitch.value, 10);
els.pitchVal.textContent = (v > 0 ? "+" + v : v) + " 반음";
});
els.generate.addEventListener("click", generate);
updateCharCount();
loadEngines();
loadHealth();