한국어 우선 MeloTTS 웹 서비스 추가 (GPU, 속도/피치, 로그인 없음)
- FastAPI 백엔드: /api/voices, /api/tts, /api/health - 한국어 메인 + 영어(5종 악센트), 중국어/일본어 UI 제거 - 언어별 목소리 모델 선택, 속도(0.5~2.0x)/피치(±12반음) 조절 - 로그인/사용제한/과금 유도 없음 (무제한 테스트) - NVIDIA GPU 자동 사용 (torch cu128, Blackwell sm_120) - 유저 친화 한국어 UI (frontend) - Docker/compose (GPU 예약), 8788 포트 - MeloTTS(순수 파이썬) 벤더링 + 검증된 버전 고정(constraints)
This commit is contained in:
144
frontend/app.js
Normal file
144
frontend/app.js
Normal file
@@ -0,0 +1,144 @@
|
||||
const $ = (id) => document.getElementById(id);
|
||||
|
||||
const els = {
|
||||
text: $("text"),
|
||||
charCount: $("charCount"),
|
||||
language: $("language"),
|
||||
speaker: $("speaker"),
|
||||
speed: $("speed"),
|
||||
speedVal: $("speedVal"),
|
||||
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 voices = [];
|
||||
let lastUrl = null;
|
||||
|
||||
function updateCharCount() {
|
||||
els.charCount.textContent = els.text.value.length;
|
||||
}
|
||||
|
||||
function populateSpeakers() {
|
||||
const code = els.language.value;
|
||||
const lang = voices.find((v) => v.code === code);
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
async function loadVoices() {
|
||||
try {
|
||||
const res = await fetch("/api/voices");
|
||||
const data = await res.json();
|
||||
voices = data.languages || [];
|
||||
els.language.innerHTML = "";
|
||||
voices.forEach((v) => {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = v.code;
|
||||
opt.textContent = v.label;
|
||||
els.language.appendChild(opt);
|
||||
});
|
||||
populateSpeakers();
|
||||
} 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,
|
||||
language: els.language.value,
|
||||
speaker: els.speaker.value,
|
||||
speed: parseFloat(els.speed.value),
|
||||
pitch: parseFloat(els.pitch.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.language.addEventListener("change", populateSpeakers);
|
||||
els.speed.addEventListener("input", () => {
|
||||
els.speedVal.textContent = parseFloat(els.speed.value).toFixed(2) + "x";
|
||||
});
|
||||
els.pitch.addEventListener("input", () => {
|
||||
const v = parseInt(els.pitch.value, 10);
|
||||
els.pitchVal.textContent = (v > 0 ? "+" + v : v) + " 반음";
|
||||
});
|
||||
els.generate.addEventListener("click", generate);
|
||||
|
||||
updateCharCount();
|
||||
loadVoices();
|
||||
loadHealth();
|
||||
Reference in New Issue
Block a user