DEV Community

Cover image for How I built my own set of audio plugins with JUCE
Lluis Estape
Lluis Estape

Posted on

How I built my own set of audio plugins with JUCE

A build log on ESP, six VST3 plugins written in C++ with JUCE 8 and shipped through a store I built myself. What the framework does for you, where it stops, and the one measurement that changed how I work.

The line

Six plugins, all JUCE 8, all VST3 plus standalone, all GPL v3, all downloadable from esp-plugin-store.vercel.app:

Plugin What it is
Basic Oscilator three oscillators on juce::dsp, the first thing I ever built, kept honestly
VERTEX dynamic range compressor with a live transfer curve
ESP-L1 brick-wall limiter with pre and post spectrum overlay
MEGACRUSHER distortion, saturation and bit-crusher, three algorithms
SPECTRUM real-time analyser, 2048-point FFT, spectrogram and 3D waterfall
SYNTH/1 16-voice wavetable synth, unison, step sequencer, FX rack, interactive EQ

That table is in the order I wrote them, and the order matters more than any single plugin. Each one starts roughly where the previous one ran out of framework.

What juce::dsp actually hands you

Basic Oscilator is three oscillators, three LFOs, a bit-crusher and a master gain. Almost all of it is the juce::dsp module doing the work:

juce::dsp::ProcessSpec spec;
spec.maximumBlockSize = (juce::uint32) samplesPerBlock;
spec.sampleRate       = sampleRate;
spec.numChannels      = (juce::uint32) getTotalNumOutputChannels();

for (int i = 0; i < 3; ++i) {
    oscillators[i].prepare (spec);
    lfos[i].prepare (spec);
    lfos[i].initialise ([](float x) { return std::sin (x); });
}
masterGain.prepare (spec);
Enter fullscreen mode Exit fullscreen mode

That is the whole contract of the module. Prepare everything with one ProcessSpec, wrap your buffer in an AudioBlock, hand it to a processor as a context:

juce::dsp::AudioBlock<float> block { tempBuffer };
oscillators[i].process (juce::dsp::ProcessContextReplacing<float> (block));
Enter fullscreen mode Exit fullscreen mode

juce::dsp::Oscillator takes its waveform as a lambda, so the three waves are three one-liners:

case 0: osc.initialise ([](float x) { return std::sin (x); });                       // sine
case 1: osc.initialise ([](float x) { return x / juce::MathConstants<float>::pi; }); // saw
case 2: osc.initialise ([](float x) { return x < 0.0f ? -1.0f : 1.0f; });            // square
Enter fullscreen mode Exit fullscreen mode

Two things about that code, and I am leaving both in the repo rather than quietly fixing them.

The first is a bug I can name precisely: tempBuffer.setSize(...) is called inside processBlock. That is a heap allocation on the audio thread, once per block, which is the one thing you are not allowed to do. It never caused an audible dropout on my machine at a 512-sample buffer, which is exactly why beginners keep it. The rule is not "avoid allocations because they are slow", it is "avoid them because their worst case is unbounded and your deadline is not".

The second is not a bug, it is the framework being honest about its scope. x / pi is a mathematically perfect sawtooth and a spectrally terrible one: it has infinite harmonics, and every one above Nyquist folds back into the audible band. juce::dsp::Oscillator does not band-limit and does not claim to. Everything I built afterwards is, one way or another, me finding out what that costs.

The three things every plugin inherited

Before the DSP gets interesting, the skeleton. All six share it.

AudioProcessorValueTreeState for every parameter. Automation, host state, preset save and load all arrive free, and the UI never writes a parameter directly. Even an interactive gesture goes through the tree:

apvts.getParameter (id)->setValueNotifyingHost (range.convertTo0to1 (value));
Enter fullscreen mode Exit fullscreen mode

Which is why dragging a band on SYNTH/1's EQ is automation-recordable without one extra line of code.

A hard split between the two classes. The processor owns state and touches audio. The editor owns pixels and touches neither. Where they meet, they meet through atomics or a FIFO.

Nothing on the audio thread blocks, and the UI does the expensive work. In SPECTRUM and ESP-L1 the audio thread's entire visualisation job is pushing samples into a lock-free FIFO. The editor pulls from it on a timer, runs the FFT, computes the meter ballistics, and draws. On the message thread a slow frame costs a dropped frame. In processBlock it costs a dropped buffer, and a dropped buffer is a click.

Compressor and limiter: the same envelope, two different opinions

