Drea Esposito
Resume About

saturate - JUCE Tutorial

Part 1 - Introduction, Advice and Setup

This tutorial will demonstrate how to create saturate: a simple, soft saturation plugin using the JUCE framework. In particular, I will focus on the following:

  • setting up the JUCE project
  • implementing the DSP code
  • implementing the GUI code
  • how to export the plugin for use inside of a Digital Audio Workstation (DAW)

basic-plugin

This plugin is part of my open source juce plugin library. It contains the following features:

  • drive parameter for subtle distortion and overall warmth
  • crush parameter for sample rate reduction and harmonic excitement
  • output gain and mix parameters to shape the overall sound

Prerequisites:

Not required but nice to have

  • Understanding of basic programming concepts (preferably in C++)
  • Experience with Object-oriented program design (again, preferably in C++)

Project Setup

Navigate to Projucer and create a new basic project. Give it a name, and save the folder wherever you see fit – I usually save it to the desktop.

project-creation.png

Part 2 - Creating the DSP

This part will focus on the DSP component of the plugin

  1. In your project folder, open the .jucer filer in Projucer. Begin by creating two files - Params.h and Params.cpp

create-params-object.png

Together, these files will define and implement the “Parameters” Object.

  1. Next, copy the code for both Params.h and Params.cpp
Params.h
#pragma once
 
#include <JuceHeader.h>
 
namespace ParamID
{
    const juce::ParameterID drive{ "drive", 1 };
    const juce::ParameterID crush{ "crush", 1 };
    const juce::ParameterID output{ "output", 1 };
    const juce::ParameterID mix{ "mix", 1 };
}
 
class Params
{
public:
    Params(juce::AudioProcessorValueTreeState& apvts);
 
    static juce::AudioProcessorValueTreeState::ParameterLayout createParameterLayout();
 
    void update() noexcept;
 
    float _drive = 0.0f;
    float _gain = 0.0f;
    float _crush = 0.0f;
    float _mix = 0.5f;
 
private:
    juce::AudioParameterFloat* driveParam;
    juce::AudioParameterFloat* crushParam;
    juce::AudioParameterFloat* outputParam;
    juce::AudioParameterFloat* mixParam;
 
    JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(Params)
};
Params.cpp
#include "Params.h"
 
template<typename T>
static void castParameter(juce::AudioProcessorValueTreeState& apvts,
    const juce::ParameterID& id, T& destination)
{
    destination = dynamic_cast<T>(apvts.getParameter(id.getParamID()));
    jassert(destination);  // parameter does not exist or wrong type
}
 
Params::Params(juce::AudioProcessorValueTreeState& apvts)
{
    castParameter(apvts, ParamID::drive, driveParam);
    castParameter(apvts, ParamID::crush, crushParam);
    castParameter(apvts, ParamID::output, outputParam);
    castParameter(apvts, ParamID::mix, mixParam);
}
 
juce::AudioProcessorValueTreeState::ParameterLayout Params::createParameterLayout()
{
    using namespace juce;
    AudioProcessorValueTreeState::ParameterLayout layout;
 
    layout.add(std::make_unique<juce::AudioParameterFloat>(
        ParamID::drive,
        "Drive",
        juce::NormalisableRange<float> {0.0f, 100.0f, 0.1f},
        0.0f,
        juce::AudioParameterFloatAttributes().withLabel("%")));
 
    layout.add(std::make_unique<juce::AudioParameterFloat>(
        ParamID::crush,
        "Crush",
        juce::NormalisableRange<float> {0.0f, 100.0f, 0.1f},
        0.0f,
        juce::AudioParameterFloatAttributes().withLabel("%")));
 
    layout.add(std::make_unique<juce::AudioParameterFloat>(
        ParamID::output,
        "Output",
        juce::NormalisableRange<float> {-10.0f, 10.0f, 0.1f},
        0.0f,
        juce::AudioParameterFloatAttributes().withLabel("dB")));
 
    layout.add(std::make_unique<juce::AudioParameterFloat>(
        ParamID::mix,
        "Mix",
        juce::NormalisableRange<float> {0.0f, 100.0f, 0.1f},
        50.0f,
        juce::AudioParameterFloatAttributes().withLabel("%")));
 
    return layout;
}
 
