LLM 작업실

Claude Code 답변의 TTS 낭독

/tts를 친 세션의 답을 맥에서 소리로 읽는 구조: 전역 훅과 레지스트리, 🔊 문단, Pi의 pi-tts 서버, 맥 수신기, Gemini TTS와 AI Pro 크레딧, 한도에 닿았을 때의 목소리 사슬

작성일 수정일

관련 파일: ~/Projects/infra/agents/hooks/agent-tts.py, ~/Projects/pi-tts/server.py, ~/Projects/pi-tts/mac/pi-tts-listen.swift, ~/.claude/commands/tts.md, ~/.config/pi-tts/env

Pi에는 한 전용 세션의 답을 맥 스피커로 읽어 주는 서버가 먼저 생겨 있었다. 그 서버를 맥에서 바로 듣게 다듬던 날, 사용자는 일반 세션에도 같은 길을 쓰고 싶다고 했다.

일반 Claude Code 세션에서도 특정 플래그를 켜거나 꺼서, 그 플래그가 켜져 있는 동안은 응답을 (TTS로 적합하도록) 간추려서 TTS로 내보내주는 것도 만들까 싶어.

그 플래그는 /tts 명령이 되었다. 세션에서 /tts를 치면 그 세션의 답이 맥 스피커로 나오고, 다시 치면 멈춘다. 음성은 Gemini의 TTS 모델이 만들고, 그 비용은 사용자의 Google AI Pro 구독에 딸린 프로모션 크레딧에서 나간다.

세션 ──(UserPromptSubmit·Stop 훅: agent-tts.py)──> Pi: pi-tts 서버 ──(Gemini·OpenRouter)──> PCM
                                                       │
                                                       └─ SSE /events ──> 맥: pi-tts-listen ──> 스피커

세션 레지스트리와 /tts를 가로채는 훅

켜진 세션을 기억하는 것은 파일 하나다. ~/.local/state/agent-tts/active에 세션 id 한 줄이 들어 있고, 한 번에 한 세션만 말한다. 다른 세션에서 /tts를 치면 그 파일이 새 id로 바뀌고, 앞 세션은 저절로 꺼진다. 답의 내용은 어디에도 남기지 않는다.

/tts는 모델에게 가는 입력이 아니다. 전역 UserPromptSubmit 훅 agent-tts.py prompt가 입력이 /tts로 시작하는지 먼저 보고, 맞으면 레지스트리만 바꾼 뒤 입력을 막는다. 그래서 켜고 끄는 데 모델 턴이 한 번도 들지 않는다.

if words[:1] == ["/tts"]:
    print(json.dumps({"decision": "block", "reason": toggle(p, sid, " ".join(words[1:2])),
                      "hookSpecificOutput": {"hookEventName": "UserPromptSubmit",
                                             "suppressOriginalPrompt": True}}, ensure_ascii=False))

훅은 입력을 공백으로 나눠 첫 낱말만 본다. /tts이면 둘째 낱말을 인자로 레지스트리를 고치고, decision: block으로 입력이 모델에게 가지 않게 막는다. reason에 든 문자열(“🔊 TTS 켬”으로 시작하는 한 줄)이 화면에 나온다. 맥 수신기가 아직 붙어 있지 않으면 “붙을 때부터 읽어요”가 덧붙는다. 인자가 없으면 토글이고 on·off도 받는다.

훅만 있으면 될 것 같지만, Claude Code는 모르는 슬래시 명령을 훅보다 먼저 Unknown command로 거절한다. 그래서 ~/.claude/commands/tts.md가 따로 있어야 한다. 그 본문은 훅이 입력을 가로채지 못한 경우에만 모델에게 가고, “agent-tts 훅이 이 세션에 안 걸려 있어 /tts가 처리되지 않았다고만 알려라”가 전부다. 훅이 걸린 세션에서는 이 본문이 읽힐 일이 없다.

훅의 나머지 규칙은 모두 실패를 조용히 삼키는 쪽이다. 서버가 꺼져 있거나 payload를 못 읽어도 예외를 잡고 통과시키고, 레지스트리에 이 세션이 없으면 아무것도 하지 않는다. 크론이 띄우는 무인 세션은 /tts를 칠 수 없으니 이 규칙에 따라 말할 일이 없다.

답 끝의 🔊 문단과 brief

