From 3cfa592db97f910131d907d2fef930e56116bddf Mon Sep 17 00:00:00 2001 From: Gabriel Brown Date: Tue, 18 Aug 2026 05:39:15 -0400 Subject: [PATCH] Build complete PipeWire sound settings --- .../modules/quicksettings/AudioDeviceList.qml | 20 +-- .../modules/settings/AudioBalance.qml | 72 ++++++++ .../modules/settings/SoundDeviceList.qml | 40 +++++ .../modules/settings/SoundDeviceRow.qml | 168 ++++++++++++++++++ .../quickshell/modules/settings/SoundPage.qml | 69 ++++--- .../dot/quickshell/services/AudioDevices.qml | 43 +++++ .../dot/quickshell/services/SoundFeedback.qml | 97 ++++++++++ config/dot/quickshell/sound-page-harness.qml | 34 ++++ tests/quickshell/sound-page-contract.sh | 100 +++++++++++ 9 files changed, 600 insertions(+), 43 deletions(-) create mode 100644 config/dot/quickshell/modules/settings/AudioBalance.qml create mode 100644 config/dot/quickshell/modules/settings/SoundDeviceList.qml create mode 100644 config/dot/quickshell/modules/settings/SoundDeviceRow.qml create mode 100644 config/dot/quickshell/services/AudioDevices.qml create mode 100644 config/dot/quickshell/services/SoundFeedback.qml create mode 100644 config/dot/quickshell/sound-page-harness.qml create mode 100755 tests/quickshell/sound-page-contract.sh diff --git a/config/dot/quickshell/modules/quicksettings/AudioDeviceList.qml b/config/dot/quickshell/modules/quicksettings/AudioDeviceList.qml index 1fe9c4e..8a7db06 100644 --- a/config/dot/quickshell/modules/quicksettings/AudioDeviceList.qml +++ b/config/dot/quickshell/modules/quicksettings/AudioDeviceList.qml @@ -6,6 +6,7 @@ import QtQuick import Quickshell import Quickshell.Services.Pipewire import qs.config +import qs.services Item { id: root @@ -16,23 +17,12 @@ Item { implicitHeight: list.implicitHeight - readonly property var nodes: { - return Pipewire.nodes.values.filter(n => { - if (n.isStream) - return false; - // Sources have to be filtered on the type flags: !isSink also - // matches video nodes (webcams show up here otherwise). - return root.output ? n.isSink : (n.type & PwNodeType.AudioSource) === PwNodeType.AudioSource; - }); - } + readonly property var nodes: root.output ? AudioDevices.outputs : AudioDevices.inputs - readonly property var current: root.output ? Pipewire.defaultAudioSink : Pipewire.defaultAudioSource + readonly property var current: AudioDevices.current(root.output) function select(node): void { - if (root.output) - Pipewire.preferredDefaultAudioSink = node; - else - Pipewire.preferredDefaultAudioSource = node; + AudioDevices.select(root.output, node) } ScrollColumn { @@ -52,7 +42,7 @@ Item { implicitHeight: 38 icon: root.output ? "audio-speakers-symbolic" : "audio-input-microphone-symbolic" iconFallback: "audio-card-symbolic" - label: nodeRow.modelData.description || nodeRow.modelData.nickname || nodeRow.modelData.name + label: AudioDevices.label(nodeRow.modelData) selected: nodeRow.modelData === root.current onClicked: root.select(nodeRow.modelData) } diff --git a/config/dot/quickshell/modules/settings/AudioBalance.qml b/config/dot/quickshell/modules/settings/AudioBalance.qml new file mode 100644 index 0000000..1458153 --- /dev/null +++ b/config/dot/quickshell/modules/settings/AudioBalance.qml @@ -0,0 +1,72 @@ +import QtQuick +import Quickshell +import Quickshell.Services.Pipewire +import qs.config +import qs.widgets + +SettingRow { + id: root + + property var node: null + + label: "Balance" + detail: "Adjust the left and right channels" + controlWidth: 270 + visible: root.available + divider: false + + PwObjectTracker { + objects: root.node ? [root.node] : [] + } + + function channelIndex(channel): int { + if (!root.node?.audio) + return -1; + const channels = root.node.audio.channels; + for (let index = 0; index < channels.length; index++) { + if (channels[index] === channel) + return index; + } + return -1; + } + + readonly property int leftIndex: root.channelIndex(PwAudioChannel.FrontLeft) + readonly property int rightIndex: root.channelIndex(PwAudioChannel.FrontRight) + readonly property bool available: root.node?.audio + && root.leftIndex >= 0 && root.rightIndex >= 0 + && root.node.audio.volumes.length > Math.max(root.leftIndex, root.rightIndex) + + readonly property real position: { + if (!root.available) + return 0.5; + const left = root.node.audio.volumes[root.leftIndex]; + const right = root.node.audio.volumes[root.rightIndex]; + const level = Math.max(left, right); + if (level <= 0.001) + return 0.5; + return right >= left ? 0.5 + (1 - left / level) * 0.5 + : 0.5 - (1 - right / level) * 0.5; + } + + function setBalance(value: real): void { + if (!root.available) + return; + const next = Array.from(root.node.audio.volumes); + const level = Math.max(next[root.leftIndex], next[root.rightIndex], 0.001); + if (value < 0.5) { + next[root.leftIndex] = level; + next[root.rightIndex] = level * value * 2; + } else { + next[root.leftIndex] = level * (1 - value) * 2; + next[root.rightIndex] = level; + } + root.node.audio.volumes = next; + } + + ValueSlider { + anchors.fill: parent + value: root.position + icon: "audio-speakers-symbolic" + onMoved: value => root.setBalance(value) + } +} diff --git a/config/dot/quickshell/modules/settings/SoundDeviceList.qml b/config/dot/quickshell/modules/settings/SoundDeviceList.qml new file mode 100644 index 0000000..388aa61 --- /dev/null +++ b/config/dot/quickshell/modules/settings/SoundDeviceList.qml @@ -0,0 +1,40 @@ +import QtQuick +import Quickshell.Services.Pipewire +import qs.config +import qs.services + +Column { + id: root + + property bool output: true + readonly property var nodes: AudioDevices.nodes(root.output) + readonly property var current: AudioDevices.current(root.output) + + width: parent ? parent.width : 620 + spacing: 8 + + Repeater { + model: root.nodes + + SoundDeviceRow { + required property var modelData + + width: root.width + node: modelData + output: root.output + selected: modelData === root.current + } + } + + Text { + width: parent.width + visible: root.nodes.length === 0 + text: Pipewire.ready ? "No audio devices found" : "Discovering audio devices…" + color: Theme.fgDim + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize + horizontalAlignment: Text.AlignHCenter + topPadding: 18 + bottomPadding: 18 + } +} diff --git a/config/dot/quickshell/modules/settings/SoundDeviceRow.qml b/config/dot/quickshell/modules/settings/SoundDeviceRow.qml new file mode 100644 index 0000000..2fc5cfa --- /dev/null +++ b/config/dot/quickshell/modules/settings/SoundDeviceRow.qml @@ -0,0 +1,168 @@ +import QtQuick +import Quickshell +import Quickshell.Services.Pipewire +import qs.config +import qs.widgets +import qs.services +import qs.modules.quicksettings + +Rectangle { + id: root + + required property var node + property bool output: true + property bool selected: false + + implicitHeight: root.output || !root.selected ? 88 : 105 + radius: Theme.cardRadius + color: root.selected ? Theme.alpha(Theme.accent, 0.09) : Theme.alpha(Theme.fg, 0.025) + border.width: 1 + border.color: root.selected ? Theme.alpha(Theme.accent, 0.34) : Theme.alpha(Theme.fg, 0.07) + + PwObjectTracker { + objects: root.node ? [root.node] : [] + } + + PwNodePeakMonitor { + id: inputPeak + node: root.node + enabled: !root.output && root.selected + } + + readonly property real volume: root.node?.audio?.volume ?? 0 + readonly property bool muted: root.node?.audio?.muted ?? false + + function iconName(): string { + if (!root.output) + return root.muted ? "microphone-sensitivity-muted-symbolic" : "audio-input-microphone-symbolic"; + if (root.muted || root.volume <= 0.001) + return "audio-volume-muted-symbolic"; + if (root.volume < 0.34) + return "audio-volume-low-symbolic"; + if (root.volume < 0.67) + return "audio-volume-medium-symbolic"; + return "audio-volume-high-symbolic"; + } + + Row { + id: heading + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.leftMargin: 12 + anchors.rightMargin: 10 + anchors.topMargin: 8 + height: 28 + spacing: 10 + + ThemedIcon { + anchors.verticalCenter: parent.verticalCenter + size: 20 + icon: root.output ? "audio-speakers-symbolic" : "audio-input-microphone-symbolic" + iconFallback: "audio-card-symbolic" + tint: root.selected ? Theme.accent : Theme.fg + } + + Column { + width: Math.max(0, parent.width - 20 - useButton.width - parent.spacing * 2) + anchors.verticalCenter: parent.verticalCenter + spacing: 1 + + Text { + width: parent.width + text: AudioDevices.label(root.node) + color: Theme.fg + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize + font.weight: root.selected ? Font.DemiBold : Font.Medium + elide: Text.ElideRight + } + + Text { + width: parent.width + visible: root.node.nickname && root.node.nickname !== AudioDevices.label(root.node) + text: root.node.nickname ?? "" + color: Theme.fgDim + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + elide: Text.ElideRight + } + } + + SettingsButton { + id: useButton + anchors.verticalCenter: parent.verticalCenter + width: root.selected ? 78 : 64 + text: root.selected ? "Default" : "Use" + enabled: !root.selected + onClicked: AudioDevices.select(root.output, root.node) + } + } + + IconButton { + id: muteButton + anchors.left: parent.left + anchors.leftMargin: 8 + anchors.top: heading.bottom + anchors.topMargin: 7 + size: 30 + iconSize: 17 + icon: root.iconName() + iconFallback: root.output ? "audio-volume-high-symbolic" : "audio-input-microphone-symbolic" + onClicked: { + if (root.node?.audio) + root.node.audio.muted = !root.node.audio.muted; + } + } + + ValueSlider { + id: volumeSlider + anchors.left: muteButton.right + anchors.leftMargin: 7 + anchors.right: volumeText.left + anchors.rightMargin: 10 + anchors.verticalCenter: muteButton.verticalCenter + value: root.muted ? 0 : root.volume + onMoved: value => { + if (!root.node?.audio) + return; + root.node.audio.muted = false; + root.node.audio.volume = value; + } + } + + Text { + id: volumeText + anchors.right: parent.right + anchors.rightMargin: 12 + anchors.verticalCenter: muteButton.verticalCenter + width: 38 + text: Math.round(root.volume * 100) + "%" + color: Theme.fgDim + font.family: Theme.fontMono + font.features: Theme.tabularFigures + font.pixelSize: Theme.fontSizeSmall + horizontalAlignment: Text.AlignRight + } + + Rectangle { + anchors.left: volumeSlider.left + anchors.right: volumeText.right + anchors.top: muteButton.bottom + anchors.topMargin: 5 + height: 5 + radius: height / 2 + visible: !root.output && root.selected + color: Theme.alpha(Theme.fg, 0.10) + + Rectangle { + anchors.left: parent.left + anchors.top: parent.top + anchors.bottom: parent.bottom + width: parent.width * Math.max(0, Math.min(1, inputPeak.peak)) + radius: parent.radius + color: inputPeak.peak > 0.88 ? Theme.danger : Theme.accentSecondary + + } + } +} diff --git a/config/dot/quickshell/modules/settings/SoundPage.qml b/config/dot/quickshell/modules/settings/SoundPage.qml index a850abe..3254ecc 100644 --- a/config/dot/quickshell/modules/settings/SoundPage.qml +++ b/config/dot/quickshell/modules/settings/SoundPage.qml @@ -1,8 +1,6 @@ import QtQuick -import Quickshell.Services.Pipewire import qs.config import qs.services -import qs.modules.quicksettings SettingsPage { title: "Sound" @@ -10,43 +8,58 @@ SettingsPage { SettingsCard { title: "Output" - subtitle: Pipewire.defaultAudioSink?.description ?? "No output device" + subtitle: AudioDevices.current(true)?.description ?? "No output device" - AudioSlider { - width: parent.width - node: Pipewire.defaultAudioSink - output: true - } - Rectangle { - width: parent.width - height: 1 - color: Theme.alpha(Theme.fg, 0.06) - } - AudioDeviceList { + SoundDeviceList { width: parent.width output: true - maxHeight: 190 + } + + AudioBalance { + width: parent.width + node: AudioDevices.current(true) } } SettingsCard { title: "Input" - subtitle: Pipewire.defaultAudioSource?.description ?? "No input device" + subtitle: AudioDevices.current(false)?.description ?? "No input device" - AudioSlider { - width: parent.width - node: Pipewire.defaultAudioSource - output: false - } - Rectangle { - width: parent.width - height: 1 - color: Theme.alpha(Theme.fg, 0.06) - } - AudioDeviceList { + SoundDeviceList { width: parent.width output: false - maxHeight: 160 + } + } + + SettingsCard { + title: "Sound feedback" + subtitle: "Use the same event preferences as GTK and GNOME applications." + + SettingRow { + label: "Event sounds" + detail: "Play alerts and interface event sounds" + controlWidth: 42 + + SettingsToggle { + anchors.fill: parent + checked: SoundFeedback.eventSounds + enabled: !SoundFeedback.busy + onToggled: checked => SoundFeedback.setEventSounds(checked) + } + } + + SettingRow { + label: "Input feedback" + detail: "Play sounds for supported typing and input events" + controlWidth: 42 + divider: false + + SettingsToggle { + anchors.fill: parent + checked: SoundFeedback.inputFeedback + enabled: !SoundFeedback.busy + onToggled: checked => SoundFeedback.setInputFeedback(checked) + } } } diff --git a/config/dot/quickshell/services/AudioDevices.qml b/config/dot/quickshell/services/AudioDevices.qml new file mode 100644 index 0000000..8c7851e --- /dev/null +++ b/config/dot/quickshell/services/AudioDevices.qml @@ -0,0 +1,43 @@ +pragma Singleton + +// Shared PipeWire device discovery and default selection. Quick Settings and +// Panama Settings intentionally use this same boundary so they cannot disagree +// about what counts as an input or which node should become the default. + +import Quickshell +import Quickshell.Services.Pipewire +import QtQuick + +Singleton { + id: root + + readonly property var outputs: Pipewire.nodes.values.filter(node => + !node.isStream && node.isSink) + + readonly property var inputs: Pipewire.nodes.values.filter(node => + !node.isStream + && (node.type & PwNodeType.AudioSource) === PwNodeType.AudioSource) + + function nodes(output: bool): var { + return output ? root.outputs : root.inputs; + } + + function current(output: bool): var { + return output ? Pipewire.defaultAudioSink : Pipewire.defaultAudioSource; + } + + function select(output: bool, node: var): void { + if (!node) + return; + if (output) + Pipewire.preferredDefaultAudioSink = node; + else + Pipewire.preferredDefaultAudioSource = node; + } + + function label(node: var): string { + if (!node) + return "Unknown device"; + return node.description || node.nickname || node.name || "Unknown device"; + } +} diff --git a/config/dot/quickshell/services/SoundFeedback.qml b/config/dot/quickshell/services/SoundFeedback.qml new file mode 100644 index 0000000..1a1f142 --- /dev/null +++ b/config/dot/quickshell/services/SoundFeedback.qml @@ -0,0 +1,97 @@ +pragma Singleton + +// GNOME and GTK applications already honour these desktop sound preferences. +// Panama controls the same durable keys so moving between sessions does not +// create two competing notions of whether event feedback is enabled. + +import Quickshell +import Quickshell.Io +import QtQuick + +Singleton { + id: root + + property bool eventSounds: true + property bool inputFeedback: false + property string lastError: "" + readonly property bool busy: eventRead.running || inputRead.running + || eventWrite.running || inputWrite.running + + function parsedBoolean(text: string, fallback: bool): bool { + const value = text.trim(); + if (value === "true") + return true; + if (value === "false") + return false; + return fallback; + } + + function refresh(): void { + if (!eventRead.running) + eventRead.running = true; + if (!inputRead.running) + inputRead.running = true; + } + + function setEventSounds(enabled: bool): void { + root.eventSounds = enabled; + eventWrite.command = ["gsettings", "set", "org.gnome.desktop.sound", "event-sounds", String(enabled)]; + eventWrite.running = true; + } + + function setInputFeedback(enabled: bool): void { + root.inputFeedback = enabled; + inputWrite.command = ["gsettings", "set", "org.gnome.desktop.sound", "input-feedback-sounds", String(enabled)]; + inputWrite.running = true; + } + + Process { + id: eventRead + command: ["gsettings", "get", "org.gnome.desktop.sound", "event-sounds"] + stdout: StdioCollector { + onStreamFinished: root.eventSounds = root.parsedBoolean(this.text, root.eventSounds) + } + onExited: (code, status) => { + if (code !== 0) + root.lastError = "Event sound preferences could not be read."; + } + } + + Process { + id: inputRead + command: ["gsettings", "get", "org.gnome.desktop.sound", "input-feedback-sounds"] + stdout: StdioCollector { + onStreamFinished: root.inputFeedback = root.parsedBoolean(this.text, root.inputFeedback) + } + onExited: (code, status) => { + if (code !== 0) + root.lastError = "Input feedback preferences could not be read."; + } + } + + Process { + id: eventWrite + onExited: (code, status) => { + if (code !== 0) { + root.lastError = "Event sound preferences could not be changed."; + root.refresh(); + } else { + root.lastError = ""; + } + } + } + + Process { + id: inputWrite + onExited: (code, status) => { + if (code !== 0) { + root.lastError = "Input feedback preferences could not be changed."; + root.refresh(); + } else { + root.lastError = ""; + } + } + } + + Component.onCompleted: root.refresh() +} diff --git a/config/dot/quickshell/sound-page-harness.qml b/config/dot/quickshell/sound-page-harness.qml new file mode 100644 index 0000000..a653197 --- /dev/null +++ b/config/dot/quickshell/sound-page-harness.qml @@ -0,0 +1,34 @@ +// Read-only contract harness for the Sound page. It instantiates every device +// row against the real PipeWire graph but exposes no mutating IPC methods. + +import Quickshell +import Quickshell.Io +import Quickshell.Services.Pipewire +import QtQuick +import qs.services +import qs.modules.settings + +ShellRoot { + SoundPage { + width: 760 + height: 900 + } + + PwObjectTracker { + objects: AudioDevices.outputs.concat(AudioDevices.inputs) + } + + IpcHandler { + target: "sound-page-test" + + function status(): string { + return JSON.stringify({ + ready: Pipewire.ready, + outputs: AudioDevices.outputs.length, + inputs: AudioDevices.inputs.length, + defaultOutput: AudioDevices.label(AudioDevices.current(true)), + defaultInput: AudioDevices.label(AudioDevices.current(false)) + }); + } + } +} diff --git a/tests/quickshell/sound-page-contract.sh b/tests/quickshell/sound-page-contract.sh new file mode 100755 index 0000000..4cd2a25 --- /dev/null +++ b/tests/quickshell/sound-page-contract.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env bash + +# The Sound page is a first-class PipeWire control surface, not a launcher for +# another settings app. This contract keeps the real device plumbing shared +# with Quick Settings and verifies the controls that must remain available. + +set -euo pipefail + +repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +sound_page="$repo_dir/config/dot/quickshell/modules/settings/SoundPage.qml" +device_list="$repo_dir/config/dot/quickshell/modules/settings/SoundDeviceList.qml" +device_row="$repo_dir/config/dot/quickshell/modules/settings/SoundDeviceRow.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" +quick_devices="$repo_dir/config/dot/quickshell/modules/quicksettings/AudioDeviceList.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)" + +fail() { + printf 'sound page contract: %s\n' "$1" >&2 + exit 1 +} + +qs_for_harness() { + XDG_CONFIG_HOME="$config_home" XDG_STATE_HOME="$state_home" qs -p "$harness" "$@" +} + +cleanup() { + qs_for_harness kill >/dev/null 2>&1 || true + rm -rf "$config_home" "$state_home" +} +trap cleanup EXIT + +for file in "$sound_page" "$device_list" "$device_row" "$balance" "$audio_devices" "$sound_feedback" "$quick_devices"; do + [[ -f "$file" ]] || fail "missing ${file#"$repo_dir/"}" +done + +# One shared source of truth owns device discovery and default selection. +rg -Fq 'pragma Singleton' "$audio_devices" || fail 'audio device service is not a singleton' +rg -Fq 'Singleton {' "$audio_devices" || fail 'audio device service has no singleton root' +rg -Fq 'PwNodeType.AudioSource' "$audio_devices" || fail 'audio sources are not filtered by PipeWire type' +rg -Fq 'Pipewire.preferredDefaultAudioSink = node;' "$audio_devices" || fail 'output selection does not reach PipeWire' +rg -Fq 'Pipewire.preferredDefaultAudioSource = node;' "$audio_devices" || fail 'input selection does not reach PipeWire' +rg -Fq 'AudioDevices.outputs' "$quick_devices" || fail 'Quick Settings does not share output discovery' +rg -Fq 'AudioDevices.inputs' "$quick_devices" || fail 'Quick Settings does not share input discovery' +rg -Fq 'AudioDevices.select(root.output, node)' "$quick_devices" || fail 'Quick Settings does not share device selection' + +# Every hardware row binds its node before reading/writing audio state. +rg -Fq 'PwObjectTracker {' "$device_row" || fail 'device rows do not bind PipeWire objects' +rg -Fq 'root.node.audio.muted = !root.node.audio.muted;' "$device_row" || fail 'device mute is not writable' +rg -Fq 'root.node.audio.volume = value;' "$device_row" || fail 'per-device volume is not writable' +rg -Fq 'PwNodePeakMonitor {' "$device_row" || fail 'input rows have no level monitor' +rg -Fq 'enabled: !root.output && root.selected' "$device_row" || fail 'input monitoring is not scoped to the selected source' + +# Stereo hardware gets a real channel balance control. +rg -Fq 'PwAudioChannel.FrontLeft' "$balance" || fail 'balance does not identify the left channel' +rg -Fq 'PwAudioChannel.FrontRight' "$balance" || fail 'balance does not identify the right channel' +rg -Fq 'root.node.audio.volumes = next;' "$balance" || fail 'balance does not write per-channel volume' + +[[ "$(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 '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' + +# 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 + fail 'Sound controls bypass the Quickshell PipeWire service' +fi + +printf 'sound page static contract: PASS\n' + +# Instantiate the complete page against the real, read-only PipeWire graph. +# Merely constructing these controls must never change a default or volume. +qs_for_harness --daemonize >/dev/null +for _ in $(seq 1 60); do + qs_for_harness ipc show 2>/dev/null | rg -q '^target sound-page-test$' && break + sleep 0.1 +done +qs_for_harness ipc show 2>/dev/null | rg -q '^target sound-page-test$' \ + || fail 'Sound page harness did not start' + +for _ in $(seq 1 60); do + status="$(qs_for_harness ipc call sound-page-test status)" + [[ "$(jq -r .ready <<<"$status")" == "true" ]] && break + sleep 0.1 +done +jq -e '.ready == true and .outputs > 0 and .inputs > 0 + and (.defaultOutput | length > 0) and (.defaultInput | length > 0)' \ + <<<"$status" >/dev/null \ + || fail "real PipeWire graph was not represented: $status" + +trap - EXIT +cleanup +printf 'sound page runtime: PASS (%s outputs, %s inputs)\n' \ + "$(jq -r .outputs <<<"$status")" "$(jq -r .inputs <<<"$status")"