diff --git a/Cargo.lock b/Cargo.lock index 2104cecb35..e070f426fe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index b5750d6a41..6e45d70f8b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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", diff --git a/crates/tools/servers/agent_notify_server/Cargo.toml b/crates/tools/servers/agent_notify_server/Cargo.toml new file mode 100644 index 0000000000..8b1d2376b0 --- /dev/null +++ b/crates/tools/servers/agent_notify_server/Cargo.toml @@ -0,0 +1,41 @@ +[package] +name = "agent-notify-server" +edition = "2021" +version = "0.0.1" +authors = ["Brandon Thomas "] +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 } diff --git a/crates/tools/servers/agent_notify_server/README.md b/crates/tools/servers/agent_notify_server/README.md new file mode 100644 index 0000000000..de575f693c --- /dev/null +++ b/crates/tools/servers/agent_notify_server/README.md @@ -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__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 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. diff --git a/crates/tools/servers/agent_notify_server/config/notify_config.yaml b/crates/tools/servers/agent_notify_server/config/notify_config.yaml new file mode 100644 index 0000000000..b4c1c4b96f --- /dev/null +++ b/crates/tools/servers/agent_notify_server/config/notify_config.yaml @@ -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__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 diff --git a/crates/tools/servers/agent_notify_server/config/test_beep.wav b/crates/tools/servers/agent_notify_server/config/test_beep.wav new file mode 100644 index 0000000000..d41cf401c4 Binary files /dev/null and b/crates/tools/servers/agent_notify_server/config/test_beep.wav differ diff --git a/crates/tools/servers/agent_notify_server/src/audio_player.rs b/crates/tools/servers/agent_notify_server/src/audio_player.rs new file mode 100644 index 0000000000..de6eac2541 --- /dev/null +++ b/crates/tools/servers/agent_notify_server/src/audio_player.rs @@ -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` 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, + status: Arc>, +} + +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, + 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, +} + +#[derive(Default)] +struct InternalStatus { + loop_playing: bool, + loop_name: Option, + voices_active: u32, + current_stage: u32, + current_gap_millis: u64, + current_jitter_millis: u64, + loop_pool_size: u32, + loop_started: Option, +} + +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, + /// 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::(); + 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, status: Arc>) { + 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 = 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>, +) -> 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, + status: &Arc>, +) { + 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, + status: Arc>, +) { + 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> = 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, + jitter_millis: Arc, + stop: Arc, + 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, + jitter_millis: Arc, + stop: Arc, +) { + 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(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, + thread: Option>, +} + +#[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 = (0..50) + .map(|_| jittered_gap_millis(5_000, 1_000, &mut rng)) + .collect(); + assert!(samples.len() > 10, "expected >10 distinct values, got {}", samples.len()); + } + } +} diff --git a/crates/tools/servers/agent_notify_server/src/config.rs b/crates/tools/servers/agent_notify_server/src/config.rs new file mode 100644 index 0000000000..ca1ac308c5 --- /dev/null +++ b/crates/tools/servers/agent_notify_server/src/config.rs @@ -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, + pub alert_done_sound: Option, + pub alert_await_user_input_sound: Option, + + #[serde(default)] + pub extra_alert_beep_sounds: Vec, + #[serde(default)] + pub extra_alert_done_sounds: Vec, + #[serde(default)] + pub extra_alert_await_sounds: Vec, + + pub loop_alert_timeout_millis: Option, + /// 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, + /// Gap (millis) after `escalate_wait_2`. Falls back to stage 1. + pub loop_alert_timeout_millis_2: Option, + /// Gap (millis) after `escalate_wait_3`. Falls back to stage 2. + pub loop_alert_timeout_millis_3: Option, + + /// 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, + /// Jitter at stage 1. Falls back to the previous stage. + pub loop_alert_jitter_millis_1: Option, + /// Jitter at stage 2. Falls back to stage 1. + pub loop_alert_jitter_millis_2: Option, + /// Jitter at stage 3. Falls back to stage 2. + pub loop_alert_jitter_millis_3: Option, + + /// Seconds after a loop starts before a second concurrent voice is added. + pub escalate_wait_1: Option, + /// Seconds before a third concurrent voice is added. + pub escalate_wait_2: Option, + /// Seconds before a fourth concurrent voice is added. + pub escalate_wait_3: Option, +} + +impl NotifyConfig { + pub fn read_from_file>(path: P) -> anyhow::Result { + 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>(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, base: &Path) { + if let Some(p) = slot.as_ref() { + if p.is_relative() { + *slot = Some(base.join(p)); + } + } +} + +fn resolve_vec(slot: &mut Vec, base: &Path) { + for p in slot.iter_mut() { + if p.is_relative() { + *p = base.join(&*p); + } + } +} diff --git a/crates/tools/servers/agent_notify_server/src/endpoints/alert_handlers.rs b/crates/tools/servers/agent_notify_server/src/endpoints/alert_handlers.rs new file mode 100644 index 0000000000..aebd374ae0 --- /dev/null +++ b/crates/tools/servers/agent_notify_server/src/endpoints/alert_handlers.rs @@ -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) -> 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) -> 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) -> 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, + 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)), + } +} diff --git a/crates/tools/servers/agent_notify_server/src/endpoints/loop_handlers.rs b/crates/tools/servers/agent_notify_server/src/endpoints/loop_handlers.rs new file mode 100644 index 0000000000..e4ec921623 --- /dev/null +++ b/crates/tools/servers/agent_notify_server/src/endpoints/loop_handlers.rs @@ -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) -> 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) -> 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) -> 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, + extras: Vec, + 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") +} diff --git a/crates/tools/servers/agent_notify_server/src/endpoints/mod.rs b/crates/tools/servers/agent_notify_server/src/endpoints/mod.rs new file mode 100644 index 0000000000..8d13ad89c3 --- /dev/null +++ b/crates/tools/servers/agent_notify_server/src/endpoints/mod.rs @@ -0,0 +1,5 @@ +pub mod alert_handlers; +pub mod loop_handlers; +pub mod root_handler; +pub mod state_handler; +pub mod stop_handler; diff --git a/crates/tools/servers/agent_notify_server/src/endpoints/root_handler.rs b/crates/tools/servers/agent_notify_server/src/endpoints/root_handler.rs new file mode 100644 index 0000000000..da8fcdf472 --- /dev/null +++ b/crates/tools/servers/agent_notify_server/src/endpoints/root_handler.rs @@ -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) +} diff --git a/crates/tools/servers/agent_notify_server/src/endpoints/state_handler.rs b/crates/tools/servers/agent_notify_server/src/endpoints/state_handler.rs new file mode 100644 index 0000000000..0fb714f510 --- /dev/null +++ b/crates/tools/servers/agent_notify_server/src/endpoints/state_handler.rs @@ -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) -> 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, + alert_done_sound: Option, + alert_await_user_input_sound: Option, + 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(), + } + } +} diff --git a/crates/tools/servers/agent_notify_server/src/endpoints/stop_handler.rs b/crates/tools/servers/agent_notify_server/src/endpoints/stop_handler.rs new file mode 100644 index 0000000000..da64dca4b7 --- /dev/null +++ b/crates/tools/servers/agent_notify_server/src/endpoints/stop_handler.rs @@ -0,0 +1,8 @@ +use actix_web::{HttpResponse, Responder, web}; + +use crate::server_state::ServerState; + +pub async fn stop_handler(state: web::Data) -> impl Responder { + state.audio.stop_all(); + HttpResponse::Ok().body("ok\n") +} diff --git a/crates/tools/servers/agent_notify_server/src/main.rs b/crates/tools/servers/agent_notify_server/src/main.rs new file mode 100644 index 0000000000..538ef5a4aa --- /dev/null +++ b/crates/tools/servers/agent_notify_server/src/main.rs @@ -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(()) +} diff --git a/crates/tools/servers/agent_notify_server/src/server_state.rs b/crates/tools/servers/agent_notify_server/src/server_state.rs new file mode 100644 index 0000000000..f03f79b06f --- /dev/null +++ b/crates/tools/servers/agent_notify_server/src/server_state.rs @@ -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, + pub audio: AudioPlayerHandle, +} diff --git a/crates/tools/servers/agent_notify_server/static/index.html b/crates/tools/servers/agent_notify_server/static/index.html new file mode 100644 index 0000000000..8e40012920 --- /dev/null +++ b/crates/tools/servers/agent_notify_server/static/index.html @@ -0,0 +1,95 @@ + + + + + Agent Notify Server + + + +

