A contributor checklist for connecting a new command to the compiler, audio engine, editor and documentation.
A built-in flows through two coupled Rust systems that must stay in sync: compiler → JSON IR → engine, plus docs and tests. The IR has no separate schema file — ir/src/ir.rs's NodeKind is the contract, and the serde op tag is the identity every consumer dispatches on. The compiler re-exports these types; the engine depends on volta-ir without the compiler. When adding node fields, classify them in ir/src/ir/inputs.rs: its exhaustive visitor distinguishes graph inputs from labels and supplies pruning, remapping and validation. Then say what each input READS, in ir/src/edge_units.rs — visit_input_ports mirrors that visitor arm for arm and field for field, with no .. in either, so a new node kind and a new operand on an existing one will not compile until every input carries a PortReads. There are two: Reads(Dimension) for a port that means a physical quantity, and Dimensionless for audio, a trigger, a normalized amount, an index or a ratio. Dimensionless is a declaration, not a way out — write it when the ENGINE's read of that operand (engine/src/graph.rs and the eval_* it dispatches to) is a plain number, and say so where it is not obvious. validate_for refuses an edge whose producer carries the wrong dimension (an Hz output into a Seconds port), with the same three concessions the compiler's surface law makes: a scalar flows anywhere, the same dimension flows, and a musical period flows into a duration because the tempo map converts it at the consumer. The five *_values port validators (filter_values, envelope_values, reader_values, kernel_values, simulation_values) keep their own, narrower rules and run first, so a filter cutoff still gets their sentence. ir/src/validation.rs rejects missing references, cycles and unsupported execution contracts before preparation.
Wire references are typed. A field that names something outside the graph is not a String. A name the module itself defines carries a reserved prefix and is minted and parsed by ir::references::Handle (bounce:/bake:/comp:/def:/song:/partials:/latent:) — never by a format! or a starts_with; references::variant_base is the OTHER colon grammar, the bank of bd:3. A field that names somewhere audio is READ from — a track, a bus, or the reserved input — is ir::references::SourceRef: #[serde(transparent)], so the wire is the same bare string, with one membership test (is_input) and one resolver (role, or audio_role for the readers that may not name the master bus; routing::resolve_owner is role plus the refusal sentence). A new carrier joins NodeKind::visit_source_refs — exhaustive by variant — and by doing so joins the test that proves validate_for refuses a dangling reference at every site of the family, on the native path and the JSON path alike.
The execution envelope. Every module carries a header — formatVersion plus a requires list — that ir/src/validation.rs::validate_for checks before anything is installed, on the native typed path and the JSON path alike (both reach it through CompiledModule::from_module, so both receive the same sentence). formatVersion must equal ir::EXECUTION_VERSION; there is no migration, because silently reading a shape this build does not know is how a plan installs halfway. requires entries are ir::capabilities::Capability values — typed, but spelled on the wire exactly as before, and an unrecognized name decodes as Unknown rather than failing the decode, so the refusal is one validation sentence both callers get.
requires is derived, not authored: ir::capabilities::required reads it off the module's content and capabilities::declare writes it at the end of lowering, so every module the compiler emits declares exactly what it uses (golden_ir asserts that per song, over the whole corpus). What a host then does with an entry depends on what the host can do with the capability, which engine/src/capabilities.rs declares as a Fulfilment:
Executes— nothing happens.Refuses— the module is refused before installation, whether or not it declared the capability. Arequiresthat OMITS something the module uses is refused too, with its own sentence, so a hand-edited wire module cannot strip the header to get past a host that would have said no.Degrades— the module installs and that part renders as silence (or passes through dry). This is announced, not refused: the degradations ride onCompiledModule::degradedandWasmEngine::module_degradations()for the host to show. An omitted degrade is deliberately not fatal — a stale wasm bundle, or any hand-builtModule, would otherwise fail over a part that merely plays as silence.
When you add a builtin that only some builds can run, add its capability to ir/src/capabilities.rs (name, and a required derivation that finds it in the IR) and its fulfilment to engine/src/capabilities.rs, rather than folding it to silence in the engine. Derive from the node kinds the lowerer actually emits — tapLive with a stage option lowers to EndpointTap { live: true }, not to TapLive, and a derivation that missed that missed 30 of the 37 corpus songs that use it.
There is one engine (engine/). It renders the IR natively, in the desktop workbench, in the CLAP/VST3 plugin, and in the browser (compiled to wasm by engine-wasm/ and run in an AudioWorklet). The old TypeScript Web Audio rendering path was deleted 2026-07-24 and the TypeScript workbench deleted 2026-08-09, so there is no second DSP implementation to mirror a builtin into. What is left in runtime/src/ is the product pages, the page-side host, and two op-dispatching mirrors that are machine-checked (§A.3).
First decide which kind of thing you're adding — the work differs sharply:
| Adding… | Means | Path |
|---|---|---|
| Compiler built-in / primitive node | a DSP primitive the compiler knows and lowers to an IR node the engine instantiates | §A (the big one) |
| Stdlib function | a reusable patch written in plain .volta source, composed from existing built-ins | §B (no compiler changes) |
| New syntax (keyword, operator, literal) | lexer/parser-level surface | §A + compiler/src/lex.rs / compiler/src/parse.rs |
A. Adding a compiler BUILT-IN (new primitive node)#
A.1 Rust compiler (compiler/src/)#
| File | What changes |
|---|---|
compiler/src/builtins.rs | Add a BuiltinKind enum variant, then classify it in BuiltinKind::track_role — an exhaustive match, so the crate won't compile until you do. TrackRole::None is the ordinary signal-world answer; the others are Source (play/sound/playOn), Effect (a mixer-bus insert), Modifier (a track-chain stage that sets a parameter/lane or hosts a plugin), Pattern and Arrangement. Source/Effect also make t = play … |> yourFx register t as a track, and every non-None role is rejected in signal position. This is the single source of the two guards that used to be hand-maintained lists; compiler/tests/builtin_roles.rs pins the derived sets and compiler/tests/builtins_signatures.rs pins the registration itself. Do not hand-register a builtin with a call port in builtins(): every such name is descriptor-owned, and builtins() projects name, signature and handler kind from descriptor_registry(). Hand add(Builtin { … }) rows remain only for the note inputs (pitch, vel, …) and a few nullary/special forms (for a bare nullary signal-source meaning, also extend signal_source()). |
compiler/src/builtins.rs (origin module) | Every builtin belongs to an origin module with a qualified spelling (track.gain, grain.size — Names and library imports §4). The map is section/prefix-derived (derive_module), so most names need nothing; add a QUALIFIED_OVERRIDES row only when the qualified member should differ from the flat name, and decide prelude membership — a niche builtin may join NON_PRELUDE (empty today) to be reachable only qualified. compiler/tests/builtin_modules.rs pins totality, injectivity, and tag safety (a module tag may never equal a registry name). |
the family module under compiler/src/builtins/ | Add the builtin's descriptor — one entry in the right family (controls.rs, track_effects.rs, shapes.rs, …): its parameter schema (schema_of(name, receiver, &[p(…), …]), every argument in positional order with its slot, default, clamp and liveness — Named options and parameter values), and an Execution per implementation and port. This is what gives the call named options (f x { opt = … }), the arity ceiling, check-time mode-string validation, and the catalogue signature; a descriptor must spell one port per slot, optional trailing ones included. compiler/tests/named_options.rs::schema_defaults_match_the_lowerer fails until every default equals the lowering arm's. Each port also needs a call/<name>/overload/<impl>/<port> row in compiler/assets/parameter-audit.json (then python3 scripts/parameter-inventory.py). A new family module joins builtins::descriptor_registry, and compiler/tests/descriptor_touchpoints.rs holds the other projections to it. The full recipe is Named options and parameter values § "Adding a descriptor-owned builtin". compiler/src/params/table.rs is the empty legacy table — do not add rows to it. |
ir/src/ir.rs | Add a NodeKind variant with serde-serializable fields. This is the JSON IR contract. Use #[serde(rename/default/skip_serializing_if)] to control the JSON shape. A track-level effect is a TrackEffect variant instead (or as well). |
compiler/src/lower/expr.rs, compiler/src/lower/expr/controls.rs | Add the handler lowering the call to a NodeKind: a control gets a lower_control arm in lower/expr/controls.rs (an exhaustive match over its GraphImplementation, with type obligations in compiler/src/semantics/controls.rs); other voice-world builtins an arm in lower_app. Fold compile-time-constant args where possible. |
compiler/src/lower/track.rs | Only for a track-world builtin: lower_track_expr (its lower_track_expr_inner arms, which are not exhaustive) is where a TrackRole::Effect/Modifier becomes a TrackInsert/TrackEffect on the strip. compiler/src/lower/mixer.rs applies the same effect sections to mixer strips. |
compiler/src/lower/application.rs, compiler/src/lower/combinator.rs, compiler/src/lower/pattern.rs, compiler/src/lower/score.rs, compiler/src/lower/timing.rs | Special shapes: patch application/morph, pattern combinators, score/shred forms, and the rated-pattern materialization boundary. |
compiler/src/check.rs | Auto-picks the builtin up from builtins() and its descriptor. Edit only if it needs polymorphism / rate-relaxation rules. |
compiler/src/refine.rs | Optional — only if the node does arithmetic / needs output-range (interval) analysis. Unlisted kinds fall through to unbounded. |
compiler/src/cost.rs | Optional — a relative DSP weight for the static cost model. Unlisted kinds get the fallback weight. |
compiler/src/viewer.rs | Optional one-liner — classify the new op tag if the notebook should draw a per-instance viewer for it (See patterns and signals). |
compiler/src/lex.rs, compiler/src/parse.rs, compiler/src/resolve.rs, compiler/src/eval.rs, compiler/src/mini.rs | Only when adding genuinely new syntax (keyword, operator, mini-notation token) rather than a named function. compiler/src/eval.rs matters if the builtin is compile-time-foldable (list ops, range, …). |
A.2 Rust engine (engine/) — the only DSP implementation#
| File | What changes |
|---|---|
engine/src/graph.rs | compile_node is the ir::NodeKind → CompiledNode compile: add the arm that resolves id-references to indices and flattens the node into the evaluator's representation. This is the engine's half of the IR contract. |
engine/src/voice/eval.rs | Add a one-line dispatch arm to the per-block node walk, calling self.eval_yourthing(…). |
the matching topic file under engine/src/voice/ | Write the eval_* body where the node belongs — engine/src/voice/eval_nodes.rs, engine/src/voice/eval_osc.rs, engine/src/voice/filters.rs, engine/src/voice/modfx.rs, engine/src/voice/shaper.rs, engine/src/voice/noise.rs, engine/src/voice/granular.rs, engine/src/voice/eval_physical.rs, engine/src/voice/eval_sampler.rs, engine/src/voice/vocode.rs, engine/src/voice/feedback.rs. Per-voice state goes in engine/src/voice/state.rs. |
engine/src/effect.rs | Track-level effects only: TrackFx::build turns a TrackEffect into a bus-insert instance whose state persists across blocks. Standalone DSP usually gets its own module beside it (engine/src/plate.rs, engine/src/fdn.rs, engine/src/eq.rs, …). |
engine/src/lib.rs | Only for a track effect with an external binding (a sample buffer, a hosted plugin): add it to insert_binds_external so the chain rebuilds when the resource reloads. |
Nothing else in the engine needs to know. engine-wasm/ is a thin wrapper — the browser gets the new node for free once you rebuild it (§D).
A.3 The two op-dispatching mirrors (machine-checked)#
| File | What changes |
|---|---|
runtime/src/types.ts | The Node discriminated union mirroring NodeKind for the product pages. The op string + field names/types must match the Rust IR JSON exactly. runtime/src/ir-types-sync.test.ts fails on any variant with no arm (and on stale arms). |
ui/src/modgraph.rs | The workbench's mod-matrix walker, dispatching on the quoted op tag (classify_source / arith_children / trace_sources / const_floor). It fails soft, so the same suite requires every NodeKind to be either walked here or listed in its CARRIERS set — list a node there only if it is a true audio carrier the mod trace should terminate at. |
A.3b A new kernel form or kernel op#
A kernel-shaped builtin (a process* / spectral* form) reuses the kernel bytecode rather than adding a node per law. A new KernelOp variant must be classified in ir/src/kernel_values.rs (check_ops — refuse it in shapes that cannot supply it, as Bin/BinAt are refused outside check_spectral — and hoist), given arms in engine/src/kernel.rs (both interpreters and the fast-tier decode, which may decline it), in compiler/src/kernel.rs::eval_ops_full, and listed in runtime/src/types.ts's KernelOp operand fields. A new kernel FORM adds a KernelForm in compiler/src/lower/expr.rs (lower_kernel), a Layout flag in compiler/src/kernel.rs if its context names differ, a lambda Arrow in builtins/overloads.rs and a descriptor in builtins/fn_operands.rs. Kernel-only context names (sampleRate, binHz, …) are hand-registered Builtins whose nullary use outside a kernel is a lowering error naming where they belong. See Write a spectral effect for the worked example.
A.4 A STATEMENT-shaped builtin (a new soft keyword)#
A top-level statement — assert (cond) "msg", modulate m (graph) — is not a new declaration surface: it parses as a hidden, private binding on an ordinary registry builtin whose result is Ty::Unit (parse_assert_decl / parse_modulate_decl in compiler/src/parse.rs), which lowering intercepts in pass 1 and erases. Four things go with it, and three of them fail silently:
| File | What changes |
|---|---|
compiler/src/parse.rs | a DeclForm variant plus its arms in starts_form (a starts_<kw>_decl shape test), parse_form and the parser test's decl_form_sample. The one variant list drives both parse_program's dispatch and atom_starts's refusal (via interrupts_expr), so the previous declaration's application loop cannot swallow the statement; the matches are exhaustive, so a missing arm does not compile. Discriminate a binding of the same name the way starts_anon_track does: refuse = / : after the head, and refuse starts_fn_decl_lhs. |
compiler/src/lower/expr.rs | a BuiltinKind arm refusing it inside a signal expression, with its own message (the type checker usually gets there first, but a Ty::Hole slot does not). |
ui/src/benchmodel.rs | col0_opens_top_level mirrors the parser's top-level shapes. Left out, the Bay splices edits after the statement into the preceding declaration's block. |
ui/src/corpus.rs | DECL_HEADS, if the statement's second word is the name a reader would search for. |
Entry-only is free, and it is reachability that gives it. Nothing references the hidden binding, so resolve.rs's reachability walk drops a library's copy before lowering ever sees it — which is also why the per-parse fresh_name() counter cannot collide across modules. An origins-based skip in lowering is belt and braces, not the mechanism.
Docs & tests — see §C / §D below.#
B. Adding a STDLIB function (plain .volta patch)#
Stdlib functions need no compiler changes — they're built on the module system and compose existing built-ins. See The standard library.
| Location | What changes |
|---|---|
the library modules in examples/songs/ (examples/songs/fx.volta, examples/songs/drums.volta, examples/songs/rhythm.volta, examples/songs/bass.volta, examples/songs/keys.volta, …) | Source of truth. Add the patch to the right file. A new file also needs its bare name in runtime/public/lib/index.json — that tracked manifest defines the library set. |
the generated copies under runtime/public/lib/ | Written by runtime/scripts/sync-assets.mjs (npm runs it on predev/prebuild/pretest) — gitignored; never hand-edit or cp into them. |
docs/reference/stdlib.md | List the new patch in the as-built reference. |
docs/design/shipped/stdlib.md | Tick the stage/status if it's a tracked addition. |
C. Docs & in-app reference (almost always, both paths)#
Registering a builtin without documenting it now fails make test: the docs-sync system (README "The machine-checked core") compares the registry against every doc surface.
| File | What changes |
|---|---|
compiler/assets/docs-data.json | Most-missed file (machine-checked). The reference catalogue — the single source of truth for every doc surface. Add an entry to entries (name, section, params, signature, tier, blurb, optional detail/example/keywords). It feeds volta_compiler::docs (hover, completion, signature help — and the LSP through them) and both generated mirrors. compiler/tests/docs_catalogue.rs fails if a registered builtin has no entry. params is indexed positionally by the hover card and by signature help, so follow the conventions in compiler/src/docs.rs: every argument the lowerer accepts, receiver first (chop src n — a |> stage shifts past it automatically), [x] for optional only, list literals spelled [a, b, …], one argument per whitespace-separated token, and the pipe-last combinators' pattern argument FIRST (fast p n). Keep example compilable — compiler/tests/docs_examples.rs compiles every one as a bench island. The file is embedded with include_str! in compiler/src/docs.rs, so any write to it rebuilds volta-compiler and every crate above it (CLI, workbench, desktop app, LSP, wasm packages, the engine's test binaries) in every profile — Cargo tracks the input by modification time, so even a byte-identical rewrite counts. Batch catalogue edits before building; the mirror generator for ui/assets/docs-data.json skips identical rewrites for the same reason. |
| the catalogue's generated fields | options/params come from the parameter-schema row, tier from the CORE/LAB tables in compiler/src/params.rs. Re-sync with UPDATE_SNAPSHOTS=1 cargo test -p volta-compiler --test docs_catalogue catalogue_matches_the_parameter_schema, then python3 compiler/scripts/sync-docs-options.py (and python3 compiler/scripts/sync-docs-tiers.py if the tier moved), then the two mirror generators below. |
compiler/src/docs/help.rs | Generated hover presentation; do not add a separate tooltip entry. It joins schema slots/defaults/choices/liveness, concrete registry types, and the catalogue's names/polymorphic signatures. Keep each parameter schema's first sentence readable for musicians; implementation notes belong in later sentences. compiler/tests/tooltip_help.rs covers every builtin and every reserved keyword from lex::KEYWORDS. Native, browser and LSP hover share this generator. |
runtime/src/docs-data.ts, ui/assets/docs-data.json | Generated mirrors — never hand-edit. Regenerate both after a catalogue edit: node runtime/scripts/gen-docs-data-ts.mjs && node runtime/scripts/gen-docs-data-json.mjs. compiler/tests/docs_catalogue.rs (an FNV-1a hash pinned in the TS header) and ui/tests/docsdata.rs fail while either is stale. |
docs/reference/builtins.json | Generated — regenerate with UPDATE_SNAPSHOTS=1 cargo test -p volta-compiler --test builtins_manifest. The machine-readable registry manifest runtime/src/docs-sync.test.ts anchors on. |
docs/reference/language.md | The whole-language file — the AI copilot's grounding prose. Mention the builtin in the right §; runtime/src/docs-sync.test.ts fails if a registered builtin is never mentioned, and if any file in docs/reference/ is unreachable from its §16 index. |
editors/vscode/syntaxes/volta.tmLanguage.json | Generated builtin vocabulary. Run npm --prefix editors/vscode run metadata:update; metadata:check (part of the extension's npm run check) rejects drift. Do not hand-edit its builtin expression. |
ui/src/complete.rs, lsp/src/analysis.rs | Only when the symbol needs special context completion beyond the shared catalogue (for example sample-name completion). |
the canonical per-feature doc in docs/reference/ | Create one for a substantial feature (cf. docs/reference/granular.md, docs/reference/fm.md, docs/reference/warp.md); update the existing one otherwise. A new file must also be classified in docs/reference/categories.json and indexed in docs/reference/language.md §16. |
ui/assets/reference-corpus.json | Generated from the Markdown in docs/reference/ — after touching any reference doc run make docs-reference (node runtime/scripts/gen-reference-corpus.mjs). runtime/src/docs-sync.test.ts rebuilds the model in memory and fails while the committed copy is stale. |
docs/ARCHITECTURE.md | Update if implementation/IR/lowering behavior changed; §8 tracks the spec↔shipped delta, §1 the repo tree. |
docs/TODO.md | Tick the shipped stage; it indexes the design specs. |
the owning design spec under docs/design/ | Tick the spec's own Status: marker; git mv proposed→active→shipped as it progresses. Then run make docs-now — runtime/src/docs-status.test.ts fails while the generated blocks in docs/NOW.md and docs/TODO.md are stale. |
docs/ideas.md | If the idea originated here, remove it once it has graduated to a design spec. |
Do not touch docs/spec.md. It is the historical 2025 design thesis (README, "Keeping docs in sync"). The as-built language is owned by docs/reference/language.md, which is machine-checked; intended-but-unbuilt design belongs in a spec under docs/design/.
D. Tests & rebuilds#
| File / command | What changes |
|---|---|
a song in examples/songs/ | Add or extend one that exercises the new builtin (these double as docs and CI). examples/songs/ is the single corpus; runtime/public/songs/ holds generated copies. |
compiler/tests/golden_ir.rs + the snapshots in compiler/tests/snapshots/ | The golden test compiles every song in examples/songs/ and diffs JSON IR against the snapshots. After any IR-shape change, regenerate with UPDATE_SNAPSHOTS=1 cargo test --test golden_ir and review the diff carefully. |
compiler/tests/types_props.rs | Add type-checking / signature cases. |
engine/src/voice/tests.rs and the per-family suites in engine/tests/ | Unit-test the DSP where the behavior is non-obvious — most families already have a per-topic file to extend (engine/tests/svf_static.rs, engine/tests/physical_models.rs, engine/tests/shaper.rs, engine/tests/granular_cloud.rs, …). |
engine/tests/golden_audio.rs | Only if the builtin is an analog/circuit-model family that deserves a bit-pinned canonical clip; bless with UPDATE_SNAPSHOTS=1 cargo test -p volta-engine --test golden_audio after reviewing the printed peak/RMS diff. |
engine/tests/audio_gates.rs | The whole-song rendered-audio gates. A new song entry (or a measured change to an existing one) is re-blessed with UPDATE_SNAPSHOTS=1 cargo test -p volta-engine --test audio_gates — the absolute gates are not blessable and must be fixed, not accepted. |
./build-wasm.sh | Rebuild the compiler wasm package after compiler changes, or the browser compiles against a stale language. |
./build-engine-wasm.sh | Rebuild the engine wasm/AudioWorklet after engine changes, or browser playback silently diverges from the desktop. |
./build-ui-wasm.sh | Rebuild the browser workbench after any change under ui/. |
make test-fast | The PR-sized native gate: compiler + engine libs, audio_gates, the workbench behavior suite, the Worker service's tests, lessons and notebooks. make test runs every native suite; make test-core is the Rust workspace alone; make test-browser is the wasm builds and the runtime suite. |
The two easiest things to forget#
compiler/assets/docs-data.json+docs/reference/language.md— both are enforced:compiler/tests/docs_catalogue.rschecks the catalogue straight against the registry (and that the generated mirrors were regenerated), andruntime/src/docs-sync.test.ts(run bymake test-runtime) checksdocs/reference/language.mdagainst the generateddocs/reference/builtins.json— so forgetting either fails the suite instead of silently shipping an undocumented builtin.- Golden IR snapshots (
compiler/tests/golden_ir.rs) — any IR-shape change breaks them until regenerated. Their quiet twin isruntime/src/ir-types-sync.test.ts: a newNodeKindwith noruntime/src/types.tsarm and noui/src/modgraph.rshandling fails there, because both mirrors would otherwise degrade silently.
Minimal end-to-end checklist (simple monomorphic builtin)#
compiler/src/builtins.rs—BuiltinKindvariant +track_roleir/src/ir.rs—NodeKindvariantcompiler/src/lower/expr/controls.rs(lower_control) orcompiler/src/lower/expr.rs(lower_app) — the lowering arm- the family module under
compiler/src/builtins/— the descriptor (schema, named options + arity, executions), plus itscompiler/assets/parameter-audit.jsonrows engine/src/graph.rs— acompile_nodearmengine/src/voice/eval.rs— a dispatch arm, plus theeval_*body in its topic fileruntime/src/types.ts—Nodeunion arm (must match the IR JSON)ui/src/modgraph.rs— teach the mod-matrix walker the newop(or list it as a carrier)compiler/assets/docs-data.json— a catalogue entry, thennode runtime/scripts/gen-docs-data-ts.mjs && node runtime/scripts/gen-docs-data-json.mjsnpm --prefix editors/vscode run metadata:update— fallback grammar vocabularydocs/reference/language.md— mention it in the right §- a song in
examples/songs/that uses it UPDATE_SNAPSHOTS=1 cargo test --test golden_ir— regenerate + review the golden snapshotUPDATE_SNAPSHOTS=1 cargo test -p volta-compiler --test builtins_manifest— regenerate the manifest, thencd runtime && npx vitest run src/docs-sync.test.ts src/ir-types-sync.test.tsto confirm every doc surface and bothopmirrors know the new builtin./build-wasm.sh && ./build-engine-wasm.sh— or the browser keeps the old language and the old DSP