Fix sound options and panel controls

This commit is contained in:
2026-09-10 00:41:34 +01:00
parent e827ba10e4
commit aa29f017f7
8 changed files with 183 additions and 23 deletions
+11 -3
View File
@@ -85,13 +85,21 @@ var EFFECTS = [
}, },
{ {
type: "sound", label: "Sound", icon: "󰕾", type: "sound", label: "Sound", icon: "󰕾",
subtitle: "Plays a chime", subtitle: "Plays a selected chime; Fit in 0 uses its natural speed",
rows: [ 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: "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: [ 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" }
] }
] ]
}, },
{ {
+3 -2
View File
@@ -22,7 +22,8 @@ or resume every effect. A first rule is there to start: any notification that
mentions **deliveroo** shakes the screen. mentions **deliveroo** shakes the screen.
Runtime dependencies, all part of a stock Omarchy: `jq`, `busctl` (systemd), 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 optional helper for a checkout somewhere else: it links the plugin into
`~/.config/omarchy/plugins`, puts the `attention-required` command on your `~/.config/omarchy/plugins`, puts the `attention-required` command on your
PATH and enables the bell. `omarchy plugin add` never runs it. 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` | | `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) | | `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) | | | `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 | | `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_<NAME>` | | `command` | Runs a shell command. | | | | `run`; sees `AR_APP`, `AR_SUMMARY`, `AR_BODY`, `AR_RULE`, every option as `AR_OPT_<NAME>` |
+1 -1
View File
@@ -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 | | `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) | | `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) | | | `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 | | `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_<NAME>` in the environment | | `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_<NAME>` in the environment |
+35 -10
View File
@@ -25,7 +25,7 @@ Panel {
implicitWidth: button.implicitWidth implicitWidth: button.implicitWidth
implicitHeight: button.implicitHeight 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 bool paused: svc ? !svc.enabled : false
readonly property var cfg: svc ? svc.rawConfig : ({}) readonly property var cfg: svc ? svc.rawConfig : ({})
readonly property var rules: svc ? svc.rawRules : [] readonly property var rules: svc ? svc.rawRules : []
@@ -40,6 +40,25 @@ Panel {
readonly property int labelW: Style.space(96) readonly property int labelW: Style.space(96)
readonly property var catalog: Catalog.EFFECTS 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 ---------- // ---------- navigation ----------
property string page: "list" // list | rule | effect property string page: "list" // list | rule | effect
property int current: -1 // the rule the deeper pages are about property int current: -1 // the rule the deeper pages are about
@@ -185,27 +204,33 @@ Panel {
spacing: Style.space(10) spacing: Style.space(10)
PanelHero { PanelHero {
id: hero
width: parent.width width: parent.width
title: "Attention Required" title: "Attention Required"
meta: root.armedLabel() 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 foreground: root.fg
fontFamily: root.fontFamily fontFamily: root.fontFamily
readonly property bool attentionPaused: root.paused
readonly property var attentionService: root.svc
function toggleAttention() {
if (attentionService) attentionService.setEnabled(attentionPaused)
}
iconComponent: Component { iconComponent: Component {
Text { Text {
text: root.paused ? "󰂜" : "󰂞" text: hero.attentionPaused ? "󰂜" : "󰂞"
color: root.fg color: hero.foreground
opacity: root.paused ? 0.5 : 1 opacity: hero.attentionPaused ? 0.5 : 1
font.family: root.fontFamily font.family: hero.fontFamily
font.pixelSize: Style.font.display font.pixelSize: Style.font.display
} }
} }
trailingControl: Component { trailingControl: Component {
ToggleSwitch { ToggleSwitch {
checked: !root.paused checked: !hero.attentionPaused
foreground: root.fg foreground: hero.foreground
cursorPad: root.trailInset cursorPad: Style.space(6)
onToggled: if (root.svc) root.svc.setEnabled(root.paused) onToggled: hero.toggleAttention()
} }
} }
} }
BIN
View File
Binary file not shown.
+57 -7
View File
@@ -3,14 +3,23 @@
# Plays a sound. # Plays a sound.
# #
# Options (set on the effect in rules.json): # Options (set on the effect in rules.json):
# file path to a sound file; default is the freedesktop "new message" chime # sound message | bell | warning | complete | phone (default: message)
# duration fit all repeats into this many seconds; 0 uses natural speed
# intensity volume, 0.0 .. 1.0, default 1 (`volume` works too) # intensity volume, 0.0 .. 1.0, default 1 (`volume` works too)
# speed how many times to play it, default 1 (`repeat` works too) # repeat how many times to play it, default 1 (`speed` works for old rules)
set -uo pipefail set -uo pipefail
file=${AR_OPT_FILE:-/usr/share/sounds/freedesktop/stereo/message-new-instant.oga} case ${AR_OPT_SOUND:-message} in
bell) file=/usr/share/sounds/freedesktop/stereo/bell.oga ;;
warning) file=/usr/share/sounds/freedesktop/stereo/dialog-warning.oga ;;
complete) file=/usr/share/sounds/freedesktop/stereo/complete.oga ;;
phone) file=/usr/share/sounds/freedesktop/stereo/phone-incoming-call.oga ;;
*) file=/usr/share/sounds/freedesktop/stereo/message-new-instant.oga ;;
esac
volume=${AR_OPT_INTENSITY:-${AR_OPT_VOLUME:-1}} volume=${AR_OPT_INTENSITY:-${AR_OPT_VOLUME:-1}}
times=${AR_OPT_SPEED:-${AR_OPT_REPEAT:-1}} times=${AR_OPT_REPEAT:-}
[[ $times =~ ^[0-9]+$ ]] || times=${AR_OPT_SPEED:-1}
duration=${AR_OPT_DURATION:-0}
[[ $volume =~ ^[0-9]*\.?[0-9]+$ ]] || volume=1 [[ $volume =~ ^[0-9]*\.?[0-9]+$ ]] || volume=1
[[ $times =~ ^[0-9]+$ ]] || times=1 [[ $times =~ ^[0-9]+$ ]] || times=1
(( times > 50 )) && times=50 (( times > 50 )) && times=50
@@ -31,6 +40,47 @@ play() {
fi fi
} }
for ((i = 0; i < times; i++)); do play_repeatedly() {
play || exit 1 for ((i = 0; i < times; i++)); do
done 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
+13
View File
@@ -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 src = fs.readFileSync(path.join(__dirname, "..", "Rules.js"), "utf8").replace(/\/\/ Not a .pragma library[^\n]*/, "")
const R = {} const R = {}
new Function("exports", src + "\n" + ["normalizeConfig", "matchingRules", "stripTags", "describeRule", "normalizeEffect", "withDefaults", "renderTemplate"].map(n => `exports.${n} = ${n}`).join("\n"))(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({ const cfg = R.normalizeConfig({
rules: [ rules: [
@@ -58,4 +62,13 @@ assert.deepStrictEqual(all.rules[1].effects, [])
assert.strictEqual(R.renderTemplate("", { summary: "Hi", body: "<b>x</b>" }, { name: "r" }), "Hi") assert.strictEqual(R.renderTemplate("", { summary: "Hi", body: "<b>x</b>" }, { name: "r" }), "Hi")
assert.strictEqual(R.renderTemplate("{rule}: {summary} / {body} ({app})", { app: "A", summary: "Hi", body: "<b>x</b>" }, { name: "r" }), "r: Hi / x (A)") assert.strictEqual(R.renderTemplate("{rule}: {summary} / {body} ({app})", { app: "A", summary: "Hi", body: "<b>x</b>" }, { 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") console.log("rules: all tests passed")
+63
View File
@@ -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"