Commit 2acbd9cd by PLN (Algolia)

fix(lcxl): the watcher paints the LOADED track, so dead knobs go DARK (#78)

PLN, after the value ramp landed: "now all knobs have lights, i cant trust
anymore 'is there sth mapped there or not?' knobs should always be no-lit if they
are not-mapped, in that track, to help not touch dead controls".

The interesting part is that the colour code was never wrong. build_frame()
already paints only the CCs present in `bindings` and leaves everything else OFF.
The lie was in WHICH BINDINGS IT WAS HANDED: the watcher runs with no track
argument, so load_bindings(None) returns the CONVENTION board — all 40 controls
lit by lane role, regardless of what is actually loaded. That was equally untrue
before the ramp; dim role-hue just made it easy to ignore, and saturated colour
made the board finally READ as "everything here is live".

So the feature did not create the problem, it made a pre-existing lie legible —
and fixing the colour back would have hidden the fault again.

MEASURED:
    convention board (what the watcher painted)   40/40 lit
    do_it_right                                   19/40   -> 21 dead knobs lit
    wap                                           25/40   -> 15
    desire                                        22/40   -> 18
Fifteen to twenty-one controls were glowing on every track while doing nothing.
On a dark stage that is a knob you reach for and a change you do not get.

The fix is a published current-track file (~/.cache/parvagues/current-track):
  * `--map TRACK` now PUBLISHES as well as paints. Without that, a one-shot paint
    at boot showed the right board for 30 seconds and then the watcher's re-assert
    overwrote it with the convention paint — the same board, lying again. One
    writer, one meaning.
  * `--watch` with no pinned track follows the file on a 1s poll and repaints on
    change. Polled rather than inotify on purpose: a watch that dies unnoticed is
    exactly this rig's signature failure (a binding resolved once, never
    rechecked), and one stat/second of a small file costs nothing measurable.
  * touched-state clears on track change (carrying it would claim you had already
    worked controls on a track you just opened); VALUES persist, because the knobs
    did not physically move.
  * bindings moved into a shared box — three reads in the aseqdump loop and the
    re-assert thread would otherwise have kept painting the previous track's map.

load_bindings(None) now also SAYS it is painting a board that may be lying,
instead of reporting "40 controls lit by lane role" as though that were good news.

The remaining gap, deliberately not closed here: nothing publishes the track when
PLN ctrl+enters a file in Pulsar — only `tidal-remote boot` does. The clean signal
is a five-line hook in the HUD package, which already tracks the active .tidal.
Filed rather than rushed the day before rehearsals.

