
简介面向安卓平台实时语音开发者的 Speex 回音消除 so 库完整源码工程覆盖 VoIP 通话、网络对讲等场景解决音频采集中的回声抵消与原生库集成问题。压缩包共 2488 个文件、约 19.93MB包含 20 个 so 动态库、59 个 h 与 47 个 c 原生源码、59 个 java 与 188 个 class 的 JNI 桥接层另有 510 个 flat、183 个 xml、122 个 json 等文件覆盖构建产物、界面配置与资源索引并配合 gradle、mk 等构建脚本整体像一套可直接导入的安卓工程。从中可学习 Speex 编码器、解码器与自适应回音消除模块的内部实现分析 NDK 编译脚本、JNI 封装和多 ABI 预编译库包内还保留了中间产物与完整工程结构便于按需求调整编解码参数、重新编译或集成到自己的语音应用。已有 1320 人学习下载适合具备 C/C 和安卓原生开发基础、希望深入音频底层的开发者参考也可作为语音项目的二次开发基线。1. Android Speex 回音消除 so 库源码对讲类应用为什么绕不开它做语音对讲、VoIP 通话或会议 App 时免提外放那一下刺耳的“嗡——”回音是很多开发者被用户反复投诉过的场景。这份 Android Speex 回音消除 so 库源码解决的就是采集链路里最麻烦的声学回声问题它把 Speex 的 AECAcoustic Echo Cancellation算法封装成可直接接入 AudioRecord 采集线程的 so 库源码里带 NDK 编译脚本和完整 C 实现。适合设备端对讲、会议、语音助手这类需要先把回声消掉再把数据推给上行链路的项目。拿到手能直接用也能改参数适配自己的板子。2. Speex AEC 的原理与选型MDF 滤波器、回声路径与方案边界先把原理说清楚不然你拿到源码也不知道参数往哪个方向调。回声消除的本质是在麦克风信号里把“自己人”减掉而且要在用户已经听到声音之后、再被麦克风收进来之前完成这件事所以它必须实时跑在采集链路上。2.1 回声怎么产生从扬声器到麦克风的声学耦合路径免提通话时远端说话人的声音从扬声器放出来经过空气、机身结构、桌面反射等路径又被麦克风拾取。这个过程中声音被扬声器和空气路径都“染色”了形成一条从扬声器到麦克风的传递函数。对手机来说扬声器在底部、麦克风在顶部或底部都有耦合手握住机身、手机摆放角度变化都会让这条传递函数实时变化。这就是为什么单纯调小音量、换用指向性麦克风都压不住回声因为回声路径是动态的。Speex 的做法是维护一个自适应滤波器用远端参考信号 x(n) 去逼近真实回声 d(n)。这个滤波器在时域上是上千阶的 FIR直接逐样本更新计算量很大所以 Speex AEC 核心用的是 MDFMulti-delay Frequency Domain算法把参考信号分段做 FFT在频域里分块更新滤波器系数再用更新后的滤波器估计当前回声并从麦克风信号 y(n) 中减去。要注意的是近端和远端同时说话双讲场景时滤波器必须冻结更新否则会把近端语音当成回声一起消掉。Speex 内部自带双讲检测逻辑这一点集成到 Android 时不用自己操心。2.2 选型边界Speex AEC 与浏览器音视频引擎方案的取舍拿到源码包之后读者第一个问题往往是“现在不是有更新更好的回声消除方案吗为什么还选 Speex”。这里要把边界讲明白。浏览器音视频引擎里那套 AEC3 方案确实是目前开源里效果最顶级的它内部有更精细的频带分割和残余回声控制但代价是代码重量级依赖整个音频处理管线的多个模块想只抽出 AEC 部分搬到 Android NDK 里需要连带着搬进来很多上层逻辑。Speex 的 libspeexdsp 则是一个极轻量的 C 库对外就几个函数编译出来的 so 体积小内存占用也低非常适合设备端对讲、低功耗语音模组这类只需要“消回声”而不需要整套音视频引擎的场景。不过 Speex AEC 也有明显的天花板它本质是线性自适应滤波器只能消除线性回声路径。如果扬声器音量开得很大导致削波或者机身产生共振这些非线性成分它消不掉。后面避坑章节会详细讲这方面限制。真实项目里针对免提外放场景Speex AEC 配上一级残余回声抑制效果是能被用户接受的。2.3 源码包里的关键模块从 speex_echo.h 到 mdf.c这类源码包的结构大同小异拿到手先按下面的清单核对路径确认文件齐全再动手编译。路径作用jni/Android.mk、jni/Application.mkNDK 编译入口决定 ABI、优化级别和源文件列表speexdsp/include/speex/speex_echo.hAEC 对外接口声明 state_init、echo_cancellation、ctl、destroyspeexdsp/libspeexdsp/mdf.cMDF 自适应滤波器主体AEC 核心算法speexdsp/libspeexdsp/preprocess.c预处理模块负责残余回声抑制和噪声消除speexdsp/include/speex/speex_preprocess.h预处理模块接口头文件speexdsp/libspeexdsp/fftwrap.c、smallft.cFFT 封装MDF 的频域变换依赖其中 mdf.c 是整个 so 库的心脏speex_echo_state_init 分配的 SpeexEchoState 结构体里保存了滤波器系数、参考信号历史和双讲检测状态。fftwrap.c 是 FFT 的可选实现层编译时必须编进去但具体用哪种 FFT 后端由宏决定。源码包里如果有 echo_diagnose.c那是用来采集和打印回声路径冲激响应的诊断工具一般不需要编进正式产物做排查时可以单独编一份。3. 把 Speex 源码编成 so 库NDK 脚本、宏开关与产物验证很多开发者在集成第三方 so 库时习惯直接拿别人编好的 .so 文件往工程里一丢。但这份资源既然是“so 库源码”就一定要自己走一遍 NDK 编译流程。自己编译的好处是能控制 ABI、优化指令集和浮点/定点模式后面调效果的时候不会被黑匣子卡住。3.1 编译环境准备拿到源码包后的目录组织与 NDK 版本选择源码包解压后典型目录结构如下speex-aec-android/ ├── jni/ │ ├── Android.mk │ └── Application.mk ├── speexdsp/ │ ├── include/speex/ │ │ ├── speex_echo.h │ │ └── speex_preprocess.h │ └── libspeexdsp/ │ ├── mdf.c │ ├── preprocess.c │ ├── fftwrap.c │ ├── smallft.c │ └── ... └── libs/编译前先确认本机 NDK 环境。我一般会锁在 r21 到 r23 这一代的版本上原因有两个一是 speexdsp 是比较老的 C 代码新版 clang 对隐式声明的告警升级为错误后经常出现“编译到一半被-Werror 打断”的翻车现场二是 r23 之后的 NDK 对最低 API 版本有默认限制匹配老项目时反而要多配置。环境变量建议写成export ANDROID_NDK_HOME/opt/android-ndk-r23 export PATH$PATH:$ANDROID_NDK_HOME然后进入源码包根目录执行$ANDROID_NDK_HOME/ndk-build NDK_PROJECT_PATH. APP_BUILD_SCRIPT./jni/Android.mk NDK_APPLICATION_MK./jni/Application.mk这条命令里的 NDK_PROJECT_PATH 指向当前目录APP_BUILD_SCRIPT 指定我们用源码包里的 Android.mk而不是让 ndk-build 在子目录里乱找目标。如果源码包本身没有带可用的 jni 脚本按 3.2 的配置自己建一份即可。3.2 Android.mk 与 Application.mk 的配置要点浮点/定点与 NEON 开关Android.mk 是编译的核心。以下是一份可实际使用的配置源文件列表以你拿到的 speexdsp 目录为准LOCAL_PATH : $(call my-dir) include $(CLEAR_VARS) LOCAL_MODULE : libspeexdsp LOCAL_CFLAGS : -O3 -D_USE_NEON -DFLOATING_POINT -DEXPORT LOCAL_C_INCLUDES : \ $(LOCAL_PATH)/speexdsp/include \ $(LOCAL_PATH)/speexdsp/libspeexdsp LOCAL_SRC_FILES : \ speexdsp/libspeexdsp/mdf.c \ speexdsp/libspeexdsp/preprocess.c \ speexdsp/libspeexdsp/fftwrap.c \ speexdsp/libspeexdsp/smallft.c \ speexdsp/libspeexdsp/filterbank.c \ speexdsp/libspeexdsp/resample.c \ speexdsp/libspeexdsp/buffer.c LOCAL_LDLIBS : -llog include $(BUILD_SHARED_LIBRARY)这段脚本里最值得关注的是 LOCAL_CFLAGS 里的三个宏。-D_USE_NEON 让 Speex 在 ARMv7 和 ARMv8 上启用 NEON 向量优化这是体积换取性能的关键开关-DFLOATING_POINT 表示编译浮点版本适合 arm64-v8a-DEXPORT 是为了把 speex_echo.h 里的 EXPORT 宏定义为空避免老代码里的导出修饰符在新编译环境下产生告警。对应 Application.mk 建议这样写APP_ABI : arm64-v8a armeabi-v7a APP_PLATFORM : android-21 APP_OPTIM : release APP_STL : c_static有一点要特别注意armeabi-v7a 的 CPU 浮点单元普遍较弱如果仍然用 -DFLOATING_POINT 编译运行时回声消除会占用大量 CPU出现通话卡顿。针对 v7a把 CFLAGS 里的 FLOATING_POINT 换成 -DFIXED_POINT切到定点运算模式性能会明显改善。所以产物如果两个 ABI 都要出最好分两次编译分别配置浮点和定点开关。3.3 编译产物验证readelf 检查依赖nm 检查导出符号编译成功后libs 目录下会生成对应 ABI 的 libspeexdsp.so。不要急着集成先用工具验证产物结构ls -l libs/arm64-v8a/libspeexdsp.so readelf -d libs/arm64-v8a/libspeexdsp.so | grep -E NEEDED|SONAME nm -D libs/arm64-v8a/libspeexdsp.so | grep speex_echoreadelf 的输出里 NEEDED 应该只有 libc.so 和 liblog.so说明这个库没有多余的外部依赖放进任何 Android 工程都不会缺符号。nm -D 的输出里应该能看到 speex_echo_state_init、speex_echo_cancellation、speex_echo_state_destroy、speex_echo_ctl 这四个核心导出函数。如果后发现导出符号缺失先检查 LOCAL_SRC_FILES 是否漏了 mdf.c再检查 EXPORT 宏是否被意外定义成了 static。4. JNI 封装与采集链路接入三个函数让 AEC 跑起来so 库编译好只是第一步真实 App 里要让回声消除跑起来需要一层 JNI 桥接把 Java/Kotlin 层的 short[] PCM 数据送进 Speex再把消完回声的数据取回来。这层封装设计得好不好直接影响采集线程的稳定性和内存效率。4.1 Java 层接口设计用 long 句柄避免全局引用泄漏Java 层不要频繁创建 native 对象。推荐用 long 型句柄保存 SpeexEchoState 指针示例代码如下public class AecProcessor { static { System.loadLibrary(speexdsp); } private long handle; public synchronized native long initNative(int sampleRate, int frameSize); public synchronized native int processNative(long handle, short[] micInput, short[] refOutput, short[] aecOutput); public synchronized native void destroyNative(long handle); }processNative 的三个 short[] 分别是麦克风采集数据、扬声器参考数据、AEC 输出数据。short 是 16 位 PCM 采样点Java 层不要先转成 byte[] 再转回 short那样会多两遍拷贝采集线程高负载时 GC 压力会明显上升。handle 用 long 而不是 int是因为 64 位设备上指针本身是 64 位的用 int 存会截断导致 native 层访问非法地址。4.2 JNI 层实现初始化里的参数换算与内存释放细节在 native 层初始化函数负责计算 Speex 需要的帧长和滤波器长度并设置采样率JNIEXPORT jlong JNICALL Java_com_example_aec_AecProcessor_initNative(JNIEnv *env, jobject thiz, jint sample_rate, jint frame_size) { // Speex 的 frame_size 是每次处理的采样点数10ms 对应 sample_rate/100 SpeexEchoState *st speex_echo_state_init(frame_size, sample_rate * 100 / 1000); if (st NULL) { return 0; } int sampling_rate sample_rate; speex_echo_ctl(st, SPEEX_ECHO_SET_SAMPLING_RATE, sampling_rate); return (jlong) st; }这里 filter_length 传的是 sample_rate * 100 / 1000也就是 100ms 的回声路径长度。对于手机正常握持、扬声器音量适中的场景100ms 足够覆盖直达声和大部分反射声。frame_size 必须与后续每次 processNative 传入的数组长度严格一致这一点后面避坑章节会展开。处理函数的核心是拿到三个 short[] 的裸指针调用 speex_echo_cancellation然后释放JNIEXPORT jint JNICALL Java_com_example_aec_AecProcessor_processNative(JNIEnv *env, jobject thiz, jlong handle, jshortArray mic, jshortArray ref, jshortArray out) { SpeexEchoState *st (SpeexEchoState *) handle; jshort *micPtr env-GetShortArrayElements(mic, NULL); jshort *refPtr env-GetShortArrayElements(ref, NULL); jshort *outPtr env-GetShortArrayElements(out, NULL); speex_echo_cancellation(st, micPtr, refPtr, outPtr); env-ReleaseShortArrayElements(out, outPtr, 0); env-ReleaseShortArrayElements(ref, refPtr, JNI_ABORT); env-ReleaseShortArrayElements(mic, micPtr, JNI_ABORT); return 0; }Release 时的模式要注意out 数组需要把 native 计算结果写回 Java所以释放模式传 0mic 和 ref 两个输入数组在 JNI 里没有改动释放时传 JNI_ABORT避免无谓的复制回写。如果这段代码放在 .c 文件里编译记得把 env- 换成 (*env)-这是 C 与 C 的 JNI 接口语法差异。4.3 参数匹配规则采样率、帧长与参考信号的同步接入链路时最常问的是参数怎么设。下面这张表来自实际项目中的默认值可以作为起步配置参数推荐值说明采样率16000 或 48000必须与 AudioRecord/AudioTrack 保持一致frame_size采样率 / 10010ms每次 processNative 处理的采样点数不可中途变更filter_length采样率 * 0.1100ms覆盖正常扬声器回声路径远场对讲可加大到 200msABIarm64-v8a / armeabi-v7a7a 建议用定点编译8a 用浮点编译参考信号的获取位置是关键。参考信号必须取“最终送到扬声器的那个 PCM 数据”而不是播放文件的原始数据。如果 App 里有混音、音量调节、音效处理参考信号要从这些处理完成之后的位置截取。正确做法是在写入 AudioTrack 之前把将要播放的 short[] 分流一份给 processNative 的 ref 参数同时从 AudioRecord 读到的麦克风数据作为 mic 参数调用一次 processNative得到 aecOutput 再送去编码或 RTP 打包。5. 避坑指南Speex AEC 集成中的五个常见问题与排查思路Speex 本身是成熟的算法但集成到 Android 采集链路时十个翻车有九个都不是算法问题而是编译配置、数据对齐和参考信号取错了位置。我把实际踩过的坑按阶段分成编译期和运行期两类。5.1 编译期so 加载失败与老 C 代码的告警问题现象so 库放进工程后运行到 System.loadLibrary 直接崩logcat 报 dlopen failed: library not found。 原因绝大多数情况是 APP_ABI 只编了 arm64-v8a而测试机是 32 位应用或 32 位系统系统在 /lib/arm 目录下找不到 64 位 so。 解决APP_ABI 同时保留 arm64-v8a 和 armeabi-v7a 两个产物如果应用只支持 64 位也要在 build.gradle 里显式设置 ndk.abiFilters 并在所有设备上测试。还有一个容易被忽略的点如果项目里同时依赖了其他第三方 so 库这些库也必须包含相同 ABI 的版本否则 linker 会因为其中一个库缺失而整体加载失败。现象ndk-build 执行到一半clang 报“call to undeclared function speex_rand”之后退出。 原因老版本 speexdsp 源码里没有包含所有头文件依赖新版 NDK 默认开启了 -Werror把原本只是警告的隐式函数声明变成了错误。 解决在 LOCAL_CFLAGS 里追加 -Wno-implicit-function-declaration或者直接换成 r21r23 这一代的 NDK。从那以后我编译老 C 代码时会先加这一条省得被无意义的告警打断。5.2 运行期残余回声、帧长错位与非线性失真现象回声确实变小了但通话时对方还能听到自己的声音像是“隔着一层窗户”的残余回声。 原因speex_echo_cancellation 消掉的是线性回声收敛后的残余回声还在单靠 AEC 本身压不到静音级别。很多集成者只调了 echo cancellation没有启用 preprocess 模块做残余回声抑制。 解决把 speex_preprocess 链到 AEC 后处理具体接入方式在第 6 章会给出完整代码。这一步在免提场景里几乎是必须的。现象处理后的音频出现周期性“咕噜咕噜”声或者偶发爆音。 原因frame_size 在初始化时定的是 16016kHz 下 10ms但采集线程某个回调里填充的数据不足 160 个采样点Speex 把不满一帧的数据当成完整帧处理滤波器系数被错误更新。 解决严格要求每次调用 speex_echo_cancellation 都传入 frame_size 个采样点。采集线程里用环形缓冲先把 AudioRecord 的数据凑满一帧再交给 AEC 处理。宁可丢掉几个采样点也不能用半帧数据硬算。现象手机外放开到 80% 以上音量回声突然变得特别明显音量调低又恢复。 原因扬声器在大音量下削波产生谐波失真。Speex 的线性滤波器只能模拟正常回声路径对削波产生的非线性谐波无能为力。 解决在参考信号送入 AEC 之前做增益限制或者使用系统 AudioManager 把媒体音量限制在扬声器线性区。另外在车机、音箱类设备上如果功放本身就有非线性单纯靠 Speex 不够需要配合硬件 AEC 或更重的非线性处理方案。现象初始化时传入了 0 帧长或 sampleRate 传了 8000导致 Speex 内部 FFT 计算异常第一次 processNative 就崩溃。 原因speex_echo_state_init 对 frame_size 有最小限制8kHz 采样率配 10ms 帧长没问题但如果 frame_size 小于 64内部 FFT 表初始化就会失败。 解决初始化后立即判空返回的 SpeexEchoState 指针对外只暴露 16k/48k 两个采样率选项8k 场景要单独验证帧长和滤波器长度的组合。6. 进阶把残余回声抑制链补上再离线和真机验证效果只做 speex_echo_cancellation 的工程是不完整的。完整的回声消除链路应该是 AEC 线性滤波之后再接 preprocess 做残余回声抑制和噪声消除。初始化时把预处理状态和 AEC 状态关联起来SpeexPreprocessState *pre speex_preprocess_state_init(frame_size, sample_rate); int denoise 1; speex_preprocess_ctl(pre, SPEEX_PREPROCESS_SET_DENOISE, denoise); // 关键让 preprocess 复用 AEC 的回声路径估计 speex_preprocess_ctl(pre, SPEEX_PREPROCESS_SET_ECHO_STATE, echo_state);每一帧的处理顺序是固定的先跑 AEC 做线性对消再对消完的数据跑 preprocessspeex_echo_cancellation(echo_state, micFrame, refFrame, aecOut); speex_preprocess_run(pre, aecOut);在这里 aecOut 是就地处理的speex_preprocess_run 会直接修改传入的缓冲区。denoise 开关建议保持开启但不要同时打开 AGC因为 AGC 会放大残余噪声反而让回声听感更奇怪。验证效果时不要只用耳朵听。我习惯在调试页面里把 AEC 前后的 PCM 各落一份文件void dumpPcm(String tag, short[] data, int len) { try (FileOutputStream fos openFileOutput(aec_ tag .pcm, MODE_PRIVATE)) { ByteBuffer buf ByteBuffer.allocate(len * 2).order(ByteOrder.LITTLE_ENDIAN); for (int i 0; i len; i) buf.putShort(data[i]); fos.write(buf.array()); } catch (IOException ignored) { } }落盘后用 Audacity 打开两份文件播放场景设置成远端持续说话观察麦克风信号的频谱。回声能量集中在 300Hz3.4kHz理想的输出是这些频段的尖刺相比输入衰减 20dB 以上。如果看到频谱上仍有持续平稳的尖刺优先检查参考信号的对齐位置而不是加大 filter_length。以前我为了把回声压到更低一味把 filter_length 从 100ms 调到 300ms结果近端语音的低频被削得发闷。后来才意识到AEC 的效果天花板不在滤波器长度而在参考信号是否同步、扬声器是否工作在非线性区。从那以后我每次集成 AEC 都会先在 AudioTrack 写入前标记参考信号的时间戳再谈参数调优希望帮到你。本文还有配套的精品资源点击获取