fn semitones_to_ratio(semitones: f64) -> f64 {
2.0_f64.powf(semitones / 12.0)
}
fn semitone_distance(hz_a: f64, hz_b: f64) -> f64 {
if hz_a <= 0.0 || hz_b <= 0.0 {
return 0.0;
}
(12.0 * (hz_b / hz_a).log2()).abs()
}
#[derive(Debug, Clone)]
pub struct HarmonicSeries {
pub fundamental_hz: f64,
pub partials: Vec<(u32, f64)>,
}
impl HarmonicSeries {
pub fn new(fundamental_hz: f64) -> Self {
Self { fundamental_hz, partials: Vec::new() }
}
pub fn generate_partials(&mut self, n: usize) -> Vec<f64> {
self.partials.clear();
let mut freqs = Vec::with_capacity(n);
for k in 1..=(n as u32) {
let amplitude = 1.0 / k as f64;
self.partials.push((k, amplitude));
freqs.push(self.fundamental_hz * k as f64);
}
freqs
}
pub fn partial_freq(&self, k: u32) -> f64 {
self.fundamental_hz * k as f64
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Voicing {
Close,
Open,
Drop2,
Drop3,
}
#[derive(Debug, Clone)]
pub struct Chord {
pub root_hz: f64,
pub intervals: Vec<f64>,
pub voicing: Voicing,
}
impl Chord {
pub fn frequencies(&self) -> Vec<f64> {
let mut freqs: Vec<f64> =
self.intervals.iter().map(|r| self.root_hz * r).collect();
freqs.sort_by(|a, b| a.partial_cmp(b).unwrap());
match self.voicing {
Voicing::Close => freqs,
Voicing::Open => {
let mut open = Vec::with_capacity(freqs.len());
for (i, &f) in freqs.iter().enumerate() {
if i % 2 == 0 {
open.push(f);
} else {
open.push(f * 2.0);
}
}
open.sort_by(|a, b| a.partial_cmp(b).unwrap());
open
}
Voicing::Drop2 => {
if freqs.len() < 2 {
return freqs;
}
let top_idx = freqs.len() - 2; freqs[top_idx] /= 2.0;
freqs.sort_by(|a, b| a.partial_cmp(b).unwrap());
freqs
}
Voicing::Drop3 => {
if freqs.len() < 3 {
return freqs;
}
let top_idx = freqs.len() - 3; freqs[top_idx] /= 2.0;
freqs.sort_by(|a, b| a.partial_cmp(b).unwrap());
freqs
}
}
}
}
pub struct ChordBuilder;
const MAJOR_INTERVALS: &[f64] = &[0.0, 4.0, 7.0];
const MINOR_INTERVALS: &[f64] = &[0.0, 3.0, 7.0];
const DOMINANT7_INTERVALS: &[f64] = &[0.0, 4.0, 7.0, 10.0];
const MAJOR7_INTERVALS: &[f64] = &[0.0, 4.0, 7.0, 11.0];
const MINOR7_INTERVALS: &[f64] = &[0.0, 3.0, 7.0, 10.0];
const DIM7_INTERVALS: &[f64] = &[0.0, 3.0, 6.0, 9.0];
const AUG_INTERVALS: &[f64] = &[0.0, 4.0, 8.0];
impl ChordBuilder {
fn build(root_hz: f64, semitones: &[f64], voicing: Voicing) -> Chord {
let intervals = semitones.iter().map(|&s| semitones_to_ratio(s)).collect();
Chord { root_hz, intervals, voicing }
}
pub fn major(root_hz: f64, voicing: Voicing) -> Chord {
Self::build(root_hz, MAJOR_INTERVALS, voicing)
}
pub fn minor(root_hz: f64, voicing: Voicing) -> Chord {
Self::build(root_hz, MINOR_INTERVALS, voicing)
}
pub fn dominant7(root_hz: f64, voicing: Voicing) -> Chord {
Self::build(root_hz, DOMINANT7_INTERVALS, voicing)
}
pub fn major7(root_hz: f64, voicing: Voicing) -> Chord {
Self::build(root_hz, MAJOR7_INTERVALS, voicing)
}
pub fn minor7(root_hz: f64, voicing: Voicing) -> Chord {
Self::build(root_hz, MINOR7_INTERVALS, voicing)
}
pub fn dim7(root_hz: f64, voicing: Voicing) -> Chord {
Self::build(root_hz, DIM7_INTERVALS, voicing)
}
pub fn aug(root_hz: f64, voicing: Voicing) -> Chord {
Self::build(root_hz, AUG_INTERVALS, voicing)
}
}
pub fn voice_leading_distance(chord_a: &Chord, chord_b: &Chord) -> f64 {
let a_freqs = chord_a.frequencies();
let b_freqs = chord_b.frequencies();
let mut total = 0.0;
let len = a_freqs.len().min(b_freqs.len());
for i in 0..len {
total += semitone_distance(a_freqs[i], b_freqs[i]);
}
let extra = (a_freqs.len() as isize - b_freqs.len() as isize).unsigned_abs();
total += extra as f64 * 12.0;
total
}
pub fn optimal_voice_leading<'a>(from: &Chord, candidates: &'a [Chord]) -> Option<&'a Chord> {
candidates.iter().min_by(|a, b| {
let da = voice_leading_distance(from, a);
let db = voice_leading_distance(from, b);
da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
})
}
#[cfg(test)]
mod tests {
use super::*;
const A4: f64 = 440.0;
#[test]
fn harmonic_series_partials_count() {
let mut hs = HarmonicSeries::new(A4);
let freqs = hs.generate_partials(8);
assert_eq!(freqs.len(), 8);
assert_eq!(hs.partials.len(), 8);
}
#[test]
fn harmonic_series_frequencies() {
let mut hs = HarmonicSeries::new(100.0);
let freqs = hs.generate_partials(4);
assert!((freqs[0] - 100.0).abs() < 1e-9);
assert!((freqs[1] - 200.0).abs() < 1e-9);
assert!((freqs[2] - 300.0).abs() < 1e-9);
assert!((freqs[3] - 400.0).abs() < 1e-9);
}
#[test]
fn harmonic_series_amplitudes_decay() {
let mut hs = HarmonicSeries::new(A4);
hs.generate_partials(4);
for (i, &(k, amp)) in hs.partials.iter().enumerate() {
assert_eq!(k, i as u32 + 1);
assert!((amp - 1.0 / k as f64).abs() < 1e-9);
}
}
#[test]
fn chord_builder_major_close_has_three_notes() {
let c = ChordBuilder::major(261.63, Voicing::Close);
assert_eq!(c.frequencies().len(), 3);
}
#[test]
fn chord_builder_dominant7_has_four_notes() {
let c = ChordBuilder::dominant7(A4, Voicing::Close);
assert_eq!(c.frequencies().len(), 4);
}
#[test]
fn chord_builder_all_types_compile() {
let root = 261.63_f64;
let _ = ChordBuilder::major(root, Voicing::Close);
let _ = ChordBuilder::minor(root, Voicing::Open);
let _ = ChordBuilder::dominant7(root, Voicing::Drop2);
let _ = ChordBuilder::major7(root, Voicing::Drop3);
let _ = ChordBuilder::minor7(root, Voicing::Close);
let _ = ChordBuilder::dim7(root, Voicing::Open);
let _ = ChordBuilder::aug(root, Voicing::Close);
}
#[test]
fn open_voicing_spreads_notes() {
let close = ChordBuilder::major(261.63, Voicing::Close).frequencies();
let open = ChordBuilder::major(261.63, Voicing::Open).frequencies();
let close_range = close.last().unwrap() - close.first().unwrap();
let open_range = open.last().unwrap() - open.first().unwrap();
assert!(open_range > close_range);
}
#[test]
fn drop2_voicing_different_from_close() {
let close = ChordBuilder::dominant7(A4, Voicing::Close).frequencies();
let drop2 = ChordBuilder::dominant7(A4, Voicing::Drop2).frequencies();
assert_ne!(close, drop2);
}
#[test]
fn voice_leading_distance_same_chord_zero() {
let a = ChordBuilder::major(A4, Voicing::Close);
let b = ChordBuilder::major(A4, Voicing::Close);
let dist = voice_leading_distance(&a, &b);
assert!(dist < 1e-6);
}
#[test]
fn voice_leading_distance_semitone_move() {
let c = ChordBuilder::major(261.63, Voicing::Close);
let cs = ChordBuilder::major(261.63 * 2.0_f64.powf(1.0 / 12.0), Voicing::Close);
let dist = voice_leading_distance(&c, &cs);
assert!(dist > 0.0);
}
#[test]
fn optimal_voice_leading_selects_closest() {
let from = ChordBuilder::major(261.63, Voicing::Close);
let near = ChordBuilder::major(261.63 * 2.0_f64.powf(1.0 / 12.0), Voicing::Close);
let far = ChordBuilder::major(A4, Voicing::Close);
let candidates = vec![far.clone(), near.clone()];
let best = optimal_voice_leading(&from, &candidates).unwrap();
let best_dist = voice_leading_distance(&from, best);
let far_dist = voice_leading_distance(&from, &far);
assert!(best_dist <= far_dist);
}
#[test]
fn optimal_voice_leading_empty_candidates() {
let from = ChordBuilder::major(A4, Voicing::Close);
assert!(optimal_voice_leading(&from, &[]).is_none());
}
#[test]
fn semitone_distance_octave() {
let d = semitone_distance(A4, A4 * 2.0);
assert!((d - 12.0).abs() < 1e-6);
}
}