☰
Speex 回音消除 so 库源码解析:Android 免提通话如何用自适应滤波消回声
2026/10/19 9:21:20 网站建设 项目流程

简介:面向Android原生开发者的Speex回音消除.so库源码资源包,专为实时语音通信、VoIP及语音聊天应用设计。Speex是开源音频编解码器,内含自适应预测编码与回声消除算法,本资源解决在Android端通过NDK编译生成.so并集成JNI调用的关键问题。压缩包共2488个文件,大小19.93MB,包含C/C++源码、编译产物.so动态库、Java层JNI接口、class/dex字节码、Android构建配置(gradle、Android.mk)、XML资源及JSON配置等,多样文件类型可支撑从源码阅读到库构建再到应用集成的完整链路。已有1320人学习下载,适合具备C/C++基础、熟悉Android音频系统并有NDK开发需求的工程师。通过本资源可深入理解Speex编解码原理与回声消除实现细节,掌握不同ABI架构so库的编译流程,获取可直接移植到工程中的JNI封装与音频处理模块。

1. 一份能自己编译的 Speex 回音消除 so:它到底解决了什么问题

做语音通话或语音社交 App 时,最常收到的投诉之一就是"你能听到自己的回声"。手机外放漏音、免提会议、直播间连麦,这些场景里扬声器播出去的声音被麦克风重新采进来,再传回远端,于是对方就在自己耳机里听到延迟了几十毫秒的说话声。android Speex 回音消除 so 库源码把 Speex 的 echo canceller、preprocess 预处理状态机和 JNI 封装整个摊开了,用户拿到的不是编译好的黑盒 so,而是可以自己改帧长、滤波长度、AGC 增益,再通过 NDK 重新编出 libspeexdsp.so 的完整工程。适合做 VoIP 通话、在线教室、语音直播连麦,想在 Android 采集链路上自己控回声消除和噪声抑制效果的开发者。

2. 源码包结构拆解:从 jni 目录到 libspeexdsp.so 的产物链路

2.1 先看目录:这份源码把东西放在了哪

拿到包先别急着编,按目录结构捋一遍能少走很多弯路。我拆的这份 so 库源码,外层是一个标准的 Android 工程结构,真正的 C/C++ 部分全部集中在 jni 目录下,app 模块只保留了一个 Java 封装类和一个放 so 的 jniLibs 目录。整体是这样铺的:

speex_echo_android/ ├── jni/ │ ├── Android.mk │ ├── Application.mk │ ├── speex_jni.cpp │ ├── include/speex/ │ │ ├── speex_echo.h │ │ ├── speex_preprocess.h │ │ ├── speexdsp_config.h │ │ └── speexdsp_types.h │ └── src/ │ ├── mdf.c │ ├── preprocess.c │ ├── fftwrap.c │ ├── filterbank.c │ └── jitter.c ├── app/ │ └── src/main/ │ ├── java/com/example/audio/SpeexEcho.java │ └── jniLibs/ │ ├── armeabi-v7a/libspeexdsp.so │ └── arm64-v8a/libspeexdsp.so ├── CMakeLists.txt └── build.gradle

这里每个文件职责都很明确。speex_jni.cpp 是 Java 层和 C 层之间的唯一桥梁,负责把 Java 传进来的 short 数组转换成 Speex 需要的短整型 PCM 指针;include/speex 下是工程对外暴露的全部头文件,speex_echo.h 和 speex_preprocess.h 是最核心的两个 API;src 下才是真正的算法实现,mdf.c 是频域自适应滤波的回声消除主体,preprocess.c 是预处理状态机,fftwrap.c 做 FFT 封装。JNI 层只依赖两个头文件,内部实现被完全挡住,这也是整个工程能编成一个干净 so 的关键。

2.2 核心源码文件各自负责什么

一张表把从头到尾的文件职责列清楚,后面排错时才知道该去哪个文件翻:

文件职责对外接口
mdf.c频域自适应滤波,回声消除核心speex_echo_state_init / speex_echo_cancellation
preprocess.c预处理状态机,串起去噪、AGC 和 AECspeex_preprocess_state_init / speex_preprocess_run
fftwrap.cFFT 封装,屏蔽不同平台 FFT 实现差异内部使用
filterbank.c临界频带滤波器组,为噪声估计服务内部使用
speex_jni.cppJNI 封装,Java native 方法落到 C 层Java 层 SpeexEcho 类
speex_echo.h回声消除 API 与常量定义对外头文件
speex_preprocess.h预处理 API 与参数请求码对外头文件

