The expression language
Each [params] value is a pure expression evaluated every frame. A malformed
expression (or structural config) makes the whole preset fail to load with a
surfaced error — the engine keeps the last good preset, never crashes (NFR 10).
- Variables (19):
bass mid treb onset beat bar time tempo novelty bass_raw mid_raw treb_raw onset_raw beat_index time_since_beat beat_in_bar bar_index bar_phase index.beatis 0/1;baris the 0..1 beat phase despite the name (bar_phaseis the real one);timeis seconds;tempois BPM, not a 0..1 band;noveltyis an experimental track-change transient;indexis the element’s own 0..1 position in a per-element binding, and0everywhere else. The four*_rawescapes carry the pre-v2 absolute magnitudes (see below). The five musical-time variables are not equals — see the two layers immediately below.
The two musical-time layers, and why neither is a musical period on its own.
beat_index and time_since_beat are unconditional — always tracked — but
beat_index does not count musical beats. It counts onset-detector events
(ADR-0109):
measured across two sets of three genres at 1.35x-2.10x detections per musical
beat in one and 1.20x / 1.22x / 2.28x in the other, and it wanders between 1x, 2x
and 4x inside one track. The ratio is material-dependent and not a stable
integer, so beat_index is a monotone activity counter and not a musical
period: mod(beat_index, 16) is not four bars and mod(beat_index, 4) is not
“every 4th beat”. Neither idiom is safe to write.
beat_in_bar, bar_index and bar_phase come from a gated downbeat
estimator and are counter-derived whenever it is not confident. That estimator
folds over a tempo-driven bar grid rather than over beat_index, so its unit
is a stable multiple of the beat instead of a wandering one — a real bar when the
tempo estimate is on the right octave, and half or double one when it is not. That
caveat is not theoretical: on the hip-hop capture below the estimate read a 165 BPM
median on a track that counts at ~90, so its “bar” there spanned two musical beats.
Measured through the live app on three genres, the share of hops over the 0.25
confidence gate is 2.36 % on rock/pop, 3.67 % on hip-hop (at that wrong
octave) and 0.42 % on techno.
Read that as roughly 2-4 % on material with bar-scale accents, near zero on
material without. Four-on-the-floor puts a kick on every beat, so there is no
bar-scale accent to find and the honest outcome is a shut gate. The accent feature
is 70 % bass band, which is why it finds so little
(ADR-0082’s
Outcome carries that diagnosis).
So the trio is counter-derived most of the time, and beat_index is not the
safer unit to reach for instead. Bind the trio freely, since it stays periodic and
never claims a wrong beat 1, but do not write a look whose whole point is landing
on the real bar line. For a structural pulse, bar_index / beat_in_bar are the
closest unit there is; for a timed one, time in seconds against tempo in
BPM is the only reading that is neither gated nor onset-driven.
- Functions:
sin cos abs floor sqrt log min max pow mod clamp lerp smoothstep select bin hash noise(17).logis the natural log;bin(x)reads the spectrum at a normalized position;hash/noiseare the seeded randomness below. - Constants:
pi,tau. - Comparisons:
> < >= <= == !=, each yielding1/0, plusselect(cond, x, y). No boolean operators —minis and,maxis or,1 - cis not.
Full grammar reference: Expression language.
What range the bands actually occupy
bass/mid/treb/onset really are 0..1. Each is a fraction of that
signal’s own slowly-decaying recent peak, so > 0.5 means “loud for this track”
— on any track, at any gain, under any stimulus. That is what keeps a threshold
from being quietly unreachable because the levels turn out to be a hundred times
smaller than they looked.
| variable | min | mean | max | note |
|---|---|---|---|---|
bass | 0.035 | 0.661 | 1.000 | |
mid | 0.031 | 0.575 | 1.000 | |
treb | 0.002 | 0.281 | 1.000 | |
onset | 0.001 | 0.145 | 1.000 | normalized envelope now, not raw flux |
bass + mid + treb | 0.078 | 1.517 | 3.000 | the three-band sum, per hop |
bin(x) | 0.000 | 0.089 | 1.000 | still on its own scale — see below |
Measured 2026-07-30 over --signal dynamic:110. Re-measure rather than guess
— any filmstrip run prints the band table:
cargo run -p standalone --example shot -- --signal dynamic:110 --out strip.pngThree things to carry from that table.
Full scale is now reachable. All four hit 1.000, so --set bass=1 is a
peak, not the 100×-a-real-mean fiction it used to be. Calibrating against a
--set capture is finally reasonable — with the caveat that you are looking at a
held peak, so a gate that only fires there fires rarely.
bin(x) is still on its own scale, for a new reason. The band array
normalizes against one peak shared by all 64 bands (which is what keeps a
bin(hi) - bin(lo) contrast meaningful), so a single band only reaches 1.000
when it is the loudest in the frame. Its typical value is 0.089, an order of
magnitude under the scalars. A threshold that works on bass will be far too high
on bin().
The *_raw escapes read on the old scale. bass_raw, mid_raw, treb_raw
and onset_raw are the pre-v2 magnitudes, unchanged — means of 0.040 / 0.006 / 0.006 / 0.002. Reach for them only when a look genuinely wants absolute loudness
(a quiet track should look quieter), and when you do, the old warnings below
apply to them in full.
Comparison gates written against magnitudes on the wrong scale are dead code:
six shipped presets had their defining mechanism disabled for months because their
thresholds sat above anything music produces, and every one of them looked healthy
in
--report. Two rules follow:
- A threshold goes between the mean and the max of what it reads. Above the
max it never fires; below the min it always does — and both halves of that bite.
On the v2 scale
bass + midhas a mean of1.24against a max of2.0, soselect(bass + mid > 1.5, 12, 6)is a live gate whileselect(bass + mid > 0.085, 12, 6)is the constant12and> 2.4is the constant6. Pre-v2 the low-threshold failure is the commoner mistake on this scale, and it is the one that was found in nine shipped bindings at once and retuned. - Calibrate a continuous parameter against the mean and a percussive one against the peak. A zoom or a hue drift spends its life near the mean; a flash or a burst exists to fire on the hit.
- A GAIN can be wrong the same way.
clamp(band * G, 0, C)reaches its ceiling atC / G; if that is below the typical level, the term is a constant no matter how reactive it reads. Phase 7 found 263 of 332 clamped band terms in that state at once. This one is now checked —--report’socccolumn names the binding andcore/tests/saturation.rsfails the build on it (Plan 0056 / ADR-0062, and the[occupancy]table below). Do the division while composing anyway: the gate fires at occupancy0.9, so a term pinned for half a track passes it. The rule that came out of the retune: pickG = C / 0.85forbass/midandC / 0.60fortreb/onset, which puts a typical passage near half the cap and a peak at it.
--report now checks all of this for you: the second table reads at these levels,
and its gates / ceils / occ columns name any select() that never went both
ways, any clamp() ceiling the value never reached, and any clamp() that never
let its ceiling go
(Headless capture and video).
The gain rule has one exception class: a param whose cap is a FAILURE STATE rather than a maximum. The rule above assumes the top of a param’s range is a more of whatever the param does — more zoom, more bloom, more curl — so arranging for a peak to reach it is the whole point. Some params are not like that. Their range contains a region where the picture exists and a region where it does not, and the edge between the two is not a maximum, it is a death. Gaining a band term to reach the cap on one of those means gaining it to reach the state where there is nothing on screen.
The treatment is to pull the range in at both ends — pick the two values the look lives between, and let the band drift between those. A small reactive span is the correct answer here rather than a timid one: it is a drift between two living states, not a sweep to the edge of the parameter space. The occupancy gate has nothing to say about it, because the term never had a cap to reach.
Gray-Scott feed/kill is the worked example. The reaction-diffusion field
draws its picture out of the gaps between the growing regions, and the live
regime is a narrow band in the feed×kill plane. Gains derived by the house
rule take the field into the filled regime, where the gaps close, no contour is
left to draw, and the preset renders as a flat wash — found by rendering it, as
flat mustard, by the author of a reaction-diffusion preset that did not survive
curation. The three shipped ones show the treatment instead: reaction_etching
runs feed = "0.0420 + noise(…) * 0.0022 + clamp(bass * 1.18, 0, 1) * 0.0016" —
a base inside the live band, a ±0.0016 reactive span, and the clamp bounding
the band rather than capping the term. reaction_verdigris and
reaction_mitosis are the same shape at their own regimes.
The class is unlikely to have one member, which is why it is worth naming
rather than leaving in a preset header. Anything whose parameter space has a dead
region behind an edge is in it. If you are about to write C / 0.85 on a param,
first ask what its cap actually looks like on screen.
Seeded randomness — hash, noise, and [generator] seed
hash(x) scatters (neighbouring arguments unrelated, [0, 1)); noise(x)
wanders (smooth over one unit of x, [0, 1]). Both are pure functions of the
argument and the preset’s seed — same input, same frame, same number.
[params]hue = "noise(time * 0.3)" # organic drift, not a rampburst = "0.4 + hash(floor(time * 2)) * 0.6" # a new value twice a secondthickness = "2 + hash(index * 64) * 3" # scatter a per-element readout
[generator]seed = 12 # any non-negative integer: the same look, every time# seed = "random" # a different look every time the preset loads"random" redraws on every load — app start, and every hot reload of the
preset folder — so while you are editing a file it re-rolls on each save.
- Prefer
noise(time * k)to a sum of detuned sines in new presets. The older files fake a wander with three or four sines whose periods do not line up (the retiredattractor_dejonghad four); onenoisecall is shorter and has no period at all. Existing ones are fine as they are — this is not a rewrite. - Sum
noisecalls at different rates for a richer wander:noise(t*0.11)*0.6 + noise(t*0.43)*0.3. There is no octave machinery and does not need to be. - Offset the argument, not just its scale, to decorrelate two parameters:
noise(time * 0.2)andnoise(time * 0.2 + 50)wander independently. seedis not an L-system key despite living in that table. Any preset of any system may carry a[generator]table holding nothing but a seed.
seed = "random"is invisible to the harness. Every capture path —shot, the goldens,--report, the behavioral gates — forces the numeric fallback (0), so a filmstrip of a random-seeded preset shows an instance, not the one the app will draw. Tune with a number; switch to"random"last, if at all.
Built from a8ce055 at version 0.115.0. This site tracks main and is not versioned per release.