Commit 973f77dd by PLN (Algolia)

fix(leds): repaint on FILE EDIT, not just track change — and light the mutes armed by name

Two lies the board was telling, both found by PLN looking at it during the column
migration. Neither had any error output anywhere; both are the rig's signature
shape, a value resolved once and never rechecked.

== 1. STALE PARSE: the board showed a file that no longer existed ==
PLN: "i see the leds red on C5 c6 not on c4 why? feels like old led convention?"

His instinct was right about staleness and wrong about the cause — it was old
DATA, not old code. The watcher followed track CHANGES only, so it held whatever
the file said the moment it was opened. Timestamps settled it in one line:

    watcher started      15:21:24
    bombe_dj.tidal        16:27:17   (the migration)

^54 existed in the 15:21 version and does not exist now. C5+C6 lit / C4 dark was
a *faithful* picture of a file 66 minutes dead. And this is livecoding — the file
changes constantly, so this was not an edge case, it was every save.

follow_loop now stats the track's mtime alongside the path and re-parses on
either. Two extra stats per second; still no inotify, deliberately — a watch that
dies silently is the exact failure class this rig keeps producing.

One subtlety in the fix: touched-state is cleared on a track CHANGE (carrying it
over would claim you had already worked controls on a track you just opened) but
PRESERVED on an edit to the track already loaded. Otherwise a ctrl+S mid-set
wipes the one thing that display exists for.

== 2. HELPERS ARMED BY NAME: four live buttons painted as unmapped ==
PLN: "i expect filters and mutes on all and the ^41 as the mask control no?"

Every ParVagues orbit is written `dN $ gF2 $ gM3 $ ...`, and BootTidal defines
`gM3 = gMask . gMute3` — so ONE name arms TWO controls, and a "^NN" scan of the
.tidal sees neither. On 5 of the 13 OPAL tracks (do_it_right, vague_de_crime,
desire, the_revolution_will_be_sampled, electric_hammer) ^41 and all three mutes
sat DARK while live. Dark means "not mapped here"
(feedback_dark_means_unmapped_outranks_all), so the board was asserting that four
of the most-used buttons did nothing.

parse_track now resolves gMask / gMute1-3 / gM1-3 to their CCs. Role is the
neutral "fx", not the invoking orbit's role: the mutes are SHARED (bombe_dj
drives gMute3 from d4, d5 and d7), so an orbit-derived role would be whichever
orbit the parser happened to see last. A stable colour beats an arbitrary one;
refining button colour is #49/#51.

== THE NEAR-MISS WORTH RECORDING ==
I first "verified" this fix against bombe_dj and measured NO change — 28 bindings
before, 28 after — and was about to conclude the whole diagnosis was wrong.
bombe_dj is one of the 8 tracks where those CCs happen to appear literally
somewhere in the file, so it cannot show the bug at all. Running the comparison
across all 13 tracks instead of the one in front of me is what recovered it.
A single-file spot-check disproving a real bug is a worse outcome than no check.

