Commit 78ba7fca by PLN (Algolia)

fix(lcxl3): we were fighting for the wrong screen — every knob owns its own

PLN, three rounds in: the OLED "still shows values". Move a fader and you get
the firmware's bare 0-127; move a relative encoder and you get "0", because the
wire value of a relative encoder IS 0-ish. "No info at all." Meanwhile our own
overlay — track name, what the control does, a readout — was being re-summoned
every 0.9 s under a 1.2 s firmware timeout, field-diffed, stamped only on real
bring-ups. Three increasingly clever versions of the same losing move.

The programmer's reference says why, in the display chapter's first list:

    "• 05h (5) - 24h (36): Temporary display for Analogue controls (same as CC
       indices, 05h (5) - 0Ch (12): Faders, 0Dh (13) - 24h (36): Encoders)
     • 35h (53): Permanent/Stationary display
     • 36h (54): Overlay/Temporary display"

There are not two display targets. There are thirty-four. Every fader and every
encoder owns a display target of its own, addressed by its own DAW-mode CC
index, and THAT is the one the firmware auto-triggers when you touch the
control — not 36h, which is where we had been writing all evening. Config bit 6
is its on-switch and it defaults to on:

    "• Bit 6: Allow Launch Control XL 3 to generate temporary display
       automatically on Change (default: Set).
     • Bit 5: Allow Launch Control XL 3 to generate temporary display
       automatically on Touch (default: Set; this is the Shift + rotate)."

So a fader sweep fired target 05h thirty times a second while we re-summoned
36h once a second. Unwinnable by construction, and no amount of keep-alive
tuning was ever going to change that. It also explains the one part of the
symptom that never fit a timeout story — buttons were fine. Buttons live at
25h-34h, outside the analogue range, own no per-control target, and so were
never contested at all.

And the fix is not to win the fight. Arrangement 4 is the firmware's default:

    "4 | 2 lines: Parameter Name and Numeric Parameter Value (default) | Yes | 1 | Name"

One field, Name, and a value the firmware draws itself. The Name field was
empty because nobody had ever written it. Write it, and the display we were
trying to suppress becomes the display we wanted: it already tracks the value at
wire speed and already times its own dismissal. So the driver now labels all 32
analogue controls on their own targets at startup — "d4 # crushbus",
"d5 # octerbus", "d1 level [ard]" — and stands down from the overlay for them.

Faders and A1-A4 carry no ^NN in any .tidal, because they are Ardour-learned
levels; their label comes from the grid's role table instead, with an explicit
[ard] mark. Mid-set, "this fader is Ardour's, not Tidal's" is the most useful
sixteen characters the panel can hold. Unmapped controls say "unmapped", because
dark-means-unmapped is the surface's oldest rule and the screen must not
contradict the LED beside it.

Three controls keep our overlay: the DJF knobs. "HPF 3.4kHz !!" and the heart
frames at the zero detent are prose, and arrangement 4's value field is a
number. For exactly those three targets we clear bits 5+6 — which is the
documented, per-control way to make the firmware stand down — and take the
screen back uncontested. Restored on exit, since a next session finding three
knobs that silently refuse to show a value would be a worse bug than this one.

Along the way, three things the per-control screen made visible by being the
first thing to render our labels at full width:

- set_text filtered to `0x20 <= c <= 0x7E` and so dropped the four reassigned
  control codes its own docstring advertised. The language module's heart frames
  had been arriving as plain spaces this whole time.
- parse_context said "an inline trailing comment IS the best label" and did not
  do it: the only `--` strip was anchored at the start of the body, where a
  trailing comment never is, so the Haskell always won. C8 of piment_bresilien
  read `d8 # n "23")) --` on a line ending in PLN's own `-- Raise COMEON!`.
  The comment is split off first now and preferred.
- With no stage note, the body fallback was raw combinator soup. It now reads
  the sample name off the first quoted string and the effect off the first
  identifier — "break:18", "n 6" — which is what PLN asked the line to say
  ("sample name or effect like 'rose:4'"). Across live/: 158 syntax-noisy
  labels out of 22368 became 3.

