- 타임스트레치 우선순위를 ffmpeg atempo(기본) → rubberband → librosa 로 변경 - MeloEngine: 문장 단위로 분리 후 문장 사이 무음(sentence_gap), 단어(어절) 사이 무음(word_gap)을 타임스트레치와 독립적으로 삽입. speed 는 글자(음소) 속도만 조절 - API(TTSRequest)에 word_gap/sentence_gap 필드 추가, 전 엔진 시그니처 확장 - 프론트엔드에 '글자 속도/단어 간격/문장 간격' 슬라이더 추가, supports 플래그로 미지원 엔진(supertonic/oute/cosy)에서는 간격 슬라이더 자동 비활성화
217 lines
5.9 KiB
JavaScript
217 lines
5.9 KiB
JavaScript
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"
|
|
: '<span class="cpu">CPU</span>';
|
|
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 || ""} ` +
|
|
`<span class="lic${nc ? " nc" : ""}">${eng.license || "?"}</span> ` +
|
|
`· ${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();
|