2026寒假练 - 基于人工智能硬件实验套件平台的语音点歌音乐盒
该项目使用了人工智能硬件实验套件平台,实现了语音点歌音乐盒的设计,它的主要功能为:1.预置3 段旋律序列,通过扬声器播放;2.麦克风关键词识别实现播放、暂停、下一首、上一首指令的识别;3.OLED 显示曲目编号、播放进度百分比、当前音量;。
标签
ESP32
人工智能
语音识别
寒假练
汽车抓狂人
更新2026-03-27
20

项目总结报告

任务介绍

  1. 预置3 段旋律序列,通过扬声器播放;
  2. 麦克风关键词识别实现播放、暂停、下一首、上一首指令的识别;
  3. OLED 显示曲目编号、播放进度百分比、当前音量;

项目介绍

项目基于虾哥 小智AI项目,本地ESP-SR获取音频数据,上传到服务器端,由服务器端接收并做ASR解析,再JSON格式下发到端侧,端侧对数据解析获取语音识别内容,并根据实际的语音识别内容做出相应控制。

采用ESP32_Senser作为主控,板载的数字话筒作为数据采集,获取原始的PDM数据,经过算法FAE的预处理,转换成干净的原始信号。

front-indication.png

处理后的原始数据经过WIFI,以JSON格式上传到服务器端(此处指 虾哥-小智服务器),再由服务器端进行处理,将处理好的结果通过JSON格式发送到端侧(此处指 ESP32主控)。

ESP32主控解析对应的JSON数据,并按ASR、LLM、TTS分开处理,由于本次项目只用到了ASR功能,所以对应的只对ASR数据进行分析。建立状态机,针对不同的ASR读取结果进行对应的处理,比如:”上一首“、”下一首“、”播放“、”暂停“.

对于ASR获取的结果,对应的I2S控制MAX98357A实现旋律的播放和暂停以及切换,PDM数据保存在ESP数组中,当需要播放时,将对应的数组数据传递到MAX98357A的序列中发送出去。

对应的控制OLED的显示,基于LVGL框架搭建基础页面,再针对性的修改页面,实现ASR控制。页面主要显示:曲号、进度、音量,当“暂停”时,切换到暂停页面(显示暂停播放)。

综合ESP32主控+数字话筒、MAX98357A+喇叭、OLED实现完整的功能。

方案框图和项目设计思路


image-20260306091347876.png

通过 数字话筒 获取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++ 允许直接操作内存(指针、引用),没有虚拟机(如 Java)或解释器(如 Python)的开销,编译后生成原生机器码,因此在对性能要求极高的场景下优势明显。

CMake 是一门声明式、跨平台的构建配置语言(严格来说是 “配置脚本语言”),核心作用是帮你生成适配不同操作系统 / 编译器的构建文件(比如 Linux 下的 Makefile、Windows 下的 Visual Studio 工程、macOS 下的 Xcode 工程),让你用一套配置就能在多平台编译代码(尤其是 C/C++ 项目)。

  • 跨平台核心优势
    • 不用为 Windows 写 .sln、为 Linux 写 Makefile、为 macOS 写 xcodeproj —— 一套 CMakeLists.txt 走天下,CMake 会帮你适配不同平台的编译器(MSVC/GCC/Clang)、路径规则、库依赖方式。
  • 声明式语法(非编程式)
    • CMake 语法不侧重 “逻辑编程”,而是声明 “要做什么”(比如 “编译哪些源文件”“链接哪个库”),而非 “怎么做”(比如具体的编译命令)。语法简单,核心是 “指令(命令)+ 参数”。
  • 模块化与扩展性
    • 支持模块化管理大型项目(比如把不同模块拆分成多个 CMakeLists.txt),也能方便地查找系统库(find_package)、链接第三方库(target_link_libraries)。

软件流程图及关键代码介绍

生成并增强图片.png

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框架,三行显示,第一行显示曲目编号,第二行显示音量,第三行显示进度。更新曲目编号、音量、进度方法分别针对第一行、第二行、第三行进行对应更新,刷新页面。

功能展示图及说明

image.png

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

image.png

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

image.png

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


image.png

当检测到下一首指令时,切换到下一首,并显示曲目且对应播放和计算进度。image.png

一共三首旋律,可以最多切换到曲目三

image.png

当检测到上一首指令时,切换到上一首,并显示曲目且对应播放和计算进度。

项目遇到的难题及解决方法

项目中对于PCM播放出现AFE堵塞,导致喂狗不及时。采用PCM数据直接分段式传输,留出时间给喂狗操作,从而避免喂狗不及时带来的危害。

心得体会

对于本次项目的开发过程中,也是一次对MCP协议及LLM框架的深度理解,也深刻的熟悉了ESP32完整编译链体系,对ESP32的开发有个大概的理解,加强了我对基于MCP协议搭建的服务器端ASR的理解。熟悉了FreeRTOS框架及大模型识别的理解。

附件下载
PRJ.rar
源码
团队介绍
团队由我个人组成,负责程序编写和全部的硬件搭建以及调试
评论
0 / 100
查看更多
硬禾服务号
关注最新动态
0512-67862536
info@eetree.cn
江苏省苏州市苏州工业园区新平街388号腾飞创新园A2幢815室
苏州硬禾信息科技有限公司
Copyright © 2024 苏州硬禾信息科技有限公司 All Rights Reserved 苏ICP备19040198号