Skip to content

Instantly share code, notes, and snippets.

@say4n
Last active August 22, 2026 18:31
Show Gist options
  • Select an option

  • Save say4n/fe67c39cd05fcbc749fe5e3e4833fb74 to your computer and use it in GitHub Desktop.

Select an option

Save say4n/fe67c39cd05fcbc749fe5e3e4833fb74 to your computer and use it in GitHub Desktop.
KDE Plasma: mirror media-key sink volume to monitor speaker gain over DDC/CI (Plasma 6 / Wayland)

media keys that actually control my monitor speakers

Setup: Dell S3221QS over DisplayPort, audio goes to the monitor from the GPU. KDE's volume keys only touched the PipeWire sink, which is digital attenuation applied before the signal ever leaves the PC. The monitor's own amp gain (DDC/CI VCP 0x62) sat wherever the OSD left it, so pressing volume down did way less than expected.

This daemon listens for kmix's media key events on D-Bus and copies the sink level to the monitor via ddcutil after every press. Plasma keeps ownership of the keys, so the OSD popup and applet slider work like normal -- I did try stealing the bindings outright and fighting kglobalaccel over them, and it loses that fight every time plasmashell restarts. Riding along on the signals is the only approach that survived a relogin.

Works fine on Plasma 6.7 Wayland, should be fine anywhere kmix + PipeWire + ddcutil exist.

files

  • monitor-volume-daemon.py -> ~/.local/bin/, chmod +x
  • monitor-volume.service -> ~/.config/systemd/user/
  • monitor-volume -> ~/.local/bin/, chmod +x (standalone up/down/mute if you ever need it outside the daemon)

needs ddcutil, pactl, and python3-gi.

systemctl --user daemon-reload
systemctl --user enable --now monitor-volume.service
journalctl --user -u monitor-volume.service -f   # watch it sync

If your monitor isn't ddcutil display 1, edit DISPLAY_NUM in the daemon.

how it behaves

Every press waits ~350ms first, because plasma-pa applies its own change async and reading the sink any earlier mirrors the previous value (learned that one the hard way: mute set the monitor to 50 and unmute to 0). Rapid keypresses get debounced since a ddcutil write takes about a second anyway. Mute maps to VCP volume 0, unmute restores whatever the sink says.

uninstall