Agent Notify Server

+

Local audio-cue HTTP server for long-running agents.

+ +
+

One-shot alerts

+ + + + + +
EndpointBehavior
GET /alert_beepPlay alert_beep once.
GET /alert_donePlay alert_done once.
GET /alert_awaitPlay alert_await_user_input once.
+
+ +
+

Looping alerts replaces existing loop

+ + + + + +
EndpointBehavior
GET /loop_beepLoop alert_beep. Replaces any prior loop.
GET /loop_doneLoop alert_done. Replaces any prior loop.
GET /loop_awaitLoop alert_await_user_input. Replaces any prior loop.
+

+ Between iterations a single voice waits loop_alert_timeout_millis + before replaying. Stages 1–3 can override via + loop_alert_timeout_millis_{1,2,3} — each falls back to the + previous stage if unset, so the loop progressively speeds up as it + escalates. loop_alert_jitter_millis[_1|_2|_3] apply + +/- rand(0..=jitter) per sleep. +

+

+ Escalation: after the loop starts, a second voice joins + at escalate_wait_1 seconds, a third at + escalate_wait_2, and a fourth at escalate_wait_3. + Each new voice is drawn from extra_alert_<state>_sounds; + 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. +

+
+ +
+

Stop

+ + + +
EndpointBehavior
GET /stopStop all playback (loops and queued one-shots).
+
+ +
+

State read-only

+ + + +
EndpointBehavior
GET /stateJSON snapshot of audio engine + config. Does not change playback.
+

+ The audio object shows the current loop (loop_name = + beep / done / await), 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 config object + mirrors the loaded YAML so you can verify it parsed correctly. +

+
+ +
+

Mixing rules

+
    +
  • A one-shot mixes with whatever is looping — both play simultaneously.
  • +
  • Requesting a new loop replaces the prior loop.
  • +
  • /stop halts everything.
  • +
+
+ +