# Notification sounds A sound per urgency for notification balloons, chosen on the notifications settings page (`docs/superpowers/specs/2026-10-19-notification-settings-design.md`), honouring the sound hints an app sends. Today nothing plays a sound. ## Config Two keys join `~/.config/notifyd/config.json`: ```json { "sounds": { "low": "", "normal": "", "critical": "" }, "soundTheme": "freedesktop" } ``` - `sounds.`: an absolute file path, or `""` for silent. The defaults are all silent, so nothing changes until a sound is picked. A path that is not a string reads as `""`. - `soundTheme`: the freedesktop sound theme an app's `sound-name` hint is looked up in. A non-string or empty value reads as `freedesktop`. `soundTheme` is read by the daemon and mirrored to gsettings by the page (see Settings page); `sounds` is the balloon shell's. Both are resolved in `shared/NotifyConfig.js` for the page; notifyd's `Config` gains `SoundTheme`. ## notifyd: resolving app hints Three freedesktop hints, read in `Notify`: - `suppress-sound` (boolean): the app wants no sound, usually because it plays its own. - `sound-file` (string): a path to play. - `sound-name` (string): a name from the sound naming spec, e.g. `message-new-instant`. Two fields join `Popup` and so `queue.json` and `history.json`: - `sound` (string): the resolved file, `""` when the app sent no usable hint. `sound-file` wins over `sound-name`, as the spec orders them. A `sound-file` is used only if it is an absolute path to an existing regular file; a relative path or a missing file reads as no hint, logged. - `silent` (bool): `suppress-sound` was true. `sound-name` lookup, in `internal/notify/sounds.go`, mirrors the icon lookup in `icons.go` (same XDG roots, `iniValue`, `fileExists`): 1. For the configured theme, then each theme in its `Inherits=`, then `freedesktop`: for each XDG data root (`$XDG_DATA_HOME`, then `$XDG_DATA_DIRS`), try `sounds//stereo/.{oga,ogg,wav}`. 2. The name is also tried with its last `-` component dropped, repeatedly, as the naming spec's fallback (`message-new-instant` then `message-new` then `message`). 3. No match is `""`. Cycles in `Inherits` are cut by a visited set. The theme is read from the current `Config` under the service lock, like the timeouts. `sounds_test.go` builds a temporary XDG tree and covers: a direct hit, an inherited hit, the freedesktop fallback, the name fallback, extension order, a miss, `sound-file` winning over `sound-name`, a relative or missing `sound-file` rejected, and `suppress-sound`. ## Balloon shell: playing `notifications/Balloons.qml` plays a sound when a notification first appears or is replaced, keyed by `id` + `created` (a replace keeps the id and changes `created`). It plays only when the balloon would show at that moment: not while the drawer is open, not while snoozed, and not when `NotifyConfig.withheld` holds it back (mute, DND). The same checks that decide the balloon decide the sound. Which sound, in `NotifyConfig.soundFor(cfg, n)`, tested under node: 1. `n.silent`: none. 2. `n.sound` non-empty: the app's sound. 3. otherwise `cfg.sounds[n.urgency]`, `""` meaning none. The player is `pw-play ` in a single `Process`. A new sound stops one still playing (`running = false` then `true`), so a burst plays one sound rather than a chord. Entries already in the queue on the shell's first read are marked seen without playing, so a shell restart is silent. ## Settings page A "Sounds" section after "Timeouts", one row per urgency: - the chosen file's base name, or "none"; - **Choose…**: a `Qt.labs.platform` `FileDialog` (it takes the kvantum style in this QApplication, AGENTS.md), opening in `~/.local/share/sounds`, filtered to `*.oga *.ogg *.wav`; - **▶**: previews the file with `pw-play`, unsaved draft included; - **✕**: back to none. Below the rows, a theme chooser for app `sound-name` hints: one button per directory under `~/.local/share/sounds` and `/usr/share/sounds` that holds an `index.theme`, listed with `find` once when the page opens. The current `soundTheme` is highlighted. The theme is the desktop's too. Save sets `soundTheme` in the file and `gsettings set org.gnome.desktop.sound theme-name `, so libcanberra and GTK event sounds use the same theme. notifyd still reads only the file: no daemon dependency on gsettings. The page also reads gsettings, with `gsettings get` each time the module refreshes (every settings window open) and after each save. When the system theme differs from `soundTheme`, a yellow line under the chooser says so, "System sound theme is , changed outside this page", with a **Use ** button that puts it in the draft. Nothing is changed silently in either direction. A missing `gsettings` or schema reads as no system theme and the line stays hidden. Saving otherwise works as before: the same draft, the file write, then `notifyctl reload`. The save runs the gsettings write between the two: a failure there is reported ("saved; the system sound theme could not be set") and does not stop the reload. ## Out of scope - Volume: sounds play at the sink's volume. - Per-app sounds. - The HDMI sink clipping the first moment of a sound: a PipeWire `dither.noise` change in the WirePlumber config, handled separately. ## Docs notifyd `README.md` (the two hints, the fields, `soundTheme`), the module and balloon READMEs, and AGENTS.md if a trap turns up. ## Verification - `go test ./...` in notifyd; `node shared/test_notify_config.js`. - With a normal sound set: `notify-send a b` plays it; a muted app, DND on a normal notification, and snooze play nothing. - `notify-send -h string:sound-name:message-new-instant x y` plays the theme's sound; `-h boolean:suppress-sound:true` plays nothing. - A shell restart with notifications in the queue plays nothing.