6、SpeexDSP集成:SpeexDSP库的编译、JNI封装、在Android中的调用流程
好,咱们今天来聊聊SpeexDSP在Android上的集成。说实话,这个库虽然名字叫Speex,但它跟语音编码器Speex是两码事。SpeexDSP是一个独立的音频处理库,专门做回声消除、降噪和自动增益控制。我在项目中用过好几个回声消除方案,WebRTC的AEC3、SpeexDSP、还有厂商自研的,各有优劣。但SpeexDSP有个最大的好处——轻量、稳定、API清晰,特别适合嵌入式设备和Android平台。
嗯,咱们直接进入正题。集成SpeexDSP到Android,核心就三步:编译原生库、编写JNI封装、在Java/Kotlin层调用。每一步都有坑,我一个个说。
6.1 编译SpeexDSP原生库
首先,你得拿到SpeexDSP的源码。去它的官方仓库或者直接下载release包。我个人习惯用NDK交叉编译,这样生成的so库体积小、性能好。
这里我给出一个标准的Android.mk写法,你直接放到jni/目录下就行:
LOCAL_PATH := $(call my-dir)
include $(CLEAR_VARS)
LOCAL_MODULE := speexdsp
LOCAL_SRC_FILES := \
preprocess.c \
echo_diagnostic.c \
jitter.c \
mdf.c \
fftwrap.c \
filterbank.c \
resample.c \
scal.c \
smallft.c \
snr.c \
vbr.c \
os_support.c \
buffer.c \
ltp.c \
cb_search.c \
exc_10_16_table.c \
exc_10_32_table.c \
exc_20_32_table.c \
exc_5_256_table.c \
exc_5_64_table.c \
exc_8_128_table.c \
gain_table.c \
gain_table_lbr.c \
hexc_10_32_table.c \
hexc_table.c \
high_lsp_tables.c \
lsp_tables_nb.c \
ltp_sse.c \
nb_celp.c \
quant_lsp.c \
sb_celp.c \
vq.c \
bits.c \
filters.c \
lpc.c \
lpc_bfin.c \
lsp.c \
modes.c \
modes_wb.c \
nb_celp.c \
plc.c \
sb_celp.c \
speex.c \
stereo.c
LOCAL_CFLAGS := -DEXPORT= -DFLOATING_POINT -DUSE_SMALLFT -DVAR_ARRAYS
LOCAL_LDLIBS := -llog
include $(BUILD_SHARED_LIBRARY)
这里有个关键点:LOCAL_CFLAGS里的-DFLOATING_POINT。SpeexDSP支持定点数和浮点数两种模式。浮点模式精度高,但耗电;定点模式省电,但精度稍差。我在做手机端实时通话时,用的都是浮点模式,因为现在的手机CPU性能完全够用,而且浮点运算的音频质量明显更好。
编译命令很简单:
cd 你的项目目录/jni
ndk-build
编译成功后,在libs/目录下会生成armeabi-v7a、arm64-v8a等架构的so文件。记得把libspeexdsp.so拷贝到你的Android项目的src/main/jniLibs/对应目录下。
6.2 JNI封装:让Java调用C代码
原生库编译好了,但Java层没法直接调用C函数。这时候就需要JNI(Java Native Interface)来搭桥。说白了,JNI就是一张「翻译表」,告诉Java虚拟机:你调的这个native方法,对应的是C语言里的哪个函数。
我习惯把JNI封装成一个单独的类,叫SpeexDSPNative。这样上层调用起来非常干净:
public class SpeexDSPNative {
static {
System.loadLibrary("speexdsp");
}
// 初始化回声消除器
public static native long init(int sampleRate, int frameSize, int filterLength);
// 处理回声消除
public static native int processEcho(long handle, short[] nearEnd, short[] farEnd, short[] out);
// 释放资源
public static native void release(long handle);
}
对应的C端JNI实现长这样:
#include <jni.h>
#include <speex/speex_echo.h>
#include <speex/speex_preprocess.h>
JNIEXPORT jlong JNICALL
Java_com_example_audio_SpeexDSPNative_init(
JNIEnv *env, jclass clazz,
jint sampleRate, jint frameSize, jint filterLength) {
SpeexEchoState *echoState = speex_echo_state_init(frameSize, filterLength);
if (!echoState) return 0;
int sampleRateInt = sampleRate;
speex_echo_ctl(echoState, SPEEX_ECHO_SET_SAMPLING_RATE, &sampleRateInt);
// 初始化预处理(降噪+AGC)
SpeexPreprocessState *preprocess = speex_preprocess_state_init(frameSize, sampleRate);
int denoise = 1;
int agc = 1;
speex_preprocess_ctl(preprocess, SPEEX_PREPROCESS_SET_DENOISE, &denoise);
speex_preprocess_ctl(preprocess, SPEEX_PREPROCESS_SET_AGC, &agc);
// 把两个状态指针打包成一个long返回
// 实际项目中建议用结构体封装
return (jlong)echoState;
}
这里有个小技巧:speex_echo_state_init的第二个参数是滤波器长度。这个值怎么设?我一般取sampleRate * 0.2,也就是200毫秒的音频数据。太短了回声消除不干净,太长了内存占用大。你可以根据实际场景调整。
6.3 Android调用流程:从麦克风到扬声器
好,现在库有了,JNI也有了,怎么在Android的音频流里用起来?
标准的调用流程是这样的:
- 初始化:在AudioTrack和AudioRecord打开后,创建SpeexDSPNative实例。
- 获取远端参考信号:把要播放给扬声器的音频数据(far-end)传给SpeexDSP。
- 获取近端麦克风信号:把麦克风采集到的音频数据(near-end)也传进去。
- 处理:调用processEcho,得到消除回声后的输出。
- 循环:在音频回调中不断重复2-4步。
代码实现大概是这样的:
public class AudioProcessor {
private long nativeHandle;
private short[] farEndBuffer;
private short[] nearEndBuffer;
private short[] outputBuffer;
public void startProcessing(int sampleRate, int frameSize) {
int filterLength = (int)(sampleRate * 0.2);
nativeHandle = SpeexDSPNative.init(sampleRate, frameSize, filterLength);
farEndBuffer = new short[frameSize];
nearEndBuffer = new short[frameSize];
outputBuffer = new short[frameSize];
// 启动音频采集和播放线程
startAudioThreads();
}
private void onAudioData(short[] micData, short[] speakerData) {
// 注意:这里要保证两个buffer长度一致
System.arraycopy(micData, 0, nearEndBuffer, 0, frameSize);
System.arraycopy(speakerData, 0, farEndBuffer, 0, frameSize);
SpeexDSPNative.processEcho(nativeHandle, nearEndBuffer, farEndBuffer, outputBuffer);
// outputBuffer就是消除回声后的数据,可以送去编码或直接播放
sendToEncoder(outputBuffer);
}
}
这里有个容易忽略的点:时间同步。回声消除的原理是「用参考信号减去回声」,所以far-end信号必须和near-end信号在时间上对齐。如果播放线程和采集线程不同步,消除效果会大打折扣。我遇到过最头疼的问题就是——播放延迟导致回声消除算法「看到」的参考信号和实际扬声器发出的声音对不上,结果不仅没消掉回声,反而引入了新的噪声。
解决方案是:在播放端和采集端使用同一个AudioClock,或者用时间戳对齐。简单点的话,可以给每个音频帧打上序号,确保far-end和near-end的序号一致。
6.4 整体架构图
下面这张图展示了SpeexDSP在Android音频链路中的位置和调用关系:
从图上可以看得很清楚:应用层采集到near-end和far-end两路信号,通过JNI传给SpeexDSP原生库,库内部完成回声消除、降噪、AGC等处理,最后输出干净的音频数据。整个过程是实时流式的,每一帧都要处理。
6.5 避坑指南
最后,我总结几个实际项目中容易踩的坑:
- 采样率必须一致:near-end、far-end和SpeexDSP初始化时的采样率,三者必须完全一样。我曾经因为AudioRecord设了44100Hz,但SpeexDSP初始化用了48000Hz,结果回声消除完全失效。
- 帧大小要匹配:SpeexDSP的frameSize必须是2的幂次方,常见的是128、256、512。我一般用256,延迟和性能比较均衡。
- 多线程安全:SpeexDSP的API不是线程安全的。如果你的采集线程和播放线程同时调用processEcho,记得加锁。或者干脆在同一个线程里串行处理。
- 内存泄漏:每次init后,一定要在Activity的onDestroy或Service的onDestroy里调用release。否则每次打开关闭通话都会泄漏内存,时间长了App就崩了。
嗯,SpeexDSP的集成流程大概就是这样。说白了,编译、JNI、调用三步走,每一步都有细节要注意。但只要你把采样率、帧大小、时间同步这几个关键点把控好,效果还是相当不错的。我在几个商用项目里都用过这套方案,用户反馈回声消除效果明显,而且CPU占用很低,比WebRTC的AEC3轻量不少。
- 编译时注意浮点/定点模式选择,推荐浮点模式
- JNI封装要处理好short数组的传递和生命周期
- 调用流程必须保证near-end和far-end时间同步
- 采样率、帧大小、滤波器长度三个参数要匹配
公众号:蓝海资料掘金营,微信deep3321