systemctl --user disable --now monitor-volume.service
rm ~/.local/bin/monitor-volume{,-daemon.py} ~/.config/systemd/user/monitor-volume.service
#!/usr/bin/env bash
# Control Dell monitor speaker volume over DDC/CI, mimicking normal media-key behavior.
# Usage: monitor-volume {up|down|mute}
set -euo pipefail
DISPLAY_NUM="1" # ddcutil display selector
STEP=5
STATE_DIR="${XDG_STATE_HOME:-$HOME/.local/state}"
PREV_FILE="$STATE_DIR/monitor-volume-prev"
ddc() {
ddcutil --display "$DISPLAY_NUM" --sleep-multiplier 0.1 "$@"
}
get_vol() {
# Parse "current value = 25" out of getvcp output
ddc getvcp 62 | grep -oP 'current value\s*=\s*\K[0-9]+'
}
set_vol() {
ddc setvcp 62 "$1" >/dev/null
}
show_osd() {
qdbus6 org.kde.plasmashell /org/kde/osdService volumeChanged "$(get_vol)" 100 >/dev/null 2>&1 || true
}
mkdir -p "$STATE_DIR"
case "${1:-}" in
up)
vol=$(get_vol)
new=$((vol + STEP))
((new > 100)) && new=100
set_vol "$new"
;;
down)
vol=$(get_vol)
new=$((vol - STEP))
((new < 0)) && new=0
set_vol "$new"
;;
mute)
vol=$(get_vol)
if ((vol > 0)); then
echo "$vol" > "$PREV_FILE"
set_vol 0
else
prev=$(cat "$PREV_FILE" 2>/dev/null || echo 50)
set_vol "$prev"
fi
;;
*)
echo "usage: monitor-volume {up|down|mute}" >&2
exit 1
;;
esac
show_osd
#!/usr/bin/env python3
"""
monitor-volume-daemon: keeps a monitor's speaker gain (DDC/CI VCP 0x62)
in sync with PipeWire's default sink volume.
Plasma's audio applet ("kmix") owns the XF86Audio media keys; this daemon
watches two things and copies whatever the sink level ends up being over to
the monitor with ddcutil:
- kmix globalShortcutPressed signals (keyboard volume keys)
- `pactl subscribe` events (slider drags, applet, anything else that
changes the default sink)
Both paths funnel into one debounced apply, so dragging the slider produces
a single ddcutil write instead of one per pixel.
"""
import re
import subprocess
import sys
from gi.repository import Gio, GLib
KGLOBALACCEL_NAME = "org.kde.kglobalaccel"
KMIX_PATH = "/component/kmix"
COMPONENT_IFACE = "org.kde.kglobalaccel.Component"
KMIX_ACTIONS = {"increase_volume", "decrease_volume", "mute"}
DDC_ARGS = ["--display", "1", "--sleep-multiplier", "0.1"]
VCP_VOLUME = "62"
# plasma-pa applies its own volume change asynchronously after a keypress;
# reading the sink too early mirrors the *previous* level. This delay also
# coalesces slider drags into a single ddcutil write.
SYNC_DELAY_MS = 350
state = {"pending": 0}
def pactl(*args):
return subprocess.run(
["pactl", *args], capture_output=True, text=True
).stdout
def sink_volume():
"""Default sink volume in percent (first channel), or None."""
m = re.search(r"/\s+(\d+)%", pactl("get-sink-volume", "@DEFAULT_SINK@"))
return int(m.group(1)) if m else None
def sink_muted():
return "yes" in pactl("get-sink-mute", "@DEFAULT_SINK@")
def apply_to_monitor():
vol = 0 if sink_muted() else sink_volume()
if vol is None:
return
vol = max(0, min(100, vol))
try:
subprocess.run(
["ddcutil", *DDC_ARGS, "setvcp", VCP_VOLUME, str(vol)],
capture_output=True,
timeout=10,
)
print(f"synced monitor volume to {vol}", flush=True)
except Exception as e:
print(f"ddcutil failed: {e}", file=sys.stderr)
def schedule_apply():
"""Apply once things settle; only the latest request in the window runs."""
state["pending"] += 1
token = state["pending"]
def do_apply():
if state["pending"] == token:
apply_to_monitor()
return False # don't repeat the timeout
GLib.timeout_add(SYNC_DELAY_MS, do_apply)
def start_pactl_watch():
"""Watch `pactl subscribe` for default-sink relevant change events."""
proc = subprocess.Popen(["pactl", "subscribe"], stdout=subprocess.PIPE)
def on_data(_fd, _cond):
line = proc.stdout.readline().decode(errors="replace")
# e.g.: Event 'change' on sink #71
if "'change'" in line and "on sink" in line:
schedule_apply()
return True
GLib.io_add_watch(proc.stdout.fileno(), GLib.IO_IN, on_data)
def on_shortcut_pressed(conn, sender, path, iface, signal, params):
component, shortcut, _timestamp = params.unpack()
if component == "kmix" and shortcut in KMIX_ACTIONS:
schedule_apply()
def main():
bus = Gio.bus_get_sync(Gio.BusType.SESSION, None)
bus.signal_subscribe(
KGLOBALACCEL_NAME,
COMPONENT_IFACE,
"globalShortcutPressed",
KMIX_PATH,
None,
Gio.DBusSignalFlags.NONE,
on_shortcut_pressed,
)
start_pactl_watch()
print("monitor-volume-daemon: mirroring sink volume to DDC/CI", flush=True)
# bring the monitor in line with wherever the sink is right now
schedule_apply()
loop = GLib.MainLoop()
try:
loop.run()
except KeyboardInterrupt:
pass
if __name__ == "__main__":
main()
[Unit]
Description=Mirror PipeWire sink volume to monitor speaker gain over DDC/CI on media-key presses
After=graphical-session.target
PartOf=graphical-session.target
[Service]
Type=simple
ExecStart=/usr/bin/python3 %h/.local/bin/monitor-volume-daemon.py
Restart=on-failure
RestartSec=3
[Install]
WantedBy=graphical-session.target
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment