返回列表
技术
30 分钟

????

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 日志。这一轮属于防御性修复,没解决根本问题。

第六轮:播放队列阻塞主线程

原来的 playAudioQueueawait 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 拖垮主线程

到这里我开始怀疑是主线程负载的问题。

ScriptProcessorNodeonaudioprocess 回调在主线程上高频执行,每 ~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_bufferingproxy_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% 的带宽开销换跨平台兼容性,在语音场景下完全值得。

手机不是小电脑。这句话我以前听过很多次,这次算是真正理解了。

#项目实战#工作效率