diff options
Diffstat (limited to 'docs/superpowers')
| -rw-r--r-- | docs/superpowers/specs/2026-10-19-notification-sounds-design.md | 123 |
1 files changed, 123 insertions, 0 deletions
diff --git a/docs/superpowers/specs/2026-10-19-notification-sounds-design.md b/docs/superpowers/specs/2026-10-19-notification-sounds-design.md new file mode 100644 index 0000000..857ca4d --- /dev/null +++ b/docs/superpowers/specs/2026-10-19-notification-sounds-design.md @@ -0,0 +1,123 @@ +# 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.<urgency>`: 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 the daemon's; `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/<theme>/stereo/<name>.{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 <file>` 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. + +Saving and reloading are unchanged: the same draft, file and `notifyctl +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. |
