Comparisons and branching
The six comparison operators — > < >= <= == != — each yield exactly
1.0 (true) or 0.0 (false), so they compose with arithmetic:
0.4 + (bass > 0.2) * 0.3 # 0.4 normally, 0.7 once the bass crosses 0.2select(cond, x, y) picks between two whole expressions. Because it evaluates
only the branch it takes, it is also the way to guard a partial function:
select(bass > 0.5, 3.0, 0.8) # a threshold switchselect(x >= 0, sqrt(x), 0) # safe — the untaken sqrt never runsThat last property is why select exists rather than a lerp-based blend: a
blend evaluates both sides, so an out-of-domain sqrt would poison the result
with NaN even on the branch you did not want.
There are no boolean operators (&&, ||, !) — with clean 0/1
comparison results they add nothing:
| You want | Write |
|---|---|
a AND b | min(a, b) |
a OR b | max(a, b) |
NOT c | 1 - c |
min(bass > 0.3, tempo > 120) # loud AND fastmax(beat, onset > 0.6) # on a beat OR a strong transientChained comparisons are legal but rarely what you mean. a > b > c parses
left-associatively as (a > b) > c, comparing a 0/1 against c. Write
min(a > b, b > c) instead.
Set the threshold from a measured level, not from --set
bass, mid, treb and onset are each a fraction of their own
slowly-decaying recent peak, so they genuinely span 0..1 and a threshold means
the same thing on every track:
| variable | mean | max | so a live threshold sits… |
|---|---|---|---|
bass | 0.661 | 1.000 | around 0.7–0.95 |
mid | 0.575 | 1.000 | around 0.6–0.95 |
treb | 0.281 | 1.000 | around 0.3–0.9 |
bass + mid + treb | 1.517 | 3.000 | around 1.6–2.6 |
bin(x) | 0.089 | 1.000 | around 0.15–0.6 |
Measured 2026-07-30 over --signal dynamic:110; re-measure any time with
shot --signal dynamic:110 --out strip.png, which prints the table.
Two traps remain, and they are the mirror image of the old one.
A threshold can be too LOW. A gate set below the typical level fires always,
and the else branch becomes the dead code instead of the then. This is the
commoner failure on the normalized scale, and the giveaway is a threshold written
for raw levels — nine bindings across the shipped library once needed retuning for
exactly that reason.
bin(x) is not on the scalars’ scale. The band array normalizes against one
peak shared by all 64 bands — which is what keeps bin(hi) - bin(lo) a meaningful
contrast — so a single band only reaches 1.000 when it is the loudest in the
frame. Its typical value is 0.089. A threshold tuned on bass is roughly 7×
too high for bin().
--set bass=1 writes the band straight onto the analysis frame, and since v2 that
is a reachable peak rather than a fiction — so calibrating against a --set
capture is now reasonable, remembering it is a held peak:
select(bass + mid + treb > 2.8, 24, 6) # near the 3.0 ceiling: fires rarelyselect(bass + mid + treb > 0.075, 24, 6) # below the 0.078 minimum: the constant 24select(bass + mid + treb > 1.9, 24, 6) # a real gateBefore v2, six shipped presets had their defining mechanism disabled by the
opposite error for months — fragment_kaleido never left 6 folds, reaction_reef
never folded at all — and all six scored healthy in --report, because its
stimuli were full-scale too. That whole class is what normalization removes.
Absolute level, when you actually want it
bass_raw, mid_raw, treb_raw and onset_raw carry the pre-v2 magnitudes,
unchanged: means of 0.040 / 0.006 / 0.006 / 0.002 against maxima of 0.106 / 0.019 / 0.032 / 0.016. Normalization deliberately hides absolute dynamics — a
quiet track and a loud one read alike — so reach for a *_raw when a look should
scale with real loudness. Everything the old warnings above said about tiny levels
and unreachable thresholds applies to these in full.
Musical time
Five variables place you in the music rather than measuring it:
| variable | range | meaning |
|---|---|---|
beat_index | 0, 1, 2, … | monotone onset-detection counter — see the trap below |
time_since_beat | seconds | 0 exactly on a detection, climbing to the next |
beat_in_bar | 0–3 | which beat of the bar this is |
bar_index | 0, 1, 2, … | bar counter (see the note below on monotonicity) |
bar_phase | 0–1 | position across the whole bar |
Two naming traps live here, not one. bar is beat phase under a historical
name, kept because too much shipped content binds it; bar_phase is the real bar
position. And beat_index / time_since_beat are named for beats but driven by
the onset detector, which is the larger trap of the two — the section below it
is about exactly that.
These make phrase-scale structure a one-liner:
select(beat_in_bar == 0, 1.4, 1.0) # accent every downbeat0.5 + 0.5 * sin(bar_phase * tau) # a sweep that breathes with the barselect(mod(bar_index, 8) < 4, 0.2, 0.8) # an 8-bar A/B alternation1.0 - clamp(time_since_beat * 6, 0, 1) # a decay retriggered by every detection
beat_indexcounts onsets, not beats, and no fixed multiplier converts between them. It increments on the onset detector’s flag —flux > mean + 1.5 sigmawith a 96 ms refractory and no tempo gating — so a hi-hat, a snare rattle and a chord change each advance it. Measured against the real beat on live material across two sets of three genres: 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 within a single track. That instability is the finding: there is no “about twice as often” to correct for.So
mod(beat_index, N)never means N beats.mod(beat_index, 16)is not four bars of four. Whatbeat_indexis good for is anything that only wants to change on activity without claiming a period:hash(beat_index)to re-roll a colour or a count on each hit, or a modulus chosen as a rough “every so often” and read as such. See ADR-0109.
beat_in_bar, bar_index and bar_phase come from a downbeat estimator that
publishes only while it is confident, and falls back to plain counters
otherwise. So they are always periodic and always usable, and never confidently
wrong about where the bar starts — you cannot see which mode is active,
deliberately, and you do not need to. 4/4 is assumed.
How often is it locked? Roughly 2–4 % of hops on material with bar-scale accents, and near zero on material without. The estimator folds accent history over a tempo-driven bar grid, so the four alignments it chooses between are alignments of a unit that is a stable multiple of the beat rather than a wandering one — a real bar when the tempo estimate is on the right octave, half or double one when it is not. Measured through the live app on three genres, the share of hops over the
0.25confidence gate:
rock/pop hip-hop techno over the gate 2.36 % 3.67 % 0.42 % The hip-hop column carries that caveat. Its capture read a
bpmmedian of 165 on a track that counts at ~90 — an octave high, which ADR-0109 records as the half of the ambiguity the autocorrelation has no evidence to settle — so the grid’s “bar” there spanned two musical beats. A stable two, not a wandering 1.35-2.10, which is the whole improvement; but not a bar.Techno reading lowest is the gate working, not a failure. Four-on-the-floor puts a kick on every beat, so there is no bar-scale accent structure to find. A gate that shuts on material with no bar accent is the honest outcome: a confidently wrong downbeat is worse than none, which is why the estimator publishes only while it is confident.
The remaining ceiling is diagnosed, not mysterious: the accent the estimator folds is 70 % bass band, on the assumption that the kick marks the bar. In four-on-the-floor the kick marks every beat; in a backbeat it marks 1 and 3, a half-bar. See ADR-0082’s
Outcomeand ADR-0109.What this means when you write a preset: the four one-liners above are all still correct and safe, but most of the time
beat_in_bar == 0fires on a beat the counter chose rather than one the music did, and it will not reliably agree with where you hear the downbeat. If a look must land on the real bar line, it cannot today. If it only needs a periodic four-beat pulse, these deliver it — just do not read the names as a promise about the music. And do not fall back tomod(beat_index, 16): the bar trio is the closest unit to a bar there is, andbeat_indexis not a musical period at all.
bar_index is monotone except across an alignment change. It is
(beat count - alignment) / 4 — where the beat count is the bar grid’s, and
beat_index only while the grid warms up — and alignment moves on the beat the
estimator locks, drops back to the counter, or is overtaken by a challenger, so at
that one beat the counter can repeat a bar or skip forward one. It never moves by
more than a bar and hysteresis makes it rare (a challenger has to lead for three
bars first), but if you write mod(bar_index, 8) for an 8-bar arc, know that a
lock landing mid-phrase can repeat or drop one bar of it. That is the deliberate
trade: a repeated bar is a much softer failure than a downbeat on the wrong beat,
which is the whole reason the gate exists.
Check the gate you just wrote
--report’s reachability check walks every expression and names any comparison
that only ever took one value, any select() whose condition never went both
ways, and any clamp() ceiling the value never reached
(Headless capture and video).
Run it before you ship a gate.
That covers the bare-comparison form too, which is the one this page tells you to
write: reseed = "onset > 0.55" holds no select(), and a threshold nothing
crosses makes it a boolean param stuck at 0 forever. It reports as a COMP
line (ADR-0043).
Reachability cannot see a gain — a second statistic does. A comparison is a
fork the walker can watch; a clamp(bass * 16, 0, 0.3) is not. A multiplier
written for the raw magnitudes drives such a ceiling from just above silence and
holds it there — a binding that reads as a constant while every gate stays green.
The whole shipped library was once in that state, so this is the failure to
expect, not a hypothetical.
The same traversal also records occupancy — the fraction of
hops a clamp() spends at its upper bound — which is the mirror of the
ceils finding and the more serious of the two
(ADR-0062).
--report’s occ column names the binding, and core/tests/saturation.rs is a
HARD gate at occupancy 0.9; a clamp that is genuinely meant to pin declares
[occupancy] exempt = [...], which silences the gate and not the diagnostic. The
arithmetic is still worth doing while you compose, because the gate is deliberately
high: a term reaches its cap at ceiling / multiplier, and if that number is below
the typical level in the table above the term is a constant long before occupancy
0.9 convicts it.
Built from a8ce055 at version 0.115.0. This site tracks main and is not versioned per release.