4. 自适应滤波原理

谱减法,说白了就是「把噪声减掉」。

你想想看,带噪语音 = 纯净语音 + 噪声。如果我们知道噪声长什么样,直接从带噪信号里把它扣掉,不就完事了吗?

这个思路听起来简单,但实际落地时坑不少。我在项目里第一次用谱减法做车载语音增强,结果出来的语音像「在水里说话」——音乐噪声特别重。后来才明白,谱减法不是简单的减法,它背后有一套严谨的数学逻辑。

4.1 谱减法的核心思想

谱减法假设噪声是加性的、平稳的,并且与语音不相关。我们通常在频域操作:

  1. 对带噪语音做短时傅里叶变换(STFT),得到幅度谱和相位谱。
  2. 从带噪幅度谱中减去估计出的噪声幅度谱。
  3. 用原始相位(人耳对相位不敏感)和减完的幅度谱做 ISTFT 恢复时域信号。

数学表达式很简单:

|Ŝ(ω)| = |Y(ω)| - |N̂(ω)|

其中 |Y(ω)| 是带噪幅度谱,|N̂(ω)| 是估计的噪声幅度谱,|Ŝ(ω)| 就是降噪后的幅度谱。

关键点:谱减法只减幅度,保留相位。相位用原始带噪信号的相位。这个做法在 SNR 较高时效果不错,但 SNR 低时会有明显失真。

4.2 噪声估计:谱减法的命门

噪声估计不准,谱减法基本就废了。我见过不少新手直接拿前几帧的平均值当噪声,结果语音段也被削掉一大截。

常用的噪声估计方法有:

方法 原理 优缺点
语音活动检测(VAD) 检测到非语音帧时更新噪声估计 简单,但低 SNR 下 VAD 不准
最小值跟踪 跟踪每个频点的最小值作为噪声底噪 对非平稳噪声适应性好,但响应慢
递归平均 用平滑因子递归更新噪声谱 计算量小,但参数敏感

我个人习惯用 VAD + 递归平均的组合。先通过能量或过零率判断是不是语音段,如果是噪声段就更新噪声谱,语音段则保持噪声谱不变。

经验之谈:VAD 的阈值不要设太死。我曾经在工厂环境测试,背景有周期性机械噪声,VAD 死活检测不到语音段。后来把阈值放宽 20%,配合能量归一化,效果才稳定下来。

4.3 谱减法的改进:半波整流与谱下限

直接做减法有个大问题:减完可能得到负值。幅度谱不能为负,所以早期做法是半波整流——负值直接置零。

但半波整流会产生「音乐噪声」——那些被置零的频点突然消失,听起来像一阵一阵的「吱吱」声。

解决办法是引入谱下限(Spectral Floor):

|Ŝ(ω)| = max( |Y(ω)| - α · |N̂(ω)| , β · |N̂(ω)| )

其中:

  • α 是过减因子(通常 1~3),控制减噪力度
  • β 是谱下限系数(通常 0.01~0.1),保留一点背景噪声,避免音乐噪声

α 越大,降噪越狠,但语音失真也越大。β 越大,背景越自然,但残留噪声也越多。这是个 trade-off。

注意:α 和 β 不要同时调大。α 调大后 β 要适当调小,否则降噪效果会被 β 抵消。我见过有人 α=3、β=0.2,结果降噪前后几乎没区别。

4.4 谱减法流程图

下面这张图展示了谱减法的完整处理流程:

谱减法降噪流程图 谱减法降噪处理流程 带噪语音 y(n) 分帧 + 加窗(汉明窗) FFT → 幅度谱 |Y| + 相位谱 ∠Y 噪声估计(VAD + 递归平均) 谱减法:|Ŝ| = |Y| - α·|N̂| 噪声谱 |N̂| 谱下限:max(|Ŝ|, β·|N̂|) IFFT(幅度|Ŝ| + 相位∠Y) 降噪语音 ŝ(n) ⚠ 关键参数 α:过减因子(1~3) β:谱下限系数(0.01~0.1)

