Multi-Plugin Bundles¶
A bundle packages many distinct plugins into ONE binary per format — a
single .clap / .vst3 that a host scans once and lists as N plugins (the
shape Expert Sleepers' Silent Way suite uses). Pulp supports this without
regressing the single-plugin path: one plugin per binary stays the default (see
opinionated defaults),
and bundling is opt-in.
When to bundle¶
- You ship a suite of related plugins and want one install, one scan entry, one code-signed artifact (e.g. a control-voltage toolkit, a channel-strip set).
- You want the plugins to share code/assets in a single binary.
If you ship one plugin, use pulp_add_plugin
— don't reach for a bundle.
Anatomy¶
A bundle target is declared with
pulp_add_plugin_bundle. Each
format gets one entry translation unit that registers every plugin via the
bundle macros. The format build functions automatically prefer a
<fmt>_bundle_entry.cpp over the single-plugin <fmt>_entry.cpp.
CLAP — clap_bundle_entry.cpp¶
One module entry symbol; its factory enumerates every plugin.
#include "my_plugins.hpp"
#include <pulp/format/clap_entry.hpp>
PULP_CLAP_BUNDLE_PLUGIN(Gain, my::create_gain)
PULP_CLAP_BUNDLE_PLUGIN(Width, my::create_width)
PULP_CLAP_BUNDLE_ENTRY() // exactly one, after the plugins
Each plugin's id/name/category come from its PluginDescriptor. Every plugin
needs a unique bundle_id; create_plugin(id) resolves by id, and a duplicate
id is dropped from enumeration rather than advertised-but-unreachable.
VST3 — vst3_bundle_entry.cpp¶
One GetPluginFactory(); it lists one class per plugin.
#include "my_plugins.hpp"
#include <pulp/format/vst3_entry.hpp>
static const Steinberg::FUID kGainUID(0x…, 0x…, 0x…, 0x…); // stable, unique
static const Steinberg::FUID kWidthUID(0x…, 0x…, 0x…, 0x…);
PULP_VST3_BUNDLE_PLUGIN(Gain, my::create_gain, {.id = "com.you.suite.gain"})
PULP_VST3_BUNDLE_PLUGIN(Width, my::create_width, {.id = "com.you.suite.width"})
PULP_VST3_FACTORY_BEGIN("You", "https://you.example", "mailto:support@you.example")
PULP_VST3_BUNDLE_CLASS(Gain, kGainUID, "Gain", Steinberg::Vst::PlugType::kFx, "1.0.0")
PULP_VST3_BUNDLE_CLASS(Width, kWidthUID, "Width", Steinberg::Vst::PlugType::kFx, "1.0.0")
PULP_VST3_FACTORY_END
The Ident token (Gain, Width) links each _BUNDLE_PLUGIN to its
_BUNDLE_CLASS at compile time — a mismatch fails to compile, so the pair
cannot silently desync.
CMakeLists.txt¶
pulp_add_plugin_bundle(MySuite
FORMATS CLAP VST3
BUNDLE_NAME "MySuite"
BUNDLE_ID "com.you.suite"
VERSION "1.0.0"
MANUFACTURER "You"
SOURCES gain.cpp width.cpp # omit for header-only plugins
)
A complete, buildable two-plugin example lives in
examples/combined-bundle-demo (header-only plugins, CLAP + VST3), with an
artifact test that dlopens the built .clap and asserts it exposes two plugins.
Format support¶
| Format | Bundles | Notes |
|---|---|---|
| CLAP | ✅ | One clap_entry; factory enumerates N plugins. |
| VST3 | ✅ | One GetPluginFactory; one class per plugin. |
| AU / AUv3 | ⏳ | An AU .component bundle needs a multi-component Info.plist (an AudioComponents array with N entries) and all N AUSDK_COMPONENT_ENTRY factory symbols exported. Tracked as a follow-up slice; requesting AU from pulp_add_plugin_bundle is a configure-time error today rather than a silently-single-plugin binary. |
| AAX | ⏳ | Same multi-component packaging work as AU. |
How it works¶
All plugins register into a shared, keyed registry
(pulp::format::register_plugin(PluginRegistration{…})). Only the bundle macros
publish keyed entries; the single-plugin macros use a legacy global slot and
leave the keyed table empty, so the single-plugin contract is byte-for-byte
unchanged. See the clap,
vst3, and
auv2 skills for the per-format macro
details and gotchas.