- 30 Jul, 2026 1 commit
-
-
Four tasks closed 2026-07-29 evening: the silent-eval harness fix (and the real bug it uncovered), the take-pack sidecar+archive tool, and the desire rhythm study. Written for a cold reader per the archive's own convention — commit hashes are in git log, this is the narrative and the numbers that don't show up in a diff.
PLN (Algolia) authored
-
- 29 Jul, 2026 39 commits
-
-
The existing MORNING.md led with a warning about ctrl+enter fading to silence over 4 bars (#79) — fixed and confirmed by ear hours ago ("no sample goes oblivion!!👏 "). Leading with a solved problem buries what actually needs review tonight. Replaced with five things worth his eyes/ears, ordered by urgency: the desire compile break (#108, a real gig blocker), the corrected take-94 mix (v2, the trim threshold fix), silent-eval now covering all 13 setlist tracks, the slop renderer's first real output (not yet good enough to send, flagged as such), and the four desire bass-rhythm alternatives to A/B. Everything else from the session is mechanical and needs no listening, so it's kept to one short list at the bottom rather than mixed in with what actually needs his judgment.PLN (Algolia) authored -
PLN: "i gotta solve desire bassline is not great rythmics coulld be cooler more nto more ete_a_mauerpark or insouciance or haunted_house i guess." Read the three references he named and extracted what they share. The diagnosis turns out to be mechanical rather than a matter of taste. desire's d4 is note (scale "aeolian" "0 . 0*<1 2> . <0 3> . 0*<1 1 4>" + 2 - "[24,36]") — four EQUAL groups, the same degree in nearly all of them, never a rest, never displaced. The only variable is density, applied to a repeated note. That is a metronome with a stutter, and no amount of filter movement makes it a riff. The six mechanisms both references use and desire has none of: 1. uneven durations via `@` (insouciance 5:1:1:1 of 8; mauerpark 1:1:14 of 16) — one long note holds the harmony, short notes carry the motion; 2. the pitch pattern IS the rhythm pattern, so a new note is a new onset, instead of a static degree with density stacked on top; 3. calage — `(0.125 <~)` displaces the whole line so it pushes against the kick rather than doubling it; 4. accent follows rhythm — `|* gain ("1@5 0.95 0.98 0.95")` carries the SAME weighting as the notes; 5. space — rests inside the phrase and whole bars masked out; 6. the phrase is longer than the bar (4-bar alternation, plus `slow 2`). Four proposals in study/desire_bass_rhythm.tidal. A, B and C each isolate ONE mechanism so what he hears is attributable; D combines all six. Pitch design is held constant everywhere — aeolian, +2 root, "[24,36]" double — because the key was measured at corr=0.903 and is not what he is unhappy with. All four verified through silent-eval --seeded: they compile and every one emits events cold. Measured onsets per bar: B 3.0, A 4.0, C 4.5, D 5.75, against ~5.5 for what desire has today. D is deliberately DENSER than the current line and still reads as a riff — which is the control that shows the problem was never "too many notes", it was where they fall and how long they last. desire.tidal is untouched: it is in his dirty working tree, and it also does not currently compile at all (#108).PLN (Algolia) authored -
PLN has said repeatedly he is bad at socials and hoped slop would carry that weight. The plan for this has been complete since the clip planner landed; the renderer had never been written, so nothing could actually be produced. It can now. `visuals/slop/render_slop_clip.mjs` takes either a planned cut (`--idea desire --cut 90`, from clip_ideas.json with its measured tilt/kick-density/novelty and playset choice) or an arbitrary window of any file (`--audio ... --start ... --dur ...`), and renders a vertical 1080x1920 reel with Slopmotion reacting to that audio. DESIGN NOTES WORTH KEEPING - It lives in THIS repo, not hexa. hexa is Kevin's project and its main is clean; the script borrows its node_modules through createRequire instead of being committed into someone else's tree. Its scratch files go in hexa/public/__slop and are removed in a finally block — nothing is left behind in his working copy. - Capture is REALTIME via CDP screencast, not per-frame screenshots. Hydra's animation clock is wall time, so frame-stepping plays motion back ~3x fast and drifts away from the audio it is supposed to be reacting to. Screencast frames arrive timestamped and become a vfr->cfr concat, so a dropped frame is a slightly longer one — invisible — rather than a wrong clock. - The bands are computed from the REAL audio through an AnalyserNode, not from the synthetic pump the FX-preview script uses. Kick is a fast rise in the sub band above its own slow running average, because an absolute threshold would need retuning per track while a relative one rides the mix. - Backdrop clip choice is a hash of the job name, not Math.random: two renders of the same cut must be comparable, which is the entire point of a review pipeline. - The viewport is 9:16 natively rather than a 16:9 scene centre-cropped afterwards, which would throw away most of the motion. TWO ENVIRONMENT PROBLEMS FOUND AND FIXED WITHOUT COLLATERAL - The hexa checkout could not boot at all: 9 declared dependencies were missing from node_modules, so vite 500'd on @supabase/ssr and @vercel/speed-insights. `npm ci` refused (lock out of sync with package.json), and plain `npm install` would have rewritten package-lock.json — a TRACKED file in Kevin's repo. Installed the 9 with --no-save --no-package-lock instead; his tracked files are byte-identical after. - Playwright 1.60 wants chromium build 1223 and the cache has 1217. Rather than pulling ~150 MB into PLN's shared browser cache unasked, the script finds the newest full chromium already present. Full chromium, not chrome-headless-shell, which ships without the GPU/ANGLE stack this needs. WHAT IS NOT DONE: frame rate. 7.0 fps at 1080x1920 under swiftshader — the page itself renders at 7 fps, so it is the GL backend and the machine, not the capture. The two follow-up measurements (hardware EGL, and half resolution) both came back at 1.1 fps, which is not a verdict on either: load average was 12.5 with Pulsar burning a core from the #7 marker leak. They measure the machine. Recorded in FEEDBACK.md with an explicit warning to re-measure on a quiet box before concluding anything — a false workaround adopted from a contended benchmark is very hard to remove later. Deliberately not pushed further tonight: a sustained software-GL render is a CPU load test, and those do not run at night here while the rig is up.
PLN (Algolia) authored -
Four of thirteen setlist tracks could not be built, so their orbits had never been verified cold. Two causes, both the same shape: THE HARNESS DIVERGED FROM THE BOOT ENVIRONMENT, which is what #93 predicted would keep producing new errors. 1. Ambiguous `cutoff`. BootTidal defines `let cutoff = pF "cutoff"`. In ghci a let binding SHADOWS an import, quietly and legally. The harness dedents that block to module top level, where there is no shadowing — GHC reports "Ambiguous occurrence" and the track becomes unverifiable for a difference that does not exist on the rig. Fixed by hiding every name the generated module defines from the Context import, with the hide-list DERIVED from the generated text rather than listed, so it cannot drift when BootTidal gains a helper. 235 names on the current boot. 2. Overlapping IsString instances on a chord literal ("<gb3'maj db3'maj bb2'min bb2'maj>" could be ParseBP's `Pattern a` or Simple's `ControlPattern`). ExtendedDefaultRules and NoMonomorphismRestriction are ON BY DEFAULT IN GHCI, which is where these patterns actually run. Turning them on matched the environment; annotating each literal would have treated the symptom one track at a time forever. === And then the third failure turned out not to be a harness limit at all `live/collab/raph/desire.tidal` line 46 reads `# pan 0.42plz /se`. It is committed, not a stray working-copy edit. `plz` is not a Tidal function and the file has NO blank lines, so it is one single do-block: that one typo means every orbit of desire is dead on ctrl+enter. It is on the OPAL setlist, six days out. The tool had been reporting it as "BUILD FAILED — harness limit, NOT a track verdict". That message was written to protect the music from the tool, and it was right twice and catastrophically wrong the third time: it told the reader, in bold, to ignore a broken setlist track. A parser miss must never masquerade as a data conflict, and the inverse is worse. So the verdict is now computed, not asserted. Errors whose line numbers fall inside the track's own generated lines get a distinct BROKEN verdict — "THE TRACK DOES NOT COMPILE — it will fail live too" — and count as a gig blocker. Errors in the boot helpers or the scaffolding stay a harness limit. Line numbers are also mapped back to the real .tidal by matching the offending line's TEXT, because a generated-module line number is useless to whoever has to fix the file and looks authoritative while being so; unmatched or ambiguous lines are left alone rather than guessed at. Result: 12/13 build and every declared orbit emits events cold. The 13th is a real bug in the music, reported as one, with a file:line you can jump to. NOT FIXED HERE deliberately: desire.tidal is in PLN's dirty working tree and Pulsar saves the BUFFER, so a disk edit made behind his back can be silently reverted by his next ctrl+S. It is item 1 of MORNING.md instead, with the one-line fix, to be applied in the editor where it will actually stick.PLN (Algolia) authored -
Take 94 came back with no track trail, and there was no way to tell from the logs whether the watcher had seen the track changes, failed to bind them, or simply never run. It had been running for 2h46m. It had emitted nothing but systemd's own start/stop lines the entire time. Two independent causes, and either alone is enough to blind the unit: 1. Python BLOCK-buffers stdout when it is a pipe, and under systemd it always is. Even the lines that were being printed would have sat unflushed in a 4 KB buffer. Fixed with Environment=PYTHONUNBUFFERED=1 in the unit. 2. `--watch -q` suppressed the track-change line itself, so there was nothing to flush. That is the wrong thing to make quiet. `quiet` should mean "no per-CC chatter" — thousands of lines an hour — not "never say what you are painting", which is a handful of lines an hour and the entire purpose of the daemon. The track-change log is now unconditional. Verified live rather than by reading: restarted the unit, wrote a different track to ~/.cache/parvagues/current-track, restored the original, and read the journal back. Both transitions appear with their binding counts (desire 28 controls, vague_de_crime 20), and a re-parse of the same file is labelled distinctly from a real track change — that distinction matters because an edit-and-save must keep the touched-state while a genuine track change must clear it. Same family as the stale-binding pattern this rig keeps producing: something resolved once, invalidated by an event, with no error anywhere. Here the error reporting itself was the thing that had been silently resolved to /dev/null.
PLN (Algolia) authored -
Two things in one file, both from the same evening's practice recording. === PART 1: `pack` — the performance must travel with the audio (#106, #107) "ensure recordings have the midi too, keep this from blowing up tracking either ardour rec or compressing to avoid storing hours of no-move?" Today the audio is permanent and the performance is not. Ardour's sources live forever under the session; the MIDI and the track timeline sit in a gig-log directory that prune() deletes on a timer. So the 29862 CC events that explain take 94 were on a countdown while the audio they explain was not. `pack` writes a sidecar next to the audio: meta.json (window, sr, subtype, per-orbit peak dBFS, silent orbits, xrun delta, gear + boot state), midi.jsonl (every cc/note/track/mark record in the window, copied verbatim out of the log so pruning cannot reach it), and edl.json. The sidecar states its own honest limit in a field: gig-log COALESCES CC per (port, channel, controller) per second, keeping count + first/last/min/max. That is a faithful summary of a knob sweep and is what makes the log cheap, but a .mid rendered from it would be a reconstruction, not a recording. The JSONL is the truth. `--compress` then wavpacks every source and verifies each one with `wvunpack -vm` before reporting a single byte saved — an archiver that reports a ratio without checking is reporting a hope. The .wav is NEVER deleted; removal stays a separate human step after the take has been heard. FLAC was rejected outright: it cannot store 32-bit float, and routing through s32 would hard-clip the 8 orbits sitting over 0 dBFS while looking like a win on the silent one. Two measurement bugs found by running it on real data rather than trusting it: - xrun is a RUNNING TOTAL in gig-log's `s` records, not a per-tick delta (its own CUMULATIVE tuple exists for exactly this mistake). Summing it reported 58,668,469 xruns for a 21-minute take. Rebased to last-minus-first: 230, a real number. - Records are not events. A `cc` record is a one-second bucket carrying `n`, so printing the record count understated the performance 25x (1167 vs 29862). === PART 2: the trim threshold was protecting every bad transition PLN heard the first machine-trimmed mix and found the hole: "silence around 18 is not normal its a bad transition ahah in a real mixing work wed cut it proper and fade or at least trim most". The gap he heard WAS detected and reported, and survived anyway. SILENT_RUN_S was 20 s; the three gaps that made it into the mix measured 17 s, 18 s and 11 s. Every one sat just under the bar. A threshold chosen so the trimmer could never eat a musical break had silently become a threshold that preserves every failed transition instead. Three changes, all of them his rule rather than a tuning: 1. CLAMP, don't binary-cut. The bar drops to 10 s, but a detected silence is no longer removed whole — the last 2 s before the music resumes is kept as a breath. That is "or at least trim most" implemented literally, and it makes the lower threshold safe: the worst case if the rule fires on something musical is now a shortened rest, not a missing one. classify() computes both the detected region and the removed region so the report and the mixer cannot disagree. 2. FADE every seam. Every cut this tool had ever made was a butt-join, which in a live room is a click. Each removal now fades out into the cut and back in out of it over 250 ms, applied sample-accurately — the old mixer decided keep/drop per one-second block by its midpoint, quantising every boundary to +-0.5 s. Verified on the re-rendered take: max sample-to-sample step at the five seams is 0.0001-0.0029 against a p99.99 of 0.0562 for the take as a whole, i.e. the joins are 20-100x below ordinary musical transients. 3. BRIDGE blips. A dead patch is rarely clean end to end, and one orbit's tail crossing -60 dBFS for a second splits it into pieces that each fall under the bar. Only single-orbit blips are bridged: a real hit lights several orbits, and that is what keeps this from swallowing a one-shot. Result on take 94: 143 s trimmed instead of 119 s, all five gaps closed including the one at +18.0 min he named, output 18.75 min, peak -3.36 dBFS. === `selftest` Added because two of these rules could not be validated on real material — the bridge fires zero times on take 94 and a correct fade is inaudible by construction. A rule you have never seen fire is a hope, not a rule. It found a bug on the first run, in the test's own arithmetic: asserting "no span survives a multi-orbit hit" was wrong because the left half is exactly SILENT_RUN_S long and qualifies by itself. The property that actually matters is that no trimmed span ever CONTAINS a moment when the music was playing, and that is what it now asserts. Correction to a verification, worth recording: the first check of the re-rendered mix reported an 11 s silence still present. It did not exist. I measured the output against -60 dBFS while the mix carries a uniform -16 dB, so I was testing at an effective -44 dBFS at source. At the matched threshold exactly one rest over 4 s remains, 9.25 s, correctly left alone as musical. The right lens per control, again.
PLN (Algolia) authored -
First listen to the auto-trimmed take-94 mix. The verdict on the sum itself is good ("great mix itself its clean") which clears the record-bus routing of suspicion: 12 orbits summed with a flat -16 dB keeps the balance he actually performed. The correction is the valuable half. He flagged the silence around 18 min as "not normal, a bad transition — in a real mixing work wed cut it proper and fade or at least trim most". That silence WAS detected and reported by take-lens, and survived anyway: SILENT_RUN_S was 20 s and all three surviving gaps measured 17 s, 18 s and 11 s. A threshold chosen so the trimmer could never eat a musical break had become a threshold that preserves every bad transition instead. Two rules extracted, both more general than this take: clamp long silences to a short breath rather than choosing between keeping all of it and removing all of it; and fade every cut seam, because every removal this tool has made so far was a butt-join. Also logged: the stretches he heard as "stuck in loops" are precisely the `loop` kind the classifier already flags and deliberately does not auto-cut.PLN (Algolia) authored -
Added `mix` (sum the orbits to one gain-corrected, trimmed stereo file), then found two real bugs in the trim rule by verifying my own render instead of trusting it. BUG 1 — the rule demanded idle hands AND silence audio_lens profile on the rendered mix reported rms -180 dBFS at 19.2 min: DIGITAL silence, sitting in the middle of a "trimmed" master. The first rule only cut where the hands were idle AND nothing sounded, and there PLN was busy on the controller with nothing playing. That third state — fiddling in silence — IS the practice/ plugging-in the trim exists to remove. Silence needs no corroboration from the hands, so it is now its own kind with its own (much shorter) 20s threshold: long enough to never eat a break or a drop's pre-silence (~4 bars, under 8s at these tempos), short enough to catch the real thing. BUG 2 — and the reason it was invisible: analysis ran at DISPLAY resolution A direct 1-second measurement of the same mix found 103 seconds of silence in five runs of 11-31s. The classifier, bucketing at 15s, found NONE of them: a 17-second silence straddling two 15-second buckets leaves no bucket fully silent, so every bucket's rms sat far above -60 dBFS. The threshold was fine; the grain was lying. Analysis now always runs at GRAIN_S = 1s and `--bucket` only sets the sparkline width. Detection resolution must never be a display preference — the coarse view is for eyes, the decision is for data. STRUCTURAL: one rule, one reader `classify()` is now the single source of the cut rule and `analyse()` the single audio pass, both shared by the report and the mixer. They were duplicated, which is the worst possible arrangement here: a mixer that cuts what the report called `keep` destroys material silently. Also dedup candidates by 80% OVERLAP rather than strict containment — the hands-idle run began 1s before the silence run on take 94, so containment reported the same stretch twice and the table contradicted itself. VALIDATED end to end on take 94 (21.14 min, 12 orbits) candidates 5, no contradictions: 3x silence (21s, 30s, 68s) TRIM, 2x playing (68s at 8.00 and 3.72 orbits avg) KEEP. Trimming only `silence`+`dead` -> 19.15 min out, peak -3.36 dBFS, rms -24.47. Re-measured the render: silence 103s -> 52s. The three survivors are 17s, 18s and 11s — all under the 20s threshold BY DESIGN, plausibly musical gaps that want an ear rather than a rule. Spectral balance broadband at 60s/300s/600s/900s via audio_lens profile (the opening 20s reads kick/sub-only because it genuinely is). `loop` is deliberately NOT cuttable: a droning pad under idle hands can be an intro as easily as a distraction. It proposes; the ear decides.
PLN (Algolia) authored -
PLN asked for "a master split per track loaded time, cleanup parts obviously 'practice or plugging in' that are scarce VS intro/outro (can we detect this? did we record play metadata as the ardour records?)". Three datasets existed and had never been joined: - Ardour names sources `Take<N>_Tidal <orbit>-<seq>%<L|R>.wav`, so the take number plus the frame count plus the mtime give an exact wall-clock window with no session-XML parsing at all. - gig-log has coalesced MIDI CC at 1 Hz: 29862 events over this take, on 46 distinct controllers. Hands-idle is the clearest signature of "plugging in". - per-orbit RMS/peak says what the SOUND did. The third one is not optional, and that is the design insight. Idle hands ALONE is ambiguous: you can play a part for three minutes without touching a knob. So a candidate is classified by what the audio was doing underneath it — `dead` (nothing above -60 dBFS: safe to cut), `loop` (a couple of orbits droning, hands off: probably plugging in), `playing` (most orbits going: a real part, KEEP). On take 94 that distinction earned its keep immediately: of three hands-idle runs, two were real parts at 8 and 4 orbits average, and only the 75s tail was actually dead. FINDINGS ON TAKE 94 (21.1 min, 20:08:33 -> 20:29:41, 12 orbits) - 8 of 12 orbits peak OVER 0 dBFS, worst d4 at +9.63 dB. The files are 32-bit FLOAT, so this is headroom and not damage — a gain cut recovers it exactly, and it only becomes real clipping on integer export or at the master bus. Labelling it "CLIPPING" would have been a false alarm sending him to re-record material that is completely intact, so the tool now reads the subtype and says which it is. A uniform -16 dB on the record bus lands the hottest orbit on the -6 dBFS source target. - d12 silent for the whole take (-78 dBFS). - no track timeline: nothing published the loaded track while it recorded. The tool says so plainly instead of guessing, because a confident wrong boundary is worse than none — the existing Bandcamp splits carry crossfade bleed for exactly that reason. Takes from d5baca32 onward split exactly. A MEASUREMENT TRAP, RECORDED IN THE CODE The first pass estimated duration as size/(sr*3), assuming 24-bit. These files are 32-bit float. That put take 94 at 28.2 min starting 20:01:30 instead of 21.1 min starting 20:08:33 — and manufactured a "6m15s dead intro" that never existed, purely from a 7-minute window shift. The fallback now assumes 4 bytes and, more importantly, exact lengths come from the frame count and estimated ones are labelled "do not cut from it". Also: logs_covering() checks EVERY log's [first,last] range rather than assuming the newest one holds the window, because a take can straddle a recorder restart — which is exactly what happened today. It proposes, it does not cut: output is a report plus an EDL JSON for armada/tide-table/edl_render.py. Trims are a taste call and the ear is the gate.PLN (Algolia) authored -
PLN recorded an afternoon of OPAL takes, then asked for "a master split per track loaded time". The answer should have been trivial. It was not: nothing in the rig recorded WHEN each track was loaded. The gig log had 78222 samples of thermals, xruns, gear and 7429 coalesced MIDI CC events for today alone — and no way to say what any of it was. A CC 41 is meaningless on its own, because the LCXL map is PER TRACK: on one track index 41 is a crush bus, on another it is a break gate. The performance data was all there and none of it was resolvable. Meanwhile the exact ground truth was sitting in a 33-byte file the whole time. Pulsar has published `~/.cache/parvagues/current-track` since #84 (2576e77) for the LED watcher to follow. Nobody was reading it into the log. WHY THIS MATTERS MORE THAN THE SPLIT IT WAS ASKED FOR Splitting a livecoded set by ear is genuinely hard, and we have the scars: orbit roles stay stable across tracks so level/presence detection fails, and every dN is a 4-cycle crossfade that bleeds the incoming track into the outgoing one's tail — the existing Bandcamp splits carry that bleed. A `k:"track"` line replaces all of that inference with a timestamp. Boundaries stop being clever and become read. And it retro-actively upgrades the CC stream from telemetry to an annotated performance score: track + CC + time resolves, through the per-track LCXL map we already generate, to "d4's DJ filter opened at 18:42:13". That is the input for telling practice apart from performance (hands-off vs hands-on density), for finding the moment worth clipping, and eventually for the emotion/timeline bundle. IMPLEMENTATION — deliberately the boring version - `_read_track()` never raises: absent file (publisher not up yet) or a short read both degrade to None. A missing track must degrade the log, never stop it. - Written ONLY on change, so a 3-hour session costs a handful of lines instead of 10800 repeats of one string. The header carries the initial value, so a session that never switches still has one authoritative statement of what was loaded. - Polled, not watched — for the same reason the LED watcher polls. One stat+read of 33 bytes per second is unmeasurable; an inotify watch adds a dependency and a whole class of "the watch silently died", which is the exact failure mode this data exists to prevent. - `load()` folds track records into `marks` so they render in the existing timeline for free, keeping `k:"track"` so a splitter can tell an automatic boundary from a human annotation. VALIDATED - `selftest` PASS; sampler + both readers still 0.66% of one core over 6s, well under the 3% observer budget (the observer must not perturb — we already learned that lesson when probe-chain's own capture streams caused the xruns). - END-TO-END, not just unit: ran the real recorder against a temp track file, moved it twice, and read the log back. Header carried the initial track; both changes appeared exactly once with their `from`, each within the 1s poll. Green tests on a pure function would not have proven the value reaches the file.
PLN (Algolia) authored -
#46 #90 #94 #97 #100 #84 #35 off the active board, each with the non-obvious part kept: the 32 role inversions phase 2 shipped and how they were repaired, why the FX bus rule is PV011 and not PV010, and the two independent highlight bugs (a class name that never matched, and an outline fix written in the one stylesheet copy that could not unset the other's border). Narrative in log 019.
PLN (Algolia) authored -
Captain's-log entry for the 2026-07-29 session, written while it is fresh (the armada/tasks discipline: these logs are the raw material for posts and video narration, not bureaucracy). Two findings, one class: a machine check stayed green while the instrument was broken. One was Tidal's (xfadeIn turns the outgoing pattern down but never off, and a gain-0 event still broadcasts its cut group). One was mine, shipped the previous evening (the button remap assigned cells in reading order, inverting 32 gate/gesture pairs that only PLN's ear caught). The shareable core, for later: a gain of zero is not the absence of an event. In a system where events carry side effects — voice stealing, global FX bus writes — the silent ones still act. Plus the green-check problem twice in one day, convergence as a bug report, and the honest limit of verdict-equality checking.
PLN (Algolia) authored -
The bug that ate PLN's whole afternoon, and the reason he was working around it by hand: "so for now hush then ctrl+enter seems like a fix ahah but not a lovely one :D". ## The symptom, and the clue that cracked it "wait i feel theres still a xfade issue? when i leave d5 on the 33 effcet, it fades in oblivion!" (and d4, d7, d8, d12...) "reboot-then-ctrl-enter: doesnt seem to obliviate, loops forever. sounds like a track-to-track state bug" Same code, two outcomes, depending on history. That is the whole diagnosis in one sentence — and `hush` curing it is the confirmation, because `streamHush` PREPENDS silence to the pattern history. ## The mechanism `BootTidal.hs:100` made every dN a 4-cycle crossfade (#13): xfade i = transition tidal True (Sound.Tidal.Transition.xfadeIn 4) i d5 = xfade 5 . (|< orbit 4) Tidal's xfadeIn is one line: xfadeIn t now (pat:pat':_) = overlay (pat |* gain rising) (pat' |* gain falling) The envelopes are correct — measured with queryArc, the incoming rises 0.442 -> 1.0 and the outgoing falls 0.990 -> 0.0 across the 4 cycles, then both saturate. The bug is that the outgoing pattern is turned DOWN but never OFF. `|*` takes structure from the LEFT, so `pat'` keeps every event it ever had. Measured: at cycle 64 of a 4-cycle fade, the outgoing pattern still emits one onset per cycle. **And a gain-0 event is not a harmless event.** SuperDirt broadcasts the cut group in `playSynths` (DirtEvent.sc:182-189) *before* amplitude is ever read — `~amp` only appears later, at line 160, as an argument to the gate synth — and for a positive `cut` it sets `cutAllSamples: 1`, releasing EVERY voice in the group regardless of sample. So the silent ghost keeps killing the incoming pattern's voice on every single hit, forever. The orbit is not fading out. It is being cut to death by its own predecessor. Every observation follows, including the ones that looked contradictory: * only orbits that own a `# cut` die. In take_5_drops, where PLN said "weirdly all d123 stay", d1/d2/d3 have NO cut group — and the ones he reported dying in vague_de_crime (d4, d5, d7) have cut 4, cut 5, cut 7. * a fresh boot is fine: one pattern in history, and `xfadeIn _ _ (pat:[]) = pat` returns it untouched, envelopes and all. * "4 bars" is the fade length. You hear the OLD pattern fade out — it wins the cut, being overlaid second and therefore sent last — and the new one never arrives at all. * d8's "weird glitches instead of proper heading breaks": cut 8, a chopped break fighting its own ghost. Not explained by this, and still open: take_5_drops' d4, which has no cut group at all. That fade is a separate lead (it shares `crushbus 41` with d7 — see the new pvlint PV011). ## The fix Keep Tidal's envelopes EXACTLY, and stop the outgoing pattern when its fade ends: xfadeCutIn t now (q:q':_) = overlay (q |* gain rising) (playFor now (now + t) q' |* gain falling) Measured, onsets per cycle (incoming/outgoing), fading at cycle 0 over 4: xfadeIn 4 1/1 1/1 1/1 1/1 | 1/1 1/1 1/1 1/1 ghost forever clutchIn 4 0/1 0/1 1/0 1/0 | 1/0 1/0 1/0 1/0 clean, never overlaps xfadeCut 4 1/1 1/1 1/1 1/1 | 1/0 1/0 1/0 1/0 clean from cycle 4 `clutchIn` is also clean — it degrades one pattern into the other so they never overlap at all — and is arguably the better transition for a rig where most orbits own a cut group. It is NOT the default here because it changes the feel of every transition on the rig, and 7 days before a gig is not when to do that. It stays available as `clutchIn`, and Tidal's original stays reachable as `xfadeLeaky` for A/B comparison. Audibly, for a cut orbit, the transition becomes: the old pattern fades out over 4 bars, the new one arrives at full exactly as its envelope reaches 1.0. Which is what PLN already describes hearing — except that now the new one arrives. ## Validation - The binding was lifted VERBATIM out of BootTidal.hs and typechecked against the type `transition` demands (`Time -> [ControlPattern] -> ControlPattern`). The stream block cannot be typechecked in place — it closes over a live `Stream` — so this is how that part of the file gets checked at all. - `tools/check-boot.sh` gains **Pass 5**, a permanent gate: it lifts `xfadeCutIn` out of the boot file and counts onsets from the outgoing pattern at cycles 4/5/8/16/64/256. Must be 0. - Mutation-tested, which is the only reason to trust it: with `playFor` removed the gate reports 6 ghost onsets and fails. Passes 1-4 all stay green on the broken version — it typechecks, it parses as Pulsar sends it, and it emits events. Counting the outgoing onsets is the only check that can see this class of bug, which is exactly why it now runs on every boot check. - All 32 blocks still parse as single statements (#79 seam intact) — the new multi-line `case` sits inside the existing `let`, so it is still one statement.PLN (Algolia) authored -
These five could not go in the previous commit because they hold PLN's own uncommitted work from this afternoon's recording session, mixed in with the role alignment. Committing them anyway, because durability beats a tidy diff: once they are in git, a stale Pulsar buffer overwriting one of them shows up as a diff instead of vanishing silently (#95, the buffer-clobber trap). ## What is HIS in this commit - **bombe_dj**: he resolved the d1 button overflow BY HAND, exactly the way #54 proposes. The three lines phase 2 had commented out with a FIXME are back, folded onto d1's own controls: the sample swap moved to a KNOB (`midiOn "^29" (# "kick:4")`) and the kick variants merged onto `^41`. That is a manual gSel, and it is better than what the FIXME asked for. - **the_revolution_will_be_sampled**: he fixed the d8 inversion himself, restoring `midiOn "^92" (ply ...)` / `midiOff "^60" (mask ...)` before any tool got there. - **vague_de_crime**: a `# cps (130/60/4)` gesture parked as a comment and another moved onto ^30; d1's commented overflow restored onto ^41. - **do_it_right**: a `dr` typo removed, a d5 gain nudged 1.3 -> 1.4, a `-- TODO: Move to d6?` note, and an extra d5 ply gesture. - **take_5_drops**: the `31` -> `21` typo in d12's `n` pattern, one `>|` -> `|>` on d5, gains adjusted. ## What is MINE The button-role alignment only — `"^NN"` token moves, per the two commits above. ## One thing worth recording about the validation bombe_dj's d7 exposed a limit of verdict-equality checking. The swap rewrote `midiOn ("^59" - "^91")` to `midiOn ("^91" - "^59")`, and subtraction is not commutative — that LOOKS like an inverted gesture. It is not: all three references in the block flipped together (the two operands and the outer/inner `midiOn` pair), so it is a consistent relabeling and the behaviour is identical with the two physical buttons exchanged. But silent-eval could not have told me either way. Both CCs seed to 0, so `0 - 0 = 0` whichever order they are in, and the default branch is untouched by construction. **Verdict equality at the seed proves nothing about behaviour under a knob press.** It is the right gate for "did this rewrite silence anything at boot" and the wrong gate for "does this control still do what it did". The second question needs pv-at (#74), or an ear.PLN (Algolia) authored -
Applies the convention from the previous commit across the corpus: gates onto the latching row, gestures onto the momentary one. 34 files here contain nothing but `"^NN"` token changes — verified mechanically by normalising every changed line and checking the -/+ pair is identical once the CC is masked. ## What moved 52 corpus-wide repairs (tools/fix-button-roles.py, three converging passes) 29 setlist permutations (tools/migrate-columns.py --apply) The two tools do different jobs and both are needed. fix-button-roles only MOVES a control to a free cell, so it declines a case where the target is occupied. migrate-columns plans a whole column at once, so it can SWAP — which is what the remaining cases needed: d5's `ply` and d5's take-chooser both wanted a cell, and exchanging them puts the held flourish on the momentary button while the chooser (a state: which take plays) takes the latching one. That reading was not designed, it fell out of role-first assignment with a positional fallback, and it is right. ## Proof it changed no sound `tools/silent-eval.py --seeded` before and after, twice (after the corpus pass and again after the permutation): **byte-identical verdicts** both times, including the same 3 harness build failures (#93). Every button CC seeds to 0 in BootTidal's `_seed`, so permuting CCs *between two calls in the same block* cannot change either call's default branch — `midiOn` stays never-applied and `midiOff` stays always-applied. The edit is event-neutral at boot by construction; the harness run confirms the construction. `tools/surface-columns.py` still reports 83/83 orbits column-aligned with 0 renumbers needed — the swaps stay inside each orbit's own column, so the property phase 2 established is preserved. ## Still open, deliberately 12 sites where ONE orbit drives two gestures. Only one momentary cell exists per column, so the second has to sit on the latching row. Reported as PV010 `info` and not repaired: the real fix is gSel (#54), folding a pair of gestures onto one control. Naming it beats hiding it.
PLN (Algolia) authored -
PLN found this by ear, twice in ten minutes, while recording OPAL takes: "techno drum mask is inverted on d8, should ALWAYS be the mask midiOff on ^60 and the ply or other multiplier/nassim button effect on the push-release 92" "drums in revolution seem way too fast ... yea def an inversion" ## The bug I shipped Phase 2 of the surface remap (5910aacc) column-aligned every button reference, and assigned each orbit's two cells in FIRST-APPEARANCE ORDER. That is role-blind, and tracks conventionally write the gesture line above the gate line: $ midiOn "^92" (ply "1 <2!3 4>") -- momentary flourish $ midiOff "^60" (mask "t(4,8,1)") -- latched gate so the first control encountered — the gesture — took the LATCHING cell, and the gate took the momentary one. Measured on the diff itself rather than on corpus history: of the button lines that commit moved, **32 were inverted**, 11 happened to be repaired, 10 were already wrong, 2 were right both times. 13 files, nearly all of them in the OPAL setlist. "Way too fast" is the audible signature: a `ply` on a latching button stays multiplied after one press. The mirror image is a `mask` that only gates while a finger holds it down. Neither errors. Neither is silent. Only the ear catches it — which is precisely the class of bug that needs a machine check. ## The convention, now written down once row E (41-44, 57-60), latching -> GATES (mask, struct) row F (73-76, 89-92), momentary -> GESTURES (ply, fast, stut, chop, slice...) A real limit of the grid, found while encoding it: **d1-d3 own only ONE button.** Their row-F cells (73,74,75) are the per-family mutes gMute1/2/3, so a gesture on ^41/^42/^43 is not an inversion — it is the only cell that exists. Every tool here skips those columns rather than inventing a slot. ## tools/button_roles.py — one judgement, three consumers migrate-columns.py ASSIGNS cells, fix-button-roles.py REPAIRS them, pvlint PV010 REPORTS them. Three copies of a musical rule is three chances to drift (the #97 lesson, applied before it could bite). Two parsing rules were earned in the space of one afternoon, and both were wrong in my first cut: 1. **A gate beats a gesture at the SAME level.** `mask "t(8,16,1)" . chop 16` is a latched break-gate that happens to chop while open. Treating it as ambiguous is what hid revolution's d8 pair from the first scan — the very pair PLN heard. 2. **But only at the same level.** `superimpose (struct "t . t(3,8)" . arpeggiate . (|+ note 12))` is a HELD FLOURISH; the `struct` builds the added layer's rhythm two levels down and says nothing about the button. A plain substring search called phunk's d6 a gate and moved it to the latching row — the same inversion, one level deeper. So the role belongs to the top-level chain, with nested arguments stripped and string literals skipped (`mask "t(4,8,1)"` would otherwise look like it opened two parens and swallowed the rest). 3. **The body is not the rest of the line.** `midiOn "^91" ( -- SLICE!` puts the function on the FOLLOWING lines. A same-line regex returns "unknown", and an unknown control gets a positional fallback — re-creating the inversion. The classifier now follows the parentheses, capped at 8 lines. ## Validation - 214 tests pass (24 new: 14 for the classifier, 10 for PV010/PV011). - The classifier is CONVERGENT: applying the repair twice yields 0 further rewrites. That is what caught mistakes 2 and 3 — each fix made the tool disagree with its own previous pass, and the disagreement was the bug report. - pvlint on the setlist: 0 errors, and PV010's 12 unfixable cases are reported as `info`, not warnings, because an orbit with two gestures genuinely cannot put both on the momentary row. A lint that nags about the impossible gets ignored, and this one has to stay trustworthy enough to gate a gig. ## PV011 — one FX bus slot shared by two orbits PLN's ask, right after PV004 surprised him with a shared cut group: "putain a unexpected cut shared, well done! like shared busses they screw things. can we lint and flag these? diff bus shared button is ok, but to be detected too". A `<fx>bus N` is a SLOT in SuperDirt, so two orbits naming the same number share one instance and the last event to land sets the amount for both. It immediately found take_5_drops putting d4's bass and d7's choir on `crushbus 41` — the standing suspect for "d7 feels way more attacky than before". Different effects on the same number are fine and are not flagged: the namespace is per effect, exactly as he said.
PLN (Algolia) authored -
feat(pvlint): PV009 — gain MULTIPLIED by a control that seeds to zero, i.e. a layer that never arrives PLN, mid-run, working it out himself: "d9 on wap didnt take effects on 17 and 19? ok no its the 17 superimpose that melts too !! does our xfade screw the superimposes?
💡 " The xfade is innocent. wap.tidal:91 is $ superimpose ( (# "acidOto3091") . (|- note 12) . (|* gain "^17") . (# cut 91) ) and ^17 seeds to 0 under BootTidal's policy ("A/B knobs -> 0, effect amount off"). Gain MULTIPLIED by 0 is silence, so the superimposed layer is not quiet — it does not exist. The 4-cycle dN crossfade then eases from the previously-audible state into that silence, which is what reads as a melt and is exactly why the xfade looked guilty. It was the messenger. The distinction the rule encodes: `#` ASSIGNS and survives a 0 seed (`# crushbus 51 (range 16 4.5 "^53")` at seed 0 is simply crush 0), while `|*` and `|/` ANNIHILATE. Only the multiplicative form can delete a pattern, which is why flagging `#` sites would bury the real finding under hundreds of false ones. SEVERITY IS `info`, DELIBERATELY. This idiom is usually correct — it is a bring-in fader for a layer, and PLN uses it that way. The rule does not exist to call it wrong; it exists so nobody hunts it BY EAR. Making it a warning would fire on a normal, intended idiom and get the whole gate ignored, the mistake already made once in check-drift's first draft. It shows under `pvlint --info`. WHY IT MATTERS MOST RIGHT NOW: a remap moves the CC, but it does not move the KNOB. Physical positions survive across a surface change, so a knob left at 0 silently acquires a new victim. That is the single root cause behind everything PLN heard this session — d9's layer gone on wap, and the same class of staleness behind the d4/d5/d8 symptoms (latched buttons whose orbit changed under them). It is the identical failure to the very first question he asked today: Ardour's fader raised by mouse while the physical fader sat at 0. There are no motors, so the surface never follows the map. SCOPE, measured (both forms — `|* p (range 0 hi "^NN")` and the bare `|* p "^NN"`, which my first scan missed and which is the worse of the two): 33 sites across 25 files corpus-wide 2 in the OPAL setlist: wap.tidal:91 (bare), desire.tidal:75 (range 0 1.5) Notable elsewhere: scratchomatic multiplies gain by "^84" — an ARDOUR-learned fader — and computer_riddim/computer_riddub/anniv multiply by "^50", the gF2 DJ filter knob. 6 new tests. The negatives carry the weight, as always: `range 1 1.5` (the prescribed neutral-low-end fix) must NOT flag, `#` assignment must not flag, and C1-C3 must not flag because they seed to 0.5 (centre), not 0. 496 pass.PLN (Algolia) authored -
PLN named this himself while testing the remap: "i see the hud shows d9 on A8 so didnt we migrate parvagues HUD to new convention? why 2 sources of truth tbh" It was five. The CC -> physical-control -> owning-orbit mapping was hardcoded independently in: tools/surface-columns.py GRID tools/migrate-columns.py KNOB_A/B/C, BUTTONS, ARDOUR, label(), and the destination ARITHMETIC (28 + orbit, 48 + orbit) tools/lcxl-leds.py 12 + (orbit - 8) for the d9-d12 level knobs tools/pvlint/rules.py PV008's BT_CCS / BL_CCS / FAMILY_CCS <hud>/lib/render.js ORBIT_CONVENTION After the 2026-07-29 remap the Python copies moved and the HUD's did not, so the topbar drew d9 on A8 while the LED board and the .tidal files both said A1. The display contradicted the hardware under his hands, mid-test, and NOTHING FAILED — which is the property that guaranteed it would happen again on the next remap. NOW tools/lcxl_grid.py is the only place the grid is written: the row/column table, the role of every cell (level / fx / fx2 / gate / gate2 / family_filter / family_mute), the Ardour-owned set, and gPanic. Everything else derives from it, and the two non-Python consumers read a GENERATED artifact: tools/lcxl_grid.json for anything outside Python <hud>/lib/lcxl-grid.generated.js imported by render.js Same pattern as the fleet colour language (models.py -> gen_tokens -> tokens.css): author the ontology once in Python, generate for every other language. Two details worth keeping: * ROW ALIASES. The consumers had each invented their own names — D/fader, E/btn1/BT, F/btn2/BL. Forcing one vocabulary would have churned five files and PLN's own muscle memory for zero benefit, so every row carries all its names and each tool keeps printing what it always printed. * ROWS E AND F ARE ONE ROW OF EIGHT, not two of four. They are non-contiguous on the hardware (41-44 then 57-60) and modelling that as two rows is exactly what put d6's second button in column 5 in the old map. Asserted directly. THE TEST IS THE DELIVERABLE 14 new tests. Half assert the authored table is coherent (48 controls, every row covers columns 1-8, every orbit has a level/fx/gate, no Tidal slot lands on an Ardour-learned control, d1-d3 own exactly one knob and one button because C1-3 and F1-3 are the family controls). The other half assert every CONSUMER still agrees, and that regenerating the artifacts is a no-op — so a remap that forgets one copy fails the suite instead of shipping a lying topbar. One test simply checks render.js has not re-grown a literal ORBIT_CONVENTION. VALIDATION — behaviour must be bit-identical, this is a refactor surface-columns 83/83 aligned, 0 renumbers (unchanged) migrate-columns --plan: 0 moves, 0 overflow (nothing left to do) pvlint 13 tracks, 0 errors, 9 pre-existing warnings pytest 490 passed (was 476 + 14 new) HUD specs smoke / lcxl-leds / scene-directive all pass lcxl_grid --check 48 controls, every row 1-8, every orbit housed Also fixed while here: the LED watcher was still running the process started at 16:39, i.e. code from before the A1-lights-for-d9 feature existed. That is why PLN saw no A1 LED while d9 was declared — not a mapping bug, a stale daemon. Restarted. Worth remembering as its own class: for gear that runs as a service, "I fixed the code" is not "the rig picked it up".PLN (Algolia) authored -
Earned during the phase-2 button remap (5910aacc). The migrator left an over-budget control where it was, which LOOKS conservative and is the opposite: once ^42 became d2's button, d1's surviving reference to ^42 meant one press fired both orbits. perfect.tidal shipped that way until a hand-written check found it — and that check then existed only in a terminal scrollback, which is not a gate. WHY IT DESERVES TO BE AN ERROR, NOT A WARNING Nothing errors. Nothing goes silent. The extra layer only appears while a specific button is held. On stage that reads as "the track is broken today" rather than as a mapping bug, which is the exact signature of the failures this rig keeps losing evenings to. Same family as the orphan-orbit ghost: audible, plausible, and invisible to every static check we had. WHAT IT DOES NOT FLAG — the cry-wolf cases, each a real line from the corpus * gF1-3 and gMute1-3 (and gPanic) are per-FAMILY BY DESIGN — measured, not assumed: gF1 -> d1/d2/d3/d8, gF2 -> d4, gF3 -> leads. Flagging those would fire on every track in the set and the rule would be switched off within a day. * one orbit referencing a button three times is ONE gesture, not three collisions (bombe_dj's kick does exactly that) — findings are per ORBIT, as in PV004. * COMMENTED-OUT gestures. This matters more than it looks: commenting the surplus is precisely how #94 parks an over-budget control, so a rule that counted comments would fail every track the migrator just fixed. * knobs. Knob sharing is a columns question, not a button collision. VALIDATION OPAL setlist 0 PV008 findings — clean, as of 5910aacc whole corpus 136 findings across 56 of 792 files pvlint tests 38 passed (5 new: the real perfect.tidal case, plus one negative per cry-wolf class above) The corpus number is history, not exposure: those are old-world tracks that predate the column grid, and per the measure-the-set-not-the-corpus lesson the number that matters before a gig is the setlist's, which is zero. The corpus gets fixed when it gets migrated (#64 / post-gig), and now there is a rule that will prove it.
PLN (Algolia) authored -
96 substitutions across all 13 OPAL tracks. "Column N is orbit N" is now true for the WHOLE surface, not just the knobs: BT 41-44/57-60 d1..d8 gate BL 73-76/89-92 gMute1/2/3 | d4..d8 gate2 A 13-20 d9..d12 level | d9..d12 fx B 29-36 d1..d8 fx C 49-56 gF1/2/3 | d4..d8 fx2 D 77-84 d1..d8 level surface-columns: 23/84 aligned, 127 renumbers -> 82/82 aligned, 0 renumbers PLN drove the key decision. My first attempt left over-budget controls in place and reached only 11/13 tracks, which he rejected outright: "we need finish the job. why half moved? cant you move all-but then comment out with fixme?" He was right on both counts, and leaving them was not the conservative choice — it was a bug generator. THE CASCADE, AND WHY COMMENTING OUT DISSOLVES IT The board has 8 BT + 5 BL = 13 per-orbit buttons for 8 orbits, because BL1-3 are the family mutes. So d1..d3 get exactly ONE button each, and bombe_dj's kick — a 3-state gesture on TWO buttons — $ midiOn ("^41"-"^42") (<| "k(3,8) ~") $ midiOff ("^42"+"^41") (<| "k*<1!7 2> ~ ...") $ midiOn "^42" (<| "k k k*<1!8 2!8> ...") is over budget by design. Left in place, d1's ^42 blocked d2, which blocked d3, then d4, then d5: 4/8 aligned. Commented out, the control is free and the row aligns. Nothing is lost — the original sits one line above under two dashes, restorable the moment gSel (#54) can fold three states onto d1's own control. THREE BUGS THE GATES CAUGHT BEFORE THIS SHIPPED — all in my own tool 1. LEAVING OVERFLOW IN PLACE IS A CROSS-ORBIT TRIGGER. Once ^42 becomes d2's button, any surviving d1 reference means pressing d2's gate also fires d1's kick variant. Measured, not theorised: perfect.tidal ended up with ^42 driving BOTH d1 and d2. A new gate now asserts no button drives two orbits — 0 across the setlist. 2. THE CLASH GUARD HAD A HOLE. A clashed control also stays put, so it too becomes an obstacle — but I computed clashes in the same sweep that consumed them. bombe_dj: d2 was left on ^43 by a clash while d3 was still planned to move ONTO ^43. Fixed by iterating pass 2 to a fixed point, growing the sticky set until a sweep finds nothing new, with a non-convergence bail-out. 3. COMMENTING A LINE ORPHANS ITS CLOSING PARENS. A chain step spans several lines, so commenting only the line carrying the CC left the body live and the `)` dangling — `perfect: parse error on input ')'`, and desire went from building to not building. The unit is a `$`-chain SEGMENT; it is expanded to one, then paren-balance-checked, and the tool refuses rather than emit broken Haskell. 4. MULTI-BLOCK ORBITS WERE SILENTLY DROPPED. perfect.tidal declares `d1` SIX times as section alternates. Keying the plan by orbit number meant each later block overwrote the earlier one's plan, so the first block's ^42 -> ^41 vanished — the real reason gate 1 kept failing after I "fixed" it. An orbit's control set is the UNION across its blocks. Recovered 3 more moves (perfect 7->8, electric_hammer 3->5). VALIDATION no button drives 2+ orbits 0 tracks (was 1) silent-eval --seeded OK, every declared orbit emits events build failures 4, identical to before — no new breakage (#93) surface-columns 82/82 aligned, 0 renumbers pvlint 0 errors, 9 pre-existing warnings (#70, dup d5) pytest 471 passed check-boot / -blocks OK 9 overflow controls are now commented with a FIXME naming the orbit, its slots, and the restore path. That is the honest total: the surface is fully aligned, and every control that could not fit says so in the file rather than in someone's memory.PLN (Algolia) authored -
Held out of daf074f2 because the file was already in PLN's modified set and the standing rule is not to commit over his uncommitted .tidal work. He waived it: "commit over ouais je funk do the fix there too ill take it from there." Same mechanical change as the other 254 files — the 7-line shadow stanza removed so BootTidal.hs is the only definition: let gMask = (midiOn "^41" (mask "t!3 <t!3 [f <t f>]>")) let gMute = (midiOn "^73" (mask "f*16")) let gMute2 / gMute3 let gM1 = gMask . gMute / gM2 / gM3 `let width` and `let dMask` are untouched: BootTidal defines neither, and dMask is this track's own compositional mask, not boilerplate.
PLN (Algolia) authored -
refactor(corpus): BootTidal is now the ONLY definition of the global helpers — 1705 local shadows removed PLN loaded bombe_dj to test the new surface and found ^41 still chopping every bar, two days after gMask was "retired". He asked the right question: "why so many let gMask in bombe dj? did you do the edits yet?" THE BUG WAS ARCHITECTURAL, NOT A MISSED EDIT Every track opened with a copy-pasted stanza: let gMask = (midiOn "^41" (mask "t . <f t f <f t>> <t f f <t f>>")) let gMute1 = (midiOn "^73" (mask "f*16")) let gM1 = gMask . gMute let gF1 = (# djfbus 1 (range 0.05 0.95 "^49")) A local `let` SHADOWS the boot definition, so for years every central fix stopped at the door of 254 files. Two consequences, both live on stage: * gMask was retired in BootTidal.hs (`gMask = id`) and still masked on the 237 tracks that redefined it. The retirement reached 5 of 13 setlist tracks. * gF1/2/3 were rewritten to the two-sided gDJF (centre = bypass, left = lpf, right = hpf, and 0 = a 180 Hz lowpass = silence). 333 local defs kept the OLD one-directional `(# djfbus N (range 0.05 0.95 "^49"))`. So the DJ-filter fix that took a whole afternoon to get right was invisible in performance. Two tracks even carried `let midiGGlobal = "^77" * 1.5` — reading fader ^77, which is now MIDI-learned to Ardour's Tidal 01 gain. That is bug #53 alive: an accidental nudge of fader 1 would attenuate every midiG-using stream. WHAT LANDED tools/deshadow-helpers.py, --plan/--apply, two passes: pass 1 1551 deletions of exact boilerplate, so boot's definition takes over pass 2 154 deletions of defs pass 1 ORPHANED (a local `let gMute` whose only user was the `let gM1 = gMask . gMute` just removed). BootTidal has no `gMute`, so pass 1 cannot touch it, and half a stanza reads like it still does something. Referenced nowhere = zero events change. 11 COMMENTED OUT, not deleted, with a DESHADOW(#96) marker The safety property is that direction: a def is deleted ONLY if its body positively matches a known boilerplate regex. Anything unrecognised is commented, so a bad regex leaves a comment instead of deleting PLN's music. It earned that immediately — the 11 include real composition that only worked BECAUSE it shadowed: let gF2 = (whenmod 64 48 (# djf (slow 16 $ range 0.5 0.2 saw))) -- Parts DJF let gF1 = (someCyclesBy "<f!10 t!6 f!112>" (# djfbus 1 ...)) -- Intro DJF let gMask = mask "t t <t <t f> t <f [f t]>>" -- a fixed compositional mask and one latent build error, `let gF1 = (y# djfbus 1 ...)`, which cannot compile. DELIBERATELY NOT TOUCHED * gDJF (65 files). Local is `let gDJF = …` (0 args); boot's is `gDJF ch` (1 arg). Removing the shadow is a TYPE ERROR, not a global. Own pass, #96. * gMute/gM/gF/gM'/gDJF1/gMaskEnd16/… (315 defs) — names BootTidal does not define at all. Deleting breaks the build. VALIDATION — verdict equality, not "it looks fine" silent-eval over the 13-track OPAL setlist, before vs after, with an EMPTY control map. Every single line of the diff is a line NUMBER shifting by 7, the height of the deleted stanza. Same tracks, same orbits, same counts: before: 9 tracks with a silent orbit, 4 unbuildable by the harness (#93) after: 9 tracks with a silent orbit, 4 unbuildable by the harness -> VERDICTS IDENTICAL check-boot OK — all g* helpers audible with an untouched controller check-boot-blocks OK — 32 blocks, each still one statement pytest 471 passed surface-columns 5 knob misalignments at HEAD -> 4 now (desire's borrowed ^52 is gone). No regression; the remaining 4 pre-date this. Also in desire.tidal, on PLN's call ("just simplify to make it finish the job, losing nothing, leaving FIXMEs"): d3's ^52 was borrowing d4's column because the grid gives d3 exactly one knob (B3, already used; C3 is the gF3 family filter). Parked as a static `# legato 0.55` with the knob form one line above in a comment, and the d7/d9 open question closed as a FIXME rather than left blocking. Nothing deleted, everything re-enableable by removing two dashes. Excluded from this commit: live/midi/nova/lounge/ouais_je_funk.tidal, which was already in PLN's modified set. The deshadow is applied on disk there; his to commit.PLN (Algolia) authored -
Rich entries for the afternoon that turned the LCXL from a lookup into a channel strip. Written for a cold reader, because these are the blog/video source material. The four findings worth reading again months from now: * A daemon with THREE possible lifecycles, two of which could run at once — two processes writing SysEx to one board, fighting over every LED, neither wrong enough to look broken. * `StartLimitIntervalSec=0` in [Service] instead of [Unit]: systemd logs "Unknown key ... ignoring" and the default retry limit stays in force. A silent failure one section heading away from working. * 34 "destination already taken" clashes that were entirely PHANTOM — an artefact of costing a permutation one move at a time when the corpus is uniformly off by one. * A measurement that overruled my own design: I proposed eight per-column DJ filters; measuring which orbits actually receive gF1/gF2/gF3 showed they are per-FAMILY (drums / bass / leads) and consistent across all 13 tracks. Building the "better" design would have destroyed a working abstraction. Also: the physical LCXL faders sat at 0 while the Ardour faders had been raised by mouse — silent divergence, and the next brush of a fader would have killed the orbit mid-set. Found in a log, not by ear.
PLN (Algolia) authored -
PLN: "wap has atm bass on d4, yet d4 effects on 57 and 89!! Why dont i see your changes in tidal ??" ... then, on the diagnosis: "how can we trust each other with pulsar" and "cause reloads dont do it, and i could close tabs before your edits then, but its annoying ahahah". == WHAT HAPPENED == Pulsar saves the BUFFER, not the file. Its process had been running since Sun Jul 26 17:19, so every track opened before this afternoon held three-day-old text. PLN commented two lines out in wap.tidal and hit ctrl+S — and Pulsar wrote that entire stale buffer back, silently reverting EVERY control the column migration had moved: ^31 -> ^52 ^32 -> ^53 ^33 -> ^34 ^17 -> ^18 and it deleted the FIXME(#54) comment No warning, no conflict marker, no error. He noticed an hour later because a knob was in the wrong place. do_it_right.tidal was hit the same way. Two details that make this nastier than it sounds, both now written down: * "Window: Reload" does NOT fix it — that restores buffers from Pulsar's session cache, not from disk. The only reliable action is to CLOSE THE TAB and reopen. * The clobber is INDISTINGUISHABLE from a hand edit at a glance. It arrives inside a legitimate diff, mixed with real intent. Repaired by re-running migrate-columns (idempotent — it recomputes the permutation from current content), plus one hand fix for wap's d9 ^18 -> ^17 which the migrator does not cover since d9-d12 are not column orbits. PLN's actual intent — commenting out three crushbus/octer lines in wap d4 — is preserved: those were live pre- migration and he disabled them on purpose. Verified by diffing against the pre-migration commit rather than assuming. == WHY DETECTION AND NOT PREVENTION == Prevention is unavailable. Pulsar's ~/.pulsar/storage/application.json is 75 bytes of project roots — there is no way for a tool to ask which files are open, so I cannot refuse to edit an open file. And "close every tab before Claude edits" is a chore, not a system; PLN is right to reject it. So the trust mechanism is two things that need no discipline from either of us: 1. COMMIT IMMEDIATELY. Already the practice, and it is exactly why this was recoverable in two commands instead of lost — the clobber showed up as a diff against a known-good commit. 2. DETECT IN SECONDS, NOT AN HOUR. tools/check-drift.sh. == THE DESIGN LESSON INSIDE check-drift == The first version counted changed ^NN lines and called any bulk change a clobber. It then flagged my own REPAIR of the clobber as drift. That is crying wolf, and a gate that cries wolf gets ignored — the same principle already written into `gig-log preflight` about never failing on intentional performance state. The fix was to define the thing properly. Drift is not "control numbers changed", it is "control numbers moved AWAY FROM THE GRID". So the grid is measured FIRST (surface-columns --knobs, floor of 5 = the deliberate gSel overflows), and the working-tree diff is then judged in that light: bulk CC change + broken grid = DRIFT; bulk CC change + intact grid = a repair, reported and not failed. Verified both directions, which is the only way this is worth anything: positive — current repaired tree: exit 0, wap correctly read as "toward the grid" negative — restored the pre-migration wap over the top to simulate the clobber: 13 knobs out of column and 16 ^NN lines changed, both flagged, exit 1 Then restored, grid back to 5. Wired into gig-up.sh's readiness gate alongside preflight and check-mix, so the question gets asked whether or not anyone remembers to ask it.PLN (Algolia) authored -
PLN, after a change of mine reached his ears before a check did: "we gotta make things work bro we cant break as we move i thought we had clarity and confidence by now :) go on, take the longshorter path not hte immediate one". Right, and the honest diagnosis is not "be more careful". It is that this toolbox had a whole missing category of check. Everything in it is COLD: pvlint parses .tidal text silent-eval queries patterns against an EMPTY control map, in a fresh ghci surface-columns reads .tidal text check-boot loads BootTidal in a throwaway ghci check-mix reads the SAVED Ardour session All five were green all afternoon. Meanwhile gMask sat ARMED AT 127 on the live board from 15:36 to 16:50 — a global chopping the last eighth out of every bar of every orbit wrapped in gM1/gM2/gM3, which is every orbit of every track. Nothing was broken. A switch was on, and NOT ONE tool in the chain looks at the switches. PLN found it by ear, and the ear should never be the smoke detector. `gig-log preflight` closes that. It reads the live recorder's surface state and returns a verdict on the controls that can silence a whole rig from outside any pattern: gPanic armed, any gMute engaged, a DJ filter parked in a band-killing position, gMask armed if it is still active. == THREE DESIGN DECISIONS, EACH FROM A PAST FAILURE == 1. A STALE LOG FAILS. Exit 2, and distinct from exit 1 (surface unsafe) so a caller can tell "I could not check" from "I checked and it is bad". A preflight that reports SAFE because it read yesterday's session converts an unknown into a false reassurance, which is precisely the shape of every bad afternoon this rig has had. is_live() already existed; this is the first thing to gate on it. 2. gMASK'S MEANING IS READ FROM BootTidal.hs, NOT HARDCODED. ^41 was a global gate this morning; since 37857225 it is d1's gate and gMask is `id`. Same CC, opposite verdict. Hardcoding either answer would be wrong half the time and would go stale exactly the way every other cached binding here has (feedback_stale_binding_pattern). An unreadable BootTidal assumes the mask is STILL ACTIVE — the cautious read, because guessing "retired" silences a real FAIL. 3. IT NEVER FAILS ON THE STATE BUTTONS. Gates on 41-44/57-60/76/89-92 are gestures PLN arms on purpose. They are LISTED ("armed on purpose (not a failure)") so nothing is silently on, but they do not block. A gate that cries wolf about intentional performance state is a gate that gets ignored, and then it is worth nothing on the night it matters. It also names what it cannot see rather than implying completeness: Ardour faders are invisible over MIDI, so the output points at check-mix.py and says that reads the SAVED session. == AND IT IS WIRED IN, WHICH IS THE ACTUAL POINT == gig-up.sh now ends with a readiness gate that runs BOTH preflight and check-mix. Everything above that line only proves processes STARTED; these two are the only steps that ask whether the rig will make a SOUND. Neither aborts the launch — you may be starting up precisely to fix them. This is the "real readiness gates" half of feedback_simple_setup, and it means the surface check happens whether or not anyone remembers to ask for it. Verified: live run reports SAFE with the board as PLN just left it (48 controls touched, gMask correctly detected as retired, ^93 untouched and flagged as sitting at the #55 seed). Negative-tested against a truncated log: exit 2, "recorder is NOT running", no verdict offered. 5 new tests (309 total), including the load-bearing one that a stale log must FAIL rather than pass, and that a COMMENTED-OUT `gMask = id` does not count as retired.PLN (Algolia) authored -
Two changes PLN asked for, plus the five tests that had to be rewritten because they encoded the behaviour we just removed. == 1. gMask IS RETIRED == PLN: "kill gMask! its unprevisible anyway. we can then consider a gMask per track ... tbh gmask is risky id rather invest all on my own manual masking perf" It was `midiOn "^41" (mask "t!7 f")` — top-left button chopping the last eighth out of every bar on every stream wrapped in gM1/gM2/gM3, which in practice is every orbit of every track. A global that silently removes events from everything is exactly what makes a rig feel haunted, and the gesture plays better by hand. It becomes `gMask = id` rather than being deleted: 230 .tidal files name it directly and removing the binding would stop every one of them compiling. Same retirement pattern already used for the midiG family (#73) — keep the name, empty the behaviour, delete the usages at leisure. gM1/gM2/gM3 are now just the mutes. The payoff is the SURFACE, not the sound: ^41 is button-top-row column 1, the one slot the column-aligned button map needs for d1's gate. With it free, the top button row can be fully per-orbit d1..d8 and the whole board reduces to one sentence. That unblocks phase 2 (#92). Worth recording how visible this was: gig-log reported `41 127 gMask someCyclesBy gate -- gates 100% of cycles`. gMask had been fully ARMED on the live board since 15:36 while PLN was testing the set. Nothing was broken; a global was simply switched on and nothing put that in front of him. He has since pushed the state buttons back off. == 2. THE BOARD WAS DENYING d9 EXISTS == PLN, on bombe_dj: "i see no led under d9 while d9 has a sound a FMRhodes". A1-A4 are d9-d12's LEVEL, MIDI-learned in Ardour to the Tidal 09-12 track gains (#46). No .tidal writes ^13-^16 — and it must not, since Tidal never sees those CCs — so a "^NN" scan finds nothing and the knobs went dark. Dark means "not mapped here" (feedback_dark_means_unmapped_outranks_all), so the board asserted that four working controls did nothing. To be precise about a phrasing that confused things: the ORBITS d9-d12 are entirely Tidal's, they carry real sounds (wap's d9 is vec1_acid), and every orbit-parsing tool sees them — HUD, pvlint, silent-eval, surface-columns. What belongs to Ardour is only the four CC NUMBERS. The orbit was always visible; the knob-to-orbit LINK was the missing piece. parse_track now lights A_N exactly when the track DECLARES d(8+N), coloured by that orbit's own family — these are per-orbit by construction, unlike the shared filters and mutes. A track with no d11 still gets a dark A3, because there is nothing there to level. So the row answers the question actually asked mid-set: which extra orbits does this track have, and where is their volume? == 3. FIVE TESTS REWRITTEN, NOT DELETED == They asserted gM1 -> {41,73} and "the mask is measured by DENSITY". Both were true and are now false. Each was retargeted at the new truth rather than removed, and one was ADDED that the old design never needed: test_gMask_is_retired_and_claims_no_cc — asserts ^41 is owned by NOTHING, scanning every helper. A gMask that quietly reclaimed CC 41 would put two jobs on one button, which is the precise failure this remap exists to remove. That deserves a guard, not a comment. Verified: check-boot all 4 passes green (helpers audible against an untouched controller, and the block-seam replay confirms 32 blocks each parse as one statement); silent-eval --seeded on bombe_dj still emits on every declared orbit; 305 tests pass. NOTE the retirement only takes effect on a Tidal REBOOT — the running ghci loaded BootTidal at 16:36, seven minutes before this edit. Until then ^41 still masks.PLN (Algolia) authored -
Two lies the board was telling, both found by PLN looking at it during the column migration. Neither had any error output anywhere; both are the rig's signature shape, a value resolved once and never rechecked. == 1. STALE PARSE: the board showed a file that no longer existed == PLN: "i see the leds red on C5 c6 not on c4 why? feels like old led convention?" His instinct was right about staleness and wrong about the cause — it was old DATA, not old code. The watcher followed track CHANGES only, so it held whatever the file said the moment it was opened. Timestamps settled it in one line: watcher started 15:21:24 bombe_dj.tidal 16:27:17 (the migration) ^54 existed in the 15:21 version and does not exist now. C5+C6 lit / C4 dark was a *faithful* picture of a file 66 minutes dead. And this is livecoding — the file changes constantly, so this was not an edge case, it was every save. follow_loop now stats the track's mtime alongside the path and re-parses on either. Two extra stats per second; still no inotify, deliberately — a watch that dies silently is the exact failure class this rig keeps producing. One subtlety in the fix: touched-state is cleared on a track CHANGE (carrying it over would claim you had already worked controls on a track you just opened) but PRESERVED on an edit to the track already loaded. Otherwise a ctrl+S mid-set wipes the one thing that display exists for. == 2. HELPERS ARMED BY NAME: four live buttons painted as unmapped == PLN: "i expect filters and mutes on all and the ^41 as the mask control no?" Every ParVagues orbit is written `dN $ gF2 $ gM3 $ ...`, and BootTidal defines `gM3 = gMask . gMute3` — so ONE name arms TWO controls, and a "^NN" scan of the .tidal sees neither. On 5 of the 13 OPAL tracks (do_it_right, vague_de_crime, desire, the_revolution_will_be_sampled, electric_hammer) ^41 and all three mutes sat DARK while live. Dark means "not mapped here" (feedback_dark_means_unmapped_outranks_all), so the board was asserting that four of the most-used buttons did nothing. parse_track now resolves gMask / gMute1-3 / gM1-3 to their CCs. Role is the neutral "fx", not the invoking orbit's role: the mutes are SHARED (bombe_dj drives gMute3 from d4, d5 and d7), so an orbit-derived role would be whichever orbit the parser happened to see last. A stable colour beats an arbitrary one; refining button colour is #49/#51. == THE NEAR-MISS WORTH RECORDING == I first "verified" this fix against bombe_dj and measured NO change — 28 bindings before, 28 after — and was about to conclude the whole diagnosis was wrong. bombe_dj is one of the 8 tracks where those CCs happen to appear literally somewhere in the file, so it cannot show the bug at all. Running the comparison across all 13 tracks instead of the one in front of me is what recovered it. A single-file spot-check disproving a real bug is a worse outcome than no check. Tests: 6 new cases in at/tests/test_lcxl_colour.py, one per SHAPE rather than per file — gM3 lights both mask and mute, each gM variant selects only its own mute, bare gMask/gMute names resolve, a COMMENTED helper lights nothing (same rule as a commented ^NN — a disabled helper is not a binding), and a track arming no mask/mute leaves those buttons dark. 42 pass in the colour suite, 165 in the full tools suite.PLN (Algolia) authored -
PLN gave the go and accepted the trade in his own words: "i agree for the trade and change of knobs. i accept the fate! and truthful leds will help quick learn". The faders were remapped this afternoon; doing the tracks now means his hands relearn the surface ONCE, with four days to drill it, instead of learning an interim layout this week and the real one after the gig. 12 tracks, 54 substitutions, 5 gSel FIXMEs, 0 collisions Each of d1-d8 now has its effect on its own B knob (and a second on its C knob for d4-d8), directly above the fader that levels it. bombe_dj landed separately in fdde0924 as the proof track. == WHAT MADE THIS SAFE, AND IT IS NOT THE DIFF SIZE == The migrator computes each track's WHOLE permutation, prints it, then writes once. That is not tidiness, it is correctness: * desire.tidal needs two genuine SWAPS — d7 ^55<->^35 and d8 ^56<->^36. Applied sequentially, the first move would overwrite the second's source and both controls would end up on one knob. Applied simultaneously they just exchange. * the same property dissolved the 34 "destination taken" clashes the naive costing reported: the corpus is uniformly off by one column, so every orbit wants the slot of the orbit below it, free only once that one moves too. Genuine collisions across all 13 tracks: zero. * the rewrite is scoped PER ORBIT. desire had ^19 driving both d7 and d9, which migrate to different destinations; a file-wide replace would have fused two gestures into one, silently. * commented-out ^NN refs are never touched — they are alternatives PLN re-enables mid-set, and rewiring them would break weeks from now with no trace. == THE 5 OVERFLOWS, LEFT IN PLACE ON PURPOSE == perfect d4 ^17, perfect d5 ^18, mafia_sans_serif d7 ^19, the_revolution_will_be_sampled d7 ^55, desire d3 ^52. These orbits have three or more effect knobs; the grid honestly offers two (one for d1-d3, whose C knob is a family DJ filter). Rather than spill them into a neighbouring column and call the board aligned, each carries a FIXME(#54) pointing at gSel. PLN: "if we have two effects, we consolidate via gSel indeed." == VERIFICATION, AND ONE HONEST GAP == pvlint 13 tracks, 0 errors, 9 warnings (all pre-existing: duplicate d5 declarations, one out-of-range sample index — none introduced here) silent-eval --seeded every BUILDABLE track's every declared orbit still emits events, cold, against the seeded control map. This is the check that would catch a renumber having quietly emptied an orbit, and it is clean. surface-columns --knobs 77 knob renumbers -> 5 (exactly the gSel overflows) THE GAP: the silent-eval harness cannot BUILD 3 of the 13 tracks — overlapping IsString instances on chord literals, and an ambiguous `cutoff` between Tidal.Context and Tidal.Params. I re-ran two of them from HEAD and they fail identically there, so this migration did not cause it. But it means three tracks' orbits are UNVERIFIED, and a harness that cannot build a track must never be read as a pass. Filed separately. Also fixed one stale comment of my own in desire.tidal: after the d7 swap the NANANA knob is B7, not C7. A comment that lies about the layout is worse than none when you are relearning the board. Not committed here, deliberately: backlog.md and tools/at/fixtures/claude.tidal carry PLN's own uncommitted edits.PLN (Algolia) authored -
PLN: "lets do the bombe dj first to confirm". One track, end to end, so the tool and the grid are both proven before the other twelve. d3 ^52 C4 -> ^31 B3 legato d4 ^17 A5 -> ^32 B4 midiOff (slow 4) d4 ^53 C5 -> ^52 C4 crushbus 41 d5 ^34 B6 -> ^33 B5 octerbus 52 d5 ^54 C6 -> ^53 C5 crushbus 51 It also cleaned up a collision I had introduced myself twenty minutes earlier: 0538eb97 moved bombe_dj's d9 onto ^17, but d4 was ALREADY using ^17, so between the two commits that one knob drove both orbits. Caught by running --plan before --apply rather than trusting the previous step. The lesson is the tool's design rule, not a footnote: build the whole permutation, then look at it, then write. == THREE DESIGN DECISIONS THAT ARE THE WHOLE TOOL == 1. THE PERMUTATION IS APPLIED SIMULTANEOUSLY. Costed one move at a time, the setlist showed 34 "destination already taken" clashes. Every one was phantom: the corpus is uniformly off by one column, so each orbit's knob wants the slot of the orbit below it, occupied only until THAT one also moves. Build the full map, rewrite in a single pass, and the clashes evaporate. Applying moves sequentially would have corrupted the files. Real collisions across the whole setlist: zero. 2. THE REWRITE IS SCOPED PER ORBIT, never file-wide. Two orbits can legitimately share a CC today — desire.tidal had ^19 driving both d7 and d9 — and they migrate to DIFFERENT destinations. A file-wide search-and-replace would send both to one place and silently fuse two gestures into one. So the substitution walks orbit by orbit with only that orbit's map. 3. COMMENTS ARE NEVER REWRITTEN. A commented-out ^NN is an ALTERNATIVE PLN may re-enable mid-set. Renumbering it would quietly rewire that alternative to a different orbit's knob, and the breakage would surface weeks later with no trace of a cause. Hands off by construction: faders 77-84 and knobs 13-16 (Ardour-learned — the CC77-to-silence footgun), the 8 BootTidal helper CCs, and all buttons (41-44/57-60/73-76/89-92 — gates and mutes are phase 2, and retraining a gate is more disruptive than retraining a knob). == AND A FIX TO THE VERIFICATION LENS, WHICH MATTERED MORE THAN IT LOOKS == After applying, surface-columns still reported "11 moves" for bombe_dj. Nothing was wrong: it counts BUTTON refs too, and buttons are deliberately out of phase-1 scope. But a gate that reports deliberately-deferred work as a failure is a gate you learn to ignore — and that is precisely how a real regression gets through. Added --knobs, which narrows the grid itself so every downstream number speaks about phase 1 only. Same discipline as feedback_parsers_over_copy: the measurement must measure what was actually done. == VERIFIED == pvlint 1 track, 0 errors, 0 warnings silent-eval --seeded every declared orbit still emits events, cold surface-columns --knobs 0 renumbers remaining (was 5) Remaining for bombe_dj, on purpose: d9's second effect (^19) still wants gSel (FIXME already in the file from 0538eb97), and the button rows are phase 2.PLN (Algolia) authored -
First slice of the #92 column migration, and the slice that had live breakage in it. THE GRID, settled with PLN this afternoon after one wrong turn and one measurement that overruled me: A 13-16 d9 d10 d11 d12 LEVEL (Ardour, learned today) A 17-20 d9 d10 d11 d12 EFFECT <- this commit B 29-36 d1..d8 EFFECT (next slice) C 49-51 gF1 gF2 gF3 (unchanged — see below) C 52-56 free, per-orbit 2nd effects D 77-84 d1..d8 LEVEL (Ardour, learned today) The wrong turn was mine: I proposed extending the DJ filters to gF1..gF8, one per column. Then I measured which orbits actually receive each filter across all 13 setlist tracks: gF1 -> d1×13 d2×13 d3×13 d8×12 drums / core rhythm gF2 -> d4×14 bass, almost exclusively gF3 -> d5×12 d7×9 + d9-d12 leads / melodic / extras That is not three arbitrary filters, it is a per-FAMILY design applied consistently across the whole set — you filter a stem group, like a DJ mixer, not one channel. Per-orbit filters would have destroyed it and handed PLN eight knobs he would never use that way. gF1-3 stay exactly as they are, and C4-C8 stay free, which as a bonus removes the "one effect per orbit" constraint I thought the grid forced. PLN's instinct ("i wanna keep C1/2/3 as djf these are core") was righter than his reasoning for it. == THE LIVE BREAKAGE == piment_bresilien.tidal:77 drove its d10 crushbus from "^14". As of today CC 14 is MIDI-learned to Ardour's Tidal 10 gain. So that one knob was crushing the bass and riding the orbit's fader simultaneously — a control doing two unrelated jobs, the kind of thing that reads as "the rig is haunted" mid-set. Now ^18 (A6), which is d10's own effect slot. This was the concrete half of #88. == THE REST == wap d9 ^18 -> ^17 gain swell bombe_dj d9 ^18 -> ^17 midiOff (slow 4) desire d9 ^19 -> ^17 gain swell do_it_right d12 ^31 -> ^20 modIndex (^31 was B3 = d3's slot, and was also Ardour-bound until this afternoon's release) == ONE BEHAVIOUR CHANGE THAT NEEDS PLN'S EAR == In desire.tidal, "^19" drove BOTH d9's gain swell AND d7's NANANA (line 84). Moving d9 to its own knob necessarily splits that gesture. If the link was deliberate — one knob swelling the synth while bringing in the vocal — it is now two knobs and we should restore it. Flagged with a FIXME in the file rather than silently "fixed", because a lost gesture is not something a linter can miss for you. d7's ^19 moved to ^55 (C7, its own 2nd-effect slot) rather than squatting on A7, which belongs to d11. == FIXMEs LEFT ON PURPOSE (PLN: "if we have two effects, we consolidate via gSel indeed. mark FIXMEs in code in there") == wap d9 and bombe_dj d9 each have a SECOND effect (crushbus, modIndex) and d9-d12 get one A-row knob each, so there is no honest home for it. Both marked FIXME(#54) to fold into ^17 via gSel rather than borrowing d11's knob. The wrong fix would have been to spread them onto neighbouring columns and call it done. == VERIFICATION, in the order that matters == pvlint 5 tracks, 0 errors (2 pre-existing warnings, untouched) silent-eval every declared orbit of all 5 tracks still emits events, cold, against the seeded control map — this is the one that proves a renumber did not silently empty an orbit surface-columns Ardour collisions 1 -> 0; the 5 primary d9-d12 controls all land on their own knob; 2 second-effects correctly flagged Uncommitted WIP in 6 setlist tracks was backed up before editing; only CC numbers and comments changed, so PLN's in-progress edits are preserved in the diff. Aside, spotted by pvlint and NOT fixed here: do_it_right d5 references index 8 of "daft" which has 5 samples — that layer never arrives. Own task.PLN (Algolia) authored -
surface-columns reported 11 CCs as "held by BootTidal" — including 18, 34 and 77. All three are false. Every one appears only inside a `--` comment: BootTidal.hs:299 -- midiOn ("^34" - "^18") (perfect.tidal:63) BootTidal.hs:322 -- midiGGlobal used to read the LIVE fader: orDef 0.769 "^77" * 1.3 one documenting a track's idiom, one a note about a retired helper. BootTidal claims none of them. This is the dangerous direction of wrong. An INFLATED helper set says "these controls are unavailable", so it silently shrinks the design space for the #92 column migration — B-row column 6 (^34) and A-row column 6 (^18) would have looked spoken-for when they are free, and ^77 would have looked like a Tidal-side conflict with Ardour's newly-learned fader 1 when there is none. A wrong number that closes doors is worse than one that opens them, because nobody goes looking. Caught it by grepping BootTidal for the three CCs while planning #92 and finding every hit was a comment line. Fix is one line: run strip_comment over the helper block before matching, the same way pvlint and silent-eval already do. Corrected numbers: 8 helper CCs (41, 49, 50, 51, 73, 74, 75, 93) — gMask, the three DJ filters, the three mutes, and panic. Exactly the set you would predict from reading the helpers, which is the tell that it is right this time. Knock-on: the renumber count rises 174 -> 192, because ^34 and ^18 are no longer excused as immovable helpers and now correctly count as track controls that would have to move. #92 updated.PLN (Algolia) authored -
feat(surface): measure whether each orbit's knobs sit ABOVE its fader — the whole corpus is off by one PLN, mid-remap, spotted the half of #46 I had not measured: "but we need the coverage of the effects moving in the tracks, e.g. bass from 81 to 80 means effects on 53 now move to 52" He is right, and my earlier conflict scan answered the wrong question. That scan looked for CC *collisions* — two things claiming one control — and found only two (^78, ^14), which is how #46 came to be costed as "two track edits". But the LCXL is a GRID of eight channel strips, and the property that makes a surface readable is not absence-of-collision, it is COLUMN COHERENCE: the knobs directly above a fader must shape the same orbit that fader levels. Otherwise the bass fader is in column 4 while the bass filter is in column 5, and every reach is a lookup. tools/surface-columns.py measures it: per track, per orbit, which grid columns that orbit's "^NN" references actually land in, and what would have to move for column == orbit. It separates two classes, because conflating them would have produced a work list that is mostly noise: * TRACK controls — a raw ^NN in the .tidal. Free to move; a text edit. * HELPER controls — CCs baked into BootTidal (gF1/2/3 on 49/50/51, gMask 41, gMute1-3 on 73/74/75, gPanic 93, plus 18/34/77 — 11 in all). A track cannot move these by editing itself. They are global by construction and will always read as "misaligned" against a per-column model. THE RESULT, over the 13 OPAL setlist tracks — 84 d1-d8 orbits: only 19/84 orbits are column-aligned; full alignment = 174 ^NN renumbers and the pattern is startlingly uniform across all 13 files: d1 -> col 2 d4 -> col 5 d7 -> col 7 (aligned) d2 -> col 3 d5 -> col 6 d8 -> col 8 (aligned) d3 -> col 4 d6 -> col 7 So the corpus convention is "orbit N lives in column N+1" for d1-d6, and N+0 for d7-d8. The +1 is not an accident: column 1's C-knob and both its buttons are already spoken for by gF1 / gMask / gMute1, so per-orbit controls were pushed one column right to dodge them. And the two rules meet badly — column 7 is double booked by d6 and d7 (in desire.tidal both really do react to ^59). The consequence for #46 is the useful part: because the corpus is +1 for six orbits and +0 for two, THERE IS NO FADER MAPPING THAT MAKES TODAY'S TRACKS COHERENT. Shifting the faders +1 to match would strand d8; leaving them arbitrary is where we are. Either the tracks move, or the surface stays a lookup. PLN's instinct — that the Ardour re-learn is only half the job — was exactly right. Survey, not a gate: exits 0 always, because alignment is a design choice and this tool's job is to price it, not to enforce it. --plan prints the exact ^NN -> ^NN moves per track for when we do it (#92).PLN (Algolia) authored -
The board's colours only persist because a daemon holds a model of every control and repaints from it. That daemon had no home. It could be started three ways: 1. by hand, tools/lcxl-leds.py --watch 2. by gig-up.sh, which `setsid`-spawned its own copy (GIG_LEDS=watch) 3. as a *transient* systemd unit, which is how it was actually running today (systemd-run --unit=lcxl-leds-watch) Every one of those is wrong in a different way. (1) dies with the terminal. (2) does not know about (3), so launching gig-up on a machine that already had the watcher up gave you TWO processes writing SysEx to the same LCXL, fighting over every LED — and neither of them wrong enough to look broken, which is the worst kind of bug this rig produces. (3) has no file on disk, so it evaporates at the next reboot and the board silently stops persisting colours. PLN, on being shown the three: "watcher must be a saved tool part of gear indeed". So: tools/lcxl-leds-watch.service, symlinked into ~/.config/systemd/user/ the same way parvagues-bridge.service already is, enabled, WantedBy=default.target — it starts at boot with linger, before any login. gig-up.sh no longer spawns anything; it `systemctl --user restart`s the unit, which is idempotent AND guarantees exactly one owner even if a stale watcher survived a crash. One owner of the board, always. Two details worth the ink: - StartLimitIntervalSec=0 belongs in [Unit], not [Service]. Put in [Service] systemd says "Unknown key ... ignoring" — a warning in the journal nobody reads — and the default limit of 5 restarts in 10 s stays in force. The LCXL is hot-pluggable and usually absent at boot, so with Restart=always/RestartSec=10 the unit would burn its five retries and fall into `failed`, board dark for the rest of the session. A silent failure one section heading away from working. Caught it because the first install DID log the warning; fixed and re-verified with systemd-analyze. - Cost, measured from the transient unit's own accounting before replacing it: 2.140 s CPU over 1 h 53 m wall = 0.03% of a core, 14.5 M peak RSS. The watcher forks a helper per LED write, which is a real throughput problem for the 1-2 s paint lag — but it is emphatically not a load problem, so it can stay Nice=5 / CPUWeight=20 and never be a candidate when hunting xruns. Verified: unit enabled + active, systemd-analyze verify clean, no Unknown-key warning on reload, `bash -n gig-up.sh` clean, and exactly one watcher process owned by the unit (MainPID matches, NRestarts=0). Note `pgrep -cf 'lcxl-leds.py --watch'` reports 2 — it counts the shell running the pgrep pipeline itself. Read the process list, not the count. Closes #85.PLN (Algolia) authored -
PLN (Algolia) authored
-
docs(tasks): archive #79/#82/#78/#8/#61/#81 — the seed that never ran, and a task built on a guessed unit name
PLN (Algolia) authored -
PLN, after the value ramp landed: "now all knobs have lights, i cant trust anymore 'is there sth mapped there or not?' knobs should always be no-lit if they are not-mapped, in that track, to help not touch dead controls". The interesting part is that the colour code was never wrong. build_frame() already paints only the CCs present in `bindings` and leaves everything else OFF. The lie was in WHICH BINDINGS IT WAS HANDED: the watcher runs with no track argument, so load_bindings(None) returns the CONVENTION board — all 40 controls lit by lane role, regardless of what is actually loaded. That was equally untrue before the ramp; dim role-hue just made it easy to ignore, and saturated colour made the board finally READ as "everything here is live". So the feature did not create the problem, it made a pre-existing lie legible — and fixing the colour back would have hidden the fault again. MEASURED: convention board (what the watcher painted) 40/40 lit do_it_right 19/40 -> 21 dead knobs lit wap 25/40 -> 15 desire 22/40 -> 18 Fifteen to twenty-one controls were glowing on every track while doing nothing. On a dark stage that is a knob you reach for and a change you do not get. The fix is a published current-track file (~/.cache/parvagues/current-track): * `--map TRACK` now PUBLISHES as well as paints. Without that, a one-shot paint at boot showed the right board for 30 seconds and then the watcher's re-assert overwrote it with the convention paint — the same board, lying again. One writer, one meaning. * `--watch` with no pinned track follows the file on a 1s poll and repaints on change. Polled rather than inotify on purpose: a watch that dies unnoticed is exactly this rig's signature failure (a binding resolved once, never rechecked), and one stat/second of a small file costs nothing measurable. * touched-state clears on track change (carrying it would claim you had already worked controls on a track you just opened); VALUES persist, because the knobs did not physically move. * bindings moved into a shared box — three reads in the aseqdump loop and the re-assert thread would otherwise have kept painting the previous track's map. load_bindings(None) now also SAYS it is painting a board that may be lying, instead of reporting "40 controls lit by lane role" as though that were good news. The remaining gap, deliberately not closed here: nothing publishes the track when PLN ctrl+enters a file in Pulsar — only `tidal-remote boot` does. The clean signal is a five-line hook in the HUD package, which already tracks the active .tidal. Filed rather than rushed the day before rehearsals. 458 tests (was 453). NOTE: the running watcher must be RESTARTED to pick this up; not done now, because PLN is about to play and a dark board mid-set beats a correct board that arrived by surprise.PLN (Algolia) authored -
BootTidal.hs:376-377 applies BOTH: gDJF ch = (# lpf (range 180 20000 (fmap (\v -> 1 - 2 * max 0 (0.5 - v)) ...))) . (# hpf (range 20 8000 (fmap (\v -> 2 * max 0 (v - 0.5)) ...))) Yesterday's `left at` column transcribed only the `# lpf` line, so it called gF3-parked-at-80 "open" when it is really a ~2 kHz HIGH-PASS — which guts a bass or a voice, and which is exactly the helper PLN had commented off two different d5 orbits to get the sound back. The report was confidently wrong about the whole upper half of the knob, in the direction of reassurance. Now models both bands and grades on the pair: 0 lpf 180 hpf 20⚠ ⚠ NEAR-SILENT 64 lpf 20000 hpf 83 open 80 lpf 20000 hpf 2094⚠ thin — low end cut 127 lpf 20000 hpf 8000⚠ ⚠ NO BODY LEFT Hard right is not "open". It never was. Two of the existing tests encoded the old blind spot — they used cc 49 = 100 as the "safe" control value, which is hpf 4607 Hz. The code was right and the tests were wrong, so the tests moved to the centre. And the new test caught something small and real: "centre = true bypass" is an APPROXIMATION, not an identity. 0..127 is an ODD range, so 0.5 falls between cc 63 and cc 64 and no cc value hits bypass exactly — 63 gives a ~19.7 kHz lowpass, 64 an 83 Hz highpass. Both inaudible, so the knob is fine in practice, but the test now asserts the truth rather than the comment in BootTidal.hs. 453 tests green across tools/.PLN (Algolia) authored -
The report ended an unclosed log with:⚠ no `end` record — the recorder is still running, or it was killed Both readings are plausible and the reader has no way to pick. I picked wrong: I asked systemd about `parvagues-gig-log` — a name I guessed instead of read; the unit is `gig-log.service` — got "inactive", and used that false negative to resolve the ambiguity into "killed". The recorder had in fact been up for eleven hours, enabled, with its pw-top and aseqdump children alive, still writing. A whole task got filed about restoring a service that was never down. Two mistakes worth naming because they chain: guessing an identifier rather than reading it, and then letting a broken check settle a question the tool had deliberately left open. The wording invited exactly that. So the tool now decides and says which: ● RECORDING NOW — this log is still open, numbers are partial⚠ no `end` record and no recent sample: the recorder was KILLED is_live() decides from the DATA's own recency — last sample within a few periods of now — and deliberately not from a process match. `pgrep -f` matches any shell that merely mentions the string, and unit names are exactly the thing I just got wrong. Recency needs no name and nothing to guess. 86 tests (was 83): a live log must say RECORDING NOW and never KILLED, a stale one the reverse, and is_live must answer from timestamps alone.PLN (Algolia) authored -
PLN this morning: "moving to track 2, wap.. no bass? when i move the knob C5 it starts sounding". And on do_it_right: "ctrl_enter, i hear the 4-bar pattern each bar lower, 4th barely audible, its clearly a xfade". Both sentences are the same bug, and the second one hid the first for days. THE MASK. Every dN is `xfade N` with xfadeIn 4, so the four bars you hear after an eval are the PREVIOUS pattern leaving. A silent eval gets a graceful exit and reads as a "drift to silence". The sound after ctrl+enter is not evidence the eval worked — it is evidence the last one did. THE BUG. Pulsar does not feed BootTidal.hs to ghci verbatim. boot-tidal.js splits it on BLANK LINES and strips the `:{`/`:}` it finds; repl.js tidalSendExpression then wraps each chunk in its OWN `:{ ... :}`. A ghci `:{ ... :}` accepts exactly ONE statement. The #55 seed was written as :{ let _seed = concat [ ... ] :} mapM_ (\(k, v) -> setF k (pure v)) _seed putStrLn "[BootTidal] seeded ..." — correct for a file read line-by-line, and fused by Pulsar into a single statement that dies with "parse error (possibly incorrect indentation or mismatched brackets)". So THE SEED NEVER RAN. Not once between 2026-07-27, when it was written, and today. Every boot left the control map empty. An untouched "^NN" yields NO EVENTS — not 0, nothing — so any `# param (range a b "^NN")` emptied its whole orbit, and the rig only made those sounds after a knob was physically moved. Which is precisely what PLN described, in the sentence I had filed as a separate question about crush ranges. MEASURED, with tools/silent-eval.py (added earlier today) over the OPAL setlist: empty control map 23 orbits silent across 10 of 10 buildable tracks, including desire d1-d6 — the ENTIRE track with the seed ZERO silent. Every declared orbit emits events. The seed is necessary AND sufficient. The tracks were never the problem. THE FIX is two blank lines. They are load-bearing and the file now says so. Verified through the real seam, not by inspection: replicating Pulsar's exact chunking and wrapping and feeding it to ghci now yields 51 setF calls with the right values (13=0.0, 49=0.5, 77=0.769, 78=1.0 ...) and prints the boot banner. Before the fix the same harness produced only the parse error. WHY EVERY EXISTING GUARD MISSED IT. check-boot.sh passes 1-3 prove the helper block typechecks, the seed block typechecks, and the helpers emit events against an empty control map. All three were green throughout. They test the Haskell; the failure was in how the file is CHUNKED AND FED. Green checks on a component say nothing about whether the data reaches it — the rig's signature failure, and this is the purest instance of it yet. #61 was closed on exactly that false comfort. So this adds check-boot.sh pass 4 / tools/check-boot-blocks.py, which replicates Pulsar's chunking bug-for-bug (the non-global .replace included), feeds every block to a bare ghci, and fails on parse errors only — "not in scope" and type errors are expected without Tidal and are ignored. Negative-tested against the pre-fix file: it names block 26 at BootTidal.hs:663 and exits 1. One bug found in the guard before trusting it: capturing stdout and stderr separately and concatenating them put every marker before every error, so each parse error was attributed to the LAST block rather than its own — it confidently blamed block 28. Now one interleaved stream.PLN (Algolia) authored
-