频域维纳滤波降噪算法——设计思路与工程手记
写在前头
这份笔记记录的是一个面向实时语音通信的频域降噪算法。它的设计目标很明确:在 48kHz 采样率下,以 10ms 帧长实时处理单通道语音,把稳态和准稳态背景噪声压下去,同时尽量不伤语音。算法脱胎于 WebRTC 的 Noise Suppression 模块,但经过了面向嵌入式的重构——所有动态分配被消除,所有 C++ 模板和虚函数被拍平成纯 C 结构体。
我按照信号流经的顺序来谈。先说为什么要这么做,再说具体怎么做。
1. 信号模型
一切降噪算法的出发点都是同一个假设:麦克风采集到的信号 $x(n)$ 是干净语音 $s(n)$ 和加性噪声 $d(n)$ 的叠加:
这个模型简单到近乎天真,但它成立的前提条件其实很苛刻:噪声和语音不相关,噪声在短时间尺度上是准平稳的。实际工程中,空调嗡嗡声、风扇声、马路上的低频轰鸣,在几十毫秒内确实变化不大——这就够了。
我们的目标是从 $x(n)$ 中恢复 $\hat{s}(n)$。在频域里,这意味着对每个频率 bin 施加一个增益 $G(k)$:
问题归结为:怎么求这个 $G(k)$?
2. 为什么选频域
时域降噪(比如 LMS 自适应滤波)需要参考信号,这在单麦克风场景下不现实。频域方法的好处是:
频率分辨率。噪声和语音在不同频段的能量分布差异很大。白噪声全频段平坦,语音能量集中在 300Hz–4kHz。频域处理可以逐 bin 调节衰减深度。
短时平稳假设更容易满足。一帧 10ms 内,噪声功率谱几乎不变,这给估计器提供了稳定的观测窗口。
计算量可控。256 点 FFT 在嵌入式平台上是成熟操作,远比时域长 FIR 滤波器经济。
代价是引入了帧延迟和频谱泄漏,这些后面会谈。
3. 总体处理流程
一帧 480 个采样点(48kHz × 10ms)进来后,经过以下环节:

