Commit ce663177 by PLN (Algolia)

lcxl3: a latch is carried across a track change, not cleared

PLN, from the stage: "moving from track X to Y, i had to press states like
^42 twice to 'apply their state'? was weird". ^42 is row E, column 2 — a
driver-owned latch.

carry_values deliberately emits no CC for buttons, because a 0 into a midiOn
gate is someCyclesBy 0 and the stream stops playing. It also cleared the
driver's latch, on the reasoning that LEDs should stop lying across a change.
Those two halves contradict: nothing is sent, so Tidal's bus still holds 127,
while the driver now believes 0. The next press computes 'not False' = ON and
re-sends 127 into a bus already at 127 — inaudible — and the press after it
closes the gate he was trying to open.

Not a hypothesis. The lcxl3-driver journal logged 37 of these across 14 of
the 2026-09-24 set's 15 track changes, and the service runs without
--no-latch, with Midi Through cut, so the doubled-CC path is not involved.

The fix is a deletion: the latch IS what Tidal sees, so it survives exactly
as the CC value does. Row F stays momentary — its release travels the generic
path and really does send 0. A carried latch now paints lit under the new
track's label, which is true, and doubles as the ghost-gate indicator this
surface never had.

The test's own assertions are checked against the pre-fix code: both fail
there.
parent be43aff1
# Post-gig, 2026-09-24 → the belt and braces
PLN, 2026-09-25: *"lets ensure rec always via our tooling for validating ardour
state … we should have belt sus and logs validation before a gig and a better
'tableau de bord' to confirm all is armed"* and *"too bad these TODOs were not
done 20 days ago. second best time is now"*.
Each line pays off a `TODO_GIG` "NOT TONIGHT" entry or a loss measured in
`armada/tide-table/take103_2026-09-24_recovered_set.md`.
- [ ] **1. Latch double-press.** `lap_reset` clears the driver's latch and sends
no CC, so Tidal's bus keeps 127 while the driver believes 0 — the next
press re-sends 127 into a bus already at 127 and reads as dead. 37 of
these across 14 of 15 track changes on the 24th. Fix is a deletion: carry
the latch. (`TODO_GIG`: "Latch double-press on the mute path")
- [ ] **2. Nothing checks record-arm.** `Tidal 08` sat at `rec-enable=0` all
night; twelve tools audit faders, mutes, routing, ghosts, preload — none
audit arm. Extend `check-mix.py`, add a `check-gig.py` probe.
- [ ] **3. gig-log loses its MIDI address.** Bound seq port `133:0` at 00:59,
never rebound; zero control moves logged for a 94-minute set. Probe now,
rebind as its own change. (`TODO_GIG`: "gig-log loses its MIDI address and
never looks for it again")
- [ ] **4. `gig_record.sh` is asserted by nothing.** No unit, no entry in
`rig_units.py`, never started on this machine — the black-box net that
would have saved the missing 17m44s AND orbit 08. Caveat to settle first:
it records `$(pactl get-default-sink).monitor`, which on the 24th was the
laptop card while Master was hand-linked to the UMC.
- [ ] **5. Tableau de bord.** The Bridge should render `check-gig.py --json`,
not grow a bespoke "armed" tile. One source of truth.
- [ ] **6. Ardour's OSC arm lens is broken** — `arm:false roll:false` for 76
recorded minutes, `conflict:true` on every record. Find out whether the
OSC surface is even enabled before anything reads it. A REC reminder must
key on the FILES lens only.
- [ ] **7. Propose, PLN's call:** a `Mix` track inside Ardour (input =
`Master/audio_out 1+2`, armed) so the mix lands in the same take at stem
grade. Solves "one orbit unarmed", not "forgot REC".
...@@ -1126,10 +1126,32 @@ class Driver: ...@@ -1126,10 +1126,32 @@ class Driver:
which is the precise opposite of the move it exists to serve. Both 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 old and the new track's tempo cell are protected, because during
the change either one may be the knob under his thumb. the change either one may be the knob under his thumb.
* every BUTTON. Emitting to a gate is the mutebomb; the latch state is * every BUTTON. Emitting to a gate is the mutebomb: `midiOn` on a 0
reset in the DRIVER so the LEDs stop lying and the next press means is `someCyclesBy 0`, i.e. the stream stops playing. So nothing is
ON, but nothing is sent. A gate that was open stays open until he sent, and a gate that was open stays open until he closes it, which
closes it, which is what a performer expects of a held gesture. is what a performer expects of a held gesture.
A LATCH IS CARRIED, NOT CLEARED (fixed 2026-09-25, measured 2026-09-24)
This used to also do `self.latch.clear()`, on the reasoning that the
LEDs should stop lying across a track change. It had the opposite
effect, and the gig proved it. The two halves contradict:
* we deliberately send NO CC, so Tidal's control bus still holds 127.
* clearing the latch makes the driver believe 0.
So the next press computes `not False` = ON and re-sends 127 into a bus
already at 127 — inaudible. From the stage that reads as "the button
does nothing", and the press AFTER it closes the gate he was trying to
open. PLN, 2026-09-25: *"moving from track X to Y, i had to press states
like ^42 twice to 'apply their state'? was weird"*. The 2026-09-24 set
logged 37 of these across 14 of its 15 track changes.
The latch IS what Tidal sees, so it survives exactly as the CC value
does. Row F stays momentary and is zeroed: its release passes through
the generic path and really does send 0. A carried latch paints lit
under the new track's label, and that is TRUE — the CC is still on the
bus, so a lit button is also the ghost-gate indicator this surface never
had.
""" """
keep = {v2 for v2 in self.values keep = {v2 for v2 in self.values
if self.grid.CC_ROLE.get(v2, ("", 0))[0] == "family_filter"} if self.grid.CC_ROLE.get(v2, ("", 0))[0] == "family_filter"}
...@@ -1147,13 +1169,13 @@ class Driver: ...@@ -1147,13 +1169,13 @@ class Driver:
if v2 in self.mapped: if v2 in self.mapped:
self.emit(v2, 0) self.emit(v2, 0)
reset += 1 reset += 1
latches = sum(1 for on in self.latch.values() if on) held = {v2 for v2, on in self.latch.items() if on}
self.latch.clear() self.latch = {v2: True for v2 in held}
for v2 in self.grid.BUTTON_CCS: for v2 in self.grid.BUTTON_CCS:
if v2 in self.values: if v2 in self.values and v2 not in held:
self.values[v2] = 0 self.values[v2] = 0
print(f" lap reset · {reset} effect knobs -> 0 · {len(keep)} carried " print(f" lap reset · {reset} effect knobs -> 0 · {len(keep)} carried "
f"(DJF + tempo) · {latches} latches cleared (no CC sent)") f"(DJF + tempo) · {len(held)} latches carried (no CC sent)")
def anchor_phase(self, t_eval: float) -> None: def anchor_phase(self, t_eval: float) -> None:
"""Cycle 0 was at `t_eval` — rebuild the beat count from there. """Cycle 0 was at `t_eval` — rebuild the beat count from there.
......
"""A track change must not change what Tidal believes.
THE GIG THAT WROTE THIS TEST (2026-09-24, measured 2026-09-25)
`carry_values` deliberately sends NO CC for buttons, because emitting 0 into a
`midiOn` gate is `someCyclesBy 0` — the stream stops playing, the mutebomb. But
it also used to call `self.latch.clear()`, so the driver came out of a track
change believing every gate was OFF while Tidal's control bus still held 127.
The next press computed `not False` = ON and re-sent 127 into a bus already at
127: inaudible. PLN, from the stage: *"moving from track X to Y, i had to press
states like ^42 twice to 'apply their state'"*. The lcxl3-driver journal logged
37 of these across 14 of the set's 15 track changes.
So the invariant is not about LEDs or about defaults. It is: **a step that emits
nothing must leave the driver's belief unchanged**, or the driver and Tidal
disagree and every subsequent toggle is inverted. `^42` (row E, column 2) is the
exact control PLN named.
Row F is the deliberate exception: those are momentary gestures whose release
travels the generic path and really does send 0, so zeroing their mirror is
truthful.
"""
from __future__ import annotations
import importlib.util
import pathlib
import sys
import pytest
TOOLS = pathlib.Path(__file__).resolve().parent.parent
sys.path.insert(0, str(TOOLS))
import lcxl_grid as grid # noqa: E402
_spec = importlib.util.spec_from_file_location("drv", TOOLS / "lcxl3-driver.py")
drv = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(drv)
LATCH_CC = 42 # row E, column 2 — the one PLN named
MOMENTARY_CC = 73 # row F: a held gesture, not a state
class Lap:
"""The narrowest thing `carry_values` can run against.
Deliberately not a real Driver: constructing one opens MIDI ports. Every
attribute below is one `carry_values` actually reads, so a field added to
that method fails here loudly instead of being silently skipped.
"""
def __init__(self, values, latch):
self.values = dict(values)
self.latch = dict(latch)
self.grid = grid
self.mapped = set(values)
self.tempo = None
self.emitted: list[tuple[int, int]] = []
def emit(self, cc: int, value: int) -> None:
self.emitted.append((cc, value))
def lap(self):
drv.Driver.carry_values(self, None)
return self
def test_a_held_latch_survives_the_track_change():
lap = Lap({LATCH_CC: 127}, {LATCH_CC: True}).lap()
assert lap.latch.get(LATCH_CC) is True, "the gate is still open in Tidal"
assert lap.values[LATCH_CC] == 127, "so the driver must still believe it"
def test_the_next_press_after_a_change_closes_an_open_gate():
"""The whole bug, expressed as the press that follows.
`on = not self.latch.get(v2, False)` is the driver's toggle. With the latch
carried it yields OFF, which is the move PLN was making. With the latch
cleared it yielded ON — a no-op into a bus already at 127.
"""
lap = Lap({LATCH_CC: 127}, {LATCH_CC: True}).lap()
on = not lap.latch.get(LATCH_CC, False)
assert on is False
assert (127 if on else 0) == 0, "one press closes it, not two"
def test_a_released_latch_stays_released():
lap = Lap({LATCH_CC: 0}, {LATCH_CC: False}).lap()
assert lap.latch.get(LATCH_CC, False) is False
assert lap.values[LATCH_CC] == 0
def test_carry_values_emits_nothing_for_buttons():
"""The mutebomb fence: `midiOn` on a 0 is `someCyclesBy 0`, i.e. silence."""
lap = Lap({LATCH_CC: 127, MOMENTARY_CC: 127},
{LATCH_CC: True}).lap()
assert [cc for cc, _ in lap.emitted if cc in grid.BUTTON_CCS] == []
def test_row_f_momentaries_go_home():
"""A gesture is not a state: its release sends 0 on the generic path."""
lap = Lap({MOMENTARY_CC: 127}, {}).lap()
assert lap.values[MOMENTARY_CC] == 0
def test_the_named_control_is_actually_a_latch():
"""If ^42 ever stops being row E, this test is testing the wrong thing."""
assert grid.CC_TO_CELL[LATCH_CC][0] == "E"
assert grid.CC_TO_CELL[MOMENTARY_CC][0] == "F"
assert LATCH_CC in grid.BUTTON_CCS and MOMENTARY_CC in grid.BUTTON_CCS
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