????
Site Owner
发布于 2026-03-15
# 移动端AI语音对话炸了九次才修好,我学到了什么 我做了一个 AI 语音对话功能。PC 上跑得好好的,一到手机端就崩。WebSocket 报 1006,音频播不出来,连接莫名其妙断开。
移动端AI语音对话炸了九次才修好,我学到了什么
我做了一个 AI 语音对话功能。PC 上跑得好好的,一到手机端就崩。WebSocket 报 1006,音频播不出来,连接莫名其妙断开。
接下来是九轮排查的完整记录。每一轮我都以为"这次肯定修好了",然后被现实打脸。
背景
项目是一个 Web 端的 AI 语音聊天应用。用户按住说话,前端录音通过 WebSocket 发给后端,后端转发给豆包(字节的语音大模型),豆包返回 ASR 文本和 TTS 音频,后端再转发回前端播放。
技术栈:前端 React + TypeScript,后端 Spring Boot + WebSocket,语音引擎是豆包 Realtime API。
线上环境走 nginx 反向代理。
第一轮:ArrayBuffer 长度不对齐
手机 Chrome 一开口说话就报错:
ArrayBuffer length minus the byteOffset is not a multiple of the element size
new Int16Array(arrayBuffer) 要求 buffer 字节数是 2 的倍数。PC 端 WebSocket 收到的数据恰好是偶数长度,手机端不一定——移动端的 WebSocket 帧拆分策略跟 PC 不同,可能收到奇数长度的 chunk。
修复很直接,加个对齐保护:
const alignedLen = arrayBuffer.byteLength & ~1;
if (alignedLen === 0) return;
const pcmData = new Int16Array(arrayBuffer, 0, alignedLen / 2);
三个地方都加上了。问题解决,但手机端还有别的错。
第二轮:二进制音频被当文本转发
翻后端日志,发现大量 event: 550 的消息。550 是豆包的 TTS 音频响应。
问题出在 DoubaoRealtimeService 里,只对 event 150(会话启动确认)做了特殊处理,550 走进了 else 分支——二进制音频数据被 new String(payload, UTF_8) 强转成文本,塞进 JSON 发给前端。前端收到一坨乱码。
改成 BinaryMessage 直接转发:
} else if (message.event == 550) {
if (message.payload != null && userSession.isOpen()) {
userSession.sendMessage(new BinaryMessage(message.payload));
}
}
PC 端验证通过。手机端还是报 PARSE_ERROR。
第三轮:畸形 JSON 帧
else 分支里其他事件的 payload 也有问题。有些豆包事件的 payload 不是合法 JSON,直接拼进字符串会产生畸形文本帧,移动端浏览器对此零容忍。
加了 JSON 校验:
try {
objectMapper.readTree(text);
String userMessage = "{\"event\":\"text\",\"data\":" + text + "}";
userSession.sendMessage(new TextMessage(userMessage));
} catch (Exception jsonErr) {
log.warn("event={} payload is not valid JSON, skipping", message.event);
}
PARSE_ERROR 消失了。手机端还是 1006 断开。
第四轮:WebSocket 消息大小限制
Spring WebSocket 默认二进制消息上限 8KB。前端发送 4096 samples × 2 bytes = 8192 bytes,刚好踩在边界上。
在 WebSocketConfig 里放宽限制:
registration
.setMessageSizeLimit(64 * 1024)
.setSendBufferSizeLimit(512 * 1024)
.setSendTimeLimit(20 * 1000);
实测移动端采样率确实是 16000Hz,没有超限。但留着这个配置作为安全余量。手机端问题依旧。
第五轮:WebSocket 关闭竞争条件
后端日志出现 CloseStatus[code=1002, reason=null]。豆包连接关闭时调用 userSession.close(),可能跟前端主动断开撞车。
加了 isOpen() 检查,异常降级为 debug 日志。这一轮属于防御性修复,没解决根本问题。
第六轮:播放队列阻塞主线程
原来的 playAudioQueue 用 await source.onended 等每段音频播完,每段阻塞主线程约 300ms。
改成基于 AudioContext.currentTime 的非阻塞调度:
let scheduleTime = Math.max(ctx.currentTime, playScheduleEndRef.current);
while (audioQueueRef.current.length > 0) {
source.start(scheduleTime);
scheduleTime += duration;
}
playScheduleEndRef.current = scheduleTime;
所有音频段一次性排进时间线,主线程立即释放。PC 端完美。手机端还是 1006。
第七轮:ScriptProcessorNode 拖垮主线程
到这里我开始怀疑是主线程负载的问题。
ScriptProcessorNode 的 onaudioprocess 回调在主线程上高频执行,每 ~256ms 触发一次。同时主线程还要处理 WebSocket 消息、JSON 解析、AudioBuffer 创建、React 渲染……PC 端 CPU 扛得住,手机端扛不住。主线程一卡,WebSocket 的 ping/pong 就超时,1006。
把录音迁移到 AudioWorkletNode:
class PCMRecorderProcessor extends AudioWorkletProcessor {
process(inputs) {
// 在独立音频线程完成 Float32 → Int16 转换
// 通过 port.postMessage 发回主线程
}
}
AudioWorklet 跑在独立的音频渲染线程,不占主线程。用 Blob URL 内联注入,不需要额外文件请求。不支持的浏览器自动降级回 ScriptProcessor。
PC 端完全正常。手机端……还是 1006。我开始怀疑人生。
第八轮:nginx 对二进制帧的处理
仔细看断开时机:所有文本帧(ASR、session_started)都正常,第一个 BinaryMessage(音频数据)发出去连接就断。
线上走 nginx 反向代理。nginx 对 WebSocket 二进制帧的处理可能有问题——proxy_buffering、proxy_buffer_size 默认值偏小,大的二进制帧可能被截断。
绕过方案:后端把音频数据 Base64 编码后用 TextMessage 发送。
String base64Audio = Base64.getEncoder().encodeToString(message.payload);
String msg = "{\"event\":\"audio\",\"data\":{\"audio\":\"" + base64Audio + "\"}}";
userSession.sendMessage(new TextMessage(msg));
带宽多了 33%,但保证了 nginx 兼容性。前端已有 Base64 解码逻辑,不用改。
手机端还是断。但这次我注意到一个细节——服务端到客户端已经全是文本帧了,客户端到服务端呢?
第九轮:binary/text frame 混用,移动端不买账
录音数据还在用 ws.send(int16Data.buffer) 发 binary frame,控制消息用 ws.send(JSON.stringify(...)) 发 text frame。同一个 WebSocket 连接上交替出现 binary 和 text frame。
PC 端 Chrome 对此很宽容。移动端不行。移动端浏览器(Chrome、Safari、微信内置浏览器)对帧类型混用的处理很严格,会判定为协议违规,触发 1002,前端表现为 1006。
这才是根本原因。
录音数据也改成 Base64 文本帧:
const base64 = arrayBufferToBase64(e.data);
wsRef.current.send(JSON.stringify({ event: 'audio', data: base64 }));
后端对应从 handleBinaryMessage 改到 handleTextMessage 里处理。
全链路无 binary frame。移动端终于正常了。
最终数据流
麦克风 → AudioWorklet → Float32→Int16→Base64 → TextMessage → 后端
后端 → Base64 decode → PCM → 豆包协议 → 豆包
豆包 → 协议解析 → PCM → Base64 → TextMessage → 前端
前端 → Base64 decode → Int16 → Float32 → AudioContext → 扬声器
干干净净,全是文本帧。
回头看
九轮修下来,真正的 root cause 只有一个:移动端浏览器不允许同一个 WebSocket 连接上混用 binary 和 text frame。前面八轮修的都是真实存在的问题,但都不是导致移动端 1006 的根本原因。
几个带走的教训:
同一个 WebSocket 连接上,帧类型要统一。要么全 text,要么全 binary,别混着来。移动端浏览器对此的容忍度远低于 PC。
Base64 多吃 33% 带宽,但语音场景数据量不大,换来跨平台兼容性是划算的。
AudioWorklet 是移动端音频处理的正确选择。ScriptProcessorNode 在主线程跑,手机 CPU 吃不消。
后端转发第三方数据时一定要做格式校验。豆包返回的不一定都是合法 JSON,直接拼进字符串会炸。
排查移动端问题,先确认 PC 和手机走的是不是同一条链路。本地开发、线上环境、有没有代理,差一层就可能差一个世界。
最让我感慨的是,PC 端从头到尾都是正常的。每一轮修完我都觉得"这次肯定行了",然后手机端继续打我的脸。
更深一层的东西
这次排查改变了我对移动端开发的认知。之前我一直把手机浏览器当成"屏幕小一点的 PC 浏览器",九轮下来发现完全不是这么回事。
移动端不是缩小的 PC。CPU 架构不同,网络栈实现不同,浏览器对 Web 标准的解读也不同。同样一段 WebSocket 代码,PC 端 Chrome 和移动端 Chrome 的行为可以完全不一样。binary/text frame 混用这件事,W3C 规范里没说不行,但移动端实现就是不接受。协议规范的灰色地带,PC 端可能放你过去,移动端直接把门关上。
还有一个容易忽略的点:性能问题的表现形式。ScriptProcessorNode 阻塞主线程,直觉上应该表现为音频卡顿或者 UI 掉帧,但实际表现是 WebSocket 1006 断开。音频处理拖慢了主线程,主线程来不及响应 WebSocket 的 ping/pong,连接就超时了。如果只盯着网络层排查,永远找不到原因。这种问题需要系统思维——一个模块的瓶颈可能在完全不相关的地方爆发。
最后是策略选择。PC 端可以用渐进增强——先实现基础功能,再逐步加上高级特性。移动端往往需要反过来,优雅降级,而且要比你预期的更激进。AudioWorklet 降级到 ScriptProcessor 是一个例子,binary frame 全部换成 Base64 text frame 是另一个。33% 的带宽开销换跨平台兼容性,在语音场景下完全值得。
手机不是小电脑。这句话我以前听过很多次,这次算是真正理解了。