What You Must Build to Replace JUCE with iPlug2 + Dear ImGui + NanoVG + Faust
JUCE is a monolithic audio plugin framework covering plugin-format entry points, parameter management, state serialization, GUI, threading, and build distribution in a single integrated library. Replacing it with iPlug2 + Dear ImGui + NanoVG + Faust is architecturally viable but incomplete: the four-library stack covers approximately 60% of JUCE's functional surface. The remaining 40% must be built as a thin custom framework layer — eleven purpose-built components, totaling about 24 developer-days — that bridges the gaps cleanly without reimplementing what the stack already provides.
Executive summary
The combined stack's strengths are real: iPlug2 provides first-class VST3/AU/AAX/CLAP format entry points, a complete audio callback architecture, basic parameter declaration, and binary state serialization. Dear ImGui provides a fast, zero-dependency immediate-mode UI layer. NanoVG provides production-quality 2D vector rendering. Faust provides a safe, mathematically-grounded DSP language with C++ code generation.
The gaps cluster around four areas: (1) parameter lifecycle — unified DAW automation recording, undo/redo, and XML/named state schema; (2) MIDI — per-sample MidiBuffer and MPE support; (3) threading — a lock-free audio-to-GUI communication bus; and (4) build infrastructure — multi-format manifest generation and code-signing integration.
This article calls the thin layer wrapping the stack the alternative stack — eleven bridge components living in a glue/ tree adjacent to the existing thirdparty/ tree. It is not a replacement for any of the four libraries; it is the mortar between them. A developer using it still writes iPlug2, writes Faust .dsp, and writes Dear ImGui draw calls — the glue components just wire those three things together correctly.
Architecture overview
The stack forms six distinct horizontal layers. Each layer has a clear owner and can be replaced independently.
┌─────────────────────────────────────────────────────────────────────┐
│ L0: Plugin Format Layer │
│ iPlug2 base classes (IPlugVST3 / IPlugAU / IPlugAAX / IPlugCLAP) │
│ Owns: DAW entry-point, plugin lifecycle, parameter declaration │
└──────────────────────────────┬──────────────────────────────────────┘
│ calls ProcessBlock / DrawFrame
┌──────────────────────────────┴──────────────────────────────────────┐
│ L1: DSP Layer │
│ Faust-generated C++ architecture class (compute()) │
│ Owns: signal-flow graph, sample-rate propagation, block processing │
└──────────────────────────────┬──────────────────────────────────────┘
│ sample buffers + MIDI events
┌──────────────────────────────┴──────────────────────────────────────┐
│ L2: Signal Exchange Layer ← glue/core + glue/midi │
│ AudioGuiMessageBus · MidiBufferManager · SampleAccurateEventBuffer │
│ Owns: thread-safe param updates, per-sample MIDI scheduling │
└──────────────────────────────┬──────────────────────────────────────┘
│ param values + state blobs
┌──────────────────────────────┴──────────────────────────────────────┐
│ L3: State & Automation Layer ← glue/core │
│ ParamAutomationBridge · StateSerializer · UndoHistoryManager │
│ Owns: DAW automation recording, preset save/load, undo │
└──────────────────────────────┬──────────────────────────────────────┘
│ draw commands + scale
┌──────────────────────────────┴──────────────────────────────────────┐
│ L4: Rendering Layer │
│ Dear ImGui (immediate-mode) + NanoVG (vector paths) via IGraphics │
│ Owns: GPU-accelerated 2D draw, widget immediate-mode loop │
└──────────────────────────────┬──────────────────────────────────────┘
│ panels + DPI scale
┌──────────────────────────────┴──────────────────────────────────────┐
│ L5: Presentation Layer ← glue/gui + glue/transport │
│ RetainedComponentShell · HiDPIScaleManager · TransportSyncManager │
│ Owns: panel visibility, DPI normalization, host position fields │
└─────────────────────────────────────────────────────────────────────┘
Cross-cutting:
glue/faust: FaustParamBridge — build-time Faust↔IParam sync validation
glue/cmake: PluginManifestBuilder — multi-format targets + code signing
Layer replacement contract: L1 (Faust) can be replaced with a hand-written DSP class without touching any other layer. L4 (ImGui+NanoVG) can be replaced with another IGraphics backend by swapping the rendering target. L0 (iPlug2 format classes) is the one layer with no current open-source alternative that covers all four formats.
Stack capability matrix
| Subsystem | JUCE | iPlug2+ImGui+NanoVG+Faust | Coverage | Gap Component |
|---|---|---|---|---|
| Plugin Format Wrapping | AudioProcessor + format adapters | IPlugVST3 / IPlugAU / IPlugAAX / IPlugCLAP | FULL¹ | — |
| Audio Callback Architecture | processBlock(AudioBuffer, MidiBuffer) | ProcessBlock(sample**, nFrames) | PARTIAL | SampleAccurateEventBuffer |
| Parameter Management | AudioProcessorValueTreeState | IParam (declaration/normalization/display) | PARTIAL | ParamAutomationBridge, UndoHistoryManager |
| State Serialization | ValueTree XML + getStateInformation | IByteChunk binary | PARTIAL | StateSerializer |
| MIDI Handling | MidiBuffer + MidiMessage + MPEInstrument | IMidiMsg + IMidiQueue | PARTIAL | MidiBufferManager |
| GUI Framework | Component hierarchy (retained-mode) | Dear ImGui (immediate-mode) | PARTIAL | RetainedComponentShell |
| Rendering Pipeline | Graphics + HiDPI auto | IGraphicsNanoVG + IGraphicsImGui | PARTIAL | HiDPIScaleManager |
| Host Integration | AudioPlayHead (full position info) | ITimeInfo (basic transport) | PARTIAL | TransportSyncManager |
| Threading Model | AsyncUpdater + MessageManagerLock | none (developer responsibility) | ABSENT | AudioGuiMessageBus |
| Binary Distribution | juce_add_plugin + signing scripts | iPlug2 CMake per-format targets | PARTIAL | PluginManifestBuilder |
¹ Qualified: CLAP extension protocol (note-ports, note-expressions, voice-info) is not covered by IPlugCLAP as of 2024 — see "CLAP extension coverage" below.
Data and control flow
Five distinct signal paths traverse the stack. Each path has a defined owner at every layer boundary.
Audio signal path
Host
└→ ProcessBlock(inputs, outputs, nFrames) [L0 iPlug2]
├→ MidiBufferManager::flush_events_before(n) [L2 glue] ← per-sample MIDI
└→ Faust::compute(inputs, outputs, nFrames) [L1 Faust]
└→ writes outputs[][] → back to Host
Faust's compute() is called once per buffer. The audio thread does not call any GUI code. MIDI events are pre-sorted by sample offset and fired by the glue flush call inside the DSP loop when per-sample accuracy is required.
Parameter change path (user gesture → DAW automation)
User drags knob (GUI thread)
└→ ImGui event detected in DrawFrame [L4 ImGui]
└→ ParamAutomationBridge::begin_gesture(idx) [L3 glue]
└→ ParamAutomationBridge::set_and_notify(idx, v)[L3 glue]
└→ IPlugBase::InformHostOfParamChange() [L0 iPlug2]
└→ Host records automation value → DAW
└→ ParamAutomationBridge::end_gesture(idx) [L3 glue]
The reverse path (DAW playback → GUI sync) crosses the audio/GUI thread boundary:
DAW writes automation (audio thread)
└→ IParam::Set(v) [L0 iPlug2]
└→ AudioGuiMessageBus::push_param_change(idx,v) [L2 glue] ← wait-free
└→ (GUI thread, next DrawFrame)
└→ AudioGuiMessageBus::flush_on_gui() [L2 glue]
└→ ImGui widget reflects new value[L4 ImGui]
MIDI event path
Host calls ProcessMidiMsg(msg) pre-block [L0 iPlug2]
└→ SampleAccurateEventBuffer::push_event(msg, 0) [L2 glue] ← offset 0 (host default)
OR: (CLAP note-expression extension, if supported)
└→ SampleAccurateEventBuffer::push_event(msg, off) [L2 glue] ← real sample offset
MidiBufferManager::update_mpe(msg, offset) [L2 glue] ← MPE state tracking
Inside ProcessBlock DSP loop (per-sample):
└→ MidiBufferManager::flush_events_before(n, cb) [L2 glue]
└→ cb called with (IMidiMsg, sample_offset)
└→ Faust voice trigger / DSP parameter change
State save / load path
Host calls SerializeState() / GetStateInformation() [L0 iPlug2]
└→ StateSerializer::put_double/put_string(key, val) [L3 glue]
└→ StateSerializer::save(IByteChunk&) [L3 glue]
└→ IByteChunk serialized → Host preset file
Host calls UnSerializeState() / SetStateInformation() [L0 iPlug2]
└→ StateSerializer::load(IByteChunk&, start) [L3 glue]
└→ loaded_version() → migration if needed
└→ get_double/get_string(key, default) [L3 glue]
└→ IParam::Set(v) for each parameter [L0 iPlug2]
GUI render path
Host triggers DrawFrame() [L0 iPlug2]
└→ AudioGuiMessageBus::flush_on_gui() [L2 glue] ← param sync first
└→ HiDPIScaleManager::sync_imgui_scale() [L5 glue]
└→ HiDPIScaleManager::sync_nanovg_frame(vg, w, h) [L5 glue]
└→ ImGui::NewFrame() [L4 ImGui]
└→ RetainedComponentShell::draw_all(g) [L5 glue]
└→ Panel::draw(g) per visible panel [developer code]
├→ ImGui widgets (knobs, sliders, buttons)
└→ NanoVG paths (custom rendering)
└→ ImGui::Render() [L4 ImGui]
Subsystem gap analysis
1. Plugin Format Wrapping
JUCE provides: AudioProcessor base class + VST3ClientExtensions, JucePlugin_Build_VST3 CMake macros, AU/AAX/CLAP adapters, moduleinfo.json generation.
The stack provides: IPlugVST3 (full VST3 component+controller), IPlugAU (AUv2/AUv3), IPlugAAX, IPlugCLAP (CLAP factory/descriptor). CMake targets iPlug2_VST3, iPlug2_CLAP, etc.
Coverage: FULL
All four major plugin formats are covered by dedicated iPlug2 base classes. No additional component required for core format wrapping. (CLAP extension protocol — note-ports, note-expressions — is not covered by IPlugCLAP; see the dedicated note below.)
2. Audio Callback Architecture
JUCE provides: processBlock(AudioBuffer&, MidiBuffer&) — MidiBuffer carries per-sample-offset events.
The stack provides: virtual void ProcessBlock(sample** inputs, sample** outputs, int nFrames). MIDI via virtual void ProcessMidiMsg(const IMidiMsg& msg) before the block (no per-sample offset).
Coverage: PARTIAL
Component: SampleAccurateEventBuffer
Replaces: JUCE MidiBuffer per-sample event timestamping
API surface sketch:
void push_event(const IMidiMsg& msg, int sample_offset);
void push_sysex(const ISysEx& sysex, int sample_offset);
void flush_events_before(int sample_offset,
std::function cb);
void clear();
int pending_count() const;
bool empty() const;
Implementation complexity: LOW
Dependencies: iPlug2 IMidiMsg, ISysEx
3. Parameter Management
JUCE provides: AudioProcessorValueTreeState (APVTS): atomic parameter creation, automatic DAW automation binding, XML state, ValueTree observer, undo/redo, thread-safe access.
The stack provides: IParam (declaration, normalization, display). InformHostOfParamChange() must be called manually. No undo stack, no XML state, no unified automation layer.
Coverage: PARTIAL
Component: ParamAutomationBridge
Replaces: APVTS automation recording (beginChangeGesture/endChangeGesture/setValueNotifyingHost)
API surface sketch:
explicit ParamAutomationBridge(IPlugBase* plug);
void begin_gesture(int param_idx);
void end_gesture(int param_idx);
void set_and_notify(int param_idx, double normalized_value);
void register_change_listener(int param_idx, std::function cb);
void serialize_to_xml(tinyxml2::XMLDocument& doc) const;
void deserialize_from_xml(const tinyxml2::XMLDocument& doc);
Implementation complexity: MEDIUM
Dependencies: iPlug2 IParam, IPlugBase::InformHostOfParamChange
Component: UndoHistoryManager
Replaces: JUCE UndoManager integrated with APVTS
API surface sketch:
void begin_action(const std::string& label);
void record_param_change(int param_idx, double old_val, double new_val);
void commit_action();
bool undo();
bool redo();
void clear();
Implementation complexity: LOW
Dependencies: iPlug2 IParam, ParamAutomationBridge
4. State Serialization
JUCE provides: ValueTree XML encode/decode, getStateInformation auto-serializes as XML, version-tagged state, sub-trees for grouped parameters.
The stack provides: IByteChunk raw binary buffer with PutDouble/GetDouble/PutStr/GetStr. No XML schema, no version token, no named fields.
Coverage: PARTIAL
Component: StateSerializer
Replaces: JUCE ValueTree XML serialization + getStateInformation/setStateInformation
API surface sketch:
StateSerializer(int version);
void put_double(const std::string& key, double value);
void put_string(const std::string& key, const std::string& value);
void put_int(const std::string& key, int value);
double get_double(const std::string& key, double default_val) const;
std::string get_string(const std::string& key, const std::string& def) const;
int get_int(const std::string& key, int default_val) const;
void save(IByteChunk& chunk) const;
bool load(const IByteChunk& chunk, int start_pos);
int loaded_version() const;
Implementation complexity: MEDIUM
Dependencies: iPlug2 IByteChunk; tinyxml2 or nlohmann::json (interface is format-agnostic)
5. MIDI Handling
JUCE provides: MidiBuffer (per-sample-accurate), MidiMessage (full spec including sysex), MPEInstrument (per-note pitchbend/pressure/timbre), MidiKeyboardState, MidiRPNGenerator.
The stack provides: IMidiMsg (Status enum + Channel/Data1/Data2). ProcessMidiMsg() pre-block, one call per message. No per-sample offsets. No MPE tracking. ISysEx basic container.
Coverage: PARTIAL
Component: MidiBufferManager
Replaces: JUCE MidiBuffer + MPEInstrument
API surface sketch:
void push_message(const IMidiMsg& msg, int sample_offset);
void push_sysex(const ISysEx& sysex, int sample_offset);
void iterate(int start, int end,
std::function note_cb,
std::function sysex_cb) const;
void clear_before(int sample_offset);
struct MPENote { int channel; float pitch_bend; float pressure; float timbre; bool active; };
MPENote get_mpe_note(int channel) const;
void update_mpe(const IMidiMsg& msg, int sample_offset);
bool is_mpe_active() const;
Implementation complexity: HIGH
Dependencies: iPlug2 IMidiMsg, ISysEx, SampleAccurateEventBuffer
6. GUI Framework
JUCE provides: juce::Component hierarchy (retained-mode, addChildComponent, setBounds, setVisible), MouseListener, full widget set, accessibility, keyboard navigation, FlexBox/Grid layout.
The stack provides: Dear ImGui (immediate-mode: no retained tree, all state held by application). iPlug2's IControl (retained NanoVG) cannot be mixed with IGraphicsImGui.
Coverage: PARTIAL
Component: RetainedComponentShell
Replaces: JUCE Component lifecycle for complex multi-panel plugin UIs
API surface sketch:
struct Panel {
std::string id;
bool visible = true;
virtual void draw(IGraphics* g) = 0;
virtual void on_show() {}
virtual void on_hide() {}
virtual ~Panel() = default;
};
void add_panel(std::unique_ptr panel);
void show_panel(const std::string& id);
void hide_panel(const std::string& id);
Panel* get_panel(const std::string& id) const;
void draw_all(IGraphics* g);
Implementation complexity: MEDIUM
Dependencies: Dear ImGui, iPlug2 IGraphics
7. Rendering Pipeline (HiDPI/Retina scaling)
JUCE provides: Automatic HiDPI scaling, per-monitor DPI notifications (ComponentPeer::handleDPIChange()), automatic Graphics coordinate scaling.
The stack provides: IGraphics::GetDrawScale(). nvgBeginFrame must receive this value. ImGui::GetIO().DisplayFramebufferScale must be manually set. Per-monitor DPI (Windows PMv2) not automatically handled.
Coverage: PARTIAL
Component: HiDPIScaleManager
Replaces: JUCE automatic per-monitor DPI scaling for ImGui + NanoVG backends
API surface sketch:
void attach(IGraphics* graphics);
float get_draw_scale() const;
void sync_imgui_scale();
void sync_nanovg_frame(NVGcontext* vg, int fb_width, int fb_height);
void on_dpi_change(float new_scale);
Implementation complexity: LOW
Dependencies: iPlug2 IGraphics, Dear ImGui io, NanoVG nvgBeginFrame
8. Host Integration
JUCE provides: AudioPlayHead::PositionInfo: timeInSeconds, ppqPosition, bpm, timeSigNumerator/Denominator, ppqPositionOfLastBarStart, hostTimeNs, barsBeats (pre-computed bar+beat+tick).
The stack provides: ITimeInfo: mTempo, mPPQPos, mSamplePos, mNumerator, mDenominator, mTransportIsRunning/Looping. Missing: hostTimeNs, ppqPositionOfLastBarStart, barsBeats.
Coverage: PARTIAL
Component: TransportSyncManager
Replaces: JUCE AudioPlayHead extended position fields
API surface sketch:
struct Position {
double bpm;
double ppq_pos;
int64_t sample_pos;
int numerator, denominator;
bool is_playing, is_looping;
double ppq_last_bar_start;
struct { int bar; int beat; double tick; } bars_beats;
};
static Position compute(const ITimeInfo& t);
static double ppq_to_bar_start(double ppq, int num, int denom);
static void decompose_ppq(double ppq, int num, int denom,
int& bar, int& beat, double& tick);
Implementation complexity: LOW
Dependencies: iPlug2 ITimeInfo (pure computation, no external deps)
9. Threading Model
JUCE provides: MessageManager, MessageManagerLock, AsyncUpdater (coalescing audio→GUI notifications), ChangeBroadcaster/ChangeListener, juce::AbstractFifo, thread-checking assertions.
The stack provides: None of the above in the public API.
Coverage: ABSENT
Component: AudioGuiMessageBus
Replaces: JUCE AsyncUpdater + MessageManagerLock for audio→GUI parameter updates
API surface sketch:
struct ParamChangeEvent { int param_idx; double normalized; };
AudioGuiMessageBus(int capacity = 1024);
// Call from audio thread (wait-free):
void push_param_change(int param_idx, double normalized);
// Call from GUI draw thread:
void flush_on_gui(std::function cb);
bool pop_param_change(int& param_idx, double& normalized);
template void push(T value);
template bool pop(T& out);
Implementation complexity: MEDIUM
Dependencies: moodycamel::ConcurrentQueue (or std::atomic ring buffer)
Note: Some DAW hosts guarantee non-concurrent audio/GUI scheduling as an
optimization, but this is not in the plugin format specification.
AudioGuiMessageBus provides format-compliant thread safety across all hosts.
10. Binary Distribution
JUCE provides: juce_add_plugin CMake (multi-format targets, bundle structure, moduleinfo.json), CodeSign.cmake (macOS codesign + staple + notarize), Windows signtool integration, AAX PACE Wraptool integration.
The stack provides: Per-format CMake targets, PostBuildMacOS.cmake (basic bundle structure). No AAX PACE signing. No macOS notarization workflow. No single multi-format generator.
Coverage: PARTIAL — platform-specific:
- macOS:
codesign --deep --strict --timestamp+staple+notarytoolrequired for Gatekeeper. Apple Developer ID required. - Windows: signtool with EV code signing certificate. AAX additionally requires PACE Wraptool (paid AVID license).
- Linux: no signing standard; CLAP/VST3 discovered by the scanner. (Major Linux DAW scanner behavior is worth confirming for your targets.)
Component: PluginManifestBuilder
Replaces: JUCE juce_add_plugin CMake function + signing integration
API surface sketch (CMake):
add_plugin(
TARGET my_plugin
FORMATS VST3 AU AAX CLAP
PLUGIN_NAME "My Plugin"
PLUGIN_MANUFACTURER "My Studio"
PLUGIN_VERSION "1.0.0"
PLUGIN_ID "Mstu/MyPl"
IS_INSTRUMENT OFF
[AAX_PACE_WRAPTOOL_PATH "/path/to/wraptool"]
[CODESIGN_IDENTITY "Developer ID Application: ..."]
[NOTARIZE ON]
)
Implementation complexity: LOW
Dependencies: CMake 3.21+, xcode-select (macOS), signtool (Windows)
Design strengths
Battle-tested format adapters. iPlug2's IPlugVST3, IPlugAU, IPlugAAX, and IPlugCLAP classes are the product of roughly 15 years of production plugin development. Format compliance and host compatibility are inherited from a library maintained by plugin professionals, not a developer-owned problem.
Faust's formal DSP semantics eliminate entire classes of bugs. The block-diagram semantics enforce correct sample-rate propagation, buffer-size independence, and signal routing at the language level. Aliased write-backs between input and output buffers (a common processBlock bug in JUCE), incorrect sample-rate scaling, and feedback loop formation are compile-time or formalizer-time catches rather than runtime defects. No other DSP layer in the comparison provides this guarantee.
Zero-dependency immediate-mode rendering. Dear ImGui's imgui.h + imgui.cpp compile into ~100KB of portable C++ with no external dependencies. Build iteration for UI changes is trivially fast, and iPlug2's IGraphicsImGui handles backend wiring. The immediate-mode model also eliminates an entire category of retained-tree consistency bugs.
NanoVG rendering quality is production-grade. NanoVG's GPU-accelerated Bézier paths with sub-pixel anti-aliasing and font atlas rendering are visually indistinguishable from native 2D rendering on all three platforms. The library is used in production audio UIs (Surge Synthesizer, VCV Rack) and adds ~120KB to the binary.
Clean layer separation. The stack's four libraries have no mutual dependencies. Faust has no knowledge of ImGui; ImGui has no knowledge of iPlug2; iPlug2 doesn't know what Faust is. Any layer can be replaced: a developer who outgrows Faust can substitute a hand-written DSP class without touching the GUI layer; a developer who wants a custom rendering backend can replace NanoVG without touching the format layer.
Permissive licensing. iPlug2 (permissive zlib-style license), Dear ImGui (MIT), NanoVG (zlib), Faust (LGPL v2.1+). No commercial license cost. JUCE requires a paid license for commercial products (JUCE Indie/Pro tiers). For a solo developer or small studio, this is a meaningful cost difference.
A novel FaustParamBridge provides a compile-time correctness guarantee JUCE cannot offer. JUCE's APVTS has no mechanism to validate that parameter names in the XML state match the DSP parameter names. FaustParamBridge's faust2bridge.py build script generates the bridge constructor from the .dsp source — a rename in the DSP becomes a build error, not a silent wrong-parameter-drives-wrong-DSP-zone bug.
Design risks and smells
These are architectural concerns — structural properties of the design that create ongoing maintenance risk or correctness hazards. They are distinct from the runtime failure modes below.
Four-idiom cognitive load. A developer must internalize four different library idioms simultaneously: iPlug2's preprocessor macro config (PLUG_NAME, PLUG_MFR, MAX_NIN), Dear ImGui's stateless Begin/End scope pattern, NanoVG's nvgBeginPath/Fill immediate path API, and Faust's architecture wrapper pattern. There is no single unified developer guide that bridges all four. This is the primary onboarding risk for new contributors.
iPlug2's preprocessor macro configuration is a maintenance smell. Plugin identity and channel count are configured via #define macros in config.h (e.g. #define PLUG_NAME "My Plugin", #define MAX_NIN 2). These are compile-time constants, not runtime-configurable, and they are scattered across a single flat header rather than expressed in the CMake build system. The add_plugin() macro should generate config.h from CMake variables at configure-time, wrapping this smell in a single authoritative source.
AudioGuiMessageBus initialization-order risk in multi-plugin host processes. Pro Tools and some other DAWs load multiple plugin instances in a single process. AudioGuiMessageBus must be per-instance (not static) for correct behavior; a static/singleton implementation would corrupt parameter state across instances. The API sketch (constructed per instance) is correct, but the risk must be enforced by convention — there is no runtime check for this invariant.
RetainedComponentShell does not provide mouse hit-testing, event bubbling, or accessibility. iPlug2's IControl provides a hit-test tree; Dear ImGui handles its own mouse/keyboard routing internally. RetainedComponentShell bridges panel-level visibility but leaves the gap between Panel::draw() and interactive ImGui widgets unstructured. A plugin with overlapping interactive regions needs the developer to manage z-order and hit-testing manually.
No GUI regression testing path. Dear ImGui and NanoVG do not ship an offline (headless) rendering mode for automated testing. There is no standard mechanism to render a frame to an image buffer without a GPU context. UI regression testing will require either custom headless scaffolding (Mesa software rendering + a null iPlug2 backend) or visual diff testing under a real host. This gap is shared with JUCE (which also has no standard GUI test harness), but it is worth acknowledging.
Faust↔IParam manual sync is an ongoing operational risk. FaustParamBridge catches renames at build time. It does NOT automatically handle parameter reordering (if two sliders swap index positions in the Faust graph, the bridge still compiles but the IParam bindings swap silently). The code generator must also be kept in sync with the installed Faust version's UI descriptor format — a Faust version bump may change the descriptor schema and silently break the generator.
Failure-first discovery
If iPlug2 + Dear ImGui + NanoVG + Faust were shipped today with zero glue code, these failure modes occur in priority order:
Automation recording broken in every DAW.
Failure: No automation data recorded when the user moves a parameter knob.
Root cause: IParam::Set() does not call InformHostOfParamChange() / does not trigger beginChangeGesture. Manual call required.
Addressed by: ParamAutomationBridge
Preset save/load corrupts on version update. Failure: Adding a parameter in v1.1 corrupts all v1.0 presets (binary offset mismatch). Root cause: IByteChunk has no version header, no named fields. Addressed by: StateSerializer
MPE controllers produce wrong sound. Failure: Linnstrument / Roli Seaboard per-note pitchbend/pressure arrives as raw CC; no per-note tracking. Root cause: No MPEInstrument equivalent; channel-to-note mapping absent. Addressed by: MidiBufferManager
GUI race condition under load. Failure: ImGui reads a parameter mid-update from the audio thread; torn read or crash on some platforms. Root cause: No lock-free audio→GUI parameter channel. (Some hosts guarantee non-concurrent scheduling, but this is not in the plugin format spec.) Addressed by: AudioGuiMessageBus
Blurry GUI on macOS Retina / Windows high-DPI displays.
Failure: NanoVG framebuffer at logical size produces 50% blur on 2× Retina; ImGui text pixelated.
Root cause: nvgBeginFrame and ImGui io.DisplayFramebufferScale not set to GetDrawScale().
Addressed by: HiDPIScaleManager
Bar-position display broken in step sequencers.
Failure: ppqPositionOfLastBarStart and barsBeats must be reconstructed manually from ITimeInfo; easy to compute incorrectly.
Root cause: ITimeInfo does not pre-compute these fields.
Addressed by: TransportSyncManager
Faust parameter renames silently produce wrong DSP behavior.
Failure: Renaming a Faust slider in the .dsp file breaks the manual IParam mapping silently; wrong parameter drives wrong DSP zone.
Root cause: Faust UI descriptors and IParam declarations manually synchronized with no validation.
Addressed by: FaustParamBridge
AAX plugin cannot be commercially distributed (PACE signing absent). Failure: AAX binary fails the Pro Tools AAX scan without PACE Wraptool signing. Root cause: No PACE Wraptool integration in iPlug2 CMake. Addressed by: PluginManifestBuilder
Per-sample MIDI timing incorrect for virtual instruments. Failure: Note-on at sample 127 (out of 128) fires identically to sample 0; one buffer of incorrect articulation per event. Root cause: ProcessMidiMsg called pre-block with no sample offset. Addressed by: SampleAccurateEventBuffer
Component / module map
| Component | Module | Replaces | Complexity | Est. Days |
|---|---|---|---|---|
| AudioGuiMessageBus | core | JUCE AsyncUpdater + MessageManagerLock | MEDIUM | 2 |
| ParamAutomationBridge | core | JUCE APVTS automation recording | MEDIUM | 3 |
| StateSerializer | core | JUCE ValueTree XML state | MEDIUM | 3 |
| UndoHistoryManager | core | JUCE UndoManager in APVTS | LOW | 1 |
| SampleAccurateEventBuffer | midi | JUCE MidiBuffer timestamping | LOW | 1 |
| MidiBufferManager | midi | JUCE MidiBuffer + MPEInstrument | HIGH | 5 |
| HiDPIScaleManager | gui | JUCE per-monitor DPI auto-scaling | LOW | 1 |
| RetainedComponentShell | gui | JUCE Component lifecycle | MEDIUM | 3 |
| TransportSyncManager | transport | JUCE AudioPlayHead extended fields | LOW | 1 |
| FaustParamBridge | faust | (novel — no JUCE equivalent) | LOW | 2 |
| PluginManifestBuilder | cmake | JUCE juce_add_plugin + signing | LOW | 2 |
Total: 5 LOW + 4 MEDIUM + 1 HIGH = 11 components, about 24 developer-days.
Dependencies and coupling
The glue layer's internal dependency graph (direct glue-to-glue dependencies only):
SampleAccurateEventBuffer ──────────────────────────────→ (no glue deps)
ParamAutomationBridge ──────────────────────────────→ (no glue deps)
StateSerializer ──────────────────────────────→ (no glue deps)
AudioGuiMessageBus ──────────────────────────────→ (no glue deps)
HiDPIScaleManager ──────────────────────────────→ (no glue deps)
TransportSyncManager ──────────────────────────────→ (no glue deps)
RetainedComponentShell ──────────────────────────────→ (no glue deps)
PluginManifestBuilder ──────────────────────────────→ (no glue deps)
MidiBufferManager ──────────────────────────────→ SampleAccurateEventBuffer
UndoHistoryManager ──────────────────────────────→ ParamAutomationBridge
FaustParamBridge ──────────────────────────────→ ParamAutomationBridge
- 8 of 11 components are standalone (zero internal glue dependencies). They depend only on their respective stack libraries — a low-coupling design where most components can be adopted individually.
- SampleAccurateEventBuffer and ParamAutomationBridge are root nodes — the correct starting point for implementation.
- No circular dependencies. The graph is a DAG with maximum depth 2.
- AudioGuiMessageBus is architecturally load-bearing despite having no glue deps. Every audio→GUI parameter update path must cross it. Correctness failures here propagate silently (torn reads, missed updates) and are hard to reproduce in automated tests; treat it as the highest-fidelity component in the stack.
- FaustParamBridge creates a build-time coupling (via the code generator) between the Faust DSP source and the IParam table. This is intentional — it converts a runtime coupling hazard into a build-time correctness check.
Recommendations
Define the public API surface before implementing. Write each component's .h header file first, review it, lock it, then write .cpp. This keeps the API surface small and prevents implementation details from leaking into the interface.
Prototype AudioGuiMessageBus under adversarial conditions before integrating. Three test scenarios before shipping: (1) audio thread at 96kHz/32-sample saturating the ring buffer; (2) GUI thread starvation (60Hz draw vs. 1000Hz audio); (3) two plugin instances in the same DAW process. Lock-free data structures fail in non-obvious ways at queue-full conditions. A prototype that fails these tests is better than a production integration that corrupts parameters.
Use Faust's faust2api code generator, not raw faust.h inclusion. faust --lang cpp --classname MyDSP generates a self-contained C++ class with a clean compute(float**, float**, int) interface and a buildUserInterface(UI*) method the bridge generator can parse. This decouples the DSP class from the Faust runtime library.
Adopt CMake FetchContent for the four stack libraries. Git submodules introduce state complexity (detached HEADs, forgotten --recursive, partial clones). FetchContent_Declare with a pinned GIT_TAG achieves the same result with less maintenance overhead and better CI reproducibility — the version pin is visible in CMakeLists.txt rather than hidden in .gitmodules.
Wire the bridge generator as a CMake add_custom_command. The bridge generator must run before the C++ compile step. Adding it to CMake ensures DSP changes always regenerate the bridge. Place the generated file in ${CMAKE_BINARY_DIR}/generated/ (not the source tree) to avoid spurious git diffs.
Treat iPlug2's config.h macros as generated output, not hand-edited source. Write a CMake configure template (config.h.in) that emits all PLUG_* and MAX_N* macros from add_plugin() arguments. This eliminates the maintenance smell above and keeps config.h values consistent with the CMake build definition.
Implementation roadmap
Ordered by failure-mode severity (P0 first):
| Priority | Step | Component | Complexity | Est. Days |
|---|---|---|---|---|
| P0 | 1 | AudioGuiMessageBus | MEDIUM | 2 |
| P0 | 2 | ParamAutomationBridge | MEDIUM | 3 |
| P0 | 3 | StateSerializer | MEDIUM | 3 |
| P1 | 4 | SampleAccurateEventBuffer | LOW | 1 |
| P1 | 5 | MidiBufferManager | HIGH | 5 |
| P1 | 6 | HiDPIScaleManager | LOW | 1 |
| P1 | 7 | TransportSyncManager | LOW | 1 |
| P1 | 8 | FaustParamBridge | LOW | 2 |
| P1 | 9 | PluginManifestBuilder | LOW | 2 |
| P2 | 10 | RetainedComponentShell | MEDIUM | 3 |
| P2 | 11 | UndoHistoryManager | LOW | 1 |
Total: about 24 developer-days for a v1.0 glue layer.
- P0 (steps 1–3): The core parameter lifecycle. Must work before any plugin ships. AudioGuiMessageBus first because it underpins all parameter-change paths.
- P1 (steps 4–9): Correctness and distribution. Build in dependency order: SampleAccurateEventBuffer before MidiBufferManager.
- P2 (steps 10–11): Developer-experience improvements for complex plugin UIs. Can be deferred.
Next steps
- Scaffold the CMake structure (0.5 days): create the CMake plugin function, wire
FetchContentfor all four stack libraries at pinned tags, verify a hello-plugin compiles on macOS + Linux with at least VST3 and CLAP targets. - Prototype AudioGuiMessageBus (2 days): implement with
moodycamel::ConcurrentQueueas the backing store. Run the three adversarial scenarios above. Do not proceed to ParamAutomationBridge until this component is confirmed wait-free under saturation. - Implement ParamAutomationBridge against the scaffold (3 days): target one parameter automating correctly in Ableton Live and Logic Pro. Validation: record an automation lane, play it back, verify the parameter follows within ±0.001 of the recorded value.
- Implement StateSerializer (3 days): target round-trip correctness for 10 parameters, with a deliberate version-mismatch test (v1.0 state loaded in a v1.1 plugin with one added parameter should use the declared default, not crash or produce garbage).
- Reassess MidiBufferManager scope: after SampleAccurateEventBuffer (1 day), run a step-sequencer timing test. If per-sample accuracy is confirmed, evaluate whether full MPE support (the HIGH-complexity component) is required for the first plugin target. Deferring MPE saves about 4 developer-days and can be added later without breaking the API surface.
A note on CLAP extension coverage
iPlug2's IPlugCLAP covers the core CLAP plugin factory and descriptor (the minimum required for DAW recognition and audio I/O), plus the audio-ports, note-ports, and basic params extensions. The CLAP extension protocol — note-expressions, voice-info, remote-controls, tuning, and timeline extensions — is not implemented by IPlugCLAP as of 2024.
Practical impact: standard audio-effect and simple instrument CLAP plugins are fully covered. Polyphonic expression instruments (MPE-style per-note expressions via the CLAP note-expressions extension) require either the CLAP extension directly or MidiBufferManager's MPE tracking as a fallback workaround.
Further reading
Canonical project documentation for the four-library stack:
- iPlug2 — cross-platform C++ audio plug-in framework (format entry points, IGraphics). https://github.com/iPlug2/iPlug2
- Dear ImGui — immediate-mode GUI library for C++. https://github.com/ocornut/imgui
- NanoVG — antialiased 2D vector graphics rendering library for OpenGL. https://github.com/memononen/nanovg
- Faust — functional language for sound synthesis and audio processing (GRAME-CNCM). https://faust.grame.fr/
- CLAP — Clever Audio Plugin standard (interface and extensions). https://github.com/free-audio/clap