Commit 857de5b2 by PLN (Algolia)

feat(lcxl3): the v3 visual language — driver-owned values, latches, and a

surface that breathes at the track's tempo

The design session with PLN, hands on the device, four AskUserQuestion answers
and three live iterations. Everything here is v3-only BY CONSTRUCTION: the
corpus, BootTidal, SC and Ardour still speak v2 absolutes, so the classic LCXL
(PLN's fallback, and his friend's surface) keeps working with zero changes —
"we need to be BC always" is the standing constraint.

THE LANGUAGE (lcxl3_language.py, new — parallel to lcxl-leds.py, replacing
nothing):
- rings: hue = ROLE (per-orbit family anchors, warm rhythm bloc / purple bass /
  cool melodics), lightness = VALUE. This is the exact trade v2 could not make
  with three LED hues — the settled 2026-07-29 language spent all hue on value
  because role had nowhere else to live. RGB dissolves the trade.
- dark = unmapped survives unchanged: it outranks everything on both surfaces.
- DJF row C, PLN's spec after the first blue<->green attempt proved "too
  subtle": blue = lows kept, DEEP GREEN BREATHING SLOWLY = the zero mark
  (there is no pot ridge on an endless encoder — the 0 must be VISIBLE),
  then green -> yellow -> red as the HPF cut grows. Near-silence extremes
  breathe to the BEAT — sine, not blink ("a bit aggressive atm"), rate from
  the track's setcps (118 BPM for rose_rouge). Wall-clock phase: it breathes
  AT tempo without claiming to know Tidal's cycle position — true phase sync
  is a feed from SC, later, not a silent assumption now.

DRIVER-OWNED TRUTH (three kinds, one principle):
- encoder VALUES: rows B/C relative, offset-64 decode — MEASURED off PLN's
  slow clicks arriving as 63/65/66; the first decoder assumed two's complement
  and read +1 as -63, direction inverted, every click a full-range jump. Row A
  stays absolute deliberately: A1-A4 are Ardour-learned levels, and integrating
  them from a blind seed would yank a live fader toward silence.
- row-E LATCHES: the latch never lived in Tidal (midiOn/midiOff read the last
  value) — it lived in the v2 hardware's toggle buttons. v3 DAW buttons are
  momentary, so the driver flips state on press, swallows the release, and the
  LED paints driver state — which IS what Tidal hears. Row F stays momentary:
  gestures are held, and the panic chord needs four simultaneous 127s.
- PAINT as a binding: the device repaints its own defaults on events we cannot
  see, so the driver re-asserts the full board every 2 s — same doctrine as
  every aconnect link on this rig. Faders are OUT of the paint path: they have
  no LEDs, and painting them was a no-op lie this commit stops telling.

OLED:
- touched-control context in CORPUS terms, line 2 parsed from the .tidal:
  "# crushbus", "mask <f f f t>", and — best of all — PLN's own inline stage
  notes: ^35 shows "HANDS IN THE AIR", ^59 "Le Delay rose!!!", ^36 "Savoy 8/8
  Break". His words, on his hardware, at the moment his finger lands.
- anti-blink: fields are diffed and the configure/bring-up pair fires only when
  the overlay has likely expired — re-summoning a live overlay every event was
  the blink PLN saw.
- gates read ON/off, DJFs read "LPF 43% / BYPASS / HPF 61% !!".

