"이 버그 고쳐줘"라고 말하면 즉시 백그라운드 작업(background task)으로 던져 두고, 사용자는 계속 다른 이야기를 할 수 있다. 작업이 끝나면 비서가 조용한 틈을 골라 "다 됐어요"라고 자연스럽게 끼어들어 말한다.
QwenAudio/qwen-audio-agent · 라이선스 Apache-2.0 · 언어 JavaScript/Node(ESM, Node 22.22.2+) · npm 워크스페이스 모노레포 5개(server·web·tui·desktop·cli) · 음성엔진 Qwen Audio 3.0 Realtime(알리바바 DashScope) · 백엔드 연결 ACP 9종 · 프론트 WebUI·터미널 TUI·macOS 데스크톱 오브 · 최신 릴리스 v1.2.0(v1.3.0 테스트 중) · 추적 파일 276개 · 테스트 파일 73개 · 최신 커밋 2026-08-02 · TrendShift Daily #7)
qwen-audio-agent는 "이미 있는 코딩 에이전트(OpenCode·Codex·Claude Code 등)를 그대로 두고, 그 앞에 실시간 음성 대화라는 얼굴 하나를 붙여, 사용자가 말로 지시하고 · 대화가 끊기지 않고 · 무거운 작업은 뒤에서 병렬로 처리되게 하는 Node.js 음성 런타임"이다. 한마디로 에이전트의 "입과 귀"다. 두뇌(실제 코딩·툴 실행)는 기존 에이전트가 맡고, 이 프로젝트는 그 두뇌에 항상 켜져 있는 음성 채널을 연결해 준다.
콜센터에 전화하면 상담원이 계속 말을 이어 준다. "네, 확인해 볼게요… 잠시만요… 아, 그건 이렇게 하시면 돼요." 그 사이 뒤에서는 백오피스 직원들이 실제 서류를 뒤지고 시스템을 조작한다. 상담원은 당신을 침묵 속에 방치하지 않고, 뒷일이 끝나면 "처리됐습니다"라고 알려 준다.
qwen-audio-agent가 바로 이 구조다. Realtime 음성 계층(상담원)은 즉답과 대화를 담당하고, 진짜 작업은 백엔드 코딩 에이전트(백오피스)에게 spawn_thinking으로 넘긴다. 넘기는 순간 기다리지 않고 대화가 계속되며, 결과가 나오면 조용한 틈에 자연스럽게 말로 전달된다. "말하는 사람"과 "일하는 사람"을 분리한 것이 이 프로젝트의 전부라 해도 된다.
사용자가 마이크에 대고 한 마디 하면 시스템 내부에서 벌어지는 일을 압축하면 이렇다:
여기서 핵심은 ④의 갈림길이다. Realtime 계층은 "지금 당장 입으로 답할 수 있는 것"(시간·날씨 같은 간단한 응답, 기억 조회)만 직접 처리하고, 조금이라도 무거운 것은 전부 기다리지 않고(non-blocking) 백엔드로 던진다. 이 "안 기다림"이 대화의 연속성을 만든다.
첫째, 음성 AI의 고질병을 정면으로 건드린다. Siri든 ChatGPT 음성 모드든, "질문하면 → 처리하는 동안 어색한 침묵 → 답변"의 한 턴 왕복 구조다. 특히 에이전트가 실제 일(코드 수정, 자료 검색)을 하기 시작하면 그 침묵이 몇 초~몇 분으로 늘어난다. qwen-audio-agent는 "작업은 뒤로 넘기고 대화는 계속한다"는 발상으로 이 침묵을 없앤다. 음성 UX를 써 본 사람이라면 곧바로 가치를 체감하는 각도다.
둘째, 특정 에이전트에 묶이지 않는다(vendor-neutral). 이 프로젝트는 자기만의 에이전트를 새로 만들지 않았다. 대신 ACP(Agent Client Protocol)라는 표준 통로로 이미 존재하는 코딩 에이전트 9종(OpenCode·OpenClaw·Qoder·Kimi·Hermes·CodeBuddy·Codex·Claude Code·범용 ACP)에 붙는다. "음성은 우리가, 두뇌는 네가 쓰던 걸로" — 이 조합 자유도가 개발자에게 매력적이다.
자동차의 "핸즈프리 키트" 같다. 핸즈프리 키트는 엔진을 새로 만들지 않는다. 당신 차(=기존 에이전트)에 블루투스 마이크·스피커만 얹어, 운전하면서 전화·음악을 손 안 대고 쓰게 한다. qwen-audio-agent도 코딩 에이전트를 바꾸지 않고, 그 위에 "말로 부리는 인터페이스"만 얹는다. 게다가 어떤 차에든(9종 에이전트) 붙는 범용 키트다.
셋째, 진입 장벽이 낮다. 음성 모델로 알리바바 Qwen Audio 3.0 Realtime을 쓰는데, 알리바바 클라우드 바이롄(百炼/Model Studio)이 무료 체험 크레딧을 준다. 설치는 npm install -g qwen-audio-agent 한 줄이면 되고, macOS에는 데스크톱 앱(플로팅 음성 오브)까지 있다. "무료로 지금 당장 말로 에이전트를 부려 본다"는 체험이 쉽게 손에 잡힌다.
대부분의 음성 데모는 "말하면 답한다"에서 멈춘다. 이 프로젝트는 그 너머의 어려운 문제들을 다룬다 — 비서가 말하는 중에 끼어들기(barge-in), 백그라운드 작업을 대화와 병렬로 굴리기, 작업 결과를 "사용자가 말하는 중엔 끼어들지 않고, 조용해지면 자연스럽게" 전달하기(안전 삽입 창), 재시작해도 진행 중이던 작업 복구하기. 음성 인터페이스를 실제 제품 수준으로 끌어올리려 할 때 부딪히는 문제들의 참고 구현으로서 가치가 크다.
GitHub 조직명이 QwenAudio라 알리바바 Qwen 공식 저장소로 착각하기 쉽다. 정확히는 Qwen Audio "모델"을 음성 엔진으로 쓰는 프로젝트다. 코드가 접속하는 외부 호스트는 알리바바 DashScope(dashscope.aliyuncs.com)와 로컬(127.0.0.1)뿐이고, 텔레메트리·지갑 유출·난독화 설치 스크립트 같은 스캠 징후는 없다(Apache-2.0, 정식 SECURITY/PRIVACY 문서 구비). 다만 마이크 오디오가 알리바바 클라우드로 전송된다는 점은 인지하고 써야 한다.
이 저장소는 npm 워크스페이스(workspaces) 모노레포다. 즉 package.json 하나가 여러 하위 패키지를 묶고, 서로를 로컬 의존으로 참조한다. 5개 워크스페이스가 각자 역할을 맡는다:
Gateway 본체는 순수 Node ESM으로 짜였고, 웹 서버 프레임워크는 무겁지 않은 조합을 쓴다.
| 패키지 | 버전 | 용도 |
|---|---|---|
| express | ^4.22 | REST API(/api/health·/api/tasks·/api/timeline 등)와 정적 web/dist 서빙 |
| ws | ^8.21 | WebSocket 서버·클라이언트 양쪽. 브라우저↔Gateway, Gateway↔DashScope 모두 이걸로 |
| @agentclientprotocol/sdk | 1.3.0 | ACP — 백엔드 코딩 에이전트와 stdio JSON-RPC 통신 |
| @modelcontextprotocol/sdk | 1.29.0 | MCP — 백엔드에 "세션 제어 툴"을 주입할 때 |
| zod | ^4.4 | 이벤트·설정 스키마 검증(런타임 타입 안전) |
| 프론트 | 스택 | 특징 |
|---|---|---|
| web/ (WebUI) | React 19 + Vite 7 + react-markdown | 브라우저 getUserMedia → ScriptProcessorNode로 16kHz PCM 캡처. 데스크톱 렌더러로도 재사용 |
| tui/ (터미널) | 프레임워크 없음 — raw ANSI + readline | 1,228줄 순수 Node. 오디오만 플랫폼별 네이티브 브리지로 분리 |
| desktop/ (macOS) | Electron 43 + electron-builder/updater | frame 없는 투명 플로팅 창(오브). 내장 Gateway를 직접 소유·관리 |
macos-voice-io.swift(CoreAudio, 552줄)가 에코 제거(AEC)가 내장된 전이중을, Linux/Windows는 portaudio-voice-io.py(sounddevice, 240줄)가 반이중을 담당한다. "JS는 조율, 무거운 오디오는 네이티브"라는 역할 분담이다.실제 음성 인식·생성은 알리바바 클라우드의 DashScope가 한다. Gateway는 이 클라우드에 WebSocket으로 접속해 오디오를 주고받는다. 접속 규격은 OpenAI Realtime API 호환(beta dialect)이라, OpenAI Realtime을 다뤄 본 사람에겐 메시지 구조가 익숙하다. 입력 16kHz·출력 24kHz PCM.
사용자에게는 하나의 음성 비서로 보이지만, 내부는 아는 것과 할 일을 셋으로 나눈다(docs/architecture.md).
이 분리에는 엄격한 규칙이 있다. Realtime 계층은 백엔드 어댑터를 import하지 못하고, UI는 공개 이벤트만 소비한다. 즉 "입"은 "두뇌"의 내부를 모른 채 정해진 통로(6개 툴)로만 부탁하고, 화면은 결과 이벤트만 받는다. 계층 간 의존을 단방향으로 고정해 복잡도를 가둔 설계다.
qwenaudio를 실행하면 뜨는 것이 Gateway다. Express(HTTP) + ws(WebSocket) 서버가 기본 127.0.0.1:3101에서 돈다.
/api/health, /api/tasks(+/:id, /:id/events SSE), /api/timeline, /api/permissions/:id/api/realtime 경로만 수락 — Origin/loopback 검증 + 신원(identity) 필수qwenaudio gateway install이 macOS launchd(~/Library/LaunchAgents/*.plist) 또는 Linux systemd --user 유닛을 만들어 개인 비서를 24시간 켜 둔다. 로그는 ~/.config/qwaudio/logs/gateway.log흔히 실시간 음성이라면 WebRTC를 떠올리지만, 이 프로젝트는 전 구간 raw PCM을 WebSocket에 실어 보낸다. WebSocket 홉이 두 번 있다:
DashScope 세션을 여는 협상 코드(server/src/voice/providers/dashscope.mjs). 여기서 smart_turn이 서버측 VAD(음성 구간 감지)로, "사용자가 말을 끝냈다"를 클라우드가 판단하게 한다:
session.modalities = textOnly ? ['text'] : ['text', 'audio']
session.voice = config.audioVoice // 기본 'longanqian' 음색
session.input_audio_format = 'pcm' // 입력 16kHz
session.output_audio_format = 'pcm' // 출력 24kHz
session.turn_detection = textOnly ? null : { type: 'smart_turn' } // 서버측 VAD
smart_turn)에 이 판단을 맡긴다. 사용자가 말을 시작하면 클라우드가 speech_started 신호를 쏘고, 그걸 받아 Gateway가 재생을 끊는다(다음 항목).비서가 한창 말하는 중에 사용자가 입을 열면, 위 VAD가 input_audio_buffer.speech_started를 보낸다. Gateway는 이걸 받는 즉시 재생을 끊고 상태를 "듣는 중"으로 되돌린다(server/src/voice/realtime-gateway.mjs):
if (event.type === 'input_audio_buffer.speech_started') {
userSpeaking = true
clearResponseCandidate()
announcements.dismissActive()
send(ws, { type: 'playback.clear', reason: 'user_interruption' }) // 클라 재생 즉시 중단
send(ws, { type: 'turn.started', turnId })
send(ws, { type: 'voice.state', state: 'listening', turnId })
frontend?.cancel() // 업스트림(DashScope)에 response.cancel
}
반이중(TUI에서 x 키)에서는 VAD 대신 사용자가 보낸 명시적 interrupt 이벤트가 같은 경로를 탄다. 즉 "자동 감지"든 "수동 키"든 끼어들기 로직은 하나로 통일돼 있다.
백엔드 접속의 심장은 ACP 핸드셰이크다(server/src/agent/acp-process-client.mjs). 에이전트를 자식으로 spawn하고, 그 stdin/stdout을 줄바꿈 구분 JSON 스트림으로 감싼 뒤 프로토콜 버전을 맞춘다:
const child = this.spawn(this.command, this.args, { cwd, env, stdio: ['pipe','pipe','pipe'] })
const stream = acp.ndJsonStream(Writable.toWeb(child.stdin), Readable.toWeb(child.stdout))
const app = acp.client({ name: 'qwen-audio-agent' })
.onRequest(acp.methods.client.session.requestPermission, ctx => this.handlePermission(ctx))
.onNotification(acp.methods.client.session.update, ctx => this.handleUpdate(ctx.params))
const connection = app.connect(stream)
this.initializeResult = await this.context.request(acp.methods.agent.initialize, {
protocolVersion: acp.PROTOCOL_VERSION,
clientInfo: { name: 'qwen-audio-agent', version: PACKAGE_VERSION },
}, { signal: AbortSignal.timeout(15_000) }) // 버전 불일치·무응답이면 즉시 에러
어떤 에이전트에 붙을지는 AGENT_PROTOCOL 환경변수로 고른다(9종). 접속 방식은 조금씩 다르다:
| AGENT_PROTOCOL | 접속 방식 |
|---|---|
| opencode · qoder · kimi | 네이티브 ACP (에이전트가 ACP를 직접 말함) |
| codex | 외부 어댑터 @agentclientprotocol/codex-acp를 경유 |
| claude | @zed-industries/claude-code-acp 어댑터 경유 |
| hermes · codebuddy | hermes acp / codebuddy --acp 서브커맨드 |
| openclaw | 내장 ACP 브리지(전용 게이트웨이 127.0.0.1:18789) |
| acp (범용) | ACP_COMMAND/ACP_ARGS로 임의의 ACP 에이전트를 코드 수정 없이 연결 |
에이전트가 9종이지만 공용 어댑터(acp-backend-adapter.mjs, 1,506줄) 하나가 Work 라이프사이클·위임·권한을 통일한다. 새 에이전트를 추가할 때 전체 로직을 다시 짜지 않고, 프로토콜 계약만 맞추면 된다. 특히 acp(범용) 모드는 Gateway 코드를 한 줄도 안 고치고 새 에이전트를 붙일 수 있게 해 둔 확장점이다. "구체 구현이 아니라 프로토콜에 의존하라"는 원칙의 실물 예시.
Realtime의 spawn_thinking(목표) 툴은 결과를 기다리지 않는다. 즉시 "접수됨"만 반환하고 사용자는 계속 말할 수 있다. 실제 실행은 Task 계층이 맡는다(server/src/task/):
maxConcurrent=4, owner당 2. 단 한 owner의 백엔드 세션엔 한 번에 하나의 Work만 직렬 투입(FIFO)queued → running → (delegated → finalizing) → completed (+ cancelling/failed)tasks.json에 원자적 기록(temp→rename) — Gateway가 재시작해도 진행 중이던 작업을 복구주방의 "패스(pass) 벨"과 같다. 웨이터(Realtime)는 주문을 주방(백엔드)에 넣고 서서 기다리지 않는다 — 다른 테이블을 계속 응대한다. 요리가 완성되면 주방이 벨을 울리지만, 웨이터는 아무 때나 뛰어가지 않고 지금 손님과 말하는 중이 아닌 틈을 봐서 음식을 내온다. "안전 삽입 창"이 바로 이 타이밍 감각을 코드로 만든 것이다.
구조가 알려주는 힌트 둘. 첫째, 가장 큰 두 파일이 realtime-gateway.mjs(1,566줄)와 acp-backend-adapter.mjs(1,506줄)다 — 이 프로젝트의 무게중심이 "음성 파이프라인"과 "백엔드 연결" 딱 두 곳에 실려 있음을 그대로 보여 준다. 둘째, voice/·agent/·task/·conversation/로 폴더가 책임별로 갈려 있어, 관심사(음성/에이전트/작업/기억)가 코드 트리에 1:1로 대응된다. 새 기여자가 "어디를 봐야 하나"를 폴더 이름만으로 찾게 한 배려다.
OpenAI Realtime 호환 규격으로 WebSocket 위에 오디오를 스트리밍하는 실제 코드를 볼 수 있다. session.update로 modality·VAD·음색을 협상하고, audio.append/audio.delta로 PCM을 주고받고, speech_started로 끼어들기를 감지하는 흐름 — 실시간 음성 앱을 만들 때 반드시 마주치는 개념들을 동작하는 참조 구현으로 익힌다. "왜 WebRTC 대신 WS+PCM인가"를 두고 트레이드오프를 생각해 보는 것도 좋은 공부다.
요즘 뜨는 Agent Client Protocol을 실전으로 배운다. 자식 프로세스를 띄우고 stdin/stdout으로 JSON-RPC를 나누는 패턴, initialize 핸드셰이크, 세션 라이프사이클(new/prompt/cancel/close), 권한 요청 콜백까지. 이걸 알면 Codex·Claude Code 같은 CLI 에이전트를 내 프로그램에 붙이는 법이 손에 잡힌다.
"작업을 기다리지 않고 던진다(spawn_thinking) → Work 큐로 병렬 실행 → 조용한 틈에 결과를 자연 발화"라는 비동기 UX 설계. FIFO 큐, 동시성 상한, "안전 삽입 창" 타이밍 로직은 채팅·알림·음성 어디에나 응용된다. 사용자를 기다리게 하지 않는 인터페이스를 코드로 어떻게 만드는지의 표본.
음성 백엔드를 provider(DashScope/로컬 s2s) × protocol(beta/GA dialect) × capabilities(미지원 항목만 선언)로 나눠, provider 이름으로 if 분기하지 않고도 규격 편차를 흡수한다. "차이가 나는 부분만 데이터로 선언"하는 이 설계는 여러 외부 API를 하나로 감쌀 때 두고두고 쓰는 패턴이다.
기본 loopback 전용, DNS rebinding 방어(request-security.mjs), 시크릿 파일 0600 권한, 저장소에 키를 안 남기는 습관, 원격 노출 시 인증 리버스 프록시 + 명시적 허용 Origin 강제. "개인 비서를 내 기기에서 안전하게 돌린다"는 요구를 어떻게 코드로 방어하는지 배운다.
같은 앱이 macOS는 Swift CoreAudio(AEC 전이중), Linux/Windows는 Python PortAudio(반이중), 브라우저는 Web Audio로 갈라지는 실물. 저지연 오디오에서 JS의 한계를 네이티브 브리지로 보완하는 현실적 타협을 볼 수 있다.
AGENT_PROTOCOL을 비우면(=none) 백엔드 위임 없이 순수 음성 대화만 돈다. 먼저 이 모드로 "말 걸고 끼어들기"를 체험한 뒤, 백엔드를 붙여 spawn_thinking이 대화를 막지 않는 걸 관찰하면, 이 프로젝트가 푸는 문제가 무엇인지 몸으로 이해된다.
| 항목 | 필요한 것 |
|---|---|
| 런타임 | Node.js 22.22.2+(또는 24.15+/26+), npm 10+. nvm use로 .nvmrc 자동 적용 |
| 음성 API 키 | DashScope API Key(알리바바 바이롄/Model Studio). 무료 체험 크레딧 제공 → config.env에 DASHSCOPE_API_KEY |
| 백엔드(선택) | 실제 작업 위임을 쓰려면 OpenCode·Codex·Claude Code 등 ACP 에이전트 하나. 없으면 프론트 전용 |
| macOS 음성 | CoreAudio 기반 전이중 + 에코 제거(직접 말해서 끼어들기). 첫 실행 시 마이크 권한 승인. Swift는 캐시로 자동 컴파일 |
| Linux / Windows 음성 | sounddevice + 시스템 PortAudio 설치. 기본 반이중(재생 중 x 키로 끼어들기). --audio-mode full이면 전이중(AEC 없음 → 헤드폰 권장) |
| 데스크톱 앱 | macOS: 릴리스의 .dmg 설치(Electron). 플로팅 음성 오브 + 메뉴바 액세서리 앱 |
~/.config/qwaudio/의 USER.md·frontend-memory.json·tasks.json은 기기에만 저장되고 저장소로 커밋되지 않는다npm install -g qwen-audio-agent → qwenaudio config로 만든 config.env에 DashScope 키를 넣고, 한 터미널에서 qwenaudio(Gateway), 다른 터미널에서 qwenaudio tui를 띄운다. 말을 걸어 답을 듣고, 비서가 말하는 도중에 끼어들어 보자(macOS는 그냥 말하기, Linux/Win은 x). 끼어들기가 얼마나 빨리 먹히는지 체감하는 게 목표.
먼저 AGENT_PROTOCOL을 비워(프론트 전용) 대화만 해 본다. 그다음 OpenCode나 Codex를 설치하고 AGENT_PROTOCOL=opencode로 바꿔, "이 폴더 파일 목록 정리해 줘" 같은 진짜 작업을 시켜 본다. 작업이 도는 동안에도 다른 질문에 답이 나오는지(=논블로킹) 확인하라. 이 프로젝트의 존재 이유를 눈으로 보는 과제.
AGENT_PROTOCOL=acp + ACP_COMMAND/ACP_ARGS로 ACP를 말하는 아무 CLI를 연결해 본다. 없으면 간단한 "에코 에이전트"를 @agentclientprotocol/sdk로 20~40줄 짜서, session/prompt를 받으면 그대로 되돌려 주게 만들어 붙여 보자. Gateway 코드를 한 줄도 안 고치고 새 백엔드가 붙는 확장점을 체험한다.
server/src/의 config에서 결과 발화 배치(120ms)·quiet(350ms) 값을 극단적으로 키우거나 줄여 빌드한 뒤, 백그라운드 작업 완료 알림이 언제 끼어드는지를 관찰한다. 너무 짧으면 사용자 말을 끊고, 너무 길면 답이 늦는다. "자연스러움"이 어떤 수치 튜닝의 결과인지를 몸으로 이해하는 과제.
server/src/voice/frontend-tools.mjs의 6개 툴 옆에 새 툴(예: get_weather 또는 set_reminder)을 추가해, 음성으로 호출되게 만든다. "즉답 툴(프론트에서 바로 처리)"과 "위임 툴(백엔드로 넘김)"의 경계를 직접 설계해 보는 것이 핵심 — 무엇을 입이 직접 하고 무엇을 두뇌에 넘길지 판단하는 감각을 기른다.
| 주차 | 주제 | 구체적으로 |
|---|---|---|
| 1주 | 실시간 음성의 기초 | WebSocket 스트리밍, PCM/샘플레이트(16k/24k), VAD, OpenAI Realtime API 규격. voice/realtime-provider.mjs·providers/dashscope.mjs를 표본으로 |
| 2주 | ACP & 프로세스 통합 | stdio JSON-RPC, 자식 프로세스 관리, initialize 핸드셰이크, 세션 라이프사이클·권한 콜백. agent/acp-process-client.mjs |
| 3주 | 비동기 작업 오케스트레이션 | Work 큐·동시성 상한·FIFO·상태 머신·원자적 영속화, "안전 삽입 창" 타이밍. task/ 전체 |
| 4주 | 크로스 플랫폼 오디오 | 에코 제거(AEC), CoreAudio(Swift), PortAudio(sounddevice), Web Audio ScriptProcessor/AudioWorklet. tui/native/* |
| 5주(선택) | Electron 데스크톱 | 투명·frame 없는 창, 액세서리 앱, electron-builder/updater 자동 업데이트, 내장 Gateway 소유. desktop/src/main.mjs |
x 키).qwenaudio로 뜨는 Express+ws 서버(기본 127.0.0.1:3101). 클라이언트·음성모델·백엔드 에이전트·Task 큐가 모두 여기서 만난다. launchd/systemd로 상주 서비스화 가능.wss로 접속(OpenAI Realtime 호환 규격)해 오디오를 주고받는다. 무료 체험 크레딧 제공.smart_turn)를 써서 speech_started로 끼어들기를 트리거한다.VoiceProcessingIO로 내장 처리해 전이중이 자연스럽고, PortAudio 전이중엔 AEC가 없어 헤드폰이 권장된다.npm i -g qwen-audio-agent)docs/architecture.md(3계층·의존 규칙) · docs/configuration.md(환경변수 전체) · server/src/voice/realtime-gateway.mjs(음성 파이프라인) · server/src/agent/acp-process-client.mjs(ACP 핸드셰이크)