任务介绍
本项目实现了2026寒假练活动Seeed XIAO ESP32S3 Sense开发板的语音控制音乐播放器任务,基于ESP32-S3双核架构,利用板载PDM麦克风采集语音信号,通过Edge Impulse训练的神经网络模型实现关键词识别,控制SD卡中MP3文件的播放,同时通过OLED屏幕实时显示曲目编号、播放进度百分比和当前音量等状态信息。

硬件平台
本次使用Seeed Studio推出的XIAO ESP32S3 Sense开发板,是一款面向AIoT应用的超小型高性能开发平台。该开发板搭载了基于Xtensa LX7双核架构的ESP32-S3芯片,主频高达240MHz,具备丰富的外设资源,包括多个I2C接口、SPI接口、I2S接口等,非常适合用于边缘AI与实时嵌入式系统的开发。开发板集成了PDM数字麦克风、摄像头接口、microSD卡槽,便于快速搭建多媒体原型系统。
在软件方面,本项目采用Arduino框架作为底层开发环境,结合FreeRTOS多任务调度机制实现双核并行处理。语音识别部分使用Edge Impulse平台训练的轻量级神经网络模型,音频解码采用ESP8266Audio库实现MP3实时解码播放。
主控设备:Seeed XIAO ESP32S3 Sense开发板
- 搭载ESP32-S3芯片(Xtensa LX7双核,240MHz)
- 集成PDM数字麦克风(GPIO42/GPIO41)
- 板载microSD卡槽,支持MP3文件存储
- 支持FreeRTOS多核任务调度
外设扩展模块
- 128x64 SSD1306 OLED显示屏(I2C接口,D4=SDA,D5=SCL)
- MAX98357A I2S DAC功放模块(D0=BCLK,D1=LRC,D3=DOUT)
- microSD卡(存储不少于3首MP3音乐文件)
任务分析与实现
本系统实现了基于ESP32-S3双核架构的语音控制MP3播放器,主要功能包括:
四通道数据交互:
- 语音采集与识别
- PDM麦克风采样率:16kHz
- 支持"start"、"pause"、"next"三条语音指令
- MP3音频解码播放
- I2S DAC输出,支持暂停/恢复断点续播
- 自动播放下一首,循环播放
- OLED显示控制
- 显示刷新率:2Hz(每500ms更新一次)
- 实时显示曲目编号、播放进度、音量信息
- 串口命令控制
- 支持播放/暂停、停止、上下曲、音量调节等完整指令集
系统架构设计
本项目充分利用ESP32-S3的双核特性,将语音识别与音频播放分别部署在不同的CPU核心上,通过FreeRTOS Queue实现线程安全的跨核通信:
核心 | 任务 | 说明 |
|---|---|---|
Core 0 | 语音采集 + Edge Impulse推理 | PDM麦克风数据采集与神经网络分类 |
Core 1 | MP3解码 + OLED显示 + 串口处理 | 音频播放主循环与用户界面 |
方案框图:
Edge Impulse 语音模型训练过程
本项目的语音识别功能基于Edge Impulse平台完成模型训练与部署,以下详细描述了从数据采集到模型导出的完整流程。
一、数据采集与预处理
1. 语音样本录制
使用XIAO ESP32S3 Sense板载PDM麦克风录制语音样本,每个关键词录制多段长音频(约10秒),采样率设置为16kHz、16位单声道。录制的关键词类别包括:
- start:用于触发播放/恢复功能
- pause:用于触发暂停功能
- next:用于切换到下一首曲目
- noise:环境背景噪声样本
- unknown:非目标关键词的语音样本
2. 数据分割
将录制的长音频上传至Edge Impulse后,使用平台提供的Split Sample功能将每段长音频按照1000ms的窗口长度自动分割为独立的训练样本。每个关键词被分割为多个1秒长度的样本片段,确保模型能够学习到关键词在不同时间位置出现的特征。
3. 训练/测试集划分
数据采集完成后,通过Edge Impulse的"Perform train/test split"功能自动将数据集按照约80%/20%的比例划分为训练集和测试集。初始划分时发现"start"类别的训练/测试比例为50%/50%,存在不均衡问题。
执行自动划分操作后,各类别的比例趋于合理:
二、Impulse设计与特征提取
1. 创建Impulse
在Edge Impulse的"Create impulse"页面中配置处理管线:
- 时间序列数据块:输入轴为audio,窗口大小设置为1000ms,窗口步进(stride)为500ms,采样频率16000Hz,启用Zero-pad data
- 处理块:选择Audio (MFCC),提取梅尔频率倒谱系数作为语音特征
- 学习块:选择Classification分类器
- 输出特征:5个类别(next, noise, pause, start, unknown)
2. MFCC特征参数
MFCC处理块将原始音频信号转换为637维特征向量,通过对音频信号进行短时傅里叶变换、梅尔滤波器组映射和离散余弦变换,提取出能够有效表征语音内容的频谱特征。
三、模型训练与评估
1. 神经网络架构
分类器采用1D卷积神经网络架构,具体网络结构如下:
textInput layer (637 features)
↓
Reshape layer (13 columns)
↓
1D conv/pool layer (8 filters, 3 kernel size, 1 layer)
↓
Dropout (rate 0.25)
↓
1D conv/pool layer (16 filters, 3 kernel size, 1 layer)
↓
Dropout (rate 0.25)
↓
Flatten layer
↓
Output layer (5 classes)
模型版本选择Quantized (int8)以适配ESP32-S3的有限计算资源。
2. 训练结果
模型在验证集上取得了优异的分类性能:
- 准确率(Accuracy):93.8%
- 损失值(Loss):0.09
混淆矩阵显示,三个关键词指令(NEXT、PAUSE、START)均达到100%的识别准确率,NOISE类别也达到100%。UNKNOWN类别有50%被误分类为NOISE,这在实际应用中影响较小,因为两者都属于非指令类别。
3. 模型导出
训练完成后,将模型导出为Arduino库格式(esp32-XIAO-voice_inferencing),直接集成到Arduino项目中使用。
代码详解
整体软件架构
系统采用模块化设计,共包含4个核心模块:
文件 | 功能 |
|---|---|
| 主程序,任务调度与命令分发 |
| MP3解码播放,SD卡管理 |
| PDM采集与Edge Impulse推理 |
| OLED显示驱动 |
一、主程序与双核任务调度
系统启动后在setup()函数中完成初始化序列,关键流程如下:
- 创建FreeRTOS命令队列(容量10条),用于语音识别模块向播放器模块传递指令
- 初始化OLED显示屏,显示"Initializing..."启动提示
- 初始化音频播放器(SD卡挂载 + I2S DAC配置)
- 在Core 0上创建语音识别任务,分配32KB栈空间(神经网络推理需要大栈)
- 自动播放第一首MP3文件
void setup() {
Serial.begin(115200);
// 创建命令队列
commandQueue = xQueueCreate(10, sizeof(player_cmd_t));
// 初始化显示
display_init();
display_show_message("Initializing...");
// 初始化播放器(SD卡 + I2S DAC)
audio_player_init();
// 在 Core 0 启动语音识别
xTaskCreatePinnedToCore(
voiceTaskCore0,
"VoiceTask",
1024 * 32, // 栈大小(推理需要大栈)
NULL, 1, NULL,
0 // Core 0
);
// 自动播放第一首
audio_player_play(0);
}
主循环loop()运行在Core 1上,依次执行四项任务:
- 处理语音命令:从队列中取出语音识别发送的命令并执行
- 处理串口命令:解析用户通过串口发送的单字符控制指令
- MP3解码:持续调用
audio_player_loop()推进MP3帧解码 - 定时刷新OLED:每500ms更新一次显示内容
void loop() {
handleVoiceCommands(); // 1. 处理语音命令
handleSerialCommand(); // 2. 处理串口命令
audio_player_loop(); // 3. MP3 解码
// 4. 定时刷新 OLED
static uint32_t lastDisplay = 0;
if (millis() - lastDisplay > 500) {
lastDisplay = millis();
display_update(audio_player_get_state());
}
}
命令系统采用统一的枚举类型player_cmd_t,无论是语音指令还是串口输入,最终都通过executeCommand()函数统一执行:
void executeCommand(player_cmd_t cmd) {
switch (cmd) {
case CMD_PLAY_PAUSE: audio_player_toggle_pause(); break;
case CMD_STOP: audio_player_stop(); break;
case CMD_NEXT: audio_player_next(); break;
case CMD_PREV: audio_player_prev(); break;
case CMD_VOL_UP: audio_player_volume_up(); break;
case CMD_VOL_DOWN: audio_player_volume_down(); break;
default: break;
}
display_update(audio_player_get_state());
}
二、音频播放器模块 (audio_player.cpp)
音频播放器模块负责SD卡上MP3文件的管理与解码播放,核心设计要点如下:
1. 硬件引脚配置
I2S DAC引脚经过精心选择,避免与SPI(SD卡)和I2C(OLED)产生冲突:
#define I2S_BCLK D0 // GPIO1
#define I2S_LRC D1 // GPIO2
#define I2S_DOUT D3 // GPIO4
#define SD_CS 21 // GPIO21
2. 初始化流程
初始化过程依次完成互斥锁创建、SD卡挂载、MP3文件扫描和I2S输出配置:
bool audio_player_init() {
playerMutex = xSemaphoreCreateMutex();
SD.begin(SD_CS);
scanMP3Files(); // 扫描根目录下所有.mp3文件
out = new AudioOutputI2S();
out->SetPinout(I2S_BCLK, I2S_LRC, I2S_DOUT);
out->SetGain(currentGain); // 默认75%音量
mp3 = new AudioGeneratorMP3();
return true;
}
scanMP3Files()函数遍历SD卡根目录,将所有.mp3文件路径存入数组,最多支持50个文件。
3. 播放控制
播放指定文件:停止当前播放,打开新文件并记录文件大小(用于计算播放进度),启动MP3解码器。
void audio_player_play(int index) {
xSemaphoreTake(playerMutex, portMAX_DELAY);
if (mp3 && mp3->isRunning()) mp3->stop();
if (file) { delete file; file = NULL; }
currentFile = index;
// 获取文件大小用于进度计算
File f = SD.open(mp3Files[index].c_str());
if (f) { fileSize = f.size(); f.close(); }
file = new AudioFileSourceSD(mp3Files[index].c_str());
mp3->begin(file, out);
isPlaying = true;
xSemaphoreGive(playerMutex);
}
暂停/恢复:暂停时记录当前文件读取位置,恢复时通过seek()定位到暂停点继续播放。如果恢复失败,会尝试向前回退2048字节重新寻找MP3帧头,确保续播的可靠性。
void audio_player_toggle_pause() {
// ...
if (isPaused) {
if (file) pausedPosition = file->getPos();
mp3->stop();
} else {
file = new AudioFileSourceSD(mp3Files[currentFile].c_str());
file->seek(pausedPosition, SEEK_SET);
bool started = mp3->begin(file, out);
if (!started) {
// 回退找帧头
uint32_t retryPos = (pausedPosition > 2048) ?
(pausedPosition - 2048) : 0;
// ...重试逻辑
}
}
}
音量控制:采用浮点增益值(0.0~1.0),步进25%,通过AudioOutputI2S::SetGain()实时调节输出增益。
void audio_player_volume_up() {
xSemaphoreTake(playerMutex, portMAX_DELAY);
currentGain += GAIN_STEP; // GAIN_STEP = 0.25f
if (currentGain > GAIN_MAX) currentGain = GAIN_MAX;
out->SetGain(currentGain);
xSemaphoreGive(playerMutex);
}
4. 线程安全
所有播放器操作均通过playerMutex互斥锁保护,确保Core 0的语音命令与Core 1的播放循环不会产生数据竞争。
5. 播放状态查询
audio_player_get_state()函数返回完整的播放器状态结构体,供OLED显示模块使用:
typedef struct {
bool isPlaying;
bool isPaused;
int currentFile;
int fileCount;
int progressPercent; // 基于文件位置/文件大小计算
int volumePercent; // currentGain * 100
char fileName[32];
} player_state_t;
三、语音识别模块 (voice_control.cpp)
语音识别模块是本项目的核心创新点,运行在Core 0上,包含两个关键子系统:PDM音频采集和Edge Impulse神经网络推理。
1. 滑动窗口采集机制
传统的固定窗口采集方式存在严重的"时机依赖"问题——如果语音指令恰好跨越两个采集窗口的边界,两个窗口都无法捕获完整的指令,导致识别率仅有20-30%。

