Commit 1459ef02 by PLN (Algolia)

feat(lcxl): drive the LEDs over SysEx — the Note-On path was never going to work

The LED work (#11) had been stuck for two days on "some nonzero value lights it",
with no reliable palette and buttons that refused to light at all. Today's live
session found out why, and it was not a bug in our code.

THE ROOT CAUSE. The Launchpad Note-On protocol only lights a control if the
CURRENTLY SELECTED template maps that exact note/CC on that exact MIDI channel.
On User 1 the LCXL's buttons are CC-mapped, so every Note-On we sent to notes
73/74/91/92 had no matching target and was silently discarded. The knobs are
note-mapped, which is why they lit and the buttons never did — a split that
looked like flaky hardware and was actually the protocol working as documented.

THE FIX. The SysEx "Set LEDs" message updates any control regardless of its
note/CC value or MIDI channel:

  F0 00 20 29 02 11 78 <template> <index> <value> [<index> <value> ...] F7

Verified on hardware: top button row went red and bottom row green on the first
try, after Note-On had failed on those same buttons all morning. Indices are
contiguous (00-07/08-0F/10-17 knob rows, 18-1F and 20-27 button rows, 28-2B
side, 2C-2F arrows), which is far easier to generate role-family colours over
than the scattered note numbers.

PALETTE, MEASURED NOT GUESSED. colour = (16 * green) + red + flags, each channel
0-3, flags 12 normal / 8 flashing. Confirmed by lighting the top knob row with
the full ramp and having PLN read the colours back left to right: off, red low,
red full, green low, green full, amber low, amber full, yellow — matching the
Programmer's Reference table exactly. Also ran the built-in brightness tests
(B0 00 7F / 7D): all-orange then all-dim-yellow, which proves the unit is NOT in
low-power mode and that dim-vs-full is simply hard to read by eye. Conclusion for
the design: use FLASH, not brightness, when a state must be unmissable.

STALE-BINDING FIX. ~lcxlOut was resolved once at boot. A replug moves the ALSA
port (we watched it go 20:0 -> 24:0) and the cached MIDIOut then points at a
device that no longer exists — LEDs die silently while input keeps working. It
now re-resolves lazily on every write, and drops the cache on error so the next
write retries. Added ~lcxlReconnect for explicit post-replug recovery, kept
manual on purpose: MIDIClient.init re-scans the whole MIDI world and is not
something to fire mid-set on a guess.

This is the fourth instance today of one failure shape: a device binding resolved
once at startup, invalidated by a device event, failing with NO error. The others
were Ardour's master port (twice) and the HUD's aseqdump sitting alive on a dead
port. Tracked in #41.

Validation: delimiter balance checked; full compile is proven at the next SC boot,
not before — deliberately not restarting SuperCollider mid-rehearsal.
parent 1b81e5ea
...@@ -69,15 +69,95 @@ if (~stopMidiToOsc != nil, { ...@@ -69,15 +69,95 @@ if (~stopMidiToOsc != nil, {
// LCXL LED write validated via plain Note-On (see reference_lcxl_led_stall // LCXL LED write validated via plain Note-On (see reference_lcxl_led_stall
// memory: `aseqsend -p 20:0 90 29 3C ...` lights a button LED). Wrapped in // memory: `aseqsend -p 20:0 90 29 3C ...` lights a button LED). Wrapped in
// try{} so a missing/renamed device never blocks the boot. // try{} so a missing/renamed device never blocks the boot.
~lcxlOut = try { // --- LCXL LED driver (SysEx). Validated on hardware 2026-07-27.
var dest = MIDIClient.destinations.detect({ |d| d.device.asString.containsi("Launch Control") }); //
if (dest.notNil, { MIDIOut.newByName(dest.device, dest.name) }, { nil }); // WHY SYSEX AND NOT NOTE-ON: the Launchpad protocol only lights a control if
// the CURRENT template maps that exact note/CC on that exact MIDI channel. Our
// buttons are CC-mapped on User 1, so Note-On writes were silently dropped —
// the knobs lit, the buttons never did, and nothing reported an error. The
// SysEx "Set LEDs" message updates any control regardless of its note/CC value
// or channel, so it cannot be broken by how the template happens to be laid
// out. (Programmer's Reference, "Launch Control XL System Exclusive Protocol".)
//
// F0 00 20 29 02 11 78 <template> <index> <value> [<index> <value> ...] F7
//
// Template 00h-07h = User 1-8. We are always on User 1 = 0.
~lcxlTemplate = 0;
// Control index map (contiguous, unlike the scattered note numbers):
// 00-07 top knob row | 08-0F mid knob row | 10-17 bottom knob row
// 18-1F top button row | 20-27 bottom button row
// 28-2B Device/Mute/Solo/RecordArm | 2C-2F Up/Down/Left/Right
~lcxlIdx = (
knobA: (0..7), knobB: (8..15), knobC: (16..23),
btnTop: (24..31), btnBot: (32..39),
side: (40..43), arrows: (44..47)
);
// Colour = (16 * green) + red + flags, green/red each 0-3.
// flags 12 = normal, 8 = flashing. Brightness reads poorly on this hardware —
// prefer FLASH over dim/full when a state must be unmissable.
~lcxlCol = (
off: 12,
redLo: 13, red: 15,
amberLo: 29, amber: 63,
yellow: 62,
greenLo: 28, green: 60,
redFlash: 11, amberFlash: 59, yellowFlash: 58, greenFlash: 56
);
// Resolve the MIDIOut LAZILY, every call. Caching it at boot is exactly how
// this broke: a replug moves the ALSA port (20:0 -> 24:0) and the cached
// MIDIOut points at a device that no longer exists, so LEDs die silently while
// input still works. Cheap enough to re-detect per write.
~lcxlFindOut = {
try {
var dest = MIDIClient.destinations.detect({ |d|
d.device.asString.containsi("Launch Control")
});
if (dest.notNil, { MIDIOut.newByName(dest.device, dest.name) }, { nil });
} { nil };
};
~lcxlOut = ~lcxlFindOut.value;
// Explicit recovery after a replug. NOT automatic: MIDIClient.init re-scans the
// whole MIDI world and can disturb live input bindings, which is not something
// to do mid-set on a guess. Call it by hand when the LEDs have gone dead.
~lcxlReconnect = {
MIDIClient.init;
MIDIIn.connectAll;
~lcxlOut = ~lcxlFindOut.value;
"lcxl: reconnected -> ".post; ~lcxlOut.postln;
}; };
// Set one or many LEDs in a single message. pairs = [index, value, index, ...]
~lcxlLedRaw = { |pairs|
var out = ~lcxlOut ?? { ~lcxlOut = ~lcxlFindOut.value };
if (out.notNil, {
try {
out.sysex(Int8Array.newFrom(
[0xF0, 0x00, 0x20, 0x29, 0x02, 0x11, 0x78, ~lcxlTemplate]
++ pairs ++ [0xF7]
));
} { ~lcxlOut = nil }; // force re-resolve on the next write
});
};
~lcxlLedSet = { |index, colour| ~lcxlLedRaw.value([index, colour]) };
// Turn every LED off and reset buffers/duty-cycle to defaults, so we always
// paint from a known state instead of inheriting the last session's mess.
~lcxlReset = { var o = ~lcxlOut ?? { ~lcxlFindOut.value }; if (o.notNil, { o.control(0, 0, 0) }) };
~lcxlChordBtns = [73, 74, 91, 92]; ~lcxlChordBtns = [73, 74, 91, 92];
// Same four buttons as SysEx indices on the bottom button row (note 73 -> 20h).
~lcxlChordIdx = [0x20, 0x21, 0x26, 0x27];
~lcxlChordHeld = Set.new; ~lcxlChordHeld = Set.new;
~lcxlPanicState = 0; ~lcxlPanicState = 0;
~lcxlLed = { |btnNum, on| ~lcxlLed = { |btnNum, on|
if (~lcxlOut.notNil, { ~lcxlOut.noteOn(0, btnNum, if(on, 60, 0)) }); var i = ~lcxlChordBtns.indexOf(btnNum);
if (i.notNil, {
~lcxlLedSet.value(~lcxlChordIdx[i], if(on, ~lcxlCol[\redFlash], ~lcxlCol[\off]));
});
}; };
~lcxlChordCheck = { |num, val| ~lcxlChordCheck = { |num, val|
if (~lcxlChordBtns.includes(num), { if (~lcxlChordBtns.includes(num), {
......
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