Commit 6ae2b820 by PLN (Algolia)

feat(armada): draft a gig's tracks.json — derive what is derivable, refuse to guess the rest

build_release_plan.py refuses to emit anything without the canonical www gig
page, with the right message: "Gig metadata is never invented here — create it
there first." Every upload adapter reads that plan, so one missing file blocks
SoundCloud, YouTube and Bandcamp at once. CosmicFest 2026 has no page, which
makes that gate the single thing standing between a finished master and PLN
hearing it on the platform he asked to hear it on.

The gate is correct and this does not weaken it. It splits the file along the
line the gate cares about instead of blurring it:

  derivable   name, file, bpm, start/end/duration, style, samples
  PLN only    the gig's title, venue, stage, description, and each track's
              `section` (the movements of the set)

Everything in the second group is emitted as null with a `_needs_pln` list, never
as a plausible guess. The output is a DRAFT at whatever path you pass; promoting
it into the www repo stays PLN's call. So tomorrow is four fields and a review
rather than an afternoon of reconstruction.

Derivations reuse what already parses the corpus rather than re-reading it:
`style` comes from the score's own directory, which is how OPAL's published
styles line up with the tree, and `samples` comes from catalog_view.json's
`score_sounds`. A score absent from that view is REPORTED, not handed an empty
list — an empty sample list is invisible once it is on the shelf, which is the
whole lesson of feedback_parsers_over_copy. Seven of CosmicFest's fourteen are
currently absent because the view is stale; re-running build_catalog_view fills
them.

