Commit 72937cc7 by PLN (Algolia)

lcxl: the panel could not see the daemon that lights the board

PLN, the night before a gig: "i dont see midi feedback visuel anymore on the
LCXL fix this first plz". The hardware was fine. The daemon was gone.

The journal had the whole story and nobody was reading it:

    lcxl-leds --watch: no LCXL sequencer port; retrying in 30s   (x8)
    Stopping LCXL LED watcher...
    Stopped LCXL LED watcher.

The board was unplugged, the watcher retried, then it was STOPPED — and
`Restart=always` does not resurrect a unit somebody stopped. On replug the
board came back and the daemon did not. Three independent holes let that
become a silent, session-long dark surface:

1. `lcxl-leds-watch` was MISSING from rig.py's SERVICES table, so the Bridge
   panel showed a fully green rig over a dark board. That is precisely the
   failure the module's own docstring opens with ("A GREEN UNIT IS NOT SOUND"),
   one table row away. It was also in neither gig-up.sh nor converge: NOTHING
   on this box checked whether the surface was lit.
2. The unit was `linked`, not `enabled` — it never started at login. It only
   ever ran because something started it by hand.
3. `midi-autoconnect` was `disabled` too. The wiring it enforces
   (LCXL 20:0 -> Midi Through 14:0 -> SuperCollider in2) happened to be intact,
   so knobs still worked and the fault stayed invisible. But the prescribed fix
   for the LED stall IS a replug, and a replug drops those connections with
   nothing to re-apply them. The two failures compound: the remedy for one
   silently triggers the other, mid-set.

Fixed: both units enabled, and the watcher is on the panel with a state that
`active` cannot express. It retries forever by design, so "alive" is not "lit" —
_leds_state() cross-checks the board via procfs and reports the third case
honestly. No board is ABSENT, not broken; board present with the daemon down is
"plugged in but DARK, start this", the one combination that was silently wrong.
All five services now read green for a reason each, not by omission.

Also, the daylight ramp. PLN: "maybe brightness can be leveraged, top
brightness always would make more readable signals even in day perfs." The
six-step unipolar ramp spent THREE steps on dim shades — exactly the budget
that vanishes outdoors. The replacement keeps five steps and puts every one at
full brightness by using the two mixed hues the old ramp never touched:

    red 15 (g0,r3) -> orange 31 (g1,r3) -> amber 63 (g3,r3)
                   -> yellow 62 (g3,r2) -> green 60 (g3,r0)

Every value has a component at maximum, so nothing depends on a brightness
difference to be legible. It costs one step and gains three readable ones —
the same trade he already made for the DJ filters in July ("i agree on clarity
> resolution"), which have been all-full-brightness ever since. This just
brings the unipolar knobs in line. LCXL_DIM_RAMP=1 restores the old ramp, so
a dark-stage revert is one env var and a restart, not a code change.

And the gear's MIDI monitor: "super noisy ... cant we do way more dense".
"Control change" spent 14 characters saying "CC", and a single fader sweep
prepended ~100 near-identical rows. Events now abbreviate (CC/ON/OFF/PB/...)
and consecutive events from the same control coalesce into ONE row that
updates in place with a x-count — a 100-event sweep is one line, one DOM
write per event, no node churn. Row height 20px -> 15px on top of that.

Verified: 79/79 bridge tests pass; /api/rig reports all five services up with
per-service proof; ramp asserted to have max(g,r)==3 at every step.
parent b893a06d
......@@ -41,6 +41,13 @@ SERVICES = [
("parvagues-sc-watchdog", "sc-watchdog", None, "restarts SuperDirt if it dies"),
("tidal-ardour-autoroute","autoroute", None, "orbits → Ardour tracks"),
("midi-autoconnect", "MIDI wiring", None, "LCXL ↔ Tidal, re-applied"),
# 2026-08-21: this unit was MISSING from the table, so the panel showed a
# fully green rig while the LCXL sat dark for a whole session — the exact
# failure this module's own docstring warns about, one row away. It had been
# stopped (not crashed) with the board unplugged, and `Restart=always` does
# NOT bring back a unit somebody stopped. Nothing else on the box checks it:
# not gig-up.sh, not converge. Now it is on the panel and `enable`d.
("lcxl-leds-watch", "LCXL LEDs", None, "the surface paint / MIDI feedback"),
]
# Apps converge refuses to launch, which is exactly what the button adds.
......@@ -108,6 +115,28 @@ def _lcxl():
return "Launch Control XL" in cards or "LCXL" in cards
def _leds_state(state, detail):
"""The LED watcher has a THIRD state that `active` cannot express.
It retries forever by design (the board is hot-pluggable and usually absent
at boot), so `active` means "the daemon is alive", NOT "the surface is lit".
On 2026-08-21 it logged `no LCXL sequencer port; retrying in 30s` eight times
and a unit-only check would have called that green.
So cross-check against the board itself, and keep the module's law: report
ABSENT as absent. No board is not a fault — it is a missing board.
"""
board = _lcxl()
if not board:
if state == "up":
return "absent", "no board — watcher up, waiting for a replug"
return "absent", "no board plugged in"
if state == "up":
return "up", "painting the surface"
# Board present, daemon down: the one combination that is silently wrong.
return "down", f"{detail} — board is plugged in but DARK, start this"
def status():
"""Every piece of the rig, with the state the reader actually needs."""
lines = L._cmdlines() # one /proc scan for the whole snapshot
......@@ -135,6 +164,8 @@ def status():
else:
state = "up" if active == "active" else "down"
detail = active
if unit == "lcxl-leds-watch":
state, detail = _leds_state(state, detail)
items.append({"key": unit, "name": label, "blurb": blurb,
"state": state, "detail": detail, "kind": "service"})
......
......@@ -113,8 +113,10 @@
.midi-ctl .live.on{color:var(--cool)}
#midiLog{background:var(--raised);border:1px solid var(--hairline);border-radius:12px;
height:300px;overflow-y:auto;padding:6px}
.mev{display:grid;grid-template-columns:48px 96px 22px 1fr 64px;gap:9px;align-items:center;
padding:3px 8px;border-radius:5px;font-family:var(--mono);font-size:12px}
.mev{display:grid;grid-template-columns:34px 30px 16px 30px 1fr 30px 30px;gap:7px;
align-items:center;padding:1px 7px;border-radius:4px;font-family:var(--mono);font-size:11px}
.mev .num{color:var(--ink);text-align:right}
.mev .cnt{color:var(--faint);text-align:right;font-size:10px}
.mev:nth-child(odd){background:#ffffff06}
.mev .src{color:var(--faint)} .mev .ev{font-weight:600;color:var(--mute)}
.mev .ch{color:var(--faint);text-align:right} .mev .nn{color:var(--ink)}
......@@ -363,22 +365,58 @@ async function loadMidiPorts(){
: `<option value="">no MIDI ports</option>`;
if(cur)[...sel.options].forEach(o=>{if(o.value===cur)sel.value=cur;});
}
// "Control change" spends 14 characters saying what "CC" says. A live monitor is
// read at a glance mid-set, so every column earns its width or goes.
const MIDI_ABBR=[[/^control/i,"CC"],[/^note on/i,"ON"],[/^note off/i,"OFF"],
[/^pitch/i,"PB"],[/^program/i,"PGM"],[/^.*aftertouch/i,"AT"],
[/^channel/i,"CH"],[/^sysex/i,"SYX"],[/^clock/i,"CLK"]];
function midiLabel(e){
for(const [re,ab] of MIDI_ABBR) if(re.test(e)) return ab;
return (e||"").slice(0,4).toUpperCase();
}
// A fader sweep is ONE gesture but ~100 events. Keying on what makes an event
// the "same control" lets consecutive ones collapse into a single live row.
function midiKey(ev){
return [ev.source,midiLabel(ev.event||""),ev.ch,
ev.controller!=null?ev.controller:ev.note].join("/");
}
function midiVal(ev){
return ev.velocity!=null?ev.velocity:(ev.value!=null?ev.value:null);
}
function midiRow(ev){
const e=ev.event||"";
const cls = (/note/i.test(e)&&/on/i.test(e))?"on" : /off/i.test(e)?"off"
: /control/i.test(e)?"cc" : /pitch/i.test(e)?"pitch" : "";
const nn = ev.note_name || (ev.controller!=null?("cc "+ev.controller):"");
const val = ev.velocity!=null?ev.velocity : (ev.value!=null?ev.value:null);
const vel = val!=null ? `<span class="vel"><span style="width:${Math.round(val/127*100)}%"></span></span>`
: `<span></span>`;
const nn = ev.note_name || (ev.controller!=null?String(ev.controller):"");
const row=document.createElement("div"); row.className="mev "+cls;
row.innerHTML=`<span class="src">${ev.source||""}</span><span class="ev">${e}</span>`+
`<span class="ch">${ev.ch!=null?ev.ch:""}</span><span class="nn">${nn}</span>${vel}`;
row.dataset.key=midiKey(ev); row.dataset.n="1";
row.innerHTML=`<span class="src">${ev.source||""}</span>`+
`<span class="ev">${midiLabel(e)}</span>`+
`<span class="ch">${ev.ch!=null?ev.ch:""}</span>`+
`<span class="nn">${nn}</span>`+
`<span class="vel"><span></span></span>`+
`<span class="num"></span><span class="cnt"></span>`;
midiPaint(row,ev);
return row;
}
// Update in place: one DOM write per event, no node churn, no reflow storm.
function midiPaint(row,ev){
const v=midiVal(ev);
row.querySelector(".vel>span").style.width = v!=null?(Math.round(v/127*100)+"%"):"0";
row.querySelector(".num").textContent = v!=null?v:"";
}
function addMidi(ev){
const log=$("#midiLog"); const empty=log.querySelector(".midi-empty");
if(empty)log.innerHTML="";
const head=log.firstElementChild, key=midiKey(ev);
if(head && head.dataset.key===key){
// Same control still moving — refresh the existing row and count the burst
// instead of pushing a near-identical line. A 100-event sweep is 1 row.
const n=(+head.dataset.n||1)+1; head.dataset.n=n;
head.querySelector(".cnt").textContent = "\u00d7"+n;
midiPaint(head,ev);
return;
}
log.prepend(midiRow(ev)); // newest on top
while(log.children.length>200)log.removeChild(log.lastChild);
}
......
......@@ -131,6 +131,12 @@ HUE = {
"amber": {"dim": 29, "full": 63, "flash": 59},
"red": {"dim": 13, "full": 15, "flash": 11},
}
# Daylight by default: never rely on a brightness difference to carry meaning.
# LCXL_DIM_RAMP=1 restores the original six-step ramp (three of whose steps are
# dim) for a genuinely dark stage. One env var + a restart, so it is a soundcheck
# decision, not a code change.
DIM_RAMP = os.environ.get("LCXL_DIM_RAMP", "") not in ("", "0", "no", "false")
ROLE_HUE = {"rhythm": "green", "bass": "amber", "lead": "amber",
"pad": "amber", "riser": "red", "fx": "red"}
......@@ -237,22 +243,45 @@ def value_ramp(value: int) -> int:
His spec, verbatim (2026-07-28): "from dim red nothing through bright red
dim orange bright orange dim green bright green at max".
The LCXL is bicolor (2 bits red x 2 bits green), so the whole palette is 16
states and only ~6 read reliably on a dim stage. That is exactly six steps,
so this ramp uses the entire usable budget and nothing is wasted on shades
nobody can tell apart. See tools/mock-lcxl.py --palette.
The LCXL is bicolor (2 bits red x 2 bits green) = 16 states. The six-step
ramp above spent THREE of its steps on dim shades, which is exactly the
budget PLN cannot read in daylight: 2026-08-21, *"top brightness always
would make more readable signals even in day perfs"*.
So the default ramp is now DAYLIGHT: five steps, every one full-brightness,
built from the two mixed hues the old ramp never used —
red 15 (g0,r3) -> orange 31 (g1,r3) -> amber 63 (g3,r3)
-> yellow 62 (g3,r2) -> green 60 (g3,r0)
Every value has at least one component at maximum, so nothing relies on a
brightness difference to be legible. It costs ONE step versus the old six
and gains three readable ones. This is the same trade PLN already made for
the DJ filters in July (*"i agree on clarity > resolution"*) — `filter_colour`
has been all-full-brightness ever since, and this brings the unipolar knobs
in line with it.
Set LCXL_DIM_RAMP=1 to restore the old six-step ramp for a dark stage.
See tools/mock-lcxl.py --palette.
NOT for the DJ filters: those are bipolar (centre = bypass), and a monotonic
ramp would paint bypass as mid-orange and the two opposite musical extremes
as the same colour. They keep `filter_colour`.
"""
v = max(0, min(127, int(value)))
if DIM_RAMP: # the original six-step ramp, half of it dim
if v < 8: return 13 # dim red — effectively nothing
if v < 32: return 15 # bright red — just coming in
if v < 56: return 29 # dim amber
if v < 80: return 31 # bright amber — half
if v < 112: return 28 # dim green
return 60 # bright green — at max
# DAYLIGHT ramp (default): five steps, EVERY ONE at full brightness.
if v < 26: return 15 # red — nothing / just coming in
if v < 51: return 31 # orange — (g1,r3) red-dominant mix
if v < 77: return 63 # amber — (g3,r3) half
if v < 102: return 62 # yellow — (g3,r2) green-dominant mix
return 60 # green — at max
def control_colour(cc: int, role: str, value: int | None, touched: bool,
......
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