use rustfft::{num_complex::Complex, FftPlanner};
use crate::core::fft::COMPLEX_ZERO;
use crate::core::types::{StretchParams, TransientThresholdPolicy};
const FLUX_WEIGHT: f32 = 0.6;
const ENERGY_WEIGHT: f32 = 0.4;
const SPECTRAL_FLUX_ONSET_WEIGHT: f32 = 0.6;
const PHASE_DEVIATION_ONSET_WEIGHT: f32 = 0.4;
const ENERGY_SMOOTH_ALPHA: f32 = 0.9;
const GAMMA_FLUX: f32 = 40.0;
const GAMMA_ENERGY: f32 = 20.0;
const ONSET_MASK_RATIO: f32 = 0.6;
const GLOBAL_ONSET_FLOOR_RATIO: f32 = 0.12;
const MAD_FLOOR_MEDIAN_RATIO: f32 = 0.06;
#[derive(Debug, Clone)]
pub struct TransientMap {
pub onsets: Vec<usize>,
pub onsets_fractional: Vec<f64>,
pub strengths: Vec<f32>,
pub flux: Vec<f32>,
pub hop_size: usize,
pub per_frame_band_flux: Vec<[f32; 4]>,
}
#[derive(Debug, Clone, Copy)]
pub struct TransientDetectionOptions {
pub lookahead_confirm_frames: usize,
pub lookahead_threshold_relax: f32,
pub lookahead_peak_retain_ratio: f32,
pub strong_spike_bypass_multiplier: f32,
pub threshold_policy: TransientThresholdPolicy,
}
impl TransientDetectionOptions {
#[inline]
pub fn from_stretch_params(params: &StretchParams) -> Self {
Self {
lookahead_confirm_frames: params.transient_lookahead_frames,
lookahead_threshold_relax: params.transient_lookahead_threshold_relax,
lookahead_peak_retain_ratio: params.transient_lookahead_peak_retain_ratio,
strong_spike_bypass_multiplier: params.transient_strong_spike_bypass_multiplier,
threshold_policy: params.transient_threshold_policy.sanitized(),
}
}
}
const BAND_FLUX_LOW_LIMIT: f32 = 100.0;
const BAND_FLUX_MID_LIMIT: f32 = 500.0;
const BAND_FLUX_HIGH_LIMIT: f32 = 4000.0;
fn compute_spectral_flux(
samples: &[f32],
sample_rate: u32,
fft_size: usize,
hop_size: usize,
) -> (Vec<f32>, Vec<[f32; 4]>) {
let mut planner = FftPlanner::new();
let fft = planner.plan_fft_forward(fft_size);
let window =
crate::core::window::generate_window(crate::core::window::WindowType::Hann, fft_size);
let bin_weights = compute_bin_weights(fft_size, sample_rate);
let bin_freq = sample_rate as f32 / fft_size as f32;
let num_bins = fft_size / 2 + 1;
let num_frames = (samples.len() - fft_size) / hop_size + 1;
let mut prev_magnitude = vec![0.0f32; num_bins];
let mut flux_values = Vec::with_capacity(num_frames);
let mut band_flux_values = Vec::with_capacity(num_frames);
let mut fft_buffer = vec![COMPLEX_ZERO; fft_size];
for frame_idx in 0..num_frames {
let start = frame_idx * hop_size;
for (buf, (&s, &w)) in fft_buffer
.iter_mut()
.zip(samples[start..].iter().zip(window.iter()))
{
*buf = Complex::new(s * w, 0.0);
}
fft.process(&mut fft_buffer);
let mut flux = 0.0f32;
let mut band_flux = [0.0f32; 4]; for (bin, ((&c, prev), &weight)) in fft_buffer[..num_bins]
.iter()
.zip(prev_magnitude.iter_mut())
.zip(bin_weights[..num_bins].iter())
.enumerate()
{
let mag = (1.0 + GAMMA_FLUX * c.norm()).ln();
let diff = mag - *prev;
if diff > 0.0 {
flux += diff * weight;
let freq = bin as f32 * bin_freq;
let band_idx = if freq < BAND_FLUX_LOW_LIMIT {
0 } else if freq < BAND_FLUX_MID_LIMIT {
1 } else if freq < BAND_FLUX_HIGH_LIMIT {
2 } else {
3 };
band_flux[band_idx] += diff;
}
*prev = mag;
}
flux_values.push(flux);
band_flux_values.push(band_flux);
}
if let Some(first) = flux_values.first_mut() {
*first = 0.0;
}
if let Some(first) = band_flux_values.first_mut() {
*first = [0.0; 4];
}
(flux_values, band_flux_values)
}
fn compute_onset_energy(samples: &[f32], fft_size: usize, hop_size: usize) -> Vec<f32> {
let num_frames = (samples.len() - fft_size) / hop_size + 1;
let mut energies = Vec::with_capacity(num_frames);
let inv_fft = 1.0 / fft_size as f32;
for frame_idx in 0..num_frames {
let start = frame_idx * hop_size;
let end = start + fft_size;
let rms: f32 = samples[start..end].iter().map(|&s| s * s).sum::<f32>() * inv_fft;
energies.push((1.0 + GAMMA_ENERGY * rms.sqrt()).ln());
}
let mut envelope = Vec::with_capacity(num_frames);
let mut smoothed = 0.0f32;
for i in 0..num_frames {
let diff = if i > 0 {
(energies[i] - energies[i - 1]).max(0.0)
} else {
0.0
};
smoothed = ENERGY_SMOOTH_ALPHA * smoothed + (1.0 - ENERGY_SMOOTH_ALPHA) * diff;
envelope.push(smoothed);
}
envelope
}
#[inline]
fn wrap_phase(phase: f32) -> f32 {
let two_pi = 2.0 * std::f32::consts::PI;
let p = phase + std::f32::consts::PI;
p - (p / two_pi).floor() * two_pi - std::f32::consts::PI
}
fn compute_phase_deviation(
samples: &[f32],
sample_rate: u32,
fft_size: usize,
hop_size: usize,
) -> (Vec<f32>, Vec<PhaseDeviationInfo>) {
let mut planner = FftPlanner::new();
let fft = planner.plan_fft_forward(fft_size);
let window =
crate::core::window::generate_window(crate::core::window::WindowType::Hann, fft_size);
let bin_weights = compute_bin_weights(fft_size, sample_rate);
let num_bins = fft_size / 2 + 1;
let num_frames = if samples.len() >= fft_size {
(samples.len() - fft_size) / hop_size + 1
} else {
return (vec![], vec![]);
};
let two_pi = 2.0 * std::f32::consts::PI;
let phase_advance: Vec<f32> = (0..num_bins)
.map(|bin| two_pi * bin as f32 * hop_size as f32 / fft_size as f32)
.collect();
let mut prev_phase = vec![0.0f32; num_bins];
let mut deviation_values = Vec::with_capacity(num_frames);
let mut deviation_info = Vec::with_capacity(num_frames);
let mut fft_buffer = vec![COMPLEX_ZERO; fft_size];
for frame_idx in 0..num_frames {
let start = frame_idx * hop_size;
for (buf, (&s, &w)) in fft_buffer
.iter_mut()
.zip(samples[start..].iter().zip(window.iter()))
{
*buf = Complex::new(s * w, 0.0);
}
fft.process(&mut fft_buffer);
let mut total_deviation = 0.0f32;
let mut max_deviation = 0.0f32;
let mut max_deviation_bin: usize = 0;
let mut max_deviation_phase_diff = 0.0f32;
for (bin, ((&c, prev), &weight)) in fft_buffer[..num_bins]
.iter()
.zip(prev_phase.iter_mut())
.zip(bin_weights[..num_bins].iter())
.enumerate()
{
let mag = (1.0 + GAMMA_FLUX * c.norm()).ln();
let current_phase = c.arg();
let expected = *prev + phase_advance[bin];
let deviation = wrap_phase(current_phase - expected).abs();
let weighted = deviation * weight * mag;
total_deviation += weighted;
if weighted > max_deviation {
max_deviation = weighted;
max_deviation_bin = bin;
max_deviation_phase_diff = wrap_phase(current_phase - expected);
}
*prev = current_phase;
}
deviation_values.push(total_deviation);
deviation_info.push(PhaseDeviationInfo {
strongest_bin: max_deviation_bin,
phase_diff: max_deviation_phase_diff,
});
}
if let Some(first) = deviation_values.first_mut() {
*first = 0.0;
}
(deviation_values, deviation_info)
}
#[derive(Debug, Clone, Copy)]
struct PhaseDeviationInfo {
strongest_bin: usize,
phase_diff: f32,
}
fn compute_fractional_positions(
onsets: &[usize],
deviation_info: &[PhaseDeviationInfo],
hop_size: usize,
fft_size: usize,
) -> Vec<f64> {
let two_pi = 2.0 * std::f64::consts::PI;
onsets
.iter()
.map(|&onset_sample| {
let frame_idx = onset_sample / hop_size;
if frame_idx >= deviation_info.len() {
return onset_sample as f64;
}
let info = deviation_info[frame_idx];
if info.strongest_bin == 0 {
return onset_sample as f64;
}
let bin_freq_factor = two_pi * info.strongest_bin as f64 / fft_size as f64;
let fractional_offset = -(info.phase_diff as f64) / bin_freq_factor;
let clamped = fractional_offset.clamp(-(hop_size as f64), hop_size as f64);
(onset_sample as f64 + clamped).max(0.0)
})
.collect()
}
const ENERGY_GATE_THRESHOLD: f32 = 0.01;
fn combine_detection_functions(
flux: &[f32],
energy: &[f32],
phase_deviation: &[f32],
) -> (Vec<f32>, bool) {
let max_flux = flux.iter().copied().fold(0.0f32, f32::max);
let max_energy = energy.iter().copied().fold(0.0f32, f32::max);
let max_phase = phase_deviation.iter().copied().fold(0.0f32, f32::max);
let flux_norm = if max_flux > 1e-10 { max_flux } else { 1.0 };
let phase_norm = if max_phase > 1e-10 { max_phase } else { 1.0 };
let use_energy = max_energy > ENERGY_GATE_THRESHOLD;
let energy_norm = if use_energy { max_energy } else { 1.0 };
let n = flux.len();
let combined: Vec<f32> = (0..n)
.map(|i| {
let f = flux[i] / flux_norm;
let pd = if i < phase_deviation.len() {
phase_deviation[i] / phase_norm
} else {
0.0
};
let spectral_combined =
SPECTRAL_FLUX_ONSET_WEIGHT * f + PHASE_DEVIATION_ONSET_WEIGHT * pd;
let e_contrib = if use_energy {
ENERGY_WEIGHT * (energy[i] / energy_norm)
} else {
0.0
};
FLUX_WEIGHT * spectral_combined + e_contrib
})
.collect();
(combined, use_energy)
}
pub fn detect_transients(
samples: &[f32],
sample_rate: u32,
fft_size: usize,
hop_size: usize,
sensitivity: f32,
) -> TransientMap {
let default_params = StretchParams::default();
detect_transients_with_options(
samples,
sample_rate,
fft_size,
hop_size,
sensitivity,
TransientDetectionOptions::from_stretch_params(&default_params),
)
}
pub fn detect_transients_with_options(
samples: &[f32],
sample_rate: u32,
fft_size: usize,
hop_size: usize,
sensitivity: f32,
options: TransientDetectionOptions,
) -> TransientMap {
if samples.len() < fft_size {
return TransientMap {
onsets: vec![],
onsets_fractional: vec![],
strengths: vec![],
flux: vec![],
hop_size,
per_frame_band_flux: vec![],
};
}
let (flux_values, band_flux) = compute_spectral_flux(samples, sample_rate, fft_size, hop_size);
let energy_envelope = compute_onset_energy(samples, fft_size, hop_size);
let (phase_deviation, deviation_info) =
compute_phase_deviation(samples, sample_rate, fft_size, hop_size);
let (combined, energy_active) =
combine_detection_functions(&flux_values, &energy_envelope, &phase_deviation);
let onsets = if energy_active {
let min_gap = options
.threshold_policy
.sanitized()
.min_gap_for_sensitivity(sensitivity);
adaptive_threshold_with_gap(&combined, sensitivity, hop_size, min_gap, options)
} else {
Vec::new()
};
let strengths = compute_onset_strengths(&combined, &onsets, hop_size);
let onsets_fractional =
compute_fractional_positions(&onsets, &deviation_info, hop_size, fft_size);
TransientMap {
onsets,
onsets_fractional,
strengths,
flux: combined,
hop_size,
per_frame_band_flux: band_flux,
}
}
const BAND_SUB_BASS_LIMIT: f32 = 100.0;
const BAND_BASS_MID_LIMIT: f32 = 500.0;
const BAND_MID_LIMIT: f32 = 2000.0;
const BAND_HIGH_MID_LIMIT: f32 = 8000.0;
const WEIGHT_SUB_BASS: f32 = 1.0;
const WEIGHT_BASS_MID: f32 = 0.9;
const WEIGHT_MID: f32 = 0.8;
const WEIGHT_HIGH_MID: f32 = 1.2;
const WEIGHT_VERY_HIGH: f32 = 0.7;
fn compute_bin_weights(fft_size: usize, sample_rate: u32) -> Vec<f32> {
let num_bins = fft_size / 2 + 1;
let bin_freq = sample_rate as f32 / fft_size as f32;
(0..num_bins)
.map(|bin| {
let freq = bin as f32 * bin_freq;
if freq < BAND_SUB_BASS_LIMIT {
WEIGHT_SUB_BASS
} else if freq < BAND_BASS_MID_LIMIT {
WEIGHT_BASS_MID
} else if freq < BAND_MID_LIMIT {
WEIGHT_MID
} else if freq < BAND_HIGH_MID_LIMIT {
WEIGHT_HIGH_MID
} else {
WEIGHT_VERY_HIGH
}
})
.collect()
}
impl Default for TransientDetectionOptions {
fn default() -> Self {
let params = StretchParams::default();
Self::from_stretch_params(¶ms)
}
}
fn adaptive_threshold_with_gap(
flux: &[f32],
sensitivity: f32,
hop_size: usize,
min_gap_frames: usize,
options: TransientDetectionOptions,
) -> Vec<usize> {
if flux.is_empty() {
return vec![];
}
let threshold_policy = options.threshold_policy.sanitized();
let k = threshold_policy.threshold_k_base
+ (1.0 - sensitivity).max(0.0) * threshold_policy.threshold_k_slope;
let global_floor = GLOBAL_ONSET_FLOOR_RATIO * flux.iter().copied().fold(0.0f32, f32::max);
let mut onsets = Vec::new();
let mut last_onset: Option<usize> = None;
let mut local = Vec::with_capacity(threshold_policy.median_window_frames);
let mut mad_local = Vec::with_capacity(threshold_policy.mad_window_frames);
let lookahead_frames = options.lookahead_confirm_frames;
let threshold_relax = options.lookahead_threshold_relax.clamp(0.0, 1.0);
let peak_retain_ratio = options.lookahead_peak_retain_ratio.clamp(0.0, 1.0);
let strong_bypass_multiplier = options.strong_spike_bypass_multiplier.max(1.0);
for (i, &flux_val) in flux.iter().enumerate() {
let start = i.saturating_sub(threshold_policy.median_window_frames);
local.clear();
local.extend_from_slice(&flux[start..i]);
let median = if local.is_empty() {
0.0
} else {
local.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
local[local.len() / 2]
};
let mad_start = i.saturating_sub(threshold_policy.mad_window_frames);
mad_local.clear();
mad_local.extend(flux[mad_start..i].iter().map(|&v| (v - median).abs()));
let mad = if mad_local.is_empty() {
0.0
} else {
let mid = mad_local.len() / 2;
let (_, mad, _) = mad_local.select_nth_unstable_by(mid, |a, b| a.total_cmp(b));
*mad
};
let mad = mad.max(MAD_FLOOR_MEDIAN_RATIO * median);
let threshold = (median + k * mad + threshold_policy.threshold_floor).max(global_floor);
let is_local_peak =
(i == 0 || flux_val >= flux[i - 1]) && (i + 1 >= flux.len() || flux_val >= flux[i + 1]);
let mask_start = i.saturating_sub(min_gap_frames.saturating_mul(2));
let recent_max = flux[mask_start..i].iter().copied().fold(0.0f32, f32::max);
let masked = flux_val < recent_max * ONSET_MASK_RATIO;
if flux_val > threshold && is_local_peak && !masked {
let strong_spike = flux_val > threshold * strong_bypass_multiplier;
if !strong_spike && lookahead_frames > 0 && i + 1 < flux.len() {
let lookahead_end = i.saturating_add(lookahead_frames).min(flux.len() - 1);
let relaxed_threshold = threshold * threshold_relax;
let retain_level = flux_val * peak_retain_ratio;
let confirmed = flux[i + 1..=lookahead_end]
.iter()
.any(|&future| future > relaxed_threshold || future > retain_level);
if !confirmed {
continue;
}
}
if let Some(last) = last_onset {
if i - last < min_gap_frames {
continue;
}
}
onsets.push(i * hop_size);
last_onset = Some(i);
}
}
onsets
}
fn compute_onset_strengths(combined: &[f32], onsets: &[usize], hop_size: usize) -> Vec<f32> {
if onsets.is_empty() {
return vec![];
}
let raw: Vec<f32> = onsets
.iter()
.map(|&onset_sample| {
let frame = onset_sample / hop_size;
if frame < combined.len() {
combined[frame]
} else {
0.0
}
})
.collect();
let max_val = raw.iter().copied().fold(0.0f32, f32::max);
if max_val < 1e-10 {
return vec![0.0; onsets.len()];
}
raw.iter().map(|&v| v / max_val).collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn adaptive_threshold(flux: &[f32], sensitivity: f32, hop_size: usize) -> Vec<usize> {
let options = TransientDetectionOptions::default();
let min_gap = options
.threshold_policy
.min_gap_for_sensitivity(sensitivity);
adaptive_threshold_with_gap(flux, sensitivity, hop_size, min_gap, options)
}
#[test]
fn test_detect_transients_click_train() {
let sample_rate = 44100u32;
let duration_secs = 2.0;
let num_samples = (sample_rate as f64 * duration_secs) as usize;
let mut samples = vec![0.0f32; num_samples];
let click_interval = sample_rate as usize / 2; for i in (0..num_samples).step_by(click_interval) {
for j in 0..10.min(num_samples - i) {
samples[i + j] = if j < 5 { 1.0 } else { -0.5 };
}
}
let result = detect_transients(&samples, sample_rate, 2048, 512, 0.5);
assert!(
result.onsets.len() >= 2,
"Expected at least 2 onsets, got {}",
result.onsets.len()
);
}
#[test]
fn test_detect_transients_silence() {
let samples = vec![0.0f32; 44100];
let result = detect_transients(&samples, 44100, 2048, 512, 0.5);
assert!(
result.onsets.is_empty(),
"Expected no onsets in silence, got {}",
result.onsets.len()
);
}
#[test]
fn test_detect_transients_too_short() {
let samples = vec![0.0f32; 100];
let result = detect_transients(&samples, 44100, 2048, 512, 0.5);
assert!(result.onsets.is_empty());
assert!(result.strengths.is_empty());
assert!(result.flux.is_empty());
}
#[test]
fn test_detect_transients_strengths() {
let sample_rate = 44100u32;
let num_samples = sample_rate as usize * 2;
let mut samples = vec![0.0f32; num_samples];
let click_positions = [0, sample_rate as usize / 2, sample_rate as usize];
let click_amplitudes = [1.0f32, 0.3, 1.0];
for (&pos, &) in click_positions.iter().zip(click_amplitudes.iter()) {
for j in 0..10.min(num_samples - pos) {
samples[pos + j] = if j < 5 { amp } else { -amp * 0.5 };
}
}
let result = detect_transients(&samples, sample_rate, 2048, 512, 0.5);
assert_eq!(
result.onsets.len(),
result.strengths.len(),
"Onsets and strengths should have same length"
);
for &s in &result.strengths {
assert!(
(0.0..=1.0).contains(&s),
"Strength {} out of [0, 1] range",
s
);
}
if !result.strengths.is_empty() {
let max_strength = result.strengths.iter().copied().fold(0.0f32, f32::max);
assert!(
(max_strength - 1.0).abs() < 1e-6,
"Max strength should be 1.0, got {}",
max_strength
);
}
}
#[test]
fn test_bin_weights() {
let weights = compute_bin_weights(4096, 44100);
assert_eq!(weights.len(), 2049);
assert!(
(weights[0] - WEIGHT_SUB_BASS).abs() < 1e-6,
"DC bin weight should be WEIGHT_SUB_BASS ({}), got {}",
WEIGHT_SUB_BASS,
weights[0]
);
let bin_4k = (4000.0 / (44100.0 / 4096.0)) as usize;
assert!(weights[bin_4k] > 1.0);
}
#[test]
fn test_bin_weights_all_bands_covered() {
let fft_size = 4096;
let sr = 44100u32;
let weights = compute_bin_weights(fft_size, sr);
let bin_freq = sr as f32 / fft_size as f32;
assert!(
(weights[0] - WEIGHT_SUB_BASS).abs() < 1e-6,
"DC bin weight should be WEIGHT_SUB_BASS"
);
let bin_99 = (99.0 / bin_freq) as usize;
assert!(
(weights[bin_99] - WEIGHT_SUB_BASS).abs() < 1e-6,
"Bin at ~99Hz should be WEIGHT_SUB_BASS"
);
let bin_110 = (110.0 / bin_freq) as usize;
assert!(
(weights[bin_110] - WEIGHT_BASS_MID).abs() < 1e-6,
"Bin at ~110Hz should be WEIGHT_BASS_MID"
);
let bin_1000 = (1000.0 / bin_freq) as usize;
assert!(
(weights[bin_1000] - WEIGHT_MID).abs() < 1e-6,
"Bin at ~1000Hz should be WEIGHT_MID"
);
let bin_4000 = (4000.0 / bin_freq) as usize;
assert!(
(weights[bin_4000] - WEIGHT_HIGH_MID).abs() < 1e-6,
"Bin at ~4000Hz should be WEIGHT_HIGH_MID"
);
let bin_10000 = (10000.0 / bin_freq) as usize;
assert!(
(weights[bin_10000] - WEIGHT_VERY_HIGH).abs() < 1e-6,
"Bin at ~10kHz should be WEIGHT_VERY_HIGH"
);
let nyquist_bin = fft_size / 2;
assert!(
(weights[nyquist_bin] - WEIGHT_VERY_HIGH).abs() < 1e-6,
"Nyquist bin should be WEIGHT_VERY_HIGH"
);
}
#[test]
fn test_bin_weights_48khz() {
let weights = compute_bin_weights(4096, 48000);
let bin_freq = 48000.0f32 / 4096.0;
let bin_4k = (4000.0 / bin_freq) as usize;
assert!(
(weights[bin_4k] - WEIGHT_HIGH_MID).abs() < 1e-6,
"4kHz at 48kHz should be WEIGHT_HIGH_MID"
);
}
#[test]
fn test_adaptive_threshold_empty_flux() {
let result = adaptive_threshold(&[], 0.5, 512);
assert!(result.is_empty());
}
#[test]
fn test_adaptive_threshold_all_below_threshold() {
let flux = vec![0.001f32; 50];
let result = adaptive_threshold(&flux, 0.5, 512);
assert!(
result.is_empty(),
"Uniform low flux should produce no onsets"
);
}
#[test]
fn test_adaptive_threshold_single_spike() {
let mut flux = vec![0.0f32; 50];
flux[25] = 1.0; let result = adaptive_threshold(&flux, 0.5, 512);
assert!(
!result.is_empty(),
"Large spike should be detected as onset"
);
assert_eq!(result[0], 25 * 512);
}
#[test]
fn test_adaptive_threshold_lookahead_rejects_weak_isolated_spike() {
let mut flux = vec![0.0f32; 50];
flux[25] = 0.02;
let result = adaptive_threshold_with_gap(
&flux,
0.5,
512,
TransientDetectionOptions::default()
.threshold_policy
.min_gap_for_sensitivity(0.5),
TransientDetectionOptions::default(),
);
assert!(
result.is_empty(),
"Weak isolated spike should be rejected by lookahead confirmation"
);
}
#[test]
fn test_adaptive_threshold_lookahead_zero_detects_weak_isolated_spike() {
let mut flux = vec![0.0f32; 50];
flux[25] = 0.02;
let result = adaptive_threshold_with_gap(
&flux,
0.5,
512,
TransientDetectionOptions::default()
.threshold_policy
.min_gap_for_sensitivity(0.5),
TransientDetectionOptions {
lookahead_confirm_frames: 0,
..TransientDetectionOptions::default()
},
);
assert_eq!(result, vec![25 * 512]);
}
#[test]
fn test_default_options_match_stretch_params_defaults() {
let params = StretchParams::default();
let options = TransientDetectionOptions::default();
assert_eq!(
options.lookahead_confirm_frames,
params.transient_lookahead_frames
);
assert!(
(options.lookahead_threshold_relax - params.transient_lookahead_threshold_relax).abs()
< 1e-6
);
assert!(
(options.lookahead_peak_retain_ratio - params.transient_lookahead_peak_retain_ratio)
.abs()
< 1e-6
);
assert!(
(options.strong_spike_bypass_multiplier
- params.transient_strong_spike_bypass_multiplier)
.abs()
< 1e-6
);
assert_eq!(
options.threshold_policy,
params.transient_threshold_policy.sanitized()
);
}
#[test]
fn test_adaptive_threshold_sensitivity_high() {
let mut flux = vec![0.01f32; 100];
flux[20] = 0.1;
flux[50] = 0.1;
flux[80] = 0.1;
let high_sens = adaptive_threshold(&flux, 0.9, 512);
let low_sens = adaptive_threshold(&flux, 0.1, 512);
assert!(
high_sens.len() >= low_sens.len(),
"High sensitivity ({}) should detect >= low sensitivity ({})",
high_sens.len(),
low_sens.len()
);
}
#[test]
fn test_adaptive_threshold_min_onset_gap() {
let mut flux = vec![0.001f32; 50];
flux[10] = 2.0;
flux[12] = 2.0;
let result = adaptive_threshold(&flux, 0.5, 512);
let onsets_near: Vec<_> = result
.iter()
.filter(|&&pos| (10 * 512..=12 * 512).contains(&pos))
.collect();
assert!(
onsets_near.len() <= 1,
"Close spikes should be deduplicated, got {} onsets",
onsets_near.len()
);
}
#[test]
fn test_adaptive_threshold_spikes_beyond_gap() {
let mut flux = vec![0.001f32; 50];
flux[10] = 2.0;
flux[20] = 2.0;
let result = adaptive_threshold(&flux, 0.5, 512);
assert!(
result.len() >= 2,
"Well-separated spikes should both be detected, got {}",
result.len()
);
}
#[test]
fn test_spectral_flux_silence_is_zero() {
let samples = vec![0.0f32; 44100];
let (flux, band_flux) = compute_spectral_flux(&samples, 44100, 2048, 512);
assert!(!flux.is_empty());
for &f in &flux {
assert!(f.abs() < 1e-6, "Flux for silence should be ~0, got {}", f);
}
assert_eq!(flux.len(), band_flux.len());
for bf in &band_flux {
for &v in bf {
assert!(v.abs() < 1e-6, "Band flux for silence should be ~0");
}
}
}
#[test]
fn test_spectral_flux_constant_tone_after_onset() {
let sample_rate = 44100u32;
let num_samples = sample_rate as usize;
let input: Vec<f32> = (0..num_samples)
.map(|i| (2.0 * std::f32::consts::PI * 440.0 * i as f32 / sample_rate as f32).sin())
.collect();
let (flux, _band_flux) = compute_spectral_flux(&input, sample_rate, 2048, 512);
assert!(flux.len() > 2);
assert_eq!(flux[0], 0.0, "First frame flux should be suppressed");
let peak = flux.iter().copied().fold(0.0f32, f32::max);
let late_flux_avg: f32 =
flux[flux.len() / 2..].iter().sum::<f32>() / (flux.len() / 2) as f32;
assert!(
late_flux_avg < peak * 0.5 + 1e-6,
"Late flux avg {} should stay low vs peak {}",
late_flux_avg,
peak
);
}
#[test]
fn test_spectral_flux_impulse_detection() {
let sample_rate = 44100u32;
let fft_size = 2048;
let hop_size = 512;
let num_samples = sample_rate as usize;
let mut input = vec![0.0f32; num_samples];
let impulse_pos = sample_rate as usize / 2;
for j in 0..10 {
if impulse_pos + j < num_samples {
input[impulse_pos + j] = if j < 3 { 1.0 } else { -0.5 };
}
}
let (flux, _band_flux) = compute_spectral_flux(&input, sample_rate, fft_size, hop_size);
let impulse_frame = impulse_pos / hop_size;
if impulse_frame < flux.len() {
let avg_flux = flux.iter().sum::<f32>() / flux.len() as f32;
let max_flux_near_impulse = flux
[impulse_frame.saturating_sub(2)..(impulse_frame + 3).min(flux.len())]
.iter()
.copied()
.fold(0.0f32, f32::max);
assert!(
max_flux_near_impulse > avg_flux,
"Flux near impulse {} should be above average {}",
max_flux_near_impulse,
avg_flux
);
}
}
#[test]
fn test_per_frame_band_flux_populated() {
let sample_rate = 44100u32;
let num_samples = sample_rate as usize;
let mut samples = vec![0.0f32; num_samples];
for j in 0..10 {
samples[num_samples / 2 + j] = if j < 5 { 1.0 } else { -0.5 };
}
let result = detect_transients(&samples, sample_rate, 2048, 512, 0.5);
assert!(!result.per_frame_band_flux.is_empty());
assert_eq!(result.flux.len(), result.per_frame_band_flux.len());
}
}