VOLTAreference

Reference / Connect and share

Host instrument and effect plugins

Connect and share

Open in the workbench →
On this page · 4 sections

Use installed plugins in the supported desktop host, save useful states, and understand their playback and export requirements.

What it is#

The native desktop backend hosts third-party CLAP, Audio Unit, and VST3 plugins — as track effect inserts or as the track's sound source (instrument) — behind one format-agnostic backend seam. Parameters are addressable by numeric id or display name, set to a constant, automated with a transport-clocked pattern lane, or driven by any control signal (LFOs, envelopes, follower sidechains, and live macro knobs). Format is chosen by the path; parameter values are normalized [0,1] across every format.

Surface#

Four builtins (top-level / track position only):

-- effect insert on a track bus (format dispatched by path):
lead = play pluck !"c3 e3 g3" |> plugin "Valhalla.clap"
comp = play drums beat      |> plugin "/Library/Audio/Plug-Ins/VST3/Comp.vst3"

-- a track whose sound source IS a plugin instrument:
keys = instrument "Vital.clap" !"c3 e3 g3 c4"
bell = instrument "au:aumu:dls :appl" !"c5 e5"      -- Apple DLSMusicDevice

-- select a plugin within a bundle, and set params (by id or by name):
pad  = instrument "Surge.clap" !"c3" |> pluginId "org.surge-synth.surge-xt"
                                     |> pluginParam 12 0.8
                                     |> pluginParam "Filter Cutoff" 0.6

-- a NAMED preset from the workspace store — e.g. a Kontakt patch dialed in
-- once via the editor, saved from the picker's `preset` chip, then loaded
-- by name in any song (state applies first; declared params stay on top):
grand = instrument "Kontakt 8.vst3" !"c3 e3 g3" |> pluginPreset "grand piano"

-- MIDI CC lanes on an instrument — continuous articulation (mod-wheel
-- dynamics CC1, expression CC11, …); constant, pattern lane, or signal,
-- normalized [0,1] like pluginParam:
str  = instrument "Kontakt 8.vst3" pat |> pluginPreset "strings"
                                       |> ccLane 1 (lfoSine @ 4c * 0.3 + 0.55)
vla  = instrument "Kontakt 8.vst3" mel |> ccLane 1 !"0.3 0.5 0.8 0.6"

-- pattern-valued param: a stepwise automation lane on the transport:
acid = play bass !"c2 c2 g2 c2" |> plugin "au:AUDistortion"
                                |> pluginParam "Wet/Dry Mix" !"0.2 0.8 [0.5 1]"

-- signal-valued param: any control expression, block-rate; a macro is a
-- live knob wired straight to the plugin parameter; follower sidechains:
wob  = play bass !"c2 c2 g2 c2" |> plugin "Wobbler.clap"
                                |> pluginParam "Cutoff" (lfoSine @ 1/2c * 0.4 + 0.5)
                                |> pluginParam "Drive"  (macro "drive" 0.3)

Dispatch is by path suffix: .clap → CLAP, .vst3 → VST3, au:type:sub:manu → Audio Unit (the space-padded FourCC triple, e.g. au:aufx:dist:appl). A bare reference ("Kontakt 8.vst3", or even "Kontakt 8" with no extension) resolves against the standard system + user plugin directories at activation (plugin::resolve_plugin_ref, shared with volta autosample --plugin) — song files never need machine paths.

