Commit 30e62f45 by PLN (Algolia)

feat(postprod): the set→record pipeline becomes reusable tooling

PLN: "this becomes our tooling, often a set will be postprocessed as we did —
reusable scripts and views plz". So the per-gig finish.sh is replaced by
spec-driven tools: a new gig is now a copy of judge_specs/<gig>.json, never a
copy of a script. POSTPROD.md documents the whole flow and the trap each stage
exists to avoid.

render_release.py — split every variant, verify durations and format, and
re-render the continuous mixes. Ran clean: 14 tracks x streaming+club, all
durations within tolerance, 48000/24.

Its `continuous` stage answers PLN's other catch: "imo the master club rec will
also have desire removed". A set is released TWICE — as tracks, and as the
unbroken mix — and cutting a track had only fixed the first; both continuous
masters still played Desire in full. This matters past tidiness: the continuous
upload is the lower-risk rights path (an isolated track was flagged on
SoundCloud where the 87-minute mix passed), so the mix is the artefact most
likely to ship and it has to match the tracklist. The excision derives its kept
spans from the same segments the tracks came from, so mix and tracklist cannot
disagree. Both variants now render to 4403.65s = 73.4 min, matching the sum of
the 14 tracks exactly:
    [1.45 .. 4166.30] 69.4 min  +  [4547.57 .. 4786.37] 4.0 min