Verification I can actually do, given a write-only display and no readback:
--show-oled dry-runs the whole label table offline, opening no port, and fails
on any name over 16 characters; the new test pins the target numbers against the
guide's table, the exact configure/set_text bytes, that a button raises instead
of writing into the void, that the hearts survive the filter, and the three
parse_context cases above. What no test can prove is a pixel. That needs eyes.

Fallbacks kept in case the name field turns out not to render: --oled-overlay
suppresses every native display and restores the uncontested-overlay behaviour
across the whole surface, and --oled-native-all hands even the DJFs to the
firmware, for the eye test that compares the two side by side.
parent ca173ae1
...@@ -85,11 +85,74 @@ FEAT_DISPLAY_TIMEOUT = 0x71 # 113, 1/10 s units ...@@ -85,11 +85,74 @@ FEAT_DISPLAY_TIMEOUT = 0x71 # 113, 1/10 s units
FEAT_ENCODER_CURVE = 0x79 # 121, 0 slow / 1 med / 2 fast FEAT_ENCODER_CURVE = 0x79 # 121, 0 slow / 1 med / 2 fast
# Display targets ("Configure displays") # Display targets ("Configure displays")
#
# The guide lists THREE kinds of target, and the middle one is the one we missed
# for a whole evening of chasing a display we could not win:
#
# "• 05h (5) - 24h (36): Temporary display for Analogue controls (same as CC
# indices, 05h (5) - 0Ch (12): Faders, 0Dh (13) - 24h (36): Encoders)
# • 35h (53): Permanent/Stationary display
# • 36h (54): Overlay/Temporary display"
#
# So every analogue control OWNS A DISPLAY TARGET, addressed by its own DAW-mode
# CC index. That target is what the firmware auto-triggers when you move the
# control — not 36h. Fighting it from 36h is unwinnable by construction; the fix
# is to write ITS name field.
TGT_CONTROL_FIRST = 0x05 # 5 - per-control temporary display == the CC index
TGT_CONTROL_LAST = 0x24 # 36
TGT_STATIONARY = 0x35 # 53 - permanent display TGT_STATIONARY = 0x35 # 53 - permanent display
TGT_TEMPORARY = 0x36 # 54 - overlay/temporary TGT_TEMPORARY = 0x36 # 54 - overlay/temporary (GLOBAL, not per-control)
TGT_BITMAP_STATIONARY = 0x20 # 32 - bitmap only accepts 32 or 33 TGT_BITMAP_STATIONARY = 0x20 # 32 - bitmap only accepts 32 or 33
TGT_BITMAP_TEMPORARY = 0x21 # 33 TGT_BITMAP_TEMPORARY = 0x21 # 33
# CONFIG_DISPLAY's <config> byte carries more than the arrangement:
#
# "• Bit 6: Allow Launch Control XL 3 to generate temporary display
# automatically on Change (default: Set).
# • Bit 5: Allow Launch Control XL 3 to generate temporary display
# automatically on Touch (default: Set; this is the Shift + rotate).
# • Bit 0-4: Display arrangement"
#
# Bit 6 is the native value display's on-switch, and it defaults to SET. Clearing
# it on a per-control target is the documented way to make the firmware stop
# summoning its own screen for that control.
CFG_CANCEL = 0x00 # "special value for cancelling display"
CFG_BRING_UP = 0x7F # "bring up the display with its current contents"
CFG_AUTO_CHANGE = 0x40 # bit 6
CFG_AUTO_TOUCH = 0x20 # bit 5
CFG_AUTO_BOTH = CFG_AUTO_CHANGE | CFG_AUTO_TOUCH
# Display arrangements (guide's table; "Num" = the VALUE is firmware-supplied).
ARR_CANCEL = 0 # special: cancel
ARR_NAME_TEXTVAL = 1 # 2 lines, F0 Name / F1 Value (text value, ours)
ARR_TITLE_NAME_TEXTVAL = 2 # 3 lines, F0 Title / F1 Name / F2 Value (ours)
ARR_TITLE_8NAMES = 3 # 1 line + 2x4, 9 fields
ARR_NAME_NUMVAL = 4 # 2 lines, Name + NUMERIC value — the DEFAULT, and
# the one that renders the bare "0" PLN kept seeing:
# one field (Name), value drawn by the firmware.
ARR_TRIGGER = 31 # special: trigger
def is_analogue(index: int) -> bool:
"""Does this DAW-mode control index own a per-control display target?
True for the 8 faders and 24 encoders (05h-24h). False for the buttons
(25h+), which have no per-control target — the global 36h overlay is
uncontested there, which is why buttons never lost the screen.
"""
return TGT_CONTROL_FIRST <= index <= TGT_CONTROL_LAST
def display_config(arrangement: int = ARR_NAME_NUMVAL,
auto_change: bool = True, auto_touch: bool = True) -> int:
"""Pack a <config> byte. Defaults reproduce the firmware's own defaults."""
cfg = arrangement & 0x1F
if auto_change:
cfg |= CFG_AUTO_CHANGE
if auto_touch:
cfg |= CFG_AUTO_TOUCH
return cfg
# Surface modes, for FEAT_MODE_SELECT # Surface modes, for FEAT_MODE_SELECT
MODES = {"mixer": 0x01, "control": 0x02} MODES = {"mixer": 0x01, "control": 0x02}
for _n in range(1, 5): # Custom 1-4 -> 06h-09h for _n in range(1, 5): # Custom 1-4 -> 06h-09h
...@@ -239,10 +302,55 @@ class Surface: ...@@ -239,10 +302,55 @@ class Surface:
self.sysex(CMD_CONFIG_DISPLAY, target, config & 0x7F) self.sysex(CMD_CONFIG_DISPLAY, target, config & 0x7F)
def set_text(self, target: int, field: int, text: str) -> None: def set_text(self, target: int, field: int, text: str) -> None:
"""ASCII 20h-7Eh only; 1Bh/1Ch/1Dh/1Eh are box/box/flat/heart.""" """ASCII 20h-7Eh, plus 1Bh/1Ch/1Dh/1Eh = box/filled box/flat/heart.
data = [ord(c) & 0x7F for c in text if 0x20 <= ord(c) <= 0x7E]
The first filter said `0x20 <= c <= 0x7E` and so silently DROPPED the
four reassigned control codes the docstring itself advertised — the
language module's heart frames (`\\x1e 0 \\x1e`) were reaching the
device as ` 0 `. The guide is explicit that these four are legal:
"The text uses the standard ASCII character mapping in the range 20h
(32) - 7Eh (126) with the addition of the below control codes, which
have been reassigned to provide additional non-ASCII characters."
"""
data = [ord(c) & 0x7F for c in text
if 0x20 <= ord(c) <= 0x7E or 0x1B <= ord(c) <= 0x1E]
self.sysex(CMD_SET_TEXT, target, field, *data) self.sysex(CMD_SET_TEXT, target, field, *data)
# ------------------------------------------------- the per-control screen ----
def label_control(self, index: int, name: str,
arrangement: int = ARR_NAME_NUMVAL,
native: bool = True) -> None:
"""Teach the firmware what to CALL this control on its own overlay.
`native=True` leaves bits 5+6 set, so the firmware keeps auto-triggering
this control's display on change/touch — but now it draws the name WE
gave it above the value it already tracks. `native=False` clears both
bits, which stops the firmware summoning anything for this control and
leaves the global 36h overlay uncontested.
The arrangement is sent non-zero either way, because the guide warns:
"for changing triggerability, it needs to be set non-zero (since the
value 0 for these still acts for cancelling the display)".
Addressed by the ABSOLUTE control index, exactly like the LEDs — a
relative-mode encoder reports CC 4Dh-64h but is still target 0Dh-24h.
"""
if not is_analogue(index):
raise ValueError(
f"control {index} (0x{index:02X}) has no per-control display "
f"target; only faders/encoders 0x05-0x24 do")
self.configure_display(
index, display_config(arrangement, native, native))
self.set_text(index, 0, name)
def native_overlay(self, on: bool,
arrangement: int = ARR_NAME_NUMVAL) -> None:
"""Enable/disable the firmware's auto value display on ALL analogue controls."""
cfg = display_config(arrangement, on, on)
for index in range(TGT_CONTROL_FIRST, TGT_CONTROL_LAST + 1):
self.configure_display(index, cfg)
def bitmap(self, rows: list[int], target: int = TGT_BITMAP_STATIONARY) -> None: def bitmap(self, rows: list[int], target: int = TGT_BITMAP_STATIONARY) -> None:
"""1216 bytes: 19 per row x 64 rows, 7 pixels per byte, MSB leftmost. """1216 bytes: 19 per row x 64 rows, 7 pixels per byte, MSB leftmost.
...@@ -451,6 +559,32 @@ def cmd_text(args) -> int: ...@@ -451,6 +559,32 @@ def cmd_text(args) -> int:
return 0 return 0
def cmd_label(args) -> int:
"""Name one analogue control on the firmware's OWN per-control overlay."""
s = Surface("daw")
s.label_control(args.index, args.name, args.arrangement,
native=not args.suppress)
print(f"target 0x{args.index:02X} ({control_name(args.index)}) "
f"name -> {args.name!r}")
print(" native auto-trigger: "
+ ("OFF (bits 5+6 clear — the firmware will not summon this control's "
"screen)" if args.suppress else "ON (bits 5+6 set)"))
print(" now MOVE that control and read the screen.")
s.close()
return 0
def cmd_native(args) -> int:
"""Turn the firmware's automatic value display on/off for every analogue control."""
s = Surface("daw")
s.native_overlay(args.state == "on", args.arrangement)
print(f"native auto value display -> {args.state} for targets "
f"0x{TGT_CONTROL_FIRST:02X}-0x{TGT_CONTROL_LAST:02X} "
f"(32 controls), arrangement {args.arrangement}")
s.close()
return 0
def cmd_bitmap(args) -> int: def cmd_bitmap(args) -> int:
s = Surface("daw") s = Surface("daw")
target = TGT_BITMAP_TEMPORARY if args.temporary else TGT_BITMAP_STATIONARY target = TGT_BITMAP_TEMPORARY if args.temporary else TGT_BITMAP_STATIONARY
...@@ -632,6 +766,24 @@ def main(argv: list[str] | None = None) -> int: ...@@ -632,6 +766,24 @@ def main(argv: list[str] | None = None) -> int:
q.add_argument("--temporary", action="store_true") q.add_argument("--temporary", action="store_true")
q.set_defaults(func=cmd_text) q.set_defaults(func=cmd_text)
q = sub.add_parser("label", help="name ONE analogue control on the firmware's "
"own per-control overlay (targets 0x05-0x24)")
q.add_argument("index", type=int, help="DAW-mode control index: 5-12 fader, 13-36 encoder")
q.add_argument("name")
q.add_argument("--arrangement", type=int, default=ARR_NAME_NUMVAL,
help="4 = Name + firmware's numeric value (default); "
"1 = Name + our own text value")
q.add_argument("--suppress", action="store_true",
help="clear config bits 5+6: stop the firmware auto-summoning "
"this control's display at all")
q.set_defaults(func=cmd_label)
q = sub.add_parser("native", help="enable/disable the firmware's automatic value "
"display on ALL analogue controls")
q.add_argument("state", choices=("on", "off"))
q.add_argument("--arrangement", type=int, default=ARR_NAME_NUMVAL)
q.set_defaults(func=cmd_native)
q = sub.add_parser("bitmap", help="draw a demo bitmap (optionally animated)") q = sub.add_parser("bitmap", help="draw a demo bitmap (optionally animated)")
q.add_argument("--frames", type=int, default=1) q.add_argument("--frames", type=int, default=1)
q.add_argument("--delay", type=float, default=0.12) q.add_argument("--delay", type=float, default=0.12)
......
...@@ -135,6 +135,13 @@ def button_colour(v2cc: int, pressed: bool, mapped: bool) -> tuple[int, int, int ...@@ -135,6 +135,13 @@ def button_colour(v2cc: int, pressed: bool, mapped: bool) -> tuple[int, int, int
# ------------------------------------------------------------------ OLED ----- # ------------------------------------------------------------------ OLED -----
# The families, named once. gDJF splits by BLOC and gMute by ROLE — the two are
# genuinely different partitions of the surface (see `mute role map`), so they
# get two tables, not one shared guess.
FAMILY_NAME = {1: "percs", 2: "bass", 3: "melodic"}
MUTE_NAME = {1: "kick", 2: "percs", 3: "bass+mel"}
def touch_lines(v2cc: int, value: int, def touch_lines(v2cc: int, value: int,
ctx: str | None = None) -> tuple[str, str, str]: ctx: str | None = None) -> tuple[str, str, str]:
"""(title, param, value) for the temporary touched-control overlay. """(title, param, value) for the temporary touched-control overlay.
...@@ -146,13 +153,9 @@ def touch_lines(v2cc: int, value: int, ...@@ -146,13 +153,9 @@ def touch_lines(v2cc: int, value: int,
role, who = G.CC_ROLE.get(v2cc, ("?", 0)) role, who = G.CC_ROLE.get(v2cc, ("?", 0))
lab = ctx or G.label(v2cc) lab = ctx or G.label(v2cc)
if role == "family_filter": if role == "family_filter":
fam = {1: "percs", 2: "bass", 3: "melodic"}.get(who, "?") return (f"DJF {FAMILY_NAME.get(who, '?')}", lab, djf_readout(value))
v = value
state = djf_readout(value)
return (f"DJF {fam}", lab, state)
if role == "family_mute": if role == "family_mute":
fam = {1: "kick", 2: "percs", 3: "bass+mel"}.get(who, "?") return (f"MUTE {MUTE_NAME.get(who, '?')}", lab, str(value))
return (f"MUTE {fam}", lab, str(value))
# Per-orbit cells lead with the ORBIT — "d6 fx2 / C6 / 100" — because the # Per-orbit cells lead with the ORBIT — "d6 fx2 / C6 / 100" — because the
# orbit is what PLN thinks in; the physical cell name is the secondary key. # orbit is what PLN thinks in; the physical cell name is the secondary key.
title = f"d{who} {role}" if isinstance(who, int) and who else lab title = f"d{who} {role}" if isinstance(who, int) and who else lab
...@@ -161,6 +164,42 @@ def touch_lines(v2cc: int, value: int, ...@@ -161,6 +164,42 @@ def touch_lines(v2cc: int, value: int,
return (title, lab, str(value)) return (title, lab, str(value))
def control_label(v2cc: int, ctx: str | None = None,
mapped: bool = True, width: int = 16) -> str:
"""ONE line for the firmware's OWN per-control overlay (target == CC index).
This is a different job from `touch_lines`. There we own three fields and the
value; here the firmware draws the value itself (arrangement 4 = "Parameter
Name and Numeric Parameter Value") and gives us exactly one field: the name.
So every character goes to identity — orbit first, because that is what PLN
thinks in, then what the control DOES in this track.
Faders and A1-A4 have no `^NN` in the .tidal at all: they are Ardour-learned
levels, so `ctx` is None for them by construction and the label has to come
from the grid's role table. They get an explicit `[ard]` mark, because "this
fader is Ardour's, not Tidal's" is the single most useful thing the screen
can tell you about it mid-set.
An unmapped control says so. `dark means unmapped` is the surface's oldest
rule and the screen must not contradict the LED beside it.
"""
cell = G.label(v2cc)
if not mapped:
return f"{cell} unmapped"[:width]
role, who = G.CC_ROLE.get(v2cc, ("", 0))
if role == "family_filter":
return f"DJF {FAMILY_NAME.get(who, '?')}"[:width]
if role == "family_mute":
return f"MUTE {MUTE_NAME.get(who, '?')}"[:width]
orbit = f"d{who}" if isinstance(who, int) and who else cell
what = ctx or role or ""
label = f"{orbit} {what}".strip()
if v2cc in G.ARDOUR_CCS:
mark = " [ard]"
label = label[:width - len(mark)] + mark
return label[:width]
def djf_hz(value: int) -> tuple[str, float]: def djf_hz(value: int) -> tuple[str, float]:
"""('LPF'|'HPF'|'ZERO', cutoff Hz) straight from BootTidal's gDJF ranges.""" """('LPF'|'HPF'|'ZERO', cutoff Hz) straight from BootTidal's gDJF ranges."""
vn = max(0, min(127, value)) / 127.0 vn = max(0, min(127, value)) / 127.0
......
#!/usr/bin/env python3
"""What can be checked about a screen without looking at it.
The display protocol has NO readback — `configure_display` and `set_text` are
write-only, so nothing here proves a pixel. What it does prove is the layer where
a mistake would be silent AND invisible: the bytes on the wire, and the text we
chose to put in them. The evening this fixes was lost to a wrong TARGET, not a
wrong byte, and a wrong target looks exactly like a working driver from the host
side — hence a test that pins the target numbers against the guide's own table.
Run: python3 tools/tests/test_lcxl3_display.py (silence + exit 0 = pass)
"""
import importlib.util
import pathlib
import sys
TOOLS = pathlib.Path(__file__).resolve().parent.parent
sys.path.insert(0, str(TOOLS))
import lcxl3 as L # noqa: E402
import lcxl3_language as lang # noqa: E402
import lcxl_grid as grid # noqa: E402
_spec = importlib.util.spec_from_file_location("drv", TOOLS / "lcxl3-driver.py")
drv = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(drv)
FAILED = []
def check(cond, msg):
if not cond:
FAILED.append(msg)
class FakeOut:
"""Enough mido surface to record what a Surface would have sent."""
def __init__(self):
self.sent = []
def send(self, msg):
self.sent.append(msg)
def close(self):
pass
def fake_surface():
s = L.Surface.__new__(L.Surface) # no port, no device, no DAW mode
s.out = FakeOut()
s.inp = None
return s
# ---- the guide's target table, quoted in lcxl3.py, pinned here --------------
# "05h (5) - 24h (36): Temporary display for Analogue controls (same as CC
# indices, 05h (5) - 0Ch (12): Faders, 0Dh (13) - 24h (36): Encoders)"
check(L.TGT_CONTROL_FIRST == 0x05, "per-control target range must start at 05h")
check(L.TGT_CONTROL_LAST == 0x24, "per-control target range must end at 24h")
check(all(L.is_analogue(i) for i in L.FADERS), "all 8 faders own a display target")
check(all(L.is_analogue(i) for i in L.ENCODERS), "all 24 encoders own one")
check(not any(L.is_analogue(i) for i in L.BUTTONS),
"buttons own NO per-control target — that is why they never lost the screen")
check(L.TGT_CONTROL_LAST + 1 == min(L.BUTTONS),
"the analogue targets must end exactly where the buttons begin")
# ---- config byte: bits 5+6 are the native display's on-switch ---------------
# "Bit 6: Allow ... automatically on Change (default: Set).
# Bit 5: Allow ... automatically on Touch (default: Set)."
check(L.display_config(4, True, True) == 0x64, "arr 4 + both auto bits = 0x64")
check(L.display_config(4, False, False) == 0x04, "arr 4, natives off = 0x04")
check(L.display_config(2, False, False) == 0x02,
"0x02 is what the OLD code sent to 0x36: arrangement 2, autos irrelevant "
"there — it never touched the per-control targets at all")
check(L.display_config(1, True, False) == 0x41, "bit 6 only = 0x41")
# The arrangement must survive as bits 0-4 and never collide with the auto bits.
check(all(L.display_config(a) & 0x1F == a for a in range(32)),
"arrangement occupies bits 0-4 intact")
# ---- label_control puts the right bytes on the wire ------------------------
s = fake_surface()
s.label_control(0x18, "d4 crushbus") # encoder r2c4
data = [list(m.data) for m in s.out.sent]
check(len(data) == 2, "label_control sends exactly configure + set_text")
check(data[0] == [0x00, 0x20, 0x29, 0x02, 0x15, 0x04, 0x18, 0x64],
f"configure bytes wrong: {data[0]}")
check(data[1][:8] == [0x00, 0x20, 0x29, 0x02, 0x15, 0x06, 0x18, 0x00],
f"set_text header wrong (target 0x18, field 0): {data[1][:8]}")
check(bytes(data[1][8:]).decode() == "d4 crushbus", "the name must arrive verbatim")
# The DJF path: same target, autos CLEARED so our own overlay wins uncontested.
s = fake_surface()
s.label_control(0x1D, "DJF percs", native=False)
check(list(s.out.sent[0].data)[-1] == 0x04,
"native=False must clear bits 5+6 (config 0x04, arrangement still non-zero "
"because 0 would CANCEL the display)")
# A button has no target and must be refused loudly, not sent into the void.
try:
fake_surface().label_control(0x25, "gate")
FAILED.append("label_control(button) must raise, not silently no-op")
except ValueError:
pass
# ---- set_text must stop eating the reassigned control codes ----------------
s = fake_surface()
s.set_text(L.TGT_TEMPORARY, 2, lang.ZERO_FRAMES[0]) # hearts around a zero
body = bytes(list(s.out.sent[0].data)[8:])
check(body.count(0x1E) == 2,
f"the two hearts (1Eh) must survive the ASCII filter, got {body!r}")
# ---- the labels themselves -------------------------------------------------
# Faders carry no ^NN at all — they are Ardour-learned levels — so their label
# has to come from the grid's role table, not from a track's context.
d1 = grid.CELL_TO_CC[("D", 1)]
check(lang.control_label(d1, None) == "d1 level [ard]",
f"fader label from CC_ROLE, marked Ardour-owned: {lang.control_label(d1, None)!r}")
check(lang.control_label(d1, None, mapped=False) == "D1 unmapped",
"an unmapped control must SAY so — the screen may not contradict a dark LED")
c1 = grid.CELL_TO_CC[("C", 1)]
check(lang.control_label(c1, None) == "DJF percs", "family filter names its bloc")
f1 = grid.CELL_TO_CC[("F", 1)]
check(lang.control_label(f1, None) == "MUTE kick",
"gMute groups by ROLE, not bloc — kick alone is family 1")
b4 = grid.CELL_TO_CC[("B", 4)]
check(lang.control_label(b4, "# crushbus") == "d4 # crushbus",
"orbit first, then what the control does in this track")
check(len(lang.control_label(b4, "# a_very_long_effect_name")) <= 16,
"labels must fit the panel")
# ---- parse_context: the trailing stage note beats the Haskell ---------------
# This line is real (piment_bresilien.tidal:87) and used to yield `# n "23")) --`.
ctx = drv.parse_context(' $ midiOn "^56" ((# n "23")) -- Raise COMEON!\n')
check(ctx.get(56) == "Raise COMEON!",
f"PLN's own words must win over the Haskell body: {ctx.get(56)!r}")
# With no stage note, the useful noun is the sample, not the combinator.
ctx = drv.parse_context(' $ midiOn "^28" (loopAt 1 . (# "break:18"))\n')
check(ctx.get(28) == "break:18",
f"no comment -> first quoted string, not `loopAt 1 . (#`: {ctx.get(28)!r}")
ctx = drv.parse_context(' $ midiOn "^25" ((# n "6"))\n')
check(ctx.get(25) == "n 6",
f"a numeric string keeps its identifier for meaning: {ctx.get(25)!r}")
# A commented-out line must still never label a control.
check(drv.parse_context('-- $ midiOn "^56" (id) -- nope\n') == {},
"commented-out code labels nothing")
# ---- label_table: every analogue control, and only those -------------------
m = drv.build_map()
rows = drv.label_table(m, {}, set(m.values()), lang, grid)
check(len(rows) == 32, f"32 analogue controls own a display, got {len(rows)}")
check(sorted(r[0] for r in rows) == list(range(0x05, 0x25)),
"the table must cover targets 05h-24h exactly")
check(all(r[2] for r in rows), "no control may be left nameless")
djf = [r for r in rows if grid.CC_ROLE.get(r[1], ("", 0))[0] == "family_filter"]
check(len(djf) == 3 and not any(r[3] for r in djf),
"the 3 DJF knobs keep their rich readout: drawn by US, natives suppressed")
check(all(r[3] for r in rows if r not in djf),
"everything else is drawn by the firmware, from our name")
# The fallback flag must hand the whole surface back to our own overlay.
rows = drv.label_table(m, {}, set(m.values()), lang, grid, overlay_only=True)
check(not any(r[3] for r in rows), "--oled-overlay suppresses every native display")
if FAILED:
print(f"{len(FAILED)} FAILED:")
for f in FAILED:
print(" -", f)
sys.exit(1)
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