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.
| Operator | Structure | Value rule | Example |
|---|---|---|---|
a # b | left (a) | b overrides (right-biased) | !"1 1 1 1" # !"0.2 0.9" |
a <| b | right (b) | a kept (≡ b # a) | steady <| accents |
a |+ b | left | a + b | !"0 12 7" # …, base |+ !"0 5" |
a +| b | right | a + b | wobble +| base |
a |* b | left | a * b | accent |* swell |
a *| b | right | a * b | swell *| accent |
a |- b / a -| b | left / right | a − b | base |- !"0 2" |
a |/ b / a /| b | left / right | a ÷ b (÷0 → 0) | rates |/ !"1 2" |
a |+| b / a |*| b | both | a + b / a × b | hits |+| 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"sproduces 1, and!"440"Hz |+ !"12"stproduces 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-12of 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.