The Problem
A volume widget must show the current audio level, allow changing it, and toggle mute. It should also show the active audio sink (speakers vs headphones). Unlike other widgets, volume needs two-way communication: read the current level and write a new one.
The Naive Approach
Call pamixer --get-volume and pamixer --get-mute in a polling Timer. To set volume, call pamixer --set-volume <n>. This spawns subprocesses constantly — every slider drag would fork a process.
Timer {
interval: 1000
onTriggered: {
// "pamixer --get-volume" then parse stdout
// "pamixer --get-mute" for mute state
}
}
// On slider change:
// exec("pamixer", ["--set-volume", sliderValue.toString()])Volume control is like a stereo amplifier. The amplifier has a knob (input) and a display (output). A good volume widget connects both — the knob adjusts the amplifier, and the display shows the current level without lag.
The Idea
Quickshell provides PipeWire integration via the PulseAudio or PipeWire singleton. It exposes sinks, sources, volume levels, and mute state as reactive properties. Setting volume is a direct property assignment — no shell commands.
Let's Build It
A volume widget with mute toggle and slider:
import Quickshell
import Quickshell.Services.PipeWire
Row {
spacing: 8
property var sink: PipeWire.defaultSink
property real volume: sink ? sink.volume : 0
property bool muted: sink ? sink.muted : false
Text {
text: {
if (muted) return "ﱝ"
if (volume <= 0) return ""
if (volume < 0.5) return ""
return ""
}
font.pixelSize: 16
color: muted ? "#585b70" : "#cdd6f4"
MouseArea {
anchors.fill: parent
onClicked: if (sink) sink.muted = !sink.muted
}
}
Rectangle {
width: 80; height: 6
radius: 3
color: "#313244"
anchors.verticalCenter: parent.verticalCenter
Rectangle {
width: parent.width * volume
height: parent.height
radius: 3
color: "#89b4fa"
Behavior on width { SmoothedAnimation { duration: 100 } }
}
MouseArea {
anchors.fill: parent
onPressed: mouse => setVolume(mouse.x / width)
onPositionChanged: mouse => setVolume(mouse.x / width)
function setVolume(fraction) {
if (sink) sink.volume = Math.max(0, Math.min(1, fraction))
}
}
}
Text {
text: Math.round(volume * 100) + "%"
font.pixelSize: 12
color: "#a6adc8"
}
}Let's Improve It
Add per-app audio streams and a vertical slider variant:
Column {
spacing: 4
Repeater {
model: PipeWire.sinks
Row {
spacing: 6
Text {
text: modelData.name || "Sink " + index
font.pixelSize: 10
color: "#585b70"
width: 80
elide: Text.ElideRight
}
Slider {
from: 0; to: 1
value: modelData.volume
onMoved: modelData.volume = value
width: 80
height: 16
}
}
}
}Assuming a single sink. PipeWire can have multiple sinks (HDMI audio, Bluetooth headphones, built-in speakers). Always use PipeWire.defaultSink or iterate PipeWire.sinks. Never hardcode sink IDs.
Not clamping volume. Volume values range from 0.0 to 1.0 (or sometimes higher for max amplification). Always clamp slider input with Math.max(0, Math.min(1, fraction)) to avoid negative or overflow values.
Ignoring muted state. The volume slider should snap to zero position when muted but remember the previous level for unmuting. Store preMuteVolume when mute is activated and restore it on unmute.
Under the Hood
PipeWire's volume is a float from 0.0 to 1.0 (mapped to the underlying hardware's dB scale). When you set sink.volume, Quickshell calls the PipeWire API to update the channel volumes. The hardware responds immediately (milliseconds). The muted property maps to the SUSPEND flag on the PipeWire node.
The SmoothedAnimation on the bar width makes slider movements feel responsive even though we're clamping to 100ms animation. The mouse tracking in positionChanged fires at the mouse device's report rate (typically 125-1000Hz). Each move calls sink.volume = fraction, which hits PipeWire. For smooth dragging, PipeWriter handles up to hundreds of updates per second without glitching.
Exercises
- PipeWire singleton provides reactive volume and mute state
- Click icon to toggle mute, drag bar to set volume
- Clamp volume between 0.0 and 1.0
- Handle multiple sinks via defaultSink or iteration
- PreMuteVolume pattern preserves level across mute cycles