Commit 6850da97 by PLN (Algolia)

merge: tempo_timeline, the confirmed gig facts, and the board

Two commits, 4 files, reviewed as a diff against master.

  tempo_timeline.py       tempo per window in a rendered track, to test whether
                          a planned track was actually played. Answered WAP: not
                          played (median 124.5 over segment 1, nothing at 133
                          above the confidence floor), calibrated first on a
                          declared-160 track which returned 161.5.
  gigmeta sidecar         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.
  build_gig_tracksjson    resolve the frontmatter-only fields before writing
                          _provenance, so their origin is recorded whether or
                          not --md-out was passed.
  TASKS_DUMP.md           the board after the release session: six resume
                          points, two of which are the kind that look done when
                          they are not.

Two bugs of mine are in the branch rather than tidied out of it: the octave fold
was an infinite loop for any value a sub-octave band cannot represent, and the
per-frame FFT loop was ~100k Python-level calls for a five-minute stem.
parents 89f9d712 b3279c8d
...@@ -1315,3 +1315,123 @@ still need the browser edit FORM — api-v2 PUT is dead for both create and upda ...@@ -1315,3 +1315,123 @@ still need the browser edit FORM — api-v2 PUT is dead for both create and upda
⚠️ **Do not upload CosmicFest's 14-track split before PLN approves the tone.** SoundCloud ⚠️ **Do not upload CosmicFest's 14-track split before PLN approves the tone.** SoundCloud
is idempotent by permalink: a v2 re-render would be silently SKIPPED, not replaced. The is idempotent by permalink: a v2 re-render would be silently SKIPPED, not replaced. The
continuous mix goes up first, for judgement; the split follows approval. continuous mix goes up first, for judgement; the split follows approval.
---
## BOARD STATE — 2026-09-02, CosmicSet is on the shelf *(pre-compact)*
The two-set release board from 2026-09-01 is **half discharged**: CosmicFest-26
went from "finished on disk with nowhere to go" to a private 15-item release on
SoundCloud, with a canonical www gig page behind it. OPAL-26's remaining upload
work is untouched and is now the only release thread left.
### ✅ Closed this session
| was | now |
|---|---|
| C-1 gig metadata blocks every upload adapter | **done**`content/lives/2026/cosmicfest-2026.md` + `tracks.json`, every non-derivable field sourced |
| C-2 tone call +5 vs +8 | **done** — PLN chose +5 for catalogue coherence; measured against OPAL v4, not a genre norm |
| C-3 upload CosmicSet private | **13/15 confirmed live**, 2 re-uploading (see below) |
| C-4 does the demucs OOM mean data loss | **no** — the killed run is a fossil in the journal; rerun succeeded at 7.6 GB, artefacts verified |
| the WAP question | **resolved** — not played, confirmed by PLN *and* by tempo timeline |
| merge the release tooling | **merged** `2f947c2..b747cc7`, pushed |
### 🔴 RESUME POINTS — exact next steps
**R1 · Finish the CosmicSet upload (5 min, unblocked).**
Two tracks were accepted, API-verified live, then vanished from the account —
SC-side, not the files (both FLACs decode with zero ffmpeg errors) and not quota
(`/me.quota` says `unlimited_upload_quota: true`). A plain re-run fixes it:
```bash
cd /home/pln/Work/Sound/tidal-ears
.venv/bin/tidal-ears master sc form-album \
--plan /home/pln/Work/Sound/Prod/Cosmic26_master/release_plan_v1_streaming.json \
--sharing private --which all --go # idempotent: skips the 13 already up
```
Then the gate, which is the real check — the uploader saying "N uploaded" is not
evidence that N tracks exist:
```bash
cd /home/pln/Work/Sound/Tidal/armada/tide-table
/home/pln/Work/Sound/tidal-ears/.venv/bin/python check_permalinks.py \
/home/pln/Work/Sound/Prod/Cosmic26_master/release_plan_v1_streaming.json
```
Want: `15 already up as this same audio · 0 COLLISION(S)`. Missing at compact:
`/am-i-doing-it-right-cosmicfest-2026`, `/vague-de-crime-cosmicfest-2026`.
**R2 · Push the www gig page (needs PLN, 10 s).**
`www` is `git@github.com:PLNech/www.git` and no SSH key was loaded in the
session, so the branch is committed but local:
worktree `/home/pln/Work/Web/www-wt-cosmicmeta`, branch
`claude/cosmicfest-2026-metadata`, 2 commits (`e8ba8d1`, `c4814d7`).
PLN runs `! ssh-add`, then `git -C /home/pln/Work/Web/www-wt-cosmicmeta push -u
origin claude/cosmicfest-2026-metadata`. **Do not** commit these in the main
`www` tree — it is on `claude/self-host` with another session's uncommitted work
in `app/` and `components/`. The same two files are also sitting UNTRACKED in the
main tree on purpose, because `build_release_plan.py` reads them from the `WWW`
path; they are duplicates of what the worktree has committed, not new work.
**R3 · The mix permalink carries a doubled year (cosmetic, 2 min).**
Live at `/cosmicset-2026-2026-full-set-cosmicfest-2026`; the builder has since
been fixed to emit `cosmicset-2026-full-set-cosmicfest-2026`. Metadata PUTs are
free on api-v2, so: regenerate the plan AND rename on SC **in the same
operation** — renaming alone would make the next run see the mix as free and
re-upload 693 MB.
**R4 · One ear call on the split, and only one.**
Over the last ~80 s of segment 1 the drums stem reads 161.5 BPM — the tempo of
the next track. Either a long livecoded blend (which is what PLN's "direct from
Bombe DJ to Something about Drums" looks like in Tidal) or a boundary at 6:46.6
sitting later than the music. A tempo timeline can see a segue; it cannot say
where the boundary belongs.
```bash
cd /home/pln/Work/Sound/Tidal/armada/tide-table
D=/home/pln/Work/Sound/Prod/Cosmic26_master/stems_demucs/sections
/home/pln/Work/Sound/tidal-ears/.venv/bin/python tempo_timeline.py \
"$D/01-ceci_nest_pas_une_bombe/drums.wav" --window 20 --hop 10 --expect 124
```
**R5 · There is still NO `release_signoff` for CosmicFest.**
The plan on disk carries an `_UNSIGNED` block naming why it was forced: the
audition upload had to precede the signoff, because SoundCloud is where PLN
listens. **Do not flip these permalinks public** until a `release_signoff` lands
in `judge_specs/cosmicfest-2026_ear_verified.json`.
**R6 · Two levers on the record, both unspent and both PLN's call.**
- **The low end**, which is the real harmony lever and much bigger than the air
shelf we spent a night on: 0.36x of OPAL below 60 Hz, 0.67x in 60-120, next to
44.8% kick presence against OPAL's 59%. A shelf under 120 Hz is untried and is
risky on a room capture — it lifts rumble and mud with the kick, and costs
headroom on a master already at -0.9 dBTP.
- **The club -9 LUFS variant**, still parked in the spec's `variants_deferred`
with its reasoning. Moving the key back into `variants` renders it.
- **Cover art**: nothing is attached to any of the 15 uploads. `Tidal/output/
Jardin_*.jpg` looked like candidates but choosing is not mine to do.
### 🟢 Unblocked by this session, not started
- **OPAL-26's remaining 7 SC tracks.** Now genuinely low-risk: the permalink
gate exists, and the four titles that collide across the two gigs are known.
Note OPAL's existing uploads are **unsuffixed**, so a suffix policy applied
now would be inconsistent with them — decide that before uploading more.
- **OPAL's two known defects**: `take-five-drops` is ~24 s short, and
`ghosts-in-the-toilets` has a malformed tag (needs the browser edit FORM;
api-v2 PUT is dead for it).
- **`build_gig_tracksjson.py --meta` is now the way to make any gig page**, so
the older gigs missing pages are cheap to add. See
`reference_gig_tracklist_sources` for where the non-derivable facts hide —
three of the four fields I asked PLN for were already written down in `www`.
### Tooling added (all merged to master except the follow-ups branch)
`master_stemless.py` · `demucs_sections.py` · `eda_stems.py` ·
`setlist_to_segments.py` · `compare_masters.py` · `build_gig_tracksjson.py` ·
`check_permalinks.py` · `tempo_timeline.py` · `finish_cosmic.sh`
Branches at compact: `claude/cosmic-followups` (Tidal, pushed) ·
`claude/scform-permalink-override` (tidal-ears, pushed, 2 commits) ·
`claude/cosmicfest-2026-metadata` (www, **local only**, 2 commits).
`live/collab/raph/piment_bresilien.tidal` is modified in the Tidal tree and is
**PLN's own edit** — his friend wants to collab on that track. Not mine, not to
be touched.
...@@ -160,6 +160,13 @@ def main() -> int: ...@@ -160,6 +160,13 @@ def main() -> int:
still_needed = [k for k in ("title", "venue", "stage", "description") still_needed = [k for k in ("title", "venue", "stage", "description")
if mval(k) in (None, "")] + needs 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 = { out = {
"_draft": [ "_draft": [
"Generated by build_gig_tracksjson.py. Derived fields (name, file, bpm,", "Generated by build_gig_tracksjson.py. Derived fields (name, file, bpm,",
......
...@@ -27,8 +27,8 @@ ...@@ -27,8 +27,8 @@
}, },
"stage": { "stage": {
"value": "Le Jardin", "value": "Le Jardin",
"source": "derived from the invite page's lineup heading — PLN to confirm the name and casing", "source": "PLN, confirming the invite page — derived from the lineup heading and then approved verbatim",
"locator": "www/app/cosmicfest/CosmicFestClient.js:234 \"le 22 aout, dans le jardin\"", "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" "date": "2026-09-02"
}, },
"location": { "location": {
...@@ -88,7 +88,14 @@ ...@@ -88,7 +88,14 @@
"source": "PLN's own set plan in backlog.md — the canonical informal tracklist source (reference_gig_tracklist_sources). NOT invented.", "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", "locator": "Tidal/backlog.md ## COSMICFEST (~line 1986): Ouverture / SUNSET / _We call it NuJazz_ / NUIT / FINALE",
"date": "2026-09-02", "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.", "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": "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." "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