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. ...@@ -127,10 +127,45 @@ SuperDirt, and that is the only genuinely new component in any of this.
## Order ## Order
1. theme / scale / tray menu (in flight) — the sunlight problem, tonight. 1. ~~theme / scale / tray menu~~ — **done**, the sunlight problem.
2. the DJF three-zone strip off a driver-published state file. 2. ~~the DJF three-zone strip off a driver-published state file~~ — **done**.
3. the full 48-cell state layer, dirty-cell repaint. 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. 4. `eval-events.jsonl` as an orbit-activity layer.
5. only if still wanted: a SuperDirt OSC feed for beat-level activity. 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 ...@@ -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 publishes `surface-state.json`, and midiviz draws the three family filters as
zones with the detent at 64. zones with the detent at 64.
- **Step 3 — the full 48-cell layer.** The publisher already sends all 32 - ~~**Step 3 — the full 48-cell layer.**~~ **Shipped 2026-09-22**, and the
analogue values; `_paint_state` filters to `family_filter` with a one-line dirty-cell repaint it was waiting on turned out to be unnecessary — see
guard. Widening it needs dirty-cell repaint so the layer stays free at rest: `docs/2026-09-22-midiviz-state-wire.md`. The rule that made it cheap is about
`seq` says whether anything moved, the value diff says which cells. when not to draw, not about how to draw less.
- **Step 4 — orbit activity from `eval-events.jsonl`.** Already being written, - **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 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 viewer which orbits are live in the loaded track, which answers the real stage
......
...@@ -230,18 +230,23 @@ def _offscreen_widget(mv_mod, state_path, width=1000, height=620): ...@@ -230,18 +230,23 @@ def _offscreen_widget(mv_mod, state_path, width=1000, height=620):
return app, w 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 """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.""" same way _paint_state derives them, so a geometry change breaks both."""
chi = max(2, round(w.ch) - w.inset) chi = max(2, round(w.ch) - w.inset)
ebar = max(2, round(chi * w.theme.bar_frac)) ebar = max(2, round(chi * w.theme.bar_frac))
sh = max(3, round(3 * w.dens)) sh = max(3, round(3 * w.dens))
rows = {row: r for r, row in enumerate(grid.PHYSICAL_ORDER)} rows = {r: i for i, r in enumerate(grid.PHYSICAL_ORDER)}
y = round(w.my + rows["C"] * w.ch) + chi - ebar - 2 - sh y = round(w.my + rows[row] * w.ch) + chi - ebar - 2 - sh
x = round(w.mx + (col - 1) * w.cw) + 1 x = round(w.mx + (col - 1) * w.cw) + 1
return x, y + sh // 2, max(4, max(2, round(w.cw) - w.inset) - 2) 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): def test_the_strip_actually_draws_and_says_low_zero_high(tmp_path):
"""Three values, three different pictures. The assertion is on PIXELS. """Three values, three different pictures. The assertion is on PIXELS.
...@@ -300,3 +305,164 @@ def test_the_zero_zone_is_one_named_tunable(): ...@@ -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.""" """It is a guess about a clamping integrator, so it must be findable."""
assert isinstance(MV.ZERO_ZONE, int) assert isinstance(MV.ZERO_ZONE, int)
assert 0 <= MV.ZERO_ZONE <= 8, "a wide zero zone would hide a real nudge" 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