面向需要理解设备接入、握手、状态机与时序的开发者(固件侧、服务端、或集成方)。
如果你家里摆着一台小音响,喊一声"你好小鸿"它能回你一句"我在",你觉得稀松平常。但在这句对话背后,设备、服务器、AI 三者之间其实有一套相当讲究的"接头暗号"------设备怎么找到服务器、怎么证明自己是谁、怎么把一句话拆成几十个小包裹送出去、又怎么在半路被人打断。
这篇文章不讲高深理论,就是把这套暗号一条条拆开,讲给同样在捣鼓语音设备的人听。代码里那些字段我一个都不落下,但我会尽量用"人话"解释它们为什么存在。
项目开源地址: gitcode.com/qq8864/xiao...
先说个大前提:一条连接,两类消息
设备跟服务器之间,从头到尾只维持一条 WebSocket 连接。这条连接上跑着两种完全不同的东西:
- 文本消息 :长得像
{"type": "listen", ...}这种 JSON,负责"打招呼""说开始""说结束"这类控制动作; - 二进制消息:一坨一坨的音频数据,40 毫秒一帧,听上去很碎,但拼起来就是一句完整的话。
有人会问:为什么不干脆开两条连接,一条发指令一条传音频?因为语音这件事最怕乱序------你总不能"开始播放"还没到,音频帧先到了吧?共用一条连接,服务器内部再走同一个发送队列,就能保证"先下指令、再发声音、最后说结束"这个顺序铁打不乱。
另外还有个细节:设备怎么自报家门?靠 WebSocket 握手时带的一个请求头 Device-Id。没带的话服务器也不翻脸,自动给你编一个 anon-xxxx,只是以后想精确找到这台设备就费劲了。
传输层总览
| 项 | 值 | 说明 |
|---|---|---|
| 协议 | WebSocket(RFC 6455) | 单条长连接承载全部控制与音频 |
| 服务端路径 | /xiaohong/v1(默认) |
可在 config.toml [server] ws_path 修改 |
| 文本帧 | JSON 控制消息 | 握手/状态/控制(hello、listen、tts、abort、display...) |
| 二进制帧 | opus 音频帧 | 每帧一个独立 WS 二进制消息,默认 40ms/帧 |
| 设备标识 | HTTP Header Device-Id / device-id |
缺失时服务端自动生成 anon-<uuid> |
| 连接方向 | 设备主动连 Server | Server 不反向连设备 |
关键约定 :文本帧与二进制帧共用同一条连接、按发送顺序到达, 服务端内部也通过同一个命令队列(cmd_tx)下发,保证 tts/start → 音频帧 → tts/stop 的严格时序。
连接建立与握手(重点)
设备侧发起连接
设备(WS63 固件,mongoose 客户端)通过 mg_ws_connect 连接 ws://<server>:8080/xiaohong/v1,WebSocket 握手时携带:
Device-Id(或device-id)头:设备唯一标识(如 MAC 地址形式1D:61:03:13:21:78);- 可选
Authorization/Protocol-Version等附加头(由固件注入,供服务端扩展鉴权)。
注意:
mg_ws_connect仅支持ws:///wss://,误填http(s)://会导致握手异常甚至崩溃。
见面先握手:你一句我一句,谁也不用等谁
设备连上服务器之后,第一件事是握手。有意思的是,这个握手不是一问一答,而是两边同时开口:
- 设备把 WebSocket 连上,带上自己的
Device-Id; - 服务器这边,连接一建立就立刻丢过来一个
hello; - 设备那边,WebSocket 握手一完成(固件里叫
MG_EV_WS_OPEN),也立刻把自己的hello发出去; - 服务器收到设备的
hello,把设备登记进在线列表。
两边谁都不用等谁------服务器不等设备的 hello 到了才回话,设备也不等服务器的 hello 到了才开口。这跟我们平时打电话不一样:电话要等对方"喂"一声才说话,这里更像两个人同时推门进来、同时说"早"。好处是省了一个来回的延迟,谁先到都行,后到的把该对齐的信息对齐了就完事。
服务器发过来的 hello 长这样:
json
{
"type": "hello",
"version": 1,
"transport": "websocket",
"session_id": "uuid-xxxx",
"audio_params": {
"format": "opus",
"sample_rate": 24000,
"channels": 1,
"frame_duration": 40
}
}
这里面有两样东西值得记住:
session_id:这趟连接的"身份证号",后面每一句消息都得带上它,服务器靠它认人;audio_params:服务器在告诉设备"我这边音频的规矩是 opus 编码、24kHz、单声道、40 毫秒一帧",你按这个来。
设备回过来的 hello 结构差不多,但含义是反过来:告诉服务器"我这边上传音频用什么格式、什么采样率"。服务器拿到之后,会用这些参数去创建解码器和编码器------上行怎么解、下行怎么编,全看这一句。
这里藏着一个挺坑的细节:设备报的格式必须是真的。你要是嘴上说"我传的是 opus",实际塞过来的却是裸 PCM,服务器会真拿 opus 解码器去解,结果就是一片噪声加一堆解码失败。这种问题最难受,因为链路看着是通的,声音就是不对。

