模拟面试实时语音流程

AI Agent 工程实践教程 · 第 13 章

梳理实时语音面试中的录音、转写、LLM 追问、TTS 播放和状态同步。

返回系列目录

第十三天:模拟面试实时语音流程

前面我们已经完成了学生端 AI 问答、知识库问答、Text-to-SQL、面试复盘等功能。

今天要讲一个更接近真实产品体验的 AI 功能:模拟面试实时语音对话

它和“面试复盘”不一样。

面试复盘是学生上传真实面试录音,系统异步转写和分析。模拟面试是学生在平台里直接和 AI 面试官实时对话:

学生说话
  ↓
浏览器采集麦克风音频
  ↓
Java 后端转发给火山实时语音模型
  ↓
火山识别学生语音并生成面试官追问
  ↓
Java 后端把文本和音频返回前端
  ↓
前端播放 AI 面试官声音

这个功能最核心的难点不是“调一次大模型接口”,而是要把 麦克风采集、WebSocket、ASR、对话模型、TTS、PCM 播放 串成一条稳定的实时链路。


1. 今天学完要会什么

学完这一节,应该能回答下面几个问题:

  1. 模拟面试和面试复盘的区别是什么?
  2. 为什么实时语音对话要用 WebSocket?
  3. 学生端是怎样采集麦克风音频的?
  4. Java 后端在实时语音链路里负责什么?
  5. 火山 StartSession 里为什么要配置 TTS 的 audio_config
  6. 为什么音频最好用 WebSocket 二进制帧返回前端?
  7. 前端拿到 PCM 后是怎样播放出声音的?

这一节课的重点不是背某一个 API 参数,而是理解实时语音功能的端到端工程链路。


2. 功能目标

模拟面试希望达到的体验是:

学生点击开始
  ↓
系统打开麦克风
  ↓
AI 面试官先开场提问
  ↓
学生自然回答
  ↓
学生点击“说完了”提交本轮回答
  ↓
AI 面试官根据回答继续追问

它不是一个普通聊天框。

普通聊天框只需要:

文本输入 -> 大模型 -> 文本输出

实时语音模拟面试需要:

音频输入 -> 语音识别 -> 对话理解 -> 语音合成 -> 音频播放

所以它涉及三类数据:

数据类型 来源 作用
学生语音 PCM 浏览器麦克风 发给火山做 ASR 和对话
面试官文本 火山实时事件 前端展示实时返回内容
面试官语音 PCM 火山 TTS 前端播放 AI 面试官声音

3. 先理解 WebSocket 是什么

在讲模拟面试代码之前,先要理解一个基础概念:WebSocket

平时我们写后台接口,大部分用的是 HTTP。

HTTP 的特点是:

前端发起请求
  ↓
后端处理请求
  ↓
后端返回结果
  ↓
这次请求结束

比如学生点击“查询订单”,前端调用接口:

GET /student/orders

后端返回订单列表,这次交互就结束了。

这种方式适合普通业务接口,例如:

场景 是否适合 HTTP
登录 适合
查询课程列表 适合
上传简历 适合
保存作业 适合

但是模拟面试不一样。

模拟面试过程中,浏览器和后端之间不是“一问一答”,而是要持续不断地互相发消息:

浏览器不断发送学生麦克风音频
后端不断返回 AI 面试官文本
后端不断返回 AI 面试官音频
浏览器还要随时告诉后端:这一段回答结束了

如果用普通 HTTP,会变成:

前端每隔几十毫秒发一次 HTTP 请求上传音频
前端再不断轮询后端有没有 AI 回复
后端有音频还要拆成很多次响应

这样做会很别扭:

问题 说明
请求太多 音频是连续数据,用 HTTP 会产生大量请求
延迟高 轮询不是实时的,AI 回复会慢
服务端不好主动推送 HTTP 一般是前端请求,后端响应
音频流不好处理 实时音频更适合持续连接

WebSocket 解决的就是这个问题。

它可以在浏览器和后端之间建立一条持续连接:

浏览器连接后端 WebSocket
  ↓
连接保持不断
  ↓
浏览器可以随时发消息给后端
  ↓
后端也可以随时发消息给浏览器

可以把 WebSocket 理解成:

HTTP 像发短信:发一次,回一次。
WebSocket 像打电话:接通以后,双方可以一直说。

模拟面试就是一个“打电话”场景,所以更适合 WebSocket。

在这个功能里,WebSocket 里传两种消息:

消息类型 用途
文本 JSON 表示状态、事件、识别文本、提交指令
二进制 Binary 传输麦克风 PCM 和面试官 PCM 音频

