Commit 82cb436e by PLN (Algolia)

feat(take-segments): the tracklist was in the gig-log all along

Splitting a livecoded set by listening to it is hard, and we have the scars for
it: orbits stay up across a transition, there is no silence at the seam, and a
gap-based detector finds the breakdowns instead of the track changes.

But gig-log has been writing the answer at 1 Hz this whole time. Every time the
active .tidal file changes in Pulsar it records

    {"k":"track", "path":"live/collab/raph/mafia_sans_serif.tidal",
     "from":"live/midi/nova/dnb/liquid/you_my_sunshine.tidal"}

which is ground truth for WHAT was playing, for free, with no DSP. The audio
never had to be interrogated at all.

THE ONE COMPLICATION is that PLN tabs around while he plays — glancing at the
next track, checking a helper, flicking back. Take95 contains
wap -> do_it_right -> wap -> do_it_right inside 90 seconds. So a segment
shorter than --min-dwell is a GLANCE and gets absorbed into whichever NEIGHBOUR
is longer (the track you were on, not the one you peeked at).

Debouncing needs a fixed-point loop, not one pass: dropping a glance can leave
two spans of the same track adjacent, merging those can leave a span that is
still under threshold. Take95's take_5_drops/do_it_right flicker takes three
rounds to settle. 29 raw events -> 11 tracks.

