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
......@@ -19,6 +19,31 @@ certainly right but I cannot re-read them, so **treat a #17x number as a name, n
---
## 🧭 READ THIS FIRST (2026-09-05)
This file has accreted amendments since 2026-08-16 and the OLDEST sections are the
LEAST true. Start at the bottom:
- **`🔴 RESUME POINTS` (end of file)** — R1–R5, written for a reader with zero
context: exact commands, paths, artifacts already on disk, and the decisions that
are PLN's rather than a machine's.
- **`⚠️ SHARED-CHECKOUT HAZARD` (end of file)** — read before committing anything in
Sound/Tidal. `git push origin master` from a non-master HEAD exits 0 and pushes
nothing; it already cost four commits.
**Superseded below, do not act on it:**
- **A3's "TWO DEFECTS TO FIX FIRST"** — wrong. `/take-five-drops` was never "24 s
short"; it is the Aug-12 render generation, and it is 1 of **14** stale uploads,
not 1 of 2 defects. And "every other track matches to the second" was a loose
tolerance, not a verdict. See the 2026-09-05 amendments.
- **A3's account table ("8 of 15 uploaded")** — re-read the account instead:
6 correct v4 · 14 stale · 7 never uploaded. `sc_pull_2026-09-05/` has the truth.
- **A3's "NOT YET DONE: artwork"** — artwork EXISTS on 8 stale uploads and on the
full set; 101 originals are archived. It was never attached by our tooling.
- **The catalog is no longer stale** and `check-mix.py`'s argv bug is fixed.
---
## EPIC A — OPAL-26 release ⟵ *the only epic blocking a shipment*
North star: get "Sunset Forest" (opal-festival-2026, 2026-08-08) released. 14 tracks —
......@@ -114,6 +139,11 @@ cd ~/Work/Sound/tidal-ears && .venv/bin/python -m tidal_ears.cli master sc form-
--sharing private --state ../Prod/Opal26_master/release/sc_state.json --go
```
**SUPERSEDED 2026-09-05 — the two items below are the WRONG diagnosis.** Kept
only so the reasoning is auditable. Defect 1 is not a truncation, it is a stale
render generation, and there are 14 of them not 1. Defect 2 stands. Jump to the
2026-09-05 amendments and the resume points.
**TWO DEFECTS TO FIX FIRST:**
1. **`/take-five-drops` uploaded SHORT — 6:57 on SoundCloud vs 7:21 in the plan (440.7 s),
~24 s missing.** Every other track matches to the second. Delete it on SoundCloud and
......@@ -1746,3 +1776,607 @@ timecode is carried through unchanged rather than guessed at or dropped. The pag
currently documents the set **as performed** (15 tracks). Should it stay that way,
or drop to the 14 released tracks so the page matches what a visitor can actually
go and listen to? Not a machine call.
### ✅ The gated delete EXISTS — `master sc delete --manifest` (tidal-ears `ab3285b` on `main`)
The "explicit, separate call" that `scwrite.py`'s refusal message had been
promising since August. `_is_test()` and `delete()` are **byte-for-byte
untouched and unreachable** from the new path — a test proves it. The old gate
was not widened; a second gate was added beside it.
The allowlist is a **manifest**, not a permalink pattern, because a pattern gate
cannot protect `/wap` from `/opal26-wap`. Guards, each proven refusing against
the live account:
| guard | behaviour |
|---|---|
| dry-run **default** | `--go` required to delete anything |
| identity, **both directions** | manifest names `permalink` AND `sc_id`; if the permalink now resolves to a different id, **REFUSE** — not warn |
| `public: true` | refused unless `--allow-public` (proven on `/au-revoir-lord-toyota`) |
| missing/short `reason` | refused — a deletion without provenance is not allowed |
| `--max` (default 20) | refuses an over-size manifest **before any API call** |
| wrong account | aborts the whole run before planning |
| receipt | per entry: title, duration, created_at, artwork_url, genre, license, sharing + on-disk paths of archived artwork and source FLAC, existence re-checked at run time |
Generator: `armada/tide-table/build_delete_manifest.py` (`bd3a680`) turns the
lens verdicts into the manifest; it refuses to emit anything marked `OK`, anything
from `coincidences_discarded`, or any entry without an `sc_id`.
**Dry run is clean: 14 deletable, 0 refused.** All 14 are private, all uploaded
2026-08-12T21:17:43Z, and **every one resolves to a FLAC still present in
`Opal26_master/tracks/`** — deleting them loses a URL, not a recording. 8 of 14
have their original artwork already archived in `sc_pull_2026-09-05/artwork/`.
⚠️ **`--go` IS NOT YET APPROVED.** Awaiting PLN on the list.
🔴 **The delete transport is UNPROVEN.** Reads are fine (132-track walk, 14 GETs,
no DataDome), but **no DELETE has ever been issued from this codebase against
api-v2**, and `POST /tracks` 400s forever on that path — which is the whole reason
`scform` exists. If `--go` returns a 403 with a captcha body, the fix is already
anticipated: `delete_manifested()` takes a `deleter` callable, to be backed by
`scbrowser.Browser.api("DELETE", …)`; it just is not wired to a `--transport` flag
yet. Do not read a clean dry run as evidence the deletes will land.
🐛 **A real bug caught in the generator, and it is `feedback_name_the_reference`
again.** The first version lined stale uploads up against the newest cut **by
track number**. The Aug-12 cut had 15 tracks and v4 has 14, so dropping *Desire*
renumbered *REVOLUTION* from 15 to 14 — and the manifest shipped
`"#14 Desire, 388.1s vs v4 205.6s"`, where 205.6 s is REVOLUTION's length. Fixed
to key on the title slug; a track absent from the newest cut now says so instead
of borrowing a neighbour's duration.
📉 **`/me`'s `track_count` says 70 while the account holds 132 tracks** — it counts
public only. Any tool sizing a walk from `track_count` under-counts by 47%.
🔧 `master.py` swallowed every subcommand's return code (`args.func(args)`), so
refusals reported success to `$?`. Nonzero ints now propagate — this also fixes
`form-album`'s exit status.
### 🔴 PROCESS INCIDENT — four commits landed on another session's branch
Sound/Tidal is a **shared checkout** and a concurrent session switched it from
`master` to `claude/rig-streamline` between two of my commits. Four then landed
there, and I did not notice because **`git push origin master` from a non-master
HEAD is a silent no-op**: it pushes the local `master` ref, which was already up
to date, prints nothing, exits 0. The "PUSHED" in my own log was self-issued.
Recovered by cherry-picking the four onto `master` in a temporary worktree
(`eefd706``1b03cb0`, `111c903``577767a`, `84e37fe``d429f16`, `719e09a``dcbf355`),
touching neither `claude/rig-streamline`'s tip nor the live working tree — another
agent and PLN were both active in it. The other session's `361a05b`
("fix(midi): cut the surface's direct leg to SuperCollider") is theirs and was
left alone.
The global CLAUDE.md already prescribes worktrees for shared repos; I had applied
that to delegated subagents and exempted the orchestrator — which is the session
that commits most. There is no exemption. Banked as memory
`feedback_shared_checkout_moves_under_you`: verify HEAD before every commit, and
verify the push moved the remote (`git rev-list --left-right --count
origin/master...master` must read `0 0`) rather than trusting exit 0.
### ✅ `check-mix.py` resolves a GUI-launched session — and IMMEDIATELY caught the bug (`27f2351`)
The argv-only resolution bug is fixed, and not by copy-paste. The 3-rung ladder
(argv → `/proc` cwd → open fds) now lives once, in **`tools/ardour_session.py`**
(stdlib-only, following `pvbanks.py`'s convention), and BOTH `gig-log.py` and
`check-mix.py` import it. `FilesLens` keeps only its own caching/logging state,
and gig-log's "keep check-mix in sync" comment is deleted because there is
nothing left to keep in sync — `feedback_one_parser_per_concept` satisfied by
construction rather than by discipline.
`tools/tests/test_gig_log.py`: **169 passed** before and after, no test edited.
Rungs 2 and 3 were proven against synthetic `/proc` fixtures (bare argv + real
cwd; bare argv + unrelated cwd + only an fd deep under `audiofiles/`) since the
live process's argv already carried the path — stated plainly rather than
claimed as a full end-to-end proof.
🔴 **AND IT FIRED ON THE FIRST REAL RUN. PLN — this needs your hands.**
Verified twice, independently:
```
Tidal 07: fader at -inf dB — SILENT
Tidal 08: fader at -inf dB — SILENT
Tidal 10: fader at -inf dB — SILENT
Tidal 12: fader at -inf dB — SILENT
```
`/home/pln/Work/Sound/Ardour/Tidal Live/Tidal Live.ardour`, session as last
SAVED. **This is the THIRD recurrence** (04-08+12, then 02+10, now 07/08/10/12)
— see `reference_ardour_saved_vs_live_recurs`. Raise the faders on those four
**and SAVE the session**, then re-run `python3 tools/check-mix.py` until it says
0 silent orbits. Note the trap the tool prints itself: Ardour meters are
POST-fader, so a shut fader shows no meter, which looks exactly like Tidal
emitting nothing. It isn't.
The parked PLN-only quick check "Ardour faders: verify SAVED" is now **runnable**,
which is the whole point of the fix — and it went from unrunnable to failing, not
to passing. `feedback_assembling_checks_revalidates` again: wiring a check into a
caller runs it under fresh conditions and finds something.
### 🟡 Two pre-existing test failures, unrelated but now visible
Found while running the suite, neither touching the changed files:
- `tools/tests/test_orphan_orbits.py` **fails** — 16 rows where it expects 13.
- `tools/tests/test_lcxl3_display.py` **collects 0 tests** — a test file that
cannot fail is not a passing test (`feedback_verify_the_plumbing`).
Both predate this work. The 16-vs-13 smells like the corpus grew and the fixture
did not, which is the kind of expectation that should be derived, not frozen.
### 🧹 Leftovers from the concurrent-session mess, for whoever rebases
`claude/rig-streamline`'s working tree still holds uncommitted copies of edits
that are **already safely on master**: `tools/check-mix.py`, `tools/gig-log.py`
(modified) and `tools/ardour_session.py` (untracked), plus
`armada/tide-table/build_delete_manifest.py` (untracked, byte-identical to
master's tracked copy). Nothing was reset or discarded in a tree shared with
live sessions. **Before merging or rebasing that branch, expect these to
double-apply**`rm` the untracked ones and check out the modified ones from
master rather than committing them twice.
---
## 🔴 RESUME POINTS — written 2026-09-05 pre-compact, for a session with ZERO context
### R1 · Finish the OPAL-26 SoundCloud release ⟵ *the only shipping thread*
**State:** the account holds 6 correct v4 tracks, 14 stale Aug-12 uploads, and 7
v4 tracks never uploaded. Artwork for all 101 arty tracks is archived. The delete
manifest is generated and dry-run clean. **Blocked on PLN's `--go` only.**
Artifacts (all already on disk, do not regenerate):
- `~/Work/Sound/Prod/sc_pull_2026-09-05/catalog.json` — the account, 132 tracks
- `~/Work/Sound/Prod/sc_pull_2026-09-05/artwork/` — 101 originals
- `~/Work/Sound/Prod/sc_pull_2026-09-05/opal26_generation_verdicts.json`
- `~/Work/Sound/Prod/sc_pull_2026-09-05/opal26_delete_manifest.json` — 14 entries
- `~/Work/Sound/Prod/Opal26_master/release/opal26_plan.json` — 15 entries, exact
**Step 1 — delete the 14 (NEEDS PLN'S APPROVAL FIRST):**
```
cd ~/Work/Sound/tidal-ears
.venv/bin/python -m tidal_ears.cli master sc delete \
--manifest ~/Work/Sound/Prod/sc_pull_2026-09-05/opal26_delete_manifest.json
# ^ dry run, expect "14 deletable, 0 refused". Add --go ONLY with his word.
```
⚠️ **The delete transport is UNPROVEN** — no DELETE has ever been issued from this
codebase against api-v2, and `POST /tracks` 400s forever on that path. If `--go`
returns 403 with a captcha body, wire `delete_manifested()`'s existing `deleter`
callable to `scbrowser.Browser.api("DELETE", …)`; there is no `--transport` flag yet.
**Step 2 — re-verify, then upload the 8 (7 missing + take-five once its slug frees):**
```
cd ~/Work/Sound/Tidal
python3 armada/tide-table/sc_generation_lens.py \
~/Work/Sound/Prod/sc_pull_2026-09-05/catalog.json \
--master ~/Work/Sound/Prod/Opal26_master \
--plan ~/Work/Sound/Prod/Opal26_master/release/opal26_plan.json \
--newest tracks_v4_streaming
python3 armada/tide-table/check_permalinks.py \
~/Work/Sound/Prod/Opal26_master/release/opal26_plan.json
cd ~/Work/Sound/tidal-ears && .venv/bin/python -m tidal_ears.cli master sc form-album \
--plan ~/Work/Sound/Prod/Opal26_master/release/opal26_plan.json \
--sharing private --state ~/Work/Sound/Prod/Opal26_master/release/sc_state.json --go
```
**PASS `--state` THIS TIME.** It never has been, which is why the account was the
only record. Re-run `sc pull` after, not just during — uploads have been accepted,
verified, and then vanished.
**Step 3 — re-attach the covers.** They live only on the tracks being deleted.
Artwork can only be SET via `scwrite.py`'s `sc album` PUT path; `scform.py` has no
artwork verb. Source images: `sc_pull_2026-09-05/artwork/`.
**Step 4 — fill `audio:` in `www/content/lives/2026/opal-festival-2026.md`** (empty
today), then the catalog's release links behind it.
**Open decisions, both PLN's:**
- **`/desire`** is the one manifest entry that is not a duplicate. Desire was cut
from the release so no v4 replacement exists — deleting it removes Desire from
SoundCloud entirely. The FLAC is safe locally (`tracks/`, `tracks_v3/`). Cut from
the release ≠ off the platform.
- **`/opal26-remix`** (4273.548 s, unidentified) and **`/opal26_master_raw`**
(5207.454 s, the raw master) are deliberately NOT in the manifest.
- **Suffix policy:** OPAL's uploads are unsuffixed; CosmicFest used
`--permalink-suffix`. Decide before uploading more, or the two gigs stay
inconsistent forever.
### R2 · PLN's hands, 2 minutes, and it is currently FAILING
```
python3 tools/check-mix.py # expect: 0 silent orbits
```
Right now it reports **Tidal 07 / 08 / 10 / 12 at -inf dB** in the saved
`Tidal Live` session — the **third** recurrence (04-08+12, then 02+10, now these).
Raise those four faders in Ardour **and SAVE**, then re-run until clean. Ardour
meters are POST-fader, so a shut fader looks identical to Tidal emitting nothing.
🔎 **Lead worth one look:** the unmerged branch `claude/rig-autoconnect-regression`
(1 commit, `a4da75e`) is *"the two reconcilers that lived only in $HOME — five
orbits reached no Ardour track"*. Five orbits reaching no track and four orbits at
-inf may be the same wound. Check before merging or discarding that branch.
### R3 · Catalog coverage — 14 gigs uncatalogued (unstarted, mid effort)
`catalog_view.json` covers 25 gigs; `www/content/lives/` has **39** gig pages
(2022:2 · 2023:4 · 2024:14 · 2025:14 · 2026:5). Missing: algolia-fdlm-2022,
mephisteuf, 38c3-chaos-music-club, 38c3-house-of-tea, opal-festival-2024,
toplap-solstice-2024, 39c3-house-of-tea, 39c3-toilet-rave, algorave-lyon, ensad,
opal-festival-2025, toplap-fromscratch-2025, ete-surprise, le-vortex.
The hard part is the track↔gig mapping, not the parsing — see
`feedback_locate_matrix_method` (never trust a low-z locate, ear-gate it).
Also still open from the reconciler itself: 3 DIVERGENT tracks (Blue Gold, Love
First, Clameur) and 2 conflicts (Atari-ght, Ere de Jeu).
### R4 · Two parsers disagree about what a gig track IS
`tools/gig-metadata.py` vs `armada/tide-table/build_gig_tracksjson.py`. The latter
drafts NEW pages and never computes the `styleDistribution` that `LiveEvent.js`
renders; they also disagree outright on REVOLUTION's style (jazz vs nujazz). Until
one owns the concept and the other calls it, use `gig-metadata.py --only-played`
for a REGEN of an existing gig. `feedback_one_parser_per_concept`.
### R5 · Small, named, unblocked
- **`test_orphan_orbits.py` FAILS** (16 rows, expects 13) and
**`test_lcxl3_display.py` collects ZERO tests**. Both pre-existing. A test file
that cannot fail is not a passing test. The 16-vs-13 smells like the corpus grew
and the fixture did not — derive the expectation, don't freeze it.
- **`mix/` overlaps tidal-ears' mastering CLI.** Committed (`a14aee7`) so the
overlap is visible in one tree rather than half-invisible in an untracked dir.
PLN's call which one is the spine.
- **Three captain's logs claim `log: 025`**, which `025-fifteen-intros` owns, and
they use dated filenames against the README's `NNN-slug.md`. Their dates
(2026-08-14) land them BEFORE 039, so the next free numbers read out of order.
- **`TODO.md` is deliberately untracked** — a paused 2026-06-22 perf/thermal board
would be a second answer to "what's next". Fold its live items in and delete, or
keep it outside git.
- **www `feat/paradiso`**: 1 unmerged commit (`b130192` "Paradiso: v2").
### ⚠️ SHARED-CHECKOUT HAZARD — read before committing anything in Sound/Tidal
`/home/pln/Work/Sound/Tidal` is checked out on **`claude/rig-streamline`** (another
session, 7 commits ahead of master, tip `c7ac174`). **Verify HEAD before every
commit** (`git rev-parse --abbrev-ref HEAD`) and **verify the push moved the
remote** (`git rev-list --left-right --count origin/master...master` must read
`0 0`) — `git push origin master` from a non-master HEAD exits 0 and pushes
nothing. Commit to master through a temporary worktree; never `git checkout` in
that tree, other sessions and PLN have live uncommitted work in it.
That branch also holds **uncommitted duplicates of work already on master**
`tools/check-mix.py`, `tools/gig-log.py` (modified), `tools/ardour_session.py` and
`armada/tide-table/build_delete_manifest.py` (untracked). Before rebasing or
merging it, `rm` the untracked ones and restore the modified ones from master, or
they double-apply. Also uncommitted there and **not ours**:
`copycat/veridis_quo.tidal`, `perf-tray.py`, `tools/rig_units.py` (the other
session) and PLN's own `piment_bresilien` / `sunny_side_up` / `rose_rouge` /
`tous_ensemble` `.tidal` edits.
### 🧹 Branch hygiene done this session
Pruned (merged, verified): `claude/button-column-migration`, `claude/lcxl3-surface`,
`tidal-to-tracks-tooling`, and tidal-ears' `claude/sc-gated-delete`. Surviving with
reason: `claude/rig-streamline` (active), `claude/rig-autoconnect-regression`
(1 unmerged commit, see R2), www `feat/paradiso` (1 unmerged).
---
## ✅ R2 CLOSED — the faders are up and SAVED (2026-09-05, PLN)
*"done and ardour saved and closed :)"* — verified by `tools/check-mix.py`:
**`OK — all 12 Tidal orbits reach the master bus.`** Third recurrence of the
saved--inf bug, closed.
Two incidental facts worth keeping:
**1. check-mix works with Ardour CLOSED.** It resolved
`Ardour/Tidal Live/Tidal Live.ardour` with no `ardour` process alive, so the
fader check does not need the rig up — it reads the saved file, which is exactly
the state that matters. Useful: the check can run in a pre-gig script before
anything is launched.
**2. 🟡 NINE of twelve faders moved, not four — PLN's ears needed.**
| orbit | before | after | Δ |
|---|---|---|---|
| 02 | −9.4 | +0.6 | **+10.0** |
| 03 | −8.4 | −0.1 | **+8.3** |
| 04 | −10.1 | −0.4 | **+9.7** |
| 05 | −8.1 | +0.6 | **+8.7** |
| 06 | −8.4 | +0.6 | **+9.0** |
| 07 · 08 · 10 · 12 | −inf | +0.6 · +1.5 · +0.1 · +6.0 | the intended fix ✅ |
| 01 · 09 · 11 | +0.0 · +6.0 · +0.0 | unchanged | — |
Orbits 02–06 were sitting in a **coherent −8 to −10 dB trim set** and are now all
at roughly unity. Master is unchanged at −0.2 dB so nothing clips, but the
BALANCE between orbits moved by ~9 dB on five of them.
**Hypothesis, NOT established** — the LCXL3's physical faders are absolute-position
controls, so touching one snaps the DAW value to wherever the pot actually sits.
That is the pot-pickup class, and note the board's claim that LCXL v3 "retires the
pot-pickup value jump" is about the **endless ENCODERS (the knobs)** — the eight
faders are still absolute, so fader pickup survives the v3 upgrade entirely. But
PLN may equally have dragged them in Ardour on purpose. **Do not record a cause
without asking him.**
**Open question for PLN:** was the −8/−10 dB trim on 02–06 deliberate gain
staging, or was it itself drift? If deliberate, it is gone and wants restoring
(`feedback_gig_gain_staging`: source peaks at −6 dBFS, let FOH amplify). If it was
drift, this is now the cleanest that session has been. One listen decides it, and
only he can.
## 🔍 CosmicFest-26 checked with the same lens — CLEAN, and it settles the suffix question
Ran `sc_generation_lens.py` against `Cosmic26_master` proactively, before that
release goes anywhere. Verdicts in
`sc_pull_2026-09-05/cosmic26_generation_verdicts.json`:
```
OK 12 · stale 0 · strays 0 · coincidences dropped 0 · never uploaded 2
```
**Zero stale, zero strays** — the opposite of OPAL. Two reasons, and both are
lessons rather than luck:
1. **Only ONE render generation exists** (`tracks_v1_streaming`). Nothing to
confuse. OPAL had three on disk and all three reached the account.
2. **The permalinks are gig-scoped** — every one ends `-cosmicfest-2026`, from
`build_release_plan.py --permalink-suffix`. So no CosmicFest upload could ever
collide with, or be mistaken for, the OPAL cut of the same score — and four of
these titles (Take Five, Piment, Sunshine, Eh ouais je Funk) exist at both gigs.
### ✅ This ANSWERS the open suffix-policy decision, with evidence
The board asked whether to keep OPAL unsuffixed for consistency. Compare the two
releases as they actually sit on the account today:
| | permalinks | stale/strays |
|---|---|---|
| CosmicFest-26 | gig-scoped (`-cosmicfest-2026`) | **0** |
| OPAL-26 | unsuffixed | **14** |
Consistency with a broken scheme is not a virtue. **Suffix OPAL's remaining
uploads too.** The 6 already-correct unsuffixed tracks can stay as they are
(they are right, and renaming costs a permalink for nothing) — but every NEW
upload should be gig-scoped, because a livecoded catalogue replays titles by
design and a global namespace will collide again on the next gig.
### 🔴 CosmicFest needs exactly 2 re-uploads, and we already know why
```
/am-i-doing-it-right-cosmicfest-2026 396.69s NOT UPLOADED
/vague-de-crime-cosmicfest-2026 252.00s NOT UPLOADED
```
**These are almost certainly the two known vanishers**, not new failures — see
`topic_soundcloud`: two CosmicFest tracks logged `✓ live at`, were verified by
API, then 404'd on `/resolve` and were absent from `/users/{id}/tracks`. Both
FLACs decode with zero ffmpeg errors and `/me.quota` was fine, so it is SC-side
and **a plain re-run converges**`form-album` is idempotent and will skip the
12 that are already up.
This is also the standing lesson in action: *verify the shelf AFTER a run, not
only during it.* "N uploaded, 0 failed" from the uploader was not evidence that
N tracks exist, and it took a lens run three weeks later to notice two were gone.
⚠️ Still gated on PLN either way: the board's rule is **do not upload
CosmicFest's split before he approves the tone**, and that approval has not
landed. The 2 missing tracks are a re-run, not a new release decision — but they
are part of a release he has not signed off.
## 🐛 TWO BUGS THAT WERE HIDING EACH OTHER (2026-09-05, `21f83b5` + `e8f7066`)
### 1. `pytest tools/tests/` ran **ZERO** tests
`test_setlist.py` was a plain script ending in a bare module-level `sys.exit()`.
pytest raises that during **collection**, so the whole directory aborted with an
`INTERNALERROR` and ran nothing — while 273 tests passed when invoked file-by-file.
Every test in this repo was invisible to the obvious command. Exit guarded under
`__main__`, one assertion added so pytest sees the checks.
**Whole suite, one command: `274 passed, 2 skipped, 0 failed`** (from zero).
Also fixed in the same pass (`21f83b5`): `test_orphan_orbits.py` (was 9 failing) —
a stale fixture, not a parser bug. The setlist grew and commit `2376e43`
("d10 is the riser") added a d10 safe-riser to `bombe_dj`/`wap`/`you_my_sunshine`/
`mafia_sans_serif`/`desire` after `HAND_MEASURED`'s 2026-07-28 freeze, while
`piment_bresilien` went the other way (d10→d9, PLN's own request). Counts are now
**derived** from the setlist file rather than frozen literals, so writing a track
no longer breaks a test. And `test_lcxl3_display.py` collected 0 → **30** real
tests. Both proven able to fail.
### 2. And the thing that hid behind it: **the first track was never the first track**
`test_setlist.py` had been FAILING all along, on a check whose name says exactly
what was wrong — *"a deliberation note is not a track (the quand_on_decolle
phantom)"*. OPAL 2026's backlog section opens:
```
## SOUNDCHECK
-- Quand on decolle
```
`entries()` took every list item under the gig heading, so **every tool reading
this setlist believed the OPAL 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). Parser now yields 15, in the right order, and announces the skip
on stderr — a dropped track and a skipped soundcheck must never look alike.
The fix is deliberately **not** the section whitelist `entries()` 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`). `_NOT_THE_SET_RE` is the
inverse and far narrower: the one phase that is definitionally not a performance.
**`backlog.md` was NOT touched and was never wrong** — a soundcheck belongs in his
set list, it just isn't in his set. Fix the parser, never the data
(`feedback_parsers_over_copy`).
🔎 **FOLLOW-UP worth one look:** anything that consumed `setlist.entries()`
positionally had a phantom at index 0 and everything shifted by one.
`setlist_to_segments.py` is the obvious suspect — check whether any gig's segments
were ever derived from the setlist rather than from measured boundaries. OPAL is
safe (its v4 came from ear-verified boundaries) and CosmicFest came back clean
from the generation lens, so this may be harmless — but it is unverified, and the
class of error is exactly `feedback_report_the_whole_timeline`.
---
# 🧭 BOARD STATE — 2026-09-05, second pre-compact (SUPERSEDES the R1–R5 block above)
The R1–R5 resume points earlier in this file were written mid-session. **R2 is now
closed** and three new things landed after them. Read this block, not that one.
## ✅ Closed since those resume points were written
| was | now |
|---|---|
| R2 · Ardour faders at -inf (3rd recurrence) | **CLOSED by PLN***"done and ardour saved and closed :)"*; `check-mix: OK — all 12 Tidal orbits reach the master bus` |
| R5 · `test_orphan_orbits` fails 16-vs-13 | **FIXED** `21f83b5` — stale fixture, counts now DERIVED from the setlist file |
| R5 · `test_lcxl3_display` collects 0 | **FIXED** `21f83b5` — 30 real tests, proven able to fail |
| (undiscovered) `pytest tools/tests/` ran ZERO tests | **FIXED** `e8f7066`**274 passed, 2 skipped, 0 failed** in one command |
| (undiscovered) OPAL setlist's first track was a soundcheck | **FIXED** `e8f7066` — 15 entries, opens on Ceci n'est pas Une Bombe |
| Open: OPAL suffix policy | **ANSWERED by evidence** — suffix new uploads; see `dfab5ca` and the CosmicFest section |
## 🔴 THE FOUR DECISIONS THAT ARE PLN'S, AND NOTHING ELSE MOVES WITHOUT THEM
1. **`--go` on the 14 OPAL deletes.** Manifest generated, dry-run clean (14 deletable,
0 refused), artwork archived, every target private and every one still has its FLAC
on disk. ⚠️ **Transport unproven** — no DELETE has ever been issued from this
codebase against api-v2; if it 403s, wire `delete_manifested()`'s existing `deleter`
callable to `scbrowser.Browser.api("DELETE", …)`.
Command in R1 above. **Do not pass `--go` without his word.**
2. **`/desire` specifically** — the one manifest entry that is not a duplicate. No v4
replacement exists, so deleting it removes Desire from SoundCloud entirely. FLAC safe
locally. Cut from the release ≠ off the platform.
3. **CosmicFest tone approval** — still not landed, and the board's rule is do not
upload its split before it does. The 2 missing tracks are a re-run, not a new
release decision, but they belong to a release he has not signed off.
4. **OPAL gig page: as-performed (15, Desire included) or as-released (14)?** Desire's
row carries an unverified v3 timecode until this is answered.
Plus one **ears** question, not a decision: **were orbits 02–06's −8 to −10 dB trims
deliberate gain staging, or drift?** Nine of twelve faders moved when four were broken;
02–06 went from a coherent −8/−10 dB set to roughly unity (~9 dB balance shift, master
unchanged at −0.2). One listen decides it. Hypothesis (NOT established): the LCXL3's
faders are absolute-position, so touching one snaps the DAW value to the pot — and note
the board's "v3 retires pot-pickup" claim covers the endless ENCODERS only; the eight
faders are still absolute, so that class survives the upgrade.
## 🟢 NEXT, in order, all of it unblocked and doable without PLN
1. **`setlist_to_segments.py` audit (~15 min, do this first).** The setlist parser had a
phantom at index 0 for a month, so anything consuming `entries()` **positionally**
was shifted by one. Check whether any gig's segments were ever derived from the
setlist rather than from measured boundaries. OPAL is safe (ear-verified v4) and
CosmicFest came back clean from the lens, so this may be harmless — **but nobody has
checked**, and the class of error is `feedback_report_the_whole_timeline`.
2. **R4 · one parser per gig-track concept.** `tools/gig-metadata.py` vs
`armada/tide-table/build_gig_tracksjson.py`: the latter never computes the
`styleDistribution` that `LiveEvent.js` renders, and they disagree on REVOLUTION's
style (jazz vs nujazz). Until one owns the concept and the other calls it, use
`gig-metadata.py --only-played` for a REGEN of an existing gig.
3. **R3 · 14 uncatalogued gigs** (25 catalogued vs 39 gig pages). The hard part is the
track↔gig mapping, not the parsing — `feedback_locate_matrix_method`, ear-gate it,
never trust a low-z locate. Also still open from the reconciler: 3 DIVERGENT tracks
(Blue Gold, Love First, Clameur) and 2 conflicts (Atari-ght, Ere de Jeu).
4. **`mix/` vs tidal-ears' mastering CLI** — two toolboxes that both master a set
(`a14aee7` committed it so the overlap is visible in one tree). PLN's call which is
the spine, but the comparison can be written up for him.
## 🟡 Small, named, still true
- **Three captain's logs claim `log: 025`** (which `025-fifteen-intros` owns) and use
dated filenames against the README's `NNN-slug.md`. Their 2026-08-14 dates land them
BEFORE 039, so the next free numbers read out of order. PLN's archive, PLN's spine.
- **`TODO.md` deliberately untracked** — a paused 2026-06-22 board would be a second
answer to "what's next". Fold its live items in and delete, or keep it out of git.
- **www `feat/paradiso`**: 1 unmerged commit (`b130192`).
- **`claude/rig-autoconnect-regression`**: 1 unmerged commit (`a4da75e`, *"the two
reconcilers that lived only in $HOME — five orbits reached no Ardour track"*). Five
orbits reaching no track and four orbits at -inf may be the same wound — worth one
look before merging or discarding.
## ⚠️ SHARED-CHECKOUT HAZARD (unchanged, still the top trap)
`/home/pln/Work/Sound/Tidal` is on **`claude/rig-streamline`** — another live session,
tip moving (last seen `151c3d9`). **`git push origin master` from a non-master HEAD
exits 0 and pushes nothing.** Verify HEAD before every commit; verify the push moved
the remote (`git rev-list --left-right --count origin/master...master` must read `0 0`).
Commit to master through a temporary worktree; never `git checkout` in that tree.
Because it is on that branch, `pytest tools/tests/` still reports "no tests ran"
**there** — the fix is on master and that is expected, not a regression.
That branch also holds uncommitted duplicates of work already on master
(`tools/check-mix.py`, `tools/gig-log.py`, `tools/ardour_session.py`,
`armada/tide-table/build_delete_manifest.py`) — `rm`/restore before rebasing or they
double-apply. And PLN's own live `.tidal` edits, which are not ours.
---
# BOARD STATE — 2026-09-05, evening (session-planning; SUPERSEDES all blocks above)
PLN gave the go on the OPAL deletes, CosmicFest tone, and the gig page. Read
this block first; earlier ones are history.
## Ground truth, measured today (not recalled)
| thing | state |
|---|---|
| Tidal master | `7e966a5`, pushed, `0 0`. **474 passed, 2 skipped, 0 failed** |
| www main | `d7dd44c`, pushed. PLN has in-progress edits (app/, LiveEvent, next.config) |
| tidal-ears main | `5d53a1c`, pushed |
| local branches | 2: `master` + `claude/rig-streamline` (fully merged, still the shared tree's HEAD) |
| catalog | **81 tracks / 25 gigs, `as_of 2026-09-05`** — the "STALE 73/23" note above is DEAD |
| corpus | 40 recorded / 41 unrecorded · 63 takes, 61 with EDA |
| SoundCloud | 118 tracks: **70 public, 48 private** |
| OPAL on SC | **stale 0, strays 0** · 6 of 14 correct v4 cuts up · **8 never uploaded** |
## Closed today
* **The 14 stale OPAL deletes RAN.** All confirmed gone (account 132 → 118
permalinks). Receipt: `Prod/sc_pull_2026-09-05/opal26_delete_manifest.receipt.go.json`.
Every one of the 14 has its FLAC locally; `/take-five-drops` freed its permalink.
* **The delete tool lied about it.** Reported `0/14 deleted, 0 failed` because
SC deletion is ASYNCHRONOUS. Fixed (`5d53a1c`): polling confirmation, `issued`
vs `deleted` recorded separately, exit 4 = accepted-but-unconfirmed ≠ failure.
* **Branches consolidated.** `rig-streamline`'s 8 real commits merged (`3c210a2`);
three branches were already upstream by patch-id and are deleted.
* **Two failing tide-table tests fixed** — and the cause was NOT my setlist
soundcheck fix. `backlog_setlists.py` keyed anchors by LINE NUMBER in PLN's
running journal; ten later commits shifted them. Now keyed on header text.
* **The phantom audit closed with a better finding.** Nothing consumed the
SOUNDCHECK phantom positionally. But `opal_title_map()` silently required the
gig page to be AS-PERFORMED: as-released drops REVOLUTION's title, measured.
Documented + 3 tests.
* **midiviz**: X to close, `[x]` to pin, and the stickiness fixed at its real
cause — `rig_units.ensure()`, not the window. User-close latch in
`$XDG_RUNTIME_DIR` (dies at logout = the right lifetime).
* **OPAL gig page** verified as-performed and given the provenance that stops
the misreading (`durationBasis`, `performedButUnreleased`).
## Waiting on PLN — all three need his SCREEN or EARS, nothing else
1. **10 uploads in one browser session** (`sc form-album`, `headless=False`, so
it holds the display): 8 missing OPAL v4 cuts + 2 CosmicFest. Tone and
deletes are already approved. Profile cookies date from 2026-09-02 — run
`sc browser-check` first.
2. **The fader question** (orbits 02–06 were at −8/−10 dB, now unity: a ~9 dB
shift on five orbits). One listen decides deliberate-vs-drift.
3. **The shared checkout is still on `claude/rig-streamline`**, so the running
midiviz is the OLD code. Switching is safe but is PLN's call (another
session may be live in that tree). Verified prep: the only two files that
would conflict are `tools/check-mix.py` and `tools/gig-log.py`, whose
working-tree content is already master's; both untracked files are
byte-identical to master's blobs; PLN's 5 `.tidal` edits carry cleanly.
## The long view (see session synthesis, same date)
* **North star = four deliverables**: {OPAL, COSMIC} × {club mix, album split}.
Nothing else has a finish line. No gig is scheduled; release is the only
shipping thread.
* **Biggest structural finding**: the bottleneck is not building, it is
**harvesting**. 48 private SC tracks, a shipped corpus dataviz nobody can
see, a built YouTube adapter never used to publish, `phare`/`sémaphore`
dormant. The release thread is one browser session from done.
* **Spine decision still open and now evidenced**: retire `mix/` in favour of
tidal-ears; only the crowd-mic recipe needs porting.
* Three test-suites-that-cannot-run found this week (`test_setlist.py`,
`test_sc_delete.py`, and `test_adversarial.py`'s 12 `fixture 'suite' not
found` errors — the last still OPEN).
---
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.
......@@ -1814,3 +1814,165 @@ suite 112 → 128.
**Deps.** Built on #20's `engine/stempack.py`; produced the data #21 browses. Unblocks #22
(the acapellas, instrumentals and ~27 demucs catches) with no new code.
## A3-b — The OPAL SoundCloud cuts: a stale generation, not a bad cut (2026-09-05)
**Description.** PLN: *"on soundcloud i see our private cuts of OPAL and they are
badly cut begin/end unaligbed we need to reupload and save their images tho wheres
out catalog at?"* Diagnose why the ear-verified v4 release sounds wrong on the
account, preserve the artwork before touching anything, and report where the
catalog stands. The board's standing theory was that `/take-five-drops` had
uploaded "24 s short" while every other track "matched to the second".
**Done.**
- `master sc pull --out ~/Work/Sound/Prod/sc_pull_2026-09-05 --artwork` — read-only,
token still live, no login. 132 tracks, 10 playlists, **101 artwork originals**.
Also the state recovery: no `sc_state.json` had ever been written.
- `armada/tide-table/sc_generation_lens.py` (`fe8ec16`) — roll call + whole-account
stray scan, duration-keyed, date-gated, plan-authoritative titles.
- `armada/tide-table/build_delete_manifest.py` (`bd3a680`) and
`master sc delete --manifest` in tidal-ears (`ae1ca0c`, merged `ab3285b`) — the
"explicit, separate call" `scwrite.py` had promised since August. Dry-run
default, identity verified in both directions, public refused, per-entry reason
required, batch capped before any API call, full receipt. **Dry run: 14
deletable, 0 refused**, audited independently. Nothing deleted — `--go` is PLN's.
- Catalog rebuilt (`f1fa484`): generated 73→**81 tracks**, 23→**25 gigs**, now
identical to the view, OPAL and CosmicFest present.
- OPAL `tracks.json` regenerated from v4 (www `5654047`) — was off by up to 21 s.
- `tools/ardour_session.py` + check-mix fix (`27f2351`); tree triage 39→5 paths
(`039ff57`, `71516e2`, `a14aee7`, `f5d3461`, `ec9c7d4`, `1b03cb0`); CLAUDE.md
path fix (`577767a`); log 040 + resume points (`33794e3`).
**Learnings.**
- **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 — 15 tracks — beside the 6
correct v4 ones, under junk slugs (`/opal26-wap`, `/12-mafia-12`,
`/ceci-nest-pas-une-bombe`).
- **The wrong cut wore the cover art.** 8 of the 14 stale uploads had artwork;
none of the 6 correct v4 tracks did. The superseded audio looked *finished* and
the correct audio looked like drafts. PLN's ear was reading the artwork. A
completeness signal attaches to whatever was last fussed over — usually the
older thing, because it had more time.
- **`/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. The board
had been carrying a plausible wrong cause for three weeks.
- **The tolerance was the bug.** SoundCloud's `full_duration` reproduces the
source **to the millisecond** (477.600 → 477.600), 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 — exactly how six stale uploads passed an August duration check.
- **Roll call cannot find a stray.** Checking each *expected* permalink
structurally cannot see a stale cut under a different slug; the whole-account
scan is what found 13 of the 14. A check that only looks where it expects a
thing can never discover that thing somewhere else.
- **A duration collision is not provenance.** `/au-revoir-lord-toyota` (2020) and
`/saria-demo` (2025) matched OPAL cuts to 50 ms by chance on a 132-track
account. Strays are now hard-gated on a date floor and the discards are printed.
A coincidence must never reach a delete manifest.
- **Refuse to re-render before measuring.** The obvious move on "the cuts are bad"
is to re-cut. All 14 v4 FLACs matched `segments_v4.json` to the millisecond
(CRIME's 2 s rest and REVOLUTION's reverb tail included), and the ear file's
mtime postdating the renders was the *signoff record* being written, not a late
boundary edit — checked specifically because it looked like a smoking gun.
- **Filenames are not authoritative for a permalink.** `Perfect <3` uploads to
`/perfect-3`, so a filename-derived slug reported two live tracks as NOT
UPLOADED. Titles must come from the release plan.
- **`AS_OF` was a hardcoded literal.** `build_catalog_view.py` re-stamped
`"2026-06-06"` on every rebuild forever. A generated artifact that lies about
its own freshness is worse than a stale one, because it defeats the check.
- **`/me`'s `track_count` counts PUBLIC tracks only** — 70 reported against 132
held. Any tool sizing a walk from it under-counts by 47%.
- **A generator compared by track number across a re-cut.** The Aug-12 set had 15
tracks and v4 has 14, so dropping *Desire* renumbered *REVOLUTION* 15→14 and the
manifest confidently reported "#14 Desire, 388.1s vs 205.6s" — REVOLUTION's
length. Key on identity, never on position, across generations.
- **`git push origin master` from a non-master HEAD is a silent no-op.** A
concurrent session moved this shared checkout to its own branch between two of
my commits; four landed there, and the push pushed the already-current local
`master` ref, printed nothing, exited 0. Recovered by worktree cherry-pick. The
worktree rule for shared repos has no orchestrator exemption.
- **Assembling a check revalidates it.** check-mix went from *unrunnable* straight
to *failing*: Tidal 07/08/10/12 at -inf dB in the saved session, the third
recurrence of that bug. A closed fader and a silent instrument are identical on
a post-fader meter.
**Deps.** Closes the diagnosis half of A3 and supersedes its "two known defects"
note. Blocks on PLN for `--go` and for `/desire`. Unblocks the 8 remaining uploads,
then OPAL's empty `audio:` field on the website and the catalog release links
behind it. `sc_generation_lens.py` is reusable and CosmicFest will need it.
## Tests — the suite that ran zero tests, and the phantom it hid (2026-09-05)
**Description.** Two test files were flagged during a pre-compact sweep:
`test_orphan_orbits.py` failing 16-rows-vs-13-expected, and
`test_lcxl3_display.py` collecting zero tests. Fix them properly — not by bumping
a number or adding a skip.
**Done.** `21f83b5` and `e8f7066`.
- `test_orphan_orbits.py`: 9 failures → 41/41. Cause was a **stale fixture**, not
a parser bug, confirmed by `git log --follow` plus an independent grep of the
`.tidal` files rather than by trusting the parser under test. `2376e43`
("d10 is the riser", 2026-08-01) added a d10 safe-riser idiom to `bombe_dj`,
`wap`, `you_my_sunshine`, `mafia_sans_serif` and `desire` *after*
`HAND_MEASURED` froze on 2026-07-28, while `piment_bresilien` went the other way
(d10→d9, PLN's own request, `71bb9bc`/`5e5a37a`). So the
`vague_de_crime→bombe_dj` orphan set legitimately shrank from `{6,10}` to `{6}`.
Setlist counts are now **derived** from the setlist file via the same pipeline
`check-tracks.sh` uses.
- `test_lcxl3_display.py`: 0 collected → **30** ordinary pytest tests. It was
plain-script style (module-level `check()` calls). Baseline correctness
established by running the script directly *before* converting.
- `test_setlist.py`: guarded its module-level `sys.exit()` under `__main__` and
added an assertion so pytest sees its checks.
- `tools/setlist.py`: added `_NOT_THE_SET_RE` (`sound ?check|line ?check|balance`)
matched against the section heading, skips announced on stderr.
- Suite: **no tests ran (INTERNALERROR) → 274 passed, 2 skipped, 0 failed.**
Canary `test_gig_log.py` 169 passed throughout.
**Learnings.**
- **`pytest tools/tests/` was running ZERO tests.** `test_setlist.py` ended in a
bare module-level `sys.exit()`, which pytest raises during **collection**, so
the whole directory aborted with an `INTERNALERROR` — while 273 tests passed
when invoked file-by-file. Every test in the repo was invisible to the obvious
command. **A collected count of 0 is a failure, never a pass**, and per-file
runs hide a collection abort completely.
- **A test nobody can see failing is worse than a test that does not exist**,
because its name reads as reassurance. `test_setlist.py` had been red for a
month on a check whose name is a complete bug report: *"a deliberation note is
not a track (the quand_on_decolle phantom)"*.
- **And the bug was real.** OPAL 2026's backlog section opens `## SOUNDCHECK /
-- Quand on decolle`, and `entries()` took every list item under the gig
heading — so **every tool reading the 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).
- **The right fix was the inverse of the tempting one.** `entries()` carries a
comment explaining why it refuses to whitelist which of PLN's prose headings
are *part of* the set — the first attempt died on
`## Livecoding Techno DNB Nu-jazz`. A **blocklist** naming the one phase that is
definitionally not a performance is small, stable and safe, where a whitelist of
set sections is a list of the headings he happened to write that day.
- **`backlog.md` was never wrong** and was not touched. A soundcheck genuinely
belongs in his set list; it just isn't in his set. Fix the parser, never the
data.
- **A frozen count in a fixture is a time bomb in a live corpus.** `== 13` rots
every time PLN writes a track. Derive the expectation, or the test punishes the
author for working.
- **Two bugs hid each other**, which is why both survived a month: the
uncollectable file concealed its own failure *and* everybody else's. Fixing the
harness immediately surfaced a month-old correctness bug at no extra cost.
- **Every fix was verified by breaking it.** Neutering `_NOT_THE_SET_RE` made
`test_backlog_setlist_checks_all_pass` go red naming the phantom;
wrong-valuing a `HAND_MEASURED` entry produced the exact mismatch; editing
`rec_line`'s `divmod(total, 3600)``3601` failed
`test_rec_line_formats_hmmss_past_an_hour` with `REC 1:02:02` vs `1:02:03`.
That is the only evidence that counts.
- **Restoring a deliberate break can eat the fix.** `git checkout -- tools/setlist.py`
reverted the uncommitted fix along with the sabotage. Commit before you break
things, or re-apply and re-verify — which is what happened here.
**Deps.** Fell out of the first pre-compact's R5. Unblocks nothing directly but
makes every future change verifiable in one command. Leaves one unverified
follow-up: anything consuming `setlist.entries()` **positionally** was shifted by
one for a month — `setlist_to_segments.py` is the suspect.
......@@ -9,7 +9,7 @@
"slug": "2023/mephisteuf",
"confirm": false,
"header": "## Live @MephisTeuf 😈‍💻😈",
"anchor_line": 710,
"anchor_line": 722,
"n_tracks": 6,
"n_resolved": 5,
"suspect_bleed": false,
......@@ -62,7 +62,7 @@
"slug": "2024/opal-festival-2024",
"confirm": false,
"header": "## Opal <3🦉",
"anchor_line": 1031,
"anchor_line": 1043,
"n_tracks": 14,
"n_resolved": 14,
"suspect_bleed": false,
......@@ -171,7 +171,7 @@
"slug": "2024/toplap-solstice-2024",
"confirm": false,
"header": "### TopLap Solstice Stream 2024",
"anchor_line": 1169,
"anchor_line": 1181,
"n_tracks": 4,
"n_resolved": 3,
"suspect_bleed": false,
......@@ -210,7 +210,7 @@
"slug": "2024/38c3-house-of-tea",
"confirm": false,
"header": "#### Day 2 - HOUSE OF Tea",
"anchor_line": 1194,
"anchor_line": 1206,
"n_tracks": 12,
"n_resolved": 11,
"suspect_bleed": false,
......@@ -305,7 +305,7 @@
"slug": "2024/38c3-toilet",
"confirm": false,
"header": "#### Day 3 - Hardcore in the Toilet Club",
"anchor_line": 1242,
"anchor_line": 1254,
"n_tracks": 4,
"n_resolved": 3,
"suspect_bleed": false,
......@@ -344,7 +344,7 @@
"slug": "2025/ensad",
"confirm": false,
"header": "# Set @ENSAD 2025",
"anchor_line": 1441,
"anchor_line": 1453,
"n_tracks": 9,
"n_resolved": 8,
"suspect_bleed": false,
......@@ -418,7 +418,7 @@
"slug": "2025/opal-festival-2025",
"confirm": false,
"header": "# OPAL 2025",
"anchor_line": 1632,
"anchor_line": 1644,
"n_tracks": 17,
"n_resolved": 11,
"suspect_bleed": false,
......@@ -548,7 +548,7 @@
"slug": "2025/39c3-house-of-tea",
"confirm": false,
"header": "# CCC 39c3 ALGORAVE <3",
"anchor_line": 1786,
"anchor_line": 1798,
"n_tracks": 14,
"n_resolved": 13,
"suspect_bleed": false,
......@@ -657,7 +657,7 @@
"slug": "2026/le-vortex",
"confirm": false,
"header": "# VORTEX26",
"anchor_line": 1816,
"anchor_line": 1828,
"n_tracks": 9,
"n_resolved": 8,
"suspect_bleed": false,
......@@ -733,7 +733,7 @@
"slug": "2024/algolia-fdlm",
"confirm": true,
"header": "## Algolia FDLM2024",
"anchor_line": 988,
"anchor_line": 1000,
"n_tracks": 26,
"n_resolved": 26,
"suspect_bleed": true,
......@@ -926,7 +926,7 @@
"slug": "2025/algorave-lyon",
"confirm": true,
"header": "⠀⠀⠀⠹⣟⠀⠀⠀ GZ 2025 LYON ⠀⣰⠏⠀⠀",
"anchor_line": 1550,
"anchor_line": 1562,
"n_tracks": 0,
"n_resolved": 0,
"suspect_bleed": false,
......@@ -936,7 +936,7 @@
"slug": "2026/ete-surprise",
"confirm": true,
"header": "## SURPRISE IT'S A DANCEFLOOR",
"anchor_line": 1831,
"anchor_line": 1843,
"n_tracks": 11,
"n_resolved": 8,
"suspect_bleed": false,
......
......@@ -27,24 +27,39 @@ CATALOG_VIEW = HERE / "catalog_view.json"
OUT = HERE / "backlog_setlists.json"
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);
# 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
# into the next densely-packed setlist (the backlog has no blank-gap between them).
# value = slug OR (slug, span) to bound a block that would otherwise bleed into
# 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 = {
1441: "2025/ensad", # "# Set @ENSAD 2025"
1816: "2026/le-vortex", # "# VORTEX26"
1031: "2024/opal-festival-2024", # "## Opal <3🦉" / OPAL 2024
1632: ("2025/opal-festival-2025", 1667), # "# OPAL 2025" (bleeds into Latin Heritage)
710: ("2023/mephisteuf", 735), # "## Live @MephisTeuf" (commented block = cut)
1194: "2024/38c3-house-of-tea", # "#### Day 2 - HOUSE OF Tea" (under 38C3)
1242: ("2024/38c3-toilet", 1253), # cross-check only (already has tracks.json)
1786: "2025/39c3-house-of-tea", # "# CCC 39c3 ALGORAVE ... Pour Un Thé Dansant"
1169: "2024/toplap-solstice-2024", # "### TopLap Solstice Stream 2024"
988: "confirm:2024/algolia-fdlm", # "## Algolia FDLM2024" — already has tracks.json
1550: "confirm:2025/algorave-lyon", # "GZ 2025 LYON ALGORAVE" then "# LABENNE LIVE" (?)
1831: "confirm:2026/ete-surprise", # "## SURPRISE IT'S A DANCEFLOOR" — slug guess
'# Set @ENSAD 2025': "2025/ensad",
'# VORTEX26': "2026/le-vortex",
'## Opal <3🦉': "2024/opal-festival-2024",
'# OPAL 2025': ("2025/opal-festival-2025", 35), # bleeds into Latin Heritage
'## Live @MephisTeuf 😈\u200d💻😈': ("2023/mephisteuf", 25), # commented block = cut
'#### Day 2 - HOUSE OF Tea': "2024/38c3-house-of-tea", # (under 38C3)
'#### Day 3 - Hardcore in the Toilet Club': ("2024/38c3-toilet", 11), # cross-check only (already has tracks.json)
'# CCC 39c3 ALGORAVE <3': "2025/39c3-house-of-tea", # "... Pour Un Thé Dansant"
'### TopLap Solstice Stream 2024': "2024/toplap-solstice-2024",
'## Algolia FDLM2024': "confirm:2024/algolia-fdlm", # already has tracks.json
' ⠀⠀⠀⠹⣟⠀⠀⠀ GZ 2025 LYON ⠀⣰⠏⠀⠀': "confirm:2025/algorave-lyon", # ASCII banner, no real heading nearby
"## 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,
# 2024/38c3-chaos-music-club, 2025/39c3-toilet-rave, 2025/toplap-fromscratch-2025. The
......@@ -136,19 +151,37 @@ def clean_track_line(raw: str):
return body, bpm, transition, True
def _slug_end(val):
"""ANCHORS value → (slug, end_line|None)."""
def _slug_span(val):
"""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)
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,
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
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."""
anchor_lines = sorted(k - 1 for k in ANCHORS)
hard_end = next((a for a in anchor_lines if a > start_idx), len(lines))
hard_end = next((a for a in anchor_idxs if a > start_idx), len(lines))
if end_line is not None:
hard_end = min(hard_end, end_line - 1)
out, items, blanks = [], 0, 0
......@@ -174,16 +207,19 @@ _LABEL = re.compile(
def parse(lines, name_idx):
resolved = resolve_anchors(lines)
anchor_idxs = sorted(k - 1 for k in resolved)
gigs = {}
for ln1, val in sorted(ANCHORS.items()):
slug, end_line = _slug_end(val)
for ln1, val in sorted(resolved.items()):
slug, span = _slug_span(val)
end_line = ln1 + span if span is not None else None
confirm = slug.startswith("confirm:")
real_slug = slug.split(":", 1)[1] if confirm else slug
i = ln1 - 1
header = lines[i].strip()
section = None
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)
if hm: # subsection label
section = re.sub(r"[^\w\s'-]", "", deaccent(hm.group(1))).strip() or None
......
#!/usr/bin/env python3
"""build_delete_manifest — turn a generation verdict into a delete allowlist.
## Why a manifest and not a pattern
SoundCloud will not replace the audio behind an existing permalink — that is a
paid feature — and our uploader is idempotent by permalink, so it SKIPS a
permalink that already exists rather than replacing it. A stale upload is
therefore permanent until somebody deletes it, and the whole Aug-12 first cut
of OPAL-26 is currently sitting on the account beside the six correct v4
uploads under ad-hoc slugs.
Those slugs are the reason this is a list and not a rule. The stale first cut
lives at `/opal26-wap`, `/desire`, `/12-mafia-12`; the good v4 cut lives at
`/wap` and `/mafia`. No pattern permissive enough to reach the first set fails
to reach the second. So `tidal-ears master sc delete` takes an explicit
manifest naming every track by **permalink AND sc_id**, verifies both against
the live account, and refuses on any disagreement. This script writes that
manifest, and it is the only thing standing between `sc_generation_lens`'s
verdict and a destructive call — so it fails loudly rather than emitting a
questionable entry.
## What gets in, and what can never
IN: verdicts whose verdict starts with `STALE` or `ORPHAN` — the right
permalink holding the wrong cut — and every `strays[]` entry, an older
cut of this set living under some other permalink.
OUT: anything with verdict `OK` (that IS the ear-verified cut),
`NOT UPLOADED` (there is nothing there to delete), anything in
`coincidences_discarded[]`, and anything without an `sc_id`.
`coincidences_discarded` is the one that earns its assertion. The lens finds
strays by duration, and on a 132-track account two unrelated tracks will
occasionally share a length to 50 ms: `/au-revoir-lord-toyota` (2020, and
PUBLIC) and `/saria-demo` (2025) both matched an OPAL render. The lens already
drops anything uploaded before the oldest render on disk, because an upload
that predates the render cannot be a cut of it. This script re-checks that
decision against the file it reads instead of trusting the file's own bucket
names — one parser per concept, but two chances to notice a mistake, because
the cost here is asymmetric and permanent.
## Provenance
Every entry carries a `reason` citing the evidence (which generation matched,
the duration gap against the cut that supersedes it), the artwork pulled from
that track, and the local render the upload came from — `reason` is required by
the delete tool and refused if absent, because this is PLN's archive and a
deletion with no recorded why is a hole nobody can later explain.
## Usage
./build_delete_manifest.py [VERDICTS.json] [-o MANIFEST.json]
[--pull DIR] [--renders DIR] [--account NAME]
"""
from __future__ import annotations
import argparse
import json
import re
import sys
import unicodedata
from datetime import date
from pathlib import Path
PROD = Path("/home/pln/Work/Sound/Prod")
DEFAULT_VERDICTS = PROD / "sc_pull_2026-09-05" / "opal26_generation_verdicts.json"
DEFAULT_OUT = PROD / "sc_pull_2026-09-05" / "opal26_delete_manifest.json"
DEFAULT_RENDERS = PROD / "Opal26_master"
AUDIO_EXT = {".flac", ".wav", ".mp3", ".m4a", ".aiff"}
SET_SLUG = "opal-festival-2026"
"""The gig, as the site's canonical content names it.
`../../Web/www/next/content/lives/2026/opal-festival-2026.md` is where the gig
metadata lives; nothing here invents any of it. The slug is quoted in each
reason only so a reader of the receipt knows which set is meant.
"""
def slugify(title: str) -> str:
"""`scform.permalink_of`, mirrored — the key that survives a renumbering.
Used here to line a stale upload up against its counterpart in the newest
cut BY TITLE. Track numbers cannot do that job across generations: the
Aug-12 cut had fifteen tracks, the v4 cut has fourteen, and dropping
Desire renumbered REVOLUTION from 15 to 14. Comparing by number therefore
printed "#14 Desire, 388.1s vs v4 205.6s" — 205.6s is REVOLUTION's length.
A measurement is a comparison, and an unnamed reference is how a
provenance string ends up citing the wrong track (`feedback_name_the_reference`).
"""
s = unicodedata.normalize("NFKD", title or "")
s = s.encode("ascii", "ignore").decode("ascii").lower()
return re.sub(r"[^a-z0-9]+", "-", s).strip("-")
def artwork_for(pull: Path, sc_id, title: str) -> str | None:
"""The pulled original artwork for a track, if the pull got one.
Named `<id>_<slug>.jpg` by `sc pull`, but matched on the id alone: the
slug half is derived from a title that may since have changed, and the id
half is the part that cannot.
"""
d = pull / "artwork"
if not d.is_dir():
return None
hits = sorted(d.glob(f"{sc_id}_*"))
return str(hits[0]) if hits else None
def render_for(renders: Path, generation: str, n: int) -> str | None:
"""The local file for track `n` of a render generation.
This is what makes the deletion reversible in fact rather than only in
principle: the Aug-12 cut is still on disk as
`Opal26_master/tracks/07 - Piment Bresilien.flac`, so removing its upload
loses a URL, not a recording. Matched by the leading track number, the
same key `sc_generation_lens` indexes a generation by.
"""
d = renders / generation
if not d.is_dir():
return None
for f in sorted(d.iterdir()):
if f.suffix.lower() not in AUDIO_EXT or not f.is_file():
continue
m = re.match(r"\s*(\d{1,2})\s*[-_. ]", f.name)
if m and int(m.group(1)) == n:
return str(f)
return None
def reason_for(kind: str, *, title: str, n: int, generation: str,
newest: str, sc_dur, other_dur, created: str, pull: Path,
art: str | None) -> str:
"""One sentence of evidence per entry, in the delete tool's own voice.
Cites what was measured (which generation the upload's duration matches,
and by how much it differs from the cut that supersedes it), when it was
uploaded, what supersedes it, and where the bytes were kept.
"""
if isinstance(sc_dur, (int, float)) and isinstance(other_dur, (int, float)):
gap = f", {sc_dur:.1f}s vs {other_dur:.1f}s in the newest cut"
elif isinstance(sc_dur, (int, float)):
gap = f", {sc_dur:.1f}s, and this track is not in the newest cut at all"
else:
gap = ""
where = "stray permalink" if kind == "stray" else "correct permalink"
kept = (f"artwork preserved in {pull.name}/artwork/"
if art else "no artwork on this upload; nothing lost")
return (f"{created} cut of {SET_SLUG} (#{n} {title}{gap}) on a {where}; "
f"matches render generation `{generation}`, superseded by the "
f"ear-verified `{newest}`; {kept}")
def build(v: dict, pull: Path, renders: Path, account: str) -> tuple[list, dict]:
"""Verdict document -> manifest entries. Pure apart from the disk probes."""
newest = v.get("newest_generation") or "newest"
floor = v.get("stray_floor_date") or ""
# Keyed by title-slug, NOT by track number: see `slugify`.
newest_dur = {slugify(e.get("title") or ""): e.get("expected_duration_s")
for e in v.get("verdicts", [])}
# Everything the manifest must never contain, collected as ids so the
# assertions below can be stated as set arithmetic rather than as trust.
forbidden: dict[int, str] = {}
for c in v.get("coincidences_discarded", []):
if c.get("sc_id"):
forbidden[c["sc_id"]] = (
f"duration coincidence, uploaded {str(c.get('sc_created_at'))[:10]} "
f"before the oldest render ({floor})")
for e in v.get("verdicts", []):
if e.get("verdict") == "OK" and e.get("sc_id"):
forbidden[e["sc_id"]] = "verdict OK — this is the cut to keep"
entries = []
for e in v.get("verdicts", []):
if not str(e.get("verdict", "")).startswith(("STALE", "ORPHAN")):
continue
if not e.get("sc_id"):
print(f" ! {e.get('verdict')} at /{e.get('permalink')} has no "
f"sc_id — skipped, there is nothing to address", file=sys.stderr)
continue
gen = (e["verdict"].split(":", 1) + [""])[1] or "unknown"
art = artwork_for(pull, e["sc_id"], e.get("title") or "")
entries.append({
"permalink": e["permalink"],
"sc_id": e["sc_id"],
"reason": reason_for("stale", title=e.get("title") or "",
n=e["n"], generation=gen, newest=newest,
sc_dur=e.get("sc_duration_s"),
other_dur=newest_dur.get(slugify(e.get("title") or "")),
created=str(e.get("sc_created_at"))[:10],
pull=pull, art=art),
"title": e.get("title"),
"sc_url": e.get("sc_url"),
"sc_created_at": e.get("sc_created_at"),
"sc_duration_s": e.get("sc_duration_s"),
"sc_public": e.get("sc_public"),
"verdict": e["verdict"],
"source_generation": gen,
"source_track_n": e["n"],
"artwork_archived": art,
"local_source": render_for(renders, gen, e["n"]),
})
for s in v.get("strays", []):
if not s.get("sc_id"):
print(f" ! stray /{s.get('permalink')} has no sc_id — skipped",
file=sys.stderr)
continue
gen = s.get("matches_generation") or "unknown"
n = s.get("matches_track")
art = artwork_for(pull, s["sc_id"], s.get("matches_title") or "")
entries.append({
"permalink": s["permalink"],
"sc_id": s["sc_id"],
"reason": reason_for("stray", title=s.get("matches_title") or "",
n=n, generation=gen, newest=newest,
sc_dur=s.get("sc_duration_s"),
other_dur=newest_dur.get(slugify(s.get("matches_title") or "")),
created=str(s.get("sc_created_at"))[:10],
pull=pull, art=art),
"title": s.get("matches_title"),
"sc_url": s.get("sc_url"),
"sc_created_at": s.get("sc_created_at"),
"sc_duration_s": s.get("sc_duration_s"),
"sc_public": s.get("sc_public"),
"verdict": f"STRAY:{gen}",
"source_generation": gen,
"source_track_n": n,
"artwork_archived": art,
"local_source": render_for(renders, gen, n) if n else None,
})
assert_clean(entries, forbidden, floor)
return entries, {
"account": account,
"set": SET_SLUG,
"generated_by": "armada/tide-table/build_delete_manifest.py",
"generated_at": date.today().isoformat(),
"source": str(v.get("_source_path") or ""),
"source_pulled_at": v.get("pulled_at"),
"newest_generation": newest,
"tolerance_s": v.get("tolerance_s"),
"stray_floor_date": floor,
"artwork_archive": str(pull / "artwork"),
"render_archive": str(renders),
}
def assert_clean(entries: list, forbidden: dict[int, str], floor: str) -> None:
"""Fail loudly rather than emit a manifest that could delete the wrong thing.
These are assertions and not warnings because the output feeds a
destructive call. A warning printed above 14 lines of table is a warning
nobody reads; a non-zero exit with no file written cannot be missed.
"""
bad = []
seen: dict = {}
for e in entries:
if e["sc_id"] in forbidden:
bad.append(f"/{e['permalink']} (sc_id {e['sc_id']}) is excluded: "
f"{forbidden[e['sc_id']]}")
if not e.get("sc_id"):
bad.append(f"/{e['permalink']} has no sc_id")
if not (e.get("reason") or "").strip():
bad.append(f"/{e['permalink']} has no reason")
if e.get("sc_public"):
bad.append(f"/{e['permalink']} is PUBLIC — a public track must not "
f"reach a delete manifest without a human saying so")
# An entry created before the oldest render on disk cannot be a cut of
# this set, whichever list the verdict file filed it under.
created = str(e.get("sc_created_at") or "")[:10]
if floor and created and created < floor:
bad.append(f"/{e['permalink']} was uploaded {created}, before the "
f"oldest render ({floor}) — it cannot be a cut of this set")
for key in ("permalink", "sc_id"):
k = (key, e[key])
if k in seen:
bad.append(f"duplicate {key} {e[key]!r}")
seen[k] = True
if bad:
print("REFUSING to write the manifest:", file=sys.stderr)
for b in bad:
print(f" ! {b}", file=sys.stderr)
raise SystemExit(f"{len(bad)} problem(s); no file written")
def main() -> int:
ap = argparse.ArgumentParser(
description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("verdicts", nargs="?", type=Path, default=DEFAULT_VERDICTS,
help=f"sc_generation_lens output (default: {DEFAULT_VERDICTS})")
ap.add_argument("-o", "--out", type=Path, default=DEFAULT_OUT)
ap.add_argument("--pull", type=Path, default=None,
help="the `sc pull` directory holding the archived artwork "
"(default: the verdict file's own directory)")
ap.add_argument("--renders", type=Path, default=DEFAULT_RENDERS,
help="directory holding the tracks*/ render generations")
ap.add_argument("--account", default="parvagues",
help="the SoundCloud account the manifest is FOR; the "
"delete tool refuses to run it against another")
a = ap.parse_args()
if not a.verdicts.exists():
raise SystemExit(f"not found: {a.verdicts}")
v = json.loads(a.verdicts.read_text())
v["_source_path"] = str(a.verdicts)
pull = a.pull or a.verdicts.parent
entries, meta = build(v, pull, a.renders, a.account)
print(f"{a.verdicts.name}: {len(v.get('verdicts', []))} roll-call verdicts, "
f"{len(v.get('strays', []))} strays, "
f"{len(v.get('coincidences_discarded', []))} coincidences dropped")
print(f"newest generation: {meta['newest_generation']} · "
f"stray floor: {meta['stray_floor_date']}\n")
print(f"{'permalink':38} {'sc_id':>10} {'dur':>8} {'uploaded':10} "
f"{'gen':14} art local render")
for e in entries:
print(f"/{e['permalink'][:36]:37} {e['sc_id']:>10} "
f"{(e['sc_duration_s'] or 0):8.1f} {str(e['sc_created_at'])[:10]:10} "
f"{e['source_generation'][:14]:14} "
f"{'yes' if e['artwork_archived'] else ' - ':3} "
f"{Path(e['local_source']).name if e['local_source'] else 'MISSING'}")
no_render = [e for e in entries if not e["local_source"]]
if no_render:
print(f"\n ! {len(no_render)} entr"
f"{'y' if len(no_render) == 1 else 'ies'} have no local render — "
f"deleting those loses the only copy:")
for e in no_render:
print(f" /{e['permalink']}")
a.out.parent.mkdir(parents=True, exist_ok=True)
a.out.write_text(json.dumps({**meta, "tracks": entries},
indent=2, ensure_ascii=False) + "\n")
print(f"\n{len(entries)} entries -> {a.out}")
print(f"\nDry-run it before anyone types --go:\n"
f" cd ~/Work/Sound/tidal-ears && .venv/bin/python -m "
f"tidal_ears.master sc delete --manifest {a.out}")
return 0
if __name__ == "__main__":
sys.exit(main())
......@@ -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 order played, and `segments_v4.json` carries `perf_track` alongside the
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")
sv = Path("/home/pln/Work/Sound/Prod/Opal26_master/segments_v4.json")
......
......@@ -75,9 +75,27 @@ def test_eda_coverage_is_honest(view):
# ── 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):
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_conflict"] <= 4 # was 2 — alarm if conflicts spike
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 = [
# click on an already-running lens silently did nothing.
{"key": "midimon", "name": "MIDI Monitor", "blurb": "live MIDI as glyph-rain — the surface, watched",
"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
......@@ -391,6 +396,17 @@ def _sweep_stale_pending(spec):
except OSError as 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):
"""Spawn the launcher detached. Returns {ok, status, msg, url?}."""
......@@ -444,6 +460,7 @@ def launch(key):
argv = _argv(spec)
if not argv:
return {"ok": False, "status": "no-terminal", "msg": "no terminal emulator found"}
_clear_latch(spec)
try:
proc = subprocess.Popen(argv, cwd=str(TIDAL), start_new_session=True,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
......
......@@ -362,6 +362,33 @@ def _install_signals() -> None:
pass
# The latch rig_units.ensure() reads. Duplicated as four lines here rather
# than imported: midiviz must run standalone (`python3 midiviz.py`) from a
# checkout with no rig on the box at all, and importing tools/rig_units.py
# from tools/bridge/ would make the lens depend on the supervisor it is only
# leaving a note for. The path is the contract; rig_units.latch_path() is the
# same expression and the test asserts they agree.
UNIT_NAME = "midiviz"
def latch_close_path():
rt = os.environ.get("XDG_RUNTIME_DIR") or f"/run/user/{os.getuid()}"
return os.path.join(rt, "parvagues", f"{UNIT_NAME}.closed")
def _latch_close():
"""Record that the human closed this, best-effort and never fatal."""
try:
path = latch_close_path()
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w") as fh:
fh.write(time.strftime("%Y-%m-%dT%H:%M:%S%z") + "\n")
except OSError as e:
print(f"midiviz: could not write the close latch ({e}); "
f"the rig may start me again on its next converge",
file=sys.stderr)
def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0,
pinned_port: str | None = None, watch: bool = False):
QtCore, QtGui, QtWidgets = _qt()
......@@ -374,6 +401,15 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0,
self.setWindowFlags(Qt.WindowType.Window
| Qt.WindowType.FramelessWindowHint
| Qt.WindowType.WindowStaysOnTopHint)
# On-top starts on (it is a lens you glance at over the editor) but
# is now a RUNTIME choice, because PLN asked for the pin and the
# close to be separate controls. All-desktops is deliberately NOT
# in here: it is a KWin rule keyed on the app id, it is
# unconditional, and toggling the pin must not disturb it.
self.on_top = True
self._user_closed = False
self._chrome_hot = False
self._t_pad = 4 # vertical slack on the hit targets
self.setAttribute(Qt.WidgetAttribute.WA_OpaquePaintEvent, True)
self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
self.setCursor(Qt.CursorShape.SizeAllCursor)
......@@ -656,6 +692,7 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0,
pulse = 0.5 + 0.5 * math.sin((now - self.t0) * 0.55)
self._paint_chrome(p, w, h, pulse)
self._paint_header(p, now, pulse)
self._paint_controls(p)
self._paint_matrix(p, now, pulse)
self._paint_gutter(p, now, pulse)
self._paint_stream(p, now)
......@@ -674,6 +711,56 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0,
p.drawLine(cx, cy, cx + dx * n, cy)
p.drawLine(cx, cy, cx, cy + dy * n)
# ── the two controls, top right ────────────────────────────────────
#
# PLN: "needs basic menu or at least top right X to close ... maybe X
# and a [x] where x goes and comes and is the 'stick above' feature".
# So: no menu. Two glyph-sized targets in the header's right edge, in
# the same monospace micro font as everything else -- the pin reads as
# a checkbox whose x is literally the state, and the close is an X.
#
# Geometry lives in one place and both the painter and the hit-test
# call it, because a control drawn at one position and clickable at
# another is worse than no control at all.
MIN_TARGET = 18 # px: a trackpad-sized hit target
# Laid out from the RIGHT EDGE inwards, targets first and glyphs
# centred inside them -- not the other way round. Sizing the target to
# the glyph is what produced an 8px-wide X: fine to look at, unhittable
# mid-set, and at 0.6 scale the two targets then had to overlap or
# shrink below usable. Deciding the targets first makes "always
# disjoint and never smaller than MIN_TARGET" true by construction at
# every zoom level, and the text simply follows.
def _ctrl_rects(self):
"""(pin_rect, close_rect), disjoint and hittable at any scale."""
h = max(self.hdr_h + 2 * self._t_pad, self.MIN_TARGET)
y = self.hdr_y + (self.hdr_h - h) // 2
cw = max(self.MIN_TARGET, round(self.uw * 2))
pw = max(self.MIN_TARGET, round(self.uw * 3))
close = QtCore.QRect(self.gut_x - cw, y, cw, h)
pin = QtCore.QRect(close.left() - 2 - pw, y, pw, h)
return pin, close
def _ctrl_left(self):
"""Where the controls begin — the sparkline must stop here."""
return self._ctrl_rects()[0].left()
def _paint_controls(self, p):
p.setFont(self.f_micro)
# Dim like the CC reference layer: findable, never competing with
# the values the eye tracks while playing. Brighter under the
# pointer, which is the only affordance a frameless window has.
p.setPen(self.chrome[12 if self._chrome_hot else 6])
pin, close = self._ctrl_rects()
base = round(self.hdr_y + self.u_asc)
pin_txt = "[x]" if self.on_top else "[ ]"
p.drawText(pin.left() + max(0, (pin.width()
- round(self.uw * 3)) // 2),
base, pin_txt)
p.drawText(close.left() + max(0, (close.width()
- round(self.uw)) // 2),
base, "X")
def _paint_header(self, p, now, pulse):
p.setFont(self.f_micro)
lv = 5 + int(4 * pulse) if now - self.last_event_t > IDLE_AFTER else 12
......@@ -695,7 +782,9 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0,
# sparkline of events/100 ms, most recent at the right
hx = self.pad + round(self.uw * (len(head) + 1))
bw = max(1, round(self.scale))
avail = max(4, int((self.gut_x - hx) / (bw + 1)))
# stop short of the chrome, or the bars draw under the controls
right = self._ctrl_left() - round(self.uw)
avail = max(4, int(max(0, right - hx) / (bw + 1)))
bars = list(self.hist)[-avail:]
top = self.hdr_y
bot = self.hdr_y + self.hdr_h - 1
......@@ -877,7 +966,10 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0,
def keyPressEvent(self, e):
k = e.key()
if k in (Qt.Key.Key_Q, Qt.Key.Key_Escape):
self._user_closed = True
self.close()
elif k == Qt.Key.Key_T:
self._set_on_top(not self.on_top)
elif k == Qt.Key.Key_P:
self.paused = not self.paused
elif k == Qt.Key.Key_C:
......@@ -900,8 +992,30 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0,
self._apply_scale()
self.update()
def _set_on_top(self, on: bool):
self.on_top = on
# Qt requires the window be re-shown after a flag change for the
# compositor to be told. It does not touch the KWin all-desktops
# rule, which is keyed on the app id and stays in force.
self.setWindowFlag(Qt.WindowType.WindowStaysOnTopHint, on)
self.show()
self.update()
def mousePressEvent(self, e):
if e.button() == Qt.MouseButton.LeftButton:
# Controls win over the drag. The whole surface is a drag
# handle (frameless window, no titlebar), so without this the
# X would only ever move the window.
pin, close = self._ctrl_rects()
pos = e.position().toPoint()
if close.contains(pos):
self._user_closed = True
self.close()
return
if pin.contains(pos):
self._set_on_top(not self.on_top)
self._drag_from = None
return
# Wayland forbids a client positioning itself; startSystemMove is
# the compositor-blessed path. X11 falls back to a manual move.
wh = self.windowHandle()
......@@ -911,6 +1025,16 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0,
self._drag_from = e.globalPosition().toPoint() - self.pos()
def mouseMoveEvent(self, e):
pin, close = self._ctrl_rects()
pos = e.position().toPoint()
hot = pin.contains(pos) or close.contains(pos)
if hot != self._chrome_hot:
self._chrome_hot = hot
# An arrow over the controls, the move cursor everywhere else:
# the cursor is the only hint a frameless window can give.
self.setCursor(Qt.CursorShape.ArrowCursor if hot
else Qt.CursorShape.SizeAllCursor)
self.update()
if self._drag_from is not None:
self.move(e.globalPosition().toPoint() - self._drag_from)
......@@ -923,6 +1047,18 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0,
self.rebind_timer.stop()
if self.reader:
self.reader.close()
# "if i close i wanna close it". A clean exit is not a failure, so
# systemd will not restart us -- but rig_units.ensure() starts
# every non-manual unit that is not active, and gig-up and the
# Bridge watcher both converge, so the window came straight back.
# Leave a latch ensure() honours. $XDG_RUNTIME_DIR means it dies
# at logout, which is the right lifetime: closed for this session,
# present again next login, nothing to remember to undo.
#
# Only for a close the HUMAN asked for. A compositor teardown at
# logout must not be mistaken for an opinion.
if self._user_closed:
_latch_close()
e.accept()
return MidiViz()
......
......@@ -132,3 +132,131 @@ def test_the_real_parser_feeds_the_real_ingest_path():
# every one of the 48 authored grid cells is covered
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"
# ── 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
import xml.etree.ElementTree as ET
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"
# 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
# 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
# the Ardour process's own argv and only fall back to a guess if that fails.
# reading the wrong mixer is worse than no auditor, so resolve the RUNNING session
# 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:
"""The .ardour file the live Ardour process was launched with (argv), or None.
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.
"""The RUNNING Ardour's `.ardour` session file, or None — see
tools/ardour_session.py for the evidence ladder (argv > cwd > open fds).
"""
try:
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
return ardour_session.resolve_session_file()
def default_session() -> Path:
......
......@@ -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
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
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
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
......@@ -131,6 +132,9 @@ from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent / "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(
"GIG_LOG_DIR", os.path.expanduser("~/.local/share/parvagues/gig-log")))
......@@ -714,89 +718,22 @@ class RecPass:
# ---- session resolution: argv > cwd > open fds -----------------------------
#
# WHY THREE RUNGS (2026-09-05, live-rig verification): the first version of
# this lens copied check-mix.py's running_session() outright — argv only —
# and it MISSED a real, currently-running Ardour on this machine, because it
# had been opened from the GUI's recent-sessions list rather than
# `ardour Foo.ardour`, so its argv was bare (`ardour-9.7.0` and nothing
# 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
# The ladder itself (WHY three rungs, what each one trusts and why) now
# lives in tools/ardour_session.py — ONE parser for "which session is
# Ardour running", shared with check-mix.py (feedback_one_parser_per_concept).
# FilesLens below is a thin wrapper: it owns the per-instance caching/logging
# state, the ladder module owns the evidence-gathering.
class FilesLens:
""""Is Ardour recording" answered from the one place that can't lie: bytes
landing on disk under interchange/<session>/audiofiles/.
Session resolution climbs the argv > cwd > open-fds ladder above (see
that block's WHY for the 2026-09-05 live-rig gap that made this three
rungs instead of one). It is duplicated from, not imported from,
check-mix.py — that file is a script, not a library, and this one stays
stdlib-only either way — so keep them in sync if either changes, and
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).
Session resolution is tools/ardour_session.py's argv > cwd > open-fds
ladder (see that module's docstring for the 2026-09-05 live-rig gap that
made this three rungs instead of one) — the exact same resolver
check-mix.py calls, so the two tools can never disagree on which session
is live.
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
......@@ -817,20 +754,7 @@ class FilesLens:
@staticmethod
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
return ardour_session.ardour_pids(proc_root)
@classmethod
def resolve_session_file(cls, proc_root: Path = Path("/proc"),
......@@ -840,44 +764,13 @@ class FilesLens:
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 cls._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
return ardour_session.resolve_session_file(proc_root, log_ambiguity)
@classmethod
def resolve_audiofiles_dir(cls, proc_root: Path = Path("/proc"),
log_ambiguity=None) -> Path | None:
"""<session>/interchange/<session name>/audiofiles/, or None."""
p = cls.resolve_session_file(proc_root, log_ambiguity)
return (p.parent / "interchange" / p.stem / "audiofiles") if p else None
return ardour_session.resolve_audiofiles_dir(proc_root, log_ambiguity)
def _log(self, reason: str | None) -> None:
if reason == self._logged_reason:
......
......@@ -44,8 +44,10 @@ accident. `--status` spells it out rather than printing systemd's word.
"""
from __future__ import annotations
import os
import argparse
import subprocess
from pathlib import Path
import sys
# unit, label, process that proves it really works (None = the unit is the whole
......@@ -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:
"""Enable per policy AND start. The half gig-up.sh never did."""
st = states()
......@@ -178,6 +219,11 @@ def ensure(apply: bool) -> int:
# lcxl3-driver, so starting it here would KILL the LCXL3 translation
# mid-bring-up while printing nothing but green.
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"]))
if not todo:
print(f"rig: all {len(UNITS)} units enabled-per-policy and active — nothing to do")
......
......@@ -101,6 +101,21 @@ class SetlistError(RuntimeError):
# real track to a typo'd marker.
_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 —
# "[lightT][120] Vague de CRIME" spends 2, "[129] Desire [TODO BASS ETC!]" spends 4.
_MAX_DECORATION = 4
......@@ -230,6 +245,11 @@ def entries(gig: str = GIG, backlog: pathlib.Path | None = None) -> list[Entry]:
if _NOTE_RE.match(raw):
print(f"setlist: note, not a track — skipped: {raw}", file=sys.stderr)
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))
name = BRACKET.sub(" ", raw)
bpm = next((int(BPM.match(t).group(1)) for t in tags if BPM.match(t)), None)
......
#!/usr/bin/env python3
"""What can be checked about a screen without looking at it.
The display protocol has NO readback — `configure_display` and `set_text` are
......@@ -7,13 +6,15 @@ a mistake would be silent AND invisible: the bytes on the wire, and the text we
chose to put in them. The evening this fixes was lost to a wrong TARGET, not a
wrong byte, and a wrong target looks exactly like a working driver from the host
side — hence a test that pins the target numbers against the guide's own table.
Run: python3 tools/tests/test_lcxl3_display.py (silence + exit 0 = pass)
"""
from __future__ import annotations
import importlib.util
import pathlib
import sys
import pytest
TOOLS = pathlib.Path(__file__).resolve().parent.parent
sys.path.insert(0, str(TOOLS))
......@@ -25,12 +26,7 @@ _spec = importlib.util.spec_from_file_location("drv", TOOLS / "lcxl3-driver.py")
drv = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(drv)
FAILED = []
def check(cond, msg):
if not cond:
FAILED.append(msg)
NOW = 1_000_000.0
class FakeOut:
......@@ -53,146 +49,243 @@ def fake_surface():
return s
# ---- the guide's target table, quoted in lcxl3.py, pinned here --------------
# "05h (5) - 24h (36): Temporary display for Analogue controls (same as CC
# indices, 05h (5) - 0Ch (12): Faders, 0Dh (13) - 24h (36): Encoders)"
check(L.TGT_CONTROL_FIRST == 0x05, "per-control target range must start at 05h")
check(L.TGT_CONTROL_LAST == 0x24, "per-control target range must end at 24h")
check(all(L.is_analogue(i) for i in L.FADERS), "all 8 faders own a display target")
check(all(L.is_analogue(i) for i in L.ENCODERS), "all 24 encoders own one")
check(not any(L.is_analogue(i) for i in L.BUTTONS),
"buttons own NO per-control target — that is why they never lost the screen")
check(L.TGT_CONTROL_LAST + 1 == min(L.BUTTONS),
"the analogue targets must end exactly where the buttons begin")
# ---- config byte: bits 5+6 are the native display's on-switch ---------------
# "Bit 6: Allow ... automatically on Change (default: Set).
# Bit 5: Allow ... automatically on Touch (default: Set)."
check(L.display_config(4, True, True) == 0x64, "arr 4 + both auto bits = 0x64")
check(L.display_config(4, False, False) == 0x04, "arr 4, natives off = 0x04")
check(L.display_config(2, False, False) == 0x02,
"0x02 is what the OLD code sent to 0x36: arrangement 2, autos irrelevant "
"there — it never touched the per-control targets at all")
check(L.display_config(1, True, False) == 0x41, "bit 6 only = 0x41")
# The arrangement must survive as bits 0-4 and never collide with the auto bits.
check(all(L.display_config(a) & 0x1F == a for a in range(32)),
"arrangement occupies bits 0-4 intact")
# ---- label_control puts the right bytes on the wire ------------------------
s = fake_surface()
s.label_control(0x18, "d4 crushbus") # encoder r2c4
data = [list(m.data) for m in s.out.sent]
check(len(data) == 2, "label_control sends exactly configure + set_text")
check(data[0] == [0x00, 0x20, 0x29, 0x02, 0x15, 0x04, 0x18, 0x64],
f"configure bytes wrong: {data[0]}")
check(data[1][:8] == [0x00, 0x20, 0x29, 0x02, 0x15, 0x06, 0x18, 0x00],
f"set_text header wrong (target 0x18, field 0): {data[1][:8]}")
check(bytes(data[1][8:]).decode() == "d4 crushbus", "the name must arrive verbatim")
# The DJF path: same target, autos CLEARED so our own overlay wins uncontested.
s = fake_surface()
s.label_control(0x1D, "DJF percs", native=False)
check(list(s.out.sent[0].data)[-1] == 0x04,
"native=False must clear bits 5+6 (config 0x04, arrangement still non-zero "
"because 0 would CANCEL the display)")
# A button has no target and must be refused loudly, not sent into the void.
try:
fake_surface().label_control(0x25, "gate")
FAILED.append("label_control(button) must raise, not silently no-op")
except ValueError:
pass
# ---- set_text must stop eating the reassigned control codes ----------------
s = fake_surface()
s.set_text(L.TGT_TEMPORARY, 2, lang.ZERO_FRAMES[0]) # hearts around a zero
body = bytes(list(s.out.sent[0].data)[8:])
check(body.count(0x1E) == 2,
f"the two hearts (1Eh) must survive the ASCII filter, got {body!r}")
# ---- the labels themselves -------------------------------------------------
# Faders carry no ^NN at all — they are Ardour-learned levels — so their label
# has to come from the grid's role table, not from a track's context.
d1 = grid.CELL_TO_CC[("D", 1)]
check(lang.control_label(d1, None) == "d1 level [ard]",
f"fader label from CC_ROLE, marked Ardour-owned: {lang.control_label(d1, None)!r}")
check(lang.control_label(d1, None, mapped=False) == "D1 unmapped",
"an unmapped control must SAY so — the screen may not contradict a dark LED")
c1 = grid.CELL_TO_CC[("C", 1)]
check(lang.control_label(c1, None) == "DJF percs", "family filter names its bloc")
f1 = grid.CELL_TO_CC[("F", 1)]
check(lang.control_label(f1, None) == "MUTE kick",
"gMute groups by ROLE, not bloc — kick alone is family 1")
b4 = grid.CELL_TO_CC[("B", 4)]
check(lang.control_label(b4, "# crushbus") == "d4 # crushbus",
"orbit first, then what the control does in this track")
check(len(lang.control_label(b4, "# a_very_long_effect_name")) <= 16,
"labels must fit the panel")
# ---- parse_context: the trailing stage note beats the Haskell ---------------
# This line is real (piment_bresilien.tidal:87) and used to yield `# n "23")) --`.
ctx = drv.parse_context(' $ midiOn "^56" ((# n "23")) -- Raise COMEON!\n')
check(ctx.get(56) == "Raise COMEON!",
f"PLN's own words must win over the Haskell body: {ctx.get(56)!r}")
# With no stage note, the useful noun is the sample, not the combinator.
ctx = drv.parse_context(' $ midiOn "^28" (loopAt 1 . (# "break:18"))\n')
check(ctx.get(28) == "break:18",
f"no comment -> first quoted string, not `loopAt 1 . (#`: {ctx.get(28)!r}")
ctx = drv.parse_context(' $ midiOn "^25" ((# n "6"))\n')
check(ctx.get(25) == "n 6",
f"a numeric string keeps its identifier for meaning: {ctx.get(25)!r}")
# A commented-out line must still never label a control.
check(drv.parse_context('-- $ midiOn "^56" (id) -- nope\n') == {},
"commented-out code labels nothing")
# ---- label_table: every analogue control, and only those -------------------
m = drv.build_map()
rows = drv.label_table(m, {}, set(m.values()), lang, grid)
check(len(rows) == 32, f"32 analogue controls own a display, got {len(rows)}")
check(sorted(r[0] for r in rows) == list(range(0x05, 0x25)),
"the table must cover targets 05h-24h exactly")
check(all(r[2] for r in rows), "no control may be left nameless")
djf = [r for r in rows if grid.CC_ROLE.get(r[1], ("", 0))[0] == "family_filter"]
check(len(djf) == 3 and not any(r[3] for r in djf),
"the 3 DJF knobs keep their rich readout: drawn by US, natives suppressed")
check(all(r[3] for r in rows if r not in djf),
"everything else is drawn by the firmware, from our name")
# The fallback flag must hand the whole surface back to our own overlay.
rows = drv.label_table(m, {}, set(m.values()), lang, grid, overlay_only=True)
check(not any(r[3] for r in rows), "--oled-overlay suppresses every native display")
# ---- home_lines: the third row defaults to the drip, footer overrides it --
check(lang.home_lines("rose_rouge", "120BPM 3:45") == ("ROSE_ROUGE", "120BPM 3:45", "ParVagues"),
"no footer arg -> unchanged behaviour, the everyday drip")
check(lang.home_lines("x", "y", "REC 01:23") == ("X", "y", "REC 01:23"),
"an explicit footer replaces the drip verbatim")
check(len(lang.home_lines("x", "y", "?REC 1:02:03")[2]) <= 16,
"a footer must be clipped to the 16-char panel like the other two rows")
# ---- rec_line: pure (rec dict, now) -> optional "REC h:mm:ss" ---------------
NOW = 1_000_000.0
check(drv.rec_line(None, NOW) is None, "no rec record at all -> keep the drip")
check(drv.rec_line({}, NOW) is None, "an empty/malformed record -> keep the drip")
check(drv.rec_line({"k": "rec", "rec": False, "t": NOW, "el": 12}, NOW) is None,
"rec:false -> keep the drip even with a fresh timestamp")
check(drv.rec_line({"k": "rec", "rec": True, "t": NOW, "el": 83}, NOW) == "REC 01:23",
"83s elapsed -> mm:ss, zero-padded")
check(drv.rec_line({"k": "rec", "rec": True, "t": NOW, "el": 3723}, NOW) == "REC 1:02:03",
"past one hour -> h:mm:ss")
check(drv.rec_line({"k": "rec", "rec": True, "t": NOW, "el": 5, "conflict": True}, NOW)
== "?REC 00:05",
"conflict:true prefixes '?' — the two lenses disagree, don't trust blindly")
check(drv.rec_line({"k": "rec", "rec": True, "t": NOW - 10, "el": 5}, NOW) is None,
"a 10s-old rec line is a dead heartbeat -> keep the drip, never freeze a REC")
check(drv.rec_line({"k": "rec", "rec": True, "t": NOW, "el": -1}, NOW) is None,
"a negative elapsed is malformed, not a countdown -> keep the drip")
check(drv.rec_line({"k": "rec", "rec": True, "el": 5}, NOW) is None,
"a record with no timestamp at all can never be judged fresh -> keep the drip")
# ---- last_rec: tail-reads the newest k:"rec" line, ignores everything else --
import tempfile
with tempfile.TemporaryDirectory() as td:
p = pathlib.Path(td) / "gig-fake.jsonl"
# --------------------------------------------------------------------------- #
# the guide's target table, quoted in lcxl3.py, pinned here
# --------------------------------------------------------------------------- #
def test_control_target_range_matches_the_guide():
""""05h (5) - 24h (36): Temporary display for Analogue controls (same as CC
indices, 05h (5) - 0Ch (12): Faders, 0Dh (13) - 24h (36): Encoders)" """
assert L.TGT_CONTROL_FIRST == 0x05, "per-control target range must start at 05h"
assert L.TGT_CONTROL_LAST == 0x24, "per-control target range must end at 24h"
assert all(L.is_analogue(i) for i in L.FADERS), "all 8 faders own a display target"
assert all(L.is_analogue(i) for i in L.ENCODERS), "all 24 encoders own one"
assert not any(L.is_analogue(i) for i in L.BUTTONS), (
"buttons own NO per-control target — that is why they never lost the screen")
assert L.TGT_CONTROL_LAST + 1 == min(L.BUTTONS), (
"the analogue targets must end exactly where the buttons begin")
# --------------------------------------------------------------------------- #
# config byte: bits 5+6 are the native display's on-switch
# --------------------------------------------------------------------------- #
def test_display_config_native_auto_bits():
""""Bit 6: Allow ... automatically on Change (default: Set).
Bit 5: Allow ... automatically on Touch (default: Set)." """
assert L.display_config(4, True, True) == 0x64, "arr 4 + both auto bits = 0x64"
assert L.display_config(4, False, False) == 0x04, "arr 4, natives off = 0x04"
assert L.display_config(2, False, False) == 0x02, (
"0x02 is what the OLD code sent to 0x36: arrangement 2, autos irrelevant "
"there — it never touched the per-control targets at all")
assert L.display_config(1, True, False) == 0x41, "bit 6 only = 0x41"
def test_display_config_arrangement_bits_never_collide_with_auto_bits():
"""The arrangement must survive as bits 0-4 and never collide with the auto bits."""
assert all(L.display_config(a) & 0x1F == a for a in range(32)), (
"arrangement occupies bits 0-4 intact")
# --------------------------------------------------------------------------- #
# label_control puts the right bytes on the wire
# --------------------------------------------------------------------------- #
def test_label_control_sends_configure_then_set_text():
s = fake_surface()
s.label_control(0x18, "d4 crushbus") # encoder r2c4
data = [list(m.data) for m in s.out.sent]
assert len(data) == 2, "label_control sends exactly configure + set_text"
assert data[0] == [0x00, 0x20, 0x29, 0x02, 0x15, 0x04, 0x18, 0x64], (
f"configure bytes wrong: {data[0]}")
assert data[1][:8] == [0x00, 0x20, 0x29, 0x02, 0x15, 0x06, 0x18, 0x00], (
f"set_text header wrong (target 0x18, field 0): {data[1][:8]}")
assert bytes(data[1][8:]).decode() == "d4 crushbus", "the name must arrive verbatim"
def test_label_control_native_false_clears_auto_bits_keeps_arrangement():
"""The DJF path: same target, autos CLEARED so our own overlay wins uncontested."""
s = fake_surface()
s.label_control(0x1D, "DJF percs", native=False)
assert list(s.out.sent[0].data)[-1] == 0x04, (
"native=False must clear bits 5+6 (config 0x04, arrangement still non-zero "
"because 0 would CANCEL the display)")
def test_label_control_on_a_button_raises_instead_of_silently_no_opping():
"""A button has no target and must be refused loudly, not sent into the void."""
with pytest.raises(ValueError):
fake_surface().label_control(0x25, "gate")
# --------------------------------------------------------------------------- #
# set_text must stop eating the reassigned control codes
# --------------------------------------------------------------------------- #
def test_set_text_preserves_reassigned_control_codes():
s = fake_surface()
s.set_text(L.TGT_TEMPORARY, 2, lang.ZERO_FRAMES[0]) # hearts around a zero
body = bytes(list(s.out.sent[0].data)[8:])
assert body.count(0x1E) == 2, (
f"the two hearts (1Eh) must survive the ASCII filter, got {body!r}")
# --------------------------------------------------------------------------- #
# the labels themselves
# --------------------------------------------------------------------------- #
def test_fader_label_comes_from_the_role_table_not_track_context():
"""Faders carry no ^NN at all — they are Ardour-learned levels — so their label
has to come from the grid's role table, not from a track's context."""
d1 = grid.CELL_TO_CC[("D", 1)]
assert lang.control_label(d1, None) == "d1 level [ard]", (
f"fader label from CC_ROLE, marked Ardour-owned: {lang.control_label(d1, None)!r}")
assert lang.control_label(d1, None, mapped=False) == "D1 unmapped", (
"an unmapped control must SAY so — the screen may not contradict a dark LED")
def test_control_label_family_filter_and_role_naming():
c1 = grid.CELL_TO_CC[("C", 1)]
assert lang.control_label(c1, None) == "DJF percs", "family filter names its bloc"
f1 = grid.CELL_TO_CC[("F", 1)]
assert lang.control_label(f1, None) == "MUTE kick", (
"gMute groups by ROLE, not bloc — kick alone is family 1")
b4 = grid.CELL_TO_CC[("B", 4)]
assert lang.control_label(b4, "# crushbus") == "d4 # crushbus", (
"orbit first, then what the control does in this track")
assert len(lang.control_label(b4, "# a_very_long_effect_name")) <= 16, (
"labels must fit the panel")
# --------------------------------------------------------------------------- #
# parse_context: the trailing stage note beats the Haskell
# --------------------------------------------------------------------------- #
def test_parse_context_stage_note_beats_the_haskell():
"""This line is real (piment_bresilien.tidal:87) and used to yield `# n "23")) --`."""
ctx = drv.parse_context(' $ midiOn "^56" ((# n "23")) -- Raise COMEON!\n')
assert ctx.get(56) == "Raise COMEON!", (
f"PLN's own words must win over the Haskell body: {ctx.get(56)!r}")
def test_parse_context_falls_back_to_the_quoted_sample_with_no_comment():
"""With no stage note, the useful noun is the sample, not the combinator."""
ctx = drv.parse_context(' $ midiOn "^28" (loopAt 1 . (# "break:18"))\n')
assert ctx.get(28) == "break:18", (
f"no comment -> first quoted string, not `loopAt 1 . (#`: {ctx.get(28)!r}")
def test_parse_context_numeric_string_keeps_its_identifier():
ctx = drv.parse_context(' $ midiOn "^25" ((# n "6"))\n')
assert ctx.get(25) == "n 6", (
f"a numeric string keeps its identifier for meaning: {ctx.get(25)!r}")
def test_parse_context_commented_out_line_labels_nothing():
assert drv.parse_context('-- $ midiOn "^56" (id) -- nope\n') == {}, (
"commented-out code labels nothing")
# --------------------------------------------------------------------------- #
# label_table: every analogue control, and only those
# --------------------------------------------------------------------------- #
def test_label_table_covers_every_analogue_control_and_only_those():
m = drv.build_map()
rows = drv.label_table(m, {}, set(m.values()), lang, grid)
assert len(rows) == 32, f"32 analogue controls own a display, got {len(rows)}"
assert sorted(r[0] for r in rows) == list(range(0x05, 0x25)), (
"the table must cover targets 05h-24h exactly")
assert all(r[2] for r in rows), "no control may be left nameless"
djf = [r for r in rows if grid.CC_ROLE.get(r[1], ("", 0))[0] == "family_filter"]
assert len(djf) == 3 and not any(r[3] for r in djf), (
"the 3 DJF knobs keep their rich readout: drawn by US, natives suppressed")
assert all(r[3] for r in rows if r not in djf), (
"everything else is drawn by the firmware, from our name")
def test_label_table_overlay_only_suppresses_every_native_display():
"""The fallback flag must hand the whole surface back to our own overlay."""
m = drv.build_map()
rows = drv.label_table(m, {}, set(m.values()), lang, grid, overlay_only=True)
assert not any(r[3] for r in rows), "--oled-overlay suppresses every native display"
# --------------------------------------------------------------------------- #
# home_lines: the third row defaults to the drip, footer overrides it
# --------------------------------------------------------------------------- #
def test_home_lines_default_third_row_is_the_drip():
assert lang.home_lines("rose_rouge", "120BPM 3:45") == (
"ROSE_ROUGE", "120BPM 3:45", "ParVagues"), (
"no footer arg -> unchanged behaviour, the everyday drip")
def test_home_lines_explicit_footer_replaces_the_drip():
assert lang.home_lines("x", "y", "REC 01:23") == ("X", "y", "REC 01:23"), (
"an explicit footer replaces the drip verbatim")
def test_home_lines_footer_is_clipped_to_the_panel_width():
assert len(lang.home_lines("x", "y", "?REC 1:02:03")[2]) <= 16, (
"a footer must be clipped to the 16-char panel like the other two rows")
# --------------------------------------------------------------------------- #
# rec_line: pure (rec dict, now) -> optional "REC h:mm:ss"
# --------------------------------------------------------------------------- #
def test_rec_line_no_record_at_all_keeps_the_drip():
assert drv.rec_line(None, NOW) is None, "no rec record at all -> keep the drip"
def test_rec_line_empty_record_keeps_the_drip():
assert drv.rec_line({}, NOW) is None, "an empty/malformed record -> keep the drip"
def test_rec_line_false_keeps_the_drip_even_with_a_fresh_timestamp():
assert drv.rec_line({"k": "rec", "rec": False, "t": NOW, "el": 12}, NOW) is None, (
"rec:false -> keep the drip even with a fresh timestamp")
def test_rec_line_formats_mmss_under_an_hour():
assert drv.rec_line({"k": "rec", "rec": True, "t": NOW, "el": 83}, NOW) == "REC 01:23", (
"83s elapsed -> mm:ss, zero-padded")
def test_rec_line_formats_hmmss_past_an_hour():
assert drv.rec_line({"k": "rec", "rec": True, "t": NOW, "el": 3723}, NOW) == "REC 1:02:03", (
"past one hour -> h:mm:ss")
def test_rec_line_conflict_flag_prefixes_a_question_mark():
assert drv.rec_line(
{"k": "rec", "rec": True, "t": NOW, "el": 5, "conflict": True}, NOW
) == "?REC 00:05", (
"conflict:true prefixes '?' — the two lenses disagree, don't trust blindly")
def test_rec_line_stale_heartbeat_never_freezes_a_rec_display():
assert drv.rec_line({"k": "rec", "rec": True, "t": NOW - 10, "el": 5}, NOW) is None, (
"a 10s-old rec line is a dead heartbeat -> keep the drip, never freeze a REC")
def test_rec_line_negative_elapsed_is_malformed_not_a_countdown():
assert drv.rec_line({"k": "rec", "rec": True, "t": NOW, "el": -1}, NOW) is None, (
"a negative elapsed is malformed, not a countdown -> keep the drip")
def test_rec_line_missing_timestamp_can_never_be_judged_fresh():
assert drv.rec_line({"k": "rec", "rec": True, "el": 5}, NOW) is None, (
"a record with no timestamp at all can never be judged fresh -> keep the drip")
# --------------------------------------------------------------------------- #
# last_rec: tail-reads the newest k:"rec" line, ignores everything else
# --------------------------------------------------------------------------- #
def _fake_gig_log(tmp_path):
p = tmp_path / "gig-fake.jsonl"
p.write_text(
'{"k": "s", "t": 1.0}\n'
'{"k": "rec", "t": 2.0, "rec": true, "el": 1}\n'
......@@ -200,16 +293,21 @@ with tempfile.TemporaryDirectory() as td:
'{"k": "cc", "t": 3.0}\n'
'{"k": "rec", "t": 4.0, "rec": true, "el": 3}\n'
)
return p
def test_last_rec_tail_reads_the_newest_rec_line(tmp_path):
p = _fake_gig_log(tmp_path)
got = drv.last_rec(p)
check(got is not None and got.get("t") == 4.0,
f"must return the LAST rec line, not the first or a non-rec one: {got!r}")
check(drv.last_rec(pathlib.Path(td) / "gone.jsonl") is None,
"a missing file must degrade to None, never raise")
check(drv.newest_gig_log(pathlib.Path(td) / "no-such-dir") is None,
"a missing gig-log directory must degrade to None, never raise")
if FAILED:
print(f"{len(FAILED)} FAILED:")
for f in FAILED:
print(" -", f)
sys.exit(1)
assert got is not None and got.get("t") == 4.0, (
f"must return the LAST rec line, not the first or a non-rec one: {got!r}")
def test_last_rec_missing_file_degrades_to_none(tmp_path):
assert drv.last_rec(tmp_path / "gone.jsonl") is None, (
"a missing file must degrade to None, never raise")
def test_newest_gig_log_missing_directory_degrades_to_none(tmp_path):
assert drv.newest_gig_log(tmp_path / "no-such-dir") is None, (
"a missing gig-log directory must degrade to None, never raise")
......@@ -156,11 +156,26 @@ def test_sound_hints_never_raise_on_an_orbit_with_no_quoted_name(tmp_path):
# 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():
"""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()
assert len(setlist) == 13
assert len(setlist) == len(_raw_setlist_rows())
for p, label in setlist:
assert p.exists()
assert label
......@@ -187,24 +202,35 @@ def test_setlist_comments_and_blank_lines_are_ignored(tmp_path, monkeypatch):
# parser that only agrees with fixtures
# --------------------------------------------------------------------------- #
# Hand-measured by PLN + me on 2026-07-28, before this parser existed. 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 by PLN + me on 2026-07-28, before this parser existed, and
# RE-measured on 2026-09-05 (by reading each file's column-0 `dN` declarations
# 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 = {
"bombe_dj": {1, 2, 3, 4, 5, 7, 8, 9},
"wap": {1, 2, 3, 4, 5, 7, 8, 9},
"piment_bresilien": {1, 2, 3, 4, 5, 7, 8, 10},
# 2026-08-01, commit 2376e43 ("d10 is the riser") added a d10 safe-riser
# idiom to 7 tracks that lacked one — bombe_dj, wap, you_my_sunshine,
# 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},
"gimme_acid": {1, 2, 3, 4, 5, 8, 9, 10, 11, 12},
"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
# 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
# column-0 `dN` declarations, not by copying the parser's output — a hand
# measurement that quotes the thing it checks is a tautology.
"you_my_sunshine": {1, 2, 3, 4, 5, 6, 8, 9, 11},
"desire": {1, 2, 3, 4, 5, 6, 7, 8, 9},
# consolidation he made in do_it_right).
"you_my_sunshine": {1, 2, 3, 4, 5, 6, 8, 9, 10, 11},
"desire": {1, 2, 3, 4, 5, 6, 7, 8, 9, 10},
"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):
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
into the next track; the mechanism says d6 and d10 are the survivors."""
assert oo.orphans(oo.resolve("vague_de_crime"), oo.resolve("bombe_dj")) == {6, 10}
into the next track; the mechanism says d6 is the survivor.
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():
......@@ -281,14 +311,11 @@ def test_check_tracks_reads_the_SAME_setlist_file():
def test_the_setlist_survives_the_shell_parse_too():
"""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
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 subprocess
out = subprocess.run(
["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
rows = _raw_setlist_rows()
assert len(rows) == len(oo.load_setlist())
for r in rows:
assert not _re.search(r"[#\[]", r), f"comment leaked into {r!r}"
assert (oo.REPO / r).exists(), r
......
......@@ -158,4 +158,24 @@ check("the generated .txt round-trips through the parser",
== [e.path for e in real])
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