2026寒假练 人工智能套件实现语音点歌音乐盒
该项目使用了人工智能套件,实现了语音点歌音乐盒的设计,它的主要功能为:人工智能套件实现语音点歌音乐盒。
标签
ESP32
人工智能套件
音乐盒
冲向天空的猪
更新2026-03-24
27

电子森林音乐盒项目文档

一、 任务要求

  1. 预置不少于 3 段旋律序列,通过扬声器或蜂鸣器播放;
  2. 若使用蜂鸣器,需使用 RGB 灯带按节拍或音符强弱实现律动灯效;
  3. 麦克风关键词识别实现播放、暂停、下一首、上一首、音量大、音量小等至少 3 条指令,注意指令必须包含切换播放曲目。语音识别可使用EDGE IMPULSE、ESP-SR或上位机等部署相关模型;
  4. OLED 显示曲目编号、播放进度百分比、当前音量;
  5. 可选加分:微信小程序或网页远程选曲、查看进度与调整音量;
  6. 可选加分:自行设计 3D 外壳或音乐盒结构,需提交结构文件与装配说明。




二、 解决方案概要

  1. 音频播放:使用 Flask 搭建网页后台并准备三首音乐,通过 ESP32 驱动 I2S 扬声器进行网络音频流播放。
  2. 视觉效果:本项目未采用蜂鸣器,因此未添加 RGB 灯带做律动灯效。
  3. 语音识别与抗干扰处理:使用 Edge Impulse 训练语音模型。
    • 最终方案:综合评估后,决定仍沿用直接的关键词识别方式,并通过加强环境噪音的数据训练来提高模型抗干扰能力。
  4. 显示与按键控制:OLED 界面基于 u8g2 图形库实现;交互控制采用 ADC 模拟按键实现功能操作。
  5. 远程控制后台:利用 Flask 部署网页后台(支持本地与云服务器),实现远程选曲和音量调节。




三、 任务实现详细过程

3.1 硬件准备与调试

在进行项目开发前,需对所用硬件进行整体测试,以便及时发现并解决潜在的设计问题。

1. ADC 按键模块

1280X1280

  • 电压匹配问题:按键模块设计的基准电压为 5V,而 ESP32 的 ADC 基准电压为 3.3V。如果直接接入,分压后的电压会超过 ESP32 的安全量程。

1280X1280

  • 硬件修正:将分压电阻 R2 替换为 5.1kΩ 及以下规格的电阻,使最高采样电压降至 3.3V 左右。
  • 按键规划:因为按键 1 的分压电阻差值不够精确,且本项目仅需 3 个按键即可完成操作,因此不使用按键 1。
  • 信号处理:实测发现基准电压存在毛刺波动,需通过软件采集多组数据并进行中值滤波以获取稳定的 ADC 值。
  • 各按键触发阈值定义如下:
// [按键2] 音量+ (700-900)
#define BTN_RANGE_2_MIN 700
#define BTN_RANGE_2_MAX 900  

// [按键3] 音量- (920-1200)
#define BTN_RANGE_3_MIN 920
#define BTN_RANGE_3_MAX 1200  

// [按键4] 播放/暂停 (1600-2100)
#define BTN_RANGE_4_MIN 1600
#define BTN_RANGE_4_MAX 2100  

2. 其余器件

所需核心器件包含:OLED 屏幕、MAX98357A 音频解码器、ESP32 核心板、SD 卡一张,以及为了保证音量效果替换的大功率喇叭。各模块经测试均可正常工作。

1280X1280

3.2 软件开发环境

  • 开发平台:Arduino IDE、Python
  • ESP32 版本:3.3.6
  • 后端框架:Flask
  • 模型训练平台:Edge Impulse

3.3 模型训练过程

参考相关教程完成模型训练:

  1. 根据 Arduino IDE 中安装的 ESP32 版本,选择对应的 3.x 教程进行 SD 卡识别训练。

1280X1280

  1. 录制并上传四个关键词的语音数据集。

1280X1280

  1. 训练完成后,导出包含模型的 .zip 库文件,并将其导入 Arduino 库中调用即可。

1280X1280

1280X1280

3.4 OLED 界面开发

系统使用 u8g2 库并开启 UTF-8 支持来绘制包含中文字符的播放器界面。

  • 上电状态:初始化时屏幕显示“正在连接 WiFi...”。

