qwen-audio-agentAudio
§ 00 · Source-level Observatory

qwen-audio-agent 实时语音 Agent Runtime

qwen-audio-agent 把 Qwen Audio Realtime 放在前台,把 Codex、Claude、OpenClaw、OpenCode 等后台 Agent 放在 ACP 适配层后面,形成“语音协调 Agent + 后台执行 Agent + 项目 Session”的三层结构。

★ 439
GitHub Stars
~18K
LOC
276
Files
2026-07-30
Last commit
§ 00 · OVERVIEW

它解决什么,凭什么值得拆

第一层入口不是简单 WebSocket 转发。server/src/voice/realtime-gateway.mjs:86 创建 noServer 网关,server/src/voice/realtime-gateway.mjs:112 到 server/src/voice/realtime-gateway.mjs:126 在 upgrade 阶段同时做路径、Origin 和身份校验,这解释了为什么它能被桌面、Web 和 TUI 共用而不是暴露任意 socket。

语音所有权是这个项目的核心状态机之一。server/src/voice/realtime-gateway.mjs:98 到 server/src/voice/realtime-gateway.mjs:109 对同一 owner 广播 active/busy/available,server/src/voice/realtime-gateway.mjs:279 到 server/src/voice/realtime-gateway.mjs:292 决定 takeover 后输入和输出是否真正生效,避免两个客户端同时抢麦。

响应相关性不是靠“最后一条消息”猜测。server/src/voice/realtime-gateway.mjs:144 到 server/src/voice/realtime-gateway.mjs:158 保存 response candidate、turn correlation、transcript 和 permission 集合,server/src/voice/realtime-gateway.mjs:377 到 server/src/voice/realtime-gateway.mjs:400 还放了 12 秒 watchdog,实时模型不开始回复时会重建前端连接。

前台模型拿到的工具定义集中在 server/src/voice/realtime-provider.mjs:17 到 server/src/voice/realtime-provider.mjs:163,其中 server/src/voice/realtime-provider.mjs:197 到 server/src/voice/realtime-provider.mjs:222 把 Qwen Realtime session 和这些工具绑定,所以语音 Agent 可以委派任务、查询状态、处理权限和读写用户记忆。

▸核心数据

架构模式
WebSocket realtime gateway + Qwen Audio frontend + ACP backend adapter + persistent task ledger。
核心抽象
turn generation、response context、voice ownership、spawn_thinking work、ACP coordinator/project Session。
性能/精度数据
网关限制 pending audio chunk 为 30,响应启动 watchdog 为 12s,Realtime 连接稳定窗口为 10s。
下游依赖
Web UI、TUI、Electron desktop、OpenClaw/OpenCode/Codex/Claude/Qoder 等 ACP 后台。
本文导航 — 6 个章节依次:概述 → 架构图 → 核心模块 → 关键流程 → 测试与验证 → 风险。子页面在每个 H3 末尾以 深度阅读 → 入口展开。
§ 01 · ARCHITECTURE

三层语音 Agent 架构

后台任务提交经过明显的防重复和防陈旧保护。server/src/voice/tools/tool-call-handler.mjs:187 到 server/src/voice/tools/tool-call-handler.mjs:215 解析 call_id、工具名和 turn generation,server/src/voice/tools/tool-call-handler.mjs:350 到 server/src/voice/tools/tool-call-handler.mjs:374 防止同一轮重复提交。

▸系统拓扑

Web / TUI / Desktop │ realtime websocket + audio chunks ▼ server/src/voice/realtime-gateway.mjs │ turn correlation · playback receipts · task announcements ▼ RealtimeFrontend / Qwen Audio Realtime │ function calls ▼ ToolCallHandler ── spawn_thinking / status / permission / memory │ ▼ ACP Backend Adapter ── Codex / Claude / OpenClaw / OpenCode sessions

▸模块清单

任务账本不是旁路日志,而是交付机制。server/src/task/task-manager.mjs:225 到 server/src/task/task-manager.mjs:288 创建 work,server/src/task/task-manager.mjs:291 到 server/src/task/task-manager.mjs:317 由 scheduler 控制启动和 progress 事件,语音层再根据这些事件决定何时播报结果。

