From f020b5a172fdf7dd855c6b0482be31cd636413ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Forment?= Date: Fri, 20 Mar 2026 23:29:47 +0100 Subject: [PATCH] Feat: improve 'at' in cagire grammar --- crates/forth/src/compiler.rs | 38 +++++ crates/forth/src/ops.rs | 3 +- crates/forth/src/types.rs | 29 +++- crates/forth/src/vm.rs | 189 ++++++++------------- crates/forth/src/words/compile.rs | 2 +- crates/forth/src/words/sequencing.rs | 6 +- crates/forth/src/words/sound.rs | 10 -- demos/01.cagire | 2 +- docs/tutorials/at.md | 20 +-- docs/tutorials/generators.md | 4 +- docs/tutorials/harmony.md | 4 +- tests/forth/midi.rs | 19 +-- tests/forth/temporal.rs | 244 +++++++++++++++------------ 13 files changed, 293 insertions(+), 277 deletions(-) diff --git a/crates/forth/src/compiler.rs b/crates/forth/src/compiler.rs index 2a12303..d92348b 100644 --- a/crates/forth/src/compiler.rs +++ b/crates/forth/src/compiler.rs @@ -176,6 +176,13 @@ fn compile(tokens: &[Token], dict: &Dictionary) -> Result, String> { ops.push(Op::Branch(else_ops.len())); ops.extend(else_ops); } + } else if word == "at" { + if let Some((body_ops, consumed)) = compile_at(&tokens[i + 1..], dict)? { + i += consumed; + ops.push(Op::AtLoop(Arc::from(body_ops))); + } else if !compile_word(word, Some(*span), &mut ops, dict) { + return Err(format!("unknown word: {word}")); + } } else if word == "case" { let (case_ops, consumed) = compile_case(&tokens[i + 1..], dict)?; i += consumed; @@ -355,6 +362,37 @@ fn compile_if( Ok((then_ops, else_ops, then_pos + 1, then_span, else_span)) } +fn compile_at(tokens: &[Token], dict: &Dictionary) -> Result, usize)>, String> { + let mut depth = 1; + + enum AtCloser { Dot, MidiDot, Done } + let mut found: Option<(usize, AtCloser)> = None; + + for (i, tok) in tokens.iter().enumerate() { + if let Token::Word(w, _) = tok { + match w.as_str() { + "at" => depth += 1, + "." if depth == 1 => { found = Some((i, AtCloser::Dot)); break; } + "m." if depth == 1 => { found = Some((i, AtCloser::MidiDot)); break; } + "done" if depth == 1 => { found = Some((i, AtCloser::Done)); break; } + "." | "m." | "done" => depth -= 1, + _ => {} + } + } + } + + let Some((pos, closer)) = found else { + return Ok(None); + }; + let mut body_ops = compile(&tokens[..pos], dict)?; + match closer { + AtCloser::Dot => body_ops.push(Op::Emit), + AtCloser::MidiDot => body_ops.push(Op::MidiEmit), + AtCloser::Done => {} + } + Ok(Some((body_ops, pos + 1))) +} + fn compile_case(tokens: &[Token], dict: &Dictionary) -> Result<(Vec, usize), String> { let mut depth = 1; let mut endcase_pos = None; diff --git a/crates/forth/src/ops.rs b/crates/forth/src/ops.rs index 991f8cb..8d99f8c 100644 --- a/crates/forth/src/ops.rs +++ b/crates/forth/src/ops.rs @@ -110,7 +110,8 @@ pub enum Op { ClearCmd, SetSpeed, At, - Arp, + AtLoop(Arc<[Op]>), + IntRange, StepRange, Generate, diff --git a/crates/forth/src/types.rs b/crates/forth/src/types.rs index 2e16fa4..477a9c6 100644 --- a/crates/forth/src/types.rs +++ b/crates/forth/src/types.rs @@ -96,7 +96,7 @@ pub enum Value { Str(Arc, Option), Quotation(Arc<[Op]>, Option), CycleList(Arc<[Value]>), - ArpList(Arc<[Value]>), + } impl PartialEq for Value { @@ -107,7 +107,7 @@ impl PartialEq for Value { (Value::Str(a, _), Value::Str(b, _)) => a == b, (Value::Quotation(a, _), Value::Quotation(b, _)) => a == b, (Value::CycleList(a), Value::CycleList(b)) => a == b, - (Value::ArpList(a), Value::ArpList(b)) => a == b, + _ => false, } } @@ -143,7 +143,7 @@ impl Value { Value::Float(f, _) => *f != 0.0, Value::Str(s, _) => !s.is_empty(), Value::Quotation(..) => true, - Value::CycleList(items) | Value::ArpList(items) => !items.is_empty(), + Value::CycleList(items) => !items.is_empty(), } } @@ -153,14 +153,14 @@ impl Value { Value::Float(f, _) => f.to_string(), Value::Str(s, _) => s.to_string(), Value::Quotation(..) => String::new(), - Value::CycleList(_) | Value::ArpList(_) => String::new(), + Value::CycleList(_) => String::new(), } } pub(super) fn span(&self) -> Option { match self { Value::Int(_, s) | Value::Float(_, s) | Value::Str(_, s) | Value::Quotation(_, s) => *s, - Value::CycleList(_) | Value::ArpList(_) => None, + Value::CycleList(_) => None, } } } @@ -171,6 +171,7 @@ pub(super) struct CmdRegister { params: Vec<(&'static str, Value)>, deltas: Vec, global_params: Vec<(&'static str, Value)>, + delta_secs: Option, } impl CmdRegister { @@ -180,6 +181,7 @@ impl CmdRegister { params: Vec::with_capacity(16), deltas: Vec::with_capacity(4), global_params: Vec::new(), + delta_secs: None, } } @@ -237,9 +239,26 @@ impl CmdRegister { std::mem::take(&mut self.global_params) } + pub(super) fn set_delta_secs(&mut self, secs: f64) { + self.delta_secs = Some(secs); + } + + pub(super) fn take_delta_secs(&mut self) -> Option { + self.delta_secs.take() + } + + pub(super) fn clear_sound(&mut self) { + self.sound = None; + } + + pub(super) fn clear_params(&mut self) { + self.params.clear(); + } + pub(super) fn clear(&mut self) { self.sound = None; self.params.clear(); self.deltas.clear(); + self.delta_secs = None; } } diff --git a/crates/forth/src/vm.rs b/crates/forth/src/vm.rs index 027353a..253e162 100644 --- a/crates/forth/src/vm.rs +++ b/crates/forth/src/vm.rs @@ -241,31 +241,7 @@ impl Forth { sound_len.max(param_max) }; - let has_arp_list = |cmd: &CmdRegister| -> bool { - matches!(cmd.sound(), Some(Value::ArpList(_))) - || cmd.global_params().iter().chain(cmd.params().iter()) - .any(|(_, v)| matches!(v, Value::ArpList(_))) - }; - - let compute_arp_count = |cmd: &CmdRegister| -> usize { - let sound_len = match cmd.sound() { - Some(Value::ArpList(items)) => items.len(), - _ => 0, - }; - let param_max = cmd - .params() - .iter() - .map(|(_, v)| match v { - Value::ArpList(items) => items.len(), - _ => 0, - }) - .max() - .unwrap_or(0); - sound_len.max(param_max).max(1) - }; - let emit_with_cycling = |cmd: &CmdRegister, - arp_idx: usize, poly_idx: usize, delta_secs: f64, outputs: &mut Vec| @@ -277,7 +253,7 @@ impl Forth { return Err("nothing to emit".into()); } let resolved_sound_val = - cmd.sound().map(|sv| resolve_value(sv, arp_idx, poly_idx)); + cmd.sound().map(|sv| resolve_value(sv, poly_idx)); let sound_str = match &resolved_sound_val { Some(v) => Some(v.as_str()?.to_string()), None => None, @@ -286,8 +262,8 @@ impl Forth { .iter() .chain(cmd.params().iter()) .map(|(k, v)| { - let resolved = resolve_value(v, arp_idx, poly_idx); - if let Value::CycleList(_) | Value::ArpList(_) = v { + let resolved = resolve_value(v, poly_idx); + if let Value::CycleList(_) = v { if let Some(span) = resolved.span() { if let Some(trace) = trace_cell.borrow_mut().as_mut() { trace.selected_spans.push(span); @@ -595,47 +571,17 @@ impl Forth { } Op::Emit => { - if has_arp_list(cmd) { - let arp_count = compute_arp_count(cmd); + if let Some(dsecs) = cmd.take_delta_secs() { let poly_count = compute_poly_count(cmd); - let explicit_deltas = !cmd.deltas().is_empty(); - let delta_list: Vec = if explicit_deltas { - cmd.deltas().to_vec() - } else { - Vec::new() - }; - let count = if explicit_deltas { - arp_count.max(delta_list.len()) - } else { - arp_count - }; - - for i in 0..count { - let delta_secs = if explicit_deltas { - let dv = &delta_list[i % delta_list.len()]; - let frac = dv.as_float()?; - if let Some(span) = dv.span() { + for poly_idx in 0..poly_count { + if let Some(sound_val) = + emit_with_cycling(cmd, poly_idx, dsecs, outputs)? + { + if let Some(span) = sound_val.span() { if let Some(trace) = trace_cell.borrow_mut().as_mut() { trace.selected_spans.push(span); } } - ctx.nudge_secs + frac * ctx.step_duration() - } else { - ctx.nudge_secs - + (i as f64 / count as f64) * ctx.step_duration() - }; - for poly_i in 0..poly_count { - if let Some(sound_val) = - emit_with_cycling(cmd, i, poly_i, delta_secs, outputs)? - { - if let Some(span) = sound_val.span() { - if let Some(trace) = - trace_cell.borrow_mut().as_mut() - { - trace.selected_spans.push(span); - } - } - } } } } else { @@ -657,7 +603,7 @@ impl Forth { } } if let Some(sound_val) = - emit_with_cycling(cmd, 0, poly_idx, delta_secs, outputs)? + emit_with_cycling(cmd, poly_idx, delta_secs, outputs)? { if let Some(span) = sound_val.span() { if let Some(trace) = @@ -1196,12 +1142,60 @@ impl Forth { cmd.set_deltas(deltas); } - Op::Arp => { + Op::AtLoop(body_ops) => { ensure(stack, 1)?; - let values = std::mem::take(stack); - stack.push(Value::ArpList(Arc::from(values))); + let deltas = std::mem::take(stack); + let n = deltas.len(); + + for (i, delta_val) in deltas.iter().enumerate() { + let frac = delta_val.as_float()?; + let delta_secs = ctx.nudge_secs + frac * ctx.step_duration(); + + let iter_ctx = StepContext { + step: ctx.step, + beat: ctx.beat, + bank: ctx.bank, + pattern: ctx.pattern, + tempo: ctx.tempo, + phase: ctx.phase, + slot: ctx.slot, + runs: ctx.runs * n + i, + iter: ctx.iter, + speed: ctx.speed, + fill: ctx.fill, + nudge_secs: ctx.nudge_secs, + sr: ctx.sr, + cc_access: ctx.cc_access, + speed_key: ctx.speed_key, + mouse_x: ctx.mouse_x, + mouse_y: ctx.mouse_y, + mouse_down: ctx.mouse_down, + }; + + cmd.set_delta_secs(delta_secs); + + let mut trace_opt = trace_cell.borrow_mut().take(); + let mut var_writes_guard = var_writes_cell.borrow_mut(); + let vw = var_writes_guard.as_mut().expect("var_writes taken"); + self.execute_ops( + body_ops, + &iter_ctx, + stack, + outputs, + cmd, + trace_opt.as_deref_mut(), + vars_snapshot, + vw, + )?; + drop(var_writes_guard); + *trace_cell.borrow_mut() = trace_opt; + + cmd.clear_params(); + cmd.clear_sound(); + } } + Op::Adsr => { let r = pop(stack)?; let s = pop(stack)?; @@ -1499,35 +1493,13 @@ impl Forth { // MIDI operations Op::MidiEmit => { + let at_loop_delta = cmd.take_delta_secs(); let (_, params) = cmd.snapshot().unwrap_or((None, &[])); - // Build schedule: (arp_idx, poly_idx, delta_secs) - let schedule: Vec<(usize, usize, f64)> = if has_arp_list(cmd) { - let arp_count = compute_arp_count(cmd); + // Build schedule: (poly_idx, delta_secs) + let schedule: Vec<(usize, f64)> = if let Some(dsecs) = at_loop_delta { let poly_count = compute_poly_count(cmd); - let explicit = !cmd.deltas().is_empty(); - let delta_list = cmd.deltas(); - let count = if explicit { - arp_count.max(delta_list.len()) - } else { - arp_count - }; - let mut sched = Vec::with_capacity(count * poly_count); - for i in 0..count { - let delta_secs = if explicit { - let frac = delta_list[i % delta_list.len()] - .as_float() - .unwrap_or(0.0); - ctx.nudge_secs + frac * ctx.step_duration() - } else { - ctx.nudge_secs - + (i as f64 / count as f64) * ctx.step_duration() - }; - for poly_i in 0..poly_count { - sched.push((i, poly_i, delta_secs)); - } - } - sched + (0..poly_count).map(|pi| (pi, dsecs)).collect() } else { let poly_count = compute_poly_count(cmd); let deltas: Vec = if cmd.deltas().is_empty() { @@ -1542,7 +1514,6 @@ impl Forth { for poly_idx in 0..poly_count { for &frac in &deltas { sched.push(( - 0, poly_idx, ctx.nudge_secs + frac * ctx.step_duration(), )); @@ -1551,14 +1522,14 @@ impl Forth { sched }; - for (arp_idx, poly_idx, delta_secs) in schedule { + for (poly_idx, delta_secs) in schedule { let get_int = |name: &str| -> Option { params .iter() .rev() .find(|(k, _)| *k == name) .and_then(|(_, v)| { - resolve_value(v, arp_idx, poly_idx).as_int().ok() + resolve_value(v, poly_idx).as_int().ok() }) }; let get_float = |name: &str| -> Option { @@ -1567,7 +1538,7 @@ impl Forth { .rev() .find(|(k, _)| *k == name) .and_then(|(_, v)| { - resolve_value(v, arp_idx, poly_idx).as_float().ok() + resolve_value(v, poly_idx).as_float().ok() }) }; let chan = get_int("chan") @@ -1960,10 +1931,6 @@ where F: Fn(f64) -> f64 + Copy, { match val { - Value::ArpList(items) => { - let mapped: Result, _> = items.iter().map(|x| lift_unary(x, f)).collect(); - Ok(Value::ArpList(Arc::from(mapped?))) - } Value::CycleList(items) => { let mapped: Result, _> = items.iter().map(|x| lift_unary(x, f)).collect(); Ok(Value::CycleList(Arc::from(mapped?))) @@ -1977,11 +1944,6 @@ where F: Fn(i64) -> i64 + Copy, { match val { - Value::ArpList(items) => { - let mapped: Result, _> = - items.iter().map(|x| lift_unary_int(x, f)).collect(); - Ok(Value::ArpList(Arc::from(mapped?))) - } Value::CycleList(items) => { let mapped: Result, _> = items.iter().map(|x| lift_unary_int(x, f)).collect(); @@ -1996,16 +1958,6 @@ where F: Fn(f64, f64) -> f64 + Copy, { match (a, b) { - (Value::ArpList(items), b) => { - let mapped: Result, _> = - items.iter().map(|x| lift_binary(x, b, f)).collect(); - Ok(Value::ArpList(Arc::from(mapped?))) - } - (a, Value::ArpList(items)) => { - let mapped: Result, _> = - items.iter().map(|x| lift_binary(a, x, f)).collect(); - Ok(Value::ArpList(Arc::from(mapped?))) - } (Value::CycleList(items), b) => { let mapped: Result, _> = items.iter().map(|x| lift_binary(x, b, f)).collect(); @@ -2045,11 +1997,8 @@ where Ok(()) } -fn resolve_value(val: &Value, arp_idx: usize, poly_idx: usize) -> Cow<'_, Value> { +fn resolve_value(val: &Value, poly_idx: usize) -> Cow<'_, Value> { match val { - Value::ArpList(items) if !items.is_empty() => { - Cow::Owned(items[arp_idx % items.len()].clone()) - } Value::CycleList(items) if !items.is_empty() => { Cow::Owned(items[poly_idx % items.len()].clone()) } diff --git a/crates/forth/src/words/compile.rs b/crates/forth/src/words/compile.rs index a25d13e..2e04751 100644 --- a/crates/forth/src/words/compile.rs +++ b/crates/forth/src/words/compile.rs @@ -88,7 +88,7 @@ pub(super) fn simple_op(name: &str) -> Option { "tempo!" => Op::SetTempo, "speed!" => Op::SetSpeed, "at" => Op::At, - "arp" => Op::Arp, + "adsr" => Op::Adsr, "ad" => Op::Ad, "apply" => Op::Apply, diff --git a/crates/forth/src/words/sequencing.rs b/crates/forth/src/words/sequencing.rs index 7b0fd3d..338a73e 100644 --- a/crates/forth/src/words/sequencing.rs +++ b/crates/forth/src/words/sequencing.rs @@ -309,9 +309,9 @@ pub(super) const WORDS: &[Word] = &[ name: "at", aliases: &[], category: "Time", - stack: "(v1..vn --)", - desc: "Set delta context for emit timing", - example: "0 0.5 at kick s . => emits at 0 and 0.5 of step", + stack: "(v1..vn -- )", + desc: "Looping block: re-executes body per delta. Close with . (audio), m. (MIDI), or done (no emit)", + example: "0 0.5 at kick snd 1 2 rand freq . | 0 0.5 at 60 note m. | 0 0.5 at !x done", compile: Simple, varargs: true, }, diff --git a/crates/forth/src/words/sound.rs b/crates/forth/src/words/sound.rs index 41505f6..428475f 100644 --- a/crates/forth/src/words/sound.rs +++ b/crates/forth/src/words/sound.rs @@ -24,16 +24,6 @@ pub(super) const WORDS: &[Word] = &[ compile: Simple, varargs: false, }, - Word { - name: "arp", - aliases: &[], - category: "Sound", - stack: "(v1..vn -- arplist)", - desc: "Wrap stack values as arpeggio list for spreading across deltas", - example: "c4 e4 g4 b4 arp note => arpeggio", - compile: Simple, - varargs: true, - }, Word { name: "clear", aliases: &[], diff --git a/demos/01.cagire b/demos/01.cagire index f1f76d2..23ff5fd 100644 --- a/demos/01.cagire +++ b/demos/01.cagire @@ -7,7 +7,7 @@ "steps": [ { "i": 0, - "script": "0 7 .. at\n c2 maj9 arp note\n wide bigverb mysynth \n 2000 1000 0.4 0.8 rand expslide llpf\n 0.4 0.8 rand llpq\n ." + "script": "0 7 .. at\n mysynth [ c2 maj9 ] cycle note\n wide bigverb\n 2000 1000 0.4 0.8 rand expslide llpf\n 0.4 0.8 rand llpq\n ." }, { "i": 8, diff --git a/docs/tutorials/at.md b/docs/tutorials/at.md index a58e827..ca40027 100644 --- a/docs/tutorials/at.md +++ b/docs/tutorials/at.md @@ -34,9 +34,9 @@ clear snare snd . ;; 1 snare (deltas cleared) ``` -## Cross-product: at Without arp +## Polyphonic at -Without `arp`, deltas multiply with polyphonic voices. If you have 3 notes and 2 deltas, you get 6 emits -- every note at every delta: +Deltas multiply with polyphonic voices. If you have 3 notes and 2 deltas, you get 6 emits — every note at every delta: ```forth 0 0.5 at @@ -45,27 +45,25 @@ c4 e4 g4 note 1.5 decay sine snd . 6 emits: 3 notes x 2 deltas. A chord played twice per step. -## 1:1 Pairing: at With arp +## Arpeggios with at + cycle -`arp` changes the behavior. Instead of cross-product, deltas and arp values pair up 1:1. Each delta gets one note from the arpeggio: +Use `cycle` inside an `at` block to pick one note per subdivision: ```forth 0 0.33 0.66 at -c4 e4 g4 arp note 0.5 decay sine snd . +sine snd [ c4 e4 g4 ] cycle note 0.5 decay . ``` -C4 at 0, E4 at 0.33, G4 at 0.66. +C4 at 0, E4 at 0.33, G4 at 0.66. `cycle` advances per iteration of the at-loop. -If the lists differ in length, the shorter one wraps around: +If the list is shorter than the number of deltas, it wraps: ```forth 0 0.25 0.5 0.75 at -c4 e4 arp note 0.3 decay sine snd . +sine snd [ c4 e4 ] cycle note 0.3 decay . ``` -C4, E4, C4, E4 — the shorter list wraps to fill 4 time points. - -This is THE key distinction. Without `arp`: every note at every time. With `arp`: one note per time slot. +C4, E4, C4, E4 — wraps to fill 4 time points. ## Generating Deltas diff --git a/docs/tutorials/generators.md b/docs/tutorials/generators.md index 2506e2b..5b9d4ab 100644 --- a/docs/tutorials/generators.md +++ b/docs/tutorials/generators.md @@ -1,6 +1,6 @@ # Generators & Sequences -Sequences of values drive music: arpeggios, parameter sweeps, rhythmic patterns. Cagire has dedicated words for building sequences on the stack, transforming them, and collapsing them to single values. +Sequences of values drive music: melodic patterns, parameter sweeps, rhythmic patterns. Cagire has dedicated words for building sequences on the stack, transforming them, and collapsing them to single values. ## Ranges @@ -81,7 +81,7 @@ Four words reshape values already on the stack. All take n (the count of items t ```forth 1 2 3 4 4 rev ;; 4 3 2 1 -c4 e4 g4 3 rev ;; g4 e4 c4 (descending arpeggio) +c4 e4 g4 3 rev ;; g4 e4 c4 (descending) ``` `shuffle` randomizes order: diff --git a/docs/tutorials/harmony.md b/docs/tutorials/harmony.md index e381e64..88bcfc1 100644 --- a/docs/tutorials/harmony.md +++ b/docs/tutorials/harmony.md @@ -302,10 +302,10 @@ Combine with voicings for smoother voice leading: note 1.5 decay saw snd . ``` -Arpeggiate diatonic chords using `arp` (see the *Timing with at* tutorial for details on `arp`): +Arpeggiate diatonic chords using `at` + `cycle` (see the *Timing with at* tutorial): ```forth -0 major seventh arp note 0.5 decay sine snd . +0 0.25 0.5 0.75 at sine snd [ 0 major seventh ] cycle note 0.5 decay . ``` ## Frequency Conversion diff --git a/tests/forth/midi.rs b/tests/forth/midi.rs index eef2c80..f3fb6c5 100644 --- a/tests/forth/midi.rs +++ b/tests/forth/midi.rs @@ -253,19 +253,12 @@ fn test_midi_at_with_polyphony() { } #[test] -fn test_midi_arp_notes() { - let outputs = expect_outputs("c4 e4 g4 arp note m.", 3); - assert!(outputs[0].contains("/note/60/")); - assert!(outputs[1].contains("/note/64/")); - assert!(outputs[2].contains("/note/67/")); -} - -#[test] -fn test_midi_arp_with_at() { - let outputs = expect_outputs("0 0.25 0.5 at c4 e4 g4 arp note m.", 3); - assert!(outputs[0].contains("/note/60/")); - assert!(outputs[1].contains("/note/64/")); - assert!(outputs[2].contains("/note/67/")); +fn test_midi_at_loop_notes() { + // at-loop with m. closer: 3 iterations, each emits one MIDI note + let outputs = expect_outputs("0 0.25 0.5 at 60 note m.", 3); + for o in &outputs { + assert!(o.contains("/note/60/")); + } } #[test] diff --git a/tests/forth/temporal.rs b/tests/forth/temporal.rs index bec08e3..3f45edc 100644 --- a/tests/forth/temporal.rs +++ b/tests/forth/temporal.rs @@ -171,7 +171,9 @@ fn at_three_deltas() { #[test] fn at_persists_across_emits() { - let outputs = expect_outputs(r#"0 0.5 at "kick" snd . "hat" snd ."#, 4); + // With at as a loop, each at...block is independent. + // Two separate at blocks each emit twice. + let outputs = expect_outputs(r#"0 0.5 at "kick" snd . 0 0.5 at "hat" snd ."#, 4); let sounds = get_sounds(&outputs); assert_eq!(sounds, vec!["kick", "kick", "hat", "hat"]); } @@ -202,17 +204,10 @@ fn at_records_selected_spans() { let script = r#"0 0.5 0.75 at "kick" snd ."#; f.evaluate_with_trace(script, &default_ctx(), &mut trace).unwrap(); - // Should have 6 selected spans: 3 for at deltas + 3 for sound (one per emit) - assert_eq!(trace.selected_spans.len(), 6, "expected 6 selected spans (3 at + 3 sound)"); - - // Verify at delta spans (even indices: 0, 2, 4) - assert_eq!(&script[trace.selected_spans[0].start as usize..trace.selected_spans[0].end as usize], "0"); - assert_eq!(&script[trace.selected_spans[2].start as usize..trace.selected_spans[2].end as usize], "0.5"); - assert_eq!(&script[trace.selected_spans[4].start as usize..trace.selected_spans[4].end as usize], "0.75"); + // With at-loop, each iteration emits once: 3 sound spans total + assert_eq!(trace.selected_spans.len(), 3, "expected 3 selected spans (1 sound per iteration)"); } -// --- arp tests --- - fn get_notes(outputs: &[String]) -> Vec { outputs .iter() @@ -220,16 +215,13 @@ fn get_notes(outputs: &[String]) -> Vec { .collect() } -fn get_gains(outputs: &[String]) -> Vec { - outputs - .iter() - .map(|o| parse_params(o).get("gain").copied().unwrap_or(f64::NAN)) - .collect() -} - #[test] -fn arp_auto_subdivide() { - let outputs = expect_outputs(r#"sine snd c4 e4 g4 b4 arp note ."#, 4); +fn at_loop_with_cycle_notes() { + // at-loop + cycle replaces at + arp: cycle advances per subdivision + let ctx = ctx_with(|c| c.runs = 0); + let f = forth(); + let outputs = f.evaluate(r#"0 0.25 0.5 0.75 at sine snd [ c4 e4 g4 b4 ] cycle note ."#, &ctx).unwrap(); + assert_eq!(outputs.len(), 4); let notes = get_notes(&outputs); assert!(approx_eq(notes[0], 60.0)); assert!(approx_eq(notes[1], 64.0)); @@ -245,104 +237,41 @@ fn arp_auto_subdivide() { } #[test] -fn arp_with_explicit_at() { - let outputs = expect_outputs(r#"0 0.25 0.5 0.75 at sine snd c4 e4 g4 b4 arp note ."#, 4); +fn at_loop_cycle_wraps() { + // cycle inside at-loop wraps when more deltas than items + let ctx = ctx_with(|c| c.runs = 0); + let f = forth(); + let outputs = f.evaluate(r#"0 0.25 0.5 0.75 at sine snd [ c4 e4 ] cycle note ."#, &ctx).unwrap(); + assert_eq!(outputs.len(), 4); let notes = get_notes(&outputs); - assert!(approx_eq(notes[0], 60.0)); - assert!(approx_eq(notes[1], 64.0)); - assert!(approx_eq(notes[2], 67.0)); - assert!(approx_eq(notes[3], 71.0)); - let deltas = get_deltas(&outputs); - let step_dur = 0.125; - let sr: f64 = 48000.0; - assert!(approx_eq(deltas[0], 0.0)); - assert!(approx_eq(deltas[1], (0.25 * step_dur * sr).round())); - assert!(approx_eq(deltas[2], (0.5 * step_dur * sr).round())); - assert!(approx_eq(deltas[3], (0.75 * step_dur * sr).round())); + assert!(approx_eq(notes[0], 60.0)); // idx 0 % 2 = 0 -> c4 + assert!(approx_eq(notes[1], 64.0)); // idx 1 % 2 = 1 -> e4 + assert!(approx_eq(notes[2], 60.0)); // idx 2 % 2 = 0 -> c4 + assert!(approx_eq(notes[3], 64.0)); // idx 3 % 2 = 1 -> e4 } #[test] -fn arp_single_note() { - let outputs = expect_outputs(r#"sine snd c4 arp note ."#, 1); - let notes = get_notes(&outputs); - assert!(approx_eq(notes[0], 60.0)); +fn at_loop_rand_different_per_subdivision() { + // rand inside at-loop produces different values per iteration + let f = forth(); + let outputs = f.evaluate(r#"0 0.5 at sine snd 1 1000 rand freq ."#, &default_ctx()).unwrap(); + assert_eq!(outputs.len(), 2); + let freqs: Vec = outputs.iter() + .map(|o| parse_params(o).get("freq").copied().unwrap_or(0.0)) + .collect(); + assert!(freqs[0] != freqs[1], "rand should produce different values: {} vs {}", freqs[0], freqs[1]); } #[test] -fn arp_fewer_deltas_than_notes() { - let outputs = expect_outputs(r#"0 0.5 at sine snd c4 e4 g4 b4 arp note ."#, 4); - let notes = get_notes(&outputs); - assert!(approx_eq(notes[0], 60.0)); - assert!(approx_eq(notes[1], 64.0)); - assert!(approx_eq(notes[2], 67.0)); - assert!(approx_eq(notes[3], 71.0)); - let deltas = get_deltas(&outputs); - let step_dur = 0.125; - let sr: f64 = 48000.0; - assert!(approx_eq(deltas[0], 0.0)); - assert!(approx_eq(deltas[1], (0.5 * step_dur * sr).round())); - assert!(approx_eq(deltas[2], 0.0)); // wraps: 2 % 2 = 0 - assert!(approx_eq(deltas[3], (0.5 * step_dur * sr).round())); // wraps: 3 % 2 = 1 -} - -#[test] -fn arp_fewer_notes_than_deltas() { - let outputs = expect_outputs(r#"0 0.25 0.5 0.75 at sine snd c4 e4 arp note ."#, 4); - let notes = get_notes(&outputs); - assert!(approx_eq(notes[0], 60.0)); - assert!(approx_eq(notes[1], 64.0)); - assert!(approx_eq(notes[2], 60.0)); // wraps - assert!(approx_eq(notes[3], 64.0)); // wraps -} - -#[test] -fn arp_multiple_params() { - let outputs = expect_outputs(r#"sine snd c4 e4 g4 arp note 0.5 0.7 0.9 arp gain ."#, 3); - let notes = get_notes(&outputs); - assert!(approx_eq(notes[0], 60.0)); - assert!(approx_eq(notes[1], 64.0)); - assert!(approx_eq(notes[2], 67.0)); - let gains = get_gains(&outputs); - assert!(approx_eq(gains[0], 0.5)); - assert!(approx_eq(gains[1], 0.7)); - assert!(approx_eq(gains[2], 0.9)); -} - -#[test] -fn arp_no_arp_unchanged() { - // Standard CycleList without arp → cross-product (backward compat) +fn at_loop_poly_cycling() { + // CycleList inside at-loop: poly stacking within each iteration let outputs = expect_outputs(r#"0 0.5 at sine snd c4 e4 note ."#, 4); let notes = get_notes(&outputs); - // Cross-product: each note at each delta - assert!(approx_eq(notes[0], 60.0)); - assert!(approx_eq(notes[1], 60.0)); - assert!(approx_eq(notes[2], 64.0)); - assert!(approx_eq(notes[3], 64.0)); -} - -#[test] -fn arp_mixed_cycle_and_arp() { - // CycleList sound (2) + ArpList note (3) → 3 arp × 2 poly = 6 voices - // Each arp step plays both sine and saw simultaneously (poly stacking) - let outputs = expect_outputs(r#"sine saw snd c4 e4 g4 arp note ."#, 6); - let sounds = get_sounds(&outputs); - // Arp step 0: poly 0=sine, poly 1=saw - assert_eq!(sounds[0], "sine"); - assert_eq!(sounds[1], "saw"); - // Arp step 1: poly 0=sine, poly 1=saw - assert_eq!(sounds[2], "sine"); - assert_eq!(sounds[3], "saw"); - // Arp step 2: poly 0=sine, poly 1=saw - assert_eq!(sounds[4], "sine"); - assert_eq!(sounds[5], "saw"); - let notes = get_notes(&outputs); - // Both poly voices in each arp step share the same note - assert!(approx_eq(notes[0], 60.0)); - assert!(approx_eq(notes[1], 60.0)); - assert!(approx_eq(notes[2], 64.0)); - assert!(approx_eq(notes[3], 64.0)); - assert!(approx_eq(notes[4], 67.0)); - assert!(approx_eq(notes[5], 67.0)); + // Each iteration emits both poly voices (c4 and e4) + assert!(approx_eq(notes[0], 60.0)); // iter 0, poly 0 + assert!(approx_eq(notes[1], 64.0)); // iter 0, poly 1 + assert!(approx_eq(notes[2], 60.0)); // iter 1, poly 0 + assert!(approx_eq(notes[3], 64.0)); // iter 1, poly 1 } // --- every+ / except+ tests --- @@ -400,3 +329,102 @@ fn every_offset_zero_is_same_as_every() { assert_eq!(a.len(), b.len(), "iter={}: every and every+ 0 should match", iter); } } + +// --- at-loop feature tests --- + +#[test] +fn at_loop_choose_independent_per_subdivision() { + let f = forth(); + let outputs = f.evaluate(r#"0 0.5 at sine snd [ 60 64 67 71 ] choose note ."#, &default_ctx()).unwrap(); + assert_eq!(outputs.len(), 2); + // Both are valid notes from the set (just verify they're within range) + let notes = get_notes(&outputs); + for n in ¬es { + assert!([60.0, 64.0, 67.0, 71.0].contains(n), "unexpected note {n}"); + } +} + +#[test] +fn at_loop_multiple_blocks_independent() { + let outputs = expect_outputs( + r#"0 0.5 at "kick" snd . 0 0.25 0.5 at "hat" snd ."#, + 5, + ); + let sounds = get_sounds(&outputs); + assert_eq!(sounds[0], "kick"); + assert_eq!(sounds[1], "kick"); + assert_eq!(sounds[2], "hat"); + assert_eq!(sounds[3], "hat"); + assert_eq!(sounds[4], "hat"); +} + +#[test] +fn at_loop_single_delta_one_iteration() { + let outputs = expect_outputs(r#"0.25 at "kick" snd ."#, 1); + let sounds = get_sounds(&outputs); + assert_eq!(sounds[0], "kick"); + let deltas = get_deltas(&outputs); + let step_dur = 0.125; + let sr: f64 = 48000.0; + assert!(approx_eq(deltas[0], (0.25 * step_dur * sr).round())); +} + +#[test] +fn at_without_closer_falls_back_to_legacy() { + // When at has no matching closer (., m., done), falls back to Op::At + let f = forth(); + let result = f.evaluate(r#"0 0.5 at "kick" snd"#, &default_ctx()); + assert!(result.is_ok()); +} + +#[test] +fn at_loop_cycle_advances_across_runs() { + // Across different runs values, cycle inside at-loop picks correctly + for base_runs in 0..3 { + let ctx = ctx_with(|c| c.runs = base_runs); + let f = forth(); + let outputs = f.evaluate( + r#"0 0.5 at sine snd [ c4 e4 g4 ] cycle note ."#, + &ctx, + ).unwrap(); + assert_eq!(outputs.len(), 2); + let notes = get_notes(&outputs); + // runs for iter i = base_runs * 2 + i + let expected_0 = [60.0, 64.0, 67.0][(base_runs * 2) % 3]; + let expected_1 = [60.0, 64.0, 67.0][(base_runs * 2 + 1) % 3]; + assert!(approx_eq(notes[0], expected_0), "runs={base_runs}: iter 0 expected {expected_0}, got {}", notes[0]); + assert!(approx_eq(notes[1], expected_1), "runs={base_runs}: iter 1 expected {expected_1}, got {}", notes[1]); + } +} + +#[test] +fn at_loop_midi_emit() { + let f = forth(); + let outputs = f.evaluate("0 0.25 0.5 at 60 note m.", &default_ctx()).unwrap(); + assert_eq!(outputs.len(), 3); + for o in &outputs { + assert!(o.contains("/midi/note/60/")); + } + // First should have no delta (or delta/0), others should have delta + assert!(outputs[1].contains("/delta/")); + assert!(outputs[2].contains("/delta/")); +} + +#[test] +fn at_loop_done_no_emit() { + let f = forth(); + let outputs = f.evaluate("0 0.5 at [ 1 2 ] cycle drop done", &default_ctx()).unwrap(); + assert!(outputs.is_empty()); +} + +#[test] +fn at_loop_done_sets_variables() { + let f = forth(); + let outputs = f + .evaluate("0 0.5 at [ 10 20 ] cycle !x done kick snd @x freq .", &default_ctx()) + .unwrap(); + assert_eq!(outputs.len(), 1); + // Last iteration wins: cycle(1) = 20 + let params = parse_params(&outputs[0]); + assert!(approx_eq(*params.get("freq").unwrap(), 20.0)); +}