- 29 Jul, 2026 4 commits
-
-
check-tracks.sh is THE empirical gate: it boots every track in the set and proves each declared orbit makes sound, which is also the only compile check a .tidal file can have (there is no static typechecker for a GHCi fragment). The comment at the top says to run it the day before the gig. It read `setlist_opal2026.txt` from the repo ROOT. That file has never existed. So the gate has been unrunnable since it was written — and it failed in the least helpful way possible, printing "no such file" per track from an empty list rather than saying the setlist was missing. Now points at `armada/setlist_opal2026.txt`, the file added for #68, so ONE list drives both questions: "does every orbit sound" (this) and "which orbits ghost across each transition" (orphan-orbits.py). Reorder the gig, re-answer both. Two fixes it needed to actually read that file: * strip TRAILING comments, not just full-line ones — the setlist annotates each path with its codename and BPM (`live/…/wap.tidal # WAP [133]`), and leaving that on the line makes every track report "no such file"; * fail loudly when the setlist is absent, instead of silently iterating nothing. Also: `--help` was taken as a track name, so asking for help answered "FAIL — no such file", which reads like the rig is broken. Verified: all 13 tracks resolve through the shell reader. Pinned by two tests — one asserts check-tracks.sh and orphan-orbits.py name the SAME setlist file (a broken gate is invisible until the day you need it), the other runs the actual sed pipeline and asserts no comment text leaks into a path. Suite 435 -> 437, all green. NOT run end-to-end: it needs audio, and it is ~45 s per track by design (a tight loop lands evals on a half-loaded interpreter and reports false failures). It is the natural companion to tomorrow's run-through.PLN (Algolia) authored -
Five tasks closed overnight, written up as long-form entries rather than one-liners because these are the documentary trail: each carries the original symptom in PLN's own words, the mechanism, the numbers, and the wrong turns. The through-line across all five, worth naming: EVERY ONE of them found a second, worse bug than the one it set out to fix, and in four cases the second bug was found by LOOKING AT THE OUTPUT rather than by reasoning about the code. * gig-log's report exposed its own xrun baseline as nonsense (72096 xruns on an idle machine) — twice, before the third rule held. * rendering the LED board exposed the half of the panic bug that lived in parse_track, after the half in control_colour was already fixed. * the per-track probe PLAN exposed that lens.py could not see gF1/gF2/gF3 at all. * wiring a status label into the tray exposed that it opened the ARCHIVE Ardour session, could never have launched Ardour anyway, and reported it running when it was not. A tool that prints what it believes is a tool that can be caught lying. MORNING.md is the other deliverable: PLN opens the laptop with a coffee to "just play", so the state of the rig has to be readable in 30 seconds — what changed on the surface, the one 5-minute thing worth doing (the ghost report), and what is waiting on his ears rather than on work.PLN (Algolia) authored -
PLN: "i rightclick the perf indicator and see no gear status?" He was right, and the state had been there all along. launchers.is_running() existed; _build_menu() just ran ONCE at startup and read only `available`. So a running Ardour looked identical to a stopped one, and — worse — the safe action ("do nothing, it's already up") looked identical to the dangerous one. Two SuperColliders is a zombie port 6010 and a silent rig. Now: a `Gear ▸ 3/6 up` submenu, each row labelled with what it IS — ● Pulsar — running (greyed: launching a second one is never what you meant) ○ MIDI Monitor (clickable) ✗ Something — not installed Glyphs rather than colour, because a tray menu inherits the desktop palette and this gets read in a dark room seconds before playing. Web tools stay clickable when running, since clicking them opens a URL rather than spawning a duplicate. Refreshed on menu-OPEN only, never on the 2 s icon timer: reading gear state walks /proc, and doing that 30x a minute for a label nobody is looking at is exactly the per-tick cost this rig keeps getting bitten by. THREE REAL BUGS FOUND WHILE WIRING IT UP, each worse than the missing label 1. THE TRAY OPENED THE WRONG ARDOUR SESSION. ARDOUR_SESSION pointed at "Tidal Multi" — the older ARCHIVE of per-orbit recordings — not "Tidal Live", the session that performs and records the stems. Any faders touched there would have been the wrong ones. Auditing that same archive as if it were live already produced a confidently wrong fader report on 2026-07-28; this was the same mixup one layer down, waiting to happen again 6 days before OPAL. 2. IT COULD NEVER HAVE LAUNCHED ARDOUR ANYWAY. Candidates were ardour8/7/6/ardour; the installed binary is ardour9 (real exe `ardour-9.2.0`). So the entry reported "unavailable" and greyed itself out while Ardour was running on the same machine. 3. THE RUNNING CHECK MATCHED THE WHOLE WORLD. `pgrep -f ardour` matched the `tidal-ardour-autoroute.sh` helper script AND any shell whose command line merely mentioned the word — including the shell I was testing from. So it could report Ardour UP while Ardour was DOWN, which is worse than reporting nothing, because it is the state you act on. is_running now matches the EXECUTABLE basename (`exe`), with full-line matching kept only for interpreted tools where argv[0] is `python3` and the identity is the script path. AND IT NO LONGER FORKS is_running was one `pgrep` subprocess PER launcher, and snapshot() is called by the web Bridge's poll as well as the tray. Replaced with a single forkless /proc scan shared across the whole snapshot: 6 items in 10.7 ms, zero forks, down from 6 forks per refresh. Same lesson as the LED daemon's per-event fork and gig-log's per-sample sampling — on an audio rig, do not pay a process for a boolean. Verified live: all six entries now report correctly (Pulsar/Ardour/QjackCtl up, MIDI Monitor + Foundry + Armada down); perf-tray restarted and active. TESTS: +11 in test_launchers.py, suite 424 -> 435, all green. Pinned: the autoroute-script and bare-shell false positives; a version-suffixed binary (ardour-9.2.0, and a hypothetical ardour-10.0.1) matching; a filename ending in ".ardour" NOT counting as a running Ardour; snapshot() scanning /proc exactly once; is_running spawning no subprocess at all (subprocess.run/Popen monkeypatched to raise); and the session path being the LIVE one with "Tidal Multi" absent.PLN (Algolia) authored -
PLN's ask, verbatim: "all controls have impact on sound... even the crushes should change noticeably". The existing suite proves the RIG works against a fixture; this proves a TRACK is wired, which is the thing that bites at 160 BPM when a knob turns out to do nothing. python3 tools/at --track live/.../gimme_acid.tidal --dry-run # no audio python3 tools/at --track live/.../gimme_acid.tidal [--json] Per control: park at REST, take TWO baselines, probe, restore, judge. Four outcomes, not two — MOVED / NO_IMPACT / INCONCLUSIVE / UNMEASURABLE — because a swing that does not clearly beat the pattern's own drift is genuinely unknown, and calling that a pass is how a green suite comes to mean nothing. NO_SIGNAL is kept separate from NO_IMPACT: "nothing was playing" and "the control is dead" have different fixes, and conflating them once cost a whole evening chasing SuperDirt. THE BUG THE FIRST DRY RUN FOUND, which is the real content of this commit The plan for gimme_acid came back "0 bipolar, no CC 49/50/51 at all" — on a track that applies gF1/gF2/gF3 to SIX orbits. gF1/gF2/gF3 are defined in BootTidal.hs, not in the track, so lens.py's scan of the .tidal file alone was structurally blind to the three DJ FILTERS and to EVERY MUTE in the rig. The controls PLN reaches for most were the ones the acceptance test could not see. Fixed by PARSING BootTidal.hs (parsers-over-copy — it is the source of truth and it moves: gF1 was rewired onto gDJF, midiGGlobal was retired hours ago, and a hardcoded table would rot into a confidently wrong report). boot_helpers() now resolves: * `gF1 = gDJF "^49"` -> CC49, and BORROWS its lens from gDJF's body, since "gDJF" is not a keyword any table knows and the lpf/hpf evidence sits on two separate lines * `gM1 = gMask . gMute1` -> composition, inheriting CC41 AND CC73 * a track's own `let gMute = ...` SHADOWS the boot one, as Tidal does Result on gimme_acid: 18 controls -> 22, with 49/50/51 present, bipolar, resting at 64 and probing downward. Across the OPAL set: 275 controls, 15-29 per track, all three filters resolved in all 13, zero Ardour-owned CCs. Two parse bugs found and fixed on the way, both by reading the output instead of trusting it: * `mask "f*16"` was classified as DENSITY. An all-false mask is a MUTE — same keyword, opposite lens (rms vs onsets) — and my first all-false test asked "does it contain a 1", which the *16 repeat count satisfies. So gMute1/2/3, three textually IDENTICAL helpers, came back mute/density/density: rms would have been the wrong lens on two of PLN's three mutes, and two working mutes would have reported NO_IMPACT. * `gM3 = gMask . gMute3` swallowed the Launchpad block that follows it and reported CC 7 and CC 9 as mutes, because continuation lines were appended to whatever was defined last. A continuation now has to LOOK like one. SAFETY CC93 is added to a new lens.NEVER_PROBE. It arms panic — gPanic gates on "^93", so sending it high silences every gPanic'd stream. "Probe every control" plus "one control is a kill switch" is how a self-test mutes a rehearsal and gets blamed on the rig. It is reported and never swept. CC77-84 stay excluded by construction in lens AND re-asserted at the probe; CC77 down is total silence, so belt and braces is proportionate there. Controls are restored per control, not at exit, so Ctrl-C mid-run is safe. Restore goes to Control.rest — 64 for a DJ filter, because djf 0.05 is a ~26 Hz low-pass, i.e. silence, and parking one at 0 to get a "baseline" measures silence and then calls the track broken (#48). --dry-run exists deliberately: it prints the plan with no audio and no MIDI, so the half that can be checked tonight — which controls were found, which lens each gets, what will be sent, what will be restored — is checkable at all. "Built but never run" is exactly how the LED work shipped a colour ramp that was never called. TESTS: +79 (22 in test_lens.py, 57 in the new test_track_runner.py), suite 345 -> 424, all green. Every boot-helper regression above is pinned, including a parametrised all-false-mask table with `f*16` in it; each lens is asserted to have a floor (2 * max(drift, 0) is zero, so without one any tiny swing looks significant on a quiet capture); the probe is asserted to REFUSE all of CC77-84; and the dry-run plan is built for all 13 OPAL tracks with the DJ filters asserted bipolar-and-resting-at-64 in each. STATUS: the judgement half is validated. The AUDIO half is built but unrun — it needs monitors, and the house is quiet. First thing to run with sound up.PLN (Algolia) authored
-
- 28 Jul, 2026 29 commits
-
-
PLN, by ear, 2026-07-28: loaded vague_de_crime, played bombe_dj, and "heard diam's voice, now still hearing the synth from crimewave... i hear it regardless of gains, but filters work on them". THE MECHANISM, and why his two symptoms are the proof `dN $ ...` replaces orbit N and says NOTHING about the others, so every orbit the outgoing track declares and the incoming one does not just keeps running forever underneath the new track. "Regardless of gains" follows because the new track's gain lines address only ITS OWN orbits — nothing in it can reach the ghost. "But filters work on them" follows because gF3 is the GLOBAL CC51 DJ filter, applied INSIDE the ghost's own still-running pattern. An orbit that ignores the faders but answers the DJ filter is diagnostic of exactly this, and nothing else. Not a rig fault. It bites at every transition where the outgoing track declares an orbit the incoming one does not — which is 8 of the 12 OPAL transitions. WHAT SHIPPED armada/setlist_opal2026.txt — the set as DATA, in play order, resolved from backlog.md's codenames by locating each file rather than guessing ("Mafia" is mafia_sans_serif, "Le shifteur marteau" is electric_hammer, "Take five Drops" is take_5_drops). Reordering this file re-answers the ghost question, which is exactly what #12 needs. tools/orphan-orbits.py — reuses pvlint's existing orbit parser (DRY) and: * walks the set pairwise and names each transition's ghosts WITH THE SOUND they play, because "d6 (crimewave)" is actionable on stage and "d6" is not * --matrix, the full A->B grid, so #12 can order the set with the ghosts in view * --silence TRACK, a paste-ready `dN $ silence` preamble * --pair A B, exiting non-zero when ghosts exist so #44 can gate on it THE RESULT FOR THIS SET 12 transitions, 8 leave ghosts, 16 orphaned orbits. Worst: after REVOLUTION (4), after Gimme Acid (3). The original ear-report reproduces exactly — vague_de_crime -> bombe_dj leaves {6, 10}, and d6 IS the crimewave synth. And one ordering lever worth more than the report: Perfect <3 declares ALL TWELVE orbits, so its column in the matrix is entirely clean. It is a free RESET POINT — put it after the messiest stretch and ghosts stop propagating. The tool now detects and names reset points generally rather than leaving that to be noticed. WHY IT STOPS SHORT OF FIXING IT AUTOMATICALLY The obvious automation is to have tidal-remote eval a generated silence block. Read the plugin first: `eval-file` calls atom.workspace.open(..., activatePane: true) — it OPENS AND ACTIVATES the file, so it would yank the editor away from the track being played, mid-transition. That is worse than the ghost. Until the plugin gets a focus-free `code` command, the fix is `hush` (safe under pressure, costs the tail) or pasting the block at the top of a track — and that is a .tidal edit, which is PLN's call, not mine. `dN` IS `xfade N`, so `d6 $ silence` should FADE the ghost rather than cut it; that wants an ear before it is assumed. TESTS: 39 new, suite 306 -> 345, all green. The one that matters is the cross-validation: the parser's orbit set is asserted against the TEN sets hand-measured on 2026-07-28, before this parser existed. All ten agree, so the tool and the ear agree. Also pinned: a commented-out `-- d6` counts as neither declared nor overwriting (correct in both directions); the silence block never silences the track's own orbits (that shape is a mute-bomb and would be blamed on the rig); it uses `silence` and never `# gain 0` (which leaves the pattern scheduled and revivable by a later global gain); and d1-d5 never orphan anywhere in this set — so if a future track drops one, the ghost lands on the loudest possible orbit and the suite says so before the stage does.PLN (Algolia) authored -
Two colour bugs PLN found BY EYE, on the hardware, in the same glance. Both were cases of the code being confidently reasonable and visibly wrong. #76 — "why are [mutes] 1/2/3 resp red red green? these 3 buttons have same roles, why diff colours?" He was right and the cause was in two places at once. The panic feature is an OVERLOAD: hold LCXL buttons 73+74+91+92 together, the SC bridge edge-detects the chord and flips a persistent "^93" toggle, and gPanic gates on it. But those buttons have day jobs — 73 is gMute1, 74 is gMute2 — so: * control_colour tested `cc in PANIC_CHORD` BEFORE role, so 73/74 painted dim RED at rest while 75 (gMute3, identical job, not a chord member) painted by role. Three buttons, one job, two colours. * parse_track ALSO force-bound all four members to role "fx" (red hue) via setdefault, so even on a track binding gMute3 and not gMute1/2 the row read literally "R R G". Found by rendering mock-lcxl.py after fixing the first half and noticing the row still looked wrong. The panic identity does not exist at rest, so it must not be painted at rest. Now: `panic` is threaded in from the model (`values[93]`), the chord members paint by role like any other button when it is clear, and arming flashes red on all four as a GLOBAL OVERLAY applied last — over role, and over dark, because an unbound member must still flash. CC93 owns no LED of its own (it sits outside row F's 89-92), so those four buttons are the only place the armed state can live, and it is the highest-value LED in the rig: it answers "why is there no sound?". The watch loop rebuilds the whole frame on a ^93 flip rather than painting one index, since one event changes four LEDs. Verified by rendering the convention board: row F at rest is now `dimGRN dimGRN dimGRN dimAMB dimAMB dimAMB dimGRN dimAMB` — identical to row E, which is exactly what PLN reported seeing on row E ("G G G Y Y Y G Y"). Armed: `RED! RED! grn amb amb amb RED! RED!`. #11a — the DJ filter is THREE states now, not seven Settled by PLN: "sunset the dimorange and just have red in lows green in highs for clarity" + "i agree on clarity > resolution". The old ramp spent four of its seven steps on dim-amber and mid shades either side of centre, so the row read as a wash of oranges at a glance and the one thing you actually need — WHICH WAY is this filter cutting — was the hardest bit to see. Now: bright red (LPF, lows) / bright amber at the 61-67 detent band (bypass) / bright green (HPF, highs). All three are palette corners, the only states that read reliably on a dark stage. The centre band stays bright because djf 0.5 / CC 64 is BYPASS, not zero — djf 0.05 is a ~26 Hz low-pass, i.e. silence, which is the #48 footgun this colour exists to keep visible. #11b — "I See only two states, dim and not dim, at 0 and not 0 atm on the knobs" Also true, and embarrassing: `value_ramp` — his own verbatim six-step spec — was written, unit-tested, documented in the module header, and never once called. control_colour had exactly three outcomes for knobs (dim / full / flash), so a knob at 30% and one at 90% were the same colour. Wired in. The trade, stated because it should not regress silently: the ramp spends all three hues on VALUE, so hue no longer carries ROLE on rows A/B/C. That is the right way round — role is already fixed by POSITION (the lane convention), while a knob's setting has no other channel at all, since the pot's pointer is invisible on a dark stage. Keeping "untouched" as a colour was tried and abandoned: dim green would have meant both "rhythm, untouched" and "value ~3/4". That nuance belongs in the HUD, which has unlimited colours. Unbound controls still go DARK, which is the distinction that actually matters. Module header rewritten to describe the convention that now exists, rather than the one it used to. TESTS: +24 in test_lcxl_colour.py (8 -> 32), suite 282 -> 306, all green. The old knob rule had NO test at all — which is how a never-called ramp survived being shipped. Now covered: the filter has exactly three states and no dim amber survives; the detent band is contiguous and symmetric; the three mutes are equal at rest AND when engaged; arming repaints all four members and nothing else; an omitted panic argument behaves as unarmed (the #55 "untouched means zero" lesson); the overlay beats both role and dark; and the chord is asserted NOT to be force-bound as fx. Live: lcxl-leds-watch restarted, board repainted.PLN (Algolia) authored -
After a set, "did it glitch?" and "did it get hot?" and "which controls did I actually use?" are answered from memory. Memory is a bad instrument, and the answers matter: #8 (thermal impact under load), #56 (Pulsar starving audio), #46/#11 (which surface controls are really in the hands). PLN records the run-through in Ardour; this gives that take a machine-readable twin. WHAT IT DOES `gig-log.py record` 1 Hz JSONL: package/core temp, fan, freq, throttle counters, per-core cpu%, and per-gear %cpu + RSS for scsynth / sclang / ArdourGUI / the whole pulsar process group. Plus PipeWire xruns and every LCXL control move. `gig-log.py mark ...` annotate the live session ("gimme acid drop") `gig-log.py report` render it back: sparklines, xrun timeline, gear table, a per-CC surface table with first/last touch `gig-log.py status` is it running, what is it writing `gig-log.py install` systemd --user unit, enabled, starts with the session `gig-log.py selftest` prove the parsers AND the cost Wall-clock stamped in the header, so the timeline lines up with the Ardour take. THE OBSERVER MUST NOT PERTURB — measured, not asserted This rig has twice produced the fault it was measuring: probe-chain's capture streams caused the xruns it was hunting, and the LED daemon's per-event fork caused the lag it was reporting. So: no audio capture at all, no per-sample subprocess spawn (every number comes from sysfs/procfs), and exactly two long-lived children (`pw-top -b`, `aseqdump`) drained by threads so a filling pipe can never block them. Measured cost of the whole thing: 0.40% of one core with 270 pw-top lines parsed in 10 s; 0.79% as the live systemd unit. The first selftest reported 0.66% and was FLATTERING ITSELF — it never started the reader threads, so it measured a sampler with nothing to parse. Same mistake as the first --bench run in the LED work; fixed, then re-measured lower and honest. AND THE LOGGER MUST NOT BE A FIREHOSE A fader sweep is 100+ events/s. A logger that writes them all costs more than what it measures, so CC/pitchbend are coalesced to one line per control per second carrying count + first/last/min/max — the #71/#72 fix applied to ourselves. 128 events on one knob is one line that still shows the whole travel. NOTES ARE NEVER COALESCED: a CC is a state and may be superseded, a note is an event and dropping one loses a thing that happened. COUNTERS ARE DELTAS, AND THE BASELINE IS WHERE THE BUGS LIVED Two absolute counters here are large and meaningless alone: package_throttle was 17024 after 2 idle days, and Ardour's PipeWire ERR was 67072. Both count history, including power excursions and device changes that never touched audio. So the header records baselines and samples record deltas. Getting Ardour's baseline right took three rules, and the first report caught each one: 1. baseline on FIRST sight -> pw-top prints a zero-filled snapshot before the profiler has data, so base=0 and a 25-second IDLE session reported 72096 xruns. 2. baseline on SECOND sight -> usually right. pw-top emits a non-deterministic NUMBER of zero tables, so it silently reported 67123 on the next real run. A rule that is right most of the time is the worst kind for a gig log, because the one bad reading looks exactly like a disaster. 3. baseline = MAX over each node's first 5 sightings. ERR is monotonic within a node's lifetime, so the max over a warm-up IS the true starting count — no timing assumption at all. A value below the baseline means the node was destroyed and recreated, so re-baseline instead of reporting negative. Validated by three independent 15 s runs, all reporting 0 (measure twice in time before trusting one reading). The report DECLARES the warm-up blind spot rather than hiding it. Also learned on the way: Ardour accumulates ~6 xruns/min even with nothing playing (67072 -> 67123 -> 67134 across captures minutes apart), which is exactly why only the session delta may ever be quoted. FIXED IN THE SHARED READER perf._read(None) raised TypeError instead of returning the default, so on any machine without a coretemp/dell_smm hwmon the thermal read CRASHED rather than reading "unknown" — the Bridge shares this code path. Guarded. Refactored _proc_cpu_rss into a pure parse_proc_stat() to make it testable, and it needed to be: pulsar's renderer comms look like `(pulsar) --type=renderer`, so splitting /proc/pid/stat on whitespace from the left shifts every field and silently reports some other column as CPU. TESTS: 71 new (tools/tests/test_gig_log.py), suite 211 -> 282, all green. Covers real pw-top/aseqdump lines, every baseline regression above, coalescing invariants, notes-survive-a-CC-flood, a torn final line costing one sample not the log, absent gear degrading to partial data instead of a crash, and sparklines bucketing by MAX so a one-second burst inside a 40-minute set cannot be averaged away.PLN (Algolia) authored -
docs(tasks): archive #71/#72/#53 — the LED lag was a representation bug, and it had a twin in the Bridge
PLN (Algolia) authored -
PLN, 2026-07-29: "midiGlobal can be killed imo. so yea please do the refactor of boottidal so we can free gain track1". `midiGGlobal = orDef 0.769 "^77" * 1.3` read the LIVE fader, and that was wrong twice over. First as SAFETY: it put a global gain on ONE physical fader, so brushing past fader 1 in the dark attenuated every midiG-using stream at once — the whole set, quietly, with nothing on screen to explain it. A control that can silence everything should not be reachable by accident. Second as ERGONOMICS: CC77 being read by Tidal is what kept the surface one lane off. Ardour has learned CC 78-84, CC77 is free, and with Tidal no longer reading it the eight faders can finally line up fader N -> dN instead of fader N -> d(N-1) (#46). Any track that reached for a fader was reaching one to the right of the orbit it was thinking about. Now a fixed pre-set: `midiGGlobal = 1.0`. Deliberately inaudible — the old untouched default evaluated to 0.769 * 1.3 = 0.9997, so every existing track moves by 0.003 dB. Global headroom is still adjustable, but it is one number in one place rather than something a hip can knock. NOT retired: the midiG family itself. PLN thought he no longer used it ("i dont use midiGs anymore iirc since ardour faders"), and behaviourally he is right -- with the global term gone, `midiG' ch l h` reduces to plain `gain (range l h ch)`. But the NAME is called in 167 files (`midiG'`) plus 12 (`midiG`), and deleting a definition the corpus references is a Haskell compile error, which takes the whole `let` block down and silences the entire track. That is the exact failure mode of the last two debugging evenings, and five days from OPAL is not when to re-open it. Retiring the usage is a post-gig corpus migration, sibling to #64. Verified with tools/check-boot.sh: the helper block typechecks against tidal-1.9.5 under `ghc -fno-code`, the #55 seed block typechecks, and all 13 g* helpers (including midiGdef, which is the one this edit could plausibly have broken) still yield events with an UNTOUCHED controller. Not "no error appeared" -- the helpers were run against an empty control map and their event counts checked. Also lands tools/at/lens.py: infers WHICH MEASUREMENT can see a given control from the track's own source, so a per-track acceptance test can assert "this knob measurably moves the orbit it is wired to" without using a lens that is blind to the effect. rms cannot see a filter or a bitcrusher -- they rearrange the spectrum and leave the level alone -- so an rms-based "did anything change?" reports a confident NO on an effect that works perfectly. Classifies 96.7% of the corpus's 5046 control bindings (33% unknown -> 3.3% once the scanner looked at a 3-line window instead of one line, because Tidal expressions wrap and an unclassified control is one the suite silently SKIPS -- precisely the controls most likely to be broken). 41 unit tests, every positive case a real corpus line, with a coverage guard so a future edit cannot quietly regress the scan.PLN (Algolia) authored -
Found by sweeping the rest of the gear for #71's bug class, which turned out to have a sibling living in the Bridge. `MidiStream._reader` did `q.put_nowait(ev)` and on `queue.Full` silently `pass`ed. That keeps 512 stale events and throws away the one that just happened -- exactly backwards for MIDI state, where the newest value IS the truth. A dashboard tab that stalled for ~1.3 s at 400 CC/s would fill its queue and then display frozen values for the rest of the set, with no error logged anywhere. Same failure shape as the LEDs: nothing breaks, it just quietly stops telling you the truth. Downstream, `_sse_midi` wrote and flushed once per event, so one fader sweep cost ~400 HTTP flushes a second per open tab, each with its own json.dumps. Fixed the way the LCXL painter was: drop-OLDEST on a full queue, and batch a frame's worth of events into a single write. New `coalesce()` folds the batch on one rule -- CONTINUOUS controls (CC, pitchbend, aftertouch) are STATE and may be superseded by a newer value; NOTES are EVENTS and may never be dropped. That distinction is the whole point: a fast monitor that loses a note is strictly worse than a slow one, so the tests assert every note survives a flood of 100 controller messages. Order is preserved by overwriting a superseded value where it stood rather than moving it to the end, so the monitor still reads as a timeline. The wire format is unchanged (one `data:` line per event), so ui/index.html's onmessage/JSON.parse is untouched. Also extracted `_fanout` so the drop policy is testable without ALSA. A green test on `parse_line` proved nothing about the queue behaviour behind it -- verify the seam. 8 new tests, 32 green in tools/bridge.
PLN (Algolia) authored -
PLN, watching the board for the first time on real hardware: "its slow to track if i move all fders quickly i see the animations lagging a sec or two behind". Not a slow device. Three compounding faults, each of which alone would have been survivable: 1. THE BUG. The read loop repainted whenever a CC *value* changed. But colour is a STEP function of value -- six steps in value_ramp, seven in filter_colour. Sweeping one knob 0 -> 127 emits ~128 events and can change the board at most 6 times. We were asking the wire for ~20x the work that could possibly be visible. 2. Every send re-resolved the port from scratch: `aconnect -o` AND `amidi -l` AND then `aseqsend`. Three forks per LED, ~1.7 ms each, measured. 3. All of it ran INSIDE `for line in proc.stdout`, so a write in flight stopped us reading the next MIDI event. aseqdump's pipe QUEUES rather than drops, so nothing was lost -- everything just arrived later, and later, without bound. That unboundedness is why it read as "a second or two" rather than a constant delay. The fix, in payoff order. A new `Painter` thread owns the wire; the reader only touches the model and hands it colours, never blocking. The painter dedupes on the COLOUR the board will show, coalesces so only the last colour per index within a frame reaches the wire, and batches every dirty index into ONE SysEx (the Launchpad dialect takes (index, colour) pairs, so a whole-surface repaint is a single write). Port resolution is cached for 2 s and thrown away the instant a write fails -- evidence, not a timer -- which keeps the replug-recovery property that made it uncached in the first place. Rate limiting is a floor on the GAP between writes, not a fixed tick, so an isolated button press still goes out immediately. Measured, not claimed. `--bench` replays PLN's own complaint (8 knobs + 8 faders swept together, 500 events/s) against a transport modelled at its real cost, and runs the legacy path beside the new one: legacy 744 wire msgs wall 11.23s overrun +8.23s coalesced 48 wire msgs wall 3.00s overrun +0.00s 15.5x fewer messages; latency p50 7.9 ms, p99 20.2 ms Overrun IS the visible lag -- it is how far behind his hands the board finishes. On the real device the per-write cost also fell 7.70 ms -> 2.72 ms with ports cached, so the total wire work is down roughly 44x. Twelve regression tests, no hardware needed. Speed regresses silently -- nothing goes red, it just gets slow again -- so the assertions are numeric: wire rate bounded by the frame rate and not the input rate, reader never falls behind, p99 under 50 ms, a lone press not delayed by the frame boundary. The last one is the one that matters: fast and wrong beats nothing, so we decode every SysEx the coalescer emitted through the mock surface and assert the board that LANDS is exactly what a full `build_frame` recomputation would have produced. Also: verbose logging now fires only when the board actually changes. Printing 400 lines a second of "CC77 = 63" was itself I/O in the hot loop, and told nobody anything.PLN (Algolia) authored -
Three days stuck on LED feedback that had never once been observed lighting a physical LED: every validation ran against a fake aseqdump stream. PLN confirmed the hardware works on 2026-07-28. Building the mock surface took under an hour and immediately explained the complaint that had survived two rounds of fixes — the DJ filters rest at BRIGHT AMBER, which is filter_colour(64) behaving exactly as designed and visually identical to the factory yellow he was trying to escape. Correct and looks-broken were the same picture.
PLN (Algolia) authored -
The LED work had a hole in the middle of it. `lcxl-leds.py --watch` was written, wired into the boot sequence and into gig-up.sh, and validated only against a FAKE aseqdump stream — nobody, human or machine, had ever seen it light a single LED. PLN kept reporting "still seeing static colors" and I had no way to check my own work, because I cannot look at the device. mock-lcxl decodes the same SysEx the real surface receives and renders it in the terminal, so the convention can be designed, reviewed and regression-tested without hardware. The only thing still needing PLN's eyes is whether the physical LEDs match the picture. It answered the "static yellow" complaint on its first run. Painting claude.tidal lights knobC 1-3 BRIGHT AMBER (63) — which is exactly filter_colour(64), the DJ filters resting at centre. Our code is working as designed; the design is the problem. Bright amber at rest is visually indistinguishable from the factory yellow PLN is trying to get away from, so "correct" and "looks broken" are the same picture. Second finding, same run: control_colour returns hue["full"] for every value from 8 to 119, so a touched knob is one flat bright colour across its whole travel. Touch changes it once and then it never moves again — which is precisely what he has been describing. So value_ramp() implements his spec verbatim — "from dim red nothing through bright red dim orange bright orange dim green bright green at max". That is six steps, and six is not a coincidence: the LCXL is bicolor (2 bits red x 2 bits green = 16 states) and only about six read reliably on a dim stage. The ramp uses the entire usable budget and spends nothing on shades nobody can tell apart. `mock-lcxl.py --palette` shows the whole budget; --ramp shows the convention resolving across 0..127. The DJ filters deliberately keep filter_colour and do NOT get the ramp: they are bipolar, centre 64 is bypass, and a monotonic red->green ramp would paint bypass as mid-orange and the two opposite musical extremes as the same colour. 8 regression tests covering the ramp order, monotonicity, "a mapped control at zero is dim, never off" (a control you cannot see is one you forget exists), "a filter is never dark", and a SysEx round-trip through the mock surface.
PLN (Algolia) authored -
Green unit tests on pure functions prove nothing about whether the rig makes sound. Every failure that has cost this project an evening lived in the SEAM: a helper that typechecks but silences an orbit, a knob that moves but changes nothing, an orbit still playing from the previous track. pytest cannot see any of it; a microphone can. So pv-at drives the REAL rig — boots a track through tidal-remote, taps the real PipeWire graph through probe-chain's `Tap`, and asserts on measured audio. Reusable by construction: a case is a named assertion, not a one-shot script, so tonight's investigation is tomorrow's regression suite. The fixture is a track, not a test tone: claude.tidal, a slow D-minor roller at 124. Test tones prove the signal path works and nothing about whether MUSIC survives it, and they are miserable to listen to while debugging. Each of its eight orbits is simultaneously a part of the arrangement and an assertion — kick/presence, hats/density, sub/register, stab/brightness, pad/sustain, arp/mask, riser/time-axis, break/mute. 8/8 passing against the live rig. Three of the assertions had to be rewritten first, and each rewrite is the same lesson in a new costume — the arithmetic is never the bug, the QUESTION is: * sub_is_low asserted on spectral centroid and failed d3 at 837 Hz — an orbit carrying `# lpf 220`. A magnitude-weighted centroid integrates the whole spectrum, so a low-level broadband floor spread over 20 kHz drags the mean far above where the energy actually is (d5: lpf 1400, centroid 6344 Hz). Replaced with band-energy ratio: d3 now reads 95% of its energy below 300 Hz. The centroid stays for RELATIVE tests, where the floor is common to both readings and cancels — filter_bites moves 2749 -> 2152 Hz cleanly. * riser_rises compared the first half of the window to the second and was a COIN FLIP: d7 is driven by a saw whose period is comparable to the capture window, so a fixed window lands on a random phase. Consecutive runs of the identical pattern scored +7.1 dB and -20.9 dB. Replaced with the maximum draw-up (largest rise from any bin to any later bin), which is true at every phase: 32.4 dB over a 34.1 dB span. * stab_is_bright demanded ">35% of energy above 1 kHz" and failed d4 at 32.6%. That threshold was invented, and the honest response to a number that close is to fix the question rather than nudge the threshold until it passes. Now comparative — the stab must sit well above the sub (32.6% vs 0.4%) — which is a musical invariant instead of a guess. Safety is structural, not procedural: SAFE_CC is an allowlist, CC 77-84 are excluded by construction (Ardour owns those faders; CC77 down is total silence), the LCXL port is re-resolved BY NAME on every send, and the suite hushes when it finishes. Exit code 2 means "rig not running", kept distinct from 1 "a case failed" — conflating an unmet precondition with a pass is how a green suite comes to mean nothing.PLN (Algolia) authored -
Three evenings this week went to bugs that were VISIBLE IN THE TEXT and that no eye caught mid-set, because none of them produce an error message. They produce silence, on stage: * desire.tidal — `off 0.125 (|+ note 12 . (|* gain 0.6))`. `.` binds looser than application, so this composes a FUNCTION into a ValueMap. GHC refuses the whole block, and since a blank line is Tidal's block separator, ONE character silenced nine orbits while the file looked perfect on screen. * you_my_sunshine.tidal — a local `let gF3 = (# djfbus 3 (range 0.05 0.95 "^51"))` shadowing the global helper we had already fixed. SuperDirt maps djf through linexp(0, 0.5, 20, 10000): bypass is 0.5, and 0.05 is a ~26 Hz low-pass. Four orbits (d5/d7/d9/d11) inaudible until a knob crossed centre. * the #55 mute-bomb — an untouched `^NN` yields NO events, so `range a b "^NN"` is `silence`, not a number. All three are static properties of the file, so they belong in a linter. Seven rules, each earned by a real failure: PV001 local djfbus shadow (with an autofix that deletes the line so the track inherits the corrected global), PV002 operator-section composition (autofix adds the missing parens), PV003 double-applied global gain, PV004 shared cut group, PV005 duplicate orbit in one block, PV006 sample index past the end of its folder, PV007 orbit inventory (the input to the orphan-orbit transition check, `--setlist`). Three of my own rules were wrong on first contact with the corpus, and the corpus caught all three — which is the argument for running a linter against 694 real files before trusting it: * PV002 was string-blind and called four mini-notation `.` separators compile-killers (`"k ~ ~ k . ~"` is grouping, not composition). 13 findings -> 0, all of them false. * PV003 claimed the whole 77-84 fader bank was "owned by Ardour" and raised 1109 errors. BootTidal itself reads `^77` (midiGGlobal); reading a fader CC is the documented convention. Narrowed to the actual hazard — ^77 applied twice — and demoted to a warning. * PV005 flagged every re-declaration of an orbit, but keeping several evaluable versions per file is normal live practice. Scoped to one block: 576 findings -> 32. * PV006 pooled all indices in an orbit against all folders and invented a finding on d8 (n=24 is org_jungle_breaks', not breaks165's). Also fixed the parser: ORBIT_RE demanded a same-line `$` and so dropped d5 from you_my_sunshine's inventory WHILE THE TRACK WAS PLAYING IT — PLN writes `d5 -- comment` with the `$` on the continuation line, which Haskell's layout rule allows. A linter that under-reports is worse than none. Corpus after the corrections: 694 tracks, 316 errors (all PV001 shadows, in archive material), 281 warnings. First real run found a live one — d5 and d11 of you_my_sunshine share cut group 5, so the voice and the chop truncate each other. 33 unit tests, every negative case a real corpus line that an earlier rule version wrongly flagged.PLN (Algolia) authored -
The full #66 entry for the documentary trail. The headline is not "all 10 tracks play" (they do, 91 orbits, zero silent) but that the instruments were wrong more often than the rig — six times, and the arithmetic was never the bug in any of them. Three answered a question about SHAPE with summary statistics, one divided by zero and called it an effect, one compared two points on the same hill, and one measured a system that included the measurement. The last is the best story: an xrun counter climbing +816 in 25 seconds, days before a gig, matching PLN's own report of crackles — and caused entirely by probe-chain's own PipeWire capture nodes. Remove them, repeat the identical musical load, get zero. Both REAL faults that day were compile errors wearing an audio costume.
PLN (Algolia) authored -
#12 (order the set into a BPM arc) has been blocked on three tempo questions, and they were framed as things PLN had to decide. Two of them are measurable. FIRST, ONE DISSOLVED ON INSPECTION. you_my_sunshine was recorded as "144 measured vs 166 written", a 22 BPM gap big enough to INVERT the planned rising finish Mafia(160)->Sunshine(166). The file has two setcps lines and the 144 one is COMMENTED OUT. The active tempo is 166, the backlog was right all along, and the "mismatch" was a grep reading a comment. That is the parsers-over-copy lesson again: a parser miss must never masquerade as a data conflict. (My own first re-extraction then mangled every number by using `tr -d '/60'`, which deletes the digits 6 and 0 — 160 became 1. Two parsing bugs in one sitting, on four-line shell one-liners.) SECOND, THE REAL QUESTION NEEDS AUDIO. gimme_acid declares 80 while the backlog calls it 160, and `setcps` cannot settle it: setcps(80/60/4) declares four beats per cycle at 80, but whether that is HEARD as 80 or 160 depends on what the patterns put inside the cycle. Half-time notation is standard in dnb. This decides whether the track sits beside the 160 BPM peak or is the second-slowest thing in the set. So: spectral-flux novelty curve -> autocorrelation -> the fastest pulse that explains the signal, reported together with its half/double family, because tempo from audio is only ever determined up to a factor of two and pretending otherwise is the error. BUILT THE KATANA FIRST, and it needed four passes — every one caught on synthetic click tracks of KNOWN tempo before the tool informed any decision: 1. Inter-onset-interval MODE. Failed on the first real track: gimme_acid's d1 is a continuous 303 line whose flux peaks every ~80 ms of internal movement, so the modal gap gave "750 events/min". Counting gaps between events cannot find a beat when the events are not beats. -> autocorrelation, which asks about PERIODICITY and is unbothered by extra onsets inside each period. 2. Octave errors: a clean 124 click read 61.9, a clean 160 read 80.0 — exactly half, correlation 0.85+, confidently wrong. Cause: at 100 Hz frames the true lag 48.39 must round to 48, which misaligns every later beat, while exactly 2x landed on a whole frame. -> 200 Hz frames + 15 ms Gaussian smoothing, so a fractional period still matches itself. 3. A uniform +1.8% bias (~2 BPM at techno tempo — enough to swap a 124 and a 127 in a set order). Suspected the unnormalised overlap in np.correlate, fixed that too, and the bias did not budge. The actual cause: the shortest-lag-within-12% rule, written to choose between octaves, was also sliding down the LEFT FLANK of the correct peak — comparing points on one hill as if they were different hills. -> candidates restricted to local maxima, plus parabolic interpolation. Result: 13/13 synthetic cases within 0.03%, including jittered, noisy, offbeat-8ths, and the half-time case (80 BPM with 16th kicks correctly reads 160 felt). A tempo number that is 2% wrong looks perfectly reasonable, which is exactly why this had to be calibrated against known truth instead of eyeballed against real audio.PLN (Algolia) authored -
The end-to-end gate result, plus the two findings that arrived after the log was first written: the mixer is clean LIVE (zero "Ardour eats it" across all 10 tracks, after PLN raised the physical faders mid-session), and the rig does not xrun under musical load — zero across 60s at maximum event density, scsynth ERR = 1 all session. Also records the fourth instrument error, which is the best story of the day: the xrun counter that climbed +816 in 25 seconds and looked exactly like "heavy patterns cause dropouts on gig week" was probe-chain's own capture streams. Remove the probes, repeat the identical musical load, get zero. Every probe run creates and destroys ~18 PipeWire nodes and each renegotiation costs Ardour an xrun. The pattern across all four: the arithmetic was never wrong. Three of them answered a question about SHAPE with a pair of summary statistics; the fourth measured a system that included the measurement. What makes them dangerous is that a perturbing instrument produces PLAUSIBLE numbers pointing at the wrong subsystem, and confirmation bias supplies the rest — I was looking for a crackle cause and was handed one.
PLN (Algolia) authored -
Third and last calibration of shape_of() against real audio. perfect.tidal's d4 measured |▇▇▆▆▇▇▆▅▆▆▇▆▃| — visibly steady for twelve bins — and was reported "DECAYING, 13 dB down, never recovering, almost certainly an xfade tail" on the strength of the thirteenth. The capture window had simply closed inside a gap between events. The midpoint-crossing rule added in 708c2aef cannot catch this: a dip in the last bin crosses the midpoint exactly ONCE, which is the signature of a genuine fade. So ask a different question — does the verdict survive dropping the final bin? If the inner trend is flat, the final bin WAS the verdict, and the answer is STEADY with the edge dip named explicitly rather than hidden. A real decay is unaffected, including one that only begins in the last three bins (still DECAYING). Eight synthetic shapes now separate cleanly: steady-with-edge-dip, monotonic decay, late decay, gated-riser-dipping-at-the-end, spiky-sparse, died, steady, rising. Pattern across all three fixes to this function: every false positive came from answering a question about SHAPE with a pair of summary statistics. Each fix replaced a statistic with the structural question that actually distinguishes the cases — does it recur, does it survive without its last sample. The verdict text now says which, so the reading can be argued with instead of believed.
PLN (Algolia) authored -
The pre-set checklist carried a line that has been wrong since gDJF was rewritten: "Faders 49/50 down (= bypass)". `gDJF` is two composed sections whose lpf and hpf are BOTH wide open at ch=0.5 and close as you move off centre in either direction (BootTidal.hs ~358, corrected mid-rehearsal 2026-07-27 when PLN described exactly that: "middle had no filter, total left = almost only subbass, total right = almost only superhigh"). So a knob at 0 is a hard low-pass. The runbook was instructing him to start the set with the bass-only end of the filter engaged — the opposite of bypass — and muscle memory built on a checklist is worse than no checklist. Measured the same day for confirmation: CC49 at 0 puts 72% of d1's energy below 150 Hz; at 127, 75% sits above 2 kHz; centre 64 is flat. Also adds §8, "I evaluated it and heard nothing", because the most common cause of silence on this rig is not audio: three times in one week a dead rig was a compile error. The identifying rule is structural and instant — ALL declared orbits silent means it did not compile (one block, one error), SOME silent means a real audio problem. With the bisect-by-blank-line technique PLN invented, the editor-buffer caveat, and a pointer to the new EVAL ERROR notification that finally makes this class visible. And wires the new instruments into the pre-set flow: check-mix.py (save Ardour first — it reads the file, and file vs live desk have disagreed in both directions), check-tracks.sh (the empirical compile gate, ~45s/track so run it the day before), lcxl-leds.py --map. Status section updated honestly: everything here was run against the live rig that day EXCEPT the LED paint, which nobody has confirmed by eye.
PLN (Algolia) authored -
The closing A-again capture gives a single estimate of how much the signal moves on its own, and on the first real run that estimate was wrong in the direction that matters. Two captures of d2 at the SAME control setting read 1.6 and 2.4 onsets/s — 33% apart, which happened to equal gMask's entire measured swing — yet the closing capture matched the opening one exactly, so the tool reported drift 0.0% and called the control EFFECTIVE. One repeat can agree with the first reading by luck. Now the baseline is captured --repeats times (default 2) BEFORE the control is touched, and the spread between those captures is the floor a swing has to beat; the closing A-again is combined with it via max(). This is why d7's ply came back INCONCLUSIVE (swing 13.3% vs drift 10.0%) instead of EFFECTIVE — which is the right answer: across two runs that same control read -18% and then +3.3%, i.e. noise. The rule this encodes: on a livecoded rig the material is not stationary. Patterns written as `"~ c . <[~ c ~ c] [<c ~ ~ c> ...]>"` play a different bar every cycle, so a before/after difference is evidence of nothing until you know how big a difference the music produces unaided. Measuring the control without measuring the noise is how you end up certain about a knob that does nothing. Also lands the #66 log (armada/tasks/018) with the full per-track and per-control tables, and the three findings still open: gMask's swing is not clear of its own variance, a reproducible ~15s settle lag after a control returns to rest, and Ardour's xrun counter climbing while SuperCollider's does not.
PLN (Algolia) authored -
Three corrections after the first real run against the rig, each one a case of the tool answering a question it could not actually answer. 1. THE PLY VERDICT WAS A DIVISION BY ZERO. `ply` reported "EFFECTIVE, +inf % onsets/s" on d4. The truth: the envelope-ratio onset detector returned a flat 0.0 events/s, because d4 is a SUSTAINED bass and that detector only fires on a 1.6x jump between adjacent bins. Percentage against zero is infinity, and the tool declared an effect from an artifact — precisely what its own docstring promises never to do. Replaced with SPECTRAL FLUX (half-wave-rectified per-band magnitude increase, adaptive median + 2.5*MAD threshold), which fires on a NEW NOTE even when total level barely moves — exactly what ply does, since it subdivides events without getting louder. d4's baseline went 0.0 -> 8.1 onsets/s, and ply then measured a real, REVERTING change: 8.1 -> 4.7 at CC127 -> 7.7 back at 0. Plus a floor: under 0.8 onsets/s the answer is INCONCLUSIVE with an explanation, not a number. inf/nan deltas print NOT JUDGED and are excluded. 2. NO CHANGE MEANT THE WRONG LENS, NOT A DEAD CONTROL. gMask on d2 read "NO CHANGE, 0.5 dB rms" — correct arithmetic, wrong question. gMask is `midiOn "^41" (mask "t!7 f")`: it removes one eighth of a cycle, so it changes the PATTERN and costs ~0.6 dB. On the density lens the same control reads a clean -42% onsets. A "no change" verdict is only meaningful if the measure could have seen the change. 3. A-B-A, BECAUSE THE PATTERNS MOVE BY THEMSELVES. gMask showed an IDENTICAL -42% at both test values, which is either a control that fails to revert or a pattern that just varied — and d2 of vague_de_crime is `"~ c . <[~ c ~ c] [<c ~ ~ c> ...]>"`, a different bar every cycle. An A-B comparison cannot tell those apart. So the baseline condition is now re-measured at the END, the self-drift is reported, and any swing under 2x the drift is INCONCLUSIVE rather than EFFECTIVE. First use immediately paid off: ply's timbre swing 19.9% against 4.7% drift = trustworthy. Self-inflicted note for next time: these edits were made WHILE a batch loop was invoking the file, so one run died on a half-applied change (AttributeError on an argparse dest added two edits later). Its captured data was fine, but don't hot-edit a tool a running job is calling.
PLN (Algolia) authored -
The painter existed as of the previous commit but nothing called it, which is exactly how the surface went dark in the first place: the only LED path was the Pulsar HUD, so the board was lit only when the editor happened to be open AND its frame pipeline was healthy. #57 says that pipeline latches an early empty frame. A rig whose feedback depends on an editor being up is a rig with no feedback. So both boot paths paint now: * tools/tidal-remote.py boot becomes a 5-step dance — reboot, wait for BootTidal, seed the surface (lcxl-init), PAINT it (lcxl-leds --map <track>), eval. Order matters and is unchanged in spirit: the seed gives every `^NN` a value instead of `silence`, and the paint makes that state visible. Per-track, so the board is a map of the file about to play: dark = this track does not bind that control. * gig-up.sh paints at step 2b, the moment the rig owns MIDI — BEFORE Ardour and Pulsar are even launched, using the no-track CONVENTION paint so there is always something to look at. GIG_LEDS=watch additionally starts the touch-reactive daemon (log → gig-leds.log, gitignored); GIG_LEDS=off skips the whole thing. Both call sites are NON-FATAL on failure and say so out loud. LEDs are feedback, not sound: a dark board is annoying, a boot that aborts because of a dark board is a lost gig. gig-up additionally names the one thing software cannot fix — if input works but the board stays dark, that is a USB OUT endpoint stall and only a REPLUG clears it. Both messages refuse to claim success: "a clean exit is not a lit LED". amidi/aseqsend returning 0 means the bytes left the machine, nothing more. Validation: bash -n on gig-up.sh, ast.parse on tidal-remote.py, and the paint command itself run against the live device (rc=0, 40 LEDs in one 89-byte SysEx write). NOT run: tidal-remote boot, deliberately — PLN is measuring audio in this same rig right now and a reboot would trash his results. The 5-step path is verified structurally, not live.
PLN (Algolia) authored -
probe-chain reported piment_bresilien's d10 as "DECAYING, almost certainly an xfade tail" in two consecutive runs. It is nothing of the kind: d10 is a `mask "<t f!7>"` riser — one cycle sounding, seven silent — and the sparkline showed it plainly, dipping and coming back (|▇▇▇▇▇▅▃▇▇▇▆▄|). The classifier was reading head-quartile vs tail-quartile, which is a monotonicity question answered with two averages: exactly the missing-information mistake that made an aggregate unable to tell a fade from a sparse part, one level up. First attempt asked "does it recover after its lowest bin?" — which fails when the dip lands at the END of the window, since there is no 'after' to recover into, and that is precisely the real d10's shape. The right evidence is REPETITION: count how many times the level crosses its own midpoint downward. **A monotonic fade crosses once, by definition. Two or more is a cycle, and a cycle is a part.** Validated against seven synthetic shapes before trusting it on real audio, and they now separate cleanly: gated-riser-with-a-dip-at-the-end -> GATED/SPARSE, spiky-sparse -> GATED/SPARSE (this one was misclassified as DECAYING even in my own self-test and shipped that way), monotonic decay -> DECAYING, decay after a steady head -> DECAYING, silent tail -> DIED, plus STEADY and RISING. Why it matters more than the numbers suggest: a tool that cries wolf on a healthy riser gets ignored on the night it is right. J-7 is the wrong week to teach PLN to distrust the instrument. Also lands tools/check-tracks.sh — the empirical pre-gig gate. There is no static typechecker for a .tidal file (it is a GHCi fragment, not a module), so the only honest question is "evaluate it and does sound come out". Three times this week a silent rig was really a compile error, and each time the damage was TOTAL rather than partial because a blank line is Tidal's block separator and these tracks have almost none. All-orbits-silent is therefore diagnostic: the track did not compile. Run it the day before a gig and after ANY BootTidal.hs edit, since a broken helper silences the whole corpus at once. It distinguishes Tidal faults from Ardour faders and refuses to let a mixer problem read as a track bug.
PLN (Algolia) authored -
The problem, in PLN's words, asked three times: "why no button lights, still see only A1 green? recover that asap i see no feedback anymore not even yellow lifeline" — then "back from 'all yellow' to 'our coding, but touch-reactive and persistent post touches'". Three failures were stacked: 1. NOTHING PAINTED AT BOOT. The only LED painter lived in the Pulsar HUD (lib/lcxl-leds.js), so the board was lit only if Pulsar was open, had activated the package, and its frame pipeline worked — and #57 says its lastFrame dedupe latches an early empty frame, so even with Pulsar open one bad first frame means a permanently dark surface with no error anywhere. 2. FACTORY YELLOW WINNING — a real Midi-Through feedback loop echoing velocity 127 back as an LED colour byte, not a device default. 3. NO PERSISTENCE. The LEDs are write-only, there is NO readback, so software must own the state and re-assert it. Nothing did. Approach: tools/lcxl-leds.py, python3 stdlib only, standalone. It works with Pulsar closed and is scriptable from gig-up. --map [TRACK] paints by ROLE derived from the `^NN` bindings the file actually contains (dark = unbound on this track, which is the biggest cognitive win); --map with no track paints the channel CONVENTION, so a boot can never leave the board dark. --watch is the touch-reactive daemon: it keeps a model of every control and repaints from that model, so persistence is the data structure rather than a feature bolted on. --test walks every index so a dead LED is visible. --dry-run prints hex and sends nothing. It NEVER sends a CC. Not one, ever. SysEx out only; input is read-only via aseqdump as a child process, which does not steal MIDI from SuperCollider. Sending CCs would move live audio parameters, and CC 77-84 are MIDI-learned to Ardour track gains (CC77 down = total silence). Three things this cost, worth writing down: * TRANSPORT. `amidi -p hw:2,0,0 -S ...` is what lit knob A1 by hand — but only because nothing else held the raw device. On a LIVE rig the ALSA sequencer layer owns the rawmidi substream and amidi dies with "cannot open port hw:2,0,0: Device or resource busy". The tool that works on a cold rig failed on the only rig that matters. Primary transport is now `aseqsend` to the LCXL's writable SEQUENCER port, which coexists with SuperCollider; amidi stays as fallback. * PARSING. Splitting the .tidal on blank lines is right in principle (that IS Tidal's block separator) but stripping full-line `-- comments` first manufactures blank lines that cut a dN block in half: on vague_de_crime that put 9/9 bindings in the "fx" fallback because no segment head ever matched dN. Segment boundaries are now original blank lines PLUS every dN line, comments stripped only for the CC scan. Same track now reads rhythm=9, fx=6; gimme_acid 21 controls across rhythm/bass/fx; perfect.tidal 25 across rhythm/bass/lead/fx. * THE SEAM. The first --watch regexes assumed bare space-separated numbers and would have matched NOTHING — a daemon that runs clean, logs nothing and paints nothing. Pulled aseqdump's real format strings out of the binary ("Control change %2d, controller %d, value %d") and verified the whole loop by putting a fake aseqdump on PATH: 9/9 synthetic events decoded, coloured and sent, plus a respawn-with-backoff when the child exited. Also: the periodic re-assert runs on its own thread, not inside the read loop — inside, it would only ever fire when an event arrived, i.e. never during the silences when a device hiccup would actually go unnoticed. Verified: hex reviewed byte-by-byte against the protocol, 40 index/value pairs in one write, all bytes < 0x80; sends return rc=0 to the real device; --watch attaches and survives; event decode unit-checked (CC13@64 -> green full, CC49@0 -> dark red = LPF hard down, CC33@127 -> green flash, note 73 -> red flash). UNVERIFIED: what the panel actually looks like — nobody has eyes on it. Absence of an error is not evidence of a lit LED.PLN (Algolia) authored -
desire.tidal measured 0/9 orbits: every declared channel SILENT AT SOURCE, master bus at -inf. Not the sample-buffer bug (b4f62757 fixed that), not a fader — the file simply never compiled. off 0.125 (|+ note 12 . (|* gain 0.6)) `.` binds tighter than the section, so this parses as `|+ (note 12 . (|* gain 0.6))` — composing a function into a ValueMap. GHC's own words: "'(.)' is applied to too few arguments". The fix is one pair of parens: `(|+ note 12) . (|* gain 0.6)`. After it, 9/9 orbits measure STEADY through SC and Ardour. WHY IT COST A WHOLE TRACK. desire.tidal has ZERO blank lines, and a blank line is Tidal's block separator — so the file is ONE block, and a block either compiles entirely or not at all. One character took nine orbits. This is the third track this week where "the rig is silent" was really "the code did not compile", and it was only visible because the editor stopped deleting the word "error" from GHC messages (fork commit 374d8a3): PLN read the EVAL ERROR notification off his screen and forwarded it. Blank lines between orbits are free gig insurance. Also lands tools/control-lens.py, the instrument for the other half of the question. probe-chain proves an orbit makes LEVEL; it cannot prove a control WORKS, because rms is blind to a DJ filter or a bit-crusher — those move timbre at roughly constant loudness. So control-lens measures the right quantity per control kind: spectral centroid + band energies for filter/crush, rms for gain/mute, onset rate for ply/density. It drives the CC through the same path a physical knob takes (aseqsend -> SC MIDIFunc.cc -> /ctrl -> sStateMV, reused from lcxl-init rather than reinvented), captures before and after, and refuses to guess: a contaminated tap is DISCARDED, a silent baseline is SKIPPED (else one dead fader reads as twelve dead knobs), and "no measured change" is reported as NO CHANGE, never softened to "subtle". It also hard-refuses CC 77-84, which are MIDI-learned to Ardour's track gains.
PLN (Algolia) authored -
PLN: 'wtf tidal multi? we made everytihng on tidal live remember that please'. He was right and this line was the source of the error: check-mix.py had hardcoded the Tidal Multi path, so it parsed a stale archive session and confidently reported five faders at -inf that were actually up in the live session. A mixer auditor reading the wrong mixer is worse than no auditor. Both sessions are real — Tidal Multi still holds the ~398 G of historical per-orbit recordings — so this documents the distinction rather than renaming one away.
PLN (Algolia) authored -
Written for a cold reader and for the blog: the two-part mechanism (upstream header-only reads + our own flag left flipped), why whitelist-by-mtime made it look haunted, the count assertion catching its own counting bug on the AppleDouble twins, the 'Required 0 MB' red herring, and the two harness failures (zsh word-splitting, an early readiness gate) that cost more time than the bug itself.
PLN (Algolia) authored -
PLN, after I misdiagnosed a decaying orbit as a sparse one: "do you lack tooling to detect these over time? do you just listen to the loops once lol". Yes, and he was right. THE MEASUREMENT BUG. probe-chain reported ONE peak/rms pair per orbit over the whole capture window. That aggregate is mathematically incapable of showing a slope, and on this rig the slope IS the diagnosis. d7 in vague_de_crime measured peak -22.5 / rms -68.2 dBFS in a 14s window, and I read the 46 dB crest factor as "a sparse stab per cycle, just buried in the mix". It was nothing of the kind: it was a pattern sounding for ~3s and then dying, i.e. the `dN` = `xfade N` tail of the OUTGOING pattern while a SILENT new one faded in. PLN heard it correctly from the first sentence — "I hear it on ctrl+enter, then it fades in the back over 3s" — while my instrument said "quiet". A sparse part and a dying part produce the SAME aggregate: low rms, one lone peak. That ambiguity is not a judgement call to be made more carefully next time; it is missing information, and the fix is to stop throwing the time axis away. WHAT THIS ADDS. `probe-chain.py -t [BIN_S]` bins each capture (default 1s) into per-bin rms and prints a sparkline plus a classification: DIED loud at first, then silent for the tail -> xfade tail, the eval did NOT take DECAYING >12 dB downward trend -> almost certainly an xfade tail, not a part RISING fading in STEADY a real, sustained part Rendered as blocks with silence as a space, so a die-off reads as a visibly empty tail instead of a number you have to interpret. It reuses the existing verified tap machinery, so it inherits the contamination guard from 872676a2 — a row marked UNRELIABLE gets no timeline, because a fabricated slope would be worse than a fabricated level. VALIDATED TWICE. First against synthetic series, to prove the classifier can actually separate the two cases that fooled me: xfade tail [-12..-45,-inf x7] |▇▆▆▅▄ | -> DIED sparse part [-20,-55,-19,-58,...] |▆▂▆▂▆▃▆▂▆▂▆▂| -> (spiky, no dead tail) steady part [-18..-20 flat] |▆▆▆▆▆▆▆▆▆▆▆▆| -> STEADY Then on the real rig, on the very orbit that caused this: d7 over 18s in 1.5s bins, |▅▆▅▅▆▅▆▅▅▅▆▆|, STEADY -25.0 -> -23.4 dB. Flat. Confirmed by PLN's ears in the same minute ("now its sticking i hear d7 stay"). ALSO HERE: check-mix.py no longer audits the wrong mixer. It hardcoded "Tidal Multi/Tidal Multi.ardour" while the session in use is "Tidal Live" — so it confidently reported five faders at -inf that were actually up, a stale file audited as if it were live. It now resolves the session from the running Ardour process's own argv (matching on cmdline, because the binary is ArdourGUI and `pgrep ardour` finds nothing — a detail that already caused one wrong conclusion), falling back to Tidal Live by name. A mixer auditor reading the wrong mixer is worse than no auditor. Standing caveat unchanged and worth repeating: check-mix reads the session AS SAVED, so unsaved fader moves are invisible to it. Same family as the trap found minutes later — Pulsar evaluates the editor BUFFER, not the file on disk, so a grep of the file can disagree with what is actually playing. Verify against the layer that is live. Refs #26, #62, #65.PLN (Algolia) authored -
THE SILENT ORBITS WERE A TWO-PART INTERACTION, AND PART TWO WAS OURS. Symptom, for two evenings: orbits went silent mid-set. Which ones changed every boot. The boot log looked perfect — every bank registered with the right file count — and 19741 `Buffer UGen: no buffer data` messages piled up in a single 6h session where no performer would ever see them. Part one is upstream design. `~dirt.doNotReadYet = true` registers banks from WAV HEADERS ONLY; the audio is read on first use. That read is fired asynchronously by `readFileIfNecessary` (DirtSoundLibrary.sc:268-274), which returns immediately — so the synth for the very event that triggered the read gets a bufnum with no samples in it. A correct file count in the log proves nothing about playability, because the count comes from the header. Part two is ours, and it is the one that turned a first-hit glitch into a dead orbit. `preload.scd` — our own generated warm-up — set `~dirt.doNotReadYet = false` and NEVER SET IT BACK. `readFileIfNecessary` has exactly one caller, and that call site is gated on `doNotReadYet` (DirtSoundLibrary.sc:259-261). With the flag left false, a bank registered header-only at boot is never read. Not read late: never. So every bank the whitelist happened to MISS was permanently silent, forever. And the whitelist missed banks routinely, because gig-up.sh built it from the N most-recently-EDITED tracks (`--last`). That is a guess about what will be played. the_revolution_will_be_sampled had not been edited recently, so `like_sugar` and `the_revolution` were never warmed — which is precisely the set of banks feeding the six orbits that were dead. Whitelist-by-mtime also explains the symptom that made this look haunted: the misses changed as files were touched, so a different set of orbits died on each boot. Neither half is fatal alone. A miss with the flag restored is a slow first hit; a flipped flag with a complete whitelist is invisible. Together they are a silent-orbit generator. THE FIX, in the generator that emits preload.scd: - Restore `doNotReadYet = true` after warming. This is the load-bearing line: it demotes the whitelist from load-bearing to a mere optimisation, which is the only thing it is safe to be on stage. - Drive the whitelist from an explicit setlist file (setlist_opal2026.txt) rather than from mtimes. Warm what will be PLAYED. `--last` stays for jamming. - Emit a per-bank COUNT ASSERTION. Re-loading a bank REPLACES it (addBuffer defaults appendToExisting = false, DirtSoundLibrary.sc:42-46), so this is index-stable and `like_sugar:21` keeps its meaning; the assertion guards the one case that would break that, a whitelist gone stale against the folders. - gig-up.sh writes via a temp file, because `> preload.scd` truncates BEFORE the generator runs — a failure used to leave an empty preload the boot read as "nothing to warm". The assertion earned its keep on its first run, failing `jbk_kick: expected 508, got 254`. Exactly 2x, and the tool was wrong, not the rig: that folder symlinks into the rhadamanthe pack, which carries an AppleDouble `._X.wav` beside every real `X.wav`. `glob('*.wav')` matches both; SuperDirt's `pathMatch("*")` skips dotfiles; `ls` hides them so the folder looks clean. Counting now skips dotfiles. VALIDATION — instrumental, no ears (PLN at work), two timepoints, per the rule that short windows make a time-varying fault look static: BEFORE: the_revolution_will_be_sampled, d4/d5/d9/d10/d11/d12 all -inf at source. AFTER : all 11 declared orbits carry signal, and STILL carry it on a second probe ~95s later with no re-eval (levels stable to ~0.2 dB). no buffer data since the eval: 0 (was 19741 in 6h) Preload: 47/47 banks pass the count assertion, 2352 files, 2.2 s, 1.2 GB. Also fully green: perfect 12/12 orbits, gimme_acid 10/10, mafia_sans_serif 7/7, wap 5/5. Bare `s "like_sugar*4"` / `"the_revolution*2"` with no helpers: audible. STILL BROKEN, SEPARATELY: vague_de_crime is silent at source with ZERO buffer errors, and every one of its dead orbits routes through the ^NN helpers (gMute2/gM1/gM3/gF1/gF3). That is #61 — an untouched `^NN` evaluates to `silence`, not 0 — not this bug. Fixing the buffers removed the noise that was hiding it. Refs #26, #63. Surfaces #61. Note for #44: the readiness gate passes BEFORE the preload finishes, since "listening to Tidal" is printed by ~dirt.start well ahead of the warm-up.PLN (Algolia) authored -
Two entries, both written as documentary source rather than bookkeeping. #63 is the epidemic itself: `~dirt.doNotReadYet = true` makes SuperDirt read WAV HEADERS ONLY, so every bank registers with a correct file count and its buffers hold no audio. The methodological lesson is worth more than the bug — every measurement used a 4-8 second window, and at that timescale a TIME-VARYING failure looks STATIC. "These two sample banks are dead" was overwhelming and wrong, and six hypotheses got built on top of it. The decisive test was one command: same pattern, t+3s healthy, t+26s gone. Also recorded honestly: our own probe tool FABRICATED signal from contaminated taps and contradicted PLN's ears for an hour. Five orbits at an identical +0.0 dBFS peak was the fingerprint nobody read. Kept in the archive on purpose — a tool that invents evidence outranks the human in an argument it should always lose, and that is worth remembering out loud. #59 is the smaller, funnier one: "sound broke after a Window Reload, fixed by a Tidal reboot" was never a breakage. Lazy activationHooks left the package dormant, and `tidalcycles:reboot` was simply the first activationCommand that woke it. The workaround worked for a reason unrelated to the story told about it, which is exactly why nobody looked further for months.
PLN (Algolia) authored -
The tool invented signal, and the invented signal sent an entire investigation down the wrong road. A run on 2026-07-28 came back with five different orbits at an IDENTICAL +0.0 dBFS peak. Twelve independent orbits do not peak at exactly full scale together — that was one shared source (the mix) being measured five times, because the capture nodes had acquired PipeWire links nobody asked for. `--target 0` is supposed to stop pw-record auto-connecting to the default source, and usually does, but not reliably. The consequence was worse than a missing number. The contaminated table said d4 and d5 had "recovered after seeding", so that got reported as a result, while Ardour's own meters showed no input on those tracks at all. PLN was looking at the truth and being told otherwise. A measurement tool that fabricates signal is not a tool — it is a confident liar, and it outranks the human's ears in an argument it should always lose. Two changes: * link() now CUTS every pre-existing link into the capture node before making its own, so an auto-connection cannot ride along. * verify() re-reads the graph AFTER the capture window and asserts the node was fed by exactly the two ports we intended. Checked after, not before, because an auto-connection can appear at any point while the node is alive — a setup -time check would still let contamination through. Rows that fail verification print "UNRELIABLE — ignore this row" INSTEAD of a diagnosis, plus a summary naming what each bad tap was really linked to. Refusing to answer is the correct behaviour here; a plausible wrong number costs hours. Validated by re-running against the live rig: the clean table (d1/d2/d3/d7 with signal, d4/d5/d9-d12 silent at source) matched what PLN could hear, where the contaminated one had contradicted him. Confirmed on the way that the SC->Ardour link mapping is exact (out_(2N-1)/out_(2N) -> ardour:Tidal NN for all 12), so the disagreement was never routing.PLN (Algolia) authored -
Companion to the `remote` channel just added to pulsar-tidalcycles. With this, the whole cold-start-and-measure sequence runs unattended: tools/tidal-remote.py boot live/midi/nova/jazz/the_revolution_will_be_sampled.tidal tools/probe-chain.py -s 6 `boot` encodes the one ordering that actually works, which was learned the expensive way: reboot -> wait for BootTidal.hs -> seed the surface -> eval Every step of that order is load-bearing. Tidal's control map (`sStateMV`, where `^NN` values live) is state INSIDE the ghci process, so a reboot empties it — seeding before a reboot is wasted work. And evaluating before seeding is the mute-bomb from #61: an untouched `^NN` resolves to `silence`, not 0, so any stream referencing an unmoved control is completely silent with no error printed anywhere. Getting these three steps in the wrong order is precisely how an evening went into "d4/d5/d9-d12 make no sound". Two things it refuses to do: * It does not claim success. UDP sends land in the void whether or not anyone is listening, so `channel_open()` tests the port by trying to BIND it — if the bind succeeds, nobody was there, and the command fails loudly instead of printing a cheerful nothing. A remote that reports its own success is how you end up with a green check on a silent rig. * It does not infer readiness. Every path ends by telling the caller to go measure with probe-chain. The rig has taught us repeatedly that static self-reports agree with each other while the audio disagrees with all of them. OSC encoding is hand-rolled (~10 lines, all-string args) so this stays dependency-free — it has to run from a bare launch script, where a pip install is not part of the plan. Verified by round-tripping an encoded message back through a decoder: 64 bytes, 4-byte aligned, address and typetag and args all recovered. Also resolves track names loosely (absolute path, repo-relative, or bare name searched under live/ and copycat/), erroring on ambiguity rather than guessing — the corpus has genuine duplicate basenames and picking one silently would be worse than asking.PLN (Algolia) authored
-
- 27 Jul, 2026 7 commits
-
-
feat(rig): measure the chain, manage the server, seed the surface — three tools that end the silent-orbit era Tonight cost an evening to "d4/d5/d9-d12 make no sound", and the reason it cost an evening is that every layer was inspected by READING and none by MEASURING. PLN's correction — "this is a great tidal-ears case, observe the audio streams" — was right, and the measurement took six seconds. ## tools/probe-chain.py — where does the signal stop? Taps the live chain at three points at once and reports peak+rms dBFS per orbit: SuperCollider:out_N/N+1 the orbit as SuperDirt renders it ardour:Tidal NN/out after Ardour's fader/mute/plugins ardour:Master/out the mix Taps are ADDITIVE PipeWire links into `pw-record --target 0` capture streams, so nothing existing is unlinked and the audio path is untouched — safe mid-set. The `--target 0` is the trick that makes it possible to tap a mid-chain node instead of only a device. Orbit->port mapping is DERIVED, not hardcoded: `~dirt.start(57120, [0,2,4,...])` means orbit k writes bus 2k, and PipeWire exposes bus b as out_(b+1)/out_(b+2). First run settled in one shot what four sessions of hypothesising could not: d1 -28.6 rms d2 -42.5 d3 -37.4 d7 -21.5 d8 -29.8 <- audible d4 d5 d6 d9 d10 d11 d12 = -inf, absolute digital silence Exact match with what PLN could hear. **Ardour fully exonerated** — faders, mutes, routing, all of it. The signal never existed. Every hypothesis that had pointed downstream was wrong, and one reading killed them all. It reports peak AND rms deliberately: a lone peak can be one stray sample or a click, and judging an orbit "working" on peak alone is how you mistake a transient for audio. ## parvagues-sc.service — SuperCollider becomes visible The reason the real error was unreadable all evening: sclang's post window was a bare /dev/pts/3. Not readable by tooling, gone when the terminal closes, gone on a crash. The process was equally fragile — owned by a terminal, and killable by a stray probe (which is exactly what happened: a probe script of mine attached to the DEFAULT server, i.e. PLN's live one, and its `0.exit` sent `/quit`. `s` in sclang is the default server; changing the SuperDirt OSC port isolates nothing). As a systemd --user unit we get restart-by-command, survival across terminal close and sandbox teardown, one place for The Bridge to show status — and the post window in the JOURNAL, where it can be PARSED into readiness state instead of squinted at. Within a minute of it landing: like_sugar (22) the_revolution (36) diams_dj (16) — the "missing" banks were registered correctly all along, plus 106 real `File reading failed` warnings on `zz._`-prefixed files that had never once been visible. That is the #45 lead, handed over by a log we could finally read. `Restart=no` on purpose: a failing boot script under Restart=always would spam the MIDI graph and hide the failure. Audio gear should fail loudly, never flap. `QT_QPA_PLATFORM=offscreen` is load-bearing, not cosmetic — a Qt-linked sclang dies with the display stack, which is how an overnight suspend used to kill the music (d8346672). ## tools/lcxl-init.py — fix the mute-bomb at its actual root Verified against tidal-1.9.5 source rather than guessed: `"^51"` is `cF_ "51"` is `_cX_ getF "51"`, and `_cX_` returns **`silence`** on a missing key. `#` cannot emit without a right-hand value, so any stream touching an unmoved control is silent — and `dN` being `xfade N` means you hear the PREVIOUS pattern's 4-cycle tail dying, which reads as a filter closing. That illusion is what cost the evening. BootTidal.hs's two mitigations are both weaker than this one. `orDef` reaches only the ~13 g* helpers — never the ~1860 inline `^NN` refs across 343 track files, nor arithmetic sites like `midiOn ("^34" - "^18")` where touched-minus-untouched is itself empty. The `setF` seed block is correct in principle (same `sStateMV` the OSC `/ctrl` handler writes — traced in Stream.hs) but depends on boot order and on that ghci having actually run it. lcxl-init instead emits real CC messages into the identical path a physical knob takes: CC -> SC's `MIDIFunc.cc` -> `/ctrl` -> Tidal's map. By construction indistinguishable from PLN sweeping the whole surface by hand — the one thing already known to work. It cannot diverge from reality because it IS the reality path, and it fixes helpers, inline refs and arithmetic sites at once, whatever state ghci is in. PLN's framing: "most things that are templated lack an init value" — so give them one, from the hardware side. Encodes the corpus convention `range <neutral> <extreme>`, so 0 is neutral for everything except the three DJ filters, whose neutral is the centre detent (64/127 -> lpf 20000 / hpf 20 = true bypass). D-row faders (77-84) are deliberately NOT seeded: they are MIDI-learned to Ardour TRACK GAINS, so blasting values would move Ardour's faders while the physical faders stay put — a surface/mix desync worse than the problem. `^77` is already covered by `orDef 0.769`. Two bugs found and fixed by verifying rather than assuming: * port resolution used `aconnect -i` (READABLE ports) and so returned SC's `out0` — a source. Sending to it goes nowhere. It must be `-o`, the writable destinations. Resolved BY NAME on every call, never cached: the LCXL has already moved 20:0 -> 24:0 across a replug, and a binding resolved once at boot then silently invalidated is this rig's most expensive bug class. * the first verification attempt tapped the destination port with aseqdump and saw 0 messages — but you cannot SUBSCRIBE to a destination. Delivery was then proven properly through Midi Through (which echoes in to out): controller 49 value 64 and controller 50 value 64 observed arriving. The send was always fine; the observation was broken. Report which one you proved.PLN (Algolia) authored -
fix(rig): the yellow-LED loop was our own systemd service — and the "broken" orbits were closed Ardour faders Two independent silent failures, both found tonight, both invisible from the editor. Neither was in Tidal, which is where every hypothesis pointed. ## 1. The yellow-button loop: we were fighting ourselves Symptom, standing for weeks: paint an LCXL button a colour, press it, it snaps to yellow and wipes the colour. `aconnect -d 14:0 24:0` fixed it — and then it came back, every time, "after a Tidal reboot". We blamed SuperCollider's `MIDIIn.connectAll`, Ardour's control surface, WirePlumber. It was `~/.local/bin/midi-autoconnect.sh`, a service we wrote ourselves. Its rule 2 connected `Midi Through:0 -> Launch Control XL:0`, commented "LED/visual feedback", and re-asserted it every 2 seconds. That IS the loop: press -> LCXL emits Note On velocity 127 -> LCXL:0 -> Midi Through:0 -> (rule 2) -> LCXL:0 -> LCXL reads an inbound note velocity as an LED COLOUR -> 127 = 0x7F = full red + full green = YELLOW Not "reboot recreates it" — a 2-second reconcile loop was silently undoing the operator's fix within two seconds of every manual cut. The rule bought nothing: the HUD drives the LEDs directly by SysEx to the LCXL's own port. Rule 2 is now INVERTED — it disconnects instead of connecting, so the return leg is actively severed rather than merely not created (Ardour and SC can both recreate it independently; same shape as every other binding on this rig — one that must be re-asserted, never assumed). LCXL -> Midi Through -> Ardour still works, so click-to-map fader learn is unaffected. Rule 3 (Midi Through -> every aseqdump) is deleted too: aseqdump clients already subscribe to the LCXL directly, so the extra hop delivered every CC TWICE to the HUD — duplicated work on the renderer we are trying to keep off the audio thread's back (see the typing-crackle investigation). Verified: after `systemctl --user restart`, `Connecting To` on client 14 no longer lists 24:0, and stays that way across reconciles. ## 2. tools/check-mix.py — the mixer is part of the signal path An evening went into "d7 plays one bar then fades to nothing" and "d9-d12 make no sound, I see the meter move but nothing on master". We chased the untouched-control mute-bomb, the DJF helpers, orbit routing, a missing sample bank. Measured and eliminated, in order: PipeWire links (all 24 present, out_17->Tidal 09 ... out_23->Tidal 12), Ardour route outputs (all 12 active and connected to Master), the sample bank (like_sugar present, 22 files, `:21` valid, inside a loaded glob since April), and the helpers themselves (check-boot.sh pass 3: all 13 audible against an empty control map). Then: **Tidal 02, 06, 07, 08 and 12 have their faders at -inf dB.** d7 in bombe_dj is Tidal 07. d12 in the_revolution is Tidal 12. Exact match. Why it cost a whole evening rather than a glance: * Ardour meters are POST-fader by default, so a shut fader shows NO meter — the display agrees with the "Tidal is broken" theory instead of refuting it. * `dN` is `xfade N`, so a re-eval fades the OUTGOING pattern out over ~4 cycles. With the fader shut you hear the previous pattern dying: "plays a bar then slowly goes to zero", which reads exactly like a filter closing. * The faders are MIDI-learnable from the LCXL, so a physical fader left down persists into the saved session and outlives every reboot of everything. The tie-breaker: `d12` in the_revolution uses no `g*` helper and no `^NN` at all. Such a pattern cannot be silenced by anything inside Tidal — so its silence proved the fault was downstream, and the fader was the only downstream thing left unverified. A control case with no confounders beat four sessions of hypothesising. check-mix.py reads the session XML and flags, per Tidal route: fader at/near -inf, muted, or not routed to Master. Exit 1 so `gig-up.sh` can gate on it. It states its own limitation rather than implying more than it checks: the session is read from DISK, so a clean report means "boots healthy", not "the running mixer is healthy". Live verification needs Ardour's OSC surface, not yet wired.PLN (Algolia) authored -
PLN asked what tooling would have caught tonight's mute-bomb. Honest answer: nothing we had, and not tidal-ears — it analyses audio, and this bug produced SILENCE. Silence is indistinguishable from silence no matter how well measured. The defect was symbolic: a pattern emitting zero events. Typechecking could not catch it either. `gF1 $ sound "bd"` is perfectly well-typed while producing NO EVENTS, because an untouched "^NN" yields nothing and `#` cannot emit without a right-hand value. Pass 1 of this script was green throughout the entire outage. The missing capability was the ability to ask one question mechanically: "what does this helper do with an EMPTY control map?" — i.e. on a fresh boot, before any knob has moved. That is now pass 3: it extracts the helper block, appends assertions, compiles, and RUNS them, checking that each of gF1/gF2/gF3, gMask, gMute1-3, gM1-3, gPanic, midiGdef and a composed gF1.gM2 stack all emit events with `State (Arc 0 1) M.empty`. It is a real execution rather than a typecheck, but pure pattern evaluation only: no stream, no scsynth, no MIDI, no port binding. Safe to run mid-set. Verified in BOTH directions, which is the whole point: - current BootTidal.hs -> 13/13 audible, exit 0 - orDef guards stripped out (i.e. the pre-3685c743 code) -> 11 FAILs naming each silent helper, exit 1 That negative test also confirmed something useful about the shape of the bug: gPanic and midiGdef stayed audible even with the guards stripped, because they route through `range`/arithmetic that survives an empty map differently from `#`. So a partial fix would have looked partially fine — exactly the kind of half-green result that lets a bug like this hide for months. Turns the ad-hoc Haskell probe written during the incident into a permanent regression test. This bug class has now bitten three times (#21 djfbus, #48's one-directional filters, #55's untouched-control silence); pass 3 is the first thing that would have caught any of them before a boot.PLN (Algolia) authored -
The bug behind "no sound on d5 in perfect.tidal, meter moves, master silent, plays a bar then slowly fades to zero, then crackles". Also, finally, the true root of #21 — which was diagnosed as a missing `djfbus` and fixed as "safe at rest (0)", when the actual defect was one layer deeper. FINDING, proven by queryArc against an empty controls map (not by ear): gF1 $ sound "bd", knob never touched -> SILENT (no events) gMask untouched -> SILENT gMute3 untouched -> SILENT gain ("^77" * 1.3) untouched -> SILENT gPanic untouched -> SILENT A bare `"^NN"` whose CC has never arrived does NOT default to 0 — it yields NO EVENTS AT ALL. And `#` (i.e. `|>`) cannot emit without a value on its right-hand side, so the entire stream disappears. Every g*-wrapped stream in the corpus is therefore silent until that physical control is moved once. This is why "move all the controls" was always step one of a gig, and why PLN's rig felt fine for months: the habit was masking a mute-bomb. Today's BootTidal restart (to pick up the 77903244 DJF fix) wiped the control map and re-armed it across the whole surface at once — so the fix appeared to make things worse. It also explains the odd "plays one bar then slowly fades to zero": `dN` is `xfade N`, which crossfades the outgoing pattern out over 4 cycles while fading the new one in. The new one had no events, so what PLN heard was the OLD pattern dying gracefully into nothing — meter moving throughout, master quiet once the fade completed. The symptom looked like a filter closing; it was a crossfade to silence. TWO-LAYER FIX, because one layer cannot reach everything: 1. `orDef` in the helper let-block — substitutes a default when a control pattern is empty. Applied to gDJF (0.5 = centre = true bypass), midiOn/midiOff/midiNo/midiBoth (0/1/1/0 = "not pressed"), midiGGlobal (0.769, so *1.3 == unity rather than 0), and gPanic (0 = normal, not silent). It must live at the HELPER level, not per call site, because tracks combine controls arithmetically — `midiOn ("^34" - "^18")` in perfect.tidal:63 — and `touched - untouched` is itself empty, so a per-CC default could never catch it. 2. Boot-time control seeding via `setF` — the corpus-wide half. `orDef` cannot reach the 1860 sites across 343 track files that apply controls directly (`# crushbus 41 (range 16 3.5 "^53")`). Editing those is #46-scale work. Seeding every LCXL control at boot fixes all of them at once while touching no track file; a real CC overwrites its seed the instant the knob moves. Seed policy follows the authoring convention `range <neutral> <extreme>`, so 0 == neutral: A/B knobs 0, C1-C3 0.5 (DJF centre), C4-C8 0, fader 77 0.769 (unity), faders 78-84 1.0 (gains open = audible), all buttons 0. Seed coverage was verified by set-difference, not assumed: 49 seeded vs 54 distinct "^NN" in live/. The 5 stragglers were each resolved rather than waved off — ^45 needs no seed (it flows through midiOn, so orDef covers it), ^6 and ^61 are commented out at their only call sites, and ^1 (an external keyboard modwheel) and ^21 were added explicitly. Verification of the fix, all four knob positions, via an explicit controls map — untouched now bypasses and NO touched behaviour changed: untouched -> cutoff=20000 hcutoff=20 (was: SILENT) at 0.0 -> cutoff=180 hcutoff=20 (low-pass to subbass) at 0.5 -> cutoff=20000 hcutoff=20 (bypass) at 1.0 -> cutoff=20000 hcutoff=8000 (high-pass to superhigh) gMute3 pressed -> SILENT (mute still mutes) gPanic armed -> gain 0.0 (panic still panics) tools/check-boot.sh gains a second pass over the seed block (pinning the `streamSetF :: Stream -> String -> Pattern Double -> IO ()` signature it calls through), so neither half of this can rot silently. Its own first attempt at that pass was itself broken — a sed that produced `_seed = _seed = concat` — and the checker caught it. Same lesson as 77903244: run the guard, don't trust that you wrote it correctly. prelude.tidal (the stale-boot rescue sheet) carries the matching orDef form plus a ready-to-eval `_seedNow` list, for the case where a gig laptop boots silent and the cheapest fix is one paste rather than sweeping 40 controls.PLN (Algolia) authored -
Mid-rehearsal, PLN reported the three DJ filters still misbehaving after yesterday's #48 bidirectional rewrite. He described three symptoms: C1 centre->0 : no LPF effect on d3 (hats) C1 centre->0 : kick goes from TOTALLY high-passed to normal C1 centre->127: d3 high-passes Those fingerprint the *previous* (2026-07-25) definition exactly — `gHPF ch = (# hpf (range 20 8000 ch))`, one-directional, no lpf term: at centre hpf=4010Hz (kick gutted), at 0 hpf=20Hz (normal), at max hpf=8000Hz. Four observations, four matches. So the running ghci was not executing the new code. The question was why. Root cause: the new gDJF was written as a right section containing another operator of the same fixity — gDJF ch = (# lpf (...) # hpf (...)) -- `#` is infixl 9 which is a hard Haskell parse error, not a subtle type slip. GHC: "The operator '#' of a section must have lower precedence than that of the operand". One bad line takes down the entire enclosing `let` block, i.e. EVERY g* helper — gF1/2/3, gMask, gMute*, gPanic, the drum helpers. What made it dangerous rather than merely broken: nothing surfaced it. The live ghci still held the older definitions from before the edit, so the rig kept making sound and behaving plausibly while the file on disk was unloadable. The failure was scheduled for the next cold boot — i.e. on stage at OPAL. Same shape as #21's mute-bomb: a definition that lives only in a running process is not a verified definition. Fix: express it as two composed sections, which is also semantically cleaner (two independent unions rather than one nested): gDJF ch = (# lpf (...)) . (# hpf (...)) The filter math itself was correct and is unchanged — verified at three points: v=0 -> lpf 180 / hpf 20 (low-pass closed to subbass), v=0.5 -> lpf 20000 / hpf 20 (both wide open = true bypass at the centre detent), v=1 -> lpf 20000 / hpf 8000 (high-pass climbed to superhigh). Applied to both BootTidal.hs and the live/lib/prelude.tidal rescue sheet, which had inherited the identical broken form. Also adds tools/check-boot.sh so this class of bug cannot ship again: it extracts the helper let-block, dedents it into a real module, stubs the two stream-bound names it closes over (p, setI), and runs `ghc -fno-code`. Types only — no codegen, no stream, no port 6010 grab, safe to run mid-set. Validation, both directions: - real BootTidal.hs -> OK, exit 0 (whole 164-431 block typechecks against tidal-1.9.5 / ghc 9.4.7) - bug reintroduced -> FAIL, exit 1, pointing at the exact line That negative test earned its keep immediately: the first version of the guard used `ghc | grep -q` under `set -o pipefail`, where a failing ghc makes the pipeline non-zero regardless of grep's verdict, silently inverting the check. It reported OK on a file it had just proven broken. Now it runs ghc once and tests the captured exit code. A guard that has never seen the bug it targets is a hypothesis, not a guard.PLN (Algolia) authored -
PLN (Algolia) authored
-
Rehearsal/play session on the UMC rig (SOF->UMC migration validated end to end tonight): electric_hammer, bombe_dj, ete_a_mauerpark, vague_de_crime, gimme_acid, take_5_drops tuned; raph collab tracks (desire, mafia_sans_serif, piment_bresilien) touched; backlog.md and armada calibration catalogs regenerated alongside. Committing as a clean checkpoint before starting the d1-d12 channel harmonization + LCXL fader remap (task #46) and the sample/synth reference cleanup (task #45), so those land as reviewable diffs against a known-good base rather than mixed in with tonight's live tuning.
PLN (Algolia) authored
-