mlx-audio + Qwen3-TTS로 로컬 뉴스 브리핑 음성 자동화하기

(수정: 2026년 3월 22일) · 7분 읽기 Threads Disquiet
목차

mlx-audio + Qwen3-TTS로 뉴스 브리핑 음성을 자동화한 기록. Apple Silicon 전용 제약을 NanoClaw의 skill 철학 안에서 풀어낸 과정.

Apple Silicon 칩에서 Telegram 채팅으로 음성 파형이 연결되는 아이소메트릭 일러스트

실제 출력 샘플 — mlx-audio + Qwen3-TTS로 생성한 뉴스 브리핑 음성

NanoClaw는 Claude를 코어로 동작하는 개인 AI 어시스턴트로, Telegram을 통해 일정 관리, 뉴스 브리핑, 태스크 자동화를 처리하는 로컬 에이전트 프레임워크다. 매일 아침저녁으로 이 어시스턴트에서 뉴스 브리핑을 텍스트로 받고 있었는데, 출근길 지하철에서 화면을 볼 수 없을 때 읽지 못한 브리핑이 쌓이는 게 아쉬웠다.

Google TTS나 ElevenLabs 같은 클라우드 API를 쓰면 한 줄로 해결된다. 솔직히 하루 2회, 2000자 기준이면 클라우드 비용은 월 수백 원 수준이다. 비용만 놓고 보면 로컬 TTS를 만들 이유가 약하다.

클라우드 TTS로컬 TTS (mlx-audio)
비용월 수백~수천 원 (사용량 비례)0원 (전기세 제외)
지연1~3초 (네트워크 포함)30~60초 (모델 로딩+생성)
프라이버시텍스트가 외부 서버로 전송로컬에서 완결
운영 복잡도SDK 버전 관리만Python, ffmpeg, 모델 업데이트 직접 관리
이식성어디서든 동작Apple Silicon 전용

그럼에도 로컬을 선택한 이유는 두 가지였다. 브리핑 텍스트에 개인 일정과 내부 프로젝트 정보가 포함되어 외부 전송을 피하고 싶었고, Apple Silicon 맥북에서 Neural Engine을 활용하는 mlx-audio의 한국어 음성 품질이 충분히 쓸 만한 수준이어서 직접 해볼 동기가 됐다. 프라이버시 요구가 낮거나 즉각적인 응답이 필요한 경우라면 클라우드 API가 명백히 나은 선택이다.

최적 설정을 찾기까지

mlx-audio에는 여러 모델과 음성이 있다. 사전 테스트를 거쳐 뉴스 브리핑에 가장 적합한 조합을 찾았다.

옵션선택 이유
모델Qwen3-TTS-12Hz-1.7B-CustomVoice-bf16한국어 발음 정확도와 자연스러움
음성sohee뉴스 톤에 적합한 여성 음성
속도2.2뉴스 아나운서 수준의 빠른 전달
temperature0.1낮은 랜덤성으로 한숨/호흡 아티팩트 제거
top_k30후보 토큰 범위 축소로 발음 안정성 확보
repetition_penalty1.3반복/불필요 토큰 생성 억제
instructprofessional news anchor...CustomVoice 모델의 톤/스타일 지시

초기에는 temperature 0.9, speed 1.75로 시작했지만, 실 사용 과정에서 한숨 쉬는 소리 같은 아티팩트가 발생했다. temperature를 0.1로 크게 낮추고, top_k도 100에서 30으로 줄이니 깔끔한 발화가 나왔다. repetition_penalty 1.3과 instruct 파라미터(CustomVoice 모델 전용)를 추가해서 뉴스 앵커 톤을 잡았고, 속도도 2.2로 올려 아나운서 느낌에 가까워졌다.

최종 확정된 mlx-audio CLI 호출 명령은 이렇다:

/opt/homebrew/bin/python3.12 -m mlx_audio.tts.generate \
  --text "뉴스 브리핑 텍스트..." \
  --model mlx-community/Qwen3-TTS-12Hz-1.7B-CustomVoice-bf16 \
  --voice sohee \
  --speed 2.2 \
  --temperature 0.1 \
  --top_k 30 \
  --repetition_penalty 1.3 \
  --instruct "professional news anchor, confident and authoritative tone, clear enunciation, no sighing or breathing sounds" \
  --max_tokens 4800 \
  --output_path /tmp/tts-work/ \
  --file_prefix briefing

