Commit 9b482463 by PLN (Algolia)

fix(lcxl3): identity is the ALSA port id, not the name — and the surface now plays

Follow-up to fa22bb85, which fixed the direction trap but still could not run.
Two more defects, then the first verified end-to-end translation.

DEFECT 3 — the virtual port could never be found. _clients() returned
(port_id, CLIENT_name) and every lookup matched the client name. python-rtmidi
registers the client as 'RtMidiOut Client' and puts OUR chosen name on the
PORT:

    client 133: 'RtMidiOut Client' [type=user,pid=...]
        0 'ParVagues LCXL3 '

So `VIRTUAL_PORT_NAME in client_name` was false forever. The driver printed
"reconcile: src=None dst=130:0 — nothing to do yet" on every cycle while
`aconnect -i` listed the port plainly two lines below. _clients() now returns
(port_id, client_name, port_name) and _find_port() matches either — both names
are real and each is load-bearing somewhere.

DEFECT 4 — prune() would have cut the driver's own link. It skipped "our" port
by testing VIRTUAL_PORT_NAME against the name from `aconnect -l`, which is the
CLIENT name — 'RtMidiOut Client'. That never matches, so the very first prune
after a successful connect would have severed the connection it had just made,
every cycle, forever. prune() now takes an explicit set of protected PORT IDS.
Identity here is the ALSA port id; the names are decoration. Any place that
matches a MIDI endpoint by name on this rig is a latent version of this bug.

Also: reconcile() prunes only AFTER a confirmed connect. Cutting the raw
surface while the replacement stream is not actually delivering would leave a
dead surface, which is worse than a wrong one.

VERIFIED END TO END on the live rig, PLN's hands on the hardware:

    fader 1      v3 #5   -> v2 #77   (38 events, full sweep 37..0)
    button E1    v3 #37  -> v2 #41   (127 then 0)

^77 is lcxl_grid's row D cell 1 and ^41 is the kick gate — the exact control
the #94 migration aligned all 169 tracks onto, and the one v3 renumbering would
have moved to button E5. 0 unmapped, 0 dropped. The graph after prune:

    133:0  RtMidiOut Client  -> 130:0    (ours, the only surface path)
    20:0 / 20:1 LCXL3 1                  GONE from SuperCollider
    14:0 Midi Through -> 130:2           re-cut every cycle

Open, and deliberately not guessed at: something re-adds Midi Through -> SC
within 5 s. midi-autoconnect only ever creates the OTHER direction, so the
re-adder is unidentified. prune contains it each cycle; the window is real but
small, and naming a culprit without evidence is how the last three "rig is
broken" findings turned out to be the measuring tool.

