4. 降噪滤波器入门

各位好,欢迎来到第四章。

前几章我们把语音信号的基础和噪声模型捋了一遍。从这章开始,咱们要真正动手处理噪声了。我个人的习惯是,先拿传统滤波器热热身。别看它们“传统”,在不少实际场景里,这些方法依然能打,而且理解它们对后面学自适应滤波、深度学习降噪非常有帮助。

今天咱们聊三个经典角色:移动平均滤波器、中值滤波器和维纳滤波器。它们思路完全不同,但目标一致——把噪声压下去,把语音保下来。

核心思路:传统降噪滤波器,本质上都是在做“平滑”或“估计”。移动平均和中值在时域上直接操作,维纳则从统计最优的角度切入。三者代表了三种不同的降噪哲学。

传统降噪滤波器知识框架 传统降噪滤波器知识框架 传统降噪滤波器 移动平均滤波器 中值滤波器 维纳滤波器 核心思想 时域滑动平均 低通特性 简单快速 核心思想 排序取中值 抗脉冲噪声 边缘保持 核心思想 最小均方误差 统计最优 需噪声估计

4.1 移动平均滤波器:最朴素的平滑器

移动平均滤波器,说白了就是取当前点前后几个点的平均值,替代当前点的值。公式很简单:

y[n] = (1/M) * Σ x[n + k]   (k 从 -K 到 K, M = 2K+1)

其中 M 是窗口长度。窗口越大,平滑效果越强,但语音的细节丢失也越严重。

为什么它能降噪? 因为噪声通常是零均值的随机信号,多个样本平均后,噪声会相互抵消一部分,而语音信号在短时间内变化不大,平均后保留得比较好。你想想看,这其实就是一种低通滤波——把高频的噪声波动给滤掉了。

我的经验:在嵌入式设备上做实时语音预处理时,我经常用移动平均滤波器做第一级降噪。它计算量极小,一个滑动窗口加个累加器就能搞定。但要注意,窗口长度不能太大,否则语音的辅音部分会被严重模糊,听起来像“含着一口水在说话”。

来看一段 Python 实现:

import numpy as np

def moving_average(signal, window_size):
    """
    移动平均滤波器
    signal: 输入信号 (1D array)
    window_size: 窗口长度 (奇数)
    """
    if window_size % 2 == 0:
        window_size += 1  # 强制奇数
    half = window_size // 2
    # 边界处理:反射填充
    padded = np.pad(signal, half, mode='reflect')
    # 卷积实现
    kernel = np.ones(window_size) / window_size
    smoothed = np.convolve(padded, kernel, mode='valid')
    return smoothed

这段代码用了卷积实现,效率不错。边界处我用了反射填充,避免两端出现奇怪的跳变。嗯,这里要注意:如果你的信号很短,窗口长度要相应缩小,否则边界效应会占主导。

避坑指南:我曾经在一个语音端点检测项目里直接用移动平均滤波做预处理,结果发现静音段的噪声被平滑后,反而让语音起始点变得模糊,导致检测延迟。后来我改用自适应窗口长度——在语音段用小窗口保留细节,静音段用大窗口压制噪声。效果好了很多。

4.2 中值滤波器:脉冲噪声的克星

中值滤波器的思路更直接——取窗口内所有样本的中位数,而不是平均值。公式:

y[n] = median( x[n-K], ..., x[n], ..., x[n+K] )

为什么中值能降噪?因为中值对异常值不敏感。想象一下,如果某个采样点被脉冲噪声(比如点击声、爆音)污染了,它的值会远偏离正常范围。取平均值时,这个异常点会把结果拉偏;但取中值时,只要异常点不是窗口内的大多数,它根本不影响结果。

我个人的经验:中值滤波器在去除“椒盐噪声”类脉冲干扰时,效果远好于移动平均。但代价是计算量大——每次都要排序。不过对于语音信号,窗口通常不大(3~9个点),排序开销可以接受。

def median_filter(signal, window_size):
    """
    中值滤波器
    """
    if window_size % 2 == 0:
        window_size += 1
    half = window_size // 2
    padded = np.pad(signal, half, mode='reflect')
    smoothed = np.zeros_like(signal)
    for i in range(len(signal)):
        window = padded[i : i + window_size]
        smoothed[i] = np.median(window)
    return smoothed

这段代码是逐点处理的,效率一般。实际工程中可以用scipy.signal.medfilt,它底层用优化算法,快很多。

