VOLTAreference

Reference / Language and libraries

Configurable library modules

Language and libraries

Open in the workbench →
On this page · 6 sections

Reuse a module with a chosen configuration so one library can serve several related sounds or musical roles.

What it is#

A module parameterized over a config — ML's functor, framed for music as a re-skinnable kit. One module Name(params) = { … } definition yields a whole family of voices (or patterns): you apply it with a named config and reach its fields by qualified access. "Give me a sample set and I'll give you a whole drum instrument"; "give me a key and I'll give you a voiced progression." Each application flattens into its own independent synths/patterns, and any macro knob declared inside is namespaced per instance — all at compile time, so the module evaporates into the flat IR the runtime already understands.

Surface#

-- in a library module (no song of its own):
module drumKit(punch: Normalized, tone: Normalized) = {
  kick  = patch { source = sine (freq pitch), ampEnv = adsr 0ms (60ms + punch * 90ms) 0 80ms, … },
  snare = patch { … },
  hat   = patch { … }
}
-- in a song: apply with a named config, reach fields by qualified access
import "kits" (drumKit)

k909 = drumKit { punch = 0.85, tone = 0.6 }   -- one instance
k808 = drumKit { punch = 0.3,  tone = 0.2 }   -- another, fully independent

kick9 = play k909.kick  !"g1 ~ g1 ~"
hat8  = play k808.hat   !"c4 c4 c4 c4"

A module's fields can be patches (k909.kick is a synth), pattern values (pop4 { key = tonality "c major" } exposes inC.changes / inC.bassline) or tracks (below). Qualified access also works in-file (drums.kick) and cross-module via an alias (import "x" as a → a.thing). An inline macro "drive" inside a kit body becomes a per-instance knob: k909.drive and k808.drive are distinct sliders in the rack.

Qualified inline macros can also drive ADSR times inside a module instance: their declared initials supply the envelope's constant fallback while the live node connections stay active (compiler/tests/module_envelope_macros.rs; Ready-made instruments uses it for per-instrument envelopes).

A macro passed as a module argument is aliased, not copied (2026-09-16). A module that writes color = macro "tone" tone over its own tone parameter mints k909.tone when the argument is a number — one knob per instance, as above. When the argument is a top-level macro, the instance's inline macro becomes that macro instead:

module electricGuitar(tone: Normalized) = {
  voice = patch { source = saw (freq pitch) |> lpf (800Hz + (macro "tone" tone) * 4000Hz) 2, gain = vel * 0.5 }
}

tone = macro 0.84                      -- the bench knob
gtr  = electricGuitar { tone = tone }  -- gtr reads `tone`; no `gtr.tone` exists

One knob, live: moving tone moves the instance. Two instances over the same argument share the one knob. Before this, the instance minted a second knob whose initial was 0.0 (an argument that is not a literal never reached the initial) and the song's own knob drove nothing — a silent, two-knob failure. An argument that merely mentions a macro (tone * 0.5) cannot alias — a macro's initial is a number, not a graph — and is a compile error that says so, rather than the dead knob it used to be. Scale the macro inside the module.

Any other argument must fold to a number — a literal, a negative literal, a top-level constant or arithmetic over constants (lvl * 0.5) all mint <instance>.x at that value. A signal (linram 0 1 1s) or anything else that does not fold is a compile error naming the knob that would sit dead at 0, and a third (modulation) argument on a module's inline macro is refused rather than dropped: the per-instance knob carries no graph, so declare a modulated macro at top level and pass it in to alias it.

Example songs: examples/songs/kitfactory.volta (two re-skinned drumKits), examples/songs/inkey.volta (a key-parameterized pop4 progression). Stdlib source: examples/songs/kits.volta.

An instance exports tracks#

A field whose value is a track is a track of that instance — the rack pattern where a toy module owns its own line and the entry program only wires it up:

tone = patch { source = saw (freq pitch), gain = vel * 0.3 }
module rig(k: Normalized) = { line = play tone !"c3 e3 g3" |> gain k }

a = rig { k = 0.4 }
b = rig { k = 0.9 }
set = song { main = [a.line, b.line] }   -- two independent tracks

Each instance's field is registered as the named track value <instance>.<field>, the way a patch field registers a synth. So a bare projection lowers (out = r.line), a stage may be piped onto it (out = r.line |> gain 0.5), a scene list may name it, and tap "r.line", duck "r.line" or a host's start_track("r.line") address it by that name (for a mixer strip see "Notes & limits"). Two instances of one module give two independent tracks, with the inline macro "x" knobs in their bodies qualified per instance (a.lvl, b.lvl) by the same rule as a patch field's.

The emission rule (the same one a consumed import follows, Names and library imports § "Imported tracks"). The instance's track is lowered and validated under <instance>.<field>, and then kept in the IR only if the finished module still names it. So:

  • out = r.line is a rename: the IR carries ONE track, called out — not the instance's copy as well. out = r.line |> gain 0.5 likewise.
  • song { main = [r.line] } (or a tap "r.line", or any other by-name reference that survives into the IR) emits the track under r.line.
  • a record that merely happens to carry a track field, with nothing naming it, emits nothing — so no existing song's IR moves.

A projection is resolved once and shared, so a field named by two consumers lowers (and draws its randomness) a single time.

Configurations are ordinary values#

A module takes one record argument. The parameter list describes that record's fields; its body sees each field as a lexical binding. You can name, import, return, locally bind, project or update the configuration:

module voice(tone: Hz, level: Normalized) = {
  lead = patch { source = saw tone, gain = level }
}
makeSettings = \frequency amount -> { tone = frequency, level = amount }
settings = makeSettings 440Hz 0.3
kit = voice (settings with { tone = 660Hz })
main = play kit.lead !"c3 e3 g3"

Aliases and higher-order helpers can carry the module function too: other = voice, followed by other settings, uses the same rules as voice settings. A zero-parameter module takes {}. Untyped parameters still require the corresponding fields. A typed config rejects missing, extra, duplicate and incompatible fields; errors for an imported config's incompatible shape point to the caller's argument. Editor field suggestions come from the record parameter type, including ordinary functions that accept records.

A parameter may declare a default. The parameter list is the configuration record's field list, so a default written there is a record-field default and reads by exactly the same rule (Language reference §2 "Default arguments"; rationale and the rules in full: User Function Arguments):

module comp(gain: Normalized = 0.5, pan: Bipolar = 0) = {
  lead = patch { source = saw (freq pitch), gain = vel * gain }
}
hot  = comp { gain = 0.9 }   -- pan is 0
flat = comp {}               -- gain is 0.5, pan is 0

The default must be a literal — a number with an optional unit suffix (negatives included), a string or a boolean — so that it means the same thing at every call site, including one in a file that imported the module; an expression is refused where it is written. The default is spelled at the call, so an alias, an import, a returned factory, a projected field and a list element all fill the same values, and the completed call lowers to the same IR as the fully written one. A field without a default is still required, with the same caller-owned record is missing \x\`` sentence.

An alias of an already constructed kit shares its instruments and controls. Applying the factory again constructs an independent instance. Static choices (if / case) and forward configuration declarations also work. Existing limits on general type inference and runtime branching still apply.

Compatibility: the documented voice { tone = …, level = … } spelling is unchanged. The old incidental positional-module call voice 440Hz 0.3 must be written voice { tone = 440Hz, level = 0.3 }. Ordinary functions now receive a record intact: get = \x -> x.x applied to { x = 0.3 } receives the whole record. Matching a field name to a function parameter no longer silently unpacks the argument.

How it's implemented#

  • Compiler:
    • parse.rs — starts_module_decl / parse_module_decl parse the module Name(params) = { … } soft-keyword form and desugar it to a single lambda taking a structural record, with lexical projections in its body. The record parameter has a private name (#module-config) no source can spell, followed by one generated let per field projecting it, so a projection can never capture a user binding. Because a module is an ordinary function over that one record, an alias of a module behaves exactly like the module; nothing matches a record field against a lambda parameter name. Readers of the authored model undo the desugaring: the Rack peels the projections back into the original parameter names and spans (ui/src/rackedit.rs::authored_lambda), and the editor's completion data labels the record config (compiler/src/ide.rs::lambda_parameters).
    • eval.rs — evaluates record construction, projection and updates through ordinary lexical substitution. Records remain typed expressions inside CtValue::Expr; the compiler does not need a separate runtime record.
    • check.rs — ordinary record compatibility checks the config field types; bounded structural resolution follows factories without evaluating music. Declared field units survive projection of bare numeric values. A parameter default is carried on the field's type as Ty::Defaulted (compiler/src/types.rs); code reading a record field's type goes through Ty::declared() rather than matching the type directly. fill_declared_defaults is the one place defaults are filled in, spelled at the caller's span, which is why an alias, an import or a factory all receive the same completed record.
    • lower.rs — a binding whose value is a record of patches is a kit instance: each field flattens to a synth named <instance>.<field>, and qualify_inline_macros rewrites the body's inline macro "x" literals to <instance>.x so two instances get distinct knobs (Macros and automation). A track-typed field takes the same route: pass 1 registers it on TrackBindings (lower/track_bindings.rs::register_instance_field), lower/track.rs's TExprKind::Field arm resolves a bare projection through that cache, and pass 2a″ emits it into the reference-gated set the consumed-import sweep already filters.
    • resolve.rs — import … as aliasing is the Names and library imports pass (alias_target + rename_refs).
  • Runtime: none. Like Reuse sounds and patterns with imports, parameterized modules are a pure compiler concern — everything flattens into the flat IR before the runtime (or native engine) sees it.
  • Assets: the demo kits live in examples/songs/kits.volta (drumKit, pop4), surfaced through the stdlib loader.

Notes & limits#

  • Instance fields flatten to dotted names in the one flat IR namespace; the module-level namespacing story is Names and library imports.
  • A track-typed field needs a top-level instance to be addressable: <instance>.<field> is built from the binding's name, so a record bound inside a let has no qualified track name (its projection still lowers).
  • The reference gate reads the lowered tracks, synths, scores, songs and automation lanes. A strip declared only in mixer { … } is lowered after that sweep, so name an instance track from a scene (or bind it) as well as from the mixer.
  • A module parameter can be annotated Patch (≡ SynthPatch), Number (≡ Float) or Note (≡ Pitch): module lane(inst: Patch, pat: Pattern Note) = { t = play inst pat }. Patch erases to an open type, so a wrong instrument is caught where instruments resolve ("instrument argument does not resolve to a synth"), not at the annotation. A user type Note = … shadows the builtin spelling.
  • Records are compile-time structure; projecting a signal field yields its signal expression. Arbitrary runtime record allocation is not supported.
  • Aggregate and generic typing shipped with Language Foundations Stage 2 ; that spec's § "Not scheduled" lists the follow-ups that still need a decision.
  • Typed params (Stage 3) lean on Define your own value types for the field vocabulary. module is now sugar for a function of a record. Trait/tuning overlap and re-export remain Configurable library modules §8 open questions.