Resume point 1 is closed: the driver has met a live SuperCollider, and it took
four defects to get one fader through. Every one of them was invisible to
reading — three reported success while doing nothing.
parent fa22bb85
...@@ -130,95 +130,117 @@ def _aconnect_pairs() -> str: ...@@ -130,95 +130,117 @@ def _aconnect_pairs() -> str:
return "" return ""
def _clients(direction: str) -> list[tuple[str, str]]: def _clients(direction: str) -> list[tuple[str, str, str]]:
"""[(client:port, name)] from `aconnect -i|-o`.""" """[(port_id, client_name, port_name)] from `aconnect -i|-o`.
BOTH names, because they are genuinely different and each is load-bearing:
python-rtmidi registers the CLIENT as 'RtMidiOut Client' and puts our chosen
name on the PORT. Matching only the client name (the original code) meant the
driver's own virtual port could never be found — it reported src=None while
`aconnect -i` listed 'ParVagues LCXL3' plainly.
"""
try: try:
out = subprocess.run( out = subprocess.run(
["aconnect", direction], capture_output=True, text=True, timeout=3 ["aconnect", direction], capture_output=True, text=True, timeout=3
).stdout ).stdout
except Exception: except Exception:
return [] return []
res, cur = [], None res, cid, cname = [], None, ""
for line in out.splitlines(): for line in out.splitlines():
if line.startswith("client "): if line.startswith("client "):
cid = line.split()[1].rstrip(":")
parts = line.split("'") parts = line.split("'")
cur = (line.split()[1].rstrip(":"), parts[1].strip() if len(parts) > 1 else "") cname = parts[1].strip() if len(parts) > 1 else ""
elif cur and line.strip() and line[0] in " \t": elif cid and line.strip() and line[0] in " \t":
tok = line.strip().split() tok = line.strip().split()
if tok and tok[0].isdigit(): if tok and tok[0].isdigit():
res.append((f"{cur[0]}:{tok[0]}", cur[1])) parts = line.split("'")
pname = parts[1].strip() if len(parts) > 1 else ""
res.append((f"{cid}:{tok[0]}", cname, pname))
return res return res
def _find_port(direction: str, needle: str) -> str | None:
"""First port whose CLIENT or PORT name contains needle."""
for pid, cname, pname in _clients(direction):
if needle in cname or needle in pname:
return pid
return None
def _sc_input_ports() -> list[str]: def _sc_input_ports() -> list[str]:
"""SuperCollider's own MIDI *destination* ports, e.g. ['130:0', '130:1', ...]. """SuperCollider's MIDI *destination* ports, e.g. ['130:0', ...].
THE DIRECTION TRAP, and it bit the original reconcile() silently. THE DIRECTION TRAP, which bit the original reconcile() silently.
`aconnect -i` lists ports you can read FROM (sources); `aconnect -o` lists `aconnect -i` lists ports you can read FROM; `-o` lists ports you can
ports you can write TO (destinations). SuperCollider appears in BOTH: its write TO. SuperCollider appears in both: in0..inN are destinations,
in0..inN are destinations, its out0..outN are sources. Asking `-i` for out0..outN are sources. Asking `-i` for "SuperCollider" returns out0
"SuperCollider" therefore returns out0 (130:5 here), and (130:5 on this rig), and `aconnect <virt> 130:5` writes into an
`aconnect <virt> 130:5` writes into an output-only port. It fails — and output-only port. It fails — and the original code discarded the
because the original code discarded the CompletedProcess it then printed CompletedProcess, then printed "reconcile: src -> dst" as if it had
"reconcile: src -> dst" as if it had worked. The driver had never worked. Nothing was ever connected.
connected anything to SC in its life.
""" """
return [p for p, n in _clients("-o") if "SuperCollider" in n] return [pid for pid, cname, _ in _clients("-o") if "SuperCollider" in cname]
def _sources_feeding(dst: str) -> list[tuple[str, str]]: def _sources_feeding(dst: str) -> list[tuple[str, str]]:
"""[(src_port, src_client_name)] currently connected INTO dst. """[(src_port_id, src_client_name)] currently connected INTO dst.
Parsed off the SOURCE side ("Connecting To: ...") because that is the only Parsed off the SOURCE side ("Connecting To: ..."), the only place
place `aconnect -l` prints an edge with both of its ends. `aconnect -l` prints an edge with both of its ends.
""" """
res, cur_client, cur_name, port = [], None, "", None res, cid, cname, port = [], None, "", None
for line in _aconnect_pairs().splitlines(): for line in _aconnect_pairs().splitlines():
if line.startswith("client "): if line.startswith("client "):
cur_client = line.split()[1].rstrip(":") cid = line.split()[1].rstrip(":")
parts = line.split("'") parts = line.split("'")
cur_name = parts[1].strip() if len(parts) > 1 else "" cname = parts[1].strip() if len(parts) > 1 else ""
port = None port = None
elif cur_client and line.strip() and line[0] in " \t": elif cid and line.strip() and line[0] in " \t":
tok = line.strip().split() tok = line.strip().split()
if tok and tok[0].isdigit(): if tok and tok[0].isdigit():
port = f"{cur_client}:{tok[0]}" port = f"{cid}:{tok[0]}"
elif "Connecting To:" in line and port: elif "Connecting To:" in line and port:
for t in line.split("Connecting To:")[1].split(","): for t in line.split("Connecting To:")[1].split(","):
if t.strip().split("[")[0].strip() == dst: if t.strip().split("[")[0].strip() == dst:
res.append((port, cur_name)) res.append((port, cname))
return res return res
# Clients whose traffic must NOT reach SuperCollider while we translate. # Clients whose traffic must NOT reach SuperCollider while we translate. These
# These deliver the surface's RAW v3 numbers, which collide with the corpus's v2 # carry the surface's RAW v3 numbers, which collide with the corpus's v2 numbers
# numbers on 16 indices with a DIFFERENT meaning — so leaving them connected does # on 16 indices with a DIFFERENT meaning — so leaving them connected does not
# not merely duplicate, it fires the WRONG control alongside the right one. Row C # merely duplicate, it fires the WRONG control alongside the right one: row C
# knob 4 would drive both ^52 (correct) and ^32 (row B's cell). # knob 4 would drive ^52 (correct) and ^32 (row B's cell) at once.
RAW_CLIENTS = ("LCXL3", "Launch Control XL") RAW_CLIENTS = ("LCXL3", "Launch Control XL")
# Midi Through carries whatever midi-autoconnect's rule 1 pushes into it — the # Midi Through carries whatever midi-autoconnect's rule 1 pushes into it — the
# surface again, i.e. a second copy of the same stream. A doubled CC is survivable # surface again, i.e. a second copy of the same stream. A doubled CC is
# on a knob and fatal on a latch: the toggle fires twice and cancels itself, which # survivable on a knob and fatal on a latch: the toggle fires twice and cancels,
# reads from the stage as "the button does nothing". # which reads from the stage as "the button does nothing".
THRU_CLIENTS = ("Midi Through",) THRU_CLIENTS = ("Midi Through",)
def prune(verbose: bool = False, cut_thru: bool = True) -> list[str]: def prune(protect: set[str], verbose: bool = False,
"""Cut every path into SuperCollider that is not our translated port. cut_thru: bool = True) -> list[str]:
"""Cut every raw path into SuperCollider, protecting `protect` port ids.
PROTECT BY PORT ID, NOT BY NAME. python-rtmidi calls our client
'RtMidiOut Client', so a name-based "skip our own port" test does not match
and prune would cheerfully cut the very link it just made. Identity here is
the ALSA port id; the names are decoration.
WHY THIS EXISTS — found against a LIVE SuperCollider, 2026-08-29 WHY THIS EXISTS — found against a LIVE SuperCollider, 2026-08-29
`MIDIIn.connectAll` subscribes SC to EVERY source present at boot. On `MIDIIn.connectAll` subscribes SC to EVERY source present at boot: on
this rig that was in2=Midi Through, in3=LCXL3 MIDI In, in4=LCXL3 DAW In. this rig in2=Midi Through, in3=LCXL3 MIDI, in4=LCXL3 DAW. So the moment
So the moment the driver published a correct translated stream, SC heard the driver published a correct translated stream, SC heard it ALONGSIDE
it ALONGSIDE two raw ones. reconcile() only ever added links, so the two raw ones. reconcile() only ever ADDED links, so the driver could not
driver could not have been correct here however right its translation have been correct here however right its translation was.
was. This is the half that only a live SC could show.
""" """
cut = [] cut = []
bad = RAW_CLIENTS + (THRU_CLIENTS if cut_thru else ()) bad = RAW_CLIENTS + (THRU_CLIENTS if cut_thru else ())
for dst in _sc_input_ports(): for dst in _sc_input_ports():
for src, name in _sources_feeding(dst): for src, name in _sources_feeding(dst):
if VIRTUAL_PORT_NAME in name: if src in protect:
continue continue
if any(b in name for b in bad): if any(b in name for b in bad):
subprocess.run(["aconnect", "-d", src, dst], subprocess.run(["aconnect", "-d", src, dst],
...@@ -230,26 +252,29 @@ def prune(verbose: bool = False, cut_thru: bool = True) -> list[str]: ...@@ -230,26 +252,29 @@ def prune(verbose: bool = False, cut_thru: bool = True) -> list[str]:
def reconcile(verbose: bool = False, cut_thru: bool = True) -> bool: def reconcile(verbose: bool = False, cut_thru: bool = True) -> bool:
"""Wire our virtual port into SC's MIDI input, and cut the raw ones.""" """Wire our virtual port into SC's MIDI input, then cut the raw ones.
src = next((p for p, n in _clients("-i") if VIRTUAL_PORT_NAME in n), None)
if src is None: Order matters and is deliberate: we only prune AFTER a confirmed connect.
src = next((p for p, n in _clients("-o") if VIRTUAL_PORT_NAME in n), None) Cutting the raw surface while our replacement stream is not actually
dst = next(iter(_sc_input_ports()), None) # -o, NOT -i: see the note there delivering would leave PLN with a dead surface — worse than a wrong one.
"""
src = _find_port("-i", VIRTUAL_PORT_NAME)
dst = next(iter(_sc_input_ports()), None) # -o, NOT -i: see note above
if not src or not dst: if not src or not dst:
if verbose: if verbose:
print(f" reconcile: src={src} dst={dst} — nothing to do yet") print(f" reconcile: src={src} dst={dst} — nothing to do yet")
return False return False
r = subprocess.run(["aconnect", src, dst], capture_output=True, text=True, timeout=3) r = subprocess.run(["aconnect", src, dst], capture_output=True,
text=True, timeout=3)
err = (r.stderr or "").strip() err = (r.stderr or "").strip()
ok = r.returncode == 0 or "already subscribed" in err.lower() already = "already subscribed" in err.lower()
if not ok: if r.returncode != 0 and not already:
# Report it. A reconciler that cannot say "I failed" is decoration. # Report it. A reconciler that cannot say "I failed" is decoration.
print(f" reconcile: FAILED {src} -> {dst}: {err or r.returncode}") print(f" reconcile: FAILED {src} -> {dst}: {err or r.returncode}")
return False return False
if verbose: if verbose:
print(f" reconcile: {src} -> {dst}" print(f" reconcile: {src} -> {dst}" + (" (already)" if already else ""))
+ (" (already)" if "already" in err.lower() else "")) prune({src}, verbose=verbose, cut_thru=cut_thru)
prune(verbose=verbose, cut_thru=cut_thru)
return True return True
......
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