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():
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():
frame = leds.sysex_pairs([(0x10, 13), (0x11, 60), (0x20, 15)])
state = mock.parse_frames(frame)
......
......@@ -50,14 +50,29 @@ Row D (faders) has **no LEDs at all**. Many pairs fit in one message, so a full
**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.
State language (settled with PLN 2026-07-29 — clarity over resolution)
---------------------------------------------------------------------
dark = this control does nothing on this track <- the biggest cognitive win
KNOBS (rows A/B/C) — hue carries the VALUE.
unipolar (rows A/B): dim red -> red -> dim amber -> amber -> dim green -> green
i.e. `value_ramp`, PLN's own six-step spec
bipolar (row C, the DJ filters): red = cutting the highs (LPF)
BRIGHT AMBER = the centre detent, true bypass
green = cutting the lows (HPF)
Role is NOT in the hue on the knobs. It does not need to be: the lane
convention fixes role by POSITION, whereas a knob's setting has no other
channel at all — the pot's pointer is invisible on a dark stage.
BUTTONS (rows E/F) — hue carries the ROLE, brightness the on/off state.
dim/full = off/on, hue = green rhythm · amber bass+lead+pad · red riser/FX
(Colouring these by FUNCTION instead of lane-role is #49, still open.)
GLOBAL OVERLAY — "^93" armed flashes RED on all four panic-chord buttons, over
whatever else they were showing, because it answers "why is there no sound?".
**Dim vs full reads poorly on this hardware** — established by having PLN read a
ramp back — so FLASH, never brightness alone, carries anything unmissable.
Usage
-----
......@@ -183,21 +198,33 @@ ALL_LIT_CCS = [cc for cc in range(128) if cc_to_index(cc) is not None]
# --------------------------------------------------------------------------- #
# value -> colour
# --------------------------------------------------------------------------- #
DJF_CENTRE_LO, DJF_CENTRE_HI = 61, 67 # the detent band that means "bypass"
def filter_colour(value: int) -> int:
"""DJ-filter knob ramp. Brightness = distance from neutral, hue = direction.
"""DJ-filter knob: THREE states. red = lows, bright amber = bypass, green = highs.
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".
Settled by PLN 2026-07-29, and it REPLACES a seven-step ramp:
"now maybe we sunset the dimorange and just have red in lows green in highs
for clarity" / "i agree on clarity > resolution".
The old version 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. Three unambiguous, full-brightness states beat seven shades nobody can
resolve on a dark stage.
Direction, in his words: red is low (the LPF end, toward subbass), green is high
(the HPF end). 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 is here to keep visible.
"""
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
if v < DJF_CENTRE_LO: return 15 # red — LPF, cutting the highs away
if v <= DJF_CENTRE_HI: return 63 # BRIGHT amber — centre detent == true bypass
return 60 # green — HPF, cutting the lows away
def value_ramp(value: int) -> int:
......@@ -224,9 +251,15 @@ def value_ramp(value: int) -> int:
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,
panic: bool = False) -> 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.
it is a pure function of the MODEL (role + value + touched + panic), never of
an event.
`panic` is the live state of "^93" — armed means every stream wrapped in gPanic
is silent. It is passed in rather than inferred from `cc`, and that is the whole
of fix #76 (see below).
"""
if cc in DJ_FILTERS:
# Never dark: a filter you cannot see is a filter you forget is closed.
......@@ -237,18 +270,43 @@ def control_colour(cc: int, role: str, value: int | None, touched: bool) -> int:
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"]
# #76 — PANIC IS A STATE, NOT A MEMBERSHIP.
# The panic chord is an OVERLOAD: hold 73+74+91+92 together and the SC
# bridge edge-detects the chord and flips the persistent "^93" toggle. Those
# buttons individually are gMute1 / gMute2 / (91,92) — they have day jobs.
# The old rule tested `cc in PANIC_CHORD` BEFORE role, so 73 and 74 sat dim
# RED at rest while 75 (gMute3, same job, not a chord member) sat green, and
# PLN spotted it immediately: "why are [mutes] 1/2/3 resp red red green?
# these 3 buttons have same roles, why diff colours?". The panic identity
# simply does not exist at rest, so it must not be painted at rest.
#
# ^93 has no LED of its own (CC93 is outside row F's 89-92), so these four
# buttons are the only place the armed state can be shown — and it is the
# highest-value LED in the rig, because it answers "why is there no sound?".
if panic and cc in PANIC_CHORD:
return HUE["red"]["flash"] # ARMED: everything gPanic'd is silent
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"]
# knobs (rows A/B/C): hue carries the VALUE, via PLN's six-step ramp.
#
# #11 — this ramp existed, was unit-tested, and was never wired in. PLN caught
# that too: "wait 'brightness value 6 steps ramp'? I See only two states, dim
# and not dim, at 0 and not 0 atm on the knobs". It was true — the old rule had
# exactly three outcomes (dim / full / flash), so a knob at 30% and a knob at
# 90% were the same colour.
#
# The cost, stated plainly: the ramp spends all three hues on VALUE, so hue can
# no longer carry ROLE on rows A/B/C. That is the right trade, because role is
# already encoded by POSITION (the lane convention is fixed, LANE_ROLE), while
# a knob's setting has no other channel at all — the pot's pointer is invisible
# on a dark stage. Unbound controls still go DARK, which is the distinction that
# actually matters. The touched/untouched nuance moves to the HUD, which has
# unlimited colours; trying to keep it here collided with the ramp (dim green is
# both "rhythm, untouched" and "value ~3/4").
if value is None:
# Pre-seed only: boot seeding (#61) means this is effectively unreachable.
return value_ramp(0)
return value_ramp(value)
# --------------------------------------------------------------------------- #
......@@ -329,9 +387,16 @@ def parse_track(path: Path) -> dict[int, str]:
# 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:
# easiest way to lose a track on stage. Their VALUE is meaningful even unbound,
# so a default role is the right call for them.
#
# The panic chord used to be force-bound here too, and that was the OTHER half
# of #76: it lit 73/74/91/92 as "fx" (red) for no reason other than chord
# membership, so on a track binding gMute3 but not gMute1/2 the row read
# "R R G" — three buttons with the same job, three different colours. A chord
# is not a binding. Panic is painted by build_frame as a global OVERLAY when
# "^93" is armed, and is invisible otherwise.
for cc in DJ_FILTERS:
bindings.setdefault(cc, "fx")
return bindings
......@@ -358,11 +423,21 @@ def build_frame(bindings: dict[int, str], values: dict[int, 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
panic = bool(values.get(PANIC_CC)) # ^93: armed == gPanic'd streams silent
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)
frame[idx] = control_colour(cc, role, values.get(cc), cc in touched, panic)
if panic:
# Panic is a global OVERLAY, painted last and over everything — including
# controls this track does not bind, which would otherwise be dark. It has to
# win: when it is armed, "why is there no sound?" is the only question on the
# board, and CC93 owns no LED of its own to answer it with.
for cc in PANIC_CHORD:
idx = cc_to_index(cc)
if idx is not None:
frame[idx] = HUE["red"]["flash"]
return frame
......@@ -683,11 +758,17 @@ class Painter:
def resolve_paint(cc: int, val: int, bindings: dict[int, str],
ack_unbound: bool = False) -> tuple[int, int] | None:
ack_unbound: bool = False,
panic: bool = False) -> tuple[int, int] | None:
"""(led index, colour) for one incoming control event, or None if not a lit index.
Pulled out of the read loop so the bench and the tests exercise the SAME function
the live daemon does -- a benchmark of a re-implementation measures nothing.
`panic` is the current ^93 state from the model. Note that CC93 itself has no LED,
so an event ON 93 resolves to None here — arming panic has to repaint the four
chord members, which is why the watch loop rebuilds the frame instead of painting
one index (see cmd_watch).
"""
idx = cc_to_index(cc)
if idx is None:
......@@ -699,7 +780,7 @@ def resolve_paint(cc: int, val: int, bindings: dict[int, str],
# would undermine the one thing PLN reads at a glance. The touch is still
# recorded in the model, so --ack-unbound can surface it when debugging.
return idx, (HUE["red"]["dim"] if (ack_unbound and val) else OFF)
return idx, control_colour(cc, role, val, True)
return idx, control_colour(cc, role, val, True, panic)
# --------------------------------------------------------------------------- #
......@@ -894,9 +975,22 @@ def cmd_watch(s: Sender, track: str | None, reassert: float = 30.0,
val = 0 if n.group(1) == "off" else int(n.group(4))
if cc is not None and val is not None:
events += 1
was_panic = bool(values.get(PANIC_CC))
values[cc] = val
touched.add(cc)
hit = resolve_paint(cc, val, bindings, ack_unbound)
if cc == PANIC_CC and bool(val) != was_panic:
# ^93 flipped. It owns no LED of its own, and it changes the
# colour of FOUR other indices at once, so this is the one
# event that cannot be served by a single-index paint. Rebuild
# the frame; the painter coalesces it like any other write.
painter.set_frame(build_frame(bindings, dict(values),
set(touched)))
if verbose:
print(f" ^93 PANIC {'ARMED — gPanic streams are SILENT'
if val else 'cleared'}")
continue
hit = resolve_paint(cc, val, bindings, ack_unbound,
panic=bool(values.get(PANIC_CC)))
# Print only when the BOARD changes. Logging every event was itself
# a per-event stdout write in the hot loop, and 400 CC/s of "CC77 =
# 63" tells nobody anything.
......
......@@ -147,8 +147,18 @@ def show_ramp(role: str) -> str:
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.append(f" unbound on this track: {swatch(leds.OFF)} = off (dark). Since #11"
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:"]
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