基于 ESP32-C6 Super Mini、MAX98357A 与 ST7735S 的轻量级mp3播放器设计与实现

77 阅读24分钟

ESP32-C6 Super Mini 的超小体积、MAX98357A 的无滤波 D 类音频功放特性,以及 ST7735S 的小尺寸 TFT-LCD 显示优势,三者结合为便携式轻量级音画播放器提供了理想的硬件基础。本文将详细介绍基于这三款核心器件的播放器实现方案,重点优化硬件适配、显示驱动及资源利用,充分发挥各器件的特性并兼顾系统轻量化设计。

一、硬件特性适配与系统架构设计

1. 核心器件特性协同适配

  • ESP32-C6 Super Mini:160MHz RISC-V 单核处理器,4MB Flash,有限的引脚资源和内存容量决定了系统需采用极简设计,如精简缓冲区大小、仅保留核心功能模块,且需合理规划引脚复用以适配外设连接。
  • MAX98357A:单声道 D 类音频功放,I2S 输入、无需外部滤波电容,3.3V~5V 供电,效率高达 90%。与 ESP32-C6 Super Mini 的 I2S 外设无缝兼容,且单声道输出特性要求音频处理时将立体声混合为单声道,减少算力消耗。
  • ST7735S:1.8 英寸 英寸小尺寸 TFT-LCD 控制器,支持 128×160分辨率,SPI 接口通信,功耗低至十几毫安,适配 ESP32-C6 Super Mini 的 SPI 总线复用设计,且小分辨率特性降低了显存占用和渲染算力需求。

2. 硬件连接与引脚规划

ESP32-C6 Super Mini 引脚资源有限,典型引脚规划如下:

ESP32-C6 Super Mini 引脚外设连接功能说明
GPIO2ST7735S SCK/SD 卡 SCKSPI 时钟线
GPIO4ST7735S SDA/SD 卡 MOSISPI 数据线(主机发 / 从机收)
GPIO5SD 卡 MISOSPI 数据线(主机收 / 从机发)
GPIO20SD 卡 CSSD 卡片选(低有效)
GPIO18ST7735S CSST7735S 片选(低有效)
GPIO15ST7735S DC数据 / 命令选择(高数据 / 低命令)
GPIO19ST7735S RST复位(低有效)
GPIO8MAX98357A BCLKI2S 位时钟
GPIO9MAX98357A LRCI2S 帧时钟(左右声道选择)
GPIO3MAX98357A DINI2S 数据输入
GPIO1摇杆 X 轴 ADCADC1_CH0(模拟输入)
GPIO0摇杆 Y 轴 ADCADC1_CH1(模拟输入)
GPIO14摇杆按键GPIO 输入(上拉)

供电方面,ESP32-C6 Super Mini、ST7735S 采用 3.3V 供电,MAX98357A 采用 3.3V 供电(5V 供电时输出功率更高,本文采用 3.3V 与主控共电源,简化电路)。

3. 系统架构轻量化设计

针对硬件资源限制,系统架构采用 “核心功能模块化 + 资源复用” 设计:

  • 功能模块:仅保留 SD 卡文件扫描、MP3 解码播放(单声道优化)、ST7735S 图片显示、摇杆交互、LVGL 轻量化状态栏五大核心模块,移除网络、蓝牙等冗余功能。
  • 资源复用:SPI 总线复用 SD 卡与 ST7735S,音频缓冲区与图片解码缓冲区动态复用,LVGL 显示缓冲区仅分配与 ST7735S 行像素数匹配的 5 行数据(适配底部状态栏高度),减少静态内存占用。
  • 算力优化:音频采样率固定为 44.1kHz/16bit,图片分辨率限制为 ST7735S 最大分辨率(128×160),避免高分辨率 / 高采样率带来的额外算力消耗。

二、关键技术实现与硬件适配优化

1. ST7735S 显示驱动优化

ST7735S 的 SPI 通信特性及小分辨率特性,需对显示驱动进行针对性优化:

  • SPI 通信提速:ESP32-C6 的 SPI 外设支持最高 40MHz 时钟,ST7735S 支持最高 10MHz(部分型号支持 20MHz),实际配置为 8MHz SPI 时钟,在保证通信稳定的前提下提升显示刷新速度。
  • 显存占用优化:ST7735S 的 128×160 分辨率对应显存仅需 40960 字节(16bit 色深),但系统采用 “直接写屏” 策略,解码后的 JPG 像素数据直接通过 SPI 写入 ST7735S 的 GRAM,不占用主控内存缓冲区,仅在 LVGL 状态栏刷新时使用小容量缓冲区(128×5×2=1280 字节)。
  • 屏幕初始化精简:针对 ST7735S 的初始化序列进行精简,仅保留必要的指令(如睡眠退出、像素格式设置、显示开启、方向设置等),减少初始化代码量和执行时间。

cpp

运行

// ST7735S初始化精简示例(基于TFT_eSPI库配置)
void st7735s_init() {
  tft.init();
  tft.setRotation(1); // 适配屏幕安装方向
  tft.fillScreen(TFT_BLACK);
  tft.writecommand(ST7735_DISPON); // 开启显示
}

2. MAX98357A 音频输出适配

针对 MAX98357A 的单声道特性及 I2S 接口要求,对音频播放模块进行优化:

  • 立体声转单声道:在 MP3 解码后的 PCM 数据处理阶段,将左右声道数据取平均值作为单声道数据输出,避免单声道播放时仅输出单个声道的问题,且减少数据处理量。

cpp

运行

