Commit 0824a277 by PLN (Algolia)

feat(lcxl3): the rings breathe at the tempo when a control is engaged

Motion on the board now means one thing: THIS IS MAKING SOUND. It used to mean
only "a DJF sits at its zero mark", because animated() hard-returned False for
every role but family_filter.

Three laws, all at bar rate (the slow breath, not the beat one):

  fx / fx2, every orbit   breathe whenever the knob is off zero
  level, d9-d12 only      breathe whenever the level is not off. Only those
                          four, because d1-d8 levels live on row D and FADERS
                          HAVE NO LEDS — painting them was already called a
                          no-op lie in paint_cell, and asking for it here would
                          have been the same lie one layer up
  boolean cells           dark until 127, breathing at 127

That last law needs to know what a control IS, so parse_kinds() reads it off
the track: a cc reached through `# effect (... "^NN" ...)` is a dial, a cc
reached through midiOn/midiOff is a switch. Tidal applies midiOn's function
while the CC reads high, so such a control is boolean however many steps the
hardware sends — and a ring fading up across a range the music ignores is a
ring that lies about the sound. When one cc is used both ways, continuous wins:
the value demonstrably matters somewhere, so fading is the honest paint.

The breath's CEILING is the value (_breathe_to): the brightest instant equals
_lightness(value) and never exceeds it, so "lightness carries VALUE" still
holds while motion carries engagement. A knob at 30% breathes dim.

Levels stay honest. Ardour owns cc 13-16 and has not told us their values, so
ring_colour(value_known=False) keeps the flat rest colour rather than breathing
on a guess — the oldest rule here is that the LEDs do not lie. The feedback
feed that fills ardour_seen comes next; until it does, those four rings look
exactly as they did.

Also folds two defaults that had drifted: paint used 0 for an unseen control
and the glow tick used 64. value_of() settles it — an unseen DJF is at its
centre detent, because that is where the knob physically rests; anything else
unseen is OFF, because engagement we have never observed must not be claimed.

Checked against rose_rouge: B8+C8 (d8's stacks, the ones asked about) classify
bool; A6/A7/A8's delay and squiz knobs classify cont; breath peak equals the
static value at every level; DJF behaviour unchanged; unmapped still black.
parent 761d8589
......@@ -384,6 +384,42 @@ def parse_context(text: str) -> dict[int, str]:
return ctx
def parse_kinds(text: str) -> dict[int, str]:
"""cc -> "bool" | "cont": is this control a SWITCH or a dial, in THIS track?
PLN, 2026-09-06: "same for the d8 stacks or any midiOn, where the fader is
a 0-----1 range with 127=1 else =0, maybe glow only at max". Tidal's
`midiOn "^NN" f` applies f while the CC reads high, so such a control is
boolean however many steps the hardware sends — and a ring that fades up
across a range the music ignores is a ring that lies about the sound.
A cc reached through `# effect (... "^NN" ...)` is continuous; a cc reached
through `midiOn/midiOff` is boolean. When one track does BOTH with the same
cc, CONTINUOUS wins: the value demonstrably matters somewhere, so fading is
the honest paint and the switch reading is the lossy one.
Classified by what stands to the LEFT of the cc on its own line, which is
where Tidal's application order puts the consumer. Line-based, and
commented-out lines are skipped — the same contract `parse_context` states:
a paint hint may be imperfect, it must never lie about which cc it owns.
"""
import re
kinds: dict[int, str] = {}
for line in text.splitlines():
if line.lstrip().startswith("--"):
continue
for m in re.finditer(r'"\^(\d+)"', line):
cc = int(m.group(1))
if kinds.get(cc) == "cont":
continue # continuous is sticky, see above
before = line[:m.start()]
if re.search(r'#\s*\w+\s*\(?[^"]*$', before):
kinds[cc] = "cont"
elif re.search(r'midi(?:On|Off)\b[^)]*$', before):
kinds[cc] = "bool"
return kinds
def parse_track(path: pathlib.Path) -> tuple[set[int], set[int]]:
"""(^NN control set, dN orbit set) straight out of a .tidal file.
......@@ -766,6 +802,13 @@ class Driver:
self.tempo: tuple[int, float, float] | None = None # (cc, lo, hi)
self.resets = False # eval == cycle 0 on this track
self.ctx: dict[int, str] = {}
# What each control IS in this track (switch vs dial), so the ring
# can breathe by engagement rather than by raw CC position.
self.kinds: dict[int, str] = {}
# CCs whose value Ardour has actually TOLD us (MIDI feedback). Until
# a cc is in here its ring stays a flat rest colour: see
# ring_colour(value_known=...). Empty is the correct cold state.
self.ardour_seen: set[int] = set()
self.mapped: set[int] = self.lang.mapped_ccs(set(), set())
# Bar phase is INTEGRATED, not read off the wall clock: with a tempo
# knob the BPM changes under us, and `time.time() * bpm / 60` jumps
......@@ -924,6 +967,7 @@ class Driver:
self.tempo = parse_tempo_knob(text)
self.resets = resets_cycles(text)
self.ctx = parse_context(text)
self.kinds = parse_kinds(text)
self.mapped = self.lang.mapped_ccs(track_ccs, orbits)
tempo_note = ""
if self.tempo:
......@@ -1118,21 +1162,40 @@ class Driver:
self._phase_t = now
return self._beats % 1.0, (self._beats / 4.0) % 1.0
def value_of(self, v2: int) -> int:
"""The value we believe a control holds.
Unifies two defaults that had drifted apart (paint used 0, the glow tick
used 64). A DJF we have never seen sits at its CENTRE detent — that is
where the knob physically rests and the zero mark is what it should
paint. Anything else unseen is OFF, because motion on this board now
means "engaged", and engagement we have never observed must not be
claimed.
"""
if v2 in self.values:
return self.values[v2]
role, _ = self.grid.CC_ROLE.get(v2, ("", 0))
return 64 if role == "family_filter" else 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)
rgb = self.lang.button_colour(v2, self.value_of(v2) > 0, mapped)
else:
beat, bar = self.phases()
if mapped and self.tempo and v2 == self.tempo[0]:
rgb = self.lang.tempo_colour(self.current_bpm(), beat, bar,
staged=bool(self.mutes_held()))
else:
rgb = self.lang.ring_colour(v2, self.values.get(v2, 0), mapped,
beat, bar)
rgb = self.lang.ring_colour(
v2, self.value_of(v2), mapped, beat, bar,
kind=self.kinds.get(v2),
value_known=(v2 not in self.grid.ARDOUR_CCS
or v2 in self.ardour_seen),
)
if self._last_rgb.get(v3) != rgb:
self.surface.rgb(v3, *rgb)
self._last_rgb[v3] = rgb
......@@ -1166,7 +1229,8 @@ class Driver:
# at the beat when a mute is held, which is the drop counting itself
# in on the ring while his eyes are on the OLED.
if (self.tempo and v2 == self.tempo[0]) \
or self.lang.animated(v2, self.values.get(v2, 64)):
or self.lang.animated(v2, self.value_of(v2),
self.kinds.get(v2)):
self.paint_cell(v2)
def _home_detail(self) -> str:
......
......@@ -84,6 +84,18 @@ def _breath(phase: float) -> float:
return 0.5 + 0.5 * math.sin(2 * math.pi * phase)
def _breathe_to(peak: float, phase: float, floor: float = 0.45) -> float:
"""Sine between floor*peak and peak — motion whose CEILING is the value.
PLN, 2026-09-06: "breathe when not off, bright max == value". So the
brightest instant of the breath IS `_lightness(value)`, never more. A knob
at 30% breathes dim, a knob at full breathes bright, and neither invents a
brightness the value does not have — the board keeps saying "lightness
carries VALUE" while motion says "this is engaged".
"""
return peak * (floor + (1.0 - floor) * _breath(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))
......@@ -100,27 +112,70 @@ def djf_colour(value: int, beat: float = 0.0, bar: float = 0.0) -> tuple[int, in
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."""
# Which cells breathe, and why. PLN set the rule on 2026-09-06: motion means
# "this is making sound right now", so it belongs on every ENGAGED control, not
# only the DJF cells that owned it first.
#
# family_filter at the zero mark, or in a danger zone (unchanged)
# fx / fx2 whenever the knob is off zero (every orbit)
# level whenever the level is not off — knobs 13-16 ONLY, because
# d1-d8 levels live on row D, and FADERS HAVE NO LEDS. PLN:
# "d1-d8 dont have leds nothing on faders so we cant do that"
# boolean cells only at 127 — see BOOL_ON
#
# `kind` is what the .tidal says this control IS (driver.parse_kinds): "bool"
# when a midiOn/midiOff consumes it, "cont" when it sits in a `# effect`. None
# means undetermined, and an undetermined kind is treated as continuous — the
# same shrug parse_track takes on a sample name it cannot resolve.
BOOL_ON = 127 # `midiOn "^NN"` reads high/low; only full counts as on
def animated(v2cc: int, value: int, kind: str | None = None) -> bool:
"""Does this cell breathe, at this value, in this track?"""
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
if role == "family_filter":
return (DJF_LO <= v <= DJF_HI) or v <= DJF_DANGER_LO or v >= DJF_DANGER_HI
if v2cc in G.BUTTON_CCS:
return False # buttons paint STATE (button_colour), not motion
if kind == "bool":
return v >= BOOL_ON
if role in ("fx", "fx2", "level"):
return v > 0
return False
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."""
beat: float = 0.0, bar: float = 0.0,
kind: str | None = None,
value_known: bool = True) -> tuple[int, int, int]:
"""Colour for any knob/fader cell, in v2/corpus terms.
`value_known` is False for a control Ardour owns whose value has not
reached us. That case must stay a flat rest colour: breathing it would
assert "this orbit is audible" on a guess, and the oldest rule on this
board is that the LEDs do not lie.
"""
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:
hue = ORBIT_HUE.get(who, 0)
if v2cc in G.ARDOUR_CCS and not value_known:
# 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))
return _rgb(hue, 0.9, 0.30)
if kind == "bool":
# A switch. Below full it is OFF in Tidal and must LOOK off; a ring
# fading up across a range the music ignores is a ring that lies.
# `_lightness(0)` (not black) because dark means UNMAPPED, always.
if value < BOOL_ON:
return _rgb(hue, 1.0, _lightness(0))
return _rgb(hue, 1.0, _breathe_to(1.0, bar))
peak = _lightness(value)
if animated(v2cc, value, kind):
return _rgb(hue, 1.0, _breathe_to(peak, bar))
return _rgb(hue, 1.0, peak)
def button_colour(v2cc: int, pressed: bool, mapped: bool) -> tuple[int, int, int]:
......
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