Commit 72943be9 by PLN (Algolia)

feat(surface): the mutes group by ROLE, the filters by BLOC — two maps, one author (#105)

PLN settled the gM question on 2026-07-30, and the answer was not the one this task
had assumed for a day:

  "I want the kick, now on d1/fader1, to have its mute on F1 botrow button. I want
   the C1 knob to djf all percs, C2 only bass djf, C3 melodies DJF. however i want
   the mutes consistent across all all tracks: m1 d1 / m2 other percs / m3 all
   bass+melodics"

## The thing I had wrong

#105 was framed as "align gM<N> to gF<N>" — make the mute index mirror the filter
index so an orbit's knob and button share a column. I measured 35 mismatches in the
set under that invariant and was ready to fix them.

That invariant is WRONG, and the measurement was answering the wrong question. The
filters and the mutes are two different gestures:

  FILTERS want the rhythm section as ONE BLOC     — you sweep the drums together
  MUTES want the kick SPLIT OUT on its own button — you drop the kick alone

So gF groups {d1,d2,d3,d8} and gM splits d1 from {d2,d3,d8}. Under the real map the
edit count is 55, not 35 — and `d4: gF2+gM3`, which the mirror-invariant called a
mismatch in 11 tracks, is CORRECT. Had I shipped the "alignment" I would have moved
11 correct lines and left the kick sharing a button with the hats.

Recorded as tools/lcxl_grid.py `_FILTER_FAMILY` / `_MUTE_FAMILY` — in the ONE
authored grid (#97), with his words above it, so the asymmetry reads as deliberate
rather than as drift. `as_dict()` now exports `orbit_family` so the HUD can say which
knob AND which button own an orbit; `--generate` rewrote tools/lcxl_grid.json and the
HUD package's own copy (outside this repo, in Tools/pulsar-parvagues-hud).

## tools/fix-mute-roles.py

Convergent migrator: `--apply` rewrites, `--check` exits 1 on drift (a pre-gig gate),
`--fill` gives a mute to a block that has none, `--report-missing` writes a markdown
report instead. Reads the map from lcxl_grid rather than restating it.

## PV012 / PV013, so it cannot re-drift

PV012 flags an orbit whose gM does not match its ROLE — deliberately NOT "gM index
!= gF index", and there is a regression test asserting `d2 $ gF1 $ gM2` is clean,
because a rule written to the mirror invariant would flag that valid line. PV013
flags an orbit with no family mute at all: PLN's "no mute is oversight!" — nothing on
the surface can drop such an orbit, so taking it out means editing live.

## PV013 immediately caught a bug in the migrator that wrote it

Minutes after PV013 existed, it flagged `d5` in you_my_sunshine — an orbit the
migrator had walked straight past. Cause: fix-mute-roles had its OWN orbit regex
requiring `^dN $`, and that file's head line is `d5  -- The Voice of Love`, no `$`,
with the gates on a commented continuation. pvlint's regex allows `dN` followed by
`$`, `--`, or EOL. Two parsers disagreeing about what an orbit IS is the same class
of bug as two copies of the grid, so the migrator now imports pvlint's parser and
owns none of its own. Re-running found 14 more rewrites and 7 more missing mutes it
had silently skipped — the miss was under-application, not corruption, but it would
have left exactly the inconsistency this task exists to remove.

Also fixes #109: the orphan-orbits hand-measurement for you_my_sunshine still
expected d7, which PLN moved to d6 in ce887b78. Re-derived by reading the file's
column-0 declarations, not by pasting the parser's output — a hand measurement that
quotes the thing it checks is a tautology.

Suite: 222 passed, 0 failed (8 new PV012/PV013 tests).
parent ce887b78
......@@ -565,6 +565,56 @@
"fx": 20
}
},
"orbit_family": {
"1": {
"filter": 1,
"mute": 1
},
"2": {
"filter": 1,
"mute": 2
},
"3": {
"filter": 1,
"mute": 2
},
"4": {
"filter": 2,
"mute": 3
},
"5": {
"filter": 3,
"mute": 3
},
"6": {
"filter": 3,
"mute": 3
},
"7": {
"filter": 3,
"mute": 3
},
"8": {
"filter": 1,
"mute": 2
},
"9": {
"filter": 3,
"mute": 3
},
"10": {
"filter": 3,
"mute": 3
},
"11": {
"filter": 3,
"mute": 3
},
"12": {
"filter": 3,
"mute": 3
}
},
"ardour_ccs": [
13,
14,
......
......@@ -127,6 +127,46 @@ ARDOUR_EXTRA = tuple(12 + n for n in range(1, 5)) # A1-A4 = d9-d12 levels
PANIC_CC = 93
# --------------------------------------------------------------------------
# Which FAMILY an orbit belongs to. Filters and mutes group DIFFERENTLY —
# that asymmetry is deliberate, authored by PLN 2026-07-30 settling #105:
#
# "I want the kick, now on d1/fader1, to have its mute on F1 botrow button.
# I want the C1 knob to djf all percs, C2 only bass djf, C3 melodies DJF.
# however i want the mutes consistent across all all tracks:
# m1 d1 / m2 other percs / m3 all bass+melodics"
#
# So the DJ filters treat the whole rhythm section as one bloc (you sweep the
# drums together, which is the gesture), while the mutes split the kick out on
# its own button (you drop the kick alone, which is the other gesture). Both
# maps are three-wide because columns 4-8 of rows C and F are already spent as
# d4-d8's fx2/gate2 slots — see _ROLES above. There is no fourth family.
#
# Note the kick ALSO has its own per-orbit gate at E1 (^41), freed when gMask
# was retired. "Mute the kick alone" is served twice over: F1 as the family
# mute, E1 as its own gate.
_FILTER_FAMILY = {1: 1, 2: 1, 3: 1, 8: 1, # all percs -> C1
4: 2, # bass only -> C2
5: 3, 6: 3, 7: 3, 9: 3, 10: 3, 11: 3, 12: 3} # melodies -> C3
_MUTE_FAMILY = {1: 1, # the kick, alone -> F1
2: 2, 3: 2, 8: 2, # other percs -> F2
4: 3, 5: 3, 6: 3, 7: 3, 9: 3, 10: 3, 11: 3, 12: 3} # bass+mels -> F3
# Orbits past 12 (d13/d14 exist on the 14-orbit boot) are melodic/FX by
# default. A fallback, not a claim — if one of them ever becomes a drum,
# author it above rather than letting the default decide.
_FAMILY_FALLBACK = 3
def filter_family(orbit: int) -> int:
"""Which gF<N> / row-C knob this orbit's DJ filter belongs to."""
return _FILTER_FAMILY.get(orbit, _FAMILY_FALLBACK)
def mute_family(orbit: int) -> int:
"""Which gM<N> / row-F button this orbit's mute belongs to."""
return _MUTE_FAMILY.get(orbit, _FAMILY_FALLBACK)
# --------------------------------------------------------------------------
# Derived views. Import these; do not re-derive them in a consumer.
# --------------------------------------------------------------------------
......@@ -225,6 +265,10 @@ def as_dict() -> dict:
for cc in sorted(CC_TO_CELL)
},
"orbit_home": {str(o): orbit_home(o) for o in range(1, 13)},
# Filters and mutes group differently on purpose — see _FILTER_FAMILY.
# The HUD needs both to say which knob and which button own an orbit.
"orbit_family": {str(o): {"filter": filter_family(o), "mute": mute_family(o)}
for o in range(1, 13)},
"ardour_ccs": sorted(ARDOUR_CCS),
"family_ccs": sorted(FAMILY_CCS),
"panic_cc": PANIC_CC,
......
......@@ -429,6 +429,7 @@ try:
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.
_grid = None
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}
......@@ -680,6 +681,109 @@ def pv011_fx_bus_shared(track: Track) -> Iterable[Finding]:
)
# --------------------------------------------------------------------------
# PV012/PV013 — the family MUTE an orbit reaches for
# --------------------------------------------------------------------------
MUTE_REF_RE = re.compile(r"\bg(?:M|Mute)([123])\b")
def _mute_of(orb) -> tuple[int, int] | None:
"""(family index, 1-indexed line) of the orbit's first live gM reference."""
for off, ln in enumerate(orb.lines):
m = MUTE_REF_RE.search(strip_comment(ln))
if m:
return int(m.group(1)), orb.start + off
return None
@rule
def pv012_mute_family_mismatch(track: Track) -> Iterable[Finding]:
"""d1 on gM2, or d4 on gM3 — the mute button does not match the orbit's role.
PLN authored the map 2026-07-30 (#105):
*"I want the kick, now on d1/fader1, to have its mute on F1 botrow button
... however i want the mutes consistent across all all tracks:
m1 d1 / m2 other percs / m3 all bass+melodics"*
The corpus had drifted into mirroring gM<N> onto gF<N>, because gM<N> used to
be `gMask . gMute<N>` and gMask has since been retired. That mirror is wrong
for the hand: the FILTERS want the rhythm section as one bloc (you sweep the
drums together) while the MUTES want the kick split out on its own button (you
drop the kick alone). Two gestures, two groupings — which is why this rule
reads lcxl_grid.mute_family() and NOT the orbit's gF index.
Consistency here is muscle memory, not tidiness: mid-set PLN reaches for F1 to
drop the kick without looking. A track where F1 mutes the hats instead is a
surprise at exactly the wrong moment.
Fix mechanically with `python3 tools/fix-mute-roles.py --apply`.
"""
if _grid is None: # grid unavailable: cannot judge
return
for orb in track.orbits():
got = _mute_of(orb)
if got is None:
continue
fam, line = got
want = _grid.mute_family(orb.number)
if fam == want:
continue
cc_got = _grid.CELL_TO_CC[("F", fam)]
cc_want = _grid.CELL_TO_CC[("F", want)]
yield Finding(
rule="PV012",
severity="warning",
line=line,
message=f"d{orb.number} mutes on gM{fam} (F{fam}, ^{cc_got}) "
f"but its role wants gM{want} (F{want}, ^{cc_want})",
detail=f"The authored map is m1=d1 (the kick alone), m2=other percs, "
f"m3=bass+melodics — tools/lcxl_grid.py _MUTE_FAMILY. On this "
f"track F{want} will not drop d{orb.number} and F{fam} will drop "
f"it unexpectedly. Fix with "
f"`python3 tools/fix-mute-roles.py --apply {track.path}`.",
)
@rule
def pv013_no_family_mute(track: Track) -> Iterable[Finding]:
"""An orbit with no gM at all — a stem you cannot drop from the surface.
PLN, 2026-07-30, on finding six of these in the OPAL set:
*"no mute is oversight!"*
Not always true — a one-shot, a riser, or a scratch block can reasonably have
no gate, which is why this is a warning and why `fix-mute-roles.py` only fills
the setlist automatically and merely REPORTS the rest for him to eyeball. But
in a track being performed it is almost always an omission: the family mute is
the fastest way to take a layer out, and an orbit without one can only be
stopped by editing and re-evaluating the block, mid-set, by hand.
Adding one is boot-safe: gMute<N> is `midiOn "^7N" (mask "f*16")` and every
button CC seeds to 0, so the mask is never applied until the button is pressed.
"""
if _grid is None:
return
for orb in track.orbits():
if _mute_of(orb) is not None:
continue
want = _grid.mute_family(orb.number)
cc = _grid.CELL_TO_CC[("F", want)]
yield Finding(
rule="PV013",
severity="warning",
line=orb.start,
message=f"d{orb.number} declares no family mute "
f"(role wants gM{want} = F{want}, ^{cc})",
detail=f"Nothing on the surface can drop this orbit — taking it out "
f"means editing the block live. Add `$ gM{want}` after the gF, "
f"or `python3 tools/fix-mute-roles.py --fill --apply "
f"{track.path}`. If the omission is deliberate (a one-shot or a "
f"riser), say so in a comment so the next reader knows.",
)
def check(track: Track, enabled: set[str] | None = None) -> list[Finding]:
out: list[Finding] = []
for fn in RULES:
......
......@@ -394,3 +394,74 @@ def test_pv011_ignores_commented_lines():
src = ('d4 $ n "0" # crushbus 41 (range 16 3.5 "^52")\n\n'
'd7 $ n "0" -- # crushbus 41 (range 16 3.5 "^55")\n')
assert lint(src, "PV011") == []
# --------------------------------------------------------------- PV012/PV013
def test_pv012_flags_the_kick_muting_on_family_two():
"""The exact shape found in 12 of 13 OPAL tracks on 2026-07-30.
d1 filtered in column 1 but muted in column 2 — so F1, the button PLN
reaches for to drop the kick, dropped nothing.
"""
f = lint('d1 $ gF1 $ gMute2 -- KICK: Sub thud, 4otf\n', "PV012")
assert len(f) == 1
assert f[0].severity == "warning"
assert "d1 mutes on gM2" in f[0].message
assert "wants gM1" in f[0].message and "^73" in f[0].message
def test_pv012_flags_percs_sitting_on_the_kicks_button():
"""The bulk of the migration: d2/d3/d8 had to VACATE gM1 for the kick."""
for orbit in ("d2", "d3", "d8"):
f = lint(f'{orbit} $ gF1 $ gM1\n', "PV012")
assert len(f) == 1, orbit
assert "wants gM2" in f[0].message, orbit
def test_pv012_accepts_the_authored_map():
"""m1=d1 alone, m2=other percs, m3=bass+melodics. No finding on any of these."""
ok = ('d1 $ gF1 $ gM1\n\n'
'd2 $ gF1 $ gM2\n\n'
'd3 $ gF1 $ gM2\n\n'
'd8 $ gF1 $ gM2\n\n'
'd4 $ gF2 $ gM3\n\n'
'd5 $ gF3 $ gM3\n\n'
'd11 $ gF3 $ gM3\n')
assert lint(ok, "PV012") == []
def test_pv012_does_not_mirror_the_filter_index():
"""The regression this rule exists to prevent.
gM used to be `gMask . gMute`, so the corpus drifted toward gM<N> == gF<N>.
d2 filters on gF1 and mutes on gM2 — index MISMATCH, and CORRECT, because
filters group the rhythm section as a bloc while mutes split the kick out.
A rule that compared the two indices would flag this valid line.
"""
assert lint('d2 $ gF1 $ gM2\n', "PV012") == []
assert lint('d4 $ gF2 $ gM3\n', "PV012") == []
def test_pv012_ignores_commented_mutes():
src = 'd8 $ gF1 $ gM2\n -- $ gF1 $ gM1 -- La Boite a breaks\n'
assert lint(src, "PV012") == []
def test_pv013_flags_an_orbit_with_no_mute_at_all():
"""PLN: "no mute is oversight!" — six of these were in the OPAL set."""
f = lint('d8 $ gF1\n $ midiOn "^92" (ply "1 <2!3 4>")\n', "PV013")
assert len(f) == 1
assert f[0].severity == "warning"
assert "d8 declares no family mute" in f[0].message
assert "gM2" in f[0].message and "^74" in f[0].message
def test_pv013_silent_when_a_mute_is_present():
assert lint('d8 $ gF1 $ gM2\n', "PV013") == []
def test_pv013_a_commented_mute_does_not_count():
"""It is commented out, so the surface still cannot drop the orbit."""
f = lint('d8 $ gF1\n -- $ gM2\n', "PV013")
assert len(f) == 1
......@@ -198,7 +198,12 @@ HAND_MEASURED = {
"gimme_acid": {1, 2, 3, 4, 5, 8, 9, 10, 11, 12},
"vague_de_crime": {1, 2, 3, 4, 5, 6, 7, 8, 10},
"mafia_sans_serif": {1, 2, 3, 4, 5, 7, 8},
"you_my_sunshine": {1, 2, 3, 4, 5, 7, 8, 9, 11},
# d7 became d6 on 2026-07-30 (PLN's edit, committed in ce887b7: the Guitar
# Sunshine block moved down a slot with ^91->^90 and ^59->^58, the same
# consolidation he made in do_it_right). Re-derived by reading the file's
# column-0 `dN` declarations, not by copying the parser's output — a hand
# measurement that quotes the thing it checks is a tautology.
"you_my_sunshine": {1, 2, 3, 4, 5, 6, 8, 9, 11},
"desire": {1, 2, 3, 4, 5, 6, 7, 8, 9},
"the_revolution_will_be_sampled": {1, 2, 3, 4, 5, 7, 8, 9, 10, 11, 12},
}
......
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