ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

MQTT已连接但语音不通?音频传输的协议选型与实践

MQTT已连接但语音不通?音频传输的协议选型与实践 1. 先搞清楚MQTT连接正常为什么小智还是“哑巴”1.1 一个真实的故障现场前两周在调一个叫“小智”的智能语音助手项目设备端用的是ESP32-S3加麦克风阵列服务端部署了ASR语音识别和TTS语音合成。整个链路初版设计很简单——MQTT负责设备连接和状态上报音频流走WebSocket。看起来挺清晰的分工然而真正联调的时候我盯着日志看了整整两天。日志里明明白白写着MQTT Connected而且通过mosquitto_sub订阅设备状态topic能看到小智每隔几秒上报一次心跳唤醒事件也能正常发到服务端。但问题在于用户对小智说完话之后它一个字都回不出来。更诡异的是唤醒词检测是正常的设备上的LED灯也亮了说明本地唤醒逻辑在跑可只要一进入“服务端识别回复”的环节就断掉。当时我一度怀疑是麦克风坏了又怀疑是TTS服务出了问题。排查到最后才发现根源竟然是我在协议选择上犯了一个非常基础的错误——把MQTT连接正常当成了整条语音链路可用却忽略了音频数据根本没有被送出去。小智确实“在线”但它从头到尾没有一条能用来传声音的通道。1.2 控制面与数据面两个完全不同的世界这个案例暴露出来的问题其实在物联网语音设备里非常典型。很多人一上来就想让MQTT包打天下觉得既然设备已经和服务端建立了连接那不管是控制指令还是音频内容统统通过这个连接传不就行了不行。这里必须建立一个基础认知任何语音交互系统从架构上都可以拆成两个层面。第一个层面叫控制面传的是小数据、低速率的控制信息比如设备上线通知、唤醒事件、音量调整指令、播放状态同步。这类消息的特点是频率低、单条体积小、丢失后影响有限重试代价低。MQTT天生就是干这个的它简单、轻量、有完整的发布订阅模型3条指令就能在设备端把连接建起来。第二个层面叫数据面传的是连续、高速、时延敏感的媒体数据。语音采集后得到的PCM音频帧、服务端返回的TTS合成音频都属于这一类。音频数据的特点是持续产生、流量大、对实时性要求极高——人耳对超过200毫秒的延迟就开始有明显不适感超过500毫秒基本没法正常对话。小智的问题恰恰在于控制面和数据面被混为一谈了。设备端MQTT一连接上我就以为“万事俱备”结果控制指令能发出去音频数据却在数据面这个环节上静默丢失。服务端等不到音频ASR没法启动自然也就没有回复。所以从结果上看小智“看起来在线”实际上是一个没有听觉的哑巴。这个案例给我的教训是在动手写代码之前先把设备的通信链路画出来明确哪些消息走控制面、哪些数据走数据面再针对不同的面去选协议。控制面选MQTT没毛病但数据面必须另寻出路。2. 音频为什么不走MQTT协议设计早就划好了边界2.1 TCP可靠传输恰恰是音频的致命伤MQTT底层基于TCPTCP最大的特点就是可靠传输——数据包丢失了会重传数据顺序乱了会重组。对于控制指令来说这是优点丢了一条指令重发就是多等几十毫秒也无所谓。但对于实时音频来说TCP的可靠机制反而成了最大的问题。音频流讲究的是“当下这一刻的数据就要当下立刻送达”。如果网络出现丢包TCP会选择重传丢失的那一段这会导致后续数据全部在缓冲区里排队等重传数据到达后才继续往上送。这个现象叫队头阻塞。音频播放端拿到的是一段缺失后又被补上、整体延迟严重超标的“矩形波”到了人耳里就是一顿一顿的卡顿甚至直接超时被丢弃。我实测过一组数据在轻度丢包环境下大概1%的丢包率用MQTT传输音频数据端到端延迟能从正常的150毫秒飙升到800甚至1000毫秒以上。在智能音箱这种对实时交互要求很高的场景里这种抖动是不可接受的。而UDP或者基于UDP之上的应用层协议丢包后选择跳过这一段播放端用静音补偿一下听感反而稳定得多。2.2 发布/订阅模型与流式数据的错位MQTT采用的是发布/订阅模型一个消息发布到某个topic所有订阅了这个topic的客户端都能收到。这个模型非常适合一对多的控制消息分发一个指令发下去几十个设备同时响应。但音频是典型的点对点流式数据。一台小智只有一个唯一对应的语音服务端音频数据不需要广播只需要从设备端定向传到服务端再把服务端合成的音频定向传回设备端。如果用MQTT来做要么给每台设备单独创建topic比如sound/device_001/audio要么多个设备共享一个topic再靠设备ID区分。前者会让topic数量爆炸后者则会在Broker里产生大量无效转发。更重要的是MQTT消息之间是独立的、离散的一个topic上的多条消息服务端可以从任意顺序消费也可以随意丢弃某一条——这个特性对控制消息完全没问题但音频流是有时间戳、有顺序、有上下文关联的一旦消息被乱序消费或者中间漏掉一帧解码出来的声音就是破碎的。2.3 算一笔账一条MQTT消息到底能承载多少音频抛开协议机制不谈我们从数据量角度算一笔账结果更加直观。假设小智用的是16kHz采样率、16bit位深、单声道的PCM裸音频。一秒钟的音频数据量是16kHz × 2字节 × 1秒 32000字节也就是约31.25KB。而一条MQTT消息在实际项目中为了不阻塞Broker、不触发设备端内存溢出通常会把单条消息控制在1KB以内。就算放宽到4KB一条消息最多也只能容纳128毫秒的音频。一次正常的语音交互用户说3秒指令结束再播3秒TTS回复总共6秒音频按4KB一条算需要发送大约48条MQTT消息。这还没算上MQTT消息头、topic字节数、TCP/IP协议栈开销。更麻烦的是音频数据是一边采集一边发的如果每128毫秒就要发一条消息Broker要承受每秒约8次的入站和8次出站消息转发。一旦设备数量从1台变成50台Broker很快就成了瓶颈。所以无论从单条消息容量还是从消息吞吐量角度MQTT都不适合承载音频。如果用上Opus这类有损压缩编码32kbps码率下每秒只要4KB数据一条4KB的MQTT消息刚好够1秒音频。听起来似乎可行但别忘了MQTT的QoS机制和TCP重传带来的延迟抖动还在音频的实时性问题并不会因为压缩就消失。2.4 QoS机制在音频场景下的反作用MQTT提供了三个QoS等级0最多一次、1至少一次、2恰好一次。在设计者的预期里QoS 1和QoS 2是为了保证控制消息不丢而设计的它们会在Broker和客户端之间做确认重发。音频场景下如果用QoS 1每一条音频消息发布后设备端都要等Broker的PUBACK确认。在弱网环境下一条消息可能要等几百毫秒才能收到确认这期间设备端不能清掉发送缓冲区否则重发时数据就没了。结果就是发送窗口被占满后面的音频数据只能排队实时性直接崩塌。如果把QoS降为0虽然没有了确认等待但TCP的丢失重传机制依然存在底层队头阻塞问题仍然无解。所以结论很清楚MQTT的QoS设计目标和音频传输的目标是相悖的。它要的是“每条消息都可靠送达”而音频要的是“整条流尽可能低延迟”。强行把音频塞进MQTT就像用货车去送外卖虽然东西能到但到了之后全凉了。3. 音频通道的正确打开方式主流协议对比与分析3.1 WebSocket实时语音交互的第一选择小智最终采用的是WebSocket作为音频通道。为什么是它核心原因是WebSocket能提供一条全双工的、基于TCP的长连接而且不需要像RTSP那样建立复杂的会话控制流程也不像WebRTC那样需要STUN/TURN和ICE协商。WebSocket对设备端非常友好ESP32生态里有成熟的WebSocket客户端库与服务端建立连接只需要一次HTTP握手。握手成功后设备可以随时向服务端发送二进制音频帧服务端也可以随时把TTS音频帧推回设备。整条通道是持久化的避免了频繁建连的开销。延迟方面WebSocket基于TCP理论上会有队头阻塞问题但它的协议头开销很低客户端到服务端大约2到14字节而且如果做好了音频压缩比如Opus单帧数据量很小TCP的队头阻塞影响会被明显弱化。实测下来设备端到服务端语音识别服务的端到端延迟稳定在150到300毫秒之间完全符合智能音箱的交互要求。选WebSocket还有一个隐性优势调试方便。浏览器里用WebSocket客户端直接连服务端就能测试不需要安装任何流媒体工具。我在调试小智的时候就经常用websocat这个命令行工具直接连到服务端往里面扔音频帧看服务端能不能返回TTS结果整个过程非常顺畅。3.2 RTSP/RTP标准流媒体体系的成熟方案RTSP实时流传输协议配合RTP实时传输协议是传统流媒体领域的事实标准常见于网络摄像头、IP对讲设备。RTP本身构建在UDP之上天然规避了TCP的队头阻塞问题而且RTP头里带时间戳、序列号、负载类型播放端可以做精准的抖动消除和丢包隐藏。那为什么小智没有选RTSP因为这套方案对小型的交互式语音场景来说太“重”了。RTSP定义了完整的会话建立、播放、暂停、拆除流程需要维护状态机服务端要额外处理RTSP会话管理。而且RTSP通常是一个请求一个会话适合“你看你的视频流、我听我的音频流”这种单向流媒体消费而不适合“设备说一句、服务端回一句”这种高频双向实时对话。但是如果你的设备是嵌入式Linux平台并且需要同时处理视频流和音频流需要向多个客户端分发媒体那RTSP依然是成熟靠谱的选择。小智只做语音交互只需要一上一下两条音频流用RTSP反而杀鸡用牛刀。3.3 HTTP-FLV与HLS公网与弱网场景的变通如果把场景搬到公网环境尤其是设备和服务端之间有NAT、防火墙等限制时基于UDP的RTP和WebRTC往往会因为端口不可达而变得棘手。这时候HTTP-FLV和HLS这些“HTTP里套流媒体”的方案就有用武之地了。HTTP-FLV是直播界的老面孔它的核心思想是把FLV封装格式的媒体数据分块通过HTTP传输。优点是兼容性好服务端只需要挂一个HTTP服务设备端用HTTP请求就能持续拉流。缺点是只能单向设备要上送音频还是得走WebSocket或者其他通道。HLS则把媒体切成一个个小文件播放端按顺序去拉取。HLS的延迟通常在3到10秒做实时语音对话基本不可用但在播放TTS合成音频这种“不需要实时交互、只需要稳定播放”的场景里反而是好选择。比如小智如果要播放一首较长的背景音乐或播报预先合成好的长文本用HLS回源拉取可以省去维护长连接的开销。3.4 WebRTC低延迟通话的进阶路线如果要追求极致的低延迟和最好的弱网表现绕不开WebRTC。WebRTC基于UDP内部实现了完整的丢包重传RTX、前向纠错FEC、抖动缓冲Jitter Buffer和拥塞控制端到端延迟可以做到50毫秒以内。但WebRTC是一套庞大且复杂的体系。设备端需要做ICE候选收集、STUN打洞、DTLS加密协商、SRTP媒体传输光是信令握手这一套流程就比WebSocket复杂一个量级。ESP32这类MCU上要跑WebRTC就算有开源的libwebrtc移植版对芯片的算力和内存要求也非常高实际项目里很少见到直接用WebRTC的MCU语音方案。我的看法是如果你的设备是AOSP平板、Linux开发板这种“性能有余”的平台而且需要做双向视频通话WebRTC绝对值得投入但如果你做的只是小智这种带唤醒词、语音指令、TTS播报的音箱设备WebSocket加Opus编码就已经能覆盖90%的需求没必要为了极致的延迟牺牲掉开发效率。4. 完整排查实录从“已连接”到“能说话”的四个关键步骤4.1 第一步确认控制面状态别被“已连接”误导回头看小智的故障我的第一步排查其实就走偏了。当时我看到MQTT连接正常就往ASR服务、TTS服务那边找问题折腾半天没头绪。后来冷静下来先把“控制面”和“数据面”分离验证才找到了方向。具体做法是这样我先用mosquitto_sub -t xiaozhi/status订阅设备状态主题确认设备每5秒上报一次心跳。然后我在服务端写了一个临时的MQTT消费者监听xiaozhi/wakeup主题对着小智说了一句“小智小智”果然收到了一条包含{event:wakeup,device_id:001}的JSON消息。这说明从设备到服务端的控制链路是完全通的。但服务端的ASR接口却始终收不到任何音频。这时候我才把怀疑对象锁定到音频通道。这里要提醒大家MQTT连接状态只代表控制面的联通性绝不代表数据面的可用性。排查语音设备故障第一步永远是先分面验证——黑盒测试控制信号是否到达再测数据面不要上来就猜服务端有问题。4.2 第二步验证音频通道是否真实建立确定了问题在数据面之后我开始验证WebSocket连接状态。小智设备端和服务端的音频通道是在设备连接Wi-Fi后主动发起WebSocket握手建立的。但我在设备日志里搜WebSocket相关输出发现建立连接的代码根本没有打印成功日志说明握手阶段就失败了。我又在服务端抓包用tcpdump -i any port 8080抓取WebSocket端口的数据包结果发现服务端根本没有收到TCP SYN包。换句话说设备端压根没有发起连接请求。我又回去翻设备端日志发现代码里有一处逻辑错误——WebSocket连接被放在了一个条件判断里只有在收到MQTT下行指令start_voice_session之后才触发。而我的服务端代码压根没有发过这个指令因为它一直在等音频数据到来后才发指令形成了一个循环等待的死结。正确的做法是设备端应该在开机后无条件建立音频通道或者至少在收到唤醒事件后立刻建立。我修改了逻辑把WebSocket连接提前到设备初始化阶段和MQTT连接并行建立。这样唤醒事件发生时音频通道已经是现成的数据面不再依赖控制面的触发。4.3 第三步检查编码格式与音频参数匹配音频通道建立之后小智依然没有完全恢复不过这次症状变成了服务端能收到音频数据但ASR识别出来全是乱码。排查下来是编码格式不匹配。设备端默认采集出来的音频是16kHz、16bit、单声道的PCM数据但我在服务端配置的ASR接口期望输入是Opus编码格式。服务端一收到PCM报文就按Opus去解码自然解出一堆噪音。解决方法是二选一要么在服务端修改识别服务配置把输入格式改成PCM要么在设备端加一个编码器把PCM转成Opus再发送。我最终选择了后者因为Opus在相同码率下音质明显优于PCM而且传输体积小能降低网络压力。ESP32上移植了一个轻量级的Opus编码器库把20毫秒一帧的PCM数据编码成Opus帧再塞进WebSocket二进制消息里发出去。这里有一个关键参数要注意音频采样率、位深、声道数、帧长这四个参数设备端和服务端必须完全对齐。任何一个对不上轻则音质下降重则解码失败。我在设备端加了一个音频参数配置topic通过MQTT下发给服务端服务端收到后动态调整ASR的解码参数从根上避免了参数不一致的问题。4.4 第四步端到端联调与回归验证音频通道建立好、编码格式对齐之后我又做了一轮端到端联调验证的不只是“能不能出声”还有“延迟是不是可控”。联调的方式是打开设备喊一句唤醒词然后正常说一条指令从麦克风采集到服务端ASR返回文本再到TTS合成音频播放出来我记录了整个过程的耗时。当前链路各环节耗时分布大概是设备端采集编码约30毫秒WebSocket上行传输约50毫秒ASR识别约200毫秒TTS合成约150毫秒音频下行传输加解码播放约80毫秒总计约510毫秒。这个延迟在可接受范围内但听起来还是有一点“迟钝感”。我又做了两处优化一是在设备端启用本地VAD人声检测检测到人声结束后立刻发送一个音频结束标志服务端收到后马上开始ASR识别不用等固定超时二是将TTS结果分帧下发设备端可以边收边播不必等整段音频完全收到才播放。这两处优化把整体延迟压到了350毫秒以内实际交互已经很接近商用智能音箱的体感了。5. 协议选型的经验总结不是所有场景都需要WebSocket5.1 我按场景整理的选型建议经过这次小智的实战我对音频协议选型形成了一套自己的判断框架。每次接到类似的语音设备需求我都会先问三个问题设备平台是什么网络环境是局域网还是公网交互模式是实时对话还是单向播放如果是MCU平台ESP32、STM32网络环境是家用Wi-Fi交互模式是双向实时对话WebSocket就是最稳妥的选择。它开发成本低、库支持完善、调试工具多延迟也够用。如果设备平台是嵌入式Linux需要同时处理音视频多路流并分发到多个终端RTSP/RTP这套标准流媒体协议更合适。如果设备只做单向播放比如定时的语音播报、远程安防喊话HTTP-FLV或者直接播放预生成的音频文件就够了没必要维护复杂的双向通道。WebRTC更适合对延迟要求极高、且平台性能足够的场景。比如车机上的语音助手、远程视频通话设备、会议系统这些场景交互密集、网络环境复杂值得投入研发成本去适配WebRTC。但它对MCU不友好这一点必须提前想清楚。5.2 一张表看懂音频通道选型边界我把常见的几种方案整理成了一张表方便大家按需查阅。音频通道方案典型延迟双向/单向开发复杂度适用平台适用场景WebSocket Opus/PCM150-300ms双向低MCU、Linux、浏览器智能音箱、语音助手、对讲设备RTSP/RTP200-1000ms双向/单向中Linux、嵌入式设备网络摄像头、音视频监控、广播HTTP-FLV1-3s单向低Linux、MCU直播、单向音视频流分发HLS3-10s单向低Linux、MCU点播、TTS长文本播放WebRTC50-200ms双向高Linux、Android、iOS实时通话、车机语音、低延迟互动这张表不是标准答案但能帮你快速建立选型方向。实际的协议选择还会受硬件编解码能力、服务端架构、团队技术水平等多方面因素影响。我也见过一些量产产品用自定义TCP私有协议做音频传输效果同样稳定但前提是团队有足够的网络底层能力去处理粘包拆包和重传策略。说到底协议是服务于业务场景的。MQTT负责让设备“连得上”音频通道负责让设备“说得出话”两者各司其职系统才会健康。这次排障给我最大的收获其实是逼着自己把通信模型想清楚了。做智能语音设备第一件事不是写代码而是先把控制面、数据面分明白然后把每一路数据流用合适的协议去承载。一旦把这条路走通后面不管是接ASR还是换TTS都只是细节问题。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进