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

View File

@@ -22,6 +22,12 @@
<textarea id="text" rows="5" placeholder="예) 안녕하세요, 오늘도 좋은 하루 보내세요!">안녕하세요, 오늘 하루도 힘내세요! 이 목소리는 GPU로 생성되었습니다.</textarea>
<div class="text-meta"><span id="charCount">0</span>자</div>
<div class="control control-full">
<label class="field-label" for="engine">TTS 엔진</label>
<select id="engine"></select>
<div class="engine-info" id="engineInfo"></div>
</div>
<div class="controls">
<div class="control">
<label class="field-label" for="language">언어</label>

View File

@@ -97,6 +97,25 @@ textarea:focus { border-color: var(--accent); }
.text-meta { text-align: right; color: var(--muted); font-size: 12px; margin: 6px 2px 0; }
.control-full { margin-top: 18px; }
.engine-info {
margin-top: 8px;
font-size: 12px;
color: var(--muted);
line-height: 1.5;
}
.engine-info .lic {
display: inline-block;
margin-left: 6px;
padding: 1px 7px;
border-radius: 6px;
background: rgba(53,208,165,.12);
color: var(--ok);
font-size: 11px;
}
.engine-info .lic.nc { background: rgba(255,107,125,.12); color: var(--err); }
.engine-info .cpu { color: #e0b341; }
.controls {
display: grid;
grid-template-columns: 1fr 1fr;