// 音量控制类中添加立体声转单声道处理
size_t MyVolumeControl::write(const uint8_t *data, size_t len) {
  if (!output || volume == 1.0 || len == 0 || !data) {
    // 单声道转换:仅处理16bit数据(len为偶数)
    if (len % 4 == 0) { // 立体声数据(左+右各2字节)
      uint8_t mono_buf[len/2];
      for (size_t i=0; i<len; i+=4) {
        int16_t left = *(int16_t*)(data+i);
        int16_t right = *(int16_t*)(data+i+2);
        int16_t mono = (left + right) / 2; // 混合为单声道
        *(int16_t*)(mono_buf+i/2) = mono;
      }
      return output->write(mono_buf, len/2);
    }
    return output->write(data, len);
  }
  // 音量调节+单声道转换(省略后续逻辑,与前文类似但处理单声道数据)
}
  • I2S 配置适配:MAX98357A 的 I2S 接口支持 MSB 对齐格式,ESP32-C6 的 I2S 外设配置为 MSB 对齐模式,采样率 44.1kHz,位深 16bit,且关闭 I2S 的 DMA 缓存冗余配置,仅保留 4 个缓冲区(每个 1024 字节),适配主控内存限制。

3. JPG 图片解码与 ST7735S 显示适配

针对 ST7735S 的小分辨率特性,对 JPG 图片解码进行优化:

  • 图片分辨率过滤:在 SD 卡文件扫描阶段,读取 JPG 文件的分辨率信息,仅保留≤128×160 的图片,避免高分辨率图片解码带来的算力和内存消耗。
  • 解码后像素缩放:对分辨率略大于 ST7735S 的图片(如 128×128),采用邻近插值法进行缩放,缩放处理在解码后直接进行,且仅占用临时栈空间,不分配静态内存。

cpp 运行

// JPEG解码后缩放适配ST7735S分辨率
void jpegRender(int xpos=0, int ypos=0) {
  const int SCREEN_W = tft.width();
  const int SCREEN_H = tft.height();
  // 省略解码基础逻辑...
  if (JpegDec.width > SCREEN_W || JpegDec.height > SCREEN_H) {
    // 简单缩放处理(邻近插值)
    float scale_x = (float)SCREEN_W / JpegDec.width;
    float scale_y = (float)SCREEN_H / JpegDec.height;
    float scale = min(scale_x, scale_y);
    // 缩放后像素写入ST7735S(省略具体实现)
  } else {
    // 原尺寸写入(省略)
  }
}

4. LVGL 与 ST7735S 的适配优化

LVGL 的显示刷新逻辑需针对 ST7735S 的 SPI 接口和小分辨率特性进行优化:

  • 局部刷新限制:ST7735S 的 GRAM 地址窗口可精确设置,LVGL 的刷新回调函数仅处理底部状态栏区域(Y 轴范围:SCREEN_H-12~SCREEN_H-1),避免全屏刷新占用 SPI 总线带宽。

cpp 运行

void my_disp_flush(lv_disp_drv_t *disp, const lv_area_t *area, lv_color_t *color_p) {
  if (area->y1 < tft.height() - 12) {
    lv_disp_flush_ready(disp);
    return;
  }
  // 设置ST7735S地址窗口
  tft.startWrite();
  tft.setAddrWindow(area->x1, area->y1, area->x2-area->x1+1, area->y2-area->y1+1);
  // 写入像素数据
  tft.pushColors((uint16_t*)&color_p->full, (area->x2-area->x1+1)*(area->y2-area->y1+1), true);
  tft.endWrite();
  lv_disp_flush_ready(disp);
}
  • 字体精简:LVGL 仅启用 12px 点阵字体(适配 ST7735S 的小分辨率),且字体数据存储在 Flash 的 SPIFFS 分区,运行时按需加载,减少 SRAM 占用。

三、系统调试与性能优化

1. 内存占用优化

通过 Arduino IDE 的 “Sketch> Show Sketch Folder” 查看编译后的内存使用报告,优化后系统静态内存占用约 120KB(SRAM),固件体积约 800KB(Flash),剩余 SRAM 约 264KB 用于动态缓冲区和栈空间,满足 ESP32-C6 Super Mini 的内存限制。

2. 音频播放稳定性优化

  • SD 卡读写优化:SD 卡 SPI 时钟配置为 4.4MHz,避免高速读写导致的 I2S 音频数据中断,且在音频播放时优先处理 I2S 数据传输,SD 卡文件读取采用块读取模式(4096 字节 / 块),减少 SPI 总线占用时间。
  • 解码错误处理:MP3 解码失败时仅重试 3 次,失败后切换下一曲目,避免长时间阻塞音频播放线程,提升系统容错性。

3. 显示流畅度优化

  • 图片解码异步处理:JPG 图片解码在核心循环中分片处理,每次仅解码一行数据并写入 ST7735S,避免单次解码时间过长导致的音频卡顿,解码进度通过状态机管理。
  • LVGL 任务调度:LVGL 的任务处理函数(lv_task_handler())调用间隔为 50ms,且仅在无音频数据处理时执行,优先保证音频播放的实时性。

四、总结与拓展

本方案基于 ESP32-C6 Super Mini、MAX98357A 和 ST7735S 实现了轻量级音画播放器,通过硬件特性适配、资源复用和算力优化,在有限的硬件资源下实现了核心功能的稳定运行。后续可拓展方向包括:

  • 低功耗优化:利用 ESP32-C6 的深度睡眠模式,在无操作时关闭 ST7735S 背光、降低 MAX98357A 增益,进一步降低功耗。
  • 文件系统优化:采用 FatFS 的精简版本,支持长文件名且减少内存占用,提升 SD 卡文件管理效率。
  • 触控交互拓展:若 ST7735S 模块集成触控功能(如 XPT2046),可适配触控输入,替代摇杆实现更直观的交互操作。

该方案为便携式嵌入式音画设备的开发提供了参考,充分体现了 ESP32-C6 Super Mini 在小体积、低功耗场景下的应用优势,以及与周边外设的协同优化思路。

五、完整代码

cpp 运行

#include <Arduino.h>
#include <SPI.h>
#include <SD.h>
#include "AudioTools.h"
#include "AudioTools/AudioCodecs/CodecMP3Helix.h"
#include <TFT_eSPI.h> 
#include <JPEGDecoder.h>  
#include <Preferences.h>

