1. 29 Aug, 2026 19 commits
    • fix(lcxl3): we were fighting for the wrong screen — every knob owns its own · 78ba7fca
      PLN, three rounds in: the OLED "still shows values". Move a fader and you get
      the firmware's bare 0-127; move a relative encoder and you get "0", because the
      wire value of a relative encoder IS 0-ish. "No info at all." Meanwhile our own
      overlay — track name, what the control does, a readout — was being re-summoned
      every 0.9 s under a 1.2 s firmware timeout, field-diffed, stamped only on real
      bring-ups. Three increasingly clever versions of the same losing move.
      
      The programmer's reference says why, in the display chapter's first list:
      
          "• 05h (5) - 24h (36): Temporary display for Analogue controls (same as CC
             indices, 05h (5) - 0Ch (12): Faders, 0Dh (13) - 24h (36): Encoders)
           • 35h (53): Permanent/Stationary display
           • 36h (54): Overlay/Temporary display"
      
      There are not two display targets. There are thirty-four. Every fader and every
      encoder owns a display target of its own, addressed by its own DAW-mode CC
      index, and THAT is the one the firmware auto-triggers when you touch the
      control — not 36h, which is where we had been writing all evening. Config bit 6
      is its on-switch and it defaults to on:
      
          "• Bit 6: Allow Launch Control XL 3 to generate temporary display
             automatically on Change (default: Set).
           • Bit 5: Allow Launch Control XL 3 to generate temporary display
             automatically on Touch (default: Set; this is the Shift + rotate)."
      
      So a fader sweep fired target 05h thirty times a second while we re-summoned
      36h once a second. Unwinnable by construction, and no amount of keep-alive
      tuning was ever going to change that. It also explains the one part of the
      symptom that never fit a timeout story — buttons were fine. Buttons live at
      25h-34h, outside the analogue range, own no per-control target, and so were
      never contested at all.
      
      And the fix is not to win the fight. Arrangement 4 is the firmware's default:
      
          "4 | 2 lines: Parameter Name and Numeric Parameter Value (default) | Yes | 1 | Name"
      
      One field, Name, and a value the firmware draws itself. The Name field was
      empty because nobody had ever written it. Write it, and the display we were
      trying to suppress becomes the display we wanted: it already tracks the value at
      wire speed and already times its own dismissal. So the driver now labels all 32
      analogue controls on their own targets at startup — "d4 # crushbus",
      "d5 # octerbus", "d1 level [ard]" — and stands down from the overlay for them.
      
      Faders and A1-A4 carry no ^NN in any .tidal, because they are Ardour-learned
      levels; their label comes from the grid's role table instead, with an explicit
      [ard] mark. Mid-set, "this fader is Ardour's, not Tidal's" is the most useful
      sixteen characters the panel can hold. Unmapped controls say "unmapped", because
      dark-means-unmapped is the surface's oldest rule and the screen must not
      contradict the LED beside it.
      
      Three controls keep our overlay: the DJF knobs. "HPF 3.4kHz !!" and the heart
      frames at the zero detent are prose, and arrangement 4's value field is a
      number. For exactly those three targets we clear bits 5+6 — which is the
      documented, per-control way to make the firmware stand down — and take the
      screen back uncontested. Restored on exit, since a next session finding three
      knobs that silently refuse to show a value would be a worse bug than this one.
      
      Along the way, three things the per-control screen made visible by being the
      first thing to render our labels at full width:
      
      - set_text filtered to `0x20 <= c <= 0x7E` and so dropped the four reassigned
        control codes its own docstring advertised. The language module's heart frames
        had been arriving as plain spaces this whole time.
      - parse_context said "an inline trailing comment IS the best label" and did not
        do it: the only `--` strip was anchored at the start of the body, where a
        trailing comment never is, so the Haskell always won. C8 of piment_bresilien
        read `d8 # n "23")) --` on a line ending in PLN's own `-- Raise COMEON!`.
        The comment is split off first now and preferred.
      - With no stage note, the body fallback was raw combinator soup. It now reads
        the sample name off the first quoted string and the effect off the first
        identifier — "break:18", "n 6" — which is what PLN asked the line to say
        ("sample name or effect like 'rose:4'"). Across live/: 158 syntax-noisy
        labels out of 22368 became 3.
      
      Verification I can actually do, given a write-only display and no readback:
      --show-oled dry-runs the whole label table offline, opening no port, and fails
      on any name over 16 characters; the new test pins the target numbers against the
      guide's table, the exact configure/set_text bytes, that a button raises instead
      of writing into the void, that the hearts survive the filter, and the three
      parse_context cases above. What no test can prove is a pixel. That needs eyes.
      
      Fallbacks kept in case the name field turns out not to render: --oled-overlay
      suppresses every native display and restores the uncontested-overlay behaviour
      across the whole surface, and --oled-native-all hands even the DJFs to the
      firmware, for the eye test that compares the two side by side.
      PLN (Algolia) authored
    • feat(lcxl3): Hz-true DJF readout, the OLED ghost, and the edge-button census · ca173ae1
      Round 4 of the live design session, plus the nine-press census.
      
      DJF readout speaks Hz, not percent — and the numbers come from BootTidal's own
      gDJF ranges (lpf: range 180 20000 over 2v; hpf: range 20 8000 over 2(v-.5)),
      so what the OLED says is what the filter DOES. The zero mark animates: heart
      frames at ~3 fps while a DJF rests in the detent (charset 0x1E is a heart).
      Home screen line 3 is now "118 BPM" — "36 live" was a control count nobody
      asked for, and PLN rightly mocked it.
      
      The OLED GHOST (/tmp/lcxl3-oled-ghost.txt): the protocol has no display
      readback, so nothing can pull the screen — instead the driver mirrors every
      frame IT sends into a box-drawing the shell can read. What the ghost cannot
      show is the firmware's own native value overlay, and that is the point: when
      PLN's eyes see content the ghost does not have, the discrepancy itself is the
      diagnosis. (That discrepancy is live: the native overlay still outdraws our
      temporary text on every control move — an Opus worker is in the programmer's
      reference hunting per-control display targets, the likely correct fix.)
      
      Keep-alive: the round-3 overlay stamped _oled_up_at on every push, so after
      the firmware's 1.2 s timeout our overlay was never re-summoned — one good
      overlay per touch, then raw values forever. Now the bring-up re-fires every
      0.9 s of continuous activity and stamps only when actually sent.
      
      THE EDGE-BUTTON CENSUS (PLN pressed, the log answered): Solo/Arm is ONE key
      (ch1 CC65, two press cycles for his presses 1+2), Mute/Select is ONE key
      (CC66), the Novation logo is alive (CC104), Track </> are CC103/102, Page
      up/down CC106/107. Authored as EDGE_BUTTONS in lcxl3.py; the driver now names
      them in the log instead of counting them unmapped. No functions assigned yet —
      the settled direction is Track = setlist cursor, Page = OLED pages, the two
      verb keys = record-arm and panic, logo in reserve.
      PLN (Algolia) authored
    • feat(mafia): the Afia oil ad rides again — scene header points at its GLITCHWAVE source · 89c7d967
      The glitch loop cut live from the Afia ad (afia_flow.gif) is now this
      track's backdrop, through the HUD's new standard: the header names the
      source, scene-ingest derives the playable twin into visuals/scenes/.
      PLN (Algolia) authored
    • feat(bridge): midiviz — the MIDI stream as a picture, not a transcript · 30a704ec
      PLN, 2026-08-29: "midiwatch still naively verbose [`14:0 Control change ch0,
      controller 29, value 15`] improve it massively. no more console, tiny borderless
      window that exits on q/exit/ctrl+c/alt+f4, that renders the stream in a nicer,
      cyberpunk matrix dense cryptic purple-on-dark, style. no word like 'control
      change' pure viz"
      
      The complaint is not about verbosity, it is about REGISTER. One line per event is
      the right shape for a debugger and the wrong shape for a performer: a fader sweep
      emits ~400 events a second, so midimon's prose scrolls past faster than an eye
      can land on it, and the one question you actually have mid-set — "which orbit did
      I just touch" — is the one thing a sentence makes you decode. So this is a lens,
      not a log.
      
      The picture
      -----------
      Zero English words render. An event's identity is its POSITION, its type is a
      GLYPH CLASS, its value is a BAR plus two hex digits, and its recency is
      BRIGHTNESS. The layout is the authored surface itself — lcxl_grid's rows A..F in
      PHYSICAL_ORDER down, columns 1..8 across, column N *is* orbit N — so the answer
      is spatial and needs no decoding. A per-orbit charge glow behind each column says
      "this one is live"; the column digit brightens with it. Buttons render as latches
      (filled above half, hollow below) because rows E/F are gates, not levels — a bar
      would have lied about what they do. Channel shifts the hue of the digits, not of
      the cell, so the cell keeps meaning "which control" while the tint carries "from
      where". Only what the grid CANNOT place — foreign CCs, notes, bend, program —
      falls as rain in the right-hand gutter, which makes "that came from somewhere
      else" a visible fact rather than a thing you read. A ribbon along the bottom
      scrolls the raw order of arrival, brightest at the right.
      
      Every hue stays inside the violet→magenta arc, so eight roles stay
      distinguishable while the window still reads as one colour.
      
      Choices worth keeping
      ---------------------
      * **Mapped-but-idle must not be black.** All 48 grid cells carry a floor tint
        even when nothing has ever arrived on them. "Dark = not mapped" is the reading
        a cockpit trains you into, and a dark cell that IS mapped reads as a hardware
        fault — the same trap the LED work settled.
      * **Toolkit: PySide6 (Qt6), already installed (6.11.2).** PyQt6 and pygame are
        not present; GTK4 + pycairo are, but Qt gave frameless + always-on-top hints +
        startSystemMove (the only way a Wayland client may reposition itself) without
        hand-rolling a drag.
      * **Two frame rates, not a busy loop.** This machine performs live audio. ~25 fps
        while events arrive, dropping to 5 fps two seconds after the last one, where
        the only motion is a slow dim pulse. Colours are a precomputed LUT (8 families
        x 16 decay levels), paint uses the int overloads, the CRT scanline texture is
        one cached pixmap blitted once, and the ribbon is bounded to exactly what fits
        so a frame copies nothing.
      * **Profiled rather than guessed.** The first version cost 30.6% of a core under
        a sustained 400 ev/s sweep. Stage-by-stage timing put the cost in the sheer
        NUMBER of drawText calls, not in pixels: gutter rain alone was 3.1 ms of an
        8.9 ms frame (a 96-drop cap with a trail glyph each = up to 192 calls). Capping
        rain at 32 heads with a trail only on the brightest, batching the ribbon into
        same-colour RUNS, and 33→40 ms took the frame to 5.5 ms and the sweep to 17.7%.
        Idle is 1.4%. The ribbon batches only hex glyphs, because those are guaranteed
        to come from the monospaced face at exactly one advance — a class glyph (◆ ≈ ≡)
        may be served by a fallback face and would drift the ribbon off its grid.
      
      Port choice: delegated, not re-derived
      --------------------------------------
      `midimon.resolve_port()` owns "which port", and midiviz delegates to it. Two
      things about it are load-bearing and were nearly re-derived wrong here: the
      preference order is authored, and it matches the whole `aseqdump -l` ROW rather
      than the CLIENT column — python-rtmidi registers the lcxl3 driver's client as
      'RtMidiOut Client' and puts 'ParVagues LCXL3' on the PORT. A client-name match
      (which is what surface.resolve_port does, correctly, for its own purpose) falls
      straight through to the raw hardware: the monitor would work and show the wrong
      stream. Confirmed live on this rig, which has both up — the driver is 133:0 under
      'RtMidiOut Client'. The local fallback copy exists only for trees whose midimon
      predates the helper, and a test asserts it cannot drift from the original.
      
      Launcher
      --------
      The `midimon` KEY is kept, so the Bridge hub's and the tray's existing button open
      the new thing with no change on either face; midimon.py is untouched and remains
      the terminal fallback. `terminal: True` is dropped — midiviz draws its own
      window, and wrapping a GUI in konsole would have parked an empty black terminal
      behind it for the length of the set. That also re-enables the already-running
      guard, so the button is now idempotent instead of spawning a window per click.
      
      Verified
      --------
      * `ast.parse` clean; full bridge suite 90 passed (was 78 + 12 new).
      * `midiviz.py --selftest`: 3 s of synthetic events — every one of the 48 authored
        grid cells, four channels, un-gridded CCs, notes, bend, program, aftertouch,
        sysex — pushed through the REAL parse_line + enrich + ingest + paint path, 140
        paints, 68 grabs, 139 distinct sampled colours. It grabs the widget each tick,
        so the drawing code is exercised for real and offscreen, not merely constructed.
      * Exit paths run, not assumed: SIGINT and SIGTERM each exit 0 from a live process
        (q/Esc/close share that path).
      * Plumbing, not just pure functions: a real Reader subscribed to the resolved
        driver port with no error, and a second one pinned to Midi Through received 8
        genuine events played in with aplaymidi — aseqdump → parse_line → enrich →
        drain, end to end against real ALSA.
      * Rendered and looked at, at 420x260 and at 2x, before and after the
        optimizations: identical output, which is the point of the run-length batching.
      
      Found on the way: data-less realtime messages (Start/Stop/Continue/Clock) print
      with no data column, and midimon's row regex requires one, so they never parse —
      midimon's console silently omits transport too, and midiviz has four glyphs that
      cannot light up. Left in midimon (one parser per concept) and pinned by a test
      that says what to delete when it is fixed.
      PLN (Algolia) authored
    • fix(rig): Ardour hears the surface again — two stale identities, one webcam · 63f53205
      PLN's report: "no impact of lcxl in ardour... why would i need to remap midi
      controls there across lcxl gens?" He is right: he never needs to remap. The
      session file has the learned bindings (v2 CC 77-84 + 13-16, channel 1) and the
      lcxl3-driver emits exactly that. The path was broken in TWO places, both by
      name-vs-identity rot:
      
      1. midi-autoconnect resolved its source by NAME and connected "name:0" — but
         aconnect resolves CLIENT names only, and the driver's virtual port carries
         its name on the PORT line (client = python-rtmidi's 'RtMidiOut Client').
         The connect failed into 2>/dev/null for exactly the source the candidate
         list most prefers, so Midi Through carried NOTHING in DAW mode. Now the
         resolver returns a numeric client:port address (awk over aconnect -i,
         matching client AND port lines). Fourth name-identity bug of the day.
      
      2. Ardour's Generic-MIDI input connection was saved by device identity, and
         that identity was "ALSA;;C922 Pro Stream Webcam" — the webcam had taken
         over the slot the surface once owned. The purest stale-binding specimen the
         rig has produced. tidal-ardour-autoroute now asserts
         Midi-Bridge Midi Through -> ardour:MIDI Control In on its 2 s cadence,
         no-op until Ardour registers the port, permanent once it does.
      
      Midi Through stays the rendezvous ON PURPOSE: v2 raw or v3-translated, both
      feed it, which is what keeps the classic LCXL plug-and-play (the BC
      constraint). Ardour 9.7 note for the archives: the Control Surfaces page is
      now a vendor tree built from the .map files' manufacturer field — the old
      "Generic MIDI" lives under vendor "Any" (GenericMIDI.map, manufacturer="Any"),
      NOT under "Other", and Novation's "Launch Control XL (generic)" entry is the
      factory map that would OVERRIDE the learned bindings — do not enable it.
      PLN (Algolia) authored
    • feat(lcxl3): the v3 visual language — driver-owned values, latches, and a · 857de5b2
      surface that breathes at the track's tempo
      
      The design session with PLN, hands on the device, four AskUserQuestion answers
      and three live iterations. Everything here is v3-only BY CONSTRUCTION: the
      corpus, BootTidal, SC and Ardour still speak v2 absolutes, so the classic LCXL
      (PLN's fallback, and his friend's surface) keeps working with zero changes —
      "we need to be BC always" is the standing constraint.
      
      THE LANGUAGE (lcxl3_language.py, new — parallel to lcxl-leds.py, replacing
      nothing):
      - rings: hue = ROLE (per-orbit family anchors, warm rhythm bloc / purple bass /
        cool melodics), lightness = VALUE. This is the exact trade v2 could not make
        with three LED hues — the settled 2026-07-29 language spent all hue on value
        because role had nowhere else to live. RGB dissolves the trade.
      - dark = unmapped survives unchanged: it outranks everything on both surfaces.
      - DJF row C, PLN's spec after the first blue<->green attempt proved "too
        subtle": blue = lows kept, DEEP GREEN BREATHING SLOWLY = the zero mark
        (there is no pot ridge on an endless encoder — the 0 must be VISIBLE),
        then green -> yellow -> red as the HPF cut grows. Near-silence extremes
        breathe to the BEAT — sine, not blink ("a bit aggressive atm"), rate from
        the track's setcps (118 BPM for rose_rouge). Wall-clock phase: it breathes
        AT tempo without claiming to know Tidal's cycle position — true phase sync
        is a feed from SC, later, not a silent assumption now.
      
      DRIVER-OWNED TRUTH (three kinds, one principle):
      - encoder VALUES: rows B/C relative, offset-64 decode — MEASURED off PLN's
        slow clicks arriving as 63/65/66; the first decoder assumed two's complement
        and read +1 as -63, direction inverted, every click a full-range jump. Row A
        stays absolute deliberately: A1-A4 are Ardour-learned levels, and integrating
        them from a blind seed would yank a live fader toward silence.
      - row-E LATCHES: the latch never lived in Tidal (midiOn/midiOff read the last
        value) — it lived in the v2 hardware's toggle buttons. v3 DAW buttons are
        momentary, so the driver flips state on press, swallows the release, and the
        LED paints driver state — which IS what Tidal hears. Row F stays momentary:
        gestures are held, and the panic chord needs four simultaneous 127s.
      - PAINT as a binding: the device repaints its own defaults on events we cannot
        see, so the driver re-asserts the full board every 2 s — same doctrine as
        every aconnect link on this rig. Faders are OUT of the paint path: they have
        no LEDs, and painting them was a no-op lie this commit stops telling.
      
      OLED:
      - touched-control context in CORPUS terms, line 2 parsed from the .tidal:
        "# crushbus", "mask <f f f t>", and — best of all — PLN's own inline stage
        notes: ^35 shows "HANDS IN THE AIR", ^59 "Le Delay rose!!!", ^36 "Savoy 8/8
        Break". His words, on his hardware, at the moment his finger lands.
      - anti-blink: fields are diffed and the configure/bring-up pair fires only when
        the overlay has likely expired — re-summoning a live overlay every event was
        the blink PLN saw.
      - gates read ON/off, DJFs read "LPF 43% / BYPASS / HPF 61% !!".
      
      Validated on the live rig across three rounds of PLN's eyes and hands:
      translation + paint + OLED coexist in one process; 27 live cells / 13 dark on
      rose_rouge; 18/18 controls context-labelled; latch verified against E1.
      PLN (Algolia) authored
    • fix(bridge): RIG UP now shows its work in a second, and midimon opens wired · b30715b7
      Three gaps PLN hit launching the rig on 2026-08-29, reported as "had to launch
      ardour myself / had to open midi watch myself / once opened had to wire it to
      midi through myself". All three were real, and one of them nearly got "fixed"
      the wrong way.
      
      GAP 1 — the button looked dead for over a minute. _run() ran gig-up --converge
      to completion (~100 s on a cold boot while SuperDirt warms 51 banks, up to the
      300 s timeout) and only THEN launched the apps. So RIG UP showed a spinner and
      no window, PLN concluded it had failed, and launched Ardour by hand.
      
      My first attempt was to launch the apps first. That was wrong, and PLN caught
      it: "superdirt froze before loading midi when in my launcher, when ardour was
      midi connected, had to untick ardour midi, then launch". Ardour holding the
      surface makes sclang hang inside MIDIClient.init / MIDIIn.connectAll — the same
      rawmidi contention that stops `amidi` writing LEDs while SC is up. The original
      ordering was not an accident, it was load-bearing, and reordering it would have
      traded a cosmetic complaint for a reliable boot freeze. Reverted.
      
      The correct shape is a SPLIT, not a reorder:
      
          PRE_APPS  = ["pulsar"]              touch no MIDI -> open immediately
          POST_APPS = ["ardour", "midimon"]   contend for the surface -> wait
      
      Pulsar's GHCi reaches Tidal over TCP 6010 and never touches ALSA, so it can
      open at once and give the button a visible effect within a second, while its own
      multi-second startup overlaps converge instead of queuing behind it. Ardour
      still waits for SuperDirt to own MIDI first. Converge output now appends to the
      launch lines rather than overwriting them, so the log shows both halves.
      
      GAP 2 — midimon was in NEITHER list. RIG UP never had it to open, so "had to
      open midi watch myself" was exactly correct. Added to POST_APPS.
      
      GAP 3 — midimon opened blind. `aseqdump` with no -p subscribes to nothing and
      prints "Waiting for data at port", so every launch needed hand-wiring before it
      could be used. Added resolve_port(), which picks the most interesting source
      present, in a deliberate order: the lcxl3-driver's TRANSLATED stream first
      (the numbers the corpus speaks — what you want when a knob does the wrong
      thing), then the raw v3 DAW port (the numbers the hardware speaks — for "is it
      even sending"), then the custom-mode port, then Midi Through as a catch-all.
      
      It matches CLIENT and PORT names both, because python-rtmidi registers the
      driver's client as 'RtMidiOut Client' and puts 'ParVagues LCXL3' on the port —
      a client-only match misses precisely the stream most worth watching. That is
      the third bug today caused by treating a MIDI endpoint's name as its identity.
      
      Verified with the driver running: resolve_port() -> ('133:0', 'ParVagues LCXL3'),
      i.e. it selects the translated stream over the two raw ports and Midi Through.
      PLN (Algolia) authored
    • fix(lcxl3): identity is the ALSA port id, not the name — and the surface now plays · 9b482463
      Follow-up to fa22bb85, which fixed the direction trap but still could not run.
      Two more defects, then the first verified end-to-end translation.
      
      DEFECT 3 — the virtual port could never be found. _clients() returned
      (port_id, CLIENT_name) and every lookup matched the client name. python-rtmidi
      registers the client as 'RtMidiOut Client' and puts OUR chosen name on the
      PORT:
      
          client 133: 'RtMidiOut Client' [type=user,pid=...]
              0 'ParVagues LCXL3 '
      
      So `VIRTUAL_PORT_NAME in client_name` was false forever. The driver printed
      "reconcile: src=None dst=130:0 — nothing to do yet" on every cycle while
      `aconnect -i` listed the port plainly two lines below. _clients() now returns
      (port_id, client_name, port_name) and _find_port() matches either — both names
      are real and each is load-bearing somewhere.
      
      DEFECT 4 — prune() would have cut the driver's own link. It skipped "our" port
      by testing VIRTUAL_PORT_NAME against the name from `aconnect -l`, which is the
      CLIENT name — 'RtMidiOut Client'. That never matches, so the very first prune
      after a successful connect would have severed the connection it had just made,
      every cycle, forever. prune() now takes an explicit set of protected PORT IDS.
      Identity here is the ALSA port id; the names are decoration. Any place that
      matches a MIDI endpoint by name on this rig is a latent version of this bug.
      
      Also: reconcile() prunes only AFTER a confirmed connect. Cutting the raw
      surface while the replacement stream is not actually delivering would leave a
      dead surface, which is worse than a wrong one.
      
      VERIFIED END TO END on the live rig, PLN's hands on the hardware:
      
          fader 1      v3 #5   -> v2 #77   (38 events, full sweep 37..0)
          button E1    v3 #37  -> v2 #41   (127 then 0)
      
      ^77 is lcxl_grid's row D cell 1 and ^41 is the kick gate — the exact control
      the #94 migration aligned all 169 tracks onto, and the one v3 renumbering would
      have moved to button E5. 0 unmapped, 0 dropped. The graph after prune:
      
          133:0  RtMidiOut Client  -> 130:0    (ours, the only surface path)
          20:0 / 20:1 LCXL3 1                  GONE from SuperCollider
          14:0 Midi Through -> 130:2           re-cut every cycle
      
      Open, and deliberately not guessed at: something re-adds Midi Through -> SC
      within 5 s. midi-autoconnect only ever creates the OTHER direction, so the
      re-adder is unidentified. prune contains it each cycle; the window is real but
      small, and naming a culprit without evidence is how the last three "rig is
      broken" findings turned out to be the measuring tool.
      
      Resume point 1 is closed: the driver has met a live SuperCollider, and it took
      four defects to get one fader through. Every one of them was invisible to
      reading — three reported success while doing nothing.
      PLN (Algolia) authored
    • fix(lcxl3): the driver had never connected anything to SuperCollider · fa22bb85
      Resume point 1 was "run lcxl3-driver against a live SuperCollider, because it
      never has been". It has now, and the run found two defects that no amount of
      reading could have found — both invisible precisely because the code reported
      success.
      
      DEFECT 1 — the aconnect direction trap. reconcile() resolved its destination
      with `_clients("-i")`:
      
          dst = next((p for p, n in _clients("-i") if "SuperCollider" in n), None)
      
      `aconnect -i` lists ports you can read FROM. SuperCollider appears in both
      directions: in0..in4 are destinations, out0..out6 are sources. So `-i` returned
      SC's *output* port — 130:5 on this rig — and the driver ran
      
          aconnect ParVagues LCXL3:0  130:5
      
      which writes into an output-only port and fails. The CompletedProcess was
      discarded, so the failure had no way to be seen, and the next line printed
      "reconcile: src -> dst" as though the link existed. Every previous reasoning
      step about this driver rested on a connection that was never made.
      
      Fixed by resolving destinations from `-o` via a new _sc_input_ports(), and by
      checking the return code and PRINTING the failure. A reconciler that cannot say
      "I failed" is decoration — the rig has been bitten by this shape before
      (feedback_verify_the_plumbing, reference_check_the_instrument_first).
      
      DEFECT 2 — reconcile only ever ADDED links, so it could not be correct here.
      `MIDIIn.connectAll` subscribes SC to every source present at boot. Measured on
      the live rig:
      
          14:0  Midi Through  -> 130:2
          20:0  LCXL3 1 MIDI  -> 130:3
          20:1  LCXL3 1 DAW   -> 130:4
      
      So the moment the driver published a correct, translated stream, SC was hearing
      it ALONGSIDE two raw ones. This is worse than duplication: v3's DAW map
      collides with the corpus's v2 numbers on 16 indices with different meanings, so
      row C knob 4 would drive ^52 (right) and ^32 (row B's cell) at once — two
      orbits moving when one knob turns. And a doubled CC is fatal on a latch: the
      toggle fires twice and cancels, which reads from the stage as "the button does
      nothing".
      
      Added prune(), which cuts every path into SC that is not our virtual port,
      re-asserted on the same timer as the connect (the binding must be re-asserted,
      never assumed — feedback_stale_binding_pattern). Midi Through is cut by default
      because midi-autoconnect's rule 1 pushes the surface into it, i.e. it is a
      second copy of the same stream; --keep-thru opts out.
      
      Added `--graph`, which prints what actually feeds SC and what prune would do.
      That is the diagnostic whose absence let defect 1 hide: the driver's own view
      of the graph was never printable, so it could never be checked against reality.
      
      Verified on the live rig (sclang 845344 / scsynth 845718, LCXL3 on client 20):
      --graph now resolves 130:0-130:4 and correctly classifies 3 WOULD CUT paths and
      PipeWire-RT-Event's monitor bridge as left alone. Hardware also confirms the
      translation table's row D: PLN reports fader 1 = CC5, which is V3_ROWS["D"][0].
      PLN (Algolia) authored
    • docs(board): end-of-session state, with the six resume points written for a cold reader · 0a7475b2
      Eleven threads closed, six resume points with exact commands. The first one is
      load-bearing and gets said plainly: tools/lcxl3-driver.py has never run against a
      live SuperCollider, because sclang, scsynth and Ardour were all at zero when it
      was written. Every claim about PLN playing his corpus on the new surface rests on
      that one untested step, so it is resume point 1 rather than a footnote.
      
      Also records what is deliberately left running (the audition server, plus the
      firewall holes its LAN access needs and the ?set= key that cost PLN a listening
      session), and what is parked because it is not mine to commit — four uncommitted
      www files from a prior session's CosmicFest page work.
      PLN (Algolia) authored
    • docs(armada): logs 032, 033, 034 — the surface, the microphone, the recorder · 588d0a41
      Three threads worth their own entries in the documentary trail.
      
      032 — the LCXL3. Closes the board's twelve-day-old OLED question (arbitrary
      128x64 bitmaps, ACKed per frame for animation), records the keystone result that
      paint is DAW-mode-only, and the sixteen-index CC collision whose decisive case is
      v3 #41 = button E5 against v2 #41 = the kick gate log 031 had just aligned 169
      files onto. Plus two instruments that lied: aseqsend -s takes a FILENAME and exits
      0 on 32 silent no-ops, and pgrep -c -f matched its own command line.
      
      033 — fourteen tracks from one microphone. 12 of 14 named correctly off a
      handheld stage mic with no stems and no log. Includes the three lenses, the three
      failed tempo estimators, the same reference-size bias appearing in three separate
      costumes, and PLN's ears beating my reporting twice because I printed 4 of 86
      detections.
      
      034 — the recorder nobody enumerated. Why CosmicFest has no session log: the unit
      was installed 2026-07-29, never enabled, and absent from the authored inventory,
      so nothing could assert it. Paid in three places, with a gate proved capable of
      failing.
      PLN (Algolia) authored
    • chore(tide-table): regenerated tf-idf + locate at df<=40, and ignore the rebuildable caches · cca62851
      sample_tfidf.json re-run over the corpus; set_finder_locate.json is the 35-bank
      pass (was 19) after MAX_DF rose from 6 to 40 on the strength of 1/df weighting.
      
      The npz hash DB (2.3 MB, rebuilds in 39 s) and the per-recording query profile
      are ignored: they are caches keyed on inputs, not sources. Noting the tension
      deliberately — 'generated + gitignored + never compared' is exactly what made
      preload staleness debut at a venue — so both are produced by an explicit
      subcommand and their inputs are versioned.
      PLN (Algolia) authored
    • fix(rig): the other two thirds of the gig-log fix, which 30fceb43's message… · b00692c0
      fix(rig): the other two thirds of the gig-log fix, which 30fceb43's message claimed but did not contain
      
      30fceb43 said the debt was "paid in three places" and shipped one file, the unit
      itself. The inventory entry, the regenerated target and the hard gate stayed in
      the working tree — so the commit that explains the fix and the commit that
      performs it were different commits, and only one of them existed.
      
      What is here:
      
        rig_units.py    gig-log joins SERVICES, so --ensure enables it and --status
                        reports it. 7 units, 0 problems. It was absent until now, and
                        that absence is the entire reason CosmicFest has no log: the
                        unit was installed 2026-07-29, never enabled, and nothing in
                        the authored list knew it existed.
      
        parvagues-rig.target  regenerated, now Wants 8 units.
      
        gig-up.sh       a HARD gate, deliberately not "is the unit active". A green
                        unit is not a recording — the process can be up while the
                        writer is wedged. It asserts a session file's mtime is within
                        20 s, i.e. it is writing NOW. Proved it can fail rather than
                        assuming: stopped gig-log, waited 25 s, gate failed at age
                        25s; restarted, age 1 s, passed.
      
      Same class of error as the amend on c628415e earlier this session, where a message
      described a midi-autoconnect fix the commit did not carry. Both times the message
      was written from intent rather than from `git show --stat`.
      PLN (Algolia) authored
    • feat(bounds): a `noise` mark, because a defect between two tracks belonged to neither · d17228a1
      PLN, auditioning CosmicFest: "at 22:42 its end of reose_rouge, theres a loud blip
      noise, at 22:44.9 its the start of rose rouge, could we cut noise and blend
      better?"
      
      The lab could only ever answer one question — where does the next track start —
      so garbage living in the 2.9 s BETWEEN two tracks had nowhere to go. It survived
      as prose in a comment box, which no downstream tool reads.
      
      `n` marks the playhead as noise and deliberately leaves the cut call untouched: a
      blip between two tracks is not an opinion about where either begins, and forcing
      it through the cut field would corrupt the one number the mastering chain trusts.
      `N` clears them, because a mis-tapped defect marker you cannot remove is worse
      than no marker at all.
      
      They render in warm orange, distinct from every existing mark colour: those are
      all CANDIDATES for a cut, and this one is the opposite — a place to avoid.
      
      Exported from both the decided and skipped blocks, so a seam PLN rejects can still
      carry "and there is garbage at 22:42". That combination is exactly what happened
      here and exactly what the old export could not represent.
      PLN (Algolia) authored
    • fix(bounds): a skipped seam carried PLN's best information, and the exporter threw it away · 7b74d8b3
      PLN: "wait you dont get the notes when i skip? DANG they have lots of info."
      
      The exporter emitted skipped boundaries as bare integers:
      
          skipped: Object.entries(calls).filter(([, c]) => c.t === null).map(([k]) => Number(k))
      
      so eight CosmicFest seams exported as `[2,4,9,10,15,16,17,18]` while the notes he
      had typed into every one of them vanished. And a skip is not an absence of
      judgement — it is usually a judgement that the SEAM is wrong, which is more
      informative than confirming a cut time. What was lost included "theres no
      blue_gold in this set, we're hearing do_it_right throughout", "there was no
      gimme_acid in this performance", "theres no funk->piment transition! in this set
      i playd piment once, then funk", and "it could be called Outro Dub Siren".
      
      The notes were never actually gone: the textarea writes `{t: t ?? null, comment}`,
      so a skipped call keeps its comment in state and in localStorage. Emitting
      `skipped` as {id: {source, note}} therefore recovers notes ALREADY STORED, not
      only future ones — PLN re-exports and gets them back verbatim. Verified the built
      bundle carries it, after first mis-verifying: `grep -c` counts LINES and a
      minified bundle is one line, so the honest count is 2 occurrences, not 1.
      
      With his corrections the true set is 14 tracks, written to
      judge_specs/cosmicfest-2026_setlist_ear.json as ground truth.
      
      Scored against it, the three-lens pipeline named 12 of 14 tracks correctly and
      labelled 81% of the 63 minutes, 85% of the labelled overlap correct. bombe_dj,
      rose_rouge, ouais_je_funk, punkachien and livecode_parade landed at 100%.
      
      The two failures are the same failure. `perfect` was called gimme_acid and the
      improvised outro was called REVOLUTION, and both are reference-bound: crossmatch
      can only choose among tracks it holds a prior render of, so audio from a track it
      has never heard gets assigned to the nearest thing it has. All five "ghosts" —
      blue_gold, desire, gimme_acid, quand_on_decolle, REVOLUTION — are that same
      mechanism. It is a structural limit, and the fix is one render per track, not a
      better threshold.
      
      Also banks his ear-feedback in performance_notes.md, including two things no
      analysis could have produced: a loud blip in the 2.9 s between two tracks (a
      defect belonging to NEITHER, which the boundary tool currently has no way to
      express), and why desire was dropped — "Clearly didnt play desire i was not found
      of that closing at opal".
      PLN (Algolia) authored
    • 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 7 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