This is the authoritative design document. For a compact, implementer-ready semantics reference structured for TDD, see DSL-truthtables.md. The two documents must remain consistent — any behavioural change requires updates to both.
Line comments begin with // and run to end of line. Block comments are delimited by /* and */ and may span multiple lines. Both are invisible to the parser.
// this is a line comment
note lead [0 2 4] // inline comment
/*
This is a
multi-line comment
*/
note lead [0 1 2 3]
Generators are objects that yield a stream of values for use in synth instantiation or set messages. Literal numbers are generators that always yield the same value. Everything in [...] brackets is also a generator — not a data structure, but a stateful object that yields its elements on each call.
Unlike SuperCollider patterns/streams, all generators yield indefinitely. They are never exhausted — it's the caller's responsibility to stop polling when a phrase needs to end.
[] is the only non-scalar generator. All other generator forms — numeric literals, rand, gauss, step, mul, lin, geom, etc. — are scalar: they yield a single value per poll and make no claim about time. [] does both: it yields values and assigns each a temporal position within the cycle. This is why nesting [] inside [] subdivides time (the inner list fills its parent slot with multiple timed events) rather than violating the generator contract — the inner list's temporal extent is simply scaled to fit the outer slot.
This scalar/non-scalar distinction is load-bearing elsewhere in the spec: the right-hand side of transposition and the 'stut count argument both require a scalar generator and reject [...] outright.
{} curly brackets are used exclusively by the utf8{} generator (see below). Outside of that context, a bare { or } is a lex error.
See truth tables 12 (Whitespace).
Whitespace (spaces or newlines) is required between distinct top-level tokens — for example, between note and [. Inside generator expressions, however, tokens are written adjacently with no whitespace: 0rand4, not 0 rand 4. The same applies to all generator keywords (gauss, exp, brown, step, mul, lin, geom) and their separators (m, x). Whitespace inside a generator expression is a syntax error.
Inside [...] sequence generators, elements are separated by spaces. Commas are not valid separators — except inside a range expression (see below).
See truth table 4 (Weighted random /
'pick).
[...] is the fundamental generator type. By default it yields its elements in order, cycling back to the start. Modifiers change the traversal strategy:
[1 2 3] // yields 1, 2, 3, 1, 2, 3, ... — like Pseq([1, 2, 3])
[1 2 3]'shuf // shuffle then traverse, like Pshuf
[1 2 3]'pick // uniform random element each time, like Prand
[1 2?2 3]'pick // weighted random — like Pwrand with weights 1/2/1
[0..10]'arp // arpeggiate ascending (default \up)
[0..10]'arp(\down) // arpeggiate descending
'pick supports optional per-element weights via the ? operator. Unweighted elements default to weight 1; when no weights are present, 'pick is uniform random. When any weights are present, selection is proportional to the weights (normalised to sum to 1).
?n—nmust be a non-negative numeric literal (integer or float).?0means the element is never picked.- If every element has weight 0, the slot is silent (rest event), the same as
_. - Negative weights (e.g.
?-1) are a parse error. - Generator expressions are not valid as weights —
?must be followed by a numeric literal. - The
?weight syntax is only meaningful on a list whose own modifiers include'pick. Using?on a list without'pickis not an error, but the weight is ignored and a warning is logged. This rule applies per list level:[[1 2?3]'pick 5]is fine, but[[1 2?3] 5]'pickignores the inner?3because the inner list has no'pick.
See truth table 25 (
'arp).
'arp is a list-level modifier that collects the cycle's output values, removes duplicates, and traverses them in a melodic pattern. Syntax:
[0..10]'arp // default \up algorithm — sorted ascending
[0..10]'arp(\down) // sorted descending
[0..10]'arp(\updown) // palindrome, no repeated endpoints
[0..10]'arp(\down 16) // \down traversal cycled to 16 values
[0 5 2 7 3]'arp(\inward) // pincer from outer to inner
Grammar: 'arp | 'arp(\symbol) | 'arp(\symbol integer)
Algorithms:
| Symbol | Traversal |
|---|---|
\up |
Sorted ascending (default) |
\down |
Sorted descending |
\inward |
Pincer from both ends toward middle |
\outward |
Starts at middle, expands outward (reverse of \inward) |
\updown |
Ascending then descending palindrome; natural length = 2×(N−1) |
\converge |
Alias for \inward |
\diverge |
Alias for \outward |
Duplicate removal: before arpeggiation, numeric values are deduplicated by equality, preserving the order of first occurrence. This applies after all rests are filtered out.
Rests: rest elements in the list are filtered out before deduplication and arpeggiation. If all elements are rests, the cycle yields a single rest event.
Single-element input: 'arp is a no-op — yields that single value as the one cycle slot.
Default output length equals the natural traversal length for the chosen algorithm. For \up, \down, \inward, \outward, \converge, \diverge the natural length equals the deduped input length N. For \updown the natural length is 2×(N−1).
Length override 'arp(\symbol n): cycles the natural traversal to produce exactly n values. n = 0 or negative is a semantic error.
Even-length center for \inward/\outward: for deduped input of length 2k (even), \inward pairs outer→inner: a0, a(2k-1), a1, a(2k-2), ... ending at the two middle elements a(k-1), ak. \outward is the reverse.
Semantic errors:
'arpon a scalar element inside a list (e.g.[0rand7'arp]) — attach to the list instead:[0rand7]'arp'arpcombined with'shufor'pick— choose one traversal strategy- Unknown algorithm symbol
- Length override ≤ 0
Composition: 'arp composes with other list-level modifiers ('stut, 'lock, 'eager). 'arp applies first (generates the traversal), then other modifiers operate on the resulting sequence.
See truth table 22 (Range notation).
A compact SC-inspired syntax for generating integer or float sequences inside [...]. All bounds are inclusive.
[0..7] // [0 1 2 3 4 5 6 7]
[0, 2..10] // [0 2 4 6 8 10]
[0.0, 0.25..1.0] // [0.0 0.25 0.5 0.75 1.0]
[10, 8..0] // [10 8 6 4 2 0] (descending)
Forms:
[start..end]— integer range; step defaults to1ifend >= start,-1ifend < start.[start, step..end]— explicit step; the step value equalssecond − first.
Rules:
- Both bounds are inclusive.
- Float ranges require an explicit step —
[0.0..1.0]is a parse error (the parser rejects a float before..with no preceding comma). - Descending ranges are valid when
end < start(default step-1) or when the explicit step would count down towardend. - A range that produces zero elements is a semantic error (e.g.
[0, 0..5]— step of 0; or[5..0]with positive step 1 would produce 0 elements if start > end but is handled by auto-negating the step). - Ranges are eagerly expanded to a flat value array at parse/compile time — they behave identically to an explicit list
[v1 v2 ... vN]. - A range list may carry the usual list modifiers (
'shuf,'pick,'lock, etc.) after the]. - Slice selection:
[0..15]is the natural way to express a pool of 16 slices (e.g.slice drums [0..15]'pick).
_ marks an event slot as silence. It occupies the same time as any other element but no synth is spawned. The evaluator emits a ScheduledEvent with type: 'rest' so the scheduler knows the slot was intentionally silent (useful for 'mono legato handling and visualisation).
note lead [0 2 _ 4] // rest on the 3rd slot — 4 elements, each gets 1/4 cycle
note lead [_ 2 4] // rest on the 1st slot
[0 _ 2]'stut // rest is repeated alongside notes (inside a list, not a top-level statement)
_ cannot carry accidentals, ? weights, or generator suffixes — it is purely syntactic and carries no pitch information.
See truth table 5 (Generator polling / nesting).
0rand4 // Pwhite(min = 0, max = 4) => 4, 1, 0, 4, 3, 0, 2, 1 (canonical form)
0.rand4 // Pwhite(min = 0.0, max = 4) => 3.123891023, 0.23123424, 2.023909
0~4 // Shorthand for rand — syntactic sugar
// When either bound is a float, rand produces a continuous float in [min, max).
// The float passes through the generator unchanged; rounding happens downstream.
// In degree context (inside []) degreeToMidi rounds to nearest integer before
// scale lookup — microtonal degrees are not supported. Float bounds are most
// meaningful in non-degree contexts, e.g. 'legato(0.5rand1.2).
0gauss4 // Pgauss(mean = 0, sdev = 4)
1exp7 // Pexprand(min = 1, max = 7)
0brown10m2 // Pbrown(min = 0, max = 10, max_step = 2)
// Linear series like Pseries(start = 0, step = 2, length = 4)
0step2x4
// Geometric series like Pgeom(start = 1, multiplier = 2, length = 4)
5mul2x4
// Linear interpolation Pseq(Array.interpolation(first = 2, last = 7, length = 8))
2lin7x8
// Geometric/exponential interpolation (no counterpart in SC)
2geom7x8
See truth table 21 (utf8 generator).
utf8{word} converts the characters of a bare identifier to their UTF-8 byte values and yields them in sequence, cycling indefinitely.
// "coffee" → [99 111 102 102 101 101] → % 14 → [1 7 4 4 3 3]
note lead utf8{coffee} % 14
// nested inside a sequence list
note lead [utf8{hello} % 7 0 2]'shuf
Syntax:
utf8Generator = "utf8" "{" identifier "}" ;
(* utf8Generator is a new alternative in atomicGenerator *)
atomicGenerator = sequenceGenerator
| utf8Generator
| numericGenerator ;utf8must be immediately followed by{with no whitespace.- The content inside
{}is a single bare identifier (letters, digits, underscores — same as any Flux identifier). - The identifier is treated as a literal string — its characters are encoded as UTF-8 bytes. It is not looked up as a variable name or generator alias.
- The generator cycles: after the last byte, it restarts from the first.
utf8{word}is a scalar generator — it yields a single integer per poll. It is valid wherever a scalar generator is valid: directly as the sole generator in a pattern, or nested inside[...].- Combined with
%(modulo), it maps byte values into a useful scale-degree range, e.g.utf8{coffee} % 14.
Whitespace rule: utf8 must be written adjacent to { — utf8 {coffee} is a lex error: with the space, utf8 tokenises as a plain identifier and the bare { is unrecognised by the lexer.
Generators can be sequenced like any literals.
[0 1exp7 4gauss2] // Pseq([0, Pexprand(1, 7), Pgauss(4, 2)])
Nesting generators as input to other generators is achieved with parentheses, which disambiguates chained expressions such as 0rand2rand4:
(0rand2)rand4 // Pwhite(Pwhite(0, 2), 4)
How often nested generators are polled is determined by 'lock vs. 'eager. Stateful generators (step, mul, lin, geom) maintain their state and loop after the sequence ends.
A number of generators work by filtering events that come from upstream generators.
'stut— repeat each element n times'maybe— pass each element through with a given probability, otherwise skip it'spread— expand a multi-value generator's iteration into multiple sibling cycle slots
See truth table 3 (Stutter).
The 'stut(n) modifier repeats every element n times. Default: n = 2. The bare form 'stut is valid and equivalent to 'stut(2).
note lead [0'stut] // 0 yielded 2 times
// play each generated value two times instead of one
note lead [0rand7 4rand6]'stut
// repeat each generated value 4 times
note lead [0rand7 4rand6]'stut(4)
// repeat each value 2-4 times, count drawn per source event (default eager(0))
note lead [0rand7 4rand6]'stut(2rand4)
// count redrawn every 4 cycles
note lead [0rand7 4rand6]'stut(2rand4'eager(4))
How 'eager and 'lock apply to 'stut:
- Default (
'eager(0)): stutter count is drawn per source event — each pre-stutter slot gets its own count. 'stut(2rand4'eager(1)): count drawn once per cycle, shared by all source slots.'stut(2rand4'eager(4)): count redrawn every 4 cycles.'stut(2rand4'lock): stutter count is chosen once and frozen forever.
The stutter count must be a positive integer ≥ 1. A count of 0 or a negative value is a semantic error. The count argument must be a scalar generator — a list generator is a semantic error.
The 'maybe(p) modifier passes each element through with probability p (0.0–1.0) and skips it otherwise. Default: p = 0.5. The bare form 'maybe is valid and equivalent to 'maybe(0.5).
See truth table 24 (
'spread).
The 'spread modifier expands a multi-value generator's single iteration into multiple consecutive cycle slots, each getting equal time. Without 'spread, a generator occupies one event slot per cycle regardless of how many values it could produce in one iteration.
'spread is a per-element modifier — it attaches to a generator that is an element of a [...] list. Attaching 'spread to a top-level generator (with no enclosing [...]) is a semantic error: there are no sibling slots to spread into.
// 0step1x4 has a natural iteration of 4 values; 'spread expands all 4 into the cycle.
// Equivalent to note lead [0 1 2 3].
note lead [0step1x4'spread]
// Explicit count: expand exactly 2 values (Pshunce-style wrapping if n > length).
note lead [0step1x4'spread(2)]
// List element: the inner list's 3 elements become 3 sibling slots in the outer list.
// [A [0 2 4]'spread] yields 4 slots (A, 0, 2, 4) — not 2 slots where [0 2 4] subdivides.
note lead [A [0 2 4]'spread]
// Combined: [A 0step1x4'spread] → A + 4 spread values = 5 slots.
note lead [A 0step1x4'spread]
What 'spread operates on:
- Series generators (
step,mul,lin,geom): bare'spreadexpands all N values of one complete iteration (thexNlength) into N consecutive sibling slots. - List generators (
[...], including range notation[0..7]): bare'spreadflattens the list's elements into the parent list's slots.[[0 2 4]'spread]is equivalent to[0 2 4]; more usefully,[A [0 2 4]'spread]yields 4 slots (A, 0, 2, 4). - Scalar generators (
rand,gauss,exp,brown, integer/float literals,utf8{word}): bare'spreadis a no-op with a console warning — scalar generators have no natural iteration length.'spread(n)on a scalar IS valid and expands to n consecutive polls of the same generator.
'spread(n) — explicit count:
'spread(n) expands exactly n values. For series generators, if n exceeds the generator's natural length, values wrap around (Pshunce-style). For list generators, values wrap around if n > list length. For scalar generators, n polls of the generator are emitted.
n = 0is a semantic error (consistent with'stut(0)).n < 0is a semantic error.nmust be a scalar generator — a list generator as the count argument is a semantic error.
Per-cycle consumption:
'spread consumes one full iteration per cycle. Series generators reset and re-seed at cycle boundaries per the default eager rules. Bare 'spread on a series generator is equivalent to drawing all N values of that iteration.
Validity:
- Valid:
'spreadon a generator that is an element of a[...]list (including nested lists —[[0 2 4]'spread]works because the inner list is an element of the outer list). - Semantic error:
'spreadon a top-level list with no enclosing[...]context, e.g.note lead [0 2 4]'spread(attaches to the outer list — nothing to spread into). A clear error message is emitted. - Parse error:
note lead 0step1x4'spread— the grammar requires[...]as the pattern body; a bare generator expression with a modifier suffix does not parse as a valid pattern.
'spread and 'stut interaction:
'spread and 'stut can both be applied to elements in the same list:
'spreadapplied first (attaches to the element), then'stutapplied to the containing list: spread expands the element's values into slots first, then stut repeats each slot.[0step1x4'spread]'stut(2)→ 8 slots:[0,0, 1,1, 2,2, 3,3].'stutapplied to the element, then'spreadapplied to the element:0step1x4'stut(2)'spread—'stutis chained on the step generator before'spread, making each step value repeat twice. Since the step generator is still a series of length 4 but'stutis a per-slot repetition modifier (not a value-expansion modifier),'spreadsees the underlying series length and expands 4 values. The'stuton the generator itself (not the list) is ignored by'spreadexpansion. This composition is declared a semantic error if encountered — use list-level'stutinstead.
See truth table 24 ('spread) for the complete interaction truth table.
See truth table 26 (Sequence shape modifiers).
Three modifiers that reshape the event array for a cycle after traversal and sampling — not on the generator structure. They are list-level modifiers attached to [...].
[1 2 3 4]'rev // reverses: plays as [4 3 2 1]
[1 2 3]'mirror // palindrome (repeated endpoints): [1 2 3 2 1]
[1 2 3]'bounce // palindrome (no repeated endpoints): [1 2 3 2]
Semantics:
'rev— reverses the event array.[a b c d]'rev→[d c b a].'mirror— appends the reverse of the array without its first element:[a b c]'mirror→[a b c b a]. Both endpoints appear twice — natural length =2N − 1.'bounce— appends the reverse with both endpoints removed:[a b c]'bounce→[a b c b]. No endpoint repeats — natural length =2(N − 1).
Applied post-traversal: shape modifiers operate on the evaluated event array after traversal ('shuf, 'pick, 'arp). [1~4]'rev reverses this cycle's random draws, not the generator. The modifier is re-applied each cycle.
Cycle duration is fixed. 'mirror and 'bounce produce more events per cycle; each event gets a proportionally shorter time slot, consistent with sublist behaviour.
Single-element is a no-op. Applying any shape modifier to a one-element sequence returns [a] unchanged, with no error.
Composition with 'stut: shape modifiers apply before 'stut. [1 2 3]'mirror'stut(2) → mirror first (5 elements), then stutter each (10 events).
Grammar: bare modifier — no arguments. 'rev, 'mirror, and 'bounce take no arguments.
The primary keyword specifies the content type — what kind of events are generated. All content types loop indefinitely by default. The 'n modifier opts into finite playback.
| Keyword | Description |
|---|---|
note |
Polyphonic pitched events. New synth instance per event. |
mono |
Monophonic pitched events. Single persistent synth node; events send set messages instead of spawning new instances. Rough equivalent of SC's Pmono. |
sample |
Buffer playback. Event list contains \symbol buffer refs; each event picks a buffer by name. |
slice |
Beat-sliced buffer playback. Event list contains integer slice indices into a fixed buffer. |
cloud |
Granular synthesis. Persistent granular synth node, modulated via .set messages. No event list — use []. |
A name is required between the content type keyword and the generator expression (see "Generator naming" below). A space is required between the name and [.
note lead [0 1 2 3] // polyphonic pitched events, loops indefinitely
mono bass [0 1 2 3] // monophonic pitched events, loops indefinitely
The duration of one cycle is always one cycle, as in TidalCycles. Each element is triggered with a temporal interval of exactly 1/n cycles, distributing elements evenly in time.
// n = 4, each element gets 1/4 cycle
note lead [0 1 2 3]
// n = 6, each element gets 1/6 cycle
note lead [0 1 2 3 4 5]
// elements in sublists get slices of the parent's time slot
// 0, 1 and 4 get 1/4 cycle; 2 and 3 each get 1/8 cycle (i.e. (1/4)/2)
note lead [0 1 [2 3] 4]
The 'offset modifier schedules all events a number of milliseconds early (negative value) or late (positive value) relative to their normal trigger time. Equivalent to the \lag key in SC. Applies to all content types. See truth table 14 ('offset).
note lead [0 1 2]'offset(20) // all events 20 ms late
note lead [0 1 2]'offset(-10) // all events 10 ms early
See truth table 13 (
'legato).
note spawns new self-releasing synth instances per event (not persistent nodes). Gate is closed via a scheduled set message after each event's time slot, scaled by a legato factor. This conforms to standard SC synthdef conventions (gate input + ADSR).
The default legato for note is 0.8, matching SuperCollider's Pbind convention. Overridable per-pattern via 'legato(n).
Legato is a modifier, patternisable like any other stochastic argument:
note lead [0 2 4 7]'legato(0.8) // fixed legato (same as default)
note lead [0 2 4 7]'legato(0.5rand1.2) // stochastic legato, eager(0) by default (per source event)
note lead [0 2 4 7]'legato(0.5rand1.2'eager(1)) // one legato value per cycle, shared by all events
note lead [0 2 4 7]'legato(0.5rand1.2'eager(4)) // new legato value every 4 cycles
Legato values > 1.0 produce overlap (useful for pads/drones).
'legato has no effect on mono — mono uses a persistent node and legato as note-overlap control is undefined there.
The scheduler must therefore track two times per event: note-on time and gate-close time.
mono maintains a single persistent synth node per named generator:
- First evaluation: a new synth node is spawned.
- Subsequent evaluations (same name, new cycle):
.setmessages are sent to the existing node — no re-spawn. - Stop or removal: the runtime closes the gate automatically. Release duration is governed entirely by the SynthDef envelope; there is no DSL
'releasemodifier. 'stut(n)onmono: sends n repeated.setmessages to the persistent node within the event slot. Audibility is SynthDef-dependent.'legatohas no effect onmono— legato as note-overlap control is undefined for persistent nodes.
mono bass [0 1 2 3] // single persistent node, pitch updated each event
mono bass [0 1 2 3]'stut // each pitch change sent twice
sample, slice, and cloud operate on audio buffers loaded into the engine at boot.
| Content type | List contents | Default SynthDef | Default buffer |
|---|---|---|---|
sample |
\symbol buffer refs — each event picks a buffer by name |
samplePlayer |
bundled one-shot kit |
slice |
integer slice indices into a fixed buffer | slicePlayer |
bundled amen-style loop |
cloud |
no list — use [] |
grainCloud |
bundled voice recording |
SynthDef override: sample(\name), slice(\name), cloud(\name) follow the same convention as note(\name): the argument replaces the default SynthDef entirely.
Channel-count-based SynthDef variant selection: At event dispatch time, the channel count of the active buffer is looked up from the buffer registry. The SynthDef name is resolved to samplePlayer_mono or samplePlayer_stereo (and similarly for slicePlayer). If no variant exists for the detected channel count, the event is skipped with a logged error. grainCloud SynthDefs only exist as _mono variants — if a stereo buffer is selected, a warning is logged and the mono variant is used.
@buf decorator: @buf(\name) specifies which buffer a slice or cloud pattern operates on. Accepts a static \symbol or any sequence generator for per-cycle buffer selection — the same traversal modifiers ('pick, 'shuf, sequential, 'lock, 'eager) apply as on any [...] list. The generator is polled once per cycle; all events within the cycle share the same buffer name.
@buf(\myloop) slice drums [0 2 4 8]'numSlices(16) // static
@buf([\loopA \loopB]'pick) slice drums [0 4 8 12] // random per cycle
@buf([\a \b \c]'shuf) slice drums [0 4 8 12] // shuffle deck
@buf([\loopA \loopB]) slice drums [0 4 8 12] // sequential cycling
@buf([\loopA \loopB]'lock) slice drums [0 4 8 12] // frozen after first pick
@buf on sample is a semantic error — buffer selection in sample is per-event inside the list.
'numSlices(n) is a pattern-level modifier on slice that tells the SynthDef how many slices the buffer has been divided into:
slice drums [0 2 4 8]'numSlices(16) // 16-slice grid
cloud persistent node: cloud works like mono — it spawns a single persistent granular synth node and sends .set messages each cycle. The event list is empty ([]). Parameters are controlled via "param notation:
@buf(\recording) cloud grain []"density(8)"pos(0.5rand0.8)
Structural length is frozen at the cycle boundary. All generators inside the list are evaluated for length once when the cycle begins; the resulting event array is pre-calculated and handed off to the scheduler.
See truth table 7 (Content type timing).
By default all content types loop indefinitely. The 'n modifier opts into finite playback:
note lead [0 2 4] // loop indefinitely (default)
note lead [0 2 4]'n // play once (equivalent to 'n(1))
note lead [0 2 4]'n(1) // play once
note lead [0 2 4]'n(4) // play 4 times
The count must be a positive integer ≥ 1. Zero, negative, or non-integer counts are semantic errors.
See truth table 7 (Content type timing).
'at applies to all content types and specifies the phase offset at which the pattern begins within the cycle. Useful for establishing phase relationships between patterns, and for scheduling finite runs at a specific point in time.
note lead [0 2 4]'at(0) // default: begins on the start of the next cycle
note lead [0 2 4]'at(1) // begin 1 cycle after the beginning of the next cycle
note lead [0 2 4]'at(3/4) // begin 3/4 cycle after the beginning of the next cycle
note lead [0 2 4]'at(-1/8) // begin 1/8 cycle before the beginning of next cycle
note lead [0 2 4]'at(1/2) // loop, phase-shifted half a cycle
note lead [0 2 4]'n'at(1/4) // play once, starting 1/4 cycle in
If a pattern would be scheduled to start in the past, its start is postponed to the next cycle.
'at vs 'offset distinction:
'at(n)— where in the cycle the pattern begins (cycle-relative, fractional cycles; affects the whole pattern).'offset(n)— millisecond nudge per event for timing feel (sub-rhythmic; affects individual event placement within the grid).
The @ operator schedules an element at an absolute position within the cycle (0 = cycle start, 1 = one full cycle). Positions are fractions, written the same way as everywhere else in the DSL:
note lead [0@0 4@1/4 7@5/8] // 0 at 0, 4 at 1/4 cycle, 7 at 5/8 cycle
@ is optional on individual elements. A bare degree keeps its natural uniform-spacing slot; only elements with @ have their position overridden:
note lead [0 4 7@1/2] // 0 at 0, 4 at 1/3 (natural slot), 7 at 1/2 (override)
note lead [0 2@1] // 0 at 0 (natural), 2 at 1 (one full cycle in)
In note [0 1 2], 0, 1, and 2 are interpreted as scale degrees which specify musical pitch.
To arrive at the final oscillator frequency, the degree passes through this chain:
degree → scale → root → octave → cent → frequency
The variables in this chain can be overridden with the set command or @ decorators.
scale: Preset. Default: "major".root: Distance from C, measured in semitones. Default: 0 (C).octave: Octave on a piano. Default: 5.cent: Pitch deviation from ideal frequency, measured in cents (100 per semitone step). Default: 0.- Degrees are relative to root (0 is the root, 1 is the 2nd degree, etc.). No default — must be specified.
Chromatic transposition (ctranspose) is not supported — it mixes degree-space and semitone-space incoherently. If genuinely needed, 'st(n) is reserved as an escape-hatch modifier but is not implemented in the initial version.
The common case of setting root, scale, and octave together uses @key:
@key(g# lydian) // root + scale; octave defaults to 5
@key(g# lydian 4) // explicit octave
set key(...) is also valid and equivalent to @key at global scope:
set key(g# lydian)
The @cent decorator remains available for fine-tuning but is not part of the common vocabulary.
See truth table 10 (Generator arithmetic).
Arithmetic operators apply element-wise to the degree values produced by the left-hand generator. The left-hand side is always the pattern's generator expression; the right-hand side is a scalar generator or a list generator.
Supported operators:
| Operator | Meaning | Example |
|---|---|---|
+ |
Addition | note lead [0 2 4] + 2 |
- |
Subtraction | note lead [0 2 4] - 1 |
* |
Multiplication | note lead [0 2 4] * 2 |
/ |
Division | note lead [0 2 4] / 2 |
** |
Exponentiation | note lead [0 2 4] ** 2 |
% |
Modulo | note lead utf8{coffee} % 14 |
note lead [0 2 4] + 2 // shift all degrees up 2 scale steps
note lead [0 2 4] - 1 // shift down 1 scale step
note lead [0 2 4] + 0rand3 // stochastic transposition, eager(0) by default (redrawn per source event)
note lead [0 1 2] * 2 // double each degree: 0, 2, 4
note lead utf8{coffee} % 14 // map byte values into scale-degree range
Scalar right-hand side — a constant scalar value is applied uniformly to every element each cycle (existing +/- behaviour is preserved). A stochastic scalar (e.g. 0rand3) follows the default eager rules: by default 'eager(0) redraws the RHS per source event; list-level 'eager(1) shares one draw across all source events in the cycle.
note lead [0 2 4] + 3 // every element gets +3 scale steps
note lead [0 2 4] + 0rand3 // default 'eager(0): independent offset per source event
note lead [0 2 4]'eager(1) + 0rand3 // one offset per cycle, shared across all events
Generator right-hand side — a list generator ([...]) or scalar generator may appear on the right. When a list generator is used, its values wrap around for position i: rhs_value = rhs[i % rhs_length]. Both operands reset their state at cycle boundaries.
// [0 1 2] + [4 8] → pos 0: 0+4=4, pos 1: 1+8=9, pos 2: 2+4=6
note lead [0 1 2] + [4 8] // → 4, 9, 6, 4, 9, 6 per cycle
// scalar RHS — existing behaviour, applied uniformly
note lead [0 1 2] + 3 // → 3, 4, 5
Division by zero — when the right-hand side evaluates to zero for a given element slot, a warning is emitted and the event for that slot is skipped (best-effort for live coding):
[1 2 3] / [4 0] // 1/4 fires; 2/0 is skipped with a warning; 3/4 fires (pos 2 wraps to rhs[0]=4)
Modulo zero — a % 0 is defined as the identity a (not an error or skip):
[1 2 3] % [4 0] // 1%4=1, 2%0=2, 3%4=3
Double-negative — note [0] - -4 is a parse error; use note [0] + 4 instead. This restriction applies only to + and - because the leading - on the RHS is syntactically ambiguous with a negative number literal; for *, /, **, and % the RHS must always be a positive scalar or a list generator.
See truth table 23 (Chord literals).
<d1 d2 ... dn> denotes N simultaneous degree values in a single event slot, spawning N synths at the same time. Purely additive — no timing implications; all voices fire at the same beat offset.
// produces two chord events where chords are timed like [0 1]
note chords [<0 2 4> <1 3 6>]
// Generator in chord, produces chord events like [<0 6> 2], [<0 4> 2], etc.
note chords [<0 4~7> 2]
// Produces error message: "Chords are not supported for mono content type"
mono lead [<1 2 3>]
Syntax:
chordLiteral = "<" chordElement+ ">" ;
chordElement = numericGenerator | degreeLiteral | rest ;<>must contain at least one element; a bare<>with no elements is a parse error.- Elements inside
<>are separated by spaces (same as[...]). - Each element is an independent generator evaluated under standard
'eagersemantics. - A chord literal is a non-scalar generator — it is valid wherever a sequence element (
[...]) or standalone event body is valid.
Constraints:
<>withmono: Semantic error — multiple simultaneous.setmessages with different degree values to the same node produce non-deterministic behaviour. Must be caught at evaluate time:"Chords are not supported for mono content type".<>as transposition operand: Parse error —note [0 2 4] + <0 4>would imply voice multiplication, not a chord. The transposition rule does not accept chord literals on the RHS.
See truth table 15 (Accidentals).
Accidentals modify a scale degree by one semitone. They are written as a suffix directly on the degree integer, with no space:
2b // third, flat
4# // fifth, sharp
3bb // third, double flat
4## // fifth, double sharp
Accidentals are interpreted as literals at parse time — 2b is a single token with value degree=2, accidental=flat. They are valid wherever a degree literal appears (inside [...] lists or as transposition operands).
set is a top-level statement for setting ambient session parameters that apply globally unless overridden. This avoids modifier sprawl and prevents the DSL from reinventing Pbind-style key-value pairs piecemeal.
set scale(minor)
set root(7)
set tempo(120)
set key(g# lydian)
Parameters: scale, root, octave, tempo, cent, key.
See truth tables 8 (Decorator scoping) and 11 (Indentation).
@ decorators apply session parameters to a scoped block of expressions, overriding global set values within that scope. They use a parenthesised argument list — the same syntax supports single arguments (@root(7)), compound arguments (@key(g# lydian 4)), and stochastic arguments (@root(3rand7)).
@scale(minor) @root(7)
note lead [0 1 2]
@octave(4)
note lead [0 2 4 5]
Here note lead [0 1 2] inherits @scale(minor) and @root(7). note lead [0 2 4 5] inherits all three, with @octave(4) added at the nested level.
Decorators must appear on their own line and always introduce an indented block — writing a decorator inline on the same line as an expression is a parse error:
// parse error — inline form is not allowed
@scale(minor) note lead [0 1 2]
// correct — decorator on its own line, body indented
@scale(minor)
note lead [0 1 2]
set and @ are complementary. set scale(minor) sets a session-wide default; @scale(minor) with an indented block overrides it for that block's scope. They share the same parameter namespace but are distinct syntactic forms — set never introduces a block, @ always does.
Indentation: block scope uses fixed indentation (2 spaces). Variable indentation is not supported — indentation level is determined by the number of leading 2-space units. This keeps the parser simple and the code visually consistent.
Stochastic decorator arguments follow the same 'lock/'eager(n) semantics as everything else. @root(3rand7) with 'eager(4) redraws every 4 cycles; with 'lock the value is drawn once when the block is first entered and frozen thereafter. 'lock is the sensible default for decorators — a randomly wandering root is an opt-in, not the default.
@buf(\name) is a pattern-level decorator that specifies which buffer a slice or cloud pattern operates on. Like all decorators, @buf must appear on its own line introducing an indented block:
@buf(\myloop)
slice drums [0 2 4 8]
@buf(\recording)
cloud grain []
@buf accepts a \symbol argument or a generator expression that produces \symbol values:
@buf([\loopA \loopB]'pick)
slice drums [0 4 8 12] // per-cycle buffer selection
Writing @buf inline before the content expression is a parse error — the same rule that applies to all decorators.
@buf on sample is a semantic error — buffer selection in sample is per-event inside the list.
Flux uses two conventions for naming things, depending on whether the name refers to a runtime artefact or built-in language vocabulary.
SynthDef names, FX names, and buffer names are written as symbols: a backslash immediately followed by an identifier, with no space.
\moog // SynthDef name
\lpf // FX name
\kit // buffer name
This is borrowed from SuperCollider. The backslash+identifier is a single token; whitespace between \ and the name is not permitted. String literals ("moog") are not valid in Flux — use symbols instead.
\symbol means "look this up in a runtime registry" — the set of valid names is open and user-extensible.
Scale names, key names, and root names are written as bare identifiers — no backslash.
set scale(minor) // scale name — bare identifier
set key(g# lydian) // key name — bare identifier
@scale(dorian) note lead [0 2 4]
Bare identifiers in these positions mean "this is a fixed, language-defined name" — the set of valid values is closed and defined by the language.
All content type keywords take exactly one optional \symbol argument to choose a SynthDef. The \symbol notation is required (not a bare identifier) to avoid name collisions with named generators.
note(\moog) lead [0 1 2 3]'lfoRate(1/4)
sample(\oneshot) drums [bd sn bd sn]
Generators are named by placing an identifier between the content type keyword (and optional SynthDef argument) and the generator expression. A name is mandatory — unnamed generator expressions are a parse error.
note lead [0 2 4]
sample drums [\kick \hat \snare]
mono bass [0 -2 0 -3]
note(\moog) lead [0 1 2 3]'lfoRate(1/4)
Generator naming is assignment, not declaration. Re-evaluating with the same name replaces the previous binding. The runtime diffs old and new state and updates in place where possible (e.g. set messages for mono content). Running synths are updated rather than killed and restarted — this preserves FX tails and avoids audible cuts. New synths are started for changed generator content where in-place update is not possible.
Duplicate names within a single evaluation are a static error, detected before any audio changes are made:
// ERROR — two generators named "lead" in one evaluation
note lead [0 2 4]
note lead [0 4 7]
A named generator can be the parent of derived voices, using child:parent syntax:
sample drums [\kick \hat \snare]
sample perc:drums 'at(1/8) | fx(\hpf)
perc inherits drums's pattern and params, overriding only what's explicitly specified. The parent name is resolved at evaluation time. If drums is edited, perc re-derives.
Derivation is always named — anonymous derived generators are not supported.
Removing a parent while derived generators still reference it is a static error, detected before any audio changes are made. The user must remove or re-parent derived generators first:
// ERROR — "perc" references "drums" which is not present
sample perc:drums 'at(1/8)
Named generators own their insert FX chains. Removing or redefining a named generator triggers drain-and-free: the FX synth runs until silence or a configurable timeout before being freed.
See truth table 9 (FX pipe).
Flux uses a two-tier FX model:
- Master bus FX — configured via the UI, not the DSL. All audio routes through a default chain (EQ → Reverb → Compressor → Limiter). The DSL cannot reference or modify master bus FX.
- Insert FX — DSL-instantiated, scoped to a source pattern via
|. Created when the source starts, released after a silence tail when the source stops.
There are no send FX.
A default master bus chain is set up at boot:
- EQ
- Reverb
- Compressor
- Limiter
The UI allows adjusting parameters, reordering, adding, and removing FX from the chain. The DSL has no syntax for master bus FX — it is a UI-only concern.
Insert FX are anonymous, scoped to a source pattern via the | pipe operator. Created when the source starts, released after a silence tail when the source stops.
note lead [0 2 4 7] | fx(\lpf)'cutoff(800)
note lead [0 2 4 7] | fx(\lpf)'cutoff([800 1200 2000 400]'eager)
note lead [0 2 4 7] | fx(\delay)'time(3/8)'feedback(0.4)
The pipe operator implicitly passes the source as the audio input to the FX node — no explicit routing required.
FX parameters use the same 'key(value) modifier syntax as everything else. Values can be literals, generators, or stochastic expressions. FX nodes are plain scsynth nodes and receive .set messages like any other synth.
Wet/dry level is an optional integer percentage written after all parameter modifiers. Default is 100% wet.
note lead [0 2 4 7] | fx(\ringmod) 70% // 70% wet, 30% dry
note lead [0 2 4 7] | fx(\lpf)'cutoff(800) 50%
Silence tail duration defaults to 5 seconds (post-envelope — the FX node runs until silence after its source has stopped). Override with 'tail:
note lead [0 2 4 7] | fx(\lpf)'cutoff(1200)'tail(10) // 10s tail
note lead [0 2 4 7] | fx(\lpf)'cutoff(1200)'tail(0) // free immediately when source stops
'tail value is in seconds and must be a non-negative number.
Three prefix characters serve distinct, non-overlapping roles in the DSL. Understanding the boundary between them is essential for reading and writing Flux code.
| Sigil | Role | What it does |
|---|---|---|
@ |
Decorator | Language-side pitch calculation. Sets parameters in the degree-to-frequency chain (root, scale, octave, cent). Always translates musical intent — never a raw synth argument passthrough. |
' |
Modifier | Transforms the event stream or controls generator behaviour. Agnostic about content: 'stut, 'legato, 'lock, 'eager, etc. Never touches raw synth arguments directly. |
" |
Param | Direct synth argument access. Bypasses language abstractions and sends a value straight to a named SynthDef parameter. Intentionally unglamorous — heavy reliance on "param is a signal to reconsider the SynthDef design or elevate the parameter to a first-class concept. |
The three mechanisms are mutually exclusive in what they can express:
@root(7)is a decorator — it participates in pitch calculation.'legato(0.8)is a modifier — it shapes the event stream."amp(0.5)is a param — it passes0.5directly to theampargument of the current SynthDef.
No sigil can substitute for another. Using 'amp(0.5) to set amplitude is not valid — amp is a SynthDef argument, not a stream modifier.
See truth tables 1 (Modifier attachment) and 2 (Modifier precedence).
The sign ' in an expression like x'y indicates a modifier, i.e. that y modifies the behaviour of x. Modifiers are strictly for stream and generator operations — they do not provide direct access to SynthDef arguments. Use "param notation for that (see below).
Modifiers are methods that return this, so chaining is supported:
note lead [0rand7 4rand6]'eager(1)'stut(2)
Modifiers attach to the immediately preceding token, not to the whole expression. This is the core rule governing modifier placement throughout the language.
[0rand7 4rand6]'stut(2)'lock // 'lock attaches to the generator, not to the content type keyword
Modifiers are generally written after the list they modify. Evaluation order is left-to-right.
Valid attachment points. A modifier ('name) or a "param must attach directly to a generator expression. The valid targets are:
| Target | Example | Notes |
|---|---|---|
| List generator | [0 2 4]'stut(2) |
Attaches to the whole list. |
| Scalar generator | 0rand7'lock |
Attaches to a single stochastic or literal generator. |
| Parenthesised expression | (0rand4)'lock |
The group is treated as one generator token. |
| Whole content-type expression | note [0 2 4]'legato(0.8) |
Attaches to the content-type expression as a whole — see below. |
| Another modifier (chaining) | [0 2 4]'stut(2)'lock |
Chained modifiers return this, so the next modifier attaches to the previous one. |
Placement after a non-generator token is a syntax error. In particular, a modifier or "param cannot attach to a bare content-type keyword, decorator, operator, or separator:
| Code | Failure |
|---|---|
note'legato(0.8) |
Syntax error — note alone is a content-type keyword, not a generator. |
note lead"amp(0.5) |
Syntax error — "param requires a preceding generator expression. |
'stut(2) |
Syntax error — no preceding token to attach to (see truth table 1). |
note [0] +'stut(2) |
Syntax error — the transposition operator is not a generator. |
To apply a modifier to the whole content-type expression (including a transposition operand), use a modifier continuation line — see below.
Implementation note: No whitespace is permitted between
'and the modifier name ([0]'lock, not[0]' lock). The current JS parser has a known deviation — it accepts a space because the lexer tokenises'and the identifier separately and Chevrotain ignores inter-token whitespace. Enforcing this would require a compound lexer token or a contextual lexer mode. Until fixed, the parser should emit a parse error for[0]' lockrather than silently accepting it. See truth table 12.
'eager(0) is the default for all generators. The argument is a non-negative integer cycle period (or 0 for per-event):
'eager(0)— redraw on every source event (pre-stutter). Default; bare'eageris shorthand for this.'eager(1)— draw once per cycle, shared across all source events in that cycle.'eager(n)for n ≥ 2 — redraw every n cycles.'lock— draw once at first evaluation, freeze forever.
Negative arguments are clamped to 0 (per source event) with a console warning — leniency for live coding, so a mistyped sign does not kill the pattern.
A "source event" is a pre-stutter slot: for [0 1]'stut(1~4), the two source events are the 0 and 1 slots, each of which may be stuttered into multiple output events. Under 'eager(0), stochastic modifier arguments like 1~4, 'legato(0.5rand1.2), "amp(0.3rand0.8) are redrawn per source event; all stuttered copies of a single source event share that draw.
Each generator is an independent stateful object. 'eager(n) on a list propagates down as the default to each element and to modifier arguments on that list; each generator applies its own annotation independently. There is no implicit value-sharing between elements.
// draw new values on every source event (explicit, same as default)
note lead [0rand7 4]'eager(0)
// one draw per cycle shared by all slots (was the old default)
note lead [0rand7 4]'eager(1)
// redraw every 4 cycles
note lead [0 4rand6]'eager(4)
// frozen after first evaluation — each element locks at its own first-drawn value
note lead [0rand7 4rand6]'lock
'lock and 'eager can be used at whatever level of granularity is needed (list-level, element-level, modifier argument-level).
note lead [0rand7 4rand6]'lock // both elements lock at their own first-drawn values
note lead [0rand7'lock 4rand6] // first element locked, second draws per source event (inner overrides outer)
Because modifiers attach to the immediately preceding token, a modifier written directly after a transposition operand would attach only to that operand — not to the whole content-type expression. To attach a modifier to a whole content-type expression (including its transposition operand), write it on an indented continuation line:
note lead [0 2 4] + 0rand3
'stut(2)
'legato(0.8)
Each continuation line begins with ' and attaches to the content-type expression as a whole, in the order written. This is the only way to reach the whole-content-type-expression attachment point described in the table above. The parser distinguishes modifier continuations from decorator block bodies by the leading ' character on the indented line.
Continuation lines are currently modifier-only; "param does not have a continuation form and must be written inline after a generator expression.
See truth table 18 (
"param).
"param(value) sends a value directly to a named SynthDef argument, bypassing the language's pitch and stream abstractions. It is valid anywhere a modifier is valid.
The token form is " immediately followed by an identifier, with no whitespace — analogous to \symbol. The "identifier is a single token.
note lead [0 2 4]"amp(0.5) // set amp to 0.5
note lead [0 2 4]"amp(0.5)"pan(-0.3) // chained: set amp and pan
note bass [0 2 4] | fx(\lpf)"cutoff(800)"rq(0.3) // on FX node
The value argument accepts the same expressions as modifiers — literals, generators, stochastic expressions:
note pad [0 2 4]"amp(0.3rand0.8) // stochastic amp, eager(0) by default (per source event)
note pad [0 2 4]"amp(0.3rand0.8'eager(4)) // redraw every 4 cycles
note pad [0 2 4]"amp(0.3rand0.8'lock) // freeze at first drawn value
SynthDef parameter names come from the SynthDef's specs object in static/compiled_synthdefs/metadata.json. Each key is a parameter name (e.g. amp, pan, rel); the value carries { default, min, max, unit, curve }. The active SynthDef is determined by the \symbol argument on the content type keyword (note(\kick) → look up kick). Parameter names are lowercase identifiers.
Tooling:
- The completion provider offers parameter names on
"trigger, prefix-filtered as the user types. - The hover provider shows
min,max,default, andunitfor a hovered"paramtoken.
Redefinition of a running pattern takes effect at the next cycle boundary. This is the musically correct behaviour — consistent with TidalCycles and with how performers think about metric structure.
All generators are evaluated eagerly at the cycle boundary — never lazily mid-cycle. This is a fundamental design constraint:
- For looping patterns (default): all generators inside the list are fully evaluated at the start of each cycle. The resulting event array is handed off to the scheduler as a concrete sequence. No generator polling happens during playback.
- For finite patterns (
'n): all generators are evaluated once when the pattern is first scheduled, producing a fixed event array for the entire duration (including all repetitions).
This guarantee is what makes 'stut and other count-modifying modifiers tractable: the scheduler receives a complete, fixed-length event array per cycle and can calculate durations, gate times, and subdivisions without needing to consult generators again during playback.
Generators have no access to external runtime state (e.g. MIDI input, sensor values, another pattern's current position) at the moment of playback. Values are committed at cycle start. This is intentional: Flux is a live coding tool, not a DAW. If a value should change, the performer re-evaluates the expression, which takes effect at the next cycle boundary.
When the user starts playback from a stopped state (Ctrl-Enter while nothing is playing), the cycle clock and cycle counter are reset to 0 and dispatching begins immediately — at beat 0 of cycle 0. "Immediately" means within the scheduler's normal lookahead window, not at the literal instant of the keystroke.
This is a phase-defining action. There is no prior metrical frame to preserve, so the clock starts fresh. The first sound should arrive within one lookahead window (~100 ms) after the keypress.
When the user re-evaluates while playback is already running (Ctrl-Enter while a loop is active), the change is quantised to the next cycle boundary. The outgoing loop plays to the end of its current cycle; the new loop picks up from beat 0 of the next cycle. The cycle clock is not reset — it continues uninterrupted.
This is a phase-preserving action, consistent with TidalCycles and Sonic Pi. Quantising to the cycle boundary keeps patterns metrically aligned during live coding.
If a pause state is added in the future, pause→resume must preserve clock phase — it must not reset the cycle clock. Resuming from a paused position is a phase-preserving action: the clock continues from where it was frozen. Only a full stop→play transition is phase-defining.
The cycle counter (used by 'stretch, cycleOffset, and similar constructs) resets to 0 on every stop→play transition. Continuing the counter across stop/play boundaries is not a behaviour any pattern relies on, and resetting matches user expectation: pressing play always starts from cycle 0.