Build complete PipeWire sound settings

This commit is contained in:
Gabriel Brown
2026-08-18 05:54:14 -04:00
parent 81096e2d95
commit 3cfa592db9
9 changed files with 600 additions and 43 deletions
@@ -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)
}
@@ -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)
}
}
@@ -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
}
}
@@ -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
}
}
}
@@ -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)
}
}
}
@@ -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";
}
}
@@ -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()
}
@@ -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))
});
}
}
}