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]>
This commit is contained in:
ekklesia
2026-09-20 14:46:46 -05:00
co-authored by Claude Opus 5
parent 20007238be
commit 27cd64a668
9 changed files with 1464 additions and 109 deletions
+56
View File
@@ -174,6 +174,62 @@ session instead. It never starts mako.
- [ ] Running it as an unprivileged admin account explains it needs sudo
- [ ] `tests/test-classroom-notify-test.sh` passes (also run by the build)
## Audio
The room prefers the television. `AUDIO_OUTPUT` in device.conf decides:
`auto` (prefer a connected HDMI or DisplayPort set, fall back to the
jack), `hdmi`, or `analog`. A machine with no digital output at all — a
laptop, when one arrives — is correct on `auto` without any change here.
Two test suites cover this, and both run in the build:
- [ ] `tests/test-classroom-audio.sh` — the decision itself, against the
shapes real pactl emits
- [ ] `tests/test-classroom-audio-daemon.sh` — the real daemon driven
through every situation a room gets into, against a pactl that
answers the way PipeWire does
On a machine:
- [ ] `classroom-audio show` lists the outputs, what it chose, the
profile it would set and whether it is `settled`
- [ ] `journalctl --user -u ekklesia-classroom-audio` shows one
`output is ...` line per real change and nothing in between
- [ ] Leaving the room alone for an hour adds no further lines and no
measurable CPU time
### The television switched on after the computer
This is the scenario that silenced River Kids on 0.2.2 and the one no lab
can produce: ALSA reports a television through the HDMI port's
availability, and no loopback or dummy sound driver has such a port. The
suites above play it through a mock, and the mock is faithful to the real
server as far as it was measured — but a mock cannot prove the real
ALSA jack change arrives. **Do not take a passing build as evidence that
this works.** It has to be done at a machine, once, per release:
1. Switch the television **off** at the set, not the remote.
2. Reboot the computer and let it reach the classroom desktop.
3. Leave it at least two minutes, so any startup settling is over.
4. `journalctl --user -u ekklesia-classroom-audio` — expect one
`output is ...` line. In a room with nothing in the analog jack it
names the HDMI port even now, which is right: there is nowhere
better for the sound to go, and the set is then already correct
when it comes on.
5. Open Wonder Ink and Chrome and start something playing.
6. Switch the television **on**.
7. Sound should arrive within about a minute, in both applications,
**without closing or reopening either one**.
8. `wpctl status` should show the HDMI sink as default, and
`classroom-audio show` should say `settled=True`.
9. Switch the television off and on again. Sound should come back.
10. Check the volume is where the teacher left it, and that an output
muted on purpose is still muted.
If step 7 fails, the jack change is not reaching PipeWire at all; capture
`pactl subscribe` and `classroom-audio show` with the set off and then on,
which is what distinguishes that from a policy fault.
## Messaging
- [ ] With `MQTT_ENABLED=false`, `journalctl -u ekklesia-classroom-mqtt`