Commit 5818b2b0 by PLN (Algolia)

feat(monitor): headphones mode — every orbit in your ears, no Ardour

The rig is multichannel by design: orbit k is the pair (out_{2k-1}, out_{2k}),
wired to its own Ardour track at a gig. Away from the stage that design means
d1 is the only orbit that can reach a sink — and on a freshly booted laptop
(2026-09-07) not even that: scsynth had NO links to any sink, so the whole box
was silent with every check green.

- tools/fold-orbits.py: fold all orbit pairs onto one stereo sink, summed at
  unity. Idempotent, and it VERIFIES — pw-link exits 0 for "File exists" and
  for a port that vanished mid-call, so its exit status proves nothing. The
  channel count is discovered from the graph (28 today, 12 in an older
  comment, 2 on stock SuperDirt), never assumed. Refuses while Ardour runs,
  which owns the mix and prunes SC->hardware links every 2 s.
- gig-up.sh --headphones: SuperDirt -> fold -> Pulsar. No Ardour, no fader
  restore; the fold becomes the readiness gate, because it IS the whole
  monitoring path.
- gig-preflight: `monitor path` check — stage (Ardour receiving on N tracks)
  or headphones (fold present), and it asks fold-orbits rather than
  re-deriving the port math.
- 18 tests, fixtures captured from the real graph: `pw-link -lo` marks links
  by INDENTATION only, so a grep of it fabricates relationships, and a source
  port printed with no indented child is the exact state this tool fixes.