让它开口说话:一句话,拆成几百个小包裹
握手完成,设备待命。这时候 AI 想让它说"你好,我是小鸿",服务器会做这么一串事:
先发一条 tts/start,相当于打个招呼:"注意,我要开始说话了,把嘴巴准备好、把播放缓冲清干净。"
json
{
"type": "tts", "state": "start", "session_id": "uuid-xxxx",
"text": "你好,我是小鸿", "index": 0, "audio_codec": "opus"
}
然后,服务器把这句话交给文字转语音(TTS),合成出一长串音频,再切成 40 毫秒一段的 opus 帧,一帧一帧地往下发。这里有个讲究:前 12 帧(差不多半秒)会一股脑先塞过去,让设备肚子里先有点存货,这叫预缓冲------不然网络抖一下,声音就会像老式收音机那样断断续续。
后面剩下的帧就按节奏来:40 毫秒一帧,稳稳地送。全部发完之后,来一句 tts/stop:
json
{ "type": "tts", "state": "stop", "session_id": "uuid-xxxx", "audio_codec": "opus" }
设备收到这句话,会把缓冲里还没播完的尾巴播完,再闭上嘴。这里也藏了个细节:不能一听到 stop 就立刻闭嘴,否则一句话常常只播了前半句就没了。
整个过程画成时间线就是这样:
bash
服务器 设备
│ tts/start ────────────▶ 清空缓冲,准备
│ 音频帧 1~12 ──────────▶ 预缓冲半秒
│ 音频帧 13 ────────────▶ 40ms 后
│ 音频帧 14 ────────────▶ 40ms 后
│ ... ...
│ tts/stop ─────────────▶ 播完尾巴,闭嘴
如果你是写程序的人,可能还会关心一件事:AI 那边调用接口,是不是要傻等这几分钟?不用。服务器早就把它做成异步的了------AI 一喊"开始播报",接口立刻回一句"收到,开始播了",剩下的事在后台慢慢做。不然一个播报接口挂三五分钟,谁受得了。