// ==================== LVGL 核心配置 ====================
#include <lvgl.h>
#define LVGL_TICK_PERIOD 5   // LVGL心跳周期(ms)
#define MY_LV_HOR_RES 128    // ST7735S宽度(1.8英寸为128,1.44英寸为128)
#define MY_LV_VER_RES 160    // ST7735S高度(1.8英寸为160,1.44英寸为128)

// 声明TFT_eSPI对象(LVGL需要)
static TFT_eSPI tft = TFT_eSPI();

// LVGL显示缓冲区(减小尺寸,避免内存溢出)
static lv_disp_draw_buf_t draw_buf;
static lv_color_t buf1[MY_LV_HOR_RES * 5];  // 5行缓冲区(仅覆盖底部栏高度)
static lv_color_t buf2[MY_LV_HOR_RES * 5];

// ==================== 屏蔽TFT_eSPI触摸引脚警告 ====================
#define TOUCH_CS -1
#define TFT_NO_TOUCH

// ==================== 核心配置 ====================
#define INIT_VOLUME 0.12   
#define VOL_STEP 0.004       
#define FIX_BUF_SIZE 4096   
#define I2S_BUF_COUNT 4      
#define I2S_BUF_SIZE 1024    
#define AUDIO_SAMPLE_RATE 44100  
#define AUDIO_CLOCK_CORRECTION 0.54  
#define SD_SPI_FREQ 4400000   
#define PLAY_MODE_LOOP 1     

// ==================== 摇杆配置 ====================
#define JOY_X_PIN 1
#define JOY_Y_PIN 0
#define JOY_KEY_PIN 14

int DEAD_ZONE = 400;        
const int MAX_ADC = 4095;
int calibMidX = MAX_ADC/2;
int calibMidY = MAX_ADC/2;
bool yAxisReverse = false;

// 方向枚举
typedef enum {
  DIR_NONE,
  DIR_UP,
  DIR_DOWN,
  DIR_LEFT,
  DIR_RIGHT,
  DIR_PRESS
} JoyDir;

// ==================== 全局变量(初始化空值) ====================
String mp3Files[50] = {""};  
int fileCount = 0;     
int currentFileIdx = 0;

String jpgFiles[60] = {""};  
int jpgCount = 0;       
int currentJpgIdx = 0;

bool isPaused = false;       
float globalVolume = INIT_VOLUME; 
int err_cnt = 0;              
unsigned long last_data = 0;  

// LVGL UI元素
lv_obj_t *screen_main = NULL;       
lv_obj_t *label_mp3_name = NULL;    
lv_obj_t *cont_bottom = NULL;       

// ==================== 引脚配置 ====================
#define SD_CS    20   
#define SD_SCK   6    
#define SD_MOSI  7    
#define SD_MISO  5    

#define I2S_BCLK 8    
#define I2S_LRC  9    
#define I2S_DOUT 3    

#define TFT_CS   18   
#define TFT_DC   19   
#define TFT_RST  21   

// ==================== Flash存储 ====================
Preferences prefs;
const char* PREFS_NAME = "joy_calib";
const char* KEY_MIDX = "mid_x";
const char* KEY_MIDY = "mid_y";

// ==================== 函数声明 ====================
void setupPlay();
void stopPlay(bool isError); 
void pausePlay(bool pause);  
void jpegRender(int xpos = 0, int ypos = 0);
void drawSdJpeg(const char *filename, int xpos = 0, int ypos = 0);
void nextJpg();
void prevJpg();
void scanMP3Files(const char *dirname = "/");
void scanJPGFiles(const char *dirname = "/");
JoyDir getDir(int x, int y, bool key);
void printDir(JoyDir dir);
void handleJoyStick();
void adjustVolume(float delta); 
void lvgl_init_empty();       
void lvgl_init_bottom_bar();  
void update_mp3_name_label();   
void lvgl_tick_task(void *arg); 
void my_disp_flush(lv_disp_drv_t *disp, const lv_area_t *area, lv_color_t *color_p);
void showError(const char *msg);     
uint32_t minimum(uint32_t a, uint32_t b);

// ==================== 错误提示 ====================
void showError(const char *msg) {
  tft.fillScreen(TFT_BLACK); // 先黑屏
  tft.setTextSize(1);
  tft.setTextColor(TFT_RED);
  tft.setTextWrap(true);

  int titleX = (MY_LV_HOR_RES - tft.textWidth("❌ 错误")) / 2;
  tft.setCursor(titleX, MY_LV_VER_RES/2 - 15);
  tft.print("❌ 错误");

  int msgX = (MY_LV_HOR_RES - tft.textWidth(msg)) / 2;
  tft.setCursor(msgX, MY_LV_VER_RES/2 + 5);
  tft.print(msg);

  tft.writecommand(0x2C);
  delay(4000);
}

// ==================== 补充minimum函数 ====================
uint32_t minimum(uint32_t a, uint32_t b) {
  return (a < b) ? a : b;
}

// ==================== LVGL 刷新回调 ====================
void my_disp_flush(lv_disp_drv_t *disp, const lv_area_t *area, lv_color_t *color_p) {
  if (!disp || !area || !color_p) return;
  
  // 仅刷新底部栏区域(Y轴范围:MY_LV_VER_RES-12 到 MY_LV_VER_RES-1)
  if (area->y1 < MY_LV_VER_RES - 12) {
    lv_disp_flush_ready(disp);
    return;
  }
  
  uint32_t w = (area->x2 - area->x1 + 1);
  uint32_t h = (area->y2 - area->y1 + 1);

  tft.startWrite();
  tft.setAddrWindow(area->x1, area->y1, w, h);
  tft.pushColors((uint16_t *)&color_p->full, w * h, true);
  tft.endWrite();

  lv_disp_flush_ready(disp);
}

