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;
}
小技巧:模拟数据最好保存成 WAV 文件,方便用 Audacity 可视化验证。我一般会在测试用例里同时输出「输入信号」和「输出信号」的波形对比图。

核心测试用例设计

下面这几个测试用例,是我认为 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%
注意:覆盖率不是万能的。我见过覆盖率 95% 的项目,漏掉了一个「滤波器系数初始化顺序」的 bug,导致偶发性的回声泄漏。所以除了看覆盖率,更要看测试用例的设计质量。

知识体系总览

下面这张图,把 AEC 单元测试的核心脉络梳理清楚了。你可以把它当作测试设计的 checklist。

AEC 单元测试 模拟音频数据 • 纯正弦波(参考信号) • 延迟+衰减(回声路径) • 双讲混合信号 • 非线性失真(谐波) 核心测试场景 • 纯远端(回声消除) • 双讲(语音保留) • 非线性失真处理 • 边界条件(静音/削波) 验证指标 • 残留回声 < -40dB • 近端语音保留 > 80% • 收敛时间 < 50ms • 无溢出/削波 持续集成 (CI) 自动运行 每次提交代码 → 自动构建 → 运行全部测试 → 生成覆盖率报告

写在最后

单元测试这东西,写的时候觉得麻烦,但 debug 的时候就知道它的好了。尤其是 AEC 这种对时序和信号质量极度敏感的模块,没有测试用例兜底,改代码就像走钢丝。

我个人建议:先把「纯远端」和「双讲」这两个核心场景的测试写好,然后逐步补充边界条件和非线性场景。测试数据一定要可复现,最好用固定的随机种子或者预先生成的 WAV 文件。这样不管谁跑、什么时候跑,结果都是一致的。

嗯,今天就聊到这儿。下次你写 AEC 代码的时候,记得先写测试——你会发现,代码质量真的会上一个台阶。


公众号:蓝海资料掘金营,微信deep3321