项目总结报告
任务介绍
- 预置3 段旋律序列,通过扬声器播放;
- 麦克风关键词识别实现播放、暂停、下一首、上一首指令的识别;
- OLED 显示曲目编号、播放进度百分比、当前音量;
项目介绍
项目基于虾哥 小智AI项目,本地ESP-SR获取音频数据,上传到服务器端,由服务器端接收并做ASR解析,再JSON格式下发到端侧,端侧对数据解析获取语音识别内容,并根据实际的语音识别内容做出相应控制。
采用ESP32_Senser作为主控,板载的数字话筒作为数据采集,获取原始的PDM数据,经过算法FAE的预处理,转换成干净的原始信号。

处理后的原始数据经过WIFI,以JSON格式上传到服务器端(此处指 虾哥-小智服务器),再由服务器端进行处理,将处理好的结果通过JSON格式发送到端侧(此处指 ESP32主控)。
ESP32主控解析对应的JSON数据,并按ASR、LLM、TTS分开处理,由于本次项目只用到了ASR功能,所以对应的只对ASR数据进行分析。建立状态机,针对不同的ASR读取结果进行对应的处理,比如:”上一首“、”下一首“、”播放“、”暂停“.
对于ASR获取的结果,对应的I2S控制MAX98357A实现旋律的播放和暂停以及切换,PDM数据保存在ESP数组中,当需要播放时,将对应的数组数据传递到MAX98357A的序列中发送出去。
对应的控制OLED的显示,基于LVGL框架搭建基础页面,再针对性的修改页面,实现ASR控制。页面主要显示:曲号、进度、音量,当“暂停”时,切换到暂停页面(显示暂停播放)。
综合ESP32主控+数字话筒、MAX98357A+喇叭、OLED实现完整的功能。
方案框图和项目设计思路

