Commit b30715b7 by PLN (Algolia)

fix(bridge): RIG UP now shows its work in a second, and midimon opens wired

Three gaps PLN hit launching the rig on 2026-08-29, reported as "had to launch
ardour myself / had to open midi watch myself / once opened had to wire it to
midi through myself". All three were real, and one of them nearly got "fixed"
the wrong way.

GAP 1 — the button looked dead for over a minute. _run() ran gig-up --converge
to completion (~100 s on a cold boot while SuperDirt warms 51 banks, up to the
300 s timeout) and only THEN launched the apps. So RIG UP showed a spinner and
no window, PLN concluded it had failed, and launched Ardour by hand.

My first attempt was to launch the apps first. That was wrong, and PLN caught
it: "superdirt froze before loading midi when in my launcher, when ardour was
midi connected, had to untick ardour midi, then launch". Ardour holding the
surface makes sclang hang inside MIDIClient.init / MIDIIn.connectAll — the same
rawmidi contention that stops `amidi` writing LEDs while SC is up. The original
ordering was not an accident, it was load-bearing, and reordering it would have
traded a cosmetic complaint for a reliable boot freeze. Reverted.

The correct shape is a SPLIT, not a reorder:

    PRE_APPS  = ["pulsar"]              touch no MIDI -> open immediately
    POST_APPS = ["ardour", "midimon"]   contend for the surface -> wait

Pulsar's GHCi reaches Tidal over TCP 6010 and never touches ALSA, so it can
open at once and give the button a visible effect within a second, while its own
multi-second startup overlaps converge instead of queuing behind it. Ardour
still waits for SuperDirt to own MIDI first. Converge output now appends to the
launch lines rather than overwriting them, so the log shows both halves.

GAP 2 — midimon was in NEITHER list. RIG UP never had it to open, so "had to
open midi watch myself" was exactly correct. Added to POST_APPS.

