# Notification Sounds Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** A sound per urgency for notification balloons, chosen on the notifications settings page, honouring apps' `sound-file`, `sound-name` and `suppress-sound` hints, with the sound theme mirrored to gsettings. **Architecture:** notifyd resolves the hints into two published fields (`sound`, `silent`), looking `sound-name` up in `config.json`'s `soundTheme`. The balloon shell plays with `pw-play` when a new notification would show a balloon, choosing via `NotifyConfig.soundFor`. The settings page edits `sounds` and `soundTheme`, writes the theme to gsettings on save, and reads gsettings back to flag outside changes. **Tech Stack:** Go stdlib + godbus (notifyd, `~/Programming/GIT/notifyd`), Quickshell 0.3.1 QML, `Qt.labs.platform` FileDialog, `pw-play`, `gsettings`, node for the JS check. Spec: `docs/superpowers/specs/2026-10-19-notification-sounds-design.md`. Builds on `docs/superpowers/specs/2026-10-19-notification-settings-design.md` (already shipped). Two repos. `notifyd:` paths are relative to `~/Programming/GIT/notifyd`, others to this repo. Commits GPG-signed (never disable), each ending with a blank line and `Co-Authored-By: Claude Opus 5.5 `. No em dashes, no `/home/` paths in committed files. Never `pkill -f`, never start/kill `qs` or `notifyd`, never run `./reload.sh`. --- ## File map notifyd: - Create `internal/notify/sounds.go`, `internal/notify/sounds_test.go`. - Modify `internal/notify/config.go` (+`SoundTheme`), `config_test.go`. - Modify `internal/notify/store.go` (`Popup` gains `Sound`, `Silent`). - Modify `internal/notify/service.go` (`Notify` fills them). - Modify `README.md`. quickshell: - Modify `shared/NotifyConfig.js` (+`sounds`, `soundTheme`, `soundFor`), `shared/test_notify_config.js`. - Modify `notifications/Balloons.qml` (player). - Modify `desktop/modules/notifications/NotificationsModule.qml` (themes list, gsettings read, save merges `sounds`, saver sets gsettings), `NotificationsPage.qml` (Sounds section). - Modify `desktop/modules/notifications/README.md`, `notifications/README.md`. --- ### Task 1: notifyd sound lookup **Files:** Create `notifyd:internal/notify/sounds.go`, `notifyd:internal/notify/sounds_test.go`. - [ ] **Step 1: Failing test** `sounds_test.go` (GPL header from `store.go` lines 1-10): ```go package notify import ( "os" "path/filepath" "testing" "github.com/godbus/dbus/v5" ) // soundTree makes $XDG_DATA_HOME/sounds//stereo/ for each entry // and an index.theme per theme with the given Inherits, and points // XDG_DATA_HOME there with XDG_DATA_DIRS empty-but-set to another temp dir. func soundTree(t *testing.T, inherits map[string]string, files ...string) string { t.Helper() home := t.TempDir() t.Setenv("XDG_DATA_HOME", home) t.Setenv("XDG_DATA_DIRS", t.TempDir()) for theme, inh := range inherits { dir := filepath.Join(home, "sounds", theme) if err := os.MkdirAll(filepath.Join(dir, "stereo"), 0o755); err != nil { t.Fatal(err) } body := "[Sound Theme]\nName=" + theme + "\n" if inh != "" { body += "Inherits=" + inh + "\n" } if err := os.WriteFile(filepath.Join(dir, "index.theme"), []byte(body), 0o644); err != nil { t.Fatal(err) } } for _, f := range files { p := filepath.Join(home, "sounds", f) if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(p, []byte("x"), 0o644); err != nil { t.Fatal(err) } } return filepath.Join(home, "sounds") } func TestLookupSound(t *testing.T) { root := soundTree(t, map[string]string{"mine": "parent", "parent": "", "freedesktop": ""}, "mine/stereo/bell.oga", "mine/stereo/bell.wav", "parent/stereo/message-new-instant.ogg", "freedesktop/stereo/dialog-warning.oga", "freedesktop/stereo/message.oga", ) cases := []struct{ name, want string }{ {"bell", root + "/mine/stereo/bell.oga"}, // direct, .oga before .wav {"message-new-instant", root + "/parent/stereo/message-new-instant.ogg"}, // inherited {"dialog-warning", root + "/freedesktop/stereo/dialog-warning.oga"}, // freedesktop fallback {"message-new-email", root + "/freedesktop/stereo/message.oga"}, // name fallback {"nothing-here", ""}, } for _, c := range cases { if got := LookupSound(c.name, "mine"); got != c.want { t.Errorf("LookupSound(%q) = %q, want %q", c.name, got, c.want) } } } func TestLookupSoundInheritsCycle(t *testing.T) { soundTree(t, map[string]string{"a": "b", "b": "a"}) if got := LookupSound("bell", "a"); got != "" { t.Errorf("got %q, want empty", got) } } func TestSoundFromHints(t *testing.T) { root := soundTree(t, map[string]string{"freedesktop": ""}, "freedesktop/stereo/bell.oga") file := filepath.Join(t.TempDir(), "ding.wav") if err := os.WriteFile(file, []byte("x"), 0o644); err != nil { t.Fatal(err) } v := dbus.MakeVariant cases := []struct { name string hints map[string]dbus.Variant sound string silent bool }{ {"none", map[string]dbus.Variant{}, "", false}, {"name", map[string]dbus.Variant{"sound-name": v("bell")}, root + "/freedesktop/stereo/bell.oga", false}, {"file wins", map[string]dbus.Variant{"sound-name": v("bell"), "sound-file": v(file)}, file, false}, {"relative file rejected", map[string]dbus.Variant{"sound-file": v("ding.wav")}, "", false}, {"missing file rejected", map[string]dbus.Variant{"sound-file": v(file + ".gone")}, "", false}, {"bad file falls to name", map[string]dbus.Variant{"sound-file": v("x"), "sound-name": v("bell")}, root + "/freedesktop/stereo/bell.oga", false}, {"suppress", map[string]dbus.Variant{"suppress-sound": v(true), "sound-name": v("bell")}, root + "/freedesktop/stereo/bell.oga", true}, {"wrong types", map[string]dbus.Variant{"suppress-sound": v("yes"), "sound-name": v(3)}, "", false}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { sound, silent := SoundFromHints(c.hints, "freedesktop") if sound != c.sound || silent != c.silent { t.Errorf("got (%q, %v), want (%q, %v)", sound, silent, c.sound, c.silent) } }) } } ``` - [ ] **Step 2: Run, expect failure** `go test ./internal/notify/ -run 'Sound'` → `undefined: LookupSound`. - [ ] **Step 3: Implement** `sounds.go` (GPL header): ```go package notify import ( "log" "os" "path/filepath" "strings" "github.com/godbus/dbus/v5" ) // XDGSoundDirs is the sound theme search path: the user's dir then each data // dir, the same roots as XDGIconDirs. func XDGSoundDirs() []string { dirs := XDGIconDirs() for i, d := range dirs { dirs[i] = filepath.Join(filepath.Dir(d), "sounds") } return dirs } var soundExts = []string{".oga", ".ogg", ".wav"} // LookupSound resolves a sound naming spec name: the theme, its Inherits // chain, then freedesktop, each searched across every root; within that, the // full name first and then with trailing -components dropped, as the spec's // fallback (message-new-email, message-new, message). "" when nothing matches. func LookupSound(name, theme string) string { var chain []string seen := map[string]bool{} for t := theme; t != "" && !seen[t]; { seen[t] = true chain = append(chain, t) next := "" for _, root := range XDGSoundDirs() { if v := inheritsOf(filepath.Join(root, t)); v != "" { next = v break } } t = next } if !seen["freedesktop"] { chain = append(chain, "freedesktop") } for n := name; n != ""; { for _, t := range chain { for _, root := range XDGSoundDirs() { for _, ext := range soundExts { p := filepath.Join(root, t, "stereo", n+ext) if fileExists(p) { return p } } } } i := strings.LastIndexByte(n, '-') if i < 0 { break } n = n[:i] } return "" } // SoundFromHints reads the three freedesktop sound hints. sound-file wins over // sound-name, but only as an absolute path to an existing file: anything else // is logged and treated as absent, so the name still gets its turn. silent is // suppress-sound, published as is; the renderer decides what it means. func SoundFromHints(hints map[string]dbus.Variant, theme string) (sound string, silent bool) { if v, ok := hints["suppress-sound"]; ok { silent, _ = v.Value().(bool) } if v, ok := hints["sound-file"]; ok { if p, _ := v.Value().(string); p != "" { if filepath.IsAbs(p) && fileExists(p) { return p, silent } log.Printf("notifyd: sound-file %q is not an absolute path to a file", p) } } if v, ok := hints["sound-name"]; ok { if n, _ := v.Value().(string); n != "" { return LookupSound(n, theme), silent } } return "", silent } ``` Note `XDGIconDirs` appends `/usr/local/share/icons` and `/usr/share/icons` only when `XDG_DATA_DIRS` is empty; the test sets it to a temp dir so the real system themes do not leak in. `inheritsOf` takes the first comma-separated value only, same as for icons. - [ ] **Step 4: Run, expect pass** `go test ./internal/notify/ -run 'Sound' -v`, then `go vet ./... && go test ./...`, `gofmt -l .` empty. - [ ] **Step 5: Commit** ``` feat(notify): resolve sound-file, sound-name and suppress-sound hints sound-name is looked up in a sound theme, its Inherits chain and freedesktop, with the naming spec's trailing-component fallback. A sound-file must be an absolute path to an existing file, else it is treated as absent so a sound-name in the same notification still applies. ``` --- ### Task 2: notifyd config and published fields **Files:** Modify `notifyd:internal/notify/config.go`, `config_test.go`, `store.go`, `service.go`, `README.md`. - [ ] **Step 1: Failing test** Append to `config_test.go`: ```go func TestLoadConfigSoundTheme(t *testing.T) { if got := LoadConfig(writeConfig(t, `{"soundTheme": "mine"}`)).SoundTheme; got != "mine" { t.Errorf("soundTheme = %q, want mine", got) } if got := LoadConfig(writeConfig(t, `{"soundTheme": ""}`)).SoundTheme; got != "freedesktop" { t.Errorf("empty soundTheme = %q, want freedesktop", got) } if got := LoadConfig(writeConfig(t, `{"soundTheme": 4, "liveCap": 3}`)); got.SoundTheme != "freedesktop" || got.LiveCap != 3 { t.Errorf("got %+v", got) } } ``` Run `go test ./internal/notify/ -run SoundTheme` → `got.SoundTheme undefined`. - [ ] **Step 2: Implement** `config.go`: add to `Config` after `HistoryCap`: ```go SoundTheme string `json:"soundTheme"` ``` and update its doc comment's list of what the daemon reads ("timeouts, caps and the sound theme for sound-name hints"). In `DefaultConfig()` add `SoundTheme: "freedesktop"`. In `LoadConfig`, after the timeout clamps: ```go if c.SoundTheme == "" { c.SoundTheme = def.SoundTheme } ``` `store.go`, `Popup`: add after `Image`: ```go Sound string `json:"sound"` Silent bool `json:"silent"` ``` `service.go`, `Notify`: where it copies `timeouts` under `s.mu`, also copy the theme: ```go s.mu.Lock() timeouts := s.cfg.Timeouts theme := s.cfg.SoundTheme s.mu.Unlock() ``` and in the `n := &Popup{...}` literal add nothing; after it: ```go n.Sound, n.Silent = SoundFromHints(hints, theme) ``` - [ ] **Step 3: Test** `go vet ./... && go test ./...`, `gofmt -l .` empty. `TestLoadConfigMissingFileIsDefaults` etc. compare whole structs and still pass because `DefaultConfig` carries the theme. - [ ] **Step 4: README** In `README.md`'s Configuration section, add `"soundTheme": "freedesktop"` to the JSON example and this paragraph after the caps one: ```markdown `soundTheme` is the freedesktop sound theme an app's `sound-name` hint is looked up in (then its `Inherits`, then `freedesktop`). The daemon publishes each notification's resolved `sound` (a path, or `""`) and `silent` (the app's `suppress-sound`); a `sound-file` hint wins over `sound-name` when it is an absolute path to an existing file. Playing is the renderer's job. ``` - [ ] **Step 5: Commit, push** ``` feat(notify): publish each notification's sound and suppress-sound The sound-name lookup uses config.json's soundTheme, default freedesktop. The quickshell settings page mirrors that key to gsettings; the daemon only reads the file. ``` Then `git push`. - [ ] **Step 6: Build for the user** Build only, into the session scratchpad the controller gives you; never into `~/bin`: ```bash go build -o /notifyd ./cmd/notifyd && go build -o /notifyctl ./cmd/notifyctl ``` --- ### Task 3: `NotifyConfig.js` sounds **Files:** Modify `shared/NotifyConfig.js`, `shared/test_notify_config.js`. - [ ] **Step 1: Failing test** In `test_notify_config.js`, export `soundFor` (`return { defaults, resolve, withheld, soundFor };`) and add before `console.log("ok")`: ```js // Sounds: strings only, empty theme falls back. const s = C.resolve({ sounds: { low: "/a.oga", normal: 3 }, soundTheme: "" }); assert.deepStrictEqual(s.sounds, { low: "/a.oga", normal: "", critical: "" }); assert.strictEqual(s.soundTheme, "freedesktop"); assert.strictEqual(C.resolve({ soundTheme: "mine" }).soundTheme, "mine"); // soundFor: silent beats everything, the app's sound beats the urgency's. const sc = C.resolve({ sounds: { low: "", normal: "/n.oga", critical: "/c.oga" } }); const sn = (urgency, sound, silent) => ({ app: "x", urgency, sound, silent }); assert.strictEqual(C.soundFor(sc, sn("normal", "", false)), "/n.oga"); assert.strictEqual(C.soundFor(sc, sn("low", "", false)), ""); assert.strictEqual(C.soundFor(sc, sn("low", "/app.oga", false)), "/app.oga"); assert.strictEqual(C.soundFor(sc, sn("critical", "/app.oga", true)), ""); // An old daemon publishes neither field. assert.strictEqual(C.soundFor(sc, { app: "x", urgency: "critical" }), "/c.oga"); ``` Run `node shared/test_notify_config.js` → assertion failure on `s.sounds`. - [ ] **Step 2: Implement** In `defaults()` add: ```js sounds: { low: "", normal: "", critical: "" }, soundTheme: "freedesktop", ``` In `resolve`, read `const s = raw.sounds || {};` beside `t` and `b`, and add to the returned object: ```js sounds: { low: typeof s.low === "string" ? s.low : d.sounds.low, normal: typeof s.normal === "string" ? s.normal : d.sounds.normal, critical: typeof s.critical === "string" ? s.critical : d.sounds.critical, }, soundTheme: typeof raw.soundTheme === "string" && raw.soundTheme !== "" ? raw.soundTheme : d.soundTheme, ``` Append: ```js // The file a balloon plays, "" for none: the app's suppress-sound wins, then // the app's own sound (notifyd resolved its hints), then the urgency's. function soundFor(cfg, n) { if (n.silent) return ""; if (n.sound) return n.sound; return cfg.sounds[n.urgency] || ""; } ``` Update the header comment's mention of what is "this side's alone" to include `sounds`. - [ ] **Step 3: Pass, commit** `node shared/test_notify_config.js` → `ok`. ``` notifications: resolve the sound keys and choose a balloon's sound ``` --- ### Task 4: balloon shell plays **Files:** Modify `notifications/Balloons.qml`. - [ ] **Step 1: Implement** Add `import "NotifyConfig.js" as NC` (the link `notifications/NotifyConfig.js` exists). Inside the root `Scope`, after `readonly property var live`: ```qml // A sound per new or replaced notification (replace keeps the id and // changes created), played only when its balloon would show now: the // same drawer, snooze, mute and DND checks as NotificationBalloon. The // first read after a start only records what is there, so a restart is // silent. seen is rebuilt from the queue each time, so it never grows. property var seen: ({}) property bool primed: false Connections { target: Notify function onQueueChanged() { const next = {}; let file = ""; for (const n of Notify.queue || []) { const key = n.id + ":" + n.created; next[key] = true; if (!root.primed || root.seen[key] || file !== "") continue; if (Notify.drawerOpen || Notify.snoozeUntil > Date.now()) continue; if (NC.withheld(Notify.config, n, Notify.dnd)) continue; file = NC.soundFor(Notify.config, n); } root.seen = next; root.primed = true; if (file !== "") { // ponytail: one player, a new sound cuts the last; a queue of // sounds if bursts ever need every one heard. player.command = ["pw-play", file]; player.running = false; player.running = true; } } } Process { id: player } ``` The queue is newest first, so the first unseen, unwithheld entry with a sound is the newest. `Quickshell.Io` is already imported for `FileView`. - [ ] **Step 2: Check reload** `qs log -p notifications --log-times | tail -5`: a `Reloading configuration` after the edit, no errors. `shared/` is untouched in this task so the hot reload is enough for this file; `Notify.qml` is unchanged. - [ ] **Step 3: Commit** ``` notifications: play a sound for a new balloon pw-play, once per new or replaced notification, only when the balloon would show: the drawer, snooze, mute and DND that withhold the balloon silence it too. A restart records the queue without playing it. ``` --- ### Task 5: settings page **Files:** Modify `desktop/modules/notifications/NotificationsModule.qml`, `NotificationsPage.qml`. Read both files first; they changed after the first plan (draft binding via `follow()`, nested merge in `save()`, `busy` on the SaveBar, Field rebinding, red/dim chips). - [ ] **Step 1: Module: themes, gsettings, save** In `NotificationsModule.qml`: 1. Properties: ```qml // The installed sound themes (directories with an index.theme), and the // system theme as gsettings has it, "" when unreadable. Both reread on // every refresh, so a change made outside the page shows. property var themes: [] property string systemTheme: "" ``` 2. `refresh()` becomes: ```qml function refresh() { if (!nm.dirty) nm.follow(); nm.themeLister.running = false; nm.themeLister.running = true; nm.gsGet.running = false; nm.gsGet.running = true; } ``` 3. Processes: ```qml property Process themeLister: Process { command: ["sh", "-c", 'find "$HOME/.local/share/sounds" /usr/share/sounds -mindepth 2 -maxdepth 2 -name index.theme 2>/dev/null'] stdout: StdioCollector { onStreamFinished: nm.themes = [...new Set(text.split("\n").filter(l => l) .map(l => l.split("/").slice(-2)[0]))].sort() } } // gsettings prints the string quoted: 'freedesktop'. property Process gsGet: Process { command: ["gsettings", "get", "org.gnome.desktop.sound", "theme-name"] stdout: StdioCollector { onStreamFinished: nm.systemTheme = text.trim().replace(/^'(.*)'$/, "$1") } onExited: code => { if (code !== 0) nm.systemTheme = ""; } } ``` 4. `save()`: add `sounds` to the one-level merge beside `timeouts` and `balloons`: ```qml sounds: Object.assign({}, obj(raw.sounds), nm.draft.sounds), ``` 5. The saver: the theme goes to gsettings between the write and the reload. Replace its `command` and `onExited`: ```qml command: ["sh", "-c", 'mkdir -p "$(dirname "$1")" && cat > "$1" || exit 1; ' + 'gsettings set org.gnome.desktop.sound theme-name "$2" || g=3; ' + 'notifyctl reload || exit 2; exit ${g:-0}', "sh", Notify.configPath, nm.saver.theme] property string theme: "" onExited: code => { nm.notice = code === 0 ? "saved" : code === 2 ? "saved; notifyd not reachable, it reads the file at its next start" : code === 3 ? "saved; the system sound theme could not be set" : "could not write " + Notify.configPath; nm.gsGet.running = false; nm.gsGet.running = true; } ``` and in `save()` set `nm.saver.theme = nm.draft.soundTheme;` before starting it. Update the comment above `save()` to say 3 is the gsettings write. - [ ] **Step 2: Page: Sounds section** In `NotificationsPage.qml` add `import Qt.labs.platform as P` and `import Quickshell.Io`, and a Sounds section right after the Timeouts `Repeater`: ```qml Heading { text: "Sounds" } Text { width: pg.width; wrapMode: Text.Wrap text: "Played when a balloon pops, never for one muted, held by Do Not Disturb or snoozed. An app's own sound, or its request for silence, wins." color: Theme.overlay font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } } Repeater { model: [["low", "Low"], ["normal", "Normal"], ["critical", "Critical"]] Row { required property var modelData spacing: 10 readonly property string file: pg.d.sounds[modelData[0]] Text { width: 90; anchors.verticalCenter: parent.verticalCenter text: modelData[1]; color: Theme.text font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } } Text { width: 180; anchors.verticalCenter: parent.verticalCenter elide: Text.ElideMiddle text: parent.file ? parent.file.split("/").pop() : "none" color: parent.file ? Theme.text : Theme.overlay font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } } Button { text: "Choose…" onClicked: { picker.key = modelData[0]; picker.open(); } } Button { icon: "" // nf-fa-play enabled: parent.file !== "" onClicked: { preview.command = ["pw-play", parent.file]; preview.running = false; preview.running = true; } } Button { icon: "" enabled: parent.file !== "" onClicked: pg.mod.edit({ sounds: Object.assign({}, pg.d.sounds, { [modelData[0]]: "" }) }) } } } Text { topPadding: 6 text: "Theme for sounds apps ask for by name, also set as the system sound theme" color: Theme.subtext font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4; bold: true } } Flow { width: pg.width; spacing: 6 Repeater { model: pg.mod.themes Button { required property string modelData text: modelData border.color: pg.d.soundTheme === modelData ? Theme.accent : Qt.alpha(Theme.text, 0.2) onClicked: pg.mod.edit({ soundTheme: modelData }) } } } Row { spacing: 10 visible: pg.mod.systemTheme !== "" && pg.mod.systemTheme !== pg.d.soundTheme Text { anchors.verticalCenter: parent.verticalCenter text: "System sound theme is " + pg.mod.systemTheme + ", changed outside this page" color: Theme.yellow font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } } Button { text: "Use " + pg.mod.systemTheme onClicked: pg.mod.edit({ soundTheme: pg.mod.systemTheme }) } } Process { id: preview } // Qt.labs.platform, not QtQuick.Dialogs: the Quick dialog opens light on // this desktop (AGENTS.md); this one takes the widget style. P.FileDialog { id: picker property string key: "" folder: "file://" + Quickshell.env("HOME") + "/.local/share/sounds" nameFilters: ["Sounds (*.oga *.ogg *.wav)"] onAccepted: pg.mod.edit({ sounds: Object.assign({}, pg.d.sounds, { [picker.key]: decodeURIComponent(file.toString().replace(/^file:\/\//, "")) }) }) } ``` `Quickshell` is already imported by the page (check; add `import Quickshell` if not). Check the play glyph `` exists: `fc-list ':charset=f04b' family | grep -ci inconsolata` must be non-zero; if not, pick another play glyph that is. Note the yellow line hides when gsettings is unreadable (`systemTheme === ""`) and when it agrees with the draft, so "Use x" clears it immediately and Save then sets both. - [ ] **Step 3: Check it loads** Edit `desktop/shell.qml`? Not needed: no new file, both edited files reload on save. Check `qs log -p desktop --log-times | tail -20` for a reload after the edits and no `TypeError`, `ReferenceError`, `is not a type`, `Unable to assign`, `Cannot assign`. Open the page through IPC if a target exists (`qs -p desktop ipc show`) and recheck the log. - [ ] **Step 4: Commit, push** ``` desktop: notification sounds on the settings page A sound per urgency chosen with the widget file dialog, previewed with pw-play, and the sound theme for apps' sound-name hints. Save writes the theme to gsettings as well, and the page reads gsettings back on every open so a change made elsewhere is shown, with a button to adopt it, rather than overwritten. ``` --- ### Task 6: docs **Files:** `desktop/modules/notifications/README.md`, `notifications/README.md`. - [ ] **Step 1:** Module README: add `sounds` (balloon shell) and `soundTheme` (notifyd, mirrored to gsettings by the page) rows to the key table, and a paragraph: the page reads gsettings on every open and flags a mismatch; the save exit codes (1 write, 3 gsettings, 2 reload). - [ ] **Step 2:** Balloon README: a "Sounds" section: played with `pw-play` once per new or replaced notification, only when the balloon would show; precedence `silent` > the app's `sound` > `sounds[urgency]`; a restart is silent; one player, a new sound cuts the last. - [ ] **Step 3:** Commit `docs: notification sounds`, push.