Commit ca173ae1 by PLN (Algolia)

feat(lcxl3): Hz-true DJF readout, the OLED ghost, and the edge-button census

Round 4 of the live design session, plus the nine-press census.

DJF readout speaks Hz, not percent — and the numbers come from BootTidal's own
gDJF ranges (lpf: range 180 20000 over 2v; hpf: range 20 8000 over 2(v-.5)),
so what the OLED says is what the filter DOES. The zero mark animates: heart
frames at ~3 fps while a DJF rests in the detent (charset 0x1E is a heart).
Home screen line 3 is now "118 BPM" — "36 live" was a control count nobody
asked for, and PLN rightly mocked it.

The OLED GHOST (/tmp/lcxl3-oled-ghost.txt): the protocol has no display
readback, so nothing can pull the screen — instead the driver mirrors every
frame IT sends into a box-drawing the shell can read. What the ghost cannot
show is the firmware's own native value overlay, and that is the point: when
PLN's eyes see content the ghost does not have, the discrepancy itself is the
diagnosis. (That discrepancy is live: the native overlay still outdraws our
temporary text on every control move — an Opus worker is in the programmer's
reference hunting per-control display targets, the likely correct fix.)

Keep-alive: the round-3 overlay stamped _oled_up_at on every push, so after
the firmware's 1.2 s timeout our overlay was never re-summoned — one good
overlay per touch, then raw values forever. Now the bring-up re-fires every
0.9 s of continuous activity and stamps only when actually sent.

