This commit is contained in:
media
2026-10-04 11:45:14 -05:00
parent f60ec71e78
commit 2c7574f030
6 changed files with 533 additions and 70 deletions
+3
View File
@@ -0,0 +1,3 @@
venv
__pycache__/
*.py[cod]
+26
View File
@@ -0,0 +1,26 @@
# Audio Router
This daemon owns the physical PipeWire/JACK links for sanctuary audio. MQTT
topic names and payloads remain under the existing `audio/player` base topic.
YTPlayer audio follows one path:
```text
MPV -> ytplayer_bus -> Audio Router -> HDMI, analog, or disconnected
```
The virtual sink definition lives at
`pipewire/20-ytplayer-bus.conf`. Install it as
`~/.config/pipewire/pipewire-pulse.conf.d/20-ytplayer-bus.conf`; PipeWire Pulse
will recreate the bus after restarts.
Preset policy:
- `service`: mixer to HDMI only; YTPlayer disconnected
- `video`: YTPlayer to HDMI
- `announcements` and `aux_only`: YTPlayer to analog
- `mute_all`: YTPlayer disconnected
The router starts muted, restores the retained `audio/player/mode/state` once,
and periodically reconciles missing links. YTPlayer is configured as an
exclusive source, so a preset that enables both HDMI and analog is rejected.
+33
View File
@@ -2,6 +2,13 @@ mqtt:
host: 10.40.0.2 host: 10.40.0.2
port: 1883 port: 1883
base_topic: audio/player base_topic: audio/player
router:
# Start disconnected, then restore the retained MQTT mode. Recheck links so
# PipeWire device or virtual-sink restarts heal without a daemon restart.
startup_mode: mute_all
reconcile_interval: 1.0
exclusive_sources:
- ytplayer
routes: routes:
mixer_to_hdmi: mixer_to_hdmi:
source: mixer source: mixer
@@ -15,6 +22,12 @@ routes:
bgmusic_to_analog: bgmusic_to_analog:
source: bgmusic source: bgmusic
output: analog output: analog
ytplayer_to_hdmi:
source: ytplayer
output: hdmi
ytplayer_to_analog:
source: ytplayer
output: analog
ports: ports:
mixer: mixer:
left: Scarlett 2i2 3rd Gen Analog Stereo:capture_FL left: Scarlett 2i2 3rd Gen Analog Stereo:capture_FL
@@ -22,6 +35,9 @@ ports:
bgmusic: bgmusic:
left: SnapBGM:monitor_FL left: SnapBGM:monitor_FL
right: SnapBGM:monitor_FR right: SnapBGM:monitor_FR
ytplayer:
left: YTPlayerBus:monitor_FL
right: YTPlayerBus:monitor_FR
hdmi: hdmi:
left: Built-in Audio Pro 3:playback_AUX0 left: Built-in Audio Pro 3:playback_AUX0
right: Built-in Audio Pro 3:playback_AUX1 right: Built-in Audio Pro 3:playback_AUX1
@@ -34,23 +50,40 @@ presets:
mixer_to_analog: false mixer_to_analog: false
bgmusic_to_hdmi: false bgmusic_to_hdmi: false
bgmusic_to_analog: false bgmusic_to_analog: false
ytplayer_to_hdmi: false
ytplayer_to_analog: false
service: service:
mixer_to_hdmi: true mixer_to_hdmi: true
mixer_to_analog: false mixer_to_analog: false
bgmusic_to_hdmi: false bgmusic_to_hdmi: false
bgmusic_to_analog: false bgmusic_to_analog: false
ytplayer_to_hdmi: false
ytplayer_to_analog: false
announcements: announcements:
mixer_to_hdmi: false mixer_to_hdmi: false
mixer_to_analog: false mixer_to_analog: false
bgmusic_to_hdmi: true bgmusic_to_hdmi: true
bgmusic_to_analog: true bgmusic_to_analog: true
ytplayer_to_hdmi: false
ytplayer_to_analog: true
aux_only: aux_only:
mixer_to_hdmi: false mixer_to_hdmi: false
mixer_to_analog: false mixer_to_analog: false
bgmusic_to_hdmi: false bgmusic_to_hdmi: false
bgmusic_to_analog: true bgmusic_to_analog: true
ytplayer_to_hdmi: false
ytplayer_to_analog: true
video: video:
mixer_to_hdmi: false mixer_to_hdmi: false
mixer_to_analog: false mixer_to_analog: false
bgmusic_to_hdmi: true bgmusic_to_hdmi: true
bgmusic_to_analog: false bgmusic_to_analog: false
ytplayer_to_hdmi: true
ytplayer_to_analog: false
silent:
mixer_to_hdmi: false
mixer_to_analog: false
bgmusic_to_hdmi: false
bgmusic_to_analog: true
ytplayer_to_hdmi: true
ytplayer_to_analog: false
+9
View File
@@ -0,0 +1,9 @@
# Persistent virtual sink for the YTPlayer service. MPV writes only here;
# router.py owns all links from its monitor ports to physical outputs.
pulse.cmd = [
{
cmd = "load-module"
args = "module-null-sink sink_name=ytplayer_bus channels=2 rate=48000 channel_map=front-left,front-right sink_properties=device.description=YTPlayerBus"
flags = [ ]
}
]
+315 -69
View File
@@ -1,141 +1,387 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import json import json
import signal import signal
import sys
import threading
import time import time
from pathlib import Path
import jack import jack
import yaml import yaml
import paho.mqtt.client as mqtt import paho.mqtt.client as mqtt
CONFIG_FILE = "config.yaml" CONFIG_FILE = Path(__file__).resolve().with_name("config.yaml")
class JackRouter: class JackRouter:
def __init__(self): def __init__(self, config_file=CONFIG_FILE, jack_client=None):
with open(CONFIG_FILE, "r") as f: with open(config_file, "r", encoding="utf-8") as f:
self.cfg = yaml.safe_load(f) self.cfg = yaml.safe_load(f)
self.client = jack.Client("AudioRouter") self.client = jack_client or jack.Client("AudioRouter")
self.mode = "mute_all" self.lock = threading.RLock()
self.mode = self.cfg.get("router", {}).get("startup_mode", "mute_all")
self.base_topic = self.cfg["mqtt"]["base_topic"].rstrip("/") self.base_topic = self.cfg["mqtt"]["base_topic"].rstrip("/")
self._reported_failures = set()
self.exclusive_sources = set(
self.cfg.get("router", {}).get("exclusive_sources", ["ytplayer"])
)
self._validate_config()
def _validate_config(self):
routes = self.cfg["routes"]
ports = self.cfg["ports"]
for route_name, route in routes.items():
if route["source"] not in ports or route["output"] not in ports:
raise ValueError(f"Route {route_name!r} references an unknown port group")
for mode, preset in self.cfg["presets"].items():
unknown_routes = set(preset) - set(routes)
if unknown_routes:
raise ValueError(
f"Preset {mode!r} references unknown routes: "
f"{sorted(unknown_routes)!r}"
)
enabled_outputs = {}
for route_name, enabled in preset.items():
if not enabled:
continue
route = routes[route_name]
enabled_outputs.setdefault(route["source"], set()).add(route["output"])
for source in self.exclusive_sources:
outputs = enabled_outputs.get(source, set())
if len(outputs) > 1:
raise ValueError(
f"Preset {mode!r} routes exclusive source {source!r} "
f"to multiple outputs: {sorted(outputs)!r}"
)
def _log_route_failure(self, operation, src, dst, error):
key = (operation, src, dst)
if key not in self._reported_failures:
print(f"{operation.capitalize()} failed: {src} -> {dst}: {error}", flush=True)
self._reported_failures.add(key)
def _clear_route_failure(self, operation, src, dst):
self._reported_failures.discard((operation, src, dst))
def connect(self, src, dst): def connect(self, src, dst):
try: try:
if not self.client.get_all_connections(dst).count(src): connections = self.client.get_all_connections(dst)
connected_names = [port.name for port in connections]
if src not in connected_names:
self.client.connect(src, dst) self.client.connect(src, dst)
print(f"Connected: {src} -> {dst}") print(f"Connected: {src} -> {dst}", flush=True)
self._clear_route_failure("connect", src, dst)
return True
except jack.JackError as e: except jack.JackError as e:
print(f"Connect failed: {src} -> {dst}: {e}") self._log_route_failure("connect", src, dst, e)
return False
def connection_state(self, src, dst):
"""Return True/False for a link, or None when it cannot be inspected."""
try:
connections = self.client.get_all_connections(dst)
connected_names = [port.name for port in connections]
self._clear_route_failure("inspect", src, dst)
return src in connected_names
except jack.JackError as error:
self._log_route_failure("inspect", src, dst, error)
return None
def disconnect(self, src, dst): def disconnect(self, src, dst):
try: try:
self.client.disconnect(src, dst) self.client.disconnect(src, dst)
print(f"Disconnected: {src} -> {dst}") print(f"Disconnected: {src} -> {dst}", flush=True)
except jack.JackError: except jack.JackError:
pass pass
state = self.connection_state(src, dst)
if state is False:
self._clear_route_failure("disconnect", src, dst)
return True
if state is True:
self._log_route_failure(
"disconnect",
src,
dst,
"link is still present after disconnect",
)
return False
def route_pairs(self, route_name):
route = self.cfg["routes"][route_name]
source = self.cfg["ports"][route["source"]]
output = self.cfg["ports"][route["output"]]
return [
(source["left"], output["left"]),
(source["right"], output["right"]),
]
def all_route_pairs(self):
pairs = []
for route_name in self.cfg["routes"]:
pairs.extend(self.route_pairs(route_name))
return pairs
def clear_routes(self): def clear_routes(self):
all_routes = self.cfg["routes"] with self.lock:
for src, dst in self.all_route_pairs():
for route in all_routes.values():
source = self.cfg["ports"][route["source"]]
output = self.cfg["ports"][route["output"]]
pairs = [
(source["left"], output["left"]),
(source["right"], output["right"]),
]
for src, dst in pairs:
self.disconnect(src, dst) self.disconnect(src, dst)
def routes_for_mode(self, mode): def routes_for_mode(self, mode):
preset = self.cfg["presets"].get(mode) route_names = self.route_names_for_mode(mode)
if preset is None: if route_names is None:
return None return None
resolved_routes = [] resolved_routes = []
for route_name in route_names:
for route_name, enabled in preset.items(): resolved_routes.extend(self.route_pairs(route_name))
if not enabled:
continue
route = self.cfg["routes"][route_name]
source_name = route["source"]
output_name = route["output"]
source = self.cfg["ports"][source_name]
output = self.cfg["ports"][output_name]
resolved_routes.append((source["left"], output["left"]))
resolved_routes.append((source["right"], output["right"]))
return resolved_routes return resolved_routes
def route_names_for_mode(self, mode):
preset = self.cfg["presets"].get(mode)
if preset is None:
return None
return [route_name for route_name, enabled in preset.items() if enabled]
def exclusive_route_is_safe(self, desired_route_name):
desired_route = self.cfg["routes"][desired_route_name]
source_name = desired_route["source"]
if source_name not in self.exclusive_sources:
return True
desired_output = desired_route["output"]
safe = True
for route_name, route in self.cfg["routes"].items():
if route["source"] != source_name or route["output"] == desired_output:
continue
for src, dst in self.route_pairs(route_name):
state = self.connection_state(src, dst)
if state is True:
self.disconnect(src, dst)
state = self.connection_state(src, dst)
if state is not False:
self._log_route_failure(
"exclusive-check",
src,
dst,
f"cannot safely route {source_name!r} to {desired_output!r}",
)
safe = False
return safe
def connect_routes(self, route_names):
for route_name in route_names:
if not self.exclusive_route_is_safe(route_name):
continue
for src, dst in self.route_pairs(route_name):
self.connect(src, dst)
def set_mode(self, mode): def set_mode(self, mode):
routes = self.routes_for_mode(mode) route_names = self.route_names_for_mode(mode)
if routes is None: if route_names is None:
print(f"Unknown mode: {mode}") print(f"Unknown mode: {mode}", flush=True)
return False return False
print(f"Setting mode: {mode}") with self.lock:
self.clear_routes() if mode == self.mode:
print(f"Reconciling mode: {mode}", flush=True)
self.reconcile()
return True
for src, dst in routes: print(f"Setting mode: {mode}", flush=True)
self.connect(src, dst) # Break before make: remove every managed route before applying the
# next preset, so an exclusive source can never bridge two outputs.
self.clear_routes()
self.mode = mode
self.connect_routes(route_names)
self.mode = mode
return True return True
def reconcile(self):
"""Restore the selected preset after PipeWire ports appear or restart."""
with self.lock:
route_names = self.route_names_for_mode(self.mode) or []
desired = set(self.routes_for_mode(self.mode) or [])
for src, dst in self.all_route_pairs():
if (src, dst) not in desired:
self.disconnect(src, dst)
self.connect_routes(route_names)
def state(self): def state(self):
return { with self.lock:
"mode": self.mode, return {
"running": True, "mode": self.mode,
"timestamp": int(time.time()), "running": True,
} "timestamp": int(time.time()),
}
def main(): def main():
router = JackRouter() router = JackRouter()
shutdown_event = threading.Event()
mqtt_connected_event = threading.Event()
startup_restored_event = threading.Event()
restore_lock = threading.Lock()
restore_state = {"deadline": None}
mqtt_cfg = router.cfg["mqtt"] mqtt_cfg = router.cfg["mqtt"]
client = mqtt.Client() client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2)
mode_set_topic = f"{router.base_topic}/mode/set"
mode_state_topic = f"{router.base_topic}/mode/state"
def publish_state(): def publish_state():
client.publish(f"{router.base_topic}/state", json.dumps(router.state()), retain=True) snapshot = router.state()
client.publish(f"{router.base_topic}/mode/state", router.mode, retain=True) client.publish(f"{router.base_topic}/state", json.dumps(snapshot), retain=True)
client.publish(mode_state_topic, snapshot["mode"], retain=True)
def on_connect(client, userdata, flags, rc): def finish_startup_restore():
print(f"MQTT connected: {rc}") with restore_lock:
client.subscribe(f"{router.base_topic}/mode/set") if startup_restored_event.is_set():
router.set_mode("aux_only") return False
publish_state() startup_restored_event.set()
restore_state["deadline"] = None
client.unsubscribe(mode_state_topic)
return True
def on_connect(client, userdata, flags, reason_code, properties=None):
try:
if reason_code != 0:
print(f"MQTT connection failed: {reason_code}", file=sys.stderr, flush=True)
return
print(f"MQTT connected: {reason_code}", flush=True)
mqtt_connected_event.set()
client.subscribe(mode_set_topic)
if startup_restored_event.is_set():
publish_state()
else:
# mode/set is intentionally not retained on this installation,
# while mode/state is. Read mode/state once to restore the last
# applied preset, then unsubscribe so it remains output-only.
client.subscribe(mode_state_topic)
with restore_lock:
restore_state["deadline"] = time.monotonic() + 2.0
except Exception as error:
print(f"MQTT on_connect error: {error}", file=sys.stderr, flush=True)
def on_connect_fail(client, userdata):
print(
f"MQTT connection to {mqtt_cfg['host']}:{mqtt_cfg['port']} failed; "
"will retry",
file=sys.stderr,
flush=True,
)
def on_disconnect(client, userdata, disconnect_flags, reason_code, properties=None):
mqtt_connected_event.clear()
with restore_lock:
if not startup_restored_event.is_set():
restore_state["deadline"] = None
if shutdown_event.is_set():
print(f"MQTT disconnected: {reason_code}", flush=True)
else:
print(f"MQTT disconnected: {reason_code}; will retry", file=sys.stderr, flush=True)
def on_message(client, userdata, msg): def on_message(client, userdata, msg):
mode = msg.payload.decode().strip() try:
print(f"MQTT received: {msg.topic} = {mode}") if shutdown_event.is_set():
return
mode = msg.payload.decode("utf-8", errors="replace").strip()
print(f"MQTT received: {msg.topic} = {mode}", flush=True)
if router.set_mode(mode): if msg.topic == mode_state_topic:
publish_state() if (
getattr(msg, "retain", False)
and not startup_restored_event.is_set()
and router.set_mode(mode)
):
finish_startup_restore()
publish_state()
return
if msg.topic == mode_set_topic and router.set_mode(mode):
finish_startup_restore()
publish_state()
except Exception as error:
print(f"MQTT on_message error: {error}", file=sys.stderr, flush=True)
def shutdown(*args): def shutdown(*args):
print("Stopping router...") print("Stopping router...", flush=True)
router.set_mode("mute_all") shutdown_event.set()
publish_state()
raise SystemExit
signal.signal(signal.SIGINT, shutdown) signal.signal(signal.SIGINT, shutdown)
signal.signal(signal.SIGTERM, shutdown) signal.signal(signal.SIGTERM, shutdown)
client.on_connect = on_connect client.on_connect = on_connect
client.on_connect_fail = on_connect_fail
client.on_disconnect = on_disconnect
client.on_message = on_message client.on_message = on_message
client.suppress_exceptions = True
client.reconnect_delay_set(min_delay=1, max_delay=30)
jack_shutdown_state = {"stopped": False, "reason": None}
def on_jack_shutdown(status, reason):
# JACK invokes this from its process thread. Only update plain state;
# the main thread will exit and systemd will construct a fresh client.
jack_shutdown_state["stopped"] = True
jack_shutdown_state["reason"] = reason
router.client.set_shutdown_callback(on_jack_shutdown)
with router.client: with router.client:
client.connect(mqtt_cfg["host"], int(mqtt_cfg["port"]), 60) # Clear any stale managed links before network activity. Desired mode
client.loop_forever() # will be restored from the retained mode/state publication.
router.set_mode(router.mode)
client.connect_async(mqtt_cfg["host"], int(mqtt_cfg["port"]), 60)
loop_rc = client.loop_start()
if loop_rc != mqtt.MQTT_ERR_SUCCESS:
raise RuntimeError(f"Failed to start MQTT network loop: {loop_rc}")
interval = max(
0.2,
float(router.cfg.get("router", {}).get("reconcile_interval", 1.0)),
)
try:
while not shutdown_event.wait(interval):
if jack_shutdown_state["stopped"]:
raise RuntimeError(
f"JACK/PipeWire stopped: {jack_shutdown_state['reason']}"
)
with restore_lock:
restore_deadline = restore_state["deadline"]
if (
mqtt_connected_event.is_set()
and not startup_restored_event.is_set()
and restore_deadline is not None
and time.monotonic() >= restore_deadline
):
# No retained mode was available. Remain safely muted and
# publish that truthful state until a new command arrives.
if finish_startup_restore():
publish_state()
router.reconcile()
finally:
shutdown_event.set()
client.loop_stop()
# Disconnect live links without overwriting the retained desired
# mode. The next process can then restore that preset safely.
if not jack_shutdown_state["stopped"]:
router.clear_routes()
if __name__ == "__main__": if __name__ == "__main__":
+146
View File
@@ -0,0 +1,146 @@
import unittest
from types import SimpleNamespace
import jack
import router
class FakeJackClient:
def __init__(self):
self.available_ports = set()
self.connections = set()
self.connect_calls = []
self.disconnect_calls = []
self.failed_disconnects = set()
def get_all_connections(self, destination):
if destination not in self.available_ports:
raise jack.JackError(f"missing destination: {destination}")
return [
SimpleNamespace(name=source)
for source, target in self.connections
if target == destination
]
def connect(self, source, destination):
if source not in self.available_ports or destination not in self.available_ports:
raise jack.JackError("missing port")
self.connect_calls.append((source, destination))
self.connections.add((source, destination))
def disconnect(self, source, destination):
self.disconnect_calls.append((source, destination))
if (source, destination) in self.failed_disconnects:
raise jack.JackError("injected disconnect failure")
try:
self.connections.remove((source, destination))
except KeyError as error:
raise jack.JackError("not connected") from error
class AudioRouterTests(unittest.TestCase):
def setUp(self):
self.jack_client = FakeJackClient()
self.router = router.JackRouter(jack_client=self.jack_client)
for port_group in self.router.cfg["ports"].values():
self.jack_client.available_ports.update(port_group.values())
def route_pairs(self, route_name):
route = self.router.cfg["routes"][route_name]
source = self.router.cfg["ports"][route["source"]]
output = self.router.cfg["ports"][route["output"]]
return {
(source["left"], output["left"]),
(source["right"], output["right"]),
}
def test_ytplayer_preset_policy(self):
expected = {
"mute_all": set(),
"service": set(),
"announcements": self.route_pairs("ytplayer_to_analog"),
"aux_only": self.route_pairs("ytplayer_to_analog"),
"video": self.route_pairs("ytplayer_to_hdmi"),
}
all_ytplayer_pairs = (
self.route_pairs("ytplayer_to_hdmi")
| self.route_pairs("ytplayer_to_analog")
)
for mode, expected_pairs in expected.items():
with self.subTest(mode=mode):
actual = set(self.router.routes_for_mode(mode)) & all_ytplayer_pairs
self.assertEqual(actual, expected_pairs)
def test_service_contains_only_mixer_to_hdmi(self):
self.assertEqual(
set(self.router.routes_for_mode("service")),
self.route_pairs("mixer_to_hdmi"),
)
def test_exclusive_source_cannot_target_both_outputs(self):
self.router.cfg["presets"]["video"]["ytplayer_to_analog"] = True
with self.assertRaisesRegex(ValueError, "exclusive source 'ytplayer'"):
self.router._validate_config()
def test_reconcile_connects_virtual_bus_when_ports_appear_late(self):
ytplayer_ports = set(self.router.cfg["ports"]["ytplayer"].values())
self.jack_client.available_ports -= ytplayer_ports
self.router.set_mode("video")
self.assertFalse(
self.jack_client.connections & self.route_pairs("ytplayer_to_hdmi")
)
self.jack_client.available_ports |= ytplayer_ports
self.router.reconcile()
self.assertEqual(
self.jack_client.connections & self.route_pairs("ytplayer_to_hdmi"),
self.route_pairs("ytplayer_to_hdmi"),
)
def test_switch_is_break_before_make(self):
self.router.set_mode("announcements")
analog_pairs = self.route_pairs("ytplayer_to_analog")
hdmi_pairs = self.route_pairs("ytplayer_to_hdmi")
self.assertEqual(self.jack_client.connections & analog_pairs, analog_pairs)
self.router.set_mode("video")
self.assertFalse(self.jack_client.connections & analog_pairs)
self.assertEqual(self.jack_client.connections & hdmi_pairs, hdmi_pairs)
def test_disconnect_failure_cannot_bridge_exclusive_outputs(self):
self.router.set_mode("announcements")
analog_pairs = self.route_pairs("ytplayer_to_analog")
hdmi_pairs = self.route_pairs("ytplayer_to_hdmi")
self.jack_client.failed_disconnects = set(analog_pairs)
self.router.set_mode("video")
self.assertEqual(self.jack_client.connections & analog_pairs, analog_pairs)
self.assertFalse(self.jack_client.connections & hdmi_pairs)
def test_repeated_same_mode_does_not_break_working_links(self):
self.router.set_mode("video")
desired = set(self.router.routes_for_mode("video"))
disconnect_count = len(self.jack_client.disconnect_calls)
connect_count = len(self.jack_client.connect_calls)
self.router.set_mode("video")
self.assertTrue(desired <= self.jack_client.connections)
self.assertEqual(len(self.jack_client.disconnect_calls), disconnect_count + 8)
self.assertEqual(len(self.jack_client.connect_calls), connect_count)
def test_connect_uses_port_names_to_avoid_duplicate_link(self):
source, destination = next(iter(self.route_pairs("ytplayer_to_hdmi")))
self.jack_client.connections.add((source, destination))
self.router.connect(source, destination)
self.assertEqual(self.jack_client.connect_calls, [])
if __name__ == "__main__":
unittest.main()