UDP 音频通道协议
约 2257 字大约 8 分钟
2026-03-08
本文档描述联犀 AI 交互协议中,实时语音对话所使用的 UDP 音频传输通道的完整规范,包括通道建立、数据包格式、AES-CTR 加密方案和音频编码要求。
1. 通道概述
实时语音对话采用 UDP 传输音频,而非 MQTT,原因如下:
| 对比维度 | MQTT | UDP |
|---|---|---|
| 延迟 | 较高(有 broker 中转) | 极低(直连服务端) |
| 适用场景 | 控制指令、文本消息 | 实时音频流 |
| 可靠性 | 保证送达(QoS) | 允许丢包(语音容错) |
| 带宽占用 | 较高(协议开销) | 低(紧凑包格式) |
UDP 通道仅用于音频数据传输(上行:设备麦克风数据;下行:TTS 合成语音),AI 协议的会话控制消息仍通过 MQTT 传输。
2. 通道建立
2.1 获取连接参数
在 sessionCreate 时设置 transport: "udp"(或不填,默认即 UDP),云端在 sessionCreated 响应的 udp 字段中返回连接参数:
{
"method": "sessionCreated",
"code": 0,
"data": {
"sessionId": "sess-abc123",
"transport": "udp",
"udp": {
"server": "192.168.1.100",
"port": 6789,
"key": "YWJjZGVmZ2hpamtsbW5vcA==",
"nonce": "MTIzNDU2Nzg5MDEyMzQ1Ng=="
}
}
}| 字段 | 说明 |
|---|---|
| server | UDP 服务器地址(IP 或域名) |
| port | UDP 端口号 |
| key | AES-CTR 密钥(Base64 编码,解码后 16 字节) |
| nonce | AES-CTR Nonce(Base64 编码,解码后 16 字节) |
2.2 建立连接
设备获取 UDP 参数后:
- 创建 UDP Socket,记录目标地址(server + port)
- 解码
key和nonce(Base64 → 字节数组) - 发送
audioStart通知云端准备接收音频
注意: UDP 本身无连接概念,"建立连接"指设备记录服务端地址并开始发包。服务端通过第一个合法数据包识别设备的 UDP 来源地址。
3. 数据包格式
每个 UDP 音频数据包由固定长度头部和可变长度负载组成:
3.1 包头结构
偏移 字节数 字段 说明
──── ────── ───── ────────────────────────────────────
0 1 type 包类型(0x00 = 音频数据)
1 1 flags 标志位(保留,当前填 0x00)
2 2 payload_len 负载长度(大端序 Big-Endian,字节数)
4 4 ssrc 同步源标识(设备唯一 ID,固定值)
8 4 timestamp 时间戳(毫秒,从连接开始计)
12 2 sequence 序列号(从 0 开始,单调递增,每包 +1)
14 N payload 加密后的 Opus 音频数据字段详解:
| 字段 | 类型 | 说明 |
|---|---|---|
| type | uint8 | 0x00 = 音频数据包;其他值保留 |
| flags | uint8 | 标志位,当前版本固定为 0x00 |
| payload_len | uint16 BE | 负载(payload)的字节长度,不含包头 |
| ssrc | uint32 BE | 同步源 ID,每个设备/会话使用固定值(建议用设备 MAC 地址低 4 字节) |
| timestamp | uint32 BE | 当前帧的时间戳(毫秒,从本次会话开始时计算) |
| sequence | uint16 BE | 序列号,从 0 开始,每发送一个包加 1,溢出后从 0 重新开始 |
| payload | bytes | 经 AES-CTR 加密的 Opus 编码音频数据 |
3.2 包头总长度
固定 14 字节,后跟 payload_len 字节的加密负载。
3.3 示例包头(十六进制)
00 00 00 50 00 01 23 45 00 00 03 E8 00 0F [加密负载 80 字节...]
│ │ │ │ │ │ │ │ │ │
│ │ └─payload_len=80 │ └timestamp=1000ms └sequence=15
│ └─flags=0x00 └─ssrc=0x12345
└─type=0x00(音频)4. AES-CTR 加密
4.1 算法规格
| 参数 | 值 |
|---|---|
| 算法 | AES-CTR(计数器模式) |
| 密钥长度 | 128 位(16 字节) |
| 块大小 | 128 位(16 字节) |
| 计数器大小 | 128 位(16 字节) |
4.2 密钥和 Nonce
- Key:从
sessionCreated.udp.key获取,Base64 解码后为 16 字节原始密钥 - Nonce:从
sessionCreated.udp.nonce获取,Base64 解码后为 16 字节
4.3 每包计数器派生
每个数据包使用独立的计数器,基于 Nonce 和序列号派生,确保每包加密密钥流不同:
counter = nonce XOR (sequence << 112)即:将序列号(sequence,uint16)左移 112 位(14 字节)后,与 Nonce 进行异或运算,得到该包的 128 位计数器初始值。
Python 示例:
import struct
from Crypto.Cipher import AES
def encrypt_payload(payload: bytes, key: bytes, nonce: bytes, sequence: int) -> bytes:
"""加密单个 UDP 包的负载"""
# 将序列号编码到计数器的高位(前 2 字节)
seq_bytes = struct.pack('>H', sequence) # 大端序 uint16
# counter = nonce XOR (seq 左移至前 2 字节)
counter = bytearray(nonce)
counter[0] ^= seq_bytes[0]
counter[1] ^= seq_bytes[1]
cipher = AES.new(key, AES.MODE_CTR, nonce=bytes(counter[8:]), initial_value=bytes(counter[:8]))
return cipher.encrypt(payload)
def decrypt_payload(encrypted: bytes, key: bytes, nonce: bytes, sequence: int) -> bytes:
"""解密单个 UDP 包的负载(与加密对称)"""
return encrypt_payload(encrypted, key, nonce, sequence)C 语言示意:
void derive_counter(uint8_t counter[16], const uint8_t nonce[16], uint16_t seq) {
memcpy(counter, nonce, 16);
counter[0] ^= (seq >> 8) & 0xFF;
counter[1] ^= seq & 0xFF;
}4.4 加解密流程
发送端(设备上行):
原始 Opus 帧
↓
derive_counter(nonce, sequence)
↓
AES-CTR 加密
↓
组装包头(type, flags, payload_len, ssrc, timestamp, sequence)
↓
发送 UDP 包接收端(服务端下行):
收到 UDP 包
↓
解析包头(提取 sequence)
↓
derive_counter(nonce, sequence)
↓
AES-CTR 解密 payload
↓
得到原始 Opus 帧
↓
送入 VAD/ASR 处理5. 序列号管理
5.1 基本规则
- 序列号从 0 开始,每发送一个包加 1
- 序列号为 uint16,范围 0–65535,溢出后从 0 重新开始
- 上行(设备→服务端)和下行(服务端→设备)各维护独立的序列号计数器
5.2 重放防护
服务端会检查序列号,防止重放攻击:
- 若接收到的序列号与预期相差超过阈值(默认 32 个包),则丢弃该包
- 设备端应保证序列号严格递增,不得重复发送相同序列号的包
5.3 丢包处理
UDP 不保证送达,设备端无需重传丢失的音频帧(VAD 和 ASR 具备一定的丢包容忍能力)。如遇网络极差导致大量丢包,可以:
- 关闭当前会话(
sessionClose) - 重新发起
sessionCreate - 使用新的 key/nonce 重建 UDP 通道
6. 音频编码规范
6.1 上行音频(设备 → 云端)
| 参数 | 推荐值 | 可选值 |
|---|---|---|
| 编码格式 | Opus | — |
| 采样率 | 16000 Hz | 8000 / 24000 / 48000 |
| 声道 | 1(单声道) | — |
| 帧时长 | 60 ms | 20 / 40 / 60 |
| 每帧原始 PCM | 960 样本(@16kHz × 60ms) | — |
| 每帧 Opus 输出 | 约 200–1200 字节(取决于语音活动) |
推荐 16000 Hz 单声道 60ms 帧,这是大多数 ASR 模型的最优输入规格,同时带宽占用较小。
6.2 下行音频(云端 → 设备)
TTS 合成的音频由云端决定参数,在 sessionCreated.audioParams 中返回实际规格:
| 参数 | 典型值 |
|---|---|
| 编码格式 | Opus |
| 采样率 | 24000 Hz(高质量 TTS)或 16000 Hz |
| 声道 | 1(单声道) |
| 帧时长 | 60 ms |
设备端解码时应以 sessionCreated.audioParams 为准,而非硬编码假设。
6.3 Opus 编码建议
- 使用 VOIP 模式(
OPUS_APPLICATION_VOIP)可优化语音质量 - 建议比特率:16–32 kbps(语音场景)
- 开启 DTX(不连续传输)以在静音时减少包数
- 开启 FEC(前向纠错)以增强丢包鲁棒性
7. 完整收发示例
7.1 上行(设备发送麦克风数据)
import socket, struct, base64
from Crypto.Cipher import AES
# 从 sessionCreated 获取参数
key = base64.b64decode("YWJjZGVmZ2hpamtsbW5vcA==")
nonce = base64.b64decode("MTIzNDU2Nzg5MDEyMzQ1Ng==")
udp_server = ("192.168.1.100", 6789)
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sequence = 0
ssrc = 0x12345678 # 设备唯一 ID
start_time_ms = int(time.time() * 1000)
def send_audio_frame(opus_frame: bytes):
global sequence
timestamp = int(time.time() * 1000) - start_time_ms
# 加密负载
counter = bytearray(nonce)
counter[0] ^= (sequence >> 8) & 0xFF
counter[1] ^= sequence & 0xFF
cipher = AES.new(key, AES.MODE_CTR,
nonce=bytes(counter[8:]),
initial_value=bytes(counter[:8]))
encrypted = cipher.encrypt(opus_frame)
# 组装包头(14 字节)
header = struct.pack(
'>BBHIIH', # type(1) flags(1) payload_len(2) ssrc(4) timestamp(4) sequence(2)
0x00, # type = 音频数据
0x00, # flags = 0
len(encrypted), # payload_len
ssrc, # ssrc
timestamp, # timestamp (ms)
sequence # sequence
)
sock.sendto(header + encrypted, udp_server)
sequence = (sequence + 1) & 0xFFFF # uint16 溢出回绕7.2 下行(设备接收 TTS 音频)
def receive_audio():
while session_active:
data, _ = sock.recvfrom(4096)
if len(data) < 14:
continue
# 解析包头
type_, flags, payload_len, ssrc, timestamp, seq = struct.unpack('>BBHIIH', data[:14])
if type_ != 0x00:
continue # 非音频包,跳过
encrypted = data[14:14 + payload_len]
# 解密
counter = bytearray(nonce)
counter[0] ^= (seq >> 8) & 0xFF
counter[1] ^= seq & 0xFF
cipher = AES.new(key, AES.MODE_CTR,
nonce=bytes(counter[8:]),
initial_value=bytes(counter[:8]))
opus_frame = cipher.decrypt(encrypted)
# 送入 Opus 解码器 → 播放
pcm = opus_decoder.decode(opus_frame, frame_size=960)
audio_player.play(pcm)8. 常见问题
Q1:UDP 包丢失会影响对话质量吗?
少量丢包(< 5%)对 ASR 识别和 TTS 播放影响很小,Opus 的 FEC 机制可以恢复。大量丢包时建议重建会话。
Q2:设备防火墙需要开放什么端口?
设备端只需能够向服务端的 UDP 端口(从 sessionCreated.udp.port 获取)发送数据包。大多数防火墙对出向 UDP 流量不做限制,服务端响应通过同一五元组返回。
Q3:会话过期后 UDP 参数还能使用吗?
不能。会话过期(收到 sessionExpired 错误)后,需重新 sessionCreate 获取新的 key/nonce,旧的加密参数将失效。
Q4:上行和下行共享同一个 UDP Socket 吗?
是的。设备使用同一个 UDP Socket 发送上行音频,并接收来自同一服务端地址的下行 TTS 音频。
Q5:sequence 溢出会影响加密安全吗?
sequence 为 uint16,溢出后从 0 重新开始。如果 nonce 固定不变,序列号溢出后会复用相同的计数器,理论上存在密钥流复用风险。但实际场景中,一次 AI 对话通常不超过 65535 帧(@ 60ms/帧约 65 分钟),且会话结束后 nonce 也随之失效,安全性在正常使用场景下可以接受。如需更高安全性,应定期重建会话。
更新日志
2026/3/12 18:14
查看所有更新日志
69dce-docs: 新增设备接入-联犀协议-AI交互文档于
