KG
목록으로 돌아가기
Side Project

2. TTS Generator

문서 정규화, 화자별 segment 처리, 비동기 Job 흐름으로 팟캐스트형 TTS를 구현한 과정을 정리한 회고입니다.

2026년 3월 26일 8분 읽기1581 단어
Side Project
회고
TTS Generator
OpenVoice

tts-generator 회고 2편 | 팟캐스트형 TTS를 실제로 어떻게 구현했는가

텍스트와 Markdown 문서를 같은 문서 모델로 정규화하고, 화자별 segment를 비동기 작업으로 생성한 뒤, pause를 넣어 하나의 오디오 파일로 합치는 방식으로 구현했다.

먼저 전체 흐름부터 보기

이번 프로젝트에서 중요한 것은 "입력을 바로 읽게 하지 않는다"는 점이다. 텍스트든 Markdown든 먼저 문서 구조로 바꾸고, 그 문서를 다시 여러 개의 segment로 나눈 다음, 마지막에 하나의 오디오 파일로 합친다.

전체 흐름을 간단히 적으면 이렇다.

  1. 사용자가 텍스트를 입력하거나 Markdown 파일을 업로드한다.
  2. 입력을 TTSDocumentTTSSegment 구조로 정규화한다.
  3. Job을 생성하고 백그라운드에서 segment별 음성을 만든다.
  4. segment 사이에 pause를 넣어 하나의 WAV로 병합한다.
  5. 필요하면 MP3로 변환하고 다운로드하게 한다.

이 흐름으로 만든 이유는 단순하다. 긴 문서를 한 번에 처리하면 실패 지점을 찾기 어렵고, 화자나 pause 같은 세부 제어도 어렵다. 반대로 segment 단위로 쪼개면 화자별 보이스, 문장 길이, pause, 진행률을 모두 따로 관리할 수 있다.

입력을 먼저 문서 모델로 정규화했다

텍스트 입력과 Markdown 업로드를 서로 다른 기능으로 다루기 시작하면 코드가 금방 갈라진다. 그래서 먼저 둘 다 같은 문서 모델로 모으는 쪽을 택했다.

현재 코드에서는 DocumentFactory가 이 역할을 맡고 있다.

def build_from_markdown(self, file_name: str, content: str) -> TTSDocument:
    parsed = self.markdown_parser.parse(content)
    options = DocumentOptions(
        engine=str(parsed.options.get("engine", self.settings.default_engine)),
        output_format=AudioFormat(str(parsed.options.get("output_format", self.settings.default_format))),
        default_voice=str(parsed.options.get("default_voice", self.settings.default_voice)),
        default_speed=float(parsed.options.get("default_speed", self.settings.default_speed)),
        default_style=str(parsed.options.get("default_style", self.settings.default_style)),
        default_mode=parsed.options.get("default_mode", self.settings.default_mode),
        normalize_spoken_text=bool(parsed.options.get("normalize_spoken_text", True)),
        sentence_split=bool(parsed.options.get("sentence_split", True)),
        pause_ms_line=int(parsed.options.get("pause_ms_line", self.settings.default_pause_ms_line)),
        pause_ms_paragraph=int(parsed.options.get("pause_ms_paragraph", self.settings.default_pause_ms_paragraph)),
    )

문서 입력 방식이 달라도 마지막에는 TTSDocument 하나로 수렴하게 만든 이유는 이후 파이프라인을 단순하게 유지하기 위해서다. 나중에 멀티 파일 병합을 붙이더라도, 결국은 같은 문서 모델로 모이면 된다.

여기서 한 번 더 중요한 단계가 있다. 문서 전체를 바로 읽지 않고 TTSSegment 목록으로 다시 나누는 부분이다. 화자, 속도, 보이스, pause를 segment마다 들고 가야 나중에 병합할 때 제어가 쉬워진다.

Markdown에 tts 블록을 넣어 화자별 설정을 받도록 했다

이번 프로젝트에서 가장 마음에 드는 부분 중 하나가 이 문법이다. 별도 설정 UI를 크게 만들지 않고도, Markdown 상단에 tts 블록을 두면 문서 안에 TTS 규칙까지 같이 적을 수 있다.

예를 들면 이런 식이다.

engine: melo
format: wav
voice.default: KR
speed.default: 1.0
mode.default: conversational
voice.진행자: KR
voice.보조화자: sample:my-voice
speed.보조화자: 1.1
pause_ms.line: 300
pause_ms.paragraph: 700

본문은 화자: 내용 형태로 적는다.