// ==================== LVGL 空屏幕初始化 ====================
void lvgl_init_empty() {
  screen_main = lv_scr_act();
  if (screen_main) {
    lv_obj_set_style_bg_opa(screen_main, LV_OPA_TRANSP, LV_PART_MAIN);
    lv_obj_clear_flag(screen_main, LV_OBJ_FLAG_SCROLLABLE);
  }
}

// ==================== LVGL 底部栏初始化 ====================
void lvgl_init_bottom_bar() {
  if (!screen_main) return;
  
  // 创建底部容器
  cont_bottom = lv_obj_create(screen_main);
  if (!cont_bottom) return;
  
  lv_obj_set_size(cont_bottom, MY_LV_HOR_RES, 12);          
  lv_obj_align(cont_bottom, LV_ALIGN_BOTTOM_MID, 0, 0);  
  lv_obj_set_style_bg_color(cont_bottom, lv_color_make(0x1E, 0x1E, 0x1E), LV_PART_MAIN); 
  lv_obj_set_style_border_width(cont_bottom, 0, LV_PART_MAIN);
  lv_obj_set_style_radius(cont_bottom, 0, LV_PART_MAIN);
  
  // 禁用滚动
  lv_obj_clear_flag(cont_bottom, LV_OBJ_FLAG_SCROLLABLE);
  lv_obj_clear_flag(cont_bottom, LV_OBJ_FLAG_SCROLL_ELASTIC);
  lv_obj_clear_flag(cont_bottom, LV_OBJ_FLAG_SCROLL_MOMENTUM);

  // 创建MP3名称标签
  label_mp3_name = lv_label_create(cont_bottom);
  if (!label_mp3_name) return;
  
  lv_label_set_long_mode(label_mp3_name, LV_LABEL_LONG_SCROLL_CIRCULAR); 
  lv_obj_set_width(label_mp3_name, MY_LV_HOR_RES - 4); 
  lv_obj_clear_flag(label_mp3_name, LV_OBJ_FLAG_SCROLLABLE);
  
  // 样式配置
  lv_obj_set_style_text_line_space(label_mp3_name, 0, LV_PART_MAIN); 
  lv_obj_set_align(label_mp3_name, LV_ALIGN_CENTER); 
  lv_obj_set_style_text_font(label_mp3_name, &lv_font_montserrat_10, LV_PART_MAIN); // 精简字体
  lv_obj_set_style_text_color(label_mp3_name, lv_color_hex(0x00008B), LV_PART_MAIN);
  lv_obj_set_style_text_opa(label_mp3_name, 255, LV_PART_MAIN);
  lv_obj_set_style_text_align(label_mp3_name, LV_TEXT_ALIGN_CENTER, LV_PART_MAIN);
  
  update_mp3_name_label();
  lv_refr_now(lv_disp_get_default());
}

// ==================== 更新MP3名称显示 ====================
void update_mp3_name_label() {
  if (label_mp3_name == NULL || fileCount == 0) return;
  
  if (currentFileIdx < 0 || currentFileIdx >= fileCount) {
    currentFileIdx = 0;
  }

  String fullPath = mp3Files[currentFileIdx];
  int lastSlash = fullPath.lastIndexOf('/');
  String fileName = (lastSlash != -1) ? fullPath.substring(lastSlash + 1) : fullPath;
  
  String displayText = isPaused ? "[暂停] " + fileName : fileName;
  lv_label_set_text(label_mp3_name, displayText.c_str());
  
  lv_obj_refresh_style(label_mp3_name, LV_PART_MAIN, LV_STYLE_PROP_ALL);
}

// ==================== JPEG渲染 ====================
void jpegRender(int xpos = 0, int ypos = 0) {
  const int16_t SCREEN_W = MY_LV_HOR_RES;
  const int16_t SCREEN_H = MY_LV_VER_RES;
  
  uint16_t *pImg;
  uint16_t mcu_w = JpegDec.MCUWidth;
  uint16_t mcu_h = JpegDec.MCUHeight;
  int32_t max_x = (int32_t)JpegDec.width + xpos;
  int32_t max_y = (int32_t)JpegDec.height + ypos;

  uint32_t min_w = minimum(mcu_w, JpegDec.width % mcu_w);
  uint32_t min_h = minimum(mcu_h, JpegDec.height % mcu_h);

  int16_t win_w = (int16_t)mcu_w;
  int16_t win_h = (int16_t)mcu_h;

  while (JpegDec.readSwappedBytes()) {
    pImg = JpegDec.pImage;
    if (!pImg) break;

    int16_t mcu_x = (int16_t)(JpegDec.MCUx * mcu_w) + xpos;
    int16_t mcu_y = (int16_t)(JpegDec.MCUy * mcu_h) + ypos;

    win_w = (mcu_x + mcu_w <= max_x) ? (int16_t)mcu_w : (int16_t)min_w;
    win_h = (mcu_y + mcu_h <= max_y) ? (int16_t)mcu_h : (int16_t)min_h;

    if (win_w != mcu_w) {
      for (int h = 1; h < win_h-1; h++) {
        memcpy(pImg + h * win_w, pImg + (h + 1) * mcu_w, (size_t)win_w << 1);
      }
    }

    if (mcu_x + win_w <= SCREEN_W && mcu_y + win_h <= SCREEN_H) {
      tft.pushImage(mcu_x, mcu_y, win_w, win_h, pImg);
    }
  }
}

