aboutsummaryrefslogtreecommitdiffstats
path: root/docs/superpowers/specs
diff options
context:
space:
mode:
Diffstat (limited to 'docs/superpowers/specs')
-rw-r--r--docs/superpowers/specs/2026-10-19-notification-sounds-design.md123
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.