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… 🤔

loading blog data