Commit f68277f2 by PLN (Algolia)

fix(preload): warm what gets played, computed from the gig records

The preload warmed a setlist FILE, and the only one was armada/setlist_opal2026
.txt -- the 16 tracks of one August gig. So the plan was fresh, correct, and
aimed at the wrong set. Measured: driving rose_rouge logged 41 'reading
soundfile as needed' lines in 180s, every one of them the rose bank, while ZERO
of the 60 warmed banks lazy-loaded.

That is check-preload.sh's documented failure one level up. There the plan was
stale because two tools disagreed about the setlist; here the plan matches its
setlist exactly and the SETLIST has drifted from what gets played -- which no
plan-vs-setlist freshness check can see, because the two agree.

gen_setlist.py computes the list instead of maintaining one, from the canonical
records in authority order: <www>/content/lives/<year>/<slug>/tracks.json, which
carries both a date and each track's exact repo-relative file; then
armada/tide-table/judge_specs/*_setlist_ear.json for gigs whose tracks.json is
not built yet -- where the cosmicfest set lives, 'THE ground truth, 14 tracks',
with rose_rouge at #5. Ear lists carry no gig date, so they are included and
flagged rather than dated by guesswork.

60 banks -> 111, 16 tracks -> 39. 51 banks were one first-play from a disk read.
rose is covered.

check-preload.sh now computes its own list rather than borrowing
set-coherence.setlist_tracks(), because the two ask different questions: a cheat
sheet wants tonight's running order, a preload wants anything that might get
played, and the cost asymmetry is total. PV_PRELOAD_SETLIST / PV_PRELOAD_MONTHS
override. Not a fourth setlist parser -- the same setlist_samples parser, fed a
wider list.

Cost of over-covering, measured: 122/124 banks OK in 5.0s (was 60/61 in 2.6s),
scsynth RSS 4.8G on a box with 62G total and 40G available.

Logs two findings from the same boot: 64 AppleDouble resource forks named
zz._*.wav in rhadamanthe_fx/divers/vocal, which fail to load AND occupy sample
indices (left alone -- the zz. prefix looks like a deliberate rename, so
deleting is the owner's call); and preload's COUNT MISMATCH banner, which
reports those 2 real load failures with the wrong diagnosis.
parent cd90dcb0
...@@ -77,3 +77,7 @@ visuals/slop/out/ ...@@ -77,3 +77,7 @@ visuals/slop/out/
# Bakeoff viewer for the clips in visuals/slop/out/ — an index for media git # Bakeoff viewer for the clips in visuals/slop/out/ — an index for media git
# does not hold, so it would point at nothing on a fresh clone. # does not hold, so it would point at nothing on a fresh clone.
visuals/slop/bakeoff/ visuals/slop/bakeoff/
# Generated by tools/gen_setlist.py from the canonical gig records — computed,
# never hand-maintained, so it must not drift in git.
armada/setlist_recent.txt
...@@ -2129,7 +2129,57 @@ scattered through this file resolve locally. ...@@ -2129,7 +2129,57 @@ scattered through this file resolve locally.
setlist tracks, 7.5 KB, gitignored (correctly, it is generated). Verified at setlist tracks, 7.5 KB, gitignored (correctly, it is generated). Verified at
the next boot: `preload: warming the set's samples…` / the next boot: `preload: warming the set's samples…` /
`=== PRELOAD: 60/61 banks OK in 2.6 s ===`. `=== PRELOAD: 60/61 banks OK in 2.6 s ===`.
- 🔴 **The preload plan is fresh and aimed at the wrong set.** Re-running the - ~~**The preload plan is fresh and aimed at the wrong set.**~~ **FIXED
2026-09-07: `tools/gen_setlist.py`.** The preload now warms what PLN actually
PLAYS, computed from the canonical gig records rather than a hand-kept file,
so it cannot drift: **60 banks → 111**, covering **39 tracks** from the last
12 months instead of 16 from one August gig. 51 banks were one first-play
away from a disk read. `rose` — the bank behind all 41 measured lazy reads —
is covered.
Sources, in authority order: `<www>/content/lives/<year>/<slug>/tracks.json`
(canonical; carries both `date` and each track's exact repo-relative `file`)
for opal-festival-2026, montreuil-algorave, raise and bunker; then
`armada/tide-table/judge_specs/*_setlist_ear.json` for gigs whose tracks.json
is not built yet — that is where the **cosmicfest-2026** list lives, "THE
ground truth, 14 tracks", with `rose_rouge` at #5. Those carry no gig date, so
the tool includes them and SAYS SO rather than inventing one.
`check-preload.sh` now computes its own list instead of borrowing
`set-coherence.setlist_tracks()`, because the two ask different questions: a
cheat sheet wants TONIGHT's running order, a preload wants anything PLN might
play, and the cost asymmetry is total (a warmed unused bank costs boot
seconds; an unwarmed one costs a disk read on the audio thread at the venue).
Override with `PV_PRELOAD_SETLIST=path` for one specific gig, or
`PV_PRELOAD_MONTHS=6`.
**Measured cost of over-covering:** 124 banks eager, `122/124 OK in 5.0 s`
(was 60/61 in 2.6 s), scsynth RSS **4.8 G** — on a box with 62 G and 40 G
available, i.e. 8%. Fine. Two unresolved track names, both harmless:
`mafia` is an alias for `mafia_sans_serif.tidal`, already in via opal, and
`Outro Dub Siren` is a live improvisation with no file.
- **64 AppleDouble resource forks are masquerading as .wav in three banks PLN
plays.** `rhadamanthe_fx` (38), `rhadamanthe_divers` (21),
`rhadamanthe_vocal` (5) — files named `zz._<original>.wav`, 4096 bytes each,
and `file` calls them exactly what they are: "AppleDouble encoded Macintosh
file". They are macOS metadata, not audio. SuperCollider fails to read them
(that is the `122/124` above and a wall of `WARNING: File reading failed for
path:` at every boot), and because they are counted as files in the bank
**they occupy sample indices** — so `s "rhadamanthe_fx:n"` is silent for 38
values of n, and `bank_file_count` overcounts. Another 129 sit under
`~/Work/Sound/Samples/baba/__MACOSX/`, which is extraction junk rather than a
playable bank.
**NOT touched**: the `zz.` prefix looks like a deliberate rename to sort them
last rather than delete them, which is a fence with a reason until PLN says
otherwise. Deleting them is his call; `find <bank> -name 'zz._*' -delete`
after a look is the whole fix, and it would also silence the boot warnings
and un-shift the indices.
- **`preload.scd`'s COUNT MISMATCH banner still cries wolf on a fresh plan**
(unchanged): `122/124 banks OK` then `=== PRELOAD COUNT MISMATCH — whitelist
is STALE, regenerate it ===` on a plan generated sixty seconds earlier. Now
we know WHY the counts differ — the 2 misses are the AppleDouble banks above,
which the generator counts and the server cannot load — so the banner is
reporting a real fact with the wrong diagnosis. It should say "N banks failed
to LOAD" and name them, not "regenerate the whitelist", which does nothing.
- 🔴 (superseded, kept for the shape) **The preload plan was fresh and aimed at
the wrong set.** Re-running the
same load after the fix still logged 41 lazy reads — and **all 41 were the same load after the fix still logged 41 lazy reads — and **all 41 were the
`rose` bank**, because `rose_rouge` is NOT in `armada/setlist_opal2026.txt`. `rose` bank**, because `rose_rouge` is NOT in `armada/setlist_opal2026.txt`.
Zero of the 60 preloaded banks lazy-loaded, and SC logged ZERO `late` Zero of the 60 preloaded banks lazy-loaded, and SC logged ZERO `late`
......
...@@ -30,14 +30,52 @@ cd "$(dirname "$0")/.." || exit 2 ...@@ -30,14 +30,52 @@ cd "$(dirname "$0")/.." || exit 2
FIX=0 FIX=0
[ "${1:-}" = "--fix" ] && FIX=1 [ "${1:-}" = "--fix" ] && FIX=1
# The canonical setlist = whatever gig-up and cheat-sheet already trust. Borrowed, not # WHAT SET SHOULD THE PRELOAD WARM? Not the same answer as everyone else's.
# re-implemented: a fourth parser for "what is the setlist" is how this bug happened. #
tracks=$(python3 -c " # This used to borrow set-coherence.setlist_tracks() — "whatever gig-up and
# cheat-sheet already trust" — on the sound principle that a fourth parser for
# "what is the setlist" is how the #120 bug happened. But those tools and this
# one are asking DIFFERENT QUESTIONS:
#
# cheat-sheet / orphan-orbits / set-coherence -> THIS GIG's running order.
# A track that is not in tonight's set has no business on the cheat sheet.
# preload -> ANYTHING PLN MIGHT PLAY.
# A bank warmed and unused costs boot seconds and RAM. A bank NOT warmed
# costs a disk read on the audio thread, mid-transition, at the venue.
# The asymmetry is total, so the preload should over-cover on purpose.
#
# Conflating them is what left this box warming the 16 Opal tracks while
# `rose_rouge` — track 5 of the cosmicfest set — lazy-loaded its whole `rose`
# bank: 41 `reading soundfile as needed` lines measured in 180 s on 2026-09-07,
# every one of them `rose`, while ZERO of the 60 warmed banks lazy-loaded. The
# plan was fresh and correct and aimed at one gig from August.
#
# So: prefer the COMPUTED list of what was actually played recently
# (tools/gen_setlist.py, derived from the canonical per-gig records, so it
# cannot go stale), and fall back to set-coherence if that cannot run. Not a
# fourth parser — the same setlist_samples parser, fed a wider list.
# Override with PV_PRELOAD_SETLIST=path to warm one specific gig instead.
if [ -n "${PV_PRELOAD_SETLIST:-}" ]; then
tracks=$(python3 -c "
import sys
from pathlib import Path
print(' '.join(ln.split('#')[0].strip() for ln in
Path('${PV_PRELOAD_SETLIST}').read_text().splitlines()
if ln.split('#')[0].strip()))") || {
echo "check-preload: could not read PV_PRELOAD_SETLIST=${PV_PRELOAD_SETLIST}" >&2; exit 2; }
else
tracks=$(python3 tools/gen_setlist.py --months "${PV_PRELOAD_MONTHS:-12}" 2>/dev/null \
| sed -e 's/#.*//' -e 's/[[:space:]]*$//' | grep -vE '^$' | tr '\n' ' ')
if [ -z "${tracks// /}" ]; then
echo "check-preload: gen_setlist.py produced nothing — falling back to set-coherence" >&2
tracks=$(python3 -c "
import importlib.util import importlib.util
spec = importlib.util.spec_from_file_location('sc','tools/set-coherence.py') spec = importlib.util.spec_from_file_location('sc','tools/set-coherence.py')
m = importlib.util.module_from_spec(spec); spec.loader.exec_module(m) m = importlib.util.module_from_spec(spec); spec.loader.exec_module(m)
print(' '.join(str(p) for p in m.setlist_tracks()))") || { print(' '.join(str(p) for p in m.setlist_tracks()))") || {
echo "check-preload: could not read the setlist from set-coherence.py" >&2; exit 2; } echo "check-preload: could not read the setlist from set-coherence.py" >&2; exit 2; }
fi
fi
# Compare BANK SETS, not file bytes: the generator stamps a header and orders banks, and # Compare BANK SETS, not file bytes: the generator stamps a header and orders banks, and
# a diff on either would cry wolf on a rebuild that changed nothing that matters. # a diff on either would cry wolf on a rebuild that changed nothing that matters.
......
#!/usr/bin/env python3
"""gen_setlist — the tracks actually played recently, from the canonical gig data.
Why this exists (2026-09-07)
----------------------------
`preload.scd` warms the banks of a SETLIST FILE. The only such file was
`armada/setlist_opal2026.txt` — the 16 tracks of Opal 2026 — so the preload was
fresh, correct, and aimed at one gig from August. Measured the same night:
driving `rose_rouge` logged **41** `reading soundfile as needed` lines, every
one of them the `rose` bank, because `rose_rouge` is not in that file. Zero of
the 60 preloaded banks lazy-loaded. The mechanism was perfect and pointed at the
wrong set.
That is `check-preload.sh`'s documented failure one level up. There, the plan was
stale because two tools disagreed about the setlist. Here the plan matches its
setlist exactly and THE SETLIST HAS DRIFTED FROM WHAT GETS PLAYED — which no
plan-vs-setlist freshness check can see, because the two agree.
So this does not maintain another hand-written list. It COMPUTES the list from
the gig data that already exists, every time, and cannot go stale:
tools/gen_setlist.py # last 12 months, to stdout
tools/gen_setlist.py --months 6
tools/gen_setlist.py --out armada/setlist_recent.txt
tools/gen_setlist.py --json # per-gig detail
SOURCES, in order of authority
------------------------------
1. `<www>/content/lives/<year>/<slug>/tracks.json` — the canonical gig record
per CLAUDE.md. Carries both a `date` and, per track, the exact repo-relative
`file`. Nothing needs guessing.
2. `armada/tide-table/judge_specs/*_setlist_ear.json` — ear-derived ground truth
for gigs whose `tracks.json` has not been built yet. Carries bare track NAMES
and no date, so those are resolved through `setlist_samples.resolve_track`
and reported separately: this tool will not invent a gig date to make a
window filter work. PLN, on the canonical sources: "Never invent gig
metadata."
WHAT IT DELIBERATELY DOES NOT DO
--------------------------------
It does not touch `preload.scd`, and it does not edit
`armada/setlist_opal2026.txt` — that file is a real historical record of one
gig, not scratch. Feed the output to the existing generator instead:
tools/gen_setlist.py --out armada/setlist_recent.txt
tools/setlist_samples.py --setlist armada/setlist_recent.txt --emit-sc > preload.scd
Track-name resolution goes through `tools/setlist_samples.py`, never a local
regex — a `.tidal` file is a program, not data (see CLAUDE.md).
"""
from __future__ import annotations
import argparse
import json
import os
import re
import sys
from datetime import date, timedelta
from pathlib import Path
REPO = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(REPO / "tools"))
# The canonical gig record lives in the www repo. Overridable because the path
# has already moved once: CLAUDE.md still said `../../Web/www` while the tree is
# actually at `Perso/www`, which cost a tool call to discover on 2026-09-07.
WWW = Path(os.environ.get(
"PV_WWW_ROOT", str(REPO.parent.parent / "Perso" / "www"))) / "content" / "lives"
JUDGE_SPECS = REPO / "armada" / "tide-table" / "judge_specs"
def _resolve(name: str) -> str | None:
"""Bare track name -> repo-relative .tidal path, via the SSOT parser."""
import setlist_samples as ss
hits = ss.resolve_track(name)
if not hits:
return None
try:
return str(hits[0].resolve().relative_to(REPO))
except ValueError:
return str(hits[0])
def from_tracks_json(cutoff: date) -> tuple[list[dict], list[dict]]:
"""(gigs in window, gigs outside window). Each: slug, date, files."""
inside, outside = [], []
if not WWW.is_dir():
print(f"gen_setlist: no gig records at {WWW} — set PV_WWW_ROOT",
file=sys.stderr)
return inside, outside
for tj in sorted(WWW.glob("*/*/tracks.json")):
try:
d = json.loads(tj.read_text())
except (OSError, json.JSONDecodeError) as e:
print(f"gen_setlist: skipping {tj}: {e}", file=sys.stderr)
continue
raw = str(d.get("date") or "")[:10]
try:
when = date.fromisoformat(raw)
except ValueError:
print(f"gen_setlist: {tj} has no usable date ({raw!r}) — skipped",
file=sys.stderr)
continue
files = [t["file"] for t in (d.get("tracks") or []) if t.get("file")]
rec = {"slug": d.get("gig") or tj.parent.name, "date": raw,
"files": files, "source": str(tj)}
(inside if when >= cutoff else outside).append(rec)
return inside, outside
def from_judge_specs() -> list[dict]:
"""Ear-derived setlists. Real track lists, NO date — reported, never dated."""
out = []
if not JUDGE_SPECS.is_dir():
return out
for js in sorted(JUDGE_SPECS.glob("*_setlist_ear.json")):
try:
d = json.loads(js.read_text())
except (OSError, json.JSONDecodeError) as e:
print(f"gen_setlist: skipping {js}: {e}", file=sys.stderr)
continue
names = [t.get("track") for t in (d.get("tracks") or []) if t.get("track")]
files, unresolved = [], []
for n in names:
p = _resolve(n)
(files.append(p) if p else unresolved.append(n))
out.append({"slug": d.get("gig") or js.stem, "date": None,
"files": files, "unresolved": unresolved, "source": str(js)})
return out
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--months", type=int, default=12,
help="window for DATED gigs (default 12)")
ap.add_argument("--out", metavar="FILE",
help="write the setlist here instead of stdout")
ap.add_argument("--no-undated", action="store_true",
help="exclude ear-derived setlists that carry no gig date")
ap.add_argument("--json", action="store_true", help="per-gig detail as JSON")
a = ap.parse_args()
cutoff = date.today() - timedelta(days=round(a.months * 30.44))
dated, older = from_tracks_json(cutoff)
undated = [] if a.no_undated else from_judge_specs()
# Order is stable and meaningful: newest dated gig first, then the undated
# ear lists. Dedupe keeps the FIRST sighting so the comment naming a track's
# gig names the most recent one that played it.
seen: dict[str, str] = {}
for g in sorted(dated, key=lambda g: g["date"], reverse=True) + undated:
for f in g["files"]:
seen.setdefault(f, f"{g['slug']}{' ' + g['date'] if g['date'] else ''}")
missing = [f for f in seen if not (REPO / f).is_file()]
if a.json:
print(json.dumps({"cutoff": cutoff.isoformat(), "months": a.months,
"dated": dated, "undated": undated,
"tracks": len(seen), "missing_files": missing},
indent=2, ensure_ascii=False))
return 0
lines = [
f"# GENERATED by tools/gen_setlist.py — do not hand-edit.",
f"# What ParVagues actually played in the last {a.months} month(s), "
f"from the canonical gig records.",
f"# Window for dated gigs: on/after {cutoff.isoformat()}.",
f"#",
]
for g in sorted(dated, key=lambda g: g["date"], reverse=True):
lines.append(f"# {g['date']} {g['slug']} ({len(g['files'])} tracks)")
for g in undated:
note = f" [!] no gig date in the record — included regardless"
lines.append(f"# ?????????? {g['slug']} ({len(g['files'])} tracks){note}")
if g.get("unresolved"):
lines.append(f"# unresolved names: "
f"{', '.join(g['unresolved'])}")
lines.append("#")
for f, why in seen.items():
lines.append(f"{f} # {why}")
text = "\n".join(lines) + "\n"
if a.out:
Path(a.out).write_text(text)
print(f"gen_setlist: {len(seen)} track(s) -> {a.out}")
else:
sys.stdout.write(text)
# Report to stderr so it never pollutes a setlist being piped.
print(f"gen_setlist: {len(dated)} dated gig(s) in window, "
f"{len(older)} older, {len(undated)} undated; {len(seen)} unique track(s)",
file=sys.stderr)
if undated:
print(f"gen_setlist: NOTE — these carry no gig date and were included "
f"anyway: {', '.join(g['slug'] for g in undated)}. Confirm they "
f"belong in a {a.months}-month window.", file=sys.stderr)
for g in undated:
if g.get("unresolved"):
print(f"gen_setlist: {g['slug']} names no .tidal for: "
f"{', '.join(g['unresolved'])}", file=sys.stderr)
if missing:
print(f"gen_setlist: {len(missing)} listed file(s) do not exist: "
f"{', '.join(missing[:5])}", file=sys.stderr)
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