VOLTAreference

Reference / Patterns and harmony

Combine patterns and controls

Patterns and harmony

Open in the workbench →
On this page · 4 sections

Layer patterns, align their structure and merge control information using Volta's pattern operators.

The operators read both sides as data, so a random draw inside either side is unrolled and repeats (Patterns: events over time § Fresh-per-cycle randomness).

What it is#

Tidal-style operators that let one pattern's structure interact with another's: combine two patterns with an explicit rule for whose onsets survive and how the values combine. The Volta-native twist is that the merge is unit-checked — cutoffPat |+ gainPat (adding hertz to a normalized scalar) is a compile-time error naming both units, something no Tidal/Strudel can do.

Surface — the operators#

Twelve infix operators on patterns. The bar marks the structure side; both operands are patterns and the result is a pattern.

OperatorStructureValue ruleExample
a # bleft (a)b overrides (right-biased)!"1 1 1 1" # !"0.2 0.9"
a <| bright (b)a kept (≡ b # a)steady <| accents
a |+ blefta + b!"0 12 7" # …, base |+ !"0 5"
a +| brighta + bwobble +| base
a |* blefta * baccent |* swell
a *| brighta * bswell *| accent
a |- b / a -| bleft / righta − bbase |- !"0 2"
a |/ b / a /| bleft / righta ÷ b (÷0 → 0)rates |/ !"1 2"
a |+| b / a |*| bbotha + b / a × bhits |+| ghosts

The last six shipped with Pattern Applicative Stage 5. |-/|/ are non-commutative, so the value is always left ∘ right whichever side supplies the structure. A both-structure form unions the two one-sided walks, deduped by onset: the left-structured event wins a collision, and a rest never eats the other side's event.

The value side is sampled stepwise at each of the structure side's onsets — the same wrap ("before the first onset the previous cycle's tail holds") and rest-holds rule as the Control patterns for individual events lanes. Both sides are expanded to the LCM of their cycle spans first, so a value side longer than one cycle (<a b c>) still advances across cycles.

Precedence#

parse_struct sits one level tighter than |> and looser than and / arithmetic, left-associative:

  • x |> gain (a |* b) — the merge stays inside the argument.
  • a |+ b + c — arithmetic groups first: a |+ (b + c).
  • p # q # r — left-associative: (p # q) # r.

Spelling. Volta's |> is already the pipe token, so Tidal's #≡|> alias can't be borrowed verbatim. We keep Tidal's bar marks structure convention and # for the plain override.

Control patterns and baking (ctrl)#

A control pattern is a numeric pattern tagged for a named destination:

accents = ctrl "gain" !"1 0.6 0.85 0.6"   -- Pattern → a `gain` control pattern

ctrl "name" p moves each event's value into its controls["name"] slot. When a control pattern is the value side of a # onto a note/sample pattern, the note pattern keeps its trigger identity (pitch/sample) and absorbs the control into each event's baked controls map:

-- structure from the sound pattern; per-hit gain baked from a unit-checked merge
accentPat = !"1 0.6 0.85 0.6"
swellPat  = !"1 1.2"
kit = sound (!"bd sn bd sn" # ctrl "gain" (accentPat |* swellPat))

The runtime then applies the baked controls at trigger time, with the same composition rule as the matching *_pattern lane: gain/speed multiply, pitch/pan override, warp ORs, variant fills where the token has none. A static baked control and a sampled lane value ride the identical path into the voice. See Control patterns for individual events for the per-destination composition table.

The unit-checked merge (the payoff)#

Because a numeric pattern carries a unit (Patterns with units), the merge operators flow units through:

  • arithmetic merges use the same unit plan as scalar arithmetic, including subtraction, division and both-structure forms. Compatible time/interval representations convert before combining values: !"1000"ms |/ !"1"s produces 1, and !"440"Hz |+ !"12"st produces 880 Hz;
  • multiplication scales quantities; division produces a ratio for compatible quantities or the explicitly supported reciprocal domain. Pattern division retains its zero-safe rule: a divisor within 1e-12 of zero in the common representation yields zero. Other non-finite results are errors;
  • # / <| require the units be compatible (equal, or one side Normalized/Dimensionless adopting the other).

A mismatch is a type error naming both units and the operator:

-- ALLOWED: a normalized wobble ducks an absolute-Hz sweep (Normalized scales Hz)
swept = saw (freq pitch) |> lpf (!"400 900 1600"Hz |* !"1 0.6 1") (const 6)

``volta fails Hz and Normalized cannot merge under|+-- REJECTED at compile time: -- cannot mergePattern HzwithPattern Normalizedunder|+` -- — Hz and Normalized are incompatible units crash = saw (freq pitch) |> lpf (!"400 900 1600"Hz |+ !"1 0.6 1") (const 6)


The result pattern carries the combined unit, so it flows onward through the
existing pattern-unit coercion (into a cutoff slot, a `pitch + …st` detune, a
`gain` lane, …).

## How it's implemented

- **Compiler**
  - `lex.rs` — tokens `Hash` (`#`), `LtBar` (`<|`), `BarPlus`/`PlusBar` (`|+`/`+|`),
    `BarStar`/`StarBar` (`|*`/`*|`). The two-char merge tokens are scanned before
    the single `|`/`+`/`*`/`<`, after `|>`.
  - `ast.rs` — `BinOp` variants `StructL`/`StructR`/`AddL`/`AddR`/`MulL`/`MulR`;
    `BinOp::is_pattern_merge()`.
  - `parse.rs` — `parse_struct`, the precedence level between `parse_pipe` and
    `parse_and`.
  - `check.rs` — `infer_binop` → `merge_pattern_ty`: mode 1 numeric ⊗ numeric
    (the shared arithmetic plan, or `merge_units_override` for overrides), mode 2 a
    note/sample structure side absorbing a numeric control side.
  - `lower/pattern.rs` — `resolve_pattern_merge` (the eager stepwise merge, LCM
    expansion, `sample_step`) and `resolve_ctrl` (the `ctrl` tag). Wired into
    `resolve_pattern` (a `BinOp` arm) for the lane path.
  - `lower/expr.rs` — `lower_binop` routes a merge used in signal position through
    `resolve_pattern_merge` → `add_pattern_mod`, adopting the use-site unit.
  - `builtins.rs` — `ctrl : Str → Pattern Number → Pattern`.
- **Event control map (Stage 1)** — `MiniEvent.controls` / `TrackEvent.controls`
  (`BTreeMap<String, f64>`, serde skip-if-empty). The engine applies it in
  `engine/src/lib.rs` (`event_dynamics` / `event_controls` / `event_pan` /
  `merged_event_params`), on every host. Empty (no operator used) ⇒
  byte-identical to before — the pre-existing golden IR snapshots and the
  golden-audio manifest do not churn.
- **Tests** — `compiler/src/lower/tests.rs` (`merge_*` — the stepwise/wrap math,
  the `#` override, the `ctrl` bake, the unit rejection + the allowed
  Normalized-scaling case); `engine/tests/ctrl_params.rs` (the baked `controls`
  map reaching a `trackParam` per hit — it replaced a deleted TypeScript test);
  `engine/tests/golden_audio.rs`
  (`pattern_merge_cutoff`, an audible render guard); `examples/songs/ohms.volta`
  (the end-to-end example, golden-IR snapshotted).

## Notes & limits

- **Merges are eager and finite.** No lazy query engine — the merge samples the
  value side at the structure side's onsets and bakes a concrete `MiniPattern`,
  so deterministic replay and editor flashing survive.
- **In legacy timing (version 1), operand `fast`/`slow` is folded away**, like `stack`: the merge works on the
  rendered `[0,1)` events, so `(a |> fast 2) |+ b` ignores the `fast`. Apply time
  transforms to the *result* instead. Since 2026-08-16 this is no longer silent —
  the compiler emits the shared `ArrangeCellScale` advisory naming the operator
  (`` `fast` has no effect inside a `|+` operand … ``), the same warning
  `arrange` cells, `stack`, `voiceLines` and `wchoose` get
  (production-gaps.md §23 R2).
  With the default timing (version 2), both operands materialize their rates before the
  merge. The chosen structure keeps its timing and samples values at those
  absolute onsets. See [[reference/patterns]] §Timing versions.
- **Fragmenting structure-from-both (`|>|`/`|<|`), continuous operands, and the
  deep-time family** (`zoom`/`within`/…) are **not** here — demoted to [[ideas]]
  2026-08-20 (no corpus demand; the design doc's reassessment records the
  shapes they'd take if revived). The non-fragmenting `|+|`/`|*|` did ship.
- **`ctrl` destinations resolve — or the compile fails** (Stage 8,
  2026-08-24). A `ctrl` name is either one of the six lane destinations
  (gain/speed/pan/pitch/warp/variant — composed at trigger exactly like the
  matching `*_pattern` lane) or a **`trackParam` the track's synth declares**:
  the baked value rides the event-params channel into the `trackParam "name"`
  node per hit, so `p = patch { …, cutoff = korg35 (trackParam "brightness"
  800) … }` plus `# ctrl "brightness" !"0.2 0.9"` is per-hit patch-parameter
  patterning. On a name collision a per-token `(key=value)` param wins over
  the pattern-wide `ctrl` (the more specific spelling). A name that resolves
  to neither is a compile error naming the track, the lanes, and the missing
  `trackParam` — the pre-Stage-8 silent no-op is gone
  (`lower::validate_ctrl_destinations`; the runtime merge is
  `merged_event_params`, engine `lib.rs`). A **glide** synth gets them too: its
  mono (or reused poly) voice re-applies the event's controls at every renote,
  not only at the trigger ([[reference/glide]]; both note-on paths dropped them
  until 2026-09-16).
- **Only the symbolic operators exist.** The named-combinator aliases
  (`structL`/`addL`/…) have not shipped.