1. 29 Jul, 2026 35 commits
    • fix(boot): the "fade into oblivion" was the PREVIOUS pattern cutting the new one dead · be52627d
      The bug that ate PLN's whole afternoon, and the reason he was working around it by
      hand: "so for now hush then ctrl+enter seems like a fix ahah but not a lovely one :D".
      
      ## The symptom, and the clue that cracked it
      
        "wait i feel theres still a xfade issue? when i leave d5 on the 33 effcet, it
         fades in oblivion!"                                    (and d4, d7, d8, d12...)
        "reboot-then-ctrl-enter: doesnt seem to obliviate, loops forever. sounds like a
         track-to-track state bug"
      
      Same code, two outcomes, depending on history. That is the whole diagnosis in one
      sentence — and `hush` curing it is the confirmation, because `streamHush`
      PREPENDS silence to the pattern history.
      
      ## The mechanism
      
      `BootTidal.hs:100` made every dN a 4-cycle crossfade (#13):
      
          xfade i = transition tidal True (Sound.Tidal.Transition.xfadeIn 4) i
          d5 = xfade 5 . (|< orbit 4)
      
      Tidal's xfadeIn is one line:
      
          xfadeIn t now (pat:pat':_) = overlay (pat |* gain rising) (pat' |* gain falling)
      
      The envelopes are correct — measured with queryArc, the incoming rises 0.442 ->
      1.0 and the outgoing falls 0.990 -> 0.0 across the 4 cycles, then both saturate.
      The bug is that the outgoing pattern is turned DOWN but never OFF. `|*` takes
      structure from the LEFT, so `pat'` keeps every event it ever had. Measured: at
      cycle 64 of a 4-cycle fade, the outgoing pattern still emits one onset per cycle.
      
      **And a gain-0 event is not a harmless event.** SuperDirt broadcasts the cut group
      in `playSynths` (DirtEvent.sc:182-189) *before* amplitude is ever read — `~amp`
      only appears later, at line 160, as an argument to the gate synth — and for a
      positive `cut` it sets `cutAllSamples: 1`, releasing EVERY voice in the group
      regardless of sample. So the silent ghost keeps killing the incoming pattern's
      voice on every single hit, forever. The orbit is not fading out. It is being cut
      to death by its own predecessor.
      
      Every observation follows, including the ones that looked contradictory:
      
        * only orbits that own a `# cut` die. In take_5_drops, where PLN said "weirdly
          all d123 stay", d1/d2/d3 have NO cut group — and the ones he reported dying in
          vague_de_crime (d4, d5, d7) have cut 4, cut 5, cut 7.
        * a fresh boot is fine: one pattern in history, and `xfadeIn _ _ (pat:[]) = pat`
          returns it untouched, envelopes and all.
        * "4 bars" is the fade length. You hear the OLD pattern fade out — it wins the
          cut, being overlaid second and therefore sent last — and the new one never
          arrives at all.
        * d8's "weird glitches instead of proper heading breaks": cut 8, a chopped break
          fighting its own ghost.
      
      Not explained by this, and still open: take_5_drops' d4, which has no cut group at
      all. That fade is a separate lead (it shares `crushbus 41` with d7 — see the new
      pvlint PV011).
      
      ## The fix
      
      Keep Tidal's envelopes EXACTLY, and stop the outgoing pattern when its fade ends:
      
          xfadeCutIn t now (q:q':_) =
            overlay (q |* gain rising) (playFor now (now + t) q' |* gain falling)
      
      Measured, onsets per cycle (incoming/outgoing), fading at cycle 0 over 4:
      
          xfadeIn 4    1/1 1/1 1/1 1/1 | 1/1 1/1 1/1 1/1    ghost forever
          clutchIn 4   0/1 0/1 1/0 1/0 | 1/0 1/0 1/0 1/0    clean, never overlaps
          xfadeCut 4   1/1 1/1 1/1 1/1 | 1/0 1/0 1/0 1/0    clean from cycle 4
      
      `clutchIn` is also clean — it degrades one pattern into the other so they never
      overlap at all — and is arguably the better transition for a rig where most orbits
      own a cut group. It is NOT the default here because it changes the feel of every
      transition on the rig, and 7 days before a gig is not when to do that. It stays
      available as `clutchIn`, and Tidal's original stays reachable as `xfadeLeaky` for
      A/B comparison.
      
      Audibly, for a cut orbit, the transition becomes: the old pattern fades out over 4
      bars, the new one arrives at full exactly as its envelope reaches 1.0. Which is
      what PLN already describes hearing — except that now the new one arrives.
      
      ## Validation
      
      - The binding was lifted VERBATIM out of BootTidal.hs and typechecked against the
        type `transition` demands (`Time -> [ControlPattern] -> ControlPattern`). The
        stream block cannot be typechecked in place — it closes over a live `Stream` —
        so this is how that part of the file gets checked at all.
      - `tools/check-boot.sh` gains **Pass 5**, a permanent gate: it lifts `xfadeCutIn`
        out of the boot file and counts onsets from the outgoing pattern at cycles
        4/5/8/16/64/256. Must be 0.
      - Mutation-tested, which is the only reason to trust it: with `playFor` removed
        the gate reports 6 ghost onsets and fails. Passes 1-4 all stay green on the
        broken version — it typechecks, it parses as Pulsar sends it, and it emits
        events. Counting the outgoing onsets is the only check that can see this class
        of bug, which is exactly why it now runs on every boot check.
      - All 32 blocks still parse as single statements (#79 seam intact) — the new
        multi-line `case` sits inside the existing `let`, so it is still one statement.
      PLN (Algolia) authored
    • fix(corpus): role-align the 5 tracks PLN was playing — carries his in-flight edits too · 6ed637de
      These five could not go in the previous commit because they hold PLN's own
      uncommitted work from this afternoon's recording session, mixed in with the role
      alignment. Committing them anyway, because durability beats a tidy diff: once they
      are in git, a stale Pulsar buffer overwriting one of them shows up as a diff
      instead of vanishing silently (#95, the buffer-clobber trap).
      
      ## What is HIS in this commit
      
      - **bombe_dj**: he resolved the d1 button overflow BY HAND, exactly the way #54
        proposes. The three lines phase 2 had commented out with a FIXME are back, folded
        onto d1's own controls: the sample swap moved to a KNOB (`midiOn "^29" (# "kick:4")`)
        and the kick variants merged onto `^41`. That is a manual gSel, and it is better
        than what the FIXME asked for.
      - **the_revolution_will_be_sampled**: he fixed the d8 inversion himself, restoring
        `midiOn "^92" (ply ...)` / `midiOff "^60" (mask ...)` before any tool got there.
      - **vague_de_crime**: a `# cps (130/60/4)` gesture parked as a comment and another
        moved onto ^30; d1's commented overflow restored onto ^41.
      - **do_it_right**: a `dr` typo removed, a d5 gain nudged 1.3 -> 1.4, a `-- TODO:
        Move to d6?` note, and an extra d5 ply gesture.
      - **take_5_drops**: the `31` -> `21` typo in d12's `n` pattern, one `>|` -> `|>` on
        d5, gains adjusted.
      
      ## What is MINE
      
      The button-role alignment only — `"^NN"` token moves, per the two commits above.
      
      ## One thing worth recording about the validation
      
      bombe_dj's d7 exposed a limit of verdict-equality checking. The swap rewrote
      `midiOn ("^59" - "^91")` to `midiOn ("^91" - "^59")`, and subtraction is not
      commutative — that LOOKS like an inverted gesture. It is not: all three references
      in the block flipped together (the two operands and the outer/inner `midiOn` pair),
      so it is a consistent relabeling and the behaviour is identical with the two
      physical buttons exchanged.
      
      But silent-eval could not have told me either way. Both CCs seed to 0, so
      `0 - 0 = 0` whichever order they are in, and the default branch is untouched by
      construction. **Verdict equality at the seed proves nothing about behaviour under a
      knob press.** It is the right gate for "did this rewrite silence anything at boot"
      and the wrong gate for "does this control still do what it did". The second
      question needs pv-at (#74), or an ear.
      PLN (Algolia) authored
    • fix(corpus): 81 button cells move onto the row their ROLE belongs to · 2c178c96
      Applies the convention from the previous commit across the corpus: gates onto the
      latching row, gestures onto the momentary one. 34 files here contain nothing but
      `"^NN"` token changes — verified mechanically by normalising every changed line and
      checking the -/+ pair is identical once the CC is masked.
      
      ## What moved
      
        52  corpus-wide repairs (tools/fix-button-roles.py, three converging passes)
        29  setlist permutations (tools/migrate-columns.py --apply)
      
      The two tools do different jobs and both are needed. fix-button-roles only MOVES a
      control to a free cell, so it declines a case where the target is occupied.
      migrate-columns plans a whole column at once, so it can SWAP — which is what the
      remaining cases needed: d5's `ply` and d5's take-chooser both wanted a cell, and
      exchanging them puts the held flourish on the momentary button while the chooser
      (a state: which take plays) takes the latching one. That reading was not designed,
      it fell out of role-first assignment with a positional fallback, and it is right.
      
      ## Proof it changed no sound
      
      `tools/silent-eval.py --seeded` before and after, twice (after the corpus pass and
      again after the permutation): **byte-identical verdicts** both times, including the
      same 3 harness build failures (#93). Every button CC seeds to 0 in BootTidal's
      `_seed`, so permuting CCs *between two calls in the same block* cannot change
      either call's default branch — `midiOn` stays never-applied and `midiOff` stays
      always-applied. The edit is event-neutral at boot by construction; the harness run
      confirms the construction.
      
      `tools/surface-columns.py` still reports 83/83 orbits column-aligned with 0
      renumbers needed — the swaps stay inside each orbit's own column, so the property
      phase 2 established is preserved.
      
      ## Still open, deliberately
      
      12 sites where ONE orbit drives two gestures. Only one momentary cell exists per
      column, so the second has to sit on the latching row. Reported as PV010 `info` and
      not repaired: the real fix is gSel (#54), folding a pair of gestures onto one
      control. Naming it beats hiding it.
      PLN (Algolia) authored
    • fix(surface): a button's ROLE decides its row — the remap had been assigning cells in reading order · 5089eec0
      PLN found this by ear, twice in ten minutes, while recording OPAL takes:
      
        "techno drum mask is inverted on d8, should ALWAYS be the mask midiOff on ^60
         and the ply or other multiplier/nassim button effect on the push-release 92"
        "drums in revolution seem way too fast ... yea def an inversion"
      
      ## The bug I shipped
      
      Phase 2 of the surface remap (5910aacc) column-aligned every button reference, and
      assigned each orbit's two cells in FIRST-APPEARANCE ORDER. That is role-blind, and
      tracks conventionally write the gesture line above the gate line:
      
          $ midiOn  "^92" (ply "1 <2!3 4>")      -- momentary flourish
          $ midiOff "^60" (mask "t(4,8,1)")      -- latched gate
      
      so the first control encountered — the gesture — took the LATCHING cell, and the
      gate took the momentary one. Measured on the diff itself rather than on corpus
      history: of the button lines that commit moved, **32 were inverted**, 11 happened
      to be repaired, 10 were already wrong, 2 were right both times. 13 files, nearly
      all of them in the OPAL setlist.
      
      "Way too fast" is the audible signature: a `ply` on a latching button stays
      multiplied after one press. The mirror image is a `mask` that only gates while a
      finger holds it down. Neither errors. Neither is silent. Only the ear catches it —
      which is precisely the class of bug that needs a machine check.
      
      ## The convention, now written down once
      
          row E (41-44, 57-60), latching   -> GATES    (mask, struct)
          row F (73-76, 89-92), momentary  -> GESTURES (ply, fast, stut, chop, slice...)
      
      A real limit of the grid, found while encoding it: **d1-d3 own only ONE button.**
      Their row-F cells (73,74,75) are the per-family mutes gMute1/2/3, so a gesture on
      ^41/^42/^43 is not an inversion — it is the only cell that exists. Every tool here
      skips those columns rather than inventing a slot.
      
      ## tools/button_roles.py — one judgement, three consumers
      
      migrate-columns.py ASSIGNS cells, fix-button-roles.py REPAIRS them, pvlint PV010
      REPORTS them. Three copies of a musical rule is three chances to drift (the #97
      lesson, applied before it could bite). Two parsing rules were earned in the space
      of one afternoon, and both were wrong in my first cut:
      
      1. **A gate beats a gesture at the SAME level.** `mask "t(8,16,1)" . chop 16` is a
         latched break-gate that happens to chop while open. Treating it as ambiguous is
         what hid revolution's d8 pair from the first scan — the very pair PLN heard.
      
      2. **But only at the same level.** `superimpose (struct "t . t(3,8)" . arpeggiate
         . (|+ note 12))` is a HELD FLOURISH; the `struct` builds the added layer's
         rhythm two levels down and says nothing about the button. A plain substring
         search called phunk's d6 a gate and moved it to the latching row — the same
         inversion, one level deeper. So the role belongs to the top-level chain, with
         nested arguments stripped and string literals skipped (`mask "t(4,8,1)"` would
         otherwise look like it opened two parens and swallowed the rest).
      
      3. **The body is not the rest of the line.** `midiOn "^91" ( -- SLICE!` puts the
         function on the FOLLOWING lines. A same-line regex returns "unknown", and an
         unknown control gets a positional fallback — re-creating the inversion. The
         classifier now follows the parentheses, capped at 8 lines.
      
      ## Validation
      
      - 214 tests pass (24 new: 14 for the classifier, 10 for PV010/PV011).
      - The classifier is CONVERGENT: applying the repair twice yields 0 further
        rewrites. That is what caught mistakes 2 and 3 — each fix made the tool disagree
        with its own previous pass, and the disagreement was the bug report.
      - pvlint on the setlist: 0 errors, and PV010's 12 unfixable cases are reported as
        `info`, not warnings, because an orbit with two gestures genuinely cannot put
        both on the momentary row. A lint that nags about the impossible gets ignored,
        and this one has to stay trustworthy enough to gate a gig.
      
      ## PV011 — one FX bus slot shared by two orbits
      
      PLN's ask, right after PV004 surprised him with a shared cut group: "putain a
      unexpected cut shared, well done! like shared busses they screw things. can we
      lint and flag these? diff bus shared button is ok, but to be detected too". A
      `<fx>bus N` is a SLOT in SuperDirt, so two orbits naming the same number share one
      instance and the last event to land sets the amount for both. It immediately found
      take_5_drops putting d4's bass and d7's choir on `crushbus 41` — the standing
      suspect for "d7 feels way more attacky than before". Different effects on the same
      number are fine and are not flagged: the namespace is per effect, exactly as he
      said.
      PLN (Algolia) authored
    • feat(pvlint): PV009 — gain MULTIPLIED by a control that seeds to zero, i.e. a… · 2cd4108a
      feat(pvlint): PV009 — gain MULTIPLIED by a control that seeds to zero, i.e. a layer that never arrives
      
      PLN, mid-run, working it out himself: "d9 on wap didnt take effects on 17 and 19?
      ok no its the 17 superimpose that melts too !! does our xfade screw the
      superimposes? 💡"
      
      The xfade is innocent. wap.tidal:91 is
      
          $ superimpose ( (# "acidOto3091") . (|- note 12) . (|* gain "^17") . (# cut 91) )
      
      and ^17 seeds to 0 under BootTidal's policy ("A/B knobs -> 0, effect amount off").
      Gain MULTIPLIED by 0 is silence, so the superimposed layer is not quiet — it does
      not exist. The 4-cycle dN crossfade then eases from the previously-audible state
      into that silence, which is what reads as a melt and is exactly why the xfade
      looked guilty. It was the messenger.
      
      The distinction the rule encodes: `#` ASSIGNS and survives a 0 seed
      (`# crushbus 51 (range 16 4.5 "^53")` at seed 0 is simply crush 0), while `|*`
      and `|/` ANNIHILATE. Only the multiplicative form can delete a pattern, which is
      why flagging `#` sites would bury the real finding under hundreds of false ones.
      
      SEVERITY IS `info`, DELIBERATELY. This idiom is usually correct — it is a bring-in
      fader for a layer, and PLN uses it that way. The rule does not exist to call it
      wrong; it exists so nobody hunts it BY EAR. Making it a warning would fire on a
      normal, intended idiom and get the whole gate ignored, the mistake already made
      once in check-drift's first draft. It shows under `pvlint --info`.
      
      WHY IT MATTERS MOST RIGHT NOW: a remap moves the CC, but it does not move the
      KNOB. Physical positions survive across a surface change, so a knob left at 0
      silently acquires a new victim. That is the single root cause behind everything
      PLN heard this session — d9's layer gone on wap, and the same class of staleness
      behind the d4/d5/d8 symptoms (latched buttons whose orbit changed under them). It
      is the identical failure to the very first question he asked today: Ardour's fader
      raised by mouse while the physical fader sat at 0. There are no motors, so the
      surface never follows the map.
      
      SCOPE, measured (both forms — `|* p (range 0 hi "^NN")` and the bare
      `|* p "^NN"`, which my first scan missed and which is the worse of the two):
          33 sites across 25 files corpus-wide
           2 in the OPAL setlist:  wap.tidal:91 (bare), desire.tidal:75 (range 0 1.5)
      Notable elsewhere: scratchomatic multiplies gain by "^84" — an ARDOUR-learned
      fader — and computer_riddim/computer_riddub/anniv multiply by "^50", the gF2 DJ
      filter knob.
      
      6 new tests. The negatives carry the weight, as always: `range 1 1.5` (the
      prescribed neutral-low-end fix) must NOT flag, `#` assignment must not flag, and
      C1-C3 must not flag because they seed to 0.5 (centre), not 0. 496 pass.
      PLN (Algolia) authored
    • refactor(surface): ONE authored grid — five hardcoded copies become derived or generated (#97) · 2778f1cd
      PLN named this himself while testing the remap: "i see the hud shows d9 on A8 so
      didnt we migrate parvagues HUD to new convention? why 2 sources of truth tbh"
      
      It was five. The CC -> physical-control -> owning-orbit mapping was hardcoded
      independently in:
      
          tools/surface-columns.py     GRID
          tools/migrate-columns.py     KNOB_A/B/C, BUTTONS, ARDOUR, label(), and the
                                       destination ARITHMETIC (28 + orbit, 48 + orbit)
          tools/lcxl-leds.py           12 + (orbit - 8) for the d9-d12 level knobs
          tools/pvlint/rules.py        PV008's BT_CCS / BL_CCS / FAMILY_CCS
          <hud>/lib/render.js          ORBIT_CONVENTION
      
      After the 2026-07-29 remap the Python copies moved and the HUD's did not, so the
      topbar drew d9 on A8 while the LED board and the .tidal files both said A1. The
      display contradicted the hardware under his hands, mid-test, and NOTHING FAILED —
      which is the property that guaranteed it would happen again on the next remap.
      
      NOW
      tools/lcxl_grid.py is the only place the grid is written: the row/column table,
      the role of every cell (level / fx / fx2 / gate / gate2 / family_filter /
      family_mute), the Ardour-owned set, and gPanic. Everything else derives from it,
      and the two non-Python consumers read a GENERATED artifact:
      
          tools/lcxl_grid.json                        for anything outside Python
          <hud>/lib/lcxl-grid.generated.js            imported by render.js
      
      Same pattern as the fleet colour language (models.py -> gen_tokens -> tokens.css):
      author the ontology once in Python, generate for every other language.
      
      Two details worth keeping:
        * ROW ALIASES. The consumers had each invented their own names — D/fader,
          E/btn1/BT, F/btn2/BL. Forcing one vocabulary would have churned five files
          and PLN's own muscle memory for zero benefit, so every row carries all its
          names and each tool keeps printing what it always printed.
        * ROWS E AND F ARE ONE ROW OF EIGHT, not two of four. They are non-contiguous
          on the hardware (41-44 then 57-60) and modelling that as two rows is exactly
          what put d6's second button in column 5 in the old map. Asserted directly.
      
      THE TEST IS THE DELIVERABLE
      14 new tests. Half assert the authored table is coherent (48 controls, every row
      covers columns 1-8, every orbit has a level/fx/gate, no Tidal slot lands on an
      Ardour-learned control, d1-d3 own exactly one knob and one button because C1-3
      and F1-3 are the family controls). The other half assert every CONSUMER still
      agrees, and that regenerating the artifacts is a no-op — so a remap that forgets
      one copy fails the suite instead of shipping a lying topbar. One test simply
      checks render.js has not re-grown a literal ORBIT_CONVENTION.
      
      VALIDATION — behaviour must be bit-identical, this is a refactor
        surface-columns    83/83 aligned, 0 renumbers   (unchanged)
        migrate-columns    --plan: 0 moves, 0 overflow  (nothing left to do)
        pvlint             13 tracks, 0 errors, 9 pre-existing warnings
        pytest             490 passed (was 476 + 14 new)
        HUD specs          smoke / lcxl-leds / scene-directive all pass
        lcxl_grid --check  48 controls, every row 1-8, every orbit housed
      
      Also fixed while here: the LED watcher was still running the process started at
      16:39, i.e. code from before the A1-lights-for-d9 feature existed. That is why
      PLN saw no A1 LED while d9 was declared — not a mapping bug, a stale daemon.
      Restarted. Worth remembering as its own class: for gear that runs as a service,
      "I fixed the code" is not "the rig picked it up".
      PLN (Algolia) authored
    • feat(pvlint): PV008 — one physical button driving two orbits, the #94 bug as a permanent rule · 3060f506
      Earned during the phase-2 button remap (5910aacc). The migrator left an over-budget
      control where it was, which LOOKS conservative and is the opposite: once ^42 became
      d2's button, d1's surviving reference to ^42 meant one press fired both orbits.
      perfect.tidal shipped that way until a hand-written check found it — and that check
      then existed only in a terminal scrollback, which is not a gate.
      
      WHY IT DESERVES TO BE AN ERROR, NOT A WARNING
      Nothing errors. Nothing goes silent. The extra layer only appears while a specific
      button is held. On stage that reads as "the track is broken today" rather than as a
      mapping bug, which is the exact signature of the failures this rig keeps losing
      evenings to. Same family as the orphan-orbit ghost: audible, plausible, and invisible
      to every static check we had.
      
      WHAT IT DOES NOT FLAG — the cry-wolf cases, each a real line from the corpus
        * gF1-3 and gMute1-3 (and gPanic) are per-FAMILY BY DESIGN — measured, not assumed:
          gF1 -> d1/d2/d3/d8, gF2 -> d4, gF3 -> leads. Flagging those would fire on every
          track in the set and the rule would be switched off within a day.
        * one orbit referencing a button three times is ONE gesture, not three collisions
          (bombe_dj's kick does exactly that) — findings are per ORBIT, as in PV004.
        * COMMENTED-OUT gestures. This matters more than it looks: commenting the surplus
          is precisely how #94 parks an over-budget control, so a rule that counted
          comments would fail every track the migrator just fixed.
        * knobs. Knob sharing is a columns question, not a button collision.
      
      VALIDATION
        OPAL setlist        0 PV008 findings — clean, as of 5910aacc
        whole corpus        136 findings across 56 of 792 files
        pvlint tests        38 passed (5 new: the real perfect.tidal case, plus one
                            negative per cry-wolf class above)
      
      The corpus number is history, not exposure: those are old-world tracks that predate
      the column grid, and per the measure-the-set-not-the-corpus lesson the number that
      matters before a gig is the setlist's, which is zero. The corpus gets fixed when it
      gets migrated (#64 / post-gig), and now there is a rule that will prove it.
      PLN (Algolia) authored
    • feat(surface): REMAP PHASE 2 — the button rows column-align, and the board is now one sentence · 5910aacc
      96 substitutions across all 13 OPAL tracks. "Column N is orbit N" is now true for
      the WHOLE surface, not just the knobs:
      
          BT 41-44/57-60   d1..d8 gate
          BL 73-76/89-92   gMute1/2/3 | d4..d8 gate2
          A  13-20         d9..d12 level | d9..d12 fx
          B  29-36         d1..d8 fx
          C  49-56         gF1/2/3 | d4..d8 fx2
          D  77-84         d1..d8 level
      
          surface-columns:  23/84 aligned, 127 renumbers  ->  82/82 aligned, 0 renumbers
      
      PLN drove the key decision. My first attempt left over-budget controls in place and
      reached only 11/13 tracks, which he rejected outright: "we need finish the job. why
      half moved? cant you move all-but then comment out with fixme?" He was right on both
      counts, and leaving them was not the conservative choice — it was a bug generator.
      
      THE CASCADE, AND WHY COMMENTING OUT DISSOLVES IT
      The board has 8 BT + 5 BL = 13 per-orbit buttons for 8 orbits, because BL1-3 are the
      family mutes. So d1..d3 get exactly ONE button each, and bombe_dj's kick — a 3-state
      gesture on TWO buttons —
      
          $ midiOn  ("^41"-"^42") (<| "k(3,8) ~")
          $ midiOff ("^42"+"^41") (<| "k*<1!7 2> ~ ...")
          $ midiOn  "^42"         (<| "k k k*<1!8 2!8> ...")
      
      is over budget by design. Left in place, d1's ^42 blocked d2, which blocked d3, then
      d4, then d5: 4/8 aligned. Commented out, the control is free and the row aligns.
      Nothing is lost — the original sits one line above under two dashes, restorable the
      moment gSel (#54) can fold three states onto d1's own control.
      
      THREE BUGS THE GATES CAUGHT BEFORE THIS SHIPPED — all in my own tool
      
      1. LEAVING OVERFLOW IN PLACE IS A CROSS-ORBIT TRIGGER. Once ^42 becomes d2's button,
         any surviving d1 reference means pressing d2's gate also fires d1's kick variant.
         Measured, not theorised: perfect.tidal ended up with ^42 driving BOTH d1 and d2.
         A new gate now asserts no button drives two orbits — 0 across the setlist.
      
      2. THE CLASH GUARD HAD A HOLE. A clashed control also stays put, so it too becomes an
         obstacle — but I computed clashes in the same sweep that consumed them. bombe_dj:
         d2 was left on ^43 by a clash while d3 was still planned to move ONTO ^43. Fixed by
         iterating pass 2 to a fixed point, growing the sticky set until a sweep finds
         nothing new, with a non-convergence bail-out.
      
      3. COMMENTING A LINE ORPHANS ITS CLOSING PARENS. A chain step spans several lines, so
         commenting only the line carrying the CC left the body live and the `)` dangling —
         `perfect: parse error on input ')'`, and desire went from building to not building.
         The unit is a `$`-chain SEGMENT; it is expanded to one, then paren-balance-checked,
         and the tool refuses rather than emit broken Haskell.
      
      4. MULTI-BLOCK ORBITS WERE SILENTLY DROPPED. perfect.tidal declares `d1` SIX times as
         section alternates. Keying the plan by orbit number meant each later block
         overwrote the earlier one's plan, so the first block's ^42 -> ^41 vanished — the
         real reason gate 1 kept failing after I "fixed" it. An orbit's control set is the
         UNION across its blocks. Recovered 3 more moves (perfect 7->8, electric_hammer 3->5).
      
      VALIDATION
        no button drives 2+ orbits    0 tracks (was 1)
        silent-eval --seeded          OK, every declared orbit emits events
        build failures                4, identical to before — no new breakage (#93)
        surface-columns               82/82 aligned, 0 renumbers
        pvlint                        0 errors, 9 pre-existing warnings (#70, dup d5)
        pytest                        471 passed
        check-boot / -blocks          OK
      
      9 overflow controls are now commented with a FIXME naming the orbit, its slots, and
      the restore path. That is the honest total: the surface is fully aligned, and every
      control that could not fit says so in the file rather than in someone's memory.
      PLN (Algolia) authored
    • refactor(corpus): deshadow ouais_je_funk too — PLN's call, he takes it from there · 1764d3e1
      Held out of daf074f2 because the file was already in PLN's modified set and the
      standing rule is not to commit over his uncommitted .tidal work. He waived it:
      "commit over ouais je funk do the fix there too ill take it from there."
      
      Same mechanical change as the other 254 files — the 7-line shadow stanza removed
      so BootTidal.hs is the only definition:
      
          let gMask  = (midiOn "^41" (mask "t!3 <t!3 [f <t f>]>"))
          let gMute  = (midiOn "^73" (mask "f*16"))
          let gMute2 / gMute3
          let gM1 = gMask . gMute   /  gM2  /  gM3
      
      `let width` and `let dMask` are untouched: BootTidal defines neither, and dMask
      is this track's own compositional mask, not boilerplate.
      PLN (Algolia) authored
    • refactor(corpus): BootTidal is now the ONLY definition of the global helpers —… · daf074f2
      refactor(corpus): BootTidal is now the ONLY definition of the global helpers — 1705 local shadows removed
      
      PLN loaded bombe_dj to test the new surface and found ^41 still chopping every
      bar, two days after gMask was "retired". He asked the right question: "why so
      many let gMask in bombe dj? did you do the edits yet?"
      
      THE BUG WAS ARCHITECTURAL, NOT A MISSED EDIT
      Every track opened with a copy-pasted stanza:
      
          let gMask  = (midiOn "^41" (mask "t . <f t f <f t>> <t f f <t f>>"))
          let gMute1 = (midiOn "^73" (mask "f*16"))
          let gM1    = gMask . gMute
          let gF1    = (# djfbus 1 (range 0.05 0.95 "^49"))
      
      A local `let` SHADOWS the boot definition, so for years every central fix
      stopped at the door of 254 files. Two consequences, both live on stage:
      
        * gMask was retired in BootTidal.hs (`gMask = id`) and still masked on the
          237 tracks that redefined it. The retirement reached 5 of 13 setlist tracks.
        * gF1/2/3 were rewritten to the two-sided gDJF (centre = bypass, left = lpf,
          right = hpf, and 0 = a 180 Hz lowpass = silence). 333 local defs kept the
          OLD one-directional `(# djfbus N (range 0.05 0.95 "^49"))`. So the DJ-filter
          fix that took a whole afternoon to get right was invisible in performance.
      
      Two tracks even carried `let midiGGlobal = "^77" * 1.5` — reading fader ^77,
      which is now MIDI-learned to Ardour's Tidal 01 gain. That is bug #53 alive: an
      accidental nudge of fader 1 would attenuate every midiG-using stream.
      
      WHAT LANDED
      tools/deshadow-helpers.py, --plan/--apply, two passes:
      
        pass 1  1551 deletions of exact boilerplate, so boot's definition takes over
        pass 2   154 deletions of defs pass 1 ORPHANED (a local `let gMute` whose only
                 user was the `let gM1 = gMask . gMute` just removed). BootTidal has no
                 `gMute`, so pass 1 cannot touch it, and half a stanza reads like it
                 still does something. Referenced nowhere = zero events change.
          11  COMMENTED OUT, not deleted, with a DESHADOW(#96) marker
      
      The safety property is that direction: a def is deleted ONLY if its body
      positively matches a known boilerplate regex. Anything unrecognised is
      commented, so a bad regex leaves a comment instead of deleting PLN's music. It
      earned that immediately — the 11 include real composition that only worked
      BECAUSE it shadowed:
      
          let gF2 = (whenmod 64 48 (# djf (slow 16 $ range 0.5 0.2 saw))) -- Parts DJF
          let gF1 = (someCyclesBy "<f!10 t!6 f!112>" (# djfbus 1 ...))    -- Intro DJF
          let gMask = mask "t t <t <t f> t <f [f t]>>"    -- a fixed compositional mask
      
      and one latent build error, `let gF1 = (y# djfbus 1 ...)`, which cannot compile.
      
      DELIBERATELY NOT TOUCHED
        * gDJF (65 files). Local is `let gDJF = …` (0 args); boot's is `gDJF ch`
          (1 arg). Removing the shadow is a TYPE ERROR, not a global. Own pass, #96.
        * gMute/gM/gF/gM'/gDJF1/gMaskEnd16/… (315 defs) — names BootTidal does not
          define at all. Deleting breaks the build.
      
      VALIDATION — verdict equality, not "it looks fine"
      silent-eval over the 13-track OPAL setlist, before vs after, with an EMPTY
      control map. Every single line of the diff is a line NUMBER shifting by 7, the
      height of the deleted stanza. Same tracks, same orbits, same counts:
      
          before: 9 tracks with a silent orbit, 4 unbuildable by the harness (#93)
          after:  9 tracks with a silent orbit, 4 unbuildable by the harness
          -> VERDICTS IDENTICAL
      
        check-boot        OK — all g* helpers audible with an untouched controller
        check-boot-blocks OK — 32 blocks, each still one statement
        pytest            471 passed
        surface-columns   5 knob misalignments at HEAD -> 4 now (desire's borrowed ^52
                          is gone). No regression; the remaining 4 pre-date this.
      
      Also in desire.tidal, on PLN's call ("just simplify to make it finish the job,
      losing nothing, leaving FIXMEs"): d3's ^52 was borrowing d4's column because the
      grid gives d3 exactly one knob (B3, already used; C3 is the gF3 family filter).
      Parked as a static `# legato 0.55` with the knob form one line above in a
      comment, and the d7/d9 open question closed as a FIXME rather than left
      blocking. Nothing deleted, everything re-enableable by removing two dashes.
      
      Excluded from this commit: live/midi/nova/lounge/ouais_je_funk.tidal, which was
      already in PLN's modified set. The deshadow is applied on disk there; his to
      commit.
      PLN (Algolia) authored
    • docs(tasks): archive #85/#88/#89/#92 — the surface remap, and four bugs that hid in plain sight · d6e509c3
      Rich entries for the afternoon that turned the LCXL from a lookup into a channel
      strip. Written for a cold reader, because these are the blog/video source material.
      
      The four findings worth reading again months from now:
      
      * A daemon with THREE possible lifecycles, two of which could run at once — two
        processes writing SysEx to one board, fighting over every LED, neither wrong
        enough to look broken.
      * `StartLimitIntervalSec=0` in [Service] instead of [Unit]: systemd logs "Unknown
        key ... ignoring" and the default retry limit stays in force. A silent failure one
        section heading away from working.
      * 34 "destination already taken" clashes that were entirely PHANTOM — an artefact of
        costing a permutation one move at a time when the corpus is uniformly off by one.
      * A measurement that overruled my own design: I proposed eight per-column DJ
        filters; measuring which orbits actually receive gF1/gF2/gF3 showed they are
        per-FAMILY (drums / bass / leads) and consistent across all 13 tracks. Building
        the "better" design would have destroyed a working abstraction.
      
      Also: the physical LCXL faders sat at 0 while the Ardour faders had been raised by
      mouse — silent divergence, and the next brush of a fader would have killed the orbit
      mid-set. Found in a log, not by ear.
      PLN (Algolia) authored
    • fix(tracks): repair the stale-Pulsar-buffer clobber, and detect the next one in seconds · a5c61c0b
      PLN: "wap has atm bass on d4, yet d4 effects on 57 and 89!! Why dont i see your
      changes in tidal ??" ... then, on the diagnosis: "how can we trust each other with
      pulsar" and "cause reloads dont do it, and i could close tabs before your edits
      then, but its annoying ahahah".
      
      == WHAT HAPPENED ==
      Pulsar saves the BUFFER, not the file. Its process had been running since
      Sun Jul 26 17:19, so every track opened before this afternoon held three-day-old
      text. PLN commented two lines out in wap.tidal and hit ctrl+S — and Pulsar wrote
      that entire stale buffer back, silently reverting EVERY control the column
      migration had moved:
      
          ^31 -> ^52     ^32 -> ^53     ^33 -> ^34     ^17 -> ^18
          and it deleted the FIXME(#54) comment
      
      No warning, no conflict marker, no error. He noticed an hour later because a knob
      was in the wrong place. do_it_right.tidal was hit the same way.
      
      Two details that make this nastier than it sounds, both now written down:
        * "Window: Reload" does NOT fix it — that restores buffers from Pulsar's session
          cache, not from disk. The only reliable action is to CLOSE THE TAB and reopen.
        * The clobber is INDISTINGUISHABLE from a hand edit at a glance. It arrives
          inside a legitimate diff, mixed with real intent.
      
      Repaired by re-running migrate-columns (idempotent — it recomputes the permutation
      from current content), plus one hand fix for wap's d9 ^18 -> ^17 which the migrator
      does not cover since d9-d12 are not column orbits. PLN's actual intent — commenting
      out three crushbus/octer lines in wap d4 — is preserved: those were live pre-
      migration and he disabled them on purpose. Verified by diffing against the
      pre-migration commit rather than assuming.
      
      == WHY DETECTION AND NOT PREVENTION ==
      Prevention is unavailable. Pulsar's ~/.pulsar/storage/application.json is 75 bytes
      of project roots — there is no way for a tool to ask which files are open, so I
      cannot refuse to edit an open file. And "close every tab before Claude edits" is a
      chore, not a system; PLN is right to reject it.
      
      So the trust mechanism is two things that need no discipline from either of us:
       1. COMMIT IMMEDIATELY. Already the practice, and it is exactly why this was
          recoverable in two commands instead of lost — the clobber showed up as a diff
          against a known-good commit.
       2. DETECT IN SECONDS, NOT AN HOUR. tools/check-drift.sh.
      
      == THE DESIGN LESSON INSIDE check-drift ==
      The first version counted changed ^NN lines and called any bulk change a clobber.
      It then flagged my own REPAIR of the clobber as drift. That is crying wolf, and a
      gate that cries wolf gets ignored — the same principle already written into
      `gig-log preflight` about never failing on intentional performance state.
      
      The fix was to define the thing properly. Drift is not "control numbers changed",
      it is "control numbers moved AWAY FROM THE GRID". So the grid is measured FIRST
      (surface-columns --knobs, floor of 5 = the deliberate gSel overflows), and the
      working-tree diff is then judged in that light: bulk CC change + broken grid =
      DRIFT; bulk CC change + intact grid = a repair, reported and not failed.
      
      Verified both directions, which is the only way this is worth anything:
        positive — current repaired tree: exit 0, wap correctly read as "toward the grid"
        negative — restored the pre-migration wap over the top to simulate the clobber:
                   13 knobs out of column and 16 ^NN lines changed, both flagged, exit 1
      Then restored, grid back to 5.
      
      Wired into gig-up.sh's readiness gate alongside preflight and check-mix, so the
      question gets asked whether or not anyone remembers to ask it.
      PLN (Algolia) authored
    • feat(rig): a preflight that reads LIVE STATE — the gap that let gMask sit armed for 74 minutes · 764accb1
      PLN, after a change of mine reached his ears before a check did: "we gotta make
      things work bro we cant break as we move i thought we had clarity and confidence by
      now :) go on, take the longshorter path not hte immediate one".
      
      Right, and the honest diagnosis is not "be more careful". It is that this toolbox
      had a whole missing category of check.
      
      Everything in it is COLD:
          pvlint            parses .tidal text
          silent-eval       queries patterns against an EMPTY control map, in a fresh ghci
          surface-columns   reads .tidal text
          check-boot        loads BootTidal in a throwaway ghci
          check-mix         reads the SAVED Ardour session
      
      All five were green all afternoon. Meanwhile gMask sat ARMED AT 127 on the live
      board from 15:36 to 16:50 — a global chopping the last eighth out of every bar of
      every orbit wrapped in gM1/gM2/gM3, which is every orbit of every track. Nothing
      was broken. A switch was on, and NOT ONE tool in the chain looks at the switches.
      PLN found it by ear, and the ear should never be the smoke detector.
      
      `gig-log preflight` closes that. It reads the live recorder's surface state and
      returns a verdict on the controls that can silence a whole rig from outside any
      pattern: gPanic armed, any gMute engaged, a DJ filter parked in a band-killing
      position, gMask armed if it is still active.
      
      == THREE DESIGN DECISIONS, EACH FROM A PAST FAILURE ==
      
      1. A STALE LOG FAILS. Exit 2, and distinct from exit 1 (surface unsafe) so a caller
         can tell "I could not check" from "I checked and it is bad". A preflight that
         reports SAFE because it read yesterday's session converts an unknown into a
         false reassurance, which is precisely the shape of every bad afternoon this rig
         has had. is_live() already existed; this is the first thing to gate on it.
      
      2. gMASK'S MEANING IS READ FROM BootTidal.hs, NOT HARDCODED. ^41 was a global gate
         this morning; since 37857225 it is d1's gate and gMask is `id`. Same CC, opposite
         verdict. Hardcoding either answer would be wrong half the time and would go
         stale exactly the way every other cached binding here has
         (feedback_stale_binding_pattern). An unreadable BootTidal assumes the mask is
         STILL ACTIVE — the cautious read, because guessing "retired" silences a real
         FAIL.
      
      3. IT NEVER FAILS ON THE STATE BUTTONS. Gates on 41-44/57-60/76/89-92 are gestures
         PLN arms on purpose. They are LISTED ("armed on purpose (not a failure)") so
         nothing is silently on, but they do not block. A gate that cries wolf about
         intentional performance state is a gate that gets ignored, and then it is worth
         nothing on the night it matters.
      
      It also names what it cannot see rather than implying completeness: Ardour faders
      are invisible over MIDI, so the output points at check-mix.py and says that reads
      the SAVED session.
      
      == AND IT IS WIRED IN, WHICH IS THE ACTUAL POINT ==
      gig-up.sh now ends with a readiness gate that runs BOTH preflight and check-mix.
      Everything above that line only proves processes STARTED; these two are the only
      steps that ask whether the rig will make a SOUND. Neither aborts the launch — you
      may be starting up precisely to fix them. This is the "real readiness gates" half
      of feedback_simple_setup, and it means the surface check happens whether or not
      anyone remembers to ask for it.
      
      Verified: live run reports SAFE with the board as PLN just left it (48 controls
      touched, gMask correctly detected as retired, ^93 untouched and flagged as sitting
      at the #55 seed). Negative-tested against a truncated log: exit 2, "recorder is
      NOT running", no verdict offered. 5 new tests (309 total), including the
      load-bearing one that a stale log must FAIL rather than pass, and that a
      COMMENTED-OUT `gMask = id` does not count as retired.
      PLN (Algolia) authored
    • feat(surface): retire gMask, freeing ^41 for d1's gate — and stop the board denying d9 exists · 37857225
      Two changes PLN asked for, plus the five tests that had to be rewritten because
      they encoded the behaviour we just removed.
      
      == 1. gMask IS RETIRED ==
      PLN: "kill gMask! its unprevisible anyway. we can then consider a gMask per track
      ... tbh gmask is risky id rather invest all on my own manual masking perf"
      
      It was `midiOn "^41" (mask "t!7 f")` — top-left button chopping the last eighth
      out of every bar on every stream wrapped in gM1/gM2/gM3, which in practice is
      every orbit of every track. A global that silently removes events from everything
      is exactly what makes a rig feel haunted, and the gesture plays better by hand.
      
      It becomes `gMask = id` rather than being deleted: 230 .tidal files name it
      directly and removing the binding would stop every one of them compiling. Same
      retirement pattern already used for the midiG family (#73) — keep the name, empty
      the behaviour, delete the usages at leisure. gM1/gM2/gM3 are now just the mutes.
      
      The payoff is the SURFACE, not the sound: ^41 is button-top-row column 1, the one
      slot the column-aligned button map needs for d1's gate. With it free, the top
      button row can be fully per-orbit d1..d8 and the whole board reduces to one
      sentence. That unblocks phase 2 (#92).
      
      Worth recording how visible this was: gig-log reported `41 127 gMask
      someCyclesBy gate -- gates 100% of cycles`. gMask had been fully ARMED on the
      live board since 15:36 while PLN was testing the set. Nothing was broken; a
      global was simply switched on and nothing put that in front of him. He has since
      pushed the state buttons back off.
      
      == 2. THE BOARD WAS DENYING d9 EXISTS ==
      PLN, on bombe_dj: "i see no led under d9 while d9 has a sound a FMRhodes".
      
      A1-A4 are d9-d12's LEVEL, MIDI-learned in Ardour to the Tidal 09-12 track gains
      (#46). No .tidal writes ^13-^16 — and it must not, since Tidal never sees those
      CCs — so a "^NN" scan finds nothing and the knobs went dark. Dark means "not
      mapped here" (feedback_dark_means_unmapped_outranks_all), so the board asserted
      that four working controls did nothing.
      
      To be precise about a phrasing that confused things: the ORBITS d9-d12 are
      entirely Tidal's, they carry real sounds (wap's d9 is vec1_acid), and every
      orbit-parsing tool sees them — HUD, pvlint, silent-eval, surface-columns. What
      belongs to Ardour is only the four CC NUMBERS. The orbit was always visible; the
      knob-to-orbit LINK was the missing piece.
      
      parse_track now lights A_N exactly when the track DECLARES d(8+N), coloured by
      that orbit's own family — these are per-orbit by construction, unlike the shared
      filters and mutes. A track with no d11 still gets a dark A3, because there is
      nothing there to level. So the row answers the question actually asked mid-set:
      which extra orbits does this track have, and where is their volume?
      
      == 3. FIVE TESTS REWRITTEN, NOT DELETED ==
      They asserted gM1 -> {41,73} and "the mask is measured by DENSITY". Both were
      true and are now false. Each was retargeted at the new truth rather than removed,
      and one was ADDED that the old design never needed:
      
        test_gMask_is_retired_and_claims_no_cc  — asserts ^41 is owned by NOTHING,
        scanning every helper. A gMask that quietly reclaimed CC 41 would put two jobs
        on one button, which is the precise failure this remap exists to remove. That
        deserves a guard, not a comment.
      
      Verified: check-boot all 4 passes green (helpers audible against an untouched
      controller, and the block-seam replay confirms 32 blocks each parse as one
      statement); silent-eval --seeded on bombe_dj still emits on every declared orbit;
      305 tests pass.
      
      NOTE the retirement only takes effect on a Tidal REBOOT — the running ghci
      loaded BootTidal at 16:36, seven minutes before this edit. Until then ^41 still
      masks.
      PLN (Algolia) authored
    • fix(leds): repaint on FILE EDIT, not just track change — and light the mutes armed by name · 973f77dd
      Two lies the board was telling, both found by PLN looking at it during the column
      migration. Neither had any error output anywhere; both are the rig's signature
      shape, a value resolved once and never rechecked.
      
      == 1. STALE PARSE: the board showed a file that no longer existed ==
      PLN: "i see the leds red on C5 c6 not on c4 why? feels like old led convention?"
      
      His instinct was right about staleness and wrong about the cause — it was old
      DATA, not old code. The watcher followed track CHANGES only, so it held whatever
      the file said the moment it was opened. Timestamps settled it in one line:
      
          watcher started      15:21:24
          bombe_dj.tidal        16:27:17   (the migration)
      
      ^54 existed in the 15:21 version and does not exist now. C5+C6 lit / C4 dark was
      a *faithful* picture of a file 66 minutes dead. And this is livecoding — the file
      changes constantly, so this was not an edge case, it was every save.
      
      follow_loop now stats the track's mtime alongside the path and re-parses on
      either. Two extra stats per second; still no inotify, deliberately — a watch that
      dies silently is the exact failure class this rig keeps producing.
      
      One subtlety in the fix: touched-state is cleared on a track CHANGE (carrying it
      over would claim you had already worked controls on a track you just opened) but
      PRESERVED on an edit to the track already loaded. Otherwise a ctrl+S mid-set
      wipes the one thing that display exists for.
      
      == 2. HELPERS ARMED BY NAME: four live buttons painted as unmapped ==
      PLN: "i expect filters and mutes on all and the ^41 as the mask control no?"
      
      Every ParVagues orbit is written `dN $ gF2 $ gM3 $ ...`, and BootTidal defines
      `gM3 = gMask . gMute3` — so ONE name arms TWO controls, and a "^NN" scan of the
      .tidal sees neither. On 5 of the 13 OPAL tracks (do_it_right, vague_de_crime,
      desire, the_revolution_will_be_sampled, electric_hammer) ^41 and all three mutes
      sat DARK while live. Dark means "not mapped here"
      (feedback_dark_means_unmapped_outranks_all), so the board was asserting that four
      of the most-used buttons did nothing.
      
      parse_track now resolves gMask / gMute1-3 / gM1-3 to their CCs. Role is the
      neutral "fx", not the invoking orbit's role: the mutes are SHARED (bombe_dj
      drives gMute3 from d4, d5 and d7), so an orbit-derived role would be whichever
      orbit the parser happened to see last. A stable colour beats an arbitrary one;
      refining button colour is #49/#51.
      
      == THE NEAR-MISS WORTH RECORDING ==
      I first "verified" this fix against bombe_dj and measured NO change — 28 bindings
      before, 28 after — and was about to conclude the whole diagnosis was wrong.
      bombe_dj is one of the 8 tracks where those CCs happen to appear literally
      somewhere in the file, so it cannot show the bug at all. Running the comparison
      across all 13 tracks instead of the one in front of me is what recovered it.
      A single-file spot-check disproving a real bug is a worse outcome than no check.
      
      Tests: 6 new cases in at/tests/test_lcxl_colour.py, one per SHAPE rather than per
      file — gM3 lights both mask and mute, each gM variant selects only its own mute,
      bare gMask/gMute names resolve, a COMMENTED helper lights nothing (same rule as a
      commented ^NN — a disabled helper is not a binding), and a track arming no
      mask/mute leaves those buttons dark. 42 pass in the colour suite, 165 in the
      full tools suite.
      PLN (Algolia) authored
    • feat(surface): column-align the whole OPAL set — 54 knobs move onto their own orbit's column · 346c6529
      PLN gave the go and accepted the trade in his own words: "i agree for the trade and
      change of knobs. i accept the fate! and truthful leds will help quick learn". The
      faders were remapped this afternoon; doing the tracks now means his hands relearn
      the surface ONCE, with four days to drill it, instead of learning an interim layout
      this week and the real one after the gig.
      
          12 tracks, 54 substitutions, 5 gSel FIXMEs, 0 collisions
      
      Each of d1-d8 now has its effect on its own B knob (and a second on its C knob for
      d4-d8), directly above the fader that levels it. bombe_dj landed separately in
      fdde0924 as the proof track.
      
      == WHAT MADE THIS SAFE, AND IT IS NOT THE DIFF SIZE ==
      
      The migrator computes each track's WHOLE permutation, prints it, then writes once.
      That is not tidiness, it is correctness:
      
        * desire.tidal needs two genuine SWAPS — d7 ^55<->^35 and d8 ^56<->^36. Applied
          sequentially, the first move would overwrite the second's source and both
          controls would end up on one knob. Applied simultaneously they just exchange.
        * the same property dissolved the 34 "destination taken" clashes the naive
          costing reported: the corpus is uniformly off by one column, so every orbit
          wants the slot of the orbit below it, free only once that one moves too.
          Genuine collisions across all 13 tracks: zero.
        * the rewrite is scoped PER ORBIT. desire had ^19 driving both d7 and d9, which
          migrate to different destinations; a file-wide replace would have fused two
          gestures into one, silently.
        * commented-out ^NN refs are never touched — they are alternatives PLN
          re-enables mid-set, and rewiring them would break weeks from now with no trace.
      
      == THE 5 OVERFLOWS, LEFT IN PLACE ON PURPOSE ==
      perfect d4 ^17, perfect d5 ^18, mafia_sans_serif d7 ^19,
      the_revolution_will_be_sampled d7 ^55, desire d3 ^52.
      These orbits have three or more effect knobs; the grid honestly offers two (one for
      d1-d3, whose C knob is a family DJ filter). Rather than spill them into a
      neighbouring column and call the board aligned, each carries a FIXME(#54) pointing
      at gSel. PLN: "if we have two effects, we consolidate via gSel indeed."
      
      == VERIFICATION, AND ONE HONEST GAP ==
        pvlint                     13 tracks, 0 errors, 9 warnings (all pre-existing:
                                   duplicate d5 declarations, one out-of-range sample
                                   index — none introduced here)
        silent-eval --seeded       every BUILDABLE track's every declared orbit still
                                   emits events, cold, against the seeded control map.
                                   This is the check that would catch a renumber having
                                   quietly emptied an orbit, and it is clean.
        surface-columns --knobs    77 knob renumbers -> 5 (exactly the gSel overflows)
      
      THE GAP: the silent-eval harness cannot BUILD 3 of the 13 tracks — overlapping
      IsString instances on chord literals, and an ambiguous `cutoff` between
      Tidal.Context and Tidal.Params. I re-ran two of them from HEAD and they fail
      identically there, so this migration did not cause it. But it means three tracks'
      orbits are UNVERIFIED, and a harness that cannot build a track must never be read
      as a pass. Filed separately.
      
      Also fixed one stale comment of my own in desire.tidal: after the d7 swap the
      NANANA knob is B7, not C7. A comment that lies about the layout is worse than none
      when you are relearning the board.
      
      Not committed here, deliberately: backlog.md and tools/at/fixtures/claude.tidal
      carry PLN's own uncommitted edits.
      PLN (Algolia) authored
    • feat(surface): the column migrator, proven on bombe_dj — 5 knobs onto their own columns · fdde0924
      PLN: "lets do the bombe dj first to confirm". One track, end to end, so the tool
      and the grid are both proven before the other twelve.
      
          d3  ^52 C4  ->  ^31 B3     legato
          d4  ^17 A5  ->  ^32 B4     midiOff (slow 4)
          d4  ^53 C5  ->  ^52 C4     crushbus 41
          d5  ^34 B6  ->  ^33 B5     octerbus 52
          d5  ^54 C6  ->  ^53 C5     crushbus 51
      
      It also cleaned up a collision I had introduced myself twenty minutes earlier:
      0538eb97 moved bombe_dj's d9 onto ^17, but d4 was ALREADY using ^17, so between
      the two commits that one knob drove both orbits. Caught by running --plan before
      --apply rather than trusting the previous step. The lesson is the tool's design
      rule, not a footnote: build the whole permutation, then look at it, then write.
      
      == THREE DESIGN DECISIONS THAT ARE THE WHOLE TOOL ==
      
      1. THE PERMUTATION IS APPLIED SIMULTANEOUSLY. Costed one move at a time, the
         setlist showed 34 "destination already taken" clashes. Every one was phantom:
         the corpus is uniformly off by one column, so each orbit's knob wants the slot
         of the orbit below it, occupied only until THAT one also moves. Build the full
         map, rewrite in a single pass, and the clashes evaporate. Applying moves
         sequentially would have corrupted the files. Real collisions across the whole
         setlist: zero.
      
      2. THE REWRITE IS SCOPED PER ORBIT, never file-wide. Two orbits can legitimately
         share a CC today — desire.tidal had ^19 driving both d7 and d9 — and they
         migrate to DIFFERENT destinations. A file-wide search-and-replace would send
         both to one place and silently fuse two gestures into one. So the substitution
         walks orbit by orbit with only that orbit's map.
      
      3. COMMENTS ARE NEVER REWRITTEN. A commented-out ^NN is an ALTERNATIVE PLN may
         re-enable mid-set. Renumbering it would quietly rewire that alternative to a
         different orbit's knob, and the breakage would surface weeks later with no
         trace of a cause.
      
      Hands off by construction: faders 77-84 and knobs 13-16 (Ardour-learned — the
      CC77-to-silence footgun), the 8 BootTidal helper CCs, and all buttons
      (41-44/57-60/73-76/89-92 — gates and mutes are phase 2, and retraining a gate is
      more disruptive than retraining a knob).
      
      == AND A FIX TO THE VERIFICATION LENS, WHICH MATTERED MORE THAN IT LOOKS ==
      After applying, surface-columns still reported "11 moves" for bombe_dj. Nothing
      was wrong: it counts BUTTON refs too, and buttons are deliberately out of phase-1
      scope. But a gate that reports deliberately-deferred work as a failure is a gate
      you learn to ignore — and that is precisely how a real regression gets through.
      Added --knobs, which narrows the grid itself so every downstream number speaks
      about phase 1 only. Same discipline as feedback_parsers_over_copy: the
      measurement must measure what was actually done.
      
      == VERIFIED ==
        pvlint                        1 track, 0 errors, 0 warnings
        silent-eval --seeded          every declared orbit still emits events, cold
        surface-columns --knobs       0 renumbers remaining (was 5)
      
      Remaining for bombe_dj, on purpose: d9's second effect (^19) still wants gSel
      (FIXME already in the file from 0538eb97), and the button rows are phase 2.
      PLN (Algolia) authored
    • feat(surface): d9-d12 controls onto their own knobs — and kill the one knob doing two jobs · 0538eb97
      First slice of the #92 column migration, and the slice that had live breakage in it.
      
      THE GRID, settled with PLN this afternoon after one wrong turn and one measurement
      that overruled me:
      
          A 13-16  d9 d10 d11 d12 LEVEL    (Ardour, learned today)
          A 17-20  d9 d10 d11 d12 EFFECT   <- this commit
          B 29-36  d1..d8 EFFECT           (next slice)
          C 49-51  gF1 gF2 gF3             (unchanged — see below)
          C 52-56  free, per-orbit 2nd effects
          D 77-84  d1..d8 LEVEL            (Ardour, learned today)
      
      The wrong turn was mine: I proposed extending the DJ filters to gF1..gF8, one per
      column. Then I measured which orbits actually receive each filter across all 13
      setlist tracks:
      
          gF1 -> d1×13  d2×13  d3×13  d8×12     drums / core rhythm
          gF2 -> d4×14                          bass, almost exclusively
          gF3 -> d5×12  d7×9  + d9-d12          leads / melodic / extras
      
      That is not three arbitrary filters, it is a per-FAMILY design applied consistently
      across the whole set — you filter a stem group, like a DJ mixer, not one channel.
      Per-orbit filters would have destroyed it and handed PLN eight knobs he would never
      use that way. gF1-3 stay exactly as they are, and C4-C8 stay free, which as a bonus
      removes the "one effect per orbit" constraint I thought the grid forced. PLN's
      instinct ("i wanna keep C1/2/3 as djf these are core") was righter than his
      reasoning for it.
      
      == THE LIVE BREAKAGE ==
      piment_bresilien.tidal:77 drove its d10 crushbus from "^14". As of today CC 14 is
      MIDI-learned to Ardour's Tidal 10 gain. So that one knob was crushing the bass and
      riding the orbit's fader simultaneously — a control doing two unrelated jobs, the
      kind of thing that reads as "the rig is haunted" mid-set. Now ^18 (A6), which is
      d10's own effect slot. This was the concrete half of #88.
      
      == THE REST ==
        wap        d9  ^18 -> ^17     gain swell
        bombe_dj   d9  ^18 -> ^17     midiOff (slow 4)
        desire     d9  ^19 -> ^17     gain swell
        do_it_right d12 ^31 -> ^20    modIndex  (^31 was B3 = d3's slot, and was also
                                      Ardour-bound until this afternoon's release)
      
      == ONE BEHAVIOUR CHANGE THAT NEEDS PLN'S EAR ==
      In desire.tidal, "^19" drove BOTH d9's gain swell AND d7's NANANA (line 84). Moving
      d9 to its own knob necessarily splits that gesture. If the link was deliberate —
      one knob swelling the synth while bringing in the vocal — it is now two knobs and
      we should restore it. Flagged with a FIXME in the file rather than silently
      "fixed", because a lost gesture is not something a linter can miss for you.
      d7's ^19 moved to ^55 (C7, its own 2nd-effect slot) rather than squatting on A7,
      which belongs to d11.
      
      == FIXMEs LEFT ON PURPOSE (PLN: "if we have two effects, we consolidate via gSel
         indeed. mark FIXMEs in code in there") ==
      wap d9 and bombe_dj d9 each have a SECOND effect (crushbus, modIndex) and d9-d12
      get one A-row knob each, so there is no honest home for it. Both marked
      FIXME(#54) to fold into ^17 via gSel rather than borrowing d11's knob. The wrong
      fix would have been to spread them onto neighbouring columns and call it done.
      
      == VERIFICATION, in the order that matters ==
        pvlint            5 tracks, 0 errors (2 pre-existing warnings, untouched)
        silent-eval       every declared orbit of all 5 tracks still emits events, cold,
                          against the seeded control map — this is the one that proves a
                          renumber did not silently empty an orbit
        surface-columns   Ardour collisions 1 -> 0; the 5 primary d9-d12 controls all
                          land on their own knob; 2 second-effects correctly flagged
      
      Uncommitted WIP in 6 setlist tracks was backed up before editing; only CC numbers
      and comments changed, so PLN's in-progress edits are preserved in the diff.
      
      Aside, spotted by pvlint and NOT fixed here: do_it_right d5 references index 8 of
      "daft" which has 5 samples — that layer never arrives. Own task.
      PLN (Algolia) authored
    • fix(surface): the helper-CC scan was counting CCs that appear only in COMMENTS · d8fd702f
      surface-columns reported 11 CCs as "held by BootTidal" — including 18, 34 and 77.
      All three are false. Every one appears only inside a `--` comment:
      
          BootTidal.hs:299   --   midiOn ("^34" - "^18")   (perfect.tidal:63)
          BootTidal.hs:322   -- midiGGlobal used to read the LIVE fader: orDef 0.769 "^77" * 1.3
      
      one documenting a track's idiom, one a note about a retired helper. BootTidal
      claims none of them.
      
      This is the dangerous direction of wrong. An INFLATED helper set says "these
      controls are unavailable", so it silently shrinks the design space for the #92
      column migration — B-row column 6 (^34) and A-row column 6 (^18) would have
      looked spoken-for when they are free, and ^77 would have looked like a Tidal-side
      conflict with Ardour's newly-learned fader 1 when there is none. A wrong number
      that closes doors is worse than one that opens them, because nobody goes looking.
      
      Caught it by grepping BootTidal for the three CCs while planning #92 and finding
      every hit was a comment line. Fix is one line: run strip_comment over the helper
      block before matching, the same way pvlint and silent-eval already do.
      
      Corrected numbers: 8 helper CCs (41, 49, 50, 51, 73, 74, 75, 93) — gMask, the
      three DJ filters, the three mutes, and panic. Exactly the set you would predict
      from reading the helpers, which is the tell that it is right this time.
      
      Knock-on: the renumber count rises 174 -> 192, because ^34 and ^18 are no longer
      excused as immovable helpers and now correctly count as track controls that would
      have to move. #92 updated.
      PLN (Algolia) authored
    • feat(surface): measure whether each orbit's knobs sit ABOVE its fader — the… · d8fb13bf
      feat(surface): measure whether each orbit's knobs sit ABOVE its fader — the whole corpus is off by one
      
      PLN, mid-remap, spotted the half of #46 I had not measured:
      
          "but we need the coverage of the effects moving in the tracks, e.g. bass
           from 81 to 80 means effects on 53 now move to 52"
      
      He is right, and my earlier conflict scan answered the wrong question. That scan
      looked for CC *collisions* — two things claiming one control — and found only two
      (^78, ^14), which is how #46 came to be costed as "two track edits". But the LCXL
      is a GRID of eight channel strips, and the property that makes a surface readable
      is not absence-of-collision, it is COLUMN COHERENCE: the knobs directly above a
      fader must shape the same orbit that fader levels. Otherwise the bass fader is in
      column 4 while the bass filter is in column 5, and every reach is a lookup.
      
      tools/surface-columns.py measures it: per track, per orbit, which grid columns
      that orbit's "^NN" references actually land in, and what would have to move for
      column == orbit. It separates two classes, because conflating them would have
      produced a work list that is mostly noise:
      
        * TRACK controls — a raw ^NN in the .tidal. Free to move; a text edit.
        * HELPER controls — CCs baked into BootTidal (gF1/2/3 on 49/50/51, gMask 41,
          gMute1-3 on 73/74/75, gPanic 93, plus 18/34/77 — 11 in all). A track cannot
          move these by editing itself. They are global by construction and will always
          read as "misaligned" against a per-column model.
      
      THE RESULT, over the 13 OPAL setlist tracks — 84 d1-d8 orbits:
      
          only 19/84 orbits are column-aligned; full alignment = 174 ^NN renumbers
      
      and the pattern is startlingly uniform across all 13 files:
      
          d1 -> col 2    d4 -> col 5    d7 -> col 7   (aligned)
          d2 -> col 3    d5 -> col 6    d8 -> col 8   (aligned)
          d3 -> col 4    d6 -> col 7
      
      So the corpus convention is "orbit N lives in column N+1" for d1-d6, and N+0 for
      d7-d8. The +1 is not an accident: column 1's C-knob and both its buttons are
      already spoken for by gF1 / gMask / gMute1, so per-orbit controls were pushed one
      column right to dodge them. And the two rules meet badly — column 7 is double
      booked by d6 and d7 (in desire.tidal both really do react to ^59).
      
      The consequence for #46 is the useful part: because the corpus is +1 for six
      orbits and +0 for two, THERE IS NO FADER MAPPING THAT MAKES TODAY'S TRACKS
      COHERENT. Shifting the faders +1 to match would strand d8; leaving them arbitrary
      is where we are. Either the tracks move, or the surface stays a lookup. PLN's
      instinct — that the Ardour re-learn is only half the job — was exactly right.
      
      Survey, not a gate: exits 0 always, because alignment is a design choice and this
      tool's job is to price it, not to enforce it. --plan prints the exact ^NN -> ^NN
      moves per track for when we do it (#92).
      PLN (Algolia) authored
    • fix(rig): the LED watcher had three lifecycles and two could run at once — now it is gear · 901b43a6
      The board's colours only persist because a daemon holds a model of every control
      and repaints from it. That daemon had no home. It could be started three ways:
      
        1. by hand,  tools/lcxl-leds.py --watch
        2. by gig-up.sh, which `setsid`-spawned its own copy (GIG_LEDS=watch)
        3. as a *transient* systemd unit, which is how it was actually running today
           (systemd-run --unit=lcxl-leds-watch)
      
      Every one of those is wrong in a different way. (1) dies with the terminal. (2)
      does not know about (3), so launching gig-up on a machine that already had the
      watcher up gave you TWO processes writing SysEx to the same LCXL, fighting over
      every LED — and neither of them wrong enough to look broken, which is the worst
      kind of bug this rig produces. (3) has no file on disk, so it evaporates at the
      next reboot and the board silently stops persisting colours.
      
      PLN, on being shown the three: "watcher must be a saved tool part of gear indeed".
      
      So: tools/lcxl-leds-watch.service, symlinked into ~/.config/systemd/user/ the same
      way parvagues-bridge.service already is, enabled, WantedBy=default.target — it
      starts at boot with linger, before any login. gig-up.sh no longer spawns anything;
      it `systemctl --user restart`s the unit, which is idempotent AND guarantees exactly
      one owner even if a stale watcher survived a crash. One owner of the board, always.
      
      Two details worth the ink:
      
      - StartLimitIntervalSec=0 belongs in [Unit], not [Service]. Put in [Service] systemd
        says "Unknown key ... ignoring" — a warning in the journal nobody reads — and the
        default limit of 5 restarts in 10 s stays in force. The LCXL is hot-pluggable and
        usually absent at boot, so with Restart=always/RestartSec=10 the unit would burn
        its five retries and fall into `failed`, board dark for the rest of the session.
        A silent failure one section heading away from working. Caught it because the
        first install DID log the warning; fixed and re-verified with systemd-analyze.
      
      - Cost, measured from the transient unit's own accounting before replacing it:
        2.140 s CPU over 1 h 53 m wall = 0.03% of a core, 14.5 M peak RSS. The watcher
        forks a helper per LED write, which is a real throughput problem for the 1-2 s
        paint lag — but it is emphatically not a load problem, so it can stay Nice=5 /
        CPUWeight=20 and never be a candidate when hunting xruns.
      
      Verified: unit enabled + active, systemd-analyze verify clean, no Unknown-key
      warning on reload, `bash -n gig-up.sh` clean, and exactly one watcher process
      owned by the unit (MainPID matches, NRestarts=0). Note `pgrep -cf 'lcxl-leds.py
      --watch'` reports 2 — it counts the shell running the pgrep pipeline itself. Read
      the process list, not the count.
      
      Closes #85.
      PLN (Algolia) authored
    • docs(tasks): archive #79/#82/#78/#8/#61/#81 — the seed that never ran, and a… · feb10fcd
      docs(tasks): archive #79/#82/#78/#8/#61/#81 — the seed that never ran, and a task built on a guessed unit name
      PLN (Algolia) authored
    • fix(lcxl): the watcher paints the LOADED track, so dead knobs go DARK (#78) · 2acbd9cd
      PLN, after the value ramp landed: "now all knobs have lights, i cant trust
      anymore 'is there sth mapped there or not?' knobs should always be no-lit if they
      are not-mapped, in that track, to help not touch dead controls".
      
      The interesting part is that the colour code was never wrong. build_frame()
      already paints only the CCs present in `bindings` and leaves everything else OFF.
      The lie was in WHICH BINDINGS IT WAS HANDED: the watcher runs with no track
      argument, so load_bindings(None) returns the CONVENTION board — all 40 controls
      lit by lane role, regardless of what is actually loaded. That was equally untrue
      before the ramp; dim role-hue just made it easy to ignore, and saturated colour
      made the board finally READ as "everything here is live".
      
      So the feature did not create the problem, it made a pre-existing lie legible —
      and fixing the colour back would have hidden the fault again.
      
      MEASURED:
          convention board (what the watcher painted)   40/40 lit
          do_it_right                                   19/40   -> 21 dead knobs lit
          wap                                           25/40   -> 15
          desire                                        22/40   -> 18
      Fifteen to twenty-one controls were glowing on every track while doing nothing.
      On a dark stage that is a knob you reach for and a change you do not get.
      
      The fix is a published current-track file (~/.cache/parvagues/current-track):
        * `--map TRACK` now PUBLISHES as well as paints. Without that, a one-shot paint
          at boot showed the right board for 30 seconds and then the watcher's re-assert
          overwrote it with the convention paint — the same board, lying again. One
          writer, one meaning.
        * `--watch` with no pinned track follows the file on a 1s poll and repaints on
          change. Polled rather than inotify on purpose: a watch that dies unnoticed is
          exactly this rig's signature failure (a binding resolved once, never
          rechecked), and one stat/second of a small file costs nothing measurable.
        * touched-state clears on track change (carrying it would claim you had already
          worked controls on a track you just opened); VALUES persist, because the knobs
          did not physically move.
        * bindings moved into a shared box — three reads in the aseqdump loop and the
          re-assert thread would otherwise have kept painting the previous track's map.
      
      load_bindings(None) now also SAYS it is painting a board that may be lying,
      instead of reporting "40 controls lit by lane role" as though that were good news.
      
      The remaining gap, deliberately not closed here: nothing publishes the track when
      PLN ctrl+enters a file in Pulsar — only `tidal-remote boot` does. The clean signal
      is a five-line hook in the HUD package, which already tracks the active .tidal.
      Filed rather than rushed the day before rehearsals.
      
      458 tests (was 453). NOTE: the running watcher must be RESTARTED to pick this up;
      not done now, because PLN is about to play and a dark board mid-set beats a
      correct board that arrived by surprise.
      PLN (Algolia) authored
    • fix(gig-log): the DJ filter has TWO halves — the report was reading only one · 81aecc96
      BootTidal.hs:376-377 applies BOTH:
      
          gDJF ch = (# lpf (range 180 20000 (fmap (\v -> 1 - 2 * max 0 (0.5 - v)) ...)))
                  . (# hpf (range 20   8000  (fmap (\v -> 2 * max 0 (v - 0.5))     ...)))
      
      Yesterday's `left at` column transcribed only the `# lpf` line, so it called
      gF3-parked-at-80 "open" when it is really a ~2 kHz HIGH-PASS — which guts a bass
      or a voice, and which is exactly the helper PLN had commented off two different
      d5 orbits to get the sound back. The report was confidently wrong about the whole
      upper half of the knob, in the direction of reassurance.
      
      Now models both bands and grades on the pair:
          0   lpf   180  hpf   20    NEAR-SILENT
          64  lpf 20000  hpf   83   open
          80  lpf 20000  hpf 2094    thin — low end cut
          127 lpf 20000  hpf 8000    NO BODY LEFT
      Hard right is not "open". It never was.
      
      Two of the existing tests encoded the old blind spot — they used cc 49 = 100 as
      the "safe" control value, which is hpf 4607 Hz. The code was right and the tests
      were wrong, so the tests moved to the centre.
      
      And the new test caught something small and real: "centre = true bypass" is an
      APPROXIMATION, not an identity. 0..127 is an ODD range, so 0.5 falls between cc 63
      and cc 64 and no cc value hits bypass exactly — 63 gives a ~19.7 kHz lowpass, 64
      an 83 Hz highpass. Both inaudible, so the knob is fine in practice, but the test
      now asserts the truth rather than the comment in BootTidal.hs.
      
      453 tests green across tools/.
      PLN (Algolia) authored
    • fix(gig-log): say whether the recorder is RUNNING or was KILLED, don't offer a choice · a4dc37ba
      The report ended an unclosed log with:
      
           no `end` record — the recorder is still running, or it was killed
      
      Both readings are plausible and the reader has no way to pick. I picked wrong:
      I asked systemd about `parvagues-gig-log` — a name I guessed instead of read; the
      unit is `gig-log.service` — got "inactive", and used that false negative to
      resolve the ambiguity into "killed". The recorder had in fact been up for eleven
      hours, enabled, with its pw-top and aseqdump children alive, still writing. A
      whole task got filed about restoring a service that was never down.
      
      Two mistakes worth naming because they chain: guessing an identifier rather than
      reading it, and then letting a broken check settle a question the tool had
      deliberately left open. The wording invited exactly that.
      
      So the tool now decides and says which:
      
          ● RECORDING NOW — this log is still open, numbers are partial
           no `end` record and no recent sample: the recorder was KILLED
      
      is_live() decides from the DATA's own recency — last sample within a few periods
      of now — and deliberately not from a process match. `pgrep -f` matches any shell
      that merely mentions the string, and unit names are exactly the thing I just got
      wrong. Recency needs no name and nothing to guess.
      
      86 tests (was 83): a live log must say RECORDING NOW and never KILLED, a stale one
      the reverse, and is_live must answer from timestamps alone.
      PLN (Algolia) authored
    • fix(boot): the #55 seed NEVER RAN — Pulsar fused three statements into one (#79) · 2648074d
      PLN this morning: "moving to track 2, wap.. no bass? when i move the knob C5 it
      starts sounding". And on do_it_right: "ctrl_enter, i hear the 4-bar pattern each
      bar lower, 4th barely audible, its clearly a xfade".
      
      Both sentences are the same bug, and the second one hid the first for days.
      
      THE MASK. Every dN is `xfade N` with xfadeIn 4, so the four bars you hear after
      an eval are the PREVIOUS pattern leaving. A silent eval gets a graceful exit and
      reads as a "drift to silence". The sound after ctrl+enter is not evidence the
      eval worked — it is evidence the last one did.
      
      THE BUG. Pulsar does not feed BootTidal.hs to ghci verbatim. boot-tidal.js splits
      it on BLANK LINES and strips the `:{`/`:}` it finds; repl.js tidalSendExpression
      then wraps each chunk in its OWN `:{ ... :}`. A ghci `:{ ... :}` accepts exactly
      ONE statement. The #55 seed was written as
      
          :{
          let _seed = concat [ ... ]
          :}
          mapM_ (\(k, v) -> setF k (pure v)) _seed
          putStrLn "[BootTidal] seeded ..."
      
      — correct for a file read line-by-line, and fused by Pulsar into a single
      statement that dies with "parse error (possibly incorrect indentation or
      mismatched brackets)".
      
      So THE SEED NEVER RAN. Not once between 2026-07-27, when it was written, and
      today. Every boot left the control map empty. An untouched "^NN" yields NO
      EVENTS — not 0, nothing — so any `# param (range a b "^NN")` emptied its whole
      orbit, and the rig only made those sounds after a knob was physically moved.
      Which is precisely what PLN described, in the sentence I had filed as a separate
      question about crush ranges.
      
      MEASURED, with tools/silent-eval.py (added earlier today) over the OPAL setlist:
          empty control map   23 orbits silent across 10 of 10 buildable tracks,
                              including desire d1-d6 — the ENTIRE track
          with the seed       ZERO silent. Every declared orbit emits events.
      The seed is necessary AND sufficient. The tracks were never the problem.
      
      THE FIX is two blank lines. They are load-bearing and the file now says so.
      Verified through the real seam, not by inspection: replicating Pulsar's exact
      chunking and wrapping and feeding it to ghci now yields 51 setF calls with the
      right values (13=0.0, 49=0.5, 77=0.769, 78=1.0 ...) and prints the boot banner.
      Before the fix the same harness produced only the parse error.
      
      WHY EVERY EXISTING GUARD MISSED IT. check-boot.sh passes 1-3 prove the helper
      block typechecks, the seed block typechecks, and the helpers emit events against
      an empty control map. All three were green throughout. They test the Haskell; the
      failure was in how the file is CHUNKED AND FED. Green checks on a component say
      nothing about whether the data reaches it — the rig's signature failure, and this
      is the purest instance of it yet. #61 was closed on exactly that false comfort.
      
      So this adds check-boot.sh pass 4 / tools/check-boot-blocks.py, which replicates
      Pulsar's chunking bug-for-bug (the non-global .replace included), feeds every
      block to a bare ghci, and fails on parse errors only — "not in scope" and type
      errors are expected without Tidal and are ignored. Negative-tested against the
      pre-fix file: it names block 26 at BootTidal.hs:663 and exits 1.
      
      One bug found in the guard before trusting it: capturing stdout and stderr
      separately and concatenating them put every marker before every error, so each
      parse error was attributed to the LAST block rather than its own — it confidently
      blamed block 28. Now one interleaved stream.
      PLN (Algolia) authored
    • feat(silent-eval): execute every track's dN against an empty control map · 02cdd985
      PLN, playing this morning: "ctrl_enter on do it right, i hear the 4-bar pattern
      each bar lower, 4th barely audible, its clearly a xfade". Every dN is `xfade N`
      with xfadeIn 4, so those four bars are the PREVIOUS pattern leaving — the new one
      was already silent, and the crossfade was handing the bug a graceful exit.
      
      Two guards existed and neither could see it:
        * check-boot.sh runs the g* HELPERS against an empty control map — but the
          dangerous "^NN" uses are in the TRACKS. `# crushbus 41 (range 16 4.5 "^53")`
          is do_it_right's line, not BootTidal's, so check-boot stayed green while that
          orbit emitted nothing.
        * check-tracks.sh measures real audio — ground truth, but it needs the rig, a
          quiet house and ~45s a track.
      
      This is the missing middle: rewrite each `dN $ ...` into a plain binding on top
      of BootTidal's dedented helper block (with the track's own `let`s shadowing, as
      they do live), then QUERY it with a deliberately empty controls map. Pure pattern
      evaluation — no stream, no scsynth, no MIDI, no port 6010 — so it is safe to run
      mid-set and answers all 13 tracks without anyone's ears.
      
      RESULT, and it is unambiguous:
          empty control map   23 orbits silent across 10 of 10 buildable tracks,
                              including desire d1-d6, i.e. the ENTIRE track
          with the #55 seed   ZERO. Every declared orbit emits events.
      The seed is necessary AND sufficient. The tracks are not broken; the silence PLN
      heard means the seed did not reach the running Tidal.
      
      TWO BUGS FOUND IN THIS TOOL BEFORE TRUSTING IT — both would have been confidently
      wrong findings, and both were caught by looking at output rather than at code:
      
      1. The seed parser used `[^\]]+?` to capture `n <- [13..20] ++ [29..36]`, which
         stops at the first `]`. It silently produced 4 seeds instead of 51, so the
         "seeded" run was indistinguishable from the unseeded one. Now line-based.
      
      2. Far worse: the first version queried ONE cycle and reported six orbits as
         silent. Every one was a false positive — `mask "<f!24 t!8>"`, `"<~ [~ ~ ~
         cheval]>"`, `n "<~ <~ 7> ~ 5>"` are alternations whose cycle 0 is empty BY
         DESIGN, risers that fire once every 8 or 32 bars. Collapsing the time axis
         cannot distinguish sparse from dead. Now queries a 64-cycle window and prints
         the first sounding cycle when it is later than 4.
      
      Honest limits: 3 of 13 tracks (perfect, mafia_sans_serif, the_revolution) do not
      yet build in the harness — `cutoff` is ambiguous between BootTidal and
      Sound.Tidal.Params, and one chord literal needs its type pinned. These are
      reported as BUILD FAILED and explicitly never as a verdict about the music, so a
      harness limit cannot masquerade as a finding.
      PLN (Algolia) authored
    • feat(gig-log): report where a control is PARKED, not just how far it travelled · d1b21c94
      The surface table said cc 51 moved 533 times somewhere between 1 and 127. That
      is a biography, not a state, and it cannot answer the only question you ask a
      log at 3am: "why is that orbit silent?". Only the LAST value can.
      
      The data was already in the file — MidiReader.feed() has always coalesced to
      count/first/last/min/max per control per second, so `v1` is the value the knob
      was left at. The report simply never printed it. This is a formatting change to
      capture that already happened.
      
      Three additions:
      
      * `report` gains a `left at` column, and for the three DJ filters it prints what
        that value MEANS in hertz. Transcribing BootTidal.hs:376 turned up something
        worth its own note: for v <= 0.5 the gDJF expression reduces to
        lpf = 180 + 39640*v, i.e. LINEAR IN HERTZ. Pitch perception is logarithmic, so
        that knob spends nearly all its travel in the top two octaves and crosses the
        entire audible bottom in the last ~2% — it feels inert, then collapses. That is
        one gesture producing both "it went quiet" and "it sounds lpf'd", which is
        exactly the pair of symptoms #79 was filed with.
      
      * `gig-log.py controls` — the whole surface, by CC, with each value translated:
        filters to Hz, gMute/gMask to "mutes N% of cycles", panic to armed/clear. It
        ends with an explicit list of anything parked somewhere that silences or thins
        the sound, because a table you have to interpret under stage lights is a table
        you will misread.
      
      * `report --from/--to`, wall clock or +M:SS. The recorder ran 10h48m of which
        ~90 minutes was playing and eight hours was tooling; every aggregate therefore
        described the wrong thing. Windowing has one non-obvious requirement, and it
        gets a test of its own: xrun and throttle are RUNNING TOTALS, so a naive slice
        reports the whole session's count inside the window — wrong, and wrong high.
        slice_session() rebases them to the window.
      
      VALIDATION — it immediately paid for itself by killing two hypotheses:
          49  51  gF1  lpf 16098 Hz — open
          50  59  gF2  lpf 18595 Hz — open
          51  80  gF3  lpf 20000 Hz — open
          41   0  gMask   gates 0% of cycles
          73/74/75 0      mutes 0% of cycles
      #79's two leading suspects were "a DJ filter parked below centre" and "a mask
      left engaged". Both are now dead, from a file, with no rig and nobody's ears.
      
      83 tests (was 71). The new ones pin the BootTidal arithmetic so it cannot drift
      from the Haskell, the last-value ordering rule (latest TIMESTAMP wins, not file
      order), the cumulative rebase, and midnight-crossing wall-clock parsing.
      PLN (Algolia) authored
    • fix(check-tracks): the pre-gig gate could not run — it read a setlist that never existed · 99f8466f
      check-tracks.sh is THE empirical gate: it boots every track in the set and proves
      each declared orbit makes sound, which is also the only compile check a .tidal file
      can have (there is no static typechecker for a GHCi fragment). The comment at the
      top says to run it the day before the gig.
      
      It read `setlist_opal2026.txt` from the repo ROOT. That file has never existed. So
      the gate has been unrunnable since it was written — and it failed in the least
      helpful way possible, printing "no such file" per track from an empty list rather
      than saying the setlist was missing.
      
      Now points at `armada/setlist_opal2026.txt`, the file added for #68, so ONE list
      drives both questions: "does every orbit sound" (this) and "which orbits ghost
      across each transition" (orphan-orbits.py). Reorder the gig, re-answer both.
      
      Two fixes it needed to actually read that file:
        * strip TRAILING comments, not just full-line ones — the setlist annotates each
          path with its codename and BPM (`live/…/wap.tidal   # WAP [133]`), and leaving
          that on the line makes every track report "no such file";
        * fail loudly when the setlist is absent, instead of silently iterating nothing.
      
      Also: `--help` was taken as a track name, so asking for help answered
      "FAIL — no such file", which reads like the rig is broken.
      
      Verified: all 13 tracks resolve through the shell reader. Pinned by two tests —
      one asserts check-tracks.sh and orphan-orbits.py name the SAME setlist file (a
      broken gate is invisible until the day you need it), the other runs the actual sed
      pipeline and asserts no comment text leaks into a path.
      
      Suite 435 -> 437, all green. NOT run end-to-end: it needs audio, and it is ~45 s
      per track by design (a tight loop lands evals on a half-loaded interpreter and
      reports false failures). It is the natural companion to tomorrow's run-through.
      PLN (Algolia) authored
    • docs(tasks): archive #75/#11/#76/#68/#69 — and a morning briefing to play from · b3dddedb
      Five tasks closed overnight, written up as long-form entries rather than one-liners
      because these are the documentary trail: each carries the original symptom in PLN's
      own words, the mechanism, the numbers, and the wrong turns.
      
      The through-line across all five, worth naming: EVERY ONE of them found a second,
      worse bug than the one it set out to fix, and in four cases the second bug was found
      by LOOKING AT THE OUTPUT rather than by reasoning about the code.
        * gig-log's report exposed its own xrun baseline as nonsense (72096 xruns on an
          idle machine) — twice, before the third rule held.
        * rendering the LED board exposed the half of the panic bug that lived in
          parse_track, after the half in control_colour was already fixed.
        * the per-track probe PLAN exposed that lens.py could not see gF1/gF2/gF3 at all.
        * wiring a status label into the tray exposed that it opened the ARCHIVE Ardour
          session, could never have launched Ardour anyway, and reported it running when
          it was not.
      A tool that prints what it believes is a tool that can be caught lying.
      
      MORNING.md is the other deliverable: PLN opens the laptop with a coffee to "just
      play", so the state of the rig has to be readable in 30 seconds — what changed on
      the surface, the one 5-minute thing worth doing (the ghost report), and what is
      waiting on his ears rather than on work.
      PLN (Algolia) authored
    • fix(tray): say what gear is ALREADY UP — and stop opening the wrong Ardour session (#69) · 8bbcfd98
      PLN: "i rightclick the perf indicator and see no gear status?"
      
      He was right, and the state had been there all along. launchers.is_running()
      existed; _build_menu() just ran ONCE at startup and read only `available`. So a
      running Ardour looked identical to a stopped one, and — worse — the safe action
      ("do nothing, it's already up") looked identical to the dangerous one. Two
      SuperColliders is a zombie port 6010 and a silent rig.
      
      Now: a `Gear ▸ 3/6 up` submenu, each row labelled with what it IS —
        ● Pulsar — running     (greyed: launching a second one is never what you meant)
        ○ MIDI Monitor         (clickable)
        ✗ Something — not installed
      Glyphs rather than colour, because a tray menu inherits the desktop palette and
      this gets read in a dark room seconds before playing. Web tools stay clickable
      when running, since clicking them opens a URL rather than spawning a duplicate.
      Refreshed on menu-OPEN only, never on the 2 s icon timer: reading gear state walks
      /proc, and doing that 30x a minute for a label nobody is looking at is exactly the
      per-tick cost this rig keeps getting bitten by.
      
      THREE REAL BUGS FOUND WHILE WIRING IT UP, each worse than the missing label
      
      1. THE TRAY OPENED THE WRONG ARDOUR SESSION. ARDOUR_SESSION pointed at
         "Tidal Multi" — the older ARCHIVE of per-orbit recordings — not "Tidal Live",
         the session that performs and records the stems. Any faders touched there would
         have been the wrong ones. Auditing that same archive as if it were live already
         produced a confidently wrong fader report on 2026-07-28; this was the same
         mixup one layer down, waiting to happen again 6 days before OPAL.
      
      2. IT COULD NEVER HAVE LAUNCHED ARDOUR ANYWAY. Candidates were
         ardour8/7/6/ardour; the installed binary is ardour9 (real exe `ardour-9.2.0`).
         So the entry reported "unavailable" and greyed itself out while Ardour was
         running on the same machine.
      
      3. THE RUNNING CHECK MATCHED THE WHOLE WORLD. `pgrep -f ardour` matched the
         `tidal-ardour-autoroute.sh` helper script AND any shell whose command line
         merely mentioned the word — including the shell I was testing from. So it could
         report Ardour UP while Ardour was DOWN, which is worse than reporting nothing,
         because it is the state you act on. is_running now matches the EXECUTABLE
         basename (`exe`), with full-line matching kept only for interpreted tools where
         argv[0] is `python3` and the identity is the script path.
      
      AND IT NO LONGER FORKS
      is_running was one `pgrep` subprocess PER launcher, and snapshot() is called by
      the web Bridge's poll as well as the tray. Replaced with a single forkless /proc
      scan shared across the whole snapshot: 6 items in 10.7 ms, zero forks, down from
      6 forks per refresh. Same lesson as the LED daemon's per-event fork and gig-log's
      per-sample sampling — on an audio rig, do not pay a process for a boolean.
      
      Verified live: all six entries now report correctly (Pulsar/Ardour/QjackCtl up,
      MIDI Monitor + Foundry + Armada down); perf-tray restarted and active.
      
      TESTS: +11 in test_launchers.py, suite 424 -> 435, all green. Pinned: the
      autoroute-script and bare-shell false positives; a version-suffixed binary
      (ardour-9.2.0, and a hypothetical ardour-10.0.1) matching; a filename ending in
      ".ardour" NOT counting as a running Ardour; snapshot() scanning /proc exactly once;
      is_running spawning no subprocess at all (subprocess.run/Popen monkeypatched to
      raise); and the session path being the LIVE one with "Tidal Multi" absent.
      PLN (Algolia) authored
    • feat(pv-at): --track proves every control on a REAL track moves the sound (#74) · ab368180
      PLN's ask, verbatim: "all controls have impact on sound... even the crushes should
      change noticeably". The existing suite proves the RIG works against a fixture;
      this proves a TRACK is wired, which is the thing that bites at 160 BPM when a knob
      turns out to do nothing.
      
          python3 tools/at --track live/.../gimme_acid.tidal --dry-run   # no audio
          python3 tools/at --track live/.../gimme_acid.tidal [--json]
      
      Per control: park at REST, take TWO baselines, probe, restore, judge. Four
      outcomes, not two — MOVED / NO_IMPACT / INCONCLUSIVE / UNMEASURABLE — because a
      swing that does not clearly beat the pattern's own drift is genuinely unknown, and
      calling that a pass is how a green suite comes to mean nothing. NO_SIGNAL is kept
      separate from NO_IMPACT: "nothing was playing" and "the control is dead" have
      different fixes, and conflating them once cost a whole evening chasing SuperDirt.
      
      THE BUG THE FIRST DRY RUN FOUND, which is the real content of this commit
      
      The plan for gimme_acid came back "0 bipolar, no CC 49/50/51 at all" — on a track
      that applies gF1/gF2/gF3 to SIX orbits. gF1/gF2/gF3 are defined in BootTidal.hs,
      not in the track, so lens.py's scan of the .tidal file alone was structurally
      blind to the three DJ FILTERS and to EVERY MUTE in the rig. The controls PLN
      reaches for most were the ones the acceptance test could not see.
      
      Fixed by PARSING BootTidal.hs (parsers-over-copy — it is the source of truth and
      it moves: gF1 was rewired onto gDJF, midiGGlobal was retired hours ago, and a
      hardcoded table would rot into a confidently wrong report). boot_helpers() now
      resolves:
        * `gF1 = gDJF "^49"` -> CC49, and BORROWS its lens from gDJF's body, since
          "gDJF" is not a keyword any table knows and the lpf/hpf evidence sits on two
          separate lines
        * `gM1 = gMask . gMute1` -> composition, inheriting CC41 AND CC73
        * a track's own `let gMute = ...` SHADOWS the boot one, as Tidal does
      Result on gimme_acid: 18 controls -> 22, with 49/50/51 present, bipolar, resting
      at 64 and probing downward. Across the OPAL set: 275 controls, 15-29 per track,
      all three filters resolved in all 13, zero Ardour-owned CCs.
      
      Two parse bugs found and fixed on the way, both by reading the output instead of
      trusting it:
        * `mask "f*16"` was classified as DENSITY. An all-false mask is a MUTE — same
          keyword, opposite lens (rms vs onsets) — and my first all-false test asked
          "does it contain a 1", which the *16 repeat count satisfies. So gMute1/2/3,
          three textually IDENTICAL helpers, came back mute/density/density: rms would
          have been the wrong lens on two of PLN's three mutes, and two working mutes
          would have reported NO_IMPACT.
        * `gM3 = gMask . gMute3` swallowed the Launchpad block that follows it and
          reported CC 7 and CC 9 as mutes, because continuation lines were appended to
          whatever was defined last. A continuation now has to LOOK like one.
      
      SAFETY
      CC93 is added to a new lens.NEVER_PROBE. It arms panic — gPanic gates on "^93", so
      sending it high silences every gPanic'd stream. "Probe every control" plus "one
      control is a kill switch" is how a self-test mutes a rehearsal and gets blamed on
      the rig. It is reported and never swept. CC77-84 stay excluded by construction in
      lens AND re-asserted at the probe; CC77 down is total silence, so belt and braces
      is proportionate there.
      Controls are restored per control, not at exit, so Ctrl-C mid-run is safe. Restore
      goes to Control.rest — 64 for a DJ filter, because djf 0.05 is a ~26 Hz low-pass,
      i.e. silence, and parking one at 0 to get a "baseline" measures silence and then
      calls the track broken (#48).
      
      --dry-run exists deliberately: it prints the plan with no audio and no MIDI, so
      the half that can be checked tonight — which controls were found, which lens each
      gets, what will be sent, what will be restored — is checkable at all. "Built but
      never run" is exactly how the LED work shipped a colour ramp that was never
      called.
      
      TESTS: +79 (22 in test_lens.py, 57 in the new test_track_runner.py), suite
      345 -> 424, all green. Every boot-helper regression above is pinned, including a
      parametrised all-false-mask table with `f*16` in it; each lens is asserted to have
      a floor (2 * max(drift, 0) is zero, so without one any tiny swing looks
      significant on a quiet capture); the probe is asserted to REFUSE all of CC77-84;
      and the dry-run plan is built for all 13 OPAL tracks with the DJ filters asserted
      bipolar-and-resting-at-64 in each.
      
      STATUS: the judgement half is validated. The AUDIO half is built but unrun — it
      needs monitors, and the house is quiet. First thing to run with sound up.
      PLN (Algolia) authored
  2. 28 Jul, 2026 5 commits
    • feat(orphan-orbits): name the ghosts before the stage does (#68) · 3ab9ae03
      PLN, by ear, 2026-07-28: loaded vague_de_crime, played bombe_dj, and "heard
      diam's voice, now still hearing the synth from crimewave... i hear it regardless
      of gains, but filters work on them".
      
      THE MECHANISM, and why his two symptoms are the proof
      `dN $ ...` replaces orbit N and says NOTHING about the others, so every orbit the
      outgoing track declares and the incoming one does not just keeps running forever
      underneath the new track. "Regardless of gains" follows because the new track's
      gain lines address only ITS OWN orbits — nothing in it can reach the ghost.
      "But filters work on them" follows because gF3 is the GLOBAL CC51 DJ filter,
      applied INSIDE the ghost's own still-running pattern. An orbit that ignores the
      faders but answers the DJ filter is diagnostic of exactly this, and nothing else.
      
      Not a rig fault. It bites at every transition where the outgoing track declares
      an orbit the incoming one does not — which is 8 of the 12 OPAL transitions.
      
      WHAT SHIPPED
      armada/setlist_opal2026.txt — the set as DATA, in play order, resolved from
      backlog.md's codenames by locating each file rather than guessing ("Mafia" is
      mafia_sans_serif, "Le shifteur marteau" is electric_hammer, "Take five Drops" is
      take_5_drops). Reordering this file re-answers the ghost question, which is
      exactly what #12 needs.
      
      tools/orphan-orbits.py — reuses pvlint's existing orbit parser (DRY) and:
        * walks the set pairwise and names each transition's ghosts WITH THE SOUND they
          play, because "d6 (crimewave)" is actionable on stage and "d6" is not
        * --matrix, the full A->B grid, so #12 can order the set with the ghosts in view
        * --silence TRACK, a paste-ready `dN $ silence` preamble
        * --pair A B, exiting non-zero when ghosts exist so #44 can gate on it
      
      THE RESULT FOR THIS SET
      12 transitions, 8 leave ghosts, 16 orphaned orbits. Worst: after REVOLUTION (4),
      after Gimme Acid (3). The original ear-report reproduces exactly —
      vague_de_crime -> bombe_dj leaves {6, 10}, and d6 IS the crimewave synth.
      
      And one ordering lever worth more than the report: Perfect <3 declares ALL TWELVE
      orbits, so its column in the matrix is entirely clean. It is a free RESET POINT —
      put it after the messiest stretch and ghosts stop propagating. The tool now
      detects and names reset points generally rather than leaving that to be noticed.
      
      WHY IT STOPS SHORT OF FIXING IT AUTOMATICALLY
      The obvious automation is to have tidal-remote eval a generated silence block.
      Read the plugin first: `eval-file` calls atom.workspace.open(..., activatePane:
      true) — it OPENS AND ACTIVATES the file, so it would yank the editor away from
      the track being played, mid-transition. That is worse than the ghost. Until the
      plugin gets a focus-free `code` command, the fix is `hush` (safe under pressure,
      costs the tail) or pasting the block at the top of a track — and that is a .tidal
      edit, which is PLN's call, not mine. `dN` IS `xfade N`, so `d6 $ silence` should
      FADE the ghost rather than cut it; that wants an ear before it is assumed.
      
      TESTS: 39 new, suite 306 -> 345, all green.
      The one that matters is the cross-validation: the parser's orbit set is asserted
      against the TEN sets hand-measured on 2026-07-28, before this parser existed. All
      ten agree, so the tool and the ear agree. Also pinned: a commented-out `-- d6`
      counts as neither declared nor overwriting (correct in both directions); the
      silence block never silences the track's own orbits (that shape is a mute-bomb
      and would be blamed on the rig); it uses `silence` and never `# gain 0` (which
      leaves the pattern scheduled and revivable by a later global gain); and d1-d5
      never orphan anywhere in this set — so if a future track drops one, the ghost
      lands on the loudest possible orbit and the suite says so before the stage does.
      PLN (Algolia) authored
    • fix(lcxl): panic is a STATE, not a membership — and the six-step ramp was never wired in · 0b23f3d9
      Two colour bugs PLN found BY EYE, on the hardware, in the same glance. Both were
      cases of the code being confidently reasonable and visibly wrong.
      
      #76 — "why are [mutes] 1/2/3 resp red red green? these 3 buttons have same
      roles, why diff colours?"
      
      He was right and the cause was in two places at once. The panic feature is an
      OVERLOAD: hold LCXL buttons 73+74+91+92 together, the SC bridge edge-detects the
      chord and flips a persistent "^93" toggle, and gPanic gates on it. But those
      buttons have day jobs — 73 is gMute1, 74 is gMute2 — so:
      
        * control_colour tested `cc in PANIC_CHORD` BEFORE role, so 73/74 painted dim
          RED at rest while 75 (gMute3, identical job, not a chord member) painted by
          role. Three buttons, one job, two colours.
        * parse_track ALSO force-bound all four members to role "fx" (red hue) via
          setdefault, so even on a track binding gMute3 and not gMute1/2 the row read
          literally "R R G". Found by rendering mock-lcxl.py after fixing the first
          half and noticing the row still looked wrong.
      
      The panic identity does not exist at rest, so it must not be painted at rest.
      Now: `panic` is threaded in from the model (`values[93]`), the chord members
      paint by role like any other button when it is clear, and arming flashes red on
      all four as a GLOBAL OVERLAY applied last — over role, and over dark, because an
      unbound member must still flash. CC93 owns no LED of its own (it sits outside
      row F's 89-92), so those four buttons are the only place the armed state can
      live, and it is the highest-value LED in the rig: it answers "why is there no
      sound?". The watch loop rebuilds the whole frame on a ^93 flip rather than
      painting one index, since one event changes four LEDs.
      
      Verified by rendering the convention board: row F at rest is now
      `dimGRN dimGRN dimGRN dimAMB dimAMB dimAMB dimGRN dimAMB` — identical to row E,
      which is exactly what PLN reported seeing on row E ("G G G Y Y Y G Y"). Armed:
      `RED! RED! grn amb amb amb RED! RED!`.
      
      #11a — the DJ filter is THREE states now, not seven
      
      Settled by PLN: "sunset the dimorange and just have red in lows green in highs
      for clarity" + "i agree on clarity > resolution". The old ramp spent four of its
      seven steps on dim-amber and mid shades either side of centre, so the row read
      as a wash of oranges at a glance and the one thing you actually need — WHICH WAY
      is this filter cutting — was the hardest bit to see. Now: bright red (LPF, lows)
      / bright amber at the 61-67 detent band (bypass) / bright green (HPF, highs).
      All three are palette corners, the only states that read reliably on a dark
      stage. The centre band stays bright because djf 0.5 / CC 64 is BYPASS, not zero
      — djf 0.05 is a ~26 Hz low-pass, i.e. silence, which is the #48 footgun this
      colour exists to keep visible.
      
      #11b — "I See only two states, dim and not dim, at 0 and not 0 atm on the knobs"
      
      Also true, and embarrassing: `value_ramp` — his own verbatim six-step spec — was
      written, unit-tested, documented in the module header, and never once called.
      control_colour had exactly three outcomes for knobs (dim / full / flash), so a
      knob at 30% and one at 90% were the same colour. Wired in.
      
      The trade, stated because it should not regress silently: the ramp spends all
      three hues on VALUE, so hue no longer carries ROLE on rows A/B/C. That is the
      right way round — role is already fixed by POSITION (the lane convention),
      while a knob's setting has no other channel at all, since the pot's pointer is
      invisible on a dark stage. Keeping "untouched" as a colour was tried and
      abandoned: dim green would have meant both "rhythm, untouched" and "value ~3/4".
      That nuance belongs in the HUD, which has unlimited colours. Unbound controls
      still go DARK, which is the distinction that actually matters.
      
      Module header rewritten to describe the convention that now exists, rather than
      the one it used to.
      
      TESTS: +24 in test_lcxl_colour.py (8 -> 32), suite 282 -> 306, all green.
      The old knob rule had NO test at all — which is how a never-called ramp survived
      being shipped. Now covered: the filter has exactly three states and no dim amber
      survives; the detent band is contiguous and symmetric; the three mutes are equal
      at rest AND when engaged; arming repaints all four members and nothing else; an
      omitted panic argument behaves as unarmed (the #55 "untouched means zero"
      lesson); the overlay beats both role and dark; and the chord is asserted NOT to
      be force-bound as fx.
      
      Live: lcxl-leds-watch restarted, board repainted.
      PLN (Algolia) authored
    • feat(gig-log): a session recorder, so a run-through can be REVIEWED and not just felt · 4c57362d
      After a set, "did it glitch?" and "did it get hot?" and "which controls did I
      actually use?" are answered from memory. Memory is a bad instrument, and the
      answers matter: #8 (thermal impact under load), #56 (Pulsar starving audio),
      #46/#11 (which surface controls are really in the hands). PLN records the
      run-through in Ardour; this gives that take a machine-readable twin.
      
      WHAT IT DOES
        `gig-log.py record`   1 Hz JSONL: package/core temp, fan, freq, throttle
                              counters, per-core cpu%, and per-gear %cpu + RSS for
                              scsynth / sclang / ArdourGUI / the whole pulsar process
                              group. Plus PipeWire xruns and every LCXL control move.
        `gig-log.py mark ...` annotate the live session ("gimme acid drop")
        `gig-log.py report`   render it back: sparklines, xrun timeline, gear table,
                              a per-CC surface table with first/last touch
        `gig-log.py status`   is it running, what is it writing
        `gig-log.py install`  systemd --user unit, enabled, starts with the session
        `gig-log.py selftest` prove the parsers AND the cost
      
      Wall-clock stamped in the header, so the timeline lines up with the Ardour take.
      
      THE OBSERVER MUST NOT PERTURB — measured, not asserted
      This rig has twice produced the fault it was measuring: probe-chain's capture
      streams caused the xruns it was hunting, and the LED daemon's per-event fork
      caused the lag it was reporting. So: no audio capture at all, no per-sample
      subprocess spawn (every number comes from sysfs/procfs), and exactly two
      long-lived children (`pw-top -b`, `aseqdump`) drained by threads so a filling
      pipe can never block them. Measured cost of the whole thing: 0.40% of one core
      with 270 pw-top lines parsed in 10 s; 0.79% as the live systemd unit. The first
      selftest reported 0.66% and was FLATTERING ITSELF — it never started the reader
      threads, so it measured a sampler with nothing to parse. Same mistake as the
      first --bench run in the LED work; fixed, then re-measured lower and honest.
      
      AND THE LOGGER MUST NOT BE A FIREHOSE
      A fader sweep is 100+ events/s. A logger that writes them all costs more than
      what it measures, so CC/pitchbend are coalesced to one line per control per
      second carrying count + first/last/min/max — the #71/#72 fix applied to
      ourselves. 128 events on one knob is one line that still shows the whole travel.
      NOTES ARE NEVER COALESCED: a CC is a state and may be superseded, a note is an
      event and dropping one loses a thing that happened.
      
      COUNTERS ARE DELTAS, AND THE BASELINE IS WHERE THE BUGS LIVED
      Two absolute counters here are large and meaningless alone: package_throttle
      was 17024 after 2 idle days, and Ardour's PipeWire ERR was 67072. Both count
      history, including power excursions and device changes that never touched audio.
      So the header records baselines and samples record deltas.
      
      Getting Ardour's baseline right took three rules, and the first report caught
      each one:
        1. baseline on FIRST sight -> pw-top prints a zero-filled snapshot before the
           profiler has data, so base=0 and a 25-second IDLE session reported 72096
           xruns.
        2. baseline on SECOND sight -> usually right. pw-top emits a
           non-deterministic NUMBER of zero tables, so it silently reported 67123 on
           the next real run. A rule that is right most of the time is the worst kind
           for a gig log, because the one bad reading looks exactly like a disaster.
        3. baseline = MAX over each node's first 5 sightings. ERR is monotonic within
           a node's lifetime, so the max over a warm-up IS the true starting count —
           no timing assumption at all. A value below the baseline means the node was
           destroyed and recreated, so re-baseline instead of reporting negative.
      Validated by three independent 15 s runs, all reporting 0 (measure twice in
      time before trusting one reading). The report DECLARES the warm-up blind spot
      rather than hiding it.
      
      Also learned on the way: Ardour accumulates ~6 xruns/min even with nothing
      playing (67072 -> 67123 -> 67134 across captures minutes apart), which is
      exactly why only the session delta may ever be quoted.
      
      FIXED IN THE SHARED READER
      perf._read(None) raised TypeError instead of returning the default, so on any
      machine without a coretemp/dell_smm hwmon the thermal read CRASHED rather than
      reading "unknown" — the Bridge shares this code path. Guarded.
      
      Refactored _proc_cpu_rss into a pure parse_proc_stat() to make it testable, and
      it needed to be: pulsar's renderer comms look like `(pulsar) --type=renderer`,
      so splitting /proc/pid/stat on whitespace from the left shifts every field and
      silently reports some other column as CPU.
      
      TESTS: 71 new (tools/tests/test_gig_log.py), suite 211 -> 282, all green.
      Covers real pw-top/aseqdump lines, every baseline regression above, coalescing
      invariants, notes-survive-a-CC-flood, a torn final line costing one sample not
      the log, absent gear degrading to partial data instead of a crash, and
      sparklines bucketing by MAX so a one-second burst inside a 40-minute set cannot
      be averaged away.
      PLN (Algolia) authored
    • docs(tasks): archive #71/#72/#53 — the LED lag was a representation bug, and it… · cd5c46bb
      docs(tasks): archive #71/#72/#53 — the LED lag was a representation bug, and it had a twin in the Bridge
      PLN (Algolia) authored
    • feat(boot): kill the global gain that lived on a fader — and free fader 1 for d1 · 9647e446
      PLN, 2026-07-29: "midiGlobal can be killed imo. so yea please do the refactor of
      boottidal so we can free gain track1".
      
      `midiGGlobal = orDef 0.769 "^77" * 1.3` read the LIVE fader, and that was wrong twice
      over. First as SAFETY: it put a global gain on ONE physical fader, so brushing past
      fader 1 in the dark attenuated every midiG-using stream at once — the whole set, quietly,
      with nothing on screen to explain it. A control that can silence everything should not be
      reachable by accident. Second as ERGONOMICS: CC77 being read by Tidal is what kept the
      surface one lane off. Ardour has learned CC 78-84, CC77 is free, and with Tidal no longer
      reading it the eight faders can finally line up fader N -> dN instead of fader N -> d(N-1)
      (#46). Any track that reached for a fader was reaching one to the right of the orbit it
      was thinking about.
      
      Now a fixed pre-set: `midiGGlobal = 1.0`. Deliberately inaudible — the old untouched
      default evaluated to 0.769 * 1.3 = 0.9997, so every existing track moves by 0.003 dB.
      Global headroom is still adjustable, but it is one number in one place rather than
      something a hip can knock.
      
      NOT retired: the midiG family itself. PLN thought he no longer used it ("i dont use
      midiGs anymore iirc since ardour faders"), and behaviourally he is right -- with the
      global term gone, `midiG' ch l h` reduces to plain `gain (range l h ch)`. But the NAME is
      called in 167 files (`midiG'`) plus 12 (`midiG`), and deleting a definition the corpus
      references is a Haskell compile error, which takes the whole `let` block down and
      silences the entire track. That is the exact failure mode of the last two debugging
      evenings, and five days from OPAL is not when to re-open it. Retiring the usage is a
      post-gig corpus migration, sibling to #64.
      
      Verified with tools/check-boot.sh: the helper block typechecks against tidal-1.9.5 under
      `ghc -fno-code`, the #55 seed block typechecks, and all 13 g* helpers (including
      midiGdef, which is the one this edit could plausibly have broken) still yield events with
      an UNTOUCHED controller. Not "no error appeared" -- the helpers were run against an empty
      control map and their event counts checked.
      
      Also lands tools/at/lens.py: infers WHICH MEASUREMENT can see a given control from the
      track's own source, so a per-track acceptance test can assert "this knob measurably moves
      the orbit it is wired to" without using a lens that is blind to the effect. rms cannot see
      a filter or a bitcrusher -- they rearrange the spectrum and leave the level alone -- so an
      rms-based "did anything change?" reports a confident NO on an effect that works perfectly.
      Classifies 96.7% of the corpus's 5046 control bindings (33% unknown -> 3.3% once the
      scanner looked at a 3-line window instead of one line, because Tidal expressions wrap and
      an unclassified control is one the suite silently SKIPS -- precisely the controls most
      likely to be broken). 41 unit tests, every positive case a real corpus line, with a
      coverage guard so a future edit cannot quietly regress the scan.
      PLN (Algolia) authored