configure jitter

This commit is contained in:
Brandon Thomas committed 2026-05-21 22:13:01 -04:00
1 parent 4eb6f16123
commit bfc6104e66
8 files changed
+145 -12

No files matched your search

Generated
+1
View File
@@ -428,6 +428,7 @@ dependencies = [
"anyhow",
"env_logger 0.11.9",
"log",
"rand 0.9.2",
"rodio",
"serde",
"serde_derive",
@@ -36,3 +36,6 @@ env_logger = { workspace = true }
# Errors
anyhow = { workspace = true }
# Random
rand = { workspace = true }
@@ -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
@@ -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
@@ -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<JoinHandle<()>> = 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<AtomicU64>,
jitter_millis: Arc<AtomicU64>,
stop: Arc<AtomicBool>,
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<AtomicU64>,
jitter_millis: Arc<AtomicU64>,
stop: Arc<AtomicBool>,
) {
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<R: Rng>(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<AtomicBool>,
thread: Option<JoinHandle<()>>,
}
#[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.
}
}
}
}
@@ -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<u64>,
/// 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<u64>,
/// Jitter at stage 1. Falls back to the previous stage.
pub loop_alert_jitter_millis_1: Option<u64>,
/// Jitter at stage 2. Falls back to stage 1.
pub loop_alert_jitter_millis_2: Option<u64>,
/// Jitter at stage 3. Falls back to stage 2.
pub loop_alert_jitter_millis_3: Option<u64>,
/// Seconds after a loop starts before a second concurrent voice is added.
pub escalate_wait_1: Option<u64>,
/// 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);
@@ -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);
@@ -45,7 +45,8 @@
before replaying. Stages 1–3 can override via
<code>loop_alert_timeout_millis_{1,2,3}</code> — each falls back to the
previous stage if unset, so the loop progressively speeds up as it
escalates.
escalates. <code>loop_alert_jitter_millis[_1|_2|_3]</code> apply
<code>+/- rand(0..=jitter)</code> per sleep.
</p>
<p style="color:#555;font-size:14px;">
<strong>Escalation:</strong> after the loop starts, a second voice joins