最新一个名叫 Xiaozhi 的 ESP32 项目很火,几十块的成本能做到出一个和大模型语音交互的设备,还是很有意思的,比某猫精灵智能太多。虽然它的服务端没有开源,但也可以猜到一二。所以我只好先从客户端源码分析,理解了之后,可以去实现一个自己的服务端,做到灵活定制。
一、引言
在当今物联网和人工智能飞速发展的时代,智能聊天机器人成为了热门的应用领域。“小智 AI 聊天机器人”项目基于 ESP32 平台,实现了语音交互、固件升级、设备状态管理等功能,具有很高的实用性和技术价值。本文将详细分析该项目代码的执行流程,帮助开发者更好地理解和掌握项目的核心逻辑。
二、项目整体架构概述
该项目使用c++语言进行esp32单片机程序开发。采用模块化设计,借助 ESP - IDF 框架内置的freeRTOS内核实现多线程处理,提高了系统的稳定性和响应性能。主要模块包括主程序模块、应用程序模块、后台任务模块、语音识别唤醒模块、固件升级模块等。各模块之间相互协作,共同完成聊天机器人的各项功能。
开源项目地址:https://github.com/78/xiaozhi-esp32
后台服务: https://github.com/xinnan-tech/xiaozhi-esp32-server
文档地址: https://ccnphfhqs21z.feishu.cn/wiki/F5krwD16viZoF0kKkvDcrZNYnhb
三、代码执行流程详细分析
3.1 项目初始化
项目的初始化工作在 main.cc
文件中完成,主要步骤如下:
- 事件循环初始化:调用
esp_event_loop_create_default()
初始化默认事件循环,为后续的事件处理提供基础。 - NVS 闪存初始化:使用
nvs_flash_init()
初始化 NVS 闪存,用于存储 WiFi 配置。若出现错误,如ESP_ERR_NVS_NO_FREE_PAGES
或ESP_ERR_NVS_NEW_VERSION_FOUND
,则擦除并重试。 - 应用程序启动:调用
Application::GetInstance().Start()
启动应用程序。
// xiaozhi-esp32\main\main.cc
extern "C" void app_main(void)
{
// Initialize the default event loop
ESP_ERROR_CHECK(esp_event_loop_create_default());
// Initialize NVS flash for WiFi configuration
esp_err_t ret = nvs_flash_init();
if (ret == ESP_ERR_NVS_NO_FREE_PAGES || ret == ESP_ERR_NVS_NEW_VERSION_FOUND) {
ESP_LOGW(TAG, "Erasing NVS flash to fix corruption");
ESP_ERROR_CHECK(nvs_flash_erase());
ret = nvs_flash_init();
}
ESP_ERROR_CHECK(ret);
// Launch the application
Application::GetInstance().Start();
// The main thread will exit and release the stack memory
}
3.2 应用程序启动
Application::Start()
方法是应用程序启动的核心,具体步骤如下:
- 设备状态设置:将设备状态设置为启动中,调用
SetDeviceState(kDeviceStateStarting)
。 - 显示屏和音频编解码器初始化:获取显示屏和音频编解码器实例,并进行相关配置,如初始化 Opus 编解码器和重采样器。
- 音频编解码器启动:启动音频编解码器,并设置输入输出就绪事件回调函数。
- 主循环任务创建:使用
xTaskCreate
创建主循环任务,调用Application::MainLoop()
方法。 - 网络启动:调用
board.StartNetwork()
启动网络。 - 协议初始化:根据配置选择 MQTT 或 WebSocket 协议进行初始化。
// xiaozhi-esp32\main\application.cc
void Application::Start() {
auto& board = Board::GetInstance();
SetDeviceState(kDeviceStateStarting);
/* Setup the display */
auto display = board.GetDisplay();
/* Setup the audio codec */
auto codec = board.GetAudioCodec();
opus_decode_sample_rate_ = codec->output_sample_rate();
opus_decoder_ = std::make_unique<OpusDecoderWrapper>(opus_decode_sample_rate_, 1);
opus_encoder_ = std::make_unique<OpusEncoderWrapper>(16000, 1, OPUS_FRAME_DURATION_MS);
// For ML307 boards, we use complexity 5 to save bandwidth
// For other boards, we use complexity 3 to save CPU
if (board.GetBoardType() == "ml307") {
ESP_LOGI(TAG, "ML307 board detected, setting opus encoder complexity to 5");
opus_encoder_->SetComplexity(5);
} else {
ESP_LOGI(TAG, "WiFi board detected, setting opus encoder complexity to 3");
opus_encoder_->SetComplexity(3);
}
if (codec->input_sample_rate() != 16000) {
input_resampler_.Configure(codec->input_sample_rate(), 16000);
reference_resampler_.Configure(codec->input_sample_rate(), 16000);
}
codec->OnInputReady([this, codec]() {
BaseType_t higher_priority_task_woken = pdFALSE;
xEventGroupSetBitsFromISR(event_group_, AUDIO_INPUT_READY_EVENT, &higher_priority_task_woken);
return higher_priority_task_woken == pdTRUE;
});
codec->OnOutputReady([this]() {
BaseType_t higher_priority_task_woken = pdFALSE;
xEventGroupSetBitsFromISR(event_group_, AUDIO_OUTPUT_READY_EVENT, &higher_priority_task_woken);
return higher_priority_task_woken == pdTRUE;
});
codec->Start();
/* Start the main loop */
xTaskCreate([](void* arg) {
Application* app = (Application*)arg;
app->MainLoop();
vTaskDelete(NULL);
}, "main_loop", 4096 * 2, this, 4, nullptr);
/* Wait for the network to be ready */
board.StartNetwork();
// Initialize the protocol
display->SetStatus(Lang::Strings::LOADING_PROTOCOL);
#ifdef CONFIG_CONNECTION_TYPE_WEBSOCKET
protocol_ = std::make_unique<WebsocketProtocol>();
#else
protocol_ = std::make_unique<MqttProtocol>();
#endif
protocol_->OnNetworkError([this](const std::string& message) {
SetDeviceState(kDeviceStateIdle);
Alert(Lang::Strings::ERROR, message.c_str(), "sad", Lang::Sounds::P3_EXCLAMATION);
});
protocol_->OnIncomingAudio([this](std::vector<uint8_t>&& data) {
std::lock_guard<std::mutex> lock(mutex_);
if (device_state_ == kDeviceStateSpeaking) {
// ...
}
});
}
3.3 主循环执行
Application::MainLoop()
方法是应用程序的主循环,不断等待事件并处理:
- 事件等待:使用
xEventGroupWaitBits
等待调度事件、音频输入就绪事件、音频输出就绪事件。 - 事件处理:根据不同的事件调用相应的处理函数,如
InputAudio()
、OutputAudio()
或执行调度任务。
// xiaozhi-esp32\main\application.cc
void Application::MainLoop() {
while (true) {
auto bits = xEventGroupWaitBits(event_group_,
SCHEDULE_EVENT | AUDIO_INPUT_READY_EVENT | AUDIO_OUTPUT_READY_EVENT,
pdTRUE, pdFALSE, portMAX_DELAY);
if (bits & AUDIO_INPUT_READY_EVENT) {
InputAudio();
}
if (bits & AUDIO_OUTPUT_READY_EVENT) {
OutputAudio();
}
if (bits & SCHEDULE_EVENT) {
std::unique_lock<std::mutex> lock(mutex_);
std::list<std::function<void()>> tasks = std::move(main_tasks_);
lock.unlock();
for (auto& task : tasks) {
task();
}
}
}
}
在 application.cc
文件中,Application::Schedule
方法是一个关键的调度机制,它允许将任务放入队列中,然后在主循环中依次执行。
下面详细解释该机制的实现:
代码片段
// xiaozhi-esp32\main\application.cc
void Application::Schedule(std::function<void()> callback) {
{
std::lock_guard<std::mutex> lock(mutex_);
main_tasks_.push_back(std::move(callback));
}
xEventGroupSetBits(event_group_, SCHEDULE_EVENT);
}
实现步骤解释
-
参数:
std::function<void()>
类型的callback
参数,这是一个通用的可调用对象,代表一个任务。可以是 lambda 表达式、函数指针或其他可调用对象。
-
加锁保护任务队列:
std::lock_guard<std::mutex> lock(mutex_);
:使用std::lock_guard
来自动管理mutex_
的锁定和解锁。在多线程环境中,mutex_
用于保护main_tasks_
队列,防止多个线程同时访问和修改队列,确保线程安全。
-
添加任务到队列:
main_tasks_.push_back(std::move(callback));
:将传入的callback
任务添加到main_tasks_
队列的末尾。std::move
用于将callback
的所有权转移到队列中,避免不必要的复制。
-
通知主循环有新任务:
xEventGroupSetBits(event_group_, SCHEDULE_EVENT);
:使用 FreeRTOS 的事件组机制,通过xEventGroupSetBits
函数设置event_group_
中的SCHEDULE_EVENT
位。这会通知主循环有新的任务需要执行。
主循环中的任务处理
在 Application::MainLoop
方法中,会等待 SCHEDULE_EVENT
事件,当事件发生时,会从 main_tasks_
队列中取出所有任务并依次执行:
// xiaozhi-esp32\main\application.cc
void Application::MainLoop() {
while (true) {
auto bits = xEventGroupWaitBits(event_group_,
SCHEDULE_EVENT | AUDIO_INPUT_READY_EVENT | AUDIO_OUTPUT_READY_EVENT,
pdTRUE, pdFALSE, portMAX_DELAY);
if (bits & SCHEDULE_EVENT) {
std::unique_lock<std::mutex> lock(mutex_);
std::list<std::function<void()>> tasks = std::move(main_tasks_);
lock.unlock();
for (auto& task : tasks) {
task();
}
}
// 处理其他事件...
}
}
Application::Schedule
机制的核心是将任务放入队列,并通过事件组通知主循环有新任务。主循环会在合适的时机取出任务并执行,从而实现任务的异步调度。这种机制确保了任务的顺序执行,同时避免了多线程环境下的竞态条件。
3.4 语音识别唤醒
语音识别唤醒功能主要通过 WakeWordDetect
类实现,具体步骤如下:
- 初始化:在
Application::Start
中调用WakeWordDetect::Initialize
方法,初始化音频前端配置,加载唤醒词模型,创建音频检测任务。 - 音频数据输入:在
Application::InputAudio
中,将音频数据输入到WakeWordDetect
类的Feed
方法。 - 音频检测:
WakeWordDetect::AudioDetectionTask
持续从 AFE 中获取数据,检测到唤醒词后停止检测并调用回调函数。 - 唤醒词编码与发送:调用
WakeWordDetect::EncodeWakeWordData
对唤醒词数据进行编码,并通过协议发送到服务器。
3.5 固件升级检查
Application::CheckNewVersion()
方法用于检查是否有新的固件版本可用,步骤如下:
- 版本检查:调用
ota_.CheckVersion()
检查版本。 - 新版本处理:若有新的版本,提示用户并等待设备空闲,启动升级过程,更新显示屏信息并停止相关任务。
- 升级失败处理:若升级失败,显示错误信息并重启设备。
// xiaozhi-esp32\main\application.cc
void Application::CheckNewVersion() {
auto& board = Board::GetInstance();
auto display = board.GetDisplay();
// Check if there is a new firmware version available
ota_.SetPostData(board.GetJson());
const int MAX_RETRY = 10;
int retry_count = 0;
while (true) {
if (!ota_.CheckVersion()) {
retry_count++;
if (retry_count >= MAX_RETRY) {
ESP_LOGE(TAG, "Too many retries, exit version check");
return;
}
ESP_LOGW(TAG, "Check new version failed, retry in %d seconds (%d/%d)", 60, retry_count, MAX_RETRY);
vTaskDelay(pdMS_TO_TICKS(60000));
continue;
}
retry_count = 0;
if (ota_.HasNewVersion()) {
Alert(Lang::Strings::OTA_UPGRADE, Lang::Strings::UPGRADING, "happy", Lang::Sounds::P3_UPGRADE);
// Wait for the chat state to be idle
do {
vTaskDelay(pdMS_TO_TICKS(3000));
} while (GetDeviceState() != kDeviceStateIdle);
// Use main task to do the upgrade, not cancelable
Schedule([this, display]() {
SetDeviceState(kDeviceStateUpgrading);
display->SetIcon(FONT_AWESOME_DOWNLOAD);
std::string message = std::string(Lang::Strings::NEW_VERSION) + ota_.GetFirmwareVersion();
display->SetChatMessage("system", message.c_str());
auto& board = Board::GetInstance();
board.SetPowerSaveMode(false);
#if CONFIG_USE_WAKE_WORD_DETECT
wake_word_detect_.StopDetection();
#endif
// 预先关闭音频输出,避免升级过程有音频操作
auto codec = board.GetAudioCodec();
codec->EnableInput(false);
codec->EnableOutput(false);
{
std::lock_guard<std::mutex> lock(mutex_);
audio_decode_queue_.clear();
}
background_task_->WaitForCompletion();
delete background_task_;
background_task_ = nullptr;
vTaskDelay(pdMS_TO_TICKS(1000));
ota_.StartUpgrade([display](int progress, size_t speed) {
char buffer[64];
snprintf(buffer, sizeof(buffer), "%d%% %zuKB/s", progress, speed / 1024);
display->SetChatMessage("system", buffer);
});
// If upgrade success, the device will reboot and never reach here
display->SetStatus(Lang::Strings::UPGRADE_FAILED);
ESP_LOGI(TAG, "Firmware upgrade failed...");
vTaskDelay(pdMS_TO_TICKS(3000));
Reboot();
});
return;
}
// No new version, mark the current version as valid
ota_.MarkCurrentVersionValid();
std::string message = std::string(Lang::Strings::VERSION) + ota_.GetCurrentVersion();
display->ShowNotification(message.c_str());
if (ota_.HasActivationCode()) {
// Activation code is valid
SetDeviceState(kDeviceStateActivating);
ShowActivationCode();
// Check again in 60 seconds or until the device is idle
for (int i = 0; i < 60; ++i) {
if (device_state_ == kDeviceStateIdle) {
break;
}
vTaskDelay(pdMS_TO_TICKS(1000));
}
continue;
}
SetDeviceState(kDeviceStateIdle);
display->SetChatMessage("system", "");
PlaySound(Lang::Sounds::P3_SUCCESS);
// Exit the loop if upgrade or idle
break;
}
}
3.6 后台任务处理
BackgroundTask
类负责处理后台任务,具体如下:
- 任务创建:在构造函数中使用
xTaskCreate
创建后台任务,调用BackgroundTask::BackgroundTaskLoop
方法。 - 任务调度:
BackgroundTask::Schedule
方法用于添加任务到任务列表,并通知后台任务有新任务。 - 任务完成等待:
BackgroundTask::WaitForCompletion
方法用于等待所有任务完成。
// xiaozhi-esp32\main\background_task.cc
BackgroundTask::BackgroundTask(uint32_t stack_size) {
xTaskCreate([](void* arg) {
BackgroundTask* task = (BackgroundTask*)arg;
task->BackgroundTaskLoop();
}, "background_task", stack_size, this, 2, &background_task_handle_);
}
void BackgroundTask::Schedule(std::function<void()> callback) {
std::lock_guard<std::mutex> lock(mutex_);
if (active_tasks_ >= 30) {
int free_sram = heap_caps_get_free_size(MALLOC_CAP_INTERNAL);
if (free_sram < 10000) {
ESP_LOGW(TAG, "active_tasks_ == %u, free_sram == %u", active_tasks_.load(), free_sram);
}
}
active_tasks_++;
main_tasks_.emplace_back([this, cb = std::move(callback)]() {
cb();
{
std::lock_guard<std::mutex> lock(mutex_);
active_tasks_--;
if (main_tasks_.empty() && active_tasks_ == 0) {
condition_variable_.notify_all();
}
}
});
condition_variable_.notify_all();
}
void BackgroundTask::WaitForCompletion() {
std::unique_lock<std::mutex> lock(mutex_);
condition_variable_.wait(lock, [this]() {
return main_tasks_.empty() && active_tasks_ == 0;
});
}
void BackgroundTask::BackgroundTaskLoop() {
ESP_LOGI(TAG, "background_task started");
while (true) {
std::unique_lock<std::mutex> lock(mutex_);
condition_variable_.wait(lock, [this]() {
return !main_tasks_.empty(); });
std::list<std::function<void()>> tasks = std::move(main_tasks_);
lock.unlock();
for (auto& task : tasks) {
task();
}
}
}
参数 arg 的含义
在 xTaskCreate 函数的 lambda 表达式中,参数 arg 是传递给任务入口函数的参数。在这个具体的代码中, arg 被设置为 this ,也就是当前 BackgroundTask 对象的指针。通过将 arg 转换为 BackgroundTask* 类型,就能够在任务入口函数里访问当前 BackgroundTask 对象的成员函数和成员变量。
在这个任务入口函数中, arg 被用来调用 BackgroundTask 类的 BackgroundTaskLoop 方法,从而启动后台任务的循环。这样做的目的是让新创建的任务能够在后台持续运行,处理调度的任务。
在当前的 BackgroundTask 类构造函数代码里, arg 之所以是 this 指针,是为了在创建的 FreeRTOS 任务中能够访问当前 BackgroundTask 类实例的成员函数和成员变量。
注意这里的arg ,为什么就是 this?
在 C++ 里,类的成员函数的确存在一个隐藏的 this
指针。下面详细介绍 this
指针的相关内容:
1. this
指针的定义
在 C++ 类的非静态成员函数内部,this
指针是一个隐含的指针,它指向调用该成员函数的对象实例。通过 this
指针,成员函数可以访问调用它的对象的成员变量和其他成员函数。
2. this
指针的作用
- 区分成员变量和局部变量:当成员函数的参数名和成员变量名相同时,可以使用
this
指针来明确引用成员变量。 - 返回对象自身:在链式调用的场景中,成员函数可以返回
*this
,以便连续调用该对象的其他成员函数。 - 在成员函数中获取对象的地址:可以使用
this
指针来获取调用该成员函数的对象的内存地址。
3. 示例代码
#include <iostream>
class MyClass {
private:
int value;
public:
// 构造函数,使用 this 指针区分成员变量和参数
MyClass(int value) {
this->value = value;
}
// 成员函数,返回对象自身,支持链式调用
MyClass& add(int num) {
this->value += num;
return *this;
}
// 成员函数,打印对象的地址和值
void printInfo() {
std::cout << "Address of this object: " << this << std::endl;
std::cout << "Value of this object: " << this->value << std::endl;
}
};
int main() {
MyClass obj(10);
obj.add(5).add(3);
obj.printInfo();
return 0;
}
4. 代码解释
- 构造函数
MyClass(int value)
:这里使用this->value
来明确赋值给成员变量value
,避免与参数value
混淆。 - 成员函数
add(int num)
:函数返回*this
,这意味着返回调用该函数的对象本身,从而支持链式调用,例如obj.add(5).add(3)
。 - 成员函数
printInfo()
:使用this
指针输出对象的内存地址,同时使用this->value
输出对象的成员变量值。
5. 注意事项
- 静态成员函数:静态成员函数不属于任何对象实例,因此没有
this
指针。 - 友元函数:友元函数不是类的成员函数,所以也没有
this
指针。
综上所述,this
指针是 C++ 类成员函数中一个非常有用的特性,它为成员函数提供了对调用对象的直接访问能力。
注意,application.cc` 中有调度机制和BackgroundTask中的调度机制,有什么区别呢?
Application::Application() {
event_group_ = xEventGroupCreate();
background_task_ = new BackgroundTask(4096 * 8);
esp_timer_create_args_t clock_timer_args = {
.callback = [](void* arg) {
Application* app = (Application*)arg;
app->OnClockTimer();
},
.arg = this,
.dispatch_method = ESP_TIMER_TASK,
.name = "clock_timer",
.skip_unhandled_events = true
};
esp_timer_create(&clock_timer_args, &clock_timer_handle_);
}
Application::~Application() {
if (clock_timer_handle_ != nullptr) {
esp_timer_stop(clock_timer_handle_);
esp_timer_delete(clock_timer_handle_);
}
if (background_task_ != nullptr) {
delete background_task_;
}
vEventGroupDelete(event_group_);
}
在 application.cc
里的调度机制和 background_task.cc
中的 BackgroundTask
类都有调度任务的功能,但它们的用途和设计目标有所不同:
application.cc
中的调度机制
application.cc
里的调度机制主要是服务于应用程序的主循环,它的调度任务一般和应用程序的核心逻辑紧密相关,像处理音频输入输出、网络事件等。这些任务一般在主循环里执行,并且会和其他的事件处理逻辑(例如音频输入输出事件)协同工作。
BackgroundTask
类的作用
BackgroundTask
类的主要目的是创建一个独立的后台任务,这个任务专门用来异步执行调度的任务。它的存在有以下几个好处:
1. 隔离任务执行
BackgroundTask
把任务的执行和主循环隔离开来,这样主循环就能专注于处理核心事件,避免因为执行耗时任务而阻塞。比如,在主循环里处理音频输入输出时,若有其他耗时任务(像文件操作、复杂计算),就可以把这些任务交给 BackgroundTask
去执行,防止影响音频处理的实时性。
2. 资源管理
BackgroundTask
类可以对任务进行管理,包含任务的调度、执行和完成状态的监控。通过 Schedule
方法,能够把任务添加到任务队列,WaitForCompletion
方法可以等待所有任务完成。这样可以有效管理资源,避免资源耗尽。
3. 线程安全
BackgroundTask
类使用了 std::mutex
和 std::condition_variable
来保证线程安全,防止多个线程同时访问任务队列时出现数据竞争问题。
示例代码说明
下面是 BackgroundTask
类的关键代码:
// xiaozhi-esp32\main\background_task.cc
// 创建后台任务
BackgroundTask::BackgroundTask(uint32_t stack_size) {
xTaskCreate([](void* arg) {
BackgroundTask* task = (BackgroundTask*)arg;
task->BackgroundTaskLoop();
}, "background_task", stack_size, this, 2, &background_task_handle_);
}
// 调度任务
void BackgroundTask::Schedule(std::function<void()> callback) {
std::lock_guard<std::mutex> lock(mutex_);
if (active_tasks_ >= 30) {
int free_sram = heap_caps_get_free_size(MALLOC_CAP_INTERNAL);
if (free_sram < 10000) {
ESP_LOGW(TAG, "active_tasks_ == %u, free_sram == %u", active_tasks_.load(), free_sram);
}
}
active_tasks_++;
main_tasks_.emplace_back([this, cb = std::move(callback)]() {
cb();
{
std::lock_guard<std::mutex> lock(mutex_);
active_tasks_--;
if (main_tasks_.empty() && active_tasks_ == 0) {
condition_variable_.notify_all();
}
}
});
condition_variable_.notify_all();
}
// 等待所有任务完成
void BackgroundTask::WaitForCompletion() {
std::unique_lock<std::mutex> lock(mutex_);
condition_variable_.wait(lock, [this]() {
return main_tasks_.empty() && active_tasks_ == 0;
});
}
// 后台任务循环
void BackgroundTask::BackgroundTaskLoop() {
ESP_LOGI(TAG, "background_task started");
while (true) {
std::unique_lock<std::mutex> lock(mutex_);
condition_variable_.wait(lock, [this]() {
return !main_tasks_.empty(); });
std::list<std::function<void()>> tasks = std::move(main_tasks_);
lock.unlock();
for (auto& task : tasks) {
task();
}
}
}
application.cc
里的调度机制主要处理应用程序的核心逻辑和事件,而 BackgroundTask
类则是为了创建一个独立的后台任务,用来异步执行调度的任务,以实现任务隔离、资源管理和线程安全。
四、总结
通过对“小智 AI 聊天机器人”项目代码执行流程的详细分析,我们可以看到该项目通过模块化设计和多线程处理,实现了语音交互、固件升级、设备状态管理等功能。各个模块之间相互协作,形成了一个完整的系统。开发者可以根据项目的架构和执行流程,对项目进行扩展和优化,以满足不同的应用需求。同时,该项目也为其他类似的物联网和人工智能项目提供了很好的参考和借鉴。
其他资源
Xiaozhi-esp32 代码和协议解读https://zhuanlan.zhihu.com/p/20801696176