qwen-audio-agentAudio
§ · Nested detail page

实时语音网关 深度解析

从真实源码拆解 qwen-audio-agent 的关键子系统。

§ 00 · IMPORT CHAIN

导入链与契约

这个子模块负责把客户端 WebSocket、Qwen Realtime、后台任务通知和播放回执放进同一个 turn 语义里。server/src/voice/realtime-gateway.mjs:86 建立入口,server/src/voice/realtime-gateway.mjs:129 到 server/src/voice/realtime-gateway.mjs:158 初始化每个连接的 turn、response 和 transcript 状态,说明它不是一个薄代理。

src/voice/realtime-gateway.mjs · L86-127JavaScript
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'
106          : holder ? 'busy' : 'available',
107        holder,
108      })
109    }
110  }
111
112  server.on('upgrade', (request, socket, head) => {
113    const url = new URL(request.url, 'http://localhost')
114    if (rejectUnsupportedRealtimeUpgrade(socket, url.pathname)) return
115    if (!isAllowedOrigin(request)) {
116      rejectUpgrade(socket, '403 Forbidden', 'origin not allowed')
117      return
118    }
119    const identity = identityManager.resolveUpgrade(request)
120    if (!identity) {
121      rejectUpgrade(socket, '401 Unauthorized', 'identity required')
122      return
123    }
124    wss.handleUpgrade(request, socket, head, ws => {
125      wss.emit('connection', ws, url, identity)
126    })
127  })
§ 01 · CORE IMPL

核心实现,逐段拆

▸连接与语音所有权

连接建立时先经过 origin 与 identity 检查,server/src/voice/realtime-gateway.mjs:112 到 server/src/voice/realtime-gateway.mjs:126 是第一层安全边界;之后 server/src/voice/realtime-gateway.mjs:276 到 server/src/voice/realtime-gateway.mjs:300 维护 owner 级 active client,保证 takeover、busy 和 release 都会广播给同一用户的其他客户端。

src/voice/realtime-gateway.mjs · L276-322JavaScript
276    if (!voiceConnections.has(ownerId)) voiceConnections.set(ownerId, new Set())
277    voiceConnections.get(ownerId).add(voiceClient)
278
279    const activateVoiceClient = ({
280      takeover = false,
281      enableInput = true,
282      enableOutput = true,
283    } = {}) => {
284      const result = activeVoiceClients.activate(
285        ownerId,
286        voiceClient,
287        { takeover },
288      )
289      inputEnabled = result.granted && enableInput
290      outputEnabled = result.granted && enableOutput
291      broadcastVoiceOwnership(ownerId)
292      return result.granted
293    }
294    const releaseVoiceClient = () => {
295      inputEnabled = false
296      outputEnabled = false
297      if (activeVoiceClients.release(ownerId, voiceClient)) {
298        broadcastVoiceOwnership(ownerId)
299      }
300    }
301    const toolCalls = new ToolCallHandler({
302      taskManager,
303      ownerId,
304      sessionId,
305      transcripts,
306      getFrontend: () => frontend,
307      getTurnId: () => committedTurnId,
308      getTurnGeneration: () => committedTurnGeneration,
309      memoryStore,
310      getClientContext: () => clientContext,
311      getConversationContext: () => conversationSync.frontendContext({
312        ownerId,
313        sessionId,
314      }),
315      onMemoryChanged: () => frontend?.updateAgentContext({
316        memories: memoryStore?.list(ownerId) || [],
317      }),
318      coordinator,
319      coordinatorAvailable,
320      respondPermission,
321      permissionPolicy,
322    })

▸播放、通知与响应上下文

播放状态会反向确认任务通知。server/src/voice/realtime-gateway.mjs:502 到 server/src/voice/realtime-gateway.mjs:520 在播放开始时发送 speaking 状态并确认通知;server/src/voice/realtime-gateway.mjs:523 到 server/src/voice/realtime-gateway.mjs:561 处理用户打断、重试和 tombstone,防止迟到音频复活已取消响应。

src/voice/realtime-gateway.mjs · L502-561JavaScript
502    const startPlayback = id => {
503      const context = responseContexts.get(id)
504      // A cancelled response remains as a short-lived tombstone so late
505      // provider audio and client receipts cannot resurrect it.
506      if (context?.suppressed) return
507      announcementWindow.startPlayback(id)
508      const playbackTurnId = context?.turnId || playbackTurns.get(id) || turnId
509      send(ws, {
510        type: 'voice.state',
511        state: 'speaking',
512        turnId: playbackTurnId,
513        origin: context?.origin || 'model',
514      })
515      if (!context || context.playbackStarted) return
516      context.playbackStarted = true
517      if (confirmsTaskNotificationOnPlaybackStart(context)) {
518        announcements.confirmMany(contextTaskIds(context))
519      }
520      flushPendingTranscripts(id, context)
521    }
522
523    const cancelQueuedPlayback = (id, { reason = '' } = {}) => {
524      const context = responseContexts.get(id)
525      announcementWindow.finishPlayback(id, {
526        hasFunctionCall: Boolean(context?.hasFunctionCall),
527      })
528      const playbackTurnId = playbackTurns.get(id) || turnId
529      playbackTurns.delete(id)
530      if (context?.origin === 'announcement') {
531        if (reason === 'user_interruption') {
532          announcements.confirmMany(contextTaskIds(context))
533        } else {
534          announcements.retryMany(contextTaskIds(context))
535        }
536      }
537      if (context?.playbackStarted && reason === 'user_interruption') {
538        send(ws, {
539          type: 'response.interrupted',
540          responseId: id,
541          ...publicResponseContext(context),
542        })
543      }
544      if (context) {
545        context.suppressed = true
546        context.playbackEnded = true
547        context.pendingTranscripts = []
548        scheduleResponseContextCleanup(id, context)
549      }
550      send(ws, {
551        type: 'voice.state',
552        state: userSpeaking ? 'listening' : 'idle',
553        turnId: userSpeaking ? turnId : playbackTurnId,
554        origin: context?.origin || 'model',
555      })
556      const timer = setTimeout(
557        () => announcements.flush(),
558        config.announcementQuietMs,
559      )
560      timer.unref?.()
561    }
§ 02 · KEY FLOW

关键流程

client connect → active voice ownership → speech_started turn → response context → audio/transcript deltas → playback receipt → task notification confirmed → completed task result → announcement queue → spoken or inline delivery

关键流程的价值在于每一步都有可回收状态:server/src/voice/realtime-gateway.mjs:377 的 response watchdog 处理模型沉默,server/src/voice/realtime-gateway.mjs:490 的 cleanup 删除完成上下文,server/src/voice/realtime-gateway.mjs:720 到 server/src/voice/realtime-gateway.mjs:760 则让用户新一轮语音打断旧播放并进入 thinking。

§ 03 · EDGE CASES

边界 / 失败模式

  • server/test/realtime-gateway.test.mjs:9 到 server/test/realtime-gateway.test.mjs:37 确认非 realtime upgrade 会被销毁,防止错误路径占用 WebSocket。
  • server/test/realtime-gateway.test.mjs:52 到 server/test/realtime-gateway.test.mjs:73 确认只有 active output client 的已知 response 才能提交播放回执。
  • src/useRealtimeVoice.js:320 到 src/useRealtimeVoice.js:357 在浏览器端做断线重连和播放清理,服务端异常不会留下悬挂音频。

客户端侧同样实现了网关协议的一半:src/useRealtimeVoice.js:256 到 src/useRealtimeVoice.js:285 在连接打开后发送 session、locale、voiceEnabled 和 takeover,src/useRealtimeVoice.js:287 到 src/useRealtimeVoice.js:339 再消费服务端 voice、audio、transcript 和 error 事件。

播放回执不是浏览器自动事件的简单包装。src/useRealtimeVoice.js:202 到 src/useRealtimeVoice.js:254 手动排队 AudioBufferSource,并在真正到达播放时间后发送 playback.started;这解释了服务端为什么可以用播放开始来确认任务通知已经被用户听到。

用户打断需要两端同时收敛。src/useRealtimeVoice.js:140 到 src/useRealtimeVoice.js:168 先停止本地 source 并发取消回执,src/useRealtimeVoice.js:500 到 src/useRealtimeVoice.js:503 再发送 interrupt,让服务端取消正在排队的模型回复和公告。