HiFi player project, first commit.
This commit is contained in:
commit
4126ea13a1
49 files changed
+6333
No files matched your search
@@ -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"]
|
||||
@@ -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"]
|
||||
}
|
||||
+78
@@ -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
|
||||
Vendored
+20
@@ -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
|
||||
}
|
||||
Vendored
+10
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"type": "gdbtarget",
|
||||
"request": "attach",
|
||||
"name": "Eclipse CDT GDB Adapter"
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+31
@@ -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"
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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)、播放器状态机与按键交互为本工程重新实现。
|
||||
@@ -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)
|
||||
@@ -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 */
|
||||
}
|
||||
@@ -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
|
||||
@@ -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 */
|
||||
}
|
||||
@@ -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
|
||||
@@ -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 <stdbool.h>
|
||||
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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 <stdio.h>
|
||||
#include <string.h>
|
||||
#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
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 <unistd.h>
|
||||
#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
|
||||
@@ -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 <string.h>
|
||||
#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);
|
||||
}
|
||||
@@ -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 <stdint.h>
|
||||
#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
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 <unistd.h>
|
||||
#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
|
||||
@@ -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; /* 返回键值 */
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
+330
@@ -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 <string.h>
|
||||
|
||||
#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);
|
||||
}
|
||||
}
|
||||
+114
@@ -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 <stdbool.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#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 */
|
||||
@@ -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 <stdio.h>
|
||||
|
||||
#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;
|
||||
}
|
||||
}
|
||||
@@ -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 */
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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 */
|
||||
@@ -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 <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#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);
|
||||
}
|
||||
@@ -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 <stdbool.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#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 */
|
||||
@@ -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 <cstdlib>
|
||||
#include <cstring>
|
||||
#include <new>
|
||||
|
||||
#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;
|
||||
}
|
||||
@@ -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 <stdbool.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#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 */
|
||||
@@ -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 <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#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;
|
||||
}
|
||||
}
|
||||
@@ -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 <stdbool.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#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 */
|
||||
@@ -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 <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
#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;
|
||||
}
|
||||
}
|
||||
@@ -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 <stdint.h>
|
||||
|
||||
#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 */
|
||||
+150
@@ -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);
|
||||
}
|
||||
@@ -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 */
|
||||
@@ -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 <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
/* 底部按键提示条的布局参数 */
|
||||
#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;
|
||||
}
|
||||
@@ -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 <stdbool.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#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 */
|
||||
@@ -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
|
||||
)
|
||||
@@ -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"
|
||||
+76
@@ -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 <stdio.h>
|
||||
|
||||
#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");
|
||||
}
|
||||
@@ -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,
|
||||
|
@@ -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
|
||||
Reference in new issue
Block a user