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]>
203 lines
8.0 KiB
Bash
Executable File
203 lines
8.0 KiB
Bash
Executable File
#!/bin/bash
|
|
set -uo pipefail
|
|
|
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
|
|
PYTHONPATH="$ROOT/lib" python3 - <<'PY'
|
|
from classroom_audio import (
|
|
ANALOG_ONLY, AUTO, HDMI, candidates, choose, profile_for, settled,
|
|
)
|
|
|
|
passed = failed = 0
|
|
|
|
|
|
def check(label, condition, detail=""):
|
|
global passed, failed
|
|
if condition:
|
|
print(f" ok {label}")
|
|
passed += 1
|
|
else:
|
|
print(f" FAIL {label}")
|
|
if detail:
|
|
print(f" {detail}")
|
|
failed += 1
|
|
|
|
|
|
# The shapes here are what `pactl -f json list cards` really emits, taken
|
|
# from PipeWire 1.4.2 and WirePlumber 0.5.8 running in the lab. Two things
|
|
# the first set of fixtures here got wrong, and both of them mattered:
|
|
#
|
|
# * a port carries no "direction" key at all, so nothing can be filtered
|
|
# on it and the port names are doing all of the work; and
|
|
# * an unknown availability is the phrase "availability unknown", not an
|
|
# empty string.
|
|
#
|
|
# Fixtures that describe a machine nobody has cannot catch a bug on a
|
|
# machine somebody does have, which is most of why 0.2.2 shipped.
|
|
UNKNOWN_AVAILABILITY = "availability unknown"
|
|
|
|
# Every profile a real Intel HDA card lists against these ports, trimmed
|
|
# to the ones that matter here: each output appears both on its own and
|
|
# paired with the analog input.
|
|
ANALOG_PROFILES = [
|
|
"output:analog-stereo",
|
|
"output:analog-stereo+input:analog-stereo",
|
|
"output:analog-surround-40",
|
|
"output:analog-surround-40+input:analog-stereo",
|
|
]
|
|
HDMI_PROFILES = [
|
|
"output:hdmi-stereo",
|
|
"output:hdmi-stereo+input:analog-stereo",
|
|
"output:hdmi-surround",
|
|
"output:hdmi-surround+input:analog-stereo",
|
|
]
|
|
|
|
|
|
def card(name, ports, active_profile="output:analog-stereo+input:analog-stereo"):
|
|
return {
|
|
"name": name,
|
|
"index": 0,
|
|
"active_profile": active_profile,
|
|
"ports": {
|
|
port: {
|
|
"availability": availability,
|
|
"priority": priority,
|
|
"profiles": profiles,
|
|
}
|
|
for port, availability, priority, profiles in ports
|
|
},
|
|
}
|
|
|
|
|
|
# The room this policy was written for. A Samsung television on HDMI with
|
|
# working PCM audio, and an onboard analog jack with nothing in it that
|
|
# reports itself unavailable. WirePlumber chose analog.
|
|
THE_ROOM = [card("alsa_card.pci-0000_00_1f.3", [
|
|
("analog-output-lineout", "not available", 9900, ANALOG_PROFILES),
|
|
("hdmi-output-0", "available", 5900, HDMI_PROFILES),
|
|
])]
|
|
|
|
print("1. the classroom that went to analog")
|
|
chosen = choose(THE_ROOM, AUTO)
|
|
check("auto picks the television, not the empty jack",
|
|
chosen and chosen["port"] == "hdmi-output-0",
|
|
f"chose {chosen and chosen['port']}")
|
|
check("and the stereo television profile that keeps the input",
|
|
profile_for(chosen) == "output:hdmi-stereo+input:analog-stereo",
|
|
f"chose {profile_for(chosen)}")
|
|
check("which is the profile an administrator reaches for by hand",
|
|
profile_for(chosen)
|
|
== "output:hdmi-stereo+input:analog-stereo")
|
|
|
|
print("2. an unavailable digital port is not a candidate")
|
|
TV_OFF = [card("alsa_card.pci", [
|
|
("analog-output-lineout", "available", 9900, ANALOG_PROFILES),
|
|
("hdmi-output-0", "not available", 5900, HDMI_PROFILES),
|
|
])]
|
|
chosen = choose(TV_OFF, AUTO)
|
|
check("auto falls back to analog with the television off",
|
|
chosen and chosen["port"] == "analog-output-lineout",
|
|
f"chose {chosen and chosen['port']}")
|
|
|
|
print("3. availability is not always reported")
|
|
UNKNOWN = [card("alsa_card.pci", [
|
|
("analog-output-lineout", UNKNOWN_AVAILABILITY, 9900, ANALOG_PROFILES),
|
|
("hdmi-output-0", UNKNOWN_AVAILABILITY, 5900, HDMI_PROFILES),
|
|
])]
|
|
chosen = choose(UNKNOWN, AUTO)
|
|
check("a silent card still prefers the digital output",
|
|
chosen and chosen["port"] == "hdmi-output-0",
|
|
f"chose {chosen and chosen['port']}")
|
|
check("the real phrase for unknown is read as unknown, not as absent",
|
|
[item for item in candidates(UNKNOWN)
|
|
if item["availability"] == "unknown"],
|
|
"'availability unknown' was not understood")
|
|
|
|
print("4. an available analog jack does not outrank a live television")
|
|
BOTH = [card("alsa_card.pci", [
|
|
("analog-output-lineout", "available", 9900, ANALOG_PROFILES),
|
|
("hdmi-output-0", "available", 5900, HDMI_PROFILES),
|
|
])]
|
|
chosen = choose(BOTH, AUTO)
|
|
check("auto still picks HDMI when both are connected",
|
|
chosen and chosen["port"] == "hdmi-output-0",
|
|
f"chose {chosen and chosen['port']}")
|
|
|
|
print("5. explicit overrides")
|
|
check("analog forces the jack even with a live television",
|
|
choose(BOTH, ANALOG_ONLY)["port"] == "analog-output-lineout")
|
|
check("hdmi forces the television even when it is off",
|
|
choose(TV_OFF, HDMI)["port"] == "hdmi-output-0")
|
|
check("hdmi still falls back rather than leaving the room silent",
|
|
choose([card("alsa_card.pci", [
|
|
("analog-output-lineout", "available", 9900, ANALOG_PROFILES),
|
|
])], HDMI)["port"] == "analog-output-lineout")
|
|
|
|
print("6. two displays")
|
|
TWO = [card("alsa_card.pci", [
|
|
("hdmi-output-0", "not available", 5900, HDMI_PROFILES),
|
|
("hdmi-output-1", "available", 5800,
|
|
["output:hdmi-stereo-extra1",
|
|
"output:hdmi-stereo-extra1+input:analog-stereo"]),
|
|
])]
|
|
chosen = choose(TWO, AUTO)
|
|
check("the one that is switched on wins, whatever its priority",
|
|
chosen and chosen["port"] == "hdmi-output-1",
|
|
f"chose {chosen and chosen['port']}")
|
|
|
|
print("7. nothing at all")
|
|
check("a machine with no sound card decides nothing rather than crashing",
|
|
choose([], AUTO) is None)
|
|
check("and a card with only inputs is not an output",
|
|
choose([{"name": "c", "index": 0, "ports": {
|
|
"analog-input-mic": {"availability": UNKNOWN_AVAILABILITY,
|
|
"priority": 100,
|
|
"profiles": ["input:analog-stereo"]}}}],
|
|
AUTO) is None)
|
|
|
|
print("8. ports that are not outputs of interest are ignored")
|
|
check("a bluetooth headset port is not mistaken for a display",
|
|
not [item for item in candidates([card("bt", [
|
|
("headset-output", "available", 100, ["headset-head-unit"])])])
|
|
if item["kind"] == HDMI])
|
|
|
|
print("9. the profile chosen is always a real one on the port")
|
|
for fixture in (THE_ROOM, TV_OFF, UNKNOWN, BOTH, TWO):
|
|
pick = choose(fixture, AUTO)
|
|
check(f"{pick['port']}: profile is one the port actually offers",
|
|
profile_for(pick) in pick["profiles"],
|
|
f"{profile_for(pick)} not in {pick['profiles']}")
|
|
|
|
print("10. stereo is preferred over surround for a television")
|
|
check("a card offering only surround still yields an output profile",
|
|
profile_for({"profiles": ["output:hdmi-surround71",
|
|
"output:hdmi-surround71+input:analog-stereo"]})
|
|
== "output:hdmi-surround71+input:analog-stereo")
|
|
check("a port with no profiles at all decides nothing",
|
|
profile_for({"profiles": []}) is None)
|
|
|
|
print("11. knowing whether there is anything left to wait for")
|
|
# This is what stops the room polling when it is already right, and what
|
|
# keeps it looking when a television might still be switched on.
|
|
check("a room already on its television is settled",
|
|
settled(THE_ROOM, AUTO, choose(THE_ROOM, AUTO)))
|
|
check("a room on analog with an HDMI port that is off is NOT settled",
|
|
not settled(TV_OFF, AUTO, choose(TV_OFF, AUTO)),
|
|
"a television switched on later would never be looked for")
|
|
check("a machine with no digital output at all is settled on analog",
|
|
settled([card("alsa_card.pci", [
|
|
("analog-output-lineout", "available", 9900, ANALOG_PROFILES),
|
|
])], AUTO, choose([card("alsa_card.pci", [
|
|
("analog-output-lineout", "available", 9900, ANALOG_PROFILES),
|
|
])], AUTO)),
|
|
"a laptop with only a jack would poll for a television forever")
|
|
check("an analog preference is settled once it has the jack",
|
|
settled(BOTH, ANALOG_ONLY, choose(BOTH, ANALOG_ONLY)))
|
|
check("a machine with no sound card at all is settled",
|
|
settled([], AUTO, None))
|
|
|
|
print()
|
|
print(f"{passed} passed, {failed} failed")
|
|
raise SystemExit(1 if failed else 0)
|
|
PY
|