paper&honey

M1 Pro 로컬 TTS 실험

Qwen3-TTS로컬 TTSApple Silicon음성 합성

M1 맥북에서 Qwen3-TTS를 돌려보니, 문제는 속도보다 청킹이었다

M1 Pro에서 약 70초짜리 한국어 음성을 두 번 만들었더니 두 번 모두 53초가 걸렸다. 긴 대본을 잘라 다시 붙인 방법, 참조 문장 끝소리가 따라붙은 실패, 로컬과 Typecast를 나누는 기준까지 담았다.

작성 paper&honey 운영자 11년차 개발자 · AI 자동화 운영자 게시 수정 약 9분

합성은 끝났는데 뒤쪽 청크들이 “은”으로 시작했다. “은사관”, “은태종”, “은세종”처럼 들렸다. 모델을 잘못 고른 게 아니었다. 참조 음성을 받아쓴 문장을 “할 일은”에서 끊어 둔 게 문제였다.

속도도 다시 쟀다. M1 Pro 맥북으로 같은 458자 대본을 두 번 돌렸더니 70.4초와 70.2초짜리 음성이 나왔고, 합성은 두 번 모두 53초가 걸렸다. Qwen3-TTS 1.7B Base의 MLX용 8비트 변환본을 썼고, 모델을 불러오는 시간은 빠져 있다. 같은 날 앞서 돌린 두 실행은 60초와 67초였으니, 한 번 나온 숫자만 성능처럼 박아 둘 일도 아니었다.

Apple M1 Pro에서 Qwen3-TTS 1.7B MLX 8비트 모델로 약 70초 음성을 두 번 합성해 모두 53초가 걸린 실제 터미널 화면
같은 458자 대본을 연달아 두 번 합성한 실제 Ghostty 화면이다. 사용자 경로가 나온 초기화 구간은 빼고 마지막 세 청크와 완료 로그만 위아래로 붙였다. 두 번 모두 합성 53초, RTF 0.76·0.75였다. 원본 크기로 보기

로컬로 옮긴 이유

TTS API 대신 내 맥에서 먼저 돌린다

수정할 때마다 다시 합성하는 초안은 로컬로, 마지막 품질이 중요한 음성은 API까지 열어 둔다.

긴 대본은 한 번에 끝나는 일이 거의 없다. 직접 들어 보면 숨을 쉬어야 할 자리가 붙어 있고, 숫자 하나를 이상하게 읽거나 문장 톤이 앞뒤와 어긋난다. 한두 문장을 고치고 전체를 다시 뽑는 일이 반복된다. API를 먼저 부르면 이 수정 과정에도 사용량이 계속 붙는다.

그래서 자동화 설정에 아예 로컬 우선 규칙을 넣었다. 새 TTS 작업은 Qwen3-TTS로 먼저 만들고, 목소리를 고르지 못했으면 같은 대본으로 여러 보이스를 들어 본 뒤 결정한다.

engine_policy: 새 TTS는 로컬 Qwen3-TTS로 먼저 합성
voice_policy: 보이스가 정해지지 않았으면 audition부터 실행

로컬이라고 공짜는 아니다. 맥을 쓰는 시간과 전기, 모델을 관리하는 수고가 든다. 대신 초안을 몇 번 다시 만들어도 호출 횟수를 세지 않아도 되고, 참조 음성과 대본을 내 컴퓨터 안에서 다룰 수 있다. API와 품질 대결을 하려는 게 아니라 반복 작업을 맡길 자리를 정한 셈이다.

실행 환경

M1 Pro 32GB에 1.7B 8비트를 올렸다

Qwen 공식 MLX 모델이 아니라, Qwen3-TTS 1.7B Base의 MLX용 커뮤니티 변환본을 썼다.

테스트한 맥북은 Apple M1 Pro 10코어·메모리 32GB 모델이다. macOS 26.6.2와 Python 3.12.13, mlx-audio 0.4.6, mlx 0.32.0에서 실행했다. 내려받은 모델은 mlx-community/Qwen3-TTS-12Hz-1.7B-Base-8bit이고, 로컬 캐시 크기는 2.9GB였다.

이름이 길어서 헷갈리기 쉽다. Qwen 공식 저장소가 공개한 1.7B Base 계열을 Apple Silicon에서 돌릴 수 있게 변환한 모델이다. 내가 돌린 건 Qwen 공식 MLX 배포판이 아니다. 모델과 실행 라이브러리가 업데이트되면 같은 맥에서도 속도와 결과가 달라질 수 있다.

