VOLTAreference

Reference / Patterns and harmony

Patterns with units

Patterns and harmony

Open in the workbench →
On this page · 4 sections

Write a sequence of frequencies, durations or other physical values and keep its meaning clear through transformations.

What it is#

A numeric mini-notation pattern can now be used directly as a control signal wherever a control-rate value is expected — a filter cutoff, an oscillator frequency, any signal-typed parameter. The pattern adopts the unit of the slot it flows into: !"500 1500 1000 2000" in a cutoff position is a pattern of hertz, not of normalized scalars. This realizes Spec §3.4's "a filter takes a cutoff signal, not a cutoff number" for the pattern case.

Before Stage 1, a bare pattern in a Sig-typed slot was a type error; the only ways to pattern a continuous parameter were arithmetic (2000Hz * !"0.3 1"), a Ty::Hole-typed slot (|> gain !"1 0.5"), or signal !"…" |> scale […]. Those all still work; this adds the direct, intuitive spelling.

Surface#

A numeric pattern dropped straight into a typed control slot:

-- patterned filter cutoff in absolute Hz (was a type error before Stage 1)
carve = saw (freq pitch) |> lpf !"800 1600 400 1200" (const 3)

-- explicit unit suffix when there's no typed context to infer from (Stage 2)
sweep = !"500 1500 1000"Hz        -- a named Pattern Hz, reusable as a cutoff
times = !"100 50 200"ms           -- canonicalized to seconds, like 100ms

-- patterned transposition — `pitch ± a semitone/cent SIGNAL` is freq modulation,
-- so it needs no `freq` wrapper (the `+` does the conversion). `st`/`ct` aliases.
lead  = saw (pitch + !"0 12 7 -5"st)
vib   = saw (pitch + lfoSine @ 6Hz * 5ct)

-- still works, unchanged: a normalized pattern scaled into a range
duck  = lfoSine @ 1/16c |> scale [500Hz, 4000Hz] |> multiply (!"0.3 1 1 1")

The deleted browser editor showed the inferred unit as a faint ghost chip at the end of each numeric pattern (!"800 1600" → Hz), suppressed for Normalized patterns and explicit suffixes. That chip is historical behavior, not a current Rust-workbench affordance.

Rules that apply today (Stage 1):

  • Numeric payload only. !"800 1600" (Number) coerces. !"c3 e3" (Pitch) and !"bd sn" (Sample) do not — they remain play / sound triggers.
  • Unit from context, or an explicit suffix. A bare pattern in a Sig(_, u) / Const(u) slot adopts u (so the cutoff pattern above is Hz). With no typed context, a trailing !"…"U suffix pins the unit; otherwise it defaults to Normalized, exactly as before — 2000Hz * !"0.3 1 0.7 1" is unchanged. A suffix on a pitch/sample pattern is an error; a suffix that disagrees with its slot (!"100 50"ms into an Hz cutoff) is a unit-mismatch error.
  • Canonicalized like a literal. A pattern adopting ms is scaled to seconds the same way 100ms is; raw-magnitude units (Hz, dB, Bipolar, Ratio) pass through unscaled.
  • The suffix survives a combinator (since 2026-08-16, production-gaps.md §23 R4). !"2 4"Hz |> fast 2, rev !"2 4"Hz, stack [!"2"Hz, !"4"Hz] and a bound-then-transformed r = !"2 4"Hz … r |> fast 2 all keep Hz. Before this, every combinator returned an un-united Pattern ? and the numbers were silently re-read under the slot's default — an 8× rate error for Hz, ~2000× for ms. Two things still erase it, and both now say so:
    • a fold whose operands disagree (stack [!"2 4"Hz, !"250"ms]) has no coherent unit to carry, so the result is un-united. The compiler warns (PatternUnitErased) naming the fold and both units; in a cycle-rate lane or stepSeq, where the misreading is catastrophic, it is still a hard error instead.
    • slice, which turns a numeric index pattern into a sample pattern — there is no numeric payload left to carry a unit.

How it's implemented#

  • Compiler — coercion (compiler/src/check.rs, check_expr): after the existing Const → Sig lift, a numeric Pattern(inner) checked against an expected Sig(_, u) / Const(u) is accepted and retyped to Pattern(Const(u)), carrying the adopted unit into lowering. A suffix-less (Normalized) pattern adopts any unit; a unit-pinned one must be units_compatible and keeps its own suffix as the payload unit (the lowerer converts it into the slot's domain). Non-numeric payloads don't match the arm and stay errors.
  • Compiler — combinator propagation (compiler/src/check.rs, propagated_pattern_unit / pattern_input_pos): a combinator's declared result is Pattern ?, so infer_app re-types it to Pattern (Const u) when its input pattern carries a pinned u. Where that input sits, and whether there is one at all, is each name's descriptor (P2.3): a transform declares its subject position (rev p, every n f p, ctrl "n" p last; p |> mask q, fast p n first) and whether its payload law carries at all, and a constructor or merge names its sibling value patterns wherever they sit (sew/stitch/wedge, seq/par/stack, arrange/wchoose's [n, p] pairs), propagating only when every pinned sibling agrees. voiceLines is the one name still listed by hand in pattern_input_pos. slice carries nothing on purpose (Payload::Replaced — an index pattern becomes a sample pattern), as do the value constructors (patternOf, pure, patternOfEvents), which build a new payload. Normalized/Dimensionless are never propagated: they mean "no suffix", which is exactly what Pattern ? already reads as.
  • Compiler — lowering (compiler/src/lower/expr.rs): pattern_mod_unit(ty) reads the adopted unit off the (coerced) use-site type; add_pattern_mod takes it, runs each event value through canonicalize_value, and tags the PatternMod node with canonical_unit(u). The unit travels on the existing Node.unit field (ir/src/ir.rs) — no new IR field.
  • Engine (engine/src/voice/eval_nodes.rs, eval_pattern_mod): nothing is unit-aware at run time — the compiler already canonicalized every event value. The node emits its stepped value on the global transport clock (with a ~5 ms anti-pop glide per step), so a PatternMod tagged Hz feeds a filter's cutoff as hertz and a Cents/Semitones one feeds the detune Sum the lowerer built.
  • Historical editor chip. The browser editor's ghost unit chip lived in the TypeScript workbench and was deleted 2026-08-10; see git history.
  • Pitch ± signal (the checker's arithmetic resolution in compiler/src/semantics/operations.rs, then try_pitch_detune in compiler/src/lower/expr.rs): Pitch ± Sig(Cents| Semitones) → Sig(_, Hz), lowered to Sum{ PitchToHz(pitch), signal } (the signal routes to .detune). A constant offset still folds to a PitchAdd and stays a Pitch (try_pitch_offset). st/ct are suffix aliases for Semitones/Cents.

Notes & limits#

  • The freq wrapper is for a bare pitch. pitch + signal is already Hz (it is the modulation), so saw (pitch + !"…"st) — not freq (pitch + …). freq pitch + signal also works (Hz + Cents). Only freq (pitch + signal) is rejected (freq expects a Pitch).
  • signal / stepSeq unchanged. They keep their fixed Sig Control Normalized return type — explicit normalized shapers whose unit comes from a downstream scale.
  • Pitch/Sample patterns are out of scope here; they belong to play / sound (see Patterns: events over time).