当前位置:首页 > 未分类 > 正文内容

speex_mdf

2026-04-03 | 分类:未分类 | 评论:0人 | 浏览:10次

# 1 算法介绍

该代码基于经典的 **MDF(多延迟块频域自适应滤波)** 算法,并采用了 **AUMDF(交替更新MDF)** 变体,以及 **无显式双讲检测** 的变步长机制。代码的核心思想是:**在频域进行快速卷积和滤波器更新,同时通过“前景/背景”双滤波器机制和动态功率控制来保证鲁棒性。**

此实现参考了 Speex 和 Opus 中的 AEC 代码,是实际工程中常用的频域回声消除方案。该算法在VoIP、会议系统等领域得到广泛应用,是频域自适应滤波的经典实现。

本代码实现的是Speex音频编解码器中的回声消除算法,基于以下论文:

1. **MDF算法**:J. S. Soo, K. K. Pang, “Multidelay block frequency adaptive filter,” IEEE Trans. Acoust. Speech Signal Process., Vol. ASSP-38, No. 2, February 1990.

2. **变学习率**:J.-M. Valin, “On Adjusting the Learning Rate in Frequency Domain Echo Cancellation With Double-Talk,” IEEE TASLP, Vol. 15, No. 3, pp. 1030-1034, 2007.

**代码亮点解读**
1. **定点模拟**:`float_to_short` 并不是为了节省内存,而是为了复现原版 C 语言(Speex)在 DSP 上运行时因为截断误差带来的特性。
2. **半步更新**:文件头注释中提到 “we only move half the way toward the goal”,体现在 `ss = .35/M`,这是一种动量或软约束技巧,能极大提升频域更新的稳定性。
3. **彻底抛弃了 VAD/GDD**:传统的 AEC 需要一个严格的“双讲检测器”来决定是否冻结滤波器,一旦误判就会要么有回声,要么把近端人声抹掉。本代码完全通过能量的连续统计(`Davg`, `Dvar`)和前景背景切换机制,实现了“润物细无声”的非线性自适应,这是 Valin 2007 论文的最大贡献。

Speex MDF算法代码实现了一个基于 **多延迟块频域(MDF)** 的回声消除器,并采用了 **交替更新 MDF(AUMDF)** 变体和 **可变学习率** 以提高双讲鲁棒性。

是一个高度优化的频域自适应回声消除算法,其核心创新包括:

**算法特点**

1. **MDF/AUMDF结构**:高效处理长回声路径
2. **双滤波器机制**:保证输出稳定性
3. **连续变学习率**:无需显式双讲检测,实现鲁棒的双讲处理
4. **泄漏估计**:精确估计残余回声,指导学习率调整
| 特性 | 描述 |
|——|——|
| **核心算法** | 多延迟块频域自适应滤波器 (MDF) |
| **变体** | 交替更新MDF (AUMDF) |
| **双讲处理** | 无显式检测,连续变学习率 |
| **结构** | 前景/背景双滤波器 |
| **应用** | 声学回声消除 (AEC) |

| 特性 | 优势 |
|——|——|
| **频域处理** | 计算效率高,$O(N \log N)$ vs $O(N^2)$ |
| **MDF结构** | 支持长回声路径,降低FFT大小 |
| **双滤波器** | 提供稳定输出,防止发散 |
| **变学习率** | 无需显式双讲检测,鲁棒性强 |
| **比例自适应** | 加速收敛,适应回声路径特性 |
– **多延迟块(MDF)**:将长滤波器分成多个短块,降低计算复杂度。
– **交替更新(AUMDF)**:每帧只更新部分块的时域约束,减少计算量。
– **前景/背景滤波器**:通过双讲检测,在双讲时暂停背景更新,防止发散;在安静时快速收敛。
– **连续可变学习率**:根据残余回声、双讲和背景噪声调整步长,无需显式双讲检测。
– **鲁棒性处理**:包含 DC 陷波、预加重、饱和检测、异常重置等机制。

## 1.1 关键公式汇总

**频域滤波**

$$\mathbf{Y}(k) = \sum_{j=1}^{M} \mathbf{X}_j(k) \odot \mathbf{W}_j(k)$$

**频域权值更新**

$$\mathbf{W}_j(k+1) = \mathbf{W}_j(k) + p_j \cdot \frac{\mathbf{E}(k) \odot \mathbf{X}_j^*(k)}{\mathbf{P}(k)}$$

**泄漏估计**

$$\hat{\rho} = \frac{P_{ey}}{P_{yy}} = \frac{\sum_i E_h(i) \cdot Y_h^*(i)}{\sum_i |Y_h(i)|^2}$$

**变学习率**

$$\mu_i = \frac{0.7 \cdot \hat{\rho} \cdot Y_i + 0.3 \cdot RER \cdot e_i}{(R_i + 1) \cdot P_i}$$

**滤波器切换条件**

$$\Delta \cdot |\Delta| > S_{ff} \cdot D_{bf}$$

其中 $\Delta = S_{ff} – S_{ee}$。

## 1.2 以下是完整的代码处理流程拆解:

### 第一阶段:初始化阶段__init__
当实例化 `MDF(fs, frame_size, filter_length)` 时,算法进行环境搭建:
1. **参数计算**:
– `M`:将长滤波器(如250ms)拆分成 `M` 个频域块(如按8ms一块)。
– `N`:FFT窗口大小(通常是 `frame_size` 的2倍)。
2. **内存分配**:为频域信号(`X`, `Y`, `E`)、滤波器权重(`W`, `foreground`)、功率谱(`power`)等分配复数/实数数组。
3. **窗函数与比例因子**:
– 生成汉宁窗(`window`),用于重叠保存法中平滑拼接。
– 初始化块比例因子(`prop`),按指数衰减分布,离当前帧越近的块权重越大。
4. **预处理参数**:
– 预加重系数(`preemph = 0.9`),用于高频提升。
– 根据采样率计算直流陷波滤波器(`notch_radius`)的参数。

### 第二阶段:外部帧处理循环 (`main_loop`)
这是与外部交互的接口,处理一整段语音:
1. **量化模拟**:将输入的浮点信号 `u`(远端/扬声器)和 `d`(近端/麦克风)转换为 `[-32768, 32767]` 的16位整数。这模拟了实际DSP芯片的定点运算行为。
2. **分帧送入**:将长信号按 `frame_size` 切片,逐帧送入核心处理函数 `speex_echo_cancellation_mdf`。
3. **结果重组**:将处理后的短整型结果转回浮点数,输出 `e`(残余误差/消除后的信号)和 `y`(估计的回声)。

### 第三阶段:核心帧处理 (`speex_echo_cancellation_mdf`)
这是算法的灵魂,每处理一帧(如8ms)执行一次。流程非常严密,可分为以下几个步骤:

#### 步骤 3.1:时域预处理
– **直流消除**:对麦克风信号 `mic` 通过二阶IIR陷波滤波器(`filter_dc_notch16`),滤除直流干扰。
– **预加重**:对麦克风和远端信号分别进行一阶高通滤波(减去上一帧乘以0.9的值),增强高频,使频谱更平坦。

#### 步骤 3.2:频域变换与排队 (FFT & Queue)
– 将远端信号的新一帧放入缓冲区 `self.x`,对整个 `N` 点窗口做 FFT,得到频域信号。
– **块移位**:将历史频域块 `self.X` 往后移动一位(最老的块被丢弃),将最新的FFT结果放入第0块。形成 $M+1$ 个频域块的队列。

#### 步骤 3.3:前景滤波与初始误差计算
*注:代码维护了两套滤波器权重——`foreground`(前景,用于输出)和 `W`(背景,用于摸索更新)。*
– 使用**前景滤波器** `foreground` 与所有的频域块 `X` 相乘并求和,得到估计回声 `Y`。
– 做 IFFT 还原到时域,利用重叠保存法,取出当前帧的有效部分。
– 计算初始误差:`e_foreground = 麦克风输入 – 前景估计回声`,并计算其能量 `Sff`。

#### 步骤 3.4:背景滤波器更新 (Weight Update – 核心)
如果麦克风没有发生饱和(削波):
– **频域梯度下降**:结合当前误差 `E`、共轭远端信号、以及最重要的**频率相关功率归一化步长** `power_1`,更新背景权重 `W`。
*(这对应论文中不依赖双讲检测,而是用残余误差和背景噪声连续调节步长的机制)*
– **时域约束 (AUMDF的体现)**:为了防止频域卷积带来的循环卷积效应(块边界处的突变),将背景权重 `W` 做 IFFT 变回时域,**强行将后半部分置零**,然后再 FFT 变回频域。这确保了它等效于一个真实的线性卷积滤波器。且为了节省算力,每次只更新部分块(`2+self.cancel_count) % (M-1)`)。

#### 步骤 3.5:计算背景误差
– 使用刚刚更新过的**背景滤波器** `W` 再次计算估计回声 `y`。
– 计算背景误差能量 `See`(即使用新权重后的残余能量)。

#### 步骤 3.6:前景/背景切换逻辑 (Robustness 核心)
算法通过比较 `Sff`(旧前景误差) 和 `See`(新背景误差) 来决定下一步怎么走:
– **情况A:背景变好 (See < Sff)**: 认为“摸索”有了进步。将背景权重 `W` 提拔为前景权重 `foreground`。并对误差信号应用汉宁窗进行平滑重叠,避免切换带来的听觉突变。清空方差统计。 - **情况B:背景变差 (发散/双讲发生)**: 如果 `See` 远大于 `Sff`(通过方差判定 `Davg1/Davg2`),说明可能遇到了近端说话(双讲)或者突发噪声导致权重更新跑偏了。 **处理**:果断放弃背景权重,将背景 `W` 强行拉回当前的前景 `foreground`。保证输出质量绝不恶化。清空方差统计。 #### 步骤 3.7:后处理与输出 - **去预加重**:将误差信号加上高通滤掉的部分,恢复原始音色。 - **饱和检测**:如果发现原始麦克风信号达到满幅(如 > 32000),标记 `saturated`,在接下来的帧中暂停背景更新,防止误差过大污染权重。
– **异常重置**:如果出现负数能量等违背物理常理的情况(`screwed_up` 累加到50),直接调用 `__init__` 进行硬重启。

#### 步骤 3.8:功率谱与自适应步长更新 (无双讲检测的秘密)
这一步为下一帧的“步骤3.4”准备变量 `power_1`(学习率):
1. 计算回声返回损耗增强(RER)。
2. **计算泄漏估计**:`leak_estimate`。通过比较残余误差功率和估计回声功率,动态评估当前还有多少比例的回声没消除干净。
3. **更新自适应步长 `power_1`**:
– 基础步长由远端信号能量 `Sxx` 和背景误差 `See` 决定。
– 如果远端声音大、误差小,步长就大(快速收敛)。
– 如果远端声音小或误差突然变大(暗示双讲或噪声),步长自动缩小(停止更新,保护权重)。
*(代码注释掉了一段直接用 `leak_estimate` 调节步长的高级逻辑,启用了其下方一段相对基础的逻辑,但核心思想一致)*

## 1.3 流程图总结

