小鸿AI WS63 ↔ MCP Server WebSocket 通信协议详解

面向需要理解设备接入、握手、状态机与时序的开发者(固件侧、服务端、或集成方)。

如果你家里摆着一台小音响,喊一声"你好小鸿"它能回你一句"我在",你觉得稀松平常。但在这句对话背后,设备、服务器、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):// 会导致握手异常甚至崩溃。

见面先握手:你一句我一句,谁也不用等谁

设备连上服务器之后,第一件事是握手。有意思的是,这个握手不是一问一答,而是两边同时开口:

  1. 设备把 WebSocket 连上,带上自己的 Device-Id
  2. 服务器这边,连接一建立就立刻丢过来一个 hello
  3. 设备那边,WebSocket 握手一完成(固件里叫 MG_EV_WS_OPEN),也立刻把自己的 hello 发出去;
  4. 服务器收到设备的 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 就闭嘴、音量别只画条不真调、断了记得重连)已经帮你排好队了,拿走不谢。

相关推荐
小虎AI生活2 小时前
接口型数字人平台技术选型:打通视频自动化最后一公里的架构设计
ai编程
gyratesky4 小时前
我用agent做了个运动打卡数据治理流程
ai编程·vibecoding
颜进强4 小时前
前端看后端 15:什么是 DNS?
前端·后端·ai编程
echoVic4 小时前
会话选择器不是列表:Orca 如何守住切换边界
agent·ai编程
echoVic4 小时前
Agent 架构里最容易混淆的四种责任
agent·ai编程
hhb_6185 小时前
AI编程协同架构:智能驱动开发新时代
架构·ai编程
程序员老刘7 小时前
Qwen 3.8 max干了20分钟没干完,免费模型3分17秒搞定,问题出在哪?
flutter·ai编程
2601_955760077 小时前
Claude API 多人协作中的版本管理方法
java·ai编程
Bigger7 小时前
Han:一个让 AI Agent 也能做出高级中国风页面的 CSS 设计系统
前端·ai编程·设计