所以后面看到:

ws.send(...)

可以理解成前端通过这条持续连接给后端发消息。

看到:

session.sendMessage(...)

可以理解成 Java 后端通过这条持续连接给浏览器发消息。


4. 当前项目的技术选型

当前 YanQue 项目采用的是:

React 学生端
  ↓
Java Spring Boot 后端
  ↓
火山 / 豆包 Realtime WebSocket

完整链路如下:

YanQue-Student-Web
  StudentMockInterviewVoicePage.tsx
    ↓ WebSocket
YanQue-Admin
  MockInterviewVoiceWebSocketHandler.java
    ↓
  DoubaoRealtimeVoiceClient.java
    ↓ WebSocket
火山 Realtime Dialogue

用图表示就是:

模拟面试实时语音总体架构

这里没有让前端直接连接火山。

原因是:

方案 优点 问题
前端直连火山 链路短 密钥暴露、鉴权复杂、业务状态难控制
Java 后端中转 统一鉴权、统一业务状态、方便保存会话 后端要处理 WebSocket 协议和音频转发

学生端正式功能更适合走 Java 后端中转,因为学生登录、简历画像、模拟面试 session、权限控制都在 Java 业务系统里。


5. 一次模拟面试的完整流程

用户点击“开始语音对话”以后,请求经过下面这条链路:

StudentMockInterviewVoicePage.tsx
  ↓
studentApi.startMockInterviewVoice(sessionId)
  ↓
StudentMockInterviewServiceImpl.startVoiceInterview()
  ↓
DoubaoRealtimeVoiceClient.start()
  ↓
连接火山 Realtime WebSocket
  ↓
发送 StartConnection
  ↓
发送 StartSession
  ↓
前端连接 Java WebSocket
  ↓
浏览器采集麦克风 PCM
  ↓
Java 转发 PCM 到火山
  ↓
火山返回识别文本、回答文本、TTS 音频
  ↓
Java 把文本 JSON 和音频二进制分别发回前端
  ↓
前端展示文本并播放音频

注意这里有两个 WebSocket:

WebSocket 连接双方 用途
前端 WebSocket 浏览器 ↔ Java 后端 传学生音频、返回面试官文本和音频
火山 WebSocket Java 后端 ↔ 火山 调用实时语音模型

Java 后端相当于一个实时语音网关。

更完整的时序图如下:

语音面试启动时序

这张图里最重要的是看清楚:前端不是直接连火山,而是先让 Java 建好火山会话,再用 voiceSessionId 把前端 WebSocket 和火山 WebSocket 关联起来。

如果把启动、前端 WebSocket 接入、音频上传、手动提交本轮回答、火山返回音频都放在一起,完整时序会更像下面这样:

模拟面试实时语音完整时序图

这张图可以按五段看:

  1. 前端先用 HTTP 请求 Java,开始本场模拟面试语音。
  2. Java 生成 voiceSessionId,再去连接火山 Realtime。
  3. Java 等火山 StartConnectionStartSession 都成功后,把 voiceSessionId 返回给前端。
  4. 前端再带着 voiceSessionId 建立浏览器到 Java 的 WebSocket,Java 注册 listener。
  5. 后续学生音频、点击“说完了”、AI 面试官文本和 PCM 音频,都通过这两条 WebSocket 中转。

6. 前端如何采集学生语音

前端主要代码:

YanQue-Student-Web/src/pages/StudentMockInterviewVoicePage.tsx

浏览器通过 getUserMedia 打开麦克风:

const stream = await navigator.mediaDevices.getUserMedia({
  audio: {
    channelCount: 1,
    echoCancellation: true,
    noiseSuppression: true,
    autoGainControl: true,
  },
});

然后用 AudioContext + ScriptProcessorNode 拿到实时音频帧:

processor.onaudioprocess = (event) => {
  const channelData = event.inputBuffer.getChannelData(0);
  ws.send(floatToPcm16(channelData, audioContext.sampleRate));
};

浏览器采集到的是 Float32Array,火山需要的是 16k PCM,所以前端要做一次重采样和格式转换:

浏览器 Float32 音频
  ↓
重采样到 16000Hz
  ↓
转换成 16bit little-endian PCM
  ↓
WebSocket binary 发给 Java

这一步对应函数:

floatToPcm16(input, audioContext.sampleRate)

前端音频处理可以画成这样:

前端采集音频并手动提交回答

这里要注意两个格式:

阶段 格式
浏览器采集出来 Float32Array,值范围大约是 -1 ~ 1
发给后端之前 pcm_s16le,也就是 16 位小端 PCM

7. 前端如何提交本轮回答

学生说话时,前端会一直把麦克风音频发给 Java。

但是火山也需要知道:这一轮回答什么时候结束

当前实现用一个更直观的方式:学生说完以后,点击页面上的“说完了”按钮。

学生开始回答
  ↓
前端持续上传 PCM 音频
  ↓
学生点击“说完了”
  ↓
发送 AUDIO_COMMIT

核心逻辑是:

const commitCurrentAnswer = () => {
  const ws = wsRef.current;
  if (!ws || ws.readyState !== WebSocket.OPEN) {
    return;
  }
  ws.send(JSON.stringify({ type: 'AUDIO_COMMIT' }));
}

这里的 AUDIO_COMMIT 会告诉后端:

当前这一段用户回答结束了,可以让火山生成面试官回复。

注意,AUDIO_COMMIT 不是关闭整场模拟面试。

它只是提交当前这一轮回答。

所以这三个动作要分清楚:

动作 含义
持续发送 binary 音频 学生正在说话,前端持续上传声音
发送 AUDIO_COMMIT 学生这一轮说完了,请火山开始处理
关闭 WebSocket 整场语音面试结束

8. Java 后端 WebSocket 的职责

Java 前端 WebSocket 入口:

YanQue-Admin/src/main/java/cn/yanque/studentFront/websocket/MockInterviewVoiceWebSocketHandler.java

它主要做四件事:

职责 说明
接收前端麦克风音频 handleBinaryMessage 接收 PCM
转发音频给火山 调用 doubaoRealtimeVoiceClient.forwardAudio()
接收前端提交事件 AUDIO_COMMIT 调用火山提交当前语音段
转发火山事件给前端 文本走 JSON,音频走 BinaryMessage

前端发来的二进制音频会进入:

protected void handleBinaryMessage(WebSocketSession session, BinaryMessage message)

然后转发给火山:

doubaoRealtimeVoiceClient.forwardAudio(voiceSessionId, readBytes(message.getPayload()));

这个 Handler 的职责图:

Java WebSocket Handler 职责

这也是这节课需要学生重点看懂的后端入口类。


9. 为什么后端发送要加锁

Spring WebSocket 的 session.sendMessage() 不能被多个线程同时调用。

但是实时语音场景里可能同时发生:

线程 A:给前端回 AUDIO_RECEIVED
线程 B:转发火山 DOUBAO_EVENT
线程 C:转发火山音频 BinaryMessage

如果同时写同一个 WebSocket session,Tomcat 可能报:

TEXT_PARTIAL_WRITING

所以后端要给每个前端 WebSocket session 加发送锁:

private static final String ATTR_SEND_LOCK = "sendLock";

发送 JSON 和发送二进制都共用同一把锁:

Object sendLock = session.getAttributes().computeIfAbsent(ATTR_SEND_LOCK, key -> new Object());
synchronized (sendLock) {
    if (session.isOpen()) {
        session.sendMessage(...);
    }
}

这一步解决的是连接稳定性问题。

可以把发送锁理解成一个排队口:多个线程都要通过同一个 sendLock,同一时刻只有一个线程能写当前 WebSocketSession

没有这把锁时,多个线程可能同时写同一条 WebSocket,底层容器会不知道当前帧到底是谁还没写完。


10. Java 如何连接火山实时语音

火山客户端代码:

YanQue-Admin/src/main/java/cn/yanque/studentFront/client/DoubaoRealtimeVoiceClient.java

连接火山时需要带请求头:

.header("X-Api-App-ID", properties.getAppId())
.header("X-Api-Access-Key", properties.getAccessToken())
.header("X-Api-Resource-Id", properties.getResourceId())
.header("X-Api-App-Key", properties.getAppKey())
.header("X-Api-Request-Id", requestId)
.header("X-Api-Connect-Id", requestId)

连接成功后,依次发送:

StartConnection
StartSession

其中 StartSession 非常关键,因为它决定了输入音频格式、面试官提示词、TTS 输出格式。

当前启动过程不要把 StartConnectionStartSession 随便连着发,而是应该等服务端确认。这个确认过程已经放在上面的启动时序图里:先等 event=50,再发 StartSession,再等 event=150

项目里用 CompletableFuture 做等待,本质就是:

发送 StartConnection 后先挂起等待
  ↓
火山返回成功事件
  ↓
CompletableFuture 完成
  ↓
