VOLTAreference

Reference / For contributors

Embed Volta in another application

For contributors

Open in the workbench →
On this page · 6 sections

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, artifacts libvolta.{dylib,so,a}).
  • Header: libvolta/include/volta.h — generated by cbindgen, checked in; regenerate with make libvolta-header after changing the FFI.
  • Example host: libvolta/examples/chost.c; build + run with make 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 in make 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#

GroupCalls
Compilevolta_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
Enginevolta_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)
Rendervolta_render(e, L, R, frames) · volta_render_interleaved(e, out, frames, ch) — ch ∈ {1, 2}; any frames (chunked internally to the engine BLOCK)
Transportvolta_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
Instrumentvolta_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)
Macrosvolta_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)
Discoveryvolta_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
Positionvolta_song_position(*out) (playing / beat / step / step_count / song_frac) · volta_song_step_name(i)
Visualsvolta_viz_json (heap JSON, volta_string_free; NULL when nothing new)
Miscvolta_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_update steps 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 with volta_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, which volta_load_module uses, so follow-up calls can name the new tracks at once) or the previous identity with control changes refused until finish_install adopts 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 after MODULE_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_song stops everything first (transport re-anchors; the song's beat axis starts at 0), applies the first scene immediately, and auto-advances through the resolved arrange on volta_update ticks (advance is queued ~0.5 beat early; the engine quantizes to the bar). A bar is the module's: steps, song_frac and scene boundaries count in bars of its meter (3.5 beats in 7/8) and follow a meterMap. 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-arrange songs advance uniformly ≈14 s per scene (browser parity); songGraph songs hold scene 0 — navigate with volta_apply_scene.
  • Automation. Section with { m = v } pins apply; ramp a b sweeps across the arrange step over the engine's Command::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, marked RampStart::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 the volta_update cadence. A manual volta_apply_scene does the same, ramping over the scene's first arranged length (else one bar) at the base tempo. songPos / sectionPos ramp 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 second EngineHandle::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_info carries the range); moves smooth ~15 ms engine-side. volta_ramp_macro aims at from now (the smoother glides there) and sweeps linearly to to; a later volta_set_macro cancels it. volta_macro_value reads 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. pitch is a MIDI note number as float (69 = A4; fractional bends), vel 0..1. volta_one_shot plays 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_position keeps reading playing = 0). volta_note_on / note_off / note_vel are held voices on the engine's preview bus (the patch inspector's audition bus), keyed by a host id (re-using a live id re-triggers; a note holds up to an hour). That bus's monitor gain starts at 0, so the first volta_note_on after a module load raises it to 1.0 — unless the host called volta_preview_monitor itself (0 = a silent study voice; 0..1.5). A held note survives a volta_load_module that 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, its id still valid for note_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 — place included — instead of reaching the master directly; nothing changes in how the host triggers them. volta_pause holds the main bus (tracks and one-shots) and volta_resume releases 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 and soft_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_shot do 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 (None when two do). No C ABI entry point yet — a volta_set_preview_route(const char*) wrapper over those two calls is all it needs; the browser kit already exports WasmEngine::set_preview_route / sole_track_for_synth.
  • Stopping one track lifts its keys. volta_stop_track stops 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_stop is 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 declared tempo. volta_current_bpm is the instantaneous tempo as of the last render (it sweeps during a ramp; 0 when stopped).
  • Samples load from WAV files (hound decode, 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_rate 0 = 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_json is the engine's reduced viz snapshot as JSON — camelCase keys time, songClock, tracks (per-track level / onset data), macros, voices, analysis, master (loudness + true-peak meter), pitches (sounding MIDI notes) — the same document the browser's VizBus consumes. 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.volta beside 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, vocode work; input/onsets render 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 }), then compile(src, sources) (IR JSON, throws the caret diagnostic) or compile_checked(src, sources) (never throws; structured diagnostics). sources is { "<import path>": "<module text>" }, read from lib/.
  • <kit>/worklets/engine-single/<hash>/native-engine-processor.js — the processor kit.json names (processor, beside engineWasm in the same content-addressed <hash>/ directory, and engineBuild, a module whose ENGINE_BUILD names both); it imports ../../native-engine-core.js, which needs kernel-code-host.js and textcodec-polyfill.js beside 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.addModule it, create new AudioWorkletNode(ctx, "volta-native-engine", { outputChannelCount: [2] }), post { type: "init", module: <wasm bytes> } and wait for { type: "ready" }; then every WasmEngine method (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 (input in Volta).
  • <kit>/lib/index.json + *.volta — the shipped stdlib, as text.
  • <kit>/wavetables/index.json + *.json — load_wavetable(name, json) a table before load_module when 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.