Files
Cagire/tests/forth/temporal.rs
2026-03-18 02:16:05 +01:00

403 lines
13 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

use super::harness::*;
use std::collections::HashMap;
fn parse_params(output: &str) -> HashMap<String, f64> {
let mut params = HashMap::new();
let parts: Vec<&str> = output.trim_start_matches('/').split('/').collect();
let mut i = 0;
while i + 1 < parts.len() {
if let Ok(v) = parts[i + 1].parse::<f64>() {
params.insert(parts[i].to_string(), v);
}
i += 2;
}
params
}
fn get_deltas(outputs: &[String]) -> Vec<f64> {
outputs
.iter()
.map(|o| parse_params(o).get("delta").copied().unwrap_or(0.0))
.collect()
}
fn get_gates(outputs: &[String]) -> Vec<f64> {
outputs
.iter()
.map(|o| parse_params(o).get("gate").copied().unwrap_or(0.0))
.collect()
}
fn get_sounds(outputs: &[String]) -> Vec<String> {
outputs
.iter()
.map(|o| {
let parts: Vec<&str> = o.trim_start_matches('/').split('/').collect();
if parts.len() >= 2 && parts[0] == "sound" {
parts[1].to_string()
} else {
String::new()
}
})
.collect()
}
const EPSILON: f64 = 1e-9;
fn approx_eq(a: f64, b: f64) -> bool {
(a - b).abs() < EPSILON
}
#[test]
fn stepdur_baseline() {
let f = run("stepdur");
assert!(approx_eq(stack_float(&f), 0.125));
}
#[test]
fn single_emit() {
let outputs = expect_outputs(r#""kick" snd ."#, 1);
let deltas = get_deltas(&outputs);
assert!(approx_eq(deltas[0], 0.0), "single emit at start should have delta 0");
}
#[test]
fn multiple_emits_all_at_zero() {
let outputs = expect_outputs(r#""kick" snd . . . ."#, 4);
let deltas = get_deltas(&outputs);
for (i, delta) in deltas.iter().enumerate() {
assert!(approx_eq(*delta, 0.0), "emit {}: expected delta 0, got {}", i, delta);
}
}
#[test]
fn sound_persists() {
let outputs = expect_outputs(r#""kick" snd . . "hat" snd . ."#, 4);
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");
}
#[test]
fn alternating_sounds() {
let outputs = expect_outputs(r#""kick" snd . "snare" snd . "kick" snd . "snare" snd ."#, 4);
let sounds = get_sounds(&outputs);
assert_eq!(sounds, vec!["kick", "snare", "kick", "snare"]);
}
#[test]
fn gate_is_step_duration() {
let outputs = expect_outputs(r#""kick" snd ."#, 1);
let gates = get_gates(&outputs);
assert!(approx_eq(gates[0], 0.5), "gate should be 4 * step_duration (0.5), got {}", gates[0]);
}
#[test]
fn cycle_picks_by_runs() {
for runs in 0..4 {
let ctx = ctx_with(|c| c.runs = runs);
let f = forth();
let outputs = f.evaluate(r#""kick" snd ( . ) ( ) 2 cycle"#, &ctx).unwrap();
if runs % 2 == 0 {
assert_eq!(outputs.len(), 1, "runs={}: emit should be picked", runs);
} else {
assert_eq!(outputs.len(), 0, "runs={}: no-op should be picked", runs);
}
}
}
#[test]
fn pcycle_picks_by_iter() {
for iter in 0..4 {
let ctx = ctx_with(|c| c.iter = iter);
let f = forth();
let outputs = f.evaluate(r#""kick" snd ( . ) ( ) 2 pcycle"#, &ctx).unwrap();
if iter % 2 == 0 {
assert_eq!(outputs.len(), 1, "iter={}: emit should be picked", iter);
} else {
assert_eq!(outputs.len(), 0, "iter={}: no-op should be picked", iter);
}
}
}
#[test]
fn cycle_with_sounds() {
for runs in 0..3 {
let ctx = ctx_with(|c| c.runs = runs);
let f = forth();
let outputs = f.evaluate(
r#"( "kick" snd . ) ( "hat" snd . ) ( "snare" snd . ) 3 cycle"#,
&ctx
).unwrap();
assert_eq!(outputs.len(), 1, "runs={}: expected 1 output", runs);
let sounds = get_sounds(&outputs);
let expected = ["kick", "hat", "snare"][runs % 3];
assert_eq!(sounds[0], expected, "runs={}: expected {}", runs, expected);
}
}
#[test]
fn at_single_delta() {
let outputs = expect_outputs(r#"0.5 at "kick" snd ."#, 1);
let deltas = get_deltas(&outputs);
let step_dur = 0.125;
let sr: f64 = 48000.0;
assert!(approx_eq(deltas[0], (0.5 * step_dur * sr).round()), "expected delta at 0.5 of step, got {}", deltas[0]);
}
#[test]
fn at_list_deltas() {
let outputs = expect_outputs(r#"0 0.5 at "kick" snd ."#, 2);
let deltas = get_deltas(&outputs);
let step_dur = 0.125;
let sr: f64 = 48000.0;
assert!(approx_eq(deltas[0], 0.0), "expected delta 0, got {}", deltas[0]);
assert!(approx_eq(deltas[1], (0.5 * step_dur * sr).round()), "expected delta at 0.5 of step, got {}", deltas[1]);
}
#[test]
fn at_three_deltas() {
let outputs = expect_outputs(r#"0 0.33 0.67 at "kick" snd ."#, 3);
let deltas = get_deltas(&outputs);
let step_dur = 0.125;
let sr: f64 = 48000.0;
assert!(approx_eq(deltas[0], 0.0), "expected delta 0");
assert!(approx_eq(deltas[1], (0.33 * step_dur * sr).round()), "expected delta at 0.33 of step");
assert!(approx_eq(deltas[2], (0.67 * step_dur * sr).round()), "expected delta at 0.67 of step");
}
#[test]
fn at_persists_across_emits() {
let outputs = expect_outputs(r#"0 0.5 at "kick" snd . "hat" snd ."#, 4);
let sounds = get_sounds(&outputs);
assert_eq!(sounds, vec!["kick", "kick", "hat", "hat"]);
}
#[test]
fn at_reset_with_zero() {
let outputs = expect_outputs(r#"0 0.5 at "kick" snd . 0.0 at "hat" snd ."#, 3);
let sounds = get_sounds(&outputs);
assert_eq!(sounds, vec!["kick", "kick", "hat"]);
}
#[test]
fn clear_resets_at_deltas() {
let outputs = expect_outputs(r#"0 0.5 at "kick" snd . clear "hat" snd ."#, 3);
let sounds = get_sounds(&outputs);
assert_eq!(sounds, vec!["kick", "kick", "hat"]);
let deltas = get_deltas(&outputs);
assert!(approx_eq(deltas[2], 0.0), "after clear, hat should emit at delta 0, got {}", deltas[2]);
}
#[test]
fn at_records_selected_spans() {
use cagire::forth::ExecutionTrace;
let f = forth();
let mut trace = ExecutionTrace::default();
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");
}
// --- arp tests ---
fn get_notes(outputs: &[String]) -> Vec<f64> {
outputs
.iter()
.map(|o| parse_params(o).get("note").copied().unwrap_or(0.0))
.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);
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()));
}
#[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);
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()));
}
#[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));
}
#[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)
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));
}
// --- every+ / except+ tests ---
#[test]
fn every_offset_fires_at_offset() {
for iter in 0..8 {
let ctx = ctx_with(|c| c.iter = iter);
let f = forth();
let outputs = f.evaluate(r#""kick" snd ( . ) 4 2 every+"#, &ctx).unwrap();
if iter % 4 == 2 {
assert_eq!(outputs.len(), 1, "iter={}: should fire", iter);
} else {
assert_eq!(outputs.len(), 0, "iter={}: should not fire", iter);
}
}
}
#[test]
fn every_offset_wraps_large_offset() {
// offset 6 with n=4 → 6 % 4 = 2, same as offset 2
for iter in 0..8 {
let ctx = ctx_with(|c| c.iter = iter);
let f = forth();
let outputs = f.evaluate(r#""kick" snd ( . ) 4 6 every+"#, &ctx).unwrap();
if iter % 4 == 2 {
assert_eq!(outputs.len(), 1, "iter={}: should fire (wrapped offset)", iter);
} else {
assert_eq!(outputs.len(), 0, "iter={}: should not fire", iter);
}
}
}
#[test]
fn except_offset_inverse() {
for iter in 0..8 {
let ctx = ctx_with(|c| c.iter = iter);
let f = forth();
let outputs = f.evaluate(r#""kick" snd ( . ) 4 2 except+"#, &ctx).unwrap();
if iter % 4 != 2 {
assert_eq!(outputs.len(), 1, "iter={}: should fire", iter);
} else {
assert_eq!(outputs.len(), 0, "iter={}: should not fire", iter);
}
}
}
#[test]
fn every_offset_zero_is_same_as_every() {
for iter in 0..8 {
let ctx = ctx_with(|c| c.iter = iter);
let f = forth();
let a = f.evaluate(r#""kick" snd ( . ) 3 every"#, &ctx).unwrap();
let b = f.evaluate(r#""kick" snd ( . ) 3 0 every+"#, &ctx).unwrap();
assert_eq!(a.len(), b.len(), "iter={}: every and every+ 0 should match", iter);
}
}