aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-05-21 20:16:34 -0400
committerCraig Jennings <c@cjennings.net>2026-05-21 20:16:34 -0400
commitf7079db3aa3e0073df6ce5409d4b6de0a431e26f (patch)
treee6ed90fdbd40c0122c4a2cd6c4ac25b34cb02be6
parentd6fa23bb592ce4184c3a8b62de5cb6826f874ee2 (diff)
downloadarchsetup-f7079db3aa3e0073df6ce5409d4b6de0a431e26f.tar.gz
archsetup-f7079db3aa3e0073df6ce5409d4b6de0a431e26f.zip
feat(notify): add --silent flag, volume knob, and level sound files
The touchpad toggle's notification was too loud, and the eight notify sounds varied by ~13 dB in RMS loudness — bug and fail came out two to three times louder than info or security. I added a --silent flag to notify (shows the popup, plays no sound) and a NOTIFY_VOLUME knob (paplay scale, default 65536) so the master level can drop without re-encoding. toggle-touchpad now passes --silent on both enable and disable. normalize-notify-sounds.sh measures each .ogg and shifts it to a uniform -31 dB mean. It writes through the file instead of mv-ing over it, so the stow symlinks survive when the script runs against the live sound dir. I re-encoded all eight sounds to the new level. Tests: a new tests/notify suite (12 tests) covers --silent, the volume knob, flag composition, and the error paths.
-rwxr-xr-xdotfiles/common/.local/bin/notify43
-rw-r--r--dotfiles/common/.local/share/sounds/notify/alarm.oggbin45775 -> 49034 bytes
-rw-r--r--dotfiles/common/.local/share/sounds/notify/alert.oggbin41684 -> 37850 bytes
-rw-r--r--dotfiles/common/.local/share/sounds/notify/bug.oggbin10187 -> 8522 bytes
-rw-r--r--dotfiles/common/.local/share/sounds/notify/fail.oggbin74886 -> 64144 bytes
-rw-r--r--dotfiles/common/.local/share/sounds/notify/info.oggbin31466 -> 29007 bytes
-rw-r--r--dotfiles/common/.local/share/sounds/notify/question.oggbin49846 -> 46024 bytes
-rw-r--r--dotfiles/common/.local/share/sounds/notify/security.oggbin19910 -> 19963 bytes
-rw-r--r--dotfiles/common/.local/share/sounds/notify/success.oggbin39244 -> 34480 bytes
-rwxr-xr-xdotfiles/hyprland/.local/bin/toggle-touchpad4
-rwxr-xr-xscripts/normalize-notify-sounds.sh48
-rw-r--r--tests/notify/test_notify.py186
12 files changed, 271 insertions, 10 deletions
diff --git a/dotfiles/common/.local/bin/notify b/dotfiles/common/.local/bin/notify
index a89a3ff..6918e01 100755
--- a/dotfiles/common/.local/bin/notify
+++ b/dotfiles/common/.local/bin/notify
@@ -3,7 +3,7 @@
# notify - Display notifications with icons and sounds
#
# Usage:
-# notify <type> "title" "body" [--persist]
+# notify <type> "title" "body" [--persist] [--silent]
#
# Types:
# success - Green checkmark, pleasant chime
@@ -17,12 +17,17 @@
#
# Options:
# --persist Don't auto-dismiss (stays until manually closed)
+# --silent Show the notification but play no sound
+#
+# Environment:
+# NOTIFY_VOLUME Playback volume for the sound (paplay scale: 65536 = 100%).
+# Lower it to soften every notification without re-encoding
+# the sound files. Default 65536.
#
# Examples:
# notify success "Job Complete" "Download finished in 12 minutes"
# notify fail "Job Failed" "Connection refused" --persist
-# notify alert "Warning" "Network speed reduced"
-# notify question "Input Needed" "Continue or abort?"
+# notify info "Touchpad" "Disabled" --silent
set -euo pipefail
@@ -31,7 +36,7 @@ SOUND_DIR="$HOME/.local/share/sounds/notify"
usage() {
cat <<EOF
-Usage: notify <type> "title" "body" [--persist]
+Usage: notify <type> "title" "body" [--persist] [--silent]
Types:
success Green checkmark, pleasant chime
@@ -45,10 +50,15 @@ Types:
Options:
--persist Don't auto-dismiss notification
+ --silent Show the notification but play no sound
+
+Environment:
+ NOTIFY_VOLUME Sound playback volume (paplay scale: 65536 = 100%)
Examples:
notify success "Job Complete" "Download finished"
notify fail "Build Failed" "Test errors" --persist
+ notify info "Touchpad" "Disabled" --silent
EOF
exit 1
}
@@ -61,7 +71,24 @@ fi
TYPE="$1"
TITLE="$2"
BODY="$3"
-PERSIST="${4:-}"
+shift 3
+
+# Optional flags, in any order
+PERSIST=""
+SILENT=""
+for arg in "$@"; do
+ case "$arg" in
+ --persist) PERSIST="--persist" ;;
+ --silent) SILENT=1 ;;
+ *)
+ echo "Error: Unknown option '$arg'" >&2
+ usage
+ ;;
+ esac
+done
+
+# Master playback volume (paplay scale: 65536 = 100%)
+NOTIFY_VOLUME="${NOTIFY_VOLUME:-65536}"
# Validate type and set icon/sound
case "$TYPE" in
@@ -129,9 +156,9 @@ if [[ -f "$ICON" ]]; then
NOTIFY_ARGS+=(--icon="$ICON")
fi
-# Play sound in background (if it exists)
-if [[ -f "$SOUND" ]]; then
- paplay "$SOUND" &
+# Play sound in background (unless silenced, and if the file exists)
+if [[ -z "$SILENT" && -f "$SOUND" ]]; then
+ paplay --volume="$NOTIFY_VOLUME" "$SOUND" &
fi
# Send notification
diff --git a/dotfiles/common/.local/share/sounds/notify/alarm.ogg b/dotfiles/common/.local/share/sounds/notify/alarm.ogg
index b4dff15..de9b0b7 100644
--- a/dotfiles/common/.local/share/sounds/notify/alarm.ogg
+++ b/dotfiles/common/.local/share/sounds/notify/alarm.ogg
Binary files differ
diff --git a/dotfiles/common/.local/share/sounds/notify/alert.ogg b/dotfiles/common/.local/share/sounds/notify/alert.ogg
index 217f33a..9001f2a 100644
--- a/dotfiles/common/.local/share/sounds/notify/alert.ogg
+++ b/dotfiles/common/.local/share/sounds/notify/alert.ogg
Binary files differ
diff --git a/dotfiles/common/.local/share/sounds/notify/bug.ogg b/dotfiles/common/.local/share/sounds/notify/bug.ogg
index 6842082..a1bfb03 100644
--- a/dotfiles/common/.local/share/sounds/notify/bug.ogg
+++ b/dotfiles/common/.local/share/sounds/notify/bug.ogg
Binary files differ
diff --git a/dotfiles/common/.local/share/sounds/notify/fail.ogg b/dotfiles/common/.local/share/sounds/notify/fail.ogg
index 916d88a..e4f067f 100644
--- a/dotfiles/common/.local/share/sounds/notify/fail.ogg
+++ b/dotfiles/common/.local/share/sounds/notify/fail.ogg
Binary files differ
diff --git a/dotfiles/common/.local/share/sounds/notify/info.ogg b/dotfiles/common/.local/share/sounds/notify/info.ogg
index 57900b4..0dc8e10 100644
--- a/dotfiles/common/.local/share/sounds/notify/info.ogg
+++ b/dotfiles/common/.local/share/sounds/notify/info.ogg
Binary files differ
diff --git a/dotfiles/common/.local/share/sounds/notify/question.ogg b/dotfiles/common/.local/share/sounds/notify/question.ogg
index 8e44c55..2a6017e 100644
--- a/dotfiles/common/.local/share/sounds/notify/question.ogg
+++ b/dotfiles/common/.local/share/sounds/notify/question.ogg
Binary files differ
diff --git a/dotfiles/common/.local/share/sounds/notify/security.ogg b/dotfiles/common/.local/share/sounds/notify/security.ogg
index 301287e..d2ecd20 100644
--- a/dotfiles/common/.local/share/sounds/notify/security.ogg
+++ b/dotfiles/common/.local/share/sounds/notify/security.ogg
Binary files differ
diff --git a/dotfiles/common/.local/share/sounds/notify/success.ogg b/dotfiles/common/.local/share/sounds/notify/success.ogg
index c28da65..6bf93fe 100644
--- a/dotfiles/common/.local/share/sounds/notify/success.ogg
+++ b/dotfiles/common/.local/share/sounds/notify/success.ogg
Binary files differ
diff --git a/dotfiles/hyprland/.local/bin/toggle-touchpad b/dotfiles/hyprland/.local/bin/toggle-touchpad
index cab605b..ed11674 100755
--- a/dotfiles/hyprland/.local/bin/toggle-touchpad
+++ b/dotfiles/hyprland/.local/bin/toggle-touchpad
@@ -14,11 +14,11 @@ state=$(cat "$STATE_FILE")
if [ "$state" = "enabled" ]; then
hyprctl keyword "device[$TOUCHPAD]:enabled" false >/dev/null
echo "disabled" > "$STATE_FILE"
- notify info "Touchpad" "Disabled"
+ notify info "Touchpad" "Disabled" --silent
else
hyprctl keyword "device[$TOUCHPAD]:enabled" true >/dev/null
echo "enabled" > "$STATE_FILE"
- notify info "Touchpad" "Enabled"
+ notify info "Touchpad" "Enabled" --silent
fi
# Refresh the waybar indicator immediately (custom/touchpad listens on signal 9).
diff --git a/scripts/normalize-notify-sounds.sh b/scripts/normalize-notify-sounds.sh
new file mode 100755
index 0000000..52c1d36
--- /dev/null
+++ b/scripts/normalize-notify-sounds.sh
@@ -0,0 +1,48 @@
+#!/bin/bash
+# Normalize notify sound files to a uniform RMS loudness so every notification
+# plays at the same perceived level. Re-encodes each file in place (ogg -> ogg).
+# Run once after adding or changing a sound in the notify set.
+#
+# Each file is measured with ffmpeg's volumedetect, then shifted by a constant
+# gain so its mean (RMS) volume lands on TARGET_DB. Peaks sit near 0 dB already,
+# so the spread was all in the RMS; leveling the RMS makes them match by ear.
+#
+# Usage:
+# normalize-notify-sounds.sh [SOUND_DIR]
+#
+# Environment:
+# TARGET_DB Target mean (RMS) loudness in dB. Default -31 (gentle + even).
+# Lower is quieter. The notify script's NOTIFY_VOLUME knob tunes
+# the master level at playback time without re-encoding.
+
+set -euo pipefail
+
+SOUND_DIR="${1:-$HOME/.local/share/sounds/notify}"
+TARGET_DB="${TARGET_DB:--31}"
+
+command -v ffmpeg >/dev/null || { echo "ffmpeg not found" >&2; exit 1; }
+command -v ffprobe >/dev/null || { echo "ffprobe not found" >&2; exit 1; }
+
+shopt -s nullglob
+files=("$SOUND_DIR"/*.ogg)
+(( ${#files[@]} )) || { echo "No .ogg files in $SOUND_DIR" >&2; exit 1; }
+
+for f in "${files[@]}"; do
+ mean=$(ffmpeg -hide_banner -nostats -i "$f" -af volumedetect -f null /dev/null 2>&1 \
+ | grep -oP 'mean_volume: \K[-0-9.]+' || true)
+ if [ -z "$mean" ]; then
+ echo "skip (could not measure): $f" >&2
+ continue
+ fi
+ gain=$(awk -v t="$TARGET_DB" -v m="$mean" 'BEGIN { printf "%.1f", t - m }')
+ tmp=$(mktemp --suffix=.ogg)
+ ffmpeg -hide_banner -loglevel error -y -i "$f" \
+ -af "volume=${gain}dB" -c:a libvorbis -q:a 6 "$tmp"
+ # Write through the file rather than mv over it: when SOUND_DIR is the
+ # stow-symlinked ~/.local copy, mv would replace the symlink with a real
+ # file and decouple it from the repo. cat preserves the symlink target.
+ cat "$tmp" > "$f"
+ rm -f "$tmp"
+ printf "%-14s mean %7s dB gain %+6s dB -> target %s dB\n" \
+ "$(basename "$f")" "$mean" "$gain" "$TARGET_DB"
+done
diff --git a/tests/notify/test_notify.py b/tests/notify/test_notify.py
new file mode 100644
index 0000000..c49af57
--- /dev/null
+++ b/tests/notify/test_notify.py
@@ -0,0 +1,186 @@
+"""Tests for dotfiles/common/.local/bin/notify.
+
+notify wraps notify-send (the visual popup) and paplay (the sound). The tests
+run the real script with HOME pointed at a temp dir holding fake icon/sound
+assets, and with fake `notify-send` and `paplay` executables on PATH that log
+their arguments. Assertions are made on those logs:
+
+ - the popup always fires (notify-send logged)
+ - the sound fires only when not --silent, at NOTIFY_VOLUME
+ - --persist and --silent compose in any order
+ - bad type / too few args / unknown flag fail
+
+No real audio plays and no real notification is sent.
+
+Run from repo root:
+ python3 -m unittest tests.notify.test_notify
+"""
+
+import os
+import shutil
+import subprocess
+import tempfile
+import time
+import unittest
+
+
+REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
+SCRIPT = os.path.join(REPO_ROOT, "dotfiles/common/.local/bin/notify")
+
+SOUND_TYPES = ["success", "fail", "alert", "question", "alarm", "info", "security", "bug"]
+
+
+class NotifyHarness(unittest.TestCase):
+
+ def setUp(self):
+ self.tmp = tempfile.mkdtemp(prefix="notify-test-")
+ self.home = os.path.join(self.tmp, "home")
+ self.bin = os.path.join(self.tmp, "bin")
+ self.sound_dir = os.path.join(self.home, ".local/share/sounds/notify")
+ self.icon_dir = os.path.join(self.home, ".local/share/icons/notify")
+ os.makedirs(self.bin)
+ os.makedirs(self.sound_dir)
+ os.makedirs(self.icon_dir)
+
+ # Fake assets so the script's existence checks pass.
+ for t in SOUND_TYPES:
+ open(os.path.join(self.sound_dir, f"{t}.ogg"), "w").close()
+ open(os.path.join(self.icon_dir, f"{t}.png"), "w").close()
+
+ # Fake binaries that log their argv to a file under tmp.
+ self.notify_log = os.path.join(self.tmp, "notify-send.log")
+ self.paplay_log = os.path.join(self.tmp, "paplay.log")
+ self._make_stub("notify-send", self.notify_log)
+ self._make_stub("paplay", self.paplay_log)
+
+ def tearDown(self):
+ shutil.rmtree(self.tmp, ignore_errors=True)
+
+ def _make_stub(self, name, logfile):
+ path = os.path.join(self.bin, name)
+ with open(path, "w") as f:
+ f.write("#!/bin/bash\n")
+ f.write('printf "%s\\n" "$*" >> "' + logfile + '"\n')
+ os.chmod(path, 0o755)
+
+ def run_notify(self, *args, env_extra=None):
+ env = os.environ.copy()
+ env["HOME"] = self.home
+ env["PATH"] = self.bin + os.pathsep + env.get("PATH", "")
+ if env_extra:
+ env.update(env_extra)
+ return subprocess.run(
+ [SCRIPT, *args], env=env, capture_output=True, text=True, timeout=10,
+ )
+
+ def read_log(self, path, wait=False):
+ """Return log contents. If wait, poll briefly (the sound is backgrounded)."""
+ if wait:
+ deadline = time.time() + 2.0
+ while time.time() < deadline and not os.path.exists(path):
+ time.sleep(0.02)
+ if not os.path.exists(path):
+ return ""
+ with open(path) as f:
+ return f.read()
+
+ def paplay_called(self):
+ # Silent path never spawns paplay, so a short settle is enough to be sure.
+ time.sleep(0.3)
+ return os.path.exists(self.paplay_log)
+
+
+# -----------------------------------------------------------------------------
+# Normal cases
+# -----------------------------------------------------------------------------
+
+class TestNotifyNormal(NotifyHarness):
+
+ def test_default_shows_popup_and_plays_sound(self):
+ result = self.run_notify("info", "Title", "Body")
+ self.assertEqual(result.returncode, 0, msg=result.stderr)
+ self.assertIn("Title", self.read_log(self.notify_log))
+ self.assertIn("info.ogg", self.read_log(self.paplay_log, wait=True))
+
+ def test_sound_played_at_default_volume(self):
+ self.run_notify("success", "T", "B")
+ self.assertIn("--volume=65536", self.read_log(self.paplay_log, wait=True))
+
+ def test_each_type_selects_its_own_sound(self):
+ for t in SOUND_TYPES:
+ with self.subTest(type=t):
+ # Fresh log per type.
+ if os.path.exists(self.paplay_log):
+ os.remove(self.paplay_log)
+ self.run_notify(t, "T", "B")
+ self.assertIn(f"{t}.ogg", self.read_log(self.paplay_log, wait=True))
+
+
+# -----------------------------------------------------------------------------
+# --silent
+# -----------------------------------------------------------------------------
+
+class TestNotifySilent(NotifyHarness):
+
+ def test_silent_shows_popup_but_no_sound(self):
+ result = self.run_notify("info", "T", "B", "--silent")
+ self.assertEqual(result.returncode, 0, msg=result.stderr)
+ self.assertIn("T", self.read_log(self.notify_log))
+ self.assertFalse(self.paplay_called(), "paplay should not run under --silent")
+
+ def test_silent_then_persist_both_apply(self):
+ result = self.run_notify("info", "T", "B", "--silent", "--persist")
+ self.assertEqual(result.returncode, 0, msg=result.stderr)
+ self.assertIn("--expire-time=0", self.read_log(self.notify_log))
+ self.assertFalse(self.paplay_called())
+
+ def test_persist_then_silent_order_independent(self):
+ result = self.run_notify("info", "T", "B", "--persist", "--silent")
+ self.assertEqual(result.returncode, 0, msg=result.stderr)
+ self.assertIn("--expire-time=0", self.read_log(self.notify_log))
+ self.assertFalse(self.paplay_called())
+
+
+# -----------------------------------------------------------------------------
+# Volume knob + persist
+# -----------------------------------------------------------------------------
+
+class TestNotifyVolumeAndPersist(NotifyHarness):
+
+ def test_notify_volume_env_overrides_playback_volume(self):
+ self.run_notify("info", "T", "B", env_extra={"NOTIFY_VOLUME": "30000"})
+ self.assertIn("--volume=30000", self.read_log(self.paplay_log, wait=True))
+
+ def test_persist_adds_expire_time_zero(self):
+ self.run_notify("info", "T", "B", "--persist")
+ self.assertIn("--expire-time=0", self.read_log(self.notify_log))
+
+ def test_no_persist_has_no_expire_time(self):
+ self.run_notify("info", "T", "B")
+ self.assertNotIn("--expire-time", self.read_log(self.notify_log))
+
+
+# -----------------------------------------------------------------------------
+# Error cases
+# -----------------------------------------------------------------------------
+
+class TestNotifyErrors(NotifyHarness):
+
+ def test_unknown_type_exits_nonzero(self):
+ result = self.run_notify("bogus", "T", "B")
+ self.assertNotEqual(result.returncode, 0)
+ self.assertIn("Unknown type", result.stderr)
+
+ def test_too_few_args_shows_usage(self):
+ result = self.run_notify("info", "OnlyTitle")
+ self.assertNotEqual(result.returncode, 0)
+ self.assertIn("Usage", result.stderr + result.stdout)
+
+ def test_unknown_flag_exits_nonzero(self):
+ result = self.run_notify("info", "T", "B", "--bogus")
+ self.assertNotEqual(result.returncode, 0)
+ self.assertIn("Unknown option", result.stderr)
+
+
+if __name__ == "__main__":
+ unittest.main()