2026寒假练 - 基于ESP32S3的语音点歌音乐盒
该项目使用了edge impulse,实现了麦克风语音识别的设计,它的主要功能为:从麦克风捕获语音,对语音进行关键词识别,进而控制mp3播放、暂停、切歌。 该项目使用了ESP32-audioI2S-master库,实现了播放mp3的设计,它的主要功能为:从SD卡上打开mp3文件并解码、功放,由扬声器播放。 该项目使用了U8g2库,实现了OLED显示中英文的设计,它的主要功能为:显示mp3播放信息:曲目信息、播放进度、音量。
标签
嵌入式系统
开发板
cc1989summer
更新2026-03-24
19

- 所选任务介绍

   - 项目介绍


本次我使用了XIAO ESP32 Sense主控板(搭载ESP32S3、麦克风、SD卡槽)以及对应的底板(搭载SD卡),外加MAX98357和扬声器,实现语音点歌音乐盒:说出上一曲或下一曲时,mp3播放器会自动切换。

本次没有使用原版小车底座,主要是因为MAX98357和SD卡引脚有冲突,同时换成了自备的8Ω 0.5W扬声器,以提升音质效果。

使用开发工具为Arduino IDE,主要软件为Edge Impulse模型训练及部署平台,ESP32-audioI2S-master mp3播放库,以及U8g2 OLED驱动库。


   - 简短的所有使用到的硬件介绍



主要使用了麦克风、SD卡、OLED屏幕、I2S音频功放、扬声器,对应的引脚已在图中标示出。


   - 方案框图和项目设计思路介绍

设计思路主要围绕2条主线

主线1:mp3播放及显示,使用了ESP32-audioI2S-master mp3播放库,以及U8g2 OLED驱动库。

为此前期专门编写了mp3播放程序及OLED显示程序。


主线2:语音识别及控制切歌,使用了Edge Impulse模型训练及部署平台。

具体方法参考了seeed官网中的方法。


   - 调试软件及使用的编程语言说明、软件流程图及关键代码介绍


调试软件及使用的编程语言:Arduino IDE

所需ESP32开发板软件版本

ESP32 3.3.7(这个版本对各类库的兼容性最好)

所需arduino

ESP32-audioI2S-master 3.4.4(播放SD卡中mp3

U8g2 2.35.30(驱动OLED中英文显示)


软件流程图:

image.png

image.png


关键代码介绍

首先是edge impulse的素材采集、模型训练及部署,这部分参考了seeed官方的例程,详见:

https://wiki.seeedstudio.com/cn/tinyml_course_Key_Word_Spotting/

为了训练模型,建立了许多个edge impulse项目:image.png


这里需要重点说明下:

在将素材分割成1s一个的语音片段时。

默认的自动切歌如图:

image.png


看起来非常工整,但经过多次测试,这种的识别率非常糟糕。

原因在于说话的时机,因为麦克风在采集时,你不一定是按上图所示去说话,也就是说不可能他在采集时,刚好过了一小会(比如200ms)你刚好开始说话,一旦不是这种就无法识别。


解决方案,在模型采集阶段,考虑各类说话时机:

1)刚开始采集就说话

2)采集开始约200ms开始说话(关键词刚好落在正中间,如上图)

3) 采集开始约500ms开始说话,采集结束,关键词也结束


优化后的切割方式如下图

大概5~6个方格,一上来就说话(语音在方格最左边)

大概5~6个方格,等片刻(约200ms)才说话(语音在方格最中间)

大概5~6个方格,刚结束就说完(语音在方格最右边)

image.png


这样训练出来的程序,识别率会高一些。


mp3关键代码(纯播放mp3,不语音识别,不显示在OLED,可由串口控制切歌):

使用MAX98357模块,接线图如下:

电源3.3V,SD和GAIN可以悬空不接(实测可行)。

image.png


注意要安装ESP32-audioI2S-master 3.4.4库,ESP32是 3.3.7版本。

另外,mp3必须是128kbps,文件名不能是中文。

image.png

#include <Arduino.h>
#include <SPI.h>
#include <SD.h>
#include <Audio.h>
// ------------------- 引脚定义 -------------------
// SD卡引脚(自定义SPI)
#define SD_CS    21
#define SD_SCK   7
#define SD_MOSI  9
#define SD_MISO  8
// MAX98357A I2S引脚
#define I2S_LRC  2
#define I2S_BCLK 3
#define I2S_DOUT 4


// ------------------- 播放控制枚举(预留控制参数) -------------------
enum PlayControl {
  PLAY,    // 播放
  PAUSE,   // 暂停
  PREV,    // 上一曲
  NEXT     // 下一曲
};


// ------------------- 音频对象 -------------------
Audio audio;
// ------------------- 自定义SPI初始化 -------------------
SPIClass sd_spi(FSPI); // 使用FSPI总线(ESP32-S3的SPI2)
// ------------------- 新增:播放列表与索引 -------------------
const char* playList[] = {"/music1.mp3", "/music2.mp3", "/music3.mp3"}; // 顺序播放列表
int currentIndex = 0; // 当前播放歌曲的索引,从0开始
int totalSongs = sizeof(playList) / sizeof(playList[0]); // 自动计算歌曲总数,便于后续扩展
// ------------------- 函数声明:播放指定索引的歌曲 -------------------
void playSong(int index);
void controlPlay(PlayControl cmd);


// ------------------- 核心控制函数 -------------------
// 执行播放控制指令
void controlPlay(PlayControl cmd) {
  switch(cmd) {  
    case PREV:
      Serial.println("⏮️ 上一曲");
      currentIndex = (currentIndex - 1 + 3) % 3; // 循环索引
      playSong(currentIndex);
      break;
     
    case NEXT:
      Serial.println("⏭️ 下一曲");
      currentIndex = (currentIndex + 1) % 3; // 循环索引
      playSong(currentIndex);
      break;
  }
}



void setup() {
  // 初始化串口(调试用,波特率115200)
  Serial.begin(115200);
  delay(1000); // 等待串口和硬件稳定
  Serial.println("=== ESP32-S3 MP3播放器初始化 ===");
  // 1. 初始化自定义SPI总线(SD卡)
  sd_spi.begin(SD_SCK, SD_MISO, SD_MOSI, SD_CS); // SCK, MISO, MOSI, CS
  Serial.print("📌 初始化SD卡...");
  if (!SD.begin(SD_CS, sd_spi)) {
    Serial.println("失败!");
    Serial.println("请检查:1.SD卡接线 2.SD卡格式(FAT32) 3.卡未损坏");
    while (1) delay(1000); // 初始化失败则卡死
  }
  Serial.println("成功!");


  // 新增:检查播放列表中所有歌曲是否存在
  Serial.println("📌 检查播放列表所有文件...");
  bool allFileExist = true;
  for (int i = 0; i < totalSongs; i++) {
    Serial.printf("  检查%s...", playList[i]);
    if (!SD.exists(playList[i])) {
      Serial.println("不存在!");
      allFileExist = false;
    } else {
      Serial.println("存在!");
    }
  }
  if (!allFileExist) {
    Serial.println("❌ 部分文件缺失,请检查SD卡根目录文件!");
    while (1) delay(1000);
  }


  // 2. 配置I2S音频输出(适配MAX98357A)
  Serial.print("📌 配置I2S音频...");
  audio.setPinout(I2S_BCLK, I2S_LRC, I2S_DOUT); // BCLK, LRC, DOUT
  // 设置音量(0-21,10为适中,避免破音)
  audio.setVolume(21);
  Serial.println("完成!");


  // 3. 开始播放第一首歌
  Serial.printf("📌 开始播放列表第一首:%s...", playList[0]);
  playSong(currentIndex);
  Serial.println("成功!");
}


void loop() {
  // 核心音频处理循环(必须持续调用)
  audio.loop();


  if (Serial.available() > 0) {
    char cmd = Serial.read();
    switch(cmd) {
      case '<': controlPlay(PREV); break;
      case '>': controlPlay(NEXT); break;
    }
  }


  // 播放状态打印(每秒一次)
  if (audio.isRunning()) {
    static unsigned long lastPrint = 0;
    if (millis() - lastPrint > 1000) {
      // 按字节数估算播放进度(兼容所有库版本)
      //File f = SD.open(playList[currentIndex]);
      //uint32_t fileSize = f.size();
      uint32_t fileSize = audio.getAudioFileDuration();
      //f.close();
      uint32_t playedBytes = audio.getAudioCurrentTime();
      float progress = (float)playedBytes / fileSize * 100;
      Serial.printf("🎵 播放中:%s | 进度:%ld:%ld / %ld:%ld,%.1f%% | 音量:%d\n", playList[currentIndex],playedBytes/60,playedBytes%60,fileSize/60,fileSize%60,progress, audio.getVolume());
      lastPrint = millis();
    }
  } else {
    // 播放完成,切换到下一首
    currentIndex++;
    // 索引超出列表,回到第一首(整体循环)
    if (currentIndex >= totalSongs) {
      currentIndex = 0;
      Serial.println("🔄 播放列表完成,重新开始循环!");
    }
    // 播放下一首
    Serial.printf("➡️  切换至下一首:%s...", playList[currentIndex]);
    playSong(currentIndex);
    Serial.println("成功!");
    delay(500); // 切换间隔,避免频繁触发
  }
}


