Commit 779cf17d by PLN (Algolia)

merge(rig): fold master into rig-streamline — spawn guard beside the close latch

A peer session consolidated branches onto master (6678acd1) and landed work in the
same two files this branch was changing, so it flagged the collision early. Merged
now, while both sides' intent is still known, rather than later from the diff alone.

Both conflicts were purely additive — each side had inserted different code at the
same seam — so both sides are kept:

- launchers.py: my spawn guard (pidfile + identity re-check + per-decision logging,
  3c00a9cb) sits alongside their `_clear_latch()`. These compose correctly and are
  about opposite problems: mine refuses to start a SECOND instance, theirs clears
  the "the human closed this" latch when a start is deliberate. Their
  `_clear_latch(spec)` call inside `launch()` merged cleanly into my rewritten
  version of that function.
- midiviz.py: their close-latch block (`latch_close_path`, `_latch_close`) plus MY
  `build_widget` signature, which carries the `pinned_port`/`watch` parameters the
  replug-resilience work added (e9d16314). Their side's copy of that def line was
  the older two-arg form and was dropped in favour of mine.

Verified in an isolated worktree, not in the shared checkout — that tree holds the
peer's uncommitted edits to check-mix.py and gig-log.py, and merging there would
have demanded I stash another session's in-flight work. Two untracked files of
theirs were moved aside to let git proceed and restored byte-identical afterwards.

  pytest tools/tests tools/bridge/tests armada/tide-table -> 476 passed
  midiviz --selftest -> PASS (parsed=68 ingested=253 paints=172, 163 colours)

