diff --git a/Cargo.lock b/Cargo.lock index 7298e228f3..e070f426fe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -428,6 +428,7 @@ dependencies = [ "anyhow", "env_logger 0.11.9", "log", + "rand 0.9.2", "rodio", "serde", "serde_derive", diff --git a/crates/tools/servers/agent_notify_server/Cargo.toml b/crates/tools/servers/agent_notify_server/Cargo.toml index ac19c4d683..8b1d2376b0 100644 --- a/crates/tools/servers/agent_notify_server/Cargo.toml +++ b/crates/tools/servers/agent_notify_server/Cargo.toml @@ -36,3 +36,6 @@ env_logger = { workspace = true } # Errors anyhow = { workspace = true } + +# Random +rand = { workspace = true } diff --git a/crates/tools/servers/agent_notify_server/README.md b/crates/tools/servers/agent_notify_server/README.md index ce0ca24eb4..3ad0e3ae96 100644 --- a/crates/tools/servers/agent_notify_server/README.md +++ b/crates/tools/servers/agent_notify_server/README.md @@ -58,6 +58,10 @@ loop_alert_timeout_millis: 2000 loop_alert_timeout_millis_1: 1000 loop_alert_timeout_millis_2: 500 loop_alert_timeout_millis_3: 200 +loop_alert_jitter_millis: 250 +loop_alert_jitter_millis_1: 150 +loop_alert_jitter_millis_2: 80 +loop_alert_jitter_millis_3: 40 escalate_wait_1: 15 escalate_wait_2: 30 escalate_wait_3: 45 @@ -70,6 +74,12 @@ escalate_wait_3: 45 - `loop_alert_timeout_millis_{1,2,3}` override the gap at each escalation stage. Each falls back to the previous stage when unset — so you can set just `_1: 200` to drop straight to a 200ms gap from voice 2 onward. +- `loop_alert_jitter_millis[_1|_2|_3]` add `+/- rand(0..=jitter)` to each + sleep (clamped at 0). Same fallback chain as the timeouts. Useful to + keep voices from re-aligning even when they share a gap. The *upcoming* + stage's jitter is also applied to the escalation wait itself, so voices + 2/3/4 don't always land exactly on `escalate_wait_N` — their phase + drifts by `+/- rand(0..=jitter)` ms. - **Escalation**: a `/loop_*` request starts one voice immediately. At `escalate_wait_1` / `escalate_wait_2` / `escalate_wait_3` seconds, a second / third / fourth concurrent voice joins the mix. New voices are 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 9fb68a8565..79f2eb3435 100644 --- a/crates/tools/servers/agent_notify_server/config/notify_config.yaml +++ b/crates/tools/servers/agent_notify_server/config/notify_config.yaml @@ -20,16 +20,19 @@ alert_done_sound: /Users/bt/dev/storyteller/artcraft/frontend/apps/artcraft/app/ alert_await_user_input_sound: /Users/bt/dev/storyteller/artcraft/frontend/apps/artcraft/app/public/resources/sound/smrpg_ghost.wav extra_alert_beep_sounds: [] + extra_alert_done_sounds: - /Users/bt/dev/storyteller/artcraft/frontend/apps/artcraft/app/public/resources/sound/smrpg_specialflower.wav - /Users/bt/dev/storyteller/artcraft/frontend/apps/artcraft/app/public/resources/sound/smrpg_correct.wav - /Users/bt/dev/storyteller/artcraft/frontend/apps/artcraft/app/public/resources/sound/smrpg_character_extrapower.wav + extra_alert_await_sounds: - /Users/bt/dev/storyteller/artcraft/frontend/apps/artcraft/app/public/resources/sound/smrpg_wrong.wav - /Users/bt/dev/storyteller/artcraft/frontend/apps/artcraft/app/public/resources/sound/smrpg_drybones_crumble.wav - /Users/bt/dev/storyteller/artcraft/frontend/apps/artcraft/app/public/resources/sound/smrpg_enemy_spikethrow.wav loop_alert_timeout_millis: 2000 + # Optional per-stage gaps. Each falls back to the previous stage when unset, # so leaving them out keeps the loop at `loop_alert_timeout_millis` the whole # time; specifying them lets the loop accelerate as it escalates. @@ -37,7 +40,15 @@ loop_alert_timeout_millis_1: 1000 loop_alert_timeout_millis_2: 500 loop_alert_timeout_millis_3: 200 +# Random +/- jitter applied to each sleep. Same fallback rules as the timeout +# schedule above. Setting non-zero values keeps voices from re-aligning over +# time even when they share the same gap. +loop_alert_jitter_millis: 250 +loop_alert_jitter_millis_1: 150 +loop_alert_jitter_millis_2: 80 +loop_alert_jitter_millis_3: 40 + # 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 +escalate_wait_1: 10 +escalate_wait_2: 20 +escalate_wait_3: 30 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 d46c4b4235..416cc6da1a 100644 --- a/crates/tools/servers/agent_notify_server/src/audio_player.rs +++ b/crates/tools/servers/agent_notify_server/src/audio_player.rs @@ -30,6 +30,7 @@ use std::sync::mpsc::{self, Receiver, Sender}; use std::thread::{self, JoinHandle}; use std::time::{Duration, Instant}; +use rand::Rng; use rodio::{Decoder, OutputStream, OutputStreamHandle, Sink}; const STOP_POLL_INTERVAL: Duration = Duration::from_millis(50); @@ -68,6 +69,9 @@ pub struct LoopSpec { /// the session share the current stage's value, so existing voices also /// speed up when the supervisor moves to a faster stage. pub gap_millis_schedule: [u64; 4], + /// Max +/- jitter (millis) at each escalation stage. Same shape and + /// sharing semantics as `gap_millis_schedule`. + pub jitter_millis_schedule: [u64; 4], /// Wall-clock seconds from loop start when layers 2, 3, 4 should join. pub escalate_waits_secs: [u64; 3], } @@ -173,14 +177,17 @@ fn run_loop_supervisor( return; } - let schedule = spec.gap_millis_schedule; - let gap_millis = Arc::new(AtomicU64::new(schedule[0])); + let gap_schedule = spec.gap_millis_schedule; + let jitter_schedule = spec.jitter_millis_schedule; + let gap_millis = Arc::new(AtomicU64::new(gap_schedule[0])); + let jitter_millis = Arc::new(AtomicU64::new(jitter_schedule[0])); let mut iterators: Vec> = Vec::with_capacity(4); iterators.push(spawn_iterator( &stream_handle, spec.pool[0].clone(), gap_millis.clone(), + jitter_millis.clone(), stop.clone(), 0, )); @@ -195,19 +202,35 @@ fn run_loop_supervisor( waits[2].saturating_sub(waits[1]), ]; + let mut rng = rand::rng(); + for layer in 0..3usize { - if !sleep_with_stop(Duration::from_secs(intervals[layer]), &stop) { + // Jitter the spawn phase by the *upcoming* stage's jitter, so voices + // 2/3/4 don't all land exactly on `escalate_wait_N`. + let nominal_interval_ms = intervals[layer].saturating_mul(1000); + let phase_jitter_ms = jitter_schedule[layer + 1]; + let interval_ms = jittered_gap_millis(nominal_interval_ms, phase_jitter_ms, &mut rng); + if !sleep_with_stop(Duration::from_millis(interval_ms), &stop) { break; } - let next_gap = 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 next_gap = gap_schedule[layer + 1]; + let next_jitter = jitter_schedule[layer + 1]; + let prev_gap = gap_millis.swap(next_gap, Ordering::Relaxed); + let prev_jitter = jitter_millis.swap(next_jitter, Ordering::Relaxed); + if prev_gap != next_gap || prev_jitter != next_jitter { + log::info!( + "escalation stage {}: gap now {}ms +/- {}ms", + layer + 1, + next_gap, + next_jitter + ); } let pool_idx = (layer + 1) % pool_len; iterators.push(spawn_iterator( &stream_handle, spec.pool[pool_idx].clone(), gap_millis.clone(), + jitter_millis.clone(), stop.clone(), layer + 1, )); @@ -229,6 +252,7 @@ fn spawn_iterator( stream_handle: &OutputStreamHandle, path: PathBuf, gap_millis: Arc, + jitter_millis: Arc, stop: Arc, layer: usize, ) -> JoinHandle<()> { @@ -236,7 +260,9 @@ fn spawn_iterator( 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)) + .spawn(move || { + run_loop_iterator(stream_handle, path, gap_millis, jitter_millis, stop) + }) .expect("spawn loop iterator thread") } @@ -244,6 +270,7 @@ fn run_loop_iterator( stream_handle: OutputStreamHandle, path: PathBuf, gap_millis: Arc, + jitter_millis: Arc, stop: Arc, ) { let sink = match Sink::try_new(&stream_handle) { @@ -254,6 +281,8 @@ fn run_loop_iterator( } }; + let mut rng = rand::rng(); + while !stop.load(Ordering::SeqCst) { if let Err(e) = enqueue_sound(&sink, &path) { log::warn!("loop iterator: failed to enqueue {}: {}", path.display(), e); @@ -273,12 +302,29 @@ fn run_loop_iterator( } let gap = gap_millis.load(Ordering::Relaxed); - if gap > 0 && !sleep_with_stop(Duration::from_millis(gap), &stop) { + let jitter = jitter_millis.load(Ordering::Relaxed); + let sleep_ms = jittered_gap_millis(gap, jitter, &mut rng); + if sleep_ms > 0 + && !sleep_with_stop(Duration::from_millis(sleep_ms), &stop) + { return; } } } +/// Apply `+/- rand(0..=jitter)` to `gap`, clamped to 0 so we never wrap. +fn jittered_gap_millis(gap: u64, jitter: u64, rng: &mut R) -> u64 { + if jitter == 0 { + return gap; + } + let delta = rng.random_range(0..=jitter); + if rng.random_bool(0.5) { + gap.saturating_add(delta) + } else { + gap.saturating_sub(delta) + } +} + fn enqueue_sound(sink: &Sink, path: &Path) -> anyhow::Result<()> { let file = BufReader::new(File::open(path)?); let source = Decoder::new(file)?; @@ -304,3 +350,44 @@ struct LoopController { stop: Arc, thread: Option>, } + +#[cfg(test)] +mod tests { + use super::*; + + mod jittered_gap_millis_tests { + use super::*; + + #[test] + fn zero_jitter_returns_gap_unchanged() { + let mut rng = rand::rng(); + for _ in 0..50 { + assert_eq!(jittered_gap_millis(500, 0, &mut rng), 500); + } + } + + #[test] + fn nonzero_jitter_stays_within_bounds_and_varies() { + let mut rng = rand::rng(); + let mut seen_below = false; + let mut seen_above = false; + for _ in 0..500 { + let v = jittered_gap_millis(1000, 200, &mut rng); + assert!(v >= 800 && v <= 1200, "out of bounds: {}", v); + if v < 1000 { seen_below = true; } + if v > 1000 { seen_above = true; } + } + assert!(seen_below && seen_above, "expected jitter on both sides"); + } + + #[test] + fn negative_jitter_is_clamped_to_zero() { + let mut rng = rand::rng(); + for _ in 0..200 { + let v = jittered_gap_millis(50, 1000, &mut rng); + assert!(v <= 1050); + // saturating_sub at the floor — never wraps below 0. + } + } + } +} diff --git a/crates/tools/servers/agent_notify_server/src/config.rs b/crates/tools/servers/agent_notify_server/src/config.rs index 17ce81a728..ca1ac308c5 100644 --- a/crates/tools/servers/agent_notify_server/src/config.rs +++ b/crates/tools/servers/agent_notify_server/src/config.rs @@ -35,6 +35,16 @@ pub struct NotifyConfig { /// Gap (millis) after `escalate_wait_3`. Falls back to stage 2. pub loop_alert_timeout_millis_3: Option, + /// Maximum +/- random jitter (millis) applied to each sleep at stage 0. + /// Each sleep becomes `timeout +/- rand(0..=jitter)`, clamped at 0. + pub loop_alert_jitter_millis: Option, + /// Jitter at stage 1. Falls back to the previous stage. + pub loop_alert_jitter_millis_1: Option, + /// Jitter at stage 2. Falls back to stage 1. + pub loop_alert_jitter_millis_2: Option, + /// Jitter at stage 3. Falls back to stage 2. + pub loop_alert_jitter_millis_3: 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. @@ -92,6 +102,15 @@ impl NotifyConfig { [g0, g1, g2, g3] } + /// Per-stage jitter (millis). Same fallback semantics as the gap schedule. + pub fn loop_jitter_schedule_millis(&self) -> [u64; 4] { + let j0 = self.loop_alert_jitter_millis.unwrap_or(0); + let j1 = self.loop_alert_jitter_millis_1.unwrap_or(j0); + let j2 = self.loop_alert_jitter_millis_2.unwrap_or(j1); + let j3 = self.loop_alert_jitter_millis_3.unwrap_or(j2); + [j0, j1, j2, j3] + } + fn resolve_relative_paths(&mut self, base: &Path) { resolve_opt(&mut self.alert_beep_sound, base); resolve_opt(&mut self.alert_done_sound, base); 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 27ca3acdf2..d5afa89104 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 @@ -49,6 +49,7 @@ fn play_loop_or_404( let spec = LoopSpec { pool, gap_millis_schedule: state.config.loop_gap_schedule_millis(), + jitter_millis_schedule: state.config.loop_jitter_schedule_millis(), escalate_waits_secs: state.config.escalate_waits_secs(), }; state.audio.play_loop(spec); diff --git a/crates/tools/servers/agent_notify_server/static/index.html b/crates/tools/servers/agent_notify_server/static/index.html index 15e204cdd2..af557b18cf 100644 --- a/crates/tools/servers/agent_notify_server/static/index.html +++ b/crates/tools/servers/agent_notify_server/static/index.html @@ -45,7 +45,8 @@ 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. + escalates. loop_alert_jitter_millis[_1|_2|_3] apply + +/- rand(0..=jitter) per sleep.

Escalation: after the loop starts, a second voice joins