HiFi player project, first commit.
This commit is contained in:
commit
4126ea13a1
49 files changed
+6333
No files matched your search
+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");
|
||||
}
|
||||
Reference in new issue
Block a user