A song may load only installed plugins. Loading a plugin runs its native code, so a path a song names must resolve inside /Library/Audio/Plug-Ins or ~/Library/Audio/Plug-Ins (VST3, CLAP, Components), or a folder listed in the VOLTA_PLUGIN_PATH environment variable (colon-separated, for a plugin developer's build folder — a song cannot set it). Anything else — a path beside a mounted disk image, a relative path, a .. escape — is refused at activation with a message naming the folders (plugin::confine_song_plugin_ref, checked on the resolved path). au: references name a registered component and pass. Operator tools whose path the person typed (volta-pilot, volta autosample --plugin) use PluginHost::trusting_any_path. While the desktop is in a jam room no new plugin activates (PluginHost::set_suspended): the room's text is a peer's to write, and a browser peer could not host one anyway. A pluginParam key is a host-stable numeric id or a case-insensitive name (exact first, else a unique substring match); a missing or ambiguous name warns with candidates and is skipped (never misdirected at id 0).

How it's implemented#

  • Compiler: builtins/plugins.rs — the descriptor family that owns plugin / pluginId / pluginParam / pluginPreset / ccLane: their ports, arities, cadences, and the string-LITERAL operand each of the three name ports takes. instrument is a track-source descriptor in builtins/track_sources.rs. Both lower in lower/track.rs. Plugin hosting is behind cargo features in the ENGINE only; the compiler registers these names unconditionally, so a song's IR does not depend on how the host was built. IR: ir::PluginParam { name, value, pattern, signal }, TrackEffect::Plugin, Track.plugin + the $plugin sentinel synth. A signal-valued param lowers via lower_signal_synth into an anonymous single-out synth graph on PluginParam.signal (the macro-initial const-fold runs after the signal probe, so a macro-valued param stays a live lane).
  • Engine (engine/src/plugin/): the PluginBackend: Send trait (backend.rs) with ParamSpec { id, name, min, max, default }, resolve_param_key, and walk_param_lane; per-format clap_backend.rs / au_backend.rs / vst3_backend.rs; mod.rs dispatches by path. Effects wire through effect.rs::build_plugin_effect; instruments through lib.rs::build_track_instruments. Signal lanes compile to TrackPlan.signal_lanes (graph.rs::from_module) and render as one persistent voice per lane in a dedicated param_arena (a second VoiceArena, so modulation never sums into the audible mix), anchored at transport beat 0. Pattern lanes apply sample-accurately on VST3/CLAP, block-rate (~2.7 ms) on AU. A hot-swap carries live instances over when the same plugin sits at the same track / role / slot (state, editor binding, tails, in-flight notes survive). AU and VST3 instantiate on the calling thread (some AUs assert the main queue); AU registry lookups (AudioComponentFindNext, by-name enumeration), which wait on the AudioComponentRegistrar daemon, run on a helper thread bounded by au_backend.rs::REGISTRY_TIMEOUT (20 s), so a wedged registrar makes the plugin unavailable instead of freezing the load. Behind engine/Cargo.toml feature gates: plugin-host (umbrella) + clap-host / au-host / vst3-host.
  • Live editors, all formats (engine-host/src/plugin_window.rs): AU opens its Cocoa view (custom or AUGenericView); VST3 and CLAP open through the format-agnostic plugin::create_editor_view seam. VST3: a persistent IEditController (single-component cast, or instantiated + IConnectionPoint-connected + setComponentState-synced) wired with the host IComponentHandler, whose performEdit routes through a lock-guarded queue the audio thread drains — editor knob-turns change the playing sound; teardown lives in the shared Arc<Vst3Core> keepalive (never under an open editor). CLAP: the worker checks its !Send instance out to the OS main thread (ClapMainShuttle), the clap.gui extension embeds Cocoa; a retire while the editor is open defers teardown until the window closes (the worker's on-loan ledger).
  • State save/restore: EngineHandle::save_plugin_states / restore_plugin_states (AU ClassInfo, VST3 IComponent::get/setState, CLAP state extension via the worker), persisted as the <song>.volta.state.json sidecar — written on save and restored after a song load (JSON contract: {track, role, slot, format, state: base64}).
  • CC lanes (ccLane, gap M1 closed 2026-08-01): PluginCc rides PluginInstrumentSpec.ccs (instrument-only — inserts have no MIDI input); the wrapper walks CC pattern lanes beside param lanes and routes per format at render: VST3 has no CC input events at all, so the wrapper asks the controller's IMidiMapping which ParamID the plugin bound to that controller (cached, warmed control-side at load) and sends normalized param changes; AU gets real 0xB0 bytes down the note path (MusicDeviceMIDIEvent/event-list, same UMP latch); CLAP gets clap_event_midi merged time-sorted into the input queue. Signal-valued ccLanes ride the same anonymous-modulation voices as pluginParam signals (SignalLanePlan.cc / ResolvedSignalLane.cc); hot-swap reuse re-applies CC values/lanes like params. Unit-tested via a mock backend (plugin::hosted::cc_tests).
  • volta-pilot (app-native/src/pilot.rs, bin volta-pilot, macOS + plugins): scripted plugin-editor automation — hosts one instrument, opens its REAL editor in an NSWindow, and obeys a command file (shot/click/drag/type/drop/probe/save/quit) so an agent can see the GUI (own-window CGWindowListCreateImage — no Screen Recording permission), drive it, verify sound offline (probe renders a note and reports the peak), and snapshot state straight into the preset store. The workhorse discovery: Kontakt 8's Qt/NTK UI ignores synthesized NSEvents (posted OR sent), but file loading is drag-and-drop, and the drop side of AppKit is a protocol call — drop x y file.nki fabricates an NSDraggingInfo (define_class) over a writeObjects: pasteboard and walks draggingEntered:→performDragOperation: on the hit-tested QNSView directly. Drops land on the rack strip (y≈70), not the browser page. One instance per preset (a second drop STACKS in the rack); .nkis enumerate from /Users/Shared/*/Instruments. This mapped the whole sodium roster unattended.
  • Named presets (engine/src/plugin/preset.rs): the same opaque blobs under a name, stored at ~/Documents/Volta/presets/<plugin-label>/ <name>.<format>.state (raw bytes — Kontakt states run to megabytes; preset::set_root overrides the root). |> pluginPreset "name" rides the IR (PluginInstrumentSpec.preset / TrackEffect::Plugin.preset), applies at activation before declared params, defeats hot-swap reuse when it changes, and warns (default sound) on a miss. Capture/audition surface: the picker's preset chip → EngineHandle::save_plugin_preset / plugin_presets / load_plugin_preset (per-instance, beside the sidecar pair). The offline bake path is volta autosample --plugin — see Capture an instrument as samples.
  • Desktop app (app-native/, volta-studio): enables all three host features and implements the workbench's plugin bridge calls (open_plugin_editor, the loaded-plugin list, save/restore of plugin state) in app-native/src/main.rs. Picker + param-knob UI is ui/src/pluginpicker.rs — the header plugin dropdown: installed-plugin search + insert-at-cursor, a per-instance knob browser with live values, and preview / live-editor buttons. (The old Tauri shell app-tauri/ and its runtime/src/plugin-picker.ts web picker were removed 2026-07-20 / 2026-08-09.)

Notes & limits#

  • Native-only — there is no plugin hosting in the browser build.
  • Build — hosting is ON by default in volta-studio (the crate's default plugins feature → engine clap-host/au-host/vst3-host + volta-engine-host/plugin-ui); make run-studio and a plain cargo run -p volta-studio both get the header plugin menu. --no-default-features drops it. Do not reach for --features plugin-host: that is volta-engine's umbrella feature — from the workspace root it resolves against the engine, builds clean, and leaves the shell's plugins cfgs (and the menu) off.
  • Latency & PDC — every backend reports declared latency (PluginBackend::latency_samples, queried once at activate: CLAP latency ext / VST3 getLatencySamples / AU kAudioUnitProperty_Latency), carried in LoadedPlugin.latency; the engine delays lighter tracks at the master sum to compensate — see Mixing tracks and buses §6.5 for semantics and limits.
  • Values are normalized [0,1] across all formats (VST3 directly; AU/CLAP rescale through the declared [min, max]); an undeclared id passes through unmapped.
  • panTrack on a plugin track uses the same pan law as a native patch (Mixing tracks and buses §3.5). A hosted instrument's audio is one continuous stream, so the pan applies to its bus, sampled at the block's cycle position (per-event pan like jux cannot apply the way it does per-voice) — but the law is now the engine's single voice::pan_gains_at, so |> panTrack 0.5 means one thing regardless of what is making the sound. It used to be the W3C StereoPannerNode stereo-input law: on a mono bus the peak channel ran +3.01 / +4.42 / +5.33 / +5.85 / +6.02 dB above the native path at pan 0 / 0.25 / 0.5 / 0.75 / 1, with total power up to g_L²+g_R² = 4 — so substituting a hosted CLAP/VST for a native patch on a panned track jumped 5–6 dB and could drive the master limiter (dsp-quality-audit-2026-08-03.md §A3). The cost of one law is that a genuinely stereo plugin bus is balanced rather than image-rotated: hard over, the far channel's content is attenuated away instead of folded into the near one — the same thing panTrack does to a stereo native voice, and what the mixer strip's own pan does to a bus. Pinned by lib::tests::plugin_bus_pan_is_the_engine_pan_law.
  • Only the first input + first output bus are wired (multi-port deferred). AU .component path resolution is blocked on a newer plist crate. CLAP editor caveats: no host timer/gui-request extensions are vended yet (plugins animate via their own timers), and state save fails soft (None) while that plugin's editor window is open (the instance is on loan). Control-thread waits on the CLAP worker are bounded (plugin::await_worker): activation gives up after 20 s and becomes an activation error; state save/restore and the editor checkout give up after 5 s and fail soft, so a wedged plugin never freezes the workbench. Window paint itself is not verifiable headlessly — the editor plumbing is (see the vst3_editor_*/state tests and the CLAP shuttle tests).
  • Discovery helper: cargo run -p volta-engine --features au-host --example plugin-params -- "au:AUDistortion" dumps a plugin's declared parameter table.