You are helping build a plugin for VCV Rack v2 — a open-source Eurorack modular synthesizer simulator. Plugins are C++ shared libraries (.dylib/.so/.dll) that Rack loads at runtime.
How VCV Rack Plugins Work
A plugin is a collection of modules. Each module has two halves:
- Module (engine) — subclasses
rack::Module, holds state and DSP logic. Theprocess()method is called every audio sample. - ModuleWidget (UI) — subclasses
rack::ModuleWidget, lays out the panel with knobs, ports, lights, and displays.
These are always paired: one Module struct and one ModuleWidget struct, registered together via createModel<>().
Project Structure
A VCV Rack plugin follows this layout:
MyPlugin/
plugin.json # Manifest: slug, version, license, modules list
plugin.hpp # extern Plugin* + extern Model* declarations
plugin.cpp # init() — registers all module models
Makefile # Delegates to Rack SDK's plugin.mk
src/
MyModule.hpp # Module + ModuleWidget struct declarations
MyModule.cpp # Module implementation + Model registration
dsp/ # DSP classes (separated from Rack glue)
utils/ # Utility classes (limiters, wavetables, etc.)
tests/ # Unit tests
res/
MyModule.svg # Panel graphic (per module)
MyModule-dark.svg # Optional dark theme variant
presets/
MyModule/
00_Preset.vcvm # Factory presets
dep/
Rack-SDK/ # Vendored SDK (downloaded by CLI or manually)
build.sh # RACK_DIR=./dep/Rack-SDK make dist
clean.sh # RACK_DIR=./dep/Rack-SDK make clean
install.sh # RACK_DIR=./dep/Rack-SDK make install
Workflow: Creating a New Plugin
1. Scaffold the Project
If the user has vcp (vc-plugins-cli) installed:
vcp --create-plugin
Otherwise, use the Rack SDK helper:
$RACK_DIR/helper.py createplugin MyPlugin
Or scaffold manually by creating the files below.
2. Create plugin.json
Read references/manifest-reference.md for the full schema. Key fields:
{
"slug": "my-plugin",
"name": "My Plugin",
"version": "2.0.0",
"license": "GPL-3.0-or-later",
"brand": "MyBrand",
"author": "Your Name",
"authorEmail": "you@example.com",
"modules": []
}
The slug must NEVER change after release. Version major must match Rack major (2.x.x for Rack 2).
3. Create plugin.hpp and plugin.cpp
plugin.hpp:
#pragma once
#include <rack.hpp>
using namespace rack;
extern Plugin* pluginInstance;
extern Model* modelMyModule;
plugin.cpp:
#include "plugin.hpp"
Plugin* pluginInstance;
void init(Plugin* p) {
pluginInstance = p;
p->addModel(modelMyModule);
}
4. Create the Module
See references/module-template.md for a complete starter template. The core pattern:
// MyModule.hpp
#pragma once
#include <rack.hpp>
using namespace rack;
struct MyModule : Module {
enum ParamId {
FREQ_PARAM,
NUM_PARAMS
};
enum InputId {
FREQ_INPUT,
AUDIO_INPUT,
NUM_INPUTS
};
enum OutputId {
AUDIO_OUTPUT,
NUM_OUTPUTS
};
enum LightId {
NUM_LIGHTS
};
MyModule() {
config(NUM_PARAMS, NUM_INPUTS, NUM_OUTPUTS, NUM_LIGHTS);
configParam(FREQ_PARAM, -54.f, 54.f, 0.f, "Frequency", " Hz", dsp::FREQ_C4, dsp::FREQ_C4);
configInput(FREQ_INPUT, "Frequency CV");
configInput(AUDIO_INPUT, "Audio");
configOutput(AUDIO_OUTPUT, "Audio");
}
void process(const ProcessArgs& args) override;
};
struct MyModuleWidget : ModuleWidget {
MyModuleWidget(MyModule* module) {
setModule(module);
setPanel(createPanel(asset::plugin(pluginInstance, "res/MyModule.svg")));
addChild(createWidget<ScrewSilver>(Vec(0, 0)));
addChild(createWidget<ScrewSilver>(Vec(box.size.x - RACK_GRID_WIDTH, 0)));
addChild(createWidget<ScrewSilver>(Vec(0, RACK_HEIGHT - RACK_GRID_WIDTH)));
addChild(createWidget<ScrewSilver>(Vec(box.size.x - RACK_GRID_WIDTH, RACK_HEIGHT - RACK_GRID_WIDTH)));
float cx = box.size.x / 2.f;
addParam(createParamCentered<RoundBlackKnob>(Vec(cx, 80.f), module, MyModule::FREQ_PARAM));
addInput(createInputCentered<PJ301MPort>(Vec(cx, 130.f), module, MyModule::FREQ_INPUT));
addInput(createInputCentered<PJ301MPort>(Vec(cx, 250.f), module, MyModule::AUDIO_INPUT));
addOutput(createOutputCentered<PJ301MPort>(Vec(cx, 310.f), module, MyModule::AUDIO_OUTPUT));
}
};
// MyModule.cpp
#include "MyModule.hpp"
Model* modelMyModule = createModel<MyModule, MyModuleWidget>("MyModule");
void MyModule::process(const ProcessArgs& args) {
float pitch = params[FREQ_PARAM].getValue() + inputs[FREQ_INPUT].getVoltage();
float freq = dsp::FREQ_C4 * std::pow(2.f, pitch / 12.f);
float in = inputs[AUDIO_INPUT].getVoltage() / 5.f;
// ... DSP here ...
outputs[AUDIO_OUTPUT].setVoltage(in * 5.f);
}
5. Create the Panel SVG
Panels are SVGs designed in Inkscape. See references/panel-design.md for full details. Key specs:
- Document units: mm (not px)
- Height: 128.5 mm (standard Eurorack)
- Width: multiples of 5.08 mm (1 HP)
- Text must be converted to paths (Path > Object to Path)
- Use a
componentslayer with colored circles for auto-generation:- Red
#ff0000= Param - Green
#00ff00= Input - Blue
#0000ff= Output - Magenta
#ff00ff= Light
- Red
6. Build and Test
make # Compile
make dist # Package as .vcvplugin
make install # Install to Rack user folder
If plugin doesn't appear, check log.txt in Rack user folder.
The process() Method — Where DSP Happens
process() is called once per audio sample (typically 44100 or 48000 Hz). Rules:
- Always use
args.sampleTimefor time-dependent math — never hardcode sample rates - Audio signals: +/-5V (divide by 5 to normalize, multiply by 5 to output)
- CV signals: 0-10V unipolar, +/-5V bipolar
- Pitch: 1V/octave, baseline C4 =
dsp::FREQ_C4(261.6256 Hz) - NaN/Inf guard:
std::isfinite(out) ? out : 0.fon all outputs - No allocations, no locks, no blocking I/O in process()
Common process() Patterns
Simple per-sample:
void process(const ProcessArgs& args) {
float dt = args.sampleTime;
float cv = inputs[CV_INPUT].getVoltage();
float param = params[PARAM].getValue();
float combined = param + cv;
combined = clamp(combined, 0.f, 1.f);
// ... DSP ...
float out = dsp.process(combined);
out = std::isfinite(out) ? clamp(out, -5.f, 5.f) : 0.f;
outputs[OUT].setVoltage(out);
}
Block-based (for FFT, neural nets, convolution):
static const int BLOCK_SIZE = 128;
float inBuffer[BLOCK_SIZE];
float outBuffer[BLOCK_SIZE];
int bufferPos = 0;
void process(const ProcessArgs& args) {
float in = inputs[AUDIO_IN].getVoltage() / 5.f;
inBuffer[bufferPos] = in;
bufferPos++;
if (bufferPos >= BLOCK_SIZE) {
bufferPos = 0;
engine.processBlock(inBuffer, outBuffer, BLOCK_SIZE);
}
float out = outBuffer[bufferPos] * 5.f;
outputs[AUDIO_OUT].setVoltage(clamp(out, -5.f, 5.f));
}
Voltage Standards Quick Reference
| Signal | Range | Notes |
|---|---|---|
| Audio | +/-5V (10Vpp) | Standard output level |
| Unipolar CV | 0 to 10V | Envelopes, velocity |
| Bipolar CV | +/-5V | Pitch, LFO, modulation |
| Triggers | 10V, 1ms duration | Use dsp::PulseGenerator |
| Gates | 10V when high | Use dsp::SchmittTrigger for input |
| Pitch | 1V/oct | C4 = 0V = 261.6 Hz |
Widget Construction — Building the UI
Always use centered layout helpers:
float cx = box.size.x / 2.f;
addParam(createParamCentered<RoundBlackKnob>(Vec(cx, y), module, ParamId));
addInput(createInputCentered<PJ301MPort>(Vec(cx, y), module, InputId));
addOutput(createOutputCentered<PJ301MPort>(Vec(cx, y), module, OutputId));
addLight(createLightCentered<MediumLight<GreenLight>>(Vec(cx, y), module, LightId));
Available Components
The Rack SDK includes a full component library. See references/component-library.md for the complete catalog of 60+ components (knobs, ports, switches, buttons, sliders, screws, lights). Most commonly used:
| Component | Use For |
|---|---|
RoundBlackKnob |
Standard knobs |
RoundLargeBlackKnob |
Primary/main knobs |
RoundSmallBlackKnob |
Secondary/CV amount knobs |
RoundBlackSnapKnob |
Stepped selection knobs |
Trimpot |
Small trim pots (attenuverters) |
Davies1900hLargeBlackKnob |
Vintage-style knobs |
PJ301MPort |
Standard jacks (in/out) |
ThemedPJ301MPort |
Theme-aware jacks (dark/light) |
CKSS |
2-position toggle |
CKSSThree |
3-position toggle |
LEDButton |
Illuminated button |
ScrewSilver / ScrewBlack |
Panel screws |
ThemedScrew |
Theme-aware screws |
Context Menus
void appendContextMenu(Menu* menu) override {
auto* m = dynamic_cast<MyModule*>(module);
if (!m) return;
menu->addChild(new MenuSeparator());
menu->addChild(createMenuItem("Action", "", [=]() { m->doSomething(); }));
menu->addChild(createBoolMenuItem("Toggle", "",
[=]() { return m->getState(); },
[=](bool v) { m->setState(v); }));
}
Serialization (Saving/Loading State)
Parameters are auto-saved. Only serialize custom state:
json_t* dataToJson() override {
json_t* root = json_object();
json_object_set_new(root, "filePath", json_string(filePath.c_str()));
json_object_set_new(root, "mode", json_integer(mode));
json_object_set_new(root, "enabled", json_boolean(enabled));
return root;
}
void dataFromJson(json_t* root) override {
json_t* j = json_object_get(root, "filePath");
if (j) filePath = json_string_value(j);
json_t* m = json_object_get(root, "mode");
if (m) mode = json_integer_value(m);
}
Async Loading (for file I/O, heavy DSP init)
Never block the audio thread. Use this pattern for file loading:
std::thread loadThread;
std::atomic<bool> isLoading{false};
std::atomic<bool> hasPending{false};
void loadFile(const std::string& path) {
if (isLoading) return;
if (loadThread.joinable()) loadThread.join();
isLoading = true;
loadThread = std::thread([this, path]() {
// Heavy work here (file I/O, model loading, etc.)
hasPending.store(true, std::memory_order_release);
isLoading = false;
});
}
// In process():
if (hasPending.exchange(false, std::memory_order_acq_rel)) {
// Apply loaded data on audio thread
}
~MyModule() {
if (loadThread.joinable()) loadThread.join();
}
Polyphony
Support 16 channels max. Organize per-channel state into structs:
struct Engine {
float phase = 0.f;
dsp::BiquadFilter filter;
};
Engine engines[16];
void process(const ProcessArgs& args) {
int channels = std::max(1, inputs[AUDIO_INPUT].getChannels());
for (int c = 0; c < channels; c++) {
float in = inputs[AUDIO_INPUT].getPolyVoltage(c) / 5.f;
float out = engines[c].process(in);
outputs[AUDIO_OUTPUT].setVoltage(out * 5.f, c);
}
outputs[AUDIO_OUTPUT].setChannels(channels);
}
Expanders (Inter-Module Communication)
Use double-buffered expander messages for thread-safe communication between adjacent modules:
struct ExpanderMessage {
float audioL, audioR;
bool connected;
};
// Host module constructor:
rightExpander.producerMessage = new ExpanderMessage();
rightExpander.consumerMessage = new ExpanderMessage();
// Host process():
if (rightExpander.module && rightExpander.module->model == modelMyExpander) {
auto* msg = static_cast<ExpanderMessage*>(rightExpander.consumerMessage);
// Read from expander
}
Makefile Pattern
RACK_DIR ?= dep/Rack-SDK
FLAGS += -Isrc
CXXFLAGS +=
LDFLAGS +=
SOURCES += $(wildcard src/*.cpp)
DISTRIBUTABLES += res
DISTRIBUTABLES += $(wildcard LICENSE*)
include $(RACK_DIR)/plugin.mk
Add subdirectories: SOURCES += $(wildcard src/dsp/*.cpp)
Add presets: DISTRIBUTABLES += presets
DSP Architecture: Separate Concerns
Keep Rack glue separate from DSP. Your DSP classes should not include rack.hpp:
src/
MyModule.cpp # Rack Module — reads params/inputs, calls DSP, writes outputs
dsp/
MyDspEngine.h # Pure DSP — no Rack dependency
filters.h
oscillator.h
The Module is a thin wrapper that:
- Reads Rack params and inputs (voltage domain)
- Converts to normalized values (/ 5.f for audio)
- Calls DSP engine methods
- Converts back to voltage (* 5.f) and writes outputs
Build and Release
Local Build
RACK_DIR=./dep/Rack-SDK make dist # Build .vcvplugin package
RACK_DIR=./dep/Rack-SDK make install # Install to Rack
Cross-Compilation (all platforms)
If using rack-plugin-toolchain with Docker:
docker run --rm -v $(pwd):/plugin rack-plugin-toolchain:19 make plugin-build PLUGIN_DIR=/plugin
GitHub Actions CI/CD
See references/ci-cd.md for a complete workflow that builds for all 4 targets (lin-x64, win-x64, mac-x64, mac-arm64) and publishes releases on tag.
Tag releases: git tag v2.0.0 && git push --tags (tag must match version in plugin.json).
Use the Rack SDK — Don't Reinvent Utilities
The Rack SDK provides a rich set of utility functions. Always use these instead of writing your own. Agents commonly make the mistake of re-implementing basic utilities that already exist in the SDK.
Math/Utility functions available in rack.hpp (namespace rack or rack::dsp):
clamp(value, min, max)— clamps a value to range. Do NOT write your own clamp.rescale(value, min, max, outMin, outMax)— maps a value from one range to anothermath::clamp(),math::rescale(),math::normalize()— inrack::mathnamespacemath::interpolateLinear(),math::interpolateLog()— interpolation helpersdsp::quadraticBipolar(),dsp::cubic(),dsp::exponentialBipolar()— parameter curvesdsp::amplitudeToDb(),dsp::dbToAmplitude()— dB conversiondsp::FREQ_C4(261.6256 Hz),dsp::FREQ_A4(440 Hz) — standard frequenciesdsp::exp2_taylor5()— fast 2^x approximation for pitch conversiondsp::approxSin(),dsp::approxCos()— fast trig approximationsdsp::SchmittTrigger,dsp::PulseGenerator,dsp::Timer— digital utilitiesdsp::BiquadFilter,dsp::RCFilter,dsp::SlewLimiter,dsp::ExponentialFilter— filtersdsp::minBlep— anti-aliasing for oscillator discontinuities
String/formatting:
string::f()— sprintf-like formatting returning std::stringstring::toLowerCase(),string::trim(),string::truncate()— string utils
Always check the SDK API before writing utility code. See references/rack-sdk-api.md for the full reference, or read the SDK headers in dep/Rack-SDK/include/.
Common Pitfalls
- No spaces in paths — the Makefile build system cannot handle them
- Text in SVGs must be paths — Rack's renderer doesn't support fonts
- Don't store font/image references across frames — OpenGL context may be destroyed; load each frame via
APP->window->loadFont() - Don't hardcode sample rate — use
args.sampleTime - Don't allocate in process() — no new/malloc, no STL container growth
- Don't block in process() — no file I/O, no mutex locks, no network
- Don't hard-clip outputs — let downstream modules handle saturation
- Don't write your own clamp/rescale — use
clamp()andrescale()from the SDK - Slug must never change — it's the permanent identifier
Reference Files
Load these when you need deeper detail on a specific topic:
references/manifest-reference.md— Full plugin.json schema with all fields and module tagsreferences/module-template.md— Complete copy-paste module template with all optional featuresreferences/dsp-patterns.md— DSP cookbook: oscillators, filters, envelopes, triggersreferences/panel-design.md— SVG panel creation with Inkscape, component placement, dark themereferences/component-library.md— Full catalog of 60+ built-in UI components (knobs, ports, switches, etc.)references/ci-cd.md— GitHub Actions workflow for multi-platform buildsreferences/rack-sdk-api.md— Key SDK API reference (engine, dsp, app, widget namespaces)references/testing.md— DSP unit testing: test framework, mock strategies, coverage, performance benchmarks