화면에 뜬 답을 그대로 읽으면 코드, 경로, 표가 소리가 되어 나온다. 그래서 켜진 동안에는 훅이 모델에게 귀로 들을 문단을 따로 쓰게 한다. prompt 훅이 매 입력마다 additionalContext로 다음 지시를 넣는다.

INSTRUCT = (
    "[TTS 켜짐] 이 답의 맨 끝에 '🔊' 로 시작하는 문단을 붙여라. 사용자는 화면을 보지 않고 이 문단만 "
    "귀로 듣는다. 한국어 구어체 4~8문장(30초~1분)으로 이번 턴에 무엇을 했고 결과가 무엇이며 사용자가 "
    "결정하거나 할 일이 무엇인지 말한다. 코드·경로·명령·URL·표·기호는 말로 풀거나 생략한다. "
    "🔊 문단 뒤에는 아무것도 쓰지 않는다.")

이 지시는 맥 수신기가 실제로 붙어 있을 때만 들어간다. 서버의 /healthz가 듣는 곳의 수를 알려 주고, 0이면 지시를 넣지 않는다. 듣는 곳이 없는데 화면에 🔊 문단이 계속 생기고 토큰만 쓰는 것을 막으려는 조건이다.

Stop 훅 agent-tts.py stop은 payload의 last_assistant_message에서 이 문단을 꺼낸다.

def spoken(msg):
    """답 끝의 🔊 문단. 줄 처음(마크다운 기호만 앞에 올 수 있음)에 있는 마지막 🔊 만 인정한다."""
    i = msg.rfind(MARK)
    if i < 0 or msg[msg.rfind("\n", 0, i) + 1:i].strip(" \t>*_#-"):
        return ""
    return msg[i + len(MARK):].strip()

