"""TTS 텍스트 전처리/WAV 병합 유틸리티 (legacy_gradio.py 이식).""" from __future__ import annotations import re import tempfile import wave from pathlib import Path # 멘트/REST 경로(enqueue_text)의 텍스트 청크 분할 기준 (한국어 글자수). # split_text 는 이 길이까지 문장을 모아 한 블록으로 합성하는데, 블록 하나는 워커 # 생성 토큰 상한(TTS_MAX_NEW_TOKENS=2048 ≈ ~19s 오디오)을 넘으면 끝이 잘린다. # 한국어 차분한 멘트는 ~0.2s/글자 → 100자 ≈ ~22s 로 상한을 초과해, 3문장짜리 인사 # (예: "…보름이의 사인 코너와 함께합니다")의 마지막 문장이 합성에서 통째로 잘리던 # 회귀(사용자 보고 2026-06-29, /api/tts/generate 측정으로 19.3s 컷 확인). 70자 ≈ # ~15s 로 상한 아래 여유를 두면 긴 멘트가 2+ 블록으로 쪼개져 합성→merge 로 완창된다. # (자유대화 chat 은 enqueue_chunk 별도 경로라 이 값 무관.) # 참고: scaleout-cosyvoice image 는 worker packing 폐기(2026-05-22) 로 vLLM # max_model_len 상한을 제거(업스트림 default ~32K)했으므로 이 값과의 결합 제약은 # 해소됨. 단, scaleout-dual image 는 여전히 max_model_len=2048 packing patch 를 # 유지하므로 dual 운영 시 CHUNK_SIZE 를 크게 키우면 토큰 초과 가능 — 상세는 # doc/runbook/tts_engine_multi.md. CHUNK_SIZE_DEFAULT = 70 def clean_for_speech(text: str) -> str: """마크다운/URL/특수문자·섹션 라벨을 제거해 TTS 입력에 적합하게 정제. LLM 이 규칙을 어기고 `**1) 상황 설명:**` 같은 구조적 마크업을 넣어도 TTS 로 이상하게 들리지 않게 클린업. 프롬프트 엔지니어링 + 이 후처리 2중으로 방어. CosyVoice 이벤트 마커 14종(req_tts_event_markers)은 **정확 일치 round-trip 보존** — 아래 기호 제거(step 6/7)가 `[laughter]`→"laughter" 로 벗겨 영단어로 낭독되는 사고를 막는다. 실제 발화/제거는 엔진 확정 dispatch 에서 결정(tts_markers.prepare_text_for_engine). ⛔ 임의 괄호는 지금처럼 제거(LLM 마크업 방어 불변) — 14종 정확 일치만 통과. """ from tts_markers import protect_markers, restore_markers text, _saved_markers = protect_markers(text) _section_labels = ( "상황|설명|해석|예시|정답|질문|답변|힌트|주의|참고|비고|팁|요약|결론|" "소개|대사|멘트|이름|나이|직업|성별|배경|" "한국어\\s*상황\\s*설명|한국어\\s*해석|한국어\\s*뜻|" "영어\\s*문장|영어\\s*표현|" "Q|A|q|a|Tip|TIP|Note|note|Hint|hint|Example|example" ) # 1) URL 먼저 제거. text = re.sub(r"https?://\S+", "", text) # 2) 마크다운 강조/코드/헤더/인용 기호 먼저 — 이래야 `**1) 상황:**` 에서 번호·라벨 규칙이 동작. text = re.sub(r"\*\*+", "", text) text = re.sub(r"__+", "", text) text = re.sub(r"(?m)^[ \t]*[#]{1,6}[ \t]+", "", text) text = re.sub(r"(?m)^[ \t]*>[ \t]+", "", text) # 3) 줄 시작의 불릿 / 번호 라벨 / 원문자 제거. text = re.sub(r"(?m)^[ \t]*[-*•][ \t]+", "", text) text = re.sub(r"(?m)^[ \t]*\(?\d+\s*[.))][ \t]*", "", text) text = re.sub(r"(?m)^[ \t]*[①-⑳⒈-⒛]\s*", "", text) # 4) 줄 시작 + 라인 중간 모두에서 섹션 라벨 (`상황:`, `Q:`, `한국어 해석:`) 제거. # 줄 시작이 가장 일반적인 패턴, 줄 중간이더라도 단독 라벨 뒤 콜론이면 TTS 가 이상하게 읽음. label_re = rf"(?:{_section_labels})[ \t]*[::]" text = re.sub(rf"(?m)^[ \t]*{label_re}[ \t]*", "", text) # 문장 중간에 " 상황: " 형태로 나와도 제거 — 공백 · 문장 시작 뒤에 오는 경우. text = re.sub(rf"(?:(?<=\s)|(?<=[.!?。]\s)){label_re}[ \t]*", "", text) # 5) 느낌표 제거. TTS 가 과한 흥분/외침으로 읽는 것을 방지. # chat / Voice Moments / 즐겨찾기 재생성 등 모든 경로가 이 함수를 거치므로 # 한 번에 일괄 적용됨. # — 인접한 .?, .? 가 있으면 그냥 삭제 (?!. → ?) # — 그 외엔 마침표로 치환 (응원할게! → 응원할게.) — 문장경계 보존용. text = re.sub(r"[!!]+(?=[.?])", "", text) text = re.sub(r"(?<=[.?])[!!]+", "", text) text = re.sub(r"[!!]+", ".", text) # 6) 남은 기타 기호 제거. text = re.sub(r"[*_`#~\[\](){}>|]", "", text) text = re.sub(r"[^\w\s가-힣a-zA-Z0-9.,?·\-:;]", "", text) # 7) 빈 줄 과도 중첩 → 단일 줄바꿈, 연속 공백 → 단일 공백. text = re.sub(r"\n{2,}", "\n", text) text = re.sub(r"[ \t]{2,}", " ", text) # 8) 보호했던 이벤트 마커 복원 (round-trip). text = restore_markers(text, _saved_markers) return text.strip() def split_text(text: str, max_len: int = CHUNK_SIZE_DEFAULT) -> list[str]: """문장 경계를 우선한 텍스트 청크 분할. 이벤트 마커 paired 스팬(…·…)은 청크 경계에서 찢기지 않게 강제 수용(req_tts_event_markers P1.2) — 여는 태그만 있고 닫는 태그가 없는 버퍼는 max_len 을 넘겨도 flush 하지 않고 스팬이 닫힐 때까지 누적한다. """ from tts_markers import PAIRED_MARKERS clean = clean_for_speech(text) if not clean: return [] def _has_open_paired(s: str) -> bool: # 여는 태그 수 > 닫는 태그 수 = 아직 안 닫힌 스팬 → 자르면 안 됨. return any( s.count(open_t) > s.count(close_t) for open_t, close_t in PAIRED_MARKERS.values() ) sentences = re.split(r"(?<=[.!?。])\s*", clean) sentences = [s.strip() for s in sentences if s.strip()] chunks: list[str] = [] buf = "" for sent in sentences: if buf and len(buf) + len(sent) > max_len and not _has_open_paired(buf): chunks.append(buf.strip()) buf = sent else: buf = (buf + " " + sent) if buf else sent if buf.strip(): chunks.append(buf.strip()) if not chunks and clean: # 문장 경계가 전혀 없는 장문 폴백 — paired 스팬이 있으면 통째 유지(태그 분리 금지). if _has_open_paired(clean) or PAIRED_MARKERS and any( o in clean for o, _c in PAIRED_MARKERS.values()): chunks = [clean] else: chunks = [clean[i : i + max_len] for i in range(0, len(clean), max_len)] return chunks def find_last_sentence_boundary(text: str) -> int: """마지막 문장 끝 인덱스(+1). 없으면 -1.""" last = -1 for m in re.finditer(r"[.!?。~](?=\s|$)", text): last = m.end() return last # --- 한국어 숫자 읽기 정규화 (TTS) ------------------------------------------- # CosyVoice/Qwen3 의 내장 텍스트 정규화(TN)는 한국어 숫자를 못 읽음(중/영 전용) → # 한글+아라비아숫자는 영어로 읽힘("24.6"→"twenty four point six"). 합성 전에 숫자를 # 한글로 풀어써서 보낸다(엔진 무관). 문맥별 읽기: 시각=고유어(네 시), 분/일/월/년·일반 # =한자어, 소수=정수+점+자릿수, 월=유월/시월 불규칙. (라디오/멘트 합성 경로에서 적용.) _SINO = "영일이삼사오육칠팔구" _SINO_SMALL = ["", "십", "백", "천"] # 일의 자리부터 _SINO_BIG = ["", "만", "억", "조", "경"] # 4자리 그룹 단위 _NATIVE_ONES = ["", "한", "두", "세", "네", "다섯", "여섯", "일곱", "여덟", "아홉"] _NATIVE_TENS = ["", "열", "스물", "서른", "마흔", "쉰", "예순", "일흔", "여든", "아흔"] _MONTHS = {1: "일", 2: "이", 3: "삼", 4: "사", 5: "오", 6: "유", 7: "칠", 8: "팔", 9: "구", 10: "시", 11: "십일", 12: "십이"} # 고유어로 세는 대표 단위. 그 외 단위(원·도·년·번 등)는 한자어(일반 정규화가 처리). # 'N번'은 한자어(교향곡 40번=사십 번, 3번 출구=삼 번)라 제외 — 고유어 '한 번/두 번'은 # 보통 한글로 쓰임. '대'도 N대=사십 대(연령) 등 한자어 빈도가 높아 제외. _NATIVE_COUNTERS = "개|명|살|마리|시간|군데|가지|차례|권|잔|채|그루|송이|벌|켤레|쪽" _NUM_RE = r"(\d{1,3}(?:,\d{3})+|\d+)" # 천단위 콤마 허용 def _sino_under_man(n: int) -> str: """0 str: """정수 → 한자어 읽기.""" if n == 0: return "영" if n < 0: return "마이너스 " + _sino(-n) groups = [] while n > 0: groups.append(n % 10000) n //= 10000 parts = [] for i in range(len(groups) - 1, -1, -1): g = groups[i] if g == 0: continue seg = _sino_under_man(g) if g == 1 and _SINO_BIG[i] == "만": seg = "" # 1만 = '만' (일만 생략, 관용) parts.append(seg + _SINO_BIG[i]) return "".join(parts) def _native(n: int): """1..99 → 고유어 관형형 (한·두·네·스무·스물한...). 범위 밖이면 None(→한자어 폴백).""" if n <= 0 or n > 99: return None tens, ones = divmod(n, 10) t = ("스무" if ones == 0 else "스물") if tens == 2 else _NATIVE_TENS[tens] return t + _NATIVE_ONES[ones] def _num_or_digits(s: str) -> str: """일반 정수 — 앞자리 0(전화·코드)이면 자릿수 하나씩(공일공), 아니면 한자어.""" raw = s.replace(",", "") if len(raw) > 1 and raw[0] == "0": return "".join("공" if c == "0" else _SINO[int(c)] for c in raw) return _sino(int(raw)) def normalize_korean_numbers(text: str) -> str: """아라비아 숫자를 한국어 읽기로 정규화. (TTS 가 숫자를 영어로 읽는 것 방지.) 예: "6월 17일 새벽 4시, FM 24.6" → "유월 십칠일 새벽 네 시, FM 이십사 점 육". 순서 중요: 소수→시간→시→월→일/년/분/초→고유어단위→퍼센트→일반정수. """ if not text or not any(c.isdigit() for c in text): return text n = _NUM_RE def _int(m): return int(m.group(1).replace(",", "")) def _hr(m): # 고유어 시각, 범위 밖이면 한자어 v = _int(m) return (_native(v) or _sino(v)) text = re.sub(rf"{n}\.(\d+)", lambda m: _sino(int(m.group(1).replace(",", ""))) + " 점 " + "".join(_SINO[int(c)] for c in m.group(2)), text) text = re.sub(rf"{n}\s*시간", lambda m: _hr(m) + " 시간", text) text = re.sub(rf"{n}\s*시", lambda m: _hr(m) + " 시", text) text = re.sub(rf"{n}\s*월", lambda m: (_MONTHS.get(_int(m), _sino(_int(m)))) + "월", text) text = re.sub(rf"{n}\s*일", lambda m: _sino(_int(m)) + "일", text) text = re.sub(rf"{n}\s*년", lambda m: _sino(_int(m)) + "년", text) text = re.sub(rf"{n}\s*분", lambda m: _sino(_int(m)) + " 분", text) text = re.sub(rf"{n}\s*초", lambda m: _sino(_int(m)) + " 초", text) text = re.sub(rf"{n}\s*({_NATIVE_COUNTERS})", lambda m: (_native(_int(m)) or _sino(_int(m))) + " " + m.group(2), text) text = re.sub(rf"{n}\s*%", lambda m: _sino(_int(m)) + " 퍼센트", text) text = re.sub(n, lambda m: _num_or_digits(m.group(1)), text) return text def concat_wav(paths: list[str | Path]) -> str | None: """WAV 파일들을 한 파일로 이어붙여 임시 경로 반환.""" paths = [str(p) for p in paths if p] if not paths: return None if len(paths) == 1: return paths[0] tmp = tempfile.NamedTemporaryFile(suffix=".wav", delete=False) with wave.open(paths[0], "rb") as first: params = first.getparams() with wave.open(tmp.name, "wb") as out: out.setparams(params) out.writeframes(first.readframes(first.getnframes())) for p in paths[1:]: with wave.open(p, "rb") as w: out.writeframes(w.readframes(w.getnframes())) return tmp.name