For developers: use Volta's C interface to compile and render music from a host application.
The C-ABI library around the compiler + native engine: a host compiles .volta source, pulls rendered blocks from its own audio callback, and drives the performance through macros / scenes / songs — or plays it as an instrument (one-shots, held notes, tempo, macro ramps, host-decoded samples, viz) for a synth-toy app. Every call is a thin wrapper over the same EngineHandle method the browser worklet (engine-wasm) wraps. Design spec: Embed Volta in another application.
- Crate:
libvolta/(workspace member;cdylib+staticlib+rlib, artifactslibvolta.{dylib,so,a}). - Header:
libvolta/include/volta.h— generated by cbindgen, checked in; regenerate withmake libvolta-headerafter changing the FFI. - Example host:
libvolta/examples/chost.c; build + run withmake libvolta-example(compiles a song, lists macros / tracks / synths, renders a WAV, wiggles a macro, then fires a one-shot and a held note_on/note_off on the first synth, asserting both halves are non-silent). Also takes a song path:./target/release/chost examples/pluck.volta. - Tests:
libvolta/tests/ffi.rs— exercised through the raw extern "C" surface; runs inmake test-core.
Lifecycle#
VoltaModule *m = volta_compile(source, lib_dir); /* or volta_compile_file / volta_compile_ir_json */
VoltaEngine *e = volta_engine_new(48000);
volta_load_sample_wav(e, "break", "assets/break.wav"); /* before load_module */
volta_load_module(e, m); /* bakes bounces, installs */
volta_play(e); /* first song, else all tracks */
/* audio callback (host-owned): */ volta_render_interleaved(e, out, frames, 2);
/* or planar */ volta_render(e, L, R, frames);
/* game tick, ≥10 Hz while a song plays: */ volta_update(e);
/* instrument (control thread; the transport need not be running): */
volta_one_shot(e, "lead", 60.0f, 0.9f, 0.5f); /* fire-and-forget, main bus */
volta_note_on(e, key, "lead", 64.0f, 0.8f); /* held; first call arms the */
volta_note_vel(e, key, 0.4f); volta_note_off(e, key); /* preview-bus monitor */
char *viz = volta_viz_json(e); /* … */ volta_string_free(viz);
volta_stop(e); volta_engine_free(e); volta_module_free(m);
Every fallible call returns VoltaStatus (VOLTA_OK = 0, negative errors); volta_last_error() is the thread-local rendered message (compile errors carry the full caret diagnostic). Panics are caught at the boundary (VOLTA_ERR_INTERNAL), never unwound into C.
volta_load_module validates the execution contract and prepares the entire plan before stopping the installed performance or changing its sample bank. An unsupported contract or invalid reference returns VOLTA_ERR_COMPILE and leaves the current performance running. Both source-compiled modules and volta_compile_ir_json handles use this installation boundary; successfully decoding JSON does not by itself guarantee that a plan can be installed.
Surface#
| Group | Calls |
|---|---|
| Compile | volta_compile(src, lib_dir) · volta_compile_file(path) · volta_compile_ir_json(json) · volta_diagnostics_json(src, lib_dir) · volta_module_json (+ volta_string_free) · volta_module_free |
| Engine | volta_engine_new(sr) · volta_engine_free · volta_sample_rate · volta_load_sample_wav(e, name, path) · volta_load_sample_pcm(e, name, mono, frames, sr) · volta_load_module(e, m) |
| Render | volta_render(e, L, R, frames) · volta_render_interleaved(e, out, frames, ch) — ch ∈ {1, 2}; any frames (chunked internally to the engine BLOCK) |
| Transport | volta_play · volta_play_song(name) · volta_apply_scene(scene) · volta_start_track / volta_stop_track · volta_stop · volta_pause / volta_resume · volta_set_tempo(bpm, ramp_s) · volta_current_bpm · volta_update |
| Instrument | volta_one_shot(synth, pitch, vel, dur_s) · volta_note_on(id, synth, pitch, vel) · volta_note_off(id) · volta_note_vel(id, vel) · volta_preview_monitor(level) |
| Macros | volta_macro_count · volta_macro_info(i, *out) (name/unit/initial/range) · volta_set_macro(name, v) · volta_ramp_macro(name, from, to, dur_s) · volta_macro_value(name, *out) |
| Discovery | volta_track_count · volta_track_name(i) · volta_synth_count · volta_synth_name(i) — module order; tracks are what start/stop take, synths what one-shots / notes take |
| Position | volta_song_position(*out) (playing / beat / step / step_count / song_frac) · volta_song_step_name(i) |
| Visuals | volta_viz_json (heap JSON, volta_string_free; NULL when nothing new) |
| Misc | volta_version · volta_last_error |
Threading contract#
volta_render*locks only the audio side — call from the audio callback. Control calls lock only the control side and reach the audio thread over the engine's lock-free command ring; the two locks are never held together, so control activity can't block the callback.- Control calls are mutually serialized (safe from any thread, but they block each other; use one control thread).
volta_updatesteps the song driver. Call ≥ 10 Hz while a song plays; scene changes are bar-quantized by the engine, so a late tick lands on the next bar rather than drifting.- Returned strings are borrowed: error text until the next failing call on the same thread; macro/track/synth/step names until the next
volta_load_module/volta_engine_free/ song change. The heap strings (volta_module_json,volta_diagnostics_json,volta_viz_json) are the host's to free withvolta_string_free. - Installing a module is a transaction settled exactly once. Every load goes through the engine's one install protocol (
install_module,engine/src/preparation.rs): the module and its ancillary commands are staged as one batch bound to the revision they were prepared against, and the audio callback accepts or refuses it. Handing the batch over is not installing it. While audio decides, the control side holds either the new identity plus a rollback snapshot (optimistic, whichvolta_load_moduleuses, so follow-up calls can name the new tracks at once) or the previous identity with control changes refused untilfinish_installadopts it (deferred). A refusal restores the whole previous control identity and discards the commands and loads staged with it; installs prepared against a refused revision are refused too. When no callback is running — a stopped or wedged device, a closed audio context — the producer settles the batch itself afterMODULE_INSTALL_DEADLINE(2 s), or at once when it knows no callback runs, so a dead device cannot hold loading, saving or export hostage. Both sides settle by compare-and-exchange on one status word, so exactly one of them decides. A new host adds a visibility mode or an install field to this protocol rather than a second install path.
Semantics & v1 limits#
- Songs.
volta_play_songstops everything first (transport re-anchors; the song's beat axis starts at 0), applies the first scene immediately, and auto-advances through the resolvedarrangeonvolta_updateticks (advance is queued ~0.5 beat early; the engine quantizes to the bar). A bar is the module's: steps,song_fracand scene boundaries count in bars of itsmeter(3.5 beats in7/8) and follow ameterMap. The header defines no bar constant —VOLTA_BAR_BEATS(4.0, wrong under any other meter) was removed. Non-looping songs (|> once) queue a bar-quantized stop at the end. No-arrangesongs advance uniformly ≈14 s per scene (browser parity);songGraphsongs hold scene 0 — navigate withvolta_apply_scene. - Automation. Section
with { m = v }pins apply;ramp a bsweeps across the arrange step over the engine'sCommand::RampMacro(linear on the audio clock — the shape the in-engine arrangement runner arms). The driver sends a step's pins and ramps WITH its scene, half a beat ahead, markedRampStart::NextBar; the engine holds them and starts them in the block holding the boundary the scene lands on, so a ramp spans exactly its step whatever thevolta_updatecadence. A manualvolta_apply_scenedoes the same, ramping over the scene's first arranged length (else one bar) at the base tempo.songPos/sectionPosramp for real. - Transitions (
edges) do not fire in v1 (Embed Volta in another application Stage 2). - Test arrangement behavior on the swap path (
AudioEngine::swap, a secondEngineHandle::load_module), not a first load, which clears the transport loop and re-seeds every macro.lfo*sources are note-local, so an engine test of a transport-locked construct needs one long note:play p (!"c3" |> slow 4). A swap moves a held note's live values (an envelope time waits for the next note); a structural edit keeps the old graph. - Macros are set in their own unit (
volta_macro_infocarries the range); moves smooth ~15 ms engine-side.volta_ramp_macroaims atfromnow (the smoother glides there) and sweeps linearly toto; a latervolta_set_macrocancels it.volta_macro_valuereads the base the host last set and follows a host ramp (a control-side mirror clocked on rendered frames, ≤ one block behind the audio); audio-side shred:=writes and song-driver automation are not mirrored back. - Instrument.
pitchis a MIDI note number asfloat(69 = A4; fractional bends),vel0..1.volta_one_shotplays on the main bus, and sounds on a stopped engine too: the engine renders the main arena without a transport clock while any voice is active, so the transport stays stopped (volta_song_positionkeeps readingplaying = 0).volta_note_on/note_off/note_velare held voices on the engine's preview bus (the patch inspector's audition bus), keyed by a hostid(re-using a live id re-triggers; a note holds up to an hour). That bus's monitor gain starts at 0, so the firstvolta_note_onafter a module load raises it to 1.0 — unless the host calledvolta_preview_monitoritself (0 = a silent study voice; 0..1.5). A held note survives avolta_load_modulethat did not change its synth: the engine compares each synth's span-free graph plus the unit and range of every macro and control it reads, keeps the held and releasing voices of an unchanged one — sample-exact, itsidstill valid fornote_off— and stops the rest, so adding a sequencer lane under someone's chord does not cut it. The monitor gain rides with a surviving voice; a load that kept none zeroes it again. Held notes and one-shots of a patch that is a sub-mix member (submix keys = [voice], Mixing tracks and buses §1.3) pass that sub-mix's chain —placeincluded — instead of reaching the master directly; nothing changes in how the host triggers them.volta_pauseholds the main bus (tracks and one-shots) andvolta_resumereleases it. - Preview level. The preview bus carries no cap of its own. A held note's path is
gain × VOICE_GAIN (0.5) × monitor gain × MASTER_GAIN (0.7), then the master safety limiter andsoft_clip— the same ending a track has, and exact below the limiter (one 0.4-gain sine at monitor 1.0 peaks 0.1397 against a nominal 0.1400). Four such notes ask for 0.56 and measure 0.4874; at monitor 1.5 they ask for 0.84 and measure 0.5233. That ~0.5 ceiling is the shared limiter, not the audition path: the same four sines as a track chord measure 0.5407. The one preview-specific bound is the monitor gain's clamp, 0…1.5. See Inspect a patch § Notes & limits. - Preview routing.
volta_note_on/volta_one_shotdo NOT pass the inserts of the track that plays the synth — the preview bus joins the mix after every track has been mixed. The engine can route it:EngineHandle::set_preview_route(Some("track"))sums the monitor onto that track's bus before the track is mixed, so the audition goes through its fader, strip, sends and inserts;sole_track_for_synth(name)resolves the single track that plays a synth (Nonewhen two do). No C ABI entry point yet — avolta_set_preview_route(const char*)wrapper over those two calls is all it needs; the browser kit already exportsWasmEngine::set_preview_route/sole_track_for_synth. - Stopping one track lifts its keys.
volta_stop_trackstops the scheduler AND note-offs the voices that track is still holding, so the patch's release stage runs and a sustaining note decays instead of hanging for the rest of the session (before 2026-09-16 it hung: measured 0.0803 rms held flat, now 0.0623 → 0.0341 → silence).volta_stopis unchanged and still reaches digital silence inside a block — it drops the clock, and the main arena is not rendered without one. - Tempo.
volta_set_tempo(bpm, ramp_s)sweeps the running clock (0 = instant); the engine ignores it while nothing plays — each start begins at the module's declaredtempo.volta_current_bpmis the instantaneous tempo as of the last render (it sweeps during a ramp; 0 when stopped). - Samples load from WAV files (
hounddecode, downmixed mono) or as host-decoded mono PCM (volta_load_sample_pcm; an iOS host decodes with AVFoundation, a game with its asset pipeline;sample_rate0 = the engine's); load before the module so effect chains / bounces resolve, and loading a name again replaces it. Wavetable names (wavetable "name") have no loader yet —wavetableOf-built tables and everything else in the IR work. - Viz.
volta_viz_jsonis the engine's reduced viz snapshot as JSON — camelCase keystime,songClock,tracks(per-track level / onset data),macros,voices,analysis,master(loudness + true-peak meter),pitches(sounding MIDI notes) — the same document the browser'sVizBusconsumes. NULL when there is nothing new (no module, or the transport is not playing); poll it every frame while drawing — polling also reclaims retired engine state. - Imports resolve to
<dir>/x.voltabeside the entry (CLI parity); the remote registry is deliberately absent — vendor packs on disk. - Native-only features behave as on the desktop engine (
tap,follower,vocodework;input/onsetsrender silence — no mic); plugin hosting is compiled out.
Browser embed kit#
The browser twin of this C ABI: one relocatable directory holding the compiler wasm, the single-owner engine wasm, the AudioWorklet processor that hosts it, the stdlib text and the stock wavetables, so a static page outside this repository compiles and plays Volta through the same engine — no Vite, no cross-origin isolation, no runtime/ code. Stage it with make embed-kit (scripts/embed-kit.sh [--from <checkout>] [--out <dir>], default dist/embed-kit/) after ./build-wasm.sh, ./build-engine-wasm.sh and npm --prefix runtime run sync:assets. kit.json names every entry point relative to the kit root, plus the Volta version and commit it was cut from.
<kit>/compiler/volta_wasm.js+volta_wasm_bg.wasm—await init({ module_or_path }), thencompile(src, sources)(IR JSON, throws the caret diagnostic) orcompile_checked(src, sources)(never throws; structured diagnostics).sourcesis{ "<import path>": "<module text>" }, read fromlib/.<kit>/worklets/engine-single/<hash>/native-engine-processor.js— the processorkit.jsonnames (processor, besideengineWasmin the same content-addressed<hash>/directory, andengineBuild, a module whoseENGINE_BUILDnames both); it imports../../native-engine-core.js, which needskernel-code-host.jsandtextcodec-polyfill.jsbeside it. Because every engine URL carries its content hash, a host caching one kit release can never pair its glue with another release's wasm.audioWorklet.addModuleit, createnew AudioWorkletNode(ctx, "volta-native-engine", { outputChannelCount: [2] }), post{ type: "init", module: <wasm bytes> }and wait for{ type: "ready" }; then everyWasmEnginemethod (engine-wasm/src/lib.rs) is one RPC{ type: "call", id, method, args }→{ type: "result", id, ok, value }. The node's input is the engine's live input (inputin Volta).<kit>/lib/index.json+*.volta— the shipped stdlib, as text.<kit>/wavetables/index.json+*.json—load_wavetable(name, json)a table beforeload_modulewhen the source names it.
The engine rules a page host must know match the C ABI above: one_shot sounds with or without a running transport, while preview_trigger / preview_release held notes sound at any time once preview_monitor(1.0) is sent, and every load_module zeroes that monitor again.
A page does not have to walk a song { arrange = … } itself. The engine carries the arrangement runner (Unified Timeline): after load_module, arm_arrangement(restart) hands playback to the song's own written form — scenes apply at their bars, ramp automation sweeps, a written timeline seats its regions — and answers false when the module declares no arrangement (do nothing then). restart = true is transport ▶ (committed graph decisions cleared); false resumes from the current beat. Polling current_beat and calling apply_scene from the page is only needed for a form the page invents at run time. The same holds for the C ABI's volta_update driver, which arms the runner for the host.
Files#
libvolta/src/lib.rs (FFI + handles + render/control seam) · libvolta/src/driver.rs (the song driver) · libvolta/cbindgen.toml + libvolta/include/volta.h · libvolta/examples/chost.c + libvolta/run-example.sh · libvolta/tests/ffi.rs · scripts/embed-kit.sh (the browser kit, make embed-kit). Engine additions: AudioEngine::current_beat / seconds_until_beat (engine/src/lib.rs); volta_current_bpm reads AudioEngine::instantaneous_bpm.