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
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
# 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).
......@@ -299,21 +383,170 @@ class Driver:
self.out_channel = args.channel - 1
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:
print(f"enabling DAW mode (the only mode that paints, and the only one")
print(f" that reports the fixed map we translate from)")
self.surface.daw_mode(True)
time.sleep(0.2)
if self.args.relative:
for row in (1, 2, 3):
for row in REL_ROWS:
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}")
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:
if self.args.relative:
for row in (1, 2, 3):
for row in REL_ROWS:
try:
self.surface.relative(row, False)
except Exception:
......@@ -341,17 +574,46 @@ class Driver:
signal.signal(signal.SIGTERM, onsig)
print("\ntranslating — ctrl-c to stop\n")
last_recon = time.time()
last_recon = last_pulse = last_assert = time.time()
while not self._stop:
for msg in self.surface.inp.iter_pending():
self.stats["in"] += 1
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
# Feature-control traffic (ch7) is state, not a musical control.
if msg.channel == L.CH_FEATURE:
if self.args.verbose:
print(f" [feature] {L._describe(msg)}")
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)
if v2 is None:
key = (msg.channel, msg.control)
......@@ -360,6 +622,26 @@ class Driver:
if self.args.verbose:
print(f" [unmapped] ch{msg.channel + 1} #{msg.control}")
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(
mido.Message(
"control_change",
......@@ -369,15 +651,26 @@ class Driver:
)
)
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:
print(
f" {L.control_name(msg.control):<16} "
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,
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)
self.stop()
......@@ -399,8 +692,20 @@ def main(argv: list[str] | None = None) -> int:
help="output MIDI channel, 1-based (default 1; SC ignores it, "
"Ardour may not)")
p.add_argument("--relative", action="store_true",
help="EXPERIMENT: switch encoder rows to relative. Their CC numbers "
"shift +64, so the map below no longer applies — expect drops.")
help="switch encoder rows to relative: the driver integrates the "
"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",
help="print what currently feeds SuperCollider's MIDI in, and exit")
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