图 Fig001:算法总体处理流程。左侧为 Band 0 频域主链路,右侧为高频段时域增益处理。
核心思想:只在最低子带做完整的频域处理,高频段只施加一个标量增益。这是工程上的折中——人耳对高频噪声的敏感度低于低频,而高频做逐 bin 处理既费算力又容易引入音乐噪声。
4. 三分频器:多相滤波 + DCT 调制
4.1 为什么要分频
直接对 48kHz 信号做 FFT 当然可以,但有两个问题:
- 256 点 FFT 在 48kHz 下频率分辨率只有 187.5Hz/bin,低频段太粗。
- 语音能量集中在 8kHz 以下,高频段几乎是噪声的天下。分频后对低频段做精细处理,高频段粗放处理,算力用在刀刃上。
分频后每个子带等效采样率为 16kHz,256 点 FFT 的频率分辨率变为 62.5Hz/bin——对语音基频(通常 80–400Hz)来说足够细了。
4.2 多相分解
三分频器的原型是一个低通 FIR 滤波器 $H(z)$,通带截止 7kHz,阻带起始 9.2kHz,阻带衰减 40dB。利用 Noble Identity 做多相分解:
每个子滤波器只有 4 个系数(原型滤波器长度 = 4 × 3 × 子带数 = 48 阶,但多相分解后每相只有 4 tap)。实际实现中,由于 DCT 调制的周期性,12 个多相分支里有 2 个恒为零,只剩 10 个非零分支。
4.3 DCT 调制
三个子带的中心频率分别是 $f_s/12$、$3f_s/12$、$5f_s/12$(即 4kHz、12kHz、20kHz @ 48kHz)。调制矩阵是一个 3 点 DCT:
分析时:先抽取(3:1 降采样),再对每个多相分支做 4-tap 滤波,最后乘以 DCT 系数并累加得到三个子带。综合是逆过程。
4.4 工程代价
这个滤波器组不满足完美重建条件。直接分析再综合(中间不做任何处理),SNR 大约只有 9.5dB。这是有意为之——用重建精度换滤波器复杂度和延迟。对降噪应用来说,这个损失可以接受,因为我们的目标本来就不是无损还原。
滤波器组引入 24 个采样点的群延迟(0.5ms @ 48kHz),高频段需要额外延迟补偿来对齐。
5. 帧分割与加窗
5.1 帧参数
分频后的子带信号(160 samples/帧)不能直接送 FFT,需要:
- 扩展帧:将当前 160 点与上一帧尾部 96 点拼接,形成 256 点的扩展帧。
- 加窗:对扩展帧施加一个混合窗——前 96 点是升 Hanning,中间 65 点是平坦段(增益为 1),后 95 点是降 Hanning。
这个窗的设计意图:中间平坦段保证帧中心区域的信号不被衰减,两端的 Hanning 渐变抑制频谱泄漏。数学上,窗函数 $w(n)$ 满足:
5.2 Overlap-Add
IFFT 之后得到 256 点时域信号,再乘一次同样的窗(分析窗 × 综合窗 = COLA 条件),然后取前 160 点与上一帧残余 96 点叠加:
其中 $m(n)$ 是上一帧 256 点输出的后 96 点。这保证了帧间连续性,避免块效应。
6. 噪声功率谱估计
这是整个算法里最关键的环节。增益算得准不准,全看噪声估计跟不跟得上。
6.1 分位数噪声跟踪
核心思想:对每个频率 bin,在 log 域跟踪其功率谱的 25% 分位数。为什么是 25%?因为语音出现时功率会跳高,而噪声主导时功率处于低分位。25% 分位数大致对应"没有语音时的噪声底"。
具体做法:维护 $S = 6$ 组并行估计器,每组独立更新但周期错开。每组有一个 log 域分位数估计 $\hat{q}_s(k)$ 和一个概率密度估计 $\hat{f}_s(k)$。每来一帧:
其中 $\alpha = 0.25$ 是目标分位数,$\delta = 40 / \hat{f}_s(k)$ 是自适应步长(密度越大步长越小,收敛越稳),$N_s$ 是该组已累积的帧数。
6 组估计器以不同周期重置(200 帧为周期,各组偏移 33 帧),保证任何时刻都有一组处于"已收敛"状态。这个设计避免了单组估计器重置时的跳变。
6.2 启动阶段的参数化噪声模型
前 50 帧(500ms),分位数估计器还没收敛。这时用一个参数化模型来填充:
白噪声模型:全频段平坦,电平为所有 bin 的均值乘以过减因子。
粉噪声模型:功率谱随频率下降,建模为 $P(k) = A / k^{\beta}$。在 log 域做线性回归:
用最小二乘拟合 $\log A$(截距)和 $\beta$(斜率),约束 $\beta \in [0, 1]$。
启动阶段,最终噪声估计是分位数估计和参数化模型的加权混合:
其中 $n$ 是当前帧号。随着帧数增加,参数化模型逐渐退出。
6.3 后验更新:语音概率加权的噪声跟踪
分位数估计给出的是"保守下界"。真正的噪声谱还需要一阶 IIR 平滑来跟踪缓慢变化:
其中 $P_s(k)$ 是语音存在概率,$P_{ns}(k) = 1 - P_s(k)$。
关键设计:当 $P_s(k) > 0.2$ 时,$\gamma$ 从 0.9 跳到 0.99——几乎冻结更新。这防止了语音段把噪声估计拉高。只有当语音概率很低时,才允许噪声估计快速跟踪。
另外维护一个"保守噪声谱",只在 $P_s < 0.2$ 时以 0.05 的步长更新。这个保守谱后面会用于语音概率计算中的模板匹配。
7. 语音存在概率
7.1 为什么需要它
维纳滤波的增益公式里,先验 SNR 的估计依赖于"当前帧到底有没有语音"。如果把噪声段误判为语音,噪声估计会被污染;如果把语音段误判为噪声,语音会被削掉。所以需要一个逐帧、逐 bin 的语音存在概率。
7.2 三个特征
算法提取三个全局特征(每帧一个标量,不是逐 bin 的):
(a)对数似然比 (LRT)
对每个 bin 计算:
然后做时间平滑:$\bar{\ell}(k) \leftarrow 0.5 \cdot \bar{\ell}(k) + 0.5 \cdot \ell(k)$
全频段均值 $L = \frac{1}{129}\sum_k \bar{\ell}(k)$ 作为 LRT 特征。
直觉:如果信号里只有噪声,后验 SNR 在 0 附近波动,$\ell(k)$ 趋近于零。有语音时,$\ell(k)$ 显著为正。
(b)频谱平坦度 (Spectral Flatness)
就是几何均值除以算术均值。白噪声的频谱平坦,$F \to 1$;语音有谐波结构,$F$ 明显小于 1。
时间平滑:$F \leftarrow 0.7 F + 0.3 F_{new}$
(c)频谱差异 (Spectral Difference)
当前频谱与保守噪声模板之间的归一化方差差:
这本质上是一个偏相关系数——去掉噪声模板能解释的部分后,剩余方差有多大。剩余越大,说明当前信号越不像纯噪声。
7.3 直方图建模与先验模型
三个特征不是直接拿来判决的。算法维护三个 100-bin 直方图,在 500 帧的窗口内累积特征的分布。每 500 帧提取一次模型参数:
- LRT 直方图 → 确定 LRT 阈值 $\theta_{lrt}$(低波动时设为 1.0,否则取均值的 1.2 倍)
- 平坦度直方图 → 找主峰位置,乘以 0.9 作为平坦度阈值 $\theta_{flat}$
- 频谱差异直方图 → 找主峰位置,乘以 1.2 作为差异阈值 $\theta_{diff}$
同时确定三个特征的权重(被拒绝的特征权重置零,剩余特征均分权重)。
7.4 先验概率的 Sigmoid 融合
三个特征各自通过一个 tanh 映射变成 [0, 1] 的指示函数:
注意 $I_1$ 里是 $\theta_{flat} - F$(反向),因为平坦度越低越像语音。
加权求和后做一阶平滑得到全局先验语音概率:
7.5 逐 bin 后验概率
最终,将全局先验和逐 bin 的似然比结合,得到每个 bin 的语音存在概率:
这就是贝叶斯后验:先验 odds 乘以似然比。当 $\bar{\ell}(k)$ 很大时(强语音),$P_s(k) \to 1$;当 $\bar{\ell}(k) \approx 0$ 时(纯噪声),$P_s(k) \to P_{prior}$。
8. 维纳滤波器
8.1 增益公式
有了噪声谱 $\hat{N}(k)$ 和先验 SNR $\gamma_{prior}(k)$,维纳增益为:
其中 $\alpha$ 是过减因子(over-subtraction factor)。当 $\alpha = 1$ 时是标准维纳滤波;$\alpha > 1$ 时更激进,噪声减得更深但语音失真风险增大。
增益有下限 $G_{min}$(称为 minimum attenuating gain),防止把信号完全静音:
8.2 先验 SNR 的定向判决估计
先验 SNR 不是直接算的(因为你不知道干净语音是什么),而是用 Ephraim & Malah (1985) 的 Decision-Directed 方法:
第一项是"上一帧用增益滤波后的信号功率 / 上一帧噪声"——代表对上一帧语音功率的估计。第二项是当前后验 SNR。
为什么 0.98/0.02 这个比例?因为后验 SNR 波动极大(噪声的随机性),如果权重给多了,增益会逐帧剧烈抖动,听起来就是"音乐噪声"。0.98 的时间常数约 50 帧(500ms),足够平滑。代价是对突发语音的响应慢了约 1-2 帧——可以接受。
8.3 启动阶段的滤波器混合
前 50 帧,维纳滤波器也在收敛。这时额外计算一个基于累积频谱的初始滤波器:
最终滤波器是两者的线性混合:
9. 增益修正:让降噪"听起来自然"
9.1 问题
纯维纳滤波有一个听感问题:在语音段,增益接近 1,没问题;但在语音和噪声的过渡区域,增益变化太快,会产生"呼吸效应"——噪声突然消失又突然出现。
9.2 解决方案
算法计算滤波前后的能量比:
然后根据 $g$ 和全局语音概率 $P_{prior}$ 做一个双向缩放:
当 $g > 0.5$(滤波前后能量差别不大,说明是语音段):$S_1 = 1 + 1.3(g - 0.5)$,轻微放大,补偿维纳滤波对语音的少量衰减。
当 $g < 0.5$(能量被大幅削减,说明是噪声段):$S_2 = 1 - 0.3(0.5 - \max(g, G_{min}))$,轻微缩小,但不过度——避免静音段出现"抽吸感"。
最终缩放因子:
用语音概率做平滑过渡,而不是硬切换。
10. 高频段处理
Band 1 和 Band 2 不做 FFT,只施加一个标量增益。这个增益从 Band 0 的信息推导:
取 Band 0 最高 32 个 bin 的平均语音概率 $\bar{P}_s$ 和平均滤波器增益 $\bar{G}$。
如果中间有 AEC 等模块削了信号,用分析/处理频谱比修正 $\bar{P}_s$。
通过 tanh 映射得到基础增益:$g_{base} = \frac{1}{2}\big[1 + \tanh(2\bar{P}_s - 1)\big]$
与低频增益混合:
- 若 $\bar{P}_s \ge 0.5$(语音为主):$g = 0.25 g_{base} + 0.75 \bar{G}$
- 若 $\bar{P}_s < 0.5$(噪声为主):$g = 0.5 g_{base} + 0.5 \bar{G}$
语音段更信任低频滤波器的逐 bin 结果(权重 0.75),噪声段更信任概率判决(各 0.5)。
高频段还施加了一个延迟补偿(24 samples),对齐三分频器的群延迟。
11. 启动策略
算法有两个启动阶段:
| 阶段 | 帧数 | 时长 | 行为 |
|---|---|---|---|
| 短启动 | 0–50 | 0–500ms | 参数化噪声模型渐退,维纳滤波器混合 |
| 长启动 | 0–200 | 0–2s | 分位数估计器收敛,信号能量归一化建立 |
| 稳态 | >200 | >2s | 所有模块独立运行,增益修正启用 |
前 200 帧内,增益修正(第 9 节)被禁用——因为能量统计还不稳定,贸然缩放会引入伪影。
零帧检测:如果输入全零(比如静音段),直接跳过分析。这防止了统计量被零信号拉偏——否则阈值会适应到"零就是噪声",等真正的信号来了反而全被当语音放过。
12. 降噪档位
算法提供 4 个预设档位,通过两个参数控制降噪深度:
| 档位 | 过减因子 $\alpha$ | 增益下限 $G_{min}$ | 最大衰减 | 增益修正 |
|---|---|---|---|---|
| 0 | 1.0 | 0.5 | 6 dB | 关 |
| 1 | 1.0 | 0.25 | 12 dB | 开 |
| 2 | 1.1 | 0.125 | 18 dB | 开 |
| 3 | 1.25 | 0.09 | 21 dB | 开 |
最大衰减 = $-20\log_{10}(G_{min})$ dB。
工程选择建议:嵌入式通话场景用档位 1 或 2。档位 3 在极低 SNR 下会引入明显的语音失真(“水下音”),除非噪声真的很大否则不推荐。
13. 关键设计决策回顾
为什么用 256 点 FFT 而不是 512 点?
160 点帧长(10ms @ 16kHz 等效)加 96 点 overlap 刚好凑 256。512 点会提高频率分辨率但增加算法延迟到 20ms+,对实时通话不可接受。256 点在 62.5Hz 分辨率下已经能区分基频谐波。
为什么噪声估计用 6 组并行?
单组分位数估计器在重置时会跳变。6 组错开重置周期,任何时刻取其中一组的结果,保证输出连续。这是用 6 倍存储换平滑性——在 RAM 紧张的嵌入式上,这是 12KB 的代价(每组 129 bin × 2 个 float 数组 × 6 组)。
为什么高频段不做频域处理?
三个原因:(1) 算力——每多一个频段做 FFT/IFFT,MIPS 翻倍;(2) 高频段语音能量低,逐 bin 增益的估计误差大,容易产生音乐噪声;(3) 人耳对高频噪声的掩蔽效应强,粗放处理听感差异不大。
为什么 Decision-Directed 的平滑因子是 0.98?
这是 Ephraim-Malah 论文里的经典值。0.98 对应约 500ms 时间常数。太小(如 0.9)→ 增益波动大,音乐噪声明显;太大(如 0.999)→ 对语音起始响应太慢,会吃掉辅音。0.98 是大量主观听音实验的折中。
14. 数值实现备忘
- 幅度谱计算加了 +1 的偏置($|X(k)| + 1$),防止后续除法出现零除和 log(0)。
- 所有 log/exp 用快速近似(查表 + 线性插值),不用标准库。在 ARM Cortex-M4 上,
logf要 80+ cycles,查表只要 5 cycles。 - SNR 计算中的分母加了 0.0001 的 epsilon,这是 Q15 定点时代的遗留习惯,浮点下其实可以更小。
- 增益下限 $G_{min}$ 同时充当"噪声门"的角色——即使算出来的增益更低,也不会让信号低于这个电平。这避免了完全静音段出现"数字死寂"的不自然感。
15. 算法局限性
坦率地说,这个算法有几个已知的短板:
- 非稳态噪声。突然的关门声、键盘敲击,分位数跟踪器来不及反应,会有 200-500ms 的泄漏窗口。
- 多说话人。算法假设只有一个目标说话人,第二个人的声音会被部分当作噪声处理。
- 音乐噪声。尽管 DD 方法和增益下限都在抑制,极低 SNR(< 0dB)下仍然能听到零星的"鸟鸣声"。
- 滤波器组不完美重建。即使不降噪,分析-综合链路也有约 9.5dB 的 SNR 损失。对高保真应用不可接受,对通话场景勉强够用。
- 启动时间。完整收敛需要 2 秒。如果设备频繁开关,前 2 秒的降噪效果是不稳定的。
这些是经典频域方法的固有局限。要根本性解决,得上深度学习方案(RNNoise、DTLN 之类),但那就是另一个量级的算力和内存需求了。
笔记完。整理于嵌入式降噪移植项目。