最近在整理语音信号处理的素材顺手把那套用了很长时间的“小波阈值去噪”脚本重新翻了出来。这次拿一段干净人声做实验先用 Matlab 注入高斯白噪声再用小波阈值去噪把信号“洗”一遍最后从时域波形和频谱两个维度对比前后变化。整个过程不复杂但值得抠的细节比想象中多下面把原理、代码、参数调优和踩坑记录一次性整理出来。这个实验非常适合正在接触语音信号处理、语音增强、语音特征预处理的同学不管你是做作业题、课程设计还是准备进入实际项目这套流程都不会过时。小波阈值去噪比传统低通滤波直观得多参数不像深度网络那样需要海量训练数据只要 Matlab 基础数值库加 Wavelet Toolbox 就能跑通。我们先从“为什么小波去噪对语音有效”讲起再进入完整实现。1. 小波阈值去噪为什么在语音上特别吃香1.1 语音是典型的非平稳信号语音和很多周期性工业振动信号不一样它的最大特点是“非平稳”声带振动带来准周期的浊音段会在频谱上形成清晰的基频和谐波结构舌齿摩擦产生的高频清音时间上往往又短又冲再加上停顿、气声、音调起伏整个信号时强时弱不同片段的频率特征变化非常快。这种信号放在全局频域里看会糊成一片放在时域里看又很难直接分离噪声。小波变换的核心优势就在这儿它是一种“多尺度放大镜”低频用宽窗看长期趋势高频用窄窗看瞬态细节。语音的浊音段低频成分丰富、清音段高频瞬态突出小波各层系数恰好对应这种不同时间分辨率的频带结构。所以语音信号天然适合用小波来分析这也是小波阈值去噪能在语音去噪里长期占有一席之地的底层原因。1.2 低通滤波和短时傅里叶的局限很多人第一反应是“噪声不是高频吗直接低通滤波不就行了”。这个思路用在工频噪声、部分窄带噪声上勉强成立但高斯白噪声覆盖整个频率范围低通滤波把高频噪声滤掉的同时也会把语音里真正重要的高频辅音一起消掉。像 /s/、/f/、/t/ 这类清音辅音能量集中在中高频段一旦被低通削平语音清晰度立刻下降听感闷且含糊。语音识别场景更糟前端特征对应的频谱细节直接丢了。短时傅里叶变换 STFT 是一种改善方案但它有固定窗长的矛盾窗太长频率分辨率高但时间分辨率差清音和突发音会被糊开窗太短时间定位好了频率分辨率又不够。小波变换通过尺度的自适应伸缩绕开了这个矛盾所以在这个场景下比 STFT 更顺手。1.3 小波阈值去噪的工作逻辑小波阈值去噪的原理可以拆成三步分解、阈值处理、重构。先用小波变换把带噪语音分解成一个近似系数和多个细节系数近似系数保存大尺度低频轮廓细节系数保存各个频带的细节成分。语音的主要结构集中在近似系数和部分幅值较大的细节系数上而高斯白噪声能量比较均匀地分散在所有尺度的小系数里表现为“细碎的毛刺”。然后设定阈值 λ幅值小于 λ 的系数被当成噪声置零或收缩幅值大于 λ 的系数被保留或收缩。最后用逆小波变换重构建信号。因为语音成分在相应频带的系数通常明显大于噪声系数所以小波重构之后语音轮廓还在噪声底被大幅压掉。这个原理理解起来不难难的是阈值怎么定、小波基怎么选、分解几层这些才是影响最终效果的关键。2. 动手前先定方案噪声怎么加、效果怎么评2.1 加噪用信噪比 SNR 控制噪声强度实验里不能随随便便“加点噪声”就完事必须用可控参数把噪声强度定住。最常见的方式是固定信噪比 SNR单位 dB公式是SNR 10 * log10(Ps / Pn)其中 Ps 是干净信号平均功率Pn 是噪声功率。我们从目标 SNR 反推需要的噪声功率Pn Ps / (10^(SNR/10))然后在 Matlab 里用randn生成高斯白噪声序列乘上sqrt(Pn)再加到干净信号上。有一点要注意这里用的是整个信号的平均功率也就是 RMS 意义上的信噪比。如果语音里静音段很长某段瞬时信噪比可能远高于全局信噪比远处背景噪声更明显的地方又会低于全局值但作为仿真实验已经足够规范。实际语音去噪场景里0 dB 到 10 dB 是比较典型的恶劣信噪比区间。我习惯把实验设成 5 dB既能明显看到波形和频谱的恶化又不会让噪声彻底盖过语音轮廓方便观察阈值去噪的效果边界。2.2 指标SNR、RMSE 与人耳判断并重做去噪实验不能只靠眼睛“看波形干净了”需要量化指标来支撑判断。常用指标有三类信噪比 SNR直接度量信号功率和噪声功率的比值去噪前后对比得到 SNR 增益是最常用的数字指标。均方根误差 RMSE度量重构信号和原始干净信号之间的波形偏差反映整体失真。对数谱距离 LDS度量频谱幅度的偏差比时域 RMSE 更贴近听觉感知。实际经验里SNR 提升高不一定等于听感好。软阈值去噪通常会带来一定程度的系数收缩波形细节损失一点SNR 增益可能并不夸张但听感却很干净。硬阈值去噪 SNR 提升往往更大但如果阈值附近系数被硬性截断重构波形容易产生“颗粒感”和振铃听起来反而不自然。所以量化指标必须配合波形图、频谱图和实际试听一起判断别只盯着一个数字。2.3 参数小波基、分解层数、阈值规则怎么选小波阈值去噪四个最重要参数小波基、分解层数、阈值规则、软硬阈值方式。我常用的组合是sym8小波、5 层分解、固定阈值规则配合软阈值。下面分别解释为什么这么选。小波基里最常见的候选是 Daubechies 系列db4、db8和 Symlets 系列sym4、sym8。db系列是紧支撑正交小波实现简单但对称性差相位失真容易增加sym8是近似对称的 symlet相位畸变更小重建波形更平滑。语音听起来相对自然我优先推荐sym8。如果你处理的信号本身高频注意事项多也可以试试db4差别不会太大但db小波在高分解层数时边界效应更明显。分解层数同样影响很大。层数太浅噪声没有充分分散到不同频带阈值处理相当于“大刀砍高频”层数太深每一层细节系数样本数变少用统计方法估计噪声水平就不再稳定。经验上 8 kHz 采样语音选 4 到 5 层16 kHz 到 48 kHz 的宽带语音可以选 6 到 8 层。小波每层对应一个频带以 8 kHz 采样率为例第 1 层细节对应 2 kHz 到 4 kHz第 2 层对应 1 kHz 到 2 kHz第 5 层对应 125 Hz 到 250 Hz。需要保留的语音频率内容大概到 4 kHz分解到 5 层能把低频基频和中高频辅音拆在不同的尺度上便于逐层处理。阈值规则有四类常用选择我整理成表格方便对照阈值规则基本原理适用场景sqtwolog固定阈值λ σ√(2lnN)理论最优噪声明显、信号分布较宽的常规场景rigrsure无偏风险估计自适应最小化风险高信噪比希望尽量保留语音细节heursure启发式选择结合前两种信噪比不确定想取折中minimaxi极小极大意义下的最保守阈值弱语音成分多尽量少伤细节用代码自己实现时最稳的是sqtwolog思路的固定阈值配合每层噪声标准差估计。软硬阈值上我优先选软阈值软阈值把大于 λ 的系数向零收缩整个处理后系数连续重建波形不会有硬截止造成的抖动硬阈值虽然更保幅值但系数在 λ 附近跳变重构容易出现类似“吉布斯”的振荡语音里表现为不自然的金属感。3. Matlab 实操从加噪到去噪的完整流程3.1 读入人声与双声道处理第一步是读入干净人声。Matlab 用audioread读取 wav 文件返回的采样点已经是 double 类型范围接近 [-1, 1]省去很多麻烦。常见的坑是读取立体声文件得到的矩阵有多列后边做 FFT 或小波变换时变元尺寸不匹配所以先检测列数统一转成单声道。[s, fs] audioread(clean_speech.wav); if size(s, 2) 1 s mean(s, 2); % 双声道取平均 end s s / max(abs(s)); % 统一幅值范围这里有两个细节。第一取平均之前如果你只需要左声道可以直接写成s(:,1)这比mean(s,2)更简单也避免左右声道的相位抵消问题。实际录音中左右声道可能有时间差平均会造成轻微的梳状滤波效应。第二max(abs(s))归一化必须放在加噪之前不然同类语音不同响度会直接影响功率计算和阈值选择。如果手边没有现成的 wav 文件也可以用一段合成信号代替演示。后面的完整代码里我写了回退方案用基频加若干谐波再加少量随机成分模拟一个“语音近似”信号。它不是真的语音但能完整跑通流程适合验证代码逻辑。3.2 wavedec、wthresh、waverec 三步走核心去噪部分我不用现成的一行封装而是拆开成三步方便看清每个环节做了什么。% 小波分解 level 5; wname sym8; [c, l] wavedec(x, level, wname); % 逐层阈值处理 cnew c; idx l(1) 1; for k 1:level len_k l(k 1); d_k c(idx:idx len_k - 1); % 取出第 k 层细节系数 sigma_k median(abs(d_k)) / 0.6745; % MAD 估计该层噪声标准差 lam_k sigma_k * sqrt(2 * log(N)); % 固定阈值 d_new wthresh(d_k, s, lam_k); % 软阈值 cnew(idx:idx len_k - 1) d_new; idx idx len_k; end % 重构信号 xd waverec(cnew, l, wname);有几个地方必须解释清楚。wavedec返回的c不是简单的一维数组它按照“近似系数 第 level 层细节 第 level-1 层细节 …… 第 1 层细节”的顺序拼接。所以循环里必须用分解向量l来定位每一层细节系数在c里的起始位置不能直接c(1:end)一把抓。这是新手最容易报错的地方。噪声标准差的估计用 MAD绝对中位差而不是std。原因是语音本身的强峰会使标准差估计偏高从而把阈值推得过大导致细节被过度压制。MAD 对孤立大系数更稳健公式里的 0.6745 来自标准正态分布绝对中位数的性质median(|x|) 0.6745 * sigma。这个估计只在高斯白噪声假设下比较准如果混入脉冲噪声需要换分位数或者其他鲁棒方法。阈值 λ σ√(2lnN) 里的 N 是信号总采样点数。从理论上讲这是 Donoho 和 Johnstone 提出的通用阈值在信号干净程度未知时是一个比较稳的起点。N 越大阈值越高因为采样点越多噪声中出现的极端值就越可能“冒充”信号。3.3 wden 一行式去噪的取舍Matlab 里也有现成封装比如经典函数wdenxd2 wden(x, heursure, s, one, level, sym8);还有新版里的wdenoisexd2 wdenoise(x, level, Wavelet, sym8, ... DenoisingMethod, Bayesian, ThresholdRule, Soft);一行就能出结果非常省事。但我还是建议你把wavedecwthreshwaverec的拆解流程亲手写一遍原因有两个一是考试、面试或移植到其他语言时你得知道内部发生了什么二是调参时如果只能依赖黑盒遇到效果不好根本不知道怎么修。现成封装适合快速验证手写版本适合深度调优和问题定位。两种方式我都用过实际对比下手写版本和wden的输出差异不会特别大。但手写版本让你有能力改变阈值规则比如按层使用不同的阈值、加入自适应分帧这是黑盒封装做不到的。4. 波形与频谱去噪前后到底发生了什么4.1 时域波形对比怎么读加噪之后的语音波形变化很直观。干净语音在浊音段有规律起伏波形呈现出近似周期性的振荡清音段幅度小且杂乱。加入 5 dB 高斯白噪声后波形整体蒙上一层毛刺静音段不再安静浊音段的清晰峰谷也被噪声掩盖表现成“锯齿状”的抖动。经小波阈值去噪后波形上的毛刺明显减少浊音段恢复出和原始波形接近的包络静音段基本回去不再有明显的随机振荡。但要注意去噪后的波形往往略“缩水”尤其清音段的幅度和细节会有一定损失。如果你对比局部 0.5 秒窗口会看得更清楚辅音部分的波形细节可能变平滑浊音段的主峰保留得很完整。比较正确的方式是截取一段包含浊音和清音的区间把干净、加噪、去噪三条曲线画在同一张图里。不要在整个 3 秒、5 秒的尺度上对比曲线全是密密麻麻的线肉眼看不出任何有效信息。4.2 频谱对比噪声底与频谱泄漏频域里观察更直观。干净语音的频谱有明显的高峰集中在基频和各个共振峰位置清音辅音的能量以较宽带的形式分布在高频段。加入高斯白噪声后整个频带的噪声底统一抬高幅度谱类似垫了一层“地板”原本被噪声淹没的弱谐波峰变得难以辨认。小波阈值去噪后噪声底整体下降但个别频段仍会有残留。残留多少取决于阈值强度阈值偏大时噪声底压得狠但高频辅音也被削平阈值偏小时弱语音保留得多可噪声底也降不下去。所以只看去噪前后两个频谱图还不够最好画出干净信号频谱作为参照看“峰”和“底”的分离程度。画频谱图时有一个绕不开的细节频谱泄漏。语音片段的时间长度并不是整周期直接做 FFT 等于对原始片段施加矩形窗矩形窗旁瓣很高频谱会“拖尾”造成本来没有的频率分量凭空出现。解决办法是加窗比如用海明窗 Hamming 降低旁瓣。这也解释了为什么热词里“频谱泄漏”、“加窗”总能跟 FFT 频谱分析绑定在一起。代码里我使用hamming窗做 4096 点 FFT频率轴从 0 到 fs/2画20*log10的幅度谱低频动态范围会更清楚。4.3 量化结果一个典型实验复盘我在一次典型实验里得到的数据是这样的原始干净信号 5 dB 信噪比加噪后用sym8、分解 5 层、软阈值固定阈值处理去噪后输出信噪比大约 14.3 dBSNR 增益接近 9 dBRMSE 从 0.24 左右降到 0.05 左右。波形对比图上浊音段重建得很干净清音段幅度有所收缩整体听感自然没有明显金属振铃。需要强调这个数字不是固定答案。把阈值规则换成heursureSNR 增益可能略低一点但清音保留更好把软阈值换成硬阈值SNR 增益可能瞬时抬高但听感反而毛糙。这就是做参数实验最有趣的地方。5. 常见问题与避坑经验速查5.1 高频细节被削掉听感发闷这个问题基本人人会遇到。如果去噪后波形看起来很平滑但语音像隔着被子说话多半是阈值设高了或者小波基频响应在高频段被过度压缩。解决思路把阈值规则从sqtwolog换成rigrsure或heursure它们对弱信号更温和检查分解层数是不是太深高频细节分布到太多层以后每层都做阈值处理容易把有效成分一起截掉如果只用一层全局阈值尝试按层估计噪声标准差避免某一层的大噪声阈值污染其他层。5.2 边界出现异常尖峰或振铃信号开头和结尾总是容易出现异常凸起这是小波变换的边界效应。默认模式下信号边界处理方式可能造成重构时左右端不连续。解决办法是显式设置边界延拓模式dwtmode(per); % 周期延拓在wavedec之前执行这一行重构时边界会平滑很多。但注意别在程序中间反复切换dwtmode小波分解和重构必须使用相同的模式。5.3 工具箱缺失与信号自身的问题如果你用的是精简版 Matlab没有安装 Wavelet Toolbox调用wavedec直接报错。检查方式在命令行输入ver找有没有 Wavelet Toolbox。没有就只能用信号处理工具箱里的滤波器组 DIY 实现或者换用安装完整环境的版本跑实验。信号自身的问题也要先处理如果加载回来的信号均值不为零波形上有直流偏移做 FFT 时 0 Hz 会有一个巨大峰后边全被拉平。先去均值再进小波流程。另外audioread读出来的如果是多通道数据列数不等于 1 时很多函数会给出维数错误最好在脚本最前面统一处理成单声道。5.4 关于软件版本和兼容性我给出的代码尽量兼容不同 Matlab 版本wavedec、wthresh、waverec是很老的函数新版本基本都保留。但wdenoise是相对新的函数如果你的版本比较旧直接运行会报“未定义函数或变量”。所以完整代码里我用的是wavedec方案只有扩展讨论里才提到wdenoise。命令行里临时检查函数是否存在可以用exist(wdenoise,file)。6. 完整可运行代码与后续扩展6.1 完整代码下面把完整的实验脚本放出来可以直接复制运行。代码里处理了文件不存在的情况并输出 SNR、RMSE、波形和频谱图。%% 语音小波阈值去噪演示 % 环境Matlab R2018 及以上需要 Wavelet Toolbox clear; clc; close all; rng(2025); %% 1) 准备一段干净语音 speechFile clean_speech.wav; fs 8000; if exist(speechFile, file) 2 [s, fs] audioread(speechFile); else % 合成近似语音基音前几次谐波少量清音成分 dur 3; t_dur (0:fs*dur-1)/fs; f0 140; s 0.40*sin(2*pi*f0*t_dur) ... 0.22*sin(2*pi*2*f0*t_dur) ... 0.13*sin(2*pi*3*f0*t_dur) ... 0.05*sin(2*pi*5*f0*t_dur) ... 0.08*randn(length(t_dur),1); end if size(s, 2) 1 s mean(s, 2); % 双声道取平均 end s s / max(abs(s)); % 归一化 N length(s); t (0:N-1)/fs; %% 2) 加指定SNR的高斯白噪声 SNR_dB 5; Ps mean(s.^2); Pn Ps / (10^(SNR_dB/10)); noise sqrt(Pn) * randn(N,1); x s noise; %% 3) 小波阈值去噪 level 5; wname sym8; [c, l] wavedec(x, level, wname); cnew c; idx l(1) 1; for k 1:level len_k l(k 1); d_k c(idx:idx len_k - 1); % MAD估计该层噪声标准差 sigma_k median(abs(d_k)) / 0.6745; lambda_k sigma_k * sqrt(2 * log(N)); % 软阈值 d_new wthresh(d_k, s, lambda_k); cnew(idx:idx len_k - 1) d_new; idx idx len_k; end xd waverec(cnew, l, wname); % 备用一行式xd wden(x,heursure,s,one,level,sym8); %% 4) 量化指标 SNR_in 10*log10(sum(s.^2) / sum((x - s).^2)); SNR_out 10*log10(sum(s.^2) / sum((xd - s).^2)); RMSE_in sqrt(mean((x - s).^2)); RMSE_out sqrt(mean((xd - s).^2)); fprintf(SNR : %.2f dB - %.2f dB增益 %.2f dB\n, ... SNR_in, SNR_out, SNR_out - SNR_in); fprintf(RMSE: %.4f - %.4f\n, RMSE_in, RMSE_out); %% 5) 时域波形对比 seg round([0.5 1.0] * fs); idxSeg seg(1):seg(2)-1; tt t(idxSeg); figure(Position,[100 100 1200 1000]); subplot(3,1,1); plot(tt, s(idxSeg), k); ylabel(Clean); title(干净语音); xlim([tt(1) tt(end)]); grid on; subplot(3,1,2); plot(tt, x(idxSeg), b); ylabel(Noisy); title(加噪语音 5dB); xlim([tt(1) tt(end)]); grid on; subplot(3,1,3); plot(tt, xd(idxSeg), r); ylabel(Denoised); title(小波阈值去噪结果); xlim([tt(1) tt(end)]); grid on; %% 6) 频谱对比 Nfft min(4096, N); win hamming(Nfft); f (0:Nfft/2-1) * fs / Nfft; S_s abs(fft(s(1:Nfft) .* win, Nfft)); S_x abs(fft(x(1:Nfft) .* win, Nfft)); S_d abs(fft(xd(1:Nfft) .* win, Nfft)); figure(Position,[100 100 1200 500]); plot(f, 20*log10(S_s(1:Nfft/2) eps), k, ... f, 20*log10(S_x(1:Nfft/2) eps), b, ... f, 20*log10(S_d(1:Nfft/2) eps), r); legend(Clean,Noisy,Denoised); xlabel(频率/Hz); ylabel(幅度/dB); title(sprintf(频谱对比SNR增益 %.2f dB, SNR_out - SNR_in)); xlim([0 fs/2]); grid on;如果运行时报Nfft大于信号长度说明你的语音文件很短。可以把Nfft写成pow2(nextpow2(min(N,4096)))或者把s(1:Nfft)改成s(1:min(Nfft,N))并同步调整频率轴。6.2 去噪之外还能怎么延伸小波阈值去噪这条路走通之后扩展空间很大。第一个方向是分帧处理。目前代码是对整条信号做一次全局阈值但如果语音里安静段和响亮段信噪比差太多全局阈值会对响段过杀、对静段漏杀。可以先按 20 到 30 毫秒分帧每帧单独做阈值估计和重构效果更细腻。第二个方向是换用自适应阈值。固定阈值在 N 很大时偏高对弱语音不友好。可以尝试 SURE 阈值、贝叶斯阈值或者用更高阶的 Block Thresholding控制小波系数块级的保留。这个方向在论文里经常出现Matlab 的wdenoise里也内置了部分方法可以拿来对照。第三个方向是换噪声类型。真实环境下的噪声很少是高斯白噪声可能是低频嗡嗡声、突发撞击声、平稳的空调噪声。小波阈值去噪对白噪声假设依赖很强遇到彩色噪声要先估计噪声频谱或者把谱减法、维纳滤波和小波去噪结合。把基础实验换成实际麦克风录音你会发现同一套代码的调参路径完全不一样。第四个方向是跳出语音把同一套流程拿到心电信号、振动信号、探地雷达信号里试。小波阈值去噪处理的都是“瞬态信号 噪声”只是阈值规则和小波基需要微调。这也是为什么这类实验在论文里随处可见它确实是一个通用工具。我个人在实际操作中的体会是小波阈值去噪最怕的不是参数不会调而是调好参数后说不清楚结果为什么好。每次改参数把频谱图重新画一遍观察哪个频段的噪声降下去了、哪个频段的语音细节被削掉了比光看 SNR 数字管用得多。这套流程跑通之后后面无论往分帧、自适应阈值还是多麦克风阵列方向走都会有一条很顺的路。