从这张表能看出一个关键设计:算法层和 JNI 层是解耦的。mdf.c 里没有任何 Android 痕迹,它接收的是 frame_size、filter_length 这样的纯 C 参数,编译出来可以跑在 PC 上做离线验证。JNI 层只做数据搬运和参数透传,不参与算法决策。这个设计直接决定了后面二次开发能不能顺手——不想用 JNI 的话,完全可以用 ctypes 或 C FFI 把同一份源码挂到其它平台,回音消除逻辑一行都不用改。

2.3 两套构建脚本的取舍

源码包同时准备了 Android.mk 和 CMakeLists.txt 两套构建入口。用 Android.mk 是传统 ndk-build 路线,适合只想产出一个 so 的老工程;用 CMake 是 Android Studio 目前默认的方式,IDE 可以直接同步。两套文件的分工是:Android.mk 负责把 src 下的 C 文件全部编译进同一个模块,Application.mk 声明要产出的 ABI 和编译标准。

jni/Android.mk 的核心片段大致是这样:

LOCAL_PATH := $(call my-dir) include $(CLEAR_VARS) LOCAL_MODULE := speexdsp LOCAL_SRC_FILES := src/mdf.c src/preprocess.c src/fftwrap.c \ src/filterbank.c src/jitter.c speex_jni.cpp LOCAL_C_INCLUDES := $(LOCAL_PATH)/include LOCAL_CFLAGS := -O3 -D__ARM_ARCH_NEON__ -mfloat-abi=softfp -mfpu=neon LOCAL_LDLIBS := -llog include $(BUILD_SHARED_LIBRARY)

这里每一条都比较讲究。LOCAL_C_INCLUDES 指到 include 目录,保证 speex_jni.cpp 里的 include "speex/speex_echo.h" 能找得到;LOCAL_CFLAGS 里的 -D__ARM_ARCH_NEON__ 打开 NEON 向量优化路径,mdf.c 里的复数乘加运算在 arm64 下能吃到明显加速;-O3 对 mdf.c 这种重循环代码很重要,少开一层优化,实时性和 CPU 用量都会变差。Application.mk 里我一般只写 APP_ABI := armeabi-v7a arm64-v8a,x86 模拟器单测可以临时加一行,Release 包不建议带上 x86,体积没有任何意义。

3. 核心回音消除引擎:频域自适应滤波与预处理链路

3.1 状态初始化:frame_size 和 filter_length 怎么配对

打开 speex_echo.h 会看到整个引擎的入口,一个初始化函数和一个处理函数就构成了全部主流程:

SpeexEchoState *speex_echo_state_init(int frame_size, int filter_length); int speex_echo_cancellation(SpeexEchoState *st, const spx_int16_t *x, /* 近端:麦克风采集到的信号 */ const spx_int16_t *ref, /* 远端:同一时刻扬声器播放的参考 */ spx_int16_t *y); /* 输出:消掉回声之后的信号 */

frame_size 是一次处理的采样点数,filter_length 是自适应滤波器覆盖的时间长度,两者共同决定回声消除能覆盖多远。最常见的配对是 frame_size=160、filter_length=1600,在 16kHz 采样率下分别对应 10ms 的处理帧和 100ms 的回声尾音窗口。100ms 的含义是:远端信号进入扬声器,经过房间反射再被麦克风采到,这条链路的总延迟不超过 100ms 时,滤波器能把整段回声建出来并消掉。会议室、客厅这类中远场反射的尾音一般在 60ms 到 120ms 之间,所以我习惯把 filter_length 调到 2000,覆盖 125ms,代价是滤波器更重、收敛略慢,但消得更干净。

3.2 一次完整的回声消除调用链

处理循环里每次从音频设备读入一帧近端信号 x,同时从播放缓冲区拿到同一时刻的远端参考 ref,调一次 speex_echo_cancellation,输出就是消掉回声的干净信号。这段循环是实时音频线程的主干:

