政安晨的个人主页:政安晨
欢迎 👍点赞✍评论⭐收藏
希望政安晨的博客能够对您有所裨益,如有不足之处,欢迎在评论区提出指正!
目录
小智AI使用Websocket协议与平台进行通信,提供AI交互应用能力,本篇我们使用“代码陪伴”的方式了解并详细介绍小智AI项目中WebSocket通信协议的实现细节。通过分析源代码,我们将深入理解设备端与服务器端之间的通信流程、消息结构以及错误处理机制。本文基于提供的代码实现整理而成,旨在为开发者提供一个清晰的参考文档。
什么是WebSocket?
WebSocket通信是一种基于TCP协议的全双工通信协议,它允许在客户端和服务器之间建立持久连接,从而实现实时的双向数据传输。与传统的HTTP请求-响应模式不同,WebSocket协议通过单个TCP连接即可实现客户端和服务器之间的高效交互。
具体来说,WebSocket通信具有以下特点:
全双工通信 :WebSocket允许客户端和服务器同时发送和接收数据,而不需要像HTTP那样每次通信都需要发起新的请求。
持久连接 :WebSocket通过一次握手后,会保持连接状态,双方可以随时发送数据,无需重复建立连接。
低延迟 :由于避免了HTTP协议中频繁的连接建立和断开操作,WebSocket能够显著降低通信延迟,非常适合实时应用场景,例如在线聊天、股票行情推送和多人在线游戏等。
WebSocket通信的基本过程
WebSocket通信通常包括以下几个步骤:
- 客户端通过HTTP协议向服务器发送一个特殊的升级请求(Upgrade Request),请求将协议从HTTP升级为WebSocket。
- 如果服务器支持WebSocket协议,它会返回一个确认响应,完成协议切换。
- 完成握手后,客户端和服务器之间便建立了持久的WebSocket连接,随后可以自由地进行双向通信。
总之,WebSocket是一种专门为实时、双向通信设计的协议,在需要快速数据交换的应用场景中具有显著优势。
1. 总体流程概览
1.1 设备端初始化
设备启动时,会执行一系列初始化操作,包括音频编解码器、显示屏、LED等的初始化。随后,设备连接网络并创建一个实现
Protocol
接口的WebSocket协议实例(WebsocketProtocol
)。最后,设备进入主循环,等待各种事件(如音频输入、音频输出、调度任务等)的发生。
1.2 建立WebSocket连接
当设备需要开始语音会话时(例如用户唤醒、手动按键触发等),会调用
OpenAudioChannel()
方法。该方法的主要步骤如下:
- 获取WebSocket URL:根据编译配置获取WebSocket URL(
CONFIG_WEBSOCKET_URL
)。- 设置请求头:设置Authorization、Protocol-Version、Device-Id、Client-Id等请求头。
- 建立连接:调用
Connect()
方法与服务器建立WebSocket连接。- 发送“hello”消息:连接成功后,设备会发送一条JSON消息,示例如下:
{ "type": "hello", "version": 1, "transport": "websocket", "audio_params": { "format": "opus", "sample_rate": 16000, "channels": 1, "frame_duration": 60 } }
1.3 服务器回复“hello”
设备等待服务器返回一条包含
"type": "hello"
的JSON消息,并检查"transport": "websocket"
是否匹配。如果匹配,则认为服务器已就绪,标记音频通道打开成功。如果在超时时间内未收到正确回复,认为连接失败并触发网络错误回调。
1.4 后续消息交互
设备端和服务器端之间可发送两种主要类型的数据:
- 二进制音频数据(Opus编码):用于实时音频流传输。
- 文本JSON消息:用于传输聊天状态、TTS/STT事件、IoT命令等。
接收回调主要分为:
- OnData(...):
- 当
binary
为true
时,认为是音频帧;设备会将其当作Opus数据进行解码。- 当
binary
为false
时,认为是JSON文本,需要在设备端用cJSON进行解析并做相应业务逻辑处理。- OnDisconnected():
- 当服务器或网络出现断连时,回调
OnDisconnected()
被触发。- 设备会调用
on_audio_channel_closed_()
,并最终回到空闲状态。
1.5 关闭WebSocket连接
设备在需要结束语音会话时,会调用CloseAudioChannel()
主动断开连接,并回到空闲状态。或者如果服务器端主动断开,也会引发同样的回调流程。
2. 通用请求头
在建立WebSocket连接时,代码示例中设置了以下请求头:
- Authorization:用于存放访问令牌,形如"Bearer <token>"。
- Protocol-Version:固定示例中为"1"。
- Device-Id:设备物理网卡MAC地址。
- Client-Id:设备UUID(可在应用中唯一标识设备)。
这些头会随着WebSocket握手一起发送到服务器,服务器可根据需求进行校验、认证等。
3. JSON消息结构
WebSocket文本帧以JSON方式传输,以下为常见的"type"字段及其对应业务逻辑。若消息里包含未列出的字段,可能为可选或特定实现细节。
3.1 客户端→服务器
3.1.1 Hello
连接成功后,由客户端发送,告知服务器基本参数。
示例:
{
"type": "hello",
"version": 1,
"transport": "websocket",
"audio_params": {
"format": "opus",
"sample_rate": 16000,
"channels": 1,
"frame_duration": 60
}
}
3.1.2 Listen
表示客户端开始或停止录音监听。
常见字段:
- "session_id":会话标识。
- "type": "listen"。
- "state":"start", "stop", "detect"(唤醒检测已触发)。
- "mode":"auto", "manual" 或 "realtime",表示识别模式。
示例:
{
"session_id": "xxx",
"type": "listen",
"state": "start",
"mode": "manual"
}
3.1.3 Abort
终止当前说话(TTS播放)或语音通道。
示例:
{
"session_id": "xxx",
"type": "abort",
"reason": "wake_word_detected"
}
reason
值可为 "wake_word_detected" 或其他。
3.1.4 Wake Word Detected
用于客户端向服务器告知检测到唤醒词。
示例:
{
"session_id": "xxx",
"type": "listen",
"state": "detect",
"text": "你好小智"
}
3.1.5 IoT
发送当前设备的物联网相关信息:Descriptors(描述设备功能、属性等)、States(设备状态的实时更新)。
示例:
{
"session_id": "xxx",
"type": "iot",
"descriptors": { ... }
}
或
{
"session_id": "xxx",
"type": "iot",
"states": { ... }
}
3.2 服务器→客户端
3.2.1 Hello
服务器端返回的握手确认消息。
必须包含:
- "type": "hello"。
- "transport": "websocket"。
可能包含:
audio_params
,表示服务器期望的音频参数,或与客户端对齐的配置。
成功接收后:
- 客户端会设置事件标志,表示WebSocket通道就绪。
3.2.2 STT
{"type": "stt", "text": "..."}
。
表示服务器端识别到了用户语音。(例如语音转文本结果)。 设备可能将此文本显示到屏幕上,后续再进入回答等流程。
3.2.3 LLM
{"type": "llm", "emotion": "happy", "text": "😀"}
。
服务器指示设备调整表情动画/UI表达。
3.2.4 TTS
{"type": "tts", "state": "start"}
:服务器准备下发TTS音频,客户端进入“speaking”播放状态。{"type": "tts", "state": "stop"}
:表示本次TTS结束。{"type": "tts", "state": "sentence_start", "text": "..."}
:让设备在界面上显示当前要播放或朗读的文本片段(例如用于显示给用户)。
3.2.5 IoT
{"type": "iot", "commands": [ ... ]}
。
服务器向设备发送物联网的动作指令,设备解析并执行(如打开灯、设置温度等)。
3.2.6 音频数据:二进制帧
当服务器发送音频二进制帧(Opus编码)时,客户端解码并播放。 若客户端正在处于“listening”(录音)状态,收到的音频帧会被忽略或清空以防冲突。
4. 音频编解码
4.1 客户端发送录音数据
音频输入经过可能的回声消除、降噪或音量增益后,通过Opus编码打包为二进制帧发送给服务器。 如果客户端每次编码生成的二进制帧大小为N字节,则会通过WebSocket的binary消息发送这块数据。
4.2 客户端播放收到的音频
收到服务器的二进制帧时,同样认定是Opus数据。 设备端会进行解码,然后交由音频输出接口播放。 如果服务器的音频采样率与设备不一致,会在解码后再进行重采样。
5. 常见状态流转
以下简述设备端关键状态流转,与WebSocket消息对应:
- Idle → Connecting:
- 用户触发或唤醒后,设备调用
OpenAudioChannel()
→建立WebSocket连接→发送"type":"hello"。- Connecting → Listening:
- 成功建立连接后,若继续执行
SendStartListening(...)
,则进入录音状态。此时设备会持续编码麦克风数据并发送到服务器。- Listening → Speaking:
- 收到服务器TTS Start消息(
{"type":"tts","state":"start"}
)→停止录音并播放接收到的音频。- Speaking → Idle:
- 服务器TTS Stop(
{"type":"tts","state":"stop"}
)→音频播放结束。若未继续进入自动监听,则返回Idle;如果配置了自动循环,则再度进入Listening。- Listening / Speaking → Idle(遇到异常或主动中断):
- 调用
SendAbortSpeaking(...)
或CloseAudioChannel()
→中断会话→关闭WebSocket→状态回到Idle。
6. 错误处理
6.1 连接失败
如果Connect(url)
返回失败或在等待服务器“hello”消息时超时,触发on_network_error_()
回调。设备会提示“无法连接到服务”或类似错误信息。
6.2 服务器断开
如果WebSocket异常断开,回调OnDisconnected()
:
- 设备回调
on_audio_channel_closed_()
。- 切换到Idle或其他重试逻辑。
7. 其它注意事项
7.1 鉴权
设备通过设置Authorization: Bearer <token>
提供鉴权,服务器端需验证是否有效。 如果令牌过期或无效,服务器可拒绝握手或在后续断开。
7.2 会话控制
代码中部分消息包含session_id
,用于区分独立的对话或操作。服务端可根据需要对不同会话做分离处理,WebSocket协议为空。
7.3 音频负载
代码里默认使用Opus格式,并设置sample_rate = 16000
,单声道。帧时长由OPUS_FRAME_DURATION_MS
控制,一般为60ms。可根据带宽或性能做适当调整。
7.4 IoT指令
type":"iot"
的消息用户端代码对接thing_manager
执行具体命令,因设备定制而不同。服务器端需确保下发格式与客户端保持一致。
7.5 错误或异常JSON
当JSON中缺少必要字段,例如{"type": ...}
,客户端会记录错误日志(ESP_LOGE(TAG, "Missing message type, data: %s", data);
),不会执行任何业务。
8. 消息示例
下面给出一个典型的双向消息示例(流程简化示意):
8.1 客户端 → 服务器(握手)
{
"type": "hello",
"version": 1,
"transport": "websocket",
"audio_params": {
"format": "opus",
"sample_rate": 16000,
"channels": 1,
"frame_duration": 60
}
}
8.2 服务器 → 客户端(握手应答)
{
"type": "hello",
"transport": "websocket",
"audio_params": {
"sample_rate": 16000
}
}
8.3 客户端 → 服务器(开始监听)
{
"session_id": "",
"type": "listen",
"state": "start",
"mode": "auto"
}
- 同时客户端开始发送二进制帧(Opus数据)。
8.4 服务器 → 客户端(ASR结果)
{
"type": "stt",
"text": "用户说的话"
}
8.5 服务器 → 客户端(TTS开始)
{
"type": "tts",
"state": "start"
}
- 接着服务器发送二进制音频帧给客户端播放。
8.6 服务器 → 客户端(TTS结束)
{
"type": "tts",
"state": "stop"
}
- 客户端停止播放音频,若无更多指令,则回到空闲状态。
9. 代码实现细节
9.1 OpenAudioChannel()
bool WebsocketProtocol::OpenAudioChannel() { if (websocket_ != nullptr) { delete websocket_; } error_occurred_ = false; std::string url = CONFIG_WEBSOCKET_URL; std::string token = "Bearer " + std::string(CONFIG_WEBSOCKET_ACCESS_TOKEN); websocket_ = Board::GetInstance().CreateWebSocket(); websocket_->SetHeader("Authorization", token.c_str()); websocket_->SetHeader("Protocol-Version", "1"); websocket_->SetHeader("Device-Id", SystemInfo::GetMacAddress().c_str()); websocket_->SetHeader("Client-Id", Board::GetInstance().GetUuid().c_str()); }
9.2 SendText()
void WebsocketProtocol::SendText(const std::string& text) { if (websocket_ == nullptr) { return; } if (!websocket_->Send(text)) { ESP_LOGE(TAG, "Failed to send text: %s", text.c_str()); SetError(Lang::Strings::SERVER_ERROR); } }
9.3 CloseAudioChannel()
void WebsocketProtocol::CloseAudioChannel() { if (websocket_ != nullptr) { delete websocket_; websocket_ = nullptr; } }
9.4 WebSocket数据处理
在WebsocketProtocol
类中,通过OnData
和OnDisconnected
回调处理WebSocket数据和断开事件。
websocket_->OnData([this](const char* data, size_t len, bool binary) { if (binary) { if (on_incoming_audio_ != nullptr) { on_incoming_audio_(std::vector<uint8_t>((uint8_t*)data, (uint8_t*)data + len)); } } else { // Parse JSON data auto root = cJSON_Parse(data); auto type = cJSON_GetObjectItem(root, "type"); if (type != NULL) { if (strcmp(type->valuestring, "hello") == 0) { ParseServerHello(root); } else { if (on_incoming_json_ != nullptr) { on_incoming_json_(root); } } } else { ESP_LOGE(TAG, "Missing message type, data: %s", data); } cJSON_Delete(root); } last_incoming_time_ = std::chrono::steady_clock::now(); }); websocket_->OnDisconnected([this]() { ESP_LOGI(TAG, "Websocket disconnected"); if (on_audio_channel_closed_ != nullptr) { on_audio_channel_closed_(); } });
9.5 解析服务器“hello”消息
void WebsocketProtocol::ParseServerHello(const cJSON* root) { auto transport = cJSON_GetObjectItem(root, "transport"); if (transport == nullptr || strcmp(transport->valuestring, "websocket") != 0) { ESP_LOGE(TAG, "Unsupported transport: %s", transport->valuestring); return; } auto audio_params = cJSON_GetObjectItem(root, "audio_params"); if (audio_params != NULL) { auto sample_rate = cJSON_GetObjectItem(audio_params, "sample_rate"); if (sample_rate != NULL) { server_sample_rate_ = sample_rate->valueint; } auto frame_duration = cJSON_GetObjectItem(audio_params, "frame_duration"); if (frame_duration != NULL) { server_frame_duration_ = frame_duration->valueint; } } xEventGroupSetBits(event_group_handle_, WEBSOCKET_PROTOCOL_SERVER_HELLO_EVENT); }
9.6 设备初始化示例
以esp32-cgc_board.cc
为例,展示了设备初始化过程。
esp_lcd_panel_init(panel); esp_lcd_panel_invert_color(panel, DISPLAY_INVERT_COLOR); esp_lcd_panel_swap_xy(panel, DISPLAY_SWAP_XY); esp_lcd_panel_mirror(panel, DISPLAY_MIRROR_X, DISPLAY_MIRROR_Y); display_ = new SpiLcdDisplay(panel_io, panel, DISPLAY_WIDTH, DISPLAY_HEIGHT, DISPLAY_OFFSET_X, DISPLAY_OFFSET_Y, DISPLAY_MIRROR_X, DISPLAY_MIRROR_Y, DISPLAY_SWAP_XY, { .text_font = &font_puhui_14_1, .icon_font = &font_awesome_14_1, .emoji_font = font_emoji_32_init(), }); void InitializeButtons() { boot_button_.OnClick([this]() { auto& app = Application::GetInstance(); if (app.GetDeviceState() == kDeviceStateStarting && !WifiStation::GetInstance().IsConnected()) { ResetWifiConfiguration(); } app.ToggleChatState(); }); asr_button_.OnClick([this]() { std::string wake_word="你好小智"; Application::GetInstance().WakeWordInvoke(wake_word); }); } void InitializeIot() { auto& thing_manager = iot::Thing
10. 总结
本文详细介绍了小智AI项目中WebSocket通信协议的实现细节。通过分析源代码,我们理解了设备端与服务器端之间的通信流程、消息结构以及错误处理机制。本协议通过在WebSocket上层传输JSON文本与二进制音频帧,完成功能包括音频流上传、TTS音频播放、语音识别与状态管理、IoT指令下发等。服务器与客户端需提前约定各类消息的字段含义、时序逻辑以及错误处理规则,方能保证通信顺畅。
希望本文能为开发者提供一个清晰的参考文档,便于后续对接、开发或扩展。