Run Volta as an instrument inside a supported plugin host and connect the song to its musical environment.
What it is#
Volta.clap (and, feature-gated, Volta.vst3) is an instrument plugin that embeds the native engine + compiler. It watches a .volta file and hot-reloads it on save while the DAW transport runs, locks pattern playback to the host timeline, exposes macros as host-automatable parameters, and plays host MIDI notes through a synth in the module. Opening the plugin window mounts the volta-ui workbench itself (design doc §11) — the same editor/bench/mixer/rack/scopes the desktop and browser run, editing the watched file with hot-swap; external editors stay first-class, syncing through the same file watch.
Build & install#
make bundle-plugin # → plugin-target/target/bundled/Volta.clap
# or by hand:
cd plugin-target && cargo xtask bundle volta-plugin --release
# VST3 (GPLv3-encumbered — see the design doc §1 before distributing):
cd plugin-target && cargo xtask bundle volta-plugin --release --features vst3
Install on macOS by copying the bundle:
- CLAP →
~/Library/Audio/Plug-Ins/CLAP/Volta.clap - VST3 →
~/Library/Audio/Plug-Ins/VST3/Volta.vst3
make test-plugin runs the crate's suite (first build fetches the nih-plug git dependency, so it needs network once). make premerge runs it, and make gate-smart plans plugin-target as its own unit whenever its directory, the root crates it builds against (compiler, engine, sources, ui and their closure) or the root manifests change: first cargo metadata --locked, which fails in seconds when a root [workspace.dependencies] change left plugin-target/Cargo.lock stale (refresh it with cd plugin-target && cargo update --workspace and check the diff only adds what the root lock already resolves), then the suite. Profile overrides do not cross workspace boundaries, so plugin-target/Cargo.toml mirrors the root dev profile's per-package settings; keep the two in step. A test that paints a real workbench frame runs on a 64 MiB thread (a libtest thread's 2 MiB is too small for an unoptimized frame). A standalone smoke-test binary exists behind the standalone feature: cargo run --features standalone --bin volta-plugin-standalone.
Using it#
- Put a module at
~/Documents/Volta/plugin.volta(the desktop workspace dir), or setVOLTA_PLUGIN_FILE=/path/to/song.voltain the DAW's environment. The path is persisted per-instance in the DAW project. - Imports resolve against sibling
.voltafiles (same dir, andlib/under it), keyed by file stem. Samples load from<dir>/samples/and~/Documents/Volta/samples/— WAV only; anindex.jsonmanifest (theruntime/public/samplesformat) is honored for names/variants, else file stems (andsubdir→subdir:N) become bank names.bounceOfbindings bake on load. - Press play in the DAW: with a
song, its first scene plays (full arrangement playback is deferred); otherwise all tracks play. Stop, loop and relocate follow the host, each applied on the sample the block it arrived in began at — not at an offset inside that block, because no host API hands nih-plug intra-block transport events. See § "The host bridge" for what that does and does not buy. Compile errors keep the previous module playing and log to the host's plugin log. - Play MIDI notes: they sound on the synth named
plugged(or the first synth), carry the host's own sample offset, and release on their note-off.Note Lengthis the fallback for a host that sends none. - Open the plugin window for the workbench (1280×820, fixed for now). Its editor pulls the watched file; ⌘Enter hot-swaps; its autosave writes the file (the file watch dedupes by content hash, so nothing double-compiles). An external edit re-pulls the buffer. The workbench ▶/⏹ drive the engine directly; host transport edges still win. Sync/OSC/MIDI-port panels are inert in-plugin (the host owns clock and MIDI routing), and community/ learn surfaces are absent, as in the browser degradations. Building
--no-default-featuresdrops the GUI entirely (the pre-Stage-6 headless plugin).
Parameters#
| Param | Meaning |
|---|---|
Macro 01…32 | Generic 0..1 pool. Which destination each knob drives comes from the persistent parameter bank (below), not declaration order, through the destination's declared range. A knob only writes once it moves after a load (untouched knobs leave the source's initial value + modulation graph authoritative). The slot↔destination map is logged on every load, and each knob's value readout names what it drives. |
Note Length | Fallback release for a host MIDI note that never gets a note-off (0.05–8 s). A note-off releases sooner. |
Host Tempo | On (default): the module's tempo = N bpm is rewritten to the host tempo and recompiled (debounced) so cycle lengths re-derive exactly; modules with a tempoMap are left alone. Off: the module's own tempo governs; beat positions still lock to the host. |
State saved with the DAW project: all params, the watched path, the last compiled source text (used on restore when the file is missing, so a moved project still sounds), and the parameter bank.
The host bridge#
Design: Unified Timeline §9, Stage 6. Shared, host-agnostic logic: engine/src/host.rs. nih-plug adapter: plugin-target/src/hostsync.rs.
One conductor#
Exactly one thing owns a run: Volta, the host, or an explicitly selected external sync source. In the plugin the host owns play/stop, quarter-note position, tempo, meter and the loop/discontinuity events it actually provides — feeding engine/src/host.rs at all IS that declaration. (There is deliberately no Conductor enum: it would name three states nothing branches on, and collide with volta_time::Conductor, which is the beat-mapping machine Volta's own timeline runs on.) Volta's authored conductor is retained as project data; no second tempo map runs underneath the host. What Volta keeps is everything below the conductor — the patterns, the graph, the mixer, the macros and (with Host Tempo on) the source-level tempo rewrite that makes cycle lengths and 1/4c rates re-derive exactly at the host's BPM.
The capability report#
Missing host data is held and reported, never manufactured. HostCapability::describe() names what is and is not provided, and the plugin logs it at initialize. For nih-plug today:
- tempo: block-start only — intra-block tempo is not manufactured. No host API hands nih-plug intra-block transport events, so a tempo ramp is sampled once per (sub-)block. When a host declares no tempo at all the clock holds the last one it declared and the report says
tempo: not provided. - meter: whatever the host declares; otherwise the last declared meter is held.
- loop: reported only when the host reports a loop range or an active loop.
- transport offsets: no —
reading_from_transportpassesjumps: &[]because neither CLAP nor VST3 gives nih-plug an intra-block transport event, so a transport change lands at the block start. - parameter & note offsets: yes —
SAMPLE_ACCURATE_AUTOMATIONsplits the buffer at each parameter change and nih-plug keeps MIDItiming. These are two separate facets ofHostCapability(transport_offsetsandevent_offsets) precisely because one flag covering both could only ever be a lie about one of them. - note expression: yes.
- gesture edges: no — nih-plug does not surface the host's automation begin/end, so a takeover is inferred from the value moving, and the report says so rather than pretending otherwise.
A backward seek is not a loop wrap#
Discontinuity has three values and all three are used:
| the host says | the transport jumps | reported |
|---|---|---|
loop active, range [a, b) | back to a | LoopWrap |
loop active, range [a, b) | anywhere else | Seek |
| a loop range, loop off | backwards | Seek |
| nothing about looping | backwards | Unknown |
| nothing about looping | forwards | Seek (a wrap cannot go forwards) |
The landing beat must equal the loop start within JUMP_TOLERANCE_BEATS (1e-3 quarter notes, ~0.5 ms at 120 BPM — below any host's own rounding). A position that merely disagrees with the clock's prediction by less than DRIFT_TOLERANCE_BEATS (1/64 note) is jitter, not a discontinuity — plus whatever a tempo the host changed BETWEEN blocks already made the previous block's prediction wrong by (HostClock::stale_prediction_beats). Without that second term a 30 BPM step across a 2048-frame block manufactures a Seek the host never performed: 0.021 beats of honest disagreement against a 0.0156 floor. All three outcomes re-anchor the transport identically; what differs is what the record is told it was.
The sample-offset contract#
render_split splits the render at the union of volta_engine::BLOCK boundaries and the block's event offsets, and applies each event at its seam. The plugin owns the AudioEngine inside process, so everything the engine can do sample-accurately happens there — no mutex, no ring, no block quantization. Everything needing a control-thread-built payload goes through the CtlEvent ring carrying at_sample, the engine's absolute sample clock at the seam. Two different things get called "sample-accurate", so the table states both, and says what the second column actually is:
| event | applied on its own sample | carries its own sample |
|---|---|---|
| tempo change | yes (set_tempo_live) | yes |
| seek / loop wrap | yes (reorigin_beat_at_sample) | yes |
| stop | yes (stop_all) | yes |
| play — the clock re-origin | yes (reorigin_beat_at_sample) | yes |
| play — which tracks start | no: the track set is control-side | yes |
| host parameter → macro | yes (set_macro_id), once the slot map has reached the audio thread | yes |
| note on | the host's sample reaches preview_trigger_at; see below | yes |
| note off | no when held across a control pass: the plugin calls preview_release, not preview_release_at; see below | yes |
| note expression | no: same path as the note | yes |
Column two is a statement about the CtlEvent, not about a recorder: the plugin keeps no performance log of its own, and what timestamps a take is the engine's begin_performance, which records what the engine did at the sample it did it. Column two is what such a recorder would read, and what the onset is scheduled from.
What "the host's sample reaches preview_trigger_at" is worth. The engine schedules an onset still ahead of its clock on that exact sample and clamps one already behind it to now — a rendered sample cannot be un-rendered. The voice TEMPLATE is control-thread work, so by the time the command drains the seam is usually already rendered and the onset lands at the drain point (≤ 1 ms later) rather than on the host's sample. What it is never again is the top of whichever block happened to drain the command, which is what passing None bought: up to a whole volta_engine::BLOCK of slop, 2.7 ms at 48 kHz, on every note a DAW sends.
Note-offs. A note-off that shared its note-on's host block sets the note's LENGTH exactly: the pair is folded into the trigger's duration_sec, so the release lands on the host's sample relative to the onset rather than a control pass later. A note held across passes — anything longer than ~1 ms — takes preview_release, which applies at the next drain. EngineHandle does offer an id-addressed release on a sample (preview_release_at, clamped forward like an onset, over Arena::release_voice(slot, at, sr)), but the plugin's note-off path does not call it yet; wiring it in is what would close the remaining gap.
SAMPLE_ACCURATE_AUTOMATION is on, so the wrapper splits the buffer at each parameter change and the change's offset is that sub-block's own start; nih-plug compensates pos_beats for the split, so the clock stays coherent. A parameter move the audio thread could not apply yet (no slot map published — the first blocks after a load) is marked applied: false, and the control thread writes it by name instead, so it is never applied twice. The split is by id on the audio thread (set_macro_id) and by name on the control thread (set_macro) for one reason: a name lookup is a HashMap<String, _> probe, and the by-name path exists only for the blocks before any id table has reached the audio thread. Whoever holds the id uses the id; whoever holds only a name is, by construction, off the audio thread.
Ordering, and what is never dropped#
render_split owns the ordering contract rather than trusting its caller:
- it sorts the block's events itself, stably, so a host free to hand them over out of order (a VST3 parameter queue is per-parameter, so a two-knob block arrives interleaved by knob, not by time) cannot make the render walk pass an event and then apply it;
- the push order is transport → parameters → MIDI, so at one offset the transport is applied before anything that depends on where the transport is;
- an event whose offset lands past the end of the block — a host that timed it against a longer buffer, or a block this plugin could not render at all — is carried into the next block and applied at its offset 0, ahead of that block's own events. Only a carry list that is itself full counts as dropped;
- a knob move's baseline advances only when its event was actually accepted, so a block that returns early (no frames, no engine yet, a buffer past the declared maximum) defers the move instead of marking it synced and losing it.
The two dropped counters (HostClock::dropped_total, and BlockEvents::dropped_total for the per-block list) and the live HostCapability are published from process into SharedEngine and logged by the control thread, because the audio thread may not log and the control thread cannot see a HostClock the audio thread owns.
MIDI and note-offs#
A host note takes an id-addressed preview_trigger / preview_release pair. The id is the host's own voice_id when it supplies one (so overlapping voices on one key stay distinct), else (channel, note) in the same id space ui/src/midimap.rs uses, so it can never collide with the workbench monitor. A held note releases on its note-off; Note Length is only the fallback for a host that sends none, and it is a real bound — the voice cannot be pinned forever. A note-off that arrived in the same host block as its note-on gives the note its exact length instead (see § "The sample-offset contract"). Per-note pressure and volume reach the sound; pan, tuning, vibrato and brightness are carried in the record and are not folded into a parameter they do not mean.
The preview monitor is re-raised on every load: a module swap clears the preview arena and zeroes its monitor gain.
The persistent parameter bank#
ParamBank maps a host slot number to a durable destination key.
- Size: 128 slots. It is the ceiling every shipping plugin format handles without complaint, and far more destinations than a song exposes. It is bounded on purpose: a bank that could never run out would have to renumber, which is the one thing host automation cannot survive. Running out is a named error, not a silent recycle.
- 32 is a different number. The host-visible knob count is frozen by the parameter IDs and cannot grow without breaking saved projects. A mapping past slot 31 keeps its durable identity and simply has no knob; the load log says which.
- Persistence. The whole bank — slot numbers, keys, labels, orphans, ownership — is stored in plugin state as JSON (
#[persist = "parambank"]). A project saved before the bank existed restores as an empty bank rather than failing. - No renumbering. Assignment is idempotent: an existing key returns its existing slot. A rename, a reorder, an inserted effect or a plain recompile leave every existing slot number exactly where it was.
- Orphans. A destination that vanishes stays visible, flagged
orphaned, and its slot is not reused while any slot is still free. The knob's value readout shows0.500 (orphaned: space), so the host sees it without the parameter count changing. A returning key un-orphans into its original slot. - Orphans are reclaimed under pressure, and only then. Orphans that were never given back would exhaust the bank: roughly six swaps of a 20-macro song filled all 128 slots with dead keys and
assignbegan refusing destinations that actually existed. So when nothing is free and a real destination needs a slot, the bank prunes exactly one orphan — the one dead longest, by the rebind generation stamped on it when it died, which survives the state blob — and names it throughtake_pruned, which the load path logs. Live mappings are never recycled at any pressure: a bank with no orphans left is stillBankError::Full. - Ownership, takeover and Restore. Every entry declares
HostorVolta. An explicit host gesture takes the destination over; Restore hands it back. Ownership survives a rename, a reorder and a recompile. - Echo suppression. Every workbench write-out —
SetMacro,TakeoverMacro, both endpoints ofRampMacro— goes throughSharedEngine::note_volta_macro, soEchoGuardremembers(slot, value)for a bounded window. The host's callback replaying that value is not recorded as a new Volta gesture, and a match is consumed, so a second identical host move is believed. The window expires — suppression is bounded, never indefinite. A slot remembers up toECHO_DEPTHoutstanding writes, because a burst is the normal case: dragging a knob pushes several values out before the host echoes the first, and remembering only the newest would bring every earlier one back as a takeover the user never performed. (The plugin still does not push values INTO the host's knobs — there is noGuiContext::set_parameterpath — so what comes back today is the host replaying its own recorded lane.EchoGuard::wroteis already the single call a write-back would have to make.) - Minted
mix:addresses are internal. Stage 4's dotted parameter addresses resolve through amix:<addr>runtime slot that is renumbered by every recompile, so it can never be a persisted identity. A bank key beginningmix:is refused as ambiguous — that refusal is the shipped behaviour, andload_textmapsmacros only, so nothing in this plugin mints an address yet.assign_addressedis the entry point an adapter that does must use: it takes the human address (bass.filter.cutoff) as the durable identity, flags the entryminted, and keeps it out ofuser_visible— which is the filterload_textuses to decide which mappings a knob may drive.
Delay compensation (PDC) — not wired#
The plugin reports no latency to the host and compensates none. That is correct for what it currently runs (the engine renders in place, with no lookahead limiter or oversampling on by default) and wrong for anything that adds some: an event applied to the engine now would be heard that much later, and a receiver that does not say so silently mistimes every host automation point and every note against the rest of the project.
The shared bridge used to carry a ReceiverLatency type for this. It was never populated — the engine exposes no total-latency figure to populate it from — so it has been removed rather than left standing as a promise. Wiring PDC means, in order: an engine-side total (own lookahead + hosted-plugin reports), InitContext::set_latency_samples at initialize, and a re-declaration whenever a load changes it.
User kernels in a host (JIT memory)#
A song's user kernels (process, effect declarations, dsplib) compile to machine code at load when the process may map executable memory, and run on the register interpreter otherwise — the same output bit for bit, only slower (see Write a custom audio processor § Cost). A plugin runs under its host's code signature, so the host decides:
| host process (macOS, Apple Silicon) | mmap(MAP_JIT) | user kernels run on |
|---|---|---|
not hardened (ad-hoc/dev builds, clap-validator) | allowed | native code |
hardened runtime + com.apple.security.cs.allow-jit (REAPER) | allowed | native code |
hardened runtime + …allow-unsigned-executable-memory only (Ableton Live 10/12) | allowed | native code |
| hardened runtime, neither entitlement | refused | the interpreter |
VOLTA_KERNEL_JIT=0 in the host's environment | not tried | the interpreter |
(The entitlements quoted are what codesign -d --entitlements - reads from the installed applications on the verification machine, 2026-09-26; Logic Pro loads no CLAP/VST3.) The backend probes once per process — compile, map and call a trivial function — and never maps code another way: under the hardened runtime without either entitlement a page made executable with mprotect kills the process when it runs (measured with a signed C probe), while a refused MAP_JIT is an ordinary error. So the plugin cannot crash a host by trying; it logs the verdict on every load:
volta-plugin: user kernels: 2 of 2 voice programs on native code
volta-plugin: user kernels: 0 of 2 voice programs on native code — this host refuses JIT memory, the interpreter runs them
(The count is the voice and control graphs' programs; track-insert kernels compile at load on the same verdict.) To check a signature directly: cargo build --profile fast -p volta-engine --example kernel-jit-probe, sign a copy the way the host is signed (codesign -s - -f --options runtime [--entitlements …]) and run it — it prints the verdict and checks native against interpreted output. Volta's own app ships allow-jit in app-native/macos/Volta.entitlements; nothing in the plugin bundle can grant it (entitlements belong to the main executable).
Engine caveats in plugin context#
input(live audio in) renders silence — the plugin declares no audio input (effect-mode is a deferred follow-on).- Scene
rampautomation behaves as on the native engine (pinned to its start value). - Transport changes land at the block start, because no host API gives nih-plug intra-block transport events. The clock reports this rather than interpolating; parameter and note offsets are unaffected.
- A note's AUDIBLE onset is its host sample or the control thread's next drain, whichever is later — the voice template is control-thread work. A note-off held across a control pass lands at the next drain: the plugin does not yet call
EngineHandle::preview_release_at, which would place it on the host's sample. - Wavetable banks (
wavetable "name"of factory tables) aren't shipped into the plugin bank yet — deferred (wavetableOfframes built from samples work, as they bake engine-side).
Verification#
2026-09-26 — user kernels in hosts (user-dsp-tier Stage 8b). plugin-target/tests/kernel_jit_fallback.rs loads a song with three user kernels (a macro-driven shaper, a delay-line law with a cell, a track-insert processS) through the plugin's own load path and renders one second twice in one process: with every executable mapping refused (kernel::jit::simulate_map_failure, which fails the real ExecMem mapping the probe goes through — no program compiles), then with mapping allowed — bit-identical and non-silent. clap-validator 0.4.1 on a headless Volta.clap (cargo xtask bundle volta-plugin --release --no-default-features, VOLTA_PLUGIN_FILE pointing at that song): 44 tests, 31 passed, 5 failed, 8 skipped — the same five pre-existing param/state failures as below (param-conversions, the state-reproducibility-* family), every process-* test passing (notes, varying sample rates and block sizes, reset); the plugin logged 2 of 2 … on native code. The same validator re-signed with the hardened runtime and only disable-library-validation (a real refusing host): the same 44/31/5/8 and 0 of 2 … this host refuses JIT memory. kernel-jit-probe signed four ways: plain, allow-jit and allow-unsigned-executable-memory → native; hardened with no entitlement → enabled: false, interpreter, bit-identical, no crash. NOT exercised: a real DAW session (Live, REAPER) — the table above is read from their signatures, not observed in them.
2026-08-05 — GUI build.
clap-validator 0.4.1onVolta.clap(gui default build): 44 tests, 31 passed, 5 failed, 8 skipped. The 5 failures are identical on a--no-default-features(headless) build, i.e. pre-existing param-layer issues surfaced by 0.4.1's stricter suite, not the GUI:Note Length's value↔string roundtrip is not bit-consistent, and the state-reproducibility trio reports macro-pool values changing across a state reload without a rescan request. Both live inparams.rs/nih-plug territory — open items. (The 2026-07-07 run underclap-validator 0.3.2was 21 tests / 0 failed.)make test-plugin(2026-09-15): 53 passed, 0 failed — 23volta-pluginunit tests (interleave round-trip, macro-range mapping + pool ordering, tempo-rewrite/inject/skip, bundle + sample-dir scanning, first-scene/target-synth selection, the note-on/note-off batch pairing rules, and thehostsyncnote-id / stable ordering / overflow / seam / slot-map / knob-diff / report tests), the transport e2e, 6 GUI-bridge tests (tests/gui_bridge.rs) that paint real workbench frames headlessly overPluginBridgeand drive the echo guard and Host ownership through it, 15 host-bridge tests (tests/host_bridge.rs), and 8patch-playertests.cargo test -p volta-engine --lib host::(2026-09-15): 35 passed, 0 failed — the clock's segments, held tempo/meter, the four discontinuity cases, a between-block tempo step that is not a seek, the capability report and its packed round trip, bank idempotence / orphans / ownership / JSON round trip, orphan pruning under assign pressure (including twenty song swaps that neither exhaust the bank nor lose a destination), themix:refusal, and the echo guard's bound and burst depth.tests/host_bridge.rsdrives the shipping seam walk (hostsync::render_split+hostsync::apply_event, the two functionsprocess()is made of) with a synthetic transport and event list: a tempo change mid-block applied 256 samples in rather than at the block start (and theCtlEventcarrying that sample), a parameter applied at its offset and reaching the engine, MIDI offsets/ids/expression, a host note whose onset moves by exactly the offset it was given — through the real control worker andpreview_trigger_at— a note-off in the same block fixing the note's length against aNote Lengthcontrol, an event past the block end carried rather than dropped, an unsorted list sorted before it is split, a short scratch deferring every event instead of losing it, a loop wrap distinguished from a backward seek, a rename + reorder + recompile through the realload_textkeeping every slot and every owner, and echo suppression not forming a feedback loop. It does not coverhostsync::reading_from_transport: nih-plug'sTransportkeeps its position fields and its constructorpub(crate), so no test outside nih-plug can build one with a position in it. That layer isclap-validator's.- Standalone wrapper boot verified (dummy backend): compiles the watched file, logs
Macro 01 ⇢ "bright", picks the MIDI target synth. - NOT yet verified: the GUI inside a real DAW (keyboard capture, wgpu surface in a parented window) — install is at
~/Library/Audio/Plug-Ins/CLAP/Volta.clap; test in Bitwig/Reaper by hand.