주의할 점은 --output_path파일 경로가 아니라 디렉토리라는 것이다. --file_prefix와 조합해서 결과 파일을 찾아야 한다. 이 함정에 빠져서 첫 구현에서 “Is a directory” 에러를 만났다.

파라미터가 확정되자 다음 문제는 이 TTS 엔진을 NanoClaw의 어디에, 어떻게 넣느냐였다.

아키텍처: skill 철학과 현실의 충돌

NanoClaw는 skill 기반 확장을 지향한다. 새로운 기능은 가능한 한 skill 파일로 패키징해서, 다른 사용자도 /add-기능명으로 설치할 수 있어야 한다. 하지만 TTS 파이프라인에는 근본적인 제약이 있었다.

mlx-audio는 Apple의 MLX 프레임워크 위에서 동작하는 텍스트-음성 변환 라이브러리다. GPU 가속을 Apple Silicon의 Neural Engine으로 수행하기 때문에 macOS에서만 실행 가능하다. Docker나 Linux 컨테이너에서는 사용할 수 없다.

NanoClaw의 에이전트는 Linux 컨테이너 안에서 돌아간다. mlx-audio는 호스트(macOS)에서만 실행 가능하다. 이 간극을 컨테이너-호스트 간 파일 기반 IPC로 메울 것인가가 핵심 설계 문제였다.

처음에는 코어 코드를 직접 수정하려 했다. sendAudio 메서드, TTS 실행기, IPC 핸들러를 각각 추가하면 된다. 단순하고 확실하지만, NanoClaw의 확장 철학에 맞지 않았다. 두 번째로는 기존 use-local-whisper skill의 패턴을 참고했지만, 결국 코드 수정을 skill로 감싼 것일 뿐 본질은 같았다.

최종적으로 2계층 구조로 결정했다.

  • 호스트 skill (/add-local-tts): 설치와 적용을 안내하는 가이드. 코어 코드 수정은 직접 수행하되, skill 문서로 재현 가능하게 기록한다. /add-whatsapp, /add-slack과 같은 레벨의 채널 추가 skill이다.
  • 컨테이너 skill (tts-audio): 에이전트가 send_tts_audio 도구를 사용하는 방법을 안내한다.

이름도 add-mlx-audio(구현체 종속)에서 add-local-tts(기능 중심)로 바꿨다. 기존의 use-local-whisper(음성 수신)와 대칭을 이루는 네이밍이다.

실제 변경 범위는 다음과 같다.

파일변경
src/tts.ts신규. mlx-audio child_process + ffmpeg MP3 변환
src/types.tsChannel 인터페이스에 sendAudio? 추가
src/channels/telegram.tssendAudio 메서드 (sendImage 패턴 + 429 retry)
src/ipc.tstts_audio IPC 핸들러. fire-and-forget으로 비동기 처리
src/index.tssendAudio 의존성 주입
container/agent-runner/src/ipc-mcp-stdio.tssend_tts_audio MCP 도구
.claude/skills/add-local-tts/SKILL.md호스트 skill 가이드
container/skills/tts-audio/SKILL.md컨테이너 skill 가이드

이 구조의 trade-off:

영역제약현실적 영향
포터빌리티Apple Silicon 전용Linux/서버 이전 시 TTS 엔진 전면 교체 필요
엔진 교체환경변수(TTS_COMMAND, TTS_MODEL)로 교체 지점은 열려 있음같은 mlx-audio 내 모델 교체는 무리 없음. 완전히 다른 엔진(Bark, XTTS, 클라우드 API)으로의 전환은 tts.ts 재설계에 해당
운영 오버헤드Python, ffmpeg, 모델 등 로컬 종속성 직접 관리모델 업데이트, Python 호환성 등이 수동
리소스 소모Qwen3-TTS 1.7B 모델이 ~3.6GB 메모리 점유M1 Pro 16GB 기준 생성 중 다른 작업 체감 저하 있음. M4 Pro 24GB에서는 여유

사전 조건

# mlx-audio 설치 (Apple Silicon macOS 전용, 릴리스 주기가 빨라 버전 고정 권장)
pip install mlx-audio==0.4.1

