From 12f7f569289de380853079ca19dfdd68981bd4d4 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 21 May 2026 21:53:40 -0400 Subject: [PATCH] better behavior --- .../servers/agent_notify_server/README.md | 24 ++- .../config/notify_config.yaml | 27 ++- .../agent_notify_server/config/test_beep.wav | Bin 0 -> 112404 bytes .../agent_notify_server/src/audio_player.rs | 168 +++++++++++++----- .../servers/agent_notify_server/src/config.rs | 46 ++++- .../src/endpoints/loop_handlers.rs | 42 +++-- .../agent_notify_server/static/index.html | 12 +- 7 files changed, 256 insertions(+), 63 deletions(-) create mode 100644 crates/tools/servers/agent_notify_server/config/test_beep.wav diff --git a/crates/tools/servers/agent_notify_server/README.md b/crates/tools/servers/agent_notify_server/README.md index 13af5aa897..5007b11556 100644 --- a/crates/tools/servers/agent_notify_server/README.md +++ b/crates/tools/servers/agent_notify_server/README.md @@ -45,14 +45,32 @@ Mixing rules: 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 +escalate_wait_1: 15 +escalate_wait_2: 30 +escalate_wait_3: 45 ``` - Paths can be absolute, or relative to the YAML file's directory. -- `loop_alert_timeout_millis` is the gap *between* consecutive plays of a - looping sound (after one play ends, before the next begins). Omit to replay - back-to-back. - WAV and MP3 are both supported (decoded via rodio + symphonia). +- `loop_alert_timeout_millis` is the gap *between* consecutive plays of a + single voice. Omit to replay back-to-back. +- **Escalation**: a `/loop_*` request starts one voice immediately. At + `escalate_wait_1` / `escalate_wait_2` / `escalate_wait_3` seconds, a + second / third / fourth concurrent voice joins the mix. New voices are + taken from `extra_alert__sounds` in order; when that pool is + exhausted the supervisor cycles back through the full pool + (primary + extras), so existing voices double up. Each voice runs in + its own thread and drifts naturally relative to the others. ## Claude Code wiring diff --git a/crates/tools/servers/agent_notify_server/config/notify_config.yaml b/crates/tools/servers/agent_notify_server/config/notify_config.yaml index 7beeceb110..e36ba0bb88 100644 --- a/crates/tools/servers/agent_notify_server/config/notify_config.yaml +++ b/crates/tools/servers/agent_notify_server/config/notify_config.yaml @@ -4,11 +4,34 @@ # 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. +# single voice (after one play finishes, before the next begins). If +# omitted, looping sounds replay back-to-back. +# +# Escalation: after a /loop_* endpoint starts a loop, additional concurrent +# voices join the mix at the configured wait times. Each new voice is +# picked from `extra_alert__sounds` in order; if the pool is +# exhausted the supervisor cycles back through it, so existing voices +# double up. Voices are independent threads and drift naturally relative +# to each other. # Paths can be absolute, or relative to this YAML file's directory. alert_beep_sound: test_beep.wav alert_done_sound: /Users/bt/dev/storyteller/artcraft/frontend/apps/artcraft/app/public/resources/sound/smrpg_flower.wav alert_await_user_input_sound: /Users/bt/dev/storyteller/artcraft/frontend/apps/artcraft/app/public/resources/sound/smrpg_ghost.wav + +extra_alert_beep_sounds: [] +extra_alert_done_sounds: + - /Users/bt/dev/storyteller/artcraft/frontend/apps/artcraft/app/public/resources/sound/smrpg_specialflower.wav + - /Users/bt/dev/storyteller/artcraft/frontend/apps/artcraft/app/public/resources/sound/smrpg_correct.wav + - /Users/bt/dev/storyteller/artcraft/frontend/apps/artcraft/app/public/resources/sound/smrpg_character_extrapower.wav +extra_alert_await_sounds: + - /Users/bt/dev/storyteller/artcraft/frontend/apps/artcraft/app/public/resources/sound/smrpg_wrong.wav + - /Users/bt/dev/storyteller/artcraft/frontend/apps/artcraft/app/public/resources/sound/smrpg_drybones_crumble.wav + - /Users/bt/dev/storyteller/artcraft/frontend/apps/artcraft/app/public/resources/sound/smrpg_enemy_spikethrow.wav + loop_alert_timeout_millis: 2000 + +# Seconds-from-loop-start at which voices 2, 3, and 4 join the mix. +escalate_wait_1: 15 +escalate_wait_2: 30 +escalate_wait_3: 45 diff --git a/crates/tools/servers/agent_notify_server/config/test_beep.wav b/crates/tools/servers/agent_notify_server/config/test_beep.wav new file mode 100644 index 0000000000000000000000000000000000000000..d41cf401c45a9db21a9021fddebb896af5815eaa GIT binary patch literal 112404 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 X-~wEL3vdB0zy-Jf7vKW__XYk7LJ` polled every 50ms (kept short so `/stop` and Ctrl+C -//! feel instant). +//! - **A loop session** — a supervisor thread that escalates over time. It +//! starts one looping iterator immediately, then adds a second, third, and +//! fourth concurrent iterator at the configured escalation times. Each +//! iterator owns its own `Sink` and runs in its own thread, so the voices +//! drift naturally relative to one another. Replacing the loop session +//! (`PlayLoop` while one is running) or `StopAll` signals every iterator +//! and the supervisor via a shared `Arc` polled every 50ms. //! -//! Because the two channels share the same `OutputStreamHandle`, a one-shot -//! sound naturally mixes with whatever is looping. +//! 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; @@ -42,8 +44,8 @@ impl AudioPlayerHandle { 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 play_loop(&self, spec: LoopSpec) { + let _ = self.tx.send(AudioCommand::PlayLoop(spec)); } pub fn stop_all(&self) { @@ -55,6 +57,18 @@ impl AudioPlayerHandle { } } +#[derive(Clone, Debug)] +pub struct LoopSpec { + /// Ordered pool of sounds: primary first, then extras. The supervisor + /// indexes this with `layer % pool.len()`, so when extras run out it + /// cycles back through the pool (doubling already-playing voices). + pub pool: Vec, + /// Gap between consecutive plays of a single iterator's sound. + pub gap_millis: u64, + /// Wall-clock seconds from loop start when layers 2, 3, 4 should join. + pub escalate_waits_secs: [u64; 3], +} + pub fn spawn_audio_player() -> (AudioPlayerHandle, JoinHandle<()>) { let (tx, rx) = mpsc::channel::(); let thread = thread::Builder::new() @@ -66,7 +80,7 @@ pub fn spawn_audio_player() -> (AudioPlayerHandle, JoinHandle<()>) { enum AudioCommand { PlayOnce(PathBuf), - PlayLoop(PathBuf, u64), + PlayLoop(LoopSpec), StopAll, Shutdown, } @@ -76,7 +90,6 @@ fn run_audio_engine(rx: Receiver) { 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; } @@ -100,9 +113,13 @@ fn run_audio_engine(rx: Receiver) { log::warn!("play_once {}: {}", path.display(), e); } } - AudioCommand::PlayLoop(path, gap_millis) => { + AudioCommand::PlayLoop(spec) => { stop_current_loop(&mut current_loop); - current_loop = Some(start_loop(&stream_handle, path, gap_millis)); + if spec.pool.is_empty() { + log::warn!("play_loop: empty sound pool, ignoring"); + continue; + } + current_loop = Some(start_loop_supervisor(&stream_handle, spec)); } AudioCommand::StopAll => { stop_current_loop(&mut current_loop); @@ -117,23 +134,18 @@ fn run_audio_engine(rx: Receiver) { } } - // 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 { +fn start_loop_supervisor(stream_handle: &OutputStreamHandle, spec: LoopSpec) -> 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"); + .name("agent-notify-loop-supervisor".to_string()) + .spawn(move || run_loop_supervisor(stream_handle, spec, stop_for_thread)) + .expect("spawn loop supervisor thread"); LoopController { stop, thread: Some(thread) } } @@ -142,12 +154,82 @@ fn stop_current_loop(current_loop: &mut Option) { 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); + log::warn!("audio loop supervisor panicked while shutting down: {:?}", e); } } } } +fn run_loop_supervisor( + stream_handle: OutputStreamHandle, + spec: LoopSpec, + stop: Arc, +) { + let pool_len = spec.pool.len(); + if pool_len == 0 { + return; + } + + let mut iterators: Vec> = Vec::with_capacity(4); + iterators.push(spawn_iterator( + &stream_handle, + spec.pool[0].clone(), + spec.gap_millis, + 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]), + ]; + + for layer in 0..3usize { + if !sleep_with_stop(Duration::from_secs(intervals[layer]), &stop) { + break; + } + let pool_idx = (layer + 1) % pool_len; + iterators.push(spawn_iterator( + &stream_handle, + spec.pool[pool_idx].clone(), + spec.gap_millis, + stop.clone(), + layer + 1, + )); + } + + // 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: u64, + stop: Arc, + layer: usize, +) -> JoinHandle<()> { + log::info!("loop voice {} starting: {}", layer + 1, path.display()); + let stream_handle = stream_handle.clone(); + thread::Builder::new() + .name(format!("agent-notify-loop-{}", layer + 1)) + .spawn(move || run_loop_iterator(stream_handle, path, gap_millis, stop)) + .expect("spawn loop iterator thread") +} + fn run_loop_iterator( stream_handle: OutputStreamHandle, path: PathBuf, @@ -163,15 +245,11 @@ fn run_loop_iterator( }; while !stop.load(Ordering::SeqCst) { - match enqueue_sound(&sink, &path) { - Ok(()) => {} - Err(e) => { - log::warn!("loop iterator: failed to enqueue {}: {}", path.display(), e); - return; - } + if let Err(e) = enqueue_sound(&sink, &path) { + 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(); @@ -184,16 +262,10 @@ fn run_loop_iterator( 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)); - } + if gap_millis > 0 + && !sleep_with_stop(Duration::from_millis(gap_millis), &stop) + { + return; } } } @@ -205,6 +277,20 @@ fn enqueue_sound(sink: &Sink, path: &Path) -> anyhow::Result<()> { Ok(()) } +/// Sleep for `total` while polling `stop` every [`STOP_POLL_INTERVAL`]. +/// Returns `true` if the full duration elapsed, `false` if `stop` was set. +fn sleep_with_stop(total: Duration, stop: &AtomicBool) -> bool { + let started = Instant::now(); + while started.elapsed() < total { + if stop.load(Ordering::SeqCst) { + return false; + } + let remaining = total.saturating_sub(started.elapsed()); + thread::sleep(remaining.min(STOP_POLL_INTERVAL)); + } + !stop.load(Ordering::SeqCst) +} + struct LoopController { stop: Arc, thread: Option>, diff --git a/crates/tools/servers/agent_notify_server/src/config.rs b/crates/tools/servers/agent_notify_server/src/config.rs index b67edd5874..a16d35dd6b 100644 --- a/crates/tools/servers/agent_notify_server/src/config.rs +++ b/crates/tools/servers/agent_notify_server/src/config.rs @@ -9,12 +9,31 @@ use serde_derive::{Deserialize, Serialize}; pub const DEFAULT_CONFIG_PATH: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/config/notify_config.yaml"); +pub const DEFAULT_ESCALATE_WAIT_1_SECS: u64 = 15; +pub const DEFAULT_ESCALATE_WAIT_2_SECS: u64 = 30; +pub const DEFAULT_ESCALATE_WAIT_3_SECS: u64 = 45; + #[derive(Clone, Debug, Default, Deserialize, Serialize)] pub struct NotifyConfig { pub alert_beep_sound: Option, pub alert_done_sound: Option, pub alert_await_user_input_sound: Option, + + #[serde(default)] + pub extra_alert_beep_sounds: Vec, + #[serde(default)] + pub extra_alert_done_sounds: Vec, + #[serde(default)] + pub extra_alert_await_sounds: Vec, + pub loop_alert_timeout_millis: Option, + + /// Seconds after a loop starts before a second concurrent voice is added. + pub escalate_wait_1: Option, + /// Seconds before a third concurrent voice is added. + pub escalate_wait_2: Option, + /// Seconds before a fourth concurrent voice is added. + pub escalate_wait_3: Option, } impl NotifyConfig { @@ -46,17 +65,36 @@ impl NotifyConfig { } } + 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), + ] + } + fn resolve_relative_paths(&mut self, base: &Path) { - resolve(&mut self.alert_beep_sound, base); - resolve(&mut self.alert_done_sound, base); - resolve(&mut self.alert_await_user_input_sound, base); + 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(slot: &mut Option, base: &Path) { +fn resolve_opt(slot: &mut Option, base: &Path) { if let Some(p) = slot.as_ref() { if p.is_relative() { *slot = Some(base.join(p)); } } } + +fn resolve_vec(slot: &mut Vec, base: &Path) { + for p in slot.iter_mut() { + if p.is_relative() { + *p = base.join(&*p); + } + } +} diff --git a/crates/tools/servers/agent_notify_server/src/endpoints/loop_handlers.rs b/crates/tools/servers/agent_notify_server/src/endpoints/loop_handlers.rs index 1f474ce7ae..15d13b6956 100644 --- a/crates/tools/servers/agent_notify_server/src/endpoints/loop_handlers.rs +++ b/crates/tools/servers/agent_notify_server/src/endpoints/loop_handlers.rs @@ -2,35 +2,55 @@ use std::path::PathBuf; use actix_web::{HttpResponse, Responder, web}; +use crate::audio_player::LoopSpec; use crate::server_state::ServerState; pub async fn loop_beep_handler(state: web::Data) -> impl Responder { - play_loop_or_404(&state, state.config.alert_beep_sound.clone(), "alert_beep_sound") + play_loop_or_404( + &state, + state.config.alert_beep_sound.clone(), + state.config.extra_alert_beep_sounds.clone(), + "alert_beep_sound", + ) } pub async fn loop_done_handler(state: web::Data) -> impl Responder { - play_loop_or_404(&state, state.config.alert_done_sound.clone(), "alert_done_sound") + play_loop_or_404( + &state, + state.config.alert_done_sound.clone(), + state.config.extra_alert_done_sounds.clone(), + "alert_done_sound", + ) } pub async fn loop_await_handler(state: web::Data) -> impl Responder { play_loop_or_404( &state, 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, - maybe_path: Option, + primary: Option, + extras: Vec, 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)), - } + 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 { + pool, + gap_millis: state.config.loop_alert_timeout_millis.unwrap_or(0), + escalate_waits_secs: state.config.escalate_waits_secs(), + }; + state.audio.play_loop(spec); + HttpResponse::Ok().body("ok\n") } diff --git a/crates/tools/servers/agent_notify_server/static/index.html b/crates/tools/servers/agent_notify_server/static/index.html index 2c374b2c77..fd19a73c61 100644 --- a/crates/tools/servers/agent_notify_server/static/index.html +++ b/crates/tools/servers/agent_notify_server/static/index.html @@ -41,8 +41,16 @@ 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. + Between iterations a single voice waits loop_alert_timeout_millis + before replaying. +

+

+ Escalation: after the loop starts, a second voice joins + at escalate_wait_1 seconds, a third at + escalate_wait_2, and a fourth at escalate_wait_3. + Each new voice is drawn from extra_alert_<state>_sounds; + when those run out the pool cycles, doubling already-playing voices. + Voices are independent threads — they drift relative to one another.