1. 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
  2. 21 Aug, 2026 39 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
    • docs(foundry): packs/ holds the manifest, not the audio · 3e26a735
      Provenance for a cut that shipped: which stem each loop came from, at what tempo, graded
      how. The audio stays in Samples/ — this is the record, not a copy.
      PLN (Algolia) authored
    • fix(foundry): the report section I added never rendered, and hits vs drums was a false positive · b2c90154
      Two bugs in one small feature, both mine.
      
      The 'worth a second listen' table was appended by a str.replace whose anchor did not
      match, and I had not asserted on that one — so the code was present, the function
      computed 39 rows, and the report silently shipped without them. Every other patch in this
      file asserts its anchor; this is why.
      
      And the comparison was at the wrong level. A `hits` kit IS percussion, so CLAP hearing
      'a kick drum' in one is agreement, not contradiction — comparing family names instead of
      their underlying stems reported every kick and snare in the pack as suspicious. Same for
      fx/tonal/vox, which all map to `other` and genuinely cannot contradict each other.
      
      Also adds --report-only, which rebuilds the report and cheatsheet from the manifest. The
      manifest is the record and those are views of it, so anything that legitimately changes
      the audio after a cut (restage staging a kit and re-grading it) leaves them describing
      numbers that are no longer true — and re-cutting a whole pack to refresh a markdown table
      would be absurd.
      PLN (Algolia) authored
    • fix(foundry): the manifest described audio that was never written · 34e01673
      kitgate re-grades the shipped files and compares against what the finder recorded, on
      the principle that two code paths reaching the same verdict is worth more than one path
      asserting it. It immediately caught my own bug: staging applied the kit gain at write
      time, AFTER grading, so the recorded grade described the pre-staging clip. Several
      sub-scores have a level term, so the two differ — 0.875 recorded against 0.925 on disk —
      and the gate correctly called that a defect.
      
      Staged clips are now re-graded before the manifest is written, so the number stored is
      the number you get by grading the file. Selection still runs on the pre-staging grades,
      which is fine and worth stating: the gain is uniform across a kit, so it cannot reorder
      its members.
      
      This is the second time the cross-check has paid for itself in a day — it also caught the
      stale manifest after restage. A checker that only ever agrees with the thing it checks is
      not earning anything.
      
      Also: the report now lists cuts whose CLAP instrument tag contradicts the kit they landed
      in. Nothing is re-filed on that basis. For a stem whose filename token is unrecognised
      there is no producer label to fall back on, so it lands wherever the coarse measurement
      pointed — MAREA's `marea` stem went into the drums kit while CLAP hears a spoken voice.
      CLAP is the one signal here that can separate a sung line from a keyboard, so it is used
      to say what to audition first, not to decide.
      
      24 tests.
      PLN (Algolia) authored
    • docs(foundry): kitgate/restage in the quickstart, and the two periodicity caveats · 8cf1e841
      Anyone reading a periodicity number needs to know it was length-biased before the fix
      and that its floor is per-role — otherwise the vox column looks like a disaster.
      PLN (Algolia) authored
    • feat(foundry): stage a kit's level, because amp = gain^4 · 75e4ec16
      These are stems from a loud commercial master — MAREA's ALL DRUMS and KIT both peak at
      +0.2 dBFS — so a loop cut from them arrives at full scale. grade already calls that
      clipping, and SuperDirt cannot use it: amp = gain^4, so `# gain 1.2` is +7.6 dB and the
      orbit clips the moment you touch it. The house rule is peaks at -6 dBFS at the source and
      let the amplification happen downstream, so that is where kits land now.
      
      Two decisions inside kit_headroom_gain that matter more than the ceiling:
      
      NOT per file. One scalar for the whole kit. Normalising each file on its own would make a
      sparse hat loop exactly as loud as a full drum bus, throwing away the balance that two
      loops from one kit otherwise have for free — the reason to cut a kit from one track in
      the first place.
      
      NEVER boosts. A kit already below the ceiling is left exactly where it is. A quiet stem
      is quiet on purpose; lifting it amplifies its noise floor and discards real information
      about the arrangement.
      
      restage.py applies the same function to kits already on disk, so a kit restaged after the
      fact is bit-identical to one cut with it in place. Useful beyond this pack: hand-cut kits
      carry whatever level their source had, and there are 163 kits in Samples/.
      
      It also re-grades the manifest for anything it changed. Whoever moves the audio owns the
      record of it — every grade has a level term, so a stale manifest describes bytes that no
      longer exist, and kitgate compares the two precisely to catch that drift. Verified on the
      synthetic pack: clipping cleared, two drum loops A -> S, and the gate then caught the
      stale grades until restage started writing them back.
      
      23 tests in the file.
      PLN (Algolia) authored
    • feat(foundry): kitgate — one gate between cutting a pack and loading it · e6e0ab78
      Four checks, run before anything is linked into Dirt-Samples.
      
      grade is deliberately re-run over the WRITTEN FILES rather than trusted from the
      finder's in-memory scoring: same verdict by a different code path, so a disagreement
      means one of them is wrong, which is the entire reason to run it twice.
      
      Bank shadowing is the one that would hurt silently. loadSoundFiles defaults to
      appendToExisting = false, so a new folder whose basename matches an existing bank frees
      that bank's buffers and takes the name — no error anywhere, and the only symptom is that
      an old track now plays the wrong sound. Checked BEFORE linking, through tools/pvbanks.py
      so this and bank-check.py cannot disagree about what a bank is.
      
      Rights is advisory and never blocks the load, because loading a kit locally is not
      releasing a track — but it does say out loud when a kit cut from someone else's stems
      is not yet ledgered.
      
      Self-test on the synthetic pack immediately blocked on clipping in the fixture, which
      is the fixture's fault and the gate's point.
      PLN (Algolia) authored
    • fix(foundry): the periodicity check was measuring loop length, then role · 0c140b17
      Two errors stacked in one metric, both found by looking at its distribution instead
      of its verdicts.
      
      **It penalised short loops for being short.** Autocorrelation at lag k has only N-k
      overlapping frames, but the score was normalised by ac[0], which sums all N — so the
      number was partly a function of how much of the loop the lag consumed, and a 1-bar lag
      consumes half a 2-bar loop against an eighth of an 8-bar one. On a perfectly periodic
      click track the biased estimator reports 0.418 / 0.623 / 0.754 at 2 / 4 / 8 bars for
      IDENTICAL material. Dividing by the per-lag overlap count first: 0.834 / 0.830 / 0.862,
      flat as it must be. Corpus medians moved 0.12 -> 0.24 (2 bar), 0.31 -> 0.41, 0.43 -> 0.49.
      
      **And then it asked every role the same question.** Split by family across 81 bar-loops:
      drums 0.754, fx 0.549, tonal 0.214, bass 0.173, vox 0.087 — an 8.7x spread end to end.
      A drum loop that does not repeat at the bar is broken. A sung phrase that does not repeat
      within itself is a sung phrase, and a held chord is a perfectly good loop with no onset
      period at all. One threshold across all of them condemned 12 of 20 vocal loops for the
      crime of being vocal. Floors are now per family, and vox is measured-but-never-flagged.
      
      Also: dead bars are now rejected at EXPORT rather than reported afterwards. A 4-bar loop
      whose last two bars are a fade grades S — silent-to-silent is a perfect seam and the
      length is still an exact bar multiple — and 10 of the first 161 cuts were exactly that.
      An objective defect with an objective test belongs in the gate, not in a report someone
      has to read.
      
      First full run for the record: 161 samples, 43 kits, ZERO bar-length errors and 161 of
      161 distinct sounds, so the bar-multiple guarantee and the export dedup both hold at
      scale. 21 tests in this file, 104 in the suite.
      PLN (Algolia) authored
    • docs(foundry): log 027 — the label and the lens · ee41a29b
      Two ways to be wrong about a stem, pulling opposite directions. Plus the README
      section for stempack/kitcheck and the thresholds, all picked from measured data.
      PLN (Algolia) authored
    • fix(foundry): gate onset density on envelope crest — a steady sine read as drums · b8854723
      librosa's peak-picker finds peaks in whatever onset envelope it is handed, flat ones
      included: a steady 50 Hz sine yields 84 onsets over six seconds (14/s) and was being
      filed as drums, which made the bass branch unreachable for exactly the signal it was
      written for. Onset count is now gated on the frame-RMS crest — ~1.0 for a sustained
      tone against 3.1-9.5 for anything struck.
      
      Measured across MAREA/ANGIE/ME to pick the threshold rather than guess it. Worth
      recording that crest is a SPARSITY measure, not a percussion one — MAREA's single
      vocal phrase reads 17.98 — so it only ever gates onset density and is never used
      alone. HPSS was tried first and rejected: a kick-heavy drum bus reads 0.094
      percussive, because HPSS hears a modern kick as harmonic.
      
      18 tests.
      PLN (Algolia) authored