The Volume Keys That Worked All Along: A Sway Debugging Whodunit
My volume keys “stopped working” — and the keys turned out to be the one thing that wasn’t broken.
I thought the black-screen post was the end of the saga. It was the start of a hobby.
A few days ago I wrote up how I turned a black screen into a Sway desktop I actually love on my Dell XPS 13. This is the next chapter, and it has a moral I keep having to relearn: on Linux, when a control “does nothing,” the thing you’re pressing is rarely the thing that’s broken.
The complaint
Symptom · no sound, no barMy volume keys stopped working. Both kinds: the physical rocker on the side of the chassis and the Fn keys on the keyboard. Press them and… nothing. No sound change, no on-screen bar. Same dead silence either way.
The cruel irony — which I didn’t appreciate until later — is that this is the very same volume binding I bragged about taming in the last post. Remember this line, the one I called “the seatbelt I didn’t know I needed”?
bindsym XF86AudioRaiseVolume exec wpctl set-volume -l 1.0 @DEFAULT_AUDIO_SINK@ 5%+
That seatbelt was still buckled. The car just wasn’t connected to the road anymore. Hold that thought.
Step 1: are the keys even reaching Sway?
Bisect · did the keypress arrive?My first instinct, like last time, was “I’ve broken a keybinding.” But before ripping anything apart, there’s a 30-second test that splits the entire problem in two: make the binding announce itself. I chained a log line onto each volume key, alongside the real command:
bindsym XF86AudioRaiseVolume exec echo "$(date +%T) raise" >> /tmp/volkeys.log; wpctl set-volume -l 1.0 @DEFAULT_AUDIO_SINK@ 5%+ bindsym XF86AudioLowerVolume exec echo "$(date +%T) lower" >> /tmp/volkeys.log; wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%- bindsym XF86AudioMute exec echo "$(date +%T) mute" >> /tmp/volkeys.log; wpctl set-mute @DEFAULT_AUDIO_SINK@ toggle
Then swaymsg reload, mash the keys, and look:
cat /tmp/volkeys.log
13:56:45 raise 13:56:46 lower 13:56:53 mute
The log was full. Every press — rocker and keyboard alike — reached Sway and fired its binding perfectly. (Fun aside: the chassis rocker shows up to Sway as an Intel HID 5 button array device, separate from the keyboard. Both worked.)
So the keys were innocent. Whatever was wrong lived entirely downstream of the keypress. Just like the black workspace from last time, my first suspect was the one part that was behaving flawlessly.
Step 2: the command was shouting into a void
Diagnosis · two audio serversThe binding ran wpctl, which controls PipeWire. So I asked the obvious question I’d skipped: what’s actually driving my speakers right now?
pactl info | grep -i "server name"
# Server Name: pulseaudioThere it was. Plain, standalone PulseAudio owned the audio hardware — but my keys were politely asking PipeWire to change the volume. Both were installed and running at once:
pgrep -a pulseaudio # the real PulseAudio — holding the speakers hostage pgrep -a pipewire # also running pgrep -a wireplumber # also running
Every keypress changed a PipeWire sink that nobody could hear, while PulseAudio quietly held the actual output. The seatbelt binding from my last post was flawless; it was buckled into a car with no wheels. That’s why the same line that worked beautifully a few days ago did nothing now — somewhere between posts, the real pulseaudio package had crept back in and stolen the hardware out from under PipeWire.
This is exactly the kind of silent X11/Wayland-era leftover I warned past-me about last time, just wearing an audio costume.
The same line that worked beautifully a few days ago did nothing now — a seatbelt buckled into a car with no wheels.
The fix, part 1: one stack, not two
Fix · evict standalone PulseAudioFedora’s intended setup is PipeWire all the way down, with pipewire-pulseaudio providing the PulseAudio API as a shim. So I evicted the real daemon:
sudo dnf swap --allowerasing pulseaudio pipewire-pulseaudio systemctl --user enable --now pipewire-pulse.socket pipewire-pulse.service
One gotcha bit immediately: right after the swap, pactl failed with Connection refused, because pipewire-pulse was installed but not yet running. The systemctl --user enable --now line is what wakes it. Confirm with the server name you actually want to see:
pactl info | grep -i "server name"
# Server Name: PulseAudio (on PipeWire 1.4.11)That (on PipeWire) is the whole point: one stack, no tug-of-war over the hardware. Now wpctl and my keys command real sound.
The fix, part 2: a bar to see it
Build · an on-screen bar with wobThe other half of my complaint was that I never saw a volume indicator. That’s not a bug — Sway simply has no built-in OSD. (My waybar shows a volume number, but I wanted the big slidey bar that flashes when you press a key.)
The go-to tool for this is swayosd, but it isn’t packaged for Fedora 43. What is in the repos is wob — a delightfully tiny “Wayland Overlay Bar” that draws a bar from numbers you pipe into it.
sudo dnf install -y wob
wob reads integers (0–100) from stdin, one per line. The idiomatic wiring is a FIFO it tails. In my Sway config:
exec rm -f $XDG_RUNTIME_DIR/wob.sock && mkfifo $XDG_RUNTIME_DIR/wob.sock && tail -f $XDG_RUNTIME_DIR/wob.sock | wob
Then a little wrapper changes the volume and writes the new level to the FIFO. Mine lives at ~/.config/sway/volume-wob.sh:
#!/bin/sh # Adjust the default sink via wpctl (PipeWire-native) and push the resulting # level (0-100) to the wob FIFO so Sway shows an on-screen bar. export LC_ALL=C WOBSOCK="${XDG_RUNTIME_DIR}/wob.sock" SINK="@DEFAULT_AUDIO_SINK@" case "$1" in up) wpctl set-mute "$SINK" 0; wpctl set-volume -l 1.0 "$SINK" 5%+ ;; down) wpctl set-mute "$SINK" 0; wpctl set-volume "$SINK" 5%- ;; mute) wpctl set-mute "$SINK" toggle ;; esac vol=$(wpctl get-volume "$SINK" 2>/dev/null) # e.g. "Volume: 0.10" or "... [MUTED]" case "$vol" in *MUTED*) level=0 ;; *) level=$(printf '%s' "$vol" | awk '{printf "%d", $2 * 100}') ;; esac [ -z "$level" ] && level=0 [ "$level" -gt 100 ] && level=100 # timeout guards against a dead wob reader hanging the keypress (see gotcha #3) [ -p "$WOBSOCK" ] && timeout 1 sh -c "printf '%s\n' '$level' > '$WOBSOCK'"
And the bindings, finally pointing somewhere useful — note that the -l 1.0 seatbelt from the last post survives, right where it belongs:
bindsym XF86AudioRaiseVolume exec ~/.config/sway/volume-wob.sh up bindsym XF86AudioLowerVolume exec ~/.config/sway/volume-wob.sh down bindsym XF86AudioMute exec ~/.config/sway/volume-wob.sh mute bindsym XF86AudioMicMute exec wpctl set-mute @DEFAULT_AUDIO_SOURCE@ toggle
Press a key now and the bar slides up on screen while the volume actually moves. Both halves of the complaint, gone.