while (running) { /* 从 AudioRecord 读取麦克风数据,16kHz 16bit PCM */ read_mic(near_buf, frame_size); /* 从播放侧拿到同一时段的参考信号,必须是同一时刻 */ read_far_reference(far_buf, frame_size); /* 回声消除主处理 */ speex_echo_cancellation(echo_state, near_buf, far_buf, out_buf); /* 级联预处理:去噪 + AGC + 残留回声二次抑制 */ speex_preprocess_run(pre_state, out_buf); write_to_encoder(out_buf, frame_size); }

这里的 x 和 ref 必须时间对齐。ref 滞后 20ms,滤波器会把这段时延当成回声路径模型的一部分,结果消不干净还带一点金属声。speex_echo_cancellation 内部不是简单做一次减法,而是对 x 和 ref 分别分帧、加窗、FFT 到频域,在频域上估计扬声器到麦克风的路径传递函数,合成一份预测回声,再从近端信号里减掉。每处理完一帧,误差信号会反馈回去更新滤波器系数,这就是自适应滤波的核心循环。

3.3 preprocess:把 AEC、去噪、AGC 串成一条链路

只有 AEC 还不算一条可用的采集链路。speex_preprocess_state_init 初始化的状态机会按固定顺序处理一帧数据:先做噪声估计和频谱减,再做自动增益控制,最后把外部挂进来的 AEC 结果和自身残留抑制合并输出。外部 AEC 是通过 SPEEX_PREPROCESS_SET_ECHO_STATE 命令挂进去的,AEC 状态由 speex_echo_state_init 创建,preprocess 只是持有它的指针。组合初始化代码长这样:

SpeexEchoState *echo = speex_echo_state_init(160, 2000); SpeexPreprocessState *pre = speex_preprocess_state_init(160, 16000); int sr = 16000; speex_echo_ctl(echo, SPEEX_ECHO_SET_SAMPLING_RATE, &sr); int denoise = 1, agc = 1, agc_level = 2400; speex_preprocess_ctl(pre, SPEEX_PREPROCESS_SET_DENOISE, &denoise); speex_preprocess_ctl(pre, SPEEX_PREPROCESS_SET_AGC, &agc); speex_preprocess_ctl(pre, SPEEX_PREPROCESS_SET_AGC_LEVEL, &agc_level); speex_preprocess_ctl(pre, SPEEX_PREPROCESS_SET_ECHO_STATE, echo);

参数说明:denoise 打开的是噪声抑制,适合环境不固定的语音输入;AGC_LEVEL 设置目标音量强度,2400 是 Speex 的常用参考值,数值越大音量越大,超过 2800 会把底噪也抬起来;采样率必须通过 SPEEX_ECHO_SET_SAMPLING_RATE 显式告诉状态机,否则它内部按 8kHz 默认值计算归一化频率,16kHz 的数据喂进去所有频点都会错位,表现出来就是噪声没消掉、语音发闷。处理时先调 speex_echo_cancellation 得到初步结果,再调 speex_preprocess_run 对同一块缓冲区做原地后处理,顺序不能反,反了 preprocess 会先碰信号、破坏远端参考的统计特征,AEC 收敛会变得很慢。

4. JNI 封装与数据通路:Java 层怎么把 PCM 送进 C 层

4.1 Java 层接口设计

Java 侧只暴露一个类,三个 native 方法对应初始化、处理、销毁,这是最朴素也最不容易出错的封装方式:

public class SpeexEcho { static { System.loadLibrary("speexdsp"); } private long nativeHandle; public native int echoInit(int frameSize, int filterLength, int sampleRate); public native int echoProcess(short[] nearMic, short[] farRef, short[] out); public native int echoDestroy(); }

nativeHandle 这个 long 字段专门用来保存 C 层指针。之所以不用 int,是因为 32 位 int 在 64 位设备上存不下 64 位指针,高位截断后 JNI 层拿到的是一个非法地址,一旦调用 echoProcess 就会直接崩溃。Java 层拿到音频数据后,把近端和远端两个 short 数组传进来,第三个 short 数组接收输出。关于数组长度,我建议在 Java 层做一次长度校验,不等于 frame_size 就直接抛异常,免得 JNI 层还要处理长度不一致的边界情况。

4.2 JNI 实现细节

