最近在研究 AI 硬件“小智”,其官方主要支持 ESP32 平台。为了在 Windows 上体验并集成到桌面应用中,我使用 C++ 复刻一个 Windows 客户端。本文主要记录如何通过逆向分析通信协议,打通 WebSocket 信令与 Opus 音频流的全过程,以及在降低延迟方面所做的关键优化。 其实可以抄作业的,GitHub上面有Linux的客户端。 它的arm架构内部采用的UDP几个程序进行通信。其实那种设计模式在ARM架构开发板上是非常有道理的,基本崩了一个另外一个可以把它拉起来!
技术栈选择
为了保持高性能和低延迟,同时兼顾开发效率,我选择以下轻量级 C++ 库:
- 网络通信:
ixwebsocket(支持 WebSocket 和 HTTP/HTTPS) - JSON 解析:
nlohmann/json(现代 C++ JSON 库) - 音频驱动:
miniaudio(单头文件,极简跨平台音频库) - 音频编解码:
libopus(低延迟语音编码标准)
第一步:身份认证与 Token 获取 (HTTP)
小智的通信流程并非直接连接 WebSocket,而是先通过 HTTP 接口进行“激活检查”和“鉴权”。
1. 激活与鉴权机制
客户端启动时,需要构造一个包含 Device-Id (MAC地址) 和 Client-Id (UUID) 的 JSON 包,POST 请求到 /xiaozhi/ota/ 接口。
- 坑点一:身份合法性 最初使用硬编码的 MAC 地址导致 WebSocket 连接时服务器直接断开 (Code 1005)。经过抓包分析,必须使用从未被拉黑的、合法的随机 MAC 和 UUID。如果是新设备,服务器会返回
activation_code,需要在网页端激活后才能使用。 - 坑点二:动态 Token 不能写死
test-token。激活成功后,服务器会在 HTTP 响应的websocket.token字段中下发真实的 JWT Token。
核心代码实现
// 伪代码:检查激活状态并获取 Token
std::string XiaozhiClient::CheckActivation() {
// 构造请求头,注意模拟 User-Agent
auto args = httpClient.createRequest();
args->extraHeaders["Device-Id"] = m_mac;
// 发送 POST 请求
auto res = httpClient.post("https://api.tenclass.net/xiaozhi/ota/", requestBody, args);
auto j = json::parse(res->body);
// 检查是否需要激活
if (j.contains("activation")) {
// 提示用户去网页输入 j["activation"]["code"]
return "";
}
// 提取动态 Token
if (j.contains("websocket")) {
return j["websocket"]["token"];
}
return "";
}
第二步:建立连接 (WebSocket)
拿到 Token 后,即可建立全双工的长连接。小智的交互模式是全流式的(Full-Duplex Streaming),这意味着我们在发送音频的同时,可能正在接收回复。
1. 握手协议
连接建立后(Open事件),必须立即发送 hello 包进行协议握手。
关键参数:
Authorization: Bearer + 动态Tokenaudio_params: 这里埋了一个大坑,我稍后在音频部分细说。
2. 信令处理
服务器通过 JSON 下发指令,主要包含:
hello: 握手成功,返回 session_id。tts: 对方正在说话(状态包含start,sentence_start,stop)。需要根据start和stop来控制本地的录音开关,实现“它说我听,我说它听”的半双工逻辑(防止回音),或者配合回声消除算法实现全双工。stt: 实时返回服务器识别到的用户语音文字。
// WebSocket 消息回调处理
void XiaozhiClient::_onWsMessage(const ix::WebSocketMessagePtr& msg) {
if (msg->type == ix::WebSocketMessageType::Open) {
// 发送 Hello 包,携带音频参数
SendHello();
} else if (msg->type == ix::WebSocketMessageType::Message) {
if (msg->binary) {
// 收到二进制消息 -> Opus 音频流 -> 送入播放队列
m_audioEngine.PushOpusPacket(msg->str);
} else {
// 收到文本消息 -> JSON 信令解析
HandleJsonSignal(json::parse(msg->str));
}
}
}
第三步:音频引擎与延迟优化 (The Core)
这是最硬核的部分。为了实现“像打电话一样”的实时对话体验,我踩了两个深坑。
坑点三:采样率陷阱 (16k vs 24k)
通常语音识别项目惯用 16000Hz 采样率。我在初期一直使用 16k,结果导致声音出现严重的机械音和变调,且频繁断连。 排查过程:通过打印 [RAW PROTOCOL] 发现,服务器下发的 hello 包中明确指定了 "sample_rate": 24000。 解决方案:将本地 AudioEngine 的采样率全线调整为 24000Hz,Opus 帧长设为 60ms (1440样本),瞬间解决了音质问题。
坑点四:延迟优化 (极速响应的秘密)
起初小智的反应很慢,有明显的迟滞感。我进行了三步优化:
- Opus 配置:启用
OPUS_APPLICATION_VOIP模式,将码率限制在 32kbps。对于人声来说,这个码率足够清晰,且网络包极小,传输极快。 缩减抖动缓冲:
- 优化前:为了求稳,我设置了 300ms 的播放缓冲水位。这意味着收到数据后要憋 0.3秒才播放。
- 优化后:将缓冲阈值砍到 100ms (约 2400 个样本)。配合 C++ 处理,实现了“秒回”的听感。
- 流式处理:服务器采用的是流式生成。ASR 识别出几个字,LLM 就生成几个字,TTS 就合成几段音频。客户端必须收到一包播一包,绝对不能等待完整句子。
AudioEngine 核心代码摘要
// 初始化音频引擎 (极速模式)
bool AudioEngine::Init() {
//Opus 编码器:VOIP 模式,32kbps,60ms 帧长
m_pEncoder = opus_encoder_create(24000, 1, OPUS_APPLICATION_VOIP, &err);
opus_encoder_ctl(m_pEncoder, OPUS_SET_BITRATE(32000));
opus_encoder_ctl(m_pEncoder, OPUS_SET_EXPERT_FRAME_DURATION(OPUS_FRAMESIZE_60_MS));
//Miniaudio:极低延迟配置
ma_device_config config = ma_device_config_init(ma_device_type_duplex);
config.sampleRate = 24000; // 匹配服务器,真不一定是16000
config.periodSizeInMilliseconds = 10; // 硬件周期设为 10ms
// ...
}
// 播放回调 (防抖逻辑)
void _onAudioCallback(...) {
// 只有当缓冲区数据量 > 100ms 时才开始播放,防止网络抖动造成的爆音
if (m_playBuffer.size() > CACHE_TARGET_SIZE) {
// 块拷贝数据到声卡缓冲区
}
}
第四步:中文乱码问题
Windows 控制台默认是 GBK 编码,而服务器下发的是 UTF-8。为了在调试时看清内容,封装了一个 StringUtil 类,利用 Windows API MultiByteToWideChar 和 WideCharToMultiByte 进行编码转换。这一步简单,但对于调试 Log 至关重要。
总结
通过以上步骤,成功在 Windows 上实现了一个没有任何 Python 依赖、纯 C++ 编写的小智客户端。 最终效果:
- 连接:稳定,支持断线重连。
- 听感:清晰,无杂音。
- 响应:极快,基本达到同声传译级别的响应速度。
