All posts

The Volume Keys That Worked All Along: A Sway Debugging Whodunit

Sway · Dell XPS 13 · Fedora 43

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.

XF86AudioRaiseVolume · press → nothing · no sound, no bar

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.


00Log · The complaint

The complaint

Symptom · no sound, no bar

My 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”?

~/.config/sway/config
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.

01Log · Bisect

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:

~/.config/sway/config
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:

shell
cat /tmp/volkeys.log
/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.

02Log · Diagnosis

Step 2: the command was shouting into a void

Diagnosis · two audio servers

The binding ran wpctl, which controls PipeWire. So I asked the obvious question I’d skipped: what’s actually driving my speakers right now?

shell
pactl info | grep -i "server name"
# Server Name: pulseaudio

There 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:

shell
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.

03Log · Fix 1/2

The fix, part 1: one stack, not two

Fix · evict standalone PulseAudio

Fedora’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:

shell
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:

shell
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.

04Log · Fix 2/2

The fix, part 2: a bar to see it

Build · an on-screen bar with wob

The 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.

shell
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:

~/.config/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:

~/.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:

~/.config/sway/config
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.

The wob volume bar sliding up on screen as the key is pressed
The wob volume bar sliding up on screen as the key is pressed
05Log · Gotchas

Three gotchas that drew blood

Gotchas · three that drew blood

Last time it was 3359% volume and a fullscreen video bridge. This time:

  1. 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.
  2. 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.)
  3. 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.

06Log · Side quest

Side quest: stop locking me out while plugged in

Side quest · lock only on battery

While 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:

~/.config/sway/config
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:

~/.config/sway/config
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:

~/.config/sway/lock-idle.sh
#!/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.

07Field notes

What I’d tell past-me (volume edition)

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.

08Postscript

Even my cleanup fought back

Postscript · the command that shot itself

One last laugh. While tidying up stray test processes, I reached for the obvious one-liner to kill the old bar:

shell
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.

Comments (0)

No comments yet. Be the first!

Write a Comment