Documentation
The complete guide to building audio plugins with TypeScript and JSX. Write process() for DSP, render() for UI — get a real VST3.
Getting Started
- Open the Builder — Head to the plugin builder and sign in with Google or create an account.
- Pick a template — Choose "Effect" or "Synth" to scaffold a new plugin, or start from scratch.
- Write code — The Monaco editor gives you TypeScript + JSX with full IntelliSense for the entire plugin API.
- Preview live — The preview panel updates as you type. Knobs, sliders, and meters all render via WebGPU.
- Export — Hit Build VST3. The compiler transpiles your TypeScript to C++ and produces a signed native binary.
Plugins are single .tsx files that export a default class extending EffectPlugin or SynthPlugin. The framework handles everything else — block processing, parameter automation, UI rendering, and native compilation.
Module System
Three importable modules provide the entire SDK surface:
import { EffectPlugin, SynthPlugin } from '@the-plug/core';
import type { AudioFrame, PluginAPI, MidiEvent } from '@the-plug/core';
import { Knob, Slider, Button, Canvas, CurveEditor, Dropdown } from '@the-plug/ui';
import { adsr, svf, compress, lerp, dbToGain, sine } from '@the-plug/dsp';@the-plug/core
Base classes, types, interfaces. EffectPlugin, SynthPlugin, PluginComponent, PluginProcess.
@the-plug/ui
UI components: Knob, Slider, Button, Canvas, CurveEditor, Dropdown, AssetExplorer.
@the-plug/dsp
Pure DSP utilities and stateful factory constructors: filters, envelopes, dynamics, oscillators, math.
Effect Plugins
EffectPlugin processes audio input → output. It receives stereo audio, transforms it, and writes the result. This is what you use for EQs, compressors, delays, reverbs, distortion, and any other audio effect.
import { EffectPlugin } from '@the-plug/core';
import { Knob } from '@the-plug/ui';
type Params = { gain: number; mix: number };
export default class SimpleGainPlugin extends EffectPlugin<Params> {
static id = 'com.theplug.gain';
static pluginName = 'Simple Gain';
static parameters = [
{ id: 'gain', name: 'Gain', default: 1.0, min: 0, max: 2, units: 'x' },
{ id: 'mix', name: 'Mix', default: 1.0, min: 0, max: 1 },
];
// Called once per audio sample — think in single samples, not blocks
process(frame: AudioFrame<Params>) {
const g = frame.getParam('gain');
const m = frame.getParam('mix');
frame.outL = dryWet(frame.inL, frame.inL * g, m);
frame.outR = dryWet(frame.inR, frame.inR * g, m);
}
// Return JSX describing your plugin's UI
render(api: PluginAPI<Params>) {
return (
<div style={{ display: 'flex', gap: 24, padding: 20 }}>
<Knob value={api.getParam('gain')}
onChange={v => api.setParam('gain', v)}
label="Gain" min={0} max={2} />
<Knob value={api.getParam('mix')}
onChange={v => api.setParam('mix', v)}
label="Mix" />
</div>
);
}
}process(frame) is called once per audio sample. You read frame.inL / frame.inR, apply your effect, and write frame.outL / frame.outR. The compiler handles block batching, SIMD, and sample-accurate parameter automation. Synth Plugins
SynthPlugin generates audio from MIDI — it has no audio inputs, only outputs. Use it for synthesizers, drum machines, samplers, and any instrument that produces sound from MIDI notes.
import { SynthPlugin } from '@the-plug/core';
import { adsr, svf } from '@the-plug/dsp';
import { Knob, Slider } from '@the-plug/ui';
type Params = { cutoff: number; resonance: number; attack: number; release: number };
export default class SimpleSynth extends SynthPlugin<Params> {
static id = 'com.theplug.synth';
static pluginName = 'Simple Synth';
static category = 'synth';
static parameters = [
{ id: 'cutoff', name: 'Cutoff', default: 2000, min: 20, max: 20000 },
{ id: 'resonance', name: 'Resonance', default: 0.5, min: 0, max: 1 },
{ id: 'attack', name: 'Attack', default: 0.01, min: 0.001, max: 2 },
{ id: 'release', name: 'Release', default: 0.3, min: 0.01, max: 5 },
];
// Auto-managed DSP state — the framework allocates & resets automatically
static dsp = {
env: adsr(), // ADSR envelope generator
filter: svf(), // State-variable filter
};
phase = 0;
noteFreq = 0;
gate = 0;
process(frame: AudioFrame<Params>) {
// Handle MIDI events at this exact sample
for (let i = 0; i < frame.midiCount; i++) {
const evt = frame.getMidiEvent(i);
if (evt.type === 0x90) { // Note On
this.noteFreq = 440 * Math.pow(2, (evt.note - 69) / 12);
this.gate = 1;
} else if (evt.type === 0x80) { // Note Off
this.gate = 0;
}
}
// Generate saw wave
this.phase += this.noteFreq / frame.sampleRate;
if (this.phase >= 1) this.phase -= 1;
let osc = this.phase * 2 - 1;
// Apply envelope & filter via auto-managed DSP
const env = frame.dsp.env(
this.gate,
frame.getParam('attack'), 0.1, 0.7, frame.getParam('release')
);
const filtered = frame.dsp.filter(
osc, frame.getParam('cutoff'), frame.getParam('resonance'), 'lp'
);
frame.outL = filtered * env;
frame.outR = filtered * env;
}
render(api: PluginAPI<Params>) {
return (
<div style={{ display: 'flex', gap: 16, padding: 20 }}>
<Knob value={api.getParam('cutoff')} onChange={v => api.setParam('cutoff', v)}
label="Cutoff" min={20} max={20000} />
<Knob value={api.getParam('resonance')} onChange={v => api.setParam('resonance', v)}
label="Res" />
<Slider value={api.getParam('attack')} onChange={v => api.setParam('attack', v)}
label="Attack" min={0.001} max={2} />
<Slider value={api.getParam('release')} onChange={v => api.setParam('release', v)}
label="Release" min={0.01} max={5} />
</div>
);
}
}SynthPlugin has no frame.inL / frame.inR — it only writes to frame.outL / frame.outR. It receives MIDI events via frame.midiCount and frame.getMidiEvent(i). EffectPlugin
- Has audio inputs + outputs
frame.inL,frame.inR→ transform →frame.outL,frame.outR- EQ, compressor, reverb, delay, distortion
SynthPlugin
- Only audio outputs (no inputs)
- Receives MIDI via
frame.midiCount - Synths, samplers, drum machines
Parameters
Declare parameters as a static array. They become DAW-automatable VST3 parameters with sample-accurate interpolation.
static parameters = [
{
id: 'gain', // Unique ID — matches your Params type key
name: 'Gain', // Display name in the DAW
default: 1.0, // Initial value
min: 0, // Minimum (default: 0)
max: 2, // Maximum (default: 1)
units: 'x', // Display units (optional)
precision: 2, // Decimal places (optional)
},
];Reading parameters
| Context | Method | Notes |
|---|---|---|
| DSP (per-sample) | frame.getParam('gain') | Sample-accurate, automation-interpolated |
| DSP (block) | api.getParam('gain') | Block-start value |
| UI thread | api.getParam('gain') | Current value, read-only |
| UI thread | api.setParam('gain', 0.8) | Write from UI (e.g. knob onChange) |
AudioFrame — Per-Sample Processing
The preferred processing mode. process(frame) is called once per audio sample. The compiler batches it into efficient block processing with SIMD.
Audio I/O
| Property | Type | Description |
|---|---|---|
frame.inL | readonly number | Input sample, left channel (Effect only) |
frame.inR | readonly number | Input sample, right channel (Effect only) |
frame.outL | number | Output sample, left channel — assign your result here |
frame.outR | number | Output sample, right channel |
Timing
| Property | Description |
|---|---|
frame.sampleRate | Host sample rate (e.g. 44100, 48000) |
frame.sampleIndex | Absolute sample index (never resets) |
frame.currentTimeSec | Playback time in seconds |
frame.tempo | Current tempo in BPM |
frame.beat | Beat position within bar (e.g. 0.0–3.999 in 4/4) |
frame.ppq | PPQ position (interpolated) |
frame.isPlaying | Transport playing? |
frame.phase('1/4') | Musical phase 0–1 within division ('1/4', '1/8', '1/16', '1/4t', etc.) |
Input History & Lookahead
| Property | Requires | Description |
|---|---|---|
frame.previousL | static inputHistory = N | Previous N input samples (Float32Array). Zero-latency ring buffer. |
frame.previousR | " | Right channel history |
frame.nextL | static lookahead = M | Next M future samples. Adds M samples of latency (reported to host). |
frame.nextR | " | Right channel lookahead |
Auto-Managed DSP (frame.dsp)
| Property | Description |
|---|---|
frame.dsp.xxx(...) | Call any stateful DSP declared in static dsp = { xxx: factory() }. State is auto-allocated. |
Block Processing
For FFT / spectral work, use the block-based process() signature instead. You get full buffers and an AudioAPI with FFT, buffer operations, and factory methods.
process(inputs: AudioBus, outputs: AudioBus, api: AudioAPI, ctx: ProcessContext) {
const inL = api.inL; // SampleBuffer (array-like)
const outL = api.outL;
// FFT-based processing
const fft = fft_create(1024);
fft_forward(fft, inL, freqReal, freqImag);
// ... modify spectrum ...
fft_inverse(fft, freqReal, freqImag, outL);
fft_destroy(fft);
}AudioAPI Factory Methods
| Method | Returns |
|---|---|
api.createSVF(freq, q, type) | SVF — state-variable filter |
api.createADSR(a, d, s, r) | ADSR — envelope generator |
api.createCompressor(t, r, a, rel) | Compressor — dynamics compressor |
api.createLimiter(ceil, a, r) | Limiter — brickwall limiter |
api.createMeter(windowSec) | Meter — RMS/peak meter |
api.createLadder(freq, res, sat) | LadderFilter — Moog-style 4-pole |
api.createSmoother(initial, rampTime) | SmoothedValue — parameter smoother |
JSX & Rendering
The render(api) method returns JSX that describes your plugin's UI. It works like React — return a tree of elements and components, and the framework handles diffing and rendering.
render(api: PluginAPI) {
return (
<div style={{
display: 'flex',
flexDirection: 'column',
backgroundColor: '#1a1a2e',
padding: 24,
gap: 16,
}}>
<h1 style={{ color: '#fff', fontSize: 18 }}>My Plugin</h1>
<div style={{ display: 'flex', gap: 24 }}>
<Knob value={api.getParam('gain')}
onChange={v => api.setParam('gain', v)}
label="Gain" size={64} />
<Slider value={api.getParam('mix')}
onChange={v => api.setParam('mix', v)}
label="Mix" orientation="vertical" />
</div>
<Button label="Bypass"
active={api.getParam('bypass') > 0.5}
onClick={() => api.setParam('bypass', api.getParam('bypass') > 0.5 ? 0 : 1)} />
</div>
);
}All standard HTML-like elements are supported: <div>, <span>, <p>, <h1>–<h6>, <img>, <input>, <button>, <section>, <table>, lists, and more. They accept style, event handlers (onClick, onPointerDown/Move/Up, onWheel, onKeyDown), and className.
UI Components
Import from @the-plug/ui. Every component accepts a style prop with interactive pseudo-selectors (':hovered', ':active', ':disabled').
<Knob>
GPU-rendered rotary control with configurable vector layers, gradients, and indicators.
<Knob
value={api.getParam('cutoff')}
onChange={v => api.setParam('cutoff', v)}
min={20} max={20000}
size={64}
label="Cutoff"
sensitivity={0.005}
color="#00ccff" />value numberonChange (v: number) => voidmin / max numbersize numberlabel stringsensitivity numberframes number — film-strip frameslayers EnhancedLayerConfig[] — custom vector layerscolor stringdisabled boolean<Slider>
Horizontal or vertical fader control.
<Slider
value={api.getParam('volume')}
onChange={v => api.setParam('volume', v)}
orientation="vertical"
width={40} height={120}
label="Vol" showValue />value / onChangeorientation 'horizontal' | 'vertical'width / heightmin / maxlabel / showValue<Button>
Toggle or momentary button with active state.
<Button label="Bypass" active={bypassed} onClick={() => toggle()} />label stringonClick () => voidactive booleandisabled booleanwidth / height<Canvas>
Free-form drawing surface supporting both 2D Canvas API and WebGPU shaders.
<Canvas
width={300} height={150}
mode="2d"
onRender={(ctx, time) => {
ctx.fillStyle = '#00ccff';
ctx.fillRect(0, 0, 300, 150);
ctx.beginPath();
ctx.arc(150, 75, 50, 0, Math.PI * 2);
ctx.fill();
}} />width / heightmode '2d' | 'gpu'onRender — 2D canvas callbackonGpuRender — WebGPU callbackonMount — one-time initonPointerDown/Move/Up<CurveEditor>
Interactive spline/bezier curve editor with presets, snapping, and WebGPU rendering. 1:1 parity between browser and native.
<CurveEditor
width={400} height={200}
points={[
{ x: 0, y: 0 },
{ x: 0.3, y: 0.8, tension: 0.5 },
{ x: 1, y: 1 },
]}
interpolation="smooth"
snap gridX={4} gridY={4}
onChange={pts => handleCurve(pts)}
showFill showGrid showPoints />points CurvePoint[] — { x, y, tension?, locked? }interpolation 'linear' | 'smooth' | 'step' | 'hold'preset 'sine' | 'exponential' | 'sCurve' | 'sawUp' | …bipolar booleansnap / snapX / snapYonChange (points) => voidplayheadPosition number — 0–1 playheadreadOnly / maxPoints / minPoints<Dropdown>
Searchable dropdown / select with grouping, icons, and nested submenus.
<Dropdown
items={[
{ id: 'lp', label: 'Low Pass', value: 'lp' },
{ id: 'hp', label: 'High Pass', value: 'hp' },
{ id: 'bp', label: 'Band Pass', value: 'bp' },
]}
selectedId="lp"
searchable
onChange={item => setFilterType(item.value)} />items DropdownItem[] | DropdownGroup[]selectedId / selectedIdssearchable / multipleplaceholder / clearableonChange / onMultiChangetheme DropdownThemes.dark | .light | .vintage<TextField>
Text input with selection, caret, multiline support, and keyboard events.
value / placeholderonChange / onSubmitmultiline / disabledfontSize / caretColor<AssetExplorer>
File browser for sample banks and asset folders. Supports list, grid, and tree views with lazy loading.
viewMode 'list' | 'grid' | 'tree'onSelect / onItemActivatefilterTypes / filterBankIdsortBy 'name' | 'type' | 'size' | 'duration'multiSelect / lazyLoadtheme AssetExplorerThemes.dark | .vintage | .minimal@the-plug/dsp
The DSP module provides stateful factory constructors and pure stateless functions. Use factories in static dsp = { ... } for auto-managed state, or call pure functions directly in process().
import { adsr, svf, compress, ladder, smooth } from '@the-plug/dsp';
export default class MyPlugin extends EffectPlugin {
// Declare DSP processors — state is auto-allocated by the framework
static dsp = {
env: adsr(), // ADSR envelope (4 floats of state)
filt: svf(), // State-variable filter (2 floats)
comp: compress(), // Compressor (2 floats)
moog: ladder(), // Moog-style 4-pole (5 floats)
smoother: smooth(), // Exponential smoother (1 float)
};
process(frame) {
// Call them via frame.dsp — state persists across samples
const filtered = frame.dsp.filt(frame.inL, 1000, 0.7, 'lp');
const compressed = frame.dsp.comp(filtered, -18, 4, 0.01, 0.1);
frame.outL = compressed;
frame.outR = compressed;
}
}Stateful Factories
| Factory | frame.dsp.xxx(...) Signature |
|---|---|
adsr() | (gate, attack, decay, sustain, release) → number |
svf() | (input, freq, q, type) → number — 'lp'|'hp'|'bp'|'notch'|'peak'|'allpass' |
ladder() | (input, freq, resonance, saturation) → number |
biquad() | (input, freq, q, type[, gainDb]) → number |
compress() | (input, thresholdDb, ratio, attackSec, releaseSec) → number |
limit() | (input, ceilingDb, attackSec, releaseSec) → number |
gateProcess() | (input, thresholdDb, attackSec, releaseSec) → number |
meter() | (input, windowSec) → number |
smooth() | (target, rampTimeSec) → number |
ballistics() | (input, attackMs, releaseMs) → number |
crossover() | (input, freq) → { low, high } |
random() | () → number (0–1) |
onepoleLp() | (input, coefficient) → number |
onepoleHp() | (input, coefficient) → number |
Pure Stateless Functions
| Category | Functions |
|---|---|
| Oscillators | sine(phase), saw(phase), square(phase, pw?), triangle(phase) |
| Waveshaping | softclip(x), hardclip(x, threshold), fold(x, limit) |
| Mixing | dryWet(dry, wet, mix), pan(x, panPosition) |
| Conversion | gainToDb(gain), dbToGain(db), flushDenormal(x) |
| Interpolation | lerp(a, b, t), cubic_interp(y0, y1, y2, y3, t) |
| Math | clamp(x, lo, hi) — plus all Math.* methods map to std:: |
State Management
Plugins support React-like component state for dynamic UIs. Call this.setState() to trigger a re-render.
interface MyState { tab: 'main' | 'settings'; loading: boolean; }
export default class MyPlugin extends EffectPlugin<Params, MyState> {
state: MyState = { tab: 'main', loading: false };
render(api) {
return (
<div>
<Button label="Main"
active={this.state.tab === 'main'}
onClick={() => this.setState({ tab: 'main' })} />
<Button label="Settings"
active={this.state.tab === 'settings'}
onClick={() => this.setState({ tab: 'settings' })} />
{this.state.tab === 'main'
? <Knob value={api.getParam('gain')} />
: <Slider value={api.getParam('mix')} />
}
</div>
);
}
}PluginComponent<TProps, TState> to create reusable UI components with their own render() and state. They receive this.props and this.api. MIDI
Access MIDI events at sample-accurate timing. Synth plugins receive MIDI automatically; effect plugins can also read and send MIDI.
process(frame) {
// Read incoming MIDI at this exact sample
for (let i = 0; i < frame.midiCount; i++) {
const evt = frame.getMidiEvent(i);
if (evt.type === 0x90 && evt.velocity > 0) {
// Note On — evt.note (0–127), evt.velocity (0–127), evt.channel (0–15)
} else if (evt.type === 0x80) {
// Note Off
} else if (evt.type === 0xB0) {
// CC — evt.note = CC number, evt.velocity = value
}
}
// Send MIDI out
frame.noteOn(0, 60, 100); // channel, note, velocity
frame.noteOff(0, 60); // channel, note
frame.cc(0, 1, 64); // channel, CC#, value
}| MidiEvent Property | Description |
|---|---|
type | 0x90 Note On, 0x80 Note Off, 0xB0 CC, 0xE0 Pitch Bend, 0xC0 Program Change |
note | 0–127 (middle C = 60) |
velocity | 0–127 |
channel | 0–15 |
sampleOffset | Sample-accurate offset within block |
Transport & Timing
Access the DAW transport state for tempo-synced effects, sequencers, and LFOs.
process(frame) {
// phase() returns 0–1 within a musical division
const lfoPhase = frame.phase('1/4'); // synced to quarter notes
const depth = sine(lfoPhase) * 0.5 + 0.5;
frame.outL = frame.inL * depth;
frame.outR = frame.inR * depth;
}| Property | Description |
|---|---|
frame.tempo | BPM |
frame.beat | Beat within bar (0.0–3.999 in 4/4) |
frame.ppq | PPQ position |
frame.barPosition | Current bar (float) |
frame.isPlaying | Transport running? |
frame.isRecording | Recording? |
frame.isLooping | Loop mode active? |
frame.transportJumped | True on first sample after seek/loop-wrap |
frame.timeSigNumerator | e.g. 4 |
frame.timeSigDenominator | e.g. 4 |
frame.phase(div) | Musical phase 0–1 — '1/4', '1/8', '1/16', '1/4d' (dotted), '1/4t' (triplet) |
PluginAPI — UI Thread
Available in render(), initialize(), onParamChange(), and destroy(). Provides parameter access, audio state visualization, HTTP fetch, file I/O, and sample management.
Core Methods
| Method | Description |
|---|---|
api.getParam(id) | Read parameter value |
api.setParam(id, value) | Write parameter value (e.g. from knob onChange) |
api.getSampleRate() | Host sample rate |
api.getAudioState(label) | Read DSP state snapshot for visualization (e.g. waveform, spectrum) |
api.setAudioState(label, data) | Push UI-edited data to audio thread (double-buffered, RT-safe) |
HTTP Fetch (Axios-compatible)
| Method | Returns |
|---|---|
api.fetch.get(url, config?) | Promise<FetchResponse> |
api.fetch.post(url, data?, config?) | Promise<FetchResponse> |
api.fetch.put(url, data?, config?) | " |
api.fetch.delete(url, config?) | " |
File I/O
| Method | Description |
|---|---|
api.promptForFile(options) | Opens OS file picker → Promise<FileHandle> |
api.decodeAudioData(buffer) | Decode ArrayBuffer → Promise<AudioBufferInfo> |
api.saveFile(filename, data) | Save arbitrary file → Promise<boolean> |
Style System
All JSX elements accept a style prop using CSS-like properties. Full flexbox layout is supported with pixel values (no units needed).
<div style={{
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
alignItems: 'center',
gap: 16,
padding: 24,
backgroundColor: '#1a1a2e',
borderRadius: 8,
width: 400,
height: 300,
}}>
<div style={{ flex: 1, alignSelf: 'stretch' }}>...</div>
</div>UI components also support interactive pseudo-selectors:
<Button
label="Click Me"
style={{
backgroundColor: '#333',
color: '#fff',
':hovered': { backgroundColor: '#555' },
':active': { backgroundColor: '#00ccff', color: '#000' },
':disabled': { opacity: 0.4 },
}} />Supported layout properties: position, display, flex / flexDirection / flexWrap / flexGrow / flexShrink / flexBasis, justifyContent, alignItems / alignSelf / alignContent, gap, padding*, margin, width / height / minWidth / maxWidth, overflow, order.
Assets & Samples
Declare sample banks and asset folders in your plugin's static config. The framework handles loading, unloading, and audio-thread-safe playback.
static assets = {
sampleBanks: [{
id: 'drums',
name: 'Drum Kit',
path: './samples/drums',
samples: [
{ id: 'kick', name: 'Kick', path: 'kick.wav', rootNote: 36 },
{ id: 'snare', name: 'Snare', path: 'snare.wav', rootNote: 38 },
],
}],
assetFolders: [{
id: 'wavetables',
name: 'Wavetables',
path: './wavetables',
type: 'wavetable',
}],
};| API | Method |
|---|---|
api.samples | .loadSample(bankId, sampleId), .loadBank(bankId), .unloadSample(), .getBanks() |
api.assets | .loadWavetable(folderId, assetId), .loadImpulseResponse(), .loadAsset() |
api.samplePlayer (audio thread) | .readSample(bufferId, ch, frame), .readSampleInterp(bufferId, ch, pos) |
Type Reference
Key TypeScript types used throughout the SDK.
| Type | Description |
|---|---|
AudioFrame<T> | Per-sample processing context with I/O, params, timing, MIDI, DSP state |
AudioAPI<T> | Block-level processing API with buffers, FFT, factory methods |
PluginAPI<T> | UI-thread API with params, fetch, file I/O, sample library |
MidiEvent | { type, note, velocity, channel, sampleOffset } |
TransportInfo | Full transport snapshot (tempo, time sig, position, flags) |
ProcessContext | { sampleRate, numSamples, numChannels, transport } |
CurvePoint | { x: number, y: number, tension?: number, locked?: boolean } |
ParameterDefinition | { id, name, default, min?, max?, units?, precision? } |
FileHandle | { name, path, size, arrayBuffer(), data } |
AudioBufferInfo | { id, channels, sampleRate, length, duration } |
SampleBuffer | Array-like audio buffer for block processing |
DspFactory | Factory descriptor for auto-managed DSP state |
Plugin Lifecycle Hooks
| Hook | When |
|---|---|
initialize(api) | Called once when plugin loads. Use for async setup, fetching data, loading samples. |
reset(api, ctx) | Called on transport changes (play/stop/seek). Reset delay lines, envelopes, etc. |
onParamChange(api, paramId, value) | Called when any parameter changes. Use to trigger side effects. |
destroy(api) | Called when plugin unloads. Clean up resources. |
Ready to build?
Open the plugin builder and start coding your first VST3 plugin.
Open the Builder →