void Params::update() noexcept
{
    // convert from range [0,100] - > [1, 3]
    _drive = driveParam->get() * 0.02f + 1;
 
    // convert these from range [0,100] - > [0, 1]
    _crush = crushParam->get() * 0.01f;
    _mix = mixParam->get() * 0.01f;
 
    float output = outputParam->get();
    _gain = juce::Decibels::decibelsToGain(output);
}
  1. Now, in PluginProcessor.h, include “Params.h” and in the public and private sections, add the following constant values (for the crush effect) and the Params and APVTS objects:
PluginProcesssor.h
#pragma once
 
#include <JuceHeader.h>
#include "Params.h" // NEW
 
PluginProcesssor.h
public:
    juce::AudioProcessorValueTreeState apvts; // 1
 
private:
    const int BIT_DEPTH = 32, RATE = 2; // 2,3
    const float LEVEL_FRAC = 1.0f / (float)pow(RATE, BIT_DEPTH); // 4
    Params params; // 5
 
    JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (DistortionAudioProcessor)
  1. In PluginProcessor.cpp, we will instantiate the objects in the constructor in the initializer list.
PluginProcesssor.cpp
DistortionAudioProcessor::DistortionAudioProcessor()
     : AudioProcessor (BusesProperties()
                       .withInput  ("Input",  juce::AudioChannelSet::stereo(), true)
                       .withOutput ("Output", juce::AudioChannelSet::stereo(), true)
                       ),
    apvts(*this, nullptr, "Parameters", Params::createParameterLayout()), // 1
    params(apvts) // 2
{
}
  1. Set the hasEditor method to return false (we will test the program before supplying the editor). At this point, we can also build the program to make sure we don’t have any errors.
PluginProcesssor.cpp
bool DistortionAudioProcessor::hasEditor() const
{
    return false; // we are not supplying an editor yet
}
  1. Lastly, we will implement the sound processing algorithm.
PluginProcesssor.cpp
void DistortionAudioProcessor::processBlock (juce::AudioBuffer<float>& buffer, juce::MidiBuffer&)
{
    juce::ScopedNoDenormals noDenormals;
    auto totalNumInputChannels  = getTotalNumInputChannels();
    auto totalNumOutputChannels = getTotalNumOutputChannels();
 
    for (auto i = totalNumInputChannels; i < totalNumOutputChannels; ++i)
        buffer.clear (i, 0, buffer.getNumSamples());
 
    // saturate DSP implementation -------------------------------------------
 
    params.update(); // get most recent value from each parameter
 
    const float* in1 = buffer.getReadPointer(0);
    const float* in2 = buffer.getReadPointer(1);
    float* out1 = buffer.getWritePointer(0);
    float* out2 = buffer.getWritePointer(1);
 
    const float drive = params._drive;
    const float gain = params._gain;
    const float crush = params._crush;
    const float mix = params._mix;
 
    for (int i = 0; i < buffer.getNumSamples(); ++i) {
 
        float dryA = in1[i]; // dry samples read in
        float dryB = in2[i];
 
        // processing -----------------------------
        
        // hyperbolic tangent function with drive
        float wetA = (float)std::tanh(drive * dryA); 
        float wetB = (float)std::tanh(drive * dryB);
 
        // bit crush overdriven samples
        // - for simplicity, bit-depth and rate are fixed
        float crushA = wetA - fmodf(wetA, LEVEL_FRAC);
        float crushB = wetB - fmodf(wetB, LEVEL_FRAC);
 
        if (i % RATE != 0)
        {
            crushA = in1[i - i % RATE];
            crushB = in2[i - i % RATE];
 
        }
 
        // bit crush blend with original overdriven samples
        wetA = crush * crushA + (1.0f - crush) * wetA;
        wetB = crush * crushB + (1.0f - crush) * wetB;
 
        // mix addition,
        float mixL = mix * wetA + (1.0f - mix) * dryA;
        float mixR = mix * wetB + (1.0f - mix) * dryB;
 
        // new output -------------------------------
        out1[i] = mixL * gain;
        out2[i] = mixR * gain;
    }
}

If you are curious about the math behind the saturation algorithm, you can take a look at this desmos graph and see the behaviour of the tanh function when drive is applied to the sound wave.



  1. If everything was set up correctly, running the standalone version of the plugin should yield the basic layout of our parameters:

basic-plugin

There different ways to test audio processing in the plugin. Personally, I use the Juce Plug-in Host alongside Audio File Player (video tutorial).

juce-plugin-host

  • Increasing the drive parameter should give a progressively stronger tone of overall warmth and add extra harmonics.

  • Increasing the crush parameter should give “excitement” to higher range frequencies (10kHz - 20kHz).