“`text
[远端信号 u] + [近端信号 d]
| |
V V
(预加重) (陷波滤波 + 预加重)
| |
V |
[FFT入队列 X] |
| |
+——+——+
|
V
+—> [前景滤波器 Foreground]
| |
| V
| 初始误差 E_foreground
| |
| V
| +—–+—–+
| | |
| V V
| [更新背景W] [计算背景误差 E_background]
| | |
| | V
| | [对比 E_foreground 和 E_background]
| | |
| | +—–+—–+
| | | |
| | [背景更好] [背景发散(双讲/噪声)]
| | | |
| | [W->前景] [前景->W (放弃更新)]
| | | |
+—–+—–+———–+
|
V
[去预加重 + 饱和保护] —> 输出: 消除回声后的信号 e
|
V
[更新功率谱 & 计算下一帧步长 power_1] —> (反馈给下一帧的 [更新背景W])
“`

# 2 预加重

在数字信号处理(尤其是语音和音频处理)中,**预加重** 是一个极其基础且重要的步骤。你之前阅读的 Speex AEC 代码中,那句 `tmp32 = self.input[i, chan] – (np.dot(self.preemph, self.memD[chan]))`(其中 `preemph=0.9`)就是在做这件事。

## 2.1 预加重

预加重本质上是一个**一阶高通滤波器**。

* **时域公式**:$y[n] = x[n] – \alpha \cdot x[n-1]$
* $x[n]$ 是当前的输入样本
* $x[n-1]$ 是上一个输入样本
* $\alpha$ 是预加重系数(语音处理中通常取 **0.9 到 0.97** 之间,代码里是 0.9)
* **频域特性(Z变换)**:$H(z) = 1 – \alpha z^{-1}$
* 它的频率响应大致以 **+6 dB/倍频程** 的斜率上升。
* **效果**:它会**放大高频信号,衰减低频信号**,使信号的频谱变得“平坦”一些。

人类的语音信号有一个天然的物理特性:**频谱倾斜**。由于声道的形状,语音信号在低频(如元音 a, e)的能量极高,而在高频(如摩擦音 s, sh, f)的能量衰减非常快(大约以 -12 dB/倍频程下降)。

进行预加重有三大核心目的:

1. 为了让 LPC(线性预测编码)更好地工作(最核心原因)
Speex 是基于 CELP(码激励线性预测)算法的。LPC 的核心思想是:**用过去的 $p$ 个样本预测当前的样本**。
* 如果不做预加重,信号的能量全集中在低频,LPC 滤波器的极点会全部挤在低频区域去拼命拟合那巨大的低频能量,导致**对高频的预测精度极差**。
* 经过预加重后,频谱变平坦了,LPC 滤波器就能“平均用力”,在整个频带上都能建立准确的预测模型。这对语音编码的音质、AEC 回声路径的估计准确性至关重要。

2. 均衡信噪比
在实际环境中(比如打电话),背景噪声(风扇声、电路底噪)的频谱通常是相对平坦的,而语音是低频强、高频弱。
如果不做预加重,在 4kHz 以上的高频段,**语音能量可能会比背景噪声还要低**,导致算法无法分辨高频语音和噪声。预加重把高频语音抬上去了,使得整个频带的信噪比更加均匀,有利于后续的噪声抑制(ANS)和回声消除。

3. 避免有限精度带来的数值问题
在定点运算(如 Speex 的 C 语言原生代码)中,低频的大能量数值容易导致整数溢出,而高频的小能量数值又会被截断为 0。预加重拉近了高低频的幅度量级,缓解了定点计算的动态范围压力。

## 2.2 逆预加重

既然把高频抬高了,算法处理完之后,必须把它还原回去,否则声音就不可用了。

* **时域公式**:$x[n] = y[n] + \alpha \cdot x[n-1]$
*(注意:这里用的是还原后的输出 $x[n-1]$ 进行反馈,而不是输入 $y[n-1]$)*
* **频域特性**:$H^{-1}(z) = \frac{1}{1 – \alpha z^{-1}}$
* 这是一个一阶低通滤波器,以 **-6 dB/倍频程** 衰减,刚好抵消预加重的影响。

为什么处理完还要做逆预加重?

在你之前看的 AEC 代码末尾,有这样一句:
`tmp_out = tmp_out + self.preemph * self.memE[chan]`
这就是在做逆预加重。原因很简单:

1. **恢复原始音色**:如果 AEC 输出的是预加重后的信号,听起来会非常尖锐、刺耳(全是被放大的高频气流声),必须通过逆预加重把高频压回去,还原成正常人的声音。
2. **对接下游模块**:AEC 只是前端模块,它的输出通常要送给 AGC(自动增益控制)或交给听筒播放。下游模块假设输入的是原始的、频谱倾斜的语音信号。如果不还原,下游模块(尤其是 AGC)会因为高频能量异常而做出错误的增益控制。

## 2.3 整个流水线的视角

在你的 AEC 代码中,流程是这样的:

1. **输入端**:原始麦克风信号 $\xrightarrow{\text{预加重}}$ 变平坦的信号。
2. **算法核心**:所有复杂的 FFT、滤波器更新(MDF)、回声估计,都在**平坦的频谱**上进行,获得了极高的估计精度。
3. **输出端**:估计出的干净误差信号 $\xrightarrow{\text{逆预加重}}$ 恢复原始频谱倾斜的信号 $\rightarrow$ 交给扬声器播放或后续处理。

这种**“前端抬高 -> 核心处理 -> 后端还原”**的套路,在音频 DSP 中无处不在(比如 MFCC 特征提取中的预加重也是这个道理)。

# 3 DC陷波滤波器

这个DC陷波滤波器的设计精巧地平衡了陷波深度、过渡带宽度和计算效率,是实时音频处理中的经典实现。

这是一个**二阶IIR直流陷波滤波器**,用于消除音频信号中的直流偏置(DC offset)分量。在自适应回声消除等应用中,直流分量会严重影响算法性能,因此需要预处理消除。

DC陷波滤波器 (DC Notch Filter) 原理与公式推导

| 特性 | 说明 |
|——|——|
| **滤波器类型** | 二阶IIR陷波滤波器 |
| **陷波位置** | DC (ω=0, z=1) |
| **零点** | z=1 双重零点 |
| **极点** | 共轭极点对,由r和den2决定 |
| **DC增益** | 0 (完美消除) |
| **高频增益** | ≈1 (透明通过) |
| **关键参数** | notch_radius (控制陷波宽度) |
| **计算复杂度** | ~9次浮点运算/样本 |
| **主要应用** | AEC预处理、音频预处理 |

## 3.1 数学模型推导

设:
– $x[n]$ = `vin` (输入信号)
– $y[n]$ = `out[i]` (输出信号)
– $v[n]$ = `vout` (中间变量)
– $m_0[n]$ = `mem[0]` (状态变量1)
– $m_1[n]$ = `mem[1]` (状态变量2)
– $r$ = `notch_radius` (陷波半径,通常接近1)
– $d$ = `den2` (分母系数)

## 3.2 差分方程提取

从代码逻辑可得状态更新方程:

$$
\begin{aligned}
v[n] &= m_0[n-1] + x[n] \\
m_0[n] &= m_1[n-1] + 2(-x[n] + r \cdot v[n]) \\
m_1[n] &= x[n] – d \cdot v[n] \\
y[n] &= r \cdot v[n]
\end{aligned}
$$

其中:
$$d = r^2 + 0.7(1-r)(1-r)$$

## 3.3 传递函数推导

将状态方程代入,消去中间变量:

**第一步**:将 $v[n] = m_0[n-1] + x[n]$ 代入 $m_0[n]$ 方程:

$$m_0[n] = m_1[n-1] + 2(-x[n] + r \cdot m_0[n-1] + r \cdot x[n])$$

简化:
$$m_0[n] = m_1[n-1] + 2(r-1)x[n] + 2r \cdot m_0[n-1]$$

**第二步**:对 $m_1[n]$ 方程进行代入:

$$m_1[n] = x[n] – d(m_0[n-1] + x[n]) = (1-d)x[n] – d \cdot m_0[n-1]$$

**第三步**:进行Z变换并求解传递函数。

设 $M_0(z)$, $M_1(z)$, $X(z)$, $V(z)$, $Y(z)$ 分别为对应信号的Z变换。

从中间变量 $v[n]$ 的方程:
$$V(z) = z^{-1}M_0(z) + X(z)$$

从 $m_0[n]$ 的Z变换:
$$M_0(z) = z^{-1}M_1(z) + 2(r-1)X(z) + 2r \cdot z^{-1}M_0(z)$$

整理:
$$M_0(z)(1 – 2r \cdot z^{-1}) = z^{-1}M_1(z) + 2(r-1)X(z)$$

从 $m_1[n]$ 的Z变换:
$$M_1(z) = (1-d)X(z) – d \cdot z^{-1}M_0(z)$$

**第四步**:联立求解传递函数 $H(z) = Y(z)/X(z)$。

经过代数运算(过程较繁琐),最终得到:

$$H(z) = \frac{r(1-z^{-1})^2}{1 – 2rz^{-1} + d \cdot z^{-2}}$$

### 3.3.1 传递函数分析

将传递函数写成标准形式:

$$H(z) = \frac{r(1-z^{-1})^2}{1 – 2rz^{-1} + d \cdot z^{-2}}$$

**零点分析**:
分子 $r(1-z^{-1})^2 = r \cdot z^{-2}(z-1)^2$

零点位于 **$z = 1$ (双重零点)**,即在DC频率 ($\omega = 0$) 处形成陷波。

**极点分析**:
分母 $1 – 2rz^{-1} + d \cdot z^{-2} = 0$

极点位置由 $r$ 和 $d$ 决定。由于 $r \approx 1$(典型值0.98-0.99),极点靠近单位圆,但不会导致不稳定。

## 3.4 频率响应特性

### 3.4.1 DC处增益

将 $z = e^{j\omega}$,当 $\omega = 0$ 时 $z = 1$:

$$H(1) = \frac{r(1-1)^2}{1 – 2r + d} = 0$$

**DC增益为零**,完美消除直流分量。

### 3.4.2 高频特性

当 $\omega \to \pi$ (高频),$z \to -1$:

$$H(-1) = \frac{r(1+1)^2}{1 + 2r + d} = \frac{4r}{1 + 2r + d}$$

对于典型参数($r = 0.98$, $d \approx 0.96$):
$$H(-1) \approx \frac{3.92}{1 + 1.96 + 0.96} \approx 1$$

**高频增益接近1**,对音频信号影响极小。

### 3.4.3 过渡带宽度

陷波的宽度由参数 $r$ 控制:

| $r$ 值 | 陷波宽度 | 响应速度 |
|——–|———-|———-|
| 0.99 | 很窄 | 慢 |
| 0.98 | 窄 | 中等 |
| 0.95 | 较宽 | 快 |

$r$ 越接近1,陷波越窄,但收敛越慢(更多的历史样本参与)。

## 3.5 等效方框图

