简介:面向音频处理与语音识别开发者,这份C++音频去噪代码注释清晰、结构紧凑,可直接运行调试,覆盖时域与频域处理,用于解决录音环境、传输链路中引入的背景噪声与设备干扰问题。工程包含音频读写、傅立叶频域变换、噪声估计、滤波去噪及后处理等完整处理环节,代码模块划分明确,既适合初学者理解去噪链路,也方便开发者针对特定场景调整参数或复用算法模块。资源包共17个文件,以6个h头文件、3个c源文件、3个cpp源文件为主体,并配有Visual Studio解决方案文件(sln、vcxproj)和一段带噪语音wav测试样本,压缩包整体仅39KB,轻量易用。目前已有2181人学习下载,是经过较多开发者验证的实用工程。通过阅读代码可掌握从傅立叶变换、谱减法到自适应滤波的传统去噪方法,理解阈值设定、参数调整与平滑后处理等关键操作,并借助清晰注释快速定位算法实现步骤,还能了解wav音频文件的读写与FFT封装细节,兼具教学与实战价值。
1. 音频去噪这件事:为什么值得用纯C++跑一遍
某次A同学要批量处理一批口播录音,每一条都是同一个房间、同样的设备、同样的底噪。Audacity的交互式降噪每一条都要手动选噪段、预览、调整,处理几十条下来人已经麻了。这个「音频去噪C++代码」项目解决的就是这个问题——一个命令行程序,输入WAV、输出WAV,把底噪批量压下去,全程不需要打开任何图形界面。核心只有几百行C++,不依赖第三方库,注释密度接近代码量,属于那种打开就能读懂、改起来不心虚的项目。它的算法链路是:读入PCM采样 → 分帧加窗 → FFT → 估计噪声谱 → 逐频点谱减 → IFFT → 重叠相加 → 写出干净音频。适合刚接触音频处理想搞懂频谱降噪的人,也适合需要离线批量降噪的桌面工具场景。
2. 项目架构与技术选型:从WAV读入到噪声谱估计的完整链路
2.1 目录结构与模块划分
拿到源码先看目录。整个项目结构很规整,五个源文件,职责划得明明白白:
denoise/ ├── main.cpp # 命令行入口,解析参数,串联整个处理流程 ├── wav_io.h/cpp # WAV文件读写,屏蔽RIFF/fmt/data chunk细节 ├── fft.h/cpp # 迭代FFT/IFFT,radix-2算法,不依赖外部库 ├── subtract.h/cpp # 噪声谱估计 + 谱减增益计算,核心算法 └── params.h # 默认参数集中管理,调参只改这一份这个拆分思路是典型的「文件格式 / 数学变换 / 业务算法」三层隔离。wav_io只负责字节流和PCM数组之间的转换,不知道什么叫噪声;fft只做复数域的变换,不管业务;subtract是核心算法模块,不碰文件操作。这样二次开发时每一层都能单独替换——想从谱减换成维纳滤波只改subtract,想支持24bit或立体声只动wav_io,互不牵连。main.cpp则是所有模块的组装点,参数解析、噪声谱预估计、帧循环全在这里串起来。
2.2 技术选型:为什么是谱减法
项目选的是频域谱减法(Spectral Subtraction),这是一个有明确理由的选择。语音和稳态噪声在频域上有可分离的统计特征:语音能量集中在几十到几千赫兹的共振峰附近,而风扇、空调、设备底噪这类稳态噪声的频谱变化平缓,可以在一段静音里估计出来。谱减法的思路非常直接:每帧做FFT得到幅度谱,把估计的噪声谱从幅度谱中减去,保留原始相位,再逆变换回时域。
为什么不用其他方案?深度学习降噪效果好,但要训练数据、要推理框架、要GPU,对一个「打开就能编译」的C++命令行程序来说过重;时域滤波像移动平均或维纳时域滤波,没法按频点独立做增益,一刀切的结果是语音和噪声一起糊掉。谱减法在工程性价比上正好:几十行核心代码、纯CPU能跑、效果可控、参数可解释。项目注释里特别写了一个关键决定——保留原始相位不重新构造相位。人耳对相位不敏感,相位失真容易造成「空洞感」和机械声,保留原相位是实践下来的稳妥做法。
2.3 处理流水线:一段采样数据从进到出的完整路径
结合main.cpp的调用顺序,完整链路可以拆成五个步骤:
- main解析命令行参数,readWav把WAV文件解成16bit有符号PCM采样数组
- 取前导静音帧(默认前10帧)逐帧做FFT,把幅度谱累加取平均,得到全局噪声谱
- 对每一帧信号:加汉宁窗 → FFT → 拆出幅度谱和相位 → 逐频点做谱减 → 合成新复数 → IFFT
- 重叠相加(overlap-add)还原成连续时域波形,用窗函数平方和归一化消除幅度调制
- 经过简单的削波保护后,writeWav写回16bit PCM文件
第2步是项目里比较聪明的设计:噪声谱是处理开始前一次性预估计的,不是边处理边更新。这样稳定性好——不会因为某一帧恰好是强语音而把噪声谱带偏。很多仿制的降噪项目在这步翻车,边跑边更新噪声谱,结果语音段出现「呼吸感」的周期性起伏。关键参数集中在params.h里,这张表是整个调参的基础:
| 参数 | 默认值 | 作用 |
|---|---|---|
| frameSize | 1024 | FFT窗口大小,约23ms@44.1kHz |
| overlap | 512 | 重叠样本数,50%重叠配合汉宁窗 |
| alpha | 1.8 | 过减因子,越大噪声压得越狠 |
| beta | 0.02 | 光谱下限系数,保留微量底噪避免音乐噪声 |
| noiseFrames | 10 | 用于估计噪声谱的前导帧数 |
alpha和beta这两个参数相互作用,第4章会展开怎么调。这里先记住一句话:alpha调太大会把语音削闷,beta调太小会出现「水声」。
3. 核心代码拆解:谱减法三段关键实现
代码部分我只挑三段最能体现设计思路的展开:WAV解析、分帧加窗与FFT、谱减增益计算。这三段看懂,整个项目就能动手改了。
3.1 WAV解析:把44字节头之外的音频数据接进来
WAV本质是RIFF容器,里面按chunk存放fmt和data。项目只支持16bit单声道PCM,所以读取逻辑可以写得很直白:
bool readWav(const std::string& path, WavData& out) { std::ifstream f(path, std::ios::binary); char header[44]; f.read(header, 44); // 标准 PCM WAV 头固定 44 字节 // 偏移 22 处是声道数,偏移 24 处是采样率,偏移 34 处是位深 out.channels = *(int16_t*)(header + 22); out.sampleRate = *(int32_t*)(header + 24); out.bitsPerSample = *(int16_t*)(header + 34); if (out.channels != 1 || out.bitsPerSample != 16) { return false; // 只支持单声道 16bit,超出范围直接拒绝 } // 偏移 40 处是 data chunk 字节数,44 之后就是采样数据 int dataBytes = *(int32_t*)(header + 40); std::vector<int16_t> raw(dataBytes / 2); f.read((char*)raw.data(), dataBytes); out.samples = std::move(raw); return true; }这里有两个容易翻车的点。第一,WAV头不一定是44字节,如果文件里存在fact等扩展chunk,从固定偏移读data大小就会读错;这个项目的策略是只认标准44字节头,遇到非常规文件直接拒绝并提示,属于「算错不如不读」的保守路线。第二,int16_t强转依赖小端字节序,x86/x64平台没问题,跨到ARM大端平台需要手动做字节序转换,虽然不常见,但值得留意。
参数说明:sampleRate是整个程序里所有帧长换算成毫秒的基础,channels在这里强制校验为1,省掉了后续处理立体声交错存储时的解耦麻烦。WAV写入是读取的逆过程,先写44字节的RIFF头再写PCM数据,没什么特殊技巧,项目里wav_io.cpp对称实现了。
3.2 分帧加窗与FFT:频谱分析的第一道关卡
直接把长信号切帧做FFT是不行的——帧边界处信号截断会产生频谱泄漏,高频能量拖尾,重建时还会在帧边界出现周期性咔哒声。项目用汉宁窗加50%重叠来解决,这也是语音增强的标准配置:
// 生成汉宁窗,N 为帧长 std::vector<float> hann(int N) { std::vector<float> w(N); for (int n = 0; n < N; n++) { w[n] = 0.5f * (1.0f - cosf(2.0f * M_PI * n / (N - 1))); } return w; }为什么50%重叠?因为汉宁窗在50%重叠时满足恒波幅特性——相邻两帧重叠位置的窗函数相加接近常量,重叠相加重建时不会产生幅度调制。项目在重建时进一步用了窗函数平方和做归一化:
// 重叠相加,invWindow 里存的是同一份汉宁窗系数 for (int i = 0; i < N; i++) { accum[writePos + i] += invWindow[i] * frameOut[i]; norm[writePos + i] += invWindow[i] * invWindow[i]; } // 所有帧处理完后:output[i] = accum[i] / norm[i];FFT部分项目用了自实现的迭代库图算法,radix-2,要求帧长是2的幂。核心是位反转置换加蝶形运算:
void fft(std::vector<float>& re, std::vector<float>& im, bool invert) { int n = re.size(); // 位反转置换 for (int i = 1, j = 0; i < n; i++) { int bit = n >> 1; for (; j & bit; bit >>= 1) j ^= bit; j ^= bit; if (i < j) { std::swap(re[i], re[j]); std::swap(im[i], im[j]); } } // 蝶形运算,len 从 2 开始逐级翻倍 for (int len = 2; len <= n; len <<= 1) { float ang = 2.0f * M_PI / len * (invert ? -1 : 1); float wR = cosf(ang), wI = sinf(ang); for (int i = 0; i < n; i += len) { float curR = 1.0f, curI = 0.0f; for (int k = 0; k < len / 2; k++) { float uR = re[i + k], uI = im[i + k]; float vR = re[i + k + len/2] * curR - im[i + k + len/2] * curI; float vI = re[i + k + len/2] * curI + im[i + k + len/2] * curR; re[i + k] = uR + vR; im[i + k] = uI + vI; re[i + k + len/2] = uR - vR; im[i + k + len/2] = uI - vI; float nR = curR * wR - curI * wI; curI = curR * wI + curI * wR; curR = nR; } } } if (invert) { float s = 1.0f / n; for (int i = 0; i < n; i++) { re[i] *= s; im[i] *= s; } } }参数说明:frameSize用1024,44.1kHz采样率下对应约23ms一帧,正好落在语音短时平稳假设的10~30ms区间。帧长加大,频域分辨率更高但时域跟随变差;帧长减小则反之。这个FFT实现要求N是2的幂,改frameSize时只能填1024、2048这类数,填512行、填1000直接崩。
3.3 噪声谱估计与谱减增益:整段代码的心脏
代码的逻辑分两步。第一步,拿前导静音帧估计全局噪声幅度谱:
// 累计前 noiseFrames 帧的幅度谱后取平均 for (int k = 0; k < frameSize / 2; k++) { noiseMag[k] = accumulated[k] / (float)noiseFrames; }只累加正频域的N/2个频点,是因为实信号FFT的负频域是共轭对称的,处理正频足够,逆变换时镜像回去即可。新手最容易在这个地方多处理一倍频点,结果高频能量翻倍。
第二步,每一帧逐频点做谱减。这一步是整个去噪公式的核心:
// 对某一频点 k,拆出幅度和相位 float magnitude = sqrt(re[k]*re[k] + im[k]*im[k]); float phase = atan2(im[k], re[k]); // 过减:噪声谱幅度放大 alpha 倍后再减 float magSub = magnitude - alpha * noiseMag[k]; // 光谱下限:幅度不许低于原始幅度的 beta 倍 if (magSub < beta * magnitude) { magSub = beta * magnitude; } // 用处理后的幅度配原始相位,合成新频点 reOut[k] = magSub * cosf(phase); imOut[k] = magSub * sinf(phase);参数说明:alpha是过减因子,默认1.8。直接减掉平均噪声谱是不够的,因为每帧噪声幅度是随机波动的,固定减可能在某些频点减成负数,产生残余窄带尖峰——听感就是「音乐噪声」。过减因子把估计噪声谱放大后再减,宁可把语音里受污染的部分一起削掉一部分,也不能让残余噪声变成明显可听的碎音。beta是光谱下限系数,默认0.02,等于给每个频点兜底保留2%的原始幅度,让静音段不是绝对零输出,听感更自然。
这段代码的处理顺序也值得注意:先取幅度和相位,幅度做减法,相位原封不动,最后再用cos和sin重新合成。所有谱减类算法都遵循这个「改幅度、留相位」的原则。
4. 编译运行与参数调优:让去噪效果从「能听」到「能发」
4.1 编译与构建
项目不依赖第三方库,编译很简单。Linux或macOS下用g++直接编:
g++ -O2 -std=c++11 main.cpp wav_io.cpp fft.cpp subtract.cpp -o denoise -lm-O2是必须的。FFT这种密集计算不开优化,处理一条几分钟的录音能慢一个数量级,开O2之后就正常了。-lm用来链接数学库,代码里用了cosf、atan2、sqrtf这些函数,链接libm保证在所有平台都正确。Windows下Visual Studio新建空控制台项目,把五个cpp文件拖进去编译即可,不需要额外配置。
日常开发我一般会在项目根目录放一个Makefile,改完代码一条make命令就能重新编译,不至于每次敲长串g++命令。第一版可以直接用上面的命令行编译,跑通了再补Makefile也不迟。
4.2 命令行参数与默认值
程序是命令行工具,典型调用方式:
./denoise input.wav output.wav --alpha 1.8 --beta 0.02 --noise-frames 10input.wav和output.wav是必填位置参数,其余参数全部可选,因为params.h里已经有默认值。实际调参时可以用几组现成的参考档位:
| 噪声场景 | alpha | beta | 帧长 | 预期效果 |
|---|---|---|---|---|
| 较轻底噪 | 1.2~1.5 | 0.05 | 1024 | 语音损伤小 |
| 中等底噪 | 1.8 | 0.02 | 1024 | 默认组合 |
| 底噪很重 | 2.0~2.5 | 0.01 | 512 | 噪声压得干净但语音偏闷 |
| 音乐素材 | 1.3 | 0.08 | 2048 | 尽量保留音乐动态 |
参数说明:alpha加大,噪声谱被减掉更多,但语音高频跟着受损,听感「发闷」;beta减小,音乐噪声更明显。实操建议先固定beta,扫alpha几个档位,找到听感转折点再回调beta微调。一般不动帧长,除非目标音频本身的采样率或内容类型明显不同。
4.3 效果验证:别只靠耳朵说「好像干净了」
去噪效果至少要过两组客观验证,不能全凭感觉。
第一,看静音段的波形幅度。找一段输入里确定没有语音的静音段,对比处理前后的幅度。处理后静音段波形幅度应该缩到原始幅度的beta倍附近——默认0.02意味着约-34dB。如果静音段波形幅度还是很明显,说明噪声谱估计偏低,要检查前导帧是否真的全是静音,或者把alpha调大。
第二,测RMS音量变化。用ffmpeg做客观统计:
ffmpeg -i output.wav -af volumedetect -f null - 2>&1 | grep mean_volume处理前mean_volume如果是-20dB左右,处理后掉到-38dB以下,说明底噪压制有效。同时对比语音段的RMS,如果语音段比处理前掉了超过5dB,基本可以判定alpha过大把语音一起削了。
提示:处理前先完整听一遍输入,确认噪声是稳态噪声。键盘打字、关门声、咳嗽这类瞬态冲击噪声,谱减法基本无效,这不是参数问题,是算法边界。遇到这种情况要么手动切掉瞬态段,要么换成专门处理冲击噪声的算法。
5. 避坑记录:我在这类项目上踩过的四个典型问题
谱减法的坑几乎都集中在工程实现细节上,以下四条是真实调试中遇到的问题,每一条对应一段完整的排查过程。
5.1 输出全是「水声」,像在水下说话
现象:降噪后的音频出现一种颤抖的「水声」,安静段尤其明显,语音段也带着嗡嗡回响。这是谱减法最著名的副作用——音乐噪声(musical noise)。
原因:噪声谱是统计估计,实际每帧噪声的幅度是随机波动的。固定减掉一个平均噪声谱后,某些频点减过头变成负值,被下限截断后形成窄带孤立尖峰,听感就是随机钢琴音一样的碎音。
解决:把alpha调到1.8以上,用「过减」把噪声谱的随机波动一起压掉;同时beta保持2%的量做兜底。如果音乐噪声还明显,下一步就是给平滑后的噪声谱加时域递归平滑,或者直接换成第6章要讲的维纳滤波增益——增益函数是连续的,不会产生硬截断。
5.2 帧边界周期性咔哒声
现象:输出波形看起来正常,但每隔固定间隔出现极短促的「咔」声,用Audacity放大看,帧边界处有明显的不连续跳变。
原因:这是典型的「只分帧加窗、没做重叠相加重建」。帧与帧之间在边界处幅度不连续,播放时就被放大成周期性爆音。处理长信号时分帧是必要操作,但每帧独立处理完必须再拼回去,拼接方式决定了会不会留下爆音。
解决:改用50%重叠加汉宁窗,重叠区域直接相加,再除以窗函数平方和归一化。这是这个项目已经实现的重建方式——注意归一化这步不能省,只做简单相加会导致重叠区域幅度变为原来的两倍,同样会出现幅度调制。
5.3 语音发闷,高频像被蒙了一层布
现象:底噪确实降下去了,但人声明显发闷,高频细节丢失,听感像隔着一层布说话。
原因:排查后发现噪声谱估计里混入了语音能量。噪声谱是用前10帧估计的,如果录音开头静音段太短,前导帧里有语音进去,「噪声谱」就包含了语音的共振峰能量,处理时把所有帧的对应频点一起削狠了。
解决:录音时确保开头有至少1秒纯静音,或者把noiseFrames调小到5帧。更稳的通用做法是加噪声段检测:每一帧先算能量,低于全局能量阈值的帧才参与噪声谱平均,这个逻辑加在subtract模块里大概20行能写完。
5.4 处理后的音频音调变高、速度变快
现象:输入44100Hz的WAV,输出听起来像是加速了约6%,音调也整体偏高。
原因:这是一个经典的WAV头解析错误。采样率字段被读错,重叠相加时的帧步进算错,波形时域被压缩。深入排查发现是读取WAV头时遇到带扩展chunk的文件,固定偏移全部错位,fmt信息读出来就是错的。
解决:项目最终采用「只认标准44字节头,遇到扩展chunk直接拒绝」的策略。确实要处理非常规WAV时,正确做法是遍历所有chunk,逐个比对fourcc找到fmt和data,而不是赌固定偏移。这次之后我每次换音频处理代码,第一件事都是先用十六进制工具看一眼文件头,确认WAV头结构再写解析逻辑。
6. 二次开发:把它改造成自己的去噪工具
6.1 从固定系数到信噪比自适应
默认alpha是常数,但实际录音每一帧的信噪比都在变。语音帧信噪比高,不需要下重手;噪声主导的帧信噪比低,要加大压制。一个很小的增量改进是让alpha随帧内信噪比自适应:
float snrPost = (re[k]*re[k] + im[k]*im[k]) / (noiseMag[k]*noiseMag[k] + 1e-10f); float alphaAdapt = (snrPost > 10.0f) ? 1.2f : 2.0f;这个改动加在subtract.cpp的频点循环里,不超过10行,效果上能明显减少语音发闷的问题。代价是要多做一次除法运算,处理时间略增,但对正常时长的录音完全无感。
6.2 用维纳滤波增益替换谱减
谱减是减幅度,维纳滤波是乘增益。把核心公式替换成:
float xi = snrPost; // 简化先验信噪比 float gain = xi / (1.0f + xi); // 维纳增益,恒在 0~1 之间 reOut[k] = gain * re[k]; imOut[k] = gain * im[k];这个替代的优点是增益值永远落在0到1之间,不会出现幅度减成负数的情况,音乐噪声天然更弱。缺点是维纳滤波对噪声谱估计的偏差更敏感,噪声谱估不准时语音损伤会比谱减更明显。我个人的使用习惯是:稳态底噪用谱减,底噪较轻但动态变化快的录音用自适应维纳滤波。做完替换后,记得重跑第4.3节的RMS对比和试听验证,确保改造没有引入新问题。
从那以后,我每次改这类去噪代码,都强制把「加噪样本测试、RMS客观对比、音频试听」三个动作完整走一遍,不再凭感觉改完就交。希望你拿到这套代码后,也先跑通默认流程,再按自己的录音场景去调alpha和beta。希望帮到你。
本文还有配套的精品资源,点击获取