简介:这份压缩包面向 Android 端语音通信与音频录制开发者,聚焦借助 WebRTC 音频处理模块完成降噪,适合具备 Android 基础、希望把回声消除、噪声抑制、自动增益控制与混响消除接入实际音频链路的进阶学习者,也可作为语音通话、在线会议和移动录音项目的集成参考。包内共 112 个文件,以 XML 配置、BIN 缓存数据、C/C++ 头文件与源码、Java 调用示例、Gradle 构建脚本及少量 SO 动态库为主,整体约 2.53MB,可用于 Android Studio 工程参考与依赖整理。已有 2725 人学习,说明移动音频降噪在语音通话、录音等场景中关注度较高。读者可从中了解 WebRTC 预编译库的组织方式、jniLibs 放置思路,以及 AudioRecord 与 AudioTrack 同音频处理器连接的实现路径,并结合耳机测试建议评估不同环境下的降噪表现,为后续集成、参数调试与效果验证提供可复用的参考。
1. 从一段"像坐在机房上课"的录音说起
某开发者做过一个在线课堂 Demo,主讲端用 Android 平板,教室后排的空调出风口正好对着设备麦克风。学生端反馈很一致:人声能听清,但背景一直有"呼呼"的气流声和键盘敲击的余音,像坐在机房上课。他先试了系统自带的NoiseSuppressor,结果更玄学——一部分机型底噪干净了,另一部分机型开始出现断续和"水下说话"的金属感,同一份代码在不同品牌手机上表现完全不同。
后来他把采集链路换成 WebRTC 的音频处理模块(webrtc::AudioProcessing),把降噪(NS)、高通滤波(HPF)、回声消除(AEC)、自动增益(AGC)统一到一套 C++ 实现里,Android 端只负责采集和 JNI 传参。同一套参数在几十台设备上表现基本一致,这才是可交付的状态。这份资源就是这条链路的完整工程:Java 采集层、JNI 桥、C++ 处理层,以及 WebRTC 音频模块的编译接入。它适合正在做实时语音、会议、直播连麦、录音笔类 App 的 Android 开发,尤其是被"不同机型降噪效果不一致"折腾过的人。
2. 技术栈与工程骨架:NDK、CMake 和 webrtc::AudioProcessing 的分层
这份工程没有把 WebRTC 整个塞进来,只取了音频处理这一块,整体是三层结构。第一层是 Java 采集层,负责AudioRecord的启停、权限、线程和缓冲;第二层是 JNI 桥,只做数据搬运和句柄管理,不写业务逻辑;第三层是 C++ 处理层,持有webrtc::AudioProcessing实例,负责降噪、滤波、增益这些真正的音频运算。
选这个分层不是为了好看,是被逼的。WebRTC 的音频处理 API 全部是 C++ 的,参数结构体嵌套很深,如果在 JNI 里直接把每个参数拆成jint、jboolean传,方法签名会膨胀到没法维护。所以桥接层只暴露"创建 / 处理 / 改配置 / 释放"四个口子,其余全部封在 C++ 里。
2.1 目录结构与模块边界
工程骨架大致是这样组织的,app模块下按语言分目录,C++ 侧再按"桥接"和"处理"拆开:
app/src/main/ ├── java/com/example/audions/AudioCapture.java // 采集线程 + 环形缓冲 ├── java/com/example/audions/WebrtcAudioProcessor.java // native 方法声明 ├── java/com/example/audions/MainActivity.java // 启停与权限 └── cpp/ ├── CMakeLists.txt ├── jni_bridge.cpp // JNIEXPORT 入口,只管类型转换 ├── audio_processor.h ├── audio_processor.cpp // AudioProcessing 的创建、配置、ProcessStream └── third_party/webrtc/ ├── include/ // 只放用到的头文件 └── lib/<ABI>/libwebrtc_audio.athird_party/webrtc里放的是裁剪后的静态库,按 ABI 分目录(arm64-v8a、armeabi-v7a),头文件只保留audio_processing.h、audio_processing_builder.h及其依赖的一小撮。完整 WebRTC 源码树动辄几个 G,一次全量编译通常要几十分钟,日常开发反复编译不现实,所以工程里走的是"预编译静态库 + 精简头文件"的路子。
提示:预编译库和头文件必须来自同一次构建产物。头文件版本和
.a版本对不上时,最典型的表现是编译通过、链接通过,运行时在ApplyConfig里读到一个错位的结构体字段,然后音频直接静音,很难查。
2.2 CMakeLists:静态库导入与 ABI 隔离
构建脚本要处理三件事:把.a当导入目标、关掉不需要的 ABI、解决 C++ 运行库冲突。
cmake_minimum_required(VERSION 3.18.1) project(webrtc_audion LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) # 导入预编译的 WebRTC 音频静态库,路径按 ABI 区分 set(WEBRTC_LIB_DIR ${CMAKE_SOURCE_DIR}/third_party/webrtc/lib/${ANDROID_ABI}) add_library(webrtc_audio STATIC IMPORTED) set_target_properties(webrtc_audio PROPERTIES IMPORTED_LOCATION ${WEBRTC_LIB_DIR}/libwebrtc_audio.a) add_library(audion SHARED jni_bridge.cpp audio_processor.cpp) target_include_directories(audion PRIVATE ${CMAKE_SOURCE_DIR}/third_party/webrtc/include) target_link_libraries(audion webrtc_audio android log OpenSLES)几个参数值得单独说。ANDROID_ABI是 NDK 构建时自动注入的变量,arm64-v8a和armeabi-v7a会各跑一遍脚本,所以导入路径必须带 ABI 变量,否则 32 位包会去链接 64 位库,报file format not recognized。android和log是 NDK 内置库,用于__android_log_print;OpenSLES只有在你要走原生播放参考信号时才需要,纯采集降噪可以去掉。
更关键的是 C++ 运行库的选择。WebRTC 官方构建产物默认静态链接libc++,如果你的audion.so用c++_shared构建,两个运行库同时进入进程,异常和 RTTI 符号会冲突。我一般会在build.gradle里显式指定:
android { defaultConfig { ndk { abiFilters 'arm64-v8a', 'armeabi-v7a' } } packagingOptions { // 避免多个 so 里重复打包 libc++_shared pickFirst 'lib/**/libc++_shared.so' } } externalNativeBuild { cmake { arguments '-DANDROID_STL=c++_static' } }ANDROID_STL=c++_static是和预编译库对齐的关键参数。改成c++_shared之后,如果.a里带了std::string、std::vector之类的符号,链接期就会报一堆undefined reference to std::__ndk1::...,看起来像头文件没包含,实际是运行库不匹配。
2.3 JNI 接口:四个方法就够用
Java 层保持极薄,所有状态都藏在 native 的long句柄里:
public final class WebrtcAudioProcessor { static { System.loadLibrary("audion"); } private long handle; // sampleRate 建议用采集端真实采样率,channels 只支持 1 或 2 public native long nativeCreate(int sampleRate, int channels); // in/out 长度必须为 10ms 对应的采样点数,返回值 0 表示成功 public native int nativeProcess(long handle, short[] in, short[] out, int frames); // nsLevel: 0~3 对应 kLow/kModerate/kHigh/kVeryHigh public native int nativeSetConfig(long handle, int nsLevel, boolean hpf, boolean agc); public native void nativeRelease(long handle); }nativeCreate返回jlong存的是 C++ 对象指针,用reinterpret_cast<jlong>(ptr)转换。这种做法比用全局单例安全,一个进程里可以同时存在多个实例(比如同时处理录音和播放两条流),释放时对号入座即可。nativeRelease必须和nativeCreate成对出现,并且要保证调用时采集线程已经停止——指针被delete之后处理线程还在跑,就是标准的 use-after-free。
3. 核心链路拆解:一帧 PCM 从 AudioRecord 到降噪输出的完整代码
WebRTC 音频模块对输入有两个硬性要求:采样点数必须是 10ms 对应的整数倍,数据必须是 float 非交织格式。Android 的AudioRecord给你的却是short[],长度还不一定对得上。这条链路的所有坑,几乎都出在这两个差异上。
3.1 Java 侧采集循环:凑满一帧再送
AudioRecord.read不保证一次读满你要的长度,尤其在系统繁忙或者缓冲区刚被消费的时候。所以采集循环不能假设"读一次就是一帧",必须自己凑:
private static final int SAMPLE_RATE = 48000; private static final int CHANNELS = 1; // 10ms 对应的采样点数:48000 * 0.01 = 480 private static final int FRAME_SAMPLES = SAMPLE_RATE / 100; private void captureLoop() { int minBytes = AudioRecord.getMinBufferSize( SAMPLE_RATE, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT); // 至少给 4 帧的余量,避免 read 频繁 underrun int bufferBytes = Math.max(minBytes, FRAME_SAMPLES * 2 * 4); AudioRecord record = new AudioRecord( MediaRecorder.AudioSource.VOICE_COMMUNICATION, SAMPLE_RATE, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT, bufferBytes); if (record.getState() != AudioRecord.STATE_INITIALIZED) { Log.e(TAG, "AudioRecord init failed"); return; } short[] frame = new short[FRAME_SAMPLES]; short[] out = new short[FRAME_SAMPLES]; int filled = 0; record.startRecording(); while (running) { // 一次可能读不满,循环补齐到恰好一帧 int n = record.read(frame, filled, FRAME_SAMPLES - filled); if (n < 0) { Log.e(TAG, "read error: " + n); break; } filled += n; if (filled < FRAME_SAMPLES) { continue; } filled = 0; int ret = processor.nativeProcess(handle, frame, out, FRAME_SAMPLES); if (ret != 0) { Log.w(TAG, "process failed: " + ret); continue; } // out 现在是人声增强后的 PCM16,可直接送编码或播放 sink.write(out, 0, FRAME_SAMPLES); } record.stop(); record.release(); }参数里最值得琢磨的是音源。VOICE_COMMUNICATION会开启厂商的语音预处理链路(部分机型自带 NS、AGC、AEC),而我们的 WebRTC NS 是第二级降噪。两级串联时,前级已经把高频细节削掉,后级再按语音模型去猜,很容易把气声和辅音一起判定成噪声,听起来像"水声"。如果确定要走 WebRTC 全套,优先试MediaRecorder.AudioSource.MIC,或者UNPROCESSED(需要AudioManager.PROPERTY_SUPPORT_AUDIO_SOURCE_UNPROCESSED为 true,不是所有设备都支持)。
bufferBytes取 4 帧余量是我一贯的做法。给太少,read会周期性返回 0,音频出现规律性的空洞;给太多,端到端延迟上升。4 帧大约 40ms 缓冲,对录音类应用完全够,对连麦场景可以降到 2 帧。
3.2 C++ 侧初始化:用 AudioProcessingBuilder 建配置
webrtc::AudioProcessing的推荐创建方式是 builder 模式,配置结构体一次性带进去,比创建完再逐个SetXXX更不容易漏项:
#include "api/audio/audio_processing.h" bool AudioProcessor::Init(int sample_rate_hz, int channels) { sample_rate_ = sample_rate_hz; channels_ = channels; frame_samples_ = sample_rate_hz / 100; // 10ms webrtc::AudioProcessing::Config config; // 高通滤波:切掉 80Hz 以下的低频嗡声(空调、桌面震动、风声) config.high_pass_filter.enabled = true; // 降噪:kHigh 是语音通信里比较稳的档位,kVeryHigh 更狠但伤辅音 config.noise_suppression.enabled = true; config.noise_suppression.level = webrtc::AudioProcessing::Config::NoiseSuppression::kHigh; // 回声消除和 AGC 默认关,按场景在 SetConfig 里打开 config.echo_canceller.enabled = false; config.gain_controller1.enabled = false; config.gain_controller2.enabled = false; apm_ = webrtc::AudioProcessingBuilder().SetConfig(config).Create(); if (apm_ == nullptr) { return false; } // 预分配 float 缓冲,避免每帧 new in_f_.resize(frame_samples_ * channels_); out_f_.resize(frame_samples_ * channels_); return true; }这里有几个版本相关的注意点。较新的 WebRTC 版本提供AudioProcessingBuilder().SetConfig(config).Create(),一步到位;老版本是AudioProcessing::Create()之后调ApplyConfig(config)。两种写法在工程里都常见,接入前先看头文件里 builder 有没有SetConfig方法。
config.gain_controller1和config.gain_controller2是两代 AGC 实现,同时打开会互相打架,官方注释里明确写了不能同时启用。我在初始化里先把两个都关掉,等业务侧明确要哪一套再打开,避免后期排查时忘了默认值。
3.3 ProcessStream:short 与 float 的转换边界
真正干活的就一个函数。ProcessStream接受的是float* const*(指针数组,每个声道一个指针),非交织布局,长度必须是 10ms:
int AudioProcessor::Process(const int16_t* in, int16_t* out, size_t frames) { if (apm_ == nullptr || frames != frame_samples_) { return -1; // 帧长不对,直接拒掉 } constexpr float kScale = 1.0f / 32768.0f; // 去交织 + 归一化到 [-1, 1] if (channels_ == 1) { for (size_t i = 0; i < frames; ++i) { in_f_[i] = static_cast<float>(in[i]) * kScale; } } else { for (size_t i = 0; i < frames; ++i) { for (int c = 0; c < channels_; ++c) { in_f_[c * frames + i] = static_cast<float>(in[i * channels_ + c]) * kScale; } } } // 按声道组装指针数组 float* src[2] = {nullptr, nullptr}; float* dst[2] = {nullptr, nullptr}; for (int c = 0; c < channels_; ++c) { src[c] = in_f_.data() + c * frames; dst[c] = out_f_.data() + c * frames; } webrtc::StreamConfig cfg(sample_rate_, static_cast<size_t>(channels_)); int err = apm_->ProcessStream(src, cfg, cfg, dst); if (err != 0) { return err; // 常见值:-1 参数错误,-2 内部状态异常 } // 反归一化 + clamp,防止浮点越界后转 short 溢出成爆音 if (channels_ == 1) { for (size_t i = 0; i < frames; ++i) { float v = std::max(-1.0f, std::min(1.0f, out_f_[i])); out[i] = static_cast<int16_t>(v * 32767.0f); } } else { for (size_t i = 0; i < frames; ++i) { for (int c = 0; c < channels_; ++c) { float v = std::max(-1.0f, std::min(1.0f, out_f_[c * frames + i])); out[i * channels_ + c] = static_cast<int16_t>(v * 32767.0f); } } } return 0; }逐段说下逻辑。开头的帧长校验别省,ProcessStream遇到非 10ms 数据不会崩溃,但会返回错误码,如果你忽略返回值继续往下走,输出的就是上一帧的残留数据,听起来像卡带。归一化用1/32768,反归一化用32767,这是 WebRTC 内部一直沿用的换算方式,两个系数不对称是有意为之——short的取值范围是[-32768, 32767],负方向多一个。
StreamConfig的构造在老版本是两参数(sample_rate, channels),含义就是 float 非交织;新版本加了第三个bool interleaved参数,默认false也是非交织。如果你传true,那src里要放交织的int16数据并做reinterpret_cast,两种风格混用是编译期最容易犯的错。
clamp 那两行看起来多余,其实很关键。降噪和 AGC 都是浮点运算,输出偶尔会略微超过 ±1.0,直接乘 32767 再强转int16_t是未定义行为,在部分 ARM 设备上会得到反相的极值,听感就是"啪"的一声爆音。
3.4 双声道场景的去交织
立体声是很多会议 App 的默认配置,但 WebRTC 的 NS 主要针对单声道语音设计。我的做法是:采集侧保留双声道(方便后期做空间感或者做声道选择),进处理前先用左声道跑一遍降噪,然后把处理后的左声道复制回右声道,或者只在左声道上做降噪、右声道原样透传。如果业务不关心立体声,直接CHANNEL_IN_MONO采集最省事,CPU 也低一半。
4. 参数配置:采样率、帧长、NS 档位与 AEC/AGC 的取舍
这套模块的参数不多,但每一个都会明显影响听感。调参之前先记住一条:所有参数都是"语音可懂度"和"噪声残留"之间的交换,没有全能配置。
4.1 NS 四档的取舍
noise_suppression.level有四个枚举值,实际听感差异比名字大得多:
| 档位 | 降噪强度 | 副作用 | 适合场景 |
|---|---|---|---|
kLow | 弱 | 几乎不伤语音,底噪残留明显 | 录音笔、音乐人声、后期还要再做处理的素材 |
kModerate | 中 | 轻微齿音损失 | 会议、在线课堂,语音为主但对自然度有要求 |
kHigh | 较强 | 强噪声下辅音变闷 | 客服通话、连麦、车载通话,工程默认值 |
kVeryHigh | 最强 | 明显"水下感"、气声被吃掉 | 极端噪声环境,且能接受音质下降 |
我的经验是默认kHigh,遇到用户投诉"声音发闷"就降到kModerate,遇到投诉"还有底噪"就确认是不是采集音源选错了——很多时候不是档位不够,是VOICE_COMMUNICATION的预处理把信号改得面目全非了。
关于采样率,48kHz 采集就按 48kHz 送进ProcessStream,别在 Java 层重采样到 16k。WebRTC 内部对 NS 会在低 band 上做主要处理,你从 48k 降到 16k 再送进去,等于把 8k 以上的信息提前扔掉,降噪后的声音会更闷。48k 全链路跑的 CPU 开销在中端机上通常远小于 10ms 的实时预算,不值得为这点开销牺牲音质。
4.2 AEC:参考信号必须来自播放侧
回声消除是这套模块里最容易配错的部分。ProcessReverseStream喂的必须是"即将从扬声器播放出去的音频",而不是麦克风采集到的音频:
// 在播放线程里调用,ref 是准备送去 AudioTrack 的那一帧 int AudioProcessor::PushRender(const int16_t* ref, size_t frames) { if (apm_ == nullptr || frames != render_frame_samples_) { return -1; } constexpr float kScale = 1.0f / 32768.0f; for (size_t i = 0; i < frames; ++i) { render_f_[i] = static_cast<float>(ref[i]) * kScale; } float* rev[1] = {render_f_.data()}; webrtc::StreamConfig cfg(render_rate_, 1); // 注意:第三、四个参数是输入输出配置,通常传同一个 return apm_->ProcessReverseStream(rev, cfg, cfg, rev); }三个硬性条件:参考信号必须是 10ms 对齐的、必须和采集侧使用同一个AudioProcessing实例、采集和播放的时钟不能长期漂移。第三条在纯 Java 播放(AudioTrack)场景下尤其麻烦,播放线程和采集线程各自跑各自的节奏,跑上几分钟就会错开几十毫秒,AEC 从"消除回声"变成"引入失真"。工程里如果要做稳定 AEC,建议采集和播放都走同一套时钟驱动,或者定期用播放侧的帧计数校准。
4.3 AGC:两代实现只能开一套
gain_controller1是老实现,参数多、行为可预测;gain_controller2是新实现,自带电平估计器,对突发大声压得更自然。二选一:
// 方案 A:AGC1,适合需要精确控制目标电平的场景 config.gain_controller1.enabled = true; config.gain_controller1.mode = webrtc::AudioProcessing::Config::GainController1::kAdaptiveAnalog; config.gain_controller1.target_level_dbfs = 3; config.gain_controller1.compression_gain_db = 9; config.gain_controller1.enable_limiter = true; // 方案 B:AGC2,适合语音为主、追求自然听感的场景 config.gain_controller2.enabled = true; config.gain_controller2.adaptive_digital.enabled = true;target_level_dbfs表示目标峰值电平,值越小越响,但太激进会把噪声一起抬起来。我一般设 3 到 6 之间,配合enable_limiter = true防止削波。如果降噪和 AGC 同时开,注意顺序:WebRTC 内部的处理顺序是 HPF → NS → AGC,所以 NS 把底噪压下去之后 AGC 会把残留的微弱噪声放大,这就是"降噪后反而听到嘶嘶声"的常见来源,处理办法是把 AGC 的压缩增益调低,或者干脆关掉 AGC 只在业务侧做固定增益。
4.4 运行时改配置:加锁
ApplyConfig不是线程安全的,必须在没有ProcessStream在跑的时候调用。业务上又经常需要"用户在设置页切一下降噪档位"这种热更新,所以我会在 C++ 侧加一把锁,把处理和配置变更串行化:
int AudioProcessor::ApplyConfig(int ns_level, bool hpf, bool agc) { std::lock_guard<std::mutex> lock(mutex_); if (apm_ == nullptr) return -1; webrtc::AudioProcessing::Config config = apm_->GetConfig(); config.high_pass_filter.enabled = hpf; config.noise_suppression.enabled = ns_level >= 0; if (ns_level >= 0) { config.noise_suppression.level = static_cast< webrtc::AudioProcessing::Config::NoiseSuppression::Level>(ns_level); } config.gain_controller2.enabled = agc; return apm_->ApplyConfig(config); }GetConfig()拿到当前配置再改,比从零构造一份更安全——从零构造会把之前设过的 AEC 之类的开关全部重置。锁的粒度要盖住Process和ApplyConfig两边,只在ApplyConfig里加锁等于没加。
5. 避坑与排查:Android 音频降噪最常见的 5 个翻车现场
5.1 现象:开了降噪,底噪反而变成"咕噜咕噜"的水声
原因基本可以锁定为两级降噪串联。VOICE_COMMUNICATION音源在多数机型上会启用厂商的 NS 和 AGC,信号进到 WebRTC 时高频细节已经被削过一轮,WebRTC 的噪声模型拿到这种信号,会把残留的气声判定成噪声并做频谱减除,减除后的残余就是那种周期性的"咕噜"声。
解决方式是换音源。先试MediaRecorder.AudioSource.MIC,它的预处理链路最干净;如果设备支持UNPROCESSED,那是最理想的选择,可以用AudioManager.getProperty(AudioManager.PROPERTY_SUPPORT_AUDIO_SOURCE_UNPROCESSED)判断。判断不支持就退回MIC,别硬用UNPROCESSED,部分机型上它会直接录出全零数据。
5.2 现象:某些机型完全没有声音,日志里也没有报错
两条线索。一是AudioRecord构造之后没检查getState(),初始化失败时对象照样能创建出来,startRecording也不抛异常,但read一直返回 0。二是nativeProcess的返回值被忽略了,ProcessStream返回 -1 时输出缓冲保持原样(上一帧的旧数据),连续几十帧下来听起来就是卡顿或者静音。
解决方式是把两个检查都补上:构造后立刻判断STATE_INITIALIZED,非初始化状态就release并上报;nativeProcess返回非 0 时把这一帧填静音而不是直接丢弃,避免下游拿到脏数据。
5.3 现象:降噪后音量忽大忽小,安静段落被抬得特别响
这是 AGC 和 NS 的顺序副作用。NS 把底噪压到很低之后,AGC 检测到整体电平不够,会把增益往上推;等到有人说话,电平突然上来,AGC 又来不及收,就出现"呼吸感"。如果同时开了gain_controller1和gain_controller2,问题会更严重,两套增益逻辑各自为政。
解决方式是只保留一套 AGC,并且把compression_gain_db控制在 9 以内。如果业务对音量稳定性要求高,可以直接关掉 AGC,改成在 C++ 侧算一个滑动窗口 RMS,用固定增益补偿,行为完全可控。
5.4 现象:AEC 打开后双讲被削,说话像在打电话的另一端
参考信号没对齐是头号嫌疑。ProcessReverseStream喂的数据如果来自"已经播放完的缓冲"而不是"即将播放的缓冲",延迟就对不上,AEC 会把近端人声当成回声消掉。另一个常见错误是拿降噪后的数据当参考信号——参考信号应该是纯净的播放信号,经过 NS 处理之后频谱变了,AEC 的自适应滤波器会收敛到错误的方向。
解决方式是确认三点:参考信号取自播放队列的写入侧、帧长与采集侧一致、采集和播放使用同一个AudioProcessing实例。漂移问题在长时间通话里才会暴露,测试时至少连续跑 10 分钟。
5.5 现象:跑几分钟后手机发烫,帧率开始抖
多数是 JNI 层的反复分配。GetShortArrayElements/ReleaseShortArrayElements每帧调一次,加上 C++ 侧每帧new float[],48000Hz 下单声道每秒 100 次分配,GC 和内存碎片都会上来。
解决方式是把缓冲提前分配到成员变量里(前面Init里的in_f_/out_f_就是干这个的),Java 侧复用short[]不要在循环里 new,进一步可以把short[]换成DirectByteBuffer,用GetDirectBufferAddress拿到裸指针,省掉一次数组拷贝。这几处改完,CPU 占用通常能降一个可观测的档位。
6. 二次开发:把"能降噪"改成"能上线"
拿到这份工程之后,第一件事不是改算法,是把它变成可配置、可验证的样子。我一般会做三处改造。
第一处是把参数从硬编码挪到配置对象。AudioProcessor暴露一个AudioConfig结构体,包含采样率、声道数、NS 档位、HPF 开关、AGC 模式,由 Java 层从SharedPreferences或远端配置读出来再传下去。这样灰度的时候不需要发版就能调档位。
第二处是加一层"处理前后对比"的自检开关。调试期把输入帧和输出帧各存一份到文件,出问题的时候可以直接听 A/B,比看日志快得多:
// 仅 debug 构建启用,正式包编译期裁掉 #ifdef AUDION_DEBUG_DUMP void DumpFrame(const char* tag, const int16_t* data, size_t frames) { FILE* fp = fopen(tag, "ab"); if (fp != nullptr) { fwrite(data, sizeof(int16_t), frames, fp); fclose(fp); } } #endif第三处是补一个可量化的回归测试。降噪效果不能只靠耳朵判断,我习惯用一段固定的静音底噪 PCM 跑一遍,比较处理前后的 RMS 能量,能量下降 15dB 以上算达标:
import numpy as np def rms_dbfs(path): data = np.fromfile(path, dtype=np.int16).astype(np.float32) / 32768.0 if data.size == 0: return -120.0 return 20 * np.log10(max(np.sqrt(np.mean(data ** 2)), 1e-6)) before = rms_dbfs("noise_raw.pcm") after = rms_dbfs("noise_processed.pcm") print(f"raw={before:.1f} dBFS, processed={after:.1f} dBFS, delta={before - after:.1f} dB")这段脚本跑在 CI 里,每次改完参数都过一遍,避免"调了一版感觉更好"结果整体降噪量反而退回去。语音段落也要单独测,用一段有人声的素材算处理后的人声 RMS,掉超过 3dB 就说明 NS 开始吃语音了,档位该往回退。
还有个小技巧:把ProcessStream的耗时用clock_gettime打点统计,每 100 帧输出一次 P99。这个数字比 CPU 占用百分比更能反映实时性风险,P99 一旦接近 10ms,说明某些机型上已经出现处理不及时,得考虑降采样率或者降档位。
从那以后我每次接新的音频处理库,都强制先跑一遍"静音底噪 + 人声素材"的双基线测试,再谈调参。跳过这一步直接凭听感调,最后一定会在某个机型上翻车。希望帮到你。
本文还有配套的精品资源,点击获取