“`
┌─────────────────────────────────────┐
│ 状态空间实现 │
│ │
x[n] ──┬──►│ ┌───────┐ ┌───────┐ │
│ │ │ m₀[n] │ │ m₁[n] │ │
│ │ │ z⁻¹ │ │ z⁻¹ │ │
│ │ └───┬───┘ └───┬───┘ │
│ │ │ │ │
│ │ ┌───▼───┐ ┌───▼───┐ │
│ │ │ + │◄─────┤ + │ │
│ │ └───┬───┘ └───────┘ │
│ │ │ │
│ │ ┌───▼───┐ │
│ │ │ ×r │──────────────────────►│─── y[n]
│ │ └───────┘ │
│ │ │
│ │ 反馈路径:(复杂IIR结构) │
└──►│ · 2(r-1)x[n] 前馈 │
│ · 2r·m₀[n-1] 反馈 │
│ · (1-d)x[n] – d·m₀[n-1] │
└─────────────────────────────────────┘
“`

## 3.6 参数设计依据

den2系数的含义

“`python
den2 = r² + 0.7*(1-r)*(1-r)
“`

这个系数的设计使得极点位置满足:
– 极点实部 $\approx r$(接近单位圆)
– 极点虚部 $\approx \sqrt{0.3}(1-r)$(轻微离轴)

系数 **0.7** 是经验值,提供最佳的陷波宽度与稳定性的折中。

典型参数选择

“`python
# Speex/WebRTC 典型配置
notch_radius = 0.98 # 或 0.99

# 计算得到的 den2
# r = 0.98: den2 = 0.98² + 0.7×0.02² = 0.9604 + 0.00028 = 0.96068
# r = 0.99: den2 = 0.99² + 0.7×0.01² = 0.9801 + 0.00007 = 0.98017
“`

## 3.7 与标准DC Blocker的关系

### 3.7.1 一阶DC Blocker (简单形式)

标准一阶DC blocker:
$$H(z) = \frac{1-z^{-1}}{1-\alpha z^{-1}}$$

其中 $\alpha \approx 0.995$。

**特点**:
– 单零点在 z=1
– 单极点在 z=α
– 计算简单,但陷波不够陡峭

### 3.7.2 二阶DC Notch (本代码)

$$H(z) = \frac{r(1-z^{-1})^2}{1-2rz^{-1}+d \cdot z^{-2}}$$

**特点**:
– 双重零点在 z=1(陷波更深)
– 共轭极点对(过渡更平滑)
– 更好的低频响应

### 3.7.3 频率响应对比

| 特性 | 一阶DC Blocker | 二阶DC Notch |
|——|—————|————–|
| DC衰减 | ∞ (零点) | ∞ (双重零点) |
| 低频衰减斜率 | 20dB/dec | 40dB/dec |
| 计算复杂度 | 2次乘法/样本 | ~6次乘法/样本 |
| 过渡带 | 较宽 | 较窄 |

## 3.8 代码实现细节分析

“`python
def filter_dc_notch16(self, mic, mem):
out = np.zeros_like(mic)

# 预计算分母系数(只需计算一次)
den2 = self.notch_radius**2 + 0.7 * \
(1-self.notch_radius)*(1 – self.notch_radius)

# 逐样本处理(IIR滤波器需要顺序计算)
for i in range(self.frame_size):
vin = mic[i]

# 步骤1:计算中间输出 vout
# vout = mem[0] + vin(状态反馈 + 当前输入)
vout = mem[0] + vin

# 步骤2:更新状态 mem[0]
# mem[0] = mem[1] + 2*(-vin + r*vout)
# 展开:= mem[1] – 2*vin + 2*r*(mem[0] + vin)
# = mem[1] + 2*(r-1)*vin + 2*r*mem[0]
mem[0] = mem[1] + 2*(-vin + self.notch_radius*vout)

# 步骤3:更新状态 mem[1]
# mem[1] = vin – den2*vout
# 部分前馈 + 极点反馈
mem[1] = vin – (den2*vout)

# 步骤4:计算最终输出
# 输出 = r * vout(增益补偿)
out[i] = self.notch_radius * vout

return out, mem
“`

计算复杂度

每样本操作:
– 加法:5次
– 乘法:4次
– 总计:~9次浮点运算/样本

对于帧长N:
– 总运算量:O(9N) ≈ O(N)

状态变量初始化

“`python
mem = [0.0, 0.0] # 通常初始化为零

# 如果已知信号的初始DC偏置,可以预设状态以减少收敛时间
# mem[0] = -initial_dc_offset / notch_radius
“`

## 3.9 应用场景

### 3.9.1 自适应回声消除(AEC)

在AEC系统中,DC分量会导致:
1. 自适应滤波器权值的DC偏置累积
2. 双端检测(DTD)误判
3. 残留回声估计偏差

**解决方案**:在预加重之前先用DC notch消除直流。

### 3.9.2 音频编解码

DC偏置会:
– 降低编码效率(浪费比特)
– 产生低频噪声
– 影响静音检测

### 3.9.3 语音识别

DC偏置会改变能量分布,影响特征提取。

# 4 MDF自适应滤波器中各块的更新比例因子

MDF_adjust_prop 函数,这个函数用于**计算MDF(多延迟块频域)自适应滤波器中各块的更新比例因子**。在AUMDF(交替更新MDF)算法中,不同块的滤波器权值需要根据其”活跃程度”分配不同的学习率,这个比例因子决定了每个块应该获得多大的更新强度。

这个函数体现了**比例自适应**的核心思想:根据滤波器权值的当前状态动态调整学习率,使得算法能够快速收敛活跃区域,同时保持对变化区域的响应能力。

