mirror of
https://github.com/storytold/artcraft.git
synced 2026-11-11 04:16:55 +00:00
configure jitter
This commit is contained in:
1 parent
4eb6f16123
commit
bfc6104e66
8 files changed
+145
-12
No files matched your search
Generated
+1
@@ -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
|
||||
|
||||
Reference in new issue
Block a user