Skip to content

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.2

select(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 switch
select(x >= 0, sqrt(x), 0) # safe — the untaken sqrt never runs

That 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 wantWrite
a AND bmin(a, b)
a OR bmax(a, b)
NOT c1 - c
min(bass > 0.3, tempo > 120) # loud AND fast
max(beat, onset > 0.6) # on a beat OR a strong transient

Chained 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:

variablemeanmaxso a live threshold sits…
bass0.6611.000around 0.70.95
mid0.5751.000around 0.60.95
treb0.2811.000around 0.30.9
bass + mid + treb1.5173.000around 1.62.6
bin(x)0.0891.000around 0.150.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 rarely
select(bass + mid + treb > 0.075, 24, 6) # below the 0.078 minimum: the constant 24
select(bass + mid + treb > 1.9, 24, 6) # a real gate

Before 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:

variablerangemeaning
beat_index0, 1, 2, …monotone onset-detection counter — see the trap below
time_since_beatseconds0 exactly on a detection, climbing to the next
beat_in_bar03which beat of the bar this is
bar_index0, 1, 2, …bar counter (see the note below on monotonicity)
bar_phase01position 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 downbeat
0.5 + 0.5 * sin(bar_phase * tau) # a sweep that breathes with the bar
select(mod(bar_index, 8) < 4, 0.2, 0.8) # an 8-bar A/B alternation
1.0 - clamp(time_since_beat * 6, 0, 1) # a decay retriggered by every detection

beat_index counts onsets, not beats, and no fixed multiplier converts between them. It increments on the onset detector’s flag — flux > mean + 1.5 sigma with 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. What beat_index is 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.25 confidence gate:

rock/pophip-hoptechno
over the gate2.36 %3.67 %0.42 %

The hip-hop column carries that caveat. Its capture read a bpm median 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 Outcome and 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 == 0 fires 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 to mod(beat_index, 16): the bar trio is the closest unit to a bar there is, and beat_index is 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.