How to make a simple reverb with the JUCE DSP module

Introduction

In this tutorial, I will show you how to make a simple reverb with the JUCE DSP module.

szkkng/SimpleReverb
Contribute to szkkng/SimpleReverb development by creating an account on GitHub.

Let’s get started!

Prerequisites

Launch Projucer, create a new project named SimplePlugin, and enable juce_dsp so that you can use the JUCE DSP module.

DSP

This chapter explains the DSP part of Simple Reverb.

APVTS

I recommend using APVTS(AudioProcessorValueTreeState) to manage parameters because it is so much simpler than the traditional way. Prepare the APVTS object as shown below:

class SimpleReverbAudioProcessor  : public juce::AudioProcessor
{
public:
・・・
static juce::AudioProcessorValueTreeState::ParameterLayout createParameterLayout();
        
juce::AudioProcessorValueTreeState apvts { *this, nullptr, "Parameters", createParameterLayout() };

Implement all the parameters that you want to manage in APVTS in createParameter(). In this case, we will create a reverb, so we will use juce::dsp::Reverb::Parameters(juce::Reverb::Parameters):

These parameters can be made to correspond to knobs one by one, but it is inconvenient to have separate Dry and Wet parameters, so they are combined into one as Dry/Wet. Also, I personally think it is cooler to display the parameters as %. These implementations are shown below:

juce::AudioProcessorValueTreeState::ParameterLayout SimpleReverbAudioProcessor::createParameterLayout()
{
    juce::AudioProcessorValueTreeState::ParameterLayout layout;
    
    layout.add (std::make_unique<juce::AudioParameterFloat> ("Room Size",
                                                             "Room Size",
                                                             juce::NormalisableRange<float> (0.0f, 1.0f, 0.001f, 1.0f),
                                                             0.5f,
                                                             juce::String(),
                                                             juce::AudioProcessorParameter::genericParameter,
                                                             [](float value, int) {
                                                                if (value * 100 < 10.0f)
                                                                    return juce::String (value * 100, 2);
                                                                else if (value * 100 < 100.0f)
                                                                    return juce::String (value * 100, 1);
                                                                else
                                                                    return juce::String (value * 100, 0); },
                                                             nullptr));
    
    layout.add (std::make_unique<juce::AudioParameterFloat> ("Damping",
                                                             "Damping",
                                                             juce::NormalisableRange<float> (0.0f, 1.0f, 0.001f, 1.0f),
                                                             0.5f,
                                                             juce::String(),
                                                             juce::AudioProcessorParameter::genericParameter,
                                                             [](float value, int) {
                                                                if (value * 100 < 10.0f)
                                                                    return juce::String (value * 100, 2);
                                                                else if (value * 100 < 100.0f)
                                                                    return juce::String (value * 100, 1);
                                                                else
                                                                    return juce::String (value * 100, 0); },
                                                             nullptr));
    
    
    layout.add (std::make_unique<juce::AudioParameterFloat> ("Width",
                                                             "Width",
                                                             juce::NormalisableRange<float> (0.0f, 1.0f, 0.001f, 1.0f),
                                                             0.5f,
                                                             juce::String(),
                                                             juce::AudioProcessorParameter::genericParameter,
                                                             [](float value, int) {
                                                                if (value * 100 < 10.0f)
                                                                    return juce::String (value * 100, 2);
                                                                else if (value * 100 < 100.0f)
                                                                    return juce::String (value * 100, 1);
                                                                else
                                                                    return juce::String (value * 100, 0); },
                                                            nullptr));

    layout.add (std::make_unique<juce::AudioParameterFloat> ("Dry/Wet",
                                                             "Dry/Wet",
                                                             juce::NormalisableRange<float> (0.0f, 1.0f, 0.001f, 1.0f),
                                                             0.5f,
                                                             juce::String(),
                                                             juce::AudioProcessorParameter::genericParameter,
                                                             [](float value, int) {
                                                                if (value * 100 < 10.0f)
                                                                    return juce::String (value * 100, 2);
                                                                else if (value * 100 < 100.0f)
                                                                    return juce::String (value * 100, 1);
                                                                else
                                                                    return juce::String (value * 100, 0); },
                                                             nullptr));
    
    layout.add (std::make_unique<juce::AudioParameterBool> ("Freeze", "Freeze", false));
    
    return layout;
}

juce::dsp::Reverb

Now that the APVTS is ready, we will implement the Reverb part. Let’s create an object of juce::dsp::Reverb::Parameters, which was introduced earlier. Also, create two juce::dsp::Reverb objects to support stereo channels.

class SimpleReverbAudioProcessor  : public juce::AudioProcessor
{
・・・
private:
    juce::dsp::Reverb::Parameters params;
    juce::dsp::Reverb leftReverb, rightReverb; 
    //==============================================================================
    JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (SimpleReverbAudioProcessor)
};

Next, prepare a ProcessSpec object to hold the information necessary to initialize the Reverb object you have created:

void SimpleReverbAudioProcessor::prepareToPlay (double sampleRate, int samplesPerBlock)
{
    juce::dsp::ProcessSpec spec;

    spec.sampleRate = sampleRate;
    spec.maximumBlockSize = samplesPerBlock;
    spec.numChannels = 1;
    
    leftReverb.prepare (spec);
    rightReverb.prepare (spec);
}

Then, we will implement the audio processing part. As mentioned earlier, we’ll combine them into one as Dry/Wet, so we’ll give it a little twist. It means that the value of dry should be the maximum value of 1 minus the value of wet. Like this:

void SimpleReverbAudioProcessor::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());

    params.roomSize   = *apvts.getRawParameterValue ("Room Size");
    params.damping    = *apvts.getRawParameterValue ("Damping");
    params.width      = *apvts.getRawParameterValue ("Width");
    params.wetLevel   = *apvts.getRawParameterValue ("Dry/Wet");
    params.dryLevel   = 1.0f - *apvts.getRawParameterValue ("Dry/Wet");
    params.freezeMode = *apvts.getRawParameterValue ("Freeze");
    
    leftReverb.setParameters (params);
    rightReverb.setParameters (params);

    juce::dsp::AudioBlock<float> block (buffer);
        
    auto leftBlock = block.getSingleChannelBlock (0);
    auto rightBlock = block.getSingleChannelBlock (1);
    
    juce::dsp::ProcessContextReplacing<float> leftContext (leftBlock);
    juce::dsp::ProcessContextReplacing<float> rightContext (rightBlock);
    
    leftReverb.process (leftContext);
    rightReverb.process (rightContext);
}

