Auto-Gain Compensation Sample Rate problem

Hi everybody, I’m new both here and in juce development, I’m trying to implement auto-gain compensation in the eq I’m building, while it has manual gain, I’d like to add auto-gain. I tried it in many ways and I tried using any possible LLM to find help but no luck, I can’t pinpoint what I’m doing wrong but the gain compensation is close to dead at low frequencies (below 500hz) and quite responsive in high frequencies (think an high shelf), it basically works less and less as I roll down in the spectrum. I also tried using the output gain knob before the auto-gain and when I boost the knob the auto-gain successfully brings back the signal at unity. Then I tried switching sample rates in PluginDoctor and I noticed the auto-gain does less and less correction as I raise the sample rate. I’m stuck at this for a while now.
This so far is my cleanest iteration (I’m not using LUFS for now, I know, K-Weighting, smoothing etc. I’m just trying to get a basic version working):

I have an AutoGainCompensator.h

#pragma once
#include <JuceHeader.h>

class AutoGainCompensator
{
public:
    AutoGainCompensator() = default;
    
    void prepare(double sampleRate, int samplesPerBlock);
    void processBlock(juce::AudioBuffer<float>& buffer, bool autoGainEnabled);
    void updateGainCompensation(float inputRMS, float outputRMS);
    void reset();
    
    static float calculateRMS(const juce::AudioBuffer<float>& buffer);
    
private:
    double sampleRate = 44100.0;
    juce::SmoothedValue<float> gainSmoother;
    
    // RMS calculation variables
    static constexpr float rmsTimeConstant = 0.02f; // 20ms window
    static constexpr float minThreshold = 0.0001f;  // -80dB
    static constexpr float maxGainReduction = 0.1f; // 10:1 max reduction
    static constexpr float maxGainBoost = 10.0f;    // 10:1 max boost
};

the cpp file for compensation:

#include "AutoGainCompensator.h"

void AutoGainCompensator::prepare(double sr, int samplesPerBlock)
{
    sampleRate = sr;
    gainSmoother.reset(sampleRate, 0.0001);
    gainSmoother.setCurrentAndTargetValue(1.0f);
}

void AutoGainCompensator::processBlock(juce::AudioBuffer<float>& buffer, bool autoGainEnabled)
{
    if (!autoGainEnabled)
    {
        // When disabled, smoothly return to unity gain
        gainSmoother.setTargetValue(1.0f);
        
        // Still need to apply the smoother to avoid clicks when re-enabling
        if (!gainSmoother.isSmoothing())
            return; // If not smoothing and disabled, no need to process
    }
    
    // Apply the smoothed gain compensation
    for (int sample = 0; sample < buffer.getNumSamples(); ++sample)
    {
        float currentGain = gainSmoother.getNextValue(); // Get the smoothed value ONCE per sample
        
        for (int channel = 0; channel < buffer.getNumChannels(); ++channel)
        {
            buffer.getWritePointer(channel)[sample] *= currentGain; 
        }
    }
}

void AutoGainCompensator::updateGainCompensation(float inputRMS, float outputRMS)
{
    float targetGain = 1.0f; // Default to unity gain

    // Only calculates compensation if both RMS values are above minimum threshold
    // This prevents boosting noise when there's no significant signal
    if (outputRMS > minThreshold && inputRMS > minThreshold)
    {
        targetGain = inputRMS / outputRMS;
        
        // Apply safety limits to prevent extreme corrections
        // jlimit clamps the value between the specified min and max
        targetGain = juce::jlimit(maxGainReduction, maxGainBoost, targetGain);
    }
    
    gainSmoother.setTargetValue(targetGain);
}

float AutoGainCompensator::calculateRMS(const juce::AudioBuffer<float>& buffer)
{
    float sumSquares = 0.0f;
    int totalSamples = 0;
    
    // Iterate over all channels and samples to calculate sum of squares
    for (int channel = 0; channel < buffer.getNumChannels(); ++channel)
    {
        const auto* channelData = buffer.getReadPointer(channel);
        
        for (int sample = 0; sample < buffer.getNumSamples(); ++sample)
        {
            const float sampleValue = channelData[sample];
            sumSquares += sampleValue * sampleValue;
            totalSamples++;
        }
    }
    
    // Avoid division by zero
    if (totalSamples == 0)
        return 0.0f;
        
    // Return the square root of the mean of squares
    return std::sqrt(sumSquares / static_cast<float>(totalSamples));
}

void AutoGainCompensator::reset()
{
    // Reset the smoother to its initial state (unity gain)
    gainSmoother.reset(sampleRate, 0.2); // Re-initialize with current sample rate
    gainSmoother.setCurrentAndTargetValue(1.0f);
}

And in my PluginProcessor.cpp I have this:

ParametricEQAudioProcessor::~ParametricEQAudioProcessor() {}

void ParametricEQAudioProcessor::prepareToPlay(double sampleRate, int samplesPerBlock)
{
    if (oversamplingEnabled)
    {
        oversampler->initProcessing(static_cast<size_t>(samplesPerBlock));
        setLatencySamples(static_cast<int>(std::ceil(oversampler->getLatencyInSamples())));
    }
    else
    {
        oversampler->reset();
        setLatencySamples(0);
    }

    double sampleRateForBands = oversamplingEnabled
                                     ? sampleRate * oversampler->getOversamplingFactor()
                                     : sampleRate;

    int samplesPerBlockForBands = oversamplingEnabled
                                      ? samplesPerBlock * static_cast<int>(oversampler->getOversamplingFactor())
                                      : samplesPerBlock;

    for (auto& b : bands)
        b->prepareToPlay(sampleRateForBands, samplesPerBlockForBands);

    juce::dsp::ProcessSpec spec;
    spec.sampleRate = sampleRateForBands;
    spec.maximumBlockSize = static_cast<juce::uint32>(samplesPerBlockForBands);
    spec.numChannels = static_cast<juce::uint32>(getTotalNumOutputChannels());

    highShelf.prepare(spec);
    highCut.prepare(spec);
    lowShelf.prepare(spec);
    lowCutStage1.prepare(spec);
    lowCutStage2.prepare(spec);
    outputGain->prepare(spec);

    // Prepare the auto-gain compensator
    autoGainCompensator.prepare(sampleRate, samplesPerBlock); 

    // Update filters after prepareToPlay to ensure correct sample rate is used
    updateHighShelfFilter();
    updateHighCutFilter();
    updateLowShelfFilter();
    updateLowCutFilter();
    updateOutputGain();
}

void ParametricEQAudioProcessor::releaseResources()
{
    oversampler->reset();
    // Reset the auto-gain compensator
    autoGainCompensator.reset();
}

void ParametricEQAudioProcessor::processBlock(juce::AudioBuffer<float>& buffer, juce::MidiBuffer& midi)
{
    juce::ScopedNoDenormals noDenormals; 

    bool autoGainState = *autoGainEnabled;

    // Clear any garbage in output channels
    auto totalNumInputChannels  = getTotalNumInputChannels();
    auto totalNumOutputChannels = getTotalNumOutputChannels();

    for (auto i = totalNumInputChannels; i < totalNumOutputChannels; ++i)
        buffer.clear(i, 0, buffer.getNumSamples());

    // Measure input RMS (if auto-gain is enabled)
    float inputRMS = 0.0f;
    if (autoGainState)
    {
        inputRMS = AutoGainCompensator::calculateRMS(buffer);
    }

    // Convert the float buffer to a double buffer for internal processing
    const int numChans = buffer.getNumChannels();
    const int numSamples = buffer.getNumSamples();
    juce::AudioBuffer<double> dblBuf(numChans, numSamples);

    for (int ch = 0; ch < numChans; ++ch)
        for (int i = 0; i < numSamples; ++i)
            dblBuf.setSample(ch, i, buffer.getSample(ch, i));

    // EXISTING PLUGIN PROCESSING HERE
    processBlock(dblBuf, midi); // Call the double precision version, which contains all EQ and output gain

    // Convert the double buffer back to a float buffer
    for (int ch = 0; ch < numChans; ++ch)
        for (int i = 0; i < numSamples; ++i)
            buffer.setSample(ch, i, (float)dblBuf.getSample(ch, i));

    // Measure output RMS and update compensation (if auto-gain is enabled)
    if (autoGainState)
    {
        float outputRMS = AutoGainCompensator::calculateRMS(buffer);
        autoGainCompensator.updateGainCompensation(inputRMS, outputRMS);
    }
    
    // Apply the auto-gain compensation to the final float buffer
    autoGainCompensator.processBlock(buffer, autoGainState);
}

void ParametricEQAudioProcessor::processBlock(juce::AudioBuffer<double>& buffer, juce::MidiBuffer&)
{
 
    juce::dsp::AudioBlock<double> block(buffer);


    if (oversamplingEnabled)
    {
        auto upsampledBlock = oversampler->processSamplesUp(block);
        juce::dsp::ProcessContextReplacing<double> context(upsampledBlock);

       // NOT INCLUDED EQ PROCESSING CODE HAPPENS HERE

                bands[i]->process(upsampledBlock, getSampleRate() * oversampler->getOversamplingFactor());
            }
        }

        oversampler->processSamplesDown(block);
    }
    else
    {
        juce::dsp::ProcessContextReplacing<double> context(block);
 // NOT INCLUDED EQ PROCESSING CODE HAPPENS HERE
                bands[i]->process(block, getSampleRate());
            }
        }
    }

    // output gain at the very end of the double processing chain
    juce::dsp::ProcessContextReplacing<double> outputContext(block);
    outputGain->process(outputContext);
}

