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]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
87b55761c6
commit
2ff9d47be9
@@ -0,0 +1,162 @@
|
||||
"""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]
|
||||
Reference in New Issue
Block a user