Skip to content

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. beat is 0/1; bar is the 0..1 beat phase despite the name (bar_phase is the real one); time is seconds; tempo is BPM, not a 0..1 band; novelty is an experimental track-change transient; index is the element’s own 0..1 position in a per-element binding, and 0 everywhere else. The four *_raw escapes 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). log is the natural log; bin(x) reads the spectrum at a normalized position; hash/noise are the seeded randomness below.
  • Constants: pi, tau.
  • Comparisons: > < >= <= == !=, each yielding 1/0, plus select(cond, x, y). No boolean operators — min is and, max is or, 1 - c is 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.

variableminmeanmaxnote
bass0.0350.6611.000
mid0.0310.5751.000
treb0.0020.2811.000
onset0.0010.1451.000normalized envelope now, not raw flux
bass + mid + treb0.0781.5173.000the three-band sum, per hop
bin(x)0.0000.0891.000still 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:

Terminal window
cargo run -p standalone --example shot -- --signal dynamic:110 --out strip.png

Three 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 + mid has a mean of 1.24 against a max of 2.0, so select(bass + mid > 1.5, 12, 6) is a live gate while select(bass + mid > 0.085, 12, 6) is the constant 12 and > 2.4 is the constant 6. 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 at C / 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’s occ column names the binding and core/tests/saturation.rs fails the build on it (Plan 0056 / ADR-0062, and the [occupancy] table below). Do the division while composing anyway: the gate fires at occupancy 0.9, so a term pinned for half a track passes it. The rule that came out of the retune: pick G = C / 0.85 for bass/mid and C / 0.60 for treb/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 ramp
burst = "0.4 + hash(floor(time * 2)) * 0.6" # a new value twice a second
thickness = "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 retired attractor_dejong had four); one noise call is shorter and has no period at all. Existing ones are fine as they are — this is not a rewrite.
  • Sum noise calls 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) and noise(time * 0.2 + 50) wander independently.
  • seed is 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.