Commit 3ed1bc2d by PLN (Algolia)

feat(tide-table): tempo_timeline — check whether a planned track was played

PLN's set plan lists WAP [133] second at CosmicFest and the recording has no WAP
in it. He remembered dropping it — "didnt play WAP i think in the end [...]
direct from Bombe DJ to Something about Drums, its what i chose that day" — and
asked me to check rather than take his word, which is the right instinct: the
rival explanation is that WAP IS in the recording, inside segment 1, and the
boundary pass read two tracks as one. Duration cannot separate those (track 1 is
6.8 min, an ordinary length here for one track and also for two short ones).

Tempo can, because the two candidates differ: Bombe DJ is 124 and WAP is 133,
which is 0.484 s against 0.451 s per beat — 7% apart, well inside what an
inter-onset histogram resolves. So this walks a rendered track in windows and
reports what tempo each window is actually playing, on the demucs DRUMS stem
rather than the master, because the question is about kick timing and a stem is
far cleaner to find onsets in than a stage-mic mix.

Method deliberately copied from tools/tempo-lens.py, which measures a live orbit
and cannot read a file: spectral-flux onsets, then the MODE of the inter-onset
intervals — never the mean, which over a pattern with ghost notes lands between
two real values and reports a tempo nothing is playing.

Calibrated before it was trusted, on There's Something About Drums (declared
160): median 161.5. Then the answer, on segment 1 with --expect 124 --alt 133:
median 124.5 over 26 windows with a steady pulse, and NO window at 133 above the
steadiness floor. One 20 s window reads 132.5 at confidence 0.11, below the
floor and with no neighbour agreeing — WAP as a played track would occupy twelve
to twenty-four consecutive windows, not one. So: not played. PLN's memory and
the audio agree, and the boundary pass did not miss a track.

Two bugs of my own on the way here, both worth the record:

  * the octave fold was an INFINITE LOOP. Folding into a band of [100, 180) by
    doubling-and-halving never terminates for a value like 99: 99 -> 198 (out
    the top) -> 99 -> 198, because no power of two lands it inside a band
    narrower than an octave. Two processes sat at 98% CPU while I assumed the
    machine was merely busy with the upload. The fold is now closed-form
    (`bpm * 2**-floor(log2(bpm/lo))`) into [X, 2X), and the CLI takes
    --octave-from because an octave is the only honest width: tempo from audio
    is determined up to a factor of two and no further.
  * the per-frame FFT loop was ~100k Python-level calls for a five-minute stem.
    One strided view and one batched rfft instead.

Also, since the meta sidecar is where facts live: PLN's confirmed set time
(~20:30), his verbatim approval of "Le Jardin", and the WAP question moved from
open to resolved with both sources named. And build_gig_tracksjson now resolves
the frontmatter-only fields (time, location, CTA, tags) BEFORE writing
`_provenance`, so their origin is recorded in tracks.json whether or not
--md-out was passed. tracks.json is the file that outlives the invocation.

