Builds

[TTS] 한국어 오픈소스 TTS 만들기 2편 - 품질 격차 줄이기

leevigong 2026. 5. 11. 21:07
반응형

이전 포스팅: [TTS] ElevenLabs 대신 한국어 오픈소스로 TTS 만들기 1편

 

지난 포스팅에서 Supertonic + Qwen3-TTS 조합을 채택하고, onset-aware chop과 sentence-aware pause로 1차 정제까지 했다. 며칠동안 사내에서 시연하면서 컨펌 받아보니 "목소리 복제도 나름 잘되고 나쁘진 않은데 잡음이나 이런게 ElevenLabs와 격차가 확실히 존재하다"는 얘기들이 마음에 걸렸다. 그래서 추가로 그 격차를 줄이려 한 작업들이다.

  • F5-TTS, GPT-SoVITS와 다시 비교 청취
  • Whisper 자동 transcribe로 prosody 모드를 default화
  • onset chop 두 번째 라운드 — 짧은 vocal-fry가 아니라 300ms짜리 사전 잡음

1. F5-TTS, GPT-SoVITS 등 모델 재비교

지난 포스팅에선 Raon / Supertonic / Qwen만 비교했는데, "Qwen이 진짜 최선인가" 검증 차원에서 한국어 가능한 후보 둘을 더 끌어와 동일 조건으로 청취 비교했다.

 

비교 셋업은 동일 reference(20초), 동일 transcript, 동일 5문장(평서·감탄·전환·머뭇거림·장문), 각자 격리된 venv. 문장은 모델별 약점이 잘 드러나도록 패턴을 다섯 가지로 골랐다.

 

항목 Qwen3-TTS F5-TTS GPT-SoVITS
라이선스 Apache 2.0 MIT MIT
ref 길이 제약 3~30초 권장 자유 3~10초만 (강제)
Sample Rate 24 kHz 24 kHz 32 kHz (v2)
M5 Max CPU 5문장 약 6분 약 5.5분 약 30초(캐시 후)
한국어 자연스러움 ★★★★ ★★★ ★★★
클로닝 충실도 ★★★★ ★★★ ★★

결론..

  • F5-TTS: 다국어 모델이라 한국어 발화가 살짝 어색하다. 장문에서 호흡이 부자연스럽게 끊긴다.
  • GPT-SoVITS: ref를 9초로 강제 트림해야 해서 20초짜리 ref 정보를 절반밖에 못 쓴다. 합성 자체는 빠른데 음색이 달리 들렸다. Python API 표면이 버전마다 달라서 통합 작업 자체가 까다롭기도 하다(get_tts_wav 시그니처가 자주 바뀌고, Korean을 i18n("韩文")으로 매핑해야 하고, weight 파일을 repo 안 특정 경로에 심볼릭 링크로 두어야 한다). Production 후보로 쓰기엔 운영 부담이 크다.
  • Qwen3-TTS: 가장 자연스러움.

후보 모델 비교는 시간 낭비처럼 보일 수 있지만, 굳이 모델을 옮길 이유가 없다를 검증했다.

 

2. Qwen 클로닝 품질을 한 단계 올리기 — ref_text 자동화

지난 포스팅에서 언급하지 않았는데 Qwen3-TTS의 보이스 클로닝에는 두 가지 모드가 있다.

  • 빠른 모드 (x_vector_only): reference 오디오의 음색만 임베딩으로 뽑아서 사용. 운율은 모델이 알아서 만든다.
  • 정밀 모드: reference 오디오 + reference 텍스트(전사문)를 같이 주면, 음색뿐 아니라 prosody(억양·강세·호흡)까지 복제한다.

두 가지 모드는 품질 차이가 꽤 크다.

정밀 모드가 명백히 더 자연스러운데, 문제는 사용자가 ref를 업로드할 때마다 그 오디오의 transcript를 직접 입력해야 한다는 점이다. 

그래서 번거로움을 줄이고자, ref 업로드 → Whisper 자동 전사 → DB 저장 → 합성 시 자동 주입 흐름으로 바꿨다.

 

faster-whisper small 모델 기준, M5 Max(CPU + int8)에서 10초 ref를 1~2초 안에 전사한다. 첫 호출 때만 약 244MB 다운로드.

