"""OuteTTS 엔진 (프록시, 개인용/비상업). CC-BY-NC-4.0 라이선스라 개인/비상업 용도로만 사용. 워커 URL(OUTETTS_URL)이 설정된 경우에만 활성화된다. """ from __future__ import annotations import json import os import urllib.error import urllib.request from .base import BaseEngine WORKER_URL = os.environ.get("OUTETTS_URL", "").rstrip("/") _VOICES = [ {"id": "female_1", "label": "여성 1"}, {"id": "female_2", "label": "여성 2"}, {"id": "male_1", "label": "남성 1"}, {"id": "male_2", "label": "남성 2"}, ] class OuteTTSEngine(BaseEngine): id = "outetts" label = "OuteTTS (개인용·비상업)" license = "CC-BY-NC-4.0" uses_gpu = True notes = "Qwen2 기반 한국어 TTS. 비상업 라이선스라 개인 사용 전용. 속도·피치 후처리." @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": "KO", "label": "한국어", "speakers": _VOICES}, ], "default_language": "KO", "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("OuteTTS 워커가 설정되지 않았습니다.") payload = json.dumps( { "text": text, "voice": speaker or "female_1", "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"OuteTTS 합성 실패: {detail}") except Exception as exc: # pragma: no cover raise ValueError(f"OuteTTS 워커 연결 실패: {exc}")