Commit 37857225 by PLN (Algolia)

feat(surface): retire gMask, freeing ^41 for d1's gate — and stop the board denying d9 exists

Two changes PLN asked for, plus the five tests that had to be rewritten because
they encoded the behaviour we just removed.

== 1. gMask IS RETIRED ==
PLN: "kill gMask! its unprevisible anyway. we can then consider a gMask per track
... tbh gmask is risky id rather invest all on my own manual masking perf"

It was `midiOn "^41" (mask "t!7 f")` — top-left button chopping the last eighth
out of every bar on every stream wrapped in gM1/gM2/gM3, which in practice is
every orbit of every track. A global that silently removes events from everything
is exactly what makes a rig feel haunted, and the gesture plays better by hand.

It becomes `gMask = id` rather than being deleted: 230 .tidal files name it
directly and removing the binding would stop every one of them compiling. Same
retirement pattern already used for the midiG family (#73) — keep the name, empty
the behaviour, delete the usages at leisure. gM1/gM2/gM3 are now just the mutes.

The payoff is the SURFACE, not the sound: ^41 is button-top-row column 1, the one
slot the column-aligned button map needs for d1's gate. With it free, the top
button row can be fully per-orbit d1..d8 and the whole board reduces to one
sentence. That unblocks phase 2 (#92).

Worth recording how visible this was: gig-log reported `41 127 gMask
someCyclesBy gate -- gates 100% of cycles`. gMask had been fully ARMED on the
live board since 15:36 while PLN was testing the set. Nothing was broken; a
global was simply switched on and nothing put that in front of him. He has since
pushed the state buttons back off.

== 2. THE BOARD WAS DENYING d9 EXISTS ==
PLN, on bombe_dj: "i see no led under d9 while d9 has a sound a FMRhodes".

A1-A4 are d9-d12's LEVEL, MIDI-learned in Ardour to the Tidal 09-12 track gains
(#46). No .tidal writes ^13-^16 — and it must not, since Tidal never sees those
CCs — so a "^NN" scan finds nothing and the knobs went dark. Dark means "not
mapped here" (feedback_dark_means_unmapped_outranks_all), so the board asserted
that four working controls did nothing.

To be precise about a phrasing that confused things: the ORBITS d9-d12 are
entirely Tidal's, they carry real sounds (wap's d9 is vec1_acid), and every
orbit-parsing tool sees them — HUD, pvlint, silent-eval, surface-columns. What
belongs to Ardour is only the four CC NUMBERS. The orbit was always visible; the
knob-to-orbit LINK was the missing piece.

parse_track now lights A_N exactly when the track DECLARES d(8+N), coloured by
that orbit's own family — these are per-orbit by construction, unlike the shared
filters and mutes. A track with no d11 still gets a dark A3, because there is
nothing there to level. So the row answers the question actually asked mid-set:
which extra orbits does this track have, and where is their volume?

== 3. FIVE TESTS REWRITTEN, NOT DELETED ==
They asserted gM1 -> {41,73} and "the mask is measured by DENSITY". Both were
true and are now false. Each was retargeted at the new truth rather than removed,
and one was ADDED that the old design never needed:

  test_gMask_is_retired_and_claims_no_cc  — asserts ^41 is owned by NOTHING,
  scanning every helper. A gMask that quietly reclaimed CC 41 would put two jobs
  on one button, which is the precise failure this remap exists to remove. That
  deserves a guard, not a comment.

Verified: check-boot all 4 passes green (helpers audible against an untouched
controller, and the block-seam replay confirms 32 blocks each parse as one
statement); silent-eval --seeded on bombe_dj still emits on every declared orbit;
305 tests pass.

NOTE the retirement only takes effect on a Tidal REBOOT — the running ghci
loaded BootTidal at 16:36, seven minutes before this edit. Until then ^41 still
masks.
parent 973f77dd
......@@ -387,13 +387,31 @@ let -- DPV specific parameters
-- orDef 0 (#55): untouched
-- must be NORMAL, not silent
-- Global masks (defaults)
gMask = (midiOn "^41" (mask "t!7 f"))
-- gMask is RETIRED (2026-07-29). It used to be `midiOn "^41" (mask "t!7 f")`
-- — button 1 chopping the last 8th out of every bar. PLN killed it:
-- "kill gMask! its unprevisible anyway ... id rather invest all on my own
-- manual masking perf"
-- and he is right: a global that silently removes an event from every stream
-- it wraps is exactly the kind of thing that makes a rig feel haunted, and
-- the gesture is better played by hand.
--
-- It stays as `id` rather than being deleted because 230 .tidal files name it
-- directly; removing the binding would stop every one of them compiling. Same
-- retirement pattern this rig already used for the midiG family (#73): keep
-- the name, empty the behaviour, delete the usages later at leisure.
--
-- The payoff is the surface, not the sound: retiring it FREES ^41 (button top
-- row, column 1), which is the one slot the column-aligned button map needed
-- for d1's gate. With ^41 free, the top button row becomes fully per-orbit
-- (d1..d8) and the whole board reduces to one sentence — see #46/#92.
gMask = id
gMute1 = (midiOn "^73" (mask "f*16"))
gMute2 = (midiOn "^74" (mask "f*16"))
gMute3 = (midiOn "^75" (mask "f*16"))
gM1 = gMask . gMute1
gM2 = gMask . gMute2
gM3 = gMask . gMute3
-- gM<N> used to be `gMask . gMute<N>`; with gMask retired it is just the mute.
gM1 = gMute1
gM2 = gMute2
gM3 = gMute3
-- Launchpad S controls
-- Launchpad S pad controls
......
......@@ -346,10 +346,21 @@ def _bindings_for(tmp_path, body):
return led.parse_track(p)
def test_gM3_lights_both_the_mask_and_its_mute(tmp_path):
def test_gM3_lights_its_mute(tmp_path):
b = _bindings_for(tmp_path, 'd1 $ gF1 $ gM3\n $ "bd*4"\n')
assert 41 in b, "gM3 includes gMask on ^41 — it must not be dark"
assert 75 in b, "gM3 includes gMute3 on ^75 — it must not be dark"
assert 75 in b, "gM3 is gMute3 on ^75 — it must not be dark"
def test_naming_a_gM_does_NOT_light_41_since_gMask_was_retired(tmp_path):
"""^41 belongs to d1's gate now. gMask = id owns nothing (2026-07-29).
Lighting ^41 off the back of a gM<N> would be the old behaviour surviving in
the display after being removed from the sound — a board describing a rig that
no longer exists, which is the exact class of lie this session spent the
afternoon removing.
"""
b = _bindings_for(tmp_path, 'd1 $ gF1 $ gM1 $ "bd*4"\n')
assert 41 not in b
def test_each_gM_variant_selects_its_own_mute(tmp_path):
......@@ -360,9 +371,28 @@ def test_each_gM_variant_selects_its_own_mute(tmp_path):
assert not (others & set(b)), f"{name} must not light {others}"
def test_bare_gMute_and_gMask_names_are_also_resolved(tmp_path):
b = _bindings_for(tmp_path, 'd1 $ gMask $ gMute2 $ "bd*4"\n')
assert 41 in b and 74 in b
def test_a_bare_gMute_name_is_resolved(tmp_path):
b = _bindings_for(tmp_path, 'd1 $ gMute2 $ "bd*4"\n')
assert 74 in b
def test_declaring_a_high_orbit_lights_its_level_knob(tmp_path):
"""A1-A4 are d9-d12's LEVEL, MIDI-learned to Ardour — no .tidal names ^13-^16.
PLN, on bombe_dj: "i see no led under d9 while d9 has a sound a FMRhodes".
A "^NN" scan cannot see these because Ardour owns the CC, so the board painted
a working control as unmapped. Light A_N when the track DECLARES d(8+N).
"""
b = _bindings_for(tmp_path, 'd9 $ gF3 $ "FMRhodes2"\n')
assert 13 in b, "declaring d9 must light A1, its level knob"
assert not ({14, 15, 16} & set(b)), \
"d10/d11/d12 are not declared — their level knobs stay dark"
def test_high_orbit_level_knobs_cover_d9_through_d12(tmp_path):
for orbit, cc in ((9, 13), (10, 14), (11, 15), (12, 16)):
b = _bindings_for(tmp_path, f'd{orbit} $ gF3 $ "arpy"\n')
assert cc in b, f"d{orbit} must light ^{cc}"
def test_a_commented_out_helper_does_not_light_anything(tmp_path):
......
......@@ -214,22 +214,47 @@ def test_the_dj_filter_lens_is_borrowed_from_the_parameterised_helper():
def test_boot_helpers_resolves_composition():
"""`gM1 = gMask . gMute1` inherits BOTH — the mask on CC41 and the mute on CC73."""
assert set(lens.boot_helpers()["gM1"]) == {41, 73}
assert set(lens.boot_helpers()["gM2"]) == {41, 74}
assert set(lens.boot_helpers()["gM3"]) == {41, 75}
"""`gM<N> = gMute<N>` — one CC each, since gMask was retired 2026-07-29.
This test used to assert {41, 73} because gM1 was `gMask . gMute1`. Keeping the
composition-resolving assertion matters even though the composition is now
trivial: the parser's job is to follow `.` chains, and a one-link chain still
exercises that path. What changed is the ANSWER, not the requirement.
"""
assert set(lens.boot_helpers()["gM1"]) == {73}
assert set(lens.boot_helpers()["gM2"]) == {74}
assert set(lens.boot_helpers()["gM3"]) == {75}
def test_the_mutes_are_measured_by_LEVEL():
"""gMute is `mask "f*16"` — it silences the stream, so the lens is rms.
def test_the_mutes_are_measured_by_LEVEL_and_the_mask_by_DENSITY():
"""Same keyword, opposite lenses. gMask is `mask "t!7 f"` (drops one event in
eight -> onsets); gMute is `mask "f*16"` (silences the stream -> rms)."""
The density half of this test is gone with gMask (see the next test). The
distinction it protected is still worth stating: `mask` appears in both
helpers, so the KEYWORD cannot pick the lens — only what the mask pattern
actually does can. [[feedback_right_lens_per_control]].
"""
h = lens.boot_helpers()
assert h["gMask"][41][1] == "onsets"
for n, cc in ((1, 73), (2, 74), (3, 75)):
assert h[f"gMute{n}"][cc][0] == "mute"
assert h[f"gMute{n}"][cc][1] == "rms"
def test_gMask_is_retired_and_claims_no_cc():
"""gMask = id since 2026-07-29 — PLN: "kill gMask! its unprevisible anyway".
It is kept as a name (230 .tidal files call it) but owns nothing. The reason
this needs a TEST and not just a comment: ^41 has been handed to d1's gate in
the column-aligned button map, so a gMask that quietly reclaimed CC 41 would
put two different jobs on one button — and that is the failure this whole
remap exists to remove. Assert the slot is free.
"""
h = lens.boot_helpers()
assert not h.get("gMask"), f"gMask must own no CC, got {h.get('gMask')}"
for name, ccs in h.items():
assert 41 not in ccs, f"^41 must be free for d1's gate, but {name} claims it"
def test_all_three_mutes_are_classified_IDENTICALLY():
"""The bug this caught: gMute1/2/3 are three textually identical helpers and they
came back mute, density, density. rms would have been the wrong lens on two of
......@@ -257,10 +282,10 @@ def test_an_all_false_mask_is_a_mute_and_anything_else_is_density(body, is_mute)
def test_a_continuation_line_cannot_leak_into_the_wrong_helper():
"""`gM3 = gMask . gMute3` is followed by the Launchpad block, and appending every
indented line to whatever was defined last made gM3 report CC 7 and CC 9 as
mutes. A continuation has to LOOK like one (open with an operator)."""
assert set(lens.boot_helpers()["gM3"]) == {41, 75}
"""`gM3` is followed by the Launchpad block, and appending every indented line
to whatever was defined last made gM3 report CC 7 and CC 9 as mutes. A
continuation has to LOOK like one (open with an operator)."""
assert set(lens.boot_helpers()["gM3"]) == {75}
assert 7 not in lens.boot_helpers()["gM3"]
......
......@@ -426,14 +426,29 @@ def parse_track(path: Path) -> dict[int, str]:
# stable colour beats an arbitrary one. Refining the button colour language is
# #49/#51.
whole = "\n".join(re.sub(r"--.*$", "", ln) for ln in raw)
for name in set(re.findall(r"\b(gMask|gMute[123]|gM[123])\b", whole)):
if name == "gMask":
bindings.setdefault(41, "fx")
elif name.startswith("gMute"):
bindings.setdefault(72 + int(name[-1]), "fx")
else: # gM1/gM2/gM3 = mask + one mute
bindings.setdefault(41, "fx")
bindings.setdefault(72 + int(name[-1]), "fx")
for name in set(re.findall(r"\b(gMute[123]|gM[123])\b", whole)):
bindings.setdefault(72 + int(name[-1]), "fx")
# gMask is retired to `id` in BootTidal (2026-07-29) and no longer owns ^41,
# which now belongs to d1's gate. Naming it must not light anything.
# A1-A4 = the LEVEL of d9..d12, MIDI-learned to Ardour (#46). No .tidal ever
# writes ^13-^16 — Ardour owns them — so a "^NN" scan leaves them dark, and
# dark means "not mapped here". PLN, on bombe_dj: "i see no led under d9 while
# d9 has a sound a FMRhodes". The board was denying the existence of a control
# that works.
#
# Light A_N exactly when the track DECLARES d(8+N), so the row answers the
# question actually being asked mid-set: which extra orbits does this track
# have, and where is their volume? A track with no d11 still gets a dark A3,
# because there is nothing there to level.
#
# 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.
declared = {int(m.group(1)) for ln in raw
if (m := BLOCK_ORBIT.match(ln)) is not None}
for orbit in declared:
if 9 <= orbit <= 12:
bindings.setdefault(12 + (orbit - 8), classify_sound(None, orbit))
return bindings
......
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