Both of the features that had to survive this merge do: the surviving grep shows
the spawn guard, the pending sweep, the latch clear, the close X, the pin toggle,
the reconnect timer and the hardware-vs-phantom port discrimination all present.
parents 5468507b 6678acd1
---
log: 040
title: "The wrong cut wore the cover art"
date: 2026-09-05
task: "A3 (SoundCloud uploader) · catalog coherence · check-mix session resolution"
tags: [soundcloud, release, opal2026, tooling, catalog, forensics, git]
shareable: true
---
## Cap (what & why)
PLN opened the day with a complaint and a question: *"on soundcloud i see our
private cuts of OPAL and they are badly cut begin/end unaligned we need to
reupload and save their images tho wheres out catalog at?"*
Two jobs, then. Find out why the OPAL-26 release sounds wrong on SoundCloud, and
report where the catalog actually stands. The board had a theory for the first
one already — a note saying `/take-five-drops` had uploaded "24 s short" while
every other track "matched to the second". That theory was wrong in both halves,
and finding out how it was wrong is the whole log.
## Manœuvre (how)
**First, refuse to re-render.** The obvious move on "the cuts are bad" is to
re-cut. Before touching `apply_boundaries.py` I measured what was on disk:
every one of the 14 `tracks_v4_streaming/` FLACs matches `segments_v4.json` to
the millisecond, CRIME's `pad_end_s: 2.0` rest and REVOLUTION's 5 s reverb tail
included. The upload plan's titles, files and durations line up exactly. The ear
file's mtime (19:33) postdating the renders (19:18) turned out to be the
`release_signoff` record being written, not a late boundary edit — I checked that
specifically because it looked like a smoking gun and would have justified a
re-render. **The local pipeline was clean. The fault had to be downstream.**
**Then read the account instead of reasoning about it.** `master sc pull --out
… --artwork` — read-only, one pass, and the existing token was still live so no
login was needed. 132 tracks, 10 playlists, **101 artwork originals** to
`~/Work/Sound/Prod/sc_pull_2026-09-05/`. That single command was the artwork
rescue PLN asked for, the diagnosis, *and* the state recovery: no
`sc_state.json` had ever been written, so the account itself was the only record
of what had landed.
**Then build the lens, and get it wrong three times.** `sc_generation_lens.py`
compares every upload's duration against every local render generation.
Three defects in my own tool, each left in as a guard:
1. **Filenames are not authoritative.** A title of `Perfect <3` uploads to
`/perfect-3`, so a filename-derived slug reported two live tracks as NOT
UPLOADED. Titles now come from the release plan — what the uploader used.
2. **Streaming and club masters share every duration**, so offering both as
rival candidates marked all 14 AMBIGUOUS. Identical generations collapse into
one; they are one set of boundaries wearing two masters.
3. **A duration collision is not provenance.** `/au-revoir-lord-toyota` (2020)
and `/saria-demo` (2025) matched OPAL cuts to 50 ms by pure chance on a
132-track account. Strays are now hard-gated on a date floor — nothing older
than the oldest render on disk can be a cut of it — and the discards are
printed, not silently dropped.
**Then the second pass, which is the one that earned its keep.** Roll call —
checking each *expected* permalink — structurally cannot see a stale cut hiding
under a different slug. So the lens also scans the whole account by duration.
## Prise (findings / artifacts)
**SoundCloud accumulates generations.** It will not replace the audio behind a
permalink (paid feature), and our uploader is idempotent by permalink, so every
re-upload under a fresh slug *adds* a copy. Nothing ever replaces anything. The
entire Aug-12 first cut of OPAL-26 was still up — all fifteen tracks — beside
the six correct v4 ones:
| | count | |
|---|---|---|
| correct v4 | 6 | `/wap` `/there-s-something-about-drums-3` `/you-my-sunshine` `/piment-bresilien` `/eh-ouais-je-funk` `/ghosts-in-the-toilets` |
| stale | **14** | `/take-five-drops` + 13 under junk slugs: `/opal26-wap`, `/12-mafia-12`, `/ceci-nest-pas-une-bombe`, `/you-my-sunshine-5`, `/desire`, `/15-la-revolution-sera-samplee-15` … |
| never uploaded | 7 | ceci-n-est-pas-une-bombe, am-i-doing-it-right, perfect-3, gimme-acid, mafia, vague-de-crime, revolution |
**`/take-five-drops` was never "24 s short". It was 417.000 s — to the
millisecond, the Aug-12 cut.** Not truncation. A different generation.
**Duration is far sharper than anyone had assumed.** SoundCloud's
`full_duration` reproduces the source *to the millisecond* (477.600 s uploaded →
477.600 s reported). So the working tolerance is **0.05 s**, not the seconds a
transcode seems to deserve. At ±1.5 s, the Aug-12 and v4 cuts of `/wap`
(361.0 vs 361.6) and `/perfect` (315.0 vs 314.6) are the same track — which is
precisely how six stale uploads passed a duration check in August.
**Built:** `armada/tide-table/sc_generation_lens.py` (`fe8ec16`),
`build_delete_manifest.py` (`bd3a680`), and `master sc delete --manifest` in
tidal-ears (`ab3285b`) — the "explicit, separate call" `scwrite.py`'s refusal
message had been promising since August. Dry run: **14 deletable, 0 refused**,
audited independently.
**Catalog** (`f1fa484`): `catalog.generated.json` went 73 → **81 tracks**,
23 → **25 gigs**, now identical to the view with no silent drops, OPAL and
CosmicFest both present. Root cause: `AS_OF = "2026-06-06"` was a hardcoded
literal in `build_catalog_view.py`, so every rebuild re-stamped the same frozen
date. Coverage gap that remains: 39 gig pages in www vs 25 gigs catalogued.
**check-mix** (`27f2351`): the argv-only session resolution is fixed by
extracting gig-log's 3-rung ladder into a shared `tools/ardour_session.py`.
169 gig-log tests pass unchanged. It then failed on its first real run —
**Tidal 07/08/10/12 at -inf dB in the saved session**, the third recurrence.
## Sel (the shareable learning)
**The wrong cut was the one wearing the cover art.** Eight of the fourteen stale
uploads had artwork. Not one of the six correct v4 tracks did. So browsing his
own account, the superseded audio looked *finished* and the correct audio looked
like drafts. PLN's ear was reading the artwork, and the artwork was lying.
Artwork is a completeness signal, and completeness signals attach to whatever
someone last fussed over — which is usually the *older* thing, because it had
more time. When you ask "is this release done?", read the audio, never the
chrome around it.
**Two lessons about lenses, both old friends.** The board's "matches to the
second" was never a verdict, it was a tolerance — the instrument was the bug,
again. And roll call cannot find a stray, because a check that only looks where
it expects a thing can never discover a thing somewhere else. Absence needs
proof.
**And one about git.** Four of my commits landed on a concurrent session's
branch, because this is a shared checkout and another session moved HEAD under
me between two commits. I did not notice for an hour, because **`git push origin
master` from a non-master HEAD is a silent no-op** — it pushes the local
`master` ref, which was already current, prints nothing, exits 0. The "PUSHED"
in my log was something I had written myself. Recovered by cherry-picking onto
master through a temporary worktree, touching neither their branch nor the live
tree. The global rule already said "use a worktree in shared repos"; I had
applied it to delegated agents and quietly exempted the orchestrator, which is
the session that commits most.
## Hameçon (hook)
*"Your ears were right and the audio was fine."* A release that sounded broken,
where every rendered file was correct to the millisecond — because the platform
had quietly kept every draft, and the drafts were the ones with the covers on.
Runner-up, for the git audience: *the commit that reported success for pushing
nothing.*
## Sillage (what it unlocks)
- The delete manifest is generated and dry-run clean; `--go` is PLN's call. Once
the 14 stale uploads go, the 7 missing v4 tracks can be uploaded and the
covers re-attached, which finally fills OPAL's empty `audio:` field on the
website and the release links behind it in the catalog.
- `sc_generation_lens.py` is reusable and CosmicFest will need it — that release
went up under a suffix policy while OPAL's uploads are unsuffixed.
- The check-mix fix makes PLN's parked two-minute fader check *runnable*, and it
went from unrunnable straight to failing. Assembling checks into one caller
revalidates them under fresh conditions.
- The unmerged branch `claude/rig-autoconnect-regression` holds one commit —
*"the two reconcilers that lived only in $HOME — five orbits reached no Ardour
track"*. Five orbits reaching no track, and four orbits at -inf, are close
enough to be worth checking whether they are the same wound.
...@@ -9,7 +9,7 @@ ...@@ -9,7 +9,7 @@
"slug": "2023/mephisteuf", "slug": "2023/mephisteuf",
"confirm": false, "confirm": false,
"header": "## Live @MephisTeuf 😈‍💻😈", "header": "## Live @MephisTeuf 😈‍💻😈",
"anchor_line": 710, "anchor_line": 722,
"n_tracks": 6, "n_tracks": 6,
"n_resolved": 5, "n_resolved": 5,
"suspect_bleed": false, "suspect_bleed": false,
...@@ -62,7 +62,7 @@ ...@@ -62,7 +62,7 @@
"slug": "2024/opal-festival-2024", "slug": "2024/opal-festival-2024",
"confirm": false, "confirm": false,
"header": "## Opal <3🦉", "header": "## Opal <3🦉",
"anchor_line": 1031, "anchor_line": 1043,
"n_tracks": 14, "n_tracks": 14,
"n_resolved": 14, "n_resolved": 14,
"suspect_bleed": false, "suspect_bleed": false,
...@@ -171,7 +171,7 @@ ...@@ -171,7 +171,7 @@
"slug": "2024/toplap-solstice-2024", "slug": "2024/toplap-solstice-2024",
"confirm": false, "confirm": false,
"header": "### TopLap Solstice Stream 2024", "header": "### TopLap Solstice Stream 2024",
"anchor_line": 1169, "anchor_line": 1181,
"n_tracks": 4, "n_tracks": 4,
"n_resolved": 3, "n_resolved": 3,
"suspect_bleed": false, "suspect_bleed": false,
...@@ -210,7 +210,7 @@ ...@@ -210,7 +210,7 @@
"slug": "2024/38c3-house-of-tea", "slug": "2024/38c3-house-of-tea",
"confirm": false, "confirm": false,
"header": "#### Day 2 - HOUSE OF Tea", "header": "#### Day 2 - HOUSE OF Tea",
"anchor_line": 1194, "anchor_line": 1206,
"n_tracks": 12, "n_tracks": 12,
"n_resolved": 11, "n_resolved": 11,
"suspect_bleed": false, "suspect_bleed": false,
...@@ -305,7 +305,7 @@ ...@@ -305,7 +305,7 @@
"slug": "2024/38c3-toilet", "slug": "2024/38c3-toilet",
"confirm": false, "confirm": false,
"header": "#### Day 3 - Hardcore in the Toilet Club", "header": "#### Day 3 - Hardcore in the Toilet Club",
"anchor_line": 1242, "anchor_line": 1254,
"n_tracks": 4, "n_tracks": 4,
"n_resolved": 3, "n_resolved": 3,
"suspect_bleed": false, "suspect_bleed": false,
...@@ -344,7 +344,7 @@ ...@@ -344,7 +344,7 @@
"slug": "2025/ensad", "slug": "2025/ensad",
"confirm": false, "confirm": false,
"header": "# Set @ENSAD 2025", "header": "# Set @ENSAD 2025",
"anchor_line": 1441, "anchor_line": 1453,
"n_tracks": 9, "n_tracks": 9,
"n_resolved": 8, "n_resolved": 8,
"suspect_bleed": false, "suspect_bleed": false,
...@@ -418,7 +418,7 @@ ...@@ -418,7 +418,7 @@
"slug": "2025/opal-festival-2025", "slug": "2025/opal-festival-2025",
"confirm": false, "confirm": false,
"header": "# OPAL 2025", "header": "# OPAL 2025",
"anchor_line": 1632, "anchor_line": 1644,
"n_tracks": 17, "n_tracks": 17,
"n_resolved": 11, "n_resolved": 11,
"suspect_bleed": false, "suspect_bleed": false,
...@@ -548,7 +548,7 @@ ...@@ -548,7 +548,7 @@
"slug": "2025/39c3-house-of-tea", "slug": "2025/39c3-house-of-tea",
"confirm": false, "confirm": false,
"header": "# CCC 39c3 ALGORAVE <3", "header": "# CCC 39c3 ALGORAVE <3",
"anchor_line": 1786, "anchor_line": 1798,
"n_tracks": 14, "n_tracks": 14,
"n_resolved": 13, "n_resolved": 13,
"suspect_bleed": false, "suspect_bleed": false,
...@@ -657,7 +657,7 @@ ...@@ -657,7 +657,7 @@
"slug": "2026/le-vortex", "slug": "2026/le-vortex",
"confirm": false, "confirm": false,
"header": "# VORTEX26", "header": "# VORTEX26",
"anchor_line": 1816, "anchor_line": 1828,
"n_tracks": 9, "n_tracks": 9,
"n_resolved": 8, "n_resolved": 8,
"suspect_bleed": false, "suspect_bleed": false,
...@@ -733,7 +733,7 @@ ...@@ -733,7 +733,7 @@
"slug": "2024/algolia-fdlm", "slug": "2024/algolia-fdlm",
"confirm": true, "confirm": true,
"header": "## Algolia FDLM2024", "header": "## Algolia FDLM2024",
"anchor_line": 988, "anchor_line": 1000,
"n_tracks": 26, "n_tracks": 26,
"n_resolved": 26, "n_resolved": 26,
"suspect_bleed": true, "suspect_bleed": true,
...@@ -926,7 +926,7 @@ ...@@ -926,7 +926,7 @@
"slug": "2025/algorave-lyon", "slug": "2025/algorave-lyon",
"confirm": true, "confirm": true,
"header": "⠀⠀⠀⠹⣟⠀⠀⠀ GZ 2025 LYON ⠀⣰⠏⠀⠀", "header": "⠀⠀⠀⠹⣟⠀⠀⠀ GZ 2025 LYON ⠀⣰⠏⠀⠀",
"anchor_line": 1550, "anchor_line": 1562,
"n_tracks": 0, "n_tracks": 0,
"n_resolved": 0, "n_resolved": 0,
"suspect_bleed": false, "suspect_bleed": false,
...@@ -936,7 +936,7 @@ ...@@ -936,7 +936,7 @@
"slug": "2026/ete-surprise", "slug": "2026/ete-surprise",
"confirm": true, "confirm": true,
"header": "## SURPRISE IT'S A DANCEFLOOR", "header": "## SURPRISE IT'S A DANCEFLOOR",
"anchor_line": 1831, "anchor_line": 1843,
"n_tracks": 11, "n_tracks": 11,
"n_resolved": 8, "n_resolved": 8,
"suspect_bleed": false, "suspect_bleed": false,
......
...@@ -27,24 +27,39 @@ CATALOG_VIEW = HERE / "catalog_view.json" ...@@ -27,24 +27,39 @@ CATALOG_VIEW = HERE / "catalog_view.json"
OUT = HERE / "backlog_setlists.json" OUT = HERE / "backlog_setlists.json"
AS_OF = "2026-06-06" AS_OF = "2026-06-06"
# ── Authored gig anchors: (1-based line of the header in backlog.md) → site slug ── # ── Authored gig anchors: exact header TEXT in backlog.md → site slug ────────────
# `confirm:`-prefixed slugs are NOT yet trusted (ambiguous header / needs PLN's ear); # `confirm:`-prefixed slugs are NOT yet trusted (ambiguous header / needs PLN's ear);
# they are parsed and reported but withheld from the emitted artifact. # they are parsed and reported but withheld from the emitted artifact.
# value = slug OR (slug, end_line) to bound a block that would otherwise bleed # value = slug OR (slug, span) to bound a block that would otherwise bleed into
# into the next densely-packed setlist (the backlog has no blank-gap between them). # the next densely-packed setlist (the backlog has no blank-gap between them). `span`
# is a LINE COUNT measured from the anchor (not an absolute line) — it is drawn from
# content that lives INSIDE the anchored block, so it doesn't need updating every
# time unrelated journal entries shift the anchor itself.
#
# Anchors used to be keyed by raw line number, pinned once on 2026-06-06. backlog.md
# is PLN's running journal, not a stable document — 10 commits since then inserted
# ~50 lines earlier in the file, so every literal line number silently drifted.
# Opal 2024's anchor (originally line 1031) drifted through blank lines and a
# lucky near-miss for months, then on 2026-08-23 ('update: post cosmic', 49e1b78)
# the drift finally walked the anchor PAST its own header into the tail of the
# PREVIOUS gig's block — n_resolved silently fell from 14 to 3, caught only when
# the coverage-regression test was finally run again (2026-09-05). Anchors now key
# off the header's own TEXT, resolved fresh against the current file every build()
# — self-healing against drift elsewhere, and loud (RuntimeError) if a heading is
# ever actually renamed or duplicated, instead of silently mis-anchoring again.
ANCHORS = { ANCHORS = {
1441: "2025/ensad", # "# Set @ENSAD 2025" '# Set @ENSAD 2025': "2025/ensad",
1816: "2026/le-vortex", # "# VORTEX26" '# VORTEX26': "2026/le-vortex",
1031: "2024/opal-festival-2024", # "## Opal <3🦉" / OPAL 2024 '## Opal <3🦉': "2024/opal-festival-2024",
1632: ("2025/opal-festival-2025", 1667), # "# OPAL 2025" (bleeds into Latin Heritage) '# OPAL 2025': ("2025/opal-festival-2025", 35), # bleeds into Latin Heritage
710: ("2023/mephisteuf", 735), # "## Live @MephisTeuf" (commented block = cut) '## Live @MephisTeuf 😈\u200d💻😈': ("2023/mephisteuf", 25), # commented block = cut
1194: "2024/38c3-house-of-tea", # "#### Day 2 - HOUSE OF Tea" (under 38C3) '#### Day 2 - HOUSE OF Tea': "2024/38c3-house-of-tea", # (under 38C3)
1242: ("2024/38c3-toilet", 1253), # cross-check only (already has tracks.json) '#### Day 3 - Hardcore in the Toilet Club': ("2024/38c3-toilet", 11), # cross-check only (already has tracks.json)
1786: "2025/39c3-house-of-tea", # "# CCC 39c3 ALGORAVE ... Pour Un Thé Dansant" '# CCC 39c3 ALGORAVE <3': "2025/39c3-house-of-tea", # "... Pour Un Thé Dansant"
1169: "2024/toplap-solstice-2024", # "### TopLap Solstice Stream 2024" '### TopLap Solstice Stream 2024': "2024/toplap-solstice-2024",
988: "confirm:2024/algolia-fdlm", # "## Algolia FDLM2024" — already has tracks.json '## Algolia FDLM2024': "confirm:2024/algolia-fdlm", # already has tracks.json
1550: "confirm:2025/algorave-lyon", # "GZ 2025 LYON ALGORAVE" then "# LABENNE LIVE" (?) ' ⠀⠀⠀⠹⣟⠀⠀⠀ GZ 2025 LYON ⠀⣰⠏⠀⠀': "confirm:2025/algorave-lyon", # ASCII banner, no real heading nearby
1831: "confirm:2026/ete-surprise", # "## SURPRISE IT'S A DANCEFLOOR" — slug guess "## SURPRISE IT'S A DANCEFLOOR": "confirm:2026/ete-surprise", # slug guess
} }
# Still unanchored (gaps for #66, need PLN's ear / SC confirmation): 2022/algolia-fdlm-2022, # Still unanchored (gaps for #66, need PLN's ear / SC confirmation): 2022/algolia-fdlm-2022,
# 2024/38c3-chaos-music-club, 2025/39c3-toilet-rave, 2025/toplap-fromscratch-2025. The # 2024/38c3-chaos-music-club, 2025/39c3-toilet-rave, 2025/toplap-fromscratch-2025. The
...@@ -136,19 +151,37 @@ def clean_track_line(raw: str): ...@@ -136,19 +151,37 @@ def clean_track_line(raw: str):
return body, bpm, transition, True return body, bpm, transition, True
def _slug_end(val): def _slug_span(val):
"""ANCHORS value → (slug, end_line|None).""" """ANCHORS value → (slug, span|None). `span` is a line COUNT from the anchor
(see ANCHORS docstring above) — the caller turns it into an absolute end_line
once the anchor itself has been resolved against the live file."""
return val if isinstance(val, tuple) else (val, None) return val if isinstance(val, tuple) else (val, None)
def block_span(lines, start_idx, end_line=None): def resolve_anchors(lines):
"""ANCHORS' header TEXT → 1-based line number, resolved fresh against `lines`.
Never guesses: a header that isn't found (renamed) or that matches more than
once (duplicated) raises loudly, rather than silently mis-anchoring the way a
stale literal line number did for months (see ANCHORS docstring)."""
out = {}
for header, val in ANCHORS.items():
matches = [i for i, ln in enumerate(lines) if ln.rstrip() == header]
if len(matches) != 1:
raise RuntimeError(
f"backlog_setlists: anchor header {header!r} "
f"{'not found' if not matches else f'matches {len(matches)} lines'} "
f"in backlog.md — the heading itself changed; fix ANCHORS.")
out[matches[0] + 1] = val
return out
def block_span(lines, start_idx, anchor_idxs, end_line=None):
"""Collect a gig block. Headers in backlog.md are DECORATIVE (stylized banners, """Collect a gig block. Headers in backlog.md are DECORATIVE (stylized banners,
repeated `# PARVAGUES`, sub-labels) not hierarchical, so we don't scope by level. repeated `# PARVAGUES`, sub-labels) not hierarchical, so we don't scope by level.
From the anchor, walk to the next ANCHOR (or an explicit end_line), terminating From the anchor, walk to the next ANCHOR (or an explicit end_line), terminating
early once the setlist proper ends — a run of ≥3 blank lines after we've collected early once the setlist proper ends — a run of ≥3 blank lines after we've collected
≥2 items (journal prose resumes). Decorative headers are kept as section context.""" ≥2 items (journal prose resumes). Decorative headers are kept as section context."""
anchor_lines = sorted(k - 1 for k in ANCHORS) hard_end = next((a for a in anchor_idxs if a > start_idx), len(lines))
hard_end = next((a for a in anchor_lines if a > start_idx), len(lines))
if end_line is not None: if end_line is not None:
hard_end = min(hard_end, end_line - 1) hard_end = min(hard_end, end_line - 1)
out, items, blanks = [], 0, 0 out, items, blanks = [], 0, 0
...@@ -174,16 +207,19 @@ _LABEL = re.compile( ...@@ -174,16 +207,19 @@ _LABEL = re.compile(
def parse(lines, name_idx): def parse(lines, name_idx):
resolved = resolve_anchors(lines)
anchor_idxs = sorted(k - 1 for k in resolved)
gigs = {} gigs = {}
for ln1, val in sorted(ANCHORS.items()): for ln1, val in sorted(resolved.items()):
slug, end_line = _slug_end(val) slug, span = _slug_span(val)
end_line = ln1 + span if span is not None else None
confirm = slug.startswith("confirm:") confirm = slug.startswith("confirm:")
real_slug = slug.split(":", 1)[1] if confirm else slug real_slug = slug.split(":", 1)[1] if confirm else slug
i = ln1 - 1 i = ln1 - 1
header = lines[i].strip() header = lines[i].strip()
section = None section = None
tracks, unmatched = [], [] tracks, unmatched = [], []
for raw in block_span(lines, i, end_line): for raw in block_span(lines, i, anchor_idxs, end_line):
hm = re.match(r"\s*#{1,6}\s+(.+)$", raw) hm = re.match(r"\s*#{1,6}\s+(.+)$", raw)
if hm: # subsection label if hm: # subsection label
section = re.sub(r"[^\w\s'-]", "", deaccent(hm.group(1))).strip() or None section = re.sub(r"[^\w\s'-]", "", deaccent(hm.group(1))).strip() or None
......
...@@ -48,6 +48,21 @@ def opal_title_map() -> dict[str, str]: ...@@ -48,6 +48,21 @@ def opal_title_map() -> dict[str, str]:
The join key is performance order: `tracks.json` lists score paths 1..N in The join key is performance order: `tracks.json` lists score paths 1..N in
the order played, and `segments_v4.json` carries `perf_track` alongside the the order played, and `segments_v4.json` carries `perf_track` alongside the
title that actually shipped on the file. Neither file alone has both halves. title that actually shipped on the file. Neither file alone has both halves.
THIS DEPENDS ON tracks.json BEING THE SET AS PERFORMED, and nothing in
either file used to say so. OPAL was played as 15 tracks; the release cut
Desire, and `segments_v4.json` renumbered around the hole -- its
`perf_track` keys are 1..13 and 15, with no 14. So the enumerate() below
only lines up while Desire still occupies performance slot 14 in
tracks.json.
Measured 2026-09-05, both ways: as-performed yields 14 mappings including
REVOLUTION. Drop Desire to make the page match the release and the count
falls to 13 -- REVOLUTION's title is LOST, silently, because i=14 then
points at a perf_track that does not exist. Nothing mislabels, which is
what would have made it hard to notice. PLN chose as-performed for the
page on 2026-09-05 (a documentary call, not a technical one); this join
quietly needs that same choice, and test_opal_title_map guards it.
""" """
tj = Path("/home/pln/Work/Web/www/content/lives/2026/opal-festival-2026/tracks.json") tj = Path("/home/pln/Work/Web/www/content/lives/2026/opal-festival-2026/tracks.json")
sv = Path("/home/pln/Work/Sound/Prod/Opal26_master/segments_v4.json") sv = Path("/home/pln/Work/Sound/Prod/Opal26_master/segments_v4.json")
......
...@@ -75,9 +75,27 @@ def test_eda_coverage_is_honest(view): ...@@ -75,9 +75,27 @@ def test_eda_coverage_is_honest(view):
# ── coverage regression guards (catch silent rot in the agreement numbers) ─── # ── coverage regression guards (catch silent rot in the agreement numbers) ───
def test_tracks_total_matches_the_generated_catalog(view):
"""tracks_total must equal the CHECKED-IN catalog's own count, not a frozen
number. catalog.generated.json (build_catalog.py) is a 1:1 derivation of this
same view (n_tracks == len(view["tracks"])), so the two can only disagree when
either the corpus grew/shrank and nobody regenerated the catalog, or the view
pipeline started dropping/duplicating a row — both real regressions.
A hardcoded snapshot (`== 73`) needed hand-editing every time the catalog
legitimately grew (it went 73 -> 81 on 2026-09-05 alone), and a lone floor
(`>= 73`) would happily let the corpus SHRINK by 8 tracks unnoticed. Deriving
the expectation from the catalog itself does neither: it tracks honest growth
for free and still fails the moment the two views disagree."""
import json
from pathlib import Path
catalog_path = Path(__file__).resolve().parent.parent / "catalog.generated.json"
catalog = json.loads(catalog_path.read_text())
assert view["stats"]["tracks_total"] == catalog["n_tracks"]
def test_agreement_distribution_within_bounds(view): def test_agreement_distribution_within_bounds(view):
s = view["stats"] s = view["stats"]
assert s["tracks_total"] == 73 # update consciously if catalog grows
assert s["ac_agree"] >= 45 # was 48 — alarm if it craters assert s["ac_agree"] >= 45 # was 48 — alarm if it craters
assert s["ac_conflict"] <= 4 # was 2 — alarm if conflicts spike assert s["ac_conflict"] <= 4 # was 2 — alarm if conflicts spike
assert s["ac_divergent"] <= 6 # was 3 — wrong-link suspects assert s["ac_divergent"] <= 6 # was 3 — wrong-link suspects
......
"""The OPAL title join, and the assumption it silently rests on.
`opal_title_map()` joins tracks.json's 1..N performance order against
segments_v4.json's `perf_track`. It is correct today, and it is correct only
because the gig page documents the set AS PERFORMED: OPAL was played as 15
tracks, the release cut Desire, and segments_v4's perf_track keys are 1..13
and 15 with a hole at 14. Re-cut the page to the 14 released tracks and the
enumerate() slides -- REVOLUTION's title vanishes with no error and no
mislabel, which is the hard kind of wrong to see.
So this asserts the OUTCOME (every released track gets its title) rather than
the mechanism, and it asserts the hole is really there -- because if
segments_v4 is ever renumbered densely, the reasoning above stops holding and
somebody should be told by a red test rather than by a missing track.
"""
import json
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
import pytest # noqa: E402
import setlist_to_segments as sts # noqa: E402
TRACKS_JSON = Path("/home/pln/Work/Web/www/content/lives/2026/"
"opal-festival-2026/tracks.json")
SEGMENTS_V4 = Path("/home/pln/Work/Sound/Prod/Opal26_master/segments_v4.json")
needs_files = pytest.mark.skipif(
not (TRACKS_JSON.exists() and SEGMENTS_V4.exists()),
reason="OPAL release artifacts not on this box")
@needs_files
def test_every_released_track_gets_its_title():
m = sts.opal_title_map()
segments = json.loads(SEGMENTS_V4.read_text())
assert len(m) == len(segments), (
f"{len(segments)} tracks shipped but only {len(m)} got a title -- the "
f"positional join has slipped. Missing titles: "
f"{sorted(set(r['title'] for r in segments) - set(m.values()))}")
# The encore is the one the slip would drop first, so name it explicitly.
assert "the_revolution_will_be_sampled" in m, \
"REVOLUTION lost its title: tracks.json is probably no longer the " \
"set as performed (Desire must keep performance slot 14)"
assert m["the_revolution_will_be_sampled"] == "REVOLUTION"
@needs_files
def test_the_assumption_is_still_true():
"""tracks.json is as-performed, and segments_v4 has the hole."""
scores = [t.get("file") or "" for t in
json.loads(TRACKS_JSON.read_text())["tracks"]]
perf = sorted(r["perf_track"] for r in
json.loads(SEGMENTS_V4.read_text()))
assert any("desire" in s.lower() for s in scores), (
"Desire is gone from tracks.json, so the page is the RELEASE, not the "
"performance -- opal_title_map()'s enumerate() no longer lines up with "
"perf_track and REVOLUTION will silently lose its title")
assert len(scores) == len(perf) + 1, (
f"{len(scores)} performed vs {len(perf)} released: expected exactly "
f"one performed-but-unreleased track (Desire)")
assert 14 not in perf and 15 in perf, (
f"segments_v4 perf_track = {perf}. The hole at 14 is what makes the "
f"positional join work; if these were renumbered densely, "
f"opal_title_map() needs rewriting, not this test relaxing")
@needs_files
def test_desire_is_deliberately_unmapped():
"""It has no v4 segment, so it must map to nothing -- not to a neighbour."""
m = sts.opal_title_map()
assert "desire" not in m, \
"Desire was cut from the release; a title here means the join " \
"borrowed one from an adjacent track"
"""ardour_session — resolve the RUNNING Ardour session file without argv alone.
ONE PARSER FOR THIS CONCEPT (feedback_one_parser_per_concept): this used to be
two independent implementations — `tools/gig-log.py`'s `FilesLens` (the full
3-rung ladder) and `tools/check-mix.py`'s `running_session()` (argv only,
never fixed to match). Two parsers for "which session is Ardour running"
diverged exactly as expected: check-mix missed every Ardour launched from the
GUI's recent-sessions list, because that leaves argv bare (`ardour-9.7.0`,
nothing else). This module is the one place both tools call into now.
THE LADDER: argv > cwd > open fds
----------------------------------
WHY THREE RUNGS (2026-09-05, live-rig verification): a real, currently-running
Ardour on this machine was missed by an argv-only check, because it had been
opened from the GUI's recent-sessions list rather than `ardour Foo.ardour`,
so its argv was bare. So: argv is tried first (strongest — Ardour was
launched WITH the session path), then cwd (Ardour chdirs into the session
dir on load — weaker, a launcher script could cd elsewhere first), then a
scan of open file descriptors (weakest, but Ardour always holds files open
under the session while it's loaded, e.g. audiofiles/peakfiles, regardless
of how it was launched or where its cwd drifted to since).
Zero non-stdlib deps, zero subprocess/audio I/O — same "no forks, no reads
of audio data" contract gig-log.py's module holds everywhere else. Callers
that need to log an ambiguous fd-scan result (more than one candidate
session dir) pass `log_ambiguity`; callers that don't care (a one-shot
audit script) just omit it.
"""
from __future__ import annotations
import os
from pathlib import Path
def session_from_argv(argv: list[bytes]) -> Path | None:
"""Strongest evidence: the session file as a literal argv entry."""
for arg in argv[1:]:
if arg.endswith(b".ardour"):
try:
p = Path(arg.decode(errors="replace"))
except ValueError:
continue
if p.is_file():
return p
return None
def _first_ardour_file(d: Path) -> Path | None:
try:
matches = sorted(d.glob("*.ardour"))
except OSError:
return None
return matches[0] if matches else None
def session_from_cwd(cwd: Path | None) -> Path | None:
"""Second rung: Ardour's cwd IS the session dir on this rig's launchers,
but a bare-argv GUI launch can still leave cwd wherever the desktop
entry started it from — hence "try", not "trust"."""
return _first_ardour_file(cwd) if cwd is not None else None
def session_from_fd_targets(targets: list[Path], max_up: int = 6
) -> tuple[Path | None, dict[Path, int]]:
"""Third, weakest rung: for each open fd's target path, walk up to
`max_up` ancestor directories looking for one holding a `*.ardour` file
(an fd under interchange/<name>/audiofiles/ is several levels below the
session dir). Returns the BEST-evidenced session file plus every
candidate's open-fd count, so a caller can log an ambiguity — e.g. a
second Ardour instance, or a stale fd into an old session — rather than
silently picking one. "Best" = most open fds: a session actually in use
has audiofiles, peakfiles, the .ardour.bak and more all open at once; a
leftover single fd into an abandoned session is exactly the case this
count is meant to out-vote.
"""
counts: dict[Path, int] = {}
for target in targets:
d = target.parent
for _ in range(max_up):
if _first_ardour_file(d) is not None:
counts[d] = counts.get(d, 0) + 1
break
if d.parent == d:
break
d = d.parent
if not counts:
return None, {}
best_dir = max(counts, key=lambda k: counts[k])
return _first_ardour_file(best_dir), counts
def ardour_pids(proc_root: Path = Path("/proc")) -> list[str]:
pids = []
try:
for proc in proc_root.iterdir():
if not proc.name.isdigit():
continue
try:
argv0 = (proc / "cmdline").read_bytes().split(b"\0")[0]
except OSError:
continue
if b"ardour" in argv0.lower():
pids.append(proc.name)
except OSError:
pass
return pids
def resolve_session_file(proc_root: Path = Path("/proc"),
log_ambiguity=None) -> Path | None:
"""The running Ardour's `.ardour` session file, or None. `proc_root`
defaults to the real /proc; tests point it at a fixture tree built the
same shape (numeric pid dirs, `cmdline`, a `cwd` symlink, an `fd/` dir
of symlinks) so this whole ladder is exercised without touching the
real system."""
for pid in ardour_pids(proc_root):
proc = proc_root / pid
try:
argv = (proc / "cmdline").read_bytes().split(b"\0")
except OSError:
argv = [b""]
got = session_from_argv(argv)
if got:
return got
try:
cwd = Path(os.readlink(proc / "cwd"))
except OSError:
cwd = None
got = session_from_cwd(cwd)
if got:
return got
targets = []
try:
for fd in (proc / "fd").iterdir():
try:
targets.append(Path(os.readlink(fd)))
except OSError:
continue
except OSError:
pass
got, counts = session_from_fd_targets(targets)
if len(counts) > 1 and log_ambiguity:
log_ambiguity(counts)
if got:
return got
return None
def resolve_audiofiles_dir(proc_root: Path = Path("/proc"),
log_ambiguity=None) -> Path | None:
"""<session>/interchange/<session name>/audiofiles/, or None."""
p = resolve_session_file(proc_root, log_ambiguity)
return (p.parent / "interchange" / p.stem / "audiofiles") if p else None
...@@ -85,7 +85,12 @@ LAUNCHERS = [ ...@@ -85,7 +85,12 @@ LAUNCHERS = [
# click on an already-running lens silently did nothing. # click on an already-running lens silently did nothing.
{"key": "midimon", "name": "MIDI Monitor", "blurb": "live MIDI as glyph-rain — the surface, watched", {"key": "midimon", "name": "MIDI Monitor", "blurb": "live MIDI as glyph-rain — the surface, watched",
"candidates": ["python3"], "args": [str(MIDIVIZ)], "pgrep": "midiviz.py", "candidates": ["python3"], "args": [str(MIDIVIZ)], "pgrep": "midiviz.py",
"focus": "midiviz"}, "focus": "midiviz",
# Launching it from here is an explicit "I want this open", so it
# un-says a previous close (midiviz writes a latch that
# rig_units.ensure() honours until logout). Cleared only when we
# actually SPAWN -- focusing a running window is not a new decision.
"latch": "midiviz"},
] ]
# Web tools = start-or-open. `port` open ⇒ running; else `cmd` is spawned, then # Web tools = start-or-open. `port` open ⇒ running; else `cmd` is spawned, then
...@@ -391,6 +396,17 @@ def _sweep_stale_pending(spec): ...@@ -391,6 +396,17 @@ def _sweep_stale_pending(spec):
except OSError as e: except OSError as e:
_log(f"could not sweep {pend.name}: {e}") _log(f"could not sweep {pend.name}: {e}")
def _clear_latch(spec):
"""Drop a unit's user-close latch, because we are starting it on purpose."""
unit = spec.get("latch")
if not unit:
return
rt = os.environ.get("XDG_RUNTIME_DIR") or f"/run/user/{os.getuid()}"
try:
os.unlink(os.path.join(rt, "parvagues", f"{unit}.closed"))
except (FileNotFoundError, OSError):
pass
def launch(key): def launch(key):
"""Spawn the launcher detached. Returns {ok, status, msg, url?}.""" """Spawn the launcher detached. Returns {ok, status, msg, url?}."""
...@@ -444,6 +460,7 @@ def launch(key): ...@@ -444,6 +460,7 @@ def launch(key):
argv = _argv(spec) argv = _argv(spec)
if not argv: if not argv:
return {"ok": False, "status": "no-terminal", "msg": "no terminal emulator found"} return {"ok": False, "status": "no-terminal", "msg": "no terminal emulator found"}
_clear_latch(spec)
try: try:
proc = subprocess.Popen(argv, cwd=str(TIDAL), start_new_session=True, proc = subprocess.Popen(argv, cwd=str(TIDAL), start_new_session=True,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
......
...@@ -132,3 +132,131 @@ def test_the_real_parser_feeds_the_real_ingest_path(): ...@@ -132,3 +132,131 @@ def test_the_real_parser_feeds_the_real_ingest_path():
# every one of the 48 authored grid cells is covered # every one of the 48 authored grid cells is covered
assert set(V.grid.CC_TO_CELL) <= set(ccs) assert set(V.grid.CC_TO_CELL) <= set(ccs)
assert any(cc not in V.grid.CC_TO_CELL for cc in ccs), "no un-gridded CC to rain" assert any(cc not in V.grid.CC_TO_CELL for cc in ccs), "no un-gridded CC to rain"
# ── the close latch ────────────────────────────────────────────────────────
#
# The latch is a PATH agreed on by three files that deliberately do not import
# each other: midiviz writes it, rig_units.ensure() honours it, and
# launchers.launch() clears it. That is the whole contract, and a typo in any
# one of them fails silently in the direction that costs PLN the complaint he
# raised -- the window coming back after he closed it. So assert they agree
# rather than trusting three copies of an f-string.
def test_latch_path_agrees_across_the_three_files(tmp_path, monkeypatch):
monkeypatch.setenv("XDG_RUNTIME_DIR", str(tmp_path))
import importlib.util
import sys as _sys
from pathlib import Path as _Path
root = _Path(__file__).resolve().parents[3]
def _load(name, rel):
spec = importlib.util.spec_from_file_location(name, root / rel)
mod = importlib.util.module_from_spec(spec)
_sys.modules[name] = mod
spec.loader.exec_module(mod)
return mod
rig = _load("_rig_units_latch", "tools/rig_units.py")
mv = _load("_midiviz_latch", "tools/bridge/midiviz.py")
assert str(rig.latch_path("midiviz")) == mv.latch_close_path(), (
"rig_units.ensure() reads a different path than midiviz writes, so a "
"close would be silently undone by the next converge")
assert str(tmp_path) in mv.latch_close_path(), \
"the latch must live under XDG_RUNTIME_DIR so it dies at logout"
def test_a_written_latch_is_seen_and_cleared(tmp_path, monkeypatch):
monkeypatch.setenv("XDG_RUNTIME_DIR", str(tmp_path))
import importlib.util
import sys as _sys
from pathlib import Path as _Path
root = _Path(__file__).resolve().parents[3]
def _load(name, rel):
spec = importlib.util.spec_from_file_location(name, root / rel)
mod = importlib.util.module_from_spec(spec)
_sys.modules[name] = mod
spec.loader.exec_module(mod)
return mod
rig = _load("_rig_units_latch2", "tools/rig_units.py")
mv = _load("_midiviz_latch2", "tools/bridge/midiviz.py")
lau = _load("_launchers_latch2", "tools/bridge/launchers.py")
assert not rig.closed_by_user("midiviz"), "no latch yet"
mv._latch_close()
assert rig.closed_by_user("midiviz"), \
"ensure() cannot see the close midiviz just recorded"
# An explicit launch is a new decision and must un-say the close.
lau._clear_latch({"latch": "midiviz"})
assert not rig.closed_by_user("midiviz"), \
"launching it on purpose left the latch in place, so the next " \
"converge would refuse to start it"
# Clearing a latch that is not there is not an error.
lau._clear_latch({"latch": "midiviz"})
lau._clear_latch({})
# ── the two controls stay hittable ─────────────────────────────────────────
#
# Sizing a hit target to its glyph gave an 8px-wide X: pleasant to look at,
# unhittable during a set, and at 0.6 scale the pin and the X had to either
# overlap or shrink below usable. The layout now derives targets from the
# right edge and centres the glyphs inside them, so this holds by
# construction -- which is worth an assertion at the scales PLN actually
# zooms through, since a regression here is invisible until he reaches for it.
def _widget(scale, width):
import importlib.util
import sys as _sys
from pathlib import Path as _Path
root = _Path(__file__).resolve().parents[3]
spec = importlib.util.spec_from_file_location("_mv_ctrl",
root / "tools/bridge/midiviz.py")
mv = importlib.util.module_from_spec(spec)
_sys.modules["_mv_ctrl"] = mv
spec.loader.exec_module(mv)
try:
_QtCore, _QtGui, QtWidgets = mv._qt()
except Exception:
return None, None
app = QtWidgets.QApplication.instance() or QtWidgets.QApplication([])
w = mv.build_widget("--", None, scale=scale)
w.resize(width, 400)
w.show()
app.processEvents()
return mv, w
def test_controls_are_hittable_and_disjoint_at_every_scale():
import os
import pytest
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
mv, w0 = _widget(1.0, 900)
if mv is None:
pytest.skip("no Qt available")
for scale in (0.6, 1.0, 1.6, 2.4):
for width in (520, 900, 1920):
_, w = _widget(scale, width)
pin, close = w._ctrl_rects()
assert not pin.intersects(close), (
f"pin and X overlap at scale={scale} width={width}: a click "
f"meant for one would hit the other")
assert close.right() <= w.width(), \
f"the X is off-screen at scale={scale} width={width}"
assert min(pin.width(), pin.height(),
close.width(), close.height()) >= w.MIN_TARGET, (
f"a target fell below MIN_TARGET at scale={scale} "
f"width={width}: pin={pin} close={close}")
# The sparkline must stop before the controls, not run under them.
assert w._ctrl_left() <= pin.left(), \
"the sparkline is allowed to draw over the controls"
w.close()
...@@ -57,38 +57,27 @@ import sys ...@@ -57,38 +57,27 @@ import sys
import xml.etree.ElementTree as ET import xml.etree.ElementTree as ET
from pathlib import Path from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import ardour_session # the argv>cwd>fds ladder, shared with gig-log.py
ARDOUR_DIR = Path.home() / "Work/Sound/Ardour" ARDOUR_DIR = Path.home() / "Work/Sound/Ardour"
# THE SESSION IS "Tidal Live". This used to be hardcoded to "Tidal Multi", which on # THE SESSION IS "Tidal Live". This used to be hardcoded to "Tidal Multi", which on
# 2026-07-28 made this tool confidently report five faders at -inf while the ACTUALLY # 2026-07-28 made this tool confidently report five faders at -inf while the ACTUALLY
# OPEN session had them up — a stale file audited as if it were live. A mixer auditor # OPEN session had them up — a stale file audited as if it were live. A mixer auditor
# reading the wrong mixer is worse than no auditor, so resolve the RUNNING session from # reading the wrong mixer is worse than no auditor, so resolve the RUNNING session
# the Ardour process's own argv and only fall back to a guess if that fails. # via tools/ardour_session.py's argv>cwd>fds ladder and only fall back to a guess if
# that fails entirely.
#
# Argv-only used to be this tool's own check, and it silently missed any Ardour
# launched from the GUI's recent-sessions list (bare argv, no session path) — the
# same gap gig-log.py's FilesLens found and fixed on 2026-09-05. Both tools now
# call the one shared resolver so they can't disagree on which session is live.
def running_session() -> Path | None: def running_session() -> Path | None:
"""The .ardour file the live Ardour process was launched with (argv), or None. """The RUNNING Ardour's `.ardour` session file, or None — see
tools/ardour_session.py for the evidence ladder (argv > cwd > open fds).
Ardour's process is `ardour-N.N.N /path/to/Foo.ardour` and the binary is named
ArdourGUI, not "ardour" — a `pgrep ardour` returns nothing, which already caused
one wrong conclusion. Match on the cmdline instead.
""" """
try: return ardour_session.resolve_session_file()
for proc in Path("/proc").iterdir():
if not proc.name.isdigit():
continue
try:
argv = (proc / "cmdline").read_bytes().split(b"\0")
except OSError:
continue
if not argv or b"ardour" not in argv[0].lower():
continue
for arg in argv[1:]:
if arg.endswith(b".ardour"):
p = Path(arg.decode(errors="replace"))
if p.is_file():
return p
except OSError:
pass
return None
def default_session() -> Path: def default_session() -> Path:
......
...@@ -86,7 +86,8 @@ two lenses because neither one alone tells the truth: ...@@ -86,7 +86,8 @@ two lenses because neither one alone tells the truth:
by byte, on disk. Nothing about Ardour's config, arm state or transport display by byte, on disk. Nothing about Ardour's config, arm state or transport display
can lie to it — either bytes landed or they didn't. But it needs a live, can lie to it — either bytes landed or they didn't. But it needs a live,
resolvable session (see check-mix.py's 2026-07-28 lesson: audit the RUNNING resolvable session (see check-mix.py's 2026-07-28 lesson: audit the RUNNING
session, from the process's own argv, never a hardcoded/guessed path). session — resolved by tools/ardour_session.py's argv>cwd>fds ladder —
never a hardcoded/guessed path).
* OSC is Ardour's own opinion: master record-arm and transport-rolling, over its * OSC is Ardour's own opinion: master record-arm and transport-rolling, over its
control-surface protocol. It works even when the files lens can't resolve a control-surface protocol. It works even when the files lens can't resolve a
session, but it is exactly the kind of "config says X" signal that has burned session, but it is exactly the kind of "config says X" signal that has burned
...@@ -131,6 +132,9 @@ from pathlib import Path ...@@ -131,6 +132,9 @@ from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent / "bridge")) sys.path.insert(0, str(Path(__file__).resolve().parent / "bridge"))
import perf # noqa: E402 (rootless Thermals/_read live here already — DRY with the Bridge) import perf # noqa: E402 (rootless Thermals/_read live here already — DRY with the Bridge)
sys.path.insert(0, str(Path(__file__).resolve().parent))
import ardour_session # noqa: E402 (the argv>cwd>fds ladder, shared with check-mix.py)
LOG_DIR = Path(os.environ.get( LOG_DIR = Path(os.environ.get(
"GIG_LOG_DIR", os.path.expanduser("~/.local/share/parvagues/gig-log"))) "GIG_LOG_DIR", os.path.expanduser("~/.local/share/parvagues/gig-log")))
...@@ -714,89 +718,22 @@ class RecPass: ...@@ -714,89 +718,22 @@ class RecPass:
# ---- session resolution: argv > cwd > open fds ----------------------------- # ---- session resolution: argv > cwd > open fds -----------------------------
# #
# WHY THREE RUNGS (2026-09-05, live-rig verification): the first version of # The ladder itself (WHY three rungs, what each one trusts and why) now
# this lens copied check-mix.py's running_session() outright — argv only — # lives in tools/ardour_session.py — ONE parser for "which session is
# and it MISSED a real, currently-running Ardour on this machine, because it # Ardour running", shared with check-mix.py (feedback_one_parser_per_concept).
# had been opened from the GUI's recent-sessions list rather than # FilesLens below is a thin wrapper: it owns the per-instance caching/logging
# `ardour Foo.ardour`, so its argv was bare (`ardour-9.7.0` and nothing # state, the ladder module owns the evidence-gathering.
# else). check-mix.py has this exact same latent bug (noted there, not
# fixed there in this pass — check-mix only reads the SAVED session file
# anyway, so a wrong resolution there is a smaller blast radius than here,
# where it silences the ground-truth lens entirely). So: argv is tried
# first (strongest — Ardour was launched WITH the session path), then cwd
# (Ardour chdirs into the session dir on load — weaker, a launcher script
# could cd elsewhere first), then a scan of open file descriptors (weakest,
# but Ardour always holds files open under the session while it's loaded,
# e.g. audiofiles/peakfiles, regardless of how it was launched or where its
# cwd drifted to since).
def _session_from_argv(argv: list[bytes]) -> Path | None:
"""Strongest evidence: the session file as a literal argv entry."""
for arg in argv[1:]:
if arg.endswith(b".ardour"):
try:
p = Path(arg.decode(errors="replace"))
except ValueError:
continue
if p.is_file():
return p
return None
def _first_ardour_file(d: Path) -> Path | None:
try:
matches = sorted(d.glob("*.ardour"))
except OSError:
return None
return matches[0] if matches else None
def _session_from_cwd(cwd: Path | None) -> Path | None:
"""Second rung: Ardour's cwd IS the session dir on this rig's launchers,
but a bare-argv GUI launch can still leave cwd wherever the desktop
entry started it from — hence "try", not "trust"."""
return _first_ardour_file(cwd) if cwd is not None else None
def _session_from_fd_targets(targets: list[Path], max_up: int = 6
) -> tuple[Path | None, dict[Path, int]]:
"""Third, weakest rung: for each open fd's target path, walk up to
`max_up` ancestor directories looking for one holding a `*.ardour` file
(an fd under interchange/<name>/audiofiles/ is several levels below the
session dir). Returns the BEST-evidenced session file plus every
candidate's open-fd count, so a caller can log an ambiguity — e.g. a
second Ardour instance, or a stale fd into an old session — rather than
silently picking one. "Best" = most open fds: a session actually in use
has audiofiles, peakfiles, the .ardour.bak and more all open at once; a
leftover single fd into an abandoned session is exactly the case this
count is meant to out-vote.
"""
counts: dict[Path, int] = {}
for target in targets:
d = target.parent
for _ in range(max_up):
if _first_ardour_file(d) is not None:
counts[d] = counts.get(d, 0) + 1
break
if d.parent == d:
break
d = d.parent
if not counts:
return None, {}
best_dir = max(counts, key=lambda k: counts[k])
return _first_ardour_file(best_dir), counts
class FilesLens: class FilesLens:
""""Is Ardour recording" answered from the one place that can't lie: bytes """"Is Ardour recording" answered from the one place that can't lie: bytes
landing on disk under interchange/<session>/audiofiles/. landing on disk under interchange/<session>/audiofiles/.
Session resolution climbs the argv > cwd > open-fds ladder above (see Session resolution is tools/ardour_session.py's argv > cwd > open-fds
that block's WHY for the 2026-09-05 live-rig gap that made this three ladder (see that module's docstring for the 2026-09-05 live-rig gap that
rungs instead of one). It is duplicated from, not imported from, made this three rungs instead of one) — the exact same resolver
check-mix.py — that file is a script, not a library, and this one stays check-mix.py calls, so the two tools can never disagree on which session
stdlib-only either way — so keep them in sync if either changes, and is live.
note check-mix.py still only has the FIRST rung (a smaller blast radius
there: it audits a saved session file, it does not gate a whole lens).
Cost: the resolution ladder (argv/cwd/fd-scan) runs only at lens init Cost: the resolution ladder (argv/cwd/fd-scan) runs only at lens init
and on the FILES_RESCAN_S retry cadence — NEVER per tick, because the and on the FILES_RESCAN_S retry cadence — NEVER per tick, because the
...@@ -817,20 +754,7 @@ class FilesLens: ...@@ -817,20 +754,7 @@ class FilesLens:
@staticmethod @staticmethod
def _ardour_pids(proc_root: Path = Path("/proc")) -> list[str]: def _ardour_pids(proc_root: Path = Path("/proc")) -> list[str]:
pids = [] return ardour_session.ardour_pids(proc_root)
try:
for proc in proc_root.iterdir():
if not proc.name.isdigit():
continue
try:
argv0 = (proc / "cmdline").read_bytes().split(b"\0")[0]
except OSError:
continue
if b"ardour" in argv0.lower():
pids.append(proc.name)
except OSError:
pass
return pids
@classmethod @classmethod
def resolve_session_file(cls, proc_root: Path = Path("/proc"), def resolve_session_file(cls, proc_root: Path = Path("/proc"),
...@@ -840,44 +764,13 @@ class FilesLens: ...@@ -840,44 +764,13 @@ class FilesLens:
the same shape (numeric pid dirs, `cmdline`, a `cwd` symlink, an the same shape (numeric pid dirs, `cmdline`, a `cwd` symlink, an
`fd/` dir of symlinks) so this whole ladder is exercised without `fd/` dir of symlinks) so this whole ladder is exercised without
touching the real system.""" touching the real system."""
for pid in cls._ardour_pids(proc_root): return ardour_session.resolve_session_file(proc_root, log_ambiguity)
proc = proc_root / pid
try:
argv = (proc / "cmdline").read_bytes().split(b"\0")
except OSError:
argv = [b""]
got = _session_from_argv(argv)
if got:
return got
try:
cwd = Path(os.readlink(proc / "cwd"))
except OSError:
cwd = None
got = _session_from_cwd(cwd)
if got:
return got
targets = []
try:
for fd in (proc / "fd").iterdir():
try:
targets.append(Path(os.readlink(fd)))
except OSError:
continue
except OSError:
pass
got, counts = _session_from_fd_targets(targets)
if len(counts) > 1 and log_ambiguity:
log_ambiguity(counts)
if got:
return got
return None
@classmethod @classmethod
def resolve_audiofiles_dir(cls, proc_root: Path = Path("/proc"), def resolve_audiofiles_dir(cls, proc_root: Path = Path("/proc"),
log_ambiguity=None) -> Path | None: log_ambiguity=None) -> Path | None:
"""<session>/interchange/<session name>/audiofiles/, or None.""" """<session>/interchange/<session name>/audiofiles/, or None."""
p = cls.resolve_session_file(proc_root, log_ambiguity) return ardour_session.resolve_audiofiles_dir(proc_root, log_ambiguity)
return (p.parent / "interchange" / p.stem / "audiofiles") if p else None
def _log(self, reason: str | None) -> None: def _log(self, reason: str | None) -> None:
if reason == self._logged_reason: if reason == self._logged_reason:
......
...@@ -44,8 +44,10 @@ accident. `--status` spells it out rather than printing systemd's word. ...@@ -44,8 +44,10 @@ accident. `--status` spells it out rather than printing systemd's word.
""" """
from __future__ import annotations from __future__ import annotations
import os
import argparse import argparse
import subprocess import subprocess
from pathlib import Path
import sys import sys
# unit, label, process that proves it really works (None = the unit is the whole # unit, label, process that proves it really works (None = the unit is the whole
...@@ -165,6 +167,45 @@ Wants=pipewire.service ...@@ -165,6 +167,45 @@ Wants=pipewire.service
""" """
# ── the user-close latch ───────────────────────────────────────────────────
#
# PLN, 2026-09-05, about midiviz: "its too sticky atm if i close i wanna close
# it". He also asked for the same lens to be permanently present. Both are
# reasonable and they only look contradictory: "always open" is about how a
# SESSION starts, "closed means closed" is about what happens after he acts.
#
# ensure() starts every non-manual unit that is not active, and gig-up and the
# Bridge watcher both call it -- so closing the window put it back within the
# next converge, with nothing in the output admitting why. Making the unit
# "manual" would have answered the complaint by breaking the feature.
#
# So a close is recorded, and it is recorded in $XDG_RUNTIME_DIR: that
# directory is wiped at logout, which is exactly the lifetime this decision
# should have. Closed for the rest of this session; back on at next login,
# with no state to clean up and nothing to forget to undo.
def _latch_dir() -> Path:
rt = os.environ.get("XDG_RUNTIME_DIR") or f"/run/user/{os.getuid()}"
return Path(rt) / "parvagues"
def latch_path(unit: str) -> Path:
return _latch_dir() / f"{unit}.closed"
def closed_by_user(unit: str) -> bool:
return latch_path(unit).exists()
def clear_latch(unit: str) -> None:
"""Anything that deliberately starts a unit un-says the close."""
try:
latch_path(unit).unlink()
except FileNotFoundError:
pass
except OSError:
pass
def ensure(apply: bool) -> int: def ensure(apply: bool) -> int:
"""Enable per policy AND start. The half gig-up.sh never did.""" """Enable per policy AND start. The half gig-up.sh never did."""
st = states() st = states()
...@@ -178,6 +219,11 @@ def ensure(apply: bool) -> int: ...@@ -178,6 +219,11 @@ def ensure(apply: bool) -> int:
# lcxl3-driver, so starting it here would KILL the LCXL3 translation # lcxl3-driver, so starting it here would KILL the LCXL3 translation
# mid-bring-up while printing nothing but green. # mid-bring-up while printing nothing but green.
if s["active"] != "active" and BOOT[u] != "manual": if s["active"] != "active" and BOOT[u] != "manual":
# A unit the human closed on purpose stays closed until logout.
if closed_by_user(u):
print(f"rig: {u:<24} inactive because it was CLOSED by hand "
f"— leaving it alone (latch: {latch_path(u)})")
continue
todo.append(("start", u, s["active"])) todo.append(("start", u, s["active"]))
if not todo: if not todo:
print(f"rig: all {len(UNITS)} units enabled-per-policy and active — nothing to do") print(f"rig: all {len(UNITS)} units enabled-per-policy and active — nothing to do")
......
...@@ -101,6 +101,21 @@ class SetlistError(RuntimeError): ...@@ -101,6 +101,21 @@ class SetlistError(RuntimeError):
# real track to a typo'd marker. # real track to a typo'd marker.
_NOTE_RE = re.compile(r"^\s*(?:Q|N\.?B|NOTE|TODO|FIXME|IDEA)\s*[:?]", re.I) _NOTE_RE = re.compile(r"^\s*(?:Q|N\.?B|NOTE|TODO|FIXME|IDEA)\s*[:?]", re.I)
# A SOUNDCHECK is not the set. This is NOT the section whitelist that `entries()`
# rightly refuses to build — that one tried to enumerate which of PLN's prose
# headings are *part of* the set, and died on "## Livecoding Techno DNB Nu-jazz".
# This is the inverse and much narrower: naming the one phase that is definitionally
# not a performance. It stays short on purpose, and skips are announced on stderr,
# because a dropped track and a skipped soundcheck must never look alike.
#
# Found 2026-09-05: OPAL 2026's section opens with "## SOUNDCHECK / -- Quand on
# decolle", so every tool reading this setlist believed the set had 16 tracks
# opening on Quand on décolle. The recorded set has 15 and opens on Ceci n'est pas
# Une Bombe (segments_v3/v4 track 1). test_setlist.py had been asserting exactly
# this since it was written, and nobody could see it fail — the file was a plain
# script that pytest could not collect.
_NOT_THE_SET_RE = re.compile(r"^(sound\s*check|line\s*check|balance)$", re.I)
# How many words of decoration a real entry may carry around its track name — # How many words of decoration a real entry may carry around its track name —
# "[lightT][120] Vague de CRIME" spends 2, "[129] Desire [TODO BASS ETC!]" spends 4. # "[lightT][120] Vague de CRIME" spends 2, "[129] Desire [TODO BASS ETC!]" spends 4.
_MAX_DECORATION = 4 _MAX_DECORATION = 4
...@@ -230,6 +245,11 @@ def entries(gig: str = GIG, backlog: pathlib.Path | None = None) -> list[Entry]: ...@@ -230,6 +245,11 @@ def entries(gig: str = GIG, backlog: pathlib.Path | None = None) -> list[Entry]:
if _NOTE_RE.match(raw): if _NOTE_RE.match(raw):
print(f"setlist: note, not a track — skipped: {raw}", file=sys.stderr) print(f"setlist: note, not a track — skipped: {raw}", file=sys.stderr)
continue continue
# Announced, never silent — see _NOT_THE_SET_RE.
if _NOT_THE_SET_RE.match(section):
print(f"setlist: {section} is not the set — skipped: {raw}",
file=sys.stderr)
continue
tags = tuple(t.strip() for t in BRACKET.findall(raw)) tags = tuple(t.strip() for t in BRACKET.findall(raw))
name = BRACKET.sub(" ", raw) name = BRACKET.sub(" ", raw)
bpm = next((int(BPM.match(t).group(1)) for t in tags if BPM.match(t)), None) bpm = next((int(BPM.match(t).group(1)) for t in tags if BPM.match(t)), None)
......
...@@ -156,11 +156,26 @@ def test_sound_hints_never_raise_on_an_orbit_with_no_quoted_name(tmp_path): ...@@ -156,11 +156,26 @@ def test_sound_hints_never_raise_on_an_orbit_with_no_quoted_name(tmp_path):
# the setlist # the setlist
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
def _raw_setlist_rows() -> list[str]:
"""Independent ground truth for what the setlist file names: the same sed
pipeline check-tracks.sh uses (strip full-line + trailing comments, drop
blanks), NOT oo.load_setlist() counting itself. The set grows over time —
13 became 16 between 2026-07-28 and 2026-09-05 as PLN added tracks — so any
expectation here has to track the file, not a frozen literal."""
import subprocess
out = subprocess.run(
["sed", "-e", "s/#.*//", "-e", "s/[[:space:]]*$//", str(oo.SETLIST)],
capture_output=True, text=True, check=True).stdout
return [ln for ln in out.splitlines() if ln.strip()]
def test_the_real_setlist_loads_and_every_track_exists(): def test_the_real_setlist_loads_and_every_track_exists():
"""A setlist that silently drops a track silently drops a transition, so """A setlist that silently drops a track silently drops a transition, so
load_setlist raises rather than skipping.""" load_setlist raises rather than skipping. The row count is checked against an
independent count of the setlist file (see _raw_setlist_rows), not a frozen
literal — a hardcoded number rots the first time PLN adds a track."""
setlist = oo.load_setlist() setlist = oo.load_setlist()
assert len(setlist) == 13 assert len(setlist) == len(_raw_setlist_rows())
for p, label in setlist: for p, label in setlist:
assert p.exists() assert p.exists()
assert label assert label
...@@ -187,24 +202,35 @@ def test_setlist_comments_and_blank_lines_are_ignored(tmp_path, monkeypatch): ...@@ -187,24 +202,35 @@ def test_setlist_comments_and_blank_lines_are_ignored(tmp_path, monkeypatch):
# parser that only agrees with fixtures # parser that only agrees with fixtures
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# Hand-measured by PLN + me on 2026-07-28, before this parser existed. If the # Hand-measured by PLN + me on 2026-07-28, before this parser existed, and
# parser and the hand count ever disagree, ONE of them is wrong and the tool must # RE-measured on 2026-09-05 (by reading each file's column-0 `dN` declarations
# not be trusted until that is resolved. # with grep, not by copying the parser's output — a hand measurement that quotes
# the thing it checks is a tautology). If the parser and the hand count ever
# disagree, ONE of them is wrong and the tool must not be trusted until that is
# resolved.
HAND_MEASURED = { HAND_MEASURED = {
"bombe_dj": {1, 2, 3, 4, 5, 7, 8, 9}, # 2026-08-01, commit 2376e43 ("d10 is the riser") added a d10 safe-riser
"wap": {1, 2, 3, 4, 5, 7, 8, 9}, # idiom to 7 tracks that lacked one — bombe_dj, wap, you_my_sunshine,
"piment_bresilien": {1, 2, 3, 4, 5, 7, 8, 10}, # mafia_sans_serif and desire among them. The 07-28 fixture predates that
# commit and was missing d10 on all five; re-measured against the file as
# it stands now.
"bombe_dj": {1, 2, 3, 4, 5, 7, 8, 9, 10},
"wap": {1, 2, 3, 4, 5, 7, 8, 9, 10},
# piment_bresilien is the ONE track in this set that moved its riser the
# other way: PLN asked (2026-08-02, commits 71bb9bc/5e5a37a) to move d10 to
# d9 ("eg a synmenace iirc") rather than adopt the new d10 idiom — the file
# carries a `-- Menace` comment on d9 confirming it. So this one loses d10,
# not gains it.
"piment_bresilien": {1, 2, 3, 4, 5, 7, 8, 9},
"perfect": {1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12}, "perfect": {1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12},
"gimme_acid": {1, 2, 3, 4, 5, 8, 9, 10, 11, 12}, "gimme_acid": {1, 2, 3, 4, 5, 8, 9, 10, 11, 12},
"vague_de_crime": {1, 2, 3, 4, 5, 6, 7, 8, 10}, "vague_de_crime": {1, 2, 3, 4, 5, 6, 7, 8, 10},
"mafia_sans_serif": {1, 2, 3, 4, 5, 7, 8}, "mafia_sans_serif": {1, 2, 3, 4, 5, 7, 8, 10},
# d7 became d6 on 2026-07-30 (PLN's edit, committed in ce887b7: the Guitar # d7 became d6 on 2026-07-30 (PLN's edit, committed in ce887b7: the Guitar
# Sunshine block moved down a slot with ^91->^90 and ^59->^58, the same # Sunshine block moved down a slot with ^91->^90 and ^59->^58, the same
# consolidation he made in do_it_right). Re-derived by reading the file's # consolidation he made in do_it_right).
# column-0 `dN` declarations, not by copying the parser's output — a hand "you_my_sunshine": {1, 2, 3, 4, 5, 6, 8, 9, 10, 11},
# measurement that quotes the thing it checks is a tautology. "desire": {1, 2, 3, 4, 5, 6, 7, 8, 9, 10},
"you_my_sunshine": {1, 2, 3, 4, 5, 6, 8, 9, 11},
"desire": {1, 2, 3, 4, 5, 6, 7, 8, 9},
"the_revolution_will_be_sampled": {1, 2, 3, 4, 5, 7, 8, 9, 10, 11, 12}, "the_revolution_will_be_sampled": {1, 2, 3, 4, 5, 7, 8, 9, 10, 11, 12},
} }
...@@ -217,8 +243,12 @@ def test_the_parser_agrees_with_the_hand_measurement(stem, want): ...@@ -217,8 +243,12 @@ def test_the_parser_agrees_with_the_hand_measurement(stem, want):
def test_the_ear_report_reproduces_vague_de_crime_to_bombe_dj(): def test_the_ear_report_reproduces_vague_de_crime_to_bombe_dj():
"""The ORIGINAL bug report, as a test. PLN heard the crimewave synth survive """The ORIGINAL bug report, as a test. PLN heard the crimewave synth survive
into the next track; the mechanism says d6 and d10 are the survivors.""" into the next track; the mechanism says d6 is the survivor.
assert oo.orphans(oo.resolve("vague_de_crime"), oo.resolve("bombe_dj")) == {6, 10}
d10 was a second survivor when this was first written, but commit 2376e43
(2026-08-01) gave bombe_dj its own d10 safe riser — both tracks now declare
d10, so it stopped being a ghost. Only d6 (crimewave) still orphans."""
assert oo.orphans(oo.resolve("vague_de_crime"), oo.resolve("bombe_dj")) == {6}
def test_d6_of_vague_de_crime_really_is_the_crimewave_sound_he_heard(): def test_d6_of_vague_de_crime_really_is_the_crimewave_sound_he_heard():
...@@ -281,14 +311,11 @@ def test_check_tracks_reads_the_SAME_setlist_file(): ...@@ -281,14 +311,11 @@ def test_check_tracks_reads_the_SAME_setlist_file():
def test_the_setlist_survives_the_shell_parse_too(): def test_the_setlist_survives_the_shell_parse_too():
"""The setlist annotates each path with codename + BPM after a '#'. The shell """The setlist annotates each path with codename + BPM after a '#'. The shell
reader has to strip TRAILING comments, not just full-line ones, or every track reader has to strip TRAILING comments, not just full-line ones, or every track
reports "no such file".""" reports "no such file". Row count is checked against oo.load_setlist() itself
(derived), not a frozen literal — see _raw_setlist_rows."""
import re as _re import re as _re
import subprocess rows = _raw_setlist_rows()
out = subprocess.run( assert len(rows) == len(oo.load_setlist())
["sed", "-e", "s/#.*//", "-e", "s/[[:space:]]*$//", str(oo.SETLIST)],
capture_output=True, text=True, check=True).stdout
rows = [ln for ln in out.splitlines() if ln.strip()]
assert len(rows) == 13
for r in rows: for r in rows:
assert not _re.search(r"[#\[]", r), f"comment leaked into {r!r}" assert not _re.search(r"[#\[]", r), f"comment leaked into {r!r}"
assert (oo.REPO / r).exists(), r assert (oo.REPO / r).exists(), r
......
...@@ -158,4 +158,24 @@ check("the generated .txt round-trips through the parser", ...@@ -158,4 +158,24 @@ check("the generated .txt round-trips through the parser",
== [e.path for e in real]) == [e.path for e in real])
print(f"\n{len(FAILED)} failed" if FAILED else "\nall passed") print(f"\n{len(FAILED)} failed" if FAILED else "\nall passed")
sys.exit(1 if FAILED else 0)
def test_backlog_setlist_checks_all_pass():
"""Expose this plain-script suite to pytest.
The checks above run at import. Until 2026-09-05 this module ended in a bare
`sys.exit()`, which raises SystemExit during pytest's collection and aborted
the WHOLE directory with an INTERNALERROR — `pytest tools/tests/` ran ZERO
tests while 273 of them passed when invoked individually. So this file's own
real failure (the quand_on_decolle phantom) sat unseen, and so did everybody
else's.
A test file that cannot fail is not a passing test; a test file that stops the
suite is worse. The exit is now guarded under __main__ so the script still
works run directly, and this assertion is what makes pytest see it.
"""
assert not FAILED, "setlist checks failed:\n " + "\n ".join(FAILED)
if __name__ == "__main__":
sys.exit(1 if FAILED else 0)
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