资讯详情

资讯详情

C++音频去噪实战:谱减法原理与完整实现

简介一套完整的音频去噪C实现面向音频处理初学者和中高级开发者适用于语音识别前的预处理、音频分析及音乐后期等降噪场景。代码基于Visual Studio工程组织压缩包共17个文件包括6个头文件、3个C源文件、3个C源文件以及解决方案与工程配置文件、一段WAV格式的带噪语音样本整体仅39KB结构精练方便快速定位核心算法模块。目前已有2181人学习下载。代码注释清晰覆盖WAV音频读写、噪声估计、频域变换、滤波处理到结果写回的完整流程涉及谱减法、维纳滤波、自适应滤波等常用降噪技术的实现细节并提供了带噪语音输入与干净输出对照有助于理解预处理、噪声估计、滤波、后处理各环节的作用。对想要掌握音频信号处理基础、改进现有去噪方案或进行二次开发的读者是一份可直接参考的实用代码。1. 音频去噪从拿到一份能跑的C代码开始要论音频处理里最常被搜索、最容易被低估的技术点音频去噪绝对排前列。录课件有空调声录语音有电流底噪剪片子时人声背后还有键盘声——网上降噪代码不少但能直接编译、注释清晰、真的把“去噪”做明白而不把语音切成机器音的 C 实现反而不多。这份代码的价值就在于读入 WAV在频域做谱减再写回干净的 WAV整条链路完整注释把每步的公式和变量含义都标了。适合三类人做课程设计需要交一个能跑通的 Demo做工具链想把它嵌进现有音频流程以及想彻底弄懂谱减法不想只拿着 MATLAB 脚本空看原理的开发者。2. 去噪原理与选型谱减法、维纳滤波和噪声门为什么是谱减法2.1 三种常见去噪算法的对比与适用边界对着这段 C 代码看原理有一个问题躲不开去噪算法那么多为什么这份代码选谱减法而不是维纳滤波或者噪声门选型其实不是越复杂越好而是看实现成本、参数可控性和实际听感三者能不能平衡。常见做法是把三个算法放在一起对比着选。谱减法的核心思路是在频域估计出噪声谱然后用带噪信号的幅度谱减去噪声分量维纳滤波则是通过估计信噪比算出一个增益函数来压低噪声频点噪声门最粗暴能量低于阈值就整段静音。算法核心思路需要什么先验计算量典型短板谱减法估计噪声谱并从幅度谱中扣减无语音时段的噪声谱低处理不当会产生音乐噪声维纳滤波根据信噪比计算频域增益每帧信噪比估计中信噪比低估时语音失真明显噪声门能量低于阈值就静音阈值与触发恢复时间很低语音中间的低能量音会丢尾音被切断噪声门的短板在“人声内部的噪声”上非常致命。比如说话间隙的呼吸声想保留噪声门直接把这段静音了听起来就像人声被切成一块一块的。维纳滤波的听感通常比谱减法干净但代码量多出不少而且信噪比估计本身就是一个需要调参的环节初学者很容易在这一步翻车。谱减法胜在实现直白先采集一段纯噪声做估计再对每一帧做 FFT减掉噪声幅度反变换回来。这份 C 代码就是沿着这条线走的。2.2 谱减法在 C 里的落地STFT 框架与相位保留谱减法不是直接把整段音频拿去做一个 FFT 就完事。音频去噪需要“时频兼顾”所以要上短时傅里叶变换STFT。常见做法是把信号切成若干帧每帧 20 到 50 毫秒加窗后做 FFT在频域处理完再 IFFT 回来通过重叠相加重建时域信号。我一般会在看代码时先确认一个关键点处理过程中相位是否被保留。人耳对相位失真不敏感所以绝大多数谱减法实现都只修改幅度谱相位直接用原始带噪信号的相位。这样做的好处是反变换后波形对齐容易不容易出现相位跳变导致的爆音。代码里如果在 FFT 之后保存了实部和虚部并且把相位数组传进 IFFT那就对了。2.3 核心公式与代码过减因子和噪声底限谱减法的数学表达并不复杂。假设带噪信号幅度谱是|X|估计出的噪声谱是|N|那么处理后的幅度谱|Y|可以写成|Y|^2 |X|^2 - alpha * |N|^2其中alpha是过减因子。alpha越大噪声削得越狠但语音失真也越大。这里还有第二个关键参数beta叫噪声底限用来给每个频点一个最低保留值防止减过头出现负幅度。// spectral_subtract.cpp // 对单个频点做谱减alpha 为过减因子beta 为噪声底限比例 void spectralSubtract(float* mag, float* noiseMag, float* outMag, int fftSize, float alpha, float beta) { for (int i 0; i fftSize; i) { float magSqr mag[i] * mag[i]; float noiseSqr noiseMag[i] * noiseMag[i]; // 减去 alpha 倍的噪声能量 float subtracted magSqr - alpha * noiseSqr; // 如果减去之后比 beta 倍的噪声能量还低就保留一个底限 float floorValue beta * noiseSqr; if (subtracted floorValue) { outMag[i] sqrtf(floorValue); } else { // 防止负数开方 outMag[i] (subtracted 0.0f) ? sqrtf(subtracted) : 0.0f; } } }这段代码是整份资源里最核心的函数。第一个参数mag是带噪信号的幅度谱noiseMag是估计出来的噪声幅度谱alpha控制去噪力度beta是防止音乐噪声的保险丝。逻辑上先做能量域减法再判断是否低于底限最后用sqrtf把能量转回幅度。注意这里比较的是能量不是幅度因为谱减法是在功率谱上做减法的。如果直接对幅度相减效果会差一截这是很多改参数的人没有意识到的。3. 代码结构与核心实现从读 WAV 到写回 WAV 的完整链路3.1 WAV 文件解析跳过头部按 PCM 取数拿到这份 C 代码第一步不是看去噪函数而是看文件读写。大多数音频去噪代码都默认输入是标准 PCM WAV也就是 44 字节文件头后面跟采样数据。代码里通常会自己写一个解析函数而不会依赖第三方库这在项目里是常见做法。// wav_reader.cpp // 只读标准 PCM WAV支持 16bit 单声道 bool readWav(const char* path, std::vectorfloat samples, int sampleRate) { FILE* fp fopen(path, rb); if (!fp) return false; char header[44]; fread(header, 1, 44, fp); // 从 24 字节处读采样率对应 WAV 格式的 sample rate 字段 sampleRate *(int*)(header 24); // 从 34 字节处读位深16bit 对应 2 字节 int bitsPerSample *(short*)(header 34); int bytesPerSample bitsPerSample / 8; // 跳过文件头从数据段开始读 fseek(fp, 44, SEEK_SET); short buf 0; while (fread(buf, bytesPerSample, 1, fp) 1) { // 把 16bit 整型归一化到 [-1.0, 1.0] samples.push_back(buf / 32768.0f); } fclose(fp); return true; }这段解析逻辑用的是直接映射头部字节的方式header 24是采样率字段header 34是位深字段。代码里把 short 型样本除以 32768 归一化到浮点这是为了让后续 FFT 处理数值稳定。很多新手翻车在读取的原始 short 值没转浮点就丢进 FFT结果频谱幅度大到离谱alpha 无论怎么调都没有效果。3.2 分帧与加窗汉宁窗和 50% 重叠读完 WAV 之后去噪前先要分帧加窗。帧长一般取 512 或 1024 个采样点重叠率 50%。加窗的目的是减少 FFT 的频谱泄漏。常见做法是选汉宁窗因为它的窗函数满足 COLA常数重叠相加条件50% 重叠时重建信号幅度不会出现起伏。// windowing.cpp // 生成汉宁窗并用窗函数对一帧数据加窗 void applyHannWindow(float* frame, int frameSize) { for (int i 0; i frameSize; i) { float w 0.5f * (1.0f - cosf(2.0f * M_PI * i / (frameSize - 1))); frame[i] * w; } }汉宁窗的公式里分母是frameSize - 1不是frameSize。这两个写法差一帧在 50% 重叠时对 COLA 条件有直接影响。很多移植到 Python 或 MATLAB 的人在这行抄错结果重叠相加后波形出现周期性振幅波动听感上像声音在水里。分帧时的循环结构一般是从起始位置取frameSize点加窗做 FFT处理IFFT然后把结果叠加到输出缓冲区对应位置最后除以窗函数的平方和。这种重叠相加才是完整的重建逻辑。3.3 噪声估计与逐帧谱减完整处理循环噪声估计最简单的方式是取前 N 帧当作纯噪声。这个假设成立的前提是最开头的一段没有语音。代码里通常会给一个nInitFrames参数默认 10 帧左右也就是 0.2 到 0.3 秒。如果样本开头正好有人说话这个假设就废了所以很多工程会在采集时先录一段环境声。// denoise_process.cpp // 逐帧处理主流程噪声谱取自前 10 帧的平均 void processAudio(std::vectorfloat samples, int sampleRate) { int frameSize 1024; int hopSize frameSize / 2; int initFrames 10; std::vectorfloat noiseMag(frameSize, 0.0f); // 先对前几帧做 FFT累加幅度谱再平均得到噪声估计 for (int f 0; f initFrames; f) { std::vectorfloat frame(frameSize, 0.0f); int start f * hopSize; for (int i 0; i frameSize start i (int)samples.size(); i) frame[i] samples[start i]; applyHannWindow(frame.data(), frameSize); // 这里调用 FFT计算幅度谱并累加 computeMagnitude(frame.data(), frameSize, noiseMag.data(), true); } for (int i 0; i frameSize; i) noiseMag[i] / initFrames; // 按 hopSize 滑窗逐帧处理 std::vectorfloat output(samples.size(), 0.0f); std::vectorfloat windowSum(samples.size(), 0.0f); int pos 0; while (pos frameSize (int)samples.size()) { std::vectorfloat frame(frameSize, 0.0f); std::copy(samples.begin() pos, samples.begin() pos frameSize, frame.begin()); applyHannWindow(frame.data(), frameSize); // 正变换取幅度和相位 std::vectorfloat mag(frameSize), phase(frameSize); computeMagnitudeAndPhase(frame.data(), frameSize, mag.data(), phase.data()); // 谱减用第一节的 spectralSubtract 函数 std::vectorfloat cleanMag(frameSize); spectralSubtract(mag.data(), noiseMag.data(), cleanMag.data(), frameSize, 1.5f, 0.05f); // 幅度乘相位做逆变换 for (int i 0; i frameSize; i) frame[i] cleanMag[i] * cosf(phase[i]); inverseFFT(frame.data(), frameSize); // 重叠相加 for (int i 0; i frameSize; i) { output[pos i] frame[i]; windowSum[pos i] 0.5f * (1.0f - cosf(2.0f * M_PI * i / (frameSize - 1))); } pos hopSize; } // 归一化消除窗函数重叠带来的增益 for (int i 0; i (int)samples.size(); i) samples[i] (windowSum[i] 0.0f) ? output[i] / windowSum[i] : 0.0f; }这段主流程把噪声估计和谱减串起来了。computeMagnitude在累加模式下把前几帧的幅度谱直接加起来最后统一除initFrames得到平均噪声谱。后面每帧都做同样的 FFT然后调用谱减函数最后用cos/sin把幅度和相位拼回去做 IFFT。最后一步除以windowSum是重叠相加的关键这一步漏了输出音量会周期性起伏听感像呼吸效应。需要说明的是computeMagnitude和inverseFFT内部一般建议直接用成熟的 FFT 库比如 FFTW 或者自家项目里已经封装好的基 2 实现。这份代码的价值在于谱减和分帧逻辑清晰FFT 后端可以替换成你自己工程里已有的实现耦合度很低。4. 参数设置与音质调优帧长、过减因子、噪声底限怎么配4.1 帧长与重叠率先定时间分辨率还是频率分辨率帧长是参数里最优先要调的一项。帧长越短时间分辨率越高能跟上语音里快速的音节变化但频率分辨率差低频噪声估计不准帧长越长频率分辨率越高低速噪声削得干净但时间上会模糊语音瞬态会失真。采样率 16kHz 时常见做法是帧长取 512 点32ms或 1024 点64ms。512 适合口语材料比如录音笔里的说话声1024 适合音乐素材因为音调细节更多。我一般会把这段代码里的frameSize从 1024 改成 512 对比试听哪个底噪低、人声不发闷就选哪个。4.2 过减因子 alpha噪声去得越狠语音损失越大过减因子alpha是谱减法里最需要反复试的参数。它在代码里的默认值是 1.5。这个值如果放到 1.0 以下降噪基本听不出来放到 2.5 以上背景噪声确实压得很低但人声会变硬像是用消音器捂着嘴说话。工程上有一个经验范围空调等稳态噪声用 1.2 到 1.5键盘、鼠标这类瞬态噪声混合环境用 1.8 到 2.2。但是注意alpha 调大的时候音乐噪声出现的概率会同步上升因为频谱里那些原本微弱的随机起伏会被放大。所以 alpha 要跟下一节的 beta 一起调不能单独看一个参数。4.3 噪声底限 beta 与平滑系数按住音乐噪声的两道闸门噪声底限beta解决的是“减过头”的问题。谱减法处理后的频谱里如果某个频点减去噪声后能量变成负数直接归零会在频谱上形成孤立尖峰反变换后就是那种随机的“吱吱”声。给一个底限相当于保证每个频点都保留一点原来的噪声能量让频谱不会出现突兀的断崖。我一般会加一道时域平滑来进一步抑制音乐噪声。做法是在相邻帧之间做一阶递归平滑magSmooth[i] prevMagSmooth[i] * 0.8 mag[i] * 0.2这个递归式在代码里改动很小但效果非常明显。平滑系数 0.8 可以让频谱变化变慢减少帧间的不连续。注意平滑是加在幅度谱上的不是加在输出波形上加错位置会让声音变糊。参数默认值调节范围作用调大效果调小效果frameSize1024512~2048帧长频率分辨率高时间分辨率高alpha1.51.0~2.5去噪力度更干净语音硬噪声残留多beta0.050.01~0.2频谱底限音乐噪声少但底噪大底噪小音乐噪声强smooth0.80.5~0.95帧间平滑更柔和可能糊更清晰可能脆4.4 一条推荐的调参路径正确的调参路径不是一次性把所有值都改到位而是一层一层来。我的习惯是先用默认参数跑一遍听声音薄不薄、底噪还剩多少然后只调alpha找到一个噪声能接受、人声不发闷的值再调beta按住音乐噪声最后看波形里有没有音量起伏有的话检查窗函数和重叠相加。5. 常见问题与排查四个把去噪做成劣化音质的坑5.1 音乐噪声谱减法特有的“吱吱”声现象去噪后的音频里出现类似水滴声的随机小尖峰尤其在安静的段落里很明显。原因谱减法在频域对每个频点做减法某些频点减过头变成负值幅度归零后形成孤立峰值反变换后就是短促的“吱”声。这是谱减法本身的结构性缺陷不是代码写错。解决第一把beta从 0.05 调到 0.1 以上给每个频点一个底限第二加上帧间平滑我推荐magSmooth prev * 0.8 cur * 0.2第三如果还压不住把alpha降到 1.2 左右牺牲一点降噪效果来换取听感。这三步按顺序做不要把参数一次性乱跳。5.2 爆音加窗不对或忘记重叠相加现象处理完的音频开头或中间有“啵啵”的爆音波形上看是突然的高幅值脉冲。原因常见是窗函数生成时把frameSize - 1写成了frameSize导致窗函数不满足常量重叠相加条件另一个常见原因是重建时只做了帧叠加没有除以窗函数的平方和造成输出信号幅度周期性鼓包。解决先单独生成窗函数的宽打印前几个值确认窗的第一点是 0中间接近 1最后一点是 0。然后检查重叠相加部分看windowSum数组是否已经累加窗平方。最后把输出除以windowSum这一步叫归一化漏掉则必定爆音。5.3 声音变薄低频被过度削减现象去噪后背景干净了但人声听起来变薄、变干像手机外放的声音。原因谱减法在低频段也会同样使用过减因子而语音里的基频正好在低频alpha 偏大时低频被削掉音色自然变薄。另一个原因是帧长太短低频频率分辨率不足估计出的噪声谱在低频偏大。解决把帧长从 512 改到 1024 试试如果仍然薄可以在谱减函数里对低频段单独设置更小的 alpha。常见做法是给 alpha 加一个频率相关的权重低频用 1.0高频用 1.5。这个改动只涉及几行代码但音色改善非常明显。5.4 处理时间长FFT 实现选型不对现象一段五分钟的 WAV 处理耗时几十秒明显不像是 C 该有的速度。原因循环里对每帧都调用 FFT如果 FFT 是实现得不好的递归版或者没有缓存 FFT 的旋转因子表时间会指数级增长。解决把 FFT 后端换成迭代型的基 2 实现或者直接用成熟的库。这份代码里 FFT 是独立接口替换不影响谱减逻辑。替换后同样一段音频处理时间通常能降到几秒内。6. 进阶用法把参数做成可复用配置模板离线去噪代码调好一次容易难的是面对不同场景时快速切换。我经常遇到的情况是录了一段语音试讲用的是空调环境第二天换成咖啡馆背景噪声频谱完全不同。如果每次都重新打开代码改 alpha、beta迟早调昏头。所以我会把参数抽成配置文件做成几个模板需要时直接加载。{ voice_clean_room: { frame_size: 512, alpha: 1.2, beta: 0.08, smooth: 0.8, init_frames: 10 }, air_conditioner_noise: { frame_size: 1024, alpha: 1.5, beta: 0.05, smooth: 0.85, init_frames: 20 }, keyboard_mixed: { frame_size: 512, alpha: 2.0, beta: 0.12, smooth: 0.75, init_frames: 10 } }代码里加一个loadConfig函数把这些字段解析并传给去噪管线就能实现开箱即用的多场景切换。这里有个设计细节值得提init_frames也可以做参数空调这类稳态噪声需要多取几帧的噪声估计取少了频谱估计不稳键盘这种瞬态噪声反而不能取太多帧因为开头可能正好混入按键声。验证参数是否有效通常不会只看一眼波形就下结论。做法是先在噪声段单独跑一遍确认噪声明显下降再在纯语音段跑一遍确认没有丢字和明显失真最后在语音段和噪声交替的地方跑第三遍重点听切换处有没有爆音。这样三遍下来参数是否可用就清楚了。从那以后我每次调去噪代码都强制走一遍这三组验证不再凭印象改数字踩坑概率低了很多。希望帮到你。本文还有配套的精品资源点击获取
觉得有用,分享给同行:

为您的企业打造数字门面

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

立即咨询 →