资讯详情

资讯详情

谱减法语音去噪实战:从原理到Python实现

做了五年语音处理回头发现最常被同事问起的还是谱减法。现在深度学习降噪方案满天飞但在实时嵌入式场景、资源受限的板卡上跑不动模型的时候很多谱减法依旧是那个朴实但管用的选择。这篇文章就完整记录一下“基础版谱减法去噪”的实现思路、核心公式、可直接跑的代码以及我在调参和工程化过程中踩过的坑。如果你正在做语音识别前端降噪、录音素材清理或者刚开始接触语音增强这篇内容可以直接当一份带注释的参考。谱减法的优点非常突出计算量小、原理直观、依赖的假设少几行代码就能跑出一个还能听的结果。但它的缺点也同样出名——处理完的语音容易带一种“音乐噪声”听起来像是水声或者回声这是算法本身的统计特性决定的并不是代码写错了。理解这一点是后面所有调参的基础。1. 谱减法为什么有效从时域难题到频域解题1.1 带噪语音的基本模型谱减法的一切出发点是下面这个被用了三十多年的加性噪声模型y(n) s(n) d(n)其中y(n)是麦克风采集到的带噪信号s(n)是纯净语音d(n)是加性噪声。所谓“加性”意思就是噪声和语音在时域上直接相加没有卷积、没有非线性、没有回声叠加。现实中很多场景都能近似满足这个模型比如白噪声背景、电脑风扇声、空调声、录音底噪这些都是加性噪声的典型代表。对这个模型做傅里叶变换得到频域表示Y(k) S(k) D(k)其中k表示频点。由于语音和噪声在统计上是互不相关的两者在功率谱层面可以直接叠加|Y(k)|² ≈ |S(k)|² |D(k)|²这个式子是整个谱减法的根基。它说的是带噪信号的功率谱约等于纯净语音的功率谱加上噪声的功率谱。那么只要我们能把噪声的功率谱估计出来用带噪信号的功率谱减去噪声功率谱剩下的就是纯净语音的功率谱。这就是“谱减法”这个名字的来源——它做的不是时域波形相减而是频域功率谱相减。1.2 为什么不在时域直接做减法一个自然的问题是既然噪声是加性的直接把带噪信号减去噪声波形不就行了问题是我们拿不到噪声波形。我们能拿到的只有带噪信号本身噪声混在语音里无法单独分离出来。但在频域这个情况有转机。噪声的功率谱在短时间内具备相对稳定的统计特性尤其对于平稳噪声比如风扇声、底噪它的功率谱在几百毫秒内变化不大。这意味着我们可以利用不含语音的“静音段”来估计噪声的功率谱然后用这个估计值去处理整段音频。另一个更实际的原因是语音信号在频域的能量分布非常稀疏且有规律。语音由基频及其谐波组成在频谱上表现为一个个竖条而噪声通常铺满整个频带。频域的减法可以针对每个频点独立调整增益时域的波形相减做不到这一点。1.3 相位为什么直接沿用带噪信号做频域处理的人都知道傅里叶变换得到的是复数包含幅度和相位两部分。谱减法只对幅度谱做运算相位直接使用带噪信号的原始相位不做任何修改。这个操作的依据是人耳对相位信息的敏感程度远低于对幅度信息的敏感程度。语音的可懂度和音质主要取决于短时幅度谱相位在一定误差范围内对听感影响不明显。这在语音增强领域被称为“相位无关性假设”也是为什么谱减法以及后来大量频域增强算法都直接复用带噪相位。实操中这个假设一直是成立的。只要帧长在合理范围20-30ms保留带噪相位引起的失真几乎听不出来。真正影响听感的是幅度谱减得好不好、参数合不合适。2. 基础版谱减法实操完整实现与参数解析2.1 分帧、加窗与FFT的配置处理连续语音之前需要把信号切成一帧一帧的短片段因为语音是非平稳信号但在20-30ms的短时范围内可以近似认为平稳傅里叶变换才有意义。帧长选择有一个经验区间10-30ms。取太短频域分辨率不够低频细节丢失取太长假设语音平稳就不成立了可能出现回声感。我测试过一批16kHz采样率的样本25ms是表现最稳的配置也就是一帧包含400个采样点。帧移通常取帧长的一半也就是10ms对应160个采样点。这样前后帧有50%的重叠。重叠的目的有两个一是补偿加窗造成的边缘能量衰减二是保证帧与帧之间的连续性避免恢复出来的波形出现块状跳变。窗函数一般选汉宁窗。汉宁窗的主瓣较宽、旁瓣衰减快适合语音信号处理。矩形窗虽然旁瓣窄但会产生严重的频谱泄漏实际工程中几乎不用。汉宁窗的代价是帧边缘被压低但重叠相加可以补偿回来。FFT点数方面帧长为400个点时做512点FFT即可多余的点补零。补零不会增加真实频率分辨率但能让频谱看起来更平滑也方便一些后续处理。2.2 噪声谱估计前导静音段的平均幅度谱基础版谱减法中噪声功率谱的估计方式是整个流程的基石。最常见的做法是取信号最前面的一段“前导静音段”假设这个时间段里没有语音只有纯噪声然后对这一段做分帧、FFT把所有帧的幅度谱取平均作为噪声幅度谱的估计。假设噪声帧数为N每帧FFT后的幅度谱为|D_i(k)|那么噪声幅度谱估计为|D_hat(k)| (1/N) Σ|D_i(k)|取平均这一步非常关键。单帧噪声幅度谱的随机起伏很大直接拿某一帧当噪声估计值会导致后续相减时出现大量随机残留。平均掉N帧之后噪声谱的方差大约降为原来的1/N稳定很多。通常N取5-10帧。这个方案成立的前提是音频开头确实有一段静音。录音时留1秒左右的静音是最佳实践。如果你的录音从头到尾都有语音没有任何静音段这个基础方案就失效了需要改用其他方法比如最小值统计法或者基于分位数的方法那就不再是“基础版”的范畴了。2.3 谱减公式与半波整流拿到了噪声幅度谱估计之后对每一帧带噪语音在每一个频点上做如下操作|S_hat(k)| max(|Y(k)| - α|D_hat(k)|, β|D_hat(k)|)其中α是过减因子β是谱下限系数。|Y(k)|是当前帧带噪信号的幅度谱|D_hat(k)|是噪声幅度谱估计值。这个公式比教科书里的基础形式多了一层保护。最原始的谱减法里相减结果小于0的部分直接置0也就是|S_hat(k)| max(|Y(k)| - |D_hat(k)|, 0)。但在工程实践中这样做有两个问题一是α1时减得不够干净残留噪声依然明显二是直接置0会让频谱上出现大量孤立的零值点听感上就是断断续续的“音乐噪声”。所以实际项目中基本都会引入α和β两个参数。α让减法更激进把残余噪声压得更低β给每个频点设置一个底线避免信号被减得太狠导致语音失真。α和β的取值需要配合调整这个话题在下一节专门展开。做完频率谱相减后配合带噪信号的相位做逆FFT得到增强后的时域帧再用重叠相加法合成完整波形。2.4 可以直接跑的Python参考实现下面给出一个完整的谱减法Python实现不依赖深度学习框架只需要numpy和scipy。为了可读性我尽量用显式循环而不是花哨的向量化写法方便理解每一步在做什么。import numpy as np from scipy.io import wavfile def load_wav(path): sr, data wavfile.read(path) if data.dtype np.int16: data data.astype(np.float32) / 32768.0 elif data.dtype np.int32: data data.astype(np.float32) / 2147483648.0 # 多声道取单声道 if data.ndim 1: data data.mean(axis1) return sr, data def spectral_subtraction(signal, sr, frame_len_ms25, frame_hop_ms10, noise_frames10, alpha1.5, beta0.01): # 参数换算 frame_len int(sr * frame_len_ms / 1000) frame_hop int(sr * frame_hop_ms / 1000) n_fft 1 (frame_len - 1).bit_length() # 向上取2的幂 # 分帧 n_frames int(np.ceil((len(signal) - frame_len) / frame_hop)) 1 window np.hanning(frame_len) frames [] for i in range(n_frames): start i * frame_hop end start frame_len if end len(signal): frame np.zeros(frame_len) frame[:len(signal) - start] signal[start:] else: frame signal[start:end] frames.append(frame * window) frames np.stack(frames) # FFT得到幅度谱和相位 spectrum np.fft.rfft(frames, nn_fft) magnitude np.abs(spectrum) phase np.angle(spectrum) # 用前 noise_frames 帧估计噪声幅度谱 noise_mag magnitude[:noise_frames].mean(axis0) # 逐帧谱减 enhanced_mag np.maximum(magnitude - alpha * noise_mag, beta * noise_mag) # 合成复数频谱保留带噪相位 enhanced_spec enhanced_mag * np.exp(1j * phase) # 逆FFT enhanced_frames np.fft.irfft(enhanced_spec, nn_fft)[:, :frame_len] # 重叠相加 out_len (n_frames - 1) * frame_hop frame_len output np.zeros(out_len) window_sum np.zeros(out_len) for i in range(n_frames): start i * frame_hop output[start:start frame_len] enhanced_frames[i] * window window_sum[start:start frame_len] window * window # 归一化消除窗函数带来的能量起伏 output np.divide(output, window_sum, outnp.zeros_like(output), wherewindow_sum 1e-8) return output sr, sig load_wav(noisy_input.wav) out spectral_subtraction(sig, sr) wavfile.write(enhanced_output.wav, sr, (out * 32768).astype(np.int16))这段代码我实测过在风扇噪声、空调噪声这类平稳噪声下降噪效果明显语音可懂度保持较好。代码里有一个细节需要注意重叠相加时我用了window * window做归一化权重这是因为每一帧都乘了汉宁窗重叠区域相加时需要用窗的平方和来归一化才能还原出正确的时域幅度。很多初学者在这里直接做简单平均结果波形出现周期性的“呼吸感”就是这个原因。3. 音乐噪声从哪来怎么压下去3.1 音乐噪声的听感特征谱减法处理完的音频经常会出现一种特殊的残留噪声。它不是平滑的白噪声而是听起来像一串串起伏的水声、金属声、或者“嗡嗡”的音乐片段因此被叫做音乐噪声musical noise。这种噪声在静音段尤其明显因为语音段的音乐噪声被语音本身掩盖掉了。我第一次跑通代码后满怀期待地听处理结果结果被这阵“水音”整得怀疑人生。后来才明白这不是代码写错了而是谱减法的统计本质决定的。3.2 音乐噪声的成因音乐噪声的产生有几个层面理解它才能真正压住它。第一个层面是噪声谱估计的方差。我们估计噪声幅度谱时做了平均但平均只能降低方差不能消除方差。真实的噪声幅度谱在每一个频点上都在随机波动。谱减法相当于在每个频点上硬性地减去一个固定值减完之后那些原本刚好高于噪声均值的地方变成了“幸存的小峰”那些低于均值的地方直接被砍成β或0。这些小峰在频谱上就是一个个离散的尖刺在听感上就成了音乐噪声。第二个层面是频点间的孤立性。语音信号的频谱在谐波之间有相对连续的过渡而谱减法造成的残留峰是随机分布的频点之间没有关联。听感上随机孤立峰比连续分布的噪声要刺耳得多。第三个层面是半波整流带来的非线性。把负数压成0或β会让频谱产生不连续性加剧音乐噪声的感知强度。3.3 过减因子与谱下限的配合压制音乐噪声最直接的工具就是α和β两个参数。α过减因子的作用是“砍得更深”。α越大每个频点被减掉的噪声越多残留的随机峰越少音乐噪声越轻。但α过大的代价是语音本身也被削掉出现“桶音”、发闷、甚至吃掉清辅音的问题。β谱下限的作用是“铺一个底”。β越大频谱中每个频点保留的底噪越高随机孤立峰相对于底噪的突起程度就越低听感更平滑但残留噪声也更大。β取0.01意味着每个频点至少保留噪声幅度估计值的1%这层低底噪虽然可见但人耳不太敏感可以在不显著损伤语音的前提下平滑听感。我的调参经验是α从1.0开始往上加每次加0.2β固定在0.01-0.02每调一次先用一小段静音部分听音乐噪声再用一小段语音部分听失真。α到1.5左右大部分平稳噪声场景都能获得不错的折中。如果再往上加语音失真会明显加剧听感上变得“遥远”和“单薄”。3.4 额外的平滑策略如果α和β调到极限还觉得音乐噪声刺耳可以再加两道平滑。第一道是噪声谱的时间平滑。估计噪声时不用简单平均而是用递归平滑|D_hat(k)| γ|D_hat(k)| (1-γ)|D(k)|γ取0.7-0.9这样当前帧的噪声估计会参考前一帧的结果整体更稳定每帧之间的随机起伏更小。第二道是对增强后的幅度谱做中值滤波。沿着频率轴做3-5点的中值滤波可以打掉那些孤立的残留小峰。代价是可能会模糊语音的谐波结构清辅音处的细节会损失一点。这个操作对音乐噪声的压制效果显著但做不做取决于你优先保音质还是保听感。我自己的工程习惯是先调α和β再做时间平滑最后才考虑频率中值滤波。能用参数解决的不动滤波器因为滤波器引入的信号损失更不可控。4. 工程实战效果评估与高频问题排查4.1 参数速查表下面这份参数表是我在不同场景下反复测试后的经验值可以作为起点再根据实际音频微调。参数典型值调整方向帧长16kHz下400点25ms噪音偏低频可适当加长但别超过30ms帧移160点10ms减小帧移能提升连续性但运算量增加窗函数汉宁窗一般不用换除非特殊需求FFT点数512跟随帧长向上取2的幂即可噪声帧数8-12帧静音段长可多取更稳α过减因子1.2-1.8音乐噪声重则调大语音失真则调小β谱下限0.005-0.02底噪声大取小值底噪轻取大值一组值得参考的组合是α1.5β0.01噪声帧数10。这个组合在多种平稳噪声下都能获得比较均衡的效果。4.2 高频问题定位与处理我在实际测试和使用者反馈中发现以下几个问题出现频率最高每个都有明确的排查思路。处理后的音频有“空洞感”或断续感。这个问题的根源是α过大部分语音帧的弱音段被过度衰减时域上听起来一卡一卡的。解决方法是降低α同时适当提高β给弱音段兜底。如果降α之后音乐噪声又冒出来就同时加强噪声谱的时间平滑而不是继续压α。噪声段残留一种“咝咝”的高频声。这种情况多发生在噪声估计帧数偏少噪声谱估计不够稳。把噪声帧数从5提高到12或者对噪声谱做时间平滑基本能解决。处理后语音发闷高频细节丢失。这通常是α在全频带统一取值导致的。高频段的语音能量本来就低和低频段用同样的过减因子高频被减得更狠。解决思路是改用分频带谱减法低频段α稍大比如1.5高频段α稍小比如1.0-1.2而不是所有频点一刀切。静音段反而出现“咕噜咕噜”的水声。这其实还是音乐噪声但出现在静音段说明噪声估计偏低实际噪声比估计的强相减后残余量过大。把噪声帧数增加或重新选择静音段范围通常能缓解。如果录音本身就是连续噪声且无静音段基础版谱减法无法解决需要用更高级的噪声估计方法。处理后的音量忽大忽小。这种情况在长音频上容易暴露。前面说过重叠相加需要用窗平方和归一化。如果归一化不当每一帧的能量恢复不一致就会在宏观上听到音量起伏。排查时先检查归一化逻辑再检查帧移是否和窗函数匹配。4.3 客观效果评估方法听感是最直接的评估手段但只有听感不够。在开发和调参过程中需要客观的指标来量化效果。信噪比SNR是最基本的指标。如果原始音频有干净的参考语音可以在降噪前后分别计算SNR看提升幅度。计算方法很简单SNR 10 * log10(语音能量 / 噪声能量)不过实际带噪录音里语音和噪声是混合的没有参考信号就没办法直接算。一个替代方案是使用分段信噪比也就是逐帧计算带噪信号能量和降噪信号能量在语音段的比值。频谱图对比是最直观的诊断工具。把降噪前后的语谱图画在一起重点看两点一是噪声频带的能量是否被压制下去二是语音的谐波结构是否保持完整。如果语谱图上语音谐波之间出现大量“麻点”说明音乐噪声严重如果谐波本身变模糊说明语音细节受损。更专业的指标有PESQ和STOI。PESQ评价语音质量STOI评价可懂度。如果手边有干净参考信号跑一下这两个指标能显著提升调参效率。PESQ提升范围一般在0.5-1.0之间算不错超过1.0在基础版谱减法里比较少见。4.4 从基础版向工程版的演进路径基础版谱减法只是一个起点真正落地到产品里还有大量可以演进的方向。第一个方向是Berouti谱减法。它把原来的减法形式改得更灵活通过引入多个参数控制每帧、每频点的过减强度对音乐噪声有更好的抑制效果计算量几乎不增加。第二个方向是多频带谱减法。把频谱按频率分成多个子带每个子带独立估计噪声、独立做减法各自设置过减因子。这种做法能很好地解决高频被过度衰减的问题适合宽带语音处理。第三个方向是改进噪声估计。用最小值统计法、分位数噪声估计或者MCRA方法替代前导静音段平均能够应对无静音段录音和噪声快速变化的场景。这些方法的计算负担也不大是性价比很高的升级。再往后就是维纳滤波、MMSE估计这类统计方法了。它们的假设更强、数学更复杂但降噪效果和音乐噪声控制都优于基础版谱减法。不过基础版的经验和代码框架完全可以迁移过去因为在这些算法里分帧、FFT、噪声估计、重叠相加这些核心工程环节是一样的。也就是说把基础版谱减法吃透再做更高级的算法会快很多。最后说几句调试心得做谱减法调参最容易犯的毛病是贪心。刚听到降噪效果时觉得惊艳就想把噪声减得更干净一路把α往上调结果语音越调越闷直到不可用才意识到过了头。现在我的习惯是每个参数每次只动一步记录下当前参数组合下的听感、SNR和语谱图特征再去微调下一个参数。这样虽然慢但每一步都清楚自己在干什么。一个很实用的小技巧是先在单独的一段纯语音和一段纯噪声上分别试参数听清楚语音失真的表现和噪声残留的表现再去处理混合音频。这个过程能帮你迅速建立参数变化对结果影响的直觉后面处理真实录音时会少走很多弯路。基础版谱减法不是最强的降噪算法但它是理解语音增强这一整条技术线的钥匙。框架吃透了后面你去看维纳滤波、统计模型甚至神经网络降噪的实现都会有一种“这个环节我认识”的熟悉感。这大概就是经典算法的价值所在。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →