#![allow(clippy::cast_precision_loss)]
use crate::AudioEffect;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Key {
C = 0,
CSharp = 1,
D = 2,
DSharp = 3,
E = 4,
F = 5,
FSharp = 6,
G = 7,
GSharp = 8,
A = 9,
ASharp = 10,
B = 11,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Scale {
Chromatic,
Major,
Minor,
HarmonicMinor,
Dorian,
Mixolydian,
Pentatonic,
Blues,
}
impl Scale {
#[must_use]
fn intervals(self) -> [bool; 12] {
match self {
Scale::Chromatic => [true; 12],
Scale::Major => [
true, false, true, false, true, true, false, true, false, true, false, true,
],
Scale::Minor => [
true, false, true, true, false, true, false, true, true, false, true, false,
],
Scale::HarmonicMinor => [
true, false, true, true, false, true, false, true, true, false, false, true,
],
Scale::Dorian => [
true, false, true, true, false, true, false, true, false, true, true, false,
],
Scale::Mixolydian => [
true, false, true, false, true, true, false, true, false, true, true, false,
],
Scale::Pentatonic => [
true, false, true, false, true, false, false, true, false, true, false, false,
],
Scale::Blues => [
true, false, false, true, false, true, true, true, false, false, true, false,
],
}
}
fn allowed_notes(self, key: Key) -> [bool; 12] {
let base = self.intervals();
let offset = key as usize;
let mut rotated = [false; 12];
for i in 0..12 {
rotated[(i + offset) % 12] = base[i];
}
rotated
}
}
#[derive(Debug, Clone)]
pub struct AutoTuneConfig {
pub correction: f32,
pub key: Key,
pub scale: Scale,
pub reference_a4: f32,
pub humanize: f32,
pub min_freq: f32,
pub max_freq: f32,
}
impl Default for AutoTuneConfig {
fn default() -> Self {
Self {
correction: 0.5,
key: Key::C,
scale: Scale::Chromatic,
reference_a4: 440.0,
humanize: 0.0,
min_freq: 60.0,
max_freq: 1200.0,
}
}
}
impl AutoTuneConfig {
#[must_use]
pub fn with_key_scale(mut self, key: Key, scale: Scale) -> Self {
self.key = key;
self.scale = scale;
self
}
#[must_use]
pub fn with_correction(mut self, correction: f32) -> Self {
self.correction = correction.clamp(0.0, 1.0);
self
}
#[must_use]
pub fn with_humanize(mut self, humanize: f32) -> Self {
self.humanize = humanize.clamp(0.0, 1.0);
self
}
#[must_use]
pub fn with_reference(mut self, a4_hz: f32) -> Self {
self.reference_a4 = a4_hz.clamp(400.0, 480.0);
self
}
}
struct YinDetector {
buffer: Vec<f32>,
write_pos: usize,
threshold: f32,
min_period: usize,
max_period: usize,
half_size: usize,
diff: Vec<f32>,
cmndf: Vec<f32>,
}
impl YinDetector {
fn new(sample_rate: f32, min_freq: f32, max_freq: f32) -> Self {
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let min_period = (sample_rate / max_freq).ceil() as usize;
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let max_period = (sample_rate / min_freq).floor() as usize;
let half_size = max_period.max(256);
let buffer_size = half_size * 2;
Self {
buffer: vec![0.0; buffer_size],
write_pos: 0,
threshold: 0.15,
min_period: min_period.max(2),
max_period,
half_size,
diff: vec![0.0; half_size],
cmndf: vec![0.0; half_size],
}
}
fn push(&mut self, sample: f32) {
self.buffer[self.write_pos] = sample;
self.write_pos = (self.write_pos + 1) % self.buffer.len();
}
fn read_back(&self, offset: usize) -> f32 {
let len = self.buffer.len();
let idx = (self.write_pos + len - 1 - offset) % len;
self.buffer[idx]
}
fn detect(&mut self, sample_rate: f32) -> Option<f32> {
let w = self.half_size;
let max_tau = self.max_period.min(w - 1);
if max_tau <= self.min_period {
return None;
}
self.diff[0] = 0.0;
for tau in 1..=max_tau {
let mut sum = 0.0f32;
for j in 0..w {
let x_j = self.read_back(j);
let x_j_tau = self.read_back(j + tau);
let delta = x_j - x_j_tau;
sum += delta * delta;
}
self.diff[tau] = sum;
}
self.cmndf[0] = 1.0;
let mut running_sum = 0.0f32;
for tau in 1..=max_tau {
running_sum += self.diff[tau];
if running_sum > 0.0 {
#[allow(clippy::cast_precision_loss)]
let tau_f = tau as f32;
self.cmndf[tau] = self.diff[tau] * tau_f / running_sum;
} else {
self.cmndf[tau] = 1.0;
}
}
let mut best_tau = None;
for tau in self.min_period..=max_tau {
if self.cmndf[tau] < self.threshold {
let mut min_tau = tau;
let mut min_val = self.cmndf[tau];
let search_end = (tau + 4).min(max_tau);
for t in (tau + 1)..=search_end {
if self.cmndf[t] < min_val {
min_val = self.cmndf[t];
min_tau = t;
} else {
break;
}
}
best_tau = Some(min_tau);
break;
}
}
let tau = best_tau?;
let refined_tau = if tau > 0 && tau < max_tau {
let s0 = self.cmndf[tau - 1];
let s1 = self.cmndf[tau];
let s2 = self.cmndf[tau + 1];
let denom = 2.0 * s1 - s2 - s0;
if denom.abs() > 1e-12 {
#[allow(clippy::cast_precision_loss)]
let correction = (s0 - s2) / (2.0 * denom);
tau as f32 + correction
} else {
tau as f32
}
} else {
tau as f32
};
if refined_tau > 0.0 {
Some(sample_rate / refined_tau)
} else {
None
}
}
fn reset(&mut self) {
self.buffer.fill(0.0);
self.write_pos = 0;
}
}
fn quantize_to_scale(freq: f32, reference_a4: f32, allowed: &[bool; 12]) -> (f32, f32) {
let midi_continuous = 69.0 + 12.0 * (freq / reference_a4).ln() / core::f32::consts::LN_2;
let midi_rounded = midi_continuous.round() as i32;
let pitch_class = ((midi_rounded % 12) + 12) % 12;
let mut best_offset = 0i32;
for offset in 0..7 {
let up = ((pitch_class + offset) % 12) as usize;
let down = ((pitch_class - offset + 12) % 12) as usize;
if allowed[up] {
best_offset = offset;
break;
}
if allowed[down] && offset > 0 {
best_offset = -offset;
break;
}
}
let target_midi = midi_rounded + best_offset;
#[allow(clippy::cast_precision_loss)]
let target_freq = reference_a4 * 2.0f32.powf((target_midi as f32 - 69.0) / 12.0);
let semitone_offset = midi_continuous - target_midi as f32;
(target_freq, semitone_offset)
}
#[must_use]
pub fn frequency_to_note_name(freq: f32, reference_a4: f32) -> String {
const NAMES: [&str; 12] = [
"C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B",
];
let midi = 69.0 + 12.0 * (freq / reference_a4).ln() / core::f32::consts::LN_2;
let midi_rounded = midi.round() as i32;
let note_idx = ((midi_rounded % 12) + 12) % 12;
let octave = (midi_rounded / 12) - 1;
format!("{}{}", NAMES[note_idx as usize], octave)
}
struct PitchCorrector {
delay_buffer: Vec<f32>,
buffer_size: usize,
write_pos: usize,
read_phase: f64,
current_ratio: f64,
target_ratio: f64,
smooth_coeff: f64,
}
impl PitchCorrector {
fn new(sample_rate: f32) -> Self {
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let buffer_size = (sample_rate * 0.1) as usize; let buffer_size = buffer_size.max(256);
let initial_delay = buffer_size / 2;
Self {
delay_buffer: vec![0.0; buffer_size],
buffer_size,
write_pos: initial_delay,
read_phase: 0.0,
current_ratio: 1.0,
target_ratio: 1.0,
smooth_coeff: 0.999, }
}
fn set_correction_speed(&mut self, speed: f32) {
let speed_clamped = speed.clamp(0.01, 1.0) as f64;
self.smooth_coeff = 1.0 - speed_clamped * 0.01;
}
fn set_target_ratio(&mut self, ratio: f64) {
self.target_ratio = ratio.clamp(0.5, 2.0);
}
fn process(&mut self, input: f32) -> f32 {
self.delay_buffer[self.write_pos] = input;
self.write_pos = (self.write_pos + 1) % self.buffer_size;
self.current_ratio =
self.smooth_coeff * self.current_ratio + (1.0 - self.smooth_coeff) * self.target_ratio;
self.read_phase += self.current_ratio;
if self.read_phase >= self.buffer_size as f64 {
self.read_phase -= self.buffer_size as f64;
}
let read_pos = self.read_phase;
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let idx0 = read_pos as usize % self.buffer_size;
let idx1 = (idx0 + 1) % self.buffer_size;
let frac = (read_pos - idx0 as f64) as f32;
self.delay_buffer[idx0] * (1.0 - frac) + self.delay_buffer[idx1] * frac
}
fn reset(&mut self) {
self.delay_buffer.fill(0.0);
self.write_pos = 0;
self.read_phase = 0.0;
self.current_ratio = 1.0;
self.target_ratio = 1.0;
}
}
pub struct AutoTune {
config: AutoTuneConfig,
detector: YinDetector,
corrector: PitchCorrector,
allowed_notes: [bool; 12],
last_detected_freq: f32,
last_target_freq: f32,
sample_rate: f32,
sample_counter: usize,
detection_interval: usize,
}
impl AutoTune {
#[must_use]
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
pub fn new(config: AutoTuneConfig, sample_rate: f32) -> Self {
let allowed_notes = config.scale.allowed_notes(config.key);
let mut corrector = PitchCorrector::new(sample_rate);
corrector.set_correction_speed(config.correction);
let detection_interval = (sample_rate * 0.005).max(1.0) as usize;
Self {
detector: YinDetector::new(sample_rate, config.min_freq, config.max_freq),
corrector,
allowed_notes,
last_detected_freq: 0.0,
last_target_freq: 0.0,
sample_rate,
sample_counter: 0,
detection_interval,
config,
}
}
pub fn set_correction(&mut self, correction: f32) {
self.config.correction = correction.clamp(0.0, 1.0);
self.corrector.set_correction_speed(self.config.correction);
}
pub fn set_key_scale(&mut self, key: Key, scale: Scale) {
self.config.key = key;
self.config.scale = scale;
self.allowed_notes = scale.allowed_notes(key);
}
pub fn set_humanize(&mut self, humanize: f32) {
self.config.humanize = humanize.clamp(0.0, 1.0);
}
#[must_use]
pub fn detected_frequency(&self) -> f32 {
self.last_detected_freq
}
#[must_use]
pub fn target_frequency(&self) -> f32 {
self.last_target_freq
}
#[must_use]
pub fn current_note(&self) -> String {
if self.last_target_freq > 0.0 {
frequency_to_note_name(self.last_target_freq, self.config.reference_a4)
} else {
String::from("--")
}
}
}
impl AudioEffect for AutoTune {
fn process_sample(&mut self, input: f32) -> f32 {
self.detector.push(input);
self.sample_counter += 1;
if self.sample_counter >= self.detection_interval {
self.sample_counter = 0;
if let Some(freq) = self.detector.detect(self.sample_rate) {
self.last_detected_freq = freq;
let (target_freq, semitone_offset) =
quantize_to_scale(freq, self.config.reference_a4, &self.allowed_notes);
self.last_target_freq = target_freq;
let effective_correction = if self.config.humanize > 0.0 {
let deviation = semitone_offset.abs();
let humanize_threshold = self.config.humanize * 0.5; if deviation < humanize_threshold {
self.config.correction * (deviation / humanize_threshold)
} else {
self.config.correction
}
} else {
self.config.correction
};
if freq > 0.0 {
let full_ratio = target_freq as f64 / freq as f64;
let ratio = 1.0 + (full_ratio - 1.0) * effective_correction as f64;
self.corrector.set_target_ratio(ratio);
}
}
}
self.corrector.process(input)
}
fn reset(&mut self) {
self.detector.reset();
self.corrector.reset();
self.last_detected_freq = 0.0;
self.last_target_freq = 0.0;
self.sample_counter = 0;
}
fn set_sample_rate(&mut self, sample_rate: f32) {
*self = Self::new(self.config.clone(), sample_rate);
}
fn latency_samples(&self) -> usize {
self.detector.half_size
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::f32::consts::PI;
fn generate_sine(freq: f32, sample_rate: f32, num_samples: usize) -> Vec<f32> {
(0..num_samples)
.map(|i| {
#[allow(clippy::cast_precision_loss)]
let t = i as f32 / sample_rate;
(2.0 * PI * freq * t).sin()
})
.collect()
}
#[test]
fn test_autotune_creation() {
let config = AutoTuneConfig::default();
let autotune = AutoTune::new(config, 48000.0);
assert!((autotune.sample_rate - 48000.0).abs() < 1e-3);
assert!((autotune.detected_frequency() - 0.0).abs() < 1e-6);
}
#[test]
fn test_autotune_process_finite() {
let config = AutoTuneConfig::default();
let mut autotune = AutoTune::new(config, 48000.0);
for i in 0..4800 {
#[allow(clippy::cast_precision_loss)]
let input = (i as f32 * 0.1).sin() * 0.5;
let output = autotune.process_sample(input);
assert!(output.is_finite(), "Output should be finite at sample {i}");
}
}
#[test]
fn test_scale_intervals_chromatic() {
let intervals = Scale::Chromatic.intervals();
assert!(intervals.iter().all(|&v| v));
}
#[test]
fn test_scale_intervals_major() {
let intervals = Scale::Major.intervals();
assert!(intervals[0]); assert!(!intervals[1]); assert!(intervals[2]); assert!(!intervals[3]); assert!(intervals[4]); assert!(intervals[5]); assert!(!intervals[6]); assert!(intervals[7]); assert!(!intervals[8]); assert!(intervals[9]); assert!(!intervals[10]); assert!(intervals[11]); }
#[test]
fn test_scale_intervals_minor() {
let intervals = Scale::Minor.intervals();
assert!(intervals[0]); assert!(intervals[2]); assert!(intervals[3]); assert!(intervals[5]); assert!(intervals[7]); assert!(intervals[8]); assert!(intervals[10]); }
#[test]
fn test_allowed_notes_c_major() {
let allowed = Scale::Major.allowed_notes(Key::C);
assert!(allowed[0]); assert!(allowed[4]); assert!(allowed[7]); assert!(!allowed[1]); }
#[test]
fn test_allowed_notes_g_major() {
let allowed = Scale::Major.allowed_notes(Key::G);
assert!(allowed[7]); assert!(allowed[9]); assert!(allowed[11]); assert!(allowed[0]); assert!(allowed[2]); assert!(allowed[4]); assert!(allowed[6]); assert!(!allowed[1]); }
#[test]
fn test_quantize_a440_chromatic() {
let allowed = Scale::Chromatic.allowed_notes(Key::C);
let (target, offset) = quantize_to_scale(440.0, 440.0, &allowed);
assert!(
(target - 440.0).abs() < 1.0,
"A440 should quantize to itself: {target}"
);
assert!(offset.abs() < 0.01);
}
#[test]
fn test_quantize_between_notes() {
let allowed = Scale::Chromatic.allowed_notes(Key::C);
let (target, _offset) = quantize_to_scale(450.0, 440.0, &allowed);
assert!(
(target - 440.0).abs() < 2.0,
"450Hz should snap to A4 (440): {target}"
);
}
#[test]
fn test_quantize_to_c_major_skips_black_keys() {
let allowed = Scale::Major.allowed_notes(Key::C);
let fsharp4 = 440.0 * 2.0f32.powf(-3.0 / 12.0); let (target, _offset) = quantize_to_scale(fsharp4, 440.0, &allowed);
let f4 = 440.0 * 2.0f32.powf(-4.0 / 12.0);
let g4 = 440.0 * 2.0f32.powf(-2.0 / 12.0);
let dist_to_f = (target - f4).abs();
let dist_to_g = (target - g4).abs();
assert!(
dist_to_f < 2.0 || dist_to_g < 2.0,
"F#4 should snap to F4 or G4 in C major: target={target}"
);
}
#[test]
fn test_frequency_to_note_name() {
assert_eq!(frequency_to_note_name(440.0, 440.0), "A4");
assert_eq!(frequency_to_note_name(261.63, 440.0), "C4");
assert_eq!(frequency_to_note_name(880.0, 440.0), "A5");
}
#[test]
fn test_yin_detector_sine() {
let sample_rate = 48000.0;
let freq = 440.0;
let samples = generate_sine(freq, sample_rate, 24000);
let mut detector = YinDetector::new(sample_rate, 60.0, 1200.0);
for &s in &samples {
detector.push(s);
}
let detected = detector.detect(sample_rate);
assert!(detected.is_some(), "Should detect pitch for 440Hz sine");
if let Some(f) = detected {
let rel_error = (f - freq).abs() / freq;
assert!(
rel_error < 0.15,
"Pitch detection error too large: {rel_error:.3} ({f:.1} Hz vs {freq:.1} Hz)"
);
}
}
#[test]
fn test_yin_detector_low_freq() {
let sample_rate = 48000.0;
let freq = 100.0;
let samples = generate_sine(freq, sample_rate, 48000);
let mut detector = YinDetector::new(sample_rate, 60.0, 1200.0);
for &s in &samples {
detector.push(s);
}
let detected = detector.detect(sample_rate);
assert!(detected.is_some(), "Should detect 100Hz");
if let Some(f) = detected {
let rel_error = (f - freq).abs() / freq;
assert!(
rel_error < 0.25,
"100Hz detection off: detected {f} (error {rel_error:.3})"
);
}
}
#[test]
fn test_yin_detector_silence() {
let sample_rate = 48000.0;
let mut detector = YinDetector::new(sample_rate, 60.0, 1200.0);
for _ in 0..4800 {
detector.push(0.0);
}
let detected = detector.detect(sample_rate);
if let Some(f) = detected {
assert!(f.is_finite());
}
}
#[test]
fn test_pitch_corrector_passthrough() {
let mut corrector = PitchCorrector::new(48000.0);
corrector.set_target_ratio(1.0);
let mut output_energy = 0.0f32;
for i in 0..4800 {
#[allow(clippy::cast_precision_loss)]
let input = (i as f32 * 0.1).sin() * 0.5;
let output = corrector.process(input);
output_energy += output * output;
assert!(output.is_finite());
}
assert!(output_energy > 0.0, "Should produce non-zero output");
}
#[test]
fn test_autotune_with_sine_c_major() {
let config = AutoTuneConfig::default()
.with_key_scale(Key::C, Scale::Major)
.with_correction(0.8);
let mut autotune = AutoTune::new(config, 48000.0);
let samples = generate_sine(440.0, 48000.0, 48000);
let mut output = Vec::with_capacity(samples.len());
for &s in &samples {
output.push(autotune.process_sample(s));
}
assert!(output.iter().all(|s| s.is_finite()));
let energy: f32 = output.iter().map(|s| s * s).sum();
assert!(energy > 0.1, "Output should have energy");
}
#[test]
fn test_autotune_humanize() {
let config = AutoTuneConfig::default()
.with_correction(0.8)
.with_humanize(0.5);
let mut autotune = AutoTune::new(config, 48000.0);
for i in 0..4800 {
#[allow(clippy::cast_precision_loss)]
let input = (i as f32 * 0.05).sin() * 0.3;
let output = autotune.process_sample(input);
assert!(output.is_finite());
}
}
#[test]
fn test_autotune_reset() {
let config = AutoTuneConfig::default();
let mut autotune = AutoTune::new(config, 48000.0);
for i in 0..2400 {
#[allow(clippy::cast_precision_loss)]
let input = (i as f32 * 0.1).sin();
autotune.process_sample(input);
}
autotune.reset();
assert!((autotune.detected_frequency() - 0.0).abs() < 1e-6);
assert!((autotune.target_frequency() - 0.0).abs() < 1e-6);
}
#[test]
fn test_autotune_set_key_scale() {
let config = AutoTuneConfig::default();
let mut autotune = AutoTune::new(config, 48000.0);
autotune.set_key_scale(Key::G, Scale::Major);
assert!(autotune.allowed_notes[6]); }
#[test]
fn test_autotune_current_note_no_pitch() {
let config = AutoTuneConfig::default();
let autotune = AutoTune::new(config, 48000.0);
assert_eq!(autotune.current_note(), "--");
}
#[test]
fn test_config_builder() {
let config = AutoTuneConfig::default()
.with_key_scale(Key::D, Scale::Minor)
.with_correction(0.9)
.with_humanize(0.3)
.with_reference(442.0);
assert_eq!(config.key, Key::D);
assert_eq!(config.scale, Scale::Minor);
assert!((config.correction - 0.9).abs() < 1e-6);
assert!((config.humanize - 0.3).abs() < 1e-6);
assert!((config.reference_a4 - 442.0).abs() < 1e-6);
}
#[test]
fn test_all_scales_have_at_least_5_notes() {
let scales = [
Scale::Chromatic,
Scale::Major,
Scale::Minor,
Scale::HarmonicMinor,
Scale::Dorian,
Scale::Mixolydian,
Scale::Pentatonic,
Scale::Blues,
];
for scale in &scales {
let count = scale.intervals().iter().filter(|&&v| v).count();
assert!(
count >= 5,
"{scale:?} should have at least 5 notes, has {count}"
);
}
}
#[test]
fn test_all_keys() {
let keys = [
Key::C,
Key::CSharp,
Key::D,
Key::DSharp,
Key::E,
Key::F,
Key::FSharp,
Key::G,
Key::GSharp,
Key::A,
Key::ASharp,
Key::B,
];
for key in &keys {
let allowed = Scale::Major.allowed_notes(*key);
let count = allowed.iter().filter(|&&v| v).count();
assert_eq!(count, 7, "Major scale should have 7 notes for key {key:?}");
}
}
#[test]
fn test_quantize_all_semitones_chromatic() {
let allowed = Scale::Chromatic.allowed_notes(Key::C);
for midi_note in 48..72 {
#[allow(clippy::cast_precision_loss)]
let freq = 440.0 * 2.0f32.powf((midi_note as f32 - 69.0) / 12.0);
let (target, offset) = quantize_to_scale(freq, 440.0, &allowed);
assert!(
(target - freq).abs() < 1.0,
"MIDI {midi_note}: {freq} should quantize to itself, got {target}"
);
assert!(offset.abs() < 0.1);
}
}
#[test]
fn test_latency_nonzero() {
let config = AutoTuneConfig::default();
let autotune = AutoTune::new(config, 48000.0);
assert!(
autotune.latency_samples() > 0,
"AutoTune should report non-zero latency"
);
}
#[test]
fn test_harmonic_minor_intervals() {
let intervals = Scale::HarmonicMinor.intervals();
assert!(intervals[0]); assert!(intervals[2]); assert!(intervals[3]); assert!(intervals[5]); assert!(intervals[7]); assert!(intervals[8]); assert!(intervals[11]); assert!(!intervals[10]); }
#[test]
fn test_blues_scale() {
let intervals = Scale::Blues.intervals();
assert!(intervals[0]); assert!(intervals[3]); assert!(intervals[5]); assert!(intervals[6]); assert!(intervals[7]); assert!(intervals[10]); }
}