Commit 794b6313 by PLN (Algolia)

bridge midiviz: sync the DevCon skin from the standalone repo

parent 1294c6d1
#!/usr/bin/env python3
# SPDX-License-Identifier: GPL-3.0-or-later
"""midiviz — the MIDI stream as a picture, not a transcript.
`midimon.py` prints one line per event ("14:0 Control change ch0, controller
......@@ -108,6 +109,30 @@ import midimon # noqa: E402 parse_line / enrich / resolve_po
import midistream # noqa: E402 parse_ports
import oscfeed # noqa: E402 the Tidal HL feed (SuperDirt mirror)
# ── brand — whose lens this is ─────────────────────────────────────────────
# midiviz was born inside ParVagues (a livecoding rig) and ships wearing it;
# nothing here NEEDS that name. Override the three at launch to wear your own:
#
# MIDIVIZ_BRAND="Coolnaut" MIDIVIZ_WORDMARK="COOLNAUT" \
# MIDIVIZ_BRAND_SLUG="coolnaut" python3 midiviz.py
#
# * BRAND is the spoken name (menu labels, tray presence);
# * WORDMARK is the block lettering drawn over row D (upper-case on purpose —
# that is what the vendored display face is for, see the SYNE_PATH note);
# * BRAND_SLUG is the directory under $XDG_CONFIG_HOME and $XDG_RUNTIME_DIR
# where THIS instance keeps its memory and latches — one per brand, so two
# brands never fight over one saved theme.
# The watermarked wave swaps the same way: MIDIVIZ_WAVE=/path/to/logo.png
# (any transparent PNG; it is painted faint enough to never compete with a
# digit — see the WAVE_PATH note below).
# What branding does NOT touch: the wire paths midiviz READS
# (`~/.cache/parvagues/surface-state.json`, `eval-events.jsonl`) are contracts
# with the producers that write them — a driver, an editor — not decoration.
# Where those live is documented in the README's "wire" section.
BRAND = os.environ.get("MIDIVIZ_BRAND", "ParVagues")
WORDMARK = os.environ.get("MIDIVIZ_WORDMARK", "PARVAGUES")
BRAND_SLUG = os.environ.get("MIDIVIZ_BRAND_SLUG", "parvagues")
# ── 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:
......@@ -349,6 +374,26 @@ class Theme(NamedTuple):
scan: "tuple[tuple[int, int, int, int], tuple[int, int, int, int]] | None"
bar_frac: float # value-bar height as a fraction of the cell
bold: bool # a heavier face, for a screen in daylight
# The face every digit and label is set in: "mono" (the cockpit's
# monospace stack) or "jersey" (the vendored pixel face, see JERSEY_PATH).
ui_font: str
# The eight family hues, in FAM_* order. Every theme but devcon keeps the
# ParVagues violet arc (FAM_HUE); a family still MEANS the same role
# whatever hue it wears, so a theme may re-dress it but never reorder it.
fam_hue: tuple[float, ...]
# How a cell body is made. False: `tint` is an HSV ramp, as always. True:
# the family's vivid colour laid OVER the page, `tint`'s value read as how
# much of it -- a warm hue darkened toward black is brown, the same hue
# thinned over navy stays itself (how the DevCon site layers its colour).
tint_over_page: bool
# The texture's pitch, in base px: 0 = the CRT (fine lines, a vertical
# every fourth), anything else = a square grid at that pitch -- the
# DevCon artwork's graph paper.
scan_grid: float
# The PARVAGUES wordmark (`l`) as the site's hero headline instead of the
# neon: Jersey 15, hero tracking, navy on a baked gradient plate. The
# theme decides, not --word-face, so no other page ever wears the plate.
word_plate: bool
# The dark ink ramp, sampled at levels 1..4, IS the dark surface ramp -- which
......@@ -366,7 +411,9 @@ THEMES: dict[str, Theme] = {
spec=SpecRamp(0.68, -0.055, 0.80, 0.30, 0.70, 12, 84),
spec_peak=(0xc8, 0xa8, 0xff, 90),
scan=((0xa0, 0x60, 0xff, 16), (0xa0, 0x60, 0xff, 9)),
bar_frac=0.22, bold=False,
bar_frac=0.22, bold=False, ui_font="mono",
fam_hue=FAM_HUE, tint_over_page=False, scan_grid=0.0,
word_plate=False,
),
# Indoors on a pale desktop. Still a lens, still the violet arc, still a
# decay gradient the eye can follow -- the ink simply runs the other way.
......@@ -385,7 +432,47 @@ THEMES: dict[str, Theme] = {
spec=SpecRamp(0.62, -0.05, 0.55, 0.62, -0.28, 34, 120),
spec_peak=(0x4a, 0x2a, 0x78, 110),
scan=((0x50, 0x3a, 0x78, 10), (0x50, 0x3a, 0x78, 6)),
bar_frac=0.22, bold=False,
bar_frac=0.22, bold=False, ui_font="mono",
fam_hue=FAM_HUE, tint_over_page=False, scan_grid=0.0,
word_plate=False,
),
# Direct sunlight, and built for nothing else. Near-white page, heavy ink
# at every decay level (a "dim" state outdoors is a blank state), thicker
# value bars, and no scanline texture to eat the little contrast there is.
# It is not meant to be pretty; it is meant to be the one that still works
# when the laptop is the brightest thing on a table at noon.
# The DevCon26 stage skin, v2 (PLN, 2026-09-28: "not white theme"): the
# event's OWN tokens, pulled from algolia.com/devcon's CSS -- navy page
# #0d1833, electric blue #003dff / #457aff, orange #ff7700, mint
# #07ffda (gradient #adf6ff->#46dbee->#07ffda). The artwork's corail
# (measured #fd956e, 47% of the intro loop) walks the spec wash: one
# linear hue drift, corail 21 deg down through magenta 296 to cyan --
# wrapped, see _build_palette. Family hues stay the orbit identity.
"devcon": Theme(
name="devcon", bg=(0x0d, 0x18, 0x33),
# Vivid, not grey (PLN, 2026-09-28: "a bit grey, make this pop"): the
# ink keeps its saturation to the top instead of paling out, and the
# cell bodies carry the hue at every heat step. The grey was low
# saturation at low value -- a family hue at s=0.30 on navy is mud.
ink=Ramp(0.14, 0.86, 1.10, 1.00, -0.22, 2.00),
tint=Ramp(0.10, 0.42, 1.00, 0.85, 0.00, 1.00), # page->hue mix, see tint_over_page
tint_t=_TINT_T_LINEAR,
chrome=Ramp(0.70, -0.40, 0.80, 0.25, 0.30, 1.00),
chrome_hue=216.0, # #457aff, the site's electric blue
spec=SpecRamp(0.058, -0.45, 0.85, 0.75, -0.30, 40, 140),
spec_peak=(0x07, 0xff, 0xda, 160), # the site's mint
# Pale cyan #a2edf2 hairlines, the artwork's grid on its gradient.
scan=((0xa2, 0xed, 0xf2, 13), (0xa2, 0xed, 0xf2, 13)),
# The site sets everything that is not body copy in Jersey 15, so
# every digit here is too.
bar_frac=0.34, bold=True, ui_font="jersey",
# The families in the event's colours (PLN, 2026-09-28: "DevCon
# palette"), from the site's CSS and the artwork's measured mass:
# level = electric blue #457aff, fx = corail #fd956e, fx2 = magenta
# #df96ef, gate = mint #07ffda, gate2 = cyan #46dbee, family = orange
# #ff7700, other = the chrome's blue, note = the artwork's rose.
fam_hue=(223, 16, 290, 171, 191, 28, 216, 350),
tint_over_page=True, scan_grid=14.0, word_plate=True,
),
# Direct sunlight, and built for nothing else. Near-white page, heavy ink
# at every decay level (a "dim" state outdoors is a blank state), thicker
......@@ -411,12 +498,14 @@ THEMES: dict[str, Theme] = {
spec=SpecRamp(0.60, -0.04, 0.72, 0.48, -0.26, 48, 150),
spec_peak=(0x18, 0x0c, 0x38, 190),
scan=None,
bar_frac=0.34, bold=True,
bar_frac=0.34, bold=True, ui_font="mono",
fam_hue=FAM_HUE, tint_over_page=False, scan_grid=0.0,
word_plate=False,
),
}
# Cycle order for the `d` key and the menu: darkest to brightest page, so the
# key has a direction and "one more press" means "one step sunnier".
THEME_ORDER: tuple[str, ...] = ("dark", "light", "sun")
THEME_ORDER: tuple[str, ...] = ("dark", "light", "devcon", "sun")
DEFAULT_THEME = "dark"
# What each page is FOR, for the menu only. `dark`/`light`/`sun` stay the
# vocabulary of the config file, the `--theme` flag and every test; a menu row
......@@ -425,6 +514,7 @@ THEME_MENU: dict[str, str] = {
"dark": "Dark · the cockpit, indoors",
"light": "Light · a pale desktop",
"sun": "Sun · daylight, maximum ink",
"devcon": "DevCon · the Algolia stage skin",
}
TAU = 0.85 # seconds; brightness e-folding time
......@@ -475,7 +565,8 @@ BASE_W, BASE_H = 420, 260
# (Perso/www/public/images/parvagues/logo_transparent.png, downscaled to
# 640px) because midiviz must run standalone from a checkout with no www
# tree on the box at all.
WAVE_PATH = Path(__file__).resolve().parent / "ui" / "parvagues_wave.png"
WAVE_PATH = (Path(os.environ["MIDIVIZ_WAVE"]) if os.environ.get("MIDIVIZ_WAVE")
else Path(__file__).resolve().parent / "ui" / "parvagues_wave.png")
WAVE_ALPHA = 0.12
# …and its wordmark, over D1-D4 (PLN, 2026-09-24: "PARVAGUES in nice our
# usual site font bloc letters over D1/2/3/4 alongside the wave same
......@@ -484,7 +575,27 @@ WAVE_ALPHA = 0.12
# recipe) and the pink is --neon-high. The variable font's latin subset is
# vendored out of the built site for the same standalone reason as the wave.
SYNE_PATH = Path(__file__).resolve().parent / "ui" / "syne-extrabold.ttf"
# Jersey 15 (Soft Type Project, OFL, vendored from google/fonts) — the face of
# the Algolia DevCon26 stage kit, measured, not eyed: algolia.com/devcon's CSS
# sets every headline in 'Jersey 15' (Jersey 25 was a guess, and wrong). Same
# standalone reason as Syne; `--word-face jersey` puts the wordmark in it,
# default stays the brand's Syne.
JERSEY_PATH = Path(__file__).resolve().parent / "ui" / "Jersey15-Regular.ttf"
WORD_FACES = ("syne", "jersey")
JERSEY_UI_SIZE = 1.3 # px multiplier when Jersey sets the digits
WORD_ALPHA = 0.8
# The DevCon hero, read off algolia.com/devcon (.devcon-hero__headline): Jersey
# 15 at weight 400, letter-spacing -2.72px on 136px (-2%), --devcon-navy. The
# plate is the artwork's own gradient (corail -> magenta -> cyan, measured on
# the intro loop and the header) with its pale grid, because navy type needs
# the bright page the site sets it on.
PLATE_INK = "#14283f"
PLATE_STOPS = ((0.0, "#fd956e"), (0.55, "#df96ef"), (1.0, "#46dbee"))
PLATE_GRID = (0xff, 0xff, 0xff, 46)
# Only the gradient is translucent; the letters stay solid. PLN, 2026-09-28:
# "ensure that we still see the D1-3 under the lettering" -- those three cells
# are wired controls, and a sticker that hid them would read as unwired.
PLATE_ALPHA = 0.5
# ── the HL feed ────────────────────────────────────────────────────────────
# PLN, 2026-09-24: "please do indeed sub from the events, to indeed feed
......@@ -784,7 +895,7 @@ USER_CLOSE_EXIT = 78
#
# Writes go through a temp file and `os.replace`, so a crash mid-save leaves
# the previous choice rather than the corrupt file this paragraph is about.
CONFIG_DIR = "parvagues"
CONFIG_DIR = BRAND_SLUG # one config dir per brand (see the brand block)
CONFIG_NAME = "midiviz.json"
......@@ -843,7 +954,7 @@ def save_config(theme: str, scale: float) -> bool:
def latch_close_path():
rt = os.environ.get("XDG_RUNTIME_DIR") or f"/run/user/{os.getuid()}"
return os.path.join(rt, "parvagues", f"{UNIT_NAME}.closed")
return os.path.join(rt, CONFIG_DIR, f"{UNIT_NAME}.closed")
def _latch_close():
......@@ -1113,7 +1224,8 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0,
pinned_port: str | None = None, watch: bool = False,
spectro: bool = False, theme: str = DEFAULT_THEME,
persist: bool = False, tray: bool = False,
hl: "oscfeed.HLFeed | None" = None):
hl: "oscfeed.HLFeed | None" = None,
word_face: str = "syne"):
QtCore, QtGui, QtWidgets = _qt()
Qt = QtCore.Qt
......@@ -1200,6 +1312,14 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0,
str(SYNE_PATH))
except Exception:
self._syne_id = -1
# Same deal, same non-crash rule: a checkout without Jersey loses
# only the alternate wordmark face, never the lens.
try:
self._jersey_id = QtGui.QFontDatabase.addApplicationFont(
str(JERSEY_PATH))
except Exception:
self._jersey_id = -1
self._word_face = word_face if word_face in WORD_FACES else "syne"
self._word_font = None
self._word_font_key = None
self._word_pm = None
......@@ -1226,7 +1346,7 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0,
self._ref_w = max(1, round(BASE_W * self.scale))
self._ref_h = max(1, round(BASE_H * self.scale))
self.dens = self.scale
self._metrics_key = None # (main px, micro px, bold) last measured
self._metrics_key = None # (main px, micro px, bold, face) last measured
self.setMinimumSize(MIN_W, MIN_H)
self.resize(self._ref_w, self._ref_h)
# …then read it BACK off the widget. If a minimumSize ever clamped
......@@ -1344,7 +1464,7 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0,
# LUT[family][level] — every INK colour this window can ever paint,
# so a frame constructs no QColor at all.
self.lut = []
for hue in FAM_HUE:
for hue in th.fam_hue:
col = []
for lv in range(LEVELS):
val, sat = th.ink.at(lv / (LEVELS - 1))
......@@ -1357,10 +1477,16 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0,
# separate, shallower ramp, because a panel must stay near the page
# while the ink on it walks away from it.
self.tint = []
for hue in FAM_HUE:
for hue in th.fam_hue:
row = []
for t in th.tint_t:
val, sat = th.tint.at(t)
if th.tint_over_page:
c = QtGui.QColor.fromHsvF(hue / 360.0, sat, 1.0)
row.append(QtGui.QColor(
*(round(b + (f - b) * val) for b, f in
zip(th.bg, (c.red(), c.green(), c.blue())))))
else:
row.append(QtGui.QColor.fromHsvF(hue / 360.0, sat, val))
self.tint.append(row)
# Chrome (border, row letters, CC reference digits, the two header
......@@ -1383,7 +1509,11 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0,
# else, so a spectrum frame constructs no QColor.
sp = th.spec
self.spec_lut = [
QtGui.QColor.fromHsvF(sp.h0 + sp.hspan * (lv / (SPEC_LEVELS - 1)),
# The hue drift wraps (`% 1.0`): a theme may walk the wheel
# through 0 — DevCon26's does, corail 21 deg descending to
# cyan past magenta — and fromHsvF rejects a negative hue.
# Identity on every theme that never crosses zero.
QtGui.QColor.fromHsvF((sp.h0 + sp.hspan * (lv / (SPEC_LEVELS - 1))) % 1.0,
sp.sat, sp.v0 + sp.vspan * (lv / (SPEC_LEVELS - 1)),
(sp.a0 + sp.aspan * (lv / (SPEC_LEVELS - 1))) / 255.0)
for lv in range(SPEC_LEVELS)
......@@ -1401,10 +1531,10 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0,
th = THEMES.get(name)
if th is None or th is self.theme:
return
was_bold = self.theme.bold
was_face = (self.theme.bold, self.theme.ui_font)
self.theme = th
self._build_palette()
if th.bold != was_bold:
if (th.bold, th.ui_font) != was_face:
self._apply_scale() # new weight, new metrics, new layout
if getattr(self, "tray", None) is not None:
# The tray icon is painted from the palette, so it goes stale
......@@ -1429,6 +1559,21 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0,
save_config(self.theme.name, self.scale)
def _font(self, px: int):
if self.theme.ui_font == "jersey" and self._jersey_id >= 0:
fams = QtGui.QFontDatabase.applicationFontFamilies(self._jersey_id)
if fams:
f = QtGui.QFont(fams[0])
# Jersey's caps stand shorter in their em than the mono's,
# so the same pixel size reads a size smaller.
f.setPixelSize(max(5, round(px * JERSEY_UI_SIZE)))
# Tabular figures, so every digit shares one advance. A-F
# still do not (see `_paint_stream`). And no bold: Jersey
# ships a Regular only, and a synthesized bold smears the
# pixel grid that is the whole point of the face.
f.setFeature(QtGui.QFont.Tag("tnum"), 1)
return f
# No Jersey on this checkout (id -1): the mono stack, silently,
# the same non-crash rule as the wordmark.
f = QtGui.QFont()
f.setFamilies(["Geist Mono", "JetBrains Mono", "Fira Code",
"DejaVu Sans Mono", "monospace"])
......@@ -1470,7 +1615,7 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0,
"""
self.dens = d = self._density()
px, upx = max(5, round(11 * d)), max(5, round(8 * d))
key = (px, upx, self.theme.bold)
key = (px, upx, self.theme.bold, self.theme.ui_font)
if key != self._metrics_key:
# Re-measured only when the FACE actually changed. The weight
# belongs in that key because `set_theme` calls this precisely
......@@ -1481,7 +1626,11 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0,
self.f_micro = self._font(upx)
fm = QtGui.QFontMetricsF(self.f_main)
fmm = QtGui.QFontMetricsF(self.f_micro)
self.mw, self.mh = fm.horizontalAdvance("0"), fm.height()
# The WIDEST hex glyph is the ribbon's grid step; on a
# monospaced face they are all the same and this is "0".
self.mw = max(fm.horizontalAdvance(c) for c in HEX)
self.mh = fm.height()
self._hex_mono = len({fm.horizontalAdvance(c) for c in HEX}) == 1
self.uw, self.uh = fmm.horizontalAdvance("0"), fmm.height()
self.m_asc, self.u_asc = fm.ascent(), fmm.ascent()
self._relayout()
......@@ -1538,13 +1687,14 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0,
pm = QtGui.QPixmap(w, h)
pm.fill(QtGui.QColor(0, 0, 0, 0))
p = QtGui.QPainter(pm)
step = max(2, round(3 * self.dens))
grid = self.theme.scan_grid
step = max(2, round((grid or 3) * self.dens))
hz, vt = self.theme.scan
p.setPen(QtGui.QColor(*hz))
for y in range(0, h, step):
p.drawLine(0, y, w, y)
p.setPen(QtGui.QColor(*vt))
for x in range(0, w, step * 4):
for x in range(0, w, step if grid else step * 4):
p.drawLine(x, 0, x, h)
p.end()
self._scan = pm
......@@ -1729,17 +1879,20 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0,
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))
plate = self.theme.word_plate
face = "jersey" if plate else self._word_face
key = (face, plate, round(target_w), round(target_h))
if self._word_font_key == key:
return self._word_font
fam_id = self._syne_id
fam_id = self._jersey_id if face == "jersey" else self._syne_id
if fam_id < 0:
return None
fam = QtGui.QFontDatabase.applicationFontFamilies(fam_id)[0]
f = QtGui.QFont(fam)
f.setPixelSize(max(6, target_h * 0.92))
f.setLetterSpacing(QtGui.QFont.SpacingType.PercentageSpacing,
106.0) # +0.06em, as the hero tracks
98.0 if plate # -2%, the DevCon hero
else 106.0) # +0.06em, as the hero tracks
self._word_font, self._word_font_key = f, key
return f
......@@ -1747,7 +1900,7 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0,
"""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"
word = WORDMARK # the brand's block lettering, one glyph at a time
qp.setPen(pen)
x = float(pad)
for c in word:
......@@ -1782,14 +1935,16 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0,
PARVAGU"), and it must FIT the D1 cell at the cell's margin —
which means the TYPE shrinks to the width, honestly, rather than
a squash that would make the letters strangers to themselves."""
key = (round(span_w), round(span_h))
plate = self.theme.word_plate
key = (round(span_w), round(span_h), plate)
if self._word_pm_key == key:
return self._word_pm
f = self._wave_font(span_w, span_h)
if f is None:
return None
ink_col = QtGui.QColor(PLATE_INK if plate else "#d900ff")
def bake(px, canvas_w, glow=True):
def bake(px, canvas_w, glow=not plate):
fb = QtGui.QFont(f)
fb.setPixelSize(max(4, px))
fmb = QtGui.QFontMetricsF(fb)
......@@ -1815,9 +1970,10 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0,
qp.translate(math.cos(a) * rad, math.sin(a) * rad)
self._draw_word_layer(qp, base, pad_b, fmb, glow_pen)
qp.restore()
# the core: --neon-high itself, the site's own pink
# the core: --neon-high itself, the site's own pink (or,
# on the plate, the DevCon navy -- pure, no glow)
self._draw_word_layer(qp, base, pad_b, fmb,
QtGui.QPen(QtGui.QColor("#d900ff")))
QtGui.QPen(ink_col))
finally:
qp.end()
return img, pad_b
......@@ -1837,7 +1993,14 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0,
px = max(4.0, px * fit / word_w)
img, pad = bake(px, span_w + int(px * 2))
else:
img, pad = bake(px, word_w + pad * 2)
# pad1, not pad: pad is not bound on this path. It went unseen
# behind PARVAGUES (long enough to always take the fit branch
# at every geometry the selftest tries) — a SHORTER wordmark,
# i.e. exactly what the brand override invites, lands here.
# pad1 came from the probe bake at the same px, and bake's
# margin is a pure function of px, so it is the same number
# this bake is about to use.
img, pad = bake(px, word_w + pad1 * 2)
# crop to the true ink so the margins are the cell's, no more
left = 0
w_ = img.width()
......@@ -1850,9 +2013,38 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0,
min(w_, right - left + 1 + pad),
img.height())
pm = QtGui.QPixmap.fromImage(cropped)
if plate:
pm = self._plate_pixmap(span_w, span_h, pm)
self._word_pm, self._word_pm_key = pm, key
return pm
def _plate_pixmap(self, span_w, span_h, word_pm):
"""The DevCon hero as one baked picture: gradient, grid, word.
Built once per geometry like the word itself, so a frame is still
one blit and constructs no gradient."""
pw, ph = max(1, round(span_w)), max(1, round(span_h))
pm = QtGui.QPixmap(pw, ph)
pm.fill(QtGui.QColor(0, 0, 0, 0))
qp = QtGui.QPainter(pm)
try:
g = QtGui.QLinearGradient(0, 0, pw, ph)
for at, col in PLATE_STOPS:
g.setColorAt(at, QtGui.QColor(col))
qp.setOpacity(PLATE_ALPHA)
qp.fillRect(0, 0, pw, ph, QtGui.QBrush(g))
qp.setPen(QtGui.QColor(*PLATE_GRID))
step = max(4, round(ph / 5))
for x in range(step, pw, step):
qp.drawLine(x, 0, x, ph)
for y in range(step, ph, step):
qp.drawLine(0, y, pw, y)
qp.setOpacity(1.0)
qp.drawPixmap(round((pw - word_pm.width()) / 2.0),
round((ph - word_pm.height()) / 2.0), word_pm)
finally:
qp.end()
return pm
def _col_has_ink(self, img, x):
return any(img.pixelColor(x, y).alpha() > 8
for y in range(img.height()))
......@@ -1868,7 +2060,8 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0,
if pm is None:
return
row_y = self.my + 3 * self.ch # PHYSICAL_ORDER index of D
p.setOpacity(WORD_ALPHA)
# the plate carries its own alpha (see PLATE_ALPHA)
p.setOpacity(1.0 if self.theme.word_plate else WORD_ALPHA)
try:
# clamped to the span's outer rect: at the smallest windows
# the glow ring can reach 1px past the inner margin, and a
......@@ -2545,9 +2738,8 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0,
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
call — which is legitimate only for hex glyphs, and only on a
monospaced face, where they all advance exactly `self.mw`. 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.
"""
......@@ -2561,7 +2753,9 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0,
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
# Runs need every hex glyph at exactly `mw`; a proportional
# face (Jersey's A-F) gets one call per glyph instead.
key = (fam, lv) if g in HEX and self._hex_mono else None
if run and key == run_key:
run.append(g)
else:
......@@ -2738,7 +2932,7 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0,
# on black read as the two different pages they are.
for i, fam in enumerate((FAM_LEVEL, FAM_FX, FAM_NOTE)):
val, sat = th.ink.at(1.0 - i * 0.30)
p.setBrush(QtGui.QColor.fromHsvF(FAM_HUE[fam] / 360.0, sat, val))
p.setBrush(QtGui.QColor.fromHsvF(th.fam_hue[fam] / 360.0, sat, val))
p.drawRect(6, 5 + i * 9, n - 13 - i * 4, 6)
edge = th.chrome or th.ink
val, sat = edge.at(0.55)
......@@ -2788,7 +2982,7 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0,
grp.addAction(a)
m.addAction(a)
self._theme_acts[name] = a
self._act_cycle = QtGui.QAction("Next page\td", self)
self._act_cycle = QtGui.QAction("Next theme\td", self)
self._act_cycle.triggered.connect(lambda _c=False: self.cycle_theme())
m.addAction(self._act_cycle)
......@@ -2831,15 +3025,15 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0,
self._act_spec.setCheckable(True)
self._act_spec.triggered.connect(lambda _c=False: self.toggle_spectro())
m.addAction(self._act_spec)
self._act_hl = QtGui.QAction("Tidal HL feed\th", self)
self._act_hl = QtGui.QAction("Tidal orbit feed\th", self)
self._act_hl.setCheckable(True)
self._act_hl.triggered.connect(lambda _c=False: self.toggle_hl())
m.addAction(self._act_hl)
self._act_wave = QtGui.QAction("ParVagues wave\tw", self)
self._act_wave = QtGui.QAction(f"{BRAND} wave\tw", self)
self._act_wave.setCheckable(True)
self._act_wave.triggered.connect(lambda _c=False: self.toggle_wave())
m.addAction(self._act_wave)
self._act_word = QtGui.QAction("PARVAGUES lettering\tl", self)
self._act_word = QtGui.QAction(f"{WORDMARK} lettering\tl", self)
self._act_word.setCheckable(True)
self._act_word.triggered.connect(lambda _c=False: self.toggle_word())
m.addAction(self._act_word)
......@@ -3443,7 +3637,9 @@ def selftest_config() -> tuple[bool, str]:
def selftest(seconds: float = 3.0, show: bool = False,
theme: str = DEFAULT_THEME) -> int:
theme: str = DEFAULT_THEME, shot_dir: str | None = None) -> int:
if shot_dir:
os.makedirs(shot_dir, exist_ok=True) # img.save() no-ops on a missing dir
if not show:
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
os.environ.setdefault("QT_LOGGING_RULES", "qt.qpa.*=false")
......@@ -3516,7 +3712,30 @@ def selftest(seconds: float = 3.0, show: bool = False,
w.resize(sw, sh)
app.processEvents()
w.tick()
# Re-feed before every grab: the decay is seconds-long and the loop
# above already spent most of them, so a picture taken now would show
# the resting state rather than the lens working.
for ev in parsed[:24]:
w.ingest(ev)
app.processEvents()
img = w.grab().toImage()
if shot_dir and (sw, sh) == (1600, 1000):
img.save(os.path.join(shot_dir, "hero-%s.png" % w.theme.name))
# The brand layer is OFF by default (it is atmosphere, and the
# default lens must be pure measurement), so a screenshot set that
# never turns it on would fail to document it. Enable once here;
# every theme grab after this carries the watermark too.
if not w.wave:
w.toggle_wave()
if not w.word:
w.toggle_word()
app.processEvents()
for ev in parsed[:24]:
w.ingest(ev)
w.tick()
app.processEvents()
w.grab().toImage().save(
os.path.join(shot_dir, "hero-branded-%s.png" % w.theme.name))
shapes["%dx%d" % (sw, sh)] = round(w.dens, 2)
if img.width() != sw or img.height() != sh:
shapes_ok = False
......@@ -3540,8 +3759,12 @@ def selftest(seconds: float = 3.0, show: bool = False,
QtCore.Qt.Key.Key_D,
QtCore.Qt.KeyboardModifier.NoModifier))
w.tick()
for ev in parsed[:24]:
w.ingest(ev)
app.processEvents()
img = w.grab().toImage()
if shot_dir:
img.save(os.path.join(shot_dir, "theme-%s.png" % w.theme.name))
sx, sy = max(1, img.width() // 12), max(1, img.height() // 12)
seen = {img.pixel(xx, yy)
for yy in range(0, img.height(), sy)
......@@ -3592,9 +3815,12 @@ def main(argv=None) -> int:
help="initial scale, 0.6-2.4 (also +/- at runtime; "
"remembered between runs)")
ap.add_argument("--theme", choices=sorted(THEMES), default=None,
help="palette: dark (the cockpit), light (pale desktop), "
help="theme: dark (the cockpit), light (pale desktop), "
"sun (maximum contrast, for playing outdoors); "
"'d' cycles at runtime and the choice is remembered")
ap.add_argument("--word-face", choices=WORD_FACES, default="syne",
help="face of the PARVAGUES wordmark; jersey is the "
"DevCon26 stage face")
ap.add_argument("--no-tray", action="store_true",
help="skip the system-tray presence even where one exists")
ap.add_argument("--spectro", action="store_true",
......@@ -3603,11 +3829,14 @@ def main(argv=None) -> int:
ap.add_argument("--spectro-target",
help="capture this node instead of the default sink")
ap.add_argument("--no-hl", action="store_true",
help="do not subscribe to the Tidal HL feed (SuperDirt "
help="do not subscribe to the Tidal orbit feed (SuperDirt "
"mirror on port %d; 'h' toggles it at runtime)"
% oscfeed.VIZ_PORT)
ap.add_argument("--selftest", action="store_true",
help="render synthetic events for --seconds, then exit")
ap.add_argument("--screenshot", metavar="DIR",
help="with --selftest: save rendered frames (one per "
"theme, plus a large hero shot) as PNGs into DIR")
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")
......@@ -3619,7 +3848,8 @@ def main(argv=None) -> int:
print("-> %s" % (resolve_watch_port()[1],))
return 0
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,
shot_dir=a.screenshot)
# PLN, 2026-09-24: "do ensure never more than one instance, midiviz
# should check unique else restart not duplicate". flock on a runtime
......@@ -3628,14 +3858,14 @@ def main(argv=None) -> int:
# 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"
lock_path = Path(rt) / CONFIG_DIR / "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 "
print("midiviz: already running, refusing to duplicate "
"(focus the existing window)", file=sys.stderr)
try:
import launchers
......@@ -3665,7 +3895,7 @@ def main(argv=None) -> int:
# The theme joins it for the same reason: a `--theme` that lost to a saved
# one, or a saved one that lost to a corrupt file, is otherwise a silent
# surprise, and the window has no words to explain itself with.
print("⚓ midiviz — %s · theme %s · %.2f× · q/Esc/Ctrl-C to quit · right-click for the menu"
print("⚓ midiviz · %s · theme %s · %.2f× · q/Esc/Ctrl-C to quit · right-click for the menu"
% (label or "port %s" % port, theme, scale), file=sys.stderr)
# A blind `aseqdump` (no -p) subscribes to NOTHING -- see the
# WATCH_PREFERENCE comment above -- so start a Reader only once a real
......@@ -3692,7 +3922,8 @@ def main(argv=None) -> int:
app.setQuitOnLastWindowClosed(False)
w = build_widget(port or "--", reader, scale=scale,
pinned_port=pinned, watch=True, theme=theme,
persist=True, tray=not a.no_tray, hl=hl)
persist=True, tray=not a.no_tray, hl=hl,
word_face=a.word_face)
if a.spectro:
src = None
if a.spectro_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