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
......
{
"_generated_by": "tools/lcxl_grid.py — do not edit; run --generate",
"physical_order": [
"A",
"B",
"C",
"D",
"E",
"F"
],
"rows": {
"A": {
"aliases": [
"A",
"knobA",
"top"
],
"columns": {
"1": 13,
"2": 14,
"3": 15,
"4": 16,
"5": 17,
"6": 18,
"7": 19,
"8": 20
}
},
"B": {
"aliases": [
"B",
"knobB",
"mid"
],
"columns": {
"1": 29,
"2": 30,
"3": 31,
"4": 32,
"5": 33,
"6": 34,
"7": 35,
"8": 36
}
},
"C": {
"aliases": [
"C",
"knobC",
"bot"
],
"columns": {
"1": 49,
"2": 50,
"3": 51,
"4": 52,
"5": 53,
"6": 54,
"7": 55,
"8": 56
}
},
"D": {
"aliases": [
"D",
"fader"
],
"columns": {
"1": 77,
"2": 78,
"3": 79,
"4": 80,
"5": 81,
"6": 82,
"7": 83,
"8": 84
}
},
"E": {
"aliases": [
"E",
"btn1",
"BT"
],
"columns": {
"1": 41,
"2": 42,
"3": 43,
"4": 44,
"5": 57,
"6": 58,
"7": 59,
"8": 60
}
},
"F": {
"aliases": [
"F",
"btn2",
"BL"
],
"columns": {
"1": 73,
"2": 74,
"3": 75,
"4": 76,
"5": 89,
"6": 90,
"7": 91,
"8": 92
}
}
},
"cc": {
"13": {
"row": "A",
"column": 1,
"label": "A1",
"role": "level",
"owner": 9,
"ardour_owned": true
},
"14": {
"row": "A",
"column": 2,
"label": "A2",
"role": "level",
"owner": 10,
"ardour_owned": true
},
"15": {
"row": "A",
"column": 3,
"label": "A3",
"role": "level",
"owner": 11,
"ardour_owned": true
},
"16": {
"row": "A",
"column": 4,
"label": "A4",
"role": "level",
"owner": 12,
"ardour_owned": true
},
"17": {
"row": "A",
"column": 5,
"label": "A5",
"role": "fx",
"owner": 9,
"ardour_owned": false
},
"18": {
"row": "A",
"column": 6,
"label": "A6",
"role": "fx",
"owner": 10,
"ardour_owned": false
},
"19": {
"row": "A",
"column": 7,
"label": "A7",
"role": "fx",
"owner": 11,
"ardour_owned": false
},
"20": {
"row": "A",
"column": 8,
"label": "A8",
"role": "fx",
"owner": 12,
"ardour_owned": false
},
"29": {
"row": "B",
"column": 1,
"label": "B1",
"role": "fx",
"owner": 1,
"ardour_owned": false
},
"30": {
"row": "B",
"column": 2,
"label": "B2",
"role": "fx",
"owner": 2,
"ardour_owned": false
},
"31": {
"row": "B",
"column": 3,
"label": "B3",
"role": "fx",
"owner": 3,
"ardour_owned": false
},
"32": {
"row": "B",
"column": 4,
"label": "B4",
"role": "fx",
"owner": 4,
"ardour_owned": false
},
"33": {
"row": "B",
"column": 5,
"label": "B5",
"role": "fx",
"owner": 5,
"ardour_owned": false
},
"34": {
"row": "B",
"column": 6,
"label": "B6",
"role": "fx",
"owner": 6,
"ardour_owned": false
},
"35": {
"row": "B",
"column": 7,
"label": "B7",
"role": "fx",
"owner": 7,
"ardour_owned": false
},
"36": {
"row": "B",
"column": 8,
"label": "B8",
"role": "fx",
"owner": 8,
"ardour_owned": false
},
"41": {
"row": "E",
"column": 1,
"label": "E1",
"role": "gate",
"owner": 1,
"ardour_owned": false
},
"42": {
"row": "E",
"column": 2,
"label": "E2",
"role": "gate",
"owner": 2,
"ardour_owned": false
},
"43": {
"row": "E",
"column": 3,
"label": "E3",
"role": "gate",
"owner": 3,
"ardour_owned": false
},
"44": {
"row": "E",
"column": 4,
"label": "E4",
"role": "gate",
"owner": 4,
"ardour_owned": false
},
"49": {
"row": "C",
"column": 1,
"label": "C1",
"role": "family_filter",
"owner": 1,
"ardour_owned": false
},
"50": {
"row": "C",
"column": 2,
"label": "C2",
"role": "family_filter",
"owner": 2,
"ardour_owned": false
},
"51": {
"row": "C",
"column": 3,
"label": "C3",
"role": "family_filter",
"owner": 3,
"ardour_owned": false
},
"52": {
"row": "C",
"column": 4,
"label": "C4",
"role": "fx2",
"owner": 4,
"ardour_owned": false
},
"53": {
"row": "C",
"column": 5,
"label": "C5",
"role": "fx2",
"owner": 5,
"ardour_owned": false
},
"54": {
"row": "C",
"column": 6,
"label": "C6",
"role": "fx2",
"owner": 6,
"ardour_owned": false
},
"55": {
"row": "C",
"column": 7,
"label": "C7",
"role": "fx2",
"owner": 7,
"ardour_owned": false
},
"56": {
"row": "C",
"column": 8,
"label": "C8",
"role": "fx2",
"owner": 8,
"ardour_owned": false
},
"57": {
"row": "E",
"column": 5,
"label": "E5",
"role": "gate",
"owner": 5,
"ardour_owned": false
},
"58": {
"row": "E",
"column": 6,
"label": "E6",
"role": "gate",
"owner": 6,
"ardour_owned": false
},
"59": {
"row": "E",
"column": 7,
"label": "E7",
"role": "gate",
"owner": 7,
"ardour_owned": false
},
"60": {
"row": "E",
"column": 8,
"label": "E8",
"role": "gate",
"owner": 8,
"ardour_owned": false
},
"73": {
"row": "F",
"column": 1,
"label": "F1",
"role": "family_mute",
"owner": 1,
"ardour_owned": false
},
"74": {
"row": "F",
"column": 2,
"label": "F2",
"role": "family_mute",
"owner": 2,
"ardour_owned": false
},
"75": {
"row": "F",
"column": 3,
"label": "F3",
"role": "family_mute",
"owner": 3,
"ardour_owned": false
},
"76": {
"row": "F",
"column": 4,
"label": "F4",
"role": "gate2",
"owner": 4,
"ardour_owned": false
},
"77": {
"row": "D",
"column": 1,
"label": "D1",
"role": "level",
"owner": 1,
"ardour_owned": true
},
"78": {
"row": "D",
"column": 2,
"label": "D2",
"role": "level",
"owner": 2,
"ardour_owned": true
},
"79": {
"row": "D",
"column": 3,
"label": "D3",
"role": "level",
"owner": 3,
"ardour_owned": true
},
"80": {
"row": "D",
"column": 4,
"label": "D4",
"role": "level",
"owner": 4,
"ardour_owned": true
},
"81": {
"row": "D",
"column": 5,
"label": "D5",
"role": "level",
"owner": 5,
"ardour_owned": true
},
"82": {
"row": "D",
"column": 6,
"label": "D6",
"role": "level",
"owner": 6,
"ardour_owned": true
},
"83": {
"row": "D",
"column": 7,
"label": "D7",
"role": "level",
"owner": 7,
"ardour_owned": true
},
"84": {
"row": "D",
"column": 8,
"label": "D8",
"role": "level",
"owner": 8,
"ardour_owned": true
},
"89": {
"row": "F",
"column": 5,
"label": "F5",
"role": "gate2",
"owner": 5,
"ardour_owned": false
},
"90": {
"row": "F",
"column": 6,
"label": "F6",
"role": "gate2",
"owner": 6,
"ardour_owned": false
},
"91": {
"row": "F",
"column": 7,
"label": "F7",
"role": "gate2",
"owner": 7,
"ardour_owned": false
},
"92": {
"row": "F",
"column": 8,
"label": "F8",
"role": "gate2",
"owner": 8,
"ardour_owned": false
}
},
"orbit_home": {
"1": {
"fx": 29,
"level": 77,
"gate": 41
},
"2": {
"fx": 30,
"level": 78,
"gate": 42
},
"3": {
"fx": 31,
"level": 79,
"gate": 43
},
"4": {
"fx": 32,
"fx2": 52,
"level": 80,
"gate": 44,
"gate2": 76
},
"5": {
"fx": 33,
"fx2": 53,
"level": 81,
"gate": 57,
"gate2": 89
},
"6": {
"fx": 34,
"fx2": 54,
"level": 82,
"gate": 58,
"gate2": 90
},
"7": {
"fx": 35,
"fx2": 55,
"level": 83,
"gate": 59,
"gate2": 91
},
"8": {
"fx": 36,
"fx2": 56,
"level": 84,
"gate": 60,
"gate2": 92
},
"9": {
"level": 13,
"fx": 17
},
"10": {
"level": 14,
"fx": 18
},
"11": {
"level": 15,
"fx": 19
},
"12": {
"level": 16,
"fx": 20
}
},
"ardour_ccs": [
13,
14,
15,
16,
77,
78,
79,
80,
81,
82,
83,
84
],
"family_ccs": [
49,
50,
51,
73,
74,
75,
93
],
"panic_cc": 93
}
#!/usr/bin/env python3
"""lcxl_grid — THE grid. Authored once here; everything else derives or is generated.
WHY THIS FILE EXISTS
--------------------
PLN, 2026-07-29, loading bombe_dj after 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 three. The mapping from a MIDI CC to a physical control, and from a
physical control to the orbit that owns it, was hardcoded independently in:
tools/surface-columns.py GRID: cc -> (column, row)
tools/migrate-columns.py KNOB_A/B/C, BUTTONS, label(), slots
tools/lcxl-leds.py ROW_BASE + per-orbit binding logic
tools/pvlint/rules.py PV008's BT_CCS / BL_CCS / FAMILY_CCS
<hud>/lib/lcxl-map.js + lib/render.js TABLE + ORBIT_CONVENTION
After the 2026-07-29 remap the Python copies moved and the HUD's did not, so the
topbar showed d9 on A8 while the file and the LED board both said A1 — the
display contradicted the hardware under his hands, mid-test. That is the worst
failure mode for a glance-instrument, and it was structurally guaranteed to
happen again on the next remap.
So: this module is the only place the grid is WRITTEN. Python consumers import
it. The HUD (JavaScript, separate repo) consumes a GENERATED artifact, and a test
regenerates and compares, so editing one copy fails the suite instead of shipping.
Pattern borrowed from the fleet colour language (armada/tide-table/models.py ->
gen_tokens -> tokens.css/json): author the ontology once in Python, generate for
every other language.
THE GRID — one sentence: COLUMN N IS ORBIT N, all the way down.
col 1 2 3 4 5 6 7 8
A 13-20 d9 lvl d10 lvl d11 lvl d12 lvl d9 fx d10 fx d11 fx d12 fx
B 29-36 d1 fx d2 fx d3 fx d4 fx d5 fx d6 fx d7 fx d8 fx
C 49-56 gF1 gF2 gF3 d4 fx2 d5 fx2 d6 fx2 d7 fx2 d8 fx2
D 77-84 d1 lvl d2 lvl d3 lvl d4 lvl d5 lvl d6 lvl d7 lvl d8 lvl
E 41-44 d1 gate d2 gate d3 gate d4 gate
57-60 d5 gate d6 gate d7 gate d8 gate
F 73-76 gMute1 gMute2 gMute3 d4 gate2
89-92 d5 gate2 d6 gate2 d7 gate2 d8 gate2
Rows E and F are NON-CONTIGUOUS (41-44 then 57-60): the two halves are eight
columns of one row on the hardware, and treating them as two rows is what put
d6's second button in column 5 in the old map.
C1-C3 and F1-F3 are the FAMILY controls, not per-orbit. That is measured, not
assumed — across all 13 setlist tracks:
gF1 -> d1x13 d2x13 d3x13 d8x12 drums / core rhythm
gF2 -> d4x14 bass, almost exclusively
gF3 -> d5x12 d7x9 + d9-d12 leads / melodic / extras
Which is why d1-d3 own exactly ONE knob and ONE button each, and why an orbit
with two button gestures (bombe_dj's 3-state kick) is over budget BY DESIGN.
ROW NAME ALIASES exist because the three consumers each invented their own:
`D`/`fader`, `E`/`btn1`/`BT`, `F`/`btn2`/`BL`. Rather than force a rename across
five files and a live muscle-memory, every row carries all its names.
USAGE
python3 tools/lcxl_grid.py --check # self-consistency, prints the grid
python3 tools/lcxl_grid.py --generate # write the JSON + the HUD's JS
"""
from __future__ import annotations
import argparse
import json
import pathlib
import sys
ROOT = pathlib.Path(__file__).resolve().parent.parent
JSON_OUT = ROOT / "tools" / "lcxl_grid.json"
HUD_OUT = ROOT.parent.parent / "Tools" / "pulsar-parvagues-hud" / "lib" / "lcxl-grid.generated.js"
# --------------------------------------------------------------------------
# THE AUTHORED FACTS. Everything below this block is derived.
# --------------------------------------------------------------------------
# row id -> (aliases, [(column, cc), ...])
# Segments are listed so a non-contiguous row stays ONE row of eight columns.
_ROWS: dict[str, tuple[tuple[str, ...], list[tuple[int, int]]]] = {
"A": (("A", "knobA", "top"), [(n, 12 + n) for n in range(1, 9)]),
"B": (("B", "knobB", "mid"), [(n, 28 + n) for n in range(1, 9)]),
"C": (("C", "knobC", "bot"), [(n, 48 + n) for n in range(1, 9)]),
"D": (("D", "fader"), [(n, 76 + n) for n in range(1, 9)]),
"E": (("E", "btn1", "BT"), [(n, 40 + n) for n in range(1, 5)]
+ [(n, 52 + n) for n in range(5, 9)]),
"F": (("F", "btn2", "BL"), [(n, 72 + n) for n in range(1, 5)]
+ [(n, 84 + n) for n in range(5, 9)]),
}
# Physical row order as PLN corrected it: the faders sit BETWEEN the knobs and
# the buttons. Any UI that lays the surface out vertically must use this order.
PHYSICAL_ORDER = ("A", "B", "C", "D", "E", "F")
# What each (row, column) is FOR. `orbit` means "column N belongs to orbit N";
# a literal int pins it. Roles:
# level fader/knob setting an orbit's volume (Ardour-owned, see below)
# fx an orbit's primary effect knob
# fx2 an orbit's second effect knob
# gate an orbit's primary on/off button
# gate2 an orbit's second button
# family_filter / family_mute shared across a stem family, NOT per-orbit
_ROLES: dict[str, list[tuple[int, str, object]]] = {
# d9-d12 live on row A: levels in columns 1-4, effects in columns 5-8.
"A": [(n, "level", 8 + n) for n in range(1, 5)]
+ [(n, "fx", 8 + (n - 4)) for n in range(5, 9)],
"B": [(n, "fx", "orbit") for n in range(1, 9)],
"C": [(1, "family_filter", 1), (2, "family_filter", 2), (3, "family_filter", 3)]
+ [(n, "fx2", "orbit") for n in range(4, 9)],
"D": [(n, "level", "orbit") for n in range(1, 9)],
"E": [(n, "gate", "orbit") for n in range(1, 9)],
"F": [(1, "family_mute", 1), (2, "family_mute", 2), (3, "family_mute", 3)]
+ [(n, "gate2", "orbit") for n in range(4, 9)],
}
# Ardour MIDI-learned these to the Tidal 01-12 strip gains. Writing or seeding
# them from Tidal is the CC77-goes-to-silence footgun; a track referencing one is
# a CONFLICT, not a column question.
ARDOUR_ROWS = ("D",) # all 8 faders
ARDOUR_EXTRA = tuple(12 + n for n in range(1, 5)) # A1-A4 = d9-d12 levels
# gPanic is a global kill; never sweep or seed it.
PANIC_CC = 93
# --------------------------------------------------------------------------
# Derived views. Import these; do not re-derive them in a consumer.
# --------------------------------------------------------------------------
CC_TO_CELL: dict[int, tuple[str, int]] = {} # cc -> (row, column)
CELL_TO_CC: dict[tuple[str, int], int] = {} # (row, column) -> cc
ROW_CCS: dict[str, list[int]] = {} # row -> ccs in column order
ALIAS_TO_ROW: dict[str, str] = {}
CC_ROLE: dict[int, tuple[str, int]] = {} # cc -> (role, orbit-or-family-n)
for _row, (_aliases, _cells) in _ROWS.items():
for _alias in _aliases:
ALIAS_TO_ROW[_alias] = _row
ROW_CCS[_row] = [cc for _c, cc in sorted(_cells)]
for _col, _cc in _cells:
CC_TO_CELL[_cc] = (_row, _col)
CELL_TO_CC[(_row, _col)] = _cc
for _row, _specs in _ROLES.items():
for _col, _role, _who in _specs:
_cc = CELL_TO_CC[(_row, _col)]
CC_ROLE[_cc] = (_role, _col if _who == "orbit" else int(_who))
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"])
FAMILY_CCS: set[int] = {cc for cc, (role, _n) in CC_ROLE.items()
if role.startswith("family_")} | {PANIC_CC}
def label(cc: int) -> str:
"""44 -> 'E4'. The canonical short name of a physical control."""
cell = CC_TO_CELL.get(int(cc))
return f"{cell[0]}{cell[1]}" if cell else f"cc{cc}"
def label_as(cc: int, style: str) -> str:
"""Same, in a consumer's own row vocabulary — e.g. label_as(44,'BT') -> 'BT4'."""
cell = CC_TO_CELL.get(int(cc))
if not cell:
return f"cc{cc}"
row, col = cell
for alias, target in ALIAS_TO_ROW.items():
if target == row and alias.startswith(style):
return f"{alias}{col}"
return f"{row}{col}"
def slots(orbit: int, kind: str) -> list[int]:
"""The CCs `orbit` owns of one kind, most-reachable first.
kind='knob' -> its fx then fx2; kind='button' -> its gate then gate2.
Returns ONE entry for d1-d3, because C1-3 and F1-3 are the family controls.
Derived from CC_ROLE, so it cannot drift from the table above — the previous
version hardcoded `28 + orbit` / `48 + orbit` in migrate-columns.py.
"""
want = ("fx", "fx2") if kind == "knob" else ("gate", "gate2")
out = []
for role in want:
for cc, (r, who) in CC_ROLE.items():
if r == role and who == orbit and cc not in ARDOUR_CCS:
out.append(cc)
return out
def orbit_home(orbit: int) -> dict[str, int]:
"""Where an orbit lives: {'level': cc, 'fx': cc, ...}. Empty roles omitted."""
out: dict[str, int] = {}
for cc, (role, who) in CC_ROLE.items():
if who == orbit and not role.startswith("family_"):
out.setdefault(role, cc)
return out
def as_dict() -> dict:
"""The whole grid, JSON-ready. This is what non-Python consumers get."""
return {
"_generated_by": "tools/lcxl_grid.py — do not edit; run --generate",
"physical_order": list(PHYSICAL_ORDER),
"rows": {
row: {
"aliases": list(_ROWS[row][0]),
"columns": {str(col): CELL_TO_CC[(row, col)]
for _c, _cc in _ROWS[row][1]
for col in [_c]},
}
for row in PHYSICAL_ORDER
},
"cc": {
str(cc): {
"row": CC_TO_CELL[cc][0],
"column": CC_TO_CELL[cc][1],
"label": label(cc),
"role": CC_ROLE.get(cc, ("unassigned", 0))[0],
"owner": CC_ROLE.get(cc, ("unassigned", 0))[1],
"ardour_owned": cc in ARDOUR_CCS,
}
for cc in sorted(CC_TO_CELL)
},
"orbit_home": {str(o): orbit_home(o) for o in range(1, 13)},
"ardour_ccs": sorted(ARDOUR_CCS),
"family_ccs": sorted(FAMILY_CCS),
"panic_cc": PANIC_CC,
}
# --------------------------------------------------------------------------
# Self-check. These are invariants of the SURFACE, so a violation means the
# authored table above is wrong — not that a consumer is out of date.
# --------------------------------------------------------------------------
def problems() -> list[str]:
out = []
if len(CC_TO_CELL) != 48:
out.append(f"expected 48 controls (6 rows x 8), got {len(CC_TO_CELL)}")
if len(CELL_TO_CC) != len(CC_TO_CELL):
out.append("a CC appears in two cells, or two CCs share one cell")
for row in PHYSICAL_ORDER:
cols = sorted(c for r, c in CC_TO_CELL.values() if r == row)
if cols != list(range(1, 9)):
out.append(f"row {row} does not cover columns 1-8: {cols}")
missing = sorted(set(CC_TO_CELL) - set(CC_ROLE))
if missing:
out.append(f"controls with no role: {[label(c) for c in missing]}")
# Every orbit must have somewhere to live, or the migrator has nowhere to aim.
for o in range(1, 9):
home = orbit_home(o)
if "level" not in home or "fx" not in home or "gate" not in home:
out.append(f"d{o} is missing a level/fx/gate slot: {home}")
for o in range(9, 13):
if "level" not in orbit_home(o) or "fx" not in orbit_home(o):
out.append(f"d{o} is missing a level/fx slot")
# d1-d3 must have exactly one of each, d4-d8 two — the family-control budget.
for o in (1, 2, 3):
if len(slots(o, "knob")) != 1 or len(slots(o, "button")) != 1:
out.append(f"d{o} should own exactly one knob and one button "
f"(C{o}/F{o} are family controls)")
for o in range(4, 9):
if len(slots(o, "knob")) != 2 or len(slots(o, "button")) != 2:
out.append(f"d{o} should own two knobs and two buttons")
# A control Ardour learned must never be handed out as a Tidal effect slot.
for o in range(1, 13):
for kind in ("knob", "button"):
for cc in slots(o, kind):
if cc in ARDOUR_CCS:
out.append(f"d{o} {kind} slot {label(cc)} is Ardour-owned")
return out
def generate() -> list[pathlib.Path]:
payload = as_dict()
JSON_OUT.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n")
js = (
"'use babel';\n\n"
"// GENERATED by Sound/Tidal/tools/lcxl_grid.py — DO NOT EDIT.\n"
"// Regenerate: python3 tools/lcxl_grid.py --generate\n"
"// The grid is authored once in Python because it had drifted into three\n"
"// hardcoded copies, and the HUD's was the one that went stale: it showed\n"
"// d9 on A8 after the remap while the LED board and the .tidal files said A1.\n\n"
"export const GRID = " + json.dumps(payload, indent=2, ensure_ascii=False) + ";\n\n"
"// Convenience: orbit -> its physical home, in the row/lane shape render.js uses.\n"
"export const ORBIT_CONVENTION = Object.fromEntries(\n"
" Object.entries(GRID.orbit_home).map(([orbit, home]) => {\n"
" const cc = home.level;\n"
" const cell = GRID.cc[String(cc)];\n"
" return [Number(orbit), { row: cell.row, lane: cell.column }];\n"
" })\n"
");\n"
)
written = [JSON_OUT]
if HUD_OUT.parent.is_dir():
HUD_OUT.write_text(js)
written.append(HUD_OUT)
return written
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--check", action="store_true")
ap.add_argument("--generate", action="store_true")
a = ap.parse_args()
if not (a.check or a.generate):
a.check = True
if a.check:
print(" col 1 2 3 4 5 6 7 8")
for row in PHYSICAL_ORDER:
cells = []
for col in range(1, 9):
cc = CELL_TO_CC[(row, col)]
role, who = CC_ROLE[cc]
if role.startswith("family_"):
tag = f"{'gF' if 'filter' in role else 'gMute'}{who}"
else:
tag = f"d{who}{'' if role == 'level' else ':' + role}"
cells.append(f"{tag:>10}")
print(f" {row} {' '.join(str(c) for c in ROW_CCS[row][:1]):>3} " + "".join(cells))
errs = problems()
if errs:
print("\nPROBLEMS:")
for e in errs:
print(f" !! {e}")
return 1
print(f"\nlcxl_grid: OK — 48 controls, every row 1-8, every orbit housed.")
if a.generate:
for p in generate():
print(f" wrote {p}")
return 0
if __name__ == "__main__":
sys.exit(main())
...@@ -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