// ------------------- 新增函数:播放指定索引的歌曲 -------------------
void playSong(int index) {
  if (!audio.connecttoFS(SD, playList[index])) {
    Serial.printf("❌ 播放%s失败!\n", playList[index]);
    while (1) delay(1000); // 单首播放失败则卡死,可根据需求修改
  }
}


OLED关键代码(U8g2)

需安装U8g2 2.35.30库

#include <Arduino.h>
#include <U8g2lib.h>
#include <Wire.h>
U8G2_SSD1306_128X64_NONAME_1_HW_I2C u8g2(U8G2_R0, /* reset=*/ U8X8_PIN_NONE, /* clock=*/ 6, /* data=*/ 5);   // ESP32 Thing, HW I2C with pin remapping
void setup(void) {
  u8g2.begin();
  u8g2.enableUTF8Print();   // enable UTF8 support for the Arduino print() function
}


void loop(void) {
  u8g2.setFont(u8g2_font_wqy15_t_gb2312);  // use chinese2 for all the glyphs of "你好世界"
  u8g2.setFontDirection(0);
  u8g2.firstPage();
  do {
    //u8g2.setCursor(0, 15);
    //u8g2.print("Hello World!");
    u8g2.setCursor(0, 15);
    u8g2.print("日照香炉生紫烟,");   // Chinese "Hello World"
    u8g2.setCursor(0, 30);
    u8g2.print("遥看瀑布挂前川。");   // Chinese "Hello World"
    u8g2.setCursor(0, 45);
    u8g2.print("飞流直下三千尺,");   // Chinese "Hello World"
    u8g2.setCursor(0, 60);
    u8g2.print("疑是银河落九天。");   // Chinese "Hello World"
  } while ( u8g2.nextPage() );
  delay(1000);
}


显示效果如图。


补充说明下,上面显示的都是静态文字或符号,如需显示动态变量,如歌曲进度。需要使用sprintf 函数,对各类型进行转换(如整数、浮点等)



比如在显示歌曲进度部分,就使用了

sprintf(music_Progress, "%ld:%ld/%ld:%ld %.0f%% %d\n",currentTime/60,currentTime%60,duration/60,duration%60,progress, audio.getVolume());

u8g2.print(music_Progress);



 u8g2.firstPage(); 
      do {
      sprintf(musicID, "%s", playList[currentIndex]);
      sprintf(musicID_CN, "%s", musicList[currentIndex]);
      sprintf(music_Progress, "%ld:%ld/%ld:%ld %.0f%% %d\n",currentTime/60,currentTime%60,duration/60,duration%60,progress, audio.getVolume());      
 
      u8g2.setCursor(0, 15);
      u8g2.print("寒假在家一起练");    // Chinese "Hello World"
      u8g2.setCursor(0, 30);
      u8g2.print(musicID);
      u8g2.setCursor(0, 45);
      u8g2.print(musicID_CN);
      u8g2.setCursor(0, 60);
      u8g2.print(music_Progress);


      } while (u8g2.nextPage());


最重要的语音识别和控制逻辑如下:


run_classifier(&signal, &result, debug_nn); 获得识别结果,再进行循环对比获得置信度最高的语音指令(pred_index编号,pred_value 置信度、result.classification[pred_index].label 语音标签:比如"上一曲"、“下一曲”、“播放”、“暂停”等


再对标签进行判断,进而进行响应的控制逻辑,比如切歌、亮灯等。

// 3. 语音识别逻辑(非阻塞,实时检测)
  static unsigned long lastInfer = 0;
  if (millis() - lastInfer > EI_CLASSIFIER_INTERVAL_MS) {
    bool m = microphone_inference_record();
    if (m) {
      signal_t signal;
      signal.total_length = EI_CLASSIFIER_RAW_SAMPLE_COUNT;
      signal.get_data = &microphone_audio_signal_get_data;
      ei_impulse_result_t result = { 0 };
      EI_IMPULSE_ERROR r = run_classifier(&signal, &result, debug_nn);
      if (r == EI_IMPULSE_OK) {
        // 寻找置信度最高的语音指令
        int pred_index = -1;
        float pred_value = 0.0;
        for (size_t ix = 0; ix < EI_CLASSIFIER_LABEL_COUNT; ix++) {
          if (result.classification[ix].value > pred_value) {
            pred_index = ix;
            pred_value = result.classification[ix].value;
          }
        }
        // 置信度超过阈值,执行指令
        if (pred_value >= VOICE_THRESHOLD && pred_index != -1) {
          isVoiceSwitch = true; // 标记语音切歌,暂停自动切歌
          String cmd = result.classification[pred_index].label;
          #if DEBUG_SERIAL
          Serial.printf("\n🗣️  识别到指令:%s | 置信度:%.2f\n", cmd.c_str(), pred_value);
          #endif



          // 指令映射:根据你的Edge Impulse模型标签修改!
          // 【关键】替换为你训练的标签,如"next"、"prev"、"pause"、"play"
          if (cmd == "下一曲") {
            playNextSong();
          } else if (cmd == "上一曲") {
            playPrevSong();
          }
          else if (cmd == "播放") {
            playingSong();
          }
          else if (cmd == "暂停") {
            pausingSong();
          }
          isVoiceSwitch = false; // 解除语音切歌标记
        }
      }
    }
    lastInfer = millis();
  }
}


以下是之前的测试视频,识别率还可以:


image.png


image.png


image.png



   - 功能展示图及说明(可包含实物展示/软件/串口/工具调试结果图等)


实物运行照片




image.png


1. 预置不少于 3 段(6首mp3)旋律序列,通过扬声器播放;完成

2. 如若使用蜂鸣器,需要使用RGB 灯带按节拍或音符强弱做律动灯效;若使用扬声器,无需使用RGB灯。无需

3. 麦克风关键词识别实现播放、暂停、下一首、上一首、音量大、音量小等至少 3 条指令,注意指令必须包含切换播放曲目。语音识别可使用EDGE IMPULSE、ESP-SR或上位机等部署相关模型;完成,可以语音切歌、播放、暂停

4. OLED 显示曲目编号、播放进度百分比、当前音量;完成




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


第一个难题是:


MAX98357模块,在测试中发现,必须手摸着BCLK引脚才能播放音乐,否则没声音。

为了豆包:

MAX98357A 是 I2S 音频功放,BCLK(位时钟)是它的核心同步信号 —— 没有稳定的 BCLK 信号,功放无法解析 I2S 音频数据,自然不出声;手触摸时:

  1. 信号补全:人体感应到周围的电磁信号(比如 ESP32 的时钟辐射),给 BCLK 引脚提供了微弱的时钟信号,功放勉强工作;
  2. 接地回路:人体连接了 BCLK 引脚到地的 “临时回路”,解决了引脚悬空 / 虚接的问题;
  3. 接触电阻补偿:手触摸消除了引脚虚焊 / 接触不良的电阻,让信号能正常传输。



后面我的解决方案是:接一条飞线就可以了,如图上面的那条黑线。

第二个难题是:

edge impulse的识别率低,后面琢磨了采样时机的问题,采用修正了语音分割方法(上面有讲):

大概5~6个方格,一上来就说话(语音在方格最左边)

大概5~6个方格,等片刻(约200ms)才说话(语音在方格最中间)

大概5~6个方格,刚结束就说完(语音在方格最右边)


识别率就高一些了。当然最终极办法是采用群友的方案:滑动采样,后面再研究测试了。


   - 心得体会


一个完整的工程涉及软件、硬件,时间、精力等等,唯有一定时间的学习、投入、测试,才能吃透过程点,才能完成最终的项目,这是对工程师能力的历练和性格的磨练(毕竟会遇到很多问题点)

非常感谢本次硬禾的活动,我收获了不少,后面有时间,我会琢磨钻研下,实现一个ESP32+6轴平衡车。

附件下载
ei-cc01-arduino-1.0.10.zip
训练好的edge impuse模型
SDmp36-1.ino
基于dege impulse 的智能语音识别音乐盒arduino程序
团队介绍
我是cc,一名业余电子爱好者,熟悉常见的各类嵌入式软硬件工具,本次通过运用edge impulse、ESP32-audioI2S-master、U8g2软件,并掌握了SD卡、麦克风、MAX98357 I2S功放的硬件使用。
评论
0 / 100
查看更多
硬禾服务号
关注最新动态
0512-67862536
info@eetree.cn
江苏省苏州市苏州工业园区新平街388号腾飞创新园A2幢815室
苏州硬禾信息科技有限公司
Copyright © 2024 苏州硬禾信息科技有限公司 All Rights Reserved 苏ICP备19040198号