Window comes from the AUDIO (soxi duration + mtime of the stems), not from the
log, so a recorder still running after the transport stopped cannot stretch the
take. Titles come from tools/setlist.py, so a rename in backlog.md reaches the
ID3 tag — the SSOT rule now extends all the way to the export (#123).

Emits the --segments shape `tidal-ears master split` already accepts, so the
mastering pipeline needs no changes.

Take95 (2026-08-02, 61.2 min): 11 tracks, bombe_dj through you_my_sunshine —
which is exactly where PLN said he stopped ("didnt play desire/revolution/
hammer").
parent c9270265
#!/usr/bin/env python3
"""take-segments — derive a take's TRACKLIST from the gig-log, not from the audio.
Splitting a livecoded set by listening to it is hard and we have the scars: orbits
stay up across a transition, there is no silence at the seam, and a boundary
detector trained on gaps finds the breakdowns instead of the track changes (see
`tidal-ears master boundaries`, and the notes in armada/tide-table).
But we already record the answer. `gig-log.py` writes a `track` event every time
the active `.tidal` file changes in Pulsar:
{"t": 1785350744.459, "k": "track",
"path": "live/collab/raph/mafia_sans_serif.tidal",
"from": "live/midi/nova/dnb/liquid/you_my_sunshine.tidal"}
That is ground truth for WHAT was playing, at 1 Hz, for free, with no DSP.
THE ONE THING IT IS NOT is a tracklist, because PLN tabs around while he plays —
glancing at the next track, checking a helper, flicking back. Take95 has
`wap -> do_it_right -> wap -> do_it_right` inside 90 seconds. So every segment
shorter than --min-dwell is a GLANCE, not a performance, and is absorbed into
whatever surrounds it.
Emits the `--segments` shape `tidal-ears master split` already accepts, so the
mastering pipeline needs no changes:
[{"track": 1, "start": 7.4, "end": 233.1, "title": "Ceci n'est pas Une Bombe"}]
Titles come from tools/setlist.py (backlog.md, the SSOT) when the track is in the
set, so a rename in the backlog reaches the ID3 tags. Tracks played but not in
the set keep their file stem, prettified.
Usage:
tools/take-segments.py --take Take95 # infer window from the wavs
tools/take-segments.py --take Take95 --min-dwell 90
tools/take-segments.py --take Take95 -o segments.json
"""
from __future__ import annotations
import argparse
import datetime as dt
import glob
import json
import os
import pathlib
import re
import subprocess
import sys
ROOT = pathlib.Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "tools"))
LOG_DIR = pathlib.Path.home() / ".local/share/parvagues/gig-log"
ARDOUR = pathlib.Path.home() / "Work/Sound/Ardour"
def newest_log() -> pathlib.Path:
logs = sorted(LOG_DIR.glob("gig-*.jsonl"), key=lambda p: p.stat().st_mtime)
if not logs:
raise SystemExit(f"take-segments: no gig-log under {LOG_DIR}")
return logs[-1]
def take_window(take: str) -> tuple[float, float]:
"""(start, end) unix seconds for a take, from its own stems.
Ardour writes every stem of a take continuously and closes them together, so
mtime is end-of-record and soxi gives the length. Deriving the window from the
AUDIO rather than from the log means a log that kept running after the
transport stopped cannot stretch the take.
"""
hits = sorted(glob.glob(str(ARDOUR / "**" / f"{take}_*.wav"), recursive=True))
if not hits:
raise SystemExit(f"take-segments: no stems matching {take}_*.wav under {ARDOUR}")
ref = hits[0]
out = subprocess.run(["soxi", "-D", ref], capture_output=True, text=True)
if out.returncode != 0:
raise SystemExit(f"take-segments: soxi failed on {ref}: {out.stderr.strip()}")
dur = float(out.stdout)
end = os.path.getmtime(ref)
return end - dur, end
def raw_segments(log: pathlib.Path, start: float, end: float) -> list[dict]:
"""Contiguous [start,end) spans of one .tidal file, in take-relative seconds."""
events = []
with log.open() as fh:
for line in fh:
try:
e = json.loads(line)
except json.JSONDecodeError:
continue # a torn last line while recording
if e.get("k") == "track" and start <= e.get("t", 0) <= end:
events.append(e)
if not events:
return []
# The FIRST event's `from` tells us what was already playing when the take
# opened — without it the set would start at the second track.
segs = []
first = events[0]
if first.get("from"):
segs.append({"path": first["from"], "start": 0.0})
for e in events:
segs.append({"path": e["path"], "start": e["t"] - start})
for a, b in zip(segs, segs[1:]):
a["end"] = b["start"]
segs[-1]["end"] = end - start
return segs
def debounce(segs: list[dict], min_dwell: float) -> list[dict]:
"""Absorb glances, then merge neighbours that are the same track.
Two passes, repeated to a fixed point: dropping a glance can put two spans of
the same track next to each other, and merging those can leave a span that is
STILL under the threshold. One pass of each is not enough — Take95's
take_5_drops/do_it_right flicker needs three rounds to settle.
"""
segs = [dict(s) for s in segs]
for _ in range(50):
merged = []
for s in segs:
if merged and merged[-1]["path"] == s["path"]:
merged[-1]["end"] = s["end"]
else:
merged.append(s)
if len(merged) > 1:
short = [i for i, s in enumerate(merged)
if s["end"] - s["start"] < min_dwell]
if short:
i = min(short, key=lambda j: merged[j]["end"] - merged[j]["start"])
# Give the glance's time to whichever neighbour is longer: the
# track you were ON, not the one you peeked at.
prev = merged[i - 1] if i > 0 else None
nxt = merged[i + 1] if i + 1 < len(merged) else None
if prev and (not nxt or (prev["end"] - prev["start"])
>= (nxt["end"] - nxt["start"])):
prev["end"] = merged[i]["end"]
else:
nxt["start"] = merged[i]["start"]
merged.pop(i)
if merged == segs:
return merged
segs = merged
return segs
def titles() -> dict[str, str]:
"""stem -> the codename PLN wrote in backlog.md. The SSOT reaches the ID3 tag."""
try:
import setlist
return {e.path.stem: e.codename for e in setlist.entries()}
except Exception as exc: # never block an export on the backlog
print(f"take-segments: setlist titles unavailable ({exc})", file=sys.stderr)
return {}
def prettify(stem: str) -> str:
return re.sub(r"[_-]+", " ", stem).strip().title()
def main() -> int:
ap = argparse.ArgumentParser(
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--take", required=True, help="Ardour take prefix, e.g. Take95")
ap.add_argument("--log", type=pathlib.Path, help="gig-log jsonl (default: newest)")
ap.add_argument("--min-dwell", type=float, default=60.0,
help="seconds a track must hold to count as played (default 60)")
ap.add_argument("-o", "--output", type=pathlib.Path,
help="write JSON here (default: stdout table only)")
args = ap.parse_args()
log = args.log or newest_log()
start, end = take_window(args.take)
segs = debounce(raw_segments(log, start, end), args.min_dwell)
if not segs:
raise SystemExit(f"take-segments: no track events inside {args.take}'s window — "
f"was gig-log running?")
tmap = titles()
out = []
for i, s in enumerate(segs, 1):
stem = pathlib.Path(s["path"]).stem
out.append({"track": i, "start": round(s["start"], 3),
"end": round(s["end"], 3),
"title": tmap.get(stem) or prettify(stem),
"source": s["path"]})
print(f"{args.take}: {dt.datetime.fromtimestamp(start):%Y-%m-%d %H:%M} — "
f"{(end - start) / 60:.1f} min, {len(out)} tracks "
f"(min-dwell {args.min_dwell:.0f}s)\n")
for s in out:
d = s["end"] - s["start"]
print(f" {s['track']:>2}. {int(s['start'])//60:>3}:{int(s['start'])%60:02d}"
f" {int(d)//60:>2}:{int(d)%60:02d} {s['title']}")
if args.output:
args.output.write_text(json.dumps(out, indent=2, ensure_ascii=False) + "\n")
print(f"\nwrote {args.output}")
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