Stoatworks Labs

Reference

Audio plugin APIs

Two formats carry essentially all of it: VST 3, which Steinberg defines and which runs on all three desktop platforms, and Audio Units, which Apple defines and which is the only way into Logic, GarageBand and iOS. This is the shape of each interface, the lifecycle, the parameter model, the bundle layout — and, before any of that, the threading rules, because a plugin that loads is a day's work and a plugin that does not click under load is the actual job.

It assumes you can write C++ and DSP. It does not teach either. It is the companion to the video plugin APIs page, and the same contract applies: interface facts come from the SDKs, host-behaviour facts were measured on shipped plugins here and are attributed as measurements, because no specification will ever tell you where a particular console actually scans for plugins.

Not covered: AAX (Avid's format for Pro Tools — the SDK is under NDA, so there is nothing useful to write publicly), CLAP (open, MIT, growing, and genuinely pleasant, but not yet what a client asks for), VST 2 (Steinberg withdrew the licence in 2018; do not start there), LV2 and RTAS.

The two, side by side

 VST 3Audio Unit v2Audio Unit v3
Who defines itSteinbergAppleApple
PlatformsmacOS, Windows, LinuxmacOS onlymacOS, iOS, iPadOS
Parameter rangeAlways normalised 0–1Real units, with min and maxReal units, on an AUParameterTree
Parameter identityParamID — a uint32 you chooseAudioUnitParameterID — a uint32AUParameterAddress — a uint64
Latency reported inSamplesSecondsSeconds
Validationvalidator (in the SDK), pluginvalauval, pluginvalauval, pluginval
LicenceDual: Steinberg proprietary, or GPLv3Part of macOS; no separate SDK licencePart of the OS; no separate SDK licence

The row people underestimate is licence. The VST 3 SDK is dual licensed: you either sign Steinberg's proprietary agreement or you ship under GPLv3, which makes your plugin's whole source obligation a business decision taken on day one rather than a detail. Audio Units have no such question — the API is part of the operating system.

The row that shapes your code is parameter range. VST 3 parameters are normalised 0–1 always, and every conversion to real units is the editor's job. AU parameters carry their real minimum and maximum. Writing one plugin for both means picking a side internally and converting at exactly one boundary — normally by keeping the DSP in real units and normalising at the VST 3 edge, because a filter that thinks in hertz is a filter you can debug.

The realtime rules, first

These are identical in both formats, they are not negotiable, and violating one of them produces a fault that is nearly impossible to attribute after the fact: an occasional click, on one machine, at one buffer size, that nobody can reproduce.

Your process callback runs on a thread with a hard deadline. At 48kHz and a 64-sample buffer, that deadline is 1.33 milliseconds, and missing it is an audible glitch through a PA. So, inside it:

  • No allocation. No new, no malloc, no std::vector::push_back, no std::string, no std::function assignment. The allocator takes a lock and you do not control who else holds it. Allocate everything in the prepare call, sized for the maximum block.
  • No locks. A mutex the UI thread also takes is a priority inversion waiting to happen. Pass values across with atomics, and pass structures across with a lock-free FIFO.
  • No file or network I/O, and no logging. Including the logging you added to find the click.
  • Nothing that can throw, and no unbounded loop — a while( !converged ) is a deadline you cannot bound.
  • Denormals off. A decaying IIR tail into denormal range costs orders of magnitude on some CPUs, and the symptom is CPU load that climbs after the music stops. Set FTZ/DAZ at the top of the callback and restore on exit — juce::ScopedNoDenormals does exactly this.

Two more that are format-specific but universal in effect. The block length varies. VST 3's numSamples and AU's frameCount are whatever the host felt like, up to the maximum you were told about; code that assumes a fixed size works until somebody changes their interface. And the audio thread and the message thread both touch your parameters — in AUv3 the implementorValueObserver block is explicitly called from either.

VST 3

VST 3 is COM without Microsoft: everything derives from FUnknown with queryInterface, addRef and release, identity is a 16-byte TUID, and a module exposes a factory that enumerates classes. If you have written COM, none of it will surprise you. If you have not, the thing to hold onto is that you are never handed a concrete type — you ask an object whether it supports an interface.

// The module entry points, per platform.
bool InitDll();  bool ExitDll();                       // Windows
bool bundleEntry( CFBundleRef ); bool bundleExit();    // macOS
bool ModuleEntry( void* ); bool ModuleExit();          // Linux

// And the one that matters, exported from every platform:
IPluginFactory* PLUGIN_API GetPluginFactory();

// The factory lists classes. A plugin is normally TWO of them.
PClassInfo2:
    TUID        cid;            // 16-byte FUID, unique forever
    const char* category;       // "Audio Module Class" | "Component Controller Class"
    const char* name;
    const char* subCategories;  // "Fx|EQ", "Instrument|Synth", ...
    const char* vendor;
    const char* version;

The split that defines the format

A VST 3 plugin is normally two separate classes: an IComponent/IAudioProcessor that does the DSP, and an IEditController that owns the UI and the parameter display. They are registered separately, and the specification permits a host to run them in different processes.

That is the source of most VST 3 confusion, and the rule that resolves it is: the processor owns the state; the controller is told about it. The processor's getState/setState is what gets written into the project file. The controller receives the same stream through setComponentState so it can show the right numbers, and it must not assume it can reach across and read a member.

You may implement both on one object — the SDK's SingleComponentEffect exists for it, and JUCE does something equivalent — and it is fine for a great many plugins. Know that you are taking the shortcut.

// The processor. Realtime. May live in another process entirely.
class IComponent : public IPluginBase {
    tresult setIoMode( IoMode );
    int32   getBusCount( MediaType, BusDirection );
    tresult getBusInfo( MediaType, BusDirection, int32 index, BusInfo& );
    tresult activateBus( MediaType, BusDirection, int32 index, TBool state );
    tresult setActive( TBool state );
    tresult setState( IBStream* );   // and getState — this is the plugin's state
};

class IAudioProcessor : public FUnknown {
    tresult setBusArrangements( SpeakerArrangement* in, int32 numIn,
                                SpeakerArrangement* out, int32 numOut );
    tresult canProcessSampleSize( int32 symbolicSampleSize );  // kSample32 | kSample64
    uint32  getLatencySamples();
    tresult setupProcessing( ProcessSetup& );   // sample rate, maxSamplesPerBlock, realtime/offline
    tresult setProcessing( TBool state );
    tresult process( ProcessData& );
};

// The editor. UI thread. Owns the parameter display logic.
class IEditController : public IPluginBase {
    tresult setComponentState( IBStream* );     // the processor's state, for display
    int32   getParameterCount();
    tresult getParameterInfo( int32 paramIndex, ParameterInfo& );
    tresult getParamStringByValue( ParamID, ParamValue valueNormalized, String128 );
    ParamValue normalizedParamToPlain( ParamID, ParamValue valueNormalized );
    tresult setParamNormalized( ParamID, ParamValue value );
    IPlugView* createView( FIDString name );    // "editor"
};

Processing

setupProcessing gives you the sample rate, the maximum block and whether this is realtime or an offline bounce; setActive( true ) is where you allocate; setProcessing( true ) is where you reset filter state, because it brackets transport starts. Then process is called with a ProcessData.

struct ProcessData {
    int32 processMode;              // kRealtime | kPrefetch | kOffline
    int32 symbolicSampleSize;       // kSample32 | kSample64
    int32 numSamples;               // block length — NOT fixed, and NOT always maxSamplesPerBlock
    int32 numInputs, numOutputs;
    AudioBusBuffers* inputs;        // per bus: numChannels, silenceFlags, channelBuffers32/64
    AudioBusBuffers* outputs;
    IParameterChanges* inputParameterChanges;   // automation, WITH SAMPLE OFFSETS
    IParameterChanges* outputParameterChanges;
    IEventList* inputEvents;        // notes, and anything else event-shaped
    IEventList* outputEvents;
    ProcessContext* processContext; // tempo, time signature, playing/recording state, PPQ position
};

// Automation is a queue per parameter, and each point carries an offset into
// the block. Ignoring the offset and taking the last value is the standard
// shortcut; it is also why some plugins zipper on fast automation.
IParamValueQueue* q = data.inputParameterChanges->getParameterData( i );
ParamID id = q->getParameterId();
for( int32 p = 0; p < q->getPointCount(); ++p ) {
    int32 sampleOffset; ParamValue value;
    q->getPoint( p, sampleOffset, value );      // value is normalised 0–1
}

silenceFlags on a bus is a real optimisation and a real trap: a host may tell you a channel is silent, and if you propagate that flag onward while your reverb tail is still ringing, the tail gets cut. Clear the output flags unless you are certain.

processContext is where tempo, time signature, PPQ position and the playing/recording state arrive. Every field is guarded by a bit in its state mask — check the bit before believing the value, because a host that has no transport still fills the struct with something.

Buses, and the sidechain

Buses are declared at construction and arranged by the host through setBusArrangements, which passes SpeakerArrangement bitmasks — kStereo is left plus right, not "2". Return kResultFalse for a layout you cannot do and the host tries another; return true for everything and you will be handed a 7.1.4 bed one day.

A sidechain is an auxiliary input bus, and the thing to design for is that it may not be connected. A ducker whose detector reads an unconnected sidechain bus reads silence and never ducks — so the fallback to the main input has to be explicit, tested, and tested with the bus deliberately left disconnected.

# macOS — a real bundle
Zero EQ.vst3/Contents/MacOS/Zero EQ
Zero EQ.vst3/Contents/Info.plist

# Windows — a DIRECTORY named .vst3, since VST 3.6.10.
# A bare .dll renamed .vst3 still loads in most hosts, but is the old way.
Zero EQ.vst3/Contents/x86_64-win/Zero EQ.vst3

# Linux
Zero EQ.vst3/Contents/x86_64-linux/Zero EQ.so

# Where hosts scan
macOS    /Library/Audio/Plug-Ins/VST3        ~/Library/Audio/Plug-Ins/VST3
Windows  C:\Program Files\Common Files\VST3
Linux    ~/.vst3    /usr/lib/vst3    /usr/local/lib/vst3

Trap — a ParamID is forever. It is a number you choose, and it is what a saved project stores. Unlike an index, it does not shift when you insert a parameter — which is the whole point of it — but it also cannot be reused for something else. Pick them from an enum with explicit values and never renumber.

Audio Units

Audio Units come in two generations that coexist. AUv2 is a C-plus-C++ component in a .component bundle, dating from Mac OS X 10.0, and is what most shipping macOS plugins still are. AUv3 is an App Extension: an AUAudioUnit subclass in Objective-C or Swift, delivered inside a host application, sandboxed, and the only form that exists on iOS.

You do not have to pick. An AUv3 gets a v2-compatible entry automatically, so a v3 plugin appears to a v2 host as a v2 plugin. Write v3 for anything new.

Registration is a plist key

There is no installer, no registry and no scan manifest. You drop a bundle in a folder and an AudioComponents array in its Info.plist tells the system what is inside.

<!-- Info.plist. This array IS the registration; there is no installer step. -->
<key>AudioComponents</key>
<array>
  <dict>
    <key>type</key>          <string>aufx</string>   <!-- effect -->
    <key>subtype</key>       <string>Zeq1</string>   <!-- yours, per plugin -->
    <key>manufacturer</key>  <string>Alsg</string>   <!-- yours, per company -->
    <key>name</key>          <string>Allan Sargeant: Zero EQ</string>
    <key>description</key>   <string>Zero-latency parametric EQ</string>
    <key>version</key>       <integer>65536</integer> <!-- (major<<16)|(minor<<8)|patch -->
    <key>factoryFunction</key><string>ZeroEQAUFactory</string>
    <key>sandboxSafe</key>   <true/>
  </dict>
</array>

<!-- Type codes -->
aufx  effect          aumf  MIDI-controlled effect   aumu  instrument
augn  generator       auou  output                   aumx  mixer
aupn  panner          aufc  format converter

<!-- The triple type + subtype + manufacturer IS the plugin's identity.
     A session stores it. Change subtype or manufacturer and every existing
     session loses the plugin. The manufacturer code must contain at least
     one character that is not lower-case. -->

Locations: /Library/Audio/Plug-Ins/Components and ~/Library/Audio/Plug-Ins/Components for AUv2. An AUv3 is registered by installing the app that contains it — which is why AUv3 on iOS is distributed through the App Store and why an AUv3 on macOS still ships inside a host application, even a trivial one.

Rendering is a pull

This is the structural difference from VST 3. A VST 3 plugin is handed input buffers. An Audio Unit is handed a block to call when it wants input — which is what lets the graph be assembled lazily, and which means an effect that needs no input this cycle can simply not ask for it.

// AUv3. One block, called on the realtime thread, and everything the
// plugin does per buffer happens inside it.
typedef AUAudioUnitStatus (^AUInternalRenderBlock)(
    AudioUnitRenderActionFlags* actionFlags,
    const AudioTimeStamp*       timestamp,
    AUAudioFrameCount           frameCount,
    NSInteger                   outputBusNumber,
    AudioBufferList*            outputData,
    const AURenderEvent*        realtimeEventListHead,  // sample-accurate, a linked list
    AURenderPullInputBlock      pullInputBlock );        // AU is a PULL model

// The event list is where automation and MIDI arrive, already time-ordered:
//   AURenderEventParameter        eventSampleTime, parameterAddress, value
//   AURenderEventParameterRamp    ... plus rampDurationSampleFrames
//   AURenderEventMIDI             eventSampleTime, cable, length, data[3]
//
// It is captured by the block, so the block must not touch 'self'. Capture
// the pointers it needs — the DSP object, the parameter cache — by value.

Buffers are non-interleaved 32-bit float in an AudioBufferList. Note that the list you are given may arrive with null mData pointers, meaning "render in place into the input's buffers" — check, rather than assuming you own memory.

Two properties bracket the work: maximumFramesToRender is set before allocateRenderResourcesAndReturnError:, and that call is where you size everything. deallocateRenderResources is the matching teardown.

Parameters

An AUParameterTree of AUParameters and AUParameterGroups, each parameter carrying an AUParameterAddress, a real-unit range and a unit from the AudioUnitParameterUnit enumeration — Hertz, Decibels, Percent, Seconds, Indexed, Boolean and a long tail of others. The host renders the right control and the right suffix because you said what the number is, which is genuinely nicer than normalising everything and describing it in a callback.

// Real units, not normalised. This is the largest single difference from VST 3.
AUParameter* freq = [AUParameterTree createParameterWithIdentifier:@"freq"
    name:@"Frequency"
    address:kParamFrequency          // AUParameterAddress, a uint64 you choose
    min:20.0f max:20000.0f           // Hz, as the DSP wants them
    unit:kAudioUnitParameterUnit_Hertz
    unitName:nil
    flags:kAudioUnitParameterFlag_IsReadable
        | kAudioUnitParameterFlag_IsWritable
        | kAudioUnitParameterFlag_DisplayLogarithmic
    valueStrings:nil dependentParameters:nil];

// Two callbacks bridge the UI thread and the audio thread. Both are called
// from either. Neither may allocate, block or log.
tree.implementorValueObserver = ^( AUParameter* p, AUValue v ) { /* atomic store */ };
tree.implementorValueProvider = ^AUValue ( AUParameter* p )     { /* atomic load  */ };

Trap — the render block must not capture self. It is retained by the framework and called on the realtime thread; capturing the AUAudioUnit creates a retain cycle and, worse, invites an Objective-C message send — which can take a lock — into your audio callback. Capture the DSP object pointer and the parameter cache by value, and nothing else.

State is fullState (a dictionary the host persists) and fullStateForDocument (the same, plus anything too large or too machine-specific for a preset). Factory presets are AUAudioUnitPreset objects with a negative number reserved for user presets and non-negative numbers for yours.

The UI is a separate AUViewController, fetched by the host through requestViewControllerWithCompletionHandler:. In an out-of-process AUv3 that view controller is genuinely in another process, so it talks to the audio unit through the parameter tree and nothing else. Design for that even if you are running in-process today.

auval is not optional

auval -v aufx Zeq1 Alsg. Apple's own validator, and hosts — Logic in particular — will refuse a plugin that fails it. It is also the fastest way to find that your plugin has been silently rejected: a plugin that does not appear in a host has usually failed validation, and auval will say why in a way that no host ever does.

Doing it once, with JUCE

Almost nobody writes both of the above by hand any more, and the fleet's own audio plugins — Zero EQ, MixerReturn, Contourtonist — do not. JUCE gives you one AudioProcessor subclass and builds VST 3, AU and a standalone application from it.

juce_add_plugin( ZeroEQ
    COMPANY_NAME              "Allan Sargeant"
    BUNDLE_ID                 "com.allansargeant.zeroeq"
    PLUGIN_MANUFACTURER_CODE  Alsg     # AU manufacturer — needs one non-lower-case char
    PLUGIN_CODE               Zeq1     # AU subtype
    FORMATS                   VST3 AU Standalone
    PRODUCT_NAME              "Zero EQ"
    IS_SYNTH                  FALSE
    NEEDS_MIDI_INPUT          FALSE
    VST3_CATEGORIES           "Fx" "EQ"
    AU_MAIN_TYPE              "kAudioUnitType_Effect"
    COPY_PLUGIN_AFTER_BUILD   TRUE )

What that buys you is the wrapper layer: normalised-versus-real parameter conversion, AudioProcessorValueTreeState for state and automation, bus layout negotiation, and an editor that attaches to the right platform view. What it does not buy you is exemption from the sections above — every trap in them still exists underneath, and the ones that bite hardest are the ones where the abstraction is thinner than it looks.

Four that cost real time here:

  • Non-ASCII characters in C++ string literals break JUCE. juce::String( const char* ) asserts in CharPointer_ASCII::isValidString and mangles the text. An em dash in a UI string is enough. Comments are fine; literals are not — and because it is a debug-only assert, it reaches release silently. Grep for it before shipping.
  • juce::Interpolators::WindowedSinc has 100 samples of latency and about 1% passband gain error. getBaseLatency() reports the latency accurately — prime the filter with that many samples and discard the output, or every resampled file lands late relative to one that already matched the device rate. The gain error matters for testing: on a ramp stimulus, a 1% gain error and a 1% timing error are indistinguishable, so measure timing with a step and find its half-height crossing.
  • jmin/jmax take two to four arguments, not an initializer listjmin({a, b, c}) fails with a confusing "no matching function". And someFile = {} is ambiguous for juce::File; write juce::File().
  • A console test harness that touches model classes needs a juce::ScopedJuceInitialiser_GUI, because ChangeBroadcaster needs a MessageManager. And backend defines are not portable — JUCE_JACK=1 on macOS fails the build looking for jack/jack.h.

Validating, and the host traps

Run pluginval at strictness 5 on both formats, and auval on the AU, and do it as a release gate rather than when something looks wrong. Both find real bugs — bus enumeration, state round-tripping, parameter thread safety — that no amount of playing the plugin in one DAW will surface.

They also produce warnings that are not your bug, and knowing which is which saves a day. Two seen consistently on an AU with a sidechain here:

  • "Disabling non-main buses failed" — a known AUv2/JUCE interaction where AU's bus-disable semantics for aux buses do not round-trip the way pluginval expects. It affects AU plugins with a sidechain generally. auval itself still exits 0.
  • "Current program is -1" — the AU wrapper reporting that no factory preset has been explicitly selected until a host picks one. Adding a preset system does not clear it; it reappears on every run.

Document warnings like these honestly in a README rather than working around them or quietly omitting them, and re-check them each release rather than assuming last release's triage still holds.

Where hosts actually look

Measured, not documented — Waves SuperRack Performer does not scan ~/Library/Audio/Plug-Ins/VST3. Only the system folder, /Library/Audio/Plug-Ins/VST3. Three plugins here were invisible from the user folder; copying one to the system folder made it appear immediately. So "drop the bundle into your plugin folder" in a README sends people to the folder where it will silently not appear. That system folder was drwxrwxrwx on the machine this was measured on, so no sudo is needed to install there.

Two related findings from the same session, both of which had been plausible theories first: ad-hoc signing is not a blocker — the plugin loads and runs with no Developer ID and no notarisation — and quarantine is usually a red herring for a locally built plugin, because a JUCE COPY_PLUGIN_AFTER_BUILD install carries only com.apple.provenance, never com.apple.quarantine. And SuperRack scans at launch only: replacing a bundle underneath a running instance does nothing until it is quit and relaunched.

One testing lesson worth more than the rest

A test whose input cannot distinguish the correct behaviour from the incorrect one proves nothing, however precise its numbers look. A harmonic EQ here was verified by FFT-ing a single tone at the band's own frequency and declaring the harmonic content mathematically exact. That test is structurally incapable of detecting the bug it had, which was that the saturator ran across the whole broadband signal instead of the band: with only one tone present, "saturate only this band" and "saturate everything" produce byte-identical output. The replacement is two tones, one inside the band and one far outside it. Anything frequency-selective needs a stimulus with content both inside and outside the region under test.

Where this came from

The interface descriptions are from the VST 3 SDK and Apple's AudioToolbox and AVFAudio headers. The host behaviour — where SuperRack scans, which pluginval warnings recur, what ad-hoc signing does and does not block — was measured on this fleet's own plugins on real machines, and is dated by that: a host release can change any of it, so treat it as a starting hypothesis rather than a fact about the format.

The JUCE traps each cost time in a shipped plugin. The plugins are open source — Zero EQ is the largest and has the sidechain, the preset system and the validation notes; MixerReturn is the one that found the SuperRack scan path.

Corrections welcome: get in touch. The video counterpart is Video plugin APIs.