Commit bf763762 by PLN (Algolia)

autoroute: Master follows the default output, and SuperDirt gets no microphone

Master tracks the default sink (HDMI, the jack, a Bluetooth headset) only while
its wiring is the one autoroute made; anything else (qjackctl, several sinks)
is custom and left alone, and disconnecting all of Master's outputs hands
control back. The new link is made before the old one is cut. The default-sink
lookup runs every 2nd tick, not every tick.

SuperDirt takes no mic: capture -> SuperCollider:in_* links are cut, with
TIDAL_AUTOROUTE_SC_MICS=1 to keep them, documented in the script header.
parent 425bf627
......@@ -34,7 +34,7 @@ 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):
def _reconcile(tmp_path, lo, inputs=INPUTS, default_sink=SINK, extra_env=None, state=None):
stubs = tmp_path / "bin"
stubs.mkdir(exist_ok=True)
calls = tmp_path / "pw-link.calls"
......@@ -52,7 +52,12 @@ def _reconcile(tmp_path, lo, inputs=INPUTS, default_sink=SINK, extra_env=None):
(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']}")
state_dir = tmp_path / "state"
state_dir.mkdir(exist_ok=True)
if state is not None:
(state_dir / "master-sink").write_text(state)
env = dict(os.environ, PATH=f"{stubs}:{os.environ['PATH']}",
TIDAL_AUTOROUTE_STATE_DIR=str(state_dir))
env.update(extra_env or {})
out = subprocess.run(["bash", "-c", f"source '{SCRIPT}'\nreconcile"],
env=env, capture_output=True, text=True, timeout=20)
......@@ -163,5 +168,149 @@ def test_master_on_a_bluetooth_headset_is_not_dangling(tmp_path):
"""Monitoring on the Bose while the default sink is the laptop speaker: linking
Master to the speaker as well would leak the whole set into the room."""
calls, out = _reconcile(tmp_path, CTRL_LINKED + MASTER_ON_BOSE)
assert not [c for c in calls if c.startswith("ardour:Master")]
assert out == ""
assert not [c for c in calls if c.startswith("ardour:Master") or c.startswith("-d")]
assert "custom wiring" in out # said once, not acted on
# --------------------------------------------------------------------------- #
# Master follows the default output, unless wired by hand
# --------------------------------------------------------------------------- #
HDMI = "alsa_output.pci-0000_00_1f.3-platform-sof_sdw.HiFi__hw_sofsoundwire_5__sink"
JACK = "alsa_output.pci-0000_00_1f.3-platform-sof_sdw.HiFi__hw_sofsoundwire__sink"
def _inputs_for(*sinks):
ins = "ardour:Tidal 01/audio_in 1\nardour:Tidal 01/audio_in 2\n"
for sk in sinks:
ins += f"{sk}:playback_FR\n{sk}:playback_FL\n"
return ins
def _master_on(*sinks):
out = ""
for ch, port in (("1", "FL"), ("2", "FR")):
out += f"ardour:Master/audio_out {ch}\n"
out += "".join(f" |-> {sk}:playback_{port}\n" for sk in sinks)
return out
def _state(tmp_path):
f = tmp_path / "state" / "master-sink"
return f.read_text() if f.exists() else None
def test_master_follows_the_default_output_when_it_moves(tmp_path):
"""HDMI today, the jack tomorrow: Master we linked follows the default."""
calls, out = _reconcile(tmp_path, CTRL_LINKED + ORBIT_1_LINKED + _master_on(HDMI),
inputs=_inputs_for(HDMI, JACK), default_sink=JACK, state=HDMI)
assert f"ardour:Master/audio_out 1 {JACK}:playback_FL" in calls
assert f"ardour:Master/audio_out 2 {JACK}:playback_FR" in calls
assert f"-d ardour:Master/audio_out 1 {HDMI}:playback_FL" in calls
assert f"-d ardour:Master/audio_out 2 {HDMI}:playback_FR" in calls
assert _state(tmp_path) == JACK and "followed the default output" in out
def test_the_new_link_is_made_before_the_old_one_is_cut(tmp_path):
"""No silent gap while the output moves."""
calls, _ = _reconcile(tmp_path, CTRL_LINKED + ORBIT_1_LINKED + _master_on(HDMI),
inputs=_inputs_for(HDMI, JACK), default_sink=JACK, state=HDMI)
first_new = next(i for i, c in enumerate(calls) if c.startswith("ardour:Master") and JACK in c)
first_cut = next(i for i, c in enumerate(calls) if c.startswith("-d") and HDMI in c)
assert first_new < first_cut
def test_master_already_on_the_default_is_left_alone(tmp_path):
calls, out = _reconcile(tmp_path, CTRL_LINKED + ORBIT_1_LINKED + _master_on(HDMI),
inputs=_inputs_for(HDMI), default_sink=HDMI, state=HDMI)
assert calls == [] and out == ""
def test_several_sinks_at_once_is_custom_wiring_and_left_alone(tmp_path):
"""qjackctl / Ardour's matrix: the owner's wiring wins."""
calls, out = _reconcile(tmp_path, CTRL_LINKED + ORBIT_1_LINKED + _master_on(HDMI, JACK),
inputs=_inputs_for(HDMI, JACK), default_sink=JACK, state=HDMI)
assert calls == []
assert "custom wiring" in out and "hand it back" in out
def test_a_sink_we_did_not_link_is_custom_wiring(tmp_path):
calls, out = _reconcile(tmp_path, CTRL_LINKED + ORBIT_1_LINKED + _master_on(JACK),
inputs=_inputs_for(HDMI, JACK), default_sink=HDMI, state=HDMI)
assert calls == [] and "rewired by hand" in out
assert _state(tmp_path) == HDMI # we do not overwrite what we did not do
def test_after_a_restart_a_lone_sink_that_is_the_default_is_adopted_silently(tmp_path):
calls, out = _reconcile(tmp_path, CTRL_LINKED + ORBIT_1_LINKED + _master_on(HDMI),
inputs=_inputs_for(HDMI), default_sink=HDMI, state=None)
assert calls == [] and out == "" and _state(tmp_path) == HDMI
def test_after_a_restart_a_lone_sink_that_is_not_the_default_is_custom(tmp_path):
calls, out = _reconcile(tmp_path, CTRL_LINKED + ORBIT_1_LINKED + _master_on(JACK),
inputs=_inputs_for(HDMI, JACK), default_sink=HDMI, state=None)
assert calls == [] and "custom wiring" in out and _state(tmp_path) is None
def test_disconnecting_everything_hands_control_back(tmp_path):
"""The documented way back from custom wiring: Master dangling -> default again."""
calls, out = _reconcile(tmp_path, CTRL_LINKED + ORBIT_1_LINKED + MASTER_DANGLING,
inputs=_inputs_for(HDMI), default_sink=HDMI, state=JACK)
assert f"ardour:Master/audio_out 1 {HDMI}:playback_FL" in calls
assert _state(tmp_path) == HDMI
def test_the_follow_check_runs_on_its_cadence_not_every_tick(tmp_path):
"""The default-sink lookup is a fork: do it every FOLLOW_EVERY ticks."""
stubs = tmp_path / "bin"
_reconcile(tmp_path, CTRL_LINKED + ORBIT_1_LINKED + _master_on(HDMI),
inputs=_inputs_for(HDMI), default_sink=HDMI, state=HDMI)
(stubs / "pactl").write_text(f"#!/bin/sh\necho x >> '{tmp_path}/pactl.calls'\necho '{HDMI}'\n")
env = dict(os.environ, PATH=f"{stubs}:{os.environ['PATH']}",
TIDAL_AUTOROUTE_STATE_DIR=str(tmp_path / "state"), TIDAL_AUTOROUTE_FOLLOW_EVERY="4")
subprocess.run(["bash", "-c", f"source '{SCRIPT}'\nfor i in 1 2 3 4 5 6 7 8; do reconcile; done"],
env=env, capture_output=True, text=True, timeout=30)
assert len((tmp_path / "pactl.calls").read_text().splitlines()) == 2
# --------------------------------------------------------------------------- #
# SuperDirt takes no microphone
# --------------------------------------------------------------------------- #
MIC = "alsa_input.pci-0000_00_1f.3-platform-sof_sdw.HiFi__hw_sofsoundwire_4__source"
MICS_LINKED = (f"{MIC}:capture_FL\n |-> SuperCollider:in_1\n"
f"{MIC}:capture_FR\n |-> SuperCollider:in_2\n")
BASE = CTRL_LINKED + ORBIT_1_LINKED + MASTER_ON_SPEAKER
def test_the_laptop_mics_are_cut_from_superdirt(tmp_path):
calls, out = _reconcile(tmp_path, BASE + MICS_LINKED)
assert f"-d {MIC}:capture_FL SuperCollider:in_1" in calls
assert f"-d {MIC}:capture_FR SuperCollider:in_2" in calls
assert "takes no mic" in out and "TIDAL_AUTOROUTE_SC_MICS=1" in out
def test_a_bluetooth_mic_is_cut_too(tmp_path):
bt = "bluez_input.70_26_05_D0_D5_90.0"
calls, _ = _reconcile(tmp_path, BASE + f"{bt}:capture_MONO\n |-> SuperCollider:in_1\n")
assert f"-d {bt}:capture_MONO SuperCollider:in_1" in calls
def test_the_mic_knob_leaves_the_links_alone(tmp_path):
"""The note a future owner needs: set TIDAL_AUTOROUTE_SC_MICS=1 to listen again."""
calls, out = _reconcile(tmp_path, BASE + MICS_LINKED, extra_env={"TIDAL_AUTOROUTE_SC_MICS": "1"})
assert not [c for c in calls if c.startswith("-d") and "SuperCollider:in_" in c]
assert "takes no mic" not in out
def test_a_sinks_monitor_port_is_not_a_microphone(tmp_path):
mon = f"{SINK}:monitor_FL\n |-> SuperCollider:in_1\n"
calls, _ = _reconcile(tmp_path, BASE + mon)
assert not [c for c in calls if c.startswith("-d") and "SuperCollider:in_" in c]
def test_no_ardour_tracks_means_the_mics_are_left_alone_too(tmp_path):
"""Not a performance: prune 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 + MICS_LINKED, inputs=inputs)
assert not [c for c in calls if c.startswith("-d")]
......@@ -12,12 +12,33 @@
# monitoring is via Ardour Master. sclang/SuperDirt re-adds the
# 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.
# MASTER: Ardour Master follows the DEFAULT OUTPUT (HDMI today, the headphone
# jack tomorrow, a Bluetooth headset when connected), but only while
# Master's wiring is the one this script made. Three cases:
# * Master reaches no sound device: link it to the default sink. (A sink
# node that is re-created, a card toggled off and on, leaves Master
# dangling and the rig perfectly silent while every unit reads
# active. 2026-10-11.)
# * Master reaches exactly the sink we linked last, and the default has
# moved: move Master to the new default (link first, then unlink the
# old one, so there is no silent gap).
# * ANYTHING ELSE is custom wiring (qjackctl, Ardour's connection
# matrix, several sinks at once): hands off, one log line. To hand
# control back, disconnect all of Master's outputs; it then follows
# the default again.
# The last sink we linked is remembered in $XDG_RUNTIME_DIR (tmpfs, gone at
# logout). After a restart, a lone sink that already IS the default is
# adopted; a lone sink that is not is treated as custom.
# Ardour only stores external links when you save: Ctrl+S after a change
# if you want its own session to agree.
# Off switch: TIDAL_AUTOROUTE_MASTER=0.
# MICS: SuperDirt takes NO microphone. Physical capture -> SuperCollider:in_*
# links are cut (the JACK driver auto-connects the laptop mics at boot, and
# the capture streams threw `Broken pipe` xruns from the moment the rig
# launched on 2026-10-11, with Ardour carrying its own Mic track anyway).
# If you ever want SuperCollider listening to a mic again (live input,
# sampling), set TIDAL_AUTOROUTE_SC_MICS=1 for this unit, or the links
# will be cut again within 2 s. Sinks' own monitor ports are not touched.
# CTRL: keep Midi Through -> ardour:MIDI Control In, with the port NAMED FROM
# THE LIVE GRAPH (see below).
#
......@@ -87,7 +108,13 @@ sc_hw_playback_links() { # stdin: the `pw-link -lo` snapshot
# lcxl3-driver), which is what keeps the classic LCXL plug-and-play.
CTRL_IN="ardour:MIDI Control In"
MASTER_RELINK="${TIDAL_AUTOROUTE_MASTER:-1}"
SC_MICS="${TIDAL_AUTOROUTE_SC_MICS:-0}" # 1 = leave capture -> SuperCollider alone
FOLLOW_EVERY="${TIDAL_AUTOROUTE_FOLLOW_EVERY:-2}" # ticks between default-sink lookups (a fork)
MASTER_DIR="${TIDAL_AUTOROUTE_STATE_DIR:-${XDG_RUNTIME_DIR:-/tmp}/parvagues}"
MASTER_FILE="$MASTER_DIR/master-sink"
MASTER_STATE=""
TICK=0
INP=""
# 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,
......@@ -98,24 +125,24 @@ ctrl_source() { # stdin: the `pw-link -lo` snapshot
awk '/^Midi-Bridge:Midi Through:/ && !/playback/ { print; exit }'
}
# How many sound devices Ardour Master reaches. A Bluetooth headset counts: if the
# set is monitored on the Bose, a Master linked only to bluez_output.* HAS an
# output, and "fixing" it by also linking the laptop speaker would leak the whole
# set into the room.
master_hw_links() { # stdin: the `pw-link -lo` snapshot
# The sound devices Ardour Master reaches (node names, one per line, unique). A
# Bluetooth headset counts: if the set is monitored on the Bose, a Master linked
# only to bluez_output.* HAS an output, and "fixing" it by also linking the laptop
# speaker would leak the whole set into the room.
master_sinks() { # stdin: the `pw-link -lo` snapshot
awk '/^[^ \t]/ { src = $0; next }
index(src, "ardour:Master/audio_out") == 1 && /(alsa|bluez)_output\./ { n++ }
END { print n + 0 }'
index(src, "ardour:Master/audio_out") == 1 && /(alsa|bluez)_output\./ {
d = $0; sub(/^[ \t]*\|-> /, "", d); sub(/:[^:]*$/, "", d); seen[d] = 1 }
END { for (d in seen) print d }' | sort
}
# 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)"
default_sink() { pactl get-default-sink 2>/dev/null; }
# Link Master's two outputs to the first two playback ports of a sink. Ports come
# from the graph rather than assumed FL/FR: an interface names them AUX0/AUX1.
link_master_to() { # $1 = sink node name
local ports p1 p2
ports="$(grep -F "$1:playback_" <<<"$INP" | 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"
......@@ -123,11 +150,70 @@ relink_master() {
return 0
}
unlink_master_from() { # $1 = sink node name; reads the HAVE index
local key src dst
for key in "${!HAVE[@]}"; do
src="${key%%>*}"; dst="${key#*>}"
[[ "$src" == "ardour:Master/audio_out"* && "$dst" == "$1:playback_"* ]] \
&& pw-link -d "$src" "$dst" 2>/dev/null
done
}
remember_master_sink() { mkdir -p "$MASTER_DIR" 2>/dev/null && printf '%s' "$1" > "$MASTER_FILE" 2>/dev/null; }
note_mic() { echo "tidal-ardour-autoroute: $1"; }
note_master() { # log once per state change, never per tick
[ "$MASTER_STATE" = "$1" ] || echo "tidal-ardour-autoroute: $2"
MASTER_STATE="$1"
}
reconcile_master() { # $1 = the `pw-link -lo` snapshot
[ "$MASTER_RELINK" = 1 ] && [[ "$1" == *"ardour:Master/audio_out"* ]] || return 0
local sinks managed="" def at_cadence=0
sinks="$(master_sinks <<<"$1")"
[ -r "$MASTER_FILE" ] && read -r managed < "$MASTER_FILE"
(( (TICK - 1) % FOLLOW_EVERY == 0 )) && at_cadence=1
if [ -z "$sinks" ]; then # dangling: give it the default
def="$(default_sink)"
if [ -n "$def" ] && link_master_to "$def"; then
remember_master_sink "$def"
note_master "linked" "Ardour Master had no hardware output; linked it to the default sink (${def##*.})"
else
note_master "failed" "Ardour Master has no hardware output and there is no default sink to give it"
fi
elif [[ "$sinks" == *$'\n'* ]]; then # several sinks at once
note_master "custom" "Master feeds several sinks: custom wiring, leaving it alone (disconnect all of Master's outputs to hand it back)"
elif [ -n "$managed" ] && [ "$sinks" != "$managed" ]; then # not the sink we linked
note_master "custom" "Master was rewired by hand: leaving it alone (disconnect all of Master's outputs to hand it back)"
elif (( at_cadence )); then # ours, or a candidate for adoption
def="$(default_sink)"
if [ -z "$def" ]; then
:
elif [ "$sinks" = "$def" ]; then
[ "$managed" = "$def" ] || remember_master_sink "$def" # steady state, or adopt
MASTER_STATE="following"
elif [ -n "$managed" ]; then # ours, and the default has moved
if link_master_to "$def"; then
unlink_master_from "$sinks"
remember_master_sink "$def"
note_master "moved" "Master followed the default output: ${sinks##*.} -> ${def##*.}"
MASTER_STATE="following"
fi
else
note_master "custom" "Master feeds ${sinks##*.}, not the default: custom wiring, leaving it alone"
fi
fi
}
reconcile() {
local lo src inp
lo="$(pw-link -lo 2>/dev/null)" # ONE snapshot per tick, shared below
inp="$(pw-link -i 2>/dev/null)"
INP="$inp"
index_links <<<"$lo"
TICK=$((TICK + 1))
# ADD: the control surface feed (no-op until Ardour registers the port)
if [[ "$lo" != *"|-> $CTRL_IN"* ]]; then
......@@ -145,17 +231,8 @@ reconcile() {
tracks="$(tidal_track_numbers <<<"$inp")"
[ -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
# Master follows the default output unless someone wired it by hand (see header)
reconcile_master "$lo"
# ADD: orbit pair -> matching Tidal track
while read -r nn; do
......@@ -171,6 +248,18 @@ reconcile() {
while IFS=$'\t' read -r src dst; do
[ -n "${src:-}" ] && [ -n "${dst:-}" ] && pw-link -d "$src" "$dst" 2>/dev/null
done < <(sc_hw_playback_links <<<"$lo")
# PRUNE: SuperDirt takes no microphone (see the MICS note in the header).
if [ "$SC_MICS" != 1 ]; then
local key
for key in "${!HAVE[@]}"; do
src="${key%%>*}"; dst="${key#*>}"
if [[ "$dst" == "$SC:in_"* && "$src" =~ ^(alsa|bluez)_input\. ]]; then
pw-link -d "$src" "$dst" 2>/dev/null
note_mic "cut $src -> $dst (SuperDirt takes no mic; TIDAL_AUTOROUTE_SC_MICS=1 to keep them)"
fi
done
fi
}
# Source guard: the tests source this file for its functions.
......
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