GAP 3 — midimon opened blind. `aseqdump` with no -p subscribes to nothing and
prints "Waiting for data at port", so every launch needed hand-wiring before it
could be used. Added resolve_port(), which picks the most interesting source
present, in a deliberate order: the lcxl3-driver's TRANSLATED stream first
(the numbers the corpus speaks — what you want when a knob does the wrong
thing), then the raw v3 DAW port (the numbers the hardware speaks — for "is it
even sending"), then the custom-mode port, then Midi Through as a catch-all.

It matches CLIENT and PORT names both, because python-rtmidi registers the
driver's client as 'RtMidiOut Client' and puts 'ParVagues LCXL3' on the port —
a client-only match misses precisely the stream most worth watching. That is
the third bug today caused by treating a MIDI endpoint's name as its identity.

Verified with the driver running: resolve_port() -> ('133:0', 'ParVagues LCXL3'),
i.e. it selects the translated stream over the two raw ports and Midi Through.
parent 9b482463
...@@ -101,9 +101,55 @@ def list_ports(): ...@@ -101,9 +101,55 @@ def list_ports():
subprocess.run(["aseqdump", "-l"]) subprocess.run(["aseqdump", "-l"])
# What to watch, best first. `aseqdump` with no -p subscribes to NOTHING and
# prints "Waiting for data at port ..." — so midimon opened blind and PLN had to
# wire it to Midi Through by hand every time ("had to wire it to midi through
# myself", 2026-08-29). A monitor that does not monitor anything on open is a
# monitor you have to repair before you can use it.
#
# The ORDER encodes what is actually interesting:
# 1. the lcxl3-driver's translated stream — the numbers the CORPUS speaks, i.e.
# what you actually want to verify when a knob does the wrong thing.
# 2. the raw v3 DAW port — the numbers the HARDWARE speaks, for when the
# question is "is the surface even sending".
# 3. the raw v3 custom-mode port, for standalone work.
# 4. Midi Through, the old default, kept last as a catch-all.
WATCH_PREFERENCE = ("ParVagues LCXL3", "LCXL3 1 DAW", "LCXL3", "Launch Control XL",
"Midi Through")
def resolve_port() -> tuple[str | None, str]:
"""(port_id, human_label) of the most interesting source present.
Matches CLIENT *and* PORT names: python-rtmidi registers the driver's client
as 'RtMidiOut Client' and puts 'ParVagues LCXL3' on the port, so a
client-only match misses exactly the stream we most want to watch.
"""
try:
out = subprocess.run(["aseqdump", "-l"], capture_output=True,
text=True, timeout=3).stdout
except (OSError, subprocess.SubprocessError):
return None, "aseqdump unavailable"
# `aseqdump -l` prints: " 20:1 LCXL3 1 LCXL3 1 DAW In"
rows = []
for line in out.splitlines():
tok = line.split()
if tok and ":" in tok[0] and tok[0].split(":")[0].isdigit():
rows.append((tok[0], line))
for want in WATCH_PREFERENCE:
for pid, line in rows:
if want in line:
return pid, f"{pid} · {want}"
return None, "no MIDI source found"
def monitor(port: str | None, raw: bool): def monitor(port: str | None, raw: bool):
label = ""
if not port:
port, label = resolve_port()
cmd = ["aseqdump"] + (["-p", port] if port else []) cmd = ["aseqdump"] + (["-p", port] if port else [])
print(f"{DIM}⚓ midimon — {' '.join(cmd)} · Ctrl-C to stop{RESET}", file=sys.stderr) where = label or (f"port {port}" if port else "NOTHING — nothing to watch")
print(f"{DIM}⚓ midimon — {where} · Ctrl-C to stop{RESET}", file=sys.stderr)
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, text=True, bufsize=1) proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, text=True, bufsize=1)
try: try:
for line in proc.stdout or []: for line in proc.stdout or []:
...@@ -123,7 +169,11 @@ def monitor(port: str | None, raw: bool): ...@@ -123,7 +169,11 @@ def monitor(port: str | None, raw: bool):
def main(argv=None): def main(argv=None):
ap = argparse.ArgumentParser(description=__doc__, ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter) formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("-p", "--port", help="source port (CLIENT:PORT), e.g. 28:0") ap.add_argument("-p", "--port",
help="source port (CLIENT:PORT), e.g. 28:0. Default: the most "
"interesting source present — the lcxl3-driver's "
"translated stream if it is up, else the raw surface, "
"else Midi Through.")
ap.add_argument("-l", "--list", action="store_true", help="list ports and exit") ap.add_argument("-l", "--list", action="store_true", help="list ports and exit")
ap.add_argument("--raw", action="store_true", help="passthrough, no parsing") ap.add_argument("--raw", action="store_true", help="passthrough, no parsing")
a = ap.parse_args(argv) a = ap.parse_args(argv)
......
...@@ -56,7 +56,33 @@ from rig_units import SERVICES as _INVENTORY # noqa: E402 ...@@ -56,7 +56,33 @@ from rig_units import SERVICES as _INVENTORY # noqa: E402
SERVICES = [(u, label, proc, blurb) for u, label, proc, _boot, blurb in _INVENTORY] SERVICES = [(u, label, proc, blurb) for u, label, proc, _boot, blurb in _INVENTORY]
# Apps converge refuses to launch, which is exactly what the button adds. # Apps converge refuses to launch, which is exactly what the button adds.
APPS = ["pulsar", "ardour"] #
# SPLIT BY MIDI CONTENTION, and the split is not cosmetic — it is the reason the
# original ordering (converge, THEN apps) existed at all.
#
# PLN, 2026-08-29: "superdirt froze before loading midi when in my launcher, when
# ardour was midi connected, had to untick ardour midi, then launch". Ardour
# holding the surface makes sclang hang inside MIDIClient.init / MIDIIn.connectAll
# — the same rawmidi contention that stops `amidi` writing LEDs while SC is up
# (reference_lcxl_two_midi_paths, reference_lcxl_led_transport). So SuperDirt MUST
# claim MIDI before Ardour is allowed near the surface. Launching Ardour first
# does not merely race: it reliably freezes the boot.
#
# But the complaint that started this was also real — RIG UP showed a spinner and
# no window for over a minute, because converge takes ~100 s on a cold boot, so
# PLN launched Ardour by hand believing the button had failed. The answer is to
# split rather than to reorder:
#
# PRE_APPS touch no MIDI, so they can open immediately and give the button a
# visible effect within a second. Pulsar is an editor; its GHCi talks
# to Tidal over TCP 6010, never to ALSA.
# POST_APPS contend for the surface (Ardour) or read the seq graph that only
# exists once SC is up (midimon), so they wait for converge.
PRE_APPS = ["pulsar"]
# midimon was in NEITHER list, which is the whole of "had to open midi watch
# myself": RIG UP never had it to open.
POST_APPS = ["ardour", "midimon"]
APPS = [*PRE_APPS, *POST_APPS] # kept: the rig list still reports on all
def _unit_states(units): def _unit_states(units):
...@@ -229,6 +255,21 @@ class _Converge: ...@@ -229,6 +255,21 @@ class _Converge:
def _run(self, launch_apps): def _run(self, launch_apps):
out, rc = "", 1 out, rc = "", 1
def _open(keys):
nonlocal out
for key in keys:
try:
res = L.launch(key)
out += f" {key}: {res.get('status')} — {res.get('msg')}\n"
except Exception as e: # never let a launcher kill the job
out += f" {key}: error — {e}\n"
# Immediately: the apps that cannot interfere with SuperDirt's MIDI init.
# This is what makes the button feel like it did something.
if launch_apps:
_open(PRE_APPS)
try: try:
# --converge implies --live. NOT --audio: that makes sound, and RIG UP # --converge implies --live. NOT --audio: that makes sound, and RIG UP
# must stay safe to press mid-set, backstage, or at 3am — the same # must stay safe to press mid-set, backstage, or at 3am — the same
...@@ -236,21 +277,17 @@ class _Converge: ...@@ -236,21 +277,17 @@ class _Converge:
r = subprocess.run(["bash", str(GIG_UP), "--converge", "--quiet"], r = subprocess.run(["bash", str(GIG_UP), "--converge", "--quiet"],
capture_output=True, text=True, timeout=300, capture_output=True, text=True, timeout=300,
cwd=str(TIDAL)) cwd=str(TIDAL))
out, rc = (r.stdout or "") + (r.stderr or ""), r.returncode out, rc = out + (r.stdout or "") + (r.stderr or ""), r.returncode
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
out, rc = "converge timed out after 300s", 124 out, rc = out + "converge timed out after 300s", 124
except (OSError, subprocess.SubprocessError) as e: except (OSError, subprocess.SubprocessError) as e:
out, rc = f"could not run {GIG_UP}: {e}", 127 out, rc = out + f"could not run {GIG_UP}: {e}", 127
# Only now: the ones that need SuperDirt to already own the surface.
# Best-effort and reported — a DAW that will not open must not make the
# whole button look failed.
if launch_apps: if launch_apps:
# The half converge refuses to do. Best-effort and reported: a DAW _open(POST_APPS)
# that will not open must not make the whole button look failed.
for key in APPS:
try:
res = L.launch(key)
out += f"\n {key}: {res.get('status')} — {res.get('msg')}"
except Exception as e: # never let a launcher kill the job
out += f"\n {key}: error — {e}"
with self._lock: with self._lock:
self._running = False self._running = False
......
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