ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

用C++和JUCE实现多频段动态范围压缩器:分频、包络与界面设计

用C++和JUCE实现多频段动态范围压缩器:分频、包络与界面设计

简介:这是基于 C++ 与 JUCE 框架开发多频段动态范围压缩器音频插件的完整工程,面向音频插件开发者、信号处理初学者及音乐制作技术人员,可帮助理解从频段划分、动态检测、阈值/Attack/Release 压缩控制到增益补偿与信号合成的实现路径。压缩包共 81 个文件,以 cpp/h 源码、jucer 工程配置、wav 调试音频和 png 界面截图为主,另有 README 与 LICENSE,整体约 8.88MB;既有 PluginProcessor、Compressor 等核心代码,也包含 JuceLibraryCode 自动生成模块,适合对照学习 JUCE 插件的工程组织方式。内容预览中的 sweep_high/mid/low 及 drums_dry/lowcomp/highcomp 等示例文件,可用于检验各频段压缩和分频效果;sweep_filter 与 spectrogram 图像则辅助分析滤波器与频段边界特性。目前已有 249 人学习下载,对希望快速上手 JUCE 音频插件开发、并想结合可视化调参界面完善多频段压缩器的读者,具有直接参考价值。

1. 多频段动态范围压缩器难在分频与衔接:不如先看懂这个JUCE实现

去年做母带处理链时碰到一个很现实的问题:直接用宽频段压缩器推整个混音,低频鼓组一响,高频的空气感和镲片细节就被一起压掉了。换成多频段动态范围压缩器是行业里最常见的解法,但商业插件是个黑匣子,参数很多却不知道内部怎么处理。这个项目就是为搞清楚这件事而写的:用C++与JUCE实现了一个三频段动态范围压缩器,核心链路包括Linkwitz-Riley四阶分频、包络检测、增益计算与平滑,以及可拖拽的频段分割点和实时频谱显示。适合想从零做音频插件、或想弄明白多频段压缩内部信号流的人。源码拿到手后先不用急着往宿主里装,把分频重建这条路走通,后面所有参数才有意义。

2. 架构与技术选型:为什么用C++和JUCE搭一套三频段压缩器

2.1 技术选型:从DSP原型到可分发插件

做音频插件选择C++几乎是必然。Python能快速验证算法,但没法直接产出VST3、AU这类可在宿主里加载的二进制;C语言裸写又要自己管窗口、事件循环和跨平台编译。JUCE把这层全包了:它内置dsp模块,提供滤波器、FFT、过采样、音频缓冲封装,UI层还自带Knob、Slider、Graphics绘图能力,一套代码同时编译出VST3、AU、Standalone。这个项目正是走了这条最成熟的路线——DSP逻辑与界面完全解耦,dsp目录下可以脱离GUI单独做单元测试。

工程里我最欣赏的一点是它没有把DSP和UI揉在PluginProcessor一个文件里。Processor只做参数路由、延迟补偿和音频块分发,真正的算法全在src/dsp下的几个类中。这样做的直接好处是:跑自动化测试时不需要实例化任何界面组件,构建时间也大幅缩短。如果你之前只在脚本环境里验证过压缩算法,这个工程的拆分方式值得直接抄。

2.2 工程目录与整体结构

源码包拿到后,目录结构大致是下面这个样子。头文件与实现分离,dsp层和ui层完全独立,Processor作为两者的中介。

MultiBandCompressor/ ├─ CMakeLists.txt ├─ src/ │ ├─ PluginProcessor.h / .cpp │ ├─ PluginEditor.h / .cpp │ ├─ dsp/ │ │ ├─ CrossoverBank.h / .cpp │ │ ├─ EnvelopeFollower.h / .cpp │ │ ├─ CompressorBand.h / .cpp │ │ └─ GainSmoother.h / .cpp │ └─ ui/ │ ├─ BandSplitView.h / .cpp │ ├─ MeterView.h / .cpp │ └─ SpectrumAnalyzer.h / .cpp

