From 84520334733eec41aa13c23a42c4318c478bd530 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Forment?= Date: Sun, 1 Feb 2026 16:15:09 +0100 Subject: [PATCH] Feat: adding some basic music theory --- crates/forth/src/ops.rs | 1 + crates/forth/src/theory/chords.rs | 129 +++++++++++++ crates/forth/src/theory/mod.rs | 1 + crates/forth/src/vm.rs | 13 +- crates/forth/src/words.rs | 291 ++++++++++++++++++++++++++++++ tests/forth.rs | 3 + tests/forth/chords.rs | 178 ++++++++++++++++++ tests/forth/randomness.rs | 5 + 8 files changed, 620 insertions(+), 1 deletion(-) create mode 100644 crates/forth/src/theory/chords.rs create mode 100644 tests/forth/chords.rs diff --git a/crates/forth/src/ops.rs b/crates/forth/src/ops.rs index 109f657..eb5fada 100644 --- a/crates/forth/src/ops.rs +++ b/crates/forth/src/ops.rs @@ -88,6 +88,7 @@ pub enum Op { Generate, GeomRange, Times, + Chord(&'static [i64]), // MIDI MidiEmit, GetMidiCC, diff --git a/crates/forth/src/theory/chords.rs b/crates/forth/src/theory/chords.rs new file mode 100644 index 0000000..9f595cc --- /dev/null +++ b/crates/forth/src/theory/chords.rs @@ -0,0 +1,129 @@ +pub struct Chord { + pub name: &'static str, + pub intervals: &'static [i64], +} + +pub static CHORDS: &[Chord] = &[ + // Triads + Chord { + name: "maj", + intervals: &[0, 4, 7], + }, + Chord { + name: "m", + intervals: &[0, 3, 7], + }, + Chord { + name: "dim", + intervals: &[0, 3, 6], + }, + Chord { + name: "aug", + intervals: &[0, 4, 8], + }, + Chord { + name: "sus2", + intervals: &[0, 2, 7], + }, + Chord { + name: "sus4", + intervals: &[0, 5, 7], + }, + // Seventh chords + Chord { + name: "maj7", + intervals: &[0, 4, 7, 11], + }, + Chord { + name: "min7", + intervals: &[0, 3, 7, 10], + }, + Chord { + name: "dom7", + intervals: &[0, 4, 7, 10], + }, + Chord { + name: "dim7", + intervals: &[0, 3, 6, 9], + }, + Chord { + name: "m7b5", + intervals: &[0, 3, 6, 10], + }, + Chord { + name: "minmaj7", + intervals: &[0, 3, 7, 11], + }, + Chord { + name: "aug7", + intervals: &[0, 4, 8, 10], + }, + // Sixth chords + Chord { + name: "maj6", + intervals: &[0, 4, 7, 9], + }, + Chord { + name: "min6", + intervals: &[0, 3, 7, 9], + }, + // Extended chords + Chord { + name: "dom9", + intervals: &[0, 4, 7, 10, 14], + }, + Chord { + name: "maj9", + intervals: &[0, 4, 7, 11, 14], + }, + Chord { + name: "min9", + intervals: &[0, 3, 7, 10, 14], + }, + Chord { + name: "dom11", + intervals: &[0, 4, 7, 10, 14, 17], + }, + Chord { + name: "min11", + intervals: &[0, 3, 7, 10, 14, 17], + }, + Chord { + name: "dom13", + intervals: &[0, 4, 7, 10, 14, 21], + }, + // Add chords + Chord { + name: "add9", + intervals: &[0, 4, 7, 14], + }, + Chord { + name: "add11", + intervals: &[0, 4, 7, 17], + }, + Chord { + name: "madd9", + intervals: &[0, 3, 7, 14], + }, + // Altered dominants + Chord { + name: "dom7b9", + intervals: &[0, 4, 7, 10, 13], + }, + Chord { + name: "dom7s9", + intervals: &[0, 4, 7, 10, 15], + }, + Chord { + name: "dom7b5", + intervals: &[0, 4, 6, 10], + }, + Chord { + name: "dom7s5", + intervals: &[0, 4, 8, 10], + }, +]; + +pub fn lookup(name: &str) -> Option<&'static [i64]> { + CHORDS.iter().find(|c| c.name == name).map(|c| c.intervals) +} diff --git a/crates/forth/src/theory/mod.rs b/crates/forth/src/theory/mod.rs index a6cfabb..901a041 100644 --- a/crates/forth/src/theory/mod.rs +++ b/crates/forth/src/theory/mod.rs @@ -1,3 +1,4 @@ +pub mod chords; mod scales; pub use scales::lookup; diff --git a/crates/forth/src/vm.rs b/crates/forth/src/vm.rs index a918fd1..26888c0 100644 --- a/crates/forth/src/vm.rs +++ b/crates/forth/src/vm.rs @@ -455,7 +455,11 @@ impl Forth { let a_f = a.as_float()?; let b_f = b.as_float()?; let (lo, hi) = if a_f <= b_f { (a_f, b_f) } else { (b_f, a_f) }; - let val = self.rng.lock().unwrap().gen_range(lo..hi); + let val = if (hi - lo).abs() < f64::EPSILON { + lo + } else { + self.rng.lock().unwrap().gen_range(lo..hi) + }; stack.push(Value::Float(val, None)); } } @@ -629,6 +633,13 @@ impl Forth { stack.push(result); } + Op::Chord(intervals) => { + let root = stack.pop().ok_or("stack underflow")?.as_int()?; + for &interval in *intervals { + stack.push(Value::Int(root + interval, None)); + } + } + Op::Oct => { let shift = stack.pop().ok_or("stack underflow")?; let note = stack.pop().ok_or("stack underflow")?; diff --git a/crates/forth/src/words.rs b/crates/forth/src/words.rs index 7400268..5171cb0 100644 --- a/crates/forth/src/words.rs +++ b/crates/forth/src/words.rs @@ -830,6 +830,292 @@ pub const WORDS: &[Word] = &[ compile: Simple, varargs: false, }, + // Chords - Triads + Word { + name: "maj", + aliases: &[], + category: "Chord", + stack: "(root -- root third fifth)", + desc: "Major triad", + example: "c4 maj => 60 64 67", + compile: Simple, + varargs: true, + }, + Word { + name: "m", + aliases: &[], + category: "Chord", + stack: "(root -- root third fifth)", + desc: "Minor triad", + example: "c4 m => 60 63 67", + compile: Simple, + varargs: true, + }, + Word { + name: "dim", + aliases: &[], + category: "Chord", + stack: "(root -- root third fifth)", + desc: "Diminished triad", + example: "c4 dim => 60 63 66", + compile: Simple, + varargs: true, + }, + Word { + name: "aug", + aliases: &[], + category: "Chord", + stack: "(root -- root third fifth)", + desc: "Augmented triad", + example: "c4 aug => 60 64 68", + compile: Simple, + varargs: true, + }, + Word { + name: "sus2", + aliases: &[], + category: "Chord", + stack: "(root -- root second fifth)", + desc: "Suspended 2nd", + example: "c4 sus2 => 60 62 67", + compile: Simple, + varargs: true, + }, + Word { + name: "sus4", + aliases: &[], + category: "Chord", + stack: "(root -- root fourth fifth)", + desc: "Suspended 4th", + example: "c4 sus4 => 60 65 67", + compile: Simple, + varargs: true, + }, + // Chords - Seventh + Word { + name: "maj7", + aliases: &[], + category: "Chord", + stack: "(root -- root third fifth seventh)", + desc: "Major 7th", + example: "c4 maj7 => 60 64 67 71", + compile: Simple, + varargs: true, + }, + Word { + name: "min7", + aliases: &[], + category: "Chord", + stack: "(root -- root third fifth seventh)", + desc: "Minor 7th", + example: "c4 min7 => 60 63 67 70", + compile: Simple, + varargs: true, + }, + Word { + name: "dom7", + aliases: &[], + category: "Chord", + stack: "(root -- root third fifth seventh)", + desc: "Dominant 7th", + example: "c4 dom7 => 60 64 67 70", + compile: Simple, + varargs: true, + }, + Word { + name: "dim7", + aliases: &[], + category: "Chord", + stack: "(root -- root third fifth seventh)", + desc: "Diminished 7th", + example: "c4 dim7 => 60 63 66 69", + compile: Simple, + varargs: true, + }, + Word { + name: "m7b5", + aliases: &[], + category: "Chord", + stack: "(root -- root third fifth seventh)", + desc: "Half-diminished (min7b5)", + example: "c4 m7b5 => 60 63 66 70", + compile: Simple, + varargs: true, + }, + Word { + name: "minmaj7", + aliases: &[], + category: "Chord", + stack: "(root -- root third fifth seventh)", + desc: "Minor-major 7th", + example: "c4 minmaj7 => 60 63 67 71", + compile: Simple, + varargs: true, + }, + Word { + name: "aug7", + aliases: &[], + category: "Chord", + stack: "(root -- root third fifth seventh)", + desc: "Augmented 7th", + example: "c4 aug7 => 60 64 68 70", + compile: Simple, + varargs: true, + }, + // Chords - Sixth + Word { + name: "maj6", + aliases: &[], + category: "Chord", + stack: "(root -- root third fifth sixth)", + desc: "Major 6th", + example: "c4 maj6 => 60 64 67 69", + compile: Simple, + varargs: true, + }, + Word { + name: "min6", + aliases: &[], + category: "Chord", + stack: "(root -- root third fifth sixth)", + desc: "Minor 6th", + example: "c4 min6 => 60 63 67 69", + compile: Simple, + varargs: true, + }, + // Chords - Extended + Word { + name: "dom9", + aliases: &[], + category: "Chord", + stack: "(root -- root third fifth seventh ninth)", + desc: "Dominant 9th", + example: "c4 dom9 => 60 64 67 70 74", + compile: Simple, + varargs: true, + }, + Word { + name: "maj9", + aliases: &[], + category: "Chord", + stack: "(root -- root third fifth seventh ninth)", + desc: "Major 9th", + example: "c4 maj9 => 60 64 67 71 74", + compile: Simple, + varargs: true, + }, + Word { + name: "min9", + aliases: &[], + category: "Chord", + stack: "(root -- root third fifth seventh ninth)", + desc: "Minor 9th", + example: "c4 min9 => 60 63 67 70 74", + compile: Simple, + varargs: true, + }, + Word { + name: "dom11", + aliases: &[], + category: "Chord", + stack: "(root -- root third fifth seventh ninth eleventh)", + desc: "Dominant 11th", + example: "c4 dom11 => 60 64 67 70 74 77", + compile: Simple, + varargs: true, + }, + Word { + name: "min11", + aliases: &[], + category: "Chord", + stack: "(root -- root third fifth seventh ninth eleventh)", + desc: "Minor 11th", + example: "c4 min11 => 60 63 67 70 74 77", + compile: Simple, + varargs: true, + }, + Word { + name: "dom13", + aliases: &[], + category: "Chord", + stack: "(root -- root third fifth seventh ninth thirteenth)", + desc: "Dominant 13th", + example: "c4 dom13 => 60 64 67 70 74 81", + compile: Simple, + varargs: true, + }, + // Chords - Add + Word { + name: "add9", + aliases: &[], + category: "Chord", + stack: "(root -- root third fifth ninth)", + desc: "Major add 9", + example: "c4 add9 => 60 64 67 74", + compile: Simple, + varargs: true, + }, + Word { + name: "add11", + aliases: &[], + category: "Chord", + stack: "(root -- root third fifth eleventh)", + desc: "Major add 11", + example: "c4 add11 => 60 64 67 77", + compile: Simple, + varargs: true, + }, + Word { + name: "madd9", + aliases: &[], + category: "Chord", + stack: "(root -- root third fifth ninth)", + desc: "Minor add 9", + example: "c4 madd9 => 60 63 67 74", + compile: Simple, + varargs: true, + }, + // Chords - Altered dominants + Word { + name: "dom7b9", + aliases: &[], + category: "Chord", + stack: "(root -- root third fifth seventh flatninth)", + desc: "7th flat 9", + example: "c4 dom7b9 => 60 64 67 70 73", + compile: Simple, + varargs: true, + }, + Word { + name: "dom7s9", + aliases: &[], + category: "Chord", + stack: "(root -- root third fifth seventh sharpninth)", + desc: "7th sharp 9 (Hendrix chord)", + example: "c4 dom7s9 => 60 64 67 70 75", + compile: Simple, + varargs: true, + }, + Word { + name: "dom7b5", + aliases: &[], + category: "Chord", + stack: "(root -- root third flatfifth seventh)", + desc: "7th flat 5", + example: "c4 dom7b5 => 60 64 66 70", + compile: Simple, + varargs: true, + }, + Word { + name: "dom7s5", + aliases: &[], + category: "Chord", + stack: "(root -- root third sharpfifth seventh)", + desc: "7th sharp 5", + example: "c4 dom7s5 => 60 64 68 70", + compile: Simple, + varargs: true, + }, // LFO Word { name: "ramp", @@ -2676,6 +2962,11 @@ pub(super) fn compile_word( return true; } + if let Some(intervals) = theory::chords::lookup(name) { + ops.push(Op::Chord(intervals)); + return true; + } + if let Some(word) = lookup_word(name) { match &word.compile { Simple => { diff --git a/tests/forth.rs b/tests/forth.rs index f5a20d8..9de9552 100644 --- a/tests/forth.rs +++ b/tests/forth.rs @@ -54,3 +54,6 @@ mod generator; #[path = "forth/midi.rs"] mod midi; + +#[path = "forth/chords.rs"] +mod chords; diff --git a/tests/forth/chords.rs b/tests/forth/chords.rs new file mode 100644 index 0000000..6ceb0ff --- /dev/null +++ b/tests/forth/chords.rs @@ -0,0 +1,178 @@ +use cagire::forth::Value; + +use super::harness::{expect_stack, run}; + +fn ints(vals: &[i64]) -> Vec { + vals.iter().map(|&v| Value::Int(v, None)).collect() +} + +// Triads + +#[test] +fn chord_major() { + expect_stack("c4 maj", &ints(&[60, 64, 67])); +} + +#[test] +fn chord_minor() { + expect_stack("c4 m", &ints(&[60, 63, 67])); +} + +#[test] +fn chord_diminished() { + expect_stack("c4 dim", &ints(&[60, 63, 66])); +} + +#[test] +fn chord_augmented() { + expect_stack("c4 aug", &ints(&[60, 64, 68])); +} + +#[test] +fn chord_sus2() { + expect_stack("c4 sus2", &ints(&[60, 62, 67])); +} + +#[test] +fn chord_sus4() { + expect_stack("c4 sus4", &ints(&[60, 65, 67])); +} + +// Seventh chords + +#[test] +fn chord_maj7() { + expect_stack("c4 maj7", &ints(&[60, 64, 67, 71])); +} + +#[test] +fn chord_min7() { + expect_stack("c4 min7", &ints(&[60, 63, 67, 70])); +} + +#[test] +fn chord_dom7() { + expect_stack("c4 dom7", &ints(&[60, 64, 67, 70])); +} + +#[test] +fn chord_dim7() { + expect_stack("c4 dim7", &ints(&[60, 63, 66, 69])); +} + +#[test] +fn chord_half_dim() { + expect_stack("c4 m7b5", &ints(&[60, 63, 66, 70])); +} + +#[test] +fn chord_minmaj7() { + expect_stack("c4 minmaj7", &ints(&[60, 63, 67, 71])); +} + +#[test] +fn chord_aug7() { + expect_stack("c4 aug7", &ints(&[60, 64, 68, 70])); +} + +// Sixth chords + +#[test] +fn chord_maj6() { + expect_stack("c4 maj6", &ints(&[60, 64, 67, 69])); +} + +#[test] +fn chord_min6() { + expect_stack("c4 min6", &ints(&[60, 63, 67, 69])); +} + +// Extended chords + +#[test] +fn chord_dom9() { + expect_stack("c4 dom9", &ints(&[60, 64, 67, 70, 74])); +} + +#[test] +fn chord_maj9() { + expect_stack("c4 maj9", &ints(&[60, 64, 67, 71, 74])); +} + +#[test] +fn chord_min9() { + expect_stack("c4 min9", &ints(&[60, 63, 67, 70, 74])); +} + +#[test] +fn chord_dom11() { + expect_stack("c4 dom11", &ints(&[60, 64, 67, 70, 74, 77])); +} + +#[test] +fn chord_min11() { + expect_stack("c4 min11", &ints(&[60, 63, 67, 70, 74, 77])); +} + +#[test] +fn chord_dom13() { + expect_stack("c4 dom13", &ints(&[60, 64, 67, 70, 74, 81])); +} + +// Add chords + +#[test] +fn chord_add9() { + expect_stack("c4 add9", &ints(&[60, 64, 67, 74])); +} + +#[test] +fn chord_add11() { + expect_stack("c4 add11", &ints(&[60, 64, 67, 77])); +} + +#[test] +fn chord_madd9() { + expect_stack("c4 madd9", &ints(&[60, 63, 67, 74])); +} + +// Altered dominants + +#[test] +fn chord_dom7b9() { + expect_stack("c4 dom7b9", &ints(&[60, 64, 67, 70, 73])); +} + +#[test] +fn chord_dom7s9() { + expect_stack("c4 dom7s9", &ints(&[60, 64, 67, 70, 75])); +} + +#[test] +fn chord_dom7b5() { + expect_stack("c4 dom7b5", &ints(&[60, 64, 66, 70])); +} + +#[test] +fn chord_dom7s5() { + expect_stack("c4 dom7s5", &ints(&[60, 64, 68, 70])); +} + +// Different roots + +#[test] +fn chord_a3_min7() { + expect_stack("a3 min7", &ints(&[57, 60, 64, 67])); +} + +#[test] +fn chord_e4_dom7s9() { + expect_stack("e4 dom7s9", &ints(&[64, 68, 71, 74, 79])); +} + +#[test] +fn chord_with_integer_root() { + let f = run("60 maj"); + let stack = f.stack(); + assert_eq!(stack, ints(&[60, 64, 67])); +} diff --git a/tests/forth/randomness.rs b/tests/forth/randomness.rs index bd5208a..5304ae8 100644 --- a/tests/forth/randomness.rs +++ b/tests/forth/randomness.rs @@ -171,3 +171,8 @@ fn logrand_requires_positive() { expect_error("-1.0 10.0 logrand", "logrand requires positive values"); expect_error("1.0 0.0 logrand", "logrand requires positive values"); } + +#[test] +fn rand_equal_bounds() { + expect_float("5.0 5.0 rand", 5.0); +}