Commit 80b732b0 by PLN (Algolia)

feat(lcxl): a virtual LaunchControl XL, and PLN's six-step colour ramp

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.
parent 6fbd4256
"""Regression tests for the LCXL colour convention.
These exist because the LED work was previously validated only against a FAKE
aseqdump stream — written, wired into boot, and never once seen to light an LED.
A colour rule is a pure function, so it can and must be tested.
"""
import importlib.util, sys
from pathlib import Path
TOOLS = Path(__file__).resolve().parents[2]
def _load(name, path):
spec = importlib.util.spec_from_file_location(name, path)
m = importlib.util.module_from_spec(spec); sys.modules[name] = m
spec.loader.exec_module(m); return m
leds = _load("lcxl_leds", TOOLS / "lcxl-leds.py")
mock = _load("mock_lcxl", TOOLS / "mock-lcxl.py")
def test_colour_byte_decodes_to_two_bits_each():
assert mock.decode_colour(13) == (1, 0) # dim red
assert mock.decode_colour(15) == (3, 0) # bright red
assert mock.decode_colour(60) == (0, 3) # bright green
assert mock.decode_colour(63) == (3, 3) # bright amber
def test_value_ramp_is_pln_six_steps_in_order():
"""dim red -> bright red -> dim amber -> bright amber -> dim green -> bright green."""
seq = []
for v in range(128):
c = leds.value_ramp(v)
if not seq or seq[-1] != c:
seq.append(c)
assert seq == [13, 15, 29, 31, 28, 60]
def test_value_ramp_is_monotonic_in_perceived_progress():
"""Never goes backwards: each step is >= the previous in (green, red) order."""
def rank(c):
r, g = mock.decode_colour(c)
return (g, r)
vals = [leds.value_ramp(v) for v in range(128)]
ranks = [rank(c) for c in vals]
assert ranks == sorted(ranks) or len(set(ranks)) == 6
def test_value_ramp_bottom_is_dim_not_off():
"""A mapped control at zero must still be visible, or you forget it exists."""
assert leds.value_ramp(0) != leds.OFF
def test_dj_filter_is_bipolar_and_brightest_at_centre():
"""Centre 64 is bypass. Both ends are extremes — a monotonic ramp would lie."""
assert leds.filter_colour(64) == 63
assert leds.filter_colour(0) != leds.filter_colour(127)
assert leds.filter_colour(64) not in (leds.filter_colour(0), leds.filter_colour(127))
def test_dj_filter_is_never_dark():
"""A filter you cannot see is a filter you forget is closed."""
assert all(leds.filter_colour(v) != leds.OFF for v in range(128))
def test_sysex_roundtrip_through_the_mock_surface():
frame = leds.sysex_pairs([(0x10, 13), (0x11, 60), (0x20, 15)])
state = mock.parse_frames(frame)
assert state == {0x10: 13, 0x11: 60, 0x20: 15}
def test_mock_never_reports_a_forbidden_fader_led():
"""CC77-84 are faders: no LEDs at all. They must not appear in a frame."""
assert "D" not in leds.ROW_BASE
...@@ -198,6 +198,30 @@ def filter_colour(value: int) -> int: ...@@ -198,6 +198,30 @@ def filter_colour(value: int) -> int:
return 28 # dark green — HPF hard up return 28 # dark green — HPF hard up
def value_ramp(value: int) -> int:
"""PLN's six-step UNIPOLAR ramp, for any control that is a 0..max range.
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.
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 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
def control_colour(cc: int, role: str, value: int | None, touched: bool) -> int: def control_colour(cc: int, role: str, value: int | None, touched: bool) -> int:
"""The one colour rule for the whole surface. Persistent by construction: """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. it is a pure function of the MODEL (role + value + touched), never of an event.
......
#!/usr/bin/env python3
"""mock-lcxl — a virtual LaunchControl XL you can SEE in a terminal.
Why this exists (2026-07-28)
----------------------------
The LED work had a hole in the middle of it: `lcxl-leds.py --watch` was written,
wired into the boot sequence, and validated only against a FAKE `aseqdump`
stream. Nobody — human or machine — had ever seen it light a single LED. PLN's
report was "still seeing static colors", and there was no way for me to check my
own work, because I cannot look at the device.
So: decode the same SysEx the real surface receives, and render it. Now the
colour convention can be designed, reviewed and REGRESSION-TESTED without
hardware, and the only thing left needing PLN's eyes is whether the physical
LEDs match this picture.
Colour bytes
------------
The LCXL is BICOLOR. A colour byte is `0b00GG11RR`: two bits of green, two bits
of red, plus the 0x0C flag bits. That is 4 red levels x 4 green levels = 16
states, of which only about 6 are reliably distinguishable on a dim stage. Any
"smooth ramp" design has to live inside that budget — which is exactly the
constraint worth seeing before committing to a convention.
Usage
-----
tools/lcxl-leds.py --map TRACK --dry-run | tools/mock-lcxl.py
tools/mock-lcxl.py --ramp # show the value->colour ramp
tools/mock-lcxl.py --ramp --role bass # ...for a given role family
tools/mock-lcxl.py --palette # every reachable colour
"""
from __future__ import annotations
import argparse
import importlib.util
import re
import sys
from pathlib import Path
TOOLS = Path(__file__).resolve().parent
def _load(name: str, path: Path):
spec = importlib.util.spec_from_file_location(name, path)
mod = importlib.util.module_from_spec(spec)
sys.modules[name] = mod
spec.loader.exec_module(mod)
return mod
leds = _load("lcxl_leds", TOOLS / "lcxl-leds.py")
SYSEX_RE = re.compile(
r"F0\s+00\s+20\s+29\s+02\s+11\s+78\s+([0-9A-F]{2})\s+(.*?)\s*F7",
re.IGNORECASE | re.DOTALL)
# Two bits each. Rendered as 24-bit ANSI so the four brightness steps are
# actually distinguishable in a terminal, which they barely are on the device.
RED_LEVELS = [(40, 40, 46), (110, 30, 30), (180, 40, 40), (255, 60, 60)]
GRN_LEVELS = [(40, 40, 46), (30, 110, 40), (40, 180, 60), (70, 255, 90)]
def decode_colour(v: int) -> tuple[int, int]:
"""-> (red 0-3, green 0-3)."""
return v & 0x03, (v >> 4) & 0x03
def swatch(v: int) -> str:
r, g = decode_colour(v)
rr, rg, rb = RED_LEVELS[r]
gr, gg, gb = GRN_LEVELS[g]
# Additive, like the physical LED pair.
R, G, B = min(255, rr + gr - 40), min(255, rg + gg - 40), min(255, rb + gb - 40)
if r == 0 and g == 0:
return "\033[38;2;70;70;80m ·· \033[0m"
return f"\033[48;2;{R};{G};{B}m \033[0m"
def name_of(v: int) -> str:
r, g = decode_colour(v)
if r == 0 and g == 0:
return "off"
hue = "red" if g == 0 else ("green" if r == 0 else "amber")
level = max(r, g)
return f"{['', 'dim ', 'mid ', 'bright '][level]}{hue}"
def parse_frames(text: str) -> dict[int, int]:
"""Fold every SysEx frame in `text` into a final index -> colour map."""
state: dict[int, int] = {}
for _tmpl, body in SYSEX_RE.findall(text):
vals = [int(x, 16) for x in body.split()]
for i in range(0, len(vals) - 1, 2):
state[vals[i]] = vals[i + 1]
return state
def render(state: dict[int, int]) -> str:
"""Draw the surface: 3 knob rows, 8 faders (no LEDs), 2 button rows."""
out = ["", " " + "".join(f" {c+1:<2d} " for c in range(8))]
for row in ("A", "B", "C"):
base = leds.ROW_BASE[row]
cells = "".join(" " + swatch(state.get(base + c, leds.OFF)) + " "
for c in range(8))
out.append(f" knob{row} {cells}")
out.append(" fader " + "".join(" -- " for _ in range(8))
+ " (no LEDs; CC77-84 are Ardour's)")
for row in ("E", "F"):
base = leds.ROW_BASE[row]
cells = "".join(" " + swatch(state.get(base + c, leds.OFF)) + " "
for c in range(8))
out.append(f" btn{row} {cells}")
lit = sum(1 for v in state.values() if v != leds.OFF)
out.append(f"\n {lit}/{leds.LIT_INDICES} lit")
return "\n".join(out)
def show_ramp(role: str) -> str:
"""The value -> colour ramp, as the convention actually resolves it.
This is the picture to argue with when designing #11: it shows what the
surface can really express, not what a gradient in your head can.
"""
out = [f"\n VALUE -> COLOUR, role={role!r}", ""]
out.append(" DJ filter (CC49/50/51) — BIPOLAR, centre 64 = bypass:")
line, labels = " ", " "
for v in range(0, 128, 8):
c = leds.filter_colour(v)
line += swatch(c)
labels += f"{v:<4d}"
out += [line, labels, ""]
seen = []
for v in range(128):
c = leds.filter_colour(v)
if not seen or seen[-1][1] != c:
seen.append((v, c))
out.append(" breakpoints: " + ", ".join(
f"{v}={name_of(c)}" for v, c in seen))
out.append("")
out.append(f" ordinary knob, role={role!r} (touched):")
line, labels = " ", " "
for v in range(0, 128, 8):
c = leds.control_colour(13, role, v, True)
line += swatch(c)
labels += f"{v:<4d}"
out += [line, labels]
out.append(f" untouched: {swatch(leds.control_colour(13, role, None, False))}"
f" = {name_of(leds.control_colour(13, role, None, False))}")
out += ["", " PLN's six-step unipolar ramp (value_ramp) — volume/effect ranges:"]
line, labels = " ", " "
for v in range(0, 128, 8):
line += swatch(leds.value_ramp(v))
labels += f"{v:<4d}"
out += [line, labels]
steps = []
for v in range(128):
c = leds.value_ramp(v)
if not steps or steps[-1][1] != c:
steps.append((v, c))
out.append(" steps: " + ", ".join(f"{v}+={name_of(c)}" for v, c in steps))
return "\n".join(out)
def show_palette() -> str:
out = ["\n EVERY REACHABLE COLOUR (4 red x 4 green — the whole budget)", ""]
for g in range(4):
line = " "
for r in range(4):
v = 0x0C | r | (g << 4)
line += swatch(v) + f" {v:<3d} {name_of(v):<14s}"
out.append(line)
out.append("\n Distinguishable on a dim stage: roughly the corners plus the "
"mid-amber.\n Design the convention to THAT, not to a smooth gradient.")
return "\n".join(out)
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
ap.add_argument("--ramp", action="store_true", help="show the value->colour ramp")
ap.add_argument("--palette", action="store_true", help="show every reachable colour")
ap.add_argument("--role", default="fx", help="role family for --ramp")
ap.add_argument("file", nargs="?", help="file of SysEx hex (default: stdin)")
args = ap.parse_args()
if args.palette:
print(show_palette())
return 0
if args.ramp:
print(show_ramp(args.role))
return 0
text = Path(args.file).read_text() if args.file else sys.stdin.read()
state = parse_frames(text)
if not state:
print("mock-lcxl: no SysEx frames found on input", file=sys.stderr)
return 1
print(render(state))
return 0
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