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