use rustfft::{num_complex::Complex, FftPlanner};
const LOG_FLOOR: f32 = 1e-10;
pub fn spectral_centroid(magnitudes: &[f32], sample_rate: u32, fft_size: usize) -> f32 {
let mut weighted_sum = 0.0f64;
let mut magnitude_sum = 0.0f64;
let bin_freq = sample_rate as f64 / fft_size as f64;
for (i, &mag) in magnitudes.iter().enumerate() {
let freq = i as f64 * bin_freq;
weighted_sum += freq * mag as f64;
magnitude_sum += mag as f64;
}
if magnitude_sum > 1e-10 {
(weighted_sum / magnitude_sum) as f32
} else {
1000.0 }
}
pub fn adaptive_cepstral_order(centroid: f32, fft_size: usize) -> usize {
let order = if centroid < 500.0 {
25 } else if centroid < 1500.0 {
35 } else if centroid < 4000.0 {
50 } else {
40 };
order.min(fft_size / 4).max(10)
}
pub fn extract_envelope(
magnitudes: &[f32],
num_bins: usize,
order: usize,
planner: &mut FftPlanner<f32>,
cepstrum_buf: &mut Vec<Complex<f32>>,
envelope_out: &mut Vec<f32>,
) {
let fft_size = (num_bins - 1) * 2;
cepstrum_buf.resize(fft_size, Complex::new(0.0, 0.0));
envelope_out.resize(num_bins, 1.0);
for i in 0..num_bins {
let log_mag = magnitudes[i].max(LOG_FLOOR).ln();
cepstrum_buf[i] = Complex::new(log_mag, 0.0);
}
for i in 1..num_bins - 1 {
cepstrum_buf[fft_size - i] = cepstrum_buf[i];
}
let ifft = planner.plan_fft_inverse(fft_size);
ifft.process(cepstrum_buf);
let norm = 1.0 / fft_size as f32;
let effective_order = order.min(fft_size / 2);
for (i, c) in cepstrum_buf.iter_mut().enumerate().take(fft_size) {
if i > effective_order && i < fft_size - effective_order {
*c = Complex::new(0.0, 0.0);
} else {
*c *= norm; }
}
let fft = planner.plan_fft_forward(fft_size);
fft.process(cepstrum_buf);
for i in 0..num_bins {
envelope_out[i] = cepstrum_buf[i].re.exp();
}
}
#[allow(clippy::too_many_arguments)]
pub fn extract_envelope_with_fft_scratch(
magnitudes: &[f32],
num_bins: usize,
order: usize,
fft_forward: &std::sync::Arc<dyn rustfft::Fft<f32>>,
fft_inverse: &std::sync::Arc<dyn rustfft::Fft<f32>>,
fft_forward_scratch: &mut Vec<Complex<f32>>,
fft_inverse_scratch: &mut Vec<Complex<f32>>,
cepstrum_buf: &mut Vec<Complex<f32>>,
envelope_out: &mut Vec<f32>,
) {
let fft_size = (num_bins - 1) * 2;
cepstrum_buf.resize(fft_size, Complex::new(0.0, 0.0));
envelope_out.resize(num_bins, 1.0);
for i in 0..num_bins {
let log_mag = magnitudes[i].max(LOG_FLOOR).ln();
cepstrum_buf[i] = Complex::new(log_mag, 0.0);
}
for i in 1..num_bins - 1 {
cepstrum_buf[fft_size - i] = cepstrum_buf[i];
}
let inv_need = fft_inverse.get_inplace_scratch_len();
if fft_inverse_scratch.len() < inv_need {
fft_inverse_scratch.resize(inv_need, Complex::new(0.0, 0.0));
}
fft_inverse.process_with_scratch(cepstrum_buf, &mut fft_inverse_scratch[..inv_need]);
let norm = 1.0 / fft_size as f32;
let effective_order = order.min(fft_size / 2);
for (i, c) in cepstrum_buf.iter_mut().enumerate().take(fft_size) {
if i > effective_order && i < fft_size - effective_order {
*c = Complex::new(0.0, 0.0);
} else {
*c *= norm;
}
}
let fwd_need = fft_forward.get_inplace_scratch_len();
if fft_forward_scratch.len() < fwd_need {
fft_forward_scratch.resize(fwd_need, Complex::new(0.0, 0.0));
}
fft_forward.process_with_scratch(cepstrum_buf, &mut fft_forward_scratch[..fwd_need]);
for i in 0..num_bins {
envelope_out[i] = cepstrum_buf[i].re.exp();
}
}
#[allow(clippy::too_many_arguments)]
pub fn extract_envelope_adaptive(
magnitudes: &[f32],
num_bins: usize,
override_order: Option<usize>,
sample_rate: u32,
fft_size: usize,
planner: &mut FftPlanner<f32>,
cepstrum_buf: &mut Vec<Complex<f32>>,
envelope_out: &mut Vec<f32>,
) {
let order = match override_order {
Some(o) => o,
None => {
let centroid = spectral_centroid(magnitudes, sample_rate, fft_size);
adaptive_cepstral_order(centroid, fft_size)
}
};
extract_envelope(
magnitudes,
num_bins,
order,
planner,
cepstrum_buf,
envelope_out,
);
}
fn estimate_noise_floor(
magnitudes: &[f32],
start_bin: usize,
num_bins: usize,
scratch: &mut Vec<f32>,
) -> f32 {
if start_bin >= num_bins {
return LOG_FLOOR;
}
scratch.clear();
scratch.extend(
magnitudes[start_bin..num_bins]
.iter()
.copied()
.filter(|&m| m > LOG_FLOOR),
);
if scratch.is_empty() {
return LOG_FLOOR;
}
scratch.sort_unstable_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let idx = (scratch.len() as f64 * 0.10) as usize;
scratch[idx.min(scratch.len() - 1)]
}
#[inline]
fn clamp_correction(correction: f32, magnitude: f32, noise_floor: f32) -> f32 {
let snr = if noise_floor > 1e-10 {
magnitude / noise_floor
} else {
100.0 };
let max_correction = if snr > 10.0 {
3.0 } else if snr > 3.0 {
2.0 } else {
1.5 };
correction.clamp(1.0 / max_correction, max_correction)
}
#[inline]
pub fn apply_envelope_correction(
magnitudes: &mut [f32],
analysis_envelope: &[f32],
synthesis_envelope: &[f32],
num_bins: usize,
start_bin: usize,
) {
let mut scratch = Vec::new();
apply_envelope_correction_with_scratch(
magnitudes,
analysis_envelope,
synthesis_envelope,
num_bins,
start_bin,
&mut scratch,
);
}
#[inline]
pub fn apply_envelope_correction_with_scratch(
magnitudes: &mut [f32],
analysis_envelope: &[f32],
synthesis_envelope: &[f32],
num_bins: usize,
start_bin: usize,
noise_floor_scratch: &mut Vec<f32>,
) {
let noise_floor = estimate_noise_floor(magnitudes, start_bin, num_bins, noise_floor_scratch);
for bin in start_bin..num_bins {
let synth_env = synthesis_envelope[bin].max(LOG_FLOOR);
let correction = analysis_envelope[bin] / synth_env;
let clamped = clamp_correction(correction, magnitudes[bin], noise_floor);
magnitudes[bin] *= clamped;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_envelope_extraction_flat_spectrum() {
let num_bins = 129; let magnitudes = vec![1.0f32; num_bins];
let mut planner = FftPlanner::new();
let mut cepstrum_buf = Vec::new();
let mut envelope = Vec::new();
extract_envelope(
&magnitudes,
num_bins,
30,
&mut planner,
&mut cepstrum_buf,
&mut envelope,
);
for (i, &e) in envelope.iter().enumerate() {
assert!(
(e - 1.0).abs() < 0.1,
"Envelope at bin {} should be ~1.0, got {}",
i,
e
);
}
}
#[test]
fn test_envelope_extraction_peaked_spectrum() {
let num_bins = 129;
let mut magnitudes = vec![0.1f32; num_bins];
for (i, mag) in magnitudes.iter_mut().enumerate().take(40).skip(20) {
*mag = 1.0 - ((i as f32 - 30.0) / 10.0).powi(2);
*mag = mag.max(0.1);
}
let mut planner = FftPlanner::new();
let mut cepstrum_buf = Vec::new();
let mut envelope = Vec::new();
extract_envelope(
&magnitudes,
num_bins,
20,
&mut planner,
&mut cepstrum_buf,
&mut envelope,
);
assert!(
envelope[30] > envelope[0] * 1.5,
"Peak envelope {} should be higher than edge {}",
envelope[30],
envelope[0]
);
}
#[test]
fn test_envelope_correction_identity() {
let num_bins = 64;
let envelope = vec![2.0f32; num_bins];
let mut magnitudes = vec![1.0f32; num_bins];
let original = magnitudes.clone();
apply_envelope_correction(&mut magnitudes, &envelope, &envelope, num_bins, 0);
for i in 0..num_bins {
assert!(
(magnitudes[i] - original[i]).abs() < 1e-6,
"Magnitude at bin {} should be unchanged",
i
);
}
}
#[test]
fn test_envelope_correction_scales() {
let num_bins = 64;
let analysis_env = vec![2.0f32; num_bins];
let synthesis_env = vec![1.0f32; num_bins];
let mut magnitudes = vec![1.0f32; num_bins];
for mag in magnitudes.iter_mut().take(7) {
*mag = 1e-6;
}
apply_envelope_correction(&mut magnitudes, &analysis_env, &synthesis_env, num_bins, 0);
for (i, &mag) in magnitudes.iter().enumerate().take(num_bins).skip(7) {
assert!(
(mag - 2.0).abs() < 1e-6,
"Magnitude at bin {} should be 2.0, got {}",
i,
mag
);
}
}
#[test]
fn test_envelope_correction_clamped() {
let num_bins = 16;
let analysis_env = vec![100.0f32; num_bins];
let synthesis_env = vec![0.01f32; num_bins];
let mut magnitudes = vec![1.0f32; num_bins];
apply_envelope_correction(&mut magnitudes, &analysis_env, &synthesis_env, num_bins, 0);
for (i, &mag) in magnitudes.iter().enumerate().take(num_bins) {
assert!(
mag <= 3.0 + 1e-6,
"Magnitude at bin {} should be clamped to 3.0, got {}",
i,
mag
);
}
}
#[test]
fn test_spectral_centroid_flat() {
let num_bins = 129;
let magnitudes = vec![1.0f32; num_bins];
let sample_rate = 44100;
let fft_size = 256;
let centroid = spectral_centroid(&magnitudes, sample_rate, fft_size);
let bin_freq = sample_rate as f32 / fft_size as f32;
let expected = (num_bins - 1) as f32 / 2.0 * bin_freq;
assert!(
(centroid - expected).abs() < expected * 0.1,
"Flat spectrum centroid {} should be near {:.0}",
centroid,
expected
);
}
#[test]
fn test_spectral_centroid_bass_heavy() {
let num_bins = 129;
let mut magnitudes = vec![0.001f32; num_bins];
for mag in magnitudes.iter_mut().take(10) {
*mag = 1.0;
}
let sample_rate = 44100;
let fft_size = 256;
let centroid = spectral_centroid(&magnitudes, sample_rate, fft_size);
assert!(
centroid < 1000.0,
"Bass-heavy centroid {} should be < 1000 Hz",
centroid
);
}
#[test]
fn test_adaptive_cepstral_order_bass() {
let order = adaptive_cepstral_order(200.0, 4096);
assert_eq!(order, 25, "Bass content should get order 25");
}
#[test]
fn test_adaptive_cepstral_order_vocal() {
let order = adaptive_cepstral_order(2000.0, 4096);
assert_eq!(order, 50, "Vocal content should get order 50");
}
#[test]
fn test_adaptive_cepstral_order_clamped() {
let order = adaptive_cepstral_order(2000.0, 64);
assert_eq!(order, 16, "Should be clamped to fft_size/4 = 16");
}
#[test]
fn test_extract_envelope_adaptive_override() {
let num_bins = 129;
let magnitudes = vec![1.0f32; num_bins];
let mut planner = FftPlanner::new();
let mut cepstrum_buf1 = Vec::new();
let mut envelope1 = Vec::new();
let mut cepstrum_buf2 = Vec::new();
let mut envelope2 = Vec::new();
extract_envelope(
&magnitudes,
num_bins,
30,
&mut planner,
&mut cepstrum_buf1,
&mut envelope1,
);
extract_envelope_adaptive(
&magnitudes,
num_bins,
Some(30),
44100,
256,
&mut planner,
&mut cepstrum_buf2,
&mut envelope2,
);
for (i, (&e1, &e2)) in envelope1.iter().zip(envelope2.iter()).enumerate() {
assert!(
(e1 - e2).abs() < 1e-6,
"Envelope mismatch at bin {}: {} vs {}",
i,
e1,
e2
);
}
}
#[test]
fn test_snr_aware_clamp_strong_signal() {
let result = clamp_correction(5.0, 1.0, 0.01);
assert!(
(result - 3.0).abs() < 1e-6,
"Strong signal correction should clamp to 3.0, got {}",
result
);
}
#[test]
fn test_snr_aware_clamp_weak_signal() {
let result = clamp_correction(5.0, 0.02, 0.01);
assert!(
(result - 1.5).abs() < 1e-6,
"Weak signal correction should clamp to 1.5, got {}",
result
);
}
#[test]
fn test_snr_aware_clamp_medium_signal() {
let result = clamp_correction(5.0, 0.05, 0.01);
assert!(
(result - 2.0).abs() < 1e-6,
"Medium signal correction should clamp to 2.0, got {}",
result
);
}
#[test]
fn test_estimate_noise_floor() {
let mut magnitudes = vec![0.01f32; 100];
magnitudes[50] = 1.0;
magnitudes[51] = 0.8;
magnitudes[52] = 0.6;
let mut scratch = Vec::new();
let floor = estimate_noise_floor(&magnitudes, 0, 100, &mut scratch);
assert!(
floor < 0.05,
"Noise floor should be near 0.01, got {}",
floor
);
}
}