代码继续发送 StartSession

11. Java 如何解析火山返回的二进制帧

火山 Realtime 返回给 Java 的消息,不一定是普通 JSON。

当前代码里,Java 到火山这一段 WebSocket 收到的大部分数据是 火山自己的二进制协议帧

也就是说,onBinary 收到的不是直接可播放的 PCM,也不是直接可读的 JSON,而是一整包协议数据:

协议头
  ↓
event 编号
  ↓
可选 sessionId
  ↓
payload 长度
  ↓
payload 内容

所以后端需要用:

parseServerFrame(ByteBuffer data)

把火山返回的原始二进制帧,解析成项目内部更好处理的 JSONObject

火山官方协议里最关键的知识

火山官方文档里把实时语音消息分成两层:

WebSocket
  ↓
火山自己的二进制协议帧
  ↓
event / sessionId / payload

所以我们不能把 onBinary 收到的数据直接当成音频播放,也不能直接当成 JSON 解析。

一帧数据最前面固定有 4 个字节的基础协议头:

Byte 0:协议版本 + 头长度
Byte 1:messageType + flags
Byte 2:serialization + compression
Byte 3:reserved

每个字节又会拆成高 4 位和低 4 位:

字节 高 4 位 低 4 位 我们项目里的作用
Byte 0 Protocol Version Header Size 当前一般是 0x11,表示 v1 + 4 字节基础头
Byte 1 Message Type Message Type Specific Flags 判断这是文本事件、音频事件、错误事件,以及后面是否带 event/sequence
Byte 2 Serialization Method Compression Method 判断 payload 是 JSON、原始二进制,是否压缩
Byte 3 Reserved Reserved 保留字段,当前直接跳过

比如这 4 个字节:

11 94 10 00

可以这样拆:

字节 二进制 拆解 含义
0x11 0001 0001 0001 + 0001 协议 v1,基础头 4 字节
0x94 1001 0100 1001 + 0100 服务端文本响应,并且携带 event
0x10 0001 0000 0001 + 0000 payload 是 JSON,不压缩
0x00 0000 0000 保留 暂时不用

这里的 0x94 很重要:

0x94 = 1001 0100

高 4 位 1001 = messageType = Full-server response
低 4 位 0100 = flags = 这帧后面带 event 编号

火山文档里的常见 messageType 可以这样记:

messageType 含义 常见方向
0b0001 Full-client request 客户端发 JSON 事件给火山
0b1001 Full-server response 火山返回 JSON 事件给客户端
0b0010 Audio-only request 客户端发音频给火山
0b1011 Audio-only response 火山返回音频给客户端
0b1111 Error information 火山返回错误信息

常见 flags 可以这样记:

flags 含义
0b0100 后面带 event 编号
0b0000 没有 sequence 字段
0b0001 带 sequence,且不是最后一包
0b0010 最后一包,但不带 sequence
0b0011 最后一包,带负数 sequence,常见是 -1
0b1111 错误包,后面会带错误 code

注意一个容易写错的点:

flags 不负责表示 sessionId。

也就是说,不能看到某个 bit 就认为后面一定有 sessionId。

火山文档里的规则是:

事件类型 后面跟什么 ID
Connection 级事件 connect id size + connect id
Session 级事件 session id size + session id

我们项目里只关心一条模拟面试会话,所以重点读的是 Session 级事件里的 sessionId

再举一个完整一点的例子。

假设火山返回一条 StartSession 成功事件,前 4 个字节是:

11 94 10 00

后面的结构大概是:

11 94 10 00
00 00 00 96
00 00 00 24
39 35 38 31 ... 2d 61 62 63
00 00 00 2A
7B 22 74 79 70 65 ... 7D

拆开看:

片段 含义
11 94 10 00 4 字节协议头
00 00 00 96 event = 150,也就是 StartSession 成功
00 00 00 24 sessionId 长度,十六进制 0x24 = 36
39 35 38 31 ... sessionId 的 UTF-8 字节
00 00 00 2A payload 长度,十六进制 0x2A = 42
7B 22 ... 7D payload 内容,可能是一段 JSON

如果火山返回的是 AI 面试官语音,前 4 个字节可能类似:

11 B4 00 00

拆开看:

字节 拆解 含义
0x11 0001 0001 v1 + 4 字节基础头
0xB4 1011 0100 Audio-only response,并且携带 event
0x00 0000 0000 payload 是原始二进制,不压缩
0x00 保留 暂时不用

后面的 payload 就不是 JSON,而是 PCM 音频字节。

