一、项目概述
本项目以 MAX32690EVKIT 开发板为核心,结合 MAX9867 音频 CODEC、CMSIS-DSP 数字信号处理库和 LVGL 图形库,设计并实现了一套实时音频采集、频谱分析与 128x128 TFT 可视化显示系统。系统通过 I2C 完成 CODEC 参数配置,通过 I2S 接收 24 bit 立体声音频数据,并借助 DMA 双缓冲机制持续搬运音频块;当缓冲区数据就绪后,MAX32690 执行 512 点实数 FFT,完成主频率、幅值和频谱分布提取,最终在 TFT 屏上以中文标签、时域波形和频谱图的形式进行展示。
针对 128x128 小尺寸屏幕的显示空间,界面采用紧凑布局:顶部展示频率和幅值,中部显示时域波形与中文标语“冲向天空的猪”,底部显示频谱图与主峰坐标。PB0 按键用于切换柱状频谱和曲线频谱两种显示方式。
二、系统组成
1. 主控与计算单元
MAX32690EVKIT 作为系统主控,承担外设初始化、音频采集调度、FFT 运算、峰值提取、按键处理和显示刷新等任务。MAX32690 内置 Cortex-M4F 浮点单元,能够满足本项目中实时 DSP 运算对浮点计算能力的需求。
2. 音频采集链路
MAX9867 CODEC 负责模拟音频输入、ADC 转换以及 I2S 数字音频输出。工程通过 I2C 寄存器配置完成系统时钟、DAI 格式、ADC 输入路径、Line-in 增益和输出通道供电设置。I2S 接收端采用 24 bit、MSB first、外部 SCK/WS、立体声模式接收数据。
3. 显示与交互
显示层基于 LVGL 创建标签和图表控件,底层由 lv_port_disp.c 将 LVGL flush 回调适配到 ST7735 TFT 驱动。UI 采用固定布局和 12px 中文字体,在有限屏幕空间内优先呈现测量结果和图形信息。PB0 作为用户交互输入,用于切换频谱显示形态。
三、系统总体数据流
模拟音频输入
-> MAX9867 ADC / CODEC
-> I2S 24 bit 立体声音频流
-> DMA 双缓冲
-> MAX32690 主循环取就绪缓冲
-> 取左声道样本
-> 去直流 + Hann 窗
-> CMSIS-DSP 512 点 RFFT
-> 幅度谱计算
-> 主峰搜索 + 三点插值
-> LVGL 更新波形、频谱、频率和幅值
-> ST7735 TFT 显示

四、主程序流程
主程序采用“中断快速响应、主循环集中处理”的设计思路。DMA 回调仅负责缓冲区切换、就绪标记以及下一次 DMA 传输启动,FFT 运算与 UI 刷新均放在主循环中完成,从而避免在中断上下文中执行耗时任务,提升音频采集链路的稳定性。

为进一步说明双缓冲机制,DMA 回调流程可单独表示如下:

