Commit 2778f1cd by PLN (Algolia)

refactor(surface): ONE authored grid — five hardcoded copies become derived or generated (#97)

PLN named this himself while testing the remap: "i see the hud shows d9 on A8 so
didnt we migrate parvagues HUD to new convention? why 2 sources of truth tbh"

It was five. The CC -> physical-control -> owning-orbit mapping was hardcoded
independently in:

    tools/surface-columns.py     GRID
    tools/migrate-columns.py     KNOB_A/B/C, BUTTONS, ARDOUR, label(), and the
                                 destination ARITHMETIC (28 + orbit, 48 + orbit)
    tools/lcxl-leds.py           12 + (orbit - 8) for the d9-d12 level knobs
    tools/pvlint/rules.py        PV008's BT_CCS / BL_CCS / FAMILY_CCS
    <hud>/lib/render.js          ORBIT_CONVENTION

After the 2026-07-29 remap the Python copies moved and the HUD's did not, so the
topbar drew d9 on A8 while the LED board and the .tidal files both said A1. The
display contradicted the hardware under his hands, mid-test, and NOTHING FAILED —
which is the property that guaranteed it would happen again on the next remap.

NOW
tools/lcxl_grid.py is the only place the grid is written: the row/column table,
the role of every cell (level / fx / fx2 / gate / gate2 / family_filter /
family_mute), the Ardour-owned set, and gPanic. Everything else derives from it,
and the two non-Python consumers read a GENERATED artifact:

    tools/lcxl_grid.json                        for anything outside Python
    <hud>/lib/lcxl-grid.generated.js            imported by render.js

Same pattern as the fleet colour language (models.py -> gen_tokens -> tokens.css):
author the ontology once in Python, generate for every other language.

Two details worth keeping:
  * ROW ALIASES. The consumers had each invented their own names — D/fader,
    E/btn1/BT, F/btn2/BL. Forcing one vocabulary would have churned five files
    and PLN's own muscle memory for zero benefit, so every row carries all its
    names and each tool keeps printing what it always printed.
  * ROWS E AND F ARE ONE ROW OF EIGHT, not two of four. They are non-contiguous
    on the hardware (41-44 then 57-60) and modelling that as two rows is exactly
    what put d6's second button in column 5 in the old map. Asserted directly.

THE TEST IS THE DELIVERABLE
14 new tests. Half assert the authored table is coherent (48 controls, every row
covers columns 1-8, every orbit has a level/fx/gate, no Tidal slot lands on an
Ardour-learned control, d1-d3 own exactly one knob and one button because C1-3
and F1-3 are the family controls). The other half assert every CONSUMER still
agrees, and that regenerating the artifacts is a no-op — so a remap that forgets
one copy fails the suite instead of shipping a lying topbar. One test simply
checks render.js has not re-grown a literal ORBIT_CONVENTION.

VALIDATION — behaviour must be bit-identical, this is a refactor
  surface-columns    83/83 aligned, 0 renumbers   (unchanged)
  migrate-columns    --plan: 0 moves, 0 overflow  (nothing left to do)
  pvlint             13 tracks, 0 errors, 9 pre-existing warnings
  pytest             490 passed (was 476 + 14 new)
  HUD specs          smoke / lcxl-leds / scene-directive all pass
  lcxl_grid --check  48 controls, every row 1-8, every orbit housed

Also fixed while here: the LED watcher was still running the process started at
16:39, i.e. code from before the A1-lights-for-d9 feature existed. That is why
PLN saw no A1 LED while d9 was declared — not a mapping bug, a stale daemon.
Restarted. Worth remembering as its own class: for gear that runs as a service,
"I fixed the code" is not "the rig picked it up".
parent 3060f506
...@@ -109,6 +109,9 @@ import threading ...@@ -109,6 +109,9 @@ import threading
import time import time
from pathlib import Path from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import lcxl_grid # noqa: E402 — the one authored grid (#97)
REPO = Path(__file__).resolve().parent.parent REPO = Path(__file__).resolve().parent.parent
TEMPLATE = 0x00 # User 1 TEMPLATE = 0x00 # User 1
...@@ -444,11 +447,17 @@ def parse_track(path: Path) -> dict[int, str]: ...@@ -444,11 +447,17 @@ def parse_track(path: Path) -> dict[int, str]:
# #
# Role is the orbit's own, so the colour matches that orbit's family — these # Role is the orbit's own, so the colour matches that orbit's family — these
# are per-orbit by construction, unlike the shared filters and mutes. # are per-orbit by construction, unlike the shared filters and mutes.
# The level CC comes from the authored grid (#97), not from `12 + (orbit - 8)`
# arithmetic repeated here. That arithmetic IS the convention, and every copy
# of it is a place the board can start disagreeing with the surface — which is
# exactly how the HUD ended up drawing d9 on A8.
declared = {int(m.group(1)) for ln in raw declared = {int(m.group(1)) for ln in raw
if (m := BLOCK_ORBIT.match(ln)) is not None} if (m := BLOCK_ORBIT.match(ln)) is not None}
for orbit in declared: for orbit in declared:
if 9 <= orbit <= 12: if 9 <= orbit <= 12:
bindings.setdefault(12 + (orbit - 8), classify_sound(None, orbit)) cc = lcxl_grid.orbit_home(orbit).get("level")
if cc is not None:
bindings.setdefault(cc, classify_sound(None, orbit))
return bindings return bindings
......
...@@ -73,13 +73,17 @@ SETLIST = ROOT / "armada" / "setlist_opal2026.txt" ...@@ -73,13 +73,17 @@ SETLIST = ROOT / "armada" / "setlist_opal2026.txt"
CC_REF = re.compile(r'"\^(\d+)"') CC_REF = re.compile(r'"\^(\d+)"')
KNOB_A = set(range(13, 21)) # All DERIVED from the single authored grid (#97) — this file used to hardcode the
KNOB_B = set(range(29, 37)) # CC ranges AND the destination arithmetic (`28 + orbit`, `48 + orbit`), which is
KNOB_C = set(range(49, 57)) # exactly the duplication that let the HUD's copy go stale after the remap.
KNOBS = KNOB_A | KNOB_B | KNOB_C import lcxl_grid # noqa: E402
# Hands off, for the reasons in the docstring.
ARDOUR = set(range(77, 85)) | {13, 14, 15, 16} KNOB_A = set(lcxl_grid.ROW_CCS["A"])
BUTTONS = set(range(41, 45)) | set(range(57, 61)) | set(range(73, 77)) | set(range(89, 93)) KNOB_B = set(lcxl_grid.ROW_CCS["B"])
KNOB_C = set(lcxl_grid.ROW_CCS["C"])
KNOBS = lcxl_grid.KNOB_CCS
ARDOUR = lcxl_grid.ARDOUR_CCS # hands off, for the reasons in the docstring
BUTTONS = lcxl_grid.BUTTON_CCS
def helper_ccs() -> set[int]: def helper_ccs() -> set[int]:
...@@ -96,46 +100,36 @@ def helper_ccs() -> set[int]: ...@@ -96,46 +100,36 @@ def helper_ccs() -> set[int]:
return {int(m) for m in CC_REF.findall(text)} return {int(m) for m in CC_REF.findall(text)}
_ROW_NAME = {"E": "BT", "F": "BL"} # this tool prints buttons as BT/BL
def label(cc: int) -> str: def label(cc: int) -> str:
"""'^35' -> 'B7'. The one place CC->physical-control naming lives here.""" """44 -> 'BT4'. This tool's row vocabulary, from the authored grid."""
for base, row in ((12, "A"), (28, "B"), (48, "C"), cell = lcxl_grid.CC_TO_CELL.get(int(cc))
(40, "BT"), (52, "BT"), (72, "BL"), (84, "BL")): if not cell:
col = cc - base
if 1 <= col <= 8:
if row == "BT" and not (41 <= cc <= 44 or 57 <= cc <= 60):
continue
if row == "BL" and not (73 <= cc <= 76 or 89 <= cc <= 92):
continue
if row in ("A", "B", "C") and col > 8:
continue
return f"{row}{col}"
return f"cc{cc}" return f"cc{cc}"
row, col = cell
return f"{_ROW_NAME.get(row, row)}{col}"
def knob_slots(orbit: int) -> list[int]: def knob_slots(orbit: int) -> list[int]:
"""The knob destinations an orbit owns, in order of reachability. """The knob destinations an orbit owns, most reachable first.
B<col> first (row B is per-orbit for all 8 columns), then C<col> — but C1/C2/C3 B<col> then C<col> — but C1/C2/C3 are gF1/gF2/gF3, the per-FAMILY DJ filters,
are gF1/gF2/gF3, the per-FAMILY DJ filters, so d1..d3 own exactly one knob. so d1..d3 own exactly one knob. Derived, not arithmetic: the old
`28 + orbit` / `48 + orbit` encoded that rule a second time.
""" """
slots = [28 + orbit] return lcxl_grid.slots(orbit, "knob")
if orbit >= 4:
slots.append(48 + orbit)
return slots
def button_slots(orbit: int) -> list[int]: def button_slots(orbit: int) -> list[int]:
"""The button destinations an orbit owns, in order of reachability. """The button destinations an orbit owns, most reachable first.
BT (top button row) is per-orbit across all 8 columns: 41-44 then 57-60. BT is per-orbit across all 8 columns. BL columns 1-3 are gMute1/2/3, the
BL (bottom row) columns 1-3 are gMute1/gMute2/gMute3, the per-family mutes, per-family mutes, so d1..d3 own exactly ONE button — the structural reason a
so d1..d3 own exactly ONE button. That is the structural reason a two-button two-button gesture on d1 cannot survive column alignment.
gesture on d1 cannot survive column alignment — see the clash report.
""" """
slots = [40 + orbit if orbit <= 4 else 52 + orbit] # 41..44, 57..60 return lcxl_grid.slots(orbit, "button")
if orbit >= 4:
slots.append(72 + orbit if orbit <= 4 else 84 + orbit) # 76, 89..92
return slots
def _seen_ccs(blocks: list, pool: set[int], helpers: set[int]) -> list[tuple[int, int]]: def _seen_ccs(blocks: list, pool: set[int], helpers: set[int]) -> list[tuple[int, int]]:
......
...@@ -414,11 +414,24 @@ def pv007_orbit_inventory(track: Track) -> Iterable[Finding]: ...@@ -414,11 +414,24 @@ def pv007_orbit_inventory(track: Track) -> Iterable[Finding]:
# PV008 — one physical button driving two orbits # PV008 — one physical button driving two orbits
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
# BT (top button row) and BL (bottom). BL1-3 are gMute1/2/3, the per-family # DERIVED from the one authored grid (#97). Hardcoding these was how the HUD's
# mutes, so they are SUPPOSED to be shared and are excluded below. # copy went stale after the 2026-07-29 remap; a lint rule silently disagreeing
BT_CCS = set(range(41, 45)) | set(range(57, 61)) # with the migrator about which CCs are buttons would be the same bug wearing a
BL_CCS = set(range(73, 77)) | set(range(89, 93)) # green checkmark. Family controls (gF1-3, gMute1-3, gPanic) come from the grid
FAMILY_CCS = {49, 50, 51, 73, 74, 75, 93} # gF1-3, gMute1-3, gPanic # too, so "shared on purpose" stays defined in exactly one place.
try:
import os as _os
import sys as _sys
_sys.path.insert(0, _os.path.dirname(_os.path.dirname(_os.path.abspath(__file__))))
import lcxl_grid as _grid
BUTTON_CCS = _grid.BUTTON_CCS
FAMILY_CCS = _grid.FAMILY_CCS
except Exception: # pragma: no cover
# pvlint must stay importable on its own — it is the pre-gig gate, and a
# missing sibling module is not a reason to leave PLN without a check.
BUTTON_CCS = set(range(41, 45)) | set(range(57, 61)) | set(range(73, 77)) | set(range(89, 93))
FAMILY_CCS = {49, 50, 51, 73, 74, 75, 93}
CC_IN_CODE = re.compile(r'"\^(\d+)"') CC_IN_CODE = re.compile(r'"\^(\d+)"')
...@@ -446,7 +459,7 @@ def pv008_button_drives_two_orbits(track: Track) -> Iterable[Finding]: ...@@ -446,7 +459,7 @@ def pv008_button_drives_two_orbits(track: Track) -> Iterable[Finding]:
for ln_off, ln in enumerate(orb.lines): for ln_off, ln in enumerate(orb.lines):
for m in CC_IN_CODE.finditer(strip_comment(ln)): for m in CC_IN_CODE.finditer(strip_comment(ln)):
cc = int(m.group(1)) cc = int(m.group(1))
if cc in (BT_CCS | BL_CCS) and cc not in FAMILY_CCS: if cc in BUTTON_CCS and cc not in FAMILY_CCS:
owners.setdefault(cc, {}).setdefault( owners.setdefault(cc, {}).setdefault(
orb.number, orb.start + ln_off) orb.number, orb.start + ln_off)
for cc, per_orbit in sorted(owners.items()): for cc, per_orbit in sorted(owners.items()):
......
...@@ -59,19 +59,18 @@ ROOT = Path(__file__).resolve().parent.parent ...@@ -59,19 +59,18 @@ ROOT = Path(__file__).resolve().parent.parent
BOOT = ROOT / "BootTidal.hs" BOOT = ROOT / "BootTidal.hs"
SETLIST = ROOT / "armada" / "setlist_opal2026.txt" SETLIST = ROOT / "armada" / "setlist_opal2026.txt"
# cc -> (column, row-label). Built from the physical grid above. # cc -> (column, row-label), DERIVED from the single authored grid (#97). This
GRID: dict[int, tuple[int, str]] = {} # used to be hardcoded here, and separately in migrate-columns, lcxl-leds, pvlint
for _n in range(1, 9): # and the HUD — five copies, of which the HUD's silently went stale after the
GRID[12 + _n] = (_n, "A") # 2026-07-29 remap and showed d9 on A8. Row labels stay this tool's own vocabulary
GRID[28 + _n] = (_n, "B") # ("fader"/"btn1"/"btn2") because they appear in output PLN reads; lcxl_grid
GRID[48 + _n] = (_n, "C") # carries the aliases so nothing has to be renamed.
GRID[76 + _n] = (_n, "fader") import lcxl_grid # noqa: E402
for _n in range(1, 5):
GRID[40 + _n] = (_n, "btn1") _ROW_LABEL = {"A": "A", "B": "B", "C": "C", "D": "fader", "E": "btn1", "F": "btn2"}
GRID[72 + _n] = (_n, "btn2") GRID: dict[int, tuple[int, str]] = {
for _n in range(5, 9): cc: (col, _ROW_LABEL[row]) for cc, (row, col) in lcxl_grid.CC_TO_CELL.items()
GRID[52 + _n] = (_n, "btn1") # 57..60 }
GRID[84 + _n] = (_n, "btn2") # 89..92
CC_REF = re.compile(r'"\^(\d+)"') CC_REF = re.compile(r'"\^(\d+)"')
......
"""The grid is authored once — these tests are what make that true (#97).
Two jobs:
1. The AUTHORED table is internally coherent (48 controls, every row 1-8,
every orbit housed, no Tidal slot on an Ardour-owned control).
2. Every DERIVED consumer still agrees with it, and the GENERATED artifacts are
up to date.
Job 2 is the one that earns its keep. Before this, the grid was hardcoded in five
places, and on 2026-07-29 the Python copies moved while the HUD's did not — the
topbar drew d9 on A8 while the LED board and the .tidal files said A1. The
display contradicted the hardware under PLN's hands and nothing failed. Now a
stale copy fails here instead.
"""
from __future__ import annotations
import importlib.util
import json
import os
import sys
import pytest
TOOLS = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, TOOLS)
import lcxl_grid as grid # noqa: E402
def _load(name: str, filename: str):
"""Import a hyphenated tool module (not a legal package name)."""
spec = importlib.util.spec_from_file_location(name, os.path.join(TOOLS, filename))
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
# ------------------------------------------------------- 1. the authored table
def test_grid_is_self_consistent():
"""The invariants of the SURFACE. A failure means the table is wrong."""
assert grid.problems() == []
def test_48_controls_no_cc_reused():
assert len(grid.CC_TO_CELL) == 48
assert len(set(grid.CC_TO_CELL)) == 48
assert len(grid.CELL_TO_CC) == 48
def test_button_rows_are_one_row_of_eight_not_two():
"""E and F are non-contiguous on the hardware (41-44 then 57-60).
Treating each half as its own row is precisely what put d6's second button in
column 5 in the old map.
"""
assert grid.ROW_CCS["E"] == [41, 42, 43, 44, 57, 58, 59, 60]
assert grid.ROW_CCS["F"] == [73, 74, 75, 76, 89, 90, 91, 92]
assert grid.CC_TO_CELL[58] == ("E", 6) # d6's gate, column 6
assert grid.CC_TO_CELL[90] == ("F", 6) # d6's gate2, column 6
def test_column_n_is_orbit_n_for_d1_to_d8():
"""The whole convention in one assertion."""
for orbit in range(1, 9):
for role in ("level", "fx", "gate"):
cc = grid.orbit_home(orbit)[role]
assert grid.CC_TO_CELL[cc][1] == orbit, f"d{orbit} {role} is off-column"
def test_d1_to_d3_own_one_knob_and_one_button():
"""C1-3 and F1-3 are the FAMILY controls, so d1-d3 are one-slot orbits.
This is the structural reason bombe_dj's two-button kick gesture is over
budget by design, and why #94 had to comment it out rather than relocate it.
"""
for orbit in (1, 2, 3):
assert len(grid.slots(orbit, "knob")) == 1
assert len(grid.slots(orbit, "button")) == 1
for orbit in range(4, 9):
assert len(grid.slots(orbit, "knob")) == 2
assert len(grid.slots(orbit, "button")) == 2
def test_no_tidal_slot_lands_on_an_ardour_control():
"""Writing CC 77-84 or 13-16 from Tidal is the goes-to-silence footgun."""
for orbit in range(1, 13):
for kind in ("knob", "button"):
for cc in grid.slots(orbit, kind):
assert cc not in grid.ARDOUR_CCS, f"d{orbit} {kind} slot {cc}"
def test_family_controls_are_the_measured_ones():
"""gF1-3 on C1-3, gMute1-3 on F1-3, plus gPanic. Measured, not assumed."""
assert {49, 50, 51} <= grid.FAMILY_CCS
assert {73, 74, 75} <= grid.FAMILY_CCS
assert grid.PANIC_CC in grid.FAMILY_CCS
# ...and nothing per-orbit leaked in
assert 41 not in grid.FAMILY_CCS # d1's gate, since gMask retired
assert 76 not in grid.FAMILY_CCS # d4's gate2
# ------------------------------------------------- 2. consumers must not drift
def test_surface_columns_grid_matches():
sc = _load("surface_columns", "surface-columns.py")
label = {"A": "A", "B": "B", "C": "C", "D": "fader", "E": "btn1", "F": "btn2"}
expect = {cc: (col, label[row]) for cc, (row, col) in grid.CC_TO_CELL.items()}
assert sc.GRID == expect
def test_migrate_columns_sets_and_slots_match():
mc = _load("migrate_columns", "migrate-columns.py")
assert mc.KNOBS == grid.KNOB_CCS
assert mc.BUTTONS == grid.BUTTON_CCS
assert mc.ARDOUR == grid.ARDOUR_CCS
for orbit in range(1, 9):
assert mc.knob_slots(orbit) == grid.slots(orbit, "knob")
assert mc.button_slots(orbit) == grid.slots(orbit, "button")
def test_migrate_columns_labels_every_control():
"""No control may print as a bare `ccNN` — that reads as "unknown" to PLN."""
mc = _load("migrate_columns", "migrate-columns.py")
for cc in grid.CC_TO_CELL:
assert not mc.label(cc).startswith("cc"), cc
assert mc.label(45) == "cc45" # genuinely off-grid stays honest
def test_pvlint_pv008_uses_the_grid():
from pvlint import rules
assert rules.BUTTON_CCS == grid.BUTTON_CCS
assert rules.FAMILY_CCS == grid.FAMILY_CCS
# ------------------------------------------------- 3. generated artifacts fresh
def test_generated_json_is_up_to_date():
"""Regenerating must be a no-op. If this fails, run --generate and commit."""
assert grid.JSON_OUT.exists(), "run: python3 tools/lcxl_grid.py --generate"
on_disk = json.loads(grid.JSON_OUT.read_text())
assert on_disk == grid.as_dict(), (
"tools/lcxl_grid.json is stale — run `python3 tools/lcxl_grid.py --generate`")
@pytest.mark.skipif(not grid.HUD_OUT.parent.is_dir(),
reason="HUD package not checked out next to this repo")
def test_generated_hud_js_is_up_to_date():
"""The copy that went stale last time. Now it is generated and checked."""
text = grid.HUD_OUT.read_text()
assert "DO NOT EDIT" in text
# raw_decode stops at the end of the first JSON value and ignores the JS that
# follows it, so this does not depend on brace-counting the whole file.
payload, _end = json.JSONDecoder().raw_decode(
text.split("export const GRID = ", 1)[1])
assert payload == grid.as_dict(), (
"the HUD's generated grid is stale — run "
"`python3 tools/lcxl_grid.py --generate` and commit both repos")
@pytest.mark.skipif(not grid.HUD_OUT.parent.is_dir(), reason="HUD not present")
def test_hud_no_longer_hardcodes_the_convention():
"""render.js must IMPORT the map, not restate it — the actual #97 fix."""
render = (grid.HUD_OUT.parent / "render.js").read_text()
assert "lcxl-grid.generated" in render
assert "const ORBIT_CONVENTION = {" not in render, (
"render.js has re-grown its own hardcoded ORBIT_CONVENTION")
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