Files
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

320 lines
10 KiB
Python
Executable File

#!/usr/bin/env python3
"""A pactl that answers for a classroom nobody has to be standing in.
This exists because the one thing the room actually does — a television
switched off while the computer boots and switched on afterwards — cannot
be produced by any sound card a lab has. ALSA reports a television through
the HDMI port's availability, and no loopback or dummy driver has one.
So the scenarios that matter are played through this instead, and the real
classroom-audio daemon runs against it unmodified. Everything here is
shaped the way PipeWire 1.4.2 and WirePlumber 0.5.8 really answer, checked
against both running in the lab:
cards "ports" is an object keyed by port name, a port carries NO
"direction", and an unknown availability is the phrase
"availability unknown"
sinks have no top-level "card"; the link to the card is
properties["device.name"], and "active_port" is a port name
profile switching one destroys the old sink and creates a new one,
and emits a burst of events rather than a single one
It also records every command that changes anything, so a test can say
"and nothing was switched twice", which is the property the room needs
most and the one that is invisible from a single snapshot.
"""
import json
import os
import sys
import time
STATE = os.environ["MOCK_AUDIO_STATE"]
EVENTS = os.environ["MOCK_AUDIO_EVENTS"]
JOURNAL = os.environ["MOCK_AUDIO_JOURNAL"]
def load():
with open(STATE) as handle:
return json.load(handle)
def save(state):
temporary = f"{STATE}.new"
with open(temporary, "w") as handle:
json.dump(state, handle, indent=2)
os.replace(temporary, STATE)
def record(line):
with open(JOURNAL, "a") as handle:
handle.write(line + "\n")
def emit(*events):
with open(EVENTS, "a") as handle:
for event in events:
handle.write(event + "\n")
def output_token(profile):
"""hdmi-stereo, from output:hdmi-stereo+input:analog-stereo."""
head = (profile or "").split("+", 1)[0]
return head.split(":", 1)[1] if ":" in head else ""
def active_port_of(card):
profile = card.get("active_profile") or ""
for name, port in (card.get("ports") or {}).items():
if profile in (port.get("profiles") or []) and "output" in profile:
# The port whose own kind matches the output half of the
# profile, so an analog profile does not light the HDMI port.
token = output_token(profile)
if name.startswith("hdmi") or name.startswith("displayport"):
if "hdmi" in token or "dp" in token:
return name, port
elif "analog" in token or "stereo" in token:
return name, port
return None, None
def sinks_of(state):
"""One sink per card, for whatever its active profile turned on."""
sinks = []
index = 40
for card in state["cards"]:
profile = card.get("active_profile") or ""
index += 1
if not profile.startswith("output:"):
continue
name, port = active_port_of(card)
if name is None:
continue
suffix = card["name"].split(".", 1)[-1]
sink_name = f"alsa_output.{suffix}.{output_token(profile)}"
levels = state.setdefault("levels", {})
level = levels.get(sink_name, {"volume": 100, "mute": False})
sinks.append({
"index": index,
"state": "SUSPENDED",
"name": sink_name,
"description": card.get("description", "Built-in Audio"),
"mute": level["mute"],
"volume": {
"front-left": {"value_percent": f"{level['volume']}%"},
"front-right": {"value_percent": f"{level['volume']}%"},
},
"properties": {
"device.name": card["name"],
"node.name": sink_name,
"alsa.card_name": card.get("description", "Built-in Audio"),
"device.profile.name": output_token(profile),
},
"ports": [{
"name": name,
"availability": port.get("availability",
"availability unknown"),
"priority": port.get("priority", 0),
}],
"active_port": name,
})
return sinks
def sink_named(state, name):
for sink in sinks_of(state):
if sink["name"] == name:
return sink
return None
def main():
argv = sys.argv[1:]
if argv[:2] == ["-f", "json"]:
argv = argv[2:]
as_json = True
else:
as_json = False
if not argv:
return 1
state = load()
# The server not being up yet is the whole of the boot race, so it is
# a state this has to be able to be in.
if not state.get("server_up", True) and argv[0] != "subscribe":
sys.stderr.write("Connection failure: Connection refused\n")
return 1
command = argv[0]
if command == "info":
if not state.get("server_up", True):
sys.stderr.write("Connection failure: Connection refused\n")
return 1
print("Server Name: PulseAudio (on PipeWire 1.4.2)")
return 0
if command == "list":
what = argv[1] if len(argv) > 1 else ""
if what == "cards":
payload = state["cards"]
elif what == "sinks":
payload = sinks_of(state)
elif what == "sink-inputs":
payload = state.get("streams", [])
else:
payload = []
print(json.dumps(payload) if as_json else "")
return 0
if command == "get-default-sink":
print(state.get("default_sink") or "")
return 0
if command == "set-default-sink":
name = argv[1]
if sink_named(state, name) is None:
sys.stderr.write(f"Failure: No such entity {name}\n")
return 1
if state.get("default_sink") != name:
record(f"set-default-sink {name}")
state["default_sink"] = name
save(state)
# A no-op set-default-sink emits nothing on the real server;
# measured. Only a real change is announced.
emit("Event 'change' on server #4294967295")
return 0
if command == "set-card-profile":
card_name, profile = argv[1], argv[2]
for card in state["cards"]:
if card["name"] != card_name:
continue
if profile not in (card.get("profiles") or []):
sys.stderr.write(f"Failure: No such profile {profile}\n")
return 1
record(f"set-card-profile {card_name} {profile}")
old = sinks_of(state)
card["active_profile"] = profile
save(state)
new = sinks_of(state)
# Switching a profile tears the old sink down and builds a new
# one, and the server says all of it.
events = []
for sink in old:
if sink["name"] not in [item["name"] for item in new]:
events.append(f"Event 'remove' on sink #{sink['index']}")
events.append(f"Event 'change' on card #{card.get('index', 0)}")
for sink in new:
if sink["name"] not in [item["name"] for item in old]:
events.append(f"Event 'new' on sink #{sink['index']}")
emit(*events)
# A stream on a sink that has gone is re-landed by the server
# on whatever is default, exactly as WirePlumber does.
survivors = [item["name"] for item in new]
for stream in state.get("streams", []):
if stream.get("sink_name") not in survivors:
fallback = state.get("default_sink") or (
survivors[0] if survivors else None)
stream["sink_name"] = fallback
target = sink_named(state, fallback) if fallback else None
stream["sink"] = target["index"] if target else -1
save(state)
return 0
sys.stderr.write(f"Failure: No such card {card_name}\n")
return 1
if command == "set-sink-volume":
name, value = argv[1], argv[2]
record(f"set-sink-volume {name} {value}")
state.setdefault("levels", {}).setdefault(
name, {"volume": 100, "mute": False})
state["levels"][name]["volume"] = int(value.rstrip("%"))
save(state)
emit(f"Event 'change' on sink #{sink_named(state, name)['index']}")
return 0
if command == "set-sink-mute":
name, value = argv[1], argv[2]
record(f"set-sink-mute {name} {value}")
state.setdefault("levels", {}).setdefault(
name, {"volume": 100, "mute": False})
state["levels"][name]["mute"] = value not in ("0", "false", "no")
save(state)
emit(f"Event 'change' on sink #{sink_named(state, name)['index']}")
return 0
if command == "move-sink-input":
index, name = int(argv[1]), argv[2]
target = sink_named(state, name)
if target is None:
sys.stderr.write(f"Failure: No such entity {name}\n")
return 1
for stream in state.get("streams", []):
if stream["index"] == index:
if stream.get("sink_name") != name:
record(f"move-sink-input {index} {name}")
stream["sink_name"] = name
stream["sink"] = target["index"]
save(state)
emit(f"Event 'change' on sink-input #{index}")
return 0
sys.stderr.write(f"Failure: No such entity {index}\n")
return 1
if command == "subscribe":
# Blocks and prints events as they happen, like the real one. Only
# events written from now on: a subscription never replays what it
# missed, which is precisely how the rooms came to be silent.
with open(EVENTS) as handle:
handle.seek(0, os.SEEK_END)
while True:
line = handle.readline()
if not line:
time.sleep(0.05)
continue
sys.stdout.write(line)
sys.stdout.flush()
sys.stderr.write(f"mock-pactl: unhandled {' '.join(argv)}\n")
return 1
if __name__ == "__main__":
sys.exit(main())