UDP 音频通道协议
约 2740 字大约 9 分钟
2026-03-08
2026-04-08 校对说明
当前仓库实际实现与本文第 3、4 节存在差异。以代码为准:
- 服务端解包逻辑:
backend/core/service/aisvr/internal/server/udp/session_crypto.go- 现网测试客户端:
backend/things/tools/devicesim/udp.go- 现网对照客户端:
backend/things/tools/win-ai-client/udp_client.go当前实际数据包格式不是本文描述的 14 字节 RTP 风格头,而是:
- 前 16 字节直接作为 AES-CTR nonce / 包头
nonce[0] = 0x01nonce[1] = 0x00nonce[2:4] = payload_lennonce[4:12] = nonce8nonce[12:16] = uint32 sequencenonce[16:] = ciphertextsequence当前实际为 uint32,不是旧文档里的 uint16因此本文后续章节暂可作为历史设计参考,不应直接作为当前设备侧实现依据,后续需要按现网代码重写。
本文档描述联犀 AI 交互协议中,实时语音对话所使用的 UDP 音频传输通道的完整规范,包括通道建立、数据包格式、AES-CTR 加密方案和音频编码要求。
0. 现网实现快照
当前现网实现以代码为准,推荐直接对照:
backend/core/service/aisvr/internal/server/udp/session_crypto.gobackend/things/tools/devicesim/udp.gobackend/things/tools/win-ai-client/udp_client.go
0.1 当前真实包格式
当前 UDP 包不是本文后文描述的 14 字节 RTP 风格包头,而是:
packet = nonce(16 bytes) + ciphertext
nonce[0] = 0x01
nonce[1] = 0x00
nonce[2:4] = payload_len (big endian)
nonce[4:12] = nonce8
nonce[12:16] = uint32 sequence (big endian)服务端解包逻辑:
nonce := data[:16]
ciphertext := data[16:]
seqNum := binary.BigEndian.Uint32(data[12:16])
stream := cipher.NewCTR(s.Block, nonce)0.1.1 当前推荐实现结论
设备侧若要与现网完全对齐,推荐直接按以下顺序实现:
- 先发
sessionCreate - 拿到
sessionCreated中的udp.key / udp.nonce - 发
audioStart - 用 16 字节 nonce 头 + ciphertext 格式持续发 UDP 音频包
- 发
audioStop - 若用户明确说“再见 / 结束对话”,等待服务端告别语播放完成和
sessionClosed - 如需继续对话,重新
sessionCreate
不要直接照抄本文旧版的 14 字节 RTP 风格包头描述。
0.2 当前推荐音频参数
结合现网服务端默认值和 devicesim 回归结果,推荐:
| 参数 | 当前推荐值 | 说明 |
|---|---|---|
| 编码格式 | opus | 当前稳定路径 |
| 采样率 | 24000 | 与现网服务端默认一致 |
| 声道 | 1 | 单声道 |
| 帧时长 | 60ms | 当前回归稳定 |
补充说明:
devicesim当前已加入:delay_noise样本echo_mix样本
- 如果设备侧遇到“回声误打断”或“短控制句被吞”,优先使用这些样本与现网对照客户端交叉验证
0.3 当前已验证客户端实现
现网通过的设备侧参考实现:
backend/things/tools/devicesim/backend/things/tools/win-ai-client/
如果新客户端和现网表现不一致,优先对照这两套代码,而不是只对照本文历史设计段落。
1. 通道概述
实时语音对话采用 UDP 传输音频,而非 MQTT,原因如下:
| 对比维度 | MQTT | UDP |
|---|---|---|
| 延迟 | 较高(有 broker 中转) | 极低(直连服务端) |
| 适用场景 | 控制指令、文本消息 | 实时音频流 |
| 可靠性 | 保证送达(QoS) | 允许丢包(语音容错) |
| 带宽占用 | 较高(协议开销) | 低(紧凑包格式) |
UDP 通道仅用于音频数据传输(上行:设备麦克风数据;下行:TTS 合成语音),AI 协议的会话控制消息仍通过 MQTT 传输。
补充说明:
audioStop只表示“本轮说话结束”,不是“整个对话结束”- 当用户语音明确表达“再见”“结束对话”等退出意图时,服务端会在 MQTT AI 通道里主动下发
sessionClosed - 设备收到
sessionClosed后,当前 UDP 会话应视为结束,后续继续交互需重新走sessionCreate
2. 通道建立
2.1 获取连接参数
在 sessionCreate 时设置 transport: "udp"(或不填,默认即 UDP),云端在 sessionCreated 响应的 udp 字段中返回连接参数:
{
"method": "sessionCreated",
"code": 200,
"data": {
"sessionId": "sess-abc123",
"transport": "udp",
"udp": {
"server": "192.168.1.100",
"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 当前现网行为
- 当前 aisvr UDP 层不会仅因为序列号乱序或重复就直接丢弃数据包
- 服务端会记录最近收到的
RemoteSeq,供会话状态和排障使用 - UDP 本身允许丢包、乱序;是否影响识别质量,取决于实时音频链路和上层 VAD/ASR
5.3 丢包处理
UDP 不保证送达,设备端无需对丢失的音频帧做重传。若网络质量极差,建议:
- 结束当前轮对话
- 重新
sessionCreate - 使用新的
key / nonce重建 UDP 通道
6. 音频编码规范
6.1 上行音频(设备 → 云端)
| 参数 | 推荐值 | 可选值 |
|---|---|---|
| 编码格式 | Opus | — |
| 采样率 | 24000 Hz | 8000 / 16000 / 24000 / 48000 |
| 声道 | 1(单声道) | — |
| 帧时长 | 60 ms | 20 / 40 / 60 |
| 每帧原始 PCM | 1440 样本(@24kHz × 60ms) | 随采样率、帧长变化 |
| 每帧 Opus 输出 | 约几十到上千字节 | 取决于语音活动和编码参数 |
现网回归最稳定的组合仍是 24000 Hz / 单声道 / 60ms / Opus。
6.2 下行音频(云端 → 设备)
TTS 音频的编码参数以 sessionCreated.audioParams 为准,当前常见返回值为:
| 参数 | 典型值 |
|---|---|
| 编码格式 | Opus |
| 采样率 | 24000 Hz |
| 声道 | 1(单声道) |
| 帧时长 | 60 ms |
设备端解码时不要硬编码 16k / 20ms,应按服务端返回值初始化解码器。
6.3 Opus 编码建议
- 使用
OPUS_APPLICATION_VOIP - 语音场景建议 16–32 kbps
- 可按设备能力开启 DTX / FEC
- 若出现“短句被截断”或“回声误打断”,先与
devicesim当前默认参数对齐再排查
7. 完整收发示例
7.1 上行(设备发送麦克风数据)
seq++
packet, err := buildEncryptedPacket(udpKeyHex, udpNonceHex, seq, opusFrame)
if err != nil {
return err
}
_, err = conn.Write(packet)
return err7.2 下行(设备接收 TTS 音频)
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
}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。按 60ms 一帧连续发送,达到 2^32 帧所需时间约 8 年,正常语音对话场景下可忽略不计。
更新日志
2026/5/6 14:10
查看所有更新日志
4a596-删除定价页项目数量限制显示于4ef2c-docs(ai交互): 同步语音退出意图会话结束语义于09d68-docs(ai): sync interaction and udp protocol notes于2e886-docs: refresh ai interaction notes于69dce-docs: 新增设备接入-联犀协议-AI交互文档于
