UDP 音频通道协议
约 3055 字大约 10 分钟
2026-03-08
包格式由本页定义;/ai与signaling/message会话的控制字段、序号策略和回复边界不能混用。统一信令的128步长、每回复nonce及首末包屏障见AI音频控制。
本文档描述联犀 AI 交互协议中,实时语音对话所使用的 UDP 音频传输通道的完整规范,包括通道建立、数据包格式、AES-CTR 加密方案和音频编码要求。
流式 ASR + LLM + TTS 与 Realtime 端到端语音模型并存;串联/实时描述云端处理链路,halfDuplex/fullDuplex 描述设备交互策略,Realtime 也可选择半双工。二者使用相同的 16 字节包头 + AES-CTR 密文,没有为全双工新增 UDP 字段。半双工按输入/播放策略轮换;全双工在回复播放期间也可继续上行,并允许插话。/ai 入口的交互模式由 MQTT sessionCreated.data.interactionMode 决定,详见 AI 交互协议;signaling/message 则读取 sessionCreated.data.selected.interactionMode。
| 会话模式 | 建议设备音频参数 | 上行行为 |
|---|---|---|
流式 ASR + LLM + TTS / halfDuplex | Opus、24 kHz、单声道、60 ms | 按轮次收音,可通过 audioStop 结束本轮 |
Realtime 端到端语音模型 / halfDuplex | 以本次选定参数为准 | 仍按半双工策略管理输入与播放,不能因实时模型而假定可同时收发 |
Realtime 端到端语音模型 / fullDuplex | Opus、16 kHz、单声道、20 ms | 会话活跃且未暂停时持续上行,播放期间也不停止 |
16kHz/20ms 与24kHz/60ms 参数组合已有软件模拟链路验证;/ai 最终以 sessionCreated.data.audioParams 为准,signaling/message 以 sessionCreated.data.selected.audioParams 为准,不能据此推断实际硬件 AEC 效果。
入口与阅读顺序
首次接入先看 MQTT+UDP 完整教程。本页第2节控制创建示例、第5节普通递增规则及 sessionClose/sessionClosed 名称限定 /ai;signaling/message 使用顶层 sessionId、data.media.udp、data.selected、sessionEnd/sessionEnded,实际探测后再等待 sessionEstablished。回复隔离、128序号步长和输入屏障按 AI 音频控制,不复用 /ai 的控制策略。以下共同包头与加解密算法可复用;不增加新的 UDP 字段。
1. 通道概述
实时语音对话采用 UDP 传输音频,而非 MQTT,原因如下:
| 对比维度 | MQTT | UDP |
|---|---|---|
| 延迟 | 较高(有 broker 中转) | 可减少可靠传输排队,实际延迟需测量 |
| 适用场景 | 控制指令、文本消息 | 实时音频流 |
| 可靠性 | QoS 定义交付语义,不代表业务已执行 | 允许丢包(语音容错) |
| 带宽占用 | 较高(协议开销) | 低(紧凑包格式) |
UDP 通道仅用于音频数据传输(上行:设备麦克风数据;下行:TTS 合成语音),AI 协议的会话控制消息仍通过 MQTT 传输。
补充说明:
- 流式 ASR + LLM + TTS 方案的
audioStop结束本轮输入;全双工模式的audioStop暂停上行输入,都不表示关闭会话 - 流式 ASR + LLM + TTS 方案可在识别用户明确“再见”“结束对话”等退出意图并播报后下发
sessionClosed;全双工设备若需确定结束会话,应主动发送sessionClose - 设备收到
sessionClosed后,当前 UDP 会话应视为结束,后续继续交互需重新走sessionCreate
2. 通道建立
2.1 获取连接参数
在 sessionCreate 时设置 transport: "udp"(或不填,默认即 UDP),云端在 sessionCreated 响应的 udp 字段中返回连接参数:
{
"method": "sessionCreated",
"code": 200,
"data": {
"sessionId": "sess-abc123",
"transport": "udp",
"udp": {
"server": "udp.example.invalid",
"port": 6789,
"key": "30313233343536373839616263646566",
"nonce": "01000000a1b2c3d46612345600000000"
}
}
}| 字段 | 说明 |
|---|---|
| server | UDP 服务器地址(IP 或域名) |
| port | UDP 端口号 |
| key | AES-CTR 密钥,16 字节十六进制字符串(32 个 hex 字符) |
| nonce | UDP 会话 nonce 模板,16 字节十六进制字符串(32 个 hex 字符) |
2.2 建立连接
设备获取 UDP 参数后:
- 创建 UDP Socket,记录目标地址(server + port)
- 解码
key和nonce(hex → 字节数组) - 发送
audioStart通知云端准备接收音频
注意: UDP 本身无连接概念,"建立连接"指设备记录服务端地址并开始发包。服务端通过第一个合法数据包识别设备的 UDP 来源地址。
补充说明:
sessionCreated.udp.nonce是模板值,设备侧应保留其中的nonce[4:12]- 每个实际发送的 UDP 包,都需要把
payload_len和sequence重写到该 16 字节头中 - 当前现网客户端(
devicesim/win-ai-client)均按此方式实现
3. 数据包格式
当前现网 UDP 音频包格式如下:
packet = packet_nonce(16 bytes) + ciphertext其中 ciphertext 长度与原始 Opus 帧长度相同,packet_nonce 同时承担:
- UDP 包头
- AES-CTR 的 IV / counter 初始值
3.1 16 字节头结构
偏移 字节数 字段 说明
──── ────── ───── ─────────────────────────────────────
0 1 version 固定 `0x01`
1 1 reserved 当前固定 `0x00`
2 2 payload_len 音频帧长度(大端序 Big-Endian)
4 4 conn_id 会话连接 ID(服务端靠它找 UDP session)
8 4 nonce_tail 会话 nonce 其余部分(当前由服务端生成)
12 4 sequence uint32 序列号(大端序)
16 N ciphertext AES-CTR 加密后的 Opus 帧3.2 字段说明
| 字段 | 类型 | 说明 |
|---|---|---|
| version | uint8 | 当前固定为 0x01 |
| reserved | uint8 | 当前固定为 0x00 |
| payload_len | uint16 BE | 当前包 Opus 帧字节数 |
| conn_id | 4 bytes | 服务端用于命中 UDP 会话;现网从 packet[4:8] 提取 |
| nonce_tail | 4 bytes | 与 conn_id 一起组成 nonce[4:12],设备侧应直接复用服务端下发值 |
| sequence | uint32 BE | 每发送一包递增一次,上下行各自独立维护 |
| ciphertext | bytes | 用 packet_nonce 作为 IV 做 AES-CTR 加密后的音频帧 |
3.3 头部总长度
固定 16 字节,后跟 payload_len 字节密文。
3.4 示例数据包(十六进制)
01 00 00 50 a1 b2 c3 d4 66 12 34 56 00 00 00 0f [ciphertext 80 bytes...]
│ │ │ │ │────────── nonce[4:8] ─────────│ │──────── sequence=15 ───────│
│ │ └ payload_len=80
│ └ reserved=0x00
└ version=0x014. AES-CTR 加密
4.1 算法规格
| 参数 | 值 |
|---|---|
| 算法 | AES-CTR |
| 密钥长度 | 128 位(16 字节) |
| 块大小 | 128 位(16 字节) |
| IV / Counter 初值 | 直接使用 packet_nonce[0:16] |
4.2 设备侧如何构造每包 nonce
设备侧不要把 sessionCreated.udp.nonce 直接原样发出去,而是应把它当成模板:
hex.DecodeString(sessionCreated.udp.nonce)得到 16 字节模板- 保留模板中的
nonce[4:12] - 每发一包前,重写:
nonce[0] = 0x01nonce[1] = 0x00nonce[2:4] = payload_lennonce[12:16] = uint32 sequence
- 用这个 16 字节
packet_nonce作为 AES-CTR IV - 最终发包:
packet_nonce + ciphertext
现网没有额外的 XOR 派生规则。
4.3 Go 示例
func buildEncryptedPacket(aesKeyHex, nonceHex string, seq uint32, opusFrame []byte) ([]byte, error) {
key, err := hex.DecodeString(aesKeyHex)
if err != nil {
return nil, err
}
packetNonce, err := hex.DecodeString(nonceHex)
if err != nil {
return nil, err
}
if len(key) != 16 || len(packetNonce) != 16 {
return nil, fmt.Errorf("invalid udp crypto params")
}
packetNonce[0] = 0x01
packetNonce[1] = 0x00
binary.BigEndian.PutUint16(packetNonce[2:4], uint16(len(opusFrame)))
binary.BigEndian.PutUint32(packetNonce[12:16], seq)
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
stream := cipher.NewCTR(block, packetNonce)
packet := make([]byte, 16+len(opusFrame))
copy(packet[:16], packetNonce)
stream.XORKeyStream(packet[16:], opusFrame)
return packet, nil
}4.4 解密规则
接收端解密更简单:
- 取前 16 字节
packet_nonce := packet[:16] - 取剩余字节
ciphertext := packet[16:] - 使用同一把 AES key,直接用
packet_nonce作为 CTR IV 解密
这正是现网服务端和参考客户端的实现方式。
5. 序列号管理
5.1 基本规则
- 当前协议使用 uint32 序列号,不是旧稿中的 uint16
- 设备侧和服务端各自维护独立的本地方向序列号
- 当前现网客户端实现里,第一包通常从 1 开始
- 序列号参与构造每包的
packet_nonce,同一发送方向内不得回退或重置
5.2 当前现网行为
fullDuplex会话上行仅接收比已接收序号更大的包:首包可使用任意序号,重复包和迟到包丢弃,跳号继续接收- 流式 ASR + LLM + TTS 会话保持原有收包行为,不因本次全双工扩展强制开启上述过滤
- 全双工设备下行也应按序过滤重复和迟到包,尤其要防止插话后旧回复的晚到包再次进入播放器;网络丢包不应触发重传或停等
序号过滤只帮助维持音频顺序,不是密码学防重放保护:AES-CTR 本身不校验报文完整性。UDP 包不带回复 respId,清空设备播放队列后网络中仍可能有旧包到达。
5.3 丢包处理
UDP 不保证送达,设备端无需对丢失的音频帧做重传。若网络质量极差,建议:
- 结束当前会话
- 重新
sessionCreate - 使用新的
key / nonce重建 UDP 通道
6. 音频编码规范
6.1 上行音频(设备 → 云端)
| 参数 | 流式 ASR + LLM + TTS 方案常用值 | 全双工模式建议值 |
|---|---|---|
| 编码格式 | Opus | Opus |
| 采样率 | 24000 Hz | 16000 Hz |
| 声道 | 1(单声道) | 1(单声道) |
| 帧时长 | 60 ms | 20 ms |
| 每帧原始 PCM | 1440 样本 | 320 样本 |
实际音频参数以 sessionCreated.audioParams 为准。服务端会按已协商参数解码设备 Opus,再适配云端模型所需的输入格式;设备不用直接发送模型内部的 PCM 帧。
6.2 下行音频(云端 → 设备)
回复音频的编码参数以 sessionCreated.audioParams 为准,常见组合如下:
| 参数 | 流式 ASR + LLM + TTS 方案常用值 | 全双工模式建议值 |
|---|---|---|
| 编码格式 | Opus | Opus |
| 采样率 | 24000 Hz | 16000 Hz |
| 声道 | 1(单声道) | 1(单声道) |
| 帧时长 | 60 ms | 20 ms |
设备端解码时不要硬编码任一组合,应按服务端返回值初始化解码器。
6.3 Opus 编码建议
- 使用
OPUS_APPLICATION_VOIP - 语音场景建议 16–32 kbps
- 可按设备能力开启 DTX / FEC
- 若出现短句截断或回声误打断,先核对本会话返回的参数、设备 AEC 和播放期间持续上行是否正常
7. 完整收发示例
7.1 上行(设备发送麦克风数据)
seq++
packet, err := buildEncryptedPacket(udpKeyHex, udpNonceHex, seq, opusFrame)
if err != nil {
return err
}
_, err = conn.Write(packet)
return err全双工模式下,上述发送循环在设备播放下行音频时仍运行;只有显式 audioStop 暂停输入或会话关闭时才停止。audioStop 不代表本句话的语音边界。设备与服务端的上下行序号各自递增,不能因新一轮回复而重置。
7.2 下行(设备接收回复音频)
func decryptPacket(aesKeyHex string, packet []byte) ([]byte, error) {
if len(packet) < 16 {
return nil, fmt.Errorf("packet too small")
}
key, err := hex.DecodeString(aesKeyHex)
if err != nil {
return nil, err
}
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
packetNonce := packet[:16]
ciphertext := packet[16:]
stream := cipher.NewCTR(block, packetNonce)
plain := make([]byte, len(ciphertext))
stream.XORKeyStream(plain, ciphertext)
return plain, nil
}全双工设备应在解密/播放前检查下行序号,拒绝不大于已接收序号的包。audioSpeechInterrupted 到达后应清空旧回复的待播队列,但不能依赖这个 MQTT 事件保证网络中不再有旧 UDP 包;MQTT 与 UDP 的到达顺序不保证一致。
8. 常见问题
Q1:udp.key / udp.nonce 是 Base64 吗?
不是。当前现网实现使用 hex string,长度均为 32 个十六进制字符,对应 16 字节原始数据。
Q2:UDP 包丢失会影响对话质量吗?
少量丢包通常可接受;大量丢包会影响 ASR 和 TTS 体验,必要时应重建会话。
Q3:设备防火墙需要开放什么端口?
设备端只需能够向服务端 sessionCreated.udp.port 对应的 UDP 端口发包。下行响应通过同一五元组返回。
Q4:会话过期后 UDP 参数还能使用吗?
不能。会话过期、sessionClosed 或重新 sessionCreate 后,都应以最新下发的 key / nonce 为准。
Q5:上行和下行共享同一个 UDP Socket 吗?
是的。设备通常使用同一个 UDP Socket 上行发送音频并接收服务端下行 TTS 音频。
Q6:sequence 溢出会成为现实问题吗?
当前协议使用 uint32。单次会话通常远短于序号回绕所需时间,但同一密钥与 nonce 模板下不能让序号回绕;到达边界前必须结束会话并获取新的 key / nonce。
更新日志
2026/10/17 03:27
查看所有更新日志
d04e1-docs(AI与音视频): 按公共主线与场景分支整合接入手册于7e927-文档站全面重构:目录重排、首页重写、案例独立成站、应用市场下线于4a596-删除定价页项目数量限制显示于4ef2c-docs(ai交互): 同步语音退出意图会话结束语义于09d68-docs(ai): sync interaction and udp protocol notes于2e886-docs: refresh ai interaction notes于69dce-docs: 新增设备接入-联犀协议-AI交互文档于