업로드 핸들러는 transcribe를 background task로 던지고 즉시 응답한다. FE는 transcript_status를 폴링하다가 ready가 되면 textarea에 자동으로 채운다. 사용자가 잘못 인식된 부분을 수정하거나, "다시 인식" 누르면 재전사한다.

[Upload]
  POST /api/voice-references → 즉시 응답 (status="pending")
                             → background: transcribe_korean() → status="ready"
[Synthesize]
  POST /api/synthesize body에 reference_text가 None이면
    → 서버가 ref 메타에서 transcript 자동 주입
    → x_vector_only_mode 자동으로 False

ref 등록 한 번 이후엔 사용자가 모드 토글을 안 만져도 default가 정밀 모드로 간다. 대부분의 평균 청취 품질이 한 단계 올라가는 게 바로 느껴졌다.

 

회귀 추적 - Whisper가 한국어 마침표를 안 찍는다

며칠 운영하다 보니 특정 ref에서 정밀 모드가 빠른 모드보다 더 안 좋게 들리는 케이스가 잡혔다. 같은 텍스트, 같은 ref인데 정밀 모드 결과가 0.75초 짧고 발화가 급하게 들렸다.

 

지표를 뽑아보니, B가 가장 짧고 onset이 가장 급하다. C가 가장 길고 도입이 자연스럽다. 같은 ref인데 ref_text만 달랐다. 저장된 auto transcript를 까보니 답이 나왔다.

  길이 첫 -28dB lead 0–300ms
A: 빠른 모드 10.68s 120ms -28.7 dB
B: 자동 전사 정밀 모드 10.23s 80ms -30.5 dB
C: 수동 전사 정밀 모드 11.28s 700ms -31.4 dB
 
"안녕하십니까 8월 19일 목요일 mbc 10시 뉴스입니다 오늘 코로나19 신규
 확진 환자 수는 역대 두 번째로 많은 2,152명을 기록했습니다"

마침표가 0개다. 정밀 모드의 Qwen은 ref_text 토큰을 ref_audio 시간축에 정렬해서 prosody를 학습하는데, 문장부호 없는 한 줄짜리 transcript를 받으면 ref를 한 호흡짜리 평탄한 발화로 해석하고 그 압축된 운율을 새 합성에 그대로 transfer한다.

원인을 좁히려고 같은 wav를 faster-whisper에 옵션을 바꿔가며 다시 던졌다.

vad_filter=False, condition_on_previous_text=True
→ '... mbc 10시 뉴스입니다 오늘 ...' (마침표 0)

initial_prompt="안녕하세요. 오늘은 날씨가 정말 좋네요."
→ '... MBC 10시 뉴스입니다. 오늘 ...' (마침표 살아남)

initial_prompt가 키였다. small 모델 한국어 디코더는 짧은 클립에서 sentence-final punctuation을 거의 안 찍는데, punctuated 예시를 prompt로 prime하면 그 스타일을 이어서 emit한다. 그래서 transcribe 호출을 다음처럼 정리했다.

 

# server/app/transcribe.py
segments, _ = model.transcribe(
    str(audio_path),
    language="ko",
    vad_filter=False,           # ref_text가 ref_audio 전체를 덮어야 정렬 OK
    beam_size=5,
    condition_on_previous_text=True,
    initial_prompt="안녕하세요. 오늘은 날씨가 정말 좋네요.",
)

재전사 결과는 수동 전사본과 바이트 단위로 동일해졌다(마침표 3개 + mbc → MBC 대문자화까지). 다시 합성한 결과도 수동 전사 버전과 길이가 사실상 같아졌고, 자연스러운 도입부 호흡이 돌아왔다.

배운 점: 정밀 모드는 ceiling이 높지만 floor는 transcript 품질에 종속된다. 자동 전사를 default로 깔 거면 모든 ref 카테고리에서 문장부호가 안정적으로 나오는지 검증해야 한다.

 

3. Onset chop 두 번째 라운드 - 사전 잡음

