commit 4126ea13a177ef51b23dee7f0c00877f78748b4d Author: francis.wang Date: Mon Oct 26 20:07:12 2026 +0800 HiFi player project, first commit. diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 0000000..dafb8ad --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,13 @@ +ARG DOCKER_TAG=latest +FROM espressif/idf:${DOCKER_TAG} + +ENV LC_ALL=C.UTF-8 +ENV LANG=C.UTF-8 + +RUN apt-get update -y && apt-get install udev -y + +RUN echo "source /opt/esp/idf/export.sh > /dev/null 2>&1" >> ~/.bashrc + +ENTRYPOINT [ "/opt/esp/entrypoint.sh" ] + +CMD ["/bin/bash", "-c"] \ No newline at end of file diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..ff6a739 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,18 @@ +{ + "name": "ESP-IDF QEMU", + "build": { + "dockerfile": "Dockerfile" + }, + "customizations": { + "vscode": { + "settings": { + "terminal.integrated.defaultProfile.linux": "bash" + }, + "extensions": [ + "espressif.esp-idf-extension", + "espressif.esp-idf-web" + ] + } + }, + "runArgs": ["--privileged"] +} \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7805557 --- /dev/null +++ b/.gitignore @@ -0,0 +1,78 @@ +# macOS +.DS_Store +.AppleDouble +.LSOverride + +# Directory metadata +.directory + +# Temporary files +*~ +*.swp +*.swo +*.bak +*.tmp + +# Log files +*.log + +# Build artifacts and directories +**/build/ +build/ +*.o +*.a +*.out +*.exe # For any host-side utilities compiled on Windows + +# ESP-IDF specific build outputs +*.bin +*.elf +*.map +flasher_args.json # Generated in build directory +sdkconfig.old +sdkconfig + +# ESP-IDF dependencies +# For older versions or manual component management +/components/.idf/ +**/components/.idf/ +# For modern ESP-IDF component manager +managed_components/ +# If ESP-IDF tools are installed/referenced locally to the project +.espressif/ + +# CMake generated files +CMakeCache.txt +CMakeFiles/ +cmake_install.cmake +install_manifest.txt +CTestTestfile.cmake + +# Python environment files +*.pyc +*.pyo +*.pyd +__pycache__/ +*.egg-info/ +dist/ + +# Virtual environment folders +venv/ +.venv/ +env/ + +# Language Servers +.clangd/ +.ccls-cache/ +compile_commands.json + +# Windows specific +Thumbs.db +ehthumbs.db +Desktop.ini + +# User-specific configuration files +*.user +*.workspace # General workspace files, can be from various tools +*.suo # Visual Studio Solution User Options +*.sln.docstates # Visual Studio diff --git a/.vscode/c_cpp_properties.json b/.vscode/c_cpp_properties.json new file mode 100644 index 0000000..27fad04 --- /dev/null +++ b/.vscode/c_cpp_properties.json @@ -0,0 +1,20 @@ +{ + "configurations": [ + { + "name": "ESP-IDF", + "compilerPath": "", + "compileCommands": "${config:idf.buildPath}/compile_commands.json", + "intelliSenseMode": "gcc-x86", + "includePath": [ + "${workspaceFolder}/**" + ], + "browse": { + "path": [ + "${workspaceFolder}" + ], + "limitSymbolsToIncludedHeaders": true + } + } + ], + "version": 4 +} \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..3694ae4 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,10 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "type": "gdbtarget", + "request": "attach", + "name": "Eclipse CDT GDB Adapter" + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..1fdee41 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,31 @@ +{ + "C_Cpp.intelliSenseEngine": "default", + "idf.extensionActivationMode": "always", + "cmake.configureOnOpen": false, + "cmake.configureOnEdit": false, + "cmake.automaticReconfigure": false, + "cmake.autoSelectActiveFolder": false, + "cmake.options.advanced": { + "build": { + "statusBarVisibility": "inherit", + "inheritDefault": "hidden" + }, + "launch": { + "statusBarVisibility": "inherit", + "inheritDefault": "hidden" + }, + "debug": { + "statusBarVisibility": "inherit", + "inheritDefault": "hidden" + } + }, + "idf.openOcdConfigs": [ + "board/esp32s3-builtin.cfg" + ], + "idf.port": "COM36", + "idf.currentSetup": "C:\\esp\\v6.1\\esp-idf", + "idf.customExtraVars": { + "IDF_TARGET": "esp32s3" +}, +"idf.flashType": "UART" +} diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..d7f8f34 --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,6 @@ +# The following five lines of boilerplate have to be in your project's +# CMakeLists in this exact order for cmake to work correctly +cmake_minimum_required(VERSION 3.22) + +include($ENV{IDF_PATH}/tools/cmake/project.cmake) +project(hifi_player) diff --git a/README.md b/README.md new file mode 100644 index 0000000..3101408 --- /dev/null +++ b/README.md @@ -0,0 +1,365 @@ +# HiFi 播放器(ESP32-S3 + LVGL + ES8388 + SD 卡) + +基于正点原子 **DNESP32S3** 开发板标准例程(IDF v5.3.x)移植改造的 HiFi 音乐播放器。 +使用 **SPI LCD** 作为显示界面、**SD 卡** 存储 WAV 音频、**4 个板载按键** 交互,界面底部实时标注每个按键的功能。 + +--- + +## 1. 功能特性 + +| 功能 | 说明 | +| :--- | :--- | +| 应用启动器 | 开机进入主界面,可选择并进入「音乐播放器」(预留 SETTINGS / ABOUT) | +| 图形界面 | LVGL 8.4,320×240 横屏,深色 HiFi 风格 | +| 音频播放 | **WAV**(PCM 16/24/32bit)与 **FLAC**(原生 FLAC / Ogg FLAC),I2S + ES8388,支持 8k~96k 采样率 | +| 存储 | SD 卡(SPI 模式),FATFS,音乐目录 `/MUSIC` | +| 播放控制 | 播放/暂停 / 下一曲 / 音量切换(无上一曲,K0 用于返回) | +| 状态显示 | 曲目序号、歌名、格式、播放进度、音量、运行状态 | +| 按键提示 | 底部 4 个按键卡片,含义随界面变化,按下时高亮 | +| 后台挂起 | 从播放器返回主界面自动暂停,再进入时从原位置继续 | +| 音量记忆 | 音量保存在 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 │ +└──────────────────────────┘ └──────────────────────────┘ +``` + +> 界面文本使用 LVGL 内置 Montserrat 字体(含图标),因此为 ASCII。 +> 如需中文界面,见第 9 节“扩展建议”。 + +--- + +## 2. 硬件资源与引脚 + +| 外设 | 引脚 / 总线 | 说明 | +| :--- | :--- | :--- | +| 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 输出 | +| 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`。 + +--- + +## 3. 按键说明(界面底部同步显示) + +**全局约定**:`K0` 固定为「返回 / 切换」键,任何 App 都一致;`K1/K2/K3` 由各个 App 自行定义。 + +| 按键 | 全局含义 | +| :--- | :--- | +| K0 短按 | 在任意 App 内 → **返回主界面**;在主界面 → **切换到下一个应用** | +| K0 长按(0.7s) | 任意界面 → **直接切换到下一个应用** | + +### 主界面(应用启动器) + +| 按键 | 功能 | 界面提示 | +| :--- | :--- | :--- | +| KEY0 | 切换到下一个应用 | `→ K0 SWITCH` | +| KEY1 | 选择上一个图标 | `◀ K1 PREV` | +| KEY2 | 选择下一个图标 | `▶ K2 NEXT` | +| KEY3 | 打开选中的图标 | `✔ K3 OPEN` | + +### 播放器界面 + +| 按键 | 功能 | 界面提示 | +| :--- | :--- | :--- | +| KEY0 | **返回主界面**(长按 = 切换应用) | `← K0 BACK` | +| KEY1 | 播放 / 暂停 | `K1 PLAY`(播放中图标变为暂停) | +| KEY2 | **下一曲** | `K2 NEXT` | +| KEY3 | 音量切换(20/40/60/80/100%) | `K3 VOL` | + +> 已按要求取消「上一首」功能,K0 释放出来作为独立的返回键。 + +按键由 `app.c` 的按键服务统一扫描(10ms 周期、30ms 去抖、短按/长按区分), +`ui.c` 是事件的唯一消费者并负责分发,因此**不会重复扫描按键,也不会因为去抖延时而卡住音频**。 + +--- + +## 4. 目录结构 + +``` +hifi_player/ +├── CMakeLists.txt # 工程入口 +├── partitions-16MiB.csv # 16MB Flash 分区表(app 3MB) +├── sdkconfig.defaults # 默认配置:PSRAM/FATFS/LVGL 等 +├── components/ +│ └── BSP/ # 从标准例程移植的板级驱动 +│ ├── CMakeLists.txt +│ ├── LED/ # 指示灯 +│ ├── MYIIC/ # I2C0(新 I2C 主机驱动) +│ ├── XL9555/ # IO 扩展:按键 / LCD 控制 / 功放使能 +│ ├── MYSPI/ # SPI2 总线 + SD 卡设备 +│ ├── SPILCD/ # ST7789 SPI LCD(含 flush 同步) +│ ├── SPI_SD/ # SD 卡 FATFS 挂载 +│ ├── ES8388/ # 音频 DAC 配置 +│ └── MYI2S/ # I2S 标准模式收发 +└── main/ + ├── CMakeLists.txt + ├── idf_component.yml # 依赖:LVGL 8.4 + micro-flac + ├── main.c # 初始化流程 + └── APP/ + ├── app.c/.h # 应用管理器 + 按键服务(模式 / 事件队列 / 切换) + ├── ui.c/.h # 界面路由:换屏 + 按键分发(唯一按键消费者) + ├── ui_common.c/.h # 界面公共组件:配色 / 卡片 / 按键提示条 + ├── lvgl_port.c/.h # LVGL 移植层(显示 + 1ms 时基) + ├── home/ # 主界面(应用启动器) + │ ├── ui_home.c + │ └── ui_home.h + └── player/ # 播放器 App + ├── ui_player.c/.h # 播放器界面 + ├── player.c/.h # 播放核心(扫描 / I2S / 请求处理) + ├── audio_source.c/.h # 统一音源:WAV 直读 + FLAC 解码转发 + ├── flac_dec.cpp # FLAC 解码的 C 封装(C++ 实现) + └── flac_dec.h # 纯 C 接口 +``` + +### 分层关系 + +``` +按键: XL9555 → app.c 的 key_task → 事件队列 + │ +界面: ui.c(LVGL 任务,50ms) ────────┘ 唯一消费者 + ├─ K0 → 返回主界面 / 切换应用(全局) + └─ K1~K3 → 交给当前 App(ui_home / ui_player) + │ +播放: ui_player → player_request() → player_task 消费并执行 + (只有 APP_MODE_PLAYER 时才输出 I2S) +``` + +--- + +## 5. 编译与烧录 + +### 5.1 环境 + +* ESP-IDF **v6.1**(本工程已在此版本上编译验证通过,生成固件约 **893 KB**) +* 也兼容 **v5.3.x / v5.4**(BSP 已按 IDF 6.x 的驱动组件拆分方式声明依赖) +* 目标芯片:`esp32s3` +* 首次编译需要联网下载 `lvgl/lvgl` 8.4.0 与 `esphome/micro-flac` 0.3.0 + +### 5.2 命令 + +```bash +# 1. 设置目标(只需一次) +idf.py set-target esp32s3 + +# 2. 编译 +idf.py build + +# 3. 烧录并查看日志(把 COMx 换成实际串口) +idf.py -p COMx flash monitor +``` + +或直接在 VS Code 的 ESP-IDF 插件里点击 **Build / Flash / Monitor**。 + +> **注意**:`sdkconfig.defaults` 已配置 16MB Flash、八线 PSRAM(80MHz)、自定义分区表。 +> 首次编译前请确认板载 Flash 为 16MB(DNESP32S3 默认如此)。 + +--- + +## 6. SD 卡准备 + +1. SD 卡格式化为 **FAT32**(建议 ≤ 32GB)。 +2. 在根目录新建文件夹 **`MUSIC`**(大写),把音频文件放进去: + +``` +SD 卡根目录/ +└── MUSIC/ + ├── canon.wav + ├── song.flac + └── ... +``` + +3. 音频要求: + + | 容器 | 编码 | 位深 | 声道 | 采样率 | + | :--- | :--- | :--- | :--- | :--- | + | **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 | + +4. 说明: + * 两种格式都统一**转换为 16bit 立体声**后送给 ES8388: + 24/32bit 取高位,单声道复制到左右,多声道(FLAC)只取前两声道。 + * 界面上的 `WAV/FLAC xxxHz xxbit` 显示的是**源文件**参数。 + * 文件类型按**文件头**自动识别(`RIFF` / `fLaC` / `OggS`),扩展名只用于扫描列表。 + +--- + +## 7. 软件架构 + +``` +┌──────────────────────┐ ┌───────────────────────┐ +│ player_task │ │ lvgl_task │ +│ (优先级 6) │ │ (优先级 4, 8KB 栈) │ +│ │ │ │ +│ 扫描 /MUSIC │ │ lv_timer_handler() │ +│ audio_source_read() │ │ ui_tick_cb(50ms) │ +│ → I2S 写 │ │ │ │ +│ 消费按键请求队列 │ │ ▼ │ +│ │ │ │ player_get_state() │ +│ ▼ │ │ (互斥锁保护的状态快照)│ +│ s_state (互斥锁) │───────▶│ │ +└──────────────────────┘ └───────────┬───────────┘ + │ flush_cb + ▼ + spilcd_flush() → SPI DMA → ST7789 +``` + +### 音频解码链路 + +``` + ┌─ WAV ── f_read 原始 PCM ── 取高位 / 单声道复制 ─┐ +audio_source_read┤ ├─▶ 16bit 立体声 ──▶ I2S + └─ FLAC ── micro_flac 解码 ── 取高位 / 声道归一化 ─┘ +``` + +`audio_source.c` 只对外暴露「给我一块 16bit 立体声 PCM」,因此 `player.c` 的播放循环 +与容器格式完全无关;新增格式(如 MP3)只需在 `audio_source_open()` 里加一个分支。 + +### 关键实现点 + +1. **单任务音频流** + 一个任务完成“读文件 + 解码 + 写 I2S + 处理按键请求”,每块音频约 46ms, + 避免多任务同时访问 FATFS/I2S 的竞争。 + +2. **非阻塞按键去抖** + `key_scan()` 每次读取 XL9555 输入寄存器(一次 I2C 读 2 字节), + 连续 3 次采样一致才确认状态变化,不使用 `vTaskDelay` 阻塞去抖。 + +3. **LVGL 与 SPI LCD** + * 绘图缓冲区 320×40 像素,放在**内部 DMA RAM**(SPI DMA 不能直接读 PSRAM)。 + * `spilcd_flush()` 通过 esp_lcd 注册的 `on_color_trans_done` 回调 + 二值信号量 + 等待传输完成,保证 LVGL 缓冲区不会被提前复用。 + * LVGL 的 RGB565 是小端,ST7789 要求高字节在前,因此在 `disp_flush_cb()` 中做原地字节交换。 + +4. **I2S 通道管理** + `i2s_trx_start()/i2s_trx_stop()` 增加了状态判断,可以重复调用而不会报错; + 切换采样率时会先关闭通道,重新配置 `data_bit_width / ws_width / sample_rate` 后再打开。 + +5. **FLAC 解码** + * 使用 `esphome/micro-flac`(ESP32-S3 带 Xtensa 汇编优化,16bit 约 30 倍实时)。 + * 该库是 C++,`flac_dec.cpp` 提供一层纯 C 封装(`flac_dec_open/read/close`), + 播放器侧代码仍是 C。 + * 码流缓冲 8KB,采用「未消费数据前移 + 追加读取」,避免解码器返回 + `NEED_MORE_DATA` 时丢数据;同时有“无进展”保护,坏文件不会死循环。 + * 解码输出缓冲按 STREAMINFO 的 `max_block_size` 动态分配,优先放 PSRAM。 + +6. **状态同步** + 播放器只写 `s_state`(互斥锁保护),UI 每 50ms 读取一次快照, + LVGL 的所有控件操作都发生在 LVGL 任务内,天然线程安全。 + +--- + +## 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 时钟 | + +--- + +## 9. 扩展建议 + +### 9.1 新增一个应用(推荐流程) + +工程已经把「界面」「按键」和「应用」解耦,新增应用只需要 4 步,并且**独立文件夹**: + +1. **建立文件夹**:例如 `main/APP/clock/`,放入 `ui_clock.c` / `ui_clock.h`, + 参考 `player/ui_player.c` 实现 4 个函数: + + ```c + void ui_clock_create(void); /* 创建屏幕(lv_obj_create(NULL)) */ + void ui_clock_show(void); /* 刷新一次并 lv_scr_load() */ + void ui_clock_tick(void); /* 周期刷新显示 */ + void ui_clock_handle_key(uint8_t key); /* 只处理 K1/K2/K3 */ + ``` + +2. **加模式**:在 `main/APP/app.h` 的 `app_mode_t` 中,于 `APP_MODE_NUM` 之前加一项, + 例如 `APP_MODE_CLOCK`。 + +3. **加表项**:在 `main/APP/app.c` 的 `s_apps[]` 中加一条: + + ```c + { + .icon = LV_SYMBOL_FILE, /* 图标字形 */ + .title = "CLOCK", /* 图标下方名称(建议 ≤ 8 字符) */ + .subtitle = "Digital clock", /* 选中时下方显示的说明 */ + .mode = APP_MODE_CLOCK, /* 进入后切到的模式 */ + .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"`。 + +返回主界面、切换应用是**全局自动生效**的(`ui.c` 里统一处理 K0),新 App 不需要关心。 + +### 9.2 其他扩展 + +> 注意:主界面按 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` 中的英文文案替换为中文。 +* **图标换成图片**:当前图标是「圆角渐变 + 内置字形」,如需真实图标, + 可用 `LV_IMG_DECLARE()` + `lv_img_create()` 替代 `s_glyph[]` 处的标签 + (图片需要用 LVGL 图片转换工具转成 C 数组,或开启 `LV_USE_PNG` + 文件系统驱动)。 +* **再增加一种格式(如 MP3 / APE)**:引入对应解码库后,只需在 + `audio_source.c` 的 `audio_source_open()` 里加一个分支、在 `audio_source_read()` + 里加一个转发即可,`player.c` 与界面代码都不需要改: + + ```c + if (is_mp3) { + src->codec = AUDIO_CODEC_MP3; + src->mp3 = mp3_dec_open(path); + ... + } + ``` + +* **专辑封面**:LVGL 支持从文件系统读取 PNG/JPG(需开启 `LV_USE_PNG` / `LV_USE_SJPG` + + 文件系统驱动),可在播放器左侧封面卡片显示 `/MUSIC` 中的同名图片。 +* **歌词显示**:解析同名 `.lrc` 文件,在进度条下方增加滚动歌词控件。 + +--- + +## 10. 参考例程 + +* `10_spilcd` —— SPI LCD 初始化、引脚定义 +* `30_music` —— ES8388 / I2S / WAV 播放 / XL9555 按键 +* `25_sd` —— SD 卡(SPI)挂载 +* `4,扩展例程-IDF版/4,LVGL例程/01_lvgl_transplant` —— LVGL 8.4 移植结构 + +第三方组件: + +* [esphome/micro-flac](https://github.com/esphome-libs/micro-flac) —— FLAC 解码(Apache-2.0) + +驱动代码从上述例程移植;LVGL 移植层、应用管理器、界面路由、图标式主界面、 +统一音源层(WAV/FLAC)、播放器状态机与按键交互为本工程重新实现。 diff --git a/components/BSP/CMakeLists.txt b/components/BSP/CMakeLists.txt new file mode 100644 index 0000000..49958a7 --- /dev/null +++ b/components/BSP/CMakeLists.txt @@ -0,0 +1,35 @@ +set(src_dirs + LED + MYIIC + XL9555 + MYSPI + SPILCD + SPI_SD + ES8388 + MYI2S) + +set(include_dirs + LED + MYIIC + XL9555 + MYSPI + SPILCD + SPI_SD + ES8388 + MYI2S) + +set(requires + driver + esp_driver_gpio + esp_driver_i2c + esp_driver_i2s + esp_driver_spi + esp_driver_sdspi + esp_driver_sdmmc + sdmmc + esp_lcd + fatfs) + +idf_component_register(SRC_DIRS ${src_dirs} INCLUDE_DIRS ${include_dirs} REQUIRES ${requires}) + +component_compile_options(-ffast-math -O3 -Wno-error=format=-Wno-format) diff --git a/components/BSP/ES8388/es8388.c b/components/BSP/ES8388/es8388.c new file mode 100644 index 0000000..0b43b84 --- /dev/null +++ b/components/BSP/ES8388/es8388.c @@ -0,0 +1,294 @@ +/** + **************************************************************************************************** + * @file es8388.c + * @author 正点原子团队(ALIENTEK) + * @version V1.0 + * @date 2025-01-01 + * @brief ES8388驱动代码 + * @license Copyright (c) 2020-2032, 广州市星翼电子科技有限公司 + **************************************************************************************************** + * @attention + * + * 实验平台:正点原子 ESP32-S3 开发板 + * 在线视频:www.yuanzige.com + * 技术论坛:www.openedv.com + * 公司网址:www.alientek.com + * 购买地址:openedv.taobao.com + * + **************************************************************************************************** + */ + +#include "es8388.h" + + +const char* es8388_tag = "es8388"; +i2c_master_dev_handle_t es8388_handle = NULL; + +/** + * @brief ES8388写寄存器 + * @param reg_addr:寄存器地址 + * @param data:写入的数据 + * @retval 无 + */ +esp_err_t es8388_write_reg(uint8_t reg_addr, uint8_t data) +{ + esp_err_t ret; + uint8_t *buf = malloc(2); + if (buf == NULL) + { + ESP_LOGE(es8388_tag, "%s memory failed", __func__); + return ESP_ERR_NO_MEM; /* 分配内存失败 */ + } + + buf[0] = reg_addr; + buf[1] = data; /* 拷贝数据至存储区当中 */ + + do + { + i2c_master_bus_wait_all_done(bus_handle, 1000); + ret = i2c_master_transmit(es8388_handle, buf, 2, 1000); + } while (ret != ESP_OK); + + free(buf); /* 发送完成释放内存 */ + + return ret; +} + +/** + * @brief ES8388读寄存器 + * @param reg_add:寄存器地址 + * @param p_data:读取的数据 + * @retval 无 + */ +esp_err_t es8388_read_reg(uint8_t reg_addr, uint8_t *pdata) +{ + uint8_t reg_data = 0; + i2c_master_transmit_receive(es8388_handle, ®_addr, 1, ®_data, 1, -1); + return reg_data; +} + +/** + * @brief ES8388初始化 + * @param 无 + * @retval 0,初始化正常 + * 其他,错误代码 + */ +uint8_t es8388_init(void) +{ + uint8_t ret_val = 0; + + /* 未调用myiic_init初始化IIC */ + if (bus_handle == NULL) + { + ESP_ERROR_CHECK(myiic_init()); + } + + i2c_device_config_t es8388_i2c_dev_conf = { + .dev_addr_length = I2C_ADDR_BIT_LEN_7, /* 从机地址长度 */ + .scl_speed_hz = IIC_SPEED_CLK, /* 传输速率 */ + .device_address = ES8388_ADDR, /* 从机7位的地址 */ + }; + /* I2C总线上添加ES8388设备 */ + ESP_ERROR_CHECK(i2c_master_bus_add_device(bus_handle, &es8388_i2c_dev_conf, &es8388_handle)); + ESP_ERROR_CHECK(i2c_master_bus_wait_all_done(bus_handle,1000)); + + ret_val |= es8388_write_reg(0, 0x80); /* 软复位ES8388 */ + ret_val |= es8388_write_reg(0, 0x00); + vTaskDelay(pdMS_TO_TICKS(200)); /* 等待复位 */ + + ret_val |= es8388_write_reg(0x01, 0x58); + ret_val |= es8388_write_reg(0x01, 0x50); + ret_val |= es8388_write_reg(0x02, 0xF3); + ret_val |= es8388_write_reg(0x02, 0xF0); + + ret_val |= es8388_write_reg(0x03, 0x09); /* 麦克风偏置电源关闭 */ + ret_val |= es8388_write_reg(0x00, 0x06); /* 使能参考 500K驱动使能 */ + ret_val |= es8388_write_reg(0x04, 0x00); /* DAC电源管理,不打开任何通道 */ + ret_val |= es8388_write_reg(0x08, 0x00); /* MCLK不分频 */ + ret_val |= es8388_write_reg(0x2B, 0x80); /* DAC控制 DACLRC与ADCLRC相同 */ + + ret_val |= es8388_write_reg(0x09, 0x88); /* ADC L/R PGA增益配置为+24dB */ + ret_val |= es8388_write_reg(0x0C, 0x4C); /* ADC数据选择为left data = left ADC, right data = left ADC 音频数据为16bit */ + ret_val |= es8388_write_reg(0x0D, 0x02); /* ADC配置 MCLK/采样率=256 */ + ret_val |= es8388_write_reg(0x10, 0x00); /* ADC数字音量控制将信号衰减 L 设置为最小!!! */ + ret_val |= es8388_write_reg(0x11, 0x00); /* ADC数字音量控制将信号衰减 R 设置为最小!!! */ + + ret_val |= es8388_write_reg(0x17, 0x18); /* DAC音频数据为16bit */ + ret_val |= es8388_write_reg(0x18, 0x02); /* DAC配置 MCLK/采样率=256 */ + ret_val |= es8388_write_reg(0x1A, 0x00); /* DAC数字音量控制将信号衰减 L 设置为最小!!! */ + ret_val |= es8388_write_reg(0x1B, 0x00); /* DAC数字音量控制将信号衰减 R 设置为最小!!! */ + ret_val |= es8388_write_reg(0x27, 0xB8); /* L混频器 */ + ret_val |= es8388_write_reg(0x2A, 0xB8); /* R混频器 */ + vTaskDelay(pdMS_TO_TICKS(100)); + + if (ret_val != ESP_OK) + { + ESP_LOGI(es8388_tag, "ES8388 fail"); + return 1; + } + else + { + ESP_LOGI(es8388_tag, "ES8388 success"); + vTaskDelay(pdMS_TO_TICKS(100)); + return 0; + } + + es8388_adda_cfg(0, 0); /* 开启DAC关闭ADC */ + es8388_input_cfg(0); /* 关闭录音输入 */ + es8388_output_cfg(0, 0); /* DAC选择通道输出 */ + es8388_hpvol_set(0); /* 设置耳机音量 */ + es8388_spkvol_set(0); /* 设置喇叭音量 */ + + return 0; +} + +/** + * @brief ES8388反初始化 + * @param 无 + * @retval 0,初始化正常 + * 其他,错误代码 + */ +esp_err_t es8388_deinit(void) +{ + return es8388_write_reg(0x02, 0xFF); /* 复位和暂停ES8388 */ +} + +/** + * @brief 设置ES8388工作模式 + * @param fmt : 工作模式 + * @arg 0, 飞利浦标准I2S; + * @arg 1, MSB(左对齐); + * @arg 2, LSB(右对齐); + * @arg 3, PCM/DSP + * @param len : 数据长度 + * @arg 0, 24bit + * @arg 1, 20bit + * @arg 2, 18bit + * @arg 3, 16bit + * @arg 4, 32bit + * @retval 无 + */ +void es8388_i2s_cfg(uint8_t fmt, uint8_t len) +{ + fmt &= 0x03; + len &= 0x07; /* 限定范围 */ + es8388_write_reg(23, (fmt << 1) | (len << 3)); /* R23,ES8388工作模式设置 */ +} + +/** + * @brief 设置耳机音量 + * @param volume : 音量大小(0 ~ 33) + * @retval 无 + */ +void es8388_hpvol_set(uint8_t volume) +{ + if (volume > 33) + { + volume = 33; + } + + es8388_write_reg(0x2E, volume); + es8388_write_reg(0x2F, volume); +} + +/** + * @brief 设置喇叭音量 + * @param volume : 音量大小(0 ~ 33) + * @retval 无 + */ +void es8388_spkvol_set(uint8_t volume) +{ + if (volume > 33) + { + volume = 33; + } + + es8388_write_reg(0x30, volume); + es8388_write_reg(0x31, volume); +} + +/** + * @brief 设置3D环绕声 + * @param depth : 0 ~ 7(3D强度,0关闭,7最强) + * @retval 无 + */ +void es8388_3d_set(uint8_t depth) +{ + depth &= 0x7; /* 限定范围 */ + es8388_write_reg(0x1D, depth << 2); /* R7,3D环绕设置 */ +} + +/** + * @brief ES8388 DAC/ADC配置 + * @param dacen : dac使能(1) / 关闭(0) + * @param adcen : adc使能(1) / 关闭(0) + * @retval 无 + */ +void es8388_adda_cfg(uint8_t dacen, uint8_t adcen) +{ + uint8_t tempreg = 0; + + tempreg |= !dacen << 0; + tempreg |= !adcen << 1; + tempreg |= !dacen << 2; + tempreg |= !adcen << 3; + es8388_write_reg(0x02, tempreg); +} + +/** + * @brief ES8388 DAC输出通道配置 + * @param o1en : 通道1使能(1)/禁止(0) + * @param o2en : 通道2使能(1)/禁止(0) + * @retval 无 + */ +void es8388_output_cfg(uint8_t o1en, uint8_t o2en) +{ + uint8_t tempreg = 0; + tempreg |= o1en * (3 << 4); + tempreg |= o2en * (3 << 2); + es8388_write_reg(0x04, tempreg); +} + +/** + * @brief ES8388 MIC增益设置(MIC PGA增益) + * @param gain : 0~8, 对应0~24dB 3dB/Step + * @retval 无 + */ +void es8388_mic_gain(uint8_t gain) +{ + gain &= 0x0F; + gain |= gain << 4; + es8388_write_reg(0x09, gain); /* R9,左右通道PGA增益设置 */ +} + +/** + * @brief ES8388 ALC设置 + * @param sel + * @arg 0,关闭ALC + * @arg 1,右通道ALC + * @arg 2,左通道ALC + * @arg 3,立体声ALC + * @param maxgain : 0~7,对应-6.5~+35.5dB + * @param minigain: 0~7,对应-12~+30dB 6dB/STEP + * @retval 无 + */ +void es8388_alc_ctrl(uint8_t sel, uint8_t maxgain, uint8_t mingain) +{ + uint8_t tempreg = 0; + tempreg = sel << 6; + tempreg |= (maxgain & 0x07) << 3; + tempreg |= mingain & 0x07; + es8388_write_reg(0x12, tempreg); /* R18,ALC设置 */ +} + +/** + * @brief ES8388 ADC输出通道配置 + * @param in : 输入通道 + * @arg 0, 通道1输入 + * @arg 1, 通道2输入 + * @retval 无 + */ +void es8388_input_cfg(uint8_t in) +{ + es8388_write_reg(0x0A, (5 * in) << 4); /* ADC1 输入通道选择L/R INPUT1 */ +} diff --git a/components/BSP/ES8388/es8388.h b/components/BSP/ES8388/es8388.h new file mode 100644 index 0000000..1918265 --- /dev/null +++ b/components/BSP/ES8388/es8388.h @@ -0,0 +1,49 @@ +/** + **************************************************************************************************** + * @file es8388.h + * @author 正点原子团队(ALIENTEK) + * @version V1.0 + * @date 2025-01-01 + * @brief ES8388驱动代码 + * @license Copyright (c) 2020-2032, 广州市星翼电子科技有限公司 + **************************************************************************************************** + * @attention + * + * 实验平台:正点原子 ESP32-S3 开发板 + * 在线视频:www.yuanzige.com + * 技术论坛:www.openedv.com + * 公司网址:www.alientek.com + * 购买地址:openedv.taobao.com + * + **************************************************************************************************** + */ + +#ifndef __ES8388_H_ +#define __ES8388_H_ + +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "esp_log.h" +#include "driver/gpio.h" +#include "myiic.h" +#include "math.h" +#include "string.h" + +#define ES8388_ADDR 0x10 /* ES8388的器件地址,固定为0x10 */ + +/* 声明函数 */ +uint8_t es8388_init(void); /* ES8388初始化 */ +esp_err_t es8388_deinit(void); /* 复位或者暂停ES8388初始化 */ +esp_err_t es8388_write_reg(uint8_t reg, uint8_t val); /* ES8388写寄存器 */ +esp_err_t es8388_read_reg(uint8_t reg_add, uint8_t *p_data); /* ES8388读寄存器 */ +void es8388_i2s_cfg(uint8_t fmt, uint8_t len); /* 设置ES8388工作模式 */ +void es8388_hpvol_set(uint8_t volume); /* 设置耳机音量 */ +void es8388_spkvol_set(uint8_t volume); /* 设置喇叭音量 */ +void es8388_3d_set(uint8_t depth); /* 设置3D环绕声 */ +void es8388_adda_cfg(uint8_t dacen, uint8_t adcen); /* ES8388 DAC/ADC配置 */ +void es8388_output_cfg(uint8_t o1en, uint8_t o2en); /* ES8388 DAC输出通道配置 */ +void es8388_mic_gain(uint8_t gain); /* ES8388 MIC增益设置(MIC PGA增益) */ +void es8388_alc_ctrl(uint8_t sel, uint8_t maxgain, uint8_t mingain); /* ES8388 ALC设置 */ +void es8388_input_cfg(uint8_t in); /* ES8388 ADC输出通道配置 */ + +#endif diff --git a/components/BSP/LED/led.c b/components/BSP/LED/led.c new file mode 100644 index 0000000..35588a9 --- /dev/null +++ b/components/BSP/LED/led.c @@ -0,0 +1,41 @@ +/** + **************************************************************************************************** + * @file led.c + * @author 正点原子团队(ALIENTEK) + * @version V1.0 + * @date 2025-01-01 + * @brief LED驱动代码 + * @license Copyright (c) 2020-2032, 广州市星翼电子科技有限公司 + **************************************************************************************************** + * @attention + * + * 实验平台:正点原子 ESP32-S3 开发板 + * 在线视频:www.yuanzige.com + * 技术论坛:www.openedv.com + * 公司网址:www.alientek.com + * 购买地址:openedv.taobao.com + * + **************************************************************************************************** + */ + +#include "led.h" + + +/** + * @brief 初始化LED + * @param 无 + * @retval 无 + */ +void led_init(void) +{ + gpio_config_t gpio_init_struct = {0}; + + gpio_init_struct.intr_type = GPIO_INTR_DISABLE; /* 失能引脚中断 */ + gpio_init_struct.mode = GPIO_MODE_INPUT_OUTPUT; /* 输入输出模式 */ + gpio_init_struct.pull_up_en = GPIO_PULLUP_DISABLE; /* 失能上拉 */ + gpio_init_struct.pull_down_en = GPIO_PULLDOWN_DISABLE; /* 失能下拉 */ + gpio_init_struct.pin_bit_mask = 1ull << LED0_GPIO_PIN; /* 设置的引脚的位掩码 */ + ESP_ERROR_CHECK(gpio_config(&gpio_init_struct)); /* 配置GPIO */ + + LED0(1); /* 关闭LED0 */ +} \ No newline at end of file diff --git a/components/BSP/LED/led.h b/components/BSP/LED/led.h new file mode 100644 index 0000000..ec862c2 --- /dev/null +++ b/components/BSP/LED/led.h @@ -0,0 +1,42 @@ +/** + **************************************************************************************************** + * @file led.h + * @author 正点原子团队(ALIENTEK) + * @version V1.0 + * @date 2025-01-01 + * @brief LED驱动代码 + * @license Copyright (c) 2020-2032, 广州市星翼电子科技有限公司 + **************************************************************************************************** + * @attention + * + * 实验平台:正点原子 ESP32-S3 开发板 + * 在线视频:www.yuanzige.com + * 技术论坛:www.openedv.com + * 公司网址:www.alientek.com + * 购买地址:openedv.taobao.com + * + **************************************************************************************************** + */ + +#ifndef __LED_H +#define __LED_H + +#include "driver/gpio.h" + + +/* 引脚定义 */ +#define LED0_GPIO_PIN GPIO_NUM_1 /* LED0连接的GPIO端口 */ + +/* LED0端口定义 */ +#define LED0(x) do { x ? \ + gpio_set_level(LED0_GPIO_PIN, 1): \ + gpio_set_level(LED0_GPIO_PIN, 0); \ + } while(0) /* LED0翻转 */ + +/* LED取反定义 */ +#define LED0_TOGGLE() do { gpio_set_level(LED0_GPIO_PIN, !gpio_get_level(LED0_GPIO_PIN)); } while(0) /* LED0翻转 */ + +/* 函数声明*/ +void led_init(void); /* 初始化LED */ + +#endif diff --git a/components/BSP/MYI2S/myi2s.c b/components/BSP/MYI2S/myi2s.c new file mode 100644 index 0000000..721730c --- /dev/null +++ b/components/BSP/MYI2S/myi2s.c @@ -0,0 +1,221 @@ +/** + **************************************************************************************************** + * @file i2s.c + * @author 正点原子团队(ALIENTEK) + * @version V1.0 + * @date 2025-01-01 + * @brief I2S驱动代码 + * @license Copyright (c) 2020-2032, 广州市星翼电子科技有限公司 + **************************************************************************************************** + * @attention + * + * 实验平台:正点原子 ESP32-S3 开发板 + * 在线视频:www.yuanzige.com + * 技术论坛:www.openedv.com + * 公司网址:www.alientek.com + * 购买地址:openedv.taobao.com + * + **************************************************************************************************** + */ + +#include "myi2s.h" +#include + + +i2s_chan_handle_t tx_handle = NULL; /* I2S发送通道句柄 */ +i2s_chan_handle_t rx_handle = NULL; /* I2S接收通道句柄 */ +i2s_std_config_t my_std_cfg; /* 标准模式配置结构体 */ +static bool s_trx_enabled = false; /* 通道是否已经使能(避免重复 enable/disable) */ + +/* + * @brief 初始化I2S + * @param 无 + * @retval ESP_OK:初始化成功;其他:失败 + */ +esp_err_t myi2s_init(void) +{ + i2s_chan_config_t chan_cfg = I2S_CHANNEL_DEFAULT_CONFIG(I2S_NUM, I2S_ROLE_MASTER); /* 默认的通道配置(I2S0,主机) */ + chan_cfg.auto_clear = true; /* 自动清除DMA缓冲区遗留的数据 */ + chan_cfg.dma_desc_num = 8; /* DMA 描述符数量 */ + chan_cfg.dma_frame_num = 480; /* 每个描述符的帧数(约 87ms 缓冲 @44.1kHz) */ + ESP_ERROR_CHECK(i2s_new_channel(&chan_cfg, &tx_handle, &rx_handle)); /* 分配新的I2S通道 */ + + i2s_std_config_t std_cfg = { /* 标准通信模式配置 */ + .clk_cfg = { /* 时钟配置 可用I2S_STD_CLK_DEFAULT_CONFIG(I2S_SAMPLE_RATE)宏函数辅助配置 */ + .sample_rate_hz = I2S_SAMPLE_RATE, /* I2S采样率 */ + .clk_src = I2S_CLK_SRC_DEFAULT, /* I2S时钟源 */ + .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位宽采样数据) */ + .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, /* 左对齐 */ + .big_endian = false, /* 小端模式 */ + .bit_order_lsb = false /* MSB */ + }, + + .gpio_cfg = { /* 引脚配置 */ + .mclk = I2S_MCK_IO, /* 主时钟线 */ + .bclk = I2S_BCK_IO, /* 位时钟线 */ + .ws = I2S_WS_IO, /* 字(声道)选择线 */ + .dout = I2S_DO_IO, /* 串行数据输出线 */ + .din = I2S_DI_IO, /* 串行数据输入线 */ + .invert_flags = { /* 引脚翻转(不反相) */ + .mclk_inv = false, + .bclk_inv = false, + .ws_inv = false, + }, + }, + }; + + my_std_cfg = std_cfg; + + ESP_ERROR_CHECK(i2s_channel_init_std_mode(tx_handle, &std_cfg)); /* 初始化TX通道 */ + ESP_ERROR_CHECK(i2s_channel_init_std_mode(rx_handle, &std_cfg)); /* 初始化RX通道 */ + ESP_ERROR_CHECK(i2s_channel_enable(tx_handle)); /* 启用TX通道 */ + ESP_ERROR_CHECK(i2s_channel_enable(rx_handle)); /* 启用RX通道 */ + s_trx_enabled = true; + + return ESP_OK; +} + +/** + * @brief I2S TRX启动 + * @param 无 + * @retval 无 + */ +void i2s_trx_start(void) +{ + if (s_trx_enabled) + { + return; + } + + ESP_ERROR_CHECK(i2s_channel_enable(tx_handle)); + ESP_ERROR_CHECK(i2s_channel_enable(rx_handle)); + s_trx_enabled = true; +} + +/** + * @brief I2S TRX停止 + * @param 无 + * @retval 无 + */ +void i2s_trx_stop(void) +{ + if (!s_trx_enabled) + { + return; + } + + ESP_ERROR_CHECK(i2s_channel_disable(tx_handle)); + ESP_ERROR_CHECK(i2s_channel_disable(rx_handle)); + s_trx_enabled = false; +} + +/** + * @brief I2S卸载 + * @param 无 + * @retval 无 + */ +void i2s_deinit(void) +{ + if (tx_handle != NULL) + { + i2s_channel_disable(tx_handle); + i2s_del_channel(tx_handle); + tx_handle = NULL; + } + + if (rx_handle != NULL) + { + i2s_channel_disable(rx_handle); + i2s_del_channel(rx_handle); + rx_handle = NULL; + } + + s_trx_enabled = false; +} + +/** + * @brief 设置采样率和位宽 + * @param sampleRate :采样率 + * @param bits_sample :位宽(16 / 24 / 32) + * @retval 无 + * @note 调用前会先关闭通道,调用后需要再调用 i2s_trx_start() 才能出声 + * @note ESP-IDF 限制:slot 位宽为 24bit 时 mclk_multiple 必须是 3 的倍数, + * 因此 24bit 用 384 倍(MCLK=384fs,BCLK=48fs,分频比 8 整数), + * 16/32bit 保持 256 倍(分频比分别为 8 / 4)。 + */ +void i2s_set_samplerate_bits_sample(int samplerate, int bits_sample) +{ + i2s_data_bit_width_t data_width; + i2s_mclk_multiple_t mclk_multiple; + + switch (bits_sample) + { + case 24: + data_width = I2S_DATA_BIT_WIDTH_24BIT; + mclk_multiple = I2S_MCLK_MULTIPLE_384; /* 必须是 3 的倍数 */ + break; + + case 32: + data_width = I2S_DATA_BIT_WIDTH_32BIT; + mclk_multiple = I2S_MCLK_MULTIPLE_256; + break; + + case 16: + default: + data_width = I2S_DATA_BIT_WIDTH_16BIT; + mclk_multiple = I2S_MCLK_MULTIPLE_256; + break; + } + + i2s_trx_stop(); + + /* + * 重配 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.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; /* 设置采样率 */ + ESP_ERROR_CHECK(i2s_channel_reconfig_std_clock(tx_handle, &my_std_cfg.clk_cfg)); +} + +/** + * @brief I2S传输数据 + * @param buffer: 数据存储区的首地址 + * @param frame_size: 数据大小 + * @retval 发送的数据长度 + */ +size_t i2s_tx_write(uint8_t *buffer, uint32_t frame_size) +{ + size_t bytes_written; + ESP_ERROR_CHECK(i2s_channel_write(tx_handle, buffer, frame_size, &bytes_written, 1000)); + return bytes_written; +} + +/** + * @brief I2S读取数据 + * @param buffer: 读取数据存储区的首地址 + * @param frame_size: 读取数据大小 + * @retval 接收的数据长度 + */ +size_t i2s_rx_read(uint8_t *buffer, uint32_t frame_size) +{ + size_t bytes_written; + ESP_ERROR_CHECK(i2s_channel_read(rx_handle, buffer, frame_size, &bytes_written, 1000)); + return bytes_written; +} diff --git a/components/BSP/MYI2S/myi2s.h b/components/BSP/MYI2S/myi2s.h new file mode 100644 index 0000000..c17600a --- /dev/null +++ b/components/BSP/MYI2S/myi2s.h @@ -0,0 +1,52 @@ +/** + **************************************************************************************************** + * @file i2s.h + * @author 正点原子团队(ALIENTEK) + * @version V1.0 + * @date 2025-01-01 + * @brief I2S驱动代码 + * @license Copyright (c) 2020-2032, 广州市星翼电子科技有限公司 + **************************************************************************************************** + * @attention + * + * 实验平台:正点原子 ESP32-S3 开发板 + * 在线视频:www.yuanzige.com + * 技术论坛:www.openedv.com + * 公司网址:www.alientek.com + * 购买地址:openedv.taobao.com + * + **************************************************************************************************** + */ + +#ifndef _MYI2S_H_ +#define _MYI2S_H_ + +#include +#include +#include "driver/gpio.h" +#include "driver/i2s_std.h" + + +#define I2S_NUM (I2S_NUM_0) /* I2S port */ +#define I2S_BCK_IO (GPIO_NUM_46) /* ES8388_SCLK */ +#define I2S_WS_IO (GPIO_NUM_9) /* ES8388_LRCK */ +#define I2S_DO_IO (GPIO_NUM_10) /* ES8388_SDIN */ +#define I2S_DI_IO (GPIO_NUM_14) /* ES8388_SDOUT */ +#define I2S_MCK_IO (GPIO_NUM_3) /* ES8388_MCLK */ +#define I2S_RECV_BUF_SIZE (2400) /* 接收大小 */ +#define I2S_SAMPLE_RATE (44100) /* 采样率 */ +#define I2S_MCLK_MULTIPLE (256) /* 如果不使用24位数据宽度,256应该足够了 */ + +extern i2s_chan_handle_t tx_handle; +extern i2s_chan_handle_t rx_handle; + +/* 函数声明 */ +esp_err_t myi2s_init(void); /* I2S初始化 */ +void i2s_trx_start(void); /* 启动I2S */ +void i2s_trx_stop(void); /* 停止I2S */ +void i2s_deinit(void); /* 卸载I2S */ +size_t i2s_tx_write(uint8_t *buffer, uint32_t frame_size); /* I2S传输数据 */ +size_t i2s_rx_read(uint8_t *buffer, uint32_t frame_size); /* I2S接收数据 */ +void i2s_set_samplerate_bits_sample(int samplerate,int bits_sample);/* 设置采样率和位宽 */ + +#endif diff --git a/components/BSP/MYIIC/myiic.c b/components/BSP/MYIIC/myiic.c new file mode 100644 index 0000000..6657eb8 --- /dev/null +++ b/components/BSP/MYIIC/myiic.c @@ -0,0 +1,45 @@ +/** + **************************************************************************************************** + * @file iic.c + * @author 正点原子团队(ALIENTEK) + * @version V1.0 + * @date 2025-01-01 + * @brief IIC驱动代码 + * @license Copyright (c) 2020-2032, 广州市星翼电子科技有限公司 + **************************************************************************************************** + * @attention + * + * 实验平台:正点原子 ESP32-S3 开发板 + * 在线视频:www.yuanzige.com + * 技术论坛:www.openedv.com + * 公司网址:www.alientek.com + * 购买地址:openedv.taobao.com + * + **************************************************************************************************** + */ + +#include "myiic.h" + + +i2c_master_bus_handle_t bus_handle; /* 总线句柄 */ + +/** + * @brief 初始化MYIIC + * @param 无 + * @retval ESP_OK:初始化成功 + */ +esp_err_t myiic_init(void) +{ + i2c_master_bus_config_t i2c_bus_config = { + .clk_source = I2C_CLK_SRC_DEFAULT, /* 时钟源 */ + .i2c_port = IIC_NUM_PORT, /* I2C端口 */ + .scl_io_num = IIC_SCL_GPIO_PIN, /* SCL管脚 */ + .sda_io_num = IIC_SDA_GPIO_PIN, /* SDA管脚 */ + .glitch_ignore_cnt = 7, /* 故障周期 */ + .flags.enable_internal_pullup = true, /* 内部上拉 */ + }; + /* 新建I2C总线 */ + ESP_ERROR_CHECK(i2c_new_master_bus(&i2c_bus_config, &bus_handle)); + + return ESP_OK; +} diff --git a/components/BSP/MYIIC/myiic.h b/components/BSP/MYIIC/myiic.h new file mode 100644 index 0000000..6ec5809 --- /dev/null +++ b/components/BSP/MYIIC/myiic.h @@ -0,0 +1,40 @@ +/** + **************************************************************************************************** + * @file iic.h + * @author 正点原子团队(ALIENTEK) + * @version V1.0 + * @date 2025-01-01 + * @brief IIC驱动代码 + * @license Copyright (c) 2020-2032, 广州市星翼电子科技有限公司 + **************************************************************************************************** + * @attention + * + * 实验平台:正点原子 ESP32-S3 开发板 + * 在线视频:www.yuanzige.com + * 技术论坛:www.openedv.com + * 公司网址:www.alientek.com + * 购买地址:openedv.taobao.com + * + **************************************************************************************************** + */ + +#ifndef __MYIIC_H +#define __MYIIC_H + +#include "driver/gpio.h" +#include "driver/i2c_master.h" +#include "esp_err.h" +#include "esp_log.h" + +/* 引脚与相关参数定义 */ +#define IIC_NUM_PORT I2C_NUM_0 /* IIC0 */ +#define IIC_SPEED_CLK 400000 /* 速率400K */ +#define IIC_SDA_GPIO_PIN GPIO_NUM_41 /* IIC0_SDA引脚 */ +#define IIC_SCL_GPIO_PIN GPIO_NUM_42 /* IIC0_SCL引脚 */ + +extern i2c_master_bus_handle_t bus_handle; /* 总线句柄 */ + +/* 函数声明 */ +esp_err_t myiic_init(void); /* 初始化MYIIC */ + +#endif diff --git a/components/BSP/MYSPI/my_spi.c b/components/BSP/MYSPI/my_spi.c new file mode 100644 index 0000000..971d7df --- /dev/null +++ b/components/BSP/MYSPI/my_spi.c @@ -0,0 +1,57 @@ +/** + **************************************************************************************************** + * @file my_spi.c + * @author 正点原子团队(ALIENTEK) + * @version V1.0 + * @date 2025-01-01 + * @brief MYSPI驱动代码 + * @license Copyright (c) 2020-2032, 广州市星翼电子科技有限公司 + **************************************************************************************************** + * @attention + * + * 实验平台:正点原子 ESP32-S3 开发板 + * 在线视频:www.yuanzige.com + * 技术论坛:www.openedv.com + * 公司网址:www.alientek.com + * 购买地址:openedv.taobao.com + * + **************************************************************************************************** + */ + +#include "my_spi.h" + + +/* SD卡设备句柄 */ +spi_device_handle_t MY_SD_Handle = NULL; + +/** + * @brief spi初始化 + * @param 无 + * @retval esp_err_t + */ +esp_err_t my_spi_init(void) +{ + spi_bus_config_t buscfg = { + .sclk_io_num = SPI_SCLK_PIN, /* 时钟引脚 */ + .mosi_io_num = SPI_MOSI_PIN, /* 主机输出从机输入引脚 */ + .miso_io_num = SPI_MISO_PIN, /* 主机输入从机输出引脚 */ + .quadwp_io_num = -1, /* 用于Quad模式的WP引脚,未使用时设置为-1 */ + .quadhd_io_num = -1, /* 用于Quad模式的HD引脚,未使用时设置为-1 */ + .max_transfer_sz = 320 * 240 * sizeof(uint16_t), /* 最大传输大小(整屏(RGB565格式)) */ + }; + /* 初始化SPI总线 */ + ESP_ERROR_CHECK(spi_bus_initialize(MY_SPI_HOST, &buscfg, SPI_DMA_CH_AUTO)); + + /* SPI驱动接口配置,SPISD卡时钟是20-25MHz */ + spi_device_interface_config_t devcfg = { + .clock_speed_hz = 20 * 1000 * 1000, /* SPI时钟 */ + .mode = 0, /* SPI模式0 */ + .spics_io_num = SD_CS_PIN, /* 片选引脚 */ + .queue_size = 7, /* 事务队列尺寸 7个 */ + }; + + /* 添加SPI总线设备 */ + ESP_ERROR_CHECK(spi_bus_add_device(MY_SPI_HOST, &devcfg, &MY_SD_Handle)); + + return ESP_OK; +} \ No newline at end of file diff --git a/components/BSP/MYSPI/my_spi.h b/components/BSP/MYSPI/my_spi.h new file mode 100644 index 0000000..ca912c5 --- /dev/null +++ b/components/BSP/MYSPI/my_spi.h @@ -0,0 +1,43 @@ +/** + **************************************************************************************************** + * @file my_spi.h + * @author 正点原子团队(ALIENTEK) + * @version V1.0 + * @date 2025-01-01 + * @brief MYSPI驱动代码 + * @license Copyright (c) 2020-2032, 广州市星翼电子科技有限公司 + **************************************************************************************************** + * @attention + * + * 实验平台:正点原子 ESP32-S3 开发板 + * 在线视频:www.yuanzige.com + * 技术论坛:www.openedv.com + * 公司网址:www.alientek.com + * 购买地址:openedv.taobao.com + * + **************************************************************************************************** + */ + +#ifndef __MY_SPI_H +#define __MY_SPI_H + +#include +#include "driver/sdspi_host.h" +#include "driver/spi_common.h" +#include "esp_err.h" + +/* SPI驱动管脚 */ +#define SPI_SCLK_PIN GPIO_NUM_12 +#define SPI_MOSI_PIN GPIO_NUM_11 +#define SPI_MISO_PIN GPIO_NUM_13 +/* 总线设备引脚定义 */ +#define SD_CS_PIN GPIO_NUM_2 +/* SPI端口 */ +#define MY_SPI_HOST SPI2_HOST +/* 设备句柄 */ +extern spi_device_handle_t MY_SD_Handle; /* SD卡句柄 */ + +/* 函数声明 */ +esp_err_t my_spi_init(void); /* SPI初始化 */ + +#endif diff --git a/components/BSP/SPILCD/spilcd.c b/components/BSP/SPILCD/spilcd.c new file mode 100644 index 0000000..ab9741a --- /dev/null +++ b/components/BSP/SPILCD/spilcd.c @@ -0,0 +1,215 @@ +/** + **************************************************************************************************** + * @file spilcd.c + * @author HiFi Player Project + * @version V1.0 + * @date 2026-01-01 + * @brief SPI LCD(ST7789,320x240)驱动 + * @note 初始化流程与引脚定义参考正点原子 DNESP32S3 标准例程(10_spilcd / 30_music), + * 并增加了“刷新完成信号量”,方便 LVGL 在 flush 时等待 DMA 传输结束。 + **************************************************************************************************** + */ + +#include "spilcd.h" + +#include +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "freertos/semphr.h" +#include "esp_lcd_panel_st7789.h" +#include "esp_heap_caps.h" +#include "esp_log.h" + +#define TAG "spilcd" + +/* 1: 2.4 寸 320x240 SPILCD 0: 1.3 寸 240x240 SPILCD */ +#define SPI_LCD_TYPE 1 + +esp_lcd_panel_handle_t panel_handle = NULL; + +static esp_lcd_panel_io_handle_t s_io_handle = NULL; /* LCD SPI IO 句柄 */ +static SemaphoreHandle_t s_flush_sem = NULL; /* SPI 传输完成信号量 */ +static uint16_t s_width = SPI_LCD_H_RES; +static uint16_t s_height = SPI_LCD_V_RES; + +/** + * @brief LCD 颜色传输完成回调(SPI ISR 上下文) + */ +static bool lcd_color_trans_done_cb(esp_lcd_panel_io_handle_t panel_io, + esp_lcd_panel_io_event_data_t *edata, + void *user_ctx) +{ + BaseType_t high_task_woken = pdFALSE; + + if (s_flush_sem != NULL) + { + xSemaphoreGiveFromISR(s_flush_sem, &high_task_woken); + } + + (void)panel_io; + (void)edata; + (void)user_ctx; + + return (high_task_woken == pdTRUE); +} + +/** + * @brief 初始化 SPI LCD + */ +esp_err_t spilcd_init(void) +{ + if (s_flush_sem == NULL) + { + s_flush_sem = xSemaphoreCreateBinary(); + if (s_flush_sem == NULL) + { + ESP_LOGE(TAG, "create flush semaphore failed"); + return ESP_ERR_NO_MEM; + } + } + + /* 打开 LCD 电源 */ + LCD_PWR(1); + vTaskDelay(pdMS_TO_TICKS(50)); + + /* 硬件复位 */ + LCD_RST(0); + vTaskDelay(pdMS_TO_TICKS(20)); + LCD_RST(1); + vTaskDelay(pdMS_TO_TICKS(120)); + + /* ---------------- 挂载到 SPI 总线 ---------------- */ + esp_lcd_panel_io_spi_config_t io_config = { + .dc_gpio_num = LCD_DC_PIN, + .cs_gpio_num = LCD_CS_PIN, + .pclk_hz = 60 * 1000 * 1000, /* 60MHz */ + .lcd_cmd_bits = 8, + .lcd_param_bits = 8, + .spi_mode = 0, + .trans_queue_depth = 7, + }; + ESP_ERROR_CHECK(esp_lcd_new_panel_io_spi((esp_lcd_spi_bus_handle_t)LCD_HOST, &io_config, &s_io_handle)); + + /* ---------------- 创建 ST7789 面板 ---------------- */ + esp_lcd_panel_dev_config_t panel_config = { + .reset_gpio_num = GPIO_NUM_NC, /* 复位由 XL9555 控制 */ + .rgb_ele_order = COLOR_RGB_ELEMENT_ORDER_RGB, + .bits_per_pixel = 16, + .data_endian = LCD_RGB_DATA_ENDIAN_BIG, /* 屏幕要求高字节在前 */ + }; + ESP_ERROR_CHECK(esp_lcd_new_panel_st7789(s_io_handle, &panel_config, &panel_handle)); + + ESP_ERROR_CHECK(esp_lcd_panel_reset(panel_handle)); + ESP_ERROR_CHECK(esp_lcd_panel_invert_color(panel_handle, true)); + ESP_ERROR_CHECK(esp_lcd_panel_init(panel_handle)); + ESP_ERROR_CHECK(esp_lcd_panel_disp_on_off(panel_handle, true)); + + /* 注册颜色传输完成回调 */ + const esp_lcd_panel_io_callbacks_t cbs = { + .on_color_trans_done = lcd_color_trans_done_cb, + }; + ESP_ERROR_CHECK(esp_lcd_panel_io_register_event_callbacks(s_io_handle, &cbs, NULL)); + + spilcd_display_dir(1); /* 横屏 */ + spilcd_clear(0x0000); /* 上电先刷黑,避免白屏闪烁 */ + + return ESP_OK; +} + +/** + * @brief 设置屏幕方向 + */ +void spilcd_display_dir(uint8_t dir) +{ + if (dir == 0) /* 竖屏 */ + { + s_width = SPI_LCD_V_RES; + s_height = SPI_LCD_H_RES; + esp_lcd_panel_swap_xy(panel_handle, false); + esp_lcd_panel_mirror(panel_handle, false, false); + + if (SPI_LCD_TYPE == 0) + { + esp_lcd_panel_set_gap(panel_handle, 0, 80); + } + } + else /* 横屏 */ + { + s_width = SPI_LCD_H_RES; + s_height = SPI_LCD_V_RES; + esp_lcd_panel_swap_xy(panel_handle, true); + esp_lcd_panel_mirror(panel_handle, true, false); + + if (SPI_LCD_TYPE == 0) + { + esp_lcd_panel_set_gap(panel_handle, 80, 0); + } + } +} + +/** + * @brief 将一块数据刷到屏幕,并等待 DMA 传输完成 + */ +void spilcd_flush(uint16_t x1, uint16_t y1, uint16_t x2, uint16_t y2, const void *data) +{ + if ((panel_handle == NULL) || (data == NULL) || (s_flush_sem == NULL)) + { + return; + } + + if (x2 > s_width) x2 = s_width; + if (y2 > s_height) y2 = s_height; + if ((x1 >= x2) || (y1 >= y2)) + { + return; + } + + /* 丢弃上一次传输残留的信号 */ + while (xSemaphoreTake(s_flush_sem, 0) == pdTRUE) + { + /* nothing */ + } + + esp_err_t ret = esp_lcd_panel_draw_bitmap(panel_handle, x1, y1, x2, y2, data); + if (ret != ESP_OK) + { + ESP_LOGE(TAG, "draw bitmap failed: %s", esp_err_to_name(ret)); + return; + } + + if (xSemaphoreTake(s_flush_sem, pdMS_TO_TICKS(200)) != pdTRUE) + { + ESP_LOGW(TAG, "wait lcd flush timeout"); + } +} + +/** + * @brief 清屏 + */ +void spilcd_clear(uint16_t color) +{ + const uint16_t lines = 24; + size_t px_count = (size_t)s_width * lines; + + uint16_t *buffer = heap_caps_malloc(px_count * sizeof(uint16_t), MALLOC_CAP_DMA | MALLOC_CAP_INTERNAL); + if (buffer == NULL) + { + ESP_LOGE(TAG, "no memory for clear buffer"); + return; + } + + /* 转换成大端字节序 */ + uint16_t color_be = (uint16_t)((color << 8) | (color >> 8)); + for (size_t i = 0; i < px_count; i++) + { + buffer[i] = color_be; + } + + for (uint16_t y = 0; y < s_height; y += lines) + { + uint16_t y_end = (uint16_t)((y + lines) > s_height ? s_height : (y + lines)); + spilcd_flush(0, y, s_width, y_end, buffer); + } + + heap_caps_free(buffer); +} diff --git a/components/BSP/SPILCD/spilcd.h b/components/BSP/SPILCD/spilcd.h new file mode 100644 index 0000000..920af1a --- /dev/null +++ b/components/BSP/SPILCD/spilcd.h @@ -0,0 +1,67 @@ +/** + **************************************************************************************************** + * @file spilcd.h + * @author HiFi Player Project + * @version V1.0 + * @date 2026-01-01 + * @brief SPI LCD(ST7789,320x240)驱动 + * @note 引脚与初始化流程参考正点原子 DNESP32S3 标准例程(10_spilcd / 30_music) + **************************************************************************************************** + */ + +#ifndef __SPILCD_H +#define __SPILCD_H + +#include +#include "esp_err.h" +#include "driver/gpio.h" +#include "esp_lcd_panel_io.h" +#include "esp_lcd_panel_ops.h" +#include "xl9555.h" + +/* ---------------- 管脚声明 ---------------- */ +#define LCD_DC_PIN GPIO_NUM_40 /* LCD 数据/命令选择脚 */ +#define LCD_CS_PIN GPIO_NUM_21 /* LCD 片选脚 */ +#define LCD_HOST SPI2_HOST /* LCD 与 SD 卡共用 SPI2 */ + +/* 背光 / 复位 / 电源由 XL9555 扩展 IO 控制 */ +#define LCD_PWR(x) do { x ? xl9555_pin_write(SLCD_PWR_IO, 1) : xl9555_pin_write(SLCD_PWR_IO, 0); } while (0) +#define LCD_RST(x) do { x ? xl9555_pin_write(SLCD_RST_IO, 1) : xl9555_pin_write(SLCD_RST_IO, 0); } while (0) +#define LCD_BL(x) do { x ? xl9555_pin_write(LCD_BL_IO, 1) : xl9555_pin_write(LCD_BL_IO, 0); } while (0) + +/* ---------------- 屏幕分辨率(横屏) ---------------- */ +#define SPI_LCD_H_RES 320 +#define SPI_LCD_V_RES 240 + +/* 面板句柄,LVGL 移植层需要用到 */ +extern esp_lcd_panel_handle_t panel_handle; + +/** + * @brief 初始化 SPI LCD(SPI 总线必须已由 my_spi_init() 初始化) + * @retval ESP_OK 成功 + */ +esp_err_t spilcd_init(void); + +/** + * @brief 设置屏幕方向 + * @param dir 0: 竖屏 1: 横屏 + */ +void spilcd_display_dir(uint8_t dir); + +/** + * @brief 用单色清屏 + */ +void spilcd_clear(uint16_t color); + +/** + * @brief 将一块 RGB565 图像数据刷到屏幕(阻塞,直到 SPI 传输完成) + * @param x1,y1 左上角坐标(包含) + * @param x2,y2 右下角坐标(不包含,即 x2-x1 为宽度) + * @param data RGB565 像素数据,**按大端字节序存放**(高字节在前)。 + * 如果是 LVGL 的原生小端数据,需要在调用前自行做字节交换 + * (参考 lvgl_port.c 中 disp_flush_cb() 的实现)。 + * @note 该函数供 LVGL 的 flush_cb 调用,也可以在其它地方直接使用 + */ +void spilcd_flush(uint16_t x1, uint16_t y1, uint16_t x2, uint16_t y2, const void *data); + +#endif diff --git a/components/BSP/SPI_SD/spi_sd.c b/components/BSP/SPI_SD/spi_sd.c new file mode 100644 index 0000000..3548e13 --- /dev/null +++ b/components/BSP/SPI_SD/spi_sd.c @@ -0,0 +1,104 @@ +/** + **************************************************************************************************** + * @file spi_sdcard.c + * @author 正点原子团队(ALIENTEK) + * @version V1.0 + * @date 2025-01-01 + * @brief SD卡 驱动代码 + * @license Copyright (c) 2020-2032, 广州市星翼电子科技有限公司 + **************************************************************************************************** + * @attention + * + * 实验平台:正点原子 ESP32-S3 开发板 + * 在线视频:www.yuanzige.com + * 技术论坛:www.openedv.com + * 公司网址:www.alientek.com + * 购买地址:openedv.taobao.com + * + **************************************************************************************************** + */ + +#include "spi_sd.h" + +sdmmc_card_t *card; /* SD / MMC卡结构 */ +const char mount_point[] = MOUNT_POINT; /* 挂载点/根目录 */ +esp_err_t ret = ESP_OK; +esp_err_t mount_ret = ESP_FAIL; + +/** + * @brief SD卡初始化 + * @param 无 + * @retval esp_err_t + */ +esp_err_t sd_spi_init(void) +{ + ret = ESP_OK; + + if (MY_SD_Handle != NULL) /* 再一次挂载或者初始化SD卡 */ + { + if (mount_ret == ESP_OK) + { + esp_vfs_fat_sdcard_unmount(mount_point, card); /* 取消挂载 */ + mount_ret = ESP_FAIL; + } + } + else if (MY_SD_Handle == NULL) /* 未初始化SPI驱动 */ + { + my_spi_init(); /* 初始化SPI驱动 */ + } + + /* 文件系统挂载配置 */ + esp_vfs_fat_sdmmc_mount_config_t mount_config = { + .format_if_mount_failed = false, /* 如果挂载失败:true会重新分区和格式化/false不会重新分区和格式化 */ + .max_files = 5, /* 打开文件最大数量 */ + .allocation_unit_size = 4 * 1024 * sizeof(uint8_t) /* 硬盘分区簇的大小 */ + }; + + /* SD卡参数配置 */ + sdmmc_host_t host = SDSPI_HOST_DEFAULT(); + + /* SD卡引脚配置 */ + sdspi_device_config_t slot_config = {0}; + slot_config.host_id = host.slot; + slot_config.gpio_cs = SD_NUM_CS; + slot_config.gpio_cd = GPIO_NUM_NC; + slot_config.gpio_wp = GPIO_NUM_NC; + slot_config.gpio_int = GPIO_NUM_NC; + + mount_ret = esp_vfs_fat_sdspi_mount(mount_point, &host, &slot_config, &mount_config, &card); /* 挂载文件系统 */ + ret |= mount_ret; + vTaskDelay(pdMS_TO_TICKS(10)); + return ret; +} + +/** + * @brief 获取SD卡相关信息 + * @param out_total_bytes:总大小 + * @param out_free_bytes:剩余大小 + * @retval 无 + */ +void sd_get_fatfs_usage(size_t *out_total_bytes, size_t *out_free_bytes) +{ + FATFS *fs; + size_t free_clusters; + int res = f_getfree("0:", (DWORD *)&free_clusters, &fs); + assert(res == FR_OK); + size_t total_sectors = (fs->n_fatent - 2) * fs->csize; + size_t free_sectors = free_clusters * fs->csize; + + size_t sd_total = total_sectors / 1024; + size_t sd_total_KB = sd_total * fs->ssize; + size_t sd_free = free_sectors / 1024; + size_t sd_free_KB = sd_free*fs->ssize; + + /* 假设总大小小于4GiB,对于SPI Flash应该为true */ + if (out_total_bytes != NULL) + { + *out_total_bytes = sd_total_KB; + } + + if (out_free_bytes != NULL) + { + *out_free_bytes = sd_free_KB; + } +} diff --git a/components/BSP/SPI_SD/spi_sd.h b/components/BSP/SPI_SD/spi_sd.h new file mode 100644 index 0000000..9aa1f9f --- /dev/null +++ b/components/BSP/SPI_SD/spi_sd.h @@ -0,0 +1,40 @@ +/** + **************************************************************************************************** + * @file spi_sd.h + * @author 正点原子团队(ALIENTEK) + * @version V1.0 + * @date 2025-01-01 + * @brief SD卡 驱动代码 + * @license Copyright (c) 2020-2032, 广州市星翼电子科技有限公司 + **************************************************************************************************** + * @attention + * + * 实验平台:正点原子 ESP32-S3 开发板 + * 在线视频:www.yuanzige.com + * 技术论坛:www.openedv.com + * 公司网址:www.alientek.com + * 购买地址:openedv.taobao.com + * + **************************************************************************************************** + */ + +#ifndef __SPI_SD_H +#define __SPI_SD_H + +#include +#include "esp_vfs_fat.h" +#include "driver/sdspi_host.h" +#include "driver/spi_common.h" +#include "sdmmc_cmd.h" +#include "driver/sdmmc_host.h" +#include "my_spi.h" + + +/* 引脚定义 */ +#define SD_NUM_CS GPIO_NUM_2 +#define MOUNT_POINT "/0:" + +/* 函数声明 */ +esp_err_t sd_spi_init(void); /* SD卡初始化 */ +void sd_get_fatfs_usage(size_t *out_total_bytes, size_t *out_free_bytes); +#endif diff --git a/components/BSP/XL9555/xl9555.c b/components/BSP/XL9555/xl9555.c new file mode 100644 index 0000000..0c07327 --- /dev/null +++ b/components/BSP/XL9555/xl9555.c @@ -0,0 +1,290 @@ +/** + **************************************************************************************************** + * @file xl9555.c + * @author 正点原子团队(ALIENTEK) + * @version V1.0 + * @date 2025-01-01 + * @brief XL9555驱动代码 + * @license Copyright (c) 2020-2032, 广州市星翼电子科技有限公司 + **************************************************************************************************** + * @attention + * + * 实验平台:正点原子 ESP32-S3 开发板 + * 在线视频:www.yuanzige.com + * 技术论坛:www.openedv.com + * 公司网址:www.alientek.com + * 购买地址:openedv.taobao.com + * + **************************************************************************************************** + */ + +#include "xl9555.h" + + +const char *xl9555_tag = "xl9555"; +i2c_master_dev_handle_t xl9555_handle = NULL; + +/** + * @brief 读取XL9555的IO值 + * @param data:读取数据的存储区 + * @param len:读取数据的大小 + * @retval ESP_OK:读取成功; 其他:读取失败 + */ +esp_err_t xl9555_read_byte(uint8_t *data, size_t len) +{ + uint8_t reg_addr = XL9555_INPUT_PORT0_REG; + + return i2c_master_transmit_receive(xl9555_handle, ®_addr, 1, data, len, -1); +} + +/** + * @brief 向XL9555寄存器写入数据 + * @param reg:寄存器地址 + * @param data:要写入数据的存储区 + * @param len:要写入数据的大小 + * @retval ESP_OK:读取成功; 其他:读取失败 + */ +esp_err_t xl9555_write_byte(uint8_t reg, uint8_t *data, size_t len) +{ + esp_err_t ret; + + uint8_t *buf = malloc(1 + len); + if (buf == NULL) + { + ESP_LOGE(xl9555_tag, "%s memory failed", __func__); + return ESP_ERR_NO_MEM; /* 分配内存失败 */ + } + + buf[0] = reg; /* 0号元素为寄存器数值 */ + memcpy(buf + 1, data, len); /* 拷贝数据至存储区中 */ + + ret = i2c_master_transmit(xl9555_handle, buf, len + 1, -1); + + free(buf); /* 发送完成释放内存 */ + + return ret; +} + +/** + * @brief 控制某个IO的电平 + * @param pin : 控制的IO + * @param val : 电平 + * @retval 返回所有IO状态 + */ +uint16_t xl9555_pin_write(uint16_t pin, int val) +{ + uint8_t w_data[2]; + uint16_t temp = 0x0000; + + xl9555_read_byte(w_data, 2); + + if (pin <= 0x0080) + { + if (val) + { + w_data[0] |= (uint8_t)(0xFF & pin); + } + else + { + w_data[0] &= ~(uint8_t)(0xFF & pin); + } + } + else + { + if (val) + { + w_data[1] |= (uint8_t)(0xFF & (pin >> 8)); + } + else + { + w_data[1] &= ~(uint8_t)(0xFF & (pin >> 8)); + } + } + + temp = ((uint16_t)w_data[1] << 8) | w_data[0]; + + xl9555_write_byte(XL9555_OUTPUT_PORT0_REG, w_data, 2); + + return temp; +} + +/** + * @brief 获取某个IO状态 + * @param pin : 要获取状态的IO + * @retval 此IO口的值(状态, 0/1) + */ +int xl9555_pin_read(uint16_t pin) +{ + uint16_t ret; + uint8_t r_data[2]; + + xl9555_read_byte(r_data, 2); + + ret = r_data[1] << 8 | r_data[0]; + + return (ret & pin) ? 1 : 0; +} + +/** + * @brief XL9555的IO配置 + * @param config_value:IO配置输入或者输出 + * @retval 返回设置的数值 + */ +void xl9555_ioconfig(uint16_t config_value) +{ + /* 从机地址 + CMD + data1(P0) + data2(P1) */ + /* P10、P11、P12、P13和P14为输入,其他引脚为输出 -->0001 1111 0000 0000 注意:0为输出,1为输入*/ + uint8_t data[2]; + esp_err_t err; + + data[0] = (uint8_t)(0xFF & config_value); + data[1] = (uint8_t)(0xFF & (config_value >> 8)); + + do + { + err = xl9555_write_byte(XL9555_CONFIG_PORT0_REG, data, 2); + if (err != ESP_OK) + { + ESP_LOGE(xl9555_tag, "%s configure %X failed, ret: %d", __func__, config_value, err); + } + + vTaskDelay(pdMS_TO_TICKS(100)); + + } while (err != ESP_OK); +} + +/** + * @brief 外部中断服务函数 + * @param arg:中断引脚号 + * @note IRAM_ATTR: 这里的IRAM_ATTR属性用于将中断处理函数存储在内部RAM中,目的在于减少延迟 + * @retval 无 + */ +static void IRAM_ATTR xl9555_exit_gpio_isr_handler(void *arg) +{ + uint32_t gpio_num = (uint32_t)arg; + + if (gpio_num == XL9555_INT_IO) + { + esp_rom_delay_us(20000); + + if (gpio_get_level(XL9555_INT_IO) == 0) + { + /* 中断处理 */ + // ESP_DRAM_LOGI("xl9555", "xl9555 int"); + } + } +} + +/** + * @brief 外部中断初始化程序 + * @param 无 + * @retval 无 + */ +void xl9555_int_init(void) +{ + gpio_config_t gpio_init_struct; + + /* 配置XL9555器件的INT中断引脚 */ + gpio_init_struct.mode = GPIO_MODE_INPUT; /* 选择为输入模式 */ + gpio_init_struct.pull_up_en = GPIO_PULLUP_ENABLE; /* 上拉使能 */ + gpio_init_struct.pull_down_en = GPIO_PULLDOWN_DISABLE; /* 下拉失能 */ + gpio_init_struct.intr_type = GPIO_INTR_NEGEDGE; /* 下降沿触发 */ + gpio_init_struct.pin_bit_mask = 1ull << XL9555_INT_IO; /* 设置的引脚的位掩码 */ + gpio_config(&gpio_init_struct); /* 配置使能 */ + + /* 注册中断服务 */ + gpio_install_isr_service(0); + + /* 设置GPIO的中断回调函数 */ + gpio_isr_handler_add(XL9555_INT_IO, xl9555_exit_gpio_isr_handler, (void*)XL9555_INT_IO); +} + +/** + * @brief 初始化XL9555 + * @param 无 + * @retval ESP_OK:初始化成功 + */ +esp_err_t xl9555_init(void) +{ + uint8_t r_data[2]; + + /* 未调用myiic_init初始化IIC */ + if (bus_handle == NULL) + { + ESP_ERROR_CHECK(myiic_init()); + } + + i2c_device_config_t xl9555_i2c_dev_conf = { + .dev_addr_length = I2C_ADDR_BIT_LEN_7, /* 从机地址长度 */ + .scl_speed_hz = IIC_SPEED_CLK, /* 传输速率 */ + .device_address = XL9555_ADDR, /* 从机7位的地址 */ + }; + /* I2C总线上添加XL9555设备 */ + ESP_ERROR_CHECK(i2c_master_bus_add_device(bus_handle, &xl9555_i2c_dev_conf, &xl9555_handle)); + + /* 输入模式下,中断才有效(读取IO电平) */ + // xl9555_int_init(); + + /* 上电先读取一次清除中断标志 */ + xl9555_read_byte(r_data, 2); + /* 配置那些扩展管脚为输入输出模式 */ + xl9555_ioconfig(0xF003); + /* 关闭蜂鸣器 */ + xl9555_pin_write(BEEP_IO, 1); + /* 关闭喇叭 */ + xl9555_pin_write(SPK_EN_IO, 1); + + return ESP_OK; +} + +/** + * @brief 按键扫描函数 + * @param mode:0->不连续;1->连续 + * @retval 键值, 定义如下: + * KEY0_PRES, 1, KEY0按下 + * KEY1_PRES, 2, KEY1按下 + * KEY2_PRES, 3, KEY2按下 + * KEY3_PRES, 4, KEY3按下 + */ +uint8_t xl9555_key_scan(uint8_t mode) +{ + uint8_t keyval = 0; + static uint8_t key_up = 1; /* 按键按松开标志 */ + + if (mode) + { + key_up = 1; /* 支持连按 */ + } + + if (key_up && (KEY0 == 0 || KEY1 == 0 || KEY2 == 0 || KEY3 == 0 )) /* 按键松开标志为1, 且有任意一个按键按下了 */ + { + esp_rom_delay_us(100000); /* 去抖动 */ + key_up = 0; + + if (KEY0 == 0) + { + keyval = KEY0_PRES; + } + + if (KEY1 == 0) + { + keyval = KEY1_PRES; + } + + if (KEY2 == 0) + { + keyval = KEY2_PRES; + } + + if (KEY3 == 0) + { + keyval = KEY3_PRES; + } + } + else if (KEY0 == 1 && KEY1 == 1 && KEY2 == 1 && KEY3 == 1) /* 没有任何按键按下, 标记按键松开 */ + { + key_up = 1; + } + + return keyval; /* 返回键值 */ +} diff --git a/components/BSP/XL9555/xl9555.h b/components/BSP/XL9555/xl9555.h new file mode 100644 index 0000000..25f5670 --- /dev/null +++ b/components/BSP/XL9555/xl9555.h @@ -0,0 +1,83 @@ +/** + **************************************************************************************************** + * @file xl9555.h + * @author 正点原子团队(ALIENTEK) + * @version V1.0 + * @date 2025-01-01 + * @brief XL9555驱动代码 + * @license Copyright (c) 2020-2032, 广州市星翼电子科技有限公司 + **************************************************************************************************** + * @attention + * + * 实验平台:正点原子 ESP32-S3开发板 + * 在线视频:www.yuanzige.com + * 技术论坛:www.openedv.com + * 公司网址:www.alientek.com + * 购买地址:openedv.taobao.com + * + **************************************************************************************************** + */ + +#ifndef __XL9555_H +#define __XL9555_H + +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "driver/gpio.h" +#include "myiic.h" +#include "string.h" + + +/* 引脚与相关参数定义 */ +#define XL9555_INT_IO GPIO_NUM_40 /* XL9555_INT引脚 */ + +/* XL9555寄存器宏 */ +#define XL9555_INPUT_PORT0_REG 0 /* 输入寄存器0地址 */ +#define XL9555_INPUT_PORT1_REG 1 /* 输入寄存器1地址 */ +#define XL9555_OUTPUT_PORT0_REG 2 /* 输出寄存器0地址 */ +#define XL9555_OUTPUT_PORT1_REG 3 /* 输出寄存器1地址 */ +#define XL9555_INVERSION_PORT0_REG 4 /* 极性反转寄存器0地址 */ +#define XL9555_INVERSION_PORT1_REG 5 /* 极性反转寄存器1地址 */ +#define XL9555_CONFIG_PORT0_REG 6 /* 方向配置寄存器0地址 */ +#define XL9555_CONFIG_PORT1_REG 7 /* 方向配置寄存器1地址 */ + +#define XL9555_ADDR 0X20 /* XL9555器件7位地址-->请看手册(9.1. Device Address) */ + +/* XL9555各个IO的功能 */ +#define AP_INT_IO 0x0001 +#define QMA_INT_IO 0x0002 +#define SPK_EN_IO 0x0004 +#define BEEP_IO 0x0008 +#define OV_PWDN_IO 0x0010 +#define OV_RESET_IO 0x0020 +#define GBC_LED_IO 0x0040 +#define GBC_KEY_IO 0x0080 +#define LCD_BL_IO 0x0100 +#define CT_RST_IO 0x0200 +#define SLCD_RST_IO 0x0400 +#define SLCD_PWR_IO 0x0800 +#define KEY3_IO 0x1000 +#define KEY2_IO 0x2000 +#define KEY1_IO 0x4000 +#define KEY0_IO 0x8000 + +#define KEY0 xl9555_pin_read(KEY0_IO) /* 读取KEY0引脚 */ +#define KEY1 xl9555_pin_read(KEY1_IO) /* 读取KEY1引脚 */ +#define KEY2 xl9555_pin_read(KEY2_IO) /* 读取KEY2引脚 */ +#define KEY3 xl9555_pin_read(KEY3_IO) /* 读取KEY3引脚 */ + +#define KEY0_PRES 1 /* KEY0按下 */ +#define KEY1_PRES 2 /* KEY1按下 */ +#define KEY2_PRES 3 /* KEY1按下 */ +#define KEY3_PRES 4 /* KEY1按下 */ + +/* 函数声明 */ +esp_err_t xl9555_init(void); /* 初始化XL9555 */ +int xl9555_pin_read(uint16_t pin); /* 获取某个IO状态 */ +uint16_t xl9555_pin_write(uint16_t pin, int val); /* 控制某个IO的电平 */ +esp_err_t xl9555_read_byte(uint8_t* data, size_t len); /* 读取XL9555的IO值 */ +esp_err_t xl9555_write_byte(uint8_t reg, uint8_t *data, size_t len); /* 向XL9555寄存器写入数据 */ +uint8_t xl9555_key_scan(uint8_t mode); /* 扫描扩展按键 */ +void xl9555_int_init(void); /* 初始化XL9555的中断引脚 */ + +#endif diff --git a/dependencies.lock b/dependencies.lock new file mode 100644 index 0000000..e7affb6 --- /dev/null +++ b/dependencies.lock @@ -0,0 +1,29 @@ +dependencies: + esphome/micro-flac: + component_hash: a06a472806f8bd7c77bf0329119806bdabfa0101686971f3553b8d90b1002444 + dependencies: + - name: idf + require: private + version: '>=4.4' + source: + registry_url: https://components.espressif.com/ + type: service + version: 0.3.0 + idf: + source: + type: idf + version: 6.1.0 + lvgl/lvgl: + component_hash: d7c1ac037ae6e85d94897f807d6e7ba0946a83e720074fc95a4f6241da9f9f53 + dependencies: [] + source: + registry_url: https://components.espressif.com/ + type: service + version: 8.4.0 +direct_dependencies: +- esphome/micro-flac +- idf +- lvgl/lvgl +manifest_hash: 0fbca35afb27dbdbbda5720a7e10bd795a9dce0bbf94d3cc3716cb165ea95bb9 +target: esp32s3 +version: 3.0.0 diff --git a/main/APP/app.c b/main/APP/app.c new file mode 100644 index 0000000..50b8063 --- /dev/null +++ b/main/APP/app.c @@ -0,0 +1,330 @@ +/** + **************************************************************************************************** + * @file app.c + * @author HiFi Player Project + * @version V1.2 + * @date 2026-01-01 + * @brief 应用管理器 + 全局按键服务 + * + * 模块职责: + * 1. 唯一持有 XL9555 按键的扫描权(其它模块不再直接读按键,避免重复扫描/漏键)。 + * 2. 按键去抖 + 区分短按/长按,统一投递到事件队列(消费者是 ui.c)。 + * 3. 维护当前应用模式、主界面应用表与选择项,并提供“返回 / 切换应用”接口。 + **************************************************************************************************** + */ + +#include "app.h" + +#include + +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "freertos/queue.h" + +#include "esp_log.h" +#include "lvgl.h" + +#include "xl9555.h" + +#define TAG "app" + +/* ---------------- 按键服务参数 ---------------- */ +#define KEY_SCAN_PERIOD_MS 10 /* 扫描周期 */ +#define KEY_DEBOUNCE_COUNT 3 /* 连续 3 次一致才确认(约 30ms) */ +#define KEY_LONG_PRESS_MS 700 /* 长按阈值 */ +#define KEY_TASK_STACK (3 * 1024) +#define KEY_TASK_PRIO 5 /* 低于播放器(6),高于 LVGL(4) */ +#define EVENT_QUEUE_LEN 16 + +/* ---------------- 主界面应用表(新增应用只需在这里加一条) ---------------- */ +static const app_entry_t s_apps[] = { + { + .icon = LV_SYMBOL_AUDIO, /* 音符:音乐播放器 */ + .title = "MUSIC", + .subtitle = "WAV files in SD /MUSIC", + .mode = APP_MODE_PLAYER, + .enabled = true, + .color_from = 0x8E5BFF, + .color_to = 0x3FB6F5, + }, + { + .icon = LV_SYMBOL_DIRECTORY, /* 文件夹:SD 卡文件浏览 */ + .title = "FILES", + .subtitle = "Browse files on SD card", + .mode = APP_MODE_NONE, + .enabled = false, + .color_from = 0x31C27C, + .color_to = 0x1E9C6B, + }, + { + .icon = LV_SYMBOL_SETTINGS, /* 齿轮:设置 */ + .title = "SETTINGS", + .subtitle = "Display, audio and keys", + .mode = APP_MODE_NONE, + .enabled = false, + .color_from = 0x9AA3B5, + .color_to = 0x6A7183, + }, + { + .icon = LV_SYMBOL_LIST, /* 列表:关于 */ + .title = "ABOUT", + .subtitle = "HiFi Player V1.2", + .mode = APP_MODE_NONE, + .enabled = false, + .color_from = 0xFFB020, + .color_to = 0xF07C18, + }, +}; + +#define APP_COUNT ((int)(sizeof(s_apps) / sizeof(s_apps[0]))) + +/* ---------------- 内部状态 ---------------- */ +typedef struct { + uint8_t last_raw; /* 上次原始电平(1=按下) */ + uint8_t stable; /* 已确认的稳定状态 */ + uint8_t counter; /* 连续一致计数 */ + TickType_t press_tick; /* 按下时刻 */ + bool long_sent; /* 本次按住是否已上报长按 */ +} key_state_t; + +static const uint16_t s_key_mask[APP_KEY_NUM] = { KEY0_IO, KEY1_IO, KEY2_IO, KEY3_IO }; +static key_state_t s_keys[APP_KEY_NUM]; + +static QueueHandle_t s_event_queue = NULL; +static app_mode_t s_mode = APP_MODE_HOME; +static int s_selection = 0; + +/* ---------------- 按键事件投递 ---------------- */ +static void key_post(uint8_t key, uint8_t type) +{ + app_event_t ev = { .key = key, .type = type }; + + if (s_event_queue != NULL) + { + /* 队列满时丢弃新事件,绝不阻塞按键任务 */ + (void)xQueueSend(s_event_queue, &ev, 0); + } +} + +/** + * @brief 按键扫描任务(非阻塞去抖 + 长短按识别) + */ +static void key_task(void *arg) +{ + (void)arg; + + memset(s_keys, 0, sizeof(s_keys)); + + while (1) + { + uint8_t data[2] = {0, 0}; + + if (xl9555_read_byte(data, 2) == ESP_OK) + { + uint16_t level = (uint16_t)(((uint16_t)data[1] << 8) | data[0]); + TickType_t now = xTaskGetTickCount(); + + for (int i = 0; i < APP_KEY_NUM; i++) + { + key_state_t *k = &s_keys[i]; + uint8_t raw = ((level & s_key_mask[i]) == 0) ? 1 : 0; /* 低电平有效 */ + + /* --- 1. 去抖计数 --- */ + if (raw != k->last_raw) + { + k->last_raw = raw; + k->counter = 0; + } + else if (k->counter < KEY_DEBOUNCE_COUNT) + { + k->counter++; + } + + if (k->counter < KEY_DEBOUNCE_COUNT) + { + continue; /* 尚未稳定 */ + } + + /* --- 2. 状态变化 --- */ + if (raw != k->stable) + { + k->stable = raw; + + if (raw) /* 刚按下:记录时刻 */ + { + k->press_tick = now; + k->long_sent = false; + } + else if (!k->long_sent) /* 松开且从未上报长按 → 短按 */ + { + key_post((uint8_t)i, APP_KEY_SHORT); + } + } + /* --- 3. 按住不放 → 长按 --- */ + else if (raw && !k->long_sent && + ((now - k->press_tick) >= pdMS_TO_TICKS(KEY_LONG_PRESS_MS))) + { + k->long_sent = true; + key_post((uint8_t)i, APP_KEY_LONG); + } + } + } + + vTaskDelay(pdMS_TO_TICKS(KEY_SCAN_PERIOD_MS)); + } +} + +/* ---------------- 对外接口 ---------------- */ +void app_start(void) +{ + if (s_event_queue == NULL) + { + s_event_queue = xQueueCreate(EVENT_QUEUE_LEN, sizeof(app_event_t)); + if (s_event_queue == NULL) + { + ESP_LOGE(TAG, "create event queue failed"); + return; + } + } + + s_mode = APP_MODE_HOME; + s_selection = 0; + + if (xTaskCreate(key_task, "key_svc", KEY_TASK_STACK, NULL, KEY_TASK_PRIO, NULL) != pdPASS) + { + ESP_LOGE(TAG, "create key task failed"); + } + else + { + ESP_LOGI(TAG, "app manager started, %d app(s) in launcher", APP_COUNT); + } +} + +app_mode_t app_get_mode(void) +{ + return s_mode; +} + +void app_set_mode(app_mode_t mode) +{ + if (mode == s_mode) + { + return; + } + + s_mode = mode; + + /* 丢弃切换过程中的残留事件,避免“上一个界面的按键”触发新界面的动作 */ + app_event_flush(); + + ESP_LOGI(TAG, "switch to mode %d", (int)mode); +} + +void app_back(void) +{ + if (s_mode != APP_MODE_HOME) + { + app_set_mode(APP_MODE_HOME); + } +} + +bool app_switch_next(void) +{ + int count = 0; + const app_entry_t *apps = app_get_entries(&count); + + if (count <= 0) + { + return false; + } + + /* 找到当前模式的表项下标(找不到则视为 -1,从第 1 个开始找) */ + int start = -1; + for (int i = 0; i < count; i++) + { + if (apps[i].mode == s_mode) + { + start = i; + break; + } + } + + /* 向后找下一个“可用且已实现”的应用 */ + for (int step = 1; step <= count; step++) + { + int idx = (start + step + count) % count; + + if (apps[idx].enabled && (apps[idx].mode < APP_MODE_NUM)) + { + s_selection = idx; + app_set_mode(apps[idx].mode); + return true; + } + } + + return false; +} + +const app_entry_t *app_get_entries(int *count) +{ + if (count != NULL) + { + *count = APP_COUNT; + } + + return s_apps; +} + +int app_get_selection(void) +{ + if ((s_selection < 0) || (s_selection >= APP_COUNT)) + { + s_selection = 0; + } + + return s_selection; +} + +void app_select_move(int delta) +{ + if (APP_COUNT <= 0) + { + return; + } + + s_selection = (app_get_selection() + delta) % APP_COUNT; + if (s_selection < 0) + { + s_selection += APP_COUNT; + } +} + +bool app_event_poll(app_event_t *ev) +{ + if ((s_event_queue == NULL) || (ev == NULL)) + { + return false; + } + + return (xQueueReceive(s_event_queue, ev, 0) == pdTRUE); +} + +bool app_event_wait(app_event_t *ev, uint32_t timeout_ms) +{ + if ((s_event_queue == NULL) || (ev == NULL)) + { + return false; + } + + TickType_t ticks = (timeout_ms == 0) ? 0 : pdMS_TO_TICKS(timeout_ms); + + return (xQueueReceive(s_event_queue, ev, ticks) == pdTRUE); +} + +void app_event_flush(void) +{ + if (s_event_queue != NULL) + { + (void)xQueueReset(s_event_queue); + } +} diff --git a/main/APP/app.h b/main/APP/app.h new file mode 100644 index 0000000..4cc69f8 --- /dev/null +++ b/main/APP/app.h @@ -0,0 +1,114 @@ +/** + **************************************************************************************************** + * @file app.h + * @author HiFi Player Project + * @version V1.2 + * @date 2026-01-01 + * @brief 应用管理器:主界面(应用启动器)/ 各 App 切换 + 全局按键服务 + * + * 设计目的: + * 把“哪个界面在运行”和“按键归谁处理”从各 App 里抽出来。 + * 新增应用(设置、相册、录音等)只需要: + * 1) 在 app_mode_t 里加一个模式; + * 2) 在 app.c 的 s_apps[] 表里加一条记录; + * 3) 新建自己的文件夹(如 APP/clock/),实现 ui_clock_create/show/tick/handle_key, + * 并在 ui.c 的路由表里挂上。 + * + * 按键语义(由 ui.c 统一实现): + * K0 短按 = 返回主界面(主界面时为切换下一个应用) + * K0 长按 = 直接切换到下一个应用 + * K1/K2/K3 = 由各 App 自己定义 + **************************************************************************************************** + */ + +#ifndef __APP_H +#define __APP_H + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/* ---------------- 应用(界面)模式 ---------------- */ +typedef enum { + APP_MODE_HOME = 0, /* 主界面:应用启动器 */ + APP_MODE_PLAYER, /* 音乐播放器 */ + APP_MODE_NUM, /* 模式总数(新增应用请加在这之前) */ +} app_mode_t; + +#define APP_MODE_NONE APP_MODE_NUM /* “无对应界面”(禁用的表项使用) */ + +/* ---------------- 4 个物理按键(XL9555 的 KEY0~KEY3) ---------------- */ +typedef enum { + APP_KEY_0 = 0, /* KEY0 */ + APP_KEY_1, /* KEY1 */ + APP_KEY_2, /* KEY2 */ + APP_KEY_3, /* KEY3 */ + APP_KEY_NUM +} app_key_t; + +/* ---------------- 按键事件类型 ---------------- */ +typedef enum { + APP_KEY_SHORT = 0, /* 短按(松开时上报) */ + APP_KEY_LONG, /* 长按(按住达到阈值立即上报,松开不再上报短按) */ +} app_key_event_t; + +typedef struct { + uint8_t key; /* app_key_t */ + uint8_t type; /* app_key_event_t */ +} app_event_t; + +/* ---------------- 主界面应用表项 ---------------- */ +typedef struct { + const char *icon; /* 图标字形(LV_SYMBOL_xxx) */ + const char *title; /* 图标下方名称 */ + const char *subtitle; /* 选中时显示的说明(可选,NULL 表示不显示) */ + app_mode_t mode; /* 进入后切到的模式(APP_MODE_NONE 表示尚未实现) */ + bool enabled; /* 是否可进入 */ + uint32_t color_from; /* 图标渐变起始色(顶部) */ + uint32_t color_to; /* 图标渐变结束色(底部) */ +} app_entry_t; + +/** + * @brief 初始化应用管理器(创建按键服务任务与事件队列) + * @note 必须在 xl9555_init() 之后调用 + */ +void app_start(void); + +/* ---------------- 模式 ---------------- */ +app_mode_t app_get_mode(void); + +/** + * @brief 切换应用模式 + * @note 切换时会清空按键事件队列,避免上一次界面的按键“漏”到新界面 + */ +void app_set_mode(app_mode_t mode); + +/** 返回主界面(已在主界面时无动作) */ +void app_back(void); + +/** + * @brief 切换到“下一个可用的应用" + * @retval true 切换成功;false 没有其它可切换的应用 + * @note 主界面 K0、以及在任意 App 内长按 K0 都会调用它 + */ +bool app_switch_next(void); + +/* ---------------- 主界面应用表 ---------------- */ +const app_entry_t *app_get_entries(int *count); + +int app_get_selection(void); +void app_select_move(int delta); /* 在当前应用表中循环移动选择项 */ + +/* ---------------- 按键事件 ---------------- */ +bool app_event_poll(app_event_t *ev); /* 非阻塞 */ +bool app_event_wait(app_event_t *ev, uint32_t timeout_ms); /* 阻塞等待 */ +void app_event_flush(void); /* 清空队列 */ + +#ifdef __cplusplus +} +#endif + +#endif /* __APP_H */ diff --git a/main/APP/home/ui_home.c b/main/APP/home/ui_home.c new file mode 100644 index 0000000..809b89e --- /dev/null +++ b/main/APP/home/ui_home.c @@ -0,0 +1,349 @@ +/** + **************************************************************************************************** + * @file ui_home.c + * @author HiFi Player Project + * @version V1.3 + * @date 2026-01-01 + * @brief 主界面 —— iPhone 风格图标网格(应用启动器) + * + * ┌──────────────────────────────────┐ + * │ APP LAUNCHER 1/4 │ 状态栏 + * ├──────────────────────────────────┤ + * │ ╭────╮ ╭────╮ ╭────╮ ╭────╮│ + * │ │ ♪ │ │ 📁 │ │ ⚙ │ │ ☰ ││ 图标(圆角渐变 + 字形) + * │ ╰────╯ ╰────╯ ╰────╯ ╰────╯│ + * │ MUSIC FILES SETTINGS ABOUT │ 名称 + * │ WAV files in SD /MUSIC │ 选中项说明 + * │ ● ○ ○ │ 页码圆点 + * ├──────────────────────────────────┤ + * │ [◀K1] [▶K2] [✔K3] [→K0] │ 按键提示(Dock) + * └──────────────────────────────────┘ + * + * 按键: + * K1 上一个图标 K2 下一个图标 K3 打开 K0 切换应用(全局) + * + * 图标不使用图片文件,而是「圆角渐变底 + LVGL 内置字形」,因此无需额外资源, + * 新增应用时只要在 app.c 的表项里填 icon / color_from / color_to 即可。 + **************************************************************************************************** + */ + +#include "ui_home.h" + +#include + +#include "ui_common.h" +#include "app.h" + +/* ---------------- 网格布局参数 ---------------- */ +#define HOME_COLS 4 /* 每页图标列数(iPhone 风格一排) */ +#define HOME_MAX_PAGES 4 /* 最多支持多少页 */ + +#define CELL_W 76 +#define CELL_H 88 +#define TILE_SIZE 68 /* 图标方块尺寸 */ +#define TILE_RADIUS 16 /* 图标圆角(iOS 超级圆角观感) */ +#define TILE_Y 0 +#define NAME_DY 72 /* 名称相对单元格顶部的偏移 */ +#define GRID_Y 36 /* 网格顶部 */ +#define GRID_X 8 /* 网格左侧起始 x */ + +#define DOTS_Y 150 /* 页码圆点 y */ +#define INFO_Y 132 /* 选中项说明 y */ + +/* ---------------- 控件 ---------------- */ +static lv_obj_t *s_scr = NULL; +static lv_obj_t *s_lbl_index = NULL; +static lv_obj_t *s_lbl_info = NULL; +static lv_obj_t *s_dot[HOME_MAX_PAGES]; + +/* 每页的图标控件 */ +static lv_obj_t *s_tile[HOME_COLS]; +static lv_obj_t *s_glyph[HOME_COLS]; +static lv_obj_t *s_name[HOME_COLS]; + +static ui_key_bar_t s_bar; +static ui_flash_t s_flash; + +/* ---------------- 缓存(避免无谓重绘) ---------------- */ +static int s_page = -1; +static int s_sel = -2; + +/* ---------------- 按键提示(Dock) ---------------- */ +static const char *const s_symbols[APP_KEY_NUM] = { + LV_SYMBOL_RIGHT, /* K0:切换应用 */ + LV_SYMBOL_LEFT, /* K1:上一个图标 */ + LV_SYMBOL_RIGHT, /* K2:下一个图标 */ + LV_SYMBOL_OK, /* K3:打开 */ +}; + +static const char *const s_captions[APP_KEY_NUM] = { + "K0 SWITCH", + "K1 PREV", + "K2 NEXT", + "K3 OPEN", +}; + +static const bool s_enabled[APP_KEY_NUM] = { true, true, true, true }; + +/* ---------------- 内部实现 ---------------- */ + +/** + * @brief 设置图标方块的外观(渐变 / 选中环 / 未实现时变暗) + */ +static void style_tile(int slot, const app_entry_t *app, bool selected) +{ + lv_obj_t *tile = s_tile[slot]; + + lv_obj_set_style_bg_color(tile, lv_color_hex(app->color_from), LV_PART_MAIN); + lv_obj_set_style_bg_grad_color(tile, lv_color_hex(app->color_to), LV_PART_MAIN); + lv_obj_set_style_bg_grad_dir(tile, LV_GRAD_DIR_VER, LV_PART_MAIN); + lv_obj_set_style_bg_opa(tile, app->enabled ? LV_OPA_COVER : LV_OPA_50, LV_PART_MAIN); + + /* 选中:白色描边环 + 更强的投影(iOS 图标的“抬起”观感) */ + lv_obj_set_style_border_width(tile, selected ? 3 : 0, LV_PART_MAIN); + lv_obj_set_style_border_color(tile, lv_color_hex(UI_COL_TEXT), LV_PART_MAIN); + lv_obj_set_style_border_opa(tile, LV_OPA_COVER, LV_PART_MAIN); + lv_obj_set_style_shadow_width(tile, selected ? 22 : 10, LV_PART_MAIN); + lv_obj_set_style_shadow_color(tile, lv_color_hex(app->color_to), LV_PART_MAIN); + lv_obj_set_style_shadow_opa(tile, selected ? LV_OPA_50 : LV_OPA_30, LV_PART_MAIN); + lv_obj_set_style_shadow_ofs_y(tile, selected ? 6 : 3, LV_PART_MAIN); + + /* 字形 */ + lv_obj_set_style_text_color(s_glyph[slot], + lv_color_hex(app->enabled ? 0xFFFFFF : UI_COL_DIM), LV_PART_MAIN); + + /* 名称 */ + lv_obj_set_style_text_color(s_name[slot], + lv_color_hex(app->enabled ? (selected ? UI_COL_TEXT : UI_COL_SUB) + : UI_COL_DIM), + LV_PART_MAIN); +} + +/** + * @brief 刷新当前页的图标、页码与说明文字 + */ +static void refresh(bool force) +{ + int count = 0; + const app_entry_t *apps = app_get_entries(&count); + int sel = app_get_selection(); + + if (count <= 0) + { + sel = -1; + } + else if (sel >= count) + { + sel = count - 1; + } + + int page = (sel < 0) ? 0 : (sel / HOME_COLS); + + if (!force && (page == s_page) && (sel == s_sel)) + { + return; /* 无变化 */ + } + + s_page = page; + s_sel = sel; + + /* ---- 图标 ---- */ + for (int i = 0; i < HOME_COLS; i++) + { + int idx = page * HOME_COLS + i; + + if (idx < count) + { + const app_entry_t *app = &apps[idx]; + + ui_label_set_if_changed(s_glyph[i], app->icon); + ui_label_set_if_changed(s_name[i], app->title); + style_tile(i, app, (idx == sel)); + + lv_obj_clear_flag(s_tile[i], LV_OBJ_FLAG_HIDDEN); + lv_obj_clear_flag(s_name[i], LV_OBJ_FLAG_HIDDEN); + } + else + { + lv_obj_add_flag(s_tile[i], LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(s_name[i], LV_OBJ_FLAG_HIDDEN); + } + } + + /* ---- 状态栏计数 ---- */ + char buffer[24]; + if ((count > 0) && (sel >= 0)) + { + snprintf(buffer, sizeof(buffer), "%d/%d", sel + 1, count); + } + else + { + snprintf(buffer, sizeof(buffer), "--/--"); + } + ui_label_set_if_changed(s_lbl_index, buffer); + + /* ---- 选中项说明 ---- */ + const char *info = ""; + if ((sel >= 0) && (apps[sel].subtitle != NULL)) + { + info = apps[sel].subtitle; + } + ui_label_set_if_changed(s_lbl_info, info); + + /* ---- 页码圆点 ---- */ + int pages = (count + HOME_COLS - 1) / HOME_COLS; + + if (pages > HOME_MAX_PAGES) + { + pages = HOME_MAX_PAGES; + } + + for (int i = 0; i < HOME_MAX_PAGES; i++) + { + if (i < pages) + { + lv_obj_clear_flag(s_dot[i], LV_OBJ_FLAG_HIDDEN); + lv_obj_set_style_bg_opa(s_dot[i], (i == page) ? LV_OPA_COVER : LV_OPA_30, LV_PART_MAIN); + } + else + { + lv_obj_add_flag(s_dot[i], LV_OBJ_FLAG_HIDDEN); + } + } +} + +/** + * @brief 打开当前选中的应用 + */ +static void open_selected(void) +{ + int count = 0; + const app_entry_t *apps = app_get_entries(&count); + int sel = app_get_selection(); + + if ((sel < 0) || (sel >= count)) + { + return; + } + + if (!apps[sel].enabled || (apps[sel].mode >= APP_MODE_NUM)) + { + return; /* 尚未实现的应用,忽略 */ + } + + app_set_mode(apps[sel].mode); +} + +/* ---------------- 对外接口 ---------------- */ + +void ui_home_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, 28, UI_COL_PANEL, 0); + + lv_obj_t *title = ui_label(header, LV_SYMBOL_LIST " APP LAUNCHER", + &lv_font_montserrat_14, UI_COL_SUB); + lv_obj_align(title, LV_ALIGN_LEFT_MID, 10, 0); + + s_lbl_index = ui_label(header, "--/--", &lv_font_montserrat_12, UI_COL_SUB); + lv_obj_align(s_lbl_index, LV_ALIGN_RIGHT_MID, -10, 0); + + /* ---------------- 图标网格(一排,iOS 风格) ---------------- */ + for (int i = 0; i < HOME_COLS; i++) + { + lv_coord_t cell_x = (lv_coord_t)(GRID_X + i * CELL_W); + + /* 图标方块(直接挂在屏幕上,避免投影被容器裁掉) */ + s_tile[i] = ui_card(s_scr, + (lv_coord_t)(cell_x + (CELL_W - TILE_SIZE) / 2), GRID_Y, + TILE_SIZE, TILE_SIZE, UI_COL_CARD, TILE_RADIUS); + + /* 图标字形 */ + s_glyph[i] = ui_label(s_tile[i], "", &lv_font_montserrat_24, 0xFFFFFF); + lv_obj_center(s_glyph[i]); + + /* 名称 */ + s_name[i] = ui_label(s_scr, "", &lv_font_montserrat_12, UI_COL_SUB); + lv_obj_set_pos(s_name[i], cell_x, (lv_coord_t)(GRID_Y + NAME_DY)); + lv_obj_set_size(s_name[i], CELL_W, 14); + lv_obj_set_style_text_align(s_name[i], LV_TEXT_ALIGN_CENTER, LV_PART_MAIN); + lv_label_set_long_mode(s_name[i], LV_LABEL_LONG_DOT); + } + + /* ---------------- 选中项说明 ---------------- */ + s_lbl_info = ui_label(s_scr, "", &lv_font_montserrat_12, UI_COL_DIM); + lv_obj_set_pos(s_lbl_info, 0, INFO_Y); + lv_obj_set_size(s_lbl_info, UI_HOR_RES, 14); + lv_obj_set_style_text_align(s_lbl_info, LV_TEXT_ALIGN_CENTER, LV_PART_MAIN); + lv_label_set_long_mode(s_lbl_info, LV_LABEL_LONG_DOT); + + /* ---------------- 页码圆点 ---------------- */ + for (int i = 0; i < HOME_MAX_PAGES; i++) + { + lv_obj_t *dot = lv_obj_create(s_scr); + lv_obj_set_size(dot, 6, 6); + lv_obj_clear_flag(dot, LV_OBJ_FLAG_SCROLLABLE | LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_style_radius(dot, LV_RADIUS_CIRCLE, LV_PART_MAIN); + lv_obj_set_style_bg_color(dot, lv_color_hex(UI_COL_TEXT), LV_PART_MAIN); + lv_obj_set_style_bg_opa(dot, LV_OPA_30, LV_PART_MAIN); + lv_obj_set_style_border_width(dot, 0, LV_PART_MAIN); + + int total_w = HOME_MAX_PAGES * 6 + (HOME_MAX_PAGES - 1) * 8; + lv_obj_set_pos(dot, (lv_coord_t)((UI_HOR_RES - total_w) / 2 + i * 14), DOTS_Y); + lv_obj_add_flag(dot, LV_OBJ_FLAG_HIDDEN); + + s_dot[i] = dot; + } + + /* ---------------- 底部按键提示(Dock) ---------------- */ + ui_key_bar_create(s_scr, &s_bar, s_symbols, s_captions, s_enabled); + + refresh(true); +} + +void ui_home_show(void) +{ + s_sel = -2; /* 强制刷新一次 */ + s_page = -1; + + refresh(true); + ui_key_bar_set_highlight(&s_bar, -1); + lv_scr_load(s_scr); +} + +void ui_home_tick(void) +{ + ui_key_bar_set_highlight(&s_bar, ui_flash_active(&s_flash)); + refresh(false); +} + +void ui_home_flash(int key) +{ + ui_flash_trigger(&s_flash, key); +} + +void ui_home_handle_key(uint8_t key) +{ + ui_flash_trigger(&s_flash, key); + + switch (key) + { + case APP_KEY_1: /* K1:上一个图标 */ + app_select_move(-1); + break; + + case APP_KEY_2: /* K2:下一个图标 */ + app_select_move(+1); + break; + + case APP_KEY_3: /* K3:打开选中的应用 */ + open_selected(); + break; + + default: + break; + } +} diff --git a/main/APP/home/ui_home.h b/main/APP/home/ui_home.h new file mode 100644 index 0000000..c396659 --- /dev/null +++ b/main/APP/home/ui_home.h @@ -0,0 +1,41 @@ +/** + **************************************************************************************************** + * @file ui_home.h + * @author HiFi Player Project + * @version V1.2 + * @date 2026-01-01 + * @brief 主界面(iPhone 风格图标网格)界面接口 + * + * 按键:K1 上一个图标 / K2 下一个图标 / K3 打开 / K0 切换应用(全局) + **************************************************************************************************** + */ + +#ifndef __UI_HOME_H +#define __UI_HOME_H + +#include "lvgl.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/** 创建主界面(只创建,不显示) */ +void ui_home_create(void); + +/** 显示主界面并复位内部状态 */ +void ui_home_show(void); + +/** 周期刷新显示 */ +void ui_home_tick(void); + +/** 处理本界面的功能键(K1/K2/K3);K0 由 ui.c 统一处理 */ +void ui_home_handle_key(uint8_t key); + +/** 仅触发按键高亮(不做动作),供 K0 使用 */ +void ui_home_flash(int key); + +#ifdef __cplusplus +} +#endif + +#endif /* __UI_HOME_H */ diff --git a/main/APP/lvgl_port.c b/main/APP/lvgl_port.c new file mode 100644 index 0000000..497ad9e --- /dev/null +++ b/main/APP/lvgl_port.c @@ -0,0 +1,131 @@ +/** + **************************************************************************************************** + * @file lvgl_port.c + * @author HiFi Player Project + * @version V1.0 + * @date 2026-01-01 + * @brief LVGL 8.x 移植层 + * + * 移植要点: + * 1. 绘图缓冲区必须放在内部 RAM,并带 DMA 能力(SPI DMA 不能直接读 PSRAM)。 + * 2. flush_cb 中把 LVGL 的小端 RGB565 数据做字节交换,再交给 spilcd_flush()。 + * 3. spilcd_flush() 是阻塞的(等 SPI 传输完成回调),因此这里调用后可以直接 + * lv_disp_flush_ready(),实现简单可靠。 + **************************************************************************************************** + */ + +#include "lvgl_port.h" + +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" + +#include "esp_heap_caps.h" +#include "esp_log.h" +#include "esp_timer.h" + +#include "lvgl.h" +#include "spilcd.h" + +#define TAG "lvgl_port" + +#define DISP_HOR_RES SPI_LCD_H_RES /* 320 */ +#define DISP_VER_RES SPI_LCD_V_RES /* 240 */ +#define DRAW_BUF_LINES 40 /* 单缓冲区高度(40 行约 25KB) */ +#define LVGL_TASK_STACK (8 * 1024) +#define LVGL_TASK_PRIO 4 + +static lv_disp_draw_buf_t s_draw_buf; +static lv_disp_drv_t s_disp_drv; +static lv_color_t *s_draw_buf_mem = NULL; +static esp_timer_handle_t s_tick_timer = NULL; + +/** + * @brief LVGL 刷新回调:把绘制好的区域送到 SPI LCD + */ +static void disp_flush_cb(lv_disp_drv_t *drv, const lv_area_t *area, lv_color_t *color_p) +{ + int32_t width = lv_area_get_width(area); + int32_t height = lv_area_get_height(area); + uint32_t pixels = (uint32_t)width * (uint32_t)height; + uint16_t *buf = (uint16_t *)color_p; + + /* LVGL 内部是 RGB565 小端,ST7789 要求高字节在前,这里原地交换 */ + for (uint32_t i = 0; i < pixels; i++) + { + buf[i] = (uint16_t)((buf[i] << 8) | (buf[i] >> 8)); + } + + /* x2 / y2 使用“不包含”的坐标 */ + spilcd_flush((uint16_t)area->x1, (uint16_t)area->y1, + (uint16_t)(area->x2 + 1), (uint16_t)(area->y2 + 1), buf); + + lv_disp_flush_ready(drv); +} + +/** + * @brief LVGL 1ms 时基 + */ +static void lv_tick_cb(void *arg) +{ + (void)arg; + lv_tick_inc(1); +} + +esp_err_t lvgl_port_init(void) +{ + lv_init(); + + /* ---------------- 绘图缓冲区(内部 DMA RAM) ---------------- */ + size_t pixel_count = (size_t)DISP_HOR_RES * DRAW_BUF_LINES; + + s_draw_buf_mem = (lv_color_t *)heap_caps_malloc(pixel_count * sizeof(lv_color_t), + MALLOC_CAP_DMA | MALLOC_CAP_INTERNAL); + if (s_draw_buf_mem == NULL) + { + ESP_LOGE(TAG, "no memory for LVGL draw buffer"); + return ESP_ERR_NO_MEM; + } + + lv_disp_draw_buf_init(&s_draw_buf, s_draw_buf_mem, NULL, pixel_count); + + /* ---------------- 注册显示设备 ---------------- */ + lv_disp_drv_init(&s_disp_drv); + s_disp_drv.hor_res = DISP_HOR_RES; + s_disp_drv.ver_res = DISP_VER_RES; + s_disp_drv.draw_buf = &s_draw_buf; + s_disp_drv.flush_cb = disp_flush_cb; + lv_disp_drv_register(&s_disp_drv); + + /* ---------------- 1ms 时基 ---------------- */ + const esp_timer_create_args_t timer_args = { + .callback = lv_tick_cb, + .name = "lv_tick", + }; + ESP_ERROR_CHECK(esp_timer_create(&timer_args, &s_tick_timer)); + ESP_ERROR_CHECK(esp_timer_start_periodic(s_tick_timer, 1000)); /* 1000us */ + + ESP_LOGI(TAG, "LVGL %d.%d.%d ready, draw buffer %u px", + LVGL_VERSION_MAJOR, LVGL_VERSION_MINOR, LVGL_VERSION_PATCH, + (unsigned)pixel_count); + + return ESP_OK; +} + +static void lvgl_task(void *arg) +{ + (void)arg; + + while (1) + { + lv_timer_handler(); + vTaskDelay(pdMS_TO_TICKS(5)); + } +} + +void lvgl_port_start(void) +{ + if (xTaskCreate(lvgl_task, "lvgl", LVGL_TASK_STACK, NULL, LVGL_TASK_PRIO, NULL) != pdPASS) + { + ESP_LOGE(TAG, "create lvgl task failed"); + } +} diff --git a/main/APP/lvgl_port.h b/main/APP/lvgl_port.h new file mode 100644 index 0000000..7856e6d --- /dev/null +++ b/main/APP/lvgl_port.h @@ -0,0 +1,35 @@ +/** + **************************************************************************************************** + * @file lvgl_port.h + * @author HiFi Player Project + * @version V1.0 + * @date 2026-01-01 + * @brief LVGL 8.x 移植层(显示 + 时基) + **************************************************************************************************** + */ + +#ifndef __LVGL_PORT_H +#define __LVGL_PORT_H + +#include "esp_err.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * @brief 初始化 LVGL:显示驱动、绘图缓冲区、1ms 时基 + * @note LVGL 会在内部使用 SPI LCD 的 panel 句柄,调用前必须先执行 spilcd_init() + */ +esp_err_t lvgl_port_init(void); + +/** + * @brief 启动 LVGL 任务(lv_timer_handler 循环) + */ +void lvgl_port_start(void); + +#ifdef __cplusplus +} +#endif + +#endif /* __LVGL_PORT_H */ diff --git a/main/APP/player/audio_source.c b/main/APP/player/audio_source.c new file mode 100644 index 0000000..ac6a948 --- /dev/null +++ b/main/APP/player/audio_source.c @@ -0,0 +1,450 @@ +/** + **************************************************************************************************** + * @file audio_source.c + * @author HiFi Player Project + * @version V1.5 + * @date 2026-01-01 + * @brief 统一音源实现(WAV 解析 + FLAC 解码转发) + * + * 两种格式都输出「立体声 + 源文件原生位深」的 PCM,不做降位: + * + * WAV :f_read 原始 PCM ──▶ 单声道则复制 ──▶ 输出 + * FLAC :flac_dec_read() ──▶ 已在内部做声道规整 + 原生位深输出 + * + * 格式识别:读取文件头 4 字节 + * "RIFF" → WAV + * "fLaC" → 原生 FLAC + * "OggS" → Ogg FLAC + **************************************************************************************************** + */ + +#include "audio_source.h" + +#include +#include +#include + +#include "esp_log.h" + +#include "ff.h" +#include "flac_dec.h" + +#define TAG "audio_src" + +#define WAV_MAX_BLOCKALIGN 8 /* 支持的 WAV 帧对齐上限(16/24/32bit × 最多 2 声道) */ +#define WAV_MAX_FRAMES 1024 /* WAV 原始 PCM 中转缓冲容纳的最大帧数 */ + +/* ---------------- 内部结构 ---------------- */ +struct audio_source { + audio_codec_t codec; + audio_info_t info; + + uint64_t frames_read; /* 已输出帧数(用于计算进度) */ + + /* ---- WAV ---- */ + FIL wav_fp; + bool wav_open; + uint16_t block_align; /* 源每帧字节数 = channels * out_bytes */ + uint32_t byte_rate; + uint32_t data_offset; + uint32_t data_size; + uint32_t data_read; /* 已读字节数 */ + uint8_t *scratch; /* 原始 PCM 中转缓冲 */ + int scratch_frames; /* 缓冲能容纳的帧数 */ + + /* ---- FLAC ---- */ + flac_dec_t *flac; +}; + +/* ---------------- 位宽映射 ---------------- */ + +/** + * @brief 源位深 → I2S 输出位宽 + */ +static inline uint16_t pick_out_bits(uint16_t bits) +{ + if (bits <= 16) + { + return 16; + } + + if (bits <= 24) + { + return 24; + } + + return 32; +} + +/* ---------------- WAV 解析 ---------------- */ + +static inline uint16_t rd_u16(const uint8_t *p) +{ + return (uint16_t)(p[0] | (p[1] << 8)); +} + +static inline uint32_t rd_u32(const uint8_t *p) +{ + return (uint32_t)p[0] | ((uint32_t)p[1] << 8) | ((uint32_t)p[2] << 16) | ((uint32_t)p[3] << 24); +} + +/** + * @brief 解析 WAV 头部(支持 fmt/data 之间夹着 LIST、fact 等块) + * @note 调用前文件指针需在文件开头;结束后文件指针位于 data 起始处 + */ +static bool wav_parse(FIL *fp, audio_info_t *info, uint32_t *data_offset, + uint32_t *data_size, uint16_t *block_align, uint32_t *byte_rate, + uint16_t *bits, uint16_t *channels) +{ + uint8_t header[12]; + uint8_t chunk[8]; + uint8_t fmt[40]; + UINT br = 0; + bool have_fmt = false; + bool have_data = false; + uint16_t audio_format = 0; + + if ((f_read(fp, header, sizeof(header), &br) != FR_OK) || (br != sizeof(header))) + { + return false; + } + + if ((memcmp(header, "RIFF", 4) != 0) || (memcmp(header + 8, "WAVE", 4) != 0)) + { + return false; + } + + while (!have_data) + { + if ((f_read(fp, chunk, sizeof(chunk), &br) != FR_OK) || (br != sizeof(chunk))) + { + break; + } + + uint32_t size = rd_u32(chunk + 4); + + if (memcmp(chunk, "fmt ", 4) == 0) + { + if ((size < 16) || (size > sizeof(fmt))) + { + break; + } + + if ((f_read(fp, fmt, size, &br) != FR_OK) || (br != size)) + { + break; + } + + audio_format = rd_u16(fmt); + *channels = rd_u16(fmt + 2); + info->sample_rate = rd_u32(fmt + 4); + *byte_rate = rd_u32(fmt + 8); + *block_align = rd_u16(fmt + 12); + *bits = rd_u16(fmt + 14); + + if ((audio_format == 0xFFFE) && (size >= 26)) + { + /* WAVE_FORMAT_EXTENSIBLE:真实格式在 SubFormat GUID 的前两个字节 */ + audio_format = rd_u16(fmt + 24); + } + + have_fmt = true; + } + else if (memcmp(chunk, "data", 4) == 0) + { + FSIZE_t offset = f_tell(fp); + FSIZE_t remain = f_size(fp) - offset; + + *data_offset = (uint32_t)offset; + /* 有些录音文件 data 长度写 0 或 0xFFFFFFFF,此时用文件剩余长度 */ + *data_size = ((size == 0) || (size > (uint32_t)remain)) ? (uint32_t)remain : size; + + have_data = true; + break; + } + else + { + /* 跳过不关心的块(块长度 2 字节对齐) */ + if (f_lseek(fp, f_tell(fp) + size + (size & 0x01)) != FR_OK) + { + break; + } + } + } + + if (!have_fmt || !have_data) + { + return false; + } + + if (*block_align == 0) + { + *block_align = (uint16_t)(((*bits) / 8) * (*channels)); + } + + if (*byte_rate == 0) + { + *byte_rate = info->sample_rate * (*block_align); + } + + /* 只接受线性 PCM,位深需为 16/24/32(非整字节位深无法从 WAV 头可靠判断) */ + return (audio_format == 1) && + (*channels >= 1) && (*channels <= 2) && + (*block_align > 0) && (*block_align <= WAV_MAX_BLOCKALIGN) && + ((*bits == 16) || (*bits == 24) || (*bits == 32)) && + (info->sample_rate >= 8000) && (*byte_rate > 0); +} + +/** + * @brief WAV:把原始 PCM 规整成立体声(保留原生位深) + */ +static int wav_read(audio_source_t *src, void *out, int max_frames) +{ + if ((src->data_read >= src->data_size) || (max_frames <= 0)) + { + return 0; + } + + uint32_t remain_frames = (src->data_size - src->data_read) / src->block_align; + uint32_t frames = (remain_frames < (uint32_t)max_frames) ? remain_frames : (uint32_t)max_frames; + + if ((int)frames > src->scratch_frames) + { + frames = (uint32_t)src->scratch_frames; + } + + if (frames == 0) + { + return 0; + } + + UINT br = 0; + size_t want = (size_t)frames * src->block_align; + + if (f_read(&src->wav_fp, src->scratch, (UINT)want, &br) != FR_OK) + { + return -1; + } + + if (br < src->block_align) + { + return 0; /* 不足一帧,视作结束 */ + } + + frames = br / src->block_align; + src->data_read += br; + + /* ---- 声道规整(位深保持不变) ---- */ + const uint8_t *s = src->scratch; + uint8_t *dst = (uint8_t *)out; + const size_t ob = src->info.out_bytes; + + if (src->info.channels == 2) + { + /* 最常见路径:立体声直接拷贝(block_align == 2 * out_bytes) */ + memcpy(dst, s, (size_t)frames * 2 * ob); + } + else + { + /* 单声道:左右都写同一样本 */ + for (uint32_t i = 0; i < frames; i++) + { + uint8_t *o = dst + i * 2 * ob; + + memcpy(o, s + i * ob, ob); + memcpy(o + ob, s + i * ob, ob); + } + } + + src->frames_read += frames; + + return (int)frames; +} + +/* ---------------- 对外接口 ---------------- */ + +audio_source_t *audio_source_open(const char *path, audio_info_t *info) +{ + if (path == NULL) + { + return NULL; + } + + audio_source_t *src = (audio_source_t *)calloc(1, sizeof(audio_source_t)); + + if (src == NULL) + { + return NULL; + } + + /* ---------------- 1. 打开文件并嗅探文件头 ---------------- */ + if (f_open(&src->wav_fp, path, FA_READ) != FR_OK) + { + ESP_LOGE(TAG, "open %s failed", path); + audio_source_close(src); + return NULL; + } + src->wav_open = true; + + uint8_t magic[4] = {0}; + UINT br = 0; + bool is_flac = false; + + if ((f_read(&src->wav_fp, magic, sizeof(magic), &br) == FR_OK) && (br == sizeof(magic))) + { + is_flac = (memcmp(magic, "fLaC", 4) == 0) || (memcmp(magic, "OggS", 4) == 0); + } + + /* ---------------- 2A. FLAC ---------------- */ + if (is_flac) + { + /* 交给专门的解码器重新打开(它需要从文件开头开始解析) */ + f_close(&src->wav_fp); + src->wav_open = false; + + flac_info_t fi; + + src->flac = flac_dec_open(path); + + if ((src->flac == NULL) || (flac_dec_get_info(src->flac, &fi) != 0)) + { + ESP_LOGE(TAG, "flac open failed: %s", path); + audio_source_close(src); + return NULL; + } + + src->codec = AUDIO_CODEC_FLAC; + src->info.codec = AUDIO_CODEC_FLAC; + src->info.sample_rate = fi.sample_rate; + src->info.bits = fi.bits; + src->info.channels = fi.channels; + src->info.out_bytes = fi.out_bytes; + src->info.out_bits = (uint16_t)fi.out_bytes * 8; + src->info.total_sec = (fi.total_samples > 0) ? + (uint32_t)(fi.total_samples / fi.sample_rate) : 0; + + 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, + (unsigned)src->info.total_sec); + + return src; + } + + /* ---------------- 2B. WAV ---------------- */ + src->codec = AUDIO_CODEC_WAV; + src->info.codec = AUDIO_CODEC_WAV; + + f_lseek(&src->wav_fp, 0); /* 回到文件开头重新解析 */ + + uint16_t bits = 0; + uint16_t channels = 0; + + if (!wav_parse(&src->wav_fp, &src->info, &src->data_offset, &src->data_size, + &src->block_align, &src->byte_rate, &bits, &channels)) + { + ESP_LOGW(TAG, "unsupported wav: %s", path); + audio_source_close(src); + return NULL; + } + + src->info.bits = bits; + src->info.channels = channels; + src->info.out_bits = pick_out_bits(bits); + src->info.out_bytes = (uint8_t)(bits / 8); /* WAV 位深必为 16/24/32 */ + src->info.total_sec = src->data_size / src->byte_rate; + + /* 原始 PCM 中转缓冲(16/24/32bit × 最多 2 声道 → 每帧最多 8 字节) */ + src->scratch = (uint8_t *)malloc((size_t)WAV_MAX_FRAMES * src->block_align); + + if (src->scratch == NULL) + { + ESP_LOGE(TAG, "no memory for wav scratch"); + audio_source_close(src); + return NULL; + } + src->scratch_frames = WAV_MAX_FRAMES; + + f_lseek(&src->wav_fp, src->data_offset); + + if (info != NULL) + { + *info = src->info; + } + + ESP_LOGI(TAG, "%s: WAV %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, + (unsigned)src->info.total_sec); + + return src; +} + +int audio_source_read(audio_source_t *src, void *out, int max_frames) +{ + if ((src == NULL) || (out == NULL) || (max_frames <= 0)) + { + return -1; + } + + if (src->codec == AUDIO_CODEC_FLAC) + { + int n = flac_dec_read(src->flac, out, max_frames); + + if (n > 0) + { + src->frames_read += (uint64_t)n; + } + + return n; + } + + return wav_read(src, out, max_frames); +} + +uint32_t audio_source_elapsed_sec(const audio_source_t *src) +{ + if ((src == NULL) || (src->info.sample_rate == 0)) + { + return 0; + } + + uint32_t sec = (uint32_t)(src->frames_read / src->info.sample_rate); + + if ((src->info.total_sec > 0) && (sec > src->info.total_sec)) + { + sec = src->info.total_sec; + } + + return sec; +} + +void audio_source_close(audio_source_t *src) +{ + if (src == NULL) + { + return; + } + + if (src->flac != NULL) + { + flac_dec_close(src->flac); + src->flac = NULL; + } + + if (src->wav_open) + { + f_close(&src->wav_fp); + src->wav_open = false; + } + + free(src->scratch); + src->scratch = NULL; + + free(src); +} diff --git a/main/APP/player/audio_source.h b/main/APP/player/audio_source.h new file mode 100644 index 0000000..333b8a3 --- /dev/null +++ b/main/APP/player/audio_source.h @@ -0,0 +1,86 @@ +/** + **************************************************************************************************** + * @file audio_source.h + * @author HiFi Player Project + * @version V1.5 + * @date 2026-01-01 + * @brief 统一音源接口(WAV / FLAC) + * + * 上层播放器只关心“给我一块立体声 PCM 和它对应的位宽”,不必区分容器格式: + * + * audio_source_open() ── 按文件头自动识别 RIFF(WAV) / fLaC、OggS(FLAC) + * │ 同时给出该曲目应使用的 I2S 位宽 out_bits(16/24/32) + * audio_source_read() ── 输出 **源文件原生位深** 的立体声交错 PCM + * │ 单声道自动复制;>2 声道只取前两声道;不做降位 + * audio_source_close() + * + * 时间进度统一用“已播放帧数 / 采样率”计算,对两种格式都准确。 + * + * 位宽映射(源位深 → I2S 位宽): + * ≤16bit → 16bit(每采样 2 字节) + * 17~24bit → 24bit(每采样 3 字节) + * 25~32bit → 32bit(每采样 4 字节) + * 非整字节位深(如 20bit)按左对齐放入更宽的槽位,音量不受影响。 + **************************************************************************************************** + */ + +#ifndef __AUDIO_SOURCE_H +#define __AUDIO_SOURCE_H + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** 支持的音频容器 */ +typedef enum { + AUDIO_CODEC_WAV = 0, /* RIFF/WAVE,PCM 16/24/32bit */ + AUDIO_CODEC_FLAC, /* 原生 FLAC 或 Ogg FLAC */ +} audio_codec_t; + +/** 音源信息 */ +typedef struct { + audio_codec_t codec; /* 容器类型 */ + uint32_t sample_rate; /* 采样率 → 用于配置 I2S 频率 */ + uint16_t bits; /* 源位深(界面显示用) */ + uint16_t channels; /* 源声道数 */ + uint16_t out_bits; /* I2S 输出位宽:16 / 24 / 32 */ + uint8_t out_bytes; /* 输出每采样字节数:2 / 3 / 4 */ + uint32_t total_sec; /* 总时长(秒),0 表示未知 */ +} audio_info_t; + +typedef struct audio_source audio_source_t; + +/** + * @brief 打开音频文件并解析头部 + * @param path 文件路径 + * @param info 输出音源信息(可为 NULL) + * @retval 句柄;失败返回 NULL + */ +audio_source_t *audio_source_open(const char *path, audio_info_t *info); + +/** + * @brief 读取一块 PCM + * @param src 音源句柄 + * @param out 输出缓冲区(立体声交错,位宽 = info.out_bits) + * @param max_frames 最多读取多少帧(1 帧 = 左右各 1 个采样) + * @retval >0 实际帧数 + * 0 播放结束 + * <0 解码/读取错误 + * @note 每帧占用 2 * out_bytes 字节,调用者需保证缓冲区足够 + */ +int audio_source_read(audio_source_t *src, void *out, int max_frames); + +/** @brief 已播放的秒数(按解码帧数计算) */ +uint32_t audio_source_elapsed_sec(const audio_source_t *src); + +/** @brief 关闭音源并释放资源(可传 NULL) */ +void audio_source_close(audio_source_t *src); + +#ifdef __cplusplus +} +#endif + +#endif /* __AUDIO_SOURCE_H */ diff --git a/main/APP/player/flac_dec.cpp b/main/APP/player/flac_dec.cpp new file mode 100644 index 0000000..595dbb1 --- /dev/null +++ b/main/APP/player/flac_dec.cpp @@ -0,0 +1,578 @@ +/** + **************************************************************************************************** + * @file flac_dec.cpp + * @author HiFi Player Project + * @version V1.5 + * @date 2026-01-01 + * @brief FLAC 解码器的 C++ 实现(对外暴露纯 C 接口,见 flac_dec.h) + * + * 数据流: + * FATFS 文件 ──f_read──▶ 8KB 码流缓冲 ──decode()──▶ 原生位深交错 PCM + * │ + * 单声道复制 / 多声道取前两声道 + * ▼ + * 立体声 + 原生位深(左对齐)→ I2S + * + * 位的处理: + * 库的 uint8_t 输出重载是“原生字节打包 + 左对齐”: + * 16bit → 2 字节、24bit → 3 字节、32bit → 4 字节, + * 20bit 这类非整字节位深则占 3 字节并填满其高 20 位。 + * 正好等价于 I2S 的 16/24/32bit 数据格式,所以这里不做任何降位, + * 上层按 out_bytes 配置 I2S 位宽即可获得无损通路。 + * + * 码流缓冲策略:未消费数据前移 + 追加读取,保证 NEED_MORE_DATA 时不丢数据, + * 并带“无进展”保护,坏文件不会死循环。 + **************************************************************************************************** + */ + +#include "flac_dec.h" + +#include +#include +#include + +#include "esp_heap_caps.h" +#include "esp_log.h" + +#include "micro_flac/flac_decoder.h" + +#include "ff.h" + +#define TAG "flac_dec" + +#define IN_BUF_SIZE (8 * 1024) /* 码流缓冲(压缩数据) */ +#define MAX_STALL 8 /* 连续无进展的容忍次数 */ + +struct flac_dec { + FIL fp; /* FATFS 文件对象 */ + bool fp_open; + + micro_flac::FLACDecoder *decoder; + + uint8_t *in_buf; /* 压缩码流缓冲 */ + size_t in_len; /* 缓冲内有效字节数 */ + size_t in_pos; /* 已消费位置 */ + bool in_eof; /* 文件已读完 */ + + uint8_t *pcm; /* 解码输出(源声道数 × 原生位深,交错) */ + size_t pcm_capacity; /* 缓冲字节数 */ + size_t pcm_bytes; /* 本帧有效字节数 */ + size_t pcm_pos; /* 已消费字节位置 */ + + uint8_t bps; /* 源每采样字节数 */ + uint8_t out_bytes; /* 输出每采样字节数(2/3/4) */ + + flac_info_t info; + bool header_ready; + bool stream_end; + bool failed; +}; + +/* ================================================================================================ + * 内部工具 + * ============================================================================================== */ + +/** + * @brief 按 STREAMINFO 的块大小分配解码输出缓冲(字节数) + * @retval true 成功 + */ +static bool ensure_pcm_buffer(flac_dec_t *d, size_t bytes) +{ + if (bytes <= d->pcm_capacity) + { + return true; + } + + /* 优先放 PSRAM,失败再退回内部 RAM */ + void *p = heap_caps_malloc(bytes, MALLOC_CAP_SPIRAM); + + if (p == NULL) + { + ESP_LOGW(TAG, "psram alloc failed (%u bytes), try internal", (unsigned)bytes); + p = malloc(bytes); + } + + if (p == NULL) + { + ESP_LOGE(TAG, "no memory for pcm buffer (%u bytes)", (unsigned)bytes); + return false; + } + + free(d->pcm); /* heap_caps_malloc 的指针可以用 free 释放 */ + d->pcm = (uint8_t *)p; + d->pcm_capacity = bytes; + + return true; +} + +/** + * @brief 把未消费的码流前移,并从文件追加新数据 + * @retval true 操作成功(不代表一定有新数据;看 in_len 是否增长) + * false 读文件失败 + */ +static bool feed_more(flac_dec_t *d) +{ + size_t remain = d->in_len - d->in_pos; + + if ((d->in_pos > 0) && (remain > 0)) + { + memmove(d->in_buf, d->in_buf + d->in_pos, remain); + } + + d->in_len = remain; + d->in_pos = 0; + + if (d->in_eof || (d->in_len >= IN_BUF_SIZE)) + { + return true; /* 文件读完或缓冲已满,只能靠现有数据 */ + } + + UINT br = 0; + FRESULT fr = f_read(&d->fp, d->in_buf + d->in_len, (UINT)(IN_BUF_SIZE - d->in_len), &br); + + if (fr != FR_OK) + { + ESP_LOGE(TAG, "f_read failed (%d)", (int)fr); + return false; + } + + d->in_len += br; + + if (br == 0) + { + d->in_eof = true; + } + + return true; +} + +/** @brief 是否还有可交给解码器的数据 */ +static inline bool has_input(const flac_dec_t *d) +{ + return d->in_pos < d->in_len; +} + +/** + * @brief 按源位深推导输出每采样字节数 + * @note 1 字节(8bit 及以下)统一扩展成 2 字节(左对齐), + * 其余直接沿用源字节数(2/3/4),这样 I2S 用 16/24/32bit 都能直接接收 + */ +static inline uint8_t pick_out_bytes(uint8_t bps) +{ + if (bps < 2) + { + return 2; + } + + if (bps > 4) + { + return 4; + } + + return bps; +} + +/** + * @brief 写一个输出采样(把源采样按需要补齐/复制) + * @param dst 输出位置(out_bytes 字节,小端,左对齐) + * @param src 源采样起点(bps 字节,小端,左对齐) + */ +static inline void write_sample(uint8_t *dst, const uint8_t *src, uint8_t bps, uint8_t out_bytes) +{ + if (bps == out_bytes) + { + memcpy(dst, src, out_bytes); + return; + } + + if (bps == 1) + { + /* 8bit 及以下:左对齐扩成 16bit(低字节补 0,高字节就是原样本) */ + dst[0] = 0; + dst[1] = src[0]; + + for (uint8_t i = 2; i < out_bytes; i++) + { + dst[i] = (src[0] & 0x80) ? 0xFF : 0x00; /* 符号扩展到更高的字节 */ + } + return; + } + + /* 理论上不会走到这里;保守处理为左对齐拷贝 */ + uint8_t n = (bps < out_bytes) ? bps : out_bytes; + + memset(dst, 0, out_bytes); + memcpy(dst + (out_bytes - n), src, n); +} + +/** + * @brief 把解码结果(源声道数)规整成 2 声道,写入调用者缓冲 + * @param frames 本次要输出的帧数 + * @retval 无 + */ +static void expand_frames(flac_dec_t *d, const uint8_t *src, uint8_t *dst, size_t frames) +{ + const size_t ch = d->info.channels; + const size_t bps = d->bps; + const size_t ob = d->out_bytes; + + if ((ch == 2) && (bps == ob)) + { + memcpy(dst, src, frames * 2 * ob); /* 最常见路径:立体声原生位深 */ + return; + } + + for (size_t i = 0; i < frames; i++) + { + const uint8_t *s = src + i * ch * bps; + uint8_t *o = dst + i * 2 * ob; + + write_sample(o, s, (uint8_t)bps, (uint8_t)ob); /* 左声道 = 声道 0 */ + + if (ch >= 2) + { + write_sample(o + ob, s + bps, (uint8_t)bps, (uint8_t)ob); /* 右声道 = 声道 1 */ + } + else + { + memcpy(o + ob, o, ob); /* 单声道复制 */ + } + } +} + +/* ================================================================================================ + * 对外接口 + * ============================================================================================== */ + +flac_dec_t *flac_dec_open(const char *path) +{ + if (path == NULL) + { + return NULL; + } + + flac_dec_t *d = new (std::nothrow) flac_dec(); + + if (d == NULL) + { + ESP_LOGE(TAG, "no memory for decoder"); + return NULL; + } + + memset(d, 0, sizeof(*d)); + + if (f_open(&d->fp, path, FA_READ) != FR_OK) + { + ESP_LOGE(TAG, "open %s failed", path); + flac_dec_close(d); + return NULL; + } + d->fp_open = true; + + d->decoder = new (std::nothrow) micro_flac::FLACDecoder(); + + if (d->decoder == NULL) + { + ESP_LOGE(TAG, "no memory for FLACDecoder"); + flac_dec_close(d); + return NULL; + } + + d->in_buf = (uint8_t *)malloc(IN_BUF_SIZE); + + if (d->in_buf == NULL) + { + ESP_LOGE(TAG, "no memory for input buffer"); + flac_dec_close(d); + return NULL; + } + + /* ---------------- 解析头部(STREAMINFO) ---------------- */ + int stall = 0; + + while (!d->header_ready) + { + if (!has_input(d) && !feed_more(d)) + { + flac_dec_close(d); + return NULL; + } + + if (!has_input(d)) + { + ESP_LOGE(TAG, "empty or truncated stream"); + flac_dec_close(d); + return NULL; + } + + size_t consumed = 0; + size_t samples = 0; + + /* 头部解析阶段不需要输出缓冲 */ + micro_flac::FLACDecoderResult res = + d->decoder->decode(d->in_buf + d->in_pos, d->in_len - d->in_pos, + (uint8_t *)NULL, 0, consumed, samples); + + d->in_pos += consumed; + + if (res == micro_flac::FLAC_DECODER_HEADER_READY) + { + const micro_flac::FLACStreamInfo &si = d->decoder->get_stream_info(); + + if (!si.is_valid() || (si.num_channels() < 1) || (si.num_channels() > 8) || + (si.max_block_size() == 0) || (si.bytes_per_sample() == 0) || + (si.bytes_per_sample() > 4)) + { + ESP_LOGE(TAG, "invalid stream info"); + flac_dec_close(d); + return NULL; + } + + d->info.sample_rate = si.sample_rate(); + d->info.channels = (uint8_t)si.num_channels(); + d->info.bits = (uint8_t)si.bits_per_sample(); + d->info.max_block_size = si.max_block_size(); + d->info.total_samples = si.total_samples_per_channel(); + + d->bps = (uint8_t)si.bytes_per_sample(); + d->out_bytes = pick_out_bytes(d->bps); + d->info.out_bytes = d->out_bytes; + + /* 解码输出缓冲按「最大块 × 声道数 × 源字节数」分配 */ + if (!ensure_pcm_buffer(d, (size_t)d->info.max_block_size * + d->info.channels * d->bps)) + { + flac_dec_close(d); + return NULL; + } + + d->header_ready = true; + stall = 0; + } + else if (res == micro_flac::FLAC_DECODER_NEED_MORE_DATA) + { + size_t before = d->in_len - d->in_pos; /* feed 之前的待解析字节数 */ + + if (!feed_more(d)) + { + flac_dec_close(d); + return NULL; + } + + /* 没有新数据可加(文件读完 / 缓冲已满)→ 头部不完整 */ + if ((d->in_len <= before) && d->in_eof) + { + ESP_LOGE(TAG, "truncated header"); + flac_dec_close(d); + return NULL; + } + + if (consumed == 0) + { + if (++stall > MAX_STALL) + { + ESP_LOGE(TAG, "header parse stalled"); + flac_dec_close(d); + return NULL; + } + } + else + { + stall = 0; + } + } + else if (res == micro_flac::FLAC_DECODER_END_OF_STREAM) + { + ESP_LOGE(TAG, "no audio in stream"); + flac_dec_close(d); + return NULL; + } + else + { + ESP_LOGE(TAG, "header parse failed (%d)", (int)res); + flac_dec_close(d); + return NULL; + } + } + + ESP_LOGI(TAG, "%s: %u Hz, %u bit (out %u byte, %u bit slot), %u ch, block=%u", + path, (unsigned)d->info.sample_rate, d->info.bits, + d->out_bytes, (unsigned)d->out_bytes * 8, + d->info.channels, (unsigned)d->info.max_block_size); + + return d; +} + +int flac_dec_get_info(flac_dec_t *dec, flac_info_t *info) +{ + if ((dec == NULL) || (info == NULL) || !dec->header_ready) + { + return -1; + } + + *info = dec->info; + return 0; +} + +int flac_dec_read(flac_dec_t *d, void *out, int max_frames) +{ + if ((d == NULL) || (out == NULL) || (max_frames <= 0)) + { + return -1; + } + + if (d->failed || !d->header_ready) + { + return -1; + } + + const size_t ch = d->info.channels; + const size_t bps = d->bps; + const size_t ob = d->out_bytes; + const size_t src_frame = ch * bps; /* 源每帧字节数 */ + + uint8_t *dst_base = (uint8_t *)out; + int written = 0; + int stall = 0; + + while (written < max_frames) + { + /* ---- 1. 先把已解码但没送出的帧搬走 ---- */ + if (d->pcm_pos < d->pcm_bytes) + { + size_t avail = (d->pcm_bytes - d->pcm_pos) / src_frame; /* 剩余帧数 */ + size_t need = (size_t)(max_frames - written); + size_t n = (avail < need) ? avail : need; + + if (n == 0) + { + d->pcm_pos = d->pcm_bytes; /* 丢弃不足一帧的残料 */ + continue; + } + + expand_frames(d, d->pcm + d->pcm_pos, dst_base + (size_t)written * 2 * ob, n); + + d->pcm_pos += n * src_frame; + written += (int)n; + continue; + } + + if (d->stream_end) + { + break; + } + + /* ---- 2. 码流用尽则补充 ---- */ + if (!has_input(d) && !feed_more(d)) + { + d->failed = true; + return (written > 0) ? written : -1; + } + + if (!has_input(d)) + { + d->stream_end = true; /* 文件读完且没有待解码数据 */ + break; + } + + /* ---- 3. 解一帧 ---- */ + size_t consumed = 0; + size_t samples = 0; + + micro_flac::FLACDecoderResult res = + d->decoder->decode(d->in_buf + d->in_pos, d->in_len - d->in_pos, + d->pcm, d->pcm_capacity, consumed, samples); + + d->in_pos += consumed; + + if (res == micro_flac::FLAC_DECODER_SUCCESS) + { + size_t bytes = samples * bps; + + if (bytes > d->pcm_capacity) + { + ESP_LOGE(TAG, "decoder overflow (%u > %u)", + (unsigned)bytes, (unsigned)d->pcm_capacity); + d->failed = true; + return (written > 0) ? written : -1; + } + + d->pcm_bytes = bytes; + d->pcm_pos = 0; + + /* 无进展保护:空帧且未消费数据 */ + if ((bytes == 0) && (consumed == 0)) + { + if (++stall > MAX_STALL) + { + ESP_LOGW(TAG, "decode stalled, stop stream"); + d->stream_end = true; + break; + } + } + else + { + stall = 0; + } + } + else if (res == micro_flac::FLAC_DECODER_HEADER_READY) + { + /* 正常不会二次出现;出现时忽略即可 */ + continue; + } + else if (res == micro_flac::FLAC_DECODER_NEED_MORE_DATA) + { + size_t before = d->in_len - d->in_pos; /* feed 之前的待解码字节数 */ + + if (!feed_more(d)) + { + d->failed = true; + return (written > 0) ? written : -1; + } + + /* 文件结束且没有新数据可加 → 剩余数据不足以解出完整帧,正常收尾 */ + if ((d->in_len <= before) && d->in_eof) + { + d->stream_end = true; + break; + } + } + else if (res == micro_flac::FLAC_DECODER_END_OF_STREAM) + { + d->stream_end = true; + break; + } + else + { + ESP_LOGE(TAG, "decode error (%d)", (int)res); + d->failed = true; + return (written > 0) ? written : -1; + } + } + + return written; +} + +void flac_dec_close(flac_dec_t *d) +{ + if (d == NULL) + { + return; + } + + if (d->fp_open) + { + f_close(&d->fp); + d->fp_open = false; + } + + delete d->decoder; + d->decoder = NULL; + + free(d->in_buf); + free(d->pcm); + d->in_buf = NULL; + d->pcm = NULL; + + delete d; +} diff --git a/main/APP/player/flac_dec.h b/main/APP/player/flac_dec.h new file mode 100644 index 0000000..143e830 --- /dev/null +++ b/main/APP/player/flac_dec.h @@ -0,0 +1,77 @@ +/** + **************************************************************************************************** + * @file flac_dec.h + * @author HiFi Player Project + * @version V1.5 + * @date 2026-01-01 + * @brief FLAC 解码器 C 接口封装 + * + * 底层使用 esphome/micro-flac(ESP32-S3 带 Xtensa 汇编优化的 FLAC 解码器), + * 这里用一层很薄的 C++ 包装,把接口暴露成纯 C,方便 player.c 直接调用。 + * + * 输出策略(V1.5 起): + * **保留源文件的位深**,不再统一压到 16bit。 + * 库输出的是“原生字节打包 + 左对齐”(16bit→2 字节,24bit→3 字节,32bit→4 字节, + * 20bit 这类非整字节位深则填满其 3 字节的高 20 位),正好符合 I2S 的要求, + * 因此上层可以按 out_bytes 配置 I2S/ES8388 的位宽,实现真正的无损通路。 + * + * 声道处理:单声道复制成左右;多于 2 声道时只取前两声道。 + **************************************************************************************************** + */ + +#ifndef __FLAC_DEC_H +#define __FLAC_DEC_H + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +typedef struct flac_dec flac_dec_t; + +/** FLAC 流信息 */ +typedef struct { + uint32_t sample_rate; /* 采样率 */ + uint8_t channels; /* 源声道数(1~8) */ + uint8_t bits; /* 源位深(4~32) */ + uint8_t out_bytes; /* 输出每采样字节数:2 / 3 / 4(对应 I2S 16/24/32bit) */ + uint32_t max_block_size; /* 最大块大小(每声道采样数) */ + uint64_t total_samples; /* 每声道总采样数,0 表示文件未记录 */ +} flac_info_t; + +/** + * @brief 打开 FLAC 文件并解析 STREAMINFO + * @param path 文件路径(如 "0:/MUSIC/a.flac") + * @retval 句柄;失败返回 NULL + * @note 返回非 NULL 时 info 已经可用(见 flac_dec_get_info) + */ +flac_dec_t *flac_dec_open(const char *path); + +/** + * @brief 获取流信息(仅在 flac_dec_open 成功后有效) + * @retval 0 成功 + */ +int flac_dec_get_info(flac_dec_t *dec, flac_info_t *info); + +/** + * @brief 读取一块 PCM 数据 + * @param dec 解码器句柄 + * @param out 输出缓冲区,写入 **立体声交错** 的原生位深 PCM(左对齐) + * @param max_frames 最多输出多少帧(1 帧 = 左右各 1 个采样) + * @retval >0 实际写入的帧数 + * 0 文件结束 + * <0 解码错误 + * @note 每帧占用 2 * out_bytes 字节,调用者需保证缓冲区足够 + */ +int flac_dec_read(flac_dec_t *dec, void *out, int max_frames); + +/** @brief 关闭并释放所有资源(可传 NULL) */ +void flac_dec_close(flac_dec_t *dec); + +#ifdef __cplusplus +} +#endif + +#endif /* __FLAC_DEC_H */ diff --git a/main/APP/player/player.c b/main/APP/player/player.c new file mode 100644 index 0000000..d4f2413 --- /dev/null +++ b/main/APP/player/player.c @@ -0,0 +1,770 @@ +/** + **************************************************************************************************** + * @file player.c + * @author HiFi Player Project + * @version V1.4 + * @date 2026-01-01 + * @brief HiFi 播放器核心实现(WAV / FLAC 播放 + I2S 输出) + * + * 与其他模块的关系: + * - 按键由 ui.c 统一消费,再通过 player_request() 投递动作请求到本模块(位标记,无阻塞)。 + * - 播放状态写入 s_state(互斥锁保护),播放器界面只读取快照。 + * - 只有处于 APP_MODE_PLAYER 时才输出音频;离开该模式自动关闭 I2S 并保留播放位置。 + * + * 音频格式支持由 audio_source.c 统一处理: + * WAV —— 线性 PCM 16/24/32bit(无需解码库,直接取高位) + * FLAC —— 原生 FLAC / Ogg FLAC(esphome/micro-flac 解码) + * 两者对外都输出 16bit 立体声交错 PCM,因此本文件的播放循环与格式无关。 + **************************************************************************************************** + */ + +#include "player.h" + +#include +#include +#include + +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "freertos/semphr.h" + +#include "esp_heap_caps.h" +#include "esp_log.h" +#include "nvs.h" + +#include "ff.h" +#include "spi_sd.h" + +#include "audio_source.h" + +#include "es8388.h" +#include "myi2s.h" +#include "xl9555.h" +#include "led.h" + +#define TAG "player" + +/* ---------------- 播放器参数 ---------------- */ +#define MUSIC_DIR "0:/MUSIC" /* SD 卡音乐目录 */ +#define MAX_TRACKS 200 /* 最多扫描的曲目数 */ + +#define AUDIO_CHUNK_FRAMES 1024 /* 每次送给 I2S 的帧数(约 23ms @44.1kHz) */ +#define AUDIO_BUF_SIZE (AUDIO_CHUNK_FRAMES * 8) /* 32bit 立体声 = 8 字节/帧 */ + +#define POLL_MS 20 /* 挂起/暂停时的轮询周期 */ +#define TASK_STACK_SIZE (6 * 1024) +#define TASK_PRIORITY 6 /* 高于 LVGL 任务,保证音频不断流 */ + +/* ---------------- 内部类型 ---------------- */ +typedef struct { + char path[300]; /* 完整路径:0:/MUSIC/xxx.wav|flac */ + char name[64]; /* 显示名(去扩展名、仅 ASCII) */ +} track_info_t; + +typedef enum { + TRACK_RESULT_END = 0, /* 正常播完 */ + TRACK_RESULT_NEXT, /* 用户按了下一曲 / 文件不支持 */ + TRACK_RESULT_ERROR, /* 文件 / SD 卡错误 */ +} track_result_t; + +/* ---------------- 全局状态 ---------------- */ +static player_state_t s_state; +static SemaphoreHandle_t s_lock = NULL; + +static track_info_t *s_tracks = NULL; +static uint8_t *s_audio_buf = NULL; +static bool s_audio_ready = false; + +/* 动作请求:UI 任务写入,播放任务读取(位标记累积) */ +static portMUX_TYPE s_req_lock = portMUX_INITIALIZER_UNLOCKED; +static volatile uint32_t s_requests = 0; + +static const uint8_t s_vol_steps[] = {20, 40, 60, 80, 100}; +#define VOL_STEP_NUM (sizeof(s_vol_steps) / sizeof(s_vol_steps[0])) +static uint8_t s_vol_percent = 60; + +/* ---------------- 状态访问(互斥锁) ---------------- */ +static void state_lock(void) +{ + if (s_lock != NULL) + { + xSemaphoreTake(s_lock, portMAX_DELAY); + } +} + +static void state_unlock(void) +{ + if (s_lock != NULL) + { + xSemaphoreGive(s_lock); + } +} + +static void state_set_message(player_status_t status, const char *message) +{ + state_lock(); + s_state.status = status; + if (message != NULL) + { + strncpy(s_state.message, message, sizeof(s_state.message) - 1); + s_state.message[sizeof(s_state.message) - 1] = '\0'; + } + state_unlock(); +} + +static void state_clear_track(void) +{ + state_lock(); + s_state.track_name[0] = '\0'; + s_state.codec[0] = '\0'; + s_state.track_index = 0; + s_state.track_total = 0; + s_state.cur_sec = 0; + s_state.tot_sec = 0; + s_state.samplerate = 0; + s_state.bits = 0; + s_state.channels = 0; + s_state.bitrate = 0; + state_unlock(); +} + +/* ---------------- 动作请求 ---------------- */ +static uint32_t take_requests(void) +{ + uint32_t reqs; + + taskENTER_CRITICAL(&s_req_lock); + reqs = s_requests; + s_requests = 0; + taskEXIT_CRITICAL(&s_req_lock); + + return reqs; +} + +void player_request(player_request_t req) +{ + if ((unsigned)req >= PLAYER_REQ_NUM) + { + return; + } + + taskENTER_CRITICAL(&s_req_lock); + s_requests |= (1u << (uint32_t)req); + taskEXIT_CRITICAL(&s_req_lock); +} + +/* ---------------- 音量 ---------------- */ +static void volume_apply(void) +{ + uint32_t reg = (uint32_t)s_vol_percent * 33 / 100; + + if (reg > 33) + { + reg = 33; + } + + if (s_audio_ready) + { + es8388_hpvol_set((uint8_t)reg); /* 耳机音量 */ + es8388_spkvol_set((uint8_t)reg); /* 喇叭音量 */ + } + + state_lock(); + s_state.volume = s_vol_percent; + state_unlock(); +} + +static void volume_load(void) +{ + nvs_handle_t handle; + uint8_t value = 0; + + if (nvs_open("hifi", NVS_READONLY, &handle) == ESP_OK) + { + if ((nvs_get_u8(handle, "vol", &value) == ESP_OK) && (value <= 100)) + { + s_vol_percent = value; + } + nvs_close(handle); + } +} + +static void volume_save(void) +{ + nvs_handle_t handle; + + if (nvs_open("hifi", NVS_READWRITE, &handle) == ESP_OK) + { + nvs_set_u8(handle, "vol", s_vol_percent); + nvs_commit(handle); + nvs_close(handle); + } +} + +static void volume_next(void) +{ + uint32_t index = 0; + + for (uint32_t i = 0; i < VOL_STEP_NUM; i++) + { + if (s_vol_percent == s_vol_steps[i]) + { + index = i; + break; + } + } + + index = (index + 1) % VOL_STEP_NUM; + s_vol_percent = s_vol_steps[index]; + + volume_apply(); + volume_save(); + + ESP_LOGI(TAG, "volume -> %u%%", s_vol_percent); +} + +/* ---------------- 音频硬件 ---------------- */ +static bool audio_init(void) +{ + if (es8388_init() != 0) + { + ESP_LOGE(TAG, "ES8388 init failed"); + return false; + } + + s_audio_ready = true; + + es8388_adda_cfg(1, 0); /* 打开 DAC,关闭 ADC */ + es8388_input_cfg(0); /* 关闭录音输入 */ + es8388_output_cfg(1, 1); /* 耳机 + 喇叭通道都打开 */ + es8388_3d_set(0); /* 关闭 3D 环绕 */ + es8388_i2s_cfg(0, 3); /* Philips I2S,16bit */ + + volume_apply(); + + if (myi2s_init() != ESP_OK) + { + return false; + } + + i2s_trx_stop(); /* 空闲时先关闭通道 */ + xl9555_pin_write(SPK_EN_IO, 0); /* 打开板载喇叭功放(低电平有效) */ + vTaskDelay(pdMS_TO_TICKS(50)); + + return true; +} + +/** + * @brief 按源文件的采样率与位深配置 I2S / ES8388 + * @param sample_rate 采样率(Hz) + * @param bits I2S 输出位宽:16 / 24 / 32 + * @note 位宽不同会重新配置 I2S 的 data_bit_width / ws_width 与 ES8388 的 DAC 字长, + * 因此 16bit、24bit、32bit 文件之间切换都是“真正按格式输出”,不做降位。 + */ +static void audio_set_format(uint32_t sample_rate, uint16_t bits) +{ + uint8_t es_len; /* ES8388 R23 的数据字长字段 */ + + switch (bits) + { + case 24: + es_len = 0; /* 24bit */ + break; + + case 32: + es_len = 4; /* 32bit */ + break; + + case 16: + default: + es_len = 3; /* 16bit */ + bits = 16; + break; + } + + es8388_i2s_cfg(0, es_len); /* Philips I2S + 对应字长 */ + i2s_set_samplerate_bits_sample((int)sample_rate, (int)bits); + /* 通道保持关闭,由播放循环在真正要出声时再 i2s_trx_start() */ + + ESP_LOGI(TAG, "audio: %u Hz / %u bit", (unsigned)sample_rate, bits); +} + +/* ---------------- 曲目扫描 ---------------- */ +/** + * @brief 判断是否为可播放的音频文件(.wav / .flac) + */ +static bool name_is_audio(const char *name) +{ + size_t len = strlen(name); + + if (len < 5) + { + return false; + } + + const char *ext = name + len - 4; /* 指向 ".xxx" */ + + if (ext[0] != '.') + { + return false; + } + + /* .wav */ + if (((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; + + 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; + } + } + + return false; +} + +/** + * @brief 生成用于 LVGL 显示的曲目名(去掉扩展名,非 ASCII 字符用 '?' 代替) + */ +static void make_display_name(const char *fname, char *out, size_t out_size) +{ + size_t len = strlen(fname); + size_t end = len; + size_t j = 0; + bool last_unknown = false; + + for (size_t i = len; i > 0; i--) + { + if (fname[i - 1] == '.') + { + end = i - 1; + break; + } + } + + for (size_t i = 0; (i < end) && (j + 1 < out_size); i++) + { + unsigned char c = (unsigned char)fname[i]; + + if ((c >= 0x20) && (c < 0x7F)) + { + out[j++] = (char)c; + last_unknown = (c == '?'); + } + else if (!last_unknown) + { + /* 中文文件名是 GBK 编码,LVGL 内置字体无法显示,统一用 '?' 占位 */ + out[j++] = '?'; + last_unknown = true; + } + } + + out[j] = '\0'; +} + +static int playlist_scan(void) +{ + FF_DIR dir; + FILINFO *info = NULL; + FRESULT res; + int count = 0; + + if (s_tracks == NULL) + { + return 0; + } + + info = (FILINFO *)malloc(sizeof(FILINFO)); + if (info == NULL) + { + ESP_LOGE(TAG, "no memory for FILINFO"); + return 0; + } + + res = f_opendir(&dir, MUSIC_DIR); + if (res == FR_OK) + { + while (count < MAX_TRACKS) + { + res = f_readdir(&dir, info); + if ((res != FR_OK) || (info->fname[0] == '\0')) + { + break; + } + + if (info->fattrib & AM_DIR) + { + continue; + } + + if (!name_is_audio(info->fname)) + { + continue; + } + + snprintf(s_tracks[count].path, sizeof(s_tracks[count].path), + "%s/%s", MUSIC_DIR, info->fname); + make_display_name(info->fname, s_tracks[count].name, sizeof(s_tracks[count].name)); + count++; + } + + f_closedir(&dir); + } + else + { + ESP_LOGW(TAG, "open %s failed (%d)", MUSIC_DIR, res); + } + + free(info); + + state_lock(); + s_state.track_total = (int16_t)count; + state_unlock(); + + ESP_LOGI(TAG, "found %d audio file(s) in %s", count, MUSIC_DIR); + + return count; +} + +/* ---------------- 单曲播放 ---------------- */ +/** + * @brief 播放一首曲子(WAV / FLAC 通用) + * @retval TRACK_RESULT_END 正常播完 + * TRACK_RESULT_NEXT 需要跳到下一首(用户操作或不支持的文件) + * TRACK_RESULT_ERROR 文件系统 / SD 卡错误 + */ +static track_result_t play_track(int index) +{ + audio_source_t *src = NULL; + audio_info_t info; + track_result_t result = TRACK_RESULT_END; + bool paused = false; + bool audio_running = false; + bool active = (app_get_mode() == APP_MODE_PLAYER); /* 是否停留在播放器界面 */ + uint32_t last_sec = 0xFFFFFFFF; + + if ((s_audio_buf == NULL) || (s_tracks == NULL) || + (index < 0) || (index >= MAX_TRACKS) || (s_tracks[index].path[0] == '\0')) + { + return TRACK_RESULT_ERROR; + } + + memset(&info, 0, sizeof(info)); + + src = audio_source_open(s_tracks[index].path, &info); + + if (src == NULL) + { + /* 不支持的格式或文件损坏:跳过,继续下一首 */ + ESP_LOGW(TAG, "cannot open %s", s_tracks[index].path); + state_set_message(PLAYER_STATUS_ERROR, "UNSUPPORTED FILE"); + return TRACK_RESULT_NEXT; + } + + /* ---------------- 更新界面状态 ---------------- */ + state_lock(); + s_state.track_index = (int16_t)index; + strncpy(s_state.track_name, s_tracks[index].name, sizeof(s_state.track_name) - 1); + s_state.track_name[sizeof(s_state.track_name) - 1] = '\0'; + snprintf(s_state.codec, sizeof(s_state.codec), "%s", + (info.codec == AUDIO_CODEC_FLAC) ? "FLAC" : "WAV"); + s_state.samplerate = (uint16_t)info.sample_rate; + s_state.bits = (uint8_t)info.bits; + s_state.i2s_bits = (uint8_t)info.out_bits; + s_state.channels = (uint8_t)info.channels; + s_state.bitrate = (uint32_t)info.sample_rate * info.channels * info.out_bits; + s_state.cur_sec = 0; + s_state.tot_sec = info.total_sec; + s_state.status = active ? PLAYER_STATUS_PLAYING : PLAYER_STATUS_PAUSED; + strncpy(s_state.message, active ? "PLAYING" : "READY", sizeof(s_state.message) - 1); + s_state.message[sizeof(s_state.message) - 1] = '\0'; + state_unlock(); + + ESP_LOGI(TAG, "load [%d] %s (%s %u Hz, %u bit -> I2S %u bit, %u ch, %u s)", + index + 1, s_tracks[index].name, + (info.codec == AUDIO_CODEC_FLAC) ? "FLAC" : "WAV", + (unsigned)info.sample_rate, info.bits, info.out_bits, + info.channels, (unsigned)info.total_sec); + + /* ---------------- 配置音频(按源文件采样率 + 原生位深) ---------------- */ + audio_set_format(info.sample_rate, info.out_bits); + + while (1) + { + /* ---- 1. 只有停留在播放器界面才输出音频 ---- */ + if (app_get_mode() != APP_MODE_PLAYER) + { + if (audio_running) + { + i2s_trx_stop(); + audio_running = false; + } + + if (active) /* 刚从播放器返回主界面 */ + { + active = false; + LED0(0); + state_set_message(PLAYER_STATUS_PAUSED, "PAUSED"); + } + + vTaskDelay(pdMS_TO_TICKS(POLL_MS)); /* 后台挂起,不占用 CPU */ + continue; + } + + if (!active) /* 从主界面重新进入播放器 */ + { + active = true; + paused = false; + take_requests(); /* 丢弃进入播放器前残留的请求 */ + state_set_message(PLAYER_STATUS_PLAYING, "PLAYING"); + } + + /* ---- 2. 处理 UI 投递的请求 ---- */ + uint32_t reqs = take_requests(); + bool quit_track = false; + + if (reqs & (1u << PLAYER_REQ_NEXT)) + { + result = TRACK_RESULT_NEXT; + quit_track = true; + } + + if (!quit_track && (reqs & (1u << PLAYER_REQ_PLAY_PAUSE))) + { + paused = !paused; + state_set_message(paused ? PLAYER_STATUS_PAUSED : PLAYER_STATUS_PLAYING, + paused ? "PAUSED" : "PLAYING"); + } + + if (!quit_track && (reqs & (1u << PLAYER_REQ_VOLUME))) + { + volume_next(); + } + + if (quit_track) + { + break; + } + + if (app_get_mode() != APP_MODE_PLAYER) + { + continue; /* 已切回主界面 */ + } + + /* ---- 3. 暂停时不输出音频,但保持解码位置 ---- */ + bool should_run = !paused; + + if (should_run && !audio_running) + { + i2s_trx_start(); + audio_running = true; + LED0(1); + } + else if (!should_run && audio_running) + { + i2s_trx_stop(); + audio_running = false; + LED0(0); + } + + if (!should_run) + { + vTaskDelay(pdMS_TO_TICKS(POLL_MS)); + continue; + } + + /* ---- 4. 解一块 PCM(WAV 直读 / FLAC 解码,立体声 + 源文件原生位深) ---- */ + int frames = audio_source_read(src, (void *)s_audio_buf, AUDIO_CHUNK_FRAMES); + + if (frames < 0) + { + ESP_LOGE(TAG, "decode/read error"); + result = TRACK_RESULT_ERROR; + break; + } + + if (frames == 0) + { + result = TRACK_RESULT_END; + break; + } + + /* 每帧 = 左右声道 × out_bytes 字节(16bit→4,24bit→6,32bit→8) */ + i2s_tx_write(s_audio_buf, (uint32_t)frames * 2u * info.out_bytes); + + /* ---- 5. 更新播放进度 ---- */ + uint32_t sec = audio_source_elapsed_sec(src); + + if (sec != last_sec) + { + last_sec = sec; + state_lock(); + s_state.cur_sec = sec; + state_unlock(); + } + } + + /* 关闭 I2S,避免切歌/退出时产生杂音 */ + if (audio_running) + { + i2s_trx_stop(); + } + LED0(0); + + audio_source_close(src); + + return result; +} + +/* ---------------- 播放器主任务 ---------------- */ +static void player_task(void *arg) +{ + bool sd_ready = false; + bool audio_ok = false; + int index = 0; + + (void)arg; + + audio_ok = audio_init(); + if (!audio_ok) + { + state_set_message(PLAYER_STATUS_ERROR, "AUDIO INIT FAILED"); + } + + s_audio_buf = (uint8_t *)heap_caps_malloc(AUDIO_BUF_SIZE, MALLOC_CAP_DMA | MALLOC_CAP_INTERNAL); + s_tracks = (track_info_t *)calloc(MAX_TRACKS, sizeof(track_info_t)); + + if ((s_audio_buf == NULL) || (s_tracks == NULL)) + { + ESP_LOGE(TAG, "no memory for player buffers"); + state_set_message(PLAYER_STATUS_ERROR, "OUT OF MEMORY"); + vTaskDelete(NULL); + return; + } + + ESP_LOGI(TAG, "player task started"); + + while (1) + { + /* ---------------- 0. 空闲时也响应音量(不依赖 SD 卡) ---------------- */ + uint32_t idle_reqs = take_requests(); + if (idle_reqs & (1u << PLAYER_REQ_VOLUME)) + { + volume_next(); + } + + /* ---------------- 1. 音频硬件异常 ---------------- */ + if (!audio_ok) + { + state_set_message(PLAYER_STATUS_ERROR, "AUDIO INIT FAILED"); + vTaskDelay(pdMS_TO_TICKS(2000)); + continue; + } + + /* ---------------- 2. 挂载 SD 卡 ---------------- */ + if (!sd_ready) + { + state_set_message(PLAYER_STATUS_BOOT, "MOUNTING SD CARD..."); + + if (sd_spi_init() == ESP_OK) + { + sd_ready = true; + ESP_LOGI(TAG, "sd card mounted"); + } + else + { + state_clear_track(); + state_set_message(PLAYER_STATUS_NO_SD, "NO SD CARD"); + vTaskDelay(pdMS_TO_TICKS(1000)); + continue; + } + } + + /* ---------------- 3. 扫描曲目 ---------------- */ + int total = playlist_scan(); + + if (total == 0) + { + state_clear_track(); + state_set_message(PLAYER_STATUS_NO_MUSIC, "PUT WAV/FLAC IN /MUSIC"); + vTaskDelay(pdMS_TO_TICKS(1500)); + continue; + } + + if (index >= total) + { + index = 0; + } + + /* ---------------- 4. 播放当前曲目 ---------------- */ + track_result_t result = play_track(index); + + if ((result == TRACK_RESULT_NEXT) || (result == TRACK_RESULT_END)) + { + index = (index + 1) % total; /* 循环播放列表(没有上一首) */ + } + else + { + /* 文件系统 / SD 卡异常:重新挂载 */ + sd_ready = false; + state_clear_track(); + state_set_message(PLAYER_STATUS_NO_SD, "SD CARD ERROR"); + vTaskDelay(pdMS_TO_TICKS(500)); + } + } +} + +/* ---------------- 对外接口 ---------------- */ +void player_start(void) +{ + if (s_lock == NULL) + { + s_lock = xSemaphoreCreateMutex(); + if (s_lock == NULL) + { + ESP_LOGE(TAG, "create mutex failed"); + } + } + + volume_load(); + + memset(&s_state, 0, sizeof(s_state)); + s_state.status = PLAYER_STATUS_BOOT; + s_state.volume = s_vol_percent; + snprintf(s_state.message, sizeof(s_state.message), "STARTING..."); + + if (xTaskCreate(player_task, "player", TASK_STACK_SIZE, NULL, TASK_PRIORITY, NULL) != pdPASS) + { + ESP_LOGE(TAG, "create player task failed"); + } +} + +void player_get_state(player_state_t *out) +{ + if (out == NULL) + { + return; + } + + if ((s_lock != NULL) && (xSemaphoreTake(s_lock, pdMS_TO_TICKS(50)) == pdTRUE)) + { + *out = s_state; + xSemaphoreGive(s_lock); + } + else + { + *out = s_state; + } +} diff --git a/main/APP/player/player.h b/main/APP/player/player.h new file mode 100644 index 0000000..4e25411 --- /dev/null +++ b/main/APP/player/player.h @@ -0,0 +1,87 @@ +/** + **************************************************************************************************** + * @file player.h + * @author HiFi Player Project + * @version V1.2 + * @date 2026-01-01 + * @brief HiFi 播放器核心:SD 卡曲目扫描、WAV 解码、I2S 输出 + * @note 按键由 ui.c 统一消费后通过 player_request() 转发进来;本模块不直接读按键。 + * 播放器只在 APP_MODE_PLAYER 模式下输出音频;离开该模式会自动暂停并保留进度。 + **************************************************************************************************** + */ + +#ifndef __PLAYER_H +#define __PLAYER_H + +#include +#include + +#include "app.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/* ---------------- 播放器状态 ---------------- */ +typedef enum { + PLAYER_STATUS_BOOT = 0, /* 正在初始化 / 挂载 SD 卡 */ + PLAYER_STATUS_NO_SD, /* 没有检测到 SD 卡 */ + PLAYER_STATUS_NO_MUSIC, /* SD 卡中没有可播放的 WAV 文件 */ + PLAYER_STATUS_PLAYING, /* 正在播放 */ + PLAYER_STATUS_PAUSED, /* 已暂停 */ + PLAYER_STATUS_ERROR, /* 音频硬件 / 文件系统错误 */ +} player_status_t; + +/* ---------------- 供 UI 读取的状态快照 ---------------- */ +typedef struct { + player_status_t status; /* 当前状态 */ + uint8_t volume; /* 音量百分比 0~100 */ + + int16_t track_index; /* 当前曲目序号(0 基) */ + int16_t track_total; /* 曲目总数 */ + uint32_t cur_sec; /* 当前播放秒数 */ + uint32_t tot_sec; /* 曲目总秒数 */ + + uint16_t samplerate; /* 采样率 */ + uint8_t bits; /* 源位深(16/24/32,界面显示用) */ + uint8_t i2s_bits; /* 实际 I2S 输出位宽(16/24/32) */ + uint8_t channels; /* 声道数 */ + uint32_t bitrate; /* 比特率,单位 bps */ + + char track_name[64]; /* 曲目显示名(已去掉扩展名,仅保留 ASCII) */ + char codec[8]; /* 容器格式:"WAV" / "FLAC" */ + char message[64]; /* 状态提示信息 */ +} player_state_t; + +/* ---------------- 播放器可接收的请求 ---------------- */ +typedef enum { + PLAYER_REQ_PLAY_PAUSE = 0, /* 播放 / 暂停 */ + PLAYER_REQ_NEXT, /* 下一曲 */ + PLAYER_REQ_VOLUME, /* 音量切档 */ + PLAYER_REQ_NUM, +} player_request_t; + +/** + * @brief 启动播放器(创建播放任务) + * @note 调用前必须完成 NVS / SPI / IIC / XL9555 / LCD 初始化 + */ +void player_start(void); + +/** + * @brief 获取播放器状态快照(线程安全,供 LVGL 任务调用) + * @param out 输出缓冲区 + */ +void player_get_state(player_state_t *out); + +/** + * @brief 向播放任务投递一个动作请求(可在任意任务中调用,不阻塞) + * @param req 请求类型 + * @note 请求是位标记累积的,播放任务下次循环时统一处理 + */ +void player_request(player_request_t req); + +#ifdef __cplusplus +} +#endif + +#endif /* __PLAYER_H */ diff --git a/main/APP/player/ui_player.c b/main/APP/player/ui_player.c new file mode 100644 index 0000000..a68e13b --- /dev/null +++ b/main/APP/player/ui_player.c @@ -0,0 +1,323 @@ +/** + **************************************************************************************************** + * @file ui_player.c + * @author HiFi Player Project + * @version V1.2 + * @date 2026-01-01 + * @brief 播放器界面 + * + * ┌──────────────────────────┐ + * │ ♪ HiFi PLAYER 01/12 │ + * ├──────────────────────────┤ + * │ ┌────┐ 歌名 │ + * │ │ ♪ │ WAV 44100Hz 16bit │ + * │ └────┘ ( PLAYING ) │ + * │ 00:42 ────●────── 03:35 │ + * │ 🔊 ──────●───── 60% │ + * ├──────────────────────────┤ + * │ [←K0] [⏯K1] [⏭K2] [🔊K3] │ + * │ BACK PLAY NEXT VOL │ + * └──────────────────────────┘ + **************************************************************************************************** + */ + +#include "ui_player.h" + +#include +#include + +#include "ui_common.h" +#include "app.h" +#include "player.h" + +/* ---------------- 控件 ---------------- */ +static lv_obj_t *s_scr = NULL; +static lv_obj_t *s_lbl_index = NULL; +static lv_obj_t *s_lbl_name = NULL; +static lv_obj_t *s_lbl_format = NULL; +static lv_obj_t *s_lbl_chip = NULL; +static lv_obj_t *s_bar_prog = NULL; +static lv_obj_t *s_lbl_cur = NULL; +static lv_obj_t *s_lbl_tot = NULL; +static lv_obj_t *s_bar_vol = NULL; +static lv_obj_t *s_lbl_vol = NULL; + +static ui_key_bar_t s_bar; +static ui_flash_t s_flash; +static uint32_t s_chip_color = 0xFFFFFFFF; + +/* ---------------- 按键提示内容 ---------------- */ +static const char *const s_symbols[APP_KEY_NUM] = { + LV_SYMBOL_LEFT, /* K0:返回主界面 */ + LV_SYMBOL_PLAY, /* K1:播放 / 暂停(图标随状态切换) */ + LV_SYMBOL_NEXT, /* K2:下一曲 */ + LV_SYMBOL_VOLUME_MAX, /* K3:音量 */ +}; + +static const char *const s_captions[APP_KEY_NUM] = { + "K0 BACK", + "K1 PLAY", + "K2 NEXT", + "K3 VOL", +}; + +static const bool s_enabled[APP_KEY_NUM] = { true, true, true, true }; + +/* ---------------- 状态显示 ---------------- */ + +static const char *status_text(player_status_t status, const char *message) +{ + switch (status) + { + case PLAYER_STATUS_PLAYING: + return "PLAYING"; + case PLAYER_STATUS_PAUSED: + return (message && message[0]) ? message : "PAUSED"; + case PLAYER_STATUS_NO_SD: + return (message && message[0]) ? message : "NO SD CARD"; + case PLAYER_STATUS_NO_MUSIC: + return (message && message[0]) ? message : "NO MUSIC"; + case PLAYER_STATUS_ERROR: + return (message && message[0]) ? message : "ERROR"; + case PLAYER_STATUS_BOOT: + default: + return (message && message[0]) ? message : "STARTING..."; + } +} + +static uint32_t status_color(player_status_t status) +{ + switch (status) + { + case PLAYER_STATUS_PLAYING: + return UI_COL_OK; + case PLAYER_STATUS_PAUSED: + return UI_COL_WARN; + case PLAYER_STATUS_NO_SD: + case PLAYER_STATUS_ERROR: + return UI_COL_ERR; + case PLAYER_STATUS_NO_MUSIC: + return UI_COL_WARN; + case PLAYER_STATUS_BOOT: + default: + return UI_COL_SUB; + } +} + +/* ---------------- 创建界面 ---------------- */ + +void ui_player_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, 30, UI_COL_PANEL, 0); + + lv_obj_t *title = ui_label(header, LV_SYMBOL_AUDIO " HiFi PLAYER", + &lv_font_montserrat_16, UI_COL_ACCENT); + lv_obj_align(title, LV_ALIGN_LEFT_MID, 10, 0); + + s_lbl_index = ui_label(header, "--/--", &lv_font_montserrat_14, UI_COL_SUB); + lv_obj_align(s_lbl_index, LV_ALIGN_RIGHT_MID, -10, 0); + + /* ---------------- 封面卡片 ---------------- */ + lv_obj_t *cover = ui_card(s_scr, 10, 36, 92, 92, 0x283A6B, 12); + + lv_obj_set_style_bg_grad_color(cover, lv_color_hex(UI_COL_ACCENT2), LV_PART_MAIN); + lv_obj_set_style_bg_grad_dir(cover, LV_GRAD_DIR_VER, LV_PART_MAIN); + lv_obj_set_style_shadow_width(cover, 18, LV_PART_MAIN); + lv_obj_set_style_shadow_color(cover, lv_color_hex(UI_COL_ACCENT2), LV_PART_MAIN); + lv_obj_set_style_shadow_opa(cover, LV_OPA_30, LV_PART_MAIN); + + lv_obj_t *note = ui_label(cover, LV_SYMBOL_AUDIO, &lv_font_montserrat_40, 0xFFFFFF); + lv_obj_center(note); + + /* ---------------- 曲目信息 ---------------- */ + s_lbl_name = ui_label(s_scr, "No media", &lv_font_montserrat_16, UI_COL_TEXT); + lv_obj_set_pos(s_lbl_name, 112, 36); + lv_obj_set_size(s_lbl_name, 198, 20); + lv_label_set_long_mode(s_lbl_name, LV_LABEL_LONG_SCROLL_CIRCULAR); + + s_lbl_format = ui_label(s_scr, "WAV / PCM", &lv_font_montserrat_12, UI_COL_SUB); + lv_obj_set_pos(s_lbl_format, 112, 60); + + lv_obj_t *chip = ui_card(s_scr, 112, 82, LV_SIZE_CONTENT, 22, UI_COL_CARD, 11); + lv_obj_set_style_pad_hor(chip, 8, LV_PART_MAIN); + lv_obj_set_style_border_width(chip, 1, LV_PART_MAIN); + lv_obj_set_style_border_color(chip, lv_color_hex(UI_COL_TRACK), LV_PART_MAIN); + + s_lbl_chip = ui_label(chip, "STARTING...", &lv_font_montserrat_12, UI_COL_SUB); + lv_obj_center(s_lbl_chip); + + /* ---------------- 播放进度 ---------------- */ + s_bar_prog = lv_bar_create(s_scr); + lv_obj_set_pos(s_bar_prog, 10, 130); + lv_obj_set_size(s_bar_prog, 300, 8); + lv_bar_set_range(s_bar_prog, 0, 100); + lv_bar_set_value(s_bar_prog, 0, LV_ANIM_OFF); + lv_obj_set_style_bg_color(s_bar_prog, lv_color_hex(UI_COL_TRACK), LV_PART_MAIN); + lv_obj_set_style_bg_opa(s_bar_prog, LV_OPA_COVER, LV_PART_MAIN); + lv_obj_set_style_radius(s_bar_prog, 4, LV_PART_MAIN); + lv_obj_set_style_bg_color(s_bar_prog, lv_color_hex(UI_COL_ACCENT), LV_PART_INDICATOR); + lv_obj_set_style_radius(s_bar_prog, 4, LV_PART_INDICATOR); + + s_lbl_cur = ui_label(s_scr, "00:00", &lv_font_montserrat_12, UI_COL_SUB); + lv_obj_set_pos(s_lbl_cur, 10, 142); + + s_lbl_tot = ui_label(s_scr, "--:--", &lv_font_montserrat_12, UI_COL_SUB); + lv_obj_align(s_lbl_tot, LV_ALIGN_TOP_RIGHT, -10, 142); + + /* ---------------- 音量 ---------------- */ + lv_obj_t *vol_icon = ui_label(s_scr, LV_SYMBOL_VOLUME_MAX, &lv_font_montserrat_14, + UI_COL_ACCENT); + lv_obj_set_pos(vol_icon, 10, 160); + + s_bar_vol = lv_bar_create(s_scr); + lv_obj_set_pos(s_bar_vol, 32, 165); + lv_obj_set_size(s_bar_vol, 228, 6); + lv_bar_set_range(s_bar_vol, 0, 100); + lv_bar_set_value(s_bar_vol, 60, LV_ANIM_OFF); + lv_obj_set_style_bg_color(s_bar_vol, lv_color_hex(UI_COL_TRACK), LV_PART_MAIN); + lv_obj_set_style_bg_opa(s_bar_vol, LV_OPA_COVER, LV_PART_MAIN); + lv_obj_set_style_radius(s_bar_vol, 3, LV_PART_MAIN); + lv_obj_set_style_bg_color(s_bar_vol, lv_color_hex(UI_COL_ACCENT2), LV_PART_INDICATOR); + lv_obj_set_style_radius(s_bar_vol, 3, LV_PART_INDICATOR); + + s_lbl_vol = ui_label(s_scr, "60%", &lv_font_montserrat_12, UI_COL_SUB); + lv_obj_align(s_lbl_vol, LV_ALIGN_TOP_RIGHT, -10, 158); + + /* ---------------- 底部按键提示 ---------------- */ + ui_key_bar_create(s_scr, &s_bar, s_symbols, s_captions, s_enabled); +} + +/* ---------------- 显示 / 刷新 ---------------- */ + +void ui_player_show(void) +{ + ui_key_bar_set_highlight(&s_bar, -1); + ui_player_tick(); + lv_scr_load(s_scr); +} + +void ui_player_tick(void) +{ + player_state_t state; + char buffer[96]; + + player_get_state(&state); + + /* ---- 标题栏:曲目序号 ---- */ + if (state.track_total > 0) + { + snprintf(buffer, sizeof(buffer), "%02d/%02d", + (int)state.track_index + 1, (int)state.track_total); + } + else + { + snprintf(buffer, sizeof(buffer), "--/--"); + } + ui_label_set_if_changed(s_lbl_index, buffer); + + /* ---- 曲名 / 格式 / 状态 ---- */ + ui_label_set_if_changed(s_lbl_name, state.track_name[0] ? state.track_name : "No media"); + + if (state.tot_sec > 0) + { + /* 位数相同只显示一个;源位深被提升时显示 "24->32bit" */ + char bits_text[16]; + uint8_t i2s_bits = state.i2s_bits ? state.i2s_bits : state.bits; + + if (i2s_bits == state.bits) + { + snprintf(bits_text, sizeof(bits_text), "%ubit", (unsigned)state.bits); + } + else + { + snprintf(bits_text, sizeof(bits_text), "%u->%ubit", + (unsigned)state.bits, (unsigned)i2s_bits); + } + + snprintf(buffer, sizeof(buffer), "%s %uHz %s %s", + state.codec[0] ? state.codec : "AUDIO", + (unsigned)state.samplerate, bits_text, + (state.channels == 1) ? "MONO" : "STEREO"); + } + else + { + snprintf(buffer, sizeof(buffer), "%s / PCM", + state.codec[0] ? state.codec : "AUDIO"); + } + ui_label_set_if_changed(s_lbl_format, buffer); + + ui_label_set_if_changed(s_lbl_chip, status_text(state.status, state.message)); + + uint32_t color = status_color(state.status); + if (color != s_chip_color) + { + s_chip_color = color; + lv_obj_set_style_text_color(s_lbl_chip, lv_color_hex(color), LV_PART_MAIN); + } + + /* ---- 播放进度 ---- */ + uint32_t total = (state.tot_sec > 0) ? state.tot_sec : 1; + uint32_t current = (state.cur_sec > state.tot_sec) ? state.tot_sec : state.cur_sec; + + lv_bar_set_value(s_bar_prog, (int32_t)((uint64_t)current * 100 / total), LV_ANIM_OFF); + + ui_format_time(current, buffer, sizeof(buffer)); + ui_label_set_if_changed(s_lbl_cur, buffer); + + if (state.tot_sec > 0) + { + ui_format_time(state.tot_sec, buffer, sizeof(buffer)); + } + else + { + snprintf(buffer, sizeof(buffer), "--:--"); + } + ui_label_set_if_changed(s_lbl_tot, buffer); + + /* ---- 音量 ---- */ + lv_bar_set_value(s_bar_vol, (int32_t)state.volume, LV_ANIM_OFF); + snprintf(buffer, sizeof(buffer), "%u%%", (unsigned)state.volume); + ui_label_set_if_changed(s_lbl_vol, buffer); + + /* ---- 播放键图标提示“按下后会发生什么” ---- */ + ui_key_bar_set_icon(&s_bar, APP_KEY_1, + (state.status == PLAYER_STATUS_PLAYING) ? LV_SYMBOL_PAUSE + : LV_SYMBOL_PLAY); + + /* ---- 按键高亮 ---- */ + ui_key_bar_set_highlight(&s_bar, ui_flash_active(&s_flash)); +} + +/* ---------------- 按键 ---------------- */ + +void ui_player_flash(int key) +{ + ui_flash_trigger(&s_flash, key); +} + +void ui_player_handle_key(uint8_t key) +{ + ui_flash_trigger(&s_flash, key); + + switch (key) + { + case APP_KEY_1: + player_request(PLAYER_REQ_PLAY_PAUSE); + break; + + case APP_KEY_2: + player_request(PLAYER_REQ_NEXT); + break; + + case APP_KEY_3: + player_request(PLAYER_REQ_VOLUME); + break; + + default: + break; + } +} diff --git a/main/APP/player/ui_player.h b/main/APP/player/ui_player.h new file mode 100644 index 0000000..702b6f8 --- /dev/null +++ b/main/APP/player/ui_player.h @@ -0,0 +1,43 @@ +/** + **************************************************************************************************** + * @file ui_player.h + * @author HiFi Player Project + * @version V1.2 + * @date 2026-01-01 + * @brief 播放器界面接口 + * + * 按键:K0 返回主界面 / K1 播放暂停 / K2 下一曲 / K3 音量 + **************************************************************************************************** + */ + +#ifndef __UI_PLAYER_H +#define __UI_PLAYER_H + +#include + +#include "lvgl.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/** 创建播放器界面(只创建,不显示) */ +void ui_player_create(void); + +/** 显示播放器界面并立即刷新一次 */ +void ui_player_show(void); + +/** 周期刷新界面数据(进度、音量、状态等) */ +void ui_player_tick(void); + +/** 处理本界面的功能键(K1/K2/K3);K0 由 ui.c 统一处理 */ +void ui_player_handle_key(uint8_t key); + +/** 仅触发按键高亮(不做动作),供 K0 使用 */ +void ui_player_flash(int key); + +#ifdef __cplusplus +} +#endif + +#endif /* __UI_PLAYER_H */ diff --git a/main/APP/ui.c b/main/APP/ui.c new file mode 100644 index 0000000..c6f60a3 --- /dev/null +++ b/main/APP/ui.c @@ -0,0 +1,150 @@ +/** + **************************************************************************************************** + * @file ui.c + * @author HiFi Player Project + * @version V1.2 + * @date 2026-01-01 + * @brief 界面路由器:模式切换 + 按键事件分发 + * + * --------------------------------------------------------------------------------------------------- + * 全局按键约定(K0 为“返回/切换”键,任何 App 都遵循同一套语义) + * --------------------------------------------------------------------------------------------------- + * K0 短按 在任意 App 内 -> 返回主界面 + * 在主界面 -> 切换到下一个应用 + * K0 长按 任意界面 -> 直接切换到下一个应用 + * K1/K2/K3 由当前 App 自己定义(见各 App 界面底部的按键提示) + * + * 因此新增 App 时,只需实现自己的 K1/K2/K3,返回与切换是完全自动的。 + **************************************************************************************************** + */ + +#include "ui.h" + +#include "ui_common.h" +#include "app.h" + +#include "ui_home.h" +#include "ui_player.h" + +static app_mode_t s_last_mode = APP_MODE_NUM; + +/** + * @brief 切换到某个 App 的界面 + */ +static void ui_load_mode(app_mode_t mode) +{ + switch (mode) + { + case APP_MODE_PLAYER: + ui_player_show(); + break; + + case APP_MODE_HOME: + default: + ui_home_show(); + break; + } +} + +/** + * @brief 把按键事件分发给当前界面 + */ +static void ui_dispatch(const app_event_t *ev) +{ + app_mode_t mode = app_get_mode(); + + /* ---------------- 1. K0:返回 / 切换应用(全局语义) ---------------- */ + if (ev->key == APP_KEY_0) + { + /* 高亮交给当前界面,纯视觉效果 */ + if (mode == APP_MODE_PLAYER) + { + ui_player_flash(ev->key); + } + else + { + ui_home_flash(ev->key); + } + + if (ev->type == APP_KEY_LONG) + { + app_switch_next(); /* 长按:直接跳到下一个应用 */ + } + else if (mode == APP_MODE_HOME) + { + app_switch_next(); /* 主界面短按:切换应用 */ + } + else + { + app_set_mode(APP_MODE_HOME); /* App 内短按:返回主界面 */ + } + return; + } + + /* ---------------- 2. 其它按键的长按暂时没有功能 ---------------- */ + if (ev->type == APP_KEY_LONG) + { + return; + } + + /* ---------------- 3. 交给当前 App ---------------- */ + switch (mode) + { + case APP_MODE_PLAYER: + ui_player_handle_key(ev->key); + break; + + case APP_MODE_HOME: + default: + ui_home_handle_key(ev->key); + break; + } +} + +/** + * @brief 界面周期任务(在 LVGL 任务中执行) + */ +static void ui_tick_cb(lv_timer_t *timer) +{ + (void)timer; + + app_mode_t mode = app_get_mode(); + + /* ---------------- 1. 应用切换:换屏 ---------------- */ + if (mode != s_last_mode) + { + s_last_mode = mode; + ui_load_mode(mode); + } + + /* ---------------- 2. 消费按键事件(唯一消费者) ---------------- */ + app_event_t ev; + while (app_event_poll(&ev)) + { + ui_dispatch(&ev); + } + + /* ---------------- 3. 当前界面刷新 ---------------- */ + switch (app_get_mode()) + { + case APP_MODE_PLAYER: + ui_player_tick(); + break; + + case APP_MODE_HOME: + default: + ui_home_tick(); + break; + } +} + +void ui_create(void) +{ + ui_home_create(); + ui_player_create(); + + s_last_mode = app_get_mode(); + ui_load_mode(s_last_mode); + + lv_timer_create(ui_tick_cb, UI_TICK_MS, NULL); +} diff --git a/main/APP/ui.h b/main/APP/ui.h new file mode 100644 index 0000000..558f973 --- /dev/null +++ b/main/APP/ui.h @@ -0,0 +1,36 @@ +/** + **************************************************************************************************** + * @file ui.h + * @author HiFi Player Project + * @version V1.2 + * @date 2026-01-01 + * @brief 界面总入口(路由器) + * + * ui.c 只做两件事: + * 1. 按 app_get_mode() 切换到对应 App 的界面; + * 2. 作为“按键事件唯一消费者”,把事件分发给当前 App 的界面。 + * + * 具体界面代码分别位于: + * home/ 主界面(应用启动器) + * player/ 播放器(界面 + 播放核心) + **************************************************************************************************** + */ + +#ifndef __UI_H +#define __UI_H + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * @brief 创建全部界面并启动界面刷新定时器 + * @note 调用前必须先执行 lvgl_port_init() + */ +void ui_create(void); + +#ifdef __cplusplus +} +#endif + +#endif /* __UI_H */ diff --git a/main/APP/ui_common.c b/main/APP/ui_common.c new file mode 100644 index 0000000..c261419 --- /dev/null +++ b/main/APP/ui_common.c @@ -0,0 +1,163 @@ +/** + **************************************************************************************************** + * @file ui_common.c + * @author HiFi Player Project + * @version V1.2 + * @date 2026-01-01 + * @brief 界面共用组件实现 + **************************************************************************************************** + */ + +#include "ui_common.h" + +#include +#include + +/* 底部按键提示条的布局参数 */ +#define KEY_BAR_Y 196 +#define KEY_BAR_W 74 +#define KEY_BAR_H 40 +#define KEY_BAR_GAP 4 +#define KEY_BAR_X0 6 + +void ui_style_screen(lv_obj_t *scr) +{ + lv_obj_clear_flag(scr, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_bg_color(scr, lv_color_hex(UI_COL_BG), LV_PART_MAIN); + lv_obj_set_style_bg_opa(scr, LV_OPA_COVER, LV_PART_MAIN); + lv_obj_set_style_pad_all(scr, 0, LV_PART_MAIN); + lv_obj_set_style_text_color(scr, lv_color_hex(UI_COL_TEXT), LV_PART_MAIN); +} + +lv_obj_t *ui_card(lv_obj_t *parent, lv_coord_t x, lv_coord_t y, + lv_coord_t w, lv_coord_t h, uint32_t color, lv_coord_t radius) +{ + lv_obj_t *obj = lv_obj_create(parent); + + lv_obj_set_pos(obj, x, y); + lv_obj_set_size(obj, w, h); + lv_obj_clear_flag(obj, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_bg_color(obj, lv_color_hex(color), LV_PART_MAIN); + lv_obj_set_style_bg_opa(obj, LV_OPA_COVER, LV_PART_MAIN); + lv_obj_set_style_radius(obj, radius, LV_PART_MAIN); + lv_obj_set_style_border_width(obj, 0, LV_PART_MAIN); + lv_obj_set_style_pad_all(obj, 0, LV_PART_MAIN); + + return obj; +} + +lv_obj_t *ui_label(lv_obj_t *parent, const char *text, + const lv_font_t *font, uint32_t color) +{ + lv_obj_t *label = lv_label_create(parent); + + lv_label_set_text(label, text); + lv_obj_set_style_text_font(label, font, LV_PART_MAIN); + lv_obj_set_style_text_color(label, lv_color_hex(color), LV_PART_MAIN); + + return label; +} + +void ui_label_set_if_changed(lv_obj_t *label, const char *text) +{ + if (strcmp(lv_label_get_text(label), text) != 0) + { + lv_label_set_text(label, text); + } +} + +void ui_format_time(uint32_t seconds, char *buffer, size_t size) +{ + snprintf(buffer, size, "%02u:%02u", + (unsigned)((seconds / 60) % 100), (unsigned)(seconds % 60)); +} + +/* ---------------- 按键提示条 ---------------- */ + +void ui_key_bar_create(lv_obj_t *parent, ui_key_bar_t *bar, + const char *const symbols[APP_KEY_NUM], + const char *const captions[APP_KEY_NUM], + const bool enabled[APP_KEY_NUM]) +{ + memset(bar, 0, sizeof(*bar)); + bar->highlight = -1; + + for (int i = 0; i < APP_KEY_NUM; i++) + { + bool en = enabled[i]; + bar->enabled[i] = en; + + lv_obj_t *card = ui_card(parent, + (lv_coord_t)(KEY_BAR_X0 + i * (KEY_BAR_W + KEY_BAR_GAP)), + KEY_BAR_Y, KEY_BAR_W, KEY_BAR_H, UI_COL_CARD, 8); + bar->card[i] = card; + + bar->icon[i] = ui_label(card, (symbols != NULL) ? symbols[i] : "", + &lv_font_montserrat_16, en ? UI_COL_ACCENT : UI_COL_DIM); + lv_obj_align(bar->icon[i], LV_ALIGN_TOP_MID, 0, 2); + + bar->caption[i] = ui_label(card, captions[i], &lv_font_montserrat_12, + en ? UI_COL_SUB : UI_COL_DIM); + lv_obj_align(bar->caption[i], LV_ALIGN_BOTTOM_MID, 0, -3); + } +} + +void ui_key_bar_set_highlight(ui_key_bar_t *bar, int index) +{ + if (bar->highlight == index) + { + return; /* 状态未变化,避免无谓重绘 */ + } + + bar->highlight = index; + + for (int i = 0; i < APP_KEY_NUM; i++) + { + bool on = (i == index) && bar->enabled[i]; + + lv_obj_set_style_bg_color(bar->card[i], lv_color_hex(on ? UI_COL_ACCENT : UI_COL_CARD), + LV_PART_MAIN); + lv_obj_set_style_text_color(bar->icon[i], + lv_color_hex(bar->enabled[i] ? (on ? UI_COL_BG : UI_COL_ACCENT) + : UI_COL_DIM), + LV_PART_MAIN); + } +} + +void ui_key_bar_set_icon(ui_key_bar_t *bar, int key, const char *symbol) +{ + if ((key < 0) || (key >= APP_KEY_NUM) || (symbol == NULL)) + { + return; + } + + ui_label_set_if_changed(bar->icon[key], symbol); +} + +void ui_key_bar_set_caption(ui_key_bar_t *bar, int key, const char *caption) +{ + if ((key < 0) || (key >= APP_KEY_NUM) || (caption == NULL)) + { + return; + } + + ui_label_set_if_changed(bar->caption[key], caption); +} + +/* ---------------- 按键高亮 ---------------- */ + +void ui_flash_trigger(ui_flash_t *flash, int key) +{ + flash->key = key; + flash->tick = lv_tick_get(); +} + +int ui_flash_active(const ui_flash_t *flash) +{ + if ((flash->key < 0) || (flash->key >= APP_KEY_NUM)) + { + return -1; + } + + return (lv_tick_elaps(flash->tick) < UI_KEY_FLASH_MS) ? (int)flash->key : -1; +} diff --git a/main/APP/ui_common.h b/main/APP/ui_common.h new file mode 100644 index 0000000..f9e6892 --- /dev/null +++ b/main/APP/ui_common.h @@ -0,0 +1,114 @@ +/** + **************************************************************************************************** + * @file ui_common.h + * @author HiFi Player Project + * @version V1.2 + * @date 2026-01-01 + * @brief 界面共用组件:配色、基础控件、底部按键提示条、按键高亮 + * + * 所有 App 的界面都从这里取用公共资源,保证风格一致、代码不重复。 + **************************************************************************************************** + */ + +#ifndef __UI_COMMON_H +#define __UI_COMMON_H + +#include +#include + +#include "lvgl.h" + +#include "app.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/* ---------------- 屏幕 ---------------- */ +#define UI_HOR_RES 320 +#define UI_VER_RES 240 +#define UI_TICK_MS 50 /* 界面刷新 / 事件处理周期 */ +#define UI_KEY_FLASH_MS 400 /* 按键提示高亮持续时间 */ + +/* ---------------- 配色 ---------------- */ +#define UI_COL_BG 0x0B0E17 +#define UI_COL_PANEL 0x151B2B +#define UI_COL_CARD 0x1C2438 +#define UI_COL_CARD_SEL 0x233A5C +#define UI_COL_TRACK 0x232C42 +#define UI_COL_ACCENT 0x4FC3F7 +#define UI_COL_ACCENT2 0x7C4DFF +#define UI_COL_TEXT 0xF2F5FF +#define UI_COL_SUB 0x8A93A8 +#define UI_COL_DIM 0x5A6076 +#define UI_COL_OK 0x3DDC84 +#define UI_COL_WARN 0xFFB020 +#define UI_COL_ERR 0xFF5C5C + +/* ---------------- 基础控件 ---------------- */ + +/** 给屏幕设置统一的背景与内边距 */ +void ui_style_screen(lv_obj_t *scr); + +/** 创建一个无边框圆角容器 */ +lv_obj_t *ui_card(lv_obj_t *parent, lv_coord_t x, lv_coord_t y, + lv_coord_t w, lv_coord_t h, uint32_t color, lv_coord_t radius); + +/** 创建标签 */ +lv_obj_t *ui_label(lv_obj_t *parent, const char *text, + const lv_font_t *font, uint32_t color); + +/** 文本不同才更新,减少重绘 */ +void ui_label_set_if_changed(lv_obj_t *label, const char *text); + +/** 秒数 -> "mm:ss" */ +void ui_format_time(uint32_t seconds, char *buffer, size_t size); + +/* ---------------- 底部按键提示条 ---------------- */ + +typedef struct { + lv_obj_t *card[APP_KEY_NUM]; + lv_obj_t *icon[APP_KEY_NUM]; + lv_obj_t *caption[APP_KEY_NUM]; + bool enabled[APP_KEY_NUM]; + int highlight; /* 当前高亮按键,-1 表示无 */ +} ui_key_bar_t; + +/** + * @brief 创建 4 个按键提示卡片(横向一排,位于屏幕底部) + * @param symbols LV_SYMBOL_xxx 图标,可为 NULL + * @param captions 卡片底部说明文字 + * @param enabled 该按键在当前界面是否可用(不可用显示为灰色) + */ +void ui_key_bar_create(lv_obj_t *parent, ui_key_bar_t *bar, + const char *const symbols[APP_KEY_NUM], + const char *const captions[APP_KEY_NUM], + const bool enabled[APP_KEY_NUM]); + +/** 设置某个按键的高亮状态(-1 表示全部取消) */ +void ui_key_bar_set_highlight(ui_key_bar_t *bar, int index); + +/** 修改某个按键的图标(例如播放/暂停切换) */ +void ui_key_bar_set_icon(ui_key_bar_t *bar, int key, const char *symbol); + +/** 修改某个按键的说明文字 */ +void ui_key_bar_set_caption(ui_key_bar_t *bar, int key, const char *caption); + +/* ---------------- 按键高亮闪动 ---------------- */ + +typedef struct { + int32_t key; /* 上次触发的按键,-1 表示无 */ + uint32_t tick; /* 触发时刻(lv_tick_get) */ +} ui_flash_t; + +/** 记录一次按键,用于 400ms 高亮 */ +void ui_flash_trigger(ui_flash_t *flash, int key); + +/** 返回当前应当高亮的按键(超时或未触发返回 -1) */ +int ui_flash_active(const ui_flash_t *flash); + +#ifdef __cplusplus +} +#endif + +#endif /* __UI_COMMON_H */ diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt new file mode 100644 index 0000000..47d0def --- /dev/null +++ b/main/CMakeLists.txt @@ -0,0 +1,24 @@ +idf_component_register( + SRCS + "main.c" + "APP/app.c" + "APP/ui.c" + "APP/ui_common.c" + "APP/lvgl_port.c" + "APP/home/ui_home.c" + "APP/player/ui_player.c" + "APP/player/player.c" + "APP/player/audio_source.c" + "APP/player/flac_dec.cpp" + INCLUDE_DIRS + "." + "APP" + "APP/home" + "APP/player" + REQUIRES + BSP + fatfs + nvs_flash + esp_timer + esphome__micro-flac + ) diff --git a/main/idf_component.yml b/main/idf_component.yml new file mode 100644 index 0000000..821426a --- /dev/null +++ b/main/idf_component.yml @@ -0,0 +1,8 @@ +## IDF Component Manager Manifest File +dependencies: + idf: + version: ">=5.0.0" + ## LVGL 图形库(与正点原子 LVGL 例程一致,使用 8.4.x 版本) + lvgl/lvgl: "^8.4.0" + ## FLAC 解码器(ESP32-S3 带 Xtensa 汇编优化,16bit 约 30 倍实时) + esphome/micro-flac: "^0.3.0" diff --git a/main/main.c b/main/main.c new file mode 100644 index 0000000..ed66132 --- /dev/null +++ b/main/main.c @@ -0,0 +1,76 @@ +/** + **************************************************************************************************** + * @file main.c + * @author HiFi Player Project + * @version V1.0 + * @date 2026-01-01 + * @brief HiFi 播放器(ESP32-S3 + SPI LCD + ES8388 + SD 卡 + LVGL) + * + * 硬件资源(正点原子 DNESP32S3 开发板): + * - SPI LCD : ST7789 320x240,SPI2(SCLK=12, MOSI=11, CS=21, DC=40) + * - SD 卡 : SPI 模式,SPI2(CS=2),FATFS 挂载点 "/0:" + * - 音频 : ES8388 + I2S0(MCLK=3, BCLK=46, WS=9, DOUT=10, DIN=14) + * - 按键 : XL9555 的 KEY0~KEY3(I2C0:SDA=41, SCL=42) + * + * 目录约定:把 .wav 文件放到 SD 卡的 /MUSIC 目录(16bit 或 24bit PCM) + **************************************************************************************************** + */ + +#include + +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" + +#include "esp_err.h" +#include "esp_log.h" +#include "nvs_flash.h" + +#include "my_spi.h" +#include "myiic.h" +#include "xl9555.h" +#include "spilcd.h" +#include "led.h" + +#include "lvgl_port.h" +#include "ui.h" +#include "app.h" +#include "player.h" + +static const char *TAG = "app"; + +void app_main(void) +{ + /* ---------------- 1. NVS ---------------- */ + esp_err_t ret = nvs_flash_init(); + if ((ret == ESP_ERR_NVS_NO_FREE_PAGES) || (ret == ESP_ERR_NVS_NEW_VERSION_FOUND)) + { + ESP_ERROR_CHECK(nvs_flash_erase()); + ret = nvs_flash_init(); + } + ESP_ERROR_CHECK(ret); + + /* ---------------- 2. 基础外设 ---------------- */ + led_init(); + LED0(0); + + /* SPI2 由 LCD 和 SD 卡共用,必须先初始化总线,再初始化 LCD */ + ESP_ERROR_CHECK(my_spi_init()); + ESP_ERROR_CHECK(myiic_init()); + ESP_ERROR_CHECK(xl9555_init()); + + /* ---------------- 3. 显示 ---------------- */ + ESP_ERROR_CHECK(spilcd_init()); + LCD_BL(1); /* 打开背光 */ + + /* ---------------- 4. 应用管理与界面 ---------------- */ + app_start(); /* 按键服务 + 应用模式管理 */ + + ESP_ERROR_CHECK(lvgl_port_init()); + ui_create(); /* 主界面 + 播放器界面 */ + lvgl_port_start(); + + /* ---------------- 5. 播放器任务(进入播放器界面后才出声) ---------------- */ + player_start(); + + ESP_LOGI(TAG, "HiFi player started, press K2 on HOME to open MUSIC PLAYER"); +} diff --git a/partitions-16MiB.csv b/partitions-16MiB.csv new file mode 100644 index 0000000..b500254 --- /dev/null +++ b/partitions-16MiB.csv @@ -0,0 +1,5 @@ +# ESP-IDF Partition Table +# Name, Type, SubType, Offset, Size, Flags +nvs, data, nvs, 0x9000, 0x6000, +phy_init, data, phy, 0xf000, 0x1000, +factory, app, factory, 0x10000, 0x2F0000, diff --git a/sdkconfig.defaults b/sdkconfig.defaults new file mode 100644 index 0000000..1cb466b --- /dev/null +++ b/sdkconfig.defaults @@ -0,0 +1,55 @@ +# ============================================================================ +# HiFi Player - 默认 SDK 配置 +# 说明:第一次编译前建议执行 idf.py set-target esp32s3 +# ============================================================================ + +# ---------------- 芯片 / Flash / PSRAM ---------------- +CONFIG_IDF_TARGET="esp32s3" +CONFIG_ESPTOOLPY_FLASHSIZE_16MB=y +CONFIG_ESPTOOLPY_FLASHSIZE="16MB" + +CONFIG_SPIRAM=y +CONFIG_SPIRAM_MODE_OCT=y +CONFIG_SPIRAM_SPEED_80M=y +CONFIG_SPIRAM_BOOT_INIT=y +CONFIG_SPIRAM_USE_MALLOC=y +CONFIG_SPIRAM_MALLOC_ALWAYSINTERNAL=16384 +CONFIG_SPIRAM_MALLOC_RESERVE_INTERNAL=32768 +CONFIG_SPIRAM_ALLOW_STACK_EXTERNAL_MEMORY=y + +# ---------------- 分区表 ---------------- +CONFIG_PARTITION_TABLE_CUSTOM=y +CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions-16MiB.csv" +CONFIG_PARTITION_TABLE_OFFSET=0x8000 + +# ---------------- 性能 ---------------- +CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_240=y +CONFIG_COMPILER_OPTIMIZATION_PERF=y +CONFIG_FREERTOS_HZ=1000 + +# ---------------- FATFS(SD 卡) ---------------- +CONFIG_FATFS_LFN_STACK=y +CONFIG_FATFS_MAX_LFN=255 +CONFIG_FATFS_CODEPAGE_936=y +CONFIG_FATFS_API_ENCODING_ANSI_OEM=y +CONFIG_FATFS_VOLUME_COUNT=2 +CONFIG_FATFS_PER_FILE_CACHE=y +CONFIG_FATFS_ALLOC_PREFER_EXTRAM=y + +# ---------------- LVGL 8.4 ---------------- +CONFIG_LV_CONF_SKIP=y +CONFIG_LV_BUILD_EXAMPLES=n +CONFIG_LV_COLOR_DEPTH_16=y +CONFIG_LV_COLOR_DEPTH=16 +CONFIG_LV_MEM_SIZE_KILOBYTES=48 +CONFIG_LV_DISP_DEF_REFR_PERIOD=30 +CONFIG_LV_TXT_ENC_UTF8=y +CONFIG_LV_USE_BAR=y +CONFIG_LV_USE_LABEL=y +CONFIG_LV_USE_FONT_PLACEHOLDER=y +CONFIG_LV_FONT_MONTSERRAT_12=y +CONFIG_LV_FONT_MONTSERRAT_14=y +CONFIG_LV_FONT_MONTSERRAT_16=y +CONFIG_LV_FONT_MONTSERRAT_24=y +CONFIG_LV_FONT_MONTSERRAT_40=y +CONFIG_LV_FONT_DEFAULT_MONTSERRAT_16=y