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:
return ""
def _clients(direction: str) -> list[tuple[str, str]]:
"""[(client:port, name)] from `aconnect -i|-o`."""
def _clients(direction: str) -> list[tuple[str, str, str]]:
"""[(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:
out = subprocess.run(
["aconnect", direction], capture_output=True, text=True, timeout=3
).stdout
except Exception:
return []
res, cur = [], None
res, cid, cname = [], None, ""
for line in out.splitlines():
if line.startswith("client "):
cid = line.split()[1].rstrip(":")
parts = line.split("'")
cur = (line.split()[1].rstrip(":"), parts[1].strip() if len(parts) > 1 else "")
elif cur and line.strip() and line[0] in " \t":
cname = parts[1].strip() if len(parts) > 1 else ""
elif cid and line.strip() and line[0] in " \t":
tok = line.strip().split()
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
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]:
"""SuperCollider's own MIDI *destination* ports, e.g. ['130:0', '130:1', ...].
THE DIRECTION TRAP, and it bit the original reconcile() silently.
`aconnect -i` lists ports you can read FROM (sources); `aconnect -o` lists
ports you can write TO (destinations). SuperCollider appears in BOTH: its
in0..inN are destinations, its out0..outN are sources. Asking `-i` for
"SuperCollider" therefore returns out0 (130:5 here), and
`aconnect <virt> 130:5` writes into an output-only port. It fails — and
because the original code discarded the CompletedProcess it then printed
"reconcile: src -> dst" as if it had worked. The driver had never
connected anything to SC in its life.
"""SuperCollider's MIDI *destination* ports, e.g. ['130:0', ...].
THE DIRECTION TRAP, which bit the original reconcile() silently.
`aconnect -i` lists ports you can read FROM; `-o` lists ports you can
write TO. SuperCollider appears in both: in0..inN are destinations,
out0..outN are sources. Asking `-i` for "SuperCollider" returns out0
(130:5 on this rig), and `aconnect <virt> 130:5` writes into an
output-only port. It fails — and the original code discarded the
CompletedProcess, then printed "reconcile: src -> dst" as if it had
worked. Nothing was ever connected.
"""
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]]:
"""[(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
place `aconnect -l` prints an edge with both of its ends.
Parsed off the SOURCE side ("Connecting To: ..."), the only place
`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():
if line.startswith("client "):
cur_client = line.split()[1].rstrip(":")
cid = line.split()[1].rstrip(":")
parts = line.split("'")
cur_name = parts[1].strip() if len(parts) > 1 else ""
cname = parts[1].strip() if len(parts) > 1 else ""
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()
if tok and tok[0].isdigit():
port = f"{cur_client}:{tok[0]}"
port = f"{cid}:{tok[0]}"
elif "Connecting To:" in line and port:
for t in line.split("Connecting To:")[1].split(","):
if t.strip().split("[")[0].strip() == dst:
res.append((port, cur_name))
res.append((port, cname))
return res
# Clients whose traffic must NOT reach SuperCollider while we translate.
# These deliver the surface's RAW v3 numbers, which collide with the corpus's v2
# numbers on 16 indices with a DIFFERENT meaning — so leaving them connected does
# not 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).
# Clients whose traffic must NOT reach SuperCollider while we translate. These
# carry the surface's RAW v3 numbers, which collide with the corpus's v2 numbers
# on 16 indices with a DIFFERENT meaning — so leaving them connected does not
# merely duplicate, it fires the WRONG control alongside the right one: row C
# knob 4 would drive ^52 (correct) and ^32 (row B's cell) at once.
RAW_CLIENTS = ("LCXL3", "Launch Control XL")
# 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
# on a knob and fatal on a latch: the toggle fires twice and cancels itself, which
# reads from the stage as "the button does nothing".
# surface again, i.e. a second copy of the same stream. A doubled CC is
# survivable on a knob and fatal on a latch: the toggle fires twice and cancels,
# which reads from the stage as "the button does nothing".
THRU_CLIENTS = ("Midi Through",)
def prune(verbose: bool = False, cut_thru: bool = True) -> list[str]:
"""Cut every path into SuperCollider that is not our translated port.
def prune(protect: set[str], verbose: bool = False,
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
`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.
So the moment the driver published a correct translated stream, SC heard
it ALONGSIDE two raw ones. reconcile() only ever added links, so the
driver could not have been correct here however right its translation
was. This is the half that only a live SC could show.
`MIDIIn.connectAll` subscribes SC to EVERY source present at boot: on
this rig in2=Midi Through, in3=LCXL3 MIDI, in4=LCXL3 DAW. So the moment
the driver published a correct translated stream, SC heard it ALONGSIDE
two raw ones. reconcile() only ever ADDED links, so the driver could not
have been correct here however right its translation was.
"""
cut = []
bad = RAW_CLIENTS + (THRU_CLIENTS if cut_thru else ())
for dst in _sc_input_ports():
for src, name in _sources_feeding(dst):
if VIRTUAL_PORT_NAME in name:
if src in protect:
continue
if any(b in name for b in bad):
subprocess.run(["aconnect", "-d", src, dst],
......@@ -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:
"""Wire our virtual port into SC's MIDI input, and cut the raw ones."""
src = next((p for p, n in _clients("-i") if VIRTUAL_PORT_NAME in n), None)
if src is None:
src = next((p for p, n in _clients("-o") if VIRTUAL_PORT_NAME in n), None)
dst = next(iter(_sc_input_ports()), None) # -o, NOT -i: see the note there
"""Wire our virtual port into SC's MIDI input, then cut the raw ones.
Order matters and is deliberate: we only prune AFTER a confirmed connect.
Cutting the raw surface while our replacement stream is not actually
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 verbose:
print(f" reconcile: src={src} dst={dst} — nothing to do yet")
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()
ok = r.returncode == 0 or "already subscribed" in err.lower()
if not ok:
already = "already subscribed" in err.lower()
if r.returncode != 0 and not already:
# Report it. A reconciler that cannot say "I failed" is decoration.
print(f" reconcile: FAILED {src} -> {dst}: {err or r.returncode}")
return False
if verbose:
print(f" reconcile: {src} -> {dst}"
+ (" (already)" if "already" in err.lower() else ""))
prune(verbose=verbose, cut_thru=cut_thru)
print(f" reconcile: {src} -> {dst}" + (" (already)" if already else ""))
prune({src}, verbose=verbose, cut_thru=cut_thru)
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