JNI 层要做的事只有三件:把 handle 还原成状态指针,把 short 数组临时变成 C 指针,调用完释放数组。核心实现:

#include <jni.h> #include "speex/speex_echo.h" struct EchoBundle { SpeexEchoState *echo; SpeexPreprocessState *pre; }; static jlong initEcho(JNIEnv *env, jobject thiz, jint frameSize, jint filterLength, jint sampleRate) { auto *bundle = new EchoBundle(); bundle->echo = speex_echo_state_init(frameSize, filterLength); bundle->pre = speex_preprocess_state_init(frameSize, sampleRate); speex_echo_ctl(bundle->echo, SPEEX_ECHO_SET_SAMPLING_RATE, &sampleRate); int denoise = 1, agc = 1; speex_preprocess_ctl(bundle->pre, SPEEX_PREPROCESS_SET_DENOISE, &denoise); speex_preprocess_ctl(bundle->pre, SPEEX_PREPROCESS_SET_AGC, &agc); return reinterpret_cast<jlong>(bundle); } static jint processEcho(JNIEnv *env, jobject thiz, jlong handle, jshortArray nearMic, jshortArray farRef, jshortArray out) { auto *bundle = reinterpret_cast<EchoBundle *>(handle); if (!bundle || !bundle->echo) return -1; jshort *near = env->GetShortArrayElements(nearMic, nullptr); jshort *far = env->GetShortArrayElements(farRef, nullptr); jshort *output = env->GetShortArrayElements(out, nullptr); speex_echo_cancellation(bundle->echo, near, far, output); speex_preprocess_run(bundle->pre, output); env->ReleaseShortArrayElements(nearMic, near, JNI_ABORT); env->ReleaseShortArrayElements(farRef, far, JNI_ABORT); env->ReleaseShortArrayElements(out, output, 0); return 0; }

逻辑说明:GetShortArrayElements 返回的是 short 数组的直接指针或拷贝,释放时用 JNI_ABORT 表示不需要把改动拷回 Java,因为 near 和 far 都是只读的;out 数组释放时用 0,表示要把处理结果写回 Java 层。这里的 EchoBundle 只是把两个状态指针包在一起,方便用一个 long 来回传。还有一个细节是初始化失败要检查返回值,speex_echo_state_init 返回 null 时不要硬往下走,否则 processEcho 里解引用空指针必然 SIGSEGV。

4.3 数据通路与线程模型

回声消除的数据流是:AudioRecord 采集线程读麦克风数据作为 near,同时从 AudioTrack 的播放回调或混音器里拿到同一时刻的远端参考 ref,两个数组进入 JNI,处理完的 out 送编码器。远端参考信号的来源是这套设计里最容易被忽略的点——如果只取自己 App 内播放的音频当 ref,系统音量、对方还有别人在放音乐都补不进来,回声就消不完整。常见做法是把会话端所有要播出的 PCM 混音后复制一份,在写入 AudioTrack 之前送进 JNI 当参考。

线程模型上,encoder 线程和 AudioTrack 播放线程是两条独立的线程,JNI 层不做加锁,因为 AEC 状态机内部有明显的时序依赖,加锁只会让音频回调出现 jitter。正确做法是保证每个回声消除状态只被一个音频线程调用,参考数据的搬运放在音频线程内部完成,宁可多一次内存拷贝也不要跨线程共享状态指针。我在调试时踩过这个坑,两个线程同时调 processEcho,最后滤波器系数直接发散,回声消完反而比不消还吵。

5. 编译与集成:在 Android 工程里跑通并验证回声消除

5.1 一条命令把 so 编出来

源码包是完整的 NDK 工程,编译入口有两个。如果你本机装了 NDK,用 ndk-build 最直接:

export ANDROID_NDK_HOME=/path/to/android-ndk cd speex_echo_android ${ANDROID_NDK_HOME}/ndk-build \ -C jni \ APP_BUILD_SCRIPT=Android.mk \ APP_ABI=armeabi-v7a,arm64-v8a

说明:-C jni 让 ndk-build 进入 jni 目录执行,Android.mk 里的 LOCAL_MODULE 决定了产物名字,默认会在 jni 同级的 libs 目录下生成 libspeexdsp.so。如果用 Android Studio 打开工程,CMakeLists.txt 会直接接管 native 构建,build.gradle 里配好 externalNativeBuild 后,直接 Run 就能看到 so 出现在 app/build/intermediates 下。两者选择标准很简单:只改 C 代码用 ndk-build,要跟着 Gradle 走 CI 用 CMake。

