1. 28 Aug, 2026 5 commits
    • feat(surface): translate the LCXL3 instead of renumbering the corpus a second time · c628415e
      The v3 and the 169 tracks disagree about what a CC means, and only one of them
      is cheap to change.
      
      Paint is DAW-mode-only. Tested rather than believed, in three steps with PLN
      watching the surface: magenta on the MIDI port in standalone did nothing; the
      same paint on the DAW port with DAW mode off did nothing; `daw on` followed by
      the same bytes lit all 24 rings and 16 buttons. So the guide's "only available
      once DAW mode is enabled" is a hardware fact, not an artefact of how the
      document is organised.
      
      But DAW mode's CC map is fixed, and it overlaps v2's with DIFFERENT meanings.
      The map was read off the hardware, one control per row, not off the PDF's
      low-resolution diagram: fader1 -> cc5 ch16, A1 -> 13, B1 -> 21, C1 -> 29,
      E1 -> 37 ch1, F1 -> 45 ch1, shift -> 63 ch7.
      
          row   v2 (lcxl_grid)   v3 DAW
          A     13-20            13-20     identical
          B     29-36            21-28
          C     49-56            29-36     <- v2's row B numbers
          D     77-84            5-12
          E     41-44, 57-60     37-44
          F     73-76, 89-92     45-52
      
      Sixteen indices collide. The one that decides it: v3 #41 is button E5, while v2
      #41 is button E1 — the kick gate the whole #94 migration just aligned 169 files
      onto. Renumbering would move the kick gate four buttons to the right, silently,
      with nothing erroring. That is the same failure class b5ad8b6c was written to
      end, so the corpus keeps its numbering and software absorbs the difference.
      
      Three things make this cheap rather than clever:
      
      SuperCollider's binding is `MIDIFunc.cc({...})` with no cc, channel or src
      filter — only the number matters, so a pure renumber is sufficient and no
      channel juggling is needed.
      
      Every encoder row is still in ABSOLUTE mode (queried: rows 1/2/3 all read 0),
      so a v3 encoder already behaves like a v2 pot. Relative mode needs an
      integrator that owns the value — genuinely better, since it kills pot-pickup
      outright, and it is where paint-by-value belongs — but it is a separate change
      and `--relative` is left as an experiment that honestly warns the map no longer
      applies.
      
      The v2 numbers are DERIVED from lcxl_grid.ROW_CCS positionally rather than
      retyped, so if the authored table moves, the translation follows.
      
      Publishing a virtual port rather than resolving SuperCollider's once: SC runs
      MIDIIn.connectAll a single time at boot, so a port that appears later is never
      connected, and a binding resolved once is the rig's most repeated failure mode.
      The driver re-asserts the aconnect link on a timer, the same shape as the two
      reconciler units.
      
      midi-autoconnect had the same stale-name bug: it hardcoded "Launch Control XL",
      which matches neither v3 port, and since it routes aconnect's complaints to
      /dev/null the failure was completely silent — Ardour stopped hearing the surface
      with every check green. It now resolves the source, preferring the driver's
      translated port over the raw device, and echoes what it picked once per change
      rather than every two seconds.
      PLN (Algolia) authored
    • fix(rig): the reconcilers I moved into the repo went in as 644, so systemd could not exec them · 8069657f
      4d9f1059 rescued these two scripts from $HOME, where they had been living
      un-versioned, and committed them without the exec bit. The symlinks in
      ~/.local/bin resolved fine to a file systemd then refused to run:
      
        Process: ExecStart=/home/pln/.local/bin/tidal-ardour-autoroute.sh
                 (code=exited, status=203/EXEC)
        Active: activating (auto-restart)
      
      An auto-restarting unit reads as busy rather than broken, which is why
      rig_units reported 'activating' and nobody chased it. Net effect: the autoroute
      that puts twelve orbits onto Ardour's Tidal NN tracks has not run since 23 Aug
      — the exact regression 4d9f1059 was written to end.
      
      The recurrence has a cause, and it is repo config: core.fileMode = false here,
      so git ignores exec-bit changes from the working tree entirely. That is why
      4e0d63ae already had to fix the mirror image of this (+x on disk, 644 in the
      index), and why chmod can never be the whole fix in THIS repo — it silently
      does nothing as far as the index is concerned. `git update-index --chmod=+x`
      is the tool that works.
      
      Both halves done: chmod for this checkout, update-index for the index, so a
      fresh clone is born runnable.
      PLN (Algolia) authored
    • feat(surface): the LCXL3 bench — a place to look at the new device before trusting it · 50c8f13a
      Not an extension of lcxl-leds.py, deliberately. v2 and v3 are different
      protocols, not different constants: v2 is a template/palette SysEx driving
      3-colour buttons, v3 has true RGB per control, a 128x64 OLED, endless encoders,
      and a CC map that only exists in DAW mode. Folding v3 in would produce a file
      that lies about both. When the numbering settles, lcxl_grid.py stays the one
      authored table and this becomes its transport.
      
      Every byte sequence in here is quoted from the official programmer's reference
      v1.0, not recalled — RGB is 01h 53h <idx> <R> <G> <B>, the bitmap is 09h with
      1216 bytes and a 7Fh terminator rather than F7h, and the feature CCs live on
      channel 7 of the DAW in port.
      
      Transport is python-rtmidi via mido rather than shelling out. aseqsend on this
      system takes -s as a FILENAME: `aseqsend -p 20:0 -s "B0 25 05"` prints
      'cannot open B0 25 05' and exits 0. A silent no-op, which is how a first paint
      attempt can look like a hardware answer. lcxl-leds.py shells out that way
      today, which is worth revisiting.
      
      The subcommand that matters is `keystone`. The guide files 'Colouring the
      surface' and 'Controlling the screen' inside the DAW mode chapter, prefaced
      'only available once DAW mode is enabled'. If that is how the document is
      organised rather than a hardware restriction, paint works in standalone and the
      corpus keeps its v2 numbering — a 169-file difference. So it gets tested in
      three steps with PLN's eyes on the surface, not assumed either way.
      
      `sweep` exists for the same reason: the DAW-mode index map is a low-resolution
      diagram in the PDF, so we light each index in turn and read the map off the
      hardware.
      PLN (Algolia) authored
    • docs(rights): samples-extra is yaxu's course pack — which un-clears kick rather than clearing snare · 60604b5b
      PLN said snare was 'also a default tidalcycles CC0/public domain pack'.
      Upstream disagrees: tidalcycles/Dirt-Samples tracks 52 files under sn/ and
      zero under snare/. The 90-file snare bank is local and was unidentified.
      
      His second memory was the one that paid — 'the very early tidalcycles docs
      pointed me to that sample pack'. The banks come from slab.org/tmp/samples-extra.zip,
      the Tidal Club course pack from Week 1 lesson 2. Proven by reading the zip's
      central directory over a range request, so 113 MB stayed on the server:
      SNARE0, SNARE119, KICK142, CLAP163 are all in it, and the local folder is
      named samples-extra.
      
      Three candidates died on dates and names, which is worth recording so nobody
      re-runs them: Blood Sport prefixes its banks bs- and holds no SNARE*.wav; the
      90s-sample-cds torrent was downloaded 2024-12-20, after these files' 2019-11-07
      mtime; MF_Drums_Samples names its files BD_MF_Valve11.wav. And there is no
      content-based route at all — AcoustID needs song-length audio, not 0.4s hits.
      
      The uncomfortable part: this makes the ledger worse, not better. The pack ships
      no licence for these banks — the only licence file in it is Blood Sport's own.
      And snare's files carry ISFT 'Sonic Foundry Sound Forge 6.0', IENG 'TJ', ICRD
      2003-08-30, i.e. 24-bit one-shots rendered on a 2002 commercial DAW. An official
      course pack establishes provenance, not licence.
      
      So kick, cleared on 2026-08-16 on the note 'TidalCycles official / samples-extra
      CC0 pack', rests on the same unverified premise. Same pack, same absent licence,
      but cleared is releasable and unknown blocks. One question to yaxu settles six
      banks at once.
      PLN (Algolia) authored
    • docs(board): the LCXL3 answers three open questions and opens a new one · b101c415
      The board carried 'can third-party SysEx paint arbitrary text to the OLED?'
      as UNVERIFIED, with a note that it mattered: HUD v2's alarms are derived
      state, not control values, so a closed OLED meant the alarms stayed on
      screen. The official programmer's reference settles it — arbitrary 128x64
      bitmaps, and the firmware ACKs each frame specifically so you can animate.
      
      Two more close the same way. LED brightness is CC 111 and non-volatile, so
      open taste call #11 stops being an argument about whether a bypassed filter
      should be bright and becomes a knob. And endless encoders emit deltas, so
      pot-pickup does not exist to solve.
      
      The new one is worse than the ones it closed. DAW mode is the only mode the
      guide documents paint in, and DAW mode's CC map is fixed and PARTLY OVERLAPS
      v2's with different meanings: v2's row-B knobs 29-36 are v3's row 3, and
      v2's faders 77-84 are v3's relative-encoder row 1. Nothing errors. Controls
      just move one row. That is the exact failure class b5ad8b6c spent 169 files
      fixing, so that branch does not merge until this is settled.
      
      Also retracted, with evidence: DIN is not our answer to the USB-hub single
      point of failure. aconnect shows only Midi Through and the controller — this
      machine has no MIDI DIN input at all, so DAW mode disabling the DIN outputs
      costs nothing today.
      
      And snare is still not cleared. 'Also a default tidalcycles CC0 pack' is a
      name collision: upstream tracks 52 files under sn/ and zero under snare/.
      The 90-file snare bank is local and unidentified, and still gates 4 tracks.
      
      CosmicFest is closed as a mastering input by PLN's call. Five probes agree
      nothing was captured to this machine; he had a stage mic and rates it poor.
      PLN (Algolia) authored
  2. 23 Aug, 2026 4 commits
    • fix(surface): finish the #94 column remap — 169 files still spoke the pre-gMask layout · b5ad8b6c
      PLN: "so many tracks still having ^42 instead of ^41 for kicks its confusing."
      
      Remap phase 2 (5910aacc) column-aligned the board so column N IS orbit N, but it
      was scoped to the FOURTEEN files of the OPAL set. Everything else still spoke
      the layout from when gMask held CC 41, where an orbit's gate row started one
      column late: d1 -> ^42, d2 -> ^43. button-column-audit found 169 such files,
      every one of them shift +1 — a single layout generation, not a scatter of typos.
      
      Layout-shifted files: 169 -> 0. Buttons on their own column: 35.7% -> 78.6%.
      
      TWO SYSTEMIC FIXES IN migrate-columns.py, both found by running it on the whole
      corpus instead of a setlist. Each refused a batch rather than writing bad
      Haskell, which is the only reason they were cheap to find:
      
      1. THE SEGMENT BOUNDARY WAS DEPTH-BLIND. A chain step ends at the next line
         starting with `$`/`#`, but a `$` inside an open paren is a CONTINUATION, not
         a new step. So a multi-line body truncated after one line, left depth 1, and
         the balance check refused the file. 10 of 169 were unmigratable. The boundary
         is now paren-aware, and the hit line is no longer assumed to BE a step start:
         `^NN` is routinely referenced from the middle of a body, so we walk back to
         the nearest enclosing step whose segment both balances and contains the hit.
         Negative depth was the tell that the anchor was too late, not that the source
         was broken.
      
      2. COMMENTING OUT A `$`-STEP CAN UNDER-APPLY THE STEP ABOVE IT. Not a paren
         problem — an ARITY problem, and silent-eval is what caught it: 4 files
         stopped compiling while 165 were fine.
      
             $ midiOff "^57" (mask "<[t f]!0 ...>")   <- a function, still hungry
             $ stack [ ... ]                          <- its argument
      
         Comment the `stack` and GHC says "midiOff is applied to too few arguments"
         and the whole do-block dies, all twelve orbits — far worse than the
         cross-orbit trigger the comment-out was avoiding. `#`-steps stay
         commentable (`#` takes a ControlPattern, so a shorter chain is still
         well-typed); `$`-steps are now ANNOTATED and left live, and the FIXME says
         so, because an annotated line IS still firing.
      
      VERIFIED against a pre-migration baseline, not against hope:
        * non-compiling tracks: 17 before, 17 after, and the SETS ARE IDENTICAL —
          zero newly broken, zero newly fixed. All 17 predate this work.
        * silent orbits: the same 19, in the same tracks, only line numbers moved.
        * pvlint: 30 -> 26 errors, no new error signature. Warnings 996 -> 1041: the
          +45 are the annotated-but-live `$`-steps honestly reporting that they still
          reach another column. Errors down, honesty up.
        * fix-button-roles: 3 clean role rewrites applied, then 0 remaining. The
          role report is otherwise unchanged from HEAD, so the migration introduced
          no gate/gesture inversion — the class only PLN's ear catches.
        * migrate-columns --plan re-run: 0 moves. Converged.
      
      Left for a second pass, deliberately: 118 sites where an orbit's two own-column
      buttons carry the same role (e.g. two gestures, no gate). Those are not
      inversions — with no gate there is nothing to swap, and the grid gives d1-d3 one
      button anyway. The count rose from 74 because column-aligning the buttons is
      what made them VISIBLE; they were unevaluable before. gSel (#54) is the fix.
      PLN (Algolia) authored
    • feat(rig): one authored unit inventory + parvagues-rig.target — the list lived in three places · 406a4610
      PLN: "we gotta ensure these are unified proper gig single service or package
      easy to maintain/add things to."
      
      tools/rig_units.py is now THE inventory. Three consumers, no re-typing:
      the Bridge panel imports SERVICES, gig-up.sh reads --ensure, and
      parvagues-rig.target is GENERATED by --target. Same shape as lcxl_grid.py (#97),
      for the same reason: the list was previously a hand-typed table in bridge/rig.py
      (5 rows, missing parvagues-bridge), a hardcoded loop in gig-up.sh (4 names), and
      whatever [Install] each unit carried. Adding a unit to the rig is one row.
      
      Two real bugs closed:
      
      * gig-up.sh only ever ran `systemctl start`, never `enable`. A start fixes
        tonight and changes nothing about the next login — exactly how
        tidal-ardour-autoroute sat `disabled` while every pre-gig check passed.
        --ensure does both verbs, per-unit boot policy: the reconcilers are "login"
        (they idle, hold no ports, make no sound), parvagues-sc stays "on-demand"
        because a login must not start SuperDirt.
      
      * `is-enabled` returning "linked" means NOT enabled — it reads like a healthy
        word. --status says so in words instead of printing systemd term.
      
      Also learned the hard way while doing this: a unit file symlinked from
      ~/.config/systemd/user into a FEATURE branch vanishes on `git checkout`, and
      `systemctl disable` on a linked unit deletes the symlink itself. Live infra
      files belong on master; both units are there now.
      
      systemctl --user start parvagues-rig.target  # the single handle
      PLN (Algolia) authored
    • fix(rig): the two reconcilers that lived only in $HOME — five orbits reached no Ardour track · 4d9f1059
      tidal-ardour-autoroute.service was disabled+inactive after launch: orbits 5-9
      (SuperCollider:out_9..18) had NO link to their "Tidal 05..09" tracks, while
      1-4 and 10-12 survived as leftovers from an earlier session. Enabled it; all
      24 outs reconcile every 2s again.
      
      Root cause of the regression is that both reconcilers — this one and
      midi-autoconnect — existed ONLY as plain files in ~/.local/bin and
      ~/.config/systemd/user, unlike every other unit here which symlinks into
      tools/. Nothing was version-controlled, so a `disable` (or a fresh systemd
      user config) was unrecoverable and invisible to git. Now in the repo, with
      $HOME pointing back at it. Also started parvagues-sc-watchdog, which was
      linked but dead.
      PLN (Algolia) authored
    • update: post cosmic · 49e1b78e
      PLN (Algolia) authored
  3. 22 Aug, 2026 1 commit
    • fix(foundry): the loops have holes in them — an aggregate cannot see a hole · 672b0fb3
      PLN opened fred_angie_bass, the first kit he tried, and found two bad loops
      immediately: ":0 starts, then silence, then starts again" and ":2 why is this
      not trimmed, silence before silence after?". Both had shipped as tier S with
      zero flags, one at grade 0.991.
      
      He was right, and it is worse than two files. Binning each loop's envelope on
      its own beat grid says 12 of 111 loops carry dead air — and 5 of 14 bass loops,
      36%, which is why he hit it on his first click. angie:0 has beats 7-9 of 32 at
      -88 dBFS; bighen_bass:1 has a five-beat hole and 62.5% occupancy at grade 0.983.
      
      This is the third time this session that silence beat the grader, and the
      reason is the same each time, one level further down. Silence-as-optimum was
      fixed at export, then again in the finder before ranking, but every one of those
      checks is an AGGREGATE: file peak, whole-file RMS, max of the beat table. An
      aggregate cannot see a hole. A loud loop with a silent bar inside it passes all
      three. And kitcheck's dead-bar rejector looks at BARS, so a 2.5-beat gap
      straddling a bar boundary is invisible — angie:0's two bars read -18.3 and
      -15.9 dBFS while the beats between them read -88. Resolution was a correctness
      input and I had treated it as a detail.
      
      So this commit does not fix it, it measures it. beatocc.py bins the envelope on
      the loop's own beat grid (bpm and bars from the manifest, never re-estimated)
      and reports SHAPE rather than level: head and tail dead beats, longest interior
      dead run, occupancy, plus a beat map under -v. It exits non-zero when any loop
      has dead air, so it can go straight into kitgate once the thresholds are known.
      
      Thresholds deliberately not chosen yet. Occupancy floors have to be per family
      like the periodicity floors — marea_vox/06_breaths_1b at 25% occupancy is a
      breath, not a defect, while a bass loop at 62% is broken — and the head-silence
      question has three plausible answers (trim, rotate, reject) with rotation ruled
      out already, since angie:2's offset is 1.17 beats and _rotate_to_downbeat snaps
      to beats. Placing those floors by guess is how silence got graded A in the first
      place. #24 on the board carries the open decisions and the data needed for each.
      
      The Fred pack section is now marked DEFECTIVE rather than SHIPPED. It needs a
      re-cut once the shape check exists, and PLN's verdict stands as written:
      "tbh i dont think the loops are usable."
      PLN (Algolia) authored
  4. 21 Aug, 2026 30 commits
    • docs: board + archive — the Fred pack landed, #20b and #21 written up · 830320db
      TODO.md: the Fred section moves from "RESUME HERE" to shipped, with the gate numbers,
      the six-cuts-five-discarded history, and the audition-first list of 6 kitcheck
      advisories that are now waiting on PLN's ears rather than on any tool.
      
      completed-archive.md: #21 (La Cale) and #20b (the pack) as standalone entries. The
      learnings worth carrying: the rack had to BE `loopAt` rather than resemble it; a gate
      blocks on defects and reports properties, because blocking on a property is a gate
      everybody learns to ignore; `n` is the folder's index, never the manifest's; a rounded
      tempo is a stretched loop; and three tests that passed while proving nothing, which was
      most of the day's actual value.
      PLN (Algolia) authored
    • docs(armada): two logs claimed 027 — the CosmicFest one becomes 029 · 42c35f24
      A parallel session in this shared checkout committed `027-the-instruments-were-the-
      broken-part.md` at 00:05, but 027 was taken at 17:34 by `027-the-label-and-the-lens.md`
      and 028 by `028-la-cale-and-the-empty-loop.md`. Renumbered to the next free slot;
      content untouched, only the filename and the heading.
      
      The captain's log is a sequence on purpose — it is the documentary's spine — so two
      entries sharing a number is not cosmetic. Worth noting that a shared working tree makes
      this collision structural rather than careless: neither session could see the other's
      number until both had committed. A worktree per session avoids the `git add` race but
      not this.
      PLN (Algolia) authored
    • docs: 027 — the instruments were the broken part, plus the pre-gig close-out · 57cf60a3
      Achievement log for the night before CosmicFest, and the board amendment that
      carries the resume points a cold session will need.
      
      The log's through-line is not the LED daemon; it is that FOUR separate
      instruments returned confident wrong numbers in one evening — `ps -o %cpu`
      summed as if it were instantaneous (it is a lifetime average; the main pulsar
      process read 2.1% while one child renderer burned 112%), `pw-top`'s ERR column
      read as current when it is cumulative (delta over 25 s: zero on every node), a
      shell path built as `live/$f` when `$f` already began `live/`, and a
      `grep -cE '[1-9] move'` that silently skips `10 move`. Three of the four
      returned a plausible number rather than an error, which is the whole problem: a
      wrong tool is rarely silent-and-empty, it is confident-and-slightly-off.
      
      Board amendment records what the next session must not redo:
      * The highlighter is EXONERATED by its own instrument — 5 RAF/s x 1.671 ms =
        0.8% of a core, `markers 293 live, creates/destroys 0/s` over 4h42m. The
        marker leak is dead in the field. Do NOT re-open that hunt.
      * Five hypotheses for the ~112% renderer are already dead, each with its
        evidence, so they are not re-tried: editor-background (disabled),
        sound-browser rAF loops (all terminate), fork-per-LED (2.8 ms p50, bench p99
        18 ms zero overrun), aseqdump pipe buffering (disproved on an isolated client).
      * gig-up is 5 blockers -> 1, and the survivor is PLN's decision to defer 20
        column moves rather than renumber knobs hours before playing.
      * CosmicFest EXISTS (PLN's own words) but has no canonical www page. The date
        and venue must come from him, never backfilled from eb9221de.
      * Parked and flagged: 3205e7f unpushed in the pulsar-tidalcycles fork,
        sound-browser.js's 144 unvalidated insertions, backlog.md's +60 lines, and a
        one-line hello_fred.tidal stub.
      PLN (Algolia) authored
    • merge: the Fred pack and La Cale · 0f271843
      150 samples in 43 kits from Fred again..'s stem pack, staged at
      Samples/Fred/output/ and linked into Dirt-Samples, plus the page for deciding
      whether any of it is good.
      
      The pack took six cuts. Each discarded run was discarded because looking at its
      output found something: dead bars grading S, one rotation pass leaving 1 loop in 6
      on a weak beat, then the big one — 24 of 153 loops were digital silence at -104…-75
      dBFS, graded A and B, because every term in the loop rubric is vacuously perfect on
      nothing. Presence is a precondition now, at the grader and before the finder ranks.
      
      La Cale (armada/ui/kits.html) plays rate = dur / (bars x barlen), which is literally
      loopAt, so what you hear is what the rig does and the exported .tidal block is the
      same two numbers in Tidal's syntax.
      
      Python suite 112 -> 128; new vitest suite 24 cases.
      PLN (Algolia) authored
    • docs(board): the board was never lost — 112 items were in the checkpoint · 1e2bc3d9
      TASKS_DUMP.md opens by saying the Task API was unreachable, so the tree had to be
      rebuilt from git + memory + the task logs. That was true of the tools and false of
      the data: a near-limit checkpoint of this session had already written the whole
      TodoWrite state to .claude/RESUME.md — 112 items, 22 done, 7 current, 83 open.
      The curated epics in this file are about a fifth of the real board. Same failure
      shape as the three that cost this session hours: an instrument reported absence,
      and nobody asked whether it could have found the thing if it were there.
      
      Reconciled against 2ef09af6..c821bdf5, nine commits a parallel session landed in
      this checkout while the release thread was busy:
      
        * EPIC I, new — rig hardening and the Bridge. gig-up --converge and the gearbox
          were two owners of one setting with no arbiter, and ppd re-asserts on AC
          transitions, so a converge done on battery was silently undone by plugging in
          for the set. check-drift told PLN to delete his set the day before a gig.
        * CosmicFest prep across 24 track files and a new livecode_parade.tidal — but
          content/lives/2026/ has no cosmicfest page, so the date is NOT canonical and
          is not mine to invent. It is now the one blocking question on the board,
          because a date is what turns every H1 row into a deadline.
      
      Also flagged: four of the seven in-progress items are stale-active (the SoundCloud
      writer, the v4 re-cut, the mix/master/split stack, the per-track AT all landed),
      and A3-old + A4-Content-ID are superseded duplicates whose only live residue is
      two safety rules and one sanctioned playlist deletion. Merge candidates, PLN's
      call, not mine.
      
      The 112-item list is NOT durable — .claude/ is untracked and the checkpoint ref
      expires in about two weeks. Promoting it into this file is now a real task.
      PLN (Algolia) authored
    • fix(cale): the CLAP tags were painted over the downbeat · ee8d4ac2
      Third round of "screenshot it rather than assume". The tag chips were absolutely
      positioned inside the waveform cell with a translucent background, so they covered the
      first ~15% of every envelope — which is the attack of the loop, the one part you look at
      to decide whether a cut landed on the beat. It read as a gap in the audio, and I nearly
      went looking for a bug in the envelope generator.
      
      Tags now sit on their own 11 px line under the waveform, truncated, with the CLAP
      confidence in the tooltip. `fred_marea_drums` reads correctly at a glance now: twelve
      loops, kick transients visible, three `kit` loops separated by @4:31 / @1:45 / @1:12.
      PLN (Algolia) authored
    • fix(check): stop naming a cause this check cannot see · c821bdf5
      The headroom warning said "pulsar at N% — #7 (highlighter marker pool). This is
      the JITTER SOURCE; a Window:Reload is the stopgap". I read that, believed it, and
      spent a chunk of a pre-gig evening hunting a leak that is already fixed.
      
      What is actually true as of 2026-08-21:
      
      * Both documented marker leaks are FIXED on the running branch —
        `decorateCodeBlocks` tracks its markers in `blockMarkers` and destroys them
        (6e9f374, 2026-07-18), and `#createPositionMarkers` destroys the previous row
        markers before clearing the map (also 6e9f374; the cached-highlight pool was
        bounded in ba77c4f, 2026-08-03).
      * PLN's renderer was **3 minutes old and flat at 112%** across five 2 s samples.
        A leak GROWS. Accumulation cannot explain a fresh process holding steady, so
        whatever the burn is, it is not the marker pool.
      * `editor-background` is confirmed in disabledPackages, so it is not that either.
        The sound-browser's three `requestAnimationFrame(processChunk)` loops all
        terminate on `currentIndex < total`, so they are bounded too.
      
      Also worth recording: `ps -o %cpu` is a LIFETIME AVERAGE, not an instantaneous
      reading. My first two measurements ("122%", "130%") summed lifetime averages
      across the process tree and were meaningless; the honest number comes from
      sampling /proc/<pid>/stat utime+stime twice. The main pulsar process reads 2.1%
      while a child renderer burns 112% — a tree sum hides which process to look at.
      
      So the warning now reports the symptom, says explicitly that the known leaks are
      fixed and that a fresh hot renderer rules out accumulation, and points at
      `tidalcycles:highlight-stats` — the package's own live instrument (DOM writes/s,
      live markers, RAF rate, active events), which is the only thing that can attribute
      a burn INSIDE the renderer. A warning that names the wrong cause is worse than one
      that names none: it spends the reader's time defending its guess.
      PLN (Algolia) authored
    • fix(foundry): the cheat sheet printed 133 bpm over loops cut at 132.5 · 831d3f14
      `loopAt` is exact, so a rounded tempo is a stretched loop — and the cheat sheet is
      where the number gets typed. It reported ANGIE and BIG HEN as "133 bpm" over files cut
      at 132.51, which plays every one of them 0.4% fast.
      
      Two decimals now, and each kit prints its own `setcps` line rather than leaving the
      ÷60÷4 to the reader: `loopAt` squeezes the file into N bars of the *current* cps, so
      the cps is half the instruction and omitting it is what makes the rounding bite.
      
      Same fix in La Cale's UI, where the transport read 123.05 next to a row reading
      123.0 — one `fmtBpm` for the transport, the rack header and every row, because the
      same tempo printed two ways reads as a disagreement in the data.
      PLN (Algolia) authored
    • feat(fred): the pack ships — 150 samples, 43 kits, linked and ledgered · ebcd5664
      Run 6 through the whole gate. 150 samples in 43 kits across 9 tracks, staged at
      `~/Work/Sound/Samples/Fred/output/<kit>/` and symlinked into Dirt-Samples, so
      `s "fred_marea_drums" # n 3` resolves right now.
      
          restage --dry-run   0 of 43 kits (idempotent, as designed)
          grade re-run        S×84 A×28 B×38 · manifest matches the audio on disk, 0 mismatches
          kitcheck            144 clean, 6 advisory, 0 failing · 150 of 150 distinct sounds
          n indices           43 kit dirs hold exactly their 150 manifest files
          bank-check          "No shadowed bank names. Every `s \"name\"` is unambiguous."
          rights              43 of 43 third_party, with provenance — per-track release blocks
      
      MAREA is the ground truth: it declares `123BPM` in its filenames and the grid read
      **123.0**. PLEASEMAKEITBETTER read 20% tempo confidence and correctly shipped chops
      only. Nothing in the pack now sits below **-63.7 dBFS** peak, where run 3 had files at
      -104.
      
      **The gate had to be restructured before it would pass, and not by loosening it.** It
      blocked all 150 samples on 20 `near-silent` and 17 `mono-incompatible` files, none of
      which was broken. That is a category error I built in: a kit is gain-staged as a unit
      and deliberately never boosted, so its quiet members are quiet *on purpose*; and low
      interchannel correlation in a vocal double or a stereo pad is the width someone paid
      for. So the rule is now explicit — **the gate blocks on defects, reports properties.**
      
      Defects, which block: `clipping`, `empty`, and `mono-incompatible` **on bass only**,
      where summing to mono on a house PA kills the fundamental and nobody chose that.
      Properties, which are printed with the numbers that let PLN judge them by eye and go
      straight into La Cale's audition-first list: 20 near-silent (crest 14-34 dB — sparse,
      not silent), 17 wide (corr -0.35…+0.19), 2 marginal seams (+7.7 and +6.7 dB, where the
      threshold for an audible click is +20).
      
      Also dropped a false positive: `off-grid` on something the manifest calls a chop. A
      sub-bar one-shot has no bar grid to be off; `grade` guesses from duration, the manifest
      knows, so the manifest wins.
      
      The 6 kitcheck advisories are all weak bar periodicity on sustained material (pads,
      keys, synth) plus two MAREA drum loops at 0.08-0.12 against a 0.30 floor — bar-aligned
      but not repeating units. Those two are the first rows to audition.
      
      Blocking a pack on a property is not the cautious direction. It is a gate nobody can
      pass, which is a gate everybody learns to ignore.
      
      Suite 125 → 128.
      PLN (Algolia) authored
    • feat: cosmicfest prep · eb9221de
      PLN's set work for CosmicFest, plus one new track and three mechanical fixes
      found by the pre-gig gate.
      
      His edits, across 23 tracks: the ^43/^44 -> ^41 control migration continued by
      hand (37857225 retired gMask and freed ^41 for d1's gate), and real musical work
      alongside it — a `resetCycles` head on rose_rouge, new `octersubbus` and
      `squizbus` sends, a `slow 2 $ ply 2` rework of rose_rouge's ^54 line,
      gain/lpf/room adjustments, and sample swaps (`n "74"` -> `n "25"` on the Savoy
      break). New track: live/midi/nova/techno/livecode_parade.tidal.
      
      Three fixes from the gate, none of which move a control number, so nothing
      changes under his hands:
      
      * mute map — d10 in something_about_drums declared gM2; the authored map
        (tools/lcxl_grid.py) puts d4-d12 on gM3. fix-mute-roles.py --apply, then
        re-run to confirm it converged to 0.
      * crushbus 101 in ouais_je_funk was shared by d9 and d10, with DIFFERENT ranges
        (16->1.5 and 16->6) landing on one bus instance — last event wins, so the
        knob turned for one orbit was heard on the other. d9 moved to its own 91 per
        the <orbit><slot> convention.
      * crushbus 41 in ghosts_in_the_toilets was shared by d4 and d7 the same way.
        d7 moved to 71.
      
      pvlint goes 4 errors -> 0 on the setlist.
      
      NOT included, deliberately: the remaining 20 column moves (ghosts 10,
      something_about_drums 7, perfect 2, the_revolution 1). PLN: "migration i
      unfinishe dgrrr lets not finish it before set for now." Renumbering which
      physical knob does what in four tracks he plays tomorrow is a muscle-memory
      change, not a safety fix, and two of the moves cannot resolve mechanically
      anyway (one overflow -> gSel, one FIXME with no slot). gig-up stays honestly
      red on `surface grid intact` until it is finished after the gig.
      PLN (Algolia) authored
    • check-drift: it told me to delete PLN's set the day before a gig · cb5a625a
      check-drift flagged 13 uncommitted .tidal files as "the stale-buffer signature"
      and its remedy section led with `git checkout -- <file>`. I nearly relayed that.
      
      All 13 were PLN's own work: the ^43/^44 -> ^41 gMask-retirement migration
      (37857225 freed ^41 for d1's gate) plus real musical edits — new octersubbus and
      squizbus lines, `slow 2 $ ply 2` reworks, gain 1.4->1.6, a sample change from
      n "74" to n "25". `git checkout --` would have destroyed a set the night before
      the gig, on the advice of a check that was confident and wrong.
      
      The script's own design comment already had the right principle: "drift is not
      control numbers changed, it is control numbers moved AWAY FROM THE GRID". Its
      per-file test just cannot see direction — it counts CHANGED ^NN lines (>= 4) and
      ANDs that with a global grid failure. Both were true here for innocent reasons:
      the grid is unaligned because the migration is HALF-APPLIED, and those files are
      the half that is done. Volume of change cannot separate a repair from a
      regression. Only direction can, and migrate-columns --plan already knows it:
      0 pending moves for a file means its edits ARE the alignment.
      
      So: the DRIFT message now names both hypotheses with the one command that
      distinguishes them, the grid failure reports migrate-columns' pending count
      (20 here) and spells out that UNALIGNED is not REGRESSED, and the destructive
      remedy is behind an explicit "look at the diff first" with this incident as the
      reason. A gate that recommends data loss on ambiguous evidence is worse than no
      gate.
      
      Still exits non-zero, honestly: 9 knobs are genuinely out of column and 20 moves
      are genuinely pending. That is a real finding about an incomplete migration, and
      completing it is a decision about muscle memory that belongs to the person whose
      hands are on the desk — not something to apply the night before a gig.
      PLN (Algolia) authored
    • rig: absent is not broken, and a deep queue is just latency · b503502f
      Two gate/monitor fixes found while clearing gig-up blockers the night before a
      gig.
      
      ABSENT IS NOT BROKEN. check-audio-graph hard-FAILED the moment Ardour's Master
      was on the internal codec, which made the whole pre-gig gate NO-GO while PLN was
      rehearsing on the laptop jack with the UMC in a bag. PLN: "no umc atm playing on
      the jack now. we need to be tolerant to various setups." He is right, and the
      repo already knows the rule — section 3 of this same script downgrades to warn
      when the interface is absent, and rig.py opens with "REPORT ABSENT AS ABSENT".
      Section 2 just never got the memo.
      
      Severity now depends on whether the UMC EXISTS. No UMC on the bus: warn, say
      plainly that the last hop cannot be proven until the interface is plugged in,
      and tell him to re-run at the venue. UMC present and Master still on the codec:
      that is the real regression this check was written for — Ardour restores its own
      saved ports and does NOT follow the PipeWire default sink, so it will happily
      ignore an interface sitting right there. Result: OK (3 warnings) instead of a
      NO-GO on a legitimate setup.
      
      A DEEP QUEUE IS NOT A BUFFER, IT IS A DELAY. PLN: "when i move faders up/down
      quickly, [it] lags by 500ms+, almost 1s, behind ahah".
      
      Chased this to the wrong end twice, worth recording. First guess was fork+exec
      per LED write — measured it: 2.8 ms p50, 8 ms p99, and --bench shows the
      coalescer at p99 18 ms with ZERO overrun, so the LED path was never the problem.
      Faders have no LEDs at all (row D), so a fader sweep does no LED writes; and the
      Pulsar HUD has no MIDI tap, so it cannot lag on MIDI either. Second guess was
      aseqdump block-buffering its stdout into a pipe (the exact bug this repo
      documents for Python) — disproved with an isolated aseqdump client fed by
      aseqsend: median 40 ms inter-arrival at a 25/s send rate, last event landing
      BEFORE the last send. It flushes per event.
      
      The real seam is the Bridge's SSE fan-out: `queue.Queue(maxsize=512)`. A fader
      sweep is ~400 CC/s, so 512 deep is 1.3 s of backlog — and once it fills it STAYS
      full, because drop-oldest holds the buffer AT capacity. Every event the browser
      renders is then 512 events stale, permanently. The drop policy was already right;
      the DEPTH was the latency. 512 was picked as "generous" and is really a latency
      budget nobody priced.
      
      Now 64 (~160 ms at that rate) — jitter absorption rather than a queue. Paired
      with the browser-side coalescing from 72937cc7, the consumer drains far faster
      than it fills, so it should rarely be reached.
      
      Verified: 79/79 bridge tests, check-audio-graph OK on the jack.
      PLN (Algolia) authored
    • fix(cale): a rounded tempo is a stretched loop, and three loops off one stem read alike · 1edeecfe
      Screenshotted the Fred view instead of trusting it, and it showed two things.
      
      The transport read **133 bpm on a 132.5 bpm kit**, because selecting a kit rounded its
      tempo. Harmless in a display, not harmless here: the rack sets `rate = dur / (bars ×
      barlen)`, so 133 stretches every loop in that kit by 0.4% and the mismatch goes straight
      into the audio. Half-integer tempos are ordinary in this corpus. No rounding, and the
      "match this kit" button now compares to 0.01 bpm so it stops offering a value it already
      has.
      
      And `fred_bighen_bass` listed three loops all labelled `bass`, two of them also both
      "8 bars · 132.5 bpm" — the kit is named for the stem, so the stem name distinguishes
      nothing. The manifest already knew `start_s`, so rows now carry `@2:14`: where in the
      source track this loop was cut from, which is the thing you actually want when choosing
      between two loops off one stem.
      PLN (Algolia) authored
    • fix(tools): two scripts were +x on disk and 644 in the index · 4e0d63ae
      gig-up's "tools executable" gate has been NO-GO on this box, and the reason is
      the trap the repo already has a lesson about: chmod fixes YOUR tree, not the
      repo. parvagues-protect.sh and install-protect.sh both ran fine here and were
      recorded 100644, so a fresh clone gets two non-executable protect scripts —
      and the gate that catches it was being read as noise because it never went
      green. Fixed with `git update-index --chmod=+x`, which is the half that was
      skipped. lcxl-path.py (new in 7bda1ccd) had the same defect and gets the same
      treatment.
      
      Note the gate checks BOTH `-x` on disk and the index mode. Only the index half
      was failing, which is exactly the failure a local chmod hides.
      PLN (Algolia) authored
    • docs(armada): log 028 — silence graded A, and three tests that proved nothing · c45fddd0
      Captain's-log entry for the Fred pack and La Cale. The keeper: nobody wrote "prefer
      silence" anywhere. It fell out of five reasonable measures, each correctly
      implemented, none of which had an opinion about whether there was any audio in the
      file. A composite score is a specification of what you want, and every term you
      weight lightly is a loophole.
      
      Second keeper, the one that will save a future session: three tests today went green
      while proving nothing, and each fake test hid a real question. The finder test used
      the stem's own beat grid — which finds no beats inside silence, so no window was ever
      generated there and it passed with the fix disabled. The niceness test looked its
      hook up in the wrong scope, got None, and fell back to a no-op lambda. Break the fix,
      watch the test fail, put the fix back.
      PLN (Algolia) authored
    • test(cale): the scheduler had one musical rule and no way to check it · c9e7558b
      `Deck.scheduleBar` mixed the decision (what plays, when) with the audio nodes, so the
      only rule in the rack with real musical consequence could not be tested without a
      browser. Extracted as `hitsInBar(voice, barIndex, bpm, swing, bufDur)`.
      
      The rule is that a bar-loop starts only on a bar that is a multiple of its own length.
      Break it and the rack still makes sound — it plays a 4-bar loop's bar 1 underneath the
      phrase's bar 4, so the groove turns around in the wrong place. That is not a crash, it
      is a musical wrongness you would find mid-set and probably blame on the sample.
      
      11 cases, including the ones I would otherwise have got wrong: a negative bar index
      when the transport is nudged backwards, a phrase that runs past bar 8, a cleared cell
      at the second entry of a 4-bar loop, and the two asymmetries between the modes — a
      bar-loop is stretched to the DECK tempo (that is what layering means) while a chop
      never is (a stab is a stab at 90 or 174), and a chop is unconstrained by stride
      because it is sub-bar by definition.
      
      Confirmed the stride tests fail when the rule is disabled: 2 failed / 22 passed.
      Suite 13 → 24.
      PLN (Algolia) authored
    • gig-up: the gate that could not fail, and the two units nobody started · 7bda1ccd
      Follow-up to 72937cc7. Having found that the Bridge panel could not see the LED
      watcher, I went looking for what else was watching the surface. Nothing was —
      and one of the things that claimed to be could not fail.
      
      THE FALSE-GREEN GATE. The pre-gig check for "LCXL -> SC" was:
      
          aconnect -l | grep -A3 "Launch Control XL" | grep -q "128:"
      
      `grep -A3` matches each of the THREE lines containing "Launch Control XL" and
      prints three lines after each; the last window runs off the end of the LCXL
      block and into the next client header, which is literally
      `client 128: 'SuperCollider'`. So the string "128:" was in the haystack whether
      or not a single wire existed. This gate has been passing since it was written,
      for a reason unrelated to what it measures. It would have said green with the
      desk connected to nothing.
      
      Proved rather than argued: recorded the live graph, stripped only the
      Midi Through -> SC edge from it, and ran both gates against the doctored file.
      Old gate PASSED. New gate reported "LCXL -> Midi Through, but Through does NOT
      reach SC" and exited 1.
      
      tools/lcxl-path.py replaces it by walking the graph. It has to: the real path
      here is TWO hops (LCXL 20:0 -> Midi Through 14:0 -> SC in2), so a direct-only
      test would be wrong in the other direction. It takes an optional recorded-graph
      file argument precisely so the gate can be tested for FAILURE without
      unplugging hardware at a venue — the reason the old one was never caught is
      that nobody could cheaply watch it fail.
      
      TWO UNITS NOBODY STARTED. lcxl-leds-watch and midi-autoconnect were both
      `disabled`, so neither came up at login; they ran only when started by hand.
      Both now join the auto-start loop.
      
      A NEW GATE. "The surface is wired" and "the surface is lit" are different
      daemons and different failures, and only the first was ever checked. The new
      "LCXL LEDs" soft check reads: if the board is on the bus, the watcher must be
      active. Deliberately conditional on the board — at a kitchen table an unplugged
      desk is normal and must not warn, but at the venue this is the difference
      between playing blind and not.
      
      Verified both new gates in BOTH directions: green as the rig stands, and red
      with the watcher stopped / the Through edge cut. A gate observed only passing
      is indistinguishable from the one this commit deletes.
      PLN (Algolia) authored
    • docs(foundry): board — #21 landed, and the six discarded runs are the record · 2c0718aa
      The Fred section now names every run that was thrown away and what found the fix,
      because that list is the actual work: 161 samples (dead bars), 153 (rotation), 153
      (24 of them digital silence graded A/B), aborted (staging root), 129 (silence
      filtered at export only, so BIGHEN's tonal kit lost three loops instead of gaining
      three real ones). Run 6 is the first where the finder itself refuses a silent window.
      
      #21 moves to landed with its commands and its three honest gaps — the Strudel tab is
      text-only, swing is in the engine but not the UI, and a rack cannot be saved as a
      named preset.
      PLN (Algolia) authored
    • fix(foundry): a background job that takes the desktop down is not a background job · 9b832eb5
      PLN, mid-cut: "you're hogging the system :(". Nine children at ~88% of a core each
      put the load average at **15.6 on 16 cores**, and the machine he performs and edits
      on became unusable. My fault twice over — the default was one subprocess per track
      regardless of the box, and nothing told the scheduler this work was background.
      
      Both fixed at the source rather than by remembering to pass a flag:
      
      * `--jobs` defaults to **half the cores, capped at 6**. It is the total that matters,
        not the track count, and each child peaks near 2 GB.
      * Children start at **nice 19 and SCHED_IDLE** via a preexec hook, so they only run
        when nothing else wants the CPU. The nine still saturate the sixteen cores; the
        editor now wins every scheduling decision, because nothing waits on this work but
        me. (The live run was reniced in place rather than killed — eight minutes of
        analysis is not worth a restart.)
      
      The test for that hook is worth a note, because the first version of it was fake: it
      looked the hook up through `_fan_out.__globals__`, where a nested function does not
      live, got `None`, fell back to a no-op lambda, and passed. Same shape of mistake as
      the finder test earlier today. So `run_at_idle_priority` is module-level now and the
      test asserts on a **real child's** reported nice and scheduling policy — verified to
      read `0 0` without the hook and `19 5` with it.
      
      Also in here, from the same run: the presence gate I added to the finder was doing a
      full `max(abs(window))` per candidate — at 8 bars that is ~700 k samples times
      thousands of windows, which is most of why this run was slower than the last. It now
      reads a per-beat peak table computed once per stem. Exact, not approximate: the
      inter-beat segments partition `[int(times[i]*sr), int(times[e]*sr))` precisely, so it
      visits the same samples — with a test that checks the two agree for every window
      rather than trusting the argument.
      
      And `kitgate --link` now takes each kit's staging root from the manifest instead of
      `publish.samples_root()`. A pack cut with `--samples-root` lives beside its source, so
      the default root would have looked in the wrong place and called every kit missing.
      
      Suite 122 → 125.
      PLN (Algolia) authored
    • lcxl: the panel could not see the daemon that lights the board · 72937cc7
      PLN, the night before a gig: "i dont see midi feedback visuel anymore on the
      LCXL fix this first plz". The hardware was fine. The daemon was gone.
      
      The journal had the whole story and nobody was reading it:
      
          lcxl-leds --watch: no LCXL sequencer port; retrying in 30s   (x8)
          Stopping LCXL LED watcher...
          Stopped LCXL LED watcher.
      
      The board was unplugged, the watcher retried, then it was STOPPED — and
      `Restart=always` does not resurrect a unit somebody stopped. On replug the
      board came back and the daemon did not. Three independent holes let that
      become a silent, session-long dark surface:
      
      1. `lcxl-leds-watch` was MISSING from rig.py's SERVICES table, so the Bridge
         panel showed a fully green rig over a dark board. That is precisely the
         failure the module's own docstring opens with ("A GREEN UNIT IS NOT SOUND"),
         one table row away. It was also in neither gig-up.sh nor converge: NOTHING
         on this box checked whether the surface was lit.
      2. The unit was `linked`, not `enabled` — it never started at login. It only
         ever ran because something started it by hand.
      3. `midi-autoconnect` was `disabled` too. The wiring it enforces
         (LCXL 20:0 -> Midi Through 14:0 -> SuperCollider in2) happened to be intact,
         so knobs still worked and the fault stayed invisible. But the prescribed fix
         for the LED stall IS a replug, and a replug drops those connections with
         nothing to re-apply them. The two failures compound: the remedy for one
         silently triggers the other, mid-set.
      
      Fixed: both units enabled, and the watcher is on the panel with a state that
      `active` cannot express. It retries forever by design, so "alive" is not "lit" —
      _leds_state() cross-checks the board via procfs and reports the third case
      honestly. No board is ABSENT, not broken; board present with the daemon down is
      "plugged in but DARK, start this", the one combination that was silently wrong.
      All five services now read green for a reason each, not by omission.
      
      Also, the daylight ramp. PLN: "maybe brightness can be leveraged, top
      brightness always would make more readable signals even in day perfs." The
      six-step unipolar ramp spent THREE steps on dim shades — exactly the budget
      that vanishes outdoors. The replacement keeps five steps and puts every one at
      full brightness by using the two mixed hues the old ramp never touched:
      
          red 15 (g0,r3) -> orange 31 (g1,r3) -> amber 63 (g3,r3)
                         -> yellow 62 (g3,r2) -> green 60 (g3,r0)
      
      Every value has a component at maximum, so nothing depends on a brightness
      difference to be legible. It costs one step and gains three readable ones —
      the same trade he already made for the DJ filters in July ("i agree on clarity
      > resolution"), which have been all-full-brightness ever since. This just
      brings the unipolar knobs in line. LCXL_DIM_RAMP=1 restores the old ramp, so
      a dark-stage revert is one env var and a restart, not a code change.
      
      And the gear's MIDI monitor: "super noisy ... cant we do way more dense".
      "Control change" spent 14 characters saying "CC", and a single fader sweep
      prepended ~100 near-identical rows. Events now abbreviate (CC/ON/OFF/PB/...)
      and consecutive events from the same control coalesce into ONE row that
      updates in place with a x-count — a 100-event sweep is one line, one DOM
      write per event, no node churn. Row height 20px -> 15px on top of that.
      
      Verified: 79/79 bridge tests pass; /api/rig reports all five services up with
      per-service proof; ramp asserted to have max(g,r)==3 at every step.
      PLN (Algolia) authored
    • docs(ui): the README described a template, not the bridge — and fix what the screenshot showed · e2b701ea
      `armada/ui/README.md` was still the stock `npm create vite` text after four pages had
      been built in it, so the one file a cold reader opens first said nothing true. It now
      lists the five pages and what question each answers, the commands, and the two things
      that are easy to get wrong: audio is *mounted* through `audio-mounts.json` (read by
      both vite dev and serve.py, so a URL works in both — a UI that only works under `npm
      run dev` is a demo), and `kits.json` is generated, uncommitted, and written to `dist/`
      as well as `public/`.
      
      The `packs/` README now says where a pack's audio is staged, since it is no longer one
      place: kits cut from someone else's stems live beside their source
      (`Samples/Fred/output/<kit>/`, `--samples-root`) so the pack stays one movable,
      deletable thing, while ParVagues' own kits sit directly under `Samples/`. The bank name
      is the directory name either way, so `s "<kit>"` cannot tell the difference.
      
      Then I took a headless screenshot of the page instead of assuming it rendered, which
      found three things a typecheck cannot: a legacy kit's filenames are long enough to
      truncate with nothing to recover them from (now `title`), the flag filter rendered as a
      dead `! 0` on every kit with no flags (now hidden), and the empty-rack text ran the
      full width of the screen.
      PLN (Algolia) authored
    • fix(foundry): the finder spent its candidate slots on the rests · bb404419
      Rejecting silent loops at export was the right fix in the wrong place. BIGHEN's
      strings stem plays in two sections and rests between them, and all six of the
      windows the finder ranked highest sat inside a rest — so filtering at the gate took
      `fred_bighen_tonal` from four silent loops down to one real one, when it should
      have gone to four real ones. `fred_bighen_fx` disappeared entirely for the same
      reason.
      
      The composite is degenerate on silence in the finder too, and for the same reason
      it was in the grader: a rest is maximally self-similar in the SSM, its seam is
      immaculate, its zero crossings are trivial. So presence is checked before ranking,
      on the window itself, sharing `grade.THRESH["empty_dbfs"]` by import so the two
      stages cannot disagree about what silence is.
      
      The test for this took two attempts, and the first one was the interesting one. A
      stem gated on its own beat grid CANNOT reproduce the bug: `beat_grid` finds no
      beats in the silence, so no window is ever generated there, and the test passed
      with the gate disabled — proving nothing. The bug needs a grid derived from ANOTHER
      stem, which is exactly how `stempack` calls `analyze_stem` and exactly why it does
      so: sharing a rhythmic track's grid gives every stem windows across the full
      duration, including the parts where that stem is not playing. Verified the test now
      fails with the gate off and passes with it on.
      
      Suite 119 → 122.
      PLN (Algolia) authored
    • chore(cale): gitignore the generated index, and drop the dep nothing imports · 18ec7b4b
      `public/kits.json` is a 1.2 MB snapshot of audio that lives outside the repo, and
      `.kitindex-cache.json` is derived from file mtimes — both regenerate in seconds and
      neither belongs in history.
      
      @strudel/web went in while working out whether the sequencer should be a Strudel
      playground. It should not: the deck already plays the rack with `loopAt` semantics,
      and a second audio graph would only add a way for the two to disagree about what
      the same cells mean. The Strudel tab emits text for strudel.cc instead. So the
      package comes back out — a dependency nothing imports is a false statement about
      what the project needs, and it would have shipped in the lockfile forever.
      PLN (Algolia) authored
    • feat(cale): a page for hearing whether a kit is any good · ec200263
      PLN asked for an SPA to audition the cut samples, "click-based looping not just
      strudel coding". La Cale — `armada/ui/kits.html`, the hold, where the cargo gets
      checked before it goes on stage.
      
      The reason it needs to exist is that a grade in a manifest is a claim, and this
      week the claim has been wrong twice in ways no report caught: a loop that fades to
      silence grades S, and 24 loops of pure digital silence graded A. Both are obvious
      in two seconds of listening and invisible in a table.
      
      **Auditioning is two different tests, so it is two buttons.** Play-once gives you
      the file untouched — attack, tail, what it actually is. Loop plays it seamlessly at
      rate 1, which is the only way to hear the seam, and the seam is the thing that
      decides whether a loop is usable because it is the sound the audience gets every
      bar. One AudioBufferSourceNode with `loop = true`, so drift cannot exist and
      nothing is resampled between the file and the speaker.
      
      **The rack is where a kit is actually judged**, because the questions that matter —
      do these two loops sit together, does this vocal survive on that break, is the bass
      really at this tempo — cannot be answered one file at a time. It schedules every
      voice from a look-ahead clock against an absolute grid, at
      
          rate = buffer.duration / (bars * 4 * 60 / bpm)
      
      which is exactly `loopAt bars`. So the layering you hear is the layering the rig
      plays, and the exported block is not an approximation of the session — it is the
      same two numbers in Tidal's syntax. Scheduling per bar rather than letting nodes
      free-run matters more than it sounds: the cutter's bar tolerance is 1 ms, which is
      60 ms of skew after a minute.
      
      The grid means what Tidal means. A bar-loop row has cells only on multiples of its
      own length — a 4-bar loop in an 8-bar phrase has two entries, not eight, because
      firing it on bar 3 plays its bar 1 under the phrase's bar 4 — and those cells
      become `mask "t f"`. A chop row is 16 steps in a bar and becomes `s "x ~ ~ x …"`.
      
      Three things the export gets right that are easy to get wrong, all pinned by tests:
      the fader is linear but Tidal's gain is quartic, so 0.5 exports as `gain 0.84` and
      copying it across would be a 12 dB error; families land on their own `dN` with no
      two voices sharing an orbit; and the mask samples the grid on the loop's stride,
      not every bar, which otherwise silences an entry that is switched on.
      
      Deliberately not wavesurfer, though the project already depends on it: a kit page
      shows a hundred rows, and a hundred instances means a hundred fetches and decodes
      to draw something 200 px wide. `kitindex.py` precomputes a 128-point envelope per
      sample, so a row costs one `<svg>` and no network — audio is fetched only when
      something is played. Same ship-the-index instinct as the tide-table.
      
      `kitindex.py` merges two sources on purpose. The FOLDER is the truth about what
      exists (via `pvbanks.playable`, so it agrees with kitgate and the watcher), which
      is why the 111 kits that predate the Foundry are browsable too. A cut MANIFEST is
      the truth about what a file MEANS — tier, family, bars, bpm, CLAP tags — and none
      of it is re-derived here, so the page and the gate cannot disagree. `n` comes from
      the folder listing, never the manifest order, because `n` is what SuperDirt plays.
      
      Two staleness traps closed while building it: the envelope cache is keyed on
      path+size+mtime, so a restaged kit misses it (a stale envelope over fresh audio is
      a lie that looks like data), and the index is written to `dist/` as well as
      `public/`, because `npm run build` copies public/ once and the LAN page would
      otherwise show an index from before the last cut.
      
      Also: the dev server hardcoded `Content-Type: audio/flac` for everything under
      /audio. The masters are FLAC; the kits are WAV. Now by extension, matching
      serve.py's `guess_type`.
      
      13 vitest cases on the export; `npm test`. Verified end to end through the real
      path — `serve.py --dir ui/dist` returns 200 on /kits.html and 206 `audio/x-wav`
      on a Range request through the new `samples` mount.
      PLN (Algolia) authored
    • tray: the gearbox gets the one face PLN actually looks at · b893a06d
      PLN, 2026-08-21: "i dont use the webui most of the time, tray indicator is the
      only ui i use for parvagues gear bro". The gearbox shipped with an engine, a CLI
      and a web panel — and its one everyday surface still said Silent/Cool/Standard/
      Extreme/Normal, still wrote governor/EPP/platform_profile itself through
      `sudo perf-audio`, and was DISABLED at login. So the surface he uses was the one
      surface that had not moved.
      
      What this does
      
      * tools/bridge/gearbox.py — the client both faces speak. Names the three gears
        and the three trims once, reads the persisted state unprivileged, and builds
        the argv for a switch. rig.py's gear() now delegates to it, so the tray and
        the web panel cannot drift on what a gear is called.
      * perf-tray.py — the mode list is gone. In its place: gear (off/standard/
        aggressive) and trim (quiet/auto/cool) as two INLINE radio blocks under their
        own headers, because the separation is the design. A reader who sees one flat
        list keeps believing there is a single "how fast" dial and that quiet and cool
        are two words for it; two headers teach the model every time the menu opens.
      * It no longer writes any thermal knob itself. Switching goes `sudo -n` to the
        arbiter, which is the point — a tray reaching around it would rebuild the
        four-writer mess GEARBOX.md removed, in one click.
      * The desired-mode bookkeeping is gone with it. The gearbox persists the gear and
        re-asserts it from a udev hook on power-source changes and a system-sleep hook
        on resume; a second bookkeeper in the tray could only ever disagree.
      * The checked gear is read from the FILE, never inferred from sysfs. Inference
        (perf.detect_mode) cannot tell a deliberate gear from power-profiles-daemon
        having moved the same knobs, which it does by design since the gearbox
        cooperates with ppd rather than masking it.
      * "Gear ▸" (the apps and services) is now "Rig ▸": gear is the thermal lever
        now, and the web panel already calls that collection the rig.
      * perf-tray.service was never tracked — it lived only in ~/.config. Now in the
        repo, so the unit and the script it runs can no longer drift apart.
      
      The icon carries the lever, so it reads with the menu shut
      
      Width = gear, colour = trim, drawn as the badge's border. One element for two
      axes because the panel renders this at ~22 px and any design with two separate
      marks turns to mush there. Width is the cue that has to survive a dark room and
      a peripheral glance ("am I in the gig gear?" is the mid-set question), so gear
      gets shape and trim, which is set-and-forget, gets colour. Off draws no ring: a
      bare badge is the honest picture of nothing steering the machine.
      
      First attempt used 2 px / 5 px, which looked right in a mockup and VANISHED on
      the panel — 64→22 px scaling turns a 2 px stroke into 0.7 px of grey. Rendering
      every combination at 20/24/32 px before believing it gave 6 px / 11 px, and then
      forced the temperature numeral and the sparkline to shrink with the ring, since
      an 11 px border over a 30 px numeral clips the digits at exactly the size where
      aggressive is the gear whose temperature you most want to read.
      
      Verified on hardware, through the real menu over its D-Bus interface (Plasma's
      own protocol), not by calling the Python directly:
      
        * live tooltip reads "gear: Standard · Cool · cap 15/35W"
        * the menu exports two radio groups, correctly checked, headers live
        * clicking Aggressive -> GEAR=aggressive, RAPL 45 W / 115 W
        * clicking Standard   -> GEAR=standard, back to 15/35 W
        * clicking Cool       -> TRIM=cool, fans 0 -> ~2900 rpm
        * a CLI-side gear change is picked up by the tray within one refresh
        * enabled and running under graphical-session.target, so it survives logout
      
      Harness caveat worth writing down: the very FIRST dbusmenu Event sent to a
      never-opened menu landed on the wrong item (asked for Aggressive, got trim Auto).
      Every subsequent one mapped exactly, from both dbus-send and gdbus. Reading it as
      provisional ids on a menu Qt has not yet had to show — a human cannot click an
      item without opening the menu first, which is what the later probes did.
      
      18 tests for gearbox.py (79 in the bridge suite, all green). They defend two
      things, neither arithmetic: the sudoers contract (the argv IS a security
      boundary — `--trim cool` must stay two words, and `performance`/`--boot`, both
      real helper verbs, must never be reachable from a face that only knows gears),
      and the honest-unset rule (never-engaged is not the same state as off, which is
      why off had to become an explicit gear).
      PLN (Algolia) authored
    • fix(foundry): silence was the grader's optimum — 24 of 153 loops were empty · f9a9ce53
      kitgate blocked the Fred pack and the reason turned out to be much worse than the
      flag it raised. Measuring the flagged files: 24 of 153 shipped loops have a peak
      between **-104 and -75 dBFS**. They are not quiet. They are digital silence. And
      they graded **0.707 to 0.896 — tier A and B**.
      
      The mechanism is that every sub-score in the loop rubric is vacuously perfect on
      nothing. The seam between two silences is immaculate. The DC of silence is exactly
      zero. Its zero crossings are trivially fine. Its bar length is whatever you cut it
      to, so it is bar-exact by construction. Level carries 10% of the composite, so a
      silent window scores 0.90 × perfect + 0.10 × nothing ≈ 0.90 and comes out tier A.
      The composite had a **degenerate optimum at "no audio at all"**, and the finder,
      doing exactly its job, walked into it: `fred_marea_bass` shipped three loops and
      all three were silence, from a stem measuring -22 dBFS RMS at 54% activity.
      
      Nothing downstream could catch it either. `_has_dead_bars` compares bars to EACH
      OTHER, so it finds a loop that fades out and is blind to one that was never there —
      a uniformly silent clip has a perfectly flat bar profile. This is the same trap as
      the fade-out loops grading S, one level deeper.
      
      So presence stops being a weighted term and becomes a **precondition**: peak below
      `empty_dbfs` ⇒ flag `empty`, grade 0.0, tier D, and the existing MIN_TIER filter
      drops it at export. The floor is -60 dBFS, and it is placed rather than tuned: the
      pack's peaks run -104…-75 and then jump to -47.1, a **28.4 dB empty gap**, so -60
      sits in clean air with nothing near it.
      
      Three details the measurements forced:
      
      * **Presence is measured on the CHANNELS, not the mono sum.** An anti-phase stereo
        pair sums to exact zero; calling that "empty" would both mis-report it and
        suppress the mono-incompatible flag, which is the one useful thing to say about
        it. (Caught by an existing test, which is why it is an existing test.)
      * **`near-silent` (RMS < -45) is advisory and now excludes `empty`.** RMS on sparse
        material measures how much silence it contains, not whether it is silent — a hat
        loop peaking at -14.7 reads -48.8 RMS. Two questions, two lenses.
      * **`mono-incompatible` no longer fires on silence.** Two independent noise floors
        are uncorrelated, so silence reads as maximally mono-incompatible; four of
        MAREA's flagged vox loops were simply empty.
      
      `kitcheck` asks the presence question too, from its own code path, sharing the one
      constant by import so the two lenses cannot drift apart.
      
      Also: restage was not idempotent. Export stages in float and writes PCM_24, so the
      peak read back off disk misses the ceiling by a quantization step, measures a hair
      hot, and asks to be staged again — by -0.00003 dB, every run, requantizing every
      file in the kit each time. `DEAD_BAND_DB = 0.05` (inaudible, four orders of
      magnitude above the quantization floor). The dry run now reports 0 of 42 kits,
      which is what it was supposed to say in the first place.
      
      7 tests; suite 115 → 119.
      PLN (Algolia) authored
    • feat(foundry): the gate now checks that `n 3` plays what the cheat sheet says · bfc17e73
      The cheat sheet numbers `n` by enumerating the manifest sorted by filename.
      SuperDirt numbers it by enumerating the *folder* sorted by filename. Those two
      agree only for as long as the folder holds exactly the manifest's files — and
      `export_track` writes into a kit dir it never empties.
      
      So re-cutting a pack is quietly unsafe. If a loop that was `00_kick_2b.wav` last
      run comes out as `00_kick_4b.wav` this run, both files are on disk, the folder
      listing gains an entry at position 0, and every index from there on addresses the
      wrong sound. Nothing else notices: the manifest is right, the grades are right,
      every file is a good loop, and the cheat sheet you paste from looks correct. You
      find out on stage.
      
      Noticed while looking at the run-3 kits and asking whether runs 1 and 2 had left
      anything behind (they hadn't — all 58 files carried run-3 mtimes). The absence of
      the bug today is not the absence of the bug.
      
      check_indices compares the two listings **index for index**, not as sets, so it
      distinguishes the two severities the report needs to separate: an orphan that
      sorts last pollutes the bank but shifts nothing, while one that sorts early
      breaks addressing from that point on — and it names the index where it breaks.
      `--prune` deletes what the manifest does not list, scoped to manifest kit dirs
      and to audio files only. Listing comes from `pvbanks.playable`, so "what
      SuperDirt will load" keeps one definition across kitgate, bank-check and the
      watcher — a checker and a loader that disagree here is the whole failure mode.
      
      3 tests (clean / shifting orphan / trailing orphan); suite 112 → 115.
      PLN (Algolia) authored
    • docs: board + archive for the stem-pack batch work · 7232003f
      #20 lands; #21 (auditioner SPA), #22 (acapellas + demucs catches), #23 (audit the existing
      163 kits) opened with enough detail to start cold. The Fred pack's finish sequence is
      written out step by step because the final run is still in flight.
      PLN (Algolia) authored
    • docs(armada): log 027 — the four findings that came from measuring the output · 3ec21fc9
      Periodicity measuring length then role, dead bars grading S, no headroom against amp=gain^4,
      and 1 loop in 6 opening on a weak beat. Plus the two bugs the checks caught in my own work,
      which is the argument for having them.
      PLN (Algolia) authored
    • fix(foundry): one rotation pass leaves 1 loop in 6 starting on a weak beat · 12fd5bb7
      Chasing "does a shipped loop actually start on its downbeat" and nearly filing a false
      alarm on the way. First measurement said 17% — but it scored 16 slots with no full-band
      term while the rotator scores bars*4 slots WITH one, so the disagreement was the
      instrument's. Factored the scorer out (_downbeat_slot_scores) so the rotation and its
      verification cannot use lookalike measures, and re-measured with the real one.
      
      The bug is real and smaller than the first number: over 60 shipped bar-loops, slot 0 was
      the best slot in 29 and in the WORSE HALF of slots in 10. Median miss was 0.032 — i.e.
      usually a near-tie — but one loop in six genuinely opens on a weak beat, which is exactly
      the "timed ok but cut BCDA/DABC" complaint from 2026-07-11 that this function was written
      to fix.
      
      Mechanism: the roll point is zero-crossing snapped by up to ~10 ms against a 20 ms attack
      window, and between two near-tied slots that is enough to flip which one wins. On clean
      synthetic material rotation is idempotent (pass 1 finds slot 4, pass 2 finds 0); on real
      music the tie-breaking is fragile.
      
      So it now checks its own result and repeats, up to 3 passes, keeping a pass only if the
      measured miss improves. Safe because rotation is free: two rolls compose into one and the
      guard cannot oscillate. Best slot 29 -> 46 of 60, worse half 10 -> 1, median miss
      0.032 -> 0.000.
      
      Default is 3 rather than opt-in: it is strictly better and applies to every caller,
      including export_take, so the GUI's forge gets it too. 112 tests.
      PLN (Algolia) authored