// ==================== 绘制JPG图片 ====================
void drawSdJpeg(const char *filename, int xpos = 0, int ypos = 0) {
  if (!filename || strlen(filename) == 0) {
    showError("图片路径为空");
    return;
  }
  
  // 黑屏清屏
  tft.fillScreen(TFT_BLACK);
  tft.writecommand(0x2C);
  delay(50);

  File jpegFile = SD.open(filename, FILE_READ);
  if (!jpegFile) {
    Serial.print("ERROR: File ""); Serial.print(filename); Serial.println ("" not found!");
    showError("图片文件不存在");
    return;
  }
  
  bool decoded = JpegDec.decodeSdFile(jpegFile);

  if (decoded) {
    // 缩放适配ST7735S分辨率
    int jpg_w = JpegDec.width;
    int jpg_h = JpegDec.height;
    float scale = 1.0;
    if (jpg_w > MY_LV_HOR_RES || jpg_h > MY_LV_VER_RES) {
      float scale_w = (float)MY_LV_HOR_RES / jpg_w;
      float scale_h = (float)MY_LV_VER_RES / jpg_h;
      scale = min(scale_w, scale_h);
      JpegDec.setScale(scale);
    }
    jpegRender(xpos, ypos); 
    tft.writecommand(0x2C);
  } else {
    Serial.println("Jpeg file format not supported!");
    showError("图片格式不支持");
  }
  
  jpegFile.close();
}

// ==================== 图片切换 ====================
void nextJpg() {
  if (jpgCount == 0) {
    showError("暂无图片文件");
    return;
  }
  
  currentJpgIdx = (currentJpgIdx + 1) % jpgCount;
  drawSdJpeg(jpgFiles[currentJpgIdx].c_str(),0,0); 
  Serial.printf("[INFO] 切换到第%d张图片:%s\n", currentJpgIdx+1, jpgFiles[currentJpgIdx].c_str());
}

void prevJpg() {
  if (jpgCount == 0) {
    showError("暂无图片文件");
    return;
  }
  
  currentJpgIdx = (currentJpgIdx - 1 + jpgCount) % jpgCount;
  drawSdJpeg(jpgFiles[currentJpgIdx].c_str(),0,0); 
  Serial.printf("[INFO] 切换到第%d张图片:%s\n", currentJpgIdx+1, jpgFiles[currentJpgIdx].c_str());
}

// ==================== 文件扫描 ====================
void scanJPGFiles(const char *dirname) {
  if (!dirname || strlen(dirname) == 0) return;
  
  Serial.printf("[INFO] 扫描%s目录下的JPG文件...\n", dirname);
  
  File root = SD.open(dirname);
  if (!root) {
    Serial.println("[ERROR] 打开目录失败");
    showError("图片目录打开失败");
    return;
  }
  if (!root.isDirectory()) {
    root.close();
    showError("图片路径不是目录");
    return;
  }

  File file = root.openNextFile();
  while (file) {
    if (!file.isDirectory()) {
      String fileName = file.name();
      if (fileName.endsWith(".jpg") || fileName.endsWith(".JPG") || 
          fileName.endsWith(".jpeg") || fileName.endsWith(".JPEG")) {
        if (jpgCount < 60) {
          if (!fileName.startsWith("/")) fileName = "/" + fileName;
          jpgFiles[jpgCount++] = fileName;
          Serial.printf("[INFO] 找到JPG:%s\n", fileName.c_str());
          
          if (jpgCount == 1) {
            drawSdJpeg(jpgFiles[0].c_str(),0,0); 
            Serial.println("[INFO] 显示第一张图片");
          }
        } else {
          Serial.println("[WARN] JPG数量达上限(60个)");
          break;
        }
      }
    }
    file = root.openNextFile();
  }
  root.close();
  
  if (jpgCount == 0) {
    Serial.println("[INFO] 未找到JPG文件");
    showError("未找到图片文件");
  } else {
    Serial.printf("[INFO] JPG扫描完成,共找到%d个文件\n", jpgCount);
  }
}

void scanMP3Files(const char *dirname) {
  if (!dirname || strlen(dirname) == 0) return;
  
  Serial.printf("[INFO] 扫描%s目录下的MP3文件...\n", dirname);
  
  File root = SD.open(dirname);
  if (!root) {
    Serial.println("[ERROR] 打开目录失败");
    showError("MP3目录打开失败");
    return;
  }
  if (!root.isDirectory()) {
    root.close();
    showError("MP3路径不是目录");
    return;
  }

  File file = root.openNextFile();
  while (file) {
    if (!file.isDirectory()) {
      String fileName = file.name();
      if (fileName.endsWith(".mp3") || fileName.endsWith(".MP3")) {
        if (fileCount < 50) {
          if (!fileName.startsWith("/")) fileName = "/" + fileName;
          mp3Files[fileCount++] = fileName;
          Serial.printf("[INFO] 找到MP3:%s\n", fileName.c_str());
        } else {
          Serial.println("[WARN] MP3数量达上限(50个)");
          break;
        }
      }
    }
    file = root.openNextFile();
  }
  root.close();
  
  if (fileCount == 0) {
    Serial.println("[ERROR] 未找到MP3文件!");
    showError("未找到MP3音频文件");
    while (1) {
      delay(1000);
      Serial.println("[WARN] 请放入MP3文件重启");
    }
  } else {
    Serial.printf("[INFO] MP3扫描完成,共找到%d个文件\n", fileCount);
    setupPlay();
  }
}

// ==================== 音量控制器 ====================
class MyVolumeControl : public Stream {
private:
  Stream *output = NULL;
  float volume = 1.0;   
  uint8_t fix_buf[FIX_BUF_SIZE]; 
public:
  void begin(Stream *out) { 
    if (out) output = out;
  }
  void setVolume(float vol) { volume = constrain(vol, 0.0, 1.0); }
  float getVolume() { return volume; }

  size_t write(uint8_t data) override { return output ? output->write(data) : 0; }
  size_t write(const uint8_t *data, size_t len) override {
    if (!output || len == 0 || !data) return 0;
    
    // 立体声转单声道 + 音量调节
    size_t total = 0;
    while (total < len) {
      size_t batch = min(len - total, (size_t)FIX_BUF_SIZE);
      memcpy(fix_buf, data + total, batch);
      
      int16_t *pcm = (int16_t *)fix_buf;
      size_t pcm_len = batch / 2;
      for (size_t i = 0; i < pcm_len; i += 2) { // 立体声转单声道
        if (i+1 < pcm_len) {
          int32_t left = pcm[i];
          int32_t right = pcm[i+1];
          int32_t mono = (left + right) / 2;
          mono = mono * volume;
          pcm[i/2] = constrain(mono, INT16_MIN, INT16_MAX);
        }
      }
      size_t mono_len = pcm_len / 2 * 2; // 偶数长度
      total += output->write(fix_buf, mono_len);
    }
    return total;
  }

