L
ling-daw
↓ 0 downloadsLing DAW engine: synth, physical-modeling drums, effects, mixer, sequencer, project I/O
lingfu install ling-dawCode / Files (91)
src/theory.rs
//! Music theory for the piano roll: key and key-change detection, chord detection with Roman numerals, a role for every note
//! (chord tone / scale tone / chromatic passing / tension / out of key), recurring phrases, next-chord suggestions and an
//! auto-correct that lines a score up with the key and the chords. Pure functions over absolute-time notes; `api.rs` gathers the
//! notes from the project. Everything is deterministic: no model is involved in deciding what is wrong, only in what to write.
pub const SHARP: [&str; 12] = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"];
pub const FLAT: [&str; 12] = ["C", "Db", "D", "Eb", "E", "F", "Gb", "G", "Ab", "A", "Bb", "B"];
/// A note in absolute beats (clip start already added).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct N {
pub start: f64,
pub len: f64,
pub key: u8,
pub vel: f32,
}
impl N {
pub fn end(&self) -> f64 {
self.start + self.len
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Key {
pub tonic: u8,
pub minor: bool,
}
const MAJOR: [u8; 7] = [0, 2, 4, 5, 7, 9, 11];
const MINOR: [u8; 7] = [0, 2, 3, 5, 7, 8, 10];
impl Key {
pub fn new(tonic: u8, minor: bool) -> Key {
Key { tonic: tonic % 12, minor }
}
/// flats for the keys that are written with them
pub fn flats(&self) -> bool {
if self.minor {
matches!(self.tonic, 2 | 5 | 7 | 10 | 3 | 0) // d g c f bb eb
} else {
matches!(self.tonic, 5 | 10 | 3 | 8 | 1 | 6) // F Bb Eb Ab Db Gb
}
}
pub fn pc_name(&self, pc: u8) -> &'static str {
if self.flats() {
FLAT[(pc % 12) as usize]
} else {
SHARP[(pc % 12) as usize]
}
}
pub fn name(&self) -> String {
format!("{} {}", self.pc_name(self.tonic), if self.minor { "minor" } else { "major" })
}
pub fn scale(&self) -> [u8; 7] {
let s = if self.minor { MINOR } else { MAJOR };
let mut out = [0u8; 7];
for (i, d) in s.iter().enumerate() {
out[i] = (self.tonic + d) % 12;
}
out
}
pub fn in_scale(&self, pc: u8) -> bool {
self.scale().contains(&(pc % 12))
}
/// notes a minor key borrows without leaving it: the raised 6th and 7th (melodic / harmonic minor)
pub fn tolerated(&self, pc: u8) -> bool {
let rel = (pc % 12 + 12 - self.tonic) % 12;
self.minor && (rel == 9 || rel == 11)
}
pub fn accepts(&self, pc: u8) -> bool {
self.in_scale(pc) || self.tolerated(pc)
}
/// the key a semitone count above this one, same mode
pub fn transposed(&self, semis: i32) -> Key {
Key::new(((self.tonic as i32 + semis).rem_euclid(12)) as u8, self.minor)
}
}
pub fn parse_key(text: &str) -> Option<Key> {
let t = text.trim().to_lowercase();
let mut chars = t.chars().peekable();
let letter = chars.next()?;
let base = match letter {
'c' => 0,
'd' => 2,
'e' => 4,
'f' => 5,
'g' => 7,
'a' => 9,
'b' => 11,
_ => return None,
};
let mut pc = base;
let mut rest: String = chars.collect();
if rest.starts_with('#') || rest.starts_with("sharp") {
pc += 1;
rest = rest.trim_start_matches('#').trim_start_matches("sharp").to_string();
} else if rest.starts_with('b') && !rest.starts_with("blues") || rest.starts_with("flat") {
pc += 11;
rest = rest.trim_start_matches("flat").trim_start_matches('b').to_string();
}
let rest = rest.trim();
let minor = rest.starts_with("min") || rest == "m" || rest.starts_with("aeolian") || rest.starts_with("dorian") || rest.starts_with("phrygian");
Some(Key::new((pc % 12) as u8, minor))
}
// ── key detection (Krumhansl-Schmuckler) ────────────────────────────────────
const PROF_MAJOR: [f64; 12] = [6.35, 2.23, 3.48, 2.33, 4.38, 4.09, 2.52, 5.19, 2.39, 3.66, 2.29, 2.88];
const PROF_MINOR: [f64; 12] = [6.33, 2.68, 3.52, 5.38, 2.60, 3.53, 2.54, 4.75, 3.98, 2.69, 3.34, 3.17];
fn corr(a: &[f64; 12], b: &[f64; 12]) -> f64 {
let ma = a.iter().sum::<f64>() / 12.0;
let mb = b.iter().sum::<f64>() / 12.0;
let (mut num, mut da, mut db) = (0.0, 0.0, 0.0);
for i in 0..12 {
num += (a[i] - ma) * (b[i] - mb);
da += (a[i] - ma).powi(2);
db += (b[i] - mb).powi(2);
}
if da <= 1e-12 || db <= 1e-12 {
0.0
} else {
num / (da * db).sqrt()
}
}
/// duration-and-velocity weighted pitch-class histogram of the notes sounding in [lo, hi)
pub fn pc_hist(notes: &[N], lo: f64, hi: f64) -> [f64; 12] {
let mut h = [0.0; 12];
for n in notes {
let a = n.start.max(lo);
let b = n.end().min(hi);
if b > a {
// the bass names the tonic: low notes count extra, which also settles relative major / minor
h[(n.key % 12) as usize] += (b - a).min(4.0) * (0.4 + n.vel as f64) * if n.key < 52 { 1.8 } else { 1.0 };
}
}
h
}
fn key_scores(h: &[f64; 12]) -> [f64; 24] {
let mut out = [0.0; 24];
for t in 0..12 {
let mut rot = [0.0; 12];
for i in 0..12 {
rot[(i + t) % 12] = PROF_MAJOR[i];
}
out[t] = corr(h, &rot);
let mut rot = [0.0; 12];
for i in 0..12 {
rot[(i + t) % 12] = PROF_MINOR[i];
}
out[12 + t] = corr(h, &rot);
}
out
}
fn key_of_index(i: usize) -> Key {
Key::new((i % 12) as u8, i >= 12)
}
/// best key for the notes in [lo, hi): (key, correlation, margin over the runner-up that is not its relative)
pub fn detect_key(notes: &[N], lo: f64, hi: f64) -> (Key, f64, f64) {
let s = key_scores(&pc_hist(notes, lo, hi));
let mut best = 0;
for i in 1..24 {
if s[i] > s[best] {
best = i;
}
}
let rel = if best < 12 { 12 + (best + 9) % 12 } else { (best - 12 + 3) % 12 };
let second = (0..24).filter(|i| *i != best && *i != rel).map(|i| s[i]).fold(-1.0, f64::max);
(key_of_index(best), s[best], s[best] - second)
}
/// best key for a pitch-class profile (notes or audio chroma): (key, correlation, margin over the runner-up that is not its relative)
pub fn detect_key_hist(h: &[f64; 12]) -> (Key, f64, f64) {
let s = key_scores(h);
let mut best = 0;
for i in 1..24 {
if s[i] > s[best] {
best = i;
}
}
let rel = if best < 12 { 12 + (best + 9) % 12 } else { (best - 12 + 3) % 12 };
let second = (0..24).filter(|i| *i != best && *i != rel).map(|i| s[i]).fold(-1.0, f64::max);
(key_of_index(best), s[best], s[best] - second)
}
/// the pitch classes a key allows: its scale, plus the leading tone of a minor key (harmonic minor)
pub fn allowed_pcs(k: Key) -> [bool; 12] {
let mut a = [false; 12];
for pc in k.scale() {
a[pc as usize] = true;
}
if k.minor {
a[((k.tonic + 11) % 12) as usize] = true;
}
a
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct KeySection {
pub start: f64,
pub end: f64,
pub key: Key,
pub score: f64,
}
/// the key over time: a two-bar window per bar, then the cheapest path through the 24 keys where changing key costs something,
/// so a modulation is reported but a stray accidental is not
pub fn key_sections(notes: &[N], total: f64, bar: f64) -> Vec<KeySection> {
let total = if total.is_finite() { total.clamp(bar, 16384.0) } else { bar };
let nbars = (total / bar).ceil() as usize;
if notes.is_empty() {
return vec![];
}
let mut sc = vec![[0.0f64; 24]; nbars];
let mut empty = vec![false; nbars];
for b in 0..nbars {
let lo = (b as f64 - 1.0).max(0.0) * bar;
let hi = (b as f64 + 2.0) * bar;
let h = pc_hist(notes, lo, hi);
empty[b] = h.iter().sum::<f64>() < 0.5;
sc[b] = key_scores(&h);
}
sections_from_scores(sc, empty, bar, total)
}
/// One instrument's say in the key: its notes (relative to the region) and how much its vote counts.
pub struct TrackVote {
pub notes: Vec<N>,
pub weight: f64,
}
/// How much a part should count when the song's structure is decided, and what it is: a loud single-note line (a melody) leads, a
/// bass and chords support it, an arpeggio counts least. Loudness scales it.
pub fn prominence(notes: &[N]) -> (&'static str, f64) {
if notes.is_empty() {
return ("empty", 0.0);
}
let role = classify(notes, false);
let avg_vel = notes.iter().map(|n| n.vel as f64).sum::<f64>() / notes.len() as f64;
let loud = 0.7 + 0.6 * avg_vel.clamp(0.0, 1.0);
let base = match role {
"lead" => 1.7,
"bass" => 1.1,
"chords" | "pad" => 1.0,
"arp" => 0.8,
_ => 1.0,
};
(role, base * loud)
}
/// The key over time by majority vote: every instrument votes for the key of its own notes in each window, weighted by its
/// prominence, so a pad cannot out-shout a melody and one wandering part cannot drag the song to another key.
pub fn key_sections_vote(tracks: &[TrackVote], total: f64, bar: f64) -> Vec<KeySection> {
let total = if total.is_finite() { total.clamp(bar, 16384.0) } else { bar };
let nbars = (total / bar).ceil() as usize;
if tracks.iter().all(|t| t.notes.is_empty()) {
return vec![];
}
let mut sc = vec![[0.0f64; 24]; nbars];
let mut empty = vec![false; nbars];
for b in 0..nbars {
let lo = (b as f64 - 1.0).max(0.0) * bar;
let hi = (b as f64 + 2.0) * bar;
let mut votes = [0.0f64; 24];
let mut wsum = 0.0;
for t in tracks {
let h = pc_hist(&t.notes, lo, hi);
let mass: f64 = h.iter().sum();
if mass < 0.5 {
continue;
}
let scores = key_scores(&h);
let best = (0..24).max_by(|x, y| scores[*x].partial_cmp(&scores[*y]).unwrap()).unwrap();
let rel = if best < 12 { 12 + (best + 9) % 12 } else { (best - 12 + 3) % 12 };
// a part with only a few notes in the window says little
let w = t.weight * (mass / 4.0).min(1.0);
votes[best] += w * (0.55 + 0.45 * scores[best].max(0.0));
votes[rel] += w * 0.3;
wsum += w;
}
empty[b] = wsum <= 0.0;
if wsum > 0.0 {
for k in 0..24 {
sc[b][k] = votes[k] / wsum * 1.4;
}
}
}
sections_from_scores(sc, empty, bar, total)
}
/// The cheapest path through the 24 keys, one per bar window, where changing key costs something, merged into sections.
fn sections_from_scores(mut sc: Vec<[f64; 24]>, empty: Vec<bool>, bar: f64, total: f64) -> Vec<KeySection> {
let nbars = sc.len();
// the key of the whole piece is the default: a section leaves it only on real evidence
let mut sum = [0.0f64; 24];
for b in 0..nbars {
for k in 0..24 {
sum[k] += sc[b][k];
}
}
let global = (0..24).max_by(|a, b| sum[*a].partial_cmp(&sum[*b]).unwrap()).unwrap();
let global_rel = if global < 12 { 12 + (global + 9) % 12 } else { (global - 12 + 3) % 12 };
for b in 0..nbars {
if !empty[b] {
sc[b][global] += 0.12;
sc[b][global_rel] += 0.06;
}
}
let penalty = 1.0;
let mut cost = vec![[0.0f64; 24]; nbars];
let mut from = vec![[0usize; 24]; nbars];
for k in 0..24 {
cost[0][k] = -sc[0][k];
}
for b in 1..nbars {
let (mut m1, mut a1) = (f64::MAX, 0usize);
for k in 0..24 {
if cost[b - 1][k] < m1 {
m1 = cost[b - 1][k];
a1 = k;
}
}
for k in 0..24 {
let stay = cost[b - 1][k];
let jump = m1 + penalty;
let (c, f) = if stay <= jump { (stay, k) } else { (jump, a1) };
cost[b][k] = c - if empty[b] { 0.0 } else { sc[b][k] };
from[b][k] = f;
}
}
let mut k = (0..24).min_by(|a, b| cost[nbars - 1][*a].partial_cmp(&cost[nbars - 1][*b]).unwrap()).unwrap();
let mut path = vec![0usize; nbars];
for b in (0..nbars).rev() {
path[b] = k;
if b > 0 {
k = from[b][k];
}
}
let mut out: Vec<KeySection> = vec![];
for b in 0..nbars {
let key = key_of_index(path[b]);
match out.last_mut() {
Some(l) if l.key == key => {
l.end = (b + 1) as f64 * bar;
l.score = l.score.max(sc[b][path[b]]);
}
_ => out.push(KeySection { start: b as f64 * bar, end: (b + 1) as f64 * bar, key, score: sc[b][path[b]] }),
}
}
if let Some(l) = out.last_mut() {
l.end = l.end.max(total);
}
out
}
pub fn key_at(sections: &[KeySection], beat: f64) -> Option<Key> {
sections.iter().find(|s| beat >= s.start && beat < s.end).or(sections.last()).map(|s| s.key)
}
// ── chords ──────────────────────────────────────────────────────────────────
/// (suffix, intervals from the root, complexity penalty)
pub const QUAL: [(&str, &[u8], f64); 14] = [
("", &[0, 4, 7], 0.0),
("m", &[0, 3, 7], 0.0),
("dim", &[0, 3, 6], 0.05),
("aug", &[0, 4, 8], 0.08),
("sus2", &[0, 2, 7], 0.06),
("sus4", &[0, 5, 7], 0.06),
("7", &[0, 4, 7, 10], 0.07),
("maj7", &[0, 4, 7, 11], 0.07),
("m7", &[0, 3, 7, 10], 0.06),
("m7b5", &[0, 3, 6, 10], 0.09),
("dim7", &[0, 3, 6, 9], 0.10),
("6", &[0, 4, 7, 9], 0.12),
("add9", &[0, 2, 4, 7], 0.12),
("5", &[0, 7], 0.10),
];
pub fn qual_index(suffix: &str) -> Option<usize> {
QUAL.iter().position(|q| q.0 == suffix)
}
#[derive(Clone, Debug, PartialEq)]
pub struct Chord {
pub start: f64,
pub len: f64,
pub root: u8,
pub q: usize,
pub bass: u8,
/// the chord was inferred from a single melodic line, not played
pub implied: bool,
}
impl Chord {
pub fn pcs(&self) -> Vec<u8> {
QUAL[self.q].1.iter().map(|i| (self.root + i) % 12).collect()
}
pub fn name(&self, key: Key) -> String {
let mut s = key.pc_name(self.root).to_string() + QUAL[self.q].0;
if self.bass % 12 != self.root && self.pcs().contains(&(self.bass % 12)) {
s += &format!("/{}", key.pc_name(self.bass));
}
s
}
pub fn has(&self, pc: u8) -> bool {
let rel = (pc % 12 + 12 - self.root) % 12;
QUAL[self.q].1.contains(&rel)
}
pub fn end(&self) -> f64 {
self.start + self.len
}
}
fn best_chord(w: &[f64; 12], bass: Option<u8>) -> Option<(u8, usize, f64)> {
let tot: f64 = w.iter().sum();
if tot < 1e-6 {
return None;
}
let mut best: Option<(u8, usize, f64)> = None;
for root in 0..12u8 {
for (qi, (_, iv, pen)) in QUAL.iter().enumerate() {
let mut inw = 0.0;
let mut missing = 0.0;
for (j, i) in iv.iter().enumerate() {
let x = w[((root + i) % 12) as usize];
// the added tone of a four-note chord only counts when it is really played (a passing melody note is not a 7th)
if iv.len() == 4 && j == 3 && x < tot * 0.2 {
continue;
}
inw += x;
if x < tot * 0.02 {
missing += if *i == 7 && j == 2 { 0.05 } else { 0.13 };
}
}
let out = tot - inw;
let mut s = inw / tot - 0.75 * out / tot - pen - missing + 0.2 * w[root as usize] / tot;
if let Some(b) = bass {
if b % 12 == root {
s += 0.12;
} else if iv.contains(&((b % 12 + 12 - root) % 12)) {
s += 0.03;
}
}
if best.map_or(true, |x| s > x.2) {
best = Some((root, qi, s));
}
}
}
best.filter(|b| b.2 > 0.2)
}
/// chords over time: every `seg` beats take the pitch classes sounding (lowest note counts extra), match them to the closest
/// chord type, then join neighbours that are the same chord
pub fn detect_chords(notes: &[N], total: f64, seg: f64, implied: bool) -> Vec<Chord> {
let mut out: Vec<Chord> = vec![];
let total = if total.is_finite() { total.clamp(0.0, 16384.0) } else { 0.0 };
let nseg = (total / seg).ceil() as usize;
for s in 0..nseg {
let lo = s as f64 * seg;
let hi = lo + seg;
let w = pc_hist(notes, lo, hi);
let mut bass: Option<u8> = None;
for n in notes {
if n.end() > lo + seg * 0.25 && n.start < hi - seg * 0.1 && bass.map_or(true, |b| n.key < b) {
bass = Some(n.key);
}
}
let mut w2 = w;
if let Some(b) = bass {
w2[(b % 12) as usize] += w.iter().sum::<f64>() * 0.12;
}
if let Some((root, q, _)) = best_chord(&w2, bass) {
let c = Chord { start: lo, len: seg, root, q, bass: bass.unwrap_or(root), implied };
match out.last_mut() {
Some(l) if l.root == c.root && l.q == c.q && (l.end() - c.start).abs() < 1e-6 => l.len += seg,
_ => out.push(c),
}
}
}
out
}
pub fn chord_at(chords: &[Chord], beat: f64) -> Option<&Chord> {
chords.iter().find(|c| beat >= c.start - 1e-6 && beat < c.end() - 1e-6)
}
/// Roman numeral of a chord in a key: case = quality, accidental = distance from the scale ("bVII", "#iv°")
pub fn roman(c: &Chord, key: Key) -> String {
let rel = ((c.root + 12 - key.tonic) % 12) as usize;
let table_major = ["I", "bII", "II", "bIII", "III", "IV", "#IV", "V", "bVI", "VI", "bVII", "VII"];
let table_minor = ["I", "bII", "II", "III", "#III", "IV", "#IV", "V", "VI", "#VI", "VII", "#VII"];
let base = if key.minor { table_minor[rel] } else { table_major[rel] };
let small = matches!(QUAL[c.q].0, "m" | "dim" | "m7" | "m7b5" | "dim7");
let (acc, num): (&str, &str) = match base.strip_prefix('b') {
Some(r) => ("b", r),
None => match base.strip_prefix('#') {
Some(r) => ("#", r),
None => ("", base),
},
};
let num = if small { num.to_lowercase() } else { num.to_string() };
let suffix = match QUAL[c.q].0 {
"dim" => "°",
"aug" => "+",
"7" | "m7" => "7",
"maj7" => "maj7",
"m7b5" => "ø7",
"dim7" => "°7",
"sus2" => "sus2",
"sus4" => "sus4",
"6" => "6",
"add9" => "add9",
"5" => "5",
_ => "",
};
format!("{acc}{num}{suffix}")
}
pub fn chord_diatonic(c: &Chord, key: Key) -> bool {
c.pcs().iter().all(|p| key.accepts(*p))
}
// ── note roles ──────────────────────────────────────────────────────────────
pub const R_CHORD: u8 = 0;
pub const R_SCALE: u8 = 1;
pub const R_PASS: u8 = 2;
pub const R_TENSION: u8 = 3;
pub const R_OUT: u8 = 4;
fn nearest_in_key(key: Key, chord: Option<&Chord>, k: u8, prefer_dir: i32) -> u8 {
let mut best = k;
let mut cost = f64::MAX;
for d in -3i32..=3 {
let c = k as i32 + d;
if !(0..=127).contains(&c) {
continue;
}
let pc = (c % 12) as u8;
if !key.in_scale(pc) {
continue;
}
let mut x = d.abs() as f64;
if chord.map_or(false, |ch| ch.has(pc)) {
x -= 0.6;
}
if prefer_dir != 0 && d.signum() == prefer_dir {
x -= 0.2;
}
if x < cost {
cost = x;
best = c as u8;
}
}
best
}
/// (role, suggested replacement key or -1) for every note, in the order given
pub fn note_roles(notes: &[N], keys: &[KeySection], chords: &[Chord]) -> Vec<(u8, i32)> {
let mut order: Vec<usize> = (0..notes.len()).collect();
order.sort_by(|a, b| notes[*a].start.partial_cmp(¬es[*b].start).unwrap().then(notes[*a].key.cmp(¬es[*b].key)));
let mut rank = vec![0usize; notes.len()];
for (r, i) in order.iter().enumerate() {
rank[*i] = r;
}
let mut out = Vec::with_capacity(notes.len());
for (i, n) in notes.iter().enumerate() {
let Some(key) = key_at(keys, n.start) else {
out.push((R_SCALE, -1));
continue;
};
let chord = chord_at(chords, n.start + 0.001);
let pc = n.key % 12;
let strong = (n.start % 2.0).abs() < 0.01 || ((n.start % 1.0).abs() < 0.01 && n.len >= 1.0);
if chord.map_or(false, |c| c.has(pc)) {
out.push((R_CHORD, -1));
} else if key.accepts(pc) {
// an in-key note a half step above a chord tone, held on a strong beat, grinds against the chord
let clash = strong && n.len >= 0.5 && chord.map_or(false, |c| c.has((pc + 11) % 12) && !c.has((pc + 1) % 12));
// the note a half step below is a chord tone; it is only a better note when it is also in the key (a chord borrowed from another
// key would otherwise pull a diatonic melody note onto a chromatic one)
let down = n.key.saturating_sub(1);
if clash && key.accepts(down % 12) {
out.push((R_TENSION, down as i32));
} else {
out.push((R_SCALE, -1));
}
} else {
let r = rank[i];
let prev = (0..r).rev().map(|j| ¬es[order[j]]).find(|p| (p.start - n.start).abs() > 0.01);
let next = (r + 1..order.len()).map(|j| ¬es[order[j]]).find(|p| (p.start - n.start).abs() > 0.01);
let near = |o: Option<&N>| o.map_or(false, |p| (p.key as i32 - n.key as i32).abs() <= 2 && key.accepts(p.key % 12));
// an out-of-key note that steps by a half step onto a note of the key (a leading tone or an auxiliary) is an ornament
// (the neighbours that matter are the next and previous notes on a DIFFERENT pitch: a repeated note or a trill is one figure)
let next_diff = (r + 1..order.len()).map(|j| ¬es[order[j]]).find(|p| p.key != n.key && p.start - n.start > 0.01);
let prev_diff = (0..r).rev().map(|j| ¬es[order[j]]).find(|p| p.key != n.key && n.start - p.start > 0.01);
let resolves = |o: Option<&N>| o.map_or(false, |p| (p.key as i32 - n.key as i32).abs() == 1 && key.accepts(p.key % 12) && (p.start - n.start).abs() <= 2.5);
let ornament = n.len <= 0.76 && (resolves(next_diff) || resolves(prev_diff));
if (n.len <= 0.76 && near(prev) && near(next)) || ornament {
out.push((R_PASS, -1));
} else {
let dir = match (prev, next) {
(Some(p), Some(q)) if q.key > p.key => 1,
(Some(p), Some(q)) if q.key < p.key => -1,
_ => 0,
};
out.push((R_OUT, nearest_in_key(key, chord, n.key, dir) as i32));
}
}
}
out
}
// ── next chord ──────────────────────────────────────────────────────────────
const NEXT_MAJOR: [[f64; 7]; 7] = [
[0.10, 0.60, 0.35, 0.90, 0.90, 0.75, 0.10],
[0.20, 0.05, 0.10, 0.40, 1.00, 0.30, 0.30],
[0.10, 0.40, 0.05, 0.70, 0.30, 0.80, 0.05],
[0.70, 0.50, 0.10, 0.05, 0.90, 0.30, 0.10],
[1.00, 0.10, 0.20, 0.50, 0.05, 0.60, 0.10],
[0.30, 0.80, 0.30, 0.80, 0.70, 0.05, 0.05],
[1.00, 0.10, 0.40, 0.10, 0.10, 0.30, 0.05],
];
const NEXT_MINOR: [[f64; 7]; 7] = [
[0.10, 0.20, 0.60, 0.80, 0.70, 0.85, 0.85],
[0.30, 0.05, 0.10, 0.30, 1.00, 0.10, 0.20],
[0.40, 0.10, 0.05, 0.60, 0.40, 0.80, 0.60],
[0.70, 0.10, 0.30, 0.05, 0.90, 0.40, 0.60],
[1.00, 0.10, 0.20, 0.30, 0.05, 0.70, 0.10],
[0.50, 0.10, 0.50, 0.70, 0.60, 0.05, 0.90],
[0.90, 0.10, 0.80, 0.40, 0.40, 0.60, 0.05],
];
/// scale-degree index (0..6) of a diatonic chord root, else None
pub fn degree_of(root: u8, key: Key) -> Option<usize> {
key.scale().iter().position(|p| *p == root % 12)
}
/// chords that work after `prev` (None = at the start): (root, quality index, roman, score), best first
pub fn next_chords(key: Key, prev: Option<&Chord>, history: &[Chord]) -> Vec<(u8, usize, String, f64)> {
let sc = key.scale();
let mut cand: Vec<(u8, usize, f64)> = vec![];
let from = prev.and_then(|p| degree_of(p.root, key));
for d in 0..7 {
let root = sc[d];
// diatonic triad quality from the scale
let third = (sc[(d + 2) % 7] + 12 - root) % 12;
let fifth = (sc[(d + 4) % 7] + 12 - root) % 12;
let mut q = match (third, fifth) {
(4, 7) => 0,
(3, 7) => 1,
(3, 6) => 2,
(4, 8) => 3,
_ => 0,
};
// the dominant of a minor key is major (harmonic minor)
if key.minor && d == 4 {
q = 0;
}
let base = match from {
Some(f) => {
if key.minor {
NEXT_MINOR[f][d]
} else {
NEXT_MAJOR[f][d]
}
}
None => {
if d == 0 {
1.0
} else if d == 3 || d == 4 {
0.5
} else {
0.3
}
}
};
let mut s = base;
// not the same chord three times, and a small bonus for chords not used lately
let recent = history.iter().rev().take(4).filter(|c| c.root == root).count() as f64;
s -= 0.12 * recent;
cand.push((root, q, s));
}
// borrowed colour
if !key.minor {
for (rel, q, s) in [(10u8, 0usize, 0.45), (8, 0, 0.30), (5, 1, 0.38)] {
let after_sub = prev.map_or(false, |p| (p.root + 12 - key.tonic) % 12 == 5 || (p.root + 12 - key.tonic) % 12 == 0);
cand.push(((key.tonic + rel) % 12, q, if after_sub { s } else { s * 0.5 }));
}
}
let mut out: Vec<(u8, usize, String, f64)> = cand
.into_iter()
.map(|(root, q, s)| {
let c = Chord { start: 0.0, len: 1.0, root, q, bass: root, implied: false };
(root, q, roman(&c, key), s)
})
.collect();
out.sort_by(|a, b| b.3.partial_cmp(&a.3).unwrap());
out.truncate(8);
out
}
/// pitches for a chord voiced near `prev` (smooth voice leading), starting around middle C when there is nothing before it
pub fn voice(root: u8, q: usize, prev: &[u8], lo: u8, hi: u8) -> Vec<u8> {
let pcs: Vec<u8> = QUAL[q].1.iter().map(|i| (root + i) % 12).collect();
let mut out: Vec<u8> = vec![];
if prev.is_empty() {
let base = (lo as i32 + ((root as i32 - lo as i32).rem_euclid(12))) as u8;
let mut x = base;
for i in QUAL[q].1 {
x = base + i;
if x <= hi {
out.push(x);
}
}
let _ = x;
return out;
}
let centre = prev.iter().map(|p| *p as f64).sum::<f64>() / prev.len() as f64;
for pc in pcs {
let mut best = None;
let mut bd = f64::MAX;
for k in lo..=hi {
if k % 12 == pc {
let d = prev.iter().map(|p| (*p as f64 - k as f64).abs()).fold(f64::MAX, f64::min) + (k as f64 - centre).abs() * 0.15;
if d < bd {
bd = d;
best = Some(k);
}
}
}
if let Some(k) = best {
if !out.contains(&k) {
out.push(k);
}
}
}
out.sort();
out
}
// ── phrases ─────────────────────────────────────────────────────────────────
#[derive(Clone, Debug, PartialEq)]
pub struct Phrase {
pub start: f64,
pub end: f64,
pub kind: &'static str, // progression | motif | sequence | cadence | run | arpeggio
pub label: String,
}
/// the top line of a texture: the highest note at each onset
fn top_line(notes: &[N]) -> Vec<N> {
let mut v: Vec<N> = notes.to_vec();
v.sort_by(|a, b| a.start.partial_cmp(&b.start).unwrap().then(b.key.cmp(&a.key)));
let mut out: Vec<N> = vec![];
for n in v {
if out.last().map_or(true, |l| (l.start - n.start).abs() > 0.02) {
out.push(n);
}
}
out
}
fn rhythm_class(len: f64) -> i32 {
// 0 thirty-second .. 5 whole-ish
if len < 0.2 {
0
} else if len < 0.4 {
1
} else if len < 0.8 {
2
} else if len < 1.6 {
3
} else if len < 3.2 {
4
} else {
5
}
}
pub fn phrases(notes: &[N], chords: &[Chord], keys: &[KeySection]) -> Vec<Phrase> {
let mut out: Vec<Phrase> = vec![];
// chord progression loops
let key0 = keys.first().map(|k| k.key).unwrap_or(Key::new(0, false));
let rom: Vec<String> = chords.iter().map(|c| roman(c, key_at(keys, c.start).unwrap_or(key0))).collect();
let mut taken = vec![false; chords.len()];
for plen in (2..=8usize).rev() {
let mut i = 0;
while i + plen * 2 <= chords.len() {
if taken[i..i + plen].iter().any(|t| *t) {
i += 1;
continue;
}
let pat = &rom[i..i + plen];
let mut reps = 1;
while i + (reps + 1) * plen <= chords.len() && &rom[i + reps * plen..i + (reps + 1) * plen] == pat {
reps += 1;
}
let distinct = pat.iter().collect::<std::collections::HashSet<_>>().len();
if reps >= 2 && distinct >= 2 {
let end = chords[i + reps * plen - 1].end();
out.push(Phrase { start: chords[i].start, end, kind: "progression", label: format!("{} x{}", pat.join(" "), reps) });
for t in taken[i..i + reps * plen].iter_mut() {
*t = true;
}
i += reps * plen;
} else {
i += 1;
}
}
}
// cadences
for i in 1..chords.len() {
let k = key_at(keys, chords[i].start).unwrap_or(key0);
let (a, b) = (degree_of(chords[i - 1].root, k), degree_of(chords[i].root, k));
let rel_b = (chords[i].root + 12 - k.tonic) % 12;
let rel_a = (chords[i - 1].root + 12 - k.tonic) % 12;
let name = match (rel_a, rel_b) {
(7, 0) => Some("authentic cadence V-I"),
(5, 0) => Some("plagal cadence IV-I"),
(7, 9) | (7, 8) => Some("deceptive cadence V-vi"),
(10, 0) if !k.minor => Some("backdoor cadence bVII-I"),
(10, 0) | (2, 0) if k.minor && a.is_some() && b.is_some() => Some("modal cadence VII-i"),
_ => None,
};
if let Some(n) = name {
out.push(Phrase { start: chords[i - 1].start, end: chords[i].end(), kind: "cadence", label: n.to_string() });
}
}
// melodic motifs: repeated interval + rhythm patterns in the top line
let tl = top_line(notes);
let sig = |i: usize, n: usize| -> Vec<(i32, i32)> { (0..n).map(|j| (tl[i + j + 1].key as i32 - tl[i + j].key as i32, rhythm_class(tl[i + j].len))).collect() };
let mut used = vec![false; tl.len()];
let mut letter = 0u8;
for n in (4..=8usize).rev() {
if tl.len() < n + 3 {
continue;
}
let mut i = 0;
while i + n < tl.len() {
if used[i..=i + n].iter().any(|u| *u) {
i += 1;
continue;
}
let s = sig(i, n);
if s.iter().all(|x| x.0 == 0) {
i += 1;
continue;
}
let mut hits = vec![i];
let mut j = i + n;
while j + n < tl.len() {
if !used[j..=j + n].iter().any(|u| *u) && sig(j, n) == s {
hits.push(j);
j += n;
} else {
j += 1;
}
}
if hits.len() >= 2 {
let name = (b'A' + letter % 26) as char;
letter += 1;
let firsts: Vec<i32> = hits.iter().map(|h| tl[*h].key as i32).collect();
let step = firsts[1] - firsts[0];
let seq = hits.len() >= 3 && step != 0 && firsts.windows(2).all(|w| w[1] - w[0] == step);
for h in &hits {
for u in used[*h..=*h + n].iter_mut() {
*u = true;
}
let label = if seq {
format!("motif {name} ({} sequence)", if step > 0 { "rising" } else { "falling" })
} else if tl[*h].key as i32 != firsts[0] {
format!("motif {name} (transposed)")
} else {
format!("motif {name}")
};
out.push(Phrase { start: tl[*h].start, end: tl[*h + n].end(), kind: if seq { "sequence" } else { "motif" }, label });
}
i = hits[0] + 1;
} else {
i += 1;
}
}
}
// scale runs and arpeggios
let mut i = 0;
while i + 3 < tl.len() {
let d = tl[i + 1].key as i32 - tl[i].key as i32;
if d != 0 && d.abs() <= 2 {
let mut j = i + 1;
while j + 1 < tl.len() && (tl[j + 1].key as i32 - tl[j].key as i32).signum() == d.signum() && (tl[j + 1].key as i32 - tl[j].key as i32).abs() <= 2 && tl[j + 1].start - tl[j].start < 1.01 {
j += 1;
}
if j - i >= 3 {
out.push(Phrase { start: tl[i].start, end: tl[j].end(), kind: "run", label: format!("scale run {}", if d > 0 { "up" } else { "down" }) });
i = j;
continue;
}
}
i += 1;
}
let mut i = 0;
while i + 3 < tl.len() {
let mut j = i;
let c = chord_at(chords, tl[i].start);
while j + 1 < tl.len() && (tl[j + 1].key as i32 - tl[j].key as i32).abs() >= 3 && (tl[j + 1].key as i32 - tl[j].key as i32).abs() <= 9 && c.map_or(false, |c| c.has(tl[j + 1].key % 12)) && tl[j + 1].start - tl[j].start < 1.01 {
j += 1;
}
if j - i >= 3 {
out.push(Phrase { start: tl[i].start, end: tl[j].end(), kind: "arpeggio", label: "arpeggio".into() });
i = j;
} else {
i += 1;
}
}
out.sort_by(|a, b| a.start.partial_cmp(&b.start).unwrap());
out
}
// ── auto-correct ────────────────────────────────────────────────────────────
/// What the fixer knows beyond the notes themselves: how strict to be (0 = chaotic like jazz or breakcore, 1 = strict and predictable),
/// which notes the other instruments also play at that moment, and which notes are part of a figure that keeps coming back.
pub struct FixCtx<'a> {
pub order: f64,
pub support: &'a [bool],
pub repeated: &'a [bool],
/// per note: it grinds (a half step or a major seventh) against something another instrument is playing at that moment
pub clash: &'a [bool],
/// what the other instruments play, ordered by start, in the same time as the notes; empty = nothing is known about them
pub others: &'a [N],
}
/// Key sections shorter than `min_len` beats are detection noise more often than modulations (a window of a few bars at the start
/// that leans toward the dominant key, say): they take the key of their neighbour, and equal neighbours merge.
pub fn merge_short_keys(keys: Vec<KeySection>, min_len: f64) -> Vec<KeySection> {
if keys.len() < 2 {
return keys;
}
let mut v = keys;
loop {
let short = v.iter().position(|k| k.end - k.start < min_len);
let Some(i) = short else { break };
if v.len() < 2 {
break;
}
// into the longer neighbour
let into = match (i.checked_sub(1), v.get(i + 1)) {
(Some(p), Some(nx)) => {
if v[p].end - v[p].start >= nx.end - nx.start {
p
} else {
i + 1
}
}
(Some(p), None) => p,
(None, Some(_)) => i + 1,
_ => break,
};
let (start, end) = (v[i].start.min(v[into].start), v[i].end.max(v[into].end));
v[into].start = start;
v[into].end = end;
v.remove(i);
// equal neighbours merge
let mut k = 0;
while k + 1 < v.len() {
if v[k].key == v[k + 1].key {
v[k].end = v[k + 1].end;
v.remove(k + 1);
} else {
k += 1;
}
}
}
v
}
/// Is the pitch class of notes[i] a member of a major or minor triad that the notes around it (within two beats, same instrument) spell?
/// An arpeggio D - F# - A makes F# a chord tone even when no other instrument plays a D chord.
pub fn in_local_triad(notes: &[N], i: usize) -> bool {
let n = ¬es[i];
let mut pcs = [false; 12];
for o in notes.iter() {
if (o.start - n.start).abs() <= 2.0 {
pcs[(o.key % 12) as usize] = true;
}
}
let pc = (n.key % 12) as usize;
for r in 0..12usize {
if !pcs[r] || !pcs[(r + 7) % 12] {
continue;
}
for third in [3usize, 4] {
if pcs[(r + third) % 12] && [r, (r + third) % 12, (r + 7) % 12].contains(&pc) {
return true;
}
}
}
false
}
/// does a note of pitch `key` over [start, start + len) grind (minor second / major seventh) against any of the other instruments' notes?
pub fn clashes_with_others(others: &[N], start: f64, len: f64, key: u8) -> bool {
// a trill, a grace note or a fast run is an ornament, not a held clash
if len < 0.5 {
return false;
}
let lo = others.partition_point(|o| o.start < start - 8.0);
for o in others[lo..].iter().take_while(|o| o.start < start + len) {
if o.len < 0.2 {
continue;
}
let overlap = (o.end().min(start + len)) - o.start.max(start);
if overlap < 0.25 {
continue;
}
// a half step (or a major seventh / minor ninth) only grinds when the two notes are close: spread over more than an
// octave and a bit it is just a wide chord voicing
let apart = (o.key as i32 - key as i32).abs();
let d = apart.rem_euclid(12);
if (d == 1 || d == 11) && apart <= 14 {
return true;
}
}
false
}
pub const FIX_KEY: u32 = 1;
pub const FIX_TENSION: u32 = 2;
pub const FIX_TIMING: u32 = 4;
pub const FIX_DUPES: u32 = 8;
pub const FIX_PASSING: u32 = 16;
#[derive(Default, Debug, Clone, PartialEq)]
pub struct FixReport {
pub key: usize,
pub tension: usize,
pub timing: usize,
pub dupes: usize,
pub passing: usize,
}
impl FixReport {
pub fn total(&self) -> usize {
self.key + self.tension + self.timing + self.dupes + self.passing
}
}
/// the grid a score is on: the coarsest of 1/4, 1/8, 1/16 (and triplets) that most onsets sit within a tolerance of
pub fn detect_grid(starts: &[f64]) -> f64 {
if starts.is_empty() {
return 0.25;
}
for g in [1.0, 0.5, 0.25, 1.0 / 3.0, 1.0 / 6.0, 0.125] {
let ok = starts.iter().filter(|s| {
let r = (*s / g).round() * g;
(*s - r).abs() < g * 0.18 + 0.01
});
if ok.count() as f64 >= starts.len() as f64 * 0.8 {
return g;
}
}
0.25
}
/// Line a clip's notes up with the key and the chords. `notes` are the clip's own (relative-start) notes, `abs_offset` is the
/// clip's start so the harmony (absolute) can be looked up. Returns the corrected notes (same order) and what changed.
pub fn autocorrect(notes: &[N], abs_offset: f64, keys: &[KeySection], chords: &[Chord], mask: u32) -> (Vec<N>, Vec<bool>, FixReport) {
autocorrect_ctx(notes, abs_offset, keys, chords, mask, &FixCtx { order: 0.75, support: &[], repeated: &[], clash: &[], others: &[] })
}
/// `autocorrect` that weighs order against chaos: a wrong-looking note is only fixed when its dissonance, after what the rest of the
/// arrangement and the clip's own repetition say in its defence, is above a tolerance that loosens as `order` falls.
pub fn autocorrect_ctx(notes: &[N], abs_offset: f64, keys: &[KeySection], chords: &[Chord], mask: u32, ctx: &FixCtx) -> (Vec<N>, Vec<bool>, FixReport) {
let order = ctx.order.clamp(0.0, 1.0);
let abs: Vec<N> = notes.iter().map(|n| N { start: n.start + abs_offset, ..*n }).collect();
let roles = note_roles(&abs, keys, chords);
let mut out = notes.to_vec();
let mut changed = vec![false; notes.len()];
let mut rep = FixReport::default();
let tolerance = 0.15 + (1.0 - order) * 0.7;
for (i, (role, sug)) in roles.iter().enumerate() {
let wanted = match *role {
R_OUT => mask & FIX_KEY != 0,
R_TENSION => mask & FIX_TENSION != 0,
R_PASS => mask & FIX_PASSING != 0,
_ => false,
};
let mut d: f64 = match *role {
R_OUT => 1.0,
R_TENSION => 0.7,
R_PASS => 0.45,
_ => 0.0,
};
if d > 0.0 {
if ctx.support.get(i) == Some(&true) {
d -= 0.45; // another instrument is playing the same pitch right then: it is harmony, not a mistake
}
if ctx.repeated.get(i) == Some(&true) {
d -= 0.3; // it comes back in the same place again and again: it is the figure
}
if abs[i].len < 0.3 {
d -= 0.1;
}
if abs[i].len >= 1.0 && (abs[i].start % 2.0).abs() < 0.01 {
d += 0.1;
}
}
// Evidence from the accompaniment: unless the order is strict, a note is only called wrong when it actually grinds against what
// the other instruments play. A chromatic note that agrees with the harmony around it (a leading tone, a borrowed chord) is music.
let know_others = !ctx.others.is_empty();
let strict = order > 0.85;
let grinds = ctx.clash.get(i) == Some(&true);
// a diatonic note rubbing a chord tone is a suspension or a seventh, ordinary harmony: only Strict touches those
// a long note outside the key that nothing around it explains (no triad spelled by its neighbours) is a mistake even when no
// other instrument grinds against it; a short one is an ornament
let exposed = abs[i].len >= 0.75 && !in_local_triad(&abs, i);
let evidence = if *role == R_TENSION { strict || !know_others } else { !know_others || strict || ((grinds || exposed) && !in_local_triad(&abs, i)) || *role == R_PASS };
let fix = wanted && d > tolerance && evidence;
if fix {
let key = key_at(keys, abs[i].start).unwrap_or(Key::new(0, false));
let mut target = if *role == R_PASS {
// chromatic passing notes only when asked: pull them to the nearest scale tone
nearest_in_key(key, chord_at(chords, abs[i].start + 0.001), abs[i].key, 0) as i32
} else {
*sug
};
// a fix must not make a new clash: look for the nearest in-key pitch that is clean, or leave the note alone
if know_others && target >= 0 && clashes_with_others(ctx.others, abs[i].start, abs[i].len, target as u8) {
let mut found = -1;
for dlt in [1i32, -1, 2, -2] {
let cand = *sug + dlt;
if (0..=127).contains(&cand) && key.accepts((cand % 12) as u8) && !clashes_with_others(ctx.others, abs[i].start, abs[i].len, cand as u8) {
found = cand;
break;
}
}
target = found;
}
if target >= 0 && target != out[i].key as i32 {
out[i].key = target as u8;
changed[i] = true;
match *role {
R_OUT => rep.key += 1,
R_TENSION => rep.tension += 1,
_ => rep.passing += 1,
}
}
}
}
if mask & FIX_TIMING != 0 {
let starts: Vec<f64> = notes.iter().map(|n| n.start).collect();
let g = detect_grid(&starts);
for (i, n) in out.iter_mut().enumerate() {
let near = (n.start / g).round() * g;
let mut moved = false;
// only notes that are a little off the grid move; a deliberate offset is left alone
if (near - n.start).abs() > 1e-4 && (near - n.start).abs() <= g * 0.35 * (0.3 + 0.7 * order) {
n.start = near;
moved = true;
}
// an end that nearly meets a grid line is pulled onto it; articulation (a short or detached note) stays
let end = n.start + n.len;
let e = (end / g).round() * g;
if (e - end).abs() > 1e-4 && (e - end).abs() <= 0.02 && e - n.start >= 0.03 {
n.len = e - n.start;
moved = true;
}
if moved {
rep.timing += 1;
changed[i] = true;
}
}
}
if mask & FIX_DUPES != 0 {
// two notes on the same pitch starting together: keep the longer; same pitch overlapping: shorten the earlier one
let mut idx: Vec<usize> = (0..out.len()).collect();
idx.sort_by(|a, b| out[*a].key.cmp(&out[*b].key).then(out[*a].start.partial_cmp(&out[*b].start).unwrap()));
for w in idx.windows(2) {
let (a, b) = (w[0], w[1]);
if out[a].key == out[b].key && out[a].len > 0.0 && out[b].len > 0.0 {
if (out[a].start - out[b].start).abs() < 0.01 {
let (keep, drop) = if out[a].len >= out[b].len { (a, b) } else { (b, a) };
let _ = keep;
out[drop].len = 0.0;
changed[drop] = true;
rep.dupes += 1;
} else if out[a].start + out[a].len > out[b].start + 0.001 {
out[a].len = (out[b].start - out[a].start).max(0.03);
changed[a] = true;
rep.dupes += 1;
}
}
}
}
(out, changed, rep)
}
/// retune notes from one key to another keeping scale degrees, so a melody written in A minor becomes the same melody in
/// D minor (or in D major: same degrees, major scale); notes outside the old scale move by the plain interval
pub fn retune(notes: &[N], from: Key, to: Key) -> Vec<N> {
let fs = if from.minor { MINOR } else { MAJOR };
let ts = if to.minor { MINOR } else { MAJOR };
notes
.iter()
.map(|n| {
let rel = (n.key as i32 - from.tonic as i32).rem_euclid(12);
let oct = (n.key as i32 - from.tonic as i32 - rel) / 12;
let new_rel = match fs.iter().position(|d| *d as i32 == rel) {
Some(deg) => ts[deg] as i32,
None => rel,
};
let mut k = to.tonic as i32 + new_rel + oct * 12;
// keep the register: move by whole octaves toward the old pitch
while k - n.key as i32 > 6 {
k -= 12;
}
while n.key as i32 - k > 6 {
k += 12;
}
N { key: k.clamp(0, 127) as u8, ..*n }
})
.collect()
}
/// a rough role for a set of notes, used by the style thumbprint: drums | bass | chords | arp | lead | pad
pub fn classify(notes: &[N], drums: bool) -> &'static str {
if drums {
return "drums";
}
if notes.is_empty() {
return "empty";
}
let mut keys: Vec<u8> = notes.iter().map(|n| n.key).collect();
keys.sort();
let med = keys[keys.len() / 2];
let avg_len = notes.iter().map(|n| n.len).sum::<f64>() / notes.len() as f64;
// polyphony: notes sharing an onset
let mut onsets: Vec<f64> = notes.iter().map(|n| (n.start * 8.0).round() / 8.0).collect();
onsets.sort_by(|a, b| a.partial_cmp(b).unwrap());
let mut distinct = onsets.clone();
distinct.dedup();
let poly = notes.len() as f64 / distinct.len().max(1) as f64;
if med < 50 && poly < 1.6 {
"bass"
} else if poly >= 2.0 && avg_len >= 1.5 {
"pad"
} else if poly >= 2.0 {
"chords"
} else if avg_len < 0.4 && med >= 52 {
"arp"
} else if med < 50 {
"bass"
} else {
"lead"
}
}
#[cfg(test)]
mod tests {
use super::*;
fn n(start: f64, len: f64, key: u8) -> N {
N { start, len, key, vel: 0.8 }
}
/// C major scale up and down with a C major / G / Am / F progression underneath
fn song() -> Vec<N> {
let mut v = vec![];
for (bar, chord) in [[48u8, 52, 55], [43, 47, 50], [45, 48, 52], [41, 45, 48]].iter().enumerate() {
for k in chord {
v.push(n(bar as f64 * 4.0, 4.0, *k));
}
}
for (i, k) in [72u8, 74, 76, 77, 79, 77, 76, 74, 72, 71, 72, 74, 76, 72, 69, 72].iter().enumerate() {
v.push(n(i as f64, 0.9, *k));
}
v
}
#[test]
fn detects_c_major_and_names_chords() {
let notes = song();
let (k, score, _) = detect_key(¬es, 0.0, 16.0);
assert_eq!(k, Key::new(0, false), "{score}");
let keys = key_sections(¬es, 16.0, 4.0);
assert_eq!(keys.len(), 1, "{keys:?}");
let chords = detect_chords(¬es, 16.0, 4.0, false);
let names: Vec<String> = chords.iter().map(|c| c.name(k)).collect();
assert_eq!(names, ["C", "G", "Am", "F"], "{names:?}");
let rom: Vec<String> = chords.iter().map(|c| roman(c, k)).collect();
assert_eq!(rom, ["I", "V", "vi", "IV"]);
}
#[test]
fn finds_the_modulation() {
let mut v = vec![];
for i in 0..16 {
v.push(n(i as f64, 0.9, [60u8, 64, 67, 64, 60, 62, 64, 60][i % 8]));
}
// then up a whole step to D major: F# and C# appear
for i in 0..16 {
v.push(n(16.0 + i as f64, 0.9, [62u8, 66, 69, 66, 62, 64, 66, 62][i % 8]));
}
let keys = key_sections(&v, 32.0, 4.0);
assert!(keys.len() >= 2, "{keys:?}");
assert_eq!(keys[0].key, Key::new(0, false));
assert_eq!(keys.last().unwrap().key, Key::new(2, false));
}
#[test]
fn marks_the_out_of_key_note_and_suggests_the_fix() {
let mut notes = song();
// a C# held for a beat and a half on a strong beat in the melody over C major
notes.push(n(2.0, 1.5, 73));
let keys = key_sections(¬es, 16.0, 4.0);
let chords = detect_chords(¬es, 16.0, 4.0, false);
let roles = note_roles(¬es, &keys, &chords);
let last = roles.len() - 1;
assert_eq!(roles[last].0, R_OUT, "{roles:?}");
assert!(roles[last].1 == 72 || roles[last].1 == 74, "{:?}", roles[last]);
// the real melody stays clean
assert!(roles[..last].iter().all(|r| r.0 != R_OUT), "{roles:?}");
}
#[test]
fn a_short_chromatic_note_between_scale_notes_is_a_passing_note() {
let mut notes = song();
notes.push(n(0.5, 0.25, 73)); // C# between C5 and D5? (72 at 0, 74 at 1)
let keys = key_sections(¬es, 16.0, 4.0);
let chords = detect_chords(¬es, 16.0, 4.0, false);
let roles = note_roles(¬es, &keys, &chords);
assert_eq!(roles.last().unwrap().0, R_PASS, "{roles:?}");
}
#[test]
fn autocorrect_pulls_stray_notes_into_the_key_and_tidies_timing() {
let mut notes = song();
notes.push(n(2.0, 1.5, 73));
notes.push(n(5.03, 0.9, 74)); // slightly off the grid
let keys = key_sections(¬es, 16.0, 4.0);
let chords = detect_chords(¬es, 16.0, 4.0, false);
let (fixed, changed, rep) = autocorrect(¬es, 0.0, &keys, &chords, FIX_KEY | FIX_TIMING);
assert!(rep.key >= 1 && rep.timing >= 1, "{rep:?}");
assert!(fixed.iter().all(|x| Key::new(0, false).in_scale(x.key % 12)), "all notes now in key");
assert!(changed.iter().any(|c| *c));
assert!((fixed.last().unwrap().start - 5.0).abs() < 1e-9);
}
#[test]
fn suggests_the_dominant_after_the_subdominant() {
let k = Key::new(0, false);
let prev = Chord { start: 0.0, len: 4.0, root: 5, q: 0, bass: 5, implied: false };
let next = next_chords(k, Some(&prev), &[]);
assert_eq!(next[0].2, "V", "{next:?}");
assert!(next.iter().any(|c| c.2 == "I"));
let start = next_chords(Key::new(9, true), None, &[]);
assert_eq!(start[0].2, "i");
}
#[test]
fn finds_a_repeated_progression_and_a_cadence() {
let k = Key::new(0, false);
let mut chords = vec![];
for (i, r) in [9u8, 5, 0, 7, 9, 5, 0, 7].iter().enumerate() {
chords.push(Chord { start: i as f64 * 4.0, len: 4.0, root: *r, q: if *r == 9 { 1 } else { 0 }, bass: *r, implied: false });
}
let keys = vec![KeySection { start: 0.0, end: 32.0, key: k, score: 1.0 }];
let ph = phrases(&[], &chords, &keys);
assert!(ph.iter().any(|p| p.kind == "progression" && p.label.starts_with("vi IV I V x2")), "{ph:?}");
assert!(ph.iter().any(|p| p.kind == "cadence"), "{ph:?}");
}
#[test]
fn finds_a_repeated_motif_and_a_run() {
let mut v = vec![];
for rep in 0..3 {
for (i, k) in [60u8, 64, 62, 67, 65].iter().enumerate() {
v.push(n(rep as f64 * 8.0 + i as f64 * 0.5, 0.4, *k + rep as u8 * 0));
}
}
for (i, k) in [60u8, 62, 64, 65, 67].iter().enumerate() {
v.push(n(30.0 + i as f64 * 0.5, 0.4, *k));
}
let ph = phrases(&v, &[], &[KeySection { start: 0.0, end: 40.0, key: Key::new(0, false), score: 1.0 }]);
assert!(ph.iter().filter(|p| p.label.starts_with("motif")).count() >= 3, "{ph:?}");
assert!(ph.iter().any(|p| p.kind == "run"), "{ph:?}");
}
#[test]
fn retune_keeps_scale_degrees() {
let a_minor = [n(0.0, 1.0, 69), n(1.0, 1.0, 72), n(2.0, 1.0, 76)];
let d_minor = retune(&a_minor, Key::new(9, true), Key::new(2, true));
assert_eq!(d_minor.iter().map(|x| x.key).collect::<Vec<_>>(), [74, 77, 81]);
let c_major = retune(&[n(0.0, 1.0, 69), n(1.0, 1.0, 72), n(2.0, 1.0, 76)], Key::new(9, true), Key::new(0, false));
assert_eq!(c_major.iter().map(|x| x.key).collect::<Vec<_>>(), [72, 76, 79]);
}
#[test]
fn key_names_parse() {
assert_eq!(parse_key("F# minor"), Some(Key::new(6, true)));
assert_eq!(parse_key("Bb major"), Some(Key::new(10, false)));
assert_eq!(parse_key("a minor").unwrap().name(), "A minor");
assert_eq!(Key::new(10, false).name(), "Bb major");
}
}