实时语音网关
WebSocket 入口、活跃语音客户端、响应上下文和播报窗口集中在 server/src/voice/realtime-gateway.mjs,负责把音频流变成可追踪的 turn。
前台 Realtime 客户端
server/src/voice/realtime-provider.mjs 封装 Qwen Audio Realtime,会话工具、结果注入、权限询问和响应队列都在这里排队。
后台 Agent 适配
server/src/agent/acp-backend-adapter.mjs 把语音层的任务交给 ACP Session,并处理权限、模型覆盖和第三层 delegation。

▸关键数据结构

src/voice/realtime-gateway.mjs · L86-105JavaScript
86export function attachRealtimeGateway(server, {
87  identityManager,
88  memoryStore,
89  coordinator,
90  coordinatorAvailable = async () => true,
91  respondPermission,
92  permissionPolicy,
93}) {
94  const wss = new WebSocketServer({ noServer: true, maxPayload: 2 * 1024 * 1024 })
95  const activeVoiceClients = new ActiveVoiceClients()
96  const voiceConnections = new Map()
97
98  const broadcastVoiceOwnership = ownerId => {
99    const active = activeVoiceClients.active(ownerId)
100    const holder = active?.descriptor || null
101    for (const client of voiceConnections.get(ownerId) || []) {
102      send(client.ws, {
103        type: 'voice.ownership',
104        state: active === client
105          ? 'active'
深度阅读 · Realtime 语音网关
§ 02 · CORE MODULES

Realtime、工具调用与任务账本

▸ToolCallHandler:把语音函数调用变成后台 work

ACP 后台适配的亮点在 Session 复用。server/src/agent/acp-backend-adapter.mjs:280 到 server/src/agent/acp-backend-adapter.mjs:329 尝试恢复 coordinator session,失败才新建;server/src/agent/acp-backend-adapter.mjs:335 到 server/src/agent/acp-backend-adapter.mjs:357 再统一配置模式和模型。

src/voice/tools/tool-call-handler.mjs · L187-244JavaScript
187  async handle(event, callContext = {}) {
188    const callId = event.call_id || event.item?.call_id || ''
189    const toolName = event.name || event.item?.name || ''
190    if (!callId) throw new Error('Realtime 工具调用缺少 call_id')
191    if (this.processedCalls.has(callId)) return
192    this.processedCalls.add(callId)
193    if (this.processedCalls.size > 500) {
194      this.processedCalls.delete(this.processedCalls.values().next().value)
195    }
196
197    const turnId = callContext.turnId
198      || event.__voiceContext?.turnId
199      || this.getTurnId()
200    const generation = Number.isInteger(callContext.turnGeneration)
201      ? callContext.turnGeneration
202      : Number.isInteger(event.__voiceContext?.turnGeneration)
203        ? event.__voiceContext.turnGeneration
204        : this.getTurnGeneration()
205    let args = {}
206    try {
207      args = JSON.parse(event.arguments || '{}')
208    } catch {
209      // Invalid arguments are handled as missing fields below.
210    }
211
212    if (this.isStale(turnId, generation)) {
213      await this.closeStaleCall(callId, turnId)
214      return
215    }
216
217    if (toolName === GET_CURRENT_TIME_TOOL_NAME) {
218      await this.getCurrentTime(callId, turnId)
219      return
220    }
221    if (toolName === USER_MEMORY_TOOL_NAME) {
222      await this.userMemory(callId, turnId, generation, args)
223      return
224    }
225    if (toolName === CANCEL_AGENT_TASK_TOOL_NAME) {
226      await this.cancelAgentTask(callId, turnId, args)
227      return
228    }
229    if (toolName === GET_AGENT_TASK_STATUS_TOOL_NAME) {
230      await this.getAgentTaskStatus(callId, turnId, args)
231      return
232    }
233    if (toolName === RESPOND_AGENT_PERMISSION_TOOL_NAME) {
234      await this.respondAgentPermission(callId, turnId, args)
235      return
236    }
237    if (toolName !== DELEGATE_TOOL_NAME) {
238      await this.sendOutput(
239        callId,
240        failure('unsupported_tool', '当前无法执行这个操作。'),
241        turnId,
242      )
243      return
244    }

▸AcpBackendAdapter:恢复 Session、模型与权限

权限不是模型一句话放行。server/src/agent/acp-backend-adapter.mjs:501 到 server/src/agent/acp-backend-adapter.mjs:550 把原生工具权限转成 pending permission,server/src/voice/tools/tool-call-handler.mjs:440 到 server/src/voice/tools/tool-call-handler.mjs:531 再把用户自然语言决定转回后台。

src/agent/acp-backend-adapter.mjs · L280-329JavaScript
280      return this.coordinatorSessions.get(key)
281    }
282    if (this.coordinatorSessionPromises.has(key)) {
283      return this.coordinatorSessionPromises.get(key)
284    }
285    const pending = (async () => {
286      const stored = this.registry.get(key)
287      let session
288      if (stored?.sessionId) {
289        try {
290          session = await this.client.resumeSession(stored.sessionId, {
291            cwd: stored.cwd || this.directory,
292            mcpServers,
293            meta: this.coordinatorMeta(ownerId),
294            ownerId,
295            role: 'coordinator',
296          })
297          const accepted = this.profile.acceptResumedSession?.({
298            session,
299            role: 'coordinator',
300            coordinatorAgent: this.coordinatorAgent,
301          }) !== false
302          if (!accepted) {
303            this.registry.delete(key)
304            session = null
305          } else {
306            session.isNew = false
307          }
308        } catch {
309          this.registry.delete(key)
310        }
311      }
312      if (!session) {
313        session = await this.client.newSession({
314          cwd: this.directory,
315          mcpServers,
316          meta: this.coordinatorMeta(ownerId),
317          ownerId,
318          role: 'coordinator',
319        })
320        session.isNew = true
321      }
322      await this.configureSession(session, 'coordinator')
323      this.coordinatorSessions.set(key, session)
324      this.registry.set(key, session)
325      return session
326    })().finally(() => this.coordinatorSessionPromises.delete(key))
327    this.coordinatorSessionPromises.set(key, pending)
328    return pending
329  }
深度阅读 · ACP 委派与任务账本
§ 03 · KEY FLOW

从语音轮次到后台执行

CLI runtime 负责把本地网关进程安全拉起。cli/src/runtime.mjs:302 到 cli/src/runtime.mjs:349 在没有健康网关时 spawn server,cli/src/runtime.mjs:351 到 cli/src/runtime.mjs:389 再验证实际 backend 协议、归属和凭据,避免连接到配置不一致的旧进程。

▸语音轮次如何关联响应

User speech → speech_started → turnId/generation → pending audio → Qwen Realtime Qwen response.created → response context → audio.delta/transcript.delta → client playback receipts playback.started confirms task notification; playback.ended releases announcement window.

▸后台任务如何回到前台播报

Realtime function call spawn_thinking → ToolCallHandler validates turn freshness → TaskManager creates lane-limited work → Coordinator runs ACP backend → delegated/completed/permission events stream back to the same voice session.
深度阅读 · 播放回执与通知确认
§ 04 · TESTS & VALIDATION

测试覆盖与失败保护

浏览器端不是只播放音频。src/useRealtimeVoice.js:256 到 src/useRealtimeVoice.js:357 管理 socket 重连和服务端事件,src/useRealtimeVoice.js:414 到 src/useRealtimeVoice.js:493 打开麦克风、重采样并上报音频,同时用音量电平驱动 UI。

▸测试覆盖

测试覆盖集中在协议边界和恢复逻辑:server/test/realtime-gateway.test.mjs:9 到 server/test/realtime-gateway.test.mjs:37 验证 WebSocket upgrade 只接受 /api/realtime,server/test/realtime-gateway.test.mjs:52 到 server/test/realtime-gateway.test.mjs:73 验证播放回执必须来自 active output client。ACP 测试则在 server/test/acp-backend-adapter.test.mjs:221 到 server/test/acp-backend-adapter.test.mjs:307 覆盖空响应重试和初始化冲突重试。

tests/realtime-gateway.test.mjs · L39-73JavaScript
39test('confirms task notifications when client playback starts', () => {
40  assert.equal(confirmsTaskNotificationOnPlaybackStart({
41    origin: 'announcement',
42  }), true)
43  assert.equal(confirmsTaskNotificationOnPlaybackStart({
44    origin: 'model',
45    consumesTaskNotification: true,
46  }), true)
47  assert.equal(confirmsTaskNotificationOnPlaybackStart({
48    origin: 'model',
49  }), false)
50})
51
52test('accepts playback receipts only from the active output client for a known response', () => {
53  assert.equal(acceptsPlaybackReceipt({
54    outputEnabled: true,
55    active: true,
56    responseKnown: true,
57  }), true)
58  assert.equal(acceptsPlaybackReceipt({
59    outputEnabled: true,
60    active: false,
61    responseKnown: true,
62  }), false)
63  assert.equal(acceptsPlaybackReceipt({
64    outputEnabled: false,
65    active: true,
66    responseKnown: true,
67  }), false)
68  assert.equal(acceptsPlaybackReceipt({
69    outputEnabled: true,
70    active: true,
71    responseKnown: false,
72  }), false)
73})

▸已知边界 / 失败模式

  • Realtime 模型不开始回复时,server/src/voice/realtime-gateway.mjs:377 到 server/src/voice/realtime-gateway.mjs:400 会清理旧 frontend 并重连。
  • 用户新一轮打断旧响应时,server/src/voice/realtime-gateway.mjs:720 到 server/src/voice/realtime-gateway.mjs:735 会清空播放并提升 turn generation。
  • 后台权限待决时,server/src/voice/tools/tool-call-handler.mjs:246 到 server/src/voice/tools/tool-call-handler.mjs:278 阻止把回答误提交成新任务。
§ 05 · HIGHLIGHTS

值得拆的工程亮点

语音所有权
ActiveVoiceClients 让 Web、TUI、Desktop 不会同时抢占麦克风;证据是 server/src/voice/realtime-gateway.mjs:94、server/src/voice/realtime-gateway.mjs:98 和 server/src/voice/realtime-gateway.mjs:279 附近的 owner 级激活逻辑。
src/voice/realtime-provider.mjs · L416-433JavaScript
416  sendFunctionOutput(callId, output, context = {}, {
417    createResponse = true,
418    response,
419  } = {}) {
420    const sendOutput = () => this.createConversationItem({
421      type: 'function_call_output',
422      call_id: callId,
423      output: JSON.stringify(output),
424    })
425    if (!createResponse) return this.enqueueAction(sendOutput)
426    return this.enqueueResponse('agent', context, async () => {
427      await sendOutput()
428      this.send({
429        type: 'response.create',
430        ...(response ? { response } : {}),
431      })
432    })
433  }
任务播报闭环
任务完成先写入 conversationSync,再由 announcement manager 排队播报;server/src/voice/realtime-gateway.mjs:411、server/src/voice/realtime-gateway.mjs:594、server/src/voice/realtime-gateway.mjs:688 说明结果持久化、通知 claim 和 inline 时间线是同一条链。
src/cli/src/runtime.mjs · L302-349JavaScript
302export async function ensureRuntime(options, {
303  root,
304  env = process.env,
305  fetchImpl = fetch,
306  spawnImpl = spawn,
307  platform = process.platform,
308  loadEnvironment = () => loadRuntimeEnvironment({ root, env }),
309  requireCredential = () => requireDashScopeCredential(env),
310} = {}) {
311  loadEnvironment()
312  const runtime = new ManagedRuntime([], { platform })
313  const local = isLocalGateway(options.url)
314  const backend = resolveBackend(options, env)
315  const backendLabel = backend.protocol
316    ? backendDefinition(backend.protocol)?.label || backend.protocol
317    : '后台 Agent'
318  let health = await readGatewayHealth(options.url, fetchImpl)
319  const existingGateway = Boolean(health)
320
321  try {
322    if (!health) {
323      if (!local) throw new Error(`无法连接远程 Gateway:${options.url}`)
324      if (options.allowMissingCredential !== true) requireCredential()
325      const target = new URL(options.url)
326      if (target.protocol !== 'http:') {
327        throw new Error('本地自动启动 Gateway 只支持 http 地址')
328      }
329      Object.assign(env, backendEnvironment(env, backend))
330      const spec = gatewaySpawn(
331        root,
332        platform,
333        backendEnvironment(env, backend),
334        target,
335        options,
336      )
337      const gateway = spawnImpl(spec.command, spec.args, spec.options)
338      runtime.children.push(gateway)
339      health = await waitForReadiness(
340        gateway,
341        waitForGateway(options.url, {
342          fetchImpl,
343          requireBackend: Boolean(
344            backend.protocol && options.waitForBackend !== false
345          ),
346        }),
347        'Gateway',
348      )
349    }
§ 06 · RISKS & GAPS

技术亮点之外,别忽略这些

这个项目的风险主要来自实时音频、后台 Agent 和权限系统同时运行;任何一层的时序错误都会变成用户能听见的错误确认。

实时队列时序复杂
server/src/voice/realtime-provider.mjs:572 到 server/src/voice/realtime-provider.mjs:620 用 response queue generation 控制并发,逻辑可靠但对维护者理解成本较高。
权限 UX 依赖模型语义判断
server/src/voice/tools/tool-call-handler.mjs:440 到 server/src/voice/tools/tool-call-handler.mjs:531 把自然语言同意映射为权限决定,体验自然但必须防止模型误判。
本地进程复用要求严格
cli/src/runtime.mjs:351 到 cli/src/runtime.mjs:389 检查旧 Gateway 兼容性,说明用户机器上已有进程会造成配置漂移风险。
测试偏协议层
server/test/realtime-gateway.test.mjs:39 到 server/test/realtime-gateway.test.mjs:73 覆盖关键函数,但端到端音频设备和真实 Qwen Realtime 网络抖动仍需要更重的集成测试。
诚实记录 — 这部分列的是我在源码里看到、但作者没有明确文档化的限制。读者请按需取舍。
前端证据补充 — 浏览器端实时语音 Hook 是三层架构的客户端落点。src/useRealtimeVoice.js:256 到 src/useRealtimeVoice.js:285 建立 socket 并声明 voice/input/output 能力,src/useRealtimeVoice.js:287 到 src/useRealtimeVoice.js:339 分派 voice state、audio delta 和 transcript 事件,说明服务端上下文会被完整投影到 UI。

播放队列是前端最容易出错的部分,src/useRealtimeVoice.js:140 到 src/useRealtimeVoice.js:168 统一取消正在排队和已经开始的音频 source,src/useRealtimeVoice.js:170 到 src/useRealtimeVoice.js:188 等待 response done、transcript done 和 source count 清零后才发送播放结束,这与服务端的 task notification 确认互相咬合。

麦克风输入不是原样透传。src/useRealtimeVoice.js:414 到 src/useRealtimeVoice.js:428 请求带回声消除和降噪的媒体流,src/useRealtimeVoice.js:436 到 src/useRealtimeVoice.js:448 把浏览器采样率重采样到服务端要求的输入采样率并编码为 PCM base64,这让 Qwen Realtime 的输入格式保持稳定。

可视化状态也来自真实音频数据。src/useRealtimeVoice.js:457 到 src/useRealtimeVoice.js:476 用 analyser 计算能量电平并驱动 listening UI,src/useRealtimeVoice.js:500 到 src/useRealtimeVoice.js:503 的 interrupt 同时停止本地播放并通知网关,形成用户打断的双端闭环。

客户端连接恢复设计和服务端 watchdog 对齐。src/useRealtimeVoice.js:341 到 src/useRealtimeVoice.js:357 在 socket 错误或关闭后清理播放并指数退避重连,src/useRealtimeVoice.js:362 到 src/useRealtimeVoice.js:369 在组件卸载时关闭 socket、停止播放、清空 muted response,避免页面切换留下幽灵音频。

这也是为什么主页面把它归类为 runtime,而不是单纯 React 组件:src/useRealtimeVoice.js:202 到 src/useRealtimeVoice.js:254 自己调度 AudioBufferSource 的开始时间和完成回执,src/useRealtimeVoice.js:31 到 src/useRealtimeVoice.js:56 则把语音所有权和输入/输出 mute 行为抽成共享判断函数。