Commit 64c29a05 by PLN (Algolia)

midiviz: the whole surface has a resting position, and the rain says what to type

Two things PLN asked for, and one correction to how step 3 was planned.

State covers all 48 cells now, not just the three family filters -- but only
where the event layer cannot speak: a cell whose last event value equals the
surface value is skipped entirely, track included. That keeps one fact to one
picture, and what is left is exactly the gap an edge-triggered stream leaves
behind -- a control untouched since launch, and a control the surface moved
while this window was not listening. The family filters stay exempt, since a
left-anchored bar lies about a centre detent whoever draws it.

E needed nothing: the driver already owns the latch, because v3 DAW buttons are
momentary, so it publishes 127 until the next press. This just draws it, which
is why it cannot drift out of step with the board.

The dirty-cell repaint the design doc was waiting on was the wrong screw. The
widget repaints whole frames and its budget is made of drawText calls, not
fillRects; the layer needed a rule about when NOT to draw, not a cheaper way to
draw.

And the gutter rains corpus tokens now -- ^42, the exact string a .tidal file
types -- for controls the grid owns, where before only un-gridded CCs rained at
all. Rate-gated per control, because the relative rows send a message per
encoder click and an ungated sweep fills all 32 heads with one repeated word.
The lanes are computed from the text width, so a three-glyph token in a
five-glyph gutter cannot be drawn off the edge.
parent 89d80cc7
......@@ -127,10 +127,45 @@ SuperDirt, and that is the only genuinely new component in any of this.
## Order
1. theme / scale / tray menu (in flight) — the sunlight problem, tonight.
2. the DJF three-zone strip off a driver-published state file.
3. the full 48-cell state layer, dirty-cell repaint.
1. ~~theme / scale / tray menu~~ — **done**, the sunlight problem.
2. ~~the DJF three-zone strip off a driver-published state file~~ — **done**.
3. ~~the full 48-cell state layer~~ — **done 2026-09-22**, and with the opposite
mechanism to the one planned here. "Dirty-cell repaint" was the wrong screw:
this widget repaints whole frames, so there is no partial path to feed, and
the cost of the layer is ~48 `fillRect` calls rather than the `drawText`
calls the frame budget is actually made of. What the layer needed was not
cheaper drawing but a rule about WHEN NOT TO DRAW — the strip is skipped in
any cell whose last event value equals the surface value, so the event layer
keeps every cell it can speak for and state fills only the gap (a control
untouched since launch, or one the surface moved while this window was not
listening). The family filters stay exempt: a left-anchored bar lies about a
centre detent whoever draws it.
4. `eval-events.jsonl` as an orbit-activity layer.
5. only if still wanted: a SuperDirt OSC feed for beat-level activity.
Steps 2–5 are after Thursday. Nothing above changes the driver's audio path.
Steps 4–5 are after Thursday. Nothing above changes the driver's audio path.
## What step 3 also settled, unplanned
PLN asked for row D and the E latches specifically ("D doesnt show the faders
mapped tot he cells … E should be sticky to track current real E button state").
**Neither was a drawing bug.** `lcxl3-driver.service` was down, so midiviz had
fallen through `WATCH_PREFERENCE` to the raw `LCXL3 1 DAW` port, where the
surface speaks v3 numbering — and that stream lands like this:
row moved -> cell midiviz lights
A -> A (identical, by coincidence)
B -> rain (v3 sends 21-28: in no row)
C -> B (v3 sends 29-36 = row B's v2 numbers)
D -> rain (v3 sends 5-12)
E1-E4 -> rain, E5-E8 -> E1-E4
F1-F4 -> rain, F5-F8 -> C1-C4
which is exactly "it only moves on abc, D doesn't show the faders, pressing EF
doesn't light EF, and the matrix rain only happens on the faders" — the rain
being the tell, because only CCs the grid does NOT own ever spawned a drop.
The lesson for this layer: **a viewer reading the wrong port draws a coherent,
confident, wrong picture**, and the only thing on screen that said so was the
port label in the header. A pip for "this is not the translated stream" belongs
in the chrome next to the paused and stream-broken pips.
......@@ -115,10 +115,10 @@ Steps 3–5 of `docs/2026-09-22-midiviz-state-wire.md`. Step 2 shipped: the driv
publishes `surface-state.json`, and midiviz draws the three family filters as
zones with the detent at 64.
- **Step 3 — the full 48-cell layer.** The publisher already sends all 32
analogue values; `_paint_state` filters to `family_filter` with a one-line
guard. Widening it needs dirty-cell repaint so the layer stays free at rest:
`seq` says whether anything moved, the value diff says which cells.
- ~~**Step 3 — the full 48-cell layer.**~~ **Shipped 2026-09-22**, and the
dirty-cell repaint it was waiting on turned out to be unnecessary — see
`docs/2026-09-22-midiviz-state-wire.md`. The rule that made it cheap is about
when not to draw, not about how to draw less.
- **Step 4 — orbit activity from `eval-events.jsonl`.** Already being written,
one object per ctrl+enter, and the driver already reads it. Tailing it tells a
viewer which orbits are live in the loaded track, which answers the real stage
......
......@@ -436,6 +436,19 @@ FPS_SPECTRO = 54 # ms; ~18 fps, the spectrum's own rate
# 96-drop cap with a trail glyph each meant up to 192 drawText per frame, 3.1 ms
# of an 8.9 ms frame. 32 heads with a trail only on the brightest few costs ~0.6.
MAX_DROPS = 32
# One drop per control per DROP_EVERY seconds.
#
# PLN, 2026-09-22: "can the matrix show even the ^42 e.g. as we move it would be
# rad". So a move on a control the grid OWNS now rains that control's corpus
# token -- literally the string a `.tidal` file types, the same one the header
# prints -- where before only CCs outside the grid rained at all.
#
# Rate-gated per CC, and that is not decoration-tidiness: rows B and C are
# relative, so one turn of an encoder is a stream of messages, and ungated a
# single sweep fills all 32 heads with the same token inside half a second. The
# column then reads as a solid block of one word instead of rain, which is the
# opposite of what was asked for.
DROP_EVERY = 0.2
BASE_W, BASE_H = 420, 260
# ── the size belongs to the window manager, the density to the window ──────
......@@ -1111,6 +1124,7 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0,
self.col_t = [0.0] * 9 # column 1..8 last-touch, for the glow
self.stream: deque = deque(maxlen=256) # bottom ribbon, newest right
self.drops: list[list] = [] # gutter rain
self._drop_t: dict[int, float] = {} # cc -> last token rained
self.hist: deque = deque([0] * 64, maxlen=64) # events per 100 ms
self._bucket_t = self.t0
self._bucket_n = 0
......@@ -1478,7 +1492,15 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0,
# exact token the corpus uses, so it can be typed straight
# into a pattern.
self.last_cc = (cc, v, chn, now)
self.stream.append((HEX[v >> 3], self._fam_for_cc(cc), now))
fam = self._fam_for_cc(cc)
self.stream.append((HEX[v >> 3], fam, now))
# The token, in the gutter, in this control's family colour.
# The cell says WHERE and HOW FAR; the ribbon says the shape
# of the traffic; this says WHAT TO TYPE, without leaving the
# window for the CC map.
if now - self._drop_t.get(cc, 0.0) >= DROP_EVERY:
self._drop_t[cc] = now
self._spawn_drop("^%d" % cc, fam)
return
# a CC the authored grid does not own: rain, not a cell.
self.stream.append((HEX[v >> 3], FAM_OTHER, now))
......@@ -1907,9 +1929,31 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0,
("%02X" % v) if two else HEX[v >> 3])
# ── state, not events ──────────────────────────────────────────────
# First slice, deliberately: the three family filters PLN actually
# named. "the DJFs are at zero, in the Low, or in the High, and this
# doesnt really grasp from viewing the midimon".
# The whole surface now, not just the three knobs the first slice drew.
#
# PLN, 2026-09-22: "D doesnt show the faders mapped tot he cells, and
# pressing D E F vbuttons also doesnt lit EF which tbh E should be sticky
# to track current real E button state so people 'get' that E1 is four on
# the floor intuitively". The first cause of that was the driver being
# down (midiviz was reading the untranslated board, where the faders
# carry CCs 5-12 and land in no cell at all). The second is this layer:
# an EVENT-only picture has nothing to say about a control until the
# control moves, so a freshly launched window shows eight dots where the
# faders are, whatever the faders are actually set to.
#
# So every cell the surface publishes is drawn — but ONLY where the event
# layer cannot speak for it, which is the one thing that keeps the two
# from becoming two pictures of the same fact. A cell whose last event
# value equals the surface value is skipped entirely: the event bar has
# it, brighter and with digits. What is left is exactly the gap — a
# control not touched since launch, and a control the surface moved while
# this window was not listening (a driver restart, a `--paint` reseed,
# another client on the port).
#
# E is sticky for free, at the right layer: v3 DAW buttons are momentary,
# so `lcxl3-driver` owns the latch (press flips, release ignored) and
# publishes 127 until the next press. Nothing here has to remember
# anything, which is why it cannot drift out of step with the board.
#
# Drawn in the cells the controls physically occupy (C1/C2/C3) rather
# than in a strip of its own, for two reasons. The layout PLN just
......@@ -1948,38 +1992,64 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0,
# room — checked at 900x560 and 1600x1000 before being called done.
sh = max(3, round(3 * self.dens))
for cc, val in st.values.items():
if grid.CC_ROLE.get(cc, ("", 0))[0] != "family_filter":
continue # slice 1; the rest is step 3
cell = grid.CC_TO_CELL.get(cc)
if not cell or cell[0] not in rows:
continue
# The family filters are drawn ALWAYS: their whole complaint was
# that a left-anchored bar lies about a centre detent, and that
# lie is told by the event layer too. Everything else defers.
detent = grid.CC_ROLE.get(cc, ("", 0))[0] == "family_filter"
ev = self.cc.get(cc)
if not detent and ev is not None and ev[0] == val:
continue
row, col = cell
x = round(mx + (col - 1) * cw) + 1
w = max(4, cwi - 2)
y = round(my + rows[row] * ch) + chi - ebar - 2 - sh
if y <= round(my + rows[row] * ch):
continue # cell too short to say it honestly
# the track the knob travels: without it, a marker at an edge
# the track the control travels: without it, a marker at an edge
# and an absent marker look the same
p.fillRect(x, y, w, sh, self.tint[FAM_OTHER][2])
# THE DETENT, at the centre. Zero is 64, not 0 — gDJF is an lpf
# and an hpf section both wide open at ch=0.5, so the centre is
# bypass and the ends are the extremes. This tick is the whole
# reason the strip exists.
mid = x + w // 2
tick = max(1, round(self.dens))
p.fillRect(mid, y - 1, tick, sh + 2, self.chrome[8])
# where it RESTS, as a bar growing out of the detent toward the
# side it is filtering: direction says LOW or HIGH, no decoding.
pos = x + round((w - 1) * max(0.0, min(1.0, val / 127.0)))
at_zero = abs(val - 64) <= ZERO_ZONE
fam = FAM_LEVEL if at_zero else FAM_FAMILY
if not at_zero:
lo, hi = (mid, pos) if pos >= mid else (pos, mid)
p.fillRect(lo, y, max(1, hi - lo), sh, self.lut[fam][11])
# the resting position itself, brightest: the eye lands here
p.fillRect(min(pos, x + w - tick), y - 1, tick, sh + 2,
self.lut[fam][15])
if detent:
# THE DETENT, at the centre. Zero is 64, not 0 — gDJF is an
# lpf and an hpf section both wide open at ch=0.5, so the
# centre is bypass and the ends are the extremes. This tick
# is the whole reason the strip exists.
mid = x + w // 2
p.fillRect(mid, y - 1, tick, sh + 2, self.chrome[8])
# where it RESTS, as a bar growing out of the detent toward
# the side it is filtering: direction says LOW or HIGH, no
# decoding.
pos = x + round((w - 1) * max(0.0, min(1.0, val / 127.0)))
at_zero = abs(val - 64) <= ZERO_ZONE
fam = FAM_LEVEL if at_zero else FAM_FAMILY
if not at_zero:
lo, hi = (mid, pos) if pos >= mid else (pos, mid)
p.fillRect(lo, y, max(1, hi - lo), sh, self.lut[fam][11])
# the resting position itself, brightest: the eye lands here
p.fillRect(min(pos, x + w - tick), y - 1, tick, sh + 2,
self.lut[fam][15])
elif cc in grid.BUTTON_CCS:
# A latch is not a position, so it gets no marker travelling
# a track: it is the whole strip, lit or unlit. An OFF latch
# therefore looks exactly like a latch nobody has pressed —
# which is correct, because on this surface they ARE the same
# state, and inventing a distinction would be the picture
# lying to look informative.
if val >= 64:
p.fillRect(x, y, w, sh, self.lut[self._fam_for_cc(cc)][13])
else:
# Knobs and faders: left-anchored, 0 at the left, the same
# geometry the event bar uses — deliberately, so a cell that
# hands over from state to events does not appear to jump.
fam = self._fam_for_cc(cc)
pos = x + round((w - 1) * max(0.0, min(1.0, val / 127.0)))
if pos > x:
p.fillRect(x, y, pos - x, sh, self.lut[fam][10])
p.fillRect(min(pos, x + w - tick), y - 1, tick, sh + 2,
self.lut[fam][15])
def _paint_gutter(self, p, now, pulse):
gx, gw = self.gut_x, self.gut_w
......@@ -1989,7 +2059,6 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0,
if not self.drops:
return
p.setFont(self.f_micro)
sub_w = gw / 3.0
limit = self.my + self.mh_px
for sub, ypos, born, g, fam in self.drops:
lv = self._lv(now - born)
......@@ -1998,7 +2067,13 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0,
y = round(self.my + ypos * self.uh + self.u_asc)
if y > limit:
continue
x = round(gx + 2 + sub * sub_w)
# Three lanes across whatever room the TEXT leaves, rather than
# three fixed thirds of the gutter. A single glyph jitters as
# widely as it always did; `^42` is three glyphs in a gutter five
# wide, so its lanes collapse to almost nothing and it can never
# be drawn off the right edge. One formula, no branch on length.
span = max(0.0, gw - 4 - self.uw * len(g))
x = round(gx + 2 + sub * span / 2.0)
p.setPen(self.lut[fam][lv])
p.drawText(x, y, g)
if lv > 11: # one trailing glyph sells the fall
......@@ -2065,6 +2140,7 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0,
self.cc.clear()
self.stream.clear()
self.drops.clear()
self._drop_t.clear()
self.col_t = [0.0] * 9
self.hist = deque([0] * 64, maxlen=64)
self.update()
......
......@@ -230,18 +230,23 @@ def _offscreen_widget(mv_mod, state_path, width=1000, height=620):
return app, w
def _strip_row(w, col):
def _strip_row(w, col, row="C"):
"""The y of the state strip, and the x span of `col`'s cell — derived the
same way _paint_state derives them, so a geometry change breaks both."""
chi = max(2, round(w.ch) - w.inset)
ebar = max(2, round(chi * w.theme.bar_frac))
sh = max(3, round(3 * w.dens))
rows = {row: r for r, row in enumerate(grid.PHYSICAL_ORDER)}
y = round(w.my + rows["C"] * w.ch) + chi - ebar - 2 - sh
rows = {r: i for i, r in enumerate(grid.PHYSICAL_ORDER)}
y = round(w.my + rows[row] * w.ch) + chi - ebar - 2 - sh
x = round(w.mx + (col - 1) * w.cw) + 1
return x, y + sh // 2, max(4, max(2, round(w.cw) - w.inset) - 2)
def _cc_event(cc: int, value: int, ch: int = 0) -> dict:
"""The shape `midimon.parse_line` hands `ingest` for a control change."""
return {"event": "Control change", "ch": ch, "controller": cc, "value": value}
def test_the_strip_actually_draws_and_says_low_zero_high(tmp_path):
"""Three values, three different pictures. The assertion is on PIXELS.
......@@ -300,3 +305,164 @@ def test_the_zero_zone_is_one_named_tunable():
"""It is a guess about a clamping integrator, so it must be findable."""
assert isinstance(MV.ZERO_ZONE, int)
assert 0 <= MV.ZERO_ZONE <= 8, "a wide zero zone would hide a real nudge"
# ── step 3: the layer covers the whole surface ─────────────────────────────
# PLN, 2026-09-22: "D doesnt show the faders mapped tot he cells, and pressing
# D E F vbuttons also doesnt lit EF which tbh E should be sticky". The first
# cause was the driver being down; these are about the layer itself.
def test_a_fader_with_no_event_draws_where_it_is_resting(tmp_path):
"""Row D, never touched since launch, still shows its position.
This is the gap the event layer cannot fill by construction: MIDI is
edge-triggered, so an untouched fader has produced no edge and the cell
would show a dot regardless of where the fader actually sits.
"""
state = tmp_path / "surface-state.json"
fader = grid.ROW_CCS["D"][0] # D1
state.write_text(json.dumps({"seq": 1, "t": 1.0, "track": "T",
"values": {str(fader): 100}}))
mv, w = _offscreen_widget(MV, state)
if mv is None:
pytest.skip("no Qt available")
w._scanlines_blit = lambda _p: None
assert w.state.poll() is True
w.repaint()
img = w.grab().toImage()
x, y, cw = _strip_row(w, 1, row="D")
inside = img.pixelColor(x + int(cw * 0.2), y).name() # 100/127 -> bar
beyond = img.pixelColor(x + int(cw * 0.95), y).name() # past the marker
assert inside != beyond, "a resting fader drew no bar in its own cell"
def test_the_event_layer_wins_whenever_it_agrees(tmp_path):
"""Two pictures of one fact is the failure this guard exists to prevent.
The rule is not "draw state under events", it is "draw state only where the
events cannot speak" — so once an event carries the same value the surface
reports, the strip must vacate the cell completely, track included.
"""
state = tmp_path / "surface-state.json"
fader = grid.ROW_CCS["D"][0]
state.write_text(json.dumps({"seq": 1, "t": 1.0, "track": "T",
"values": {str(fader): 100}}))
mv, w = _offscreen_widget(MV, state)
if mv is None:
pytest.skip("no Qt available")
w._scanlines_blit = lambda _p: None
w.state.poll()
w.repaint()
x, y, cw = _strip_row(w, 1, row="D")
with_state = w.grab().toImage().pixelColor(x + int(cw * 0.2), y).name()
# The reference for "no strip here" has to be a cell the EVENT layer painted
# identically, because _paint_matrix tints the whole cell body by value —
# an untouched neighbour differs in its floor tint and would prove nothing.
# So: the same value, arriving as an event, on a fader the state file does
# not mention at all.
other = grid.ROW_CCS["D"][7] # D8, absent from `values`
w.ingest(_cc_event(fader, 100))
w.ingest(_cc_event(other, 100))
w.repaint()
img = w.grab().toImage()
after = img.pixelColor(x + int(cw * 0.2), y).name()
x2, y2, cw2 = _strip_row(w, 8, row="D")
bare = img.pixelColor(x2 + int(cw2 * 0.2), y2).name()
assert with_state != after, "the strip stayed after the event agreed with it"
assert after == bare, "the strip left ink behind in a cell it had vacated"
def test_a_latched_button_reads_differently_from_an_unlatched_one(tmp_path):
"""E is sticky because the DRIVER owns the latch — this only draws it."""
state = tmp_path / "surface-state.json"
on, off = grid.ROW_CCS["E"][0], grid.ROW_CCS["E"][1]
state.write_text(json.dumps({"seq": 1, "t": 1.0, "track": "T",
"values": {str(on): 127, str(off): 0}}))
mv, w = _offscreen_widget(MV, state)
if mv is None:
pytest.skip("no Qt available")
w._scanlines_blit = lambda _p: None
w.state.poll()
w.repaint()
img = w.grab().toImage()
x1, y1, cw1 = _strip_row(w, 1, row="E")
x2, y2, cw2 = _strip_row(w, 2, row="E")
lit = img.pixelColor(x1 + int(cw1 * 0.5), y1).name()
dark = img.pixelColor(x2 + int(cw2 * 0.5), y2).name()
assert lit != dark, "a latched button looks like an unlatched one"
# ── the rain carries the corpus token ──────────────────────────────────────
def test_a_gridded_move_rains_its_corpus_token(tmp_path):
"""PLN: "can the matrix show even the ^42 e.g. as we move it would be rad".
The token is what a `.tidal` file types, so the test asserts the exact
string rather than "something fell" — a caret and a decimal, never hex.
"""
mv, w = _offscreen_widget(MV, tmp_path / "absent.json")
if mv is None:
pytest.skip("no Qt available")
cc = grid.ROW_CCS["E"][1] # 42, his own example
assert cc == 42, "the grid moved; pick his example again"
w.clear()
w.ingest(_cc_event(cc, 99))
assert [d[3] for d in w.drops] == ["^42"]
def test_the_token_rain_is_rate_gated_per_control(tmp_path):
"""One control sweeping cannot own all 32 heads.
Rows B and C are relative: a single turn is a stream of messages, so without
a per-CC clock the gutter fills with one repeated word and stops reading as
rain at all.
"""
mv, w = _offscreen_widget(MV, tmp_path / "absent.json")
if mv is None:
pytest.skip("no Qt available")
a, b = grid.ROW_CCS["B"][0], grid.ROW_CCS["B"][1]
w.clear()
for v in range(20):
w.ingest(_cc_event(a, v * 5 % 128))
assert len(w.drops) == 1, "the per-control gate let a sweep through"
w.ingest(_cc_event(b, 64))
assert len(w.drops) == 2, "a DIFFERENT control must rain on its own clock"
assert sorted(d[3] for d in w.drops) == sorted(["^%d" % a, "^%d" % b])
def test_clearing_the_window_forgets_the_rain_clock(tmp_path):
"""`c` is "show me nothing until something happens" — including the clock.
Without this, the first move after a clear is swallowed by a gate whose
timestamp belongs to a picture that is no longer on screen.
"""
mv, w = _offscreen_widget(MV, tmp_path / "absent.json")
if mv is None:
pytest.skip("no Qt available")
cc = grid.ROW_CCS["A"][0]
w.ingest(_cc_event(cc, 10))
w.clear()
w.ingest(_cc_event(cc, 20))
assert [d[3] for d in w.drops] == ["^%d" % cc]
def test_a_token_is_never_drawn_past_the_gutter(tmp_path):
"""The lanes are computed from the TEXT, so the widest token must still fit.
A three-glyph token in a five-glyph gutter has almost no room to jitter;
the old fixed-thirds placement would have put lane 2 far enough right to
clip it against the window edge.
"""
mv, w = _offscreen_widget(MV, tmp_path / "absent.json")
if mv is None:
pytest.skip("no Qt available")
widest = "^%d" % max(cc for row in grid.PHYSICAL_ORDER
for cc in grid.ROW_CCS[row])
span = max(0.0, w.gut_w - 4 - w.uw * len(widest))
for sub in (0, 1, 2):
x = round(w.gut_x + 2 + sub * span / 2.0)
assert x + w.uw * len(widest) <= w.gut_x + w.gut_w + 1, (
"lane %d clips %r off the right edge" % (sub, widest))
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