Audio Ordeal

Music Production, Podcast, and DJ Tutorials

How to build a VST – Lesson 4: Limiter 1

6 min read

In this tutorial I’m going to show you how to make a very basic limiter VST plugin using the JUCE library. This plugin will be easy to extend into a compressor/expander, and while building it you’ll learn about using delay buffers and how to debug effectively while developing a VST.

Set Up

Create a new plugin project in Projucer, and select VST3 as the output format. Open the source files in your preferred IDE and find the processBlock function in PluginProcessor.cpp.

If you have trouble at this stage, look back at some of the earlier tutorials.

https://audioordeal.co.uk/how-to-build-a-vst-lesson-1-intro-to-juce/

Adding starter code to processBlock function.

Change your processBlock function to look like this, then build your plugin. Running it in your DAW should mute all output from the left channel of audio.

Limiters

Limiters work by detecting peaks of amplitude in an audio signal, then lowering the level of the peaks until they are under a certain threshold. 

Limiters generally have a very fast attack time to make sure signals aren’t clipping. We’re going to make a limiter with a delayed output, which will allow the plugin to look ahead in the signal for volume peaks, and start to roll off gain before reaching the peak samples. 

We will also create a smoothing filter, to make sure changes in amplitude won’t create audio artefacts.

Coding the ProcessBlock

Circular buffers can be used for delays, circular  means the index wraps around if the value is greater than the size of the buffer.

So if you had a buffer of size 10 an index would increase like this.

7->8->9->(wraps to start)->0->1->2->3

We’re going to use a Juce class called AudioSampleBuffer to create a delay buffer.

https://docs.juce.com/master/classAudioBuffer.html

The first line of code declares an AudioSampleBuffer called delayBuffer. Which is intialised by calling the AudioSampleBuffer constructor function. By passing in the values 1 and 10 into this function we get a 1 channel buffer that’s ten samples long. After initialisation call the clear() function to set all values in the buffer to 0.

AudioSampleBuffer delayBuffer = AudioSampleBuffer(1, 10);
delayBuffer.clear();

Add these float variables to the processBlock. They have all been set with default values except the coeff variable.

float attackTime, releaseTime, limiterThresh, gain, coeff, xPeak;
attackTime = 0.3f;
releaseTime = 0.01f;
limiterThresh = 0.4f;
gain = 1.0f;
xPeak = 0.0f;

Finding the filter value from the incoming signal

1. Get the current sample. Then find amplitude value by taking the absolute value of the sample, flipping any negative values to positive.

2. If the amplitude is higher than the current peak set the coefficient to the attackTime value, else set it to releaseTime.

3. Set the current peak depending on the last peak, coefficient and amplitude.

float sample = data[i]; //-1
float amplitude = abs(sample);

if (amplitude > xPeak ) coeff = attackTime; //-2
else coeff = releaseTime;

xPeak = (1 – coeff) * xPeak + coeff * amplitude; //-3

This similar piece of code finds the gain to be applied to the delayed signal.

1. Set filter variable to the minimum value, either 1 or the limiterThreshold /
current peak.

2. If the gain is higher than the filter variable set the coefficient to the attackTime value,
else set it to releaseTime.

3. Set the current gain depending on the last gain, coefficient and filter.

float filter = fmin(1.0f, limiterThresh / xPeak); //-1

if (gain > filter) coeff = attackTime; //-2
else coeff = releaseTime;

gain= (1 – coeff) * gain + coeff * filter; //-3

So at this stage we’ve found the gain we want to apply to the input signal. However, this won’t be applied directly to the input but to the delay buffer.

Coding a Circular Buffer

Code to initialise variables to be used in the delay buffer

Initialise these int variables. writeIndex is the index where values will be inserted into the buffer, the readIndex is where we will take them from. delayIndex is how many samples behind the writeIndex the readIndex is.

int writeIndex, readIndex, delayIndex, bufferLength;
delayIndex = 2;
writeIndex = 0;
bufferLength = 10;

