refactor: 다중 엔진 레지스트리 구조로 전환 + 프론트 엔진 선택 UI

- app/engines: BaseEngine 인터페이스, MeloEngine(인프로세스), 레지스트리
- /api/engines 추가, /api/tts 에 engine 파라미터
- 프론트: 엔진 → 언어 → 목소리 선택, 라이선스/GPU/조절 지원 표기
- MeloTTS 동작 불변
This commit is contained in:
claude
2026-08-23 22:21:26 +09:00
parent 389916e9f3
commit 91f8ec3533
7 changed files with 265 additions and 52 deletions

View File

@@ -3,6 +3,8 @@ const $ = (id) => document.getElementById(id);
const els = {
text: $("text"),
charCount: $("charCount"),
engine: $("engine"),
engineInfo: $("engineInfo"),
language: $("language"),
speaker: $("speaker"),
speed: $("speed"),
@@ -19,16 +21,24 @@ const els = {
deviceChip: $("deviceChip"),
};
let voices = [];
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 code = els.language.value;
const lang = voices.find((v) => v.code === code);
const lang = currentLanguage();
els.speaker.innerHTML = "";
if (!lang) return;
lang.speakers.forEach((s) => {
@@ -39,21 +49,64 @@ function populateSpeakers() {
});
}
async function loadVoices() {
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);
});
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.pitch ? "피치" : null,
]
.filter(Boolean)
.join(" · ");
els.engineInfo.innerHTML =
`${eng.notes || ""} ` +
`<span class="lic${nc ? " nc" : ""}">${eng.license || "?"}</span> ` +
`· ${gpu}` +
(ctrl ? ` · 조절: ${ctrl}` : "");
// 지원하지 않는 컨트롤 비활성화
els.speed.disabled = supports.speed === false;
els.pitch.disabled = supports.pitch === false;
}
async function loadEngines() {
try {
const res = await fetch("/api/voices");
const res = await fetch("/api/engines");
const data = await res.json();
voices = data.languages || [];
els.language.innerHTML = "";
voices.forEach((v) => {
engines = data.engines || [];
els.engine.innerHTML = "";
engines.forEach((e) => {
const opt = document.createElement("option");
opt.value = v.code;
opt.textContent = v.label;
els.language.appendChild(opt);
opt.value = e.id;
opt.textContent = e.label;
els.engine.appendChild(opt);
});
populateSpeakers();
if (data.default) els.engine.value = data.default;
populateLanguages();
} catch (e) {
showError("목소리 목록을 불러오지 못했습니다.");
showError("엔진 목록을 불러오지 못했습니다.");
}
}
@@ -100,6 +153,7 @@ async function generate() {
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),
@@ -129,6 +183,7 @@ async function generate() {
}
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";
@@ -140,5 +195,5 @@ els.pitch.addEventListener("input", () => {
els.generate.addEventListener("click", generate);
updateCharCount();
loadVoices();
loadEngines();
loadHealth();