Commit 156444d6 by PLN (Algolia)

feat(tempo-lens): measure the tempo the room FEELS, not the one setcps declares

#12 (order the set into a BPM arc) has been blocked on three tempo questions, and
they were framed as things PLN had to decide. Two of them are measurable.

FIRST, ONE DISSOLVED ON INSPECTION. you_my_sunshine was recorded as "144 measured vs
166 written", a 22 BPM gap big enough to INVERT the planned rising finish
Mafia(160)->Sunshine(166). The file has two setcps lines and the 144 one is COMMENTED
OUT. The active tempo is 166, the backlog was right all along, and the "mismatch" was
a grep reading a comment. That is the parsers-over-copy lesson again: a parser miss
must never masquerade as a data conflict. (My own first re-extraction then mangled
every number by using `tr -d '/60'`, which deletes the digits 6 and 0 — 160 became 1.
Two parsing bugs in one sitting, on four-line shell one-liners.)

SECOND, THE REAL QUESTION NEEDS AUDIO. gimme_acid declares 80 while the backlog calls
it 160, and `setcps` cannot settle it: setcps(80/60/4) declares four beats per cycle
at 80, but whether that is HEARD as 80 or 160 depends on what the patterns put inside
the cycle. Half-time notation is standard in dnb. This decides whether the track sits
beside the 160 BPM peak or is the second-slowest thing in the set.

So: spectral-flux novelty curve -> autocorrelation -> the fastest pulse that explains
the signal, reported together with its half/double family, because tempo from audio
is only ever determined up to a factor of two and pretending otherwise is the error.

BUILT THE KATANA FIRST, and it needed four passes — every one caught on synthetic
click tracks of KNOWN tempo before the tool informed any decision:
 1. Inter-onset-interval MODE. Failed on the first real track: gimme_acid's d1 is a
    continuous 303 line whose flux peaks every ~80 ms of internal movement, so the
    modal gap gave "750 events/min". Counting gaps between events cannot find a beat
    when the events are not beats. -> autocorrelation, which asks about PERIODICITY
    and is unbothered by extra onsets inside each period.
 2. Octave errors: a clean 124 click read 61.9, a clean 160 read 80.0 — exactly half,
    correlation 0.85+, confidently wrong. Cause: at 100 Hz frames the true lag 48.39
    must round to 48, which misaligns every later beat, while exactly 2x landed on a
    whole frame. -> 200 Hz frames + 15 ms Gaussian smoothing, so a fractional period
    still matches itself.
 3. A uniform +1.8% bias (~2 BPM at techno tempo — enough to swap a 124 and a 127 in
    a set order). Suspected the unnormalised overlap in np.correlate, fixed that too,
    and the bias did not budge. The actual cause: the shortest-lag-within-12% rule,
    written to choose between octaves, was also sliding down the LEFT FLANK of the
    correct peak — comparing points on one hill as if they were different hills.
    -> candidates restricted to local maxima, plus parabolic interpolation.
Result: 13/13 synthetic cases within 0.03%, including jittered, noisy, offbeat-8ths,
and the half-time case (80 BPM with 16th kicks correctly reads 160 felt).

