Commit 7c297a61 by PLN (Algolia)

feat(lcxl3): the surface follows the track — and the tempo knob grew notches

The driver parsed its .tidal once, at launch, and never looked again. PLN said
it three ways in one session: "i see still ROSE_ROUGE in title", "why dont i
see the lcxl colors evolve as i map buttons on the new track", "probably this
will be an opp to confirm you read current file well". One bug, three faces.

FOLLOW. The editor has published the loaded track for a while
(~/.cache/parvagues/current-track, written by the HUD) and logs every
ctrl+enter (eval-events.jsonl). The driver needed no new channel — it needed to
read. It now polls both once a second: a different path is a track change
(remap, relabel all 32 targets, repaint, reset the lap clock), and a changed
mtime on the SAME path is a re-read, so mapping a button and saving lights the
board within a second. --no-follow pins the old behaviour.

CARRY POLICY, PLN's spec verbatim: "faders are real positions, but could we
reset the knobs to 'defaults' on all effects? and maybe teh DJFs stay across
tracks so i can do djf-and-fader-crossfaded-transitions". So DJFs carry (they
ARE the transition), effect knobs go to 0, faders are physical and untouched.
Two cells carry that he did not name, because the sound would have taught us
the hard way: the TEMPO knob (resetting it means the range floor — a 60->120
transition would slam back to 60 exactly when the new track lands) and every
button (emitting to a gate is the mutebomb; latches reset in the driver so the
LEDs stop lying, but nothing is sent).

LIVE TEMPO. sept1's idiom `# cps ((range 60 180 "^29")/60/4)` makes B1 the
clock — and it steered the tempo all last session while the OLED said "B1
unmapped", which is the whole argument for following the track: the sound was
right and every label lied. The BPM is now derived from the knob, and bar phase
is INTEGRATED rather than read off the wall clock, because `time.time() * bpm /
60` re-scales all of history whenever the tempo moves — the countdown would
have jumped precisely mid-transition, the one moment it is read.

THE MOVE. PLN: "start holding kick mute, move kick to the 120bpm position (i
used the center notch, now ill rely on OLED), drop kick mute at end of a bar".
So the screen becomes the notch: BPM not wire units, an arrow to the nearest
landmark that vanishes when he is on it, and — while a mute is held — the bar
countdown ("DROP kick / ~120 BPM >2"), with the ring breathing at the beat to
count it in. The bar is real, not guessed: `resetCycles` shares a block with
the patterns, so every ctrl+enter IS cycle 0 and the eval log is a phase anchor.

Plus software detents, since the v3 encoder has no ridge: a deliberate single
click that would cross a musical tempo lands ON it and holds for two more
clicks; fast sweeps pass through untouched. Two versions of that welded the
knob to the first notch it met (the closest STEP to a landmark sits just below
it, so stepping up re-crossed it and snapped back — measured, 24 consecutive
clicks stuck at 114.8 BPM); the fix tracks WHICH landmark holds us by identity
and releases only once the value has actually left it.

