4. 自适应滤波原理
谱减法,说白了就是「把噪声减掉」。
你想想看,带噪语音 = 纯净语音 + 噪声。如果我们知道噪声长什么样,直接从带噪信号里把它扣掉,不就完事了吗?
这个思路听起来简单,但实际落地时坑不少。我在项目里第一次用谱减法做车载语音增强,结果出来的语音像「在水里说话」——音乐噪声特别重。后来才明白,谱减法不是简单的减法,它背后有一套严谨的数学逻辑。
4.1 谱减法的核心思想
谱减法假设噪声是加性的、平稳的,并且与语音不相关。我们通常在频域操作:
- 对带噪语音做短时傅里叶变换(STFT),得到幅度谱和相位谱。
- 从带噪幅度谱中减去估计出的噪声幅度谱。
- 用原始相位(人耳对相位不敏感)和减完的幅度谱做 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 谱减法流程图
下面这张图展示了谱减法的完整处理流程:
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 不准时试试最小值跟踪