Commit e566bbd6 by PLN (Algolia)

autoroute: keep Master on hardware and name the control feed from the live graph

A sink node re-created under a running Ardour left Master with no hardware
output: every unit active, the rig silent. Additive only (never prunes), off
switch TIDAL_AUTOROUTE_MASTER=0, ports taken from the graph not assumed FL/FR.
The control-surface assertion used an old PipeWire port spelling and was a
silent no-op on 1.0.5; resolve it from the live graph. One pw-link snapshot per
tick instead of three.
parent c53e4731
"""tidal-ardour-autoroute owns two links it used to leave to luck.
2026-10-11. A sink node re-created under a running Ardour (a card toggled off and
on) left Master with no hardware output: every unit active, the rig silent. And
the control-surface assertion named its source port in an old PipeWire spelling
("Midi Through: Port-0 (capture)"), so on PipeWire 1.0.5 it was a silent no-op
and the faders only worked because Ardour had saved the link itself.
Run against stub `pw-link` and `pactl` so no real graph is touched.
"""
from __future__ import annotations
import os
import pathlib
import subprocess
import textwrap
SCRIPT = pathlib.Path(__file__).resolve().parents[1] / "tidal-ardour-autoroute.sh"
SINK = "alsa_output.pci-0000_00_1f.3-platform-sof_sdw.HiFi__hw_sofsoundwire_2__sink"
INPUTS = f"""\
ardour:Tidal 01/audio_in 1
ardour:Tidal 01/audio_in 2
ardour:MIDI Control In
{SINK}:playback_FR
{SINK}:playback_FL
"""
CTRL_LINKED = "Midi-Bridge:Midi Through:(capture_0) Midi Through Port-0\n |-> ardour:MIDI Control In\n"
CTRL_UNLINKED = "Midi-Bridge:Midi Through:(capture_0) Midi Through Port-0\n"
MASTER_DANGLING = "ardour:Master/audio_out 1\nardour:Master/audio_out 2\n"
MASTER_ON_SPEAKER = (f"ardour:Master/audio_out 1\n |-> {SINK}:playback_FL\n"
f"ardour:Master/audio_out 2\n |-> {SINK}:playback_FR\n")
def _reconcile(tmp_path, lo, inputs=INPUTS, default_sink=SINK, extra_env=None):
stubs = tmp_path / "bin"
stubs.mkdir(exist_ok=True)
calls = tmp_path / "pw-link.calls"
calls.write_text("")
(tmp_path / "lo").write_text(lo)
(tmp_path / "in").write_text(inputs)
(stubs / "pw-link").write_text(textwrap.dedent(f"""\
#!/bin/sh
case "$1" in
-lo) cat '{tmp_path / "lo"}' ;;
-i) cat '{tmp_path / "in"}' ;;
*) echo "$@" >> '{calls}' ;;
esac
"""))
(stubs / "pactl").write_text(f"#!/bin/sh\n[ \"$1\" = get-default-sink ] && echo '{default_sink}'\n")
for s in stubs.iterdir():
s.chmod(0o755)
env = dict(os.environ, PATH=f"{stubs}:{os.environ['PATH']}")
env.update(extra_env or {})
out = subprocess.run(["bash", "-c", f"source '{SCRIPT}'\nreconcile"],
env=env, capture_output=True, text=True, timeout=20)
assert out.returncode == 0, out.stderr
return calls.read_text().splitlines(), out.stdout
def test_a_dangling_master_gets_the_default_sink(tmp_path):
calls, out = _reconcile(tmp_path, CTRL_LINKED + MASTER_DANGLING)
assert f"ardour:Master/audio_out 1 {SINK}:playback_FL" in calls
assert f"ardour:Master/audio_out 2 {SINK}:playback_FR" in calls
assert "no hardware output" in out
def test_ports_come_from_the_graph_not_from_an_assumed_FL_FR(tmp_path):
"""An audio interface names its playback ports AUX0/AUX1."""
iface = "alsa_output.usb-interface.analog-stereo"
inputs = f"ardour:Tidal 01/audio_in 1\n{iface}:playback_AUX1\n{iface}:playback_AUX0\n"
calls, _ = _reconcile(tmp_path, CTRL_LINKED + MASTER_DANGLING, inputs=inputs, default_sink=iface)
assert f"ardour:Master/audio_out 1 {iface}:playback_AUX0" in calls
assert f"ardour:Master/audio_out 2 {iface}:playback_AUX1" in calls
def test_a_master_that_already_reaches_hardware_is_left_alone(tmp_path):
calls, out = _reconcile(tmp_path, CTRL_LINKED + MASTER_ON_SPEAKER)
assert not [c for c in calls if c.startswith("ardour:Master")]
assert out == ""
def test_it_never_prunes_masters_links(tmp_path):
"""Which sinks Master feeds is the owner's call: additive only."""
calls, _ = _reconcile(tmp_path, CTRL_LINKED + MASTER_ON_SPEAKER)
assert not [c for c in calls if c.startswith("-d") and "Master" in c]
def test_the_off_switch_is_honoured(tmp_path):
calls, _ = _reconcile(tmp_path, CTRL_LINKED + MASTER_DANGLING,
extra_env={"TIDAL_AUTOROUTE_MASTER": "0"})
assert not [c for c in calls if c.startswith("ardour:Master")]
def test_no_ardour_tracks_means_no_master_action(tmp_path):
"""Not a performance: route nothing (a linkless jack client spins at RT)."""
inputs = f"ardour:MIDI Control In\n{SINK}:playback_FL\n"
calls, _ = _reconcile(tmp_path, CTRL_LINKED + MASTER_DANGLING, inputs=inputs)
assert not [c for c in calls if c.startswith("ardour:Master")]
def test_no_default_sink_is_reported_once_not_guessed(tmp_path):
calls, out = _reconcile(tmp_path, CTRL_LINKED + MASTER_DANGLING, default_sink="")
assert not [c for c in calls if c.startswith("ardour:Master")]
assert "no default sink" in out
def test_the_control_feed_is_named_from_the_live_graph(tmp_path):
calls, _ = _reconcile(tmp_path, CTRL_UNLINKED + MASTER_ON_SPEAKER)
assert ("Midi-Bridge:Midi Through:(capture_0) Midi Through Port-0 "
"ardour:MIDI Control In") in calls
def test_the_old_spelling_of_the_port_still_resolves(tmp_path):
old = "Midi-Bridge:Midi Through: Port-0 (capture)\n"
calls, _ = _reconcile(tmp_path, old + MASTER_ON_SPEAKER)
assert "Midi-Bridge:Midi Through: Port-0 (capture) ardour:MIDI Control In" in calls
def test_an_already_linked_control_feed_costs_no_call(tmp_path):
calls, _ = _reconcile(tmp_path, CTRL_LINKED + MASTER_ON_SPEAKER)
assert not [c for c in calls if "MIDI Control In" in c]
def test_the_midi_through_playback_side_is_never_taken_for_the_source(tmp_path):
lo = "Midi-Bridge:Midi Through:(playback_0) Midi Through Port-0\n" + MASTER_ON_SPEAKER
calls, _ = _reconcile(tmp_path, lo)
assert not [c for c in calls if "MIDI Control In" in c]
...@@ -12,6 +12,15 @@ ...@@ -12,6 +12,15 @@
# monitoring is via Ardour Master. sclang/SuperDirt re-adds the # monitoring is via Ardour Master. sclang/SuperDirt re-adds the
# orbit-0 monitor (out_1/out_2 -> Headphones) on (re)boot; this kills it. # orbit-0 monitor (out_1/out_2 -> Headphones) on (re)boot; this kills it.
# #
# MASTER: if Ardour Master has NO hardware output at all, link it to the
# default sink. Additive only, never pruning: which sinks Master feeds
# is the owner's call. Ardour restores its own output links only when
# it loads a session, so a sink node that is re-created (a card toggled
# off and on, a dock replugged) leaves Master dangling and the rig
# perfectly silent while every unit reads active. 2026-10-11.
# CTRL: keep Midi Through -> ardour:MIDI Control In, with the port NAMED FROM
# THE LIVE GRAPH (see below).
#
# Track numbers are auto-detected from the live graph, so adding/removing # Track numbers are auto-detected from the live graph, so adding/removing
# "Tidal NN" tracks in Ardour needs no edit here. pw-link "File exists" / # "Tidal NN" tracks in Ardour needs no edit here. pw-link "File exists" /
# missing-port errors are expected and ignored. # missing-port errors are expected and ignored.
...@@ -33,8 +42,8 @@ tidal_track_numbers() { ...@@ -33,8 +42,8 @@ tidal_track_numbers() {
} }
# Print "src<TAB>dest" for every SuperCollider:out_* -> hardware playback link. # Print "src<TAB>dest" for every SuperCollider:out_* -> hardware playback link.
sc_hw_playback_links() { sc_hw_playback_links() { # stdin: the `pw-link -lo` snapshot
pw-link -lo 2>/dev/null | awk -v sc="$SC" ' awk -v sc="$SC" '
/^[^ \t]/ { src = $0; next } # non-indented = source port /^[^ \t]/ { src = $0; next } # non-indented = source port
index(src, sc ":out_") == 1 && /playback_/ { # indented hw-playback dest index(src, sc ":out_") == 1 && /playback_/ { # indented hw-playback dest
dst = $0; sub(/^[ \t]*\|?->? */, "", dst); dst = $0; sub(/^[ \t]*\|?->? */, "", dst);
...@@ -52,11 +61,49 @@ sc_hw_playback_links() { ...@@ -52,11 +61,49 @@ sc_hw_playback_links() {
# stable rendezvous across controller generations (v2 raw, or v3 translated by # stable rendezvous across controller generations (v2 raw, or v3 translated by
# lcxl3-driver), which is what keeps the classic LCXL plug-and-play. # lcxl3-driver), which is what keeps the classic LCXL plug-and-play.
CTRL_IN="ardour:MIDI Control In" CTRL_IN="ardour:MIDI Control In"
CTRL_SRC="Midi-Bridge:Midi Through: Port-0 (capture)" MASTER_RELINK="${TIDAL_AUTOROUTE_MASTER:-1}"
MASTER_STATE=""
# The Midi Through capture port, named from the LIVE graph. PipeWire has spelled
# it two ways ("Midi Through: Port-0 (capture)" and, on 1.0.x,
# "Midi Through:(capture_0) Midi Through Port-0"); this asserted the first one
# with errors sent to /dev/null, so on 1.0.5 the assertion was a silent no-op
# and the faders only worked because Ardour had saved the link itself.
ctrl_source() { # stdin: the `pw-link -lo` snapshot
awk '/^Midi-Bridge:Midi Through:/ && !/playback/ { print; exit }'
}
# How many alsa_output sinks Ardour Master reaches.
master_hw_links() { # stdin: the `pw-link -lo` snapshot
awk '/^[^ \t]/ { src = $0; next }
index(src, "ardour:Master/audio_out") == 1 && /alsa_output\./ { n++ }
END { print n + 0 }'
}
# Link Master's two outputs to the first two playback ports of the default sink.
# Ports are taken from the graph rather than assumed to be FL/FR: an interface
# names them AUX0/AUX1.
relink_master() {
local sink ports p1 p2
sink="$(pactl get-default-sink 2>/dev/null)"
[ -n "$sink" ] || return 1
ports="$(pw-link -i 2>/dev/null | grep -F "$sink:playback_" | sed -E 's/^[ \t]+//' | sort | head -2)"
p1="$(sed -n 1p <<<"$ports")"; p2="$(sed -n 2p <<<"$ports")"
[ -n "$p1" ] || return 1
link "ardour:Master/audio_out 1" "$p1"
[ -n "$p2" ] && link "ardour:Master/audio_out 2" "$p2"
return 0
}
reconcile() { reconcile() {
local lo src
lo="$(pw-link -lo 2>/dev/null)" # ONE snapshot per tick, shared below
# ADD: the control surface feed (no-op until Ardour registers the port) # ADD: the control surface feed (no-op until Ardour registers the port)
link "$CTRL_SRC" "$CTRL_IN" if [[ "$lo" != *"|-> $CTRL_IN"* ]]; then
src="$(ctrl_source <<<"$lo")"
[ -n "$src" ] && link "$src" "$CTRL_IN"
fi
# No Ardour tracks in the graph = not a performance. Route nothing, and # No Ardour tracks in the graph = not a performance. Route nothing, and
# above all PRUNE nothing: SC's default sink links are then its ONLY # above all PRUNE nothing: SC's default sink links are then its ONLY
...@@ -68,6 +115,18 @@ reconcile() { ...@@ -68,6 +115,18 @@ reconcile() {
tracks="$(tidal_track_numbers)" tracks="$(tidal_track_numbers)"
[ -n "$tracks" ] || return 0 [ -n "$tracks" ] || return 0
# ADD: a hardware output for Master when it has none (additive, see header)
if [ "$MASTER_RELINK" = 1 ] && [[ "$lo" == *"ardour:Master/audio_out"* ]] \
&& [ "$(master_hw_links <<<"$lo")" = 0 ]; then
if relink_master; then
[ "$MASTER_STATE" = linked ] || echo "tidal-ardour-autoroute: Ardour Master had no hardware output; linked it to the default sink"
MASTER_STATE=linked
else
[ "$MASTER_STATE" = failed ] || echo "tidal-ardour-autoroute: Ardour Master has no hardware output and there is no default sink to give it"
MASTER_STATE=failed
fi
fi
# ADD: orbit pair -> matching Tidal track # ADD: orbit pair -> matching Tidal track
while read -r nn; do while read -r nn; do
[ -n "${nn:-}" ] || continue [ -n "${nn:-}" ] || continue
...@@ -81,11 +140,14 @@ reconcile() { ...@@ -81,11 +140,14 @@ reconcile() {
# playback directly — monitoring is via Ardour Master. # playback directly — monitoring is via Ardour Master.
while IFS=$'\t' read -r src dst; do while IFS=$'\t' read -r src dst; do
[ -n "${src:-}" ] && [ -n "${dst:-}" ] && pw-link -d "$src" "$dst" 2>/dev/null [ -n "${src:-}" ] && [ -n "${dst:-}" ] && pw-link -d "$src" "$dst" 2>/dev/null
done < <(sc_hw_playback_links) done < <(sc_hw_playback_links <<<"$lo")
} }
echo "tidal-ardour-autoroute: reconciling orbit->track every ${INTERVAL}s" # Source guard: the tests source this file for its functions.
while :; do if [ "${BASH_SOURCE[0]}" = "$0" ]; then
reconcile echo "tidal-ardour-autoroute: reconciling orbit->track every ${INTERVAL}s"
sleep "$INTERVAL" while :; do
done reconcile
sleep "$INTERVAL"
done
fi
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