1. 01 Aug, 2026 1 commit
    • docs(tasks): archive #44/#114 — the orchestrator as adversarial user · 29ce99d3
      Two entries. #44's lesson is the one worth mining later: wiring six working
      checks into one caller found two of them dead, because every check had been
      verified once, interactively, by a human who supplied the missing piece without
      noticing. A human types `zsh tools/foo.sh` out of habit and never learns the
      file is not executable. An orchestrator has no habits.
      
      #114 records that the overnight constraint list did real work rather than being
      ceremony: "never write to a setlist .tidal" is what kept three ranked-"needed"
      tasks off the queue, and drawing that boundary first is why the night was
      productive instead of risky.
      PLN (Algolia) authored
  2. 31 Jul, 2026 9 commits
    • docs(morning): fold in the real corpus number — 51+21 of 694 live/ tracks · f849295c
      The sweep landed after the brief was written. Replaces the placeholder with the
      measured figure and, more importantly, with the right DENOMINATOR: study/ is
      documentation prose, so 79+30 of 799 overstates it the same way the harness bug
      did. Also surfaces the CRASH class to him directly, since those are the ones
      worth his time and the ones I deliberately did not touch.
      PLN (Algolia) authored
    • docs(corpus): the real compile-failure count, with an honest denominator (#112) · 4eb3cf01
      The clean re-run after the harness fixes. Three numbers have now been attached
      to this question and the first two were both about the tool:
      
          #112 said        14   (a baseline taken during the mute migration)
          pre-fix run     128   (the column-0 `$` artifact, ~49 of them fake)
          clean run        79 BROKEN + 30 CRASH, out of 799 files
      
      And 799 is the wrong denominator too. Split by scope:
      
          OPAL setlist        13 files    0 BROKEN   0 CRASH
          live/              694 files   51 BROKEN  21 CRASH   ~10%
          study/              59 files   13 BROKEN   3 CRASH
          sandbox/blocks/test 21 files    5 BROKEN   3 CRASH
      
      `study/` is Tidal DOCUMENTATION with inline examples — prose that was never
      meant to compile as a track. Counting it inflates the figure the same way the
      harness bug did, just more quietly. The number worth quoting is 51+21 of 694.
      
      THE GIG IS UNAFFECTED. Zero setlist tracks in either category, and gig-up is GO.
      
      The CRASH class is new and it is the valuable one. These tracks COMPILE, then
      throw when queried — invalid mini-notation inside a string, which is exactly why
      the type checker waved them through:
      
          "[d d] <d d d*2 d*4>]"     unbalanced bracket
          "~ ulgab?:1"               `?` before `:`
          "[0 .. 7 0]"               a range with a trailing element
          "<0.75 .. 0.8 0.8 .. 0.65>"  a sequence on the left of `..`
      
      Every one would fail live, because the scheduler queries every cycle. Until this
      run they were all reported as "FAIL — 0 orbit(s) SILENT".
      
      Deliberately NOT fixed here. None are in the set, and each one needs a decision
      about what PLN meant — "[0 .. 7 0]" has at least two readings and only he knows
      which. Triage is the deliverable; the edits are his call.
      
      Also records a counting caveat rather than hiding it: silent-eval prints a
      track's STEM, not its path, so stems that exist in several directories are
      marked as ambiguous instead of guessed. The real fix is repo-relative paths in
      silent-eval's output — noted on #112, not silently papered over.
      PLN (Algolia) authored
    • docs(morning): Saturday brief — the gate is GO, and #112's number was the tool's fault · caac31cb
      Leads with 'nothing needs your hands', because the previous two mornings led
      with a fader he had to raise and that framing sets the whole read. Then the two
      things that shipped, then the one finding that changes something he was already
      told: #112's '14 tracks do not compile' was a harness artifact, and the harness
      was calling his own dominant writing style a parse error.
      
      Deliberately does NOT quote a corpus-wide count. The clean re-run was still
      going; the number lands on the task instead. Quoting an unmeasured figure is
      exactly how #112 got a wrong one.
      PLN (Algolia) authored
    • test(silent-eval): pin the three bugs, and prove the pins actually bite (#112) · ee7b8b8c
      The previous commit fixed three bugs in the tool whose entire job is catching
      failures that make no noise. That makes its own failure mode the worst one
      available: saying OK about a track that does not work, or BROKEN about a track
      that does. It was doing BOTH, corpus-wide — and it stayed hidden because the 13
      setlist tracks, the only ones anyone ever pointed it at, happened to dodge all
      three.
      
      Seven tests, all structural — no ghc, no rig, 0.06s:
      
        1. a column-0 `$` is a CONTINUATION (PLN's dominant corpus style), plus a
           guard that indenting does not FLATTEN relative structure below it, plus
           one for the ordinary inline style — which gets a test precisely because
           dodging the bug is what let the bug live.
        2. a non-zero exit with no SILENT line is a CRASH, and its stderr survives
           into the detail, because stderr is the only thing that says why.
        3. the exit code answers "is anything wrong", never "is anything silent".
      
      MUTATION-CHECKED, because green tests on their own prove nothing. Each fix was
      individually reverted and the suite re-run:
      
        remove the continuation indent  -> test_dollar_at_column_zero_is_indented FAILS
        key the return to silence only  -> test_exit_code_is_not_keyed_to_silence_alone FAILS
        both restored                   -> 7 passed
      
      Test 3 is the one that matters. When crashes were split out of the silence
      count, the return value was briefly left keyed to `silent_only`, so a corpus
      where every single track failed to compile would have printed "OK — every
      declared orbit emits events" and exited 0. That is a false green from the gate,
      and a false green here is indistinguishable from a working set right up until
      the downbeat.
      
      Full suite: 229 passed.
      PLN (Algolia) authored
    • feat(gig): the surface card — generated from the grid, not copied from it (#16) · 3d91272e
      #16 was ranked "bonus, can be 80/20ed". The cheat-sheet IS the 80/20: at OPAL
      he is alone in a field with a laptop and a 48-cell control surface. The FOH
      chain and the go-bag he can hold in his head. Twelve columns of CC numbers he
      cannot.
      
      GENERATED, NOT AUTHORED. A hand-written card is a copy of tools/lcxl_grid.py,
      and a copy drifts silently the moment the grid changes — which it did twice
      this fortnight. This reads the grid and the setlist directly, so a card that
      disagrees with the rig is impossible by construction. `--md` rewrites
      docs/GIG-CHEATSHEET.md; regenerate rather than edit.
      
      THE EMERGENCY BOX GOES FIRST
      The moment you reach for this page is the moment something is wrong. A card
      that opens with a reference table makes you read past your own panic to get to
      the answer. So the top of the page is eight failure modes and their remedy,
      each one a symptom he has actually hit: LEDs dark but knobs alive (USB OUT
      endpoint stall — replug), a track making no sound (it did not compile; one
      block, one error), an orbit that will not die (it is the previous track's
      pattern), mixer silent while Tidal looks fine (a fader; the desk wins on touch).
      
      TWO LAYOUT BUGS THE FIRST RENDER CAUGHT
        * the title bar was hand-padded and off by one — now ljust'd like every
          other line, so it cannot drift again
        * gF3 and gM3 hold 7 and 8 orbits, which overran a side-by-side two-column
          family layout and silently misaligned the block. Stacked instead.
      
      AND ONE COLUMN DELETED FOR SAYING NOTHING
      The set table first carried a "roles" column. Every track in the set carries
      kick+perc+bass+lead, so it printed the identical string 13 times and pushed the
      page past 80 columns. Replaced with the orbits each track does NOT declare —
      which is the fact worth having on stage, because an undeclared orbit is not
      silent, it keeps playing whatever the previous track left there. That column is
      the transition ghost list (#77), per track, at a glance. It also shows `perfect`
      declaring all 12, i.e. the one track in the set that cannot inherit a ghost.
      
      Verified: 0 lines over 80 columns, renders from a clean checkout, and the
      family map printed matches lcxl_grid.filter_family/mute_family exactly.
      PLN (Algolia) authored
    • fix(silent-eval): the harness called 128 working tracks broken, and a crash "silence" (#112) · d280acf0
      #112 said "14 corpus tracks do not compile". Running the harness over the whole
      corpus said 128. Neither number was about the music.
      
      BUG 1 — column-0 `$` continuations reported as parse errors
        PLN writes a lot of the corpus like this:
      
            d1
            $ whenmod 128 129 (…)
            $ s "<k k*2 <k*2 k> k>"
      
        Pulsar sends a block wrapped in GHCi's `:{ … :}`, which suspends the layout
        rule, so that `$` at column 0 is a continuation and the track plays fine.
        `orbit_bindings` rewrites `dN` into `name = idcp` and emits the block into a
        real MODULE, where a column-0 `$` opens a NEW top-level declaration — a hard
        parse error. The harness then printed, in these words:
      
             THE TRACK DOES NOT COMPILE — it will fail live too.
      
        about code that has never once failed live. The intent to indent was already
        written in the comment above the line ("becomes `name = idcp` + `  $ x`");
        the indent itself was never applied. Two spaces on lines[1:] restores it and
        preserves every relative indent below.
      
        Why it stayed hidden: ZERO of the 13 setlist tracks use that style, and the
        setlist is what anyone actually runs. The harness was green on everything it
        was ever pointed at. Same shape as the chmod bug in the previous commit — a
        thing verified once, in a context that no longer covers the corpus.
      
      BUG 2 — a crashed probe reported as "FAIL — 0 orbit(s) SILENT"
        The Haskell probe sets its failure flag ONLY when it prints a SILENT line, so
        a non-zero exit with no SILENT line never measured silence at all — it threw
        while QUERYING. `check_track` mapped any non-zero return to "SILENT" and
        discarded stderr, so the one string that said what went wrong was thrown away
        and replaced by a self-contradicting verdict. New CRASH verdict keeps stderr.
      
        It immediately paid for itself. `live/chill/dub.tidal` compiles, plays three
        orbits, then throws:
      
            Syntax error in sequence:
              "<0.75 .. 0.8 0.8 .. 0.65>"
                           ^
      
        A real mini-notation bug, precisely located — and one that WOULD fail live,
        since the scheduler queries every cycle. It had been sitting behind a message
        that said zero orbits were silent.
      
      BUG 3 (latent, found while fixing 2) — a false green in the exit code
        Splitting the summary so crashes stop being counted as silence revealed that
        the return was keyed to the silence count alone. A corpus where every single
        track failed to compile would have printed "OK — every declared orbit emits
        events" and exited 0. The exit code now answers "is anything wrong", which is
        the only question a gate may answer — this is the one tool whose entire job
        is catching the failure that makes no noise.
      
      Verified: setlist still 13/13 green and gig-up still GO (so no regression on
      the path that matters); dub now reports THROWS WHEN QUERIED and exits 1;
      previously-"broken" tracks compile. The corpus-wide count is deliberately NOT
      restated here — it needs a clean re-run, and quoting a number I have not
      re-measured is how #112 got a wrong one in the first place.
      PLN (Algolia) authored
    • feat(gig): one command says GO or NO-GO — and it caught two bugs on its first run (#44) · 1219c704
      A fortnight of debugging produced a shelf of good checks: check-boot,
      check-mix, silent-eval, fix-mute-roles, pvlint, orphan-orbits. Every one of
      them worked. Every one of them was also a command PLN would have to REMEMBER —
      and on Sat 8 Aug at ~19:00 he is in a field, alone, with a soundcheck window
      and no assistant. A check you have to remember under pressure is a check you
      do not run.
      
      So gig-up.sh contains no new checking logic at all. It is a running order, an
      exit code, and a decision about which failures are GO-BLOCKING. Its whole value
      is that there is now one thing to type.
      
      WHAT IS HARD vs SOFT, and why the line is drawn there
        HARD  boot helpers    one parse error in BootTidal.hs silences EVERY track
                              at once, invisibly, while a stale ghci holds the old
                              definitions. First check for that reason.
        HARD  ardour faders   earned it three sessions running, a DIFFERENT set of
                              tracks each time: 07-28 had 05/06/08/10 at -inf,
                              07-30 had 06/10/12, 07-31 had 10. Three recurrences is
                              not bad luck, it is a property of MIDI-learned faders
                              that drift from the saved session in both directions.
        HARD  compiles / mute map / pvlint
        SOFT  transition ghosts   real (#77) and known-open. A gate that goes red for
                                  work you have consciously deferred is a gate that
                                  gets ignored.
        SOFT  LCXL present        legitimately unplugged at a kitchen table. At the
                                  venue, a warn here means the set has no hands.
      
      TWO BUGS FOUND BY THE FIRST RUN — which is the argument for building it
        1. check-boot.sh and check-tracks.sh were mode 644. NOT EXECUTABLE. The
           pre-gig audio gate has never once been runnable as `tools/check-tracks.sh`
           since it was written; it only ever ran when someone typed `zsh tools/...`.
           Every green memory of it is a green memory of a different invocation.
        2. pvlint's `--setlist` is a MODIFIER ("these paths are ordered"), not a
           mode — it still requires paths. `python3 -m pvlint --setlist` exits 2 on
           an argparse error, which from the outside looks exactly like a lint
           failure. gig-up now borrows set-coherence's setlist_tracks() rather than
           teaching a fourth file to parse the setlist.
      
      Both are the same shape and it is this rig's signature failure: a thing that
      was verified once, in a context that no longer holds. Assembling the checks
      into one caller is what re-executed them under fresh conditions.
      
      DESIGN NOTES
        * Cold by default. Makes no sound, sends no MIDI, writes no .tidal. Safe to
          run mid-set, at 3am, or backstage. The audio gate is --audio, and it skips
          itself if the cold gate is already red — booting 13 tracks to confirm what
          a typecheck just told you is 10 wasted minutes at a venue.
        * Never touches CC 77-84 (Ardour track gains; 77 down is total silence) and
          never sweeps 93 (gPanic).
        * Scope is the SETLIST, not the corpus. 14 corpus tracks do not compile
          (#112) and must never make the gig gate red.
        * Failures print FIRST and carry their fix command inline, because the read
          order in a field is "what is broken / what do I type".
        * GO states its own limit every time: check-mix reads the SAVED session, so
          green means "Ardour will BOOT with this fader up", never "the mixer IS
          correct". Saying it in those words is how the -inf bug stops hiding behind
          a clean report.
      
      Verified: NO-GO path observed for real (2 failures, both genuine, both fixed);
      GO path 7/7 green; --help, --quiet and unknown-flag paths exercised.
      PLN (Algolia) authored
    • feat(tools): set-coherence — 108 controls sit idle under his hand, and three of… · a32c6d2c
      feat(tools): set-coherence — 108 controls sit idle under his hand, and three of them are systematic (#113)
      
      PLN, 2026-07-31, having walked the OPAL set by eye:
      
        "when i went across set i saw some unmapped effects/button, a 'set coherence
         mapping review' and quick wins suggestions (bass octersub, lead slice, etc etc)
         could be good ways for you to fill-in suggest"
      
      He was right, and the number is larger than "some": **108 idle slots across 117
      declared orbits in the 13 setlist tracks.**
      
      ## Why this needed a tool rather than a read-through
      
      Absence has no line number. The surface is a fixed grid — each orbit owns a known set
      of cells (effect knob, maybe a second, gate button, maybe a second) — so a track that
      declares d5 and never references ^33 leaves d5's knob inert for that track's whole
      duration. You cannot grep for a control that isn't there; you can only compute it, by
      subtracting what the track USES from what the orbit OWNS.
      
      The tool reports both directions of the same coherence question, deliberately:
        IDLE     orbit owns a slot the track never uses   -> unused affordance (new)
        STARVED  track drives more controls than it owns  -> already covered by PV008
                                                             and the FIXME(#54)/(#94) markers
      Fixing one can create the other, which is why they belong in one report.
      
      ## Three systematic findings, not a flat list
      
      1. **The drums' own effect knobs are the deadest controls on the board.**
         d2.fx (B2 ^30) idle in 12/13 tracks, d1.fx (B1 ^29) 9/13, d3.fx (B3 ^31) 8/13.
         d1-d3 own exactly ONE per-orbit knob each — their row-C cells are the family
         filters — so this is not a spare slot going unused, it is their only knob. The
         KICK has no per-orbit effect knob bound anywhere in the entire set.
      
      2. **Row A (d9-d12 effects, A5-A8) is ~90% idle.** d11 6/6, d12 5/5, d10 6/7, d9 5/8.
         That is #98's proposal (pool A5-A8 in order of appearance) with its supporting
         numbers: four knobs almost never used, on exactly the orbits that overflow.
      
      3. **d6 has gates but NO knobs, 5 of 5 tracks.** Fallout from the d7->d6
         consolidation: ^58/^90 came along, ^34/^54 did not. And vague_de_crime's d6
         references nothing at all — no knob, no gate.
      
      ## Suggestions are grounded, not invented
      
      Measured what PLN actually binds to a per-orbit effect knob across the set — perc
      legato(4); bass lpf(3), gain(1), bandf(1); lead width(2), modIndex, filterRange,
      cutoff, resonance; kick nothing — and the suggestion table echoes that idiom before
      extending it (memory feedback_self_reference_rhythm_study: extract the grammar from
      his own tracks rather than importing generic Tidal advice).
      
      Worth recording because it inverts the ask: his two named wants sit OUTSIDE his
      measured idiom. He binds lpf to bass knobs and never octersub; slice lives on his
      buttons, not his knobs. So "bass octersub, lead slice" are additions he is reaching
      for, not corrections of drift — and saying so is more useful than pretending the
      corpus already implied them.
      
      ## Deliberately not applied
      Every suggestion changes how a track sounds and he is rehearsing on these files this
      weekend. Output is docs/2026-07-31-set-coherence.md to skim and cherry-pick. The tool
      also never suggests binding CC 77-84 / 13-16 (Ardour track gains), 93 (gPanic), or the
      family cells 49/50/51 + 73/74/75.
      
      Also confirms the #94 remap held: "borrowed" CCs — ones an orbit drives without owning
      — are 0 or 1 across every track in the set.
      PLN (Algolia) authored
  3. 30 Jul, 2026 7 commits
    • fix(gimme_acid): the EXPLOSIF knob boots at an extreme — diagnosed, not silently retuned (#111) · 91b8b958
      `# width (range 0.2 0.8 "^33") -- /!\ FIXME EXPLOSIF /!\` in gimme_acid's acid synth.
      PLN has had a hazard marker on that line for a while. The cause is mechanical and
      checkable without any audio:
      
      ^33 is an A/B knob, so BootTidal's #55 seed gives it 0. The seed policy states its own
      convention — "range <neutral> <extreme>, so 0 == neutral for effect knobs" — but 0.2 is
      NOT neutral for `width`: the preset reference PLN pasted two lines above the call says
      nominal is width = 0.51. So the line boots the synth at an extreme and the knob sweeps
      it THROUGH nominal rather than away from it, which is a good candidate for whatever
      "explosif" means to his ears.
      
      This is the exact shape BootTidal.hs already documents as an authoring bug, using
      piment_bresilien's `legato (range 0.05 2 "^52")` as its worked example, and its stated
      remedy is to fix the range in the TRACK rather than special-case the seed.
      
      Deliberately NOT applied. The candidate is `range 0.51 0.8 "^33"` — boots at nominal,
      knob only adds — but it costs the 0.2-0.51 downward half of his sweep, and that is a
      decision about how the track sounds. The analysis goes in the file next to his marker
      so the next reader inherits the diagnosis instead of re-deriving it; the sound stays
      his.
      
      Did remove one unambiguous thing: `# filterRange (range 0 8 "^53")` appeared TWICE in
      consecutive lines, same value, so the second was a no-op paste.
      
      MORNING.md rewritten for today. Leads with the finding that actually matters at J-7:
      check-mix.py reports Tidal 06/10/12 at -inf dB in the SAVED Ardour session, and
      cross-referencing those against the orbits each setlist track declares shows **9 of 13
      tracks would lose at least one orbit on a relaunch** (#62). The live faders are
      probably fine — PLN has been playing — which is precisely the bug: the fix is not
      persisted, so it dies with the process. Ctrl+S is the whole remedy, and it is his
      hands, not mine: the D row is MIDI-learned to CC 77-84 and writing from outside
      desyncs it from the physical desk.
      
      Also flagged in MORNING.md rather than guessed: yesterday's file said "OPAL Day-5" and
      today's framing was "J-7". There is no OPAL 2026 entry in Web/www/content/lives/2026/,
      which CLAUDE.md names as the canonical source for gig metadata, so the countdown is
      unresolvable from here and is asked rather than invented.
      PLN (Algolia) authored
    • refactor(corpus): 917 lines move the kick onto its own mute button — every… · 27c1581e
      refactor(corpus): 917 lines move the kick onto its own mute button — every track, not just the set (#105, #70)
      
      PLN, mid-migration, when I had scoped this to the 13 setlist tracks:
      
        "good, and not only in the set am i right? did we not do the whole refact on the
         other .tidal files on my repo?"
      
      He is right, and the reason is muscle memory: his fingers do not know which folder a
      track lives in. A half-migrated corpus is worse than either extreme, because it makes
      the surface unpredictable exactly when he reaches for an old track mid-set.
      
      ## What moved
      
      913 lines across 213 files, plus 7 orbits given a mute they never had:
      
           505+4  gM1 -> gM2      percs vacating the kick's button (the BULK of the work —
                                  not the kick itself, which is only 133 lines)
          127+1   gM2 -> gM1      the kick, arriving
            66+4  gMute2 -> gM1   same, in the legacy long spelling
              64  gM1 -> gM3
              58  gMute3 -> gM3   spelling normalisation only
           41+2   gM2 -> gM3
            12+2  gMute2 -> gM2 / gMute1 -> gM2 / gMute1 -> gM1 / gM3 -> gM2  the tail
               7  FILL            orbits with NO mute at all, in the SET only
      
      Only the setlist was filled. PLN: "ensure we have that consistently in all set, and
      flag in other non-set tracks for me to see." Outside the set a missing gate may be a
      composition choice, so docs/2026-07-30-orbits-without-a-mute.md REPORTS the rest and
      writes nothing — split into the cut that makes 2397 findings actionable:
      
          A · partial  110 files   321 blocks   uses family mutes but missed some
                                                <- the oversight he meant
          B · never    354 files  2076 blocks   no orbit has one; predates the convention
                                                <- a style era, not a mistake
      
      ## Also, in this diff: #70
      
      `live/midi/nova/dnb/liquid/you_my_sunshine.tidal` had d5 (the voice) and d11
      (`no_sunshine:4/4` chopped) both on `# cut 5`. A cut group is monophonic, so every
      d11 event killed the vocal mid-word. That is ALSO the `-- FIXME VOICE OFF` PLN had
      left on d5 line 59: one root cause wearing two labels, found by pvlint PV004 on
      2026-07-28 and confirmed by reading the file today. d11 moves to `# cut 11`, matching
      the group-N-equals-orbit-N convention d3/d6/d8/d9 already follow. The comment says
      how to revert in one line if the vocal was meant to be monophonic across both layers.
      
      ## How this was verified, given it touches 213 files PLN has to play in 7 days
      
      1. **Convergence** — `--apply` twice; second pass proposes 0 rewrites and 0 fills.
      2. **Structural proof, not spot-checks** — diffed every changed line against HEAD and
         asserted the two sides are identical once mute tokens and whitespace are stripped.
         908 of 917 changed lines are provably mute-plumbing-only; the other 9 are listed
         and each accounted for (4 fills onto head lines with no `$`, 3 trailing-whitespace,
         1 FIXME annotation, 1 the cut 5->11 fix). Nothing else in the corpus moved.
      3. **Compile-invariance argument** — gM1/gM2/gM3 are the same type and arity in
         BootTidal, so substituting one for another cannot change whether a file compiles.
         Only the 7 fills alter expression structure, and all 7 are in the set.
      4. **silent-eval --seeded on the set: 13/13 ok**, before and after.
      5. Suite: 222 passed.
      
      THE HONEST LIMIT, stated because a green check here is misleading: every button CC
      seeds to 0 at boot and gMute<N> is `midiOn "^7N" (mask "f*16")`, so all three mutes
      are equally inert until pressed. silent-eval therefore proves nothing got silenced at
      boot and NOTHING about behaviour under a press — which is the only thing this change
      alters. The real gate is PLN's ear, or pv-at (#74).
      
      NOTE FOR PLN: 213 files changed on disk. Anything open in Pulsar now has a stale
      buffer, and Pulsar saves the BUFFER — reload before evaluating or your next Ctrl-S
      silently reverts the migration in that file.
      PLN (Algolia) authored
    • feat(surface): the mutes group by ROLE, the filters by BLOC — two maps, one author (#105) · 72943be9
      PLN settled the gM question on 2026-07-30, and the answer was not the one this task
      had assumed for a day:
      
        "I want the kick, now on d1/fader1, to have its mute on F1 botrow button. I want
         the C1 knob to djf all percs, C2 only bass djf, C3 melodies DJF. however i want
         the mutes consistent across all all tracks: m1 d1 / m2 other percs / m3 all
         bass+melodics"
      
      ## The thing I had wrong
      
      #105 was framed as "align gM<N> to gF<N>" — make the mute index mirror the filter
      index so an orbit's knob and button share a column. I measured 35 mismatches in the
      set under that invariant and was ready to fix them.
      
      That invariant is WRONG, and the measurement was answering the wrong question. The
      filters and the mutes are two different gestures:
      
        FILTERS want the rhythm section as ONE BLOC     — you sweep the drums together
        MUTES want the kick SPLIT OUT on its own button — you drop the kick alone
      
      So gF groups {d1,d2,d3,d8} and gM splits d1 from {d2,d3,d8}. Under the real map the
      edit count is 55, not 35 — and `d4: gF2+gM3`, which the mirror-invariant called a
      mismatch in 11 tracks, is CORRECT. Had I shipped the "alignment" I would have moved
      11 correct lines and left the kick sharing a button with the hats.
      
      Recorded as tools/lcxl_grid.py `_FILTER_FAMILY` / `_MUTE_FAMILY` — in the ONE
      authored grid (#97), with his words above it, so the asymmetry reads as deliberate
      rather than as drift. `as_dict()` now exports `orbit_family` so the HUD can say which
      knob AND which button own an orbit; `--generate` rewrote tools/lcxl_grid.json and the
      HUD package's own copy (outside this repo, in Tools/pulsar-parvagues-hud).
      
      ## tools/fix-mute-roles.py
      
      Convergent migrator: `--apply` rewrites, `--check` exits 1 on drift (a pre-gig gate),
      `--fill` gives a mute to a block that has none, `--report-missing` writes a markdown
      report instead. Reads the map from lcxl_grid rather than restating it.
      
      ## PV012 / PV013, so it cannot re-drift
      
      PV012 flags an orbit whose gM does not match its ROLE — deliberately NOT "gM index
      != gF index", and there is a regression test asserting `d2 $ gF1 $ gM2` is clean,
      because a rule written to the mirror invariant would flag that valid line. PV013
      flags an orbit with no family mute at all: PLN's "no mute is oversight!" — nothing on
      the surface can drop such an orbit, so taking it out means editing live.
      
      ## PV013 immediately caught a bug in the migrator that wrote it
      
      Minutes after PV013 existed, it flagged `d5` in you_my_sunshine — an orbit the
      migrator had walked straight past. Cause: fix-mute-roles had its OWN orbit regex
      requiring `^dN $`, and that file's head line is `d5  -- The Voice of Love`, no `$`,
      with the gates on a commented continuation. pvlint's regex allows `dN` followed by
      `$`, `--`, or EOL. Two parsers disagreeing about what an orbit IS is the same class
      of bug as two copies of the grid, so the migrator now imports pvlint's parser and
      owns none of its own. Re-running found 14 more rewrites and 7 more missing mutes it
      had silently skipped — the miss was under-application, not corruption, but it would
      have left exactly the inconsistency this task exists to remove.
      
      Also fixes #109: the orphan-orbits hand-measurement for you_my_sunshine still
      expected d7, which PLN moved to d6 in ce887b78. Re-derived by reading the file's
      column-0 declarations, not by pasting the parser's output — a hand measurement that
      quotes the thing it checks is a tautology.
      
      Suite: 222 passed, 0 failed (8 new PV012/PV013 tests).
      PLN (Algolia) authored
    • perf(set): PLN's in-flight edits — the controls come back onto their own orbits · ce887b78
      Twelve files PLN had been working live, committed as his own change before any
      tooling touches them. Not a refactor: this is the set moving under his hands.
      
      The through-line is the #94 remap paying off. The remap had COMMENTED OUT every
      control line that fired across columns — honest, but it left orbits playing with
      their hands tied. He is now restoring them onto the CCs the orbit actually owns:
      
        gimme_acid  d1's kick variations were disabled because they rode ^42, which the
                    remap gave to d2. Restored on ^41, d1's own gate — so the kick keeps
                    its midiOn/midiOff variation without borrowing a neighbour's button.
        perfect     d5's SUPERSTAR chain was 14 commented lines. Rebuilt live on ^53/^33
                    with a nested `midiOff "^89"` mask, so the slice logic is gated by a
                    button instead of hardcoded.
        desire      the compile blocker is gone: `# pan 0.42plz /se` (line 46) is fixed.
                    That one typo, in a file with no blank lines — hence ONE do-block —
                    was killing every orbit in the track. Found by silent-eval learning to
                    blame the track instead of itself; `silent-eval --seeded` now reports
                    13/13 ok, the first time the whole OPAL setlist has built cold.
      
      Also: d7→d6 in you_my_sunshine (with ^91→^90, ^59→^58) — the same consolidation he
      made in do_it_right, freeing an orbit slot. This invalidates a hand-measured fixture
      in tools/tests/test_orphan_orbits.py; the parser is right, the fixture is stale.
      
      Orbit naming throughout (BASSE DU 3e cercle, Synth lapin speed, SuperStars, Breaks
      divins), gain tuning, and backlog notes. Structural changes stay in the next commit
      so this one reads as pure performance work.
      PLN (Algolia) authored
    • docs(tasks): archive #93/#106/#107/#31 to the structured completed log · 8c3bea37
      Four tasks closed 2026-07-29 evening: the silent-eval harness fix (and the real bug
      it uncovered), the take-pack sidecar+archive tool, and the desire rhythm study.
      Written for a cold reader per the archive's own convention — commit hashes are in
      git log, this is the narrative and the numbers that don't show up in a diff.
      PLN (Algolia) authored
  4. 29 Jul, 2026 23 commits
    • docs(morning): rewrite the review checklist — last night's version was stale · 9ec69a35
      The existing MORNING.md led with a warning about ctrl+enter fading to silence over
      4 bars (#79) — fixed and confirmed by ear hours ago ("no sample goes oblivion!! 👏").
      Leading with a solved problem buries what actually needs review tonight.
      
      Replaced with five things worth his eyes/ears, ordered by urgency: the desire
      compile break (#108, a real gig blocker), the corrected take-94 mix (v2, the trim
      threshold fix), silent-eval now covering all 13 setlist tracks, the slop renderer's
      first real output (not yet good enough to send, flagged as such), and the four
      desire bass-rhythm alternatives to A/B. Everything else from the session is
      mechanical and needs no listening, so it's kept to one short list at the bottom
      rather than mixed in with what actually needs his judgment.
      PLN (Algolia) authored
    • study(desire): the bass is a metronome with a stutter — four rhythmic alternatives (#31) · f2467db7
      PLN: "i gotta solve desire bassline is not great rythmics coulld be cooler more nto
      more ete_a_mauerpark or insouciance or haunted_house i guess."
      
      Read the three references he named and extracted what they share. The diagnosis turns
      out to be mechanical rather than a matter of taste. desire's d4 is
      
          note (scale "aeolian" "0 . 0*<1 2> . <0 3> . 0*<1 1 4>" + 2 - "[24,36]")
      
      — four EQUAL groups, the same degree in nearly all of them, never a rest, never
      displaced. The only variable is density, applied to a repeated note. That is a
      metronome with a stutter, and no amount of filter movement makes it a riff.
      
      The six mechanisms both references use and desire has none of:
       1. uneven durations via `@` (insouciance 5:1:1:1 of 8; mauerpark 1:1:14 of 16) —
          one long note holds the harmony, short notes carry the motion;
       2. the pitch pattern IS the rhythm pattern, so a new note is a new onset, instead
          of a static degree with density stacked on top;
       3. calage — `(0.125 <~)` displaces the whole line so it pushes against the kick
          rather than doubling it;
       4. accent follows rhythm — `|* gain ("1@5 0.95 0.98 0.95")` carries the SAME
          weighting as the notes;
       5. space — rests inside the phrase and whole bars masked out;
       6. the phrase is longer than the bar (4-bar alternation, plus `slow 2`).
      
      Four proposals in study/desire_bass_rhythm.tidal. A, B and C each isolate ONE
      mechanism so what he hears is attributable; D combines all six. Pitch design is held
      constant everywhere — aeolian, +2 root, "[24,36]" double — because the key was
      measured at corr=0.903 and is not what he is unhappy with.
      
      All four verified through silent-eval --seeded: they compile and every one emits
      events cold. Measured onsets per bar: B 3.0, A 4.0, C 4.5, D 5.75, against ~5.5 for
      what desire has today. D is deliberately DENSER than the current line and still
      reads as a riff — which is the control that shows the problem was never "too many
      notes", it was where they fall and how long they last.
      
      desire.tidal is untouched: it is in his dirty working tree, and it also does not
      currently compile at all (#108).
      PLN (Algolia) authored
    • feat(slop): the clip renderer exists — first reel rendered from a ParVagues take (#28) · a9ef8cb8
      PLN has said repeatedly he is bad at socials and hoped slop would carry that weight.
      The plan for this has been complete since the clip planner landed; the renderer had
      never been written, so nothing could actually be produced. It can now.
      
      `visuals/slop/render_slop_clip.mjs` takes either a planned cut (`--idea desire --cut 90`,
      from clip_ideas.json with its measured tilt/kick-density/novelty and playset choice) or
      an arbitrary window of any file (`--audio ... --start ... --dur ...`), and renders a
      vertical 1080x1920 reel with Slopmotion reacting to that audio.
      
      DESIGN NOTES WORTH KEEPING
      
      - It lives in THIS repo, not hexa. hexa is Kevin's project and its main is clean; the
        script borrows its node_modules through createRequire instead of being committed
        into someone else's tree. Its scratch files go in hexa/public/__slop and are removed
        in a finally block — nothing is left behind in his working copy.
      - Capture is REALTIME via CDP screencast, not per-frame screenshots. Hydra's animation
        clock is wall time, so frame-stepping plays motion back ~3x fast and drifts away
        from the audio it is supposed to be reacting to. Screencast frames arrive timestamped
        and become a vfr->cfr concat, so a dropped frame is a slightly longer one — invisible
        — rather than a wrong clock.
      - The bands are computed from the REAL audio through an AnalyserNode, not from the
        synthetic pump the FX-preview script uses. Kick is a fast rise in the sub band above
        its own slow running average, because an absolute threshold would need retuning per
        track while a relative one rides the mix.
      - Backdrop clip choice is a hash of the job name, not Math.random: two renders of the
        same cut must be comparable, which is the entire point of a review pipeline.
      - The viewport is 9:16 natively rather than a 16:9 scene centre-cropped afterwards,
        which would throw away most of the motion.
      
      TWO ENVIRONMENT PROBLEMS FOUND AND FIXED WITHOUT COLLATERAL
      
      - The hexa checkout could not boot at all: 9 declared dependencies were missing from
        node_modules, so vite 500'd on @supabase/ssr and @vercel/speed-insights. `npm ci`
        refused (lock out of sync with package.json), and plain `npm install` would have
        rewritten package-lock.json — a TRACKED file in Kevin's repo. Installed the 9 with
        --no-save --no-package-lock instead; his tracked files are byte-identical after.
      - Playwright 1.60 wants chromium build 1223 and the cache has 1217. Rather than pulling
        ~150 MB into PLN's shared browser cache unasked, the script finds the newest full
        chromium already present. Full chromium, not chrome-headless-shell, which ships
        without the GPU/ANGLE stack this needs.
      
      WHAT IS NOT DONE: frame rate. 7.0 fps at 1080x1920 under swiftshader — the page itself
      renders at 7 fps, so it is the GL backend and the machine, not the capture. The two
      follow-up measurements (hardware EGL, and half resolution) both came back at 1.1 fps,
      which is not a verdict on either: load average was 12.5 with Pulsar burning a core from
      the #7 marker leak. They measure the machine. Recorded in FEEDBACK.md with an explicit
      warning to re-measure on a quiet box before concluding anything — a false workaround
      adopted from a contended benchmark is very hard to remove later.
      
      Deliberately not pushed further tonight: a sustained software-GL render is a CPU load
      test, and those do not run at night here while the rig is up.
      PLN (Algolia) authored
    • fix(silent-eval): the harness matched ghci — and found a setlist track that does not compile (#93) · 6d7df300
      Four of thirteen setlist tracks could not be built, so their orbits had never been
      verified cold. Two causes, both the same shape: THE HARNESS DIVERGED FROM THE BOOT
      ENVIRONMENT, which is what #93 predicted would keep producing new errors.
      
      1. Ambiguous `cutoff`. BootTidal defines `let cutoff = pF "cutoff"`. In ghci a let
         binding SHADOWS an import, quietly and legally. The harness dedents that block to
         module top level, where there is no shadowing — GHC reports "Ambiguous occurrence"
         and the track becomes unverifiable for a difference that does not exist on the rig.
         Fixed by hiding every name the generated module defines from the Context import,
         with the hide-list DERIVED from the generated text rather than listed, so it cannot
         drift when BootTidal gains a helper. 235 names on the current boot.
      
      2. Overlapping IsString instances on a chord literal
         ("<gb3'maj db3'maj bb2'min bb2'maj>" could be ParseBP's `Pattern a` or Simple's
         `ControlPattern`). ExtendedDefaultRules and NoMonomorphismRestriction are ON BY
         DEFAULT IN GHCI, which is where these patterns actually run. Turning them on
         matched the environment; annotating each literal would have treated the symptom
         one track at a time forever.
      
      === And then the third failure turned out not to be a harness limit at all
      
      `live/collab/raph/desire.tidal` line 46 reads `# pan 0.42plz /se`. It is committed,
      not a stray working-copy edit. `plz` is not a Tidal function and the file has NO
      blank lines, so it is one single do-block: that one typo means every orbit of desire
      is dead on ctrl+enter. It is on the OPAL setlist, six days out.
      
      The tool had been reporting it as "BUILD FAILED — harness limit, NOT a track verdict".
      That message was written to protect the music from the tool, and it was right twice
      and catastrophically wrong the third time: it told the reader, in bold, to ignore a
      broken setlist track. A parser miss must never masquerade as a data conflict, and the
      inverse is worse.
      
      So the verdict is now computed, not asserted. Errors whose line numbers fall inside
      the track's own generated lines get a distinct BROKEN verdict — "THE TRACK DOES NOT
      COMPILE — it will fail live too" — and count as a gig blocker. Errors in the boot
      helpers or the scaffolding stay a harness limit. Line numbers are also mapped back to
      the real .tidal by matching the offending line's TEXT, because a generated-module line
      number is useless to whoever has to fix the file and looks authoritative while being
      so; unmatched or ambiguous lines are left alone rather than guessed at.
      
      Result: 12/13 build and every declared orbit emits events cold. The 13th is a real
      bug in the music, reported as one, with a file:line you can jump to.
      
      NOT FIXED HERE deliberately: desire.tidal is in PLN's dirty working tree and Pulsar
      saves the BUFFER, so a disk edit made behind his back can be silently reverted by his
      next ctrl+S. It is item 1 of MORNING.md instead, with the one-line fix, to be applied
      in the editor where it will actually stick.
      PLN (Algolia) authored
    • fix(lcxl-leds): the LED daemon was mute in the journal — twice over (#106) · f9b2c83a
      Take 94 came back with no track trail, and there was no way to tell from the logs
      whether the watcher had seen the track changes, failed to bind them, or simply
      never run. It had been running for 2h46m. It had emitted nothing but systemd's own
      start/stop lines the entire time.
      
      Two independent causes, and either alone is enough to blind the unit:
      
      1. Python BLOCK-buffers stdout when it is a pipe, and under systemd it always is.
         Even the lines that were being printed would have sat unflushed in a 4 KB
         buffer. Fixed with Environment=PYTHONUNBUFFERED=1 in the unit.
      
      2. `--watch -q` suppressed the track-change line itself, so there was nothing to
         flush. That is the wrong thing to make quiet. `quiet` should mean "no per-CC
         chatter" — thousands of lines an hour — not "never say what you are painting",
         which is a handful of lines an hour and the entire purpose of the daemon. The
         track-change log is now unconditional.
      
      Verified live rather than by reading: restarted the unit, wrote a different track
      to ~/.cache/parvagues/current-track, restored the original, and read the journal
      back. Both transitions appear with their binding counts (desire 28 controls,
      vague_de_crime 20), and a re-parse of the same file is labelled distinctly from a
      real track change — that distinction matters because an edit-and-save must keep
      the touched-state while a genuine track change must clear it.
      
      Same family as the stale-binding pattern this rig keeps producing: something
      resolved once, invalidated by an event, with no error anywhere. Here the error
      reporting itself was the thing that had been silently resolved to /dev/null.
      PLN (Algolia) authored
    • feat(take-lens): pack a take so it travels with its performance — and fix the trim PLN caught · cc9cbf48
      Two things in one file, both from the same evening's practice recording.
      
      === PART 1: `pack` — the performance must travel with the audio (#106, #107)
      
      "ensure recordings have the midi too, keep this from blowing up tracking either
      ardour rec or compressing to avoid storing hours of no-move?"
      
      Today the audio is permanent and the performance is not. Ardour's sources live
      forever under the session; the MIDI and the track timeline sit in a gig-log
      directory that prune() deletes on a timer. So the 29862 CC events that explain
      take 94 were on a countdown while the audio they explain was not. `pack` writes a
      sidecar next to the audio: meta.json (window, sr, subtype, per-orbit peak dBFS,
      silent orbits, xrun delta, gear + boot state), midi.jsonl (every cc/note/track/mark
      record in the window, copied verbatim out of the log so pruning cannot reach it),
      and edl.json.
      
      The sidecar states its own honest limit in a field: gig-log COALESCES CC per
      (port, channel, controller) per second, keeping count + first/last/min/max. That is
      a faithful summary of a knob sweep and is what makes the log cheap, but a .mid
      rendered from it would be a reconstruction, not a recording. The JSONL is the truth.
      
      `--compress` then wavpacks every source and verifies each one with `wvunpack -vm`
      before reporting a single byte saved — an archiver that reports a ratio without
      checking is reporting a hope. The .wav is NEVER deleted; removal stays a separate
      human step after the take has been heard. FLAC was rejected outright: it cannot
      store 32-bit float, and routing through s32 would hard-clip the 8 orbits sitting
      over 0 dBFS while looking like a win on the silent one.
      
      Two measurement bugs found by running it on real data rather than trusting it:
       - xrun is a RUNNING TOTAL in gig-log's `s` records, not a per-tick delta (its own
         CUMULATIVE tuple exists for exactly this mistake). Summing it reported 58,668,469
         xruns for a 21-minute take. Rebased to last-minus-first: 230, a real number.
       - Records are not events. A `cc` record is a one-second bucket carrying `n`, so
         printing the record count understated the performance 25x (1167 vs 29862).
      
      === PART 2: the trim threshold was protecting every bad transition
      
      PLN heard the first machine-trimmed mix and found the hole: "silence around 18 is
      not normal its a bad transition ahah in a real mixing work wed cut it proper and
      fade or at least trim most". The gap he heard WAS detected and reported, and
      survived anyway. SILENT_RUN_S was 20 s; the three gaps that made it into the mix
      measured 17 s, 18 s and 11 s. Every one sat just under the bar. A threshold chosen
      so the trimmer could never eat a musical break had silently become a threshold that
      preserves every failed transition instead.
      
      Three changes, all of them his rule rather than a tuning:
      
      1. CLAMP, don't binary-cut. The bar drops to 10 s, but a detected silence is no
         longer removed whole — the last 2 s before the music resumes is kept as a
         breath. That is "or at least trim most" implemented literally, and it makes the
         lower threshold safe: the worst case if the rule fires on something musical is
         now a shortened rest, not a missing one. classify() computes both the detected
         region and the removed region so the report and the mixer cannot disagree.
      
      2. FADE every seam. Every cut this tool had ever made was a butt-join, which in a
         live room is a click. Each removal now fades out into the cut and back in out of
         it over 250 ms, applied sample-accurately — the old mixer decided keep/drop per
         one-second block by its midpoint, quantising every boundary to +-0.5 s.
         Verified on the re-rendered take: max sample-to-sample step at the five seams is
         0.0001-0.0029 against a p99.99 of 0.0562 for the take as a whole, i.e. the joins
         are 20-100x below ordinary musical transients.
      
      3. BRIDGE blips. A dead patch is rarely clean end to end, and one orbit's tail
         crossing -60 dBFS for a second splits it into pieces that each fall under the
         bar. Only single-orbit blips are bridged: a real hit lights several orbits, and
         that is what keeps this from swallowing a one-shot.
      
      Result on take 94: 143 s trimmed instead of 119 s, all five gaps closed including
      the one at +18.0 min he named, output 18.75 min, peak -3.36 dBFS.
      
      === `selftest`
      
      Added because two of these rules could not be validated on real material — the
      bridge fires zero times on take 94 and a correct fade is inaudible by construction.
      A rule you have never seen fire is a hope, not a rule. It found a bug on the first
      run, in the test's own arithmetic: asserting "no span survives a multi-orbit hit"
      was wrong because the left half is exactly SILENT_RUN_S long and qualifies by
      itself. The property that actually matters is that no trimmed span ever CONTAINS a
      moment when the music was playing, and that is what it now asserts.
      
      Correction to a verification, worth recording: the first check of the re-rendered
      mix reported an 11 s silence still present. It did not exist. I measured the output
      against -60 dBFS while the mix carries a uniform -16 dB, so I was testing at an
      effective -44 dBFS at source. At the matched threshold exactly one rest over 4 s
      remains, 9.25 s, correctly left alone as musical. The right lens per control, again.
      PLN (Algolia) authored
    • docs(notes): PLN's ear on the first machine-trimmed take — "cut it proper and fade" · b5061e23
      First listen to the auto-trimmed take-94 mix. The verdict on the sum itself is good
      ("great mix itself its clean") which clears the record-bus routing of suspicion: 12
      orbits summed with a flat -16 dB keeps the balance he actually performed.
      
      The correction is the valuable half. He flagged the silence around 18 min as "not
      normal, a bad transition — in a real mixing work wed cut it proper and fade or at
      least trim most". That silence WAS detected and reported by take-lens, and survived
      anyway: SILENT_RUN_S was 20 s and all three surviving gaps measured 17 s, 18 s and
      11 s. A threshold chosen so the trimmer could never eat a musical break had become a
      threshold that preserves every bad transition instead.
      
      Two rules extracted, both more general than this take: clamp long silences to a short
      breath rather than choosing between keeping all of it and removing all of it; and fade
      every cut seam, because every removal this tool has made so far was a butt-join.
      
      Also logged: the stretches he heard as "stuck in loops" are precisely the `loop` kind
      the classifier already flags and deliberately does not auto-cut.
      PLN (Algolia) authored
    • fix(take-lens): detection resolution is not a display preference — plus mix · 2ddcae51
      Added `mix` (sum the orbits to one gain-corrected, trimmed stereo file), then found
      two real bugs in the trim rule by verifying my own render instead of trusting it.
      
      BUG 1 — the rule demanded idle hands AND silence
      
      audio_lens profile on the rendered mix reported rms -180 dBFS at 19.2 min: DIGITAL
      silence, sitting in the middle of a "trimmed" master. The first rule only cut where
      the hands were idle AND nothing sounded, and there PLN was busy on the controller
      with nothing playing. That third state — fiddling in silence — IS the practice/
      plugging-in the trim exists to remove. Silence needs no corroboration from the hands,
      so it is now its own kind with its own (much shorter) 20s threshold: long enough to
      never eat a break or a drop's pre-silence (~4 bars, under 8s at these tempos), short
      enough to catch the real thing.
      
      BUG 2 — and the reason it was invisible: analysis ran at DISPLAY resolution
      
      A direct 1-second measurement of the same mix found 103 seconds of silence in five
      runs of 11-31s. The classifier, bucketing at 15s, found NONE of them: a 17-second
      silence straddling two 15-second buckets leaves no bucket fully silent, so every
      bucket's rms sat far above -60 dBFS. The threshold was fine; the grain was lying.
      
      Analysis now always runs at GRAIN_S = 1s and `--bucket` only sets the sparkline
      width. Detection resolution must never be a display preference — the coarse view is
      for eyes, the decision is for data.
      
      STRUCTURAL: one rule, one reader
      `classify()` is now the single source of the cut rule and `analyse()` the single
      audio pass, both shared by the report and the mixer. They were duplicated, which is
      the worst possible arrangement here: a mixer that cuts what the report called `keep`
      destroys material silently. Also dedup candidates by 80% OVERLAP rather than strict
      containment — the hands-idle run began 1s before the silence run on take 94, so
      containment reported the same stretch twice and the table contradicted itself.
      
      VALIDATED end to end on take 94 (21.14 min, 12 orbits)
        candidates 5, no contradictions: 3x silence (21s, 30s, 68s) TRIM,
        2x playing (68s at 8.00 and 3.72 orbits avg) KEEP.
        Trimming only `silence`+`dead` -> 19.15 min out, peak -3.36 dBFS, rms -24.47.
        Re-measured the render: silence 103s -> 52s. The three survivors are 17s, 18s and
        11s — all under the 20s threshold BY DESIGN, plausibly musical gaps that want an
        ear rather than a rule. Spectral balance broadband at 60s/300s/600s/900s via
        audio_lens profile (the opening 20s reads kick/sub-only because it genuinely is).
      
      `loop` is deliberately NOT cuttable: a droning pad under idle hands can be an intro
      as easily as a distraction. It proposes; the ear decides.
      PLN (Algolia) authored
    • feat(tools): take-lens — read a recorded take through the gig log, and find the trims · 2e6e4e9f
      PLN asked for "a master split per track loaded time, cleanup parts obviously
      'practice or plugging in' that are scarce VS intro/outro (can we detect this? did we
      record play metadata as the ardour records?)".
      
      Three datasets existed and had never been joined:
        - Ardour names sources `Take<N>_Tidal <orbit>-<seq>%<L|R>.wav`, so the take number
          plus the frame count plus the mtime give an exact wall-clock window with no
          session-XML parsing at all.
        - gig-log has coalesced MIDI CC at 1 Hz: 29862 events over this take, on 46
          distinct controllers. Hands-idle is the clearest signature of "plugging in".
        - per-orbit RMS/peak says what the SOUND did.
      
      The third one is not optional, and that is the design insight. Idle hands ALONE is
      ambiguous: you can play a part for three minutes without touching a knob. So a
      candidate is classified by what the audio was doing underneath it — `dead` (nothing
      above -60 dBFS: safe to cut), `loop` (a couple of orbits droning, hands off:
      probably plugging in), `playing` (most orbits going: a real part, KEEP). On take 94
      that distinction earned its keep immediately: of three hands-idle runs, two were
      real parts at 8 and 4 orbits average, and only the 75s tail was actually dead.
      
      FINDINGS ON TAKE 94 (21.1 min, 20:08:33 -> 20:29:41, 12 orbits)
        - 8 of 12 orbits peak OVER 0 dBFS, worst d4 at +9.63 dB. The files are 32-bit
          FLOAT, so this is headroom and not damage — a gain cut recovers it exactly, and
          it only becomes real clipping on integer export or at the master bus. Labelling
          it "CLIPPING" would have been a false alarm sending him to re-record material
          that is completely intact, so the tool now reads the subtype and says which it
          is. A uniform -16 dB on the record bus lands the hottest orbit on the -6 dBFS
          source target.
        - d12 silent for the whole take (-78 dBFS).
        - no track timeline: nothing published the loaded track while it recorded. The
          tool says so plainly instead of guessing, because a confident wrong boundary is
          worse than none — the existing Bandcamp splits carry crossfade bleed for exactly
          that reason. Takes from d5baca32 onward split exactly.
      
      A MEASUREMENT TRAP, RECORDED IN THE CODE
      The first pass estimated duration as size/(sr*3), assuming 24-bit. These files are
      32-bit float. That put take 94 at 28.2 min starting 20:01:30 instead of 21.1 min
      starting 20:08:33 — and manufactured a "6m15s dead intro" that never existed, purely
      from a 7-minute window shift. The fallback now assumes 4 bytes and, more
      importantly, exact lengths come from the frame count and estimated ones are labelled
      "do not cut from it".
      
      Also: logs_covering() checks EVERY log's [first,last] range rather than assuming the
      newest one holds the window, because a take can straddle a recorder restart — which
      is exactly what happened today.
      
      It proposes, it does not cut: output is a report plus an EDL JSON for
      armada/tide-table/edl_render.py. Trims are a taste call and the ear is the gate.
      PLN (Algolia) authored
    • feat(gig-log): log WHICH TRACK is loaded — the key that decodes the whole MIDI stream · d5baca32
      PLN recorded an afternoon of OPAL takes, then asked for "a master split per track
      loaded time". The answer should have been trivial. It was not: nothing in the rig
      recorded WHEN each track was loaded.
      
      The gig log had 78222 samples of thermals, xruns, gear and 7429 coalesced MIDI CC
      events for today alone — and no way to say what any of it was. A CC 41 is
      meaningless on its own, because the LCXL map is PER TRACK: on one track index 41 is
      a crush bus, on another it is a break gate. The performance data was all there and
      none of it was resolvable.
      
      Meanwhile the exact ground truth was sitting in a 33-byte file the whole time.
      Pulsar has published `~/.cache/parvagues/current-track` since #84 (2576e77) for the
      LED watcher to follow. Nobody was reading it into the log.
      
      WHY THIS MATTERS MORE THAN THE SPLIT IT WAS ASKED FOR
      
      Splitting a livecoded set by ear is genuinely hard, and we have the scars: orbit
      roles stay stable across tracks so level/presence detection fails, and every dN is a
      4-cycle crossfade that bleeds the incoming track into the outgoing one's tail — the
      existing Bandcamp splits carry that bleed. A `k:"track"` line replaces all of that
      inference with a timestamp. Boundaries stop being clever and become read.
      
      And it retro-actively upgrades the CC stream from telemetry to an annotated
      performance score: track + CC + time resolves, through the per-track LCXL map we
      already generate, to "d4's DJ filter opened at 18:42:13". That is the input for
      telling practice apart from performance (hands-off vs hands-on density), for finding
      the moment worth clipping, and eventually for the emotion/timeline bundle.
      
      IMPLEMENTATION — deliberately the boring version
      
      - `_read_track()` never raises: absent file (publisher not up yet) or a short read
        both degrade to None. A missing track must degrade the log, never stop it.
      - Written ONLY on change, so a 3-hour session costs a handful of lines instead of
        10800 repeats of one string. The header carries the initial value, so a session
        that never switches still has one authoritative statement of what was loaded.
      - Polled, not watched — for the same reason the LED watcher polls. One stat+read of
        33 bytes per second is unmeasurable; an inotify watch adds a dependency and a
        whole class of "the watch silently died", which is the exact failure mode this
        data exists to prevent.
      - `load()` folds track records into `marks` so they render in the existing timeline
        for free, keeping `k:"track"` so a splitter can tell an automatic boundary from a
        human annotation.
      
      VALIDATED
      - `selftest` PASS; sampler + both readers still 0.66% of one core over 6s, well
        under the 3% observer budget (the observer must not perturb — we already learned
        that lesson when probe-chain's own capture streams caused the xruns).
      - END-TO-END, not just unit: ran the real recorder against a temp track file, moved
        it twice, and read the log back. Header carried the initial track; both changes
        appeared exactly once with their `from`, each within the 1s poll. Green tests on a
        pure function would not have proven the value reaches the file.
      PLN (Algolia) authored
    • docs(board): archive the OPAL rig-hardening tasks resolved 2026-07-28/29 · 466df85d
      #46 #90 #94 #97 #100 #84 #35 off the active board, each with the non-obvious part
      kept: the 32 role inversions phase 2 shipped and how they were repaired, why the FX
      bus rule is PV011 and not PV010, and the two independent highlight bugs (a class name
      that never matched, and an outline fix written in the one stylesheet copy that could
      not unset the other's border). Narrative in log 019.
      PLN (Algolia) authored
    • docs(log): 019 — the ghost that cut the new track dead, and 32 buttons only an ear could catch · 537c971a
      Captain's-log entry for the 2026-07-29 session, written while it is fresh (the
      armada/tasks discipline: these logs are the raw material for posts and video
      narration, not bureaucracy).
      
      Two findings, one class: a machine check stayed green while the instrument was
      broken. One was Tidal's (xfadeIn turns the outgoing pattern down but never off, and
      a gain-0 event still broadcasts its cut group). One was mine, shipped the previous
      evening (the button remap assigned cells in reading order, inverting 32 gate/gesture
      pairs that only PLN's ear caught).
      
      The shareable core, for later: a gain of zero is not the absence of an event. In a
      system where events carry side effects — voice stealing, global FX bus writes — the
      silent ones still act. Plus the green-check problem twice in one day, convergence as
      a bug report, and the honest limit of verdict-equality checking.
      PLN (Algolia) authored
    • fix(boot): the "fade into oblivion" was the PREVIOUS pattern cutting the new one dead · be52627d
      The bug that ate PLN's whole afternoon, and the reason he was working around it by
      hand: "so for now hush then ctrl+enter seems like a fix ahah but not a lovely one :D".
      
      ## The symptom, and the clue that cracked it
      
        "wait i feel theres still a xfade issue? when i leave d5 on the 33 effcet, it
         fades in oblivion!"                                    (and d4, d7, d8, d12...)
        "reboot-then-ctrl-enter: doesnt seem to obliviate, loops forever. sounds like a
         track-to-track state bug"
      
      Same code, two outcomes, depending on history. That is the whole diagnosis in one
      sentence — and `hush` curing it is the confirmation, because `streamHush`
      PREPENDS silence to the pattern history.
      
      ## The mechanism
      
      `BootTidal.hs:100` made every dN a 4-cycle crossfade (#13):
      
          xfade i = transition tidal True (Sound.Tidal.Transition.xfadeIn 4) i
          d5 = xfade 5 . (|< orbit 4)
      
      Tidal's xfadeIn is one line:
      
          xfadeIn t now (pat:pat':_) = overlay (pat |* gain rising) (pat' |* gain falling)
      
      The envelopes are correct — measured with queryArc, the incoming rises 0.442 ->
      1.0 and the outgoing falls 0.990 -> 0.0 across the 4 cycles, then both saturate.
      The bug is that the outgoing pattern is turned DOWN but never OFF. `|*` takes
      structure from the LEFT, so `pat'` keeps every event it ever had. Measured: at
      cycle 64 of a 4-cycle fade, the outgoing pattern still emits one onset per cycle.
      
      **And a gain-0 event is not a harmless event.** SuperDirt broadcasts the cut group
      in `playSynths` (DirtEvent.sc:182-189) *before* amplitude is ever read — `~amp`
      only appears later, at line 160, as an argument to the gate synth — and for a
      positive `cut` it sets `cutAllSamples: 1`, releasing EVERY voice in the group
      regardless of sample. So the silent ghost keeps killing the incoming pattern's
      voice on every single hit, forever. The orbit is not fading out. It is being cut
      to death by its own predecessor.
      
      Every observation follows, including the ones that looked contradictory:
      
        * only orbits that own a `# cut` die. In take_5_drops, where PLN said "weirdly
          all d123 stay", d1/d2/d3 have NO cut group — and the ones he reported dying in
          vague_de_crime (d4, d5, d7) have cut 4, cut 5, cut 7.
        * a fresh boot is fine: one pattern in history, and `xfadeIn _ _ (pat:[]) = pat`
          returns it untouched, envelopes and all.
        * "4 bars" is the fade length. You hear the OLD pattern fade out — it wins the
          cut, being overlaid second and therefore sent last — and the new one never
          arrives at all.
        * d8's "weird glitches instead of proper heading breaks": cut 8, a chopped break
          fighting its own ghost.
      
      Not explained by this, and still open: take_5_drops' d4, which has no cut group at
      all. That fade is a separate lead (it shares `crushbus 41` with d7 — see the new
      pvlint PV011).
      
      ## The fix
      
      Keep Tidal's envelopes EXACTLY, and stop the outgoing pattern when its fade ends:
      
          xfadeCutIn t now (q:q':_) =
            overlay (q |* gain rising) (playFor now (now + t) q' |* gain falling)
      
      Measured, onsets per cycle (incoming/outgoing), fading at cycle 0 over 4:
      
          xfadeIn 4    1/1 1/1 1/1 1/1 | 1/1 1/1 1/1 1/1    ghost forever
          clutchIn 4   0/1 0/1 1/0 1/0 | 1/0 1/0 1/0 1/0    clean, never overlaps
          xfadeCut 4   1/1 1/1 1/1 1/1 | 1/0 1/0 1/0 1/0    clean from cycle 4
      
      `clutchIn` is also clean — it degrades one pattern into the other so they never
      overlap at all — and is arguably the better transition for a rig where most orbits
      own a cut group. It is NOT the default here because it changes the feel of every
      transition on the rig, and 7 days before a gig is not when to do that. It stays
      available as `clutchIn`, and Tidal's original stays reachable as `xfadeLeaky` for
      A/B comparison.
      
      Audibly, for a cut orbit, the transition becomes: the old pattern fades out over 4
      bars, the new one arrives at full exactly as its envelope reaches 1.0. Which is
      what PLN already describes hearing — except that now the new one arrives.
      
      ## Validation
      
      - The binding was lifted VERBATIM out of BootTidal.hs and typechecked against the
        type `transition` demands (`Time -> [ControlPattern] -> ControlPattern`). The
        stream block cannot be typechecked in place — it closes over a live `Stream` —
        so this is how that part of the file gets checked at all.
      - `tools/check-boot.sh` gains **Pass 5**, a permanent gate: it lifts `xfadeCutIn`
        out of the boot file and counts onsets from the outgoing pattern at cycles
        4/5/8/16/64/256. Must be 0.
      - Mutation-tested, which is the only reason to trust it: with `playFor` removed
        the gate reports 6 ghost onsets and fails. Passes 1-4 all stay green on the
        broken version — it typechecks, it parses as Pulsar sends it, and it emits
        events. Counting the outgoing onsets is the only check that can see this class
        of bug, which is exactly why it now runs on every boot check.
      - All 32 blocks still parse as single statements (#79 seam intact) — the new
        multi-line `case` sits inside the existing `let`, so it is still one statement.
      PLN (Algolia) authored
    • fix(corpus): role-align the 5 tracks PLN was playing — carries his in-flight edits too · 6ed637de
      These five could not go in the previous commit because they hold PLN's own
      uncommitted work from this afternoon's recording session, mixed in with the role
      alignment. Committing them anyway, because durability beats a tidy diff: once they
      are in git, a stale Pulsar buffer overwriting one of them shows up as a diff
      instead of vanishing silently (#95, the buffer-clobber trap).
      
      ## What is HIS in this commit
      
      - **bombe_dj**: he resolved the d1 button overflow BY HAND, exactly the way #54
        proposes. The three lines phase 2 had commented out with a FIXME are back, folded
        onto d1's own controls: the sample swap moved to a KNOB (`midiOn "^29" (# "kick:4")`)
        and the kick variants merged onto `^41`. That is a manual gSel, and it is better
        than what the FIXME asked for.
      - **the_revolution_will_be_sampled**: he fixed the d8 inversion himself, restoring
        `midiOn "^92" (ply ...)` / `midiOff "^60" (mask ...)` before any tool got there.
      - **vague_de_crime**: a `# cps (130/60/4)` gesture parked as a comment and another
        moved onto ^30; d1's commented overflow restored onto ^41.
      - **do_it_right**: a `dr` typo removed, a d5 gain nudged 1.3 -> 1.4, a `-- TODO:
        Move to d6?` note, and an extra d5 ply gesture.
      - **take_5_drops**: the `31` -> `21` typo in d12's `n` pattern, one `>|` -> `|>` on
        d5, gains adjusted.
      
      ## What is MINE
      
      The button-role alignment only — `"^NN"` token moves, per the two commits above.
      
      ## One thing worth recording about the validation
      
      bombe_dj's d7 exposed a limit of verdict-equality checking. The swap rewrote
      `midiOn ("^59" - "^91")` to `midiOn ("^91" - "^59")`, and subtraction is not
      commutative — that LOOKS like an inverted gesture. It is not: all three references
      in the block flipped together (the two operands and the outer/inner `midiOn` pair),
      so it is a consistent relabeling and the behaviour is identical with the two
      physical buttons exchanged.
      
      But silent-eval could not have told me either way. Both CCs seed to 0, so
      `0 - 0 = 0` whichever order they are in, and the default branch is untouched by
      construction. **Verdict equality at the seed proves nothing about behaviour under a
      knob press.** It is the right gate for "did this rewrite silence anything at boot"
      and the wrong gate for "does this control still do what it did". The second
      question needs pv-at (#74), or an ear.
      PLN (Algolia) authored
    • fix(corpus): 81 button cells move onto the row their ROLE belongs to · 2c178c96
      Applies the convention from the previous commit across the corpus: gates onto the
      latching row, gestures onto the momentary one. 34 files here contain nothing but
      `"^NN"` token changes — verified mechanically by normalising every changed line and
      checking the -/+ pair is identical once the CC is masked.
      
      ## What moved
      
        52  corpus-wide repairs (tools/fix-button-roles.py, three converging passes)
        29  setlist permutations (tools/migrate-columns.py --apply)
      
      The two tools do different jobs and both are needed. fix-button-roles only MOVES a
      control to a free cell, so it declines a case where the target is occupied.
      migrate-columns plans a whole column at once, so it can SWAP — which is what the
      remaining cases needed: d5's `ply` and d5's take-chooser both wanted a cell, and
      exchanging them puts the held flourish on the momentary button while the chooser
      (a state: which take plays) takes the latching one. That reading was not designed,
      it fell out of role-first assignment with a positional fallback, and it is right.
      
      ## Proof it changed no sound
      
      `tools/silent-eval.py --seeded` before and after, twice (after the corpus pass and
      again after the permutation): **byte-identical verdicts** both times, including the
      same 3 harness build failures (#93). Every button CC seeds to 0 in BootTidal's
      `_seed`, so permuting CCs *between two calls in the same block* cannot change
      either call's default branch — `midiOn` stays never-applied and `midiOff` stays
      always-applied. The edit is event-neutral at boot by construction; the harness run
      confirms the construction.
      
      `tools/surface-columns.py` still reports 83/83 orbits column-aligned with 0
      renumbers needed — the swaps stay inside each orbit's own column, so the property
      phase 2 established is preserved.
      
      ## Still open, deliberately
      
      12 sites where ONE orbit drives two gestures. Only one momentary cell exists per
      column, so the second has to sit on the latching row. Reported as PV010 `info` and
      not repaired: the real fix is gSel (#54), folding a pair of gestures onto one
      control. Naming it beats hiding it.
      PLN (Algolia) authored
    • fix(surface): a button's ROLE decides its row — the remap had been assigning cells in reading order · 5089eec0
      PLN found this by ear, twice in ten minutes, while recording OPAL takes:
      
        "techno drum mask is inverted on d8, should ALWAYS be the mask midiOff on ^60
         and the ply or other multiplier/nassim button effect on the push-release 92"
        "drums in revolution seem way too fast ... yea def an inversion"
      
      ## The bug I shipped
      
      Phase 2 of the surface remap (5910aacc) column-aligned every button reference, and
      assigned each orbit's two cells in FIRST-APPEARANCE ORDER. That is role-blind, and
      tracks conventionally write the gesture line above the gate line:
      
          $ midiOn  "^92" (ply "1 <2!3 4>")      -- momentary flourish
          $ midiOff "^60" (mask "t(4,8,1)")      -- latched gate
      
      so the first control encountered — the gesture — took the LATCHING cell, and the
      gate took the momentary one. Measured on the diff itself rather than on corpus
      history: of the button lines that commit moved, **32 were inverted**, 11 happened
      to be repaired, 10 were already wrong, 2 were right both times. 13 files, nearly
      all of them in the OPAL setlist.
      
      "Way too fast" is the audible signature: a `ply` on a latching button stays
      multiplied after one press. The mirror image is a `mask` that only gates while a
      finger holds it down. Neither errors. Neither is silent. Only the ear catches it —
      which is precisely the class of bug that needs a machine check.
      
      ## The convention, now written down once
      
          row E (41-44, 57-60), latching   -> GATES    (mask, struct)
          row F (73-76, 89-92), momentary  -> GESTURES (ply, fast, stut, chop, slice...)
      
      A real limit of the grid, found while encoding it: **d1-d3 own only ONE button.**
      Their row-F cells (73,74,75) are the per-family mutes gMute1/2/3, so a gesture on
      ^41/^42/^43 is not an inversion — it is the only cell that exists. Every tool here
      skips those columns rather than inventing a slot.
      
      ## tools/button_roles.py — one judgement, three consumers
      
      migrate-columns.py ASSIGNS cells, fix-button-roles.py REPAIRS them, pvlint PV010
      REPORTS them. Three copies of a musical rule is three chances to drift (the #97
      lesson, applied before it could bite). Two parsing rules were earned in the space
      of one afternoon, and both were wrong in my first cut:
      
      1. **A gate beats a gesture at the SAME level.** `mask "t(8,16,1)" . chop 16` is a
         latched break-gate that happens to chop while open. Treating it as ambiguous is
         what hid revolution's d8 pair from the first scan — the very pair PLN heard.
      
      2. **But only at the same level.** `superimpose (struct "t . t(3,8)" . arpeggiate
         . (|+ note 12))` is a HELD FLOURISH; the `struct` builds the added layer's
         rhythm two levels down and says nothing about the button. A plain substring
         search called phunk's d6 a gate and moved it to the latching row — the same
         inversion, one level deeper. So the role belongs to the top-level chain, with
         nested arguments stripped and string literals skipped (`mask "t(4,8,1)"` would
         otherwise look like it opened two parens and swallowed the rest).
      
      3. **The body is not the rest of the line.** `midiOn "^91" ( -- SLICE!` puts the
         function on the FOLLOWING lines. A same-line regex returns "unknown", and an
         unknown control gets a positional fallback — re-creating the inversion. The
         classifier now follows the parentheses, capped at 8 lines.
      
      ## Validation
      
      - 214 tests pass (24 new: 14 for the classifier, 10 for PV010/PV011).
      - The classifier is CONVERGENT: applying the repair twice yields 0 further
        rewrites. That is what caught mistakes 2 and 3 — each fix made the tool disagree
        with its own previous pass, and the disagreement was the bug report.
      - pvlint on the setlist: 0 errors, and PV010's 12 unfixable cases are reported as
        `info`, not warnings, because an orbit with two gestures genuinely cannot put
        both on the momentary row. A lint that nags about the impossible gets ignored,
        and this one has to stay trustworthy enough to gate a gig.
      
      ## PV011 — one FX bus slot shared by two orbits
      
      PLN's ask, right after PV004 surprised him with a shared cut group: "putain a
      unexpected cut shared, well done! like shared busses they screw things. can we
      lint and flag these? diff bus shared button is ok, but to be detected too". A
      `<fx>bus N` is a SLOT in SuperDirt, so two orbits naming the same number share one
      instance and the last event to land sets the amount for both. It immediately found
      take_5_drops putting d4's bass and d7's choir on `crushbus 41` — the standing
      suspect for "d7 feels way more attacky than before". Different effects on the same
      number are fine and are not flagged: the namespace is per effect, exactly as he
      said.
      PLN (Algolia) authored
    • feat(pvlint): PV009 — gain MULTIPLIED by a control that seeds to zero, i.e. a… · 2cd4108a
      feat(pvlint): PV009 — gain MULTIPLIED by a control that seeds to zero, i.e. a layer that never arrives
      
      PLN, mid-run, working it out himself: "d9 on wap didnt take effects on 17 and 19?
      ok no its the 17 superimpose that melts too !! does our xfade screw the
      superimposes? 💡"
      
      The xfade is innocent. wap.tidal:91 is
      
          $ superimpose ( (# "acidOto3091") . (|- note 12) . (|* gain "^17") . (# cut 91) )
      
      and ^17 seeds to 0 under BootTidal's policy ("A/B knobs -> 0, effect amount off").
      Gain MULTIPLIED by 0 is silence, so the superimposed layer is not quiet — it does
      not exist. The 4-cycle dN crossfade then eases from the previously-audible state
      into that silence, which is what reads as a melt and is exactly why the xfade
      looked guilty. It was the messenger.
      
      The distinction the rule encodes: `#` ASSIGNS and survives a 0 seed
      (`# crushbus 51 (range 16 4.5 "^53")` at seed 0 is simply crush 0), while `|*`
      and `|/` ANNIHILATE. Only the multiplicative form can delete a pattern, which is
      why flagging `#` sites would bury the real finding under hundreds of false ones.
      
      SEVERITY IS `info`, DELIBERATELY. This idiom is usually correct — it is a bring-in
      fader for a layer, and PLN uses it that way. The rule does not exist to call it
      wrong; it exists so nobody hunts it BY EAR. Making it a warning would fire on a
      normal, intended idiom and get the whole gate ignored, the mistake already made
      once in check-drift's first draft. It shows under `pvlint --info`.
      
      WHY IT MATTERS MOST RIGHT NOW: a remap moves the CC, but it does not move the
      KNOB. Physical positions survive across a surface change, so a knob left at 0
      silently acquires a new victim. That is the single root cause behind everything
      PLN heard this session — d9's layer gone on wap, and the same class of staleness
      behind the d4/d5/d8 symptoms (latched buttons whose orbit changed under them). It
      is the identical failure to the very first question he asked today: Ardour's fader
      raised by mouse while the physical fader sat at 0. There are no motors, so the
      surface never follows the map.
      
      SCOPE, measured (both forms — `|* p (range 0 hi "^NN")` and the bare
      `|* p "^NN"`, which my first scan missed and which is the worse of the two):
          33 sites across 25 files corpus-wide
           2 in the OPAL setlist:  wap.tidal:91 (bare), desire.tidal:75 (range 0 1.5)
      Notable elsewhere: scratchomatic multiplies gain by "^84" — an ARDOUR-learned
      fader — and computer_riddim/computer_riddub/anniv multiply by "^50", the gF2 DJ
      filter knob.
      
      6 new tests. The negatives carry the weight, as always: `range 1 1.5` (the
      prescribed neutral-low-end fix) must NOT flag, `#` assignment must not flag, and
      C1-C3 must not flag because they seed to 0.5 (centre), not 0. 496 pass.
      PLN (Algolia) authored
    • refactor(surface): ONE authored grid — five hardcoded copies become derived or generated (#97) · 2778f1cd
      PLN named this himself while testing the remap: "i see the hud shows d9 on A8 so
      didnt we migrate parvagues HUD to new convention? why 2 sources of truth tbh"
      
      It was five. The CC -> physical-control -> owning-orbit mapping was hardcoded
      independently in:
      
          tools/surface-columns.py     GRID
          tools/migrate-columns.py     KNOB_A/B/C, BUTTONS, ARDOUR, label(), and the
                                       destination ARITHMETIC (28 + orbit, 48 + orbit)
          tools/lcxl-leds.py           12 + (orbit - 8) for the d9-d12 level knobs
          tools/pvlint/rules.py        PV008's BT_CCS / BL_CCS / FAMILY_CCS
          <hud>/lib/render.js          ORBIT_CONVENTION
      
      After the 2026-07-29 remap the Python copies moved and the HUD's did not, so the
      topbar drew d9 on A8 while the LED board and the .tidal files both said A1. The
      display contradicted the hardware under his hands, mid-test, and NOTHING FAILED —
      which is the property that guaranteed it would happen again on the next remap.
      
      NOW
      tools/lcxl_grid.py is the only place the grid is written: the row/column table,
      the role of every cell (level / fx / fx2 / gate / gate2 / family_filter /
      family_mute), the Ardour-owned set, and gPanic. Everything else derives from it,
      and the two non-Python consumers read a GENERATED artifact:
      
          tools/lcxl_grid.json                        for anything outside Python
          <hud>/lib/lcxl-grid.generated.js            imported by render.js
      
      Same pattern as the fleet colour language (models.py -> gen_tokens -> tokens.css):
      author the ontology once in Python, generate for every other language.
      
      Two details worth keeping:
        * ROW ALIASES. The consumers had each invented their own names — D/fader,
          E/btn1/BT, F/btn2/BL. Forcing one vocabulary would have churned five files
          and PLN's own muscle memory for zero benefit, so every row carries all its
          names and each tool keeps printing what it always printed.
        * ROWS E AND F ARE ONE ROW OF EIGHT, not two of four. They are non-contiguous
          on the hardware (41-44 then 57-60) and modelling that as two rows is exactly
          what put d6's second button in column 5 in the old map. Asserted directly.
      
      THE TEST IS THE DELIVERABLE
      14 new tests. Half assert the authored table is coherent (48 controls, every row
      covers columns 1-8, every orbit has a level/fx/gate, no Tidal slot lands on an
      Ardour-learned control, d1-d3 own exactly one knob and one button because C1-3
      and F1-3 are the family controls). The other half assert every CONSUMER still
      agrees, and that regenerating the artifacts is a no-op — so a remap that forgets
      one copy fails the suite instead of shipping a lying topbar. One test simply
      checks render.js has not re-grown a literal ORBIT_CONVENTION.
      
      VALIDATION — behaviour must be bit-identical, this is a refactor
        surface-columns    83/83 aligned, 0 renumbers   (unchanged)
        migrate-columns    --plan: 0 moves, 0 overflow  (nothing left to do)
        pvlint             13 tracks, 0 errors, 9 pre-existing warnings
        pytest             490 passed (was 476 + 14 new)
        HUD specs          smoke / lcxl-leds / scene-directive all pass
        lcxl_grid --check  48 controls, every row 1-8, every orbit housed
      
      Also fixed while here: the LED watcher was still running the process started at
      16:39, i.e. code from before the A1-lights-for-d9 feature existed. That is why
      PLN saw no A1 LED while d9 was declared — not a mapping bug, a stale daemon.
      Restarted. Worth remembering as its own class: for gear that runs as a service,
      "I fixed the code" is not "the rig picked it up".
      PLN (Algolia) authored
    • feat(pvlint): PV008 — one physical button driving two orbits, the #94 bug as a permanent rule · 3060f506
      Earned during the phase-2 button remap (5910aacc). The migrator left an over-budget
      control where it was, which LOOKS conservative and is the opposite: once ^42 became
      d2's button, d1's surviving reference to ^42 meant one press fired both orbits.
      perfect.tidal shipped that way until a hand-written check found it — and that check
      then existed only in a terminal scrollback, which is not a gate.
      
      WHY IT DESERVES TO BE AN ERROR, NOT A WARNING
      Nothing errors. Nothing goes silent. The extra layer only appears while a specific
      button is held. On stage that reads as "the track is broken today" rather than as a
      mapping bug, which is the exact signature of the failures this rig keeps losing
      evenings to. Same family as the orphan-orbit ghost: audible, plausible, and invisible
      to every static check we had.
      
      WHAT IT DOES NOT FLAG — the cry-wolf cases, each a real line from the corpus
        * gF1-3 and gMute1-3 (and gPanic) are per-FAMILY BY DESIGN — measured, not assumed:
          gF1 -> d1/d2/d3/d8, gF2 -> d4, gF3 -> leads. Flagging those would fire on every
          track in the set and the rule would be switched off within a day.
        * one orbit referencing a button three times is ONE gesture, not three collisions
          (bombe_dj's kick does exactly that) — findings are per ORBIT, as in PV004.
        * COMMENTED-OUT gestures. This matters more than it looks: commenting the surplus
          is precisely how #94 parks an over-budget control, so a rule that counted
          comments would fail every track the migrator just fixed.
        * knobs. Knob sharing is a columns question, not a button collision.
      
      VALIDATION
        OPAL setlist        0 PV008 findings — clean, as of 5910aacc
        whole corpus        136 findings across 56 of 792 files
        pvlint tests        38 passed (5 new: the real perfect.tidal case, plus one
                            negative per cry-wolf class above)
      
      The corpus number is history, not exposure: those are old-world tracks that predate
      the column grid, and per the measure-the-set-not-the-corpus lesson the number that
      matters before a gig is the setlist's, which is zero. The corpus gets fixed when it
      gets migrated (#64 / post-gig), and now there is a rule that will prove it.
      PLN (Algolia) authored
    • feat(surface): REMAP PHASE 2 — the button rows column-align, and the board is now one sentence · 5910aacc
      96 substitutions across all 13 OPAL tracks. "Column N is orbit N" is now true for
      the WHOLE surface, not just the knobs:
      
          BT 41-44/57-60   d1..d8 gate
          BL 73-76/89-92   gMute1/2/3 | d4..d8 gate2
          A  13-20         d9..d12 level | d9..d12 fx
          B  29-36         d1..d8 fx
          C  49-56         gF1/2/3 | d4..d8 fx2
          D  77-84         d1..d8 level
      
          surface-columns:  23/84 aligned, 127 renumbers  ->  82/82 aligned, 0 renumbers
      
      PLN drove the key decision. My first attempt left over-budget controls in place and
      reached only 11/13 tracks, which he rejected outright: "we need finish the job. why
      half moved? cant you move all-but then comment out with fixme?" He was right on both
      counts, and leaving them was not the conservative choice — it was a bug generator.
      
      THE CASCADE, AND WHY COMMENTING OUT DISSOLVES IT
      The board has 8 BT + 5 BL = 13 per-orbit buttons for 8 orbits, because BL1-3 are the
      family mutes. So d1..d3 get exactly ONE button each, and bombe_dj's kick — a 3-state
      gesture on TWO buttons —
      
          $ midiOn  ("^41"-"^42") (<| "k(3,8) ~")
          $ midiOff ("^42"+"^41") (<| "k*<1!7 2> ~ ...")
          $ midiOn  "^42"         (<| "k k k*<1!8 2!8> ...")
      
      is over budget by design. Left in place, d1's ^42 blocked d2, which blocked d3, then
      d4, then d5: 4/8 aligned. Commented out, the control is free and the row aligns.
      Nothing is lost — the original sits one line above under two dashes, restorable the
      moment gSel (#54) can fold three states onto d1's own control.
      
      THREE BUGS THE GATES CAUGHT BEFORE THIS SHIPPED — all in my own tool
      
      1. LEAVING OVERFLOW IN PLACE IS A CROSS-ORBIT TRIGGER. Once ^42 becomes d2's button,
         any surviving d1 reference means pressing d2's gate also fires d1's kick variant.
         Measured, not theorised: perfect.tidal ended up with ^42 driving BOTH d1 and d2.
         A new gate now asserts no button drives two orbits — 0 across the setlist.
      
      2. THE CLASH GUARD HAD A HOLE. A clashed control also stays put, so it too becomes an
         obstacle — but I computed clashes in the same sweep that consumed them. bombe_dj:
         d2 was left on ^43 by a clash while d3 was still planned to move ONTO ^43. Fixed by
         iterating pass 2 to a fixed point, growing the sticky set until a sweep finds
         nothing new, with a non-convergence bail-out.
      
      3. COMMENTING A LINE ORPHANS ITS CLOSING PARENS. A chain step spans several lines, so
         commenting only the line carrying the CC left the body live and the `)` dangling —
         `perfect: parse error on input ')'`, and desire went from building to not building.
         The unit is a `$`-chain SEGMENT; it is expanded to one, then paren-balance-checked,
         and the tool refuses rather than emit broken Haskell.
      
      4. MULTI-BLOCK ORBITS WERE SILENTLY DROPPED. perfect.tidal declares `d1` SIX times as
         section alternates. Keying the plan by orbit number meant each later block
         overwrote the earlier one's plan, so the first block's ^42 -> ^41 vanished — the
         real reason gate 1 kept failing after I "fixed" it. An orbit's control set is the
         UNION across its blocks. Recovered 3 more moves (perfect 7->8, electric_hammer 3->5).
      
      VALIDATION
        no button drives 2+ orbits    0 tracks (was 1)
        silent-eval --seeded          OK, every declared orbit emits events
        build failures                4, identical to before — no new breakage (#93)
        surface-columns               82/82 aligned, 0 renumbers
        pvlint                        0 errors, 9 pre-existing warnings (#70, dup d5)
        pytest                        471 passed
        check-boot / -blocks          OK
      
      9 overflow controls are now commented with a FIXME naming the orbit, its slots, and
      the restore path. That is the honest total: the surface is fully aligned, and every
      control that could not fit says so in the file rather than in someone's memory.
      PLN (Algolia) authored
    • refactor(corpus): deshadow ouais_je_funk too — PLN's call, he takes it from there · 1764d3e1
      Held out of daf074f2 because the file was already in PLN's modified set and the
      standing rule is not to commit over his uncommitted .tidal work. He waived it:
      "commit over ouais je funk do the fix there too ill take it from there."
      
      Same mechanical change as the other 254 files — the 7-line shadow stanza removed
      so BootTidal.hs is the only definition:
      
          let gMask  = (midiOn "^41" (mask "t!3 <t!3 [f <t f>]>"))
          let gMute  = (midiOn "^73" (mask "f*16"))
          let gMute2 / gMute3
          let gM1 = gMask . gMute   /  gM2  /  gM3
      
      `let width` and `let dMask` are untouched: BootTidal defines neither, and dMask
      is this track's own compositional mask, not boilerplate.
      PLN (Algolia) authored
    • refactor(corpus): BootTidal is now the ONLY definition of the global helpers —… · daf074f2
      refactor(corpus): BootTidal is now the ONLY definition of the global helpers — 1705 local shadows removed
      
      PLN loaded bombe_dj to test the new surface and found ^41 still chopping every
      bar, two days after gMask was "retired". He asked the right question: "why so
      many let gMask in bombe dj? did you do the edits yet?"
      
      THE BUG WAS ARCHITECTURAL, NOT A MISSED EDIT
      Every track opened with a copy-pasted stanza:
      
          let gMask  = (midiOn "^41" (mask "t . <f t f <f t>> <t f f <t f>>"))
          let gMute1 = (midiOn "^73" (mask "f*16"))
          let gM1    = gMask . gMute
          let gF1    = (# djfbus 1 (range 0.05 0.95 "^49"))
      
      A local `let` SHADOWS the boot definition, so for years every central fix
      stopped at the door of 254 files. Two consequences, both live on stage:
      
        * gMask was retired in BootTidal.hs (`gMask = id`) and still masked on the
          237 tracks that redefined it. The retirement reached 5 of 13 setlist tracks.
        * gF1/2/3 were rewritten to the two-sided gDJF (centre = bypass, left = lpf,
          right = hpf, and 0 = a 180 Hz lowpass = silence). 333 local defs kept the
          OLD one-directional `(# djfbus N (range 0.05 0.95 "^49"))`. So the DJ-filter
          fix that took a whole afternoon to get right was invisible in performance.
      
      Two tracks even carried `let midiGGlobal = "^77" * 1.5` — reading fader ^77,
      which is now MIDI-learned to Ardour's Tidal 01 gain. That is bug #53 alive: an
      accidental nudge of fader 1 would attenuate every midiG-using stream.
      
      WHAT LANDED
      tools/deshadow-helpers.py, --plan/--apply, two passes:
      
        pass 1  1551 deletions of exact boilerplate, so boot's definition takes over
        pass 2   154 deletions of defs pass 1 ORPHANED (a local `let gMute` whose only
                 user was the `let gM1 = gMask . gMute` just removed). BootTidal has no
                 `gMute`, so pass 1 cannot touch it, and half a stanza reads like it
                 still does something. Referenced nowhere = zero events change.
          11  COMMENTED OUT, not deleted, with a DESHADOW(#96) marker
      
      The safety property is that direction: a def is deleted ONLY if its body
      positively matches a known boilerplate regex. Anything unrecognised is
      commented, so a bad regex leaves a comment instead of deleting PLN's music. It
      earned that immediately — the 11 include real composition that only worked
      BECAUSE it shadowed:
      
          let gF2 = (whenmod 64 48 (# djf (slow 16 $ range 0.5 0.2 saw))) -- Parts DJF
          let gF1 = (someCyclesBy "<f!10 t!6 f!112>" (# djfbus 1 ...))    -- Intro DJF
          let gMask = mask "t t <t <t f> t <f [f t]>>"    -- a fixed compositional mask
      
      and one latent build error, `let gF1 = (y# djfbus 1 ...)`, which cannot compile.
      
      DELIBERATELY NOT TOUCHED
        * gDJF (65 files). Local is `let gDJF = …` (0 args); boot's is `gDJF ch`
          (1 arg). Removing the shadow is a TYPE ERROR, not a global. Own pass, #96.
        * gMute/gM/gF/gM'/gDJF1/gMaskEnd16/… (315 defs) — names BootTidal does not
          define at all. Deleting breaks the build.
      
      VALIDATION — verdict equality, not "it looks fine"
      silent-eval over the 13-track OPAL setlist, before vs after, with an EMPTY
      control map. Every single line of the diff is a line NUMBER shifting by 7, the
      height of the deleted stanza. Same tracks, same orbits, same counts:
      
          before: 9 tracks with a silent orbit, 4 unbuildable by the harness (#93)
          after:  9 tracks with a silent orbit, 4 unbuildable by the harness
          -> VERDICTS IDENTICAL
      
        check-boot        OK — all g* helpers audible with an untouched controller
        check-boot-blocks OK — 32 blocks, each still one statement
        pytest            471 passed
        surface-columns   5 knob misalignments at HEAD -> 4 now (desire's borrowed ^52
                          is gone). No regression; the remaining 4 pre-date this.
      
      Also in desire.tidal, on PLN's call ("just simplify to make it finish the job,
      losing nothing, leaving FIXMEs"): d3's ^52 was borrowing d4's column because the
      grid gives d3 exactly one knob (B3, already used; C3 is the gF3 family filter).
      Parked as a static `# legato 0.55` with the knob form one line above in a
      comment, and the d7/d9 open question closed as a FIXME rather than left
      blocking. Nothing deleted, everything re-enableable by removing two dashes.
      
      Excluded from this commit: live/midi/nova/lounge/ouais_je_funk.tidal, which was
      already in PLN's modified set. The deshadow is applied on disk there; his to
      commit.
      PLN (Algolia) authored
    • docs(tasks): archive #85/#88/#89/#92 — the surface remap, and four bugs that hid in plain sight · d6e509c3
      Rich entries for the afternoon that turned the LCXL from a lookup into a channel
      strip. Written for a cold reader, because these are the blog/video source material.
      
      The four findings worth reading again months from now:
      
      * A daemon with THREE possible lifecycles, two of which could run at once — two
        processes writing SysEx to one board, fighting over every LED, neither wrong
        enough to look broken.
      * `StartLimitIntervalSec=0` in [Service] instead of [Unit]: systemd logs "Unknown
        key ... ignoring" and the default retry limit stays in force. A silent failure one
        section heading away from working.
      * 34 "destination already taken" clashes that were entirely PHANTOM — an artefact of
        costing a permutation one move at a time when the corpus is uniformly off by one.
      * A measurement that overruled my own design: I proposed eight per-column DJ
        filters; measuring which orbits actually receive gF1/gF2/gF3 showed they are
        per-FAMILY (drums / bass / leads) and consistent across all 13 tracks. Building
        the "better" design would have destroyed a working abstraction.
      
      Also: the physical LCXL faders sat at 0 while the Ardour faders had been raised by
      mouse — silent divergence, and the next brush of a fader would have killed the orbit
      mid-set. Found in a log, not by ear.
      PLN (Algolia) authored