VERTEX is a compressor: gain, threshold from -60 to 0 dB, ratio 1 to 10, attack from 0.1 to 100 ms, release from 10 to 500 ms. The detector is juce::dsp::Compressor<float>, and I am not going to pretend otherwise, because it is a good demonstration of where the framework's line actually sits. It gives you a correct compressor. It gives you nothing at all for showing the user what that compressor is doing, and that turned out to be most of the work.

VERTEX: the transfer curve redraws live as you move threshold and ratio

The transfer curve is drawn from the same threshold and ratio the audio path uses, so the picture cannot drift from the sound. That sounds obvious. It is worth stating because the tempting shortcut, drawing a nice curve and separately writing the maths, is how visualisers end up lying.

ESP-L1 is where I stopped using the built-in and wrote the detector by hand, because a limiter is a compressor with one parameter deleted and I wanted to feel which one:

releaseCoeff  = std::exp (-1.0f / (sampleRate * releaseMs / 1000.0f));
envelopeState = std::max (peak, envelopeState * releaseCoeff);
if (envelopeState > threshold)
    gainReduction = threshold / envelopeState;
Enter fullscreen mode Exit fullscreen mode

There is no attack time. Attack is instantaneous, because a brick-wall limiter that takes 5 ms to react is a limiter that lets 5 ms of overshoot through, and the entire promise of the thing is that nothing gets past. Release stays smooth and exponential, because that is what stops the gain reduction from pumping.

One coefficient, one line, and the character of the processor is decided.

ESP-L1: pre and post limiting spectra overlaid, with gain-reduction metering

ESP-L1 runs two independent FFT pipelines, pre and post limiting, drawn overlaid so you can see what the limiter actually took. Two 2048-point transforms at 30 Hz would be an unpleasant thing to put anywhere near processBlock. Where they are, on the message thread behind a FIFO, they cost nothing that matters.

MEGACRUSHER: three ways to be wrong on purpose

Distortion is the one place where "mathematically incorrect" is the product.

SOFT: tanh(drive * x)              smooth, compressive, harmonics come in gradually
HARD: clamp(drive * x, -1, +1)     brick wall, sharp corners, very bright
FOLD: mirror x back into range     reflections, wildly inharmonic at high drive
Enter fullscreen mode Exit fullscreen mode

Drive spans 1x to 40x and changes character rather than just level: tanh at 40x is nearly a hard clip, so SOFT and HARD converge at the top of the range and diverge completely at the bottom. FOLD never converges with anything.

Then a one-pole tilt filter for tone, a bit-depth reducer from 16 down to 2 bits (std::round(x * res) / res, the same three-line trick as in Basic Oscilator, promoted to a feature), and a dry/wet mix so all of it can be run in parallel.

All three shapers alias, of course. That is not the same admission as the synth: here the harmonics are the point, the drive is extreme, and the folding sits under a wall of intentional distortion. The honest version is that oversampling the saturator is on the list and the plugin ships without it.

MEGACRUSHER: live saturation curve, ember particles that react to the drive level

SPECTRUM: one transform, four views

juce::dsp::FFT with fftOrder = 11, so 2048 points, Hann-windowed. The audio thread fills a FIFO; the editor's timer windows it, transforms it, and maps bins to a 512-point display array with logarithmic frequency scaling from 20 Hz to 20 kHz, because linear spacing puts half the pixels in the top octave where nothing interesting happens.

SPECTRUM: Bezier-smoothed frequency response with analog-style VU metering

Two display details are doing most of the work in that screenshot. The curve is smoothed with a 5-point moving average and drawn as quadratic Beziers rather than line segments, and the display array uses peak-hold with a slow per-frame decay, so transients stay visible long enough to read instead of flashing for a single frame.

The other views cost almost nothing, because the transform has already happened and only the drawing changes:

SPECTRUM's 3D waterfall: each FFT frame becomes a line receding into the past

The spectrogram view of the same signal

This is the payoff of the FIFO pattern. Once the expensive thing lives on the UI side, adding a new way to look at it is a rendering problem and never a real-time one.

SYNTH/1: where the framework runs out

Sixteen voices of juce::Synthesiser, each voice holding up to 8 unison oscillators, one juce::ADSR, and one stereo juce::dsp::StateVariableTPTFilter. Per voice the chain is: unison oscillators with phase warp, constant-power panning, tanh drive, TPT filter, then ADSR times velocity.

At the processor level: LFO, voices, master gain, chorus, phaser, tempo-synced delay, 2x oversampled saturation, 3-band EQ, then reverb.

The MOD tab: LFO scope, rate and depth, the unison engine and voice mode

The FX rack: chorus, phaser, tempo-synced delay, 2x oversampled saturation, reverb

Three details from that chain that took real time to get right.