五、关键代码
1. 24 bit I2S 数据取样
I2S 接收数据以 32 bit word 的形式存放,而有效音频样本位于高 24 位。因此,代码通过右移 8 位提取带符号 24 bit 采样值。
static int32_t sample24_from_word(uint32_t word)
{
return ((int32_t)word) >> 8;
}
该函数同时服务于 FFT 处理和 UI 波形显示,是 I2S 原始数据进入音频算法流程的基础转换步骤。
2. Hann 窗生成与 RFFT 初始化
系统启动阶段预先生成 Hann 窗,并完成 CMSIS-DSP 快速实数 FFT 实例初始化。后续主循环处理音频块时,可直接复用窗口系数和 FFT 句柄,减少运行时重复计算。
static void fft_processing_init(void)
{
int i;
for (i = 0; i < FFT_SIZE; i++) {
hann_window[i] =
0.5f - 0.5f * arm_cos_f32((2.0f * PI * (float32_t)i) / (float32_t)(FFT_SIZE - 1));
}
if (arm_rfft_fast_init_f32(&fft_instance, FFT_SIZE) != ARM_MATH_SUCCESS) {
blink_halt("Error initializing FFT");
}
}
3. FFT 主处理流程
compute_fft() 是频谱分析的核心函数,负责完成取样、归一化、去直流、加窗、RFFT、幅度谱计算、峰值搜索和频率换算。由于 I2S 数据按左右声道交错排列,当前实现选取左声道参与分析,因此读取 buffer[i * 2]。
for (i = 0; i < FFT_SIZE; i++) {
int32_t sample = sample24_from_word(buffer[i * 2]);
fft_input[i] = (float32_t)sample / 8388608.0f;
mean += fft_input[i];
}
mean /= (float32_t)FFT_SIZE;
for (i = 0; i < FFT_SIZE; i++) {
fft_input[i] = (fft_input[i] - mean) * hann_window[i];
}
arm_rfft_fast_f32(&fft_instance, fft_input, fft_output, 0);
在幅度谱计算过程中,程序同时维护一份面向显示的平滑谱 fft_display_magnitude。该平滑策略在幅值上升时保持较快响应,在幅值下降时适当延缓衰减,使小屏幕上的频谱变化更加稳定、易于观察。
for (i = 1; i < FFT_BIN_COUNT; i++) {
float32_t real = fft_output[2 * i];
float32_t imag = fft_output[(2 * i) + 1];
fft_magnitude[i] = sqrtf((real * real) + (imag * imag));
if (fft_magnitude[i] > fft_display_magnitude[i]) {
fft_display_magnitude[i] =
(0.65f * fft_display_magnitude[i]) + (0.35f * fft_magnitude[i]);
} else {
fft_display_magnitude[i] =
(0.85f * fft_display_magnitude[i]) + (0.15f * fft_magnitude[i]);
}
}
4. 三点峰值插值
在 48 kHz 采样率下,512 点 FFT 的频率分辨率为 93.75 Hz。若仅取最大 bin 作为主频,1 kHz 输入信号可能被量化到 937.5 Hz 或 1031.25 Hz。为减小这一误差,代码对峰值左右三个幅度点进行抛物线插值,估计真实峰值在 bin 内的偏移量。
left = magnitude[peak_bin - 1];
center = magnitude[peak_bin];
right = magnitude[peak_bin + 1];
denominator = left - (2.0f * center) + right;
if (fabsf(denominator) < 1.0e-12f) {
return (float32_t)peak_bin;
}
offset = 0.5f * (left - right) / denominator;
if (offset < -0.5f) {
offset = -0.5f;
} else if (offset > 0.5f) {
offset = 0.5f;
}
return (float32_t)peak_bin + offset;
频率最终按如下公式换算:
*peak_freq_hz =
(interpolated_bin * SAMPLE_RATE_HZ * SAMPLE_RATE_CALIBRATION) / (float)FFT_SIZE;
其中,SAMPLE_RATE_CALIBRATION 用于后续采样率标定,可在实测采样率与标称值存在偏差时进行修正。
5. DMA 双缓冲回调
DMA 回调函数只处理缓冲区切换和下一次 DMA 启动,不在中断中执行 FFT 或 UI 刷新。该设计缩短了中断执行时间,降低了显示刷新和频谱运算阻塞音频采集链路的风险。
static void dma_callback(int channel, int result)
{
int completed_rx_index;
if (result != E_NO_ERROR) {
blink_halt("DMA callback error");
}
if (channel == dma_ch_rx) {
completed_rx_index = active_rx_index;
active_rx_index ^= 1;
if (ready_buffer >= 0) {
dma_overrun_count++;
}
ready_buffer = completed_rx_index;
MXC_DMA_ReleaseChannel(dma_ch_rx);
dma_ch_rx =
MXC_I2S_RXDMAConfig(rx_buffers[active_rx_index], sizeof(rx_buffers[active_rx_index]));
}
}
6. 主循环调度
主循环在没有 DMA 数据时维护 UI tick 并扫描按键;当 DMA 缓冲就绪时,根据 DISPLAY_DECIMATION 对显示刷新进行分频,避免 TFT 刷新过度占用 CPU 时间。
while (1) {
int local_ready;
if (!dma_flag) {
MXC_Delay(MXC_DELAY_MSEC(UI_SERVICE_PERIOD_MS));
service_pb0();
ui_service(UI_SERVICE_PERIOD_MS);
continue;
}
dma_flag = 0;
service_pb0();
local_ready = ready_buffer;
if (local_ready >= 0) {
ready_buffer = -1;
if (++frame_divider >= DISPLAY_DECIMATION) {
frame_divider = 0;
update_display(rx_buffers[local_ready]);
LED_Toggle(LED2);
}
}
ui_service(1);
}
7. PB0 按键消抖与模式切换
PB0 按键采用软件消抖策略。只有连续多次检测到相同状态时,程序才确认按键状态发生变化;当检测到稳定按下事件后,调用 UI 层接口切换频谱显示类型。
if ((stable_count >= 2) && (raw_pressed != debounced_pressed)) {
debounced_pressed = raw_pressed;
if (debounced_pressed) {
ui_audio_toggle_spectrum_mode();
lv_refr_now(NULL);
}
}
UI 层通过改变 LVGL chart 类型和颜色实现柱状图、曲线图切换:
spectrum_line_mode = !spectrum_line_mode;
lv_chart_set_type(spec_chart, spectrum_line_mode ? LV_CHART_TYPE_LINE : LV_CHART_TYPE_BAR);
lv_chart_set_series_color(spec_chart, spec_series,
spectrum_line_mode ? lv_color_hex(UI_SPEC_LINE_COLOR) :
lv_color_hex(UI_SPEC_BAR_COLOR));
lv_chart_refresh(spec_chart);
8. UI 波形与频谱刷新
ui_audio_update() 负责将音频采样值映射为波形点,将频谱幅度归一化到 0 到 100 的图表范围,并同步更新频率、幅值和主峰坐标等中文标签。
for (i = 0; i < UI_CHART_POINTS; i++) {
int sample_index = 2 * ((i * (audio_samples / 2)) / UI_CHART_POINTS);
int32_t sample = ((int32_t)audio_buffer[sample_index]) >> 8;
wave_points[i] = (lv_coord_t)(sample / UI_WAVE_SCALE_DIVISOR);
}
for (i = 0; i < UI_CHART_POINTS; i++) {
int bin = 1 + (i * (spectrum_bins - 1)) / UI_CHART_POINTS;
spec_points[i] = (lv_coord_t)clamp_percent(spectrum[bin] / spec_scale);
}
波形显示采用固定缩放方式,以保留输入信号的相对幅度变化;频谱显示则以主峰幅值作为归一化参考,使不同输入音量下的主要频率成分仍能在小屏幕上清晰呈现。
9. LVGL 到 TFT 的刷新适配
在 LVGL flush 回调中,程序按行打包颜色数据后再调用 TFT SPI 写入。与逐像素写入相比,按行批量传输能够减少函数调用次数和 SPI 传输开销,从而提升屏幕刷新效率。
for (row = 0; row < height; row++) {
int col;
for (col = 0; col < width; col++) {
uint16_t color = color_p[(row * width) + col].full;
line_buf[(col * 2)] = (uint8_t)(color >> 8);
line_buf[(col * 2) + 1] = (uint8_t)color;
}
TFT_SPI_Write(line_buf, (uint32_t)(width * 2), true);
}
六、关键参数说明
参数 | 当前值 | 作用 |
|---|---|---|
|
| 频率换算使用的标称采样率 |
|
| 采样率校准比例,后续可根据实测修正 |
|
| DMA 每次接收的 32 bit word 数量 |
|
| 实数 FFT 点数 |
|
| 频谱 bin 数量 |
|
| 每 4 个 DMA 块刷新一次显示 |
|
| LVGL 波形图和频谱图点数 |
|
| 时域波形固定缩放系数 |
七、实现难点与解决方案
1. 1 kHz 信号显示为 1031.2 Hz
512 点 FFT 在 48 kHz 采样率下 bin 间隔为:
48000 / 512 = 93.75 Hz
1 kHz 信号并不会严格落在某个 FFT bin 上,而是位于 937.5 Hz 和 1031.25 Hz 两个频点之间。若直接以最大 bin 对应频率作为结果,读数容易偏向邻近频点。项目通过三点峰值插值对峰值位置进行细化估计,使显示结果更接近真实输入频率。
2. 删除串口日志后 CODEC 初始化异常
早期调试阶段,大量 printf 在客观上为 I2C 寄存器写入过程引入了额外延时。减少日志后,CODEC 内部电源和时钟状态可能尚未完全稳定,进而导致音频链路初始化异常。为消除对串口输出速度的隐式依赖,代码通过 CODEC_I2C_WRITE_DELAY_MS 和 CODEC_POWER_DELAY_MS 显式加入必要延时。
3. DMA 与显示刷新抢占资源
TFT 刷新和 FFT 运算都会占用一定 CPU 时间。如果每个 DMA 音频块都触发显示更新,容易造成界面卡顿或采集处理不及时。项目通过 DMA 双缓冲、短回调、主循环处理和 DISPLAY_DECIMATION 分频刷新,在采集稳定性与界面流畅度之间取得平衡。
4. 中文小字体显示问题
LVGL 默认字体不包含中文字符。项目使用 lv_font_conv 生成 12px 中文字体,并采用未压缩位图格式,以匹配当前 LV_USE_FONT_COMPRESSED = 0 的 LVGL 配置。字体文件仅包含界面实际使用的 ASCII 和中文字符,从而在满足显示需求的同时控制 ROM 占用。
八、实物展示
Linein输入1K音频信号
Linein输入5K音频信号
