"""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}")