27、单元测试:编写AEC模块的单元测试、模拟音频数据测试
单元测试这事儿,说实话,很多做音频的兄弟容易忽略。觉得「能跑就行」,结果一上真机,回声消不掉、双讲卡顿,debug 到怀疑人生。我自己的经验是——AEC 模块的单元测试,越早写越香。今天咱们就聊聊,怎么给 AEC 写靠谱的单元测试,以及怎么造模拟音频数据来验证。
为什么 AEC 需要单元测试?
你想想看,AEC 算法涉及自适应滤波、非线性处理、双讲检测……任何一个环节出问题,表现就是「回声没消干净」或者「近端声音被吞了」。这种问题在集成测试阶段才暴露,定位成本极高。
我遇到过最坑的一次:某个版本在实验室测试一切正常,到了用户家里,一打电话就啸叫。查了两天才发现,是某个边界条件下 NLMS 滤波器系数发散。如果当时有单元测试覆盖这个场景,根本不会流出去。
测试框架选型
Android 原生测试框架就够用。我个人习惯用 JUnit 4 + Mockito,配合 Robolectric 做 Android 依赖的模拟。如果你需要测试 native 层的 AEC 算法,那就得上 Google Test 或者 Catch2。
| 层级 | 推荐框架 | 适用场景 |
|---|---|---|
| Java/Kotlin 层 | JUnit 4 + Mockito | AEC 控制逻辑、状态机、参数校验 |
| Native 层 (C/C++) | Google Test | 自适应滤波、双讲检测、非线性处理 |
| 集成测试 | Android Instrumentation Test | 真实音频管道、延迟测量 |
模拟音频数据怎么造?
这是 AEC 测试里最容易被低估的一步。你不能拿真实录音去测,因为真实录音里混了太多不可控因素。我们需要的是可控的、可重复的、带标注的测试数据。
1. 纯参考信号测试
最简单的场景:远端有信号,近端静音。这时候 AEC 应该完全消除回声,输出接近静音。
// 生成 1kHz 正弦波作为远端参考信号
private short[] generateSineWave(int sampleRate, int durationMs, double frequency) {
int numSamples = sampleRate * durationMs / 1000;
short[] data = new short[numSamples];
for (int i = 0; i < numSamples; i++) {
double angle = 2.0 * Math.PI * frequency * i / sampleRate;
data[i] = (short) (Short.MAX_VALUE * 0.3 * Math.sin(angle));
}
return data;
}
2. 模拟回声路径
真实场景中,扬声器到麦克风有延迟和衰减。我们可以用简单的 FIR 滤波器来模拟。
// 模拟 10ms 延迟 + 0.5 衰减
private short[] applyEchoPath(short[] reference, int sampleRate) {
int delaySamples = sampleRate / 100; // 10ms
short[] echo = new short[reference.length + delaySamples];
for (int i = 0; i < reference.length; i++) {
echo[i + delaySamples] = (short) (reference[i] * 0.5);
}
return echo;
}
3. 双讲场景模拟
双讲(Double Talk)是 AEC 最容易翻车的地方。我们需要同时生成近端语音和远端回声的混合信号。
// 近端语音:一段 500Hz 方波
// 远端回声:经过衰减和延迟的 1kHz 正弦波
// 混合后作为麦克风输入
private short[] generateDoubleTalk(int sampleRate, int durationMs) {
short[] nearEnd = generateSquareWave(sampleRate, durationMs, 500);
short[] farEndRef = generateSineWave(sampleRate, durationMs, 1000);
short[] farEndEcho = applyEchoPath(farEndRef, sampleRate);
// 混合:近端 + 回声
short[] micInput = new short[Math.max(nearEnd.length, farEndEcho.length)];
for (int i = 0; i < micInput.length; i++) {
int sum = 0;
if (i < nearEnd.length) sum += nearEnd[i];
if (i < farEndEcho.length) sum += farEndEcho[i];
// 防止溢出
micInput[i] = (short) Math.max(Short.MIN_VALUE, Math.min(Short.MAX_VALUE, sum));
}
return micInput;
}
核心测试用例设计
下面这几个测试用例,是我认为 AEC 模块必须覆盖的。每个用例都对应一个真实的故障场景。
测试 1:纯远端播放,近端静音
- 输入:远端参考信号 = 1kHz 正弦波,近端麦克风 = 静音
- 预期:AEC 输出接近静音(残留回声 < -40dB)
- 为什么重要:这是 AEC 最基本的功能,如果这个都过不了,其他不用谈
@Test
public void testPureFarEndEchoCancellation() {
int sampleRate = 16000;
short[] farEndRef = generateSineWave(sampleRate, 1000, 1000);
short[] micInput = new short[farEndRef.length]; // 全零,模拟静音
AecProcessor aec = new AecProcessor(sampleRate);
short[] output = aec.process(farEndRef, micInput);
double residualDb = calculateDbLevel(output);
assertTrue("残留回声过高: " + residualDb + " dB", residualDb < -40.0);
}
测试 2:双讲场景下的回声抑制
- 输入:远端参考 + 近端语音混合信号
- 预期:近端语音保留,远端回声被抑制
- 注意:这里不能要求完全消除回声,因为双讲时 AEC 会收敛变慢
@Test
public void testDoubleTalkEchoSuppression() {
int sampleRate = 16000;
short[] farEndRef = generateSineWave(sampleRate, 2000, 1000);
short[] micInput = generateDoubleTalk(sampleRate, 2000);
AecProcessor aec = new AecProcessor(sampleRate);
short[] output = aec.process(farEndRef, micInput);
// 验证近端语音能量保留 > 80%
double nearEndEnergy = calculateEnergy(micInput, 500, 1000);
double outputEnergy = calculateEnergy(output, 500, 1000);
assertTrue("近端语音被过度衰减", outputEnergy > nearEndEnergy * 0.8);
}
测试 3:非线性失真场景
我曾经踩过一个坑:扬声器在小音量时 THD 很高,AEC 的线性滤波器根本消不掉。所以一定要加非线性测试。
@Test
public void testNonlinearDistortionHandling() {
// 模拟扬声器非线性:在参考信号上叠加 3 次谐波
short[] farEndRef = generateSineWave(16000, 1000, 1000);
short[] distortedRef = addHarmonics(farEndRef, 3); // 3次谐波
short[] micInput = applyEchoPath(distortedRef, 16000);
AecProcessor aec = new AecProcessor(16000);
short[] output = aec.process(farEndRef, micInput);
double residualDb = calculateDbLevel(output);
assertTrue("非线性失真导致回声残留", residualDb < -30.0);
}
测试覆盖率与持续集成
单元测试写好了,还得跑起来。我建议把 AEC 的单元测试集成到 CI 流水线中,每次提交代码自动跑。覆盖率目标可以参考:
- 核心算法(滤波、双讲检测):90%+
- 控制逻辑(状态机、参数校验):80%+
- 边界条件(静音、削波、溢出):100%
知识体系总览
下面这张图,把 AEC 单元测试的核心脉络梳理清楚了。你可以把它当作测试设计的 checklist。
写在最后
单元测试这东西,写的时候觉得麻烦,但 debug 的时候就知道它的好了。尤其是 AEC 这种对时序和信号质量极度敏感的模块,没有测试用例兜底,改代码就像走钢丝。
我个人建议:先把「纯远端」和「双讲」这两个核心场景的测试写好,然后逐步补充边界条件和非线性场景。测试数据一定要可复现,最好用固定的随机种子或者预先生成的 WAV 文件。这样不管谁跑、什么时候跑,结果都是一致的。
嗯,今天就聊到这儿。下次你写 AEC 代码的时候,记得先写测试——你会发现,代码质量真的会上一个台阶。