DSP: processors, filters, delay lines, convolution, and the FFT
Use the juce_dsp module to build effects and instruments from tested blocks, and analyse audio in the frequency domain.
Level: Intermediate to advanced
Module: link juce::juce_dsp (and juce::juce_audio_utils for examples)
Set up the project
The project below plays a quiet 220 Hz tone through the oscillator, filter, and gain chain from the processor-chain section. Turn your volume down first. The later examples (distortion, convolution, delay lines, the FFT) are further processors and displays to add to it.
This guide builds on Getting started, which explains the CMake setup and Main.cpp in detail. To skip the typing, download the starter project, DspDemo.zip, and unzip it. It contains exactly the four files below:
DspDemo/
├── CMakeLists.txt
├── Main.cpp
├── MainComponent.h
└── MainComponent.cppIf you prefer, create the DspDemo folder yourself and copy each file from this page. Either way, CMakeLists.txt expects JUCE to be cloned to ~/JUCE. If yours is elsewhere, change the path on the file (REAL_PATH ...) line. This CMakeLists.txt names the target DspDemo and links the modules this guide needs.
CMakeLists.txt
cmake_minimum_required(VERSION 3.22)
set(CMAKE_OSX_ARCHITECTURES "arm64" CACHE STRING "macOS architectures") # must come before project()
project(DSPDEMO VERSION 0.0.1)
file(REAL_PATH "~/JUCE" JUCE_DIR EXPAND_TILDE) # JUCE cloned to ~/JUCE; or use find_package (JUCE CONFIG REQUIRED)
add_subdirectory(${JUCE_DIR} JUCE)
juce_add_gui_app(DspDemo PRODUCT_NAME "Dsp Demo")
target_sources(DspDemo PRIVATE Main.cpp MainComponent.cpp)
target_compile_definitions(DspDemo PRIVATE
JUCE_WEB_BROWSER=0
JUCE_USE_CURL=0
JUCE_APPLICATION_NAME_STRING="$<TARGET_PROPERTY:DspDemo,JUCE_PRODUCT_NAME>"
JUCE_APPLICATION_VERSION_STRING="$<TARGET_PROPERTY:DspDemo,JUCE_VERSION>")
target_link_libraries(DspDemo
PRIVATE juce::juce_audio_utils
juce::juce_dsp
PUBLIC juce::juce_recommended_config_flags
juce::juce_recommended_warning_flags)Main.cpp
#include "MainComponent.h"
class HelloJuceApplication final : public juce::JUCEApplication
{
public:
const juce::String getApplicationName() override { return JUCE_APPLICATION_NAME_STRING; }
const juce::String getApplicationVersion() override { return JUCE_APPLICATION_VERSION_STRING; }
void initialise (const juce::String&) override
{
mainWindow.reset (new MainWindow (getApplicationName()));
}
void shutdown() override { mainWindow = nullptr; } // deletes the window
void systemRequestedQuit() override { quit(); }
class MainWindow final : public juce::DocumentWindow
{
public:
explicit MainWindow (juce::String name)
: DocumentWindow (name, juce::Colours::lightgrey, allButtons)
{
setUsingNativeTitleBar (true);
setContentOwned (new MainComponent(), true);
setResizable (true, true);
centreWithSize (getWidth(), getHeight());
setVisible (true); // required, or the window never appears
}
void closeButtonPressed() override
{
juce::JUCEApplication::getInstance()->systemRequestedQuit();
}
};
private:
std::unique_ptr<MainWindow> mainWindow;
};
START_JUCE_APPLICATION (HelloJuceApplication)This is the Main.cpp from tutorial 1, unchanged, and it stays unchanged for the rest of this guide.
MainComponent.h
#pragma once
#include <juce_audio_utils/juce_audio_utils.h>
#include <juce_dsp/juce_dsp.h>
// An oscillator -> filter -> gain chain built from juce::dsp blocks
class Voice
{
public:
enum { oscIndex, filterIndex, gainIndex };
Voice()
{
chain.get<oscIndex>().initialise ([] (float x) { return std::sin (x); }, 128); // lookup table
chain.get<filterIndex>().setCutoffFrequencyHz (1000.0f);
chain.get<filterIndex>().setResonance (0.7f);
chain.get<gainIndex>().setGainLinear (0.1f); // kept quiet so the demo is safe to run
}
void prepare (const juce::dsp::ProcessSpec& spec) { chain.prepare (spec); }
void reset() { chain.reset(); }
template <typename Context>
void process (const Context& context) { chain.process (context); }
void setFrequency (float hz, bool force = false) { chain.get<oscIndex>().setFrequency (hz, force); }
private:
juce::dsp::ProcessorChain<juce::dsp::Oscillator<float>,
juce::dsp::LadderFilter<float>,
juce::dsp::Gain<float>> chain;
};
class MainComponent final : public juce::AudioAppComponent
{
public:
MainComponent();
~MainComponent() override;
void prepareToPlay (int samplesPerBlockExpected, double sampleRate) override;
void getNextAudioBlock (const juce::AudioSourceChannelInfo& info) override;
void releaseResources() override;
private:
Voice voice;
JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (MainComponent)
};MainComponent.cpp
#include "MainComponent.h"
MainComponent::MainComponent()
{
setSize (400, 200);
setAudioChannels (0, 2); // no inputs, two outputs
}
MainComponent::~MainComponent()
{
shutdownAudio();
}
void MainComponent::prepareToPlay (int samplesPerBlockExpected, double sampleRate)
{
voice.prepare ({ sampleRate, (juce::uint32) samplesPerBlockExpected, 2 });
voice.setFrequency (220.0f, true);
}
void MainComponent::getNextAudioBlock (const juce::AudioSourceChannelInfo& info)
{
info.clearActiveBufferRegion();
// Process only the region of the buffer JUCE asked us to fill
juce::dsp::AudioBlock<float> block (*info.buffer);
auto sub = block.getSubBlock ((size_t) info.startSample, (size_t) info.numSamples);
voice.process (juce::dsp::ProcessContextReplacing<float> (sub));
}
void MainComponent::releaseResources()
{
voice.reset();
}Concepts in one page
- Time domain vs frequency domain. Audio samples are a signal over time. The FFT converts a block of them to a spectrum (magnitude and phase per frequency), and back.
- FIR filters compute each output from a finite window of inputs: always stable, and can have linear phase. IIR filters also feed back their own outputs: cheap and flexible, but can become unstable.
juce_dspprovides design helpers for both (Butterworth, Chebyshev, Elliptic, and other IIR designs; window, Kaiser, least-squares, and other FIR designs). - Waveshaping applies a function to each sample. Clipping and
tanhadd harmonics, and so create distortion. - Convolution applies an impulse response (a recording of how a space or device reacts to a click) to any signal: reverbs, cabinet and tape simulations.
- Delay lines hold past samples in a circular buffer: echoes, chorus, comb filters, and physical models of strings and tubes.
The processor lifecycle
Every juce::dsp processor follows the same protocol, which is why they compose:
| Method | Purpose |
|---|---|
prepare (const ProcessSpec&) | Called before processing with the sample rate, maximum block size, and channel count. Allocate here. |
process (const ProcessContext&) | Process a block. Real-time safe. |
reset() | Clear internal state (filter memory, delay lines, smoothing). |
juce::dsp::AudioBlock<float> block (buffer); // a lightweight view of an AudioBuffer
juce::dsp::ProcessContextReplacing<float> context (block); // process in place
processor.process (context);ProcessContextReplacing reads and writes the same block. ProcessContextNonReplacing takes separate input and output blocks. AudioBlock::getSubBlock (start, length) lets you process part of a buffer, such as the region of a synthesiser's output block.
Chains
ProcessorChain<A, B, C> runs processors in sequence, and forwards prepare(), process(), and reset() to each of them. Access a member by index with get<index>():
class Voice
{
public:
enum { oscIndex, filterIndex, gainIndex };
Voice()
{
chain.get<oscIndex>().initialise ([] (float x) { return std::sin (x); }, 128); // lookup table
chain.get<filterIndex>().setCutoffFrequencyHz (1000.0f);
chain.get<filterIndex>().setResonance (0.7f);
chain.get<gainIndex>().setGainLinear (0.7f);
}
void prepare (const juce::dsp::ProcessSpec& spec) { chain.prepare (spec); }
void reset() { chain.reset(); }
template <typename Context>
void process (const Context& context) { chain.process (context); }
void setFrequency (float hz, bool force = false) { chain.get<oscIndex>().setFrequency (hz, force); }
private:
juce::dsp::ProcessorChain<juce::dsp::Oscillator<float>,
juce::dsp::LadderFilter<float>,
juce::dsp::Gain<float>> chain;
};Any class with prepare, process, and reset can join a chain, including your own (a CustomOscillator made of two detuned oscillators, for example), so complex voices are assembled from simple parts.
dsp::Oscillator<T>::initialise (function, lookupTableSize)builds a waveform from any function of phase ([-π, π]). A sawtooth isjmap (x, -pi, pi, -1, 1). The table approximates expensive maths for cheaper playback. Use1 << nentries and more for smoother output.- Detune a second oscillator by about 1% for a thicker tone.
- Modulate from a control-rate LFO: run a second
OscillatoratsampleRate / 100, process the audio in sub-blocks of about 100 samples, and update (say) the filter cutoff once per sub-block withjmap (lfo.processSample (0.0f), -1.0f, 1.0f, 100.0f, 2000.0f). dsp::Reverb,Chorus,Phaser,Compressor,Limiter,NoiseGate, andOversamplingare ready-made effects that slot into the same chain.
Multi-channel filters
An IIR Filter<float> is mono. To process several channels with one set of coefficients, wrap it in ProcessorDuplicator, and update the state:
juce::dsp::ProcessorDuplicator<juce::dsp::IIR::Filter<float>,
juce::dsp::IIR::Coefficients<float>> filter;
void prepare (const juce::dsp::ProcessSpec& spec)
{
*filter.state = *juce::dsp::IIR::Coefficients<float>::makeHighPass (spec.sampleRate, 1000.0f);
filter.prepare (spec);
}Filter design lives on IIR::Coefficients (makeLowPass, makeHighPass, makeBandPass, makePeakFilter, and so on); FIR design in FIR::Coefficients and FilterDesign. For parameters that move continuously, StateVariableTPTFilter is stable under fast modulation.
Distortion: WaveShaper
juce::dsp::ProcessorChain<juce::dsp::Gain<float>,
juce::dsp::WaveShaper<float>,
juce::dsp::Gain<float>> distortion;
// Hard clip: x -> jlimit (-0.1f, 0.1f, x)
// Soft clip: x -> std::tanh (x), driven by the pre-gain
distortion.get<1>().functionToUse = [] (float x) { return std::tanh (x); };
distortion.get<0>().setGainDecibels (30.0f); // drive: more gain = more harmonics
distortion.get<2>().setGainDecibels (-20.0f); // compensate for the level increaseHard clipping (signum, jlimit) makes harsh, odd-harmonic-rich distortion. tanh rounds the corners for a softer sound, and boosting the signal before shaping pushes it towards a square wave. Consider Oversampling around a waveshaper to keep the new harmonics from aliasing.
Convolution
juce::dsp::Convolution convolution; // uniform-partitioned; loads on a background thread
void prepare (const juce::dsp::ProcessSpec& spec)
{
convolution.prepare (spec);
convolution.loadImpulseResponse (juce::File ("/path/to/ir.wav"),
juce::dsp::Convolution::Stereo::yes,
juce::dsp::Convolution::Trim::yes,
0, // 0 = whole IR
juce::dsp::Convolution::Normalise::yes);
}
void process (const juce::dsp::ProcessContextReplacing<float>& context) { convolution.process (context); }loadImpulseResponse()also accepts in-memory data (for example fromBinaryData) and anAudioBuffer. Loading happens off the audio thread and the new response is swapped in when ready.- Convolution adds latency for long responses. The
LatencyandNonUniformconstructors trade latency against CPU. Report it withsetLatencySamples (convolution.getLatency())in a plug-in. - Mix wet and dry with
dsp::DryWetMixerfor reverbs.
Delay lines and a string model
dsp::DelayLine<float> is a ready-made, interpolating circular buffer:
juce::dsp::DelayLine<float> delay { 96000 }; // maximum delay in samples
delay.prepare (spec);
delay.setDelay (0.25f * (float) spec.sampleRate); // quarter of a second
for (size_t i = 0; i < numSamples; ++i)
{
auto delayed = delay.popSample (channel); // read the delayed sample
delay.pushSample (channel, std::tanh (in[i] + feedback * delayed)); // feed back, saturated
out[i] = in[i] + wet * delayed;
}Writing your own shows what is happening: a std::vector with a moving "least recent" index; push() overwrites the oldest sample and steps the index back, and get (n) reads rawData[(index + 1 + n) % size].
- Feedback delay. Feed part of the output back into the line (through a saturator to keep it stable). Filter the feedback path with a low-pass so each repeat is duller, or a high-pass so each repeat is brighter.
- Stereo delay. Keep one delay line and delay time per channel (for example 0.7 s and 0.5 s).
- Waveguide string (Karplus–Strong family). Two delay lines carry waves in opposite directions along the string. Each sample: read the ends, push the inverted reflection into the other line, low-pass filter one path (higher harmonics decay faster in real strings), scale by a decay coefficient, and read the output at a "pickup" position. The line length is
sampleRate / frequency, so pitch comes from the length. A "pluck" fills both lines with a triangular displacement, peaking at the trigger position and scaled by note velocity:
auto length = (size_t) juce::roundToInt (sampleRate / freqHz);
forward.resize (length); backward.resize (length);
Type processSample() noexcept
{
auto forwardOut = forward.back();
auto backwardOut = backward.back();
forward.push (-backwardOut); // reflect at the far end
backward.push (-decayCoef * lowpass.processSample (forwardOut)); // lose energy, dull the tone
return forward.get (forwardPickup) + backward.get (backwardPickup);
}The FFT
dsp::FFT transforms blocks whose size is a power of two, given as an order (size = 1 << order).
juce::dsp::FFT fft (11); // 2048 points
std::array<float, 2 * 2048> data {}; // needs 2 * size floats
// Fill data[0 .. size) with (windowed) samples, then:
fft.performRealOnlyForwardTransform (data.data()); // complex, interleaved output
// or, when you only need magnitudes:
fft.performFrequencyOnlyForwardTransform (data.data()); // first size/2 entries = magnitudes
fft.performRealOnlyInverseTransform (data.data()); // back to samplesBin k corresponds to k * sampleRate / size Hz. Resolution improves, and time resolution worsens, as the size grows.
Real-time spectrum analyser
Collect samples on the audio thread, transform and draw on the message thread:
class Analyser final : public juce::AudioAppComponent, private juce::Timer
{
public:
enum { fftOrder = 11, fftSize = 1 << fftOrder, scopeSize = 512 };
Analyser() : forwardFFT (fftOrder),
window (fftSize, juce::dsp::WindowingFunction<float>::hann)
{
setAudioChannels (2, 0);
startTimerHz (30);
}
~Analyser() override { shutdownAudio(); }
void getNextAudioBlock (const juce::AudioSourceChannelInfo& info) override
{
if (info.buffer->getNumChannels() > 0)
{
auto* in = info.buffer->getReadPointer (0, info.startSample);
for (int i = 0; i < info.numSamples; ++i)
pushNextSampleIntoFifo (in[i]);
}
}
void paint (juce::Graphics& g) override
{
g.fillAll (juce::Colours::black);
g.setColour (juce::Colours::white);
auto w = getWidth();
auto h = (float) getHeight();
for (int i = 1; i < scopeSize; ++i)
g.drawLine ((float) juce::jmap (i - 1, 0, scopeSize - 1, 0, w),
juce::jmap (scope[(size_t) i - 1], 0.0f, 1.0f, h, 0.0f),
(float) juce::jmap (i, 0, scopeSize - 1, 0, w),
juce::jmap (scope[(size_t) i], 0.0f, 1.0f, h, 0.0f));
}
private:
void pushNextSampleIntoFifo (float sample) noexcept
{
if (fifoIndex == fftSize)
{
if (! nextBlockReady) // drop a block if the GUI is behind
{
std::fill (fftData.begin(), fftData.end(), 0.0f);
std::copy (fifo.begin(), fifo.end(), fftData.begin());
nextBlockReady = true;
}
fifoIndex = 0;
}
fifo[(size_t) fifoIndex++] = sample;
}
void timerCallback() override
{
if (! nextBlockReady) return;
window.multiplyWithWindowingTable (fftData.data(), fftSize); // reduce spectral leakage
forwardFFT.performFrequencyOnlyForwardTransform (fftData.data());
constexpr float mindB = -100.0f, maxdB = 0.0f;
for (int i = 0; i < scopeSize; ++i)
{
auto skewed = 1.0f - std::exp (std::log (1.0f - (float) i / (float) scopeSize) * 0.2f);
auto bin = juce::jlimit (0, fftSize / 2, (int) (skewed * (float) fftSize * 0.5f));
auto dB = juce::Decibels::gainToDecibels (fftData[(size_t) bin])
- juce::Decibels::gainToDecibels ((float) fftSize);
scope[(size_t) i] = juce::jmap (juce::jlimit (mindB, maxdB, dB), mindB, maxdB, 0.0f, 1.0f);
}
nextBlockReady = false;
repaint();
}
void prepareToPlay (int, double) override {}
void releaseResources() override {}
juce::dsp::FFT forwardFFT;
juce::dsp::WindowingFunction<float> window;
std::array<float, fftSize> fifo {};
std::array<float, 2 * fftSize> fftData {};
std::array<float, scopeSize> scope {};
int fifoIndex = 0;
std::atomic<bool> nextBlockReady { false };
};- Windowing. An FFT assumes its block repeats forever, and the seam between the end and start smears energy across bins (spectral leakage). Multiply the block by a window first.
rectangularhas the best resolution but the worst leakage,hannandhammingare good general choices, andblackmanhas the highest dynamic range and the lowest resolution. - The display skews the frequency axis (
log-like) so the bass region is not squashed, and maps decibels to0..1for drawing. - The handshake flag
nextBlockReadymust be atomic because two threads use it. For a stricter design useAbstractFifoor a lock-free queue.
SIMD acceleration
dsp::SIMDRegister<float> wraps the CPU's vector unit (SSE, AVX, or NEON), so one operation handles several samples at once. It reads like ordinary maths, and branches become masks:
using Reg = juce::dsp::SIMDRegister<float>;
Reg calc (Reg x, Reg y)
{
auto mask = Reg::greaterThan (x, y); // per-lane condition
return ((x + y * 2.0f) & mask) + (y & ~mask); // branch-free select
}The usual use is to process a group of independent channels in one pass: interleave Reg::size() channels into AudioBlock<SIMDRegister<float>>, run a IIR::Filter<SIMDRegister<float>> over it, and de-interleave (SIMDInterleavingHelpers). Guard the code with #if JUCE_USE_SIMD, align data, and measure: the gains are largest for many-channel filter banks.
Build and run
With all the files in place, configure and build from the project folder:
cmake -B build
cmake --build buildThe build puts the finished app in build/DspDemo_artefacts/. With CMake's default generator (Makefiles on macOS and Linux):
| Platform | Run it with |
|---|---|
| macOS | open "build/DspDemo_artefacts/Dsp Demo.app" |
| Linux | ./build/DspDemo_artefacts/Dsp\ Demo |
The Xcode generator is multi-config and adds a configuration folder, for example build/DspDemo_artefacts/Debug/Dsp Demo.app; build with cmake --build build --config Debug.
"The application cannot be opened because its executable is missing"? CMake creates the empty
.appbundle at the start of the build and only fills in the executable when compiling and linking succeed. Ifcmake --build buildreported errors, fix them and build again, then re-runopen. Check that the last lines of the build output sayBuilt target DspDemo.
"use of undeclared identifier 'juce'"? CMake projects have no
JuceHeader.h, so every source file must include the module headers it uses.MainComponent.hincludes them andMain.cppincludesMainComponent.h. Without those includes the compiler does not know whatjuce::,std::, orSTART_JUCE_APPLICATIONmean.
Sources
Condensed from the JUCE tutorials Introduction to DSP, Add distortion through waveshaping and convolution, Create a string model with delay lines, The fast Fourier transform, Visualise the frequencies of a signal in real time, and Optimisation using the SIMDRegister class, Copyright (c) Raw Material Software Limited, ISC licence. See the attribution notice.