PluginProcessor负责AudioProcessorValueTreeState参数树、每block的调度;CrossoverBank管理两组Linkwitz-Riley滤波器,完成low/mid/high三频段拆分;CompressorBand内部持有EnvelopeFollower与GainSmoother,实现单频段压缩;BandSplitView是界面层最核心的自定义组件,处理分频点拖拽与刻度绘制。UI与DSP之间只通过参数树传递数据,没有直接调用关系,这意味着改界面不会碰算法,改算法也不会动界面。

2.3 构建与运行环境

这个项目用CMake构建,JUCE 7以上版本都支持这种模式。我实际构建时的环境是Ubuntu 22.04 + CLion,macOS和Windows同样能编。关键点是先确认本机CMake版本不低于3.15,编译器支持C++17,然后直接走标准流程。

cmake -B build -DCMAKE_BUILD_TYPE=Release cmake --build build --target MultiBandCompressor_VST3 --config Release

第一条命令做配置,第二条指定只编译VST3目标。产物会出现在build/MultiBandCompressor_artefacts/Release/VST3目录下,把里面的.so或.vst3文件拷到宿主插件目录即可。注意第一次构建会连同JUCE子模块一起编译,耗时较长;如果只想快速验证DSP逻辑,可以只编Standalone目标,宿主都不用开。

构建时有一处容易踩坑:CMake首次配置会拉取JUCE的依赖头文件,如果网络不稳定,配置过程会卡在FetchContent阶段。常见处理是先手动下载JUCE源码到本地,再用CMAKE_PREFIX_PATH指定路径,绕开在线拉取。遇到这类问题不用慌,看CMakeCache.txt里的FetchContent目录就能确认是哪一步断了。

3. 核心信号链拆解:从分频到压缩再到增益恢复的完整链路

3.1 分频滤波器组:Linkwitz-Riley四阶分频如何拼出三频段

分频是整个多频段压缩的地基。这里用的是Linkwitz-Riley滤波器,它的特点是低通与高通在分频点幅度互补,两者相加能在幅度上重建原信号而不产生凸起。CrossoverBank里放了四组LR滤波器:第一对以lowFreq为分频点,把原信号拆成low与rest;第二对以midFreq为分频点,再把rest拆成mid与high。

// dsp/CrossoverBank.h #pragma once #include <juce_dsp/juce_dsp.h> class CrossoverBank { public: using Filter = juce::dsp::LinkwitzRileyFilter<float>; void prepare(const juce::dsp::ProcessSpec& spec) { dryBuffer.setSize(spec.numChannels, spec.maximumBlockSize); restBuffer.setSize(spec.numChannels, spec.maximumBlockSize); lowBuffer.setSize(spec.numChannels, spec.maximumBlockSize); midBuffer.setSize(spec.numChannels, spec.maximumBlockSize); highBuffer.setSize(spec.numChannels, spec.maximumBlockSize); for (auto& f : filters) f.prepare(spec); } void setCrossover(float lowFreq, float midFreq) { filters[0].setType(Filter::Type::lowpass); filters[0].setCutoffFrequency(lowFreq); filters[1].setType(Filter::Type::highpass); filters[1].setCutoffFrequency(lowFreq); filters[2].setType(Filter::Type::lowpass); filters[2].setCutoffFrequency(midFreq); filters[3].setType(Filter::Type::highpass); filters[3].setCutoffFrequency(midFreq); } void process(juce::AudioBuffer<float>& buffer) { dryBuffer.makeCopyOf(buffer); // 低频段:原信号直接低通 filters[0].process(juce::dsp::AudioBlock<float>(buffer)); lowBuffer.makeCopyOf(buffer); // rest = 原信号高通,即去掉低频后的余量 buffer.makeCopyOf(dryBuffer); filters[1].process(juce::dsp::AudioBlock<float>(buffer)); restBuffer.makeCopyOf(buffer); // 中频段 = rest 再低通 midBuffer.makeCopyOf(restBuffer); filters[2].process(juce::dsp::AudioBlock<float>(midBuffer)); // 高频段 = rest 再高通 highBuffer.makeCopyOf(restBuffer); filters[3].process(juce::dsp::AudioBlock<float>(highBuffer)); } private: Filter filters[4]; juce::AudioBuffer<float> dryBuffer, restBuffer; juce::AudioBuffer<float> lowBuffer, midBuffer, highBuffer; };

