diff --git a/CHANGELOG.md b/CHANGELOG.md index e34cba5..8642dac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ All notable changes to this project will be documented in this file. - TachyonFX based animations - Prelude: project-level Forth script for persistent word definitions. Press `d` to edit, `D` to re-evaluate. Runs automatically on playback start and project load. - Varargs stack words: `rev`, `shuffle`, `sort` (ascending), `rsort` (descending), `sum`, `prod`. All take a count and operate on the top n items. +- Euclidean rhythm words: `euclid` (k n -- hits) distributes k hits across n steps, `euclidrot` (k n r -- hits) adds rotation offset. +- Shorthand float syntax: `.25` parses as `0.25`, `-.5` parses as `-0.5`. ### Changed diff --git a/crates/forth/src/compiler.rs b/crates/forth/src/compiler.rs index be98335..9d8ae5c 100644 --- a/crates/forth/src/compiler.rs +++ b/crates/forth/src/compiler.rs @@ -86,9 +86,25 @@ fn tokenize(input: &str) -> Vec { } let span = SourceSpan { start, end }; - if let Ok(i) = word.parse::() { + + // Normalize shorthand float syntax: .25 -> 0.25, -.5 -> -0.5 + let word_to_parse = if word.starts_with('.') + && word.len() > 1 + && word.as_bytes()[1].is_ascii_digit() + { + format!("0{word}") + } else if word.starts_with("-.") + && word.len() > 2 + && word.as_bytes()[2].is_ascii_digit() + { + format!("-0{}", &word[1..]) + } else { + word.clone() + }; + + if let Ok(i) = word_to_parse.parse::() { tokens.push(Token::Int(i, span)); - } else if let Ok(f) = word.parse::() { + } else if let Ok(f) = word_to_parse.parse::() { tokens.push(Token::Float(f, span)); } else { tokens.push(Token::Word(word, span)); diff --git a/crates/forth/src/ops.rs b/crates/forth/src/ops.rs index 59ce81d..3c8eb7c 100644 --- a/crates/forth/src/ops.rs +++ b/crates/forth/src/ops.rs @@ -100,6 +100,8 @@ pub enum Op { StepRange, Generate, GeomRange, + Euclid, + EuclidRot, Times, Chord(&'static [i64]), // MIDI diff --git a/crates/forth/src/vm.rs b/crates/forth/src/vm.rs index d200052..8a9ed94 100644 --- a/crates/forth/src/vm.rs +++ b/crates/forth/src/vm.rs @@ -1025,6 +1025,29 @@ impl Forth { } } + Op::Euclid => { + let n = stack.pop().ok_or("stack underflow")?.as_int()?; + let k = stack.pop().ok_or("stack underflow")?.as_int()?; + if k < 0 || n < 0 { + return Err("euclid: k and n must be >= 0".into()); + } + for idx in euclidean_rhythm(k as usize, n as usize, 0) { + stack.push(Value::Int(idx, None)); + } + } + + Op::EuclidRot => { + let r = stack.pop().ok_or("stack underflow")?.as_int()?; + let n = stack.pop().ok_or("stack underflow")?.as_int()?; + let k = stack.pop().ok_or("stack underflow")?.as_int()?; + if k < 0 || n < 0 || r < 0 { + return Err("euclidrot: k, n, and r must be >= 0".into()); + } + for idx in euclidean_rhythm(k as usize, n as usize, r as usize) { + stack.push(Value::Int(idx, None)); + } + } + // MIDI operations Op::MidiEmit => { let (_, params) = cmd.snapshot().unwrap_or((None, &[])); @@ -1216,6 +1239,58 @@ fn emit_output( outputs.push(out); } +fn euclidean_rhythm(k: usize, n: usize, rotation: usize) -> Vec { + if k == 0 || n == 0 { + return Vec::new(); + } + if k >= n { + return (0..n as i64).collect(); + } + + let mut groups: Vec> = (0..k).map(|_| vec![true]).collect(); + groups.extend((0..(n - k)).map(|_| vec![false])); + + while groups.len() > 1 { + let ones_count = groups.iter().filter(|g| g[0]).count(); + let zeros_count = groups.len() - ones_count; + + if zeros_count == 0 || ones_count == 0 { + break; + } + + let min_count = ones_count.min(zeros_count); + let mut new_groups = Vec::with_capacity(groups.len() - min_count); + + let (mut ones, mut zeros): (Vec<_>, Vec<_>) = + groups.into_iter().partition(|g| g[0]); + + for _ in 0..min_count { + let mut one = ones.pop().unwrap(); + one.extend(zeros.pop().unwrap()); + new_groups.push(one); + } + new_groups.extend(ones); + new_groups.extend(zeros); + + groups = new_groups; + } + + let pattern: Vec = groups.into_iter().flatten().collect(); + + let rotated = if rotation > 0 && !pattern.is_empty() { + let r = rotation % pattern.len(); + pattern.iter().cycle().skip(r).take(pattern.len()).copied().collect() + } else { + pattern + }; + + rotated + .into_iter() + .enumerate() + .filter_map(|(i, hit)| if hit { Some(i as i64) } else { None }) + .collect() +} + fn perlin_grad(hash_input: i64) -> f64 { let mut h = (hash_input as u64) .wrapping_mul(6364136223846793005) diff --git a/crates/forth/src/words/compile.rs b/crates/forth/src/words/compile.rs index d05e727..b25f7d0 100644 --- a/crates/forth/src/words/compile.rs +++ b/crates/forth/src/words/compile.rs @@ -92,6 +92,8 @@ pub(super) fn simple_op(name: &str) -> Option { ".," => Op::StepRange, "gen" => Op::Generate, "geom.." => Op::GeomRange, + "euclid" => Op::Euclid, + "euclidrot" => Op::EuclidRot, "times" => Op::Times, "m." => Op::MidiEmit, "ccval" => Op::GetMidiCC, diff --git a/crates/forth/src/words/sequencing.rs b/crates/forth/src/words/sequencing.rs index aa26763..b514fa0 100644 --- a/crates/forth/src/words/sequencing.rs +++ b/crates/forth/src/words/sequencing.rs @@ -420,4 +420,24 @@ pub(super) const WORDS: &[Word] = &[ compile: Simple, varargs: false, }, + Word { + name: "euclid", + aliases: &[], + category: "Generator", + stack: "(k n -- i1 i2 ... ik)", + desc: "Push indices for k hits evenly distributed over n steps", + example: "4 8 euclid => 0 2 4 6", + compile: Simple, + varargs: false, + }, + Word { + name: "euclidrot", + aliases: &[], + category: "Generator", + stack: "(k n r -- i1 i2 ... ik)", + desc: "Push Euclidean indices with rotation r", + example: "3 8 2 euclidrot => 1 4 6", + compile: Simple, + varargs: false, + }, ]; diff --git a/tests/forth.rs b/tests/forth.rs index 9de9552..1d5e7cd 100644 --- a/tests/forth.rs +++ b/tests/forth.rs @@ -57,3 +57,6 @@ mod midi; #[path = "forth/chords.rs"] mod chords; + +#[path = "forth/euclidean.rs"] +mod euclidean; diff --git a/tests/forth/arithmetic.rs b/tests/forth/arithmetic.rs index d268856..70d465c 100644 --- a/tests/forth/arithmetic.rs +++ b/tests/forth/arithmetic.rs @@ -200,3 +200,13 @@ fn log_e() { fn log_one() { expect_int("1 log", 0); } + +#[test] +fn shorthand_float() { + expect_float(".25 .5 +", 0.75); +} + +#[test] +fn shorthand_float_negative() { + expect_float("-.5 1 +", 0.5); +} diff --git a/tests/forth/euclidean.rs b/tests/forth/euclidean.rs new file mode 100644 index 0000000..3a04f91 --- /dev/null +++ b/tests/forth/euclidean.rs @@ -0,0 +1,105 @@ +use super::harness::*; +use cagire::forth::Value; + +fn int(n: i64) -> Value { + Value::Int(n, None) +} + +#[test] +fn euclid_4_8() { + expect_stack("4 8 euclid", &[int(0), int(2), int(4), int(6)]); +} + +#[test] +fn euclid_3_8_tresillo() { + expect_stack("3 8 euclid", &[int(0), int(3), int(6)]); +} + +#[test] +fn euclid_5_8_cinquillo() { + expect_stack("5 8 euclid", &[int(0), int(2), int(4), int(6), int(7)]); +} + +#[test] +fn euclid_4_16() { + expect_stack("4 16 euclid", &[int(0), int(4), int(8), int(12)]); +} + +#[test] +fn euclid_5_16() { + expect_stack("5 16 euclid", &[int(0), int(4), int(7), int(10), int(13)]); +} + +#[test] +fn euclid_zero_hits() { + expect_stack("0 8 euclid", &[]); +} + +#[test] +fn euclid_zero_steps() { + expect_stack("4 0 euclid", &[]); +} + +#[test] +fn euclid_k_equals_n() { + expect_stack("4 4 euclid", &[int(0), int(1), int(2), int(3)]); +} + +#[test] +fn euclid_k_greater_than_n() { + expect_stack("8 4 euclid", &[int(0), int(1), int(2), int(3)]); +} + +#[test] +fn euclid_single_hit() { + expect_stack("1 8 euclid", &[int(0)]); +} + +#[test] +fn euclid_negative_k() { + expect_error("-1 8 euclid", "k and n must be >= 0"); +} + +#[test] +fn euclid_negative_n() { + expect_error("4 -8 euclid", "k and n must be >= 0"); +} + +#[test] +fn euclid_underflow() { + expect_error("8 euclid", "stack underflow"); + expect_error("euclid", "stack underflow"); +} + +#[test] +fn euclidrot_basic() { + expect_stack("3 8 1 euclidrot", &[int(2), int(5), int(7)]); +} + +#[test] +fn euclidrot_zero_rotation() { + expect_stack("3 8 0 euclidrot", &[int(0), int(3), int(6)]); +} + +#[test] +fn euclidrot_full_rotation() { + expect_stack("3 8 8 euclidrot", &[int(0), int(3), int(6)]); +} + +#[test] +fn euclidrot_negative_r() { + expect_error("3 8 -1 euclidrot", "k, n, and r must be >= 0"); +} + +#[test] +fn euclidrot_underflow() { + expect_error("8 1 euclidrot", "stack underflow"); + expect_error("1 euclidrot", "stack underflow"); + expect_error("euclidrot", "stack underflow"); +} + +#[test] +fn euclid_with_at() { + let outputs = expect_outputs("4 8 euclid at kick sound .", 4); + assert_eq!(outputs.len(), 4); +}