Commit 60de8a2d by PLN (Algolia)

feat(lcxl): a standalone LED painter — the surface stops being dark, and stops being factory yellow

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.
parent cfa56b69
#!/usr/bin/env python3
r"""lcxl-leds — paint the LaunchControl XL, from the hardware side, with OUR colour coding.
The problem this exists to kill
-------------------------------
The surface went dark. Not "wrong colours" — DARK, with only knob A1 green from a
hand-sent test SysEx. PLN's words, three times over: *"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 failure modes stacked up:
1. **Nothing painted at boot.** The LED painter lived in the Pulsar HUD
(`lib/lcxl-leds.js`), so the surface was only ever lit if Pulsar was open, had
activated the package, and its frame pipeline worked. #57 says its `lastFrame`
dedupe latches an early empty frame — so even with Pulsar open, one bad first
frame means a permanently dark board and no error anywhere.
2. **Factory yellow winning.** When anything DID light, it snapped back to a flat
amber/yellow — that was a real Midi-Through feedback loop echoing velocity 127
back as an LED colour byte (see the `reference_lcxl_midi_feedback_loop` memory),
not the device's idea of a default.
3. **No persistence.** The LEDs are **write-only — there is no readback** — so
software must OWN the LED state and re-assert it. Nothing did.
So this is a standalone, stdlib-only CLI/daemon that works with Pulsar closed, is
callable from `gig-up.sh` and `tidal-remote.py`, and keeps an in-process model of
every control so a repaint is always available from truth rather than from whatever
the last MIDI event happened to be.
What it is NOT
--------------
It **never sends a CC**. Not one. It is SysEx-out only. Sending CCs would move live
audio parameters (and CC 77-84 are MIDI-learned to Ardour track gains — CC77 down is
total silence). Input is consumed strictly read-only, by watching `aseqdump` as a
child process, which does not steal MIDI from SuperCollider/Tidal.
Protocol (verified on hardware 2026-07-27, not inferred)
--------------------------------------------------------
F0 00 20 29 02 11 78 <template> <index> <value> [<index> <value> ...] F7
template 00 = User 1 (PLN is always User 1)
index 00-07 knob row A · 08-0F row B · 10-17 row C
18-1F top button row · 20-27 bottom button row
28-2B Device/Mute/Solo/RecArm · 2C-2F arrows
colour = (16 * green) + red + flags, red/green each 0-3; flags 12 normal, 8 flash
Row D (faders) has **no LEDs at all**. Many pairs fit in one message, so a full
40-LED repaint is ONE write.
**Dim vs full reads poorly on this hardware** — established by having PLN read a
ramp back. So FLASH, never brightness, carries anything that must be unmissable.
State language (shared with the HUD painter, deliberately)
----------------------------------------------------------
dark = this track does not map this control <- the biggest cognitive win
dim = mapped, currently at/near neutral
full = mapped and engaged
flash = at the extreme of its range / armed / dangerous
hue = ROLE FAMILY: green rhythm · amber bass+lead+pad · red riser/FX
Five roles collapse into three hues because the hardware has exactly two LEDs.
Usage
-----
tools/lcxl-leds.py --map # convention paint (no track) — the BOOT paint
tools/lcxl-leds.py --map TRACK.tidal # paint by what that track actually binds
tools/lcxl-leds.py --test # walk every index; a dead LED is visible
tools/lcxl-leds.py --all green
tools/lcxl-leds.py --off
tools/lcxl-leds.py --watch [TRACK.tidal] # touch-reactive, persistent daemon
... any of the above with --dry-run # print the hex, send nothing
Port resolution
---------------
Resolved **at every call** by matching the name "Launch Control XL" in `amidi -l`.
Never cached across a failure. The port has already moved (20:0 -> 24:0) on a replug,
and a binding resolved once at boot then silently invalidated by a device event is
this rig's single most expensive recurring bug class.
If sends start failing while input still arrives, that is a USB OUT endpoint stall
(urb -32) and **only a replug fixes it** — this tool says so rather than looping.
"""
from __future__ import annotations
import argparse
import re
import shutil
import subprocess
import sys
import threading
import time
from pathlib import Path
REPO = Path(__file__).resolve().parent.parent
TEMPLATE = 0x00 # User 1
# --- colour palette (same numbers as ~lcxlCol in start_and_midi.scd) ---
OFF = 12
COLOURS = {
"off": 12,
"redlo": 13, "red": 15,
"amberlo": 29, "amber": 63,
"yellow": 62,
"greenlo": 28, "green": 60,
"redflash": 11, "amberflash": 59, "yellowflash": 58, "greenflash": 56,
}
HUE = {
"green": {"dim": 28, "full": 60, "flash": 56},
"amber": {"dim": 29, "full": 63, "flash": 59},
"red": {"dim": 13, "full": 15, "flash": 11},
}
ROLE_HUE = {"rhythm": "green", "bass": "amber", "lead": "amber",
"pad": "amber", "riser": "red", "fx": "red"}
# --- CC layout (source of truth: reference_controller_map) ---
ROW_SEGMENTS = [
("A", 13, [1, 2, 3, 4, 5, 6, 7, 8]),
("B", 29, [1, 2, 3, 4, 5, 6, 7, 8]),
("C", 49, [1, 2, 3, 4, 5, 6, 7, 8]),
("D", 77, [1, 2, 3, 4, 5, 6, 7, 8]), # faders — no LEDs
("E", 41, [1, 2, 3, 4]), ("E", 57, [5, 6, 7, 8]),
("F", 73, [1, 2, 3, 4]), ("F", 89, [5, 6, 7, 8]),
]
ROW_BASE = {"A": 0x00, "B": 0x08, "C": 0x10, "E": 0x18, "F": 0x20}
LIT_INDICES = 40 # 24 knobs + 16 buttons. Side/arrow LEDs left to the device.
DJ_FILTERS = [49, 50, 51] # gF1/gF2/gF3, centre 64 = bypass
PANIC_CHORD = [73, 74, 91, 92] # the four-button panic chord
PANIC_CC = 93
# ParVagues channel convention: lane -> role. Used for the CONVENTION paint (no
# track given) so a boot always produces a meaningful board, not a guess-free dark one.
LANE_ROLE = {1: "rhythm", 2: "rhythm", 3: "rhythm", 4: "bass",
5: "lead", 6: "lead", 7: "rhythm", 8: "pad"}
# Orbit -> role fallback, mirroring the HUD's role-classifier.
ORBIT_ROLE = {1: "rhythm", 2: "rhythm", 3: "rhythm", 4: "bass", 5: "lead",
6: "rhythm", 7: "rhythm", 8: "rhythm", 9: "pad", 10: "riser",
11: "lead", 12: "lead"}
# Sound-name role patterns, ported from the HUD's role-classifier.js. Order
# matters: riser before rhythm so "risers:N" isn't eaten by the drum wordlist.
SOUND_PATTERNS = [
("riser", re.compile(r"(risers?|sweep|whoosh|uplifter|fx_uplift)", re.I)),
("rhythm", re.compile(r"(kick|bd\b|:bd|808bd|808bb|snare|\bsn\b|\bhh\b|\boh\b|"
r"\bch\b|hat|breaks?|drums?|808|clap|cymbal|tom|perc|rim|"
r"cowbell|electro|jungle|gretsch|\bcp\b)", re.I)),
("bass", re.compile(r"(bass|sub|cbow|cpluck|moog|acid|\bcb\b)", re.I)),
("pad", re.compile(r"(pad|voice|vox|choir|noise|wind|rain|ambient|drone|"
r"ghost|foul|suns|birds|crow|atmosph|air)", re.I)),
("lead", re.compile(r"(lead|synth|gameboy|arpy|piano|keys|guitar|fpiano|"
r"qstab|pluck|melody)", re.I)),
]
# --------------------------------------------------------------------------- #
# CC <-> LED index
# --------------------------------------------------------------------------- #
def decode_cc(cc: int) -> tuple[str, int | None]:
for row, start, lanes in ROW_SEGMENTS:
i = cc - start
if 0 <= i < len(lanes):
return row, lanes[i]
return "?", None
def cc_to_index(cc: int) -> int | None:
"""LED index for a CC, or None for faders and unknown CCs (no LED exists)."""
row, lane = decode_cc(cc)
base = ROW_BASE.get(row)
if base is None or lane is None:
return None
idx = base + (lane - 1)
return idx if idx < LIT_INDICES else None
ALL_LIT_CCS = [cc for cc in range(128) if cc_to_index(cc) is not None]
# --------------------------------------------------------------------------- #
# value -> colour
# --------------------------------------------------------------------------- #
def filter_colour(value: int) -> int:
"""DJ-filter knob ramp. Brightness = distance from neutral, hue = direction.
The three DJF knobs are the one place where a control's VALUE matters more than
whether it has been touched: mid-set you need "am I filtering, and which way?".
A fully-bright amber row therefore means "all three filters are out of the way".
"""
v = max(0, min(127, int(value)))
if v <= 25: return 13 # dark red — LPF hard down, toward subbass
if v <= 51: return 14 # red — LPF closing
if v <= 60: return 29 # dark amber — approaching centre
if v <= 67: return 63 # BRIGHT amber — centre detent == true bypass
if v <= 76: return 29 # dark amber — just past centre
if v <= 102: return 44 # green — HPF climbing
return 28 # dark green — HPF hard up
def control_colour(cc: int, role: str, value: int | None, touched: bool) -> int:
"""The one colour rule for the whole surface. Persistent by construction:
it is a pure function of the MODEL (role + value + touched), never of an event.
"""
if cc in DJ_FILTERS:
# Never dark: a filter you cannot see is a filter you forget is closed.
return filter_colour(64 if value is None else value)
hue = HUE[ROLE_HUE.get(role, "red")]
row, _ = decode_cc(cc)
if row in ("E", "F"): # buttons: on/off state
on = bool(value)
if cc in PANIC_CHORD or cc == PANIC_CC:
return HUE["red"]["flash"] if on else HUE["red"]["dim"]
return hue["full"] if on else hue["dim"]
# knobs (rows A/B/C)
if not touched or value is None:
return hue["dim"] # mapped, never moved
if value < 8:
return hue["dim"] # moved, but back at its neutral end
if value >= 120:
return hue["flash"] # pinned at the extreme — unmissable
return hue["full"]
# --------------------------------------------------------------------------- #
# .tidal parsing — which controls does THIS track bind, and to what role
# --------------------------------------------------------------------------- #
BLOCK_ORBIT = re.compile(r"^\s*(?:d|p\s*)(\d+)\b")
CC_REF = re.compile(r"\^(\d+)")
SOUND_REF = re.compile(r"\b(?:sound|s)\s+\"([^\"]+)\"")
def classify_sound(sound: str | None, orbit: int | None) -> str:
if sound:
for role, rx in SOUND_PATTERNS:
if rx.search(sound):
return role
if orbit and orbit in ORBIT_ROLE:
return ORBIT_ROLE[orbit]
return "fx"
def parse_track(path: Path) -> dict[int, str]:
"""cc -> role for every `^NN` the file references.
Segmentation, and why it is NOT just "split on blank lines": a blank line is
Tidal's block separator, but a `dN` pattern often runs many lines with full-line
`-- comments` inside it, and stripping those comments manufactures blank lines
that would cut a block in half. So segment boundaries are **original** blank
lines *plus* every line that starts a new `dN`, and comments are stripped only
for the CC scan. Verified against a real track: the naive version attributed 9/9
bindings to the "fx" fallback because no segment head ever matched `dN`.
A segment with no `dN` head (a helper definition like `gF = ...`) is
cross-cutting FX.
"""
raw = path.read_text(errors="replace").splitlines()
bindings: dict[int, str] = {}
segments: list[list[str]] = []
cur: list[str] = []
for ln in raw:
if ln.strip() == "":
if cur:
segments.append(cur)
cur = []
continue
if BLOCK_ORBIT.match(ln) and cur:
segments.append(cur)
cur = []
cur.append(ln)
if cur:
segments.append(cur)
for seg in segments:
# Comments stripped here only — a commented-out binding must not light a knob.
body = "\n".join(re.sub(r"--.*$", "", ln) for ln in seg)
ccs = [int(m) for m in CC_REF.findall(body)]
if not ccs:
continue
om = BLOCK_ORBIT.match(seg[0])
orbit = int(om.group(1)) if om else None
# `sound "x"` / `s "x"` when present; otherwise ParVagues writes the sample
# name as a bare mini-notation string (`d2 $ gF1 $ "~ c . <...>"`), so fall
# back to the first quoted string whose CONTENT matches a role pattern —
# a test, not a guess, which keeps mini-notation like "t f!7" from scoring.
sm = SOUND_REF.search(body)
sound = sm.group(1) if sm else None
if sound is None:
for cand in re.findall(r"\"([^\"]+)\"", body):
if any(rx.search(cand) for _, rx in SOUND_PATTERNS):
sound = cand
break
role = classify_sound(sound, orbit)
for cc in ccs:
# An orbit/sound-derived role beats the bare "fx" fallback, so a CC
# first seen in a helper block can still be upgraded by its real orbit.
if cc not in bindings or (bindings[cc] == "fx" and role != "fx"):
bindings[cc] = role
# The three DJ filters are GLOBAL and must never be dark, whether or not this
# particular file names them: a closed filter you cannot see is the single
# easiest way to lose a track on stage. Same for the four-button panic chord —
# it works on every track, so it is always part of the map.
for cc in DJ_FILTERS + PANIC_CHORD:
bindings.setdefault(cc, "fx")
return bindings
def convention_bindings() -> dict[int, str]:
"""The no-track paint: every seeded control lit by its LANE's conventional role.
This is what boot uses. It is honest about being a convention rather than a
reading of a file, and it guarantees the surface is never dark after a boot —
the "yellow lifeline", except in our colours.
"""
out: dict[int, str] = {}
for cc in ALL_LIT_CCS:
_, lane = decode_cc(cc)
out[cc] = LANE_ROLE.get(lane or 0, "fx")
return out
# --------------------------------------------------------------------------- #
# frame building + SysEx
# --------------------------------------------------------------------------- #
def build_frame(bindings: dict[int, str], values: dict[int, int],
touched: set[int]) -> list[int]:
"""index -> colour for the whole lit surface. Unbound controls go DARK, which is
the point: the board becomes a map of the file in front of you."""
frame = [OFF] * LIT_INDICES
for cc, role in bindings.items():
idx = cc_to_index(cc)
if idx is None:
continue
frame[idx] = control_colour(cc, role, values.get(cc), cc in touched)
return frame
def sysex_pairs(pairs: list[tuple[int, int]]) -> str:
body = "".join(f" {i:02X} {v:02X}" for i, v in pairs)
return f"F0 00 20 29 02 11 78 {TEMPLATE:02X}{body} F7"
def sysex_frame(frame: list[int]) -> str:
return sysex_pairs(list(enumerate(frame)))
# --------------------------------------------------------------------------- #
# port resolution + send
# --------------------------------------------------------------------------- #
NAME_RE = re.compile(r"launch\s*control\s*xl", re.I)
def find_hw_port() -> str | None:
"""The `amidi` raw port (hw:C,D,S), resolved BY NAME, every call. Never cached.
Excludes the HUI port: the LCXL exposes two, and only the first speaks the
Launchpad SysEx dialect.
"""
if not shutil.which("amidi"):
return None
try:
out = subprocess.run(["amidi", "-l"], capture_output=True, text=True,
timeout=5).stdout
except Exception:
return None
for line in out.splitlines():
if not NAME_RE.search(line) or re.search(r"\bHUI\b", line):
continue
m = re.search(r"(hw:\d+,\d+(?:,\d+)?)", line)
if m:
return m.group(1)
return None
def find_seq_port(direction: str = "-i") -> str | None:
"""The ALSA-sequencer client:port for the LCXL.
`-i` = readable (source) side, for `aseqdump` — a READ-ONLY subscription that
does not steal MIDI from SuperCollider. `-o` = writable (destination) side, for
`aseqsend` — which is how we push SysEx.
"""
if not shutil.which("aconnect"):
return None
try:
out = subprocess.run(["aconnect", direction], capture_output=True, text=True,
timeout=5).stdout
except Exception:
return None
client = None
for line in out.splitlines():
m = re.match(r"^client (\d+): '([^']*)'", line)
if m:
client = m.group(1) if NAME_RE.search(m.group(2)) else None
continue
if client:
pm = re.match(r"^\s+(\d+) '(.*?)\s*'", line)
if pm and not re.search(r"\bHUI\b", pm.group(2)):
return f"{client}:{pm.group(1)}"
return None
class Sender:
"""SysEx-only writer with stall detection.
TRANSPORT — and this cost the first live attempt. `amidi -p hw:2,0,0 -S ...` is
what lit knob A1 by hand, but it only worked because nothing else held the raw
device. Once SuperCollider is up, the ALSA **sequencer** layer owns the rawmidi
substream and `amidi` dies with *"cannot open port hw:2,0,0: Device or resource
busy"* — i.e. the tool that works on a cold rig fails on a LIVE one, which is
the only rig that matters. So the primary transport is `aseqsend` to the LCXL's
writable **sequencer** port, which coexists with SuperCollider; `amidi` stays as
the fallback for when no seq client exists.
Failures are counted, not ignored. If writes keep failing we say USB-OUT-STALL
and tell PLN to replug, because that is the only thing that fixes urb -32 — and
a tool that retries forever on a stalled endpoint just hides a dark board.
"""
STALL_AFTER = 5
def __init__(self, dry_run: bool = False, verbose: bool = True):
self.dry_run = dry_run
self.verbose = verbose
self.fails = 0
self.sends = 0
self.stalled = False
def send(self, hexmsg: str) -> bool:
if self.dry_run:
print(hexmsg)
self.sends += 1
return True
# Both transports re-resolve the port on EVERY write. A replug already moved
# this device once (20:0 -> 24:0); a cached binding would go silently dead.
attempts: list[tuple[str, list[str]]] = []
seq = find_seq_port("-o")
if seq and shutil.which("aseqsend"):
attempts.append(("aseqsend", ["aseqsend", "-p", seq, hexmsg]))
hw = find_hw_port()
if hw and shutil.which("amidi"):
attempts.append(("amidi", ["amidi", "-p", hw, "-S", hexmsg]))
if not attempts:
self._fail("no 'Launch Control XL' port found (aconnect -o / amidi -l) "
"— unplugged?")
return False
errs = []
for name, cmd in attempts:
try:
r = subprocess.run(cmd, capture_output=True, text=True, timeout=5)
except Exception as e:
errs.append(f"{name} raised: {e}")
continue
if r.returncode == 0:
self.fails = 0
self.sends += 1
return True
errs.append(f"{name} exit {r.returncode}: "
f"{(r.stderr or r.stdout).strip()}")
self._fail("; ".join(errs))
return False
def _fail(self, why: str) -> None:
self.fails += 1
if self.verbose:
print(f"lcxl-leds: send FAILED — {why}", file=sys.stderr)
if self.fails >= self.STALL_AFTER and not self.stalled:
self.stalled = True
print("lcxl-leds: USB OUT ENDPOINT STALL suspected "
f"({self.fails} consecutive failures).\n"
" If input still works but LEDs are dark, only a REPLUG fixes this\n"
" (urb -32). Nothing in software will recover it — see the\n"
" reference_lcxl_led_stall memory.", file=sys.stderr)
# --------------------------------------------------------------------------- #
# commands
# --------------------------------------------------------------------------- #
def cmd_off(s: Sender) -> int:
return 0 if s.send(sysex_frame([OFF] * LIT_INDICES)) else 1
def cmd_all(s: Sender, name: str) -> int:
col = COLOURS.get(name.lower())
if col is None:
print(f"lcxl-leds: unknown colour '{name}'. Known: "
f"{', '.join(sorted(COLOURS))}", file=sys.stderr)
return 2
return 0 if s.send(sysex_frame([col] * LIT_INDICES)) else 1
def cmd_test(s: Sender, delay: float = 0.06) -> int:
"""Walk every index in each hue, then paint a row-identifying pattern.
Purpose is diagnostic: a DEAD LED shows up as the one that never lights during
the walk, which you cannot see from a static frame.
"""
ok = True
for col in (HUE["red"]["full"], HUE["amber"]["full"], HUE["green"]["full"]):
for i in range(LIT_INDICES):
ok &= s.send(sysex_pairs([(i, col)]))
if not s.dry_run:
time.sleep(delay)
if not s.dry_run:
time.sleep(0.15)
# Land on a row-legible pattern so the walk ends in something readable.
frame = [OFF] * LIT_INDICES
for row, base in ROW_BASE.items():
hue = {"A": "green", "B": "amber", "C": "red", "E": "green", "F": "amber"}[row]
for lane in range(8):
if base + lane < LIT_INDICES:
frame[base + lane] = HUE[hue]["full" if lane % 2 == 0 else "dim"]
ok &= s.send(sysex_frame(frame))
return 0 if ok else 1
def resolve_track(raw: str) -> Path:
p = Path(raw)
if p.exists():
return p
cand = REPO / raw
if cand.exists():
return cand
if not raw.endswith(".tidal"):
raw += ".tidal"
hits = sorted((REPO / "live").rglob(raw)) + sorted((REPO / "copycat").rglob(raw))
if len(hits) == 1:
return hits[0]
print(f"lcxl-leds: cannot resolve track '{raw}' ({len(hits)} matches)",
file=sys.stderr)
raise SystemExit(2)
def load_bindings(track: str | None, quiet: bool = False) -> dict[int, str]:
if not track:
b = convention_bindings()
if not quiet:
print(f"lcxl-leds: no track given → CONVENTION paint "
f"({len(b)} controls lit by lane role)")
return b
path = resolve_track(track)
b = parse_track(path)
if not quiet:
roles = {}
for r in b.values():
roles[r] = roles.get(r, 0) + 1
lit = sum(1 for cc in b if cc_to_index(cc) is not None)
print(f"lcxl-leds: {path.name} binds {len(b)} controls "
f"({lit} with LEDs); roles: "
+ ", ".join(f"{k}={v}" for k, v in sorted(roles.items())))
nolights = sorted(cc for cc in b if cc_to_index(cc) is None)
if nolights:
print(f" (bound but no LED exists: {nolights} — row D faders have none)")
return b
def cmd_map(s: Sender, track: str | None, values: dict[int, int] | None = None,
quiet: bool = False) -> int:
bindings = load_bindings(track, quiet=quiet)
values = values or {}
frame = build_frame(bindings, values, set(values))
lit = sum(1 for c in frame if c != OFF)
if not quiet:
print(f"lcxl-leds: painting {lit}/{LIT_INDICES} LEDs "
f"({LIT_INDICES - lit} dark = unbound on this track)")
return 0 if s.send(sysex_frame(frame)) else 1
# --------------------------------------------------------------------------- #
# --watch : the touch-reactive, persistent daemon
# --------------------------------------------------------------------------- #
# aseqdump's ACTUAL output format, taken from the binary's own format strings
# rather than guessed:
# Control change %2d, controller %d, value %d
# Note on %2d, note %d, velocity %s
# The first version of these regexes assumed bare space-separated numbers and would
# have matched NOTHING — a daemon that runs clean, logs nothing, and paints nothing.
# Green code proves nothing about whether the data reaches it; verify the seam.
ASEQ_CC = re.compile(r"Control change\s+(\d+),\s*controller\s+(\d+),\s*value\s+(\d+)")
ASEQ_NOTE = re.compile(r"Note (on|off)\s+(\d+),\s*note\s+(\d+),\s*velocity\s+(\d+)")
def cmd_watch(s: Sender, track: str | None, reassert: float = 30.0,
verbose: bool = True, ack_unbound: bool = False) -> int:
"""Own the LED state; repaint the touched control from the model on every change.
Persistence is not a feature bolted on — it is the data model. `values` is the
truth, `control_colour` is a pure function of it, so any repaint (a touch, the
periodic re-assert, a reconnect) reproduces exactly the same board. The device's
own defaults never get a say because we re-assert on a timer and the device has
no readback to argue with.
READ-ONLY input: `aseqdump` subscribes to the LCXL port without stealing it, so
SuperCollider keeps receiving every CC. We never write a CC anywhere.
"""
if not shutil.which("aseqdump"):
print("lcxl-leds: FAIL — aseqdump not found (alsa-utils)", file=sys.stderr)
return 2
bindings = load_bindings(track, quiet=not verbose)
values: dict[int, int] = {}
touched: set[int] = set()
# Paint immediately, so the board is never dark while we wait for a first touch.
s.send(sysex_frame(build_frame(bindings, values, touched)))
if verbose:
print("lcxl-leds --watch: initial frame painted; listening (Ctrl-C to stop)")
backoff = 1.0
events = 0
# The re-assert runs on its OWN thread, deliberately. The obvious placement —
# inside the aseqdump read loop — only fires when an event arrives, so during the
# long silences that make up most of a set (exactly when a device hiccup would go
# unnoticed) it would never run at all. A timer thread makes persistence hold
# while nobody is touching anything, which is the whole point of "persistent post
# touches". The lock keeps a full repaint from interleaving with a single-LED one.
send_lock = threading.Lock()
def locked_send(msg: str) -> None:
with send_lock:
s.send(msg)
def reassert_loop() -> None:
while reassert:
time.sleep(reassert)
locked_send(sysex_frame(build_frame(bindings, dict(values), set(touched))))
if reassert:
threading.Thread(target=reassert_loop, daemon=True).start()
while True:
port = find_seq_port() # re-resolved on every (re)spawn, never cached
if port is None:
print("lcxl-leds --watch: no LCXL sequencer port; retrying in "
f"{backoff:.0f}s", file=sys.stderr)
time.sleep(backoff)
backoff = min(backoff * 2, 30.0)
continue
if verbose:
print(f"lcxl-leds --watch: aseqdump -p {port}")
try:
proc = subprocess.Popen(["aseqdump", "-p", port],
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
text=True, bufsize=1)
except Exception as e:
print(f"lcxl-leds --watch: aseqdump spawn failed: {e}", file=sys.stderr)
time.sleep(backoff); backoff = min(backoff * 2, 30.0)
continue
started = time.time()
try:
for line in proc.stdout: # type: ignore[union-attr]
cc = val = None
m = ASEQ_CC.search(line)
if m:
cc, val = int(m.group(2)), int(m.group(3))
else:
n = ASEQ_NOTE.search(line)
if n:
# Row F sends Notes, not CCs — and the note NUMBER equals
# the CC number in our map (73-76 / 89-92), so both event
# kinds fold into one model keyed by control number.
cc = int(n.group(3))
val = 0 if n.group(1) == "off" else int(n.group(4))
if cc is not None and val is not None:
events += 1
idx = cc_to_index(cc)
changed = values.get(cc) != val
values[cc] = val
touched.add(cc)
if idx is not None and changed:
role = bindings.get(cc)
if role is None:
# Unbound on this track. Default is to STAY DARK: "dark
# means this control does nothing here" is the strongest
# signal on the board, and lighting it on touch would
# undermine the one thing PLN reads at a glance. The
# touch itself is still recorded in the model, so
# --ack-unbound can surface it when debugging.
colour = (HUE["red"]["dim"] if (ack_unbound and val)
else OFF)
else:
colour = control_colour(cc, role, val, True)
locked_send(sysex_pairs([(idx, colour)]))
if verbose:
row, lane = decode_cc(cc)
print(f" CC{cc:<3d} {row}{lane} = {val:<3d} "
f"-> idx {idx:02X} colour {colour}"
f"{'' if role else ' [unbound]'}")
except KeyboardInterrupt:
proc.terminate()
print(f"\nlcxl-leds --watch: stopped after {events} events, "
f"{s.sends} sends, {len(touched)} controls touched")
return 0
except Exception as e:
print(f"lcxl-leds --watch: read loop error: {e}", file=sys.stderr)
finally:
try:
proc.terminate()
except Exception:
pass
# Respawn with backoff — but reset it if we had a healthy long run, so a
# one-off device blip doesn't leave us in a 30s-poll rut all gig.
if time.time() - started > 30:
backoff = 1.0
print(f"lcxl-leds --watch: aseqdump exited; respawning in {backoff:.0f}s",
file=sys.stderr)
time.sleep(backoff)
backoff = min(backoff * 2, 30.0)
# --------------------------------------------------------------------------- #
def main() -> int:
ap = argparse.ArgumentParser(
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
g = ap.add_mutually_exclusive_group(required=True)
g.add_argument("--map", nargs="?", const="", metavar="TRACK.tidal",
help="paint by role; no argument = the channel-convention paint")
g.add_argument("--watch", nargs="?", const="", metavar="TRACK.tidal",
help="touch-reactive persistent daemon")
g.add_argument("--test", action="store_true", help="walk every LED index")
g.add_argument("--all", metavar="COLOUR", help="paint everything one colour")
g.add_argument("--off", action="store_true", help="all LEDs off")
g.add_argument("--ports", action="store_true", help="show resolved ports and exit")
ap.add_argument("--dry-run", action="store_true", help="print hex, send nothing")
ap.add_argument("--ack-unbound", action="store_true",
help="--watch: faintly light controls this track does NOT bind "
"when touched (off by default — dark is the signal)")
ap.add_argument("--reassert", type=float, default=30.0,
help="--watch: full re-assert interval in seconds (0 = off)")
ap.add_argument("-q", "--quiet", action="store_true")
args = ap.parse_args()
if args.ports:
print(f"aseqsend (LED out, primary): {find_seq_port('-o') or 'NOT FOUND'}")
print(f"amidi (LED out, fallback): {find_hw_port() or 'NOT FOUND'}")
print(f"aseqdump (CC in, read-only) : {find_seq_port('-i') or 'NOT FOUND'}")
return 0
s = Sender(dry_run=args.dry_run, verbose=not args.quiet)
if args.off:
return cmd_off(s)
if args.all:
return cmd_all(s, args.all)
if args.test:
return cmd_test(s)
if args.map is not None:
return cmd_map(s, args.map or None, quiet=args.quiet)
if args.watch is not None:
return cmd_watch(s, args.watch or None, reassert=args.reassert,
verbose=not args.quiet, ack_unbound=args.ack_unbound)
return 2
if __name__ == "__main__":
sys.exit(main())
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