1. 29 Aug, 2026 4 commits
    • feat(tide-table): weight sample evidence by 1/df — PLN's ears found two tracks my reporting hid · e5c604dc
      PLN, on hearing a rose_rouge sample inside a seam I had labelled quand_on_decolle:
      "where is our samples mathcing? gosh, i should not do so excruciating details
      myself. you have all the data bro." He was right. I had 86 offset-consistent
      detections and printed 4, because `locate` reported each bank's single BEST window
      and threw away temporal extent — which is the informative part.
      
      What was hiding in data I already had:
        rose        -> rose_rouge            22:35-24:00 (max cons 0.81), and on to 31:30
        movie_paris -> something_about_drums   6:15 (0.62), 10:30 (0.49)
        take5       -> take_5_drops           32:10-35:30
        love_parade -> livecode_parade        55:20-56:10
      
      So rose_rouge is continuously present 22:35-31:45 — a stretch I had split into
      quand_on_decolle, blue_gold and UNRESOLVED. And something_about_drums sits in the
      5:04-11:33 hole I reported as unclaimed, exactly where PLN said he played it
      ("im sure i played something about drums between bombe and do it right").
      
      Then his second point, which is the principled fix: "its almost tfidf we should do
      here. when we know rose sample is around a time slot, its a tell. when we see e.g.
      _jungle_BreakS_, it's way less idf relevant."
      
      Implemented as `fuse`: evidence for track T at time t is the sum, over detected
      banks that T uses, of offset-consistency divided by that bank's df. A bank in one
      track hands over all its evidence; a bank in eighty splits it eighty ways.
      
      One refinement, from measuring rather than assuming: plain log-IDF is too flat for
      this. jungle_breaks (df=80) carries 0.50x the weight of rose (df=2) — a 2x penalty
      for being 40x more common, which would still let it dominate since it fires
      constantly. 1/df gives 0.0125 vs 0.50.
      
      Two normalisations that are not decoration. Per track, divide by the total 1/df its
      banks could contribute, or a track referencing many rare banks beats a track with
      one perfect tell — the reference-size bias in a third costume. And close gaps
      before deciding: a rare sample fires intermittently INSIDE the track that owns it,
      so `rose` at 22:35, 25:15, 28:15, 30:00, 31:10 is one track, not five appearances.
      
      A NEGATIVE result worth recording. Weighting let MAX_DF rise from 6 to 40, taking
      fingerprint coverage from 15/23 to 23/23 candidate tracks — and it added no
      evidence at all: every new bank scored consistency 0.02-0.21 against a 0.30
      threshold. I guessed reference duration was the binding constraint and that was
      WRONG: correlation with best consistency is -0.144, and the three longest
      references are among the worst detectors. Then the confound surfaced — suns_keys
      belongs to blue_gold, which PLN did not play, so its low score is a TRUE NEGATIVE.
      Failures and true negatives are indistinguishable without the setlist, so the
      reason remains unestablished rather than explained away.
      
      Also fixes two things that wasted PLN's time. The Boundary Lab query key is `set`,
      not `gig` — bounds.tsx does get('set') ?? 'opal-festival-2026', so a wrong key
      silently loads the DEFAULT document, and he auditioned OPAL's Sunset Forest seams
      believing they were CosmicFest. The variable there is named `gig`, which is exactly
      how I made the mistake: grepping found the word, not the key. And the generator
      offered "bombe_dj -> bombe_dj" as cut #1, a seam between a track and itself,
      because it read the un-merged segment file.
      PLN (Algolia) authored
    • feat(tide-table): put the machine's labels in front of ears, using the lab that already exists · b8bfec85
      PLN: "dotn we have a webapp for listening to seams in a set? you could use it for
      me to confirm your labels. reusable tooling pattern."
      
      It does — armada/ui/bounds.html, the Boundary Lab. So this builds no second page.
      make_bounds.py generates the document that page already reads, which means the
      confirmation loop is the one that already stores calls per gig in localStorage and
      already exports boundaries-<gig>.json.
      
      That direction is the point. The three-lens fusion is a machine opinion: 11 of 18
      segments have two lenses behind them, 4 are honestly unresolved, and the two rows
      that were WRONG were caught by PLN's memory rather than by any score. An analysis
      pipeline whose output lands in a report has nowhere to be corrected.
      
      The contract was read from BoundaryLab.tsx, not assumed, and two details would
      have been wrong if guessed: marks[].t is in MASTER time (the page subtracts
      clipStart itself), and `url` is handed to WaveformPlayer with dur = clipEnd -
      clipStart, so it must point at a CLIP of that length rather than at the whole
      recording. Mark keys are colour-coded by the page, so the segments where a lens
      OVERRODE the embedding get `takeover` — brand magenta, which its own comment calls
      "the one to try first" — because that is exactly where an ear call is worth most.
      
      Clips plus a mount rather than the whole file, following audio-mounts.json's own
      reasoning: it maps /audio/<prefix>/ for BOTH vite dev and armada/serve.py
      deliberately, since "a UI that only works under npm run dev is a demo, not a
      tool". A 40s clip also loads instantly on a phone over LAN, which is where this
      gets used. Peaks are normalised per clip because these are 40s windows of a room
      mic whose level wanders, and a waveform you cannot see is not a waveform.
      
      Verified rather than asserted: /bounds.html 200, the document 200 at 190 KB, a
      clip 200 at 641 KB, and a Range request returns 206 with exactly 1024 bytes — so
      seeking works, which is the whole point of auditioning on a phone.
      PLN (Algolia) authored
    • feat(tide-table): three lenses, fused — and the system now finds the two tracks I got wrong · 4e9da249
      PLN corrected two rows: he played rose_rouge and livecode_parade, not blue_gold.
      The fusion now reaches both WITHOUT being told, which is the whole point.
      
      29:57 crossmatch said blue_gold at a weak 0.44 margin; tempo measured 118.1
      against blue_gold's declared 124 — a clash — and rose_rouge is declared 118,
      with locate holding `rose` at 0.602 offset consistency. Two lenses override one.
      54:59 the same shape: measured 130.0, gimme_acid declares 80, livecode_parade
      declares 130, locate had `love_parade` at 0.399.
      
      Both were only reachable because crossmatch STRUCTURALLY cannot name them.
      rose_rouge played neither reference gig, and livecode_parade's .tidal was created
      in the CosmicFest prep commit itself, so no prior render of it can exist anywhere.
      A blind spot, not a tuning failure — and the fix is one render per new track.
      
      The tempo lens took three attempts, and the first two measured nothing.
      librosa.feature.tempo returned 107-130 for every segment of an 80-170 BPM set:
      that is its log-normal prior centred on 120, not the music. Mode of inter-onset
      intervals on the full mix returned 233-648 BPM, locking onto the hats rather than
      the beat — and its apparent matches were hollow, because five octave multiples at
      5% tolerance passes roughly a quarter of random values. Autocorrelation of the
      40-120 Hz kick band works: bombe 123.9/124, punkachien 170.1/170, vague_de_crime
      120.1/120, do_it_right 178.3/2 = 89.15/89. Four exact to 0.1%.
      
      That validation is what licenses the overrides. An instrument which agrees where
      it should is one whose disagreements carry information; without those four hits I
      would be overriding a strong embedding on the word of an estimator I had not
      checked (feedback_check_the_instrument_first).
      
      I also had to fix my own fusion: counting each top-3 tempo candidate as a vote let
      cafe_bouillant beat ouais_je_funk, which measured 120.1 against a declared 120 —
      a perfect match demoted by an alphabetical tie. Tempo compatibility CONFIRMS the
      embedding's proposal; it only overrides when it clashes AND a second lens agrees
      on a specific alternative.
      
      Four rows are now marked unresolved rather than confidently labelled. That is a
      real answer: a confident wrong row costs more than a blank one.
      PLN (Algolia) authored
    • fix(rig): CosmicFest has no session log because nothing in the inventory knew the recorder existed · 30fceb43
      PLN: "what gig-log didnt run that night? HOW COME? ... thats sad and needs
      proper tech debt payment! We cant afford to miss that."
      
      The answer is not a crash. gig-log.service was installed 2026-07-29 and then
      left `disabled`, and rig_units.py mentioned it ZERO times. So the authored
      inventory could not enable it, --ensure could not assert it, and gig-up never
      gated on it. It ran only when somebody remembered to start it by hand — and the
      sessions that exist, up to 2026-08-20, are exactly those manual runs.
      
      Two known failure shapes, both already named in this repo, stacked:
      
      The unit lived ONLY in $HOME, never in the repo. That is 4d9f1059 again, the two
      reconcilers rescued from $HOME after five orbits reached no Ardour track. A file
      that exists in one home directory is not infrastructure, it is a habit.
      
      And it was missing from the one authored list. That is what 406a4610 was written
      to end ("the list lived in three places"). An inventory only prevents the class
      of bug it actually enumerates; gig-log was simply left off, so it inherited none
      of the protection.
      
      What was lost is not just thermals and xruns. It is the TRACK BOUNDARIES — the
      gig log's `track` events are the real tracklist (reference_gig_log_is_the_tracklist).
      Recovering CosmicFest's set afterwards took two independent DSP lenses, 30
      reference renders, and it still leaves two unlabelled stretches. One always-on
      1 Hz recorder costing 0.4% of a core would have made all of that unnecessary.
      
      Paid in three places so it cannot recur:
        - tools/gig-log.service, versioned, symlinked into ~/.config/systemd/user
        - gig-log in rig_units.py SERVICES, so --ensure enables it and the generated
          parvagues-rig.target Wants it (8 units now)
        - a HARD gate in gig-up.sh
      
      The gate deliberately does not ask "is the unit active". A green unit is not a
      recording — the process can be up while the writer is wedged
      (reference_sc_supervision, feedback_verify_the_plumbing). It asserts that a
      session file's mtime is within 20 seconds, i.e. it is writing NOW. Verified it
      can actually fail, because this repo already has a log titled "the checks that
      could not fail": stopped the recorder, waited 25s, the gate failed; restarted,
      age 1s, passed.
      
      Still open, recorded not fixed: the HUD's eval-events.jsonl also missed the set.
      It holds 21 evals on 2026-08-23 from 18:31-18:59 (prep at home) and then four
      `pluie` evals at 22:14 with zero orbits, with a 3h15m hole across the gig itself.
      Pulsar was running, so the likely cause is reference_pulsar_lazy_activation — a
      reload leaves the package dormant — but that is a hypothesis, not a diagnosis.
      PLN (Algolia) authored
  2. 28 Aug, 2026 9 commits
    • feat(tide-table): a recovered CosmicFest tracklist, with the evidence attached · 930f6ca5
      Two independent lenses over a 63-minute handheld stage-mic MP3, for a night with
      no stems and no gig-log. They agree where they can both see, and where they
      disagree the reason is structural rather than mysterious.
      
      Confirmed by both lenses, independently:
        0:00-5:04   bombe                 z=2.33 margin 0.67 + diams_dj 85% offset consistency (1056/1244)
        31:27-35:46 take5_drops           z=1.91 margin 0.58 + take5 cons 0.38
        8:54-11:28  something_about_drums              + movie_paris cons 0.49
      
      Crossmatch alone, but with margins strong enough to stand:
        11:33-16:58 am_i_doing_it_right   margin 0.87
        18:13-22:32 you_my_sunshine       margin 0.82
        48:25-51:20 punkachien            margin 1.06  <- only findable because Montreuil supplied the render
        38:31-41:41 piment_bresilien      margin 1.19
        51:30-54:10 mafia                 margin 0.69
        57:29-60:29 vague_de_crime        margin 0.82
      
      The two disagreements are the interesting part, because they are predictable.
      crossmatch can only name a track it holds a prior render of, and  is
      confident about two banks that have none: rose @31:20 at 0.602 consistency
      (rose_rouge played neither gig) and love_parade @56:10 at 0.399 (livecode_parade
      is NEW — its .tidal was created in the CosmicFest prep commit itself, so no prior
      render can exist). Trust locate there. The fix for next time is one render per
      new track and the reference set covers it forever.
      
      Recorded as gaps rather than answers: blue_gold wins four short scattered
      segments at margins 0.11-0.44, and that scatter is the signature of a fallback,
      not a performance — material with no matching reference lands on whatever is
      nearest. Segment edges come from a 45s median filter so they are +/- 20s and are
      NOT cut points; bounds.html and ears do that job.
      PLN (Algolia) authored
    • fix(tide-table): I reintroduced the reference-size bias in a new place, and measured it · 2e77398b
      `locate` normalises by reference size. `crossmatch` did not, and the number said
      so: correlation(reference length, seconds won) = +0.586. Ceci (524s) and Piment
      (506s) won most of the set; WAP and REVOLUTION won nothing at all. The cause is
      that per-track score is a MAX over that track's windows, so a 524s reference
      simply gets more chances at a high max than a 154s one.
      
      Same asymmetry, second occurrence, new location — which is the pattern in
      feedback_count_what_you_bound: a bias survives a fix when the metric moves.
      
      The correction turns "how similar is this window to track T" into "how UNUSUALLY
      similar, for track T", by standardising each track's column over the whole
      query. A track generically close to everything now has a high mean and wins
      nothing; a track that spikes in one place wins there. It assumes each track
      occupies a minority of the recording — true for 15-30 tracks over 63 minutes,
      false for a 2-track set, so it is stated rather than buried, and --no-standardise
      shows the raw bias.
      
      Two more things, both from PLN's "almost the same lineup" observation:
      
      Montreuil26's tracks_bandcamp adds 15 more renders, and six of them are tracks
      OPAL never had — Jeudi Drill, PunkAChien, L'Or Bleu, Premier Septembre, Quand on
      decolle, Techno Orage. That directly explains the two disagreements between the
      methods: `locate` was confident about rose and love_parade, and crossmatch
      mislabelled both, because rose_rouge and livecode_parade are absent from the OPAL
      reference set and it can only pick the nearest thing it holds.
      
      Six tracks now appear in BOTH gigs, so their names must merge or one track splits
      its own vote and each half loses. canon_track does that with an explicit alias
      table rather than fuzzy matching: a wrong explicit merge is auditable, whereas a
      silent fuzzy merge of two different tracks would be invisible in the output.
      
      Also caches the query profile keyed on mtime and window geometry, because
      recomputing 63 minutes of chroma_cqt per run costs five minutes and this tool
      exists to be iterated on.
      PLN (Algolia) authored
    • feat(tide-table): the first pass measured loudness, not similarity — three fixes and a reframe · 22a9fc46
      The first scan reported raw hash collisions and I then analysed the noise. Every
      bank occupied 375 of 378 time bins. Three things were wrong, each traceable to
      prior art rather than to tuning:
      
      Offset consistency was missing entirely, and it is half the algorithm. Wang's
      2003 Shazam paper is explicit: real matches agree on a time offset, so the
      (query_t, ref_t) scatterplot shows a diagonal and the offset histogram spikes.
      Counting collisions without that step counts coincidences.
      
      Fixed offset is wrong anyway for this material. Sonnleitner, Arzt & Widmer
      (ISMIR 2016, DJ mix monitoring) allow the offset line a free slope, because a DJ
      plays material at a different rate — and a livecoder changing setcps does the
      same, across 80 to 170 BPM in one set.
      
      Raw counts measure loudness. Measured, not assumed: 8 of 18 banks had their
      global peak in the final minute, at 1.3-2.5x density. The finale is loud and
      broadband, so it makes more spectral peaks, so it matches everything better.
      Scores are now normalised by each window's own query-hash count and by each
      bank's reference size.
      
      But the real problem was the reference unit, and PLN named the fix: "comparing
      to prior recs, of each track of the opal songs for example (almost the same
      lineup in the end!)". A 0.4s dry one-shot cannot survive a PA and a 320 kbps
      encode — too few hashes, below the detection floor by construction. Meanwhile
      Prod/Opal26_master/tracks holds FIFTEEN ear-verified per-track renders, 154 to
      524 seconds each, same performer, same sample library, same patches.
      
      That changes the task from sample detection to VERSION identification, where the
      literature is unambiguous: when audio is related but not identical you stop
      matching exact time-frequency points and match sequences of features instead
      (Serra et al. on covers, McFee & Ellis on structure). So `crossmatch` embeds
      15s windows as MFCC mean+std, chroma and spectral contrast, matches by cosine
      against every reference window, and median-filters over 45s because a track
      lasts minutes and a one-window flicker is noise by definition.
      
      Standardising before the cosine is not cosmetic: raw MFCC coefficients differ in
      scale by orders of magnitude, so without it the first two — level and spectral
      tilt — decide everything, and level is exactly what a room mic gets wrong.
      
      The docstring says what it cannot do. The three cafe_* tracks are the same
      instruments at nearly the same tempo and no timbral profile will separate them,
      so the output reports a runner-up margin and flags thin ones: a win by 0.01 is
      not a win.
      
      Also included, from the custom-mode thread: the generated Components entry table
      (40 of 48 controls need changing, all of row A already matches), and the sniff
      that caught Components driving the device with documented feature controls —
      9F 0B 7F then CC 30 = 6 — plus an undocumented cmd 0x05 whose read/write forms
      match the audiocontrol project's reverse-engineered protocol.
      PLN (Algolia) authored
    • feat(tide-table): recover a tracklist from one room mic, when every existing lens is blind · 470cc882
      PLN downloaded the CosmicFest capture and called it "an interesting test". It is:
      a 63-minute handheld stage-mic MP3, and all three boundary lenses the toolbox
      owns need something it does not have. Orbit-activity needs stems, there are
      none. The gig-log `track` events are the real tracklist, but the 1 Hz recorder
      never ran that night — newest session is 2026-08-20. Gap detection assumes a
      set stops between tracks, and a livecoded set does not.
      
      What is left is audio, plus one large advantage: we know what was prepped, so
      this is matching over ~23 candidates rather than open discovery. PLN warns "the
      set was slightly altered", so candidates are treated as candidates — presence
      is strong evidence, absence is weak.
      
      Two lenses, because neither is sufficient. Tempo finds seams but cannot name
      them: five candidates sit at 120 BPM and three at 124, and tempo from audio is
      only ever determined up to a factor of two. Sample signature names a segment,
      and PLN pointed at exactly the right prior art — sample_tfidf.py already
      computes which sounds IDENTIFY a track. take5 and love_parade are df=1;
      jungle_breaks is in 80 tracks and worthless. 15 of 23 candidates carry a sound
      rare enough (df<=6) to fingerprint; 19 such banks, 533k reference hashes.
      
      Constellation fingerprints rather than correlation, because the reference is a
      dry one-shot off disk and the query is that sound through a PA, a room, a
      limiter, "ppl messed with it", and a 320 kbps encode. Peak constellations encode
      only which time-frequency points stick up locally, which survives all of that.
      
      My first attempt at extracting banks was wrong and the output said so: 9 tokens
      across 23 tracks, every count 1, and a label reading "shares every bank" that
      actually meant "nothing parsed". The regex looked for `s "name"` while the
      ParVagues idiom applies a bare string — `$ "meth_bass" # n "..."`. The fix was
      not a better regex, it was using the parser that already existed.
      
      The docstring carries the limits because they are easy to launder: a detection
      means the SOUND occurred, not that the TRACK played, since banks get reused
      live. Recall is asymmetric — a track whose signature never sounded is invisible.
      And vote counts are not comparable between banks, because a 582-file bank offers
      far more chances to match than a 4-file one.
      
      Also adds `lcxl3.py sniff`: capture raw SysEx in full. The custom-mode format is
      undocumented, and Components writes modes to the device over SysEx — capturing
      those bytes is the difference between a layout authored in lcxl_grid.py and one
      merely stored in a web app.
      PLN (Algolia) authored
    • 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
  3. 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
  4. 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
  5. 21 Aug, 2026 22 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