Qwen의 공식 1.7B Base 모델 카드에는 한국어 지원과 참조 음성·전사를 이용한 음성 복제가 안내돼 있다. 원본 모델과 내가 쓴 변환본에는 Apache-2.0 라이선스가 표시돼 있지만, 다른 사람의 목소리를 복제해도 된다는 허락까지 생기는 건 아니다. 내 음성 파일은 로컬에만 두고 공개 자료에는 넣지 않았다.

내가 만든 qtts.py는 203줄이다. 참조 음성과 그 음성을 받아쓴 문장을 보이스로 등록하고, 같은 대본을 여러 보이스로 들어 보거나 긴 대본을 한꺼번에 합성한다. 실제 이름과 경로를 뺀 명령은 이렇다.

# 참조 음성과 정확히 일치하는 문장으로 보이스 등록
./.venv/bin/python qtts.py add-voice my-voice \
  --ref reference.wav \
  --ref-text "오늘 준비한 이야기를 차근차근 들려드리겠습니다."

# 대본 합성
./.venv/bin/python qtts.py synth \
  --voice my-voice \
  --script script.txt \
  --out output/

참조 음성은 목소리 정보가 담긴 파일이라 공개 저장소나 글 이미지에 넣지 않았다. 이 글에 나온 터미널 화면도 보이스 이름과 대본, 사용자 경로를 모두 뺀 뒤 다시 그렸다.

203줄짜리 배치 도구

긴 대본은 잘라서 만들고 다시 붙인다

한 번에 길게 만들지 않고 문장 단위로 나눈 뒤 WAV와 자막용 JSON을 함께 남긴다.

긴 대본을 통째로 모델에 넣지 않는다. 마침표와 물음표, 느낌표를 기준으로 문장을 나누고 기본 120자를 넘기기 전에 청크를 끊는다. 문장 하나가 너무 길면 쉼표를 먼저 찾고, 쉼표도 없으면 글자 수에 맞춰 자른다.

sentences = re.split(r"(?<=[.!?…。])\s+", text)

if buf and len(buf) + len(sentence) + 1 > 120:
    chunks.append(buf)
    buf = sentence
else:
    buf = f"{buf} {sentence}".strip()

청크마다 WAV 파일을 만들고 사이에 0.3초 무음을 넣어 full.wav로 합친다. 동시에 각 청크의 문장, 시작 시각, 길이를 chunks.json에 적는다. 발음이 깨진 부분을 찾거나 자막 시간을 맞출 때 이 파일이 기준이 된다.

output/
├── chunks/
│   ├── C000.wav
│   ├── C001.wav
│   └── C002.wav
├── chunks.json
└── full.wav

이 방식의 장점은 실패 범위가 작다는 데 있다. 20분짜리 결과에서 한 문장만 이상하면 chunks.json으로 청크를 찾고, 그 문장만 별도 대본으로 다시 합성해 교체할 수 있다. 다만 현재 도구에는 자동 재시도나 중단 지점부터 이어 가는 기능이 없다. 긴 작업이 중간에 멈추면 이미 만들어진 청크를 보고 사람이 다시 시작해야 한다.

2026년 8월 31일 재측정

약 70초 음성에 두 번 모두 53초가 걸렸다

같은 대본과 보이스로 돌렸지만 출력 길이와 RTF는 조금씩 달랐다.

1.7B 8비트 모델과 한국어 합성 보이스, 같은 458자 대본을 썼다. 대본은 다섯 청크로 나뉘었다. 첫 실행은 70.4초 음성을 만드는 데 53초, 두 번째는 70.2초 음성에 53초가 걸렸다. 모델이 메모리에 올라온 뒤부터 재는 내부 타이머라 앱을 켜고 모델을 불러오는 시간은 빠져 있다.

1차 · 5청크 · RTF 0.76

70.4초 음성 → 53초

2차 · 5청크 · RTF 0.75

70.2초 음성 → 53초

RTF는 합성 시간을 완성된 음성 길이로 나눈 값이다. 1보다 작으면 음성이 재생되는 시간보다 빨리 만들었다는 뜻이다. 이번 두 값을 60분에 단순히 곱하면 약 45~46분이지만, 실제 60분 대본을 이번 환경에서 다시 돌린 값은 아니다. 짧은 결과를 그대로 늘려 “60분도 이만큼 걸린다”고 말할 수는 없다.

같은 날 앞서 돌린 두 실행은 70.9초 음성에 67초, 69.4초 음성에 60초가 걸렸다. 대본과 보이스는 같았지만 왜 차이가 났는지까지 따로 측정하지는 않았다. 7월 31일에는 RTF 0.66~0.72, 60분 환산 40~45분이라고 README에 적어 뒀다. 당시 패키지 버전과 원시 로그도 남아 있지 않아 어느 값도 한데 섞어 평균내지 않았다. 속도를 한 줄로 말하는 것보다 측정 조건을 같이 남기는 편이 낫다.

가장 오래 붙잡힌 실패

끝소리 하나가 여러 청크에 따라붙었다

참조 음성을 받아쓴 문장이 조사로 끝나자 뒤에 만든 청크 여러 개에서 그 소리가 먼저 들렸다.

처음 등록한 참조 문장의 끝은 “할 일은”이었다. 말을 더 이어 갈 것처럼 끝나는 부분을 그대로 잘라 썼다. 합성은 성공했지만 뒤에 이어진 청크 여러 개가 “은” 소리로 시작했다. 남겨 둔 전사본에서는 “은사관”, “은태종”, “은세종”이 남아 있었다.

Qwen3-TTS 합성본 전사에서 수정 전에는 태종과 세종 앞에 은 소리가 붙고 수정 후에는 사라진 실제 비교 화면
7월 31일 합성본을 다시 받아쓴 실제 결과다. 수정 전에는 두 문장 앞에 “은”이 붙었고, 참조를 고친 뒤 만든 결과에서는 사라졌다. 대본 전체는 빼고 해당 두 줄만 남겼다. 이미지를 누르면 원본 크기로 볼 수 있다.

참조 음성 구간과 받아쓴 문장을 다시 맞추고, 둘 다 완결된 문장에서 끝나게 바꾸자 꼬리 발음이 사라졌다. 모델 설정이나 온도를 먼저 의심했지만 해결책은 훨씬 단순했다. 어느 내부 단계에서 “은”이 붙었는지까지는 따로 추적하지 않았다. 같은 설정에서 참조 구간과 전사만 고쳐 문제가 사라진 것까지 확인했다.

그 뒤로는 보이스를 등록하기 전에 세 가지만 듣는다. 시작과 끝에 잘린 음절이 없는지, 참조 음성과 전사가 한 글자도 어긋나지 않는지, 마지막 문장이 자연스럽게 끝나는지다. 긴 대본 합성에서 가장 비싼 실수는 속도가 느린 게 아니라 잘못된 참조로 여러 청크를 다시 만드는 일이었다. 아직 add-voice가 미완결 문장을 막아 주지는 않는다. README에 주의사항만 써 둔 채 코드 검사는 빼먹은 상태다.

로컬과 API의 경계

어떤 작업은 여전히 Typecast로 보낸다

반복해서 들어 볼 초안은 Qwen으로 만들고, 자막 시각까지 바로 받아야 하는 결과물은 API도 쓴다.

Qwen3-TTS를 돌린 뒤에도 Typecast API를 없애지는 않았다. 가장 큰 차이는 결제액보다 타임스탬프였다. 기존 Typecast 자동화는 단어와 글자 단위 시각을 함께 저장한다. 지금 만든 Qwen 도구가 남기는 건 청크별 시작 시각과 길이뿐이다. 자막을 글자 단위로 맞추려면 별도 음성 인식과 정렬 과정이 한 번 더 필요하다.

원하는 캐릭터 음성이 이미 있고 납기 전에 자막 시각까지 빠르게 고정해야 할 때는 Typecast를 쓴다. 반대로 같은 대본을 여러 번 갈아엎거나 긴 분량을 시험할 때는 로컬부터 돌린다. 참조 음성을 직접 관리할 수 있다는 점은 좋지만, 한 번 잘못 등록하면 여러 청크를 다시 만들어야 한다.

지금 도구에는 자동 재시도, 이어 만들기, 발음 사전이 없다. 모델 업데이트에 맞춰 환경을 잠그는 파일도 아직 없다. 그래서 “로컬이면 무제한 무료”라고 부르기보다, 내가 관리 책임까지 떠안는 대신 반복 호출을 줄이는 선택이라고 본다.

Apple Silicon에서 로컬 TTS를 돌려 본 사람이라면 어떤 모델과 RTF가 나왔는지 궁금하다. 가장 먼저 깨진 발음과 로컬·API를 나누는 기준도 댓글로 듣고 싶다.

함께 쓰는 작업 노트

댓글로 당신의 기준을 알려주세요

GitHub에서 직접 보기

GitHub 로그인 후 댓글을 남길 수 있습니다. 댓글과 GitHub 사용자명은 공개되며, 스팸·개인정보 노출·비방·불법 콘텐츠는 운영자가 숨기거나 삭제할 수 있습니다. 문제가 있는 댓글은 contact@papernhoney.com으로 신고해 주세요. 자세한 내용은 개인정보처리방침이용약관을 확인해 주세요.

버튼을 누르면 giscus와 GitHub에 연결됩니다.