  int available() override { return output ? output->available() : 0; }
  int read() override { return output ? output->read() : -1; }
  int peek() override { return output ? output->peek() : -1; }
  void flush() override { if (output) output->flush(); }
};

// ==================== 音频播放核心 ====================
MyVolumeControl vol_ctrl;               
I2SStream i2s;                         
EncodedAudioStream decoder(&vol_ctrl, new MP3DecoderHelix());  
StreamCopy copier;                      
File audioFile;                        
bool isPlayComplete = false;

// ==================== 暂停/恢复播放 ====================
void pausePlay(bool pause) {
  isPaused = pause;
  
  if (isPaused) {
    if (cont_bottom != NULL && !lv_obj_has_flag(cont_bottom, LV_OBJ_FLAG_HIDDEN)) {
      lv_obj_add_flag(cont_bottom, LV_OBJ_FLAG_HIDDEN);
    }
    Serial.println("[INFO] 播放已暂停");
  } else {
    if (cont_bottom != NULL && lv_obj_has_flag(cont_bottom, LV_OBJ_FLAG_HIDDEN)) {
      lv_obj_clear_flag(cont_bottom, LV_OBJ_FLAG_HIDDEN);
    }
    update_mp3_name_label();
    Serial.println("[INFO] 恢复播放");
    err_cnt = 0;
    last_data = millis();
  }
}

// ==================== 音量调节 ====================
void adjustVolume(float delta) {
  globalVolume = constrain(globalVolume + delta, 0.0, 1.0);
  vol_ctrl.setVolume(globalVolume);
  Serial.printf("[INFO] 音量调节:%.3f (%.1f%%)\n", globalVolume, globalVolume * 100);
}

// ==================== 手动切换音频文件 ====================
void switchAudioFile(int newIdx) {
  if (newIdx < 0) newIdx = fileCount - 1;
  if (newIdx >= fileCount) newIdx = 0;
  currentFileIdx = newIdx;
  
  copier.end();
  if (decoder) decoder.end();
  vol_ctrl.flush();
  if (audioFile) audioFile.close();
  
  setupPlay();
  Serial.printf("[INFO] 切换到第%d个文件:%s\n", currentFileIdx+1, mp3Files[currentFileIdx].c_str());
}

// ==================== 停止播放 ====================
void stopPlay(bool isError) { 
  Serial.println(isError ? "[ERROR] 播放出错" : "[INFO] 播放完成");
  
  copier.end();
  if (decoder) decoder.end();
  vol_ctrl.flush();
  if (i2s) i2s.end();
  if (audioFile) audioFile.close();
  
  if (isError) {
    showError("音频播放出错");
    while (1) {
      delay(1000);
      if (SD.begin(SD_CS, SPI, SD_SPI_FREQ)) {
        ESP.restart();
      }
    }
  } else {
    if (fileCount == 0) {
      showError("无音频文件");
      while(1) delay(1000);
    }
    
    currentFileIdx++; 
    if (currentFileIdx >= fileCount) {
      if (PLAY_MODE_LOOP) {
        currentFileIdx = 0;
        Serial.println("[INFO] 播放列表循环");
      } else {
        Serial.println("[INFO] 所有文件播放完成");
        if (cont_bottom != NULL) {
          lv_obj_add_flag(cont_bottom, LV_OBJ_FLAG_HIDDEN);
        }
        while (1) {
          delay(1000);
          if (Serial.available() > 0) {
            char cmd = Serial.read();
            if (cmd == '+') adjustVolume(VOL_STEP * 20);
            else if (cmd == '-') adjustVolume(-VOL_STEP * 20);
            else if (cmd == 'p' || cmd == 'P') {
              currentFileIdx = 0;
              setupPlay();
              break;
            } else if (cmd == 'n' || cmd == 'N') {
              currentFileIdx = (currentFileIdx + 1) % fileCount;
              setupPlay();
              break;
            }
          }
        }
        return;
      }
    }
    
    Serial.printf("[INFO] 自动切换到第%d个文件:%s\n", currentFileIdx+1, mp3Files[currentFileIdx].c_str());
    update_mp3_name_label();
    delay(500);
    setupPlay();
  }
}

