Feat: improve 'at' in cagire grammar

This commit is contained in:
2026-03-20 23:29:47 +01:00
parent 609fe108bc
commit f020b5a172
13 changed files with 293 additions and 277 deletions

View File

@@ -176,6 +176,13 @@ fn compile(tokens: &[Token], dict: &Dictionary) -> Result<Vec<Op>, 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<Option<(Vec<Op>, 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<Op>, usize), String> {
let mut depth = 1;
let mut endcase_pos = None;

View File

@@ -110,7 +110,8 @@ pub enum Op {
ClearCmd,
SetSpeed,
At,
Arp,
AtLoop(Arc<[Op]>),
IntRange,
StepRange,
Generate,

View File

@@ -96,7 +96,7 @@ pub enum Value {
Str(Arc<str>, Option<SourceSpan>),
Quotation(Arc<[Op]>, Option<SourceSpan>),
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<SourceSpan> {
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<Value>,
global_params: Vec<(&'static str, Value)>,
delta_secs: Option<f64>,
}
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<f64> {
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;
}
}

View File

@@ -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<String>|
@@ -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<Value> = 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<f64> = 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<i64> {
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<f64> {
@@ -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<Vec<_>, _> = items.iter().map(|x| lift_unary(x, f)).collect();
Ok(Value::ArpList(Arc::from(mapped?)))
}
Value::CycleList(items) => {
let mapped: Result<Vec<_>, _> = 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<Vec<_>, _> =
items.iter().map(|x| lift_unary_int(x, f)).collect();
Ok(Value::ArpList(Arc::from(mapped?)))
}
Value::CycleList(items) => {
let mapped: Result<Vec<_>, _> =
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<Vec<_>, _> =
items.iter().map(|x| lift_binary(x, b, f)).collect();
Ok(Value::ArpList(Arc::from(mapped?)))
}
(a, Value::ArpList(items)) => {
let mapped: Result<Vec<_>, _> =
items.iter().map(|x| lift_binary(a, x, f)).collect();
Ok(Value::ArpList(Arc::from(mapped?)))
}
(Value::CycleList(items), b) => {
let mapped: Result<Vec<_>, _> =
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())
}

View File

@@ -88,7 +88,7 @@ pub(super) fn simple_op(name: &str) -> Option<Op> {
"tempo!" => Op::SetTempo,
"speed!" => Op::SetSpeed,
"at" => Op::At,
"arp" => Op::Arp,
"adsr" => Op::Adsr,
"ad" => Op::Ad,
"apply" => Op::Apply,

View File

@@ -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,
},

View File

@@ -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: &[],

View File

@@ -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,

View File

@@ -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

View File

@@ -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:

View File

@@ -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

View File

@@ -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]

View File

@@ -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<f64> {
outputs
.iter()
@@ -220,16 +215,13 @@ fn get_notes(outputs: &[String]) -> Vec<f64> {
.collect()
}
fn get_gains(outputs: &[String]) -> Vec<f64> {
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<f64> = 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 &notes {
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));
}