talk / backend /utils.py
user
Deploy StyleVoiceChat backend
aa9892b
Raw History Blame Contribute Delete
12.7 kB
"""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 ์ŠคํŒฌ(<laughter>โ€ฆ</laughter>ยท<strong>โ€ฆ</strong>)์€ ์ฒญํฌ ๊ฒฝ๊ณ„์—์„œ
์ฐข๊ธฐ์ง€ ์•Š๊ฒŒ ๊ฐ•์ œ ์ˆ˜์šฉ(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<n<10000 โ†’ ํ•œ์ž์–ด (10=์‹ญ, 25=์ด์‹ญ์˜ค, 305=์‚ผ๋ฐฑ์˜ค)."""
out = []
s = str(n)
length = len(s)
for i, ch in enumerate(s):
d = int(ch)
pos = length - 1 - i # 0=์ผ,1=์‹ญ,2=๋ฐฑ,3=์ฒœ
if d == 0:
continue
if pos == 0:
out.append(_SINO[d])
elif d == 1:
out.append(_SINO_SMALL[pos]) # 10=์‹ญ (์ผ์‹ญ ์•„๋‹˜)
else:
out.append(_SINO[d] + _SINO_SMALL[pos])
return "".join(out)
def _sino(n: int) -> 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