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
...@@ -70,6 +70,90 @@ except ImportError: # pragma: no cover ...@@ -70,6 +70,90 @@ except ImportError: # pragma: no cover
VIRTUAL_PORT_NAME = "ParVagues LCXL3" VIRTUAL_PORT_NAME = "ParVagues LCXL3"
# In relative mode the guide shifts each encoder's CC by +64. We arm rows B and
# C ONLY (21-36 -> 85-100). Row A stays ABSOLUTE deliberately: A1-A4 are
# Ardour-learned level knobs, and integrating them from a driver-side seed of 0
# would yank a live fader toward silence on first touch — the CC77 footgun
# class. Absolute row A behaves exactly like the v2 pots did; row-A relative
# waits for Ardour value feedback (soft takeover), not for a guess.
REL_CCS = {base + 64: base for base in range(21, 37)}
REL_ROWS = (2, 3)
def rel_delta(v: int) -> int:
"""Offset-64 relative value -> signed delta.
MEASURED, not assumed (2026-08-29, PLN's hands on the surface): slow single
clicks arrived as raw 63/65/66 = -1/+1/+2 in offset-64. The first decoder
assumed 7-bit two's complement, which read +1 as -63 — direction inverted
and every click a full-range jump. PLN felt it before the log showed it.
"""
return v - 64
def parse_bpm(text: str) -> float:
"""The track's setcps idiom is `setcps (BPM/60/4)`. Default 120 if absent.
Wall-clock phase at this rate is NOT Tidal's cycle position — the glow
breathes AT the right tempo without claiming beat alignment. True phase
needs a feed from SC; that is the known next step, not a silent assumption.
"""
import re
m = re.search(r"setcps\s*\(?\s*([0-9.]+)\s*/\s*60", text)
return float(m.group(1)) if m else 120.0
def parse_context(text: str) -> dict[int, str]:
"""cc -> what the control DOES in this track, for the OLED's line 2.
PLN on seeing cell names there: "'A1 A2 A3' is less useful than what it is
(sample name or effect like 'rose:4' or '# crush')". The .tidal already
says it: knobs sit inside `# effect (... "^NN" ...)`, buttons inside
`midiOn/midiOff "^NN" (body)`. Line-based on purpose — the paren-aware
block traps (reference_corpus_rewrite_traps) are for REWRITERS; a display
hint may be imperfect, it just must never lie about which cc it belongs to.
"""
import re
ctx: dict[int, str] = {}
prio: dict[int, int] = {}
for line in text.splitlines():
if line.lstrip().startswith("--"):
continue # commented-out code must not label a cc
for m in re.finditer(r'"\^(\d+)"', line):
cc = int(m.group(1))
snip, rank = None, 0
m2 = re.search(r'#\s*(\w+)\b(?=[^"]*"\^' + str(cc) + '")', line)
if m2:
snip, rank = "# " + m2.group(1), 3
else:
m3 = re.search(r'midi(On|Off)\s+"\^' + str(cc) + r'"\s*\((.+)', line)
if m3 and m3.group(2).strip():
body = re.sub(r'^<\|\s*', "", m3.group(2).strip())
# an inline trailing comment IS the best label — it is what
# PLN himself called the gesture ("-- Le Delay rose!!!")
body = re.sub(r'^-+\s*', "", body)
body = body.strip('"( ').rstrip('")( ')
if body:
snip, rank = body[:17], 2 if m3.group(1) == "On" else 1
if not snip:
# last resort: the line's own trailing comment — PLN's stage
# notes ("-- Savoy 8/8 Break") beat any mechanical extraction
m4 = re.search(r'--\s*(.+)$', line)
if m4:
snip, rank = m4.group(1).strip()[:17], 1
if snip and rank > prio.get(cc, 0):
ctx[cc], prio[cc] = snip, rank
return ctx
def parse_track(path: pathlib.Path) -> tuple[set[int], set[int]]:
"""(^NN control set, dN orbit set) straight out of a .tidal file."""
import re
text = path.read_text(errors="replace")
ccs = {int(m) for m in re.findall(r"\^(\d+)", text)}
orbits = {int(m) for m in re.findall(r"\bd(\d+)\b(?=\s*\$|\s*<)", text)} or {int(m) for m in re.findall(r"\bd(\d+)\b", text)}
return ccs, orbits
# v3 DAW-mode indices per physical row, verified against the hardware by moving # v3 DAW-mode indices per physical row, verified against the hardware by moving
# one control per row and reading what arrived (fader1->cc5 ch16, A1->13 ch16, # one control per row and reading what arrived (fader1->cc5 ch16, A1->13 ch16,
# B1->21 ch16, C1->29 ch16, E1->37 ch1, F1->45 ch1). # B1->21 ch16, C1->29 ch16, E1->37 ch1, F1->45 ch1).
...@@ -299,21 +383,170 @@ class Driver: ...@@ -299,21 +383,170 @@ class Driver:
self.out_channel = args.channel - 1 self.out_channel = args.channel - 1
self._stop = False self._stop = False
# ---- the visual language (v3-only; the v2 surface keeps lcxl-leds) ----
self.lang = None
if args.paint:
import lcxl3_language
self.lang = lcxl3_language
self.v2_to_v3 = {v2: v3 for v3, v2 in self.map.items()}
track_ccs: set[int] = set()
orbits: set[int] = set()
self.track_name = "no track"
self.bpm = 120.0
self.ctx: dict[int, str] = {}
if args.track:
tp = pathlib.Path(args.track)
track_ccs, orbits = parse_track(tp)
text = tp.read_text(errors="replace")
self.bpm = parse_bpm(text)
self.ctx = parse_context(text)
self.track_name = tp.stem
print(f"track {self.track_name}: {len(track_ccs)} controls, "
f"orbits {sorted(orbits)}, {self.bpm:g} BPM, "
f"{len(self.ctx)} context labels")
self.mapped = self.lang.mapped_ccs(track_ccs, orbits)
# Driver-owned values, v2-keyed. DJF seeds at the bypass detent —
# anything else at that position would colour the whole mix.
import lcxl_grid
self.grid = lcxl_grid
self.values = {}
for v2 in self.v2_to_v3:
role, _ = self.grid.CC_ROLE.get(v2, ("", 0))
self.values[v2] = 64 if role == "family_filter" else 0
self._last_rgb: dict[int, tuple[int, int, int]] = {}
self._last_touch = 0.0
self._oled_lines = ("", "", "")
self._oled_up_at = 0.0
# Row-E latches. The latch NEVER lived in Tidal: midiOn/midiOff read
# the last value received, and on the classic surface the buttons
# themselves were hardware toggles. v3 DAW buttons are momentary, so
# the driver owns the latch now — press flips it, release is
# swallowed, and the LED paints the driver's state, which IS the
# truth Tidal sees. All-off at start matches the untouched-control
# default (orDef 0). Row F stays momentary: gestures are held, and
# the panic chord needs four simultaneous 127s.
self.latch: dict[int, bool] = {}
def start(self) -> None: def start(self) -> None:
print(f"enabling DAW mode (the only mode that paints, and the only one") print(f"enabling DAW mode (the only mode that paints, and the only one")
print(f" that reports the fixed map we translate from)") print(f" that reports the fixed map we translate from)")
self.surface.daw_mode(True) self.surface.daw_mode(True)
time.sleep(0.2) time.sleep(0.2)
if self.args.relative: if self.args.relative:
for row in (1, 2, 3): for row in REL_ROWS:
self.surface.relative(row, True) self.surface.relative(row, True)
print(" encoder rows 1-3 -> RELATIVE (deltas; values are ours to hold)") print(" encoder rows B+C -> RELATIVE (deltas; the driver owns the values)")
print(" row A stays absolute: A1-A4 are Ardour's, we hold no truth for them")
print(f"virtual port published: {VIRTUAL_PORT_NAME!r}") print(f"virtual port published: {VIRTUAL_PORT_NAME!r}")
reconcile(verbose=True, cut_thru=not self.args.keep_thru) reconcile(verbose=True, cut_thru=not self.args.keep_thru)
if self.lang:
# Touch overlays should get out of the way fast (units of 0.1 s).
self.surface.feature(L.FEAT_DISPLAY_TIMEOUT, 12)
self.full_paint()
self.oled_home()
if not self.args.no_seed:
# Seed ONLY non-Ardour knob cells: DJF to bypass, fx to 0. Never
# buttons (a seeded gate is the mutebomb), never faders/A1-A4
# (Ardour-learned: CC77 unsolicited goes-to-silence), never ^93.
n = 0
for v2, val in self.values.items():
if v2 in self.grid.KNOB_CCS and v2 not in self.grid.ARDOUR_CCS \
and v2 in self.mapped:
self.emit(v2, val)
n += 1
print(f" seeded {n} knob cells (DJF at bypass, fx at 0)")
# ------------------------------------------------------------- paint ----
def emit(self, v2: int, value: int) -> None:
mido = L._mido()
self.virt.send(mido.Message("control_change", channel=self.out_channel,
control=v2, value=value))
self.stats["out"] += 1
def phases(self) -> tuple[float, float]:
"""(beat, bar) phase 0..1 at the track's BPM, wall-clock anchored."""
beats = time.time() * self.bpm / 60.0
return beats % 1.0, (beats / 4.0) % 1.0
def paint_cell(self, v2: int) -> None:
v3 = self.v2_to_v3.get(v2)
if v3 is None or 5 <= v3 <= 12:
return # faders have NO LEDs — painting them is a no-op lie
mapped = v2 in self.mapped
if v2 in self.grid.BUTTON_CCS:
rgb = self.lang.button_colour(v2, self.values.get(v2, 0) > 0, mapped)
else:
beat, bar = self.phases()
rgb = self.lang.ring_colour(v2, self.values.get(v2, 0), mapped,
beat, bar)
if self._last_rgb.get(v3) != rgb:
self.surface.rgb(v3, *rgb)
self._last_rgb[v3] = rgb
def full_paint(self, quiet: bool = False) -> None:
for v2 in self.v2_to_v3:
self.paint_cell(v2)
if not quiet:
lit = sum(1 for c in self._last_rgb.values() if c != (0, 0, 0))
print(f" painted: {lit} live cells, "
f"{len(self._last_rgb) - lit} dark (unmapped)")
def reassert_paint(self) -> None:
"""The device repaints its own defaults on events we cannot see (page
flips arrive as notes; firmware layout changes announce nothing). So the
paint is a BINDING, and bindings on this rig are re-asserted, never
assumed — the same doctrine as every aconnect link. Cheap: 48 small
SysEx every 2 s."""
self._last_rgb.clear()
self.full_paint(quiet=True)
def tick_glow(self) -> None:
"""Repaint only the breathing cells (DJF zero mark + danger zones).
Sine at the track's BPM — PLN: a blink "is a bit aggressive", and out of
tempo it reads as an error. RGB is quantised by _rgb, so a repaint only
transmits when the 7-bit colour actually moved."""
for v2 in self.v2_to_v3:
if v2 in self.mapped and self.lang.animated(v2, self.values.get(v2, 64)):
self.paint_cell(v2)
def oled_home(self) -> None:
t, pmt, val = self.lang.home_lines(self.track_name,
f"{len(self.mapped)} live")
self.surface.configure_display(L.TGT_STATIONARY, 2)
for field, line in enumerate((t, pmt, val)):
self.surface.set_text(L.TGT_STATIONARY, field, line)
self.surface.configure_display(L.TGT_STATIONARY, 0x7F)
def oled_touch(self, v2: int, value: int) -> None:
"""Update the temporary overlay WITHOUT re-triggering it each event.
The first version re-sent configure + all 3 fields + bring-up on every
update, and the overlay visibly blinked through a sweep (PLN: "it does
blink why?"). Now: fields are diffed and only changed ones are sent, and
the configure/bring-up pair fires only when the overlay has likely
timed out (>1.0 s since our last push) — updating a live overlay's text
does not need a re-summon.
"""
now = time.time()
if now - self._last_touch < 0.10: # a sweep is 30+ events/s
return
self._last_touch = now
lines = self.lang.touch_lines(v2, value, self.ctx.get(v2))
stale = now - self._oled_up_at > 1.0
if stale:
self.surface.configure_display(L.TGT_TEMPORARY, 2)
for field, line in enumerate(lines):
if stale or line != self._oled_lines[field]:
self.surface.set_text(L.TGT_TEMPORARY, field, line)
if stale:
self.surface.configure_display(L.TGT_TEMPORARY, 0x7F)
self._oled_lines = lines
self._oled_up_at = now
def stop(self) -> None: def stop(self) -> None:
if self.args.relative: if self.args.relative:
for row in (1, 2, 3): for row in REL_ROWS:
try: try:
self.surface.relative(row, False) self.surface.relative(row, False)
except Exception: except Exception:
...@@ -341,17 +574,46 @@ class Driver: ...@@ -341,17 +574,46 @@ 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 = time.time() last_recon = last_pulse = last_assert = 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
if msg.type != "control_change": if msg.type != "control_change":
if self.args.verbose:
# Page/track/mode presses arrive as notes — the invisible
# traffic that likely explains the surface repainting its
# defaults. Make it visible so next time we KNOW.
print(f" [non-cc] {msg}")
continue continue
# Feature-control traffic (ch7) is state, not a musical control. # Feature-control traffic (ch7) is state, not a musical control.
if msg.channel == L.CH_FEATURE: if msg.channel == L.CH_FEATURE:
if self.args.verbose: if self.args.verbose:
print(f" [feature] {L._describe(msg)}") print(f" [feature] {L._describe(msg)}")
continue continue
# Relative encoders arrive at cc+64 carrying a signed DELTA.
# The driver owns the value: integrate, then speak ABSOLUTE v2
# downstream — SC and the corpus never learn relative exists,
# and neither does the classic LCXL (the BC invariant).
if self.args.relative and msg.control in REL_CCS:
base = REL_CCS[msg.control]
v2 = self.map.get(base)
if v2 is None:
continue
d = rel_delta(msg.value)
value = max(0, min(127, self.values.get(v2, 0) + d)) \
if self.lang else max(0, min(127, d))
if self.lang:
if value == self.values.get(v2):
continue # pinned at an end stop
self.values[v2] = value
self.emit(v2, value)
if self.lang:
self.paint_cell(v2)
self.oled_touch(v2, value)
if self.args.verbose:
print(f" {L.control_name(base):<16} rel {d:+3d} "
f"-> v2 #{v2:<3} = {value}")
continue
v2 = self.map.get(msg.control) v2 = self.map.get(msg.control)
if v2 is None: if v2 is None:
key = (msg.channel, msg.control) key = (msg.channel, msg.control)
...@@ -360,6 +622,26 @@ class Driver: ...@@ -360,6 +622,26 @@ class Driver:
if self.args.verbose: if self.args.verbose:
print(f" [unmapped] ch{msg.channel + 1} #{msg.control}") print(f" [unmapped] ch{msg.channel + 1} #{msg.control}")
continue continue
if self.lang and v2 in self.grid.BUTTON_CCS \
and self.grid.CC_TO_CELL[v2][0] == "E" \
and not self.args.no_latch:
if msg.value == 0:
continue # release: a latch ignores it
on = not self.latch.get(v2, False)
self.latch[v2] = on
self.values[v2] = 127 if on else 0
self.emit(v2, self.values[v2])
self.paint_cell(v2)
self.oled_touch(v2, self.values[v2])
if self.args.verbose:
print(f" {L.control_name(msg.control):<16} LATCH "
f"-> v2 #{v2:<3} = {'ON' if on else 'off'}")
continue
if self.args.relative and self.lang and 21 <= msg.control <= 36:
# An UNSHIFTED encoder CC while relative is armed means the
# +64 claim is wrong for this firmware — say so, loudly.
print(f" !! encoder #{msg.control} arrived UNSHIFTED in "
"relative mode — the +64 assumption is false, tell PLN")
self.virt.send( self.virt.send(
mido.Message( mido.Message(
"control_change", "control_change",
...@@ -369,15 +651,26 @@ class Driver: ...@@ -369,15 +651,26 @@ class Driver:
) )
) )
self.stats["out"] += 1 self.stats["out"] += 1
if self.lang:
self.values[v2] = msg.value
self.paint_cell(v2)
self.oled_touch(v2, msg.value)
if self.args.verbose: if self.args.verbose:
print( print(
f" {L.control_name(msg.control):<16} " f" {L.control_name(msg.control):<16} "
f"v3 #{msg.control:<3} -> v2 #{v2:<3} = {msg.value}" f"v3 #{msg.control:<3} -> v2 #{v2:<3} = {msg.value}"
) )
if time.time() - last_recon > self.args.reconcile_secs: now = time.time()
if now - last_recon > self.args.reconcile_secs:
reconcile(verbose=self.args.verbose, reconcile(verbose=self.args.verbose,
cut_thru=not self.args.keep_thru) cut_thru=not self.args.keep_thru)
last_recon = time.time() last_recon = now
if self.lang and now - last_pulse > 0.05:
self.tick_glow()
last_pulse = now
if self.lang and now - last_assert > 2.0:
self.reassert_paint()
last_assert = now
time.sleep(0.001) time.sleep(0.001)
self.stop() self.stop()
...@@ -399,8 +692,20 @@ def main(argv: list[str] | None = None) -> int: ...@@ -399,8 +692,20 @@ def main(argv: list[str] | None = None) -> int:
help="output MIDI channel, 1-based (default 1; SC ignores it, " help="output MIDI channel, 1-based (default 1; SC ignores it, "
"Ardour may not)") "Ardour may not)")
p.add_argument("--relative", action="store_true", p.add_argument("--relative", action="store_true",
help="EXPERIMENT: switch encoder rows to relative. Their CC numbers " help="switch encoder rows to relative: the driver integrates the "
"shift +64, so the map below no longer applies — expect drops.") "deltas and OWNS the values, emitting absolute v2 CCs — "
"pot-pickup cannot exist. Settled with PLN 2026-08-29.")
p.add_argument("--paint", action="store_true",
help="drive the RGB rings + OLED with the v3 visual language "
"(lcxl3_language: role hue x value lightness, diverging DJF, "
"touched-control context). Needs --track for the mapped set.")
p.add_argument("--track", help=".tidal file whose ^NN set defines what is LIVE; "
"everything else paints dark (dark = unmapped)")
p.add_argument("--no-latch", action="store_true",
help="row-E buttons pass through momentary instead of the "
"driver-owned toggle latch")
p.add_argument("--no-seed", action="store_true",
help="do not emit the initial knob seeds (DJF bypass, fx 0)")
p.add_argument("--graph", action="store_true", p.add_argument("--graph", action="store_true",
help="print what currently feeds SuperCollider's MIDI in, and exit") help="print what currently feeds SuperCollider's MIDI in, and exit")
p.add_argument("--keep-thru", action="store_true", p.add_argument("--keep-thru", action="store_true",
......
#!/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