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

실제 출력 샘플 — mlx-audio + Qwen3-TTS로 생성한 뉴스 브리핑 음성
NanoClaw는 Claude를 코어로 동작하는 개인 AI 어시스턴트로, Telegram을 통해 일정 관리, 뉴스 브리핑, 태스크 자동화를 처리하는 로컬 에이전트 프레임워크다. 매일 아침저녁으로 이 어시스턴트에서 뉴스 브리핑을 텍스트로 받고 있었는데, 출근길 지하철에서 화면을 볼 수 없을 때 읽지 못한 브리핑이 쌓이는 게 아쉬웠다.
Google TTS나 ElevenLabs 같은 클라우드 API를 쓰면 한 줄로 해결된다. 하지만 매일 두 번씩 호출하면 비용이 쌓이고, 브리핑 텍스트가 외부 서버로 나가는 것도 마음에 걸렸다. Apple Silicon 맥북에서 Neural Engine을 활용하는 mlx-audio라는 로컬 TTS 라이브러리가 있었고, 한국어 음성 품질이 충분히 쓸 만한 수준이어서 직접 파이프라인을 만들기로 했다.
사전 조건
파이프라인을 구성하기 전에 필요한 도구를 먼저 설치한다.
# mlx-audio 설치 (Apple Silicon macOS 전용)
pip install mlx-audio
# 모델 다운로드 (최초 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 ffmpegmlx-audio는 Homebrew Python이 아닌 시스템 Python과 충돌할 수 있다. launchd 환경에서 python3이 Xcode 번들을 가리키는 경우가 있으므로, 명령어에는 절대 경로(/opt/homebrew/bin/python3.12)를 쓰는 것이 안전하다.
최적 설정을 찾기까지
mlx-audio에는 여러 모델과 음성이 있다. 사전 테스트를 거쳐 뉴스 브리핑에 가장 적합한 조합을 찾았다.
| 옵션 | 값 | 선택 이유 |
|---|---|---|
| 모델 | Qwen3-TTS-12Hz-1.7B-CustomVoice-bf16 | 한국어 발음 정확도와 자연스러움 |
| 음성 | sohee | 뉴스 톤에 적합한 여성 음성 |
| 속도 | 2.2 | 뉴스 아나운서 수준의 빠른 전달 |
| temperature | 0.1 | 낮은 랜덤성으로 한숨/호흡 아티팩트 제거 |
| top_k | 30 | 후보 토큰 범위 축소로 발음 안정성 확보 |
| repetition_penalty | 1.3 | 반복/불필요 토큰 생성 억제 |
| instruct | professional 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” 에러를 만났다.
아키텍처: skill 철학과 현실의 충돌
NanoClaw는 skill 기반 확장을 지향한다. 새로운 기능은 가능한 한 skill 파일로 패키징해서, 다른 사용자도 /add-기능명으로 설치할 수 있어야 한다. 하지만 TTS 파이프라인에는 근본적인 제약이 있었다.
mlx-audio는 Apple의 MLX 프레임워크 위에서 동작하는 텍스트-음성 변환 라이브러리다. GPU 가속을 Apple Silicon의 Neural Engine으로 수행하기 때문에 macOS에서만 실행 가능하다. Docker나 Linux 컨테이너에서는 사용할 수 없다.
NanoClaw의 에이전트는 Linux 컨테이너 안에서 돌아간다. mlx-audio는 호스트(macOS)에서만 실행 가능하다. 이 간극을 어떻게 메울 것인가가 핵심 설계 문제였다.
처음에는 코어 코드를 직접 수정하려 했다. 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.ts | Channel 인터페이스에 sendAudio? 추가 |
src/channels/telegram.ts | sendAudio 메서드 (sendImage 패턴 + 429 retry) |
src/ipc.ts | tts_audio IPC 핸들러. fire-and-forget으로 비동기 처리 |
src/index.ts | sendAudio 의존성 주입 |
container/agent-runner/src/ipc-mcp-stdio.ts | send_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)로 교체 지점을 열어두었지만, cross-platform TTS 라이브러리(e.g. Coqui TTS)나 클라우드 API로 교체하려면tts.ts수정이 필요하다. - 운영 오버헤드: mlx-audio, ffmpeg, Python 환경 등 로컬 종속성을 직접 관리해야 한다. 클라우드 TTS는 SDK 버전 관리만 하면 되지만, 로컬은 모델 업데이트, Python 버전 호환성, ffmpeg 업그레이드 등이 수동이다.
- 리소스 소모: TTS 실행 중 Neural Engine과 RAM을 점유한다. 맥북으로 다른 작업을 병렬로 하는 경우 체감 성능 저하가 생길 수 있다.
파이프라인 구조
컨테이너 에이전트가 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 생성 실패를 사용자가 즉시 인지할 수 없다. 현재는 로그로만 기록한다. 실패 피드백이 필요하다면, 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가 발생해도 문제없지만, 빈번한 호출이 필요한 경우에는 모델 상주 방식을 검토해야 한다.
에피소드: 보이지 않는 세션 복사본
파이프라인 연결을 마치고 실제로 테스트하면서 예상치 못한 문제를 만났다. send_tts_audio MCP 도구를 추가했는데, 에이전트가 이 도구를 찾지 못했다. 코드는 분명히 추가되어 있고, 빌드도 정상이다.
원인은 NanoClaw의 세션 관리 방식에 있었다. container-runner.ts는 agent-runner/src를 세션 디렉토리에 최초 한 번만 복사한다. 한번 복사된 뒤에는 원본이 바뀌어도 세션의 복사본은 그대로다. 새 도구를 추가했지만, 기존 세션의 에이전트는 예전 코드를 보고 있었던 것이다.
기존 세션의 data/sessions/*/agent-runner-src/ipc-mcp-stdio.ts를 수동으로 갱신하거나, 세션을 삭제하고 새로 시작하면 해결된다. 단순하지만, 원인을 모르면 한참을 헤매게 되는 류의 문제다.
나머지 트러블슈팅은 비교적 단순했다. --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-forget | TTS 30~60초, 다른 IPC 메시지 블로킹 방지 |
| WAV → ffmpeg → MP3 | Telegram MP3 선호, WAV 파일 크기 과다 |
| TTS 실패 시 조용히 로그만 | 텍스트 브리핑은 이미 전송 완료 |
| 환경변수로 설정 교체 가능 | TTS 엔진을 다른 것으로 대체 가능한 구조 |
| 스크립트 1500~2000자 기준 | 6개 카테고리 뉴스 커버, 4~5분 분량 |
정리
결국 이 파이프라인의 교훈은 “skill 철학을 지키되, 플랫폼 제약 앞에서는 타협하라”는 것이었다. 코어 코드 수정은 피할 수 없었지만, 2계층 skill 구조로 재현 가능성을 확보했다.
실제로 사용해보니 매일 출근길과 퇴근길에 뉴스 브리핑을 화면 없이 듣는 습관이 생겼다. Qwen3-TTS의 한국어 품질은 아나운서 수준은 아니지만, 4~5분을 청취하는 데 피로감이 없는 수준이다. 클라우드 API를 쓰던 것과 실질적인 차이가 없으면서 비용은 0원이다.
앞으로 개선할 부분은 세 가지다. 빈번한 호출이 생기면 모델 상주 방식으로 cold start를 제거해야 한다. 실패 피드백은 위에서 언급한 editMessageText 패턴으로 구현할 예정이다. 그리고 tts.ts 자체는 NanoClaw에 의존하지 않는 범용 모듈이라, upstream PR로 분리 기여할 수 있는지 검토하고 있다.