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

@@ -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));
}