# 모델 다운로드 (최초 1회, 약 3.4GB)
python3 -c "from mlx_audio.tts.models.qwen3_tts import Qwen3TTS; Qwen3TTS('mlx-community/Qwen3-TTS-12Hz-1.7B-CustomVoice-bf16')"

# ffmpeg 설치 (WAV → MP3 변환용)
brew install ffmpeg

mlx-audio는 Homebrew Python이 아닌 시스템 Python과 충돌할 수 있다. launchd 환경에서 python3이 Xcode 번들을 가리키는 경우가 있으므로, 명령어에는 절대 경로(/opt/homebrew/bin/python3.12)를 쓰는 것이 안전하다.

instruct 파라미터: --instruct는 CustomVoice 모델(파일명에 CustomVoice가 포함된 모델) 전용이다. 일반 Qwen3-TTS 모델에서는 무시된다. CustomVoice가 아닌 모델을 사용할 경우 이 파라미터를 제거해야 한다.

파이프라인 구조

컨테이너 에이전트가 TTS를 요청하면, IPC를 통해 호스트로 전달되고, 호스트에서 mlx-audio를 실행한 뒤 Telegram으로 오디오를 보내는 구조다.

sequenceDiagram
    participant Agent as Container Agent
    participant IPC as IPC (파일 기반)
    participant Host as Host (ipc.ts)
    participant TTS as tts.ts (mlx-audio)
    participant TG as Telegram

    Agent->>IPC: send_tts_audio(text, title)
    IPC->>Host: type: "tts_audio" JSON
    Host->>TTS: generateTtsAudio(text, mp3Path)
    Note over Host: fire-and-forget (비동기)
    TTS->>TTS: python3 mlx_audio → WAV
    TTS->>TTS: ffmpeg WAV → MP3
    TTS->>Host: mp3Path
    Host->>TG: sendAudio(mp3)
    Host->>Host: 임시 파일 삭제

핵심 설계 결정은 fire-and-forget 패턴이다. TTS 변환은 30초에서 60초가 걸린다. 이 동안 IPC 메시지 처리가 블로킹되면 다른 작업이 멈추기 때문에, TTS 요청을 받으면 즉시 응답을 반환하고 백그라운드에서 처리한다. 텍스트 브리핑은 이미 별도로 전송된 상태이므로, TTS가 실패해도 정보 전달에는 문제가 없다.

fire-and-forget의 한계: TTS 생성 실패를 사용자가 즉시 인지할 수 없다. 현재는 로그로만 기록한다. 음성이 주 인터페이스인 경우(운전 중, 운동 중 등) 이 구조를 그대로 쓰면 안 된다 — 실패 시 아무 알림 없이 음성이 사라지면 사용자 경험이 크게 훼손된다. 그 경우에는 작업 큐 + 상태 콜백 + Telegram 메시지 업데이트 구조가 필요하다. 실패 피드백이 필요하다면, TTS 요청 시 “음성 생성 중…” 임시 메시지를 먼저 보내고, 완료/실패 시 editMessageText로 교체하는 패턴이 실용적이다.

// 요청 시 임시 메시지 전송
const msgId = await channel.sendText?.(groupName, '⏳ 음성 생성 중…');
generateTtsAudio(text, mp3Path)
  .then(() => channel.sendAudio?.(groupName, mp3Path, title))
  .catch(() => channel.editMessage?.(groupName, msgId, '⚠️ 음성 생성 실패'))
  .finally(() => { channel.deleteMessage?.(groupName, msgId); ... });

모델 로딩에도 고려할 점이 있다. Qwen3-TTS 모델은 최초 호출 시 메모리에 로드하는 데 수 초가 걸린다. 하루 두 번 브리핑 용도라면 매번 cold start가 발생해도 문제없지만, 빈번한 호출이 필요한 경우에는 모델 상주 방식을 검토해야 한다.

나머지 트러블슈팅은 비교적 단순했다. --output_path가 디렉토리인 점은 앞서 언급했고, launchd 환경에서 python3이 Xcode 번들 python을 가리키는 문제는 절대 경로로, ffmpeg 미설치(spawn ffmpeg ENOENT 에러)는 brew install ffmpeg로 각각 해결했다. 음성이 1분 35초에서 잘리는 문제는 --max_tokens를 1200에서 4800으로 올리고, TTS_TIMEOUT도 3분에서 5분으로 늘려서 해결했다.

