简介:面向音频处理与降噪应用开发者,这套C++项目提供了一份注释清晰的音频去噪实现,可对带噪语音进行降噪处理,适合语音识别、音频分析、音乐后期等场景的入门与二次开发。压缩包共17个文件,以C/C++源码(3个.c、3个.cpp)与6个头文件为主,同时附带Visual Studio解决方案(.sln/.vcxproj)、项目筛选文件以及一个WAV格式测试样本“带噪语音.wav”,整体仅39KB,打开工程即可编译运行。已有2178人查阅学习。代码实现了从WAV读取、FFT频域变换、噪声估计与抑制到去噪结果输出的完整链路,并对阈值处理、滤波器、谱减等知识点进行了流程化注释;借助这套代码,可以快速理解经典去噪算法的工程化落地方式,也便于在此基础上替换或扩展维纳滤波、LMS自适应滤波等更高级方法,是一套值得阅读的音频降噪参考资源。 如果你正被一段录满了风扇声、空调底噪的音频折磨,又不想为了去噪引入一整套深度学习框架,那你大概率需要一段能直接看懂、能跑起来的音频去噪C++代码。我整理的这个项目就是用C++写的谱减法去噪实现,依赖少、逻辑直观、注释清晰,适合你快速移植进自己的音频处理管线,也适合拿来入门语音前端处理。
代码解决的是最实际的问题:给定一段PCM音频,去掉平稳背景噪声的同时尽可能保留语音清晰度。它不需要GPU、不需要训练数据,一台普通PC上处理几秒钟音频几乎感觉不到耗时。无论是给录音软件做预处理,还是给嵌入式设备做离线降噪,这套代码都可以作为起点。我把算法选型、工程结构、核心实现、编译使用和调参经验全部拆开讲一遍,该避的坑也会一并说清楚。
1. 去噪算法的选型思路:为什么我在C++工程里选谱减法
先说清楚一个经常被误解的事:音频去噪不等于都要上神经网络。很多项目一看“降噪”两个字,第一反应是RNN、U-Net或者是端到端模型。但落到C++工程里,要考虑部署体积、推理速度、可解释性和调试成本。深度模型效果上限确实高,但一个注释清晰、可单步调试的传统算法往往是解决80%场景的最快路径。
1.1 传统方案横评:维纳滤波、小波去噪、谱减法该选谁
传统音频去噪有三大类常见方案,我当初都仔细比较过,也分别写过测试代码验证效果。
| 方案 | 核心思路 | 优点 | 缺点 | 适合场景 |
|---|---|---|---|---|
| 维纳滤波 | 估计信号和噪声的统计特性,设计最优线性滤波器 | 稳态噪声下理论性能好,语音失真较小 | 需要实时估计SNR,参数推导复杂,非平稳噪声下表现一般 | 预先知道噪声类型的离线处理 |
| 小波去噪 | 对信号做小波分解,把噪声对应的小波系数收缩置零后重构 | 对脉冲噪声效果好,多分辨率分析灵活 | 阈值选择经验性强,计算开销偏大,C++实现门槛偏高 | 非平稳、瞬态噪声占主导的场景 |
| 谱减法 | 从带噪语音频谱中减去估计出的噪声频谱 | 原理直观,复杂度低,只需FFT和幅度谱运算 | 处理不当会引入“音乐噪声”,对非平稳噪声效果有限 | 稳态背景噪声、实时性要求高的场景 |
这个表是筛选结论,不是最终答案。真正让我下决心用谱减法的是两个工程现实:第一,算法链路短,从PCM输入到降噪输出只有分帧、FFT、减谱、IFFT四步,每一帧都能独立调试;第二,需要写的代码量少,整个核心类只用一个文件就能装下,这恰好符合“注释清晰可用”的项目定位。
1.2 谱减法为什么匹配“注释清晰可用”这个定位
很多人以为选型就是选一个效果最好的,但实际工程里选型是在多重约束下做取舍。我选谱减法还有几个不太容易被注意到但很重要的理由。
- 数学表达极其简洁。语音频谱减去噪声频谱,公式写出来就两三行,给任何维护代码的同事看一遍就能懂,不需要解释什么是状态空间模型。
- FFT库非常成熟。C++生态里FFTW是久经考验的底层库,单精度接口效率高,而且对ARM、x86平台都有良好支持,不会成为移植的瓶颈。
- 参数数量少,可解释性强。真正需要调的只有过减因子、谱底限幅系数、噪声更新速度这几个量,出问题可以很快定位是算法问题还是参数问题。
- 中间结果便于验证。我可以把分帧后的数据、FFT后的幅度谱、噪声估计谱分别dump成文本文件,在Python里画图对比,这对调试和写注释都有很大帮助,深度学习模型很难做到这种逐级验证。
补充一句,谱减法并不是万能的,它的理想假设是噪声谱平稳且与语音不相关。如果你要处理的是街道上稀疏的汽车鸣笛、键盘敲击声这类瞬态噪声,谱减法基本无能为力,需要用专门的瞬态噪声压制手段。这个项目解决的是风扇声、环境底噪、录音设备自带的平稳噪声这一类最常见问题。
2. 工程结构:一个刚够用、又不臃肿的C++去噪模块怎么搭
代码的工程结构,是我在重写第三版时才定下来的。第一版把所有逻辑堆在main函数里,能跑但根本没法给别人看;第二版拆分得过于细碎,结构体、接口层层嵌套,注释写得再多也难读。最终版遵循一个原则:让读者从类接口就能猜到处理流程,从注释就能知道每一段代码为什么存在。
2.1 文件划分与类的职责边界
整个项目只有四个文件,多的一个都没有。
denoise/ ├── CMakeLists.txt ├── include/ │ └── AudioDenoiser.h ├── src/ │ └── AudioDenoiser.cpp └── app/ └── main.cppAudioDenoiser是核心类,负责从PCM数据进来到降噪后PCM数据出去的全过程。头文件里公开的接口尽量精简,方便外部使用:
class AudioDenoiser { public: struct Config { int sampleRate = 16000; // 采样率,Hz int frameSize = 512; // 帧长,采样点数 int hopSize = 256; // 帧移,采样点数,与frameSize配合实现50%重叠 float alpha = 1.5f; // 过减因子,越大噪声消得越狠 float beta = 0.01f; // 谱底限幅系数,越小越容易产生音乐噪声 int noiseFrames = 15; // 用于估计初始噪声谱的起始帧数 }; explicit AudioDenoiser(const Config& config); std::vector<float> process(const std::vector<float>& pcm); };process函数只接收单声道float数组,输出等长的PCM数据。刻意没把WAV读写逻辑放进类里,因为读取音频文件属于I/O层,去噪算法属于信号处理层,混在一起会让测试和复用都变得困难。main.cpp里用libsndfile读取WAV、调用process、再写回文件,职责非常清楚。
2.2 数据流与接口设计背后的考虑
整个处理流程用一张图就能说清,我写代码时也把这张图画在了头文件的注释顶部,方便阅读者快速建立整体认知。
PCM输入 -> 去直流(可选) -> 分帧 -> 加汉宁窗 -> FFT -> 功率谱计算 -> 噪声谱估计/更新 -> 谱减 -> 增益计算 -> 频域乘增益 -> IFFT -> 重叠相加 -> PCM输出接口设计上有几个细节值得说。
- 用float不用double。音频信号本身精度需求不高,float的24位尾数对16bit PCM绰绰有余,而且单精度运算在SIMD向量化时吞吐量高一倍,对嵌入式平台也更友好。
- process传入的是整个音频向量而不是逐帧回调。这样便于使用者做批量处理,也方便我写单元测试时直接造正弦波混合数据去验证。如果你需要实时流式处理,把分帧逻辑改成带状态的流式接口即可,核心算法不用动。
- Config里不暴露FFT长度等底层参数。frameSize、hopSize已经足够描述分帧行为,FFT长度在构造时内部推导为frameSize,少一个参数就少一个配置错误的机会。
还有一个容易踩的坑:预处理阶段最好先去直流偏置。很多音频采集设备会引入一个极低频的直流分量,如果不去掉,FFT后第0个频点会异常偏大,导致这个频点被谱减错误处理,输出出现严重的低频爆破声。我在process里会先对整个信号做一次均值相减,成本极低但非常有用。
3. 核心实现拆解:从PCM到频谱,从噪声估计到谱减恢复
这一节是整篇最硬核的部分。我会沿着代码实际执行的顺序,把分帧、加窗、FFT、噪声估计、谱减、IFFT每个环节的原理和代码细节说清楚,同时说明注释里写了什么、为什么这么写。读懂这一段,你就能自己维护和扩展这套代码。
3.1 分帧加窗:为什么帧长512、重叠50%
音频去噪不是对整个文件做一次FFT,而是分帧处理。语音信号在几十毫秒内可以看作平稳随机过程,超过这个范围统计特性就会变化。我默认设frameSize=512,对应16kHz采样率下32ms,正好落在这个平稳区间内。
为什么不是更长或更短:
- 帧长越长,频率分辨率越高,但时间分辨率越差,去噪时容易把短暂的语音碎成碎片,也更容易产生音乐噪声。
- 帧长越短,时间分辨率越好,但频率分辨率不足,低频段的噪声估计会很粗糙,去噪后容易感觉“闷”。
- FFT要求帧长是2的幂,512在大多数场景下是兼顾分辨率和计算效率的平衡点。如果采样率是44.1kHz,我会把frameSize调大到1024,换算成时间约23ms,效果更稳。
分帧后必须加窗。直接对原始帧做FFT相当于加了矩形窗,频谱旁瓣很高,会让噪声能量泄漏到相邻频点。我用的汉宁窗,公式和代码如下:
std::vector<float> createHanningWindow(int size) { std::vector<float> window(size); for (int i = 0; i < size; ++i) { window[i] = 0.5f * (1.0f - std::cos(2.0f * M_PI * i / (size - 1))); } return window; }50%重叠不是随便定的。汉宁窗满足COLA(constant overlap-add)条件,在50%重叠时,所有帧加窗后叠加起来的增益是常数,这意味着只要你的分析窗满足这个条件,合成阶段不需要额外做归一化,直接重叠相加就能完美重构原始信号。注释里我会特别标注这一条,防止后来的人在修改hopSize时破坏了重构性质。
3.2 噪声估计与谱减规则:过减因子、谱底限幅的确定
谱减法最核心的问题是:怎么知道当前噪声长什么样?
我的做法分为两步。第一步,用开头若干帧做初始噪声谱估计。语音通常不会在录音第0毫秒就爆发,前15帧(约0.5秒)可以假设为纯噪声,这15帧的功率谱均值就作为初始噪声谱。
// 初始噪声谱估计:取前noiseFrames帧的功率谱平均值 for (int frameIdx = 0; frameIdx < noiseFrames; ++frameIdx) { // 对当前帧做FFT,得到实部re、虚部im for (int k = 0; k < halfSpectrum; ++k) { float power = re[k] * re[k] + im[k] * im[k]; noiseSpectrum[k] = (noiseSpectrum[k] * frameIdx + power) / (frameIdx + 1); } }第二步,在后续处理中,当检测到当前帧是噪声帧时,用指数平滑方式缓慢更新噪声谱。这里有一个工程判断:我用一个简单的能量阈值判断当前帧是否包含语音。如果当前帧总能量低于前导噪声平均能量的1.5倍,就认为它是噪声帧,用来更新噪声模型;否则保留现有噪声谱。这种方式比全程固定噪声谱要鲁棒,能适应缓慢变化的背景噪声。
谱减核心规则如下,也是整个项目注释最密集的地方。
float noisePower = noiseSpectrum[k] * noiseGain; // noiseGain略大于1,等于给噪声估计留出余量 float totalPower = re[k] * re[k] + im[k] * im[k]; float cleanPower = totalPower - alpha * noisePower; // 经典谱减公式 if (cleanPower < beta * totalPower) { // 谱底限幅,防止负值导致的音乐噪声 cleanPower = beta * totalPower; } float gain = std::sqrt(cleanPower / (totalPower + 1e-10f)); re[k] *= gain; // 对实部、虚部乘同一个增益,保留原始相位 im[k] *= gain;其中alpha是过减因子,默认1.5。它控制减掉多少噪声能量:alpha偏小,残留噪声多,但语音失真小;alpha偏大,噪声更干净,但语音会明显变闷,甚至产生颤抖感。beta是谱底限幅系数,默认0.01,目的是防止cleanPower减成负数。如果不加这个下限,负值开方会直接出错,加上一个很小的下限值,能显著降低音乐噪声的可感知度。
3.3 合成阶段与音乐噪声的抑制
谱减之后得到的是修正后的幅度谱,我没有修改相位,直接用原始帧的相位做IFFT。这是语音增强里一个被反复验证的做法:人耳对相位不敏感,保留带噪相位带来的失真远小于重新估计相位的代价。更重要的是,保留原始相位让代码实现大幅简化,每一帧IFFT之后做重叠相加就能恢复时域信号。
音乐噪声是谱减法绕不开的副产品。它的听感像背景里有一种忽隐忽现的“水声”或“嘶嘶声”,成因是谱减后某些频点残留了随机出现的窄带峰值。抑制音乐噪声我在代码里做了两件事:
- 把beta限制在合理区间。beta太大会让噪声泄漏过多,beta太小音乐噪声可感知度变强,实测0.01到0.05之间是个比较安全的窗口。
- 在频域对增益做时间上的平滑。具体做法是让当前帧的增益不要突跳,跟上一帧增益做一阶滞后处理:
gainSmooth = 0.7f * gainCurrent + 0.3f * gainPrev。这个处理会让增益变化更平缓,音频听起来更自然。
合成阶段的IFFT和重叠相加代码不复杂,但有一个细节值得注意:因为加了汉宁窗并做了50%重叠,IFFT出来的帧数据要等下一次相邻帧加窗叠加才能完整还原信号前半部分。所以我在类里用一个缓冲区累积上一帧的IFFT结果,新帧算完后对应位置相加再输出。最后把输出数据除以2,因为50%重叠加窗后信号幅度会加倍,这一步在代码注释里写得很明确,防止后人误以为是普通音量缩放。
4. 编译、使用与实测:怎么跑起来,以及不同场景下参数怎么调
代码写得再清楚,跑不起来也是废的。这一节我会给出完整的依赖清单、CMake配置和命令行用法,然后用几组实际测试说说效果差异。参数调优是音频去噪里最依赖经验的部分,我把踩过的坑一并列出来。
4.1 依赖与CMake构建
项目依赖两个库:FFTW3负责FFT计算,libsndfile负责读写WAV文件。在Ubuntu系统上安装命令一条搞定:
sudo apt install libfftw3-dev libsndfile1-dev cmake g++CMakeLists.txt我写得很克制,没有引入外部子模块,方便你直接并入现有工程:
cmake_minimum_required(VERSION 3.16) project(audio_denoise CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) find_package(FFTW3 REQUIRED) find_package(SndFile REQUIRED) add_library(denoise_core src/AudioDenoiser.cpp) target_include_directories(denoise_core PUBLIC include) target_link_libraries(denoise_core PRIVATE fftw3f sndfile) add_executable(denoise_app app/main.cpp) target_link_libraries(denoise_app PRIVATE denoise_core)编译和运行:
cmake -B build -DCMAKE_BUILD_TYPE=Release cmake --build build -j ./build/denoise_app input.wav output.wav --sample-rate 16000 --alpha 1.5命令行参数在main.cpp里用简单的字符串解析实现:input.wav是原始带噪音频,output.wav是降噪后音频,其余可选参数会覆盖Config中对应的默认值,方便你在不修改代码的情况下快速试参数。
4.2 三种典型噪声场景的实测表现
我在三段真实录音上做了测试,一段是白噪声污染的语音,一段是空调风扇稳态噪声下的录音,一段是办公室键盘和谈话混合噪声。主观听感和参数建议整理如下:
| 噪声类型 | 输入听感 | 降噪后效果 | 推荐参数 |
|---|---|---|---|
| 白噪声 | 整体有明显沙沙声,语音勉强可懂 | 背景噪声基本消失,语音清晰,有轻微颗粒感 | alpha=1.5,beta=0.01 |
| 空调稳态噪声 | 低频轰隆声持续存在,语音被掩蔽 | 低频明显收敛,语音可懂度大幅提升,留少量自然底噪 | alpha=1.8,beta=0.03 |
| 办公室混合噪声 | 杂音多,时有时无 | 平稳段落有明显改善,瞬态噪声如键盘声残留 | alpha=1.5,配合后续瞬态处理 |
这组结果说明一个关键经验:不要追求绝对寂静。把alpha拉到2.5以上确实能把噪声压得更狠,但语音会失真非常明显,听起来像隔着水在说话,反而不利于听清内容。合适的alpha值是让残留噪声降到“不干扰理解”的程度,而不是归零。
4.3 我最常被问到的几个调参问题
我把过去一段时间被问得最多、也是我自己反复踩过的坑集中回答一下。
- alpha到底调到多少最合适。看场景采样率和帧长。16kHz采样率下语音集中在0到4kHz,过减因子给大一点问题不大;44.1kHz音乐素材里语音带更宽,alpha超过1.8容易把中高频细节削没。原则是从1.5起步,每次加0.2,听到语音变“闷”或发“嗡”就回到上一档。
- 为什么总有“水声”和“金属声”。大概率是beta设置太低,cleanPower频繁被压到很接近0,IFFT后的时域包络出现剧烈起伏。先把beta提到0.05试一次,如果音乐噪声还在,考虑用我在3.3节说的增益平滑,而不是继续加大alpha。
- 为什么开头的语音字被吃掉了。这是因为前导静音太短,算法还没建立准确的噪声谱,前几个语音帧被当成了噪声来减。解决办法是把录音文件开头的静音段留长到400毫秒以上;如果原始录音不允许,就把noiseFrames调低,同时把噪声更新速度调慢,可以缓解这个问题。
- 为什么输出音量比输入小。谱减本质是在做能量衰减,输出信号整体电平比输入低是正常的。代码里可以加一个自动增益补偿,统计输入输出RMS比例后统一抬升,也可以在外层音频处理链路里做响度归一化,不要直接在谱减法去噪后拉满音量,否则残留噪声也会被同步放大。
最后说一说代码之外的事
代码部分到上一节就算讲完了。最后分享一点我在这个项目里最深的两点体会,不算教学,纯粹是经验。
第一点关于注释。很多人写注释是在解释“这段代码在做什么”,但代码本身已经表达了行为,真正需要解释的是“为什么这么做”。我在这个项目里凡是涉及算法选型的地方,比如窗口的类型、重叠长度的选取、过减因子的默认值,都会在注释里写明当时的测试结论和引用的思路来源。这样做的好处是,三个月后你回来看代码,不需要重新读一遍论文才能改参数。以后你可以试试这个习惯:把每一处“魔数”都写成带命名的常量,并在常量旁注释其来由,你会发现维护代码的心态完全不一样。
第二点关于工具边界。谱减法是一把锋利的刀,但它的适用边界非常清晰。如果你的项目主要处理的是环境突变噪声、多人重叠语音、混响严重的录音,那么无论怎么调参,谱减法都会让你撞上上限。这时候正确的做法不是继续死磕alpha和beta,而是换一个其他类别的算法,或者在谱减之后串接若干后处理模块。我在项目文档里也明确写了这一点:这段代码适合做“预处理”,把一个中等信噪比的录音变成信噪比更高的录音,而不是做“重构”,把完全被掩蔽的语音凭空变出来。
你可以从今天这个项目开始,先把WAV读取、分帧、FFT、谱减、IFFT、重叠相加这一条链路完整跑通,然后放心地加注释、改参数、尝试新想法。音频处理这个东西,真正积累了手感之后,才会发现自己当初那些困惑都很值。
本文还有配套的精品资源,点击获取