这些小活动你都参加了吗?快来围观一下吧!>>
电子产品世界 » 论坛首页 » DIY与开源设计 » 电子DIY » [ESP-IDF]ESP32S31音乐频谱

共2条 1/1 1 跳转至

[ESP-IDF]ESP32S31音乐频谱

高工
2026-08-14 16:07:10     打赏

简介

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

image.png

整体思路

整个项目被拆分成了三个模块。每个模块仅仅负责自己的职责。

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 大小、柱子数量什么的不用到处翻代码。

image.png


音频采集:双缓冲 + 事件同步

音频采集这块最关键的问题是: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。这样某个频段突然出现很大的声音时,不会影响其他频段的显示。

da140cdfb2ed98d2c5abe8ed56711fe5.jpg效果如上所示(上述为已经修复过的效果,实际上如果不加高频权重的话,所有的柱子都会被打满、即占满整个屏幕)


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"

效果展示如下


完整代码如下所示

music_s31.zip


总结

音乐频谱的实现是一个复杂的工作,上述的音乐频谱任务借助了Codex进行辅助实现。实际从视频的画面上看到帧率其实还可以,但是没有得到我预想中的效果。目前并不清楚是否帧率方面还能再一步的进行优化。也希望手里有这块板子的小伙伴可以运行尝试。





关键词: ESP-IDF     音乐频谱     ESP32S31    

专家
2026-08-15 08:02:59     打赏
2楼

谢谢分享


共2条 1/1 1 跳转至

回复

匿名不能发帖!请先 [ 登陆 注册 ]