And the mute overlay finally reports the whole held SET ("MUTE x2 /
kick+percs", "all") instead of the last finger down.

Honesty notes: 60-180 over 128 steps is 0.94 BPM/step, so exactly 120 is not
reachable (step 63 -> 119.5, step 64 -> 120.4). The readout prints "~120" for
the closest step and a bare "120" only when it is exact. A track wanting true
round tempos can quantise in the pattern:
`# cps ((quantise 1 (range 60 180 "^29"))/60/4)` — then two steps both land on
120 and the detent is the music's, not the driver's.

Also closed by evidence rather than code: A1's label is correct ("d9 level
[ard]" — the grid puts d9-d12 on row A, faders carry d1-d8), so the suspected
off-by-one that sat on the board was never real.
parent fab4512a
...@@ -53,6 +53,9 @@ WHY A VIRTUAL PORT AND A RECONCILER ...@@ -53,6 +53,9 @@ WHY A VIRTUAL PORT AND A RECONCILER
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import json
import math
import os
import pathlib import pathlib
import signal import signal
import subprocess import subprocess
...@@ -79,6 +82,11 @@ VIRTUAL_PORT_NAME = "ParVagues LCXL3" ...@@ -79,6 +82,11 @@ VIRTUAL_PORT_NAME = "ParVagues LCXL3"
REL_CCS = {base + 64: base for base in range(21, 37)} REL_CCS = {base + 64: base for base in range(21, 37)}
REL_ROWS = (2, 3) REL_ROWS = (2, 3)
# Clicks absorbed at a tempo landmark before the knob moves on. 2 is enough to
# FEEL as a notch without fighting a deliberate fast sweep (which arrives as
# multi-step deltas anyway).
DETENT_TICKS = 2
def rel_delta(v: int) -> int: def rel_delta(v: int) -> int:
"""Offset-64 relative value -> signed delta. """Offset-64 relative value -> signed delta.
...@@ -103,6 +111,124 @@ def parse_bpm(text: str) -> float: ...@@ -103,6 +111,124 @@ def parse_bpm(text: str) -> float:
return float(m.group(1)) if m else 120.0 return float(m.group(1)) if m else 120.0
REPO = pathlib.Path(__file__).resolve().parent.parent
# The editor already publishes both of these, and has for a while. The driver
# does not need a new channel, a socket or a plugin: it needs to READ.
# current-track one line, the loaded .tidal, rewritten on change by the HUD
# eval-events one JSON object per ctrl+enter: {t, path, d:[streams]}
# PLN, three times in one session — "i see still ROSE_ROUGE in title", "why dont
# i see the lcxl colors evolve as i map buttons on the new track", "probably
# this will be an opp to confirm you read current file well" — is describing one
# bug: a driver that parsed its track once, at launch, and never looked again.
TRACK_FILE = pathlib.Path(os.path.expanduser("~/.cache/parvagues/current-track"))
EVAL_FILE = pathlib.Path(os.path.expanduser("~/.cache/parvagues/eval-events.jsonl"))
def read_current_track() -> str | None:
try:
t = TRACK_FILE.read_text().strip()
except OSError:
return None
return t or None
def resolve_track(raw: str, strict: bool = True) -> pathlib.Path | None:
"""Accept an absolute path, a repo-relative one, or a bare track name.
The HUD publishes repo-relative ("live/midi/nova/nujazz/x.tidal"), PLN types
whatever is shortest. Same resolution order as lcxl-leds.py so the two tools
can never disagree about which file "the track" means.
"""
p = pathlib.Path(raw).expanduser()
if p.exists():
return p
if (REPO / raw).exists():
return REPO / raw
name = raw if raw.endswith(".tidal") else raw + ".tidal"
hits = sorted((REPO / "live").rglob(name)) + sorted((REPO / "copycat").rglob(name))
if len(hits) == 1:
return hits[0]
if not strict:
# In the follow loop an unresolvable name is a shrug, not a death: the
# editor may be sitting on a scratch buffer that is not in the repo.
return None
sys.exit(f"lcxl3-driver: cannot resolve track {raw!r} ({len(hits)} matches)")
def last_eval(path: pathlib.Path) -> float:
"""Timestamp of the most recent ctrl+enter on this track, 0.0 if none.
Read from the tail: the log is append-only and grows all night, and the only
line that matters is the last one for this file.
"""
try:
with EVAL_FILE.open("rb") as fh:
fh.seek(0, os.SEEK_END)
back = min(fh.tell(), 8192)
fh.seek(-back, os.SEEK_END)
lines = fh.read().decode("utf-8", "replace").splitlines()
except OSError:
return 0.0
want = str(path)
for line in reversed(lines):
try:
ev = json.loads(line)
except ValueError:
continue
p = str(ev.get("path", ""))
if p and (want.endswith(p) or p.endswith(want)):
return float(ev.get("t", 0.0))
return 0.0
def parse_tempo_knob(text: str) -> tuple[int, float, float] | None:
"""(v2 cc, lo BPM, hi BPM) when the track's tempo IS a knob.
sept1.tidal, and now the house idiom:
# cps ((range 60 180 "^29")/60/4)
PLN heard this work through the driver while the OLED said "B1 unmapped" —
the translation is track-agnostic, so the knob steered the clock even though
the driver was still holding the PREVIOUS track's map. Which is the whole
argument for following the track: the sound was right and every label lied.
Units: a `range` inside a `cps` expression that is divided by 60 is in BPM
(`/60/4` = BPM to cps at four beats a bar). Without that divisor the numbers
ARE cps, and cps * 240 is the BPM of a four-beat bar. Guessing wrong here
puts a factor of 240 on the screen, so it is read off the line, not assumed.
"""
import re
for line in text.splitlines():
if line.lstrip().startswith("--") or "cps" not in line:
continue
m = re.search(r'range\s+([0-9.]+)\s+([0-9.]+)\s+"\^(\d+)"', line)
if not m:
continue
lo, hi, cc = float(m.group(1)), float(m.group(2)), int(m.group(3))
if not re.search(r"/\s*60", line):
lo, hi = lo * 240.0, hi * 240.0
return cc, lo, hi
return None
def resets_cycles(text: str) -> bool:
"""Does an eval of this track's pattern block ALSO reset the clock?
A blank line is Tidal's block separator (the editor sends the run of
non-blank lines around the cursor — pulsar-parvagues-hud/lib/eval-publish.js
reimplements exactly that). So when `resetCycles` shares a block with a `dN`,
every ctrl+enter on that pattern restarts cycle 0 — which is both the reason
PLN hears the kick retrigger on rapid re-evals, and a GIFT: it makes each
eval a true phase anchor, so the bar countdown can be real instead of
wall-clock guesswork.
"""
import re
for block in re.split(r"\n\s*\n", text):
if "resetCycles" in block and re.search(r"^\s*d\d+\s*\$", block, re.M):
return True
return False
def parse_context(text: str) -> dict[int, str]: def parse_context(text: str) -> dict[int, str]:
"""cc -> what the control DOES in this track, for the OLED's line 2. """cc -> what the control DOES in this track, for the OLED's line 2.
...@@ -222,7 +348,9 @@ def build_map() -> dict[int, int]: ...@@ -222,7 +348,9 @@ def build_map() -> dict[int, int]:
def label_table(m: dict[int, int], ctx: dict[int, str], mapped: set[int], def label_table(m: dict[int, int], ctx: dict[int, str], mapped: set[int],
lang, grid, overlay_only: bool = False, lang, grid, overlay_only: bool = False,
native_all: bool = False) -> list[tuple[int, int, str, bool]]: native_all: bool = False,
tempo: tuple[int, float, float] | None = None,
) -> list[tuple[int, int, str, bool]]:
"""(v3 index, v2 cc, name, native) for every control that owns a display. """(v3 index, v2 cc, name, native) for every control that owns a display.
Only the 32 analogue controls appear: faders 05h-0Ch and encoders 0Dh-24h. Only the 32 analogue controls appear: faders 05h-0Ch and encoders 0Dh-24h.
...@@ -245,6 +373,11 @@ def label_table(m: dict[int, int], ctx: dict[int, str], mapped: set[int], ...@@ -245,6 +373,11 @@ def label_table(m: dict[int, int], ctx: dict[int, str], mapped: set[int],
name = lang.control_label(v2, ctx.get(v2), is_mapped) name = lang.control_label(v2, ctx.get(v2), is_mapped)
role, _who = grid.CC_ROLE.get(v2, ("", 0)) role, _who = grid.CC_ROLE.get(v2, ("", 0))
native = not overlay_only and (native_all or role != "family_filter") native = not overlay_only and (native_all or role != "family_filter")
if tempo and v2 == tempo[0] and is_mapped:
# The tempo knob joins the DJFs on OUR overlay: the firmware can
# only draw the wire value, and "63" is not a tempo. We owe him BPM.
name = lang.tempo_label(tempo[1], tempo[2])
native = False
out.append((v3, v2, name, native)) out.append((v3, v2, name, native))
return out return out
...@@ -451,22 +584,6 @@ class Driver: ...@@ -451,22 +584,6 @@ class Driver:
import lcxl3_language import lcxl3_language
self.lang = lcxl3_language self.lang = lcxl3_language
self.v2_to_v3 = {v2: v3 for v3, v2 in self.map.items()} 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 — # Driver-owned values, v2-keyed. DJF seeds at the bypass detent —
# anything else at that position would colour the whole mix. # anything else at that position would colour the whole mix.
import lcxl_grid import lcxl_grid
...@@ -475,6 +592,26 @@ class Driver: ...@@ -475,6 +592,26 @@ class Driver:
for v2 in self.v2_to_v3: for v2 in self.v2_to_v3:
role, _ = self.grid.CC_ROLE.get(v2, ("", 0)) role, _ = self.grid.CC_ROLE.get(v2, ("", 0))
self.values[v2] = 64 if role == "family_filter" else 0 self.values[v2] = 64 if role == "family_filter" else 0
# ---- what the CURRENT track is; all of it replaced on a change ----
self.track_path: pathlib.Path | None = None
self.track_mtime = 0.0
self.track_name = "no track"
self.bpm = 120.0 # static fallback (setcps)
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] = {}
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
# the phase every time it does — the countdown would lie exactly
# when it matters most, mid-transition.
self._beats = 0.0
self._phase_t = time.time()
self._eval_seen = 0.0
self._last_mutes: tuple[int, ...] = ()
self._detent_hold = 0
self._detent_at: float | None = None
self._detent_v = -1
self._last_rgb: dict[int, tuple[int, int, int]] = {} self._last_rgb: dict[int, tuple[int, int, int]] = {}
self._last_touch = 0.0 self._last_touch = 0.0
self._oled_lines = ("", "", "") self._oled_lines = ("", "", "")
...@@ -493,9 +630,13 @@ class Driver: ...@@ -493,9 +630,13 @@ class Driver:
# the panic chord needs four simultaneous 127s. # the panic chord needs four simultaneous 127s.
self.latch: dict[int, bool] = {} self.latch: dict[int, bool] = {}
# The lap clock: elapsed-on-this-track, shown on home row 2 so a # The lap clock: elapsed-on-this-track, shown on home row 2 so a
# set's overstretch is visible at a glance. Reset belongs to the # set's overstretch is visible at a glance. A track CHANGE is the
# future Track </> setlist cursor — for now, track load = lap start. # lap: "we could have 'lap' chronometer automatically via our track
# change controls" — and following the editor gives us that for free.
self.track_started = time.time() self.track_started = time.time()
start = args.track or (read_current_track() if args.follow else None)
if start:
self.load_track(resolve_track(start), first=True)
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")
...@@ -535,10 +676,218 @@ class Driver: ...@@ -535,10 +676,218 @@ class Driver:
control=v2, value=value)) control=v2, value=value))
self.stats["out"] += 1 self.stats["out"] += 1
# ------------------------------------------------- following the editor ----
def load_track(self, path: pathlib.Path, first: bool = False) -> None:
"""(Re)read the track. Called at launch, on a track change, and on SAVE.
The save case is not a nicety: PLN maps a button, saves, and expects the
board to light up ("why dont i see the lcxl colors evolve as i map
buttons on the new track?"). Re-parsing on mtime makes the surface follow
composition, not just performance.
"""
old, old_tempo = self.track_path, self.tempo
changed = old is None or path != old
text = path.read_text(errors="replace")
track_ccs, orbits = parse_track(path)
self.track_path = path
try:
self.track_mtime = path.stat().st_mtime
except OSError:
self.track_mtime = 0.0
self.track_name = path.stem
self.bpm = parse_bpm(text)
self.tempo = parse_tempo_knob(text)
self.resets = resets_cycles(text)
self.ctx = parse_context(text)
self.mapped = self.lang.mapped_ccs(track_ccs, orbits)
tempo_note = ""
if self.tempo:
cc, lo, hi = self.tempo
tempo_note = (f", TEMPO on {self.grid.label(cc)} "
f"{lo:g}-{hi:g} BPM (live)")
print(f"track {'->' if changed and not first else '~'} {self.track_name}: "
f"{len(track_ccs)} controls, orbits {sorted(orbits)}, "
f"{len(self.ctx)} labels{tempo_note}"
f"{', eval=cycle 0' if self.resets else ''}")
if changed and not first:
self.track_started = time.time() # a new track is a new lap
self.carry_values(old_tempo)
# Anchor on the evals ALREADY logged for this file, so a load mid-track
# does not re-fire an hour-old anchor as if it just happened.
self._eval_seen = last_eval(path)
if self.resets and self._eval_seen:
self.anchor_phase(self._eval_seen)
if first:
return
self.oled_labels(quiet=True)
self.reassert_paint()
self.oled_home()
def carry_values(self, old_tempo: tuple[int, float, float] | None) -> None:
"""What survives a track change, and what goes home to its default.
PLN's spec, verbatim (2026-08-30): "faders are real positions, but could
we reset the knobs to 'defaults' on all effects? and maybe teh DJFs stay
across tracks so i can do djf-and-fader-crossfaded-transitions".
So: DJFs carry (they ARE the transition), faders are physical and cannot
be moved by us anyway, effect knobs go to 0. Two more cells carry that he
did not name, for reasons the sound would have taught us the hard way:
* the TEMPO knob. Resetting it to 0 means the range floor — a 60->120
transition would slam back to 60 the instant the new track loads,
which is the precise opposite of the move it exists to serve. Both
the old and the new track's tempo cell are protected, because during
the change either one may be the knob under his thumb.
* every BUTTON. Emitting to a gate is the mutebomb; the latch state is
reset in the DRIVER so the LEDs stop lying and the next press means
ON, but nothing is sent. A gate that was open stays open until he
closes it, which is what a performer expects of a held gesture.
"""
keep = {v2 for v2 in self.values
if self.grid.CC_ROLE.get(v2, ("", 0))[0] == "family_filter"}
for t in (old_tempo, self.tempo):
if t:
keep.add(t[0])
reset = 0
for v2 in sorted(self.values):
if v2 in keep or v2 not in self.grid.KNOB_CCS \
or v2 in self.grid.ARDOUR_CCS:
continue
if self.values.get(v2, 0) == 0:
continue
self.values[v2] = 0
if v2 in self.mapped:
self.emit(v2, 0)
reset += 1
latches = sum(1 for on in self.latch.values() if on)
self.latch.clear()
for v2 in self.grid.BUTTON_CCS:
if v2 in self.values:
self.values[v2] = 0
print(f" lap reset · {reset} effect knobs -> 0 · {len(keep)} carried "
f"(DJF + tempo) · {latches} latches cleared (no CC sent)")
def anchor_phase(self, t_eval: float) -> None:
"""Cycle 0 was at `t_eval` — rebuild the beat count from there.
Only valid on tracks whose pattern block contains `resetCycles` (see
`resets_cycles`). Assumes the tempo has not moved since that eval, which
is true of the common case (eval, then steer) and drifts gracefully
otherwise: the next eval re-anchors.
"""
now = time.time()
self._beats = max(0.0, now - t_eval) * self.current_bpm() / 60.0
self._phase_t = now
def follow_tick(self) -> None:
"""One second's worth of "is this still the track?"."""
if not self.args.follow:
return
pub = read_current_track()
path = resolve_track(pub, strict=False) if pub else None
if path is None:
path = self.track_path
if path is None:
return
try:
mt = path.stat().st_mtime
except OSError:
return
if path != self.track_path or mt != self.track_mtime:
self.load_track(path)
return
if self.resets:
t = last_eval(path)
if t > self._eval_seen:
self._eval_seen = t
self.anchor_phase(t)
# --------------------------------------------------------------- tempo ----
def current_bpm(self) -> float:
"""The tempo the track is ACTUALLY at: the knob's, if it has one."""
if self.tempo:
cc, lo, hi = self.tempo
return lo + (hi - lo) * self.values.get(cc, 0) / 127.0
return self.bpm
def tempo_step(self, cur: int, d: int) -> int:
"""One relative click on the tempo knob — with LANDMARK DETENTS.
PLN: "i used the center notch, now ill rely on OLED". The v3 encoder has
no ridge, but the driver owns the value, so the ridge can be software:
a step that would cross a landmark lands ON the landmark's closest step
instead of past it, and the next few clicks are absorbed before it moves
on. He feels a notch at every musical tempo, not just at 12 o'clock —
and because the driver (not the surface) holds the value, the classic
LCXL is unaffected: there, the pot's own position is still the truth.
"""
cc, lo, hi = self.tempo
span = hi - lo
def bpm(v: int) -> float:
return lo + span * v / 127.0
# A fast sweep is a gesture toward a destination, not a placement: it
# passes through every notch untouched. Notches are for the last clicks,
# which is where the ridge on the old pot actually earned its keep.
# A fast sweep is a gesture toward a destination, not a placement: it
# passes through every notch untouched. Notches are for the last clicks,
# which is where the ridge on the old pot actually earned its keep.
if abs(d) > 1:
self._detent_hold, self._detent_at = 0, None
return max(0, min(127, cur + d))
# Which landmark is holding us, by identity — not "is a landmark near".
# Two earlier versions welded the knob to the first notch it met: the
# closest STEP to a landmark usually sits just below it, so stepping up
# re-crosses the same landmark and snaps back. Measured: 24 consecutive
# clicks stuck at 114.8 BPM. The notch is released when the value has
# actually left it, and only then.
if self._detent_at is not None and cur != self._detent_v:
self._detent_at = None
if d and self._detent_hold > 0 and self._detent_at is not None:
self._detent_hold -= 1
return cur
target = max(0, min(127, cur + d))
crossed = [t for t in self.lang.TEMPO_LANDMARKS
if min(bpm(cur), bpm(target)) < t <= max(bpm(cur), bpm(target))
and t != self._detent_at]
if crossed:
lm = crossed[0] if d > 0 else crossed[-1]
best = min(range(128), key=lambda v: abs(bpm(v) - lm))
self._detent_hold, self._detent_at, self._detent_v = \
DETENT_TICKS, lm, best
return best
return target
def mutes_held(self) -> tuple[int, ...]:
"""Which mute FAMILIES are down right now (1=kick, 2=percs, 3=bass+mel)."""
out = []
for v2 in self.values:
role, who = self.grid.CC_ROLE.get(v2, ("", 0))
if role == "family_mute" and self.values.get(v2, 0) > 0:
out.append(who)
return tuple(sorted(out))
def beats_left(self) -> int:
"""Beats until the bar ends, 4..1 — the drop cue."""
self.phases()
return max(1, math.ceil(4.0 - (self._beats % 4.0)))
def phases(self) -> tuple[float, float]: def phases(self) -> tuple[float, float]:
"""(beat, bar) phase 0..1 at the track's BPM, wall-clock anchored.""" """(beat, bar) phase 0..1, INTEGRATED at the live BPM.
beats = time.time() * self.bpm / 60.0
return beats % 1.0, (beats / 4.0) % 1.0 Advancing a running total (rather than `time.time() * bpm / 60`) is what
makes a tempo knob safe: the wall-clock form re-scales all of history
every time the BPM moves, so the phase jumped — and the bar countdown
would have lied exactly during a transition, the one moment it is read.
"""
now = time.time()
self._beats += max(0.0, now - self._phase_t) * self.current_bpm() / 60.0
self._phase_t = now
return self._beats % 1.0, (self._beats / 4.0) % 1.0
def paint_cell(self, v2: int) -> None: def paint_cell(self, v2: int) -> None:
v3 = self.v2_to_v3.get(v2) v3 = self.v2_to_v3.get(v2)
...@@ -549,8 +898,12 @@ class Driver: ...@@ -549,8 +898,12 @@ class Driver:
rgb = self.lang.button_colour(v2, self.values.get(v2, 0) > 0, mapped) rgb = self.lang.button_colour(v2, self.values.get(v2, 0) > 0, mapped)
else: else:
beat, bar = self.phases() beat, bar = self.phases()
rgb = self.lang.ring_colour(v2, self.values.get(v2, 0), mapped, if mapped and self.tempo and v2 == self.tempo[0]:
beat, bar) 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)
if self._last_rgb.get(v3) != rgb: if self._last_rgb.get(v3) != rgb:
self.surface.rgb(v3, *rgb) self.surface.rgb(v3, *rgb)
self._last_rgb[v3] = rgb self._last_rgb[v3] = rgb
...@@ -578,12 +931,18 @@ class Driver: ...@@ -578,12 +931,18 @@ class Driver:
tempo it reads as an error. RGB is quantised by _rgb, so a repaint only tempo it reads as an error. RGB is quantised by _rgb, so a repaint only
transmits when the 7-bit colour actually moved.""" transmits when the 7-bit colour actually moved."""
for v2 in self.v2_to_v3: for v2 in self.v2_to_v3:
if v2 in self.mapped and self.lang.animated(v2, self.values.get(v2, 64)): if v2 not in self.mapped:
continue
# The tempo cell breathes too — at the bar when locked on a landmark,
# 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)):
self.paint_cell(v2) self.paint_cell(v2)
def _home_detail(self) -> str: def _home_detail(self) -> str:
el = int(time.time() - self.track_started) el = int(time.time() - self.track_started)
return f"{self.bpm:g}BPM {el // 60}:{el % 60:02d}" return f"{self.current_bpm():.0f}BPM {el // 60}:{el % 60:02d}"
def oled_home(self) -> None: def oled_home(self) -> None:
t, pmt, val = self.lang.home_lines(self.track_name, self._home_detail()) t, pmt, val = self.lang.home_lines(self.track_name, self._home_detail())
...@@ -617,7 +976,8 @@ class Driver: ...@@ -617,7 +976,8 @@ class Driver:
self._labels = label_table( self._labels = label_table(
self.map, self.ctx, self.mapped, self.lang, self.grid, self.map, self.ctx, self.mapped, self.lang, self.grid,
overlay_only=self.args.oled_overlay, overlay_only=self.args.oled_overlay,
native_all=self.args.oled_native_all) native_all=self.args.oled_native_all,
tempo=self.tempo)
for v3, _v2, name, native in self._labels: for v3, _v2, name, native in self._labels:
self.surface.label_control(v3, name, native=native) self.surface.label_control(v3, name, native=native)
time.sleep(0.002) # 32 SysEx back-to-back; give the parser air time.sleep(0.002) # 32 SysEx back-to-back; give the parser air
...@@ -644,7 +1004,19 @@ class Driver: ...@@ -644,7 +1004,19 @@ class Driver:
if now - self._last_touch < 0.05: 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)) role, _who = self.grid.CC_ROLE.get(v2, ("", 0))
if self.tempo and v2 == self.tempo[0]:
# The tempo knob gets BPM, a landmark arrow, and — while a mute is
# down — the bar countdown. PLN's move needs all three and the
# firmware's raw 0-127 gives none of them.
held = self.lang.mute_summary(self.mutes_held())
lines = self.lang.tempo_lines(self.current_bpm(), held,
self.beats_left() if held else None)
elif role == "family_mute":
# The whole held SET, not the last finger down.
lines = self.lang.mute_lines(self.mutes_held())
else:
lines = self.lang.touch_lines(v2, value, self.ctx.get(v2))
# If the firmware owns this control's screen, sending our overlay is the # If the firmware owns this control's screen, sending our overlay is the
# ownership battle itself — two writers, one 128x64 panel, and the one # ownership battle itself — two writers, one 128x64 panel, and the one
# with the hardware event loop wins. Keep the ghost honest and stand down. # with the hardware event loop wins. Keep the ghost honest and stand down.
...@@ -762,6 +1134,7 @@ class Driver: ...@@ -762,6 +1134,7 @@ class Driver:
print("\ntranslating — ctrl-c to stop\n") print("\ntranslating — ctrl-c to stop\n")
last_recon = last_pulse = last_assert = last_labels = last_home = time.time() last_recon = last_pulse = last_assert = last_labels = last_home = time.time()
last_follow = 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
...@@ -787,8 +1160,13 @@ class Driver: ...@@ -787,8 +1160,13 @@ class Driver:
if v2 is None: if v2 is None:
continue continue
d = rel_delta(msg.value) d = rel_delta(msg.value)
value = max(0, min(127, self.values.get(v2, 0) + d)) \ if not self.lang:
if self.lang else max(0, min(127, d)) value = max(0, min(127, d))
elif self.tempo and v2 == self.tempo[0] \
and not self.args.no_detents:
value = self.tempo_step(self.values.get(v2, 0), d)
else:
value = max(0, min(127, self.values.get(v2, 0) + d))
if self.lang: if self.lang:
if value == self.values.get(v2): if value == self.values.get(v2):
continue # pinned at an end stop continue # pinned at an end stop
...@@ -878,7 +1256,10 @@ class Driver: ...@@ -878,7 +1256,10 @@ class Driver:
if self.lang and now - last_assert > 2.0: if self.lang and now - last_assert > 2.0:
self.reassert_paint() self.reassert_paint()
last_assert = now last_assert = now
if self.lang and now - last_home > 10.0: if self.lang and now - last_follow > 1.0:
self.follow_tick()
last_follow = now
if self.lang and now - last_home > 2.0:
# tick the lap clock on home row 2; a field write on the # tick the lap clock on home row 2; a field write on the
# stationary target needs no re-summon # stationary target needs no re-summon
self.surface.set_text(L.TGT_STATIONARY, 1, self._home_detail()) self.surface.set_text(L.TGT_STATIONARY, 1, self._home_detail())
...@@ -962,11 +1343,20 @@ def main(argv: list[str] | None = None) -> int: ...@@ -962,11 +1343,20 @@ def main(argv: list[str] | None = None) -> int:
"(lcxl3_language: role hue x value lightness, diverging DJF, " "(lcxl3_language: role hue x value lightness, diverging DJF, "
"touched-control context). Needs --track for the mapped set.") "touched-control context). Needs --track for the mapped set.")
p.add_argument("--track", help=".tidal file whose ^NN set defines what is LIVE; " p.add_argument("--track", help=".tidal file whose ^NN set defines what is LIVE; "
"everything else paints dark (dark = unmapped)") "everything else paints dark (dark = unmapped). "
"Optional now: with --follow the driver reads "
"the track the editor publishes")
p.add_argument("--no-follow", dest="follow", action="store_false",
help="do NOT follow the editor's current track "
"(~/.cache/parvagues/current-track) or re-read it on "
"save; pin whatever --track said at launch")
p.add_argument("--no-echo", action="store_true", p.add_argument("--no-echo", action="store_true",
help="do not echo integrated encoder values back to the " help="do not echo integrated encoder values back to the "
"device (the echo is what makes the firmware's numeric " "device (the echo is what makes the firmware's numeric "
"display track relative encoders)") "display track relative encoders)")
p.add_argument("--no-detents", action="store_true",
help="tempo knob sweeps smoothly instead of notching at "
"musical landmarks (60, 90, 120, 128, 174...)")
p.add_argument("--no-latch", action="store_true", p.add_argument("--no-latch", action="store_true",
help="row-E buttons pass through momentary instead of the " help="row-E buttons pass through momentary instead of the "
"driver-owned toggle latch") "driver-owned toggle latch")
......
...@@ -166,6 +166,108 @@ def touch_lines(v2cc: int, value: int, ...@@ -166,6 +166,108 @@ def touch_lines(v2cc: int, value: int,
return (title, str(value), lab) return (title, str(value), lab)
# ------------------------------------------------------------------ tempo ----
# The tempo can BE a knob. sept1.tidal says
# # cps ((range 60 180 "^29")/60/4)
# and ^29 is row-B knob 1 in v2 numbering, so B1 IS the tempo — live, on the
# surface, with no code edit. PLN's transition move, verbatim (2026-08-30):
# "start holding kick mute, move kick to the 120bpm position (i used the center
# notch, now ill rely on OLED), drop kick mute at end of a bar for a smooth
# 60->120 transition".
#
# The v3 encoders have no centre ridge, so the SCREEN has to be the notch. That
# move needs exactly three things, and they fit the two rows he can read:
# 1. BPM, not wire units.
# 2. WHICH landmark is near and FROM WHICH SIDE — an arrow that vanishes when
# he is on it. That is the ridge his thumb used to find.
# 3. While a mute is held: how many beats until the bar ends, because the
# drop lands on a bar line or it does not land at all.
TEMPO_LANDMARKS = (60, 70, 75, 80, 85, 90, 95, 100, 105, 110, 115, 120, 125,
128, 130, 135, 140, 145, 150, 160, 170, 174, 180)
# 60-180 over 128 steps is 0.94 BPM per step, so "exactly 120" is NOT reachable:
# it sits between step 63 (119.5) and step 64 (120.4). The lock window is one
# half-step, and off-lock keeps a decimal — the screen must never round its way
# into claiming a tempo the pattern is not playing. (To make round numbers
# reachable, the TRACK can quantise: `# cps ((quantise 1 (range 60 180 "^29"))
# /60/4)` — then two steps both land on 120 and the detent is real.)
TEMPO_LOCK = 0.5
TEMPO_HUE = 40 # gold — tempo is nobody's orbit
def tempo_lock(bpm: float) -> tuple[float, bool]:
"""(nearest landmark, are we on it)."""
near = min(TEMPO_LANDMARKS, key=lambda t: abs(t - bpm))
return near, abs(near - bpm) <= TEMPO_LOCK
def tempo_lines(bpm: float, held: str = "",
beats_left: int | None = None) -> tuple[str, str, str]:
"""The tempo knob's overlay. Row 2 carries the number he steers by."""
near, locked = tempo_lock(bpm)
if locked:
# "~120" when the knob cannot land exactly there (63 -> 119.5, 64 ->
# 120.4 over a 60-180 range). The tilde is the whole truth in one
# character: this is as close to 120 as this control gets. Dropping it
# would print a round number the pattern is not playing.
val = f"{'' if abs(near - bpm) < 0.05 else '~'}{near:g} BPM"
else:
val = f"{bpm:.1f} {'^' if bpm < near else 'v'}{near:g}"
if not held:
return ("TEMPO" + (" LOCK" if locked else ""), val, "")
# A drop is staged: a mute is down and the tempo is moving. Row 1 says
# whether to wait, row 2 counts the bar out — the release cue, not decor.
if locked and beats_left is not None:
return (f"DROP {held}"[:17], f"{val} >{beats_left}"[:17], "")
return (f"HOLD {held}"[:17], val, "")
def tempo_colour(bpm: float, beat: float, bar: float,
staged: bool = False) -> tuple[int, int, int]:
"""Gold while hunting, deep green once locked — breathing at the BAR, or at
the BEAT when a mute is held, so the ring itself counts the drop in."""
near, locked = tempo_lock(bpm)
if locked:
return _rgb(135, 1.0, 0.25 + 0.45 * _breath(beat if staged else bar))
lo, hi = TEMPO_LANDMARKS[0], TEMPO_LANDMARKS[-1]
t = max(0.0, min(1.0, (bpm - lo) / (hi - lo)))
return _rgb(TEMPO_HUE, 0.9, 0.20 + 0.55 * t)
def tempo_label(lo: float, hi: float, width: int = 16) -> str:
"""The tempo knob's name on its own display target.
Not `d1 # cps`: the grid calls row-B knob 1 orbit 1's fx cell, and the
parsed context would read "# cps" — both true about the SYNTAX and wrong
about the CONTROL, which steers the whole track's clock, not d1's.
"""
return f"TEMPO {lo:g}-{hi:g}"[:width]
def mute_summary(held: list[int] | tuple[int, ...]) -> str:
"""PLN: "when i press mute buttons i expect more like 'Mute\\n{kick,percs,
kick+percs,mel,kick+mel,all}' dep on which are held together or single"."""
ids = sorted(set(held))
if not ids:
return ""
if len(ids) >= len(MUTE_NAME):
return "all"
return "+".join(MUTE_NAME.get(i, f"F{i}") for i in ids)
def mute_lines(held: list[int] | tuple[int, ...]) -> tuple[str, str, str]:
"""One overlay for the whole held SET, not last-press-wins.
The old behaviour showed `MUTE kick / 127` — true about one button and
silent about the other two, which is the least useful thing a screen can be
while three fingers are down.
"""
ids = sorted(set(held))
if not ids:
return ("MUTE", "none", "")
head = "MUTE" if len(ids) == 1 else f"MUTE x{len(ids)}"
return (head, mute_summary(ids)[:17], "")
def control_label(v2cc: int, ctx: str | None = None, def control_label(v2cc: int, ctx: str | None = None,
mapped: bool = True, width: int = 16) -> str: mapped: bool = True, width: int = 16) -> str:
"""ONE line for the firmware's OWN per-control overlay (target == CC index). """ONE line for the firmware's OWN per-control overlay (target == CC index).
......
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