这就是为什么代码里看到:

int messageType = (typeAndFlags >> 4) & 0x0F;
int flags = typeAndFlags & 0x0F;
int serialization = (serializationAndCompression >> 4) & 0x0F;

本质是在把官方协议里的高 4 位、低 4 位拆出来。

11.1 为什么要解析成 JSONObject

MockInterviewVoiceWebSocketHandler 最终只关心两类结果:

结果 说明 后续处理
文本事件 ASR 文本、模型回复文本、启动成功事件 用 JSON 文本发给前端
音频事件 火山 TTS 返回的 PCM 音频 用 WebSocket Binary 发给前端

所以 parseServerFrame 的目标不是把火山协议暴露给前端,而是统一整理成这种结构:

{
  "event": 352,
  "eventType": "SERVER_AUDIO_ONLY_RESPONSE",
  "sessionId": "...",
  "messageType": 11,
  "flags": 4,
  "serialization": 0,
  "payloadBytes": 2972,
  "audio": "base64..."
}

如果是文本事件,则可能是:

{
  "event": 350,
  "eventType": "SERVER_FULL_RESPONSE",
  "payload": "{\"text\":\"好的,请继续介绍你的项目\"}",
  "text": "好的,请继续介绍你的项目"
}

11.2 第一步:复制 ByteBuffer,并按大端序读取

代码开头是:

ByteBuffer buffer = data.asReadOnlyBuffer().order(ByteOrder.BIG_ENDIAN);

这里有两个点:

代码 含义
asReadOnlyBuffer() 拿一个只读副本,避免改变原始 ByteBuffer
ByteOrder.BIG_ENDIAN 火山协议里的整数按大端序读取

前端播放 PCM 时用的是小端序:

view.getInt16(i * 2, true)

但是火山协议头里的长度、event 编号这些整数,用的是大端序。

所以这里不要混淆:

数据 字节序
火山协议里的 int 大端序
PCM 音频采样值 pcm_s16le 小端序

11.3 第二步:读取协议头 4 个字节

代码:

int header = buffer.get() & 0xFF;
int typeAndFlags = buffer.get() & 0xFF;
int serializationAndCompression = buffer.get() & 0xFF;
buffer.get();

这 4 个字节是基础协议头。

可以这样理解:

字节 作用
第 1 字节 协议版本 + header size
第 2 字节 messageType + flags
第 3 字节 serialization + compression
第 4 字节 reserved,保留位

因为 Java 的 byte 是有符号的,所以这里写了:

& 0xFF

它的作用是把 byte 转成 0 ~ 255 的无符号整数,避免高位被当成负数。

11.4 第三步:从高低 4 位里拆出字段

代码:

int messageType = (typeAndFlags >> 4) & 0x0F;
int flags = typeAndFlags & 0x0F;
int serialization = (serializationAndCompression >> 4) & 0x0F;

一个字节有 8 位。

火山协议会把一个字节拆成“高 4 位”和“低 4 位”使用。

例如 typeAndFlags

高 4 位:messageType
低 4 位:flags

所以:

(typeAndFlags >> 4) & 0x0F

表示先右移 4 位,拿到高 4 位。

typeAndFlags & 0x0F

表示只保留低 4 位。

这一步解析出来的字段大概是:

字段 作用
messageType 消息类型,比如 JSON 事件还是音频
flags 是否带 event、sequence、错误 code 等可选字段
serialization payload 是 JSON 还是原始二进制

表格里的 headerSize 表示协议头长度。当前火山文档里基础头固定 4 字节,所以项目代码暂时没有使用这个字段。后面如果协议扩展了,再补这一段处理。

11.5 第四步:读取 event、sequence、sessionId

先看 event:

if ((flags & 0x04) != 0 && buffer.remaining() >= 4) {
    event = buffer.getInt();
}

flags & 0x04 的意思是:判断当前帧是否带 event 编号。

event 很关键,因为它告诉我们当前是什么类型的服务端事件:

event 含义
50 StartConnection 成功
150 StartSession 成功
350 服务端完整响应
352 服务端音频响应

再看 sequence:

if (hasSequence(flags) && buffer.remaining() >= 4) {
    sequence = buffer.getInt();
}

sequence 可以理解成序号,部分帧会带,用来表达消息顺序。

最后看 sessionId。

if (isSessionLevelEvent(event) && buffer.remaining() >= 4) {
    String parsedSessionId = readOptionalSessionId(buffer, true);
}

这里和 flags 不一样。

按照火山文档,connect idsession id 不是由 flags 直接决定,而是由事件类型决定:

事件层级 使用的 ID
Connection 级事件 connect id
Session 级事件 session id

所以代码里先通过:

isSessionLevelEvent(event)

判断当前是不是 Session 级事件。

如果是 Session 级事件,再尝试读取 sessionId。

为什么方法仍然叫 readOptionalSessionId

因为不是每个火山返回帧都一定带 sessionId。

sessionId 的格式是:

4 字节 sessionId 长度
  ↓
sessionId 字节内容

但如果当前帧没有 sessionId,而代码强行读取,就可能把 payload 长度误认为 sessionId 长度。

所以 readOptionalSessionId 做了一个保护:

buffer.mark();
尝试读取 sessionId
如果不像 sessionId,就 buffer.reset();

也就是说:先试着读,如果读错了,就把位置退回去。

11.6 第五步:读取 payload

代码:

byte[] payload = readPayload(buffer);

payload 才是真正的业务内容。

它可能是:

payload 类型 例子
JSON 文本 ASR 结果、模型文本、状态事件
原始音频 TTS 返回的 PCM 字节

readPayload 里也是先尝试按标准格式读取:

4 字节 payload 长度
  ↓
payload 内容

如果发现长度不合理,就退回去,把剩余字节都当成 payload。

这样写是为了兼容不同事件帧格式,避免因为某一类帧格式不同就整条链路断掉。

11.7 第六步:判断 payload 是音频还是文本

核心判断是:

if (payload.length > 0 && event != null
        && (event == EVENT_SERVER_AUDIO_ONLY_RESPONSE || serialization == 0)) {
    result.put("audio", Base64.getEncoder().encodeToString(payload));
}

这里的判断逻辑是:

payload 不为空
  ↓
当前帧有 event
  ↓
event 是服务端音频响应,或者 serialization=0
  ↓
认为 payload 是二进制音频

为什么这里要先把音频转成 base64?

因为 parseServerFrame 返回的是 JSONObject,JSON 里不能直接放原始二进制字节。

所以这里临时做成:

PCM bytes -> base64 字符串 -> JSONObject

但是注意:这只是 Java 内部临时表示。

真正发给前端时,MockInterviewVoiceWebSocketHandler 会重新解码:

sendBinary(session, Base64.getDecoder().decode(audio));

也就是最终给浏览器的仍然是 WebSocket 二进制音频,不是 JSON 音频。

11.8 第七步:文本 payload 尝试解析 JSON

如果不是音频,就按 UTF-8 文本处理:

String text = new String(payload, StandardCharsets.UTF_8);
result.put("payload", text);

如果 serialization == 1,说明 payload 很可能是 JSON:

JSONObject json = JSONObject.parseObject(text);
result.put("json", json);
result.put("text", firstString(json, "text", "content", "delta", "transcript"));

这里的 firstString 是为了兼容不同事件字段。

有些事件文本字段叫:

text
content
delta
transcript

所以代码按顺序取第一个非空字段,统一放到:

{
  "text": "..."
}

这样前端展示文本时,就不需要关心火山每个事件的字段名差异。

11.9 最后:补充 eventType,方便上层判断

函数最后会把常见 event 转成更容易读的名字:

if (event != null && event == EVENT_SERVER_FULL_RESPONSE) {
    result.put("eventType", "SERVER_FULL_RESPONSE");
} else if (event != null && event == EVENT_SERVER_AUDIO_ONLY_RESPONSE) {
    result.put("eventType", "SERVER_AUDIO_ONLY_RESPONSE");
}

这样日志和前端调试信息就不会只看到:

event=352

而是能看到:

SERVER_AUDIO_ONLY_RESPONSE

总结一下,parseServerFrame 做的是:

火山二进制帧
  ↓
读取协议头
  ↓
解析 messageType / flags / serialization
  ↓
读取 event / sequence / sessionId
  ↓
读取 payload
  ↓
判断 payload 是文本还是音频
  ↓
统一封装成 JSONObject
  ↓
交给 Handler 转发给前端

它是 Java 后端和火山二进制协议之间的“翻译层”。


12. StartSession 里最关键的配置

当前项目让火山返回 PCM,而不是默认的 OGG/Opus。

StartSession 里的 TTS 配置如下:

{
  "tts": {
    "speaker": "xxx",
    "audio_config": {
      "channel": 1,
      "format": "pcm_s16le",
      "sample_rate": 24000
    }
  }
}

含义是:

参数 含义
channel: 1 单声道
format: pcm_s16le 16bit little-endian PCM
sample_rate: 24000 24k 采样率