Any help would be appreciated, I sense is something with buffer and sample rate but I can’t spot what it is. The eq itself works great and does everything the way it should do it, only the auto-gain is broken.

The rampLengthInSeconds seems to be too small. Something like 1.0 might work better for an EQ.

gainSmoother.reset(sampleRate, 0.0001);

BTW, for an EQ, most users would prefer a static gain compensation. Auto gain compensation will adjust dynamics, which might not be suitable for an EQ.

Here’s what’s interesting actually: previously I had it at 0.2 and it was doing much less compensation, I’ve set it this low and it actually does something (even if it’s bugged).
I just tried 1 and it barely compensate anything at all… Yet another thing I can’t explain why.

Ah, I see the problem. You are using juce::SmoothedValue instead of juce::dsp::Gain. And you update juce::SmoothedValue per block. That might not be a good idea. If you do go this way, you need to be careful about the sample-rate of the smoothed value. It should be the frequency of updating:

gainSmoother.reset(sampleRate / static_cast<double>(samplesPerBlock),1.0);

Well I’ll be damned, so this is what happens: I don’t see it compensated in analyzers like PluginDoctor, but it actually does compensate also at low frequencies when using low values, but: if I do something like 0.2, 0.1 or similar values it doesn’t compensate enough, while if I use 0.001 or similar or as you proposed:
gainSmoother.reset(sampleRate / static_cast<double>(samplesPerBlock),1.0);

It does compensate also at low frequencies but I hear cracking artifacts similar to having a buffer too little on an interface.

Yes, I would suggest using juce::dsp::Gain so that it can do sample smoothing for you. Plugindoctor Linear Analysis uses IR to evaluate EQ, which is not helpful for dynamic effects, like auto gain.

Seems to be working much better, still have to test it more extensively though.

For those who will visit this, in the header file I switched this:

juce::SmoothedValue<float> gainSmoother;

With this

 juce::dsp::Gain<float> gainProcessor;

and this is the updated module:

#include "AutoGainCompensator.h"

void AutoGainCompensator::prepare(double sr, int samplesPerBlock)
{
    currentSampleRate = sr; 
    
    juce::dsp::ProcessSpec spec;
    spec.sampleRate = sr;
    spec.maximumBlockSize = static_cast<juce::uint32>(samplesPerBlock);
    spec.numChannels = 2; // Assuming stereo

    gainProcessor.prepare(spec);
    gainProcessor.setGainLinear(1.0f);
    
    // 50ms
    gainProcessor.setRampDurationSeconds(0.05);
}

void AutoGainCompensator::processBlock(juce::AudioBuffer<float>& buffer, bool autoGainEnabled)
{
    if (!autoGainEnabled)
    {
        gainProcessor.setGainLinear(1.0f);
    }
    
    juce::dsp::AudioBlock<float> block (buffer);
    juce::dsp::ProcessContextReplacing<float> context (block);
    gainProcessor.process(context);
}

void AutoGainCompensator::updateGainCompensation(float inputRMS, float outputRMS)
{
    float targetGain = 1.0f; // Default to unity gain

    if (outputRMS > minThreshold && inputRMS > minThreshold)
    {
        targetGain = inputRMS / outputRMS;
        
        targetGain = juce::jlimit(maxGainReduction, maxGainBoost, targetGain);
    }
    
    gainProcessor.setGainLinear(targetGain);
}

float AutoGainCompensator::calculateRMS(const juce::AudioBuffer<float>& buffer)
{
    float sumSquares = 0.0f;
    int totalSamples = 0;
    
    for (int channel = 0; channel < buffer.getNumChannels(); ++channel)
    {
        const auto* channelData = buffer.getReadPointer(channel);
        
        for (int sample = 0; sample < buffer.getNumSamples(); ++sample)
        {
            const float sampleValue = channelData[sample];
            sumSquares += sampleValue * sampleValue;
            totalSamples++;
        }
    }
    
  
    if (totalSamples == 0)
        return 0.0f;
        
    return std::sqrt(sumSquares / static_cast<float>(totalSamples));
}

void AutoGainCompensator::reset()
{
    gainProcessor.reset();
    gainProcessor.setGainLinear(1.0f);
}

It’s basic, it’s rough but it does the work, I’ll now implement short term LUFS instead of RMS.

Hey thanks, when I’m done with this eq I’d like to gift you a token of appreciation if possible. This eq has some very unique features and is destined only for my personal use, currently I’m still working on implementing even more stuff (dynamic eq, per-band linear/minimal phase switch, M/S processing). I’m developing solely for Mac if you do work on Mac I’d be happy to share a copy.