通过 数字话筒 获取PDM音频信号,经过本地的AFE处理,OPUS压缩编码并上传到服务器,由服务器对音频解析,进行ASR识别、LLM生成、TTS合成,再经过OPUS编码后的数据下发到端侧,端侧根据JSON格式数据的类型进行对应的操作,由于本项目主要需要实现ASR识别的功能,所以LLM生成和TTS合成部分的数据被直接忽略,不进行具体操作,将ASR识别处理的数据进行相应操作。
调试软件及使用的编程语言说明
编程基于 ESP-IDF 编译链框架 + CMake + vscode IDE,编程语法使用 C++ 语法,工程编译管理使用CMake语法。
其中,C++ 是一门应用广泛的静态类型、编译型、多范式编程语言,它在 C 语言的基础上扩展了面向对象(OOP)、泛型编程等核心特性,既保留了 C 语言对底层硬件的直接操控能力,又提供了更高层次的抽象能力,是编程领域的 “全能型选手”。
- 兼容C语言
- C++ 完全兼容标准 C 语言的语法和功能,你可以在 C++ 代码中直接使用 C 语言的所有特性(比如指针、结构体、函数指针等),这也是它能无缝对接大量 C 语言遗留代码的原因。
- 面向对象(OOP)核心
这是 C++ 区别于 C 的核心特性之一,主要包含三大支柱: - 封装:用
class/struct将数据和操作数据的函数封装在一起,控制访问权限(public/private/protected); - 继承:子类可以复用父类的属性和方法,支持多继承;
- 多态:通过虚函数(
virtual)实现 “一个接口,多种实现”,是面向对象的核心灵活点。
- 封装:用
- 泛型编程(STL)
- C++ 提供了标准模板库(STL),通过模板(
template)实现 “编写一次,适配任意类型”,包含容器(vector/map/list)、算法(sort/find)、迭代器等,极大提升开发效率。
- C++ 提供了标准模板库(STL),通过模板(
- 高性能与底层控制
- C++ 允许直接操作内存(指针、引用),没有虚拟机(如 Java)或解释器(如 Python)的开销,编译后生成原生机器码,因此在对性能要求极高的场景下优势明显。
CMake 是一门声明式、跨平台的构建配置语言(严格来说是 “配置脚本语言”),核心作用是帮你生成适配不同操作系统 / 编译器的构建文件(比如 Linux 下的 Makefile、Windows 下的 Visual Studio 工程、macOS 下的 Xcode 工程),让你用一套配置就能在多平台编译代码(尤其是 C/C++ 项目)。
- 跨平台核心优势
- 不用为 Windows 写
.sln、为 Linux 写Makefile、为 macOS 写xcodeproj—— 一套CMakeLists.txt走天下,CMake 会帮你适配不同平台的编译器(MSVC/GCC/Clang)、路径规则、库依赖方式。
- 不用为 Windows 写
- 声明式语法(非编程式)
- CMake 语法不侧重 “逻辑编程”,而是声明 “要做什么”(比如 “编译哪些源文件”“链接哪个库”),而非 “怎么做”(比如具体的编译命令)。语法简单,核心是 “指令(命令)+ 参数”。
- 模块化与扩展性
- 支持模块化管理大型项目(比如把不同模块拆分成多个
CMakeLists.txt),也能方便地查找系统库(find_package)、链接第三方库(target_link_libraries)。
- 支持模块化管理大型项目(比如把不同模块拆分成多个
软件流程图及关键代码介绍

if (strcmp(type->valuestring, "stt") == 0) {
auto text = cJSON_GetObjectItem(root, "text");
if (cJSON_IsString(text)) {
std::string asr_text = text->valuestring;
Schedule([this, asr_text, display]() {
if (asr_text.find("播放") != std::string::npos) {
is_playing_ = true;
ESP_LOGI(TAG, "播放旋律片段 %d", current_melody_index_ + 1);
display->ShowPlayerInfoPage(true);
display->UpdateTrackNumber(current_melody_index_ + 1);
display->UpdateProgress(0);
auto codec = Board::GetInstance().GetAudioCodec();
int volume = codec->output_volume();
display->UpdateVolume(volume);
PlayMelody(current_melody_index_);
} else if (asr_text.find("暂停") != std::string::npos) {
is_playing_ = false;
display->SetChatMessage("system", "暂停播放");
display->ShowPlayerInfoPage(false);
ESP_LOGI(TAG, "暂停播放");
} else if (asr_text.find("下一首") != std::string::npos) {
current_melody_index_ = (current_melody_index_ + 1) % 3;
is_playing_ = true;
ESP_LOGI(TAG, "切换到旋律片段 %d", current_melody_index_ + 1);
display->ShowPlayerInfoPage(true);
display->UpdateTrackNumber(current_melody_index_ + 1);
display->UpdateProgress(0);
auto codec = Board::GetInstance().GetAudioCodec();
int volume = codec->output_volume();
display->UpdateVolume(volume);
PlayMelody(current_melody_index_);
} else if (asr_text.find("上一首") != std::string::npos) {
current_melody_index_ = (current_melody_index_ - 1 + 3) % 3;
is_playing_ = true;
ESP_LOGI(TAG, "切换到旋律片段 %d", current_melody_index_ + 1);
display->ShowPlayerInfoPage(true);
display->UpdateTrackNumber(current_melody_index_ + 1);
display->UpdateProgress(0);
auto codec = Board::GetInstance().GetAudioCodec();
int volume = codec->output_volume();
display->UpdateVolume(volume);
PlayMelody(current_melody_index_);
}
});
}
识别获取的JSON格式数据中的 'stt' 类型,分析具体的识别数据,若包含控制关键词,对应的调整屏幕和播放旋律。当识别到播放,则将屏幕调整为显示播放编号,激活PCM播放,并将开始显示对应的进度;当识别到暂停时,则将屏幕调整为暂停页面,对应的停止播放;当识别到下一首时,将曲目编号切换到下一首的编号,对应的播放下一首曲目且更新进度;当识别到上一首时,将曲目编号切换到上一首的编号,对应的播放上一首曲目且更新进度。
const std::vector<int16_t> Application::melody1_ = {
// C4, D4, E4, C4, C4, D4, E4, C4, E4, F4, G4, E4, F4, G4
2093, 2349, 2637, 2093, 2093, 2349, 2637, 2093, 2637, 2794, 3136, 2637, 2794, 3136
};
const std::vector<int16_t> Application::melody2_ = {
// G4, A4, G4, F4, E4, C4, G4, A4, G4, F4, E4, C4
3136, 3520, 3136, 2794, 2637, 2093, 3136, 3520, 3136, 2794, 2637, 2093
};
const std::vector<int16_t> Application::melody3_ = {
// E4, E4, F4, G4, G4, F4, E4, D4, C4, C4, D4, E4, E4, D4, D4
2637, 2637, 2794, 3136, 3136, 2794, 2637, 2349, 2093, 2093, 2349, 2637, 2637, 2349, 2349
};
current_melody_index_ = 0;
is_playing_ = false;
void Application::PlayMelody(int melody_index) {
const std::vector<int16_t>* melody = nullptr;
switch (melody_index) {
case 0:
melody = &melody1_;
break;
case 1:
melody = &melody2_;
break;
case 2:
melody = &melody3_;
break;
default:
return;
}
std::vector<int16_t> pcm_data;
int sample_rate = 24000;
int duration_per_note = sample_rate / 4;
int total_notes = melody->size();
int total_duration = total_notes * duration_per_note;
for (int frequency : *melody) {
for (int i = 0; i < duration_per_note; i++) {
double t = (double)i / sample_rate;
double value = sin(2 * M_PI * frequency * t);
int16_t pcm_sample = static_cast<int16_t>(value * 32767 * 0.5);
pcm_data.push_back(pcm_sample);
}
}
auto& board = Board::GetInstance();
auto display = board.GetDisplay();
auto codec = board.GetAudioCodec();
const int chunk_size = sample_rate / 10;
int current_position = 0;
while (current_position < pcm_data.size()) {
int end_position = current_position + chunk_size;
if (end_position > pcm_data.size()) {
end_position = pcm_data.size();
}
std::vector<int16_t> chunk(pcm_data.begin() + current_position, pcm_data.begin() + end_position);
codec->OutputData(chunk);
int progress = (current_position * 100) / pcm_data.size();
display->UpdateProgress(progress);
current_position = end_position;
vTaskDelay(pdMS_TO_TICKS(100));
}
display->UpdateProgress(100);
}
代码是PCM播放代码,保存三段旋律数组,内容是音调对应的频率。当实际调用进行播放时,会根据具体的音调生成正弦波,并根据对应的波形数据分段I2S发送出去,对应合成旋律,过程中计算进度。
void OledDisplay::SetupPlayerInfoPage() {
DisplayLockGuard lock(this);
auto screen = lv_screen_active();
player_info_page_ = lv_obj_create(screen);
lv_obj_set_size(player_info_page_, LV_HOR_RES, LV_VER_RES);
lv_obj_set_style_bg_color(player_info_page_, lv_color_black(), 0);
lv_obj_set_style_border_width(player_info_page_, 0, 0);
lv_obj_set_style_pad_all(player_info_page_, 4, 0);
lv_obj_set_flex_flow(player_info_page_, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_flex_main_place(player_info_page_, LV_FLEX_ALIGN_CENTER, 0);
lv_obj_set_style_flex_cross_place(player_info_page_, LV_FLEX_ALIGN_CENTER, 0);
lv_obj_set_style_pad_row(player_info_page_, 8, 0);
track_label_ = lv_label_create(player_info_page_);
lv_label_set_text(track_label_, "T:1");
lv_obj_set_style_text_font(track_label_, fonts_.text_font, 0);
lv_obj_set_style_text_color(track_label_, lv_color_white(), 0);
volume_label_ = lv_label_create(player_info_page_);
lv_label_set_text(volume_label_, "V:50");
lv_obj_set_style_text_font(volume_label_, fonts_.text_font, 0);
lv_obj_set_style_text_color(volume_label_, lv_color_white(), 0);
progress_label_ = lv_label_create(player_info_page_);
lv_label_set_text(progress_label_, "0%");
lv_obj_set_style_text_font(progress_label_, fonts_.text_font, 0);
lv_obj_set_style_text_color(progress_label_, lv_color_white(), 0);
lv_obj_add_flag(player_info_page_, LV_OBJ_FLAG_HIDDEN);
}
void OledDisplay::ShowPlayerInfoPage(bool show) {
DisplayLockGuard lock(this);
if (player_info_page_ == nullptr) {
SetupPlayerInfoPage();
}
if (show) {
lv_obj_clear_flag(player_info_page_, LV_OBJ_FLAG_HIDDEN);
lv_obj_add_flag(content_, LV_OBJ_FLAG_HIDDEN);
lv_obj_add_flag(status_bar_, LV_OBJ_FLAG_HIDDEN);
show_player_info_ = true;
} else {
lv_obj_add_flag(player_info_page_, LV_OBJ_FLAG_HIDDEN);
lv_obj_clear_flag(content_, LV_OBJ_FLAG_HIDDEN);
lv_obj_clear_flag(status_bar_, LV_OBJ_FLAG_HIDDEN);
show_player_info_ = false;
}
}
void OledDisplay::UpdateTrackNumber(int track) {
DisplayLockGuard lock(this);
if (track_label_ == nullptr) {
return;
}
current_track_ = track;
char buffer[16];
snprintf(buffer, sizeof(buffer), "T:%d", track);
lv_label_set_text(track_label_, buffer);
}
void OledDisplay::UpdateProgress(int percent) {
DisplayLockGuard lock(this);
if (progress_label_ == nullptr) {
return;
}
progress_percent_ = percent;
char buffer[8];
snprintf(buffer, sizeof(buffer), "%d%%", percent);
lv_label_set_text(progress_label_, buffer);
}
void OledDisplay::UpdateVolume(int volume) {
DisplayLockGuard lock(this);
if (volume_label_ == nullptr) {
return;
}
current_volume_ = volume;
char buffer[8];
snprintf(buffer, sizeof(buffer), "V:%d", volume);
lv_label_set_text(volume_label_, buffer);
}
代码是屏幕显示代码,使用LVGL框架,三行显示,第一行显示曲目编号,第二行显示音量,第三行显示进度。更新曲目编号、音量、进度方法分别针对第一行、第二行、第三行进行对应更新,刷新页面。
功能展示图及说明

信息显示:第一行是编号,第二行是音量,第三行是进度。当接受到ASR指令时会对应更改页面显示内容,实现语音反馈,并播放。

当检测到停止指令时,页面显示暂停播放,等待语音新的唤醒。

当语音唤醒后,检测到播放指令时,页面开始显示进度及曲目并对应开始播放。

当检测到下一首指令时,切换到下一首,并显示曲目且对应播放和计算进度。
一共三首旋律,可以最多切换到曲目三

当检测到上一首指令时,切换到上一首,并显示曲目且对应播放和计算进度。
项目遇到的难题及解决方法
项目中对于PCM播放出现AFE堵塞,导致喂狗不及时。采用PCM数据直接分段式传输,留出时间给喂狗操作,从而避免喂狗不及时带来的危害。
心得体会
对于本次项目的开发过程中,也是一次对MCP协议及LLM框架的深度理解,也深刻的熟悉了ESP32完整编译链体系,对ESP32的开发有个大概的理解,加强了我对基于MCP协议搭建的服务器端ASR的理解。熟悉了FreeRTOS框架及大模型识别的理解。