Commit fdde0924 by PLN (Algolia)

feat(surface): the column migrator, proven on bombe_dj — 5 knobs onto their own columns

PLN: "lets do the bombe dj first to confirm". One track, end to end, so the tool
and the grid are both proven before the other twelve.

    d3  ^52 C4  ->  ^31 B3     legato
    d4  ^17 A5  ->  ^32 B4     midiOff (slow 4)
    d4  ^53 C5  ->  ^52 C4     crushbus 41
    d5  ^34 B6  ->  ^33 B5     octerbus 52
    d5  ^54 C6  ->  ^53 C5     crushbus 51

It also cleaned up a collision I had introduced myself twenty minutes earlier:
0538eb97 moved bombe_dj's d9 onto ^17, but d4 was ALREADY using ^17, so between
the two commits that one knob drove both orbits. Caught by running --plan before
--apply rather than trusting the previous step. The lesson is the tool's design
rule, not a footnote: build the whole permutation, then look at it, then write.

== THREE DESIGN DECISIONS THAT ARE THE WHOLE TOOL ==

1. THE PERMUTATION IS APPLIED SIMULTANEOUSLY. Costed one move at a time, the
   setlist showed 34 "destination already taken" clashes. Every one was phantom:
   the corpus is uniformly off by one column, so each orbit's knob wants the slot
   of the orbit below it, occupied only until THAT one also moves. Build the full
   map, rewrite in a single pass, and the clashes evaporate. Applying moves
   sequentially would have corrupted the files. Real collisions across the whole
   setlist: zero.

2. THE REWRITE IS SCOPED PER ORBIT, never file-wide. Two orbits can legitimately
   share a CC today — desire.tidal had ^19 driving both d7 and d9 — and they
   migrate to DIFFERENT destinations. A file-wide search-and-replace would send
   both to one place and silently fuse two gestures into one. So the substitution
   walks orbit by orbit with only that orbit's map.

3. COMMENTS ARE NEVER REWRITTEN. A commented-out ^NN is an ALTERNATIVE PLN may
   re-enable mid-set. Renumbering it would quietly rewire that alternative to a
   different orbit's knob, and the breakage would surface weeks later with no
   trace of a cause.

Hands off by construction: faders 77-84 and knobs 13-16 (Ardour-learned — the
CC77-to-silence footgun), the 8 BootTidal helper CCs, and all buttons
(41-44/57-60/73-76/89-92 — gates and mutes are phase 2, and retraining a gate is
more disruptive than retraining a knob).

== AND A FIX TO THE VERIFICATION LENS, WHICH MATTERED MORE THAN IT LOOKS ==
After applying, surface-columns still reported "11 moves" for bombe_dj. Nothing
was wrong: it counts BUTTON refs too, and buttons are deliberately out of phase-1
scope. But a gate that reports deliberately-deferred work as a failure is a gate
you learn to ignore — and that is precisely how a real regression gets through.
Added --knobs, which narrows the grid itself so every downstream number speaks
about phase 1 only. Same discipline as feedback_parsers_over_copy: the
measurement must measure what was actually done.

== VERIFIED ==
  pvlint                        1 track, 0 errors, 0 warnings
  silent-eval --seeded          every declared orbit still emits events, cold
  surface-columns --knobs       0 renumbers remaining (was 5)

Remaining for bombe_dj, on purpose: d9's second effect (^19) still wants gSel
(FIXME already in the file from 0538eb97), and the button rows are phase 2.
parent 0538eb97
...@@ -30,17 +30,17 @@ d3 $ gF1 $ gM1 ...@@ -30,17 +30,17 @@ d3 $ gF1 $ gM1
# gain 1.4 # gain 1.4
# room 0.3 # room 0.3
# dry (slow 16 $ range 0.2 1.8 perlin) # dry (slow 16 $ range 0.2 1.8 perlin)
# legato (range 0.05 2 "^52") # legato (range 0.05 2 "^31")
# sz 0.4 # sz 0.4
d4 $ gF2 $ gM3 d4 $ gF2 $ gM3
$ midiOn "^89" (struct "t(8,16)") $ midiOn "^89" (struct "t(8,16)")
$ juxBy 0.7 (|- note 12) $ juxBy 0.7 (|- note 12)
$ midiOff "^17" (slow 4) $ midiOff "^32" (slow 4)
$ midiOn "^57" (arp "pinkyup") $ midiOn "^57" (arp "pinkyup")
$ midiOff "^57" (arp "up") $ midiOff "^57" (arp "up")
$ ply 2 $ slow 2 $ note ("<[e3, gs3, b3, c4] [e3, d4, c4, b3] [e3, f4, ef4, e4] [e3, f4, ef4, e4]>" + "[0,-12]") $ ply 2 $ slow 2 $ note ("<[e3, gs3, b3, c4] [e3, d4, c4, b3] [e3, f4, ef4, e4] [e3, f4, ef4, e4]>" + "[0,-12]")
# "bassWarsaw" # "bassWarsaw"
# crushbus 41 (range 16 3.5 "^53") # crushbus 41 (range 16 3.5 "^52")
-- # cut 9 -- # cut 9
# room 0.5 # sz 0.5 # room 0.5 # sz 0.5
# dry 1.5 # dry 1.5
...@@ -52,8 +52,8 @@ d5 $ gF3 $ gM3 -- GUITARE DU DJ ...@@ -52,8 +52,8 @@ d5 $ gF3 $ gM3 -- GUITARE DU DJ
$ slow "<1 2 ~ <2 ~>>" $ n "9" $ slow "<1 2 ~ <2 ~>>" $ n "9"
# "diams_dj" # "diams_dj"
# cut 5 # gain 1.5 # cut 5 # gain 1.5
# octerbus 52 (range 0 0.95 "^34") # octerbus 52 (range 0 0.95 "^33")
# crushbus 51 (range 16 4.5 "^54") # crushbus 51 (range 16 4.5 "^53")
-- # room 0.3 # sz 0.6 # dry 0.4 -- # room 0.3 # sz 0.6 # dry 0.4
-- # octer 0.8 # octersub 1.2 -- # octer 0.8 # octersub 1.2
# lpf 5000 # lpf 5000
......
#!/usr/bin/env python3
"""migrate-columns — move each orbit's effect knobs into that orbit's column.
The other half of #46. See tools/surface-columns.py for the survey that prices
the work and explains the grid; this tool performs it.
THE TARGET GRID (settled with PLN 2026-07-29)
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
So each of d1-d8 gets its FADER for level and its B knob for one effect;
d4-d8 also get their C knob for a second. d1-d3 do NOT, because C1/C2/C3 are
the three family DJ filters (gF1 drums, gF2 bass, gF3 leads) — measured to be
applied per-FAMILY across the whole corpus, and deliberately left alone.
WHAT THIS TOOL WILL NOT TOUCH
* faders 77-84 and knobs 13-16 — MIDI-learned to Ardour track gains. Writing
or renumbering into that range is the CC77-goes-to-silence footgun.
* the 8 helper CCs (gF1-3, gMask, gMute1-3, gPanic) — BootTidal owns them.
* buttons (41-44, 57-60, 73-76, 89-92) — gates and mutes are a separate axis
with their own collisions against gMask/gMute, and retraining a gate is more
disruptive than retraining a knob. Phase 2, after the gig.
WHY THE PERMUTATION MUST BE APPLIED SIMULTANEOUSLY
Computing the moves one at a time makes the corpus look like a minefield: 34
"destination already taken" clashes. They are phantom. The corpus is uniformly
off by one column, so each orbit's knob wants the slot of the orbit below it,
which is occupied *only until that one also moves*. Build the whole map, then
rewrite in a single pass, and the clashes vanish. Applying moves sequentially
would corrupt the file.
WHY THE REWRITE IS SCOPED PER ORBIT
Two orbits can legitimately share a CC today (desire.tidal had ^19 driving both
d7 and d9) and they migrate to DIFFERENT destinations. A file-wide
search-and-replace would send both to the same place and silently merge two
gestures into one. So the substitution walks orbit by orbit, applying only that
orbit's own map to that orbit's own lines.
OVERFLOW -> gSel
An orbit with three or more knobs has more effects than the grid has slots.
Those are NOT renumbered into a neighbour's column; they are left in place with
a FIXME(#54) pointing at gSel, the clamped knob-selector that folds a multi-knob
stack onto one control. PLN: "if we have two effects, we consolidate via gSel
indeed. mark FIXMEs in code in there".
Usage:
tools/migrate-columns.py --plan TRACK [...] # show, change nothing
tools/migrate-columns.py --apply TRACK [...] # rewrite in place
tools/migrate-columns.py --plan # whole OPAL setlist
Always --plan first. Always pvlint + silent-eval after --apply.
"""
from __future__ import annotations
import argparse
import re
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from pvlint.core import load, strip_comment # noqa: E402
ROOT = Path(__file__).resolve().parent.parent
SETLIST = ROOT / "armada" / "setlist_opal2026.txt"
CC_REF = re.compile(r'"\^(\d+)"')
KNOB_A = set(range(13, 21))
KNOB_B = set(range(29, 37))
KNOB_C = set(range(49, 57))
KNOBS = KNOB_A | KNOB_B | KNOB_C
# Hands off, for the reasons in the docstring.
ARDOUR = set(range(77, 85)) | {13, 14, 15, 16}
BUTTONS = set(range(41, 45)) | set(range(57, 61)) | set(range(73, 77)) | set(range(89, 93))
def helper_ccs() -> set[int]:
"""CCs BootTidal's helpers own. Comment-stripped — see surface-columns."""
boot = ROOT / "BootTidal.hs"
if not boot.exists():
return set()
text = boot.read_text()
i = text.find("let -- DPV specific parameters")
if i >= 0:
j = text.find("\n:}", i)
text = text[i:j if j > 0 else len(text)]
text = "\n".join(strip_comment(l) for l in text.splitlines())
return {int(m) for m in CC_REF.findall(text)}
def plan_track(path: Path, helpers: set[int]) -> dict:
"""Per-orbit {src cc -> dest cc}, plus the overflow that needs gSel."""
track = load(path)
per_orbit: dict[int, dict[int, int]] = {}
overflow: list[tuple[int, int, int]] = [] # (orbit, cc, line)
for orb in track.orbits():
if not 1 <= orb.number <= 8:
continue
# First-appearance order matters: the first effect written is the one
# most likely to be the primary gesture, so it gets the easy B knob.
seen: list[tuple[int, int]] = []
for off, raw in enumerate(orb.lines):
for m in CC_REF.finditer(strip_comment(raw)):
cc = int(m.group(1))
if cc in KNOBS and cc not in helpers and cc not in ARDOUR:
if cc not in [c for c, _ in seen]:
seen.append((cc, orb.start + off))
m: dict[int, int] = {}
for i, (cc, line) in enumerate(seen):
if i == 0:
m[cc] = 28 + orb.number # its B knob
elif i == 1 and orb.number >= 4:
m[cc] = 48 + orb.number # its C knob
else:
overflow.append((orb.number, cc, line)) # -> gSel
# Guard: within one orbit two sources must never share a destination.
if len(set(m.values())) != len(m):
raise SystemExit(f"migrate-columns: internal collision in "
f"{path.name} d{orb.number}: {m}")
per_orbit[orb.number] = m
return dict(track=track, per_orbit=per_orbit, overflow=overflow)
def rewrite(path: Path, res: dict) -> tuple[int, list[str]]:
"""Apply the plan. Returns (substitutions made, notes)."""
lines = path.read_text().splitlines(keepends=True)
# line number (1-indexed) -> that orbit's map
line_map: dict[int, dict[int, int]] = {}
for orb in res["track"].orbits():
m = res["per_orbit"].get(orb.number)
if not m:
continue
for off in range(len(orb.lines)):
line_map[orb.start + off] = m
n = 0
for idx in range(len(lines)):
m = line_map.get(idx + 1)
if not m:
continue
code_end = len(lines[idx])
# Only rewrite live code, never a comment: a commented-out ^NN is an
# ALTERNATIVE PLN may re-enable, and renumbering it would silently
# rewire that alternative to a different orbit's knob.
c = lines[idx].find("--")
if c >= 0:
code_end = c
def sub(mo, _m=m):
nonlocal n
cc = int(mo.group(1))
if mo.start() >= code_end or cc not in _m or _m[cc] == cc:
return mo.group(0)
n += 1
return f'"^{_m[cc]}"'
lines[idx] = CC_REF.sub(sub, lines[idx])
notes = []
# FIXMEs for overflow, inserted bottom-up so earlier line numbers hold.
for orbit, cc, line in sorted(res["overflow"], reverse=True):
indent = re.match(r"\s*", lines[line - 1]).group(0)
lines.insert(line - 1,
f"{indent}-- FIXME(#54) d{orbit} 3rd+ effect: the grid gives d{orbit} "
f"B{orbit}"
+ (f" + C{orbit}" if orbit >= 4 else " only (C is a gF filter)")
+ f". Fold ^{cc} in via gSel rather than borrowing a column.\n")
notes.append(f"d{orbit} ^{cc} -> gSel FIXME at line {line}")
path.write_text("".join(lines))
return n, notes
def resolve(names: list[str]) -> list[Path]:
if not names:
names = [l.strip() for l in SETLIST.read_text().splitlines()
if l.strip() and not l.startswith("#")]
out = []
for nm in names:
p = Path(nm)
if p.exists():
out.append(p); continue
hits = sorted(ROOT.glob(f"live/**/{Path(nm).stem}.tidal"))
if hits:
out.append(hits[0])
else:
print(f" ?? no .tidal for {nm}", file=sys.stderr)
return out
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("tracks", nargs="*")
g = ap.add_mutually_exclusive_group(required=True)
g.add_argument("--plan", action="store_true")
g.add_argument("--apply", action="store_true")
a = ap.parse_args()
helpers = helper_ccs()
total_moves = total_over = 0
for p in resolve(a.tracks):
res = plan_track(p, helpers)
moves = [(s, d, o) for o, m in res["per_orbit"].items()
for s, d in m.items() if s != d]
keeps = sum(1 for m in res["per_orbit"].values()
for s, d in m.items() if s == d)
print(f"== {p.name} {len(moves)} move {keeps} keep "
f"{len(res['overflow'])} overflow")
for s, d, o in sorted(moves, key=lambda x: x[2]):
row = "A" if s in KNOB_A else "B" if s in KNOB_B else "C"
drow = "B" if d in KNOB_B else "C"
print(f" d{o:<2} ^{s:<3} {row}{(s-12) if s in KNOB_A else (s-28) if s in KNOB_B else s-48}"
f" -> ^{d:<3} {drow}{o}")
for o, cc, line in res["overflow"]:
print(f" d{o:<2} ^{cc:<3} line {line} -> gSel FIXME (no slot)")
total_moves += len(moves); total_over += len(res["overflow"])
if a.apply:
n, notes = rewrite(p, res)
print(f" APPLIED: {n} substitution(s), {len(notes)} FIXME(s)")
print(f"\n {total_moves} move(s), {total_over} overflow -> gSel")
if a.apply:
print(" NOW RUN: cd tools && python3 -m pvlint ... then "
"tools/silent-eval.py --seeded ...")
return 0
if __name__ == "__main__":
raise SystemExit(main())
...@@ -189,7 +189,19 @@ def main() -> int: ...@@ -189,7 +189,19 @@ def main() -> int:
ap.add_argument("--all", action="store_true", help="every .tidal under live/") ap.add_argument("--all", action="store_true", help="every .tidal under live/")
ap.add_argument("--plan", action="store_true", ap.add_argument("--plan", action="store_true",
help="print the exact ^NN -> ^NN moves per track") help="print the exact ^NN -> ^NN moves per track")
ap.add_argument("--knobs", action="store_true",
help="KNOB rows only (A/B/C) — the phase-1 scope. Buttons "
"(gates/mutes) are phase 2 and would otherwise inflate "
"the verdict with work that was deliberately deferred.")
a = ap.parse_args() a = ap.parse_args()
if a.knobs:
# Narrow the grid itself, so every downstream count -- cols, aligned,
# moves -- speaks about phase 1 only. A gate that reports undone phase-2
# work as a phase-1 failure is a gate you learn to ignore, which is how
# a real regression gets through.
for cc, (col, row) in list(GRID.items()):
if row not in ("A", "B", "C"):
del GRID[cc]
paths = (sorted(ROOT.glob("live/**/*.tidal")) if a.all else resolve(a.tracks)) paths = (sorted(ROOT.glob("live/**/*.tidal")) if a.all else resolve(a.tracks))
helpers = helper_ccs() helpers = helper_ccs()
......
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