Audio Algorithms

I’ve been wanting to dive into audio algorithms to finally understand what’s happening under the hood of the amazing plugins we use every day. In this post, I'm diving into the JUCE framework and exploring the processBlock function - the heartbeat of audio processing where raw samples become the effects we hear.

processBlock

The audio processing logic happens inside the processBlock function which initially looks like this when you start:

void DelayAudioProcessor::processBlock (juce::AudioBuffer<float>& buffer, juce::MidiBuffer& midiMessages)
{
    juce::ScopedNoDenormals noDenormals;
    auto totalNumInputChannels  = getTotalNumInputChannels();
    auto totalNumOutputChannels = getTotalNumOutputChannels();
ß
    for (auto i = totalNumInputChannels; i < totalNumOutputChannels; ++i)
        buffer.clear (i, 0, buffer.getNumSamples());

    for (int channel = 0; channel < totalNumInputChannels; ++channel)
    {
        auto* channelData = buffer.getWritePointer (channel);

        // ..do something to the data...
    }
}

Every few milliseconds the DAW/host sends a block of audio samples to the plug-in. The audio samples are placed in a juce::AudioBuffer object. This object is given to the processBlock function as an argument (the terms block and buffer mean the same thing and are used interchangeably).

The job of processBlock is to overwrite the sample values inside the AudioBuffer with new ones.

Replacing the Inner Loop

To see how simple audio algorithms can be, I want you to replace the following portion of the code:

for (int channel = 0; channel < totalNumInputChannels; ++channel)
{
    auto* channelData = buffer.getWritePointer (channel);

    // ..do something to the data...
}

with this single line:

buffer.applyGain(0.5f);

So that processBlock looks like this:

void DelayAudioProcessor::processBlock (juce::AudioBuffer<float>& buffer,
                                        juce::MidiBuffer& midiMessages)
{
    juce::ScopedNoDenormals noDenormals;
    auto totalNumInputChannels  = getTotalNumInputChannels();
    auto totalNumOutputChannels = getTotalNumOutputChannels();

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

    buffer.applyGain(0.5f);
}

Applying Gain by Hand

The buffer.applyGain function is very handy, but we can also apply the gain ourselves. Let’s try it, just to see how to do this by hand. It will hopefully help you understand a little better what exactly goes on under the hood of buffer.applyGain.

Replace the line buffer.applyGain(0.5f); with the following code:

for (int channel = 0; channel < totalNumInputChannels; ++channel) {
    auto* channelData = buffer.getWritePointer(channel);
    for (int sample = 0; sample < buffer.getNumSamples(); ++sample) {
        channelData[sample] = channelData[sample] * 0.5f;
    }
}

This does the exact same thing as buffer.applyGain(0.5f), but spelled out step-by-step.

Decibels

So far we have been working with linear gain values—numbers like 0.5f or 2.0f that directly multiply every audio sample. This works, but it is not how humans naturally think about loudness, and it is not how audio software usually expresses gain changes.

In audio, we typically specify gain in decibels, written as dB. Decibels follow a logarithmic scale, which means each step represents a multiplication rather than a fixed amount. This matches the way our ears perceive sound: doubling the energy of a signal does not sound “twice as loud.” Our perception compresses loudness in a logarithmic way.

In JUCE, you can convert a dB value into the linear multiplier needed inside processBlock using the following function:

juce::Decibels::decibelsToGain(valueInDecibels)

Let’s update the inner gain loop so that instead of using a fixed multiplier, we specify the gain in decibels.

// 1
float gainInDecibels = -6.0f;

// 2
float gain = juce::Decibels::decibelsToGain(gainInDecibels);

for (int channel = 0; channel < totalNumInputChannels; ++channel) {
    auto* channelData = buffer.getWritePointer(channel);

    for (int sample = 0; sample < buffer.getNumSamples(); ++sample) {
        channelData[sample] *= gain; // 3
    }
}

The function decibelsToGain() uses the standard formula for converting a dB value into a linear gain factor:

linearGain = 10^(dB / 20)

Using the value -6.0f from our example:

linearGain = 10^(-6 / 20)
-6 / 20 = -0.3
10^-0.3 ≈ 0.501187

So a gain of -6 dB becomes a linear multiplier of roughly 0.5, which is why applying -6 dB has the same audible effect as multiplying each audio sample by 0.5f.

Plug-in Parameters

Up until now we’ve been hard-coding the gain value in our plugin—for example:

buffer.applyGain(0.5f);

or, when doing it by hand:

channelData[sample] = channelData[sample] * 0.5f;

That’s fine for experiments, but real plugins need parameters that the user can control from the GUI or from the host (automation).

To help us manage these parameters in a clean and consistent way, JUCE provides a helper class called juce::AudioProcessorValueTreeState, or APVTS for short.

We declare the APVTS object inside the PluginProcessor.h file, inside the processor class. If you open that file, you’ll see something like:

class DelayAudioProcessor : public juce::AudioProcessor
{
public:
    // public methods...

private:
    // private members will go here
};

Right below the line that says private:, we will add our juce::AudioProcessorValueTreeState member and a helper function that defines which parameters the plugin exposes:

class DelayAudioProcessor : public juce::AudioProcessor
{
public:
    // public methods...

private:
    juce::AudioProcessorValueTreeState apvts {
    *this, nullptr, "Parameters", createParameterLayout()
    };
    juce::AudioProcessorValueTreeState::ParameterLayout createParameterLayout();
};

Here’s what this does:

Now that we’ve declared the APVTS object and the createParameterLayout() function in PluginProcessor.h, we can switch over to PluginProcessor.cpp to implement it.

Add the following code to PluginProcessor.cpp:

juce::AudioProcessorValueTreeState::ParameterLayout
DelayAudioProcessor::createParameterLayout()
{
    juce::AudioProcessorValueTreeState::ParameterLayout layout;

    layout.add(std::make_unique<juce::AudioParameterFloat>(
        juce::ParameterID { "gain", 1 },
        "Output Gain",
        juce::NormalisableRange<float> { -12.0f, 12.0f },
        0.0f));

    return layout;
}

Now that our plugin defines a gain parameter using the APVTS system, we no longer need to use hard-coded values inside processBlock(). Instead, we can read the current value of the parameter directly from the parameter tree.

To retrieve the value of a parameter, we call:

apvts.getRawParameterValue("gain")->load()

With the gain parameter now defined in our APVTS and retrieved inside processBlock(), the entire function looks like this:

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

    auto totalNumInputChannels  = getTotalNumInputChannels();
    auto totalNumOutputChannels = getTotalNumOutputChannels();

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

    // 1) Read gain in dB from the parameter tree
    float gainInDecibels = apvts.getRawParameterValue ("gain")->load();

    float gain = juce::Decibels::decibelsToGain (gainInDecibels);

    for (int channel = 0; channel < totalNumInputChannels; ++channel)
    {
        auto* channelData = buffer.getWritePointer (channel);

        for (int sample = 0; sample < buffer.getNumSamples(); ++sample)
        {
            channelData[sample] *= gain;
        }
    }

    // (midiMessages passes through unchanged)
}
t

Optimising Parameter Access

We can do even better. Using apvts.getRawParameterValue() is convenient, but it isn’t free. The issue with getRawParameterValue() is that it has to look up the parameter by its ID, which may involve performing multiple string comparisons, especially when the plugin has many parameters.

There is a faster and more efficient way to read parameter values: pointers. A pointer in C++ doesn’t store the value itself — it stores the memory address where the value lives. Once we have a pointer to a parameter, we can access it directly without doing a lookup each time.

By keeping track of the AudioParameterFloat* object, we can immediately access the parameter through this pointer, without looking it up over and over again. This is a type of caching: memorizing something once so it does not have to be recomputed repeatedly.

Go to PluginProcessor.h and add the following line, again in the private: section of the class:

juce::AudioParameterFloat* gainParam;

The * means this is a pointer to an AudioParameterFloat object rather than the object itself.

We still need to make gainParam point to the actual parameter in the APVTS. The best place to do this is inside the constructor of DelayAudioProcessor, because that’s where the APVTS is created.

Switch to PluginProcessor.cpp and scroll to the top. There you’ll find the constructor:

DelayAudioProcessor::DelayAudioProcessor()
#ifndef JucePlugin_PreferredChannelConfigurations
    : AudioProcessor (BusesProperties()
   #if ! JucePlugin_IsMidiEffect
    #if ! JucePlugin_IsSynth
        .withInput  ("Input",  juce::AudioChannelSet::stereo(), true)
    #endif
        .withOutput ("Output", juce::AudioChannelSet::stereo(), true)
   #endif
    )
#endif
{
}

Add the following lines inside the constructor:

auto* param = apvts.getParameter("gain");
gainParam = dynamic_cast<juce::AudioParameterFloat*>(param);

This finds the parameter object inside the APVTS and stores a pointer to it. From now on, gainParam gives us direct access to the parameter without repeatedly looking it up.

Now let’s use this pointer in processBlock(). Replace the existing line that reads the gain value with:

float gainInDecibels = gainParam->get();

Since gainParam is a pointer, we use the -> arrow notation to call the parameter object's get() function.

This is what the constructor and the processBlock function should look like at this stage:

DelayAudioProcessor::DelayAudioProcessor()
    : AudioProcessor (BusesProperties()
                        .withInput  ("Input",  juce::AudioChannelSet::stereo(), true)
                        .withOutput ("Output", juce::AudioChannelSet::stereo(), true)),
      apvts (*this, nullptr, "Parameters", createParameterLayout())
{
    // Cache pointer to the gain parameter for fast access in processBlock
    auto* param = apvts.getParameter("gain");
    gainParam = dynamic_cast<juce::AudioParameterFloat*>(param);
}
void DelayAudioProcessor::processBlock (juce::AudioBuffer<float>& buffer,
                                        juce::MidiBuffer& midiMessages)
{
    juce::ScopedNoDenormals noDenormals;

    auto totalNumInputChannels  = getTotalNumInputChannels();
    auto totalNumOutputChannels = getTotalNumOutputChannels();

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

    // 1
    float gainInDecibels = gainParam->get();

    float gain = juce::Decibels::decibelsToGain(gainInDecibels);

    for (int channel = 0; channel < totalNumInputChannels; ++channel)
    {
        auto* channelData = buffer.getWritePointer(channel);

        for (int sample = 0; sample < buffer.getNumSamples(); ++sample)
            channelData[sample] *= gain;
    }
}

With these two pieces in place, your plugin now has a fully functional, host-automatable gain parameter implemented in an efficient and DSP-safe way.

Parameter Smoothing

First, go to PluginProcessor.h and add the following member in the private: section of your processor class:

juce::LinearSmoothedValue<float> gainSmoother;

This object will handle smoothing for our linear gain value (after converting from decibels).

Next, we need to tell the smoother how fast it should react to changes. We do this in prepareToPlay(), where the sample rate is known. Update the function in PluginProcessor.cpp to look like this:

void DelayAudioProcessor::prepareToPlay (double sampleRate, int samplesPerBlock)
{
    juce::ignoreUnused (samplesPerBlock);

    const double duration = 0.02; // 20 ms smoothing
    gainSmoother.reset (sampleRate, duration);

    // Equivalent to Parameters::reset()
    gain = 0.0f;
    const float currentGainDb = gainParam->get();
    const float currentGain = juce::Decibels::decibelsToGain (currentGainDb);
    gainSmoother.setCurrentAndTargetValue (currentGain);
}

That combines their prepareToPlay() + reset() into your processor’s prepareToPlay.

With the smoother initialised, the final step is to actually use it during audio processing. Conceptually, processBlock() now does three things:

  1. Update the smoother with the latest parameter value
  2. Smoothen the value over time
  3. Apply the smoothed value to the audio samples

First, we read the current gain parameter and convert it from decibels to linear gain. Instead of applying it directly, we set it as the target value for the smoother. This corresponds to the “update” step.

// Update: read parameter and set smoothing target
const float gainInDecibels = gainParam->get();
const float targetGain = juce::Decibels::decibelsToGain (gainInDecibels);
gainSmoother.setTargetValue (targetGain);

Next comes the “smoothen” and “apply” steps. For each sample, we ask the smoother for the next interpolated value and multiply all channels by that value.

Note that the sample loop is now the outer loop. This ensures that all channels use the same smoothed gain value for a given sample.

// Smoothen + apply
const int numSamples = buffer.getNumSamples();
auto totalNumInputChannels = getTotalNumInputChannels();

for (int sample = 0; sample < numSamples; ++sample)
{
    float currentGain = gainSmoother.getNextValue();

    for (int channel = 0; channel < totalNumInputChannels; ++channel)
    {
        auto* channelData = buffer.getWritePointer (channel);
        channelData[sample] *= currentGain;
    }
}

Putting it all together, processBlock() now smoothly transitions between parameter values instead of applying abrupt jumps. This greatly reduces clicks and zipper noise when parameters change quickly or are automated by the host.