为什么不用默认格式?

火山默认可能返回 OGG 封装的 Opus 音频,浏览器要播放这种实时分片并不稳定。

改成 PCM 以后链路更直接:

火山返回 PCM
  ↓
Java 直接转发二进制
  ↓
前端按 Int16 little-endian 解码
  ↓
WebAudio 播放

StartSession 可以按功能拆成三块看:

StartSession 配置拆解

这里很容易混淆:

配置 控制谁
asr.audio_info 控制学生麦克风音频怎么上传给火山
tts.audio_config 控制火山返回给前端的面试官声音格式
dialog.extra.model 控制使用哪个端到端模型版本

按照火山官方文档,StartSession 不是只放一句 prompt,它同时负责声明这次实时对话的输入、输出和模型能力。

我们项目里可以按下面这样理解:

{
  "dialog": {
    "bot_name": "模拟面试官",
    "system_role": "面试官提示词",
    "speaking_style": "自然、专业、简洁",
    "extra": {
      "model": "1.2.1.1",
      "input_mod": "keep_alive"
    }
  },
  "asr": {
    "audio_info": {
      "format": "pcm_s16le",
      "sample_rate": 16000,
      "channel": 1
    }
  },
  "tts": {
    "audio_config": {
      "format": "pcm_s16le",
      "sample_rate": 24000,
      "channel": 1
    }
  }
}

几个字段要重点记:

字段 来自火山文档的含义 我们项目为什么需要
dialog.system_role 给模型的系统角色和行为约束 让 AI 像面试官一样追问
dialog.speaking_style 控制模型说话风格 避免回答太长、太生硬
dialog.extra.model 指定端到端模型版本 不同版本支持的音色、上下文能力不同
dialog.extra.input_mod 输入模式,常见如 keep_alive 麦克风静音时也保持会话,不容易因为没有音频流断掉
asr.audio_info 告诉火山“用户声音是什么格式” 前端上传的是 16k、单声道、16bit PCM
tts.audio_config 告诉火山“AI 声音要返回什么格式” 我们要求返回 24k PCM,前端可以直接 WebAudio 播放

这里还有一个容易写错的点:

asr.audio_info 是输入音频配置。
tts.audio_config 是输出音频配置。

学生麦克风上传给火山时,我们用的是:

pcm_s16le + 16000Hz + 单声道

火山返回 AI 面试官声音时,我们用的是:

pcm_s16le + 24000Hz + 单声道

两边采样率不一样是正常的。

输入 16k 是为了语音识别更轻、更省带宽;输出 24k 是为了 AI 面试官声音听起来更清晰。

火山文档里还提到,服务端默认可能返回 OGG 封装的 Opus 音频。

我们现在主动配置:

{
  "tts": {
    "audio_config": {
      "format": "pcm_s16le",
      "sample_rate": 24000,
      "channel": 1
    }
  }
}

目的就是让火山直接返回 PCM。

这样后端就不需要解码 OGG/Opus,只需要:

收到火山 PCM
  ↓
原样转发二进制给前端
  ↓
前端按 Int16 little-endian 播放

13. 为什么音频不要塞进 JSON

最开始的实现里,火山返回音频后,后端把音频做了 base64:

PCM bytes
  ↓
Base64 字符串
  ↓
JSON
  ↓
前端 JSON.parse
  ↓
atob 解码
  ↓
播放

这个方案理论上可行,但实时音频里不推荐。

原因是:

问题 说明
体积变大 base64 会让数据变大约 1/3
解析成本高 大量 JSON 文本帧频繁解析
延迟更高 编码、解码、字符串处理都会增加延迟
更难排查 音频和文本混在一个 JSON 里

最终采用的是:

文本事件 -> JSON TextMessage
音频事件 -> WebSocket BinaryMessage

这也是实时音频更常见的做法。

后端转发音频时:

sendBinary(session, Base64.getDecoder().decode(audio));

前端收到时:

if (typeof event.data !== 'string') {
  queueAssistantAudioBytes(event.data);
  return;
}

可以把消息分成两条通道:

这样前端判断也很清楚:

event.data 类型 处理方式
string 当成 JSON 事件解析
ArrayBuffer 当成 PCM 音频播放

14. 前端如何播放 AI 面试官声音

前端收到的是火山返回的 PCM:

24k sample_rate
16bit
little-endian
mono

所以播放时要按这个格式解析。

关键点是:

view.getInt16(i * 2, true)

这里第二个参数 true 表示 小端序

