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]>
531 lines
17 KiB
Python
Executable File
531 lines
17 KiB
Python
Executable File
#!/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 when the output changes:
|
|
|
|
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.
|
|
|
|
WHY THIS WAS REWRITTEN
|
|
----------------------
|
|
|
|
On 0.2.2 the rooms came up silent, and the reason was not the decision —
|
|
which was right — but when it was asked for. The old shape was:
|
|
|
|
apply once at startup
|
|
then apply only when an event arrives
|
|
|
|
`pactl` talks to PipeWire's PulseAudio server, and this service was
|
|
ordered after pipewire and wireplumber but not after that. At boot it
|
|
therefore raced the server and lost: the one startup apply failed with
|
|
"Connection refused", the subscription then attached successfully a few
|
|
seconds later, and from that moment the daemon sat waiting for an event
|
|
that had already happened. It looked perfectly healthy — systemd said
|
|
active, the subscription was live — and it had never applied its policy at
|
|
all. The room was left on whatever WirePlumber had chosen, which is how an
|
|
administrator came to find output:analog-stereo+input:analog-stereo on a
|
|
card whose HDMI port was available: that profile is WirePlumber's own
|
|
pick, and not one this policy would ever produce.
|
|
|
|
Reproduced in the lab by starting this daemon with pipewire-pulse stopped
|
|
and then starting it, which leaves exactly the state the room was found
|
|
in.
|
|
|
|
So the shape is now:
|
|
|
|
wait for the server, within reason
|
|
apply
|
|
subscribe, and apply again the moment the subscription attaches
|
|
apply on events, coalescing each burst into one decision
|
|
while the room does NOT have the output it would prefer, look again
|
|
on a backing-off timer; once it does, stop looking and wait
|
|
|
|
The re-apply on attach is the fix for the race. The timer is the fix for
|
|
the larger version of the same problem: this policy no longer depends on
|
|
an event arriving at the one moment it happens to be listening. A
|
|
television switched on hours later is picked up whether or not ALSA's jack
|
|
change reaches us as an event.
|
|
|
|
Nothing polls once the room has the output it wants. A machine with no
|
|
digital output at all never polls either, because there is nothing it
|
|
could be waiting for.
|
|
|
|
VOLUME AND MUTE
|
|
---------------
|
|
|
|
This no longer touches either, except once on a machine it has never
|
|
configured before. The old code unmuted the sink and forced it to 70% on
|
|
every output change, which meant every television power cycle reset a
|
|
volume the teacher had set, and could unmute an output that was muted on
|
|
purpose. WirePlumber already remembers volume per device, so leaving it
|
|
alone is both less code and the behaviour the room wants.
|
|
"""
|
|
|
|
import errno
|
|
import json
|
|
import os
|
|
import select
|
|
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. Applied once in the life of a
|
|
# machine, when this policy has never configured it before; after that the
|
|
# room's own volume is never touched again.
|
|
DEFAULT_VOLUME = "70%"
|
|
|
|
# Several events arrive for one television being switched on. The burst is
|
|
# drained to quiet before deciding, so one change is one decision rather
|
|
# than one decision per event — which is what the old sleep-per-line did,
|
|
# and it turned a single change into some forty seconds of churn.
|
|
SETTLE_SECONDS = 1.5
|
|
|
|
# However chatty the server is, service it eventually.
|
|
SETTLE_LIMIT_SECONDS = 6.0
|
|
|
|
# How long to wait for PipeWire's PulseAudio server at startup before
|
|
# carrying on regardless. Bounded: a room with no sound server must still
|
|
# reach the watch loop rather than spin here.
|
|
SERVER_WAIT_SECONDS = 60
|
|
|
|
# While the room has not got the output it would prefer — a television
|
|
# that is off, in practice — look again on a backing-off timer. Read-only
|
|
# unless the answer actually changes, and capped, so a set that is never
|
|
# switched on costs one `pactl list cards` a minute forever and changes
|
|
# nothing.
|
|
RECHECK_START_SECONDS = 5
|
|
RECHECK_LIMIT_SECONDS = 60
|
|
|
|
# A subscription that will not stay up is retried, but at a rate that
|
|
# cannot become a spin on a machine with no sound server at all.
|
|
RESUBSCRIBE_SECONDS = 3
|
|
RESUBSCRIBE_LIMIT_SECONDS = 60
|
|
|
|
# A subscription that lasted this long was a real one, so the next time it
|
|
# drops it is worth reconnecting quickly again. Anything shorter is a
|
|
# server that is not answering, and is backed off from.
|
|
SUBSCRIPTION_HEALTHY_SECONDS = 10
|
|
|
|
# A card profile is only re-asserted so many times before the daemon stops
|
|
# trying, so that a sink which never appears cannot become a profile
|
|
# switching loop.
|
|
PROFILE_ATTEMPTS = 3
|
|
|
|
# Remembers that this machine has been configured once, so the default
|
|
# volume is a first-run courtesy rather than something applied at every
|
|
# boot on top of a volume somebody chose.
|
|
STATE_DIRECTORY = os.path.join(
|
|
os.environ.get("XDG_STATE_HOME")
|
|
or os.path.expanduser("~/.local/state"),
|
|
"ekklesia-classroom",
|
|
)
|
|
STATE_FILE = os.path.join(STATE_DIRECTORY, "audio-configured")
|
|
|
|
|
|
def log(message):
|
|
print(f"classroom-audio: {message}", flush=True)
|
|
|
|
|
|
def pactl(*args, parse=False, quiet=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:
|
|
if not quiet:
|
|
log(f"pactl {' '.join(args)}: {error}")
|
|
return None
|
|
|
|
if result.returncode != 0:
|
|
if not quiet:
|
|
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 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 configured_before():
|
|
return os.path.exists(STATE_FILE)
|
|
|
|
|
|
def remember_configured():
|
|
try:
|
|
os.makedirs(STATE_DIRECTORY, exist_ok=True)
|
|
with open(STATE_FILE, "w") as handle:
|
|
handle.write("This machine's audio output has been set up once.\n")
|
|
except OSError as error:
|
|
# Losing this only means the first-run volume is offered again
|
|
# later. It is never worth failing the policy for.
|
|
log(f"could not record first run: {error}")
|
|
|
|
|
|
def sink_for(sinks, chosen):
|
|
"""The sink that carries the chosen port.
|
|
|
|
A sink is tied to its card by properties["device.name"], which is the
|
|
card's name — there is no top-level card field on a sink, whatever the
|
|
older code here believed. Two of its three conditions could never
|
|
match; the one that did returned whichever sink of that card came
|
|
first, which is not necessarily the one for the port just chosen.
|
|
"""
|
|
card = chosen["card"]
|
|
port = chosen["port"]
|
|
|
|
on_card = [
|
|
sink for sink in sinks or []
|
|
if (sink.get("properties") or {}).get("device.name") == card
|
|
]
|
|
|
|
if not on_card:
|
|
return None
|
|
|
|
for sink in on_card:
|
|
if sink.get("active_port") == port:
|
|
return sink
|
|
|
|
for sink in on_card:
|
|
names = {
|
|
entry.get("name") for entry in sink.get("ports") or []
|
|
if isinstance(entry, dict)
|
|
}
|
|
|
|
if port in names:
|
|
return sink
|
|
|
|
return on_card[0]
|
|
|
|
|
|
def active_profile_of(cards, card_name):
|
|
for card in cards or []:
|
|
if card.get("name") == card_name:
|
|
return card.get("active_profile")
|
|
|
|
return None
|
|
|
|
|
|
def move_streams(name, index):
|
|
"""Send sound that is already playing to the new output.
|
|
|
|
Changing the default sink does move streams that never asked for a
|
|
particular one — measured on PipeWire 1.4.2, WirePlumber does it — but
|
|
a stream WirePlumber has pinned to a device stays where it is, so the
|
|
move is still made explicitly.
|
|
"""
|
|
moved = 0
|
|
|
|
for stream in pactl("list", "sink-inputs", parse=True) or []:
|
|
if stream.get("sink") == index:
|
|
continue
|
|
|
|
if pactl("move-sink-input", str(stream.get("index")), name) is not None:
|
|
moved += 1
|
|
|
|
return moved
|
|
|
|
|
|
def apply(state):
|
|
"""Make the room match the decision. Returns True once it is settled."""
|
|
cards = pactl("list", "cards", parse=True, quiet=state.get("offline", False))
|
|
|
|
if cards is None:
|
|
# Said once, not every few seconds for as long as the machine has
|
|
# no sound server.
|
|
if not state.get("offline"):
|
|
log("cannot reach the sound server")
|
|
state["offline"] = True
|
|
|
|
return False
|
|
|
|
if state.get("offline"):
|
|
log("the sound server is back")
|
|
state["offline"] = False
|
|
|
|
want = preference()
|
|
chosen = policy.choose(cards, want)
|
|
|
|
if chosen is None:
|
|
if state.get("port") is not None:
|
|
log("no usable output")
|
|
|
|
state["port"] = None
|
|
|
|
return policy.settled(cards, want, None)
|
|
|
|
key = (chosen["card"], chosen["port"])
|
|
changed = state.get("port") != key
|
|
|
|
if changed:
|
|
profile = policy.profile_for(chosen)
|
|
active = active_profile_of(cards, chosen["card"])
|
|
|
|
# Only ever asked for when it is not already in place, so a room
|
|
# that is working is never disturbed and the card is not switched
|
|
# in a circle.
|
|
if profile and profile != active:
|
|
attempts = state.get("attempts", {})
|
|
tried = attempts.get(key, 0)
|
|
|
|
if tried >= PROFILE_ATTEMPTS:
|
|
log(f"giving up on {profile} for {chosen['card']}")
|
|
|
|
return True
|
|
|
|
attempts[key] = tried + 1
|
|
state["attempts"] = attempts
|
|
|
|
pactl("set-card-profile", str(chosen["card"]), profile)
|
|
# The sink only exists once the profile is in place.
|
|
time.sleep(0.6)
|
|
|
|
sinks = pactl("list", "sinks", parse=True)
|
|
sink = sink_for(sinks, chosen)
|
|
|
|
if sink is None:
|
|
# Deliberately leaves state["port"] alone so the next look tries
|
|
# again, and deliberately counted, so it cannot try forever.
|
|
log(f"no sink yet for {chosen['port']} on {chosen['card']}")
|
|
|
|
return False
|
|
|
|
name = sink.get("name")
|
|
|
|
if sink.get("name") != (pactl("get-default-sink") or "").strip():
|
|
pactl("set-default-sink", name)
|
|
|
|
if changed:
|
|
if not configured_before():
|
|
# The one time this machine's volume is set for it.
|
|
pactl("set-sink-volume", name, DEFAULT_VOLUME)
|
|
log(f"first run: {name} set to {DEFAULT_VOLUME}")
|
|
remember_configured()
|
|
|
|
moved = move_streams(name, sink.get("index"))
|
|
|
|
log(f"output is {chosen['port']} on {chosen['card']} ({name})"
|
|
+ (f", moved {moved} playing stream(s)" if moved else ""))
|
|
|
|
state["attempts"] = {}
|
|
else:
|
|
# A television coming back can leave a stream stranded on a sink
|
|
# that has gone; nothing is logged when there is nothing to do.
|
|
move_streams(name, sink.get("index"))
|
|
|
|
state["port"] = key
|
|
|
|
return policy.settled(cards, want, chosen)
|
|
|
|
|
|
def wait_for_server():
|
|
"""Let PipeWire's PulseAudio server finish starting.
|
|
|
|
The race this avoids is the whole reason the rooms were silent, and
|
|
ordering the unit after pipewire-pulse is not by itself enough: the
|
|
socket can be there before the server will answer.
|
|
"""
|
|
deadline = time.monotonic() + SERVER_WAIT_SECONDS
|
|
complained = False
|
|
|
|
while time.monotonic() < deadline:
|
|
if pactl("info", quiet=True) is not None:
|
|
return True
|
|
|
|
if not complained:
|
|
log("waiting for the sound server")
|
|
complained = True
|
|
|
|
time.sleep(1)
|
|
|
|
log("carrying on without having reached the sound server")
|
|
|
|
return False
|
|
|
|
|
|
def _interesting(line):
|
|
return any(word in line for word in ("card", "server", "sink", "source"))
|
|
|
|
|
|
def watch():
|
|
state = {}
|
|
|
|
wait_for_server()
|
|
settled = apply(state)
|
|
delay = RECHECK_START_SECONDS
|
|
backoff = RESUBSCRIBE_SECONDS
|
|
complained = False
|
|
|
|
while True:
|
|
try:
|
|
started = time.monotonic()
|
|
process = subprocess.Popen(
|
|
["pactl", "subscribe"],
|
|
stdout=subprocess.PIPE,
|
|
# pactl says "Connection refused" on its own stderr every
|
|
# time it cannot reach the server. Saying so once is this
|
|
# daemon's job; letting pactl say it into the journal
|
|
# every few seconds is not.
|
|
stderr=subprocess.DEVNULL,
|
|
)
|
|
except OSError as error:
|
|
log(f"could not subscribe: {error}")
|
|
time.sleep(backoff)
|
|
backoff = min(backoff * 2, RESUBSCRIBE_LIMIT_SECONDS)
|
|
continue
|
|
|
|
stream = process.stdout
|
|
pending = b""
|
|
|
|
# Everything that happened before this attached is invisible to it,
|
|
# including everything that happened at boot. This is the line the
|
|
# silent rooms were missing.
|
|
settled = apply(state)
|
|
|
|
try:
|
|
while process.poll() is None:
|
|
timeout = None if settled else delay
|
|
ready, _, _ = select.select([stream], [], [], timeout)
|
|
|
|
if not ready:
|
|
# The room has not got the output it would prefer, so
|
|
# look again without waiting to be told.
|
|
settled = apply(state)
|
|
delay = (RECHECK_START_SECONDS if settled
|
|
else min(delay * 2, RECHECK_LIMIT_SECONDS))
|
|
continue
|
|
|
|
try:
|
|
chunk = os.read(stream.fileno(), 65536)
|
|
except OSError as error:
|
|
if error.errno == errno.EINTR:
|
|
continue
|
|
raise
|
|
|
|
if not chunk:
|
|
break
|
|
|
|
pending += chunk
|
|
lines = pending.split(b"\n")
|
|
pending = lines.pop()
|
|
|
|
if not any(
|
|
_interesting(line.decode("utf-8", "replace"))
|
|
for line in lines
|
|
):
|
|
continue
|
|
|
|
# Drain to quiet before deciding, so one television being
|
|
# switched on is one decision and not a dozen.
|
|
limit = time.monotonic() + SETTLE_LIMIT_SECONDS
|
|
|
|
while time.monotonic() < limit:
|
|
quiet = select.select([stream], [], [], SETTLE_SECONDS)[0]
|
|
|
|
if not quiet:
|
|
break
|
|
|
|
if not os.read(stream.fileno(), 65536):
|
|
break
|
|
|
|
pending = b""
|
|
settled = apply(state)
|
|
# Whatever it was waiting for, it starts looking eagerly
|
|
# again rather than at whatever rate it had backed off to.
|
|
delay = RECHECK_START_SECONDS
|
|
except OSError as error:
|
|
log(f"subscription ended: {error}")
|
|
finally:
|
|
process.terminate()
|
|
|
|
try:
|
|
process.wait(timeout=5)
|
|
except subprocess.SubprocessError:
|
|
process.kill()
|
|
|
|
if time.monotonic() - started >= SUBSCRIPTION_HEALTHY_SECONDS:
|
|
# It ran for a while, so this is an ordinary reconnect rather
|
|
# than a server that is not there.
|
|
backoff = RESUBSCRIBE_SECONDS
|
|
complained = False
|
|
|
|
if not complained:
|
|
log("re-subscribing")
|
|
complained = True
|
|
|
|
time.sleep(backoff)
|
|
backoff = min(backoff * 2, RESUBSCRIBE_LIMIT_SECONDS)
|
|
|
|
|
|
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'} "
|
|
f"profile={policy.profile_for(chosen) if chosen else 'none'} "
|
|
f"settled={policy.settled(cards, preference(), chosen)}")
|
|
return 0
|
|
|
|
watch()
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|