Commit 3d91272e by PLN (Algolia)

feat(gig): the surface card — generated from the grid, not copied from it (#16)

#16 was ranked "bonus, can be 80/20ed". The cheat-sheet IS the 80/20: at OPAL
he is alone in a field with a laptop and a 48-cell control surface. The FOH
chain and the go-bag he can hold in his head. Twelve columns of CC numbers he
cannot.

GENERATED, NOT AUTHORED. A hand-written card is a copy of tools/lcxl_grid.py,
and a copy drifts silently the moment the grid changes — which it did twice
this fortnight. This reads the grid and the setlist directly, so a card that
disagrees with the rig is impossible by construction. `--md` rewrites
docs/GIG-CHEATSHEET.md; regenerate rather than edit.

THE EMERGENCY BOX GOES FIRST
The moment you reach for this page is the moment something is wrong. A card
that opens with a reference table makes you read past your own panic to get to
the answer. So the top of the page is eight failure modes and their remedy,
each one a symptom he has actually hit: LEDs dark but knobs alive (USB OUT
endpoint stall — replug), a track making no sound (it did not compile; one
block, one error), an orbit that will not die (it is the previous track's
pattern), mixer silent while Tidal looks fine (a fader; the desk wins on touch).

TWO LAYOUT BUGS THE FIRST RENDER CAUGHT
  * the title bar was hand-padded and off by one — now ljust'd like every
    other line, so it cannot drift again
  * gF3 and gM3 hold 7 and 8 orbits, which overran a side-by-side two-column
    family layout and silently misaligned the block. Stacked instead.

AND ONE COLUMN DELETED FOR SAYING NOTHING
The set table first carried a "roles" column. Every track in the set carries
kick+perc+bass+lead, so it printed the identical string 13 times and pushed the
page past 80 columns. Replaced with the orbits each track does NOT declare —
which is the fact worth having on stage, because an undeclared orbit is not
silent, it keeps playing whatever the previous track left there. That column is
the transition ghost list (#77), per track, at a glance. It also shows `perfect`
declaring all 12, i.e. the one track in the set that cannot inherit a ghost.

Verified: 0 lines over 80 columns, renders from a clean checkout, and the
family map printed matches lcxl_grid.filter_family/mute_family exactly.
parent d280acf0
# OPAL 2026 — surface card
*Generated by `tools/cheat-sheet.py` from `tools/lcxl_grid.py` + the
setlist. Do not hand-edit — regenerate.*
```
+----------------------------------------------------------------------------+
| WHEN IT GOES WRONG |
+----------------------------------------------------------------------------+
| |
| EVERYTHING OFF hush (or CC93 = gPanic, global kill) |
| ONE ORBIT OFF dN $ silence |
| SOUND BUT NO CONTROL the desk is not seeding -> sweep the row, |
| each knob wakes on its FIRST move |
| LEDS DARK, KNOBS WORK USB OUT endpoint stalled. REPLUG the LCXL. |
| Input keeps working while output is dead, |
| so 'it responds' does not mean it is fine. |
| A TRACK MAKES NO SOUND it did not compile. One block, one error — |
| check Pulsar's notification for EVAL ERROR. |
| AN ORBIT WILL NOT DIE it is the PREVIOUS track's pattern still |
| running (an orphan). dN $ silence, by hand. |
| MIXER SILENT, TIDAL OK an Ardour fader is down. Faders are CC77-84 |
| and the desk wins on touch. |
| |
+----------------------------------------------------------------------------+
```
## Before the first track
```
tools/gig-up.sh GO / NO-GO for the whole chain (cold, ~1 min)
tools/gig-up.sh --audio + boots every track and proves it sounds
```
A green `gig-up` proves the set **boots** correct. It does NOT prove the
mixer is correct — `check-mix` reads the SAVED Ardour session, and the
physical desk drifts from it in both directions. **Ctrl+S in Ardour**
before trusting a green fader line.
## The two groupings (they are NOT the same, on purpose)
```
FILTERS group by BLOC MUTES group by ROLE
row C, knobs 1-3 row F, buttons 1-3
gF1 ^49 d1,d2,d3,d8
gF2 ^50 d4
gF3 ^51 d5,d6,d7,d9,d10,d11,d12
gM1 ^73 d1
gM2 ^74 d2,d3,d8
gM3 ^75 d4,d5,d6,d7,d9,d10,d11,d12
DJ filter: 0.5 = BYPASS (centre). 0.05 is a ~26 Hz lowpass = silence.
So a filter knob left at zero is not neutral, it is off.
```
The kick sits alone on **gM1** so it can drop by itself, but filters with
the whole rhythm bloc on **gF1**. That asymmetry is deliberate: `gM<N>`
and `gF<N>` carrying different numbers on the same orbit is CORRECT.
## The surface, column by column
```
col 1 2 3 4 5 6 7 8
------------------------------------------------------------
A ^13 ^14 ^15 ^16 ^17 ^18 ^19 ^20
B ^29 ^30 ^31 ^32 ^33 ^34 ^35 ^36
C ^49 ^50 ^51 ^52 ^53 ^54 ^55 ^56
D ^77 ^78 ^79 ^80 ^81 ^82 ^83 ^84
E ^41 ^42 ^43 ^44 ^57 ^58 ^59 ^60
F ^73 ^74 ^75 ^76 ^89 ^90 ^91 ^92
------------------------------------------------------------
A knob top d9-d12 level (1-4) | d9-d12 fx (5-8)
B knob mid per-orbit effect, column N = orbit N
C knob bot 1-3 FAMILY FILTER | 4-8 per-orbit fx2
D fader orbit level -- ARDOUR OWNS THESE (CC77-84)
E button per-orbit gate, column N = orbit N
F button 1-3 FAMILY MUTE | 4-8 per-orbit gate2
NEVER send CC 77-84 from Tidal (Ardour track gains; 77 down = silence).
NEVER sweep CC 93 (gPanic).
```
## The set, in play order
```
# track declares ghost risk
--------------------------------------------------------------------------
1 bombe_dj 1,2,3,4,5,7,8,9 6,10,11,12
2 wap 1,2,3,4,5,7,8,9 6,10,11,12
3 do_it_right 1,2,3,4,5,6,8,10,12 7,9,11
4 take_5_drops 1,2,3,4,5,7,8,10,11,12 6,9
5 piment_bresilien 1,2,3,4,5,7,8,10 6,9,11,12
6 perfect 1,2,3,4,5,6,7,8,9,10,11,12 -
7 gimme_acid 1,2,3,4,5,8,9,10,11,12 6,7
8 vague_de_crime 1,2,3,4,5,6,7,8,10 9,11,12
9 mafia_sans_serif 1,2,3,4,5,7,8 6,9,10,11,12
10 you_my_sunshine 1,2,3,4,5,6,8,9,11 7,10,12
11 desire 1,2,3,4,5,6,7,8,9 10,11,12
12 the_revolution_will_be_sampled 1,2,3,4,5,7,8,9,10,11,12 6
13 electric_hammer 1,2,3,4,5,9,11 6,7,8,10,12
```
An orbit a track does **not** declare is not silent by default — it keeps
playing whatever the previous track put there. When a transition sounds
wrong, that is the first thing to suspect.
#!/usr/bin/env python3
"""cheat-sheet — the one page PLN reads at the venue, generated not written.
WHY THIS EXISTS
---------------
#16, ranked "bonus, can be 80/20ed". The cheat-sheet IS the 80/20: at OPAL he
is alone in a field with a laptop, a desk, and no assistant, and the two things
he will actually need are "which knob does what" and "what do I press when it
goes wrong". Everything else in #16 (FOH signal chain, go-bag) he can hold in
his head; a 12-column control surface he cannot.
GENERATED, NOT AUTHORED — on purpose. A hand-written card is a copy of the
grid, and a copy drifts silently the moment the grid changes. This reads
`tools/lcxl_grid.py` (the one authored table, #97) and the setlist, so a card
that disagrees with the rig is impossible by construction. Same discipline as
the generated HUD map: one parser per concept, one author per fact.
It deliberately puts the EMERGENCY box first. The moment you need this page is
the moment something is wrong, and a card that opens with a reference table
makes you read past your own panic to reach the answer.
USAGE
python3 tools/cheat-sheet.py # print it
python3 tools/cheat-sheet.py --md PATH # write it (default docs/)
"""
from __future__ import annotations
import argparse
import pathlib
import sys
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
import lcxl_grid as grid # noqa: E402
from pvlint.core import Track # noqa: E402
ROOT = pathlib.Path(__file__).resolve().parent.parent
DEFAULT_OUT = ROOT / "docs" / "GIG-CHEATSHEET.md"
W = 78 # printable width — fits one portrait page at a readable font size
def setlist():
"""(codename, path, sorted orbits) in play order. Reuses set-coherence's parser."""
import importlib.util
spec = importlib.util.spec_from_file_location(
"sc", pathlib.Path(__file__).resolve().parent / "set-coherence.py")
m = importlib.util.module_from_spec(spec)
spec.loader.exec_module(m)
out = []
for p in m.setlist_tracks():
if not p.exists():
continue
orbits = sorted({o.number for o in
Track(path=str(p), text=p.read_text()).orbits()})
out.append((p.stem, p, orbits))
return out
def build() -> str:
L: list[str] = []
def w(s: str = "") -> None:
L.append(s)
w("# OPAL 2026 — surface card")
w()
w("*Generated by `tools/cheat-sheet.py` from `tools/lcxl_grid.py` + the")
w("setlist. Do not hand-edit — regenerate.*")
w()
# ---- emergency, first, always ------------------------------------------
w("```")
w("+" + "-" * (W - 2) + "+")
w("|" + " WHEN IT GOES WRONG".ljust(W - 2) + "|")
w("+" + "-" * (W - 2) + "+")
for line in [
"",
" EVERYTHING OFF hush (or CC93 = gPanic, global kill)",
" ONE ORBIT OFF dN $ silence",
" SOUND BUT NO CONTROL the desk is not seeding -> sweep the row,",
" each knob wakes on its FIRST move",
" LEDS DARK, KNOBS WORK USB OUT endpoint stalled. REPLUG the LCXL.",
" Input keeps working while output is dead,",
" so 'it responds' does not mean it is fine.",
" A TRACK MAKES NO SOUND it did not compile. One block, one error —",
" check Pulsar's notification for EVAL ERROR.",
" AN ORBIT WILL NOT DIE it is the PREVIOUS track's pattern still",
" running (an orphan). dN $ silence, by hand.",
" MIXER SILENT, TIDAL OK an Ardour fader is down. Faders are CC77-84",
" and the desk wins on touch.",
"",
]:
w("|" + line.ljust(W - 2) + "|")
w("+" + "-" * (W - 2) + "+")
w("```")
w()
# ---- before you start ---------------------------------------------------
w("## Before the first track")
w()
w("```")
w(" tools/gig-up.sh GO / NO-GO for the whole chain (cold, ~1 min)")
w(" tools/gig-up.sh --audio + boots every track and proves it sounds")
w("```")
w()
w("A green `gig-up` proves the set **boots** correct. It does NOT prove the")
w("mixer is correct — `check-mix` reads the SAVED Ardour session, and the")
w("physical desk drifts from it in both directions. **Ctrl+S in Ardour**")
w("before trusting a green fader line.")
w()
# ---- the two groupings --------------------------------------------------
w("## The two groupings (they are NOT the same, on purpose)")
w()
fam_f: dict[int, list[int]] = {}
fam_m: dict[int, list[int]] = {}
for o in range(1, 13):
fam_f.setdefault(grid.filter_family(o), []).append(o)
fam_m.setdefault(grid.mute_family(o), []).append(o)
w("```")
w(" FILTERS group by BLOC MUTES group by ROLE")
w(" row C, knobs 1-3 row F, buttons 1-3")
w()
# Stacked, not side-by-side: gF3/gM3 hold 7 and 8 orbits, which blew past a
# two-column layout and silently misaligned the whole block.
for tag, cc0, fam in (("gF", 48, fam_f), ("gM", 72, fam_m)):
for i in (1, 2, 3):
names = ",".join(f"d{o}" for o in fam.get(i, []))
w(f" {tag}{i} ^{cc0+i} {names}")
w()
w(" DJ filter: 0.5 = BYPASS (centre). 0.05 is a ~26 Hz lowpass = silence.")
w(" So a filter knob left at zero is not neutral, it is off.")
w("```")
w()
w("The kick sits alone on **gM1** so it can drop by itself, but filters with")
w("the whole rhythm bloc on **gF1**. That asymmetry is deliberate: `gM<N>`")
w("and `gF<N>` carrying different numbers on the same orbit is CORRECT.")
w()
# ---- the surface --------------------------------------------------------
w("## The surface, column by column")
w()
w("```")
w(" col 1 2 3 4 5 6 7 8")
w(" " + "-" * 60)
rowmeaning = {
"A": "A knob top d9-d12 level (1-4) | d9-d12 fx (5-8)",
"B": "B knob mid per-orbit effect, column N = orbit N",
"C": "C knob bot 1-3 FAMILY FILTER | 4-8 per-orbit fx2",
"D": "D fader orbit level -- ARDOUR OWNS THESE (CC77-84)",
"E": "E button per-orbit gate, column N = orbit N",
"F": "F button 1-3 FAMILY MUTE | 4-8 per-orbit gate2",
}
for r in grid.PHYSICAL_ORDER:
ccs = dict(grid._ROWS[r][1])
line = " " + r + " " + " ".join(f"^{ccs[c]:<5}" for c in range(1, 9))
w(line.rstrip())
w(" " + "-" * 60)
for r in grid.PHYSICAL_ORDER:
w(" " + rowmeaning[r])
w()
w(" NEVER send CC 77-84 from Tidal (Ardour track gains; 77 down = silence).")
w(" NEVER sweep CC 93 (gPanic).")
w("```")
w()
# ---- the set ------------------------------------------------------------
w("## The set, in play order")
w()
# A "roles" column was tried and dropped: every track carries kick+perc+
# bass+lead, so it printed the same string 13 times and pushed the table
# past the page. What is NOT declared is the useful fact — those are the
# orbits that keep playing the previous track.
w("```")
w(f" {'#':>2} {'track':<32} {'declares':<27} ghost risk")
w(" " + "-" * 74)
for i, (stem, _p, orbits) in enumerate(setlist(), 1):
obs = ",".join(str(o) for o in orbits)
gaps = ",".join(str(o) for o in range(1, 13) if o not in orbits) or "-"
w(f" {i:>2} {stem:<32} {obs:<27} {gaps}")
w("```")
w()
w("An orbit a track does **not** declare is not silent by default — it keeps")
w("playing whatever the previous track put there. When a transition sounds")
w("wrong, that is the first thing to suspect.")
w()
return "\n".join(L) + "\n"
def main() -> int:
ap = argparse.ArgumentParser(prog="cheat-sheet")
ap.add_argument("--md", type=pathlib.Path, nargs="?", const=DEFAULT_OUT)
a = ap.parse_args()
text = build()
if a.md:
a.md.parent.mkdir(parents=True, exist_ok=True)
a.md.write_text(text)
print(f"wrote {a.md}")
else:
print(text, end="")
return 0
if __name__ == "__main__":
raise SystemExit(main())
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