Building Plugins with JUCE

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.

AudioProcessorAPVTSprocessBlockdsp::Editor
Start building ↓

0Why JUCE?

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)

Build with: cmake -B build -DCMAKE_BUILD_TYPE=Release && cmake --build build

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.

ModuleProvides
juce_audio_processorsThe AudioProcessor base class and all format wrappers
juce_audio_utilsHigher-level helpers, AudioProcessorEditor, keyboard component
juce_dspFilters, oscillators, FFT, convolution, oversampling — the DSP toolbox
juce_gui_basicsComponents, 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).

// PluginProcessor.h
class ToneWorksProcessor : public juce::AudioProcessor
{
public:
    ToneWorksProcessor();
    ~ToneWorksProcessor() override;

    // --- lifecycle (audio thread for processBlock) ---
    void prepareToPlay(double sampleRate, int maxBlockSize) override;
    void processBlock(juce::AudioBuffer<float>&, juce::MidiBuffer&) override;
    void releaseResources() override;

    // --- host queries ---
    bool isBusesLayoutSupported(const BusesLayout&) const override;
    juce::AudioProcessorEditor* createEditor() override;
    bool hasEditor() const override { return true; }
    const juce::String getName() const override { return "ToneWorks"; }
    bool acceptsMidi() const override { return false; }
    bool producesMidi() const override { return false; }
    double getTailLengthSeconds() const override { return 0.0; }

    // --- state (message thread) ---
    void getStateInformation(juce::MemoryBlock&) override;
    void setStateInformation(const void*, int) override;

    // the parameter tree — see section 3
    juce::AudioProcessorValueTreeState apvts;

private:
    static juce::AudioProcessorValueTreeState::ParameterLayout createLayout();
    juce::dsp::StateVariableTPTFilter<float> filter;
    juce::dsp::Gain<float> inGain, outGain;

    JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(ToneWorksProcessor)
};

prepareToPlay — the only place to allocate

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.

Declaring the parameter layout

juce::AudioProcessorValueTreeState::ParameterLayout
ToneWorksProcessor::createLayout()
{
    using Range = juce::NormalisableRange<float>;
    std::vector<std::unique_ptr<juce::RangedAudioParameter>> p;

    p.push_back(std::make_unique<juce::AudioParameterFloat>(
        juce::ParameterID{ "inGain", 1 }, "Input Gain",
        Range(-24.0f, 24.0f, 0.1f), 0.0f));

    // cutoff: skewed so the knob feels logarithmic in Hz
    Range cutoffRange(20.0f, 20000.0f, 1.0f);
    cutoffRange.setSkewForCentre(1000.0f);
    p.push_back(std::make_unique<juce::AudioParameterFloat>(
        juce::ParameterID{ "cutoff", 1 }, "Cutoff", cutoffRange, 8000.0f));

    p.push_back(std::make_unique<juce::AudioParameterFloat>(
        juce::ParameterID{ "resonance", 1 }, "Resonance",
        Range(0.1f, 10.0f, 0.01f, 0.5f), 0.707f));

    p.push_back(std::make_unique<juce::AudioParameterFloat>(
        juce::ParameterID{ "outGain", 1 }, "Output Gain",
        Range(-24.0f, 24.0f, 0.1f), 0.0f));

    p.push_back(std::make_unique<juce::AudioParameterBool>(
        juce::ParameterID{ "bypass", 1 }, "Bypass", false));

    return { p.begin(), p.end() };
}

// construct the APVTS in the processor's initialiser list:
ToneWorksProcessor::ToneWorksProcessor()
  : AudioProcessor(BusesProperties()
        .withInput ("Input",  juce::AudioChannelSet::stereo())
        .withOutput("Output", juce::AudioChannelSet::stereo())),
    apvts(*this, nullptr, "PARAMS", createLayout()) {}

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

$$f(v) = f_{\min} + (f_{\max}-f_{\min})\,v^{\,1/\gamma}$$

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 input
    for (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)$:

$$\begin{aligned} v_1 &= \frac{g\,(x - 2R\,s_1 - s_2)}{1 + g(2R + g)}, \quad \text{BP} = v_1 + s_1, \\ s_1 &\leftarrow \text{BP} + v_1, \qquad \text{LP} = g\cdot\text{BP} + s_2, \qquad s_2 \leftarrow \text{LP} + g\cdot\text{BP} \end{aligned}$$

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.

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.

