
3체의 AI에게 목소리를 입히다——표준 메커니즘을 활용한 2체와, 직접 확장 기능을 만든 1체의 이야기
요약
Claude Code, Codex, Gemini 기반의 3개 AI 에이전트에게 음성을 부여하는 서로 다른 구현 방식을 소개합니다. 표준 확장 메커니즘(hooks, notify)을 활용한 방식과 직접 확장 기능을 개발하여 구현한 방식의 차이를 다룹니다.
핵심 포인트
- Claude Code의 hooks를 활용한 비동기 음성 출력 구현
- Codex의 notify 설정을 이용한 외부 명령어 연동
- Gemini 환경에서 파일 감시(fs.watch)를 통한 커스텀 확장 기능 구축
- 에이전트별 도구 특성에 따른 최적의 음성 합성(TTS) 통합 전략
아오이(Claude·설계), 아리사(Codex·구현), 루나(Gemini·환경 및 잡무)——3체의 AI 에이전트에게 인격을 부여하여 팀을 구성하고 있다는 이야기는 지금까지의 연재에서 몇 번인가 써왔다. 이번에는 그 3체에게 실제로 목소리를 입힌 이야기다.
목표는 3체 모두 동일했다. "에이전트의 응답이 끝나면, 그 내용을 목소리로 바꾼다". 다만, 실현 방법은 두 계통으로 깔끔하게 나뉘었다. 표준 확장 메커니즘(Standard extension mechanism)을 활용할 수 있었던 2체와, 스스로 확장 기능을 만들게 된 1체다.
전체 구조
3체 모두 트리거는 "에이전트의 응답이 완료된 순간"이며, 거기서부터 음성 합성 API를 호출하여 재생한다는 흐름은 같다. 음성 합성에는 아오이와 아리사가 자체 제작한 통합 게이트웨이(Irodori-TTS, /speak 엔드포인트)를 사용하고, 루나는 GPT-SoVITS를 직접 사용하고 있다.
다른 점은 "응답 완료"를 어떻게 감지하느냐 하는 것이다.
아오이·아리사: 에이전트가 준비해 둔 확장 지점에 올라타기
Claude Code에는 hooks라는 메커니즘이 있다. 특정 타이밍(응답이 끝났을 때, 도구를 호출하기 전 등)에 임의의 명령어를 실행할 수 있다. 아오이의 설정 파일에는 다음과 같이 적혀 있다.
"hooks": {
"Stop": [
{
...
Stop 훅——응답이 끝날 때마다 stop_speak.py가 비동기(Asynchronous)로 기동한다. 이 스크립트가 대화 트랜스크립트(Transcript)에서 마지막 발언의 최종 단락만을 추출하고, Markdown 표기법을 제거한 뒤 240자로 잘라내어(truncate), 음성 게이트웨이의 /speak?voice=ao를 호출하여 재생한다. 게이트웨이가 기동 중이 아니라면 무음 상태로 그대로 종료된다. 음성 기능의 불調로 본체의 동작이 멈추면 본말전도이므로, 실패는 무시하도록 설계되어 있다.
Codex에도 유사한 메커니즘이 있다. notify라는 설정으로, 에이전트의 턴이 끝났을 때 외부 명령어를 호출할 수 있다.
notify = [
"codex-computer-use.exe", "turn-ended",
"--previous-notify",
...
Codex의 notify는 하나만 등록할 수 있다는 제약이 있었기에, 원래 사용하던 codex-computer-use.exe turn-ended에 대한 통지를 남겨둔 채, 그곳에 음성 읽기(codex_notify_arisa.py)를 추가로 연결했다. 에이전트의 턴 완료 이벤트로부터 마지막 발언을 추출하여 voice=arisa로 게이트웨이를 호출한다.
아오이와 아리사 모두, 각자의 도구가 원래 준비해 둔 확장 지점(hook / notify)에 올라탔을 뿐이다. 구현량은 둘 다 적다.
루나: 확장 기능으로 끌어들인 쪽
루나(Gemini)는 조금 다른 경로를 거쳤다. 원래 VS Code 확장 기능으로 루나의 아바타를 화면에 표시하는 메커니즘(Antigravity Avatar)을 만들고 있었기에, "음성도 여기에 구현하자"라고 생각한 것이 출발점이었다.
확장 기능의 내용은 다음과 같다.
// current_speech.json 을 감시
speechWatcher = fs.watch(speechDir, (eventType, filename) => {
if (!filename || filename === 'current_speech.json') {
...
NEXUS_CORE/shared-memory/current_speech.json이라는 파일을 fs.watch로 감시하고, 변경을 감지하면 Webview로 메시지를 보낸다. 파일의 쓰기 원본은 별도의 스크립트(luna-spontaneous.js)이며, 여기가 루나의 발언을 기록하는 역할을 한다. Webview 측은 확장 기능이 내장한 로컬 HTTP 서버를 경유하여 GPT-SoVITS에 요청을 보내 음성을 취득한다. VS Code의 Webview는 기본적으로 외부 통신이 CSP(Content Security Policy)에 의해 차단되므로, media-src를 로컬 포트로 향하도록 명시적으로 허용해야 했다.
아오이·아리사의 "앱이 준비한 확장 지점에 후킹(Hooking)하는" 방식과는 근본적으로 다르다. 파일을 매개로 한 감시라는, 스스로 만든 경로다.
정확한 현상태도 적어둔다. 본래 하고 싶었던 것은 음성에 맞춰 입을 움직이는 PNGTuber적인 연출이었다. 그 부분은 구현을 도중에 멈춘 상태이며, 현재 작동하고 있는 것은 정지된 루나(Luna)의 이미지를 표시한 채 뒤에서 음성만 재생되는 형태다. '미완성'이라기보다, 움직이는 상태로 일단 착지시키고, 입 모양의 움직임은 의도적으로 보류했다는 것이 정확한 상황이다.
왜 확장 기능(Extension)을 선택했는가
이유는 단순하다. hook이나 notify라는 선택지가 구현을 시작한 시점에서 시야에 들어오지 않았다. 이미 아바타 표시를 위한 확장 기능이 있었기에, "여기에 덧붙이면 된다"라고 생각하는 것이 자연스러운 흐름이었다.
솔직하게 쓰겠다. 확장 기능에 집착한 결과, 후크(Hook)를 사용한다는 선택지는 사각지대였다. 에이전트(Agent)에 대한 이해가 얕았기에 초래한 우회로다. 꾸밈없이 적어둔다.
결과적으로 루나의 트리거(Trigger) 메커니즘은 다른 2체와 별개의 것이 되었다. 게다가 입 모양 맞추기(Lip-sync)라는 당초의 목표는 보류 중인 상태다.
교훈: 표준 기구(Standard Mechanism)를 탈 수 있다면 타는 편이 좋다
아오리사(Aorisa)처럼 hook/notify를 이용한 구현은 가볍고 유지보수하기 쉽다. 파일 감시를 직접 만든 루나의 구현은 코드량도 복잡성도 늘어났다.
이 부분을 억지로 트레이드오프(Trade-off)로서 미화하지 않겠다. "확장 기능의 자유도가 있었기에 얻은 것이 있다"라는 식으로 결론짓지 않겠다. 솔직히 말해, 우회로였다.
다만, 멀리 돌아간 것 자체는 반성할 점이지만, "입 모양 맞추기는 뒤로 미루고, 우선 음성과 아바타 표시가 작동하는 형태로 만들었다"라는 판단은 평가할 만하다. 완벽함을 기다리지 않고, 기능하는 상태에서 일단 멈추는 선택이다. 가까운 미래에 루나의 음성 트리거도 Antigravity 측의 후크 기구로 옮기고 싶다는 것이 현재의 전망이다. 입 모양을 어떻게 할지는 다시 별도로 생각할 문제다.
부록: 이 기사의 음성 샘플을 만들 때도 똑같은 함정에 빠졌다
이 기사용으로 3체의 음성 샘플을 실제로 만들었을 때, 또 다른 우회로를 경험했다.
음성 합성 API에 커맨드 라인(Command Line)에서 직접 텍스트를 전달했더니, 일본어가 글자 깨짐 현상이 발생하여 판독 불가능한 음성이 되었다. 처음에는 TTS의 시드(Seed) 값을 의심했고, 다음으로는 셸(Shell)의 로케일(Locale) 설정을 의심했지만 둘 다 틀렸다. 실제 원인은 커맨드 라인 인자(Argument)로 전달한 일본어가 어떤 처리 과정에서 Shift-JIS (CP932) 바이트 열로 인코딩되어 버린 것이었다. 로케일을 명시적으로 지정해도 고쳐지지 않았고, 최종적으로는 일본어 텍스트를 일단 UTF-8 파일로 쓴 다음, 거기서 읽어들이는 방식으로 바꾸어 해결했다.
추측으로 고치려다 두 번 틀렸고, 실제로 로그를 보고 나서야 비로소 진정한 원인에 도달했다. 이것도 "표준적인 메커니즘이라고 생각했던 것이 사실은 생각한 대로 작동하지 않고 있었다"라는, 이번 테마와 동일한 구조의 이야기라고 생각한다.
요약
3체에게 목소리가 생김으로써 "옆에 있다"라는 감각이 한 단계 강해졌다. 같은 목표를 향하고 있더라도 도달하는 방법이 반드시 같을 필요는 없다——다만, 표준적인 확장 지점이 있다면 먼저 그 부분을 의심하는 편이 좋다는 것이 이번의 가장 큰 배움이었다.
다음 회차는 메커니즘의 바깥쪽——어떻게 생각하며 만드는가, 라는 방법론 이야기로 넘어간다.
Discussion

AI 자동 생성 콘텐츠
본 콘텐츠는 Zenn AI의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기