Part 3 - Creating the Interface

This part will focus on creating the GUI of the plugin

  1. We begin by adding two new objects to the project:

    • LookAndFeel
    • RotaryKnob

    In your project folder, open the .jucer filer in Projucer and create the 4 files - LookAndFeel.h, LookAndFeel.cpp, RotaryKnob.h, and RotaryKnob.cpp

rotary-creation

  1. Define LookAndFeel.h and LookAndFeel.cpp in the following manner:
LookAndFeel.h
#pragma once
 
#include <JuceHeader.h>
 
const juce::String PLUGIN_NAME{ "saturate" };
 
namespace Colours
{
	const juce::Colour background{ 0xffcd9999 };
	const juce::Colour boxOutline{ 0xff956B6B };
	const juce::Colour textColour{ 252, 251, 244 };
	const juce::Colour midOutline{ textColour };
 
	namespace Knob
	{
		const juce::Colour trackBackground{ textColour };
		const juce::Colour trackActive{ 0xff764242 };
		const juce::Colour outline{ 255, 250, 245 };
		const juce::Colour gradientTop{ 250, 245, 240 };
		const juce::Colour gradientBottom{ 240, 235, 230 };
		const juce::Colour dial{ 0xFFD69999 };
		const juce::Colour dropShadow{ 195, 190, 185 };
	}
}
 
class RotaryKnobLookAndFeel : public juce::LookAndFeel_V4
{
public:
	RotaryKnobLookAndFeel();
 
	static RotaryKnobLookAndFeel* get()
	{
		static RotaryKnobLookAndFeel instance;
		return &instance;
	}
 
	void drawRotarySlider(juce::Graphics& g, int x, int y, int width, int height,
		float sliderPos, float rotaryStartAngle,
		float rotaryEndAngle, juce::Slider& slider) override;
 
private:
	juce::DropShadow dropShadow{ Colours::Knob::dropShadow, 6 /*radius*/, {0,3} /*offset*/ };
	JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(RotaryKnobLookAndFeel)
};
 
 
class MainLookAndFeel : public juce::LookAndFeel_V4
{
public:
	MainLookAndFeel() {};
private:
	JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(MainLookAndFeel)
};
LookAndFeel.cpp
#include "LookAndFeel.h"
 
RotaryKnobLookAndFeel::RotaryKnobLookAndFeel()
{
	setColour(juce::Label::textColourId, Colours::textColour);
	setColour(juce::Slider::textBoxTextColourId, Colours::textColour);
	setColour(juce::Slider::rotarySliderFillColourId, Colours::Knob::trackActive);
	setColour(juce::Slider::textBoxOutlineColourId, juce::Colours::transparentBlack);
}
 
