From 45f7b83e7c2284e8278d0a8038242e14a3de9189 Mon Sep 17 00:00:00 2001 From: "francis.wang" Date: Tue, 27 Oct 2026 20:50:09 +0800 Subject: [PATCH] add flac support and files management app --- README.md | 324 ++++++++++--- components/BSP/MYI2S/myi2s.c | 51 +- main/APP/app.c | 8 +- main/APP/app.h | 4 + main/APP/files/ui_files.c | 863 +++++++++++++++++++++++++++++++++ main/APP/files/ui_files.h | 51 ++ main/APP/player/audio_source.c | 281 ++++++++++- main/APP/player/audio_source.h | 8 +- main/APP/player/player.c | 35 +- main/APP/ui.c | 37 +- main/APP/ui.h | 1 + main/CMakeLists.txt | 2 + 12 files changed, 1526 insertions(+), 139 deletions(-) create mode 100644 main/APP/files/ui_files.c create mode 100644 main/APP/files/ui_files.h diff --git a/README.md b/README.md index 3101408..04bdb1b 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,25 @@ # HiFi 播放器(ESP32-S3 + LVGL + ES8388 + SD 卡) -基于正点原子 **DNESP32S3** 开发板标准例程(IDF v5.3.x)移植改造的 HiFi 音乐播放器。 -使用 **SPI LCD** 作为显示界面、**SD 卡** 存储 WAV 音频、**4 个板载按键** 交互,界面底部实时标注每个按键的功能。 +基于正点原子 **DNESP32S3** 开发板标准例程移植改造的 HiFi 音乐播放器。 +使用 **SPI LCD** 作为显示界面、**SD 卡** 存储音频、**4 个板载按键** 交互, +界面底部实时标注每个按键的功能。 + +当前包含 **3 个应用**:主界面(应用启动器)、音乐播放器、文件管理器。 + +音频通路为 **原生位深透传**:源文件是 16/24/32bit,就按对应位宽重新配置 I2S 与 ES8388, +不做位深压缩,配合 FLAC 无损解码构成完整的无损链路。 + +--- + +## 版本记录 + +| 版本 | 主要变更 | +| :--- | :--- | +| V1.0 | 移植 BSP、LVGL 8.4、ES8388 + I2S,WAV 播放 | +| V1.2 | 应用启动器框架、全局 K0 返回键、按应用分文件夹 | +| V1.3 | iPhone 风格图标网格主界面 | +| V1.4 | 增加 FLAC 解码(`esphome/micro-flac`),WAV/FLAC 统一音源层 | +| **V1.5** | **原生位深透传 + I2S 位宽/采样率动态重配**;新增**文件管理器**应用;修复 `.flac` 文件无法被扫描到的问题 | --- @@ -9,9 +27,11 @@ | 功能 | 说明 | | :--- | :--- | -| 应用启动器 | 开机进入主界面,可选择并进入「音乐播放器」(预留 SETTINGS / ABOUT) | +| 应用启动器 | 开机进入主界面,图标网格选择应用(MUSIC / FILES 可用,SETTINGS / ABOUT 占位) | +| 音乐播放器 | **WAV**(PCM 16/24/32bit)与 **FLAC**(原生 FLAC / Ogg FLAC),支持 8k~96k 采样率 | +| 文件管理器 | 浏览 SD 卡全部文件,可进出任意层级子文件夹,显示文件大小与类型图标 | +| 原生位深输出 | 16/24/32bit 按源文件位深直接输出,**不做位深压缩**;I2S 位宽与采样率随曲目动态重配 | | 图形界面 | LVGL 8.4,320×240 横屏,深色 HiFi 风格 | -| 音频播放 | **WAV**(PCM 16/24/32bit)与 **FLAC**(原生 FLAC / Ogg FLAC),I2S + ES8388,支持 8k~96k 采样率 | | 存储 | SD 卡(SPI 模式),FATFS,音乐目录 `/MUSIC` | | 播放控制 | 播放/暂停 / 下一曲 / 音量切换(无上一曲,K0 用于返回) | | 状态显示 | 曲目序号、歌名、格式、播放进度、音量、运行状态 | @@ -20,25 +40,58 @@ | 音量记忆 | 音量保存在 NVS,掉电不丢失 | | 自动恢复 | SD 卡拔出/插入、目录为空时自动重新挂载与扫描 | -界面布局(两个界面): +### 界面布局 ``` -[主界面 HOME · iPhone 风格图标网格] [播放器 PLAYER] -┌──────────────────────────┐ ┌──────────────────────────┐ -│ ☰ APP LAUNCHER 1/4 │ │ ♪ HiFi PLAYER 01/12 │ -├──────────────────────────┤ ├──────────────────────────┤ -│ ╭──╮ ╭──╮ ╭──╮ ╭──╮ │ │ ┌────┐ 歌名 │ -│ │♪ │ │📁│ │⚙ │ │☰ │ │ K3 打开 │ │ ♪ │ WAV 44100Hz 16bit│ -│ ╰──╯ ╰──╯ ╰──╯ ╰──╯ │ ───────────▶ │ └────┘ ( PLAYING ) │ -│ MUSIC FILES SETTINGS … │ ◀─────────── │ 00:42 ────●────── 03:35 │ -│ WAV files in SD /MUSIC │ K0 返回 │ 🔊 ──────●───── 60% │ -│ ● │ │ [←K0][⏯K1][⏭K2][🔊K3] │ -│ [◀K1][▶K2][✔K3][→K0] │ │ BACK PLAY NEXT VOL │ -└──────────────────────────┘ └──────────────────────────┘ +[主界面 HOME · iPhone 风格图标网格] +┌──────────────────────────┐ +│ ☰ APP LAUNCHER 1/4 │ +├──────────────────────────┤ +│ ╭──╮ ╭──╮ ╭──╮ ╭──╮ │ +│ │♪ │ │📁│ │⚙ │ │☰ │ │ K3 打开选中的应用 +│ ╰──╯ ╰──╯ ╰──╯ ╰──╯ │ +│ MUSIC FILES SETTING ABOUT│ +│ WAV/FLAC files in SD … │ +│ ● │ +├──────────────────────────┤ +│ [◀K1] [▶K2] [✔K3] [→K0]│ +│ PREV NEXT OPEN SWITCH│ +└──────────────────────────┘ + +[播放器 PLAYER] +┌──────────────────────────┐ +│ ♪ HiFi PLAYER 01/12 │ +├──────────────────────────┤ +│ ┌────┐ 歌名 │ +│ │ ♪ │ FLAC 44100Hz │ +│ └────┘ 24bit STEREO │ +│ ( PLAYING ) │ +│ 00:42 ────●────── 03:35 │ +│ 🔊 ──────●───── 60% │ +├──────────────────────────┤ +│ [←K0] [⏯K1] [⏭K2] [🔊K3] │ +│ BACK PLAY NEXT VOL │ +└──────────────────────────┘ + +[文件管理器 FILES] +┌──────────────────────────┐ +│ 📁 FILES 12 SD │ +├──────────────────────────┤ +│ 0:/MUSIC/Album │ +├──────────────────────────┤ +│ 📁 .. │ +│ 📁 Album │ ← 选中行带强调色描边 +│ ♪ song01.flac 24.1M │ +│ 📄 notes.txt 128B │ +│ 📄 readme.md 2K │ +├──────────────────────────┤ +│ [←K0] [▲K1] [▼K2] [✔K3] │ +│ PARENT UP DOWN OPEN │ +└──────────────────────────┘ ``` > 界面文本使用 LVGL 内置 Montserrat 字体(含图标),因此为 ASCII。 -> 如需中文界面,见第 9 节“扩展建议”。 +> 如需中文界面,见第 10 节“扩展建议”。 --- @@ -48,13 +101,14 @@ | :--- | :--- | :--- | | SPI LCD(ST7789 320×240) | SPI2:SCLK=12, MOSI=11, CS=21, DC=40 | 与 SD 卡共用 SPI2 | | SD 卡 | SPI2:CS=2 | FATFS 挂载点 `/0:` | -| ES8388 | I2S0:MCLK=3, BCLK=46, WS=9, DOUT=10, DIN=14 | 16bit 输出 | +| ES8388 | I2S0:MCLK=3, BCLK=46, WS=9, DOUT=10, DIN=14 | 位宽随曲目动态配置(16/24/32bit) | | XL9555 | I2C0:SDA=41, SCL=42 | 按键、LCD 电源/复位/背光、喇叭使能 | | 按键 | XL9555 的 KEY0~KEY3 | 低电平有效 | | LED0 | GPIO1 | 播放时点亮 | > LCD 电源(SLCD_PWR)、复位(SLCD_RST)、背光(LCD_BL)、喇叭使能(SPK_EN) -> 均由 XL9555 扩展 IO 控制,初始化顺序必须是:`my_spi_init → myiic_init → xl9555_init → spilcd_init`。 +> 均由 XL9555 扩展 IO 控制,初始化顺序必须是: +> `my_spi_init → myiic_init → xl9555_init → spilcd_init`。 --- @@ -67,6 +121,10 @@ | K0 短按 | 在任意 App 内 → **返回主界面**;在主界面 → **切换到下一个应用** | | K0 长按(0.7s) | 任意界面 → **直接切换到下一个应用** | +> **例外**:实现了“就地返回”的 App 可以先消费掉 K0 短按。目前**文件管理器**就是这样—— +> 在子目录里按 K0 是返回上一级目录,只有在 `0:` 根目录时才回主界面。 +> 该行为由 `ui.c` 中调用 `ui_files_back()` 钩子实现,其它 App 不受影响。 + ### 主界面(应用启动器) | 按键 | 功能 | 界面提示 | @@ -74,7 +132,7 @@ | KEY0 | 切换到下一个应用 | `→ K0 SWITCH` | | KEY1 | 选择上一个图标 | `◀ K1 PREV` | | KEY2 | 选择下一个图标 | `▶ K2 NEXT` | -| KEY3 | 打开选中的图标 | `✔ K3 OPEN` | +| KEY3 | 打开选中的应用 | `✔ K3 OPEN` | ### 播放器界面 @@ -83,11 +141,24 @@ | KEY0 | **返回主界面**(长按 = 切换应用) | `← K0 BACK` | | KEY1 | 播放 / 暂停 | `K1 PLAY`(播放中图标变为暂停) | | KEY2 | **下一曲** | `K2 NEXT` | -| KEY3 | 音量切换(20/40/60/80/100%) | `K3 VOL` | +| KEY3 | 音量切档(20/40/60/80/100%) | `K3 VOL` | > 已按要求取消「上一首」功能,K0 释放出来作为独立的返回键。 -按键由 `app.c` 的按键服务统一扫描(10ms 周期、30ms 去抖、短按/长按区分), +### 文件管理器界面 + +| 按键 | 功能 | 界面提示 | +| :--- | :--- | :--- | +| KEY0 | **返回上一级目录**;已在根目录时回主界面(长按 = 切换应用) | `← K0 UP` / `← K0 HOME` | +| KEY1 | 选择上一项(到顶后跳到底部) | `▲ K1 UP` | +| KEY2 | 选择下一项 | `▼ K2 DOWN` | +| KEY3 | 进入文件夹 / 查看文件详情 | `✔ K3 OPEN` | + +> 退出子文件夹有两条路:按 **K0**,或选中列表最上方的 **`..`** 行按 **K3**。 +> 按 K3 打开普通文件时,屏幕下方会弹出 2 秒浮层,显示文件名、大小和类型 +> (`AUDIO` / `IMAGE` / `FILE`)。 + +按键由 `app.c` 的按键服务统一扫描(10ms 周期、30ms 去抖、短按/长按区分), `ui.c` 是事件的唯一消费者并负责分发,因此**不会重复扫描按键,也不会因为去抖延时而卡住音频**。 --- @@ -109,7 +180,7 @@ hifi_player/ │ ├── SPILCD/ # ST7789 SPI LCD(含 flush 同步) │ ├── SPI_SD/ # SD 卡 FATFS 挂载 │ ├── ES8388/ # 音频 DAC 配置 -│ └── MYI2S/ # I2S 标准模式收发 +│ └── MYI2S/ # I2S 标准模式收发 + 位宽动态重配 └── main/ ├── CMakeLists.txt ├── idf_component.yml # 依赖:LVGL 8.4 + micro-flac @@ -122,6 +193,9 @@ hifi_player/ ├── home/ # 主界面(应用启动器) │ ├── ui_home.c │ └── ui_home.h + ├── files/ # 文件管理器 App + │ ├── ui_files.c # 浏览 SD 卡目录(滑动窗口列表) + │ └── ui_files.h └── player/ # 播放器 App ├── ui_player.c/.h # 播放器界面 ├── player.c/.h # 播放核心(扫描 / I2S / 请求处理) @@ -137,7 +211,8 @@ hifi_player/ │ 界面: ui.c(LVGL 任务,50ms) ────────┘ 唯一消费者 ├─ K0 → 返回主界面 / 切换应用(全局) - └─ K1~K3 → 交给当前 App(ui_home / ui_player) + │ └─ 例外:FILES 在子目录时先返回上一级 + └─ K1~K3 → 交给当前 App(ui_home / ui_files / ui_player) │ 播放: ui_player → player_request() → player_task 消费并执行 (只有 APP_MODE_PLAYER 时才输出 I2S) @@ -149,11 +224,17 @@ hifi_player/ ### 5.1 环境 -* ESP-IDF **v6.1**(本工程已在此版本上编译验证通过,生成固件约 **893 KB**) +* ESP-IDF **v6.1**(本工程已在此版本上编译验证通过,固件 `0xdb3f0` = **898,032 字节 ≈ 877 KB**,分区剩余 71%) * 也兼容 **v5.3.x / v5.4**(BSP 已按 IDF 6.x 的驱动组件拆分方式声明依赖) * 目标芯片:`esp32s3` * 首次编译需要联网下载 `lvgl/lvgl` 8.4.0 与 `esphome/micro-flac` 0.3.0 +> **IDF 6.x 注意**:`driver` 这个“伞形”组件不再转发 `esp_driver_*` 子组件, +> 因此在 `components/BSP/CMakeLists.txt` 里必须逐个显式列出: +> `esp_driver_gpio esp_driver_i2c esp_driver_i2s esp_driver_spi esp_driver_sdspi esp_driver_sdmmc sdmmc esp_lcd fatfs`。 +> 同时 `driver/i2s.h`、`driver/i2c.h`、`I2S_BITS_PER_SAMPLE_*` 已被移除, +> 代码里改用 `esp_driver_i2s` 头文件与纯数字位宽。 + ### 5.2 命令 ```bash @@ -176,15 +257,16 @@ idf.py -p COMx flash monitor ## 6. SD 卡准备 -1. SD 卡格式化为 **FAT32**(建议 ≤ 32GB)。 -2. 在根目录新建文件夹 **`MUSIC`**(大写),把音频文件放进去: +1. SD 卡格式化为 **FAT32**(建议 ≤ 32GB)。**不支持 exFAT**。 +2. 在根目录新建文件夹 **`MUSIC`**,把音频文件放进去(**不递归子目录**): ``` SD 卡根目录/ -└── MUSIC/ - ├── canon.wav - ├── song.flac - └── ... +├── MUSIC/ ← 播放器只扫描这一层 +│ ├── canon.wav +│ ├── song.flac +│ └── ... +└── 其他文件/ ← 可以用「文件管理器」应用浏览 ``` 3. 音频要求: @@ -192,13 +274,32 @@ SD 卡根目录/ | 容器 | 编码 | 位深 | 声道 | 采样率 | | :--- | :--- | :--- | :--- | :--- | | **WAV**(RIFF/WAVE) | PCM(`WAVE_FORMAT_PCM` 或 `EXTENSIBLE`+PCM) | 16 / 24 / 32bit | 单声道 / 立体声 | 8k ~ 96kHz | - | **FLAC**(原生 `fLaC` / `Ogg FLAC`) | FLAC | 任意(4~32bit) | 1 ~ 8 声道 | 8k ~ 96kHz | + | **FLAC**(原生 `fLaC` / `Ogg FLAC`) | FLAC | 4 ~ 32bit | 1 ~ 8 声道 | 8k ~ 96kHz | 4. 说明: - * 两种格式都统一**转换为 16bit 立体声**后送给 ES8388: - 24/32bit 取高位,单声道复制到左右,多声道(FLAC)只取前两声道。 - * 界面上的 `WAV/FLAC xxxHz xxbit` 显示的是**源文件**参数。 - * 文件类型按**文件头**自动识别(`RIFF` / `fLaC` / `OggS`),扩展名只用于扫描列表。 + * 文件名建议**只用 ASCII**(英文 / 数字 / 下划线)。中文文件名能正常播放, + 但界面上会显示为 `?`(见下方第 5 条)。 + * 单曲数量上限 **200 首**(`MAX_TRACKS`),扩展名支持 `.wav` / `.flac`(大小写不敏感)。 + * 文件类型按**文件头**自动识别(`RIFF` / `fLaC` / `OggS`),扩展名只用于筛选列表。 + * **位深不再统一转 16bit**:源文件是什么位深就按什么位宽输出,映射关系见下表。 + * 中文文件名是 GBK 编码,LVGL 内置字体无法渲染,`make_display_name()` 会把 + 连续的非 ASCII 字节折叠成一个 `?`,但**底层路径使用原始字节**,不影响播放。 + +### 6.1 位深与输出映射(V1.5) + +| 源位深 | 每采样字节 | 每帧字节(立体声) | ES8388 R23 字长 | I2S `data_bit_width` / `ws_width` | `mclk_multiple` | +| :--- | :--- | :--- | :--- | :--- | :--- | +| ≤ 16bit(8 / 16) | 2 | 4 | 16bit(码 3) | `I2S_DATA_BIT_WIDTH_16BIT` | 256 | +| 17 ~ 24bit(20 / 24) | 3 | 6 | 24bit(码 0) | `I2S_DATA_BIT_WIDTH_24BIT` | **384** | +| 25 ~ 32bit(32) | 4 | 8 | 32bit(码 4) | `I2S_DATA_BIT_WIDTH_32BIT` | 256 | + +> **为什么 24bit 要用 384 倍 MCLK**:ESP-IDF 的 `i2s_std.c` 有一条硬性校验—— +> slot 位宽为 24bit 时 `mclk_multiple` 必须是 3 的倍数,否则 +> `i2s_channel_reconfig_std_clock()` 直接返回 `ESP_ERR_INVALID_ARG`。 +> `myi2s.c` 的 `i2s_set_samplerate_bits_sample()` 会自动切换:24bit → 384,其余 → 256。 +> +> 例:20bit 的 FLAC 会输出成 24bit 槽位(`out_bytes = 3`), +> 界面上显示为 `FLAC 44100Hz 20->24bit`,提示“源 20bit,实际按 24bit 输出”。 --- @@ -225,22 +326,35 @@ SD 卡根目录/ ### 音频解码链路 ``` - ┌─ WAV ── f_read 原始 PCM ── 取高位 / 单声道复制 ─┐ -audio_source_read┤ ├─▶ 16bit 立体声 ──▶ I2S - └─ FLAC ── micro_flac 解码 ── 取高位 / 声道归一化 ─┘ + ┌─ WAV ── f_read 原始 PCM ─────── 单声道复制 ──┐ +audio_source_read │ + └─ FLAC ── micro_flac 解码 ── 声道归一化 ────────┘ + │ + ┌────────────────────────────────┘ + ▼ + 立体声 + 原生位深(16/24/32bit,左对齐) + │ + audio_set_format(rate, out_bits) + ├─ es8388_i2s_cfg(0, len_code) + └─ i2s_set_samplerate_bits_sample(rate, bits) + │ + ▼ + i2s_tx_write(frames * 2 * out_bytes) ``` -`audio_source.c` 只对外暴露「给我一块 16bit 立体声 PCM」,因此 `player.c` 的播放循环 -与容器格式完全无关;新增格式(如 MP3)只需在 `audio_source_open()` 里加一个分支。 +`audio_source.c` 对外只暴露「给我一块**立体声 + 原生位深** PCM,并告诉我 `out_bytes`」, +因此 `player.c` 的播放循环与容器格式完全无关;新增格式(如 MP3)只需在 +`audio_source_open()` 里加一个分支。 ### 关键实现点 1. **单任务音频流** - 一个任务完成“读文件 + 解码 + 写 I2S + 处理按键请求”,每块音频约 46ms, - 避免多任务同时访问 FATFS/I2S 的竞争。 + 一个任务完成“读文件 + 解码 + 写 I2S + 处理按键请求”,每块音频约 23ms + (`AUDIO_CHUNK_FRAMES = 1024` @44.1kHz),避免多任务同时访问 FATFS/I2S 的竞争。 + 发送缓冲按最坏情况分配 8 字节/帧(32bit 立体声):`AUDIO_BUF_SIZE = 1024 × 8 = 8KB`。 2. **非阻塞按键去抖** - `key_scan()` 每次读取 XL9555 输入寄存器(一次 I2C 读 2 字节), + `key_task()` 每 10ms 读一次 XL9555 输入寄存器(一次 I2C 读 2 字节), 连续 3 次采样一致才确认状态变化,不使用 `vTaskDelay` 阻塞去抖。 3. **LVGL 与 SPI LCD** @@ -250,45 +364,94 @@ audio_source_read┤ ├─▶ * LVGL 的 RGB565 是小端,ST7789 要求高字节在前,因此在 `disp_flush_cb()` 中做原地字节交换。 4. **I2S 通道管理** - `i2s_trx_start()/i2s_trx_stop()` 增加了状态判断,可以重复调用而不会报错; - 切换采样率时会先关闭通道,重新配置 `data_bit_width / ws_width / sample_rate` 后再打开。 + `i2s_trx_start()/i2s_trx_stop()` 带状态判断,可重复调用而不报错; + 切换曲目时会先关闭通道,重新配置 `data_bit_width / ws_width / mclk_multiple / + sample_rate` 后再由播放循环按需打开。 5. **FLAC 解码** * 使用 `esphome/micro-flac`(ESP32-S3 带 Xtensa 汇编优化,16bit 约 30 倍实时)。 - * 该库是 C++,`flac_dec.cpp` 提供一层纯 C 封装(`flac_dec_open/read/close`), + * 该库是 C++,`flac_dec.cpp` 提供一层纯 C 封装(`flac_dec_open/read/close`), 播放器侧代码仍是 C。 + * 用的是 `uint8_t*` 输出重载(**原生字节打包**),而不是 `int32_t*` 的 32bit 归一化重载, + 这样才能保留源位深。 * 码流缓冲 8KB,采用「未消费数据前移 + 追加读取」,避免解码器返回 `NEED_MORE_DATA` 时丢数据;同时有“无进展”保护,坏文件不会死循环。 * 解码输出缓冲按 STREAMINFO 的 `max_block_size` 动态分配,优先放 PSRAM。 6. **状态同步** - 播放器只写 `s_state`(互斥锁保护),UI 每 50ms 读取一次快照, + 播放器只写 `s_state`(互斥锁保护),UI 每 50ms 读取一次快照, LVGL 的所有控件操作都发生在 LVGL 任务内,天然线程安全。 --- -## 8. 常见问题 +## 8. 文件管理器实现要点 -| 现象 | 排查方向 | -| :--- | :--- | -| 屏幕全白 / 全黑 | 检查 XL9555 是否初始化成功、背光 `LCD_BL` 是否置 1;确认 `sdkconfig.defaults` 的 PSRAM 配置与板子一致 | -| 屏幕花屏 | 确认 SPI 时钟 60MHz 未超频,排线接触良好;检查 `LCD_RGB_DATA_ENDIAN_BIG` 与字节交换逻辑 | -| 显示 `NO SD CARD` | SD 卡需 FAT32;`my_spi_init()` 必须在 `spilcd_init()` 之前;检查 CS=GPIO2 | -| 显示 `PUT WAV IN /MUSIC` | SD 卡根目录需有 `MUSIC` 文件夹且包含 `.wav` / `.flac` 文件 | -| 显示 `UNSUPPORTED WAV` / `UNSUPPORTED FILE` | WAV 需为 PCM(16/24/32bit);FLAC 需为标准 `fLaC` 或 Ogg FLAC;APE/MP3 需要额外解码库 | -| FLAC 无法播放但 WAV 正常 | 检查串口日志 `flac_dec` 前缀的报错;确认组件已下载(`managed_components/esphome__micro-flac`) | -| FLAC 播放有断续 | 提高 `CONFIG_SPIRAM_SPEED_80M`、确认 PSRAM 已启用;或降低 SD 卡 SPI 时钟 | +`main/APP/files/ui_files.c` 是一个可直接复用的「列表型 App」模板: + +1. **滑动窗口列表** + 界面创建时只建 **5 个固定行控件**(`FILES_ROWS`),刷新时把 + `[s_top, s_top + 5)` 区间映射到这些行上。目录里有几百个文件也不会撑爆 + LVGL 的 48KB 内存池;也完全避免“清空重建”带来的闪烁。 + +2. **排序规则** + `qsort` + 自定义比较:`..` 永远第一 → 目录 → 文件,同类按名称 + `strcasecmp`(不区分大小写)。 + +3. **过滤** + 隐藏(`AM_HID`)、系统(`AM_SYS`)、卷标(`0x08`)属性一律不显示。 + 单目录最多显示 `FILES_MAX_ENTRIES = 96` 条,超出部分截断。 + +4. **图标着色** + 目录 → 黄色 `LV_SYMBOL_DIRECTORY`;音频(`.wav`/`.flac`)→ 青色 `LV_SYMBOL_AUDIO`; + 图片(`.bmp/.png/.jpg/.jpeg/.gif`)→ 紫色 `LV_SYMBOL_IMAGE`;其它 → 灰色 `LV_SYMBOL_FILE`。 + +5. **大小格式化** + `files_format_size()` 用纯整数运算输出 `128B` / `2K` / `24.1M`, + 不依赖浮点 `printf`(省 flash、避免 `%f` 在 newlib nano 下失效)。 + +6. **别名安全的路径切换** + `files_open_dir()` 先把入参复制到 `target[]` 再写入 `s_path`, + 因此可以安全地用 `s_path` 自身作为实参调用(`files_go_up()` 就是这么做的)。 + 打开失败会自动回退到原目录并恢复原来的选中项。 + +7. **SD 卡挂载策略** + 先 `f_opendir("0:")` **探测**;只有探测失败且从未尝试过时才调用 `sd_spi_init()`。 + 正常开机时播放器任务已经挂好卡,这里只读探测,绝不和播放器抢挂载 + (否则正在播放的文件句柄会失效)。 + +> FatFs 配置为 `FF_FS_REENTRANT = 1`,因此播放器任务与 LVGL 任务同时访问 SD 卡是安全的。 --- -## 9. 扩展建议 +## 9. 常见问题 -### 9.1 新增一个应用(推荐流程) +| 现象 | 排查方向 | +| :--- | :--- | +| 屏幕全白 / 全黑 | 检查 XL9555 是否初始化成功、背光 `LCD_BL` 是否置 1;确认 PSRAM 配置与板子一致 | +| 屏幕花屏 | 确认 SPI 时钟 60MHz 未超频,排线接触良好;检查字节交换逻辑 | +| 显示 `NO SD CARD` | SD 卡需 **FAT32**(不是 exFAT);`my_spi_init()` 必须在 `spilcd_init()` 之前;检查 CS=GPIO2 | +| 显示 `PUT WAV/FLAC IN /MUSIC` | SD 卡根目录需有 `MUSIC` 文件夹且**直接**包含 `.wav` / `.flac`(不递归子目录) | +| **FLAC 文件不显示 / 不能播放** | V1.5 已修复 `name_is_audio()` 提前 `return false` 导致 `.flac` 全被过滤的 bug。若仍异常,确认扩展名确实是 `.flac`、文件是标准 `fLaC` 或 Ogg FLAC,并查看串口 `flac_dec` 前缀日志 | +| 显示 `UNSUPPORTED FILE` | WAV 必须是 PCM(16/24/32bit);APE / MP3 / M4A 需要额外解码库 | +| 界面显示 `24->32bit` 之类 | 这是正常提示:源位深与实际 I2S 输出位宽不同(如 20bit 源按 24bit 输出) | +| 24bit 曲目无声 / 报错 | 检查 `mclk_multiple` 是否为 3 的倍数;ESP-IDF 要求 24bit slot 必须满足该条件,`myi2s.c` 已自动设 384 | +| FLAC 播放有断续 | 提高 `CONFIG_SPIRAM_SPEED_80M`、确认 PSRAM 已启用;或降低 SD 卡 SPI 时钟 | +| 文件管理器看不到某些文件 | 隐藏 / 系统属性的文件被有意过滤;单目录超过 96 条会被截断 | +| 文件管理器中文名显示 `?` | 预期行为:GBK 文件名无法用内置字体渲染,功能不受影响 | +| 编译报 `'LIST_H' redefined` | 自定义宏名与 FreeRTOS `list.h` 的 include guard 冲突(`-Werror`),改名即可 | +| 编译报 `'AM_VOL' undeclared` | 本版 FatFs 未定义该宏,用字面量 `0x08` 代替 | +| 编译报 `-Werror=format-truncation` | `FILINFO::fname` 是 `char[256]`,拷入更小缓冲时改用 `strlcpy` 或显式长度校验 | + +--- + +## 10. 扩展建议 + +### 10.1 新增一个应用(推荐流程) 工程已经把「界面」「按键」和「应用」解耦,新增应用只需要 4 步,并且**独立文件夹**: -1. **建立文件夹**:例如 `main/APP/clock/`,放入 `ui_clock.c` / `ui_clock.h`, - 参考 `player/ui_player.c` 实现 4 个函数: +1. **建立文件夹**:例如 `main/APP/clock/`,放入 `ui_clock.c` / `ui_clock.h`, + 参考 `player/ui_player.c` 或 `files/ui_files.c` 实现 4 个函数: ```c void ui_clock_create(void); /* 创建屏幕(lv_obj_create(NULL)) */ @@ -297,6 +460,14 @@ audio_source_read┤ ├─▶ void ui_clock_handle_key(uint8_t key); /* 只处理 K1/K2/K3 */ ``` + 如需 K0 短按在 App 内“就地返回”(像文件管理器那样逐级退出),再额外实现: + + ```c + bool ui_clock_back(void); /* true = 已就地处理,ui.c 不再回主界面 */ + ``` + + 并在 `main/APP/ui.c` 的 `ui_dispatch()` 里加一个 `else if` 分支。 + 2. **加模式**:在 `main/APP/app.h` 的 `app_mode_t` 中,于 `APP_MODE_NUM` 之前加一项, 例如 `APP_MODE_CLOCK`。 @@ -308,28 +479,30 @@ audio_source_read┤ ├─▶ .title = "CLOCK", /* 图标下方名称(建议 ≤ 8 字符) */ .subtitle = "Digital clock", /* 选中时下方显示的说明 */ .mode = APP_MODE_CLOCK, /* 进入后切到的模式 */ - .enabled = true, /* 允许进入(false 时半透明显示) */ + .enabled = true, /* 允许进入(false 时显示为禁用) */ .color_from = 0x4FC3F7, /* 图标渐变起始色 */ .color_to = 0x2E86DE, /* 图标渐变结束色 */ }, ``` -4. **挂路由**:在 `main/APP/ui.c` 的 `ui_load_mode()` 与 `ui_tick_cb()` 里各加一个 - `case APP_MODE_CLOCK:` 分支;同时把 `main/CMakeLists.txt` 的 `SRC_DIRS` / - `INCLUDE_DIRS` 加上 `"APP/clock"`。 +4. **挂路由**:在 `main/APP/ui.c` 的 `ui_load_mode()`、`ui_dispatch()`、 + `ui_tick_cb()` 里各加一个 `case APP_MODE_CLOCK:` 分支; + 同时把 `main/CMakeLists.txt` 的 `SRCS` 加上 `"APP/clock/ui_clock.c"`、 + `INCLUDE_DIRS` 加上 `"APP/clock"`(本工程用的是显式文件列表,不是 `SRC_DIRS`)。 返回主界面、切换应用是**全局自动生效**的(`ui.c` 里统一处理 K0),新 App 不需要关心。 -### 9.2 其他扩展 +### 10.2 其他扩展 -> 注意:主界面按 iPhone 风格 **一排 4 个图标**,超过 4 个应用会自动分页, +> 主界面按 iPhone 风格 **一排 4 个图标**,超过 4 个应用会自动分页, > 屏幕下方出现页码圆点;K1/K2 可在页间连续移动。 * **中文界面**:在 `menuconfig → Component config → LVGL → Font usage` 中启用 `LV_FONT_SIMSUN_16_CJK`(内置 1000 常用汉字),或使用 [lv_font_conv](https://github.com/lvgl/lv_font_conv) 生成子集字体,然后把 - `ui_home.c` / `ui_player.c` 中的英文文案替换为中文。 -* **图标换成图片**:当前图标是「圆角渐变 + 内置字形」,如需真实图标, + 各界面里的英文文案替换为中文。文件名同理——启用中文字体后, + `make_display_name()` / `files_display_name()` 里的 `?` 折叠逻辑就可以去掉。 +* **图标换成图片**:当前图标是「圆角渐变 + 内置字形」,如需真实图标, 可用 `LV_IMG_DECLARE()` + `lv_img_create()` 替代 `s_glyph[]` 处的标签 (图片需要用 LVGL 图片转换工具转成 C 数组,或开启 `LV_USE_PNG` + 文件系统驱动)。 * **再增加一种格式(如 MP3 / APE)**:引入对应解码库后,只需在 @@ -344,13 +517,18 @@ audio_source_read┤ ├─▶ } ``` + 注意 MP3 等有损格式位深固定,`out_bytes` 填 2(16bit)即可, + `audio_set_format()` 会自动把 ES8388 / I2S 配成 16bit。 * **专辑封面**:LVGL 支持从文件系统读取 PNG/JPG(需开启 `LV_USE_PNG` / `LV_USE_SJPG` + 文件系统驱动),可在播放器左侧封面卡片显示 `/MUSIC` 中的同名图片。 * **歌词显示**:解析同名 `.lrc` 文件,在进度条下方增加滚动歌词控件。 +* **文件管理器直接播放**:在 `files_enter()` 里判断选中项是音频文件时, + 调用 `player_load_path()`(需在 `player.h` 新增该接口)+ `app_set_mode(APP_MODE_PLAYER)`, + 即可实现「浏览目录 → 点开即播」。 --- -## 10. 参考例程 +## 11. 参考例程 * `10_spilcd` —— SPI LCD 初始化、引脚定义 * `30_music` —— ES8388 / I2S / WAV 播放 / XL9555 按键 @@ -362,4 +540,4 @@ audio_source_read┤ ├─▶ * [esphome/micro-flac](https://github.com/esphome-libs/micro-flac) —— FLAC 解码(Apache-2.0) 驱动代码从上述例程移植;LVGL 移植层、应用管理器、界面路由、图标式主界面、 -统一音源层(WAV/FLAC)、播放器状态机与按键交互为本工程重新实现。 +统一音源层(WAV/FLAC + 原生位深)、文件管理器、播放器状态机与按键交互为本工程重新实现。 diff --git a/components/BSP/MYI2S/myi2s.c b/components/BSP/MYI2S/myi2s.c index 721730c..5f156aa 100644 --- a/components/BSP/MYI2S/myi2s.c +++ b/components/BSP/MYI2S/myi2s.c @@ -47,15 +47,15 @@ esp_err_t myi2s_init(void) .mclk_multiple = I2S_MCLK_MULTIPLE, /* I2S主时钟MCLK相对于采样率的倍数(默认256) */ }, - .slot_cfg = { /* 声道配置,可用I2S_STD_PHILIPS_SLOT_DEFAULT_CONFIG(I2S_DATA_BIT_WIDTH_16BIT, I2S_SLOT_MODE_STEREO)宏函数辅助配置(支持16位宽采样数据) */ + .slot_cfg = { /* 声道配置,保持 Philips I2S 标准相位,避免采样位移造成噪声 */ .data_bit_width = I2S_DATA_BIT_WIDTH_16BIT, /* 声道支持16位宽的采样数据 */ .slot_bit_width = I2S_SLOT_BIT_WIDTH_AUTO, /* 通道位宽 */ .slot_mode = I2S_SLOT_MODE_STEREO, /* 立体声 */ .slot_mask = I2S_STD_SLOT_BOTH, /* 启用通道 */ .ws_width = I2S_DATA_BIT_WIDTH_16BIT, /* WS信号位宽 */ .ws_pol = false, /* WS信号极性 */ - .bit_shift = true, /* 位移位(Philips模式下配置) */ - .left_align = true, /* 左对齐 */ + .bit_shift = true, /* Philips 模式下需要位移位 */ + .left_align = false, /* 保持 Philips I2S 标准对齐,避免噪声 */ .big_endian = false, /* 小端模式 */ .bit_order_lsb = false /* MSB */ }, @@ -106,16 +106,30 @@ void i2s_trx_start(void) * @brief I2S TRX停止 * @param 无 * @retval 无 + * @note 可重复调用(通道已关闭时直接返回),保证后续 reconfig 一定处于 READY 状态 */ void i2s_trx_stop(void) { - if (!s_trx_enabled) + if (tx_handle != NULL) { - return; + esp_err_t err = i2s_channel_disable(tx_handle); + + if ((err != ESP_OK) && (err != ESP_ERR_INVALID_STATE)) + { + ESP_ERROR_CHECK(err); + } + } + + if (rx_handle != NULL) + { + esp_err_t err = i2s_channel_disable(rx_handle); + + if ((err != ESP_OK) && (err != ESP_ERR_INVALID_STATE)) + { + ESP_ERROR_CHECK(err); + } } - ESP_ERROR_CHECK(i2s_channel_disable(tx_handle)); - ESP_ERROR_CHECK(i2s_channel_disable(rx_handle)); s_trx_enabled = false; } @@ -162,7 +176,7 @@ void i2s_set_samplerate_bits_sample(int samplerate, int bits_sample) { case 24: data_width = I2S_DATA_BIT_WIDTH_24BIT; - mclk_multiple = I2S_MCLK_MULTIPLE_384; /* 必须是 3 的倍数 */ + mclk_multiple = I2S_MCLK_MULTIPLE_384; /* 24bit slot 必须是 3 的倍数 */ break; case 32: @@ -177,21 +191,18 @@ void i2s_set_samplerate_bits_sample(int samplerate, int bits_sample) break; } - i2s_trx_stop(); + i2s_trx_stop(); /* 必须在 READY 状态才能重配 */ - /* - * 重配 slot 时驱动内部会用当前 mclk_multiple 重算时钟, - * 因此 MCLK 倍数必须在改 slot 之前就设好。 - */ - my_std_cfg.clk_cfg.mclk_multiple = mclk_multiple; - my_std_cfg.slot_cfg.data_bit_width = data_width; /* 数据位宽 */ + /* 先更新我们自己的配置副本 */ + my_std_cfg.slot_cfg.data_bit_width = data_width; /* 数据位宽 */ my_std_cfg.slot_cfg.slot_bit_width = I2S_SLOT_BIT_WIDTH_AUTO; /* 跟随数据位宽 */ - my_std_cfg.slot_cfg.ws_width = data_width; /* WS 位宽,与数据位宽一致(50% 占空比) */ - - /* 如果需要更新声道或时钟配置,需要在更新前先禁用通道 */ - ESP_ERROR_CHECK(i2s_channel_reconfig_std_slot(tx_handle, &my_std_cfg.slot_cfg)); - my_std_cfg.clk_cfg.sample_rate_hz = samplerate; /* 设置采样率 */ + my_std_cfg.slot_cfg.ws_width = data_width; /* WS 宽度与位宽一致(50% 占空比) */ + my_std_cfg.slot_cfg.left_align = false; /* Philips I2S 标准相位,左对齐会产生噪声 */ + my_std_cfg.clk_cfg.sample_rate_hz = (uint32_t)samplerate; + my_std_cfg.clk_cfg.mclk_multiple = mclk_multiple; ESP_ERROR_CHECK(i2s_channel_reconfig_std_clock(tx_handle, &my_std_cfg.clk_cfg)); + + ESP_ERROR_CHECK(i2s_channel_reconfig_std_slot(tx_handle, &my_std_cfg.slot_cfg)); } /** diff --git a/main/APP/app.c b/main/APP/app.c index 50b8063..8d98f9a 100644 --- a/main/APP/app.c +++ b/main/APP/app.c @@ -41,7 +41,7 @@ static const app_entry_t s_apps[] = { { .icon = LV_SYMBOL_AUDIO, /* 音符:音乐播放器 */ .title = "MUSIC", - .subtitle = "WAV files in SD /MUSIC", + .subtitle = "WAV / FLAC files in SD /MUSIC", .mode = APP_MODE_PLAYER, .enabled = true, .color_from = 0x8E5BFF, @@ -50,9 +50,9 @@ static const app_entry_t s_apps[] = { { .icon = LV_SYMBOL_DIRECTORY, /* 文件夹:SD 卡文件浏览 */ .title = "FILES", - .subtitle = "Browse files on SD card", - .mode = APP_MODE_NONE, - .enabled = false, + .subtitle = "Browse every file on SD card", + .mode = APP_MODE_FILES, + .enabled = true, .color_from = 0x31C27C, .color_to = 0x1E9C6B, }, diff --git a/main/APP/app.h b/main/APP/app.h index 4cc69f8..0fd3162 100644 --- a/main/APP/app.h +++ b/main/APP/app.h @@ -18,6 +18,9 @@ * K0 短按 = 返回主界面(主界面时为切换下一个应用) * K0 长按 = 直接切换到下一个应用 * K1/K2/K3 = 由各 App 自己定义 + * + * 例外:实现了 ui_xxx_back() 的 App(如文件管理器)可以先用 K0 短按 + * 完成“就地返回”(返回上一级目录),只有到顶层时才回主界面。 **************************************************************************************************** */ @@ -35,6 +38,7 @@ extern "C" { typedef enum { APP_MODE_HOME = 0, /* 主界面:应用启动器 */ APP_MODE_PLAYER, /* 音乐播放器 */ + APP_MODE_FILES, /* 文件管理器:浏览 SD 卡目录 */ APP_MODE_NUM, /* 模式总数(新增应用请加在这之前) */ } app_mode_t; diff --git a/main/APP/files/ui_files.c b/main/APP/files/ui_files.c new file mode 100644 index 0000000..84df715 --- /dev/null +++ b/main/APP/files/ui_files.c @@ -0,0 +1,863 @@ +/** + **************************************************************************************************** + * @file ui_files.c + * @author HiFi Player Project + * @version V1.0 + * @date 2026-01-01 + * @brief 文件管理器 —— 浏览 SD 卡上的全部文件,支持进出子文件夹 + * + * ┌──────────────────────────────────┐ + * │ 📁 FILES 12 SD │ 状态栏 + * ├──────────────────────────────────┤ + * │ 0:/MUSIC/Album │ 当前路径 + * ├──────────────────────────────────┤ + * │ 📁 .. │ 选中行(高亮 + 描边) + * │ 📁 Album │ + * │ ♪ song01.flac 24.1M │ 目录在前,文件在后,按名称排序 + * │ 📄 notes.txt 128B │ + * │ 📄 readme.md 2K │ + * ├──────────────────────────────────┤ + * │ [◀K0] [▲K1] [▼K2] [✔K3] │ + * │ BACK UP DOWN OPEN │ + * └──────────────────────────────────┘ + * + * 实现要点: + * 1. 不重建控件。界面创建时固定做 FILES_ROWS 个“行”,刷新时把 + * [s_top, s_top+FILES_ROWS) 的条目映射到这些行上(滑动窗口), + * 即使目录里有几百个文件也不会撑爆 LVGL 内存。 + * 2. 目录里超过 FILES_MAX_ENTRIES 的条目会被截断,避免静态数组过大。 + * 3. 非根目录时列表第一行固定是 "..",回车即可返回上级; + * K0 短按同样用于返回上级(根目录时由 ui.c 接管回主界面)。 + * 4. 中文文件名是 GBK 编码,LVGL 内置字体无法显示,用 '?' 逐字占位, + * 但进入目录 / 打开文件仍使用原始字节,不影响功能。 + **************************************************************************************************** + */ + +#include "ui_files.h" + +#include +#include +#include +#include +#include + +#include "esp_log.h" + +#include "ff.h" + +#include "spi_sd.h" + +#include "ui_common.h" +#include "app.h" + +#define TAG "ui_files" + +/* ---------------- 浏览参数 ---------------- */ +#define FILES_ROOT "0:" /* SD 卡挂载点下的根目录 */ +#define FILES_MAX_ENTRIES 96 /* 单目录最多显示多少条 */ +#define FILES_NAME_MAX 96 /* 单条文件名缓冲 */ + +#define FILES_ROWS 5 /* 列表可见行数 */ +#define FILES_ROW_H 28 /* 每行高度 */ + +#define FILES_PATH_MAX 300 /* 当前路径缓冲 */ + +/* ---------------- 屏幕布局 ---------------- */ +/* 注意:不能用 LIST_H 这类名字,FreeRTOS 的 list.h 用 LIST_H 作为 include guard */ +#define FILES_HEADER_H 30 +#define FILES_PATH_Y 32 +#define FILES_PATH_H 20 +#define FILES_LIST_Y 54 +#define FILES_LIST_H (FILES_ROWS * FILES_ROW_H) /* 140 → 底边 194 */ +#define FILES_TOAST_Y 150 + +/* 每行内部的横向布局 */ +#define ROW_NAME_X 22 /* 名称相对行左边的偏移 */ +#define ROW_NAME_W 224 /* 名称最大宽度(超出滚动) */ +#define ROW_SIZE_W 44 /* 右侧大小 / 宽度 */ + +/* FATFS 卷标属性(本版本 ff.h 未提供 AM_VOL) */ +#define FILES_AM_VOL 0x08 + +/* ---------------- 条目 ---------------- */ +typedef struct { + char name[FILES_NAME_MAX]; /* 原始文件 / 目录名(GBK 原样保留) */ + uint32_t size; /* 文件字节数,目录为 0 */ + bool is_dir; +} files_entry_t; + +/* ---------------- 行控件(固定池) ---------------- */ +typedef struct { + lv_obj_t *row; + lv_obj_t *icon; + lv_obj_t *name; + lv_obj_t *size; +} files_row_t; + +/* ---------------- 界面控件 ---------------- */ +static lv_obj_t *s_scr = NULL; +static lv_obj_t *s_lbl_path = NULL; +static lv_obj_t *s_lbl_count = NULL; +static lv_obj_t *s_lbl_empty = NULL; +static lv_obj_t *s_toast = NULL; +static lv_obj_t *s_toast_lbl = NULL; + +static files_row_t s_rows[FILES_ROWS]; + +static ui_key_bar_t s_bar; +static ui_flash_t s_flash; + +/* ---------------- 浏览状态 ---------------- */ +static files_entry_t s_entries[FILES_MAX_ENTRIES]; +static int s_count = 0; /* 有效条目数 */ +static int s_sel = 0; /* 选中项下标 */ +static int s_top = 0; /* 列表窗口首行对应的条目下标 */ +static char s_path[FILES_PATH_MAX] = FILES_ROOT; + +static FRESULT s_load_res = FR_OK; /* 最近一次目录读取结果 */ +static bool s_sd_ok = false; /* SD 卡是否已可读 */ +static bool s_mount_tried = false; /* 是否已经尝试过挂载 */ +static uint32_t s_toast_tick = 0; + +/* ---------------- 按键提示 ---------------- */ +static const char *const s_symbols[APP_KEY_NUM] = { + LV_SYMBOL_LEFT, /* K0:返回上级目录 / 回主界面 */ + LV_SYMBOL_UP, /* K1:上一项 */ + LV_SYMBOL_DOWN, /* K2:下一项 */ + LV_SYMBOL_OK, /* K3:进入目录 */ +}; + +static const char *const s_captions[APP_KEY_NUM] = { + "K0 BACK", + "K1 UP", + "K2 DOWN", + "K3 OPEN", +}; + +static const bool s_enabled[APP_KEY_NUM] = { true, true, true, true }; + +/* ================================================================================================ + * 小工具 + * ============================================================================================== */ + +/** + * @brief GBK 文件名 → 可在 LVGL 内置字体上显示的字符串 + * @note ASCII 原样保留;每个双字节汉字折叠成一个 '?',避免出现乱码方块 + */ +static void files_display_name(const char *src, char *dst, size_t size) +{ + size_t len = strlen(src); + size_t j = 0; + + if (size == 0) + { + return; + } + + for (size_t i = 0; (i < len) && (j + 1 < size); i++) + { + unsigned char c = (unsigned char)src[i]; + + if ((c >= 0x20) && (c < 0x7F)) + { + dst[j++] = (char)c; + continue; + } + + /* GBK 双字节字符:跳过第二个字节,只输出一个占位符 */ + if ((c >= 0x81) && (i + 1 < len)) + { + unsigned char t = (unsigned char)src[i + 1]; + + if ((t >= 0x40) && (t <= 0xFE)) + { + i++; + } + } + + dst[j++] = '?'; + } + + dst[j] = '\0'; +} + +/** 文件大小 → "128B" / "2K" / "24.1M"(纯整数运算,不依赖浮点 printf) */ +static void files_format_size(uint32_t bytes, char *out, size_t size) +{ + const uint32_t mb = 1024u * 1024u; + + if (bytes >= mb) + { + uint32_t whole = bytes / mb; + uint32_t frac = ((bytes % mb) * 10u) / mb; + + snprintf(out, size, "%u.%uM", (unsigned)whole, (unsigned)frac); + } + else if (bytes >= 1024u) + { + snprintf(out, size, "%uK", (unsigned)(bytes / 1024u)); + } + else + { + snprintf(out, size, "%uB", (unsigned)bytes); + } +} + +/** 冒烟测试用的扩展名判断(只影响图标和颜色) */ +static bool files_name_has_ext(const char *name, const char *ext) +{ + size_t nlen = strlen(name); + size_t elen = strlen(ext); + + if (nlen <= elen) + { + return false; + } + + const char *tail = name + nlen - elen; + + for (size_t i = 0; i < elen; i++) + { + char a = tail[i]; + char b = ext[i]; + + if ((a >= 'A') && (a <= 'Z')) + { + a = (char)(a - 'A' + 'a'); + } + + if ((b >= 'A') && (b <= 'Z')) + { + b = (char)(b - 'A' + 'a'); + } + + if (a != b) + { + return false; + } + } + + return true; +} + +static bool files_is_audio(const char *name) +{ + return files_name_has_ext(name, ".wav") || + files_name_has_ext(name, ".flac"); +} + +static bool files_is_image(const char *name) +{ + return files_name_has_ext(name, ".bmp") || + files_name_has_ext(name, ".png") || + files_name_has_ext(name, ".jpg") || + files_name_has_ext(name, ".jpeg") || + files_name_has_ext(name, ".gif"); +} + +/* ================================================================================================ + * SD 卡 + * ============================================================================================== */ + +/** + * @brief 确保 SD 卡已挂载 + * @note 正常情况下播放器任务在开机时已经挂载好,这里只做“探测”, + * 只有探测失败且从未尝试过时才主动挂载一次,避免和播放器争抢。 + */ +static void files_ensure_mounted(void) +{ + FF_DIR dir; + + if (f_opendir(&dir, FILES_ROOT) == FR_OK) + { + f_closedir(&dir); + + if (!s_sd_ok) + { + s_sd_ok = true; + ESP_LOGI(TAG, "sd card ready"); + } + + return; + } + + if (s_mount_tried) + { + return; /* 只主动尝试一次 */ + } + + s_mount_tried = true; + + if (sd_spi_init() == ESP_OK) + { + s_sd_ok = true; + ESP_LOGI(TAG, "sd card mounted by files app"); + } + else + { + ESP_LOGW(TAG, "sd card not available"); + } +} + +/* ================================================================================================ + * 目录读取 + * ============================================================================================== */ + +/** qsort 比较:`..` → 目录 → 文件,同类按名称不区分大小写 */ +static int files_cmp(const void *a, const void *b) +{ + const files_entry_t *x = (const files_entry_t *)a; + const files_entry_t *y = (const files_entry_t *)b; + + bool x_up = (strcmp(x->name, "..") == 0); + bool y_up = (strcmp(y->name, "..") == 0); + + if (x_up != y_up) + { + return x_up ? -1 : 1; + } + + if (x->is_dir != y->is_dir) + { + return x->is_dir ? -1 : 1; + } + + return strcasecmp(x->name, y->name); +} + +/** + * @brief 读取当前路径下的条目到 s_entries + * @retval 有效条目数;s_load_res 给出 FatFs 的原始结果 + */ +static int files_load(void) +{ + FF_DIR dir; + FILINFO *fi = NULL; + FRESULT res = FR_OK; + int n = 0; + + /* 非根目录:第一行固定插入 "..",回车即返回上级 */ + if (strcmp(s_path, FILES_ROOT) != 0) + { + snprintf(s_entries[0].name, sizeof(s_entries[0].name), ".."); + s_entries[0].is_dir = true; + s_entries[0].size = 0; + n = 1; + } + + fi = (FILINFO *)malloc(sizeof(FILINFO)); + + if (fi == NULL) + { + ESP_LOGE(TAG, "no memory for FILINFO"); + s_load_res = FR_NOT_ENOUGH_CORE; + return n; + } + + res = f_opendir(&dir, s_path); + + if (res == FR_OK) + { + while (n < FILES_MAX_ENTRIES) + { + res = f_readdir(&dir, fi); + + if (res != FR_OK) + { + break; + } + + if (fi->fname[0] == '\0') + { + break; /* 目录遍历结束 */ + } + + /* 隐藏 / 系统 / 卷标 一律不显示 */ + if ((fi->fattrib & AM_HID) || (fi->fattrib & AM_SYS) || + (fi->fattrib & FILES_AM_VOL)) + { + continue; + } + + if (strcmp(fi->fname, ".") == 0) + { + continue; + } + + /* strlcpy 不做格式化,可以避开 -Wformat-truncation; + 超过 FILES_NAME_MAX-1 的极端长名会被截断(仅此类目录无法再进入) */ + strlcpy(s_entries[n].name, fi->fname, sizeof(s_entries[n].name)); + s_entries[n].is_dir = (fi->fattrib & AM_DIR) != 0; + s_entries[n].size = (uint32_t)fi->fsize; + n++; + } + + f_closedir(&dir); + } + + free(fi); + s_load_res = res; + + if (n > 1) + { + qsort(s_entries, (size_t)n, sizeof(files_entry_t), files_cmp); + } + + return n; +} + +/* ================================================================================================ + * 界面刷新 + * ============================================================================================== */ + +/** 让选中项始终落在可见窗口内 */ +static void files_keep_visible(void) +{ + if (s_sel < s_top) + { + s_top = s_sel; + } + else if (s_sel >= (s_top + FILES_ROWS)) + { + s_top = s_sel - FILES_ROWS + 1; + } + + int max_top = (s_count > FILES_ROWS) ? (s_count - FILES_ROWS) : 0; + + if (s_top > max_top) + { + s_top = max_top; + } + + if (s_top < 0) + { + s_top = 0; + } +} + +/** K0 的含义随深度变化:根目录是“回主界面”,子目录是“返回上级” */ +static void files_update_keybar(void) +{ + bool at_root = (strcmp(s_path, FILES_ROOT) == 0); + + ui_key_bar_set_caption(&s_bar, APP_KEY_0, at_root ? "K0 HOME" : "K0 UP"); +} + +static void files_hide_toast(void) +{ + lv_obj_add_flag(s_toast, LV_OBJ_FLAG_HIDDEN); +} + +static void files_show_toast(const char *text) +{ + ui_label_set_if_changed(s_toast_lbl, text); + lv_obj_clear_flag(s_toast, LV_OBJ_FLAG_HIDDEN); + s_toast_tick = lv_tick_get(); +} + +/** 把当前窗口内的条目画到固定的行控件上 */ +static void files_refresh(void) +{ + char buf[80]; + + /* ---- 顶部:路径 + 条目数 ---- */ + ui_label_set_if_changed(s_lbl_path, s_path); + + snprintf(buf, sizeof(buf), "%d", s_count); + ui_label_set_if_changed(s_lbl_count, buf); + + /* ---- 空目录 / 读取失败提示 ---- */ + if (s_count == 0) + { + if (s_load_res == FR_OK) + { + ui_label_set_if_changed(s_lbl_empty, "EMPTY FOLDER"); + } + else if (s_sd_ok) + { + snprintf(buf, sizeof(buf), "CANNOT READ (%d)", (int)s_load_res); + ui_label_set_if_changed(s_lbl_empty, buf); + } + else + { + ui_label_set_if_changed(s_lbl_empty, "NO SD CARD"); + } + + lv_obj_clear_flag(s_lbl_empty, LV_OBJ_FLAG_HIDDEN); + } + else + { + lv_obj_add_flag(s_lbl_empty, LV_OBJ_FLAG_HIDDEN); + } + + /* ---- 列表行 ---- */ + for (int i = 0; i < FILES_ROWS; i++) + { + files_row_t *r = &s_rows[i]; + int idx = s_top + i; + + if (idx >= s_count) + { + lv_obj_add_flag(r->row, LV_OBJ_FLAG_HIDDEN); + continue; + } + + lv_obj_clear_flag(r->row, LV_OBJ_FLAG_HIDDEN); + + const files_entry_t *e = &s_entries[idx]; + bool sel = (idx == s_sel); + + /* 选中:深色底 + 强调色描边 */ + lv_obj_set_style_bg_color(r->row, + lv_color_hex(sel ? UI_COL_CARD_SEL : UI_COL_CARD), + LV_PART_MAIN); + lv_obj_set_style_border_width(r->row, sel ? 1 : 0, LV_PART_MAIN); + + /* 图标:目录黄色、音频青色、图片紫色、其它灰色 */ + const char *icon = LV_SYMBOL_FILE; + uint32_t icon_col = UI_COL_DIM; + + if (e->is_dir) + { + icon = LV_SYMBOL_DIRECTORY; + icon_col = UI_COL_WARN; + } + else if (files_is_audio(e->name)) + { + icon = LV_SYMBOL_AUDIO; + icon_col = UI_COL_ACCENT; + } + else if (files_is_image(e->name)) + { + icon = LV_SYMBOL_IMAGE; + icon_col = UI_COL_ACCENT2; + } + + ui_label_set_if_changed(r->icon, icon); + lv_obj_set_style_text_color(r->icon, lv_color_hex(icon_col), LV_PART_MAIN); + + /* 名称(非 ASCII 折叠成 '?') */ + files_display_name(e->name, buf, sizeof(buf)); + ui_label_set_if_changed(r->name, buf); + lv_obj_set_style_text_color(r->name, + lv_color_hex(sel ? UI_COL_TEXT : 0xD5DBEA), + LV_PART_MAIN); + + /* 右侧:目录显示 ,文件显示大小 */ + if (e->is_dir) + { + ui_label_set_if_changed(r->size, ""); + } + else + { + files_format_size(e->size, buf, sizeof(buf)); + ui_label_set_if_changed(r->size, buf); + } + } + + files_update_keybar(); +} + +/* ================================================================================================ + * 导航 + * ============================================================================================== */ + +/** + * @brief 打开一个目录(失败则退回原目录) + */ +static void files_open_dir(const char *path) +{ + char target[FILES_PATH_MAX]; + char backup[FILES_PATH_MAX]; + int old_sel = s_sel; + int n; + + /* 先把目标路径复制出来,避免 path 指向 s_path 自身时 snprintf 重叠 */ + strlcpy(target, path, sizeof(target)); + snprintf(backup, sizeof(backup), "%s", s_path); + strlcpy(s_path, target, sizeof(s_path)); + + files_ensure_mounted(); + + n = files_load(); + + if ((n == 0) && (s_load_res != FR_OK) && (strcmp(target, FILES_ROOT) != 0)) + { + ESP_LOGW(TAG, "cannot open %s (%d)", target, (int)s_load_res); + + strlcpy(s_path, backup, sizeof(s_path)); + n = files_load(); + s_sel = old_sel; + } + else + { + s_sel = 0; + } + + s_count = n; + s_top = 0; + + files_keep_visible(); + files_refresh(); +} + +/** + * @brief 返回上一级目录 + * @retval true 已返回;false 已在根目录 + */ +static bool files_go_up(void) +{ + if (strcmp(s_path, FILES_ROOT) == 0) + { + return false; + } + + char parent[FILES_PATH_MAX]; + char *slash = strrchr(s_path, '/'); + + if (slash == NULL) + { + return false; + } + + size_t len = (size_t)(slash - s_path); + + if (len == 0) + { + snprintf(parent, sizeof(parent), "%s", FILES_ROOT); + } + else + { + if (len >= sizeof(parent)) + { + len = sizeof(parent) - 1; + } + + memcpy(parent, s_path, len); + parent[len] = '\0'; + } + + files_open_dir(parent); + + return true; +} + +/** + * @brief 打开选中项:目录则进入,文件则弹出详情 + */ +static void files_enter(void) +{ + if ((s_count <= 0) || (s_sel < 0) || (s_sel >= s_count)) + { + return; + } + + const files_entry_t *e = &s_entries[s_sel]; + + if (e->is_dir) + { + if (strcmp(e->name, "..") == 0) + { + files_go_up(); + return; + } + + char next[FILES_PATH_MAX]; + size_t plen = strlen(s_path); + size_t nlen = strlen(e->name); + + /* 拼成 "<当前路径>/<子目录名>"(s_path 根目录时是 "0:",同样需要斜杠) */ + if ((plen + 1u + nlen + 1u) > sizeof(next)) + { + files_show_toast("PATH TOO LONG"); + return; + } + + memcpy(next, s_path, plen); + next[plen] = '/'; + memcpy(next + plen + 1u, e->name, nlen); + next[plen + 1u + nlen] = '\0'; + + files_open_dir(next); + + return; + } + + /* 普通文件:显示文件名 + 大小 + 类型 */ + char disp[80]; + char size_text[24]; + char text[128]; + + files_display_name(e->name, disp, sizeof(disp)); + files_format_size(e->size, size_text, sizeof(size_text)); + + snprintf(text, sizeof(text), "%s\n%s %s", disp, size_text, + files_is_audio(e->name) ? "AUDIO" : + (files_is_image(e->name) ? "IMAGE" : "FILE")); + + files_show_toast(text); +} + +/* ================================================================================================ + * 对外接口 + * ============================================================================================== */ + +/** 创建一行(固定池,不随目录内容变化) */ +static void files_create_row(lv_obj_t *parent, int i) +{ + files_row_t *r = &s_rows[i]; + + r->row = ui_card(parent, 0, (lv_coord_t)(i * FILES_ROW_H), + UI_HOR_RES - 16, FILES_ROW_H - 2, UI_COL_CARD, 7); + + lv_obj_set_style_pad_hor(r->row, 6, LV_PART_MAIN); + lv_obj_set_style_border_width(r->row, 0, LV_PART_MAIN); + lv_obj_set_style_border_color(r->row, lv_color_hex(UI_COL_ACCENT), LV_PART_MAIN); + + r->icon = ui_label(r->row, LV_SYMBOL_FILE, &lv_font_montserrat_14, UI_COL_DIM); + lv_obj_set_width(r->icon, 18); + lv_obj_align(r->icon, LV_ALIGN_LEFT_MID, 0, 0); + + r->size = ui_label(r->row, "", &lv_font_montserrat_12, UI_COL_SUB); + lv_obj_set_width(r->size, ROW_SIZE_W); + lv_obj_set_style_text_align(r->size, LV_TEXT_ALIGN_RIGHT, LV_PART_MAIN); + lv_obj_align(r->size, LV_ALIGN_RIGHT_MID, 0, 0); + + r->name = ui_label(r->row, "", &lv_font_montserrat_14, UI_COL_TEXT); + lv_obj_set_size(r->name, ROW_NAME_W, FILES_ROW_H - 4); + lv_label_set_long_mode(r->name, LV_LABEL_LONG_SCROLL_CIRCULAR); + lv_obj_align(r->name, LV_ALIGN_LEFT_MID, ROW_NAME_X, 0); +} + +void ui_files_create(void) +{ + s_scr = lv_obj_create(NULL); + ui_style_screen(s_scr); + + /* ---------------- 标题栏 ---------------- */ + lv_obj_t *header = ui_card(s_scr, 0, 0, UI_HOR_RES, FILES_HEADER_H, UI_COL_PANEL, 0); + + lv_obj_t *title = ui_label(header, LV_SYMBOL_DIRECTORY " FILES", + &lv_font_montserrat_16, 0x31C27C); + lv_obj_align(title, LV_ALIGN_LEFT_MID, 10, 0); + + lv_obj_t *hint = ui_label(header, "SD", &lv_font_montserrat_12, UI_COL_SUB); + lv_obj_align(hint, LV_ALIGN_RIGHT_MID, -44, 0); + + s_lbl_count = ui_label(header, "0", &lv_font_montserrat_12, UI_COL_ACCENT); + lv_obj_align(s_lbl_count, LV_ALIGN_RIGHT_MID, -10, 0); + + /* ---------------- 当前路径 ---------------- */ + lv_obj_t *path_bar = ui_card(s_scr, 8, FILES_PATH_Y, UI_HOR_RES - 16, FILES_PATH_H, + UI_COL_TRACK, 6); + + s_lbl_path = ui_label(path_bar, FILES_ROOT, &lv_font_montserrat_12, UI_COL_ACCENT); + lv_obj_set_width(s_lbl_path, UI_HOR_RES - 16 - 12); + lv_label_set_long_mode(s_lbl_path, LV_LABEL_LONG_SCROLL_CIRCULAR); + lv_obj_align(s_lbl_path, LV_ALIGN_LEFT_MID, 6, 0); + + /* ---------------- 列表容器(不支持滚轮,靠滑动窗口) ---------------- */ + lv_obj_t *list = lv_obj_create(s_scr); + + lv_obj_set_pos(list, 8, FILES_LIST_Y); + lv_obj_set_size(list, UI_HOR_RES - 16, FILES_LIST_H); + lv_obj_clear_flag(list, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_bg_opa(list, LV_OPA_TRANSP, LV_PART_MAIN); + lv_obj_set_style_border_width(list, 0, LV_PART_MAIN); + lv_obj_set_style_pad_all(list, 0, LV_PART_MAIN); + + for (int i = 0; i < FILES_ROWS; i++) + { + files_create_row(list, i); + } + + /* ---------------- 空目录 / 错误提示 ---------------- */ + s_lbl_empty = ui_label(s_scr, "NO SD CARD", &lv_font_montserrat_14, UI_COL_DIM); + lv_obj_align(s_lbl_empty, LV_ALIGN_TOP_MID, 0, 116); + + /* ---------------- 文件详情浮层(创建在最后,压在最上层) ---------------- */ + s_toast = ui_card(s_scr, 8, FILES_TOAST_Y, UI_HOR_RES - 16, 38, UI_COL_PANEL, 8); + + lv_obj_set_style_border_width(s_toast, 1, LV_PART_MAIN); + lv_obj_set_style_border_color(s_toast, lv_color_hex(UI_COL_ACCENT), LV_PART_MAIN); + lv_obj_add_flag(s_toast, LV_OBJ_FLAG_HIDDEN); + + s_toast_lbl = ui_label(s_toast, "", &lv_font_montserrat_12, UI_COL_TEXT); + lv_obj_set_width(s_toast_lbl, UI_HOR_RES - 16 - 16); + lv_obj_set_style_text_align(s_toast_lbl, LV_TEXT_ALIGN_CENTER, LV_PART_MAIN); + lv_obj_center(s_toast_lbl); + + /* ---------------- 底部按键提示 ---------------- */ + ui_key_bar_create(s_scr, &s_bar, s_symbols, s_captions, s_enabled); +} + +void ui_files_show(void) +{ + ui_key_bar_set_highlight(&s_bar, -1); + files_hide_toast(); + + /* 每次进入应用都从根目录重新开始,行为可预测 */ + files_open_dir(FILES_ROOT); + + lv_scr_load(s_scr); +} + +void ui_files_tick(void) +{ + /* 文件详情浮层 2 秒后自动消失 */ + if (!lv_obj_has_flag(s_toast, LV_OBJ_FLAG_HIDDEN) && + (lv_tick_elaps(s_toast_tick) > 2000)) + { + files_hide_toast(); + } + + ui_key_bar_set_highlight(&s_bar, ui_flash_active(&s_flash)); +} + +void ui_files_flash(int key) +{ + ui_flash_trigger(&s_flash, key); +} + +void ui_files_handle_key(uint8_t key) +{ + ui_flash_trigger(&s_flash, key); + + switch (key) + { + case APP_KEY_1: /* 上一项(到顶后跳到底部) */ + if (s_count > 0) + { + s_sel = (s_sel <= 0) ? (s_count - 1) : (s_sel - 1); + files_keep_visible(); + files_refresh(); + } + break; + + case APP_KEY_2: /* 下一项 */ + if (s_count > 0) + { + s_sel = (s_sel + 1) % s_count; + files_keep_visible(); + files_refresh(); + } + break; + + case APP_KEY_3: /* 进入目录 / 查看文件 */ + files_enter(); + break; + + default: + break; + } +} + +bool ui_files_back(void) +{ + if (strcmp(s_path, FILES_ROOT) == 0) + { + return false; /* 已在根目录:交给 ui.c 回主界面 */ + } + + return files_go_up(); +} diff --git a/main/APP/files/ui_files.h b/main/APP/files/ui_files.h new file mode 100644 index 0000000..655a455 --- /dev/null +++ b/main/APP/files/ui_files.h @@ -0,0 +1,51 @@ +/** + **************************************************************************************************** + * @file ui_files.h + * @author HiFi Player Project + * @version V1.0 + * @date 2026-01-01 + * @brief 文件管理器界面接口 + * + * 按键:K0 返回上级目录(根目录时回主界面) / K1 上一项 / K2 下一项 / K3 打开 + **************************************************************************************************** + */ + +#ifndef __UI_FILES_H +#define __UI_FILES_H + +#include +#include + +#include "lvgl.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/** 创建文件管理器界面(只创建,不显示) */ +void ui_files_create(void); + +/** 显示界面:每次进入都从 SD 卡根目录开始浏览 */ +void ui_files_show(void); + +/** 周期刷新(按键高亮) */ +void ui_files_tick(void); + +/** 处理功能键(K1/K2/K3);K0 由 ui.c 统一处理 */ +void ui_files_handle_key(uint8_t key); + +/** 仅触发按键高亮(不做动作) */ +void ui_files_flash(int key); + +/** + * @brief K0 短按时由 ui.c 先询问本界面能否“就地返回” + * @retval true 已返回上一级目录(ui.c 不再回主界面) + * false 已在根目录,交给 ui.c 回主界面 + */ +bool ui_files_back(void); + +#ifdef __cplusplus +} +#endif + +#endif /* __UI_FILES_H */ diff --git a/main/APP/player/audio_source.c b/main/APP/player/audio_source.c index ac6a948..e67f91f 100644 --- a/main/APP/player/audio_source.c +++ b/main/APP/player/audio_source.c @@ -34,6 +34,11 @@ #define WAV_MAX_BLOCKALIGN 8 /* 支持的 WAV 帧对齐上限(16/24/32bit × 最多 2 声道) */ #define WAV_MAX_FRAMES 1024 /* WAV 原始 PCM 中转缓冲容纳的最大帧数 */ +#define AUDIO_SRC_MAX_FRAMES 1024 /* 单次 audio_source_read 最多输出的帧数(与播放器缓冲一致) */ +#define DECIM_CHUNK_FRAMES 256 /* 抽取时每次处理多少输出帧(控制临时缓冲大小) */ +#define AUDIO_MAX_OUT_RATE 48000 /* 输出采样率上限 */ +#define AUDIO_MAX_DECIM 8 /* 最大抽取倍数 */ + /* ---------------- 内部结构 ---------------- */ struct audio_source { audio_codec_t codec; @@ -54,6 +59,10 @@ struct audio_source { /* ---- FLAC ---- */ flac_dec_t *flac; + + /* ---- 高采样率抽取(192k/96k → ≤48k) ---- */ + uint8_t decim; /* 抽取倍数,1 表示不抽取 */ + uint8_t *decim_buf; /* 抽取用的原生采样中转缓冲 */ }; /* ---------------- 位宽映射 ---------------- */ @@ -76,6 +85,10 @@ static inline uint16_t pick_out_bits(uint16_t bits) return 32; } +/* 采样率限制 / 抽取(实现见文件末尾,供 audio_source_open 使用) */ +static bool decim_setup(audio_source_t *src); +static void decim_mix(const uint8_t *src, uint8_t *dst, int frames, int decim, uint8_t ob); + /* ---------------- WAV 解析 ---------------- */ static inline uint16_t rd_u16(const uint8_t *p) @@ -256,8 +269,7 @@ static int wav_read(audio_source_t *src, void *out, int max_frames) } } - src->frames_read += frames; - + /* 注意:frames_read 统一由 audio_source_read() 累加,这里不要重复计数 */ return (int)frames; } @@ -323,14 +335,25 @@ audio_source_t *audio_source_open(const char *path, audio_info_t *info) src->info.total_sec = (fi.total_samples > 0) ? (uint32_t)(fi.total_samples / fi.sample_rate) : 0; + /* 高采样率(96k/192k)抽取到 ES8388 / MCLK 能承受的范围 */ + if (!decim_setup(src)) + { + ESP_LOGE(TAG, "unsupported sample rate %u Hz: %s", + (unsigned)fi.sample_rate, path); + audio_source_close(src); + return NULL; + } + if (info != NULL) { *info = src->info; } - ESP_LOGI(TAG, "%s: FLAC %u Hz, %u bit -> I2S %u bit, %u ch, %u s", - path, (unsigned)src->info.sample_rate, src->info.bits, - src->info.out_bits, src->info.channels, + ESP_LOGI(TAG, "%s: FLAC %u Hz%s, %u bit -> I2S %u bit @ %u Hz, %u ch, %u s", + path, (unsigned)fi.sample_rate, + (src->decim > 1) ? " (downsampled)" : "", + src->info.bits, src->info.out_bits, + (unsigned)src->info.sample_rate, src->info.channels, (unsigned)src->info.total_sec); return src; @@ -359,6 +382,15 @@ audio_source_t *audio_source_open(const char *path, audio_info_t *info) src->info.out_bytes = (uint8_t)(bits / 8); /* WAV 位深必为 16/24/32 */ src->info.total_sec = src->data_size / src->byte_rate; + /* 高采样率(96k/192k)抽取到 ES8388 / MCLK 能承受的范围 */ + if (!decim_setup(src)) + { + ESP_LOGW(TAG, "unsupported sample rate %u Hz: %s", + (unsigned)src->info.sample_rate, path); + audio_source_close(src); + return NULL; + } + /* 原始 PCM 中转缓冲(16/24/32bit × 最多 2 声道 → 每帧最多 8 字节) */ src->scratch = (uint8_t *)malloc((size_t)WAV_MAX_FRAMES * src->block_align); @@ -385,6 +417,87 @@ audio_source_t *audio_source_open(const char *path, audio_info_t *info) return src; } +/** + * @brief 带抽取的读取:把 decim 个原生帧合成 1 个输出帧 + */ +static int audio_source_read_decim(audio_source_t *src, void *out, int max_frames) +{ + uint8_t *dst = (uint8_t *)out; + const uint8_t ob = src->info.out_bytes; + const int decim = src->decim; + int produced = 0; + + while (produced < max_frames) + { + int room = max_frames - produced; + + if (room > DECIM_CHUNK_FRAMES) + { + room = DECIM_CHUNK_FRAMES; + } + + if ((room * decim) > WAV_MAX_FRAMES) + { + room = WAV_MAX_FRAMES / decim; /* 受中转缓冲帧数限制 */ + } + + if (room <= 0) + { + break; + } + + /* 先凑满 room * decim 个原生帧,避免每次少读几帧造成丢样本 */ + const int need = room * decim; + int have = 0; + bool eof = false; + + while (have < need) + { + int got = (src->codec == AUDIO_CODEC_FLAC) + ? flac_dec_read(src->flac, + src->decim_buf + (size_t)have * 2u * ob, + need - have) + : wav_read(src, + src->decim_buf + (size_t)have * 2u * ob, + need - have); + + if (got < 0) + { + return -1; + } + + if (got == 0) + { + eof = true; + break; + } + + have += got; + } + + int frames = have / decim; + + if (frames > 0) + { + decim_mix(src->decim_buf, dst + (size_t)produced * 2u * ob, + frames, decim, ob); + produced += frames; + } + + if (eof) + { + break; /* 文件末尾 */ + } + } + + if (produced > 0) + { + src->frames_read += (uint64_t)produced; + } + + return produced; +} + int audio_source_read(audio_source_t *src, void *out, int max_frames) { if ((src == NULL) || (out == NULL) || (max_frames <= 0)) @@ -392,19 +505,26 @@ int audio_source_read(audio_source_t *src, void *out, int max_frames) return -1; } - if (src->codec == AUDIO_CODEC_FLAC) + if (max_frames > AUDIO_SRC_MAX_FRAMES) { - int n = flac_dec_read(src->flac, out, max_frames); - - if (n > 0) - { - src->frames_read += (uint64_t)n; - } - - return n; + max_frames = AUDIO_SRC_MAX_FRAMES; /* 保护调用者缓冲 */ } - return wav_read(src, out, max_frames); + if (src->decim > 1) + { + return audio_source_read_decim(src, out, max_frames); + } + + int n = (src->codec == AUDIO_CODEC_FLAC) + ? flac_dec_read(src->flac, out, max_frames) + : wav_read(src, out, max_frames); + + if (n > 0) + { + src->frames_read += (uint64_t)n; + } + + return n; } uint32_t audio_source_elapsed_sec(const audio_source_t *src) @@ -446,5 +566,136 @@ void audio_source_close(audio_source_t *src) free(src->scratch); src->scratch = NULL; + free(src->decim_buf); + src->decim_buf = NULL; + free(src); } + +/* ---------------- 采样率限制 ---------------- */ + +/** + * @brief 计算抽取倍数(把采样率压到 AUDIO_MAX_OUT_RATE 以内) + * @note 板载 ES8388 的 MCLK 由 I2S 产生,MCLK = mclk_multiple × fs. + * 24bit 时 mclk_multiple 必须是 384(3 的倍数),若直接播放 192kHz, + * MCLK 会高达 73.7MHz,远超 ES8388 的输入范围,结果是无声音或杂音. + * 因此这里对 88.2k/96k/192k 等做整数抽取,统一落到 ≤48kHz. + */ +static uint8_t pick_decim(uint32_t rate) +{ + uint8_t d = 1; + + while (((rate / d) > AUDIO_MAX_OUT_RATE) && (d < AUDIO_MAX_DECIM)) + { + d++; + } + + return d; +} + +/** + * @brief 按输出采样率为音源分配抽取缓冲 + * @retval true 成功;false 采样率过高或内存不足 + */ +static bool decim_setup(audio_source_t *src) +{ + uint8_t d = pick_decim(src->info.sample_rate); + + if ((src->info.sample_rate / d) > AUDIO_MAX_OUT_RATE) + { + return false; /* 采样率过高,无法用整数抽取覆盖 */ + } + + src->decim = d; + + if (d <= 1) + { + return true; /* 常见 44.1k/48k:完全不额外占用内存 */ + } + + size_t bytes = (size_t)DECIM_CHUNK_FRAMES * d * 2u * src->info.out_bytes; + src->decim_buf = (uint8_t *)malloc(bytes); + + if (src->decim_buf == NULL) + { + return false; + } + + /* 对外统一暴露输出采样率,I2S / 界面都按这个值工作 */ + src->info.sample_rate /= d; + + return true; +} + +/* ---------------- 抽取用的采样读写 ---------------- */ + +static inline int32_t pcm_sample_get(const uint8_t *p, uint8_t bytes) +{ + switch (bytes) + { + case 2: + return (int16_t)(p[0] | (p[1] << 8)); + + case 3: + { + int32_t v = (int32_t)(p[0] | (p[1] << 8) | (p[2] << 16)); + + if (v & 0x800000) + { + v -= 0x1000000; + } + + return v; + } + + default: + return (int32_t)((uint32_t)p[0] | ((uint32_t)p[1] << 8) | + ((uint32_t)p[2] << 16) | ((uint32_t)p[3] << 24)); + } +} + +static inline void pcm_sample_put(uint8_t *p, uint8_t bytes, int32_t v) +{ + switch (bytes) + { + case 2: + p[0] = (uint8_t)(v & 0xFF); + p[1] = (uint8_t)((v >> 8) & 0xFF); + break; + + case 3: + p[0] = (uint8_t)(v & 0xFF); + p[1] = (uint8_t)((v >> 8) & 0xFF); + p[2] = (uint8_t)((v >> 16) & 0xFF); + break; + + default: + p[0] = (uint8_t)(v & 0xFF); + p[1] = (uint8_t)((v >> 8) & 0xFF); + p[2] = (uint8_t)((v >> 16) & 0xFF); + p[3] = (uint8_t)((v >> 24) & 0xFF); + break; + } +} + +/** + * @brief 整数抽取:把 decim 帧平均成 1 帧(简易低通,抑制抽取混叠) + */ +static void decim_mix(const uint8_t *src, uint8_t *dst, int frames, int decim, uint8_t ob) +{ + for (int i = 0; i < frames; i++) + { + for (int c = 0; c < 2; c++) + { + int64_t sum = 0; + + for (int k = 0; k < decim; k++) + { + const size_t idx = ((size_t)i * (size_t)decim + (size_t)k) * 2u + (size_t)c; + sum += pcm_sample_get(src + idx * ob, ob); + } + + pcm_sample_put(dst + ((size_t)i * 2u + (size_t)c) * ob, ob, (int32_t)(sum / decim)); + } + } +} diff --git a/main/APP/player/audio_source.h b/main/APP/player/audio_source.h index 333b8a3..f0f9c55 100644 --- a/main/APP/player/audio_source.h +++ b/main/APP/player/audio_source.h @@ -21,6 +21,12 @@ * 17~24bit → 24bit(每采样 3 字节) * 25~32bit → 32bit(每采样 4 字节) * 非整字节位深(如 20bit)按左对齐放入更宽的槽位,音量不受影响。 + * + * 采样率限制: + * ES8388 的 MCLK 由 I2S 产生(MCLK = mclk_multiple × fs),24bit 时 + * mclk_multiple 必须是 384。若直接播放 192kHz,MCLK 会到 73.7MHz, + * 远超 codec 能力。因此 88.2k/96k/192k 等会做整数抽取降到 ≤48kHz, + * audio_info_t.sample_rate 返回的始终是「实际输出采样率」。 **************************************************************************************************** */ @@ -43,7 +49,7 @@ typedef enum { /** 音源信息 */ typedef struct { audio_codec_t codec; /* 容器类型 */ - uint32_t sample_rate; /* 采样率 → 用于配置 I2S 频率 */ + uint32_t sample_rate; /* 采样率 → 用于配置 I2S 频率(已按输出上限抽取) */ uint16_t bits; /* 源位深(界面显示用) */ uint16_t channels; /* 源声道数 */ uint16_t out_bits; /* I2S 输出位宽:16 / 24 / 32 */ diff --git a/main/APP/player/player.c b/main/APP/player/player.c index d4f2413..19655cd 100644 --- a/main/APP/player/player.c +++ b/main/APP/player/player.c @@ -292,6 +292,8 @@ static void audio_set_format(uint32_t sample_rate, uint16_t bits) /* ---------------- 曲目扫描 ---------------- */ /** * @brief 判断是否为可播放的音频文件(.wav / .flac) + * @note 两种扩展名都要检查,不能因为不是 .wav 就提前 return false, + * 否则 .flac 永远被过滤掉。 */ static bool name_is_audio(const char *name) { @@ -302,34 +304,27 @@ static bool name_is_audio(const char *name) return false; } - const char *ext = name + len - 4; /* 指向 ".xxx" */ + const char *ext = name + len - 4; /* 指向最后 4 个字符 */ - if (ext[0] != '.') - { - return false; - } - - /* .wav */ - if (((ext[1] == 'w') || (ext[1] == 'W')) && + /* .wav —— 最后 4 个字符为 ".wav" */ + if ((ext[0] == '.') && + ((ext[1] == 'w') || (ext[1] == 'W')) && ((ext[2] == 'a') || (ext[2] == 'A')) && ((ext[3] == 'v') || (ext[3] == 'V'))) { return true; } - /* .flac(长度至少 5:"a.flac") */ - if (len >= 5) - { - const char *f = name + len - 5; + /* .flac —— 最后 5 个字符为 ".flac" */ + const char *ext5 = name + len - 5; - if ((f[0] == '.') && - ((f[1] == 'f') || (f[1] == 'F')) && - ((f[2] == 'l') || (f[2] == 'L')) && - ((f[3] == 'a') || (f[3] == 'A')) && - ((f[4] == 'c') || (f[4] == 'C'))) - { - return true; - } + if ((ext5[0] == '.') && + ((ext5[1] == 'f') || (ext5[1] == 'F')) && + ((ext5[2] == 'l') || (ext5[2] == 'L')) && + ((ext5[3] == 'a') || (ext5[3] == 'A')) && + ((ext5[4] == 'c') || (ext5[4] == 'C'))) + { + return true; } return false; diff --git a/main/APP/ui.c b/main/APP/ui.c index c6f60a3..2cb6d51 100644 --- a/main/APP/ui.c +++ b/main/APP/ui.c @@ -25,6 +25,7 @@ #include "ui_home.h" #include "ui_player.h" +#include "ui_files.h" static app_mode_t s_last_mode = APP_MODE_NUM; @@ -39,6 +40,10 @@ static void ui_load_mode(app_mode_t mode) ui_player_show(); break; + case APP_MODE_FILES: + ui_files_show(); + break; + case APP_MODE_HOME: default: ui_home_show(); @@ -57,13 +62,20 @@ static void ui_dispatch(const app_event_t *ev) if (ev->key == APP_KEY_0) { /* 高亮交给当前界面,纯视觉效果 */ - if (mode == APP_MODE_PLAYER) + switch (mode) { - ui_player_flash(ev->key); - } - else - { - ui_home_flash(ev->key); + case APP_MODE_PLAYER: + ui_player_flash(ev->key); + break; + + case APP_MODE_FILES: + ui_files_flash(ev->key); + break; + + case APP_MODE_HOME: + default: + ui_home_flash(ev->key); + break; } if (ev->type == APP_KEY_LONG) @@ -74,6 +86,10 @@ static void ui_dispatch(const app_event_t *ev) { app_switch_next(); /* 主界面短按:切换应用 */ } + else if ((mode == APP_MODE_FILES) && ui_files_back()) + { + /* 文件管理器:先返回上一级目录,停留在本应用内 */ + } else { app_set_mode(APP_MODE_HOME); /* App 内短按:返回主界面 */ @@ -94,6 +110,10 @@ static void ui_dispatch(const app_event_t *ev) ui_player_handle_key(ev->key); break; + case APP_MODE_FILES: + ui_files_handle_key(ev->key); + break; + case APP_MODE_HOME: default: ui_home_handle_key(ev->key); @@ -131,6 +151,10 @@ static void ui_tick_cb(lv_timer_t *timer) ui_player_tick(); break; + case APP_MODE_FILES: + ui_files_tick(); + break; + case APP_MODE_HOME: default: ui_home_tick(); @@ -142,6 +166,7 @@ void ui_create(void) { ui_home_create(); ui_player_create(); + ui_files_create(); s_last_mode = app_get_mode(); ui_load_mode(s_last_mode); diff --git a/main/APP/ui.h b/main/APP/ui.h index 558f973..89445e6 100644 --- a/main/APP/ui.h +++ b/main/APP/ui.h @@ -12,6 +12,7 @@ * * 具体界面代码分别位于: * home/ 主界面(应用启动器) + * files/ 文件管理器(浏览 SD 卡目录) * player/ 播放器(界面 + 播放核心) **************************************************************************************************** */ diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt index 47d0def..92f0597 100644 --- a/main/CMakeLists.txt +++ b/main/CMakeLists.txt @@ -6,6 +6,7 @@ idf_component_register( "APP/ui_common.c" "APP/lvgl_port.c" "APP/home/ui_home.c" + "APP/files/ui_files.c" "APP/player/ui_player.c" "APP/player/player.c" "APP/player/audio_source.c" @@ -14,6 +15,7 @@ idf_component_register( "." "APP" "APP/home" + "APP/files" "APP/player" REQUIRES BSP