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 remainplay/soundtriggers. - Unit from context, or an explicit suffix. A bare pattern in a
Sig(_, u)/Const(u)slot adoptsu(so the cutoff pattern above isHz). With no typed context, a trailing!"…"Usuffix pins the unit; otherwise it defaults toNormalized, 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"msinto anHzcutoff) is a unit-mismatch error. - Canonicalized like a literal. A pattern adopting
msis scaled to seconds the same way100msis; 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-transformedr = !"2 4"Hz…r |> fast 2all keepHz. Before this, every combinator returned an un-unitedPattern ?and the numbers were silently re-read under the slot's default — an 8× rate error forHz, ~2000× forms. 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 orstepSeq, 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.
- a fold whose operands disagree (
How it's implemented#
- Compiler — coercion (
compiler/src/check.rs,check_expr): after the existingConst → Siglift, a numericPattern(inner)checked against an expectedSig(_, u)/Const(u)is accepted and retyped toPattern(Const(u)), carrying the adopted unit into lowering. A suffix-less (Normalized) pattern adopts any unit; a unit-pinned one must beunits_compatibleand 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 isPattern ?, soinfer_appre-types it toPattern (Const u)when its input pattern carries a pinnedu. 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" plast;p |> mask q,fast p nfirst) 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.voiceLinesis the one name still listed by hand inpattern_input_pos.slicecarries 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/Dimensionlessare never propagated: they mean "no suffix", which is exactly whatPattern ?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_modtakes it, runs each event value throughcanonicalize_value, and tags thePatternModnode withcanonical_unit(u). The unit travels on the existingNode.unitfield (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 aPatternModtaggedHzfeeds a filter's cutoff as hertz and a Cents/Semitones one feeds the detuneSumthe 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, thentry_pitch_detuneincompiler/src/lower/expr.rs):Pitch ± Sig(Cents| Semitones) → Sig(_, Hz), lowered toSum{ PitchToHz(pitch), signal }(the signal routes to.detune). A constant offset still folds to aPitchAddand stays aPitch(try_pitch_offset).st/ctare suffix aliases for Semitones/Cents.
Notes & limits#
- The
freqwrapper is for a bare pitch.pitch + signalis alreadyHz(it is the modulation), sosaw (pitch + !"…"st)— notfreq (pitch + …).freq pitch + signalalso works (Hz + Cents). Onlyfreq (pitch + signal)is rejected (freq expects a Pitch). signal/stepSequnchanged. They keep their fixedSig Control Normalizedreturn type — explicit normalized shapers whose unit comes from a downstreamscale.- Pitch/Sample patterns are out of scope here; they belong to
play/sound(see Patterns: events over time).