From 58b2d7a41ccc2b61b987fa84b1a1bf762152de1a Mon Sep 17 00:00:00 2001 From: claude Date: Mon, 24 Aug 2026 03:06:36 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20MeloTTS=20=EC=9E=90=EB=8F=99=20?= =?UTF-8?q?=EC=96=B8=EC=96=B4=20=EA=B0=90=EC=A7=80(=ED=95=9C/=EC=98=81=20?= =?UTF-8?q?=ED=98=BC=ED=95=A9=20=EB=B6=84=EB=A6=AC=20=ED=95=A9=EC=84=B1)?= =?UTF-8?q?=20+=20CosyVoice=20=EB=B9=84=ED=99=9C=EC=84=B1=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 기본 MeloTTS 단일 엔진만 노출(사용자 요청) - AUTO 모드: 입력을 언어별로 분리해 각 언어 음성으로 합성 후 이어붙임 - 판별 불가 구간 기본 언어 = 영어(기본값 영어) - 기본 선택 언어 AUTO(default_language), 프론트 반영 - Dockerfile: CosyVoice 빌드 제거로 이미지 경량화 --- Dockerfile | 30 +------ app/engines/__init__.py | 10 +-- app/engines/melo_engine.py | 178 ++++++++++++++++++++++++++----------- frontend/app.js | 1 + 4 files changed, 130 insertions(+), 89 deletions(-) diff --git a/Dockerfile b/Dockerfile index 3c89d69..e5f41e4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -34,32 +34,6 @@ COPY warmup.py ./ # 한국어/영어 모델 및 부가 데이터 사전 다운로드 (첫 요청 지연 제거) RUN python warmup.py -# ================= CosyVoice 엔진 (한국어, Apache-2.0) ================= -# 전용 venv 로 의존성 격리 (MeloTTS 스택과 충돌 방지). -COPY vendor/CosyVoice /opt/CosyVoice -COPY cosyvoice_worker /opt/cosyvoice_worker - -RUN python -m venv /opt/cosyvoice-venv \ - && /opt/cosyvoice-venv/bin/pip install --no-cache-dir "setuptools<81" wheel \ - && /opt/cosyvoice-venv/bin/pip install --no-cache-dir torch==2.11.0 torchaudio==2.11.0 \ - --index-url https://download.pytorch.org/whl/cu128 \ - && /opt/cosyvoice-venv/bin/pip install --no-cache-dir --no-build-isolation \ - -r /opt/cosyvoice_worker/requirements.txt \ - && /opt/cosyvoice-venv/bin/pip install --no-cache-dir --no-deps --no-build-isolation \ - openai-whisper==20231117 tiktoken more-itertools - -# CosyVoice-300M-SFT 모델 베이킹 (한국어 프리셋 화자 '韩语女' 포함, ~5.4GB) -ENV COSYVOICE_MODEL_DIR=/models/cosyvoice/CosyVoice-300M-SFT -RUN /opt/cosyvoice-venv/bin/python -c "from modelscope import snapshot_download; snapshot_download('iic/CosyVoice-300M-SFT', local_dir='/models/cosyvoice/CosyVoice-300M-SFT')" - -# 빌드 시 1회 로드하여 런타임 프론트엔드(wetext 등)를 이미지에 캐시 -RUN /opt/cosyvoice-venv/bin/python -c "import sys; sys.path.insert(0,'/opt/CosyVoice'); sys.path.insert(0,'/opt/CosyVoice/third_party/Matcha-TTS'); from cosyvoice.cli.cosyvoice import CosyVoice; CosyVoice('/models/cosyvoice/CosyVoice-300M-SFT', load_jit=False, load_trt=False, fp16=False); print('cosyvoice warmup ok')" - -# 워커 활성화 스위치 -ENV COSYVOICE_URL=http://127.0.0.1:8001 - -COPY entrypoint.sh /entrypoint.sh -RUN chmod +x /entrypoint.sh - +# 기본 MeloTTS 단일 엔진 (자동 언어 감지: 한/영 혼합) EXPOSE 8788 -CMD ["/entrypoint.sh"] +CMD ["uvicorn", "app.server:app", "--host", "0.0.0.0", "--port", "8788"] diff --git a/app/engines/__init__.py b/app/engines/__init__.py index 7661673..3f1a158 100644 --- a/app/engines/__init__.py +++ b/app/engines/__init__.py @@ -31,15 +31,7 @@ def _build() -> None: except Exception as exc: # pragma: no cover print(f"[registry] MeloTTS 로드 실패: {exc}") - # CosyVoice 한국어 (전용 워커 URL 이 설정된 경우에만) - try: - from .cosyvoice_engine import CosyVoiceEngine - - if CosyVoiceEngine.available(): - e = CosyVoiceEngine() - _ENGINES[e.id] = e - except Exception as exc: # pragma: no cover - print(f"[registry] CosyVoice 엔진 스킵: {exc}") + # (CosyVoice 엔진은 사용자 요청에 따라 비활성화 — 기본 MeloTTS만 유지) _INITED = True diff --git a/app/engines/melo_engine.py b/app/engines/melo_engine.py index aa209f9..325d7a9 100644 --- a/app/engines/melo_engine.py +++ b/app/engines/melo_engine.py @@ -1,7 +1,13 @@ -"""MeloTTS 엔진 (in-process). 한국어/영어, GPU 자동 사용, 속도/피치 지원.""" +"""MeloTTS 엔진 (in-process). + +- 한국어/영어 지원, GPU 자동 사용, 속도/피치 조절 +- 자동 모드(AUTO): 입력 텍스트를 언어별로 잘라 각 언어에 맞는 음성으로 합성 후 이어붙임 +- 판별 불가(숫자/기호만 등) 구간의 기본 언어는 영어 +""" from __future__ import annotations import io +import re import threading import numpy as np @@ -17,94 +23,144 @@ from melo.api import TTS from .base import BaseEngine -# 화면에 노출할 언어/화자 정의 (중국어/일본어는 제외) -VOICES: dict[str, dict] = { - "KR": { - "label": "한국어", - "melo_lang": "KR", - "speakers": [ - {"id": "KR", "label": "한국어 기본 목소리"}, - ], - }, - "EN": { - "label": "English (영어)", - "melo_lang": "EN", - "speakers": [ - {"id": "EN-US", "label": "영어 · 미국식"}, - {"id": "EN-BR", "label": "영어 · 영국식"}, - {"id": "EN_INDIA", "label": "영어 · 인도식"}, - {"id": "EN-AU", "label": "영어 · 호주식"}, - {"id": "EN-Default", "label": "영어 · 기본"}, - ], - }, -} +# 영어 화자(억양) 목록 +EN_SPEAKERS = [ + {"id": "EN-US", "label": "영어 · 미국식"}, + {"id": "EN-BR", "label": "영어 · 영국식"}, + {"id": "EN_INDIA", "label": "영어 · 인도식"}, + {"id": "EN-AU", "label": "영어 · 호주식"}, + {"id": "EN-Default", "label": "영어 · 기본"}, +] +KR_SPEAKERS = [{"id": "KR", "label": "한국어 기본 목소리"}] + +# 실제 MeloTTS 언어 코드 +MELO_LANG = {"KR": "KR", "EN": "EN"} + +DEFAULT_LANG = "EN" # 판별 불가 구간의 기본 언어(사용자 요청: 기본값 영어) + +_KR_RE = re.compile(r"[가-힣ᄀ-ᇿ㄰-㆏]") +_EN_RE = re.compile(r"[A-Za-z]") def _pick_device() -> str: return "cuda:0" if torch.cuda.is_available() else "cpu" +def split_by_language(text: str, default_lang: str = DEFAULT_LANG) -> list[tuple[str, str]]: + """텍스트를 한국어/영어 구간으로 분리. + + 숫자·기호·공백 등 언어 판별이 안 되는 문자는 인접(진행 중) 구간에 붙인다. + 전체가 판별 불가면 default_lang 으로 처리한다. + """ + segments: list[tuple[str, str]] = [] + cur_lang: str | None = None + cur: list[str] = [] + + for ch in text: + if _KR_RE.match(ch): + kind = "KR" + elif _EN_RE.match(ch): + kind = "EN" + else: + kind = None # 중립 문자 + + if kind is None: + cur.append(ch) + continue + if cur_lang is None: + cur_lang = kind + cur.append(ch) + elif kind == cur_lang: + cur.append(ch) + else: + segments.append((cur_lang, "".join(cur))) + cur_lang = kind + cur = [ch] + + if cur: + lang = cur_lang if cur_lang is not None else default_lang + segments.append((lang, "".join(cur))) + + # 실제 발음 가능한(문자 포함) 구간만 유지 + return [(l, t) for l, t in segments if t.strip()] + + class MeloEngine(BaseEngine): id = "melo" label = "MeloTTS" license = "MIT" uses_gpu = True - notes = "자연스러운 한국어/영어 음성. 속도·피치 조절 지원." + notes = "자동 언어 감지: 한국어/영어 혼합 텍스트를 언어별로 나눠 각 음성으로 합성. 기본 영어." def __init__(self) -> None: self.device = _pick_device() self._models: dict[str, TTS] = {} - self._locks: dict[str, threading.Lock] = {k: threading.Lock() for k in VOICES} + self._locks: dict[str, threading.Lock] = {k: threading.Lock() for k in MELO_LANG} self._global_lock = threading.Lock() # ---- 모델 로딩 ------------------------------------------------- - def _get_model(self, language: str) -> TTS: - if language not in VOICES: - raise ValueError(f"지원하지 않는 언어입니다: {language}") - model = self._models.get(language) + def _get_model(self, lang: str) -> TTS: + if lang not in MELO_LANG: + raise ValueError(f"지원하지 않는 언어입니다: {lang}") + model = self._models.get(lang) if model is not None: return model - with self._locks[language]: - model = self._models.get(language) + with self._locks[lang]: + model = self._models.get(lang) if model is None: - melo_lang = VOICES[language]["melo_lang"] - model = TTS(language=melo_lang, device=self.device) - self._models[language] = model + model = TTS(language=MELO_LANG[lang], device=self.device) + self._models[lang] = model return model def warmup(self) -> None: - for lang in VOICES: + for lang in MELO_LANG: try: self._get_model(lang) except Exception as exc: # pragma: no cover print(f"[melo warmup] {lang} 모델 로드 실패: {exc}") - 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: + languages = [ + { + "code": "AUTO", + "label": "자동 감지 (한국어+영어)", + "speakers": EN_SPEAKERS, # 영어 구간에 쓸 억양 선택 (한국어는 자동) + }, + {"code": "EN", "label": "English (영어)", "speakers": EN_SPEAKERS}, + {"code": "KR", "label": "한국어", "speakers": KR_SPEAKERS}, + ] return { "id": self.id, "label": self.label, "license": self.license, "uses_gpu": self.uses_gpu, "notes": self.notes, - "languages": self._list_languages(), + "languages": languages, + "default_language": "AUTO", "supports": {"speed": True, "pitch": True}, } - def _resolve_speaker(self, model: TTS, language: str, speaker: str | None) -> int: + def _resolve_speaker(self, model: TTS, lang: 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"] + default_id = "KR" if lang == "KR" else "EN-US" if default_id in spk2id: return spk2id[default_id] return list(spk2id.values())[0] + def _synth_segment( + self, text: str, lang: str, speaker: str | None, speed: float + ) -> tuple[np.ndarray, int]: + model = self._get_model(lang) + speaker_id = self._resolve_speaker(model, lang, speaker) + with self._global_lock: + audio = model.tts_to_file( + text, speaker_id, output_path=None, speed=speed, quiet=True + ) + sr = model.hps.data.sampling_rate + return np.asarray(audio, dtype=np.float32), sr + # ---- 합성 ----------------------------------------------------- def synth_wav( self, @@ -120,17 +176,35 @@ class MeloEngine(BaseEngine): speed = float(max(0.5, min(2.0, speed))) pitch = float(max(-12.0, min(12.0, pitch))) + language = (language or "AUTO").upper() - model = self._get_model(language) - speaker_id = self._resolve_speaker(model, language, speaker) + if language == "AUTO": + # 영어 구간엔 사용자가 고른 억양, 한국어 구간엔 KR 음성 + en_speaker = speaker if (speaker and speaker.startswith("EN")) else "EN-US" + parts = split_by_language(text, default_lang=DEFAULT_LANG) + if not parts: + parts = [(DEFAULT_LANG, text)] + audios: list[np.ndarray] = [] + sr = None + gap = None + for seg_lang, seg_text in parts: + seg_spk = "KR" if seg_lang == "KR" else en_speaker + audio, seg_sr = self._synth_segment(seg_text, seg_lang, seg_spk, speed) + if sr is None: + sr = seg_sr + gap = np.zeros(int(sr * 0.06), dtype=np.float32) + elif seg_sr != sr: + if librosa is not None: + audio = librosa.resample( + audio, orig_sr=seg_sr, target_sr=sr + ).astype(np.float32) + if audios: + audios.append(gap) + audios.append(audio) + audio = np.concatenate(audios) if audios else np.zeros(1, dtype=np.float32) + else: + audio, sr = self._synth_segment(text, language, speaker, speed) - with self._global_lock: - audio = model.tts_to_file( - text, speaker_id, output_path=None, speed=speed, quiet=True - ) - sr = model.hps.data.sampling_rate - - audio = np.asarray(audio, dtype=np.float32) if abs(pitch) > 1e-3 and librosa is not None: audio = librosa.effects.pitch_shift(audio, sr, n_steps=pitch) diff --git a/frontend/app.js b/frontend/app.js index 305efe1..68e469f 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -59,6 +59,7 @@ function populateLanguages() { opt.textContent = l.label; els.language.appendChild(opt); }); + if (eng.default_language) els.language.value = eng.default_language; populateSpeakers(); updateEngineInfo(); }