Files
Classroom_Desktop/lib/classroom_audio.py
T
ekklesiaandClaude Opus 5 27cd64a668 fix: the room asks for its output again, not only once at boot
River Kids came up silent on stable 0.2.2. The decision this policy makes
was right and was never asked for.

classroom-audio applied its policy once at startup and then only when a
PipeWire event arrived. pactl talks to PipeWire's PulseAudio server, and
the unit was ordered after pipewire and wireplumber but not after that.
At boot it therefore raced the server and lost: the single startup apply
failed with "Connection refused", the subscription attached cleanly a few
seconds later, and from that moment the daemon waited for an event that
had already happened. systemd said active, the subscription was live, and
the policy had never run at all.

That is why the card was found on output:analog-stereo+input:analog-stereo
with its HDMI port available. That profile is WirePlumber's own pick, not
one this policy would ever produce, which is the evidence it never ran.

Reproduced in the lab against real PipeWire 1.4.2 and WirePlumber 0.5.8 by
starting the daemon with pipewire-pulse stopped and then starting it: the
old daemon sits idle forever, the new one converges.

The shape is now: wait for the server, apply, subscribe, apply again the
moment the subscription attaches, and coalesce each burst of events into
one decision. Applying on attach is the fix for the race. While the room
has not got the output it would prefer it also looks again on a backing
off timer capped at a minute, so a television switched on hours later is
picked up whether or not ALSA's jack change ever reaches us as an event.
Once the room has what it wants it stops looking entirely, and a machine
with no digital output never looks at all.

Also here, all of it found by measuring the real server rather than
reading the code:

  * sink lookup matched on fields a sink does not have. Two of its three
    conditions could never be true; the one that worked returned whichever
    sink of the card came first rather than the one for the chosen port.
  * volume and mute are no longer touched. The old code unmuted and forced
    70% on every output change, so every television power cycle reset a
    volume the teacher had set and could unmute an output muted on
    purpose. WirePlumber already remembers volume per device. A machine
    this policy has never configured still gets 70% once.
  * the profile chosen keeps the card's input, which is the difference
    between output:hdmi-stereo and the output:hdmi-stereo+input:analog-stereo
    an administrator reaches for by hand.
  * a card profile is only set when it is not already in place, and only
    so many times, so a sink that never appears cannot become a switching
    loop.
  * a machine with no sound server at all says so once and backs off,
    rather than filling the journal. pactl subscribe's own stderr is
    dropped because saying it is this daemon's job, and a subscription is
    only treated as healthy once it has lasted, since pactl starts
    successfully and then fails to connect.
  * the fixtures described a machine nobody has. Real pactl puts no
    "direction" on a port at all and says "availability unknown" rather
    than nothing, so a test could not have caught this.

Existing streams are moved explicitly. Measured both ways on the real
stack: changing the default sink does move a stream that never asked for
a particular sink, but a stream pinned to a device stays where it is with
no policy running, and this moves it back without the application being
restarted.

tests/test-classroom-audio-daemon.sh drives the real daemon through the
boot race, a television switched on with and without an event announcing
it, a power cycle, a set that never comes on, and a room already correct.
It uses a pactl mock because no loopback or dummy sound driver has an HDMI
port, so the one thing a lab cannot prove is that the real jack change
arrives; docs/testing.md carries the physical procedure for that and says
not to take a passing build as evidence of it.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-20 14:46:46 -05:00

217 lines
7.4 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 came 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.
A third came out of the Sunday the rooms were silent on 0.2.2. The
decision was right and was never asked for: the part that applies it had
made it once, at startup, before PipeWire's PulseAudio server was
listening, and then waited for an event to tell it to try again. So this
module also answers whether the answer it just gave is the best one the
hardware could ever give, which is what lets the caller know whether it
still has something to wait for.
The shapes here are the ones real pactl emits, which are not the ones the
first set of fixtures assumed. `ports` is an object keyed by port name,
and a port carries no `direction` at all; availability is the phrase
"availability unknown" rather than an empty string. Measured against
PipeWire 1.4.2 and WirePlumber 0.5.8, which is what the rooms run.
"""
# 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
plainest output the card offers — stereo rather than one of the
surround arrangements a television will not use — and, among equals,
one that leaves an input in place.
Dropping the input is what the narrower rule here used to do, and it
is the difference between output:hdmi-stereo and the
output:hdmi-stereo+input:analog-stereo an administrator reaches for by
hand. Nothing in the room records sound today, but taking the
microphone away is not this policy's business, and choosing the same
profile a person would choose keeps the two from undoing each other.
"""
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
def rank(name):
# The output half decides first, so stereo beats surround however
# long the rest of the name is.
output = name.split("+", 1)[0]
return (len(output), 0 if "+input:" in name else 1, len(name), name)
return sorted(outputs, key=rank)[0]
def preferred_kind(preference):
"""The kind of output this preference is holding out for."""
return ANALOG_ONLY if preference == ANALOG_ONLY else HDMI
def settled(cards, preference, chosen):
"""Whether waiting any longer could produce a better output.
False means the room is playing through something, but the output it
would rather have exists on this machine and is not usable yet — a
television that is switched off, in practice. The caller uses this to
decide whether it still has a reason to look again, so that a room
which has what it wants is driven by events alone and never polls.
"""
wanted = preferred_kind(preference)
if chosen is not None and chosen["kind"] == wanted:
return True
# Nothing of the preferred kind is wired into this machine at all, so
# there is nothing to wait for. A laptop with only a headphone jack is
# settled the moment it picks that jack.
return not any(item["kind"] == wanted for item in candidates(cards))