Commit 30a704ec by PLN (Algolia)

feat(bridge): midiviz — the MIDI stream as a picture, not a transcript

PLN, 2026-08-29: "midiwatch still naively verbose [`14:0 Control change ch0,
controller 29, value 15`] improve it massively. no more console, tiny borderless
window that exits on q/exit/ctrl+c/alt+f4, that renders the stream in a nicer,
cyberpunk matrix dense cryptic purple-on-dark, style. no word like 'control
change' pure viz"

The complaint is not about verbosity, it is about REGISTER. One line per event is
the right shape for a debugger and the wrong shape for a performer: a fader sweep
emits ~400 events a second, so midimon's prose scrolls past faster than an eye
can land on it, and the one question you actually have mid-set — "which orbit did
I just touch" — is the one thing a sentence makes you decode. So this is a lens,
not a log.

The picture
-----------
Zero English words render. An event's identity is its POSITION, its type is a
GLYPH CLASS, its value is a BAR plus two hex digits, and its recency is
BRIGHTNESS. The layout is the authored surface itself — lcxl_grid's rows A..F in
PHYSICAL_ORDER down, columns 1..8 across, column N *is* orbit N — so the answer
is spatial and needs no decoding. A per-orbit charge glow behind each column says
"this one is live"; the column digit brightens with it. Buttons render as latches
(filled above half, hollow below) because rows E/F are gates, not levels — a bar
would have lied about what they do. Channel shifts the hue of the digits, not of
the cell, so the cell keeps meaning "which control" while the tint carries "from
where". Only what the grid CANNOT place — foreign CCs, notes, bend, program —
falls as rain in the right-hand gutter, which makes "that came from somewhere
else" a visible fact rather than a thing you read. A ribbon along the bottom
scrolls the raw order of arrival, brightest at the right.

Every hue stays inside the violet→magenta arc, so eight roles stay
distinguishable while the window still reads as one colour.

Choices worth keeping
---------------------
* **Mapped-but-idle must not be black.** All 48 grid cells carry a floor tint
  even when nothing has ever arrived on them. "Dark = not mapped" is the reading
  a cockpit trains you into, and a dark cell that IS mapped reads as a hardware
  fault — the same trap the LED work settled.
* **Toolkit: PySide6 (Qt6), already installed (6.11.2).** PyQt6 and pygame are
  not present; GTK4 + pycairo are, but Qt gave frameless + always-on-top hints +
  startSystemMove (the only way a Wayland client may reposition itself) without
  hand-rolling a drag.
* **Two frame rates, not a busy loop.** This machine performs live audio. ~25 fps
  while events arrive, dropping to 5 fps two seconds after the last one, where
  the only motion is a slow dim pulse. Colours are a precomputed LUT (8 families
  x 16 decay levels), paint uses the int overloads, the CRT scanline texture is
  one cached pixmap blitted once, and the ribbon is bounded to exactly what fits
  so a frame copies nothing.
* **Profiled rather than guessed.** The first version cost 30.6% of a core under
  a sustained 400 ev/s sweep. Stage-by-stage timing put the cost in the sheer
  NUMBER of drawText calls, not in pixels: gutter rain alone was 3.1 ms of an
  8.9 ms frame (a 96-drop cap with a trail glyph each = up to 192 calls). Capping
  rain at 32 heads with a trail only on the brightest, batching the ribbon into
  same-colour RUNS, and 33→40 ms took the frame to 5.5 ms and the sweep to 17.7%.
  Idle is 1.4%. The ribbon batches only hex glyphs, because those are guaranteed
  to come from the monospaced face at exactly one advance — a class glyph (◆ ≈ ≡)
  may be served by a fallback face and would drift the ribbon off its grid.

Port choice: delegated, not re-derived
--------------------------------------
`midimon.resolve_port()` owns "which port", and midiviz delegates to it. Two
things about it are load-bearing and were nearly re-derived wrong here: the
preference order is authored, and it matches the whole `aseqdump -l` ROW rather
than the CLIENT column — python-rtmidi registers the lcxl3 driver's client as
'RtMidiOut Client' and puts 'ParVagues LCXL3' on the PORT. A client-name match
(which is what surface.resolve_port does, correctly, for its own purpose) falls
straight through to the raw hardware: the monitor would work and show the wrong
stream. Confirmed live on this rig, which has both up — the driver is 133:0 under
'RtMidiOut Client'. The local fallback copy exists only for trees whose midimon
predates the helper, and a test asserts it cannot drift from the original.

Launcher
--------
The `midimon` KEY is kept, so the Bridge hub's and the tray's existing button open
the new thing with no change on either face; midimon.py is untouched and remains
the terminal fallback. `terminal: True` is dropped — midiviz draws its own
window, and wrapping a GUI in konsole would have parked an empty black terminal
behind it for the length of the set. That also re-enables the already-running
guard, so the button is now idempotent instead of spawning a window per click.

Verified
--------
* `ast.parse` clean; full bridge suite 90 passed (was 78 + 12 new).
* `midiviz.py --selftest`: 3 s of synthetic events — every one of the 48 authored
  grid cells, four channels, un-gridded CCs, notes, bend, program, aftertouch,
  sysex — pushed through the REAL parse_line + enrich + ingest + paint path, 140
  paints, 68 grabs, 139 distinct sampled colours. It grabs the widget each tick,
  so the drawing code is exercised for real and offscreen, not merely constructed.
* Exit paths run, not assumed: SIGINT and SIGTERM each exit 0 from a live process
  (q/Esc/close share that path).
* Plumbing, not just pure functions: a real Reader subscribed to the resolved
  driver port with no error, and a second one pinned to Midi Through received 8
  genuine events played in with aplaymidi — aseqdump → parse_line → enrich →
  drain, end to end against real ALSA.
* Rendered and looked at, at 420x260 and at 2x, before and after the
  optimizations: identical output, which is the point of the run-length batching.

Found on the way: data-less realtime messages (Start/Stop/Continue/Clock) print
with no data column, and midimon's row regex requires one, so they never parse —
midimon's console silently omits transport too, and midiviz has four glyphs that
cannot light up. Left in midimon (one parser per concept) and pinned by a test
that says what to delete when it is fixed.
parent 63f53205
......@@ -27,7 +27,8 @@ TIDAL = Path.home() / "Work" / "Sound" / "Tidal"
# same archive as if it were live already produced a confidently wrong fader report
# on 2026-07-28; this is the same mixup, one layer down.
ARDOUR_SESSION = Path.home() / "Work" / "Sound" / "Ardour" / "Tidal Live" / "Tidal Live.ardour"
MIDIMON = HERE / "midimon.py"
MIDIMON = HERE / "midimon.py" # the terminal monitor, kept as the fallback
MIDIVIZ = HERE / "midiviz.py" # the frameless viz — what the button opens
# Terminal emulators we know how to wrap, best first.
TERMINALS = ("konsole", "kitty", "alacritty", "gnome-terminal", "x-terminal-emulator")
......@@ -69,8 +70,13 @@ LAUNCHERS = [
"exe": r"[Aa]rdour[-\d.]*$"},
{"key": "qjackctl", "name": "QjackCtl", "blurb": "JACK / audio graph control",
"candidates": ["qjackctl"], "args": [], "exe": r"qjackctl$"},
{"key": "midimon", "name": "MIDI Monitor", "blurb": "live, parsed MIDI seq (aseqdump done right)",
"candidates": ["python3"], "args": [str(MIDIMON)], "pgrep": "midimon.py", "terminal": True},
# Key stays "midimon" so the Bridge hub's and the tray's existing button open
# the new thing with no change on either face. `terminal` is gone: midiviz is
# its own frameless window, and wrapping a GUI in konsole would have left an
# empty black terminal parked behind it for the length of the set.
# midimon.py is untouched and remains the fallback (`python3 midimon.py`).
{"key": "midimon", "name": "MIDI Monitor", "blurb": "live MIDI as glyph-rain — the surface, watched",
"candidates": ["python3"], "args": [str(MIDIVIZ)], "pgrep": "midiviz.py"},
]
# Web tools = start-or-open. `port` open ⇒ running; else `cmd` is spawned, then
......
......@@ -83,14 +83,28 @@ def test_a_session_filename_ending_in_dot_ardour_is_not_a_running_ardour():
def test_an_interpreted_tool_still_matches_on_the_full_line():
"""midimon runs as `python3 .../midimon.py`, so argv[0] is python3 and the real
identity is the script path. That is the one case full-line matching is for."""
"""The monitor runs as `python3 .../midiviz.py`, so argv[0] is python3 and the
real identity is the script path. That is the one case full-line matching is
for. Asserted against the spec's OWN script rather than a hardcoded name, so
swapping which script the entry launches cannot silently void the test."""
spec = L.BY_KEY["midimon"]
assert spec.get("exe") is None and spec.get("pgrep")
assert L.is_running(spec, [("python3", "python3 /x/tools/bridge/midimon.py")]) is True
script = spec["args"][0]
assert L.is_running(spec, [("python3", "python3 %s" % script)]) is True
assert L.is_running(spec, [("python3", "python3 /x/other.py")]) is False
def test_the_monitor_button_opens_the_viz_windowed_not_a_terminal():
"""midiviz draws its own frameless window; wrapping it in a terminal emulator
would park an empty konsole behind it for the length of the set. Dropping
`terminal` also re-enables the already-running guard, so the Bridge button is
idempotent instead of spawning a second window per click."""
spec = L.BY_KEY["midimon"]
assert spec.get("terminal") is None
assert spec["args"][0].endswith("midiviz.py")
assert Path(spec["args"][0]).exists()
def test_is_running_is_false_for_a_spec_with_no_pattern():
assert L.is_running({"key": "x"}, [("anything", "anything")]) is False
......
"""Tests for midiviz's non-drawing parts (no Qt, no ALSA needed).
The drawing itself is verified by `python3 midiviz.py --selftest`, which renders
synthetic events through the real paint path; green tests on pure functions prove
nothing about a window. What is worth pinning here is the stuff that would fail
*silently*: the port-preference copy drifting from midimon's authored one, and a
word sneaking into the glyph set.
"""
import subprocess
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
import midimon # noqa: E402
import midiviz as V # noqa: E402
# ── the copy must never disagree with the original ─────────────────────────
def test_watch_preference_does_not_drift_from_midimon():
"""midiviz keeps a fallback copy of the preference order for trees whose
midimon predates `resolve_port`. Two lists that are supposed to be the same
list are exactly the kind of thing that quietly stops being the same."""
authored = getattr(midimon, "WATCH_PREFERENCE", None)
if authored is None:
return # this tree's midimon has none; nothing to drift from
assert tuple(V.WATCH_PREFERENCE) == tuple(authored)
def test_resolve_delegates_to_midimon_when_it_has_the_helper(monkeypatch):
monkeypatch.setattr(midimon, "resolve_port", lambda: ("99:9", "sentinel"),
raising=False)
assert V.resolve_watch_port() == ("99:9", "sentinel")
# ── the fallback resolver ──────────────────────────────────────────────────
# Real `aseqdump -l` output from this rig, with the LCXL3 and the driver both up.
_PORTS = """ Port Client name Port name
0:0 System Timer
14:0 Midi Through Midi Through Port-0
20:0 LCXL3 1 LCXL3 1 MIDI In
20:1 LCXL3 1 LCXL3 1 DAW In
133:0 RtMidiOut Client ParVagues LCXL3
"""
def _fallback(monkeypatch, text):
monkeypatch.delattr(midimon, "resolve_port", raising=False)
monkeypatch.setattr(V.subprocess, "run",
lambda *a, **k: subprocess.CompletedProcess(a, 0, text, ""))
return V.resolve_watch_port()
def test_the_driver_is_found_by_its_PORT_name_not_its_client(monkeypatch):
"""The whole point. python-rtmidi registers the lcxl3 driver's client as
'RtMidiOut Client' and puts 'ParVagues LCXL3' on the PORT, so matching only
the client column silently falls through to the raw hardware — the monitor
would work, and show the wrong stream."""
pid, label = _fallback(monkeypatch, _PORTS)
assert pid == "133:0"
assert "ParVagues LCXL3" in label
def test_it_falls_back_down_the_preference_order(monkeypatch):
without_driver = "\n".join(l for l in _PORTS.splitlines() if "ParVagues" not in l)
assert _fallback(monkeypatch, without_driver)[0] == "20:1" # raw v3 DAW port
only_thru = "\n".join(l for l in _PORTS.splitlines() if "LCXL3" not in l)
assert _fallback(monkeypatch, only_thru)[0] == "14:0" # the catch-all
assert _fallback(monkeypatch, " Port Client name Port name\n")[0] is None
def test_a_header_only_listing_is_not_mistaken_for_a_port(monkeypatch):
pid, label = _fallback(monkeypatch, "")
assert pid is None and "no MIDI source" in label
# ── the viz is wordless, and every value lands on one scale ────────────────
def test_no_glyph_is_a_word():
"""PLN: 'no word like control change pure viz'. Every rendered glyph is one
character, and none of them is a letter — hex digits are the only alnum."""
glyphs = [g for _kw, g in V.CLASS_GLYPH] + [V.OTHER_GLYPH] + list(V.HEX)
for g in glyphs:
assert len(g) == 1, g
assert not g.isalpha() or g in V.HEX, g
def test_note_on_and_off_are_different_glyph_classes():
on, on_fam = V._glyph_for("Note on")
off, off_fam = V._glyph_for("Note off")
assert on != off and on_fam == off_fam == V.FAM_NOTE
assert V._glyph_for("Some future event")[0] == V.OTHER_GLYPH
def test_every_event_kind_normalises_onto_0_127():
assert V._norm_value({"value": 0}) == 0
assert V._norm_value({"value": 127}) == 127
assert V._norm_value({"velocity": 100}) == 100
assert V._norm_value({"program": 7}) == 7
assert V._norm_value({}) == 64 # no value = mid, not 0
# pitch bend is ±8192, and centre must land mid-scale rather than clamp to 0
assert V._norm_value({"value": 0, "event": "Pitch bend"}) == 0
assert 61 <= V._norm_value({"value": -1}) <= 65
assert V._norm_value({"value": -8192}) == 0
assert V._norm_value({"value": 8191}) == 127
def test_dataless_transport_messages_do_not_parse_at_all():
"""A real gap, pinned here rather than papered over.
`midimon.parse_line`'s row regex is `addr EVENT <2+ spaces> DATA`, so an
aseqdump line with no data column — which is exactly how Start, Stop,
Continue and Clock print — matches nothing and is dropped. midiviz therefore
has glyphs (▶ ■ ▷ ·) that can never light up, and midimon's own console
silently omits transport too. Fixing it belongs in midimon (one parser per
concept); when that lands, this test is what tells you to delete the xfail.
"""
assert midimon.parse_line(" 28:0 Start") is None
assert midimon.parse_line(" 28:0 Stop") is None
# ...while the same event WITH a data column parses fine:
assert midimon.parse_line(" 28:0 Note on 1, note 60, velocity 99") is not None
def test_the_real_parser_feeds_the_real_ingest_path():
"""The selftest's synthetic lines must actually parse — if the fixture drifted
out of aseqdump's shape the selftest would render an empty window and pass."""
lines = V._synthetic_lines()
parsed = [midimon.parse_line(l) for l in lines]
unparsed = [l.split()[1] for l, p in zip(lines, parsed) if p is None]
# the ONLY tolerated failures are the data-less transport lines above
assert set(unparsed) <= {"Start", "Stop"}, unparsed
enriched = [midimon.enrich(p) for p in parsed if p is not None]
ccs = [e["controller"] for e in enriched if "controller" in e]
# every one of the 48 authored grid cells is covered
assert set(V.grid.CC_TO_CELL) <= set(ccs)
assert any(cc not in V.grid.CC_TO_CELL for cc in ccs), "no un-gridded CC to rain"
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