噪声估计的作用

噪声估计算法在整个语音降噪系统中起到核心支撑作用,先验SNR和后验SNR的计算都依赖于当前帧的噪声功率谱估计。若噪声估计偏低,会导致保留太多噪声(过度保留);若噪声估计偏高,会把语音当作噪声过滤掉(语音失真); 更新不稳定,整体听感时好时坏,忽大忽小,出现”泵声“、”音乐噪声“现象。本文介绍WebRTC中目前使用的基于分位数的噪声估计算法,及其在工程实现中的巧妙之处。

什么是基于分位数的噪声估计

基于分位数的噪声估计算法是一种利用信号统计特性区分噪声和语音的自适应方法。其核心原理在于:噪声的能量分布通常集中在低分位区域,而语音信号的能量分布会抬高高分位数。

WebRTC中的实现解读(妙呀)

1
2
3
# 代码位置
modules/audio_processing/ns/quantile_noise_estimator.h 
modules/audio_processing/ns/quantile_noise_estimator.cc

分位数计算

1
2
3
4
5
if (log_spectrum[i] > log_quantile_[j]) {
    log_quantile_[j] += 0.25f * multiplier;
} else {
    log_quantile_[j] -= 0.75f * multiplier;
}

以上是WebRTC中分位数计算的代码,它表示25%分位数估计,下面我们来逐步说明为什么这段代码可以计算25%分位数。

什么是分位数?

以25%分位数为例,它表示:如果你观察一组数,有25%是小于它的,有75%是大于它的。 比如:

1
2
数据(已排序):[1, 2, 3, 4, 5, 6, 7, 8, 9]
0.25分位数 ≈ 第3个数 = 3

非对称更新的数学直觉

设:当前估计值为Q,当前观测值为X

我们每帧更新规则如下:

情况更新量含义
x > QQ ← Q + 0.25 × step当前值太大,稍微拉高估计值
x < QQ ← Q - 0.75 × step当前值太小,大幅拉低估计值

收敛分析:平衡点 = 25%分位数

考虑连续观察大量值 {x₁, x₂, …, xₙ},估计值 Q 如果在一个固定位置附近波动,那它一定满足: 平均上调量 ≈ 平均下调量,也就是说,在那个点:

上调概率 × 上调步长 = 下调概率 × 下调步长

其中:

  • 上调概率 p_up = P(x > Q)
  • 下调概率 p_down = P(x < Q) = 1 - p_up
  • 上调步长 = 0.25
  • 下调步长 = 0.75 计算可以得到:p_down = 0.25

✅ 说明这个估计最终会逼近 25% 分位数!

这个分位数的实现真是妙了呀,避免了常规分位计算需要排序的问题,同时还可以实时更新。👍

多分位数估计分时更新

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
  // Loop over simultaneous estimates.
  for (int s = 0, k = 0; s < kSimult;
       ++s, k += static_cast<int>(kFftSizeBy2Plus1)) {
    const float one_by_counter_plus_1 = 1.f / (counter_[s] + 1.f);
    for (int i = 0, j = k; i < static_cast<int>(kFftSizeBy2Plus1); ++i, ++j) {
      // Update log quantile estimate.
      const float delta = density_[j] > 1.f ? 40.f / density_[j] : 40.f;

      const float multiplier = delta * one_by_counter_plus_1;
      if (log_spectrum[i] > log_quantile_[j]) {
        log_quantile_[j] += 0.25f * multiplier;
      } else {
        log_quantile_[j] -= 0.75f * multiplier;
      }

      // Update density estimate.
      constexpr float kWidth = 0.01f;
      constexpr float kOneByWidthPlus2 = 1.f / (2.f * kWidth);
      if (fabs(log_spectrum[i] - log_quantile_[j]) < kWidth) {
        density_[j] = (counter_[s] * density_[j] + kOneByWidthPlus2) *
                      one_by_counter_plus_1;
      }
    }

    if (counter_[s] >= kLongStartupPhaseBlocks) {
      counter_[s] = 0;
      if (num_updates_ >= kLongStartupPhaseBlocks) {
        quantile_index_to_return = k;
      }
    }

    ++counter_[s];
  }

kLongStartupPhaseBlocks=200,意味着分位数估计在200帧后,即2秒,才会更新重置输出估计结果。webrtc为了减少响应延迟,设置了三个错位的独立分位数估计器,如下代码,可以看到每一个分位数估计器的更新计数是错开的,这样可以达到每67帧,即670ms,就会有一个分位数估计器进行重置更新输出估计结果,从而达到快速响应的效果。

1
2
3
4
  constexpr float kOneBySimult = 1.f / kSimult;
  for (size_t i = 0; i < kSimult; ++i) {
    counter_[i] = floor(kLongStartupPhaseBlocks * (i + 1.f) * kOneBySimult);
  }

利用”密度“估计实现自适应步长

分位数估计器在更新的时候,其更新步长与这个density_变量直接相关,现在我们来看下webrtc的实现是如何做到自适应步长的。

density_[j]表示: 当前分位数估计点附近的”局部密度估计“,近似表示这个log频谱点的概率密度函数值。

现实场景中,噪声频点能量分布是变化的,当低噪声变化时,噪声频点能量分布密集;当语音变化时,噪声频点能量分布稀疏。因此需要估计分布密度,以调整步长动态性,防止在高密度或低密度区域过度抖动或者太慢反应

  • density_是如何计算的
1
2
3
4
5
6
7
// Update density estimate.
constexpr float kWidth = 0.01f;
constexpr float kOneByWidthPlus2 = 1.f / (2.f * kWidth);
if (fabs(log_spectrum[i] - log_quantile_[j]) < kWidth) {
  density_[j] = (counter_[s] * density_[j] + kOneByWidthPlus2) *
                one_by_counter_plus_1;
}

这是一种滑动窗口统计估计法:

步骤说明
fabs(…) < kWidth当前观测值是否落在估计值 ±0.01 范围内
kOneByWidthPlus2 = 1 / (2 × 0.01)这是一个常数权重(经验值)
density_[j] = (…)使用 指数滑动平均 来更新密度值
最终的效果是:

density_[j] 趋近于“单位宽度窗口”内命中次数的平均值 —— 表示在分位点附近的信号频谱密集程度。

1
2
const float delta = density_[j] > 1.f ? 40.f / density_[j] : 40.f;
const float multiplier = delta * one_by_counter_plus_1;

density_变量直接影响了分位数估计的步长,也就是说

  • 如果 density_ 高:说明这个频率点的能量比较“稳定”,变化较小 → delta 会小 → 更新变慢
  • 如果 density_ 低:说明这个点的能量波动大 → delta 会大 → 更新更激进

总结就是,density_ 表示当前分位点附近的局部频谱密度,用于调节更新速率,帮助 WebRTC 实现稳定、鲁棒、快速收敛的底噪估计。

Ok,这就是WebRTC中基于分位数噪声估计的全部了。总的来说,基于分位数的噪声估计算法原理简单,但WebRTC的实现有很多巧妙的地方,即保证了效果,也提高了效率,绝对是工程精华值得好好研究。

接下来会继续分享WebRTC语音降噪部分代码,希望对有兴趣的朋友有帮助。