关键逻辑在process函数里:先把原信号备份到dryBuffer,然后依次处理。低频段从原始信号低通得到,剩下三组滤波器负责从原信号的高通余量里继续拆分。由于Linkwitz-Riley低通与高通在分频点处相位对齐、幅度互补,low + rest等于原信号,rest再分成mid和high后,整条链路的重建关系就是low + mid + high = input。这个过程不需要做原信号相减,天然避免了梳状滤波。

参数上要注意setCrossover里的两个频率值,单位是Hz,低频分频点建议设在80到300之间,高频分频点设在2000到4000之间。分频点过低会让低频段失去弹性,过高则中频段覆盖太宽压缩感不明显。滤波器的阶数没有额外配置,JUCE的LinkwitzRileyFilter默认提供四阶(24dB/oct)特性,对大多数音乐素材已经够用;想更陡可以级联两组,但相位延迟也会翻倍,得不偿失。

3.2 包络检测与增益计算:RMS还是峰值,阈值和比例怎么配合

频段拆分完成后,每个频段各走一路独立的压缩器。包络检测用的是经典的单极平滑器,对输入信号的幅值求RMS再转dB。RMS更适合音乐素材,峰值检测则适合鼓这类瞬态信号。这个实现选择了RMS,并且把攻击和释放时间都参数化,方便在界面上实时调整。

// dsp/EnvelopeFollower.h #pragma once #include <cmath> class EnvelopeFollower { public: void prepare(double sampleRate) { fs = sampleRate; reset(); } void reset() { envelope = 0.0f; } void setAttack(float ms) { attackCoeff = std::exp(-1.0f / (ms / 1000.0f * (float)fs + 1e-6f)); } void setRelease(float ms) { releaseCoeff = std::exp(-1.0f / (ms / 1000.0f * (float)fs + 1e-6f)); } float process(float sample) { float absVal = std::fabs(sample); float coeff = absVal > envelope ? attackCoeff : releaseCoeff; envelope = coeff * envelope + (1.0f - coeff) * absVal; return envelope; } float getEnvelope() const { return envelope; } private: float fs = 44100.0f; float envelope = 0.0f; float attackCoeff = 0.0f; float releaseCoeff = 0.0f; };

这里攻击和释放系数用的是RC时间常数公式,exp(-1/(tau*fs))。短到1ms的attack实际上会对峰值做轻微平滑,避免逐样本跳变;release建议不小于50ms,否则压缩后的增益恢复会产生明显的“呼吸感”。计算dB时我会先拿envelope做log10再乘20,小于1e-5的幅值统一钳位,防止负无穷dB出现。

增益计算是压缩器最核心的一步,这里实现了soft knee。硬拐点压缩在阈值处增益变化不连续,听感生硬;soft knee用一个抛物线过渡带让压缩量从0平滑过渡到目标比例。

// dsp/CompressorBand.h #include "EnvelopeFollower.h" #include "GainSmoother.h" class CompressorBand { public: void prepare(const juce::dsp::ProcessSpec& spec) { envelope.prepare(spec.sampleRate); smoother.prepare(spec.sampleRate); } void setParameters(float threshDb, float ratio, float kneeDb, float attackMs, float releaseMs) { thresholdDb = threshDb; compRatio = ratio; kneeWidthDb = kneeDb; envelope.setAttack(attackMs); envelope.setRelease(releaseMs); } float computeGain(float envDb) const { float overDb = envDb - thresholdDb; if (overDb <= -kneeWidthDb * 0.5f) return 0.0f; if (overDb >= kneeWidthDb * 0.5f) return -overDb * (1.0f - 1.0f / compRatio); // soft knee 抛物线过渡 float x = overDb + kneeWidthDb * 0.5f; return (1.0f / compRatio - 1.0f) * x * x / (2.0f * kneeWidthDb); } void process(juce::AudioBuffer<float>& bandBuffer) { ... } private: EnvelopeFollower envelope; GainSmoother smoother; float thresholdDb = -20.0f; float compRatio = 3.0f; float kneeWidthDb = 6.0f; };

computeGain返回的是负的增益变化量:超过阈值越多,压得越多。当overDb在knee范围内时,抛物线保证在overDb为0时增益为0,在knee边缘与硬拐点直线段相切,所以参数连续变化时听感没有台阶。ratio参数的含义是压缩比:2:1表示输入超过阈值2dB只输出1dB,overDb越大压缩量越大。

3.3 增益平滑与补偿:为什么压缩完要再过一道Smoother

计算出目标增益后,如果直接乘到音频样本上,参数一拖动就会产生click声。这里的处理方式是不对线性增益做平滑,而是把目标增益先保持在dB域,用单极平滑器做 attack/release 过渡,再转回线性值去乘信号。

// dsp/GainSmoother.h #pragma once #include <juce_dsp/juce_dsp.h> class GainSmoother { public: void prepare(double sampleRate) { fs = (float)sampleRate; currentDb = 0.0f; targetDb = 0.0f; setRate(50.0f); } void setRate(float ms) { coeff = std::exp(-1.0f / (ms / 1000.0f * fs + 1e-6f)); } void setTarget(float db) { targetDb = db; } float process() { currentDb += (targetDb - currentDb) * (1.0f - coeff); return juce::Decibels::decibelsToGain(currentDb); } private: float fs = 44100.0f; float coeff = 0.0f; float currentDb = 0.0f; float targetDb = 0.0f; };

smoother的setRate我一般会设成5ms,比包络检测的攻击时间更短,保证增益变化跟得上音乐瞬态。这里的关键选择是在dB域平滑:人耳对dB感知近似线性,dB域匀速变化听起来就是均匀的压缩过程;如果在线性域平滑,音量从0.5到0.25的变化速率在听感上是先快后慢,很不自然。

CompressorBand里最终把envelope取dB送到computeGain,得到目标增益dB后交给smoother过渡,再乘回频段缓冲。三个频段各自做完这套流程后,在Processor里相加还原为完整信号。此时如果只压不补,整体响度会明显下降,所以Processor还额外加了一个自动makeup:根据压缩量估算需要补的增益,这个值同样经过smoother,避免响度跳变。

4. 界面交互与参数映射:拖拽分频点背后的坐标与数据流动

4.1 参数树设计:AudioProcessorValueTreeState作为唯一数据源

PluginProcessor里用AudioProcessorValueTreeState统一管理所有参数。这不是为了省事,而是音频插件必须让参数能被宿主自动化记录、保存preset,并且确保UI线程与音频线程之间参数访问是线程安全的。

// PluginProcessor.cpp using Parameter = juce::AudioParameterFloat; juce::AudioProcessorValueTreeState::ParameterLayout createParameterLayout() { juce::AudioProcessorValueTreeState::ParameterLayout layout; layout.add(std::make_unique<Parameter>( "splitLow", "Split Low", juce::NormalisableRange<float>(80.0f, 800.0f, 1.0f), 200.0f)); layout.add(std::make_unique<Parameter>( "splitMid", "Split Mid", juce::NormalisableRange<float>(800.0f, 8000.0f, 1.0f), 2000.0f)); layout.add(std::make_unique<Parameter>( "threshLow", "Thresh Low", juce::NormalisableRange<float>(-60.0f, 0.0f, 0.1f), -20.0f)); layout.add(std::make_unique<Parameter>( "ratioLow", "Ratio Low", juce::NormalisableRange<float>(1.0f, 20.0f, 0.1f), 3.0f)); // mid/high 频段的阈值、比例、attack、release 同理再补 8 个参数 return layout; }

这些参数对象在创建时就被赋予默认值,宿主打开插件时直接读取;拖动界面旋钮时,SliderAttachment会写回参数树,音频线程在每个block的开头统一拉取新值。这里注意NormalisableRange的min和max决定了旋钮的映射范围,ratio设成1到20就够了,超过20压缩曲线基本趋近于限制器,对音乐处理没有实际意义。

4.2 分频点拖拽的坐标映射:对数频率轴才是人能操作的

界面上的分频点拖拽看似简单,实际有一个隐蔽的坐标问题:频率轴不能是线性的。20Hz到20kHz跨越三个数量级,线性轴会把低频段挤在最左边像素级的一小块区域,拖拽精度完全没法看。这个项目的SpectrumView采用对数频率轴,把频率映射到像素的公式是关键。