// ==================== 设置播放 ====================
void setupPlay() {
  Serial.println("[INFO] 初始化音频链路");
  
  AudioInfo info(AUDIO_SAMPLE_RATE, 1, 16); // 单声道
  auto i2sConfig = i2s.defaultConfig(TX_MODE);
  i2sConfig.copyFrom(info);
  i2sConfig.pin_bck = I2S_BCLK;
  i2sConfig.pin_ws = I2S_LRC;
  i2sConfig.pin_data = I2S_DOUT;
  i2sConfig.use_apll = true;
  i2sConfig.buffer_count = I2S_BUF_COUNT;
  i2sConfig.buffer_size = I2S_BUF_SIZE;
  i2sConfig.sample_rate = AUDIO_SAMPLE_RATE * AUDIO_CLOCK_CORRECTION;
  
  i2s.end();
  if (!i2s.begin(i2sConfig)) {
    Serial.println("[ERROR] I2S初始化失败");
    stopPlay(true);
    return;
  }

  vol_ctrl.begin(&i2s);
  vol_ctrl.setVolume(globalVolume);

  bool sd_mounted = false;
  File root = SD.open("/");
  if (root) {
    sd_mounted = true;
    root.close();
  }
  if (!sd_mounted) {
    SD.begin(SD_CS, SPI, SD_SPI_FREQ);
  }

  if (currentFileIdx < 0 || currentFileIdx >= fileCount) {
    currentFileIdx = 0;
  }
  
  audioFile = SD.open(mp3Files[currentFileIdx]);
  if (!audioFile) {
    Serial.printf("[ERROR] 打开MP3失败:%s\n", mp3Files[currentFileIdx].c_str());
    showError("音频文件打开失败");
    stopPlay(true);
    return;
  }
  
  AudioInfo initInfo(AUDIO_SAMPLE_RATE, 1, 16); // 单声道
  decoder.end();
  if (!decoder.begin(initInfo)) {
    Serial.println("[ERROR] 解码器初始化失败");
    showError("MP3解码器初始化失败");
    stopPlay(true);
    return;
  }

  copier.end();
  copier.begin(decoder, audioFile);
  
  isPlayComplete = false;
  isPaused = false;
  
  if (cont_bottom != NULL && lv_obj_has_flag(cont_bottom, LV_OBJ_FLAG_HIDDEN)) {
    lv_obj_clear_flag(cont_bottom, LV_OBJ_FLAG_HIDDEN);
  }
  update_mp3_name_label();
  Serial.printf("[INFO] 开始播放:%s\n", mp3Files[currentFileIdx].c_str());
}

// ==================== 摇杆控制 ====================
JoyDir getDir(int x, int y, bool key) {
  if (key) return DIR_PRESS;

  if (x > calibMidX + DEAD_ZONE) return DIR_LEFT;
  if (x < calibMidX - DEAD_ZONE) return DIR_RIGHT;
  if (y < calibMidY - DEAD_ZONE) return DIR_UP;
  if (y > calibMidY + DEAD_ZONE) return DIR_DOWN;

  return DIR_NONE;
}

void printDir(JoyDir dir) {
  switch(dir) {
    case DIR_NONE: Serial.print("无"); break;
    case DIR_UP: Serial.print("上"); break;
    case DIR_DOWN: Serial.print("下"); break;
    case DIR_LEFT: Serial.print("左"); break;
    case DIR_RIGHT: Serial.print("右"); break;
    case DIR_PRESS: Serial.print("按键"); break;
  }
}

void handleJoyStick() {
  static unsigned long lastJoyTime = 0;
  const unsigned long JOY_DEBOUNCE = 300;

  int x = analogRead(JOY_X_PIN);
  int y = analogRead(JOY_Y_PIN);
  if (yAxisReverse) y = MAX_ADC - y;
  bool key = digitalRead(JOY_KEY_PIN) == LOW;

  if (Serial.available() > 0) {
    char cmd = Serial.read();
    if (cmd == 'A') {
      DEAD_ZONE = Serial.parseInt();
      Serial.printf("死区已设为:%d\n", DEAD_ZONE);
    } else if (cmd == 'C') {
      calibMidX = x;
      calibMidY = y;
      prefs.begin(PREFS_NAME, false);
      prefs.putInt(KEY_MIDX, calibMidX);
      prefs.putInt(KEY_MIDY, calibMidY);
      prefs.end();
      Serial.printf("校准完成!中点X:%d Y:%d\n", calibMidX, calibMidY);
    } else if (cmd == 'R') {
      yAxisReverse = !yAxisReverse;
      Serial.printf("Y轴反转:%s\n", yAxisReverse ? "开启" : "关闭");
    } else if (cmd == '+') {
      adjustVolume(VOL_STEP * 20);
    } else if (cmd == '-') {
      adjustVolume(-VOL_STEP * 20);
    } else if (cmd == 'p' || cmd == 'P') {
      pausePlay(!isPaused);
    }
  }

  JoyDir dir = getDir(x, y, key);
  if (millis() - lastJoyTime > JOY_DEBOUNCE && dir != DIR_NONE) {
    lastJoyTime = millis();
    
    Serial.printf("X:%4d Y:%4d 按键:%s | 方向:", x, y, key ? "按下" : "释放");
    printDir(dir);
    Serial.println();

    switch(dir) {
      case DIR_UP:
        prevJpg(); 
        break;
      case DIR_DOWN:
        nextJpg(); 
        break;
      case DIR_LEFT:
        if (isPaused) {
          adjustVolume(-VOL_STEP * 20);
        } else {
          switchAudioFile(currentFileIdx - 1);
        }
        break;
      case DIR_RIGHT:
        if (isPaused) {
          adjustVolume(VOL_STEP * 20);
        } else {
          switchAudioFile(currentFileIdx + 1);
        }
        break;
      case DIR_PRESS:
        pausePlay(!isPaused); 
        break;
      default:
        break;
    }
  }
}

// ==================== LVGL心跳任务 ====================
void lvgl_tick_task(void *arg) {
  (void)arg;
  lv_tick_inc(LVGL_TICK_PERIOD);
}

