项目概述

本项目设计并实现了一套基于 MAX32690 EVKIT 的实时音频可视化系统。系统以 MAX9867 立体声音频编解码器为采集前端,以 48 kHz 采样率对模拟音频信号进行数字化;采样数据经 I2S 从模式接口与 DMA 双缓冲(乒乓)机制传输至主控制器,使采集过程全程无需 CPU 干预。主循环对 2048 点窗口执行实数 FFT 频谱分析,并将分析结果以四种可视化模式实时呈现于 128×128 分辨率的 ST7789 TFT 显示屏上,支持波形与频谱复合显示、频谱瀑布图、频谱热力图及示波器模式。

系统软件完全基于 Rust 语言环境开发。通过四项性能优化—— Pulse-Train Engine 模拟 SPI、硬件 RAM 清零、ICC 统一缓存及增量渲染,使波形与频谱模式提升至 46 fps,接近 DMA 采集周期(21.3 ms)所决定的上限。
硬件平台
主控 MAX32690
- ARM Cortex-M4F @ 120 MHz,带硬件 FPU
- 32-bit RISC-V RV32 协处理器
- 3.25 MB Flash / 1 MB SRAM
- 集成蓝牙 5.2 LE
- 16 KB 统一缓存(指令 + 数据,2 路组相联)
- Pulse-Train Engine
音频编解码器 MAX9867
- 16-bit ADC, fS ≤ 48 kHz
- 90 dB(DAC)/ 85 dB(ADC)信噪比
- 项目仅使用 Line In 采集路径
TFT显示屏
- 128×128 像素,16-bit RGB565
- ST7789 驱动
- SPI 外设已被 MAX9867 占用,显示传输必须软件模拟 SPI,这是性能优化的起点
系统架构

模拟音频信号经 Line In 输入 MAX9867 编解码器,由片内 ADC 以 48 kHz 采样率数字化。数字音频经 I2S 从模式接口输出,由 DMA 通道 0 搬运至片上 SRAM 中的两块乒乓缓冲区(BUF_A / BUF_B),每块容量 2048 字(左声道1024 点,右声道 1024点, 共8 KB)。DMA 每完成一块传输即触发一次中断,中断服务程序负责切换 DMA 目标缓冲并置位就绪标志,从而在硬件层面形成连续不间断的采集流。
主循环检测到就绪标志后,先将 DMA 缓冲内容快速拷贝至本地缓冲,使 DMA 得以安全覆盖源缓冲,随后进入处理流程:将左声道的 1024 点数据写入环形缓冲,和上一次 DMA 采样的数据组成 2048 点数据,然后添加 Hann窗、执行实数 FFT 频谱分析,并按当前模式分发至对应的渲染函数。
各模式将处理结果绘制到位于 RAM6(地址 0x200C_0000)的帧缓冲中,随后经 Pulse-Train Engine 模拟 SPI 接口逐矩形(或整帧)刷写至 ST7789 显示屏。

采集链路完全由中断与 DMA 驱动,CPU 不参与数据传输;软件处理与硬件采集以乒乓缓冲为界并行执行,构成两条并行的流水线。处理速度必须快于 DMA 填充速度(48Khz 采样率,每 21.3 ms 完成一次 1024 点采样)才能实现 46 fps 的零丢帧运行。
音频采集与数据传输
DMA 中断与双缓冲
为消除"采集"与"处理"之间的互斥等待,系统采用乒乓双缓冲:DMA 向缓冲 A 写入的同时,主循环处理缓冲 B 中上一块已就绪的数据,两块缓冲交替使用,写读互不干扰。该机制由 DMA 完成中断驱动,中断服务程序承担四项职责:
- 记录传输间隔:将本次 DMA 完成时刻与上次完成时刻之差写入全局计数器,用来统计和打印 DMA 耗时;
- 清除中断标志:写 DMA 状态寄存器,确认本次中断已响应;
- 发布就绪信息:翻转缓冲索引
IDX,将当前已完成传输的缓冲编号存入COMPLETED,并置位就绪标志READY,供主循环轮询; - 重装 DMA 描述符:将 DMA 目标地址切换至另一块缓冲,重新使能,使采集在中断返回后立即继续。
#[interrupt]
fn DMA0() {
// 记录 DMA 间隔(用于 FPS 测量)
// ...
let mut ch = dma::DmaGlobalMmio::channel(0);
ch.write_status(DmaStatus::new_with_raw_value(1 << 2)); // 清中断标志
// 切换 DMA 目标缓冲(A ⇄ B),置 READY=1 通知主循环
}
主循环侧则在检测到就绪标志后,立即将 DMA 缓冲内容整体拷贝至本地缓冲(LOCAL_BUF),此后 DMA 即可安全覆盖源缓冲,而主循环得以在本地副本上从容完成处理与渲染,两者不再争用同一片内存:
if READY.swap(false, Ordering::Acquire) { // 缓冲区 ready
// 复制数据
unsafe {
core::ptr::copy_nonoverlapping(
src as *const u32,
core::ptr::addr_of_mut!(LOCAL_BUF) as *mut u32,
DMA_SAMPLES,
);
}
let t1 = TICK.load(Ordering::Relaxed); // 用来计算 process_and_render 的时间
// 处理和渲染逻辑
process_and_render(unsafe { &*core::ptr::addr_of!(LOCAL_BUF) }, mode);
// ...
let t2 = TICK.load(Ordering::Relaxed); // 用来计算 process_and_render 的时间
FRAME_TIME.store(t2.wrapping_sub(t1), Ordering::Relaxed); // 记录帧处理时间
环形缓冲与窗口构建
每块 DMA 数据包含 1024 对 L/R 交织样本,我们只处理一个声道 1024 点的数据。extract_channels() 将新数据归一化写入 2048 点环形缓冲,和上次采样的 1024 点数据组成 2048 点数据,实现 50% 重叠(窗口 2048,每次前移 1024)。
const DMA_PAIRS: usize = 1024;
const DMA_SAMPLES: usize = DMA_PAIRS * 2; // 2048 words (L+R interleaved)
const WINDOW: usize = 2048; // FFT window, ring = 50% overlap
fn extract_channels(buf: &[u32; DMA_SAMPLES]) -> ([f32; WINDOW], [f32; WINDOW], bool) {
// 1. 归一化写入环形缓冲(u32 → f32)
for i in 0..DMA_PAIRS {
let raw_l = (buf[i * 2] as i32 as f32) / 2147483648.0;
let raw_r = (buf[i * 2 + 1] as i32 as f32) / 2147483648.0;
unsafe {
RING_L[(RING_POS + i) % WINDOW] = raw_l;
RING_R[(RING_POS + i) % WINDOW] = raw_r;
}
}
let new_pos = (unsafe { RING_POS } + DMA_PAIRS) % WINDOW;
// ... 构建 2048 点窗口(最旧 → 最新)...
}
信号处理
去直流
音频信号本质上是围绕零电平摆动的交流信号,但采集链路不可避免地引入直流偏置,使信号中心偏离零点。若不去除该偏置,将会影响数据处理。MAX9867 本身提供硬件 DC-blocking 滤波器(MODE=1 时,寄存器值非零即使能)。但是本系统没有开启这个功能,相反是将去直流置于软件信号处理链路。
窗口均值会随相位振荡(如 42.7 ms 窗口内的 32 Hz 信号),因此采用长时 EMA 平滑而非逐窗均值:
// 4. 长时 EMA 去直流,避免窗口内信号周期非整数时均值振荡
static mut DC_L: f32 = 0.0;
static mut DC_R: f32 = 0.0;
let mean_l = sum_l / WINDOW as f32;
let mean_r = sum_r / WINDOW as f32;
DC_L = DC_L * 0.99 + mean_l * 0.01;
DC_R = DC_R * 0.99 + mean_r * 0.01;
for i in 0..WINDOW {
samples_l[i] -= DC_L;
samples_r[i] -= DC_R;
}
波形路径
对同一 2048 点窗口,两条路径并行:
- 显示路径:每 8 点取 1 抽成 256 点(
WF_HIST),映射 128 列画折线,降采样仅用于显示; - 测量路径:在完整窗口上过零计数算频率,并算过零间隔变异系数 CV 衡量周期性强弱。
fn render_waveform(samples: &[f32; WINDOW]) {
// ---- 显示路径:降采样 2048 → 256 点(每 8 点取 1)----
for i in 0..WF_HIST {
let si = (i * WINDOW / WF_HIST).min(WINDOW - 1);
(*wf_buf)[(wf_head + i) % WF_HIST] = samples[si];
}
WF_HEAD = (wf_head + WF_HIST) % WF_HIST;
// ---- 测量路径:完整窗口峰值 + 过零频率 ----
let mut peak = 1e-9; let mut crossings = 0u32; let mut first_cross = 0;
let mut last_cross = 0; let mut prev_cross_idx = 0;
let mut intervals_sum = 0.0; let mut intervals_sq = 0.0;
let mut prev_sign = samples[0] >= 0.0;
for i in 0..WINDOW {
let a = samples[i].abs();
if a > peak { peak = a; }
let curr_sign = samples[i] >= 0.0;
if prev_sign != curr_sign {
crossings += 1;
if first_cross == 0 { first_cross = i; prev_cross_idx = i; }
else {
let interval = (i - prev_cross_idx) as f32;
intervals_sum += interval; intervals_sq += interval * interval;
prev_cross_idx = i;
}
last_cross = i;
}
prev_sign = curr_sign;
}
// 频率(EMA 平滑防闪烁);每周期含 2 次过零
let raw_freq = if crossings >= 2 && last_cross > first_cross {
48000.0 / ((last_cross - first_cross) as f32 / (crossings - 1) as f32)
} else { 0.0 };
WF_FREQ = WF_FREQ * 0.8 + raw_freq * 0.5 * 0.2;
// 周期性强弱:过零间隔变异系数 CV(0=纯音稳定,1+=噪声/音乐)
let raw_cv = if crossings >= 3 {
let n = (crossings - 1) as f32;
let mean = intervals_sum / n;
let variance = intervals_sq / n - mean * mean;
if variance > 0.0 && mean > 0.0 {
(libm::sqrt(variance as f64) as f32) / mean
} else { 0.0 }
} else { 1.0 };
WF_FREQ_CV = WF_FREQ_CV * 0.8 + raw_cv * 0.2;
// ...
}
只有 CV < 0.5(周期性信号)才显示过零计数频率:
let cv = unsafe { *core::ptr::addr_of!(WF_FREQ_CV) };
let periodic = cv < 0.5;
let wf_freq = if periodic { unsafe { *core::ptr::addr_of!(WF_FREQ) } } else { 0.0 };
if wf_freq > 1.0 {
// 在频谱区叠加显示 "xxx Hz"(周期性才显示)
}
频谱路径
- 静音门控:峰值过低 → 立即清零平滑/峰值状态并返回(不等 EMA 慢慢衰减);
- 加窗 + FFT:Hann 窗 →
rfft_2048()→ 1024 个复频点; - 频段聚合:24 个对数频段,每段取频点幅度最大值(
max_bin); - 峰值保持:记录每个频道最大峰值,主要用于可视化,EMA(α=0.7)+ 三级峰值衰减(0.85/0.92/0.98)。
let spectrum: &mut [Complex32; 1024] = rfft_2048(samples);
// 24 频段:每段取 max_bin 幅度
let mut band_mags: [f32; NUM_BANDS] = [0.0; NUM_BANDS];
for b in 0..NUM_BANDS {
let (lo, hi) = BAND_BINS[b];
let mut max_bin = 0.0;
for k in lo..=hi {
let re = spectrum[k].re; let im = spectrum[k].im;
let mag = re * re + im * im;
if mag > max_bin { max_bin = mag; }
}
band_mags[b] = max_bin;
}
// 主导频率:非周期信号(音乐/语音)用过零计数无意义,改扫 FFT bin 2..512
let mut dom_bin = 0; let mut dom_mag = 0.0;
for k in 2..512 {
let re = spectrum[k].re; let im = spectrum[k].im;
let mag = re * re + im * im;
if mag > dom_mag { dom_mag = mag; dom_bin = k; }
}
let dom_freq = if dom_mag > 0.0 { dom_bin as f32 * 48000.0 / 2048.0 } else { 0.0 };
WF_DOM_FREQ = WF_DOM_FREQ * 0.7 + dom_freq * 0.3;
// EMA 平滑 + 分级峰值衰减(快衰 0.85 / 中衰 0.92 / 慢衰 0.98)
let alpha = 0.7; let beta = 1.0 - alpha;
for b in 0..NUM_BANDS {
let old = MAG_SMOOTH[b];
let new_val = old * alpha + band_mags[b] * beta;
MAG_SMOOTH[b] = new_val;
let old_peak = PEAK_HOLD[b];
let decay = if new_val < old_peak * 0.01 { 0.85 }
else if new_val < old_peak * 0.3 { 0.92 }
else { 0.98 };
PEAK_HOLD[b] = if new_val > old_peak { new_val } else { old_peak * decay };
}
四种可视化模式
模式一:波形 + 频谱(默认)
上:128 列波形折线;下:24 段对数频谱柱(47 Hz–20 kHz)+ 频率数字叠加。
模式二:频谱瀑布图(128 段 Mel)
128 段 Mel 刻度频谱,随时间向下滚动(低频在左、高频在右)。
模式三:频谱热力图(8×8 网格)
8×8 彩色方块网格,每方块映射一个对数频段,EMA 平滑过渡(低频强、高频弱)。
模式四:示波器
每帧触发扫描并对齐显示窗口,相位锁定避免波形滚动。五步流程:
- AC 耦合:按显示窗口抽样估计 DC(EMA),从样本中减去;
- 触发扫描:最旧→最新找最近上升沿(
trig_level+ 5% 滞回),留足后触发样本 - 窗口对齐:触发沿定
roll= 触发点 − pre_trig → 相位锁定、逐帧前移整周期;无触发沿则自动滚动 - 测量:过零计数测频;触发点→下降沿→下一上升沿算占空比;
- AUTOSET(短按):量 Vpp/中点 → 触发电平设中点 + 5% 滞回 → 1-2-5 标准时基吸附 → 显示 ~2 周期;垂直量程 = 70% Vpp。
关键代码——触发扫描与相位锁定:
// 显示窗口为 [ti - pre_trig, ti + disp_samples - pre_trig);
// 读取越过最新样本 (ring_pos) 会读到被覆盖的旧数据,
// 因此只接受至少 need 个样本之前的边沿;保留最旧→最新扫描中
// 最后一个满足条件的边沿 → 逐帧前移整周期(相位锁定)。
let need = osc.disp_samples.saturating_sub(osc.pre_trig);
let mut armed = false;
let mut trig_idx: Option<usize> = None;
for offset in 0..WINDOW - 1 {
let idx = (ring_pos + 1 + offset) % WINDOW; // 最旧样本开始
let curr = ring[idx] - osc.dc;
if edge_armed(&mut armed, curr, osc.trig_level, osc.hyst) {
let age = (ring_pos + WINDOW - 1 - idx) % WINDOW; // 距最新样本的年龄
if age >= need {
trig_idx = Some(idx); // 保留最后一个满足条件的边沿
}
}
}
if let Some(ti) = trig_idx {
osc.roll = (ti + WINDOW - osc.pre_trig) % WINDOW;
OSC_FROZEN.store(true, Ordering::Relaxed);
} else {
osc.roll = ring_pos; // 无沿:锚定最旧样本自动滚动
OSC_FROZEN.store(false, Ordering::Relaxed);
}
占空比测量——从触发点出发,找下降沿再到下一上升沿:
// 触发点 → 下降沿
let valid_ti = (ring_pos + WINDOW - ti) % WINDOW;
let scan_lim = valid_ti.min(WINDOW / 2);
let mut fall_idx = None;
for off in 1..scan_lim {
let s = ring[(ti + off) % WINDOW] - osc.dc;
if s < osc.trig_level - osc.hyst { fall_idx = Some((ti + off) % WINDOW); break; }
}
// 下降沿 → 下一上升沿,算周期与高电平时间 → 占空比
if let (Some(fi), Some(nr)) = (fall_idx, next_rise) {
let period = (nr + WINDOW - ti) % WINDOW;
let high = (fi + WINDOW - ti) % WINDOW;
if period > 0 {
osc.duty_pct = (high as f32 / period as f32 * 100.0).clamp(0.0, 100.0);
}
}
性能优化
Pulse Train 模拟 SPI
ST7789 需要 SPI 传输,但 SPI 外设被 MAX9867 占用。早期用 GPIO bit-bang 逐位翻转,全屏刷新仅 23 fps。
优化:用 Pulse-Train Engine(PT),把波形写进一个 32 位寄存器(PTn_TRAIN),硬件按 PTn_RATE_LENGTH.rate 时钟分频自动连续输出,CPU 无需逐位干预。
- PT10 = MOSI:输出命令/像素数据(图案:1 bit DC + 8 数据位 ×2 周期 = 18 位)
- PT11 = CLK:位时钟,图案
0b1010_1010_1010_1010_10(0/1 交替) - 两实例经 PTG
write_safe_en()原子同步启动,同拍输出
初始化
const CLK_PATTERN: u32 = 0x2AAAA; // 18 位 0/1 交替
let mut pt_mosi = Pt::new(10);
let mut pt_clk = Pt::new(11);
// rate_length: 高 5 位为 rate(分频),PT10=18 位输出、PT11=19 位输出
pt_mosi.write_rate_length((18 << 27) | (rate_control & 0x7FFF_FFFF));
pt_clk.write_rate_length((19 << 27) | (rate_control & 0x7FFF_FFFF));
pt_clk.write_train(CLK_PATTERN);
pt_mosi.write_loop_ctrl(1);
pt_clk.write_loop_ctrl(1);
发送数据
// 图案:1 bit DC + 8 数据位,每位重复 2 周期(共 18 bit)
fn mosi_pattern(dc: bool, data: u8) -> u32 {
let mut p = ((dc as u32) * 3) & 0x3;
for i in 0..8 {
let b = ((data >> (7 - i)) & 1) as u32;
p |= (b * 3) << (2 * (i + 1));
}
p
}
// 发送一字节:写图案 → 同步使能 → 等完成 → 清标志
fn transfer(&mut self, dc: bool, data: u8) {
self.pt_mosi.write_train(Self::mosi_pattern(dc, data));
self.ptg.write_safe_en(self.mosi10 | self.clk11); // PT10+PT11 同拍启动
let mask = self.mosi10 | self.clk11;
while self.ptg.read_stop_intfl() & mask != mask {} // 忙等两实例完成
self.ptg.write_stop_intfl(mask);
}
全程 CPU 只做寄存器读写,不碰位级翻转。改用 PT 硬件自动输出后,全屏刷新帧率提升到 35 fps。
硬件清零
模式切换时需要清零帧缓存,用 GCR MEMZ 对 RAM6 触发硬件清零:
// 仅模式切换时调用一次
if MODE_SWITCH.swap(false, Ordering::Relaxed) {
gcr.modify_memz(|mut m| { m.set_ram6(true); m }); // 标记 RAM6 待清零
while gcr.read_memz().ram6() {} // 轮询硬件完成(~50 ns)
delta_reset(); // 清空脏矩形状态
}
统一缓存
Flash 访问需 2 个等待状态,对 FFT、Hann 窗这类计算密集代码,Flash 延迟是主要瓶颈。ICC0 是 16 KB、2 路组相联的统一缓存(指令 + 数据),命中时零等待状态,等效 SRAM 执行速度。
热路径(FFT + 渲染)约 10 KB,首次迭代后驻留缓存,后续帧大幅加速。开启方式:
pub fn enable(&mut self) {
// 1. Disable
self.write_ctrl(0);
// 2. Invalidate
self.write_invalidate(1);
// 3. Wait for invalidate to complete
while self.read_ctrl() & ctrl::RDY == 0 {}
// 4. Enable
self.write_ctrl(ctrl::EN);
// 5. Wait for ready
while self.read_ctrl() & ctrl::RDY == 0 {}
}
实测 PT 模拟 SPI, 全屏刷新帧率 35 → 52 fps(+49%), GPIO bit-bang 不受影响。
增量渲染(dirty-rect)
核心思想:帧缓冲跨帧持久,每帧只清旧几何、画新几何,并仅把变化过的矩形经 SPI 刷到屏幕。
// ---- Delta rendering state (Waveform mode) ----
// WF_OLD[x] = 上一帧第 x 列波形垂直跨度 [lo, hi](0xFF = 无)
// BAR_OLD/PEAK_OLD/TEXT_OLD/GRID_OLD/OSC_OLD = 各类几何的旧状态
const MAX_DIRTY: usize = 288;
static mut DIRTY_N: usize = 0;
static mut DIRTY: [(u8, u8, u8, u8); MAX_DIRTY] = [(0, 0, 0, 0); MAX_DIRTY];
static mut WF_OLD: [[u8; 2]; W] = [[0xFF; 2]; W];
先清后画(每帧):清旧跨度 → 画新跨度 → 新旧并集为该列脏矩形 → 垂直合并相邻列 → 记录脏矩形:
// Phase A: 清上一帧的跨度(恢复背景色)
for xi in 0..W {
let old = WF_OLD[xi];
if old[0] != 0xFF {
fill_rect(xi, old[0] as usize, 1, old[1] as usize - old[0] as usize + 1, 0x0000);
}
}
// Phase B: 计算并画出新跨度,记录每列脏跨度
// Phase C: 相邻列跨度相接 → 合并为一个矩形,减少刷新次数
while i < W {
// ... 合并 [i..j] 的垂直相接跨度 ...
delta_dirty(i, lo, j - i + 1, hi - lo + 1);
i = j + 1;
}
脏矩形上限兜底:MAX_DIRTY = 288,溢出则本帧全屏回退(实测 0 次触发):
fn delta_dirty(sx: usize, sy: usize, w: usize, h: usize) {
if DIRTY_N < MAX_DIRTY {
DIRTY[DIRTY_N] = (sx as u8, sy as u8, w as u8, h as u8);
DIRTY_N += 1;
} else {
FULL_FLUSH = true; // 溢出 → 本帧全屏回退
}
}
频段与文本:BAR_OLD / PEAK_OLD 只清未重绘的旧条与峰值刻度;TEXT_OLD 只清旧频率文本框伸出新框的部分,并恢复被文本框遮住的频谱柱(restore_bars())。
刷新出口:全屏标志置位则整帧刷新,否则逐矩形刷新:
fn delta_flush(tft: &mut Tft) {
if FULL_FLUSH {
FULL_FLUSH = false; DIRTY_N = 0;
tft.flush_framebuffer(FB as *const u16, (W * H) as u32);
return;
}
let n = DIRTY_N;
for i in 0..n {
let (sx, sy, w, h) = DIRTY[i];
tft.flush_rect(sx as u16, sy as u16, w as u16, h as u16, FB as *const u16, W as u16);
}
}
模式切换:长按按键置 MODE_SWITCH 标志,主循环在两帧之间执行"硬件清零 + 重置脏状态 + 整帧刷新",保证新模式首帧画面干净。
实测波形+频谱模式可以在20ms内完成,可以实现 46 fps,而频谱瀑布还是采用全刷,需要 29 ms。
后续改进方向
- 蓝牙音频分析:利用 MAX32690 自带蓝牙 5.2,接入手机/耳机音频流实时分析;
- 结合 RISC-V 协处理器:将 FFT 等 DSP 任务分担给片上 RV32 核,释放 CM4 主核;
- 更多可视化模式:频谱瀑布图 3D 化、圆形频谱、柱状立体显示等;
总结
本项目以 MAX32690 + Rust 实现了从 Line In 到屏幕的完整实时音频可视化链路:
- 采集:I2S + DMA 乒乓双缓冲,硬件采集与软件处理并行,全程无 CPU 干预;
- 分析:2048 点 FFT + 24 频段对数聚合 + 双频率检测(过零 / FFT 主导,CV 自动切换);
- 显示:Pulse-Train Engine 模拟 SPI 替代 bit-bang,硬件自动输出 18 位图案;
- 性能:四项优化将帧率从 23 fps 提至 46 fps,逼近 DMA 采集上限。