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-v7aarm64-v8a等架构的so文件。记得把libspeexdsp.so拷贝到你的Android项目的src/main/jniLibs/对应目录下。

注意:我曾经遇到过一个问题——编译出来的so库在64位设备上崩溃。后来发现是忘了编译arm64-v8a版本。Android 5.0以后,64位设备默认优先加载64位so,如果你只放了32位的,系统会直接报错。所以,务必同时提供armeabi-v7a和arm64-v8a两个版本

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毫秒的音频数据。太短了回声消除不干净,太长了内存占用大。你可以根据实际场景调整。

个人经验:JNI层传递short数组时,不要用GetByteArrayElements,那个是给byte用的。short数组要用GetShortArrayElements。我刚开始写JNI时搞混过,结果音频数据全乱了,听起来像外星人在说话。

6.3 Android调用流程:从麦克风到扬声器

好,现在库有了,JNI也有了,怎么在Android的音频流里用起来?

标准的调用流程是这样的:

  1. 初始化:在AudioTrack和AudioRecord打开后,创建SpeexDSPNative实例。
  2. 获取远端参考信号:把要播放给扬声器的音频数据(far-end)传给SpeexDSP。
  3. 获取近端麦克风信号:把麦克风采集到的音频数据(near-end)也传进去。
  4. 处理:调用processEcho,得到消除回声后的输出。
  5. 循环:在音频回调中不断重复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音频链路中的位置和调用关系:

SpeexDSP Android集成架构图 Android 应用层 (Java/Kotlin) AudioRecord → 采集near-end → SpeexDSPNative.processEcho() → 输出 JNI 桥接层 Java native方法 ↔ C函数 (SpeexDSPNative.c) SpeexDSP 原生库 (C语言) speex_echo_cancellation() + speex_preprocess_run() 回声消除 | 降噪 | 自动增益控制 近端信号 (near-end) 来自麦克风 远端参考信号 (far-end) 来自扬声器播放数据 输出:消除回声后的纯净音频

从图上可以看得很清楚:应用层采集到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