How to Make a Simple Low-Cut & High-Cut Filter

Introduction

In this article, we will explain how to implement a very simple equalizer that has only two filters, a low-cut filter and a high-cut filter, which allows you to control the cut frequency and Q parameters. Let’s get started!

Prerequisites

Create a new project with whatever name you want.

Then, add the dsp module.

If you were able to do it correctly, it should look like the following.

DSP

Configuring the parameters

First, prepare an object of the AudioProcessorValueTreeState class, and pass the series of parameters you want to manage to this object using createParameterLayout(), which returns a value of the ParameterLayout type.

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

Then, define createParameterLayout() as follows:

juce::AudioProcessorValueTreeState::ParameterLayout EqTutorialAudioProcessor::createParameterLayout()
{
    juce::AudioProcessorValueTreeState::ParameterLayout layout;
    
    layout.add (std::make_unique<juce::AudioParameterFloat> ("LowCut Freq",
                                                             "LowCut Freq",
                                                             juce::NormalisableRange<float> (20.f, 20000.f, 1.f, 0.25f),
                                                             20.f,
                                                             juce::String(),
                                                             juce::AudioProcessorParameter::genericParameter,
                                                             [](float value, int) { return (value < 1000.0f) ?
                                                                 juce::String (value, 0) + " Hz" :
                                                                 juce::String (value / 1000.0f, 1) + " kHz"; },
                                                             nullptr));
    
    layout.add (std::make_unique<juce::AudioParameterFloat> ("LowCut Quality",
                                                             "LowCut Quality",
                                                             juce::NormalisableRange<float> (0.1f, 10.f, 0.01f, 0.25f),
                                                             0.71f));
    
    layout.add (std::make_unique<juce::AudioParameterFloat> ("HighCut Freq",
                                                             "HighCut Freq",
                                                             juce::NormalisableRange<float> (20.f, 20000.f, 1.f, 0.25f),
                                                             20000.f,
                                                             juce::String(),
                                                             juce::AudioProcessorParameter::genericParameter,
                                                             [](float value, int) { return (value < 1000.0f) ?
                                                                 juce::String (value, 0) + " Hz" :
                                                                 juce::String (value / 1000.0f, 1) + " kHz"; },
                                                             nullptr));
    
    layout.add (std::make_unique<juce::AudioParameterFloat> ("HighCut Quality",
                                                             "HighCut Quality",
                                                             juce::NormalisableRange<float> (0.1f, 10.f, 0.01f, 0.25f),
                                                             0.71f));
    
    return layout;
}

In this way, we used AudioParameterFloat to set a series of parameters required for each Filter. The cut frequency range was set to the range of human audibility, and the default values of the low-cut and high-cut filters were set to 20 Hz and 20 kHz, respectively.

Implementing filters

We will create a stereo filter, so we will have the same one for each side.

class EqTutorialAudioProcessor  : public juce::AudioProcessor
{
・・・
private:
    using Filter = juce::dsp::IIR::Filter<float>;
    using MonoChain = juce::dsp::ProcessorChain<Filter, Filter>;
    
    MonoChain leftChain, rightChain;

Next, implement prepareToPlay() using the ProcessSpec as follows:

void EqTutorialAudioProcessor::prepareToPlay (double sampleRate, int samplesPerBlock)
{
    juce::dsp::ProcessSpec spec;
    
    spec.maximumBlockSize = samplesPerBlock;
    spec.numChannels = 1;
    spec.sampleRate = sampleRate;
    
    leftChain.prepare(spec);
    rightChain.prepare(spec);
}

Prepare an object of the AudioBlock class and assign a channel to each of the left and right filters to perform audio processing.

void EqualizerTutorialAudioProcessor::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());
    
    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);
    
    leftChain.process (leftContext);
    rightChain.process (rightContext);
}

Check the current behavior of the plugin. If you implement createEditor() as shown below, it will automatically prepare the Editor part.

juce::AudioProcessorEditor* EqTutorialAudioProcessor::createEditor()
{
//    return new EqTutorialAudioProcessorEditor (*this);
    return new juce::GenericAudioProcessorEditor (*this);
}

