feat: 자동 언어 감지 4개 언어로 확장 (한/영/일/중)

- split_by_language: 가나/한자 CJK 처리(가나 있으면 JP, 없으면 ZH)
- JP/ZH 언어·화자 추가, AUTO 구간별 화자 매핑
- warmup에 JP/ZH 모델 사전 다운로드 추가
This commit is contained in:
claude
2026-08-24 12:45:31 +09:00
parent 7728d28d57
commit 9fe1a5479d
3 changed files with 61 additions and 33 deletions

View File

@@ -41,7 +41,7 @@ RUN pip uninstall -y triton 2>/dev/null || true \
&& find /usr/local/lib/python3.11/site-packages -name "__pycache__" -type d -prune -exec rm -rf {} + 2>/dev/null || true && find /usr/local/lib/python3.11/site-packages -name "__pycache__" -type d -prune -exec rm -rf {} + 2>/dev/null || true
# 슬림화 후에도 한/영 모델이 정상 로드되는지 검증 # 슬림화 후에도 한/영 모델이 정상 로드되는지 검증
RUN python -c "from melo.api import TTS; TTS('KR', device='cpu'); TTS('EN', device='cpu'); print('slim import ok')" RUN python -c "from melo.api import TTS; [TTS(l, device='cpu') for l in ('KR','EN','JP','ZH')]; print('slim import ok')"
# ---------- 런타임 단계 ---------- # ---------- 런타임 단계 ----------
FROM python:3.11-slim AS runtime FROM python:3.11-slim AS runtime

View File

