feat: decide the classroom's audio output from what is plugged in

The room that prompted this had a television on HDMI with working PCM
audio and nothing in the analog jack, and WirePlumber chose analog. A
teacher who corrected it by hand then had to restart the browser before
any sound came out.

The policy is a pure function so it can be tested against that room
rather than against a machine: an unavailable port is not a candidate,
an analog port merely existing is not evidence of a cable, and an
explicit AUDIO_OUTPUT overrides the lot. Twelve cases, the real room
among them.

The daemon follows PipeWire's own event stream rather than polling, and
moves streams that are already playing, which is the part that stops a
teacher having to close and reopen the browser.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
ekklesia
2026-09-10 08:47:28 -05:00
co-authored by Claude Opus 5
parent 87b55761c6
commit 2ff9d47be9
27 changed files with 626 additions and 40 deletions
+8 -3
View File
@@ -32,7 +32,12 @@ action/help_report_bug=false
action/help_about_kde=false
action/help_contents=false
[General][$i]
# Not immutable. Marking this group [$i] froze the colour scheme so hard
# that `classroom-theme light` changed the running session and then lost
# the change at the next start — measured. These are defaults for a room
# that has no settings application anyway; the lock belongs on the
# restrictions above, not on the palette.
[General]
ColorScheme=EkklesiaDark
AccentColor=79,179,168
# The teacher's own words are not decoration: keep text rendering plain
@@ -41,13 +46,13 @@ XftAntialias=true
XftHintStyle=hintslight
XftSubPixel=rgb
[KDE][$i]
[KDE]
widgetStyle=Breeze
LookAndFeelPackage=org.ekklesia.classroom.desktop
SingleClick=false
AnimationDurationFactor=0.5
[Icons][$i]
[Icons]
Theme=breeze-dark
[KFileDialog Settings][$i]
+21 -37
View File
@@ -1,48 +1,32 @@
# Window rules.
#
# There are three, they are all about size, and none of them is about
# stacking. Dialogs staying in front of the window that opened them,
# secondary windows floating over the main one instead of resizing it, and
# a print dialog being the window in front are all ordinary window-manager
# behaviour here, so there is nothing to write down.
# There is one, and it is about video.
#
# `types=1` is NET::Normal only. Without it a rule that maximises would
# also maximise the dialogs, which is exactly the behaviour the classroom
# is moving away from.
# Everything else the classroom used to arrange by hand is ordinary window
# manager behaviour and needs no rule: a dialog stays in front of the window
# that opened it, a window a page opens floats over the browser instead of
# resizing it, and a print dialog is the window in front.
#
# There is deliberately no rule that maximises the browser. A rule can only
# match a window class, and Chrome gives its main window and every window a
# page opens the same one — matching on it maximised the lesson's popup to
# the whole display, which is the behaviour this architecture exists to
# stop. Chrome can tell them apart, so `--start-maximized` in the launcher
# does it: the first window fills the screen, later ones keep the size the
# page asked for. Arranging that from outside the browser is what the old
# 314-line window arbiter was doing.
[General]
count=3
rules=classroom-browser,classroom-wonderink,classroom-video
[classroom-browser]
Description=The classroom browser fills the screen
wmclass=google-chrome
wmclassmatch=1
wmclasscomplete=false
types=1
maximizehoriz=true
maximizehorizrule=3
maximizevert=true
maximizevertrule=3
[classroom-wonderink]
Description=Wonder Ink fills the screen
# Chrome derives this from the --app URL. Measured on a running session
# rather than guessed; it is also what the launcher's StartupWMClass says,
# and the two have to agree or the panel shows two buttons for one app.
wmclass=chrome-app.wonderink.org__-Default
wmclassmatch=1
wmclasscomplete=false
types=1
maximizehoriz=true
maximizehorizrule=3
maximizevert=true
maximizevertrule=3
count=1
rules=classroom-video
[classroom-video]
Description=Video is an appliance screen, not a window
wmclass=vlc
wmclassmatch=2
# VLC reports its class as "Vlc", and KWin's matching is case sensitive —
# measured on a running session, where a lower-case substring rule matched
# nothing at all and said so nowhere.
wmclass=(?i)vlc
wmclassmatch=3
wmclasscomplete=false
noborder=true
noborderrule=2
Binary file not shown.

After

Width:  |  Height:  |  Size: 251 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 252 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 252 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 252 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 252 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