지난 포스팅에서 짧은 vocal-fry("엄~" 같은 ramp/attack 패턴)는 onset-aware chop으로 잡았다. 그런데 운영하다 보니 새로운 패턴이 보였다. 합성 결과물 앞에 300ms 가까이 지속되는 -37dB 수준의 저레벨 잡음이 깔리고, 그 뒤에 진짜 발화가 -22dB로 점프하는 케이스다.

0~300ms  : -37 ~ -45 dB  ← 사전 잡음 (지속됨)
310ms+   : -28 dB        ← 진짜 발화 시작
330ms+   : -22 dB        ← 본격

 

기존 fry 검출기는 이걸 못 잡는다. voice_start는 이미 10ms에서 잡히고(-45dB 임계 첫 통과), head 80ms vs tail 220ms RMS 차이가 +4.4dB라 fry 임계 5dB 미만이라 "정상 onset"으로 판단해버린다.

기존 검출기는 짧고 갑작스러운 잡음(fry burst)에 튜닝됐는데, 이건 패턴 자체가 다른 길게 지속되는 사전 잡음이다.

 

Two-tier detection

기존 검출은 그대로 두고, 그 앞에 한 단계 추가했다.

# 기존 voice_start (-45dB 임계) 찾은 직후 추가
loud_threshold = 10 ** (LOUD_SPEECH_DB / 20)  # -28dB
loud_above = np.where(rms > loud_threshold)[0]
if loud_above.size > 0:
    loud_start = loud_above[0] * win
    gap_ms = (loud_start - voice_start) / sr * 1000
    if gap_ms > LEAD_NOISE_GAP_MS:  # 150ms
        return audio[loud_start:]   # 진짜 큰 소리 지점부터 사용

해석은 단순하다. "처음 음성이 감지된 지점"과 "처음 진짜 큰 소리(-28dB 이상)가 나온 지점"이 150ms 넘게 떨어져 있으면, 그 사이를 전부 잡음으로 간주하고 잘라낸다. 문제의 wav에 적용했더니 정확히 320ms 지점에서 잘려 사라졌다. 기존 fry chop은 그대로 동작하니 "오늘", "아침" 같은 정상 모음 onset도 안 잘린다.

 

테스트는 두 방향으로 회귀를 잡았다.

def test_hard_chop_lead_strips_sustained_pre_noise():
    # 300ms 저레벨 잡음 + 500ms 큰 톤 → 잡음만 잘려야 함
    ...

def test_hard_chop_lead_keeps_clean_loud_onset():
    # 처음부터 큰 톤 → 거의 안 잘려야 함
    ...

 

Qwen이 못 고치는 한 가지

검출 못 하는 케이스도 발견했다. Qwen이 입력 텍스트 앞에 자기 멋대로 "자/음/그" filler를 합성으로 만드는 케이스다. 입력이 "여기서 핵심..."인데 결과가 "자 여기서 핵심..."으로 나온다. 이건 잡음이 아니라 -20dB대 풀 볼륨 음성으로 합성된 거라 두 번째 tier도 못 잡는다. (loud↔loud 사이엔 gap이 없어 chop이 트리거되지 않음).

부분적으로 운이 좋은 변형도 있긴 하다 - filler가 살짝 quieter한 ramp 형태로 들어가는 경우 위의 사전 잡음 분기가 부수효과로 자른다.하지만 본격 풀-볼륨 filler는 후처리로 구분 불가능. 대처는 (1) 재합성, (2) 텍스트 앞에 punctuation anchor 추가, (3) ref_text의 시작 패턴 점검 정도. 모델 fine-tune이 아니면 완전 해결은 어렵다.

 

결과물

지난 포스팅과 동일하게, 놀면뭐하니의 뉴스 앵커 클립에서 나오는 유재석 목소리 10초를 reference로 등록해서 아래 스크립트를 합성했다.

오늘 서울의 날씨를 전해 드리겠습니다.
아침에는 영상 7도, 한낮에는 18도까지 오르겠습니다.
다만 오후부터 서쪽 지방을 중심으로 비가 조금씩 시작되겠습니다.

유재석 음성 테스트 TTS_v1.wav
0.62MB
유재석 음성 테스트 TTS_v2.wav
0.49MB

반응형

'Builds' 카테고리의 다른 글

[TTS] ElevenLabs 대신 한국어 오픈소스로 TTS 만들기  (0) 2026.05.04