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
......@@ -166,6 +166,108 @@ def touch_lines(v2cc: int, value: int,
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,
mapped: bool = True, width: int = 16) -> str:
"""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