From aa29f017f72d1c9bec2023a2f584fe4759157a8c Mon Sep 17 00:00:00 2001 From: Alan Silva Date: Thu, 10 Sep 2026 00:41:34 +0100 Subject: [PATCH] Fix sound options and panel controls --- EffectCatalog.js | 14 +++++++--- README.md | 5 ++-- REFERENCE.md | 2 +- Settings.qml | 45 ++++++++++++++++++++++++------- bin/ar-effect | Bin 2173 -> 2193 bytes effects/sound | 64 +++++++++++++++++++++++++++++++++++++++----- tests/rules.test.js | 13 +++++++++ tests/sound.test.sh | 63 +++++++++++++++++++++++++++++++++++++++++++ 8 files changed, 183 insertions(+), 23 deletions(-) create mode 100644 tests/sound.test.sh diff --git a/EffectCatalog.js b/EffectCatalog.js index 5377d00..c446e31 100644 --- a/EffectCatalog.js +++ b/EffectCatalog.js @@ -85,13 +85,21 @@ var EFFECTS = [ }, { type: "sound", label: "Sound", icon: "󰕾", - subtitle: "Plays a chime", + subtitle: "Plays a selected chime; Fit in 0 uses its natural speed", rows: [ + { key: "duration", label: "Fit in", min: 0, max: 30, step: 0.1, fallback: 0, unit: " s" }, { key: "intensity", label: "Volume", min: 0, max: 2, step: 0.01, fallback: 1, unit: "" }, - { key: "speed", label: "Repeat", min: 1, max: 50, step: 1, fallback: 1, unit: " ×" } + { key: "repeat", label: "Times", min: 1, max: 50, step: 1, fallback: 1, unit: " ×" } ], options: [ - { key: "file", label: "File", type: "text", fallback: "", placeholder: "empty: the default chime · or a path to a sound file" } + { key: "sound", label: "Sound", type: "enum", fallback: "message", + values: [ + { value: "message", label: "Message" }, + { value: "bell", label: "Bell" }, + { value: "warning", label: "Warning" }, + { value: "complete", label: "Complete" }, + { value: "phone", label: "Phone" } + ] } ] }, { diff --git a/README.md b/README.md index 3113046..81c25d6 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,8 @@ or resume every effect. A first rule is there to start: any notification that mentions **deliveroo** shakes the screen. Runtime dependencies, all part of a stock Omarchy: `jq`, `busctl` (systemd), -`hyprctl`, `notify-send`, `pw-play` (for the sound). `./install.sh` is an +`hyprctl`, `notify-send`, `pw-play` (for the sound), and `ffmpeg`/`ffprobe` +(when sound repeats must fit a duration). `./install.sh` is an optional helper for a checkout somewhere else: it links the plugin into `~/.config/omarchy/plugins`, puts the `attention-required` command on your PATH and enables the bell. `omarchy plugin add` never runs it. @@ -87,7 +88,7 @@ that does not set the option itself. | `airplane` | A plane bobs across the screen, trailing exhaust, towing the message on a rippling flag. | flight seconds (7) | size (1) | | `altitude` 0..1 (0.2), `direction`: ltr, rtl; `text` | | `confetti` | Confetti in the theme's colours. | seconds (1) | amount (1) | launch power (1) | `style`: cannons (bottom corners, up), burst (centre), rain (top) | | `blink` | The screen dims and comes back. | seconds (1) | darkness 0..1 (0.6) | blinks per second (2) | | -| `sound` | Plays a chime with `pw-play`. | | volume (1) | times (1) | `file` | +| `sound` | Plays a selected chime with `pw-play`. | seconds to fit repeats; 0 is natural speed (0) | volume (1) | times (1) | Message, bell, warning, complete, or phone | | `focus` | Brings the sending app's window to the front. | | | | `window`: a class or title instead | | `command` | Runs a shell command. | | | | `run`; sees `AR_APP`, `AR_SUMMARY`, `AR_BODY`, `AR_RULE`, every option as `AR_OPT_` | diff --git a/REFERENCE.md b/REFERENCE.md index 055005d..44444a4 100644 --- a/REFERENCE.md +++ b/REFERENCE.md @@ -86,7 +86,7 @@ Values outside a range are clamped when the effect runs. Every effect has | `airplane` | flight seconds 0.5..120 (7) | size 0.2..10 (1) | | `altitude` 0..1 from the top (0.2); `direction`: `ltr`, `rtl`; `text` template | | `confetti` | seconds it keeps coming 0.1..60 (1) | amount 0.05..20 (1) | launch power 0.1..10 (1) | `style`: `cannons` (bottom corners, up), `burst` (centre), `rain` (top) | | `blink` | seconds 0.1..30 (1) | darkness 0.01..1 (0.6) | blinks per second 0.2..30 (2) | | -| `sound` | | volume 0..2 (1) | times played 1..50 (1) | `file`: path to a sound file (default: freedesktop's new-message chime) | +| `sound` | seconds to fit repeats 0..30; 0 is natural speed (0) | volume 0..2 (1) | repeat 1..50 (1) | `sound`: `message`, `bell`, `warning`, `complete`, or `phone` | | `focus` | | | | `window`: a window class or title to focus instead of the sending app | | `command` | | | | `run`: a shell command, run as the user with `AR_APP`, `AR_SUMMARY`, `AR_BODY`, `AR_RULE`, `AR_KEY`, `AR_URGENCY` and every effect option as `AR_OPT_` in the environment | diff --git a/Settings.qml b/Settings.qml index bb443c4..49a65b9 100644 --- a/Settings.qml +++ b/Settings.qml @@ -25,7 +25,7 @@ Panel { implicitWidth: button.implicitWidth implicitHeight: button.implicitHeight - readonly property var svc: bar && bar.shell ? bar.shell.serviceFor("alanfortlink.attention-required") : null + property var svc: null readonly property bool paused: svc ? !svc.enabled : false readonly property var cfg: svc ? svc.rawConfig : ({}) readonly property var rules: svc ? svc.rawRules : [] @@ -40,6 +40,25 @@ Panel { readonly property int labelW: Style.space(96) readonly property var catalog: Catalog.EFFECTS + function bindService() { + var host = bar && bar.shell ? bar.shell : null + if (!host || typeof host.serviceFor !== "function") return + var service = host.serviceFor("alanfortlink.attention-required") + if (service) root.svc = service + } + + onBarChanged: { + root.svc = null + root.bindService() + } + Component.onCompleted: root.bindService() + Timer { + interval: 200 + running: root.svc === null + repeat: true + onTriggered: root.bindService() + } + // ---------- navigation ---------- property string page: "list" // list | rule | effect property int current: -1 // the rule the deeper pages are about @@ -185,27 +204,33 @@ Panel { spacing: Style.space(10) PanelHero { + id: hero width: parent.width title: "Attention Required" meta: root.armedLabel() - detail: root.svc && root.svc.configError !== "" ? root.svc.configError : "" + detail: !root.svc ? "Service unavailable" : (root.svc.configError !== "" ? root.svc.configError : "") foreground: root.fg fontFamily: root.fontFamily + readonly property bool attentionPaused: root.paused + readonly property var attentionService: root.svc + function toggleAttention() { + if (attentionService) attentionService.setEnabled(attentionPaused) + } iconComponent: Component { Text { - text: root.paused ? "󰂜" : "󰂞" - color: root.fg - opacity: root.paused ? 0.5 : 1 - font.family: root.fontFamily + text: hero.attentionPaused ? "󰂜" : "󰂞" + color: hero.foreground + opacity: hero.attentionPaused ? 0.5 : 1 + font.family: hero.fontFamily font.pixelSize: Style.font.display } } trailingControl: Component { ToggleSwitch { - checked: !root.paused - foreground: root.fg - cursorPad: root.trailInset - onToggled: if (root.svc) root.svc.setEnabled(root.paused) + checked: !hero.attentionPaused + foreground: hero.foreground + cursorPad: Style.space(6) + onToggled: hero.toggleAttention() } } } diff --git a/bin/ar-effect b/bin/ar-effect index d8d8c756ae2327889c34a95c4713a5aab9987862..c6da9aade1dc5624c17c27b3c164b97fd390c01a 100755 GIT binary patch delta 51 mcmew>Fi~)W2|HU%sR0m7Hf2|WF(cSjVaz)A88D_eM;ZWgmkq}N delta 31 icmbOz_*Y 50 )) && times=50 @@ -31,6 +40,47 @@ play() { fi } -for ((i = 0; i < times; i++)); do - play || exit 1 -done +play_repeatedly() { + for ((i = 0; i < times; i++)); do + play || return 1 + done +} + +# ffmpeg changes the tempo of each chime so all repeats fit the chosen duration. +# Each repetition gets its own playback stream, keeping every hit distinct. +fit_duration() { + command -v ffprobe >/dev/null 2>&1 || { echo "sound: ffprobe is required when Fit in is set" >&2; return 1; } + command -v ffmpeg >/dev/null 2>&1 || { echo "sound: ffmpeg is required when Fit in is set" >&2; return 1; } + command -v pw-play >/dev/null 2>&1 || { echo "sound: pw-play is required when Fit in is set" >&2; return 1; } + + local clip total tempo filter + clip=$(ffprobe -v error -show_entries format=duration -of default=nw=1:nk=1 "$file" 2>/dev/null) || return 1 + [[ $clip =~ ^[0-9]*\.?[0-9]+$ ]] || return 1 + total=$(awk -v c="$clip" -v n="$times" 'BEGIN { printf "%.8f", c * n }') + tempo=$(awk -v t="$total" -v d="$duration" 'BEGIN { printf "%.8f", t / d }') + [[ $tempo =~ ^[0-9]*\.?[0-9]+$ ]] || return 1 + + # atempo accepts one factor from 0.5 to 2. Chain factors for every value the + # settings slider permits, preserving pitch while changing playback speed. + filter= + while awk -v v="$tempo" 'BEGIN { exit !(v > 2) }'; do + filter+="atempo=2," + tempo=$(awk -v v="$tempo" 'BEGIN { printf "%.8f", v / 2 }') + done + while awk -v v="$tempo" 'BEGIN { exit !(v < 0.5) }'; do + filter+="atempo=0.5," + tempo=$(awk -v v="$tempo" 'BEGIN { printf "%.8f", v * 2 }') + done + filter+="atempo=$tempo" + + for ((i = 0; i < times; i++)); do + ffmpeg -nostdin -v error -i "$file" -filter:a "$filter" -f wav - \ + | pw-play --volume="$volume" - || return 1 + done +} + +if [[ $duration =~ ^[0-9]*\.?[0-9]+$ ]] && awk -v d="$duration" 'BEGIN { exit !(d > 0) }'; then + fit_duration || exit $? +else + play_repeatedly +fi diff --git a/tests/rules.test.js b/tests/rules.test.js index e9c202c..237b3ac 100644 --- a/tests/rules.test.js +++ b/tests/rules.test.js @@ -6,6 +6,10 @@ const assert = require("assert") const src = fs.readFileSync(path.join(__dirname, "..", "Rules.js"), "utf8").replace(/\/\/ Not a .pragma library[^\n]*/, "") const R = {} new Function("exports", src + "\n" + ["normalizeConfig", "matchingRules", "stripTags", "describeRule", "normalizeEffect", "withDefaults", "renderTemplate"].map(n => `exports.${n} = ${n}`).join("\n"))(R) +const catalogSrc = fs.readFileSync(path.join(__dirname, "..", "EffectCatalog.js"), "utf8") +const C = {} +new Function("exports", catalogSrc + "\nexports.effects = EFFECTS")(C) +const settingsSrc = fs.readFileSync(path.join(__dirname, "..", "Settings.qml"), "utf8") const cfg = R.normalizeConfig({ rules: [ @@ -58,4 +62,13 @@ assert.deepStrictEqual(all.rules[1].effects, []) assert.strictEqual(R.renderTemplate("", { summary: "Hi", body: "x" }, { name: "r" }), "Hi") assert.strictEqual(R.renderTemplate("{rule}: {summary} / {body} ({app})", { app: "A", summary: "Hi", body: "x" }, { name: "r" }), "r: Hi / x (A)") +const sound = C.effects.find(effect => effect.type === "sound") +assert.ok(sound.rows.some(row => row.key === "duration" && row.min === 0 && row.max === 30 && row.fallback === 0)) +assert.ok(sound.rows.some(row => row.key === "repeat" && row.min === 1 && row.max === 50)) +assert.strictEqual(sound.options.length, 1) +assert.deepStrictEqual(sound.options[0].values.map(value => value.value), ["message", "bell", "warning", "complete", "phone"]) +assert.ok(settingsSrc.includes("function bindService()")) +assert.ok(settingsSrc.includes("running: root.svc === null")) +assert.ok(settingsSrc.includes("onToggled: hero.toggleAttention()")) + console.log("rules: all tests passed") diff --git a/tests/sound.test.sh b/tests/sound.test.sh new file mode 100644 index 0000000..bdbf34d --- /dev/null +++ b/tests/sound.test.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +# Verifies that the repeat setting reaches the player, including when a saved +# legacy speed value is present alongside it. +set -euo pipefail + +repo=$(cd "$(dirname "$0")/.." && pwd) +log=$(mktemp) +trap 'rm -f -- "$log"' EXIT + +pw-play() { printf '%s\n' "$*" >> "$AR_SOUND_LOG"; } +export -f pw-play + +# Every UI choice must reach a different system sound through ar-effect. +sounds=(message bell warning complete phone) +files=(message-new-instant.oga bell.oga dialog-warning.oga complete.oga phone-incoming-call.oga) +for i in "${!sounds[@]}"; do + : > "$log" + payload="{\"effect\":{\"type\":\"sound\",\"sound\":\"${sounds[i]}\",\"repeat\":1,\"duration\":0},\"notification\":{},\"rule\":{\"name\":\"test\"}}" + AR_SOUND_LOG="$log" bash "$repo/bin/ar-effect" sound "$payload" + grep -Fqx -- "--volume=1 /usr/share/sounds/freedesktop/stereo/${files[i]}" "$log" +done + +# An explicit repeat count wins over a saved legacy speed value. +: > "$log" +payload='{"effect":{"type":"sound","sound":"phone","repeat":3,"speed":1,"duration":0},"notification":{},"rule":{"name":"test"}}' +AR_SOUND_LOG="$log" bash "$repo/bin/ar-effect" sound "$payload" + +[[ $(wc -l < "$log") -eq 3 ]] +[[ $(grep -Fxc -- "--volume=1 /usr/share/sounds/freedesktop/stereo/phone-incoming-call.oga" "$log") -eq 3 ]] + +# Each repeat is separately tempo-adjusted when a duration is selected. +ffprobe() { printf '1\n'; } +ffmpeg() { + printf 'ffmpeg %s\n' "$*" >> "$AR_SOUND_LOG" + printf 'wav' +} +pw-play() { + cat >/dev/null + printf 'pw-play %s\n' "$*" >> "$AR_SOUND_LOG" +} +export -f ffprobe ffmpeg pw-play +: > "$log" +AR_OPT_SOUND=bell \ +AR_OPT_DURATION=1 \ +AR_OPT_REPEAT=3 \ +AR_SOUND_LOG="$log" \ +bash "$repo/effects/sound" + +[[ $(grep -Fc -- 'ffmpeg -nostdin -v error -i /usr/share/sounds/freedesktop/stereo/bell.oga -filter:a atempo=2,atempo=1.50000000 -f wav -' "$log") -eq 3 ]] +[[ $(grep -Fxc -- 'pw-play --volume=1 -' "$log") -eq 3 ]] + +# A longer target duration slows each repetition by the corresponding factor. +: > "$log" +AR_OPT_SOUND=warning \ +AR_OPT_DURATION=4 \ +AR_OPT_REPEAT=1 \ +AR_SOUND_LOG="$log" \ +bash "$repo/effects/sound" + +grep -Fq -- '-filter:a atempo=0.5,atempo=0.50000000 -f wav -' "$log" +grep -Fqx -- 'pw-play --volume=1 -' "$log" + +echo "sound: all tests passed"