1. 06 Sep, 2026 10 commits
    • docs(archive): #29 and #17 — the portable rig proved on a second box · 519ad30a
      Long-form entries for the two tasks the XPS24 session closed. Written for a
      reader months from now with no memory of it, because these are the source
      material for the blog post (#22) that was deliberately blocked on this outcome
      so its ending could be 'it played' rather than 'it should'.
      
      The through-line worth keeping: six blockers on the second machine and not one
      of them produced an error message. A hijacking startup.scd, a quark cloned but
      unregistered, tidal built against the wrong ghc, a 644 installer, no pip at all,
      and a Pulsar package whose documented install command silently downgrades the
      rig. Every one of them was invisible on the box the setup was written on, which
      is the actual lesson: config that has only ever run on one machine has been
      observed working, not tested.
      
      Also recorded, because they are about the measuring instruments rather than the
      rig: the doctor's coverage check counts bank names and so reported all 281 banks
      present while 15 GB was still copying, and its fix text for missing units named a
      tool that cannot install them. And the tilde traps -- File.exists('~/x') is
      false, nowExecutingPath is nil inside waitForBoot -- each of which would have
      turned a portability fix into a permanently-false guard.
      PLN (Algolia) authored
    • fix(install): the installer shipped non-executable, and the doctor sent you to the wrong tool · 51f1189e
      Both found the only way they could be found: by running the documented
      procedure on a machine that was not the one it was written on.
      
      **tools/rig-install.sh was committed as mode 100644.** It is the first command
      in SETUP.md's fast path. It worked for its whole life on the authoring laptop
      because the author had chmod'd it there and never committed that — a local
      chmod fixes your tree, not the repo, and the index is the only mode that
      travels. So `tools/rig-install.sh` on a fresh clone died with "permission
      denied", on the one machine the script exists to serve. Five other tracked
      shell scripts had the same mode (gpu-mode.sh, init_midi.sh, viz/launch.sh and
      two under armada/); all six are now +x in the index.
      
      Fixing the six without a guard would just wait for the seventh, so
      tools/tests/test_scripts_are_executable.py reads modes out of `git ls-files -s`
      — the index, not the working tree — and fails on any tracked *.sh at 100644.
      It also pins the five named entrypoints explicitly so a rename cannot quietly
      drop one, and it guards itself: an empty file listing must not read as
      success. Mutation-verified in both directions (set gpu-mode.sh back to 644 →
      1 failed naming the file and printing the git update-index fix; restored →
      3 passed).
      
      **The doctor's fix text for missing units pointed at rig_units.py.** On a
      fresh box that is wrong in a way that wastes real time: rig_units.py only
      enables and starts units that are ALREADY symlinked into
      ~/.config/systemd/user/, so `--ensure --apply` returns fifteen consecutive
      "Unit file does not exist" lines and reads like a broken reconciler. It isn't
      — rig-install.sh is what creates the symlinks. The check now looks at whether
      the units are merely not-enabled or genuinely not-found, and points at the
      installer in the second case.
      
      That distinction was already in the data (rig_units.py --status prints
      "not-found" in the enabled column, and the doctor already parsed that column
      into rows) — nobody had read the two facts together, because on a box where
      the symlinks have existed for months the not-found branch never fires.
      
      901 passed before, 309 in tools/tests after adding the new file.
      PLN (Algolia) authored
    • fix(portable): the rig stops assuming it lives at /home/pln — and three tilde traps on the way · b130508c
      SRE #84 phases 1 and 2. Five files, ~30 load-bearing lines, and the point is
      not tidiness: on a box where the account is not `pln`, SuperDirt booted
      completely cleanly with every one of the three sample roots silently absent,
      and the LCXL painter units exec-failed while rig-doctor reported the LCXL
      section green. Two silent failures, each standing behind something that looked
      like a pass.
      
      **Phase 1 — the three sample roots and the two same-repo loads.**
      start_and_midi.scd and sample_watch.scd held /home/pln as literal strings.
      The fix is not a blind sed, because a raw tilde expands nowhere by itself.
      Three traps, all verified on sclang 3.14.1 before writing a line:
      
        - `"~/x".standardizePath` keeps a trailing glob intact, so the loadSoundFiles
          calls can stay one-liners. Good.
        - `File.exists("~/x")` returns FALSE. A naive tilde substitution would have
          left the preload and sample-watcher guards syntactically perfect and
          permanently false — a dead preload with no error, which is worse than the
          hardcoded path it replaced.
        - `thisProcess.nowExecutingPath` is nil inside s.waitForBoot's closure. The
          two same-repo loads live in there, so the repo root is captured at TOP
          level into ~pvRepoRoot and used later. Reading it in place would have
          produced exactly the same dead-guard failure as the tilde.
      
      Deriving the repo root from the file's own location beats a home-relative
      guess: the rig now runs from any clone path, not merely any username.
      
      **Phase 2 — the two outlier units.** lcxl3-driver.service and
      lcxl-leds-watch.service baked WorkingDirectory and an absolute ExecStart,
      where every sibling unit (midiviz, perf-tray, parvagues-bridge,
      midi-autoconnect, tidal-ardour-autoroute) already used %h. Now they do too,
      verified with systemd-analyze --user verify.
      
      **Phase 3, unplanned — the fix broke the check that guards the fix.**
      rig-doctor's check_scd_sample_roots reads the roots OUT of start_and_midi.scd
      rather than hardcoding them, which is the right design and is why it caught
      this at all. But it then called Path.is_dir() on the extracted literal, and
      Python does not expand a tilde either — the same trap as File.exists, one
      language over. So the portability fix turned three PASSes into three false
      FAILs on the box where all three roots exist. That is how a check gets
      switched off. It now expands before testing, and reports the resolved path so
      the reader can see what was actually stat'ed.
      
      Validation: both .scd files compile (File.readAllString(f).compile, which
      parses without executing); both units pass systemd-analyze verify; rig-doctor
      goes from 3 fail / 36 pass to 0 fail / 39 pass with the same verdict logic;
      901 passed, 2 skipped across the suite.
      
      The lesson for the log is the third phase, not the first two. A checker that
      derives its expectations from the source it checks is strictly better than one
      with hardcoded copies — and it still shares the source's bugs, one runtime
      removed.
      PLN (Algolia) authored
    • docs(archive): the portable-rig trio — sample-pack, rig-doctor, clone-the-gig · 1d3bb807
      Three tasks that turned 'can PLN play from another laptop' from a question into
      a procedure, archived long-form because the learnings are the blog material.
      
      The through-line worth reading later: every one of the three had a structural
      flaw that only appeared when you asked where the tool would RUN. sample-pack was
      designed as two bundles until PLN reframed it as one parameterized query over the
      mapped list. rig-doctor was green at home and structurally incapable of naming
      absence on a fresh box, because it resolved against the machine it ran on.
      rig-install would have created an empty quark directory that the next run reads
      as 'already installed'. None of the three was caught by a test; all three were
      caught by asking what happens on a machine that has nothing.
      PLN (Algolia) authored
    • feat(tools): rig-install.sh + SETUP.md — clone the gig onto a new box · 2d51973f
      The install counterpart to rig-doctor.py: the doctor only ever reads,
      this converges. Idempotent by construction — detect, report, act only
      if something is actually missing, never fail the whole run for one
      optional piece. Covers every checked item in rig-doctor.py: system
      packages (printed, pacman+--yes is the only path this script ever
      executes itself), SuperCollider quarks at current HEAD (no lockfile —
      PLN rejected pinning), the mi-UGens extension, the private
      parvagues-synths repo + its link.sh, the two ~/.local/bin shims two
      systemd units silently crash-loop without (nothing in the repo created
      these before now — a top-5 fresh-box breakage), the systemd --user
      units from rig_units.py's one authored table, and the sample carry-on
      bundle (rsync, out of band from git on purpose).
      
      Root-owned installs (perf-audio+sudoers, parvagues-protect, the
      [midiviz-pin] KWin rule) are print-only, always — this script never
      runs sudo. A script that quietly escalates itself is one nobody can
      audit before a gig; `--print-root-steps` shows just those three
      commands and does nothing else.
      
      Every path is resolved at run time from the script's own location,
      $HOME, and $XDG_DATA_HOME — never baked in. This rig's oldest
      recurring bug is a binding resolved once and never re-resolved; an
      installer is exactly the kind of code tempted to commit that bug by
      writing down what it found on its own author's box. For the same
      reason it never repoints an existing systemd-unit or ~/.local/bin
      symlink that already resolves correctly, even to a DIFFERENT checkout
      than this one — only creates what's entirely missing, or repairs a
      dangling link. Tested twice on PLN's own performing laptop (already
      fully provisioned): both runs report "already ok" throughout and
      change no running state, except parvagues-synths/link.sh adopting 18
      already-identical synthdefs as symlinks (its own designed
      no-op-equivalent).
      
      Also regenerates tools/parvagues-rig.target from rig_units.py's
      generator: it had drifted (still Wanted the now-manual lcxl3-driver,
      missing midiviz) since the unit table changed under it.
      
      SETUP.md is the human procedure: the fast path, what rig-install.sh
      does and doesn't do, the LCXL2/LCXL3 hardware-detected split, a
      troubleshooting section keyed off the doctor's real FAIL text, and an
      explicit "known not portable yet" — start_and_midi.scd and
      sample_watch.scd hardcode /home/pln, so a non-pln account gets a rig
      that boots clean with banks and preload silently missing (task #28).
      Named honestly rather than hidden from the next person who hits it.
      PLN (Algolia) authored
    • feat(rig): the doctor can name what a fresh box is missing, before the audio arrives · 06039c8a
      rig-doctor's samples check had a structural hole I only saw once it ran: it
      shells out to sample-pack, which resolves bank names by walking the Dirt-Samples
      tree of the box it is running on. On PLN's laptop that is 281 banks and looks
      fine. On an empty XPS24 it reports "0 banks resolve" — true, and useless. The
      check could never say "281 missing", which is the only thing a fresh machine
      needs to hear.
      
      The fix is an EXTERNAL reference, committed: sample-manifest.json at the repo
      root (463 KB, 281 banks, 9385 filenames, generated by tools/sample-pack.py
      --all). rig-doctor now falls back to it when no --bundle is given, and --bundle
      accepts a manifest FILE as well as a bundle directory. So the ordering that
      makes a same-day setup possible works: git clone, run the doctor, get the exact
      list of missing banks — all before 15 GB of audio has moved anywhere.
      
      Committed deliberately, not gitignored. A generated artifact that nothing ever
      compares against reality is how preload.scd drifted for weeks and made its
      debut as a crackle at a venue. This one is diffable, and its own freshness is
      visible in git.
      
      Mutation-verified rather than assumed, because a checker that cannot report
      absence is exactly the failure being fixed here: planted two nonexistent banks
      in a copy of the manifest, confirmed the check goes FAIL, names them, counts
      2/281 with sizes, and flips the final verdict from "yes" to "no".
      
      That test also exposed a fix-pointer that could not be followed — with a
      manifest file it said "see <manifest.json>/README.md", a path that cannot
      exist. Now _bundle_fix() distinguishes the three cases: you have the bundle
      directory (cp from it), you have a bundle's MANIFEST.json (cp from its parent),
      or you have only the committed list (rsync from the freebox staging copy, or
      rebuild with sample-pack on a box that has the samples). A fix you cannot follow
      is not a fix.
      
      The carry-on bundle it describes is built and verified: 281 banks, 9385 files,
      15,108,195,468 bytes, checked file-by-file against its own manifest with 0
      mismatches, and staged to /mnt/freebox/PLN/parvagues-carryon byte-identical
      (9387 files both sides, 21m53s over SMB at ~11 MB/s).
      
      903 passed, 0 failed.
      PLN (Algolia) authored
    • feat(tools): rig-doctor — does THIS machine have what the rig needs at all · 58371350
      Answers a different question than gig-preflight.py's "is the installed rig
      HEALTHY right now": on a fresh box with nothing installed, can it even GET
      there, and for each gap, the exact command to fix it. Same spirit as fbk
      doctor — a friend who bought PLN's old LCXL2 can run this on their own
      laptop and paste the output back, turning "does this work elsewhere" from a
      guess into a list. Installs nothing; every check reads (binary --version,
      systemctl is-active/is-enabled, aconnect -l, git log, subprocess import
      probes) and nothing writes to the machine, the repo, or a service.
      
      The rule that matters most: never hardcode /home/pln or any absolute user
      path, and re-derive everything at call time (Path.home(), $XDG_DATA_HOME,
      Path(__file__).resolve()) rather than baking a module-level constant from
      the machine that wrote the file. That class of bug is real and already in
      this repo — start_and_midi.scd hardcodes three sample roots as literal
      /home/pln/... strings, so on a box where the user isn't pln, SuperDirt
      boots clean with those banks silently gone. This tool is built to be the
      thing that catches that, which means it can't commit it itself.
      
      Sample coverage deliberately does not parse .tidal files (one parser,
      tools/setlist_samples.py, per test_one_tidal_parser.py) — it shells out to
      tools/sample-pack.py --all --json and, with --bundle PATH, verifies against
      a carry-on bundle's MANIFEST.json as independent ground truth (without one,
      sample-pack can only self-report what THIS box already resolves, since
      resolving a bank name requires the folder to already exist on disk — a
      fresh box has nothing to walk).
      
      Findings from this run (PLN's own working laptop, the correctness signal —
      39 pass, 0 fail, 5 warn across all four invocations: human, --json, --quiet,
      --bundle /home/pln/Work/Sound/parvagues-carryon):
        - toolchain fully green: sclang/scsynth 3.14.1, all 6 quarks at their
          known-good commits, ghc 9.4.7/cabal 3.14.1.1/tidal 1.10.0, pulsar
          v4.1.0, ardour9, pipewire+pipewire-jack, all 5 python deps
        - all 281 carry-on bundle banks (15.11 GB) present when checked with
          --bundle; 18/18 synthdefs present
        - real, structural gaps this box legitimately WARNs on: /etc/sudoers.d is
          750 root:root, so presence of perf-audio's sudoers rule can't be
          verified without root (reported honestly as "unknown", not a false
          FAIL); no LCXL plugged in at run time; .env/yt-profile absent (this run
          happens to be from a worktree, which doesn't carry gitignored files —
          a checkout artifact, not a machine gap)
      
      python3 -m pytest -q: 903 passed (unchanged baseline).
      PLN (Algolia) authored
    • feat(rules): one parser for .tidal, enforced by a shrink-only ratchet · ff569232
      I wrote a THIRD .tidal parser this session -- an ad-hoc regex census of which
      synths the set calls -- while tools/setlist_samples.py already answered exactly
      that. It produced garbage twice: a substring grep matched the synth `gfunk`
      against the `gfunk_*` SAMPLE banks, then the regex version read `s "k"` (58
      files) as a missing sample and I reported it as a possible bug. PLN caught both
      in seconds. `#` is `|>` -- structure from the left, values from the RIGHT -- so
      `s "k" # s "jazz"` plays jazz and `k` is a rhythm skeleton that must never
      resolve. Nothing was broken.
      
      His diagnosis is the one worth keeping: a CLAUDE.md line alone would be a
      bandage, because the cause is not ignorance but COST ASYMMETRY. tools/ holds ~40
      scripts with no index and the canonical resolvers are human-facing CLIs, not
      importable functions, so writing a regex cost 30 seconds and finding the parser
      cost a search. The cheap path wins under pressure. What black teaches Python
      devs is not taste, it is that the decision is gone.
      
      So: a rule in CLAUDE.md that states the trap concretely (with the `s "k" # s
      "jazz"` example, since the rule is useless if you don't believe it), plus
      structural enforcement in tools/tests/test_one_tidal_parser.py.
      
      The test matches the SHAPE of a Tidal call-site regex, not a blocklist of
      filenames, so a new file is covered the day it is written. It is a RATCHET, not
      an amnesty: KNOWN_DEBT may only shrink. A file not on the list that starts
      parsing fails immediately; an entry that stops offending ALSO fails, so paying
      one off must be recorded. Mutation-verified in both directions -- a planted
      regex turns it red, and a fictitious paid-off entry turns it red -- because a
      green test is not evidence that it can fail.
      
      Enforcement immediately found FIVE pre-existing hand-rolled parsers I did not
      know about, plus a sixth once the markers were tightened. They are not the same
      mistake, so they are listed individually rather than waved through by
      directory: tidal_score.py, sample_tfidf.py and pattern_ngrams.py genuinely
      duplicate bank extraction and are what #23 should absorb; at/lens.py reads
      `mask "..."`, pvlint/rules.py reads `# n "..."` and deshadow-helpers.py rewrites
      whole source lines -- different concepts, and forcing those through a bank
      resolver would be the wrong API.
      
      Precision here is load-bearing: a looser first draft flagged
      tools/setlist.py's "^(sound\s*check|line\s*check|balance)$", which is about a
      stage soundcheck and has nothing to do with Tidal. A rule that cries wolf gets
      switched off.
      
      Also fixes a fake test inside the hollow-suite guard itself: test_suite_hygiene's
      `test_files` was a HELPER, collected by pytest as a test that passed by
      returning a list without asserting anything -- the exact shape that file exists
      to catch. Renamed to _gather_test_files. That was the suite's only
      PytestReturnNotNone warning.
      
      903 passed, 0 failed, 28 warnings.
      PLN (Algolia) authored
    • add(tools): sample-pack.py — bundle a set's must-travel sample banks · fa7aef0b
      Answers: for a chosen set of tracks, which Dirt-Samples banks does it
      actually need, and can that be handed off over swisstransfer to play the
      set from a different laptop? Selectors: --tracks (explicit/bare names),
      --set NAME (armada/setlist_*.txt or a gig's tracks.json under
      ../../Web/www/content/lives/{year}/{slug}/, --set list to enumerate),
      --all (every .tidal under live/+copycat/, the default). Dry run by
      default; --pack DEST copies, --manifest-only writes just the manifest
      (the one meant to be committed, so a remote box can verify/reproduce
      the bundle without re-scanning).
      
      Reuses tools/setlist_samples.py (extract_names/build_index/resolve_track/
      read_setlist/bank_file_count) as the ONLY .tidal parser — no new regex
      over .tidal text. A second ad-hoc parser is the specific mistake this
      tool exists to avoid (a prior grep false-positived "gfunk" prose against
      the gfunk_* banks).
      
      THE MAPPED-ONLY FILTER: only banks actually mapped into the Dirt-Samples
      root travel (a real dir there, or a symlink into ~/Work/Sound/Samples).
      Classified as stock (tracked in the Dirt-Samples git checkout — ships
      free with a fresh install, excluded unless --include-stock), mapped
      (the symlink farm + local additions), extra (~/Work/Sound/Samples/extra),
      or unmapped (e.g. tidal-drum-machines, excluded unless --include-unmapped).
      The raw ~/Work/Sound/Samples tree (36 GB, mostly work-in-progress) is
      never walked directly — only through the same mapped index
      setlist_samples.py already resolves against, which is what keeps that
      36 GB out of the bundle.
      
      BANK-INDEX SAFETY: `s "bank:N"` indexes files in the order SuperDirt's
      pathMatch("*") glob returns them, which skips dotfiles. So packing copies
      EVERY non-dotfile in a bank (never subsets), follows symlinks to copy
      real content, and preserves the bank's directory name and file names
      exactly — dropping even one real file renumbers every index after it and
      changes what plays on the receiving machine. --force on a re-pack rmtrees
      the destination bank first rather than merging, for the same reason: a
      merge leaves stale files from a previous pack sitting next to fresh ones.
      
      Measured against --all today (714 .tidal files, live/+copycat/): 400
      banks resolve; 119 are stock (~166 MB) and excluded by default; 265
      mapped + 16 extra = 281 must travel, ~15.1 GB total. rhadamanthe_divers
      alone is a symlink to 314 files / ~4.3 GB, roughly 30% of the bundle.
      
      Tokens that resolve to no bank (--unresolved) are NOT reported as
      missing/broken: ParVagues' `#` is structure-left/values-right
      (`$ s "k" # s "jazz"` — k is a rhythm skeleton, jazz is what plays), so
      most unresolved tokens are structural placeholders or built-in SuperDirt
      synth names (superpiano, moogBass, supersaw...), never dropped samples.
      setlist_samples.py's own docstring treats the unresolved set the same
      way — as a non-error remainder, not an alarm channel.
      PLN (Algolia) authored
    • docs(archive): ten completed tasks from the 09-05/06 session · 747f5be2
      Written as long-form learning rather than a list, per the archive's own
      convention — these are blog and video source material, and the learnings are
      the half worth keeping.
      
      The through-line of the session, visible only once the entries sit together:
      five separate failures where a CHECK was the broken thing, not the system it
      watched. The delete verifier reporting 0/14 for fourteen successful deletions.
      Six test suites green because they ran nothing. An assertion with an
      always-true escape clause. A drift guard that had been failing behind an
      --ignore flag people were trained to pass. A subagent's ten red tests that were
      its own stale checkout.
      
      Nearly committed this append on the wrong base: the shared checkout is on an
      older branch whose copy of this file is 162 lines behind master, so appending
      there and committing would have DELETED master's most recent entries. Extracted
      the block, restored the shared tree byte-identical, re-applied on origin/master
      in a worktree. Same lesson as the rest of the session — a stale read makes an
      append destructive, and the file being tracked is exactly what hides it.
      PLN (Algolia) authored
  2. 05 Sep, 2026 30 commits
    • docs(log): 045 — the master bus, painted behind the rain · 90c4270a
      Keeps the two things worth retelling: the A/B/A design that survived my own
      wrong premise about the xrun baseline, and Popen(bufsize=0) returning a raw
      FileIO whose short reads read as EOF — green selftest over a backdrop that
      could never receive a sample.
      PLN (Algolia) authored
    • feat(midiviz): the master bus, painted behind the rain · 504b0366
      PLN: "can we overlay behind a basic spectro? that id remove spectro VSTs
      from ardour and wed have our super perf one as overlay on single win?"
      
      So a coarse spectrum of the DEFAULT SINK's monitor -- the mix as the room
      hears it, not one orbit -- painted first, under every glyph, in the window
      that is already on top of everything. 40 log bands off a 2048-point FFT at
      18 Hz: scenery, not an analyser. If something needs measuring, tidal-ears
      is the tool and it is the right one.
      
      Off by default and off both ways. `--spectro` or the `s` key acquires the
      tap; `s` again hands it back -- the capture subprocess does not exist until
      asked and does not survive being un-asked, closeEvent releases it, and the
      unit deliberately carries no --spectro so login never acquires one. A live
      rig does not get to grow an always-on audio consumer quietly.
      
      Capture and FFT run in one thread whose entire contact with Qt is a single
      frame swapped under a lock, depth ONE: a GUI at 5 fps reading an analyser at
      18 fps shows the newest picture and discards the rest. A queue here would
      only buy latency, which is what a 512-deep queue already cost the HUD once.
      pw-record is asked for 100 ms latency explicitly -- a monitor client that
      requests a tight buffer is how you talk the graph's quantum down and pay for
      a decoration in xruns.
      
      Measured, A/B/A, same -30 dBFS 1 kHz bed in every window, 120 s each: 0
      added xruns with the tap on (2249 analysed frames, 18.7/s), 0 in either
      control window, every node's counter unmoved. Nothing in the graph noticed.
      
      Two things this cost, both recorded where they bit:
      
      * `Popen(bufsize=0)` hands back a RAW FileIO, so `read(n)` is one os.read
        and a hop-sized read is short far more often than not. Treating that as
        EOF ended the capture on its first chunk -- blocks=0, no error recorded --
        and every pure-function test stayed green on top of a backdrop that could
        not receive a sample. Only running it against real audio found it.
        `_read_exact` now owns the distinction, with a dribbling-stream test.
      * the first assertion about band placement was wrong about the code it
        tested: below ~750 Hz a 2048-point FFT has fewer bins than we have bands,
        so the edges are not the nominal log ramp, and checking 220 Hz against the
        ramp reported MISPLACED for a band that is exactly 211-234 Hz. Ask the
        edges where the band is.
      
      And the reference was wrong before the code was: the first bed I labelled an
      "80-226 Hz sweep" was 0.02*sin(2*pi*f(t)*t), whose instantaneous frequency
      is f + t*df/dt -- broadband chirp garbage. The spectrum was reading it
      correctly. A known 1 kHz tone lands in band 22, span 984-1148 Hz, every
      other band at 0.00.
      
      `_paint_spectrum` was checked against all 33 methods on the widget before it
      was named: `_paint_chrome` had to become `_paint_controls` because a second
      method quietly took the first one's name and the selftest went on passing
      while a whole layer stopped being drawn.
      
      selftest PASS (bars=40, tap released); 106 tests pass.
      PLN (Algolia) authored
    • chore(catalog): regenerate — the 8 new gigs reach the tracks that played them · a4ad0752
      The eight gig tracks.json files landed in 3a7c660b but the generated catalog was
      never rebuilt against them, so 36 tracks were still missing the gigs they
      appear in — ataright knew about bazurto and divin-live but not mephisteuf,
      and so on down the list.
      
      Rebuilt with build_catalog.py, not copied: the shared checkout had an
      uncommitted regeneration sitting in it, and this build reproduces it
      byte-identically from committed state, which is the only version worth
      trusting ([[feedback_parsers_over_copy]] — a catalog is an artifact, so
      regenerate it, never hand-carry it).
      
      Counts unchanged at 81 tracks / 7 authored overlays; this is purely the
      gigtrack edges filling in.
      
      Deliberately NOT committing catalog_view.json. Its clean rebuild here already
      equals master, while the copy in the shared tree differs in `takes` for 24
      tracks and in `source` for 3 — the latter because it was built with PLN's
      uncommitted .tidal edits baked into it. A generated file that embeds
      work-in-progress is not reproducible from the repo, so committing it would
      pin someone's scratch state as canon.
      PLN (Algolia) authored
    • docs(log): 044 — six suites that were green because they ran nothing · 6fb943a6
      The entry for the test-hygiene sweep, written while it is fresh. Keeps the two
      findings worth retelling: the assertion with an always-true escape clause that
      could not fail for months, and the collection error that trained everyone to
      pass --ignore and so hid a live API grading silence as tier A.
      PLN (Algolia) authored
    • fix(api): the live grader would score silence tier A — re-vendor, and stop · f1755c76
      hiding the suite that said so
      
      armada/api/engines/grade.py is a vendored copy of tools/foundry/engine/grade.py
      and had fallen three fixes behind the canonical:
      
        * the `empty_dbfs` presence gate. Every other sub-score is undefined on
          silence — the seam between two silences is perfect, the DC of silence is
          zero, its zero crossings are trivially fine — so a silent window scored
          0.90 x perfect and came out tier A. Fourier is deployed; this is what it
          was serving.
        * the seam denominator: mean |2nd diff| collapses to ~0 on sparse material,
          so a genuinely clean dub-drum loop measured 129x the mean and read as a
          huge click. The canonical uses the 90th percentile, amplitude-floored.
        * `not empty` on the low-RMS and mono-incompatibility flags, so silence stops
          being reported as maximally mono-incompatible.
      
      Re-vendored by copy after checking the diff both ways: the canonical is a strict
      superset and the only vendored-only lines were the OLD versions of the three
      changed ones, so nothing API-specific was lost. Now byte-identical, which is
      what the drift guard actually wants.
      
      The drift guard was not broken. It had been failing, in-repo, unseen — because
      of the second half of this commit.
      
      armada/api needs FastAPI, which lives in ~/.virtualenvs/fourier. Run `pytest`
      from the repo root with the plain interpreter and its seven test modules raise
      ModuleNotFoundError during collection, which pytest reports as "Interrupted: 7
      errors during collection" and then runs NOTHING — not the api suite, and not
      the other 890 tests either. The natural response is `--ignore=armada/api`,
      after which the api suite has no verdict at the root at all and can sit red
      indefinitely. It sat red long enough for the grader to drift.
      
      So conftest.py now skips that tree when fastapi is absent and prints the exact
      command to run it properly. An unrunnable suite should say it is being skipped
      and say how to run it; an error that stops everything only teaches you to pass
      a flag that hides it.
      
        plain python3 at root      892 passed,  2 skipped   (was: 0 run, 7 errors)
        fourier venv at root       993 passed,  2 skipped   — the whole workspace
        armada/api in its venv     101 passed,  0 failed    (was: 1 failed)
      PLN (Algolia) authored
    • fix(leds): five tests asserted a spec the board stopped using in August · 8dd2b710
      Master was red and nobody had noticed, because these five never fail alone —
      they fail in a full-suite run, and the habit had become running subsets.
      
      Four of them still described PLN's original six-step LED ramp, three of whose
      steps were dim. value_ramp moved to the DAYLIGHT ramp — five steps, every one
      full brightness — on his own ruling of 2026-08-21: "top brightness always would
      make more readable signals even in day perfs". The code carried that reasoning
      in its docstring; the tests were never brought along.
      
      So the tests now assert the shipped ramp, and the six-step spec keeps a test of
      its own under LCXL_DIM_RAMP=1, because it is still live behaviour for a dark
      stage — superseded as the default, not deleted.
      
      Two of them needed a different LENS, not a different number:
      
        * Monotonicity was measured as (green, red) ascending, which held only
          because the dim ramp never took red away. The daylight ramp walks amber ->
          yellow -> green by REMOVING red at full green, so red has to count
          downwards. Same property; the lens has to match the control.
      
        * The DJ filters' exemption from the ramp was tested by comparing the two
          functions at the centre value only — and the daylight ramp's midpoint is
          also 63, so the two coincided there and the test failed while the exemption
          was perfectly intact. A one-point comparison cannot tell a bipolar mapping
          from a unipolar one. It now asserts the exemption exactly (a DJ filter is
          painted by filter_colour at every one of the 128 values), that the two
          mappings disagree on 69 of 128, and that the bypass detent is narrow where
          the ramp's amber is wide.
      
      And one thing worth keeping: the old monotonicity test read
      
          assert ranks == sorted(ranks) or len(set(ranks)) == 6
      
      The six-step ramp has exactly 6 distinct ranks, so the second clause was ALWAYS
      true and the assertion could not fail. It was hiding a real fact — the dim ramp
      genuinely doubles back, dim red -> bright red then bright amber -> dim green —
      which is the measured reason the daylight ramp is the better default, not just
      a brightness preference. That is now asserted as the truth it is.
      
      The fifth was tools/at/tests/test_lens.py pinning the OPAL setlist at exactly
      13 tracks. That file is GENERATED from backlog.md, PLN's SSOT for set
      membership and order, so its length changes whenever he changes the set — it
      went 13 -> 16 when OPAL was recorded as-performed, and the test failed for the
      set doing exactly what it is supposed to do. A hardcoded count on a
      human-edited artifact is a stale binding. What the assertion is really for is
      non-vacuity (an unparsed setlist would make the loop below run zero times and
      pass), so it is a floor now, plus every path in the setlist must resolve on
      disk.
      
        before   885 passed, 5 failed
        after    892 passed, 0 failed
      PLN (Algolia) authored
    • test(hygiene): a guard against suites that are green because they run nothing · 70209824
      Four suites in this workspace were found hollow in one week, each hollow in a
      different way, and every one of them had read as fine for months. The pattern
      is not a bug in any of them — it is that a test suite reports on the code and
      nothing reports on the suite.
      
      So: tools/tests/test_suite_hygiene.py walks every test_*.py by AST (no imports,
      so it works with or without any project venv) and asserts three things.
      
        * No file collects zero tests. A test_*.py with no collectable function is
          green and hollow.
        * No test requests a fixture nothing declares. `def test_x(suite)` makes
          pytest error on a missing fixture, and twelve such errors read as a config
          nit for months while twelve real audio cases went unrun.
        * No module-level sys.exit(). It fires during COLLECTION and takes the whole
          directory down instead of failing one test — which is how `pytest
          tools/tests/` once ran zero tests while 273 passed individually.
      
      It also guards itself: if the glob ever stops finding files, every rule above
      passes vacuously, so the file count has a floor of its own.
      
      Verified by mutation, three planted defects, one per rule — a hollow file, a
      `def test_thing(suite)`, and a trailing sys.exit(0). Each is caught by name and
      the tree comes back clean.
      
      Also splits tools/tests/test_setlist.py, whose 22 checks reported as one
      aggregate assertion that named the file and not the fault. check() now records
      each outcome and pytest replays them one named test per check; the aggregate
      stays as the belt to those braces, and the file still runs as a script.
      
      Every count is pinned, because the failure being guarded against is not a wrong
      answer but an empty one, and an empty one is invisible without a number to
      compare against. That guard earned itself immediately: I had counted 28 checks
      by eye and the truth was 22.
      PLN (Algolia) authored
    • fix(midiviz): a close PLN asked for is not a unit failure · be61d56c
      Observed for real: he closed the window at 21:40:31, a minute after the
      deploy. Exit 78, systemd correctly did not restart it, the latch was written
      the same second, and rig --ensure reported "CLOSED by hand — leaving it
      alone". The whole chain worked on the first real human close.
      
      And then the unit sat in `failed (result: exit-code)`, because
      RestartPreventExitStatus only governs the RESTART -- systemd still classifies
      a non-zero exit as a failure. So the feature working correctly showed up red
      in systemctl, in rig --status, and on the Bridge panel. That is worse than
      cosmetic: the next person debugging this rig starts by "fixing" a unit that is
      behaving exactly as designed, which is the third time this week something has
      sent someone after the wrong layer.
      
      SuccessExitStatus=78 alongside it. The two keys do different jobs and both are
      needed: one stops the restart, the other stops the lie.
      
      The test now asserts both keys carry the same number as the code, since the
      symptom of drift in either direction looks like a bug somewhere else -- a
      window that reappears, or a green unit that reads red.
      PLN (Algolia) authored
    • docs(board): 33 gigs, and the second supervisor nobody had told · d5ebbf60
      Records the catalogue jump and, more usefully, why the midiviz complaint
      survived its first fix: there were two things restarting it, and the latch only
      answered one. The unit's Restart=always came from a real bug (an unplugged LCXL
      exiting cleanly and taking the window with it for a session), so the fix had to
      keep that and still let a person close the thing -- which is what a distinct
      exit status is for.
      
      Also writes down the tree's deliberate half-state, since a cold reader running
      pytest in the shared checkout will hit an aborted collection that is fixed on
      master and looks alarming otherwise.
      PLN (Algolia) authored
    • feat(tide-table): draft tracks.json from backlog.md — 25 gigs catalogued, now 33 · 3a7c660b
      Eight gigs had a page on the site and no tracks.json, which is the only thing
      the catalog joins on, so eight real sets were invisible to every tool that
      reads the corpus. All eight had a trusted setlist anchor in backlog.md
      already; nothing needed PLN's ears, only a drafter.
      
      build_gig_tracksjson.py gains build_from_backlog() and a --backlog-slug/--md
      mode. Extended rather than rewritten: the existing segments-based path is
      untouched, and parse_score() was lifted out of an inline closure so both modes
      share one parser instead of growing a second opinion about what a score is.
      
      gigs_total 25 -> 33, tracks_total 81, and recorded rose 40 -> 44 because the
      new gigs brought their recordings into the map with them.
      
      What these files deliberately do NOT contain, because the alternative is
      inventing facts about PLN's own shows:
      
        - no `section` -- none of the eight has an ear-verified movement structure
        - no start_s/end_s/duration_s and no totalDuration_s -- no recording has
          been measured for any of them; each file says so in `durationBasis`
          rather than leaving the absence to be guessed at
        - `stage: null` where the frontmatter sets no stage
        - `bpm: null` where backlog.md carried no inline annotation
      
      Title, date and venue come from content/lives/{year}/{slug}.md only, with
      venue mapped from `address` exactly as the ear-verified OPAL-26 file does.
      
      The 13 unresolved track names are IN the files, flagged `resolved: false` with
      `file: null`, not dropped. A name silently disappearing is the failure this
      whole pipeline exists to prevent, and six of the thirteen are opal-2025 alone
      -- that gig is 11 of 17 and is the one worth a human pass:
      
        mephisteuf            TOP HATS
        toplap-solstice-2024  Deck the Hall
        38c3-house-of-tea     CCC0
        ensad                 Parce qu'Elle est la
        39c3-house-of-tea     TechnOrage
        le-vortex             TechnOrage
        opal-festival-2025    The Secret, 1er Septembre Pour Elle, Chere Mireille,
                              JEROME, Oct29 Love First, Cafes du plus chaud au plus
                              froid
      
      The three `confirm:`-flagged gigs (algolia-fdlm, algorave-lyon, ete-surprise)
      are still withheld by backlog_setlists.py's own design, pending PLN
      confirming their anchors.
      
      Suite: 480 passed, 2 skipped, 0 failed.
      PLN (Algolia) authored
    • fix(midiviz): tell systemd the difference between a crash and a decision · fbf3414d
      PLN reported "too sticky" a SECOND time, and the latch was not the whole
      answer. The unit is Restart=always -- correctly, since an unplugged LCXL used
      to make this process exit cleanly and Restart=on-failure ignored it, leaving
      his window gone for the rest of a session. But Restart=always cannot tell that
      exit from a person clicking the X, so a deliberate close came back five
      seconds later. I had asserted in an earlier commit that "a clean exit is not a
      failure so systemd never restarted it"; that was true of the old unit and
      stopped being true when the restart policy changed underneath it.
      
      Two supervisors had to be told, and having convinced only one of them looked
      exactly like having convinced neither:
      
        rig_units.ensure()  -> the $XDG_RUNTIME_DIR latch (already landed)
        systemd             -> this commit
      
      midiviz.py now exits USER_CLOSE_EXIT=78 when the close was deliberate (the X,
      Q, Esc -- never a compositor teardown, and only when the exit was otherwise
      clean, so a real failure keeps its own code and stays restartable), and the
      unit exempts exactly that status with RestartPreventExitStatus=78. Every
      failure mode still comes back; a person closing the window does not.
      
      The number is written in two files, so a test asserts they are the same
      number, and that it collides with neither a normal exit (0/1) nor the 128+N
      signal range. If they ever drift, the symptom is the window reappearing five
      seconds after being closed -- which reads as a broken close rather than a
      mismatched integer, and would send somebody hunting the wrong layer for the
      third time.
      PLN (Algolia) authored
    • feat(tray): a Close that closes, and stays closed · 8e3ef375
      PLN: "ensure i can close midi mon atm it forces on when i want the gui/tray to
      allow run and or close? its too sticky atm ahah" -- the second report of the
      same complaint, because the first fix was committed and never made live: the
      shared checkout still runs 5468507b, so tonight's midiviz has neither the X nor
      the latch. The code was right and the deploy was missing, which from where he
      sits is indistinguishable from not being fixed.
      
      Adds the other half he asked for: launchers.stop(key), and a "Close ▸" entry
      under Rig.
      
      Closability is OPT-IN per launcher, keyed on declaring a systemd unit. A
      generic close-anything row is one mis-click from stopping Ardour mid-set, and
      this menu's whole value is being safe to touch while playing. Only the MIDI
      lens opts in today; a test asserts ardour/pulsar/supercollider never quietly
      acquire it.
      
      Close ▸ is a separate submenu rather than a Raise/Close pair on each row. The
      rows have one job mid-set -- one click raises the tool you need -- and putting
      a Close next to that is the mis-click itself. So closing lives in one place,
      holds only what is running AND closable, and is empty and disabled otherwise.
      
      The latch is written BEFORE anything is stopped. ensure() runs from gig-up and
      the Bridge watcher, a converge can land in the same second, and a latch
      written afterwards races it -- the window returns and the close looks broken,
      which is the exact complaint. A test pins the ORDER, not just the outcome.
      
      stop() prefers `systemctl --user stop` where a unit owns the process: killing
      it directly leaves systemd's view wrong, and for a unit with Restart= systemd
      is what decides next. Where there is no unit it SIGTERMs, using a /proc walk
      rather than pgrep -f -- the Bash wrapper embeds our own command line, so
      pgrep -f matching would find this session and the kill would take down the
      tool doing the killing. Same identity rule as is_running(), so "it says
      running" and "this is what I would kill" cannot disagree.
      
      And it confirms instead of assuming: it polls for up to 2s and returns
      "still-running" if the process outlives the request. Reporting a clean close
      while the window is still on screen is the same class of lie as this morning's
      delete that reported 0/14 for fourteen successful deletions.
      PLN (Algolia) authored
    • docs(board): the fast-forward promise now carries its proof · efdb09f1
      Two sessions checked each other and each found what the other missed. I
      measured that the tree was not waiting on a commit; the peer re-derived that
      independently, then added the two checks my recommendation lacked -- that HEAD
      is a true ancestor (so it is a real fast-forward, not a merge in disguise) and
      that the branch touches none of PLN's five modified .tidal files.
      
      That second one is the one that mattered. Recommending a checkout across
      somebody's live set prep without measuring the direction is how a repair
      becomes a clobber, and I had asserted the switch was clean on the strength of
      the two tools files alone.
      
      Both verified here before recording them. The caution that remains is real and
      unchanged: the fast-forward swaps code under a running rig.
      PLN (Algolia) authored
    • docs(log): 043 — correct the unblock path; nobody needs to commit anything · 71403a25
      The first version told a cold reader to wait for the peer session to commit
      tools/check-mix.py and tools/gig-log.py before the main tree could be
      fast-forwarded. That precondition does not exist, and left as written it would
      have parked the tree waiting on a commit that was never coming.
      
      The peer flagged it; verified independently before amending. For both files the
      working-tree blob equals origin/master and equals f56775ae, and only HEAD differs.
      They show as 'M' because the shared tree's HEAD (5468507b) predates master's
      shared-ladder refactor (27f23515, which changed both to import
      tools/ardour_session.py) — the tree already holds the post-refactor content. Not
      in-flight work: the tree is simply behind its own HEAD's successor.
      
      Also verified, since 'one command' is a promise: HEAD is a true ancestor of
      f56775ae, and the branch touches NONE of PLN's five modified .tidal files, so his
      set prep survives the fast-forward untouched.
      
      The caution that remains is a different one, and it is real: the live rig runs
      from that tree via ~/.local/bin symlinks, so the fast-forward swaps code under a
      running system and wants three units restarted afterwards. That is PLN's call
      with eyes on the rig, not a housekeeping step to slip in overnight.
      PLN (Algolia) authored
    • docs(board): the shared tree is not waiting on a commit that will never come · c4eb90be
      The peer session's 043-open-decisions.md tells a cold reader the main working
      tree can be fast-forwarded once check-mix.py and gig-log.py are committed. They
      are not anyone's in-flight edits: worktree, master and f56775ae all hold the
      identical blob for both files. They read as modified only because the tree's
      HEAD predates 27f2531's shared-ladder refactor, which changed both files to
      import tools/ardour_session.py -- the tree already has the post-refactor
      content.
      
      Left as a precondition, that sentence blocks the fast-forward on a commit
      nobody can make. Recorded with the hashes and the two-command fix, and
      deliberately not run: PLN has folded, the live rig runs from that tree, and
      restarting his audio services unwatched is not a call to make for him.
      PLN (Algolia) authored
    • docs(log): 043 — the four decisions parked for PLN, and where the branch really is · f56775ae
      PLN folded tired and asked for the open questions written down rather than
      answered. Each of these would change how the rig sounds or what audio exists, so
      none of them is mine to guess:
      
        1. Tidal 10 sits 5.5 dB under baseline — restore, or capture as the new intent?
        2. something_about_drums' grid migration carries an overflow whose remedy is to
           comment out d3's live lines. Musical change, not a renumbering.
        3. 24 dead Take101 sources are confirmed unrecoverable and ready to drop; the
           apply needs Ardour quit, by the tool's own design.
        4. The Freebox mirror is 7 days stale and today's Take102 exists only on the
           laptop. Given the Freebox is the declared SSOT for audio, that is the real
           risk on the list, and the reason the space-reclaim thread stayed untouched.
      
      Also records the deliberate branch/worktree split so a cold reader does not
      'fix' it: origin/claude/rig-streamline is 779cf17d (merged), the shared main tree
      is still 5468507b because it holds a peer session's uncommitted check-mix.py and
      gig-log.py edits, and the live rig runs from that main tree. The merge lives in
      ../Tidal-wt-merge where the suite is green.
      PLN (Algolia) authored
    • merge(rig): fold master into rig-streamline — spawn guard beside the close latch · 779cf17d
      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.
      PLN (Algolia) authored
    • perf(ardour-sweep): derive the archive path instead of searching 25 G for it · 5468507b
      The tool worked but could not answer its own question. Proving a missing source
      is gone meant `rglob(name)` per file across the Freebox — 24 full traversals of a
      25 G network mount for one session. It ran 8m15s without finishing, and on the
      600s budget it would have returned "not found" purely because it ran out of time.
      An unfinished search reported as an absence is how you drop the last reference to
      a take that was actually recoverable.
      
      The fix is not a faster search, it is not searching. The Freebox is a converged
      mirror of $HOME (fbk), so an archived file's location is COMPUTABLE: mirror root +
      the path relative to $HOME. One stat per file instead of a walk. Runtime went from
      "8m15s, unfinished" to 0.40s, and the answer is now exact rather than best-effort.
      It still checks the session's own dead/ and audiofiles dirs, since a file can be
      moved aside rather than removed, and it refuses outright if no mirror is mounted —
      "gone" is not a claim you get to make about audio when the SSOT is offline.
      
      It also now reports HOW CURRENT the mirror is, because that changes what the
      absence means. Here: the mirror last changed 2026-08-29 22:41, so it holds up to
      Take100. Take101 is therefore absent locally AND absent from the mirror, but was
      also never eligible for that backup — so the honest statement is "it is not
      anywhere I can reach", not "it was archived and then lost". The tool now says that
      distinction out loud rather than letting the operator read absence as proof.
      
      Verdict for Tidal Live: 294 sources, 24 missing, all Take101, 12 orbits x L/R.
      Would drop 24 sources and 24 regions. That take's .mid files survive; only the
      audio is gone.
      
      Two bugs of my own, fixed here and worth the note: the mirror loop bound `root`,
      shadowing the XML tree and killing the region sweep with "'PosixPath' object has
      no attribute 'iter'" — a local rebinding eating an outer name, which is this
      repo's recurring self-inflicted wound. And an ElementTree truth test that Python
      3.14 deprecates.
      
      Verified the write refusal fires: with Ardour running it stops at "Refusing to
      write: Ardour is running (pid [2708769])" and the session file's mtime is
      unchanged. Applying it needs Ardour closed, by design.
      PLN (Algolia) authored
    • docs(board): reconcile the board against measured reality, evening of the go · 6678acd1
      PLN authorised the deletes, the CosmicFest tone and the gig page, so the board
      needed to stop describing a world where those were pending.
      
      Every row in the new block was measured today rather than recalled, which
      mattered: the standing 'catalog is STALE at 73 tracks / 23 gigs' warning is
      itself now false (81 / 25, as_of 2026-09-05), and the deletes that the board
      was waiting on a decision for have already run and been confirmed.
      
      The three remaining items all need PLN's screen or ears and nothing else, so
      they are listed as such instead of as work. Includes the verified prep for
      switching the shared checkout off rig-streamline, since that is the reason the
      running midiviz is still the old code.
      PLN (Algolia) authored
    • test(tide-table): the OPAL title join needs the page to be as-performed · 7e966a5b
      Audited what could have consumed the phantom SOUNDCHECK entry positionally.
      Answer: nothing did. The two real consumers of setlist.entries() are safe --
      take-segments.py builds a dict keyed by path stem (a phantom adds one unused
      key) and migrate-columns.py calls tracks(), not entries(). No shipped artifact
      was off by one.
      
      But the audit found a different positional coupling, live and undocumented.
      opal_title_map() joins tracks.json's 1..N performance order against
      segments_v4.json's perf_track. OPAL was played as 15 tracks; the release cut
      Desire, and segments_v4 renumbered around the hole -- its perf_track keys are
      1..13 and 15, with no 14. So the join lines up only while Desire still holds
      performance slot 14.
      
      Measured both ways rather than argued: as-performed yields 14 mappings
      including REVOLUTION; drop Desire to make the page match the release and it
      falls to 13. REVOLUTION's title is LOST -- silently, with nothing mislabelled,
      which is what would have made it hard to notice. PLN chose as-performed today
      as a documentary call about the page; this join quietly depended on the same
      answer, and nobody knew the two were connected.
      
      So: the assumption is written down where the join lives, and three tests hold
      it -- every released track gets a title, the encore by name, Desire maps to
      nothing rather than borrowing a neighbour's, and the hole at perf_track 14 is
      still really there. That last one matters: if segments_v4 is ever renumbered
      densely the reasoning stops holding, and the right outcome is a red test
      rather than a quietly missing track.
      PLN (Algolia) authored
    • feat(midiviz): an X that closes it, a [x] that pins it, and a close that sticks · 46709b26
      PLN: "needs basic menu or at least top right X to close, and its too sticky
      atm if i close i wanna close it i guess maybe X and a [x] where x goes and
      comes and is the 'stick above' feature, but it anyway is all desks always."
      
      No menu -- two glyph targets in the header's right edge, in the same micro
      font as everything else: "[x]" toggles always-on-top (the x IS the state) and
      "X" closes. Dim by default, brighter under the pointer, which is the only
      affordance a frameless window can offer. T toggles the pin from the keyboard;
      Q and Escape already closed it and still do.
      
      Controls win over the drag. The whole surface is a drag handle, so without an
      explicit hit-test first the X would only ever have moved the window.
      
      Targets are laid out from the right edge with the glyphs centred inside them,
      not sized to fit the glyphs. Sizing to the glyph gave an 8px-wide X --
      measured -- which is fine to look at and unhittable mid-set, and at 0.6 scale
      the two targets then had to either overlap or shrink below usable. Deciding
      targets first makes "disjoint and at least 18px" true by construction at every
      zoom, asserted across four scales and three widths.
      
      All-desktops is deliberately untouched: it is a KWin rule keyed on the app id,
      it is unconditional as PLN said, and toggling the pin must not disturb it.
      
      THE STICKINESS WAS NOT THE WINDOW. A clean exit is not a failure so systemd
      never restarted it -- but rig_units.ensure() starts every non-manual unit that
      is not active, and both gig-up and the Bridge watcher converge, so the lens
      came straight back with nothing in the output admitting why. Marking the unit
      "manual" would have answered the complaint by deleting the feature he asked
      for last week.
      
      So a deliberate close is recorded in $XDG_RUNTIME_DIR, ensure() honours the
      latch and says so, and anything that starts the lens on purpose clears it.
      That directory is wiped at logout, which is exactly the right lifetime:
      "always open" is about how a session starts, "closed means closed" is about
      what happens after he acts, and the two only looked contradictory. Closed for
      this session, back at next login, no state to remember to undo. Only a close
      the human asked for latches -- a compositor teardown is not an opinion.
      
      The latch path is a contract between three files that deliberately do not
      import each other, so a test asserts they agree rather than trusting three
      copies of an f-string. That test immediately earned its keep: rig_units.py
      used Path without importing it, on a line only reached once a latch existed.
      
      Also fixes a shadow of my own making: the new painter was called
      _paint_chrome, which is already a method on this widget, so it silently
      replaced the window-chrome painter and the selftest died on the signature.
      Renamed _paint_controls.
      PLN (Algolia) authored
    • fix(tide-table): stale line anchors, not the setlist soundcheck fix, dropped opal-2024 to 3 · 436d327f
      Two unrelated failing tests, investigated in order.
      
      test_agreement_distribution_within_bounds asserted tracks_total == 73, a snapshot
      from before yesterday's catalog rebuild (73 -> 81 tracks). Bumping it to 81 would
      just re-freeze the same brittleness. Replaced it with
      test_tracks_total_matches_the_generated_catalog: assert the live view's
      tracks_total equals the CHECKED-IN catalog.generated.json's n_tracks. That file is
      a 1:1 derivation of this same view, so the two can only disagree on a real
      regression (corpus changed but catalog not regenerated, or the pipeline
      dropped/duplicated a row) — never on honest growth. Verified it still fails by
      corrupting n_tracks and re-running.
      
      test_real_backlog_coverage_does_not_regress: opal-festival-2024's recovered
      tracks fell from 13/14 to 3. The obvious suspect was e8f7066a (yesterday's
      tools/setlist.py soundcheck filter) — ruled out by inspection (e8f7066a touches
      only tools/setlist.py + its test, nothing under armada/tide-table) and by
      bisection: replaying backlog_setlists.py's ANCHORS against every historical
      backlog.md since the anchors were authored (5acf72f7, 2026-06-06) shows
      n_resolved holding at 14 across ten intervening commits and only breaking at
      49e1b78e ("update: post cosmic", 2026-08-23).
      
      The real bug: ANCHORS keyed gigs by a raw 1-based LINE NUMBER pinned once against
      a single backlog.md snapshot. backlog.md is PLN's running journal, not a stable
      document, so every one of those ten commits that inserted lines earlier in the
      file silently shifted every anchor below it. Opal 2024's anchor drifted through
      blank lines and a lucky near-miss for months (the collected span still happened
      to contain the same real content), then 49e1b78e pushed the drift past the
      header entirely into the tail of the PREVIOUS gig's block, collecting 3
      unrelated lines instead of the setlist.
      
      Fix: anchor by the header's exact TEXT, resolved fresh against the live file on
      every build() (resolve_anchors()), instead of a line number frozen at authoring
      time. Self-healing against drift elsewhere in the file; raises loudly if a
      heading is ever actually renamed or duplicated, rather than silently
      mis-anchoring. The three anchors that carried an explicit end-of-block bound
      (opal-2025/Latin-Heritage bleed, mephisteuf's commented block, the 38c3-toilet
      cross-check) now store that bound as a line-count SPAN measured from the anchor,
      since that span lives inside the block and doesn't move when the anchor does.
      
      tools/setlist.py's soundcheck filter (e8f7066a) is untouched and still yields 15
      entries for OPAL 2026 — never the culprit here, just an unrelated same-day
      change that made a plausible but wrong prime suspect.
      
      Full suite: 468 passed, 2 skipped (465 baseline + 2 fixed + 1 new regression
      test), up from 2 failing.
      PLN (Algolia) authored
    • docs(log): 042 — four blockers, three of them the measuring instrument · 9bb6c294
      Captain's log for the loose-thread sweep, plus the new tool it produced.
      
      tools/ardour-drop-missing-sources.py: Ardour's "Missing File" modal appears once
      per unreadable source, no flag suppresses it, and "skip all missing" does not fix
      it — Ardour substitutes silent stubs so the session loads, but the dead names stay
      in the session file, so the modal returns on every launch. PLN hit it twice in one
      evening. This drops the <Source> and every <Region> naming it, in <Regions> and in
      all 14 playlists.
      
      It refuses to act on a guess, twice over:
        - it will not write while Ardour is running, because Ardour holds the session in
          memory and would save over the edit on quit, silently undoing everything;
        - it will not drop a reference until it has PROVEN the file is gone from both the
          session tree and the Freebox, the SSOT for audio here. A reference is the last
          breadcrumb pointing at a lost take. And an unfinished search reports as
          unfinished rather than as "not found" — only one of those justifies dropping
          the breadcrumb.
      
      Its first version rglobbed the Freebox once per missing file: 24 full traversals
      over the network, and it never finished. Now one indexed os.walk per root against
      a set of names, with the deadline checked per directory so a fruitless search
      still stops on time.
      
      041 is marked resolved in place: the blocker's premise was wrong.
      PLN (Algolia) authored
    • feat(tray): 🌊 Launch Gig becomes GIG UP — one reconciler, armed not crippled · 9f8d9005
      Un-parks the rewire that was blocked behind the Ardour double-launch (log 041).
      The patch saved with that log no longer applied — perf-tray.py moved under it in
      90afa239 — so this is reimplemented against the current file rather than forced;
      `git apply --reject` had left the file half-patched and was reset before starting.
      
      PLN, 2026-09-05: "click launch gig started pulsar, not ardour??", then "is our
      launch gig now autodoing?".
      
      The old button opened Pulsar and nothing else. That was deliberate — #116 says
      the hot path must not be able to start the sound by accident — but the name
      promised a gig, so it read as broken rather than as careful, and the rig's real
      launcher (the Bridge's RIG UP, which converges every unit and then opens the
      apps) had not been pressed in 6.7 days. A safety rule that makes the safe path
      invisible protects nothing; it just moves the launch to a hand-typed command.
      
      So the tray and the Bridge now drive the SAME reconciler, and #116 is honoured
      by ARMING instead of by crippling — the idiom already proven two menu entries
      below on Restart SuperDirt:
      
      - SuperDirt already up  → the press only converges and focuses. One click.
      - SuperDirt down        → the press would start the sound. First click arms and
                                says so in a tray notification; a second click, which
                                means reopening the menu, commits. Arming lapses after
                                10 s.
      - No confirmation dialog, ever: a modal stealing focus mid-set is its own
        hazard (#136).
      
      "Would this start the sound?" is answered from SuperDirt's unit state, not by
      asking the Bridge — the cheap local truth, and it keeps an HTTP round trip out
      of the front of a button press. The label carries all three states so the
      dangerous one announces itself before it is pressed.
      
      The POST runs in a worker thread and is never waited on. A cold converge takes
      ~100 s, and the reason PLN launched Ardour by hand mid-evening — believing RIG UP
      had failed — was a face that showed a spinner and no window. The Bridge answers
      immediately and runs the job in the background; the Bridge page opens right away.
      Nothing in the worker touches Qt.
      
      Verified end-to-end, with Ardour already running and SuperDirt up:
        POST /api/rig {"launch_apps":true} → {"ok":true,"status":"started"}
        journal:  pulsar: SPAWNED pid 2779892 (it was down)
                  ardour: running → focus
                  midimon: running → focus
        ArdourGUI process count: 1 before, 1 after.
      
      That "ardour: running → focus" is the line this whole thread existed to produce:
      the last time this path ran it said "launched" while Ardour was up, and the two
      instances killed each other over the session lock.
      
      Tray after restart: active, NRestarts=0. Incidentally closes the suspected
      perf-tray memory leak — systemd's own accounting reports a 59.6M memory peak
      over 2h15m wall clock, so the "2.6 GB RSS" in the hand-off was a misread of
      virtual size, not a leak. Nothing to fix.
      PLN (Algolia) authored
    • fix(grid): finish the #94 column remap on the three files it skipped · 9381e227
      gig-up's "surface grid intact" blocker was NOT the CosmicFest false positive
      again — that was the first thing to rule out, because check-drift.sh once
      reported "drift" that was really PLN's own in-progress set prep and proposed
      `git checkout` on 13 files as the remedy. Checked: check-drift.sh passes, and it
      clears all five currently-modified .tidal files individually. None of PLN's open
      edits are implicated.
      
      The failure is gig-up's stricter composite (gig-up.sh:343), which also demands
      `migrate-columns.py --plan` report zero moves. It reported 10, across four
      files that are all COMMITTED and all untouched by b5ad8b6c ("finish the #94
      column remap", 2026-08-23) — `git show --stat` has no match for any of them.
      Three were last edited 2026-08-21, before that commit; the remap-finish simply
      missed them. So the direction is forward onto the authored grid, not a reversion
      of anyone's work — a standing migration debt, not a clobber.
      
      Two of the three are SWAPS (^52<->^32 in perfect, ^89<->^57 in vague_de_crime),
      which is exactly where a naive rewriter eats itself: renumber ^52->^32 first and
      the following ^32->^52 sees the value it just wrote, collapsing both onto one
      control. Checked the tool before letting it near the tracks — rewrite() maps each
      line in a single `CC_REF.sub` pass, so every match is resolved against the
      ORIGINAL text and a swap is atomic. Confirmed in the diff: both directions
      flipped, all four occurrences in vague_de_crime, nothing collapsed.
      
      Verified after: pvlint 3 tracks, 0 errors; silent-eval --seeded OK (every
      declared orbit still emits events); migrate-columns --plan now reports 0 moves
      for these three.
      
      Deliberately NOT applied: something_about_drums.tidal. Its five moves come with
      an overflow (d3's ^44 has no slot in the new grid), and the tool's remedy for an
      overflow is to COMMENT OUT every live line of that orbit and head it with a
      FIXME. That silences part of d3 until it is rewired by hand — a musical
      consequence, not a mechanical renumbering, so it is PLN's call and gig-up will
      keep flagging the grid until he makes it.
      
      Also noted, not touched: perfect.tidal:61 carries a pre-existing PV010 warning
      (d5 gates on ^89, momentary row F, wants ^57 on the latching row E), and the
      commented-out alternative at the_revolution_will_be_sampled.tidal:47 still names
      ^52 — the rewriter deliberately never edits comments, since a commented ^NN is
      an alternative PLN may re-enable.
      PLN (Algolia) authored
    • fix(gate): two typos silencing a track, and the boot check that lied about them · e47361ca
      Three of gig-up's four standing NO-GO blockers were one file and one buffering
      bug. Cleared them, verified each by planting the failure back.
      
      **something_about_drums.tidal — two typos, both silent by design of the tooling**
      
        :13  `(<| "~ <s s <s!3 ~> <~!7 [~ s]>>")d1` — a stray `d1` pasted straight
             after the closing paren, no operator, no newline. GHC reported it as
             "Variable not in scope: d1" at 13:56, and because a parse error takes the
             whole do-block with it, the reported line belonged to the GROUP, not to
             the break. The d2 block never ran.
        :81  `d8 $ gF1 $ gM1` — d8 copy-pasted from d1's block without updating the
             family macro. The authored map puts d8 in gM2 with d2 and d3, so d8 was
             wired to the wrong mute family: pressing d8's mute would have taken d1
             with it, live, and nothing about the code looked wrong.
      
        after: `silent-eval --seeded` OK (every declared orbit emits events),
        `fix-mute-roles --check` WOULD REWRITE: 0 lines, `pvlint (setlist)` ok.
      
      **check-boot-blocks.py — the check was the third defect**
      
      `tools/check-boot.sh` run alone passed clean, every line green, while gig-up
      called the same check FAIL. The difference was not the rig. Under gig-up the
      checker crashed:
      
          ValueError: invalid literal for int() with base 10: '6C1o'
      
      `'6C1o'` is two output lines interleaved character-wise. The cause is in the
      capture: stdout and stderr are deliberately ONE merged pipe (separate capture
      would put every marker before every error and attribute each error to the last
      block — the mis-attribution this tool exists to prevent), but GHC block-buffers
      stdout while writing errors to stderr unbuffered. Two writers, one pipe, so a
      marker could be flushed into a chunk a stderr write had already cut into. Rare
      enough to look like a phantom, and it presented as "your boot helpers are
      broken" — the rig's most alarming failure — when nothing was wrong.
      
        - `hSetBuffering stdout LineBuffering` is now the script's first statement, so
          each marker is one atomic write, in order.
        - Markers are self-delimiting (`@@PVBLOCK i start @@`) and matched by regex, so
          a torn one CANNOT parse as intact.
        - A torn marker, or a parse error that cannot be placed, is now INCONCLUSIVE
          (rc=2, "rerun") rather than a crash — and rather than a silent skip, which
          would have hidden a real error behind a green verdict.
      
      Verified: 3 consecutive clean runs, then a planted unbalanced paren appended to
      the block starting at :581 was caught and blamed on exactly that block
      (rc=1, "block 16 (starts near bt.hs:581) is NOT one statement"). A checker that
      cannot catch a planted bug is worth nothing, so that test is the one that counts.
      
      gig-up: 4 blockers -> 2 (surface-grid migration debt, fader baseline).
      PLN (Algolia) authored
    • fix(midiviz): survive an LCXL unplug/replug in-process, no more clean exit · e9d16314
      The bug: midiviz resolved its ALSA source port ONCE at startup and shelled
      out to `aseqdump -p <port>`. Unplugging the LCXL removes the ALSA client it
      was subscribed to, `aseqdump`'s stdout hits EOF, the reader thread's for-loop
      just returns, and nothing else was keeping `app.exec()` alive — the window
      closed with exit 0. Because that is a CLEAN exit, `Restart=on-failure` never
      fired, so PLN's "always open" glyph-rain lens was gone for the rest of the
      session the moment he unplugged the board. Reproduced from tonight's own
      journal: `midiviz.service` ran 6m57s and exited status=0/SUCCESS the instant
      the surface came off USB — textbook rig failure mode #1, "a binding resolved
      once, killed by a replug, never re-resolved", except this time the binding
      was the whole window's reason to exist rather than just a port variable.
      
      The fix, mirroring the resolve/rebind pattern already proven in
      `tools/lcxl-leds.py` (`find_seq_port` + `invalidate_ports`, cached and
      dropped on failure):
      
      - `Reader` now tracks `.alive()` (child process poll) and marks itself
        `error = "closed"` when its `aseqdump` child exits on its own (vs. an
        intentional `.close()`), so the difference between "I quit" and "I died"
        is visible to the widget.
      - `MidiViz` gained a second QTimer (`RECONNECT_MS = 2000`, matching
        `midi-autoconnect.sh`'s own reconcile cadence) that re-resolves the source
        every tick and swaps the `Reader` in place if it moved, died, or vanished.
        Losing the source is never fatal any more: the window drops to its
        existing idle/dim-pulse paint state (already built for "no events
        recently") and keeps ticking, painting, and listening for the surface's
        return — no `sys.exit`, no fatal path added anywhere.
      - Liveness is gated by `_hardware_present()`, a `type=kernel` + name-match
        scan of `aconnect -l` reused from `midi-autoconnect.sh`'s
        `DIRECT_LEG_AWK` (`hw = ($0 ~ /type=kernel/ && $0 ~ /Launch Control
        XL|LCXL/)`). This matters because `lcxl3-driver.service` publishes a
        VIRTUAL port literally named 'ParVagues LCXL3' — the translated,
        corpus-numbered stream `resolve_watch_port()` deliberately prefers, since
        that is the CC numbering the grid and every `.tidal` file actually speak.
        That virtual client can outlive a physical unplug for a beat if the driver
        hasn't noticed yet, so a bare name match would report "still connected"
        against a ghost carrying nothing. The rebind tick distrusts a match ONLY
        when the matched label is itself LCXL-named and no real hardware backs
        it; a "Midi Through" catch-all match needs no hardware and is trusted as
        before. Content still comes from the preferred (possibly virtual)
        port — only the "is it actually there" judgement moved to hardware.
      - A user-pinned `-p` port keeps working, checked instead against
        `aseqdump -l`'s live listing (a pin surviving a client renumbering across
        replug is not guaranteed, same as any other resolved-by-address binding).
      - `tools/midiviz.service`: `Restart=on-failure` → `Restart=always` +
        `StartLimitIntervalSec=0` (moved to `[Unit]`, where it belongs — the first
        install attempt logged "Unknown key 'StartLimitIntervalSec' in section
        [Service], ignoring", caught before it shipped) as belt-and-braces under
        the in-process fix, since the unit holds no audio ports and a restart
        loop costs nothing real.
      
      Verified:
      - `--selftest`: `parsed=68 ingested=250 frames=159 paints=170 grabs=83
        distinct_sampled_colours=165 platform=offscreen -> PASS`.
      - Reinstalled the unit (`install -m644` + `daemon-reload`); no more "Unknown
        key" warning in the journal on the next start.
      - Live restart: `ActiveState=active`, `MainPID=2728202`, `NRestarts=0`,
        bound to the "Midi Through" fallback (no LCXL physically plugged in
        tonight, confirming the hardware-gated fallback still works with zero
        surface present).
      - Port-loss simulation (couldn't unplug hardware; killed the reader's
        `aseqdump` child directly — the same failure shape as the source
        disappearing under it): child pid 2728206 -> `<defunct>`; within the next
        2s rebind tick a fresh `aseqdump -p 14:0` (pid 2730846) appeared as
        midiviz's child. Main PID stayed 2728202 throughout, `NRestarts` stayed 0,
        `ActiveState` stayed `active` — recovered entirely IN-PROCESS, no systemd
        restart needed. CPU time kept accumulating (1.237s over 38s wall) proving
        the paint timer never stopped ticking.
      PLN (Algolia) authored
    • fix(rig): make a duplicate Ardour spawn structurally impossible, and log the decision · 3c00a9cb
      The parked blocker was wrong about its own cause, which turned out to be the
      more useful finding.
      
      RIG UP answered one press with "ardour: launched — launched Ardour" while Ardour
      was already up. The two instances collided over the session lock and BOTH exited,
      taking all twelve orbit links with them mid-evening. The obvious suspect was
      is_running()'s `exe` regex, so the GIG UP rewire was parked behind "fix the
      detection first".
      
      Measured cold, the detection is fine. With Ardour running, is_running() returns
      True: argv0 is `ardour-9.7.0` (the wrapper /usr/bin/ardour9 is a shell script
      that exec's /usr/lib/ardour9/ardour-9.7.0) and `[Aa]rdour[-\d.]*$` matches it.
      Two presses in a row now correctly answer "running -> focus". The false negative
      is not reproducible, and -- the actual defect -- nothing anywhere recorded the
      decision, so the cause cannot be recovered. The incident had to be reconstructed
      from a chat transcript.
      
      So stop repairing a probe that measures correctly, and remove the probe's veto
      over an irreversible action:
      
      - A spawn guard keyed on a pidfile in $XDG_RUNTIME_DIR, consulted only after
        is_running() says no. It blocks while the pid we spawned is alive, plus a 20s
        window covering fork->exec->/proc visibility -- the one interval in which
        is_running() is legitimately blind. A pidfile rather than a module global
        because the two faces are two processes: the web Bridge and the perf-tray both
        call launch(), and an in-memory note in one is invisible to the other.
      - The guard re-checks process IDENTITY, not just liveness, so a recycled pid
        cannot jam it shut forever. A guard that fails closed permanently is its own
        outage.
      - _log() writes every launch decision to stderr -> the journal.
      - Ardour additionally sweeps a SUPERSEDED .pending aside before launching. No
        flag suppresses the "recover from crash?" modal -- only the file's absence does
        -- and that modal is exactly what the hot path must not contain (#136). It
        moves (never deletes, into the session's own dead/) and only when the session
        was SAVED AFTER the pending was written, i.e. a later save already superseded
        it. A .pending newer than the save holds unsaved captures; that one deserves a
        human, so its dialog is left to appear.
      
      Verified live, in this order:
        1. cold launch          -> SPAWNED pid 2708769, session path passed, 1 ArdourGUI
        2. immediate 2nd press  -> "running -> focus", still 1 ArdourGUI
        3. is_running monkeypatched to lie False (the incident's exact condition)
                                -> "NOT spawning -- we started pid 2708769 14.9s ago",
                                   still 1 ArdourGUI
        4. same, 52s later (past SPAWN_GRACE) -> still refuses, on pid liveness
        5. dead pid + old timestamp -> guard stands down, launching is possible again
      
      Also: the session chooser is gone from every entry point. Stock ardour9.desktop
      runs `ardour9` with no argument, so every launch from the menu asked "Tidal Live
      or Tidal Multi?" -- a modal in the hot path, and a chance to open the ARCHIVE by
      mistake (that same mixup produced a confidently wrong fader report on
      2026-07-28). A user-local override sends the default click straight into Tidal
      Live and keeps the chooser as a right-click action.
      
      Stale comment corrected: the real binary is 9.7.0, not the 9.2.0 recorded there.
      PLN (Algolia) authored
    • merge(rig): fold claude/rig-streamline into master · 3c210a2a
      Eight commits of rig work that had accumulated on the branch while the shared
      checkout sat on it. Reviewed as a diff, not taken on faith:
      
        perf.sh        stop DWIM-launching Pulsar into /usr/local/sbin -- 183
                       SIGABRTs between 08-19 and 09-05 because BASH_SOURCE resolved
                       to the deploy path, and the perf watcher re-entered every 30s.
        rig_units.py   neither LCXL painter may be auto-started blind; gig-up.sh's
                       generation-aware chooser owns that decision.
        midiviz        its own Wayland app id (desktopFileName, not applicationName),
                       so focus and KWin rules can name this window and no other;
                       plus the CC readout PLN asked for while wiring.
        logs 040/041   the phantom port, and the GIG UP rewire parked behind the
                       Ardour double-launch bug.
      
      The four other branches were already upstream by patch-id.
      
      # Conflicts:
      #	TASKS_DUMP.md
      PLN (Algolia) authored