use crate::error::TalkError;
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use tokio_util::sync::CancellationToken;
const WAIT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(1);
const WARMUP_SECS: f64 = 0.20;
const DRAIN_SECS: f64 = 0.15;
const BASE_FREQ: f32 = 364.0;
const THIRD_FREQ: f32 = 458.0;
const TONE_DURATION: f32 = 0.12;
const FADE_IN: f32 = 0.01;
const FADE_OUT: f32 = 0.02;
const NOTE_GAP: f32 = 0.03;
const BOOP_GAP: f32 = 0.045;
const TONE_VOLUME: f32 = 0.05;
const BOOP_VOLUME: f32 = 0.01;
const ALERT_FREQ: f32 = 550.0;
const ALERT_DURATION: f32 = 0.08;
const ALERT_GAP: f32 = 0.06;
const ALERT_VOLUME: f32 = 0.03;
fn generate_tone(freq: f32, duration_secs: f32, volume: f32, sample_rate: u32) -> Vec<f32> {
let num_samples = (duration_secs * sample_rate as f32) as usize;
let fade_in_samples = (FADE_IN * sample_rate as f32) as usize;
let fade_out_samples = (FADE_OUT * sample_rate as f32) as usize;
(0..num_samples)
.map(|i| {
let t = i as f32 / sample_rate as f32;
let sine = (2.0 * std::f32::consts::PI * freq * t).sin();
let envelope = if i < fade_in_samples {
let phase = i as f32 / fade_in_samples as f32;
(std::f32::consts::FRAC_PI_2 * phase).sin()
} else if i >= num_samples - fade_out_samples {
let remaining = (num_samples - i) as f32 / fade_out_samples as f32;
(std::f32::consts::FRAC_PI_2 * remaining).sin()
} else {
1.0
};
sine * envelope * volume
})
.collect()
}
fn generate_silence(duration_secs: f32, sample_rate: u32) -> Vec<f32> {
vec![0.0; (duration_secs * sample_rate as f32) as usize]
}
pub struct IndicatorSounds {
pub start: Vec<f32>,
pub stop: Vec<f32>,
pub boop: Vec<f32>,
pub alert: Vec<f32>,
}
impl IndicatorSounds {
pub fn new(sample_rate: u32) -> Self {
let start = Self::build_start(sample_rate);
let stop = Self::build_stop(sample_rate);
let boop = Self::build_boop(sample_rate);
let alert = Self::build_alert(sample_rate);
Self {
start,
stop,
boop,
alert,
}
}
fn build_start(sr: u32) -> Vec<f32> {
let mut samples = generate_tone(BASE_FREQ, TONE_DURATION, TONE_VOLUME, sr);
samples.extend(generate_silence(NOTE_GAP, sr));
samples.extend(generate_tone(THIRD_FREQ, TONE_DURATION, TONE_VOLUME, sr));
samples
}
fn build_stop(sr: u32) -> Vec<f32> {
let mut samples = generate_tone(THIRD_FREQ, TONE_DURATION, TONE_VOLUME, sr);
samples.extend(generate_silence(NOTE_GAP, sr));
samples.extend(generate_tone(BASE_FREQ, TONE_DURATION, TONE_VOLUME, sr));
samples
}
fn build_boop(sr: u32) -> Vec<f32> {
let mut samples = generate_tone(BASE_FREQ, TONE_DURATION, BOOP_VOLUME, sr);
samples.extend(generate_silence(BOOP_GAP, sr));
samples.extend(generate_tone(BASE_FREQ, TONE_DURATION, BOOP_VOLUME, sr));
samples
}
fn build_alert(sr: u32) -> Vec<f32> {
let mut samples = generate_tone(ALERT_FREQ, ALERT_DURATION, ALERT_VOLUME, sr);
samples.extend(generate_silence(ALERT_GAP, sr));
samples.extend(generate_tone(ALERT_FREQ, ALERT_DURATION, ALERT_VOLUME, sr));
samples.extend(generate_silence(ALERT_GAP, sr));
samples.extend(generate_tone(ALERT_FREQ, ALERT_DURATION, ALERT_VOLUME, sr));
samples
}
}
struct PlaybackState {
samples: Vec<f32>,
position: usize,
}
impl PlaybackState {
fn new() -> Self {
Self {
samples: Vec::new(),
position: 0,
}
}
fn replace(&mut self, samples: Vec<f32>) {
self.samples = samples;
self.position = 0;
}
#[cfg(test)]
fn fill(&mut self, output: &mut [f32]) {
for sample in output.iter_mut() {
if self.position < self.samples.len() {
*sample = self.samples[self.position];
self.position += 1;
} else {
*sample = 0.0;
}
}
}
}
fn fill_output_buffer(
output: &mut [f32],
channels: usize,
state: &Mutex<PlaybackState>,
frames_output: &AtomicU64,
) {
let frames = output.len() / channels;
if let Ok(mut guard) = state.try_lock() {
for frame in 0..frames {
let sample = if guard.position < guard.samples.len() {
let s = guard.samples[guard.position];
guard.position += 1;
s
} else {
0.0
};
for ch in 0..channels {
output[frame * channels + ch] = sample;
}
}
} else {
for sample in output.iter_mut() {
*sample = 0.0;
}
}
frames_output.fetch_add(frames as u64, Ordering::Release);
}
async fn wait_for_playback(state: &Mutex<PlaybackState>, sample_rate: u32, target_len: usize) {
if target_len == 0 {
return;
}
let duration_secs = target_len as f64 / f64::from(sample_rate);
let wall_limit = std::time::Duration::from_secs_f64(duration_secs + DRAIN_SECS + 0.05);
let deadline = tokio::time::Instant::now() + wall_limit;
loop {
let consumed = state.lock().map(|g| g.position).unwrap_or(target_len);
if consumed >= target_len {
tokio::time::sleep(std::time::Duration::from_secs_f64(DRAIN_SECS)).await;
log::debug!(
"play_and_wait: playback complete ({} samples), drained {:.0}ms",
target_len,
DRAIN_SECS * 1000.0,
);
return;
}
if tokio::time::Instant::now() >= deadline {
log::warn!(
"play_and_wait: wall-clock timeout ({:.2}s), position {}/{}",
wall_limit.as_secs_f64(),
consumed,
target_len,
);
return;
}
tokio::time::sleep(std::time::Duration::from_millis(2)).await;
}
}
pub struct SoundPlayer {
state: Arc<Mutex<PlaybackState>>,
pub sounds: IndicatorSounds,
sample_rate: u32,
_stream: cpal::Stream,
}
impl SoundPlayer {
pub fn new() -> Result<Self, TalkError> {
let host = cpal::default_host();
let device = host
.default_output_device()
.ok_or_else(|| TalkError::Audio("no default audio output device found".to_string()))?;
Self::from_device(device)
}
pub fn from_device(device: cpal::Device) -> Result<Self, TalkError> {
let config = device
.default_output_config()
.map_err(|e| TalkError::Audio(format!("failed to get output device config: {}", e)))?;
let sample_rate = config.sample_rate().0;
let channels = config.channels() as usize;
let sounds = IndicatorSounds::new(sample_rate);
let state = Arc::new(Mutex::new(PlaybackState::new()));
let state_cb = Arc::clone(&state);
let frames_output = Arc::new(AtomicU64::new(0));
let frames_output_cb = Arc::clone(&frames_output);
let stream = device
.build_output_stream(
&cpal::StreamConfig {
channels: config.channels(),
sample_rate: config.sample_rate(),
buffer_size: cpal::BufferSize::Default,
},
move |output: &mut [f32], _: &cpal::OutputCallbackInfo| {
fill_output_buffer(output, channels, &state_cb, &frames_output_cb);
},
|err| {
log::error!("audio output error: {}", err);
},
None,
)
.map_err(|e| TalkError::Audio(format!("failed to create output stream: {}", e)))?;
stream
.play()
.map_err(|e| TalkError::Audio(format!("failed to start output stream: {}", e)))?;
let warmup_frames = (WARMUP_SECS * f64::from(sample_rate)) as u64;
let deadline = std::time::Instant::now() + WAIT_TIMEOUT;
loop {
let emitted = frames_output.load(Ordering::Acquire);
if emitted >= warmup_frames {
log::debug!(
"audio output pipeline warm ({} frames emitted, needed {})",
emitted,
warmup_frames,
);
break;
}
if std::time::Instant::now() >= deadline {
log::warn!(
"audio output warmup timed out after {}ms ({} / {} frames) \
— start sound may be clipped",
WAIT_TIMEOUT.as_millis(),
emitted,
warmup_frames,
);
break;
}
std::thread::sleep(std::time::Duration::from_millis(1));
}
Ok(Self {
state,
sounds,
sample_rate,
_stream: stream,
})
}
pub fn play(&self, samples: &[f32]) {
if let Ok(mut guard) = self.state.lock() {
guard.replace(samples.to_vec());
}
}
pub async fn play_and_wait(&self, samples: &[f32]) {
self.play(samples);
wait_for_playback(&self.state, self.sample_rate, samples.len()).await;
}
pub async fn play_start(&self) {
let samples = self.sounds.start.clone();
self.play_and_wait(&samples).await;
}
pub async fn play_stop(&self) {
let samples = self.sounds.stop.clone();
self.play_and_wait(&samples).await;
}
pub fn start_boop_loop(
&self,
interval: std::time::Duration,
play_when: Option<Arc<std::sync::atomic::AtomicBool>>,
suppress: Option<Arc<std::sync::atomic::AtomicBool>>,
) -> CancellationToken {
let token = CancellationToken::new();
let token_clone = token.clone();
let state = Arc::clone(&self.state);
let boop_samples = self.sounds.boop.clone();
tokio::spawn(async move {
run_boop_loop(
interval,
play_when,
suppress,
state,
boop_samples,
token_clone,
)
.await;
});
token
}
pub fn alert_player(&self) -> AlertPlayer {
AlertPlayer {
state: Arc::clone(&self.state),
samples: self.sounds.alert.clone(),
}
}
}
const BOOP_GATE_POLL: std::time::Duration = std::time::Duration::from_millis(100);
async fn run_boop_loop(
interval: std::time::Duration,
play_when: Option<Arc<std::sync::atomic::AtomicBool>>,
suppress: Option<Arc<std::sync::atomic::AtomicBool>>,
state: Arc<Mutex<PlaybackState>>,
boop_samples: Vec<f32>,
cancel: CancellationToken,
) {
'outer: loop {
loop {
let play_when_ok = play_when.as_ref().is_none_or(|f| f.load(Ordering::Relaxed));
if play_when_ok {
break;
}
tokio::select! {
_ = tokio::time::sleep(BOOP_GATE_POLL) => {}
_ = cancel.cancelled() => break 'outer,
}
}
let deadline = tokio::time::Instant::now() + interval;
loop {
let now = tokio::time::Instant::now();
if now >= deadline {
break;
}
let remaining = deadline - now;
let step = remaining.min(BOOP_GATE_POLL);
tokio::select! {
_ = tokio::time::sleep(step) => {}
_ = cancel.cancelled() => break 'outer,
}
let still_open = play_when.as_ref().is_none_or(|f| f.load(Ordering::Relaxed));
if !still_open {
continue 'outer;
}
}
let play_when_ok = play_when.as_ref().is_none_or(|f| f.load(Ordering::Relaxed));
let suppressed = suppress.as_ref().is_some_and(|f| f.load(Ordering::Relaxed));
if play_when_ok && !suppressed {
if let Ok(mut guard) = state.lock() {
guard.replace(boop_samples.clone());
}
}
}
}
pub struct AlertPlayer {
state: Arc<Mutex<PlaybackState>>,
samples: Vec<f32>,
}
impl AlertPlayer {
pub fn play(&self) {
if let Ok(mut guard) = self.state.lock() {
guard.replace(self.samples.clone());
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::AtomicBool;
#[test]
fn test_generate_tone_length() {
let samples = generate_tone(440.0, 0.12, 1.0, 44_100);
let expected = (0.12 * 44_100.0) as usize;
assert_eq!(samples.len(), expected);
}
#[test]
fn test_generate_tone_not_silent() {
let samples = generate_tone(440.0, 0.12, 1.0, 44_100);
let max = samples.iter().copied().fold(0.0_f32, f32::max);
assert!(max > 0.5, "tone should have significant amplitude");
}
#[test]
fn test_generate_tone_fade_envelope() {
let samples = generate_tone(440.0, 0.12, 1.0, 44_100);
assert!(
samples[0].abs() < 0.05,
"first sample should be near zero (fade-in)"
);
assert!(
samples.last().copied().unwrap_or(1.0).abs() < 0.05,
"last sample should be near zero (fade-out)"
);
}
#[test]
fn test_generate_tone_volume_scaling() {
let loud = generate_tone(440.0, 0.12, 1.0, 44_100);
let quiet = generate_tone(440.0, 0.12, 0.01, 44_100);
let max_loud = loud.iter().copied().fold(0.0_f32, f32::max);
let max_quiet = quiet.iter().copied().fold(0.0_f32, f32::max);
assert!(
max_loud > max_quiet * 50.0,
"loud tone should be much louder"
);
}
#[test]
fn test_generate_silence() {
let silence = generate_silence(0.05, 44_100);
assert_eq!(silence.len(), (0.05 * 44_100.0) as usize);
assert!(silence.iter().all(|&s| s == 0.0));
}
#[test]
fn test_indicator_sounds_structure() {
let sounds = IndicatorSounds::new(44_100);
assert_eq!(sounds.start.len(), sounds.stop.len());
let expected_start_len =
(TONE_DURATION * 44_100.0) as usize * 2 + (NOTE_GAP * 44_100.0) as usize;
assert_eq!(sounds.start.len(), expected_start_len);
let expected_boop_len =
(TONE_DURATION * 44_100.0) as usize * 2 + (BOOP_GAP * 44_100.0) as usize;
assert_eq!(sounds.boop.len(), expected_boop_len);
}
#[test]
fn test_start_sound_ascending() {
let sounds = IndicatorSounds::new(44_100);
let tone_len = (TONE_DURATION * 44_100.0) as usize;
let first_tone = &sounds.start[..tone_len];
let second_tone_start = tone_len + (NOTE_GAP * 44_100.0) as usize;
let second_tone = &sounds.start[second_tone_start..];
let crossings = |s: &[f32]| -> usize {
s.windows(2)
.filter(|w| (w[0] >= 0.0) != (w[1] >= 0.0))
.count()
};
assert!(
crossings(second_tone) > crossings(first_tone),
"start sound should be ascending (low→high)"
);
}
#[test]
fn test_stop_sound_descending() {
let sounds = IndicatorSounds::new(44_100);
let tone_len = (TONE_DURATION * 44_100.0) as usize;
let first_tone = &sounds.stop[..tone_len];
let second_tone_start = tone_len + (NOTE_GAP * 44_100.0) as usize;
let second_tone = &sounds.stop[second_tone_start..];
let crossings = |s: &[f32]| -> usize {
s.windows(2)
.filter(|w| (w[0] >= 0.0) != (w[1] >= 0.0))
.count()
};
assert!(
crossings(first_tone) > crossings(second_tone),
"stop sound should be descending (high→low)"
);
}
#[test]
fn test_boop_is_quieter_than_start() {
let sounds = IndicatorSounds::new(44_100);
let max_start = sounds.start.iter().copied().fold(0.0_f32, f32::max);
let max_boop = sounds.boop.iter().copied().fold(0.0_f32, f32::max);
assert!(
max_start > max_boop * 3.0,
"boop should be much quieter than start"
);
}
#[test]
fn test_playback_state_replace_preempts() {
let mut state = PlaybackState::new();
state.replace(vec![1.0, 2.0, 3.0]);
let mut out = [0.0; 2];
state.fill(&mut out);
assert_eq!(out, [1.0, 2.0]);
assert_eq!(state.position, 2);
state.replace(vec![9.0, 8.0]);
let mut out2 = [0.0; 2];
state.fill(&mut out2);
assert_eq!(out2, [9.0, 8.0]);
}
#[test]
fn test_playback_state_silence_after_exhausted() {
let mut state = PlaybackState::new();
state.replace(vec![1.0]);
let mut out = [0.0; 3];
state.fill(&mut out);
assert_eq!(out, [1.0, 0.0, 0.0]);
}
#[test]
fn test_fill_output_buffer_plays_samples() {
let state = Arc::new(Mutex::new(PlaybackState::new()));
let frames_output = Arc::new(AtomicU64::new(0));
state.lock().unwrap().replace(vec![0.5, -0.3, 0.7]);
let mut output = vec![0.0f32; 4]; fill_output_buffer(&mut output, 1, &state, &frames_output);
assert_eq!(output, [0.5, -0.3, 0.7, 0.0]);
assert_eq!(state.lock().unwrap().position, 3);
}
#[test]
fn test_fill_output_buffer_multichannel_duplication() {
let state = Arc::new(Mutex::new(PlaybackState::new()));
let frames_output = Arc::new(AtomicU64::new(0));
state.lock().unwrap().replace(vec![0.5, -0.3]);
let mut output = vec![0.0f32; 4];
fill_output_buffer(&mut output, 2, &state, &frames_output);
assert_eq!(output, [0.5, 0.5, -0.3, -0.3]);
assert_eq!(frames_output.load(Ordering::Acquire), 2);
}
#[test]
fn test_fill_output_buffer_increments_frames_output() {
let state = Arc::new(Mutex::new(PlaybackState::new()));
let frames_output = Arc::new(AtomicU64::new(0));
let mut output = vec![0.0f32; 8]; fill_output_buffer(&mut output, 1, &state, &frames_output);
assert_eq!(frames_output.load(Ordering::Acquire), 8);
let mut output2 = vec![0.0f32; 4];
fill_output_buffer(&mut output2, 1, &state, &frames_output);
assert_eq!(frames_output.load(Ordering::Acquire), 12);
}
#[test]
fn test_fill_output_buffer_contended_lock_outputs_silence() {
let state = Arc::new(Mutex::new(PlaybackState::new()));
let frames_output = Arc::new(AtomicU64::new(0));
state.lock().unwrap().replace(vec![1.0, 2.0, 3.0]);
let _guard = state.lock().unwrap();
let mut output = vec![0.0f32; 3];
fill_output_buffer(&mut output, 1, &state, &frames_output);
assert_eq!(output, [0.0, 0.0, 0.0]);
assert_eq!(frames_output.load(Ordering::Acquire), 3);
}
#[test]
fn test_fill_output_buffer_silence_after_samples_exhausted() {
let state = Arc::new(Mutex::new(PlaybackState::new()));
let frames_output = Arc::new(AtomicU64::new(0));
state.lock().unwrap().replace(vec![1.0]);
let mut output = vec![0.0f32; 4];
fill_output_buffer(&mut output, 1, &state, &frames_output);
assert_eq!(output, [1.0, 0.0, 0.0, 0.0]);
assert_eq!(state.lock().unwrap().position, 1);
}
struct CallbackDriver {
stop: Arc<AtomicBool>,
thread: Option<std::thread::JoinHandle<Vec<f32>>>,
}
impl CallbackDriver {
fn start(
state: Arc<Mutex<PlaybackState>>,
frames_output: Arc<AtomicU64>,
channels: usize,
frames_per_callback: usize,
interval: std::time::Duration,
) -> Self {
let stop = Arc::new(AtomicBool::new(false));
let stop_cb = Arc::clone(&stop);
let thread = std::thread::spawn(move || {
let mut captured = Vec::new();
while !stop_cb.load(Ordering::Acquire) {
let mut buffer = vec![0.0f32; frames_per_callback * channels];
fill_output_buffer(&mut buffer, channels, &state, &frames_output);
captured.extend_from_slice(&buffer);
std::thread::sleep(interval);
}
captured
});
Self {
stop,
thread: Some(thread),
}
}
fn stop(mut self) -> Vec<f32> {
self.stop.store(true, Ordering::Release);
self.thread
.take()
.expect("thread already joined")
.join()
.expect("callback thread panicked")
}
}
impl Drop for CallbackDriver {
fn drop(&mut self) {
self.stop.store(true, Ordering::Release);
if let Some(thread) = self.thread.take() {
let _ = thread.join();
}
}
}
#[tokio::test]
async fn test_wait_for_playback_completes_with_active_callback() {
let state = Arc::new(Mutex::new(PlaybackState::new()));
let frames_output = Arc::new(AtomicU64::new(0));
let sample_rate = 48_000u32;
let input = vec![0.5f32; 4800];
let target_len = input.len();
state.lock().unwrap().replace(input);
let driver = CallbackDriver::start(
Arc::clone(&state),
Arc::clone(&frames_output),
1,
256,
std::time::Duration::from_millis(5),
);
let start = std::time::Instant::now();
wait_for_playback(&state, sample_rate, target_len).await;
let elapsed = start.elapsed();
let pos = state.lock().unwrap().position;
assert!(
pos >= target_len,
"position {} should be >= {}",
pos,
target_len,
);
assert!(
elapsed.as_millis() >= 100,
"should include drain time, took {:?}",
elapsed,
);
assert!(
elapsed.as_millis() < 1000,
"should complete well within 1 s, took {:?}",
elapsed,
);
let output = driver.stop();
for (i, &s) in output.iter().take(target_len).enumerate() {
assert!(
(s - 0.5).abs() < f32::EPSILON,
"output[{}] should be 0.5, got {}",
i,
s,
);
}
}
#[tokio::test]
async fn test_wait_for_playback_wall_clock_fallback_without_callback() {
let state = Arc::new(Mutex::new(PlaybackState::new()));
state.lock().unwrap().replace(vec![0.5f32; 4800]);
let start = std::time::Instant::now();
wait_for_playback(&state, 48_000, 4800).await;
let elapsed = start.elapsed();
assert!(
elapsed.as_millis() < 600,
"wall-clock fallback should fire within 600 ms, took {:?}",
elapsed,
);
assert!(
elapsed.as_millis() >= 200,
"should wait at least duration + drain, took {:?}",
elapsed,
);
}
#[tokio::test]
async fn test_wait_for_playback_empty_returns_immediately() {
let state = Arc::new(Mutex::new(PlaybackState::new()));
let start = std::time::Instant::now();
wait_for_playback(&state, 48_000, 0).await;
let elapsed = start.elapsed();
assert!(
elapsed.as_millis() < 10,
"empty playback should return immediately, took {:?}",
elapsed,
);
}
#[tokio::test]
async fn test_preemption_replaces_in_progress_sound() {
let state = Arc::new(Mutex::new(PlaybackState::new()));
let frames_output = Arc::new(AtomicU64::new(0));
state.lock().unwrap().replace(vec![1.0f32; 10_000]);
let driver = CallbackDriver::start(
Arc::clone(&state),
Arc::clone(&frames_output),
1,
256,
std::time::Duration::from_millis(2),
);
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
state.lock().unwrap().replace(vec![-0.5f32; 100]);
wait_for_playback(&state, 48_000, 100).await;
let output = driver.stop();
let has_original = output.iter().any(|&s| (s - 1.0).abs() < f32::EPSILON);
let has_replacement = output.iter().any(|&s| (s - (-0.5)).abs() < f32::EPSILON);
assert!(has_original, "should have played some of the original");
assert!(has_replacement, "should have played the replacement");
}
async fn drive_boop_loop(
interval: std::time::Duration,
play_when: Option<Arc<AtomicBool>>,
suppress: Option<Arc<AtomicBool>>,
run_for: std::time::Duration,
gate_script: Option<Box<dyn FnOnce(Arc<AtomicBool>) + Send + 'static>>,
) -> u32 {
let state = Arc::new(Mutex::new(PlaybackState::new()));
let boop_samples = vec![0.42_f32; 8];
let cancel = CancellationToken::new();
if let (Some(script), Some(gate)) = (gate_script, play_when.as_ref()) {
let gate = Arc::clone(gate);
tokio::spawn(async move { script(gate) });
}
let loop_state = Arc::clone(&state);
let loop_samples = boop_samples.clone();
let loop_cancel = cancel.clone();
let loop_play_when = play_when.clone();
let loop_suppress = suppress.clone();
let handle = tokio::spawn(async move {
run_boop_loop(
interval,
loop_play_when,
loop_suppress,
loop_state,
loop_samples,
loop_cancel,
)
.await;
});
let mut emitted = 0u32;
let deadline = tokio::time::Instant::now() + run_for;
loop {
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
if tokio::time::Instant::now() >= deadline {
break;
}
let mut guard = match state.lock() {
Ok(g) => g,
Err(_) => continue,
};
if guard.samples == boop_samples {
emitted += 1;
guard.samples.clear();
guard.position = 0;
}
}
cancel.cancel();
let _ = handle.await;
emitted
}
#[tokio::test]
async fn test_boop_loop_no_play_when_fires_periodically() {
let emitted = drive_boop_loop(
std::time::Duration::from_millis(50),
None,
None,
std::time::Duration::from_millis(280),
None,
)
.await;
assert!(
(4..=6).contains(&emitted),
"expected ~5 boops at 50 ms cadence over 280 ms, got {}",
emitted,
);
}
#[tokio::test]
async fn test_boop_loop_play_when_false_silences_all() {
let gate = Arc::new(AtomicBool::new(false));
let emitted = drive_boop_loop(
std::time::Duration::from_millis(50),
Some(gate),
None,
std::time::Duration::from_millis(300),
None,
)
.await;
assert_eq!(emitted, 0, "expected zero boops while play_when is false");
}
#[tokio::test]
async fn test_boop_loop_suppress_overrides_play_when() {
let play = Arc::new(AtomicBool::new(true));
let suppress = Arc::new(AtomicBool::new(true));
let emitted = drive_boop_loop(
std::time::Duration::from_millis(50),
Some(play),
Some(suppress),
std::time::Duration::from_millis(300),
None,
)
.await;
assert_eq!(
emitted, 0,
"suppress=true must silence boops even when play_when=true",
);
}
#[tokio::test]
async fn test_boop_loop_phase_resets_on_play_when_rising_edge() {
let gate = Arc::new(AtomicBool::new(false));
let emitted = drive_boop_loop(
std::time::Duration::from_millis(100),
Some(gate),
None,
std::time::Duration::from_millis(350),
Some(Box::new(|gate: Arc<AtomicBool>| {
tokio::spawn(async move {
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
gate.store(true, Ordering::Relaxed);
});
})),
)
.await;
assert_eq!(
emitted, 1,
"expected exactly 1 boop ~100ms after the gate flipped open at t=200ms; \
a count != 1 means the interval clock did NOT restart on the rising edge",
);
}
#[tokio::test]
async fn test_boop_loop_short_listening_burst_emits_no_boop() {
let gate = Arc::new(AtomicBool::new(false));
let emitted = drive_boop_loop(
std::time::Duration::from_millis(200),
Some(gate),
None,
std::time::Duration::from_millis(400),
Some(Box::new(|gate: Arc<AtomicBool>| {
tokio::spawn(async move {
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
gate.store(true, Ordering::Relaxed);
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
gate.store(false, Ordering::Relaxed);
});
})),
)
.await;
assert_eq!(
emitted, 0,
"a 100ms listening burst with a 200ms interval must emit zero boops; \
got {} (clock was not reset on falling edge)",
emitted,
);
}
#[tokio::test]
async fn test_wait_for_playback_timing_is_bounded() {
let state = Arc::new(Mutex::new(PlaybackState::new()));
for &duration_ms in &[50u64, 100, 200, 300] {
let n_samples = (48_000u64 * duration_ms / 1000) as usize;
state.lock().unwrap().replace(vec![0.1f32; n_samples]);
let start = std::time::Instant::now();
wait_for_playback(&state, 48_000, n_samples).await;
let elapsed = start.elapsed();
let upper_ms = duration_ms + 150 + 50 + 100;
assert!(
elapsed.as_millis() < upper_ms as u128,
"{} ms sound: elapsed {:?} should be < {} ms",
duration_ms,
elapsed,
upper_ms,
);
}
}
}