编译过程中最常犯的错是 ABI 不匹配。如果 Application.mk 里只有 armeabi-v7a,真机是 arm64 架构也能跑,因为 v7a 的 so 会被兼容加载,但性能上不去;反过来只编 arm64,老的 32 位真机直接报 so not found。我一般在 Application.mk 写 APP_ABI := armeabi-v7a arm64-v8a,发布前再把纯 32 位的去掉。

5.2 Android 工程里集成

编译好的 so 放到 app/src/main/jniLibs 对应的 ABI 目录下,Java 层直接调用:

public class YourAudioEngine { private final SpeexEcho echo = new SpeexEcho(); public void start(int sampleRate) { int frameSize = sampleRate / 100; // 10ms 一帧 int filterLength = sampleRate * 125 / 1000; // 125ms 尾音 echo.echoInit(frameSize, filterLength, sampleRate); } public short[] process(short[] mic, short[] ref) { short[] out = new short[mic.length]; echo.echoProcess(mic, ref, out); return out; } }

frameSize 的计算用 sampleRate / 100 而不是写死 160,是为了方便以后切采样率。集成时要注意加载时机,System.loadLibrary 放在静态块里,首次调用 echoInit 之前必须完成,否则 NoClassDefFoundError 会在真机上随机出现。回声消除状态的生命周期要和通话绑定,不要在每次音频回调里重复 echoInit,那个状态机内部有滤波器和历史 buffer,频繁重建会导致回声一帧有一帧没有。

5.3 验证:不靠耳朵,靠数据说话

回声消除效果好不好,第一轮验证不要用耳朵,用脚本量化。我在 PC 上拿一份参考音频,模拟出"麦克风信号 = 干净人声 + 延迟衰减后的远端参考",过一遍 so 里的处理逻辑,对比输出前后信号的能量:

import wave import numpy as np with wave.open('output.wav', 'rb') as f: out = np.frombuffer(f.readframes(f.getnframes()), dtype=np.int16) with wave.open('input.wav', 'rb') as f: raw = np.frombuffer(f.readframes(f.getnframes()), dtype=np.int16) # 回声段能量对比,单位 dB def db_level(x): return 20 * np.log10(np.sqrt(np.mean(x.astype(np.float64) ** 2)) + 1e-9) print(f"处理前: {db_level(raw):.2f} dB") print(f"处理后: {db_level(out):.2f} dB")

如果处理后比处理前低 15dB 以上,说明回声主体被压住了;如果只低了 3~5dB,先检查远端参考信号是不是真的和扬声器播放对齐了。真机验证时用一个固定音频从扬声器循环播放,麦克风正常采集,观察回声是否在通话对端消失。同时留意 CPU 占用,在 arm64 设备上,16kHz、frame_size=160 的一帧处理耗时应该在 0.5ms 以内,超过 2ms 就说明 NEON 优化没开或者滤波长度拉太高了。

6. 常见问题避坑:5 个让 AEC 失灵的典型错误

6.1 回声完全没消,反而出现类似啸叫的噪声

现象:接通后对端反馈回声没消失,音量还越来越大。原因:near 和 ref 信号接反了。speex_echo_cancellation 的参数顺序是近端麦克风信号在前、远端参考在后,如果封装层把 AudioTrack 的数据当成近端、麦克风数据当成参考,滤波器会试图构造一个完全错误的路径模型,输出里不仅保留回声,还会把近端信号也压掉一部分。解决:在 JNI 层加一个调试开关,把 near 和 ref 的短整型数组写成 wav 文件拉出来看,用 Audacity 对比波形,回声分量明显出现在麦克风数据里才说明接对了。

6.2 JNI 崩溃集中在 echoProcess 上

现象:logcat 里报 signal 11 (SIGSEGV),栈回溯指向 GetShortArrayElements 或 speex_echo_cancellation。原因:最常见的是 Java 层传进来的 short 数组长度比 frame_size 短。JNI 层默认数组长度可信,直接拿 frameSize 去读,越界后表现就是随机闪退。第二个常见原因是 nativeHandle 被 Java 层在 echoDestroy 之后还继续使用,指针已经释放。解决:在 processEcho 里先取数组长度和 frameSize 比较,不一致直接返回错误码;同时把 nativeHandle 封装在 Java 层的私有字段里,销毁时置 0,process 前检查 handle 是不是 0。

6.3 16kHz 以下采样率效果明显变差

现象:工程把采样率降到 8kHz 后,噪声抑制几乎失效,语音还发闷。原因:Speex 的回声消除和预处理是围绕 16kHz 语音设计的,8kHz 时频域分辨率减半,尾音覆盖时间不变但滤波点数少了一半,滤波器建模能力不够。解决:8kHz 数据不要直接送 AEC,先在 Java 层把 8k 升采样到 16k 处理完再降回 8k。我在工程里加了一个轻量重采样类,处理后再降采样,整体音质比让 Speex 直接跑 8k 好很多。

6.4 AGC 打开后音量忽大忽小

现象:对方听到音量不断波动,一句话前半句大后半句小。原因:AGC_LEVEL 设得过高,或者 preprocess 的增益更新速度和语音包络打架。Speex 的 AGC 是自动增益控制,它会持续估算语音能量并把增益往目标值拉,目标值设太高时,增益在小噪声和正常语音之间反复横跳。解决:先把 SPEXX_PREPROCESS_SET_AGC 关掉,只用 AEC 和去噪跑一轮,确认干净后再打开。AGC_LEVEL 保持在 2400 上下,并且不要在通话过程中动态改,状态机中间改参数会让增益出现断层。

6.5 编译时头文件找不到或链接报错

现象:ndk-build 报 speex/speex_echo.h: No such file or directory,或者链接阶段 undefined reference to speex_echo_state_init。原因:LOCAL_C_INCLUDES 没指到 include 目录,导致 C 编译器找不到头文件;再一个是 mdf.c 没被加进 LOCAL_SRC_FILES,函数只有声明没有实现。解决:确认 include 路径和源文件列表都以 jni 目录为基准,相对路径不要往上写。undefined reference 直接看是不是缺了 mdf.c 和 preprocess.c,这两个文件是回声消除函数真正的实现体,少一个就链接失败。

7. 二次开发:把固定参数变成可调链路

这套 so 源码最好改的地方就是参数和调用流程。先做一个最小改动:把 echoInit 里的 frame_size 和 filter_length 暴露成可由服务端下发的配置。Java 层加一个配置对象,初始化时从远程拉值,线上出问题时不用换 so,只改参数就能回退到稳定状态。这是我每次接音频模块都会留的余地——音频这种东西,玄学问题特别多,参数能热调比重新编译 so 省太多时间。

如果想进一步控制效果,可以改 preprocess 的开关顺序。默认流程是去噪、AGC、AEC 一起开,但实际场景里,音量很稳的 Voip 通话往往不需要 AGC,而直播场景需要更激进的噪声抑制。我把 SPEXX_PREPROCESS_SET_DENOISE 和 AGC 的两个开关提到 JNI init 函数的参数里,Java 层按场景传 true/false,这样同一份 so 能服务多种业务,不用为每个场景单独出包。改动量很小,就是在 initEcho 函数里加两个 int 参数,再套一层 if。

采样率适配是最常遇到的外围改动。通话从 16kHz 切到 48kHz 后,不能直接拿 48000 去初始化 Speex,它内部的滤波器设计是基于 16k 的,硬跑会得到一塌糊涂的频域估计。我一般会在调用链上插一个重采样器:48kHz 的麦克风帧先降采样到 16k 送 AEC,处理完再升采样回 48k 送编码器。这样 so 内部逻辑完全不动,只在外围做采样率转换,AEC 效果和纯 16k 链路几乎没有差别。重采样器注意保留相位缓存,分帧边缘不连贯会出现咔哒声。

每次改完参数,我都会做一轮离线回归:准备三个标准测试文件,一个纯人声、一个人声加扬声器回声、一个回声加环境噪声,过完处理链路后分别看回声衰减量和语音失真度。从那以后,我每次接音频模块都强制走一遍这个流程,先把帧长和滤波长度打日志确认状态重建,再调 AEC、去噪、AGC 的顺序和强度,最后才上真机试听。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询