Give Sound the whole story, and keep the buttons inside the card

Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
Gabriel Brown
2026-08-24 11:39:16 -04:00
parent 9bc68ba358
commit b58371bb35
38 changed files with 3884 additions and 213 deletions
+206 -17
View File
@@ -4,26 +4,46 @@
# another settings app. This contract keeps the real device plumbing shared
# with Quick Settings and verifies the controls that must remain available.
#
# It also owns the line between Sound and Dictation. Dictation used to be a
# It owns two lines that the page keeps wanting to cross.
#
# The first is the line between Sound and Dictation. Dictation used to be a
# card on this page, because it listens through the input device chosen here.
# It is an input method, so it now sits under Input with the keyboard -- and
# the thing that made the old arrangement legible, that the microphone and the
# dictation setup were visibly the same subject, has to survive the move as an
# explicit handoff rather than as a second device picker.
#
# The second is the line between the page and the shell. Anything that speaks
# pactl, wpctl or pw-metadata races the PipeWire objects AudioDevices already
# holds, so it lives in a sibling singleton -- SoundTest, SoundFeedback, and
# now SoundCards, SoundRouting and SoundDefaults. The device list, the rows,
# the balance control and the service that owns discovery must stay native, and
# no component on the page may grow a Process of its own.
set -euo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
sound_page="$repo_dir/config/dot/quickshell/modules/settings/SoundPage.qml"
dictation_page="$repo_dir/config/dot/quickshell/modules/settings/DictationPage.qml"
device_list="$repo_dir/config/dot/quickshell/modules/settings/SoundDeviceList.qml"
device_row="$repo_dir/config/dot/quickshell/modules/settings/SoundDeviceRow.qml"
application_mixer="$repo_dir/config/dot/quickshell/modules/settings/ApplicationMixer.qml"
application_row="$repo_dir/config/dot/quickshell/modules/settings/ApplicationVolumeRow.qml"
balance="$repo_dir/config/dot/quickshell/modules/settings/AudioBalance.qml"
audio_devices="$repo_dir/config/dot/quickshell/services/AudioDevices.qml"
sound_feedback="$repo_dir/config/dot/quickshell/services/SoundFeedback.qml"
settings="$repo_dir/config/dot/quickshell/modules/settings"
services="$repo_dir/config/dot/quickshell/services"
sound_page="$settings/SoundPage.qml"
dictation_page="$settings/DictationPage.qml"
device_list="$settings/SoundDeviceList.qml"
device_row="$settings/SoundDeviceRow.qml"
application_mixer="$settings/ApplicationMixer.qml"
application_row="$settings/ApplicationVolumeRow.qml"
balance="$settings/AudioBalance.qml"
badge="$settings/SoundBadge.qml"
capture_row="$settings/SoundCaptureRow.qml"
channel_strip="$settings/SoundChannelStrip.qml"
theme_row="$settings/SoundThemeRow.qml"
volume_row="$settings/SoundVolumeRow.qml"
audio_devices="$services/AudioDevices.qml"
sound_feedback="$services/SoundFeedback.qml"
sound_cards="$services/SoundCards.qml"
sound_routing="$services/SoundRouting.qml"
sound_defaults="$services/SoundDefaults.qml"
quick_devices="$repo_dir/config/dot/quickshell/modules/quicksettings/AudioDeviceList.qml"
quick_slider="$repo_dir/config/dot/quickshell/modules/quicksettings/AudioSlider.qml"
harness="$repo_dir/config/dot/quickshell/sound-page-harness.qml"
config_home="$(mktemp -d /tmp/panama-sound-config.XXXXXX)"
state_home="$(mktemp -d /tmp/panama-sound-state.XXXXXX)"
@@ -34,8 +54,25 @@ fail() {
exit 1
}
# The harness reads the two shelling services through their fixture seams, so
# the page's structure is measured against canned state rather than against
# whatever hardware happens to be plugged in. The configured sink below is a
# Bluetooth address nothing in this session can be, which is what makes the
# ghost-row assertions deterministic.
ghost_sink="bluez_output.AA_BB_CC_11_22_33.1"
cards_fixture="$state_home/cards.json"
defaults_fixture="$state_home/defaults"
printf '[]\n' >"$cards_fixture"
cat >"$defaults_fixture" <<JSON
Found "default" metadata 30
update: id:0 key:'default.configured.audio.sink' value:'{"name":"$ghost_sink"}' type:'Spa:String:JSON'
JSON
qs_for_harness() {
XDG_CONFIG_HOME="$config_home" XDG_STATE_HOME="$state_home" qs -p "$harness" "$@"
XDG_CONFIG_HOME="$config_home" XDG_STATE_HOME="$state_home" \
PANAMA_SOUND_CARDS_FIXTURE="$cards_fixture" \
PANAMA_SOUND_DEFAULTS_FIXTURE="$defaults_fixture" \
qs -p "$harness" "$@"
}
cleanup() {
@@ -44,7 +81,14 @@ cleanup() {
}
trap cleanup EXIT
for file in "$sound_page" "$dictation_page" "$device_list" "$device_row" "$application_mixer" "$application_row" "$balance" "$audio_devices" "$sound_feedback" "$quick_devices"; do
core_files=("$audio_devices" "$device_list" "$device_row" "$balance")
page_components=("$sound_page" "$device_list" "$device_row" "$application_mixer"
"$application_row" "$balance" "$badge" "$capture_row" "$channel_strip"
"$theme_row" "$volume_row")
for file in "${page_components[@]}" "$dictation_page" "$audio_devices" \
"$sound_feedback" "$sound_cards" "$sound_routing" "$sound_defaults" \
"$quick_devices" "$quick_slider"; do
[[ -f "$file" ]] || fail "missing ${file#"$repo_dir/"}"
done
@@ -73,12 +117,121 @@ rg -Fq 'root.node.audio.volumes = next;' "$balance" || fail 'balance does not wr
[[ "$(rg -c 'SoundDeviceList \{' "$sound_page")" -eq 2 ]] || fail 'Sound page does not expose output and input device lists'
rg -Fq 'AudioBalance {' "$sound_page" || fail 'Sound page has no output balance control'
rg -Fq 'ApplicationMixer {' "$sound_page" || fail 'Sound page has no Applications mixer'
rg -Fq 'label: "Device profiles"' "$sound_page" || fail 'advanced handoff is not limited to device profiles'
rg -Fq 'SystemSettings.openGnomePanel("sound")' "$sound_page" || fail 'advanced GNOME Sound handoff was removed'
rg -Fq 'SoundFeedback.setEventSounds(checked)' "$sound_page" || fail 'event sounds are not controllable'
rg -Fq 'SoundFeedback.setInputFeedback(checked)' "$sound_page" || fail 'input feedback sounds are not controllable'
rg -Fq 'org.gnome.desktop.sound' "$sound_feedback" || fail 'sound feedback does not use the desktop sound schema'
# ── Device profiles are native now ───────────────────────────────────────────
# This page used to end in a button that opened GNOME's Sound panel, because
# reading a card's profiles means shelling out and the page may not. SoundCards
# does the shelling, so the handoff is gone -- and it has to stay gone, because
# an "Open panel" button next to a working dropdown is a second answer to the
# same question.
! rg -Fq 'openGnomePanel("sound")' "$sound_page" \
|| fail 'the GNOME Sound handoff came back, next to the profile dropdown that replaced it'
! rg -Fq 'label: "Device profiles"' "$sound_page" \
|| fail 'device profiles are an ActionRow again rather than the native card'
rg -Fq 'title: "Device profiles"' "$sound_page" || fail 'the Device profiles card is gone'
rg -Fq 'SoundCards.cards' "$sound_page" || fail 'the profile card reads no cards'
rg -Fq 'SoundCards.setProfile(' "$sound_page" || fail 'the profile dropdown writes nothing'
rg -Fq 'SoundCards.refresh();' "$sound_page" || fail 'device profiles are never re-read on page open'
rg -Fq 'SoundDefaults.refresh();' "$sound_page" || fail 'the configured defaults are never re-read on page open'
rg -Fq 'SoundCards.lastError' "$sound_page" \
|| fail 'a failed profile read degrades nothing, so the card would show an empty list instead of a reason'
# ── The microphone, and who is holding it ────────────────────────────────────
rg -Fq 'PwNodeType.AudioInStream' "$audio_devices" \
|| fail 'capture streams are not filtered by PipeWire type'
rg -Fq 'captureApplications' "$audio_devices" || fail 'AudioDevices exposes no capture applications'
rg -Fq 'AudioDevices.captureApplications' "$capture_row" \
|| fail 'the microphone row does not read the grouped capture applications'
rg -Fq 'AudioDevices.setApplicationMuted(' "$capture_row" \
|| fail 'an application holding the microphone cannot be muted from the row'
rg -Fq 'PwObjectTracker {' "$capture_row" \
|| fail 'capture nodes are untracked, so their mute state reads false and the write is swallowed'
# Nothing listening means no row, not an empty one. "No applications are using
# the microphone" is a sentence nobody needs on a page they opened to change an
# output device.
rg -Fq 'visible: root.users.length > 0' "$capture_row" \
|| fail 'the microphone row renders itself when nothing is listening'
rg -Fq 'SoundTest.startMicTest(' "$sound_page" || fail 'there is no microphone test'
rg -Fq 'SoundTest.micTestState' "$sound_page" \
|| fail 'the microphone test button never says what it is doing'
# ── The ghost row ────────────────────────────────────────────────────────────
# A configured device that is not present renders last, dimmed, and inert. The
# two halves that make it honest: it is keyed off the *configured* name, and it
# only appears when no present node carries that name.
rg -Fq 'SoundDefaults.absent(root.output)' "$device_list" \
|| fail 'the device list does not ask which configured device is missing, so it can draw no ghost row'
rg -Fq 'ghost: true' "$device_list" || fail 'the list has no ghost row at all'
rg -Fq 'Returns when connected' "$device_row" || fail 'the ghost row does not say why it is there'
# Non-interactive: selecting a device that is not here would ask PipeWire to
# make a node that does not exist the default.
python3 - "$device_row" <<'PY' || fail 'the ghost row is selectable, so a device that is not here can be chosen'
import re
import sys
source = open(sys.argv[1], encoding="utf-8").read()
for handler in ("TapHandler", "HoverHandler"):
for match in re.finditer(handler + r" \{(?P<body>.*?)\n \}", source, re.S):
if "!root.ghost" not in match.group("body"):
raise SystemExit(1)
if "AudioDevices.select(" not in source:
raise SystemExit(1)
PY
# The ghost row renders after the present devices, not among them.
python3 - "$device_list" <<'PY' || fail 'the ghost row is not last in the list'
import sys
lines = open(sys.argv[1], encoding="utf-8").read().splitlines()
repeater = next(i for i, line in enumerate(lines) if line.strip().startswith("Repeater"))
ghost = next(i for i, line in enumerate(lines) if "ghost: true" in line)
if ghost < repeater:
raise SystemExit(1)
PY
# ── Badges ───────────────────────────────────────────────────────────────────
# Native, off the node's own properties. A transport badge derived from the
# device name would be a guess.
rg -Fq '"device.api"' "$device_row" || fail 'device badges are not read from the node properties'
rg -Fq '"raop"' "$device_row" || fail 'AirPlay devices carry no badge'
rg -Fq '"bluez5"' "$device_row" || fail 'Bluetooth devices carry no badge'
# ── Over-amplification ───────────────────────────────────────────────────────
# One ceiling, one preference, and the input is never part of it: a microphone
# above 100% is gain on noise, not loudness.
rg -Fq 'overAmplification' "$sound_page" || fail 'the Sound page has no over-amplification control'
rg -Fq '1.5 : 1' "$sound_page" || fail 'the output slider maximum is not gated on the preference'
rg -Fq 'root.output' "$quick_slider" \
|| fail "Quick Settings' slider does not distinguish the sink from the microphone"
rg -Fq '1.5 : 1' "$quick_slider" || fail "Quick Settings' output slider cannot over-amplify"
python3 - "$quick_slider" <<'PY' || fail 'over-amplification is not restricted to the output in Quick Settings'
import re
import sys
source = open(sys.argv[1], encoding="utf-8").read()
match = re.search(r"property real maximum:(?P<body>.*?)\n\n", source, re.S)
if not match or "root.output" not in match.group("body"):
raise SystemExit(1)
PY
rg -Fq 'root.maximum > 1' "$volume_row" \
|| fail 'the region past 100% is not marked, so 150% looks like a full slider'
# ── Per-application routing ──────────────────────────────────────────────────
rg -Fq 'SoundRouting.moveApplication(' "$application_row" \
|| fail 'an application cannot be sent to another output'
rg -Fq 'SoundRouting.routeToDefault(' "$application_row" \
|| fail 'an application cannot be handed back to the system default'
rg -Fq 'SoundRouting.currentSinkFor(' "$application_row" \
|| fail 'the output picker does not say where the application is playing'
rg -Fq 'label: "System default"' "$application_row" \
|| fail 'following the system default is not an option anyone can pick'
# ── Dictation lives on its own page under Input ──────────────────────────────
# One page owns the dictation controls. Two would mean two setup buttons
# driving the same one-time install, and whichever one someone found second
@@ -108,12 +261,28 @@ rg -Fq 'ShellState.openSettings("sound")' "$dictation_page" \
! rg -Fq 'SoundDeviceList {' "$dictation_page" \
|| fail 'DictationPage grew its own device picker -- there is one input device, and two places to change it disagree'
# Native bindings are the supported path. Shelling out would race the service
# that owns these same objects and regress Quick Settings coherence.
if rg -q '\b(Process|pactl|wpctl)\b' "$audio_devices" "$device_list" "$device_row" "$balance"; then
# ── Native bindings are the supported path ───────────────────────────────────
# Shelling out here would race the service that owns these same objects and
# regress Quick Settings coherence.
if rg -q '\b(Process|pactl|wpctl|pw-metadata)\b' "${core_files[@]}"; then
fail 'Sound controls bypass the Quickshell PipeWire service'
fi
# The ban extends to every component the rebuilt page is made of. Five new ones
# landed with it, and the cheapest way for any of them to get something the
# PipeWire bindings do not expose is a Process nobody noticed.
for component in "${page_components[@]}"; do
if rg -q '\bProcess\b' "$component"; then
fail "${component#"$settings/"} shells out -- anything that does belongs in a sibling singleton beside SoundTest"
fi
done
# And the singletons that are allowed to shell out are the ones the spec names.
for service in "$sound_cards" "$sound_routing" "$sound_defaults"; do
rg -Fq 'pragma Singleton' "$service" \
|| fail "${service#"$services/"} is not a singleton, so the page would hold its own copy of it"
done
printf 'sound page static contract: PASS\n'
# Instantiate the complete page against the real, read-only PipeWire graph.
@@ -140,6 +309,26 @@ jq -e '.ready == true and .outputs > 0 and .inputs > 0
<<<"$status" >/dev/null \
|| fail "real PipeWire graph was not represented: $status"
# Capture grouping is the playback grouping applied to the other direction, so
# the same invariant holds: every node in a group is an input stream, and an
# application recording on three streams is one entry.
jq -e '.captureApplications >= 0 and .captureTypesValid == true' <<<"$status" >/dev/null \
|| fail "capture applications were grouped from the wrong stream type: $status"
jq -e '.captureRowVisible == (.captureApplications > 0)' <<<"$status" >/dev/null \
|| fail "the microphone row does not follow whether anything is listening: $status"
# The ghost row, against a configured sink this session cannot possibly have.
# The read is a subprocess, so it lands after the first status call.
for _ in $(seq 1 60); do
status="$(qs_for_harness ipc call sound-page-test status)"
[[ "$(jq -r .ghostVisible <<<"$status")" == "true" ]] && break
sleep 0.1
done
jq -e '.ghostVisible == true' <<<"$status" >/dev/null \
|| fail "a configured output that is not present did not produce a ghost row: $status"
jq -e '.ghostLabel == "Bluetooth device (AA:BB:CC:11:22:33)"' <<<"$status" >/dev/null \
|| fail "the ghost row is labelled with the raw node name: $status"
if rg -n 'ReferenceError|TypeError|Binding loop|Unable to assign|Cannot assign|PwObjectTracker' "$shell_log"; then
fail 'Sound page emitted a QML runtime warning'
fi