Commit d1dbae36 by PLN (Algolia)

feat(midiviz): a permanent lens — own app id, all desktops, CC numbers

PLN, after the first look at it: "the midi mon is beautiful! so lets
streamline, now it should always oppen, with always on top, all desktops
displayed when i switch from desk 1 to 2 3 4" -- and then "would help to
see the chan num: 33 or 53 etc so as i press i can wire".

Always-on-top needed no change: the WindowStaysOnTopHint at :293 is
unconditional and KWin honours it. The other three did.

IDENTITY. The Wayland app_id comes from desktopFileName, not
applicationName, so KWin saw resourceClass "python3" -- verified by
querying workspace.windowList(), not assumed. Two consequences: a window
rule keyed on the app id would have matched EVERY python GUI on the box,
and launchers.focus_window("midimon") could never match, so clicking the
Bridge/tray button on an already-running lens silently did nothing. Set
desktopFileName + ship a .desktop (which also silences the portal's "App
info not found" warning), and point the launcher's focus at "midiviz".

ALL DESKTOPS is a compositor property, not a Qt flag, so it lives in a
KWin rule (~/.config/kwinrulesrc, wmclass=midiviz, desktops= empty with
desktopsrule=2/Force). Declarative on purpose: it applies at map time,
every time, with no post-launch retry racing the window's existence.
Verified on the live window rather than inferred from the key names --
onAllDesktops=true keepAbove=true.

CC NUMBERS, two layers. Per-cell dim decimals are the map you consult
while wiring; they render in their own pass because f_micro is already
current there, and a per-cell setFont would add 96 font switches a frame
to a path this file keeps to a counted number of drawText calls. They
are skipped when a cell is too narrow to hold them beside the value. And
the header now prints the last-touched control as ^53=127 -- decimal,
with the corpus's caret, so it is the literal token to type into a
pattern instead of a hex value to convert mid-set.

Autostart as a systemd --user unit bound to graphical-session.target,
not default.target: it needs WAYLAND_DISPLAY/XDG_RUNTIME_DIR imported
into the user manager first. Registered "login" in rig_units.py.

Paint path exercised by the offscreen selftest: 116 paints, PASS.
parent 719e09ae
...@@ -75,8 +75,15 @@ LAUNCHERS = [ ...@@ -75,8 +75,15 @@ LAUNCHERS = [
# its own frameless window, and wrapping a GUI in konsole would have left an # 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. # empty black terminal parked behind it for the length of the set.
# midimon.py is untouched and remains the fallback (`python3 midimon.py`). # midimon.py is untouched and remains the fallback (`python3 midimon.py`).
# `focus` is NOT the key here: the key stayed "midimon" for both faces
# and PLN's muscle memory, but focus_window matches KWin's resourceClass, and this
# window's app id is "midiviz" (verified 2026-09-05 against
# workspace.windowList(); before midiviz.py set desktopFileName it was the
# even less useful "python3"). Matching "midimon" could never hit, so a
# click on an already-running lens silently did nothing.
{"key": "midimon", "name": "MIDI Monitor", "blurb": "live MIDI as glyph-rain — the surface, watched", {"key": "midimon", "name": "MIDI Monitor", "blurb": "live MIDI as glyph-rain — the surface, watched",
"candidates": ["python3"], "args": [str(MIDIVIZ)], "pgrep": "midiviz.py"}, "candidates": ["python3"], "args": [str(MIDIVIZ)], "pgrep": "midiviz.py",
"focus": "midiviz"},
] ]
# 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
......
...@@ -299,6 +299,7 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0): ...@@ -299,6 +299,7 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0):
self.reader = reader self.reader = reader
self.port_label = port_label self.port_label = port_label
self.last_cc = None # (cc, value, channel, t) -- header readout
self.scale = scale self.scale = scale
self.paused = False self.paused = False
self.t0 = time.monotonic() self.t0 = time.monotonic()
...@@ -440,6 +441,14 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0): ...@@ -440,6 +441,14 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0):
else: else:
st[0], st[1], st[2] = v, now, chn st[0], st[1], st[2] = v, now, chn
self.col_t[cell[1]] = now self.col_t[cell[1]] = now
# PLN, 2026-09-05: "would help to see the chan num: 33 or 53
# etc so as i press i can wire". The grid shows WHERE a knob
# is and HOW FAR it moved, but never WHICH CC it is -- so
# wiring a control meant leaving the window for the CC map.
# Keep the last one touched; the header prints it as the
# 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)) self.stream.append((HEX[v >> 3], self._fam_for_cc(cc), now))
return return
# a CC the authored grid does not own: rain, not a cell. # a CC the authored grid does not own: rain, not a cell.
...@@ -540,6 +549,14 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0): ...@@ -540,6 +549,14 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0):
total = self.reader.total if self.reader else self.ingested total = self.reader.total if self.reader else self.ingested
# port address digits + a hex event counter. No words. # port address digits + a hex event counter. No words.
head = "%s│%06X" % (self.port_label, total & 0xFFFFFF) head = "%s│%06X" % (self.port_label, total & 0xFFFFFF)
# The readout is DECIMAL and carries the corpus's own caret prefix:
# what shows up here is literally what gets typed in a .tidal file
# (^53), not a hex value needing conversion mid-set.
if self.last_cc is not None:
cc, v, chn, t = self.last_cc
head += "│^%d=%d" % (cc, v)
if chn:
head += "/%d" % chn
y = self.hdr_y + self.u_asc y = self.hdr_y + self.u_asc
p.drawText(self.pad, round(y), head) p.drawText(self.pad, round(y), head)
...@@ -593,6 +610,31 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0): ...@@ -593,6 +610,31 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0):
for r, row in enumerate(grid.PHYSICAL_ORDER): for r, row in enumerate(grid.PHYSICAL_ORDER):
p.drawText(self.pad, round(my + r * ch + self.u_asc + 1), row) p.drawText(self.pad, round(my + r * ch + self.u_asc + 1), row)
# CC numbers, one dim decimal per mapped cell, top-right.
#
# Drawn HERE, in its own pass, because f_micro is already the current
# font: a per-cell setFont inside the main loop would add 96 font
# switches a frame to a render path this file deliberately keeps to a
# counted number of drawText calls.
#
# Dim on purpose -- this is a reference layer, not a reading. It is
# the map you consult while wiring; the value digits stay the thing
# your eye tracks while playing. Skipped entirely when the cell is
# too narrow to hold the digits without colliding with the value.
cc_room = round(self.cw) - self.inset >= self.uw * 2 + 8
if cc_room:
p.setPen(self.chrome[5])
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
txt = "%d" % cc
p.drawText(
round(mx + (col - 1) * cw + (round(self.cw) - self.inset)
- self.uw * len(txt) - 2),
round(my + r * ch + self.u_asc + 1), txt)
two = self.cw > self.mw * 2.4 two = self.cw > self.mw * 2.4
p.setFont(self.f_main) p.setFont(self.f_main)
for r, row in enumerate(grid.PHYSICAL_ORDER): for r, row in enumerate(grid.PHYSICAL_ORDER):
...@@ -870,6 +912,13 @@ def main(argv=None) -> int: ...@@ -870,6 +912,13 @@ def main(argv=None) -> int:
_QtCore, _QtGui, QtWidgets = _qt() _QtCore, _QtGui, QtWidgets = _qt()
app = QtWidgets.QApplication.instance() or QtWidgets.QApplication(sys.argv[:1]) app = QtWidgets.QApplication.instance() or QtWidgets.QApplication(sys.argv[:1])
app.setApplicationName("midiviz") app.setApplicationName("midiviz")
# Wayland app_id comes from desktopFileName, NOT applicationName. Without
# this, KWin saw resourceClass "python3" (verified 2026-09-05 by querying
# workspace.windowList()) -- so launchers.focus_window("midimon") could
# never match, and a KWin rule keyed on the app id would either miss or,
# worse, match EVERY python3 GUI on the box. Give the window its own
# identity so the compositor can be told about this window and no other.
app.setDesktopFileName("midiviz")
w = build_widget(port or "--", reader, scale=max(0.6, min(2.4, a.scale))) w = build_widget(port or "--", reader, scale=max(0.6, min(2.4, a.scale)))
w.show() w.show()
w.raise_() w.raise_()
......
[Desktop Entry]
Type=Application
Name=midiviz
GenericName=MIDI lens
Comment=Live MIDI as glyph-rain — the ParVagues control surface, watched
Exec=/usr/bin/python3 /home/pln/Work/Sound/Tidal/tools/bridge/midiviz.py
Icon=audio-midi
Terminal=false
Categories=AudioVideo;Audio;
StartupWMClass=midiviz
NoDisplay=false
[Unit]
Description=midiviz (ParVagues MIDI lens: the surface, watched)
Documentation=file:///home/pln/Work/Sound/Tidal/tools/bridge/midiviz.py
# GUI unit: it must start only once the compositor's env (WAYLAND_DISPLAY,
# XDG_RUNTIME_DIR) has been imported into the user manager, which is what
# graphical-session.target marks. Bound to default.target instead, it would
# race the session and fail with "could not connect to display". Same pattern
# as perf-tray.service; parvagues-bridge.service uses default.target because
# it is headless and has no display to wait for.
After=graphical-session.target
PartOf=graphical-session.target
[Service]
# Inherits the Wayland/Plasma session env from the user manager.
Environment=PYTHONUNBUFFERED=1
ExecStart=/usr/bin/python3 %h/Work/Sound/Tidal/tools/bridge/midiviz.py
# The lens is decoration with a job: if it dies it should come back, but a
# crash loop must not spin -- it holds no audio ports and nothing waits on it.
Restart=on-failure
RestartSec=5
[Install]
WantedBy=graphical-session.target
...@@ -86,6 +86,12 @@ SERVICES = [ ...@@ -86,6 +86,12 @@ SERVICES = [
# Debt: its unit file still lives only in ~/.config/systemd/user (not # Debt: its unit file still lives only in ~/.config/systemd/user (not
# repo-owned like the others). # repo-owned like the others).
("perf-tray", "perf tray", None, "login", "tray: what's up, perf mode, gear pause"), ("perf-tray", "perf tray", None, "login", "tray: what's up, perf mode, gear pause"),
# "login", not "manual": PLN asked for the lens to be permanently present
# ("now it should always oppen"), on top and on every desktop. On-top is a
# window flag midiviz already sets; all-desktops is a KWin rule keyed on its
# app id (see the setDesktopFileName call in midiviz.py -- without it the app
# id was "python3" and a rule would have matched every Python GUI).
("midiviz", "MIDI lens", None, "login", "the surface, watched (glyph-rain + CC numbers)"),
] ]
TARGET = "parvagues-rig.target" TARGET = "parvagues-rig.target"
......
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