欢迎光临
我们一直在努力

React + Node.js 实战:用豆包端到端实时语音大模型 API 落地web纯语音助手

这篇文章完全基于一个本地真实项目的实现来写:前端是 React + Next.js + TypeScript,后端是 Node.js + TypeScript + NestJS。
文中所有敏感信息都已脱敏,包括 App ID、Access Key、Access Token、数据库连接串、部署地址等。


1. 真实效果:页面长什么样

先看两个真实页面状态。第一张是用户还没开始建立连接时的状态,第二张是正在进行实时语音交互时的工作态。

未连接状态

未开始时,页面主要展示启动入口、状态提示、预设话术和当前固定配置,视觉重点是“随时可以开始说话”。

交互中

进入实时交互后,页面重心会切到当前输入文案、双圆状态表达、回复预览和播报中的交互反馈,让用户一眼就知道这轮语音链路是否正常工作。


2. 为什么我要做一个“纯语音实时助手”

很多团队在做语音功能时,第一反应是:先把文本问答跑起来,再额外补一个麦克风入口。

但“带语音能力的页面”和“纯语音助手”,本质上是两种不同的产品形态。

我这次落地的是一个独立的 /voice 页面。这个页面不追求承载过多信息,而是只盯住一件事:当前这轮实时语音交互是否顺滑。

页面结构很克制:

  • 顶部胶囊:显示当前轮最重要的一条输入文案
  • 中间双圆:表达当前语音状态
  • 底部控制区:开始、结束、静音、打断
  • 预设话术:触发一轮临时文本输入 text_once

为了保证这个页面足够轻,我把会话记录交给后端在每轮结束后自动落库,前端只服务于“现在这一轮”的采集、思考、播报和打断。

当产品目标是展厅、驾驶舱、迎宾机、语音助手面板时,这种形态往往比传统列表式界面更自然。


3. 技术选型与整体方案

先说这次项目里的真实技术栈。

前端

  • React 19
  • Next.js 16(App Router)
  • TypeScript
  • Tailwind CSS 4

后端

  • Node.js 20+
  • TypeScript
  • NestJS 11
  • ws
  • Prisma 7
  • PostgreSQL

实时语音能力

  • 豆包端到端实时语音大模型 API
  • 后端代理上游 WebSocket
  • 模型固定为 1.2.1.1
  • 持续监听模式 keep_alive
  • 上行格式 speech_opus
  • 下行格式 ogg_opus
  • 支持 text_once
  • 支持打断
  • 支持自动落库

为什么一定是“后端代理”,而不是前端直连

