An in-depth, build-it-from-nothing tutorial for the C++ framework behind a large
share of the world's audio plugins. From an empty folder to a validated VST3/AU that
loads in your DAW — the AudioProcessor, parameters, the DSP module, the
editor, MIDI, state, and packaging — with real, idiomatic code throughout.
JUCE is an open-source, cross-platform C++ framework for audio applications and plugins.
From one codebase it produces VST3, Audio Unit (v2 and v3), AAX, CLAP (via an extension),
LV2, and standalone builds for Windows, macOS, Linux, iOS and Android. It began as
Julian Storer's personal library in the early 2000s, became the de-facto standard for
commercial plugin development, and is today maintained under a dual GPLv3 / commercial
licence.
Its appeal is breadth without leaving C++: a complete GUI toolkit, a real-time-safe
juce::dsp library, the format wrappers, and a build system — so you spend
your time on the algorithm and the interface, not on the plumbing of six plugin
standards. This tutorial builds one small but complete plugin, ToneWorks
— an input gain, a resonant low-pass filter, and an output gain, with a proper editor,
automatable parameters, and saved state — and uses it to explain every part of the JUCE
plugin architecture.
Message thread
Constructs the plugin & editor
Runs the GUI (paint, mouse, timers)
Reads/writes parameters via attachments
Saves/loads state
May allocate, lock, do file I/O
Audio thread (real-time)
Calls prepareToPlay then processBlock repeatedly
Hard deadline every buffer (~1–10 ms)
No allocation, locks, or I/O
Reads parameters atomically, smooths them
Does only bounded arithmetic
▲ the two threads communicate only through atomics & lock-free FIFOs ▲
Almost every JUCE rule follows from that wall. Keep it in mind and the framework's
design — AudioProcessor vs AudioProcessorEditor,
AudioProcessorValueTreeState, SmoothedValue — reads as one
coherent answer to a single question: how do you let a GUI and a real-time DSP core
share data without ever making the audio thread wait?
1Project setup
There are two ways to create a JUCE plugin project: the Projucer (JUCE's
GUI project manager) and CMake. New projects should prefer CMake — it is
scriptable, plays well with CI and IDEs, and is how JUCE itself is built. We use CMake here.
1
Get JUCE. Clone it as a submodule or fetch it in CMake. The whole framework is header-and-source; there is nothing to install system-wide.
2
Write a CMakeLists.txt that calls juce_add_plugin and lists your source files and the JUCE modules you use.
3
Configure and build. CMake generates an Xcode/Visual Studio/Ninja project and produces a VST3 (and AU on macOS) plus a standalone app.
The CMakeLists.txt
cmake_minimum_required(VERSION 3.22)
project(ToneWorks VERSION 1.0.0)
# Bring in JUCE (as a git submodule at ./JUCE, or via FetchContent)add_subdirectory(JUCE)
juce_add_plugin(ToneWorks
COMPANY_NAME "BrendanLynskey"
PLUGIN_MANUFACTURER_CODE Blyn # 4 chars, one upper-case
PLUGIN_CODE Tnwk # unique 4-char id
FORMATS VST3 AU Standalone # what to build
IS_SYNTH FALSE
NEEDS_MIDI_INPUT FALSE
PRODUCT_NAME "ToneWorks")
juce_generate_juce_header(ToneWorks)
target_sources(ToneWorks PRIVATE
Source/PluginProcessor.cpp
Source/PluginEditor.cpp)
target_compile_definitions(ToneWorks PUBLIC
JUCE_WEB_BROWSER=0 JUCE_USE_CURL=0 JUCE_VST3_CAN_REPLACE_VST2=0)
target_link_libraries(ToneWorks PRIVATE
juce::juce_audio_utils
juce::juce_dsp
juce::juce_recommended_config_flags
juce::juce_recommended_warning_flags)
A plugin project has four core files: PluginProcessor.h/.cpp (the DSP and the
plugin's contract with the host) and PluginEditor.h/.cpp (the GUI). The rest
of this tutorial fills them in.
Module
Provides
juce_audio_processors
The AudioProcessor base class and all format wrappers
Filters, oscillators, FFT, convolution, oversampling — the DSP toolbox
juce_gui_basics
Components, sliders, look-and-feel, graphics
2The AudioProcessor
Every plugin subclasses juce::AudioProcessor. It is the object the host owns;
its virtual methods are the plugin's entire contract. Three of them matter most, and they
form a lifecycle: prepareToPlay (set up), processBlock (run, on
the audio thread), releaseResources (tear down).
The host calls prepareToPlay before playback (and again if the sample rate or
block size changes). This is where you learn $f_s$ and the maximum block size and where all
allocation and DSP set-up happens — never inside processBlock.
void ToneWorksProcessor::prepareToPlay(double sampleRate, int maxBlockSize)
{
juce::dsp::ProcessSpec spec;
spec.sampleRate = sampleRate;
spec.maximumBlockSize = (juce::uint32) maxBlockSize;
spec.numChannels = (juce::uint32) getTotalNumOutputChannels();
filter.prepare(spec);
filter.setType(juce::dsp::StateVariableTPTFilterType::lowpass);
inGain.prepare(spec);
outGain.prepare(spec);
inGain.setRampDurationSeconds(0.02); // smoothing built in
outGain.setRampDurationSeconds(0.02);
}
Bus layouts.isBusesLayoutSupported tells the host which channel configurations you accept. A simple mono/stereo effect returns true only when input and output layouts match and are mono or stereo — reject everything else so the host never hands you a configuration you cannot process.
3Parameters — the AudioProcessorValueTreeState
Parameters are the automatable controls the host sees and the values the DSP reads.
JUCE's AudioProcessorValueTreeState (universally "APVTS") ties three things
together: the parameter objects themselves, a ValueTree that stores their
state, and thread-safe attachments that bind GUI controls to them.
Reading a parameter on the audio thread is a single atomic load through a cached raw
pointer — never a string lookup. Cache the pointers once:
// in prepareToPlay or the constructor, store:
std::atomic<float>* cutoffParam = apvts.getRawParameterValue("cutoff");
// ...then in processBlock:
filter.setCutoffFrequency(cutoffParam->load());
Why a NormalisableRange?
Hosts automate parameters over a normalised $[0,1]$ range. A
NormalisableRange maps that to the real engineering range and back, and a
skew makes the mapping perceptually even. For frequency we want the knob's
midpoint near 1 kHz, so a value $v\in[0,1]$ maps roughly as
with the skew $\gamma$ chosen by setSkewForCentre so that $v=0.5$ lands on 1 kHz — matching how we hear pitch.
Interactive: zipper noise vs a smoothed parameter
The single most common beginner bug is changing a gain or coefficient abruptly per block,
producing audible steps ("zipper noise"). JUCE's SmoothedValue (and the
ramp built into dsp::Gain) fixes it. Hear the difference:
a 220 Hz tone whose gain you wobble — toggle smoothing
drag the gain quickly
With smoothing off, fast gain moves click; with it on, the coefficient ramps over ~20 ms and the change is silent. This is exactly what inGain.setRampDurationSeconds(0.02) did above.
4processBlock and the DSP module
processBlock is the real-time heart. The host hands it an
AudioBuffer<float> — a small block of samples for each channel — which
you read, transform in place, and leave as the output. The juce::dsp module
provides ready-made, real-time-safe building blocks that operate through a lightweight
ProcessContextReplacing wrapper.
void ToneWorksProcessor::processBlock(juce::AudioBuffer<float>& buffer,
juce::MidiBuffer&)
{
juce::ScopedNoDenormals noDenormals;
// clear any output channels that have no matching inputfor (int ch = getTotalNumInputChannels(); ch < getTotalNumOutputChannels(); ++ch)
buffer.clear(ch, 0, buffer.getNumSamples());
if (apvts.getRawParameterValue("bypass")->load() > 0.5f)
return; // bypass: leave input untouched// pull current parameter values (atomic loads)
inGain .setGainDecibels(apvts.getRawParameterValue("inGain") ->load());
outGain.setGainDecibels(apvts.getRawParameterValue("outGain")->load());
filter.setCutoffFrequency(apvts.getRawParameterValue("cutoff") ->load());
filter.setResonance (apvts.getRawParameterValue("resonance")->load());
// wrap the buffer and run the chain: inGain -> filter -> outGain
juce::dsp::AudioBlock<float> block(buffer);
juce::dsp::ProcessContextReplacing<float> ctx(block);
inGain .process(ctx);
filter .process(ctx);
outGain.process(ctx);
}
ScopedNoDenormals disables denormalised-float handling, which would otherwise cripple IIR filters with tiny decaying tails.
Composing with ProcessorChain
Rather than calling each processor by hand, JUCE lets you declare the whole chain as a
type and process it in one line — the idiom you will see throughout the example plugins:
juce::dsp::ProcessorChain<
juce::dsp::Gain<float>, // 0: input gain
juce::dsp::StateVariableTPTFilter<float>, // 1: filter
juce::dsp::Gain<float>> // 2: output gain chain;
// access a stage by index:
chain.get<1>().setCutoffFrequency(cutoff);
chain.process(ctx); // runs all three in order
The raw DSP behind dsp::StateVariableTPTFilter
It is worth knowing what these classes actually compute. The topology-preserving-transform
(TPT) state-variable filter — Zavalishin's zero-delay-feedback form — computes, per sample,
with $g=\tan(\pi f_c/f_s)$ and $R=1/(2Q)$:
where $s_1,s_2$ are the two state variables. The low-pass, band-pass and high-pass outputs
are all available simultaneously — which is why it is the workhorse filter of the synth and
EQ examples in this collection.
Handling per-sample parameter changes correctly
Setting a coefficient once per block is fine for slow automation but audibly steppy for
fast modulation. Two options: (1) split the block into small sub-blocks and update between
them, or (2) use SmoothedValue<float, ValueSmoothingTypes::Multiplicative>
for frequencies and update the filter each sample from smoothed.getNextValue().
The synth and modulation examples take the per-sample route because their cutoff and delay
time move continuously.
5The editor — GUI
The editor is an AudioProcessorEditor — a JUCE Component tree
running on the message thread. It never touches the DSP state directly; instead,
attachments keep a Slider and an APVTS parameter in sync in both
directions, thread-safely.
The attachment is the whole trick: move the knob and it writes the parameter (which the
audio thread reads); automate the parameter in the host and the knob follows. You write no
listener code and no locking. createEditor() in the processor simply returns
new ToneWorksEditor(*this).
Custom look with LookAndFeel
To draw a bespoke knob, subclass juce::LookAndFeel_V4 and override
drawRotarySlider. The editor's paint and each component's drawing
are pure juce::Graphics calls — arcs, paths, gradients — the same primitives
the browser demos use on a <canvas>.
void MyLNF::drawRotarySlider(juce::Graphics& g, int x, int y, int w, int h,
float pos, float a0, float a1, juce::Slider&)
{
auto r = juce::Rectangle<float>(x, y, w, h).reduced(6).toFloat();
auto c = r.getCentre();
auto rad = r.getWidth() * 0.5f;
auto ang = a0 + pos * (a1 - a0); // value -> angle
g.setColour(juce::Colour(0xff2a2d3a)); // track
juce::Path track; track.addCentredArc(c.x, c.y, rad, rad, 0, a0, a1, true);
g.strokePath(track, juce::PathStrokeType(3.0f));
g.setColour(juce::Colour(0xff8ac6ff)); // value arc
juce::Path val; val.addCentredArc(c.x, c.y, rad, rad, 0, a0, ang, true);
g.strokePath(val, juce::PathStrokeType(3.0f));
juce::Point<float> tip(c.x + rad * std::cos(ang - juce::MathConstants<float>::halfPi),
c.y + rad * std::sin(ang - juce::MathConstants<float>::halfPi));
g.drawLine({ c, tip }, 2.0f); // pointer
}
To show a live filter-response curve or a meter, keep a small copy of the needed data in an
atomic, start a juce::Timer at ~30 Hz in the editor, and repaint — never
read DSP buffers directly from paint. Every example plugin's on-screen display
works exactly this way.
6MIDI and instruments
An instrument plugin sets IS_SYNTH TRUE and
NEEDS_MIDI_INPUT TRUE, and reads the MidiBuffer that
processBlock receives. JUCE's Synthesiser class handles voice
allocation for you: you supply a SynthesiserSound (which notes it plays) and a
SynthesiserVoice (how one note sounds).
In processBlock you then simply call
synth.renderNextBlock(buffer, midi, 0, buffer.getNumSamples()) and the
Synthesiser dispatches note-on/note-off events to voices and mixes them. Add a
juce::MidiKeyboardComponent to the editor for an on-screen keyboard. The
synth example
builds a full polyphonic subtractive instrument on exactly this scaffold.
7Saving & restoring state
When the user saves a session or a preset, the host calls getStateInformation;
on reload it calls setStateInformation. With APVTS this is almost free: its
ValueTree already holds every parameter, so you serialise the tree to XML and
read it back.
void ToneWorksProcessor::getStateInformation(juce::MemoryBlock& dest)
{
if (auto xml = apvts.copyState().createXml())
copyXmlToBinary(*xml, dest);
}
void ToneWorksProcessor::setStateInformation(const void* data, int size)
{
if (auto xml = getXmlFromBinary(data, size))
if (xml->hasTagName(apvts.state.getType()))
apvts.replaceState(juce::ValueTree::fromXml(*xml));
}
Presets can be as simple as a folder of these XML blobs, or you can adopt the host's preset
mechanism. Because the ValueTree is extensible, non-parameter data — a loaded
impulse response, a drawn EQ curve — can be stored in child nodes alongside the parameters
and restored the same way.
Versioning. Give ParameterIDs a version hint (the { "cutoff", 1 } second argument) and check the state's version on load, so a future v2 of the plugin can migrate old sessions gracefully rather than silently misreading them.
8Building, validating & shipping
A plugin that compiles is not yet a plugin that ships. The last mile is validation,
signing, and packaging — the same for a JUCE plugin as for any other.
Build all formats. CMake produces the VST3, AU (macOS), and Standalone into build/…_artefacts/. Add AAX/CLAP to FORMATS once you have the respective SDK/extension.
Validate. Run Apple's auval -v aufx Tnwk Blyn for the AU, and the community pluginval --strictness-level 10 across every format — it hammers threading, state round-trips, parameter edge cases, and bus layouts.
Sign & notarise. On macOS: codesign with a Developer ID, enable the hardened runtime, notarise the bundle, and staple the ticket. On Windows: Authenticode-sign. AAX additionally needs PACE signing.
Install. Ship an installer that places each bundle in the standard folder and removes stale versions.
Report latency. If you add look-ahead or oversampling, call setLatencySamples() so the host keeps tracks aligned.
Task
Tool
Command / note
Configure
CMake
cmake -B build -DCMAKE_BUILD_TYPE=Release
Build
CMake
cmake --build build --config Release
Validate AU
auval
auval -v aufx Tnwk Blyn
Validate all
pluginval
pluginval --strictness-level 10 ToneWorks.vst3
Sign (mac)
codesign / notarytool
Developer ID + hardened runtime + notarise + staple
JUCE's promise is a narrow one, kept well: write the algorithm and the interface once, in
C++, and let the framework carry it into every host on every platform. Everything in this
tutorial — the processor, the parameter tree, the DSP chain, the editor — is the same
scaffold under a ten-thousand-plugin industry.