VOLTAreference

Reference / Work in Studio

Morph between patches

Work in Studio

Open in the workbench →
On this page · 6 sections

Explore intermediate sounds between compatible patches and decide which parameter relationships should move together.

1. What it is#

morph : Patch p → Patch p → (Const Normalized | Signal Control Normalized) → Patch p

morph a b t produces a third patch whose every field is an expression in t, built at compile time from existing node algebra (crossfade, curve "sqrt", inverse, Sum/Product). There is no runtime morph machinery: the emitted synth is indistinguishable from a hand-written one. The result registers as an ordinary patch — it nests (morph (morph a b 0.5) c 0.25), serves as a play target, and accepts patch transforms.

import "keys" (pad, pluck)

plucky = morph pad pluck 0.3     -- constant t: compile-time preset blend

t    = macro "morph" 0.0
plad = morph pad pluck t         -- live t: one knob from pad to pluck

2. The type rule#

Both operands must be patches with the same field set; each paired field must have compatible units and kind. Violations are compile errors naming the field:

  • "cannot morph: a has cutoff but b does not — give both patches the same field shape" — field sets must match exactly. A patch that folds its filter into out only morphs against another of the same shape.
  • "cannot morph pitchMod: Cents vs Seconds" — units per field. The type-level check sees signal units; const-wrapped literals are checked at lowering off the folded values (const's return type erases the unit).
  • "cannot morph ampEnv: an ADSR envelope vs a signal" — same constructor per field.
  • audio with audio, control with control.

t must be Normalized: a constant in [0, 1] (out-of-range is an error; no extrapolation) or a control-rate signal — a macro is the usual case.

Operands must be top-level patch names declared earlier in the file, nested morph calls, or inline patch { … } literals. Anonymous with-derived patches are rejected — name the intermediate (pad2 = pad with { … }) and morph the name.

3. The blend rules (lerp-space table)#

Field kindConstant tLive t
adsr literalper-segment lerp, exactper-note morph (Stage 3): each differing segment becomes a cA + (cB−cA)·t signal sampled once at note-on; equal segments stay constant. No bake, no warning — held notes keep their envelope, new notes speak with the knob's current value
Audio source (source, out, any audio-rate field)equal-power a·√(1−t) + b·√t, weights foldedequal-power via curve "sqrt" / inverse nodes
Static-foldable scalar (both sides fold to constants)exact numeric lerp — Hz geometric (fa^(1−t)·fb^t), everything else linear (dB, Cents, Semitones are already log-scaled)Hz log-space (Stage 3): 1Hz * exp2(crossfade (log2 (fa / 1Hz)) (log2 (fb / 1Hz)) t) — log2 of the endpoints folds at compile time, so it agrees with the constant-t geometric lerp at every t; other units linear crossfade
Any other modulator / scalarcrossfade a b t with t baked (the endpoints t = 0 / t = 1 short-circuit to the original graphs, node for node)crossfade a b t with the macro on the mix port
Directive: voices, glideMode, glideCurvemust be equal in both patches (or both absent) — a half-mono, half-poly synth has no meaningsame
Directive: glide (numeric)lerpsmust be equal — error otherwise

The log-space Hz rule applies only when both endpoints fold to constants (cutoff floors/ceilings nearly always do); a non-foldable Hz modulator field falls back to the linear crossfade. Because log2 is applied to constants it collapses in the compiler — only exp2 of a bounded control signal runs live. The same log2/exp2 are exposed as builtins for user-level log-space tricks (1Hz * exp2 (crossfade (log2 (lo / 1Hz)) (log2 (hi / 1Hz)) (macro "sweep" 0))). Their inputs and outputs are dimensionless; the explicit reference quantity restores the frequency unit.

Per-note envelope morph means: a segment's effective value is latched at note-on. A note triggered at shape = 0.2 keeps that envelope for its whole life even if the knob moves; the next note re-samples. This is the only coherent meaning for "morphing an attack time," and matches how velocity is sampled per note. The engine samples the segment node at the note-on anchor.

Two structural notes:

  • Cross-field references blend coherently. A field that references a sibling (cutoff = … + filtEnv * …) resolves against the merged patch's sibling, so at t = 0 the whole graph is exactly a's and at t = 1 exactly b's.
  • Equal constants don't blend — a field that folds to the same number on both sides keeps a's expression with no crossfade nodes.

4. The CPU story (live t)#

Under a live source crossfade every voice runs both patches' source DAGs all the time, even at t = 0 — Volta runs one static graph per voice, with no runtime branching. Two saw-stack patches morph into roughly 2× the oscillators per voice. The Polyphony and voice management voice cap bounds the total; authors can lower voices on the morphed patch (equal on both endpoints). Stage 4 (source-tree alignment) removes the doubling for same-family patches.

5. Diagnostics#

  • Errors (field set, units, kind, directives, t range) are compile errors with the morph expression's span.
  • There is no baked-envelope warning — Stage 3 morphs envelopes per note, so a live t over differing envelopes is well-defined (the Stage-2 MorphBakedEnvelope warning has been removed).
  • The refine pass's interval analysis treats the crossfade weights as independent [0, 1] ranges, so a morphed cutoff may carry a spurious InAudibleRange advisory (it can't see that the weights sum to 1).

6. Implementation map#

  • compiler/src/builtins/record_algebra.rs — the morph descriptor (BuiltinKind::Morph): its a/b/t ports and the static and live blend readings; the real type rule is record-shaped.
  • compiler/src/check.rs — morph_result_ty: field-set, unit, and kind checks; result type is the unified record.
  • compiler/src/lower/application.rs — the elaborator: try_elaborate_morph → merged field list, per-field morph_field dispatch. Runs in lowering pass 1, beside the patch-transform path; the merged list registers in synth_fields, and pass 2a lowers it like a hand-written patch. The live log-space Hz path and adsr_live (per-segment morph signals) live here.
  • compiler/src/lower/expr.rs — the Crossfade / Curve lowerings join operand rates (types.rs::join_rate); the Log2/Exp2 builtin lowerings; and lower_adsr_segment / bake_at_initials — a signal-valued ADSR segment becomes a node ref (sampled at note-on) plus a static fallback baked at the macros' initials.
  • ir/src/ir.rs — NodeKind::Log2/Exp2; NodeKind::Adsr grew optional attack_node/decay_node/sustain_node/release_node (serde(default, skip_serializing_if), so all-constant ADSRs stay byte-identical).
  • Engine (engine/, the one renderer on desktop, in the browser worklet and in the plugin): Log2/Exp2 per-sample in engine/src/voice/eval.rs; CompiledNode::Adsr latches sampled segment values into NodeState::Env.eff at the note-on anchor.
  • The log2 / exp2 builtins preserve rate and operate on dimensionless ratios. Hz morphs multiply the result by an explicit 1Hz reference.
  • Tests: compiler/src/lower/tests.rs (morph_*, incl. morph_live_t_lerps_hz_log_space, morph_live_t_morphs_envelope_per_note), golden snapshot compiler/tests/snapshots/blend.json.
  • Example: examples/songs/blend.volta — a live morph glass growl shape lead whose two endpoints have different envelopes (per-note morph) with a shred riding the knob, plus constant-t blends of the stdlib pad/pluck/ep.