진행자: 오늘은 비동기 처리 구조를 정리해보겠습니다.
보조화자: 먼저 왜 Job 단위로 나눴는지부터 볼게요.

파서는 이 설정을 읽어 옵션과 화자 오버라이드로 분리한다.

if key.startswith("voice."):
    speaker = key.removeprefix("voice.")
    speaker_overrides.setdefault(speaker, {})["voice"] = value
    return
if key.startswith("speed."):
    speaker = key.removeprefix("speed.")
    speaker_overrides.setdefault(speaker, {})["speed"] = self._parse_speed(value, key)
    return
if key.startswith("mode."):
    speaker = key.removeprefix("mode.")
    speaker_overrides.setdefault(speaker, {})["mode"] = self._parse_mode(value)
    return

이 방식을 택하면서 좋았던 점은 문서와 음성 설정이 분리되지 않는다는 점이었다. 예를 들어 같은 공부 메모를 다시 음성으로 만들더라도, 어떤 화자가 어떤 보이스로 읽었는지 설정이 문서 안에 그대로 남는다.

대신 구현 난이도는 조금 올라갔다. 일반 Markdown 문법과 화자 문법이 충돌하지 않게 해야 했고, 리스트나 강조 문법을 만났을 때도 최대한 자연스럽게 읽도록 처리해야 했다.

듣기 좋은 텍스트로 바꾸는 전처리가 생각보다 중요했다

처음에는 Markdown에서 텍스트만 뽑으면 될 줄 알았다. 그런데 실제로 해보니, 읽기용 문서와 듣기용 문서는 생각보다 다르다.

제목 기호, 리스트 마커, 코드 블록, 표, 기호 문자 같은 것들이 그대로 남아 있으면 음성이 꽤 어색해진다. 그래서 별도의 전처리 단계를 두고 "읽는 텍스트"를 "듣는 텍스트"로 조금씩 바꾸는 작업을 넣었다.

if normalize_spoken_text:
    replacements = {
        "&": " 그리고 ",
        "%": " 퍼센트",
        "@": " 골뱅이 ",
        "…": ". ",
        "→": " 다음 ",
        "+": " 플러스 ",
        "=": " 이콜 ",
        "_": " ",
    }

여기서 느낀 점은 Markdown를 그대로 TTS에 넣는 것과, 듣기 좋은 문장으로 조금 손본 뒤 넣는 것의 차이가 꽤 크다는 점이었다. 특히 공부 메모처럼 기호와 리스트가 많은 문서는 전처리의 체감 효과가 컸다.

이 부분은 아직도 완벽하다고 보기는 어렵다. 코드 블록을 완전히 무시할지, 일부는 설명용으로 읽게 할지 같은 기준은 계속 다듬을 수 있다. 그래도 "문서를 읽는 것"과 "문서를 말하게 하는 것"은 다르다는 점을 이번에 분명히 배웠다.

긴 작업은 Job으로 분리하고 백그라운드에서 처리했다

이번 프로젝트에서 구현적으로 가장 중요한 선택은 Job 구조였다. 긴 문서를 처리하는 동안 요청을 붙잡고 있으면 브라우저 입장에서는 멈춘 것처럼 보인다. 그래서 작업 생성과 실제 음성 생성을 분리했다.

현재 JobService는 Job을 만든 뒤 asyncio.create_task로 작업을 등록하고, 실제 생성은 asyncio.to_thread로 넘겨 처리한다.

def _schedule(self, job_id: str) -> None:
    task = asyncio.create_task(self._process_job_async(job_id))
    self._tasks[job_id] = task

async def _process_job_async(self, job_id: str) -> None:
    try:
        await asyncio.to_thread(self._process_job_sync, job_id)
    finally:
        self._tasks.pop(job_id, None)

그리고 실제 생성 루프에서는 segment 단위로 파일을 만들고, 진행률을 계속 갱신한다.

for index, segment in enumerate(job.document.segments, start=1):
    provider = self.provider_factory.get(job.document.options.engine, segment.voice)
    segment_path = self.storage_service.job_segments_dir(job_id) / f"{segment.sequence:04d}.wav"
    provider.synthesize_to_wav(
        text=segment.processed_text,
        voice=segment.voice,
        speed=segment.speed,
        output_path=segment_path,
    )
    self.audio_service.normalize_wav_segment(segment_path)
    segment_paths.append((segment_path, segment.pause_after_ms))
    job.segments_completed = index
    self.job_store.save(job)

