diff options
| -rwxr-xr-x | dotfiles/common/.local/bin/notify | 43 | ||||
| -rw-r--r-- | dotfiles/common/.local/share/sounds/notify/alarm.ogg | bin | 45775 -> 49034 bytes | |||
| -rw-r--r-- | dotfiles/common/.local/share/sounds/notify/alert.ogg | bin | 41684 -> 37850 bytes | |||
| -rw-r--r-- | dotfiles/common/.local/share/sounds/notify/bug.ogg | bin | 10187 -> 8522 bytes | |||
| -rw-r--r-- | dotfiles/common/.local/share/sounds/notify/fail.ogg | bin | 74886 -> 64144 bytes | |||
| -rw-r--r-- | dotfiles/common/.local/share/sounds/notify/info.ogg | bin | 31466 -> 29007 bytes | |||
| -rw-r--r-- | dotfiles/common/.local/share/sounds/notify/question.ogg | bin | 49846 -> 46024 bytes | |||
| -rw-r--r-- | dotfiles/common/.local/share/sounds/notify/security.ogg | bin | 19910 -> 19963 bytes | |||
| -rw-r--r-- | dotfiles/common/.local/share/sounds/notify/success.ogg | bin | 39244 -> 34480 bytes | |||
| -rwxr-xr-x | dotfiles/hyprland/.local/bin/toggle-touchpad | 4 | ||||
| -rwxr-xr-x | scripts/normalize-notify-sounds.sh | 48 | ||||
| -rw-r--r-- | tests/notify/test_notify.py | 186 |
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 Binary files differindex b4dff15..de9b0b7 100644 --- a/dotfiles/common/.local/share/sounds/notify/alarm.ogg +++ b/dotfiles/common/.local/share/sounds/notify/alarm.ogg diff --git a/dotfiles/common/.local/share/sounds/notify/alert.ogg b/dotfiles/common/.local/share/sounds/notify/alert.ogg Binary files differindex 217f33a..9001f2a 100644 --- a/dotfiles/common/.local/share/sounds/notify/alert.ogg +++ b/dotfiles/common/.local/share/sounds/notify/alert.ogg diff --git a/dotfiles/common/.local/share/sounds/notify/bug.ogg b/dotfiles/common/.local/share/sounds/notify/bug.ogg Binary files differindex 6842082..a1bfb03 100644 --- a/dotfiles/common/.local/share/sounds/notify/bug.ogg +++ b/dotfiles/common/.local/share/sounds/notify/bug.ogg diff --git a/dotfiles/common/.local/share/sounds/notify/fail.ogg b/dotfiles/common/.local/share/sounds/notify/fail.ogg Binary files differindex 916d88a..e4f067f 100644 --- a/dotfiles/common/.local/share/sounds/notify/fail.ogg +++ b/dotfiles/common/.local/share/sounds/notify/fail.ogg diff --git a/dotfiles/common/.local/share/sounds/notify/info.ogg b/dotfiles/common/.local/share/sounds/notify/info.ogg Binary files differindex 57900b4..0dc8e10 100644 --- a/dotfiles/common/.local/share/sounds/notify/info.ogg +++ b/dotfiles/common/.local/share/sounds/notify/info.ogg diff --git a/dotfiles/common/.local/share/sounds/notify/question.ogg b/dotfiles/common/.local/share/sounds/notify/question.ogg Binary files differindex 8e44c55..2a6017e 100644 --- a/dotfiles/common/.local/share/sounds/notify/question.ogg +++ b/dotfiles/common/.local/share/sounds/notify/question.ogg diff --git a/dotfiles/common/.local/share/sounds/notify/security.ogg b/dotfiles/common/.local/share/sounds/notify/security.ogg Binary files differindex 301287e..d2ecd20 100644 --- a/dotfiles/common/.local/share/sounds/notify/security.ogg +++ b/dotfiles/common/.local/share/sounds/notify/security.ogg diff --git a/dotfiles/common/.local/share/sounds/notify/success.ogg b/dotfiles/common/.local/share/sounds/notify/success.ogg Binary files differindex c28da65..6bf93fe 100644 --- a/dotfiles/common/.local/share/sounds/notify/success.ogg +++ b/dotfiles/common/.local/share/sounds/notify/success.ogg 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() |
