From 4eb6f16123e60cf1102a4189352e33ab1db45d5e Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 21 May 2026 22:01:30 -0400 Subject: [PATCH] enhanced behavior --- .../servers/agent_notify_server/README.md | 10 ++++++- .../config/notify_config.yaml | 6 ++++ .../agent_notify_server/src/audio_player.rs | 29 ++++++++++++------- .../servers/agent_notify_server/src/config.rs | 19 ++++++++++++ .../src/endpoints/loop_handlers.rs | 2 +- .../agent_notify_server/static/index.html | 9 ++++-- 6 files changed, 61 insertions(+), 14 deletions(-) diff --git a/crates/tools/servers/agent_notify_server/README.md b/crates/tools/servers/agent_notify_server/README.md index 5007b11556..ce0ca24eb4 100644 --- a/crates/tools/servers/agent_notify_server/README.md +++ b/crates/tools/servers/agent_notify_server/README.md @@ -55,6 +55,9 @@ extra_alert_await_sounds: - 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 escalate_wait_1: 15 escalate_wait_2: 30 escalate_wait_3: 45 @@ -63,7 +66,10 @@ 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. Omit to replay back-to-back. + 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. - **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 @@ -71,6 +77,8 @@ escalate_wait_3: 45 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 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 e36ba0bb88..9fb68a8565 100644 --- a/crates/tools/servers/agent_notify_server/config/notify_config.yaml +++ b/crates/tools/servers/agent_notify_server/config/notify_config.yaml @@ -30,6 +30,12 @@ extra_alert_await_sounds: - /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 # Seconds-from-loop-start at which voices 2, 3, and 4 join the mix. escalate_wait_1: 15 diff --git a/crates/tools/servers/agent_notify_server/src/audio_player.rs b/crates/tools/servers/agent_notify_server/src/audio_player.rs index fe70d7750c..d46c4b4235 100644 --- a/crates/tools/servers/agent_notify_server/src/audio_player.rs +++ b/crates/tools/servers/agent_notify_server/src/audio_player.rs @@ -25,7 +25,7 @@ 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::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::mpsc::{self, Receiver, Sender}; use std::thread::{self, JoinHandle}; use std::time::{Duration, Instant}; @@ -63,8 +63,11 @@ pub struct LoopSpec { /// 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, + /// 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], /// Wall-clock seconds from loop start when layers 2, 3, 4 should join. pub escalate_waits_secs: [u64; 3], } @@ -170,11 +173,14 @@ fn run_loop_supervisor( return; } + let schedule = spec.gap_millis_schedule; + let gap_millis = Arc::new(AtomicU64::new(schedule[0])); + let mut iterators: Vec> = Vec::with_capacity(4); iterators.push(spawn_iterator( &stream_handle, spec.pool[0].clone(), - spec.gap_millis, + gap_millis.clone(), stop.clone(), 0, )); @@ -193,11 +199,15 @@ fn run_loop_supervisor( if !sleep_with_stop(Duration::from_secs(intervals[layer]), &stop) { break; } + let next_gap = schedule[layer + 1]; + if gap_millis.swap(next_gap, Ordering::Relaxed) != next_gap { + log::info!("escalation stage {}: gap now {}ms", layer + 1, next_gap); + } let pool_idx = (layer + 1) % pool_len; iterators.push(spawn_iterator( &stream_handle, spec.pool[pool_idx].clone(), - spec.gap_millis, + gap_millis.clone(), stop.clone(), layer + 1, )); @@ -218,7 +228,7 @@ fn run_loop_supervisor( fn spawn_iterator( stream_handle: &OutputStreamHandle, path: PathBuf, - gap_millis: u64, + gap_millis: Arc, stop: Arc, layer: usize, ) -> JoinHandle<()> { @@ -233,7 +243,7 @@ fn spawn_iterator( fn run_loop_iterator( stream_handle: OutputStreamHandle, path: PathBuf, - gap_millis: u64, + gap_millis: Arc, stop: Arc, ) { let sink = match Sink::try_new(&stream_handle) { @@ -262,9 +272,8 @@ fn run_loop_iterator( return; } - if gap_millis > 0 - && !sleep_with_stop(Duration::from_millis(gap_millis), &stop) - { + let gap = gap_millis.load(Ordering::Relaxed); + if gap > 0 && !sleep_with_stop(Duration::from_millis(gap), &stop) { return; } } diff --git a/crates/tools/servers/agent_notify_server/src/config.rs b/crates/tools/servers/agent_notify_server/src/config.rs index a16d35dd6b..17ce81a728 100644 --- a/crates/tools/servers/agent_notify_server/src/config.rs +++ b/crates/tools/servers/agent_notify_server/src/config.rs @@ -27,6 +27,13 @@ pub struct NotifyConfig { pub extra_alert_await_sounds: Vec, pub loop_alert_timeout_millis: Option, + /// Gap (millis) between plays once the loop has been running for + /// `escalate_wait_1` seconds. Falls back to the previous stage. + pub loop_alert_timeout_millis_1: Option, + /// Gap (millis) after `escalate_wait_2`. Falls back to stage 1. + pub loop_alert_timeout_millis_2: Option, + /// Gap (millis) after `escalate_wait_3`. Falls back to stage 2. + pub loop_alert_timeout_millis_3: Option, /// Seconds after a loop starts before a second concurrent voice is added. pub escalate_wait_1: Option, @@ -73,6 +80,18 @@ impl NotifyConfig { ] } + /// 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] + } + fn resolve_relative_paths(&mut self, base: &Path) { resolve_opt(&mut self.alert_beep_sound, base); resolve_opt(&mut self.alert_done_sound, base); 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 15d13b6956..27ca3acdf2 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 @@ -48,7 +48,7 @@ fn play_loop_or_404( let spec = LoopSpec { pool, - gap_millis: state.config.loop_alert_timeout_millis.unwrap_or(0), + gap_millis_schedule: state.config.loop_gap_schedule_millis(), escalate_waits_secs: state.config.escalate_waits_secs(), }; state.audio.play_loop(spec); diff --git a/crates/tools/servers/agent_notify_server/static/index.html b/crates/tools/servers/agent_notify_server/static/index.html index fd19a73c61..15e204cdd2 100644 --- a/crates/tools/servers/agent_notify_server/static/index.html +++ b/crates/tools/servers/agent_notify_server/static/index.html @@ -42,7 +42,10 @@

Between iterations a single voice waits loop_alert_timeout_millis - before replaying. + before replaying. Stages 1–3 can override via + loop_alert_timeout_millis_{1,2,3} — each falls back to the + previous stage if unset, so the loop progressively speeds up as it + escalates.

Escalation: after the loop starts, a second voice joins @@ -50,7 +53,9 @@ 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. + 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.