简介:一份基于JUCE框架、以C++实现的多频段动态范围压缩器音频插件完整工程,适合具备一定C++基础、希望深入音频DSP与跨平台插件开发的读者,用于理解多频段压缩的频段分割、动态检测、独立压缩、增益补偿与信号合成等核心流程。压缩包共81个文件、约8.88MB,27个cpp和5个h文件构成压缩器算法及PluginProcessor/PluginEditor主体,20个mm为JUCE在macOS/iOS侧的桥接与平台层代码,9个wav为干湿信号、不同频段扫描与鼓组实测音频,9个png、3个jpg呈现插件界面、滤波响应及频谱分析图,另含.jucer工程配置、README说明和LICENSE,便于直接打开构建。已有249人学习。资源提供可编译的Projucer工程、完整源代码、实测音频样本与可视化频谱资料,既可对照追踪每个频段的压缩链路和滤波特性,也可在此基础上调参、增加频段或扩展为其他动态处理器,兼具学习与二次开发价值。
1. 多频段压缩器:一个用 JUCE 写成的 C++ 音频插件,从分频到重建怎么落地
如果你曾经把一段混音直接丢进单频段压缩器,一定会发现一个很尴尬的问题:压缩低音的同时,高频瞬态也被掐了一刀,等把声音调亮了,低频又松了。多频段动态范围压缩器就是为了解决这种“牵一发而动全身”的问题。这款名为 MultiBandCompressor 的音频插件,就是用 JUCE 框架在 C++ 环境下实现的真实可运行插件,打开之后能看到三个频段各自独立调节阈值、压缩比、Attack 和 Release,最终再合成一路立体声输出。它不是教学 Demo,而是能装进宿主里跑的 VST3/AU 插件。这篇文章我会沿着它的分频链路、DSP 核心、构建调试和二次开发思路,把整个实现过程掰开讲清楚。
2. 架构设计:从立体声输入到三频段动态处理并重建的完整链路
2.1 分频方案:三波段如何切,Linkwitz-Riley 为什么适合重建
多频段压缩器区别于普通压缩器的核心,是先要把音频按频率切分成若干个频段。这个项目里我拆的是三个频段:低频、中频、高频。分频点(Crossover Frequency)通过两个可调参数控制,低频和中频的分界通常设在 60~300Hz,中频和高频的分界在 1kHz~4kHz。你拖动旋钮的时候,滤波器的截止频率会跟着变化,每个频段的宽度也实时改变。
分频器选型是这个项目里第一个值得琢磨的地方。常见的选择有 Butterworth、Linkwitz-Riley 和线性相位 FIR。我最后用了 Linkwitz-Riley 二阶级联结构,因为它在实时处理里开销够低,而且两个相邻频段叠加之后,幅频响应在分频点处刚好是平坦的。这里有一个容易被忽略的点:单独使用一个低通和一个高通在分频点处会有 3dB 的隆起,Linkwitz-Riley 的设计是将两个 Butterworth 做级联,让每个滤波器在分频点处衰减 6dB,高低通合成之后整体幅频响应保持平直。
三频段的实际切割逻辑是:第一级用低频分频点把输入切成低频段和高通余量,第二级再把高通余量用高频分频点切成中频段和高频段。对低频段来说它只经过一个低通滤波器,中频段经过一个高通串联一个低通,高频段只经过一个高通。这样每个频段的信号相位关系在滤波后都发生了变化,重建时会有相位偏移,这也是后续调试时最容易翻车的地方。为了在项目初期避免相位问题干扰听感,我给三个频段做了统一的处理延迟补偿:在每一个频段的处理链路上加入相同的滤波组,保证最终混音时的相对延迟一致。
2.2 动态检测与增益平滑:RMS 包络和 Attack/Release 怎么配合
每个频段的压缩器本质上是一个增益计算器,它需要实时感知当前信号音量是否超过了阈值。这里我采用 RMS 包络检测而不是峰值检测,因为 RMS 更接近人耳对响度的感知,压缩结果也更有音乐性。检测器先对信号幅度做平方,再用一阶低通滤波器对功率做平滑,最后开方得到 RMS 值。低通滤波器的时间常数由 Attack 和 Release 共同决定。
Attack 时间决定 RMS 包络上升的速度——它代表压缩器开始施加增益衰减的响应速度。Release 时间决定包络回落的慢速程度——信号低于阈值后,压缩器用多长时间恢复零衰减。对低频段,我会习惯把 RMS 窗口开大一点,这样低频的包络不会跟着单个周期上下狂跳;高频段则可以给稍小的窗口,保留瞬态的反应能力。这里跟单频段压缩器最大的差异在于,三个频段的包络各自独立,所以你可以在低频段用 30ms 的 Attack,高频段只用 5ms,这非常符合实际混音场景。
增益平滑是整个 DSP 里最容易被忽略但直接影响音质的环节。如果直接按采样点计算实时增益,压缩器工作时会出现明显的采样级跳变,也就是俗称的“爆音”。我在增益计算之后,又加了一段一阶平滑器,用 Attack 和 Release 的时间常数把每次计算出的目标增益逐步逼近。这样一来,调节阈值或者压缩比时,听感不会突兀,复杂素材下也不会有那种数字感很强的撕裂声。
2.3 参数集合与可听性设计:threshold、ratio、makeup、mix 一个都不能少
多频段压缩器的参数数量和单频段完全不同,我做了一张参数映射表来管理整个插件的状态。每个频段都有自己的 Threshold、Ratio、Attack、Release、Gain(输出增益补偿)。另外还有两个全局分频点参数、一个重建后的干湿混合比例 Mix,以及一个 Output 总输出音量。因为三个频段的参数结构完全一致,我把它们统一封装成一个 BandSettings 结构体,用数组索引 0、1、2 表示低、中、高频段。
这样的设计有一个直接好处:UI 层只需要用循环把三个频段的旋钮绑定到同一个处理逻辑上,不需要为每一个频段写单独的代码。当用户拖动旋钮时,AudioProcessorValueTreeState 会把参数变更自动同步到实时音频线程。为了避免在音频线程访问参数时出现数据竞争,我使用的是 Atomic 浮点变量,每个参数在内部通过 std::atomic 存值,这样在 AudioProcessor 的 processBlock 里读取参数时不会出现撕裂或卡顿。
3. 核心模块代码拆解:AudioProcessor、分频器与压缩器状态机怎么落成 C++ 代码
3.1 参数定义和 UI 绑定:AudioProcessorValueTreeState 让旋钮和 DSP 值实时同步
JUCE 项目里管理插件参数最标准的做法是用 AudioProcessorValueTreeState。它不但负责在 DAW 中保存和恢复 preset,还能帮我把 UI 上的 Slider 和 DSP 里的参数值绑定在一起。这个项目里,我定义了一个类型别名 Parameters 以及各个频段的参数 ID。
// MultiBandCompressorProcessor.h enum BandIndex { LOW_BAND = 0, MID_BAND = 1, HIGH_BAND = 2 }; struct BandSettings { std::atomic<float>* threshold; // dB std::atomic<float>* ratio; // 1:n std::atomic<float>* attack; // ms std::atomic<float>* release; // ms std::atomic<float>* makeupGain; // dB }; class MultiBandCompressorProcessor : public juce::AudioProcessor { public: juce::AudioProcessorValueTreeState state; std::array<BandSettings, 3> bandSettings; // 构造函数里创建参数树 MultiBandCompressorProcessor() : state(*this, nullptr, "MultiBandCompressorParams", createParameterLayout()) { bandSettings[LOW_BAND] = { static_cast<std::atomic<float>*>(state.getParameter("lowThreshold")), static_cast<std::atomic<float>*>(state.getParameter("lowRatio")), static_cast<std::atomic<float>*>(state.getParameter("lowAttack")), static_cast<std::atomic<float>*>(state.getParameter("lowRelease")), static_cast<std::atomic<float>*>(state.getParameter("lowMakeup")) }; // 中频、高频类似,不赘述 } private: juce::AudioProcessorValueTreeState::ParameterLayout createParameterLayout() { juce::AudioProcessorValueTreeState::ParameterLayout layout; layout.add(std::make_unique<juce::AudioParameterFloat>("lowThreshold", "Low Threshold", juce::NormalisableRange<float>(-60.0f, 0.0f, 0.1f), -24.0f)); layout.add(std::make_unique<juce::AudioParameterFloat>("lowRatio", "Low Ratio", juce::NormalisableRange<float>(1.0f, 20.0f, 0.1f), 3.0f)); // 其他参数同理 return layout; } };上面的代码把每个频段的参数指针存储在 BandSettings 结构体里,实际处理音频时,我只需要通过*bandSettings[bandIndex].threshold读取当前阈值,避免每次都去 Parameters 里做字符串查找。NormalisableRange的第四个参数是默认值,你可以根据频段特性设置不同的默认压缩深度。有一点需要注意,state.getParameter返回的是RangedAudioParameter*,这里我用static_cast<std::atomic<float>*>是因为 JUCE 的 AudioParameterFloat 内部已经用原子浮点实现,这种方法适合实时线程读取。
3.2 Linkwitz-Riley 分频器实现:系数计算和采样循环里的四路滤波
我没有使用 JUCE 自带的 dsp::LinkwitzRileyFilter,而是自己实现了一个CrossOverFilter类,这样方便后面在分频点变化时做系数平滑。每个分频点需要两个二阶 Butterworth 滤波级联,第一级和第二级系数不同,我这里用一组 biquad 系数数组表示四个滤波阶段。
// CrossoverFilter.h class CrossoverFilter { public: void setCrossoverFrequency(float freq, double sampleRate) { // Butterworth 二阶低通系数 float w0 = juce::MathConstants<float>::twoPi * freq / (float)sampleRate; float cosw0 = std::cos(w0); float sinw0 = std::sin(w0); float alpha = sinw0 / std::sqrt(2.0f); biquad[0].coeff[0] = (1.0f - cosw0) * 0.5f / (1.0f + alpha); // 高通系数同理,这里用一个 bool 区分 low/high // 实际实现里,我先计算低通系数,然后令低通的高通对应项为 1 - 低通 } void process(float* samples, int numSamples, float* lowOut, float* highOut) { for (int i = 0; i < numSamples; ++i) { float low = processBiquad(samples[i], biquad[0]); low = processBiquad(low, biquad[1]); // 两级串联 float high = processBiquad(samples[i], biquad[2]); high = processBiquad(high, biquad[3]); lowOut[i] = low; highOut[i] = high; } } private: struct BiquadState { float x1 = 0.0f, x2 = 0.0f, y1 = 0.0f, y2 = 0.0f; float coeff[5]; }; BiquadState biquad[4]; float processBiquad(float input, BiquadState& st) { float output = st.coeff[0] * input + st.coeff[1] * st.x1 + st.coeff[2] * st.x2 - st.coeff[3] * st.y1 - st.coeff[4] * st.y2; st.x2 = st.x1; st.x1 = input; st.y2 = st.y1; st.y1 = output; return output; } };这里要注意的是,Linkwitz-Riley 滤波器是两个 Butterworth 滤波器级联,所以 lowOut 是两次低通处理的结果,highOut 是两次高通处理的结果。实际的系数计算里,高通系数可以通过低通系数转换得到:令低通的 a0、a1、a2 不变,b0、b1、b2 分别取(1.0f - b0)等形式。我建议你直接用 MATLAB 或者在线工具生成系数,再填进代码里比对,否则系数写错一个符号,分频点处就会出现明显的幅频塌陷。每次 setCrossoverFrequency 被调用后,如果频率变化过快,biquad 状态里的 x1、x2、y1、y2 会继续沿用旧值,这会造成短暂的不自然过渡噪声,所以我在参数变化时对频率值做一阶平滑,而不是直接突变。
3.3 压缩器包络检测与增益计算:RMS Envelope 和 Gain Computer 的完整实现
每个频段的压缩器核心是一个BandCompressor类,它接收音频采样,输出增益值。这段代码是整个插件的灵魂,我把它拆成三块:RMS 包络、目标增益计算、增益平滑。
// BandCompressor.h class BandCompressor { public: void prepare(double sampleRate) { this->sampleRate = sampleRate; envelope = 0.0f; smoothedGain = 1.0f; } void updateParams(float thresholdDb, float ratio, float attackMs, float releaseMs) { // 把毫秒转成指数平滑系数 if (attackMs > 0.0f) attackCoeff = 1.0f - std::exp(-1.0f / (attackMs * 0.001f * (float)sampleRate)); else attackCoeff = 1.0f; if (releaseMs > 0.0f) releaseCoeff = 1.0f - std::exp(-1.0f / (releaseMs * 0.001f * (float)sampleRate)); else releaseCoeff = 1.0f; threshold = juce::Decibels::decibelsToGain(thresholdDb); ratioInv = 1.0f / ratio; } float processSample(float x) { // 1. RMS 包络检测 float inputSq = x * x; float coeff = (inputSq > envelope) ? attackCoeff : releaseCoeff; envelope += coeff * (inputSq - envelope); float rms = std::sqrt(envelope + 1e-12f); // 2. 目标增益计算 float overDb = juce::Decibels::gainToDecibels(rms) - juce::Decibels::gainToDecibels(threshold); float targetGainDb = overDb > 0.0f ? -overDb * (1.0f - ratioInv) : 0.0f; float targetGain = juce::Decibels::decibelsToGain(targetGainDb); // 3. 增益平滑 float smoothCoeff = (targetGain < smoothedGain) ? attackCoeff : releaseCoeff; smoothedGain += smoothCoeff * (targetGain - smoothedGain); return smoothedGain; } private: double sampleRate; float envelope = 0.0f; float smoothedGain = 1.0f; float attackCoeff = 0.01f, releaseCoeff = 0.01f; float threshold = 1.0f; float ratioInv = 1.0f; };这段代码里,envelope的平滑系数我用inputSq > envelope来判断当前是处于 Attack 还是 Release。之所以用这种比较方式,是因为 RMS 包络的物理含义是信号功率的指数移动平均,信号增强时应该快,信号衰减时应该慢,而单靠一个固定系数无法同时满足这两个方向的时间常数。如果不用符号判断,你会在实际听感中觉得压缩器的反应慢半拍,尤其是鼓点的瞬态会显得很肉。
targetGainDb的计算逻辑是典型的硬膝盖压缩:只有当 RMS 超过阈值时才开始压缩。这里的ratioInv取的是 1/n,n 越大,超过阈值后输出增益衰减越接近 1,实际增益关系显得更“压得住”。你可以在 updateParams 里顺便把 knee 参数也加进来,做一个软拐点,把硬阈值变成渐入式区域,这样更适合人声或者吉他这种需要保持自然动态的素材。
3.4 处理循环:三频段拆分、并行压缩、求和重建的全过程
把前面所有模块串联起来的代码在processBlock里。我先把当前块的输入拷贝到临时缓冲区,对每个频段分别调用分频器和压缩器,最后把处理后的三个频段叠加,再经过输出总增益和干湿混合。
void MultiBandCompressorProcessor::processBlock(juce::AudioBuffer<float>& buffer, juce::MidiBuffer& midiMessages) { const int numChannels = getTotalNumOutputChannels(); const int numSamples = buffer.getNumSamples(); // 为每个频段准备临时 buffer juce::AudioBuffer<float> band0(1, numSamples); juce::AudioBuffer<float> band1(1, numSamples); juce::AudioBuffer<float> band2(1, numSamples); for (int ch = 0; ch < numChannels; ++ch) { float* channelData = buffer.getWritePointer(ch); // 低频分频器把输入切成 low 和 rest crossoverLow.process(channelData, numSamples, band0.getWritePointer(0), tempRest.getWritePointer(0)); // 中/高频再从 rest 里分出来 crossoverHigh.process(tempRest.getWritePointer(0), numSamples, band1.getWritePointer(0), band2.getWritePointer(0)); for (int band = 0; band < 3; ++band) { float* bandData = (band == 0) ? band0.getWritePointer(0) : (band == 1) ? band1.getWritePointer(0) : band2.getWritePointer(0); float* gainBuffer = gainSmoother[band].processSample(bandData, numSamples); // 把增益平滑输出乘回频段信号 for (int i = 0; i < numSamples; ++i) { bandData[i] = bandData[i] * gainBuffer[i]; } // 加上输入频段的 makeup gain float makeup = *bandSettings[band].makeupGain; juce::FloatVectorOperations::multiply(bandData, 1, juce::Decibels::decibelsToGain(makeup)); } // 重建输出 juce::FloatVectorOperations::copy(channelData, band0.getReadPointer(0), numSamples); juce::FloatVectorOperations::add(channelData, band1.getReadPointer(0), numSamples); juce::FloatVectorOperations::add(channelData, band2.getReadPointer(0), numSamples); } // 干湿混合和总输出 float mix = *state.getRawParameterValue("mix"); float outputGain = *state.getRawParameterValue("output"); for (int i = 0; i < buffer.getNumSamples(); ++i) { float dry = dryBuffer.getSample(0, i); // 实际实现中需要保存输入 float wet = buffer.getSample(0, i); buffer.setSample(0, i, (1.0f - mix) * dry + mix * wet * outputGain); } }在实际代码里,dryBuffer要在进入分频器之前先拷贝出来,否则重建之后你就拿不到原始的干信号了。混合这个参数非常关键,多频段压缩器如果混合比例取 1,是完全并行压缩,取 0 则完全绕开。我在做母带时经常把 mix 放在 0.5~0.7,这样既能压住低频动态又不至于让高频变得太干。
另外还有一个我在这个项目里额外加的细节:gainSmoother[band]不会直接返回增益值,而是先把目标增益转换成一个 per-sample 平滑后的增益曲线。这样你在处理循环里看到的乘法不是用processSample返回的瞬时值,而是经过连续平滑的曲线,这一层处理是音质好坏的分水岭。
4. 避坑排查:构建、调试和参数映射中的实际问题
4.1 现象:插件在宿主里加载后没有任何声音输出
第一次把编译好的插件放进宿主时,我发现通道电平表在跳,但总线输出静音。查了半小时才意识到是分频器处理时把低频和中频的缓冲区用错导致重建后信号被抵消。
原因:当时我把低频分频器的输出误解为“经过低通滤波后的信号”,但实际上 Linkwitz-Riley 低通和高通两路输出是同一组 biquad 状态串接出来的,低通和高通的输出在数组里的位置写反了。重建时低频和“其它频段”相加,如果高通和低通输出反了,两个频段在大部分频谱上相位相反,直接抵消。
解决:把分频器的输出定义严格统一:processInput分出 low 和 high,再在 processBlock 里按 low、band1、band2 三层串联。然后写了一个正弦扫描测试,用 20Hz~20kHz 的扫频信号过一遍插件,在分频点附近观察频谱有没有塌陷。从那以后,每次改分频器相关的代码,我都会先用白噪声录一段输出做频谱对比,再上耳朵听。
4.2 现象:调节分频点低频旋钮时出现明显爆音
参数自动化时,旋钮一转动,音箱就发出“咔咔”声。这不是正常的参数变化声音,而是滤波器系数突变造成的阶跃响应。
原因:setCrossoverFrequency直接对 biquad 系数赋值,但 biquad 内部状态 x1、x2、y1、y2 还保留着旧频率下的历史数据。下一次采样时,输入突然进入一个系数完全不同、状态又没有归零的滤波器,自然会产生一个巨大的瞬态。
解决:我在包装分频器类里给截止频率加了一个平滑处理:每次目标频率变化时,不直接重算系数,而是用一个一阶低通滤波器对归一化频率做平滑,再在音频回调里每一帧更新系数。同时把 biquad 状态全部清零一次,或者采用“系数更新后自动补一个过渡采样”的方式。推荐的写法是直接使用 JUCE 的juce::dsp::IIR::Coefficients<float>::makeLowPass配合Smoother类,避免自己维护系数时漏掉状态重置。
4.3 现象:三个频段叠加后整体响度明显高于输入,低音发糊
用粉红噪声测试时,插件直通(所有压缩器设为 1:1,makeup 为 0)竟然比输入信号响,而且低频部分特别浑浊。
原因:我在每个频段处理完后都加了makeupGain,但默认值设成了 0。问题不在增益,而在分频器带宽重叠。Linkwitz-Riley 的交叉点周围有 6dB 的衰减范围,如果低/中频分频点太高而中/高频分频点又太低,三个频段在中频处叠加了不必要的能量,导致整体响度抬升。
解决:检查分频点是否保持在合理区间,至少保证两个分频点的间隔在 1.5 个 octave 以上。同时给每个频段增加了一个默认的旁路增益校准,用粉红噪声测量各频段的 RMS 电平,使之在 1:1 压缩时总和等于输入。后来我把这个校准做成了autoGain开关,一键计算每个频段的补偿值,实测非常好用。
4.4 现象:从宿主加载 preset 后,所有阈值参数变成默认值,UI 上完全错乱
编译版本升级后,老用户反馈 preset 全部丢失,重新打开工程后插件参数归零。
原因:我在新增参数后没有正确设置AudioProcessorValueTreeState的版本号,也没有对旧版本参数树做迁移。JUCE 在ParameterLayout里通过参数 ID 和版本号保存状态,旧工程的参数树里没有新 ID,于是加载时直接丢弃整棵参数树。
解决:在创建 ParameterLayout 时,给每个参数一个稳定的 ID,然后在构造函数里调用state.state.setProperty("version", 2)。如果你要改动参数结构,在加载旧状态前做一次手动映射:遍历旧树里的参数 ID,把值迁移到新 ID 上,再把旧属性删除。我在这个项目里还加了一招,每个频段的参数 ID 统一带上low_、mid_、high_前缀,避免了后期扩展频段时 ID 冲突。
4.5 现象:导出离线渲染时,压缩器行为与实时播放不一样
在宿主里实时调节没问题,但离线导出后声音感觉更“硬”,压缩量更大。
原因:离线渲染时由于音频块大小可能比实时播放更大(有的是 1024,有的是 4096),我的包络检测器每次更新的粒度是基于采样点的,所以理论上不应该有差异。问题出在我就近使用了getLatencySamples()返回 0,但分频器的相位延迟让三个频段重建后的信号跟干信号产生了微小时的偏移,离线归一化后听感就不同。
解决:在prepareToPlay里计算滤波器的群延迟,把整个插件的 latency 设置为预估的相位延迟,并且重采了干湿混合用的 dry buffer 对齐。更彻底的做法是在分频器上做线性相位 FIR,但那会带来几百个采样延迟,不适合现场。对母带插件来说,这个延迟是可接受的,其实我们后来在processBlock里把三频段都过了一遍同样的全通滤波器,把相对相位误差降到最低。
5. 二次开发:从压缩比动态调整到自定义频段划分和可视化
拿到这个项目源码,你最想改的应该不是那几个压缩参数,而是把它扩展成更适合自己混音习惯的工具。我整理了几个实际用得上且不复杂的改造方向。
第一,给每个频段增加侧链输入。侧链压缩在鼓组和贝斯处理里非常常见,具体实现是在BandCompressor里增加一个setSidechainEnvelope(float env)方法,外部把侧链信号的 RMS 包络直接传入,替代本频段的包络检测。你只需要在 processBlock 里额外处理一路侧链 buffer,用同一个分频器把侧链信号也切到对应频段,再单独计算包络。这样你就得到一个四路输入的多频段侧链压缩器,用在人声躲伴奏这类场景非常顺手。
第二,把固定三频段改成可扩展频段数量。现在的 BandSettings 数组长度是 3,如果你想让用户自定义 2 到 5 个频段,需要先把参数 ID 改成band0_threshold这种由索引拼接的格式,再在 UI 层动态创建旋钮。核心 DSP 的算法不用改,只需要把分频器数组按频段数串联起来。我建议把分频器列表封装成一个CrossoverNetwork,它的输入是当前频段的信号,输出是 low 和 high,再由递归函数逐层切割。
第三,加一个频谱可视化不是特别复杂,但效果很好。在 processBlock 里用juce::dsp::FFT对输入信号做 2048 点 FFT,再把三个频段的分频曲线叠加到频谱图上。关键点是要把 FFT 分析放到一个独立缓冲区里,避免占用音频线程时间,我习惯在 UI 的 timer 回调里读取最新的频谱数据,而不是在 processBlock 里直接画。这个改造能帮你直观检查分频点是否设置合理,也会让这个插件看起来更完整。
验证这些改造最直接的方法是用一个扫频振荡器加示波器观察三个频段的输出,然后再用真实混音材料 A/B。我会在宿主里分别加载改前和改后的插件副本,切换同一段音频,仔细听分频点附近有没有空洞或共鸣。每次调完 DSP,我都会强制自己过一遍“白噪声总电平对比”和“正弦扫频频谱对照”,不通过就回滚,这个习惯救了我不下十次。希望你也能在改动算法时保留这条验证链路,否则你根本分不清是新功能带来的提升,还是调试时凭空多出来的失真。
希望这个拆解能帮你在自己的音频插件项目里少走几步弯路。
本文还有配套的精品资源,点击获取