Android免提回声消除实战:Speex AEC的NDK编译与JNI接入
简介面向Android开发者的Speex回音消除so库完整源码包适用于需要在实时语音通话、语音聊天室等场景中消除回声的App项目。包内包含Speex编解码器与回音消除算法的C/C源码、Android JNI接口封装、Gradle/Android.mk构建脚本以及编译好的armeabi-v7a、arm64-v8a等20个so库文件方便直接集成到工程中。资源共2488个文件压缩包约19.93MB除源码和构建配置外还含有188个class、190个dex等编译中间产物可帮助开发者理解从源码到so库的完整编译过程。目前已有1312人学习下载。通过分析源码和构建配置读者可以掌握Speex回音消除原理学会用NDK编译原生库并能按需修改参数定制高性能、低延迟的音频处理方案。 做实时语音通话的时候最难受的往往不是网络卡顿而是对方一直在听自己的回声。之前我接手一个Android对讲项目免提场景下回声问题特别严重当时的第一个想法是调系统自带的声学回声消除但Android设备五花八门很多机型上的效果根本不可控。后来决定从应用层入手把Speex的AECAcoustic Echo Cancellation声学回声消除模块手动编译成so库接入音频链路这才彻底把问题摁住。这篇文章就把完整路径重新梳理一遍包括SpeexDSP源码结构、NDK交叉编译、JNI接口设计、MDF自适应滤波原理、frame_size和filter_length参数怎么配以及真机调试时踩过的坑给正在做Android语音通话、对讲、会议类App想自己掌控回声消除链路的开发者做个参考。1. 先讲清楚回声从哪来为什么要自己编so库1.1 一条语音的“回音路径”是怎么形成的想象一个最简单的通话场景对方说话声音通过扬声器放出来这部分声音除了进入人耳还会在房间墙面、桌面、身体表面发生反射最终有一部分被麦克风重新采集到。这个被采集到的“远端语音”会跟着你的近端语音一起通过网络传回对方那里对方听到的就是自己说话的回声。说白了回声消除要解决的是一个线性叠加问题麦克风信号里既有近端说话人声音也有远端扬声器声音经过房间传递后的回声AEC的工作就是用“远端参考信号”即将从扬声器播放的音频去估计回声分量再从麦克风采集信号中减掉。这个估计过程依赖一个随时间变化的自适应滤波器因为扬声器到麦克风之间的声学路径会随着设备摆放位置、人手遮挡、音量变化而改变。1.2 为什么不直接用Android系统的AcousticEchoCancelerAndroid从API 19开始提供AcousticEchoCanceler看起来开个开关就能用但实际工程里它有几个硬伤第一它是系统级实现不同厂商芯片的算法效果差异极大同一套代码在不同手机上结果像抽奖第二开发者拿不到中间参数无法针对自己App的场景做调优第三也是最重要的它只做回声消除不做降噪和自动增益实际通话场景里噪音、音量忽大忽小的问题依然存在。系统方案不好使那就只能自己做。把SpeexDSP编译成so库配合JNI调用一来代码完全可控参数能按场景调二来纯C实现没有重型依赖整合成本低还能和自研的音频采集播放链路无缝对接。1.3 Speex和WebRTC、厂商3A算法怎么选在做技术选型时我对比过三个主流方案这里直接说结论方案集成成本算法复杂度参数控制粒度实测效果SpeexDSP低中高中等偏上WebRTC AEC高高中强操作系统自带3A低不可控低看机型芯片厂商3A低不可控低看平台WebRTC的AEC3效果确实更强尤其在线性回声和双讲场景下表现更好但它的模块耦合度很高要抽出来单独用比较费劲芯片厂商3A在特定平台上效果很好但离开这个平台就没法复用。Speex在效果上不是最强但它轻量、稳定、完全开源文档和资料也足够多作为应用层的自研方案性价比最高。对多数VoIP、对讲、会议场景来说Speex的回声消除能力已经够用了。2. SpeexDSP源码解析与NDK交叉编译2.1 源码获取与目录结构Speex的回声消除部分不在主编解码器里而是在独立的speexdsp库中。从speex.org或者GitHub的xiph/speexdsp仓库拉取即可稳定版本用1.2.1就够了至今很多商业项目都在用这个版本。源码目录结构里有两个关键目录include/speex/存放对外头文件libspeexdsp/存放DSP模块实现。真正和AEC相关的文件是libspeexdsp/mdf.c # 自适应滤波核心回声消除的主算法 libspeexdsp/preprocess.c # 降噪、AGC、VAD等预处理 libspeexdsp/fftwrap.c # FFT封装层 libspeexdsp/kiss_fft.c # KISS FFT实现 libspeexdsp/kiss_fftr.c # 实数FFT实现 libspeexdsp/filterbank.c # 滤波器组 libspeexdsp/resample.c # 重采样 libspeexdsp/buffer.c # 内部数据缓冲 libspeexdsp/jitter.c # 抖动缓冲如果只是要AEC核心文件就是mdf.c加FFT相关部分但实际编译时建议把上面这些全部编进去因为preprocess内部会依赖filterbank、resample等模块缺一个就会报undefined reference。2.2 NDK环境准备编译so库用NDK是绕不开的我的建议是直接用Android Studio的SDK Manager安装NDK和CMake版本不用追新r21到r23b都行太老的版本对最新AGP插件支持不好太新的版本有时候反而会遇到隐式声明报错。装完之后在命令行里验证一下环境变量export ANDROID_NDK$HOME/Library/Android/sdk/ndk/23.2.8568313接下来用NDK自带的CMake工具链文件来编译这样省去自己写toolchain的麻烦。2.3 编写CMakeLists.txt在speexdsp源码根目录建一个CMakeLists.txt内容是这样cmake_minimum_required(VERSION 3.18) project(speexdsp) set(CMAKE_C_FLAGS ${CMAKE_C_FLAGS} -O3 -fvisibilityhidden -DHAVE_CONFIG_H) set(SPEEXDSP_SOURCES ./libspeexdsp/buffer.c ./libspeexdsp/fftwrap.c ./libspeexdsp/filterbank.c ./libspeexdsp/jitter.c ./libspeexdsp/kiss_fft.c ./libspeexdsp/kiss_fftr.c ./libspeexdsp/mdf.c ./libspeexdsp/preprocess.c ./libspeexdsp/resample.c ./libspeexdsp/smallft.c ./libspeexdsp/echo_diagnostic.c ) add_library(speexdsp SHARED ${SPEEXDSP_SOURCES}) target_include_directories(speexdsp PUBLIC ./include)注意这里加了一个-DHAVE_CONFIG_H这是因为部分源码文件里会去include config.h如果不定义这个宏编译可能会报“config.h no such file”。源码包里自带的libspeexdsp/config.h可以直接用CMake运行时它会通过include目录找到。2.4 编译两种ABI编译arm64-v8a和armeabi-v7a是最常见的组合用CMake交叉编译的完整命令如下mkdir -p build/arm64-v8a cd build/arm64-v8a cmake ../.. \ -DCMAKE_TOOLCHAIN_FILE$ANDROID_NDK/build/cmake/android.toolchain.cmake \ -DANDROID_ABIarm64-v8a \ -DANDROID_PLATFORMandroid-21 make -j8编完armeabi-v7a时把-DANDROID_ABIarmeabi-v7a替换一下再编一次就行。ANDROID_PLATFORMandroid-21表示最低支持Android 5.0现在新项目一般都可以这么设覆盖率足够高。编译完成后会生成libspeexdsp.so先验证一下导出的符号是否齐全nm -D libspeexdsp.so | grep speex_echo能看到speex_echo_state_init、speex_echo_cancellation、speex_echo_state_destroy这些符号就说明核心功能都在。Android端还可以再打一层JNI封装但我的习惯是先编出独立so验证通过后再把JNI代码直接编进同一个so只暴露一个最终的libspeex_aec.so这样上层加载方便代码也更干净。3. 核心原理MDF自适应滤波与关键参数配置3.1 MDF频域自适应滤波是怎么工作的Speex的AEC核心是基于MDFMultidelay Block Frequency Domain频域多延迟自适应滤波算法。传统时域NLMS算法每个采样点都要做一次滤波器系数更新计算量很大收敛也慢MDF的做法是先把时域信号分块每块做FFT变换到频域在频域计算滤波器输出和误差再逆变换回时域。这样一次能处理一个帧的数据计算效率高很多。后面那个“Multidelay”的意思也很有意思——它把整个回声路径分成多个延迟块每个块代表一段固定长度的回声路径。这就好比你要估计一间房间的混响时域上它不是一条单一反射路径而是很多条不同延迟路径的叠加MDF就是同时维护这几个延迟块的频域滤波器组合起来逼近实际声学环境。Speex内部默认用多个延迟块级联来模拟长回声路径因此可以用较小的FFT窗口覆盖较长的回声路径内存和CPU占用都不会太高。3.2 frame_size定多少为什么是320frame_size翻译过来是“帧长度”指每次送给AEC处理的采样点数。它和采样率强相关物理意义就是一个处理块对应的音频时长常规设20ms。计算公式是frame_size 采样率 × 0.02所以16kHz采样率下frame_size是3208kHz下是16032kHz下是640。为什么是20ms而不是1ms或100ms因为太短的话FFT频率分辨率不够低频回声估计不准确太长的话延迟太大实时通话没法接受20ms是回声消除领域的工程折中值。3.3 filter_length决定你能覆盖多长的回声路径第二个关键参数filter_length是自适应滤波器的“最大搜索长度”表示它能估计的最长回声路径的采样点数量。可以这样理解从扬声器发声到被麦克风拾取中间经过空气传播的物理路径是有长度的filter_length就是这个路径的最大采样数。不同场景下filter_length的配置差异很大使用场景采样率frame_sizefilter_length说明耳机/近距通话16kHz3201024耳机场景回声路径极短普通免提16kHz3202048覆盖几十毫秒的反射路径车载/强反射环境16kHz3204096车内反射复杂需要更长滤波器这里有个常见误区filter_length不是越大越好。滤波器越长自适应收敛越慢稳态噪声也越容易被放大。我实测下来手持对讲机场景用2048足够车载免提时用4096更稳妥。如果配置过小典型表现是回声“消一半留一半”或者对方说话声音稍微大一点就明显听到“尾音”。3.4 双讲检测双方同时说话时的隐形杀手双讲double talk是AEC最容易翻车的场景近端说话人和远端说话人同时开口此时麦克风信号里既包含近端语音又包含远端回声。自适应滤波器如果在双讲时还持续更新就会被近端语音严重干扰滤波器系数直接发散表现出来就是回声突然变大甚至变成啸叫。Speex内部有一个内置的双讲检测机制会根据误差信号和参考信号的统计特征来调整更新步长避免在双讲时大范围更新滤波器。但实话实说它的双讲检测不算聪明在某些极端场景下还是会翻车。我的经验是如果双讲时回声残留明显可以在应用层加一个简单的逻辑——通过比较近端采集音量和远端参考音量来判定近端说话时暂停滤波器的自适应更新只做回声相减不更新系数。这个思路实现成本低但对双讲体验的提升非常明显。3.5 降噪和AGC怎么和AEC串联SpeexDSP的preprocess模块提供了降噪spectral subtraction和AGC自动增益控制。AEC只负责去掉回声不管噪音和音量所以完整链路的顺序是先回声消除再把输出送进preprocess做降噪和AGC。配置代码如下SpeexPreprocessState *preprocess speex_preprocess_state_init(frame_size, sample_rate); int denoise 1; speex_preprocess_ctl(preprocess, SPEEX_PREPROCESS_SET_DENOISE, denoise); int agc 1; speex_preprocess_ctl(preprocess, SPEEX_PREPROCESS_SET_AGC, agc); int level 8000; speex_preprocess_ctl(preprocess, SPEEX_PREPROCESS_SET_AGC_LEVEL, level); int noise_suppress -20; speex_preprocess_ctl(preprocess, SPEEX_PREPROCESS_SET_NOISE_SUPPRESS, noise_suppress);降噪强度默认是-30dB这个参数在安静环境下效果还行但如果环境噪音不大-30dB会把语音的高频细节削掉不少听感发闷。我实测下来普通室内场景-20dB更自然户外环境再降到-30dB具体要结合采集信号的信噪比来调。4. JNI封装与音频数据链路的完整设计4.1 设计三个最核心的JNI接口JNI封装的思路很简单最核心的接口就三个创建、处理、销毁。创建时传入frame_size、filter_length、sample_rate处理时把近端麦克风数据和远端参考数据传进去返回处理后的输出销毁时释放资源。一个可用的JNI实现长这样#include jni.h #include speex/speex_echo.h #include speex/speex_preprocess.h static SpeexEchoState *g_echo_state NULL; static SpeexPreprocessState *g_preprocess_state NULL; JNIEXPORT jint JNICALL Java_com_audio_speex_SpeexAEC_nativeInit(JNIEnv *env, jobject thiz, jint frame_size, jint filter_length, jint sample_rate) { if (g_echo_state) { speex_echo_state_destroy(g_echo_state); g_echo_state NULL; } if (g_preprocess_state) { speex_preprocess_state_destroy(g_preprocess_state); g_preprocess_state NULL; } g_echo_state speex_echo_state_init(frame_size, filter_length); g_preprocess_state speex_preprocess_state_init(frame_size, sample_rate); int denoise 1; speex_preprocess_ctl(g_preprocess_state, SPEEX_PREPROCESS_SET_DENOISE, denoise); int agc 1; speex_preprocess_ctl(g_preprocess_state, SPEEX_PREPROCESS_SET_AGC, agc); int level 8000; speex_preprocess_ctl(g_preprocess_state, SPEEX_PREPROCESS_SET_AGC_LEVEL, level); return 0; } JNIEXPORT jint JNICALL Java_com_audio_speex_SpeexAEC_nativeProcess(JNIEnv *env, jobject thiz, jshortArray input, jshortArray echo, jshortArray output) { jshort *input_buf (*env)-GetShortArrayElements(env, input, NULL); jshort *echo_buf (*env)-GetShortArrayElements(env, echo, NULL); jshort *output_buf (*env)-GetShortArrayElements(env, output, NULL); speex_echo_cancellation(g_echo_state, input_buf, echo_buf, output_buf); speex_preprocess_run(g_preprocess_state, output_buf); (*env)-ReleaseShortArrayElements(env, input, input_buf, JNI_ABORT); (*env)-ReleaseShortArrayElements(env, echo, echo_buf, JNI_ABORT); (*env)-ReleaseShortArrayElements(env, output, output_buf, 0); return 0; } JNIEXPORT void JNICALL Java_com_audio_speex_SpeexAEC_nativeDestroy(JNIEnv *env, jobject thiz) { if (g_echo_state) { speex_echo_state_destroy(g_echo_state); g_echo_state NULL; } if (g_preprocess_state) { speex_preprocess_state_destroy(g_preprocess_state); g_preprocess_state NULL; } }这个示例用全局变量保存状态代码简洁但不够线程安全正式项目里更稳妥的做法是把SpeexEchoState*转成jlong传回Java层再由Java层持有这个句柄这样多个实例互不干扰。4.2 Java层数据流的两个关键点Java层最常见的坑是把参考数据搞反。speex_echo_cancellation的第二个参数rec是麦克风采集的近端数据第三个参数play是远端参考数据——也就是“本端从网络收到后准备播放的音频”。这两个buffer填反了AEC会把近端语音当回声消掉对方可能什么都听不到。AudioRecord和AudioTrack的采样参数必须和JNI初始化时保持一致建议统一为16kHz、单声道、PCM 16bit。Android默认很多设备输出的采样率是48kHz如果直接在48kHz下做处理frame_size就不是320而是960Speex虽然支持32kHz但16kHz是语音质量和计算量的最佳平衡点。如果有需要建议在音频采集端用AudioRecord设16kHz如果设备不支持再在native层用resample从48kHz降到16kHz。4.3 用队列保证播放和采集的同步实际工程里采集线程和播放线程是各自独立的不可能保证每次process调用时input和echo在时间上严格对齐。我在项目里用了一个简单可靠的方案Java层维护一个播放数据环形队列每次从队列头部取一帧作为echo和当前input一起送进AEC。只要队列长度稳定echo和input的时间差就能控制在帧级别。补充一点如果要做录音或者离线处理处理完的speex裸流可以用ogg封装成文件这样常规播放器直接能放在做录音回放调试时非常方便。5. 调试实录回声消不干净、声音发闷的处理方法5.1 回声消不干净或消完后又复发这个问题的排查优先级最高。第一步检查filter_length如果扬声器和麦克风距离较远或者免提模式下把filter_length从1024调到2048甚至4096很大概率能解决。第二步检查参考数据是否对齐可以做一个延迟扫描测试播放一段已知的扫频信号同时用麦克风录音分析采集信号里扬声器播出的能量尖峰出现的位置计算延迟样本数然后在送入AEC前对echo做固定延迟补偿。第三步是排查双讲双讲频繁时回声会反复“冒出来”如果确认是双讲问题就用前面提到的近端VAD或音量比较逻辑来冻结滤波器更新。5.2 声音发闷、被削掉一块如果只开了AEC不开降噪时声音正常开了降噪后立刻发闷那问题出在降噪参数上。把SPEEX_PREPROCESS_SET_NOISE_SUPPRESS从默认的-30dB调到-15dB到-20dB效果会明显改善。如果不开降噪也发闷那基本是滤波器长度过大导致滤波器输出包含了太多非回声部分把有用的近端语音也一起减掉了这种情况要降低filter_length。5.3 卡顿、爆音、延迟越来越大卡顿问题首先检查数据处理链路是否阻塞了采集回调。AudioRecord的读取回调里如果直接做JNI调用遇到CPU波动时容易丢帧。更合理的做法是采集线程只负责读数据放进阻塞队列由专门的音频处理线程消费和处理。另外也要检查播放队列的长度播放队列越拉越长会导致整体延迟逐渐增大一旦超过预设阈值就要掉帧或者重采样来拉回节奏。5.4 部分机型直接崩溃这类崩溃要从ABI不匹配入手。APK里如果同时装了arm64-v8a和armeabi-v7a的so而某台64位手机只加载了armeabi-v7a版本这时如果代码里用了64位指令集不兼容的东西就会崩。建议在build.gradle里明确abiFilters只保留实际要用的两个ABI。另一个常见崩溃原因是JNI里没有正确Release数组或者在某条代码路径上提前return了导致数组引用泄漏反复进入JNI后内存升高崩溃。总结一下常见问题和对应方案现场症状可能原因解决建议回声消不干净filter_length太小调到2048或4096做延迟扫描回声反复出现双讲时滤波器发散加近端VAD冻结更新声音发闷降噪强度过大NOISE_SUPPRESS调到-15~-20dB处理卡顿采集回调被阻塞独立音频处理线程崩溃ABI不匹配/JNI泄漏统一abiFilters检查Release调试时还有个很实用的手段在native层把处理前和处理后的PCM数据同时落盘导出后用Audacity或者Audition对比听。回声消除问题很多时候不是看代码能看出来的必须靠耳朵和波形图判断尤其是回声残留的时序和频谱特征一听就知道是滤波器没收敛还是参考数据没对齐。一路做下来我的体会是回声消除这类模块算法原理搞明白比代码本身更重要。Speex不是效果最强的AEC方案但它稳定、可裁剪、完全可控对一个应用层开发者来说手里有一套能自己改的so库源码比依赖某个封装好的SDK踏实得多。还有一个建议正式发布前一定要多找几台不同品牌、不同系统的手机做免提测试很多回声问题在模拟器上完全复现不出来真机一测就原形毕露。如果你正卡在音频链路某一环希望这篇能帮你少走几个弯路。本文还有配套的精品资源点击获取