Of course, if you move the slider, nothing happens.

Setting filter coefficients

We use the following structures and functions to allow us to receive a series of parameters from the APVTS object:

struct ChainSettings
{
    float lowCutFreq { 0 }, highCutFreq { 0 };
    float lowCutQuality { 0.71f }, highCutQuality { 0.71f };
};

ChainSettings getChainSettings (juce::AudioProcessorValueTreeState& apvts);

The definition part is as follows:

ChainSettings getChainSettings (juce::AudioProcessorValueTreeState& apvts)
{
    ChainSettings settings;
    
    settings.lowCutFreq = apvts.getRawParameterValue ("LowCut Freq")->load();
    settings.lowCutQuality = apvts.getRawParameterValue ("LowCut Quality")->load();
    settings.highCutFreq = apvts.getRawParameterValue ("HighCut Freq")->load();
    settings.highCutQuality = apvts.getRawParameterValue ("HighCut Quality")->load();

    return settings;
}

Then, prepare the function to update the coefficients of each filter.

class EqTutorialAudioProcessor  : public juce::AudioProcessor
{
・・・
private:
・・・
    enum ChainPositions
    {
        LowCut,
        HighCut
    };

    void updateLowCutFilter (const ChainSettings& chainSettings);
    void updateHighCutFilter (const ChainSettings& chainSettings); 
    void updateFilters();

The definition part is as follows:

void EqTutorialAudioProcessor::updateLowCutFilter (const ChainSettings& chainSettings)
{
    auto lowCutCoefficients = juce::dsp::IIR::Coefficients<float>::makeHighPass (getSampleRate(),
                                                                                 chainSettings.lowCutFreq,
                                                                                 chainSettings.lowCutQuality);
        
    *leftChain.get<ChainPositions::LowCut>().coefficients = *lowCutCoefficients;
    *rightChain.get<ChainPositions::LowCut>().coefficients = *lowCutCoefficients;
}

void EqTutorialAudioProcessor::updateHighCutFilter (const ChainSettings& chainSettings)
{
    auto highCutCoefficients = juce::dsp::IIR::Coefficients<float>::makeLowPass (getSampleRate(),
                                                                                 chainSettings.highCutFreq,
                                                                                 chainSettings.highCutQuality);
    
    *leftChain.get<ChainPositions::HighCut>().coefficients = *highCutCoefficients;
    *rightChain.get<ChainPositions::HighCut>().coefficients = *highCutCoefficients;
}

The low-cut filter is equal to the high-pass filter, so makeHighPass() is used, while the high-cut filter is equal to the low-pass filter, so makeLowPass() is used.

As it is, we have to call updateLowCutFilter() and updateHighCutFilter() respectively, so we will combine them into updateFilters() so that we only have to call this function:

void EqTutorialAudioProcessor::updateFilters()
{
    auto chainSettings = getChainSettings (apvts);
    
    updateLowCutFilter (chainSettings);
    updateHighCutFilter (chainSettings);
}

Add this function in prepareToPlay() and processBlock() as shown below:

void EqTutorialAudioProcessor::prepareToPlay (double sampleRate, int samplesPerBlock)
{
    juce::dsp::ProcessSpec spec;
    
    spec.maximumBlockSize = samplesPerBlock;
    spec.numChannels = 1;
    spec.sampleRate = sampleRate;
    
    leftChain.prepare (spec);
    rightChain.prepare (spec);
    
    updateFilters();
}
void EqTutorialAudioProcessor::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());
    
    updateFilters();

    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);
    
    leftChain.process (leftContext);
    rightChain.process (rightContext);
}

With the above implementation, the filter should work correctly. Let’s check the operation with AudioPluginHost, etc.

UI

Now, let’s start implementing the UI part. Since we just set the GenericAudioProcessorEditor class object as the editor, let’s change it to the editor for this project.

juce::AudioProcessorEditor* EqTutorialAudioProcessor::createEditor()
{
    return new EqTutorialAudioProcessorEditor (*this);
//    return new juce::GenericAudioProcessorEditor (*this);
}

