개발/AI

이어 붙여도 티가 난다: ffmpeg 크로스페이드의 한계, 그리고 내 목소리를 포기한 이유

goldtagworks 2026. 7. 17. 21:44

 

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

이번 편은 그 이음매(seam)를 지우려는 싸움이다. ffmpeg로 청크를 붙이고, 음량을 맞추고, 크로스페이드로 경계를 문지른다. 그리고 그렇게 해도 끝내 남는 게 무엇인지, 원류 프로젝트 castforge가 왜 결국 "내 목소리"를 포기했는지까지 정직하게 적는다. 환경은 1·2편 그대로(Python 3.10~3.11, ffmpeg)를 전제한다.

먼저, 왜 이음매가 생기는가

2편 ③에서 못 박은 문장을 다시 꺼낸다. 청크를 자르는 순간 seam이 예약된다. 이유는 구조에 있다. 청크는 각각 독립적으로 합성된다. 모델은 chunk_0001을 만들 때 chunk_0000이 어떤 높이·속도·숨으로 끝났는지 모른다. 매번 참조 클립을 새로 해석해서 "이 사람이라면 이렇게 읽겠지"를 처음부터 다시 그린다.

그래서 경계마다 세 가지가 튄다.

  • 음량 — 청크마다 평균 레벨이 다르다. 조용한 문장 뒤에 큰 문장이 붙으면 계단이 생긴다.
  • 피치(F0, 기본주파수) — 문장 끝의 억양과 다음 문장 첫 음의 높이가 어긋난다. 사람이 가장 예민하게 느끼는 부분이다.
  • 음색(timbre) — 같은 참조 클립인데도 realize가 미묘하게 달라 "다른 날 녹음한 나" 같은 느낌이 든다.

앞의 두 개는 후처리로 상당 부분 잡을 수 있다. 세 번째가 이 시리즈의 벽이다. 순서대로 가보자.

⑤ 일단 붙여보기 — 그리고 왜 부족한지 눈으로 확인

먼저 ffmpeg를 파이썬에서 부르는 얇은 래퍼를 둔다. 에러가 나면 stderr 꼬리를 살려서 던진다 — ffmpeg는 실패해도 종료코드만 주면 원인을 알 수 없어서다.

import subprocess
from pathlib import Path


def _run(cmd: list[str]) -> None:
    proc = subprocess.run(cmd, capture_output=True, text=True)
    if proc.returncode != 0:
        # ffmpeg 로그는 뒤쪽에 진짜 원인이 있다
        raise RuntimeError(f"ffmpeg 실패:\n{proc.stderr[-1500:]}")

가장 단순한 이어붙이기는 concat demuxer다. 재인코딩 없이 그냥 잇는다.

def naive_concat(paths: list[str], out_path: str) -> None:
    """가장 단순한 이어붙이기. 문제를 확인하는 용도."""
    lst = Path("_concat.txt")
    # 경로에 공백/한글이 있어도 안전하게 절대경로 + 작은따옴표
    lst.write_text(
        "".join(f"file '{Path(p).resolve()}'\n" for p in paths),
        encoding="utf-8",
    )
    _run(["ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", str(lst), out_path])
    lst.unlink(missing_ok=True)

돌려서 들어보면 두 가지가 바로 걸린다. 청크마다 음량이 들쭉날쭉하고, 경계가 딱 붙어 뚝뚝 끊긴다. 숨 쉴 틈 없이 붙었다가, 어떤 데는 앞뒤 무음이 겹쳐 어색하게 빈다. 이 상태로는 낭독이라 부르기 어렵다. 그래서 붙이기 전에 음량부터 맞춘다.

⑥ 음량 정규화: 계단부터 없앤다

청크별 음량 차이는 loudnorm 필터로 잡는다. 단순 피크 정규화(가장 큰 소리를 기준으로 스케일)가 아니라, 방송 표준인 EBU R128을 따라 사람이 느끼는 체감 음량(LUFS, Loudness Units Full Scale) 을 목표값에 맞춘다. 낭독·팟캐스트는 보통 통합 라우드니스 −16 LUFS 언저리를 쓴다.

def normalize_loudness(
    in_path: str,
    out_path: str,
    I: float = -16.0,    # 목표 통합 라우드니스(LUFS). 팟캐스트 관행값
    LRA: float = 11.0,   # 라우드니스 레인지(다이내믹 폭)
    TP: float = -1.5,    # 트루 피크 상한(dBTP). 클리핑 방지
) -> None:
    _run([
        "ffmpeg", "-y", "-i", in_path,
        "-af", f"loudnorm=I={I}:LRA={LRA}:TP={TP}",
        "-ar", "24000",  # 이후 필터 체인에서 샘플레이트 통일
        out_path,
    ])

여기서 정직하게 짚을 것. 위는 1-pass다. loudnorm은 원래 2-pass(1차로 측정 → 측정값을 넣어 2차 보정)로 돌려야 목표값에 정확히 맞는다. 1-pass는 실시간 추정이라 오차가 있다. 청크가 수백 개면 2-pass는 두 배 느리니, 나는 "청크는 1-pass로 빠르게, 최종 합본만 2-pass로 한 번 더" 방식을 썼다. 품질이 최우선이면 청크도 2-pass로 돌리면 된다(성능 트레이드오프를 여기서 명시해둔다).

이걸로 음량 계단은 사라진다. 남은 건 경계의 뚝뚝 끊김이다.

⑦ 크로스페이드: 경계를 문질러 잇기

경계를 부드럽게 하려면 acrossfade 필터로 앞 청크의 끝과 뒤 청크의 시작을 짧게 겹쳐 교차 페이드한다. 60~120ms면 충분하다. 너무 길면 말이 서로 먹히고, 너무 짧으면 여전히 딸깍거린다.

문제는 acrossfade가 두 입력만 받는다는 점이다. N개를 이으려면 필터 그래프를 체인으로 엮어야 한다. 앞의 출력이 다음 acrossfade의 입력이 되도록 라벨을 이어붙인다.

def crossfade_concat(paths: list[str], out_path: str, fade: float = 0.08) -> None:
    if not paths:
        raise ValueError("입력 청크가 없습니다.")
    if len(paths) == 1:
        _run(["ffmpeg", "-y", "-i", paths[0], out_path])
        return

    inputs: list[str] = []
    for p in paths:
        inputs += ["-i", p]

    # [0:a][1:a]acrossfade[x1]; [x1][2:a]acrossfade[x2]; ... 마지막은 [out]
    parts, acc = [], "[0:a]"
    for i in range(1, len(paths)):
        out_label = "[out]" if i == len(paths) - 1 else f"[x{i}]"
        parts.append(f"{acc}[{i}:a]acrossfade=d={fade}:c1=tri:c2=tri{out_label}")
        acc = out_label

    _run([
        "ffmpeg", "-y", *inputs,
        "-filter_complex", ";".join(parts),
        "-map", "[out]", out_path,
    ])

c1=tri:c2=tri는 삼각(선형) 페이드 곡선이다. 겹치는 만큼 총 길이가 fade × (청크수 − 1)만큼 줄어드니, 자막·타임코드를 맞출 거면 이걸 감안해야 한다.

이제 전처리→정규화→크로스페이드를 한 줄로 묶는다.

def assemble(
    chunk_paths: list[str],
    out_path: str,
    fade: float = 0.08,
    workdir: str = "_norm",
) -> str:
    """청크 wav들을 음량 정규화 후 크로스페이드로 이어붙여 최종 낭독본을 만든다."""
    Path(workdir).mkdir(parents=True, exist_ok=True)
    normed = []
    for i, p in enumerate(chunk_paths):
        q = str(Path(workdir) / f"n_{i:04d}.wav")
        normalize_loudness(p, q)
        normed.append(q)
    crossfade_concat(normed, out_path, fade=fade)
    return out_path


if __name__ == "__main__":
    chunks = sorted(str(p) for p in Path("out").glob("chunk_*.wav"))
    if not chunks:
        raise SystemExit("out/ 에 청크가 없다. 2편 ④를 먼저 돌려라.")
    assemble(chunks, "final.wav", fade=0.08)
    print(f"완성: final.wav ({len(chunks)}개 청크 병합)")

실제로 더미 청크 3개(각 1.2s/1.0s/1.4s, 볼륨 제각각)로 돌려봤다. 단순 concat은 3.60초, 위 assemble은 3.44초가 나온다. 정확히 3.60 − 0.08 × 2 = 3.44, 크로스페이드 겹침만큼 줄어든 값이다. 코드는 그대로 복사해 돌아간다.

여기까지 오면 음량 계단은 없고 경계도 딸깍거리지 않는다. 처음 단순 concat과 비교하면 완성도가 확 다르다. 그런데.

남는 것: 문질러도 안 지워지는 것

크로스페이드는 진폭(음량)의 불연속을 지운다. 하지만 피치와 음색의 불연속은 못 지운다. 두 청크의 F0가 경계에서 30Hz 어긋나 있으면, 그 둘을 80ms 겹쳐 섞어봐야 "30Hz 점프가 80ms에 걸쳐 일어나는" 것으로 바뀔 뿐이다. 오히려 그 짧은 구간에서 두 음높이가 겹쳐 울렁이는 소리가 나기도 한다. 문질러서 자국을 흐리게는 해도, 없앨 수는 없다.