4.5 代码实现:C 语言版本

下面是一个简化的谱减法实现,适合嵌入式部署。我刻意去掉了动态内存分配,全部用静态数组:

#include <math.h>
#include <string.h>

#define FRAME_SIZE 256      // 帧长
#define FFT_SIZE    256      // FFT 点数
#define NOISE_FRAMES 10     // 初始噪声帧数

// 全局状态
static float noise_mag[FFT_SIZE/2+1];   // 噪声幅度谱
static int noise_count = 0;             // 已采集噪声帧数
static int is_noise_ready = 0;          // 噪声估计是否就绪

// 谱减法参数
#define ALPHA  2.0f    // 过减因子
#define BETA   0.05f   // 谱下限系数

// 简化的 VAD:基于帧能量
int vad_detect(float *frame, int len) {
    float energy = 0.0f;
    for (int i = 0; i < len; i++) {
        energy += frame[i] * frame[i];
    }
    energy /= len;
    // 阈值需要根据实际环境调整
    return (energy > 0.01f) ? 1 : 0;
}

// 谱减法处理一帧
void spectral_subtraction(float *fft_mag, float *fft_phase, int fft_len) {
    int half = fft_len / 2 + 1;
    
    // 1. 噪声估计(仅在非语音帧更新)
    if (!vad_detect(fft_mag, half)) {
        if (noise_count < NOISE_FRAMES) {
            // 初始阶段:直接累加
            for (int i = 0; i < half; i++) {
                noise_mag[i] += fft_mag[i];
            }
            noise_count++;
            if (noise_count == NOISE_FRAMES) {
                // 取平均
                for (int i = 0; i < half; i++) {
                    noise_mag[i] /= NOISE_FRAMES;
                }
                is_noise_ready = 1;
            }
        } else {
            // 递归平均更新
            float alpha_n = 0.9f;  // 平滑因子
            for (int i = 0; i < half; i++) {
                noise_mag[i] = alpha_n * noise_mag[i] + (1-alpha_n) * fft_mag[i];
            }
        }
    }
    
    // 2. 如果噪声还没估计好,直接输出原信号
    if (!is_noise_ready) return;
    
    // 3. 谱减法 + 谱下限
    for (int i = 0; i < half; i++) {
        float mag_sub = fft_mag[i] - ALPHA * noise_mag[i];
        float floor_val = BETA * noise_mag[i];
        fft_mag[i] = (mag_sub > floor_val) ? mag_sub : floor_val;
    }
}

部署建议:上面的代码里,VAD 阈值 0.01 是我在办公室环境测的。换到车载环境,阈值要提到 0.05 以上,否则发动机怠速的噪声会被当成语音。建议在设备上先跑一段纯噪声,自动计算能量基线。

4.6 谱减法的优缺点

优点 缺点
原理简单,容易实现 音乐噪声明显(尤其 β 设太小)
计算量小,适合实时系统 对非平稳噪声适应性差
参数少,调参相对容易 低 SNR 下语音失真严重
相位保留,语音自然度尚可 需要准确的噪声估计

避坑提醒:我曾经在蓝牙耳机项目里直接用谱减法,结果发现音乐噪声在静音段特别刺耳。后来加了谱下限和过减因子自适应(SNR 高时 α 小,SNR 低时 α 大),才把这个问题压下去。如果你做的是消费级产品,建议至少加上谱下限,否则用户反馈会让你崩溃。

4.7 小结

谱减法是最经典的降噪算法之一。它不完美,但它是理解更高级算法(如维纳滤波、MMSE)的基础。

我个人建议:

  • 先跑通上面的 C 代码,在 PC 上验证效果
  • 调参时先固定 β=0.05,调 α 从 1.0 到 3.0 听变化
  • 噪声估计是核心,VAD 不准时试试最小值跟踪