1280X1280

  • 音乐播放状态 UI 布局
    1280X1280
    • 左上角:显示当前播放状态(播放/暂停)。
    • 右上角:显示当前曲目序号及云端歌单总数。
    • 居中部分:显示当前正在播放的歌曲名称。
    • 中下部分:根据歌曲总时长与当前播放时间,绘制进度百分比条及时间文本。
    • 最底部:以图形化条状图实时反馈当前的音量大小。

3.5 ADC 按键处理逻辑

系统配置了三个功能按键:音量+、音量-、暂停/播放。
软件层面,每次按键扫描连续采集 5 次模拟量,经过冒泡排序算法剔除极值取中位数,以确保采集数值的准确性。获取稳定的 ADC 值后,程序判断其所在的阈值区间触发相应事件,并加入了软件防抖逻辑。

  1. 滤波处理
int getMedianADC() {
   int values[5];
   for(int i=0; i<5; i++) {
       values[i] = analogRead(ADC_PIN);
      delay(2);
   }
   for(int i=0; i<4; i++) {
       for(int j=i+1; j<5; j++) {
           if(values[i] > values[j]) {
               int temp = values[i];
               values[i] = values[j];
               values[j] = temp;
           }
       }
   }
   return values[2];
}
  1. 具体实现功能
void handleADCButtons() {
   int cleanVal = getMedianADC();
   int currentID = getKeyID(cleanVal);

   if (currentID != lastKeyID) {
       stableStartTime = millis();
       lastKeyID = currentID;    
  }
   else {
       if ((millis() - stableStartTime) > 60) {
           if (currentID != KEY_NONE) {
               if (!isKeyPressed) {
                   Serial.printf("Key Action: %d (ADC: %d)\n", currentID, cleanVal);
                   switch (currentID) {
                       case KEY_VOL_UP:
                           if (currentVolume < 21) {
                               currentVolume++;
                               audio.setVolume(currentVolume);
                               drawPlayerInterface();
                          }
                           break;
                       case KEY_VOL_DOWN:
                           if (currentVolume > 0) {
                               currentVolume--;
                               audio.setVolume(currentVolume);
                               drawPlayerInterface();
                          }
                           break;
                       case KEY_PLAY_PAUSE:
                           togglePlayPause(); // 使用统一封装的函数
                           break;
                  }
                   isKeyPressed = true;
              }
          } else {
               isKeyPressed = false;
          }
      }
  }
}
  1. 触发相关阈值
enum KeyID { KEY_NONE, KEY_VOL_UP, KEY_VOL_DOWN, KEY_PLAY_PAUSE };

int lastKeyID = KEY_NONE;
unsigned long stableStartTime = 0;
bool isKeyPressed = false;


int getKeyID(int val) {
   if (val < 400 || val > 2200) return KEY_NONE;
   if (val >= BTN_RANGE_2_MIN && val <= BTN_RANGE_2_MAX) return KEY_VOL_UP;
   if (val >= BTN_RANGE_3_MIN && val <= BTN_RANGE_3_MAX) return KEY_VOL_DOWN;
   if (val >= BTN_RANGE_4_MIN && val <= BTN_RANGE_4_MAX) return KEY_PLAY_PAUSE;
   return KEY_NONE;
}

3.6 网络通信与ESP32轮询

ESP32 连接 WiFi 后,通过访问部署的 Flask 后端,实现远程指令下发与音乐链接解析。

  • 指令轮询:ESP32 每隔 800ms 向服务器发起 GET /api/poll-command

获取其中的指令:指令通过 json 格式,获取当前的状态

这里服务器主要下发:

1.音乐的 url 和其中音乐名称给 OLED 显示中文名

{"act":"play","filename":"\u6211\u7231\u4f60.mp3","index":1,"total":4,"url":"你的音乐网址"}

2.下发音量大小,根据音量大小来调节 ESP32 端播放的音乐声音

{"act":"volume","val":"72"}

3.发送暂停和播放的指令

{"act":"pause"}