如果大小端读错,声音可能会变成噪声,甚至听起来像没有声音。

播放流程是:

ArrayBuffer
  ↓
DataView 按 Int16 little-endian 读取
  ↓
转成 Float32 [-1, 1]
  ↓
写入 AudioBuffer
  ↓
AudioBufferSourceNode 播放

代码逻辑大致如下:

const sampleCount = Math.floor(audioBytes.byteLength / 2);
const audioBuffer = audioContext.createBuffer(1, sampleCount, 24000);
const channelData = audioBuffer.getChannelData(0);
const view = new DataView(audioBytes);

for (let i = 0; i < sampleCount; i += 1) {
  channelData[i] = view.getInt16(i * 2, true) / 32768;
}

const source = audioContext.createBufferSource();
source.buffer = audioBuffer;
source.connect(audioContext.destination);
source.start(startAt);

播放链路图如下:

Binary 音频回放链路

true 代表小端序。因为后端配置的是 pcm_s16le,最后的 le 就是 little-endian,小端。


15. 关键代码清单

15.1 学生端语音页面

YanQue-Student-Web/src/pages/StudentMockInterviewVoicePage.tsx

负责:

能力 说明
打开麦克风 getUserMedia
音频采集 ScriptProcessorNode
音频格式转换 floatToPcm16
手动提交本轮回答 commitCurrentAnswer
接收 WebSocket 消息 ws.onmessage
播放面试官 PCM playPcmAudio

15.2 Java 前端 WebSocket

YanQue-Admin/src/main/java/cn/yanque/studentFront/websocket/MockInterviewVoiceWebSocketHandler.java

负责:

能力 说明
接收前端音频 handleBinaryMessage
接收提交事件 handleTextMessage
转发火山文本 sendJson
转发火山音频 sendBinary
防止并发写冲突 ATTR_SEND_LOCK

15.3 火山实时语音客户端

YanQue-Admin/src/main/java/cn/yanque/studentFront/client/DoubaoRealtimeVoiceClient.java

负责:

能力 说明
连接火山 WebSocket start
发送 StartSession buildStartSessionPayload
发送学生音频 forwardAudio
提交语音段 commitAudio
解析火山帧 parseServerFrame

15.4 配置类

YanQue-Admin/src/main/java/cn/yanque/config/DoubaoRealtimeVoiceProperties.java
YanQue-Admin/src/main/resources/application.yaml

关键配置:

output-audio-format: pcm_s16le
output-audio-sample-rate: 24000

16. 本节课我们按什么顺序完成

实时语音链路比较长,如果一次性把前端录音、Java WebSocket、火山协议、PCM 播放全部写完,出问题时很难判断是哪一段断了。

所以这节课我们按“先打通主链路,再补细节”的方式完成。

先看完整路线:

实时语音实现路线

每完成一步,就确认一次当前链路是否真的通了:

步骤 观察点
启动语音会话 后端能拿到 voiceSessionId
Java 连接火山 能收到 StartConnection 和 StartSession 成功事件
前端连接 Java 后端进入 afterConnectionEstablished
上传麦克风 后端进入 handleBinaryMessage
提交回答 后端收到 AUDIO_COMMIT
火山返回 后端收到 ASR / Chat / TTS 事件
前端播放 浏览器收到 Binary 音频并进入播放队列

学到这里要记住一个工程习惯:

长链路功能不要一口气全写完。
先让一段跑通,再接下一段。

这样排查问题时就很清楚:

如果现象是 优先检查
后端没有 voiceSessionId 启动语音会话接口
前端发不了音频 前端 WebSocket 是否 ready
火山没有响应 Java 到火山的 StartConnection / StartSession
有文字没声音 火山 TTS 配置和前端 PCM 播放
前几个字没声音 前端播放队列、AudioContext 启动时机

17. 最终链路总结

现在模拟面试实时语音的最终方案是:

学生麦克风
  ↓
前端转 16k pcm_s16le
  ↓
WebSocket binary 发给 Java
  ↓
Java 封装火山 audio frame
  ↓
火山 ASR + 对话 + TTS
  ↓
火山返回 24k pcm_s16le
  ↓
Java 解析火山 frame
  ↓
文本 JSON 返回前端
  ↓
音频 BinaryMessage 返回前端
  ↓
前端按 Int16 little-endian 解码
  ↓
WebAudio 播放 AI 面试官声音

一句话概括:

模拟面试实时语音的本质,是用 Java 做一个受业务系统控制的实时语音网关,把浏览器音频和火山 Realtime 模型稳定地接起来。