Commit ec613576 by PLN (Algolia)

midiviz: the set itself paints the lens — Tidal HL feed + the ParVagues wave

BootTidal.hs mirrors every superdirtShape packet to port 57130, oscfeed
listens, and a trigger on orbit N lights that orbit's column and the cells
that affect it — family filters and mutes included. The edge is a ring, not
a fill: it says 'the set just played through me', never a value, never a
touch. The wave (brand magenta, vendored from the www SSOT asset) sits
behind everything on 'w'; the feed toggles on 'h' and is on by default.
Column glow keeps its 30% slower curve.
parent b68e343e
......@@ -47,9 +47,18 @@ editorShape = OSCContext "/editor/highlights"
-- SuperDirt target
superdirtTarget' = superdirtTarget {oLatency = 0.1, oAddress = "127.0.0.1", oPort = 57120}
-- midiviz mirror target (2026-09-24): the SAME /play packets SuperDirt
-- receives, replayed to a port midiviz owns (tools/bridge/oscfeed.py). This
-- is the HL feed: what the MUSIC is doing — which orbit triggered, with
-- which values — as opposed to the MIDI feed, which only says what the
-- SURFACE is doing. Fire-and-forget UDP: when midiviz is not listening the
-- datagrams vanish, and the audio path never notices.
midivizTarget = superdirtTarget {oName = "ParVaguesMidiviz", oLatency = 0.1, oAddress = "127.0.0.1", oPort = 57130}
-- Set up targets for SuperDirt, DPV, metadata, AND editor highlighting
oscmapdpv = [(targetdpv, formatsdpv),
(superdirtTarget', [superdirtShape]),
(midivizTarget, [superdirtShape]), -- the HL mirror for midiviz
(metadataTarget, []),
(editorTarget, [editorShape]) -- Added editor highlighting
]
......
......@@ -2,7 +2,7 @@
{
"date": "2026-09-17",
"task": "GUI launcher did not spawn the MIDI lens or the tray",
"learning": "The launcher (gig-up.sh) and the unit converger (rig_units.py --ensure) disagreed on SuperDirt topology \u2014 raw sclang vs parvagues-sc.service \u2014 so the launcher never called ensure; the fix is ensure()'s proof-process skip, not another hand-copied unit list."
"learning": "The launcher (gig-up.sh) and the unit converger (rig_units.py --ensure) disagreed on SuperDirt topology — raw sclang vs parvagues-sc.service — so the launcher never called ensure; the fix is ensure()'s proof-process skip, not another hand-copied unit list."
},
{
"date": "2026-09-17",
......@@ -12,16 +12,16 @@
{
"date": "2026-09-18",
"task": "tray GIG UP did nothing + midimon silent",
"learning": "The Bridge ran 'gig-up.sh --converge --quiet' but gig-up.sh's arg guard only knew --headphones/--help, so every tray GIG UP died on flag 1 (exit 2, output captured). Flags now parsed: --converge boots SuperDirt + converges units with NO app windows, --quiet mutes ok/info. midimon watches 132:0 (the lcxl3-driver's translated virtual port), not the raw surface \u2014 silence there just means the driver had no input yet."
"learning": "The Bridge ran 'gig-up.sh --converge --quiet' but gig-up.sh's arg guard only knew --headphones/--help, so every tray GIG UP died on flag 1 (exit 2, output captured). Flags now parsed: --converge boots SuperDirt + converges units with NO app windows, --quiet mutes ok/info. midimon watches 132:0 (the lcxl3-driver's translated virtual port), not the raw surface — silence there just means the driver had no input yet."
},
{
"learning": "Two gig-up.sh exist: root (1-click, owns leds()) and tools/ (the GO/NO-GO gate the Bridge calls, where lcxl3-driver is boot=manual so nobody starts it). The v3 LCXL3 enumerates as 'LCXL3 1', breaking every v2 'Launch Control XL' grep \u2014 present/LEDs/path checks all silently misread. lcxl-path must match the driver's virtual PORT name 'ParVagues LCXL3' (under RtMidiOut client), never chase the PipeWire relay mesh (everything reaches everything via 143)."
"learning": "Two gig-up.sh exist: root (1-click, owns leds()) and tools/ (the GO/NO-GO gate the Bridge calls, where lcxl3-driver is boot=manual so nobody starts it). The v3 LCXL3 enumerates as 'LCXL3 1', breaking every v2 'Launch Control XL' grep — present/LEDs/path checks all silently misread. lcxl-path must match the driver's virtual PORT name 'ParVagues LCXL3' (under RtMidiOut client), never chase the PipeWire relay mesh (everything reaches everything via 143)."
},
{
"learning": "WirePlumber 0.4.17 ignores wireplumber.conf.d SPA-JSON rules (0.5 syntax) \u2014 this box reads Lua from main.lua.d only, and a partial alsa_monitor.rules reassignment clobbers the built-in table by index (dropped the card to profile off). Every wireplumber restart tears down Ardour's JACK client; recovery needed a full Ardour relaunch. pactl/wireplumber restart restored the missing HiFi profile after a UCM probe failure (card showed only off/pro-audio)."
"learning": "WirePlumber 0.4.17 ignores wireplumber.conf.d SPA-JSON rules (0.5 syntax) — this box reads Lua from main.lua.d only, and a partial alsa_monitor.rules reassignment clobbers the built-in table by index (dropped the card to profile off). Every wireplumber restart tears down Ardour's JACK client; recovery needed a full Ardour relaunch. pactl/wireplumber restart restored the missing HiFi profile after a UCM probe failure (card showed only off/pro-audio)."
},
{
"learning": "A generated boot file is only as good as the list it is generated FROM. gig-up.sh rewrote preload.scd on every launch from a default naming the PREVIOUS gig (setlist_opal2026.txt): measured 132 banks -> 45, leaving 41 of Thursday's banks to be read off disk mid-set \u2014 the exact 'crackle at the venue' failure the check was built for, re-armed by a stale default. Two structural lessons. (1) Name the set ONCE and have every consumer read that; the plan generator and the gate disagreeing about 'the set' is the whole bug. (2) An unconditional write is destructive in the quiet direction: the plan in place was a deliberate 132-bank UNION and regeneration could only narrow it, so gen_preload now asks check-preload first and leaves a covering plan alone. Corollary from the same pass: merging two tools that share a name means diffing what they DO, not just what they check \u2014 the root launcher span raw sclang while the other started parvagues-sc.service, so repointing the Bridge at 'one launcher' would have silently taken it off systemd (losing rlimits, the journal, supervision and OOM protection)."
"learning": "A generated boot file is only as good as the list it is generated FROM. gig-up.sh rewrote preload.scd on every launch from a default naming the PREVIOUS gig (setlist_opal2026.txt): measured 132 banks -> 45, leaving 41 of Thursday's banks to be read off disk mid-set — the exact 'crackle at the venue' failure the check was built for, re-armed by a stale default. Two structural lessons. (1) Name the set ONCE and have every consumer read that; the plan generator and the gate disagreeing about 'the set' is the whole bug. (2) An unconditional write is destructive in the quiet direction: the plan in place was a deliberate 132-bank UNION and regeneration could only narrow it, so gen_preload now asks check-preload first and leaves a covering plan alone. Corollary from the same pass: merging two tools that share a name means diffing what they DO, not just what they check — the root launcher span raw sclang while the other started parvagues-sc.service, so repointing the Bridge at 'one launcher' would have silently taken it off systemd (losing rlimits, the journal, supervision and OOM protection)."
},
{
"learning": "A launcher's exit code cannot tell 'it worked' from 'it did nothing'. gig-up.sh --help prints its usage and exits 0, and the window wrapper closed on that zero exactly as it closes on a good launch -- so the rig had never once been started from the icon, and the only evidence was the LCXL staying dark. The log had been written faithfully all along and held nothing but the usage text. Structural lesson: for any wrapper that closes on success, the success test must be a POSITIVE marker the wrapped thing prints on its real path ('gig-up done'), never the absence of an error. And hold the coupling from both ends in tests, because the day someone rewords that last line, every real launch starts reporting a failure."
......@@ -56,5 +56,10 @@
"date": "2026-09-23",
"task": "stale unit builds",
"learning": "A systemd unit holds whatever the interpreter read at ExecStart, so from outside a unit running three-day-old code is indistinguishable from a healthy one: same active(running), same journal. A pass was lost debugging a midiviz that predated the fixes being debugged. The only honest test is the unit's ActiveEnterTimestamp against its ExecStart file's mtime -- deliberately NOT a git comparison, because the question is 'is the process executing what I can read', so an uncommitted edit counts."
},
{
"date": "2026-09-24",
"task": "midiviz: Tidal HL feed + ParVagues wave backdrop, column glow slowed 30%",
"learning": "Tidal already ships 4 OSC targets in BootTidal.hs — mirroring superdirtShape to a 5th port (oscfeed.VIZ_PORT 57130) gives the HL trigger feed without touching SuperDirt's port; and an OSC name/value fold must compare str(k+1) keys, not int, or every named arg silently vanishes."
}
]
]
\ No newline at end of file
......@@ -105,6 +105,7 @@ for _p in (str(HERE), str(TOOLS)):
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
import oscfeed # noqa: E402 the Tidal HL feed (SuperDirt mirror)
# ── which port to watch ────────────────────────────────────────────────────
# `midimon.resolve_port()` OWNS this decision and this file delegates to it. Two
......@@ -455,6 +456,31 @@ MAX_DROPS = 32
DROP_EVERY = 0.2
BASE_W, BASE_H = 420, 260
# ── the two backdrops that are pictures, not measurements ─────────────────
# PLN, 2026-09-24: "can we do a test (option menu toggle) with ParVagues
# imagery (transparent parvagues wave) behind the midimon to style it
# definitely ParVagues-y?"
#
# The wave is the brand mark itself — the Hokusai curl drawn in binary — and
# brand magenta is a RESERVED colour in the Ship's Bridge design language,
# which is exactly why it works here: at a wash this faint it is an
# atmosphere, not a second reading competing with the cells. Vendored from
# the SSOT asset (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_ALPHA = 0.13
# ── the HL feed ────────────────────────────────────────────────────────────
# PLN, 2026-09-24: "please do indeed sub from the events, to indeed feed
# into each cell related, so people get intuitively what controls affect".
# Tidal → SuperDirt /play packets, mirrored by BootTidal.hs to oscfeed's
# port, say which ORBIT the music is triggering right now. The MIDI feed
# says what the surface is doing; this says what the set is doing. On by
# 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
# ── 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
# bigger, and have it you know squeeze and expand properly as we make it taller
......@@ -1071,7 +1097,8 @@ class SpectrumSource:
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):
persist: bool = False, tray: bool = False,
hl: "oscfeed.HLFeed | None" = None):
QtCore, QtGui, QtWidgets = _qt()
Qt = QtCore.Qt
......@@ -1135,6 +1162,17 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0,
self._bucket_t = self.t0
self._bucket_n = 0
# The HL feed: state mirrors of the two things a Tidal trigger
# lights up. `hl_t` is per-CC last-trigger (an edge/border heat,
# kept SEPARATE from self.cc's touch heat on purpose — a cell that
# flashes because the set played through it must not also claim
# you touched it, or the two questions blur into one lie).
self.hl = hl
self.hl_t: dict[int, float] = {}
self._hl_cells_cache: dict[int, list[int]] = {}
self.wave = False # the ParVagues wave, behind all
self._wave_pm: QtGui.QPixmap | None = None
# The backdrop. `spec_src` is None until somebody asks: OFF means
# no thread and no subprocess, not a hidden one.
self.spectro = False
......@@ -1563,6 +1601,101 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0,
self.drops.append([self._rng.randrange(0, 3), 0.0,
time.monotonic(), glyph, fam])
# ── the HL feed ────────────────────────────────────────────────────
# One Tidal trigger lights exactly two things, and each answers one
# question: the COLUMN glow (col_t) answers "is this orbit playing",
# and the cell edges (hl_t) answer "which of these controls does the
# set actually pass through". The MIDI feed could answer neither —
# a knob at rest in a live pattern looks identical to a knob nobody
# uses, which is the confusion this exists to end.
def _hl_cells(self, orbit: int) -> list[int]:
"""The CCs that AFFECT this orbit: everything it owns, column by
the grid's law (COLUMN N IS ORBIT N), plus its two family
controls — a gF sweep and a gM mute act on every trigger in the
family, so a picture that omitted them would understate what is
in play. Derived from the grid, never re-hardcoded here.
"""
cached = self._hl_cells_cache.get(orbit)
if cached is not None:
return cached
ccs = set(grid.orbit_home(orbit).values())
for role, fam_of in (("family_filter", grid.filter_family),
("family_mute", grid.mute_family)):
n = fam_of(orbit)
for cc, (r, who) in grid.CC_ROLE.items():
if r == role and who == n:
ccs.add(cc)
out = sorted(ccs)
self._hl_cells_cache[orbit] = out
return out
def ingest_hl(self, evs: list[dict]) -> None:
now = time.monotonic()
self.last_event_t = now # the set PLAYING is activity: keep
self.ingested += len(evs) # the window at the active frame rate
self._bucket_n += len(evs)
for ev in evs:
o = ev["orbit"]
if 1 <= o <= 8:
self.col_t[o] = now
for cc in self._hl_cells(o):
self.hl_t[cc] = now
def set_hl(self, on: bool):
"""Acquire or release the HL listener. Off genuinely tears the
socket down — same contract as the spectro tap: OFF is no thread,
not a hidden one."""
on = bool(on)
if on == (self.hl is not None):
return
if on:
self.hl = oscfeed.HLFeed().start()
else:
feed, self.hl = self.hl, None
if feed is not None:
feed.close()
self.hl_t.clear()
self._sync_menu()
self.update()
def toggle_hl(self):
self.set_hl(self.hl is None)
# ── the wave ───────────────────────────────────────────────────────
def set_wave(self, on: bool):
self.wave = bool(on)
self._sync_menu()
self.update()
def toggle_wave(self):
self.set_wave(not self.wave)
def _wave_pixmap(self):
if self._wave_pm is None:
pm = QtGui.QPixmap(str(WAVE_PATH))
if pm.isNull(): # a checkout without the asset draws
return None # without the wave, never crashes
self._wave_pm = pm
return self._wave_pm
def _paint_wave(self, p, w, h):
pm = self._wave_pixmap()
if pm is None:
return
side = int(h * 0.92)
scaled = pm.scaled(side, side,
Qt.AspectRatioMode.KeepAspectRatio,
Qt.TransformationMode.SmoothTransformation)
p.setOpacity(WAVE_ALPHA)
try:
# Bottom-right, under the matrix and out of the header's way:
# the reading lives top-left, the brand lives where the eye
# rests. Painted between the background and everything else —
# the ordering IS the feature, same as the spectrum's.
p.drawPixmap(w - scaled.width(), h - scaled.height(), scaled)
finally:
p.setOpacity(1.0)
# ── frame ──────────────────────────────────────────────────────────
def tick(self):
if _QUIT.is_set():
......@@ -1577,6 +1710,11 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0,
elif evs:
self.last_event_t = now # stay awake while paused but fed
if self.hl is not None:
hevs = self.hl.drain()
if hevs and not self.paused:
self.ingest_hl(hevs)
if now - self._bucket_t >= 0.1:
self.hist.append(self._bucket_n)
self._bucket_n = 0
......@@ -1703,6 +1841,8 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0,
# ordering IS the feature: PLN asked for it "behind".
if self.spectro:
self._paint_spectrum(p, w, h)
if self.wave:
self._paint_wave(p, w, h)
self._paint_chrome(p, w, h, pulse)
self._paint_header(p, now, pulse)
self._paint_controls(p)
......@@ -1978,6 +2118,18 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0,
p.setPen(self.lut[(fam + chn) % 8][lv])
p.drawText(x + 2, y + self.m_asc,
("%02X" % v) if two else HEX[v >> 3])
# The HL edge, LAST so nothing paints over it: a brief
# border at this control's own family hue, decaying on the
# cells' shared curve. It says "the set just played through
# me" — never a value, never a touch, which is why it is a
# ring and not a fill: a fill would lie about either.
ht = self.hl_t.get(cc)
if ht is not None:
hlv = self._lv(now - ht)
if hlv > 0:
p.setPen(self.lut[fam][hlv])
p.setBrush(Qt.BrushStyle.NoBrush)
p.drawRect(x, y, max(1, cwi - 1), max(1, chi - 1))
# ── state, not events ──────────────────────────────────────────────
# The whole surface now, not just the three knobs the first slice drew.
......@@ -2220,6 +2372,10 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0,
self.toggle_pause()
elif k == Qt.Key.Key_S:
self.toggle_spectro()
elif k == Qt.Key.Key_H:
self.toggle_hl()
elif k == Qt.Key.Key_W:
self.toggle_wave()
elif k == Qt.Key.Key_D:
self.cycle_theme()
elif k == Qt.Key.Key_C:
......@@ -2389,6 +2545,14 @@ 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.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.setCheckable(True)
self._act_wave.triggered.connect(lambda _c=False: self.toggle_wave())
m.addAction(self._act_wave)
self._act_pin = QtGui.QAction("Always on top\tt", self)
self._act_pin.setCheckable(True)
self._act_pin.triggered.connect(
......@@ -2444,6 +2608,8 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0,
act.setChecked(abs(s - self.scale) < 1e-9)
self._act_pause.setChecked(self.paused)
self._act_spec.setChecked(self.spectro)
self._act_hl.setChecked(self.hl is not None)
self._act_wave.setChecked(self.wave)
self._act_pin.setChecked(self.on_top)
self._act_show.setChecked(self.isVisible())
......@@ -2775,6 +2941,11 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0,
self.rebind_timer.stop()
if self.reader:
self.reader.close()
# The HL listener is a bound socket; like the spectro tap below,
# OFF at close must mean closed, not orphaned.
if self.hl is not None:
self.hl.close()
self.hl = None
# Hand the audio tap back before anything else. A closed window
# that left a `pw-record` on the master bus would be exactly the
# "always-on consumer" this feature promised not to become, and it
......@@ -3135,6 +3306,10 @@ def main(argv=None) -> int:
"(OFF by default; 's' toggles it at runtime)")
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 "
"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("--seconds", type=float, default=3.0)
......@@ -3173,6 +3348,9 @@ def main(argv=None) -> int:
# port resolved. Otherwise the window opens idle and the RECONNECT_MS
# rebind tick picks up the surface the moment it (or its driver) appears.
reader = Reader(port).start() if port else None
# The HL listener: on unless told off. A datagram aimed at a dead port
# vanishes, so Tidal never cares whether anyone is listening.
hl = None if a.no_hl else oscfeed.HLFeed().start()
_QtCore, _QtGui, QtWidgets = _qt()
app = QtWidgets.QApplication.instance() or QtWidgets.QApplication(sys.argv[:1])
......@@ -3190,7 +3368,7 @@ 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)
persist=True, tray=not a.no_tray, hl=hl)
if a.spectro:
src = None
if a.spectro_target:
......@@ -3202,6 +3380,9 @@ def main(argv=None) -> int:
rc = app.exec()
if w.reader is not None:
w.reader.close()
if getattr(w, "hl", None) is not None:
w.hl.close()
w.hl = None
if getattr(w, "spec_src", None) is not None:
w.spec_src.close()
# A deliberate close reports itself, so the unit does not undo it. Only
......
#!/usr/bin/env python3
"""oscfeed — the Tidal HL feed: SuperDirt's own /play packets, snoopable.
Tidal does not talk to SuperDirt alone. `BootTidal.hs` mirrors every
`superdirtShape` packet to a second target (port `VIZ_PORT`), so anything may
listen to what the music is DOING — which orbit triggered, with which values —
without touching the audio path, the way the Pulsar editor already reads its
own highlight feed. This module is that listener for midiviz.
Two halves, both importable without Qt or ALSA:
* `decode_osc` — the smallest OSC decoder the packets need. Pure function,
unit-tested against hand-built bytes.
* `HLFeed` — a UDP daemon thread with the same shape as midiviz's `Reader`
(bounded deque, drain, close): a live monitor wants the PRESENT, not a
backlog, so the same lesson as `midistream.DEPTH` applies.
"""
from __future__ import annotations
import socket
import struct
import threading
import time
from collections import deque
# BootTidal.hs sends the mirror here. High enough to dodge everything
# scsynth/sclang grab by default, referenced by nothing else in the repo.
VIZ_PORT = 57130
# Tidal's orbits are 0-based (`orbit: 0` is d1) and the rig runs 14 of them.
MIN_ORBIT, MAX_ORBIT = 1, 14
def _skip_pad(data: bytes, i: int) -> int:
"""OSC blobs are 4-aligned; return the index past the padding."""
return i + ((4 - (i % 4)) % 4)
def _read_string(data: bytes, i: int) -> tuple[str, int]:
end = data.index(b"\0", i)
return data[i:end].decode("utf-8", "replace"), _skip_pad(data, end + 1)
def _read_blob(data: bytes, i: int) -> tuple[bytes, int]:
(n,) = struct.unpack_from(">i", data, i)
i += 4
return data[i:i + n], i + n + ((4 - ((i + n) % 4)) % 4)
def decode_osc(data: bytes) -> list[dict]:
"""One UDP datagram → the OSC messages inside it.
Tidal's Stream sends either bare messages or `#bundle`s (one timetag,
then length-prefixed elements). Returns [{"addr", "args"}] with `args`
the *named* argument pairs a /play carries: Tidal→SuperDirt arguments
are ("gain", 0.8) style name/value runs, so a flat dict is the shape
every consumer wants. Unnamed args land under "0", "1", …
"""
out: list[dict] = []
stack = [data]
while stack:
cur = stack.pop(0)
if not cur:
continue
if cur.startswith(b"#bundle"):
i = 16 # "#bundle\0" + 8-byte timetag
while i + 4 <= len(cur):
(n,) = struct.unpack_from(">i", cur, i)
i += 4
if n:
stack.append(cur[i:i + n])
i = _skip_pad(cur, i + n)
continue
try:
addr, i = _read_string(cur, 0)
tags, i = _read_string(cur, i) # ",ifs…" — the comma is part of it
args: dict = {}
slot = 0
for t in tags[1:]:
if t == "i":
(v,) = struct.unpack_from(">i", cur, i)
i += 4
elif t == "f":
(v,) = struct.unpack_from(">f", cur, i)
i += 4
elif t == "d":
(v,) = struct.unpack_from(">d", cur, i)
i += 8
elif t == "s":
v, i = _read_string(cur, i)
elif t == "b":
v, i = _read_blob(cur, i)
else:
v = None # T/F/I/N… carry no bytes
args[str(slot)] = v
slot += 1
except (ValueError, struct.error, IndexError):
continue # one bad element, not the datagram
# A /play message's named pairs are positional: ("gain", v), ("orbit", v)…
# Re-fold every ("string", value) run into a dict without losing the
# originals — the names ARE the Tidal parameter names.
named: dict = {}
k = 0
while k < len(args):
key = args.get(str(k))
if isinstance(key, str) and str(k + 1) in args:
named[key] = args[str(k + 1)]
k += 2
else:
k += 1
out.append({"addr": addr, "args": named})
return out
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":
return None
o = msg["args"].get("orbit")
if isinstance(o, float) and o == int(o):
o = int(o)
if not isinstance(o, int) or not (0 <= o <= MAX_ORBIT):
return None
return o + 1 # Tidal 0-based → d1-based
class HLFeed:
"""UDP listener on `VIZ_PORT`; a bounded deque the GUI drains each frame.
Same contract as midiviz.Reader — start/drain/close/alive/error — so the
tick can treat the two feeds identically. A missed datagram is a missed
frame of truth, never a queue: bounded like the Reader, for the same
recorded reason.
"""
DEPTH = 512
def __init__(self, port: int = VIZ_PORT):
self.port = port
self.error: str | None = None
self.total = 0
self._q: deque[dict] = deque(maxlen=self.DEPTH)
self._lock = threading.Lock()
self._sock: socket.socket | None = None
self._stop = threading.Event()
def start(self) -> "HLFeed":
threading.Thread(target=self._run, daemon=True,
name="oscfeed").start()
return self
def _run(self) -> None:
try:
self._sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
self._sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
self._sock.bind(("127.0.0.1", self.port))
self._sock.settimeout(0.5)
except OSError as exc:
self.error = type(exc).__name__
return
while not self._stop.is_set():
try:
data, _ = self._sock.recvfrom(65535)
except socket.timeout:
continue
except OSError:
if not self._stop.is_set():
self.error = "recv"
break
now = time.monotonic()
# One malformed datagram must not kill the feed the way an
# uncaught exception in a daemon thread dies silently: skip it.
try:
msgs = decode_osc(data)
except (ValueError, struct.error, IndexError):
continue
for msg in msgs:
o = play_orbit(msg)
if o is None:
continue
with self._lock:
self._q.append({"orbit": o, "t": now,
"params": msg["args"]})
self.total += 1
def alive(self) -> bool:
return self._sock is not None and self.error is None
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._sock is not None:
try:
self._sock.close()
except OSError:
pass
self._sock = None
"""Tests for the Tidal HL feed: OSC decode + which cells an orbit relates to.
No Qt, no ALSA, no network: `decode_osc` is a pure function tested against
hand-built bytes — the exact packets Tidal's Stream sends (a `#bundle`
containing a `/play` message whose arguments are name/value runs) and a couple
of shapes it must survive (bare message, garbage, empty datagram).
The cell relation is tested against `lcxl_grid` itself, so the test dies the
day the grid changes shape instead of lying about it.
"""
import struct
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
import oscfeed # noqa: E402
import sys as _sys # noqa: E402
_tools = str(Path(__file__).resolve().parent.parent.parent)
if _tools not in _sys.path:
_sys.path.insert(0, _tools)
import lcxl_grid as grid # noqa: E402 lives in tools/, like midiviz's own import
def _osc_msg(addr: str, pairs: list[tuple[str, int]]) -> bytes:
"""Address + typetags + (string, int) pair args, padded the way OSC does.
A pair is: name padded to 4 (so 'orbit' → 8 bytes) + a 4-byte int."""
msg = addr.encode() + b"\0"
msg += b"\0" * ((4 - len(msg) % 4) % 4)
tags = "," + "si" * len(pairs) # (string, int) runs — /play's shape
t = tags.encode() + b"\0"
msg += t + b"\0" * ((4 - len(t) % 4) % 4)
for name, value in pairs:
n = name.encode() + b"\0"
n += b"\0" * ((4 - len(n) % 4) % 4)
msg += n + struct.pack(">i", value)
return msg
def _bundle(*elements: bytes) -> bytes:
out = b"#bundle\0" + b"\0" * 8 # timetag zero
for el in elements:
out += struct.pack(">i", len(el)) + el
out += b"\0" * ((4 - len(out) % 4) % 4)
return out
def _named_pair(name: str, value: int) -> bytes:
n = name.encode() + b"\0"
n += b"\0" * ((4 - len(n) % 4) % 4)
return n + struct.pack(">i", value)
def test_decode_play_bundle_finds_the_orbit():
# d4 → Tidal says orbit: 3; midiviz speaks d-numbers.
data = _bundle(_osc_msg("/play", [("orbit", 3)]))
msgs = oscfeed.decode_osc(data)
assert len(msgs) == 1
assert msgs[0]["addr"] == "/play"
assert oscfeed.play_orbit(msgs[0]) == 4
def test_decode_bare_message_and_named_args():
data = _osc_msg("/play", [("orbit", 0), ("gain", 1)])
msgs = oscfeed.decode_osc(data)
assert oscfeed.play_orbit(msgs[0]) == 1
assert msgs[0]["args"]["gain"] == 1
def test_non_play_and_garbage_yield_nothing():
assert oscfeed.play_orbit({"addr": "/hush", "args": {}}) is None
assert oscfeed.decode_osc(b"") == []
assert oscfeed.decode_osc(b"\x01\x02\x03") == [] # must not raise
def test_orbit_bounds():
# orbit 14 (0-based 13) is the rig's last; anything past is not ours.
data = _bundle(_osc_msg("/play", [("orbit", 13)]))
assert oscfeed.play_orbit(oscfeed.decode_osc(data)[0]) == 14
data = _bundle(_osc_msg("/play", [("orbit", 99)]))
assert oscfeed.play_orbit(oscfeed.decode_osc(data)[0]) is None
# ── the relation: which cells does a trigger light ─────────────────────────
def _hl_cells(orbit):
"""The widget's relation, re-derived the same way — tested against the
grid's own tables rather than against a copy of the widget's cache."""
ccs = set(grid.orbit_home(orbit).values())
for role, fam_of in (("family_filter", grid.filter_family),
("family_mute", grid.mute_family)):
n = fam_of(orbit)
ccs |= {cc for cc, (r, who) in grid.CC_ROLE.items()
if r == role and who == n}
return ccs
def test_d1_trigger_lights_its_column_and_both_families():
# d1 (the kick): its own fx/level/gate cells, plus C1 (all-percs filter)
# and F1 (kick-alone mute) — the two family controls that act on it.
cells = {_: grid.CC_TO_CELL.get(_) for _ in _hl_cells(1)}
assert cells[grid.CELL_TO_CC[("C", 1)]] == ("C", 1) # gF1, the perc filter
assert cells[grid.CELL_TO_CC[("F", 1)]] == ("F", 1) # gM1, the kick mute
for row, col in (("B", 1), ("D", 1), ("E", 1)): # its own column
assert (row, col) in cells.values()
def test_every_d_orbit_maps_to_at_least_one_control():
# An orbit with NO related cells would make the HL feed a no-op that
# still LOOKS wired — the worst failure shape. d1-d12 own a full column
# plus two family controls; d13/d14 (the 14-orbit boot's tail) own only
# their family bloc, which is still one honest thing to show.
for o in range(1, 15):
assert len(_hl_cells(o)) >= 1, f"d{o} has no related controls"
for o in range(1, 13):
assert len(_hl_cells(o)) >= 3, f"d{o} has too few related controls"
def test_widget_relation_matches_grid_derivation():
"""The widget caches `_hl_cells` — the cache must never drift from a fresh
derivation, which is the same two-copies-one-truth lesson the port
preference already taught."""
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
import midiviz as V
# The method hangs off the widget class; derive it without a Qt app.
cls = None
for obj in vars(V).values():
if isinstance(obj, type) and hasattr(obj, "_hl_cells"):
cls = obj
break
if cls is None:
return
holder = object.__new__(cls)
holder._hl_cells_cache = {}
for o in (1, 4, 7, 11):
assert set(holder._hl_cells(o)) == _hl_cells(o)
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