feat: CosyVoice 한국어 엔진 통합 (Apache-2.0, GPU)

- vendor/CosyVoice: 패치된 CosyVoice 코드 + Matcha-TTS 벤더링
- cosyvoice_worker: 전용 venv에서 상주하는 합성 워커(FastAPI, 모델 1회 로드)
- app/engines/cosyvoice_engine: HTTP 프록시 엔진(속도/피치)
- Dockerfile: 전용 venv(torch cu128) + 모델 베이킹 + 빌드 워밍업
- entrypoint.sh: 워커(8001) + 메인앱(8788) 동시 기동
This commit is contained in:
claude
2026-08-23 23:16:24 +09:00
parent b92d2be2af
commit 9c1009d16d
93 changed files with 74398 additions and 6 deletions

View File

@@ -31,15 +31,15 @@ def _build() -> None:
except Exception as exc: # pragma: no cover
print(f"[registry] MeloTTS 로드 실패: {exc}")
# Coqui GlowTTS-KSS (전용 venv + 체크포인트가 있을 때만)
# CosyVoice 한국어 (전용 워커 URL 이 설정된 경우에만)
try:
from .coqui_engine import CoquiKssEngine
from .cosyvoice_engine import CosyVoiceEngine
if CoquiKssEngine.available():
e = CoquiKssEngine()
if CosyVoiceEngine.available():
e = CosyVoiceEngine()
_ENGINES[e.id] = e
except Exception as exc: # pragma: no cover
print(f"[registry] Coqui 엔진 스킵: {exc}")
print(f"[registry] CosyVoice 엔진 스킵: {exc}")
_INITED = True

View File

@@ -0,0 +1,82 @@
"""CosyVoice 엔진 (프록시).
실제 합성은 전용 venv에서 도는 워커(worker.py)가 담당하고, 이 엔진은 HTTP로
요청을 전달한다. 워커 URL(COSYVOICE_URL)이 설정된 경우에만 활성화된다.
"""
from __future__ import annotations
import json
import os
import urllib.error
import urllib.request
from .base import BaseEngine
WORKER_URL = os.environ.get("COSYVOICE_URL", "").rstrip("/")
class CosyVoiceEngine(BaseEngine):
id = "cosyvoice"
label = "CosyVoice (한국어)"
license = "Apache-2.0"
uses_gpu = True
notes = "LLM 기반 한국어 음성(프리셋 화자). 속도 지원, 피치는 후처리."
@staticmethod
def available() -> bool:
return bool(WORKER_URL)
def describe(self) -> dict:
return {
"id": self.id,
"label": self.label,
"license": self.license,
"uses_gpu": self.uses_gpu,
"notes": self.notes,
"languages": [
{
"code": "KR",
"label": "한국어",
"speakers": [
{"id": "韩语女", "label": "한국어 여성 (기본)"},
],
}
],
"supports": {"speed": True, "pitch": True},
}
def synth_wav(
self,
text: str,
language: str,
speaker: str | None = None,
speed: float = 1.0,
pitch: float = 0.0,
) -> bytes:
text = (text or "").strip()
if not text:
raise ValueError("텍스트가 비어 있습니다.")
if not WORKER_URL:
raise ValueError("CosyVoice 워커가 설정되지 않았습니다.")
payload = json.dumps(
{
"text": text,
"speaker": speaker or "韩语女",
"speed": speed,
"pitch": pitch,
}
).encode("utf-8")
request = urllib.request.Request(
WORKER_URL + "/synth",
data=payload,
headers={"Content-Type": "application/json"},
)
try:
with urllib.request.urlopen(request, timeout=180) as resp:
return resp.read()
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8", errors="ignore")
raise ValueError(f"CosyVoice 합성 실패: {detail}")
except Exception as exc: # pragma: no cover
raise ValueError(f"CosyVoice 워커 연결 실패: {exc}")