개발/AI

한국어 TTS가 숫자를 못 읽는다: docx→음성 전처리·청킹 실전 코드

goldtagworks 2026. 7. 14. 15:29

 

처음 docx를 그대로 모델에 넣고 돌렸을 때, "3,250원"이 "삼천이백오십 원"이 아니라 그냥 뭉개진 소리로 나왔다. 영어권 모델에게 한글 숫자·기호는 낯선 손님이라 그렇다. 긴 글은 또 다른 문제였다. 문서 한 장을 통째로 넣으니 뒤쪽이 잘려 나가거나 발음이 무너졌다. 모델마다 한 번에 받아들이는 길이에 한계가 있어서다.

1편에서 그린 지도를 기억할 것이다. 문서에서 텍스트를 뽑고(①), 한국어를 다듬어 자르고(②), 내 목소리로 청크마다 합성한다(④). 이번 편은 그 ①②④를 실제 코드로 옮긴다. 그대로 복사해서 돌리면 된다. 환경 세팅은 1편의 준비물(Python 3.10~3.11, ffmpeg)을 전제한다.

① 문서에서 텍스트만 뽑기

docx·pdf·pptx는 포맷이 제각각이라 라이브러리도 다르다. 입문자가 가장 많이 쓰는 세 가지만 정리한다.

pip install python-docx pypdf python-pptx
from pathlib import Path

def extract_docx(path: str) -> str:
    from docx import Document
    doc = Document(path)
    # 빈 문단은 버리고, 표 안의 텍스트도 함께 긁는다
    parts = [p.text.strip() for p in doc.paragraphs if p.text.strip()]
    for table in doc.tables:
        for row in table.rows:
            for cell in row.cells:
                if cell.text.strip():
                    parts.append(cell.text.strip())
    return "\n".join(parts)

def extract_pdf(path: str) -> str:
    from pypdf import PdfReader
    reader = PdfReader(path)
    parts = []
    for page in reader.pages:
        text = (page.extract_text() or "").strip()
        if text:
            parts.append(text)
    return "\n".join(parts)

def extract_pptx(path: str) -> str:
    from pptx import Presentation
    prs = Presentation(path)
    parts = []
    for slide in prs.slides:
        for shape in slide.shapes:
            if shape.has_text_frame and shape.text_frame.text.strip():
                parts.append(shape.text_frame.text.strip())
    return "\n".join(parts)

def extract_text(path: str) -> str:
    ext = Path(path).suffix.lower()
    dispatch = {".docx": extract_docx, ".pdf": extract_pdf, ".pptx": extract_pptx}
    if ext not in dispatch:
        raise ValueError(f"지원하지 않는 형식입니다: {ext}")
    text = dispatch[ext](path).strip()
    if not text:
        raise ValueError("추출된 텍스트가 비어 있습니다. 스캔 PDF라면 OCR이 필요합니다.")
    return text

여기서 입문자가 자주 밟는 지뢰 두 개. 첫째, 스캔한 PDF는 이미지라 extract_text()가 빈 문자열을 준다. 그래서 위에서 "비어 있으면 에러"로 막아뒀다(OCR은 별도 주제라 이 시리즈 밖). 둘째, 표·머리말·페이지 번호 같은 잡음이 딸려 들어온다. 낭독에 방해되면 이 단계에서 정규식으로 걷어내는 게 낫다. 소리로 만든 뒤에는 못 지운다.

② 한국어 전처리: 숫자를 소리로 바꾸기

핵심은 숫자다. "2025년 3월 15일", "3,250원", "1,200명" 같은 표기를 모델에 넘기기 전에 읽는 대로 풀어써야 한다. 아래는 한자어 수사(삼천이백오십 식) 기준의 최소 구현이다.

import re

_UNITS = ["", "십", "백", "천"]
_BIG = ["", "만", "억", "조"]
_DIGITS = ["영", "일", "이", "삼", "사", "오", "육", "칠", "팔", "구"]

