Commit cab3ccd8 by PLN (Algolia)

feat(metadata): generate a gig's tracks.json from the parsers, never by hand

OPAL 2026 had NO metadata anywhere — content/lives/ carried 2024 and 2025
entries for the same festival and nothing for this one. Writing it by hand is
how wrong strings get copied between files (split_bandcamp.py's ALBUM already
did that once), so tools/gig-metadata.py reads every fact from whoever owns it:

  backlog.md  via tools/setlist.py --long   -> membership, ORDER, name, bpm, section
  the .tidal  via tools/setlist_samples.py  -> the sample packs actually referenced
  --segments                                -> timecodes measured off the take

The timecodes pay for themselves twice. They came from aligning the gig log to
the audio by cross-correlation (r>=0.99 over 20 probes across 11 orbits, 0.00 ms
spread), and they let a visualist cut to the set without re-deriving boundaries
by ear — which is exactly what Slopmotion needs.

OPAL 2026: 15 tracks, 89-166 BPM, 60 sample packs, 1:20:09 — and the set was
played in the EXACT planned order, 15 for 15.

Also two pan TODOs, measured rather than felt: mafia_sans_serif is width 0.05
and ghosts_in_the_toilets 0.09 (side/mid RMS) against a set median of ~0.20 —
the two narrowest tracks of the night, and they played back to back. Both files
also carry PLN's own gig-day edits; both still pass silent-eval --seeded.
parent add4e98f
-- TODO(pan): near-mono. Measured on the OPAL-26 master (2026-08-08,
-- 0:58:48-1:01:22): stereo width 0.09 (side/mid RMS) vs a set median of ~0.20 —
-- second-narrowest after mafia_sans_serif, and the two played back to back.
-- Three `pan` calls exist already; they are not buying much width. Worth
-- widening the ghost textures specifically (they are the character of the track).
do do
-- resetCycles -- resetCycles
setcps (160/60/4) setcps (160/60/4)
...@@ -5,14 +10,14 @@ d1 $ gM1 $ gF1 -- Kick solide ...@@ -5,14 +10,14 @@ d1 $ gM1 $ gF1 -- Kick solide
$ fix ((|* gain 0.9) . (# lpf 1000)) "jazz" $ fix ((|* gain 0.9) . (# lpf 1000)) "jazz"
$ fix ((# att 0.02) . (# rel 0.5) . (# lpf 5000)) "kick:4" $ fix ((# att 0.02) . (# rel 0.5) . (# lpf 5000)) "kick:4"
$ midiOn "^30" ((# "hardkick_rha:40") . (|- note 1) . (|* gain 0.8)) $ midiOn "^30" ((# "hardkick_rha:40") . (|- note 1) . (|* gain 0.8))
$ midiOn "^42" (struct "t t t t*<1!6 2 2>") $ midiOn "^41" (struct "t t t t*<1!6 2 2>")
$ midiOff "^42" (<| "k . ~ k ~ <~!3 <k ~>>") $ midiOff "^41" (<| "k . ~ k ~ <~!3 <k ~>>")
$ "[jazz,kick:4]" $ "[jazz,kick:4]"
# gain 1.5 # gain 1.5
d2 $ gM2 $ gF1 -- snare contretemps variable d2 $ gM2 $ gF1 -- snare contretemps variable
$ fix ((|* gain 0.8) . (# legato 0.05)) "cp" $ fix ((|* gain 0.8) . (# legato 0.05)) "cp"
$ midiOn "^43" (<| "~ cp [~!3 <~!3 cp>] cp*<1 1 2 <1 2>>") $ midiOn "^42" (<| "~ cp [~!3 <~!3 cp>] cp*<1 1 2 <1 2>>")
$ midiOff "^43" (<| "~ [cp*<1!3 <2 <4 [4 2]>>> ~]") $ midiOff "^42" (<| "~ [cp*<1!3 <2 <4 [4 2]>>> ~]")
$ "[cp,snare:22]" $ "[cp,snare:22]"
# pan 0.65 # pan 0.65
-- # legato 0.22 -- # legato 0.22
......
-- TODO(pan): this track is very nearly MONO. Measured on the OPAL-26 master
-- (2026-08-08, 1:01:22-1:04:33): stereo width 0.05 (side/mid RMS) against a
-- set median of ~0.20 — the narrowest thing in the whole 1h20. It is also the
-- most bass-dominant (78% of power in 60-250 Hz, only 10% sub).
-- It landed hard on the floor anyway, so this is polish, not repair: spread the
-- non-bass voices (keep <120 Hz mono for the club subs, per MASTERING.md).
resetCycles resetCycles
do do
...@@ -33,7 +39,8 @@ d4 $ gF2 $ gM3 ...@@ -33,7 +39,8 @@ d4 $ gF2 $ gM3
# crushbus 41 (range 16 2.5 "^32") # crushbus 41 (range 16 2.5 "^32")
# octerbus 42 (range 0 2.5 "^52") # octerbus 42 (range 0 2.5 "^52")
# gain 1.1 # gain 1.1
d5 $ gF2 $ gM3 -- d5 $ gF2 $ gM3
$ ((3/8) ~>)--
$ midiOn "^57" ((# n ("<0>" + "<0 0 0 [0 0 1 0] 3 5 4 5>")) $ midiOn "^57" ((# n ("<0>" + "<0 0 0 [0 0 1 0] 3 5 4 5>"))
. (ply 4) . (slice 4 2)) . (ply 4) . (slice 4 2))
$ midiOn "^89" ( $ midiOn "^89" (
......
#!/usr/bin/env python3
"""gig-metadata — build a gig's tracks.json from the parsers, never by hand.
Sources, all of them canonical (nothing here is typed twice):
backlog.md via tools/setlist.py -> membership, ORDER, name, bpm, section
the .tidal files via tools/setlist_samples.py -> sample packs actually referenced
a segments file (JSON: [{name,start_s,end_s}]) -> measured timecodes from the take
The timecodes matter beyond bookkeeping: they are what lets a visualist
(Slopmotion) cut to the set without re-deriving boundaries by ear.
Usage:
tools/gig-metadata.py --gig opal-festival-2026 --title "Sunset Forest" \
--date 2026-08-08 --segments segments.json --out tracks.json
"""
from __future__ import annotations
import argparse
import json
import re
import subprocess
import sys
from pathlib import Path
REPO = Path(__file__).resolve().parent.parent
STYLE_BY_PATH = [
("live/midi/nova/ambient/", "ambient"),
("live/midi/nova/techno/", "techno"),
("live/midi/nova/dnb/", "dnb"),
("live/midi/nova/lounge/", "lounge"),
("live/midi/nova/acid/", "acid"),
("live/midi/nova/jazz/", "nujazz"),
("live/midi/nova/remix/", "remix"),
("live/midi/nova/breaks/", "breaks"),
("live/techno/", "techno"),
("live/collab/", "collab"),
("live/boeuf/", "jam"),
]
def style_of(path: str) -> str:
for prefix, style in STYLE_BY_PATH:
if path.startswith(prefix):
return style
return "other"
def read_setlist() -> list[dict]:
"""tools/setlist.py --long is THE parser for what a set is and in what order."""
out = subprocess.run([sys.executable, str(REPO / "tools/setlist.py"), "--long"],
capture_output=True, text=True, timeout=120)
if out.returncode != 0:
raise SystemExit(f"setlist.py failed:\n{out.stderr}")
rows = []
for line in out.stdout.splitlines():
m = re.match(r"\s*(\S+)\s+(.{1,20}?)\s\s+(\S+\.tidal)\s+(.*)$", line)
if not m:
continue
bpm, section, path, name = m.groups()
rows.append({
"name": name.strip().rstrip("]"),
"file": path.strip(),
"bpm": int(bpm) if bpm.isdigit() else None,
"section": section.strip(),
"style": style_of(path.strip()),
})
return rows
def read_samples(files: list[str]) -> dict[str, list[str]]:
"""Which sample folders each track references (setlist_samples is the parser)."""
per_track: dict[str, list[str]] = {}
for f in files:
p = REPO / f
if not p.exists():
per_track[f] = []
continue
out = subprocess.run([sys.executable, str(REPO / "tools/setlist_samples.py"), str(p)],
capture_output=True, text=True, timeout=120)
names = []
for line in out.stdout.splitlines():
m = re.match(r"\s{2}(\S+)\s+/", line)
if m:
names.append(m.group(1))
per_track[f] = sorted(set(names))
return per_track
def hms(x: float) -> str:
x = max(0.0, float(x))
return f"{int(x // 3600)}:{int(x % 3600) // 60:02d}:{int(x % 60):02d}"
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--gig", required=True)
ap.add_argument("--title", required=True)
ap.add_argument("--date", required=True)
ap.add_argument("--venue")
ap.add_argument("--stage")
ap.add_argument("--segments", help="JSON [{name,start_s,end_s}] measured from the take")
ap.add_argument("--only-played", action="store_true",
help="keep only tracks present in --segments (drops soundcheck/unplayed)")
ap.add_argument("--out", required=True)
a = ap.parse_args()
rows = read_setlist()
segs = {}
if a.segments:
for s in json.loads(Path(a.segments).read_text()):
segs[s["name"]] = s
tracks = []
for r in rows:
stem = Path(r["file"]).stem
seg = segs.get(stem)
if a.only_played and not seg:
continue
entry = dict(r)
if seg:
entry["start_s"] = round(seg["start_s"], 2)
entry["end_s"] = round(seg["end_s"], 2)
entry["duration_s"] = round(seg["end_s"] - seg["start_s"], 2)
entry["start"] = hms(seg["start_s"])
entry["end"] = hms(seg["end_s"])
tracks.append(entry)
samples = read_samples([t["file"] for t in tracks])
for t in tracks:
t["samples"] = samples.get(t["file"], [])
bpms = [t["bpm"] for t in tracks if t.get("bpm")]
styles: dict[str, int] = {}
for t in tracks:
styles[t["style"]] = styles.get(t["style"], 0) + 1
doc = {
"gig": a.gig,
"title": a.title,
"date": a.date,
"venue": a.venue,
"stage": a.stage,
"bpmRange": [min(bpms), max(bpms)] if bpms else None,
"styleDistribution": dict(sorted(styles.items(), key=lambda kv: -kv[1])),
"trackCount": len(tracks),
"totalDuration_s": round(sum(t.get("duration_s", 0) for t in tracks), 2),
"samplePacks": sorted({s for t in tracks for s in t["samples"]}),
"tracks": tracks,
}
doc = {k: v for k, v in doc.items() if v is not None}
Path(a.out).parent.mkdir(parents=True, exist_ok=True)
Path(a.out).write_text(json.dumps(doc, indent=2, ensure_ascii=False) + "\n")
print(f"wrote {a.out}: {len(tracks)} tracks, "
f"{doc.get('bpmRange')} bpm, {len(doc['samplePacks'])} sample packs, "
f"{hms(doc['totalDuration_s'])} total")
if __name__ == "__main__":
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