THE EDGE-BUTTON CENSUS (PLN pressed, the log answered): Solo/Arm is ONE key
(ch1 CC65, two press cycles for his presses 1+2), Mute/Select is ONE key
(CC66), the Novation logo is alive (CC104), Track </> are CC103/102, Page
up/down CC106/107. Authored as EDGE_BUTTONS in lcxl3.py; the driver now names
them in the log instead of counting them unmapped. No functions assigned yet —
the settled direction is Track = setlist cursor, Page = OLED pages, the two
verb keys = record-arm and panic, logo in reserve.
parent 89c7d967
...@@ -512,37 +512,81 @@ class Driver: ...@@ -512,37 +512,81 @@ class Driver:
def oled_home(self) -> None: def oled_home(self) -> None:
t, pmt, val = self.lang.home_lines(self.track_name, t, pmt, val = self.lang.home_lines(self.track_name,
f"{len(self.mapped)} live") f"{self.bpm:g} BPM")
self.surface.configure_display(L.TGT_STATIONARY, 2) self.surface.configure_display(L.TGT_STATIONARY, 2)
for field, line in enumerate((t, pmt, val)): for field, line in enumerate((t, pmt, val)):
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)
def oled_touch(self, v2: int, value: int) -> None: def oled_touch(self, v2: int, value: int) -> None:
"""Update the temporary overlay WITHOUT re-triggering it each event. """Update the temporary overlay and KEEP IT ALIVE while playing.
The first version re-sent configure + all 3 fields + bring-up on every Round 3's diff logic had a self-defeating stamp: _oled_up_at was
update, and the overlay visibly blinked through a sweep (PLN: "it does refreshed on every push, so "stale" never became true during a sweep —
blink why?"). Now: fields are diffed and only changed ones are sent, and the firmware's 1.2 s timeout dropped OUR overlay after the first
the configure/bring-up pair fires only when the overlay has likely summon, and its own native value display (which shows the raw wire
timed out (>1.0 s since our last push) — updating a live overlay's text value: "0" for a relative encoder) owned the screen. PLN saw exactly
does not need a re-summon. that. Now the bring-up is re-sent every 0.9 s of continuous activity —
under the firmware timeout — and _oled_up_at is stamped ONLY when the
bring-up was actually sent.
""" """
now = time.time() now = time.time()
if now - self._last_touch < 0.10: # a sweep is 30+ events/s if now - self._last_touch < 0.05:
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))
stale = now - self._oled_up_at > 1.0 summon = now - self._oled_up_at > 0.9
if stale: if summon:
self.surface.configure_display(L.TGT_TEMPORARY, 2) self.surface.configure_display(L.TGT_TEMPORARY, 2)
for field, line in enumerate(lines): for field, line in enumerate(lines):
if stale or line != self._oled_lines[field]: if summon or line != self._oled_lines[field]:
self.surface.set_text(L.TGT_TEMPORARY, field, line) self.surface.set_text(L.TGT_TEMPORARY, field, line)
if stale: if summon:
self.surface.configure_display(L.TGT_TEMPORARY, 0x7F) self.surface.configure_display(L.TGT_TEMPORARY, 0x7F)
self._oled_lines = lines
self._oled_up_at = now self._oled_up_at = now
self._oled_lines = lines
self._last_cc = v2
self.ghost_write()
# ---------------------------------------------------------- the ghost ----
# "can you pull state of oled?" — the protocol has NO display readback, so
# nobody can PULL the screen. What we can do is mirror every byte WE send
# into a ghost the shell can read. The one thing the ghost cannot show is
# the firmware's own native value overlay — so when PLN reports content the
# ghost does not have, that discrepancy itself is the diagnosis: it was the
# firmware talking, not us.
GHOST = pathlib.Path("/tmp/lcxl3-oled-ghost.txt")
def ghost_write(self) -> None:
try:
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")
home = (t, pmt, val)
lines = self._oled_lines if age < 1.2 else home
self.GHOST.write_text(
"┌──────────────────┐ showing: %s\n" % up
+ "".join("│ %-16s │\n" % l.replace("\x1e", "<3") for l in lines)
+ "└──────────────────┘\n"
+ "overlay age %.1fs · last cc %s · home: %s\n"
% (age, getattr(self, "_last_cc", None), " / ".join(home)))
except OSError:
pass
def tick_zero_anim(self, now: float) -> None:
"""~3 fps heartbeat on the value line while a DJF rests at its zero."""
cc = getattr(self, "_last_cc", None)
if cc is None or now - self._oled_up_at > 1.1:
return
role, _ = self.grid.CC_ROLE.get(cc, ("", 0))
v = self.values.get(cc, 64)
if role != "family_filter" or not (61 <= v <= 67):
return
frame = self.lang.ZERO_FRAMES[int(now * 3) % len(self.lang.ZERO_FRAMES)]
if frame != self._oled_lines[2]:
self.surface.set_text(L.TGT_TEMPORARY, 2, frame)
self._oled_lines = (self._oled_lines[0], self._oled_lines[1], frame)
self.ghost_write()
def stop(self) -> None: def stop(self) -> None:
if self.args.relative: if self.args.relative:
...@@ -616,6 +660,14 @@ class Driver: ...@@ -616,6 +660,14 @@ class Driver:
continue continue
v2 = self.map.get(msg.control) v2 = self.map.get(msg.control)
if v2 is None: if v2 is None:
name = L.EDGE_BUTTONS.get(msg.control)
if name:
# Known edge key, no function assigned yet (the view/rig
# layer design). Named in the log so the census stays done.
if self.args.verbose and msg.value:
print(f" [edge] {name} pressed (ch{msg.channel + 1} "
f"#{msg.control})")
continue
key = (msg.channel, msg.control) key = (msg.channel, msg.control)
self.unmapped[key] = self.unmapped.get(key, 0) + 1 self.unmapped[key] = self.unmapped.get(key, 0) + 1
self.stats["dropped"] += 1 self.stats["dropped"] += 1
...@@ -667,6 +719,7 @@ class Driver: ...@@ -667,6 +719,7 @@ class Driver:
last_recon = now last_recon = now
if self.lang and now - last_pulse > 0.05: if self.lang and now - last_pulse > 0.05:
self.tick_glow() self.tick_glow()
self.tick_zero_anim(now)
last_pulse = now last_pulse = now
if self.lang and now - last_assert > 2.0: if self.lang and now - last_assert > 2.0:
self.reassert_paint() self.reassert_paint()
......
...@@ -53,6 +53,20 @@ CMD_BITMAP = 0x09 # then <target> <1216 bytes>, terminated 0x7F ...@@ -53,6 +53,20 @@ CMD_BITMAP = 0x09 # then <target> <1216 bytes>, terminated 0x7F
# DAW-mode control indices. These double as the LED addresses ("The Control Change # DAW-mode control indices. These double as the LED addresses ("The Control Change
# indices listed are also used for sending colour to the corresponding LEDs"). # indices listed are also used for sending colour to the corresponding LEDs").
# Edge buttons (DAW mode, channel 1) — identified by PLN's nine-press census,
# 2026-08-29, read back in press order from the driver's unmapped log. Two of
# the physical caps share one identity each (Solo/Arm and Mute/Select emitted
# one CC across two press cycles), and the Novation logo DOES send.
EDGE_BUTTONS = {
65: "solo_arm", # left verb key
66: "mute_select", # right verb key
104: "novation", # the logo — alive in DAW mode
103: "track_left",
102: "track_right",
106: "page_up",
107: "page_down",
}
FADERS = range(5, 13) # 05h-0Ch FADERS = range(5, 13) # 05h-0Ch
ENCODERS = range(13, 37) # 0Dh-24h, three rows of 8 ENCODERS = range(13, 37) # 0Dh-24h, three rows of 8
ENC_ROWS = (range(13, 21), range(21, 29), range(29, 37)) ENC_ROWS = (range(13, 21), range(21, 29), range(29, 37))
......
...@@ -148,14 +148,7 @@ def touch_lines(v2cc: int, value: int, ...@@ -148,14 +148,7 @@ def touch_lines(v2cc: int, value: int,
if role == "family_filter": if role == "family_filter":
fam = {1: "percs", 2: "bass", 3: "melodic"}.get(who, "?") fam = {1: "percs", 2: "bass", 3: "melodic"}.get(who, "?")
v = value v = value
if DJF_LO <= v <= DJF_HI: state = djf_readout(value)
state = "BYPASS"
elif v < DJF_LO:
state = f"LPF {int((DJF_LO - v) / DJF_LO * 100)}%"
else:
state = f"HPF {int((v - DJF_HI) / (127 - DJF_HI) * 100)}%"
if v <= DJF_DANGER_LO or v >= DJF_DANGER_HI:
state += " !!"
return (f"DJF {fam}", lab, state) return (f"DJF {fam}", lab, state)
if role == "family_mute": if role == "family_mute":
fam = {1: "kick", 2: "percs", 3: "bass+mel"}.get(who, "?") fam = {1: "kick", 2: "percs", 3: "bass+mel"}.get(who, "?")
...@@ -168,6 +161,35 @@ def touch_lines(v2cc: int, value: int, ...@@ -168,6 +161,35 @@ def touch_lines(v2cc: int, value: int,
return (title, lab, str(value)) return (title, lab, str(value))
def djf_hz(value: int) -> tuple[str, float]:
"""('LPF'|'HPF'|'ZERO', cutoff Hz) straight from BootTidal's gDJF ranges."""
vn = max(0, min(127, value)) / 127.0
if DJF_LO <= value <= DJF_HI:
return "ZERO", 0.0
if value < DJF_LO:
return "LPF", 180.0 + 19820.0 * (2.0 * vn)
return "HPF", 20.0 + 7980.0 * (2.0 * (vn - 0.5))
def _fmt_hz(hz: float) -> str:
return f"{hz/1000:.1f}k" if hz >= 1000 else f"{int(hz)}"
# Zero-mark animation frames for the OLED value line. 0x1E is the charset's
# heart. Cycled at ~3 fps by the driver while a DJF rests at its zero.
ZERO_FRAMES = ("\x1e 0 \x1e", " 0 ", "\x1e 0 \x1e", " (0) ")
def djf_readout(value: int) -> str:
side, hz = djf_hz(value)
if side == "ZERO":
return ZERO_FRAMES[0]
s = f"{side} {_fmt_hz(hz)}Hz"
if value <= DJF_DANGER_LO or value >= DJF_DANGER_HI:
s += " !!"
return s
def home_lines(track: str, detail: str = "") -> tuple[str, str, str]: def home_lines(track: str, detail: str = "") -> tuple[str, str, str]:
"""(title, param, value) for the stationary track HUD.""" """(title, param, value) for the stationary track HUD."""
return (track.upper()[:16], "ParVagues", detail[:16]) return (track.upper()[:16], "ParVagues", detail[:16])
......
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