| 特性 | 说明 |
|——|——|
| **核心功能** | 计算MDF各块的更新比例因子 |
| **设计理念** | 能量大的块获得更大的学习率 |
| **关键公式** | $\text{prop}_i = \frac{0.99 \sqrt{1+E_i}}{1+\sum_j p_j}$ |
| **偏置作用** | 保证所有块都有最小更新量 |
| **归一化** | 比例因子之和 < 0.99 | | **应用场景** | AUMDF频域权值更新 | | **复杂度** | $O(MCKN)$ | | 参数 | 含义 | 典型值 | |------|------|--------| | `N` | `window_size` | FFT窗口大小(如512, 1024) | | `M` | 块数量 | 滤波器分块数(如4, 8, 16) | | `C` | 输出通道数 | 麦克风数量 | | `K` | 输入通道数 | 扬声器数量 | | `prop` | 输出比例因子向量 | 维度 M×1 | ## 4.1 代码逐行解析 ```python def mdf_adjust_prop(self,): N = self.window_size # FFT大小 M = self.M # 块数量 C = self.C # 输出通道数(麦克风) K = self.K # 输入通道数(扬声器) prop = np.zeros((M, 1),) # 初始化比例因子向量 # 遍历每个块 for i in range(M): tmp = 1 # 初始化能量累加器(加1避免零值) # 遍历所有输出通道(麦克风) for chan in range(C): # 遍历所有输入通道(扬声器) for speak in range(K): # 累加当前块所有频点的权值模平方 # W 的维度: [频点 × 扬声器 × 块索引 × 麦克风] tmp = tmp + np.sum(np.abs(self.W[:N//2+1, speak, i, chan])**2) # 取平方根得到"权值幅度" prop[i] = np.sqrt(tmp) # 找到最大值(但至少为1,避免除零) max_sum = np.maximum(prop, 1) # 加入偏置项,避免某些块比例过小 prop = prop + .1 * max_sum # 计算总和(加1避免零值问题) prop_sum = 1 + np.sum(prop) # 归一化并缩放到0.99 prop = 0.99 * prop / prop_sum return prop ``` ## 4.2 数学公式推导 ### 4.2.1 权值能量计算 对于第 $i$ 个块,计算其权值总能量: $$E_i = \sum_{chan=0}^{C-1} \sum_{speak=0}^{K-1} \sum_{k=0}^{N/2} |W_k^{(speak, i, chan)}|^2$$ 其中: - $k$ 是频点索引(只取正频率部分:$0$ 到 $N/2$) - $W_k^{(speak, i, chan)}$ 是频域权值 ### 4.2.2 比例因子计算 **第一步**:计算能量平方根 $$p_i = \sqrt{1 + E_i}$$ **第二步**:加入偏置 $$p_i \leftarrow p_i + 0.1 \cdot \max_j(p_j, 1)$$ **第三步**:归一化 $$\text{prop}_i = \frac{0.99 \cdot p_i}{1 + \sum_{j=0}^{M-1} p_j}$$ ### 4.2.3 比例因子性质 归一化后满足: $$\sum_{i=0}^{M-1} \text{prop}_i = \frac{0.99 \sum_i p_i}{1 + \sum_i p_i} < 0.99$$ 这保证了所有块的比例因子之和略小于1,留有一定的"余地"(稳定性保证)。 ## 4.3 设计原理 为什么需要比例因子? 在MDF算法中,长滤波器被分解为多个短块: ``` 时域滤波器: h[0], h[1], ..., h[L-1] ↓ 分块 块0: h[0], ..., h[N-1] → 频域权值 W_0 块1: h[N], ..., h[2N-1] → 频域权值 W_1 ... 块M-1: h[(M-1)N], ..., h[MN-1] → 频域权值 W_{M-1} ``` **核心问题**:不同块的权值对滤波器输出的贡献不同。 在回声消除场景中: - **近端块(索引小)**:对应较小的延迟,通常包含更多的回声能量 - **远端块(索引大)**:对应较大的延迟,可能只包含噪声或很弱的回声 因此,应该根据每个块的"重要性"分配不同的更新强度。 ### 4.3.1 能量与重要性的关系 权值能量大的块意味着: 1. 该延迟区间有较强的回声路径响应 2. 该块的调整对输出影响较大 3. 应该获得更大的学习率 ### 4.3.2 偏置项的作用 ```python prop = prop + .1 * max_sum ``` 加入偏置项的目的: | 无偏置 | 有偏置 | |--------|--------| | 能量为0的块比例≈0 | 最小比例≈0.1/M | | 可能导致某些块"死亡" | 保证所有块都有最小更新 | | 对新出现的回声响应慢 | 能快速适应变化 | ## 4.4 在AUMDF更新中的应用 权值更新公式,在MDF的频域权值更新中: $$W_i^{(new)} = W_i^{(old)} + \mu \cdot \text{prop}_i \cdot \frac{X_i^* \odot E}{\|X_i\|^2 + \varepsilon}$$ 其中: - $\mu$ 是全局学习率 - $\text{prop}_i$ 是该块的比例因子 - $X_i$ 是该块对应的频域输入 - $E$ 是频域误差 - $\odot$ 表示元素乘法 比例因子的影响 | 块特性 | 能量 | prop_i | 更新强度 | |--------|------|--------|----------| | 活跃块 | 大 | 大 | 强 | | 不活跃块 | 小 | 小 | 弱 | | 静默块 | 0 | 最小值 | 最小 | ## 4.5 示例计算 假设 $M=4$ 块,计算得到各块能量: ``` 块0: E=1000 → p=√1001 ≈ 31.6 块1: E=500 → p=√501 ≈ 22.4 块2: E=100 → p=√101 ≈ 10.0 块3: E=10 → p=√11 ≈ 3.3 ``` **加入偏置** ($\max = 31.6$): ``` p' = [31.6 + 3.16, 22.4 + 3.16, 10.0 + 3.16, 3.3 + 3.16] = [34.76, 25.56, 13.16, 6.46] ``` **归一化**: ``` sum = 1 + 34.76 + 25.56 + 13.16 + 6.46 = 80.94 prop = 0.99 × [34.76, 25.56, 13.16, 6.46] / 80.94 = [0.425, 0.312, 0.161, 0.079] ``` **结果解读**: | 块 | 能量占比 | prop占比 | 解读 | |----|----------|----------|------| | 0 | 61.7% | 42.5% | 最活跃,获得最大更新 | | 1 | 30.9% | 31.2% | 较活跃 | | 2 | 6.2% | 16.1% | 较弱,偏置提升明显 | | 3 | 0.6% | 7.9% | 最弱,偏置保证最小更新 | ## 4.6 频点范围说明 ```python self.W[:N//2+1, speak, i, chan] ``` 只取前 `N//2+1` 个频点的原因: | 频点范围 | 数量 | 说明 | |----------|------|------| | 0 | 1 | DC分量 | | 1 到 N/2-1 | N/2-1 | 正频率 | | N/2 | 1 | Nyquist频率 | | N/2+1 到 N-1 | N/2-1 | 负频率(共轭对称) | 对于实数信号,频域权值具有共轭对称性: $$W_k = W_{N-k}^*$$ 因此只需计算正频率部分的能量,避免重复计算。 ## 4.7 与其他技术的关联 ### 4.7.1 与分块NLMS的对比 | 方法 | 比例分配策略 | |------|-------------| | 标准NLMS | 所有系数相同学习率 | | PNLMS(比例NLMS) | 根据每个系数的幅度分配 | | **MDF adjust_prop** | 根据**每个块**的总能量分配 | ### 4.7.2 与PNLMS的区别 PNLMS (Proportionate NLMS) 为**每个系数**设置独立的学习率: $$\mu_k \propto |w_k| + \delta$$ 而 `mdf_adjust_prop` 为**每个块**设置统一的学习率: $$\mu_i \propto \sqrt{\sum_k |W_k^{(i)}|^2}$$ **MDF采用块级比例的原因**: 1. 减少计算量(M个块 vs MN个系数) 2. 频域中系数之间有耦合 3. 块级比例更稳定 ## 4.8 性能优化考虑 ### 4.8.1 当前实现的复杂度 ```python for i in range(M): # M次迭代 for chan in range(C): # C次迭代 for speak in range(K): # K次迭代 np.sum(...) # O(N) 操作 ``` 总复杂度:**$O(M \cdot C \cdot K \cdot N)$** ### 4.8.2 可能的优化 ```python # 向量化实现 def mdf_adjust_prop_vectorized(self): # 一次性计算所有块的能量 # W形状: [N//2+1, K, M, C] W_energy = np.sum(np.abs(self.W[:self.N//2+1, :, :, :])**2, axis=(0, 3)) # 现在形状: [K, M] prop = np.sqrt(1 + np.sum(W_energy, axis=0)) # 形状: [M] max_sum = np.maximum(prop, 1) prop = prop + 0.1 * max_sum prop_sum = 1 + np.sum(prop) prop = 0.99 * prop / prop_sum return prop.reshape(-1, 1) ``` 优化后复杂度:**$O(M \cdot C \cdot K \cdot N)$ 但常数更小** # 5 MDF算法原理 ## 5.1 时域自适应滤波回顾 **标准LMS算法**(时域): $$\mathbf{w}(n+1) = \mathbf{w}(n) + \mu \cdot e(n) \cdot \mathbf{x}(n)$$ **问题**: - 长回声路径需要长滤波器(如250ms @ 16kHz = 4000抽头) - 时域卷积计算复杂度 $O(L^2)$ - 每个样本都要更新,计算量大 ## 5.2 频域自适应滤波 (FDAF) **核心思想**:利用FFT将时域卷积转换为频域点乘。 **重叠保存法 (Overlap-Save)**: 设滤波器长度为 $L$,帧长为 $N = 2L$: **时域卷积**: $$y(n) = \sum_{k=0}^{L-1} w(k) \cdot x(n-k)$$ **频域表示**: $$\mathbf{Y} = \mathbf{W} \odot \mathbf{X}$$ 其中 $\odot$ 表示逐元素乘法,$\mathbf{W}$ 和 $\mathbf{X}$ 为FFT变换后的频域表示。 **复杂度对比**: | 方法 | 每样本计算量 | |------|-------------| | 时域LMS | $O(L)$ | | 频域FDAF | $O(\log L)$ | ## 5.3 MDF算法结构 **问题**:FDAF需要 $N = 2L$ 点FFT,对于长滤波器(如4096抽头),FFT点数过大。 **MDF解决方案**:将长滤波器分解为 $M$ 个较短子滤波器。 **滤波器分解**: $$\mathbf{w} = [\mathbf{w}_1^T, \mathbf{w}_2^T, \ldots, \mathbf{w}_M^T]^T$$ 每个子滤波器长度为帧长 $N/2$。 **频域表示**: $$\mathbf{W} = [\mathbf{W}_1, \mathbf{W}_2, \ldots, \mathbf{W}_M]$$ **滤波输出**: $$\mathbf{Y} = \sum_{j=1}^{M} \mathbf{X}_j \odot \mathbf{W}_j$$ **代码对应**: ```matlab st.Y(:, chan) = 0; for j=1:M st.Y(:, chan) = st.Y(:, chan) + st.X(:, speak, j) .* st.W(:, speak, j, chan); end ``` ## 5.4 MDF算法公式推导 ### 5.4.1 频域自适应滤波推导 **第一步:定义频域信号** 设输入信号帧为 $\mathbf{x}(k) = [x(k), x(k-1), \ldots, x(k-N+1)]^T$,经FFT变换: $$\mathbf{X}(k) = \text{FFT}\{\mathbf{x}(k)\}$$ **第二步:频域滤波** 频域滤波输出: $$\mathbf{Y}(k) = \mathbf{W}(k) \odot \mathbf{X}(k)$$ 时域输出(IFFT后取有效部分): $$\mathbf{y}(k) = \text{IFFT}\{\mathbf{Y}(k)\}[N/2:N-1]$$ **第三步:频域误差** 期望信号帧 $\mathbf{d}(k)$ 与输出误差: $$\mathbf{e}(k) = \mathbf{d}(k) - \mathbf{y}(k)$$ 频域误差(零填充后FFT): $$\mathbf{E}(k) = \text{FFT}\{[\mathbf{0}, \mathbf{e}(k)]^T\}$$ **第四步:频域梯度** 时域LMS梯度: $$\nabla_{\mathbf{w}} J = -e(k) \cdot \mathbf{x}(k)$$ 频域梯度(利用循环卷积性质): $$\nabla_{\mathbf{W}} J = -\mathbf{E}(k) \odot \mathbf{X}^*(k)$$ **第五步:频域权值更新** $$\mathbf{W}(k+1) = \mathbf{W}(k) + \mu \cdot \mathbf{E}(k) \odot \mathbf{X}^*(k)$$ ### 5.4.2 归一化频域LMS (NFDAF) **功率归一化**: $$\mathbf{W}(k+1) = \mathbf{W}(k) + \frac{\mu}{\mathbf{P}(k)} \odot \mathbf{E}(k) \odot \mathbf{X}^*(k)$$ 其中功率估计: $$\mathbf{P}(k) = \lambda \mathbf{P}(k-1) + (1-\lambda) |\mathbf{X}(k)|^2$$ **代码对应**: ```matlab st.PHI = [st.power_1; st.power_1(end-1:-1:2)] .* st.prop(j) .* conj(st.X(:, speak, (j+1))) .* st.E(:, chan); st.W(:, j) = st.W(:, j) + st.PHI; ``` ### 5.4.3 AUMDF变体 **循环卷积问题**:频域自适应滤波会引入循环卷积效应,导致失真。 **AUMDF解决方案**:交替更新各子滤波器,并强制约束。 **约束条件**:权值在时域的后半部分为零(重叠保存法要求)。 **更新规则**(代码中): ```matlab if (j==1 || mod(2+st.cancel_count,(M-1)) == j) st.wtmp = ifft(st.W(:, speak, j, chan)); st.wtmp(st.frame_size+1:N) = 0; % 强制约束 st.W(:, speak, j, chan) = fft(st.wtmp); end ``` **数学表示**: $$\mathbf{w}_j \leftarrow \text{IFFT}\{\mathbf{W}_j\}$$ $$\mathbf{w}_j[N/2:N-1] = 0$$ $$\mathbf{W}_j \leftarrow \text{FFT}\{\mathbf{w}_j\}$$ # 6 双滤波器机制 ## 6.1 前景/背景滤波器结构 代码采用双滤波器结构: - **前景滤波器** `st.foreground`:用于输出 - **背景滤波器** `st.W`:用于自适应学习 ```matlab st.W = zeros(N, K, M, C); % 背景滤波器 st.foreground = zeros(N, K, M, C); % 前景滤波器 ``` ## 6.2 双滤波器原理 **背景滤波器**: - 持续进行自适应更新 - 可能受到干扰(双讲、噪声)影响而发散 **前景滤波器**: - 仅在背景滤波器性能更好时更新 - 提供稳定的输出 **性能评估**: - $S_{ff}$:前景滤波器的误差能量 - $S_{ee}$:背景滤波器的误差能量 ```matlab Sff = Sff + sum(abs(st.e(1:st.frame_size, chan)).^2); % 前景误差 See = See + sum(abs(st.e(1:st.frame_size, chan)).^2); % 背景误差 ``` ## 6.3 滤波器切换逻辑 **更新条件**(背景→前景): $$\Delta^2 > S_{ff} \cdot D_{bf}$$

其中 $\Delta = S_{ff} – S_{ee}$,$D_{bf}$ 为两滤波器响应差异。

**代码实现**:
“`matlab
if (Sff-See)*abs(Sff-See) > (Sff*Dbf)
update_foreground = 1;
elseif (st.Davg1*abs(st.Davg1) > (VAR1_UPDATE*st.Dvar1))
update_foreground = 1;

end
“`

**统计验证**:

使用两个时间尺度的统计检验:
– $D_{avg1}$, $D_{var1}$:短时间尺度
– $D_{avg2}$, $D_{var2}$:长时间尺度

$$D_{avg} = \alpha \cdot D_{avg} + (1-\alpha) \cdot (S_{ff} – S_{ee})$$
$$D_{var} = \alpha^2 \cdot D_{var} + (1-\alpha)^2 \cdot S_{ff} \cdot D_{bf}$$

## 6.4 变学习率机制

### 6.4.1 泄漏估计

**核心概念**:泄漏估计 $\hat{\rho}$ 表示回声消除后残余回声与麦克风信号的比例。

**估计方法**:

基于误差信号与滤波输出的相关性:

$$P_{ey} = E[E \cdot Y^*]$$
$$P_{yy} = E[|Y|^2]$$

**泄漏估计**:
$$\hat{\rho} = \frac{P_{ey}}{P_{yy}}$$

**代码实现**:
“`matlab
Pey_cur = Pey_cur + sum(Eh_cur.*Yh_cur);
Pyy_cur = Pyy_cur + sum(Yh_cur.^2);

st.leak_estimate = st.Pey/st.Pyy;
“`

### 6.4.2 自适应学习率

**学习率调整公式**:

对于每个频率点 $i$:

$$\mu_i = \frac{r_i}{e_i \cdot P_i}$$

其中:
– $r_i = \hat{\rho} \cdot Y_i + 0.3 \cdot RER \cdot e_i$
– $e_i = R_i + 1$(误差功率)
– $P_i$:输入功率估计
– $RER$:残余误差比

