new tool server

This commit is contained in:
Brandon Thomas committed 2026-05-21 19:42:46 -04:00
1 parent 9471f0e19a
commit 4c8a9dc62d
14 files changed
+925 -47

No files matched your search

Generated
+360 -47
View File
@@ -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"
+3
View File
@@ -150,6 +150,9 @@ members = [
"crates/testing/test_data",
"crates/testing/test_utils",
# Standalone tool servers
"crates/tools/servers/agent_notify_server",
# Vendored code
"crates/vendor/limitation",
"crates/vendor/tauri-plugin-http",
@@ -0,0 +1,38 @@
[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 }
@@ -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
@@ -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<AtomicBool>` 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<AudioCommand>,
}
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::<AudioCommand>();
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<AudioCommand>) {
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<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(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<LoopController>) {
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<AtomicBool>,
) {
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<AtomicBool>,
thread: Option<JoinHandle<()>>,
}
@@ -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<PathBuf>,
pub alert_done_sound: Option<PathBuf>,
pub alert_await_user_input_sound: Option<PathBuf>,
pub loop_alert_timeout_millis: Option<u64>,
}
impl NotifyConfig {
pub fn read_from_file<P: AsRef<Path>>(path: P) -> anyhow::Result<Self> {
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<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()
}
}
}
}
@@ -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,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<ServerState>) -> 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<ServerState>) -> 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<ServerState>) -> 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<PathBuf>,
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)),
}
}
@@ -0,0 +1,4 @@
pub mod alert_handlers;
pub mod loop_handlers;
pub mod root_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,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,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(())
}
@@ -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,66 @@
<!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 the server waits <code>loop_alert_timeout_millis</code>
(from the YAML config) before playing again.
</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>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>