模拟面试实时语音流程

模拟面试实时语音流程
AI Agent 工程实践教程 · 第 13 章
梳理实时语音面试中的录音、转写、LLM 追问、TTS 播放和状态同步。
第十三天:模拟面试实时语音流程
前面我们已经完成了学生端 AI 问答、知识库问答、Text-to-SQL、面试复盘等功能。
今天要讲一个更接近真实产品体验的 AI 功能:模拟面试实时语音对话。
它和“面试复盘”不一样。
面试复盘是学生上传真实面试录音,系统异步转写和分析。模拟面试是学生在平台里直接和 AI 面试官实时对话:
学生说话
↓
浏览器采集麦克风音频
↓
Java 后端转发给火山实时语音模型
↓
火山识别学生语音并生成面试官追问
↓
Java 后端把文本和音频返回前端
↓
前端播放 AI 面试官声音
这个功能最核心的难点不是“调一次大模型接口”,而是要把 麦克风采集、WebSocket、ASR、对话模型、TTS、PCM 播放 串成一条稳定的实时链路。
1. 今天学完要会什么
学完这一节,应该能回答下面几个问题:
- 模拟面试和面试复盘的区别是什么?
- 为什么实时语音对话要用 WebSocket?
- 学生端是怎样采集麦克风音频的?
- Java 后端在实时语音链路里负责什么?
- 火山 StartSession 里为什么要配置 TTS 的
audio_config? - 为什么音频最好用 WebSocket 二进制帧返回前端?
- 前端拿到 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 接入、音频上传、手动提交本轮回答、火山返回音频都放在一起,完整时序会更像下面这样:
这张图可以按五段看:
- 前端先用 HTTP 请求 Java,开始本场模拟面试语音。
- Java 生成
voiceSessionId,再去连接火山 Realtime。 - Java 等火山
StartConnection和StartSession都成功后,把voiceSessionId返回给前端。 - 前端再带着
voiceSessionId建立浏览器到 Java 的 WebSocket,Java 注册 listener。 - 后续学生音频、点击“说完了”、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 的职责图:
这也是这节课需要学生重点看懂的后端入口类。
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 输出格式。
当前启动过程不要把 StartConnection 和 StartSession 随便连着发,而是应该等服务端确认。这个确认过程已经放在上面的启动时序图里:先等 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 id 和 session 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 可以按功能拆成三块看:
这里很容易混淆:
| 配置 | 控制谁 |
|---|---|
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);
播放链路图如下:
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 模型稳定地接起来。