이게 왜 후처리로 근본 해결이 안 되냐면 — 정보가 그 지점에 애초에 없기 때문이다. chunk_0001을 합성할 때 모델은 chunk_0000의 마지막 F0를 입력받지 않았다. 두 조각은 서로를 모른 채 태어났다. 없는 연속성을 사후에 만들어낼 수는 없다. 진짜 해법은 "합성 단계에서 앞 청크의 상태를 조건으로 물려주는 것(streaming/stateful 합성)"인데, 이건 후처리가 아니라 모델·파이프라인을 갈아엎는 일이다.

정리하면 벽은 이렇다.

어긋남 원인 후처리로?

음량 청크별 평균 레벨 차이 ✅ loudnorm
딸깍/끊김 경계 진폭 불연속 ✅ acrossfade
피치(F0) 점프 청크 독립 합성 ⚠️ 완화만, 근본 불가
음색/realize 변동 매번 참조 재해석 ❌ 불가

castforge가 내린 결정

이 시리즈는 그냥 실습이 아니었다. 뒤에는 castforge라는 실제 프로젝트가 있었다 — 화자(스님) 목소리를 클론해 본인 저서를 본인 목소리로 낭독하고, 유료 회원이 로그인해 듣는 회원제 오디오 SaaS. "본인 목소리로 본인 책을 읽어준다"가 제품의 심장이었다.

그래서 제로샷 클론(Qwen3-TTS, Apple Silicon MLX 자체호스팅)을 1순위로 밀었다. 위에서 쌓은 전처리·청킹·조립 파이프라인이 전부 이 목표를 위한 것이었다. 그런데 실측에서 벽이 그대로 나왔다. 클론 합성본은 청크 경계 F0 점프가 평균 55Hz. 크로스페이드로 문질러도 귀에 남는 수준이었다. 에피소드 하나에 경계가 수십 개인데, 그중 몇 개만 튀어도 "본인 목소리"라는 상품성이 무너진다.

비교군으로 기성 보이스 API(문장 단위 세그먼트 + 균일한 쉼으로 병합하는 방식)를 같은 원고로 돌렸더니 경계 F0 점프가 25Hz. 절반 이하였다. 기성 보이스는 애초에 한 화자로 일관되게 학습돼 있어 조각을 이어도 덜 튄다.

여기서 선택지가 갈렸다.

  • A. 스님 본인 목소리(클론) 유지 — 제품 콘셉트에 충실. 대신 경계 이음매를 감수하거나, streaming 합성으로 파이프라인을 재설계(비용·기간 폭증).
  • B. 기성 보이스로 전환 — "본인 목소리"라는 핵심 가치를 포기. 대신 낭독이 자연스럽고, 안정적이고, API라 맥미니 로컬 모델도 필요 없어진다.

관리자·사용자와 함께 B를 택했다. 2026년 7월 3일, 엔진을 Naver CLOVA Voice(기성 보이스 API)로 확정하고 스님 클론을 공식 폐기했다. Qwen3 클론은 이음매와 realize 변동으로 접었고, XTTS v2는 애초에 3중 블로커(CPML 비상업 라이선스 + 한국어 wontfix + Apple MPS 행)로 진입도 못 했다.

냉정한 트레이드오프였다. "조금 어색한 진짜 목소리" vs "자연스러운 남의 목소리" 에서, 오래 듣는 오디오북·팟캐스트에는 후자가 낫다고 판단했다. 몇 초 데모에서 "우와 진짜 스님 목소리네"보다, 40분을 틀어놔도 거슬리지 않는 게 제품에선 이긴다.

시리즈를 닫으며

세 편에 걸쳐 이런 길을 걸었다. 문서에서 텍스트를 뽑고(1편 설계 → 2편 코드), 한국어를 다듬어 자르고(2편), 청크마다 내 목소리로 합성해(2편) ffmpeg로 이어 붙였다(3편). 기술적으로는 대부분 굴러간다. 로컬에서, 무료로, 내 목소리 비슷한 낭독을 만드는 건 이제 정말 된다.

다만 "완벽히 내 목소리인, 이음매 없는 긴 낭독"은 아직 후처리의 영역이 아니라 합성의 영역이다. 조각을 잘라 따로 만든 뒤 이어 붙이는 구조인 한, 경계는 예약되어 있다. 그 벽을 인정하고 제품이 무엇을 포기할지 정하는 것 — castforge가 배운 건 결국 기술이 아니라 그 판단이었다.

제로샷 클론이 나쁘다는 얘기가 아니다. 짧은 안내음, 캐릭터 보이스, 데모에는 여전히 강력하다. 다만 "긴 글을, 내 목소리로, 이음매 없이"라는 세 조건이 동시에 걸리면 지금은 벽이 있다. 그 벽의 위치를 정확히 아는 것부터가 시작이다.


이 글은 dog paw / development의 제로샷 TTS 3부작 마지막 편입니다. 1편 「내 목소리로 문서를 읽어주는 로컬·무료 제로샷 TTS 설계」 · 2편 「한국어 TTS가 숫자를 못 읽는다」