Glide uses a multiplicative smoother. juce::SmoothedValue<float, ValueSmoothingTypes::Multiplicative> on the frequency, not a linear ramp, because pitch is perceived logarithmically. A linear glide from 100 Hz to 200 Hz spends most of its time sounding like it has nearly arrived, then lurches.

Mono and Legato modes need a note stack, not a note. Releasing a key has to fall back to whichever key is still held, so the processor keeps a stack of held notes and preprocesses MIDI before the synthesiser sees it. Legato changes pitch without retriggering the ADSR, which is a separate code path, not a flag.

The sequencer is entirely lock-free. Notes, velocities, active steps, the play flag and the current step are all std::atomic, and serialised into the plugin state as a Sequencer child ValueTree. The step grid redraws from a 30 Hz timer that reads those atomics and never locks anything.

The step sequencer, mirrored from lock-free atomics at 30 Hz

The interactive EQ tab, drag the bands over a live spectrum

And then there is the oscillator, which was wrong for a year.

The bug: 64 harmonics is a promise you can only keep below F4

SYNTH/1's oscillator started as a wavetable doing the obvious thing: build one table per waveform holding a fixed 64 harmonics, and read it at every pitch. Better than x / pi. Not correct.

Write down when it is actually alias-free. A table holding harmonics up to 64 * f0 stays under Nyquist only while:

64 * f0 < fs/2      =>      f0 < fs/128
Enter fullscreen mode Exit fullscreen mode

At 44.1 kHz that is f0 < 344.5 Hz, roughly F4. Above that note the upper partials stored in the table exceed Nyquist and fold back as inharmonic tones. Folded partials also move the wrong way: play up the keyboard and they come down to meet you.

More than half the MIDI range sits above F4. The oscillator was aliasing over most of its useful span, and I had shipped it.

Why did I not hear it? Because a saw with some filtering and reverb on it sounds like a saw, and because I had never compared it against anything correct. "It sounds fine" is not a measurement, it is the absence of one.

The rig: measure the header, not the plugin

This is the part I would recommend to anyone doing DSP, above any specific fix.

I did not measure the oscillator inside the plugin. I built analysis/: a folder that compiles WavetableOscillator.h, the exact shipping header, unmodified, against a minimal JUCE stub, renders sustained tones across the keyboard, and dumps raw float32 buffers. A Python script turns those buffers into alias-to-signal figures.

analysis/
  measure_oscillator.cpp   # renders tones, writes .f32 buffers
  juce_stub/JuceHeader.h   # just enough JUCE to compile the header standalone
  plot_oscillator.py       # FFT, harmonic mask, alias-to-signal ratio
  data/                    # legacy_82.f32, mip_82.f32, one pair per pitch
  figures/
Enter fullscreen mode Exit fullscreen mode

The method per rendered tone: FFT it, mask out the bins belonging to true harmonics of f0, and sum what is left. That leftover is the aliasing, and its ratio to the signal is a single number per pitch. Sweep the pitch and you get a curve.

Nothing here needs a DAW, a plugin host, or ears. It runs in a terminal, it produces a number, and the number is either better than last time or it is not.

Sawtooth alias-to-signal across the keyboard, before and after

There is the bug, drawn. The red curve is the old oscillator: flat at about -82 dB while the 64-harmonic promise holds, then a cliff at exactly fs/128 = 344 Hz straight up to -26 dB and worse. By the top of the keyboard the aliasing is -10 dB. That is not a subtle artefact, that is a tenth of the output being wrong.

The spectra make it visceral:

Spectra at A4 and at 2 kHz, dotted lines are true harmonics, everything between them is aliasing

At 2 kHz the old version has more energy sitting between the harmonics than on some of them.

The fix: mip-mapping, and taking the budget from the top of the band

The standard answer is mip-mapping: a bank of tables, each holding only the harmonics that are safe in its pitch range. The detail that matters is which end of the range you compute the budget for.

H(L) = floor( (fs/2) / (f_base * 2^((L+1)/M)) )      M = bands per octave
Enter fullscreen mode Exit fullscreen mode

Note the L+1. The budget comes from the top of the band, not the bottom, so every fundamental inside the band is safe rather than just the lowest one. Take it from the bottom and you have rebuilt the original bug with a smaller blast radius per band.

That safety costs brightness, and the cost is exactly why kMipsPerOctave exists:

  • At A4 the ideal harmonic budget is floor(22050/440) = 50.
  • A full-octave bank would only allow 34, throwing away a third of the spectrum to stay safe at the top of the band.
  • Half-octave bands (kMipsPerOctave = 2) recover 48 of the ideal 50, for twice the tables and a build that still takes tens of milliseconds.