You should see the following “Hello World!”.

Implementing NumberBox

The implementation of NumberBox has been explained in previous articles, so detailed explanation is omitted.

Reproducing the UI of MOD-D3 with JUCE (2) : NumberBox

#pragma once

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

class NumberBox  : public juce::Slider
{
public:
    NumberBox();
    ~NumberBox() override;

    void paint (juce::Graphics&) override;
    
    void mouseDown (const juce::MouseEvent& event) override;
    void mouseUp (const juce::MouseEvent& event) override;

    void focusGained (juce::Component::FocusChangeType) override;
    void focusLost (juce::Component::FocusChangeType) override;

    bool getLockedOnState ();
    void setLockedOnState (bool state);

private:    
    bool isLockedOn = false;

    CustomLookAndFeel customLookAndFeel;
    
    juce::Colour grey = juce::Colour::fromFloatRGBA(0.42, 0.42, 0.42, 1.0);
    
    JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (NumberBox)
};
#include <JuceHeader.h>
#include "NumberBox.h"

NumberBox::NumberBox()
{
    setSliderStyle (juce::Slider::LinearBarVertical);
    setLookAndFeel(&customLookAndFeel);
    setColour (juce::Slider::textBoxOutlineColourId, juce::Colours::transparentWhite);
    setColour (juce::Slider::trackColourId, juce::Colours::transparentWhite);
    setTextBoxIsEditable (false);
    setVelocityBasedMode (true);
    setVelocityModeParameters (0.5, 1, 0.09, false);
    setRange (0, 100, 0.01);
    setWantsKeyboardFocus (true);
    onValueChange = [&]()
    {
        if (getValue() < 10)
            setNumDecimalPlacesToDisplay(2);
        else if (getValue() >= 10 && getValue() < 100)
            setNumDecimalPlacesToDisplay(1);
        else 
            setNumDecimalPlacesToDisplay(0);
    };
}

NumberBox::~NumberBox()
{
}

void NumberBox::paint (juce::Graphics& g)
{
    if (isLockedOn)
    {
        auto length = getHeight() > 15 ? 4 : 3;
        auto thick  = getHeight() > 15 ? 3.0 : 2.5;
        
        g.setColour (findColour (juce::Slider::textBoxTextColourId));
        //          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);
    }
}

void NumberBox::mouseDown (const juce::MouseEvent& event)
{
    juce::Slider::mouseDown (event);
    setMouseCursor (juce::MouseCursor::NoCursor);
}

void NumberBox::mouseUp (const juce::MouseEvent& event)
{
    juce::Slider::mouseUp (event);
    juce::Desktop::getInstance().getMainMouseSource().setScreenPosition(event.source.getLastMouseDownPosition());
    setMouseCursor (juce::MouseCursor::NormalCursor);
}
    
void NumberBox::focusGained (juce::Component::FocusChangeType)
{
    setLockedOnState (true); 
}

void NumberBox::focusLost (juce::Component::FocusChangeType)
{
    setLockedOnState (false); 
}

bool NumberBox::getLockedOnState ()
{
    return isLockedOn;
}

void NumberBox::setLockedOnState (bool state)
{
    isLockedOn = state;
    repaint();
}

Configuring PluginEditor

Now, create a NumberBox object as shown below. Be careful not to forget to include the NumberBox.h that you just created.

#pragma once

#include <JuceHeader.h>
#include "PluginProcessor.h"
#include "NumberBox.h"

class EqTutorialAudioProcessorEditor  : public juce::AudioProcessorEditor
{
public:
    EqTutorialAudioProcessorEditor (EqTutorialAudioProcessor&);
    ~EqTutorialAudioProcessorEditor() override;

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

    void mouseDown (const juce::MouseEvent& event) override

private:
    EqTutorialAudioProcessor& audioProcessor;
    
    NumberBox lowCutFreqBox;
    NumberBox lowCutQualityBox;
        
    NumberBox highCutFreqBox;
    NumberBox highCutQualityBox;
    
