麦克风里实际有什么?
h(n) 不是常数:移动手机、开门、改变音量,都会让路径发生变化。因此 AEC 必须持续适应,而不能只在出厂时测一次。
这是一篇从声学模型出发、一路走进 libspeexdsp/mdf.c 的实现导读:不仅讲 Speex 做了什么,也讲它为什么这样做,以及每个工程选择解决了哪类真实问题。
远端声音从扬声器播放,经机壳、桌面、墙壁和空气传播后进入麦克风。AEC 已知扬声器参考信号 x(n),要在线估计房间冲激响应 h(n),再从麦克风中减去估计回声。
h(n) 不是常数:移动手机、开门、改变音量,都会让路径发生变化。因此 AEC 必须持续适应,而不能只在出厂时测一次。
理想情况下 ŵ ≈ h,于是回声被抵消,输出主要剩下近端语音和背景噪声。
房间反射可持续数百毫秒。时域逐抽头卷积和更新会消耗大量乘加。
近端讲话时,误差不再只代表回声模型错误;若把近端语音当梯度,滤波器会学偏。
低频能量强、高频能量弱,固定步长会让强频带过冲、弱频带收敛太慢。
设备移动或扬声器音量变化后,既要快速重新收敛,又不能把瞬时噪声发布到输出。
FFT 天然计算循环卷积;不加约束就会产生跨块绕回的伪影,破坏线性声学模型。
输入饱和、非线性失真、时钟漂移和参考未对齐都可能让线性自适应算法发散。
滤波器不是在记一段声音,而是在逼近“扬声器到麦克风”的传递函数。每一帧都用误差修正权重:远端参考与误差越相关,越说明当前声学模型还有可学习的偏差。
LMS 沿 x·e 的方向更新权重;NLMS 再除以输入能量,避免远端信号忽大忽小时步长也跟着失控。
仅远端讲话时,误差主要来自模型不准;双讲时,误差里大量是近端语音。算法必须判断哪些误差像“残余回声”,哪些更像“新出现的近端内容”。
Speex 使用 Multidelay Block Frequency Domain Adaptive Filter。设帧长为 B,内部 FFT 长度固定为 N = 2B,滤波器尾长 L 被拆成 M = ceil(L/B) 个块。
滤波器越长,卷积和梯度更新的乘加数量近似线性增长。
单次 FFT 长度保持为 2B;增加尾长主要增加分块数,适合在可控帧延迟下处理长回声路径。
FFT 乘法给出循环卷积,而真实声学路径是线性卷积。Speex 把权重 IFFT 回时域,清零不该存在的半段,再 FFT 回去,相当于把权重投影回合法的线性卷积支撑区间。
标准 MDF 可每帧约束所有块;Speex 的 AUMDF 变体每帧始终约束第 0 块,再轮转约束一个其他块。关键的前部块最及时,其他块摊到多帧完成。
speex_echo_cancellation() 一帧里到底做了什么主流程不是单一公式,而是一条带保护、统计和发布策略的流水线。背景滤波器负责持续学习,前景滤波器负责稳定输出;帧尾计算出的 power_1 和误差频谱会供下一帧开头更新权重。
清除直流漂移,压低语音低频主导,让频带学习更均衡;输出端再去加重。
维护 M+1 个远端频域帧,当前帧进入 X[0],历史依次后移。
前景权重生成实际候选输出,并得到残差能量 Sff。
power_1 × prop[j] × X*E 形成频域梯度;饱和时冻结。
第 0 块每帧处理,其他块轮转,抑制循环卷积绕回。
得到 See 和两滤波器输出差 Dbf,进入显著性判决。
背景显著更好则复制到前景;显著变坏则用前景覆盖背景。
统计 Rf/Yf/Xf、计算 RER 与逐频点 power_1,供下一帧使用。
Speex 观察误差谱变化 Eh 是否跟随估计回声谱变化 Yh,用递归线性回归得到泄漏估计:
随后把 η·|Ŷ(k)|² 与误差谱、远端功率谱结合,形成每个频点的更新权重 power_1(k)。
默认被用户听到。只有背景在统计上显著更好时才更新。
每帧按频域梯度更新,允许更积极地追踪新路径。
prop[j]初始化时前块学习快、后块学习慢,前后约一个数量级;基本收敛后,再按每块权重能量的平方根动态分配,并加下限避免弱块完全停学。
尚未适应时,Speex 使用受限的临时学习率尽快建立基本模型;满足累计适应量和泄漏条件后,切换到基于 η、RER、逐频点功率的稳态学习率。
第 1 帧:约束 W₀ 和 W₁。前部块每帧都保持合法,其余成本被分摊。
Speex AEC 的价值不只在 MDF。本质上,它把算力、收敛速度、双讲鲁棒性、输出稳定性和定点实现放在同一套权衡中。
| 现实问题 | Speex 的实现选择 | 直接收益 |
|---|---|---|
| 长回声尾导致时域计算过重 | MDF 分块 + 长度 2B 的短 FFT | 在 10–20 ms 帧处理下扩展到数百毫秒尾长,尾长主要通过块数 M 扩展。 |
| FFT 循环卷积绕回 | MDF 支撑约束 + AUMDF 交替调度 | 维持线性卷积模型,同时把全块约束的成本分摊到多帧。 |
| 语音频带能量差异大 | 逐频点平滑功率 power[k] 与归一化 | 强频带不过冲,弱频带仍能学习,提升收敛稳定性。 |
| 回声路径能量前重后轻 | 分块比例 prop[j] | 把更多更新能力给直达声和早反射,又不让混响尾完全停止跟踪。 |
| 双讲时误差被近端语音污染 | leak 回归 + RER + 连续可变学习率 | 避免硬双讲开关的误判和跳变;可疑时渐进减小更新,而不是非黑即白。 |
| 持续学习可能短暂学坏 | 前景 / 背景 TWO_PATH + 显著性晋升 / 回退 | 把“探索”与“发布”分开,背景异常不会立即进入用户听到的输出。 |
| 切换滤波器可能产生咔嗒声 | Hann 窗平滑交叉过渡 | 前景晋升时减少块边界和输出切换伪影。 |
| 削波和数值异常会产生错误梯度 | 饱和冻结、能量 sanity check、自动 reset | 避免异常帧持续污染权重;发散时能回到可恢复状态。 |
| 线性 AEC 后仍会有残余回声 | speex_echo_get_residual() 输出残余谱给 preprocess | 让后置抑制器按频带继续压低剩余回声,形成“线性消除 + 非线性后处理”链路。 |
最重要的输入不是“随便一段远端音频”,而是实际送往扬声器、并与麦克风帧时间对齐的参考。AEC 不能替应用层弥补无限的固定延迟、持续漂移或严重非线性。
int sample_rate = 16000;
int frame_size = sample_rate / 100; // 10 ms
int filter_length = sample_rate * 300 / 1000;
SpeexEchoState *echo =
speex_echo_state_init(frame_size, filter_length);
speex_echo_ctl(echo,
SPEEX_ECHO_SET_SAMPLING_RATE, &sample_rate);
SpeexPreprocessState *den =
speex_preprocess_state_init(frame_size, sample_rate);
speex_preprocess_ctl(den,
SPEEX_PREPROCESS_SET_ECHO_STATE, echo);
while (running) {
// mic: 麦克风帧;far: 实际送往扬声器的对齐参考帧
speex_echo_cancellation(echo, mic, far, out);
speex_preprocess_run(den, out);
}frame_sizeAPI 注释建议每帧约 10–20 ms。短帧延迟低,但调度和 FFT 次数更多。filter_lengthAPI 注释通常建议 100–500 ms。应覆盖固定声学延迟和有效混响尾,但不是越长越好。rec / play / outrec 是近端麦克风混合信号,play 是送扬声器的远端参考,out 是线性 AEC 输出。若真实延迟超出滤波器覆盖范围,算法看不到正确对应关系;先做延迟测量与缓存对齐。
削波、功放压缩和机壳共振会产生参考中不存在的谐波,线性滤波器无法精确建模。
两端采样时钟不同会让对齐缓慢滑动,需要时钟漂移补偿或重采样策略。
更长的 filter_length 增加块数、内存与每帧频域乘法;应按实际尾长留适量余量。
SpeexEchoState_所有缓冲、统计量、前景 / 背景权重和学习率状态。speex_echo_state_init_mc计算 N、M,分配内存,初始化窗、prop、预加重和 notch 参数。weighted_spectral_mul_conj计算带 power_1 与 prop[j] 的共轭频谱梯度。mdf_adjust_prop按各 MDF 块的权重能量动态分配适应比例。speex_echo_cancellation单帧主流程:调理、FFT 延迟线、更新、约束、双路径和 leak 估计。speex_echo_get_residual对估计回声加 Hann 窗、取功率谱,再按 leak 缩放供后处理器使用。它用 MDF 把长滤波器搬到频域,用 AUMDF 控制约束成本,用逐频点归一化和泄漏驱动的学习率对抗彩色语音与双讲,再用双路径把激进学习和稳定输出隔离。最终,残余回声谱还能交给 preprocess 继续处理。