이 구조 덕분에 프론트는 작업 생성 후 바로 job_id를 받고, 이후에는 상태만 polling 하면 된다. 지금 단계에서는 별도 큐 시스템까지 가지 않아도 이 정도 단순함이면 충분하다고 봤다.

물론 한계도 있다. 현재 구조는 API 프로세스 내부 작업이라 멀티 인스턴스 환경이나 대규모 트래픽에는 맞지 않는다. 하지만 개인용 MVP 기준에서는 구현 복잡도를 과하게 올리지 않으면서도 체감 UX를 개선할 수 있는 적절한 선택이었다.

OpenVoice를 붙이면서 가장 많이 막혔다

구현하면서 가장 손이 많이 간 부분은 역시 OpenVoice였다. 기본 MeloTTS만 쓰면 구조는 단순하다. 하지만 샘플 보이스 기반 tone color conversion을 붙이기 시작하면 설치 환경, 체크포인트, 임베딩 캐시, 디바이스 선택까지 한 번에 따라온다.

현재 코드는 먼저 기본 음성을 만든 뒤, 샘플 보이스로 변환하는 2단계 구조를 사용한다.

sample = self.voice_sample_service.get_reference_voice(voice)
temp_wav = output_path.with_name(f"{output_path.stem}.base.wav")
base_result = self.base_provider.synthesize_to_wav(
    text=text,
    voice=sample.base_voice,
    speed=speed,
    output_path=temp_wav,
)
try:
    self._convert_to_reference_voice(sample=sample, source_wav=temp_wav, output_path=output_path)
finally:
    temp_wav.unlink(missing_ok=True)

여기서 좋았던 점은 기본 TTS와 샘플 보이스 변환을 분리해서 생각할 수 있다는 점이었다. 기본 합성은 기본 합성대로 안정적으로 두고, 샘플 보이스는 선택적으로 확장하는 구조가 된다.

대신 현실적인 어려움도 있었다.

  • 샘플 음질이 좋지 않으면 결과도 쉽게 흔들린다.
  • OpenVoice 체크포인트가 준비되지 않으면 사용자가 왜 실패했는지 알아야 한다.
  • 같은 샘플을 반복해서 쓸 때 임베딩을 매번 다시 만들면 느리다.

그래서 현재 구현에서는 target embedding을 캐시해 다음 요청에서 재사용하도록 해 두었다. 이 부분은 토이프로젝트여도 꽤 중요했다. 내가 계속 쓰는 도구라면 두 번째, 세 번째 실행이 빨라지는 체감이 분명해야 하기 때문이다.

segment를 병합할 때 pause와 포맷 정규화가 필요했다

음성 파일을 여러 개 만든 뒤 단순히 이어 붙이기만 하면, 생각보다 결과가 거칠게 들린다. segment 사이의 호흡이 없고, 포맷이 조금만 달라도 병합 과정에서 바로 문제가 생긴다.

그래서 AudioService에서는 두 가지를 신경 썼다.

  1. 각 segment를 내부 WAV 포맷으로 한 번 정규화한다.
  2. segment 사이에 필요한 만큼 무음을 넣어 pause를 만든다.
with wave.open(str(output_path), "wb") as writer:
    writer.setparams(params)
    for index, (segment_path, pause_after_ms) in enumerate(segments):
        with wave.open(str(segment_path), "rb") as reader:
            current = reader.getparams()
            if (
                current.nchannels != params.nchannels
                or current.sampwidth != params.sampwidth
                or current.framerate != params.framerate
            ):
                raise GenerationFailedError("WAV segment 간 오디오 포맷이 달라 병합할 수 없습니다.")
            writer.writeframes(reader.readframes(reader.getnframes()))

        if index < len(segments) - 1:
            self._write_silence(
                writer,
                params.framerate,
                params.nchannels,
                params.sampwidth,
                pause_after_ms,
            )

이 부분은 코드로 보면 단순하지만, 결과물의 인상을 크게 바꾼다. pause가 없으면 그냥 이어 붙인 느낌이 강하고, 문단 pause와 줄 간 pause가 구분되면 훨씬 "말하고 있는" 느낌이 난다.

결국 팟캐스트처럼 들리게 만드는 데에는 대단한 모델보다 이런 작은 호흡 제어가 더 중요하다는 걸 느꼈다.

프론트는 polling으로 시작했다

실시간처럼 보여야 하는 기능이라고 해서 무조건 WebSocket부터 가야 하는 것은 아니다. 이번 프로젝트에서는 Job 상태만 주기적으로 확인하면 충분하다고 판단해서 polling으로 시작했다.

