Notification sounds for GNOME and KDE
A ~225 LOC program for making Gnome’s and KDE’s notifications audible by monitoring DBus for common notification pathways and playing a sound when a message matches a certain pattern (Updated 2025-01-10).
Usage
Should work out of the box without needing to install any additional dependencies (tested on CentOS 7 and Fedora 29-33):
./gaudible.py --filter firefox --filter calendar --filter calendar-legacy
Source (@dbazile/gaudible)
Updated 2025-01-10: Fixed issue with gaudible not sounding off for notifications in KDE.
Updated 2021-02-27: Added support for playing specific sounds for specific notification sources and to honor “Do Not Disturb” mode.
https://gitlab.com/dbazile/gaudible/compare/779ab929a173653013f62a81a932e39bfd168320…master
#!/usr/bin/env python3
import argparse
import logging
import os
import queue
import re
import subprocess
import sys
import threading
import time
from dbus import SessionBus
from dbus.mainloop.glib import DBusGMainLoop
from gi.repository.GLib import MainLoop
DEFAULT_PLAYER = '/usr/bin/pw-play'
DEFAULT_SOUND = '/usr/share/sounds/freedesktop/stereo/bell.oga'
DEFAULT_RATE_MS = 1000
DESKTOP_ENV = os.getenv('XDG_SESSION_DESKTOP', '?').lower()
FILTERS = {
'evolution': ('org.gtk.Notifications', 'AddNotification', 'org.gnome.Evolution-alarm-notify'),
'evolution-legacy': ('org.freedesktop.Notifications', 'Notify', 'Evolution Reminders'),
'firefox': ('org.freedesktop.Notifications', 'Notify', 'Firefox'),
'librewolf': ('org.freedesktop.Notifications', 'Notify', 'LibreWolf'),
'notify-send': ('org.freedesktop.Notifications', 'Notify', 'notify-send'),
'chrome': ('org.freedesktop.Notifications', 'Notify', 'Google Chrome'),
}
LOG = logging.getLogger()
def main():
ap = argparse.ArgumentParser()
ap.add_argument('-v', '--verbose', action='count', default=0, help='controls amount of log output (repeat for more verbosity)')
ap.add_argument('--sound', action='append', default=[], help='registers a sound for a specific filter with format `<filter-name>:<file-path>` or use format `<file-path>` for every subscribed event')
ap.add_argument('--filter', action='append', default=[], choices=sorted(FILTERS.keys()))
ap.add_argument('--player', default=DEFAULT_PLAYER)
params = ap.parse_args()
logging.basicConfig(
datefmt='%H:%M:%S',
format='%(asctime)s [%(levelname)5s] [%(funcName)s] %(message)s',
level=logging.WARNING - (params.verbose * 10),
stream=sys.stdout,
)
if not os.access(params.player, os.R_OK | os.X_OK):
ap.error('player %r does not exist or is not executable' % (params.player,))
try:
sounds = to_sound_registry(params.sound)
except ValueError as e:
ap.error(f'sound spec parsing failed: {e}')
return
audio_player = AudioPlayer(params.player, sounds, DEFAULT_RATE_MS)
LOG.debug('desktop: %s', DESKTOP_ENV)
for interface, member, origin, args in dbus_monitor(params.filter):
for filter_key, (filter_interface, filter_member, filter_origin) in FILTERS.items():
if not (filter_interface == interface and filter_member == member and filter_origin == origin):
continue
if is_dnd():
LOG.debug('SUPPRESS: \033[33m%s \033[2m%s %s %s\033[0m', filter_key, interface, member, origin)
continue
LOG.info('RECEIVE: \033[32m%s \033[2m%s %s %s\033[0m', filter_key, interface, member, origin)
audio_player.play(filter_key)
def dbus_monitor(filters):
"""
:type filters: list[str]
:yields: interface, path, member, origin
"""
# Start GLib babysitting
DBusGMainLoop(set_as_default=True)
# Prepare session bus monitoring
bus = SessionBus()
proxy = bus.get_object('org.freedesktop.DBus', '/org/freedesktop/DBus')
proxy.BecomeMonitor(to_rules(filters), 0, dbus_interface='org.freedesktop.DBus.Monitoring')
# Hide callback/loop API so we don't force client code to babysit GLib things
msg_queue = queue.Queue()
bus.add_message_filter(lambda _, m: msg_queue.put_nowait(m))
loop = MainLoop()
threading.Thread(target=loop.run, daemon=True).start()
try:
while message := msg_queue.get(True):
interface = message.get_interface()
method = message.get_member()
args = message.get_args_list()
origin = str(args[0])
if LOG.isEnabledFor(logging.DEBUG):
LOG.debug('\033[%dm----> %s \033[2mserial=%s sender=%s dest=%s interface=%s member=%s args=%s\033[0m',
94 if message.get_type() == 1 else 37,
['method call', 'method return', 'error', 'signal'][message.get_type() - 1],
message.get_serial(),
message.get_sender(),
message.get_destination(),
interface,
method,
re.sub(r'((, )?dbus\.Byte\(\d+\))+', '<blob>', str(args)), # keep blobs from blowing up debug output
)
yield interface, method, origin, args
except KeyboardInterrupt:
loop.quit()
def is_dnd():
if DESKTOP_ENV == 'gnome':
return subprocess.check_output(['/bin/gsettings', 'get', 'org.gnome.desktop.notifications', 'show-banners'], text=True, stderr=subprocess.PIPE).strip() == 'false'
elif DESKTOP_ENV == 'kde':
return subprocess.check_output(['/bin/kreadconfig6', '--file', 'plasmanotifyrc', '--group', 'DoNotDisturb', '--key', 'NotificationSoundsMuted'], text=True, stderr=subprocess.PIPE).strip() == 'true'
return False
def to_rules(filters):
"""
:type filters: list[str]
:rtype: list[str]
"""
if not filters:
filters = FILTERS.keys()
rules = set()
for k in sorted(filters):
interface, member, origin = FILTERS[k]
rule = f'type=method_call,interface={interface},member={member},arg0={origin}'
# GNOME forwards messages from org.freedesktop.Notifications to
# org.gtk.Notifications, probably for backwards compatibility. This
# rule mod prevents echoes by selecting only one of them, BUT THIS
# MEANS WE ARE ACTUALLY TAKING THE ECHO, NOT THE ORIGINAL. DBus rule
# match syntax doesn't seem to support negation so we have no
# choice.
#
# GNOME will probably stop doing this at some point which will make
# notifications disappear from GNOME's dbus stream.
if DESKTOP_ENV == 'gnome' and interface == 'org.freedesktop.Notifications':
rule += f',sender={interface}'
LOG.info('subscribe: \033[96m%s\033[0m \033[2m%s\033[0m \033[2m%r\033[0m', k, rule, origin)
rules.add(rule)
return list(sorted(set(rules)))
def to_sound_registry(specs):
"""
:type specs: list[str]
:rtype: dict[str, str]
"""
LOG.debug('sound specs: %s', specs)
sounds = {'*': DEFAULT_SOUND}
for spec in specs:
spec = spec.strip()
if m := re.match(r'^(?P<name>[\w\-]+):(?P<path>.*)$', spec):
key = m['name'].lower().strip()
value = m['path'].strip()
if key not in FILTERS:
raise ValueError(f'unknown filter {key!r} in sound spec {spec!r}; filters are: {",".join(sorted(FILTERS.keys()))}')
else:
key = '*'
value = spec
if not os.access(value, os.R_OK):
raise ValueError(f'audio file {value!r} cannot be read in sound spec {spec!r}')
sounds[key] = value
LOG.debug('sound registry: %s', sounds)
return sounds
class AudioPlayer:
def __init__(self, player, files, rate_ms):
self._player = player
self._files = files # type: dict
self._rate_ms = max(0.01, rate_ms) / 1000
self._quiet_until = -1
def play(self, name):
if self._enforce_rate_limit():
LOG.debug('in quiet period (%.3fs remain)', self._quiet_until - time.time())
return
cmd = [self._player, self._files.get(name, self._files.get('*'))]
LOG.debug('exec: %s', ' '.join(cmd))
t = threading.Thread(target=subprocess.check_call, args=[cmd])
t.start()
return t
def _enforce_rate_limit(self):
now = time.time()
if now <= self._quiet_until:
return True
self._quiet_until = now + self._rate_ms
return False
if __name__ == '__main__':
exit(main())
Background
My motivation for writing this is because Evolution’s calendar reminders don’t play sound on a global level. Unless I stare at my computer all day (impossible when I’m reading physical documents), it’s really easy to miss the silent appointment reminder popups. The only option Evolution has to play sound for an appointment is to manually add a sound for every appointment one by one which I’m sure there’s a good reason for… Probably. 😶
I believe work is being done in lower-level components of Gnome to support better customization around notifications but until that lands, I’m using this to make sure my calendar reminders actually work properly.
Known Shortcomings
I kept this program dirt-simple to make it easy to visually inspect for folks who are as security-paranoid as I am. There are improvements I’d make otherwise, specifically:
- ✅
Playing a different sound per notification origin (e.g., Firefox, Evolution, etc)(implemented 2021-02-27) - Using Python 3 (would break out-of-the-box compatibility between Fedora and CentOS 7)
- Using GSound instead of
spawning a new
paplay
process each time - Throttling “noisy” origins (e.g., instant messaging webapps)
Other Notes
There is a way to send “hints” to the notifications subsystem that will actually play a sound without the need for manual wizardry as what I’ve done, ala:
notify-send --hint=string:sound-name:alarm-clock-elapsed test
…but the sound name would need to exist in the freedesktop theme spec and Evolution would need to send that hint along with its usual notification message to DBus.
I should submit an Evolution PR for that. If only I knew C… 🤔