让它竖起耳朵:监听分三步走
说话是往外送,听话是往里收。设备听话这件事,协议里用 listen 消息管着,分三步。
第一步叫"唤醒",设备听到"你好小鸿",立刻上报一句:
json
{ "type": "listen", "state": "detect", "session_id": "uuid-xxxx", "text": "你好" }
服务器收到这句,如果设备正闲着,就悄悄把它切到"监听中"状态,把识别缓冲清干净,准备收音频。这就是很多人没注意到的妙处:设备唤醒之后,哪怕没有任何 AI 在主动等它说话,服务器也会自己开始收集声音------等你说完,它直接把识别结果广播给所有连着的 AI。相当于设备自带一个"自动接听"功能。
第二步叫"开始"。多数时候这一步是 AI 主动发起的:AI 调 voice_listen,服务器给设备发一句 listen/start:
json
{ "type": "listen", "state": "start", "session_id": "uuid-xxxx", "mode": "auto" }
设备收到,开始往服务器传音频,一帧一帧的 opus,跟播报时反过来。
第三步叫"结束"。设备觉得你说完了(靠静音检测),上报一句 listen/stop。服务器把攒下来的音频一次性交给语音识别,把文字结果回给等在门口的那个 AI。
这里值得说一句:谁在等,结果就给谁 。如果是有 AI 主动在等(voice_listen),识别文字就回给它;如果是设备自己唤醒的,没人在等,那就广播出去,让所有 AI 都看一眼------这正好呼应了上面的"自动接听"。
半路想插话:打断这件事,靠一个计数器
生活中最尴尬的场景来了:你正放着一首歌,AI 突然要说一句"重要通知"。歌不能等它放完,得立刻停。协议里怎么处理?
服务器在开始任何一段新播报之前,会先干一件事:检查设备现在在干嘛。
- 如果设备正闲着:没事,直接开始说;
- 如果设备正在听你说话:把那句等着的话作废,发一个
abort让设备闭嘴; - 如果设备正在播报别的:也发
abort,把旧声音掐掉。
然后才开始新的播报。
但光靠"发一个停止命令"还不够稳------万一旧播报正在发送的音频帧还没发完,服务器怎么知道"我该停了"?这里有个相当聪明的设计,叫播报代际计数 ,代码里叫 play_seq。
你可以把它理解成一个"话筒编号":每次有人要开始说话,这个编号就加一。每一段播报开始的时候,服务器记住"我是第几号话筒"。之后每发一帧音频,都检查一下:现在的话筒编号还是不是我那个?如果不是,说明有新的话筒抢走了------立刻停手,剩下的帧不发了。
这个机制的好处是:新播报打断旧播报,旧的那个任务自己默默退出 ,不会傻乎乎地把 tts/stop 也发出去,把新播报的状态给搅黄了。一个计数器,把"谁能说话、谁该闭嘴"安排得明明白白。
屏幕上的字:设备还有块小屏幕
这台设备不光会说话,脸上还有块小 LCD。服务器能通过一条 display 消息,往屏幕上写字、变表情、甚至调音量:
json
{
"type": "display", "session_id": "uuid-xxxx",
"payload": { "text": "正在处理你的问题...", "status": "工作中", "emoji": "😀", "volume": 60 }
}
payload 里的字段全看心情,可以只传一个:
text:显示在对话区的那行字;status:顶部提示栏的状态文字;emoji:表情,四个字节以内,一个中文都装不下,但一个 emoji 刚好;volume:调音量。注意它不只是画个音量条------设备收到后会真去改扬声器的音量,所以别指望"显示一下"它就完了;clear:要不要先清屏。
这就让 AI 能把"我正在干活"这种进度直接怼到用户眼前,不用用户一直盯着聊天窗口。
设备的"心情":一共就三种状态
说了这么多,其实设备在服务器眼里,永远只有三种状态:
- 空闲(Idle):待命,谁来都能招呼;
- 监听中(Listening):竖着耳朵收声音,等你说完;
- 播报中(Speaking):正在说话或放歌。
所有消息本质上都是在三种状态之间推来推去:说句话,从空闲跳到播报中;听完话,从监听中跳回空闲;你插一句嘴,从播报中又跳回播报中(不过是换了个新的话筒编号)。
理解了这个状态机,很多现象就解释得通了:为什么播放音乐时 AI 能插播?因为新播报把状态从"播报中"抢成"播报中",旧任务凭编号发现自己出局,自动闭嘴。为什么识别超时设备会变回空闲?因为 listen 结束,状态就归位了。

断线了怎么办:重来一次握手
网络这东西,谁也保证不了不断。设备断线之后,服务器这边干净利落:把设备从在线列表里划掉,AI 再喊它,就回一句"没有设备在线"。
设备那边也不含糊,固件里维护着两个小旗子------"连没连上"和"服务器 hello 收到没",一旦断开,按退避策略自动重连。重连成功后,一切从头来过:重新握手、换一个新的 session_id、重新登记。
这里有个好消息:服务器不挑重连。设备随时断开随时连,不需要记住什么旧会话,重新握个手就又是一条好汉。
写在最后
回过头看,这套协议其实没什么玄机,就是几个朴素的道理:控制走文本、声音走二进制,一条连接保证顺序;见面先握手,互不等;说话要拆包、要预缓冲、要按节拍;听话分三步,谁等给谁;打断靠编号,旧人自动退;状态只有三个,推来推去不迷路;断了就重连,谁也不用记旧账。
它不华丽,但很皮实。也正是这套皮实的约定,让一台几十块钱的小设备,能稳稳地跟千里之外的 AI 对上话------你喊它一声,它应你一句,中间隔着的这几十条消息,就是本文说的这些"接头暗号"。
如果你也在做类似的语音硬件,希望这篇能帮你少踩几个坑。踩坑清单(格式要如实上报、别一听 stop 就闭嘴、音量别只画条不真调、断了记得重连)已经帮你排好队了,拿走不谢。