aboutsummaryrefslogtreecommitdiffstats
path: root/docs/superpowers/specs/2026-10-19-notification-sounds-design.md
blob: 857ca4d73ac79e0f95756eea34fd87960aae803e (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
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.