Tests: 6 new cases in at/tests/test_lcxl_colour.py, one per SHAPE rather than per
file — gM3 lights both mask and mute, each gM variant selects only its own mute,
bare gMask/gMute names resolve, a COMMENTED helper lights nothing (same rule as a
commented ^NN — a disabled helper is not a binding), and a track arming no
mask/mute leaves those buttons dark. 42 pass in the colour suite, 165 in the
full tools suite.
parent 346c6529
......@@ -320,3 +320,58 @@ def test_publishing_never_raises_on_an_unwritable_path(tmp_path, monkeypatch):
monkeypatch.setattr(leds, "TRACK_FILE", tmp_path / "nope" / "current-track")
leds.write_current_track("whatever") # must not raise
assert leds.read_current_track() is None
# ---------------------------------------------------------------------------
# Helpers invoked BY NAME (2026-07-29)
#
# PLN, having loaded a track: "i expect filters and mutes on all and the ^41 as
# the mask control no?" He was right. Every ParVagues orbit is written
# `dN $ gF2 $ gM3 $ ...`, and gM3 resolves in BootTidal.hs to `gMask . gMute3`
# -- so ONE name arms TWO controls, and a "^NN" scan of the .tidal sees neither.
# On 5 of the 13 OPAL tracks the mask and all three mutes sat DARK while live.
# Dark means "not mapped here", so the board was claiming that four of the
# most-used buttons did nothing.
#
# The trap that made this hard to spot: on the OTHER 8 tracks those CCs happen
# to appear literally somewhere in the file, so a spot-check on one of them
# (bombe_dj) showed no bug at all and briefly "disproved" a real one. Hence a
# test per shape, not per file.
# ---------------------------------------------------------------------------
def _bindings_for(tmp_path, body):
led = _load("led_helpers", TOOLS / "lcxl-leds.py")
p = tmp_path / "t.tidal"
p.write_text(body)
return led.parse_track(p)
def test_gM3_lights_both_the_mask_and_its_mute(tmp_path):
b = _bindings_for(tmp_path, 'd1 $ gF1 $ gM3\n $ "bd*4"\n')
assert 41 in b, "gM3 includes gMask on ^41 — it must not be dark"
assert 75 in b, "gM3 includes gMute3 on ^75 — it must not be dark"
def test_each_gM_variant_selects_its_own_mute(tmp_path):
for name, mute in (("gM1", 73), ("gM2", 74), ("gM3", 75)):
b = _bindings_for(tmp_path, f'd1 $ {name}\n $ "bd*4"\n')
assert mute in b, f"{name} must light ^{mute}"
others = {73, 74, 75} - {mute}
assert not (others & set(b)), f"{name} must not light {others}"
def test_bare_gMute_and_gMask_names_are_also_resolved(tmp_path):
b = _bindings_for(tmp_path, 'd1 $ gMask $ gMute2 $ "bd*4"\n')
assert 41 in b and 74 in b
def test_a_commented_out_helper_does_not_light_anything(tmp_path):
# Same rule as commented-out ^NN: a disabled helper is not a binding.
b = _bindings_for(tmp_path, 'd1 $ gF1 -- $ gM3\n $ "bd*4"\n')
assert 75 not in b, "a commented gM3 must not light ^75"
def test_a_track_using_no_mask_or_mute_leaves_those_buttons_dark(tmp_path):
b = _bindings_for(tmp_path, 'd1 $ gF1 $ "bd*4"\n')
assert not ({41, 73, 74, 75} & set(b)), \
"dark = not mapped here; do not light a mute the track never arms"
......@@ -399,6 +399,41 @@ def parse_track(path: Path) -> dict[int, str]:
# "^93" is armed, and is invisible otherwise.
for cc in DJ_FILTERS:
bindings.setdefault(cc, "fx")
# HELPERS INVOKED BY NAME. A `^NN` scan cannot see them, and they are the most
# frequently used controls on the board.
#
# Every ParVagues orbit is written `dN $ gF2 $ gM3 $ ...`. Those names resolve,
# in BootTidal.hs, to real CCs:
# gF1/gF2/gF3 -> ^49/^50/^51 (handled above, force-lit)
# gMask -> ^41
# gMute1/2/3 -> ^73/^74/^75
# gM1/gM2/gM3 = gMask . gMute{1,2,3} -- so ONE name arms TWO controls
#
# PLN, on loading bombe_dj (2026-07-29): "i expect filters and mutes on all and
# the ^41 as the mask control no?" He was right and the board was lying: the
# three mutes were live on every orbit of that track and all three sat dark,
# because the file never writes "^73" anywhere — it writes gM1. Dark means "not
# mapped here" ([[feedback_dark_means_unmapped_outranks_all]]), so three of the
# most-used buttons were claiming to do nothing.
#
# Same family as [[feedback_boot_helpers_are_invisible]]: a .tidal-only scan is
# blind to every filter and mute, so PARSE the helper names too.
#
# Role is deliberately the neutral "fx" rather than the invoking orbit's role:
# these are SHARED (bombe_dj drives gMute3 from d4, d5 and d7), so an
# orbit-derived role would just be whichever orbit the parser saw last — a
# stable colour beats an arbitrary one. Refining the button colour language is
# #49/#51.
whole = "\n".join(re.sub(r"--.*$", "", ln) for ln in raw)
for name in set(re.findall(r"\b(gMask|gMute[123]|gM[123])\b", whole)):
if name == "gMask":
bindings.setdefault(41, "fx")
elif name.startswith("gMute"):
bindings.setdefault(72 + int(name[-1]), "fx")
else: # gM1/gM2/gM3 = mask + one mute
bindings.setdefault(41, "fx")
bindings.setdefault(72 + int(name[-1]), "fx")
return bindings
......@@ -982,28 +1017,50 @@ def cmd_watch(s: Sender, track: str | None, reassert: float = 30.0,
if reassert:
threading.Thread(target=reassert_loop, daemon=True).start()
def _mtime(t) -> float:
try:
return Path(t).stat().st_mtime if t else 0.0
except OSError:
return 0.0
def follow_loop() -> None:
"""Repaint when the loaded track changes.
"""Repaint when the loaded track changes OR when its file is edited.
Polled, not watched: a 1s poll of one small file costs nothing measurable,
Polled, not watched: a 1s poll of two small stats costs nothing measurable,
and inotify would add a dependency plus a whole class of "the watch died
and nobody noticed" failure — which is precisely this rig's signature bug
(a binding resolved once and never rechecked).
The MTIME half is not a nicety. Following only track CHANGES means the
board reflects the file as it was the moment it was opened — and this is
livecoding, so the file changes constantly. On 2026-07-29 the column
migration renumbered bombe_dj's knobs while the watcher had held a parse
from 66 minutes earlier; PLN saw C5+C6 lit and C4 dark, which was a
faithful picture of a file that no longer existed, with nothing anywhere
reporting a problem. Same family as [[feedback_stale_binding_pattern]]:
resolved once, invalidated by an event, no error. Re-stat and re-parse.
"""
while True:
time.sleep(1.0)
t = read_current_track()
if t == state["track"]:
mt = _mtime(t)
if t == state["track"] and mt == state.get("mtime"):
continue
same_track = (t == state["track"])
try:
state["bindings"] = load_bindings(t, quiet=not verbose)
except Exception as e: # a bad path must not kill LEDs
print(f"lcxl-leds: cannot bind {t}: {e}", file=sys.stderr)
state["mtime"] = mt # don't re-parse a broken file
continue
state["track"] = t
state["mtime"] = mt
# Touched-state is per-track: carrying it across would claim you had
# already worked controls on a track you just opened. Values persist —
# the knobs did not physically move.
# the knobs did not physically move. But an EDIT to the track already
# loaded is not a new track: keep what the hands have touched, or a
# ctrl+S mid-set would wipe the one thing this display is for.
if not same_track:
touched.clear()
painter.set_frame(build_frame(state["bindings"], dict(values),
set(touched)))
......
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