build_release_joins.py — the confirmation pass PLN asked for ("i wanna confirm
all tracks start/ends/transitions"). It deliberately does NOT use the master:
once Desire is dropped, the seam a listener hears (CRIME straight into
REVOLUTION) exists only in the rendered output, and a master-based window there
would play six minutes of a track that does not ship. Building from the split
FLACs also audits the files that actually ship, so a split error surfaces before
upload rather than after. 15 clips: the record's opening, 13 seams, and its
closing.

apply_boundaries now emits the splitter's native shape — `track` is RELEASE
position because tidal_ears.master split uses it for both filename and the
`track=N/total` tag; performance order stays as `perf_track`. Emitting
performance order would have named the last file "15 - REVOLUTION" in a
14-track album and tagged it 15/14, which stores reject.

Both UI sets pass bounds-smoke (11 assertions each). The test now derives its
data file from the page's own ?set= — hardcoding it meant it could assert
against a different set than the browser had loaded, passing or failing for
reasons unrelated to the code.
parent 6099df85
# Set → record: the post-production pipeline
Every gig gets postprocessed the same way, so this is tooling, not a one-off.
**A new gig is a copy of `judge_specs/<gig>.json` — never a copy of a script.**
That spec holds every path; the tools hold the reasoning.
Prior art this replaced: `Opal26_master/v4_provenance/finish.sh`, which hardcoded
one gig's paths, one segments file, and one variant list. It is kept only as a
historical artefact.
## The flow
```
stems + master the ear the record
│ │ │
┌────────▼─────────┐ ┌────────────▼───────────┐ ┌──────────▼─────────┐
│ build_judge_set │──judge─▶ <gig>_boundaries_ear ◀─────│ build_bounds_set │
│ → judge.html │ .json │ GROUND TRUTH │bound│ → bounds.html │
└──────────────────┘ └────────────┬───────────┘ lab └────────────────────┘
is it a keeper? │ where is the cut?
┌─────────▼──────────┐
│ apply_boundaries │ refuses to write on
│ → segments_v N │ overlap / bad duration
└─────────┬──────────┘
┌─────────▼──────────┐
│ render_release │ split · verify · continuous
└─────────┬──────────┘
┌─────────▼──────────┐
│ build_release_joins│ → bounds.html?set=…-release
└────────────────────┘ does it PLAY as a record?
```
## Commands, in order
```sh
cd armada/tide-table
S=judge_specs/opal26.json # the only thing that changes per gig
E=judge_specs/opal26_boundaries_ear.json
# 1 — go/no-go each track → judge.html?set=<gig>
python3 build_judge_set.py $S
# 2 — settle each cut → bounds.html?set=<gig>
python3 build_bounds_set.py $S --ear $E
# …make the calls, then merge the export's `decided` into $E's `verified`
# 3 — segments from the ear (asserts, then writes)
python3 apply_boundaries.py $S --ear $E --out <releaseRoot>/segments_v4.json
# 4 — split every variant, verify, and re-render the drop-free continuous mixes
python3 render_release.py $S
# 5 — confirm the record end-to-end → bounds.html?set=<gig>-release
python3 build_release_joins.py $S --tracks <releaseRoot>/tracks_v4_streaming
cd ../ui && npx vite build && python3 ../serve.py --dir dist --port 8799
node scripts/bounds-smoke.mjs # 11 assertions
```
## The traps each stage exists to avoid
**`apply_boundaries`** — the set is butt-joined, so a track's end is the next
track's *start*: editing one start silently moves two tracks. And **a track cut
from the release still owns its boundary** — dropping Desire without honouring
its start would have run the last official track straight into Desire's intro.
Release position ≠ performance number: the splitter uses `track` for both the
filename and the `track=N/total` tag, so emitting performance order would name
the last file `15 - …` in a 14-track album and tag it `15/14`, which stores
reject. Both are emitted (`track` = release, `perf_track` = performance).
**`render_release`** — never `-c copy` on FLAC: a stream copy carries the SOURCE
STREAMINFO, so every sliced track claims the whole master's length. The audio is
correct and only the header lies, which is worse than a loud failure.
The `continuous` stage exists because **a set is released twice**: as tracks and
as the unbroken mix. Cutting a track fixed only the first — the mix still played
it. It matters beyond tidiness: the continuous upload is the lower-risk rights
path (an isolated track was flagged on SoundCloud where the 87-minute mix
passed), so the mix is the artefact most likely to ship. Its kept spans are
derived from the same segments the tracks came from, so mix and tracklist cannot
disagree.
**`build_release_joins`** — the confirmation pass cannot run on the master. Once
a track is dropped, the seam a listener hears (CRIME straight into REVOLUTION)
**exists only in the rendered output**; a master-based window there plays six
minutes of a track that does not ship. Building the clips from the split FLACs
also audits the files that actually ship, so a split error surfaces here rather
than after upload.
**The UI** is one page for both jobs: `bounds.html?set=<gig>` walks master
boundaries, `?set=<gig>-release` walks the finished record. Same component, same
smoke test — which derives its data file from the page's own `?set=`, because a
hardcoded path let the test assert against a different set than the browser had
loaded.
## Ground truth rules
- `judge_specs/<gig>_boundaries_ear.json` is **ground truth**. Detector output
never overwrites it; machine proposals live in a separate `candidates` block.
A *measurement* may fill a gap (e.g. a sample-exact silence trim) if it records
its method.
- Automated cut detection is a **dead end** for crossfaded sets — see
`cut_lens3.py`. Where two tracks share their sounds, no timbral lens can see
the handover. Offer candidates, let the ear pick, never average.
- Gig metadata (venue, set title, lineup) is canonical in
`../../Web/www/next/content/lives/{year}/{slug}.md` + `tracks.json`. Mastering
references takes by **Take-ID + date** only. Never invent it here.
......@@ -71,8 +71,12 @@ def main() -> int:
n += 1
src = next(s for s in segs if s["track"] == t)
rows.append({
"n": n, # rule 3: release position
"track": t, # ...and performance order, the join key
# `track` IS the release position, because tidal_ears.master split
# uses it for both the filename and the `track=N/total` tag. Emitting
# performance order here would name the last file "15 - ..." in a
# 14-track album and tag it 15/14, which some stores reject outright.
"track": n,
"perf_track": t, # performance order — the join key
"title": src["title"],
"start": round(starts[t], 3),
"end": round(end, 3),
......@@ -85,12 +89,12 @@ def main() -> int:
problems = []
for r in rows:
if r["duration"] <= 0:
problems.append(f"#{r['track']} {r['title']}: non-positive duration {r['duration']}")
problems.append(f"#{r['perf_track']} {r['title']}: non-positive duration {r['duration']}")
if r["start"] < 0 or r["end"] > mdur + 0.01:
problems.append(f"#{r['track']}: [{r['start']}, {r['end']}] outside master (0, {mdur:.2f})")
problems.append(f"#{r['perf_track']}: [{r['start']}, {r['end']}] outside master (0, {mdur:.2f})")
for x, y in zip(rows, rows[1:]):
if y["start"] < x["end"] - 0.001:
problems.append(f"overlap: #{x['track']} ends {x['end']} but #{y['track']} starts {y['start']}")
problems.append(f"overlap: #{x['perf_track']} ends {x['end']} but #{y['perf_track']} starts {y['start']}")
for t, v in verified.items():
if v.get("start") is None or t in cut:
continue
......@@ -102,25 +106,30 @@ def main() -> int:
print(" ✗ " + p)
return 1
doc = {
# The consumed file is a PLAIN LIST because that is what the splitter reads;
# a wrapper dict would have to be unwrapped somewhere, and the file that gets
# validated must be the file that gets consumed. Provenance goes beside it.
Path(a.out).write_text(json.dumps(rows, indent=1) + "\n")
meta = Path(a.out).with_suffix(".meta.json")
meta.write_text(json.dumps({
"_comment": [
"Generated by apply_boundaries.py from the ear-verified boundaries.",
"Do not hand-edit: edit judge_specs/<gig>_boundaries_ear.json and re-run.",
"`track` is performance order (the join key everywhere else);",
"`n` is position on the release, which differs when a track is cut.",
"Provenance for the sibling segments list. Do not hand-edit either:",
"edit judge_specs/<gig>_boundaries_ear.json and re-run apply_boundaries.py.",
"In the list, `track` is RELEASE position (what the splitter tags) and",
"`perf_track` is performance order (the join key for stems/scores).",
],
"gig": spec["gig"],
"master": spec["master"],
"master_duration": round(mdur, 3),
"cut_from_release": sorted(cut),
"segments": rows,
}
Path(a.out).write_text(json.dumps(doc, indent=1) + "\n")
"tracks": len(rows),
"kept_s": round(sum(r["duration"] for r in rows), 3),
}, indent=1) + "\n")
total = sum(r["duration"] for r in rows)
print(f"{'n':>2} {'#':>3} {'title':<32} {'start':>9} {'end':>9} {'dur':>8} src")
print(f"{'rel':>2} {'#':>3} {'title':<32} {'start':>9} {'end':>9} {'dur':>8} src")
for r in rows:
print(f"{r['n']:>2} {r['track']:>3} {r['title'][:32]:<32} "
print(f"{r['track']:>2} {r['perf_track']:>3} {r['title'][:32]:<32} "
f"{r['start']:>9.2f} {r['end']:>9.2f} {r['duration']:>8.2f} {r['source']}")
print(f"\n{len(rows)} tracks, {total/60:.1f} min kept of {mdur/60:.1f} min master "
f"({(mdur-total)/60:.1f} min dropped)")
......
#!/usr/bin/env python3
"""build_release_joins — audition the record as a LISTENER hears it, before release.
The boundary lab answers "where should this cut be" against the master. This
answers the different question PLN asked last: *"i wanna confirm all tracks
start/ends/transitions"* — and it cannot be done on the master, for one concrete
reason:
Desire is cut from the release. In the master, Vague de CRIME ends at
4166.3 and Desire runs until 4547.57. A ±45 s master window around that
edge plays six minutes of a track that does not ship. The join a listener
actually hears — CRIME's tail straight into REVOLUTION's head — exists
ONLY in the rendered output.
So this builds its clips from the split FLACs, not the master: for every
consecutive pair it concatenates the tail of one and the head of the next, which
is exactly the seam. That has a second benefit worth as much as the first — it
audits the files that ship, so a split error (wrong offset, truncated encode,
a STREAMINFO lie) shows up here rather than after upload.
Two edge entries bracket the record: the head of track 1 (does it open right —
the 1.45 s silence trim) and the tail of the last track (does it end, or stop).
Emits the same JSON shape as `build_bounds_set.py`, so the SAME UI renders it:
python3 build_release_joins.py judge_specs/opal26.json \\
--tracks /home/pln/Work/Sound/Prod/Opal26_master/tracks_v4_streaming
-> ui/public/bounds-<gig>-release.json (open with ?set=<gig>-release)
"""
from __future__ import annotations
import argparse
import json
import subprocess
import sys
import tempfile
from pathlib import Path
HERE = Path(__file__).parent
sys.path.insert(0, str(HERE))
from build_judge_set import clean_title, ffprobe_dur, peaks # noqa: E402
TAIL = 20.0 # seconds of the outgoing track
HEAD = 20.0 # seconds of the incoming track
PEAKS_N = 900
def cut(src: Path, out: Path, ss: float, dur: float) -> bool:
r = subprocess.run(
["ffmpeg", "-v", "error", "-y", "-ss", f"{ss:.4f}", "-t", f"{dur:.4f}",
"-i", str(src), "-ac", "2", "-c:a", "flac", "-compression_level", "3",
str(out)], capture_output=True)
return r.returncode == 0
def concat(parts: list[Path], out: Path) -> bool:
"""Join the pieces with ffmpeg's concat DEMUXER, not the filter.
The demuxer is sample-exact for identical formats; re-filtering would
resample and could hide the very click we are looking for.
"""
with tempfile.NamedTemporaryFile("w", suffix=".txt", delete=False) as f:
for p in parts:
f.write(f"file '{p}'\n")
lst = f.name
r = subprocess.run(
["ffmpeg", "-v", "error", "-y", "-f", "concat", "-safe", "0", "-i", lst,
"-c:a", "flac", "-compression_level", "3", str(out)], capture_output=True)
Path(lst).unlink(missing_ok=True)
return r.returncode == 0
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("spec")
ap.add_argument("--tracks", required=True)
ap.add_argument("--tail", type=float, default=TAIL)
ap.add_argument("--head", type=float, default=HEAD)
ap.add_argument("--out")
a = ap.parse_args()
spec = json.loads(Path(a.spec).read_text())
tdir = Path(a.tracks)
files = sorted(p for p in tdir.glob("*.flac"))
if not files:
print(f"no tracks in {tdir}")
return 1
durs = [ffprobe_dur(f) for f in files]
joins_dir = tdir.parent / "joins_v4"
joins_dir.mkdir(parents=True, exist_ok=True)
def title_of(p: Path) -> str:
# "07 - Piment Bresilien.flac" -> "Piment Bresilien"
return clean_title(p.stem.split(" - ", 1)[-1])
rows = []
# --- opening: does the record START right (the 1.45 s trim)
op = joins_dir / "00-open.flac"
if not cut(files[0], op, 0.0, min(a.head, durs[0])):
print("! failed to render the opening")
rows.append({
"track": 0, "title": f"OPEN · {title_of(files[0])}", "fromTitle": "silence",
"nominal": 0.0, "clipStart": 0.0, "clipEnd": ffprobe_dur(op),
"url": f"/audio/joins/{op.name}", "peaks": [round(x, 4) for x in peaks(op, PEAKS_N)],
"marks": [{"key": "join", "label": "record starts", "t": 0.0}],
"settled": False, "noCandidate": False,
"note": "First sound of the album. The head was trimmed by 1.45 s of digital silence.",
})
# --- every seam, as the listener hears it
for i in range(len(files) - 1):
outp = joins_dir / f"{i + 1:02d}-join.flac"
tail_len = min(a.tail, durs[i])
head_len = min(a.head, durs[i + 1])
with tempfile.TemporaryDirectory() as td:
pa, pb = Path(td) / "a.flac", Path(td) / "b.flac"
okA = cut(files[i], pa, max(0.0, durs[i] - tail_len), tail_len)
okB = cut(files[i + 1], pb, 0.0, head_len)
if not (okA and okB and concat([pa, pb], outp)):
print(f"! failed join {i + 1}")
continue
rows.append({
"track": i + 1,
"title": f"{title_of(files[i])} → {title_of(files[i + 1])}",
"fromTitle": title_of(files[i]),
"nominal": tail_len, # the seam, in clip seconds
"clipStart": 0.0, "clipEnd": ffprobe_dur(outp),
"url": f"/audio/joins/{outp.name}",
"peaks": [round(x, 4) for x in peaks(outp, PEAKS_N)],
"marks": [{"key": "join", "label": "seam", "t": tail_len}],
"settled": False, "noCandidate": False,
"note": f"end of #{i + 1} into start of #{i + 2}, as it ships "
f"({tail_len:.0f}s + {head_len:.0f}s)",
})
# --- closing: does the record END, or merely stop
cl = joins_dir / "99-close.flac"
tail_len = min(a.tail + 10, durs[-1])
if cut(files[-1], cl, max(0.0, durs[-1] - tail_len), tail_len):
rows.append({
"track": 99, "title": f"CLOSE · {title_of(files[-1])}",
"fromTitle": title_of(files[-1]), "nominal": tail_len,
"clipStart": 0.0, "clipEnd": ffprobe_dur(cl),
"url": f"/audio/joins/{cl.name}",
"peaks": [round(x, 4) for x in peaks(cl, PEAKS_N)],
"marks": [{"key": "join", "label": "record ends", "t": tail_len}],
"settled": False, "noCandidate": False,
"note": "Last sound of the album.",
})
doc = {"gig": f"{spec['gig']}-release", "title": f"{spec.get('title','')} — release check",
"generated": True, "pad": a.tail, "boundaries": rows}
dest = Path(a.out or HERE.parent / "ui" / "public" / f"bounds-{spec['gig']}-release.json")
dest.write_text(json.dumps(doc, indent=1))
print(f"✓ {dest} ({len(rows)} clips: open + {len(files)-1} seams + close)")
print(f" clips: {joins_dir}")
return 0
if __name__ == "__main__":
sys.exit(main())
{
"_comment": [
"Judge-set spec for OPAL 2026 'Sunset Forest'. Everything the builder needs,",
"as data so the next gig is a copy of this file, not a code change.",
"as data \u2014 so the next gig is a copy of this file, not a code change.",
"`keeps` is the master's edit as EXPLICIT NUMBERS (stem time). Its sum must",
"equal the master duration (4786.370000 s) and the builder asserts it; a",
"content-detected edit would slide every boundary. See tidal-ears/MIXING.md."
"content-detected edit would slide every boundary. See tidal-ears/MIXING.md.",
"`variants` + `releaseRoot` + `releaseTag` drive render_release.py, which replaced the gig-specific finish.sh: the post-prod pipeline is the same for every set, so a new gig is a copy of this file, never a copy of a script."
],
"gig": "opal-festival-2026",
"title": "Sunset Forest",
......@@ -31,7 +32,17 @@
]
],
"binS": 2.0,
"calibration": "clip time = master time; master stem via keeps [[6.0,4540.0],[4569.9,4822.27]] (sums to 4786.370 s = the master to the millisecond)",
"calibration": "clip time = master time; master \u2192 stem via keeps [[6.0,4540.0],[4569.9,4822.27]] (sums to 4786.370 s = the master to the millisecond)",
"note": "v4 = the premix pass that trimmed d4, which owned 76-91% of the low-frequency bed floor. Per-track loudness deliberately keeps the live arc: tracks range roughly -12.5 to -18.2 LUFS rather than being flattened to a single number.",
"segments": "/home/pln/Work/Sound/Prod/Opal26_master/segments_v3.json"
"segments": "/home/pln/Work/Sound/Prod/Opal26_master/segments_v3.json",
"variants": {
"streaming": "/home/pln/Work/Sound/Prod/Opal26_master/Opal26_SunsetForest_v4_streaming.flac",
"club": "/home/pln/Work/Sound/Prod/Opal26_master/Opal26_SunsetForest_v4_club.flac"
},
"releaseRoot": "/home/pln/Work/Sound/Prod/Opal26_master",
"releaseTag": "v4",
"earBoundaries": "/home/pln/Work/Sound/Tidal/armada/tide-table/judge_specs/opal26_boundaries_ear.json",
"segmentsRelease": "/home/pln/Work/Sound/Prod/Opal26_master/segments_v4.json",
"artist": "ParVagues",
"album": "Sunset Forest"
}
#!/usr/bin/env python3
"""render_release — take a mastered set to a releasable record. Spec-driven.
Replaces the per-gig `finish.sh`. PLN: *"this becomes our tooling, often a set
will be postprocessed as we did — reusable scripts and views plz"*. Every path
comes from the judge spec, so a new gig is a copy of `judge_specs/<gig>.json`,
never a copy of a script.
Three stages, each skippable:
split slice every variant (streaming, club, …) into tracks from the
ear-verified segments, tagging artist/album/date
verify every rendered duration against the segments, plus format — fails
loudly rather than shipping a wrong album a second time
continuous re-render the FULL-LENGTH mix with the dropped tracks excised
## Why `continuous` exists
A set is released twice: as tracks, and as the unbroken mix. Cutting a track
from the release only fixed the first one — the continuous master still played
it. PLN caught this: *"imo the master club rec will also have desire removed"*.
It matters beyond tidiness: the continuous upload is the LOWER-RISK rights path
(an isolated track flagged on SoundCloud where the 87-minute mix passed), so the
mix is the artefact most likely to ship, and it must match the tracklist.
The excision is derived from the same segments the tracks came from — the kept
spans, merged where they touch — so the mix and the tracklist cannot disagree.
Rendered with the concat demuxer over losslessly-cut pieces: sample-exact, and
it never resamples, so a seam that clicks is a real seam and not an artefact of
this tool.
python3 render_release.py judge_specs/opal26.json # all stages
python3 render_release.py judge_specs/opal26.json --only continuous
"""
from __future__ import annotations
import argparse
import json
import subprocess
import sys
import tempfile
from pathlib import Path
HERE = Path(__file__).parent
sys.path.insert(0, str(HERE))
from build_judge_set import ffprobe_dur # noqa: E402
TOL = 0.05 # seconds a rendered track may differ from its segment
def sh(cmd, **kw):
return subprocess.run(cmd, capture_output=True, text=True, **kw)
def sanitize(name: str) -> str:
return "".join("_" if c in '/\\:*?"<>|' else c for c in name).strip().strip(".")
def kept_spans(segs: list[dict]) -> list[tuple[float, float]]:
"""Merge the segments into contiguous keep ranges.
Adjacent tracks are butt-joined, so the merge collapses them; a gap only
survives where a track was CUT from the release. That gap is the whole
point — it is what gets excised from the continuous mix.
"""
spans: list[list[float]] = []
for s in sorted(segs, key=lambda x: x["start"]):
if spans and s["start"] - spans[-1][1] < 0.001:
spans[-1][1] = s["end"]
else:
spans.append([s["start"], s["end"]])
return [(a, b) for a, b in spans]
def stage_split(spec, segs, seg_path: Path) -> int:
bad = 0
for variant, master in spec["variants"].items():
m = Path(master)
if not m.exists():
print(f" MISSING {m}")
return 1
out = Path(spec["releaseRoot"]) / f"tracks_{spec['releaseTag']}_{variant}"
print(f"=== split {variant}: {ffprobe_dur(m):.3f} s -> {out.name}")
if out.exists():
for f in out.glob("*.flac"):
f.unlink()
out.mkdir(parents=True, exist_ok=True)
for s in segs:
dest = out / f"{s['track']:02d} - {sanitize(s['title'])}.flac"
cmd = ["ffmpeg", "-v", "error", "-y", "-ss", str(s["start"]),
"-to", str(s["end"]), "-i", str(m),
"-metadata", f"title={s['title']}",
"-metadata", f"track={s['track']}/{len(segs)}"]
for k, tag in (("artist", "artist"), ("album", "album"), ("date", "date")):
if spec.get(k):
cmd += ["-metadata", f"{tag}={spec[k]}"]
# Re-encode, never `-c copy`: a FLAC stream copy carries the SOURCE
# STREAMINFO, so every track claims the whole master's length. The
# audio is right and only the header lies, which is worse than a
# loud failure — players show wrong durations and stores reject it.
cmd += ["-c:a", "flac", "-compression_level", "5", str(dest)]
if sh(cmd).returncode != 0:
print(f" FAIL {dest.name}")
bad += 1
print(f" {len(list(out.glob('*.flac')))} tracks")
return bad
def stage_verify(spec, segs) -> int:
bad = 0
for variant in spec["variants"]:
d = Path(spec["releaseRoot"]) / f"tracks_{spec['releaseTag']}_{variant}"
files = sorted(d.glob("*.flac"))
if len(files) != len(segs):
print(f" {variant}: {len(files)} files, expected {len(segs)} MISMATCH")
bad += 1
continue
for s, f in zip(segs, files):
got = ffprobe_dur(f)
want = s["end"] - s["start"]
if abs(got - want) >= TOL:
print(f" {variant} #{s['track']:>2} {s['title'][:26]:<26} "
f"want {want:>8.2f} got {got:>8.2f} MISMATCH")
bad += 1
fmt = sh(["ffprobe", "-v", "error", "-show_entries",
"stream=sample_rate,bits_per_raw_sample", "-of", "csv=p=0",
str(files[0])]).stdout.strip()
print(f" {variant}: {len(files)} tracks OK, format {fmt}")
return bad
def stage_continuous(spec, segs) -> int:
spans = kept_spans(segs)
total = sum(b - a for a, b in spans)
print(f"=== continuous: {len(spans)} kept span(s), {total/60:.1f} min")
for a, b in spans:
print(f" [{a:>9.2f} .. {b:>9.2f}] {(b-a)/60:5.1f} min")
if len(spans) == 1:
print(" (nothing excised — the mix already matches the tracklist)")
bad = 0
for variant, master in spec["variants"].items():
m = Path(master)
dest = m.with_name(m.stem + "_nodrop" + m.suffix)
with tempfile.TemporaryDirectory() as td:
parts = []
for i, (a, b) in enumerate(spans):
p = Path(td) / f"{i:02d}.flac"
r = sh(["ffmpeg", "-v", "error", "-y", "-ss", f"{a:.6f}",
"-to", f"{b:.6f}", "-i", str(m), "-c:a", "flac",
"-compression_level", "3", str(p)])
if r.returncode != 0:
print(f" FAIL cutting {variant} span {i}: {r.stderr[:150]}")
bad += 1
parts.append(p)
lst = Path(td) / "list.txt"
lst.write_text("".join(f"file '{p}'\n" for p in parts))
r = sh(["ffmpeg", "-v", "error", "-y", "-f", "concat", "-safe", "0",
"-i", str(lst), "-c:a", "flac", "-compression_level", "5",
str(dest)])
if r.returncode != 0:
print(f" FAIL {dest.name}: {r.stderr[:200]}")
bad += 1
continue
got = ffprobe_dur(dest)
ok = abs(got - total) < TOL
bad += not ok
print(f" {variant}: {dest.name} {got:.2f}s "
f"({'OK' if ok else f'MISMATCH, wanted {total:.2f}'})")
return bad
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("spec")
ap.add_argument("--segments", help="default: spec.segmentsRelease")
ap.add_argument("--only", choices=["split", "verify", "continuous"], action="append")
a = ap.parse_args()
spec = json.loads(Path(a.spec).read_text())
seg_path = Path(a.segments or spec["segmentsRelease"])
segs = json.loads(seg_path.read_text())
if isinstance(segs, dict):
segs = segs["segments"]
stages = a.only or ["split", "verify", "continuous"]
print(f"{spec['gig']} · {spec.get('album','')} · {len(segs)} tracks "
f"· variants: {', '.join(spec['variants'])}")
print(f"segments: {seg_path}\n")
bad = 0
if "split" in stages:
bad += stage_split(spec, segs, seg_path)
if "verify" in stages:
print("=== verify")
bad += stage_verify(spec, segs)
if "continuous" in stages:
bad += stage_continuous(spec, segs)
print("\n" + ("ALL OK" if not bad else f"{bad} PROBLEM(S)"))
return 1 if bad else 0
if __name__ == "__main__":
sys.exit(main())
......@@ -14,6 +14,7 @@
"full": "/home/pln/Work/Sound/Prod/Opal26_master/tracks_v4_streaming",
"full-club": "/home/pln/Work/Sound/Prod/Opal26_master/tracks_v4_club",
"heads": "/home/pln/Work/Sound/Prod/Opal26_master/heads_v4",
"joins": "/home/pln/Work/Sound/Prod/Opal26_master/joins_v4",
"": "/home/pln/Work/Sound/Tidal/armada/tide-table/punkachien"
}
}
This source diff could not be displayed because it is too large. You can view the blob instead.
......@@ -22,7 +22,11 @@ import { existsSync, readFileSync } from 'node:fs'
// NB: do not name this `URL` — it shadows the global used just below
const PAGE = process.env.BOUNDS_URL ?? 'http://localhost:5199/bounds.html'
const DATA = new URL('../public/bounds-opal-festival-2026.json', import.meta.url)
// Derive the data file from the page's own ?set= — hardcoding it meant the test
// could assert against a DIFFERENT set than the browser had loaded, which passes
// or fails for reasons unrelated to the code.
const SET = new global.URL(PAGE, 'http://x').searchParams.get('set') ?? 'opal-festival-2026'
const DATA = new global.URL(`../public/bounds-${SET}.json`, import.meta.url)
const fail = (m) => { console.error(`✗ ${m}`); process.exitCode = 1 }
const ok = (m) => console.log(`✓ ${m}`)
......
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