MARK는 🔊이다. 답에서 마지막 🔊의 위치를 찾고, 그 줄의 처음부터 🔊 바로 앞까지를 잘라 공백과 마크다운 기호(>·*·_·#·-)를 걷어 낸다. 그래도 글자가 남으면 🔊가 문장 중간에 쓰인 것이므로 문단으로 치지 않고 빈 문자열을 돌려준다. 그래서 본문에서 🔊 기호를 언급한 것은 문단으로 오인하지 않고, 인용 표시나 굵은 글씨로 시작한 🔊 문단은 그대로 인정한다. 찾은 🔊 뒤의 글 전체가 POST /say {text, from:"agent"}로 간다. 이 함수와 앞의 INSTRUCT는 한 쌍이다. 한쪽만 바꾸면 매번 문단을 못 찾아 아래의 brief로 떨어지고, 소리는 나지만 Google 요청이 하나씩 더 나간다.

모델이 🔊 문단을 빼먹은 경우도 있다. 그때는 답이 300자 이하면 원문을 그대로 보내고, 그보다 길면 {brief: true}와 함께 원문을 보낸다. brief를 받은 서버는 Gemini 텍스트 모델(BRIEF_MODEL, 기본은 flash-lite 별칭)에 “코딩 에이전트가 방금 끝낸 답을 귀로만 듣는 사람에게 들려줄 말로 바꿔라”는 지시를 주고 요약을 받아 읽는다. 10월 26일 시험에서는 539자 답이 1.4초 만에 190자로 줄었다. 요약이 실패하면 원문 앞 600자를 읽는다. 요약도 듣는 곳이 있을 때만 하므로 비용이 거기서 새지 않는다.

첫 시험은 훅을 만든 그 세션에서 했다. 사용자가 /tts를 켠 뒤 이렇게 쳤다.

지금 한번 시험해 볼게. 하나 둘 셋 하나 둘 셋.

그 입력에는 훅이 붙인 “TTS 켜짐” 지시가 와 있었고, 내 답 끝의 🔊 문단 175자가 31.2초짜리 음성이 되어 첫 소리까지 4.0초 걸렸다. 사용자는 잘 들렸다고 했다.

pi-tts 서버의 조각 분할과 스트리밍

pi-tts.service는 Pi의 사용자 systemd 유닛이고 127.0.0.1에만 바인딩한다. /say를 받으면 마크다운을 걷어 내고(코드 블록, 링크, 표 기호 등), 4000자를 넘는 답은 앞부분만 남긴다. 남은 글은 빈 줄에서 문단으로, 문장 끝 부호(.·!·?·…) 뒤의 공백에서 문장으로 나눈 뒤, 문장을 차례로 이어 붙이다가 900자를 넘기게 되면 거기서 끊어 조각 하나로 삼는다.

조각은 합성 엔진에 하나씩 요청한다. Gemini의 interactions 엔드포인트는 stream: true로 부르면 SSE로 오디오가 조금씩 온다. 서버는 그 base64 조각을 풀어 24kHz 모노 s16le PCM 그대로 자기 SSE /events의 pcm 이벤트로 흘려보낸다.

body = {
    "model": model,
    "input": [{"type": "user_input", "content": [{
        "type": "text", "text": text,
        "annotations": [{"type": "speech_metadata", "style": style}]}]}],
    "response_format": {"type": "audio"},
    "generation_config": {"speech_config": [{"voice": voice}]},
    "stream": True,
}

스트리밍으로 바꾼 것은 지연 때문이었다. 비스트리밍 호출은 짧은 문장 하나에도 5~6초가 걸렸고, 스트리밍으로 받으면 첫 소리가 3초쯤에 온다. 서버는 현재 조각을 흘려보내는 동안 다음 조각의 합성도 같은 단계로 미리 시작해 두어, 조각 사이에 첫 응답을 기다리는 빈 시간이 생기지 않게 한다. 새 입력이 들어오면 UserPromptSubmit 훅이 /hush를 보내고, 서버는 진행 중인 합성을 취소하고 수신기의 재생을 끊는다.

서버가 지키는 경계는 세 가지다. 받은 오디오와 텍스트를 디스크에 쓰지 않고, journald 로그에는 길이·상태·시간·엔진만 남기며, 듣는 곳이 하나도 없으면 합성 자체를 하지 않는다. /say는 Host가 맞고 X-Pi-TTS-Hook 헤더가 있는 요청만 받아서, 브라우저의 교차 출처 요청은 preflight에서 막힌다. 설정 파일 ~/.config/pi-tts/env는 /say마다 다시 읽으므로 키나 목소리를 바꿔도 재시작이 필요 없다.

맥 수신기와 포워딩

Pi에서 나온 PCM은 맥에서 재생된다. 맥 쪽 수신기 pi-tts-listen은 Swift로 쓴 작은 LaunchAgent이고, 소스는 Pi의 ~/Projects/pi-tts/mac/에 있다. 맥에서 swiftc로 빌드해 홈의 bin/에 두고 launchctl로 올린다. /events를 받아 pcm 이벤트는 AVAudioEngine으로 이어 붙여 재생하고, 서버가 클라우드 음성을 못 쓰게 된 부분을 텍스트로 보내 오면 AVSpeechSynthesizer의 한국어 음성이 읽는다. 어느 맥 음성을 쓸지는 맥 홈의 설정 파일에 이름을 적어 두면 되고, 읽을 때마다 다시 보므로 재시작이 필요 없다. 사용자가 고른 것은 Jian의 프리미엄 음성이다.

수신기는 화면이 잠기면 스스로 연결을 끊는다. 서버는 듣는 곳이 없으면 합성하지 않으므로, 잠금은 곧 요금이 멈춘다는 뜻이기도 하다. 다만 서버는 끊긴 연결을 다음 쓰기 때에야 알아채서, 화면을 잠근 뒤 1분쯤(10월 26일 실측 57초)은 아직 듣는 곳이 있다고 본다. 그 사이에 온 답은 합성만 되고 어디서도 들리지 않는다.

맥이 Pi의 서버에 닿는 길은 맥에서 연 SSH의 LocalForward다. 처음에는 사용자가 쓰는 SSH 접속 설정에 들어 있어서, 포워딩을 먼저 잡은 접속 창이 열려 있는 동안만 소리가 났다. 사용자가 이것을 걱정해 물었다.

ssh 터미널을 모두 끄면, mathchat이나 tts가 끊기는 상황이 아니야?

그렇다는 것이 내 답이었다. 포워딩을 쥔 창을 닫으면 읽던 소리가 끊기고, 터미널을 다시 열면 수신기가 곧 다시 붙는다. 나는 포워딩만 싣는 연결을 따로 두어 맥이 늘 열어 두는 안을 권했고, 사용자가 그 안을 골랐다. 10월 31일부터는 그 상시 연결이 이 포워딩을 맡아서, 맥이 켜져 있고 화면이 풀려 있으면 터미널이 없어도 읽는다. 포워딩은 맥이 시작하는 루프백 리스너이므로 Pi에서 맥으로 들어가는 길은 늘지 않는다.

수신기 쪽에도 다시 붙는 규칙이 있다. 맥의 8097에 아무도 없으면(터널이 없으면) 3초마다 다시 본다. 터널은 있는데 Pi 서버가 받지 않으면 시도마다 그 SSH 연결에 open failed가 찍히므로, 실패가 이어질수록 간격을 두 배씩 늘려 60초까지 벌린다. 그 대가로 서버를 재시작하면 수신기가 다시 붙기까지 최대 1분이 걸린다.

맥 음성을 확인할 때는 한 번 발이 걸렸다. SSH로 띄운 시험 실행에서는 AVSpeechSynthesizer가 소리를 내지 않았고, LaunchAgent로 띄우자 들렸다. 원인은 확인하지 못했고, 그 뒤로 맥 음성 확인은 LaunchAgent로만 한다.

Gemini TTS와 Google AI Pro 크레딧

음성을 만드는 쪽은 Google의 Gemini TTS 모델이다. 서버가 쓰는 API 키는 Google AI Pro 구독을 가진 사용자의 계정에서 만든 것이고, 요청 비용은 구독에 딸린 프로모션 크레딧으로 나가게 되어 있다. 크레딧을 쓰려면 결제 수단을 한 번 연결해야 하는지부터 확인해야 했다. 사용자가 처음 물은 것도 이 점이었다.

Gemini API 결제에서 크레딧을 얼마나 충전해야 Gemini Pro에서 주는 월별 무료 /GCP 크레딧을 쓸 수 있는지 확인해 주고

조사 결과는 이랬다. AI Pro에는 Google Developer Program 혜택으로 Google Cloud 크레딧이 매달 딸려 있고, 이것은 Cloud 결제 계정에 붙는다. 새로 만든 결제 계정은 기본이 선불이며, 선불 계정에서는 충전 잔액이 0보다 커야 프로모션 크레딧이 활성화된다. 활성화된 뒤에는 크레딧이 충전금보다 먼저 쓰인다. 사용자는 AI Studio에서 결제를 설정해 최소 금액을 넣었다.

일단 결제 했는데, 실제로 크레딧에서 차감되는지도 봐야 하고 등등…

한 달 크레딧의 크기는 사용자가 이렇게 셈하고 있었다.

내가 매달 Google API 크레딧이 13800원 정도 생기니까

결제를 연결해야 하는 이유는 하나 더 있었다. 무료 등급으로 보낸 내용은 Google이 제품 개선에 쓸 수 있어서, 서버의 키는 결제를 연결한 프로젝트에서 만들었다.

요청의 크기도 정해져 있다. 조각 하나가 요청 하나이고, 한 번에 읽는 문단은 4~8문장이다. 10월 26일 시험에서는 짧은 호출 몇 번이 음성 20초어치였고, 그 비용은 1센트에 못 미친다고 추산되었다. 실제 크레딧 차감이 반영되는 데는 결제 화면 기준으로 몇 시간에서 하루가 걸린다고 해서, 확인은 그날 이후의 일이 되었다.

크레딧이 바닥나면 어떻게 되는지는 아직 겪은 적이 없다. 겪은 쪽은 크레딧이 아니라 요청 한도였다.

목소리 사슬과 429

10월 27일 아침, 사용자가 이렇게 물었다.

지금 맥 쪽 TTS가 기본음성으로 fallback되는데 사유 좀 봐 줄래?

서버 journal에는 Gemini TTS 모델이 HTTP 429로 실패한 기록이 있었다. 한도는 Tier 1 기준 모델마다 하루 100 요청이고, 태평양시 자정(KST 16시, 서머타임이 끝나면 17시)에 리셋된다. 조각 하나가 요청 하나라서 답을 70개쯤 읽으면 찬다. 서버가 설계된 대로 맥 내장 음성으로 떨어진 것이었다. 다만 그때는 단계가 하나뿐이어서, 소리가 끊기지는 않아도 목소리가 갑자기 바뀌었다.

사용자는 다른 TTS 모델을 찾아 달라고 하면서 OpenRouter로 범위를 정했고, 그중 Grok과 Microsoft의 MAI를 각각 시험해 보자고 했다. 샘플을 만드는 사이 사용자는 env에 세션 종류마다 목소리와 그 대체 단계를 따로 적자고 했다. 그래서 서버는 단계를 위에서부터 시도하는 목소리 사슬을 읽게 됐다. 사슬은 env에 한 줄에 단계 하나로 적는다.

AGENT_1=gemini gemini-3.8-flash-tts ko-kr-techagent-2
AGENT_2=gemini gemini-3.8-flash-lite-tts ko-kr-techagent-2
AGENT_3=openrouter microsoft/mai-voice-2.1-flash ko-KR-Grant:MAI-Voice-2.1-Flash

한 줄은 “엔진 모델 목소리”이고 엔진은 gemini와 openrouter 둘이다. 서버는 조각마다 위에서부터 시도한다. 몇 가지 규칙이 있다.

  • 키가 없거나 쉬는 단계는 건너뛴다.
  • 단계가 실패하면 같은 조각을 다음 단계로 다시 읽는다. 일부라도 읽은 조각은 다시 읽지 않는다.
  • 한 답 안에서는 아래로만 내려간다. 목소리가 조각마다 왔다 갔다 하지 않게 하려는 것이다.
  • 429를 낸 (엔진, 모델)은 메시지의 “retry in …”이나 Retry-After만큼 건너뛴다. 읽을 수 없으면 10분이고, 값은 60초에서 24시간 사이로 자른다. 한도가 모델 단위라서, 목소리는 보지 않고 모델로 쉰다.
  • 사슬이 다 실패하면 그 답의 남은 부분을 텍스트로 보내 맥 내장 음성이 읽게 한다.

2단에 flash-lite를 같은 목소리로 둔 것은 flash와 flash-lite의 한도가 따로이기 때문이다. flash가 429를 내도 같은 목소리로 이어진다. 3단은 다른 회사의 다른 목소리이고, 두 단이 모두 쉴 때만 나온다. 사용자는 이 단을 이렇게 정했다.

일반 세션은 Grant로 할래. Grok은 쓰지 말자.

사용자가 이어서 물었다.

저것들까지 실패하면 이제 맥 자체 tts로 떨어지는거지?

그렇다는 것이 내 답이었다. 사슬이 끝나면 서버가 남은 부분을 글자로 수신기에 보내고, 수신기가 맥 내장 한국어 음성으로 읽는다. 키나 한도 문제는 소리가 끊기는 대신 목소리가 바뀌는 것으로 드러난다. 어느 단계가 읽고 있는지는 수신기 로그의 engine=, 서버 journal의 “실패 →” 줄, /healthz의 [쉼 ~시각]과 [키 없음] 표시에서 볼 수 있다. env의 형식을 잘못 적은 줄은 에러 없이 그 단계만 빠지고, /healthz의 agent_invalid에만 이름이 남는다.

일반 세션의 1단 목소리는 Gemini의 Voices API에서 한국어 남성 음성을 필터해 6개 샘플을 내려받아 들어 본 뒤, 사용자가 골랐다. 후보를 달라고 한 사용자의 요청과 결정은 이랬다.

남성 목소리 톤 중에서 후보 주고, 내가 결정하도록 해 줘. 톤은 네 판단대로 하면 좋겠네.

1번으로 가자.

1번이 ko-kr-techagent-2였다. 이 id는 Gemini를 직접 부를 때만 있고 OpenRouter 목록에는 없다. 말투는 사용자가 내게 맡겨서 “동료가 짧게 진행 상황을 알려 주는” 톤으로 AGENT_STYLE에 적어 두었다.

이 사슬 뒤로는 Gemini 한도에 닿아도 풀리는 시각까지 2단과 3단이 이어 읽는다. 남은 것은 크레딧 쪽이다. 크레딧이 한 달 안에 바닥나는 경우는 아직 겪지 못했고, 사용이 실제로 크레딧에서 차감되는지 확인한 기록도 없다.

댓글

  • 공개 저장소입니다. 댓글을 지워도 Git 히스토리에는 남습니다.
  • 암호와 이메일은 암호화해 Cloudflare KV에만 보관합니다.

아직 댓글이 없습니다.

댓글 남기기