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
...@@ -129,12 +129,45 @@ def parse_context(text: str) -> dict[int, str]: ...@@ -129,12 +129,45 @@ def parse_context(text: str) -> dict[int, str]:
m3 = re.search(r'midi(On|Off)\s+"\^' + str(cc) + r'"\s*\((.+)', line) m3 = re.search(r'midi(On|Off)\s+"\^' + str(cc) + r'"\s*\((.+)', line)
if m3 and m3.group(2).strip(): if m3 and m3.group(2).strip():
body = re.sub(r'^<\|\s*', "", m3.group(2).strip()) body = re.sub(r'^<\|\s*', "", m3.group(2).strip())
# an inline trailing comment IS the best label — it is what # An inline trailing comment IS the best label — it is what
# PLN himself called the gesture ("-- Le Delay rose!!!") # PLN himself called the gesture ("-- Le Delay rose!!!").
body = re.sub(r'^-+\s*', "", body) # This block SAID that and did not do it: the only `--` strip
body = body.strip('"( ').rstrip('")( ') # was anchored at the start of the body, where a trailing
if body: # comment never is, so the Haskell soup always won. Putting
snip, rank = body[:17], 2 if m3.group(1) == "On" else 1 # the per-control name on the OLED is what finally showed it
# — C8 of piment_bresilien read `d8 # n "23")) --` when the
# line ends in PLN's own `-- Raise COMEON!`. Split the
# comment off FIRST and prefer it; the body is the fallback.
raw, _, tail = body.partition("--")
tail = tail.strip()
if tail:
snip = tail
else:
# No stage note on this line, so the body is Haskell, and
# `loopAt 1 . (#` names nothing on a 16-char screen. PLN
# asked for "the sample name or effect like 'rose:4'" —
# both are already in the body: the sample is its first
# quoted string (never a "^NN" control ref, hence the ^
# exclusion), the effect its first identifier outside the
# strings. Read them off the RAW body: the trailing strip
# below includes '"' in its character set and so eats the
# closing quote, which silently defeated a first attempt
# to match it afterwards.
q = re.search(r'"([^"^]+)"', raw)
quoted = q.group(1).strip() if q else ""
w = re.search(r"[A-Za-z_][\w']*", re.sub(r'"[^"]*"', " ", raw))
ident = w.group(0) if w else ""
numeric = quoted.replace(".", "").replace(" ", "").isdigit()
if quoted and not numeric:
snip = quoted # "break:18"
elif ident and quoted:
snip = f"{ident} {quoted}" # `# n "6"` -> "n 6"
elif ident:
snip = ident
else:
snip = raw.strip().strip('"( ').rstrip('")( ')
if snip:
snip, rank = snip[:17], 2 if m3.group(1) == "On" else 1
if not snip: if not snip:
# last resort: the line's own trailing comment — PLN's stage # last resort: the line's own trailing comment — PLN's stage
# notes ("-- Savoy 8/8 Break") beat any mechanical extraction # notes ("-- Savoy 8/8 Break") beat any mechanical extraction
...@@ -187,6 +220,35 @@ def build_map() -> dict[int, int]: ...@@ -187,6 +220,35 @@ def build_map() -> dict[int, int]:
return out return out
def label_table(m: dict[int, int], ctx: dict[int, str], mapped: set[int],
lang, grid, overlay_only: bool = False,
native_all: bool = False) -> list[tuple[int, int, str, bool]]:
"""(v3 index, v2 cc, name, native) for every control that owns a display.
Only the 32 analogue controls appear: faders 05h-0Ch and encoders 0Dh-24h.
The 16 buttons (25h-34h) own no per-control target, so nothing on the
firmware side ever contests the global overlay for them.
`native` decides who draws that control's screen. Default True — let the
firmware auto-trigger its own display with OUR name and ITS live value; it
tracks the value at wire speed and times the dismissal itself, which no
host-side keep-alive can match. The exception is the three DJF knobs, whose
readout is prose the numeric display cannot render ("HPF 3.4kHz !!", the
heart frames at the zero detent) — for those we clear the auto bits and take
the screen back, uncontested, on the global overlay.
"""
out: list[tuple[int, int, str, bool]] = []
for v3, v2 in sorted(m.items()):
if not L.is_analogue(v3):
continue
is_mapped = v2 in mapped
name = lang.control_label(v2, ctx.get(v2), is_mapped)
role, _who = grid.CC_ROLE.get(v2, ("", 0))
native = not overlay_only and (native_all or role != "family_filter")
out.append((v3, v2, name, native))
return out
def describe_map(m: dict[int, int]) -> None: def describe_map(m: dict[int, int]) -> None:
print(f"{'row':<4} {'v3 DAW CC':<22} -> v2 CC") print(f"{'row':<4} {'v3 DAW CC':<22} -> v2 CC")
for row, v3ccs in V3_ROWS.items(): for row, v3ccs in V3_ROWS.items():
...@@ -417,6 +479,10 @@ class Driver: ...@@ -417,6 +479,10 @@ class Driver:
self._last_touch = 0.0 self._last_touch = 0.0
self._oled_lines = ("", "", "") self._oled_lines = ("", "", "")
self._oled_up_at = 0.0 self._oled_up_at = 0.0
# Who is drawing the screen right now. The whole bug was not knowing.
self._oled_owner = "driver"
self._labels: list[tuple[int, int, str, bool]] = []
self._native: dict[int, bool] = {}
# Row-E latches. The latch NEVER lived in Tidal: midiOn/midiOff read # Row-E latches. The latch NEVER lived in Tidal: midiOn/midiOff read
# the last value received, and on the classic surface the buttons # the last value received, and on the classic surface the buttons
# themselves were hardware toggles. v3 DAW buttons are momentary, so # themselves were hardware toggles. v3 DAW buttons are momentary, so
...@@ -444,6 +510,7 @@ class Driver: ...@@ -444,6 +510,7 @@ class Driver:
self.surface.feature(L.FEAT_DISPLAY_TIMEOUT, 12) self.surface.feature(L.FEAT_DISPLAY_TIMEOUT, 12)
self.full_paint() self.full_paint()
self.oled_home() self.oled_home()
self.oled_labels()
if not self.args.no_seed: if not self.args.no_seed:
# Seed ONLY non-Ardour knob cells: DJF to bypass, fx to 0. Never # Seed ONLY non-Ardour knob cells: DJF to bypass, fx to 0. Never
# buttons (a seeded gate is the mutebomb), never faders/A1-A4 # buttons (a seeded gate is the mutebomb), never faders/A1-A4
...@@ -518,6 +585,42 @@ class Driver: ...@@ -518,6 +585,42 @@ class Driver:
self.surface.set_text(L.TGT_STATIONARY, field, line) self.surface.set_text(L.TGT_STATIONARY, field, line)
self.surface.configure_display(L.TGT_STATIONARY, 0x7F) self.surface.configure_display(L.TGT_STATIONARY, 0x7F)
# ------------------------------------------------- the per-control screen ----
def oled_labels(self, quiet: bool = False) -> None:
"""Teach the firmware's OWN overlay to speak corpus, once per track.
This is the fix. Three rounds of overlay tactics lost the same race, and
the race was unwinnable because we were playing on the wrong board: the
firmware's value display is not the global 36h overlay we kept
re-summoning, it is a SEPARATE display target per analogue control,
addressed by that control's own CC index, auto-triggered on every change
by config bit 6 (default: set). PLN moving a fader fired target 05h
30 times a second; our 0.9 s keep-alive on 36h never stood a chance, and
"no info at all" is exactly right — the fader's own screen has a name
field and we had never written it.
So stop fighting and start labelling. The firmware then draws
"d4 # crushbus" over the value it was already drawing, which is strictly
more than our overlay could show and needs no keep-alive at all.
Targets are the ABSOLUTE indices, like the LEDs: a relative-mode encoder
reports CC 85-100 but is still display target 21-36.
"""
self._labels = label_table(
self.map, self.ctx, self.mapped, self.lang, self.grid,
overlay_only=self.args.oled_overlay,
native_all=self.args.oled_native_all)
for v3, _v2, name, native in self._labels:
self.surface.label_control(v3, name, native=native)
time.sleep(0.002) # 32 SysEx back-to-back; give the parser air
self._native = {v3: native for v3, _v2, _n, native in self._labels}
if not quiet:
n_native = sum(1 for *_x, nat in self._labels if nat)
print(f" labelled {len(self._labels)} analogue controls on their OWN "
f"display targets ({n_native} firmware-drawn, "
f"{len(self._labels) - n_native} ours)")
def oled_touch(self, v2: int, value: int) -> None: def oled_touch(self, v2: int, value: int) -> None:
"""Update the temporary overlay and KEEP IT ALIVE while playing. """Update the temporary overlay and KEEP IT ALIVE while playing.
...@@ -535,6 +638,18 @@ class Driver: ...@@ -535,6 +638,18 @@ class Driver:
return return
self._last_touch = now self._last_touch = now
lines = self.lang.touch_lines(v2, value, self.ctx.get(v2)) lines = self.lang.touch_lines(v2, value, self.ctx.get(v2))
# If the firmware owns this control's screen, sending our overlay is the
# ownership battle itself — two writers, one 128x64 panel, and the one
# with the hardware event loop wins. Keep the ghost honest and stand down.
v3 = self.v2_to_v3.get(v2)
if v3 is not None and self._native.get(v3):
self._oled_lines = lines
self._last_cc = v2
self._oled_up_at = now # the screen IS up, just not by our hand
self._oled_owner = "firmware"
self.ghost_write()
return
self._oled_owner = "driver"
summon = now - self._oled_up_at > 0.9 summon = now - self._oled_up_at > 0.9
if summon: if summon:
self.surface.configure_display(L.TGT_TEMPORARY, 2) self.surface.configure_display(L.TGT_TEMPORARY, 2)
...@@ -560,16 +675,28 @@ class Driver: ...@@ -560,16 +675,28 @@ class Driver:
def ghost_write(self) -> None: def ghost_write(self) -> None:
try: try:
age = time.time() - self._oled_up_at age = time.time() - self._oled_up_at
up = "TEMPORARY (touch overlay)" if age < 1.2 else "STATIONARY (home)"
t, pmt, val = self.lang.home_lines(self.track_name, f"{self.bpm:g} BPM") t, pmt, val = self.lang.home_lines(self.track_name, f"{self.bpm:g} BPM")
home = (t, pmt, val) home = (t, pmt, val)
lines = self._oled_lines if age < 1.2 else home cc = getattr(self, "_last_cc", None)
if age >= 1.2:
up, lines = "STATIONARY (home, ours)", home
elif self._oled_owner == "firmware":
# The one case the ghost must NOT claim to mirror pixel for
# pixel: the firmware is drawing this, from the name we gave its
# per-control target plus its own live value. Fields 1-2 are our
# reading of the same control, not what is on the glass.
v3 = self.v2_to_v3.get(cc)
name = dict((a, c) for a, _b, c, _d in self._labels).get(v3, "?")
up = f"PER-CONTROL 0x{v3:02X} (firmware draws, name is ours)"
lines = (name, "^ its numeric value below", "(we only set the name)")
else:
up, lines = "TEMPORARY 0x36 (touch overlay, ours)", self._oled_lines
self.GHOST.write_text( self.GHOST.write_text(
"┌──────────────────┐ showing: %s\n" % up "┌──────────────────┐ showing: %s\n" % up
+ "".join("│ %-16s │\n" % l.replace("\x1e", "<3") for l in lines) + "".join("│ %-16s │\n" % l.replace("\x1e", "<3") for l in lines)
+ "└──────────────────┘\n" + "└──────────────────┘\n"
+ "overlay age %.1fs · last cc %s · home: %s\n" + "overlay age %.1fs · last cc %s · home: %s\n"
% (age, getattr(self, "_last_cc", None), " / ".join(home))) % (age, cc, " / ".join(home)))
except OSError: except OSError:
pass pass
...@@ -589,6 +716,15 @@ class Driver: ...@@ -589,6 +716,15 @@ class Driver:
self.ghost_write() self.ghost_write()
def stop(self) -> None: def stop(self) -> None:
if self.lang and self._labels:
# Hand the screen back the way we found it. The three DJF targets had
# their auto-trigger bits cleared, and those are NOT restored by
# leaving DAW mode on `--stay`; a next session (or Ardour) would find
# three knobs that silently refuse to show a value.
try:
self.surface.native_overlay(True)
except Exception:
pass
if self.args.relative: if self.args.relative:
for row in REL_ROWS: for row in REL_ROWS:
try: try:
...@@ -618,7 +754,7 @@ class Driver: ...@@ -618,7 +754,7 @@ class Driver:
signal.signal(signal.SIGTERM, onsig) signal.signal(signal.SIGTERM, onsig)
print("\ntranslating — ctrl-c to stop\n") print("\ntranslating — ctrl-c to stop\n")
last_recon = last_pulse = last_assert = time.time() last_recon = last_pulse = last_assert = last_labels = time.time()
while not self._stop: while not self._stop:
for msg in self.surface.inp.iter_pending(): for msg in self.surface.inp.iter_pending():
self.stats["in"] += 1 self.stats["in"] += 1
...@@ -724,6 +860,14 @@ class Driver: ...@@ -724,6 +860,14 @@ class Driver:
if self.lang and now - last_assert > 2.0: if self.lang and now - last_assert > 2.0:
self.reassert_paint() self.reassert_paint()
last_assert = now last_assert = now
if self.lang and now - last_labels > 20.0:
# Same reason `reassert_paint` exists: a DAW-mode round trip (a
# replug, Ardour grabbing the port, the LED endpoint stalling)
# drops everything we configured. Labels are cheap to restate —
# 32 SysEx every 20 s — and a screen that has silently reverted
# to "0" is indistinguishable from the bug we just fixed.
self.oled_labels(quiet=True)
last_labels = now
time.sleep(0.001) time.sleep(0.001)
self.stop() self.stop()
...@@ -737,6 +881,47 @@ class Driver: ...@@ -737,6 +881,47 @@ class Driver:
return 0 return 0
def show_oled(args) -> int:
"""Offline dry run of the per-control label table. Opens no MIDI port.
A screen is the one output we cannot read back — the protocol has no display
query, so no amount of running proves the text is right. What CAN be checked
without the device is the text itself: that every analogue control gets a
name, that faders fall back to the grid's role instead of an empty ctx, and
that nothing overflows 16 characters. That is what this prints.
"""
import lcxl3_language as lang
import lcxl_grid as grid
m = build_map()
ctx: dict[int, str] = {}
mapped: set[int] = set()
if args.track:
tp = pathlib.Path(args.track)
track_ccs, orbits = parse_track(tp)
ctx = parse_context(tp.read_text(errors="replace"))
mapped = lang.mapped_ccs(track_ccs, orbits)
print(f"track {tp.stem}: {len(ctx)} context labels, orbits {sorted(orbits)}\n")
else:
mapped = set(m.values())
print("no --track: every control treated as mapped\n")
rows = label_table(m, ctx, mapped, lang, grid,
overlay_only=args.oled_overlay,
native_all=args.oled_native_all)
print(f"{'tgt':<5} {'control':<12} {'v2':<4} {'draws':<9} name")
over = 0
for v3, v2, name, native in rows:
if len(name) > 16:
over += 1
print(f"0x{v3:02X} {L.control_name(v3):<12} {grid.label(v2):<4} "
f"{'firmware' if native else 'us (0x36)':<9} {name!r}")
print(f"\n{len(rows)} analogue targets · "
f"{sum(1 for *_x, n in rows if n)} firmware-drawn · "
f"{over} names over 16 chars")
print("buttons 0x25-0x34 own no per-control target — the global overlay is")
print("uncontested there, which is why they never lost the screen.")
return 1 if over else 0
def main(argv: list[str] | None = None) -> int: def main(argv: list[str] | None = None) -> int:
p = argparse.ArgumentParser(prog="lcxl3-driver", description=__doc__.splitlines()[0]) p = argparse.ArgumentParser(prog="lcxl3-driver", description=__doc__.splitlines()[0])
p.add_argument("--show-map", action="store_true", help="print the translation and exit") p.add_argument("--show-map", action="store_true", help="print the translation and exit")
...@@ -766,6 +951,19 @@ def main(argv: list[str] | None = None) -> int: ...@@ -766,6 +951,19 @@ def main(argv: list[str] | None = None) -> int:
"something else legitimately feeds SC through it; by default " "something else legitimately feeds SC through it; by default "
"we cut it, because it is a second copy of the surface and a " "we cut it, because it is a second copy of the surface and a "
"doubled CC cancels a latching button.") "doubled CC cancels a latching button.")
p.add_argument("--show-oled", action="store_true",
help="print the per-control label table for --track and exit. "
"No port is opened — this is the offline dry run for the "
"one thing about a screen we cannot verify ourselves.")
p.add_argument("--oled-overlay", action="store_true",
help="FALLBACK: suppress the firmware's per-control value "
"display everywhere (config bits 5+6 clear) and draw our "
"own 3-field overlay for every control, uncontested. Use "
"if the per-control name field turns out not to render.")
p.add_argument("--oled-native-all", action="store_true",
help="let the firmware draw the DJF knobs too. Loses the "
"'HPF 3.4kHz !!' readout for a bare 0-127 — for the eye "
"test that compares the two, not for a gig.")
p.add_argument("--stay", action="store_true", help="leave DAW mode on at exit") p.add_argument("--stay", action="store_true", help="leave DAW mode on at exit")
p.add_argument("--reconcile-secs", type=float, default=5.0) p.add_argument("--reconcile-secs", type=float, default=5.0)
args = p.parse_args(argv) args = p.parse_args(argv)
...@@ -785,6 +983,8 @@ def main(argv: list[str] | None = None) -> int: ...@@ -785,6 +983,8 @@ def main(argv: list[str] | None = None) -> int:
if args.show_map: if args.show_map:
describe_map(build_map()) describe_map(build_map())
return 0 return 0
if args.show_oled:
return show_oled(args)
return Driver(args).run() return Driver(args).run()
......
...@@ -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