1. 29 Jul, 2026 16 commits
    • 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 24 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
    • perf(bridge): the MIDI monitor had the LED bug's twin — it dropped the NEWEST event · 1c9df100
      Found by sweeping the rest of the gear for #71's bug class, which turned out to have a
      sibling living in the Bridge.
      
      `MidiStream._reader` did `q.put_nowait(ev)` and on `queue.Full` silently `pass`ed. That
      keeps 512 stale events and throws away the one that just happened -- exactly backwards
      for MIDI state, where the newest value IS the truth. A dashboard tab that stalled for
      ~1.3 s at 400 CC/s would fill its queue and then display frozen values for the rest of
      the set, with no error logged anywhere. Same failure shape as the LEDs: nothing breaks,
      it just quietly stops telling you the truth.
      
      Downstream, `_sse_midi` wrote and flushed once per event, so one fader sweep cost ~400
      HTTP flushes a second per open tab, each with its own json.dumps.
      
      Fixed the way the LCXL painter was: drop-OLDEST on a full queue, and batch a frame's
      worth of events into a single write. New `coalesce()` folds the batch on one rule --
      CONTINUOUS controls (CC, pitchbend, aftertouch) are STATE and may be superseded by a
      newer value; NOTES are EVENTS and may never be dropped. That distinction is the whole
      point: a fast monitor that loses a note is strictly worse than a slow one, so the tests
      assert every note survives a flood of 100 controller messages.
      
      Order is preserved by overwriting a superseded value where it stood rather than moving
      it to the end, so the monitor still reads as a timeline. The wire format is unchanged
      (one `data:` line per event), so ui/index.html's onmessage/JSON.parse is untouched.
      
      Also extracted `_fanout` so the drop policy is testable without ALSA. A green test on
      `parse_line` proved nothing about the queue behaviour behind it -- verify the seam.
      
      8 new tests, 32 green in tools/bridge.
      PLN (Algolia) authored
    • perf(lcxl): the LEDs lagged because we deduped on VALUE, and colour is a step function · 128de62d
      PLN, watching the board for the first time on real hardware: "its slow to track if
      i move all fders quickly i see the animations lagging a sec or two behind".
      
      Not a slow device. Three compounding faults, each of which alone would have been
      survivable:
      
      1. THE BUG. The read loop repainted whenever a CC *value* changed. But colour is a
         STEP function of value -- six steps in value_ramp, seven in filter_colour. Sweeping
         one knob 0 -> 127 emits ~128 events and can change the board at most 6 times. We
         were asking the wire for ~20x the work that could possibly be visible.
      2. Every send re-resolved the port from scratch: `aconnect -o` AND `amidi -l` AND then
         `aseqsend`. Three forks per LED, ~1.7 ms each, measured.
      3. All of it ran INSIDE `for line in proc.stdout`, so a write in flight stopped us
         reading the next MIDI event. aseqdump's pipe QUEUES rather than drops, so nothing
         was lost -- everything just arrived later, and later, without bound. That
         unboundedness is why it read as "a second or two" rather than a constant delay.
      
      The fix, in payoff order. A new `Painter` thread owns the wire; the reader only touches
      the model and hands it colours, never blocking. The painter dedupes on the COLOUR the
      board will show, coalesces so only the last colour per index within a frame reaches the
      wire, and batches every dirty index into ONE SysEx (the Launchpad dialect takes
      (index, colour) pairs, so a whole-surface repaint is a single write). Port resolution
      is cached for 2 s and thrown away the instant a write fails -- evidence, not a timer --
      which keeps the replug-recovery property that made it uncached in the first place.
      Rate limiting is a floor on the GAP between writes, not a fixed tick, so an isolated
      button press still goes out immediately.
      
      Measured, not claimed. `--bench` replays PLN's own complaint (8 knobs + 8 faders swept
      together, 500 events/s) against a transport modelled at its real cost, and runs the
      legacy path beside the new one:
      
          legacy     744 wire msgs   wall 11.23s   overrun +8.23s
          coalesced   48 wire msgs   wall  3.00s   overrun +0.00s
          15.5x fewer messages; latency p50 7.9 ms, p99 20.2 ms
      
      Overrun IS the visible lag -- it is how far behind his hands the board finishes. On the
      real device the per-write cost also fell 7.70 ms -> 2.72 ms with ports cached, so the
      total wire work is down roughly 44x.
      
      Twelve regression tests, no hardware needed. Speed regresses silently -- nothing goes
      red, it just gets slow again -- so the assertions are numeric: wire rate bounded by the
      frame rate and not the input rate, reader never falls behind, p99 under 50 ms, a lone
      press not delayed by the frame boundary. The last one is the one that matters: fast and
      wrong beats nothing, so we decode every SysEx the coalescer emitted through the mock
      surface and assert the board that LANDS is exactly what a full `build_frame`
      recomputation would have produced.
      
      Also: verbose logging now fires only when the board actually changes. Printing 400
      lines a second of "CC77 = 63" was itself I/O in the hot loop, and told nobody anything.
      PLN (Algolia) authored
    • docs(tasks): archive #58 — the LED thread's real bug was that nobody could see the output · 89d9f127
      Three days stuck on LED feedback that had never once been observed lighting a
      physical LED: every validation ran against a fake aseqdump stream. PLN confirmed
      the hardware works on 2026-07-28. Building the mock surface took under an hour
      and immediately explained the complaint that had survived two rounds of fixes —
      the DJ filters rest at BRIGHT AMBER, which is filter_colour(64) behaving exactly
      as designed and visually identical to the factory yellow he was trying to escape.
      Correct and looks-broken were the same picture.
      PLN (Algolia) authored
    • feat(lcxl): a virtual LaunchControl XL, and PLN's six-step colour ramp · 80b732b0
      The LED work had a hole in the middle of it. `lcxl-leds.py --watch` was
      written, wired into the boot sequence and into gig-up.sh, and validated only
      against a FAKE aseqdump stream — nobody, human or machine, had ever seen it
      light a single LED. PLN kept reporting "still seeing static colors" and I had
      no way to check my own work, because I cannot look at the device.
      
      mock-lcxl decodes the same SysEx the real surface receives and renders it in
      the terminal, so the convention can be designed, reviewed and regression-tested
      without hardware. The only thing still needing PLN's eyes is whether the
      physical LEDs match the picture.
      
      It answered the "static yellow" complaint on its first run. Painting
      claude.tidal lights knobC 1-3 BRIGHT AMBER (63) — which is exactly
      filter_colour(64), the DJ filters resting at centre. Our code is working as
      designed; the design is the problem. Bright amber at rest is visually
      indistinguishable from the factory yellow PLN is trying to get away from, so
      "correct" and "looks broken" are the same picture. Second finding, same run:
      control_colour returns hue["full"] for every value from 8 to 119, so a touched
      knob is one flat bright colour across its whole travel. Touch changes it once
      and then it never moves again — which is precisely what he has been describing.
      
      So value_ramp() implements his spec verbatim — "from dim red nothing through
      bright red dim orange bright orange dim green bright green at max". That is
      six steps, and six is not a coincidence: the LCXL is bicolor (2 bits red x 2
      bits green = 16 states) and only about six read reliably on a dim stage. The
      ramp uses the entire usable budget and spends nothing on shades nobody can
      tell apart. `mock-lcxl.py --palette` shows the whole budget; --ramp shows the
      convention resolving across 0..127.
      
      The DJ filters deliberately keep filter_colour and do NOT get the ramp: they
      are bipolar, centre 64 is bypass, and a monotonic red->green ramp would paint
      bypass as mid-orange and the two opposite musical extremes as the same colour.
      
      8 regression tests covering the ramp order, monotonicity, "a mapped control at
      zero is dim, never off" (a control you cannot see is one you forget exists),
      "a filter is never dark", and a SysEx round-trip through the mock surface.
      PLN (Algolia) authored
    • feat(at): pv-at — acceptance tests that play the rig and assert on real audio · 6fbd4256
      Green unit tests on pure functions prove nothing about whether the rig makes
      sound. Every failure that has cost this project an evening lived in the SEAM:
      a helper that typechecks but silences an orbit, a knob that moves but changes
      nothing, an orbit still playing from the previous track. pytest cannot see any
      of it; a microphone can.
      
      So pv-at drives the REAL rig — boots a track through tidal-remote, taps the
      real PipeWire graph through probe-chain's `Tap`, and asserts on measured audio.
      Reusable by construction: a case is a named assertion, not a one-shot script,
      so tonight's investigation is tomorrow's regression suite.
      
      The fixture is a track, not a test tone: claude.tidal, a slow D-minor roller at
      124. Test tones prove the signal path works and nothing about whether MUSIC
      survives it, and they are miserable to listen to while debugging. Each of its
      eight orbits is simultaneously a part of the arrangement and an assertion —
      kick/presence, hats/density, sub/register, stab/brightness, pad/sustain,
      arp/mask, riser/time-axis, break/mute.
      
      8/8 passing against the live rig. Three of the assertions had to be rewritten
      first, and each rewrite is the same lesson in a new costume — the arithmetic is
      never the bug, the QUESTION is:
      
        * sub_is_low asserted on spectral centroid and failed d3 at 837 Hz — an orbit
          carrying `# lpf 220`. A magnitude-weighted centroid integrates the whole
          spectrum, so a low-level broadband floor spread over 20 kHz drags the mean
          far above where the energy actually is (d5: lpf 1400, centroid 6344 Hz).
          Replaced with band-energy ratio: d3 now reads 95% of its energy below
          300 Hz. The centroid stays for RELATIVE tests, where the floor is common to
          both readings and cancels — filter_bites moves 2749 -> 2152 Hz cleanly.
        * riser_rises compared the first half of the window to the second and was a
          COIN FLIP: d7 is driven by a saw whose period is comparable to the capture
          window, so a fixed window lands on a random phase. Consecutive runs of the
          identical pattern scored +7.1 dB and -20.9 dB. Replaced with the maximum
          draw-up (largest rise from any bin to any later bin), which is true at every
          phase: 32.4 dB over a 34.1 dB span.
        * stab_is_bright demanded ">35% of energy above 1 kHz" and failed d4 at
          32.6%. That threshold was invented, and the honest response to a number
          that close is to fix the question rather than nudge the threshold until it
          passes. Now comparative — the stab must sit well above the sub (32.6% vs
          0.4%) — which is a musical invariant instead of a guess.
      
      Safety is structural, not procedural: SAFE_CC is an allowlist, CC 77-84 are
      excluded by construction (Ardour owns those faders; CC77 down is total
      silence), the LCXL port is re-resolved BY NAME on every send, and the suite
      hushes when it finishes. Exit code 2 means "rig not running", kept distinct
      from 1 "a case failed" — conflating an unmet precondition with a pass is how a
      green suite comes to mean nothing.
      PLN (Algolia) authored
    • feat(lint): pvlint — the bugs that silenced this rig, encoded as rules · 81eb4dc0
      Three evenings this week went to bugs that were VISIBLE IN THE TEXT and that
      no eye caught mid-set, because none of them produce an error message. They
      produce silence, on stage:
      
        * desire.tidal — `off 0.125 (|+ note 12 . (|* gain 0.6))`. `.` binds looser
          than application, so this composes a FUNCTION into a ValueMap. GHC refuses
          the whole block, and since a blank line is Tidal's block separator, ONE
          character silenced nine orbits while the file looked perfect on screen.
        * you_my_sunshine.tidal — a local `let gF3 = (# djfbus 3 (range 0.05 0.95
          "^51"))` shadowing the global helper we had already fixed. SuperDirt maps
          djf through linexp(0, 0.5, 20, 10000): bypass is 0.5, and 0.05 is a ~26 Hz
          low-pass. Four orbits (d5/d7/d9/d11) inaudible until a knob crossed centre.
        * the #55 mute-bomb — an untouched `^NN` yields NO events, so `range a b
          "^NN"` is `silence`, not a number.
      
      All three are static properties of the file, so they belong in a linter.
      
      Seven rules, each earned by a real failure: PV001 local djfbus shadow (with an
      autofix that deletes the line so the track inherits the corrected global),
      PV002 operator-section composition (autofix adds the missing parens), PV003
      double-applied global gain, PV004 shared cut group, PV005 duplicate orbit in
      one block, PV006 sample index past the end of its folder, PV007 orbit
      inventory (the input to the orphan-orbit transition check, `--setlist`).
      
      Three of my own rules were wrong on first contact with the corpus, and the
      corpus caught all three — which is the argument for running a linter against
      694 real files before trusting it:
      
        * PV002 was string-blind and called four mini-notation `.` separators
          compile-killers (`"k ~ ~ k . ~"` is grouping, not composition). 13 findings
          -> 0, all of them false.
        * PV003 claimed the whole 77-84 fader bank was "owned by Ardour" and raised
          1109 errors. BootTidal itself reads `^77` (midiGGlobal); reading a fader CC
          is the documented convention. Narrowed to the actual hazard — ^77 applied
          twice — and demoted to a warning.
        * PV005 flagged every re-declaration of an orbit, but keeping several
          evaluable versions per file is normal live practice. Scoped to one block:
          576 findings -> 32.
        * PV006 pooled all indices in an orbit against all folders and invented a
          finding on d8 (n=24 is org_jungle_breaks', not breaks165's).
      
      Also fixed the parser: ORBIT_RE demanded a same-line `$` and so dropped d5
      from you_my_sunshine's inventory WHILE THE TRACK WAS PLAYING IT — PLN writes
      `d5  -- comment` with the `$` on the continuation line, which Haskell's layout
      rule allows. A linter that under-reports is worse than none.
      
      Corpus after the corrections: 694 tracks, 316 errors (all PV001 shadows, in
      archive material), 281 warnings. First real run found a live one — d5 and d11
      of you_my_sunshine share cut group 5, so the voice and the chop truncate each
      other.
      
      33 unit tests, every negative case a real corpus line that an earlier rule
      version wrongly flagged.
      PLN (Algolia) authored
    • docs(tasks): archive #66 — six wrong instruments and one solid rig · 2788722c
      The full #66 entry for the documentary trail. The headline is not "all 10 tracks play"
      (they do, 91 orbits, zero silent) but that the instruments were wrong more often than the
      rig — six times, and the arithmetic was never the bug in any of them. Three answered a
      question about SHAPE with summary statistics, one divided by zero and called it an effect,
      one compared two points on the same hill, and one measured a system that included the
      measurement.
      
      The last is the best story: an xrun counter climbing +816 in 25 seconds, days before a
      gig, matching PLN's own report of crackles — and caused entirely by probe-chain's own
      PipeWire capture nodes. Remove them, repeat the identical musical load, get zero.
      
      Both REAL faults that day were compile errors wearing an audio costume.
      PLN (Algolia) authored
    • feat(tempo-lens): measure the tempo the room FEELS, not the one setcps declares · 156444d6
      #12 (order the set into a BPM arc) has been blocked on three tempo questions, and
      they were framed as things PLN had to decide. Two of them are measurable.
      
      FIRST, ONE DISSOLVED ON INSPECTION. you_my_sunshine was recorded as "144 measured vs
      166 written", a 22 BPM gap big enough to INVERT the planned rising finish
      Mafia(160)->Sunshine(166). The file has two setcps lines and the 144 one is COMMENTED
      OUT. The active tempo is 166, the backlog was right all along, and the "mismatch" was
      a grep reading a comment. That is the parsers-over-copy lesson again: a parser miss
      must never masquerade as a data conflict. (My own first re-extraction then mangled
      every number by using `tr -d '/60'`, which deletes the digits 6 and 0 — 160 became 1.
      Two parsing bugs in one sitting, on four-line shell one-liners.)
      
      SECOND, THE REAL QUESTION NEEDS AUDIO. gimme_acid declares 80 while the backlog calls
      it 160, and `setcps` cannot settle it: setcps(80/60/4) declares four beats per cycle
      at 80, but whether that is HEARD as 80 or 160 depends on what the patterns put inside
      the cycle. Half-time notation is standard in dnb. This decides whether the track sits
      beside the 160 BPM peak or is the second-slowest thing in the set.
      
      So: spectral-flux novelty curve -> autocorrelation -> the fastest pulse that explains
      the signal, reported together with its half/double family, because tempo from audio
      is only ever determined up to a factor of two and pretending otherwise is the error.
      
      BUILT THE KATANA FIRST, and it needed four passes — every one caught on synthetic
      click tracks of KNOWN tempo before the tool informed any decision:
       1. Inter-onset-interval MODE. Failed on the first real track: gimme_acid's d1 is a
          continuous 303 line whose flux peaks every ~80 ms of internal movement, so the
          modal gap gave "750 events/min". Counting gaps between events cannot find a beat
          when the events are not beats. -> autocorrelation, which asks about PERIODICITY
          and is unbothered by extra onsets inside each period.
       2. Octave errors: a clean 124 click read 61.9, a clean 160 read 80.0 — exactly half,
          correlation 0.85+, confidently wrong. Cause: at 100 Hz frames the true lag 48.39
          must round to 48, which misaligns every later beat, while exactly 2x landed on a
          whole frame. -> 200 Hz frames + 15 ms Gaussian smoothing, so a fractional period
          still matches itself.
       3. A uniform +1.8% bias (~2 BPM at techno tempo — enough to swap a 124 and a 127 in
          a set order). Suspected the unnormalised overlap in np.correlate, fixed that too,
          and the bias did not budge. The actual cause: the shortest-lag-within-12% rule,
          written to choose between octaves, was also sliding down the LEFT FLANK of the
          correct peak — comparing points on one hill as if they were different hills.
          -> candidates restricted to local maxima, plus parabolic interpolation.
      Result: 13/13 synthetic cases within 0.03%, including jittered, noisy, offbeat-8ths,
      and the half-time case (80 BPM with 16th kicks correctly reads 160 felt).
      
      A tempo number that is 2% wrong looks perfectly reasonable, which is exactly why this
      had to be calibrated against known truth instead of eyeballed against real audio.
      PLN (Algolia) authored
    • docs(tasks): close #66 — 91 orbits, zero silent, and four wrong instruments · 104fb035
      The end-to-end gate result, plus the two findings that arrived after the log was
      first written: the mixer is clean LIVE (zero "Ardour eats it" across all 10 tracks,
      after PLN raised the physical faders mid-session), and the rig does not xrun under
      musical load — zero across 60s at maximum event density, scsynth ERR = 1 all session.
      
      Also records the fourth instrument error, which is the best story of the day: the
      xrun counter that climbed +816 in 25 seconds and looked exactly like "heavy patterns
      cause dropouts on gig week" was probe-chain's own capture streams. Remove the probes,
      repeat the identical musical load, get zero. Every probe run creates and destroys ~18
      PipeWire nodes and each renegotiation costs Ardour an xrun.
      
      The pattern across all four: the arithmetic was never wrong. Three of them answered a
      question about SHAPE with a pair of summary statistics; the fourth measured a system
      that included the measurement. What makes them dangerous is that a perturbing
      instrument produces PLAUSIBLE numbers pointing at the wrong subsystem, and
      confirmation bias supplies the rest — I was looking for a crackle cause and was
      handed one.
      PLN (Algolia) authored
    • fix(probe): one low FINAL bin is the window closing, not a fade · 36466a40
      Third and last calibration of shape_of() against real audio. perfect.tidal's d4
      measured |▇▇▆▆▇▇▆▅▆▆▇▆▃| — visibly steady for twelve bins — and was reported
      "DECAYING, 13 dB down, never recovering, almost certainly an xfade tail" on the
      strength of the thirteenth. The capture window had simply closed inside a gap
      between events.
      
      The midpoint-crossing rule added in 708c2aef cannot catch this: a dip in the last bin
      crosses the midpoint exactly ONCE, which is the signature of a genuine fade. So ask a
      different question — does the verdict survive dropping the final bin? If the inner
      trend is flat, the final bin WAS the verdict, and the answer is STEADY with the edge
      dip named explicitly rather than hidden.
      
      A real decay is unaffected, including one that only begins in the last three bins
      (still DECAYING). Eight synthetic shapes now separate cleanly: steady-with-edge-dip,
      monotonic decay, late decay, gated-riser-dipping-at-the-end, spiky-sparse, died,
      steady, rising.
      
      Pattern across all three fixes to this function: every false positive came from
      answering a question about SHAPE with a pair of summary statistics. Each fix replaced
      a statistic with the structural question that actually distinguishes the cases —
      does it recur, does it survive without its last sample. The verdict text now says
      which, so the reading can be argued with instead of believed.
      PLN (Algolia) authored
    • docs(runbook): the checklist said to put the DJ filters DOWN for bypass — centre is bypass · 4836d74f
      The pre-set checklist carried a line that has been wrong since gDJF was rewritten:
      "Faders 49/50 down (= bypass)". `gDJF` is two composed sections whose lpf and hpf are
      BOTH wide open at ch=0.5 and close as you move off centre in either direction
      (BootTidal.hs ~358, corrected mid-rehearsal 2026-07-27 when PLN described exactly
      that: "middle had no filter, total left = almost only subbass, total right = almost
      only superhigh"). So a knob at 0 is a hard low-pass. The runbook was instructing him
      to start the set with the bass-only end of the filter engaged — the opposite of
      bypass — and muscle memory built on a checklist is worse than no checklist.
      
      Measured the same day for confirmation: CC49 at 0 puts 72% of d1's energy below
      150 Hz; at 127, 75% sits above 2 kHz; centre 64 is flat.
      
      Also adds §8, "I evaluated it and heard nothing", because the most common cause of
      silence on this rig is not audio: three times in one week a dead rig was a compile
      error. The identifying rule is structural and instant — ALL declared orbits silent
      means it did not compile (one block, one error), SOME silent means a real audio
      problem. With the bisect-by-blank-line technique PLN invented, the editor-buffer
      caveat, and a pointer to the new EVAL ERROR notification that finally makes this
      class visible.
      
      And wires the new instruments into the pre-set flow: check-mix.py (save Ardour
      first — it reads the file, and file vs live desk have disagreed in both directions),
      check-tracks.sh (the empirical compile gate, ~45s/track so run it the day before),
      lcxl-leds.py --map. Status section updated honestly: everything here was run against
      the live rig that day EXCEPT the LED paint, which nobody has confirmed by eye.
      PLN (Algolia) authored
    • feat(control-lens): the noise floor comes from REPEATED baselines, not one lucky repeat · 16429b0e
      The closing A-again capture gives a single estimate of how much the signal moves on
      its own, and on the first real run that estimate was wrong in the direction that
      matters. Two captures of d2 at the SAME control setting read 1.6 and 2.4 onsets/s —
      33% apart, which happened to equal gMask's entire measured swing — yet the closing
      capture matched the opening one exactly, so the tool reported drift 0.0% and called
      the control EFFECTIVE. One repeat can agree with the first reading by luck.
      
      Now the baseline is captured --repeats times (default 2) BEFORE the control is
      touched, and the spread between those captures is the floor a swing has to beat;
      the closing A-again is combined with it via max(). This is why d7's ply came back
      INCONCLUSIVE (swing 13.3% vs drift 10.0%) instead of EFFECTIVE — which is the right
      answer: across two runs that same control read -18% and then +3.3%, i.e. noise.
      
      The rule this encodes: on a livecoded rig the material is not stationary. Patterns
      written as `"~ c . <[~ c ~ c] [<c ~ ~ c> ...]>"` play a different bar every cycle, so
      a before/after difference is evidence of nothing until you know how big a difference
      the music produces unaided. Measuring the control without measuring the noise is how
      you end up certain about a knob that does nothing.
      
      Also lands the #66 log (armada/tasks/018) with the full per-track and per-control
      tables, and the three findings still open: gMask's swing is not clear of its own
      variance, a reproducible ~15s settle lag after a control returns to rest, and
      Ardour's xrun counter climbing while SuperCollider's does not.
      PLN (Algolia) authored
    • fix(control-lens): a control is only proven if its swing beats the signal's own drift · 7b2e5b4d
      Three corrections after the first real run against the rig, each one a case of the
      tool answering a question it could not actually answer.
      
      1. THE PLY VERDICT WAS A DIVISION BY ZERO. `ply` reported "EFFECTIVE, +inf %
         onsets/s" on d4. The truth: the envelope-ratio onset detector returned a flat
         0.0 events/s, because d4 is a SUSTAINED bass and that detector only fires on a
         1.6x jump between adjacent bins. Percentage against zero is infinity, and the
         tool declared an effect from an artifact — precisely what its own docstring
         promises never to do. Replaced with SPECTRAL FLUX (half-wave-rectified
         per-band magnitude increase, adaptive median + 2.5*MAD threshold), which fires
         on a NEW NOTE even when total level barely moves — exactly what ply does, since
         it subdivides events without getting louder. d4's baseline went 0.0 -> 8.1
         onsets/s, and ply then measured a real, REVERTING change: 8.1 -> 4.7 at CC127
         -> 7.7 back at 0.
         Plus a floor: under 0.8 onsets/s the answer is INCONCLUSIVE with an
         explanation, not a number. inf/nan deltas print NOT JUDGED and are excluded.
      
      2. NO CHANGE MEANT THE WRONG LENS, NOT A DEAD CONTROL. gMask on d2 read "NO
         CHANGE, 0.5 dB rms" — correct arithmetic, wrong question. gMask is
         `midiOn "^41" (mask "t!7 f")`: it removes one eighth of a cycle, so it changes
         the PATTERN and costs ~0.6 dB. On the density lens the same control reads a
         clean -42% onsets. A "no change" verdict is only meaningful if the measure
         could have seen the change.
      
      3. A-B-A, BECAUSE THE PATTERNS MOVE BY THEMSELVES. gMask showed an IDENTICAL -42%
         at both test values, which is either a control that fails to revert or a
         pattern that just varied — and d2 of vague_de_crime is
         `"~ c . <[~ c ~ c] [<c ~ ~ c> ...]>"`, a different bar every cycle. An A-B
         comparison cannot tell those apart. So the baseline condition is now
         re-measured at the END, the self-drift is reported, and any swing under 2x the
         drift is INCONCLUSIVE rather than EFFECTIVE. First use immediately paid off:
         ply's timbre swing 19.9% against 4.7% drift = trustworthy.
      
      Self-inflicted note for next time: these edits were made WHILE a batch loop was
      invoking the file, so one run died on a half-applied change (AttributeError on an
      argparse dest added two edits later). Its captured data was fine, but don't hot-edit
      a tool a running job is calling.
      PLN (Algolia) authored
    • feat(rig): the boot now LIGHTS the controller — gig-up and tidal-remote both paint it · 90f20cf7
      The painter existed as of the previous commit but nothing called it, which is exactly
      how the surface went dark in the first place: the only LED path was the Pulsar HUD, so
      the board was lit only when the editor happened to be open AND its frame pipeline was
      healthy. #57 says that pipeline latches an early empty frame. A rig whose feedback
      depends on an editor being up is a rig with no feedback.
      
      So both boot paths paint now:
      
      * tools/tidal-remote.py boot becomes a 5-step dance — reboot, wait for BootTidal, seed
        the surface (lcxl-init), PAINT it (lcxl-leds --map <track>), eval. Order matters and
        is unchanged in spirit: the seed gives every `^NN` a value instead of `silence`, and
        the paint makes that state visible. Per-track, so the board is a map of the file
        about to play: dark = this track does not bind that control.
      * gig-up.sh paints at step 2b, the moment the rig owns MIDI — BEFORE Ardour and Pulsar
        are even launched, using the no-track CONVENTION paint so there is always something
        to look at. GIG_LEDS=watch additionally starts the touch-reactive daemon (log →
        gig-leds.log, gitignored); GIG_LEDS=off skips the whole thing.
      
      Both call sites are NON-FATAL on failure and say so out loud. LEDs are feedback, not
      sound: a dark board is annoying, a boot that aborts because of a dark board is a lost
      gig. gig-up additionally names the one thing software cannot fix — if input works but
      the board stays dark, that is a USB OUT endpoint stall and only a REPLUG clears it.
      
      Both messages refuse to claim success: "a clean exit is not a lit LED". amidi/aseqsend
      returning 0 means the bytes left the machine, nothing more.
      
      Validation: bash -n on gig-up.sh, ast.parse on tidal-remote.py, and the paint command
      itself run against the live device (rc=0, 40 LEDs in one 89-byte SysEx write). NOT run:
      tidal-remote boot, deliberately — PLN is measuring audio in this same rig right now and
      a reboot would trash his results. The 5-step path is verified structurally, not live.
      PLN (Algolia) authored
    • fix(probe): a decay crosses its midpoint ONCE — stop calling gated risers dying · 708c2aef
      probe-chain reported piment_bresilien's d10 as "DECAYING, almost certainly an
      xfade tail" in two consecutive runs. It is nothing of the kind: d10 is a `mask
      "<t f!7>"` riser — one cycle sounding, seven silent — and the sparkline showed it
      plainly, dipping and coming back (|▇▇▇▇▇▅▃▇▇▇▆▄|). The classifier was reading
      head-quartile vs tail-quartile, which is a monotonicity question answered with two
      averages: exactly the missing-information mistake that made an aggregate unable to
      tell a fade from a sparse part, one level up.
      
      First attempt asked "does it recover after its lowest bin?" — which fails when the
      dip lands at the END of the window, since there is no 'after' to recover into, and
      that is precisely the real d10's shape. The right evidence is REPETITION: count
      how many times the level crosses its own midpoint downward. **A monotonic fade
      crosses once, by definition. Two or more is a cycle, and a cycle is a part.**
      
      Validated against seven synthetic shapes before trusting it on real audio, and
      they now separate cleanly: gated-riser-with-a-dip-at-the-end -> GATED/SPARSE,
      spiky-sparse -> GATED/SPARSE (this one was misclassified as DECAYING even in my
      own self-test and shipped that way), monotonic decay -> DECAYING, decay after a
      steady head -> DECAYING, silent tail -> DIED, plus STEADY and RISING.
      
      Why it matters more than the numbers suggest: a tool that cries wolf on a healthy
      riser gets ignored on the night it is right. J-7 is the wrong week to teach PLN to
      distrust the instrument.
      
      Also lands tools/check-tracks.sh — the empirical pre-gig gate. There is no static
      typechecker for a .tidal file (it is a GHCi fragment, not a module), so the only
      honest question is "evaluate it and does sound come out". Three times this week a
      silent rig was really a compile error, and each time the damage was TOTAL rather
      than partial because a blank line is Tidal's block separator and these tracks have
      almost none. All-orbits-silent is therefore diagnostic: the track did not compile.
      Run it the day before a gig and after ANY BootTidal.hs edit, since a broken helper
      silences the whole corpus at once. It distinguishes Tidal faults from Ardour faders
      and refuses to let a mixer problem read as a track bug.
      PLN (Algolia) authored
    • feat(lcxl): a standalone LED painter — the surface stops being dark, and stops being factory yellow · 60de8a2d
      The problem, in PLN's words, asked three times: "why no button lights, still see
      only A1 green? recover that asap i see no feedback anymore not even yellow
      lifeline" — then "back from 'all yellow' to 'our coding, but touch-reactive and
      persistent post touches'".
      
      Three failures were stacked:
      
      1. NOTHING PAINTED AT BOOT. The only LED painter lived in the Pulsar HUD
         (lib/lcxl-leds.js), so the board was lit only if Pulsar was open, had activated
         the package, and its frame pipeline worked — and #57 says its lastFrame dedupe
         latches an early empty frame, so even with Pulsar open one bad first frame means
         a permanently dark surface with no error anywhere.
      2. FACTORY YELLOW WINNING — a real Midi-Through feedback loop echoing velocity 127
         back as an LED colour byte, not a device default.
      3. NO PERSISTENCE. The LEDs are write-only, there is NO readback, so software must
         own the state and re-assert it. Nothing did.
      
      Approach: tools/lcxl-leds.py, python3 stdlib only, standalone. It works with Pulsar
      closed and is scriptable from gig-up. --map [TRACK] paints by ROLE derived from the
      `^NN` bindings the file actually contains (dark = unbound on this track, which is
      the biggest cognitive win); --map with no track paints the channel CONVENTION, so a
      boot can never leave the board dark. --watch is the touch-reactive daemon: it keeps
      a model of every control and repaints from that model, so persistence is the data
      structure rather than a feature bolted on. --test walks every index so a dead LED
      is visible. --dry-run prints hex and sends nothing.
      
      It NEVER sends a CC. Not one, ever. SysEx out only; input is read-only via aseqdump
      as a child process, which does not steal MIDI from SuperCollider. Sending CCs would
      move live audio parameters, and CC 77-84 are MIDI-learned to Ardour track gains
      (CC77 down = total silence).
      
      Three things this cost, worth writing down:
      
      * TRANSPORT. `amidi -p hw:2,0,0 -S ...` is what lit knob A1 by hand — but only
        because nothing else held the raw device. On a LIVE rig the ALSA sequencer layer
        owns the rawmidi substream and amidi dies with "cannot open port hw:2,0,0: Device
        or resource busy". The tool that works on a cold rig failed on the only rig that
        matters. Primary transport is now `aseqsend` to the LCXL's writable SEQUENCER
        port, which coexists with SuperCollider; amidi stays as fallback.
      * PARSING. Splitting the .tidal on blank lines is right in principle (that IS
        Tidal's block separator) but stripping full-line `-- comments` first manufactures
        blank lines that cut a dN block in half: on vague_de_crime that put 9/9 bindings
        in the "fx" fallback because no segment head ever matched dN. Segment boundaries
        are now original blank lines PLUS every dN line, comments stripped only for the
        CC scan. Same track now reads rhythm=9, fx=6; gimme_acid 21 controls across
        rhythm/bass/fx; perfect.tidal 25 across rhythm/bass/lead/fx.
      * THE SEAM. The first --watch regexes assumed bare space-separated numbers and
        would have matched NOTHING — a daemon that runs clean, logs nothing and paints
        nothing. Pulled aseqdump's real format strings out of the binary ("Control change
        %2d, controller %d, value %d") and verified the whole loop by putting a fake
        aseqdump on PATH: 9/9 synthetic events decoded, coloured and sent, plus a
        respawn-with-backoff when the child exited.
      
      Also: the periodic re-assert runs on its own thread, not inside the read loop —
      inside, it would only ever fire when an event arrived, i.e. never during the
      silences when a device hiccup would actually go unnoticed.
      
      Verified: hex reviewed byte-by-byte against the protocol, 40 index/value pairs in
      one write, all bytes < 0x80; sends return rc=0 to the real device; --watch attaches
      and survives; event decode unit-checked (CC13@64 -> green full, CC49@0 -> dark red
      = LPF hard down, CC33@127 -> green flash, note 73 -> red flash). UNVERIFIED: what
      the panel actually looks like — nobody has eyes on it. Absence of an error is not
      evidence of a lit LED.
      PLN (Algolia) authored
    • fix(desire): one misplaced paren silenced all nine orbits — and a lens that can see a filter · cfa56b69
      desire.tidal measured 0/9 orbits: every declared channel SILENT AT SOURCE, master
      bus at -inf. Not the sample-buffer bug (b4f62757 fixed that), not a fader — the
      file simply never compiled.
      
          off 0.125 (|+ note 12 . (|* gain 0.6))
      
      `.` binds tighter than the section, so this parses as `|+ (note 12 . (|* gain
      0.6))` — composing a function into a ValueMap. GHC's own words: "'(.)' is applied
      to too few arguments". The fix is one pair of parens: `(|+ note 12) . (|* gain
      0.6)`. After it, 9/9 orbits measure STEADY through SC and Ardour.
      
      WHY IT COST A WHOLE TRACK. desire.tidal has ZERO blank lines, and a blank line is
      Tidal's block separator — so the file is ONE block, and a block either compiles
      entirely or not at all. One character took nine orbits. This is the third track
      this week where "the rig is silent" was really "the code did not compile", and it
      was only visible because the editor stopped deleting the word "error" from GHC
      messages (fork commit 374d8a3): PLN read the EVAL ERROR notification off his
      screen and forwarded it. Blank lines between orbits are free gig insurance.
      
      Also lands tools/control-lens.py, the instrument for the other half of the
      question. probe-chain proves an orbit makes LEVEL; it cannot prove a control
      WORKS, because rms is blind to a DJ filter or a bit-crusher — those move timbre
      at roughly constant loudness. So control-lens measures the right quantity per
      control kind: spectral centroid + band energies for filter/crush, rms for
      gain/mute, onset rate for ply/density. It drives the CC through the same path a
      physical knob takes (aseqsend -> SC MIDIFunc.cc -> /ctrl -> sStateMV, reused from
      lcxl-init rather than reinvented), captures before and after, and refuses to
      guess: a contaminated tap is DISCARDED, a silent baseline is SKIPPED (else one
      dead fader reads as twelve dead knobs), and "no measured change" is reported as
      NO CHANGE, never softened to "subtle". It also hard-refuses CC 77-84, which are
      MIDI-learned to Ardour's track gains.
      PLN (Algolia) authored
    • docs: the performing session is 'Tidal Live', not 'Tidal Multi' · 3a94a97a
      PLN: 'wtf tidal multi? we made everytihng on tidal live remember that please'. He was
      right and this line was the source of the error: check-mix.py had hardcoded the Tidal
      Multi path, so it parsed a stale archive session and confidently reported five faders at
      -inf that were actually up in the live session. A mixer auditor reading the wrong mixer is
      worse than no auditor.
      
      Both sessions are real — Tidal Multi still holds the ~398 G of historical per-orbit
      recordings — so this documents the distinction rather than renaming one away.
      PLN (Algolia) authored
    • docs(tasks): archive #26 — the silent-orbit hunt, and why the fix was one line in our own preload · 5ee9395c
      Written for a cold reader and for the blog: the two-part mechanism (upstream header-only
      reads + our own flag left flipped), why whitelist-by-mtime made it look haunted, the
      count assertion catching its own counting bug on the AppleDouble twins, the 'Required
      0 MB' red herring, and the two harness failures (zsh word-splitting, an early readiness
      gate) that cost more time than the bug itself.
      PLN (Algolia) authored