
简介面向具备一定Android基础、关注实时音频质量的开发者这份WebRTC降噪工程包可用于语音通话、音频录制及环境噪声抑制场景的参考与实验。内容围绕WebRTC音频处理链路展开涉及回声消除、噪声抑制、自动增益控制和混响消除等模块并提供Android端集成所需的工程结构、预编译动态库与调用接口。压缩包共112个文件、约2.53MB主要由xml配置、Java与C源码、头文件、so库、Gradle构建脚本及属性文件组成便于在Android Studio中查看工程组织并核对jniLibs与音频处理接口的对接方式。已有2725人学习下载。读者可获得可参考的降噪工程骨架、本地库文件与模块调用入口用于语音通信或录音场景的降噪实践、效果验证与二次开发也可作为对比不同设备与环境噪声表现的实验素材。1. 先别急着接 AudioRecordAndroid 音频降噪里 WebRTC NS 到底替你做哪一步地铁里录一段语音回放时底噪像潮水一样压过人声这是 Android 音频降噪 WebRTC 方案最常被拿来救场的场景。它不是一个“开/关”的系统音效而是一条按 10 ms 帧推进的处理链高通滤掉低频轰鸣NS 压住稳态噪声VAD 判断当前帧要不要保AEC 和 AGC 则要看你是不是通话链路。这套源码把 AudioRecord 采集、JNI 桥接和 APM 参数封装在一起适合做 VoIP、课堂录音、客服对讲、现场采访这类对底噪和延迟都敏感的 App。只做离线录音时别一股脑把 AEC、AGC 全打开先让 NS 和 HPF 跑通再按场景加模块。拿到包后先别改算法先核对采样率、声道数和每帧样本数。WebRTC 的很多“降噪后声音怪”不是参数不灵而是 10 ms 帧没对齐后面几章会把这笔账拆开算。2. 拆开这套源码WebRTC APM 的处理链和 Android 侧边界这套源码的核心不是某个 Java 工具类而是 WebRTC Audio Processing 模块在 Android 上的一层封装。APM 本身处理的是一帧一帧的 PCM16 数据Java 层负责采集、播放和权限JNI 层负责把 short 数组交给AudioProcessing再把处理后的数据搬回来。理解这条边界之后参数该在 Java 层改、还是在 native 层改就不会乱。2.1 从 PCM 帧到 NS 输出10 ms 帧为什么绕不开WebRTC APM 的ProcessStream按 10 ms 粒度处理音频。16 kHz 单声道一帧是 160 个采样点48 kHz 单声道一帧是 480 个采样点如果你从 AudioRecord 一次读 20 ms就必须在进 APM 前拆成两帧或者把流配置和帧长同时改成匹配值。最常见的翻车不是 NS 不工作而是把 48 kHz 的 480 点硬塞给 16 kHz 的StreamConfig输出直接变成金属声或者周期性爆点。采样率单声道 10 ms 样本数PCM16 字节数常见用途8 kHz80160窄带语音、老设备兼容16 kHz160320语音通话、课堂录音32 kHz320640宽频语音、对讲48 kHz480960全带录音、音乐混合这张表建议直接抄到 native 侧的参数校验里。samplesPerChannel * channels必须等于sampleRate / 100 * channels不相等就返回错误码不要“先处理再说”。我见过把 20 ms 帧直接扔进去的工程NS 确实压了底噪但语音每 10 ms 被截一次听感像卡带查了两天才发现是帧长问题。WebRTC APM 内部还会做重采样和分带处理所以 48 kHz 不是不能用而是 CPU 和链路延迟都会上去。语音场景优先 16 kHz 单声道全带录音再考虑 48 kHz。别为了“高保真”把 48 kHz 双声道直接塞进 NS除非你确认每一帧的声道交错和样本数都对得上。2.2 采样率、声道和延迟源码里最该先核对的三个参数第一个参数是采样率。AudioRecord 初始化时写 16 kHzJNI 初始化也写 16 kHzAPM 的StreamConfig还要写 16 kHz这三处必须一致。有人只在 Java 层改了采样率native 侧仍然按 48 kHz 建流结果就是每帧样本数对不上降噪后的声音断续、发闷甚至直接崩溃。第二个参数是声道。单声道用CHANNEL_IN_MONO双声道用CHANNEL_IN_STEREO但 WebRTC 的声道数是交错后的声道数。双声道帧长 160 个采样点指的是每声道 160 点交错数组长度是 320。Java 层如果只按 160 去读右声道数据会被截断听感上像左右耳轮流掉线。第三个参数是延迟。10 ms 帧只是算法粒度实际延迟还包含 AudioRecord 的内部缓冲、JNI 拷贝、播放端缓冲。AudioRecord.getMinBufferSize返回的是字节数不是采样点数缓冲区给太小会 underrun给太大又会把端到端延迟拉高。语音对讲一般把缓冲控制在 2 到 4 帧离线录音可以放宽但别在采集线程里做文件 IO。音频源也要选对。VOICE_COMMUNICATION往往会触发平台侧 AEC、NS、AGC和 WebRTC 的 NS 叠在一起就是双处理纯录音场景优先MIC设备支持UNPROCESSED时更干净但不要假设所有机型都支持。通话场景再用VOICE_COMMUNICATION同时决定是信平台 AEC 还是信 WebRTC AEC不要两边都开。AudioSource特点适合场景MIC通常带少量平台处理兼容性好录音、离线降噪、采访VOICE_COMMUNICATION平台通话链可能带 AEC/NS/AGC实时对讲、VoIPUNPROCESSED尽量保留原始信号设备支持不一致需要自己控处理的采集注意同一台设备上VOICE_COMMUNICATION和MIC的频响、底噪、增益都可能不同。调 NS 参数前先固定音频源不然你调的不是算法是平台预处理的差异。2.3 编译产物与 JNI 接口.so、CMake 和 Java 封装怎么对齐源码包如果带的是预编译库Android 侧最常见的是把libwebrtc_ns.so或libwebrtc_audio_processing.a放进jniLibs/abi/再用 CMake 链接。ABI 不要贪多先保arm64-v8a需要老设备再加armeabi-v7ax86_64只在模拟器调试时开发布包带上去只会增大体积。cmake_minimum_required(VERSION 3.22.1) project(webrtc_ns LANGUAGES CXX) # WEBC_ROOT 指向源码包里的 WebRTC 头文件和库目录 set(WEBC_INCLUDE_DIR ${WEBC_ROOT}/include) set(WEBC_AUDIO_PROCESSING_LIB ${WEBC_ROOT}/lib/${ANDROID_ABI}/libwebrtc_audio_processing.a) add_library(webrtc_ns SHARED src/main/cpp/webrtc_ns_jni.cpp ) target_include_directories(webrtc_ns PRIVATE ${WEBC_INCLUDE_DIR}) target_link_libraries(webrtc_ns ${WEBC_AUDIO_PROCESSING_LIB} android log )这段 CMake 的逻辑很直白WEBC_INCLUDE_DIR给头文件WEBC_AUDIO_PROCESSING_LIB给静态库或预编译库android和log是 JNI 与日志的必需依赖。${ANDROID_ABI}由 Android Gradle Plugin 传入不要在 CMake 里写死arm64-v8a。如果源码包给的是.so而不是.a把add_library改成IMPORTED目标再设置IMPORTED_LOCATION不要直接把.so当源文件塞进add_library。JNI 符号必须和 Java/Kotlin 包名、类名严格对应。下面用com.example.webrtcns占位实际工程替换成自己的包名。类被混淆、包名被改、方法名被 R8 改掉都会出现UnsatisfiedLinkError这类问题看adb logcat比看代码快。3. 把降噪跑起来从工程导入到首帧 PCM 的落地顺序下面按实际接链路的顺序来先让工程能编、能申请权限再让 AudioRecord 稳定吐出 10 ms 帧最后把帧送进 JNI。顺序反过来做出了无声、爆音、延迟高你很难判断是采集、JNI 还是 APM 参数的问题。3.1 工程导入与权限、采样配置的最小闭环Gradle 侧先把 NDK、CMake 和 ABI 定下来。abiFilters不要一上来全开先保真机常用的arm64-v8a模拟器调试再加x86_64。externalNativeBuild指向你的CMakeLists.txtcppFlags至少给到 C17WebRTC 的头文件对标准版本有要求。android { compileSdk 34 defaultConfig { minSdk 23 ndk { abiFilters listOf(arm64-v8a, armeabi-v7a) } externalNativeBuild { cmake { cppFlags -stdc17 } } } externalNativeBuild { cmake { path file(src/main/cpp/CMakeLists.txt) } } packaging { jniLibs { useLegacyPackaging true } } }abiFilters决定最终打进 APK 的.so架构cppFlags决定 native 编译标准useLegacyPackaging主要影响.so的压缩方式老工程升级 AGP 后如果出现dlopen失败可以先检查这一项。compileSdk和minSdk按你的工程现状改不要为了抄这段把整个项目升上去。Manifest 里至少要有录音权限。后台长时间录音还要考虑前台服务Android 14 之后麦克风前台服务类型要单独声明否则应用退到后台会被切断。uses-permission android:nameandroid.permission.RECORD_AUDIO / uses-permission android:nameandroid.permission.FOREGROUND_SERVICE / uses-permission android:nameandroid.permission.FOREGROUND_SERVICE_MICROPHONE /运行时权限不要只在onCreate里查一次。用户拒绝、系统回收、从设置页返回都要重新判断。RECORD_AUDIO没给就直接初始化 AudioRecord构造函数不会抛一个你能看懂的异常最后表现就是STATE_UNINITIALIZED或者读出来全是 0。if (ContextCompat.checkSelfPermission( this, Manifest.permission.RECORD_AUDIO ) ! PackageManager.PERMISSION_GRANTED ) { ActivityCompat.requestPermissions( this, arrayOf(Manifest.permission.RECORD_AUDIO), 1001 ) return }这段代码只做一件事没有权限就申请并提前返回不继续走采集初始化。实际工程里把1001换成自己的请求码在onRequestPermissionsResult里再决定是否启动采集线程。别在权限回调里直接startRecording先检查AudioRecord.getState()否则用户点拒绝也会看到一条“初始化失败”的误导日志。3.2 AudioRecord 采集线程环形缓冲和 10 ms 切帧采集线程只做三件事读满一帧、交给处理器、把结果写走。读的时候要处理read返回小于请求长度的情况不能假设一次read一定给你 160 个 short。下面是最小闭环先保证每帧完整再谈环形缓冲和低延迟。private const val SAMPLE_RATE 16_000 private const val CHANNELS 1 private const val FRAME_MS 10 private const val FRAME_SAMPLES SAMPLE_RATE / 1000 * FRAME_MS // 160 fun startCapture(ns: WebRtcNs, running: () - Boolean) { val minBytes AudioRecord.getMinBufferSize( SAMPLE_RATE, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT ) val bufferBytes maxOf(minBytes, FRAME_SAMPLES * 2 * 4) val record AudioRecord( MediaRecorder.AudioSource.MIC, SAMPLE_RATE, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT, bufferBytes ) if (record.state ! AudioRecord.STATE_INITIALIZED) { record.release() return } val input ShortArray(FRAME_SAMPLES) val output ShortArray(FRAME_SAMPLES) Thread { Process.setThreadPriority(Process.THREAD_PRIORITY_AUDIO) record.startRecording() while (running()) { var offset 0 while (offset FRAME_SAMPLES) { val n record.read(input, offset, FRAME_SAMPLES - offset) if (n 0) break offset n } if (offset FRAME_SAMPLES) continue val ret ns.process(input, output, FRAME_SAMPLES) if (ret 0) { // 这里写 AudioTrack、文件或环形队列不要在主线程做 IO } } record.stop() record.release() }.start() }FRAME_SAMPLES按采样率 / 1000 * 10算16 kHz 得到 160。bufferBytes用minBytes和FRAME_SAMPLES * 2 * 4取大值* 2是 PCM16 的字节数* 4是留 4 帧左右的余量。read的返回值是 short 个数不是字节数循环读满一帧再处理能避免半帧进 APM 导致帧长校验失败。Process.setThreadPriority(Process.THREAD_PRIORITY_AUDIO)是采集线程的常规做法能减少被普通后台线程抢占的概率。真正做播放和落盘时在采集线程与消费线程之间加一个ShortArray环形队列队列长度按延迟预算定不要用LinkedBlockingQueueShortArray每帧 new 一次GC 一抖就是爆音。3本文还有配套的精品资源点击获取