diff --git a/app/engines/__init__.py b/app/engines/__init__.py new file mode 100644 index 0000000..c589f87 --- /dev/null +++ b/app/engines/__init__.py @@ -0,0 +1,73 @@ +"""엔진 레지스트리. + +엔진을 lazy 하게 생성하고 id 로 조회한다. 사용 불가한 엔진(전용 venv/모델 미설치)은 +자동으로 목록에서 제외한다. +""" +from __future__ import annotations + +import threading + +from .base import BaseEngine + +_ENGINES: dict[str, BaseEngine] = {} +_LOCK = threading.Lock() +_INITED = False + + +def _build() -> None: + global _INITED + if _INITED: + return + with _LOCK: + if _INITED: + return + + # MeloTTS (항상 사용 가능) + try: + from .melo_engine import MeloEngine + + e = MeloEngine() + _ENGINES[e.id] = e + except Exception as exc: # pragma: no cover + print(f"[registry] MeloTTS 로드 실패: {exc}") + + # Coqui GlowTTS-KSS (전용 venv + 체크포인트가 있을 때만) + try: + from .coqui_engine import CoquiKssEngine + + if CoquiKssEngine.available(): + e = CoquiKssEngine() + _ENGINES[e.id] = e + except Exception as exc: # pragma: no cover + print(f"[registry] Coqui 엔진 스킵: {exc}") + + _INITED = True + + +def get_engine(engine_id: str) -> BaseEngine: + _build() + if engine_id not in _ENGINES: + raise ValueError(f"알 수 없는 엔진입니다: {engine_id}") + return _ENGINES[engine_id] + + +def list_engines() -> list[BaseEngine]: + _build() + return list(_ENGINES.values()) + + +def default_engine_id() -> str: + _build() + # MeloTTS 우선 + if "melo" in _ENGINES: + return "melo" + return next(iter(_ENGINES)) if _ENGINES else "" + + +def warmup_all() -> None: + _build() + for e in _ENGINES.values(): + try: + e.warmup() + except Exception as exc: # pragma: no cover + print(f"[registry] {e.id} warmup 실패: {exc}") diff --git a/app/engines/base.py b/app/engines/base.py new file mode 100644 index 0000000..3d8ac0f --- /dev/null +++ b/app/engines/base.py @@ -0,0 +1,48 @@ +"""TTS 엔진 공통 인터페이스. + +각 엔진은 서로 다른 의존성(torch/librosa/numpy 핀)을 가질 수 있으므로, +in-process 로 도는 엔진(MeloTTS)과 전용 venv 를 subprocess 로 호출하는 엔진 +(Coqui 등)을 동일한 인터페이스로 노출한다. +""" +from __future__ import annotations + +from abc import ABC, abstractmethod + + +class BaseEngine(ABC): + id: str = "" + label: str = "" + license: str = "" + uses_gpu: bool = False + notes: str = "" + + @abstractmethod + def describe(self) -> dict: + """엔진 메타데이터 + 언어/목소리 목록. + + 반환 예: + { + "id": "melo", "label": "MeloTTS", "license": "MIT", + "uses_gpu": True, "notes": "...", + "languages": [ {"code": "KR", "label": "한국어", + "speakers": [{"id": "KR", "label": "기본"}]} ], + "supports": {"speed": True, "pitch": True}, + } + """ + raise NotImplementedError + + @abstractmethod + def synth_wav( + self, + text: str, + language: str, + speaker: str | None = None, + speed: float = 1.0, + pitch: float = 0.0, + ) -> bytes: + """WAV(PCM16) 바이트를 반환.""" + raise NotImplementedError + + def warmup(self) -> None: + """선택적: 서버 시작 시 모델 프리로드.""" + return None diff --git a/app/tts_engine.py b/app/engines/melo_engine.py similarity index 79% rename from app/tts_engine.py rename to app/engines/melo_engine.py index 1f7b10f..aa209f9 100644 --- a/app/tts_engine.py +++ b/app/engines/melo_engine.py @@ -1,9 +1,4 @@ -"""MeloTTS 래퍼 엔진. - -- 언어(한국어/영어)별 모델을 lazy-load 하고 캐시한다. -- GPU(CUDA)가 있으면 자동으로 사용한다. -- 속도(speed)와 피치(pitch, 반음 단위)를 조절할 수 있다. -""" +"""MeloTTS 엔진 (in-process). 한국어/영어, GPU 자동 사용, 속도/피치 지원.""" from __future__ import annotations import io @@ -20,6 +15,7 @@ except Exception: # pragma: no cover from melo.api import TTS +from .base import BaseEngine # 화면에 노출할 언어/화자 정의 (중국어/일본어는 제외) VOICES: dict[str, dict] = { @@ -48,7 +44,13 @@ def _pick_device() -> str: return "cuda:0" if torch.cuda.is_available() else "cpu" -class TTSEngine: +class MeloEngine(BaseEngine): + id = "melo" + label = "MeloTTS" + license = "MIT" + uses_gpu = True + notes = "자연스러운 한국어/영어 음성. 속도·피치 조절 지원." + def __init__(self) -> None: self.device = _pick_device() self._models: dict[str, TTS] = {} @@ -71,30 +73,33 @@ class TTSEngine: return model def warmup(self) -> None: - """서버 시작 시 모델을 미리 로드한다.""" for lang in VOICES: try: self._get_model(lang) except Exception as exc: # pragma: no cover - print(f"[warmup] {lang} 모델 로드 실패: {exc}") + print(f"[melo warmup] {lang} 모델 로드 실패: {exc}") - def list_voices(self) -> list[dict]: - out = [] - for code, info in VOICES.items(): - out.append( - { - "code": code, - "label": info["label"], - "speakers": info["speakers"], - } - ) - return out + def _list_languages(self) -> list[dict]: + return [ + {"code": code, "label": info["label"], "speakers": info["speakers"]} + for code, info in VOICES.items() + ] + + def describe(self) -> dict: + return { + "id": self.id, + "label": self.label, + "license": self.license, + "uses_gpu": self.uses_gpu, + "notes": self.notes, + "languages": self._list_languages(), + "supports": {"speed": True, "pitch": True}, + } def _resolve_speaker(self, model: TTS, language: str, speaker: str | None) -> int: spk2id = model.hps.data.spk2id if speaker and speaker in spk2id: return spk2id[speaker] - # 기본값: 정의된 첫 화자 default_id = VOICES[language]["speakers"][0]["id"] if default_id in spk2id: return spk2id[default_id] @@ -119,7 +124,6 @@ class TTSEngine: model = self._get_model(language) speaker_id = self._resolve_speaker(model, language, speaker) - # 동시 추론은 GPU 메모리 보호를 위해 직렬화 with self._global_lock: audio = model.tts_to_file( text, speaker_id, output_path=None, speed=speed, quiet=True @@ -134,6 +138,3 @@ class TTSEngine: sf.write(buf, audio, sr, format="WAV", subtype="PCM_16") buf.seek(0) return buf.read() - - -engine = TTSEngine() diff --git a/app/server.py b/app/server.py index 9e3e972..2bce1bc 100644 --- a/app/server.py +++ b/app/server.py @@ -1,7 +1,7 @@ -"""한국어 우선 TTS 웹 서비스 (MeloTTS 기반). +"""한국어 우선 다중 엔진 TTS 웹 서비스. -- 로그인/과금 없이 무제한으로 사용 가능 -- 한국어/영어 목소리 모델을 언어별로 선택 +- 로그인/과금 없이 무제한 사용 +- 엔진(MeloTTS, Coqui GlowTTS-KSS 등) 선택 → 언어/목소리 선택 - 속도 / 피치 조절 - GPU 자동 사용 """ @@ -15,11 +15,16 @@ from fastapi.responses import FileResponse, Response from fastapi.staticfiles import StaticFiles from pydantic import BaseModel, Field -from .tts_engine import engine +from .engines import ( + default_engine_id, + get_engine, + list_engines, + warmup_all, +) FRONTEND_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "frontend") -app = FastAPI(title="한국어 TTS 스튜디오", version="1.0.0") +app = FastAPI(title="한국어 TTS 스튜디오", version="2.0.0") app.add_middleware( CORSMiddleware, @@ -31,16 +36,16 @@ app.add_middleware( class TTSRequest(BaseModel): text: str = Field(..., description="읽을 텍스트") - language: str = Field("KR", description="언어 코드 (KR / EN)") - speaker: str | None = Field(None, description="화자 ID") + engine: str | None = Field(None, description="엔진 ID (melo / coqui-kss 등)") + language: str = Field("KR", description="언어 코드") + speaker: str | None = Field(None, description="화자/모델 ID") speed: float = Field(1.0, ge=0.5, le=2.0, description="말하기 속도") pitch: float = Field(0.0, ge=-12.0, le=12.0, description="피치(반음)") @app.on_event("startup") def _startup() -> None: - # 첫 요청 지연을 줄이기 위해 백그라운드가 아닌 즉시 워밍업 - engine.warmup() + warmup_all() @app.get("/api/health") @@ -49,20 +54,26 @@ def health() -> dict: return { "status": "ok", - "device": engine.device, "cuda": torch.cuda.is_available(), "gpu": torch.cuda.get_device_name(0) if torch.cuda.is_available() else None, + "engines": [e.id for e in list_engines()], + "default_engine": default_engine_id(), } -@app.get("/api/voices") -def voices() -> dict: - return {"languages": engine.list_voices()} +@app.get("/api/engines") +def engines() -> dict: + return { + "default": default_engine_id(), + "engines": [e.describe() for e in list_engines()], + } @app.post("/api/tts") def tts(req: TTSRequest) -> Response: + engine_id = req.engine or default_engine_id() try: + engine = get_engine(engine_id) wav = engine.synth_wav( text=req.text, language=req.language, diff --git a/frontend/app.js b/frontend/app.js index 2a5c317..305efe1 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -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" + : 'CPU'; + const supports = eng.supports || {}; + const ctrl = [ + supports.speed ? "속도" : null, + supports.pitch ? "피치" : null, + ] + .filter(Boolean) + .join(" · "); + els.engineInfo.innerHTML = + `${eng.notes || ""} ` + + `${eng.license || "?"} ` + + `· ${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(); diff --git a/frontend/index.html b/frontend/index.html index 1288130..9a63e9a 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -22,6 +22,12 @@
0자
+
+ + +
+
+
diff --git a/frontend/style.css b/frontend/style.css index c714243..7eb1b01 100644 --- a/frontend/style.css +++ b/frontend/style.css @@ -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;