其中 800ms 轮询一次,服务器可以知道消息是否被获取,可以知道当前设备是否

    if (millis() - lastCheckTime > checkInterval) {
      checkCommand();
      lastCheckTime = millis();
  }
  • 指令执行:解析服务器返回的 JSON,执行播放(play)、音量(volume)、暂停/恢复(pause/resume)等操作。
  1. 逻辑处理代码
void checkCommand() {
   if(WiFi.status() != WL_CONNECTED) return;
   HTTPClient http;
   http.begin(serverHost + "/api/poll-command");
   http.setTimeout(300);
   
   if (http.GET() == 200) {
       DynamicJsonDocument doc(2048);
       if (!deserializeJson(doc, http.getString())) {
           String action = doc["act"].as<String>();
           
           if (doc.containsKey("index")) currentSongIndex = doc["index"];
           if (doc.containsKey("total")) totalSongs = doc["total"];

           if (action == "play") {
               String newUrl = doc["url"].as<String>();
               if (doc.containsKey("filename")) {
                   String fname = doc["filename"].as<String>();
                   fname.replace(".mp3", "");
                   currentTitle = fname;
              }
               if (streamUrl != newUrl || !audio.isRunning()) {
                   streamUrl = newUrl;
                   audio.connecttohost(newUrl.c_str());
                   currentStatusRaw = "Buffering";
                   isManualPause = false;
                   drawPlayerInterface();
              } else if (isManualPause) {
                   audio.pauseResume();
                   isManualPause = false;
                   currentStatusRaw = "Playing";
                   drawPlayerInterface();
              }
          }
           else if (action == "pause") {
               if(audio.isRunning() && !isManualPause) {
                  audio.pauseResume();
                  isManualPause = true;
                  currentStatusRaw = "Paused";
                  drawPlayerInterface();
              }
          }
           else if (action == "resume") {
               if (isManualPause || !audio.isRunning()) {
                   audio.pauseResume();
                   isManualPause = false;
                   currentStatusRaw = "Playing";
                   drawPlayerInterface();
              }
          }
           else if (action == "volume") {
               int webVol = doc["val"];
               currentVolume = map(webVol, 0, 100, 0, 21);
               audio.setVolume(currentVolume);
               drawPlayerInterface();
          }
      }
  }
   http.end();
}

3.7 网页后端与云端逻辑 (Flask)

本项目利用 Python 的 Flask 框架搭建了轻量级的 Web 控制端,它主要承担了 UI 渲染、文件管理、以及作为 Web 端与 ESP32 硬件之间的“命令中枢”。以下是其核心逻辑拆解:

1. UI 渲染与静态资源托管

服务器直接渲染内嵌的 HTML 模板(包含前端极光 UI 及交互逻辑),并将 music 文件夹下的音乐文件暴露为静态资源,供前端本地试听直接调用。

# 渲染前端控制台页面,并传入歌曲列表
@app.route('/')
def index():
   return render_template_string(HTML_TEMPLATE, songs=get_song_list())

# 暴露本地音乐文件,供前端 "本机试听" 模式播放
@app.route('/music/<path:filename>')
def serve_file_raw(filename):
   return send_from_directory(MUSIC_FOLDER, filename)

2. 文件上传与管理

为了方便用户随时更新歌单,后端提供了上传和删除接口,并在上传时进行了安全拦截(限制文件大小为 10MB 及仅限 MP3 格式)。

@app.route('/api/upload', methods=['POST'])
def upload():
   f = request.files.get('file')
   if f and f.filename:
       # 获取文件大小
       f.seek(0, os.SEEK_END)
       file_size = f.tell()
       f.seek(0)

       # 安全与格式校验
       if file_size > MAX_FILE_SIZE:
           return jsonify(success=False, msg="上传失败:文件大小超过 10MB")
       if not f.filename.lower().endswith('.mp3'):
            return jsonify(success=False, msg="仅支持 MP3 格式文件")

       # 保存文件
       f.save(os.path.join(MUSIC_FOLDER, f.filename))
       return jsonify(success=True)

3. 指令中转与设备心跳机制

由于 Web 无法主动向处于局域网或内网的 ESP32 推送数据,后端采用了一个 esp_command_queue 列表作为指令缓冲池。ESP32 轮询拿走指令的同时,后端会记录当前时间戳,以此来判定设备是否在线。

esp_command_queue = []
last_esp_active_time = 0

# ESP32 硬件请求此接口获取指令
@app.route('/api/poll-command')
def poll():
   global last_esp_active_time
   last_esp_active_time = time.time() # 刷新设备活跃时间(心跳)
   
   # 队列有指令则弹出下发,否则返回空动作
   if esp_command_queue:
       return jsonify(esp_command_queue.pop(0))
   return jsonify({'act': 'none'})

# 供前端页面查询设备是否在线 (超时5秒判定为离线)
@app.route('/api/esp-status')
def status():
   return jsonify(online=(time.time() - last_esp_active_time) < 5)

4. 播放控制与音频流安全分发

当用户在网页端点歌时,后端不会直接下发原文件名给 ESP32。由于文件名可能包含中文或空格,容易导致 ESP32 URL 解析失败。后端巧妙地将文件名进行了 Base64 编码,生成安全的串流 URL 下发,并在 ESP32 请求该 URL 时解码并返回音频流。同时,后端还动态计算当前歌曲的序号和总数供 OLED 显示。

@app.route('/api/send-command', methods=['POST'])
def receive_command():
   cmd = request.json
   if cmd.get('act') == 'play':
       fname = cmd.get('filename')
       
       # 将文件名进行 Base64 编码,生成安全的音频流 URL
       b64 = base64.urlsafe_b64encode(fname.encode()).decode()
       safe_url = f"{request.host_url}api/stream/{b64}"
       cmd['url'] = safe_url
       
       # 动态计算当前曲目在歌单中的 Index (1-based) 和 Total
       songs = get_song_list()
       cmd['total'] = len(songs)
       cmd['index'] = songs.index(fname) + 1 if fname in songs else 1

   esp_command_queue.append(cmd)
   return jsonify(success=True)

# ESP32 请求播放音频流时触发解码并发送文件
@app.route('/api/stream/<b64_name>')
def stream_music_b64(b64_name):
   try:
       fname = base64.urlsafe_b64decode(b64_name).decode()
       return send_from_directory(MUSIC_FOLDER, fname)
   except:
       return "Error", 404

5.Flask项目路径

D:\
├── app.py         <-- 你的 Python 代码
└── music\         <-- 新建这个文件夹!
  ├── 晴天.mp3     <-- 放入歌曲
  └──test.mp3

6.如何部署

由于我的环境问题,我当前是部署到腾讯云的服务器上,感兴趣的小伙伴,可以在网上查找资料,如购买域名,部署轻量级服务器,还有端口的开放。在家庭中使用可以使用内网来部署,更安全可靠,直接使用本地电脑当作服务器的后台。系统架构是flask,直接python环境配置好,将相关包配置好就能成功实现。

7.网页端UI

image.png

3.8 系统软件交互流程图

本项目采用了“前端 UI <-> 云端中枢 <-> 硬件终端”的三层架构,通过 HTTP 协议进行异步指令通信与音频流传输。

1. 系统架构与数据流向图

该图展示了三个核心模块之间是如何通过 API 接口进行数据交互和相互协同的。

image-20260309154458434

2. 核心控制时序图 (播放与轮询机制)

由于 Web 无法主动向处于局域网内的 ESP32 推送数据,系统采用了硬件轮询 (Polling) 机制。下图展示了从用户在网页点击“下一首”到硬件发出声音的完整时间线。

image-20260309154511211


3.9 语音识别与离线指令控制

本项目引入了基于 Edge Impulse 训练的轻量级关键词唤醒(KWS)模型,通过 I2S PDM 数字麦克风实时侦听环境声音,实现免接触的切歌与播放控制(如“last”、“next”、“stop”)。为了在资源受限的 ESP32 上既保证音频流媒体的平滑播放,又实现高频的 AI 采样,系统在架构与算法上做了深度优化。

1. 软硬分工与双核并行架构 ESP32 包含两个独立的处理器核心。在常规单核运行模式下,密集的 AI 矩阵运算极易导致主循环中的 MP3 音频解码任务(audio.loop())被“饿死”,从而产生音乐卡顿现象。 为了解决这一问题,本项目采用了 FreeRTOS 多任务机制 进行严格的核分离:

  • Core 1(主处理核): 负责运行 loop() 主循环,处理 Wi-Fi 通信、JSON 解析、OLED 屏幕刷新以及繁重的 I2S 音乐数据解码输出。
  • Core 0(后台处理核): 通过 xTaskCreatePinnedToCore 将 AI 推理任务(kws_inference_task)和麦克风采样任务锁定在 Core 0 上独立运行,互不干扰。