+162
View File
@@ -0,0 +1,162 @@
"""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 come 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.
"""
# 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 one
that turns the output on without also claiming inputs it does not need,
which in practice is the shortest name among the stereo output ones.
"""
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
return sorted(outputs, key=lambda name: (len(name), name))[0]
+27
View File
@@ -31,6 +31,18 @@ DVD_ENABLED=false
# implemented yet; this only controls whether the current placeholder appears.
LIVE_ENABLED=false
# Which output the room plays through.
#
# auto prefer a connected HDMI or DisplayPort television, and fall
# back to the analog jack. This is right for almost every room.
# hdmi always the television, even if it does not report itself.
# analog always the jack.
#
# auto re-decides whenever the hardware changes, so a television switched
# on after the computer is picked up without anybody touching anything,
# and sound that is already playing is moved across to it.
AUDIO_OUTPUT=auto
# Size of the dock, clock and notifications, 0.8 to 1.5.
# Larger rooms and larger TVs want a larger number.
UI_SCALE=1.0
@@ -60,6 +72,21 @@ DVD_ENABLED=false
"""# Show the Sanctuary LIVE placeholder in the dock. Live viewing is not
# implemented yet; this only controls whether the current placeholder appears.
LIVE_ENABLED=false
""",
),
(
"AUDIO_OUTPUT",
"""# Which output the room plays through.
#
# auto prefer a connected HDMI or DisplayPort television, and fall
# back to the analog jack. This is right for almost every room.
# hdmi always the television, even if it does not report itself.
# analog always the jack.
#
# auto re-decides whenever the hardware changes, so a television switched
# on after the computer is picked up without anybody touching anything,
# and sound that is already playing is moved across to it.
AUDIO_OUTPUT=auto
""",
),
(
+218
View File
@@ -0,0 +1,218 @@
#!/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 on every change, and the third is the one that fixes
the complaint from the room:
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. That is exactly what happened in the room this was written
for.
Volume and mute are set once, when the output actually changes. After that
the room's own volume is left alone.
"""
import json
import os
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. Only ever applied when the
# output changes, never on top of a volume somebody has chosen.
DEFAULT_VOLUME = "70%"
# Several udev events arrive for one cable being plugged in. Settle first.
SETTLE_SECONDS = 1.5
def pactl(*args, parse=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:
log(f"pactl {' '.join(args)}: {error}")
return None
if result.returncode != 0:
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 log(message):
print(f"classroom-audio: {message}", flush=True)
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 sink_for_card(card_name):
for sink in pactl("list", "sinks", parse=True) or []:
properties = sink.get("properties") or {}
if properties.get("alsa.card_name") and card_name and \
properties.get("device.string"):
pass
if (sink.get("card") == card_name
or properties.get("device.name") == card_name
or (card_name or "") in (sink.get("name") or "")):
return sink
return None
def apply(state):
cards = pactl("list", "cards", parse=True)
if cards is None:
return
chosen = policy.choose(cards, preference())
if chosen is None:
if state.get("port") is not None:
log("no usable output")
state["port"] = None
return
profile = policy.profile_for(chosen)
changed = state.get("port") != (chosen["card"], chosen["port"])
if changed and profile:
pactl("set-card-profile", str(chosen["card"]), profile)
# The sink appears only after the profile is in place.
time.sleep(0.4)
sink = sink_for_card(chosen["card"])
if sink is None:
log(f"no sink for {chosen['card']} after {profile}")
return
name = sink.get("name")
pactl("set-default-sink", name)
if changed:
pactl("set-sink-mute", name, "0")
pactl("set-sink-volume", name, DEFAULT_VOLUME)
log(f"output is {chosen['port']} on {chosen['card']} ({name})")
# Anything already making sound follows, so a browser that was open
# before the television was switched on does not have to be restarted.
moved = 0
for stream in pactl("list", "sink-inputs", parse=True) or []:
if stream.get("sink") == sink.get("index"):
continue
if pactl("move-sink-input", str(stream.get("index")), name) is not None:
moved += 1
if moved:
log(f"moved {moved} playing stream(s) to {name}")
state["port"] = (chosen["card"], chosen["port"])
def watch():
"""Follow PipeWire's own event stream rather than polling it."""
state = {}
apply(state)
while True:
process = subprocess.Popen(
["pactl", "subscribe"],
stdout=subprocess.PIPE,
text=True,
)
try:
for line in process.stdout:
if not any(
word in line for word in ("card", "server", "sink", "source")
):
continue
# Drain the rest of the burst before acting once.
time.sleep(SETTLE_SECONDS)
apply(state)
except OSError as error:
log(f"subscription ended: {error}")
finally:
process.terminate()
log("re-subscribing")
time.sleep(3)
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'}")
return 0
watch()
return 0
if __name__ == "__main__":
sys.exit(main())
+1
View File
@@ -26,6 +26,7 @@ HOME_PAGE='https://www.youtube.com/'
exec google-chrome \
--ozone-platform=wayland \
--start-maximized \
--no-first-run \
--no-default-browser-check \
--password-store=basic \
+35
View File
@@ -0,0 +1,35 @@
#!/bin/bash
set -euo pipefail
# Light or dark, for a room whose teachers prefer one.
#
# There is one interface, not two. Both schemes are the same token file with
# the roles swapped, and Plasma applies them to the panel, the notifications,
# the window decorations and every dialog at once, so nothing has to be kept
# in step by hand.
usage() {
echo "usage: classroom-theme [light|dark|toggle|show]" >&2
exit 2
}
current() {
kreadconfig6 --file kdeglobals --group General --key ColorScheme 2>/dev/null \
|| echo EkklesiaDark
}
apply() {
plasma-apply-colorscheme "$1" >/dev/null
echo "$1"
}
case "${1:-show}" in
light) apply EkklesiaLight ;;
dark) apply EkklesiaDark ;;
toggle)
if [[ "$(current)" == EkklesiaLight ]]; then apply EkklesiaDark
else apply EkklesiaLight; fi
;;
show) current ;;
*) usage ;;
esac
+1
View File
@@ -32,6 +32,7 @@ exec google-chrome \
--user-data-dir="$PROFILE" \
--app="$HOME_PAGE" \
--ozone-platform=wayland \
--start-maximized \
--no-first-run \
--no-default-browser-check \
--password-store=basic \
@@ -0,0 +1,17 @@
[Unit]
Description=Ekklesia Classroom audio output policy
Documentation=file:///usr/local/bin/classroom-audio
After=pipewire.service wireplumber.service
Wants=pipewire.service wireplumber.service
PartOf=graphical-session.target
[Service]
ExecStart=/usr/local/bin/classroom-audio
# The room losing its sound policy is a Sunday-morning problem, so this is
# never allowed to stay dead — the same rule the panel gets.
Restart=always
RestartSec=5
Slice=session.slice
[Install]
WantedBy=plasma-workspace.target
+127
View File
@@ -0,0 +1,127 @@
#!/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
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
def card(name, ports):
return {
"name": name,
"index": 0,
"ports": {
port: {
"availability": availability,
"priority": priority,
"direction": "Output",
"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,
["output:analog-stereo", "output:analog-stereo+input:analog-stereo"]),
("hdmi-output-0", "available", 5900,
["output:hdmi-stereo", "output:hdmi-surround"]),
])]
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 a stereo output profile for it",
profile_for(chosen) == "output:hdmi-stereo",
f"chose {profile_for(chosen)}")
print("2. an unavailable digital port is not a candidate")
TV_OFF = [card("alsa_card.pci", [
("analog-output-lineout", "available", 9900, ["output:analog-stereo"]),
("hdmi-output-0", "not available", 5900, ["output:hdmi-stereo"]),
])]
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", "", 9900, ["output:analog-stereo"]),
("hdmi-output-0", "", 5900, ["output:hdmi-stereo"]),
])]
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']}")
print("4. an available analog jack does not outrank a live television")
BOTH = [card("alsa_card.pci", [
("analog-output-lineout", "available", 9900, ["output:analog-stereo"]),
("hdmi-output-0", "available", 5900, ["output:hdmi-stereo"]),
])]
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, ["output:analog-stereo"]),
])], HDMI)["port"] == "analog-output-lineout")
print("6. two displays")
TWO = [card("alsa_card.pci", [
("hdmi-output-0", "not available", 5900, ["output:hdmi-stereo"]),
("hdmi-output-1", "available", 5800, ["output:hdmi-stereo-extra1"]),
])]
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": {"direction": "Input", "availability": "available",
"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()
print(f"{passed} passed, {failed} failed")
raise SystemExit(1 if failed else 0)
PY
+9
View File
@@ -150,6 +150,15 @@ click)
sleep 0.5
;;
rclick)
outer swaymsg seat - cursor set "$2" "$3" >/dev/null
sleep 0.4
outer swaymsg seat - cursor press button3 >/dev/null
sleep 0.15
outer swaymsg seat - cursor release button3 >/dev/null
sleep 0.5
;;
move)
outer swaymsg seat - cursor set "$2" "$3" >/dev/null
;;