音频频谱(FFT)
由 Rust + WebAssembly 手写快速傅里叶变换,实时分析声音的频率成分
—
合成信号由 JS 按公式计算,不播放声音,也不需要权限 |
选择麦克风后对着它吹口哨,可以看到清晰的单一峰值
📖 原理说明
任何声音都可以分解成许多不同频率的正弦波的叠加。快速傅里叶变换(FFT)就是把一段波形(时域)转换成各个频率成分的强度(频域)的算法。这个示例用 Rust 手写了 FFT,每一帧分析一段声音,画出波形、频谱和随时间滚动的时频图。
🧮算法原理
Xk = Σn xn · e−2πikn/N离散傅里叶变换:第 k 个频率分量
- 从 O(N²) 到 O(N log N)
- 直接按定义计算 DFT,N 个频率分量每个都要把 N 个采样加一遍,一共 N² 次运算。Cooley-Tukey 算法把长度为 N 的变换拆成偶数下标和奇数下标两个长度为 N/2 的变换,再用「蝶形运算」合并;递归拆到长度 1,总共 log₂N 层,每层 N 次运算。N = 2048 时,运算量从约 420 万次降到约 2.2 万次。
- 迭代实现:位反转 + 蝶形
- 代码没有用递归:先把输入按下标的二进制位反转重新排列(例如 N=8 时,下标 1 = 001 与 4 = 100 交换位置),然后从长度 2 开始,一层层用蝶形运算合并,直到长度 N。整个过程在原数组上完成,不需要额外内存。
- Hann 窗
- FFT 假设输入是无限重复的周期信号,一段波形的首尾接不上时,频谱会「泄漏」到很宽的范围。计算前先乘以 Hann 窗
w(n) = 0.5·(1 − cos(2πn/N)),让两端平滑地降到零,泄漏基本只影响峰值两边相邻的频率格子。
- 峰值频率与音名
- 频率分辨率是
采样率 / N(48 kHz、N = 2048 时约 23 Hz)。为了比这更准,对峰值及其左右两个格子的 dB 值做抛物线插值,估计真正的峰值位置,再换算成最接近的音名和偏差(单位:音分 ¢,1 个半音 = 100 音分)。
🔄Rust 与 JavaScript 的分工
- JS每一帧取得一段长度为 N 的采样:合成信号由 JS 按公式实时计算(C 大三和弦,或 100 Hz → 10 kHz 的指数扫频);麦克风信号从 Web Audio 的
AnalyserNode.getFloatTimeDomainData() 读取。
- RustJS 调用
fft_magnitudes(采样)。Rust 加 Hann 窗、做 FFT,返回 N/2 + 1 个频率格子的幅度,已经归一化:振幅为 A 的正弦波,峰值读数约等于 A。
- JSJS 把幅度换算成分贝,按对数频率轴画出波形、频谱柱状图和时频图,并插值计算峰值频率。
浏览器的 AnalyserNode 其实自带 FFT(getFloatFrequencyData)。这里故意只从它取原始波形,由 Rust 来做变换,这样合成信号和麦克风信号走的是同一套算法。
⚡性能要点
- FFT 是整个示例里唯一的重计算。2048 点一次通常远小于 1 毫秒,底部会显示实际耗时;即使 4096 点,也远低于一帧 16.7 毫秒的预算。
- FFT 长度越大,频率分辨率越高,但每次分析覆盖的时间也越长(4096 点 @ 48 kHz ≈ 85 毫秒),对快速变化的声音反应会变慢。这是时间分辨率与频率分辨率之间固有的取舍。
- 蝶形运算的旋转因子在每一层按
f64 计算一次再转成 f32,避免逐步累乘带来的误差积累;测试里把结果与 O(N²) 的朴素 DFT 逐项对比,误差小于 10⁻³。
源码crates/media/spectrum/src/lib.rswww/spectrum/index.js