资讯动态

谱减法去噪基础版:从原理到代码的语音增强入门实践

发布时间:2026/10/14 0:13:38 来源:尧图企业网站定制
音频去噪是个越做越有意思的方向而且我越来越觉得入门语音增强最好的路径不是直接堆模型而是先把经典的谱减法跑通一遍。最近我把“谱减法去噪基础版”从算法到代码完整实现了一遍读音频、分帧加窗、FFT、噪声估计、谱减、逆变换、重叠相加每一步都用最朴素的写法跑通。这个基础版不依赖任何训练数据不需要GPU参数也就三四个但对平稳噪声的处理效果已经相当可观。这篇内容就围绕谱减法去噪基础版来展开先讲清楚它适合什么场景再解释每个参数为什么会起作用最后把我实际跑通时遇到的音乐噪声、过减失真、噪声估计不准这些问题一一记录下来。适合刚入手语音增强、想做音频预处理或者想用简单方案先验证效果的开发者参考。1. 谱减法去噪基础版信号模型与适用边界1.1 加性噪声模型为什么带噪语音可以拆成两项谱减法建立在一个非常朴素的假设上采集到的带噪信号 y(t)是纯净语音 x(t) 和背景噪声 n(t) 直接相加的结果也就是 y(t) x(t) n(t)。这种噪声被称为加性噪声。为什么它成立因为话筒拾取到的各种背景声比如风扇声、空调声、白噪声、环境嗡嗡声在物理上本来就是电压信号的线性叠加。语音信号和噪声之间没有乘法关系也不存在卷积关系所以可以直接拆成两项。在频域里傅里叶变换同样是线性变换所以对带噪信号做短时傅里叶变换后每一帧的频谱 Y 可以写成 X N。但这里有个关键问题谱减法做的是幅度谱相减而不是复数谱直接相减。原因在于语音的短时相位人耳并不敏感而幅度谱对听感的影响很大。于是谱减法的思路变成用带噪幅度谱减去估计出来的噪声幅度谱得到的差值作为纯净语音幅度谱的估计再直接借用带噪相位恢复出信号。这个思路简单得有点“粗暴”但它实实在在地解决了问题而且计算量极低。要理解谱减法先记住一句话它处理的是幅度谱不碰相位。这也是它和维纳滤波、MMSE这类统计方法最大的区别之一。1.2 为什么先学谱减法而不是直接上深度模型我见过不少刚入门的开发者一上来就打算用深度学习做语音增强结果卡在数据集清洗、模型训练、显卡资源、推理速度这些环节上很久都跑不出一个能用的结果。谱减法的价值不是替代深度学习而是用最小成本把“频域增强”这件事讲清楚。首先谱减法没有训练过程。只需要一段带噪语音和从带噪语音里估计出来的噪声谱就能直接输出增强结果。它的所有行为都围绕三四个可解释的参数展开比如过减因子、噪声地板系数、帧长、帧移。这些参数都能在听感上直接反映出来非常方便做实验对比。相比之下深度模型的参数不可控出了问题往往只能从数据和训练策略上排查门槛高很多。其次谱减法是很多进阶算法的基础。你在谱减法里理解清楚的东西比如噪声谱估计、语音存在概率、增益函数平滑后面学维纳滤波、最小统计量控制、甚至基于掩码的深度增强方法都能直接迁移过去。基础版谱减法不是终点但它是一个非常稳固的起点。1.3 基础版能应付的噪声类型与边界虽然谱减法看起来万能但基础版的实际适用范围有明显边界。它最适合的是平稳噪声和缓变噪声比如空调运行声、风扇声、电脑机箱散热声、白噪声、持续电机声。这类噪声的特点是统计特性在几百毫秒内基本不变所以只用前几百毫秒估计一次噪声谱就能覆盖整个文件。但如果是敲键盘声、关门声、鼠标点击声、餐具碰撞这类瞬态噪声谱减法基础版几乎无能为力。因为瞬态噪声发生在一两个帧内噪声估计来不及更新而且它和语音在时域上常常重叠幅度谱相减之后残留很多。强非平稳噪声比如人声背景、车流中突然的鸣笛也会让固定噪声谱失效。另外如果输入是立体声基础版通常只做单通道处理通道间相关信息无法利用效果也会打折扣。所以拿到一个去噪任务第一件事不是写代码而是先判断噪声类型。如果能确定是平稳噪声谱减法基础版完全够用如果噪声很跳那需要考虑噪声跟踪算法或者直接换成数据驱动方案。2. 基础版技术选型为什么只用 Python 和 NumPy 就够了2.1 选型的两个基本原则可复现、易排查基础版谱减法在技术选型上我不建议引入太重的依赖。很多教程会让你直接装某个音频处理库一条命令搞定 STFT但我更推荐只用 Python 标准库配合 NumPy、SciPy 这类最基础的库来做。原因有两个一是可复现性强核心代码不依赖黑盒函数换一台机器只要装同样版本的 NumPy 就能跑二是易排查所有中间结果都是 NumPy 数组随时可以打印维度、检查每一帧的值方便搞清楚算法到底在哪里出问题。有人会觉得反正用某个库也就是调用一下 STFT为什么不用这里有个隐患很多音频库自带归一化、自带窗函数补偿甚至在逆变换时做了不同风格的窗处理。一旦输出效果不对你很难判断是算法问题还是库的隐藏逻辑问题。基础版的意义在于学习核心流程所以尽量把每一步都暴露出来。2.2 模块划分一版最小可跑通的处理流水线基础版谱减法可以拆成六个模块音频读取把 wav 文件读成 float 类型的单通道数组。分帧加窗按帧长和帧移把数组切成一帧一帧的短信号乘上窗函数。短时傅里叶变换对每一帧做 FFT取出幅度谱和相位。噪声估计取前导静音帧估计平稳噪声的幅度谱。谱减处理对每个频点做带负值下限的幅度谱相减。逆变换与重叠相加用估计幅度谱配上原相位重建信号。这个流程看起来很长但每一步都只涉及几十行代码。模块之间最好用函数隔离比如load_audio、stft、noise_profile、spectral_subtract、istft_overlap_add这样后面想换参数、换增强策略只需要改中间一层。2.3 数据准备没有带噪样本时怎么自建测试集很多刚接触语音增强的朋友首先遇到的问题是没有带噪音频。其实不需要去网上找现成的混合数据自己合成非常快。用一段干净语音比如某个开源语音库里的 10 秒句子再截取一段纯噪声按目标信噪比混合就能生成测试集。信噪比的定义是信号能量与噪声能量之比公式写成 dB 就是 10 乘以 log10(两者能量比)。比如想要 0 dB 信噪比就把噪声调整到和语音总能量一样想要 10 dB噪声能量就要比语音能量小 10 倍。实际操作时可以先算语音段的能量均值再算噪声段的能量均值用缩放系数把噪声调到目标信噪比最后相加。混合出来的数据既知道真实答案又能方便对比去噪前后的差异。合成噪声时一定要给原始语音和噪声之间留出一点纯静音区间否则后面做噪声估计时很难找到一个完全干净的噪声参考段。3. 谱减核心流程逐段拆解从分帧到重叠相加3.1 分帧、加窗和STFT离散处理和连续信号的差语音是非平稳信号直接对整个文件做一次 FFT 没有意义因为频谱会随时间变化。解决方法是分帧把信号切成几十毫秒的小段每段内近似平稳。常用配置是帧长 25 毫秒、帧移 10 毫秒。比如 16 kHz 采样率下一帧就是 400 个采样点帧移 160 个点相邻帧之间重叠 60%。分帧之后不能直接做 FFT必须先乘窗函数。不乘窗的话帧边界处会突然截断导致频谱泄漏也就是在真实频点附近出现大量虚假频点。我习惯用汉宁窗它的主瓣和旁瓣兼顾得比较好。汉宁窗在两端接近于零能有效压低边界不连续带来的泄漏。重叠和窗函数是配套的汉宁窗配 50% 重叠满足重叠相加的重建条件所以这个组合在谱减法里最常见。以下是我在基础版里用的分帧加窗代码import numpy as np from scipy.io import wavfile fs, raw wavfile.read(noisy.wav) if raw.dtype np.int16: y raw.astype(np.float32) / 32768.0 else: y raw.astype(np.float32) if np.max(np.abs(y)) 1.0: y y / 32768.0 frame_len int(0.025 * fs) hop int(0.010 * fs) n_frames 1 (len(y) - frame_len) // hop win np.hanning(frame_len).astype(np.float32) frames np.zeros((n_frames, frame_len), dtypenp.float32) for i in range(n_frames): start i * hop frames[i] y[start:start frame_len] * win注意最后如果不足一帧标准做法是补零。补零会让最后一帧幅度偏低但因为只有一帧听感上几乎察觉不到。分帧完成后对每一帧做 FFT。由于信号是实数可以用np.fft.rfft它只返回正频率部分假设帧长 400 点rfft 结果就是 201 个复数点。取出幅度谱和相位幅度谱用于谱减相位谱原样保留。3.2 噪声估计基础版为什么敢只取前 0.2 秒谱减法最依赖的一步不是减法本身而是噪声估计。如果噪声估高了语音会被削掉一大片如果估低了残留噪声又很明显。基础版做了一个强烈假设音频最前面有一段纯噪声没有语音。这在录音场景里通常是成立的因为说话人不会在录音刚启动的 0.2 秒内立刻开口。因此基础版的噪声估计就是把前几帧的幅度谱平均得到一维噪声幅度谱。比如帧移 10 毫秒0.2 秒就是 20 帧。用平均而不是单帧是为了降低单帧随机波动带来的估计误差。平均后得到的噪声谱可以看作这个环境下的稳态噪声幅度基准。这里也可以做一点小增强如果前 0.2 秒混入了微弱语音直接用均值会把噪声谱抬高导致语音段过减。一种改进是取最小值或分位数比如取前 20 帧各频点的最小值作为噪声谱能明显降低偶发语音污染的影响。基础版里我建议先用平均等后面出现问题再升级成分位数。3.3 谱减公式、过减因子和噪声地板三个参数一把梭谱减的核心操作可以概括成这样对每一帧的每一个频点从带噪幅度谱里减去噪声幅度谱的若干倍。如果差值小于某个最小值就用一个下限兜底避免出现负数。用公式写就是估计幅度 max(带噪幅度 - alpha * 噪声幅度, beta * 噪声幅度)其中 alpha 是过减因子beta 是噪声地板系数。为什么不能直接减一个噪声幅度因为噪声估计不会完全准确实际噪声功率在每一帧都会有波动。如果只减一份噪声有些频点会没减干净残留噪声听起来像背景里的小杂音。alpha 大于 1相当于故意多减一些把噪声压得更彻底。但多减会带来新的问题语音频点也可能被削光产生失真。所以 beta 负责兜底把每个频点的最低响应限制在噪声地板之上。这个下限既能避免负幅度导致的重建异常又能用一层低电平噪声掩蔽掉谱减后出现的小尖刺也就是音乐噪声。在实际调参时alpha 一般取 1 到 3 之间。噪声强、信噪比低的时候alpha 适当加大噪声较弱时alpha 保持在 1.5 左右就够了。beta 我习惯取 0.05 到 0.1太小会出音乐噪声太大会让底噪明显。谱减参数不是越大越好。alpha 越大噪声压得越狠但语音也被削得越狠。好的参数应该让噪声低、语音保留、音乐噪声可接受三者取平衡。3.4 相位恢复与重建为什么可以“借”带噪相位谱减法只修正幅度谱相位继续使用带噪信号的相位。这个决定看起来很不严谨但语音增强里确实普遍这么做。原因是人耳对短时相位误差的敏感度远低于幅度误差尤其在帧长较短时相位失真对可懂度的影响较小。真正决定音质上限的其实是幅度谱的准确程度。重建流程大致如下将估计出来的幅度谱和原始带噪相位组合成复数谱做逆 FFT 得到增强后的时域帧再通过重叠相加还原成完整信号。重叠相加时每帧都已经乘过分析窗重叠部分会有多帧叠加所以通常要再乘一个合成窗做幅度补偿或者直接利用汉宁窗 50% 重叠的常数重叠相加性质。def istft_overlap_add(spectra, fs, frame_len, hop, win): n_frames spectra.shape[0] out_len (n_frames - 1) * hop frame_len out np.zeros(out_len, dtypenp.float32) win_sum np.zeros(out_len, dtypenp.float32) for i in range(n_frames): start i * hop frame_t np.fft.irfft(spectra[i], nframe_len).real out[start:start frame_len] frame_t * win win_sum[start:start frame_len] win * win # 避免除零 win_sum[win_sum 1e-8] 1.0 out out / win_sum return out这个实现里win_sum累积的是窗的平方因为每个时域样本被多个帧的窗重复加权归一化后正好可以还原。如果你用 50% 重叠汉宁窗理论归一化值是一个常数但保留累积和归一化的写法更通用后面换窗函数不用重写逻辑。4. 从零复现完整代码、参数标定和客观效果4.1 环境准备与最小依赖清单基础版谱减法对环境要求很低只要 Python 3.8 以上装上 NumPy、SciPy、matplotlib 就够了。音频读取我用 SciPy 的 wavfile简单可靠如果后续要处理 mp3、flac 这类格式可以再补一个 soundfile 库。安装命令很简单pip install numpy scipy matplotlib soundfile实际运行时不需要 GPU不需要训练框架甚至一台普通笔记本就能同时跑几十个文件的批处理。这也是谱减法最有吸引力的地方。4.2 完整代码骨架语音增强函数怎么组织把前面的模块串在一起我封装了一个函数输入是带噪信号和采样率输出是增强后的信号def spectral_subtract(y, fs, frame_len_sec0.025, hop_sec0.010, alpha2.0, beta0.08, noise_sec0.2): frame_len int(frame_len_sec * fs) hop int(hop_sec * fs) win np.hanning(frame_len).astype(np.float32) n_frames 1 (len(y) - frame_len) // hop frames [] phases [] mags [] for i in range(n_frames): start i * hop seg y[start:start frame_len] if len(seg) frame_len: seg np.pad(seg, (0, frame_len - len(seg))) spec np.fft.rfft(seg * win) mag np.abs(spec) phase np.angle(spec) frames.append((start, seg.copy())) mags.append(mag) phases.append(phase) mags np.asarray(mags) phases np.asarray(phases) noise_frames max(1, int(noise_sec / hop_sec)) noise_mag np.mean(mags[:noise_frames], axis0) est_mag np.maximum(mags - alpha * noise_mag, beta * noise_mag) est_spec est_mag * np.exp(1j * phases) est_frames np.asarray([np.fft.irfft(est_spec[i], nframe_len).real for i in range(n_frames)]) out_len (n_frames - 1) * hop frame_len out np.zeros(out_len, dtypenp.float32) win_sum np.zeros(out_len, dtypenp.float32) for i in range(n_frames): start i * hop out[start:start frame_len] est_frames[i] * win win_sum[start:start frame_len] win * win win_sum[win_sum 1e-8] 1.0 out out / win_sum return out这个函数就是完整的谱减法基础版。输入一组带噪信号输出一组时长一致的去噪信号。没有做帧间平滑没有做噪声更新但已经能跑出有效结果。4.3 参数调试一组可参考的数值与听感变化我在实验中固定了 25ms 帧长和 10ms 帧移然后分别测试 alpha 和 beta 的影响。下面是我在 0 dB 白噪声场景下得到的对比感受alphabeta主观听感适合场景1.00.05底噪仍然明显语音比较自然噪声本来就不强时2.00.05噪声明显下降语音略有变薄中等噪声环境3.00.02噪声很低但语音有点闷音乐噪声偏多强噪声但能接受失真2.00.10噪声和音乐噪声平衡较好底噪稍高通用配置建议默认2.50.05残留噪声少但语音瞬态被抹掉不少对噪声更敏感的场景我这组测试里效果最均衡的是 alpha2.0、beta0.08。不过参数没有绝对标准和噪声类型、信噪比、采样率都有关。比如 8 kHz 电话录音和 48 kHz 音乐素材同样的 alpha 听感会差很多。调试时不要只盯客观指标一定要重复听几遍重点听齿音、清辅音和句尾有没有被过度削除。4.4 效果评估波形、语谱图和客观指标怎么看只靠耳朵判断不够稳稳最好还是结合波形、语谱图和信噪比变化来看。波形图可以直观看到噪声包络是否被压下去语谱图能看出语音谐波是否保留、音乐噪声是否明显。音乐噪声在语谱图上非常典型表现为背景里出现一根根横向短条纹像是刮擦感。纯净语音的语谱图则是纵向的谐波条带结构清晰。如果去噪后横纹过多说明参数偏向于产生音乐噪声需要提高 beta 或者做增益平滑。客观指标方面基础版可以算输出信噪比也能算分段信噪比。分段信噪比是把信号切成更短的片段逐段计算信噪比再平均更贴近主观听感。还有 PESQ 这类语音质量评估指标需要额外装库基础版不一定非要算但如果你在做批量调参PESQ 能帮你快速排序不同参数组合。5. 基础版翻车现场音乐噪声、过减失真与排查思路5.1 音乐噪声的成因增益起伏是罪魁祸首音乐噪声是谱减法最具辨识度的副产品听起来像背景里有一阵一阵的、类似“水声”的闪烁噪声。它来自哪里关键在每个频点增益函数的随机波动。谱减本质上是对每个频点应用一个增益当某一帧的带噪幅度恰好略高于噪声谱估计时增益接近 1下一帧幅度略低增益又掉到接近 0。这种帧间增益不稳定会让单个频点忽大忽小听感上就成了音乐性的起伏。缓解音乐噪声有几个实用手法。第一把 alpha 降一点不要减得那么狠第二提高 beta让低增益频点有一个下限不至于直接关断第三对增益函数做时域或频域平滑比如用一阶递归平均把当前帧的增益和上一帧融合一部分。第三种方法最有效代码上也只多几行prev_gain np.ones_like(noise_mag) for i in range(n_frames): gain np.maximum(1 - alpha * noise_mag / (mags[i] 1e-6), beta) gain_smooth 0.7 * gain 0.3 * prev_gain est_mag[i] mags[i] * gain_smooth prev_gain gain_smooth平滑系数 0.3 意味着当前帧增益只占三成上一帧占七成能让增益变化更平缓音乐噪声明显变弱。代价是语音的启停会稍微变钝需要听感权衡。5.2 噪声估计不准前导静音被污染怎么办如果音频一开始就有说话声前 0.2 秒的“噪声帧”里混入了语音估计出来的噪声谱会偏高去噪时语音会被削得很厉害听起来像缺了元音。排查时先看噪声谱长什么样正常噪声谱应该是一条比较平滑的曲线如果它在某些频点上出现很高的峰值说明很可能混入了语音谐波。解决办法有几种。最简单的是手动指定噪声段比如程序里加一个noise_start和noise_end参数让用户框出一段确定没有语音的区域。其次用前导帧的分位数代替平均因为语音信号大部分时间是稀疏的取每频点的最小值或低分位数能削弱偶发语音的影响。如果语音文件本身没有干净噪声段那就得考虑做语音活动检测把每一帧判断为语音帧或噪声帧只用噪声帧更新噪声谱。基础版里不推荐一步到位但至少要支持手动选段否则很多实际语音录出来都没法直接跑好。5.3 语音发虚、像在水底说话过减和地板怎么权衡去噪后语音发虚往往是过减太狠。alpha 太大时语音频谱中的弱频点被当成噪声减掉了导致高频细节缺失、清辅音发不出来整体听感变闷。另一种情况是 beta 太高虽然音乐噪声被压住了但底噪抬升明显语音被底噪淹没听起来像在水底。这里我习惯的调法顺序是先固定 beta 0.08调 alpha 找到噪声和失真的平衡点再固定 alpha微调 beta 看音乐噪声变化。不要同时大幅度改两个参数否则很难定位问题。如果怎么调都发虚可以检查一下是不是帧长太短。帧长太短会让频率分辨率下降弱频点更容易被误杀适当加大到 32ms 甚至 40ms语音频谱结构会更完整。5.4 音频读写与精度踩坑int16、溢出和采样率很多带噪语音 wav 文件是 int16 格式直接拿来算会得到错误的能量比例因为 int16 数值范围大信号均值不为零和浮点数混在一起会出问题。所以读取后要转成 float并归一化到 [-1, 1]。输出时再转回 int16而且必须对结果做 clip防止超过 int16 范围。有时候输出听起来有“滋滋”的爆音很可能就是没 clip 导致的高峰削波。采样率也是个容易踩的坑。基础版默认按 16 kHz 写死帧长但如果是 8 kHz 录音25ms 帧长就变成 200 点如果是 48 kHz 素材帧长变成 1200 点。如果忘改 fs 参数实际帧长时间完全不同听感差异巨大。所以代码里所有分帧参数都从 fs 计算不要写死点数这是我调通后发现一定要遵守的原则。6. 基础版之后还能怎么扩展6.1 从离线到实时基础版改造的三个节点基础版是离线整段处理的但实际很多场景需要边采边处理比如通话、直播、会议软件。把谱减法改成实时处理也不是很难主要有三个改造点。第一改成流式分块。不要一次性读完整段音频而是按块读取比如每 10ms 来一个块然后在这个块的上下文中构造帧。第二噪声估计要持续更新。离线版用前 0.2 秒算一次就完事了实时版要在没有语音的时候持续更新噪声谱否则环境一变化噪声估计就会失效。第三延迟要控制住。重叠相加天然会引入一帧的算法延迟如果帧长 25ms端到端延迟可能在 30ms 左右通话场景需要再压短帧长或调整算法结构。如果只是做实验实时改造可以先在本地用一个音频文件模拟流式读取每次只喂一个块验证输出连续性和延迟。这样不用接麦克风就能把逻辑调通。6.2 从谱减到维纳滤波和统计估计进阶方向谱减法本质是幅度谱减而进阶的统计方法更关注频谱增益的推导。维纳滤波的思想是给定带噪功率谱和噪声功率谱估计一个最小均方误差意义下的最优增益。它和谱减法的区别在于维纳滤波根据信噪比自适应调整增益语音段强则保留多一些噪声段强则压得多一些整体听感更平滑音乐噪声也更少。从实现看维纳滤波只需要把谱减的增益公式换掉整个代码框架完全不用动。这也是我建议先学谱减法的原因你掌握的 STFT、噪声估计、重叠相加框架在维纳滤波、MMSE 方法里同样适用。唯一需要补的是先验信噪比估计和增益平滑。6.3 在语音识别场景里使用要注意什么如果谱减法去噪后要接语音识别比如做会议转写、通话质检要格外小心。谱减法压噪声的同时可能损伤弱音段尤其清辅音和轻音节。识别引擎一旦拿到被削掉的声学特征反而可能识别率下降。有些场景下保留一定底噪比搏掉所有噪声效果更好因为识别引擎本身对平稳噪声有一定鲁棒性。一个建议是把基础版谱减法当作预处理器先批量处理一批测试音频用同一套识别系统对比增强前后的识别字错误率而不是凭听感决定。如果识别率没有明显提升就要考虑调小 alpha甚至在识别场景里放弃谱减法改用更温和的增益平滑方案。就基础版而言我最大的体会是算法并不复杂但真正理解参数的含义需要反复听、反复改。谱减法像一把刀明明知道它的原理但用得轻重缓急全靠调试经验。希望这篇内容能帮你少走点弯路至少能再翻车时知道往哪个方向去排查。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