핵심 코드

파이프라인의 실제 동작을 이해하는 데 필요한 코드를 발췌한다.

tts.ts — 전체 파일 (import/export/error handling 포함):

import { exec } from 'child_process';
import { promises as fs } from 'fs';
import * as os from 'os';
import * as path from 'path';
import { promisify } from 'util';

const execAsync = promisify(exec);

const TTS_COMMAND = process.env.TTS_COMMAND ?? '/opt/homebrew/bin/python3.12';
const TTS_MODEL = process.env.TTS_MODEL ?? 'mlx-community/Qwen3-TTS-12Hz-1.7B-CustomVoice-bf16';
const TTS_VOICE = process.env.TTS_VOICE ?? 'sohee';
const TTS_SPEED = process.env.TTS_SPEED ?? '2.2';
const TTS_TEMPERATURE = process.env.TTS_TEMPERATURE ?? '0.1';
const TTS_TOP_K = process.env.TTS_TOP_K ?? '30';
const TTS_REPETITION_PENALTY = process.env.TTS_REPETITION_PENALTY ?? '1.3';
const TTS_INSTRUCT = process.env.TTS_INSTRUCT ??
  'professional news anchor, confident and authoritative tone, clear enunciation, no sighing or breathing sounds';
const TTS_MAX_TOKENS = process.env.TTS_MAX_TOKENS ?? '4800';
const TTS_TIMEOUT = parseInt(process.env.TTS_TIMEOUT ?? '600000', 10); // 10분

export async function generateTtsAudio(text: string, outputPath: string): Promise<void> {
  const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'tts-'));
  const prefix = 'audio';

  try {
    await execAsync(
      `${TTS_COMMAND} -m mlx_audio.tts.generate \
        --text "${text.replace(/"/g, '\\"')}" \
        --model ${TTS_MODEL} --voice ${TTS_VOICE} \
        --speed ${TTS_SPEED} --temperature ${TTS_TEMPERATURE} \
        --top_k ${TTS_TOP_K} --repetition_penalty ${TTS_REPETITION_PENALTY} \
        --instruct "${TTS_INSTRUCT}" \
        --max_tokens ${TTS_MAX_TOKENS} \
        --output_path ${tmpDir} --file_prefix ${prefix} --audio_format wav`,
      { timeout: TTS_TIMEOUT }
    );

    const files = await fs.readdir(tmpDir);
    const wavFile = files.find(f => f.startsWith(prefix) && f.endsWith('.wav'));
    if (!wavFile) throw new Error('WAV file not generated');

    // WAV → MP3 변환 + 후행 무음 트리밍
    await execAsync(
      `ffmpeg -i ${path.join(tmpDir, wavFile)} \
        -af "areverse,silenceremove=start_periods=1:start_silence=0.5:start_threshold=-40dB,areverse" \
        -codec:a libmp3lame -b:a 128k -y ${outputPath}`
    );
  } finally {
    await fs.rm(tmpDir, { recursive: true }).catch(() => {});
  }
}

환경 변수로 TTS 커맨드, 모델, 음성 파라미터를 교체할 수 있는 구조다. TTS_COMMAND를 바꾸면 다른 TTS 엔진으로 교체할 수 있고, TTS_MODEL로 모델 버전 업그레이드도 코드 변경 없이 가능하다. ffmpeg의 silenceremove 필터로 후행 무음을 자동 트리밍하는데, 모델이 max_tokens까지 무음 토큰을 생성하는 문제를 해결한다.

ipc.ts — fire-and-forget 패턴:

case 'tts_audio': {
  const { text, title } = parsed;
  // 즉시 반환, 백그라운드 실행
  generateTtsAudio(text, mp3Path)
    .then(() => channel.sendAudio?.(groupName, mp3Path, title))
    .catch(err => logger.warn(`TTS failed: ${err.message}`))
    .finally(() => fs.unlink(mp3Path).catch(() => {}));
  break;
}

agent-runner의 MCP 도구 등록:

{
  name: 'send_tts_audio',
  description: 'Convert text to speech and send as Telegram audio message',
  inputSchema: {
    type: 'object',
    properties: {
      text: { type: 'string', description: 'Text to convert to speech (1500-2000 chars recommended)' },
      title: { type: 'string', description: 'Audio file title shown in Telegram' }
    },
    required: ['text']
  }
}

설계 결정 요약

결정이유
IPC fire-and-forgetTTS 30~60초, 다른 IPC 메시지 블로킹 방지
WAV → ffmpeg → MP3Telegram MP3 선호, WAV 파일 크기 과다
TTS 실패 시 조용히 로그만텍스트 브리핑은 이미 전송 완료
환경변수로 설정 교체 가능TTS 엔진을 다른 것으로 대체 가능한 구조
스크립트 1500~2000자 기준6개 카테고리 뉴스 커버, 4~5분 분량

fire-and-forget 패턴의 trade-off는 파이프라인 구조 섹션에서 이미 다뤘다. 텍스트 브리핑이 이미 전송된 뒤 음성을 생성하므로 하루 두 번 정해진 시간의 브리핑 용도라면 로그만으로 충분하지만, 음성이 핵심 UX인 상황에서는 별도 설계가 필요하다.

이 구성이 적합하지 않은 경우

  • Apple Silicon이 아닌 환경: mlx-audio는 Apple Neural Engine에 의존한다. Linux/Windows에서는 Bark, XTTS, Coqui 등 대안을 검토해야 한다
  • 팀 공유가 필요한 경우: 개인 맥북에 종속된 파이프라인이라, 팀원이 동일 환경을 재현하기 어렵다
  • 실시간 응답이 필요한 경우: 30~60초 생성 시간은 브리핑에는 괜찮지만, 대화형 음성 인터페이스에는 부족하다

긴 텍스트와 OOM: 청크 분할 전략

운영하면서 뉴스 브리핑이 2500자를 넘기면 Qwen3-TTS가 OOM으로 죽는 문제를 만났다. 1.7B 모델이 ~3.6GB 메모리를 점유하는데, 긴 텍스트에 max_tokens 4800이 겹치면 메모리 피크가 24GB Mac의 한계에 부딪혔다.

해결책은 텍스트를 문단(\n\n) 단위로 800자 기준 청크로 분할하고, 각 청크를 순차 생성한 뒤 ffmpeg로 합치는 것이었다.

# 청크별 WAV 생성 후 concat
# 1. 각 청크를 개별 WAV로 생성 (generateTtsAudio 반복 호출)
# 2. 청크 사이 0.3초 무음 삽입 + fade-out으로 이어붙임 아티팩트 방지
# 3. 샘플레이트/채널 통일 (24kHz mono) — 불일치 시 concat 잡음 발생

# concat용 파일 리스트 생성
for f in /tmp/tts-chunks/*.wav; do echo "file '$f'" >> /tmp/concat-list.txt; done

# 합치기 + 무음 제거 + MP3 변환
ffmpeg -f concat -safe 0 -i /tmp/concat-list.txt \
  -af "silenceremove=stop_periods=-1:stop_duration=2:stop_threshold=-40dB,areverse,silenceremove=start_periods=1:start_silence=0.5:start_threshold=-40dB,areverse" \
  -codec:a libmp3lame -b:a 128k -y /tmp/briefing.mp3

silenceremovestop_periods=-1로 2초 이상의 모든 무음 구간을 제거해, 청크 경계의 부자연스러운 긴 정적을 없앴다. 800자 이하 텍스트는 기존 단일 호출을 유지한다. TTS_CHUNK_SIZE 환경변수로 분할 기준을 조정할 수 있다.

정리

“skill 철학을 지키되, 플랫폼 제약 앞에서는 타협하라”가 이 파이프라인의 교훈이었다. 코어 코드 수정은 피할 수 없었지만, 2계층 skill 구조로 재현 가능성을 확보했다.

실제로 사용해보니 매일 출근길과 퇴근길에 뉴스 브리핑을 화면 없이 듣는 습관이 생겼다. 처음 지하철에서 이어폰을 꽂고 자동 생성된 브리핑을 들었을 때, 화면을 보지 않아도 되는 자유가 생각보다 컸다. Qwen3-TTS의 한국어 품질은 아나운서 수준은 아니지만, 4~5분을 청취하는 데 피로감이 없는 수준이다.

이어서 읽기