
简介这是从WebRTC开源项目中提取的音频降噪C语言实现源码包面向嵌入式、实时通信及音频处理开发者也适合希望理解语音活动检测与频谱降噪原理的初学者。压缩包共8个文件其中3个头文件、2个C源文件另有license、txt、md各1份h/c文件构成可编译的噪声抑制模块主体CMake配置与说明文档帮助梳理工程结构、依赖关系和编译方式整体仅47KB轻量且便于快速定位关键函数。目前已有1387人学习浏览作为开源算法学习包具有不错的参考价值。代码覆盖采样量化、滤波器设计、FFT频谱分析、噪声估计、语音活动检测等关键环节并给出跨平台编译配置与优化思路跟随源码可以看清噪声功率谱估计、增益计算与降噪输出的完整调用链路进而为自研或移植音频降噪功能提供直接可复用的工程参考。1. 项目定位把 WebRTC 降噪抠成独立 C 库值不值如果你跟我一样做过嵌入式音频或者 VoIP 终端多半翻过 WebRTC 的源码目标只为了一个把它那套音频降噪NS模块抠出来用。这份 C 语言开发代码做的就是这件事——把 WebRTC 里 fixed-point 版本的 NS 模块单独拆出来去掉 AEC、AGC、VAD 等一堆耦合保留“噪声估计 维纳滤波”的核心链路封装成几个纯 C 接口可以直接编进嵌入式工程或桌面音频采集程序。它解决的问题非常直接你的采集端有环境噪声、空调声、风扇声但又不想引一整套 C 音频处理框架那就用这个。适合读的人是对音频 DSP 有一定基础、想快速集成降噪能力的开发者。反直觉的一点是真正值钱的不是滤波那段数学而是噪声底估计和增益下限的工程细节这两个点决定了降噪后是“安静”还是“闷罐子”。2. 模块架构NS 的两套实现与数据流链路WebRTC 音频处理模块里其实存在两套 NS一套是较新的 C 浮点实现以Ns类为核心主要供给浏览器内实时链路另一套是早期就有的 fixed-point C 实现文件名以nsx为前缀用纯整数运算不依赖 C 运行时。这份资源取的是后者因为标题写得很清楚C 语言开发代码。fixed-point 版本在 8k/16k 采样率下用 128 点 FFT在 32k/48k 下切到 256 点所有内部状态都是int32_t没有 malloc 之外的动态分配非常适合裸机和 RTOS 环境。2.1 为什么选 fixed-point C 版本而不是新版的 C 类新版的 NS 效果好一些但它是一个完整的 C 类依赖absl、webrtc内部公共库抽出来要连带搬一大堆头文件和基类编译链非常痛苦。fixed-point 版本则不一样核心实现集中在nsx.c和nsx_noise_estimation.c两个文件里外部接口只有WebRtcNsx_Create/Init/Analyze/Process/Free这几个函数数据结构不透明调用方只需要持有NsxHandle*就够了。代价是内部用定点数模拟浮点运算增益曲线是查表实现的理论上有轻微量化误差但实际听感差异非常小。我一般在项目里用这个版本还有一个原因它没有隐式的 C 异常、没有std::vector这类容器内存布局是固定的方便计算峰值栈大小。对讲机、采音器、视频会议终端这类资源受限的设备编译器可以换平台可以换只要 C 编译器能过它就能跑。2.2 目录结构与源文件职责拿到这份源码之后首先要把文件归属搞清楚不然改起来会抓瞎。典型的目录组织如下ns_c_lib/ ├── api/ │ └── webrtc_ns.h # 对外 C 接口不透明句柄声明 ├── src/ │ ├── nsx.c # 主处理流程Analyze/Process 入口 │ ├── nsx_core.c # 频谱分析、SNR 计算、增益查表 │ ├── nsx_noise_estimation.c # 分位数噪声估计核心 │ ├── fft4real.c # 实数 FFT 定点实现 │ └── nsx_defines.h # 内部常数、频带划分宏 ├── examples/ │ └── denoise_app.c # 命令行 demo读 PCM 文件输出降噪后 PCM ├── CMakeLists.txt └── README.mdwebrtc_ns.h是唯一需要被你工程包含的头文件内部结构体全部藏在.c文件里这样换版本或者改内部实现时上层代码不用动。nsx_core.c是算法主力里面做了加窗、FFT、频带能量计算、后验/先验 SNR、维纳增益查表nsx_noise_estimation.c单独把噪声估计抽出来是为了方便你替换成自己的噪声跟踪算法。examples/denoise_app.c是最有价值的参考它展示了“读一帧、处理一帧、写一帧”的最小闭环。2.3 核心处理链路从 PCM 帧到降噪输出的数据流整个降噪处理不是一次性完成的它被拆成 Analyze 和 Process 两个阶段这一点非常关键。Analyze 阶段只做“听”计算当前帧的频谱能量更新噪声底估计输出一个语音活动性相关的中间状态Process 阶段才做“动”用上一阶段更新完的噪声估计去计算增益把增益乘到频谱上再逆变换回时域。这样的好处是噪声估计不会被当前帧的增益处理污染坏处是如果你忘了调用 AnalyzeProcess 就会拿上一次的噪声估计硬算效果会越来越糟。数据流是这样的输入的 16 位 PCM 帧10ms16kHz 下就是 160 个采样点先加一个分析窗做实数 FFT得到 64 个频点能量然后按 Bark 尺度或等宽频带做合并得到 32 个左右频带每个频带独立做分位数噪声估计算出后验 SNR再用上一帧的先验 SNR 做决策导向平滑最后按增益公式得到每个频带的抑制因子乘回原始频谱做 IFFT用重叠相加或截断补窗的方式得到输出帧。整条链路里没有任何 IIR 滤波器的相位问题因为全部在频域完成代价是固定 10ms 的算法延迟。3. 核心代码拆解噪声估计、SNR 平滑与维纳增益前面讲了架构这章落到代码上。我按初始化、分析、抑制、合成四段来讲每一段都摘出关键代码并解释参数。先声明一点为了博客展示我把源码里一部分查表逻辑简化成等价公式但函数名、调用顺序、输入输出语义与原工程保持一致。你拿到完整源码后再对照看会更容易理解。3.1 初始化句柄创建与采样率绑定的参数布局// api/webrtc_ns.h 中的对外接口摘录 typedef struct NsxHandle NsxHandle; // 创建降噪实例成功返回 0 int WebRtcNsx_Create(NsxHandle** handle); // 初始化fs 支持 8000/16000/32000/48000 int WebRtcNsx_Init(NsxHandle* handle, uint32_t fs); // 分析阶段更新噪声估计不修改音频 int WebRtcNsx_Analyze(NsxHandle* handle, const int16_t* speech_frame); // 处理阶段实际降噪同时处理主通道和扩展通道 int WebRtcNsx_Process(NsxHandle* handle, const int16_t* speech_frame, const int16_t* speech_frame_ext, int16_t* output_frame, int16_t* output_frame_ext); // 释放实例 void WebRtcNsx_Free(NsxHandle* handle);这里最需要理解的是speech_frame_ext和output_frame_ext这一对扩展通道参数。在 WebRTC 完整链路里它通常被用于同时处理 AEC 的参考信号让主通道和参考通道共享同一个噪声估计。如果你只做单通道降噪习惯做法是传入一块和speech_frame等长的空缓冲而不是传NULL因为内部会对扩展通道做同样的 FFT 和频谱搬移空指针会导致崩溃。我一般直接定义一个int16_t ext_buf[FRAME_LEN]初始化时清零每次调用原样传入。WebRtcNsx_Init内部做的事情比接口看起来多得多它要根据fs选择 FFT 点数初始化窗函数系数把频带映射表填好并把每一个频带的初始噪声能量设定成一个经验值。这里的经验值通常是1024这个量级对应 16 位采样下约 -30dB 的底噪。如果你在低信噪比环境中启动前面几百毫秒会有一段“收敛期”那是因为噪声估计正在从初始值向下调整。3.2 分析阶段能量计算与分位数噪声估计// nsx_noise_estimation.c 中噪声估计的简化实现 static void UpdateNoiseEstimate(NsxInst_t* inst, int band) { int32_t energy inst-spectrum[band]; int32_t noise inst-noise_est[band]; int32_t diff; // 当前帧能量低于噪声估计说明可能是噪声的谷底快速跟进 if (energy noise) { // 用 5% 的步长向下平滑避免单帧抖动导致噪声估计剧烈跳动 inst-noise_est[band] noise - ((noise - energy) 5); } else { // 当前帧能量高于噪声估计只用 1/128 的权重缓慢向上爬 diff energy - noise; inst-noise_est[band] noise (diff 7); } }这段代码是典型的“非对称跟踪”噪声估计向下快步长 1/32向上慢步长 1/128。为什么要这样设计因为语音的瞬态能量总是大于噪声如果向上跟得太快噪声估计会被语音峰值带跑导致 SNR 被低估语音段被误削反过来如果噪声突然变大向下跟随太慢又会造成噪声残留。原工程里在这里还嵌套了一个直方图分位数估计机制用来把“稳态噪声”和“短暂的非语音强能量”区分开核心思想是维护最近若干帧的能量直方图取 25% 分位数作为噪声底。上面的非对称平滑更像是它的快速修正项两者配合才能在噪声突变时既不反应过度也不反应迟钝。3.3 抑制阶段后验 SNR、先验 SNR 与增益计算// nsx_core.c 中 SNR 平滑与增益计算的简化代码 for (i inst-start_band; i inst-end_band; i) { // 后验 SNR 当前帧能量 / 噪声估计能量 post_snr[i] inst-spectrum[i] / inst-noise_est[i]; // 瞬时先验 SNR 后验 SNR - 1负数截断为 0 gammap post_snr[i] - 1; if (gammap 0) gammap 0; // 决策导向Decision-Directed平滑 // 0.98 是上一帧权重0.02 是当前帧权重 prior_snr[i] 0.98f * prior_snr[i] 0.02f * gammap; // 维纳滤波增益H xi / (1 xi) gain[i] prior_snr[i] / (prior_snr[i] 1.0f); // 增益下限保护防止语音间隙出现“水声” if (gain[i] 0.1f) gain[i] 0.1f; }post_snr和prior_snr是这段代码的灵魂。post_snr直接算出来但它方差很大如果直接拿它算增益语音段和噪声段会来回切换产生讨厌的“音乐噪声”。所以 WebRTC 用了决策导向方法把上一帧的prior_snr以 0.98 的高权重保留下来当前帧的瞬时估计只占 0.02。这个系数看起来不起眼但实际效果差别巨大0.98 意味着系统对增益变化很不敏感语音起始处会有轻微的“钝感”但换来了稳定的残差噪声。你在二次开发时如果想提高语音起始的清晰度可以把 0.02 调到 0.04代价是音乐噪声会变明显。增益下限 0.1 也是经验值。它保证即使在纯噪声段频谱也不会被完全清零保留一点点底噪听感上比“深井式静音”自然得多。如果你做的是语音识别前处理反而可以把这个下限压到 0.05因为识别引擎不关心听感只关心语音失真小如果做的是人耳听的会议系统0.1 到 0.15 更合适。3.4 合成阶段增益乘回频域与逆变换输出// 简化后的频域加权合成 for (i 0; i fft_len / 2; i) { real fft_buf[i * 2]; imag fft_buf[i * 2 1]; // 增益是按频带算的同一个频带内所有 FFT bin 用同一个增益 gain gain_table[band_of_bin[i]]; fft_buf[i * 2] (int16_t)(real * gain 15); fft_buf[i * 2 1] (int16_t)(imag * gain 15); } // 逆 FFT 到时域 WebRtcSpl_RealInverseFFT(inst-real_fft, fft_buf, output_frame);这里的细节在“频带到 FFT bin 的映射”。前面说过频带数远小于 FFT bin 数所以一个频带会覆盖若干个相邻 binband_of_bin[]这个数组就是用来查映射的。处理完之后逆 FFT 输出的 160 个采样点就是降噪后的帧。原工程还包含一个时域窗函数处理用来消除帧边界的不连续性不过在 128 点 FFT 和 50% 重叠加窗的配置下输出帧可以直接拼接。如果你自己改成了非重叠模式那必须在帧边界做交叉淡化否则会听到每 10ms 一次的“咯嗒”声。4. 编译与集成把降噪模块跑进自己的音频管线这一章直接落地。我假设你已经在 Linux 或者 macOS 上机器上有 cmake 和一个能用的 C 编译器。Windows 下用 MSVC 也差不多只是 CMake 生成器换一下。4.1 CMake 组织与编译命令cmake_minimum_required(VERSION 3.10) project(ns_c_lib C) set(CMAKE_C_STANDARD 11) set(CMAKE_C_FLAGS -O2 -Wall -Wno-unused-variable) add_library(ns_c STATIC src/nsx.c src/nsx_core.c src/nsx_noise_estimation.c src/fft4real.c ) target_include_directories(ns_c PUBLIC api) add_executable(denoise_app examples/denoise_app.c) target_link_libraries(denoise_app ns_c)编译步骤就四行mkdir build cd build cmake .. make -j4 ./denoise_app noise.wav clean.wav这里有个容易忽略的点denoise_app读入的输入文件必须是裸 PCM或者你需要在代码里跳过 WAV 头。原工程示例默认读裸的 16-bit 单声道 PCM我平时测试时先用ffmpeg把 wav 转成裸数据再喂给程序。命令行参数的含义很清楚第一个是含噪输入第二个是降噪输出采样率固定为 16kHz。4.2 调用示例16kHz 单声道 PCM 的完整处理流程// examples/denoise_app.c 的核心逻辑 #include stdio.h #include stdint.h #include webrtc_ns.h #define SAMPLE_RATE 16000 #define FRAME_LEN (SAMPLE_RATE / 100) // 10ms 160 samples int main(int argc, char* argv[]) { if (argc 3) { printf(usage: denoise_app input.pcm output.pcm\n); return 1; } FILE* fin fopen(argv[1], rb); FILE* fout fopen(argv[2], wb); if (!fin || !fout) return 1; NsxHandle* ns NULL; int16_t in[FRAME_LEN]; int16_t ext[FRAME_LEN] {0}; // 单通道模式的扩展缓冲 int16_t out[FRAME_LEN]; int16_t out_ext[FRAME_LEN]; WebRtcNsx_Create(ns); if (WebRtcNsx_Init(ns, SAMPLE_RATE) ! 0) { printf(init failed\n); return 1; } size_t read 0; while ((read fread(in, sizeof(int16_t), FRAME_LEN, fin)) FRAME_LEN) { // 顺序必须是 Analyze 在前Process 在后 WebRtcNsx_Analyze(ns, in); WebRtcNsx_Process(ns, in, ext, out, out_ext); fwrite(out, sizeof(int16_t), FRAME_LEN, fout); } WebRtcNsx_Free(ns); fclose(fin); fclose(fout); return 0; }代码逻辑很直白先创建句柄再初始化采样率然后逐帧fread、Analyze、Process、fwrite。特别注意的是ext缓冲区必须存在哪怕不用也要给一段可写的内存。Analyze和Process的调用顺序不能颠倒我在刚开始集成时就因为图省事只调Process结果降噪效果越来越差后来才知道噪声估计完全没有更新。参数层面SAMPLE_RATE宏在你换 48kHz 工程时改一下就行FRAME_LEN会跟着变。WebRtcNsx_Init内部会自动选择 FFT 长度不需要你手动配置。有一点要提醒如果你的音频采集线程不是严格的 10ms 周期调度Process可能会收到不足 160 个采样点的半帧。这种场景下你需要自己维护一个 FIFO 缓冲凑满一帧再送进去不要硬处理半个帧否则 FFT 窗口错位会引入频谱泄漏。4.3 集成到采集-播放环路时的注意点采集-播放环路跟离线文件的差异在于你不能一次处理一块很大的数据必须保持“采一帧、处理一帧、放一帧”的节奏否则延迟会积累。常见做法是在采集回调里做AnalyzeProcess然后把输出提交给播放缓冲。这个路径上我踩过两个坑第一采集回调的缓冲区大小通常按设备设定不一定是 160 采样点所以要在进模块之前做重采样和缓冲对齐第二有些平台的采集回调发生在高优先级线程里不能做耗时操作而 NS 的定点版本单帧处理在普通 ARM 上大约几百个微秒问题不大但如果你加了额外的重采样就得实测一下周期是否超限。如果你要把这个模块集成到现有的音频引擎里我的建议是把它包装成一个独立组件typedef struct { NsxHandle* ns; int sample_rate; int16_t ext_buf[480]; // 48k 下的单帧长度 } NoiseSuppressor; int noise_suppressor_init(NoiseSuppressor* ns, int sample_rate); int noise_suppressor_process(NoiseSuppressor* ns, const int16_t* in, int16_t* out, int samples);这样的包装好处是上层代码永远面对的是“输入一段、输出一段”的语义不用理解 Analyze 和 Process 的区别将来替换成别家的降噪库也容易。5. 常见问题与排查5 个真实踩坑记录这章把我在这类 C 语言 DSP 模块集成中见过、踩过的坑列出来。每一条都是“现象 → 原因 → 解决”三段式你可以直接对号入座。5.1 降噪后听到周期性“呼呼”声现象处理完的音频整体安静了但背景里有一种类似风吹麦克风的低频起伏声大约每秒几次。原因帧长与音频驱动的缓冲区大小不匹配。很多采集驱动一次给你 320 或 480 个采样点如果你直接当成一个“帧”喂进去内部的 10ms 对齐假设就失效了每帧实际包含 2 到 3 个窗口周期FFT 窗函数和增益在边界处产生调制噪声。解决不要偷懒在模块前面加一个跨线程的环形缓冲严格切出 160 采样点16k 下的帧再送入。我一般用容量为 4 帧的 ring buffer避免系统调度抖动导致下溢。5.2 输出声音发闷高频全没了现象语音能听懂但听起来像隔着棉被齿音和空气感完全消失。原因增益下限设得太低或者频带合并时把高频 bin 分得太宽。WebRTC 默认实现为了抑制稳态噪声高频频带的抑制权重偏大这在男声上不明显在女声和童声上特别容易暴露。解决如果你只针对人耳听感调优把高频频带的增益下限从 0.1 提到 0.15让 4kHz 以上的部分保留更多如果你做的是语音识别前处理别动增益下限优先检查识别引擎本身是否有自己的前端降噪两层降噪叠加才是发闷的真正原因。5.3 白噪声音量不降反升现象输入白噪声输出音量居然比输入还大而且听起来更像“金属声”。原因噪声估计还没收敛就硬算增益。在算法启动的最初几百毫秒noise_est是初始化填的经验值 1024如果输入噪声的实际能量远高于这个值后验 SNR 会被估得很低增益会被顶到接近 1等于没降当噪声估计慢慢爬升到位后增益才开始压低这段时间的输出反而有被动放大感。解决在正式运行前先喂 200 到 500ms 的纯环境噪声给Analyze但不取输出让噪声估计先收敛。如果你的产品是即开即用的那就把初始噪声经验值改成你实测环境底噪的量级我在设备上就这么干过启动效果立竿见影。5.4 双通道调用时内存越界现象程序偶发崩溃用 ASAN 查是堆缓冲区越界崩溃位置在WebRtcNsx_Process内部。原因speech_frame_ext指向了一块比FRAME_LEN更短的缓冲区或者指向了只读常量区模块内部会对它做读写而不仅是读取。解决给ext也分配和in完全相同大小的int16_t数组并且全程保持可写。如果你确实不需要扩展通道就单独准备一块静态区初始化清零每次调用传它别因为“不用”就传 NULL。5.5 多线程共享同一句柄导致效果时好时坏现象两个采集线程共用同一个NsxHandle降噪效果不稳定偶尔出现尖锐的爆破声。原因Analyze和Process内部都会读写同一个噪声估计状态这不是一个线程安全的操作两个线程同时推进时中间状态被互相覆盖噪声估计在语音段和噪声段之间反复横跳产生不连续的增益。解决要么在外部加锁把AnalyzeProcess组合成临界区整体保护要么干脆每个线程各自创建独立句柄互不干扰。我推荐后者因为音频线程通常不允许阻塞拿锁等待会增加抖动风险。6. 二次开发把降噪器做成带强度的可调模块最后一章直接给改造思路。前面说过固定版本内部用一个查表来映射 SNR 到增益这个表其实就是一套“抑制曲线”。二次开发最简单的切入点是把这个曲线做成可调的。// 在 NsxHandle 内部增加一个抑制强度字段 int WebRtcNsx_SetStrength(NsxHandle* handle, int level) { // level 0: 轻度抑制保听感增益下限 0.2 // level 1: WebRTC 默认增益下限 0.1 // level 2: 重度抑制适合语音识别增益下限 0.03 if (level 0) inst-gain_floor 20000; // Q15 中的 0.2 else if (level 2) inst-gain_floor 3932; // 0.03 else inst-gain_floor 13107; // 0.1 return 0; }调用时机放在Process之前或者运行时动态调整都行。因为gain_floor只影响增益计算不影响噪声估计所以运行期改它不会造成状态不一致这是这套设计里比较舒服的地方。如果想让用户能实时在界面上调节降噪强弱只需要把level绑定到一个滑杆回调上每 100ms 更新一次即可。我建议在修改后用一个固定的测试音频回归验证拿一段含环境噪声的语音分别用三个 level 输出听两遍再交付别只调一个档位就收工。验证方法上我一般分两步走。先用脚本算量化指标比如降噪前后的信噪比提升值和 PESQ 分数再人工盲听三组对比噪声残留、语音失真、音乐噪声这三项互相牵制量化指标往往反映不全。记得把输出文件的名字包含参数例如clean_level0.pcm、clean_level1.pcm不然改来改去到最后分不清哪个是哪个版本。从那以后我每次拿到一个 DSP 模块都会先做一个能回放对比的 demo 再谈调优希望帮到你。本文还有配套的精品资源点击获取