    juce::Colour blue = juce::Colour::fromFloatRGBA (0.4, 0.8, 1.0, 1.0);
    juce::Colour yellow = juce::Colour::fromFloatRGBA (1.0, 0.7, 0.2, 1.0);
    juce::Colour black = juce::Colour::fromFloatRGBA (0.08, 0.08, 0.08, 1.0);
    
    JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (EqTutorialAudioProcessorEditor)
};

Define the constructor as follows:

EqTutorialAudioProcessorEditor::EqTutorialAudioProcessorEditor (EqTutorialAudioProcessor& p)
    : AudioProcessorEditor (&p), audioProcessor (p)
{
    setSize (300, 150);
    setWantsKeyboardFocus (true);
    
    lowCutFreqBox.setColour (juce::Slider::textBoxTextColourId, blue);
    lowCutQualityBox.setColour (juce::Slider::textBoxTextColourId, blue.darker(0.3));

    highCutFreqBox.setColour (juce::Slider::textBoxTextColourId, yellow);
    highCutQualityBox.setColour (juce::Slider::textBoxTextColourId, yellow.darker(0.3));
    
    addAndMakeVisible (lowCutFreqBox);
    addAndMakeVisible (lowCutQualityBox);
    addAndMakeVisible (highCutFreqBox);
    addAndMakeVisible (highCutQualityBox);
}

Set the background color of the editor to a smoky black color.

void EqTutorialAudioProcessorEditor::paint (juce::Graphics& g)
{
    g.fillAll (black);
}

Set each NumberBox to the following arrangement and size:

void EqTutorialAudioProcessorEditor::resized()
{
    lowCutFreqBox.setBounds (35, getHeight() / 2 - 20, 80, 20);
    lowCutQualityBox.setBounds (35, getHeight() / 2 + 10, 80, 20);
    
    highCutFreqBox.setBounds (185, getHeight() / 2 - 20, 80, 20);
    highCutQualityBox.setBounds (185, getHeight() / 2 + 10, 80, 20);
}

Now let’s build and check it out. At the moment, the parameters of NumberBox and APVTS are not linked, so this is how it looks.

DSP <-> UI

connecting a NumberBox to a parameter

To link the two together, we use SliderAttachment. As mentioned in the Document, we must make sure that the SliderAttachment object is destructed first! Therefore, we declare this object below the declaration part of the NumberBox object.

class EqTutorialAudioProcessorEditor  : public juce::AudioProcessorEditor
{
・・・
private:
    EqTutorialAudioProcessor& audioProcessor;
    
    NumberBox lowCutFreqBox;
    NumberBox lowCutQualityBox;    
    NumberBox highCutFreqBox;
    NumberBox highCutQualityBox;
    
    using Attachment = juce::AudioProcessorValueTreeState::SliderAttachment;
    
    Attachment lowCutFreqAttachment,
               lowCutQualityAttachment,
               highCutFreqAttachment,
               highCutQualityAttachment;

As shown below, specify the apvts object as the first argument, the parameter ID as the second argument, and the NumberBox object as the third argument:

EqTutorialAudioProcessorEditor::EqTutorialAudioProcessorEditor (EqTutorialAudioProcessor& p)
    : AudioProcessorEditor (&p), audioProcessor (p),
      lowCutFreqAttachment (audioProcessor.apvts, "LowCut Freq", lowCutFreqBox),
      lowCutQualityAttachment (audioProcessor.apvts, "LowCut Quality", lowCutQualityBox),
      highCutFreqAttachment (audioProcessor.apvts, "HighCut Freq", highCutFreqBox),
      highCutQualityAttachment (audioProcessor.apvts, "HighCut Quality", highCutQualityBox)

{
・・・
}

checking if operating correctly

That’s all I have to do. Let’s build it and see if it works!

Conclusion

In this article, I explained how to implement a very simple equalizer. If there are any misinterpretations or parts of the code that could be written better, please let me know in the comments or DM. Thank you for reading to the end.

References

Comments

Copied title and URL