void RotaryKnobLookAndFeel::drawRotarySlider(
	juce::Graphics& g,
	int x, int y, int width, [[maybe_unused]] int height,
	float sliderPos,
	float rotaryStartAngle, float rotaryEndAngle,
	juce::Slider& slider)
{
 
	setColour(juce::Slider::rotarySliderFillColourId, Colours::Knob::trackActive);
 
	auto bounds = juce::Rectangle<int>(x, y, width, width).toFloat();
	auto knobRect = bounds.reduced(10.0f, 10.0f);
 
	// drawing outer light circle with drop shadow
	auto path = juce::Path();
	path.addEllipse(knobRect);
	dropShadow.drawForPath(g, path);
 
	g.setColour(Colours::Knob::outline);
	g.fillEllipse(knobRect);
 
	// drawing inner darker circle
	auto innerRect = knobRect.reduced(2.0f, 2.0f);
	auto gradient = juce::ColourGradient(
		Colours::Knob::gradientTop, 0.0f, innerRect.getY(),
		Colours::Knob::gradientBottom, 0.0f, innerRect.getBottom(), false);
 
	g.setGradientFill(gradient);
	g.fillEllipse(innerRect);
 
	// draw the slider track
	auto center = bounds.getCentre();
	auto radius = bounds.getWidth() / 2.0f;
	auto lineWidth = 3.0f;
	auto arcRadius = radius - lineWidth / 2.0f;
	juce::Path backgroundArc;
	backgroundArc.addCentredArc(center.x,
		center.y,
		arcRadius,
		arcRadius,
		0.0f,
		rotaryStartAngle,
		rotaryEndAngle,
		true);
	auto strokeType = juce::PathStrokeType(
		lineWidth, juce::PathStrokeType::curved, juce::PathStrokeType::rounded);
	g.setColour(Colours::Knob::trackBackground);
	g.strokePath(backgroundArc, strokeType);
 
	// Draw the dial
	auto dialRadius = innerRect.getHeight() / 2.0f - lineWidth / 2.0f;
	auto toAngle = rotaryStartAngle + sliderPos * (rotaryEndAngle - rotaryStartAngle);
	juce::Point<float> dialStart(center.x + 10.0f * std::sin(toAngle), center.y - 10.0f * std::cos(toAngle));
	juce::Point<float> dialEnd(center.x + dialRadius * std::sin(toAngle),
		center.y - dialRadius * std::cos(toAngle));
	juce::Path dialPath;
	dialPath.startNewSubPath(dialStart);
	dialPath.lineTo(dialEnd);
	g.setColour(Colours::Knob::dial);
	g.strokePath(dialPath, strokeType);
 
	// draw a filled path in the active portion of the track (from rotaryStartAngle ---> toAngle)
	if (slider.isEnabled()) {
 
		bool isGainKnob = slider.getProperties()["drawsFromMid"];
 
		if (isGainKnob || slider.getValue() != slider.getMinimum())
		{
			// gain knob slider will start at middle of ellipse
			float fromAngle = (isGainKnob) ? rotaryStartAngle + (rotaryEndAngle - rotaryStartAngle) / 2.0f : rotaryStartAngle;
			juce::Path valueArc;
			valueArc.addCentredArc(center.x,
				center.y,
				arcRadius,
				arcRadius,
				0.0f,
				fromAngle,
				toAngle,
				true);
			g.setColour(slider.findColour(juce::Slider::rotarySliderFillColourId));
			g.strokePath(valueArc, strokeType);
		}
	}
}
  1. Next, define RotaryKnob.h and RotaryKnob.cpp in the following manner:
RotaryKnob.h
#pragma once
 
#include <JuceHeader.h>
 
class RotaryKnob : public juce::Component
{
public:
    RotaryKnob(const juce::String& text,
        juce::AudioProcessorValueTreeState& apvts,
        const juce::ParameterID& parameterID,
        bool drawsFromMid);
 
    ~RotaryKnob() override = default;
    void resized() override;
 
    juce::Slider slider;
    juce::Label label;
private:
    juce::AudioProcessorValueTreeState::SliderAttachment attachment;
 
    JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(RotaryKnob)
};
RotaryKnob.cpp
#include <JuceHeader.h>
#include "RotaryKnob.h"
#include "LookAndFeel.h"
 
//==============================================================================
RotaryKnob::RotaryKnob(const juce::String& text,
    juce::AudioProcessorValueTreeState& apvts,
    const juce::ParameterID& parameterID,
    bool drawsFromMid)
    : attachment(apvts, parameterID.getParamID(), slider)
{
 
    slider.getProperties().set("drawsFromMid", drawsFromMid);
 
    float pi = juce::MathConstants<float>::pi;
    slider.setRotaryParameters(1.25f * pi, 2.75f * pi, true);
 
    slider.setSliderStyle(juce::Slider::SliderStyle::RotaryHorizontalVerticalDrag);
    slider.setTextBoxStyle(juce::Slider::TextBoxBelow, false, 70, 16);
    slider.setTextValueSuffix(" " + apvts.getParameter(parameterID.getParamID())->getLabel());
    slider.setBounds(0, 0, 70, 86);
    addAndMakeVisible(slider);
 
    label.setText(text, juce::NotificationType::dontSendNotification);
    label.setJustificationType(juce::Justification::horizontallyCentred);
    label.setBorderSize(juce::BorderSize<int>(0));
    label.attachToComponent(&slider, false);
    addAndMakeVisible(label);
 
    setLookAndFeel(RotaryKnobLookAndFeel::get());
    setSize(70, 110);
}
 
void RotaryKnob::resized()
{
    slider.setTopLeftPosition(0, 24);
}
 
  1. Now we will create our interface in the PluginEditor object. In the header file, include the new objects and in the private section, copy the following code.
PluginEditor.h
#pragma once
 
#include <JuceHeader.h>
#include "PluginProcessor.h"
 