Now, the implementation of the dsp part is complete.

UI

This chapter explains the UI part of Simple Reverb.

CustomLookAndFeel

First, we will customize LookAndFeel, which is the foundation of the UI component we will create. Open Projucer and create a new header and cpp file.

The implementation details of the header file are as shown below:

#pragma once

#include <JuceHeader.h>

class CustomLookAndFeel : public juce::LookAndFeel_V4
{
public:
    CustomLookAndFeel();
    ~CustomLookAndFeel();
        
    juce::Slider::SliderLayout getSliderLayout (juce::Slider& slider) override;
            
    void drawRotarySlider (juce::Graphics&, int x, int y, int width, int height,
                           float sliderPosProportional, float rotaryStartAngle,
                           float rotaryEndAngle, juce::Slider&) override;
    
    juce::Label* createSliderTextBox (juce::Slider& slider) override;
    
    juce::Font getTextButtonFont (juce::TextButton&, int buttonHeight) override;
    
    void drawButtonBackground (juce::Graphics& g, juce::Button& button,
                               const juce::Colour& backgroundColour,
                               bool shouldDrawButtonAsHighlighted,
                               bool shouldDrawButtonAsDown) override;
    
private:
    juce::Colour blue      = juce::Colour::fromFloatRGBA (0.43f, 0.83f, 1.0f,  1.0f);
    juce::Colour offWhite  = juce::Colour::fromFloatRGBA (0.83f, 0.84f, 0.9f,  1.0f);
    juce::Colour grey      = juce::Colour::fromFloatRGBA (0.42f, 0.42f, 0.42f, 1.0f);
    juce::Colour blackGrey = juce::Colour::fromFloatRGBA (0.2f,  0.2f,  0.2f,  1.0f);

    JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (CustomLookAndFeel);
};

I will explain the member functions to be overridden one by one.

getSliderLayout()

getSliderLayout() is the function that sets the basic placement of the slider. In other words, it determines how big and where the slider and its text box will be placed.

Simple Reverb’s RotarySlider is designed to place a text box in the center:

juce::Slider::SliderLayout CustomLookAndFeel::getSliderLayout (juce::Slider& slider)
{
    auto localBounds = slider.getLocalBounds();
    
    juce::Slider::SliderLayout layout;

    layout.textBoxBounds = localBounds;
    layout.sliderBounds = localBounds;

    return layout;
}

drawRotarySlider()

drawRotarySlider() is a function deeply involved in the depiction part of RotarySlider. It determines how big the arc should be, what color it should be drawn in, and so on.

The RotarySlider of Simple Reverb is implemented as follows:

void CustomLookAndFeel::drawRotarySlider (juce::Graphics& g, int x, int y, int width, int height, float sliderPos,
                                          const float rotaryStartAngle, const float rotaryEndAngle, juce::Slider& slider)
{
    auto fill = slider.findColour (juce::Slider::rotarySliderFillColourId);

    auto bounds = juce::Rectangle<float> (x, y, width, height).reduced (2.0f);
    auto radius = juce::jmin (bounds.getWidth(), bounds.getHeight()) / 2.0f;
    auto toAngle = rotaryStartAngle + sliderPos * (rotaryEndAngle - rotaryStartAngle);
    auto lineW = radius * 0.1f;
    auto arcRadius = radius - lineW * 0.5f;
    
    juce::Path backgroundArc;
    backgroundArc.addCentredArc (bounds.getCentreX(),
                                 bounds.getCentreY(),
                                 arcRadius,
                                 arcRadius,
                                 0.0f,
                                 rotaryStartAngle,
                                 rotaryEndAngle,
                                 true);

    g.setColour (blackGrey);
    g.strokePath (backgroundArc, juce::PathStrokeType (lineW, juce::PathStrokeType::beveled, juce::PathStrokeType::butt));

    juce::Path valueArc;
    valueArc.addCentredArc (bounds.getCentreX(),
                            bounds.getCentreY(),
                            arcRadius,
                            arcRadius,
                            0.0f,
                            rotaryStartAngle,
                            toAngle,
                            true);
    
    g.strokePath (valueArc, juce::PathStrokeType (lineW, juce::PathStrokeType::beveled, juce::PathStrokeType::butt));
    
    auto alpha = 0.1f + (float) slider.getValue() * 0.9f;
    auto brightness = 0.4f + (float) slider.getValue() * 0.6f;

    g.setColour (fill.withAlpha (alpha).brighter (brightness));
    g.strokePath (valueArc, juce::PathStrokeType (lineW, juce::PathStrokeType::beveled, juce::PathStrokeType::butt));

    auto thumbWidth = lineW * 2.0f;
 
    juce::Path pointer;
    pointer.addRectangle (-thumbWidth / 2, -thumbWidth / 2, thumbWidth, radius + lineW);
    
    g.setColour (offWhite);
    g.fillPath (pointer, juce::AffineTransform::rotation (toAngle + 3.12f).translated (bounds.getCentre()));

    g.fillEllipse (bounds.reduced (7.0f));
}

createSliderTextBox()

createSliderTextBox() is the function involved in depicting the slider text box.