关键对比:移动平均 vs 中值滤波

特性 移动平均 中值滤波
噪声类型 高斯噪声、平稳噪声 脉冲噪声、椒盐噪声
边缘保持 差(模糊边缘) 好(保留跳变)
计算复杂度 O(N)(卷积) O(N·M·logM)(排序)
适用场景 实时低功耗系统 有突发噪声的录音

你可能会问:那语音降噪到底该用哪个?我的建议是——看噪声类型。如果是风扇、空调这类平稳噪声,移动平均更合适;如果是键盘敲击、关门声这类脉冲噪声,中值滤波是首选。当然,也可以两者串联使用,先中值去脉冲,再移动平均去平稳噪声。

4.3 维纳滤波器:从统计最优的角度出发

前面两个方法都是“拍脑袋”的启发式方法。维纳滤波器不一样,它从数学上给出了一个“最优”解——在最小均方误差(MMSE)准则下,估计出最接近原始语音的信号。

维纳滤波器的核心公式在频域上非常简洁:

H(f) = Ps(f) / (Ps(f) + Pn(f))

其中 Ps(f) 是语音功率谱,Pn(f) 是噪声功率谱。H(f) 就是维纳滤波器的频率响应。

直观理解:在某个频率上,如果语音能量远大于噪声能量(信噪比高),H(f) ≈ 1,滤波器几乎不衰减;如果噪声能量占主导(信噪比低),H(f) ≈ 0,滤波器把这个频段压掉。说白了,维纳滤波器就是一个“信噪比自适应的带通滤波器”。

我的经验:维纳滤波器的效果很大程度上取决于噪声功率谱的估计精度。我在做车载语音系统时,发现发动机噪声的功率谱会随转速变化,如果用一个固定的噪声谱,维纳滤波反而会引入“音乐噪声”——听起来像水声或金属声,非常刺耳。后来我改用噪声跟踪算法实时更新 Pn(f),才解决了这个问题。

来看一段简化实现(假设已知噪声功率谱):

def wiener_filter(noisy_signal, noise_psd, fs):
    """
    维纳滤波器(频域实现)
    noisy_signal: 带噪语音
    noise_psd: 噪声功率谱密度
    fs: 采样率
    """
    from scipy.fft import fft, ifft
    N = len(noisy_signal)
    # 对带噪信号做 FFT
    X = fft(noisy_signal)
    # 估计带噪信号的功率谱
    Px = np.abs(X)**2 / N
    # 语音功率谱估计:Px - Pn,并确保非负
    Ps = np.maximum(Px - noise_psd, 1e-10)
    # 维纳增益
    H = Ps / (Ps + noise_psd)
    # 滤波
    Y = X * H
    # IFFT 回到时域
    denoised = np.real(ifft(Y))
    return denoised

这段代码假设噪声功率谱是已知的。实际中,我们通常用语音活动检测(VAD)在静音段估计噪声谱,或者用最小值跟踪算法实时更新。

注意:维纳滤波器假设语音和噪声是平稳的、不相关的。但实际语音是非平稳的,噪声也可能与语音相关(比如回声)。这些假设不成立时,维纳滤波的效果会打折扣。我曾经在一个会议录音系统中直接套用维纳滤波,结果因为噪声非平稳,反而引入了大量失真。后来我加了分段处理和重叠相加(OLA)技术,才把效果拉回来。

4.4 三种滤波器的对比与选择

聊了这么多,咱们来总结一下。三种滤波器各有各的脾气:

  • 移动平均:简单、快速、适合平稳噪声。代价是模糊语音细节。
  • 中值滤波:抗脉冲噪声能力强,能保持边缘。但计算量稍大,对高斯噪声效果一般。
  • 维纳滤波:理论上最优,能自适应调整。但依赖噪声估计,实现复杂,容易引入音乐噪声。

在实际项目中,我通常这样选型:

  1. 如果算力有限(比如单片机),用移动平均,窗口长度根据噪声带宽调整。
  2. 如果录音环境有突发噪声(比如人走动、物品碰撞),先加一级中值滤波。
  3. 如果对音质要求高,且能接受一定延迟,用维纳滤波,配合噪声跟踪算法。

当然,这三个方法也可以组合使用。我记得在一个智能音箱项目里,我们用了“中值滤波 + 移动平均 + 维纳滤波”三级串联,效果比单用任何一种都好。但代价是延迟和计算量都上去了,需要根据产品需求做权衡。