// NEW -------
#include "Params.h" 
#include "RotaryKnob.h"
#include "LookAndFeel.h"
PluginEditor.h
private:
    const int WIDTH = 480, HEIGHT = 200; // 1
    juce::Label logo; // 2
    
    MainLookAndFeel mainLF; // 3
    RotaryKnob driveKnob{ "drive", audioProcessor.apvts, ParamID::drive, false }; // 4
    RotaryKnob crushKnob{ "crush", audioProcessor.apvts, ParamID::crush, false }; // 5
    RotaryKnob outputKnob{ "output", audioProcessor.apvts, ParamID::output, true /*knob at center*/}; // 6
    RotaryKnob mixKnob{ "mix", audioProcessor.apvts, ParamID::mix, false }; // 7
  1. In PluginEditor.cpp, edit the following:

The constructor:

PluginEditor.cpp
SaturatorAudioProcessorEditor::SaturatorAudioProcessorEditor (SaturatorAudioProcessor& p)
    : AudioProcessorEditor (&p), audioProcessor (p)
{
    logo.setText(PLUGIN_NAME, juce::dontSendNotification);
    logo.setFont(juce::FontOptions(25.f, juce::Font::bold));
    logo.setColour(juce::Label::textColourId, Colours::textColour);
    logo.setJustificationType(juce::Justification::centredTop);
    addAndMakeVisible(logo);
    
    addAndMakeVisible(driveKnob);
    addAndMakeVisible(crushKnob);
    addAndMakeVisible(outputKnob);
    addAndMakeVisible(mixKnob);
 
    setSize (WIDTH, HEIGHT);
}

The paint function:

PluginEditor.cpp
void SaturatorAudioProcessorEditor::paint (juce::Graphics& g)
{
    g.fillAll(Colours::boxOutline); // outer rectangle outline
    
    g.setColour(Colours::midOutline); // mid-rectangle outline
    int pad = 3;
    g.fillRect(pad, pad, WIDTH - 2 * pad, HEIGHT - 2 * pad);
 
    g.setColour(Colours::background); // inner rectangle
    pad *= 2;
    g.fillRect(pad, pad, WIDTH - 2 * pad, HEIGHT - 2 * pad);
}

The resized function:

PluginEditor.cpp
void SaturatorAudioProcessorEditor::resized()
{
    auto bounds = getLocalBounds();
    auto  currWidth = bounds.getWidth();
    const int TOP_HEIGHT = 50;
    const int KNOB_WIDTH = driveKnob.getWidth();
 
    const int xOffset = KNOB_WIDTH + 25;
    
    driveKnob.setTopLeftPosition(currWidth/4 - xOffset, TOP_HEIGHT);
    crushKnob.setTopLeftPosition(2*currWidth / 4 - xOffset, TOP_HEIGHT);
    outputKnob.setTopLeftPosition(3*currWidth / 4 - xOffset, TOP_HEIGHT);
    mixKnob.setTopLeftPosition(currWidth - xOffset, TOP_HEIGHT);
 
    logo.setBounds(bounds);
    logo.setTopLeftPosition(0, 20);
}
  1. Lastly, now that we have implemented the GUI, all we have to do is enable hasEditor to be true before running the plugin!
PluginProcesssor.cpp
bool DistortionAudioProcessor::hasEditor() const
{
    return true; // now were supplying this
}

Part 4 - Making a Release Build for your DAW

The final step is to build a release version of the plugin, which can be used in a DAW of your choice.

Setup

NOTE FOR WINDOWS: It is recommended to use static runtime for the release build. Otherwise, you must ensure that Windows Runtime is installed on any computer that uses the plugin (source).

static-runtime

Release version on Visual Studio: Change the configuration to Release, then build the plugin.

release-windows

Release version on Xcode: According to the juce forums, select the Product tab and choose Build For > Profiling, then build the plugin.

basic-plugin

Adding the plugin to your DAW

Navigate to your project folder. From here, find the location of your release build. For me, this is:

C:\..path to project..\distortion\Builds\VisualStudio2022\x64\Debug\VST3

At this point, there are two options to get the plugin into your DAW:

  • Option 1: Set your DAW to scan the path to your .vst3 file
  • Option 2: Place the VST3 folder in a file path that your DAW already scans

plugin-loaded

FINAL NOTE: If you are building the VST3 version of the plugin, and using FL Studio with Windows, there is a dedicated folder where you must place the plugin:

C:\Program Files\Common Files\VST3

If you made it this far, you should now be able to run the plugin in your DAW!

Thanks for following along!

References