We have just overridden getSliderLayout so that the text box is placed in the center of the RotarySlider, but this will interfere with dragging. To allow dragging even on the text box, we can pass false to setInterceptsMouseClicks():

juce::Label* CustomLookAndFeel::createSliderTextBox (juce::Slider& slider)
{
    auto* l = new juce::Label();
    l->setJustificationType (juce::Justification::centred);
    l->setColour (juce::Label::textColourId, slider.findColour (juce::Slider::textBoxTextColourId));
    l->setColour (juce::Label::textWhenEditingColourId, slider.findColour (juce::Slider::textBoxTextColourId));
    l->setColour (juce::Label::outlineWhenEditingColourId, slider.findColour (juce::Slider::textBoxOutlineColourId));
    l->setInterceptsMouseClicks (false, false);
    l->setFont (15);
    
    return l;
}

getTextButtonFont()

getTextButtonFont() is a function related to the font part of the TextButton object.

We override this function for “∞”.

juce::Font CustomLookAndFeel::getTextButtonFont (juce::TextButton&, int buttonHeight)
{
    juce::Font font ("Avenir Next Medium", 90.f, 0);
    return { font };
}

drawButtonBackground()

drawButtonBackground() is a function related to drawing the background part of a TextButton.
In this case, we don’t need to draw the border and background of “∞”, so we clear it.

void CustomLookAndFeel::drawButtonBackground (juce::Graphics& g, juce::Button& button, const juce::Colour& backgroundColour,
                                              bool shouldDrawButtonAsHighlighted, bool shouldDrawButtonAsDown)
{
}

RotarySlider

RotarySlider also creates a new header file and a cpp file, just like CustomLookAndFeel. The implementation of the header file is shown below:

#pragma once

#include <JuceHeader.h>
#include "CustomLookAndFeel.h"

class RotarySlider  : public juce::Slider
{
public:
    RotarySlider();
    ~RotarySlider() override;
    
    void paint (juce::Graphics& g) override;
    
    void mouseDown (const juce::MouseEvent& event) override;
    void mouseUp (const juce::MouseEvent& event) override;

private:
    CustomLookAndFeel customLookAndFeel;
    
    juce::Colour blue      = juce::Colour::fromFloatRGBA (0.43f, 0.83f, 1.0f, 1.0f);
    juce::Colour grey      = juce::Colour::fromFloatRGBA (0.42f, 0.42f, 0.42f, 1.0f);
    juce::Colour blackGrey = juce::Colour::fromFloatRGBA (0.2f,  0.2f,  0.2f, 1.0f);
    juce::Colour offWhite  = juce::Colour::fromFloatRGBA (0.83f, 0.84f, 0.9f, 1.f);
    
    JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (RotarySlider)
};

RotarySlider()

In this constructor, the following member functions are called to configure the details.

RotarySlider::RotarySlider()
{
    setSliderStyle (juce::Slider::SliderStyle::RotaryVerticalDrag);
    setTextBoxStyle (juce::Slider::TextBoxBelow, true, 0, 0);
    setLookAndFeel (&customLookAndFeel);
    setColour (juce::Slider::rotarySliderFillColourId, blue);
    setColour (juce::Slider::textBoxTextColourId, blackGrey);
    setColour (juce::Slider::textBoxOutlineColourId, grey);
    setVelocityBasedMode (true);
    setVelocityModeParameters (0.5, 1, 0.09, false);
    setRange (0.0, 100.0, 0.01);
    setRotaryParameters (juce::MathConstants<float>::pi * 1.25f, 
                         juce::MathConstants<float>::pi * 2.75f, 
                         true);
    setWantsKeyboardFocus (true);
    setTextValueSuffix (" %");
    onValueChange = [&]()
    {
        if (getValue() < 10)
            setNumDecimalPlacesToDisplay (2);
        else if (getValue() >= 10 && getValue() < 100)
            setNumDecimalPlacesToDisplay (1);
        else
            setNumDecimalPlacesToDisplay (0);
    };
}

