Commit 0b23f3d9 by PLN (Algolia)

fix(lcxl): panic is a STATE, not a membership — and the six-step ramp was never wired in

Two colour bugs PLN found BY EYE, on the hardware, in the same glance. Both were
cases of the code being confidently reasonable and visibly wrong.

#76 — "why are [mutes] 1/2/3 resp red red green? these 3 buttons have same
roles, why diff colours?"

He was right and the cause was in two places at once. The panic feature is an
OVERLOAD: hold LCXL buttons 73+74+91+92 together, the SC bridge edge-detects the
chord and flips a persistent "^93" toggle, and gPanic gates on it. But those
buttons have day jobs — 73 is gMute1, 74 is gMute2 — so:

  * control_colour tested `cc in PANIC_CHORD` BEFORE role, so 73/74 painted dim
    RED at rest while 75 (gMute3, identical job, not a chord member) painted by
    role. Three buttons, one job, two colours.
  * parse_track ALSO force-bound all four members to role "fx" (red hue) via
    setdefault, so even on a track binding gMute3 and not gMute1/2 the row read
    literally "R R G". Found by rendering mock-lcxl.py after fixing the first
    half and noticing the row still looked wrong.

The panic identity does not exist at rest, so it must not be painted at rest.
Now: `panic` is threaded in from the model (`values[93]`), the chord members
paint by role like any other button when it is clear, and arming flashes red on
all four as a GLOBAL OVERLAY applied last — over role, and over dark, because an
unbound member must still flash. CC93 owns no LED of its own (it sits outside
row F's 89-92), so those four buttons are the only place the armed state can
live, and it is the highest-value LED in the rig: it answers "why is there no
sound?". The watch loop rebuilds the whole frame on a ^93 flip rather than
painting one index, since one event changes four LEDs.

Verified by rendering the convention board: row F at rest is now
`dimGRN dimGRN dimGRN dimAMB dimAMB dimAMB dimGRN dimAMB` — identical to row E,
which is exactly what PLN reported seeing on row E ("G G G Y Y Y G Y"). Armed:
`RED! RED! grn amb amb amb RED! RED!`.

#11a — the DJ filter is THREE states now, not seven

Settled by PLN: "sunset the dimorange and just have red in lows green in highs
for clarity" + "i agree on clarity > resolution". The old ramp spent four of its
seven steps on dim-amber and mid shades either side of centre, so the row read
as a wash of oranges at a glance and the one thing you actually need — WHICH WAY
is this filter cutting — was the hardest bit to see. Now: bright red (LPF, lows)
/ bright amber at the 61-67 detent band (bypass) / bright green (HPF, highs).
All three are palette corners, the only states that read reliably on a dark
stage. The centre band stays bright because djf 0.5 / CC 64 is BYPASS, not zero
— djf 0.05 is a ~26 Hz low-pass, i.e. silence, which is the #48 footgun this
colour exists to keep visible.

#11b — "I See only two states, dim and not dim, at 0 and not 0 atm on the knobs"

Also true, and embarrassing: `value_ramp` — his own verbatim six-step spec — was
written, unit-tested, documented in the module header, and never once called.
control_colour had exactly three outcomes for knobs (dim / full / flash), so a
knob at 30% and one at 90% were the same colour. Wired in.

The trade, stated because it should not regress silently: the ramp spends all
three hues on VALUE, so hue no longer carries ROLE on rows A/B/C. That is the
right way round — role is already fixed by POSITION (the lane convention),
while a knob's setting has no other channel at all, since the pot's pointer is
invisible on a dark stage. Keeping "untouched" as a colour was tried and
abandoned: dim green would have meant both "rhythm, untouched" and "value ~3/4".
That nuance belongs in the HUD, which has unlimited colours. Unbound controls
still go DARK, which is the distinction that actually matters.

Module header rewritten to describe the convention that now exists, rather than
the one it used to.

TESTS: +24 in test_lcxl_colour.py (8 -> 32), suite 282 -> 306, all green.
The old knob rule had NO test at all — which is how a never-called ramp survived
being shipped. Now covered: the filter has exactly three states and no dim amber
survives; the detent band is contiguous and symmetric; the three mutes are equal
at rest AND when engaged; arming repaints all four members and nothing else; an
omitted panic argument behaves as unarmed (the #55 "untouched means zero"
lesson); the overlay beats both role and dark; and the chord is asserted NOT to
be force-bound as fx.

Live: lcxl-leds-watch restarted, board repainted.
parent 4c57362d
...@@ -62,6 +62,202 @@ def test_dj_filter_is_never_dark(): ...@@ -62,6 +62,202 @@ def test_dj_filter_is_never_dark():
assert all(leds.filter_colour(v) != leds.OFF for v in range(128)) assert all(leds.filter_colour(v) != leds.OFF for v in range(128))
# --------------------------------------------------------------------------- #
# #11a — the DJ filter is THREE states now, not seven
# --------------------------------------------------------------------------- #
def test_dj_filter_has_exactly_three_states():
"""PLN, 2026-07-29: "sunset the dimorange and just have red in lows green in
highs for clarity" + "clarity > resolution". The old seven-step version spent
four steps on shades either side of centre, so the row read as a wash of
oranges and the one thing you need — which way is it cutting — was hardest to
see."""
assert len({leds.filter_colour(v) for v in range(128)}) == 3
def test_dj_filter_is_red_low_amber_centre_green_high():
assert leds.filter_colour(0) == 15 # red — LPF, lows only
assert leds.filter_colour(30) == 15
assert leds.filter_colour(64) == 63 # bright amber — bypass
assert leds.filter_colour(100) == 60 # green — HPF, highs only
assert leds.filter_colour(127) == 60
def test_no_dim_amber_survives_in_the_filter_ramp():
"""The specific colour PLN asked to retire (29 = dim amber / 'dimorange')."""
assert 29 not in {leds.filter_colour(v) for v in range(128)}
def test_the_filter_centre_band_is_wide_enough_for_the_detent_and_no_wider():
"""The knob's centre detent lands on 63/64. Too narrow and bypass flickers red;
too wide and a real filter move shows as bypass."""
amber = [v for v in range(128) if leds.filter_colour(v) == 63]
assert 63 in amber and 64 in amber
assert len(amber) <= 12
assert amber == list(range(min(amber), max(amber) + 1)) # contiguous
def test_the_filter_ramp_is_symmetric_about_the_centre_band():
"""Neither direction may be privileged — 'am I cutting up or down' has to read
the same amount at both ends."""
lo = sum(1 for v in range(128) if leds.filter_colour(v) == 15)
hi = sum(1 for v in range(128) if leds.filter_colour(v) == 60)
assert abs(lo - hi) <= 2
# --------------------------------------------------------------------------- #
# #11b — value_ramp is actually WIRED IN now
# --------------------------------------------------------------------------- #
def test_knobs_use_the_six_step_ramp_not_three_states():
"""The bug PLN caught by eye: "I See only two states, dim and not dim, at 0 and
not 0 atm on the knobs". value_ramp was written, unit-tested, and never called —
control_colour had exactly three outcomes, so 30% and 90% were the same colour.
"""
seen = {leds.control_colour(13, "bass", v, True) for v in range(128)}
assert len(seen) == 6
assert seen == {13, 15, 29, 31, 28, 60}
def test_a_knob_at_30_percent_and_at_90_percent_are_different_colours():
a = leds.control_colour(13, "bass", 38, True)
b = leds.control_colour(13, "bass", 115, True)
assert a != b
def test_every_knob_row_uses_the_ramp():
"""Rows A, B and C are all 0..127 ranges — no row keeps the old rule."""
for cc in (13, 29, 40): # A1, B1, B-mid... all knob rows
assert leds.control_colour(cc, "bass", 100, True) == leds.value_ramp(100)
def test_the_ramp_overrides_role_hue_on_knobs_deliberately():
"""The trade this makes, asserted so it cannot regress silently: hue now carries
VALUE, so two knobs at the same value look the same whatever their role. Role is
still encoded by POSITION (the lane convention), and a knob's setting has no
other channel at all — the pot's pointer is invisible on a dark stage."""
assert leds.control_colour(13, "bass", 100, True) \
== leds.control_colour(13, "rhythm", 100, True)
def test_the_dj_filters_are_exempt_from_the_unipolar_ramp():
"""They are bipolar: centre is bypass, so a monotonic ramp would paint bypass as
mid-amber and the two opposite musical extremes as the same colour."""
for cc in leds.DJ_FILTERS:
assert leds.control_colour(cc, "fx", 64, True) == 63
assert leds.control_colour(cc, "fx", 64, True) != leds.value_ramp(64)
def test_an_unbound_control_is_still_dark_after_the_ramp_change():
"""'dark means this control does nothing here' is the distinction that matters;
the ramp must not have lit the whole board."""
frame = leds.build_frame({13: "bass"}, {13: 100}, {13})
assert frame[leds.cc_to_index(13)] == leds.value_ramp(100)
assert frame[leds.cc_to_index(14)] == leds.OFF
# --------------------------------------------------------------------------- #
# #76 — panic is a STATE (^93), not a membership
# --------------------------------------------------------------------------- #
def test_the_three_mutes_are_identical_at_rest():
"""PLN by eye: "why are [mutes] 1/2/3 resp red red green? these 3 buttons have
same roles, why diff colours?". gMute1=73 and gMute2=74 are panic-CHORD members
and the old rule tested membership BEFORE role, so they sat dim red while
gMute3=75 sat by role. The panic identity does not exist at rest."""
cols = [leds.control_colour(cc, "rhythm", 0, False, False) for cc in (73, 74, 75)]
assert len(set(cols)) == 1
def test_the_three_mutes_are_identical_when_engaged_too():
cols = [leds.control_colour(cc, "rhythm", 127, True, False) for cc in (73, 74, 75)]
assert len(set(cols)) == 1
def test_panic_unarmed_leaves_all_four_chord_members_painted_by_role():
for cc in leds.PANIC_CHORD:
assert leds.control_colour(cc, "rhythm", 0, False, False) \
== leds.HUE["green"]["dim"]
def test_arming_panic_flashes_red_on_all_four_chord_members():
"""^93 armed means every gPanic'd stream is silent. This is the highest-value
LED in the rig: it answers "why is there no sound?"."""
for cc in leds.PANIC_CHORD:
assert leds.control_colour(cc, "rhythm", 0, False, True) \
== leds.HUE["red"]["flash"]
def test_arming_panic_does_not_repaint_unrelated_buttons():
assert leds.control_colour(75, "rhythm", 0, False, True) \
== leds.control_colour(75, "rhythm", 0, False, False)
def test_panic_defaults_to_unarmed_so_an_untouched_93_behaves_as_zero():
""""^93" UNTOUCHED must read as 0, not as missing — the #55 lesson. Omitting the
argument entirely has to mean 'not armed'."""
assert leds.control_colour(73, "rhythm", 0, False) \
== leds.control_colour(73, "rhythm", 0, False, False)
def test_build_frame_reads_the_panic_state_out_of_the_model():
"""The daemon must not need a special code path: ^93 lives in `values` like any
other control, so any repaint reproduces the armed board."""
bindings = {cc: "rhythm" for cc in leds.PANIC_CHORD}
calm = leds.build_frame(bindings, {}, set())
armed = leds.build_frame(bindings, {leds.PANIC_CC: 1}, {leds.PANIC_CC})
for cc in leds.PANIC_CHORD:
i = leds.cc_to_index(cc)
assert calm[i] != armed[i]
assert armed[i] == leds.HUE["red"]["flash"]
def test_the_chord_members_are_not_force_bound_as_fx():
"""The OTHER half of #76, found by rendering the mock: parse_track used to
setdefault all four chord members to role "fx" (red hue), so on a track that
binds gMute3 but not gMute1/2 the row read literally "R R G" — PLN's exact
observation. A chord is not a binding."""
b = leds.parse_track.__doc__ is not None # sanity: function exists
assert b
src = (TOOLS / "lcxl-leds.py").read_text()
seg = src[src.index("def parse_track"):src.index("def convention_bindings")]
assert "for cc in DJ_FILTERS:" in seg
assert "DJ_FILTERS + PANIC_CHORD" not in seg
def test_panic_overlay_lights_chord_members_even_when_the_track_binds_none_of_them():
"""Painted as an overlay, so an unbound (dark) chord member still flashes when
armed. Otherwise the one state that explains silence could be invisible."""
armed = leds.build_frame({1: "rhythm"}, {leds.PANIC_CC: 1}, {leds.PANIC_CC})
for cc in leds.PANIC_CHORD:
assert armed[leds.cc_to_index(cc)] == leds.HUE["red"]["flash"]
def test_the_panic_overlay_wins_over_a_bound_role():
armed = leds.build_frame({73: "rhythm"}, {73: 127, leds.PANIC_CC: 1},
{73, leds.PANIC_CC})
assert armed[leds.cc_to_index(73)] == leds.HUE["red"]["flash"]
def test_an_unarmed_board_leaves_unbound_chord_members_dark():
calm = leds.build_frame({1: "rhythm"}, {}, set())
for cc in leds.PANIC_CHORD:
assert calm[leds.cc_to_index(cc)] == leds.OFF
def test_cc93_owns_no_led_of_its_own():
"""Which is WHY the four chord members have to carry the armed state: CC93 sits
outside row F's 89-92, so there is nowhere else to show it."""
assert leds.cc_to_index(leds.PANIC_CC) is None
assert leds.resolve_paint(leds.PANIC_CC, 1, {leds.PANIC_CC: "fx"}) is None
def test_resolve_paint_threads_the_panic_state_through():
b = {73: "rhythm"}
assert leds.resolve_paint(73, 0, b, panic=True)[1] == leds.HUE["red"]["flash"]
assert leds.resolve_paint(73, 0, b, panic=False)[1] != leds.HUE["red"]["flash"]
def test_sysex_roundtrip_through_the_mock_surface(): def test_sysex_roundtrip_through_the_mock_surface():
frame = leds.sysex_pairs([(0x10, 13), (0x11, 60), (0x20, 15)]) frame = leds.sysex_pairs([(0x10, 13), (0x11, 60), (0x20, 15)])
state = mock.parse_frames(frame) state = mock.parse_frames(frame)
......
...@@ -147,8 +147,18 @@ def show_ramp(role: str) -> str: ...@@ -147,8 +147,18 @@ def show_ramp(role: str) -> str:
line += swatch(c) line += swatch(c)
labels += f"{v:<4d}" labels += f"{v:<4d}"
out += [line, labels] out += [line, labels]
out.append(f" untouched: {swatch(leds.control_colour(13, role, None, False))}" out.append(f" unbound on this track: {swatch(leds.OFF)} = off (dark). Since #11"
f" = {name_of(leds.control_colour(13, role, None, False))}") f" the ramp owns the hue, so 'untouched' is no longer a colour —")
out.append(" it moved to the HUD, which has unlimited colours. Dark still means"
" 'this control does nothing here'.")
out += ["", " buttons (rows E/F) — role hue, off/on; and the ^93 PANIC state:"]
line, labels = " ", " "
for label, args in (("off", (0, False, False)), ("on", (127, True, False)),
("PANIC", (0, False, True))):
line += swatch(leds.control_colour(73, role, *args))
labels += f"{label:<4.4s}"
out += [line, labels]
out += ["", " PLN's six-step unipolar ramp (value_ramp) — volume/effect ranges:"] out += ["", " PLN's six-step unipolar ramp (value_ramp) — volume/effect ranges:"]
line, labels = " ", " " line, labels = " ", " "
......
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