4. 降噪算法入门

FIR滤波器,全称有限脉冲响应滤波器。说白了,它就是个加权平均器——把当前输入和过去N-1个输入按系数加权求和。没有反馈,天生稳定,相位线性。这些优点让它成了音频DSP里的常客。

我最早接触FIR是在做耳机降噪的时候。那时候刚入行,觉得IIR更省资源,结果相位乱成一团。后来才明白,降噪这种对相位敏感的场景,FIR才是正解。

4.1 FIR的核心原理

数学上,FIR的差分方程很简单:

y[n] = b0·x[n] + b1·x[n-1] + ... + bN-1·x[n-N+1]

其中b0到bN-1就是滤波器系数,也叫抽头系数。N是阶数,阶数越高,滤波器的频率响应越陡峭,但延迟和计算量也越大。

频域上看,FIR的频率响应就是系数的离散傅里叶变换。设计FIR,本质上就是找一组系数,让它的频响逼近你的目标。

关键点:FIR的相位响应是线性的,这意味着所有频率成分的延迟相同。在音频处理中,这能避免群延迟失真——说白了就是声音不会"散掉"。

4.2 窗函数设计法

设计FIR最常用的方法就是窗函数法。步骤很简单:

  1. 先算出理想滤波器的无限长脉冲响应
  2. 用窗函数截断它,得到有限长的系数

理想低通滤波器的脉冲响应是sinc函数:

hd[n] = sin(ωc·(n - (N-1)/2)) / (π·(n - (N-1)/2))

但直接截断会产生吉布斯现象——通带和阻带会有振荡。窗函数就是用来抑制这个的。

我常用的窗函数有这些:

窗类型 主瓣宽度 旁瓣衰减 适用场景
矩形窗 最窄 -13 dB 对阻带衰减要求低
汉宁窗 中等 -31 dB 通用音频处理
海明窗 中等 -41 dB 语音降噪常用
布莱克曼窗 最宽 -57 dB 需要高阻带衰减
凯泽窗 可调 可调 灵活设计

选窗函数时有个权衡:主瓣越宽,过渡带越缓;旁瓣越低,阻带衰减越好。我一般先用凯泽窗,调β参数来平衡这两者。

经验之谈:做ANC降噪时,我习惯用海明窗。它的旁瓣衰减够用,主瓣宽度适中,不会让降噪频段过渡太慢。有一次用布莱克曼窗,过渡带太宽,低频降噪效果反而变差了。

4.3 对称性优化

FIR系数有个重要性质:线性相位FIR的系数是对称的。具体分四种情况:

  • 奇对称、奇长度:系数关于中心点奇对称
  • 偶对称、奇长度:系数关于中心点偶对称
  • 奇对称、偶长度:系数关于中心线奇对称
  • 偶对称、偶长度:系数关于中心线偶对称

音频处理中,最常用的是偶对称、奇长度。为什么?因为这种滤波器在低频和高频都有良好的响应。

利用对称性,乘法次数可以减半:

// 普通实现:N次乘法
for (i = 0; i < N; i++)
    y += b[i] * x[i];

// 对称优化:N/2次乘法
for (i = 0; i < N/2; i++)
    y += b[i] * (x[i] + x[N-1-i]);
if (N % 2 == 1)
    y += b[N/2] * x[N/2];

别小看这个优化。在嵌入式平台上,乘法比加法贵得多。我曾经在一个Cortex-M4上做128阶FIR,对称优化后MIPS直接降了40%。

注意:对称优化只适用于线性相位FIR。如果你设计的FIR不是线性相位(比如最小相位FIR),系数不对称,这个优化就不能用。

4.4 循环缓冲区实现

FIR的实时实现需要维护一个延迟线——就是过去N-1个输入样本。最直接的做法是每次把数据往后挪,但这样效率太低。

循环缓冲区(Circular Buffer)是标准解法:

typedef struct {
    float *buffer;    // 缓冲区
    int size;         // 缓冲区大小(≥阶数)
    int index;        // 当前写入位置
} circ_buf_t;

float fir_process(circ_buf_t *cb, float *coeffs, int order, float input) {
    float y = 0.0f;
    int i, idx;
    
    // 写入新样本
    cb->buffer[cb->index] = input;
    
    // 卷积计算
    idx = cb->index;
    for (i = 0; i < order; i++) {
        y += coeffs[i] * cb->buffer[idx];
        idx = (idx - 1 + cb->size) % cb->size;
    }
    
    // 更新写入位置
    cb->index = (cb->index + 1) % cb->size;
    
    return y;
}

这里有个细节:缓冲区大小最好设为2的幂次。这样取模运算可以用位运算替代:

idx = (idx - 1) & (cb->size - 1);  // 比取模快得多

我在一个实时音频系统里用过这个技巧,每次FIR处理节省了约15个时钟周期。对于48kHz采样率、256阶滤波器来说,这相当于省了约5%的CPU占用。

4.5 SIMD加速

现代处理器都有SIMD指令集——ARM的NEON、x86的SSE/AVX、RISC-V的V扩展。它们能一次处理多个数据,非常适合FIR这种乘加密集的计算。

以ARM NEON为例,128位寄存器可以同时处理4个float:

#include <arm_neon.h>

void fir_neon(float *y, float *coeffs, float *buffer, int order) {
    float32x4_t v_sum = vdupq_n_f32(0.0f);
    int i;
    
    for (i = 0; i < order; i += 4) {
        float32x4_t v_coeff = vld1q_f32(&coeffs[i]);
        float32x4_t v_data  = vld1q_f32(&buffer[i]);
        v_sum = vmlaq_f32(v_sum, v_coeff, v_data);
    }
    
    // 将4个部分和累加
    float sum[4];
    vst1q_f32(sum, v_sum);
    *y = sum[0] + sum[1] + sum[2] + sum[3];
}

这段代码一次处理4个系数和4个数据,理论上能加速4倍。实际测试中,受限于内存带宽和流水线停顿,加速比大约在2.5~3.5倍之间。

做SIMD优化时,有几个坑要注意:

  • 内存对齐:NEON要求16字节对齐,否则会触发异常或性能下降
  • 边界处理:阶数不一定是4的倍数,最后几个点要单独处理
  • 数据预取:用__builtin_prefetch提前加载数据,减少cache miss

避坑指南:我曾经在一个项目里直接用NEON内联函数,结果发现性能还不如编译器自动向量化。后来查了编译器报告,发现是内存没对齐。对齐之后,性能才真正起飞。所以我的建议是:先让编译器自动向量化,不行再手写SIMD。

4.6 本章知识体系

下面这张图总结了FIR滤波器设计与优化的核心脉络:

FIR滤波器设计与优化知识体系 FIR滤波器 核心原理 窗函数设计 工程优化 实现方式 线性相位 有限脉冲响应 差分方程 理想sinc函数 窗函数截断 吉布斯抑制 对称性优化 SIMD加速 内存对齐 循环缓冲区 位运算取模 数据预取 核心思路:从数学原理出发,结合硬件特性做针对性优化 最终在嵌入式平台上实现高性能实时FIR滤波

从这张图能看出来,FIR的设计与优化是个系统工程。原理是基础,窗函数设计是方法,对称性和SIMD是性能手段,循环缓冲区是工程实现。缺一个,你的滤波器就跑不远。

我个人习惯是:先确定滤波器规格(通带、阻带、阶数),用窗函数法生成系数,然后做对称优化,最后根据目标平台选择SIMD方案。这个流程我用了很多年,基本没出过问题。