简介
在这篇文章中我将介绍如何使用ESP32-S31-Korvo-1来实现音乐频谱的功能。板子上自带 ES8389 音频 codec、RGB LCD 屏和触摸。就想搞个实时频谱可视化玩玩。效果还不错,48 根频谱柱子跑在 60 FPS,支持三种配色主题切换,触摸屏点一下就换色,没触摸的话 8 秒自动轮换。就是可惜3D打印机目前不在手里。要么打印个外壳的话放到车里倒是一个不错的选择。言归正传,接下来我们看下具体该怎么实现它。

整体思路
整个项目被拆分成了三个模块。每个模块仅仅负责自己的职责。
1- 音频采集 audio_capture.c/h 主要负责使用BSP来读取麦克风然后输出PCM的数据
2- 频谱分析 spectrum_analyzer.c/h FFT → 对数频段映射 → AGC → 平滑
3- 界面渲染 spectrum_ui.c/h LVGL Canvas 画柱子 、 峰值标记 和 主题切换
所有可调参数集中在 spectrum_config.h,改采样率、FFT 大小、柱子数量什么的不用到处翻代码。

音频采集:双缓冲 + 事件同步
音频采集这块最关键的问题是:I2S 读取是阻塞的,FFT 处理也要时间,需要想一个办法来使其两者的数据同步。我的做法是搞了个简单的双缓冲池,配合 FreeRTOS 的 EventGroup 做同步这样就可以处理延迟问题了。
audio_capture_task 跑在 CPU 0 上。它会先等待 SLOT_FREE_BIT,然后获取写锁,从 codec 读取音频数据。读取完成后交换 read 和 write 的索引,最后设置 SLOT_READY_BIT,通知另一个任务数据已经准备好了。
spectrum_proc_task 跑在 CPU 1 上。它会等待 SLOT_READY_BIT,拿到 PCM 数据后进行 FFT 处理,处理完成后再释放 SLOT_FREE_BIT,让采集任务继续读取下一批数据。
typedef struct {
int16_t *slots[2]; /* 两个缓冲槽,每个槽存放立体声交错采样数据 */
volatile int write_idx; /* 音频采集任务当前正在写入的缓冲槽索引 */
volatile int read_idx; /* 音频处理任务当前正在读取的缓冲槽索引 */
SemaphoreHandle_t wr_mutex; /* 写操作互斥锁 */
EventGroupHandle_t ev; /* 用于同步缓冲槽状态的事件组 */
} pcm_pool_t;
#define SLOT_READY_BIT (1 << 0) /* 表示有一个缓冲槽的数据已经准备好 */
#define SLOT_FREE_BIT (1 << 1) /* 表示有空闲缓冲槽可以写入 */
static pcm_pool_t s_pool;
static esp_codec_dev_handle_t s_mic_dev;
static TaskHandle_t s_audio_task;
/* ------------------------------------------------------------------ */
static esp_err_t pool_init(pcm_pool_t *p, int samples_per_slot)
{
/*
* 立体声交错存储:
* 每个采样点包含左右两个声道,
* 每个声道使用 int16_t(2 字节)保存。
*
* 数据排列方式:
* L0, R0, L1, R1, L2, R2, ...
*/
const size_t slot_bytes =
(size_t)samples_per_slot * 2 * sizeof(int16_t);
/* 为两个 PCM 缓冲槽分配内部 RAM */
for (int i = 0; i < 2; i++) {
p->slots[i] = (int16_t *)heap_caps_malloc(
slot_bytes,
MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT
);
if (!p->slots[i]) {
ESP_LOGE(TAG, "PCM 缓冲槽内存分配失败 (%u 字节)",
(unsigned)slot_bytes);
return ESP_ERR_NO_MEM;
}
/* 初始化缓冲区内容为 0 */
memset(p->slots[i], 0, slot_bytes);
}
/*
* 初始状态:
* slot 0 用于写入
* slot 1 作为读取槽
*/
p->write_idx = 0;
p->read_idx = 1;
/* 创建写入互斥锁 */
p->wr_mutex = xSemaphoreCreateMutex();
/* 创建事件组,用于采集任务和处理任务之间同步 */
p->ev = xEventGroupCreate();
if (!p->wr_mutex || !p->ev) {
return ESP_ERR_NO_MEM;
}
/*
* 初始化时还没有任何 PCM 数据准备好,
* 因此设置 SLOT_FREE_BIT,表示缓冲槽可以写入。
*/
xEventGroupSetBits(p->ev, SLOT_FREE_BIT);
return ESP_OK;
}
/* ------------------------------------------------------------------ */
static void audio_capture_task(void *arg)
{
/* 每次从音频 Codec 读取的数据大小 */
const size_t read_bytes = SPECTRUM_READ_BYTES;
while (1) {
/*
* 等待处理任务释放之前正在读取的缓冲槽。
*
* SLOT_FREE_BIT 被设置后,
* 表示现在可以继续采集下一批音频数据。
*/
xEventGroupWaitBits(
s_pool.ev,
SLOT_FREE_BIT,
pdFALSE,
pdTRUE,
pdMS_TO_TICKS(1000)
);
/*
* 获取写入互斥锁,
* 确保同一时间只有一个任务修改写缓冲区。
*/
if (xSemaphoreTake(
s_pool.wr_mutex,
pdMS_TO_TICKS(1000)
) != pdTRUE) {
ESP_LOGW(TAG, "获取 wr_mutex 超时");
continue;
}
/* 获取当前应该写入的缓冲槽索引 */
int wi = s_pool.write_idx;
/*
* 从麦克风 Codec 读取 PCM 数据,
* 并直接写入当前缓冲槽。
*/
int ret = esp_codec_dev_read(
s_mic_dev,
s_pool.slots[wi],
(int)read_bytes
);
if (ret != ESP_CODEC_DEV_OK) {
ESP_LOGW(TAG, "Codec 读取失败,错误码:0x%X", ret);
/* 读取失败后释放互斥锁 */
xSemaphoreGive(s_pool.wr_mutex);
/* 稍微等待后重新尝试 */
vTaskDelay(pdMS_TO_TICKS(10));
continue;
}
/*
* 当前缓冲槽已经写满:
*
* 1. 清除 SLOT_FREE_BIT,表示暂时不能继续写
* 2. 将当前写入槽设置为读取槽
* 3. 切换到另一个槽作为下一次写入槽
* 4. 设置 SLOT_READY_BIT,通知处理任务数据已经准备好
*/
xEventGroupClearBits(s_pool.ev, SLOT_FREE_BIT);
s_pool.read_idx = wi;
/* 双缓冲切换:0 -> 1,1 -> 0 */
s_pool.write_idx = 1 - wi;
/* 通知音频处理任务:新的 PCM 数据已经准备完成 */
xEventGroupSetBits(s_pool.ev, SLOT_READY_BIT);
/* 释放写入互斥锁 */
xSemaphoreGive(s_pool.wr_mutex);
}
}这里我用了 EventGroup,而不是 Queue。主要是因为这个场景不需要把每一帧数据都排队保存下来,对于频谱显示来说,及时处理最新的数据更重要,所以 EventGroup 会更适合这种同步方式。I2S 这边,我把 BSP 默认的 22050 Hz 采样率改成了 16 kHz。做频谱分析其实不需要特别高的采样率,16 kHz 已经可以分析到 8 kHz 左右的频率范围,同时也能减少需要处理的数据量。麦克风增益设置成了 36 dB。实际测试下来,在正常说话距离下声音采集比较稳定,做频谱显示的效果也不错。这里原本在没有加缓冲区的时候数据存在延迟。最后在AI的帮助下算是达到了一个较好的效果。
频谱分析:FFT 、 对数频段和AGC
这是整个项目最核心的部分,也是踩坑最多的地方。上面已经对I2S的音频进行了采集,之后便是需要进行傅里叶变化和对数频谱分析以及使用AGC。频谱分析使用的是 ESP-IDF 官方的 esp-dsp 库,采用 1024 点 FFT。整体流程比较简单。先从麦克风数据中提取左声道,经过简单的滤波和 Hann 窗处理后进行 FFT,然后计算每个频率的幅度。得到 FFT 结果后,再进行频段划分、频率加权和 AGC 处理,最后把结果转换成屏幕上柱子的高度。
void spectrum_analyzer_process(const int16_t *pcm_stereo, int num_frames, uint32_t now_ms)
{
/* 1. 提取左声道,去除直流分量,转换为 float,并应用 Hann 窗 */
float sum_sq = 0.0f;
for (int i = 0; i < SPECTRUM_FFT_SIZE; i++) {
float raw = (float)pcm_stereo[i * 2]; /* 左声道 */
/* 简单的一阶高通滤波,去除直流偏移和低频干扰 */
float filtered = DC_FILTER_ALPHA * (s_dc_prev_out + raw - s_dc_prev_in);
s_dc_prev_in = raw;
s_dc_prev_out = filtered;
s_fft_in[i] = filtered * s_hann_win[i];
sum_sq += filtered * filtered;
}
/* 2. 准备 FFT 输入数据,按照 实部、虚部 的方式交错排列 */
for (int i = 0; i < SPECTRUM_FFT_SIZE; i++) {
s_fft_out[2 * i] = s_fft_in[i];
s_fft_out[2 * i + 1] = 0.0f;
}
/* 3. 执行 FFT */
dsps_fft2r_fc32(s_fft_out, SPECTRUM_FFT_SIZE);
dsps_bit_rev_fc32(s_fft_out, SPECTRUM_FFT_SIZE);
/* 4. 计算每个频点的幅度 */
for (int i = 0; i < SPECTRUM_FFT_BINS; i++) {
float re = s_fft_out[2 * i];
float im = s_fft_out[2 * i + 1];
s_mag[i] = sqrtf(re * re + im * im);
}
/* 5. 计算 RMS,用于显示 dBFS */
float rms = sqrtf(sum_sq / SPECTRUM_FFT_SIZE);
if (rms > 1.0f) {
s_peak_dbfs = 20.0f * log10f(rms / 32768.0f);
} else {
s_peak_dbfs = -100.0f;
}
/* 6. 计算每个频谱柱子的能量,并进行频率加权和独立 AGC */
for (int i = 0; i < SPECTRUM_BAR_COUNT; i++) {
int lo = s_bin_map_lo[i];
int hi = s_bin_map_hi[i];
float acc = 0.0f;
int cnt = 0;
/* 计算当前频段内所有频点的平均幅度 */
for (int b = lo; b <= hi; b++) {
acc += s_mag[b];
cnt++;
}
float avg = (cnt > 0) ? acc / (float)cnt : 0.0f;
/* 频率加权,提高高频部分的显示效果 */
avg *= s_freq_weight[i];
/* 每个频段独立进行 AGC,跟踪当前频段的峰值 */
if (avg > s_band_peak[i]) {
/* 快速上升 */
s_band_peak[i] = s_band_peak[i] * 0.7f + avg * 0.3f;
} else {
/* 缓慢下降 */
s_band_peak[i] = s_band_peak[i] * 0.998f + avg * 0.002f;
}
/* 设置最小峰值,防止静音时归一化结果被过度放大 */
if (s_band_peak[i] < 50.0f) {
s_band_peak[i] = 50.0f;
}
/* 根据当前频段峰值归一化到 0 到 1 */
float norm = avg / s_band_peak[i];
if (norm < 0.0f) norm = 0.0f;
if (norm > 1.0f) norm = 1.0f;
/* 使用幂曲线调整,让较小的声音也能够显示出来 */
float shaped = powf(norm, 0.65f);
s_bars[i].target = shaped;
}
/* 7. 对频谱柱进行平滑处理,并更新峰值 */
for (int i = 0; i < SPECTRUM_BAR_COUNT; i++) {
spectrum_bar_t *b = &s_bars[i];
/* 上升快、下降慢,让柱子的变化更加自然 */
if (b->target > b->current) {
b->current = b->current * (1.0f - SPECTRUM_ATTACK)
+ b->target * SPECTRUM_ATTACK;
} else {
b->current = b->current * (1.0f - SPECTRUM_DECAY)
+ b->target * SPECTRUM_DECAY;
}
/* 峰值保持 */
if (b->target >= b->peak) {
/* 出现新的峰值时立即更新 */
b->peak = b->target;
b->peak_age = 0;
} else {
/* 记录峰值已经保持的时间 */
b->peak_age += (uint32_t)(1000.0f / SPECTRUM_UI_FPS);
/* 超过保持时间后,让峰值逐渐下降 */
if (b->peak_age > SPECTRUM_PEAK_HOLD_MS) {
b->peak -= SPECTRUM_PEAK_FALL;
if (b->peak < 0.0f) {
b->peak = 0.0f;
}
}
}
}
}实际测试时发现低频能量通常比较强(整个频谱图直接被打满),高频比较弱,所以我给高频增加了一些权重,让整个频谱显示得更加均衡。同时,每个频段都有自己的 AGC。这样某个频段突然出现很大的声音时,不会影响其他频段的显示。
效果如上所示(上述为已经修复过的效果,实际上如果不加高频权重的话,所有的柱子都会被打满、即占满整个屏幕)
Canvas 绘制
Canvas这里的每一帧都会先把 Canvas 清成黑色背景,然后依次遍历 48 根频谱柱,通过计算每根柱子的位置和高度,再画出圆角矩形和顶部的峰值标记。Canvas 的缓冲区放在 PSRAM 里,因为 800×480、RGB565 格式的一帧大约需要 750 KB,内部 RAM 很难直接放下这么大的帧缓冲。配色方面我做了三种主题,分别是青蓝紫渐变、黄橙红渐变,以及按照柱子位置变化的 HSV 彩虹色。主题的切换目前是具有600MS的动画效果。实际上目前切换的还是稍微有一点生硬。如果动画时长更长点的话,这个问题或许能够得到解决。
static void draw_spectrum(void)
{
const spectrum_bar_t *bars = spectrum_analyzer_get_bars();
lv_layer_t layer;
lv_canvas_init_layer(s_canvas, &layer);
/* Clear canvas to black. */
lv_canvas_fill_bg(s_canvas, lv_color_black(), LV_OPA_COVER);
/* Draw each bar. */
for (int i = 0; i < SPECTRUM_BAR_COUNT; i++) {
float h = bars[i].current;
int bar_h = (int)(h * SPECTRUM_AREA_H);
if (bar_h < 1) bar_h = 1;
int x = SPECTRUM_AREA_LEFT + i * SPECTRUM_BAR_TOTAL_W;
int y = SPECTRUM_AREA_BOTTOM - bar_h;
lv_color_t col = get_bar_color(h, i);
/* Draw rounded rectangle. */
lv_draw_rect_dsc_t rect_dsc;
lv_draw_rect_dsc_init(&rect_dsc);
rect_dsc.bg_color = col;
rect_dsc.bg_opa = LV_OPA_COVER;
rect_dsc.radius = SPECTRUM_BAR_CORNER_R;
rect_dsc.border_width = 0;
lv_area_t area = {
.x1 = x,
.y1 = y,
.x2 = x + SPECTRUM_BAR_W - 1,
.y2 = SPECTRUM_AREA_BOTTOM - 1,
};
lv_draw_rect(&layer, &rect_dsc, &area);
/* Draw peak marker. */
float pk = bars[i].peak;
if (pk > 0.01f) {
int pk_y = SPECTRUM_AREA_BOTTOM - (int)(pk * SPECTRUM_AREA_H);
lv_draw_rect_dsc_t peak_dsc;
lv_draw_rect_dsc_init(&peak_dsc);
peak_dsc.bg_color = lv_color_white();
peak_dsc.bg_opa = LV_OPA_80;
peak_dsc.radius = 1;
lv_area_t pk_area = {
.x1 = x,
.y1 = pk_y,
.x2 = x + SPECTRUM_BAR_W - 1,
.y2 = pk_y + SPECTRUM_PEAK_H - 1,
};
lv_draw_rect(&layer, &peak_dsc, &pk_area);
}
}
lv_canvas_finish_layer(s_canvas, &layer);
}触摸屏点一下切主题,同时重置自动切换计时器。没有触摸输入的话,8 秒自动轮换一次。音频采集跑在 CPU 0 是因为 I2S 外设和 codec 驱动默认绑定在 CPU 0 上。FFT 处理放在 CPU 1,避免跟 LVGL 抢时间。实际上这里已经优化的比较好了,但是由于计算量和每次刷新量打。实际的帧率目测应该是在30FPS左右。目视是稍微有那么一点顿挫感。尤其是频谱发生变化的时候。
关键参数
#define SPECTRUM_SAMPLE_RATE 16000 // 16 kHz 采样 #define SPECTRUM_FFT_SIZE 1024 // 1024 点 FFT #define SPECTRUM_BAR_COUNT 48 // 48 根频谱柱 #define SPECTRUM_MIN_FREQ_HZ 60 // 最低 60 Hz #define SPECTRUM_MAX_FREQ_HZ 8000 // 最高 8 kHz #define SPECTRUM_UI_FPS 60 // 60 FPS 刷新 #define SPECTRUM_ATTACK 0.75 // 攻击系数 #define SPECTRUM_DECAY 0.15 // 衰减系数 #define SPECTRUM_PEAK_HOLD_MS 250 // 峰值保持时间
这里有一点需要注意的是在保存上述的LVGL刷新的时候,对于双缓冲区的内存分配,ESP-DSP要求的是必须要放到内部的RAM中。而对于RGB LVGL的刷新的话则是可以放到外部开启的PSRAM中来避免内存不足的情况。
组件管理器依赖组件
dependencies: espressif/esp-dsp: version: "^1.5" espressif/esp32_s31_korvo_1: version: "^1.0.0"
效果展示如下
完整代码如下所示
总结
音乐频谱的实现是一个复杂的工作,上述的音乐频谱任务借助了Codex进行辅助实现。实际从视频的画面上看到帧率其实还可以,但是没有得到我预想中的效果。目前并不清楚是否帧率方面还能再一步的进行优化。也希望手里有这块板子的小伙伴可以运行尝试。
我要赚赏金