Three gotchas that drew blood
Gotchas · three that drew bloodLast time it was 3359% volume and a fullscreen video bridge. This time:
- wob dies on a single bad line. Feed it one empty string — say, when your volume parse silently fails — and it logs Invalid value received and exits. So the script defaults the level to a number and clamps it, always. Never pipe an empty line to wob.
- My locale tried to mute me. wpctl prints 0.10. Under my German locale, awk reads the comma as the decimal separator and turns 0.10 into a flat 0. Forcing LC_ALL=C in the script keeps the float honest. (Believe your status bar; distrust your locale.)
- Writing to a reader-less FIFO hangs forever. If wob isn’t running, printf > fifo blocks — and takes your keypress down with it. Wrapping the write in timeout 1 means a dead bar can’t freeze your volume keys.
And the Sway-specific footnote: exec runs at login, not on swaymsg reload. So the FIFO-and-wob line won’t restart on a reload; if the bar vanishes mid-session, log out and back in.
Believe your status bar; distrust your locale.
Side quest: stop locking me out while plugged in
Side quest · lock only on batteryWhile I was in here, one more annoyance: at my desk, plugged in, the screen kept locking on me after five minutes — a password prompt every time I turned to read something on the other monitor. That’s swayidle doing what I’d told it:
exec swayidle -w \
timeout 300 'swaylock -f -c 000000' \
timeout 600 'swaymsg "output * power off"' resume 'swaymsg "output * power on"' \
before-sleep 'swaylock -f -c 000000'Lock at five minutes, power the backlight off at ten. The backlight-off is fine — a mouse wiggle brings the panel back, no password. It’s the lock I wanted to relax while charging: keep the backlight off at ten minutes, but on AC don’t lock until a full hour. On battery the five-minute lock stays — that’s exactly when you want it locked fast.
swayidle can’t see the charger, so the power check has to live inside the command. I gated the early lock behind a tiny script and added a second, unconditional lock at the hour mark:
exec swayidle -w \
timeout 300 "$HOME/.config/sway/lock-idle.sh" \
timeout 600 'swaymsg "output * power off"' resume 'swaymsg "output * power on"' \
timeout 3600 'swaylock -f -c 000000' \
before-sleep 'swaylock -f -c 000000'lock-idle.sh locks only on battery; on AC it bows out and lets the 60-minute timeout do it. It’s five lines reading one file the kernel keeps current — /sys/class/power_supply/AC/online is 1 when the charger’s in:
#!/bin/sh # Called by swayidle at the short (5 min) timeout. On AC, skip locking and let # the 60-min timeout lock instead (the backlight still powers off at 10 min). # On battery, lock right away. for ac in /sys/class/power_supply/A{C,DP}*/online; do [ -r "$ac" ] && [ "$(cat "$ac")" = "1" ] && exit 0 # on AC: don't lock yet done swaylock -f -c 000000
Two notes: the glob A{C,DP}* covers laptops that name the adapter ADP1 instead of AC, and the backlight line is output * power off (Sway 1.11), not the older dpms off. Same login-not-reload caveat as the volume bar — swayidle won’t restart on swaymsg reload, so pkill swayidle and relaunch it.
What I’d tell past-me (volume edition)
- A dead control is usually a routing problem, not a key problem. A logging binding (echo >> file) takes thirty seconds and tells you instantly whether the keypress arrived. Mine always did.
- Ask who owns the hardware before you touch the keybinding. pactl info would have ended this in one line if I’d run it first. Two audio servers, one set of speakers — only one wins, and it wasn’t the one my keys were talking to.
- A binding that worked yesterday can be sabotaged by a package, not a typo. The line was identical to last week’s. The stack underneath it wasn’t.
- Pick tools that exist in your repos. swayosd is lovely and entirely absent from Fedora 43; wob is humble and right there. Humble-and-present beats elegant-and-imaginary.
- swayidle can’t see the world; hand it a script that can. Anything conditional — power state, the time of day — lives in the command swayidle runs, not in swayidle itself. The kernel already hands you the charger state in a one-line file; let a five-line referee read it and decide.
The black-screen post taught me that the calmest-looking part of the system is often the guilty one. The volume keys just proved it again — they sat there working perfectly while I nearly rewrote them, exactly like that flawless, faithful foot terminal hiding under a black rectangle. I’m starting to think this is the whole personality of running Sway: nothing does anything for you, so when something misbehaves, the fix is always real, always comprehensible, and almost never where you first point the finger.
Even my cleanup fought back
Postscript · the command that shot itselfOne last laugh. While tidying up stray test processes, I reached for the obvious one-liner to kill the old bar:
pkill -f 'tail -f .*wob.sock'
…and my shell died mid-command. Twice. It took me an embarrassingly long beat to see it: pkill -f matches against the entire command line of every process — including the very shell running my pkill, whose command line literally contained the string tail -f .*wob.sock. I’d written a command that hunts down and shoots itself. The Sway journey, it turns out, is fractal: even the cleanup has a black-workspace moment waiting inside it.