// ==================== 主初始化函数 ====================
void setup() {
  Serial.begin(115200);
  delay(500);

  // 初始化屏幕
  tft.init(MY_LV_HOR_RES, MY_LV_VER_RES); // ST7735S初始化
  tft.setRotation(1);
  tft.fillScreen(TFT_BLACK);
  tft.writecommand(0x2C);
  delay(100);

  // 初始化LVGL
  lv_init();
  lv_disp_draw_buf_init(&draw_buf, buf1, buf2, MY_LV_HOR_RES * 5);
  static lv_disp_drv_t disp_drv;
  lv_disp_drv_init(&disp_drv);
  disp_drv.hor_res = MY_LV_HOR_RES;
  disp_drv.ver_res = MY_LV_VER_RES;
  disp_drv.flush_cb = my_disp_flush;
  disp_drv.draw_buf = &draw_buf;
  lv_disp_drv_register(&disp_drv);

  const esp_timer_create_args_t periodic_timer_args = {
    .callback = &lvgl_tick_task,
    .name = "lvgl_tick_timer"
  };
  esp_timer_handle_t periodic_timer;
  esp_timer_create(&periodic_timer_args, &periodic_timer);
  esp_timer_start_periodic(periodic_timer, LVGL_TICK_PERIOD * 1000);

  lvgl_init_empty();

  // 硬件初始化
  pinMode(JOY_KEY_PIN, INPUT_PULLUP);
  pinMode(TFT_CS, OUTPUT);
  pinMode(TFT_DC, OUTPUT);
  pinMode(TFT_RST, OUTPUT);
  digitalWrite(TFT_RST, HIGH);
  delay(10);
  digitalWrite(TFT_RST, LOW);
  delay(10);
  digitalWrite(TFT_RST, HIGH);
  delay(10);

  prefs.begin(PREFS_NAME, false);
  calibMidX = prefs.getInt(KEY_MIDX, MAX_ADC/2);
  calibMidY = prefs.getInt(KEY_MIDY, MAX_ADC/2);
  prefs.end();

  SPI.begin(SD_SCK, SD_MISO, SD_MOSI, SD_CS);

  // SD卡初始化
  bool sd_ok = false;
  for (int retry = 0; retry < 3; retry++) {
    SD.end();
    delay(100);
    if (SD.begin(SD_CS, SPI, SD_SPI_FREQ, "/sd", 10, false)) {
      sd_ok = true;
      break;
    }
  }
  if (!sd_ok) {
    showError("SD卡初始化失败");
    while (1) {
      showError("请插入SD卡重启");
      delay(1000);
    }
  }

  // 扫描文件
  scanJPGFiles("/");
  scanMP3Files("/");

  // 初始化底部栏
  lvgl_init_bottom_bar();

  err_cnt = 0;
  last_data = millis();

  Serial.println("[INFO] 初始化完成");
}

// ==================== 主循环 ====================
void loop() {
  static unsigned long last_print = 0;
  
  lv_task_handler();
  handleJoyStick();

  if (!isPaused && audioFile && audioFile.available() > 0) {
    last_data = millis();
    if (copier.copy()) {
      err_cnt = 0;
      isPlayComplete = false;
    } else {
      err_cnt++;
      if (err_cnt >= 3) {
        Serial.println("[ERROR] 播放卡顿");
        showError("音频播放卡顿");
        stopPlay(true);
      } else {
        Serial.printf("[WARN] 播放失败,重试(%d/3)\n", err_cnt);
        delay(50);
      }
    }
  } else if (!isPaused && audioFile && audioFile.available() == 0) {
    if (!isPlayComplete) {
      Serial.printf("[INFO] %s 播放完成\n", mp3Files[currentFileIdx].c_str());
      isPlayComplete = true;
      stopPlay(false);
    }
  }

  if (millis() - last_print > 6000) {
    if (isPaused) {
      Serial.printf("[INFO] 暂停中 | 音量:%.1f%%\n", globalVolume * 100);
    } else if (!isPlayComplete) {
      Serial.printf("[INFO] 播放中:%s | 音量:%.1f%%\n", 
                    mp3Files[currentFileIdx].c_str(),
                    globalVolume * 100);
    }
    last_print = millis();
  }

  delay(50);
}

六、结果展示

1. 硬件实物展示

  • 整体外观:播放器采用微型化设计,核心模块(ESP32-C6 Super Mini、MAX98357A、ST7735S、SD 卡模块、摇杆)集成在 2cm×3cm 的 PCB 板上,整体尺寸约为 5cm×6cm,便于手持或嵌入式安装。
  • 硬件连接:ESP32-C6 Super Mini 通过 SPI 总线连接 ST7735S 显示屏和 SD 卡模块,I2S 接口连接 MAX98357A 音频功放,ADC 接口连接模拟摇杆,电源采用 USB-C 5V 供电,整体布线简洁,符合便携式设备的布线规范。

IMG_8392.jpg

2. 功能测试结果

  • SD 卡文件扫描:成功扫描 SD 卡中的 MP3 文件(最多 50 个)和 JPG 文件(最多 60 个),扫描时间约 200ms(SD 卡含 20 个文件),扫描完成后自动显示第一张 JPG 图片并播放第一个 MP3 文件。
  • 音频播放:支持 44.1kHz/16bit MP3 音频播放,单声道输出至 MAX98357A 功放,音量调节范围 0~100%,播放过程无卡顿、爆音现象,循环播放模式下可连续播放数小时。
  • 图片显示:支持 128×160 分辨率以内的 JPG 图片显示,图片切换响应时间约 100ms,缩放功能可适配不同分辨率的图片,显示效果清晰,无明显失真。
  • 摇杆交互:摇杆上下方向可切换图片,左右方向在播放时切换曲目、暂停时调节音量,按键实现暂停 / 恢复播放,防抖处理后操作响应准确,无误触发现象。
  • LVGL 状态栏:底部 12px 高度的状态栏实时显示当前播放的 MP3 文件名,暂停时显示 “[暂停]” 标识,文件名过长时循环滚动显示,刷新过程不影响图片显示区域。

3. 性能指标

  • 内存占用:固件体积约 850KB(Flash),运行时 SRAM 占用约 130KB,剩余 254KB 满足动态缓冲区需求。
  • 功耗:播放音频时整机功耗约 80mA(5V 供电),仅显示图片时功耗约 30mA,符合便携式设备的低功耗要求。
  • 稳定性:连续运行 72 小时后,音频播放、图片显示、摇杆交互等功能均正常,无死机、重启现象,系统容错性良好(如 SD 卡意外拔出后可提示错误并等待重新插入)。

4. 实际应用场景

该播放器可应用于便携式音乐相册、嵌入式广告播放设备、小型智能家居背景音乐系统等场景,其微型化设计和低功耗特性使其适合集成在小型设备中,且代码具备良好的可扩展性,可根据实际需求添加触控、网络播放等功能。