extern crate alloc;
use alloc::vec;
use alloc::vec::Vec;
use core::f32::consts::{LN_2, PI};
use resonant_fft::phase_vocoder::PhaseVocoder;
use resonant_fft::{fft, ifft, Complex};
use resonant_filters::resample::PolyphaseResampler;
use crate::error::AnalysisError;
pub fn time_stretch(
samples: &[f32],
sample_rate: f32,
stretch_factor: f32,
pitch_semitones: f32,
) -> Result<Vec<f32>, AnalysisError> {
if samples.is_empty() {
return Err(AnalysisError::EmptyInput);
}
if sample_rate <= 0.0 {
return Err(AnalysisError::InvalidParameter {
name: "sample_rate",
reason: "must be positive",
});
}
if stretch_factor <= 0.0 {
return Err(AnalysisError::InvalidParameter {
name: "stretch_factor",
reason: "must be positive",
});
}
let pitch_ratio = (pitch_semitones / 12.0 * LN_2).exp();
let pv_output = run_phase_vocoder(samples, stretch_factor * pitch_ratio)?;
if (pitch_ratio - 1.0).abs() < 1e-4 {
return Ok(pv_output);
}
resample_pitch(pv_output, pitch_ratio)
}
fn run_phase_vocoder(samples: &[f32], stretch_factor: f32) -> Result<Vec<f32>, AnalysisError> {
let n = samples.len();
let fft_size = choose_fft_size(n);
let hop_analysis = fft_size / 4;
let mut phase_vocoder = PhaseVocoder::new(fft_size, hop_analysis, stretch_factor)?;
let hop_synthesis = phase_vocoder.hop_synthesis();
let num_bins = fft_size / 2 + 1;
let hann = hann_window(fft_size);
let n_frames = n.saturating_sub(fft_size) / hop_analysis + 1;
let out_len = n_frames * hop_synthesis + fft_size;
let mut output = vec![0.0_f32; out_len];
let mut norm_buf = vec![0.0_f32; out_len];
let mut frame_buf = vec![Complex::new(0.0_f32, 0.0_f32); fft_size];
let mut mags = vec![0.0_f32; num_bins];
let mut phases = vec![0.0_f32; num_bins];
let mut out_mags = vec![0.0_f32; num_bins];
let mut out_phases = vec![0.0_f32; num_bins];
let mut in_pos = 0;
let mut out_pos = 0;
while in_pos + fft_size <= n {
for (i, c) in frame_buf.iter_mut().enumerate() {
c.re = samples[in_pos + i] * hann[i];
c.im = 0.0;
}
fft(&mut frame_buf)?;
for k in 0..num_bins {
mags[k] = frame_buf[k].norm();
phases[k] = frame_buf[k].arg();
}
phase_vocoder.process_frame(&mags, &phases, &mut out_mags, &mut out_phases);
for k in 0..num_bins {
let (sin_p, cos_p) = out_phases[k].sin_cos();
frame_buf[k] = Complex::new(out_mags[k] * cos_p, out_mags[k] * sin_p);
}
frame_buf[0].im = 0.0;
frame_buf[num_bins - 1].im = 0.0;
for k in 1..num_bins - 1 {
frame_buf[fft_size - k] = frame_buf[k].conj();
}
ifft(&mut frame_buf)?;
for j in 0..fft_size {
let win_coeff = hann[j];
output[out_pos + j] += frame_buf[j].re * win_coeff;
norm_buf[out_pos + j] += win_coeff * win_coeff;
}
in_pos += hop_analysis;
out_pos += hop_synthesis;
}
for (sample, &window_power) in output.iter_mut().zip(norm_buf.iter()) {
if window_power > 1e-8 {
*sample /= window_power;
}
}
let expected = (n as f32 * stretch_factor).round() as usize;
output.truncate(expected.min(output.len()));
Ok(output)
}
fn resample_pitch(samples: Vec<f32>, pitch_ratio: f32) -> Result<Vec<f32>, AnalysisError> {
const RESAMPLE_DENOMINATOR: usize = 1000;
let down = (pitch_ratio * RESAMPLE_DENOMINATOR as f32).round() as usize;
if down == 0 {
return Err(AnalysisError::InvalidParameter {
name: "pitch_semitones",
reason: "results in zero output rate",
});
}
let mut resampler = PolyphaseResampler::new(RESAMPLE_DENOMINATOR, down).ok_or(
AnalysisError::InvalidParameter {
name: "pitch_semitones",
reason: "resampler construction failed for the requested pitch ratio",
},
)?;
Ok(resampler.process(&samples))
}
fn choose_fft_size(signal_len: usize) -> usize {
let max_fft = (signal_len / 2).clamp(4, 2048);
1usize << max_fft.ilog2()
}
fn hann_window(n: usize) -> Vec<f32> {
if n <= 1 {
return vec![1.0_f32; n];
}
let denom = (n - 1) as f32;
(0..n)
.map(|i| 0.5 - 0.5 * (2.0 * PI * i as f32 / denom).cos())
.collect()
}
#[cfg(test)]
mod tests {
extern crate std;
use super::*;
use crate::pitch::YinEstimator;
fn sine_wave(freq_hz: f32, sample_rate: f32, n: usize) -> Vec<f32> {
(0..n)
.map(|i| (2.0 * PI * freq_hz * i as f32 / sample_rate).sin())
.collect()
}
#[test]
fn empty_input_returns_error() {
assert!(matches!(
time_stretch(&[], 44100.0, 1.0, 0.0),
Err(AnalysisError::EmptyInput)
));
}
#[test]
fn invalid_sample_rate_returns_error() {
let sig = std::vec![0.0_f32; 256];
assert!(matches!(
time_stretch(&sig, 0.0, 1.0, 0.0),
Err(AnalysisError::InvalidParameter { .. })
));
}
#[test]
fn invalid_stretch_factor_returns_error() {
let sig = std::vec![0.0_f32; 256];
assert!(matches!(
time_stretch(&sig, 44100.0, 0.0, 0.0),
Err(AnalysisError::InvalidParameter { .. })
));
assert!(matches!(
time_stretch(&sig, 44100.0, -1.0, 0.0),
Err(AnalysisError::InvalidParameter { .. })
));
}
#[test]
fn passthrough_preserves_length_and_content() {
let sr = 44100.0_f32;
let freq = 440.0_f32;
let sig = sine_wave(freq, sr, 8192);
let out =
time_stretch(&sig, sr, 1.0, 0.0).unwrap_or_else(|e| panic!("passthrough failed: {e}"));
assert_eq!(out.len(), sig.len(), "passthrough must not change length");
let fft_size = choose_fft_size(sig.len());
let margin = fft_size * 2;
if margin * 2 < sig.len() {
let max_err = sig[margin..sig.len() - margin]
.iter()
.zip(out[margin..sig.len() - margin].iter())
.map(|(a, b)| (a - b).abs())
.fold(0.0_f32, |acc, x| acc.max(x));
assert!(
max_err < 0.05,
"passthrough interior error {max_err:.4} > 0.05"
);
}
}
#[test]
fn double_stretch_doubles_length() {
let sr = 44100.0_f32;
let sig = sine_wave(440.0, sr, 8192);
let out =
time_stretch(&sig, sr, 2.0, 0.0).unwrap_or_else(|e| panic!("2× stretch failed: {e}"));
let expected = sig.len() * 2;
let ratio = out.len() as f32 / expected as f32;
assert!(
(0.9..=1.1).contains(&ratio),
"2× stretch: output/expected length ratio {ratio:.3} outside [0.9, 1.1]"
);
}
#[test]
fn half_stretch_halves_length() {
let sr = 44100.0_f32;
let sig = sine_wave(440.0, sr, 8192);
let out =
time_stretch(&sig, sr, 0.5, 0.0).unwrap_or_else(|e| panic!("0.5× stretch failed: {e}"));
let expected = sig.len() / 2;
let ratio = out.len() as f32 / expected as f32;
assert!(
(0.9..=1.1).contains(&ratio),
"0.5× stretch: output/expected length ratio {ratio:.3} outside [0.9, 1.1]"
);
}
#[test]
fn pitch_shift_up_one_octave() {
let sr = 44100.0_f32;
let freq = 440.0_f32;
let sig = sine_wave(freq, sr, 44100);
let out = time_stretch(&sig, sr, 1.0, 12.0)
.unwrap_or_else(|e| panic!("+12 semitones failed: {e}"));
let len_ratio = out.len() as f32 / sig.len() as f32;
assert!(
(0.8..=1.2).contains(&len_ratio),
"+12 semitones: length ratio {len_ratio:.3} outside [0.8, 1.2]"
);
let est = YinEstimator::new(sr)
.with_frequency_range(200.0, 2000.0)
.estimate(&out)
.ok();
let detected = est.and_then(|e| e.frequency_hz);
assert!(
detected.is_some_and(|f| (f - freq * 2.0).abs() < 80.0),
"+12 semitones: expected ~880 Hz, got {detected:?}"
);
}
#[test]
fn pitch_shift_down_one_octave() {
let sr = 44100.0_f32;
let freq = 440.0_f32;
let sig = sine_wave(freq, sr, 44100);
let out = time_stretch(&sig, sr, 1.0, -12.0)
.unwrap_or_else(|e| panic!("-12 semitones failed: {e}"));
let est = YinEstimator::new(sr)
.with_frequency_range(50.0, 500.0)
.estimate(&out)
.ok();
let detected = est.and_then(|e| e.frequency_hz);
assert!(
detected.is_some_and(|f| (f - freq * 0.5).abs() < 40.0),
"-12 semitones: expected ~220 Hz, got {detected:?}"
);
}
#[test]
fn hann_window_shape() {
let w = hann_window(16);
assert!(w[0].abs() < 1e-6, "first sample must be ~0");
assert!(w[15].abs() < 1e-6, "last sample must be ~0");
let peak = w[7].max(w[8]);
assert!(peak > 0.97, "Hann peak {peak:.4} should be > 0.97");
}
#[test]
fn choose_fft_size_is_power_of_two_and_bounded() {
for n in [16, 100, 512, 1024, 4096, 44100, 100000] {
let fft_size = choose_fft_size(n);
assert!(
fft_size.is_power_of_two(),
"fft_size {fft_size} is not a power of two"
);
assert!(fft_size >= 4, "fft_size {fft_size} < 4");
assert!(fft_size <= 2048, "fft_size {fft_size} > 2048");
}
}
}