def _read_four(n: int) -> str:
    """0~9999를 한자어로. 예: 3250 -> 삼천이백오십"""
    s = ""
    for i in range(3, -1, -1):
        d = (n // (10 ** i)) % 10
        if d == 0:
            continue
        # 10, 100, 1000 앞의 '일'은 생략 (일십->십)
        s += ("" if d == 1 and i > 0 else _DIGITS[d]) + _UNITS[i]
    return s

def number_to_korean(n: int) -> str:
    if n == 0:
        return "영"
    sign = "마이너스 " if n < 0 else ""
    n = abs(n)
    chunks = []
    idx = 0
    while n > 0:
        part = n % 10000
        if part:
            chunks.append(_read_four(part) + _BIG[idx])
        n //= 10000
        idx += 1
    return sign + "".join(reversed(chunks))

def normalize_korean(text: str) -> str:
    # 1) 천 단위 콤마 제거: 3,250 -> 3250
    text = re.sub(r"(?<=\d),(?=\d{3})", "", text)
    # 2) 숫자를 한글 수사로 치환
    text = re.sub(r"\d+", lambda m: number_to_korean(int(m.group())), text)
    # 3) 자주 나오는 기호 다듬기
    text = text.replace("%", " 퍼센트").replace("&", " 그리고 ")
    # 4) 공백 정리
    return re.sub(r"\s+", " ", text).strip()
>>> normalize_korean("3,250원짜리 커피를 12잔 마셨다")
'삼천이백오십원짜리 커피를 십이잔 마셨다'

정직하게 짚자면 이건 시작점이지 완성이 아니다. 한국어 숫자는 맥락에 따라 **고유어(하나, 둘, 열)와 한자어(일, 이, 십)**를 오간다. "3개"는 "세 개", "3월"은 "삼월"이다. 이 구분, 날짜·전화번호·소수점·단위(kg, km)까지 제대로 하려면 규칙이 꽤 불어난다. 실무에서 진지하게 갈 거면 g2pk 같은 한국어 G2P(grapheme-to-phoneme, 표기를 발음으로) 라이브러리를 얹는 걸 권한다. 위 코드는 "왜 전처리가 필요한지"를 손으로 이해하는 용도다.

정규화 순서도 중요하다. 콤마 제거를 숫자 치환보다 먼저 해야 "3,250"이 "3"과 "250"으로 쪼개지지 않는다. 순서 하나 틀리면 결과가 조용히 망가진다.

③ 청킹: 긴 글을 자르되, 아무 데서나 자르지 않기

모델은 한 번에 받는 길이에 한계가 있다. 그래서 긴 글을 덩어리(청크)로 나눈다. 이때 원칙은 하나다. 의미 단위 경계에서 자른다. 문장 한가운데를 끊으면 억양이 부자연스러워지므로, 문단을 먼저 나누고 문단이 너무 길면 문장 단위로 쪼갠다(계층 청킹).

def chunk_text(text: str, max_chars: int = 300) -> list[str]:
    """문단 -> 문장 순으로 나누되, 청크는 되도록 크게 유지한다."""
    chunks, buf = [], ""
    for para in text.split("\n"):
        para = para.strip()
        if not para:
            continue
        # 문장 경계(. ! ? 뒤 공백)로 분할, 종결부호는 살린다
        sentences = re.split(r"(?<=[.!?。])\s+", para)
        for sent in sentences:
            sent = sent.strip()
            if not sent:
                continue
            if len(buf) + len(sent) + 1 <= max_chars:
                buf = f"{buf} {sent}".strip()
            else:
                if buf:
                    chunks.append(buf)
                # 한 문장이 max_chars보다 길면 그대로 한 청크로 (자르면 더 어색)
                buf = sent
    if buf:
        chunks.append(buf)
    return chunks

max_chars는 모델·언어에 따라 조정한다. 작게 자를수록 안정적이지만 경계가 많아진다. 바로 이 지점이 1편에서 예고한 벽이다. 자른 조각은 각각 독립적으로 합성되기 때문에, 경계마다 목소리가 미세하게 튈 여지가 생긴다. 청크를 자르는 순간 seam(이음매)이 예약된다. 그래서 여기서는 "되도록 크게(경계를 줄이게)" 채우는 전략을 썼다. 이 트레이드오프의 결말은 3편에서 다룬다.

④ 청크별로 내 목소리로 합성하기

이제 청크마다 참조 클립(내 목소리) 음색으로 합성한다. 1편에서 "가장 쉬운 시작"으로 꼽은 XTTS-v2 예제다. 한국어를 네이티브로 지원하고 설치가 간단하다.

pip install coqui-tts
from pathlib import Path
from TTS.api import TTS

def synthesize_chunks(chunks: list[str], speaker_wav: str, out_dir: str) -> list[str]:
    Path(out_dir).mkdir(parents=True, exist_ok=True)
    # 최초 1회 가중치 다운로드 후 오프라인 동작
    tts = TTS("tts_models/multilingual/multi-dataset/xtts_v2")
    out_paths = []
    for i, text in enumerate(chunks):
        out = str(Path(out_dir) / f"chunk_{i:04d}.wav")
        tts.tts_to_file(
            text=text,
            speaker_wav=speaker_wav,   # 내 목소리 참조 클립 (3~6초 이상 권장)
            language="ko",
            file_path=out,
        )
        out_paths.append(out)
        print(f"[{i + 1}/{len(chunks)}] 합성 완료: {out}")
    return out_paths
if __name__ == "__main__":
    raw = extract_text("동화책.docx")
    clean = normalize_korean(raw)
    chunks = chunk_text(clean, max_chars=300)
    print(f"총 {len(chunks)}개 청크")
    synthesize_chunks(chunks, speaker_wav="my_voice.wav", out_dir="out")

주의할 점 몇 가지. XTTS는 애플 실리콘 MPS에서 이슈가 있어 CPU로 도는 경우가 있고, 그러면 느리다(1편 "속도" 한계 참고). 그리고 speaker_wav는 개인 생체정보이니 커밋·업로드하지 않는다. 이 두 가지는 1편에서 못 박은 제약 그대로다.

여기까지의 결과, 그리고 3편

이제 out/ 폴더에 chunk_0000.wav, chunk_0001.wav ... 청크별 오디오가 쌓였다. 하나씩 들어보면 놀랍게도 내 목소리다. 문제는 다음이다. 이걸 순서대로 이어 붙이면 하나의 낭독이 되어야 하는데, 경계에서 목소리가 살짝 어긋난다. 방금 전까지 나였던 목소리가 다음 청크에서 아주 약간 다른 사람이 된다.

3편에서 이 조각들을 ffmpeg 크로스페이드와 음량 정규화로 이어 붙이고, 그럼에도 끝내 남는 청크 경계 seam을 정면으로 다룬다. 원류 프로젝트 castforge가 결국 자기 목소리 클로닝을 포기하게 만든, 그 벽 이야기다.