原因很简单,但非常关键:

  • 密钥不能放前端
  • 豆包是二进制协议,前端不应该直接背协议细节
  • runtime 状态要统一维护
  • 最终消息要自动落库
  • 打断、忽略旧轮次、关闭回收,都更适合放在服务端做
  • 所以这套方案天然就是三层:

    • 浏览器纯语音页
    • 我自己的 Voice 后端模块
    • 豆包 Realtime API

    为什么纯语音助手需要独立交互模型

    因为它的数据流和传统消息界面完全不同。

    纯语音页更适合:

    • 状态机驱动
    • 双向 WebSocket
    • 音频上行 / 下行
    • 当前轮实时状态
    • 后端自动落库

    如果把会话回溯、当前录音、当前播报三种交互目标混在一起,前端状态会很快失控。

    一组脱敏后的环境变量示例

    后端:

    PORT=8080
    ENABLE_AUTH=1
    ACCESS_TOKEN=your_access_token

    VOLC_APP_ID=your_app_id
    VOLC_ACCESS_KEY=your_access_key
    VOLC_RESOURCE_ID=volc.speech.dialog
    VOLC_APP_KEY=your_fixed_app_key
    VOLC_REALTIME_BASE_URL=wss://openspeech.bytedance.com/api/v3/realtime/dialogue

    DATABASE_URL=postgresql://user:password@localhost:5432/your_db
    REDIS_URL=redis://localhost:6379/0

    # keep_alive 下默认不把 ClientInterrupt 当强依赖
    VOLC_ENABLE_CLIENT_INTERRUPT=0

    前端:

    NEXT_PUBLIC_API_BASE=http://localhost:8080/api
    NEXT_PUBLIC_ACCESS_TOKEN=your_access_token
    NEXT_PUBLIC_API_TIMEOUT=20000


    4. 先看最终架构:前后端如何串起来

    这套链路的关键不是“页面能展示”,而是浏览器、业务 WS、上游 WS、数据库四条链路得一起成立。

    整体架构图

    #mermaid-svg-jHJzqG7MecZZ0iza{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-jHJzqG7MecZZ0iza .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-jHJzqG7MecZZ0iza .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-jHJzqG7MecZZ0iza .error-icon{fill:#552222;}#mermaid-svg-jHJzqG7MecZZ0iza .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-jHJzqG7MecZZ0iza .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-jHJzqG7MecZZ0iza .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-jHJzqG7MecZZ0iza .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-jHJzqG7MecZZ0iza .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-jHJzqG7MecZZ0iza .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-jHJzqG7MecZZ0iza .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-jHJzqG7MecZZ0iza .marker{fill:#333333;stroke:#333333;}#mermaid-svg-jHJzqG7MecZZ0iza .marker.cross{stroke:#333333;}#mermaid-svg-jHJzqG7MecZZ0iza svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-jHJzqG7MecZZ0iza p{margin:0;}#mermaid-svg-jHJzqG7MecZZ0iza .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-jHJzqG7MecZZ0iza .cluster-label text{fill:#333;}#mermaid-svg-jHJzqG7MecZZ0iza .cluster-label span{color:#333;}#mermaid-svg-jHJzqG7MecZZ0iza .cluster-label span p{background-color:transparent;}#mermaid-svg-jHJzqG7MecZZ0iza .label text,#mermaid-svg-jHJzqG7MecZZ0iza span{fill:#333;color:#333;}#mermaid-svg-jHJzqG7MecZZ0iza .node rect,#mermaid-svg-jHJzqG7MecZZ0iza .node circle,#mermaid-svg-jHJzqG7MecZZ0iza .node ellipse,#mermaid-svg-jHJzqG7MecZZ0iza .node polygon,#mermaid-svg-jHJzqG7MecZZ0iza .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-jHJzqG7MecZZ0iza .rough-node .label text,#mermaid-svg-jHJzqG7MecZZ0iza .node .label text,#mermaid-svg-jHJzqG7MecZZ0iza .image-shape .label,#mermaid-svg-jHJzqG7MecZZ0iza .icon-shape .label{text-anchor:middle;}#mermaid-svg-jHJzqG7MecZZ0iza .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-jHJzqG7MecZZ0iza .rough-node .label,#mermaid-svg-jHJzqG7MecZZ0iza .node .label,#mermaid-svg-jHJzqG7MecZZ0iza .image-shape .label,#mermaid-svg-jHJzqG7MecZZ0iza .icon-shape .label{text-align:center;}#mermaid-svg-jHJzqG7MecZZ0iza .node.clickable{cursor:pointer;}#mermaid-svg-jHJzqG7MecZZ0iza .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-jHJzqG7MecZZ0iza .arrowheadPath{fill:#333333;}#mermaid-svg-jHJzqG7MecZZ0iza .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-jHJzqG7MecZZ0iza .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-jHJzqG7MecZZ0iza .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-jHJzqG7MecZZ0iza .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-jHJzqG7MecZZ0iza .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-jHJzqG7MecZZ0iza .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-jHJzqG7MecZZ0iza .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-jHJzqG7MecZZ0iza .cluster text{fill:#333;}#mermaid-svg-jHJzqG7MecZZ0iza .cluster span{color:#333;}#mermaid-svg-jHJzqG7MecZZ0iza div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-jHJzqG7MecZZ0iza .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-jHJzqG7MecZZ0iza rect.text{fill:none;stroke-width:0;}#mermaid-svg-jHJzqG7MecZZ0iza .icon-shape,#mermaid-svg-jHJzqG7MecZZ0iza .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-jHJzqG7MecZZ0iza .icon-shape p,#mermaid-svg-jHJzqG7MecZZ0iza .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-jHJzqG7MecZZ0iza .icon-shape .label rect,#mermaid-svg-jHJzqG7MecZZ0iza .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-jHJzqG7MecZZ0iza .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-jHJzqG7MecZZ0iza .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-jHJzqG7MecZZ0iza :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}

    NestJS Voice Module

    Browser /voice

    create/close/status

    text commands

    binary speech_opus

    upstream ws

    binary + json protocol

    final user/assistant text

    tts.audio binary

    VoicePage + ViewModel

    useAudioCapturegetUserMedia + AudioWorklet + WebCodecs

    useOpusPlayerMSE stream / Blob fallback

    Voice REST/api/voice/*

    Business WS/api/voice/ws

    VoiceController

    VoiceGateway

    VoiceSessionRegistry

    VoiceSessionService

    DoubaoRealtimeClient

    Codec + Translator

    PostgreSQLSession / Message

    Doubao Realtime API

    这张图里最重要的几个判断

    • 浏览器不直连豆包
    • 浏览器只理解“业务事件”,不理解豆包原始协议
    • 后端把每个语音会话当成一个 runtime 对象
    • 轮次记录只在每轮结束后异步落库
    • 当前页面只消费实时状态,不让持久化逻辑干扰交互

    5. 前端页面是怎么设计的:围绕实时语音重做交互骨架

    我在前端新开了一个独立路由:app/voice/page.tsx。

    真实目录大概是这样的:

    app/voice/page.tsx
    components/voice/VoiceControlBar.tsx
    components/voice/VoiceOrbStage.tsx
    components/voice/VoicePermissionTip.tsx
    components/voice/VoicePresetPhrases.tsx
    components/voice/VoiceStatusHint.tsx
    components/voice/VoiceTopQuery.tsx
    hooks/useAudioCapture.ts
    hooks/useOpusPlayer.ts
    hooks/useVoiceGatewaySocket.ts
    hooks/useVoiceRealtimePage.ts
    lib/api/voice-client.ts
    public/voice/pcm-capture.worklet.js
    types/voice.ts

    页面布局图

    #mermaid-svg-xcIbv2Y0u0NbJRWC{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-xcIbv2Y0u0NbJRWC .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-xcIbv2Y0u0NbJRWC .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-xcIbv2Y0u0NbJRWC .error-icon{fill:#552222;}#mermaid-svg-xcIbv2Y0u0NbJRWC .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-xcIbv2Y0u0NbJRWC .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-xcIbv2Y0u0NbJRWC .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-xcIbv2Y0u0NbJRWC .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-xcIbv2Y0u0NbJRWC .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-xcIbv2Y0u0NbJRWC .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-xcIbv2Y0u0NbJRWC .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-xcIbv2Y0u0NbJRWC .marker{fill:#333333;stroke:#333333;}#mermaid-svg-xcIbv2Y0u0NbJRWC .marker.cross{stroke:#333333;}#mermaid-svg-xcIbv2Y0u0NbJRWC svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-xcIbv2Y0u0NbJRWC p{margin:0;}#mermaid-svg-xcIbv2Y0u0NbJRWC .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-xcIbv2Y0u0NbJRWC .cluster-label text{fill:#333;}#mermaid-svg-xcIbv2Y0u0NbJRWC .cluster-label span{color:#333;}#mermaid-svg-xcIbv2Y0u0NbJRWC .cluster-label span p{background-color:transparent;}#mermaid-svg-xcIbv2Y0u0NbJRWC .label text,#mermaid-svg-xcIbv2Y0u0NbJRWC span{fill:#333;color:#333;}#mermaid-svg-xcIbv2Y0u0NbJRWC .node rect,#mermaid-svg-xcIbv2Y0u0NbJRWC .node circle,#mermaid-svg-xcIbv2Y0u0NbJRWC .node ellipse,#mermaid-svg-xcIbv2Y0u0NbJRWC .node polygon,#mermaid-svg-xcIbv2Y0u0NbJRWC .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-xcIbv2Y0u0NbJRWC .rough-node .label text,#mermaid-svg-xcIbv2Y0u0NbJRWC .node .label text,#mermaid-svg-xcIbv2Y0u0NbJRWC .image-shape .label,#mermaid-svg-xcIbv2Y0u0NbJRWC .icon-shape .label{text-anchor:middle;}#mermaid-svg-xcIbv2Y0u0NbJRWC .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-xcIbv2Y0u0NbJRWC .rough-node .label,#mermaid-svg-xcIbv2Y0u0NbJRWC .node .label,#mermaid-svg-xcIbv2Y0u0NbJRWC .image-shape .label,#mermaid-svg-xcIbv2Y0u0NbJRWC .icon-shape .label{text-align:center;}#mermaid-svg-xcIbv2Y0u0NbJRWC .node.clickable{cursor:pointer;}#mermaid-svg-xcIbv2Y0u0NbJRWC .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-xcIbv2Y0u0NbJRWC .arrowheadPath{fill:#333333;}#mermaid-svg-xcIbv2Y0u0NbJRWC .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-xcIbv2Y0u0NbJRWC .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-xcIbv2Y0u0NbJRWC .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-xcIbv2Y0u0NbJRWC .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-xcIbv2Y0u0NbJRWC .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-xcIbv2Y0u0NbJRWC .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-xcIbv2Y0u0NbJRWC .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-xcIbv2Y0u0NbJRWC .cluster text{fill:#333;}#mermaid-svg-xcIbv2Y0u0NbJRWC .cluster span{color:#333;}#mermaid-svg-xcIbv2Y0u0NbJRWC div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-xcIbv2Y0u0NbJRWC .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-xcIbv2Y0u0NbJRWC rect.text{fill:none;stroke-width:0;}#mermaid-svg-xcIbv2Y0u0NbJRWC .icon-shape,#mermaid-svg-xcIbv2Y0u0NbJRWC .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-xcIbv2Y0u0NbJRWC .icon-shape p,#mermaid-svg-xcIbv2Y0u0NbJRWC .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-xcIbv2Y0u0NbJRWC .icon-shape .label rect,#mermaid-svg-xcIbv2Y0u0NbJRWC .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-xcIbv2Y0u0NbJRWC .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-xcIbv2Y0u0NbJRWC .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-xcIbv2Y0u0NbJRWC :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}

    /voice 页面

    顶部胶囊最近一次最终识别文本

    中部双圆User Orb / AI Orb

    底部控制区开始 / 结束 / 静音 / 打断

    预设话术text_once

    右侧状态与运行提示

    顶部胶囊为什么只显示一条文案

    这个页面顶部只承载“当前轮最重要的一条输入语义”,所以它必须非常克制:

    • 语音轮次:来自真实 asr.final.text
    • text_once 轮次:来自当前点击的 text.query.content
    • 空状态:展示“请开始说话”

    这件事如果不提前想清楚,后面状态机一定会乱。

    为什么页面只承载当前轮状态

    因为信息回溯和实时交互,是两套不同的目标。

    你说话时,页面需要马上从 listening -> user_speaking。
    模型思考时,需要马上切到 thinking。
    TTS 开始后,需要马上变成 speaking。
    这些都应该由实时状态机直接驱动,而不是再套一层列表范式。


    6. 前端状态机设计:让语音页“像实时对话”,而不是“像录音留言”

    这部分是整个前端最重要的设计点。

    我在 types/voice.ts 里定义了清晰的 UI 状态:

    export type VoiceUiState =
    | "idle"
    | "connecting"
    | "session_starting"
    | "listening"
    | "user_speaking"
    | "thinking"
    | "speaking"
    | "text_submitting"
    | "error"
    | "closed";

    状态机图

    #mermaid-svg-dP9LfRb5rBr6DtgW{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-dP9LfRb5rBr6DtgW .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-dP9LfRb5rBr6DtgW .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-dP9LfRb5rBr6DtgW .error-icon{fill:#552222;}#mermaid-svg-dP9LfRb5rBr6DtgW .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-dP9LfRb5rBr6DtgW .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-dP9LfRb5rBr6DtgW .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-dP9LfRb5rBr6DtgW .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-dP9LfRb5rBr6DtgW .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-dP9LfRb5rBr6DtgW .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-dP9LfRb5rBr6DtgW .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-dP9LfRb5rBr6DtgW .marker{fill:#333333;stroke:#333333;}#mermaid-svg-dP9LfRb5rBr6DtgW .marker.cross{stroke:#333333;}#mermaid-svg-dP9LfRb5rBr6DtgW svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-dP9LfRb5rBr6DtgW p{margin:0;}#mermaid-svg-dP9LfRb5rBr6DtgW defs #statediagram-barbEnd{fill:#333333;stroke:#333333;}#mermaid-svg-dP9LfRb5rBr6DtgW g.stateGroup text{fill:#9370DB;stroke:none;font-size:10px;}#mermaid-svg-dP9LfRb5rBr6DtgW g.stateGroup text{fill:#333;stroke:none;font-size:10px;}#mermaid-svg-dP9LfRb5rBr6DtgW g.stateGroup .state-title{font-weight:bolder;fill:#131300;}#mermaid-svg-dP9LfRb5rBr6DtgW g.stateGroup rect{fill:#ECECFF;stroke:#9370DB;}#mermaid-svg-dP9LfRb5rBr6DtgW g.stateGroup line{stroke:#333333;stroke-width:1;}#mermaid-svg-dP9LfRb5rBr6DtgW .transition{stroke:#333333;stroke-width:1;fill:none;}#mermaid-svg-dP9LfRb5rBr6DtgW .stateGroup .composit{fill:white;border-bottom:1px;}#mermaid-svg-dP9LfRb5rBr6DtgW .stateGroup .alt-composit{fill:#e0e0e0;border-bottom:1px;}#mermaid-svg-dP9LfRb5rBr6DtgW .state-note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-dP9LfRb5rBr6DtgW .state-note text{fill:black;stroke:none;font-size:10px;}#mermaid-svg-dP9LfRb5rBr6DtgW .stateLabel .box{stroke:none;stroke-width:0;fill:#ECECFF;opacity:0.5;}#mermaid-svg-dP9LfRb5rBr6DtgW .edgeLabel .label rect{fill:#ECECFF;opacity:0.5;}#mermaid-svg-dP9LfRb5rBr6DtgW .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-dP9LfRb5rBr6DtgW .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-dP9LfRb5rBr6DtgW .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-dP9LfRb5rBr6DtgW .edgeLabel .label text{fill:#333;}#mermaid-svg-dP9LfRb5rBr6DtgW .label div .edgeLabel{color:#333;}#mermaid-svg-dP9LfRb5rBr6DtgW .stateLabel text{fill:#131300;font-size:10px;font-weight:bold;}#mermaid-svg-dP9LfRb5rBr6DtgW .node circle.state-start{fill:#333333;stroke:#333333;}#mermaid-svg-dP9LfRb5rBr6DtgW .node .fork-join{fill:#333333;stroke:#333333;}#mermaid-svg-dP9LfRb5rBr6DtgW .node circle.state-end{fill:#9370DB;stroke:white;stroke-width:1.5;}#mermaid-svg-dP9LfRb5rBr6DtgW .end-state-inner{fill:white;stroke-width:1.5;}#mermaid-svg-dP9LfRb5rBr6DtgW .node rect{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-dP9LfRb5rBr6DtgW .node polygon{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-dP9LfRb5rBr6DtgW #statediagram-barbEnd{fill:#333333;}#mermaid-svg-dP9LfRb5rBr6DtgW .statediagram-cluster rect{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-dP9LfRb5rBr6DtgW .cluster-label,#mermaid-svg-dP9LfRb5rBr6DtgW .nodeLabel{color:#131300;}#mermaid-svg-dP9LfRb5rBr6DtgW .statediagram-cluster rect.outer{rx:5px;ry:5px;}#mermaid-svg-dP9LfRb5rBr6DtgW .statediagram-state .divider{stroke:#9370DB;}#mermaid-svg-dP9LfRb5rBr6DtgW .statediagram-state .title-state{rx:5px;ry:5px;}#mermaid-svg-dP9LfRb5rBr6DtgW .statediagram-cluster.statediagram-cluster .inner{fill:white;}#mermaid-svg-dP9LfRb5rBr6DtgW .statediagram-cluster.statediagram-cluster-alt .inner{fill:#f0f0f0;}#mermaid-svg-dP9LfRb5rBr6DtgW .statediagram-cluster .inner{rx:0;ry:0;}#mermaid-svg-dP9LfRb5rBr6DtgW .statediagram-state rect.basic{rx:5px;ry:5px;}#mermaid-svg-dP9LfRb5rBr6DtgW .statediagram-state rect.divider{stroke-dasharray:10,10;fill:#f0f0f0;}#mermaid-svg-dP9LfRb5rBr6DtgW .note-edge{stroke-dasharray:5;}#mermaid-svg-dP9LfRb5rBr6DtgW .statediagram-note rect{fill:#fff5ad;stroke:#aaaa33;stroke-width:1px;rx:0;ry:0;}#mermaid-svg-dP9LfRb5rBr6DtgW .statediagram-note rect{fill:#fff5ad;stroke:#aaaa33;stroke-width:1px;rx:0;ry:0;}#mermaid-svg-dP9LfRb5rBr6DtgW .statediagram-note text{fill:black;}#mermaid-svg-dP9LfRb5rBr6DtgW .statediagram-note .nodeLabel{color:black;}#mermaid-svg-dP9LfRb5rBr6DtgW .statediagram .edgeLabel{color:red;}#mermaid-svg-dP9LfRb5rBr6DtgW #dependencyStart,#mermaid-svg-dP9LfRb5rBr6DtgW #dependencyEnd{fill:#333333;stroke:#333333;stroke-width:1;}#mermaid-svg-dP9LfRb5rBr6DtgW .statediagramTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-dP9LfRb5rBr6DtgW :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}

    startVoice

    WS open + session.start

    session.started

    asr.info

    asr.ended

    tts.start

    tts.end

    click preset

    chat.partial / chat.ended

    tts.start

    tts.end + restore

    session.end

    session.end

    session.end

    fatal error

    ws/upstream error

    session start error

    fatal error

    fatal error

    fatal error

    fatal error

    fatal error

    idle

    connecting

    session_starting

    listening

    user_speaking

    thinking

    speaking

    text_submitting

    closed

    error

    顶部胶囊为什么不能直接复用 asr.final

    这是我这套实现里专门收口过的一件事。

    text_once 本质上不是“识别出来的文本”,而是“用户主动点击的一句预设话术”。
    如果为了省事,在后端伪造一条 asr.final,语义就污染了:

    • “最近一次最终识别文本”不再只代表 ASR
    • 前端无法区分真实语音输入和临时文本输入
    • 后面做语音记录页时,语义会越来越乱

    所以前端明确分成两条来源:

    case "asr.final":
    // 真实语音轮次才更新顶部胶囊
    setTextOnceDisplayText("");
    setLastFinalQuestionText(event.text);
    return;

    const currentDisplay = useMemo(() => {
    if (textOnceDisplayText.trim()) {
    return { text: textOnceDisplayText.trim(), source: "text_once" as const };
    }

    if (lastFinalQuestionText.trim()) {
    return { text: lastFinalQuestionText.trim(), source: "voice_asr" as const };
    }

    return { text: "请开始说话", source: "placeholder" as const };
    }, [lastFinalQuestionText, textOnceDisplayText]);

    为什么一定要等 tts.end 之后才恢复持续监听

    这也是一个特别容易做错的点。

    如果在 chat.ended 就恢复麦克风:

    • 文本回复虽然生成完了
    • 但本地 TTS 可能还没播完
    • 这时恢复采集,很容易把 AI 自己的播报重新采进去

    所以前端真实逻辑是:

  • 收到 tts.end
  • 调 endStream()
  • 再 await waitForPlayerIdle()
  • 最后才恢复 voice_continuous
  • 代码就在 useVoiceRealtimePage.ts:

    case "tts.end":
    endStream();

    // Blob 兜底模式下,真正播放可能到这里才开始
    await waitForPlayerIdle();

    if (event.restoreVoiceContinuous || modeRef.current === "text_once") {
    await restoreMicAfterTextOnce();
    }

    setState("listening");
    return;

    为什么收到 asr.info 要立刻本地停播

    因为“打断”首先是一个前端交互问题,不是协议问题。

    如果等后端先通知上游、上游再停止、再回传状态,用户会先听到一截旧播报。实时感会很差。

    所以我在前端看到 asr.info 时,先做本地动作:

    case "asr.info":
    // 本地立即停播,优先解决听感问题
    stopAndClear();
    pendingTextQueryRef.current = false;

    if (modeRef.current === "text_once") {
    setTextOnceDisplayText("");
    }

    setReplyPreviewText("");
    setState("user_speaking");
    return;


    7. 后端为什么必须做代理:REST + 业务 WS + 豆包上游 WS

    这套方案里,后端不是一个简单转发器,而是一个实时语音编排层。

    三层关系

    • 前端纯语音页:只管页面状态、麦克风、播放、按钮
    • 后端 Voice 模块:统一编排 session / state / 落库 / 打断
    • 豆包 Realtime API:提供 ASR / Chat / TTS 一体能力

    为什么不能把协议细节放前端

    因为豆包这条链路不是“普通 JSON WebSocket”。
    它涉及:

    • 二进制协议头
    • event type
    • gzip 压缩
    • audio only request / response
    • session lifecycle
    • 文本与音频混合事件

    如果让前端直接理解这些协议,一旦供应商协议变更,前端会被拖着一起抖。
    所以我在后端做了两层隔离:

  • doubao-realtime.codec.ts:负责编解码
  • doubao-realtime.translator.ts:负责把豆包事件翻译成业务事件
  • 前端最终只看这样的事件:

    • session.started
    • asr.info
    • asr.final
    • chat.partial
    • tts.start
    • tts.end
    • error

    这就干净很多。


    8. 后端 Voice 模块是怎么拆的

    这是本地项目里真实的目录结构:

    src/voice/
    clients/doubao-realtime.client.ts
    dto/voice.dto.ts
    protocol/doubao-realtime.codec.ts
    protocol/doubao-realtime.translator.ts
    protocol/doubao-realtime.types.ts
    voice-session.registry.ts
    voice-session.service.ts
    voice.constants.ts
    voice.controller.ts
    voice.gateway.ts
    voice.module.ts
    voice.service.ts
    voice.types.ts

    每个文件的职责

    • voice.controller.ts

      • 暴露 REST 接口
      • 创建语音会话、查询状态、关闭会话、读取固定配置
    • voice.gateway.ts

      • 浏览器业务 WebSocket 入口
      • 解析文本命令
      • 区分二进制音频帧
      • 绑定具体 runtime
    • voice.service.ts

      • REST 层协调
      • 创建或绑定 Session
      • 从 registry 里取运行时快照
    • voice-session.registry.ts

      • 管理活跃 runtime
      • 在关闭后保留 closed snapshot
      • 让 GET /api/voice/sessions/:id 在关闭后返回 state=closed,而不是 404
    • voice-session.service.ts

      • 这是真正的核心
      • 管理一个语音 session 的完整生命周期
      • 负责状态机、打断、忽略旧轮次、自动落库、关闭回收
    • clients/doubao-realtime.client.ts

      • 管理与豆包上游 WebSocket 的连接
      • 封装 StartConnection / StartSession / TaskRequest / ChatTextQuery / FinishSession / FinishConnection
    • protocol/*

      • 不让业务层手写 Buffer
      • 专门处理二进制协议和事件翻译

    这个拆分为什么重要

    因为“一个语音会话”不是一个普通请求,而是一个带内存状态的 runtime。
    它既不是 controller,也不适合直接做成 singleton service 里的一个大对象。

    我最后的选择是:

    • VoiceSessionRegistry 负责创建 / 持有 runtime
    • VoiceSessionService 作为每个语音会话的实例
    • module 销毁时 registry 统一关闭所有 runtime

    这比把所有状态塞进 Map<string, object> 要稳得多。


    9. 浏览器和后端的事件契约设计

    实时语音项目最怕两件事:

  • 事件语义不清
  • 前端和后端都在“猜”对方的协议
  • 所以我这里把浏览器侧协议单独定义了。

    前端 -> 后端

    类型说明
    session.start 启动语音 runtime
    text.query 触发一轮 text_once
    interrupt 请求打断当前轮
    mic.pause 暂停麦克风
    mic.resume 恢复麦克风
    session.end 结束语音会话
    binary audio.chunk 单个 speech_opus 音频包

    后端 -> 前端

    类型说明
    session.started runtime 就绪
    state.changed 状态变更
    asr.info 检测到用户开始说话
    asr.partial 识别中间态
    asr.final 最终识别文本
    asr.ended 一轮识别结束
    chat.partial 回复文本流
    chat.ended 回复文本结束
    tts.start TTS 开始
    tts.end TTS 结束
    usage token / usage 信息
    error 业务错误
    binary tts.audio OGG/Opus 音频 chunk

    为什么不把豆包原始 event id 直接透给前端

    因为前端真正关心的是业务语义,而不是供应商协议细节。

    比如前端并不需要知道某个事件在豆包协议里叫 553 还是 350。
    它只需要知道:

    • 这是 chat.confirmed
    • 这是 tts.start
    • 这是 asr.final

    后端协议翻译层做完这一步以后,前端代码会简单很多。

    一条完整事件时序图

    PostgreSQLDoubao Realtime APIVoiceSessionServiceBusiness WSBrowser /voicePostgreSQLDoubao Realtime APIVoiceSessionServiceBusiness WSBrowser /voice#mermaid-svg-Ymm8wCrqW9xIpRb5{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-Ymm8wCrqW9xIpRb5 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-Ymm8wCrqW9xIpRb5 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-Ymm8wCrqW9xIpRb5 .error-icon{fill:#552222;}#mermaid-svg-Ymm8wCrqW9xIpRb5 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-Ymm8wCrqW9xIpRb5 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-Ymm8wCrqW9xIpRb5 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-Ymm8wCrqW9xIpRb5 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-Ymm8wCrqW9xIpRb5 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-Ymm8wCrqW9xIpRb5 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-Ymm8wCrqW9xIpRb5 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-Ymm8wCrqW9xIpRb5 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-Ymm8wCrqW9xIpRb5 .marker.cross{stroke:#333333;}#mermaid-svg-Ymm8wCrqW9xIpRb5 svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-Ymm8wCrqW9xIpRb5 p{margin:0;}#mermaid-svg-Ymm8wCrqW9xIpRb5 .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-Ymm8wCrqW9xIpRb5 text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-Ymm8wCrqW9xIpRb5 .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-Ymm8wCrqW9xIpRb5 .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-Ymm8wCrqW9xIpRb5 .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-Ymm8wCrqW9xIpRb5 .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-Ymm8wCrqW9xIpRb5 #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-Ymm8wCrqW9xIpRb5 .sequenceNumber{fill:white;}#mermaid-svg-Ymm8wCrqW9xIpRb5 #sequencenumber{fill:#333;}#mermaid-svg-Ymm8wCrqW9xIpRb5 #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-Ymm8wCrqW9xIpRb5 .messageText{fill:#333;stroke:none;}#mermaid-svg-Ymm8wCrqW9xIpRb5 .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-Ymm8wCrqW9xIpRb5 .labelText,#mermaid-svg-Ymm8wCrqW9xIpRb5 .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-Ymm8wCrqW9xIpRb5 .loopText,#mermaid-svg-Ymm8wCrqW9xIpRb5 .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-Ymm8wCrqW9xIpRb5 .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-Ymm8wCrqW9xIpRb5 .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-Ymm8wCrqW9xIpRb5 .noteText,#mermaid-svg-Ymm8wCrqW9xIpRb5 .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-Ymm8wCrqW9xIpRb5 .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-Ymm8wCrqW9xIpRb5 .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-Ymm8wCrqW9xIpRb5 .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-Ymm8wCrqW9xIpRb5 .actorPopupMenu{position:absolute;}#mermaid-svg-Ymm8wCrqW9xIpRb5 .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-Ymm8wCrqW9xIpRb5 .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-Ymm8wCrqW9xIpRb5 .actor-man circle,#mermaid-svg-Ymm8wCrqW9xIpRb5 line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-Ymm8wCrqW9xIpRb5 :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}POST /api/voice/sessions1sessionId + voiceSessionId + fixed config2WS connect3session.start4StartConnection5StartSession6session.started7state.changed(listening)8binary audio.chunk(speech_opus)9TaskRequest(audio)10ASRInfo11asr.info12ASRResponse(final)13asr.final14ASREnded15asr.ended16ChatResponse17chat.partial18TTSSentenceStart19tts.start20TTSResponse(binary)21binary tts.audio22TTSEnded23tts.end24persist final user/assistant messages25


    10. 上行音频:浏览器采集与 speech_opus 的真实工程难点

    这部分绝对不能写得太轻松。

    因为“浏览器采集音频”很简单,但“浏览器稳定输出供应商能吃的 speech_opus”并不简单。

    我的真实上行链路

    #mermaid-svg-0maBSEK9cyzTBqFU{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-0maBSEK9cyzTBqFU .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-0maBSEK9cyzTBqFU .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-0maBSEK9cyzTBqFU .error-icon{fill:#552222;}#mermaid-svg-0maBSEK9cyzTBqFU .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-0maBSEK9cyzTBqFU .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-0maBSEK9cyzTBqFU .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-0maBSEK9cyzTBqFU .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-0maBSEK9cyzTBqFU .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-0maBSEK9cyzTBqFU .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-0maBSEK9cyzTBqFU .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-0maBSEK9cyzTBqFU .marker{fill:#333333;stroke:#333333;}#mermaid-svg-0maBSEK9cyzTBqFU .marker.cross{stroke:#333333;}#mermaid-svg-0maBSEK9cyzTBqFU svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-0maBSEK9cyzTBqFU p{margin:0;}#mermaid-svg-0maBSEK9cyzTBqFU .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-0maBSEK9cyzTBqFU .cluster-label text{fill:#333;}#mermaid-svg-0maBSEK9cyzTBqFU .cluster-label span{color:#333;}#mermaid-svg-0maBSEK9cyzTBqFU .cluster-label span p{background-color:transparent;}#mermaid-svg-0maBSEK9cyzTBqFU .label text,#mermaid-svg-0maBSEK9cyzTBqFU span{fill:#333;color:#333;}#mermaid-svg-0maBSEK9cyzTBqFU .node rect,#mermaid-svg-0maBSEK9cyzTBqFU .node circle,#mermaid-svg-0maBSEK9cyzTBqFU .node ellipse,#mermaid-svg-0maBSEK9cyzTBqFU .node polygon,#mermaid-svg-0maBSEK9cyzTBqFU .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-0maBSEK9cyzTBqFU .rough-node .label text,#mermaid-svg-0maBSEK9cyzTBqFU .node .label text,#mermaid-svg-0maBSEK9cyzTBqFU .image-shape .label,#mermaid-svg-0maBSEK9cyzTBqFU .icon-shape .label{text-anchor:middle;}#mermaid-svg-0maBSEK9cyzTBqFU .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-0maBSEK9cyzTBqFU .rough-node .label,#mermaid-svg-0maBSEK9cyzTBqFU .node .label,#mermaid-svg-0maBSEK9cyzTBqFU .image-shape .label,#mermaid-svg-0maBSEK9cyzTBqFU .icon-shape .label{text-align:center;}#mermaid-svg-0maBSEK9cyzTBqFU .node.clickable{cursor:pointer;}#mermaid-svg-0maBSEK9cyzTBqFU .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-0maBSEK9cyzTBqFU .arrowheadPath{fill:#333333;}#mermaid-svg-0maBSEK9cyzTBqFU .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-0maBSEK9cyzTBqFU .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-0maBSEK9cyzTBqFU .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-0maBSEK9cyzTBqFU .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-0maBSEK9cyzTBqFU .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-0maBSEK9cyzTBqFU .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-0maBSEK9cyzTBqFU .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-0maBSEK9cyzTBqFU .cluster text{fill:#333;}#mermaid-svg-0maBSEK9cyzTBqFU .cluster span{color:#333;}#mermaid-svg-0maBSEK9cyzTBqFU div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-0maBSEK9cyzTBqFU .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-0maBSEK9cyzTBqFU rect.text{fill:none;stroke-width:0;}#mermaid-svg-0maBSEK9cyzTBqFU .icon-shape,#mermaid-svg-0maBSEK9cyzTBqFU .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-0maBSEK9cyzTBqFU .icon-shape p,#mermaid-svg-0maBSEK9cyzTBqFU .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-0maBSEK9cyzTBqFU .icon-shape .label rect,#mermaid-svg-0maBSEK9cyzTBqFU .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-0maBSEK9cyzTBqFU .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-0maBSEK9cyzTBqFU .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-0maBSEK9cyzTBqFU :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}

    getUserMedia

    Web Audio Graph

    AudioWorklet(失败时降级 ScriptProcessor)

    单声道 PCM + 电平

    LinearResampler-> 16kHz

    20ms 分帧

    WebCodecs AudioEncoder(opus)

    裸 Opus packet

    Business WS binary

    VoiceSessionService

    Doubao TaskRequest

    前端真实实现用了什么

    在 useAudioCapture.ts 里,我的流程是:

  • getUserMedia 拉起麦克风
  • 用 AudioContext 建音频图
  • 优先走 AudioWorklet
  • 如果 worklet 不可用,降级到 ScriptProcessor
  • 输出单声道 PCM
  • 线性重采样到 16kHz
  • 按 20ms 切帧
  • 用 WebCodecs AudioEncoder 编成 Opus
  • 通过业务 WS 发二进制
  • 关键代码片段如下:

    const encoder = new AudioEncoder({
    output: (chunk) => {
    const packet = new Uint8Array(chunk.byteLength);
    chunk.copyTo(packet);
    onChunk?.(packet.buffer);
    },
    error: (error) => reportError(error),
    });

    encoder.configure({
    codec: "opus",
    sampleRate: targetSampleRate,
    numberOfChannels: 1,
    bitrate: 32000,
    });

    // 20ms 切帧
    pendingSamplesRef.current = appendFloat32(pendingSamplesRef.current, normalized);
    const frameSize = Math.max(1, Math.round(targetSampleRate * 0.02));

    while (pendingSamplesRef.current.length >= frameSize) {
    const frame = pendingSamplesRef.current.slice(0, frameSize);
    pendingSamplesRef.current = pendingSamplesRef.current.slice(frameSize);
    encodeFrame(frame);
    }

    为什么我特意写了“是否兼容仍需真实 ASR 验证”

    因为这是实际工程里很容易自我欺骗的一步。

    浏览器能产出 Opus,不等于服务端一定能识别。
    服务端能收到二进制包,也不等于豆包一定把它当成合法 speech_opus。

    所以我在代码里直接留了这句注释:

    这里发送的是 WebCodecs 产出的裸 Opus packet,分包节奏按 20ms 控制;是否与豆包 speech_opus 100% 兼容,仍需要真实 asr.final 联调来最终确认。

    这不是保守,而是工程诚实。

    为什么优先 AudioWorklet,但仍保留降级

    因为浏览器环境不会永远理想。

    我在代码里是这样处理的:

    • 优先 context.audioWorklet.addModule("/voice/pcm-capture.worklet.js")
    • 如果 worklet 初始化失败,降级到 ScriptProcessor

    这至少能保证:

    • 现代浏览器下有更稳的采集链路
    • 老环境里不至于页面直接废掉

    浏览器能力检查也不能省

    这部分我单独做了能力探测:

    if (typeof AudioEncoder === "undefined" || typeof AudioData === "undefined") {
    return {
    supported: false,
    reason: "当前浏览器缺少 WebCodecs AudioEncoder,无法生成 speech_opus。",
    };
    }

    const support = await AudioEncoder.isConfigSupported({
    codec: "opus",
    sampleRate: targetSampleRate,
    numberOfChannels: 1,
    bitrate: 32000,
    });

    页面右侧会直接提示:

    • 是否缺少 speech_opus 编码能力
    • 是否只能用预设话术继续联调

    这比用户点开始以后才发现什么都发不出去,要友好得多。


    11. 下行音频:OGG/Opus 播放为什么比想象中更难

    上行难,下行也不轻松。

    尤其是浏览器里做实时 OGG/Opus 播放,你很容易以为:

    收到二进制 -> new Blob -> audio.play,不就好了?

    问题是这样做通常不是“实时播放”,而是“整轮播完再放”。

    我这次的真实策略:主方案 + 兜底方案

    useOpusPlayer.ts 里我用了两套方案:

    • 主方案:MSE 流播
    • 兜底方案:Blob 整段播放

    播放流程图

    #mermaid-svg-V6Mk3cZYxZBf0hXC{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-V6Mk3cZYxZBf0hXC .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-V6Mk3cZYxZBf0hXC .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-V6Mk3cZYxZBf0hXC .error-icon{fill:#552222;}#mermaid-svg-V6Mk3cZYxZBf0hXC .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-V6Mk3cZYxZBf0hXC .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-V6Mk3cZYxZBf0hXC .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-V6Mk3cZYxZBf0hXC .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-V6Mk3cZYxZBf0hXC .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-V6Mk3cZYxZBf0hXC .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-V6Mk3cZYxZBf0hXC .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-V6Mk3cZYxZBf0hXC .marker{fill:#333333;stroke:#333333;}#mermaid-svg-V6Mk3cZYxZBf0hXC .marker.cross{stroke:#333333;}#mermaid-svg-V6Mk3cZYxZBf0hXC svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-V6Mk3cZYxZBf0hXC p{margin:0;}#mermaid-svg-V6Mk3cZYxZBf0hXC .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-V6Mk3cZYxZBf0hXC .cluster-label text{fill:#333;}#mermaid-svg-V6Mk3cZYxZBf0hXC .cluster-label span{color:#333;}#mermaid-svg-V6Mk3cZYxZBf0hXC .cluster-label span p{background-color:transparent;}#mermaid-svg-V6Mk3cZYxZBf0hXC .label text,#mermaid-svg-V6Mk3cZYxZBf0hXC span{fill:#333;color:#333;}#mermaid-svg-V6Mk3cZYxZBf0hXC .node rect,#mermaid-svg-V6Mk3cZYxZBf0hXC .node circle,#mermaid-svg-V6Mk3cZYxZBf0hXC .node ellipse,#mermaid-svg-V6Mk3cZYxZBf0hXC .node polygon,#mermaid-svg-V6Mk3cZYxZBf0hXC .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-V6Mk3cZYxZBf0hXC .rough-node .label text,#mermaid-svg-V6Mk3cZYxZBf0hXC .node .label text,#mermaid-svg-V6Mk3cZYxZBf0hXC .image-shape .label,#mermaid-svg-V6Mk3cZYxZBf0hXC .icon-shape .label{text-anchor:middle;}#mermaid-svg-V6Mk3cZYxZBf0hXC .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-V6Mk3cZYxZBf0hXC .rough-node .label,#mermaid-svg-V6Mk3cZYxZBf0hXC .node .label,#mermaid-svg-V6Mk3cZYxZBf0hXC .image-shape .label,#mermaid-svg-V6Mk3cZYxZBf0hXC .icon-shape .label{text-align:center;}#mermaid-svg-V6Mk3cZYxZBf0hXC .node.clickable{cursor:pointer;}#mermaid-svg-V6Mk3cZYxZBf0hXC .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-V6Mk3cZYxZBf0hXC .arrowheadPath{fill:#333333;}#mermaid-svg-V6Mk3cZYxZBf0hXC .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-V6Mk3cZYxZBf0hXC .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-V6Mk3cZYxZBf0hXC .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-V6Mk3cZYxZBf0hXC .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-V6Mk3cZYxZBf0hXC .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-V6Mk3cZYxZBf0hXC .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-V6Mk3cZYxZBf0hXC .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-V6Mk3cZYxZBf0hXC .cluster text{fill:#333;}#mermaid-svg-V6Mk3cZYxZBf0hXC .cluster span{color:#333;}#mermaid-svg-V6Mk3cZYxZBf0hXC div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-V6Mk3cZYxZBf0hXC .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-V6Mk3cZYxZBf0hXC rect.text{fill:none;stroke-width:0;}#mermaid-svg-V6Mk3cZYxZBf0hXC .icon-shape,#mermaid-svg-V6Mk3cZYxZBf0hXC .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-V6Mk3cZYxZBf0hXC .icon-shape p,#mermaid-svg-V6Mk3cZYxZBf0hXC .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-V6Mk3cZYxZBf0hXC .icon-shape .label rect,#mermaid-svg-V6Mk3cZYxZBf0hXC .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-V6Mk3cZYxZBf0hXC .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-V6Mk3cZYxZBf0hXC .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-V6Mk3cZYxZBf0hXC :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}

    binary tts.audio

    MSE 可用?

    MediaSource + SourceBuffer

    bufferedChunks -> Blob

    audio.play()

    tts.end

    waitForIdle()

    主方案:MSE 流播

    核心逻辑:

    const OGG_OPUS_MIME = 'audio/ogg; codecs="opus"';

    const supportsMse =
    typeof MediaSource !== "undefined" &&
    MediaSource.isTypeSupported(OGG_OPUS_MIME);

    if (supportsMse) {
    startMseStream();
    }

    开始播放时:

    const mediaSource = new MediaSource();
    objectUrlRef.current = URL.createObjectURL(mediaSource);
    audio.src = objectUrlRef.current;

    sourceOpenListenerRef.current = () => {
    const sourceBuffer = mediaSource.addSourceBuffer(OGG_OPUS_MIME);
    sourceBuffer.mode = "sequence";
    sourceBufferRef.current = sourceBuffer;
    flushQueue();
    };

    兜底方案:Blob 整段播放

    如果 MSE 不可用,或者初始化阶段就失败,我会回退:

    const blob = new Blob(bufferedChunksRef.current, { type: OGG_OPUS_MIME });
    objectUrlRef.current = URL.createObjectURL(blob);
    audio.src = objectUrlRef.current;
    setMode("buffered_blob");
    void audio.play();

    为什么我要明确说:Blob 只是兜底,不代表真实低延迟体验

    因为这件事必须说清楚。

    • MSE 流播:更接近“边收边播”
    • Blob 播放:通常要等收到完整音频块后再播

    所以我在页面运行提示里明确写了:

    当前仅使用兜底整段播放,这不代表真实低延迟体验。

    这不是给自己找借口,而是避免把“能响”误判成“实时语音已打通”。

    打断时为什么必须 stop / clear

    播放器里我专门做了 stopAndClear():

    const stopAndClear = useCallback(() => {
    const audio = audioRef.current;
    if (audio) {
    audio.pause();
    audio.removeAttribute("src");
    audio.load();
    }

    resetPlaybackState();
    replyEndedRef.current = true;
    setStatus("stopped");
    }, [resetPlaybackState]);

    它做的不是“暂停一下”,而是:

    • pause
    • 清掉 src
    • 重置 MediaSource / SourceBuffer
    • 清空缓存 chunk
    • 结束当前 reply 状态

    如果你只是 pause(),旧音频很容易在下一轮里继续冒出来。


    12. text_once:一个很小但非常关键的交互设计

    这次我特别喜欢 text_once 这个设计,因为它很小,但非常有用。

    场景很常见:

    • 现场环境嘈杂,不适合说话
    • 需要快速演示几个固定话术
    • 用户还没有授权麦克风,但你想先演示 AI 回答

    为什么不是把整个 session 切成 text 模式

    因为这会破坏“持续监听”的主体验。

    我真正想要的是:

    • 当前这一轮走文本输入
    • 这一轮结束后自动回到语音模式

    也就是:

    • session 还是 voice session
    • mode 只是本轮临时切到 text_once

    前端怎么做

    点击预设话术后,我的真实逻辑是:

  • 顶部胶囊立刻显示这句文本
  • 设置 mode = "text_once"
  • 页面切到 text_submitting
  • 暂停麦克风
  • 发 text.query
  • 等 tts.end
  • 恢复 voice_continuous
  • 关键代码如下:

    setTextOnceDisplayText(normalized);
    setMode("text_once");
    setState("text_submitting");
    setReplyPreviewText("");

    await ensureRealtimeStarted();

    textOncePausedMicRef.current = captureIsActive && !isMutedRef.current;
    if (textOncePausedMicRef.current) {
    pauseCapture();
    }

    sendGatewayCommand({ type: "mic.pause" });
    sendGatewayCommand({
    type: "text.query",
    content: normalized,
    restoreVoiceAfterDone: true,
    });

    后端怎么配合

    后端 submitTextQuery() 里做了两件事:

  • 把当前 runtime 标记成 text_once
  • 如果当前已经有未完成轮次,先打断并落库,再切换
  • 而且有一个很重要的细节:

    • 后端不会伪造 asr.final
    • 只把 pendingTextQueryContent 用来补最终 user 落库文本

    这保证了:

    • 顶部胶囊语义不被污染
    • 数据库存储仍然完整

    13. 打断:实时语音体验里最重要的一环

    如果只能选一个“最影响实时感”的能力,那一定是打断。

    为什么前端本地 stop 比等后端更重要

    因为用户感知首先来自耳朵。

    只要用户开口了,旧播报就应该马上停。
    哪怕后端的 interrupt 还没走到上游,也不能继续播。

    所以前端的主打断动作是:

    • 收到 asr.info
    • 立即 stopAndClear()

    或者用户手点“打断播报”:

    const interrupt = useCallback(async () => {
    stopAndClear();
    try {
    sendGatewayCommand({ type: "interrupt" });
    } catch {
    // 本地停播优先,后端 interrupt 只是增强能力
    }

    if (modeRef.current === "text_once") {
    await restoreMicAfterTextOnce();
    }
    }, []);

    为什么后端还要做 ignore 机制

    因为只靠前端停播还不够。

    真实链路里,旧轮次的事件可能晚到:

    • 旧 chat.partial
    • 旧 tts.audio
    • 旧 tts.ended

    如果后端不做“忽略旧轮次”,这些晚到事件还是会污染当前会话。

    所以我在后端维护了:

    • ignoredQuestionIds
    • ignoredReplyIds

    打断当前轮次时:

    private markCurrentRoundIgnored(): void {
    if (this.currentQuestionId) this.ignoredQuestionIds.add(this.currentQuestionId);
    if (this.currentReplyId) this.ignoredReplyIds.add(this.currentReplyId);
    }

    处理上游事件时:

    case 'chat.partial':
    if (this.isIgnoredRound(event.questionId, event.replyId)) return;

    case 'tts.audio':
    if (!this.suppressIncomingAudio) {
    this.sendBrowserAudio(event.audio);
    }
    return;

    keep_alive 下为什么不能把 ClientInterrupt 当主链路

    这个坑我在项目里专门收过一轮。

    因为文档语义里,ClientInterrupt(515) 更偏向“麦克风按键输入模式”。
    而我这个项目固定是 keep_alive。

    所以后端最后做成了:

    • 默认不强依赖上游 ClientInterrupt
    • 先本地打断
    • 后端只把上游 interrupt 当 best-effort 增强能力
    • 需要时通过 VOLC_ENABLE_CLIENT_INTERRUPT=1 显式开启

    代码里也直接写了这层保护:

    private async requestUpstreamInterrupt(): Promise<void> {
    if (!this.client || !this.connected) return;

    // keep_alive 下默认只做本地打断,不把 515 当强依赖
    if (!this.bestEffortClientInterruptEnabled) {
    return;
    }

    try {
    await this.client.sendInterrupt();
    } catch (error) {
    this.logger.warn();
    }
    }

    打断时序图

    DoubaoVoiceSessionServiceBrowser PlayerUserDoubaoVoiceSessionServiceBrowser PlayerUser#mermaid-svg-9zy5Dj7YjpI4cxj0{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-9zy5Dj7YjpI4cxj0 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-9zy5Dj7YjpI4cxj0 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-9zy5Dj7YjpI4cxj0 .error-icon{fill:#552222;}#mermaid-svg-9zy5Dj7YjpI4cxj0 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-9zy5Dj7YjpI4cxj0 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-9zy5Dj7YjpI4cxj0 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-9zy5Dj7YjpI4cxj0 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-9zy5Dj7YjpI4cxj0 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-9zy5Dj7YjpI4cxj0 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-9zy5Dj7YjpI4cxj0 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-9zy5Dj7YjpI4cxj0 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-9zy5Dj7YjpI4cxj0 .marker.cross{stroke:#333333;}#mermaid-svg-9zy5Dj7YjpI4cxj0 svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-9zy5Dj7YjpI4cxj0 p{margin:0;}#mermaid-svg-9zy5Dj7YjpI4cxj0 .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-9zy5Dj7YjpI4cxj0 text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-9zy5Dj7YjpI4cxj0 .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-9zy5Dj7YjpI4cxj0 .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-9zy5Dj7YjpI4cxj0 .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-9zy5Dj7YjpI4cxj0 .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-9zy5Dj7YjpI4cxj0 #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-9zy5Dj7YjpI4cxj0 .sequenceNumber{fill:white;}#mermaid-svg-9zy5Dj7YjpI4cxj0 #sequencenumber{fill:#333;}#mermaid-svg-9zy5Dj7YjpI4cxj0 #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-9zy5Dj7YjpI4cxj0 .messageText{fill:#333;stroke:none;}#mermaid-svg-9zy5Dj7YjpI4cxj0 .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-9zy5Dj7YjpI4cxj0 .labelText,#mermaid-svg-9zy5Dj7YjpI4cxj0 .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-9zy5Dj7YjpI4cxj0 .loopText,#mermaid-svg-9zy5Dj7YjpI4cxj0 .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-9zy5Dj7YjpI4cxj0 .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-9zy5Dj7YjpI4cxj0 .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-9zy5Dj7YjpI4cxj0 .noteText,#mermaid-svg-9zy5Dj7YjpI4cxj0 .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-9zy5Dj7YjpI4cxj0 .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-9zy5Dj7YjpI4cxj0 .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-9zy5Dj7YjpI4cxj0 .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-9zy5Dj7YjpI4cxj0 .actorPopupMenu{position:absolute;}#mermaid-svg-9zy5Dj7YjpI4cxj0 .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-9zy5Dj7YjpI4cxj0 .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-9zy5Dj7YjpI4cxj0 .actor-man circle,#mermaid-svg-9zy5Dj7YjpI4cxj0 line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-9zy5Dj7YjpI4cxj0 :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}tts.audio(old round)1binary tts.audio2playing3开始说话4ASRInfo(new round)5asr.info6stopAndClear()7markCurrentRoundIgnored()8suppressIncomingAudio = true9best-effort interrupt10late tts.audio(old round)11drop ignored audio12ASRResponse(new round)13asr.final14


    14. 会话落库如何设计:只保存最终轮次,不干扰实时体验

    这个边界要讲清楚,不然很多实现最后会变成“四不像”。

    实时语音页的职责只有两个

  • 管当前状态
  • 管当前轮体验
  • 列表渲染、翻页回看、统计聚合这些事情,都不应该进入实时主链路。

    轮次记录并没有丢

    后端在每轮结束后,会自动把最终文本写进 Session / Message 表。

    Prisma 模型里,Message 已经扩成这样:

    model Message {
    id String @id @default(cuid())
    role Role
    content String
    stopped Boolean @default(false)
    meta Json?
    sessionId String
    createdAt DateTime @default(now())
    session Session @relation(fields: [sessionId], references: [id], onDelete: Cascade)
    }

    为什么只存最终文本,不存 partial

    因为 partial 最大的问题不是占空间,而是污染语义。

    如果把这些都存进去:

    • asr.partial
    • chat.partial
    • 中途被覆盖的句子

    你最后的 Message 列表会很难看,也很难复用。

    所以我只落:

    • 用户最终识别文本
    • assistant 最终回复文本

    meta 里存了什么

    后端把语音轮次上下文塞进了 meta:

    • inputMode
    • questionId
    • replyId
    • dialogId
    • voiceInterrupted
    • voiceSessionId

    这样后续如果要补语音记录、质检、审计或运营分析,也不用重新设计一套新表。

    自动落库代码

    private async finalizeCurrentRound(options: { stopped: boolean }): Promise<void> {
    if (this.currentRoundPersisted) return;

    const userText = this.currentUserFinalText.trim();
    const assistantText = this.currentAssistantFinalText.trim();
    if (!userText && !assistantText) {
    this.currentRoundPersisted = true;
    return;
    }

    const baseMeta: VoiceMessageMeta = {
    inputMode: this.currentInputMode,
    questionId: this.currentQuestionId ?? undefined,
    replyId: this.currentReplyId ?? undefined,
    dialogId: this.dialogId ?? undefined,
    voiceInterrupted: Boolean(options.stopped || this.interrupted),
    voiceSessionId: this.voiceSessionId,
    };

    const statements: Prisma.PrismaPromise<unknown>[] = [
    this.prisma.session.upsert({
    where: { id: this.sessionId },
    update: { updatedAt: now },
    create: { id: this.sessionId, title: "语音会话", updatedAt: now, createdAt: now },
    }),
    ];

    if (userText) {
    statements.push(this.prisma.message.create({
    data: {
    sessionId: this.sessionId,
    role: Role.user,
    content: userText,
    meta: baseMeta as Prisma.InputJsonObject,
    },
    }));
    }

    if (assistantText) {
    statements.push(this.prisma.message.create({
    data: {
    sessionId: this.sessionId,
    role: Role.assistant,
    content: assistantText,
    stopped: Boolean(options.stopped || this.interrupted),
    meta: baseMeta as Prisma.InputJsonObject,
    },
    }));
    }

    await this.prisma.$transaction(statements);
    }


    15. 实际踩坑与收口过程

    这一章是整篇文章里最有工程味的部分。

    1)ClientInterrupt 不能作为 keep_alive 主链路强依赖

    这是很典型的“文档看起来支持,真实模式不一定稳”的坑。

    做法上我最后选择:

    • 前端本地停播是主链路
    • 后端上游 interrupt 是增强能力
    • 默认关闭,真实联调确认稳定后再开

    2)text_once 不能伪造 asr.final

    如果为了省事在后端补一条 synthetic asr.final,短期看好像前端更容易写,长期一定出问题:

    • 顶部文案语义污染
    • 历史记录语义混乱
    • 识别结果和临时输入的边界被打穿

    最终正确做法是:

    • text_once 顶部胶囊由前端立即显示点击文本
    • lastFinalQuestionText 只代表真实 ASR 最终结果

    3)tts.end 到了,不代表本地一定播完

    这个坑如果只看协议,很容易忽略。

    在 Blob 兜底播放模式下,很多时候 tts.end 到了,本地播放才真正开始。
    所以我最后加了 waitForPlayerIdle(),先等本地播放器 idle,再恢复监听。

    4)OGG/Opus 流播兼容性比预期复杂

    即便浏览器宣称支持:

    MediaSource.isTypeSupported('audio/ogg; codecs="opus"')

    也不代表它一定能稳定初始化。
    我在代码里保留了:

    • MSE 主方案
    • Blob 兜底方案
    • 页面运行提示明确告诉用户当前是不是退化模式

    5)WebCodecs 只能说明“具备候选能力”,不等于闭环已经验证

    这是上行链路最重要的现实约束。

    浏览器能产出 Opus packet,最多只能说明:

    • 这条链路有落地可能
    • 这不是伪实现

    但它仍然需要真实联调去验证:

    • 豆包是否把这批包识别成合法 speech_opus
    • 能不能稳定回 asr.final
    • 不同浏览器差异有多大

    6)StrictMode 下异步初始化很容易误伤页面状态

    在开发态 React StrictMode 下,effect 会触发额外的 mount/unmount 重放。
    我真实项目里就遇到过一次:

    • 旧请求返回时发现页面已“卸载”
    • 结果初始化状态没有正确收口
    • 按钮一直禁用

    最后的修复办法是:

    • 在每次 effect setup 时重新恢复 mounted 标记
    • 对开发态旧请求结果做静默丢弃

    7)Next.js 开发态噪音不要误判成语音链路问题

    我还踩过两类很“偏工程环境”的坑:

    • 浏览器翻译插件注入 DOM,导致 hydration mismatch
    • Turbopack 在中文路径下产生 source map 调试噪音

    这类问题很容易把排查方向带偏。
    语音链路没问题,但开发调试层在报错。

    我的实际收口方式是:

    • 关闭翻译/划词插件影响
    • 本地 dev 改成 next dev –webpack
    • 优先看业务 WS / runtime 状态,而不是先看 dev overlay

    8)固定 App Key 被本地环境变量误覆盖

    这是个非常真实的供应商集成坑。

    我这个项目里,某个请求头值在产品约束下是固定的。
    如果你让 .env 里的错误值把它覆盖掉,表面上“配置更灵活”,实际上会让联调链路莫名其妙失败。

    所以我最后干脆把它收口成:

    • 固定常量
    • 如果环境变量试图覆盖且值不一致,直接 warning,忽略覆盖

    16. 最后总结:这套方案适合什么场景

    这套方案最适合的,是从一开始就把 Web 端交互定义成纯语音助手的这些场景:

    • 独立语音助手页
    • 展厅讲解 / 接待台
    • 驾驶舱 / 智能座舱演示
    • 需要“看起来像实时对话”的 Web 端语音界面
    • 已有业务系统,但想新增独立语音入口

    这套方案的优点

    • 前后端职责边界清晰
    • 页面状态机比消息驱动更适合实时语音
    • 豆包协议细节被后端吃掉,前端更稳定
    • 历史自动落库,不影响纯语音页体验
    • text_once、打断、恢复监听这些关键交互是完整的

    它的边界也很清楚

    • 上行 speech_opus 仍然依赖真实浏览器 + 真实模型闭环验证
    • 下行 OGG/Opus 流播仍受浏览器能力影响
    • 如果要追求更极致的低延迟和更广兼容性,音频编解码仍有继续优化空间

    下一步我会继续打磨什么

  • 单独做一个语音记录页
  • 更稳的浏览器音频编解码方案
  • 更细的 runtime 监控与日志
  • 更明确的播放器兼容性矩阵
  • 更自然的语音状态动画反馈

  • 附:后端模块关系图

    如果你更偏后端视角,这张图也许更直观。

    #mermaid-svg-TOdI8qMdJXRxzzrl{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-TOdI8qMdJXRxzzrl .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-TOdI8qMdJXRxzzrl .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-TOdI8qMdJXRxzzrl .error-icon{fill:#552222;}#mermaid-svg-TOdI8qMdJXRxzzrl .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-TOdI8qMdJXRxzzrl .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-TOdI8qMdJXRxzzrl .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-TOdI8qMdJXRxzzrl .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-TOdI8qMdJXRxzzrl .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-TOdI8qMdJXRxzzrl .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-TOdI8qMdJXRxzzrl .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-TOdI8qMdJXRxzzrl .marker{fill:#333333;stroke:#333333;}#mermaid-svg-TOdI8qMdJXRxzzrl .marker.cross{stroke:#333333;}#mermaid-svg-TOdI8qMdJXRxzzrl svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-TOdI8qMdJXRxzzrl p{margin:0;}#mermaid-svg-TOdI8qMdJXRxzzrl .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-TOdI8qMdJXRxzzrl .cluster-label text{fill:#333;}#mermaid-svg-TOdI8qMdJXRxzzrl .cluster-label span{color:#333;}#mermaid-svg-TOdI8qMdJXRxzzrl .cluster-label span p{background-color:transparent;}#mermaid-svg-TOdI8qMdJXRxzzrl .label text,#mermaid-svg-TOdI8qMdJXRxzzrl span{fill:#333;color:#333;}#mermaid-svg-TOdI8qMdJXRxzzrl .node rect,#mermaid-svg-TOdI8qMdJXRxzzrl .node circle,#mermaid-svg-TOdI8qMdJXRxzzrl .node ellipse,#mermaid-svg-TOdI8qMdJXRxzzrl .node polygon,#mermaid-svg-TOdI8qMdJXRxzzrl .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-TOdI8qMdJXRxzzrl .rough-node .label text,#mermaid-svg-TOdI8qMdJXRxzzrl .node .label text,#mermaid-svg-TOdI8qMdJXRxzzrl .image-shape .label,#mermaid-svg-TOdI8qMdJXRxzzrl .icon-shape .label{text-anchor:middle;}#mermaid-svg-TOdI8qMdJXRxzzrl .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-TOdI8qMdJXRxzzrl .rough-node .label,#mermaid-svg-TOdI8qMdJXRxzzrl .node .label,#mermaid-svg-TOdI8qMdJXRxzzrl .image-shape .label,#mermaid-svg-TOdI8qMdJXRxzzrl .icon-shape .label{text-align:center;}#mermaid-svg-TOdI8qMdJXRxzzrl .node.clickable{cursor:pointer;}#mermaid-svg-TOdI8qMdJXRxzzrl .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-TOdI8qMdJXRxzzrl .arrowheadPath{fill:#333333;}#mermaid-svg-TOdI8qMdJXRxzzrl .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-TOdI8qMdJXRxzzrl .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-TOdI8qMdJXRxzzrl .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-TOdI8qMdJXRxzzrl .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-TOdI8qMdJXRxzzrl .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-TOdI8qMdJXRxzzrl .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-TOdI8qMdJXRxzzrl .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-TOdI8qMdJXRxzzrl .cluster text{fill:#333;}#mermaid-svg-TOdI8qMdJXRxzzrl .cluster span{color:#333;}#mermaid-svg-TOdI8qMdJXRxzzrl div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-TOdI8qMdJXRxzzrl .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-TOdI8qMdJXRxzzrl rect.text{fill:none;stroke-width:0;}#mermaid-svg-TOdI8qMdJXRxzzrl .icon-shape,#mermaid-svg-TOdI8qMdJXRxzzrl .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-TOdI8qMdJXRxzzrl .icon-shape p,#mermaid-svg-TOdI8qMdJXRxzzrl .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-TOdI8qMdJXRxzzrl .icon-shape .label rect,#mermaid-svg-TOdI8qMdJXRxzzrl .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-TOdI8qMdJXRxzzrl .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-TOdI8qMdJXRxzzrl .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-TOdI8qMdJXRxzzrl :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}

    VoiceGateway浏览器 WS 入口

    VoiceControllerREST 接口

    VoiceService创建/查询/关闭

    VoiceSessionRegistryruntime 管理

    VoiceSessionService状态机/落库/打断

    DoubaoRealtimeClient上游 WS 客户端

    Codec二进制编解码

    Translator事件翻译

    会话 / 消息表


    赞(0)
    未经允许不得转载:171主机测评 » React + Node.js 实战:用豆包端到端实时语音大模型 API 落地web纯语音助手
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址