// ui/BandSplitView.cpp float BandSplitView::freqFromX(int x) const { auto t = juce::jlimit(0.0f, 1.0f, (float)x / (float)getWidth()); // 20 Hz ~ 20 kHz 对数映射:20 * 10^(t * 3) return 20.0f * std::pow(10.0f, t * 3.0f); } int BandSplitView::xFromFreq(float freqHz) const { float t = std::log10(freqHz / 20.0f) / 3.0f; return juce::roundToInt(t * (float)getWidth()); }

映射公式基于10的幂次:freq = 20 * 10^(t*3),t从0到1。这样低频200Hz与高频5000Hz在屏幕上占据的视觉宽度比例接近人耳感知,拖拽时低频段的变化速度也更均匀。xFromFreq是鼠标拖拽后把新位置写回参数的逆运算,两个函数必须严格互逆,否则每次拖拽分频点都会漂移。

参数更新后CrossoverBank需要随之重算滤波器系数,这里有个细节不能忽略:滤波器系数更新必须在音频线程内完成,不能直接在鼠标事件里改。界面拖拽时先把新频率写入一个原子变量,音频线程的processBlock里检测到值变化后再调用setCrossover,这样既响应及时,也不会在实时处理中产生系数突变。

4.3 实时频谱显示:用JUCE dsp::FFT画出三个频段的处理结果

频谱可视化的实现用了JUCE自带的dsp::FFT,每帧拿到音频数据后做加窗FFT,再取幅度谱绘制。界面刷新与音频回调是分离的,FFT计算在timer回调里做,避免阻塞实时线程。

// ui/SpectrumAnalyzer.cpp void SpectrumAnalyzer::timerCallback() { // 从环形缓冲取最近一段音频 int numSamples = juce::dsp::FFT::getFFTSize(fftOrder); if (ringBuffer.getNumReady() < numSamples) return; // 加窗并拷贝到FFT输入缓冲 for (int i = 0; i < numSamples; ++i) windowedData[i] = ringBuffer.pop() * window[i]; fft.performFrequencyOnlyForwardTransform(windowedData); repaint(); }

FFT的阶数固定为12,也就是每帧4096个样本点,约92ms时间窗,频率分辨率大概10.7Hz,够显示频谱的大致轮廓。绘制时把幅度谱从线性值换算成dBFS,再按对数频率轴映射到组件宽度。这里UI线程每50ms刷新一次就够了,刷新太快反而会因为repaint抢占CPU,导致实时音频处理出现卡顿。

拖拽分频线时,界面会把当前分频点对应的频率值以Hz为单位显示在标签上。这个显示看起来简单,但对使用体验影响很大:没有频率刻度的多频段压缩器,调整时分频点全靠猜,用户根本不知道自己在处理哪段频率。动手改这个项目时,建议把频谱图上显示的刻度间距对齐到标准1/3 octave,这样拖拽结果能和混音经验直接对应。

5. 常见问题与避坑:五条踩过的实时音频处理教训

5.1 分频点附近出现相位凹坑

现象:单独给中频段播放扫频信号,分频点附近音量明显下凹,听起来像是中频“空了一块”。

原因:Linkwitz-Riley滤波器虽然保证低通+高通幅度互补,但如果各频段的处理路径上累积了不同的额外延迟,重建相加时会在分频点附近产生相位抵消。

解决:确保三频段都从同一条原始信号链路出发,并且不在某个频段额外插入延迟。这个项目里中频段由rest再低通得到,链路层级比高频段多一级,我在Processor里对高频段做了与低频段对齐的全通延迟补偿。动手改时,可以用扫频正弦信号过一遍CrossoverBank,把输出FFT后的频谱打印出来,看到分频点有下凹就说明延迟没对齐。

5.2 参数一拖就爆音

现象:宿主里拖动压缩比或阈值时,立刻听到“咔哒”一声。

原因:系数在音频线程中被直接修改,滤波器或增益路径来不及过渡。尤其是ratio这种非线性参数,新旧差异大时突变明显。

解决:所有界面可控参数走AudioProcessorValueTreeState,而且在CompressorBand内部,阈值、ratio、knee都先暂存,在processBlock开头统一更新;对听觉敏感的阈值和增益补偿,额外过一层GainSmoother。从那以后我每次调参数都先确认目标值是否经过平滑链,不让任何参数直接作用于逐样本计算。

5.3 实时回调里分配内存导致爆音

现象:插件运行几分钟后开始随机爆音,尤其是波形界面打开时更频繁。

原因:在processBlock里做了std::vector的resize、Append或不必要的拷贝。实时音频线程不允许动态内存分配,RT调度下内存锁竞争会造成无法预期的延迟。

解决:所有中间缓冲区全部在prepare里按最大block大小初始化。这个项目的CrossoverBank里,dryBuffer、restBuffer和三个频段缓冲都是成员变量,process只做拷贝和滤波,不做任何new/delete。检查代码时我习惯用grep找processBlock路径上的new、malloc、resize,一旦出现立即移到prepare里。

5.4 分频点设置太近导致频段能量泄漏

现象:低频分频点设在120Hz,中频分频点设在180Hz,压缩低鼓时中频压缩器也跟着乱动。

原因:两组Linkwitz-Riley滤波器在相隔极近的分频点上过渡带重叠,低频段的部分能量泄漏到中频段,包络互相串扰。

解决:在参数层面对两个分频点做最小间隔限制。界面拖拽时不允许低频分频点跨过中频分频点,并且强制两个分频点至少间隔200Hz。这个限制不写进参数的NormalisableRange里,而是在setValueComplete事件中钳位。

5.5 压缩后整体响度跳变

现象:开关插件时音量忽大忽小,不同工程里同一个预设听感差异很大。

原因:压缩器只有阈值和比例没有自动增益补偿,压缩越多输出越弱。多频段压缩里每个频段各自的压缩量不同,不加补偿时低频被压得多,整体就闷。

解决:每个频段独立计算压缩量,压缩量超过1dB的部分自动补回一半作为makeup。补益信号也经过smoother,并且补益量不跟随瞬态突变,只取长时平均值。这样处理完的响度变化会平滑很多,也方便在A/B时真正判断压缩效果而不是被响度差误导。

6. 二次开发与改造空间:把三频段压缩器改成你的母带工具

这个项目的架构留出了很干净的扩展口。CrossoverBank只有两组分频点,但把filters数组换成std::vector后,就能支持任意数量频段;对应CompressorBand也改成频段数量动态创建,界面层用“点击空白处添加分频点、拖到边界删除”的交互替代固定三频段。这个改动大约需要半天时间,核心是把原本的成员变量数组重构为动态容器,其余逻辑几乎不用动。

另一种更实用的改造是加侧链输入。把EnvelopeFollower的输入从“本频段信号”换成“另一条音频流”,压缩器就变成了频率可调的ducker或dynamic EQ。比如做De-esser时,侧链信号取8kHz附近高频段,压缩器就会只在高频能量超标时动作,完全不会动到中低频。实现上只需给CompressorBand增加一个setSidechainInput接口,并把envelope.process的参数从sample换成sidechainSample。

想验证改造结果的话,我一般会做两类测试:一是用振荡器生成扫频正弦信号,过一遍CrossoverBank,把三频段相加后与原信号做差,重建误差应该低于负60dB;二是用一组固定振幅的方波测试压缩曲线,记录输出电平并和理论压缩比例对比。这些都能用JUCE自带的UnitTest框架写进工程,跑CI也不费劲。

如果你打算继续深入,建议把重点放在参数自动化的平滑策略上,很多商业插件的口碑差距就体现在这里。比如release时间随压缩量自适应变化——压缩量越大时释放越慢,能同时兼顾瞬态控制和背景噪声的呼吸感。这个项目已经给出了平滑器和包络检测的骨架,在这基础上做自适应系数并不困难。

我从这次拆解里学到的最重要一课就是:实时音频代码里,任何看似微小的状态泄漏都可能变成听感灾难。从那以后每次改完DSP,我都强制先跑一遍重建误差测试和爆音检测,再开宿主去听。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进