use std::f32::consts::PI;
use timestretch::{analyze_for_dj, ControlPath, EdmPreset, StreamProcessor, StretchParams};
const SR: u32 = 44_100;
const CHUNK: usize = 1024;
fn stream_params(ratio: f64) -> StretchParams {
StretchParams::new(ratio)
.with_sample_rate(SR)
.with_channels(1)
.with_preset(EdmPreset::DjBeatmatch)
}
fn click_train(sample_rate: u32, bpm: f64, seconds: f64) -> Vec<f32> {
let len = (sample_rate as f64 * seconds) as usize;
let beat_interval = (60.0 * sample_rate as f64 / bpm) as usize;
let mut out = vec![0.0f32; len];
for i in (0..len).step_by(beat_interval.max(1)) {
for j in 0..10.min(len - i) {
out[i + j] = if j < 5 { 1.0 } else { -0.4 };
}
}
for (i, s) in out.iter_mut().enumerate() {
let t = i as f32 / sample_rate as f32;
*s += 0.15 * (2.0 * PI * 110.0 * t).sin();
}
out
}
#[test]
fn test_source_position_tracks_passthrough_then_dsp() {
let mut processor = StreamProcessor::new(stream_params(1.0));
assert_eq!(processor.source_position(), 0);
let input = click_train(SR, 128.0, 2.0);
let mut output = Vec::with_capacity(input.len() * 4);
let mut pushed = 0usize;
for chunk in input.chunks(CHUNK).take(8) {
processor
.process_into(chunk, &mut output)
.expect("unity process");
pushed += chunk.len();
assert_eq!(
processor.source_position(),
pushed,
"passthrough position must equal frames pushed"
);
}
processor
.set_stretch_ratio(1.1)
.expect("ratio change should be accepted");
let mut last_position = processor.source_position();
for chunk in input.chunks(CHUNK).skip(8) {
processor.process_into(chunk, &mut output).expect("process");
pushed += chunk.len();
let position = processor.source_position();
assert!(position >= last_position, "position must be monotonic");
assert!(
position <= pushed,
"position {} must not exceed pushed frames {}",
position,
pushed
);
last_position = position;
}
assert!(
last_position > 8 * CHUNK,
"DSP path must advance the position past the passthrough prefix"
);
}
#[test]
fn test_set_source_position_rejected_mid_stream() {
let mut processor = StreamProcessor::new(stream_params(1.1));
let input = click_train(SR, 128.0, 1.0);
let mut output = Vec::with_capacity(input.len() * 4);
processor
.process_into(&input[..CHUNK], &mut output)
.expect("process");
assert!(processor.set_source_position(0).is_err());
let mut processor = StreamProcessor::new(stream_params(1.0));
output.clear();
processor
.process_into(&input[..CHUNK], &mut output)
.expect("unity process");
assert!(processor.set_source_position(0).is_err());
}
#[test]
fn test_reset_returns_position_to_zero() {
let mut processor = StreamProcessor::new(stream_params(1.1));
processor
.set_source_position(123_456)
.expect("fresh processor accepts a source position");
assert_eq!(processor.source_position(), 123_456);
let input = click_train(SR, 128.0, 1.0);
let mut output = Vec::with_capacity(input.len() * 4);
for chunk in input.chunks(CHUNK) {
processor.process_into(chunk, &mut output).expect("process");
}
assert!(processor.source_position() > 123_456);
processor.reset();
assert_eq!(processor.source_position(), 0);
processor
.set_source_position(777)
.expect("freshly-reset processor accepts a source position");
assert_eq!(processor.source_position(), 777);
}
#[test]
fn test_artifact_attaches_via_params_and_setter() {
let input = click_train(SR, 128.0, 4.0);
let artifact = analyze_for_dj(&input, SR);
let params = stream_params(1.1)
.with_pre_analysis(artifact.clone())
.with_beat_snap_confidence_threshold(0.1);
let mut processor = StreamProcessor::new(params);
let mut output = Vec::with_capacity(input.len() * 4);
for chunk in input.chunks(CHUNK) {
processor.process_into(chunk, &mut output).expect("process");
}
assert!(!output.is_empty());
let mut processor =
StreamProcessor::new(stream_params(1.1).with_beat_snap_confidence_threshold(0.1));
processor.set_pre_analysis(Some(artifact));
output.clear();
for chunk in input.chunks(CHUNK) {
processor.process_into(chunk, &mut output).expect("process");
}
assert!(!output.is_empty());
}
fn detect_peaks(signal: &[f32], threshold: f32, min_distance: usize) -> Vec<usize> {
let mut peaks: Vec<usize> = Vec::new();
for i in 1..signal.len().saturating_sub(1) {
let s = signal[i].abs();
if s >= threshold && s >= signal[i - 1].abs() && s >= signal[i + 1].abs() {
if let Some(&last) = peaks.last() {
if i - last < min_distance {
if s > signal[last].abs() {
*peaks.last_mut().unwrap() = i;
}
continue;
}
}
peaks.push(i);
}
}
peaks
}
#[test]
fn test_artifact_scheduled_resets_replace_online_scheduler() {
let input = click_train(SR, 128.0, 4.0);
let artifact = analyze_for_dj(&input, SR);
assert!(artifact.transient_onsets.len() >= 6);
let params = stream_params(1.1)
.with_pre_analysis(artifact.clone())
.with_beat_snap_confidence_threshold(0.1);
let mut processor = StreamProcessor::new(params);
let mut output = Vec::with_capacity(input.len() * 4);
for chunk in input.chunks(CHUNK) {
processor.process_into(chunk, &mut output).expect("process");
}
let stats = processor.transient_reset_stats();
assert_eq!(
stats.events_detected_total, 0,
"online scheduler must stay idle while an artifact drives resets"
);
let consumed = stats.input_frames_consumed_total;
let expected = artifact
.transient_onsets
.iter()
.filter(|&&onset| onset < consumed)
.count() as u64;
assert!(expected > 0, "test input too short to consume any onsets");
assert_eq!(stats.artifact_events_scheduled_total, expected);
assert!(
stats.reset_band_counts_total[2] > 0 && stats.reset_band_counts_total[3] > 0,
"artifact events must reset the upper bands"
);
}
#[test]
fn test_seek_offsets_artifact_scheduling() {
let input = click_train(SR, 128.0, 4.0);
let artifact = analyze_for_dj(&input, SR);
assert!(artifact.transient_onsets.len() >= 4);
let seek = (artifact.transient_onsets[1] + artifact.transient_onsets[2]) / 2;
let params = stream_params(1.1)
.with_pre_analysis(artifact.clone())
.with_beat_snap_confidence_threshold(0.1);
let mut processor = StreamProcessor::new(params);
processor.set_source_position(seek).expect("seek position");
let mut output = Vec::with_capacity(input.len() * 4);
for chunk in input[seek..].chunks(CHUNK) {
processor.process_into(chunk, &mut output).expect("process");
}
let stats = processor.transient_reset_stats();
assert_eq!(stats.events_detected_total, 0);
let consumed_end = seek + stats.input_frames_consumed_total;
let expected = artifact
.transient_onsets
.iter()
.filter(|&&onset| onset >= seek && onset < consumed_end)
.count() as u64;
assert!(expected >= 2, "seek test needs onsets past the seek point");
assert_eq!(
stats.artifact_events_scheduled_total, expected,
"onsets before the seek point must not fire; those after must"
);
}
#[test]
fn test_artifact_low_band_resets_suppressed_during_ratio_glide() {
let input = click_train(SR, 128.0, 4.0);
let artifact = analyze_for_dj(&input, SR);
let params = stream_params(1.08)
.with_pre_analysis(artifact)
.with_beat_snap_confidence_threshold(0.1);
let mut processor = StreamProcessor::new(params);
let mut output = Vec::with_capacity(input.len() * 4);
let mut toggle = false;
for chunk in input.chunks(CHUNK) {
toggle = !toggle;
processor
.set_stretch_ratio(if toggle { 1.12 } else { 1.08 })
.expect("ratio change");
processor.process_into(chunk, &mut output).expect("process");
}
let stats = processor.transient_reset_stats();
assert!(stats.artifact_events_scheduled_total > 0);
assert_eq!(
stats.reset_band_counts_total[0], 0,
"sub-bass resets must stay suppressed during modulation"
);
assert_eq!(
stats.reset_band_counts_total[1], 0,
"low-band resets must stay suppressed during modulation"
);
assert!(
stats.reset_band_counts_total[2] > 0,
"upper bands still re-lock during modulation"
);
}
#[test]
fn test_varispeed_artifact_onsets_fire_once_at_source_positions_under_ride() {
let input = click_train(SR, 128.0, 4.0);
let artifact = analyze_for_dj(&input, SR);
assert!(artifact.transient_onsets.len() >= 6);
let params = stream_params(1.06)
.with_pre_analysis(artifact.clone())
.with_beat_snap_confidence_threshold(0.1);
let mut processor = StreamProcessor::new(params);
processor
.set_control_path(ControlPath::VarispeedFirst)
.expect("varispeed path");
let mut output = Vec::with_capacity(input.len() * 6);
for (i, chunk) in input.chunks(CHUNK).enumerate() {
let t = i as f64 * CHUNK as f64 / SR as f64;
processor
.set_stretch_ratio(1.06 + 0.04 * (2.0 * std::f64::consts::PI * t / 2.0).sin())
.expect("ride ratio");
processor.process_into(chunk, &mut output).expect("process");
}
let stats = processor.transient_reset_stats();
assert_eq!(
stats.events_detected_total, 0,
"online scheduler must stay idle while an artifact drives resets"
);
let consumed_source = processor.source_position();
let expected = artifact
.transient_onsets
.iter()
.filter(|&&onset| onset < consumed_source)
.count() as u64;
assert!(expected >= 4, "ride test too short to consume onsets");
assert_eq!(
stats.artifact_events_scheduled_total, expected,
"artifact onsets must fire exactly once at mapped source positions"
);
assert!(
stats.reset_band_counts_total[2] > 0 && stats.reset_band_counts_total[3] > 0,
"artifact events must reset the upper bands"
);
}
#[test]
fn test_artifact_stream_transient_preservation_parity() {
let bpm = 128.0;
let input = click_train(SR, bpm, 4.0);
let artifact = analyze_for_dj(&input, SR);
let ratio = 1.1;
let run = |params: StretchParams| {
let mut processor = StreamProcessor::new(params);
let mut output = Vec::with_capacity(input.len() * 4);
for chunk in input.chunks(CHUNK) {
processor.process_into(chunk, &mut output).expect("process");
}
let mut tail = Vec::with_capacity(input.len() * 2);
processor.flush_into(&mut tail).expect("flush");
output.extend_from_slice(&tail);
output
};
let out_online = run(stream_params(ratio));
let out_artifact = run(stream_params(ratio)
.with_pre_analysis(artifact)
.with_beat_snap_confidence_threshold(0.1));
let beat_interval = 60.0 * SR as f64 / bpm;
let min_distance = (beat_interval * ratio * 0.5) as usize;
let peaks_online = detect_peaks(&out_online, 0.3, min_distance);
let peaks_artifact = detect_peaks(&out_artifact, 0.3, min_distance);
assert!(
peaks_artifact.len() + 1 >= peaks_online.len(),
"artifact-driven stream preserved fewer transients than online: {} vs {}",
peaks_artifact.len(),
peaks_online.len()
);
assert!(
peaks_artifact.len() >= 4,
"artifact-driven stream lost transients: {} peaks",
peaks_artifact.len()
);
}