Commit 3c210a2a by PLN (Algolia)

merge(rig): fold claude/rig-streamline into master

Eight commits of rig work that had accumulated on the branch while the shared
checkout sat on it. Reviewed as a diff, not taken on faith:

  perf.sh        stop DWIM-launching Pulsar into /usr/local/sbin -- 183
                 SIGABRTs between 08-19 and 09-05 because BASH_SOURCE resolved
                 to the deploy path, and the perf watcher re-entered every 30s.
  rig_units.py   neither LCXL painter may be auto-started blind; gig-up.sh's
                 generation-aware chooser owns that decision.
  midiviz        its own Wayland app id (desktopFileName, not applicationName),
                 so focus and KWin rules can name this window and no other;
                 plus the CC readout PLN asked for while wiring.
  logs 040/041   the phantom port, and the GIG UP rewire parked behind the
                 Ardour double-launch bug.

The four other branches were already upstream by patch-id.

# Conflicts:
#	TASKS_DUMP.md
parents 1c83d75a 1236fe63
---
log: 040
title: "The phantom port one zombie driver, four symptoms"
date: 2026-09-05
task: "n/a (PLN gear-failure report mid-session)"
tags: [rig, lcxl, midi, systemd, kwin, perf, forensics]
shareable: true
---
## Cap (what & why)
PLN reported five gear problems at once before a play session: "launch gig"
started Pulsar but not Ardour; a freshly-plugged classic LCXL was not
autoconnected to Midi Through; the board showed only its factory colours; no
event logging visible; no aseqdump or MIDI monitor anywhere. Five complaints,
and the working hypothesis of "five bugs" was wrong.
## Manœuvre (how)
Ran the diagnosis off runtime ground truth first (`aconnect -l`, `lsusb`,
process lineage, `journalctl`, the Bridge's own `/api/rig`) and only then read
code, with four subagents reading in parallel. The runtime evidence repeatedly
overturned the code reading — twice an agent's confident conclusion was wrong
because it trusted a name.
Then fixed the four that were safe, held the one that wasn't.
## Prise (findings / artifacts)
- **One root cause behind three symptoms.** `lcxl3-driver.service` started at
14:36 bound to a real mk3; the mk3 was then swapped for the classic and the
driver was never restarted. It kept writing mk3 SysEx into a dead port
(`mido.send()` on a vanished ALSA destination does not raise) and kept
publishing its virtual port **`ParVagues LCXL3`**.
- That phantom port poisoned `midi-autoconnect.sh`, whose candidate list is
first-match-wins: `("ParVagues LCXL3" "LCXL3" "Launch Control XL")`. It
latched onto the ghost at 13:22 and never noticed real hardware again.
- And it kept the correct painter *off*: `lcxl3-driver` declares
`Conflicts=lcxl-leds-watch`, so the unit that speaks the classic's protocol
(`00 20 29 02 11`, template/palette) had been dead since 11:44 while the
driver spoke mk3 (`00 20 29 02 15`, RGB, 34 targets) at nothing.
- **"Launch Gig" was never a gig launcher.** `perf-tray.py:318` was
`_launch("pulsar")` — editor-only *by design* (#116). The real launcher is the
Bridge's RIG UP, which `/api/rig` showed had never run once in that Bridge
process's 6.7 days (`converge: started 0.0, rc null, out ""`).
- **183 Pulsar SIGABRTs** since 2026-08-19, 80 in one 70-minute window.
`perf.sh:630` launched Pulsar in `$(dirname "${BASH_SOURCE[0]}")`, but perf.sh
is deployed root-owned as `/usr/local/sbin/perf-audio`, so Pulsar was handed
`/usr/local/sbin` as its project directory. The Bridge's perf watcher calls it
every ~30s and every mode flag routes through the same function.
- **Logging existed all along.** `gig-log.service` writes
`~/.local/share/parvagues/gig-log/gig-<stamp>.jsonl` — CC/PB coalesced per
second, every note kept. The two invisible `aseqdump -p 20:0` belonged to
gig-log and the Pulsar HUD. `midiviz.py` is the visual lens, and RIG UP already
lists it in `POST_APPS` — the monitor PLN wanted was wired to the button he
had never pressed.
- **A live double-CC path.** SC's boot runs `MIDIIn.connectAll` and
`MIDIFunc.cc` accepts `src` and never checks it, so after every SC restart the
surface fed SC twice (raw + via Midi Through): every CC handled twice, every
button `noteOn` double-firing into `~lcxlChordCheck`. A double noteOn on a
toggle is a no-op — that is how a mute button silently stops working.
Shipped: `361a05b` (autoconnect rule 3), `d1dbae3` (midiviz: app id, KWin rule,
CC numbers, unit), `c7ac174` (perf.sh DWIM removal), plus the rig_units painter
policy fix. PLN's play-test after the repair: *"just played rose_rouge works
great! and the midi mon is beautiful!"*
## Sel (the shareable learning)
- **A stale binding is worse than a crash.** A crashed driver is visible; a
driver holding handles to hardware that left is green in every check, and its
virtual port actively lies to everything that identifies gear by name.
- **The phantom fooled the investigation too.** A subagent grepped `aconnect -l`
for "LCXL3", hit `ParVagues LCXL3`, and concluded the mk3 was plugged back in.
Same trap as the autoconnect. The lesson isn't "agents are unreliable" — it's
that *name-based identity is the bug*, and it catches whoever uses it.
- **Verify identity, never infer it.** midiviz's Wayland app id was assumed to be
`midiviz` (Qt's documented `applicationName` fallback). Queried live, it was
**`python3`** — so a window rule would have matched every Python GUI on the box,
and `focus_window("midimon")` could never match anything. One D-Bus query
changed the design.
- **Five green checks, one wrong button.** Nothing about the gig launch was
broken. The label promised a gig and delivered an editor, and 86 seconds of
waiting did the rest. Naming is a correctness property.
- **A ✓ in a log is not evidence.** perf.sh verified its launch with a 0.5s
`pgrep` presence poll, which caught the doomed Electron process alive during
startup and printed "✓ Pulsar launched" before it aborted — 183 times.
- **Respect the invariant you're about to "fix".** The driver's
`StartLimitIntervalSec=0` looked like the crash-loop bug. Its comment
explained it: the USB-C hub drops the surface, and bounded retries leave it
"deaf for the rest of the session". The trigger was the boot policy, not the
retry count.
## Hameçon (hook)
"Five things broke before the gig. Four of them were the same ghost — a MIDI
port belonging to a controller that had already left the building, and it lied
to every tool that asked for gear by name. Including me."
## Sillage (what it unlocks)
- The generation rule is now stated: **neither painter may be auto-started
blind** — only the plugged hardware can answer "which painter", and
`gig-up.sh leds()` owns it.
- midiviz is a first-class instrument: permanent, on every desktop, and it now
prints the control you just touched as `^53=127` — the literal token to type
into a pattern. Wiring stops requiring a second window.
- The lean-UI direction got named and matched to PLN's own board item ("The
Bridge as the single ParVagues cockpit"): **one reconciler, three skins; no
modals in the hot path; the performing session is never the archive.**
- Next: the Ardour double-launch defect this session exposed the hard way (see
`armada/tasks/041-*` / RESUME), then `gig-down`.
diff --git a/perf-tray.py b/perf-tray.py
index 5b67bf1..abfd0c3 100644
--- a/perf-tray.py
+++ b/perf-tray.py
@@ -58,6 +58,7 @@ BRIDGE_URL = "http://127.0.0.1:8773/"
# menu can do, so it gets its own confirm step (see PerfTray.on_sc_clicked).
SC_UNIT = "parvagues-sc"
SC_CONFIRM_MS = 10_000 # how long an armed restart stays armed
+GIG_CONFIRM_MS = 10_000 # same, for a GIG UP that would start the sound
SC_READY_S = 30 # measured 2026-08-14: active→responder armed in 27 s
@@ -311,13 +312,31 @@ class PerfTray:
# not focus a window. It can, through KWin's scripting bus; see
# launchers.focus_window. So this is a top-level entry AND a real focus.
#
- # Deliberately editor-only: it does not start SuperCollider, boot Tidal
- # or arm Ardour. #116 is the standing rule that the hot path must not be
- # able to start the sound by accident — one wrong click before a set is
- # a stuck scsynth, not a convenience.
- self.gig_act = QAction("🌊 Launch Gig", self.menu)
- self.gig_act.triggered.connect(lambda: self._launch("pulsar"))
+ # PLN, 2026-09-05: "click launch gig started pulsar, not ardour??" and
+ # then "is our launch gig now autodoing?".
+ #
+ # It was editor-only: `_launch("pulsar")`, nothing else. The label
+ # promised a gig and delivered an editor, so on 2026-09-05 PLN pressed
+ # it, got Pulsar, waited, and opened Ardour by hand 86 s later. The real
+ # launcher is the Bridge's RIG UP — which /api/rig showed had never run
+ # once in that Bridge process's 6.7 days, because nothing points at it.
+ #
+ # So this now calls the SAME reconciler (POST /api/rig), rather than
+ # growing a third launch order next to gig-up.sh's and rig.py's. The
+ # Bridge owns a `running` flag, which is why the tray posts to it
+ # instead of importing rig directly: two processes must not converge at
+ # once.
+ #
+ # #116 still holds — the hot path must not start the sound by accident —
+ # but it is satisfied the way refresh_sc already satisfies its mirror:
+ # arm, then confirm. When the rig is already up a click is just a focus,
+ # which is the common case and needs no guard; only a click that would
+ # actually START things arms first. No modal, per #136: a box stealing
+ # focus mid-set is its own hazard.
+ self.gig_act = QAction("🌊 GIG UP", self.menu)
+ self.gig_act.triggered.connect(self.on_gig_clicked)
self.menu.addAction(self.gig_act)
+ self._gig_armed = False
# PLN, 2026-08-14: "add a restart SC command in parvagues tray gui menu".
#
@@ -479,11 +498,14 @@ class PerfTray:
self.rig_menu.setTitle(f"Rig ▸ {up}/{total} up")
if getattr(self, "gig_act", None) is not None:
pulsar = next((i for i in snap["apps"] if i["key"] == "pulsar"), None)
- # Say which of the two things the click will do. "Focus" vs "Open" is
- # the difference between a no-op-looking click and a confident one.
- self.gig_act.setText("🌊 Launch Gig — focus ParVagues"
- if pulsar and pulsar["running"]
- else "🌊 Launch Gig — open ParVagues")
+ # Say exactly what the click will do. Three states, because the click
+ # means three different things: raise a window, arm, or converge.
+ if self._gig_armed:
+ self.gig_act.setText("⚠ GIG UP — click again to START THE RIG")
+ elif self._rig_is_up(snap):
+ self.gig_act.setText("🌊 GIG UP — focus ParVagues")
+ else:
+ self.gig_act.setText("🌊 GIG UP — converge + open the rig")
self.gig_act.setEnabled(bool(pulsar and pulsar["available"]))
# ------------------------------------------------------------ SuperDirt
@@ -492,6 +514,71 @@ class PerfTray:
# systemctl, and doing that 30x a minute for a label nobody is looking at is
# the per-tick cost this rig keeps getting bitten by.
+ def _rig_is_up(self, snap):
+ """True when a click has nothing left to start — SuperDirt up AND the
+ editor open. Anything less and GIG UP has real work to do."""
+ try:
+ active, up = sc_state()
+ except Exception:
+ return False
+ if not active or (up is not None and up < SC_READY_S):
+ return False
+ pulsar = next((i for i in snap.get("apps", []) if i["key"] == "pulsar"), None)
+ return bool(pulsar and pulsar["running"])
+
+ def on_gig_clicked(self):
+ try:
+ snap = LA.snapshot()
+ except Exception:
+ snap = {"apps": []}
+ # Already up: this is a focus, not a launch. One click, no guard.
+ if self._rig_is_up(snap):
+ self._gig_armed = False
+ self._launch("pulsar")
+ return
+ if not self._gig_armed:
+ self._gig_armed = True
+ QTimer.singleShot(GIG_CONFIRM_MS, self._disarm_gig)
+ self.refresh()
+ return
+ self._gig_armed = False
+ self._rig_up()
+
+ def _disarm_gig(self):
+ self._gig_armed = False
+ try:
+ self.refresh()
+ except Exception:
+ pass
+
+ def _rig_up(self):
+ """Hand the job to the Bridge's converge, then open it so the verdict is
+ WATCHED. rig.py:71-73 records the original complaint: RIG UP showed a
+ spinner and no window for over a minute (converge is ~100 s on a cold
+ boot), so PLN launched Ardour by hand believing it had failed. Opening
+ the dashboard is the fix for that — the log is the progress bar."""
+ import json
+ import urllib.error
+ import urllib.request
+ req = urllib.request.Request(
+ BRIDGE_URL.rstrip("/") + "/api/rig",
+ data=json.dumps({"launch_apps": True}).encode(),
+ headers={"Content-Type": "application/json"}, method="POST")
+ try:
+ with urllib.request.urlopen(req, timeout=10) as r:
+ json.loads(r.read().decode() or "{}")
+ except Exception as e:
+ # The Bridge is the only thing that can converge. If it is not
+ # answering, say so and do NOT silently fall back to opening the
+ # editor — that is exactly the half-launch that started all this.
+ self.tray.showMessage(
+ "perf-tray",
+ "GIG UP needs the Bridge (%s): %s\n"
+ "systemctl --user start parvagues-bridge" % (BRIDGE_URL, e),
+ QSystemTrayIcon.Warning, 8000)
+ return
+ self._open_url(BRIDGE_URL)
+
def refresh_sc(self):
try:
active, up = sc_state()
---
log: 041
title: "PARKED GIG UP rewire, blocked on Ardour double-launch"
date: 2026-09-05
task: "OPEN / PARKED resume point, not an achievement"
tags: [rig, tray, bridge, launchers, ardour, parked]
shareable: false
---
## STATUS: PARKED. Written, not shipped. Do not ship as-is.
## Cap (what & why)
PLN asked "is our launch gig now autodoing?" — the tray's `🌊 Launch Gig` was
`_launch("pulsar")` and nothing else, so it opened an editor and no rig. The
rewire points it at the one real reconciler (`POST /api/rig`) instead of growing
a third launch order beside `gig-up.sh`'s and `rig.py`'s.
It is written and syntactically clean, and the tray runs with it loaded. It is
**parked because testing it end-to-end broke the rig**, which exposed a defect
that must be fixed first.
## The blocker (fix this before shipping the rewire)
`POST /api/rig``rig.py``launchers.launch("ardour")` returned
`launched — launched Ardour` **while Ardour was already running** (pid 1833333).
`is_running(spec)` gave a FALSE NEGATIVE, so a second instance started, the two
collided over the session lock, and BOTH exited. Ardour vanished mid-session and
all 12 orbit links went with it.
Wiring that to a tray button is strictly worse than the mislabeled button it
replaces: one wrong click could kill a running Ardour during a set.
- Spec: `tools/bridge/launchers.py:67-70`, `"exe": r"[Aa]rdour[-\d.]*$"`.
- Real process: `/usr/lib/ardour9/ardour-9.7.0 "…/Tidal Live/Tidal Live.ardour"`.
- Detection: `is_running()` at `tools/bridge/launchers.py:236-259`.
- Already-running branch: `tools/bridge/launchers.py:281-289`.
- Note `perf.sh:563` matches the same process fine with a plain `/ardour`
substring, so the process IS matchable — the `exe`-anchored regex is the
suspect (it anchors on `$`, and the argv0 is `ardour-9.7.0`, which the
`[-\d.]*$` should cover — so reproduce before assuming).
## EXACT resume steps
1. Reproduce cold, read-only, with Ardour running:
`python3 -c "import sys; sys.path.insert(0,'tools/bridge'); import launchers as L; print(L.is_running(next(s for s in L.SPECS if s['key']=='ardour')))"`
(adjust the spec accessor to whatever `launchers.py` actually exposes).
Expect `True`; if it prints `False`, that is the bug, reproduced.
2. Fix detection, then add a guard so `launch()` can NEVER start a second
Ardour: gate on the session lock / an existing `ardour` JACK client, not only
on a process regex. A false negative must fail closed, not launch.
3. Re-apply the rewire: `git apply armada/tasks/041-gigup-rewire.patch`
(the patch in this directory IS the parked work — `perf-tray.py` only).
4. Re-test `POST /api/rig` with everything already up. Required result:
`ardour: focused`, never `ardour: launched`.
5. Then commit the rewire.
## What the patch contains (perf-tray.py)
- `🌊 Launch Gig``🌊 GIG UP`, `triggered``on_gig_clicked`.
- `_rig_is_up(snap)`: SuperDirt active AND past `SC_READY_S` AND Pulsar running.
- Arm-then-confirm, mirroring `refresh_sc`'s idiom (`GIG_CONFIRM_MS`, 10s): a
click when the rig is already up is just a focus and needs no guard; only a
click that would START things arms first. No modal (#136).
- `_rig_up()`: POSTs `{"launch_apps": true}` to `/api/rig`, then opens the
Bridge so the converge log is watched — `rig.py:71-73` records the original
complaint (spinner, no window for a minute, so PLN launched Ardour by hand).
On a Bridge error it warns and does NOT fall back to opening the editor.
- Three-state label so the click always says what it will do.
## Also outstanding (discovered, not parked code)
- ~~`perf.sh` fix committed but NOT LIVE~~ — **DONE 2026-09-05 20:06.** PLN ran the
redeploy; `/usr/local/sbin/perf-audio` md5 now matches the repo copy exactly,
`nohup pulsar`/`SCRIPT_DIR` are absent from the deployed script, and the Pulsar
prioritization is retained. Verified after: the Bridge's perf watcher reasserts
normally ("✓ Set Pulsar to HIGH priority" / "Ardour process not found -
skipping") with **no launch attempts**, and the last Pulsar SIGABRT of all time
is 15:58:44, before the deploy. Final count: **183 crashes → 0.**
- `gig-up.sh` verdict was **NO-GO**, four pre-existing blockers unrelated to
this session's changes: boot helpers (ghc error → `tools/check-boot.sh`),
surface grid (`tools/check-drift.sh`), mute map
(`python3 tools/fix-mute-roles.py --apply`), and a `Tidal :6010` warn.
- `perf-tray` peaked **2.6 GB RSS** over 4h15m. Leak worth a look.
- `gig-down` lifecycle is BLOCKED: Freebox was down, so `Tidal Live/export/`
(25G) cannot be verified as archived and must not be deleted. `peaks/` (4.2G)
is the only unconditionally-safe reclaim, and it belongs AFTER a gig — Ardour
rebuilds peaks across 53G at session-open. `dead/`, `analysis/`, `backup/` are
all empty; there is no large risk-free win.
- Take101 (12 orbits × L/R = 24 files) was already missing from `interchange/`
and triggered Ardour's missing-source dialog. PLN: "idontcare for todays takes
they were tests at best" — cleared and saved, no action.
- **midiviz does not survive a replug.** Observed at session end: PLN unplugged
the LCXL, `lcxl-path.py` went to "no LCXL on the bus", and `midiviz.service`
went **inactive** — it resolves its ALSA source port once at startup and exits
when the port disappears, and `Restart=on-failure` does not fire on a clean
exit. So the "always open" lens is only always-open while the board stays
plugged. `tools/lcxl-leds.py:557-641` already solves exactly this (re-resolve
on every send, 2s cache invalidated on failure) — midiviz should either adopt
that or the unit should use `Restart=always` with a short `RestartSec` so it
rebinds within seconds of a replug. This is the same stale-binding family as
the bug that started the whole session.
...@@ -625,38 +625,26 @@ set_priorities() { ...@@ -625,38 +625,26 @@ set_priorities() {
# Find Pulsar editor processes and set lower priority # Find Pulsar editor processes and set lower priority
PULSAR_PIDS=$(pgrep -f pulsar) PULSAR_PIDS=$(pgrep -f pulsar)
# DWIM: if no Pulsar, launch it in the script's directory using the user's session env # No auto-launch here, deliberately. This block used to DWIM a Pulsar into
if [ -z "$PULSAR_PIDS" ]; then # existence when none was running, in "the script's directory" --
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # $(dirname "${BASH_SOURCE[0]}"). But perf.sh is DEPLOYED root-owned as
REF_PID=$(pgrep -u "$USER" -x kwin_wayland | head -1) # /usr/local/sbin/perf-audio (see perf-audio.sudoers), so BASH_SOURCE resolved
[ -z "$REF_PID" ] && REF_PID=$(pgrep -u "$USER" -x kwin_x11 | head -1) # to /usr/local/sbin and Pulsar was handed /usr/local/sbin as its project
[ -z "$REF_PID" ] && REF_PID=$(pgrep -u "$USER" -x plasmashell | head -1) # directory. It aborted every time: 183 SIGABRTs between 2026-08-19 and
if [ -n "$REF_PID" ] && [ -r "/proc/$REF_PID/environ" ]; then # 2026-09-05, 80 of them in a single 70-minute window, because the Bridge's
echo "ℹ️ Pulsar not running — launching in $SCRIPT_DIR..." # perf watcher calls set_mode() every ~30s and every mode flag routes through
get_env_var() { tr '\0' '\n' < "/proc/$1/environ" | grep "^$2=" | head -1 | cut -d= -f2-; } # here. The "✓ Pulsar launched" line was not evidence of anything -- the check
sudo -u "$USER" env \ # was a pgrep presence poll that caught the doomed process alive during
DISPLAY="$(get_env_var "$REF_PID" DISPLAY)" \ # Electron startup and returned before it died.
WAYLAND_DISPLAY="$(get_env_var "$REF_PID" WAYLAND_DISPLAY)" \ #
XDG_RUNTIME_DIR="$(get_env_var "$REF_PID" XDG_RUNTIME_DIR)" \ # Not fixed by resolving the directory better. Removed, because launching apps
DBUS_SESSION_BUS_ADDRESS="$(get_env_var "$REF_PID" DBUS_SESSION_BUS_ADDRESS)" \ # is not this script's job: perf.sh sets priorities. Ardour, two branches up,
XDG_SESSION_TYPE="$(get_env_var "$REF_PID" XDG_SESSION_TYPE)" \ # has always been prioritize-if-present with no launch branch, and Pulsar is
nohup pulsar "$SCRIPT_DIR" </dev/null >/dev/null 2>&1 & # now symmetric with it. Launching belongs to the tray and the Bridge, which
for _ in $(seq 1 20); do # both have real Pulsar launchers that pass the right project directory and
PULSAR_PIDS=$(pgrep -f pulsar) # report failures. #116 also wants the hot path unable to start things by
[ -n "$PULSAR_PIDS" ] && break # accident, and a root-run script spawning a GUI editor through
sleep 0.5 # `sudo -u $USER env ...` every 30s is the opposite of that.
done
if [ -n "$PULSAR_PIDS" ]; then
echo "✓ Pulsar launched — giving renderer 2s to spawn before prioritizing..."
sleep 2
PULSAR_PIDS=$(pgrep -f pulsar)
else
echo "⚠ Pulsar didn't appear within 10s — skipping priority set"
fi
else
echo "⚠ No user session detected (no kwin/plasma) — skipping Pulsar auto-launch"
fi
fi
if [ ! -z "$PULSAR_PIDS" ]; then if [ ! -z "$PULSAR_PIDS" ]; then
# Pulsar is the INSTRUMENT, not background load, so it stays fast in EVERY # Pulsar is the INSTRUMENT, not background load, so it stays fast in EVERY
......
...@@ -75,8 +75,15 @@ LAUNCHERS = [ ...@@ -75,8 +75,15 @@ LAUNCHERS = [
# its own frameless window, and wrapping a GUI in konsole would have left an # its own frameless window, and wrapping a GUI in konsole would have left an
# empty black terminal parked behind it for the length of the set. # empty black terminal parked behind it for the length of the set.
# midimon.py is untouched and remains the fallback (`python3 midimon.py`). # midimon.py is untouched and remains the fallback (`python3 midimon.py`).
# `focus` is NOT the key here: the key stayed "midimon" for both faces
# and PLN's muscle memory, but focus_window matches KWin's resourceClass, and this
# window's app id is "midiviz" (verified 2026-09-05 against
# workspace.windowList(); before midiviz.py set desktopFileName it was the
# even less useful "python3"). Matching "midimon" could never hit, so a
# click on an already-running lens silently did nothing.
{"key": "midimon", "name": "MIDI Monitor", "blurb": "live MIDI as glyph-rain — the surface, watched", {"key": "midimon", "name": "MIDI Monitor", "blurb": "live MIDI as glyph-rain — the surface, watched",
"candidates": ["python3"], "args": [str(MIDIVIZ)], "pgrep": "midiviz.py"}, "candidates": ["python3"], "args": [str(MIDIVIZ)], "pgrep": "midiviz.py",
"focus": "midiviz"},
] ]
# Web tools = start-or-open. `port` open ⇒ running; else `cmd` is spawned, then # Web tools = start-or-open. `port` open ⇒ running; else `cmd` is spawned, then
......
...@@ -299,6 +299,7 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0): ...@@ -299,6 +299,7 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0):
self.reader = reader self.reader = reader
self.port_label = port_label self.port_label = port_label
self.last_cc = None # (cc, value, channel, t) -- header readout
self.scale = scale self.scale = scale
self.paused = False self.paused = False
self.t0 = time.monotonic() self.t0 = time.monotonic()
...@@ -440,6 +441,14 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0): ...@@ -440,6 +441,14 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0):
else: else:
st[0], st[1], st[2] = v, now, chn st[0], st[1], st[2] = v, now, chn
self.col_t[cell[1]] = now self.col_t[cell[1]] = now
# PLN, 2026-09-05: "would help to see the chan num: 33 or 53
# etc so as i press i can wire". The grid shows WHERE a knob
# is and HOW FAR it moved, but never WHICH CC it is -- so
# wiring a control meant leaving the window for the CC map.
# Keep the last one touched; the header prints it as the
# exact token the corpus uses, so it can be typed straight
# into a pattern.
self.last_cc = (cc, v, chn, now)
self.stream.append((HEX[v >> 3], self._fam_for_cc(cc), now)) self.stream.append((HEX[v >> 3], self._fam_for_cc(cc), now))
return return
# a CC the authored grid does not own: rain, not a cell. # a CC the authored grid does not own: rain, not a cell.
...@@ -540,6 +549,14 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0): ...@@ -540,6 +549,14 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0):
total = self.reader.total if self.reader else self.ingested total = self.reader.total if self.reader else self.ingested
# port address digits + a hex event counter. No words. # port address digits + a hex event counter. No words.
head = "%s│%06X" % (self.port_label, total & 0xFFFFFF) head = "%s│%06X" % (self.port_label, total & 0xFFFFFF)
# The readout is DECIMAL and carries the corpus's own caret prefix:
# what shows up here is literally what gets typed in a .tidal file
# (^53), not a hex value needing conversion mid-set.
if self.last_cc is not None:
cc, v, chn, t = self.last_cc
head += "│^%d=%d" % (cc, v)
if chn:
head += "/%d" % chn
y = self.hdr_y + self.u_asc y = self.hdr_y + self.u_asc
p.drawText(self.pad, round(y), head) p.drawText(self.pad, round(y), head)
...@@ -593,6 +610,31 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0): ...@@ -593,6 +610,31 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0):
for r, row in enumerate(grid.PHYSICAL_ORDER): for r, row in enumerate(grid.PHYSICAL_ORDER):
p.drawText(self.pad, round(my + r * ch + self.u_asc + 1), row) p.drawText(self.pad, round(my + r * ch + self.u_asc + 1), row)
# CC numbers, one dim decimal per mapped cell, top-right.
#
# Drawn HERE, in its own pass, because f_micro is already the current
# font: a per-cell setFont inside the main loop would add 96 font
# switches a frame to a render path this file deliberately keeps to a
# counted number of drawText calls.
#
# Dim on purpose -- this is a reference layer, not a reading. It is
# the map you consult while wiring; the value digits stay the thing
# your eye tracks while playing. Skipped entirely when the cell is
# too narrow to hold the digits without colliding with the value.
cc_room = round(self.cw) - self.inset >= self.uw * 2 + 8
if cc_room:
p.setPen(self.chrome[5])
for r, row in enumerate(grid.PHYSICAL_ORDER):
for col in range(1, 9):
cc = grid.CELL_TO_CC.get((row, col))
if cc is None:
continue
txt = "%d" % cc
p.drawText(
round(mx + (col - 1) * cw + (round(self.cw) - self.inset)
- self.uw * len(txt) - 2),
round(my + r * ch + self.u_asc + 1), txt)
two = self.cw > self.mw * 2.4 two = self.cw > self.mw * 2.4
p.setFont(self.f_main) p.setFont(self.f_main)
for r, row in enumerate(grid.PHYSICAL_ORDER): for r, row in enumerate(grid.PHYSICAL_ORDER):
...@@ -870,6 +912,13 @@ def main(argv=None) -> int: ...@@ -870,6 +912,13 @@ def main(argv=None) -> int:
_QtCore, _QtGui, QtWidgets = _qt() _QtCore, _QtGui, QtWidgets = _qt()
app = QtWidgets.QApplication.instance() or QtWidgets.QApplication(sys.argv[:1]) app = QtWidgets.QApplication.instance() or QtWidgets.QApplication(sys.argv[:1])
app.setApplicationName("midiviz") app.setApplicationName("midiviz")
# Wayland app_id comes from desktopFileName, NOT applicationName. Without
# this, KWin saw resourceClass "python3" (verified 2026-09-05 by querying
# workspace.windowList()) -- so launchers.focus_window("midimon") could
# never match, and a KWin rule keyed on the app id would either miss or,
# worse, match EVERY python3 GUI on the box. Give the window its own
# identity so the compositor can be told about this window and no other.
app.setDesktopFileName("midiviz")
w = build_widget(port or "--", reader, scale=max(0.6, min(2.4, a.scale))) w = build_widget(port or "--", reader, scale=max(0.6, min(2.4, a.scale)))
w.show() w.show()
w.raise_() w.raise_()
......
...@@ -97,6 +97,64 @@ resolve_lcxl() { ...@@ -97,6 +97,64 @@ resolve_lcxl() {
return 1 return 1
} }
# Rule 3: the surface reaches SuperCollider through Midi Through ONLY.
#
# SuperDirt's boot runs `MIDIIn.connectAll` (start_and_midi.scd:19), which
# subscribes SC to EVERY hardware MIDI input it can see -- the LCXL included.
# That leaves the surface feeding SC twice: once raw, once via Midi Through.
# `MIDIFunc.cc` accepts `src` and never checks it (start_and_midi.scd:51), so
# every CC fires the handler twice and every button noteOn double-fires into
# ~lcxlChordCheck -- which is how a toggle silently becomes a no-op. Found
# 2026-09-05, after an SC restart re-armed it behind a hand-run `aconnect -d`.
#
# Cut ONLY hardware-surface -> SuperCollider. Everything else the surface feeds
# MUST survive: -> Midi Through (rule 1), and -> the aseqdump taps that gig-log
# and the Pulsar HUD read (cutting those blinds the session log).
#
# Match the RAW board on type=kernel, never on name alone: lcxl3-driver
# publishes a virtual port called 'ParVagues LCXL3' whose name matches the same
# candidates, and cutting the driver's own translated feed would kill the very
# path we want. type=kernel is what separates hardware from a virtual port.
#
# lcxl3-driver's prune() did this for the v3 board, but only while it runs.
# This loop already owns the wiring, so this is the generation-agnostic home.
SC_CLIENT_AWK='/^client [0-9]+: .SuperCollider./ { c = $2; sub(":", "", c); print c; exit }'
DIRECT_LEG_AWK='
/^client / {
cid = $2; sub(":", "", cid)
hw = ($0 ~ /type=kernel/ && $0 ~ /Launch Control XL|LCXL/)
next
}
hw && /^[ \t]+[0-9]+ / { port = $1; next }
hw && /Connecting To:/ {
line = $0
sub(/.*Connecting To:[ \t]*/, "", line)
n = split(line, dests, ",")
for (i = 1; i <= n; i++) {
d = dests[i]
gsub(/[ \t]/, "", d)
sub(/\[.*\]$/, "", d)
split(d, a, ":")
if (a[1] == scid) print cid ":" port, d
}
}'
cut_direct_to_sc() {
local scid pairs src dst
scid="$(aconnect -l 2>/dev/null | awk "$SC_CLIENT_AWK")"
[ -n "$scid" ] || return 0
pairs="$(aconnect -l 2>/dev/null | awk -v scid="$scid" "$DIRECT_LEG_AWK")"
[ -n "$pairs" ] || return 0
while read -r src dst; do
[ -n "$src" ] || continue
echo "midi-autoconnect: cutting direct $src -> SuperCollider $dst (Midi Through is the only hop)"
disconnect "$src" "$dst"
done <<< "$pairs"
}
reconcile() { reconcile() {
local resolved addr name local resolved addr name
if ! resolved="$(resolve_lcxl)"; then if ! resolved="$(resolve_lcxl)"; then
...@@ -113,6 +171,7 @@ reconcile() { ...@@ -113,6 +171,7 @@ reconcile() {
fi fi
connect "$addr" "$THRU:$THRU_PORT" # rule 1: controls out connect "$addr" "$THRU:$THRU_PORT" # rule 1: controls out
disconnect "$THRU:$THRU_PORT" "$addr" # rule 2: kill the loop disconnect "$THRU:$THRU_PORT" "$addr" # rule 2: kill the loop
cut_direct_to_sc # rule 3: one hop, not two
} }
echo "midi-autoconnect: reconciling every ${INTERVAL}s" echo "midi-autoconnect: reconciling every ${INTERVAL}s"
......
[Desktop Entry]
Type=Application
Name=midiviz
GenericName=MIDI lens
Comment=Live MIDI as glyph-rain — the ParVagues control surface, watched
Exec=/usr/bin/python3 /home/pln/Work/Sound/Tidal/tools/bridge/midiviz.py
Icon=audio-midi
Terminal=false
Categories=AudioVideo;Audio;
StartupWMClass=midiviz
NoDisplay=false
[Unit]
Description=midiviz (ParVagues MIDI lens: the surface, watched)
Documentation=file:///home/pln/Work/Sound/Tidal/tools/bridge/midiviz.py
# GUI unit: it must start only once the compositor's env (WAYLAND_DISPLAY,
# XDG_RUNTIME_DIR) has been imported into the user manager, which is what
# graphical-session.target marks. Bound to default.target instead, it would
# race the session and fail with "could not connect to display". Same pattern
# as perf-tray.service; parvagues-bridge.service uses default.target because
# it is headless and has no display to wait for.
After=graphical-session.target
PartOf=graphical-session.target
[Service]
# Inherits the Wayland/Plasma session env from the user manager.
Environment=PYTHONUNBUFFERED=1
ExecStart=/usr/bin/python3 %h/Work/Sound/Tidal/tools/bridge/midiviz.py
# The lens is decoration with a job: if it dies it should come back, but a
# crash loop must not spin -- it holds no audio ports and nothing waits on it.
Restart=on-failure
RestartSec=5
[Install]
WantedBy=graphical-session.target
...@@ -68,7 +68,16 @@ SERVICES = [ ...@@ -68,7 +68,16 @@ SERVICES = [
# translates the v3 DAW protocol to the corpus's v2 CCs (fader → Ardour # translates the v3 DAW protocol to the corpus's v2 CCs (fader → Ardour
# gains included) and owns paint + OLED. Conflicts= with lcxl-leds-watch: # gains included) and owns paint + OLED. Conflicts= with lcxl-leds-watch:
# one painter per board, whichever surface generation is plugged. # one painter per board, whichever surface generation is plugged.
("lcxl3-driver", "LCXL3 driver", None, "login", "v3 surface → v2 CCs + paint + OLED"), # "manual" since 2026-09-05, symmetric with lcxl-leds-watch below and for
# the same reason read the other way round. ensure() starts every non-manual
# unit that is not active, and this one Conflicts= the v2 painter -- so with
# the CLASSIC board plugged, a converge would start the v3 driver, kill the
# painter that was correctly lighting the classic, and then crash-loop at
# RestartSec=5 because there is no LCXL3 port to bind (86 attempts between
# 11:26 and 14:36 that day). Neither painter may be auto-started blind:
# gig-up.sh's leds() is the generation-aware chooser (it greps aconnect for
# LCXL3 and restarts the right one), and it owns this decision.
("lcxl3-driver", "LCXL3 driver", None, "manual", "v3 surface → v2 CCs + paint + OLED"),
# "manual" since 2026-09-05: this is the ORIGINAL LCXL's painter. On the # "manual" since 2026-09-05: this is the ORIGINAL LCXL's painter. On the
# LCXL3 rig it fails forever misdiagnosing a USB OUT stall, and starting it # LCXL3 rig it fails forever misdiagnosing a USB OUT stall, and starting it
# Conflicts=-kills lcxl3-driver — so neither boot nor --ensure may start # Conflicts=-kills lcxl3-driver — so neither boot nor --ensure may start
...@@ -86,6 +95,12 @@ SERVICES = [ ...@@ -86,6 +95,12 @@ SERVICES = [
# Debt: its unit file still lives only in ~/.config/systemd/user (not # Debt: its unit file still lives only in ~/.config/systemd/user (not
# repo-owned like the others). # repo-owned like the others).
("perf-tray", "perf tray", None, "login", "tray: what's up, perf mode, gear pause"), ("perf-tray", "perf tray", None, "login", "tray: what's up, perf mode, gear pause"),
# "login", not "manual": PLN asked for the lens to be permanently present
# ("now it should always oppen"), on top and on every desktop. On-top is a
# window flag midiviz already sets; all-desktops is a KWin rule keyed on its
# app id (see the setDesktopFileName call in midiviz.py -- without it the app
# id was "python3" and a rule would have matched every Python GUI).
("midiviz", "MIDI lens", None, "login", "the surface, watched (glyph-rain + CC numbers)"),
] ]
TARGET = "parvagues-rig.target" TARGET = "parvagues-rig.target"
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment