enhanced behavior

This commit is contained in:
Brandon Thomas committed 2026-05-21 22:01:30 -04:00
1 parent 12f7f56928
commit 4eb6f16123
6 files changed
+61 -14

No files matched your search

@@ -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
@@ -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
@@ -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<PathBuf>,
/// 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<JoinHandle<()>> = 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<AtomicU64>,
stop: Arc<AtomicBool>,
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<AtomicU64>,
stop: Arc<AtomicBool>,
) {
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;
}
}
@@ -27,6 +27,13 @@ pub struct NotifyConfig {
pub extra_alert_await_sounds: Vec<PathBuf>,
pub loop_alert_timeout_millis: Option<u64>,
/// 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<u64>,
/// Gap (millis) after `escalate_wait_2`. Falls back to stage 1.
pub loop_alert_timeout_millis_2: Option<u64>,
/// Gap (millis) after `escalate_wait_3`. Falls back to stage 2.
pub loop_alert_timeout_millis_3: Option<u64>,
/// Seconds after a loop starts before a second concurrent voice is added.
pub escalate_wait_1: Option<u64>,
@@ -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);
@@ -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);
@@ -42,7 +42,10 @@
</table>
<p style="color:#555;font-size:14px;">
Between iterations a single voice waits <code>loop_alert_timeout_millis</code>
before replaying.
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.
</p>
<p style="color:#555;font-size:14px;">
<strong>Escalation:</strong> after the loop starts, a second voice joins
@@ -50,7 +53,9 @@
<code>escalate_wait_2</code>, and a fourth at <code>escalate_wait_3</code>.
Each new voice is drawn from <code>extra_alert_&lt;state&gt;_sounds</code>;
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.
</p>
</div>