Commit 560e0e95 by PLN (Algolia)

feat(rig): one supervised surface tap, and an output that follows a preference list

Two pieces of the cockpit (#155, #41), both written the same way: resolve by
IDENTITY every time, never cache an address.

tools/bridge/surface.py — THE control-surface tap
-------------------------------------------------
Four tools each spawned their own aseqdump. This morning three of them were
bound to ALSA client 24 (an Arturia KeyStep) instead of 20 (the Launch Control
XL) because a replug had renumbered the clients, and each resolves its port
once, at start. I "fixed" them by moving them to 20:0.

Then PLN replugged again this evening and the numbers SWAPPED BACK:

    client 20: 'Arturia KeyStep 32'     <- was the LCXL
    client 24: 'Launch Control XL'      <- was the KeyStep

So the two I fixed are wrong again and the one I called broken is now right.
An address is a lease, not an identity. This module resolves "Launch Control
XL" by name on EVERY respawn, so a replug costs one backoff interval instead of
a silent session. First run against the live rig, with zero configuration:

    starting   port=None
    ok         port=24:0

It also keeps the last value of every CC, and — the part that matters — records
`seen: false` distinctly from `value: 0`. An untouched control is what silences
a Tidal stream (a bare "^NN" with no value yields NO EVENTS), so a cockpit that
rendered untouched-as-zero would hide the exact failure it exists to show.

And it does not litter. Three smoke runs of this file each orphaned an
aseqdump to pid 1 — precisely the mess PLN complained about this morning
("i see 4 aseqdumps... you had one job"). try/finally is not enough, because
SIGKILL, OOM and `timeout` all skip it. PR_SET_PDEATHSIG puts the guarantee in
the kernel: the tap dies with its parent however the parent dies.

tools/master-out.py — Ardour Master follows a preference list
-------------------------------------------------------------
PLN: "can we control this tedious routing... ideally theres a tie breaking list
of choice, from uhc to headphones to speakers". Ardour's master does not follow
the PipeWire default sink, so every rig move means re-patching in qjackctl.

Not "route to X" but "route to the best X actually present": UMC -> Headphones
-> Speakers, matched as substrings of the node name so serial numbers and
profile suffixes do not matter.

Two properties make it safe to run mid-set:
  * IDEMPOTENT — computes wanted links, diffs against existing, applies only
    the delta. Already correct = zero pw-link calls = cannot glitch the audio.
    The naive unlink-then-relink is a guaranteed dropout even when nothing
    changed.
  * EXCLUSIVE BUT NARROW — removes Master links to other sinks (or plugging the
    UMC back in gives you doubled output), and touches nothing else. The limit
    is a predicate, `_is_ours`, not a good intention: apply() refuses to unlink
    any source that is not a Master output.

Verified against KNOWN-BAD states, not just against agreement with reality —
the whole lesson of this session. Ten checks: preference order at each of the
three tiers, the "master stranded on headphones when the UMC appears" case
(2 adds + 2 removes), idempotency (0 and 0), and the guard refusing both a
foreign link in plan() and a foreign source handed straight to apply().
All pass. Live dry-run reports "already correct — 0 changes".
parent 098d5712
#!/usr/bin/env python3
"""surface — the control surface as ONE piece of always-on state.
The cockpit's foundation (#155). Before this, four separate tools each spawned
their own `aseqdump`: gig-log, lcxl-leds, the Pulsar HUD, and whatever PLN had
left in a terminal. On 2026-08-14 three of the four were bound to ALSA client
24 (an Arturia KeyStep) instead of 20 (the Launch Control XL), because a replug
had renumbered the clients and every one of them resolves its port ONCE, at
start. Silent, of course: the LEDs stayed dark and the log recorded a surface
nobody was touching.
So this module exists to be the ONLY subscriber, and to fix the three things
that made the old shape fragile:
1. RESOLVE BY NAME, EVERY RESPAWN. A port address (`20:0`) is not an
identity, it is a lease. `Launch Control XL` is the identity. Re-resolved
on every (re)spawn, so a replug costs one backoff interval, not a silent
session.
2. STAY UP. midistream.MidiStream is deliberately lazy — it opens the port on
first subscribe and closes it when the last watcher leaves. Correct for a
monitor tab, wrong for a cockpit: the surface's VALUES are the state, and
state you only collect while someone is looking is not state. The
supervisor here never exits.
3. REMEMBER. Keep the last value of every CC, so a view that connects
mid-set paints the real board immediately instead of a blank one.
That third point carries the day's hardest-won lesson. `_ctrl` holds ONLY the
controls that have actually been seen, and "absent" is a distinct, meaningful
answer — NOT the same as zero. An untouched control is exactly what silences a
Tidal stream (a bare "^NN" with no value yields no events at all), so a cockpit
that renders untouched-as-0 would hide the single failure it exists to show.
Callers get `seen: false` and must decide what to do about it.
Pure functions (`resolve_port`, `load_grid`, `Surface.apply`) are unit-testable
without ALSA. But green tests on those prove nothing about the seam — the tap
must also be watched against a real surface. See tests/ and the README.
"""
from __future__ import annotations
import json
import subprocess
import threading
import time
from pathlib import Path
import midimon
import midistream
# The surface we care about. Matched as a case-insensitive substring against the
# CLIENT name from `aseqdump -l`, never against a port address.
SURFACE_NAME = "Launch Control XL"
# tools/lcxl_grid.json — CC → {row, column, label, role, owner}. Loaded once and
# joined onto every event, so subscribers never have to know the mapping. This
# is the first join of the shared model: the surface's PHYSICS (what moved) and
# its MEANING (which orbit that knob owns) arrive together or not at all.
GRID_PATH = Path(__file__).resolve().parent.parent / "lcxl_grid.json"
_BACKOFF_MIN = 0.5
_BACKOFF_MAX = 5.0
def resolve_port(ports: list[dict], name: str = SURFACE_NAME) -> str | None:
"""Pick the surface's address out of `midistream.parse_ports` output.
Pure. Prefers the LOWEST port number on the matching client: the LCXL
exposes two ports (`…Launch Contro` and `…HUI`) and only the first carries
the knobs and buttons. Returns None when the surface is not plugged in,
which is a normal state, not an error.
"""
hits = [p for p in ports if name.lower() in (p.get("client") or "").lower()]
if not hits:
return None
def portnum(p):
try:
return int(str(p["addr"]).split(":")[1])
except (KeyError, IndexError, ValueError):
return 9999
return sorted(hits, key=portnum)[0]["addr"]
def load_grid(path: Path = GRID_PATH) -> dict[int, dict]:
"""CC → metadata from lcxl_grid.json, tolerant of where it sits in the file.
Walks for any dict whose values look like control entries (they carry a
`row`), so a future reshuffle of the file's top level does not silently
yield an empty map — the failure mode would be a cockpit that renders every
control as unmapped, which reads as a hardware fault.
"""
out: dict[int, dict] = {}
try:
doc = json.loads(Path(path).read_text())
except (OSError, ValueError):
return out
def walk(node):
if not isinstance(node, dict):
return
for k, v in node.items():
if isinstance(v, dict) and "row" in v:
try:
out[int(k)] = v
except (TypeError, ValueError):
pass
else:
walk(v)
walk(doc)
return out
class Surface:
"""Always-on, self-healing tap on the control surface, plus its live values."""
def __init__(self, name: str = SURFACE_NAME, grid_path: Path = GRID_PATH,
autostart: bool = True):
self.name = name
self.grid = load_grid(grid_path)
self._lock = threading.Lock()
self._ctrl: dict[int, dict] = {} # cc -> last value; ABSENT means never seen
self._subs: set = set()
self._port: str | None = None
self._state = "starting" # starting | ok | searching | no-tool
self._respawns = -1 # first spawn is not a respawn
self._started_at = time.time()
self._last_event = None
self._proc = None
self._stop = threading.Event()
if autostart:
threading.Thread(target=self._supervise, daemon=True).start()
# ── public ──────────────────────────────────────────────────────────
def snapshot(self) -> dict:
"""Everything a freshly-connected view needs to paint the board once."""
with self._lock:
controls = []
for cc, meta in sorted(self.grid.items()):
live = self._ctrl.get(cc)
controls.append({
"cc": cc,
"label": meta.get("label"),
"row": meta.get("row"),
"column": meta.get("column"),
"role": meta.get("role"),
"owner": meta.get("owner"),
# `seen` is the point. False means this control has never
# been moved since the tap came up, which for a bare "^NN"
# in a pattern means that stream emits NOTHING.
"seen": live is not None,
"value": (live or {}).get("value"),
"norm": (live or {}).get("norm"),
"at": (live or {}).get("at"),
})
# CCs seen on the wire that the grid does not know about — an
# external keyboard, or a grid that has drifted from the hardware.
unmapped = sorted(cc for cc in self._ctrl if cc not in self.grid)
return {
"port": self._port,
"state": self._state,
"respawns": max(0, self._respawns),
"uptime_s": round(time.time() - self._started_at, 1),
"last_event_s": (round(time.time() - self._last_event, 1)
if self._last_event else None),
"seen_count": len(self._ctrl),
"grid_count": len(self.grid),
"unmapped": unmapped,
"controls": controls,
}
def subscribe(self):
import queue
q: "queue.Queue" = queue.Queue(maxsize=512)
with self._lock:
self._subs.add(q)
return q
def unsubscribe(self, q) -> None:
with self._lock:
self._subs.discard(q)
def apply(self, ev: dict) -> dict:
"""Fold one enriched event into the value map; return it, grid-annotated.
Separated from the reader thread so it can be tested without ALSA.
Only control-change events carry state; notes and clock pass through
untouched (a note you played is history, a knob position is truth).
"""
cc = ev.get("controller")
if cc is not None and (ev.get("event") or "").lower().startswith("control"):
val = ev.get("value")
if val is not None:
now = time.time()
with self._lock:
self._ctrl[cc] = {"value": val, "norm": round(val / 127.0, 4),
"at": now}
self._last_event = now
meta = self.grid.get(cc)
if meta:
ev = dict(ev, label=meta.get("label"), row=meta.get("row"),
column=meta.get("column"), role=meta.get("role"),
owner=meta.get("owner"))
ev = dict(ev, norm=round(val / 127.0, 4))
return ev
def stop(self) -> None:
"""Stop supervising AND kill the tap. Both halves matter: setting the
event alone leaves _read blocked on the child's stdout, so the process
lingers until the next MIDI byte arrives — which on an idle surface is
never."""
self._stop.set()
proc, self._proc = self._proc, None
if proc is not None:
try:
proc.terminate()
except OSError:
pass
# ── supervisor ──────────────────────────────────────────────────────
def _supervise(self) -> None:
"""Resolve → spawn → read → backoff → repeat, forever.
The re-resolve at the TOP of the loop is the whole fix: every respawn
looks the surface up by name again, so a replug that renumbers the ALSA
client is absorbed automatically instead of leaving a tap wired to
whatever device inherited the old address.
"""
backoff = _BACKOFF_MIN
while not self._stop.is_set():
ports = self._list_ports()
addr = resolve_port(ports, self.name)
if addr is None:
with self._lock:
self._state = "searching"
self._port = None
self._stop.wait(backoff)
backoff = min(backoff * 2, _BACKOFF_MAX)
continue
proc = self._spawn(addr)
self._proc = proc
if proc is None:
with self._lock:
self._state = "no-tool"
self._stop.wait(_BACKOFF_MAX)
continue
with self._lock:
self._state = "ok"
self._port = addr
self._respawns += 1
backoff = _BACKOFF_MIN
self._read(proc) # blocks until the tap dies
with self._lock:
self._state = "searching"
try:
proc.terminate()
except OSError:
pass
self._stop.wait(backoff)
def _list_ports(self) -> list[dict]:
try:
r = subprocess.run(["aseqdump", "-l"], capture_output=True,
text=True, timeout=5)
except (OSError, subprocess.SubprocessError):
return []
return midistream.parse_ports(r.stdout)
def _spawn(self, addr: str):
"""Spawn the tap so the KERNEL kills it when we die.
Earned the hard way on 2026-08-14: three smoke-test runs of this very
module each left an `aseqdump` behind, reparented to pid 1 — the exact
litter PLN had just complained about ("i see 4 aseqdumps... you had one
job"). try/finally is not enough, because the parent does not always get
to run its cleanup: a SIGKILL, an OOM, or `timeout` leaves the child
orphaned and holding a MIDI port.
PR_SET_PDEATHSIG (prctl option 1) moves the guarantee into the kernel:
the child is signalled when its parent thread exits, however it exits.
Belt-and-braces with the terminate() in _supervise and the atexit hook.
"""
def _die_with_parent():
try:
import ctypes
import signal
ctypes.CDLL("libc.so.6", use_errno=True).prctl(1, signal.SIGTERM)
except Exception:
pass # non-Linux or no libc — fall back to try/finally
try:
return subprocess.Popen(["aseqdump", "-p", addr],
stdout=subprocess.PIPE, text=True, bufsize=1,
preexec_fn=_die_with_parent)
except OSError:
return None
def _read(self, proc) -> None:
for line in proc.stdout or []:
if self._stop.is_set():
return
ev = midimon.parse_line(line)
if not ev:
continue
self._fanout(self.apply(midimon.enrich(ev)))
def _fanout(self, ev: dict) -> None:
"""Drop the OLDEST on a full queue — for surface state the newest IS truth.
Same policy and same reason as midistream._fanout: a subscriber that
stalls once must not be pinned to stale values forever.
"""
import queue
with self._lock:
for q in self._subs:
try:
q.put_nowait(ev)
except queue.Full:
try:
q.get_nowait()
q.put_nowait(ev)
except (queue.Empty, queue.Full):
pass
if __name__ == "__main__":
# Manual smoke: watch the real surface. Green unit tests on resolve_port
# prove nothing about whether the tap actually binds — this is the seam.
import argparse
ap = argparse.ArgumentParser()
ap.add_argument("--seconds", type=float, default=20)
a = ap.parse_args()
import atexit
s = Surface()
atexit.register(s.stop)
t0 = time.time()
while time.time() - t0 < a.seconds:
snap = s.snapshot()
print(f" {snap['state']:<10} port={snap['port']} "
f"seen {snap['seen_count']}/{snap['grid_count']} "
f"respawns={snap['respawns']} "
f"last_event={snap['last_event_s']}s", flush=True)
time.sleep(2)
#!/usr/bin/env python3
"""master-out — point Ardour's Master at the best available output, by preference.
#41. Ardour's master does NOT follow the PipeWire default sink, so every time
the rig moves — studio to kitchen table, UMC plugged or not, headphones in —
the Master output has to be re-patched by hand in qjackctl. PLN, 2026-08-14:
"can we control this tedious routing... ideally theres a tie breaking list of
choice, from uhc to headphones to speakers".
That is exactly the shape: not "route to X", but "route to the best X that is
actually here right now". So this is a PREFERENCE LIST plus a converger.
tools/master-out.py # show state + the plan, change nothing
tools/master-out.py --apply # converge
tools/master-out.py --watch 5 # converge whenever the hardware changes
THE TWO PROPERTIES THAT MAKE THIS SAFE TO RUN WHILE PLAYING
IDEMPOTENT — it computes the links it WANTS, diffs against the links that
EXIST, and applies only the difference. Already correct means literally zero
pw-link calls, so re-running mid-set is a no-op and cannot glitch the audio.
Every previous version of this idea in this repo failed by unlinking first
and relinking after, which is a guaranteed dropout even when nothing changed.
EXCLUSIVE, BUT NARROWLY — it removes Master links to sinks OTHER than the
chosen one (otherwise you get doubled output the first time you plug the UMC
back in), and it touches nothing else. SuperDirt->Ardour track links, the
capture chain, and anything not originating at Master/audio_out are left
alone. The blast radius is a predicate, not a promise: see `_is_ours`.
DELIBERATELY NOT AUTOMATIC BY DEFAULT. Ripping the output from under a live set
because a headphone jack was nudged is worse than the manual patching this
replaces. Run --watch when you want it; the gig path should pin the device and
verify it, not chase it.
"""
from __future__ import annotations
import argparse
import subprocess
import sys
import time
# In preference order. Matched as a case-insensitive substring against the
# PipeWire node name, so it survives the serial number and the profile suffix
# ("...UMC202HD_192k_12345678-00.Direct__Direct__sink").
PRIORITY = [
("UMC / Behringer", "umc202hd"),
("Headphones", "headphones"),
("Speakers", "speaker"),
]
SOURCE_NODE = "ardour"
SOURCE_PORTS = ("Master/audio_out 1", "Master/audio_out 2")
# playback_FL/FR is the near-universal PipeWire naming for a stereo sink. Kept
# explicit rather than "first two ports" — guessing port order is how you end
# up with a silently swapped stereo image.
SINK_PORTS = ("playback_FL", "playback_FR")
def _run(args: list[str]) -> str:
try:
r = subprocess.run(args, capture_output=True, text=True, timeout=10)
return r.stdout
except (OSError, subprocess.SubprocessError):
return ""
def sinks() -> list[str]:
"""Distinct sink node names that currently exist, from their input ports."""
out, seen = [], set()
for line in _run(["pw-link", "-i"]).splitlines():
line = line.strip()
if not line.startswith("alsa_output") or ":" not in line:
continue
node = line.rsplit(":", 1)[0]
if node not in seen:
seen.add(node)
out.append(node)
return out
def choose(available: list[str], priority=PRIORITY) -> tuple[str | None, str | None]:
"""First (label, node) in preference order that is actually present. Pure."""
for label, needle in priority:
for node in available:
if needle in node.lower():
return label, node
return None, None
def links() -> list[tuple[str, str]]:
"""Existing (output_port, input_port) pairs, parsed from `pw-link -l`.
pw-link prints a port, then its connections indented with |-> or |<-. Only
the `|->` (outgoing) lines under an output port are ours to reason about.
"""
out: list[tuple[str, str]] = []
current = None
for raw in _run(["pw-link", "-l"]).splitlines():
stripped = raw.strip()
if not stripped:
continue
if not raw.startswith((" ", "\t")):
current = stripped
continue
if stripped.startswith("|->") and current:
out.append((current, stripped[3:].strip()))
return out
def _is_ours(src: str) -> bool:
"""Guard: only links leaving Ardour's Master outputs may ever be removed."""
return any(src == f"{SOURCE_NODE}:{p}" for p in SOURCE_PORTS)
def plan(node: str, existing: list[tuple[str, str]]):
"""(to_add, to_remove) to make Master feed exactly `node`. Pure."""
want = {(f"{SOURCE_NODE}:{sp}", f"{node}:{dp}")
for sp, dp in zip(SOURCE_PORTS, SINK_PORTS)}
have = {(s, d) for s, d in existing if _is_ours(s)}
return sorted(want - have), sorted(have - want)
def apply(to_add, to_remove, dry=True) -> list[str]:
log = []
for s, d in to_remove:
if not _is_ours(s): # belt and braces
log.append(f" REFUSED to unlink {s} — not a Master output")
continue
log.append(f" - {s} -> {d}")
if not dry:
_run(["pw-link", "-d", s, d])
for s, d in to_add:
log.append(f" + {s} -> {d}")
if not dry:
_run(["pw-link", s, d])
return log
def converge(dry=True, quiet=False) -> bool:
"""True when Master ends up (or already is) on the preferred sink."""
avail = sinks()
label, node = choose(avail)
if node is None:
if not quiet:
print("no known sink present — nothing to do")
for a in avail:
print(f" (saw: {a})")
return False
to_add, to_remove = plan(node, links())
if not quiet:
print(f"preferred: {label}")
print(f" {node}")
if not to_add and not to_remove:
if not quiet:
print("already correct — 0 changes (safe to re-run mid-set)")
return True
steps = apply(to_add, to_remove, dry=dry)
if not quiet:
print("plan:" if dry else "applied:")
print("\n".join(steps))
if dry:
print("\n(dry run — pass --apply to make it so)")
return True
def main():
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--apply", action="store_true", help="actually re-patch")
ap.add_argument("--watch", type=float, metavar="SEC",
help="re-converge every SEC (implies --apply)")
a = ap.parse_args()
if a.watch:
print(f"master-out — converging every {a.watch}s · Ctrl-C to stop")
last = None
try:
while True:
sig = (tuple(sinks()), tuple(sorted(links())))
if sig != last: # only speak when something moved
converge(dry=False)
print()
last = (tuple(sinks()), tuple(sorted(links())))
time.sleep(a.watch)
except KeyboardInterrupt:
print("\nstopped")
return 0
return 0 if converge(dry=not a.apply) else 1
if __name__ == "__main__":
sys.exit(main())
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