本项目采用滑动窗口 + 环形缓冲区 + 快照复制的三重优化方案:

具体实现中,采集任务持续从PDM麦克风读取音频数据,写入环形缓冲区。每当新采集的样本数达到slice_size(窗口大小的1/6)时,将环形缓冲区的当前内容快照复制到独立的推理缓冲区,触发一次推理:
static void voice_capture_task(void *arg) {
while (voice_record_status) {
size_t bytes_read = VoiceI2S.readBytes(
(char*)voiceSampleBuffer, i2s_read_size);
int samples_read = bytes_read / 2;
// 饱和增益放大(8倍)
for (int i = 0; i < samples_read; i++) {
voiceSampleBuffer[i] = saturate_amplify(voiceSampleBuffer[i], 8);
}
// 写入环形缓冲区 + 滑动窗口触发
for (int i = 0; i < samples_read; i++) {
ring_buffer[ring_buf_ix] = voiceSampleBuffer[i];
ring_buf_ix = (ring_buf_ix + 1) % n_samples;
slice_counter++;
if (slice_counter >= slice_size && !inference_ready) {
slice_counter = 0;
// 快照复制:环形缓冲区 → 推理缓冲区
uint32_t copy_ix = ring_buf_ix;
for (uint32_t j = 0; j < n_samples; j++) {
inference_buffer[j] = ring_buffer[copy_ix];
copy_ix = (copy_ix + 1) % n_samples;
}
inference_ready = true;
}
}
}
}
这种设计使得采集窗口之间有约83%的重叠(SLICE_DIVISOR=6),无论语音指令在何时出现,都能被至少一个窗口完整捕获,识别率从20-30%提升至85-95%。
2. 推理与命令映射
推理循环等待采集任务设置inference_ready标志后,构建信号对象并调用Edge Impulse的run_classifier()函数执行神经网络推理:
void voice_control_loop() {
while (!inference_ready) {
vTaskDelay(pdMS_TO_TICKS(5));
}
inference_ready = false;
signal_t signal;
signal.total_length = n_samples;
signal.get_data = &voice_get_signal_data;
ei_impulse_result_t result = { 0 };
run_classifier(&signal, &result, false);
// 找出最高置信度的分类结果
// ...
}
语音标签到播放器命令的映射关系:
语音标签 | 播放器命令 | 功能说明 |
|---|---|---|
"start" |
| 开始播放/恢复 |
"pause" |
| 暂停播放 |
"next" |
| 切换到下一首曲目 |
3. 防重复触发机制
由于滑动窗口的高重叠率,一次语音指令可能在多个连续窗口中被识别到,导致同一指令被重复执行。为此,系统实现了三重防御方案:

第一重:置信度阈值过滤
只有当分类结果的置信度超过0.8时才视为有效指令,过滤掉低置信度的模糊识别结果:
#define CONFIDENCE_THRESHOLD 0.8f
if (is_command && pred_value > CONFIDENCE_THRESHOLD) {
// 进入后续确认流程
}
第二重:连续确认(Debounce)
要求连续CONFIRM_COUNT次(默认1次)窗口检测到相同指令才触发执行,过滤单帧噪声误检:
if (pred_index == last_pred_index) {
consecutive_count++;
} else {
consecutive_count = 1;
last_pred_index = pred_index;
}
第三重:冷却期(Cooldown)
成功触发一次指令后,在COOLDOWN_MS(1500ms)内忽略同类指令,确保一句话只输出一次结果:
#define COOLDOWN_MS 1500
if (now - last_trigger_time > COOLDOWN_MS) {
sendCommand(cmd, pred_label, pred_value);
last_trigger_time = now;
} else {
Serial.printf("[Voice] [cooldown] %s suppressed\n", pred_label);
}
四、OLED显示模块 (display.cpp)
显示模块使用U8g2库驱动128x64 SSD1306 OLED屏幕,通过默认I2C接口(D4=SDA,D5=SCL)通信。屏幕内容分为四行信息:
void display_update(player_state_t state) {
u8g2.clearBuffer();
u8g2.setFont(u8g2_font_ncenB08_tr);
// 第一行:标题 + 播放状态([PLAY]/[PAUSE]/[STOP])
u8g2.drawStr(0, 12, "MP3 Player");
// 第二行:当前文件名
u8g2.drawStr(0, 28, state.fileName);
// 第三行:进度条 + 百分比
int fillW = 100 * state.progressPercent / 100;
u8g2.drawFrame(0, 34, 100, 10); // 进度条外框
u8g2.drawBox(1, 35, fillW - 1, 8); // 进度条填充
// 第四行:曲目编号(Track x/y)+ 音量(Vol:xx%)
snprintf(info, sizeof(info), "Track %d/%d",
state.currentFile + 1, state.fileCount);
snprintf(volStr, sizeof(volStr), "Vol:%d%%", state.volumePercent);
u8g2.sendBuffer();
}
OLED显示的具体信息包括:
- 曲目编号:显示"Track x/y"格式,x为当前曲目序号,y为总曲目数
- 播放进度百分比:通过文件当前读取位置与文件总大小的比值计算,同时以图形进度条直观展示
- 当前音量:显示"Vol:xx%"格式,范围0%-100%
效果展示