Validated on the live rig across three rounds of PLN's eyes and hands:
translation + paint + OLED coexist in one process; 27 live cells / 13 dark on
rose_rouge; 18/18 controls context-labelled; latch verified against E1.
parent b30715b7
#!/usr/bin/env python3
"""lcxl3_language — the v3 visual language: role hue x value lightness, on RGB.
SETTLED WITH PLN 2026-08-29 (AskUserQuestion, four answers):
rings A/B hue = ROLE (per-orbit family colour), lightness = VALUE
row C DJF diverging BLUE <-> GREEN, bright cyan landmark at the bypass
detent, intensity grows with cut amount, extremes pulse (danger:
djf 0.05 is ~26 Hz lowpass = silence)
OLED stationary track HUD + temporary touched-control context in
CORPUS terms ("d4 fx2 · 64", not "CC 29: 64")
encoders RELATIVE, driver-owned values
WHY THIS IS A SEPARATE MODULE AND NOT AN EDIT TO lcxl-leds.py
BC constraint, PLN verbatim: "Ill move from lcxl 3 to older one, friend
pulls parvagues has classic lcxl, so we need to be BC always." The classic
surface keeps its settled 3-hue language in lcxl-leds.py untouched; this
module exists only where a v3 is present, and everything it needs beyond
the surface itself comes from lcxl_grid — the one authored table. Corpus,
BootTidal, SC and Ardour never learn that v3 exists.
WHAT THE v2 LANGUAGE KEEPS (deliberately, same rules on both surfaces):
dark = this control does nothing on this track <- outranks everything
the DJF bypass detent band stays 61-67 <- same SEMANTICS, new paint
panic overlay outranks all
WHAT CHANGED AND WHY IT MAY CHANGE:
v2 spent all three hues on VALUE because three hues was all it had; the memory
records role-colours were tried and abandoned for ambiguity. Full RGB dissolves
that trade: hue carries ROLE again, lightness carries VALUE. The per-orbit hue
anchors below are DEMO values chosen tonight for legibility of the three blocs
(warm = rhythm, purple = bass, cool = melodic); mapping them onto the fleet's
OKLCH families in models.py is the intended follow-up, not done yet.
"""
from __future__ import annotations
import colorsys
import lcxl_grid as G
# ------------------------------------------------------------------ hues -----
# Per-orbit hue anchors (HSV degrees). Blocs follow the AUTHORED filter families
# (lcxl_grid._FILTER_FAMILY): warm = percs bloc (d1 d2 d3 d8), purple = bass
# (d4), cool blues = melodic (d5-d7), violets/rose = extras (d9-d12).
# 300-335 avoided: brand magenta is reserved fleet-wide (DESIGN.md).
# 130-175 avoided on steady controls: that band IS the DJF diverging scale.
ORBIT_HUE = {
1: 25, 2: 50, 3: 75, 8: 100, # rhythm bloc — warm sweep
4: 280, # bass — purple
5: 180, 6: 205, 7: 230, # melodic bloc — cool sweep
9: 250, 10: 270, 11: 340, 12: 0, # extras
}
FAMILY_HUE = {1: 25, 2: 62, 3: 230} # F1 kick · F2 percs · F3 bass+melodic
DJF_LO, DJF_HI = 61, 67 # bypass detent band — SAME as v2
DJF_DANGER_LO, DJF_DANGER_HI = 8, 119 # beyond these, you are near silence
# PLN's second iteration, on the device (2026-08-29): the blue<->green diverging
# scale was "too subtle", and with no pot ridge the ZERO was unfindable. New
# scale, his spec: "blue to lows, green midrange to mark the 0 clearly as slow
# glow at deep green, then as you go up you go red".
# LPF side deep blue, brightening with cut
# detent DEEP GREEN, breathing slowly (one breath per BAR) — the 0 mark
# HPF side green -> yellow -> red as the cut grows
# And the extremes GLOW to the BEAT (sine, not a square blink — "a bit
# aggressive atm"). Rate comes from the track's setcps; phase is wall-clock, so
# it breathes AT the tempo without claiming to know Tidal's cycle position.
HUE_ZERO, HUE_LPF, HUE_HPF_END = 135, 220, 0
REST_BTN, FLASH_BTN = 0.16, 1.0 # button brightness: rest / pressed
def _rgb(hue: float, sat: float, val: float) -> tuple[int, int, int]:
"""HSV -> the surface's 7-bit RGB triplet."""
r, g, b = colorsys.hsv_to_rgb((hue % 360) / 360.0, sat, min(1.0, val))
return int(r * 127), int(g * 127), int(b * 127)
def _lightness(value: int) -> float:
"""0..127 -> ring lightness. Never fully dark: dark means UNMAPPED only."""
return 0.10 + 0.90 * (max(0, min(127, value)) / 127.0)
def _breath(phase: float) -> float:
"""0..1 sine breathing, smooth at both ends."""
import math
return 0.5 + 0.5 * math.sin(2 * math.pi * phase)
def djf_colour(value: int, beat: float = 0.0, bar: float = 0.0) -> tuple[int, int, int]:
"""PLN's diverging DJF scale: blue lows / breathing deep-green zero / to red."""
v = max(0, min(127, value))
if DJF_LO <= v <= DJF_HI:
return _rgb(HUE_ZERO, 1.0, 0.25 + 0.40 * _breath(bar)) # the slow glow 0
if v < DJF_LO:
t = (DJF_LO - v) / DJF_LO
hue, val = HUE_LPF, 0.30 + 0.70 * t
else:
t = (v - DJF_HI) / (127 - DJF_HI)
hue, val = HUE_ZERO * (1.0 - t), 0.30 + 0.70 * t # green->yellow->red
if v <= DJF_DANGER_LO or v >= DJF_DANGER_HI:
val *= 0.40 + 0.60 * _breath(beat) # beat-glow, not blink
return _rgb(hue, 1.0, val)
def animated(v2cc: int, value: int) -> bool:
"""Cells that breathe: a DJF at its zero mark, or in the danger zone."""
role, _ = G.CC_ROLE.get(v2cc, ("", 0))
if role != "family_filter":
return False
v = max(0, min(127, value))
return (DJF_LO <= v <= DJF_HI) or v <= DJF_DANGER_LO or v >= DJF_DANGER_HI
def ring_colour(v2cc: int, value: int, mapped: bool,
beat: float = 0.0, bar: float = 0.0) -> tuple[int, int, int]:
"""Colour for any knob/fader cell, in v2/corpus terms."""
if not mapped:
return (0, 0, 0) # dark = unmapped, always
role, who = G.CC_ROLE.get(v2cc, ("fx", 0))
if role == "family_filter":
return djf_colour(value, beat, bar)
if v2cc in G.ARDOUR_CCS:
# Ardour owns this value; we cannot claim a lightness we do not know.
return _rgb(ORBIT_HUE.get(who, 0), 0.9, 0.30)
return _rgb(ORBIT_HUE.get(who, 0), 1.0, _lightness(value))
def button_colour(v2cc: int, pressed: bool, mapped: bool) -> tuple[int, int, int]:
"""Buttons: role hue, brightness = state. Latch truth lives in Tidal — the
driver only sees the press, so rest/flash is what it can honestly paint."""
if not mapped:
return (0, 0, 0)
role, who = G.CC_ROLE.get(v2cc, ("gate", 0))
hue = FAMILY_HUE.get(who, 0) if role == "family_mute" else ORBIT_HUE.get(who, 0)
return _rgb(hue, 1.0, FLASH_BTN if pressed else REST_BTN)
# ------------------------------------------------------------------ OLED -----
def touch_lines(v2cc: int, value: int,
ctx: str | None = None) -> tuple[str, str, str]:
"""(title, param, value) for the temporary touched-control overlay.
`ctx` is what the control DOES in this track — "# crushbus", the gate's
pattern — parsed from the .tidal. PLN: showing the cell name there "is less
useful than what it is (sample name or effect like 'rose:4' or '# crush')".
"""
role, who = G.CC_ROLE.get(v2cc, ("?", 0))
lab = ctx or G.label(v2cc)
if role == "family_filter":
fam = {1: "percs", 2: "bass", 3: "melodic"}.get(who, "?")
v = value
if DJF_LO <= v <= DJF_HI:
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)
if role == "family_mute":
fam = {1: "kick", 2: "percs", 3: "bass+mel"}.get(who, "?")
return (f"MUTE {fam}", lab, str(value))
# 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.
title = f"d{who} {role}" if isinstance(who, int) and who else lab
if role in ("gate", "gate2"):
return (title, lab, "ON" if value > 0 else "off")
return (title, lab, str(value))
def home_lines(track: str, detail: str = "") -> tuple[str, str, str]:
"""(title, param, value) for the stationary track HUD."""
return (track.upper()[:16], "ParVagues", detail[:16])
# ---------------------------------------------------------------- mapping ----
def mapped_ccs(track_ccs: set[int], orbits: set[int]) -> set[int]:
"""Everything that is LIVE for a track, in v2 terms.
A track file's ^NN set is not the whole story: the family filters/mutes live
in BootTidal (boot helpers are invisible in track files), so they are live on
EVERY track; and the level cells belong to Ardour for every orbit the track
actually uses. PANIC_CC is a chord, not a cell — never painted directly.
"""
live = set(track_ccs) | set(G.FAMILY_CCS)
for n in orbits:
cc = G.CELL_TO_CC.get(("D", n)) if n <= 8 else G.CELL_TO_CC.get(("A", n - 8))
if cc:
live.add(cc)
return live
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