The derived timecodes then validated themselves against PLN's own ear notes,
which is the reassuring part. Piment Bresilien lands at 0:35:46 against his
playhead comment "starting at 35:45.6 i hear proper only piment start sound",
and LiveCode Parade at 0:54:58 against the 54:59 note where crossmatch had
guessed gimme_acid and the declared 130 BPM broke the tie. 14 tracks, 63.0 min,
30 sample packs, 89-170 BPM.
parent 37eb401f
#!/usr/bin/env python3
"""build_gig_tracksjson — draft the canonical www `tracks.json` for a gig.
Why this exists: `build_release_plan.py` refuses to emit anything without
`Web/www/content/lives/<year>/<gig>.md` + `<gig>/tracks.json`, saying "Gig
metadata is never invented here — create it there first." Every upload adapter
reads that plan, so a missing gig page blocks SoundCloud, YouTube and Bandcamp
alike. CosmicFest 2026 has no page.
Most of that file is DERIVABLE and the rest is not, so this tool draws the line
explicitly instead of blurring it:
derivable name, file, bpm, start/end/duration, style, samples
PLN ONLY the gig's title, venue, stage, description (the .md frontmatter)
and each track's `section` (the set's movements)
Anything in the second group is emitted as `null` with a `_needs_pln` list, never
as a plausible guess — `feedback_metadata_vs_mastering` and
`feedback_metadata_provenance`. The output is a DRAFT written wherever you point
it; promoting it into the www repo is PLN's call, not this script's.
`samples` reuses `catalog_view.json`'s `score_sounds`, which is the existing
score parser. A track absent from that view is reported rather than given an
empty list, because an empty sample list is invisible once it is on the shelf
([[feedback_parsers_over_copy]]).
"""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
HERE = Path(__file__).resolve().parent
# The style OPAL published per track matches the score's own directory, which is
# how the corpus is organised. Derive it there rather than restating it.
STYLE_DIRS = {"techno", "dnb", "lounge", "acid", "jazz", "remix", "nujazz",
"breaks", "chip", "liquid", "house", "dub", "ambient"}
def hms(s: float) -> str:
s = int(round(s))
return f"{s // 3600}:{(s % 3600) // 60:02d}:{s % 60:02d}"
def style_of(score: str | None) -> str | None:
if not score:
return None
parts = [p for p in Path(score).parts if p in STYLE_DIRS]
return parts[-1] if parts else None
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("spec")
ap.add_argument("--out", required=True, help="draft tracks.json path")
ap.add_argument("--md-out", help="also draft the .md frontmatter skeleton")
a = ap.parse_args()
spec = json.loads(Path(a.spec).read_text())
segs = json.loads(Path(spec["segmentsRelease"]).read_text())
nominal = {s["track"]: s for s in
json.loads(Path(spec["segments"]).read_text())}
view = json.loads((HERE / "catalog_view.json").read_text())["tracks"]
sounds = {t["track"]: t.get("score_sounds") or [] for t in view}
rows, no_sounds, needs = [], [], []
for s in segs:
nom = nominal.get(s["perf_track"], {})
score = nom.get("score")
snd = sounds.get(score)
if score and snd is None:
no_sounds.append(score)
bpm = nom.get("bpm")
rows.append({
"name": s["title"],
"file": score,
"bpm": bpm if isinstance(bpm, (int, float)) else None,
"section": None, # PLN only — the set's movements
"style": style_of(score),
"start_s": s["start"],
"end_s": s["end"],
"duration_s": s["duration"],
"start": hms(s["start"]),
"end": hms(s["end"]),
"samples": sorted(snd) if snd else [],
})
if rows[-1]["section"] is None:
needs.append(f"section for #{s['track']} {s['title']}")
out = {
"_draft": [
"DRAFT generated by build_gig_tracksjson.py — not canonical until PLN",
"reviews it and it is committed under Web/www/content/lives/.",
"`section` is null for every track: the set's movements are PLN's to name.",
"Everything else is derived from the gig spec, the ear-verified segments",
"and catalog_view.json's score parser.",
],
"gig": spec["gig"],
"title": None, # PLN only
"date": spec["date"],
"venue": None, # PLN only
"stage": None, # PLN only
"trackCount": len(rows),
"totalDuration_s": round(sum(r["duration_s"] for r in rows), 2),
"bpmRange": ([min(r["bpm"] for r in rows if r["bpm"]),
max(r["bpm"] for r in rows if r["bpm"])]
if any(r["bpm"] for r in rows) else None),
"samplePacks": sorted({x for r in rows for x in r["samples"]}),
"_needs_pln": ["title", "venue", "stage", "description"] + needs,
"tracks": rows,
}
Path(a.out).write_text(json.dumps(out, indent=2, ensure_ascii=False) + "\n")
print(f"{'#':>3} {'start':>9} {'bpm':>5} {'style':<8} {'samples':>7} name")
for r, s in zip(rows, segs):
print(f"{s['track']:>3} {r['start']:>9} {str(r['bpm'] or '—'):>5} "
f"{str(r['style'] or '—'):<8} {len(r['samples']):>7} {r['name']}")
print(f"\n{len(rows)} tracks · {out['totalDuration_s']/60:.1f} min · "
f"{len(out['samplePacks'])} sample packs · bpm {out['bpmRange']}")
if no_sounds:
print(f"⚠ {len(no_sounds)} score(s) absent from catalog_view (samples left empty — "
f"re-run build_catalog_view to fill): " + ", ".join(Path(p).stem for p in no_sounds))
print(f"⚠ NEEDS PLN: title · venue · stage · description · {len(needs)} track sections")
if a.md_out:
Path(a.md_out).write_text(
"---\n"
f'# DRAFT — every TODO below is PLN\'s to fill. Do not publish as-is.\n'
f'title: "TODO set title"\n'
f'date: "{spec["date"]}"\n'
f'time: "TODO"\n'
f'location: "TODO"\n'
f'address: "TODO venue"\n'
f'stage: "TODO"\n'
f'description: "TODO"\n'
f'ctaURL: ""\nctaText: ""\nvideo: ""\naudio: ""\narchive: ""\n'
f'tags: ["livecoding", "tidalcycles"]\n'
"---\n\n"
f"# TODO set title — CosmicFest {spec['date'][:4]}\n\n"
f"{len(rows)} morceaux, {out['totalDuration_s']/60:.0f} minutes. "
"La tracklist complète, avec les timecodes mesurés sur l'enregistrement, "
"est dans `tracks.json`.\n")
print(f"✓ {a.md_out} (frontmatter skeleton)")
print(f"✓ {a.out}")
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