aboutsummaryrefslogtreecommitdiffstats
path: root/docs/superpowers/specs/2026-10-19-notification-sounds-design.md
blob: e64ed3981b868459594d0ceb69f09c0b68436b42 (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
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
# 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 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/<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.

The theme is the desktop's too. Save sets `soundTheme` in the file and
`gsettings set org.gnome.desktop.sound theme-name <theme>`, 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 <x>, changed outside this page", with a **Use <x>**
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.