paint()

It is implemented so that when the RotarySlider gets the focus, it will be marked as locked on:

void RotarySlider::paint (juce::Graphics& g)
{
    juce::Slider::paint (g);

    if (hasKeyboardFocus (false))
    {
        auto length = getHeight() > 15 ? 5.0f : 4.0f;
        auto thick  = getHeight() > 15 ? 3.0f : 2.5f;
        
        g.setColour (findColour (juce::Slider::textBoxOutlineColourId));

        //          fromX       fromY        toX                  toY
        g.drawLine (0,          0,           0,                   length,               thick);
        g.drawLine (0,          0,           length,              0,                    thick);
        g.drawLine (0,          getHeight(), 0,                   getHeight() - length, thick);
        g.drawLine (0,          getHeight(), length,              getHeight(),          thick);
        g.drawLine (getWidth(), getHeight(), getWidth() - length, getHeight(),          thick);
        g.drawLine (getWidth(), getHeight(), getWidth(),          getHeight() - length, thick);
        g.drawLine (getWidth(), 0,           getWidth() - length, 0,                    thick);
        g.drawLine (getWidth(), 0,           getWidth(),          length,               thick);
    }
}

mouseDown()/mouseUp()

It is implemented so that the mouse pointer disappears when you click and drag the mouse, and it appears when you release the mouse:

void RotarySlider::mouseDown (const juce::MouseEvent& event)
{
    juce::Slider::mouseDown (event);

    setMouseCursor (juce::MouseCursor::NoCursor);
}

void RotarySlider::mouseUp (const juce::MouseEvent& event)
{
    juce::Slider::mouseUp (event);

    juce::Desktop::getInstance().getMainMouseSource().setScreenPosition (event.source.getLastMouseDownPosition());
    setMouseCursor (juce::MouseCursor::NormalCursor);
}

NameLabel

This class is customized for the labels that correspond to each knob. If we don’t prepare this, we will have to write the same code multiple times, which is not very beautiful. The amount of code to be written is small, so just create a new header file and implement it as shown below:

#pragma once

#include <JuceHeader.h>

class NameLabel  : public juce::Label
{
public:
	NameLabel()
	{
		setFont (20.f);
		setColour (juce::Label::textColourId, grey);
		setJustificationType (juce::Justification::centred);
	}

	~NameLabel(){}

private:
	juce::Colour grey = juce::Colour::fromFloatRGBA (0.42f, 0.42f, 0.42f, 1.0f);

};

PluginEditor

Now, we have completed the implementation of the UI components that make up Simple Reverb. we just need to implement the PluginEditor part.

#pragma once

#include <JuceHeader.h>
#include "PluginProcessor.h"
#include "CustomLookAndFeel.h"
#include "RotarySlider.h"
#include "NameLabel.h"

//==============================================================================
/**
*/
class SimpleReverbAudioProcessorEditor  : public juce::AudioProcessorEditor
{
public:
    SimpleReverbAudioProcessorEditor (SimpleReverbAudioProcessor&);
    ~SimpleReverbAudioProcessorEditor() override;

    //==============================================================================
    void paint (juce::Graphics&) override;
    void resized() override;
    
private:
    SimpleReverbAudioProcessor& audioProcessor;

    NameLabel sizeLabel,
              dampLabel, 
              widthLabel,
              dwLabel;       

    RotarySlider sizeSlider, 
                 dampSlider, 
                 widthSlider,
                 dwSlider; 
    
    juce::TextButton freezeButton;
    
    juce::AudioProcessorValueTreeState::SliderAttachment sizeSliderAttachment,
                                                         dampSliderAttachment,
                                                         widthSliderAttachment,
                                                         dwSliderAttachment;
    
    juce::AudioProcessorValueTreeState::ButtonAttachment freezeAttachment;
    
    CustomLookAndFeel customLookAndFeel;
    
