Compare commits

...
Author SHA1 Message Date
Brandon Thomas d0c8dffecb state endpoint 2026-05-21 22:40:15 -04:00
Brandon Thomas a71c846634 readme 2026-05-21 22:30:37 -04:00
Brandon Thomas ec4d0bcb16 jitter 2026-05-21 22:19:44 -04:00
Brandon Thomas bfc6104e66 configure jitter 2026-05-21 22:13:01 -04:00
Brandon Thomas 4eb6f16123 enhanced behavior 2026-05-21 22:01:30 -04:00
Brandon Thomas 12f7f56928 better behavior 2026-05-21 21:53:40 -04:00
Brandon Thomas fab5fa97f8 instructions 2026-05-21 21:36:27 -04:00
Brandon Thomas 7f86cc8c42 update configs 2026-05-21 19:56:28 -04:00
Brandon Thomas d4a07af223 fix pathing, configs 2026-05-21 19:48:03 -04:00
Brandon Thomas 4c8a9dc62d new tool server 2026-05-21 19:42:46 -04:00
17 changed files with 1761 additions and 47 deletions
Generated
+361 -47
View File
@@ -420,6 +420,23 @@ dependencies = [
"cpufeatures",
]
[[package]]
name = "agent-notify-server"
version = "0.0.1"
dependencies = [
"actix-web",
"anyhow",
"env_logger 0.11.9",
"log",
"rand 0.9.2",
"rodio",
"serde",
"serde_derive",
"serde_yaml",
"tokio",
"workspace-build-acceleration",
]
[[package]]
name = "ahash"
version = "0.7.8"
@@ -492,6 +509,18 @@ dependencies = [
"nix 0.23.2",
]
[[package]]
name = "alsa"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed7572b7ba83a31e20d1b48970ee402d2e3e0537dcfe0a3ff4d6eb7508617d43"
dependencies = [
"alsa-sys",
"bitflags 2.11.0",
"cfg-if",
"libc",
]
[[package]]
name = "alsa-sys"
version = "0.3.1"
@@ -602,7 +631,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -2610,6 +2639,17 @@ dependencies = [
"coreaudio-sys",
]
[[package]]
name = "coreaudio-rs"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "321077172d79c662f64f5071a03120748d5bb652f5231570141be24cfcd2bace"
dependencies = [
"bitflags 1.3.2",
"core-foundation-sys",
"coreaudio-sys",
]
[[package]]
name = "coreaudio-sys"
version = "0.2.17"
@@ -2625,9 +2665,9 @@ version = "0.13.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "74117836a5124f3629e4b474eed03e479abaf98988b4bb317e29f08cfe0e4116"
dependencies = [
"alsa",
"alsa 0.6.0",
"core-foundation-sys",
"coreaudio-rs",
"coreaudio-rs 0.10.0",
"jni 0.19.0",
"js-sys",
"lazy_static",
@@ -2636,7 +2676,7 @@ dependencies = [
"ndk 0.6.0",
"ndk-glue",
"nix 0.23.2",
"oboe",
"oboe 0.4.6",
"parking_lot 0.11.2",
"stdweb",
"thiserror 1.0.63",
@@ -2644,6 +2684,29 @@ dependencies = [
"winapi",
]
[[package]]
name = "cpal"
version = "0.15.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "873dab07c8f743075e57f524c583985fbaf745602acbe916a01539364369a779"
dependencies = [
"alsa 0.9.1",
"core-foundation-sys",
"coreaudio-rs 0.11.3",
"dasp_sample",
"jni 0.21.1",
"js-sys",
"libc",
"mach2",
"ndk 0.8.0",
"ndk-context",
"oboe 0.6.1",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"windows 0.54.0",
]
[[package]]
name = "cpufeatures"
version = "0.2.12"
@@ -2957,6 +3020,12 @@ dependencies = [
"parking_lot_core 0.9.12",
]
[[package]]
name = "dasp_sample"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f"
[[package]]
name = "data-encoding"
version = "2.5.0"
@@ -3291,7 +3360,7 @@ dependencies = [
"libc",
"option-ext",
"redox_users",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -3996,6 +4065,12 @@ dependencies = [
"zune-inflate",
]
[[package]]
name = "extended"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af9673d8203fcb076b19dfd17e38b3d4ae9f44959416ea532ce72415a6020365"
[[package]]
name = "fal_client"
version = "0.0.1"
@@ -6372,7 +6447,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc2f4eb4bc735547cfed7c0a4922cbd04a4655978c09b54f1f7b228750664c34"
dependencies = [
"cfg-if",
"windows-targets 0.48.5",
"windows-targets 0.52.6",
]
[[package]]
@@ -6665,6 +6740,15 @@ dependencies = [
"libc",
]
[[package]]
name = "mach2"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44"
dependencies = [
"libc",
]
[[package]]
name = "magic-crypt"
version = "3.1.13"
@@ -6812,7 +6896,7 @@ version = "0.0.1"
dependencies = [
"errors",
"log",
"symphonia",
"symphonia 0.5.0",
"workspace-build-acceleration",
]
@@ -7202,6 +7286,20 @@ dependencies = [
"thiserror 1.0.63",
]
[[package]]
name = "ndk"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2076a31b7010b17a38c01907c45b945e8f11495ee4dd588309718901b1f7a5b7"
dependencies = [
"bitflags 2.11.0",
"jni-sys",
"log",
"ndk-sys 0.5.0+25.2.9519653",
"num_enum 0.7.2",
"thiserror 1.0.63",
]
[[package]]
name = "ndk"
version = "0.9.0"
@@ -7260,6 +7358,15 @@ dependencies = [
"jni-sys",
]
[[package]]
name = "ndk-sys"
version = "0.5.0+25.2.9519653"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8c196769dd60fd4f363e11d948139556a344e79d451aeb2fa2fd040738ef7691"
dependencies = [
"jni-sys",
]
[[package]]
name = "ndk-sys"
version = "0.6.0+11769913"
@@ -7789,7 +7896,21 @@ dependencies = [
"ndk-context",
"num-derive 0.3.3",
"num-traits",
"oboe-sys",
"oboe-sys 0.4.5",
]
[[package]]
name = "oboe"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8b61bebd49e5d43f5f8cc7ee2891c16e0f41ec7954d36bcb6c14c5e0de867fb"
dependencies = [
"jni 0.21.1",
"ndk 0.8.0",
"ndk-context",
"num-derive 0.4.2",
"num-traits",
"oboe-sys 0.6.1",
]
[[package]]
@@ -7801,6 +7922,15 @@ dependencies = [
"cc",
]
[[package]]
name = "oboe-sys"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c8bb09a4a2b1d668170cfe0a7d5bc103f8999fb316c98099b6a9939c9f2e79d"
dependencies = [
"cc",
]
[[package]]
name = "once_cell"
version = "1.21.4"
@@ -9674,6 +9804,16 @@ dependencies = [
"syn 1.0.109",
]
[[package]]
name = "rodio"
version = "0.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7ceb6607dd738c99bc8cb28eff249b7cd5c8ec88b9db96c0608c1480d140fb1"
dependencies = [
"cpal 0.15.3",
"symphonia 0.5.5",
]
[[package]]
name = "rootly_client"
version = "0.0.1"
@@ -9887,7 +10027,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.12.1",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -10004,7 +10144,7 @@ dependencies = [
"security-framework 3.7.0",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -11569,19 +11709,38 @@ name = "symphonia"
version = "0.5.0"
dependencies = [
"lazy_static",
"symphonia-bundle-flac",
"symphonia-bundle-mp3",
"symphonia-codec-aac",
"symphonia-bundle-flac 0.5.0",
"symphonia-bundle-mp3 0.5.0",
"symphonia-codec-aac 0.5.0",
"symphonia-codec-alac",
"symphonia-codec-opus",
"symphonia-codec-pcm",
"symphonia-codec-vorbis",
"symphonia-core",
"symphonia-format-isomp4",
"symphonia-codec-pcm 0.5.0",
"symphonia-codec-vorbis 0.5.0",
"symphonia-core 0.5.0",
"symphonia-format-isomp4 0.5.0",
"symphonia-format-mkv",
"symphonia-format-ogg",
"symphonia-format-wav",
"symphonia-metadata",
"symphonia-metadata 0.5.0",
]
[[package]]
name = "symphonia"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5773a4c030a19d9bfaa090f49746ff35c75dfddfa700df7a5939d5e076a57039"
dependencies = [
"lazy_static",
"symphonia-bundle-flac 0.5.5",
"symphonia-bundle-mp3 0.5.5",
"symphonia-codec-aac 0.5.5",
"symphonia-codec-adpcm",
"symphonia-codec-pcm 0.5.5",
"symphonia-codec-vorbis 0.5.5",
"symphonia-core 0.5.5",
"symphonia-format-isomp4 0.5.5",
"symphonia-format-riff",
"symphonia-metadata 0.5.5",
]
[[package]]
@@ -11589,9 +11748,21 @@ name = "symphonia-bundle-flac"
version = "0.5.0"
dependencies = [
"log",
"symphonia-core",
"symphonia-metadata",
"symphonia-utils-xiph",
"symphonia-core 0.5.0",
"symphonia-metadata 0.5.0",
"symphonia-utils-xiph 0.5.0",
]
[[package]]
name = "symphonia-bundle-flac"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c91565e180aea25d9b80a910c546802526ffd0072d0b8974e3ebe59b686c9976"
dependencies = [
"log",
"symphonia-core 0.5.5",
"symphonia-metadata 0.5.5",
"symphonia-utils-xiph 0.5.5",
]
[[package]]
@@ -11601,8 +11772,20 @@ dependencies = [
"bitflags 1.3.2",
"lazy_static",
"log",
"symphonia-core",
"symphonia-metadata",
"symphonia-core 0.5.0",
"symphonia-metadata 0.5.0",
]
[[package]]
name = "symphonia-bundle-mp3"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4872dd6bb56bf5eac799e3e957aa1981086c3e613b27e0ac23b176054f7c57ed"
dependencies = [
"lazy_static",
"log",
"symphonia-core 0.5.5",
"symphonia-metadata 0.5.5",
]
[[package]]
@@ -11611,7 +11794,28 @@ version = "0.5.0"
dependencies = [
"lazy_static",
"log",
"symphonia-core",
"symphonia-core 0.5.0",
]
[[package]]
name = "symphonia-codec-aac"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c263845aa86881416849c1729a54c7f55164f8b96111dba59de46849e73a790"
dependencies = [
"lazy_static",
"log",
"symphonia-core 0.5.5",
]
[[package]]
name = "symphonia-codec-adpcm"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2dddc50e2bbea4cfe027441eece77c46b9f319748605ab8f3443350129ddd07f"
dependencies = [
"log",
"symphonia-core 0.5.5",
]
[[package]]
@@ -11619,7 +11823,7 @@ name = "symphonia-codec-alac"
version = "0.5.0"
dependencies = [
"log",
"symphonia-core",
"symphonia-core 0.5.0",
]
[[package]]
@@ -11627,7 +11831,7 @@ name = "symphonia-codec-opus"
version = "0.0.1"
dependencies = [
"log",
"symphonia-core",
"symphonia-core 0.5.0",
]
[[package]]
@@ -11635,7 +11839,17 @@ name = "symphonia-codec-pcm"
version = "0.5.0"
dependencies = [
"log",
"symphonia-core",
"symphonia-core 0.5.0",
]
[[package]]
name = "symphonia-codec-pcm"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e89d716c01541ad3ebe7c91ce4c8d38a7cf266a3f7b2f090b108fb0cb031d95"
dependencies = [
"log",
"symphonia-core 0.5.5",
]
[[package]]
@@ -11643,8 +11857,19 @@ name = "symphonia-codec-vorbis"
version = "0.5.0"
dependencies = [
"log",
"symphonia-core",
"symphonia-utils-xiph",
"symphonia-core 0.5.0",
"symphonia-utils-xiph 0.5.0",
]
[[package]]
name = "symphonia-codec-vorbis"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f025837c309cd69ffef572750b4a2257b59552c5399a5e49707cc5b1b85d1c73"
dependencies = [
"log",
"symphonia-core 0.5.5",
"symphonia-utils-xiph 0.5.5",
]
[[package]]
@@ -11652,7 +11877,7 @@ name = "symphonia-codec-wavpack"
version = "0.0.1"
dependencies = [
"log",
"symphonia-core",
"symphonia-core 0.5.0",
]
[[package]]
@@ -11666,15 +11891,41 @@ dependencies = [
"log",
]
[[package]]
name = "symphonia-core"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea00cc4f79b7f6bb7ff87eddc065a1066f3a43fe1875979056672c9ef948c2af"
dependencies = [
"arrayvec",
"bitflags 1.3.2",
"bytemuck",
"lazy_static",
"log",
]
[[package]]
name = "symphonia-format-isomp4"
version = "0.5.0"
dependencies = [
"encoding_rs",
"log",
"symphonia-core",
"symphonia-metadata",
"symphonia-utils-xiph",
"symphonia-core 0.5.0",
"symphonia-metadata 0.5.0",
"symphonia-utils-xiph 0.5.0",
]
[[package]]
name = "symphonia-format-isomp4"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "243739585d11f81daf8dac8d9f3d18cc7898f6c09a259675fc364b382c30e0a5"
dependencies = [
"encoding_rs",
"log",
"symphonia-core 0.5.5",
"symphonia-metadata 0.5.5",
"symphonia-utils-xiph 0.5.5",
]
[[package]]
@@ -11683,9 +11934,9 @@ version = "0.5.0"
dependencies = [
"lazy_static",
"log",
"symphonia-core",
"symphonia-metadata",
"symphonia-utils-xiph",
"symphonia-core 0.5.0",
"symphonia-metadata 0.5.0",
"symphonia-utils-xiph 0.5.0",
]
[[package]]
@@ -11693,9 +11944,21 @@ name = "symphonia-format-ogg"
version = "0.5.0"
dependencies = [
"log",
"symphonia-core",
"symphonia-metadata",
"symphonia-utils-xiph",
"symphonia-core 0.5.0",
"symphonia-metadata 0.5.0",
"symphonia-utils-xiph 0.5.0",
]
[[package]]
name = "symphonia-format-riff"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2d7c3df0e7d94efb68401d81906eae73c02b40d5ec1a141962c592d0f11a96f"
dependencies = [
"extended",
"log",
"symphonia-core 0.5.5",
"symphonia-metadata 0.5.5",
]
[[package]]
@@ -11703,8 +11966,8 @@ name = "symphonia-format-wav"
version = "0.5.0"
dependencies = [
"log",
"symphonia-core",
"symphonia-metadata",
"symphonia-core 0.5.0",
"symphonia-metadata 0.5.0",
]
[[package]]
@@ -11714,7 +11977,19 @@ dependencies = [
"encoding_rs",
"lazy_static",
"log",
"symphonia-core",
"symphonia-core 0.5.0",
]
[[package]]
name = "symphonia-metadata"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "36306ff42b9ffe6e5afc99d49e121e0bd62fe79b9db7b9681d48e29fa19e6b16"
dependencies = [
"encoding_rs",
"lazy_static",
"log",
"symphonia-core 0.5.5",
]
[[package]]
@@ -11722,22 +11997,32 @@ name = "symphonia-play"
version = "0.5.0"
dependencies = [
"clap 3.2.25",
"cpal",
"cpal 0.13.5",
"lazy_static",
"libpulse-binding",
"libpulse-simple-binding",
"log",
"pretty_env_logger",
"rb",
"symphonia",
"symphonia 0.5.0",
]
[[package]]
name = "symphonia-utils-xiph"
version = "0.5.0"
dependencies = [
"symphonia-core",
"symphonia-metadata",
"symphonia-core 0.5.0",
"symphonia-metadata 0.5.0",
]
[[package]]
name = "symphonia-utils-xiph"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee27c85ab799a338446b68eec77abf42e1a6f1bb490656e121c6e27bfbab9f16"
dependencies = [
"symphonia-core 0.5.5",
"symphonia-metadata 0.5.5",
]
[[package]]
@@ -12285,7 +12570,7 @@ dependencies = [
"getrandom 0.4.1",
"once_cell",
"rustix 1.1.4",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -13988,6 +14273,16 @@ dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows"
version = "0.54.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9252e5725dbed82865af151df558e754e4a3c2c30818359eb17465f1346a1b49"
dependencies = [
"windows-core 0.54.0",
"windows-targets 0.52.6",
]
[[package]]
name = "windows"
version = "0.58.0"
@@ -14038,6 +14333,16 @@ dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-core"
version = "0.54.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12661b9c89351d684a50a8a643ce5f608e20243b9fb84687800163429f161d65"
dependencies = [
"windows-result 0.1.2",
"windows-targets 0.52.6",
]
[[package]]
name = "windows-core"
version = "0.58.0"
@@ -14152,6 +14457,15 @@ dependencies = [
"windows-strings 0.4.2",
]
[[package]]
name = "windows-result"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8"
dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-result"
version = "0.2.0"
+3
View File
@@ -150,6 +150,9 @@ members = [
"crates/testing/test_data",
"crates/testing/test_utils",
# Standalone tool servers
"crates/tools/servers/agent_notify_server",
# Vendored code
"crates/vendor/limitation",
"crates/vendor/tauri-plugin-http",
@@ -0,0 +1,41 @@
[package]
name = "agent-notify-server"
edition = "2021"
version = "0.0.1"
authors = ["Brandon Thomas <bt@brand.io>"]
publish = false
[[bin]]
name = "agent-notify-server"
path = "src/main.rs"
[package.metadata.cargo-udeps.ignore]
normal = ["workspace-build-acceleration"]
[dependencies]
# Workspace hack (faster compile times)
workspace-build-acceleration = { workspace = true }
# Web
actix-web = { workspace = true }
# Async runtime
tokio = { workspace = true, features = ["macros", "signal", "rt-multi-thread"] }
# Audio
rodio = { version = "0.20.1", default-features = false, features = ["symphonia-all"] }
# Serialization
serde = { workspace = true }
serde_derive = { workspace = true }
serde_yaml = { workspace = true }
# Logging
log = { workspace = true }
env_logger = { workspace = true }
# Errors
anyhow = { workspace = true }
# Random
rand = { workspace = true }
@@ -0,0 +1,288 @@
# agent-notify-server
Tiny local HTTP server that plays notification sounds for long-running agents
(Claude Code, in particular). Replaces ad-hoc `afplay` bash loops that were
prone to leaving zombie processes — the server owns the audio pipeline and
exits cleanly with Ctrl+C.
## Run
```sh
cargo run --bin agent-notify-server
# or, for a release-mode binary:
cargo run --release --bin agent-notify-server
```
The server listens on `127.0.0.1:43110` by default. Override with
`HTTP_BIND_ADDRESS`. Override the config path with `NOTIFY_CONFIG_PATH`
(defaults to `crates/tools/servers/agent_notify_server/config/notify_config.yaml`).
## Endpoints
| Method | Path | Behavior |
|--------|------|----------|
| GET | `/` | Static HTML page listing the API. |
| GET | `/alert_beep` | Play `alert_beep_sound` once (mixes over any active loop). |
| GET | `/alert_done` | Play `alert_done_sound` once. |
| GET | `/alert_await` | Play `alert_await_user_input_sound` once. |
| GET | `/loop_beep` | Loop `alert_beep_sound`. Replaces any active loop. |
| GET | `/loop_done` | Loop `alert_done_sound`. Replaces any active loop. |
| GET | `/loop_await` | Loop `alert_await_user_input_sound`. Replaces any active loop. |
| GET | `/stop` | Stop everything — loops *and* queued one-shots. |
| GET | `/state` | Read-only JSON snapshot of the audio engine and loaded config. Does **not** change playback. |
Mixing rules:
- One-shots mix with whichever loop is playing.
- Requesting a new loop replaces the prior loop.
- `/stop` halts everything.
- A missing config key makes the corresponding endpoint return `404`.
### `/state` response shape
```json
{
"audio": {
"loop_playing": true,
"loop_name": "done",
"voices_active": 2,
"current_stage": 1,
"current_gap_millis": 1000,
"current_jitter_millis": 750,
"loop_pool_size": 4,
"loop_uptime_secs": 17
},
"config": {
"alert_beep_sound": "…/test_beep.wav",
"alert_done_sound": "…/smrpg_flower.wav",
"alert_await_user_input_sound": "…/smrpg_ghost.wav",
"extra_alert_beep_count": 0,
"extra_alert_done_count": 3,
"extra_alert_await_count": 3,
"gap_schedule_millis": [2000, 1000, 500, 200],
"jitter_schedule_millis": [1500, 750, 400, 180],
"escalate_waits_secs": [10, 20, 30]
}
}
```
`loop_name` reflects which endpoint started the loop (`beep` / `done` /
`await`). When idle, `loop_playing` is `false` and the loop fields collapse
to zero/null.
## Config
`config/notify_config.yaml`:
```yaml
alert_beep_sound: test_beep.wav
alert_done_sound: smrpg_flower.wav
alert_await_user_input_sound: smrpg_ghost.wav
extra_alert_beep_sounds: []
extra_alert_done_sounds:
- smrpg_specialflower.wav
- smrpg_correct.wav
extra_alert_await_sounds:
- smrpg_wrong.wav
- smrpg_drybones_crumble.wav
loop_alert_timeout_millis: 2000
loop_alert_timeout_millis_1: 1000
loop_alert_timeout_millis_2: 500
loop_alert_timeout_millis_3: 200
loop_alert_jitter_millis: 250
loop_alert_jitter_millis_1: 150
loop_alert_jitter_millis_2: 80
loop_alert_jitter_millis_3: 40
escalate_wait_1: 15
escalate_wait_2: 30
escalate_wait_3: 45
```
- Paths can be absolute, or relative to the YAML file's directory.
- WAV and MP3 are both supported (decoded via rodio + symphonia).
- `loop_alert_timeout_millis` is the gap *between* consecutive plays of a
single voice at stage 0. Omit to replay back-to-back.
- `loop_alert_timeout_millis_{1,2,3}` override the gap at each escalation
stage. Each falls back to the previous stage when unset — so you can set
just `_1: 200` to drop straight to a 200ms gap from voice 2 onward.
- `loop_alert_jitter_millis[_1|_2|_3]` add `+/- rand(0..=jitter)` to each
sleep (clamped at 0). Same fallback chain as the timeouts. Useful to
keep voices from re-aligning even when they share a gap. The *upcoming*
stage's jitter is also applied to the escalation wait itself, so voices
2/3/4 don't always land exactly on `escalate_wait_N` — their phase
drifts by `+/- rand(0..=jitter)` ms.
- **Escalation**: a `/loop_*` request starts one voice immediately. At
`escalate_wait_1` / `escalate_wait_2` / `escalate_wait_3` seconds, a
second / third / fourth concurrent voice joins the mix. New voices are
taken from `extra_alert_<state>_sounds` in order; when that pool is
exhausted the supervisor cycles back through the full pool
(primary + extras), so existing voices double up. Each voice runs in
its own thread and drifts naturally relative to the others.
- All voices in a session share the *current* stage's gap, so existing
voices also speed up when the supervisor advances stages.
## Claude Code wiring
The intended workflow:
- When Claude needs input → start a `loop_await` sound.
- When the user replies → `stop`.
- When Claude finishes a turn → start a `loop_done` sound (replacing
`loop_await` if it was still playing); the next user prompt stops it.
### `~/.claude/agent_notify.sh`
This helper probes port 43110, spawns the server with `cargo run` if nothing
is listening (waiting up to 60s for it to come up), then curls the requested
endpoint. Drop it at `~/.claude/agent_notify.sh` and `chmod +x` it.
```bash
#!/bin/bash
# Usage: agent_notify.sh await | done | stop
set -u
PORT=43110
REPO="$HOME/dev/storyteller/artcraft"
LOGFILE="/tmp/agent-notify-server.log"
case "${1:-}" in
await) endpoint="loop_await" ;;
done) endpoint="loop_done" ;;
stop) endpoint="stop" ;;
*) echo "usage: $0 await|done|stop" >&2; exit 1 ;;
esac
probe() {
curl -fsS -o /dev/null -m 0.5 "http://127.0.0.1:$PORT/" 2>/dev/null
}
if ! probe; then
if [ ! -d "$REPO" ]; then
echo "agent-notify-server: repo not found at $REPO" >&2
exit 1
fi
(
cd "$REPO" || exit 1
nohup cargo run --bin agent-notify-server >>"$LOGFILE" 2>&1 &
disown
) </dev/null >/dev/null 2>&1
for _ in $(seq 1 120); do
probe && break
sleep 0.5
done
if ! probe; then
echo "agent-notify-server: failed to start; see $LOGFILE" >&2
exit 1
fi
fi
curl -fsS -m 1 "http://127.0.0.1:$PORT/$endpoint" -o /dev/null
```
### `~/.claude/settings.json`
Merge these hooks into your existing `hooks` block. `async: true` ensures the
hook never blocks the UI while waiting for `cargo run` on the cold-start path.
```json
{
"hooks": {
"Notification": [
{
"hooks": [
{
"type": "command",
"command": "bash ~/.claude/agent_notify_on_notification.sh",
"async": true
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "bash ~/.claude/agent_notify.sh done",
"async": true
}
]
}
],
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "bash ~/.claude/agent_notify.sh stop",
"async": true
}
]
}
],
"PostToolUse": [
{
"matcher": "AskUserQuestion",
"hooks": [
{
"type": "command",
"command": "bash ~/.claude/agent_notify.sh stop",
"async": true
}
]
}
]
}
}
```
Why two stop hooks? `UserPromptSubmit` fires only for *typed* user messages, not
for `AskUserQuestion` option selections. The `PostToolUse` matcher on
`AskUserQuestion` covers that case — it fires when the tool call returns
(i.e., once you've answered), and the helper's `/stop` silences whatever
loop the `Notification` or `Stop` hook started.
Why the notification wrapper? The `Notification` hook fires for *every*
Claude Code notification — idle reminders, permission asks, background-agent
attention pings — not just "Claude needs your input". Crucially, the idle
reminder uses the *same* `message` text as a real input prompt
("Claude is waiting for your input"), so the only reliable discriminator
is the `notification_type` field.
`~/.claude/agent_notify_on_notification.sh` reads the hook payload, logs
every event to `/tmp/agent-notify-notifications.log` for audit, and
skips events with `notification_type == "idle_prompt"`. Everything else
(permission prompts, unknown future types) fires `/loop_await` — better
to ring the bell on an unknown attention prompt than miss a real one.
### Replacing the old bash-loop system
### Audit log
The Notification wrapper writes every received notification payload to
`/tmp/agent-notify-notifications.log`, tagged `FIRE` or `SKIP`. If the await
sound stops firing for a case you care about (or fires when it shouldn't),
inspect that log and adjust the `case` patterns in
`agent_notify_on_notification.sh`.
### Replacing the old bash-loop system
If you previously had `~/.claude/notify_loop.sh`, `notify_stop.sh`,
`stop_loop.sh`, `stop_stop.sh`, kill any survivors and remove them:
```sh
pkill -f claude_notify_loop_marker 2>/dev/null
pkill -f 'afplay.*smrpg' 2>/dev/null
rm -f ~/.claude/notify_loop.sh ~/.claude/notify_stop.sh \
~/.claude/stop_loop.sh ~/.claude/stop_stop.sh \
~/.claude/notify_loop.pid ~/.claude/stop_loop.pid
```
## Shutdown
Ctrl+C exits cleanly in ~0.3s, even mid-playback. The server has
`shutdown_timeout(0)` and signals the audio engine to drop any in-flight
sound the moment SIGINT arrives — no zombie afplay processes.
@@ -0,0 +1,55 @@
# Configuration for the agent_notify_server.
#
# Each *_sound key may point to a .wav or .mp3 file. If a key is omitted (or
# the file is missing) the corresponding endpoint becomes a no-op.
#
# `loop_alert_timeout_millis` is the gap *between* consecutive plays of a
# single voice (after one play finishes, before the next begins). If
# omitted, looping sounds replay back-to-back.
#
# Escalation: after a /loop_* endpoint starts a loop, additional concurrent
# voices join the mix at the configured wait times. Each new voice is
# picked from `extra_alert_<state>_sounds` in order; if the pool is
# exhausted the supervisor cycles back through it, so existing voices
# double up. Voices are independent threads and drift naturally relative
# to each other.
# Paths can be absolute, or relative to this YAML file's directory.
alert_beep_sound: test_beep.wav
alert_done_sound: /Users/bt/dev/storyteller/artcraft/frontend/apps/artcraft/app/public/resources/sound/smrpg_flower.wav
alert_await_user_input_sound: /Users/bt/dev/storyteller/artcraft/frontend/apps/artcraft/app/public/resources/sound/smrpg_ghost.wav
extra_alert_beep_sounds: []
extra_alert_done_sounds:
- /Users/bt/dev/storyteller/artcraft/frontend/apps/artcraft/app/public/resources/sound/smrpg_specialflower.wav
- /Users/bt/dev/storyteller/artcraft/frontend/apps/artcraft/app/public/resources/sound/smrpg_correct.wav
- /Users/bt/dev/storyteller/artcraft/frontend/apps/artcraft/app/public/resources/sound/smrpg_character_extrapower.wav
extra_alert_await_sounds:
- /Users/bt/dev/storyteller/artcraft/frontend/apps/artcraft/app/public/resources/sound/smrpg_wrong.wav
- /Users/bt/dev/storyteller/artcraft/frontend/apps/artcraft/app/public/resources/sound/smrpg_drybones_crumble.wav
- /Users/bt/dev/storyteller/artcraft/frontend/apps/artcraft/app/public/resources/sound/smrpg_enemy_spikethrow.wav
loop_alert_timeout_millis: 2000
# Optional per-stage gaps. Each falls back to the previous stage when unset,
# so leaving them out keeps the loop at `loop_alert_timeout_millis` the whole
# time; specifying them lets the loop accelerate as it escalates.
loop_alert_timeout_millis_1: 1000
loop_alert_timeout_millis_2: 500
loop_alert_timeout_millis_3: 200
# Random +/- jitter applied to each sleep. Same fallback rules as the timeout
# schedule above. A fresh random offset is drawn on every iteration of every
# voice, so voices never re-align. Set values close to the gap for chaos;
# set to 0 to disable jitter at that stage.
loop_alert_jitter_millis: 1500
loop_alert_jitter_millis_1: 750
loop_alert_jitter_millis_2: 400
loop_alert_jitter_millis_3: 180
# Seconds-from-loop-start at which voices 2, 3, and 4 join the mix.
escalate_wait_1: 10
escalate_wait_2: 20
escalate_wait_3: 30
@@ -0,0 +1,507 @@
//! Background-thread audio engine for the notify server.
//!
//! The engine runs on a dedicated OS thread that owns the cpal `OutputStream`
//! (which is `!Send` on some platforms). All callers — including actix
//! handlers — interact with it through [`AudioPlayerHandle`], which forwards
//! commands over an mpsc channel.
//!
//! The engine maintains two kinds of mixing channels at the cpal output:
//!
//! - **`oneshot_sink`** — fire-and-forget sounds queued by `play_once`. Reused
//! for the lifetime of the engine via `clear() + play()` so subsequent
//! appends keep working after a `StopAll`.
//! - **A loop session** — a supervisor thread that escalates over time. It
//! starts one looping iterator immediately, then adds a second, third, and
//! fourth concurrent iterator at the configured escalation times. Each
//! iterator owns its own `Sink` and runs in its own thread, so the voices
//! drift naturally relative to one another. Replacing the loop session
//! (`PlayLoop` while one is running) or `StopAll` signals every iterator
//! and the supervisor via a shared `Arc<AtomicBool>` polled every 50ms.
//!
//! Because every channel shares the same `OutputStreamHandle`, one-shots
//! mix on top of however many loop voices are currently running.
use std::fs::File;
use std::io::BufReader;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::Mutex;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::mpsc::{self, Receiver, Sender};
use std::thread::{self, JoinHandle};
use std::time::{Duration, Instant};
use rand::Rng;
use rodio::{Decoder, OutputStream, OutputStreamHandle, Sink};
use serde_derive::Serialize;
const STOP_POLL_INTERVAL: Duration = Duration::from_millis(50);
#[derive(Clone)]
pub struct AudioPlayerHandle {
tx: Sender<AudioCommand>,
status: Arc<Mutex<InternalStatus>>,
}
impl AudioPlayerHandle {
pub fn play_once(&self, path: PathBuf) {
let _ = self.tx.send(AudioCommand::PlayOnce(path));
}
pub fn play_loop(&self, spec: LoopSpec) {
let _ = self.tx.send(AudioCommand::PlayLoop(spec));
}
pub fn stop_all(&self) {
let _ = self.tx.send(AudioCommand::StopAll);
}
pub fn shutdown(&self) {
let _ = self.tx.send(AudioCommand::Shutdown);
}
/// Snapshot of the engine state for the `/state` endpoint. Read-only —
/// does not change playback.
pub fn status(&self) -> EngineStatus {
let s = self.status.lock().unwrap_or_else(|e| e.into_inner());
EngineStatus {
loop_playing: s.loop_playing,
loop_name: s.loop_name.clone(),
voices_active: s.voices_active,
current_stage: s.current_stage,
current_gap_millis: s.current_gap_millis,
current_jitter_millis: s.current_jitter_millis,
loop_pool_size: s.loop_pool_size,
loop_uptime_secs: s.loop_started.map(|t| t.elapsed().as_secs()),
}
}
}
#[derive(Clone, Debug, Default, Serialize)]
pub struct EngineStatus {
pub loop_playing: bool,
pub loop_name: Option<String>,
pub voices_active: u32,
pub current_stage: u32,
pub current_gap_millis: u64,
pub current_jitter_millis: u64,
pub loop_pool_size: u32,
pub loop_uptime_secs: Option<u64>,
}
#[derive(Default)]
struct InternalStatus {
loop_playing: bool,
loop_name: Option<String>,
voices_active: u32,
current_stage: u32,
current_gap_millis: u64,
current_jitter_millis: u64,
loop_pool_size: u32,
loop_started: Option<Instant>,
}
impl InternalStatus {
fn reset_loop(&mut self) {
self.loop_playing = false;
self.loop_name = None;
self.voices_active = 0;
self.current_stage = 0;
self.current_gap_millis = 0;
self.current_jitter_millis = 0;
self.loop_pool_size = 0;
self.loop_started = None;
}
}
#[derive(Clone, Debug)]
pub struct LoopSpec {
/// Short tag identifying which endpoint started this loop ("beep" /
/// "done" / "await"). Surfaced in `/state` so callers can see what's
/// playing.
pub name: String,
/// Ordered pool of sounds: primary first, then extras. The supervisor
/// indexes this with `layer % pool.len()`, so when extras run out it
/// cycles back through the pool (doubling already-playing voices).
pub pool: Vec<PathBuf>,
/// Gap (millis) between consecutive plays at each escalation stage:
/// [initial, after wait_1, after wait_2, after wait_3]. All iterators in
/// the session share the current stage's value, so existing voices also
/// speed up when the supervisor moves to a faster stage.
pub gap_millis_schedule: [u64; 4],
/// Max +/- jitter (millis) at each escalation stage. Same shape and
/// sharing semantics as `gap_millis_schedule`.
pub jitter_millis_schedule: [u64; 4],
/// Wall-clock seconds from loop start when layers 2, 3, 4 should join.
pub escalate_waits_secs: [u64; 3],
}
pub fn spawn_audio_player() -> (AudioPlayerHandle, JoinHandle<()>) {
let (tx, rx) = mpsc::channel::<AudioCommand>();
let status = Arc::new(Mutex::new(InternalStatus::default()));
let status_for_engine = status.clone();
let thread = thread::Builder::new()
.name("agent-notify-audio".to_string())
.spawn(move || run_audio_engine(rx, status_for_engine))
.expect("spawn audio engine thread");
(AudioPlayerHandle { tx, status }, thread)
}
enum AudioCommand {
PlayOnce(PathBuf),
PlayLoop(LoopSpec),
StopAll,
Shutdown,
}
fn run_audio_engine(rx: Receiver<AudioCommand>, status: Arc<Mutex<InternalStatus>>) {
let (_stream, stream_handle) = match OutputStream::try_default() {
Ok(s) => s,
Err(e) => {
log::error!("audio engine: failed to open default output stream: {}", e);
while rx.recv().is_ok() {}
return;
}
};
let oneshot_sink = match Sink::try_new(&stream_handle) {
Ok(s) => s,
Err(e) => {
log::error!("audio engine: failed to create oneshot sink: {}", e);
while rx.recv().is_ok() {}
return;
}
};
let mut current_loop: Option<LoopController> = None;
while let Ok(cmd) = rx.recv() {
match cmd {
AudioCommand::PlayOnce(path) => {
if let Err(e) = enqueue_sound(&oneshot_sink, &path) {
log::warn!("play_once {}: {}", path.display(), e);
}
}
AudioCommand::PlayLoop(spec) => {
stop_current_loop(&mut current_loop, &status);
if spec.pool.is_empty() {
log::warn!("play_loop: empty sound pool, ignoring");
continue;
}
current_loop = Some(start_loop_supervisor(&stream_handle, spec, status.clone()));
}
AudioCommand::StopAll => {
stop_current_loop(&mut current_loop, &status);
oneshot_sink.clear();
oneshot_sink.play();
}
AudioCommand::Shutdown => {
stop_current_loop(&mut current_loop, &status);
oneshot_sink.stop();
return;
}
}
}
stop_current_loop(&mut current_loop, &status);
oneshot_sink.stop();
}
fn start_loop_supervisor(
stream_handle: &OutputStreamHandle,
spec: LoopSpec,
status: Arc<Mutex<InternalStatus>>,
) -> LoopController {
// Initialize the visible status before any iterator starts, so a /state
// query racing the spawn sees the new loop, not the previous one's tail.
{
let mut s = status.lock().unwrap_or_else(|e| e.into_inner());
s.loop_playing = true;
s.loop_name = Some(spec.name.clone());
s.voices_active = 1;
s.current_stage = 0;
s.current_gap_millis = spec.gap_millis_schedule[0];
s.current_jitter_millis = spec.jitter_millis_schedule[0];
s.loop_pool_size = spec.pool.len() as u32;
s.loop_started = Some(Instant::now());
}
let stop = Arc::new(AtomicBool::new(false));
let stop_for_thread = stop.clone();
let stream_handle = stream_handle.clone();
let thread = thread::Builder::new()
.name("agent-notify-loop-supervisor".to_string())
.spawn(move || run_loop_supervisor(stream_handle, spec, stop_for_thread, status))
.expect("spawn loop supervisor thread");
LoopController { stop, thread: Some(thread) }
}
fn stop_current_loop(
current_loop: &mut Option<LoopController>,
status: &Arc<Mutex<InternalStatus>>,
) {
if let Some(mut lc) = current_loop.take() {
lc.stop.store(true, Ordering::SeqCst);
if let Some(thread) = lc.thread.take() {
if let Err(e) = thread.join() {
log::warn!("audio loop supervisor panicked while shutting down: {:?}", e);
}
}
}
let mut s = status.lock().unwrap_or_else(|e| e.into_inner());
s.reset_loop();
}
fn run_loop_supervisor(
stream_handle: OutputStreamHandle,
spec: LoopSpec,
stop: Arc<AtomicBool>,
status: Arc<Mutex<InternalStatus>>,
) {
let pool_len = spec.pool.len();
if pool_len == 0 {
return;
}
let gap_schedule = spec.gap_millis_schedule;
let jitter_schedule = spec.jitter_millis_schedule;
let gap_millis = Arc::new(AtomicU64::new(gap_schedule[0]));
let jitter_millis = Arc::new(AtomicU64::new(jitter_schedule[0]));
let mut iterators: Vec<JoinHandle<()>> = Vec::with_capacity(4);
iterators.push(spawn_iterator(
&stream_handle,
spec.pool[0].clone(),
gap_millis.clone(),
jitter_millis.clone(),
stop.clone(),
0,
));
// Convert absolute escalation times into deltas relative to the previous
// escalation. `saturating_sub` handles out-of-order config (e.g. wait_2
// smaller than wait_1) by collapsing the interval to zero.
let waits = spec.escalate_waits_secs;
let intervals = [
waits[0],
waits[1].saturating_sub(waits[0]),
waits[2].saturating_sub(waits[1]),
];
let mut rng = rand::rng();
for layer in 0..3usize {
// Jitter the spawn phase by the *upcoming* stage's jitter, so voices
// 2/3/4 don't all land exactly on `escalate_wait_N`.
let nominal_interval_ms = intervals[layer].saturating_mul(1000);
let phase_jitter_ms = jitter_schedule[layer + 1];
let interval_ms = jittered_gap_millis(nominal_interval_ms, phase_jitter_ms, &mut rng);
if !sleep_with_stop(Duration::from_millis(interval_ms), &stop) {
break;
}
let next_gap = gap_schedule[layer + 1];
let next_jitter = jitter_schedule[layer + 1];
let prev_gap = gap_millis.swap(next_gap, Ordering::Relaxed);
let prev_jitter = jitter_millis.swap(next_jitter, Ordering::Relaxed);
if prev_gap != next_gap || prev_jitter != next_jitter {
log::info!(
"escalation stage {}: gap now {}ms +/- {}ms",
layer + 1,
next_gap,
next_jitter
);
}
let pool_idx = (layer + 1) % pool_len;
iterators.push(spawn_iterator(
&stream_handle,
spec.pool[pool_idx].clone(),
gap_millis.clone(),
jitter_millis.clone(),
stop.clone(),
layer + 1,
));
let mut s = status.lock().unwrap_or_else(|e| e.into_inner());
s.voices_active = iterators.len() as u32;
s.current_stage = (layer + 1) as u32;
s.current_gap_millis = next_gap;
s.current_jitter_millis = next_jitter;
}
// Wait until something signals stop, then drain iterator threads.
while !stop.load(Ordering::SeqCst) {
thread::sleep(STOP_POLL_INTERVAL);
}
for t in iterators {
if let Err(e) = t.join() {
log::warn!("audio loop iterator panicked: {:?}", e);
}
}
}
fn spawn_iterator(
stream_handle: &OutputStreamHandle,
path: PathBuf,
gap_millis: Arc<AtomicU64>,
jitter_millis: Arc<AtomicU64>,
stop: Arc<AtomicBool>,
layer: usize,
) -> JoinHandle<()> {
log::info!("loop voice {} starting: {}", layer + 1, path.display());
let stream_handle = stream_handle.clone();
thread::Builder::new()
.name(format!("agent-notify-loop-{}", layer + 1))
.spawn(move || {
run_loop_iterator(stream_handle, path, gap_millis, jitter_millis, stop)
})
.expect("spawn loop iterator thread")
}
fn run_loop_iterator(
stream_handle: OutputStreamHandle,
path: PathBuf,
gap_millis: Arc<AtomicU64>,
jitter_millis: Arc<AtomicU64>,
stop: Arc<AtomicBool>,
) {
let sink = match Sink::try_new(&stream_handle) {
Ok(s) => s,
Err(e) => {
log::warn!("loop iterator: failed to create sink: {}", e);
return;
}
};
let mut rng = rand::rng();
while !stop.load(Ordering::SeqCst) {
if let Err(e) = enqueue_sound(&sink, &path) {
log::warn!("loop iterator: failed to enqueue {}: {}", path.display(), e);
return;
}
while !sink.empty() {
if stop.load(Ordering::SeqCst) {
sink.stop();
return;
}
thread::sleep(STOP_POLL_INTERVAL);
}
if stop.load(Ordering::SeqCst) {
return;
}
let gap = gap_millis.load(Ordering::Relaxed);
let jitter = jitter_millis.load(Ordering::Relaxed);
let sleep_ms = jittered_gap_millis(gap, jitter, &mut rng);
log::debug!(
"loop iterator sleeping {}ms (gap={}, jitter=±{})",
sleep_ms,
gap,
jitter
);
if sleep_ms > 0
&& !sleep_with_stop(Duration::from_millis(sleep_ms), &stop)
{
return;
}
}
}
/// Apply a uniformly random offset in `[-jitter, +jitter]` to `gap`. A fresh
/// random value is drawn on every call, so each iteration of each voice gets
/// its own jitter — voices never re-align. The lower bound saturates at 0
/// so the result can't wrap below zero.
fn jittered_gap_millis<R: Rng>(gap: u64, jitter: u64, rng: &mut R) -> u64 {
if jitter == 0 {
return gap;
}
// Cap at i64::MAX/2 so the negation below can't overflow. In practice
// jitter values are at most a few seconds; the cap is just paranoia.
let span = (jitter.min(i64::MAX as u64 / 2)) as i64;
let delta = rng.random_range(-span..=span);
if delta >= 0 {
gap.saturating_add(delta as u64)
} else {
gap.saturating_sub(delta.unsigned_abs())
}
}
fn enqueue_sound(sink: &Sink, path: &Path) -> anyhow::Result<()> {
let file = BufReader::new(File::open(path)?);
let source = Decoder::new(file)?;
sink.append(source);
Ok(())
}
/// Sleep for `total` while polling `stop` every [`STOP_POLL_INTERVAL`].
/// Returns `true` if the full duration elapsed, `false` if `stop` was set.
fn sleep_with_stop(total: Duration, stop: &AtomicBool) -> bool {
let started = Instant::now();
while started.elapsed() < total {
if stop.load(Ordering::SeqCst) {
return false;
}
let remaining = total.saturating_sub(started.elapsed());
thread::sleep(remaining.min(STOP_POLL_INTERVAL));
}
!stop.load(Ordering::SeqCst)
}
struct LoopController {
stop: Arc<AtomicBool>,
thread: Option<JoinHandle<()>>,
}
#[cfg(test)]
mod tests {
use super::*;
mod jittered_gap_millis_tests {
use super::*;
#[test]
fn zero_jitter_returns_gap_unchanged() {
let mut rng = rand::rng();
for _ in 0..50 {
assert_eq!(jittered_gap_millis(500, 0, &mut rng), 500);
}
}
#[test]
fn nonzero_jitter_stays_within_bounds_and_varies() {
let mut rng = rand::rng();
let mut seen_below = false;
let mut seen_above = false;
for _ in 0..500 {
let v = jittered_gap_millis(1000, 200, &mut rng);
assert!(v >= 800 && v <= 1200, "out of bounds: {}", v);
if v < 1000 { seen_below = true; }
if v > 1000 { seen_above = true; }
}
assert!(seen_below && seen_above, "expected jitter on both sides");
}
#[test]
fn negative_jitter_is_clamped_to_zero() {
let mut rng = rand::rng();
for _ in 0..200 {
let v = jittered_gap_millis(50, 1000, &mut rng);
assert!(v <= 1050);
// saturating_sub at the floor — never wraps below 0.
}
}
#[test]
fn rerolls_per_call_so_consecutive_values_differ() {
// With jitter=1000 there are 2001 possible outcomes; a sample of 50
// is overwhelmingly unlikely to be all-equal under uniform random.
let mut rng = rand::rng();
let samples: std::collections::HashSet<u64> = (0..50)
.map(|_| jittered_gap_millis(5_000, 1_000, &mut rng))
.collect();
assert!(samples.len() > 10, "expected >10 distinct values, got {}", samples.len());
}
}
}
@@ -0,0 +1,138 @@
use std::fs::File;
use std::io::BufReader;
use std::path::{Path, PathBuf};
use serde_derive::{Deserialize, Serialize};
/// Default config location, resolved at compile time relative to the crate
/// root so `cargo run` works regardless of CWD.
pub const DEFAULT_CONFIG_PATH: &str =
concat!(env!("CARGO_MANIFEST_DIR"), "/config/notify_config.yaml");
pub const DEFAULT_ESCALATE_WAIT_1_SECS: u64 = 15;
pub const DEFAULT_ESCALATE_WAIT_2_SECS: u64 = 30;
pub const DEFAULT_ESCALATE_WAIT_3_SECS: u64 = 45;
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
pub struct NotifyConfig {
pub alert_beep_sound: Option<PathBuf>,
pub alert_done_sound: Option<PathBuf>,
pub alert_await_user_input_sound: Option<PathBuf>,
#[serde(default)]
pub extra_alert_beep_sounds: Vec<PathBuf>,
#[serde(default)]
pub extra_alert_done_sounds: Vec<PathBuf>,
#[serde(default)]
pub extra_alert_await_sounds: Vec<PathBuf>,
pub loop_alert_timeout_millis: Option<u64>,
/// Gap (millis) between plays once the loop has been running for
/// `escalate_wait_1` seconds. Falls back to the previous stage.
pub loop_alert_timeout_millis_1: Option<u64>,
/// Gap (millis) after `escalate_wait_2`. Falls back to stage 1.
pub loop_alert_timeout_millis_2: Option<u64>,
/// Gap (millis) after `escalate_wait_3`. Falls back to stage 2.
pub loop_alert_timeout_millis_3: Option<u64>,
/// Maximum +/- random jitter (millis) applied to each sleep at stage 0.
/// Each sleep becomes `timeout +/- rand(0..=jitter)`, clamped at 0.
pub loop_alert_jitter_millis: Option<u64>,
/// Jitter at stage 1. Falls back to the previous stage.
pub loop_alert_jitter_millis_1: Option<u64>,
/// Jitter at stage 2. Falls back to stage 1.
pub loop_alert_jitter_millis_2: Option<u64>,
/// Jitter at stage 3. Falls back to stage 2.
pub loop_alert_jitter_millis_3: Option<u64>,
/// Seconds after a loop starts before a second concurrent voice is added.
pub escalate_wait_1: Option<u64>,
/// Seconds before a third concurrent voice is added.
pub escalate_wait_2: Option<u64>,
/// Seconds before a fourth concurrent voice is added.
pub escalate_wait_3: Option<u64>,
}
impl NotifyConfig {
pub fn read_from_file<P: AsRef<Path>>(path: P) -> anyhow::Result<Self> {
let path = path.as_ref();
let file = File::open(path)?;
let reader = BufReader::new(file);
let mut config: NotifyConfig = serde_yaml::from_reader(reader)?;
if let Some(base) = path.parent() {
config.resolve_relative_paths(base);
}
Ok(config)
}
pub fn read_from_file_or_default<P: AsRef<Path>>(path: P) -> Self {
match Self::read_from_file(&path) {
Ok(c) => {
log::info!("loaded notify config from {}", path.as_ref().display());
c
}
Err(e) => {
log::warn!(
"failed to load notify config from {}: {} — falling back to empty config",
path.as_ref().display(),
e
);
Self::default()
}
}
}
pub fn escalate_waits_secs(&self) -> [u64; 3] {
[
self.escalate_wait_1.unwrap_or(DEFAULT_ESCALATE_WAIT_1_SECS),
self.escalate_wait_2.unwrap_or(DEFAULT_ESCALATE_WAIT_2_SECS),
self.escalate_wait_3.unwrap_or(DEFAULT_ESCALATE_WAIT_3_SECS),
]
}
/// Per-stage gap (millis) between plays of a single voice. Each entry
/// falls back to the previous stage when unset, so a config that only
/// specifies `loop_alert_timeout_millis_1: 200` will use that 200ms gap
/// for stages 2 and 3 too.
pub fn loop_gap_schedule_millis(&self) -> [u64; 4] {
let g0 = self.loop_alert_timeout_millis.unwrap_or(0);
let g1 = self.loop_alert_timeout_millis_1.unwrap_or(g0);
let g2 = self.loop_alert_timeout_millis_2.unwrap_or(g1);
let g3 = self.loop_alert_timeout_millis_3.unwrap_or(g2);
[g0, g1, g2, g3]
}
/// Per-stage jitter (millis). Same fallback semantics as the gap schedule.
pub fn loop_jitter_schedule_millis(&self) -> [u64; 4] {
let j0 = self.loop_alert_jitter_millis.unwrap_or(0);
let j1 = self.loop_alert_jitter_millis_1.unwrap_or(j0);
let j2 = self.loop_alert_jitter_millis_2.unwrap_or(j1);
let j3 = self.loop_alert_jitter_millis_3.unwrap_or(j2);
[j0, j1, j2, j3]
}
fn resolve_relative_paths(&mut self, base: &Path) {
resolve_opt(&mut self.alert_beep_sound, base);
resolve_opt(&mut self.alert_done_sound, base);
resolve_opt(&mut self.alert_await_user_input_sound, base);
resolve_vec(&mut self.extra_alert_beep_sounds, base);
resolve_vec(&mut self.extra_alert_done_sounds, base);
resolve_vec(&mut self.extra_alert_await_sounds, base);
}
}
fn resolve_opt(slot: &mut Option<PathBuf>, base: &Path) {
if let Some(p) = slot.as_ref() {
if p.is_relative() {
*slot = Some(base.join(p));
}
}
}
fn resolve_vec(slot: &mut Vec<PathBuf>, base: &Path) {
for p in slot.iter_mut() {
if p.is_relative() {
*p = base.join(&*p);
}
}
}
@@ -0,0 +1,35 @@
use std::path::PathBuf;
use actix_web::{HttpResponse, Responder, web};
use crate::server_state::ServerState;
pub async fn alert_beep_handler(state: web::Data<ServerState>) -> impl Responder {
play_once_or_404(&state, state.config.alert_beep_sound.clone(), "alert_beep_sound")
}
pub async fn alert_done_handler(state: web::Data<ServerState>) -> impl Responder {
play_once_or_404(&state, state.config.alert_done_sound.clone(), "alert_done_sound")
}
pub async fn alert_await_handler(state: web::Data<ServerState>) -> impl Responder {
play_once_or_404(
&state,
state.config.alert_await_user_input_sound.clone(),
"alert_await_user_input_sound",
)
}
fn play_once_or_404(
state: &ServerState,
maybe_path: Option<PathBuf>,
config_key: &str,
) -> HttpResponse {
match maybe_path {
Some(path) => {
state.audio.play_once(path);
HttpResponse::Ok().body("ok\n")
}
None => HttpResponse::NotFound().body(format!("{} not configured\n", config_key)),
}
}
@@ -0,0 +1,62 @@
use std::path::PathBuf;
use actix_web::{HttpResponse, Responder, web};
use crate::audio_player::LoopSpec;
use crate::server_state::ServerState;
pub async fn loop_beep_handler(state: web::Data<ServerState>) -> impl Responder {
play_loop_or_404(
&state,
"beep",
state.config.alert_beep_sound.clone(),
state.config.extra_alert_beep_sounds.clone(),
"alert_beep_sound",
)
}
pub async fn loop_done_handler(state: web::Data<ServerState>) -> impl Responder {
play_loop_or_404(
&state,
"done",
state.config.alert_done_sound.clone(),
state.config.extra_alert_done_sounds.clone(),
"alert_done_sound",
)
}
pub async fn loop_await_handler(state: web::Data<ServerState>) -> impl Responder {
play_loop_or_404(
&state,
"await",
state.config.alert_await_user_input_sound.clone(),
state.config.extra_alert_await_sounds.clone(),
"alert_await_user_input_sound",
)
}
fn play_loop_or_404(
state: &ServerState,
name: &str,
primary: Option<PathBuf>,
extras: Vec<PathBuf>,
config_key: &str,
) -> HttpResponse {
let Some(primary) = primary else {
return HttpResponse::NotFound().body(format!("{} not configured\n", config_key));
};
let mut pool = Vec::with_capacity(1 + extras.len());
pool.push(primary);
pool.extend(extras);
let spec = LoopSpec {
name: name.to_string(),
pool,
gap_millis_schedule: state.config.loop_gap_schedule_millis(),
jitter_millis_schedule: state.config.loop_jitter_schedule_millis(),
escalate_waits_secs: state.config.escalate_waits_secs(),
};
state.audio.play_loop(spec);
HttpResponse::Ok().body("ok\n")
}
@@ -0,0 +1,5 @@
pub mod alert_handlers;
pub mod loop_handlers;
pub mod root_handler;
pub mod state_handler;
pub mod stop_handler;
@@ -0,0 +1,9 @@
use actix_web::{HttpResponse, Responder};
const INDEX_HTML: &str = include_str!("../../static/index.html");
pub async fn root_handler() -> impl Responder {
HttpResponse::Ok()
.content_type("text/html; charset=utf-8")
.body(INDEX_HTML)
}
@@ -0,0 +1,51 @@
use std::path::PathBuf;
use actix_web::{HttpResponse, Responder, web};
use serde_derive::Serialize;
use crate::audio_player::EngineStatus;
use crate::server_state::ServerState;
pub async fn state_handler(state: web::Data<ServerState>) -> impl Responder {
let resp = StateResponse {
audio: state.audio.status(),
config: ConfigSummary::from_state(&state),
};
HttpResponse::Ok().json(resp)
}
#[derive(Serialize)]
struct StateResponse {
audio: EngineStatus,
config: ConfigSummary,
}
#[derive(Serialize)]
struct ConfigSummary {
alert_beep_sound: Option<PathBuf>,
alert_done_sound: Option<PathBuf>,
alert_await_user_input_sound: Option<PathBuf>,
extra_alert_beep_count: usize,
extra_alert_done_count: usize,
extra_alert_await_count: usize,
gap_schedule_millis: [u64; 4],
jitter_schedule_millis: [u64; 4],
escalate_waits_secs: [u64; 3],
}
impl ConfigSummary {
fn from_state(state: &ServerState) -> Self {
let c = &state.config;
Self {
alert_beep_sound: c.alert_beep_sound.clone(),
alert_done_sound: c.alert_done_sound.clone(),
alert_await_user_input_sound: c.alert_await_user_input_sound.clone(),
extra_alert_beep_count: c.extra_alert_beep_sounds.len(),
extra_alert_done_count: c.extra_alert_done_sounds.len(),
extra_alert_await_count: c.extra_alert_await_sounds.len(),
gap_schedule_millis: c.loop_gap_schedule_millis(),
jitter_schedule_millis: c.loop_jitter_schedule_millis(),
escalate_waits_secs: c.escalate_waits_secs(),
}
}
}
@@ -0,0 +1,8 @@
use actix_web::{HttpResponse, Responder, web};
use crate::server_state::ServerState;
pub async fn stop_handler(state: web::Data<ServerState>) -> impl Responder {
state.audio.stop_all();
HttpResponse::Ok().body("ok\n")
}
@@ -0,0 +1,93 @@
//! Tiny local HTTP server that plays configured notification sounds.
//!
//! Default bind address: `127.0.0.1:43110`. Override with `HTTP_BIND_ADDRESS`.
//!
//! Audio playback runs on a dedicated OS thread (see [`audio_player`]). The
//! actix server itself is single-worker — we expect only the local agent to
//! call it. On SIGINT we tell actix to skip its grace period and we ship a
//! `Shutdown` command to the audio thread so any in-progress sound is dropped
//! immediately.
use std::env;
use std::path::PathBuf;
use std::sync::Arc;
use actix_web::middleware::Logger;
use actix_web::web::Data;
use actix_web::{App, HttpServer, web};
use crate::audio_player::spawn_audio_player;
use crate::config::{DEFAULT_CONFIG_PATH, NotifyConfig};
use crate::endpoints::alert_handlers::{
alert_await_handler, alert_beep_handler, alert_done_handler,
};
use crate::endpoints::loop_handlers::{
loop_await_handler, loop_beep_handler, loop_done_handler,
};
use crate::endpoints::root_handler::root_handler;
use crate::endpoints::state_handler::state_handler;
use crate::endpoints::stop_handler::stop_handler;
use crate::server_state::ServerState;
pub mod audio_player;
pub mod config;
pub mod endpoints;
pub mod server_state;
const DEFAULT_BIND_ADDRESS: &str = "127.0.0.1:43110";
const DEFAULT_RUST_LOG: &str = "info,actix_web=info";
#[actix_web::main]
async fn main() -> anyhow::Result<()> {
if env::var_os("RUST_LOG").is_none() {
// SAFETY: single-threaded at this point — no other thread can read env.
unsafe { env::set_var("RUST_LOG", DEFAULT_RUST_LOG); }
}
env_logger::init();
let bind_address = env::var("HTTP_BIND_ADDRESS")
.unwrap_or_else(|_| DEFAULT_BIND_ADDRESS.to_string());
let config_path: PathBuf = env::var("NOTIFY_CONFIG_PATH")
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from(DEFAULT_CONFIG_PATH));
let config = Arc::new(NotifyConfig::read_from_file_or_default(&config_path));
let (audio_handle, audio_thread) = spawn_audio_player();
let state = ServerState {
config: config.clone(),
audio: audio_handle.clone(),
};
log::info!("agent-notify-server listening on http://{}", bind_address);
let server = HttpServer::new(move || {
App::new()
.app_data(Data::new(state.clone()))
.wrap(Logger::default())
.route("/", web::get().to(root_handler))
.route("/alert_beep", web::get().to(alert_beep_handler))
.route("/alert_done", web::get().to(alert_done_handler))
.route("/alert_await", web::get().to(alert_await_handler))
.route("/loop_beep", web::get().to(loop_beep_handler))
.route("/loop_done", web::get().to(loop_done_handler))
.route("/loop_await", web::get().to(loop_await_handler))
.route("/stop", web::get().to(stop_handler))
.route("/state", web::get().to(state_handler))
})
.bind(&bind_address)?
.workers(1)
.shutdown_timeout(0)
.run()
.await?;
log::info!("server stopped; shutting down audio engine");
audio_handle.shutdown();
if let Err(e) = audio_thread.join() {
log::warn!("audio thread join failed: {:?}", e);
}
let _ = server;
Ok(())
}
@@ -0,0 +1,10 @@
use std::sync::Arc;
use crate::audio_player::AudioPlayerHandle;
use crate::config::NotifyConfig;
#[derive(Clone)]
pub struct ServerState {
pub config: Arc<NotifyConfig>,
pub audio: AudioPlayerHandle,
}
@@ -0,0 +1,95 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Agent Notify Server</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
max-width: 720px; margin: 40px auto; padding: 0 16px; color: #222; }
h1 { margin-bottom: 4px; }
.sub { color: #666; margin-top: 0; }
code { background: #f3f3f3; padding: 1px 5px; border-radius: 3px; }
table { border-collapse: collapse; width: 100%; margin-top: 18px; }
th, td { text-align: left; padding: 8px 10px; border-bottom: 1px solid #eee;
vertical-align: top; }
th { background: #fafafa; font-weight: 600; }
.group { margin-top: 32px; }
.pill { display: inline-block; padding: 1px 8px; border-radius: 10px;
background: #eef; color: #224; font-size: 12px; margin-left: 6px; }
</style>
</head>
<body>
<h1>Agent Notify Server</h1>
<p class="sub">Local audio-cue HTTP server for long-running agents.</p>
<div class="group">
<h2>One-shot alerts</h2>
<table>
<tr><th>Endpoint</th><th>Behavior</th></tr>
<tr><td><code>GET /alert_beep</code></td><td>Play <em>alert_beep</em> once.</td></tr>
<tr><td><code>GET /alert_done</code></td><td>Play <em>alert_done</em> once.</td></tr>
<tr><td><code>GET /alert_await</code></td><td>Play <em>alert_await_user_input</em> once.</td></tr>
</table>
</div>
<div class="group">
<h2>Looping alerts <span class="pill">replaces existing loop</span></h2>
<table>
<tr><th>Endpoint</th><th>Behavior</th></tr>
<tr><td><code>GET /loop_beep</code></td><td>Loop <em>alert_beep</em>. Replaces any prior loop.</td></tr>
<tr><td><code>GET /loop_done</code></td><td>Loop <em>alert_done</em>. Replaces any prior loop.</td></tr>
<tr><td><code>GET /loop_await</code></td><td>Loop <em>alert_await_user_input</em>. Replaces any prior loop.</td></tr>
</table>
<p style="color:#555;font-size:14px;">
Between iterations a single voice waits <code>loop_alert_timeout_millis</code>
before replaying. Stages 1–3 can override via
<code>loop_alert_timeout_millis_{1,2,3}</code> — each falls back to the
previous stage if unset, so the loop progressively speeds up as it
escalates. <code>loop_alert_jitter_millis[_1|_2|_3]</code> apply
<code>+/- rand(0..=jitter)</code> per sleep.
</p>
<p style="color:#555;font-size:14px;">
<strong>Escalation:</strong> after the loop starts, a second voice joins
at <code>escalate_wait_1</code> seconds, a third at
<code>escalate_wait_2</code>, and a fourth at <code>escalate_wait_3</code>.
Each new voice is drawn from <code>extra_alert_&lt;state&gt;_sounds</code>;
when those run out the pool cycles, doubling already-playing voices.
All voices in a session share the current stage's gap, so existing
voices speed up too when the supervisor advances. Voices are
independent threads — they drift relative to one another.
</p>
</div>
<div class="group">
<h2>Stop</h2>
<table>
<tr><th>Endpoint</th><th>Behavior</th></tr>
<tr><td><code>GET /stop</code></td><td>Stop all playback (loops and queued one-shots).</td></tr>
</table>
</div>
<div class="group">
<h2>State <span class="pill">read-only</span></h2>
<table>
<tr><th>Endpoint</th><th>Behavior</th></tr>
<tr><td><code>GET /state</code></td><td>JSON snapshot of audio engine + config. Does not change playback.</td></tr>
</table>
<p style="color:#555;font-size:14px;">
The <code>audio</code> object shows the current loop (<code>loop_name</code> =
<em>beep</em> / <em>done</em> / <em>await</em>), how many concurrent voices are
active, the current escalation stage, the gap and jitter being applied right
now, and how long the loop has been running. The <code>config</code> object
mirrors the loaded YAML so you can verify it parsed correctly.
</p>
</div>
<div class="group">
<h2>Mixing rules</h2>
<ul>
<li>A one-shot mixes with whatever is looping — both play simultaneously.</li>
<li>Requesting a new loop replaces the prior loop.</li>
<li><code>/stop</code> halts everything.</li>
</ul>
</div>
</body>
</html>