**代码实现**:
“`matlab
for i=1:st.frame_size+1
r = st.leak_estimate*st.Yf(i);
e = st.Rf(i)+1;
if (r>.5*e)
r = .5*e;
end
r = 0.7*r + 0.3*(RER*e);
st.power_1(i) = (r/(e*st.power(i)+10));
end
“`

### 6.4.3 RER(残余误差比)

**定义**:
$$RER = \frac{S_{xx} + \hat{\rho} \cdot S_{yy}}{S_{ee}}$$

**物理意义**:
– 分子:估计的残余回声功率
– 分母:实际误差功率
– 理想情况下 $RER \approx 1$

**代码实现**:
“`matlab
RER = (.0001*Sxx + 3.*st.leak_estimate*Syy) / See;
“`

### 6.4.4 双讲处理

**无显式双讲检测**,而是通过连续调整学习率实现鲁棒性。

**双讲场景分析**:

| 场景 | $S_{dd}$ | $S_{ee}$ | 学习率 |
|——|———-|———-|——–|
| 仅远端 | 正常 | 小 | 正常 |
| 双讲 | 大 | 大 | 自动减小 |
| 仅近端 | 大 | 大 | 自动减小 |

**机制**:
$$\alpha = \frac{\beta_0 \cdot S_{yy}}{S_{ee}}$$

双讲时 $S_{ee}$ 增大,$\alpha$ 减小,学习率降低。

## 6.5 预处理与后处理

### 6.5.1 预加重

**目的**:降低信号动态范围,改善数值特性。

**预加重滤波器**:
$$x_{pre}(n) = x(n) – \alpha \cdot x(n-1)$$

**代码实现**:
“`matlab
st.preemph = .98;

tmp32 = far_end(i, speak) – st.preemph * st.memX(speak);
“`

### 6.5.2 直流陷波滤波器

**目的**:消除直流偏置。

**传递函数**:
$$H(z) = \frac{1 – z^{-1}}{1 – 2r \cos(\omega_0) z^{-1} + r^2 z^{-2}}$$

**代码实现**:
“`matlab
function [out,mem] = filter_dc_notch16(in, radius, len, mem)
for i=1:len
vin = in(i);
vout = mem(1) + vin;
mem(1) = mem(2) + 2*(-vin + radius*vout);
mem(2) = vin – (den2*vout);
out(i) = radius*vout;
end
end
“`

### 6.5.3 窗函数

**Hann窗**:
$$w(n) = 0.5 – 0.5 \cos\left(\frac{2\pi n}{N}\right)$$

**代码实现**:
“`matlab
st.window = .5-.5*cos(2*pi*((1:N)’-1)/N);
“`

**用途**:前景/背景滤波器切换时的平滑过渡。

## 6.6 比例自适应

### 6.6.1 比例因子

**目的**:对不同子滤波器分配不同的更新强度。

**初始化**:
“`matlab
decay = exp(-1/M);
st.prop(1, 1) = .7;
for i=2:M
st.prop(i, 1) = st.prop(i-1, 1) * decay;
end
st.prop = (.8 * st.prop)./sum(st.prop);
“`

**数学表示**:
$$p_i = \frac{0.8 \cdot p_i}{\sum_{j=1}^{M} p_j}$$

**物理意义**:较早的子滤波器(对应较近的回声)获得更大的更新强度。

### 6.6.2 自适应比例调整

**基于权值能量调整**:

“`matlab
function prop = mdf_adjust_prop(W, N, M, C, K)
for i=1:M
tmp = 1;
for chan=1:C
for speak=1:K
tmp = tmp + sum(abs(W(1:N/2+1, K, i, C)).^2);
end
end
prop(i) = sqrt(tmp);
end
max_sum = max(prop, 1);
prop = prop + .1*max_sum;
prop_sum = 1+sum(prop);
prop = .99*prop / prop_sum;
end
“`

**数学表示**:
$$p_i = \frac{0.99 \cdot (\sqrt{E_i} + 0.1 \cdot \max_j \sqrt{E_j})}{1 + \sum_j (\sqrt{E_j} + 0.1 \cdot \max_k \sqrt{E_k})}$$

其中 $E_i = \|\mathbf{W}_i\|^2$ 为第 $i$ 个子滤波器的能量。

## 6.7 完整算法流程

### 6.7.1 算法伪代码

“`
初始化:
设置帧长 N/2, 滤波器块数 M
初始化权值 W = 0, foreground = 0
初始化功率估计 P, 相关性 Pey, Pyy

For each frame k:

1. 预处理:
– DC陷波滤波
– 预加重

2. 频域变换:
X_j = FFT(x_j), j = 1, …, M

3. 前景滤波:
Y_fg = Σ_j X_j ⊙ W_fg_j
y_fg = IFFT(Y_fg)
e_fg = d – y_fg
S_ff = ||e_fg||^2

4. 背景滤波与更新:
Y_bg = Σ_j X_j ⊙ W_bg_j
y_bg = IFFT(Y_bg)
e_bg = d – y_bg
S_ee = ||e_bg||^2

For j = M to 1:
PHI = (1/P) ⊙ prop(j) ⊙ X_j^* ⊙ E
W_j = W_j + PHI

AUMDF约束更新

5. 滤波器切换:
If S_ff > S_ee (统计显著):
foreground = W (背景→前景)
Else if S_ff << S_ee: W = foreground (前景→背景) 6. 变学习率更新: 计算泄漏估计 ρ 计算RER 更新功率归一化因子 7. 输出: out = e_fg (去加重后) End For ``` ```matlab % 主要数据结构 st.X % 输入频域信号 [N, K, M+1] st.W % 背景滤波器权值 [N, K, M, C] st.foreground % 前景滤波器权值 [N, K, M, C] st.E % 误差频域信号 [N, C] st.Y % 输出频域信号 [N, C] st.power % 功率估计 [frame_size+1, 1] st.power_1 % 归一化因子 [frame_size+1, 1] st.prop % 比例因子 [M, 1] ``` # 7 Speex MDF回声消除算法代码详细分析 - **算法依据**:Soo & Pang (1990) 的 MDF 算法,以及 Valin (2007) 关于频域回声消除中自适应学习率调整的论文。 - **核心思想**:将长滤波器分成多个短块,在频域进行自适应滤波,通过前景/背景滤波器切换和连续调整学习率来应对双讲。 - **输入输出**: - 参考信号 `u`(扬声器信号) - 麦克风信号 `d`(近端信号) - 输出 `e`(消除回声后的信号,即误差)和 `y`(估计回声) - **关键参数**: - `fs`:采样率 - `frame_size`:帧长(通常 8ms,须为 2 的幂) - `filter_length`:滤波器总长度(通常 250ms,须为 2 的幂) 代码整体结构 ``` ┌─────────────────────────────────────────────────────────────┐ │ MDF 类结构 │ ├─────────────────────────────────────────────────────────────┤ │ __init__() - 初始化所有参数和缓冲区 │ │ main_loop() - 主处理循环 │ │ speex_echo_cancellation_mdf() - 核心AEC处理函数 │ │ filter_dc_notch16() - DC陷波滤波器 │ │ mdf_adjust_prop() - 比例因子调整 │ └─────────────────────────────────────────────────────────────┘ ``` ## 7.1 初始化函数分析 ```python import numpy as np ``` 导入 NumPy 库用于数值计算。 ```python def float_to_short(x): x = x*32768.0 x[x < -32767.5] = -32768 x[x > 32766.5] = 32767
x = np.floor(0.5+x)
return x
“`
将浮点数(范围 [-1,1])转换为 16-bit 整型(范围 -32768~32767),用于内部定点化处理。

### 7.1.1 基本参数设置

“`python
def __init__(self, fs: int, frame_size: int, filter_length: int) -> None:
nb_mic = 1
nb_speakers = 1
self.K = nb_speakers # 扬声器数量
K = self.K
self.C = nb_mic # 麦克风数量
C = self.C
“`

**参数说明**:
| 参数 | 含义 | 典型值 |
|——|——|——–|
| `fs` | 采样率 | 16000 Hz |
| `frame_size` | 帧大小 | 128 (8ms @ 16kHz) |
| `filter_length` | 滤波器长度 | 4096 (256ms @ 16kHz) |

构造函数,初始化所有状态变量。

“`python
nb_mic = 1
nb_speakers = 1
self.K = nb_speakers
K = self.K
self.C = nb_mic
C = self.C
“`
仅支持单通道(单麦克风、单扬声器),`K` 和 `C` 分别表示扬声器通道数和麦克风通道数。

### 7.1.2 MDF核心参数

“`python
self.frame_size = frame_size
self.filter_length = filter_length
self.window_size = frame_size*2 # N = 2 * frame_size
N = self.window_size
self.M = int(np.fix((filter_length+frame_size-1)/frame_size)) # 块数
“`

**数学关系**:
– FFT长度:$N = 2 \times \text{frame\_size}$
– 块数:$M = \lceil \frac{L + \text{frame\_size} – 1}{\text{frame\_size}} \rceil$
– `window_size`:FFT 点数,为帧长的两倍(重叠保留法)。
– `M`:滤波器块数,使总长度 `M * frame_size` 覆盖 `filter_length`。

**示例**:
“`
frame_size = 256, filter_length = 3072
N = 512 (FFT长度)
M = (3072 + 256 – 1) / 256 = 13 块
总滤波器长度 = 13 × 256 = 3328
“`

“`python
self.cancel_count = 0
self.sum_adapt = 0
self.saturated = 0
self.screwed_up = 0
“`
统计计数器:`cancel_count` 处理帧数;`sum_adapt` 累计自适应步长;`saturated` 指示麦克风饱和;`screwed_up` 指示异常状态。

“`python
self.sampling_rate = fs
self.spec_average = (self.frame_size)/(self.sampling_rate)
self.beta0 = (2.0*self.frame_size)/self.sampling_rate
self.beta_max = (.5*self.frame_size)/self.sampling_rate
self.leak_estimate = 0
“`
– `spec_average`:频谱平滑系数(约等于帧长/采样率)。
– `beta0` 和 `beta_max`:用于学习率调整的上下界。
– `leak_estimate`:泄漏估计(回声路径残余)。

### 7.1.3 数据结构初始化

“`python
self.e = np.zeros((N, C),)
self.x = np.zeros((N, K),)
self.input = np.zeros((self.frame_size, C),)
self.y = np.zeros((N, C),)
self.last_y = np.zeros((N, C),)
self.Yf = np.zeros((self.frame_size+1, 1),)
self.Rf = np.zeros((self.frame_size+1, 1),)
self.Xf = np.zeros((self.frame_size+1, 1),)
self.Yh = np.zeros((self.frame_size+1, 1),)
self.Eh = np.zeros((self.frame_size+1, 1),)
“`
分配时域和频域缓冲区:
– `e`、`x`、`y`、`last_y`:时域信号(长度 N,用于重叠保留)。
– `input`:当前帧的麦克风信号(经过预加重后)。
– `Yf`、`Rf`、`Xf`:频谱幅度平方(用于功率估计)。
– `Yh`、`Eh`:平滑后的频谱(用于学习率)。

“`python
# 频域信号存储
self.X = np.zeros((N, K, M+1), dtype=np.complex) # 输入频域信号
self.Y = np.zeros((N, C), dtype=np.complex) # 输出频域信号
self.E = np.zeros((N, C), dtype=np.complex) # 误差频域信号
self.W = np.zeros((N, K, M, C), dtype=np.complex) # 背景滤波器权值
self.foreground = np.zeros((N, K, M, C), dtype=np.complex) # 前景滤波器权值
“`

频域数据结构:
– `X`:参考信号的频域表示,大小为 `(FFT点数, 通道数, 块数+1)`,其中 `X[:, :, 0]` 为当前块的 FFT,`X[:, :, 1:M+1]` 存储历史块(用于 MDF 的延迟线)。
– `Y`:估计回声的频域表示。
– `E`:误差信号的频域表示。
– `W`:自适应滤波器系数(频域)。
– `foreground`:前景滤波器系数(用于双讲时切换)。

**维度解释**:
“`
X: [N, K, M+1]
N: FFT长度
K: 扬声器数
M+1: 延迟块数(+1用于存储历史)

W: [N, K, M, C]
N: FFT长度
K: 扬声器数
M: 滤波器块数
C: 麦克风数
“`

### 7.1.4 比例因子初始化

“`python
self.PHI = np.zeros((frame_size+1, 1),)
self.power = np.zeros((frame_size+1, 1),)
self.power_1 = np.ones((frame_size+1, 1),)
self.window = np.zeros((N, 1),)
self.prop = np.zeros((M, 1),)
self.wtmp = np.zeros((N, 1),)
“`
辅助数组:
– `PHI`:更新量。
– `power`:参考信号功率平滑值。
– `power_1`:归一化步长因子。
– `window`:汉宁窗(用于重叠保留法的时域加窗)。
– `prop`:每个块的自适应比例(用于分配更新步长)。
– `wtmp`:临时时域系数。

“`python
decay = np.exp(-2.4/M)
self.prop[0, 0] = .7
for i in range(1, M):
self.prop[i, 0] = self.prop[i-1, 0] * decay
self.prop = (.8 * self.prop) / np.sum(self.prop)
“`

**数学公式**:
$$\text{prop}_i = \frac{0.8 \cdot \text{prop}_i}{\sum_{j=0}^{M-1} \text{prop}_j}$$

其中初始值:
$$\text{prop}_0 = 0.7, \quad \text{prop}_i = \text{prop}_{i-1} \cdot e^{-2.4/M}$$

**物理意义**:较早的块(对应较近的回声)获得更大的更新强度。
初始化块更新比例 `prop`:指数衰减,总和归一化,用于控制不同块的自适应步长(靠前的块获得更大权重)。

### 7.1.5 窗函数

“`python
self.window = .5 – .5 * np.cos(2*np.pi*(np.arange(1, N+1).reshape(-1, 1)-1)/N)
“`
生成汉宁窗,用于重叠保留法的合成。

**Hann窗公式**:
$$w[n] = 0.5 – 0.5 \cos\left(\frac{2\pi n}{N}\right), \quad n = 0, 1, \ldots, N-1$$

**用途**:前景/背景滤波器切换时的平滑过渡。

### 7.1.6 其它初始化变量

“`python
self.memX = np.zeros((K, 1),)
self.memD = np.zeros((C, 1),)
self.memE = np.zeros((C, 1),)
self.preemph = .9
“`
预加重滤波器状态:`memX`、`memD`、`memE` 用于一阶高通滤波(去直流)和去加重。

“`python
if self.sampling_rate < 12000: self.notch_radius = .9 elif self.sampling_rate < 24000: self.notch_radius = .982 else: self.notch_radius = .992 self.notch_mem = np.zeros((2*C, 1),) ``` DC notch 滤波器参数,根据采样率选择极点半径。 ```python self.adapted = 0 self.Pey = 1 self.Pyy = 1 self.Davg1 = 0 self.Davg2 = 0 self.Dvar1 = 0 self.Dvar2 = 0 ``` 其他状态变量: - `adapted`:是否已进入自适应状态(初始为 0,后续根据能量条件开启)。 - `Pey`、`Pyy`:用于泄漏估计的互相关和自相关。 - `Davg1/2`、`Dvar1/2`:用于双讲检测的统计量(均值和方差)。 ## 7.2 主循环分析 ### 7.2.1 main_loop函数 ```python def main_loop(self, u, d): """MDF core function Args: u (array): reference signal (远端/扬声器信号) d (array): microphone signal (近端/麦克风信号) """ assert u.shape == d.shape u = float_to_short(u) # 转换为16位定点 d = float_to_short(d) e = np.zeros_like(u) # 误差信号 y = np.zeros_like(u) # 滤波器输出 end_point = len(u) for n in range(0, end_point, self.frame_size): # 获取当前帧 u_frame = u[n:n+self.frame_size] d_frame = d[n:n+self.frame_size] # 核心处理 out = self.speex_echo_cancellation_mdf(d_frame[:, None], u_frame[:, None])[:,0] e[n:n+self.frame_size] = out y[n:n+self.frame_size] = d_frame - out ``` ```python e = e/32768.0 y = y/32768.0 return e, y ``` 输出转换为浮点数(范围 [-1,1])后返回。 逐帧处理: - 每帧调用 `speex_echo_cancellation_mdf`,输入为列向量(`[:, None]` 增加维度以保持通道维度)。 - 输出为当前帧的误差信号 `out`,保存到 `e`。 - 估计回声 `y` 为 `d_frame - out`。 - 打印处理进度(每帧)。 **处理流程图**: ``` 输入信号 u, d │ ↓ ┌─────────────┐ │ 定点转换 │ float_to_short() └─────────────┘ │ ↓ ┌─────────────┐ │ 分帧处理 │ for each frame └─────────────┘ │ ↓ ┌─────────────────────────────┐ │ speex_echo_cancellation_mdf │ └─────────────────────────────┘ │ ↓ 输出 e (误差), y (估计回声) ``` --- ## 7.2 核心处理函数分析 核心回声消除函数 `speex_echo_cancellation_mdf`,此函数处理一帧数据,实现 MDF 算法的核心步骤。 ### 7.2.1 函数签名与初始化 ```python def speex_echo_cancellation_mdf(self, mic, far_end): N = self.window_size # FFT长度 M = self.M # 块数 C = self.C # 麦克风数 K = self.K # 扬声器数 Pey_cur = 1 # 误差-输出相关性 Pyy_cur = 1 # 输出功率 out = np.zeros((self.frame_size, C)) self.cancel_count += 1 ss = .35/M # 平滑参数 ss_1 = 1 - ss ``` 初始化局部变量,递增帧计数器。 ### 7.2.2 预处理:DC陷波与预加重 #### 7.2.2.1 DC陷波滤波器 实现一个二阶 IIR 陷波滤波器,用于去除直流分量。使用双二阶结构,滤波器系数根据采样率选择极点半径。 ```python for chan in range(C): self.input[:, chan], self.notch_mem[:, chan] = self.filter_dc_notch16( mic[:, chan], self.notch_mem[:, chan]) ``` **filter_dc_notch16函数**: ```python def filter_dc_notch16(self, mic, mem): out = np.zeros_like(mic) den2 = self.notch_radius**2 + 0.7 * (1-self.notch_radius)**2 for i in range(self.frame_size): vin = mic[i] vout = mem[0] + vin mem[0] = mem[1] + 2*(-vin + self.notch_radius*vout) mem[1] = vin - (den2*vout) out[i] = self.notch_radius * vout return out, mem ``` **传递函数分析**: 这是一个二阶IIR陷波滤波器,用于消除直流分量: $$H(z) = \frac{1 - z^{-1}}{1 - 2r\cos(\omega_0)z^{-1} + r^2 z^{-2}}$$ 在DC处 ($\omega_0 = 0$): $$H(z) = \frac{1 - z^{-1}}{1 - 2rz^{-1} + r^2 z^{-2}} = \frac{1 - z^{-1}}{(1 - rz^{-1})^2}$$ #### 7.2.2.2 预加重 对麦克风信号应用 DC notch 滤波器(去除直流分量)。 ```python for i in range(self.frame_size): tmp32 = self.input[i, chan] - (np.dot(self.preemph, self.memD[chan])) self.memD[chan] = self.input[i, chan] self.input[i, chan] = tmp32 ``` 一阶预加重(高通):`input = input - 0.9 * memD`,提升高频分量。 **预加重滤波器**: $$x_{pre}[n] = x[n] - \alpha \cdot x[n-1]$$ 其中 $\alpha = 0.9$(预加重系数)。 **作用**:降低信号动态范围,改善数值稳定性。 ### 7.2.3 输入信号缓冲与频域变换 ```python for speak in range(K): for i in range(self.frame_size): # 移位缓冲区 self.x[i, speak] = self.x[i+self.frame_size, speak] # 预加重 tmp32 = far_end[i, speak] - np.dot(self.preemph, self.memX[speak]) self.x[i+self.frame_size, speak] = tmp32 self.memX[speak] = far_end[i, speak] # 循环移位历史频域信号 将历史块右移,为当前块腾出位置(`axis=2` 对应块索引)。 self.X = np.roll(self.X, 1, axis=2) # FFT变换 for speak in range(K): #对当前参考信号块做 FFT,存入 `X[:,:,0]`(除以 N 是为了与后续逆变换匹配)。 self.X[:, speak, 0] = np.fft.fft(self.x[:, speak]) / N ``` 更新参考信号缓冲区 `x`(长度为 N): - 将旧数据前移(丢弃最旧的一帧)。 - 对新的参考信号进行预加重后放入尾部。 - 更新预加重状态。 **数据流图**: ``` 远端信号 far_end │ ↓ ┌────────────────┐ │ 预加重 │ └────────────────┘ │ ↓ ┌────────────────┐ │ 移位缓冲区 x │ [x_old | x_new] └────────────────┘ │ ↓ ┌────────────────┐ │ FFT变换 │ X[:, :, 0] └────────────────┘ │ ↓ ┌────────────────┐ │ 滚动历史X │ X = roll(X, axis=2) └────────────────┘ ``` ### 7.2.4 前景滤波器计算 ```python Sxx = 0 for speak in range(K): Sxx = Sxx + np.sum(self.x[self.frame_size:, speak]**2) self.Xf = np.abs(self.X[:self.frame_size+1, speak, 0])**2 ``` 计算参考信号能量(只计算新数据部分,长度为 `frame_size`)并得到功率谱 `Xf`。 ```python Sff = 0 # 前景滤波器误差能量 for chan in range(C): self.Y[:, chan] = 0 for speak in range(K): for j in range(M): # 频域卷积累加 self.Y[:, chan] = self.Y[:, chan] + self.X[:, speak, j] * self.foreground[:, speak, j, chan] # IFFT得到时域输出 self.e[:, chan] = np.fft.ifft(self.Y[:, chan]).real * N # 重叠保存:取后frame_size个有效点 self.e[:self.frame_size, chan] = self.input[:, chan] - self.e[self.frame_size:, chan] # 计算误差能量 Sff = Sff + np.sum(np.abs(self.e[:self.frame_size, chan])**2) ``` 用**前景滤波器** `foreground` 计算估计回声: - 对每个麦克风通道,累加所有扬声器通道和所有块的贡献。 - 逆变换得到时域回声(长度为 N)。 - 截取后半部分(`frame_size` 个点)作为有效回声,与 `input` 相减得到误差 `e`(实际误差仅前 `frame_size` 个点)。 - 计算误差能量 `Sff`。 **频域滤波公式**: $$\mathbf{Y}^{(fg)} = \sum_{p=0}^{M-1} \mathbf{X}_p \odot \mathbf{W}_p^{(fg)}$$ **时域输出(重叠保存)**: $$\mathbf{y}^{(fg)} = \text{IFFT}\{\mathbf{Y}^{(fg)}\}[\text{frame\_size}:N]$$ ```python if self.adapted: self.prop = self.mdf_adjust_prop() ``` 如果已进入自适应状态,根据当前滤波器系数能量重新计算块更新比例(使能量大的块获得更大步长)。 ### 7.2.5 权值更新(背景滤波器) ```python if self.saturated == 0: for chan in range(C): for speak in range(K): for j in list(range(M)[::-1]): # 从最后一个块开始 # 计算梯度更新量 self.PHI = np.concatenate([self.power_1, self.power_1[-2:0:-1]], axis=0) * self.prop[j] * np.conj(self.X[:, speak, j+1])[:,None] * self.E[:, chan][:,None] # 更新权值 self.W[:, speak, j, chan] = self.W[:, speak, j, chan] + self.PHI[:, 0] else: self.saturated -= 1 ``` 如果麦克风未饱和,则更新**背景滤波器** `W`: - 遍历所有通道、扬声器、块(反向遍历,避免使用已更新的块?此处是反向但实际影响不大)。 - 计算更新量 `PHI`: - `self.power_1` 是归一化步长因子(与参考信号功率相关)。 - `self.prop[j]` 是块比例。 - `np.conj(self.X[:, speak, j+1])` 是参考信号频谱的共轭(因为 `X` 中索引 `j+1` 存储的是历史块,`j` 从 0 开始,所以用 `j+1` 对应正确的延迟)。 - `self.E[:, chan]` 是误差频谱。 - 更新 `W`。 - 若饱和,跳过更新并递减计数。 **更新公式**: $$\mathbf{W}_p \leftarrow \mathbf{W}_p + \text{prop}_p \cdot \frac{1}{\mathbf{P}} \odot \mathbf{X}_p^* \odot \mathbf{E}$$ 其中: - $\mathbf{P}$:功率估计 - $\text{prop}_p$:比例因子 - $\mathbf{X}_p^*$:频域输入的复共轭 - $\mathbf{E}$:频域误差 ### 7.2.6 AUMDF时域约束 ```python for chan in range(C): for speak in range(K): for j in range(M): # AUMDF: 交替约束 if j == 0 or (2 + self.cancel_count) % (M-1) == j: self.wtmp = np.fft.ifft(self.W[:, speak, j, chan]).real self.wtmp[self.frame_size:N] = 0 # 强制后N/2个点为零 self.W[:, speak, j, chan] = np.fft.fft(self.wtmp) ``` **AUMDF约束规则**: | 块索引 | 约束条件 | |--------|----------| | $j = 0$ | 总是约束 | | $j \geq 1$ | 当 $(2 + k) \mod (M-1) = j$ 时约束 | **目的**:减少计算量,同时保持稳定性。 对部分块(每帧仅更新一个块,交替更新)施加时域约束: - 将频域系数逆变换回时域,将后半部分(`frame_size` 点)置零(强制滤波器长度不超过 `frame_size`,对应块长度)。 - 再变换回频域。这种交替更新(AUMDF)减少了计算量并改善了收敛性。 ### 7.2.7 背景滤波器输出 ```python self.Yf = np.zeros((self.frame_size+1, 1),) self.Rf = np.zeros((self.frame_size+1, 1),) self.Xf = np.zeros((self.frame_size+1, 1),) Dbf = 0 # 前景/背景差异 See = 0 # 背景滤波器误差能量 for chan in range(C): self.Y[:, chan] = 0 for speak in range(K): for j in range(M): self.Y[:, chan] = self.Y[:, chan] + self.X[:, speak, j] * self.W[:, speak, j, chan] self.y[:, chan] = np.fft.ifft(self.Y[:, chan]).real * N # 计算前景/背景差异 self.e[:self.frame_size, chan] = self.e[self.frame_size:N, chan] - self.y[self.frame_size:N, chan] Dbf = Dbf + 10 + np.sum(np.abs(self.e[:self.frame_size, chan])**2) # 计算背景滤波器误差 self.e[:self.frame_size, chan] = self.input[:, chan] - self.y[self.frame_size:N, chan] See = See + np.sum(np.abs(self.e[:self.frame_size, chan])**2) ``` 用背景滤波器 `W` 重新计算回声(前景滤波器在后续可能被替换)。 更新误差:将上一帧保存的误差(`e[self.frame_size:N]`)减去当前背景滤波器回声的后半部分,得到新的误差(但随后又被覆盖?这里代码似乎有误)。实际上后面又重新计算了误差(用 `input - y`),所以 `Dbf` 计算的是旧误差能量。 ### 7.2.8 双滤波器切换逻辑 ```python # 统计检验更新 VAR1_UPDATE = .5 VAR2_UPDATE = .25 VAR_BACKTRACK = 4 MIN_LEAK = .005 #双讲检测参数。更新用于双讲检测的均值和方差(基于误差能量差异和背景噪声)。 self.Davg1 = .6*self.Davg1 + .4*(Sff - See) self.Dvar1 = .36*self.Dvar1 + .16*Sff*Dbf self.Davg2 = .85*self.Davg2 + .15*(Sff - See) self.Dvar2 = .7225*self.Dvar2 + .0225*Sff*Dbf update_foreground = 0 # 判断是否更新前景滤波器,判断是否需要将背景滤波器复制到前景(即认为当前背景滤波器优于前景)。条件基于能量差异和统计量。 if (Sff - See) * abs(Sff - See) > (Sff * Dbf):
update_foreground = 1
elif (self.Davg1 * abs(self.Davg1) > (VAR1_UPDATE * self.Dvar1)):
update_foreground = 1
elif (self.Davg2 * abs(self.Davg2) > (VAR2_UPDATE * self.Dvar2)):
update_foreground = 1
“`

**切换逻辑分析**:

**条件1**:瞬时差异显著
$$(S_{ff} – S_{ee})^2 > S_{ff} \cdot D_{bf}$$

**条件2**:短时间尺度统计显著
$$D_{avg1}^2 > 0.5 \cdot D_{var1}$$

**条件3**:长时间尺度统计显著
$$D_{avg2}^2 > 0.25 \cdot D_{var2}$$

“`python
if update_foreground:
# 背景滤波器更好,更新前景
self.foreground = self.W
# 平滑过渡
for chan in range(C):
self.e[self.frame_size:N, chan] = (self.window[self.frame_size:N][:,0] * self.e[self.frame_size:N, chan]) + (
self.window[:self.frame_size][:,0] * self.y[self.frame_size:N, chan])
else:
reset_background = 0
if (-(Sff-See)*np.abs(Sff-See) > VAR_BACKTRACK*(Sff*Dbf)):
reset_background = 1
if ((-self.Davg1 * np.abs(self.Davg1)) > (VAR_BACKTRACK*self.Dvar1)):
reset_background = 1
if ((-self.Davg2 * np.abs(self.Davg2)) > (VAR_BACKTRACK*self.Dvar2)):
reset_background = 1

