처음 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가 결국 자기 목소리 클로닝을 포기하게 만든, 그 벽 이야기다.
'개발 > AI' 카테고리의 다른 글
| 내 목소리로 문서를 읽어주는 로컬·무료 제로샷 TTS 설계 (0) | 2026.07.09 |
|---|---|
| 출시 3일 만에 사라진 Claude Fable 5, 최초의 AI 수출통제 사건 전말 (0) | 2026.07.06 |