- 타임스트레치 우선순위를 ffmpeg atempo(기본) → rubberband → librosa 로 변경 - MeloEngine: 문장 단위로 분리 후 문장 사이 무음(sentence_gap), 단어(어절) 사이 무음(word_gap)을 타임스트레치와 독립적으로 삽입. speed 는 글자(음소) 속도만 조절 - API(TTSRequest)에 word_gap/sentence_gap 필드 추가, 전 엔진 시그니처 확장 - 프론트엔드에 '글자 속도/단어 간격/문장 간격' 슬라이더 추가, supports 플래그로 미지원 엔진(supertonic/oute/cosy)에서는 간격 슬라이더 자동 비활성화
90 lines
2.7 KiB
Python
90 lines
2.7 KiB
Python
"""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,
|
|
"word_gap": False,
|
|
"sentence_gap": False,
|
|
},
|
|
}
|
|
|
|
def synth_wav(
|
|
self,
|
|
text: str,
|
|
language: str,
|
|
speaker: str | None = None,
|
|
speed: float = 1.0,
|
|
pitch: float = 0.0,
|
|
word_gap: float = 0.0,
|
|
sentence_gap: float | None = None,
|
|
) -> 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}")
|