if reset_background:
self.W = self.foreground
for chan in range(C):
self.y[self.frame_size:N, chan] = self.e[self.frame_size:N, chan]
self.e[:self.frame_size, chan] = self.input[:, chan] – self.y[self.frame_size:N, chan]
See = Sff
self.Davg1 = 0
self.Davg2 = 0
self.Dvar1 = 0
self.Dvar2 = 0
“`

如果更新前景,则重置统计量,将背景滤波器赋值给前景,并合成新的误差信号(用于下一步输出?这段代码实际上是更新 `e` 的尾部,用于下一帧的误差)。

如果不更新前景,则检查是否需要将背景重置为前景(即前景优于背景)。若满足条件(负向能量差异大),则重置背景为前景,并更新误差和统计量。

### 7.2.9 输出处理与去加重

“`python
Sey = 0
Syy = 0
Sdd = 0

for chan in range(C):
for i in range(self.frame_size):
# 计算输出(去加重)
tmp_out = self.input[i, chan] – self.e[i + self.frame_size, chan]
tmp_out = tmp_out + self.preemph * self.memE[chan]

# 检测饱和
if mic[i, chan] <= -32000 or mic[i, chan] >= 32000:
if self.saturated == 0:
self.saturated = 1

out[i, chan] = tmp_out[0]
self.memE[chan] = tmp_out
“`

计算最终输出:
– 误差信号 = 预加重后的麦克风信号 – 背景滤波器估计的回声(`e[i+self.frame_size]` 是之前保存的误差尾部)。
– 对误差进行去加重(`+ 0.9 * memE`)恢复原始动态。
– 检测麦克风是否饱和(幅度超过 ±32000)。

“`python
self.e[self.frame_size:N, chan] = self.e[:self.frame_size, chan]
self.e[:self.frame_size, chan] = 0
“`
将当前误差保存到缓冲区尾部(为下一帧做准备),清空前部。

“`python
Sey = Sey + np.sum(self.e[self.frame_size:N, chan] * self.y[self.frame_size:N, chan])
Syy = Syy + np.sum(self.y[self.frame_size:N, chan]**2)
Sdd = Sdd + np.sum(self.input**2)
“`
计算互相关和自相关(用于后续学习率)。

“`python
self.E = np.fft.fft(self.e,axis=0) / N
self.y[:self.frame_size, chan] = 0
self.Y = np.fft.fft(self.y,axis=0) / N
self.Rf = np.abs(self.E[:self.frame_size+1, chan])**2
self.Yf = np.abs(self.Y[:self.frame_size+1, chan])**2
“`
频域变换,获取功率谱用于平滑。

“`python
if not (Syy >= 0 and Sxx >= 0 and See >= 0):
self.screwed_up = self.screwed_up + 50
out = np.zeros_like(out)
elif Sff > Sdd + N * 10000:
self.screwed_up = self.screwed_up + 1
else:
self.screwed_up = 0
if self.screwed_up >= 50:
print(“Screwed up, full reset”)
self.__init__(self.sampling_rate, self.frame_size, self.filter_length)
“`
检测异常状态(负能量或误差能量异常大),若连续异常则完全重置滤波器(重新初始化)。

### 7.2.10 变学习率计算

“`python
See = max(See, N * 100)
for speak in range(K):
Sxx = Sxx + np.sum(self.x[self.frame_size:, speak]**2)
self.Xf = np.abs(self.X[:self.frame_size+1, speak, 0])**2
self.power = ss_1*self.power + 1 + ss*self.Xf[:,None]
“`
确保 `See` 有下限,计算参考信号功率平滑值 `power`(用于归一化步长)。

“`python
# 计算相关性
Eh_cur = self.Rf – self.Eh
Yh_cur = self.Yf – self.Yh
Pey_cur = Pey_cur + np.sum(Eh_cur * Yh_cur)
Pyy_cur = Pyy_cur + np.sum(Yh_cur**2)

