Commit 5875bf42 by PLN (Algolia)

midiviz: /dirt/play is the wire truth; the wordmark measures its own ink

The HL feed's root cause, captured off the wire: Tidal 1.9 addresses its
superdirtShape packets /dirt/play, not /play — a decoder that accepted only
the documented address dropped every real packet while every synthetic test
passed. Also: the first-trigger log could die on a non-trigger datagram
(UnboundLocalError) and take the whole feed thread silently; the feed now
survives anything. The wordmark bakes generously, scans its own ink and
crops — font metrics ignored the letter spacing and cut the tail to
PARVAGU; pixels do not lie. Single instance by flock (exit 78, no restart
storm); the tray launches midiviz via systemctl --user start, per PLN; the
2x column experiment is retired to a uniform grid — 'a C1 move overlapping
C2 is bad taste'.
parent 813c3962
...@@ -660,6 +660,25 @@ def launch(key): ...@@ -660,6 +660,25 @@ def launch(key):
if key == "ardour": if key == "ardour":
_sweep_stale_pending(spec) _sweep_stale_pending(spec)
# PLN, 2026-09-24: "ensure parvagues tray item launches via systemctl
# --user start midiviz indeed, not via raw launch, for this is how i
# launch". A unit-owned process gets the journal, the restart policy and
# the converger's idea of the truth; a raw Popen gets none of them — and
# then LOOKS dead to everything that asks the unit, which is how a lens
# ran unsupervised for nine hours while midiviz.service read inactive.
# Only specs that OPT IN with a "unit" key route here.
unit = spec.get("unit")
if unit:
_clear_latch(spec)
r = subprocess.run(["systemctl", "--user", "start", f"{unit}.service"],
capture_output=True, text=True)
if r.returncode == 0:
_log(f"{key}: started via systemctl --user start {unit}")
return {"ok": True, "status": "launched",
"msg": f"started {spec['name']} (unit {unit})"}
_log(f"{key}: systemctl start {unit} failed: {r.stderr.strip()}")
return {"ok": False, "status": "error",
"msg": r.stderr.strip() or f"systemctl --user start {unit} failed"}
argv = _argv(spec) argv = _argv(spec)
if not argv: if not argv:
return {"ok": False, "status": "no-terminal", "msg": "no terminal emulator found"} return {"ok": False, "status": "no-terminal", "msg": "no terminal emulator found"}
......
...@@ -84,6 +84,7 @@ from __future__ import annotations ...@@ -84,6 +84,7 @@ from __future__ import annotations
import argparse import argparse
import json import json
import math import math
import fcntl
import os import os
import random import random
import re import re
...@@ -484,7 +485,6 @@ WAVE_ALPHA = 0.12 ...@@ -484,7 +485,6 @@ WAVE_ALPHA = 0.12
# vendored out of the built site for the same standalone reason as the wave. # vendored out of the built site for the same standalone reason as the wave.
SYNE_PATH = Path(__file__).resolve().parent / "ui" / "syne-extrabold.ttf" SYNE_PATH = Path(__file__).resolve().parent / "ui" / "syne-extrabold.ttf"
WORD_ALPHA = 0.8 WORD_ALPHA = 0.8
WORD_CONDENSE_FLOOR = 0.75 # never squash the bloc thinner than this
# ── the HL feed ──────────────────────────────────────────────────────────── # ── the HL feed ────────────────────────────────────────────────────────────
# PLN, 2026-09-24: "please do indeed sub from the events, to indeed feed # PLN, 2026-09-24: "please do indeed sub from the events, to indeed feed
...@@ -495,8 +495,6 @@ WORD_CONDENSE_FLOOR = 0.75 # never squash the bloc thinner than this ...@@ -495,8 +495,6 @@ WORD_CONDENSE_FLOOR = 0.75 # never squash the bloc thinner than this
# default at runtime (--no-hl for the off switch): a lens that only shows # default at runtime (--no-hl for the off switch): a lens that only shows
# the controls was half the picture. # the controls was half the picture.
HL_ON_BY_DEFAULT = True HL_ON_BY_DEFAULT = True
# How long the focus column keeps its 2× after the last touch of it.
FOCUS_HOLD = 4.0
# ── the size belongs to the window manager, the density to the window ────── # ── the size belongs to the window manager, the density to the window ──────
# PLN, 2026-09-22: "only drag-> grab atm, can we have corner grabs to make it # PLN, 2026-09-22: "only drag-> grab atm, can we have corner grabs to make it
...@@ -1709,17 +1707,14 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0, ...@@ -1709,17 +1707,14 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0,
return self._wave_pm return self._wave_pm
def _wave_font(self, target_w: float, target_h: float): def _wave_font(self, target_w: float, target_h: float):
"""Syne ExtraBold, sized to fill the row height as far as the """Syne ExtraBold at 92% of the row height. The vendored ttf is
span allows at a 0.75 condense floor — PLN, 2026-09-24: "letters the variable font INSTANCED at 800 — Qt resolves a variable
could be a bit taller, like atm they occupy flattened rectangles font's default instance (Regular) however loudly you ask for a
boxes, can be more square?" The arithmetic is honest: nine weight, which is how the first cut read thin. Width is NOT
ExtraBold Syne glyphs are ~9.4 em wide, three columns ~3.9 em, so computed from font metrics here: metrics ignored the letter
square letters would need either a 0.42× squash (its own spacing and kerned sub-advances disagreed, and that arithmetic is
distortion) or a wider span. The compromise: condense at most to exactly how "PARVAGU" lost its tail. The bake measures its own
0.75, and let the type take whatever height still fits. The ink instead (see _word_pixmap)."""
vendored ttf is the variable font INSTANCED at 800 — Qt resolves
a variable font's default instance (Regular) however loudly you
ask for a weight, which is how the first cut read thin."""
key = (round(target_w), round(target_h)) key = (round(target_w), round(target_h))
if self._word_font_key == key: if self._word_font_key == key:
return self._word_font return self._word_font
...@@ -1727,34 +1722,53 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0, ...@@ -1727,34 +1722,53 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0,
if fam_id < 0: if fam_id < 0:
return None return None
fam = QtGui.QFontDatabase.applicationFontFamilies(fam_id)[0] fam = QtGui.QFontDatabase.applicationFontFamilies(fam_id)[0]
probe = QtGui.QFont(fam)
probe.setPixelSize(40)
probe.setLetterSpacing(QtGui.QFont.SpacingType.PercentageSpacing,
106.0) # +0.06em — tighter tracking
fmr = QtGui.QFontMetricsF(probe)
word = "PARVAGUES"
em_ratio = (sum(fmr.horizontalAdvance(c) for c in word)
+ fmr.horizontalAdvance(" ") * 0.06 * (len(word) - 1)
) / 40.0
px = min(target_h * 0.92,
target_w / (WORD_CONDENSE_FLOOR * em_ratio))
f = QtGui.QFont(fam) f = QtGui.QFont(fam)
f.setPixelSize(max(6, px)) f.setPixelSize(max(6, target_h * 0.92))
f.setLetterSpacing(QtGui.QFont.SpacingType.PercentageSpacing, f.setLetterSpacing(QtGui.QFont.SpacingType.PercentageSpacing,
106.0) 106.0) # +0.06em, as the hero tracks
self._word_font, self._word_font_key = f, key self._word_font, self._word_font_key = f, key
return f return f
def _draw_word_layer(self, qp, base, pad, f, fm, pen):
"""One pass of the word: glyphs drawn one by one with the tracked
advance, so the letter spacing is OURS and identical under the
glow rings and the core."""
word = "PARVAGUES"
qp.setPen(pen)
x = float(pad)
for c in word:
qp.drawText(QtCore.QPointF(x, base), c)
x += fm.horizontalAdvance(c)
return x - pad
def _word_ink_width(self, img):
"""The rightmost non-transparent column — the METRICS LIE (they
dropped the letter spacing and kerned differently from the
rasterizer), the pixels do not. This scan is how 'PARVAGU' lost
its tail and how it gets it back."""
w, h = img.width(), img.height()
right = 0
for x in range(w - 1, -1, -1):
col_has = False
for y in range(h):
if img.pixelColor(x, y).alpha() > 8:
col_has = True
break
if col_has:
right = x
break
right = 0
return right + 1
def _word_pixmap(self, span_w: float, span_h: float): def _word_pixmap(self, span_w: float, span_h: float):
"""The wordmark BAKED — glyphs, tracking and neon glow rendered """The wordmark BAKED — glyphs, tracking and neon glow rendered
once per geometry, not per frame. The glow is the site hero's once per geometry, not per frame. PLN's verdicts are all in here:
`textShadow` in miniature, but TIGHT — PLN: "a little less the glow is TIGHT ("a little less blurry? esp made fullscreen"),
blurry? esp made fullscreen it gets very blurry" — because a wide the type is ExtraBold-800 ("too light"), and the bake measures
ring halo at 60px of type is indistinguishable from an out-of- its own ink and crops to it ("i see PARVAGU how come missing end
focus lens. Two close rings only; the core carries the colour. of letters"). Any width overrun is taken by a vector condense at
Width overrun is taken by a vector condense (a scaled painter, draw time — never raster scaling, so fullscreen rebakes stay
still antialiased), never by raster scaling — so fullscreen sharp."""
rebakes stay sharp."""
key = (round(span_w), round(span_h)) key = (round(span_w), round(span_h))
if self._word_pm_key == key: if self._word_pm_key == key:
return self._word_pm return self._word_pm
...@@ -1762,14 +1776,26 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0, ...@@ -1762,14 +1776,26 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0,
if f is None: if f is None:
return None return None
fm = QtGui.QFontMetricsF(f) fm = QtGui.QFontMetricsF(f)
word = "PARVAGUES" pad = max(2, int(f.pixelSize() * 0.25))
def measure(): # PROBE: draw once, generously, and ask the pixels how wide the
return sum(fm.horizontalAdvance(c) for c in word) \ # word really is. Metrics-driven sizing cut the tail; ink does not.
+ fm.horizontalAdvance(" ") * 0.06 * (len(word) - 1) probe_w = span_w * 3
total = measure() probe = QtGui.QImage(probe_w, round(span_h) + pad * 2,
sx = min(1.0, span_w / max(1.0, total)) # the condense factor QtGui.QImage.Format.Format_ARGB32_Premultiplied)
pad = max(2, int(f.pixelSize() * 0.22)) probe.fill(0)
img = QtGui.QImage(round(span_w * sx) + pad * 2, qp = QtGui.QPainter(probe)
try:
qp.setRenderHint(QtGui.QPainter.RenderHint.TextAntialiasing,
True)
qp.setFont(f)
base = pad + (span_h + fm.ascent() - fm.descent()) / 2.0
self._draw_word_layer(qp, base, pad, f, fm,
QtGui.QPen(QtGui.QColor("#d900ff")))
finally:
qp.end()
ink = self._word_ink_width(probe)
sx = min(1.0, span_w / max(1.0, ink - pad))
img = QtGui.QImage(round((ink) * sx) + pad * 2,
round(span_h) + pad * 2, round(span_h) + pad * 2,
QtGui.QImage.Format.Format_ARGB32_Premultiplied) QtGui.QImage.Format.Format_ARGB32_Premultiplied)
img.fill(0) img.fill(0)
...@@ -1779,15 +1805,7 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0, ...@@ -1779,15 +1805,7 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0,
True) True)
qp.setFont(f) qp.setFont(f)
qp.scale(sx, 1.0) qp.scale(sx, 1.0)
asc = fm.ascent() base = pad + (span_h + fm.ascent() - fm.descent()) / 2.0
base = pad + (span_h + asc - fm.descent()) / 2.0
def draw_word(pen):
qp.setPen(pen)
x = float(pad)
for c in word:
qp.drawText(QtCore.QPointF(x, base), c)
x += fm.horizontalAdvance(c) \
+ fm.horizontalAdvance(" ") * 0.06
# the glow: two CLOSE rings, faint — a rim of neon, not a fog # the glow: two CLOSE rings, faint — a rim of neon, not a fog
r0 = max(1.0, f.pixelSize() * 0.09) r0 = max(1.0, f.pixelSize() * 0.09)
for rad, alpha in ((r0, 40), (r0 * 0.45, 60)): for rad, alpha in ((r0, 40), (r0 * 0.45, 60)):
...@@ -1797,10 +1815,11 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0, ...@@ -1797,10 +1815,11 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0,
a = 2 * math.pi * i / 10 a = 2 * math.pi * i / 10
qp.save() qp.save()
qp.translate(math.cos(a) * rad, math.sin(a) * rad) qp.translate(math.cos(a) * rad, math.sin(a) * rad)
draw_word(glow) self._draw_word_layer(qp, base, pad, f, fm, glow)
qp.restore() qp.restore()
# the core: --neon-high itself, the site's own pink # the core: --neon-high itself, the site's own pink
draw_word(QtGui.QPen(QtGui.QColor("#d900ff"))) self._draw_word_layer(qp, base, pad, f, fm,
QtGui.QPen(QtGui.QColor("#d900ff")))
finally: finally:
qp.end() qp.end()
pm = QtGui.QPixmap.fromImage(img) pm = QtGui.QPixmap.fromImage(img)
...@@ -2186,34 +2205,19 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0, ...@@ -2186,34 +2205,19 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0,
self.lut[FAM_FX2][14]) self.lut[FAM_FX2][14])
def _col_geo(self, now): def _col_geo(self, now):
"""Per-column (x, width) with a STEADY focus column. """Uniform columns. The width experiment is RETIRED, and its
verdict is recorded so nobody rebuilds it in a month: PLN asked
PLN, 2026-09-24: "can we have 2x wider matrix column, shrinking for a 2× focus column (2026-09-24), then, seeing it — "imo the
slightly other cols, and algorithmically minimize overlaps when fact you can see a C1 move overlap C2 is bad taste" and "can we
moving several controls together, for readability" — then, of the fix this for a single sized thing for each?" He is right, and the
first cut's decay-driven widths: "its weird moving a djf makes reason generalizes: a bar whose width depends on WHEN you look at
the e.g. col1 grow then shrink over time?" He was right: a width it is not a reading. A cell owns a fixed rectangle; a bar at 50%
that breathes on a decay curve is motion with no meaning. Now the must mean 50% against the same ruler every time.
most recently touched column HOLDS 2× while it is in use and the
others give up a little width; the focus switches when another
column is touched and everything relaxes to uniform after
FOCUS_HOLD of silence. The only transitions are caused by hands.
""" """
focus, last = None, 0.0
for col in range(1, 9):
if self.col_t[col] > last:
focus, last = col, self.col_t[col]
if focus is None or now - last > FOCUS_HOLD:
weights = [1.0] * 8
else:
weights = [2.0 if c == focus else 1.0 for c in range(1, 9)]
total = self.mw_px
scale = total / sum(weights)
out, x = [], self.mx out, x = [], self.mx
for wcol in weights: for _ in range(8):
w = wcol * scale out.append((x, self.cw))
out.append((x, w)) x += self.cw
x += w
return out return out
def _paint_matrix(self, p, now, pulse): def _paint_matrix(self, p, now, pulse):
...@@ -3577,6 +3581,34 @@ def main(argv=None) -> int: ...@@ -3577,6 +3581,34 @@ def main(argv=None) -> int:
if a.selftest: if a.selftest:
return selftest(a.seconds, a.show, a.theme or DEFAULT_THEME) return selftest(a.seconds, a.show, a.theme or DEFAULT_THEME)
# PLN, 2026-09-24: "do ensure never more than one instance, midiviz
# should check unique else restart not duplicate". flock on a runtime
# lockfile: atomic, released by the kernel on death, no stale-pid
# guessing. A duplicate exits 78 — the unit's restart-prevent code —
# because Restart=always plus exit 0 would be a restart storm against
# the live instance.
rt = os.environ.get("XDG_RUNTIME_DIR") or "/run/user/%d" % os.getuid()
lock_path = Path(rt) / "parvagues" / "midiviz.instance"
try:
lock_path.parent.mkdir(parents=True, exist_ok=True)
fh = open(lock_path, "w")
try:
fcntl.flock(fh, fcntl.LOCK_EX | fcntl.LOCK_NB)
except OSError:
print("midiviz: already running — refusing to duplicate "
"(focus the existing window)", file=sys.stderr)
try:
import launchers
launchers.focus_window("midiviz")
except Exception:
pass
return USER_CLOSE_EXIT
fh.write(str(os.getpid()))
fh.flush()
globals()["_INSTANCE_LOCK"] = fh # alive for the process lifetime
except OSError:
pass # no runtime dir: degrade, don't die
_install_signals() _install_signals()
# cli beats the saved choice beats the built-in. A bad config file loses # cli beats the saved choice beats the built-in. A bad config file loses
# here silently and on purpose: see load_config. # here silently and on purpose: see load_config.
......
...@@ -114,8 +114,13 @@ def decode_osc(data: bytes) -> list[dict]: ...@@ -114,8 +114,13 @@ def decode_osc(data: bytes) -> list[dict]:
def play_orbit(msg: dict) -> int | None: def play_orbit(msg: dict) -> int | None:
"""A decoded message → its 1-based orbit number, or None if not a trigger.""" """A decoded message → its 1-based orbit number, or None if not a trigger.
if msg["addr"] != "/play":
Tidal 1.9's superdirtShape addresses its messages `/dirt/play`, not
`/play` — captured off the wire on this rig — and a decoder that only
accepted the documentation's address silently dropped every real packet
while every synthetic test passed. Accept both spellings."""
if msg["addr"] not in ("/play", "/dirt/play"):
return None return None
o = msg["args"].get("orbit") o = msg["args"].get("orbit")
if isinstance(o, float) and o == int(o): if isinstance(o, float) and o == int(o):
...@@ -175,6 +180,7 @@ class HLFeed: ...@@ -175,6 +180,7 @@ class HLFeed:
msgs = decode_osc(data) msgs = decode_osc(data)
except (ValueError, struct.error, IndexError): except (ValueError, struct.error, IndexError):
continue continue
first = False
for msg in msgs: for msg in msgs:
o = play_orbit(msg) o = play_orbit(msg)
if o is None: if o is None:
......
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