Code to wrap a write and read index around a buffer

This code calculates the current read and write indexes, applies gain to a sample taken from the delaybuffer, and puts the current sample into the delaybuffer.

1. This finds a readIndex position that is ‘delayIndex’ places behind the writeIndex. By using the modulus (%) operator of the bufferLength the readIndex will never be outside the bounds of the delayBuffer.

2. Finds the sample currently at the readIndex position of the delaybuffer and multiplies it by the gain we calculated earlier. Also inserts the original sample in the delayBuffer at the writeIndex position.

3. Calculates the next writeIndex, also using the modulus operator. Then sets the output to the limitedSample value.

readIndex = ((bufferLength + writeIndex) – delayIndex) % bufferLength; //-1

float limitedSample = gain * delayBuffer.getSample(0, readIndex); //-2
delayBuffer.setSample(0, writeIndex, sample); 

writeIndex = (writeIndex + 1) % bufferLength; //-3

data[i] = limitedSample;

Debugging

Try building and running your code now! You might notice it sounds really bad… We’ve only affected the left channel of audio in processBlock so you should hear that it’s very distorted compared to the right channel. Luckily this is an easy problem to fix, but I thought it would be worth thinking about how to debug audio applications.

It would be nice to see exactly what’s happening with our samples, rather than just listening to the output. Audacity is free software that lets you zoom into an audio waveform to the sample level.

https://www.audacityteam.org/

Either using Audacity or similar software record the output of your VST audio, you should see something similar to this.

Two waveforms, one of them is more distorted than the other

You can see the left channel has got peaks that aren’t present in the right channel. 

Zoomed in version of previous picture, showing regions of distortion

When we zoom into the sample level we can see areas where it looks like a few samples get reset to 0 before returning to normal.

This is happening because of the where the variables are declared in the code. The process block function is called more than once while audio is being processed. This means every time it’s called all the variables are reset, including the delayBuffer. This is why the output is being reset periodically and causing distortion.

To fix this issue we need to increase the scope of some of the variables, and only initialise them once. 

By looking at a visual representation of the signal it’s much easier to debug problems in the output signal.

Changing Scope of Variables

By moving variables to a header file they will be available for use by any functions in the PluginProcessor class. 

Copy and pasting code to the header file.

PluginProcessor.h is the left window. Move the following variables from PluginProcessor.cpp.

LimiterThresh, gain, xPeak

Delete these from PluginProcessor.cpp, as well as the initialisation code.

Also move the audioSampleBuffer variable to PluginProcessor.h

Also move the delayBuffer variable, and delete any initialisation.

Now all the necessary variables are in PluginProcessor.h. This means no matter how many times processBlock is called, they won’t be reset.

Now we need to initialise these variables with default values a single time. We can do this by using the prepareToPlay function included in the JUCE library.

Moving initialisation code to preparetoplay function

Find the prepareToPlay function in PluginProcessor.cpp and add this initialisation code. This will never be called after the plugin has started receiving audio.

delayBuffer = AudioSampleBuffer(1, 10);
delayBuffer.clear();

limiterThresh = 0.0f;
gain = 1.0f;
xPeak = 0.0f;

Build and run the plugin now and you should hear that the previous problem we had has been solved! Try changing the limitThresh variable to hear the difference depending on the Limiter Threshold.

Only very small values of limitThresh will affect the signal, we need to convert the input from the decibel to linear scale. This will be covered in part 2.

This was recorded with limitThresh set to 0.01f. 

That’s it for this part of the tutorial. Next time I’ll cover how to …

1- Expand this code to work with any number of input channels

2- Write a CircularBuffer class that encapsulates the AudioSampleBuffer logic we wrote in the processBlock.

3- Create a basic UI.

4- Convert from Decibel scale to linear for limiterThreshold, and from Ms to linear for attack/release.

Code for this tutorial can be found here…

https://github.com/aRycroft/JuceTutorial4

Leave a Reply

Copyright © Tom Jarvis 2020 All rights reserved. | Newsphere by AF themes.