From 9fe1a5479d9753e713d6604820c7e25139a27dd0 Mon Sep 17 00:00:00 2001 From: claude Date: Mon, 24 Aug 2026 12:45:31 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=9E=90=EB=8F=99=20=EC=96=B8=EC=96=B4?= =?UTF-8?q?=20=EA=B0=90=EC=A7=80=204=EA=B0=9C=20=EC=96=B8=EC=96=B4?= =?UTF-8?q?=EB=A1=9C=20=ED=99=95=EC=9E=A5=20(=ED=95=9C/=EC=98=81/=EC=9D=BC?= =?UTF-8?q?/=EC=A4=91)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - split_by_language: 가나/한자 CJK 처리(가나 있으면 JP, 없으면 ZH) - JP/ZH 언어·화자 추가, AUTO 구간별 화자 매핑 - warmup에 JP/ZH 모델 사전 다운로드 추가 --- Dockerfile | 2 +- app/engines/melo_engine.py | 90 ++++++++++++++++++++++++-------------- warmup.py | 2 + 3 files changed, 61 insertions(+), 33 deletions(-) diff --git a/Dockerfile b/Dockerfile index 20b0299..0806571 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 # 슬림화 후에도 한/영 모델이 정상 로드되는지 검증 -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 diff --git a/app/engines/melo_engine.py b/app/engines/melo_engine.py index 325d7a9..93fcc13 100644 --- a/app/engines/melo_engine.py +++ b/app/engines/melo_engine.py @@ -32,57 +32,79 @@ EN_SPEAKERS = [ {"id": "EN-Default", "label": "영어 · 기본"}, ] KR_SPEAKERS = [{"id": "KR", "label": "한국어 기본 목소리"}] +JP_SPEAKERS = [{"id": "JP", "label": "일본어 기본 목소리"}] +ZH_SPEAKERS = [{"id": "ZH", "label": "중국어 기본 목소리"}] -# 실제 MeloTTS 언어 코드 -MELO_LANG = {"KR": "KR", "EN": "EN"} +# 실제 MeloTTS 언어 코드 (한/영/일/중) +MELO_LANG = {"KR": "KR", "EN": "EN", "JP": "JP", "ZH": "ZH"} -DEFAULT_LANG = "EN" # 판별 불가 구간의 기본 언어(사용자 요청: 기본값 영어) +DEFAULT_LANG = "EN" # 판별 불가 구간의 기본 언어 _KR_RE = re.compile(r"[가-힣ᄀ-ᇿ㄰-㆏]") _EN_RE = re.compile(r"[A-Za-z]") +# 일본어 가나(히라가나/가타카나/반각 가타카나) +_KANA_RE = re.compile(r"[぀-ヿヲ-ン]") +# 한자(CJK 통합 한자) — 일본어/중국어 공유라 단독으로는 모호 +_HAN_RE = re.compile(r"[㐀-䶿一-鿿豈-﫿]") 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]]: - """텍스트를 한국어/영어 구간으로 분리. +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]] = [] - cur_lang: str | None = None + raw: list[tuple[str, str]] = [] + cur_group: 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: + g = _char_group(ch) + if g is None: cur.append(ch) continue - if cur_lang is None: - cur_lang = kind + if cur_group is None: + cur_group = g cur.append(ch) - elif kind == cur_lang: + elif g == cur_group: cur.append(ch) else: - segments.append((cur_lang, "".join(cur))) - cur_lang = kind + raw.append((cur_group, "".join(cur))) + cur_group = g cur = [ch] if cur: - lang = cur_lang if cur_lang is not None else default_lang - segments.append((lang, "".join(cur))) + raw.append((cur_group if cur_group is not None else "DEFAULT", "".join(cur))) - # 실제 발음 가능한(문자 포함) 구간만 유지 - return [(l, t) for l, t in segments if t.strip()] + out: list[tuple[str, str]] = [] + 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): @@ -90,7 +112,7 @@ class MeloEngine(BaseEngine): label = "MeloTTS" license = "MIT" uses_gpu = True - notes = "자동 언어 감지: 한국어/영어 혼합 텍스트를 언어별로 나눠 각 음성으로 합성. 기본 영어." + notes = "자동 언어 감지: 한국어/영어/일본어/중국어 혼합 텍스트를 언어별로 나눠 각 음성으로 합성. 기본 영어." def __init__(self) -> None: self.device = _pick_device() @@ -123,11 +145,13 @@ class MeloEngine(BaseEngine): languages = [ { "code": "AUTO", - "label": "자동 감지 (한국어+영어)", - "speakers": EN_SPEAKERS, # 영어 구간에 쓸 억양 선택 (한국어는 자동) + "label": "자동 감지 (한/영/일/중)", + "speakers": EN_SPEAKERS, # 영어 구간에 쓸 억양 선택 (그 외 언어는 자동) }, {"code": "EN", "label": "English (영어)", "speakers": EN_SPEAKERS}, {"code": "KR", "label": "한국어", "speakers": KR_SPEAKERS}, + {"code": "JP", "label": "日本語 (일본어)", "speakers": JP_SPEAKERS}, + {"code": "ZH", "label": "中文 (중국어)", "speakers": ZH_SPEAKERS}, ] return { "id": self.id, @@ -144,8 +168,9 @@ class MeloEngine(BaseEngine): spk2id = model.hps.data.spk2id if speaker and speaker in spk2id: return spk2id[speaker] - default_id = "KR" if lang == "KR" else "EN-US" - if default_id in spk2id: + default_by_lang = {"KR": "KR", "EN": "EN-US", "JP": "JP", "ZH": "ZH"} + default_id = default_by_lang.get(lang) + if default_id and default_id in spk2id: return spk2id[default_id] return list(spk2id.values())[0] @@ -181,6 +206,7 @@ class MeloEngine(BaseEngine): if language == "AUTO": # 영어 구간엔 사용자가 고른 억양, 한국어 구간엔 KR 음성 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) if not parts: parts = [(DEFAULT_LANG, text)] @@ -188,7 +214,7 @@ class MeloEngine(BaseEngine): sr = None gap = None 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) if sr is None: sr = seg_sr diff --git a/warmup.py b/warmup.py index a724f7c..4a1def4 100644 --- a/warmup.py +++ b/warmup.py @@ -19,6 +19,8 @@ from melo.api import TTS SAMPLES = { "KR": "안녕하세요. 준비가 완료되었습니다.", "EN": "Hello, the model is ready.", + "JP": "こんにちは。準備ができました。", + "ZH": "你好,准备好了。", } for lang, text in SAMPLES.items():