useEffect(() => {
  if (!job || job.status === "completed" || job.status === "failed") {
    return;
  }

  const interval = window.setInterval(() => {
    void (async () => {
      const latest = await fetchJob(job.job_id);
      setJob(latest);
    })();
  }, 1500);

  return () => window.clearInterval(interval);
}, [job?.job_id, job?.status]);

이 선택의 장점은 분명했다. 구현이 단순하고, 디버깅이 쉽고, MVP 단계에서 운영 복잡도가 낮다. 아직은 개인용 도구라서 이 정도면 충분하다.

물론 이후 사용량이 많아지거나 작업 시간이 더 길어지면 SSE나 WebSocket을 고려할 수 있다. 하지만 지금 단계에서는 "실시간 기술"보다 "실제 생성 흐름이 끝까지 안정적으로 도는가"가 더 중요한 문제였다.

구현하면서 어려웠던 과제들

이번 프로젝트를 만들면서 특히 어렵다고 느꼈던 부분은 다음과 같았다.

1. Markdown는 읽기용이지, 듣기용이 아니다

제목, 표, 코드, 링크, 리스트, 강조 문법이 많은 문서는 그대로 읽으면 어색하다. 문서 구조를 최대한 살리면서도, 듣기 기준으로는 군더더기를 걷어내야 했다.

2. 화자 문법과 일반 Markdown 문법이 자주 충돌한다

화자: 내용은 단순해 보이지만, 실제 Markdown에는 콜론이 들어가는 표현이 많다. 리스트 항목이나 스타일링된 텍스트와 어떻게 구분할지 판단 규칙을 세우는 데 생각보다 손이 갔다.

3. "비동기 처리"보다 중요한 것은 실패했을 때 설명 가능한 구조였다

오래 걸리는 작업은 언젠가 실패한다. 그래서 단순히 백그라운드로 돌리는 것보다, 어느 단계에서 실패했는지와 왜 실패했는지를 사용자가 이해할 수 있게 만드는 것이 더 중요했다.

4. 좋은 샘플 보이스가 결국 중요하다

OpenVoice를 붙이면 다 해결될 것 같지만, 실제로는 샘플 음질에 많이 좌우된다. 결국 입력 품질이 결과 품질을 크게 결정한다는 기본 원칙을 다시 확인하게 됐다.

MVP에서 일부러 하지 않은 것

이번 구현에서 일부러 보류한 것도 있다.

  • 문서 여러 개를 업로드해 하나의 Job으로 묶는 UI
  • 외부 큐 기반 분산 처리
  • 사용자별 작업 이력 저장
  • 스트리밍 재생
  • 정교한 편집용 타임라인

이런 기능까지 들어가면 제품 형태는 더 풍부해지겠지만, 내가 처음 원했던 "공부할 글을 출퇴근길에 듣는다"는 문제에서 너무 멀어질 수 있다고 봤다.

만들고 나서 느낀 점

지금은 실제로 이 도구를 출퇴근하면서 잘 쓰고 있다. 정리해둔 글이나 공부 메모를 음성 파일로 바꿔서 듣고 있는데, 눈으로만 읽을 때보다 반복 노출이 쉬워서 기억에도 더 잘 남는 느낌이 있다.

무엇보다 좋았던 점은 이 프로젝트가 "만들고 끝난 토이프로젝트"가 아니라는 점이다. 내가 자주 쓰는 문제를 내 손으로 해결했고, 지금도 계속 쓰고 있다는 사실이 꽤 만족스럽다. 완벽한 팟캐스트 제작 도구는 아니지만, 처음 목표였던 "이동 시간을 학습 시간으로 바꾼다"는 목적에는 분명히 도움이 되고 있다.

정리

tts-generator를 만들면서 가장 크게 느낀 것은 TTS 프로젝트의 핵심이 단순히 글을 읽게 하는 데 있지 않다는 점이었다. 실제로는 문서를 어떻게 나눌지, 어떤 규칙으로 화자를 구분할지, 어디에 pause를 넣을지, 긴 작업을 어떤 방식으로 보여줄지가 더 중요했다.

결국 이 프로젝트는 TTS 엔진을 붙인 경험이라기보다, 텍스트를 출퇴근길에 소비하기 좋은 오디오 경험으로 바꾸는 과정을 설계한 경험에 가까웠다. 그리고 그 결과물이 지금도 실제 생활 안에서 쓰이고 있다는 점에서, 개인적으로 꽤 만족스러운 프로젝트가 됐다.

GitHub : https://github.com/dsds60321/tts-generator