@@ -32,57 +32,79 @@ EN_SPEAKERS = [
{"id": "EN-Default", "label": "영어 · 기본"}, {"id": "EN-Default", "label": "영어 · 기본"},
] ]
KR_SPEAKERS = [{"id": "KR", "label": "한국어 기본 목소리"}] KR_SPEAKERS = [{"id": "KR", "label": "한국어 기본 목소리"}]
JP_SPEAKERS = [{"id": "JP", "label": "일본어 기본 목소리"}]
ZH_SPEAKERS = [{"id": "ZH", "label": "중국어 기본 목소리"}]
# 실제 MeloTTS 언어 코드 # 실제 MeloTTS 언어 코드 (한/영/일/중)
MELO_LANG = {"KR": "KR", "EN": "EN"} MELO_LANG = {"KR": "KR", "EN": "EN", "JP": "JP", "ZH": "ZH"}
DEFAULT_LANG = "EN" # 판별 불가 구간의 기본 언어(사용자 요청: 기본값 영어) DEFAULT_LANG = "EN" # 판별 불가 구간의 기본 언어
_KR_RE = re.compile(r"[가-힣ᄀ-ᇿ㄰-㆏]") _KR_RE = re.compile(r"[가-힣ᄀ-ᇿ㄰-㆏]")
_EN_RE = re.compile(r"[A-Za-z]") _EN_RE = re.compile(r"[A-Za-z]")
# 일본어 가나(히라가나/가타카나/반각 가타카나)
_KANA_RE = re.compile(r"[぀-ヿヲ-ン]")
# 한자(CJK 통합 한자) — 일본어/중국어 공유라 단독으로는 모호
_HAN_RE = re.compile(r"[㐀-䶿一-鿿豈-﫿]")
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]]: def _char_group(ch: str) -> str | None:
"""텍스트를 한국어/영어 구간으로 분리. """문자를 그룹으로 분류: KR / EN / CJK(가나+한자) / None(중립)."""
if _KR_RE.match(ch):
return "KR"
if _KANA_RE.match(ch) or _HAN_RE.match(ch):
return "CJK"
if _EN_RE.match(ch):
return "EN"
return None
숫자·기호·공백 등 언어 판별이 안 되는 문자는 인접(진행 중) 구간에 붙인다.
전체가 판별 불가면 default_lang 으로 처리한다. def split_by_language(text: str, default_lang: str = DEFAULT_LANG) -> list[tuple[str, str]]:
"""입력을 언어별(한/영/일/중) 구간으로 분리.
- 한글→KR, 라틴문자→EN
- 가나+한자는 하나의 CJK 구간으로 묶은 뒤, 가나가 있으면 일본어(JP), 없으면 중국어(ZH)로 판정
- 숫자·기호·공백 등 중립 문자는 진행 중 구간에 붙임
- 전체가 판별 불가면 default_lang(기본 영어)
""" """
segments: list[tuple[str, str]] = [] raw: list[tuple[str, str]] = []
cur_lang: str | None = None cur_group: str | None = None
cur: list[str] = [] cur: list[str] = []
for ch in text: for ch in text:
if _KR_RE.match(ch): g = _char_group(ch)
kind = "KR" if g is None:
elif _EN_RE.match(ch):
kind = "EN"
else:
kind = None # 중립 문자
if kind is None:
cur.append(ch) cur.append(ch)
continue continue
if cur_lang is None: if cur_group is None:
cur_lang = kind cur_group = g
cur.append(ch) cur.append(ch)
elif kind == cur_lang: elif g == cur_group:
cur.append(ch) cur.append(ch)
else: else:
segments.append((cur_lang, "".join(cur))) raw.append((cur_group, "".join(cur)))
cur_lang = kind cur_group = g
cur = [ch] cur = [ch]
if cur: if cur:
lang = cur_lang if cur_lang is not None else default_lang raw.append((cur_group if cur_group is not None else "DEFAULT", "".join(cur)))
segments.append((lang, "".join(cur)))
# 실제 발음 가능한(문자 포함) 구간만 유지 out: list[tuple[str, str]] = []
return [(l, t) for l, t in segments if t.strip()] for grp, seg in raw:
if not seg.strip():
continue
if grp == "CJK":
lang = "JP" if _KANA_RE.search(seg) else "ZH"
elif grp in ("KR", "EN"):
lang = grp
else:
lang = default_lang
out.append((lang, seg))
return out
class MeloEngine(BaseEngine): class MeloEngine(BaseEngine):
@@ -90,7 +112,7 @@ class MeloEngine(BaseEngine):
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()
@@ -123,11 +145,13 @@ class MeloEngine(BaseEngine):
languages = [ languages = [
{ {
"code": "AUTO", "code": "AUTO",
"label": "자동 감지 (한국어+영어)", "label": "자동 감지 (한/영/일/중)",
"speakers": EN_SPEAKERS, # 영어 구간에 쓸 억양 선택 (한국어는 자동) "speakers": EN_SPEAKERS, # 영어 구간에 쓸 억양 선택 (그 외 언어는 자동)
}, },
{"code": "EN", "label": "English (영어)", "speakers": EN_SPEAKERS}, {"code": "EN", "label": "English (영어)", "speakers": EN_SPEAKERS},
{"code": "KR", "label": "한국어", "speakers": KR_SPEAKERS}, {"code": "KR", "label": "한국어", "speakers": KR_SPEAKERS},
{"code": "JP", "label": "日本語 (일본어)", "speakers": JP_SPEAKERS},
{"code": "ZH", "label": "中文 (중국어)", "speakers": ZH_SPEAKERS},
] ]
return { return {
"id": self.id, "id": self.id,
@@ -144,8 +168,9 @@ class MeloEngine(BaseEngine):
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 = "KR" if lang == "KR" else "EN-US" default_by_lang = {"KR": "KR", "EN": "EN-US", "JP": "JP", "ZH": "ZH"}
if default_id in spk2id: default_id = default_by_lang.get(lang)
if default_id and default_id in spk2id:
return spk2id[default_id] return spk2id[default_id]
return list(spk2id.values())[0] return list(spk2id.values())[0]
@@ -181,6 +206,7 @@ class MeloEngine(BaseEngine):
if language == "AUTO": if language == "AUTO":
# 영어 구간엔 사용자가 고른 억양, 한국어 구간엔 KR 음성 # 영어 구간엔 사용자가 고른 억양, 한국어 구간엔 KR 음성
en_speaker = speaker if (speaker and speaker.startswith("EN")) else "EN-US" en_speaker = speaker if (speaker and speaker.startswith("EN")) else "EN-US"
spk_by_lang = {"KR": "KR", "EN": en_speaker, "JP": "JP", "ZH": "ZH"}
parts = split_by_language(text, default_lang=DEFAULT_LANG) parts = split_by_language(text, default_lang=DEFAULT_LANG)
if not parts: if not parts:
parts = [(DEFAULT_LANG, text)] parts = [(DEFAULT_LANG, text)]
@@ -188,7 +214,7 @@ class MeloEngine(BaseEngine):
sr = None sr = None
gap = None gap = None
for seg_lang, seg_text in parts: for seg_lang, seg_text in parts:
seg_spk = "KR" if seg_lang == "KR" else en_speaker seg_spk = spk_by_lang.get(seg_lang, en_speaker)
audio, seg_sr = self._synth_segment(seg_text, seg_lang, seg_spk, speed) audio, seg_sr = self._synth_segment(seg_text, seg_lang, seg_spk, speed)
if sr is None: if sr is None:
sr = seg_sr sr = seg_sr

View File

@@ -19,6 +19,8 @@ from melo.api import TTS
SAMPLES = { SAMPLES = {
"KR": "안녕하세요. 준비가 완료되었습니다.", "KR": "안녕하세요. 준비가 완료되었습니다.",
"EN": "Hello, the model is ready.", "EN": "Hello, the model is ready.",
"JP": "こんにちは。準備ができました。",
"ZH": "你好,准备好了。",
} }
for lang, text in SAMPLES.items(): for lang, text in SAMPLES.items():