Commit f584a1c2 by PLN (Algolia)

fix(metadata): teach gig-metadata both segment shapes, and refuse to guess

The OPAL tracks.json on the website still carried the pre-#148 tab-focus
boundaries, drifting up to 50 s from the measured cuts — and that file feeds
both the site and Slopmotion, so the visualist would have been cutting to
timecodes that no longer describe the audio.

Regenerating it should have been one command, except the two sides of the
pipeline describe a segment differently:

  {name, start_s, end_s}   what this tool was built for — keyed by .tidal stem
  {title, start, end}      what the mastering side produces (segments_v3.json)

Rather than add a converter script beside it, the tool now accepts both. One
parser per concept: "a segments file" is one idea and should have one reader,
not a reader plus a shim that can disagree with it.

The v3 shape is matched to the setlist BY TITLE, never by position. Position
would work today and silently pair the wrong boundary with the wrong track the
first time a soundcheck row or an unplayed track shifts the list — and this is
exactly the class of bug #148 already was. An unmatched title is therefore a
hard failure that prints both sides, not a warning.

That strictness paid for itself on the first run. Two titles failed to match:

    "There's Something About Drums"  vs  "There's **Something About Drums** <3"
    "Perfect"                        vs  "Perfect <3"

backlog.md is prose written for a human, so names carry markdown emphasis and
hearts. The fold strips punctuation but KEEPS DIGITS — so `<3` survived as a
trailing `3` and `Perfect` folded to "perfect3", matching nothing. Stripping
`<3` before the alphanumeric fold is the fix, and the ordering of those two
steps is the whole bug.

Validated by diffing the regenerated file against the canonical one field by
field: names, order, bpmRange, styleDistribution and all 60 samplePacks are
byte-identical; only start/end/duration moved, on 13-15 of 15 tracks, plus
totalDuration_s 4809 -> 4769.1. Worst drift Perfect +50 s, then Gimme Acid +33 s
and Ghosts +32 s. venue and stage are passed through verbatim — omitting the
flags drops them, which the diff caught before anything was written.
parent bee4f240
......@@ -20,6 +20,7 @@ import json
import re
import subprocess
import sys
import unicodedata
from pathlib import Path
REPO = Path(__file__).resolve().parent.parent
......@@ -92,6 +93,62 @@ def hms(x: float) -> str:
return f"{int(x // 3600)}:{int(x % 3600) // 60:02d}:{int(x % 60):02d}"
def _fold(s: str) -> str:
"""Normalise a title hard enough that the two sources can meet.
backlog.md is prose meant for a human — names carry markdown emphasis,
hearts, and the odd stray bracket (`There's **Something About Drums** <3`,
`GHOSTS IN THE TOILETS]`). The mastering side names the same track
`There's Something About Drums`. Folding both to letters-and-digits is what
lets a measured boundary find its .tidal file.
"""
s = unicodedata.normalize("NFKD", s or "")
s = "".join(c for c in s if not unicodedata.combining(c))
# `<3` must go BEFORE the alphanumeric fold, or its digit survives and
# `Perfect <3` folds to "perfect3" — which matches nothing, silently.
s = re.sub(r"<3+", " ", s)
return re.sub(r"[^a-z0-9]", "", s.lower())
def load_segments(path: Path, rows: list[dict]) -> dict[str, dict]:
"""Accept either segments shape, keyed out to .tidal stems.
{name, start_s, end_s} the stem-keyed shape this tool was built for
{title, start, end} what the mastering side produces (segments_v3)
The second is matched to the setlist BY TITLE, never by position. Position
would silently pair the wrong boundary with the wrong track the first time
a soundcheck row or an unplayed track shifts the list — and a silent
mis-pair here ships wrong timecodes to the website and to the visualist.
So an unmatched title is a hard failure, listing both sides.
"""
raw = json.loads(path.read_text())
if not isinstance(raw, list) or not raw:
raise SystemExit(f"{path}: expected a non-empty JSON list of segments")
if "name" in raw[0] and "start_s" in raw[0]:
return {s["name"]: {"start_s": s["start_s"], "end_s": s["end_s"]}
for s in raw}
by_title = {_fold(r["name"]): Path(r["file"]).stem for r in rows}
out, unmatched = {}, []
for s in raw:
title = s.get("title") or s.get("name") or ""
stem = by_title.get(_fold(title))
if not stem:
unmatched.append(title)
continue
out[stem] = {"start_s": float(s.get("start", s.get("start_s"))),
"end_s": float(s.get("end", s.get("end_s")))}
if unmatched:
raise SystemExit(
f"{path}: {len(unmatched)} segment title(s) match no setlist entry:\n"
+ "".join(f" {t!r}\n" for t in unmatched)
+ " setlist offers:\n"
+ "".join(f" {r['name']!r}\n" for r in rows))
return out
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--gig", required=True)
......@@ -106,10 +163,7 @@ def main():
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
segs = load_segments(Path(a.segments), rows) if a.segments else {}
tracks = []
for r in rows:
......
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