这篇文章完全基于一个本地真实项目的实现来写:前端是 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
  • 支持打断
  • 支持自动落库

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

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

  1. 密钥不能放前端
  2. 豆包是二进制协议,前端不应该直接背协议细节
  3. runtime 状态要统一维护
  4. 最终消息要自动落库
  5. 打断、忽略旧轮次、关闭回收,都更适合放在服务端做

所以这套方案天然就是三层:

  • 浏览器纯语音页
  • 我自己的 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、数据库四条链路得一起成立。

整体架构图

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

useAudioCapture
getUserMedia + AudioWorklet + WebCodecs

useOpusPlayer
MSE stream / Blob fallback

Voice REST
/api/voice/*

Business WS
/api/voice/ws

VoiceController

VoiceGateway

VoiceSessionRegistry

VoiceSessionService

DoubaoRealtimeClient

Codec + Translator

PostgreSQL
Session / 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

页面布局图

/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";

状态机图

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 自己的播报重新采进去

所以前端真实逻辑是:

  1. 收到 tts.end
  2. 调 endStream()
  3. 再 await waitForPlayerIdle()
  4. 最后才恢复 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
  • 文本与音频混合事件

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

  1. doubao-realtime.codec.ts:负责编解码
  2. 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. 浏览器和后端的事件契约设计

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

  1. 事件语义不清
  2. 前端和后端都在“猜”对方的协议

所以我这里把浏览器侧协议单独定义了。

前端 -> 后端

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

后端 -> 前端

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

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

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

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

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

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

一条完整事件时序图

PostgreSQLDoubao Realtime APIVoiceSessionServiceBusiness WSBrowser /voicePostgreSQLDoubao Realtime APIVoiceSessionServiceBusiness WSBrowser /voicePOST /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”并不简单。

我的真实上行链路

getUserMedia

Web Audio Graph

AudioWorklet
(失败时降级 ScriptProcessor)

单声道 PCM + 电平

LinearResampler
-> 16kHz

20ms 分帧

WebCodecs AudioEncoder(opus)

裸 Opus packet

Business WS binary

VoiceSessionService

Doubao TaskRequest

前端真实实现用了什么

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

  1. getUserMedia 拉起麦克风
  2. 用 AudioContext 建音频图
  3. 优先走 AudioWorklet
  4. 如果 worklet 不可用,降级到 ScriptProcessor
  5. 输出单声道 PCM
  6. 线性重采样到 16kHz
  7. 按 20ms 切帧
  8. 用 WebCodecs AudioEncoder 编成 Opus
  9. 通过业务 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 整段播放

播放流程图

是

否

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

前端怎么做

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

  1. 顶部胶囊立刻显示这句文本
  2. 设置 mode = "text_once"
  3. 页面切到 text_submitting
  4. 暂停麦克风
  5. 发 text.query
  6. 等 tts.end
  7. 恢复 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() 里做了两件事:

  1. 把当前 runtime 标记成 text_once
  2. 如果当前已经有未完成轮次,先打断并落库,再切换

而且有一个很重要的细节:

  • 后端不会伪造 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 PlayerUsertts.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. 会话落库如何设计:只保存最终轮次,不干扰实时体验

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

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

  1. 管当前状态
  2. 管当前轮体验

列表渲染、翻页回看、统计聚合这些事情,都不应该进入实时主链路。

轮次记录并没有丢

后端在每轮结束后,会自动把最终文本写进 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 流播仍受浏览器能力影响
  • 如果要追求更极致的低延迟和更广兼容性,音频编解码仍有继续优化空间

下一步我会继续打磨什么

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

附:后端模块关系图

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

VoiceGateway
浏览器 WS 入口

VoiceController
REST 接口

VoiceService
创建/查询/关闭

VoiceSessionRegistry
runtime 管理

VoiceSessionService
状态机/落库/打断

DoubaoRealtimeClient
上游 WS 客户端

Codec
二进制编解码

Translator
事件翻译

会话 / 消息表


更多推荐