系统上电后自动扫描SD卡中的MP3文件并开始播放第一首曲目。OLED屏幕实时显示播放器状态,用户可以通过语音指令或串口命令控制播放。
串口支持的完整命令集:
按键 | 功能 | 按键 | 功能 |
|---|---|---|---|
p | 播放/暂停 | s | 停止 |
n | 下一曲 | b | 上一曲 |
+ | 音量增加(+25%) | - | 音量降低 |
l | 列出文件 | h | 帮助 |

语音指令支持的操作:
关键词 | 功能 |
|---|---|
"播放" | 开始播放/恢复 |
"暂停" | 暂停播放 |
"下一首" | 切换下一首曲目 |
遇到的难题与解决办法
问题一:语音识别率极低,仅约20-30%
解法:分析发现原始方案使用固定窗口采集,语音指令跨窗口边界时无法被完整捕获。改用滑动窗口 + 环形缓冲区 + 快照复制的方案后,识别率提升至85-95%。关键参数为SLICE_DIVISOR=6(约83%重叠),确保任何时刻的语音指令都能被至少一个窗口完整包含。
问题二:识别成功后同一指令被重复执行多次
解法:实现了三重防御机制——置信度阈值(0.8)过滤低置信度结果、连续确认过滤单帧误检、冷却期(1500ms)防止同一指令的多窗口重复触发。
问题三:双核并行导致音频播放器状态被同时访问
解法:为播放器所有操作添加playerMutex互斥锁保护,Core 0的语音命令通过FreeRTOS Queue异步传递给Core 1执行,避免直接跨核访问共享资源。
问题四:MP3暂停后恢复播放时出现解码错误
解法:暂停时记录文件读取位置,恢复时先尝试从记录位置开始解码。如果失败,自动向前回退2048字节重新寻找MP3帧头;如果仍然失败,则从文件开头重新播放作为保底方案。
活动感想
通过本项目实践,深入掌握了ESP32-S3双核架构下FreeRTOS多任务调度的设计方法、Edge Impulse平台从数据采集到模型部署的完整工作流程,以及嵌入式系统中音频采集、解码播放、显示驱动的协同工作机制。XIAO ESP32S3 Sense开发板紧凑的尺寸和丰富的板载传感器极大降低了原型开发的硬件搭建难度。整个开发过程中,体会到嵌入式AI应用需要算法模型与系统工程的紧密配合——不仅需要训练出高准确率的模型,还需要在工程层面解决实时采集、防重复触发、多核同步等一系列实际问题,每一个环节的优化都能显著提升最终的用户体验。
感谢硬禾科技举办的2026寒假练活动,祝硬禾的活动越办越好!