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" ...@@ -27,7 +27,8 @@ TIDAL = Path.home() / "Work" / "Sound" / "Tidal"
# same archive as if it were live already produced a confidently wrong fader report # 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. # on 2026-07-28; this is the same mixup, one layer down.
ARDOUR_SESSION = Path.home() / "Work" / "Sound" / "Ardour" / "Tidal Live" / "Tidal Live.ardour" 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. # Terminal emulators we know how to wrap, best first.
TERMINALS = ("konsole", "kitty", "alacritty", "gnome-terminal", "x-terminal-emulator") TERMINALS = ("konsole", "kitty", "alacritty", "gnome-terminal", "x-terminal-emulator")
...@@ -69,8 +70,13 @@ LAUNCHERS = [ ...@@ -69,8 +70,13 @@ LAUNCHERS = [
"exe": r"[Aa]rdour[-\d.]*$"}, "exe": r"[Aa]rdour[-\d.]*$"},
{"key": "qjackctl", "name": "QjackCtl", "blurb": "JACK / audio graph control", {"key": "qjackctl", "name": "QjackCtl", "blurb": "JACK / audio graph control",
"candidates": ["qjackctl"], "args": [], "exe": r"qjackctl$"}, "candidates": ["qjackctl"], "args": [], "exe": r"qjackctl$"},
{"key": "midimon", "name": "MIDI Monitor", "blurb": "live, parsed MIDI seq (aseqdump done right)", # Key stays "midimon" so the Bridge hub's and the tray's existing button open
"candidates": ["python3"], "args": [str(MIDIMON)], "pgrep": "midimon.py", "terminal": True}, # 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 # Web tools = start-or-open. `port` open ⇒ running; else `cmd` is spawned, then
......
#!/usr/bin/env python3
"""midiviz — the MIDI stream as a picture, not a transcript.
`midimon.py` prints one line per event ("14:0 Control change ch0, controller
29, value 15"). That is the right shape for a debugger and the wrong shape for a
performer: during a set nobody reads sentences, and a fader sweep emits ~400
events a second, so the words scroll past faster than an eye can land on them.
PLN, 2026-08-28: "midiwatch still naively verbose ... no more console, tiny
borderless window ... no word like 'control change' pure viz".
So this is a *lens*, not a log. 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` rows A..F down, columns 1..8 across, and column N *is* orbit N — so a
glance answers "which orbit did I just touch" without decoding anything. Only
what the grid cannot place (foreign CCs, notes, bend, program) falls as rain in
the right-hand gutter, which is itself the signal "that came from somewhere
else".
python3 midiviz.py # auto-resolve the surface, watch it
python3 midiviz.py -p 28:0 # pin a source port
python3 midiviz.py -l # list ports, exit
python3 midiviz.py --selftest # 3 s of synthetic events, headless
Keys: q / Esc quit · p pause · c clear · +/- resize. Drag anywhere to move.
Design notes worth keeping
--------------------------
* **Mapped-but-idle must not be black.** Every one of the 48 grid cells is
painted with a floor tint even when nothing has ever arrived on it, because
"dark = not mapped" is the reading a cockpit trains you into, and a dark cell
that actually *is* mapped reads as a hardware fault (the LED work settled
this).
* **Two frame rates, not a busy loop.** This machine performs live audio. The
timer runs at ~30 fps only while events are arriving; 2 s after the last one it
drops to 5 fps and the picture becomes a slow dim pulse. Colours are a
precomputed LUT and paint uses the int overloads, so a frame allocates
essentially nothing.
* **The reader is a thread over `aseqdump`, reusing `midimon.parse_line` +
`enrich`.** One parsing source of truth for the CLI, the web SSE stream and
this window. rtmidi cannot subscribe to an arbitrary ALSA source port; the
subprocess can.
* Port choice reuses `surface.resolve_port` (lowest port number on a matching
client — the LCXL's second port is the HUI one and carries nothing) tried
against `WATCH_PREFERENCE` in order.
"""
from __future__ import annotations
import argparse
import math
import os
import random
import signal
import subprocess
import sys
import threading
import time
from collections import deque
from pathlib import Path
HERE = Path(__file__).resolve().parent
TOOLS = HERE.parent
for _p in (str(HERE), str(TOOLS)):
if _p not in sys.path:
sys.path.insert(0, _p)
import lcxl_grid as grid # noqa: E402 the authored control grid
import midimon # noqa: E402 parse_line / enrich / resolve_port
import midistream # noqa: E402 parse_ports
# ── which port to watch ────────────────────────────────────────────────────
# `midimon.resolve_port()` OWNS this decision and this file delegates to it. Two
# things about it are load-bearing and were nearly re-derived wrong here:
#
# * the preference ORDER is authored (translated corpus stream first, raw v3
# DAW port second, raw custom mode third, Midi Through last as a catch-all);
# * it matches the whole `aseqdump -l` ROW, not just the CLIENT column —
# python-rtmidi registers the lcxl3 driver's client as 'RtMidiOut Client'
# and puts 'ParVagues LCXL3' on the PORT, so a client-name match (which is
# what `surface.resolve_port` does, correctly, for its own purpose) misses
# exactly the stream we most want to watch.
#
# The fallback below exists only for a tree whose midimon predates that helper;
# it is a faithful copy, so the two can never give DIFFERENT answers to "which
# port", which is the failure mode worth engineering against.
WATCH_PREFERENCE: tuple[str, ...] = (
"ParVagues LCXL3", "LCXL3 1 DAW", "LCXL3", "Launch Control XL", "Midi Through",
)
def list_ports() -> list[dict]:
"""[{addr, client, port}] from `aseqdump -l`, or [] if aseqdump/ALSA is absent."""
try:
r = subprocess.run(["aseqdump", "-l"], capture_output=True, text=True, timeout=5)
except (OSError, subprocess.SubprocessError):
return []
return midistream.parse_ports(r.stdout)
def resolve_watch_port() -> tuple[str | None, str]:
"""(port_id, human label) of the most interesting source present."""
delegate = getattr(midimon, "resolve_port", None)
if callable(delegate):
try:
return delegate()
except TypeError: # a different signature is a different concept
pass
try:
out = subprocess.run(["aseqdump", "-l"], capture_output=True,
text=True, timeout=3).stdout
except (OSError, subprocess.SubprocessError):
return None, "aseqdump unavailable"
rows = []
for line in out.splitlines():
tok = line.split()
if tok and ":" in tok[0] and tok[0].split(":")[0].isdigit():
rows.append((tok[0], line))
for want in WATCH_PREFERENCE:
for pid, line in rows:
if want in line:
return pid, "%s · %s" % (pid, want)
return None, "no MIDI source found"
# ── event classes → glyphs (no words, ever) ────────────────────────────────
# Restricted to glyphs with reliable coverage in DejaVu Sans Mono, the
# guaranteed fallback face on this box.
HEX = "0123456789ABCDEF"
CLASS_GLYPH: tuple[tuple[str, str], ...] = (
("note on", "◆"), # ◆ solid = a note exists
("note off", "◇"), # ◇ hollow = it stopped
("control", "▪"), # ▪ (only reached for un-gridded CCs)
("pitch", "≈"), # ≈
("channel aftertouch", "▴"), # ▴
("poly aftertouch", "▴"),
("aftertouch", "▴"),
("program", "≡"), # ≡
("sysex", "◈"), # ◈
("system exclusive", "◈"),
# NOTE: the four below are currently UNREACHABLE, and that is a midimon bug,
# not a midiviz one. `parse_line`'s row regex is `addr EVENT <2+ spaces> DATA`,
# and aseqdump prints Start/Stop/Continue/Clock with no data column at all, so
# those lines match nothing and are dropped before they ever get here. Kept
# (and pinned by test_dataless_transport_messages_do_not_parse_at_all) so the
# day the shared parser learns them, transport lights up for free.
("clock", "·"), # ·
("start", "▶"), # ▶
("continue", "▷"), # ▷
("stop", "■"), # ■
("song", "·"),
("active", "·"),
("reset", "■"),
)
OTHER_GLYPH = "▫" # ▫
# Colour families. Every hue stays inside the violet→magenta arc PLN asked for;
# only the hue *within* that arc separates roles, so the window reads as one
# colour while eight things stay distinguishable.
FAM_LEVEL, FAM_FX, FAM_FX2, FAM_GATE, FAM_GATE2, FAM_FAMILY, FAM_OTHER, FAM_NOTE = range(8)
FAM_HUE = (275, 305, 258, 288, 320, 334, 246, 292)
ROLE_FAM = {"level": FAM_LEVEL, "fx": FAM_FX, "fx2": FAM_FX2, "gate": FAM_GATE,
"gate2": FAM_GATE2, "family_filter": FAM_FAMILY, "family_mute": FAM_FAMILY}
LEVELS = 16 # decay resolution of the colour LUT
BG = (0x0a, 0x06, 0x12)
TAU = 0.85 # seconds; brightness e-folding time
IDLE_AFTER = 2.0 # seconds of silence before the slow pulse
# 40 ms = 25 fps. Profiling a frame (see MAX_DROPS) put the cost squarely in the
# number of drawText calls, and at this size 25 fps is indistinguishable from 33
# while costing a fifth less of the core PLN needs for audio.
FPS_ACTIVE, FPS_IDLE = 40, 200 # timer intervals, ms
# Rain is decoration, and it was the single most expensive thing on screen: a
# 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
BASE_W, BASE_H = 420, 260
def _glyph_for(event: str) -> tuple[str, int]:
"""Event name → (glyph, colour family). The only place a name is inspected."""
e = (event or "").lower()
for kw, g in CLASS_GLYPH:
if e.startswith(kw):
return g, (FAM_NOTE if kw.startswith("note") else FAM_OTHER)
return OTHER_GLYPH, FAM_OTHER
def _norm_value(ev: dict) -> int:
"""Any event → 0..127, so one bar/brightness scale serves every type."""
for key in ("value", "velocity", "program"):
if key in ev:
v = ev[key]
if key == "value" and (v < 0 or v > 127): # pitch bend, ±8192
return max(0, min(127, int((v + 8192) * 127 / 16383)))
return max(0, min(127, int(v)))
return 64
# ── the reader ─────────────────────────────────────────────────────────────
class Reader:
"""`aseqdump` in a daemon thread; a bounded deque the GUI drains each frame.
Bounded, because a live monitor wants the PRESENT: the same lesson
`midistream.DEPTH` records — a deep buffer that fills stays full, and then
every frame you draw is a fixed lag behind the surface, forever.
"""
DEPTH = 512
def __init__(self, port: str | None):
self.port = port
self.error: str | None = None
self.total = 0
self._q: deque[dict] = deque(maxlen=self.DEPTH)
self._lock = threading.Lock()
self._proc: subprocess.Popen | None = None
self._stop = threading.Event()
def start(self) -> "Reader":
threading.Thread(target=self._run, daemon=True).start()
return self
def _run(self) -> None:
cmd = ["aseqdump"] + (["-p", self.port] if self.port else [])
try:
self._proc = subprocess.Popen(cmd, stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
text=True, bufsize=1)
except OSError as exc:
self.error = type(exc).__name__
return
try:
for line in self._proc.stdout or []:
if self._stop.is_set():
break
ev = midimon.parse_line(line)
if ev is None:
continue
with self._lock:
self._q.append(midimon.enrich(ev))
self.total += 1
except (OSError, ValueError):
self.error = "eof"
def drain(self) -> list[dict]:
with self._lock:
if not self._q:
return []
out = list(self._q)
self._q.clear()
return out
def close(self) -> None:
self._stop.set()
if self._proc:
try:
self._proc.terminate()
except OSError:
pass
self._proc = None
# ── Qt ─────────────────────────────────────────────────────────────────────
def _qt():
from PySide6 import QtCore, QtGui, QtWidgets
return QtCore, QtGui, QtWidgets
_QUIT = threading.Event()
def _install_signals() -> None:
"""Ctrl-C must close the window, but a Python handler only runs between
bytecodes — Qt's event loop would swallow it. The always-running frame timer
polls this flag, which is why there is no separate wakeup pipe."""
def bye(_sig, _frm):
_QUIT.set()
for s in (signal.SIGINT, signal.SIGTERM):
try:
signal.signal(s, bye)
except (OSError, ValueError):
pass
def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0):
QtCore, QtGui, QtWidgets = _qt()
Qt = QtCore.Qt
class MidiViz(QtWidgets.QWidget):
def __init__(self):
super().__init__(None)
self.setWindowTitle("midiviz")
self.setWindowFlags(Qt.WindowType.Window
| Qt.WindowType.FramelessWindowHint
| Qt.WindowType.WindowStaysOnTopHint)
self.setAttribute(Qt.WidgetAttribute.WA_OpaquePaintEvent, True)
self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
self.setCursor(Qt.CursorShape.SizeAllCursor)
self.reader = reader
self.port_label = port_label
self.scale = scale
self.paused = False
self.t0 = time.monotonic()
self.last_event_t = 0.0
self.frames = 0
self.painted = 0
self.ingested = 0
self._interval = FPS_ACTIVE
self._rng = random.Random(0xC0FFEE)
self._drag_from = None
self.cc: dict[int, list] = {} # cc -> [value, t_last, channel]
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.hist: deque = deque([0] * 64, maxlen=64) # events per 100 ms
self._bucket_t = self.t0
self._bucket_n = 0
self._build_palette()
self._apply_scale()
self.timer = QtCore.QTimer(self)
self.timer.setTimerType(Qt.TimerType.CoarseTimer)
self.timer.timeout.connect(self.tick)
self.timer.start(self._interval)
# ── palette / fonts / geometry ─────────────────────────────────────
def _build_palette(self):
self.bg = QtGui.QColor(*BG)
# LUT[family][level] — every colour this window can ever paint, so a
# frame constructs no QColor at all.
self.lut = []
for hue in FAM_HUE:
col = []
for lv in range(LEVELS):
t = lv / (LEVELS - 1)
val = 0.055 + 0.945 * (t ** 1.25)
sat = 0.96 - 0.50 * (t ** 1.6)
col.append(QtGui.QColor.fromHsvF(hue / 360.0, sat, val))
self.lut.append(col)
self.chrome = self.lut[FAM_OTHER]
def _font(self, px: int):
f = QtGui.QFont()
f.setFamilies(["Geist Mono", "JetBrains Mono", "Fira Code",
"DejaVu Sans Mono", "monospace"])
f.setStyleHint(QtGui.QFont.StyleHint.Monospace)
f.setPixelSize(max(5, px))
return f
def _apply_scale(self):
s = self.scale
self.f_main = self._font(round(11 * s))
self.f_micro = self._font(round(8 * s))
fm = QtGui.QFontMetricsF(self.f_main)
fmm = QtGui.QFontMetricsF(self.f_micro)
self.mw, self.mh = fm.horizontalAdvance("0"), fm.height()
self.uw, self.uh = fmm.horizontalAdvance("0"), fmm.height()
self.m_asc, self.u_asc = fm.ascent(), fmm.ascent()
self.resize(round(BASE_W * s), round(BASE_H * s))
self._relayout()
def _relayout(self):
w, h = max(80, self.width()), max(60, self.height())
s = self.scale
self.pad = max(3, round(5 * s))
gap = max(2, round(4 * s))
self.hdr_y = self.pad
self.hdr_h = round(self.uh + 2)
self.foot_h = round(self.mh + 2)
self.foot_y = h - self.pad - self.foot_h
self.gut_w = max(round(18 * s), round(self.uw * 5))
self.gut_x = w - self.pad - self.gut_w
self.lab_w = round(self.uw * 1.7)
self.mx = self.pad + self.lab_w
# A band of its own for the column digits. Column N *is* orbit N, so
# that digit is the single most load-bearing glyph in the window;
# squeezing it into the header (where the sparkline lives) lost it.
self.col_y = self.hdr_y + self.hdr_h + gap
self.col_h = round(self.uh)
self.my = self.col_y + self.col_h
self.mw_px = max(8, self.gut_x - gap - self.mx)
self.mh_px = max(8, self.foot_y - gap - self.my)
self.cw = self.mw_px / 8.0
self.ch = self.mh_px / 6.0
self.inset = max(1, round(1.6 * s)) # keeps neighbouring cells distinct
self.gut_rows = max(1, int(self.mh_px / max(1.0, self.uh)))
self.stream_n = max(4, int((w - 2 * self.pad) / max(1.0, self.mw)))
# Bound the ribbon to exactly what fits, so paint iterates the deque
# in place instead of copying and slicing a 256-list every frame.
self.stream = deque(self.stream, maxlen=self.stream_n)
self._scan = None # rebuilt lazily at paint time
def _scanlines(self):
"""One cached pixmap for the CRT texture: a whole frame of it is one blit."""
if self._scan is not None:
return self._scan
w, h = max(1, self.width()), max(1, self.height())
pm = QtGui.QPixmap(w, h)
pm.fill(QtGui.QColor(0, 0, 0, 0))
p = QtGui.QPainter(pm)
step = max(2, round(3 * self.scale))
p.setPen(QtGui.QColor(0xa0, 0x60, 0xff, 16))
for y in range(0, h, step):
p.drawLine(0, y, w, y)
p.setPen(QtGui.QColor(0xa0, 0x60, 0xff, 9))
for x in range(0, w, step * 4):
p.drawLine(x, 0, x, h)
p.end()
self._scan = pm
return pm
def _scanlines_blit(self, p):
p.drawPixmap(0, 0, self._scanlines())
def resizeEvent(self, _e):
self._relayout()
# ── ingest ─────────────────────────────────────────────────────────
def ingest(self, ev: dict) -> None:
"""One parsed event → visual state. The whole render path hangs off
this, which is what lets --selftest exercise the drawing for real."""
now = time.monotonic()
self.last_event_t = now
self.ingested += 1
self._bucket_n += 1
e = (ev.get("event") or "").lower()
chn = ev.get("ch") or 0
v = _norm_value(ev)
if e.startswith("control") and ev.get("controller") is not None:
cc = int(ev["controller"])
cell = grid.CC_TO_CELL.get(cc)
if cell is not None:
st = self.cc.get(cc)
if st is None:
self.cc[cc] = [v, now, chn]
else:
st[0], st[1], st[2] = v, now, chn
self.col_t[cell[1]] = now
self.stream.append((HEX[v >> 3], self._fam_for_cc(cc), now))
return
# a CC the authored grid does not own: rain, not a cell.
self.stream.append((HEX[v >> 3], FAM_OTHER, now))
self._spawn_drop(HEX[cc & 0xF], FAM_OTHER)
return
g, fam = _glyph_for(ev.get("event") or "")
self.stream.append((g, fam, now))
self._spawn_drop(g, fam)
def _fam_for_cc(self, cc: int) -> int:
role = grid.CC_ROLE.get(cc)
return ROLE_FAM.get(role[0], FAM_OTHER) if role else FAM_OTHER
def _spawn_drop(self, glyph: str, fam: int) -> None:
if len(self.drops) >= MAX_DROPS: # hard cap: rain is decoration
del self.drops[:len(self.drops) - MAX_DROPS + 1]
self.drops.append([self._rng.randrange(0, 3), 0.0,
time.monotonic(), glyph, fam])
# ── frame ──────────────────────────────────────────────────────────
def tick(self):
if _QUIT.is_set():
self.close()
return
now = time.monotonic()
if self.reader is not None:
evs = self.reader.drain()
if evs and not self.paused:
for ev in evs:
self.ingest(ev)
elif evs:
self.last_event_t = now # stay awake while paused but fed
if now - self._bucket_t >= 0.1:
self.hist.append(self._bucket_n)
self._bucket_n = 0
self._bucket_t = now
if self.drops: # rain falls; dead drops go
dt = self._interval / 1000.0
rows = self.gut_rows
self.drops = [d for d in self.drops
if (d.__setitem__(1, d[1] + dt * 9.0) or True)
and d[1] < rows + 2 and now - d[2] < 4.0]
idle = (now - self.last_event_t) > IDLE_AFTER and not self.drops
want = FPS_IDLE if idle else FPS_ACTIVE
if want != self._interval:
self._interval = want
self.timer.setInterval(want)
self.frames += 1
self.update()
def _lv(self, age: float, floor: int = 0) -> int:
"""Age in seconds → LUT level. The only decay curve in the file."""
if age < 0:
return LEVELS - 1
lv = int((LEVELS - 1) * math.exp(-age / TAU))
return max(floor, min(LEVELS - 1, lv))
# ── paint ──────────────────────────────────────────────────────────
def paintEvent(self, _e):
p = QtGui.QPainter(self)
try:
p.setRenderHint(QtGui.QPainter.RenderHint.Antialiasing, False)
p.setRenderHint(QtGui.QPainter.RenderHint.TextAntialiasing, True)
w, h = self.width(), self.height()
p.fillRect(0, 0, w, h, self.bg)
now = time.monotonic()
# the slow dim pulse: the only thing that moves when it is silent
pulse = 0.5 + 0.5 * math.sin((now - self.t0) * 0.55)
self._paint_chrome(p, w, h, pulse)
self._paint_header(p, now, pulse)
self._paint_matrix(p, now, pulse)
self._paint_gutter(p, now, pulse)
self._paint_stream(p, now)
self._scanlines_blit(p)
self.painted += 1
finally:
p.end()
def _paint_chrome(self, p, w, h, pulse):
p.setPen(self.chrome[2 + int(3 * pulse)])
p.drawRect(0, 0, w - 1, h - 1)
p.setPen(self.chrome[6 + int(5 * pulse)])
n = max(4, round(8 * self.scale))
for cx, cy, dx, dy in ((0, 0, 1, 1), (w - 1, 0, -1, 1),
(0, h - 1, 1, -1), (w - 1, h - 1, -1, -1)):
p.drawLine(cx, cy, cx + dx * n, cy)
p.drawLine(cx, cy, cx, cy + dy * n)
def _paint_header(self, p, now, pulse):
p.setFont(self.f_micro)
lv = 5 + int(4 * pulse) if now - self.last_event_t > IDLE_AFTER else 12
p.setPen(self.chrome[lv])
total = self.reader.total if self.reader else self.ingested
# port address digits + a hex event counter. No words.
head = "%s│%06X" % (self.port_label, total & 0xFFFFFF)
y = self.hdr_y + self.u_asc
p.drawText(self.pad, round(y), head)
# sparkline of events/100 ms, most recent at the right
hx = self.pad + round(self.uw * (len(head) + 1))
bw = max(1, round(self.scale))
avail = max(4, int((self.gut_x - hx) / (bw + 1)))
bars = list(self.hist)[-avail:]
top = self.hdr_y
bot = self.hdr_y + self.hdr_h - 1
hh = max(2, bot - top)
peak = max(4, max(bars) if bars else 4)
x = hx
for n in bars:
if n:
bh = max(1, round(hh * min(1.0, n / peak)))
p.fillRect(x, bot - bh, bw, bh, self.lut[FAM_FX][6 + min(9, n)])
x += bw + 1
# state pips instead of words: paused, stream broken
pw = max(2, round(2 * self.scale))
if self.paused:
p.fillRect(self.gut_x, top + 1, pw, hh - 1, self.lut[FAM_GATE2][14])
if self.reader is not None and self.reader.error:
p.fillRect(self.gut_x + pw * 2, top + 1, pw, hh - 1,
self.lut[FAM_FAMILY][13])
def _paint_matrix(self, p, now, pulse):
mx, my, cw, ch = self.mx, self.my, self.cw, self.ch
# per-orbit charge glow behind each column: "this orbit is live"
for col in range(1, 9):
if not self.col_t[col]:
continue
lv = self._lv(now - self.col_t[col])
if lv > 1:
p.fillRect(round(mx + (col - 1) * cw), round(my),
max(1, round(cw) - self.inset), round(self.mh_px),
self.lut[FAM_LEVEL][min(3, 1 + lv // 6)])
p.setFont(self.f_micro)
# column digits = orbit numbers, brightening with that orbit's activity
col_base = self.col_y + self.u_asc
for col in range(1, 9):
lv = self._lv(now - self.col_t[col], floor=4) if self.col_t[col] \
else 4 + int(2 * pulse)
p.setPen(self.lut[FAM_LEVEL][min(LEVELS - 1, lv)])
p.drawText(round(mx + (col - 1) * cw + (cw - self.uw) / 2),
round(col_base), HEX[col])
# row letters: the grid's own single-glyph names, on the digits' baseline
p.setPen(self.chrome[4 + int(3 * pulse)])
for r, row in enumerate(grid.PHYSICAL_ORDER):
p.drawText(self.pad, round(my + r * ch + self.u_asc + 1), row)
two = self.cw > self.mw * 2.4
p.setFont(self.f_main)
for r, row in enumerate(grid.PHYSICAL_ORDER):
for col in range(1, 9):
cc = grid.CELL_TO_CC.get((row, col))
if cc is None:
continue
x = round(mx + (col - 1) * cw)
y = round(my + r * ch)
cwi = max(2, round(cw) - self.inset)
chi = max(2, round(ch) - self.inset)
fam = self._fam_for_cc(cc)
st = self.cc.get(cc)
if st is None:
# FLOOR TINT, never black: a mapped control that simply
# has not moved must not read as unmapped.
p.fillRect(x, y, cwi, chi, self.lut[fam][1])
p.setPen(self.chrome[3])
p.drawText(x + 2, y + chi - 3, "··" if two else "·")
continue
v, t, chn = st
lv = self._lv(now - t, floor=2)
# heat: the value tints the cell body even when cold
p.fillRect(x, y, cwi, chi, self.lut[fam][1 + int(3 * v / 127)])
bar_h = max(2, round(chi * 0.22))
if cc in grid.BUTTON_CCS:
# buttons are latches: filled above half, hollow below
if v >= 64:
p.fillRect(x + 1, y + chi - bar_h - 1, cwi - 2, bar_h,
self.lut[fam][lv])
else:
p.setPen(self.lut[fam][max(3, lv - 5)])
p.drawRect(x + 1, y + chi - bar_h - 1,
max(1, cwi - 3), max(1, bar_h - 1))
else:
p.fillRect(x + 1, y + chi - bar_h - 1,
max(1, round((cwi - 2) * v / 127)), bar_h,
self.lut[fam][lv])
# the channel shifts the hue of the digits, not of the cell
p.setPen(self.lut[(fam + chn) % 8][lv])
p.drawText(x + 2, y + self.m_asc,
("%02X" % v) if two else HEX[v >> 3])
def _paint_gutter(self, p, now, pulse):
gx, gw = self.gut_x, self.gut_w
p.fillRect(gx, round(self.my), gw, round(self.mh_px), self.lut[FAM_OTHER][1])
p.setPen(self.chrome[3 + int(3 * pulse)])
p.drawLine(gx, round(self.my), gx, round(self.my + self.mh_px))
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)
if lv < 2:
continue
y = round(self.my + ypos * self.uh + self.u_asc)
if y > limit:
continue
x = round(gx + 2 + sub * sub_w)
p.setPen(self.lut[fam][lv])
p.drawText(x, y, g)
if lv > 11: # one trailing glyph sells the fall
p.setPen(self.lut[fam][lv - 7])
p.drawText(x, round(y - self.uh), g)
def _paint_stream(self, p, now):
"""The ribbon, drawn in RUNS rather than one call per glyph.
One drawText per glyph was 2.1 ms of an 8.9 ms frame. Consecutive
entries sharing a colour AND a decay level are concatenated into one
call — which is legitimate only for hex glyphs, because those are
guaranteed to come from the monospaced face at exactly `self.mw`
advance. A class glyph (◆ ≈ ≡ …) may be served by a fallback face
with a different advance, so those still draw one at a time or the
ribbon would drift out of its grid.
"""
if not self.stream:
return
p.setFont(self.f_main)
mw = self.mw
x = float(self.pad)
y = round(self.foot_y + self.m_asc)
run: list[str] = []
run_x, run_key = x, (0, 0)
for g, fam, t in self.stream:
lv = self._lv(now - t, floor=2)
key = (fam, lv) if g in HEX else None
if run and key == run_key:
run.append(g)
else:
if run:
p.setPen(self.lut[run_key[0]][run_key[1]])
p.drawText(round(run_x), y, "".join(run))
run = []
if key is None:
p.setPen(self.lut[fam][lv])
p.drawText(round(x), y, g)
else:
run, run_x, run_key = [g], x, key
x += mw
if run:
p.setPen(self.lut[run_key[0]][run_key[1]])
p.drawText(round(run_x), y, "".join(run))
# ── input ──────────────────────────────────────────────────────────
def keyPressEvent(self, e):
k = e.key()
if k in (Qt.Key.Key_Q, Qt.Key.Key_Escape):
self.close()
elif k == Qt.Key.Key_P:
self.paused = not self.paused
elif k == Qt.Key.Key_C:
self.cc.clear()
self.stream.clear()
self.drops.clear()
self.col_t = [0.0] * 9
self.hist = deque([0] * 64, maxlen=64)
elif k in (Qt.Key.Key_Plus, Qt.Key.Key_Equal):
self._rescale(+0.15)
elif k in (Qt.Key.Key_Minus, Qt.Key.Key_Underscore):
self._rescale(-0.15)
else:
super().keyPressEvent(e)
def _rescale(self, d):
s = max(0.6, min(2.4, round((self.scale + d) * 100) / 100))
if s != self.scale:
self.scale = s
self._apply_scale()
self.update()
def mousePressEvent(self, e):
if e.button() == Qt.MouseButton.LeftButton:
# Wayland forbids a client positioning itself; startSystemMove is
# the compositor-blessed path. X11 falls back to a manual move.
wh = self.windowHandle()
if wh is not None and wh.startSystemMove():
self._drag_from = None
return
self._drag_from = e.globalPosition().toPoint() - self.pos()
def mouseMoveEvent(self, e):
if self._drag_from is not None:
self.move(e.globalPosition().toPoint() - self._drag_from)
def mouseReleaseEvent(self, _e):
self._drag_from = None
def closeEvent(self, e):
self.timer.stop()
if self.reader:
self.reader.close()
e.accept()
return MidiViz()
# ── entry points ───────────────────────────────────────────────────────────
def _synthetic_lines() -> list[str]:
"""aseqdump-shaped lines covering the whole grid, several channels, and every
glyph class — fed through the REAL parser, so --selftest exercises
parse_line + enrich as well as the drawing."""
out: list[str] = []
ccs = [cc for row in grid.PHYSICAL_ORDER for cc in grid.ROW_CCS[row]]
for i, cc in enumerate(ccs):
out.append(" 28:0 Control change %d, controller %d, value %d"
% (i % 4, cc, (i * 17) % 128))
for cc in (7, 93, 120, 64): # un-gridded CCs → gutter rain
out.append(" 28:0 Control change 0, controller %d, value 99" % cc)
for n in (36, 48, 60, 67, 72):
out.append(" 28:0 Note on 1, note %d, velocity %d" % (n, 40 + n // 2))
out.append(" 28:0 Note off 1, note %d, velocity 0" % n)
out += [
" 28:0 Pitch bend 2, value 4096",
" 28:0 Pitch bend 2, value -6000",
" 28:0 Program change 3, program 7",
" 28:0 Channel aftertouch 0, value 64",
" 28:0 Poly aftertouch 5, note 60, value 31",
" 28:0 Start",
" 28:0 Stop",
" 28:0 System exclusive f0 00 20 29 f7",
]
return out
def selftest(seconds: float = 3.0, show: bool = False) -> int:
if not show:
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
os.environ.setdefault("QT_LOGGING_RULES", "qt.qpa.*=false")
QtCore, QtGui, QtWidgets = _qt()
app = QtWidgets.QApplication.instance() or QtWidgets.QApplication([])
w = build_widget("00:0", None, scale=1.0)
w.show()
app.processEvents()
parsed = [midimon.enrich(ev) for ev in
(midimon.parse_line(ln) for ln in _synthetic_lines()) if ev]
if not parsed:
print("selftest FAIL: the parser produced no events", file=sys.stderr)
return 1
grabs, i = 0, 0
colours: set = set()
t_end = time.monotonic() + seconds
while time.monotonic() < t_end:
for _ in range(3): # a plausible live event rate
w.ingest(parsed[i % len(parsed)])
i += 1
w.tick()
app.processEvents()
pm = w.grab() # forces the real paint path
grabs += 1
if grabs % 10 == 1:
img = pm.toImage()
sx = max(1, img.width() // 16)
sy = max(1, img.height() // 16)
for yy in range(0, img.height(), sy):
for xx in range(0, img.width(), sx):
colours.add(img.pixel(xx, yy))
time.sleep(0.033)
# exercise the keyboard paths too (pause, unpause, clear, both resizes)
for key in ("Key_P", "Key_P", "Key_C", "Key_Plus", "Key_Minus"):
w.keyPressEvent(QtGui.QKeyEvent(QtCore.QEvent.Type.KeyPress,
getattr(QtCore.Qt.Key, key),
QtCore.Qt.KeyboardModifier.NoModifier))
w.ingest(parsed[0])
w.tick()
app.processEvents()
w.grab()
painted = w.painted
w.close()
ok = grabs >= 30 and len(colours) >= 8 and painted >= grabs
print("midiviz selftest: parsed=%d ingested=%d frames=%d paints=%d grabs=%d "
"distinct_sampled_colours=%d platform=%s -> %s"
% (len(parsed), w.ingested, w.frames, painted, grabs, len(colours),
os.environ.get("QT_QPA_PLATFORM", "native"), "PASS" if ok else "FAIL"))
return 0 if ok else 1
def main(argv=None) -> int:
ap = argparse.ArgumentParser(description="midiviz - MIDI stream as a picture")
ap.add_argument("-p", "--port", help="source port (CLIENT:PORT), e.g. 28:0")
ap.add_argument("-l", "--list", action="store_true", help="list ports and exit")
ap.add_argument("-s", "--scale", type=float, default=1.0,
help="initial scale, 0.6-2.4 (also +/- at runtime)")
ap.add_argument("--selftest", action="store_true",
help="render synthetic events for --seconds, then exit")
ap.add_argument("--seconds", type=float, default=3.0)
ap.add_argument("--show", action="store_true",
help="run the selftest on the real display, not offscreen")
a = ap.parse_args(argv)
if a.list:
for pt in list_ports():
print("%7s %s │ %s" % (pt["addr"], pt["client"], pt["port"]))
print("-> %s" % (resolve_watch_port()[1],))
return 0
if a.selftest:
return selftest(a.seconds, a.show)
_install_signals()
port, label = a.port, ""
if port is None:
port, label = resolve_watch_port()
# The window itself stays wordless; where it is listening goes to stderr, so
# `-l`-free debugging is still possible without putting prose on the canvas.
print("⚓ midiviz — %s · q/Esc/Ctrl-C to quit" % (label or "port %s" % port),
file=sys.stderr)
reader = Reader(port).start()
_QtCore, _QtGui, QtWidgets = _qt()
app = QtWidgets.QApplication.instance() or QtWidgets.QApplication(sys.argv[:1])
app.setApplicationName("midiviz")
w = build_widget(port or "--", reader, scale=max(0.6, min(2.4, a.scale)))
w.show()
w.raise_()
w.activateWindow()
rc = app.exec()
reader.close()
return rc
if __name__ == "__main__":
sys.exit(main())
...@@ -83,14 +83,28 @@ def test_a_session_filename_ending_in_dot_ardour_is_not_a_running_ardour(): ...@@ -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(): 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 """The monitor runs as `python3 .../midiviz.py`, so argv[0] is python3 and the
identity is the script path. That is the one case full-line matching is for.""" 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"] spec = L.BY_KEY["midimon"]
assert spec.get("exe") is None and spec.get("pgrep") 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 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(): def test_is_running_is_false_for_a_spec_with_no_pattern():
assert L.is_running({"key": "x"}, [("anything", "anything")]) is False 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