#![allow(dead_code)]
#![allow(
clippy::cast_precision_loss,
clippy::cast_possible_truncation,
clippy::cast_sign_loss
)]
use crate::{utils::FractionalDelayLine, utils::InterpolationMode, AudioEffect};
#[derive(Debug, Clone)]
pub struct PitchShifterConfig {
pub semitones: f32,
pub cents: f32,
pub mix: f32,
}
impl Default for PitchShifterConfig {
fn default() -> Self {
Self {
semitones: 0.0,
cents: 0.0,
mix: 1.0,
}
}
}
pub struct PitchShifter {
delay: FractionalDelayLine,
phase: f32,
config: PitchShifterConfig,
#[allow(dead_code)]
sample_rate: f32,
}
impl PitchShifter {
#[must_use]
pub fn new(config: PitchShifterConfig, sample_rate: f32) -> Self {
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let delay_size = (sample_rate * 0.1) as usize;
Self {
delay: FractionalDelayLine::new(delay_size, InterpolationMode::Linear),
phase: 0.0,
config,
sample_rate,
}
}
pub fn set_semitones(&mut self, semitones: f32) {
self.config.semitones = semitones.clamp(-24.0, 24.0);
}
pub fn set_cents(&mut self, cents: f32) {
self.config.cents = cents.clamp(-100.0, 100.0);
}
fn pitch_ratio(&self) -> f32 {
let total_semitones = self.config.semitones + self.config.cents / 100.0;
2.0_f32.powf(total_semitones / 12.0)
}
}
impl AudioEffect for PitchShifter {
const EFFECT_ID: &'static str = "pitch_shifter";
fn process_sample(&mut self, input: f32) -> f32 {
let ratio = self.pitch_ratio();
self.delay.write(input);
let base_delay = 1000.0; let mod_delay = base_delay * (1.0 + 0.1 * self.phase.sin());
let shifted = self.delay.read(mod_delay);
self.phase += 0.01 * (ratio - 1.0);
if self.phase > std::f32::consts::TAU {
self.phase -= std::f32::consts::TAU;
}
shifted * self.config.mix + input * (1.0 - self.config.mix)
}
fn reset(&mut self) {
self.delay.clear();
self.phase = 0.0;
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PitchAlgorithm {
Resample,
PhaseVocoder,
WsolaLite,
}
pub struct AdvancedPitchShifter {
pub semitones: f32,
pub algorithm: PitchAlgorithm,
}
impl AdvancedPitchShifter {
#[must_use]
pub fn new(semitones: f32, algorithm: PitchAlgorithm) -> Self {
Self {
semitones,
algorithm,
}
}
#[must_use]
pub fn process(&self, samples: &[f32]) -> Vec<f32> {
match self.algorithm {
PitchAlgorithm::Resample => Self::shift_resample(samples, self.semitones),
PitchAlgorithm::PhaseVocoder => {
Self::shift_resample(samples, self.semitones)
}
PitchAlgorithm::WsolaLite => Self::shift_wsola(samples, self.semitones, 1024),
}
}
#[must_use]
pub fn shift_resample(samples: &[f32], semitones: f32) -> Vec<f32> {
if samples.is_empty() {
return Vec::new();
}
let speed = 2.0_f32.powf(semitones / 12.0);
let output_len = ((samples.len() as f32) / speed).round() as usize;
let output_len = output_len.max(1);
let mut output = Vec::with_capacity(output_len);
for i in 0..output_len {
let src_pos = i as f32 * speed;
let idx = src_pos as usize;
let frac = src_pos - idx as f32;
let s0 = if idx < samples.len() {
samples[idx]
} else {
0.0
};
let s1 = if idx + 1 < samples.len() {
samples[idx + 1]
} else {
0.0
};
output.push(s0 + frac * (s1 - s0));
}
output
}
#[must_use]
pub fn shift_wsola(samples: &[f32], semitones: f32, frame_size: usize) -> Vec<f32> {
if samples.is_empty() {
return Vec::new();
}
let speed = 2.0_f32.powf(semitones / 12.0);
let stretch = 1.0 / speed;
let hop = frame_size / 4;
let stretched_len = ((samples.len() as f32) * stretch).round() as usize;
let stretched_len = stretched_len.max(frame_size);
let mut stretched = vec![0.0f32; stretched_len];
let mut counts = vec![0u32; stretched_len];
let mut src_pos = 0usize;
let mut dst_pos = 0usize;
let src_hop = (hop as f32 * speed).round() as usize;
let src_hop = src_hop.max(1);
while dst_pos + frame_size <= stretched_len && src_pos + frame_size <= samples.len() {
for k in 0..frame_size {
let window = 0.5
* (1.0
- (2.0 * std::f32::consts::PI * k as f32 / (frame_size - 1) as f32).cos());
stretched[dst_pos + k] += samples[src_pos + k] * window;
counts[dst_pos + k] += 1;
}
src_pos = (src_pos + src_hop).min(samples.len().saturating_sub(frame_size));
dst_pos += hop;
}
for (s, &c) in stretched.iter_mut().zip(counts.iter()) {
if c > 0 {
*s /= c as f32;
}
}
let target_len = samples.len();
Self::resample_linear(&stretched, target_len)
}
fn resample_linear(samples: &[f32], target_len: usize) -> Vec<f32> {
if samples.is_empty() || target_len == 0 {
return Vec::new();
}
let ratio = (samples.len() - 1) as f32 / (target_len - 1).max(1) as f32;
let mut output = Vec::with_capacity(target_len);
for i in 0..target_len {
let src_pos = i as f32 * ratio;
let idx = src_pos as usize;
let frac = src_pos - idx as f32;
let s0 = if idx < samples.len() {
samples[idx]
} else {
0.0
};
let s1 = if idx + 1 < samples.len() {
samples[idx + 1]
} else {
s0
};
output.push(s0 + frac * (s1 - s0));
}
output
}
}
pub struct FormantPreserver {
pub shift_hz: f32,
window_size: usize,
}
impl FormantPreserver {
#[must_use]
pub fn new(shift_hz: f32) -> Self {
Self {
shift_hz,
window_size: 64,
}
}
#[must_use]
pub fn apply(&self, samples: &[f32], sample_rate: u32) -> Vec<f32> {
if samples.is_empty() {
return Vec::new();
}
let nyquist = sample_rate as f32 / 2.0;
let normalized_shift = (self.shift_hz.abs() / nyquist).clamp(0.0, 1.0);
let smooth_samples = (self.window_size as f32 * normalized_shift) as usize;
if smooth_samples < 2 {
return samples.to_vec();
}
let half = smooth_samples / 2;
let n = samples.len();
let mut output = Vec::with_capacity(n);
for i in 0..n {
let start = i.saturating_sub(half);
let end = (i + half + 1).min(n);
let sum: f32 = samples[start..end].iter().sum();
let count = (end - start) as f32;
output.push(sum / count);
}
output
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_pitch_shifter() {
let config = PitchShifterConfig::default();
let mut shifter = PitchShifter::new(config, 48000.0);
let output = shifter.process_sample(0.5);
assert!(output.is_finite());
}
#[test]
fn test_pitch_ratio() {
let config = PitchShifterConfig {
semitones: 12.0,
..Default::default()
};
let shifter = PitchShifter::new(config, 48000.0);
let ratio = shifter.pitch_ratio();
assert!((ratio - 2.0).abs() < 0.01); }
#[test]
fn test_pitch_algorithm_variants() {
let _ = PitchAlgorithm::Resample;
let _ = PitchAlgorithm::PhaseVocoder;
let _ = PitchAlgorithm::WsolaLite;
}
#[test]
fn test_advanced_shifter_new() {
let shifter = AdvancedPitchShifter::new(5.0, PitchAlgorithm::Resample);
assert!((shifter.semitones - 5.0).abs() < 1e-6);
}
#[test]
fn test_shift_resample_octave_up() {
let samples: Vec<f32> = (0..256).map(|i| (i as f32 * 0.1).sin()).collect();
let shifted = AdvancedPitchShifter::shift_resample(&samples, 12.0);
assert!(shifted.len() < samples.len() / 2 + 10);
assert!(shifted.iter().all(|&s| s.is_finite()));
}
#[test]
fn test_shift_resample_octave_down() {
let samples: Vec<f32> = (0..256).map(|i| (i as f32 * 0.1).sin()).collect();
let shifted = AdvancedPitchShifter::shift_resample(&samples, -12.0);
assert!(shifted.len() > samples.len());
assert!(shifted.iter().all(|&s| s.is_finite()));
}
#[test]
fn test_shift_resample_zero_semitones() {
let samples = vec![0.1f32, 0.2, 0.3, 0.4, 0.5];
let shifted = AdvancedPitchShifter::shift_resample(&samples, 0.0);
assert_eq!(shifted.len(), samples.len());
for (&a, &b) in samples.iter().zip(shifted.iter()) {
assert!((a - b).abs() < 1e-4);
}
}
#[test]
fn test_shift_resample_empty() {
let shifted = AdvancedPitchShifter::shift_resample(&[], 5.0);
assert!(shifted.is_empty());
}
#[test]
fn test_shift_wsola_output_length() {
let samples: Vec<f32> = (0..4096).map(|i| (i as f32 * 0.01).sin()).collect();
let shifted = AdvancedPitchShifter::shift_wsola(&samples, 5.0, 512);
assert_eq!(shifted.len(), samples.len());
assert!(shifted.iter().all(|&s| s.is_finite()));
}
#[test]
fn test_shift_wsola_empty() {
let shifted = AdvancedPitchShifter::shift_wsola(&[], 3.0, 512);
assert!(shifted.is_empty());
}
#[test]
fn test_advanced_shifter_process_resample() {
let shifter = AdvancedPitchShifter::new(7.0, PitchAlgorithm::Resample);
let samples: Vec<f32> = (0..512).map(|i| (i as f32 * 0.1).sin()).collect();
let output = shifter.process(&samples);
assert!(output.iter().all(|&s| s.is_finite()));
}
#[test]
fn test_advanced_shifter_process_wsola() {
let shifter = AdvancedPitchShifter::new(3.0, PitchAlgorithm::WsolaLite);
let samples: Vec<f32> = (0..4096).map(|i| (i as f32 * 0.01).sin()).collect();
let output = shifter.process(&samples);
assert_eq!(output.len(), samples.len());
assert!(output.iter().all(|&s| s.is_finite()));
}
#[test]
fn test_formant_preserver_new() {
let fp = FormantPreserver::new(200.0);
assert!((fp.shift_hz - 200.0).abs() < 1e-6);
}
#[test]
fn test_formant_preserver_apply() {
let fp = FormantPreserver::new(100.0);
let samples: Vec<f32> = (0..512).map(|i| (i as f32 * 0.1).sin()).collect();
let output = fp.apply(&samples, 48000);
assert_eq!(output.len(), samples.len());
assert!(output.iter().all(|&s| s.is_finite()));
}
#[test]
fn test_formant_preserver_empty() {
let fp = FormantPreserver::new(100.0);
let output = fp.apply(&[], 48000);
assert!(output.is_empty());
}
fn dominant_frequency(samples: &[f32], sample_rate: f32) -> (f32, usize) {
let n = samples.len();
let spectrum: Vec<oxifft::Complex<f32>> = samples
.iter()
.map(|&x| oxifft::Complex::new(x, 0.0))
.collect();
let freq_domain = oxifft::fft(&spectrum);
let peak_bin = freq_domain
.iter()
.take(n / 2)
.enumerate()
.max_by(|a, b| {
a.1.norm()
.partial_cmp(&b.1.norm())
.unwrap_or(std::cmp::Ordering::Equal)
})
.map(|(i, _)| i)
.unwrap_or(0);
let peak_hz = peak_bin as f32 * sample_rate / n as f32;
(peak_hz, peak_bin)
}
#[test]
fn test_pitch_shifter_octave_up_frequency() {
let sample_rate = 44100.0_f32;
let f0 = 440.0_f32;
let n = 4096_usize;
let input: Vec<f32> = (0..n)
.map(|i| (2.0 * std::f32::consts::PI * f0 * i as f32 / sample_rate).sin())
.collect();
let shifted = AdvancedPitchShifter::shift_resample(&input, 12.0);
assert!(!shifted.is_empty(), "shifted output must not be empty");
assert!(
shifted.iter().all(|s| s.is_finite()),
"all shifted samples must be finite"
);
let mut padded = shifted;
padded.resize(n, 0.0);
let (peak_hz, _peak_bin) = dominant_frequency(&padded, sample_rate);
let expected_hz = 880.0_f32;
let bin_width = sample_rate / n as f32;
assert!(
(peak_hz - expected_hz).abs() <= bin_width * 2.0,
"octave-up peak at {peak_hz:.1} Hz, expected ~{expected_hz} Hz (±{:.1} Hz tolerance)",
bin_width * 2.0
);
}
#[test]
fn test_pitch_shifter_down_seven_semitones_frequency() {
let sample_rate = 44100.0_f32;
let f0 = 440.0_f32;
let n = 4096_usize;
let input: Vec<f32> = (0..n)
.map(|i| (2.0 * std::f32::consts::PI * f0 * i as f32 / sample_rate).sin())
.collect();
let shifted = AdvancedPitchShifter::shift_resample(&input, -7.0);
assert!(!shifted.is_empty(), "shifted output must not be empty");
assert!(
shifted.iter().all(|s| s.is_finite()),
"all shifted samples must be finite"
);
let mut padded = shifted;
padded.resize(n, 0.0);
let (peak_hz, _peak_bin) = dominant_frequency(&padded, sample_rate);
let expected_hz = f0 * 2.0_f32.powf(-7.0 / 12.0); let bin_width = sample_rate / n as f32;
assert!(
(peak_hz - expected_hz).abs() <= bin_width * 2.0,
"down-7-semitone peak at {peak_hz:.1} Hz, expected ~{expected_hz:.1} Hz (±{:.1} Hz tolerance)",
bin_width * 2.0
);
}
}