use std::fmt::Display;
use thiserror::Error;
const SEMITONES: usize = 12;
const NUM_CHORD_KINDS: usize = 10;
const NUM_CHORDS: usize = SEMITONES * NUM_CHORD_KINDS;
const CHORD_KINDS: [ChordKind; NUM_CHORD_KINDS] = [
ChordKind::Major,
ChordKind::Minor,
ChordKind::PowerFifth,
ChordKind::DominantSeventh,
ChordKind::MajorSeventh,
ChordKind::MinorSeventh,
ChordKind::Diminished,
ChordKind::Augmented,
ChordKind::SuspendedSecond,
ChordKind::SuspendedFourth,
];
const CHORD_SPECS: &[(ChordKind, f32, f32)] = &[
(ChordKind::Major, 1.06, 3.0),
(ChordKind::Minor, 1.06, 3.0),
(ChordKind::PowerFifth, 1.005, 2.0),
(ChordKind::DominantSeventh, 1.06, 4.0),
(ChordKind::MajorSeventh, 1.00, 4.0),
(ChordKind::MinorSeventh, 1.06, 4.0),
(ChordKind::Diminished, 1.05, 3.0),
(ChordKind::Augmented, 1.055, 3.0),
(ChordKind::SuspendedSecond, 1.0, 3.0),
(ChordKind::SuspendedFourth, 1.0, 3.0),
];
const CHORD_INTERVALS: [&[usize]; NUM_CHORD_KINDS] = [
&[0, 4, 7],
&[0, 3, 7],
&[0, 7],
&[0, 4, 7, 10],
&[0, 4, 7, 11],
&[0, 3, 7, 10],
&[0, 3, 6],
&[0, 4, 8],
&[0, 2, 7],
&[0, 5, 7],
];
type Chromagram = [f32; SEMITONES];
#[derive(Copy, Clone)]
struct PrecalcProfile {
weights: Chromagram,
inv_norm: f32,
}
#[derive(Debug, Copy, Clone, PartialEq)]
pub struct Chord {
pub root: NoteName,
pub quality: ChordKind,
pub confidence: f32,
}
#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub enum ChordKind {
Major,
Minor,
PowerFifth,
DominantSeventh,
MajorSeventh,
MinorSeventh,
Diminished,
Augmented,
SuspendedSecond,
SuspendedFourth,
}
impl Display for ChordKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{self:?}")
}
}
#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)]
pub enum NoteName {
C,
Cs,
D,
Ds,
E,
F,
Fs,
G,
Gs,
A,
As,
B,
Unknown,
}
impl NoteName {
const fn from_idx(idx: usize) -> NoteName {
match idx {
0 => NoteName::C,
1 => NoteName::Cs,
2 => NoteName::D,
3 => NoteName::Ds,
4 => NoteName::E,
5 => NoteName::F,
6 => NoteName::Fs,
7 => NoteName::G,
8 => NoteName::Gs,
9 => NoteName::A,
10 => NoteName::As,
11 => NoteName::B,
_ => NoteName::Unknown,
}
}
}
#[derive(Debug, Error)]
pub enum ChordError {
#[error("expected a {expected}-bin chromagram, got {got}")]
InvalidLength {
expected: usize,
got: usize,
},
#[error("invalid argument `{arg}`: {msg}")]
InvalidArgument {
arg: &'static str,
msg: String,
},
}
pub struct ChordDetectorBuilder {
bleed: f32,
}
impl ChordDetectorBuilder {
pub fn new() -> Self {
ChordDetectorBuilder { bleed: 0.157 }
}
pub fn bleed(mut self, value: f32) -> Self {
self.bleed = value;
self
}
pub fn build(self) -> ChordDetector {
ChordDetector::with_bleed(self.bleed)
}
}
impl Default for ChordDetectorBuilder {
fn default() -> Self {
Self::new()
}
}
pub struct ChordDetector {
bleed: f32,
chroma_work: Chromagram,
profiles: [PrecalcProfile; NUM_CHORDS],
scores: [f32; NUM_CHORDS],
idx_scores: Vec<(usize, f32)>,
}
impl ChordDetector {
pub fn builder() -> ChordDetectorBuilder {
ChordDetectorBuilder::new()
}
pub fn new() -> Self {
Self::with_bleed(0.157)
}
fn with_bleed(bleed: f32) -> Self {
let mut profiles = [PrecalcProfile {
weights: [0.0; SEMITONES],
inv_norm: 0.0,
}; NUM_CHORDS];
for (kind_idx, &(_kind, bias, interval_count)) in CHORD_SPECS.iter().enumerate() {
let inv = 1.0 / (((SEMITONES as f32) - interval_count) * bias);
let intervals = CHORD_INTERVALS[kind_idx];
for root in 0..SEMITONES {
let base = kind_idx * SEMITONES + root;
profiles[base].inv_norm = inv;
for &off in intervals {
let note = (root + off) % SEMITONES;
profiles[base].weights[note] = 1.0;
}
}
}
ChordDetector {
bleed,
chroma_work: [0.0; SEMITONES],
profiles,
scores: [0.0; NUM_CHORDS],
idx_scores: Vec::with_capacity(NUM_CHORDS),
}
}
pub fn detect_chord(&mut self, chroma: &[f32]) -> Result<Chord, ChordError> {
let mut list = self.top_k(chroma, 1)?;
Ok(list.remove(0))
}
pub fn top_k(&mut self, chroma: &[f32], k: usize) -> Result<Vec<Chord>, ChordError> {
if chroma.len() != SEMITONES {
return Err(ChordError::InvalidLength {
expected: SEMITONES,
got: chroma.len(),
});
}
if k == 0 {
return Err(ChordError::InvalidArgument {
arg: "k",
msg: "must be >= 1".to_string(),
});
}
let choices = k.min(NUM_CHORDS);
self.classify_chroma(chroma, choices)
}
fn classify_chroma(
&mut self,
chroma: &[f32],
choices: usize,
) -> Result<Vec<Chord>, ChordError> {
self.chroma_work.copy_from_slice(chroma);
for i in 0..SEMITONES {
let bleed_amt = self.bleed * self.chroma_work[i];
let target = (i + SEMITONES - 5) % SEMITONES; let reduced = (self.chroma_work[target] - bleed_amt).max(0.0);
self.chroma_work[target] = reduced;
}
for (i, p) in self.profiles.iter().enumerate() {
self.scores[i] = score_chord(&self.chroma_work, p);
}
self.idx_scores.clear();
for (i, &s) in self.scores.iter().enumerate() {
self.idx_scores.push((i, s));
}
self.idx_scores
.select_nth_unstable_by(choices, |a, b| a.1.partial_cmp(&b.1).unwrap());
self.idx_scores[..choices]
.sort_unstable_by(|a, b| a.1.partial_cmp(&b.1).unwrap());
let mut result = Vec::with_capacity(choices);
for &(idx, score) in &self.idx_scores[..choices] {
let kind_idx = idx / SEMITONES;
let root_idx = idx % SEMITONES;
result.push(Chord {
root: NoteName::from_idx(root_idx),
quality: CHORD_KINDS[kind_idx],
confidence: score,
});
}
Ok(result)
}
}
#[inline(always)]
fn score_chord(chroma: &Chromagram, p: &PrecalcProfile) -> f32 {
let mut acc = 0.0;
(0..SEMITONES).for_each(|i| {
let miss = 1.0 - p.weights[i];
let c = chroma[i];
acc += miss * (c * c);
});
acc.sqrt() * p.inv_norm
}
impl Default for ChordDetector {
fn default() -> Self {
ChordDetector::new()
}
}