diff --git a/Cargo.lock b/Cargo.lock index 2104cecb35..7298e228f3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -420,6 +420,22 @@ dependencies = [ "cpufeatures", ] +[[package]] +name = "agent-notify-server" +version = "0.0.1" +dependencies = [ + "actix-web", + "anyhow", + "env_logger 0.11.9", + "log", + "rodio", + "serde", + "serde_derive", + "serde_yaml", + "tokio", + "workspace-build-acceleration", +] + [[package]] name = "ahash" version = "0.7.8" @@ -492,6 +508,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 +630,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -2610,6 +2638,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 +2664,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 +2675,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 +2683,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 +3019,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 +3359,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3996,6 +4064,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 +6446,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 +6739,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 +6895,7 @@ version = "0.0.1" dependencies = [ "errors", "log", - "symphonia", + "symphonia 0.5.0", "workspace-build-acceleration", ] @@ -7202,6 +7285,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 +7357,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 +7895,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 +7921,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 +9803,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 +10026,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -10004,7 +10143,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 +11708,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 +11747,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 +11771,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 +11793,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 +11822,7 @@ name = "symphonia-codec-alac" version = "0.5.0" dependencies = [ "log", - "symphonia-core", + "symphonia-core 0.5.0", ] [[package]] @@ -11627,7 +11830,7 @@ name = "symphonia-codec-opus" version = "0.0.1" dependencies = [ "log", - "symphonia-core", + "symphonia-core 0.5.0", ] [[package]] @@ -11635,7 +11838,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 +11856,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 +11876,7 @@ name = "symphonia-codec-wavpack" version = "0.0.1" dependencies = [ "log", - "symphonia-core", + "symphonia-core 0.5.0", ] [[package]] @@ -11666,15 +11890,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 +11933,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 +11943,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 +11965,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 +11976,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 +11996,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 +12569,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 +14272,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 +14332,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 +14456,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..ac19c4d683 --- /dev/null +++ b/crates/tools/servers/agent_notify_server/Cargo.toml @@ -0,0 +1,38 @@ +[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 } 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..5b5bc56cba --- /dev/null +++ b/crates/tools/servers/agent_notify_server/config/notify_config.yaml @@ -0,0 +1,13 @@ +# 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 +# looping sound (measured from the moment one play finishes to the moment the +# next play begins). If omitted, looping sounds replay back-to-back. + +alert_beep_sound: "/Users/bt/dev/storyteller/artcraft/frontend/apps/artcraft/app/public/resources/sound/smrpg_ghost.wav" +alert_done_sound: "/Users/bt/dev/storyteller/artcraft/frontend/apps/artcraft/app/public/resources/sound/smrpg_ghost.wav" +alert_await_user_input_sound: "/Users/bt/dev/storyteller/artcraft/frontend/apps/artcraft/app/public/resources/sound/smrpg_ghost.wav" +loop_alert_timeout_millis: 2000 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..1363b35aa5 --- /dev/null +++ b/crates/tools/servers/agent_notify_server/src/audio_player.rs @@ -0,0 +1,211 @@ +//! 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 independent mixing channels: +//! +//! - **`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 per-loop iterator thread** — owns its own `Sink` and replays the +//! configured sound, sleeping `gap_millis` between iterations. Replaced +//! atomically when a new loop sound is requested; signalled to stop via +//! `Arc` polled every 50ms (kept short so `/stop` and Ctrl+C +//! feel instant). +//! +//! Because the two channels share the same `OutputStreamHandle`, a one-shot +//! sound naturally mixes with whatever is looping. + +use std::fs::File; +use std::io::BufReader; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::mpsc::{self, Receiver, Sender}; +use std::thread::{self, JoinHandle}; +use std::time::{Duration, Instant}; + +use rodio::{Decoder, OutputStream, OutputStreamHandle, Sink}; + +const STOP_POLL_INTERVAL: Duration = Duration::from_millis(50); + +#[derive(Clone)] +pub struct AudioPlayerHandle { + tx: Sender, +} + +impl AudioPlayerHandle { + pub fn play_once(&self, path: PathBuf) { + let _ = self.tx.send(AudioCommand::PlayOnce(path)); + } + + pub fn play_loop(&self, path: PathBuf, gap_millis: u64) { + let _ = self.tx.send(AudioCommand::PlayLoop(path, gap_millis)); + } + + pub fn stop_all(&self) { + let _ = self.tx.send(AudioCommand::StopAll); + } + + pub fn shutdown(&self) { + let _ = self.tx.send(AudioCommand::Shutdown); + } +} + +pub fn spawn_audio_player() -> (AudioPlayerHandle, JoinHandle<()>) { + let (tx, rx) = mpsc::channel::(); + let thread = thread::Builder::new() + .name("agent-notify-audio".to_string()) + .spawn(move || run_audio_engine(rx)) + .expect("spawn audio engine thread"); + (AudioPlayerHandle { tx }, thread) +} + +enum AudioCommand { + PlayOnce(PathBuf), + PlayLoop(PathBuf, u64), + StopAll, + Shutdown, +} + +fn run_audio_engine(rx: Receiver) { + let (_stream, stream_handle) = match OutputStream::try_default() { + Ok(s) => s, + Err(e) => { + log::error!("audio engine: failed to open default output stream: {}", e); + // Drain the channel so callers don't block, then exit. + 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(path, gap_millis) => { + stop_current_loop(&mut current_loop); + current_loop = Some(start_loop(&stream_handle, path, gap_millis)); + } + AudioCommand::StopAll => { + stop_current_loop(&mut current_loop); + oneshot_sink.clear(); + oneshot_sink.play(); + } + AudioCommand::Shutdown => { + stop_current_loop(&mut current_loop); + oneshot_sink.stop(); + return; + } + } + } + + // Channel closed; clean up anyway. + stop_current_loop(&mut current_loop); + oneshot_sink.stop(); +} + +fn start_loop( + stream_handle: &OutputStreamHandle, + path: PathBuf, + gap_millis: u64, +) -> LoopController { + 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".to_string()) + .spawn(move || run_loop_iterator(stream_handle, path, gap_millis, stop_for_thread)) + .expect("spawn loop iterator thread"); + LoopController { stop, thread: Some(thread) } +} + +fn stop_current_loop(current_loop: &mut Option) { + 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 thread panicked while shutting down: {:?}", e); + } + } + } +} + +fn run_loop_iterator( + stream_handle: OutputStreamHandle, + path: PathBuf, + gap_millis: u64, + 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; + } + }; + + while !stop.load(Ordering::SeqCst) { + match enqueue_sound(&sink, &path) { + Ok(()) => {} + Err(e) => { + log::warn!("loop iterator: failed to enqueue {}: {}", path.display(), e); + return; + } + } + + // Wait for playback to finish, but stay responsive to stop. + while !sink.empty() { + if stop.load(Ordering::SeqCst) { + sink.stop(); + return; + } + thread::sleep(STOP_POLL_INTERVAL); + } + + if stop.load(Ordering::SeqCst) { + return; + } + + if gap_millis > 0 { + let total = Duration::from_millis(gap_millis); + let started = Instant::now(); + while started.elapsed() < total { + if stop.load(Ordering::SeqCst) { + return; + } + let remaining = total.saturating_sub(started.elapsed()); + thread::sleep(remaining.min(STOP_POLL_INTERVAL)); + } + } + } +} + +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(()) +} + +struct LoopController { + stop: Arc, + thread: Option>, +} 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..eb689f3792 --- /dev/null +++ b/crates/tools/servers/agent_notify_server/src/config.rs @@ -0,0 +1,41 @@ +use std::fs::File; +use std::io::BufReader; +use std::path::{Path, PathBuf}; + +use serde_derive::{Deserialize, Serialize}; + +pub const DEFAULT_CONFIG_PATH: &str = "config/notify_config.yaml"; + +#[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, + pub loop_alert_timeout_millis: Option, +} + +impl NotifyConfig { + pub fn read_from_file>(path: P) -> anyhow::Result { + let file = File::open(&path)?; + let reader = BufReader::new(file); + let config: NotifyConfig = serde_yaml::from_reader(reader)?; + 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() + } + } + } +} 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..1f474ce7ae --- /dev/null +++ b/crates/tools/servers/agent_notify_server/src/endpoints/loop_handlers.rs @@ -0,0 +1,36 @@ +use std::path::PathBuf; + +use actix_web::{HttpResponse, Responder, web}; + +use crate::server_state::ServerState; + +pub async fn loop_beep_handler(state: web::Data) -> impl Responder { + play_loop_or_404(&state, state.config.alert_beep_sound.clone(), "alert_beep_sound") +} + +pub async fn loop_done_handler(state: web::Data) -> impl Responder { + play_loop_or_404(&state, state.config.alert_done_sound.clone(), "alert_done_sound") +} + +pub async fn loop_await_handler(state: web::Data) -> impl Responder { + play_loop_or_404( + &state, + state.config.alert_await_user_input_sound.clone(), + "alert_await_user_input_sound", + ) +} + +fn play_loop_or_404( + state: &ServerState, + maybe_path: Option, + config_key: &str, +) -> HttpResponse { + match maybe_path { + Some(path) => { + let gap_millis = state.config.loop_alert_timeout_millis.unwrap_or(0); + state.audio.play_loop(path, gap_millis); + 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/mod.rs b/crates/tools/servers/agent_notify_server/src/endpoints/mod.rs new file mode 100644 index 0000000000..374dd17914 --- /dev/null +++ b/crates/tools/servers/agent_notify_server/src/endpoints/mod.rs @@ -0,0 +1,4 @@ +pub mod alert_handlers; +pub mod loop_handlers; +pub mod root_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/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..527dea3945 --- /dev/null +++ b/crates/tools/servers/agent_notify_server/src/main.rs @@ -0,0 +1,91 @@ +//! 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::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)) + }) + .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..2c374b2c77 --- /dev/null +++ b/crates/tools/servers/agent_notify_server/static/index.html @@ -0,0 +1,66 @@ + + + + + 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 the server waits loop_alert_timeout_millis + (from the YAML config) before playing again. +

+
+ +
+

Stop

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

Mixing rules

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