    juce::Colour blue      = juce::Colour::fromFloatRGBA (0.43f, 0.83f, 1.0f, 1.0f);
    juce::Colour offWhite  = juce::Colour::fromFloatRGBA (0.83f, 0.84f, 0.9f, 1.0f);
    juce::Colour grey      = juce::Colour::fromFloatRGBA (0.42f, 0.42f, 0.42f, 1.0f);
    juce::Colour black     = juce::Colour::fromFloatRGBA (0.08f, 0.08f, 0.08f, 1.0f);

    JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (SimpleReverbAudioProcessorEditor)
};

SimpleReverbAudioProcessorEditor()

The implementation part of this constructor is as shown below:

SimpleReverbAudioProcessorEditor::SimpleReverbAudioProcessorEditor (SimpleReverbAudioProcessor& p)
    : AudioProcessorEditor (&p), audioProcessor (p),
      sizeSliderAttachment (audioProcessor.apvts, "Room Size", sizeSlider),
      dampSliderAttachment (audioProcessor.apvts, "Damping", dampSlider),
      widthSliderAttachment (audioProcessor.apvts, "Width", widthSlider),
      dwSliderAttachment (audioProcessor.apvts, "Dry/Wet", dwSlider),
      freezeAttachment (audioProcessor.apvts, "Freeze", freezeButton)
{
    juce::LookAndFeel::getDefaultLookAndFeel().setDefaultSansSerifTypefaceName ("Avenir Next Medium");

    setSize (500, 250);
    setWantsKeyboardFocus (true);
    
    sizeLabel.setText ("Size", juce::NotificationType::dontSendNotification);
    sizeLabel.attachToComponent (&sizeSlider, false);
    
    dampLabel.setText ("Damp", juce::NotificationType::dontSendNotification);
    dampLabel.attachToComponent (&dampSlider, false);
    
    widthLabel.setText ("Width", juce::NotificationType::dontSendNotification);
    widthLabel.attachToComponent (&widthSlider, false);

    dwLabel.setText ("Dry/Wet", juce::NotificationType::dontSendNotification);
    dwLabel.attachToComponent (&dwSlider, false);
    
    freezeButton.setButtonText (juce::String (juce::CharPointer_UTF8 ("∞")));
    freezeButton.setClickingTogglesState (true);
    freezeButton.setLookAndFeel (&customLookAndFeel);
    freezeButton.setColour (juce::TextButton::buttonColourId, juce::Colours::transparentWhite);
    freezeButton.setColour (juce::TextButton::buttonOnColourId, juce::Colours::transparentWhite);
    freezeButton.setColour (juce::TextButton::textColourOnId, blue);
    freezeButton.setColour (juce::TextButton::textColourOffId, grey);

    addAndMakeVisible (sizeSlider);
    addAndMakeVisible (dampSlider);
    addAndMakeVisible (widthSlider);
    addAndMakeVisible (dwSlider);  
    addAndMakeVisible (freezeButton);
}

paint()

Fill the background with a smoky black color and depict the text “Simple Reverb”:

void SimpleReverbAudioProcessorEditor::paint (juce::Graphics& g)
{
    g.fillAll (black);
    
    g.setFont (30);
    g.setColour (offWhite);
    g.drawText ("Simple Reverb", 150, 0, 200, 75, juce::Justification::centred);
}

resized()

The placement and size of each component is shown below:

void SimpleReverbAudioProcessorEditor::resized()
{
    sizeSlider.setBounds (30, 120, 60, 60);
    dampSlider.setBounds (125, 120, 60, 60);
    widthSlider.setBounds (315, 120, 60, 60);
    dwSlider.setBounds (410, 120, 60, 60);
    freezeButton.setBounds (210, 120, 80, 40);
}

Now let’s build and see!

Conclusion

In this article, I explained how to make a simple reverb with the JUCE DSP module. I highly recommend this module as it allows you to quickly create a plugin.

If there’s a more efficient way to implement this, please do comment. Thanks for reading to the end!

Comments

Copied title and URL