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
#!/usr/bin/env python3
"""fix-mute-roles — put each orbit's MUTE on the family button its role belongs to.
WHY THIS EXISTS
---------------
PLN, 2026-07-30, closing #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"*
The corpus had drifted into treating gM<N> as a mirror of gF<N> — because it once
was: gM<N> used to be `gMask . gMute<N>`, and gMask has since been retired
(BootTidal.hs). That mirror is wrong for the hand. The DJ FILTERS want the rhythm
section as one bloc (you sweep the drums together), but the MUTES want the kick
split out on its own button (you drop the kick alone). Two different gestures, so
two different groupings — authored in tools/lcxl_grid.py as _FILTER_FAMILY and
_MUTE_FAMILY, which this tool imports rather than restating.
Before, in 12 of 13 setlist tracks:
d1 $ gF1 $ gMute2 -- kick filters in column 1, mutes in column 2
d2 $ gF1 $ gM1 -- percs mute in column 1, i.e. ON TOP OF the kick's slot
After:
d1 $ gF1 $ gM1 -- kick: F1, alone
d2 $ gF1 $ gM2 -- percs: F2, together
Note the bulk of the work is NOT the kick. It is the 37 perc lines that have to
vacate gM1 so the kick can have it.
SAFETY
------
Every button CC seeds to 0 at boot (BootTidal.hs `_seed`), and gMute<N> is
`midiOn "^7N" (mask "f*16")` — a midiOn whose control reads 0 is never applied.
So permuting WHICH family mute an orbit references cannot change any orbit's
boot behaviour: all three are equally inert until pressed. The edit is
event-neutral at boot BY CONSTRUCTION.
That also means `silent-eval --seeded` CANNOT validate the interesting half of
this change. It proves nothing got silenced at boot and NOTHING about what
happens under a press. Run it anyway as a regression net; the real gate is an
ear, or pv-at (#74).
WHAT IT WILL NOT DO
-------------------
- Touch trailing comments. `d1 $ gF1 $ gMute2 -- KICK: Sub thud, 4otf` keeps its
comment verbatim; only the token changes.
- Rewrite a commented-out line (`-- $ gF3 $ gMute3 -- COUNT TO SUPERSTARDOM`).
Note a commented mute means the orbit has NO mute — `--fill` will offer it one.
- Touch gF at all. The filter families are already correct in 13/13 setlist tracks.
- Fill a missing mute anywhere but the setlist unless told to. PLN: *"no mute is
oversight! ensure we have that consistently in all set, and flag in other
non-set tracks for me to see."* So `--fill` on the set writes; `--report-missing`
on the corpus only lists, because outside the set a missing gate may well be a
composition choice and that judgement is his.
USAGE
python3 tools/fix-mute-roles.py # dry run, OPAL setlist
python3 tools/fix-mute-roles.py --all # dry run, whole repo
python3 tools/fix-mute-roles.py --apply # rewrite the setlist in place
python3 tools/fix-mute-roles.py --all --apply # rewrite the whole repo
python3 tools/fix-mute-roles.py --fill --apply # also GIVE missing mutes
python3 tools/fix-mute-roles.py --check # exit 1 if any drift remains
python3 tools/fix-mute-roles.py --all --report-missing docs/report.md
"""
from __future__ import annotations
import argparse
import collections
import pathlib
import re
import sys
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
import lcxl_grid # noqa: E402
from pvlint.core import ORBIT_RE as DN_RE, Track, strip_comment # noqa: E402
ROOT = pathlib.Path(__file__).resolve().parent.parent
SETLIST = ROOT / "armada" / "setlist_opal2026.txt"
# The orbit-statement regex and the block chunking are pvlint's, deliberately:
# this tool had its OWN `^dN\s+\$` and it silently missed every orbit whose head
# line carries no `$` — e.g. `d5 -- The Voice of Love` with the gates on a
# commented continuation line. pvlint's PV013 caught the gap minutes after being
# written, on the very file the migration had just walked. Two parsers disagreeing
# about what an orbit IS is the same class of bug as two copies of the grid (#97),
# so there is now one parser and the migrator is the consumer.
# The family-mute reference. Both spellings: gM<N> is current, gMute<N> is the
# legacy long form BootTidal still aliases. Word-bounded so gM1 never matches
# inside gM12 or a sample name.
MUTE_RE = re.compile(r"\bg(?:M|Mute)([123])\b")
def setlist_tracks() -> list[pathlib.Path]:
if not SETLIST.exists():
raise SystemExit(f"fix-mute-roles: no setlist at {SETLIST}")
out = []
for raw in SETLIST.read_text().splitlines():
line = raw.split("#")[0].strip()
if line:
out.append(ROOT / line)
return out
# Directories that are not PLN's playable tracks: dependency trees, git internals,
# and generated artifacts. Everything else is fair game — the point of --all is that
# opening ANY old track finds the mutes where his fingers now expect them.
_SKIP_DIRS = {".git", "node_modules", "output", "__pycache__", ".venv"}
def corpus_tracks() -> list[pathlib.Path]:
"""Every playable .tidal in the repo, not just live/.
PLN 2026-07-30: *"not only in the set am i right? did we not do the whole
refact on the other .tidal files"* — yes. Muscle memory does not know which
folder a track lives in, so a half-migrated corpus is worse than either
extreme: it makes the surface unpredictable exactly when he reaches for an
old track mid-set.
"""
return sorted(p for p in ROOT.rglob("*.tidal")
if not _SKIP_DIRS & set(p.relative_to(ROOT).parts))
def is_commented(line: str, upto: int) -> bool:
"""True if the match at `upto` sits after a Haskell `--` comment opener."""
return "--" in line[:upto]
def blocks(text: str) -> list[tuple[int, int, list[int]]]:
"""Split into orbit statements, via pvlint's parser.
Returns (orbit, head_index, [0-based line indices in the block]). A block runs
from its `dN` head until a blank line or the next head — the same chunking
Pulsar uses when it evaluates (blank line = statement separator), so what this
tool calls a block is what the editor calls one eval.
"""
out: list[tuple[int, int, list[int]]] = []
for orb in Track(path="<migrate>", text=text).orbits():
head = orb.start - 1 # pvlint is 1-indexed
out.append((orb.number, head,
list(range(head, head + len(orb.lines)))))
return out
def plan_file(path: pathlib.Path) -> tuple[list[tuple[int, int, str, str]], list[tuple[int, int]]]:
"""What would change in one file.
Returns (edits, ungated) where an edit is
(line_index, orbit, old_token, new_token).
"""
text = path.read_text()
lines = text.splitlines()
edits: list[tuple[int, int, str, str]] = []
ungated: list[tuple[int, int]] = []
for orbit, head, idxs in blocks(text):
want = lcxl_grid.mute_family(orbit)
found = False
for i in idxs:
for m in MUTE_RE.finditer(lines[i]):
if is_commented(lines[i], m.start()):
continue
found = True
old = m.group(0)
new = f"gM{want}"
if old != new:
edits.append((i, orbit, old, new))
break # first live reference wins
if found:
break
if not found:
ungated.append((orbit, head + 1))
return edits, ungated
GF_RE = re.compile(r"\bgF([123])\b")
def fill_file(path: pathlib.Path, ungated: list[tuple[int, int]],
write: bool) -> list[tuple[int, int, str]]:
"""Give an orbit block that has NO family mute the one its role wants.
PLN 2026-07-30: *"no mute is oversight! ensure we have that consistently in
all set"*. An orbit with no gM cannot be muted from the surface at all —
during a set that is a stem you cannot drop, which is the opposite of the
affordance the family buttons exist to provide.
Boot-safe by the same argument as the rewrite: gMute<N> is
`midiOn "^7N" (mask "f*16")` and every button CC seeds to 0, so a midiOn
whose control reads 0 is never applied. ADDING a mute cannot change how the
block sounds until the button is pressed.
Placement follows the corpus convention `dN $ gF<N> $ gM<M>`: after the last
gF on the head line if there is one, else immediately after `dN $`. Never
inside or across a trailing comment.
"""
lines = path.read_text().splitlines(keepends=True)
done: list[tuple[int, int, str]] = []
for orbit, head_1 in ungated:
i = head_1 - 1
raw = lines[i]
nl = "\n" if raw.endswith("\n") else ""
body = raw[:-len(nl)] if nl else raw
# Split off a trailing comment so insertion never lands inside it.
cut = body.find("--")
code, comment = (body[:cut], body[cut:]) if cut >= 0 else (body, "")
want = f"gM{lcxl_grid.mute_family(orbit)}"
gfs = list(GF_RE.finditer(code))
if gfs:
at = gfs[-1].end()
new_code = f"{code[:at]} $ {want}{code[at:]}"
else:
m = DN_RE.match(code)
at = m.end()
rest = code[at:]
# `d5 $ foo` -> `d5 $ gM3 $ foo` (splice after the existing $)
# `d5` alone -> `d5 $ gM3` (there was no $ to splice after)
if rest.lstrip().startswith("$"):
cut_at = at + rest.index("$") + 1
new_code = f"{code[:cut_at]} {want} ${code[cut_at:]}"
else:
new_code = f"{code[:at]} $ {want}{rest}"
# Tidy trailing whitespace the splice may have left — but NEVER eat the
# gap before a trailing comment. `")-- 4-bar roots` still compiles and
# still reads as a comment, so this would have been an invisible
# cosmetic regression in PLN's own annotations.
new_code = re.sub(r"[ \t]+$", " " if comment else "", new_code)
lines[i] = new_code + comment + nl
done.append((i, orbit, want))
if write and done:
path.write_text("".join(lines))
return done
def apply_file(path: pathlib.Path, edits: list[tuple[int, int, str, str]]) -> None:
"""Rewrite only the matched token on each line. Comments survive verbatim."""
lines = path.read_text().splitlines(keepends=True)
for i, _orbit, old, new in edits:
line = lines[i]
for m in MUTE_RE.finditer(line):
if is_commented(line, m.start()):
continue
lines[i] = line[:m.start()] + new + line[m.end():]
break
path.write_text("".join(lines))
def main() -> int:
ap = argparse.ArgumentParser(prog="fix-mute-roles")
ap.add_argument("--apply", action="store_true", help="rewrite in place")
ap.add_argument("--check", action="store_true",
help="exit 1 if any drift remains (for CI / pre-gig gate)")
ap.add_argument("--all", action="store_true",
help="whole repo instead of just the OPAL setlist")
ap.add_argument("--fill", action="store_true",
help="also GIVE a mute to blocks that have none (PLN: 'no mute "
"is oversight'). Use with --apply to write.")
ap.add_argument("--report-missing", metavar="PATH", type=pathlib.Path,
help="write the ungated blocks to a markdown report instead of "
"filling them — for non-set tracks PLN wants to eyeball")
ap.add_argument("tracks", nargs="*", type=pathlib.Path)
a = ap.parse_args()
tracks = ([p if p.is_absolute() else ROOT / p for p in a.tracks]
or (corpus_tracks() if a.all else setlist_tracks()))
print("fix-mute-roles: the authored map (tools/lcxl_grid.py)")
by_fam: dict[int, list[int]] = collections.defaultdict(list)
for o in range(1, 13):
by_fam[lcxl_grid.mute_family(o)].append(o)
for fam in sorted(by_fam):
cc = lcxl_grid.CELL_TO_CC[("F", fam)]
orbits = " ".join(f"d{o}" for o in by_fam[fam])
print(f" gM{fam} (F{fam}, ^{cc}) {orbits}")
print(f"\n{len(tracks)} track(s)\n")
shapes: collections.Counter[str] = collections.Counter()
total = 0
ungated_total = 0
filled_total = 0
missing: list[tuple[pathlib.Path, int, int]] = []
for path in tracks:
if not path.exists():
print(f" {path}: MISSING")
continue
edits, ungated = plan_file(path)
ungated_total += len(ungated)
missing += [(path, o, ln) for o, ln in ungated]
if edits:
total += len(edits)
detail = " ".join(f"d{o}:{old}->{new}" for _i, o, old, new in edits)
print(f" {path.stem:32} {len(edits):3} {detail}")
for _i, _o, old, new in edits:
shapes[f"{old} -> {new}"] += 1
if a.apply:
apply_file(path, edits)
if a.fill and ungated:
got = fill_file(path, ungated, write=a.apply)
filled_total += len(got)
detail = " ".join(f"d{o}+{w}" for _i, o, w in got)
print(f" {path.stem:32} {'':3} FILL {detail}")
print(f"\n{'REWROTE' if a.apply else 'WOULD REWRITE'}: {total} line(s)")
if shapes:
print("by shape:")
for k, v in shapes.most_common():
print(f" {v:4} {k}")
if a.fill:
print(f"\n{'FILLED' if a.apply else 'WOULD FILL'}: {filled_total} block(s) "
"that had no family mute at all")
elif ungated_total:
print(f"\n{ungated_total} orbit block(s) declare no family mute at all "
"(--fill to give them one, --report-missing to list them)")
if a.report_missing:
by_file: dict[pathlib.Path, list[tuple[int, int]]] = collections.defaultdict(list)
for p, o, ln in missing:
by_file[p].append((o, ln))
# The distinction that makes 2397 findings actionable. A track where SOME
# orbits carry a family mute and some do not is a genuine oversight — the
# convention was applied and then missed. A track where NO orbit has one
# was written before the family mutes existed; that is a style era, not a
# mistake, and mass-filling it would be a rewrite nobody asked for.
partial: list[pathlib.Path] = []
never: list[pathlib.Path] = []
for p in by_file:
has_any = any(MUTE_RE.search(strip)
for strip in (line.split("--")[0]
for line in p.read_text().splitlines()))
(partial if has_any else never).append(p)
def fmt(p: pathlib.Path) -> str:
got = sorted(by_file[p], key=lambda t: t[1])
orbits = " ".join(f"d{o}(L{ln}→gM{lcxl_grid.mute_family(o)})"
for o, ln in got)
return f"- `{p.relative_to(ROOT)}` — {len(got)}: {orbits}"
n_partial = sum(len(by_file[p]) for p in partial)
n_never = sum(len(by_file[p]) for p in never)
out = [
"# Orbits with no family mute — for PLN's eyes",
"",
"PLN 2026-07-30: *\"no mute is oversight! ensure we have that "
"consistently in all set, and flag in other non-set tracks for me "
"to see.\"*",
"",
"The OPAL setlist has been FILLED automatically. Everything below is a "
"NON-SET track, left exactly as it was. Nothing here was written to.",
"",
f"**{len(missing)} blocks across {len(by_file)} files** — but they are "
"not one problem. Split by whether the track uses family mutes at all:",
"",
f"| | files | blocks | what it means |",
f"|---|---|---|---|",
f"| **A · partial** | {len(partial)} | {n_partial} | uses family mutes, "
"but some orbits were missed — **this is the oversight you meant** |",
f"| **B · never** | {len(never)} | {n_never} | no orbit has one; written "
"before the convention. A style era, not a mistake |",
"",
"Group B is the bulk and mass-filling it would be a rewrite nobody "
"asked for. Group A is the short, real list.",
"",
"To fix one file once you have looked at it:",
"",
"```",
"python3 tools/fix-mute-roles.py --fill --apply <path.tidal>",
"```",
"",
f"## A — partial: the convention was applied then missed ({len(partial)} files)",
"",
]
out += [fmt(p) for p in sorted(partial, key=lambda q: (-len(by_file[q]), q))]
out += ["",
f"## B — never adopted family mutes ({len(never)} files)",
"",
"Listed for completeness. Reading one of these is only worth it if "
"you intend to play it again.",
""]
out += [fmt(p) for p in sorted(never)]
a.report_missing.write_text("\n".join(out) + "\n")
print(f"\nreport: {a.report_missing}")
print(f" A partial (real oversight): {len(partial)} files, {n_partial} blocks")
print(f" B never adopted : {len(never)} files, {n_never} blocks")
if a.check and total:
print("\nfix-mute-roles: DRIFT — run --apply", file=sys.stderr)
return 1
if a.apply:
print("\nNow: re-run to confirm convergence (must report 0), then "
"`silent-eval --seeded` as a regression net. Remember it CANNOT see "
"behaviour under a press — that needs an ear or pv-at (#74).")
return 0
if __name__ == "__main__":
raise SystemExit(main())
......@@ -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