Files
Classroom_Desktop/lib/classroom_audio.py
T
ekklesiaandClaude Opus 5 2ff9d47be9 feat: decide the classroom's audio output from what is plugged in
The room that prompted this had a television on HDMI with working PCM
audio and nothing in the analog jack, and WirePlumber chose analog. A
teacher who corrected it by hand then had to restart the browser before
any sound came out.

The policy is a pure function so it can be tested against that room
rather than against a machine: an unavailable port is not a candidate,
an analog port merely existing is not evidence of a cable, and an
explicit AUDIO_OUTPUT overrides the lot. Twelve cases, the real room
among them.

The daemon follows PipeWire's own event stream rather than polling, and
moves streams that are already playing, which is the part that stops a
teacher having to close and reopen the browser.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-10 08:47:28 -05:00

163 lines
5.0 KiB
Python

"""Deciding which output a classroom should be playing through.
This module is only the decision. It takes the state of the sound cards as
PipeWire reports it and returns the port that should be in use, so the
policy can be tested against the situation a real room was actually in
rather than against a running machine.
The room that prompted this had a Samsung television on HDMI with working
PCM audio, nothing at all plugged into the onboard analog jack, and
WirePlumber had chosen analog anyway. A teacher who fixed it by hand then
had to restart the browser before any sound came out.
Two rules come out of that:
* a port that reports itself unavailable is not a candidate, and "analog
exists" is not evidence that a cable is in it; and
* the decision has to be re-made whenever the hardware situation changes,
not once at boot, because a television that is switched on after the
computer is the normal case in a classroom.
"""
# Port name prefixes PipeWire uses for the digital display outputs.
DIGITAL = ("hdmi-output", "displayport-output", "hdmi", "dp-")
ANALOG = ("analog-output", "lineout", "headphones", "speaker")
AVAILABLE = "available"
UNAVAILABLE = "not available"
UNKNOWN = "unknown"
AUTO = "auto"
HDMI = "hdmi"
ANALOG_ONLY = "analog"
def _kind(port_name):
name = (port_name or "").lower()
for prefix in DIGITAL:
if name.startswith(prefix):
return HDMI
for prefix in ANALOG:
if name.startswith(prefix):
return ANALOG_ONLY
return None
def _availability(port):
value = (port.get("availability") or "").strip().lower()
if value in ("available", "yes"):
return AVAILABLE
if value in ("not available", "no"):
return UNAVAILABLE
return UNKNOWN
def candidates(cards):
"""Every output port, tagged with what it is and whether it is there.
`cards` is the structure `pactl -f json list cards` produces.
"""
found = []
for card in cards or []:
for name, port in (card.get("ports") or {}).items():
if (port.get("direction") or "Output").lower() != "output":
continue
kind = _kind(name)
if kind is None:
continue
found.append({
"card": card.get("name"),
"card_index": card.get("index"),
"port": name,
"kind": kind,
"availability": _availability(port),
"priority": port.get("priority") or 0,
"profiles": list(port.get("profiles") or []),
})
return found
def choose(cards, preference=AUTO):
"""The port the room should be playing through, or None.
An explicit preference is obeyed as far as the hardware allows, and
falls back rather than leaving the room silent.
"""
found = candidates(cards)
if not found:
return None
def ranked(kind, accept):
pool = [
item for item in found
if item["kind"] == kind and item["availability"] in accept
]
# Highest priority first, then a stable order so a machine with two
# identical outputs does not pick a different one each boot.
return sorted(
pool,
key=lambda item: (-item["priority"], item["card"] or "", item["port"]),
)
HERE = (AVAILABLE,)
SILENT = (AVAILABLE, UNKNOWN)
ANYTHING = (AVAILABLE, UNKNOWN, UNAVAILABLE)
# A television that is on says so. A port that reports nothing is still
# worth using if there is no better answer, but never ahead of one that
# reports itself connected. A port that reports itself absent is chosen
# only when the alternative is a silent room — or when somebody has
# named it explicitly, which is what an override is for.
order = {
AUTO: [
(HDMI, HERE), (ANALOG_ONLY, HERE),
(HDMI, SILENT), (ANALOG_ONLY, SILENT),
(HDMI, ANYTHING), (ANALOG_ONLY, ANYTHING),
],
HDMI: [
(HDMI, HERE), (HDMI, SILENT), (HDMI, ANYTHING),
(ANALOG_ONLY, HERE), (ANALOG_ONLY, SILENT), (ANALOG_ONLY, ANYTHING),
],
ANALOG_ONLY: [
(ANALOG_ONLY, HERE), (ANALOG_ONLY, SILENT), (ANALOG_ONLY, ANYTHING),
(HDMI, HERE), (HDMI, SILENT), (HDMI, ANYTHING),
],
}[preference]
for kind, accept in order:
pool = ranked(kind, accept)
if pool:
return pool[0]
return None
def profile_for(port):
"""The card profile to switch to so that a port is usable.
A port lists the profiles it appears in; the classroom wants the one
that turns the output on without also claiming inputs it does not need,
which in practice is the shortest name among the stereo output ones.
"""
profiles = [name for name in port.get("profiles") or [] if name != "off"]
if not profiles:
return None
outputs = [name for name in profiles if "output" in name] or profiles
return sorted(outputs, key=lambda name: (len(name), name))[0]