做音频处理的人应该都有这个经历录完一版素材波形看着正常一戴上耳机全是“沙沙沙”的底噪。不是环境嘈杂就是麦克风本底噪声再好的内容都显得很业余。我前段时间写了一个实时降噪模块专门用来消掉这种录音底噪算法没上深度学习就是经典的频域增益控制CPU占用低、延迟可控跑起来非常稳。这篇文章就把整个项目的思路、算法、代码和实测调参过程完整拆开讲一遍。适合正在做语音录制、网络直播、播客后期、语音识别前处理的人也适合想在嵌入式或桌面端加一个实时降噪模块的开发者。看完你不仅能搞清楚底噪从哪来、为什么能消掉还能直接拿到一个可以跑的原型代码。1. 项目背景与实时降噪的技术定位1.1 录音底噪从哪里来很多人以为底噪就是“环境噪声”其实不完全是。环境噪声只是其中一类比如房间空调声、电脑风扇声、窗外车流声这些属于平稳或者缓变的背景噪声。更麻烦的是设备本底噪声前置放大器增益开大了以后即使周围很安静你也能听到那种细密的“咝咝”声这本质上是模拟电路的电子噪声。还有一种容易被忽略的是电源引入的周期噪声表现为50Hz到几百Hz的哼声或者以某几个“尖峰”形式出现在频谱上。这种噪声如果没处理好后续压缩、响度一提升就特别明显。综合来看录音底噪有这几个共性能量相对平稳、不随语音出现或消失而变化、分布在很宽的频带上但低频段往往更突出。这些共性恰恰是频域降噪可以下手的基础。1.2 “实时”到底意味着什么我们要做的是“实时降噪模块”不是离线把一整段录音跑一遍再输出。所谓实时有两个层面第一是延迟确定。音频驱动会按照固定块大小回调比如采样率16kHz时一块256个采样点就是16ms。你的处理模块必须在下一个回调到来之前处理完当前块否则就会出现丢帧、卡顿。实时不等于算得快而是延迟是可预测、有上限的。第二是逐块处理。每来一块数据就要完成噪声估计、增益计算、频域滤波、时域拼接这一整套流程。所以模块内部可以有一些状态比如上一帧的信噪比估计、噪声功率谱但不能依赖完整音频文件。这个约束直接影响算法的选型。1.3 方案选型为什么先不上 AI 模型这几年基于深度学习的降噪确实效果好但放到“实时录音模块”这个场景里我一上来就否掉了。原因很简单模型推理延迟不确定。一个RNN或Transformer帧长、上下文、模型计算量叠起来很容易超过50ms在会议、直播场景下人耳已经能察觉声音“慢半拍”。模型参数大也是一回事迁移到嵌入式平台要自己处理量化、算子优化项目周期完全不可控。经典算法里的谱减法、维纳滤波在频域做增益控制最大优势是延迟非常低FFT一帧32ms输出时再叠加总体延迟也就几十毫秒而且算法复杂度可预期。对平稳底噪比如录音底噪这种场景压制效果非常明显。所以最终选型就是以短时傅里叶变换为框架用维纳滤波和噪声谱估计实现一个实时模块。2. 核心算法拆解噪声估计、增益计算与平滑策略2.1 谱减法最直接的频域去噪先看一眼谱减法。假设带噪信号y等于干净语音s加噪声n并且语音和噪声不相关那么在功率谱上就是加法关系P_y P_s P_n如果我能估计出噪声功率谱P_n那么干净语音的功率谱近似为P_s P_y - α·P_nα是抑制强度通常取1.0到1.2。得到P_s之后用带噪信号的相位因为人耳对相位不敏感重建时域信号。实际工程上很少直接用功率相减更多是转成增益函数G(k) sqrt( max(1 - α·P_n(k)/P_y(k), floor) )这个形式的含义是某个频点上信噪比越低增益越小噪声比信号还强的频点就把增益限制在floor上比如-20dB而不是直接置零。置零会产生突兀的“音乐噪声”听起来像一阵一阵的流水声非常难听。2.2 维纳滤波形式从硬判决到软判决增益谱减法的“硬”体现在它会瞬间把增益切得很低这就有音乐噪声的问题。更稳的做法是用维纳滤波的增益形式G ξ / (1 ξ)其中ξ是先验信噪比语音存在时信号与噪声的功率比。当用户没有单独的先验信息时可以用decision-directed方法递推估计ξ(l) a·(G(l-1)·γ(l-1)) (1-a)·max(γ(l)-1, 0)这里的γ是后验信噪比等于带噪信号功率除以噪声功率a一般取0.9到0.98。它的核心思想是当前帧的先验信噪比一部分继承上一帧的结果一部分由当前帧的后验信噪比修正。这个递归关系让增益在时间轴上变得平滑避免了增益突变也就从根源上削弱了音乐噪声。2.3 噪声底估计最小值统计与条件更新噪声功率谱P_n是整个算法的基石。如果噪声估大了语音会被削得像在水里说话估小了底噪又压不干净。工程上常见几种方法起始静音段估计假设录音开头有几百毫秒静音直接用这段时间的功率谱均值作为初始噪声。最小值统计法跟踪一段时间内每个频点的功率最小值。因为语音是断续的频点在“只有噪声”的瞬间会出现低值这个低值接近真实噪声谱。条件更新法用一个语音存在概率做门限只有判定为“大概率是噪声”的帧才更新噪声谱。我的模块采用“初始静音估计 条件更新”组合。初始化时先累计前若干帧的功率作为噪声底运行过程中计算后验信噪比如果某个频点的后验信噪比很低就用当前功率对它做缓慢更新。这样既保证了平稳底噪的跟踪又不会把语音尾巴误当成噪声吃进去。2.4 关键参数为什么这么定这一套算法的核心参数并不复杂但牵一发动全身。我最终选用的参数大概是FFT长度51216kHz采样下约32ms帧移25650%重叠抑制强度α取1.0增益下限floor取-20dBdecision-directed平滑系数a取0.95噪声谱更新学习率取0.05FFT帧长不能太小太小了频域分辨率差低频底噪分不开也不能太大否则一是延迟高二是噪声不再是平稳的语音也会被当成噪声。512点是比较折中的选择。帧移选50%重叠主要为了重叠相加时能完美重建信号不需要额外设计复杂的合成窗。增益下限先不要设太低我试过-32dB确实压得更干净但人声的“空气感”也没了最后定在-20dB。3. 代码实现一个具备实时能力的降噪模块3.1 模块接口与数据结构我的实现用了Python和numpy做原型。虽然最终落地到低延迟场景我会把它改写为C或Rust但算法逻辑完全一致Python原型最方便调参。模块对外接口非常简单就一个process方法每次传入一个float32的PCM块返回同样长度的降噪后PCM。模块内部需要维护几块状态输入缓冲、输出累积缓冲、噪声功率谱、先验信噪比、上一帧增益。这些状态必须常驻内存因为实时处理时不能假设可以“重来一遍”。import numpy as np class RealtimeNoiseReducer: def __init__(self, n_fft512, hop256, sample_rate16000, alpha1.0, gain_floor_db-20.0, init_noise_frames20, noise_learn_coef0.05): self.n_fft n_fft self.hop hop self.sr sample_rate self.win np.hanning(n_fft).astype(np.float32) self.input_buf np.zeros(n_fft, dtypenp.float32) self.output_acc np.zeros(n_fft, dtypenp.float32) self.bins n_fft // 2 1 self.alpha alpha self.gain_floor 10 ** (gain_floor_db / 20.0) self.noise_psd np.ones(self.bins, dtypenp.float32) * 1e-6 self.init_noise_frames init_noise_frames self.init_frame_count 0 self.noise_learn_coef noise_learn_coef self.xi np.ones(self.bins, dtypenp.float32) self.prev_gain np.ones(self.bins, dtypenp.float32)3.2 分帧、加窗与重叠相加音频回调给进来的数据块默认是16000Hz、16bit的PCM先转成float32范围在-1到1之间。模块内部做50%重叠分帧把新来的256点放进input_buf的后半段把原来的后半段移到前半段这样input_buf就组成了一帧完整的512点。def process(self, x_in): assert x_in.shape[0] self.hop x x_in.astype(np.float32) self.input_buf[:self.n_fft - self.hop] self.input_buf[self.hop:] self.input_buf[self.n_fft - self.hop:] x frame self.input_buf * self.win spec np.fft.rfft(frame, nself.n_fft) mag np.abs(spec) pxx mag ** 2 phase spec这里我保留了复数频谱后面直接用复数频谱乘增益这样IFFT之后得到的就是带相位的时域块。加窗用的是Hann窗50%重叠下它的滑动和是常数所以后面重叠相加时不需要再做复杂的归一化处理。3.3 增益计算与实时状态更新每次拿到当前帧功率谱pxx后先做噪声初始化或运行时噪声更新。初始化阶段不做降噪只是累计噪声功率谱初始化完成后进入正式处理流程。if self.init_frame_count self.init_noise_frames: # 开头若干帧假设没有语音用于估计噪声底 self.noise_psd 0.5 * self.noise_psd 0.5 * pxx self.init_frame_count 1 gain np.ones(self.bins, dtypenp.float32) else: noise_psd_safe np.maximum(self.noise_psd, 1e-12) # 后验信噪比 post_snr pxx / noise_psd_safe post_snr_m1 np.maximum(post_snr - 1.0, 0.0) # decision-directed 递推先验信噪比 self.xi ( 0.95 * (self.prev_gain ** 2) * post_snr 0.05 * post_snr_m1 ) self.xi np.maximum(self.xi, 1e-4) # 维纳滤波增益 gain self.xi / (1.0 self.xi) gain np.maximum(gain, self.gain_floor) # 增益平滑进一步抑制音乐噪声 gain 0.7 * gain 0.3 * self.prev_gain self.prev_gain gain # 条件更新噪声谱后验信噪比很低的频点才更新 update_mask (post_snr 2.0) self.noise_psd[update_mask] ( (1.0 - self.noise_learn_coef) * self.noise_psd[update_mask] self.noise_learn_coef * pxx[update_mask] ) spec_filtered phase * gain y_frame np.fft.irfft(spec_filtered, nself.n_fft) self.output_acc[:self.hop] y_frame[:self.hop] out self.output_acc[:self.hop].copy() self.output_acc[:self.n_fft - self.hop] self.output_acc[self.hop:] self.output_acc[self.n_fft - self.hop:] 0.0 return out.astype(np.float32)后验信噪比低于2.03dB判定为噪声主导频点只有这些频点才参与噪声谱更新。这是一个很粗的语音存在概率判断但配合噪声谱的缓慢学习率已经能应付多数平稳底噪场景。增益平滑用了一阶低通0.7当前增益加0.3上一帧增益实测能把“嘶嘶的喘息感”压下去不少。3.4 延迟估算与资源占用这个模块的算法延迟主要来自两处FFT分析窗需要攒够一帧512点所以输入侧有最多512点延迟输出重叠相加时要等窗口的贡献完全覆盖实际需要补n_fft减去hop的填充也就是256点。总延迟大约在768个采样点即16kHz采样率下约48ms。这个延迟对录音监听、网络直播来说是可接受的不会感觉“声音跟不上嘴”。如果想让延迟再低一点可以把FFT长度缩到256但低频噪声分离会变差。我的建议是先保持512等功能确定后再逐步缩减测试。CPU占用方面512点FFT在一颗普通桌面CPU上消耗几乎可以忽略单次处理不到0.1ms。即使在树莓派这类嵌入式平台上用C语言重写一遍配合定点和预分配内存也完全能跑在16kHz实时回调里。瓶颈反而不在FFT而在numpy这类Python层的分配和拷贝开销落底时要注意。4. 接真实音频流测试、调参与问题排查4.1 离线仿真先跑通我不会直接把模块接到麦克风上调试那样问题复现太难。第一步永远是用离线数据仿真找一段干净语音混入一段采集好的底噪比如用手机录音时风扇声音然后用模块逐帧处理比较输出波形和频谱。这一步能验证两个问题噪声是不是被压下去了语音是不是还完整。我通常会计算处理前后的信噪比提升然后听一下处理后的音频有没有“断字”、有没有音乐噪声。如果离线这关过不了上真机只会更痛苦。4.2 实际录音中的调参记录离线验证通过后我把模块接到麦克风输入流上用扬声器播放播客节目做实测。记录几个值得说的点第一次实测时即使周围很安静切歌或翻页的瞬态声音也会被放大。这是因为瞬态信号频谱很宽噪声估计器来不及区分增益被瞬间提到很高。解决方法是把增益平滑系数调低一点让增益变化速度跟上语音包络但不要跟瞬态噪声。还有一次发现人声发闷像隔着一层布。查下来是我把噪声初始估计帧数设得太长而录音开头刚好有人说了句话噪声功率被高估导致中低频语音被误伤。后来我把初始噪声估计帧数调小并且提醒用户录音前留一秒空档问题就消失了。4.3 常见问题速查表现象常见原因解决建议人声发闷、不清晰噪声估计偏大或增益下限太低降低初始噪声累计帧数gain_floor从-20dB往-15dB调有“水下呼噜”般的音乐噪声增益跳变太快加大decision-directed平滑系数a或提高增益平滑系数语音尾巴被削掉噪声谱更新过快把语音尾音当成噪声调低noise_learn_coef增加语音存在概率门限咔哒爆音增益在某几个频点突变对增益做频域平滑限制相邻频点增益差实时回调卡顿处理函数里有内存分配或FFT库不稳定预分配所有数组避免在回调里做new/malloc底噪消不干净噪声估计滞后或抑制强度不够调高α到1.2检查噪声谱初始化是否准确4.4 音频实时处理的一些通用心得我在多次调参后形成了一套自己的套路。先关掉所有平滑机制把alpha调到1.0gain_floor调到-20dB听一版最“激进”的效果。这时候往往能听出噪声压得最狠但音乐噪声也会特别明显。然后再逐步加大平滑系数和decision-directed递推权重一点点把音乐噪声降下来。这个过程的本质是在“噪声压制量”和“语音失真度”之间找平衡点。没有任何参数能同时做到底噪完全消失、人声完全无损。每次调整都只改一个参数改完就离线跑一遍同一段素材对比波形和听感。不要凭感觉一次改五个参数不然永远定位不了问题。5. 一些额外建议与后续扩展思路这套模块做到后面已经不只是“消底噪”了。我把条件更新改成带简单VAD判断之后在语音识别前处理里的价值很大。把带噪录音跑一遍模块再送进识别引擎误识别率明显下降尤其在冰箱压缩机、空调这类平稳噪声的环境下。强烈建议有类似需求的人先做一个离线脚本验证效果再考虑上实时链路不然排查问题的时候你会非常崩溃。后面我计划继续扩展这些方向一是把噪声估计换成更鲁棒的MCRA算法应对非平稳噪声二是把模块用C重写一遍做一个OpenMP或SIMD优化版三是把这套逻辑封装成VST插件直接在录音软件里挂载使用。核心技术还是同样的频域增益框架只是工程外壳不同而已。如果你也想做一个类似的实时降噪模块我建议从这套代码开始先把离线仿真跑通再按自己的硬件平台和延迟需求调整FFT长度和参数。底噪问题看着玄乎拆开也就是“估计噪声、算增益、滤波重建”这三件事。