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):
if key == "ardour":
_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)
if not argv:
return {"ok": False, "status": "no-terminal", "msg": "no terminal emulator found"}
......
......@@ -84,6 +84,7 @@ from __future__ import annotations
import argparse
import json
import math
import fcntl
import os
import random
import re
......@@ -484,7 +485,6 @@ WAVE_ALPHA = 0.12
# vendored out of the built site for the same standalone reason as the wave.
SYNE_PATH = Path(__file__).resolve().parent / "ui" / "syne-extrabold.ttf"
WORD_ALPHA = 0.8
WORD_CONDENSE_FLOOR = 0.75 # never squash the bloc thinner than this
# ── the HL 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
# default at runtime (--no-hl for the off switch): a lens that only shows
# the controls was half the picture.
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 ──────
# 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,
return self._wave_pm
def _wave_font(self, target_w: float, target_h: float):
"""Syne ExtraBold, sized to fill the row height as far as the
span allows at a 0.75 condense floor — PLN, 2026-09-24: "letters
could be a bit taller, like atm they occupy flattened rectangles
boxes, can be more square?" The arithmetic is honest: nine
ExtraBold Syne glyphs are ~9.4 em wide, three columns ~3.9 em, so
square letters would need either a 0.42× squash (its own
distortion) or a wider span. The compromise: condense at most to
0.75, and let the type take whatever height still fits. The
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."""
"""Syne ExtraBold at 92% of the row height. The 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. Width is NOT
computed from font metrics here: metrics ignored the letter
spacing and kerned sub-advances disagreed, and that arithmetic is
exactly how "PARVAGU" lost its tail. The bake measures its own
ink instead (see _word_pixmap)."""
key = (round(target_w), round(target_h))
if self._word_font_key == key:
return self._word_font
......@@ -1727,34 +1722,53 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0,
if fam_id < 0:
return None
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.setPixelSize(max(6, px))
f.setPixelSize(max(6, target_h * 0.92))
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
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):
"""The wordmark BAKED — glyphs, tracking and neon glow rendered
once per geometry, not per frame. The glow is the site hero's
`textShadow` in miniature, but TIGHT — PLN: "a little less
blurry? esp made fullscreen it gets very blurry" — because a wide
ring halo at 60px of type is indistinguishable from an out-of-
focus lens. Two close rings only; the core carries the colour.
Width overrun is taken by a vector condense (a scaled painter,
still antialiased), never by raster scaling — so fullscreen
rebakes stay sharp."""
once per geometry, not per frame. PLN's verdicts are all in here:
the glow is TIGHT ("a little less blurry? esp made fullscreen"),
the type is ExtraBold-800 ("too light"), and the bake measures
its own ink and crops to it ("i see PARVAGU how come missing end
of letters"). Any width overrun is taken by a vector condense at
draw time — never raster scaling, so fullscreen rebakes stay
sharp."""
key = (round(span_w), round(span_h))
if self._word_pm_key == key:
return self._word_pm
......@@ -1762,14 +1776,26 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0,
if f is None:
return None
fm = QtGui.QFontMetricsF(f)
word = "PARVAGUES"
def measure():
return sum(fm.horizontalAdvance(c) for c in word) \
+ fm.horizontalAdvance(" ") * 0.06 * (len(word) - 1)
total = measure()
sx = min(1.0, span_w / max(1.0, total)) # the condense factor
pad = max(2, int(f.pixelSize() * 0.22))
img = QtGui.QImage(round(span_w * sx) + pad * 2,
pad = max(2, int(f.pixelSize() * 0.25))
# PROBE: draw once, generously, and ask the pixels how wide the
# word really is. Metrics-driven sizing cut the tail; ink does not.
probe_w = span_w * 3
probe = QtGui.QImage(probe_w, round(span_h) + pad * 2,
QtGui.QImage.Format.Format_ARGB32_Premultiplied)
probe.fill(0)
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,
QtGui.QImage.Format.Format_ARGB32_Premultiplied)
img.fill(0)
......@@ -1779,15 +1805,7 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0,
True)
qp.setFont(f)
qp.scale(sx, 1.0)
asc = fm.ascent()
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
base = pad + (span_h + fm.ascent() - fm.descent()) / 2.0
# the glow: two CLOSE rings, faint — a rim of neon, not a fog
r0 = max(1.0, f.pixelSize() * 0.09)
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,
a = 2 * math.pi * i / 10
qp.save()
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()
# 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:
qp.end()
pm = QtGui.QPixmap.fromImage(img)
......@@ -2186,34 +2205,19 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0,
self.lut[FAM_FX2][14])
def _col_geo(self, now):
"""Per-column (x, width) with a STEADY focus column.
PLN, 2026-09-24: "can we have 2x wider matrix column, shrinking
slightly other cols, and algorithmically minimize overlaps when
moving several controls together, for readability" — then, of the
first cut's decay-driven widths: "its weird moving a djf makes
the e.g. col1 grow then shrink over time?" He was right: a width
that breathes on a decay curve is motion with no meaning. Now the
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.
"""Uniform columns. The width experiment is RETIRED, and its
verdict is recorded so nobody rebuilds it in a month: PLN asked
for a 2× focus column (2026-09-24), then, seeing it — "imo the
fact you can see a C1 move overlap C2 is bad taste" and "can we
fix this for a single sized thing for each?" He is right, and the
reason generalizes: a bar whose width depends on WHEN you look at
it is not a reading. A cell owns a fixed rectangle; a bar at 50%
must mean 50% against the same ruler every time.
"""
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
for wcol in weights:
w = wcol * scale
out.append((x, w))
x += w
for _ in range(8):
out.append((x, self.cw))
x += self.cw
return out
def _paint_matrix(self, p, now, pulse):
......@@ -3577,6 +3581,34 @@ def main(argv=None) -> int:
if a.selftest:
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()
# cli beats the saved choice beats the built-in. A bad config file loses
# here silently and on purpose: see load_config.
......
......@@ -114,8 +114,13 @@ def decode_osc(data: bytes) -> list[dict]:
def play_orbit(msg: dict) -> int | None:
"""A decoded message → its 1-based orbit number, or None if not a trigger."""
if msg["addr"] != "/play":
"""A decoded message → its 1-based orbit number, or None if not a trigger.
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
o = msg["args"].get("orbit")
if isinstance(o, float) and o == int(o):
......@@ -175,6 +180,7 @@ class HLFeed:
msgs = decode_osc(data)
except (ValueError, struct.error, IndexError):
continue
first = False
for msg in msgs:
o = play_orbit(msg)
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