feat: MeloTTS 자동 언어 감지(한/영 혼합 분리 합성) + CosyVoice 비활성화
- 기본 MeloTTS 단일 엔진만 노출(사용자 요청) - AUTO 모드: 입력을 언어별로 분리해 각 언어 음성으로 합성 후 이어붙임 - 판별 불가 구간 기본 언어 = 영어(기본값 영어) - 기본 선택 언어 AUTO(default_language), 프론트 반영 - Dockerfile: CosyVoice 빌드 제거로 이미지 경량화
This commit is contained in:
30
Dockerfile
30
Dockerfile
@@ -34,32 +34,6 @@ COPY warmup.py ./
|
|||||||
# 한국어/영어 모델 및 부가 데이터 사전 다운로드 (첫 요청 지연 제거)
|
# 한국어/영어 모델 및 부가 데이터 사전 다운로드 (첫 요청 지연 제거)
|
||||||
RUN python warmup.py
|
RUN python warmup.py
|
||||||
|
|
||||||
# ================= CosyVoice 엔진 (한국어, Apache-2.0) =================
|
# 기본 MeloTTS 단일 엔진 (자동 언어 감지: 한/영 혼합)
|
||||||
# 전용 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
|
|
||||||
|
|
||||||
EXPOSE 8788
|
EXPOSE 8788
|
||||||
CMD ["/entrypoint.sh"]
|
CMD ["uvicorn", "app.server:app", "--host", "0.0.0.0", "--port", "8788"]
|
||||||
|
|||||||
@@ -31,15 +31,7 @@ def _build() -> None:
|
|||||||
except Exception as exc: # pragma: no cover
|
except Exception as exc: # pragma: no cover
|
||||||
print(f"[registry] MeloTTS 로드 실패: {exc}")
|
print(f"[registry] MeloTTS 로드 실패: {exc}")
|
||||||
|
|
||||||
# CosyVoice 한국어 (전용 워커 URL 이 설정된 경우에만)
|
# (CosyVoice 엔진은 사용자 요청에 따라 비활성화 — 기본 MeloTTS만 유지)
|
||||||
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}")
|
|
||||||
|
|
||||||
_INITED = True
|
_INITED = True
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
"""MeloTTS 엔진 (in-process). 한국어/영어, GPU 자동 사용, 속도/피치 지원."""
|
"""MeloTTS 엔진 (in-process).
|
||||||
|
|
||||||
|
- 한국어/영어 지원, GPU 자동 사용, 속도/피치 조절
|
||||||
|
- 자동 모드(AUTO): 입력 텍스트를 언어별로 잘라 각 언어에 맞는 음성으로 합성 후 이어붙임
|
||||||
|
- 판별 불가(숫자/기호만 등) 구간의 기본 언어는 영어
|
||||||
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import io
|
import io
|
||||||
|
import re
|
||||||
import threading
|
import threading
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
@@ -17,94 +23,144 @@ from melo.api import TTS
|
|||||||
|
|
||||||
from .base import BaseEngine
|
from .base import BaseEngine
|
||||||
|
|
||||||
# 화면에 노출할 언어/화자 정의 (중국어/일본어는 제외)
|
# 영어 화자(억양) 목록
|
||||||
VOICES: dict[str, dict] = {
|
EN_SPEAKERS = [
|
||||||
"KR": {
|
|
||||||
"label": "한국어",
|
|
||||||
"melo_lang": "KR",
|
|
||||||
"speakers": [
|
|
||||||
{"id": "KR", "label": "한국어 기본 목소리"},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
"EN": {
|
|
||||||
"label": "English (영어)",
|
|
||||||
"melo_lang": "EN",
|
|
||||||
"speakers": [
|
|
||||||
{"id": "EN-US", "label": "영어 · 미국식"},
|
{"id": "EN-US", "label": "영어 · 미국식"},
|
||||||
{"id": "EN-BR", "label": "영어 · 영국식"},
|
{"id": "EN-BR", "label": "영어 · 영국식"},
|
||||||
{"id": "EN_INDIA", "label": "영어 · 인도식"},
|
{"id": "EN_INDIA", "label": "영어 · 인도식"},
|
||||||
{"id": "EN-AU", "label": "영어 · 호주식"},
|
{"id": "EN-AU", "label": "영어 · 호주식"},
|
||||||
{"id": "EN-Default", "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:
|
def _pick_device() -> str:
|
||||||
return "cuda:0" if torch.cuda.is_available() else "cpu"
|
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):
|
class MeloEngine(BaseEngine):
|
||||||
id = "melo"
|
id = "melo"
|
||||||
label = "MeloTTS"
|
label = "MeloTTS"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
uses_gpu = True
|
uses_gpu = True
|
||||||
notes = "자연스러운 한국어/영어 음성. 속도·피치 조절 지원."
|
notes = "자동 언어 감지: 한국어/영어 혼합 텍스트를 언어별로 나눠 각 음성으로 합성. 기본 영어."
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.device = _pick_device()
|
self.device = _pick_device()
|
||||||
self._models: dict[str, TTS] = {}
|
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()
|
self._global_lock = threading.Lock()
|
||||||
|
|
||||||
# ---- 모델 로딩 -------------------------------------------------
|
# ---- 모델 로딩 -------------------------------------------------
|
||||||
def _get_model(self, language: str) -> TTS:
|
def _get_model(self, lang: str) -> TTS:
|
||||||
if language not in VOICES:
|
if lang not in MELO_LANG:
|
||||||
raise ValueError(f"지원하지 않는 언어입니다: {language}")
|
raise ValueError(f"지원하지 않는 언어입니다: {lang}")
|
||||||
model = self._models.get(language)
|
model = self._models.get(lang)
|
||||||
if model is not None:
|
if model is not None:
|
||||||
return model
|
return model
|
||||||
with self._locks[language]:
|
with self._locks[lang]:
|
||||||
model = self._models.get(language)
|
model = self._models.get(lang)
|
||||||
if model is None:
|
if model is None:
|
||||||
melo_lang = VOICES[language]["melo_lang"]
|
model = TTS(language=MELO_LANG[lang], device=self.device)
|
||||||
model = TTS(language=melo_lang, device=self.device)
|
self._models[lang] = model
|
||||||
self._models[language] = model
|
|
||||||
return model
|
return model
|
||||||
|
|
||||||
def warmup(self) -> None:
|
def warmup(self) -> None:
|
||||||
for lang in VOICES:
|
for lang in MELO_LANG:
|
||||||
try:
|
try:
|
||||||
self._get_model(lang)
|
self._get_model(lang)
|
||||||
except Exception as exc: # pragma: no cover
|
except Exception as exc: # pragma: no cover
|
||||||
print(f"[melo warmup] {lang} 모델 로드 실패: {exc}")
|
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:
|
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 {
|
return {
|
||||||
"id": self.id,
|
"id": self.id,
|
||||||
"label": self.label,
|
"label": self.label,
|
||||||
"license": self.license,
|
"license": self.license,
|
||||||
"uses_gpu": self.uses_gpu,
|
"uses_gpu": self.uses_gpu,
|
||||||
"notes": self.notes,
|
"notes": self.notes,
|
||||||
"languages": self._list_languages(),
|
"languages": languages,
|
||||||
|
"default_language": "AUTO",
|
||||||
"supports": {"speed": True, "pitch": True},
|
"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
|
spk2id = model.hps.data.spk2id
|
||||||
if speaker and speaker in spk2id:
|
if speaker and speaker in spk2id:
|
||||||
return spk2id[speaker]
|
return spk2id[speaker]
|
||||||
default_id = VOICES[language]["speakers"][0]["id"]
|
default_id = "KR" if lang == "KR" else "EN-US"
|
||||||
if default_id in spk2id:
|
if default_id in spk2id:
|
||||||
return spk2id[default_id]
|
return spk2id[default_id]
|
||||||
return list(spk2id.values())[0]
|
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(
|
def synth_wav(
|
||||||
self,
|
self,
|
||||||
@@ -120,17 +176,35 @@ class MeloEngine(BaseEngine):
|
|||||||
|
|
||||||
speed = float(max(0.5, min(2.0, speed)))
|
speed = float(max(0.5, min(2.0, speed)))
|
||||||
pitch = float(max(-12.0, min(12.0, pitch)))
|
pitch = float(max(-12.0, min(12.0, pitch)))
|
||||||
|
language = (language or "AUTO").upper()
|
||||||
|
|
||||||
model = self._get_model(language)
|
if language == "AUTO":
|
||||||
speaker_id = self._resolve_speaker(model, language, speaker)
|
# 영어 구간엔 사용자가 고른 억양, 한국어 구간엔 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:
|
if abs(pitch) > 1e-3 and librosa is not None:
|
||||||
audio = librosa.effects.pitch_shift(audio, sr, n_steps=pitch)
|
audio = librosa.effects.pitch_shift(audio, sr, n_steps=pitch)
|
||||||
|
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ function populateLanguages() {
|
|||||||
opt.textContent = l.label;
|
opt.textContent = l.label;
|
||||||
els.language.appendChild(opt);
|
els.language.appendChild(opt);
|
||||||
});
|
});
|
||||||
|
if (eng.default_language) els.language.value = eng.default_language;
|
||||||
populateSpeakers();
|
populateSpeakers();
|
||||||
updateEngineInfo();
|
updateEngineInfo();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user