Commit 56fa3937 by PLN (Algolia)

surface: the v3 map belongs to the grid, so any reader of the board is right

The translation from the LCXL3's DAW-mode numbering to the corpus's lived
inside lcxl3-driver.py, which made the driver the only process able to read
the board correctly. Everything else that fell through to the raw port drew
v3 indices into v2 cells: row C's knobs lit row B, the faders lit nothing at
all and rained instead, E5-E8 lit E1-E4. One cause, four symptoms, and a
picture confident enough that the drawing looked like the bug.

The table is now lcxl_grid.V3_ROWS / V3_TO_V2 and midiviz translates for
itself whenever it is on the raw port, so the cells and the rain are correct
whether or not the driver is up. The driver keeps its own copy: it is the
surface's nervous system and this is the day before a gig. Two tests hold the
two copies together instead.
parent c625c25c
......@@ -1491,6 +1491,16 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0,
if e.startswith("control") and ev.get("controller") is not None:
cc = int(ev["controller"])
# Belt AND suspenders (PLN, 2026-09-23: "in my perspective, we
# would have always cell and rain?"). The preferred port is the
# driver's translated one, but when we fall through to the raw
# board the numbers are v3 DAW indices, and drawing them as if
# they were corpus CCs is how row C's knobs lit row B, row D lit
# nothing, and E5-E8 lit E1-E4 for as long as the driver was
# down. A viewer that owns the translation is right either way,
# so the picture no longer depends on another process running.
if self.raw_port:
cc = grid.V3_TO_V2.get(cc, cc)
cell = grid.CC_TO_CELL.get(cc)
if cell is not None:
st = self.cc.get(cc)
......
......@@ -542,3 +542,53 @@ def test_the_publisher_omits_a_fader_it_has_never_observed(tmp_path, monkeypatch
Driver.state_write(pub, now=2.0, force=True)
got = json.loads((tmp_path / "surface-state.json").read_text())["values"]
assert got[str(fader)] == 100, "an observed fader must be published"
# ── belt AND suspenders: the viewer owns the translation ────────────────────
# PLN, 2026-09-23: "wat is this WATCH_PREFERENCE logic? im my perspective, we
# would have always cell and rain? belt and sus in viz :P" -- correct. The
# preferred source is the driver's translated port, but a fallthrough to the raw
# board used to put v3 DAW indices straight into v2 cells.
def test_a_raw_port_move_lands_in_the_cell_the_finger_touched(tmp_path):
mv, w = _offscreen_widget(MV, tmp_path / "absent.json")
try:
w.raw_port = True
# C1 on the hardware sends v3 CC 29. Untranslated that is row C's *v2*
# number for a different cell -- the exact mislanding PLN saw as
# "it only moves on abc".
v3 = grid.V3_ROWS["C"][0]
v2 = grid.V3_TO_V2[v3]
w.ingest(_cc_event(v3, 77))
assert v2 in w.cc, "the corpus cell was never lit"
assert v3 not in w.cc or v3 == v2, "the raw index lit a cell of its own"
assert w.cc[v2][0] == 77
assert w.last_cc[0] == v2, "the header must offer the token you can type"
finally:
w.close()
def test_the_translated_port_is_left_exactly_alone(tmp_path):
mv, w = _offscreen_widget(MV, tmp_path / "absent.json")
try:
w.raw_port = False # the driver is up; numbers are ours
cc = grid.ROW_CCS["C"][0]
w.ingest(_cc_event(cc, 77))
assert cc in w.cc and w.cc[cc][0] == 77
finally:
w.close()
def test_row_d_is_no_longer_rain_on_the_raw_port(tmp_path):
# Row D (the faders) sends v3 5-12, which the authored grid does not own at
# all -- so every fader move used to spawn a raindrop and light no cell.
mv, w = _offscreen_widget(MV, tmp_path / "absent.json")
try:
w.raw_port = True
for v3 in grid.V3_ROWS["D"]:
w.ingest(_cc_event(v3, 64))
lit = {grid.V3_TO_V2[c] for c in grid.V3_ROWS["D"]}
assert lit <= set(w.cc), "faders still fail to light their own row"
assert all(grid.CC_TO_CELL[c][0] == "D" for c in lit)
finally:
w.close()
......@@ -191,6 +191,52 @@ for _row, _specs in _ROLES.items():
ARDOUR_CCS: set[int] = {cc for r in ARDOUR_ROWS for cc in ROW_CCS[r]} | set(ARDOUR_EXTRA)
KNOB_CCS: set[int] = set(ROW_CCS["A"]) | set(ROW_CCS["B"]) | set(ROW_CCS["C"])
BUTTON_CCS: set[int] = set(ROW_CCS["E"]) | set(ROW_CCS["F"])
# ── the v3 DAW-mode surface, positionally ──────────────────────────────────
# The LCXL3 in DAW mode speaks its OWN fixed CC numbering, which is not the
# corpus's. `lcxl3-driver.py` translates it on the way through and publishes the
# result as the virtual port 'ParVagues LCXL3'. The table lived only inside the
# driver, so anything ELSE reading the board direct saw v3 numbers land in v2 cells:
# midiviz drew row C's knobs into row B, row D into nothing at all, and E5-E8
# into E1-E4 -- a coherent, confident, wrong picture, for as long as the driver
# was down (2026-09-22; the driver had in fact failed 1077 times).
#
# So the table belongs to the GRID, not to one of its readers. Verified against
# the hardware by moving one control per row and reading what arrived
# (fader1->cc5 ch16, A1->13 ch16, B1->21 ch16, C1->29 ch16, E1->37 ch1,
# F1->45 ch1).
V3_ROWS: dict[str, list[int]] = {
"A": list(range(13, 21)),
"B": list(range(21, 29)),
"C": list(range(29, 37)),
"D": list(range(5, 13)),
"E": list(range(37, 45)),
"F": list(range(45, 53)),
}
def build_v3_map() -> dict[int, int]:
"""v3 DAW CC -> v2 corpus CC, positionally, straight out of the grid.
Raises rather than guesses: a row whose hardware width disagrees with the
authored width is exactly what must not be papered over, because the result
would be a silently shifted row rather than an error.
"""
out: dict[int, int] = {}
for row, v3ccs in V3_ROWS.items():
v2ccs = ROW_CCS.get(row)
if not v2ccs:
raise KeyError(f"lcxl_grid has no row {row!r}")
if len(v2ccs) != len(v3ccs):
raise ValueError(
f"row {row} has {len(v2ccs)} v2 cells but {len(v3ccs)} v3 "
"indices -- the grid and the hardware disagree"
)
out.update(zip(v3ccs, v2ccs))
return out
V3_TO_V2: dict[int, int] = build_v3_map()
FAMILY_CCS: set[int] = {cc for cc, (role, _n) in CC_ROLE.items()
if role.startswith("family_")} | {PANIC_CC}
......
"""The v3->v2 surface map has exactly one author.
Written 2026-09-23, the morning after four separate midiviz "bugs" turned out to
be one missing translation. The table had lived inside `lcxl3-driver.py`, so the
driver was the only process that could read the board correctly -- and when the
driver was down (it had failed 1077 consecutive times) every other reader drew a
coherent, confident, wrong picture.
The table now belongs to `lcxl_grid`. The driver keeps its own copy for now, on
purpose: it is the surface's nervous system and this is the day before a gig, so
it is not being refactored. These tests are the price of that decision -- if the
two ever disagree, the suite says so instead of the stage.
"""
import importlib.util
import pathlib
import sys
import pytest
TOOLS = pathlib.Path(__file__).resolve().parents[1]
if str(TOOLS) not in sys.path:
sys.path.insert(0, str(TOOLS))
import lcxl_grid as grid # noqa: E402
def _load_driver():
spec = importlib.util.spec_from_file_location(
"lcxl3_driver_under_test", TOOLS / "lcxl3-driver.py")
mod = importlib.util.module_from_spec(spec)
try:
spec.loader.exec_module(mod)
except SystemExit as exc: # pragma: no cover
pytest.skip(f"driver refused to import: {exc}")
return mod
def test_the_driver_and_the_grid_agree_row_for_row():
drv = _load_driver()
assert drv.V3_ROWS == grid.V3_ROWS, (
"the driver's hardware row table has drifted from the grid's. One of "
"them is now lying to whoever reads the board through it."
)
def test_the_two_built_maps_are_identical():
drv = _load_driver()
assert drv.build_map() == grid.V3_TO_V2
def test_every_physical_control_translates():
# 48 cells: 8 faders, 24 encoders, 16 buttons. A hole here is a control the
# viewer would draw at its raw v3 number, i.e. in the wrong cell.
assert len(grid.V3_TO_V2) == 48
assert set(grid.V3_TO_V2.values()) == set(grid.CC_TO_CELL)
def test_row_a_is_the_coincidence_that_hid_the_bug():
# A1-A8 have the same numbers in both dialects, which is why "it only moves
# on abc" looked like a partial failure rather than a total one.
assert [grid.V3_TO_V2[c] for c in grid.V3_ROWS["A"]] == grid.V3_ROWS["A"]
@pytest.mark.parametrize("row", ["B", "C", "D", "E", "F"])
def test_every_other_row_really_does_move(row):
assert [grid.V3_TO_V2[c] for c in grid.V3_ROWS[row]] != grid.V3_ROWS[row]
def test_a_row_whose_width_disagrees_is_an_error_not_a_shift():
saved = dict(grid.ROW_CCS)
try:
grid.ROW_CCS["D"] = saved["D"][:-1] # seven cells for eight knobs
with pytest.raises(ValueError, match="row D"):
grid.build_v3_map()
finally:
grid.ROW_CCS.clear()
grid.ROW_CCS.update(saved)
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