Two more changes came with it.

Continuous level selection. The fractional band position crossfades between adjacent mip levels. A hard switch changes the harmonic count by a step, and that step is plainly audible during a glide or a slow pitch LFO: the timbre flickers.

Cubic Hermite instead of linear interpolation. Linear interpolation of a 2048-point table is a triangular kernel whose frequency response leaks badly into the stopband. It acts as a gentle lowpass plus a noise floor that rises with playback rate. Four-point Catmull-Rom costs a handful of extra multiply-adds and drops that floor substantially.

The blue curve above is the result. 96 dB better at A4. 139 dB better at C8.

The part where I was wrong twice more

This is the bit I enjoyed, and the reason the measurement rig paid for itself several times over.

A 2048-point table can represent 1024 harmonics. No practical interpolator can read them. Reading a table at a fractional rate produces images at k*(N*f0) +/- n*f0, and it is the interpolation kernel that has to suppress them. Content near the table's own Nyquist has two or three samples per cycle, where the kernel has essentially no stopband left, so those images come back as a noise floor.

Measured: at 55 Hz, an uncapped budget of 389 harmonics put images at -75.5 dBc, worse than the 64-harmonic version it replaced despite being far more correct spectrally. I could identify them beyond doubt because they sat exactly at bin(N*f0) - H*bin(f0).

Capping the budget at kTableSize/8 (eight table samples per cycle of the topmost harmonic) moves that floor to -77.3 dBc. Honest accounting: that is 1.8 dB, not a fix. The real remedy is longer tables at the low mips, because the image floor is set by table length against harmonic count, not by the cap. I kept the cap because it makes the invariant explicit and costs nothing audible: it only binds below fs/2/256 = 86 Hz, where the harmonics it discards are already under -48 dB.

And it shows up in the sweep as a real regression: below 86 Hz the new oscillator is 4 dB worse than the old one. That is in the header comment, in the report, and now in this post. A rewrite that is better by 139 dB at the top and worse by 4 dB at the bottom is still a rewrite you ship, but the 4 dB does not get to quietly disappear.

The phase accumulator is double, and setFrequency takes a double. Accumulating f0/fs in float32 lets rounding error random-walk, smearing each harmonic into its neighbours. With a double accumulator, energy within +/-2 bins of the harmonic grid sits at -164 dBc. That is two double operations per sample against four Hermite evaluations already in the loop, so it is free. Similarly, a float32 Hz value lands within about 1e-4 Hz of the target: inaudible as pitch, but it leaves the tone very slightly non-periodic, and a rectangular-window spectrum shows leakage skirts at -86 dBc. The caller already holds the precise value, so there is nothing to gain by throwing it away.

And one thing that is still broken. applyWarp() (the Sync, Bend and PWM phase-warp modes) runs before the table read and still aliases. It is phase distortion, which generates content the mip level was never chosen for. Fixing it properly needs oversampling. It is documented in the header as a known limitation rather than quietly left for someone to discover.

The store

The plugins are distributed from a store I built rather than a Gumroad page: a self-contained React 18 SPA with no build step, React, ReactDOM and Babel Standalone from a CDN, and the whole application in one index.html. Each plugin gets a seeded generative waveform animation, a screenshot carousel, and a download modal with OS and format selection. Vercel deploys it on push.

It is an unusual choice and I will defend exactly one thing about it: no build step means the deployed artifact and the file I edit are the same file. For a five-page site with no dependencies beyond React, the build tooling would have been more code than the site.

Takeaways

  • juce::dsp is scaffolding, not a synthesiser. ProcessSpec, AudioBlock, ProcessorChain and the FFT save you weeks. The band-limiting, the detector characters and the interpolation are yours to write.
  • Keep the audio thread boring. Atomics, FIFOs, APVTS, and every expensive thing on the UI side. My first plugin allocates in processBlock and every one after it does not.
  • "It sounds fine" is the absence of a measurement. My oscillator aliased over half the keyboard for a year and sounded fine the whole time.
  • Build the offline rig and point it at the shipping header. A JUCE stub and a .cpp that renders tones is an afternoon of work, and it turns "I think this is better" into "96 dB at A4".
  • Publish the regression. The 4 dB I lost below 86 Hz is in the header comment and in this post. A fix you only report the good half of is a fix you will re-break.
  • Derive the constraint, do not guess it. f0 < fs/128 is two lines of algebra that would have caught this on day one.

Everything is GPL v3 and on GitHub, SYNTH/1 included, analysis/ folder and all.


I'm an audio DSP student at UPC. If you build wavetable oscillators, go and measure yours. I would like to hear what you find.

Top comments (0)