Measured, not assumed: 14/14 orbits arrive at the sink (bd swept d1-d14, peak
per orbit), and the links survive 32 s of autoroute reconciling.
parent 0af672a5
...@@ -21,11 +21,36 @@ ARDOUR_SESSION="$HOME/Work/Sound/Ardour/Tidal Live/Tidal Live.ardour" # lean p ...@@ -21,11 +21,36 @@ ARDOUR_SESSION="$HOME/Work/Sound/Ardour/Tidal Live/Tidal Live.ardour" # lean p
READY_TIMEOUT="${READY_TIMEOUT:-120}" # s to wait for SuperDirt (samples can be slow) READY_TIMEOUT="${READY_TIMEOUT:-120}" # s to wait for SuperDirt (samples can be slow)
PRELOAD_TRACKS="${PRELOAD_TRACKS:-20}" # warm samples from the last N edited tracks (0 = off) PRELOAD_TRACKS="${PRELOAD_TRACKS:-20}" # warm samples from the last N edited tracks (0 = off)
HEADPHONES=0 # --headphones: no Ardour, all orbits folded to one sink
C_OK=$'\e[32m'; C_WARN=$'\e[33m'; C_INFO=$'\e[36m'; C_DIM=$'\e[2m'; C_RST=$'\e[0m' C_OK=$'\e[32m'; C_WARN=$'\e[33m'; C_INFO=$'\e[36m'; C_DIM=$'\e[2m'; C_RST=$'\e[0m'
ok(){ printf '%s✓ %s%s\n' "$C_OK" "$*" "$C_RST"; } ok(){ printf '%s✓ %s%s\n' "$C_OK" "$*" "$C_RST"; }
info(){ printf '%s• %s%s\n' "$C_INFO" "$*" "$C_RST"; } info(){ printf '%s• %s%s\n' "$C_INFO" "$*" "$C_RST"; }
warn(){ printf '%s! %s%s\n' "$C_WARN" "$*" "$C_RST"; } warn(){ printf '%s! %s%s\n' "$C_WARN" "$*" "$C_RST"; }
usage(){
cat <<'USAGE'
gig-up — ordered rig launch.
gig-up.sh stage rig: SuperDirt -> Ardour (the mix) -> Pulsar
gig-up.sh --headphones travel rig: SuperDirt -> Pulsar, every orbit folded
onto the default sink. No Ardour, no faders, no
stems — just all 14 orbits in your ears.
gig-up.sh --help
Env: READY_TIMEOUT (120) PRELOAD_TRACKS (20) SETLIST GIG_PERF=off GIG_CUTGPU=off
USAGE
}
while [ $# -gt 0 ]; do
case "$1" in
-H|--headphones) HEADPHONES=1 ;;
-h|--help) usage; exit 0 ;;
*) warn "unknown option: $1"; usage; exit 2 ;;
esac
shift
done
# --- readiness probes (the three signals that mean "SuperDirt owns the rig") --- # --- readiness probes (the three signals that mean "SuperDirt owns the rig") ---
scsynth_up(){ pgrep -x scsynth >/dev/null 2>&1; } scsynth_up(){ pgrep -x scsynth >/dev/null 2>&1; }
osc_up(){ ss -uln 2>/dev/null | grep -q ':57120'; } osc_up(){ ss -uln 2>/dev/null | grep -q ':57120'; }
...@@ -346,10 +371,24 @@ restore_faders(){ ...@@ -346,10 +371,24 @@ restore_faders(){
warn "faders: declined — $(printf '%s' "$out" | tail -1)" warn "faders: declined — $(printf '%s' "$out" | tail -1)"
fi fi
} }
restore_faders if [ "$HEADPHONES" = 1 ]; then
info "faders: skipped — headphones mode never opens the Ardour session."
else
restore_faders
fi
# 3) Ardour — now safe to open (SuperDirt already holds the controller) # 3) Ardour — or, on headphones, the orbit fold that replaces it
if ardour_up; then if [ "$HEADPHONES" = 1 ]; then
# No Ardour means no Master, and the rig is multichannel: orbit k lives on the
# pair (out_{2k-1}, out_{2k}), so out of the box you hear d1 and nothing else
# — and on 2026-09-07, on a freshly booted laptop, not even that: scsynth had
# no links to any sink at all. Fold every pair onto the default sink instead.
if python3 "$DIR/tools/fold-orbits.py"; then
:
else
warn "fold: failed — only d1 (or nothing) will be audible. Re-run: tools/fold-orbits.py"
fi
elif ardour_up; then
ok "Ardour already running — skipping." ok "Ardour already running — skipping."
elif [ -f "$ARDOUR_SESSION" ]; then elif [ -f "$ARDOUR_SESSION" ]; then
# pw-jack, or Ardour's JACK backend takes the WRONG libjack and there are two # pw-jack, or Ardour's JACK backend takes the WRONG libjack and there are two
...@@ -403,8 +442,16 @@ echo ...@@ -403,8 +442,16 @@ echo
if command -v python3 >/dev/null; then if command -v python3 >/dev/null; then
info "readiness: surface state (globals that could be swallowing your sound)" info "readiness: surface state (globals that could be swallowing your sound)"
python3 "$DIR/tools/gig-log.py" preflight 2>&1 | sed 's/^/ /' || true python3 "$DIR/tools/gig-log.py" preflight 2>&1 | sed 's/^/ /' || true
if [ "$HEADPHONES" = 1 ]; then
# The fold is the whole monitoring path in this mode, so it is the gate:
# a link that a suspended/replaced sink node took with it is silence with
# no error anywhere. Ardour's faders cannot swallow what never reaches them.
info "readiness: orbit fold (the entire monitoring path on headphones)"
python3 "$DIR/tools/fold-orbits.py" --check 2>&1 | sed 's/^/ /' || true
else
info "readiness: Ardour faders (reads the last SAVED session — save first)" info "readiness: Ardour faders (reads the last SAVED session — save first)"
python3 "$DIR/tools/check-mix.py" 2>&1 | tail -4 | sed 's/^/ /' || true python3 "$DIR/tools/check-mix.py" 2>&1 | tail -4 | sed 's/^/ /' || true
fi
# Pulsar saves the BUFFER, not the file, and Window:Reload restores the cached # Pulsar saves the BUFFER, not the file, and Window:Reload restores the cached
# buffer rather than disk — so a tab left open across an edit can silently write # buffer rather than disk — so a tab left open across an edit can silently write
# three-day-old text back over committed work. This catches that in seconds. # three-day-old text back over committed work. This catches that in seconds.
...@@ -413,4 +460,9 @@ if command -v python3 >/dev/null; then ...@@ -413,4 +460,9 @@ if command -v python3 >/dev/null; then
fi fi
echo echo
ok "gig-up done. SuperDirt owns the LaunchControl; Ardour + Pulsar are coming up." if [ "$HEADPHONES" = 1 ]; then
ok "gig-up done (headphones). All orbits summed to the default sink; Pulsar is coming up."
info "plugged headphones in after this? the default sink changed — re-run: tools/fold-orbits.py"
else
ok "gig-up done. SuperDirt owns the LaunchControl; Ardour + Pulsar are coming up."
fi
...@@ -430,10 +430,51 @@ def check_xruns() -> None: ...@@ -430,10 +430,51 @@ def check_xruns() -> None:
"" if state == OK else "check the perf lines above first — they cause this") "" if state == OK else "check the perf lines above first — they cause this")
def check_monitor_path() -> None:
"""Does anything the rig plays actually reach a speaker?
Two legitimate monitoring paths, and the failure is silent in both:
* STAGE — orbit pairs -> "Tidal NN" tracks -> Ardour Master. autoroute
reconciles this every 2 s.
* HEADPHONES — no Ardour, every orbit pair folded onto one sink by
tools/fold-orbits.py. Out of the box this path does not exist: the rig
is multichannel, so d2..d14 land on busses nothing is listening to,
and on 2026-09-07 a freshly booted laptop had no SC links at all.
The links are also mortal: a sink node that gets replaced (headphones
plugged in, a profile switch, wireplumber restarted) takes every link to
it, with no error printed anywhere.
"""
if not _pid_of("scsynth"):
add("monitor path", WARN, "scsynth down — nothing to route yet",
"systemctl --user start parvagues-sc")
return
ins = run(["pw-link", "-i"])
tidal_tracks = len(re.findall(r"ardour:Tidal \d+/audio_in 1", ins))
if tidal_tracks:
add("monitor path", OK, f"stage: Ardour receiving on {tidal_tracks} Tidal track(s)")
return
# No Ardour: the fold is the whole path. Ask the tool that owns it rather
# than re-deriving the port math here — one parser, one source of truth.
tool = Path(__file__).resolve().parent / "fold-orbits.py"
out = run([sys.executable, str(tool), "--check", "--json"], timeout=10)
try:
verdict = json.loads(out.strip().splitlines()[-1])
except (ValueError, IndexError):
add("monitor path", WARN, f"fold-orbits --check unreadable: {out.strip()[:80]}")
return
state = {"ok": OK, "warn": WARN, "fail": FAIL}.get(verdict.get("state"), WARN)
add("monitor path", state, f"headphones: {verdict.get('message', '?')}",
verdict.get("fix", "tools/fold-orbits.py"))
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
CHECKS = (check_perf, check_regime_agreement, check_audio_rt, CHECKS = (check_perf, check_regime_agreement, check_audio_rt,
check_superdirt_fresh, check_sample_integrity, check_midi_surface, check_superdirt_fresh, check_monitor_path, check_sample_integrity,
check_editor, check_background_load, check_xruns) check_midi_surface, check_editor, check_background_load, check_xruns)
COLOR = {OK: "\033[32m", WARN: "\033[33m", FAIL: "\033[31m"} COLOR = {OK: "\033[32m", WARN: "\033[33m", FAIL: "\033[31m"}
RESET = "\033[0m" RESET = "\033[0m"
......
"""Tests for tools/fold-orbits.py — the no-Ardour headphones fold.
What matters here is that the tool never claims an orbit is audible when it is
not. Two of its four helpers parse `pw-link` output, and `pw-link -lo` marks
links by INDENTATION alone: the destination lines carry no source name, so a
grep of that output invents relationships. So the parser is tested against real
captured output, including the shapes that broke this repo before — a source
port with no links at all, and multi-colon client names (`Midi-Bridge:Midi
Through:(capture_0) …`).
The channel count is discovered, never assumed: scsynth's
`numOutputBusChannels` is a boot option (28 today, 12 in an older comment, 2 on
a stock SuperDirt), so a constant would rot silently into "orbits 8-14 are
inaudible and nobody said so".
"""
from __future__ import annotations
import importlib.util
from pathlib import Path
TOOLS = Path(__file__).resolve().parents[1]
SPEC = importlib.util.spec_from_file_location("fold_orbits", TOOLS / "fold-orbits.py")
fo = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(fo)
SINK = "alsa_output.pci-0000_00_1f.3-platform-sof_sdw.HiFi__hw_sofsoundwire_2__sink"
# Captured from `pw-link -i` on the XPS24, 2026-09-07.
REAL_INPUTS = f"""
{SINK}:playback_FL
{SINK}:playback_FR
ardour:Tidal 01/audio_in 1
ardour:Tidal 01/audio_in 2
"""
# Captured from `pw-link -o`, trimmed to the shapes that matter.
REAL_OUTPUTS = """
Midi-Bridge:Midi Through:(capture_0) Midi Through Port-0
v4l2_input.pci-0000_00_14.0-usb-0_6_1.0:capture_1
alsa_output.pci-0000_01_00.1.pro-output-3:monitor_AUX0
SuperCollider:out_1
SuperCollider:out_2
SuperCollider:out_3
SuperCollider:out_4
SuperCollider:out_10
SuperCollider:in_1
"""
# --------------------------------------------------------------------------- #
# discovery: how many orbits does this scsynth actually have?
# --------------------------------------------------------------------------- #
def test_sc_out_indices_ignores_inputs_and_other_clients():
assert fo.sc_out_indices(fo.parse_ports(REAL_OUTPUTS)) == [1, 2, 3, 4, 10]
def test_sc_out_indices_sorts_numerically_not_lexically():
ports = [f"SuperCollider:out_{n}" for n in (1, 2, 9, 10, 11, 28)]
assert fo.sc_out_indices(ports) == [1, 2, 9, 10, 11, 28]
def test_sc_out_indices_respects_the_client_name():
assert fo.sc_out_indices(["scsynth:out_1"], client="scsynth") == [1]
assert fo.sc_out_indices(["scsynth:out_1"]) == []
def test_orbit_pairs_drops_a_lone_channel():
# out_10 has no out_9 partner: half an orbit is not an orbit, and pairing it
# with anything would send one channel of d5 to the left ear only.
assert fo.orbit_pairs([1, 2, 3, 4, 10]) == [(1, 2), (3, 4)]
def test_orbit_pairs_scales_to_the_rigs_28_channels():
assert fo.orbit_pairs(list(range(1, 29))) == [(n, n + 1) for n in range(1, 29, 2)]
def test_orbit_pairs_of_nothing():
assert fo.orbit_pairs([]) == []
# --------------------------------------------------------------------------- #
# the sink's own port naming
# --------------------------------------------------------------------------- #
def test_sink_stereo_ports_finds_the_FL_FR_pair():
assert fo.sink_stereo_ports(fo.parse_ports(REAL_INPUTS), SINK) == (
f"{SINK}:playback_FL", f"{SINK}:playback_FR")
def test_sink_stereo_ports_falls_back_to_numbered_ports():
ports = ["s:playback_1", "s:playback_2"]
assert fo.sink_stereo_ports(ports, "s") == ("s:playback_1", "s:playback_2")
def test_sink_stereo_ports_refuses_a_mono_sink():
assert fo.sink_stereo_ports(["s:playback_FL"], "s") is None
def test_sink_stereo_ports_does_not_match_a_prefix_sibling():
# `..._2__sink` and `..._2__sink.monitor` differ by a suffix; matching on
# `startswith(sink)` without the colon would fold onto the wrong node.
ports = ["s2:playback_FL", "s2:playback_FR"]
assert fo.sink_stereo_ports(ports, "s") is None
# --------------------------------------------------------------------------- #
# links: indentation is the only thing that ties a dest to its source
# --------------------------------------------------------------------------- #
REAL_LINKS = f"""SuperCollider:out_1
|-> {SINK}:playback_FL
SuperCollider:out_2
|-> {SINK}:playback_FR
SuperCollider:out_3
SuperCollider:out_4
Midi-Bridge:Midi Through:(capture_0) Midi Through Port-0
|-> SuperCollider:midi_in
"""
def test_parse_links_pairs_dests_with_their_indented_source():
assert fo.parse_links(REAL_LINKS) == {
("SuperCollider:out_1", f"{SINK}:playback_FL"),
("SuperCollider:out_2", f"{SINK}:playback_FR"),
("Midi-Bridge:Midi Through:(capture_0) Midi Through Port-0",
"SuperCollider:midi_in"),
}
def test_parse_links_does_not_invent_a_link_for_an_unlinked_port():
# out_3 and out_4 are listed with no indented child. This is the exact
# state the tool exists to fix, so reporting them as linked would make the
# fold a no-op that claims success.
linked = {src for src, _ in fo.parse_links(REAL_LINKS)}
assert "SuperCollider:out_3" not in linked
assert "SuperCollider:out_4" not in linked
def test_parse_links_survives_an_empty_graph():
assert fo.parse_links("") == set()
assert fo.parse_links("\n\n") == set()
def test_parse_links_handles_the_input_side_arrow():
# `pw-link -li` prints `|<-` instead of `|->`; same relationship, and the
# arrow must not end up glued to the port name.
text = "sink:playback_FL\n |<- SuperCollider:out_1\n"
assert fo.parse_links(text) == {("sink:playback_FL", "SuperCollider:out_1")}
# --------------------------------------------------------------------------- #
# the plan, and how it is reported
# --------------------------------------------------------------------------- #
def test_desired_links_sends_odd_left_even_right():
lr = ("sink:playback_FL", "sink:playback_FR")
assert fo.desired_links([(1, 2), (3, 4)], lr) == [
("SuperCollider:out_1", "sink:playback_FL"),
("SuperCollider:out_2", "sink:playback_FR"),
("SuperCollider:out_3", "sink:playback_FL"),
("SuperCollider:out_4", "sink:playback_FR"),
]
def test_orbits_of_maps_channels_to_d_numbers():
# orbit k is the pair (out_{2k-1}, out_{2k}) and is played by `dk`:
# out_1/out_2 -> d1, out_27/out_28 -> d14.
links = [("SuperCollider:out_1", "x"), ("SuperCollider:out_2", "x"),
("SuperCollider:out_27", "x"), ("SuperCollider:out_28", "x")]
assert fo.orbits_of(links) == [1, 14]
def test_fmt_orbits_collapses_runs():
assert fo.fmt_orbits([1, 2, 3, 5]) == "d1-d3, d5"
assert fo.fmt_orbits([7]) == "d7"
assert fo.fmt_orbits(list(range(1, 15))) == "d1-d14"
assert fo.fmt_orbits([]) == "no orbits"
def test_full_plan_for_the_real_rig_covers_every_orbit():
outs = "\n".join(f"SuperCollider:out_{n}" for n in range(1, 29))
pairs = fo.orbit_pairs(fo.sc_out_indices(fo.parse_ports(outs)))
want = fo.desired_links(pairs, (f"{SINK}:playback_FL", f"{SINK}:playback_FR"))
assert len(pairs) == 14 and len(want) == 28
assert fo.orbits_of(want) == list(range(1, 15))
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