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:
ahascutoffbutbdoes not — give both patches the same field shape" — field sets must match exactly. A patch that folds its filter intooutonly morphs against another of the same shape. - "cannot morph
pitchMod:CentsvsSeconds" — 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 kind | Constant t | Live t |
|---|---|---|
adsr literal | per-segment lerp, exact | per-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 folded | equal-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 / scalar | crossfade 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, glideCurve | must be equal in both patches (or both absent) — a half-mono, half-poly synth has no meaning | same |
Directive: glide (numeric) | lerps | must 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 att = 0the whole graph is exactlya's and att = 1exactlyb'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,
trange) are compile errors with the morph expression's span. - There is no baked-envelope warning — Stage 3 morphs envelopes per note, so a live
tover differing envelopes is well-defined (the Stage-2MorphBakedEnvelopewarning has been removed). - The refine pass's interval analysis treats the crossfade weights as independent
[0, 1]ranges, so a morphedcutoffmay carry a spuriousInAudibleRangeadvisory (it can't see that the weights sum to 1).
6. Implementation map#
compiler/src/builtins/record_algebra.rs— themorphdescriptor (BuiltinKind::Morph): itsa/b/tports 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-fieldmorph_fielddispatch. Runs in lowering pass 1, beside the patch-transform path; the merged list registers insynth_fields, and pass 2a lowers it like a hand-written patch. The live log-spaceHzpath andadsr_live(per-segment morph signals) live here.compiler/src/lower/expr.rs— theCrossfade/Curvelowerings join operand rates (types.rs::join_rate); theLog2/Exp2builtin lowerings; andlower_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::Adsrgrew optionalattack_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/Exp2per-sample inengine/src/voice/eval.rs;CompiledNode::Adsrlatches sampled segment values intoNodeState::Env.effat the note-on anchor. - The
log2/exp2builtins preserve rate and operate on dimensionless ratios. Hz morphs multiply the result by an explicit1Hzreference. - Tests:
compiler/src/lower/tests.rs(morph_*, incl.morph_live_t_lerps_hz_log_space,morph_live_t_morphs_envelope_per_note), golden snapshotcompiler/tests/snapshots/blend.json. - Example:
examples/songs/blend.volta— a livemorph glass growl shapelead whose two endpoints have different envelopes (per-note morph) with a shred riding the knob, plus constant-tblends of the stdlibpad/pluck/ep.