Side finding for PLN's ears, reported rather than acted on: over the last ~80 s
of segment 1 the drums stem reads 161.5 BPM — the tempo of the NEXT track. Two
readings, and I cannot choose between them from here. Either the segue is a long
livecoded blend with Something About Drums layered in early (which is what
"direct" looks like in TidalCycles), or the boundary at 6:46.6 sits later than
the music does. His ear owns that one.
parent 89f9d712
......@@ -160,6 +160,13 @@ def main() -> int:
still_needed = [k for k in ("title", "venue", "stage", "description")
if mval(k) in (None, "")] + needs
# Resolve the frontmatter-only fields NOW, before `_provenance` is written.
# They are consumed further down in the --md-out block, so without this
# their origin was recorded only when --md-out happened to be passed — and
# tracks.json is the file that outlives the invocation.
for k in ("time", "location", "ctaURL", "ctaText", "tags"):
mval(k)
out = {
"_draft": [
"Generated by build_gig_tracksjson.py. Derived fields (name, file, bpm,",
......
......@@ -27,8 +27,8 @@
},
"stage": {
"value": "Le Jardin",
"source": "derived from the invite page's lineup heading — PLN to confirm the name and casing",
"locator": "www/app/cosmicfest/CosmicFestClient.js:234 \"le 22 aout, dans le jardin\"",
"source": "PLN, confirming the invite page — derived from the lineup heading and then approved verbatim",
"locator": "PLN chat 2026-09-02: \"\\\"Le Jardin\\\" case perfect\" · www/app/cosmicfest/CosmicFestClient.js:234 \"le 22 aout, dans le jardin\"",
"date": "2026-09-02"
},
"location": {
......@@ -88,7 +88,14 @@
"source": "PLN's own set plan in backlog.md — the canonical informal tracklist source (reference_gig_tracklist_sources). NOT invented.",
"locator": "Tidal/backlog.md ## COSMICFEST (~line 1986): Ouverture / SUNSET / _We call it NuJazz_ / NUIT / FINALE",
"date": "2026-09-02",
"caveat": "The planned order matches the measured set exactly, with ONE difference: the plan lists 'WAP' [133] second in Ouverture and it does not appear in the recording, so the mapping shifts up by one from track 2 onward. Track 14 ('Outro: La Dub Sirene') is not in the plan at all - its section is taken from PLN's own title for it.",
"open_question": "Was WAP played? Track 1 is 6.8 min, in line with other single tracks (3 is 6.6, 7 is 6.3), so it does not look like two tracks merged - but that is an inference, not a measurement."
"caveat": "The planned order matches the measured set exactly, with ONE difference: the plan lists 'WAP' [133] second in Ouverture and it was NOT PLAYED. PLN, 2026-09-02: \"didnt play WAP i think in the end [...] i confirm, direct from Bombe DJ to Something about Drums, its what i chose that day\". So the section mapping shifts up by one from track 2 onward. Track 14 ('Outro: La Dub Sirene') is not in the plan at all - its section is taken from PLN's own title.",
"open_question": "RESOLVED 2026-09-02 by PLN, and independently checked against the audio: see tempo_timeline.py on track 1's drums stem (Bombe DJ is 124 BPM, WAP 133 - 7% apart and resolvable). A live decision, not a boundary-detection miss."
},
"time": {
"value": "20:30",
"source": "PLN",
"locator": "chat 2026-09-02: \"ftrecord time was ~20:30\"",
"date": "2026-09-02",
"caveat": "PLN said ~20:30 — approximate, and it is the RECORD start, which is the closest thing to a set start we have."
}
}
#!/usr/bin/env python3
"""tempo_timeline — tempo over TIME in a rendered track, to test "was X played?"
## The question this answers
A set plan and a set are different objects. CosmicFest's backlog plan lists WAP
[133] second and the recording has fourteen tracks with no WAP in them — so
either it was dropped, or it is sitting inside a neighbouring segment that the
boundary pass read as one track. Durations cannot settle that (track 1 is 6.8
min, which is an ordinary length for one track here and also an ordinary length
for two short ones), and neither can listening for a name.
Tempo can, when the two candidates differ: Ceci n'est pas Une Bombe is 124 and
WAP is 133. 124 puts a beat at 0.484 s, 133 at 0.451 s — 7% apart, which is well
inside what an inter-onset histogram resolves. So walk the track in windows and
ask what tempo each window is actually playing.
## Method, and why each choice
Deliberately the same method as `tools/tempo-lens.py`, which measures a LIVE
orbit and cannot read a file:
* onsets from spectral flux, not from a beat tracker — a beat tracker reports
a global tempo and would average straight through the thing being looked for
* the **MODE** of the inter-onset intervals, never the mean: a mean over a
pattern with ghost notes lands between two real values and reports a tempo
nothing is playing
* tempo from audio is determined only up to a factor of two, so the result is
folded into a stated band (default 100-180) rather than pretending the
octave is known. Two candidates 7% apart are distinguishable; 124 vs 62 is
not, and is not being asked
Run it on a **demucs drums stem** when one exists rather than the master. The
question is about kick timing, and `feedback_mastering_eda` reads stems as a
lens; a stem is also simply far cleaner to find onsets in than a stage-mic mix.
python3 tempo_timeline.py <audio> --window 30 --expect 124 --alt 133
"""
from __future__ import annotations
import argparse
import sys
import numpy as np
import soundfile as sf
def onsets(mono: np.ndarray, sr: int, hop: int = 256, n: int = 1024) -> np.ndarray:
"""Onset times from half-wave-rectified spectral flux."""
win = np.hanning(n)
frames = 1 + (len(mono) - n) // hop
if frames < 4:
return np.array([])
# One strided view + one batched rfft. The obvious per-frame loop is ~100k
# Python-level FFT calls for a five-minute stem and took minutes under load,
# which made the tool useless for the question it was written to answer.
m = np.lib.stride_tricks.as_strided(
mono, shape=(frames, n),
strides=(mono.strides[0] * hop, mono.strides[0]), writeable=False)
mag = np.abs(np.fft.rfft(m * win, axis=1))
flux = np.maximum(np.diff(mag, axis=0), 0).sum(axis=1)
if not flux.size:
return np.array([])
# Adaptive threshold: a fixed one measures the recording level, not the beat.
med = np.median(flux)
thr = med + 1.2 * (np.percentile(flux, 90) - med)
peaks = [i for i in range(1, len(flux) - 1)
if flux[i] > thr and flux[i] >= flux[i - 1] and flux[i] > flux[i + 1]]
return np.array(peaks) * hop / sr
def tempo_of(times: np.ndarray, lo: float, hi: float) -> tuple[float, float]:
"""Modal BPM in [lo, hi), folded by octaves, plus how dominant the mode is."""
if len(times) < 8:
return (0.0, 0.0)
iois = np.diff(times)
iois = iois[(iois > 0.05) & (iois < 4.0)]
if iois.size < 6:
return (0.0, 0.0)
bpm = 60.0 / iois
# Fold into ONE OCTAVE [lo, 2*lo) in closed form. The obvious
# multiply-until-it-fits loop is an infinite loop whenever the requested
# band is narrower than an octave: with [100, 180), a value of 99 goes
# 99 -> 198 (>= 180) -> 99 -> 198 forever, because no power of two lands it
# inside. I hung two processes at 98% CPU on exactly that before reading my
# own arithmetic. An octave is also the only honest width — tempo from audio
# is determined up to a factor of two and no further.
bpm = bpm * np.power(2.0, -np.floor(np.log2(bpm / lo)))
hi = 2 * lo
# 1 BPM bins: the two candidates are ~7% apart, far coarser than this.
edges = np.arange(lo, hi + 1, 1.0)
h, _ = np.histogram(bpm, bins=edges)
if not h.sum():
return (0.0, 0.0)
k = int(np.argmax(h))
# Confidence = share of intervals within +/-2 BPM of the mode. A flat
# histogram means "no steady pulse here", which must not read as a tempo.
centre = edges[k] + 0.5
share = float(np.abs(bpm - centre).__le__(2.0).mean())
return (centre, share)
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("audio")
ap.add_argument("--window", type=float, default=30.0)
ap.add_argument("--hop", type=float, default=15.0, help="window step (s)")
ap.add_argument("--octave-from", type=float, default=90.0,
help="lower edge of the octave to fold into; the band is "
"[X, 2X). Must be an octave: anything narrower cannot "
"represent every input and is not a band at all.")
ap.add_argument("--expect", type=float, help="the tempo the score declares")
ap.add_argument("--alt", type=float,
help="a rival tempo to test for, e.g. a planned track that "
"may or may not have been played")
a = ap.parse_args()
d, sr = sf.read(a.audio, dtype="float32", always_2d=True)
mono = d.mean(axis=1)
lo, hi = a.octave_from, 2 * a.octave_from
print(f"{a.audio}\n {len(mono)/sr:.1f}s @ {sr} Hz · "
f"windows of {a.window:.0f}s every {a.hop:.0f}s · band {lo:.0f}-{hi:.0f}")
if a.expect:
print(f" expecting {a.expect:.0f}"
+ (f", testing for {a.alt:.0f}" if a.alt else ""))
print(f"\n{'at':>8} {'bpm':>7} {'conf':>6} verdict")
rows = []
t = 0.0
while t + a.window <= len(mono) / sr:
seg = mono[int(t * sr):int((t + a.window) * sr)]
bpm, conf = tempo_of(onsets(seg, sr), lo, hi)
rows.append((t, bpm, conf))
if bpm == 0:
verdict = "no steady pulse"
elif a.expect and abs(bpm - a.expect) <= 2.5:
verdict = f"= expected {a.expect:.0f}"
elif a.alt and abs(bpm - a.alt) <= 2.5:
verdict = f"** MATCHES {a.alt:.0f} **"
else:
verdict = ""
print(f"{int(t)//60:>5}:{int(t)%60:02d} {bpm:>7.1f} {conf:>6.2f} {verdict}")
t += a.hop
good = [r for r in rows if r[1] > 0 and r[2] >= 0.15]
if not good:
print("\nno window had a steady enough pulse to call — inconclusive")
return 0
bpms = np.array([r[1] for r in good])
print(f"\n{len(good)}/{len(rows)} windows with a steady pulse · "
f"median {np.median(bpms):.1f} · range {bpms.min():.0f}-{bpms.max():.0f}")
if a.alt:
hits = [r for r in good if abs(r[1] - a.alt) <= 2.5]
if hits:
print(f"\n{len(hits)} window(s) at {a.alt:.0f} BPM — consistent with "
f"{a.alt:.0f} being played here:")
for t, bpm, conf in hits:
print(f" {int(t)//60}:{int(t)%60:02d} {bpm:.1f} (conf {conf:.2f})")
else:
print(f"\nNo window matches {a.alt:.0f} BPM. Nothing at that tempo is "
f"inside this audio.")
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