Notes and intervals

This commit is contained in:
2026-01-22 01:38:55 +01:00
parent c73e25e207
commit 409e815414
9 changed files with 85965 additions and 0 deletions

42842
cool Normal file

File diff suppressed because it is too large Load Diff

42839
notes Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -133,6 +133,7 @@ fn main() -> io::Result<()> {
io::stdout().execute(EnterAlternateScreen)?;
let backend = CrosstermBackend::new(io::stdout());
let mut terminal = Terminal::new(backend)?;
terminal.clear()?;
loop {
if app.audio.restart_pending {

View File

@@ -143,6 +143,7 @@ pub enum Op {
PushFloat(f64, Option<SourceSpan>),
PushStr(String, Option<SourceSpan>),
Dup,
Dupn,
Drop,
Swap,
Over,
@@ -240,6 +241,20 @@ pub const WORDS: &[Word] = &[
example: "3 dup => 3 3",
compile: Simple,
},
Word {
name: "dupn",
stack: "(a n -- a a ... a)",
desc: "Duplicate a onto stack n times",
example: "2 4 dupn => 2 2 2 2",
compile: Simple,
},
Word {
name: "!",
stack: "(a n -- a a ... a)",
desc: "Duplicate a onto stack n times (alias for dupn)",
example: "2 4 ! => 2 2 2 2",
compile: Alias("dupn"),
},
Word {
name: "drop",
stack: "(a --)",
@@ -1596,6 +1611,7 @@ pub const WORDS: &[Word] = &[
fn simple_op(name: &str) -> Option<Op> {
Some(match name {
"dup" => Op::Dup,
"dupn" => Op::Dupn,
"drop" => Op::Drop,
"swap" => Op::Swap,
"over" => Op::Over,
@@ -1660,6 +1676,81 @@ fn simple_op(name: &str) -> Option<Op> {
})
}
/// Parse note names like c4, c#4, cs4, eb4 into MIDI numbers.
/// C4 = 60 (middle C), A4 = 69 (440 Hz reference).
fn parse_note_name(name: &str) -> Option<i64> {
let name = name.to_lowercase();
let bytes = name.as_bytes();
if bytes.len() < 2 {
return None;
}
let base = match bytes[0] {
b'c' => 0,
b'd' => 2,
b'e' => 4,
b'f' => 5,
b'g' => 7,
b'a' => 9,
b'b' => 11,
_ => return None,
};
let (modifier, octave_start) = match bytes[1] {
b'#' | b's' => (1, 2),
b'b' if bytes.len() > 2 && bytes[2].is_ascii_digit() => (-1, 2), // flat: eb4, bb4
b'0'..=b'9' => (0, 1),
_ => return None,
};
let octave_str = &name[octave_start..];
let octave: i64 = octave_str.parse().ok()?;
if !(-1..=9).contains(&octave) {
return None;
}
// MIDI: C4 = 60, so C-1 = 0
Some((octave + 1) * 12 + base + modifier)
}
/// Parse interval names like m3, M3, P5 into semitone counts.
/// Supports simple intervals (1-8) and compound intervals (9-15).
fn parse_interval(name: &str) -> Option<i64> {
// Simple intervals: unison through octave
let simple = match name {
"P1" | "unison" => 0,
"m2" => 1,
"M2" => 2,
"m3" => 3,
"M3" => 4,
"P4" => 5,
"aug4" | "dim5" | "tritone" => 6,
"P5" => 7,
"m6" => 8,
"M6" => 9,
"m7" => 10,
"M7" => 11,
"P8" | "oct" => 12,
// Compound intervals (octave + simple)
"m9" => 13,
"M9" => 14,
"m10" => 15,
"M10" => 16,
"P11" => 17,
"aug11" => 18,
"P12" => 19,
"m13" => 20,
"M13" => 21,
"m14" => 22,
"M14" => 23,
"P15" => 24,
_ => return None,
};
Some(simple)
}
fn compile_word(name: &str, ops: &mut Vec<Op>) -> bool {
for word in WORDS {
if word.name == name {
@@ -1699,6 +1790,20 @@ fn compile_word(name: &str, ops: &mut Vec<Op>) -> bool {
}
}
// Note names: c4, c#4, cs4, eb4, etc. -> MIDI number
if let Some(midi) = parse_note_name(name) {
ops.push(Op::PushInt(midi, None));
return true;
}
// Intervals: m3, M3, P5, etc. -> dup top, add semitones (for chord building)
if let Some(semitones) = parse_interval(name) {
ops.push(Op::Dup);
ops.push(Op::PushInt(semitones, None));
ops.push(Op::Add);
return true;
}
// Internal ops not exposed in WORDS
if let Some(op) = simple_op(name) {
ops.push(op);
@@ -2022,6 +2127,13 @@ impl Forth {
let v = stack.last().ok_or("stack underflow")?.clone();
stack.push(v);
}
Op::Dupn => {
let n = stack.pop().ok_or("stack underflow")?.as_int()?;
let v = stack.pop().ok_or("stack underflow")?;
for _ in 0..n {
stack.push(v.clone());
}
}
Op::Drop => {
stack.pop().ok_or("stack underflow")?;
}

View File

@@ -15,6 +15,9 @@ use super::{audio_view, doc_view, main_view, patterns_view, title_view};
pub fn render(frame: &mut Frame, app: &mut App, link: &LinkState, snapshot: &SequencerSnapshot) {
let term = frame.area();
let blank = " ".repeat(term.width as usize);
let lines: Vec<Line> = (0..term.height).map(|_| Line::raw(&blank)).collect();
frame.render_widget(Paragraph::new(lines), term);
if app.ui.show_title {
title_view::render(frame, term, &mut app.ui);

View File

@@ -36,3 +36,9 @@ mod quotations;
#[path = "forth/iteration.rs"]
mod iteration;
#[path = "forth/notes.rs"]
mod notes;
#[path = "forth/intervals.rs"]
mod intervals;

75
tests/forth/intervals.rs Normal file
View File

@@ -0,0 +1,75 @@
use super::harness::{expect_int, expect_stack};
use cagire::model::forth::Value;
fn ints(vals: &[i64]) -> Vec<Value> {
vals.iter().map(|&v| Value::Int(v, None)).collect()
}
#[test]
fn interval_keeps_root() {
// Interval dups the root, then adds - so both values remain
expect_stack("c4 M3", &ints(&[60, 64]));
expect_stack("c4 P5", &ints(&[60, 67]));
}
#[test]
fn interval_stacking_builds_chord() {
// C major: root + M3 + m3 (stacked)
expect_stack("c4 M3 m3", &ints(&[60, 64, 67]));
// C minor: root + m3 + M3
expect_stack("c4 m3 M3", &ints(&[60, 63, 67]));
}
#[test]
fn interval_tritone() {
expect_stack("c4 tritone", &ints(&[60, 66]));
expect_stack("c4 aug4", &ints(&[60, 66]));
expect_stack("c4 dim5", &ints(&[60, 66]));
}
#[test]
fn interval_all_simple() {
expect_stack("c4 P1", &ints(&[60, 60])); // unison
expect_stack("c4 m2", &ints(&[60, 61]));
expect_stack("c4 M2", &ints(&[60, 62]));
expect_stack("c4 m3", &ints(&[60, 63]));
expect_stack("c4 M3", &ints(&[60, 64]));
expect_stack("c4 P4", &ints(&[60, 65]));
expect_stack("c4 P5", &ints(&[60, 67]));
expect_stack("c4 m6", &ints(&[60, 68]));
expect_stack("c4 M6", &ints(&[60, 69]));
expect_stack("c4 m7", &ints(&[60, 70]));
expect_stack("c4 M7", &ints(&[60, 71]));
expect_stack("c4 P8", &ints(&[60, 72]));
}
#[test]
fn interval_compound() {
expect_stack("c4 m9", &ints(&[60, 73]));
expect_stack("c4 M9", &ints(&[60, 74]));
expect_stack("c4 P15", &ints(&[60, 84]));
}
#[test]
fn interval_from_any_note() {
expect_stack("a4 m3", &ints(&[69, 72])); // A4 + m3 = C5
expect_stack("e4 P5", &ints(&[64, 71])); // E4 + P5 = B4
}
#[test]
fn interval_dominant_seventh() {
// C7: C E G Bb = root + M3 + m3 + m3
expect_stack("c4 M3 m3 m3", &ints(&[60, 64, 67, 70]));
}
#[test]
fn interval_major_seventh() {
// Cmaj7: C E G B = root + M3 + m3 + M3
expect_stack("c4 M3 m3 M3", &ints(&[60, 64, 67, 71]));
}
#[test]
fn interval_with_nip() {
// If you only want the new note, nip drops the one below top
expect_int("c4 M3 nip", 64);
}

62
tests/forth/notes.rs Normal file
View File

@@ -0,0 +1,62 @@
use super::harness::expect_int;
#[test]
fn note_c4_is_middle_c() {
expect_int("c4", 60);
}
#[test]
fn note_a4_is_440hz_reference() {
expect_int("a4", 69);
}
#[test]
fn note_c0() {
expect_int("c0", 12);
}
#[test]
fn note_sharps() {
expect_int("c#4", 61);
expect_int("cs4", 61);
expect_int("f#3", 54);
expect_int("fs3", 54);
}
#[test]
fn note_flats() {
expect_int("db4", 61);
expect_int("eb4", 63);
expect_int("ab4", 68);
expect_int("bb4", 70);
}
#[test]
fn note_all_naturals_octave4() {
expect_int("c4", 60);
expect_int("d4", 62);
expect_int("e4", 64);
expect_int("f4", 65);
expect_int("g4", 67);
expect_int("a4", 69);
expect_int("b4", 71);
}
#[test]
fn note_octave_range() {
expect_int("c0", 12);
expect_int("c1", 24);
expect_int("c2", 36);
expect_int("c3", 48);
expect_int("c5", 72);
expect_int("c6", 84);
expect_int("c7", 96);
expect_int("c8", 108);
expect_int("c9", 120);
}
#[test]
fn note_in_expression() {
expect_int("c4 12 +", 72); // C4 + octave = C5
expect_int("a4 c4 -", 9); // interval from C4 to A4
}

View File

@@ -15,6 +15,31 @@ fn dup_underflow() {
expect_error("dup", "stack underflow");
}
#[test]
fn dupn() {
expect_stack("2 4 dupn", &[int(2), int(2), int(2), int(2)]);
}
#[test]
fn dupn_one() {
expect_stack("5 1 dupn", &[int(5)]);
}
#[test]
fn dupn_zero() {
expect_stack("5 0 dupn", &[]);
}
#[test]
fn dupn_underflow() {
expect_error("3 dupn", "stack underflow");
}
#[test]
fn bang_alias() {
expect_stack("c4 3 !", &[int(60), int(60), int(60)]);
}
#[test]
fn drop() {
expect_stack("1 2 drop", &[int(1)]);