mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-21 17:28:24 -04:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d0c8dffecb | ||
|
|
a71c846634 | ||
|
|
ec4d0bcb16 | ||
|
|
bfc6104e66 | ||
|
|
4eb6f16123 | ||
|
|
12f7f56928 | ||
|
|
fab5fa97f8 | ||
|
|
7f86cc8c42 | ||
|
|
d4a07af223 | ||
|
|
4c8a9dc62d |
Generated
+361
-47
@@ -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"
|
||||
|
||||
@@ -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
|
||||
Binary file not shown.
@@ -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_<state>_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>
|
||||
Reference in New Issue
Block a user