// PluginEditor.h
class ToneWorksEditor : public juce::AudioProcessorEditor
{
public:
    explicit ToneWorksEditor(ToneWorksProcessor&);
    void paint(juce::Graphics&) override;
    void resized() override;
private:
    ToneWorksProcessor& proc;
    juce::Slider inGain, cutoff, resonance, outGain;
    juce::ToggleButton bypass { "Bypass" };

    using SliderAttach = juce::AudioProcessorValueTreeState::SliderAttachment;
    using ButtonAttach = juce::AudioProcessorValueTreeState::ButtonAttachment;
    std::unique_ptr<SliderAttach> inAtt, cutAtt, resAtt, outAtt;
    std::unique_ptr<ButtonAttach> bypAtt;
};
// PluginEditor.cpp — bind controls to parameters
ToneWorksEditor::ToneWorksEditor(ToneWorksProcessor& p)
  : AudioProcessorEditor(&p), proc(p)
{
    for (auto* s : { &inGain, &cutoff, &resonance, &outGain })
    {
        s->setSliderStyle(juce::Slider::RotaryHorizontalVerticalDrag);
        s->setTextBoxStyle(juce::Slider::TextBoxBelow, false, 70, 18);
        addAndMakeVisible(s);
    }
    addAndMakeVisible(bypass);

    auto& t = proc.apvts;
    inAtt  = std::make_unique<SliderAttach>(t, "inGain",    inGain);
    cutAtt = std::make_unique<SliderAttach>(t, "cutoff",    cutoff);
    resAtt = std::make_unique<SliderAttach>(t, "resonance", resonance);
    outAtt = std::make_unique<SliderAttach>(t, "outGain",   outGain);
    bypAtt = std::make_unique<ButtonAttach>(t, "bypass",    bypass);

    setSize(460, 240);
}

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).

struct SineVoice : public juce::SynthesiserVoice
{
    bool canPlaySound(juce::SynthesiserSound* s) override
        { return dynamic_cast<MySound*>(s) != nullptr; }

    void startNote(int midiNote, float vel,
                   juce::SynthesiserSound*, int) override
    {
        auto hz = juce::MidiMessage::getMidiNoteInHertz(midiNote);
        angleDelta = juce::MathConstants<double>::twoPi * hz / getSampleRate();
        level = vel;  env.noteOn();
    }
    void stopNote(float, bool allowTail) override
        { allowTail ? env.noteOff() : clearCurrentNote(); }

    void renderNextBlock(juce::AudioBuffer<float>& out,
                         int start, int num) override
    {
        while (--num >= 0) {
            auto s = (float) std::sin(angle) * level * env.getNextSample();
            for (int ch = 0; ch < out.getNumChannels(); ++ch)
                out.addSample(ch, start, s);
            angle += angleDelta; ++start;
        }
    }
    double angle = 0, angleDelta = 0;  float level = 0;
    juce::ADSR env;
};

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.

TaskToolCommand / note
ConfigureCMakecmake -B build -DCMAKE_BUILD_TYPE=Release
BuildCMakecmake --build build --config Release
Validate AUauvalauval -v aufx Tnwk Blyn
Validate allpluginvalpluginval --strictness-level 10 ToneWorks.vst3
Sign (mac)codesign / notarytoolDeveloper 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.

References & further reading

  1. JUCE. API documentation & tutorials, juce.com/learn. The juce::dsp and AudioProcessorValueTreeState class references in particular.
  2. Schiermeyer, C. (MatKat). Audio Plugin Development with JUCE — the widely-followed "SimpleEQ" video course.
  3. Pirkle, W. Designing Audio Effect Plugins in C++, 2nd ed., Routledge, 2019.
  4. Zavalishin, V. The Art of VA Filter Design (native-instruments, free PDF) — the TPT/zero-delay-feedback filter derivations.
  5. Ross Bencina. "Real-time audio programming 101: time waits for nothing."
  6. Tarr, E. Hack Audio, Routledge, 2018 — DSP fundamentals underlying the effects.
  7. The Audio Programmer community & forum; the JUCE forum (forum.juce.com).