你有多久没有“看见”过声音了?我说的不是实验室里那排密密麻麻的按钮,而是一个摆在桌面上、跟着音乐跳动的彩色柱状图。前段时间我用手头的ESP32-S3做了一台这样的音乐频谱可视化设备:麦克风把环境声音采进来,芯片用FFT把声音拆成不同频段,再通过LVGL在屏幕上把频段能量画成动态柱状图。整套流程从硬件采集到图形显示全部跑在单片机上,没有上位机参与,实时刷新大概在30帧左右。这篇就带你从头到尾过一遍,会讲清楚每个环节为什么这么做,也会把踩过的坑一并列出来。不管你是想复刻一个桌面摆件,还是想入门ESP32-S3加LVGL这套组合,都能直接照着做。
1. 项目整体设计与方案选型
1.1 为什么选ESP32-S3做实时频谱
做频谱可视化,主控要同时干两件“重活”:一是持续采集音频数据并做FFT,二是驱动屏幕刷新图形界面。普通单片机很容易在这两条链路上互相拖后腿,所以我直接选了ESP32-S3。
这颗芯片是双核240MHz,带SIMD向量指令,乐鑫官方DSP库esp-dsp能在S3上把FFT优化到毫秒级。它还有足够的外设资源:ADC、I2S、SPI、LCD接口都有,驱动一个240x240的小屏非常轻松。更重要的是内存和Flash配置,市面上常见的S3模组都是8MB Flash起步,还能外挂PSRAM,跑LVGL和中文字体都不至于太紧张。
我把它和几个常见方案放在一起对比过:
| 方案 | 主频/核心 | FFT与LVGL并行能力 | 上手难度 | 适合场景 |
|---|---|---|---|---|
| ESP32 | 240MHz双核 | 够用,但无SIMD向量指令,FFT慢一些 | 低 | 简单律动灯、柱状图 |
| ESP32-S3 | 240MHz双核 | 好,esp-dsp加速明显 | 中 | 实时频谱、复杂UI |
| STM32F4 | 168MHz单核 | 需要靠CMSIS-DSP硬算,LCD并行刷新占用高 | 中高 | 工业仪表类界面 |
| RP2040 | 133MHz双核 | 浮点弱,FFT要定点化 | 中 | 教学、极简项目 |
如果你的目标只是“低音跟着节奏闪”,ESP32就够。但要做比较完整的频谱条、动画和峰值效果,S3的余量会舒服很多。后面所有代码都基于ESP-IDF v5.2与ESP32-S3。
1.2 系统架构:一条数据流看懂全流程
整个项目可以分成两条链路:采集计算链路和显示链路。音频任务负责从I2S读数据、做FFT、把频谱映射成柱状图数值;显示任务负责把这些数值变成屏幕上的动画。
麦克风(INMP441/MAX4466) │ ▼ ESP32-S3 采样模块(I2S DMA / ADC连续模式) │ 采样率32kHz,1024点/帧 ▼ 预处理:去直流 + 汉宁窗 │ ▼ FFT(esp-dsp,1024点) │ ▼ 幅值→dB→对数频段聚合(16~64条柱) │ ▼ 峰值保持 + 平滑滤波 │ ▼ LVGL定时器(33ms/帧)更新柱状图 │ ▼ SPI刷屏(ST7789/ILI9341)两条链路用FreeRTOS任务分开:audio_task跑在core0,负责采样和FFT;lvgl_task跑在core1,负责lv_timer_handler()和界面刷新。它们之间通过一个全局数组交换数据,显示任务每次只读当前最新一帧频段值,不需要等FFT做完再去画图,这样即使采集偶发抖动,画面也不会明显卡住。
1.3 方案取舍与常见误区
看到“音频频谱”这个词,很多人第一反应是接一个音频模块直接输出频谱,比如用MSGEQ7芯片。那种方案确实简单,但它只能输出固定几个频段的模拟电平,灵活性很差。用FFT的方案则能自由定义频段数量、频率范围、动态压缩曲线,想调成“重低音炮”还是“均衡器风”都行。
我遇到最常见的误区有三个:第一个是直接用MicroPython做FFT,性能完全跟不上;第二个是裸驻极体麦克风不放大直接接ADC引脚,结果信号只有几十毫伏,根本采不到有效波形;第三个是在LVGL的任务里做FFT,界面直接卡成幻灯片。这个项目采用C语言加ESP-IDF,把任务分开,能从根本上避开这些问题。
如果你只想要低音律动,不做完整频谱,FFT其实可以只算低频几个bin。不过既然标题是“频谱可视化”,我建议一次做完整,后面接灯带、切换显示主题都方便。
2. 硬件准备与麦克风采集电路
2.1 物料清单与选型建议
这套东西的核心物料很少,关键是把麦克风和屏幕选对,能省掉很多调试时间。
| 物料 | 推荐型号/规格 | 作用 | 备注 |
|---|---|---|---|
| 主控 | ESP32-S3-DevKitC-1 或合宙S3开发板 | 采集、计算、显示 | 建议带PSRAM版本 |
| 屏幕 | ST7789 1.3寸/1.54寸 SPI屏 | 显示频谱 | 也可用ILI9341,驱动要改 |
| 数字麦克风 | INMP441 MEMS模块 | I2S采集 | 抗干扰强,推荐 |
| 模拟麦克风 | MAX4466驻极体模块 | 模拟采集 | 便宜,但电源噪声要处理 |
| 电容 | 100nF、10uF | 电源去耦 | 数字MEMS和模拟模块都需要 |
| 电阻 | 10k、100k | 偏置/分压 | 用模拟方案时需要 |
| 杜邦线/洞洞板 | 若干 | 连接 | 尽量用短而粗的杜邦线 |
新手我建议直接买INMP441数字麦克风加ST7789小屏。INMP441是I2S数字输出,内部已经做了ADC,输出信号不受引线长度和电源纹波影响;模拟MAX4466虽然便宜,但供电和增益问题会让新手多踩不少坑。
2.2 驻极体麦克风采集电路:偏置、放大、去耦
如果你手头只有MAX4466这类模拟驻极体模块,也能做,但电路上有几个点要注意。
MAX4466模块内部集成了驻极体麦克风、偏置电阻和放大器,输出被偏置在电源电压的一半左右,信号会随声音在VCC/2上下波动。这个模块可以直接接到ESP32-S3的ADC引脚,但要用ADC1通道,不要用ADC2。S3的ADC2在某些场景下会和无线外设的工作产生占用冲突,时好时坏很难排查,所以ADC1是最稳妥的选择。
接线时我会在模块的VDD和GND之间并一个100nF瓷片电容,再并一个10uF电解电容,这两个电容越靠近模块越好。如果你发现频谱低频段一直在跳动,多半是电源被屏幕刷新拉出了纹波,这时候不要共用一根长杜邦线给麦克风和屏幕供电,给麦克风单独走一组电源线会改善很多。
如果使用裸驻极体,需要自己做偏置和放大。简单做法是:驻极体正极经10k电阻接3.3V,负极接地,从正极端取出信号;信号幅度很小,需要经过一级运放放大到1V左右,再接一个1uF耦合电容到ADC输入。耦合电容和ADC输入端电阻会组成高通滤波器,截止频率大概是f = 1 / (2πRC),比如1uF配100k,约1.6Hz,主要滤掉直流偏置,不影响20Hz以上的音频。
MAX4466模块板载电位器可以调增益。我的经验是先拧到中间位置,然后播放一首动态比较大的歌,在串口打印原始采样值。如果峰值经常超过ADC量程的90%,说明增益太大,要往小调;如果最大只有10%左右,就往大调。信号削波后频谱顶部会平掉,看起来像被切了一刀,非常明显。
2.3 数字MEMS麦克风INMP441:免调前端
INMP441是我在这个项目里的最终选择。它的接线只有四根信号线,不用考虑偏置和放大,输出24bit数字音频,抗干扰能力比模拟方案强一个档次。
接线表如下:
| INMP441引脚 | ESP32-S3引脚 |
|---|---|
| VDD | 3.3V |
| GND | GND |
| SCK | GPIO4 |
| WS | GPIO5 |
| SD | GPIO6 |
| L/R | GND(选择左声道) |
L/R引脚决定数据走左声道还是右声道。接地表示左声道,接3.3V表示右声道。实际使用中要注意有些模块的L/R丝印标反了,如果你初始化后读到的数据一直是0,试着把L/R的接法换一下。
INMP441模块的VDD和GND之间同样要放一个100nF去耦电容。它和MAX4466的对比可以看这个表:
| 对比项 | MAX4466模拟模块 | INMP441数字MEMS |
|---|---|---|
| 输出 | 模拟电压 | I2S数字信号 |
| 抗干扰 | 一般,受电源影响大 | 强,长引线也没问题 |
| 电路复杂度 | 需要增益调节、去耦 | 直接接I2S,免调 |
| 价格 | 便宜 | 略贵一点 |
| 适合阶段 | 练手、低成本 | 实际项目、稳定复刻 |
如果你做的是桌面摆件,我强烈建议INMP441。它能让你把主要精力放在FFT和LVGL上,而不是和电源噪声较劲。
3. 开发环境搭建与LVGL移植
3.1 VSCode加ESP-IDF:S3开发环境快速跑通
我用的开发环境是VSCode加ESP-IDF插件。打开VSCode后,在扩展市场搜索Espressif IDF,安装后按提示配置ESP-IDF路径。插件会自动下载工具链,如果网络不稳定下载老失败,可以直接下载ESP-IDF离线安装包,装好插件后在设置里指定到对应的idf.py路径。
创建工程有两种方式:一种是插件界面里的“Show Examples”新建模板,另一种是命令行:
idf.py create-project music_spectrum cd music_spectrum idf.py set-target esp32s3 idf.py menuconfig idf.py build flash monitorset-target esp32s3这一步不能省略,默认工程可能是esp32。menuconfig里建议把Flash大小和PSRAM打开,S3开发板带PSRAM的话,LVGL画缓冲和中文字体都更从容。如果板子是N8R8或N16R8这类型号,记得在menuconfig的“Component config > ESP PSRAM”里选择对应的PSRAM模式。
烧录时如果提示找不到串口,先看设备管理器里有没有COM口。ESP32-S3开发板常见的是CP2102或CH340芯片,没识别就装驱动。有些S3模组不带自动下载电路,需要按住BOOT键再点烧录,等开始烧录后再松开。
3.2 LVGL移植:两种方式与lv_conf.h关键配置
LVGL移植到ESP-IDF有两条路:一条是手动把LVGL源码放到components/lvgl,然后在自己的组件里声明依赖;另一条是直接用lv_port_esp32这种现成模板。我建议第一次做用模板,成功后可以再改成手动方式,方便控制版本。
手动方式的大概步骤是:从LVGL官网下载8.3版本源码,解压后把lvgl文件夹丢进components目录,再把lv_conf_template.h复制成lv_conf.h放到项目根目录或include目录。lv_conf.h几个关键配置:
#define LV_COLOR_DEPTH 16 #define LV_MEM_CUSTOM 1 #define LV_USE_LOG 1 #define LV_TICK_CUSTOM 1 #define LV_DISP_DEF_REFR_PERIOD 30颜色深度16bit是RGB565,和ST7789这类屏幕匹配,内存占用也合理。LVGL自带内存分配器可以关掉,直接用系统的malloc/free,在ESP-IDF下更可靠。LV_TICK_CUSTOM 1让LVGL使用esp_timer作为时间基准,省去手动处理tick。
ST7789屏幕的驱动需要自己写SPI初始化,然后注册一个flush回调:
static void lvgl_flush_cb(lv_disp_t *drv, const lv_area_t *area, lv_color_t *color_p) { st7789_rect_fill(area->x1, area->y1, area->x2, area->y2, (uint8_t *)color_p); lv_disp_flush_ready(drv); }重点记得在flush末尾调用lv_disp_flush_ready,否则LVGL会一直等待,界面永远不会刷新。SPI速度可以先设20MHz,跑通后再提到40MHz,大部分ST7789都能稳定40MHz。
建议给LVGL分配一个比较大的draw buffer。比如240x240的屏幕,至少给10行像素,也就是240102=4800字节;若开了PSRAM,可以给到40行,刷新会明显顺滑。draw buffer在LVGL 8.3中通过lv_disp_draw_buf_init和lv_disp_drv_register配置。
3.3 FreeRTOS与LVGL任务划分
LVGL本身不是线程安全的,不要让两个任务同时调用lv_timer_handler。我的做法是在app_main里创建两个任务:
xTaskCreatePinnedToCore(audio_task, "audio_task", 8192, NULL, 6, NULL, 0); xTaskCreatePinnedToCore(lvgl_task, "lvgl_task", 8192, NULL, 4, NULL, 1);audio_task做I2S读取、FFT和频段计算,lvgl_task里就是无限循环调用lv_timer_handler,中间加一个5ms的vTaskDelay。这样即使UI某个动画耗时,也不会阻塞音频采集,数据流一直不断。
任务栈大小要留够。LVGL任务里涉及各种控件和颜色缓冲,栈给4096可能不够,建议直接8192;audio_task里如果放了一些临时数组,也要给足。测试阶段可以在任务末尾打印uxTaskGetStackHighWaterMark(NULL)看剩余栈空间。
4. 音频采集与FFT频谱计算
4.1 采样参数设计:32kHz、1024点、汉宁窗
采样参数直接决定频谱的分辨率和刷新延迟。我选择的组合是32kHz采样率,FFT点数1024,帧长度约32ms。32kHz能够覆盖到16kHz,满足大多数音乐和语音;1024点FFT在这个采样率下频率分辨率是31.25Hz,低频段能看到细节,高频段也不会太稀。
如果用48kHz采样、1024点FFT,分辨率变成46.875Hz,低频柱状图会显得“粗”;如果用2048点,频率分辨率更好,但计算量翻倍,对30fps刷新压力更大。综合下来32kHz加1024点是最平衡的组合。
音频数据从I2S读出来是32bit容器,其中有效数据是24bit。先把原始值右移8位变成有符号24bit,再转成float并除以8388608,归一化到-1到1之间。具体代码:
size_t bytes_read; int32_t raw_buf[FFT_SIZE]; float fft_input[FFT_SIZE]; i2s_channel_read(rx_chan, raw_buf, FFT_SIZE * sizeof(int32_t), &bytes_read, pdMS_TO_TICKS(100)); for (int i = 0; i < FFT_SIZE; i++) { int32_t sample = raw_buf[i] >> 8; // 取高24bit fft_input[i] = (float)sample / 8388608.0f; }静音时I2S读到的数据不是0,而是围绕0波动的微小噪声,这很正常。真正的麻烦是直流偏置,如果信号里有一个固定的直流分量,FFT的0Hz附近会形成巨大能量,低频柱子直接顶满。解决办法是对每一帧求平均值再减掉:
float sum = 0.0f; for (int i = 0; i < FFT_SIZE; i++) sum += fft_input[i]; float dc = sum / FFT_SIZE; for (int i = 0; i < FFT_SIZE; i++) fft_input[i] -= dc;加窗也很重要。不处理的话相当于用了矩形窗,频谱旁瓣很高,柱状图会有一堆莫名其妙的毛刺。我用汉宁窗,公式是:
static float hann_window[FFT_SIZE]; for (int i = 0; i < FFT_SIZE; i++) { hann_window[i] = 0.5f * (1.0f - cosf(2.0f * M_PI * i / (FFT_SIZE - 1))); } // 采样数据预处理时逐点相乘 fft_input[i] = (fft_input[i] - dc) * hann_window[i];汉宁窗主瓣稍宽,但旁瓣抑制好,做音乐频谱最合适。
4.2 用esp-dsp做FFT:S3硬件加速的关键
手写FFT没有必要,直接用乐鑫官方esp-dsp库,它针对ESP32-S3的向量指令做了优化,1024点浮点FFT性能非常可观。
添加依赖最简单的方式是:
idf.py add-dependency "espressif/esp-dsp"然后在代码里包含头文件:
#include "dsps_fft2r.h"使用esp-dsp做实数FFT有三步。第一步初始化,第二步位反转,第三步执行FFT。注意顺序不能乱:
dsps_fft2r_init_fc32(NULL, FFT_SIZE); // 数据在 fft_input 数组中,实数序列 dsps_bit_rev_fc32(fft_input, FFT_SIZE); dsps_fft2r_fc32(fft_input, FFT_SIZE);FFT之后,结果以复数方式存放在原数组里:fft_input[2*i]是第i个频点的实部,fft_input[2*i+1]是虚部。对实信号,有效频点只到N/2,所以i从0到511。
计算幅度时我会直接求能量再转dB:
float re = fft_input[2 * i]; float im = fft_input[2 * i + 1]; float mag_db = 10.0f * log10f(re * re + im * im + 1e-9f);加1e-9是为了避免log10(0)出现负无穷。这个mag_db就是后续频段聚合的原料。S3上这一整套FFT加幅值计算耗时很短,完全支撑得起30fps。
4.3 频段映射、平滑与峰值保持
FFT直接输出的512个频点信息量太大,直接画成512根细柱子不仅难看,人眼也感知不过来。人耳对频率的感知是对数的,所以我会把频点按1/6倍频程聚合成32条柱,从20Hz覆盖到20kHz。
1/6倍频程的意思就是每个频段边界都是上一个边界的2的1/6次方倍,大约是1.122倍。从20Hz开始算32次,正好能到20kHz左右。代码里可以直接用对数公式定位:
int band_index(int bin) { float freq = (float)bin * SAMPLE_RATE / FFT_SIZE; int band = (int)(logf(freq / 20.0f) / logf(2.0f) * 6.0f); if (band < 0) band = 0; if (band >= NUM_BARS) band = NUM_BARS - 1; return band; }聚合时我用能量平均,而不是幅值平均。把同一个band内所有bin的re*re + im*im加起来,除以个数,再开方转dB。这样能避免某一个极端点把整条柱子拉得忽高忽低。
平滑和峰值保持是让频谱“好看”的关键。直接显示原始dB值会非常抖,尤其是音乐播放时瞬时变化很快。我用了两个策略:
第一个是上升跟随、下降缓慢。当前值比显示值大的时候立即更新,比显示值小的时候让显示值按比例衰减:
for (int i = 0; i < NUM_BARS; i++) { if (raw_level[i] > shown_level[i]) { shown_level[i] = raw_level[i]; } else { shown_level[i] = shown_level[i] * 0.92f; } }第二是峰值保持。每条柱子记录一个峰值,实时值超过峰值就更新,否则峰值每个周期减去固定的0.5dB。峰值线会显示为一条慢慢下落的小点,这样画面既有层次又不会太乱。这个效果在只使用lv_chart时不太好直接做,后面讲界面时再给方案。
5. LVGL动态频谱界面实现与代码解析
5.1 用lv_chart还是自绘柱状图
LVGL里做柱状图有两条路线。
第一条是用lv_chart控件。它的优点是API简单,自动处理坐标和刷新,适合快速做出一个频谱界面。代码如下:
lv_obj_t *chart = lv_chart_create(lv_scr_act()); lv_obj_set_size(chart, 320, 180); lv_obj_center(chart); lv_chart_set_type(chart, LV_CHART_TYPE_BAR); lv_chart_set_range(chart, LV_CHART_AXIS_PRIMARY_Y, 0, 100); lv_chart_set_point_count(chart, NUM_BARS); lv_chart_set_div_line_count(chart, 4, 0); lv_chart_series_t *ser = lv_chart_add_series(chart, lv_palette_main(LV_PALETTE_GREEN), LV_CHART_AXIS_PRIMARY_Y);更新时用lv_chart_set_value_by_id逐条设置柱子的值:
for (int i = 0; i < NUM_BARS; i++) { lv_chart_set_value_by_id(chart, ser, i, (lv_coord_t)shown_level[i]); }lv_chart的point_count对应柱子的数量,我设成32。范围0到100把显示值归一化成百分比。
第二条路线是自己创建一排lv_obj容器当柱子,好处是可以给每根柱子单独设置颜色、圆角、透明度,做出来的效果更像专业音频软件。不过更新时要自己管理每根柱子的高度和y坐标,逻辑稍微复杂一点。
我的建议是:先跑通lv_chart,确认数据链路没问题,然后再考虑换成自绘柱子增强视觉效果。如果你的目标是追求霓虹渐变效果,直接自绘柱状图反而更省心,因为lv_chart的每根柱子默认都是同一个series颜色,想改成每根不同颜色还得额外处理。
自绘柱子核心代码思路:
static lv_obj_t *bar[NUM_BARS]; for (int i = 0; i < NUM_BARS; i++) { bar[i] = lv_obj_create(lv_scr_act()); lv_obj_remove_style_all(bar[i]); lv_obj_set_size(bar[i], bar_width, 10); lv_obj_set_pos(bar[i], base_x + i * (bar_width + gap), base_y - 10); lv_obj_set_style_bg_color(bar[i], lv_color_hsv_to_rgb(i * 8, 100, 100), 0); lv_obj_set_style_radius(bar[i], 3, 0); }更新时:
lv_obj_set_height(bar[i], (lv_coord_t)shown_level[i]); lv_obj_set_y(bar[i], base_y - (lv_coord_t)shown_level[i]);注意基准线base_y是屏幕底部柱子起始位置,我给柱子设置y坐标时用基准线减去高度,这样柱子从底部向上生长。
5.2 更新任务与完整代码骨架
为了实现动态刷新,我用LVGL的定时器创建了一个33ms的周期回调,对应30fps。定时器回调里读取最新的频段数据,做平滑,然后更新UI:
lv_timer_t *ui_timer = lv_timer_create(ui_update_cb, 33, NULL);ui_update_cb里的逻辑就是前面平滑算法的实现,最终调用lv_chart_set_value_by_id。整套代码可以归纳成这个骨架:
#define NUM_BARS 32 #define FFT_SIZE 1024 #define SAMPLE_RATE 32000 float bar_level[NUM_BARS]; float bar_shown[NUM_BARS]; lv_obj_t *chart; lv_chart_series_t *ser; static void ui_update_cb(lv_timer_t *timer) { for (int i = 0; i < NUM_BARS; i++) { float target = bar_level[i]; if (target > bar_shown[i]) { bar_shown[i] = target; } else { bar_shown[i] = bar_shown[i] * 0.92f; } lv_chart_set_value_by_id(chart, ser, i, (lv_coord_t)bar_shown[i]); } } void app_main(void) { i2s_init(); dsp_init(); lvgl_init(); xTaskCreatePinnedToCore(audio_task, "audio_task", 8192, NULL, 6, NULL, 0); lv_timer_create(ui_update_cb, 33, NULL); while (1) { lv_timer_handler(); vTaskDelay(pdMS_TO_TICKS(5)); } }audio_task里是I2S读取、去直流、加窗、FFT和频段聚合,最终结果写到bar_level数组。UI定时器只读取这个数组,不做FFT,这是保持流畅的关键。
I2S初始化部分以INMP441为例:
#include "driver/i2s_std.h" i2s_chan_handle_t rx_chan; void i2s_init(void) { i2s_chan_config_t chan_cfg = I2S_CHANNEL_DEFAULT_CONFIG(I2S_NUM_0, I2S_ROLE_MASTER); i2s_new_channel(&chan_cfg, NULL, &rx_chan); i2s_std_config_t std_cfg = { .clk_cfg = I2S_STD_CLK_DEFAULT_CONFIG(SAMPLE_RATE), .slot_cfg = I2S_STD_PHILIPS_SLOT_DEFAULT_CONFIG(I2S_DATA_BIT_WIDTH_32BIT, I2S_SLOT_MODE_STEREO), .gpio_cfg = { .mclk = I2S_GPIO_UNUSED, .bclk = GPIO_NUM_4, .ws = GPIO_NUM_5, .dout = I2S_GPIO_UNUSED, .din = GPIO_NUM_6, .invert_flags = { .mclk_inv = false, .bclk_inv = false, .ws_inv = false, }, }, }; i2s_channel_init_std_mode(rx_chan, &std_cfg); i2s_channel_enable(rx_chan); }INMP441配置成32bit slot是因为硬件输出是24bit,按32bit读取可以让DMA对齐更简单。每次读回来取高24位,前面已经写过。
FFT初始化代码:
#include "dsps_fft2r.h" void dsp_init(void) { dsps_fft2r_init_fc32(NULL, FFT_SIZE); }注意esp-dsp的初始化只需要调用一次,重复调用可能重复申请内存。整个FFT流程在audio_task里循环执行。
5.3 样式升级:圆角柱、渐变、峰值点
lv_chart默认效果比较朴素,想让频谱更好看可以从几个地方改动。
第一是颜色。LVGL 8.3里lv_chart_set_series_color只能对整个series设置一个颜色,柱状图所有柱子同色。如果你想要不同柱子有不同颜色,用自绘lv_obj路线最直接。lv_color_hsv_to_rgb可以生成从红到蓝的渐变:
lv_color_t color = lv_color_hsv_to_rgb(i * 360 / NUM_BARS, 90, 100);第二是圆角。给lv_obj设置lv_obj_set_style_radius(bar[i], 3, 0),柱子顶部会变成圆头,比直角柔和很多。
第三是峰值指示。自绘路线可以每个柱子在柱子顶端放一个2px高的细条,细条的y坐标实时跟随峰值位置。更新时峰值不直接用柱子的高度,而是用一个独立的peak_value数组:
for (int i = 0; i < NUM_BARS; i++) { if (bar_level[i] > peak_value[i]) { peak_value[i] = bar_level[i]; } else { peak_value[i] -= 0.6f; if (peak_value[i] < 0) peak_value[i] = 0; } lv_obj_set_y(peak_dot[i], base_y - (lv_coord_t)peak_value[i]); }峰值下降速度0.6是每帧,对应30fps大概每秒18个单位,视觉上很自然。数值需要根据自己的显示高度微调,没有标准答案。
最后再说一个提升观感的技巧:给频谱加背景。LVGL里可以放一张静态背景图,也可以在chart后面放一个深色lv_obj当底,让频谱浮在深色背景上。这样比纯白底看起来高级不少,也不会增加多少实现成本。
5.4 字体与中文显示的坑
如果你只想显示数字和Hz、dB这些单位,LVGL自带的默认字体完全够用。但想在界面上写“音乐频谱”或者中文标签时,就会遇到字体问题,默认字体不含中文字形。
解决办法是先用字体转换工具生成中文字体,我用的是lv_font_conv。以思源黑体为例,转换命令:
npx lv_font_conv --font NotoSansSC-Regular.otf --size 16 --bpp 4 \ --range 0x20-0x7F \ --range 0x4E00-0x9FA5 \ --format lvgl --output my_font.c0x20-0x7F是ASCII字符,0x4E00-0x9FA5是常用汉字区。注意中文字体文件会很大,如果整个汉字区都转,生成的c文件可能有几百KB,即使只存到Flash,也会增加编译和烧录时间。更好的做法是只包含你界面里用到的几个字,比如“音乐频谱”“峰值”“音量”这些。
生成后的c文件放到工程里,包含头文件后声明:
LV_FONT_DECLARE(my_font);然后在标签上设置字体:
lv_obj_set_style_text_font(label, &my_font, 0);如果显示中文出现方框或乱码,检查两个地方:一是字体文件有没有被编译进固件,二是lv_conf.h里LV_FONT_CUSTOM_DECLARE有没有对应的字体声明。LVGL 9.x和8.3字体加载方式有差异,如果你用的是9.x,记得看对应版本的文档。
6. 常见问题与排查技巧
6.1 采集与FFT环节的故障速查
做这种项目,最难排的问题往往不是代码逻辑,而是信号链路。我把实际遇到过的坑整理成表格:
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 读到的数据全是0 | INMP441的SCK/WS接反,或L/R选择不对 | 交换SCK和WS试一下;L/R分别接GND和3.3V测试 |
| 数据一直满幅 | 增益太高,模拟麦克风信号被削波 | 调低MAX4466的增益电位器,数字麦克风检查slot配置 |
| 低频柱顶满 | 没有去直流,或电源有低频纹波 | 给每帧减均值;给麦克风独立供电,加去耦电容 |
| 频谱毛刺很多 | 没加窗,或FFT点数太小 | 加汉宁窗;换成1024点 |
| FFT结果对不上频率 | 忘记调用位反转 | 检查dsps_bit_rev_fc32是否在FFT之前执行 |
| 数据正常但静音时也有固定频点 | 电源50Hz或屏幕刷新干扰 | 用数字MEMS麦克风;信号源远离屏幕和电源 |
调试采集链路时我会先在串口打印原始采样值,确保波形正常。播放一个1kHz正弦波,看FFT峰值是否出现在约第32个bin上,因为1kHz除以31.25Hz等于32。如果峰值对不上,优先检查采样率配置。
6.2 LVGL与显示环节的故障速查
显示问题通常比采集问题更直观,但也更容易让人一头雾水。
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 白屏 | SPI接线错误、屏幕初始化时序不对 | 先跑官方ST7789测试代码,排除LVGL的影响 |
| 花屏、颜色错乱 | SPI速率太高、RGB565格式不对 | 降速到10MHz试,确认颜色深度为16bit |
| 刷新很慢 | draw buffer太小、SPI无DMA | 增大draw buffer;SPI传输开启DMA |
| 画面闪烁 | 缓冲不足、刷新率设置过高 | 屏幕分辨率不高时,把LV_DISP_DEF_REFR_PERIOD设为30ms |
| LVGL卡死 | flush回调没调用ready,或内存不足 | 检查lv_disp_flush_ready;用esp_heap_caps_print_heap_info看内存 |
| 中文字体方框 | 字体没编译进固件,或字体声明缺失 | 检查LV_FONT_DECLARE,确认c文件参与编译 |
一个非常常见的坑是忘记调用lv_disp_flush_ready。LVGL每次刷新需要等上一帧flushed,如果你在回调里发了像素但没有通知LVGL,界面会卡死或者永远只显示第一帧。
6.3 排查工具与调试手段
我调试这种项目时习惯分层验证,不直接一把梭。
第一步验证麦克风。只写一个I2S读取的代码,把读到的原始数据通过串口打印出来,或者简单算一下RMS值,能确认硬件是否正常工作。
第二步验证FFT。把原始数据固定成1kHz正弦波,或者用手机播放1kHz测试音,确认FFT峰值位置正确。这步过了,说明采样和FFT链路都没问题。
第三步验证屏幕。先跑LVGL官方demo,确认屏幕能刷动画。然后再替换成自己的频谱页面,这样即使后面出问题,也能定位到是自己的UI代码还是屏幕驱动。
调试过程中可以用几个很实用的函数:
esp_timer_get_time(); // 统计FFT耗时 esp_get_free_heap_size(); // 查看堆剩余 uxTaskGetStackHighWaterMark(NULL); // 查看当前任务栈剩余在audio_task里统计一次FFT加频段聚合的耗时,串口打印出来,如果超过10ms就要考虑优化。在UI任务里看栈高水位,如果接近0,任务栈设大点,内存充足就8192起步。
6.4 松耦合和电源布置的实战建议
如果最后做出来发现频谱会被喇叭音量影响,或者播放音乐时柱状图乱跳,多半是拾音方式的问题。麦克风直接拾取环境声会把喇叭播放的音乐一起采进去,形成正反馈啸叫。更好的做法是直接从耳机口取line-in信号,或者改成蓝牙接收音频。如果要坚持麦克风拾音,麦克风尽量远离喇叭,并且给扬声器一个指向性处理。
电源方面,USB供电能跑通,但别在同一根5V线上挂灯带。WS2812这类大电流外设一启动,ADC或I2S数字麦克风很容易被干扰。我建议给屏幕和麦克风各加一组去耦电容,必要时用独立LDO供电,比如AMS1117-3.3单独给麦克风供一路。
代码结构上把audio模块、screen模块、ui模块分开,后续想加按钮切换主题、加蓝牙输入、接灯带都会方便很多。别把FFT函数和LVGL回调写在一起,不然调试一个功能时另一个功能会被牵连。
最后说点个人体会。这个项目真正花时间的不是FFT,也不是LVGL API,而是把“信号噪声”和“显示流畅度”这两件事同时做好。我一开始用MAX4466直接怼ADC,电源共用一根杜邦线,低频柱子就像心电图一样疯狂跳;后来换了INMP441数字麦克风,再给屏幕单独电源走线,世界就安静了。另一个感受是,LVGL做频谱显示其实很吃内存规划和刷新策略,先把数据传输链路调稳,再去追求炫酷效果,会少走很多弯路。接下来我打算给它加一个按键切换显示主题,再挂一路RGB灯带做低频律动,整体当成桌面氛围灯用。如果你也复刻了一版,欢迎一起交流采样率和平滑参数怎么调——这东西真的得靠耳朵和眼睛现场试,没有标准答案。