A tempo number that is 2% wrong looks perfectly reasonable, which is exactly why this
had to be calibrated against known truth instead of eyeballed against real audio.
parent 104fb035
#!/usr/bin/env python3
"""tempo-lens — measure the tempo an orbit is actually FELT at, from its audio.
Why this exists (2026-07-28, J-7 to OPAL)
-----------------------------------------
Ordering a set by BPM needs the BPM, and `setcps` is not it. `setcps(80/60/4)`
declares four beats to a cycle at 80 — but whether that is *heard* as 80 or as 160
depends entirely on what the patterns put inside the cycle. Half-time notation is
standard in dnb, and `gimme_acid` is written 80 while the backlog calls it 160. That
one question decides whether the track sits beside the 160 BPM peak or is the
second-slowest thing in the set, and no amount of reading the file can settle it.
So measure the kick. Onset times from spectral flux, then the histogram of
inter-onset intervals — the MODE, not the mean, because a mean over a pattern with
ghost notes lands between two real values and is a tempo nothing is playing.
The answer is reported as a FAMILY, not a number: tempo from audio is only ever
determined up to a factor of two (a listener may feel 80 or 160 in the same music,
and both are correct). What the tool can say is which subdivision the events
actually occupy, and that is what a set order needs.
Usage
-----
tools/tempo-lens.py --orbit 1 -s 20 # the kick orbit, 20s
tools/tempo-lens.py --orbit 1 --declared 80
Needs the track already playing (this tool never boots or evaluates anything) and
numpy. Safe to run during a set.
"""
from __future__ import annotations
import argparse
import importlib.util
import sys
from pathlib import Path
import numpy as np
TOOLS = Path(__file__).resolve().parent
def _load(name: str, path: Path):
spec = importlib.util.spec_from_file_location(name, path)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
lens = _load("control_lens", TOOLS / "control-lens.py")
SR = lens.SR
# 200 Hz, not 100. At 100 Hz a lag of 48.39 frames (124 BPM) has to be rounded to 48,
# which misaligns every later beat and CRUSHES the correlation at the true period —
# measured 0.70 at the true lag versus 0.85 at exactly twice it, because 2x happened
# to land on a whole frame (96.8 -> 97). That is how a clean 124 BPM click track got
# estimated at 61.9. Finer frames plus the smoothing below make the correlation
# tolerant of a fractional period, which is the actual requirement.
HOP_HZ = 200
SMOOTH_MS = 15.0
def flux_envelope(sig: np.ndarray) -> np.ndarray:
"""Spectral-flux novelty curve, smoothed — the input to tempo estimation.
The smoothing is not cosmetic. An impulse-thin novelty peak only correlates with
another impulse-thin peak if the lag is an exact whole number of frames; giving
each peak ~15 ms of width means a period of 48.39 frames still matches itself.
"""
win, hop = 1024, SR // HOP_HZ
n = (len(sig) - win) // hop
if n < 64:
return np.array([])
w = np.hanning(win)
frames = np.lib.stride_tricks.sliding_window_view(sig, win)[::hop][:n]
mag = np.abs(np.fft.rfft(frames * w, axis=1))
flux = np.maximum(0.0, np.diff(mag, axis=0)).sum(axis=1)
sigma = max(1.0, SMOOTH_MS * HOP_HZ / 1000.0)
half = int(3 * sigma)
k = np.exp(-0.5 * (np.arange(-half, half + 1) / sigma) ** 2)
return np.convolve(flux, k / k.sum(), mode="same")
def tempo_from_envelope(env: np.ndarray, lo_bpm=55.0, hi_bpm=210.0):
"""(bpm, strength, curve) by AUTOCORRELATING the novelty curve.
The first version of this took the MODE of the inter-onset intervals, and it
failed on the very first track it was pointed at. gimme_acid's d1 is a
continuous 303-ish line, so the flux peaks every ~80 ms of its own internal
movement and the modal gap was 0.080 s — reported as "750 events/min", with no
simple ratio to the declared 80 BPM. The tool was right to refuse a verdict, but
it had no business asking that question: **counting gaps between events cannot
find a beat when the events are not beats.**
Autocorrelation asks the right question — "at what lag does this signal resemble
itself?" — which is a statement about PERIODICITY and is unbothered by extra
onsets inside each period. Restricting the lag search to a musical range
(55-210 BPM) does the rest.
"""
if env.size < 4 * HOP_HZ:
return None, 0.0, None
x = env - env.mean()
raw = np.correlate(x, x, mode="full")[len(x) - 1:]
if raw[0] <= 0:
return None, 0.0, None
# UNBIASED: divide each lag by the number of samples that actually overlapped.
# np.correlate sums over N-k terms at lag k, so without this the curve decays
# with lag purely for arithmetic reasons and the peak is dragged toward SHORTER
# lags — i.e. every tempo reads HIGH. Measured as a uniform +1.4 to +2.0% across
# twelve synthetic click tracks, which is ~2 BPM at techno tempo: enough to swap
# a 124 track and a 127 track in a set order. A bias that consistent is never the
# music, it is the estimator.
counts = np.arange(len(x), 0, -1)
ac = (raw / counts) / (raw[0] / counts[0])
lo_lag = max(2, int(HOP_HZ * 60.0 / hi_bpm))
hi_lag = min(len(ac) - 1, int(HOP_HZ * 60.0 / lo_bpm))
if hi_lag <= lo_lag:
return None, 0.0, None
window = ac[lo_lag:hi_lag + 1]
peak = float(window.max())
if peak <= 0:
return None, 0.0, None
# OCTAVE DISAMBIGUATION — take the FASTEST pulse that still explains the signal.
#
# A perfectly periodic signal correlates just as well at TWICE the lag (two
# periods is also a match), so a plain argmax picks an arbitrary metrical level
# and often the wrong one. Caught on synthetic click tracks before this tool ever
# informed a decision: a clean 124 BPM kick was estimated at 61.9, a clean 160 at
# 80.0 — both exactly half, both with correlation 0.85+, i.e. confidently wrong.
#
# Among all lags whose correlation is within 12% of the peak, choose the SHORTEST.
# That names the fastest pulse the audio supports, which is the stable convention;
# the caller is then shown the half/double family explicitly, because tempo from
# audio is only ever determined up to a factor of two and pretending otherwise is
# the actual error.
# Candidates must be LOCAL MAXIMA, not merely "high".
#
# The shortest-lag rule below exists to choose between metrical levels, but on a
# smoothed curve each peak is a plateau ~15 ms wide, so a bare threshold test also
# slides down the LEFT FLANK of the correct peak and returns a lag ~2% short —
# which is where the stubborn uniform +1.8% tempo bias actually came from. It was
# never the octave logic and never the overlap normalisation; it was comparing
# points on one hill as if they were different hills.
interior = window[1:-1]
is_peak = (interior >= window[:-2]) & (interior > window[2:])
peaks = np.flatnonzero(is_peak) + 1
tall = peaks[window[peaks] >= 0.88 * peak]
best = int(tall[0]) + lo_lag if tall.size else int(np.argmax(window)) + lo_lag
# Parabolic interpolation across the three bins around the chosen lag. The true
# period is not a whole number of frames, so the integer bin is systematically
# off — measured a consistent +1.8% before this, which is 2 BPM at techno tempo
# and enough to make a 124 track and a 127 track swap places in a set order.
if 0 < best < len(ac) - 1:
y0, y1, y2 = ac[best - 1], ac[best], ac[best + 1]
denom = y0 - 2 * y1 + y2
if denom != 0:
shift = 0.5 * (y0 - y2) / denom
if abs(shift) <= 1.0:
best = best + shift
return 60.0 * HOP_HZ / best, float(peak), ac
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--orbit", type=int, required=True,
help="dN to listen to — use the KICK orbit (usually d1)")
ap.add_argument("-s", "--seconds", type=float, default=20.0)
ap.add_argument("--declared", type=float, default=None,
help="the track's setcps BPM, to compare against")
args = ap.parse_args()
sig, why = lens.capture(args.orbit, args.seconds)
if sig is None:
print(f"tempo-lens: DISCARD — {why}", file=sys.stderr)
return 2
env = flux_envelope(sig)
if env.size == 0:
print(f"tempo-lens: INCONCLUSIVE — no usable novelty curve on d{args.orbit}",
file=sys.stderr)
return 3
bpm, strength, _ = tempo_from_envelope(env)
if bpm is None:
print("tempo-lens: INCONCLUSIVE — no periodicity in the musical range",
file=sys.stderr)
return 3
print(f"tempo-lens: d{args.orbit}, {args.seconds:g}s of audio")
print(f" strongest periodicity: {bpm:.1f} BPM "
f"(autocorrelation {strength:.2f})")
if strength < 0.15:
print(" WEAK — that correlation is low enough that the number should not be "
"trusted. Try the kick orbit, or a longer -s.")
print("\n the same music, expressed at each metrical level "
"(audio fixes tempo only up to a factor of 2):")
for mult, label in ((0.5, "half-time"), (1.0, "as measured"), (2.0, "double-time")):
print(f" {bpm * mult:6.1f} BPM ({label})")
if args.declared:
print(f"\n declared setcps BPM: {args.declared:g}")
for mult in (0.25, 0.5, 1, 2, 4):
if abs(bpm * mult - args.declared) < 0.06 * args.declared:
if mult == 1:
print(f" MATCH — the pulse IS the declared tempo. "
f"'{args.declared:g}' is what a listener feels.")
else:
print(f" MATCH at {mult:g}x — the audible pulse is "
f"{bpm:.0f} BPM while the file declares "
f"{args.declared:g}. Neither is wrong; for ORDERING A SET "
f"use the felt {bpm:.0f}, since that is what the room "
f"responds to.")
break
else:
print(" NO simple ratio matches the declared BPM. Either this is not the "
"kick orbit, or the pulse is genuinely elsewhere — listen before "
"acting on it.")
return 0
if __name__ == "__main__":
sys.exit(main())
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment