Commit 90f20cf7 by PLN (Algolia)

feat(rig): the boot now LIGHTS the controller — gig-up and tidal-remote both paint it

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.
parent 708c2aef
...@@ -22,4 +22,5 @@ __pycache__/ ...@@ -22,4 +22,5 @@ __pycache__/
# Generated at launch by gig-up.sh (set-specific sample preload + sclang log) # Generated at launch by gig-up.sh (set-specific sample preload + sclang log)
preload.scd preload.scd
gig-sclang.log gig-sclang.log
gig-leds.log
recordings/ recordings/
---
log: 017
title: "The surface lights up again in our colours, and it stays"
date: 2026-07-28
task: "#67 (+#49/#51/#11)"
tags: [tooling, rig, controller, opal2026]
shareable: true
---
## Cap (what & why)
The LaunchControl XL had gone dark. Not miscoloured — **dark**, with exactly one knob
(A1) green from a hand-sent test SysEx. PLN asked for it back three times, and the
ask decoded into three separate deliverables: *any* feedback at boot, **our** colour
coding rather than factory yellow, and colours that react to touch **and persist
after you let go**. J-7 to OPAL. A controller you can't read is a controller you
can't play.
## Manœuvre (how)
Built `tools/lcxl-leds.py` — python3 stdlib only, standalone, no editor required.
- `--map [TRACK.tidal]` paints by **role**, derived from the `^NN` bindings the file
actually contains. Unbound controls go **dark** — that is the whole cognitive win:
the board becomes a map of the file in front of you. With no track it paints the
channel **convention**, so a boot can never end on a dark surface.
- `--watch` is the touch-reactive daemon. Persistence isn't a feature bolted on: the
daemon owns a model of every control, and colour is a *pure function* of that
model, so any repaint reproduces the same board. The LEDs are **write-only, there
is no readback**, so owning the state is the only way.
- `--test` walks every index (a dead LED is only visible in motion), `--all`/`--off`,
and `--dry-run` prints hex and sends nothing.
- Never sends a CC. Ever. SysEx out only; input is read-only via `aseqdump` as a
child, which does not steal MIDI from SuperDirt.
Wired into `tools/tidal-remote.py` (now a 5-step boot: reboot → wait → seed → **paint**
→ eval) and into `gig-up.sh` (paints as soon as the rig owns MIDI, before Ardour and
Pulsar exist; `GIG_LEDS=watch` also starts the daemon).
## Prise (findings / artifacts)
- `tools/lcxl-leds.py`, ~700 lines, zero dependencies.
- Role parsing on three real tracks: `vague_de_crime` 15 controls (rhythm 9, fx 6),
`gimme_acid` 21 (rhythm/bass/fx), `perfect` 25 (rhythm/bass/lead/fx).
- A 40-LED full repaint is **one** SysEx write of 89 bytes.
- `--watch` seam proven with a fake `aseqdump` on PATH: 9/9 synthetic events
decoded → coloured → sent, plus respawn-with-backoff when the child exited.
## Sel (the shareable learning)
Three traps, each one a general lesson:
1. **The tool that works on a cold rig can fail on the live one.** `amidi -p hw:2,0,0`
lit knob A1 by hand — but only because nothing held the raw device. With
SuperCollider up, the ALSA *sequencer* layer owns the rawmidi substream and amidi
dies with *"Device or resource busy"*. The fix was a different transport
(`aseqsend` to the sequencer port), and the lesson is that a hardware test on an
idle rig proves nothing about the rig you actually perform on.
2. **Stripping comments can change the grammar.** A blank line is Tidal's block
separator, so splitting on blank lines to attribute `^NN` to a `dN` is right — but
stripping full-line `--` comments *first* manufactures blank lines that cut a
block in half. It didn't crash; it quietly dumped 9/9 bindings into the "fx"
fallback. A parser miss masqueraded as a boring result.
3. **Verify the seam, not the function.** The first `--watch` regexes assumed
space-separated numbers. `aseqdump` actually prints
`Control change %2d, controller %d, value %d`. The daemon would have run clean,
logged nothing and painted nothing — the perfect silent failure. Pulling the
format strings out of the binary beat guessing.
And one design note: the periodic re-assert runs on its **own thread**. Inside the
read loop it would only fire when an event arrived — i.e. never during the silences
when a device hiccup would actually go unnoticed.
## Hameçon (hook)
"My MIDI controller went dark a week before the gig. Fixing it taught me that the
test which proves your hardware works can be the exact test that lies to you."
## Sillage (what it unlocks)
A scriptable painter that works with Pulsar closed retires the HUD as the *only*
LED path (#57 can now be fixed calmly instead of urgently), and gives the per-track
visual language something to build on: value ramps, armed states, transition cues.
...@@ -161,6 +161,34 @@ while :; do ...@@ -161,6 +161,34 @@ while :; do
sleep 1 sleep 1
done done
# 2b) LIGHT THE SURFACE. The rig owns MIDI now, so paint the LCXL immediately —
# before Ardour and Pulsar even start. Previously the only painter lived in the
# Pulsar HUD, so the board stayed DARK until the editor was up and its frame
# pipeline healthy (#57 latches an early empty frame), and a dark board reads as
# broken hardware. The no-track paint is the channel CONVENTION, so there is always
# something to look at; tidal-remote repaints per-track on each boot.
#
# GIG_LEDS=watch also starts the touch-reactive daemon, which keeps a model of every
# control and repaints from it — that is what makes the colours PERSIST after you let
# go of a knob. It is SysEx-out only and reads input read-only via aseqdump, so it
# never steals MIDI from SuperDirt and never sends a CC at anything.
leds(){
[ "${GIG_LEDS:-paint}" = off ] && { info "leds: disabled (GIG_LEDS=off)."; return; }
if ! "$DIR/tools/lcxl-leds.py" --map -q 2>/tmp/gig-leds.err; then
warn "leds: paint failed — $(head -2 /tmp/gig-leds.err | tr '\n' ' ')"
warn "leds: if input works but the board is dark, that's a USB OUT stall — REPLUG the LCXL."
return
fi
ok "leds: surface painted (convention colours). LOOK AT IT — a clean exit is not a lit LED."
if [ "${GIG_LEDS:-paint}" = watch ]; then
setsid "$DIR/tools/lcxl-leds.py" --watch -q >"$DIR/gig-leds.log" 2>&1 &
ok "leds: --watch daemon started (touch-reactive + persistent; log → gig-leds.log)."
else
info "leds: run 'tools/lcxl-leds.py --watch <track>' (or GIG_LEDS=watch) for touch-reactive colours."
fi
}
leds
# 3) Ardour — now safe to open (SuperDirt already holds the controller) # 3) Ardour — now safe to open (SuperDirt already holds the controller)
if ardour_up; then if ardour_up; then
ok "Ardour already running — skipping." ok "Ardour already running — skipping."
......
...@@ -18,7 +18,7 @@ The reboot ordering that matters ...@@ -18,7 +18,7 @@ The reboot ordering that matters
Tidal's control map (`sStateMV`, where `^NN` values live) is state INSIDE the Tidal's control map (`sStateMV`, where `^NN` values live) is state INSIDE the
ghci process. A reboot empties it. So the correct order is always: ghci process. A reboot empties it. So the correct order is always:
reboot -> wait for BootTidal -> seed the surface -> eval reboot -> wait for BootTidal -> seed the surface -> paint the LEDs -> eval
Seeding before a reboot is wasted work, and evaluating before seeding is the Seeding before a reboot is wasted work, and evaluating before seeding is the
mute-bomb (an untouched `^NN` is `silence`, not 0 — see #61). `boot` does the mute-bomb (an untouched `^NN` is `silence`, not 0 — see #61). `boot` does the
...@@ -132,13 +132,13 @@ def cmd_boot(track: str) -> int: ...@@ -132,13 +132,13 @@ def cmd_boot(track: str) -> int:
"""reboot -> wait -> seed -> eval, in the one order that works.""" """reboot -> wait -> seed -> eval, in the one order that works."""
path = resolve_track(track) path = resolve_track(track)
print("tidal-remote: [1/4] rebooting the interpreter (empties the control map)") print("tidal-remote: [1/5] rebooting the interpreter (empties the control map)")
send({"cmd": "reboot"}, quiet=True) send({"cmd": "reboot"}, quiet=True)
print(f"tidal-remote: [2/4] waiting {BOOT_WAIT:g}s for BootTidal.hs to load") print(f"tidal-remote: [2/5] waiting {BOOT_WAIT:g}s for BootTidal.hs to load")
time.sleep(BOOT_WAIT) time.sleep(BOOT_WAIT)
print("tidal-remote: [3/4] seeding the LCXL surface (lcxl-init)") print("tidal-remote: [3/5] seeding the LCXL surface (lcxl-init)")
seed = subprocess.run([str(REPO / "tools" / "lcxl-init.py"), "-q"], seed = subprocess.run([str(REPO / "tools" / "lcxl-init.py"), "-q"],
capture_output=True, text=True) capture_output=True, text=True)
if seed.returncode != 0: if seed.returncode != 0:
...@@ -149,7 +149,23 @@ def cmd_boot(track: str) -> int: ...@@ -149,7 +149,23 @@ def cmd_boot(track: str) -> int:
else: else:
print(" seeded") print(" seeded")
print(f"tidal-remote: [4/4] eval {Path(path).name}") # Paint the surface right after seeding, and paint it FOR THIS TRACK. The seed
# gives every `^NN` a value; this makes that state visible. Doing it here rather
# than leaving it to the Pulsar HUD is deliberate: the HUD only paints when the
# editor is open and its frame pipeline is healthy (#57), so a boot could
# complete onto a completely dark board with no error anywhere.
print("tidal-remote: [4/5] painting the LCXL for this track (lcxl-leds --map)")
paint = subprocess.run([str(REPO / "tools" / "lcxl-leds.py"), "--map", path, "-q"],
capture_output=True, text=True)
if paint.returncode != 0:
# Non-fatal by design: LEDs are feedback, not sound. A dark board is
# annoying; a boot that aborts because of a dark board is a lost gig.
print(f"tidal-remote: WARN — LED paint failed:\n{paint.stderr.strip()}",
file=sys.stderr)
else:
print(" painted (verify by EYE — a clean exit is not a lit LED)")
print(f"tidal-remote: [5/5] eval {Path(path).name}")
send({"cmd": "eval-file", "path": path}, quiet=True) send({"cmd": "eval-file", "path": path}, quiet=True)
print("\n Now MEASURE it — do not trust that this printed without errors:\n" print("\n Now MEASURE it — do not trust that this printed without errors:\n"
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment