10、参考信号获取:从AudioTrack获取播放数据、混音后的参考信号提取
回声消除这件事,说白了就是一场「自己跟自己打架」的战争。
你想想看,扬声器放出去的声音,被麦克风又收了回来。如果不把这个「自己说过的话」从麦克风信号里减掉,对方就会听到自己的回声。那问题来了——我们怎么知道扬声器到底放了什么?
嗯,这就是参考信号要做的事。
10.1 参考信号是什么?为什么它这么重要?
参考信号,就是扬声器正在播放的那一路音频数据。AEC(声学回声消除器)需要拿它和麦克风采集到的信号做对比,才能算出回声路径的估计值。
我在项目中遇到过好几次这样的情况:明明AEC算法写得没问题,回声就是消不掉。查来查去,最后发现是参考信号拿错了——要么延迟不对,要么数据不完整。你想想看,算法拿到的参考信号和实际播放的不一致,那它怎么可能算得准?
核心原则:参考信号必须和扬声器实际播放的音频数据保持严格同步。差一个采样点,效果都可能大打折扣。
10.2 从AudioTrack获取播放数据
Android系统里,AudioTrack是播放音频的主力军。但问题是,AudioTrack本身并不提供「回调接口」让你拿到它正在播放的数据。那怎么办?
我个人习惯的做法是:在数据写入AudioTrack之前,先「截胡」一份。
10.2.1 方案一:写前拷贝
这是最直接的方法。你在调用 AudioTrack.write() 之前,先把要写入的byte[]或short[]数组复制一份,存到参考信号缓冲区里。
// 伪代码示例
short[] audioData = ...; // 待播放的音频数据
short[] refData = new short[audioData.length];
System.arraycopy(audioData, 0, refData, 0, audioData.length);
// 将refData送入参考信号缓冲区
referenceBuffer.put(refData);
// 正常写入AudioTrack
audioTrack.write(audioData, 0, audioData.length);
这个方法的好处是简单、零延迟。坏处是——你得保证所有写入AudioTrack的数据都走了这条路。如果系统内部有其他模块也在往AudioTrack里写数据,你就漏掉了。
注意:我曾经在一个项目里踩过这个坑。上层应用通过MediaPlayer播放提示音,绕过了我们的AudioTrack封装层。结果参考信号里少了提示音的数据,回声消不干净。排查了两天才找到原因。
10.2.2 方案二:使用AudioRecord的回环模式
Android 10及以上版本支持 AudioRecord 的回环(Loopback)模式。你可以创建一个回环AudioRecord,它直接捕获扬声器输出的信号。
AudioRecord loopbackRecord = new AudioRecord.Builder()
.setAudioSource(MediaRecorder.AudioSource.REMOTE_SUBMIX)
.setAudioFormat(audioFormat)
.setBufferSizeInBytes(bufferSize)
.build();
loopbackRecord.startRecording();
这个方法的好处是:你拿到的就是扬声器实际播放的信号,包括系统音、通知音等。但缺点也很明显——延迟较大,而且需要系统权限(CAPTURE_AUDIO_OUTPUT)。
我个人建议:如果条件允许,优先用方案一。因为它延迟低、可控性强。方案二可以作为兜底方案,或者用于调试对比。
10.3 混音后的参考信号提取
现实场景中,扬声器播放的往往不是单一音频流。比如:
- 通话语音 + 媒体音乐
- 通话语音 + 系统提示音
- 多路音效叠加
这些信号在混音之后才送到扬声器。所以,参考信号必须是「混音后的最终结果」。
10.3.1 混音的基本原理
混音说白了就是把多路PCM数据叠加到一起。但直接相加会溢出,所以需要做归一化处理。
// 两路信号混音示例
short[] stream1 = ...;
short[] stream2 = ...;
short[] mixed = new short[stream1.length];
for (int i = 0; i < stream1.length; i++) {
int sum = (int) stream1[i] + (int) stream2[i];
// 防止溢出,做钳位处理
if (sum > Short.MAX_VALUE) sum = Short.MAX_VALUE;
else if (sum < Short.MIN_VALUE) sum = Short.MIN_VALUE;
mixed[i] = (short) sum;
}
嗯,这里要注意:混音的顺序和时机很重要。你必须保证在数据写入AudioTrack之前完成混音,并且把混音后的数据同时写入AudioTrack和参考信号缓冲区。
10.3.2 多路参考信号的合并策略
在实际项目中,我遇到过需要同时追踪多路音频源的情况。比如:
- 主通话音频流(来自网络)
- 本地录音回放(用于耳返)
- 系统音效(按键音、通知音)
每一路都需要单独捕获,然后在混音阶段合并成一路参考信号。
| 音频源 | 捕获方式 | 延迟特性 | 优先级 |
|---|---|---|---|
| 通话语音 | 写前拷贝 | 低延迟 | 高 |
| 本地录音回放 | 写前拷贝 | 低延迟 | 中 |
| 系统音效 | AudioRecord回环 | 较高延迟 | 低 |
我个人习惯的做法是:把低延迟的音频源通过写前拷贝捕获,高延迟的通过回环捕获。然后在混音阶段做时间对齐——用缓冲区延迟来补偿不同源的到达时间差。
小技巧:如果你发现参考信号和麦克风信号之间存在固定的延迟偏差,可以在参考信号路径上加入一个可配置的延迟缓冲区。我一般用环形缓冲区实现,延迟范围设为0-500ms,步进1ms。调试时通过扫参找到最优延迟值。
10.4 参考信号提取的完整流程
下面这张图展示了参考信号从捕获到送入AEC算法的完整链路:
10.5 工程实现中的几个关键点
光有流程还不够,落地的时候有几个坑你得提前知道:
10.5.1 缓冲区大小怎么定?
参考信号缓冲区的大小直接影响延迟和内存占用。我一般这样算:
- 采样率 16000Hz,16bit,单声道
- 每帧 10ms → 160个采样点
- 缓冲区至少保留 500ms 的数据 → 8000个采样点
为什么是500ms?因为有些设备的回声路径延迟可能达到300-400ms,留点余量。
10.5.2 线程安全问题
写前拷贝发生在音频生产线程,参考信号消费发生在AEC处理线程。这两个线程是并发的。我曾经因为没加锁,导致参考信号里出现了「撕裂」的数据——前半段是这一帧,后半段是下一帧。
解决方案:用无锁环形缓冲区(Lock-Free Ring Buffer),或者用 ReentrantLock 保护临界区。我个人偏向用环形缓冲区,性能更好。
10.5.3 采样率匹配
如果参考信号的采样率和AEC算法要求的采样率不一致,必须做重采样。比如AudioTrack输出是48000Hz,但AEC算法只支持16000Hz。这时候你需要一个重采样器。
Android自带的 AudioRecord 和 AudioTrack 不支持运行时重采样,所以得自己实现或者用第三方库。我一般用 libspeexdsp 的重采样器,质量不错。
一句话总结:参考信号获取的核心就三件事——截得准、混得对、送得及时。任何一个环节出问题,AEC的效果都会大打折扣。
好了,关于参考信号的获取和混音提取,我就讲这么多。下一节我们会聊到延迟估计——也就是怎么让参考信号和麦克风信号在时间轴上对齐。嗯,这部分也挺有意思的。
公众号:蓝海资料掘金营,微信deep3321