458 tests (was 453). NOTE: the running watcher must be RESTARTED to pick this up;
not done now, because PLN is about to play and a dark board mid-set beats a
correct board that arrived by surprise.
parent 81aecc96
...@@ -267,3 +267,56 @@ def test_sysex_roundtrip_through_the_mock_surface(): ...@@ -267,3 +267,56 @@ def test_sysex_roundtrip_through_the_mock_surface():
def test_mock_never_reports_a_forbidden_fader_led(): def test_mock_never_reports_a_forbidden_fader_led():
"""CC77-84 are faders: no LEDs at all. They must not appear in a frame.""" """CC77-84 are faders: no LEDs at all. They must not appear in a frame."""
assert "D" not in leds.ROW_BASE assert "D" not in leds.ROW_BASE
# --------------------------------------------------------------------------- #
# #78 — the watcher must paint the LOADED track, not the convention board
#
# "dark = this control does nothing on this track" outranks every other use of
# colour here, because a lit dead knob gets reached for mid-set. The watcher ran
# with no track, so it painted the convention board — all 40 lit by lane role,
# regardless of what was loaded. build_frame() was always correct; the LIE was in
# which bindings it was handed.
# --------------------------------------------------------------------------- #
REPO = TOOLS.parent
def _lit(bindings):
return sum(1 for c in leds.build_frame(bindings, {}, set()) if c != leds.OFF)
def test_the_convention_board_lights_EVERYTHING_which_is_the_bug():
assert _lit(leds.load_bindings(None, quiet=True)) == leds.LIT_INDICES
def test_a_real_track_leaves_a_lot_of_the_board_dark():
lit = _lit(leds.load_bindings(str(REPO / "live/techno/do_it_right.tidal"),
quiet=True))
# measured 19/40 the day this landed; assert the SHAPE, not the exact number,
# so an edit to the track is not a test failure.
assert 10 <= lit <= 30, lit
assert lit < leds.LIT_INDICES
def test_current_track_round_trips(tmp_path, monkeypatch):
monkeypatch.setattr(leds, "TRACK_FILE", tmp_path / "current-track")
assert leds.read_current_track() is None
leds.write_current_track("live/techno/do_it_right.tidal")
assert leds.read_current_track() == "live/techno/do_it_right.tidal"
def test_an_empty_published_track_reads_as_none(tmp_path, monkeypatch):
"""A truncated/blank cache file must mean 'nobody said', not a track named ''."""
f = tmp_path / "current-track"
f.write_text("\n")
monkeypatch.setattr(leds, "TRACK_FILE", f)
assert leds.read_current_track() is None
def test_publishing_never_raises_on_an_unwritable_path(tmp_path, monkeypatch):
"""LEDs are feedback, not sound: a broken cache must not break a boot."""
(tmp_path / "nope").write_text("i am a file, not a directory")
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
...@@ -100,6 +100,7 @@ If sends start failing while input still arrives, that is a USB OUT endpoint sta ...@@ -100,6 +100,7 @@ If sends start failing while input still arrives, that is a USB OUT endpoint sta
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import os
import re import re
import shutil import shutil
import subprocess import subprocess
...@@ -841,12 +842,40 @@ def resolve_track(raw: str) -> Path: ...@@ -841,12 +842,40 @@ def resolve_track(raw: str) -> Path:
raise SystemExit(2) raise SystemExit(2)
# Where the currently-loaded track is published, so the LED watcher can paint what
# is ACTUALLY mapped instead of the convention board. One line, one path.
TRACK_FILE = Path(os.path.expanduser("~/.cache/parvagues/current-track"))
def read_current_track() -> str | None:
"""The track the rig is playing, or None if nobody has said."""
try:
t = TRACK_FILE.read_text().strip()
except OSError:
return None
return t or None
def write_current_track(track: str) -> None:
"""Publish the loaded track. Best-effort: this must never break a boot."""
try:
TRACK_FILE.parent.mkdir(parents=True, exist_ok=True)
TRACK_FILE.write_text(str(track) + "\n")
except OSError:
pass
def load_bindings(track: str | None, quiet: bool = False) -> dict[int, str]: def load_bindings(track: str | None, quiet: bool = False) -> dict[int, str]:
if not track: if not track:
b = convention_bindings() b = convention_bindings()
if not quiet: if not quiet:
# This paint is a LIE and must say so. It lights all 40 lane-role
# controls regardless of what is loaded, so a knob that does nothing
# on this track still glows — and PLN reaches for it mid-set. "Dark =
# not mapped here" outranks every other use of colour on this board.
print(f"lcxl-leds: no track given → CONVENTION paint " print(f"lcxl-leds: no track given → CONVENTION paint "
f"({len(b)} controls lit by lane role)") f"({len(b)} controls lit by lane role) — WARNING: this lights "
f"controls that may do NOTHING on the loaded track (#78)")
return b return b
path = resolve_track(track) path = resolve_track(track)
b = parse_track(path) b = parse_track(path)
...@@ -866,6 +895,12 @@ def load_bindings(track: str | None, quiet: bool = False) -> dict[int, str]: ...@@ -866,6 +895,12 @@ def load_bindings(track: str | None, quiet: bool = False) -> dict[int, str]:
def cmd_map(s: Sender, track: str | None, values: dict[int, int] | None = None, def cmd_map(s: Sender, track: str | None, values: dict[int, int] | None = None,
quiet: bool = False) -> int: quiet: bool = False) -> int:
# "Paint for this track" also PUBLISHES it, so the persistent --watch daemon
# follows along. Without this, a one-shot --map at boot showed the right board
# for a moment and the watcher's next re-assert (30s) overwrote it with the
# convention paint — the same board, lying again. One writer, one meaning.
if track:
write_current_track(track)
bindings = load_bindings(track, quiet=quiet) bindings = load_bindings(track, quiet=quiet)
values = values or {} values = values or {}
frame = build_frame(bindings, values, set(values)) frame = build_frame(bindings, values, set(values))
...@@ -908,15 +943,23 @@ def cmd_watch(s: Sender, track: str | None, reassert: float = 30.0, ...@@ -908,15 +943,23 @@ def cmd_watch(s: Sender, track: str | None, reassert: float = 30.0,
print("lcxl-leds: FAIL — aseqdump not found (alsa-utils)", file=sys.stderr) print("lcxl-leds: FAIL — aseqdump not found (alsa-utils)", file=sys.stderr)
return 2 return 2
bindings = load_bindings(track, quiet=not verbose) # Follow the loaded track when the caller did not pin one. Without this the
# watcher paints the CONVENTION board forever — 40 controls lit by lane role,
# most of which are unmapped on any given track. That is #78: a lit dead knob
# is worse than no colour, because it gets touched mid-set.
follow = track is None
current = track or read_current_track()
bindings = load_bindings(current, quiet=not verbose)
values: dict[int, int] = {} values: dict[int, int] = {}
touched: set[int] = set() touched: set[int] = set()
# bindings is read by two other threads; hand them a box, not a rebindable name.
state = {"bindings": bindings, "track": current}
# ONE thread owns the wire. The reader below only ever touches the model and hands # ONE thread owns the wire. The reader below only ever touches the model and hands
# colours to the painter, so a slow write can no longer stall the read of the next # colours to the painter, so a slow write can no longer stall the read of the next
# MIDI event -- which is what made the board lag behind PLN's hands (#71). # MIDI event -- which is what made the board lag behind PLN's hands (#71).
painter = Painter(s, fps=fps).start() painter = Painter(s, fps=fps).start()
painter.set_frame(build_frame(bindings, values, touched)) painter.set_frame(build_frame(state["bindings"], values, touched))
if verbose: if verbose:
print("lcxl-leds --watch: initial frame painted; listening (Ctrl-C to stop)") print("lcxl-leds --watch: initial frame painted; listening (Ctrl-C to stop)")
...@@ -933,11 +976,41 @@ def cmd_watch(s: Sender, track: str | None, reassert: float = 30.0, ...@@ -933,11 +976,41 @@ def cmd_watch(s: Sender, track: str | None, reassert: float = 30.0,
def reassert_loop() -> None: def reassert_loop() -> None:
while reassert: while reassert:
time.sleep(reassert) time.sleep(reassert)
painter.set_frame(build_frame(bindings, dict(values), set(touched))) painter.set_frame(build_frame(state["bindings"], dict(values),
set(touched)))
if reassert: if reassert:
threading.Thread(target=reassert_loop, daemon=True).start() threading.Thread(target=reassert_loop, daemon=True).start()
def follow_loop() -> None:
"""Repaint when the loaded track changes.
Polled, not watched: a 1s poll of one small file 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).
"""
while True:
time.sleep(1.0)
t = read_current_track()
if t == state["track"]:
continue
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)
continue
state["track"] = t
# 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.
touched.clear()
painter.set_frame(build_frame(state["bindings"], dict(values),
set(touched)))
if follow:
threading.Thread(target=follow_loop, daemon=True).start()
while True: while True:
port = find_seq_port() # re-resolved on every (re)spawn, never cached port = find_seq_port() # re-resolved on every (re)spawn, never cached
if port is None: if port is None:
...@@ -983,13 +1056,13 @@ def cmd_watch(s: Sender, track: str | None, reassert: float = 30.0, ...@@ -983,13 +1056,13 @@ def cmd_watch(s: Sender, track: str | None, reassert: float = 30.0,
# colour of FOUR other indices at once, so this is the one # colour of FOUR other indices at once, so this is the one
# event that cannot be served by a single-index paint. Rebuild # event that cannot be served by a single-index paint. Rebuild
# the frame; the painter coalesces it like any other write. # the frame; the painter coalesces it like any other write.
painter.set_frame(build_frame(bindings, dict(values), painter.set_frame(build_frame(state["bindings"],
set(touched))) dict(values), set(touched)))
if verbose: if verbose:
print(f" ^93 PANIC {'ARMED — gPanic streams are SILENT' print(f" ^93 PANIC {'ARMED — gPanic streams are SILENT'
if val else 'cleared'}") if val else 'cleared'}")
continue continue
hit = resolve_paint(cc, val, bindings, ack_unbound, hit = resolve_paint(cc, val, state["bindings"], ack_unbound,
panic=bool(values.get(PANIC_CC))) panic=bool(values.get(PANIC_CC)))
# Print only when the BOARD changes. Logging every event was itself # 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 = # a per-event stdout write in the hot loop, and 400 CC/s of "CC77 =
...@@ -998,7 +1071,7 @@ def cmd_watch(s: Sender, track: str | None, reassert: float = 30.0, ...@@ -998,7 +1071,7 @@ def cmd_watch(s: Sender, track: str | None, reassert: float = 30.0,
row, lane = decode_cc(cc) row, lane = decode_cc(cc)
print(f" CC{cc:<3d} {row}{lane} = {val:<3d} " print(f" CC{cc:<3d} {row}{lane} = {val:<3d} "
f"-> idx {hit[0]:02X} colour {hit[1]}" f"-> idx {hit[0]:02X} colour {hit[1]}"
f"{'' if cc in bindings else ' [unbound]'}") f"{'' if cc in state['bindings'] else ' [unbound]'}")
except KeyboardInterrupt: except KeyboardInterrupt:
proc.terminate() proc.terminate()
painter.flush() painter.flush()
......
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