#!/usr/bin/env python3 """Keep a classroom playing through the right output. The policy is in classroom_audio; this is the part that talks to PipeWire. It applies the decision, and then re-applies it whenever the sound hardware changes, which is what makes a television switched on after the computer work without anybody touching anything. Three things happen on every change, and the third is the one that fixes the complaint from the room: set the card profile so the output exists at all set the default sink so new sound goes there move existing streams so sound that is ALREADY PLAYING follows Without the last step a browser that was started before the change keeps playing into the old sink, and the only fix a teacher can find is to close and reopen it. That is exactly what happened in the room this was written for. Volume and mute are set once, when the output actually changes. After that the room's own volume is left alone. """ import json import os import subprocess import sys import time sys.path.insert(0, "/usr/local/lib/ekklesia-classroom") sys.path.insert( 0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "lib"), ) import classroom_audio as policy # noqa: E402 try: from classroom_config import read_device_config except ImportError: # pragma: no cover - the daemon must not need it def read_device_config(): return {} # Loud enough to be heard at the back of a classroom, quiet enough that the # first thing a teacher plays is not a shock. Only ever applied when the # output changes, never on top of a volume somebody has chosen. DEFAULT_VOLUME = "70%" # Several udev events arrive for one cable being plugged in. Settle first. SETTLE_SECONDS = 1.5 def pactl(*args, parse=False): command = ["pactl"] if parse: command += ["-f", "json"] command += list(args) try: result = subprocess.run( command, capture_output=True, text=True, timeout=15, check=False ) except (OSError, subprocess.SubprocessError) as error: log(f"pactl {' '.join(args)}: {error}") return None if result.returncode != 0: log(f"pactl {' '.join(args)}: {result.stderr.strip()}") return None if not parse: return result.stdout try: return json.loads(result.stdout or "[]") except ValueError as error: log(f"pactl {' '.join(args)}: unreadable output: {error}") return None def log(message): print(f"classroom-audio: {message}", flush=True) def preference(): value = (read_device_config() or {}).get("AUDIO_OUTPUT", policy.AUTO) value = (value or "").strip().lower() return value if value in (policy.AUTO, policy.HDMI, policy.ANALOG_ONLY) \ else policy.AUTO def sink_for_card(card_name): for sink in pactl("list", "sinks", parse=True) or []: properties = sink.get("properties") or {} if properties.get("alsa.card_name") and card_name and \ properties.get("device.string"): pass if (sink.get("card") == card_name or properties.get("device.name") == card_name or (card_name or "") in (sink.get("name") or "")): return sink return None def apply(state): cards = pactl("list", "cards", parse=True) if cards is None: return chosen = policy.choose(cards, preference()) if chosen is None: if state.get("port") is not None: log("no usable output") state["port"] = None return profile = policy.profile_for(chosen) changed = state.get("port") != (chosen["card"], chosen["port"]) if changed and profile: pactl("set-card-profile", str(chosen["card"]), profile) # The sink appears only after the profile is in place. time.sleep(0.4) sink = sink_for_card(chosen["card"]) if sink is None: log(f"no sink for {chosen['card']} after {profile}") return name = sink.get("name") pactl("set-default-sink", name) if changed: pactl("set-sink-mute", name, "0") pactl("set-sink-volume", name, DEFAULT_VOLUME) log(f"output is {chosen['port']} on {chosen['card']} ({name})") # Anything already making sound follows, so a browser that was open # before the television was switched on does not have to be restarted. moved = 0 for stream in pactl("list", "sink-inputs", parse=True) or []: if stream.get("sink") == sink.get("index"): continue if pactl("move-sink-input", str(stream.get("index")), name) is not None: moved += 1 if moved: log(f"moved {moved} playing stream(s) to {name}") state["port"] = (chosen["card"], chosen["port"]) def watch(): """Follow PipeWire's own event stream rather than polling it.""" state = {} apply(state) while True: process = subprocess.Popen( ["pactl", "subscribe"], stdout=subprocess.PIPE, text=True, ) try: for line in process.stdout: if not any( word in line for word in ("card", "server", "sink", "source") ): continue # Drain the rest of the burst before acting once. time.sleep(SETTLE_SECONDS) apply(state) except OSError as error: log(f"subscription ended: {error}") finally: process.terminate() log("re-subscribing") time.sleep(3) def main(): if len(sys.argv) > 1 and sys.argv[1] == "once": apply({}) return 0 if len(sys.argv) > 1 and sys.argv[1] == "show": cards = pactl("list", "cards", parse=True) or [] for item in policy.candidates(cards): print(f"{item['kind']:6} {item['availability']:13} " f"prio={item['priority']:<6} {item['card']} {item['port']}") chosen = policy.choose(cards, preference()) print(f"\npreference={preference()} chosen=" f"{chosen['port'] if chosen else 'none'}") return 0 watch() return 0 if __name__ == "__main__": sys.exit(main())