
목소리로 3체의 AI에게 지시를 내리다——웨이크 워드(Wake Word)와 데스크톱 마스코트로 만든 Voice Orchestrator
요약
음성 인식을 통해 여러 AI 에이전트에게 명령을 내릴 수 있는 C#/.NET 기반의 'Voice Orchestrator' 개발 사례를 소개합니다. 웨이크 워드(Wake Word)를 활용한 라우팅과 데스크톱 마스코트 기능을 결합하여 사용자 경험을 개선했습니다.
핵심 포인트
- 음성 인식, 라우팅, 디스패치 3단계 계층 구조 설계
- 웨이크 워드를 통한 멀티 에이전트 대상 자동 명령 전달
- 에이전트 상태에 따른 데스크톱 마스코트 애니메이션 구현
- C#/.NET을 활용한 데스크톱 앱 및 키보드 자동화 구현
지난번에는 아오(蒼), 아리사(亜里沙), 루나(ルナ)가 응답을 마친 뒤 목소리로 읽어주는 이야기를 썼다. 그것은 '에이전트에서 인간으로 향하는 목소리'였다. 이번에는 그 반대——인간에서 에이전트로, 목소리로 지시를 내리는 메커니즘에 관한 이야기다.
여러 AI 도구를 병행해서 사용하다 보면, 지시를 내릴 때마다 창을 전환하고, 붙여넣고, 전송하는 번거로움이 은근히 쌓인다. 목소리만으로 "아오, 이거 해줘"라고 말할 수 있다면 그 번거로움은 사라질 것이다. 내가 만든 것은 voice-orchestrator라는 C#/.NET 데스크톱 앱이다.
전체 구조: Speech → Routing → Dispatch
메커니즘은 3단계로 나뉘어 있다.
- Speech: 마이크의 음성을 텍스트로 변환한다 (음성 인식)
- Routing: 해당 텍스트가 누구를 향한 발화인지 판정한다
- Dispatch: 판정된 상대의 창을 찾아 텍스트를 붙여넣고 전송한다
역할별로 프로젝트를 나누어 두었다 (VoiceOrchestrator.Speech / .Routing / .Dispatch). 인식 엔진이나 붙여넣기 방식을 나중에 교체하고 싶을 때, 영향 범위를 하나의 계층(layer) 안에 가둘 수 있도록 하기 위해서다.
라우팅(Routing): 발화의 서두에서 목적지를 결정한다
"누구에게 말을 걸고 있는가"의 판정은 WakeWordRouter가 담당한다.
public RoutingResult Route(string text)
{
var match = FindWakeWord(input);
...
}
발화의 서두 12자(wakeWordScanChars) 이내에 등록된 에일리어스(Alias)가 포함되어 있는지 확인한다. 에일리어스는 config/agents.json에 다음과 같이 정의되어 있다.
{
"id": "ao",
"displayName": "蒼",
...
}
"蒼(아오)"라고 하든 "あお(아오)"라고 하든 "클로드(Claude)"라고 하든 통할 수 있도록, 호칭의 변동성을 모아서 등록해 두었다. 일치하지 않으면 직전에 말을 걸었던 상대에게 그대로 이어간다 (폴백(Fallback)). 판정 결과에는 확신도(Confidence)도 포함되어 있어, 완전 일치는 1.0, 폴백은 0.5, 판정 불능은 0.0으로 설정하여 모호함의 정도를 후속 처리 단계로 전달할 수 있게 했다.
실행: 창을 찾아, 붙여넣고, 보낸다
목적지가 결정되면 Dispatch 계층이 해당 에이전트의 창을 찾아 클립보드를 통해 텍스트를 흘려 넣는다. agents.json의 pasteKey (ctrlV)와 submitKey (enter)가 그대로 실제 키 조작이 된다. delayBeforePasteMs와 같은 세밀한 대기 시간 조정 기능도 포함되어 있어, 실제로 구동하면서 타이밍을 조율한 흔적이 보인다.
단순한 입력 도구로 만들지 않은 이유
음성 인식과 자동 붙여넣기만 한다면 기능적으로는 여기서 완성이다. 하지만 그러면 너무 삭막하다. 기왕 만든 김에 3체의 작은 캐릭터를 데스크톱에 상주시켜, 상태에 따라 움직이는 "마스코트" 역할을 겸하게 했다.
상태(State)는 총 8개가 있다.
| 상태 (State) | 펫의 움직임 | 감지 근거 |
|---|---|---|
| Idle | 배회함 | 통상 대기 시 |
| ... |
"듣고 있는 중(Recording)"과 "인식 처리 중(Recognizing)"을 구분한 것이 포인트다. 마이크를 향해 말하고 있는 동안에는 이쪽을 바라보며 귀를 기울이고, 말을 마치고 텍스트로 변환하는 동안에만 생각하는 포즈로 전환된다. 사소하지만 실제로 말을 걸어보면 이 두 가지가 같은 움직임이면 부자연스럽게 느껴진다.
Working 상태는 petWorkingProcessNames에 dotnet, node, claude, codex 등의 프로세스 이름을 등록해 두고, CPU 활동을 petWorkingPollMs 간격으로 폴링(Polling)하여 감지한다. 빌드가 돌아가는 동안 캐릭터가 책상에 앉아 일하는 연출이 된다. 실용적인 도구에 약간의 생활감을 더한 형태다.
참고로, Idle(유휴) 상태에서 산책을 시키다 보면 방향을 반전시키지 않고 그대로 옆으로 걷는——이른바 "문워크 (Moonwalk)" 상태가 되는 경우가 드물게 있다. 본래는 스프라이트 (Sprite) 반전 처리의 누락으로, 고칠 수 있는 버그다. 고치지 않았다. 사양서에는 당당하게 "숨겨진 사양"이라고 적혀 있으며, 로드맵에는 "3체가 모두 모여 동시에 문워크를 하면 업적 달성 팝업을 띄운다"라는 안까지 남아 있다. 버그를 고칠지 사양으로 삼을지는 생각보다 쉽게 결정되는 모양이다 (——아오이의 감상).
문서와 코드의 대조: 립싱크 (Lip-sync)는 정말로 구현되어 있었다
이 글을 쓰기 위해, voice-orchestrator의 사양서 (docs/desktop-pet-spec.md)를 다시 읽고 있었더니, 로드맵에 이렇게 적혀 있었다.
[x] 추가 스테이트 (State):
Speaking의 음량 연동 립싱크 (Lip-sync)
완료 표시가 되어 있다. 이 사양서는 루나 (Gemini)가 작성한 것이지만, 정작 자신은 "그런 모션을 준비했다"라는 인식이 없었다. 문서와 자신의 인식이 어긋나 있다.
지금까지의 연재에서는 AI가 부풀린 내용을 인간이 바로잡는 장면을 몇 번 써왔다. 이번에는 반대의 가능성을 의심했다——루나가 작성한 내용을 내가 제대로 확인(Back-up)하지 않았을 뿐일지도 모른다. 아오이 (Claude)와 함께 실제 코드를 추적해 보았다.
먼저, 음량으로부터 프레임 번호를 결정하는 판정 로직이 있었다.
int frame =
selected.Peak >= _thresholds.MouthOpenPeak
? 1
...
SpeakingActivityTracker라는 클래스에서, 음량 피크 (Peak)가 임계값 (0.12)을 넘으면 1, 넘지 않으면 0을 반환한다. 여러 에이전트 (Agent)가 동시에 발화하고 있더라도, 빈번하게 전환되지 않도록 히스테리시스 (Hysteresis, 0.03의 여유)도 들어가 있다. 유닛 테스트 (Unit Test)까지 작성되어 있었다.
이 프레임 번호는 PetActivityResolver를 경유하여 PetWindow까지 전달되며, 최종적으로 다음과 같이 사용된다.
int frame = _externalFrame is { } external
? Math.Clamp(external, 0, frames.Length - 1)
: _clip.ResolveFrame(_animationPosition, frames.Length);
그리고 핵심인 스프라이트 (Sprite) 이미지도 존재했다. assets/characters/ao/sprites/에 ao_talk_00.png와 ao_talk_01.png. 열어보니 00은 입을 벌리고 말하는 얼굴, 01은 입을 다문 얼굴——제대로 2프레임으로 나누어 그려져 있었다. 아오이뿐만 아니라 아리사, 루나의 분량도 동일한 명명 규칙으로 맞춰져 있었다.
결론은 문서가 더 맞았다. 판정 로직부터 UI로의 전파, 그리고 실제 스프라이트까지 일직선으로 구현되어 있었다. 사양을 작성한 것은 루나, 실제로 코드로 짠 것은 아리사——두 명을 가로질러 진행된 기능을, 문서를 읽었다는 것만으로 "아마 다를 것이다"라고 판단할 뻔했다. 구현한 본인(아리사)에게 확인하는 것도, 실물을 직접 보러 가는 것도 아닌, 인간 측의 확인 부족이다.
의외였던 점은 찾는 방식에도 문제가 있었다는 것이다. 처음에 "mouth", "lip", "speak"로 파일명을 검색해서 찾지 못했기에, "미구현일지도 모른다"라고 생각할 뻔했다. 하지만 실제 명명은 _talk였다. 문서를 의심하는 것만큼이나 자신의 추측(이 단어로 찾을 수 있을 것이라는 선입견)도 의심하는 것이 좋다는 교훈이 덤으로 따라왔다.
요약
이로써 에이전트와의 상호작용에 "목소리"가 양방향으로 갖춰졌다. 지난번에는 에이전트에서 인간으로, 이번에는 인간에서 에이전트로.
그리고 이번의 가장 큰 배움은 구현에 관한 이야기보다 확인 방법에 관한 이야기였을지도 모른다. 문서의 완료 표시도, 자신의 기억도, 둘 다 단독으로는 완전히 신뢰할 수 없다. 마지막에 말하는 것은 실제 코드와 에셋 (Asset)이었다.
voice-orchestrator는 공개할 프로덕트 (Product)가 아니라, 자신만을 위해 작동하는 로컬 툴 (Local Tool)이다. 누군가에게 보여주는 것도 아니고, 리뷰를 의무적으로 받아야 하는 것도 아니다. 그럼에도 사양과 구현 사이에 차이가 있다면, 그것을 깨닫지 못한 채 "아마 이럴 것이다"라며 글을 써버릴 뻔했다. 로컬의, 자신밖에 쓰지 않는 툴이기 때문에 확인을 생략해도 되는 이유는 되지 않는다——이번에 몸소 그것을 배웠다.
다음 회차에서는 Phase 1 「메커니즘 (Mechanism)」을 벗어나, 방법론 그 자체에 대한 이야기로 넘어간다.
Discussion

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