# 更新平滑估计
self.Eh = (1 – self.spec_average) * self.Eh + self.spec_average * self.Rf
self.Yh = (1 – self.spec_average) * self.Yh + self.spec_average * self.Yf

# 计算泄漏估计
Pyy = np.sqrt(Pyy_cur)
Pey = Pey_cur / Pyy

# 自适应平滑因子
alpha = tmp32 / See
alpha_1 = 1 – alpha
self.Pey = alpha_1 * self.Pey + alpha * Pey
self.Pyy = alpha_1 * self.Pyy + alpha * Pyy

# 泄漏估计
self.leak_estimate = self.Pey / self.Pyy
“`

更新平滑频谱,计算当前帧的互相关和自相关,用于后续泄漏估计。
计算泄漏估计因子 `leak_estimate = Pey/Pyy`,通过平滑更新。

**泄漏估计原理**:

$$\rho = \frac{P_{ey}}{P_{yy}} = \frac{E[E \cdot Y^*]}{E[|Y|^2]}$$

表示残余回声与滤波器输出的相关性。

### 7.2.11 学习率计算

“`python
if self.Pyy < 1: self.Pyy = 1 if self.Pey < MIN_LEAK * self.Pyy: self.Pey = MIN_LEAK * self.Pyy if self.Pey > self.Pyy:
self.Pey = self.Pyy
self.leak_estimate = self.Pey/self.Pyy
“`
限制泄漏估计范围。

“`python
RER = (.0001*Sxx + 3.*self.leak_estimate*Syy) / See
if RER < Sey*Sey/(1+See*Syy): RER = Sey*Sey/(1+See*Syy) if RER > .5:
RER = .5
“`
计算残余回声比(RER),用于后续可能的学习率调整(但后续代码被注释了,实际使用另一种方式)。

“`python

# 学习率设置
adapt_rate = 0
if Sxx > N * 1000:
tmp32 = 0.25 * Sxx
if tmp32 > .25 * See:
tmp32 = .25 * See
adapt_rate = tmp32 / See

self.power_1 = adapt_rate / (self.power + 10)
“`

计算自适应步长因子 `adapt_rate`,并归一化到 `power_1`(用于更新 `W` 的归一化步长)。

**RER (Residual-to-Error Ratio)**:
$$RER = \frac{0.0001 \cdot S_{xx} + 3 \cdot \rho \cdot S_{yy}}{S_{ee}}$$

“`python
self.sum_adapt = self.sum_adapt+adapt_rate
self.last_y[:self.frame_size] = self.last_y[self.frame_size:N]
if self.adapted:
self.last_y[self.frame_size:N] = mic-out
“`
更新自适应总和,记录上一帧的回声(用于后续?)。

“`python
return out
“`
返回当前帧的输出误差。

## 7.3 比例因子调整函数

根据当前滤波器系数的能量分布更新块比例 `prop`,使能量大的块获得更大的步长,以提高收敛速度。

“`python
def mdf_adjust_prop(self):
N = self.window_size
M = self.M
C = self.C
K = self.K

prop = np.zeros((M, 1))

for i in range(M):
tmp = 1
for chan in range(C):
for speak in range(K):
tmp = tmp + np.sum(np.abs(self.W[:N//2+1, speak, i, chan])**2)
prop[i] = np.sqrt(tmp)

max_sum = np.maximum(prop, 1)
prop = prop + .1 * max_sum
prop_sum = 1 + np.sum(prop)
prop = 0.99 * prop / prop_sum

return prop
“`

**公式**:
$$\text{prop}_i = \frac{0.99 \cdot (\sqrt{E_i} + 0.1 \cdot \max(1, \sqrt{E_{max}}))}{1 + \sum_j (\sqrt{E_j} + 0.1 \cdot \max(1, \sqrt{E_{max}}))}$$

其中 $E_i = \|\mathbf{W}_i\|^2$ 为第 $i$ 个块的能量。

## 7.4 使用示例

“`python
if __name__ == “__main__”:
import soundfile as sf

# 读取音频文件
mic, sr = sf.read(“./glass_wav/wav1_mic.wav”) # 麦克风信号
ref, sr = sf.read(“./glass_wav/wav1_ref.wav”) # 远端参考信号

min_len = min(len(mic), len(ref))
mic = mic[:min_len]
ref = ref[:min_len]

# 创建MDF处理器
# sr=16000, frame_size=256 (16ms), filter_length=3072 (192ms)
processor = MDF(sr, 256, 3072)

# 执行回声消除
e, y = processor.main_loop(ref, mic)

# 保存结果
sf.write(‘./output_1/result.wav’, e, sr)
“`

**参数选择**:
– 采样率 16kHz
– 帧大小 256 样本 (16ms)
– 滤波器长度 3072 样本 (192ms)
– 块数 M = 13
– FFT长度 N = 512

## 7.5 关键算法特性总结

代码特点
| 特点 | 实现方式 |
|——|———-|
| **定点兼容** | float_to_short转换 |
| **多通道支持** | C个麦克风,K个扬声器 |
| **预处理** | DC陷波 + 预加重 |
| **平滑过渡** | Hann窗加权 |
| **异常处理** | 饱和检测、自动重置 |

### 7.5.1 双滤波器机制

“`
┌─────────────────────────────────────────────────────────────┐
│ 双滤波器结构 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 背景 │ 前景 │
│ ↓ ↓ │
│ 持续自适应 稳定输出 │
│ ↓ ↓ │
│ 可能受干扰 性能可靠 │
│ ↓ ↓ │
│ └────────── 性能比较 ──────────┘ │
│ ↓ │
│ 背景更好 → 更新前景 │
│ 前景更好 → 重置背景 │
│ │
└─────────────────────────────────────────────────────────────┘
“`

### 7.5.2 AUMDF约束策略

| 特性 | 说明 |
|——|——|
| **交替更新** | 每帧只约束部分块 |
| **减少计算** | 避免所有块同时约束 |
| **保持稳定** | 块0总是约束 |

### 7.5.3 变学习率机制

**无需显式双讲检测**,通过以下机制实现鲁棒性:

1. **泄漏估计**:$\rho = P_{ey}/P_{yy}$
2. **RER计算**:估计残余回声比例
3. **自适应调整**:根据信号特性调整学习率

# 8 FFT缩放

在 Speex(以及许多其他音频/信号处理代码)中,看到正向 FFT 除以 $N$,逆向 IFFT 乘以 $N$,**这不是代码写错了,而是一种刻意的“缩放约定”**。

这种写法的核心目的有两个:**1. 统一频域幅度的物理意义;2. 历史遗留的定点运算防溢出机制。**

其中的数学逻辑和工程原因:

## 8.1 数学上的完美抵消

首先,我们要知道 NumPy(以及大部分标准数学库)对 FFT 的定义:
* **FFT:** $X[k] = \sum_{n=0}^{N-1} x[n] e^{-j 2\pi kn/N}$ (不缩放)
* **IFFT:** $x[n] = \frac{1}{N} \sum_{k=0}^{N-1} X[k] e^{j 2\pi kn/N}$ (默认带有 $\frac{1}{N}$ 的缩放)

现在来看这段代码的执行流程:
1. **频域变换:** `X = np.fft.fft(x) / N`
2. **时域还原:** `y = np.fft.ifft(X) * N`

把 1 代入 2:
$$y = \text{IFFT}\left( \frac{\text{FFT}(x)}{N} \right) \times N$$
因为 NumPy 的 IFFT 内部自带一个 $\frac{1}{N}$,所以:
$$y = \left( \frac{\text{FFT}(x)}{N} \times \frac{1}{N} \right) \times N = \frac{\text{FFT}(x)}{N}$$
等等,这样好像没有还原?
**注意:** $\text{IFFT}(\text{FFT}(x)) = x$。所以:
$$y = \left( \frac{x}{N} \right) \times N = x$$

**结论:** 除以 $N$ 和乘以 $N$ 在数学上**完美抵消**了,信号可以无失真地在时域和频域之间来回转换。

## 8.2 为什么要这么做?(核心原因)

既然不乘除 $N$ 也能正常工作,为什么 Speex 要多此一举?

**原因 A:让频域幅度代表“平均值”而非“总和”(与帧长解耦)**
标准的 FFT 结果是**求和**。如果一段音频能量不变,但你把分析帧长 $N$ 从 160(10ms@16kHz)增加到 320(20ms@16kHz),标准 FFT 出来的频域幅度会直接翻倍(因为多加了 160 个点的值)。
* **除以 $N$ 之后**,FFT 的结果变成了**求平均**。此时频域的幅度不再受帧长 $N$ 的影响,直接反映了该频率在当前帧内的“平均能量”。
* 在 Speex 后续的处理中(比如计算噪声估计、计算感知加权、LPC 分析等),使用“平均幅度”可以让算法的阈值、权重等超参数与窗口大小无关,调参更加直观和鲁棒。

**原因 B:Speex 原本是为“定点 DSP”设计的(历史遗留)**
这是最硬核的工程原因。Speex 最早是用 C 语言写的,专门跑在计算能力极弱、没有浮点运算单元(FPU)的低端嵌入式芯片上,使用的是 **定点数(Q15 或 Q31 格式)**。
* 在定点运算中,数字不能超过最大值(比如 16位有符号整数的最大值是 32767)。
* 标准 FFT 是做加法,$N$ 个点相加,数值会被放大 $N$ 倍。在定点 DSP 中,做标准 FFT **极其容易溢出**。
* 为了防止溢出,定点 DSP 工程师通常会在 FFT 的每一级蝶形运算中右移(相当于除以 2),或者在 FFT 结束后直接除以 $N$。
* 你看到的这段 Python 代码,极有可能是**从 Speex 的 C 语言定点代码直接 1:1 翻译过来的**,保留了其底层防溢出的数学特征。

**原因 C:为什么 IFFT 要乘以 N?**
因为你在 FFT 阶段主动除以了 $N$(把信号缩小了 $N$ 倍),而 NumPy 的 `ifft` 函数内部又很蠢地帮你除以了 $N$(再缩小 $N$ 倍)。
如果我不乘以 $N$,还原出来的时域信号就会变成原来的 $\frac{1}{N^2}$,声音就听不见了。**乘以 $N$ 是为了把 NumPy IFFT 自带的那个 $\frac{1}{N}$ 给抵消掉,并把之前 FFT 人为除掉的 $N$ 给补回来。**

## 8.3 总结
这段代码的本质是**改变了 FFT/IFFT 的缩放重心**:
* 标准库做法:能量缩放全压在 IFFT 上(FFT不缩放,IFFT除以N)。
* Speex做法:能量缩放全压在 FFT 上(FFT除以N,IFFT乘N抵消标准库的缩放并还原)。

在 Python 这种浮点数精度极高的环境里,这么做显得有些多余甚至令人困惑,但这正是**移植底层 C 语言音频处理算法时常见的“代码化石”**。它不仅保证了频域特征的一致性,也最大程度还原了原版 C 代码的数学等价性。

来源:image processing(微信号/QQ号:1439279),转载请注明出处,谢谢!
上一篇: 没有了,已经是最新文章

  • 评论:(0)

已有 0 位网友发表了一针见血的评论,你还等什么?

◎欢迎参与讨论!

站内搜索

浙ICP备2022036695号-1

浙公网安备 33010902003475号