2. 音频采集与滑动窗口算法 语音指令的出现时间是随机的,为了防止指令正好落在两次采样周期的交界处而被漏听,系统引入了环形缓冲区(Ring Buffer)滑动窗口(Sliding Window)机制。

  • 软件增益: 由于原始麦克风采集的声音幅值较小,程序在底层读取数据时,直接将采样值放大了 8 倍,并加入了硬限幅防爆音处理。
  • 连续切片: 预留 1 秒钟的声音缓冲区(共 2048 个采样点),并将其均匀切分为 4 份。麦克风每填满 1/4 秒(约 250ms)的数据,就通知 AI 唤醒一次,去“听”过去 1 秒内的完整声音快照。

3. 智能防误触与动态置信度 在实际运行中,扬声器播放的音乐很容易被麦克风重新采集,导致“自己播放的歌声触发了自己的切歌指令”(即回声误唤醒)。为此,程序在模型推理(run_classifier)后,加入了智能防误触逻辑:

  • 动态门槛: 默认状态下,模型预测某项指令的概率置信度超过 0.75 (75%) 即认为触发成功。但当机器处于“播放中”状态(currentStatusRaw == "Playing")时,系统会自动将门槛拉高至 0.88 (88%),以此过滤掉大部分环境杂音与背景音乐的干扰。
  • 强制冷却期(Cooldown): 由于滑动窗口每秒触发 4 次推理,用户喊出一句指令可能会被连续识别多次。代码中设置了 1500ms 的强制冷却时间,确保一次语音指令只会被执行一次动作。

核心代码实现逻辑:

C++

// AI 推理任务 (运行在 Core 0)
void kws_inference_task(void *pvParameters) {
   unsigned long last_trigger_time = 0;    // 记录上次成功触发动作的时间
   const unsigned long COOLDOWN_MS = 1500; // 冷却期 1.5 秒

   while (1) {
       if (!slice_ready) {
           vTaskDelay(10 / portTICK_PERIOD_MS);
           continue;
      }


       // 运行模型推理
       EI_IMPULSE_ERROR r = run_classifier(&signal, &result, false);
       if (r == EI_IMPULSE_OK) {
           int best_idx = -1;
           
           // 动态置信度门槛:播放时提高门槛防环境音干扰
           float best_score = (currentStatusRaw == "Playing") ? 0.88 : 0.75;

           for (size_t ix = 0; ix < EI_CLASSIFIER_LABEL_COUNT; ix++) {
               if (result.classification[ix].value > best_score) {
                   best_score = result.classification[ix].value;
                   best_idx = ix;
              }
          }

           if (best_idx != -1) {
               const char* label = result.classification[best_idx].label;
               
               // 排除静音与噪音,且检查是否度过 1500ms 冷却期
               if (strcmp(label, "noise") != 0 && strcmp(label, "zero") != 0) {
                   if (millis() - last_trigger_time > COOLDOWN_MS) {
                       // 派发指令标志位给主循环
                       if (strcmp(label, "last") == 0) voiceCommandFlag = 1;      
                       else if (strcmp(label, "next") == 0) voiceCommandFlag = 2;      
                       else if (strcmp(label, "pause") == 0) voiceCommandFlag = 3;      
                       
                       last_trigger_time = millis(); // 进入冷却期
                  }
              }
          }
      }
  }
}

voiceCommandFlag 被置位后,Core 1 的主循环会立即捕捉到该标志,并调用对应的切歌或暂停函数,完成最终的设备控制。

总结

此次难点还是在语音识别,尽可能保持识别效果,但是还是多多少少还会有点误判。

附件下载
music_box.ino
code.py
karenty-project-1_inferencing.zip
团队介绍
个人
评论
0 / 100
查看更多
硬禾服务号
关注最新动态
0512-67862536
info@eetree.cn
江苏省苏州市苏州工业园区新平街388号腾飞创新园A2幢815室
苏州硬禾信息科技有限公司
Copyright © 2024 苏州硬禾信息科技有限公司 All Rights Reserved 苏ICP备19040198号