big commit

This commit is contained in:
2026-01-27 01:04:08 +01:00
parent 9e597258e4
commit 61daa9d79d
15 changed files with 821 additions and 222 deletions

View File

@@ -25,6 +25,11 @@ pub enum Op {
Round,
Min,
Max,
Pow,
Sqrt,
Sin,
Cos,
Log,
Eq,
Ne,
Lt,
@@ -34,6 +39,11 @@ pub enum Op {
And,
Or,
Not,
Xor,
Nand,
Nor,
IfElse,
Pick,
BranchIfZero(usize, Option<SourceSpan>, Option<SourceSpan>),
Branch(usize),
NewCmd,
@@ -67,8 +77,9 @@ pub enum Op {
Ad,
Apply,
Ramp,
Tri,
Range,
Noise,
Perlin,
Chain,
Loop,
Degree(&'static [i64]),

View File

@@ -150,6 +150,15 @@ pub(super) struct PendingEmission {
pub slot_index: usize,
}
#[derive(Clone, Debug)]
pub(super) struct ResolvedEmission {
pub sound: String,
pub params: Vec<(String, String)>,
pub parent_slot: usize,
pub offset_in_slot: f64,
pub dur: f64,
}
#[derive(Clone, Debug)]
pub(super) struct ScopeContext {
pub start: f64,
@@ -157,6 +166,7 @@ pub(super) struct ScopeContext {
pub weight: f64,
pub slot_count: usize,
pub pending: Vec<PendingEmission>,
pub resolved: Vec<ResolvedEmission>,
pub stacked: bool,
}
@@ -168,6 +178,7 @@ impl ScopeContext {
weight: 1.0,
slot_count: 0,
pending: Vec::new(),
resolved: Vec::new(),
stacked: false,
}
}

View File

@@ -4,8 +4,8 @@ use rand::{Rng as RngTrait, SeedableRng};
use super::compiler::compile_script;
use super::ops::Op;
use super::types::{
CmdRegister, Dictionary, ExecutionTrace, PendingEmission, Rng, ScopeContext, SourceSpan, Stack,
StepContext, Value, Variables,
CmdRegister, Dictionary, ExecutionTrace, PendingEmission, ResolvedEmission, Rng, ScopeContext,
SourceSpan, Stack, StepContext, Value, Variables,
};
pub type EmissionCounter = std::sync::Arc<std::sync::Mutex<usize>>;
@@ -220,6 +220,23 @@ impl Forth {
}
Op::Min => binary_op(stack, |a, b| a.min(b))?,
Op::Max => binary_op(stack, |a, b| a.max(b))?,
Op::Pow => binary_op(stack, |a, b| a.powf(b))?,
Op::Sqrt => {
let v = stack.pop().ok_or("stack underflow")?;
stack.push(lift_unary(v, |x| x.sqrt())?);
}
Op::Sin => {
let v = stack.pop().ok_or("stack underflow")?;
stack.push(lift_unary(v, |x| x.sin())?);
}
Op::Cos => {
let v = stack.pop().ok_or("stack underflow")?;
stack.push(lift_unary(v, |x| x.cos())?);
}
Op::Log => {
let v = stack.pop().ok_or("stack underflow")?;
stack.push(lift_unary(v, |x| x.ln())?);
}
Op::Eq => cmp_op(stack, |a, b| (a - b).abs() < f64::EPSILON)?,
Op::Ne => cmp_op(stack, |a, b| (a - b).abs() >= f64::EPSILON)?,
@@ -242,6 +259,21 @@ impl Forth {
let v = stack.pop().ok_or("stack underflow")?.is_truthy();
stack.push(Value::Int(if v { 0 } else { 1 }, None));
}
Op::Xor => {
let b = stack.pop().ok_or("stack underflow")?.is_truthy();
let a = stack.pop().ok_or("stack underflow")?.is_truthy();
stack.push(Value::Int(if a ^ b { 1 } else { 0 }, None));
}
Op::Nand => {
let b = stack.pop().ok_or("stack underflow")?.is_truthy();
let a = stack.pop().ok_or("stack underflow")?.is_truthy();
stack.push(Value::Int(if !(a && b) { 1 } else { 0 }, None));
}
Op::Nor => {
let b = stack.pop().ok_or("stack underflow")?.is_truthy();
let a = stack.pop().ok_or("stack underflow")?.is_truthy();
stack.push(Value::Int(if !(a || b) { 1 } else { 0 }, None));
}
Op::BranchIfZero(offset, then_span, else_span) => {
let v = stack.pop().ok_or("stack underflow")?;
@@ -630,6 +662,80 @@ impl Forth {
}
}
Op::IfElse => {
let cond = stack.pop().ok_or("stack underflow")?;
let false_quot = stack.pop().ok_or("stack underflow")?;
let true_quot = stack.pop().ok_or("stack underflow")?;
let quot = if cond.is_truthy() { true_quot } else { false_quot };
match quot {
Value::Quotation(quot_ops, body_span) => {
if let Some(span) = body_span {
if let Some(trace) = trace_cell.borrow_mut().as_mut() {
trace.executed_spans.push(span);
}
}
let mut trace_opt = trace_cell.borrow_mut().take();
self.execute_ops(
&quot_ops,
ctx,
stack,
outputs,
scope_stack,
cmd,
trace_opt.as_deref_mut(),
emission_count,
)?;
*trace_cell.borrow_mut() = trace_opt;
}
_ => return Err("expected quotation".into()),
}
}
Op::Pick => {
let idx = stack.pop().ok_or("stack underflow")?.as_int()? as usize;
let mut quots: Vec<Value> = Vec::new();
while let Some(val) = stack.pop() {
match &val {
Value::Quotation(_, _) => quots.push(val),
_ => {
stack.push(val);
break;
}
}
}
quots.reverse();
if idx >= quots.len() {
return Err(format!(
"pick index {} out of range (have {} quotations)",
idx,
quots.len()
)
.into());
}
match &quots[idx] {
Value::Quotation(quot_ops, body_span) => {
if let Some(span) = body_span {
if let Some(trace) = trace_cell.borrow_mut().as_mut() {
trace.executed_spans.push(*span);
}
}
let mut trace_opt = trace_cell.borrow_mut().take();
self.execute_ops(
quot_ops,
ctx,
stack,
outputs,
scope_stack,
cmd,
trace_opt.as_deref_mut(),
emission_count,
)?;
*trace_cell.borrow_mut() = trace_opt;
}
_ => unreachable!(),
}
}
Op::Mtof => {
let note = stack.pop().ok_or("stack underflow")?.as_float()?;
let freq = 440.0 * 2.0_f64.powf((note - 69.0) / 12.0);
@@ -851,13 +957,20 @@ impl Forth {
let val = phase.powf(curve);
stack.push(Value::Float(val, None));
}
Op::Tri => {
let freq = stack.pop().ok_or("stack underflow")?.as_float()?;
let phase = (freq * ctx.beat).fract();
let phase = if phase < 0.0 { phase + 1.0 } else { phase };
let val = 1.0 - (2.0 * phase - 1.0).abs();
stack.push(Value::Float(val, None));
}
Op::Range => {
let max = stack.pop().ok_or("stack underflow")?.as_float()?;
let min = stack.pop().ok_or("stack underflow")?.as_float()?;
let val = stack.pop().ok_or("stack underflow")?.as_float()?;
stack.push(Value::Float(min + val * (max - min), None));
}
Op::Noise => {
Op::Perlin => {
let freq = stack.pop().ok_or("stack underflow")?.as_float()?;
let val = perlin_noise_1d(freq * ctx.beat);
stack.push(Value::Float(val, None));
@@ -887,10 +1000,19 @@ impl Forth {
Op::DivEnd => {
if scope_stack.len() <= 1 {
return Err("unmatched end".into());
return Err("unmatched ~ (no div/stack to close)".into());
}
let child = scope_stack.pop().unwrap();
if child.stacked {
// Stack doesn't claim a slot - resolve directly to outputs
resolve_scope(&child, outputs);
} else {
// Div claims a slot in the parent
let parent = scope_stack.last_mut().ok_or("scope stack underflow")?;
let parent_slot = parent.claim_slot();
resolve_scope_to_parent(&child, parent_slot, parent);
}
let scope = scope_stack.pop().unwrap();
resolve_scope(&scope, outputs);
}
Op::StackStart => {
@@ -966,28 +1088,89 @@ fn resolve_value_with_span(val: &Value, emission_count: usize) -> (Value, Option
}
fn resolve_scope(scope: &ScopeContext, outputs: &mut Vec<String>) {
if scope.slot_count == 0 || scope.pending.is_empty() {
return;
}
let slot_dur = scope.duration * scope.weight / scope.slot_count as f64;
let slot_dur = if scope.slot_count == 0 {
scope.duration * scope.weight
} else {
scope.duration * scope.weight / scope.slot_count as f64
};
// Collect all emissions with their deltas for sorting
let mut emissions: Vec<(f64, String, Vec<(String, String)>, f64)> = Vec::new();
for em in &scope.pending {
let delta = scope.start + slot_dur * em.slot_index as f64;
let mut pairs = vec![("sound".into(), em.sound.clone())];
pairs.extend(em.params.iter().cloned());
emissions.push((delta, em.sound.clone(), em.params.clone(), slot_dur));
}
for em in &scope.resolved {
let slot_start = slot_dur * em.parent_slot as f64;
let delta = scope.start + slot_start + em.offset_in_slot * slot_dur;
let dur = em.dur * slot_dur;
emissions.push((delta, em.sound.clone(), em.params.clone(), dur));
}
// Sort by delta to ensure temporal ordering
emissions.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
for (delta, sound, params, dur) in emissions {
emit_output(&sound, &params, delta, dur, outputs);
}
}
fn resolve_scope_to_parent(child: &ScopeContext, parent_slot: usize, parent: &mut ScopeContext) {
if child.slot_count == 0 && child.pending.is_empty() && child.resolved.is_empty() {
return;
}
let child_slot_count = child.slot_count.max(1);
// Store offsets and durations as fractions of the parent slot
// Child's internal structure: slot_count slots, each slot is 1/slot_count of the whole
for em in &child.pending {
let offset_fraction = em.slot_index as f64 / child_slot_count as f64;
let dur_fraction = 1.0 / child_slot_count as f64;
parent.resolved.push(ResolvedEmission {
sound: em.sound.clone(),
params: em.params.clone(),
parent_slot,
offset_in_slot: offset_fraction,
dur: dur_fraction,
});
}
// Child's resolved emissions already have fractional offsets/durs relative to their slots
// We need to compose them: em belongs to child slot em.parent_slot, which is a fraction of child
for em in &child.resolved {
let child_slot_offset = em.parent_slot as f64 / child_slot_count as f64;
let child_slot_size = 1.0 / child_slot_count as f64;
let offset_fraction = child_slot_offset + em.offset_in_slot * child_slot_size;
let dur_fraction = em.dur * child_slot_size;
parent.resolved.push(ResolvedEmission {
sound: em.sound.clone(),
params: em.params.clone(),
parent_slot,
offset_in_slot: offset_fraction,
dur: dur_fraction,
});
}
}
fn emit_output(sound: &str, params: &[(String, String)], delta: f64, dur: f64, outputs: &mut Vec<String>) {
let mut pairs = vec![("sound".into(), sound.to_string())];
pairs.extend(params.iter().cloned());
if delta > 0.0 {
pairs.push(("delta".into(), delta.to_string()));
}
if !pairs.iter().any(|(k, _)| k == "dur") {
pairs.push(("dur".into(), slot_dur.to_string()));
pairs.push(("dur".into(), dur.to_string()));
}
if let Some(idx) = pairs.iter().position(|(k, _)| k == "delaytime") {
let ratio: f64 = pairs[idx].1.parse().unwrap_or(1.0);
pairs[idx].1 = (ratio * slot_dur).to_string();
pairs[idx].1 = (ratio * dur).to_string();
} else {
pairs.push(("delaytime".into(), slot_dur.to_string()));
pairs.push(("delaytime".into(), dur.to_string()));
}
outputs.push(format_cmd(&pairs));
}
}
fn perlin_grad(hash_input: i64) -> f64 {
@@ -996,11 +1179,8 @@ fn perlin_grad(hash_input: i64) -> f64 {
h ^= h >> 33;
h = h.wrapping_mul(0xff51afd7ed558ccd);
h ^= h >> 33;
if h & 1 == 0 {
1.0
} else {
-1.0
}
// Convert to float in [-1, 1] range for varied gradients
(h as i64 as f64) / (i64::MAX as f64)
}
fn perlin_noise_1d(x: f64) -> f64 {

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
mod script;
pub use cagire_forth::{Word, WordCompile, WORDS};
pub use cagire_forth::{Word, WORDS};
pub use cagire_project::{
load, save, Bank, LaunchQuantization, Pattern, PatternSpeed, Project, SyncMode, MAX_BANKS,
MAX_PATTERNS,

View File

@@ -5,22 +5,39 @@ use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph};
use ratatui::Frame;
use crate::app::App;
use crate::model::{Word, WordCompile, WORDS};
use crate::model::{Word, WORDS};
use crate::state::DictFocus;
const CATEGORIES: &[&str] = &[
// Forth core
"Stack",
"Arithmetic",
"Comparison",
"Logic",
"Sound",
"Variables",
"Randomness",
"Probability",
"Lists",
"Definitions",
// Live coding
"Sound",
"Time",
"Context",
"Music",
"Time",
"Parameters",
"LFO",
// Synthesis
"Oscillator",
"Envelope",
"Pitch Env",
"Gain",
"Sample",
// Effects
"Filter",
"Modulation",
"Mod FX",
"Lo-fi",
"Delay",
"Reverb",
];
pub fn render(frame: &mut Frame, app: &App, area: Rect) {
@@ -98,7 +115,7 @@ fn render_words(frame: &mut Frame, app: &App, area: Rect, is_searching: bool) {
let category = CATEGORIES[app.ui.dict_category];
WORDS
.iter()
.filter(|w| word_category(w.name, &w.compile) == category)
.filter(|w| w.category == category)
.collect()
};
@@ -193,39 +210,6 @@ fn render_search_bar(frame: &mut Frame, app: &App, area: Rect) {
frame.render_widget(Paragraph::new(vec![line]), area);
}
fn word_category(name: &str, compile: &WordCompile) -> &'static str {
const STACK: &[&str] = &["dup", "drop", "swap", "over", "rot", "nip", "tuck"];
const ARITH: &[&str] = &[
"+", "-", "*", "/", "mod", "neg", "abs", "floor", "ceil", "round", "min", "max",
];
const CMP: &[&str] = &["=", "<>", "<", ">", "<=", ">="];
const LOGIC: &[&str] = &["and", "or", "not"];
const SOUND: &[&str] = &["sound", "s", "emit"];
const VAR: &[&str] = &["get", "set"];
const RAND: &[&str] = &["rand", "rrand", "seed", "coin", "chance", "choose", "cycle"];
const MUSIC: &[&str] = &["mtof", "ftom"];
const TIME: &[&str] = &[
"at", "window", "pop", "div", "each", "tempo!", "[", "]", "?",
];
match compile {
WordCompile::Simple if STACK.contains(&name) => "Stack",
WordCompile::Simple if ARITH.contains(&name) => "Arithmetic",
WordCompile::Simple if CMP.contains(&name) => "Comparison",
WordCompile::Simple if LOGIC.contains(&name) => "Logic",
WordCompile::Simple if SOUND.contains(&name) => "Sound",
WordCompile::Alias(_) => "Sound",
WordCompile::Simple if VAR.contains(&name) => "Variables",
WordCompile::Simple if RAND.contains(&name) => "Randomness",
WordCompile::Probability(_) => "Probability",
WordCompile::Context(_) => "Context",
WordCompile::Simple if MUSIC.contains(&name) => "Music",
WordCompile::Simple if TIME.contains(&name) => "Time",
WordCompile::Param => "Parameters",
_ => "Other",
}
}
pub fn category_count() -> usize {
CATEGORIES.len()
}

View File

@@ -150,3 +150,53 @@ fn chain() {
fn underflow() {
expect_error("1 +", "stack underflow");
}
#[test]
fn pow_int() {
expect_int("2 3 pow", 8);
}
#[test]
fn pow_float() {
expect_float("2 0.5 pow", std::f64::consts::SQRT_2);
}
#[test]
fn sqrt() {
expect_int("16 sqrt", 4);
}
#[test]
fn sqrt_float() {
expect_float("2 sqrt", std::f64::consts::SQRT_2);
}
#[test]
fn sin_zero() {
expect_int("0 sin", 0);
}
#[test]
fn sin_pi_half() {
expect_float("3.14159265358979 2 / sin", 1.0);
}
#[test]
fn cos_zero() {
expect_int("0 cos", 1);
}
#[test]
fn cos_pi() {
expect_int("3.14159265358979 cos", -1);
}
#[test]
fn log_e() {
expect_float("2.718281828459045 log", 1.0);
}
#[test]
fn log_one() {
expect_int("1 log", 0);
}

View File

@@ -134,3 +134,83 @@ fn truthy_nonzero() {
fn truthy_negative() {
expect_int("-1 not", 0);
}
#[test]
fn xor_tt() {
expect_int("1 1 xor", 0);
}
#[test]
fn xor_tf() {
expect_int("1 0 xor", 1);
}
#[test]
fn xor_ft() {
expect_int("0 1 xor", 1);
}
#[test]
fn xor_ff() {
expect_int("0 0 xor", 0);
}
#[test]
fn nand_tt() {
expect_int("1 1 nand", 0);
}
#[test]
fn nand_tf() {
expect_int("1 0 nand", 1);
}
#[test]
fn nand_ff() {
expect_int("0 0 nand", 1);
}
#[test]
fn nor_tt() {
expect_int("1 1 nor", 0);
}
#[test]
fn nor_tf() {
expect_int("1 0 nor", 0);
}
#[test]
fn nor_ff() {
expect_int("0 0 nor", 1);
}
#[test]
fn ifelse_true() {
expect_int("{ 42 } { 99 } 1 ifelse", 42);
}
#[test]
fn ifelse_false() {
expect_int("{ 42 } { 99 } 0 ifelse", 99);
}
#[test]
fn pick_first() {
expect_int("{ 10 } { 20 } { 30 } 0 pick", 10);
}
#[test]
fn pick_second() {
expect_int("{ 10 } { 20 } { 30 } 1 pick", 20);
}
#[test]
fn pick_third() {
expect_int("{ 10 } { 20 } { 30 } 2 pick", 30);
}
#[test]
fn pick_preserves_stack() {
expect_int("5 { 10 } { 20 } 0 pick +", 15);
}

View File

@@ -22,7 +22,7 @@ fn redefine_word_overwrites() {
#[test]
fn word_with_param() {
let outputs = expect_outputs(": loud 0.9 gain ; \"kick\" s loud emit", 1);
let outputs = expect_outputs(": loud 0.9 gain ; \"kick\" s loud .", 1);
assert!(outputs[0].contains("gain/0.9"));
}
@@ -97,7 +97,7 @@ fn define_word_containing_quotation() {
#[test]
fn define_word_with_sound() {
let outputs = expect_outputs(": kick \"kick\" s emit ; kick", 1);
let outputs = expect_outputs(": kick \"kick\" s . ; kick", 1);
assert!(outputs[0].contains("sound/kick"));
}

View File

@@ -72,7 +72,7 @@ fn word_with_sound_params() {
let f = forth();
let ctx = ctx_with(|c| c.runs = 0);
let outputs = f.evaluate(
": myverb 0.5 verb ; \"sine\" s 440 freq < myverb > emit",
": myverb 0.5 verb ; \"sine\" s 440 freq < myverb > .",
&ctx
).unwrap();
assert_eq!(outputs.len(), 1);

View File

@@ -59,31 +59,31 @@ fn nested_quotations() {
#[test]
fn quotation_with_param() {
let outputs = expect_outputs(r#""kick" s { 2 distort } 1 ? emit"#, 1);
let outputs = expect_outputs(r#""kick" s { 2 distort } 1 ? ."#, 1);
assert!(outputs[0].contains("distort/2"));
}
#[test]
fn quotation_skips_param() {
let outputs = expect_outputs(r#""kick" s { 2 distort } 0 ? emit"#, 1);
let outputs = expect_outputs(r#""kick" s { 2 distort } 0 ? ."#, 1);
assert!(!outputs[0].contains("distort"));
}
#[test]
fn quotation_with_emit() {
// When true, emit should fire
let outputs = expect_outputs(r#""kick" s { emit } 1 ?"#, 1);
// When true, . should fire
let outputs = expect_outputs(r#""kick" s { . } 1 ?"#, 1);
assert!(outputs[0].contains("kick"));
}
#[test]
fn quotation_skips_emit() {
// When false, emit should not fire
// When false, . should not fire
let f = forth();
let outputs = f
.evaluate(r#""kick" s { emit } 0 ?"#, &default_ctx())
.evaluate(r#""kick" s { . } 0 ?"#, &default_ctx())
.unwrap();
// No output since emit was skipped and no implicit emit
// No output since . was skipped and no implicit emit
assert_eq!(outputs.len(), 0);
}
@@ -110,7 +110,7 @@ fn every_with_quotation_integration() {
let ctx = ctx_with(|c| c.iter = iter);
let f = forth();
let outputs = f
.evaluate(r#""kick" s { 2 distort } 2 every ? emit"#, &ctx)
.evaluate(r#""kick" s { 2 distort } 2 every ? ."#, &ctx)
.unwrap();
if iter % 2 == 0 {
assert!(
@@ -163,7 +163,7 @@ fn when_and_unless_complementary() {
let f = forth();
let outputs = f
.evaluate(
r#""kick" s { 2 distort } 2 every ? { 4 distort } 2 every !? emit"#,
r#""kick" s { 2 distort } 2 every ? { 4 distort } 2 every !? ."#,
&ctx,
)
.unwrap();

View File

@@ -130,64 +130,64 @@ fn ramp_with_range() {
}
#[test]
fn noise_deterministic() {
fn perlin_deterministic() {
let ctx = ctx_with(|c| c.beat = 2.7);
let f = forth();
f.evaluate("1.0 noise", &ctx).unwrap();
f.evaluate("1.0 perlin", &ctx).unwrap();
let val1 = stack_float(&f);
f.evaluate("1.0 noise", &ctx).unwrap();
f.evaluate("1.0 perlin", &ctx).unwrap();
let val2 = stack_float(&f);
assert!((val1 - val2).abs() < 1e-9, "noise should be deterministic");
assert!((val1 - val2).abs() < 1e-9, "perlin should be deterministic");
}
#[test]
fn noise_in_range() {
fn perlin_in_range() {
for i in 0..100 {
let ctx = ctx_with(|c| c.beat = i as f64 * 0.1);
let f = forth();
f.evaluate("1.0 noise", &ctx).unwrap();
f.evaluate("1.0 perlin", &ctx).unwrap();
let val = stack_float(&f);
assert!(val >= 0.0 && val <= 1.0, "noise out of range: {}", val);
assert!(val >= 0.0 && val <= 1.0, "perlin out of range: {}", val);
}
}
#[test]
fn noise_varies() {
fn perlin_varies() {
let ctx1 = ctx_with(|c| c.beat = 0.5);
let ctx2 = ctx_with(|c| c.beat = 1.5);
let f = forth();
f.evaluate("1.0 noise", &ctx1).unwrap();
f.evaluate("1.0 perlin", &ctx1).unwrap();
let val1 = stack_float(&f);
f.evaluate("1.0 noise", &ctx2).unwrap();
f.evaluate("1.0 perlin", &ctx2).unwrap();
let val2 = stack_float(&f);
assert!((val1 - val2).abs() > 1e-9, "noise should vary with beat");
assert!((val1 - val2).abs() > 1e-9, "perlin should vary with beat");
}
#[test]
fn noise_smooth() {
fn perlin_smooth() {
let f = forth();
let mut prev = 0.0;
for i in 0..100 {
let ctx = ctx_with(|c| c.beat = i as f64 * 0.01);
f.evaluate("1.0 noise", &ctx).unwrap();
f.evaluate("1.0 perlin", &ctx).unwrap();
let val = stack_float(&f);
if i > 0 {
assert!((val - prev).abs() < 0.2, "noise not smooth: jump {} at step {}", (val - prev).abs(), i);
assert!((val - prev).abs() < 0.2, "perlin not smooth: jump {} at step {}", (val - prev).abs(), i);
}
prev = val;
}
}
#[test]
fn noise_with_range() {
fn perlin_with_range() {
let ctx = ctx_with(|c| c.beat = 1.3);
let f = forth();
f.evaluate("1.0 noise 200.0 800.0 range", &ctx).unwrap();
f.evaluate("1.0 perlin 200.0 800.0 range", &ctx).unwrap();
let val = stack_float(&f);
assert!(val >= 200.0 && val <= 800.0, "noise+range out of bounds: {}", val);
assert!(val >= 200.0 && val <= 800.0, "perlin+range out of bounds: {}", val);
}
#[test]
fn noise_underflow() {
expect_error("noise", "stack underflow");
fn perlin_underflow() {
expect_error("perlin", "stack underflow");
}

View File

@@ -2,19 +2,19 @@ use super::harness::*;
#[test]
fn basic_emit() {
let outputs = expect_outputs(r#""kick" sound emit"#, 1);
let outputs = expect_outputs(r#""kick" sound ."#, 1);
assert!(outputs[0].contains("sound/kick"));
}
#[test]
fn alias_s() {
let outputs = expect_outputs(r#""snare" s emit"#, 1);
let outputs = expect_outputs(r#""snare" s ."#, 1);
assert!(outputs[0].contains("sound/snare"));
}
#[test]
fn with_params() {
let outputs = expect_outputs(r#""kick" s 440 freq 0.5 gain emit"#, 1);
let outputs = expect_outputs(r#""kick" s 440 freq 0.5 gain ."#, 1);
assert!(outputs[0].contains("sound/kick"));
assert!(outputs[0].contains("freq/440"));
assert!(outputs[0].contains("gain/0.5"));
@@ -22,24 +22,24 @@ fn with_params() {
#[test]
fn auto_dur() {
let outputs = expect_outputs(r#""kick" s emit"#, 1);
let outputs = expect_outputs(r#""kick" s ."#, 1);
assert!(outputs[0].contains("dur/"));
}
#[test]
fn auto_delaytime() {
let outputs = expect_outputs(r#""kick" s emit"#, 1);
let outputs = expect_outputs(r#""kick" s ."#, 1);
assert!(outputs[0].contains("delaytime/"));
}
#[test]
fn emit_no_sound() {
expect_error("emit", "no sound set");
expect_error(".", "no sound set");
}
#[test]
fn multiple_emits() {
let outputs = expect_outputs(r#""kick" s emit "snare" s emit"#, 2);
let outputs = expect_outputs(r#""kick" s . "snare" s ."#, 2);
assert!(outputs[0].contains("sound/kick"));
assert!(outputs[1].contains("sound/snare"));
}
@@ -47,7 +47,7 @@ fn multiple_emits() {
#[test]
fn envelope_params() {
let outputs = expect_outputs(
r#""synth" s 0.01 attack 0.1 decay 0.7 sustain 0.3 release emit"#,
r#""synth" s 0.01 attack 0.1 decay 0.7 sustain 0.3 release ."#,
1,
);
assert!(outputs[0].contains("attack/0.01"));
@@ -58,14 +58,14 @@ fn envelope_params() {
#[test]
fn filter_params() {
let outputs = expect_outputs(r#""synth" s 2000 lpf 0.5 lpq emit"#, 1);
let outputs = expect_outputs(r#""synth" s 2000 lpf 0.5 lpq ."#, 1);
assert!(outputs[0].contains("lpf/2000"));
assert!(outputs[0].contains("lpq/0.5"));
}
#[test]
fn adsr_sets_all_envelope_params() {
let outputs = expect_outputs(r#""synth" s 0.01 0.1 0.5 0.3 adsr emit"#, 1);
let outputs = expect_outputs(r#""synth" s 0.01 0.1 0.5 0.3 adsr ."#, 1);
assert!(outputs[0].contains("attack/0.01"));
assert!(outputs[0].contains("decay/0.1"));
assert!(outputs[0].contains("sustain/0.5"));
@@ -74,7 +74,7 @@ fn adsr_sets_all_envelope_params() {
#[test]
fn ad_sets_attack_decay_sustain_zero() {
let outputs = expect_outputs(r#""synth" s 0.01 0.1 ad emit"#, 1);
let outputs = expect_outputs(r#""synth" s 0.01 0.1 ad ."#, 1);
assert!(outputs[0].contains("attack/0.01"));
assert!(outputs[0].contains("decay/0.1"));
assert!(outputs[0].contains("sustain/0"));
@@ -82,7 +82,7 @@ fn ad_sets_attack_decay_sustain_zero() {
#[test]
fn bank_param() {
let outputs = expect_outputs(r#""loop" s "a" bank emit"#, 1);
let outputs = expect_outputs(r#""loop" s "a" bank ."#, 1);
assert!(outputs[0].contains("sound/loop"));
assert!(outputs[0].contains("bank/a"));
}

View File

@@ -35,11 +35,6 @@ fn dupn_underflow() {
expect_error("3 dupn", "stack underflow");
}
#[test]
fn bang_alias() {
expect_stack("c4 3 !", &[int(60), int(60), int(60)]);
}
#[test]
fn drop() {
expect_stack("1 2 drop", &[int(1)]);

View File

@@ -61,14 +61,14 @@ fn stepdur_baseline() {
#[test]
fn single_emit() {
let outputs = expect_outputs(r#""kick" s @"#, 1);
let outputs = expect_outputs(r#""kick" s ."#, 1);
let deltas = get_deltas(&outputs);
assert!(approx_eq(deltas[0], 0.0), "single emit at start should have delta 0");
}
#[test]
fn implicit_subdivision_2() {
let outputs = expect_outputs(r#""kick" s @ @"#, 2);
let outputs = expect_outputs(r#""kick" s . ."#, 2);
let deltas = get_deltas(&outputs);
let step = 0.5 / 2.0;
assert!(approx_eq(deltas[0], 0.0), "first slot at 0");
@@ -77,7 +77,7 @@ fn implicit_subdivision_2() {
#[test]
fn implicit_subdivision_4() {
let outputs = expect_outputs(r#""kick" s @ @ @ @"#, 4);
let outputs = expect_outputs(r#""kick" s . . . ."#, 4);
let deltas = get_deltas(&outputs);
let step = 0.5 / 4.0;
for (i, delta) in deltas.iter().enumerate() {
@@ -92,7 +92,7 @@ fn implicit_subdivision_4() {
#[test]
fn implicit_subdivision_3() {
let outputs = expect_outputs(r#""kick" s @ @ @"#, 3);
let outputs = expect_outputs(r#""kick" s . . ."#, 3);
let deltas = get_deltas(&outputs);
let step = 0.5 / 3.0;
assert!(approx_eq(deltas[0], 0.0));
@@ -102,7 +102,7 @@ fn implicit_subdivision_3() {
#[test]
fn silence_creates_gap() {
let outputs = expect_outputs(r#""kick" s @ ~ @"#, 2);
let outputs = expect_outputs(r#""kick" s . _ ."#, 2);
let deltas = get_deltas(&outputs);
let step = 0.5 / 3.0;
assert!(approx_eq(deltas[0], 0.0), "first at 0");
@@ -116,7 +116,7 @@ fn silence_creates_gap() {
#[test]
fn silence_at_start() {
let outputs = expect_outputs(r#""kick" s ~ @"#, 1);
let outputs = expect_outputs(r#""kick" s _ ."#, 1);
let deltas = get_deltas(&outputs);
let step = 0.5 / 2.0;
assert!(
@@ -129,13 +129,13 @@ fn silence_at_start() {
#[test]
fn silence_only() {
let outputs = expect_outputs(r#""kick" s ~"#, 0);
let outputs = expect_outputs(r#""kick" s _"#, 0);
assert!(outputs.is_empty(), "silence only should produce no output");
}
#[test]
fn sound_persists() {
let outputs = expect_outputs(r#""kick" s @ @ "hat" s @ @"#, 4);
let outputs = expect_outputs(r#""kick" s . . "hat" s . ."#, 4);
let sounds = get_sounds(&outputs);
assert_eq!(sounds[0], "kick");
assert_eq!(sounds[1], "kick");
@@ -145,14 +145,14 @@ fn sound_persists() {
#[test]
fn alternating_sounds() {
let outputs = expect_outputs(r#""kick" s @ "snare" s @ "kick" s @ "snare" s @"#, 4);
let outputs = expect_outputs(r#""kick" s . "snare" s . "kick" s . "snare" s ."#, 4);
let sounds = get_sounds(&outputs);
assert_eq!(sounds, vec!["kick", "snare", "kick", "snare"]);
}
#[test]
fn dur_matches_slot_duration() {
let outputs = expect_outputs(r#""kick" s @ @ @ @"#, 4);
let outputs = expect_outputs(r#""kick" s . . . ."#, 4);
let durs = get_durs(&outputs);
let expected_dur = 0.5 / 4.0;
for (i, dur) in durs.iter().enumerate() {
@@ -168,7 +168,7 @@ fn dur_matches_slot_duration() {
fn tempo_affects_subdivision() {
let ctx = ctx_with(|c| c.tempo = 60.0);
let f = forth();
let outputs = f.evaluate(r#""kick" s @ @"#, &ctx).unwrap();
let outputs = f.evaluate(r#""kick" s . ."#, &ctx).unwrap();
let deltas = get_deltas(&outputs);
// At 60 BPM: stepdur = 0.25, root dur = 1.0
let step = 1.0 / 2.0;
@@ -180,7 +180,7 @@ fn tempo_affects_subdivision() {
fn speed_affects_subdivision() {
let ctx = ctx_with(|c| c.speed = 2.0);
let f = forth();
let outputs = f.evaluate(r#""kick" s @ @"#, &ctx).unwrap();
let outputs = f.evaluate(r#""kick" s . ."#, &ctx).unwrap();
let deltas = get_deltas(&outputs);
// At speed 2.0: stepdur = 0.0625, root dur = 0.25
let step = 0.25 / 2.0;
@@ -193,11 +193,11 @@ fn cycle_picks_by_step() {
for runs in 0..4 {
let ctx = ctx_with(|c| c.runs = runs);
let f = forth();
let outputs = f.evaluate(r#""kick" s < @ ~ >"#, &ctx).unwrap();
let outputs = f.evaluate(r#""kick" s < . _ >"#, &ctx).unwrap();
if runs % 2 == 0 {
assert_eq!(outputs.len(), 1, "runs={}: @ should be picked", runs);
assert_eq!(outputs.len(), 1, "runs={}: . should be picked", runs);
} else {
assert_eq!(outputs.len(), 0, "runs={}: ~ should be picked", runs);
assert_eq!(outputs.len(), 0, "runs={}: _ should be picked", runs);
}
}
}
@@ -207,11 +207,11 @@ fn pcycle_picks_by_pattern() {
for iter in 0..4 {
let ctx = ctx_with(|c| c.iter = iter);
let f = forth();
let outputs = f.evaluate(r#""kick" s << @ ~ >>"#, &ctx).unwrap();
let outputs = f.evaluate(r#""kick" s << . _ >>"#, &ctx).unwrap();
if iter % 2 == 0 {
assert_eq!(outputs.len(), 1, "iter={}: @ should be picked", iter);
assert_eq!(outputs.len(), 1, "iter={}: . should be picked", iter);
} else {
assert_eq!(outputs.len(), 0, "iter={}: ~ should be picked", iter);
assert_eq!(outputs.len(), 0, "iter={}: _ should be picked", iter);
}
}
}
@@ -221,7 +221,7 @@ 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" s @ } { "hat" s @ } { "snare" s @ } >"#, &ctx).unwrap();
let outputs = f.evaluate(r#"< { "kick" s . } { "hat" s . } { "snare" s . } >"#, &ctx).unwrap();
assert_eq!(outputs.len(), 1, "runs={}: expected 1 output", runs);
let sounds = get_sounds(&outputs);
let expected = ["kick", "hat", "snare"][runs % 3];
@@ -238,7 +238,7 @@ fn dot_alias_for_emit() {
#[test]
fn dot_with_silence() {
let outputs = expect_outputs(r#""kick" s . ~ . ~"#, 2);
let outputs = expect_outputs(r#""kick" s . _ . _"#, 2);
let deltas = get_deltas(&outputs);
let step = 0.5 / 4.0;
assert!(approx_eq(deltas[0], 0.0));
@@ -292,7 +292,7 @@ fn internal_alternation_empty_error() {
#[test]
fn div_basic_subdivision() {
let outputs = expect_outputs(r#"div "kick" s . "hat" s . end"#, 2);
let outputs = expect_outputs(r#"div "kick" s . "hat" s . ~"#, 2);
let deltas = get_deltas(&outputs);
let sounds = get_sounds(&outputs);
assert_eq!(sounds, vec!["kick", "hat"]);
@@ -301,59 +301,58 @@ fn div_basic_subdivision() {
}
#[test]
fn div_superposition() {
let outputs = expect_outputs(r#"div "kick" s . end div "hat" s . end"#, 2);
fn div_sequential() {
// Two consecutive divs each claim a slot in root, so they're sequential
let outputs = expect_outputs(r#"div "kick" s . ~ div "hat" s . ~"#, 2);
let deltas = get_deltas(&outputs);
let sounds = get_sounds(&outputs);
assert_eq!(sounds.len(), 2);
// Both at delta 0 (superposed)
assert_eq!(sounds, vec!["kick", "hat"]);
assert!(approx_eq(deltas[0], 0.0));
assert!(approx_eq(deltas[1], 0.0));
assert!(approx_eq(deltas[1], 0.25), "second div at slot 1, got {}", deltas[1]);
}
#[test]
fn div_with_root_emit() {
// kick at root level, hat in div - both should superpose at 0
// Note: div resolves first (when end is hit), root resolves at script end
let outputs = expect_outputs(r#""kick" s . div "hat" s . end"#, 2);
// kick claims slot 0 at root, div claims slot 1 at root
let outputs = expect_outputs(r#""kick" s . div "hat" s . ~"#, 2);
let deltas = get_deltas(&outputs);
let sounds = get_sounds(&outputs);
// Order is hat then kick because div resolves before root
assert_eq!(sounds, vec!["hat", "kick"]);
assert!(approx_eq(deltas[0], 0.0));
assert!(approx_eq(deltas[1], 0.0));
assert_eq!(sounds, vec!["kick", "hat"]);
assert!(approx_eq(deltas[0], 0.0), "kick at slot 0");
assert!(approx_eq(deltas[1], 0.25), "hat at slot 1, got {}", deltas[1]);
}
#[test]
fn div_nested() {
// kick takes first slot in outer div, inner div takes second slot
// Inner div resolves first, then outer div resolves
let outputs = expect_outputs(r#"div "kick" s . div "hat" s . . end end"#, 3);
// kick claims slot 0 in outer div, inner div claims slot 1
// Inner div's 2 hats subdivide its slot (0.25 duration) into 2 sub-slots
let outputs = expect_outputs(r#"div "kick" s . div "hat" s . . ~ ~"#, 3);
let sounds = get_sounds(&outputs);
let deltas = get_deltas(&outputs);
// Inner div resolves first (hat, hat), then outer div (kick)
assert_eq!(sounds[0], "hat");
// Output order: kick (slot 0), then hats (slot 1 subdivided)
assert_eq!(sounds[0], "kick");
assert_eq!(sounds[1], "hat");
assert_eq!(sounds[2], "kick");
// Inner div inherits parent's start (0) and duration (0.5), subdivides into 2
assert!(approx_eq(deltas[0], 0.0), "first hat at 0, got {}", deltas[0]);
assert!(approx_eq(deltas[1], 0.25), "second hat at 0.25, got {}", deltas[1]);
// Outer div has 2 slots: kick at 0, inner div at slot 1 (but inner resolved independently)
assert!(approx_eq(deltas[2], 0.0), "kick at 0, got {}", deltas[2]);
assert_eq!(sounds[2], "hat");
// Outer div has 2 slots of 0.25 each
// kick at slot 0 -> delta 0
// inner div at slot 1 -> starts at 0.25, subdivided into 2 -> hats at 0.25 and 0.375
assert!(approx_eq(deltas[0], 0.0), "kick at 0, got {}", deltas[0]);
assert!(approx_eq(deltas[1], 0.25), "first hat at 0.25, got {}", deltas[1]);
assert!(approx_eq(deltas[2], 0.375), "second hat at 0.375, got {}", deltas[2]);
}
#[test]
fn div_with_silence() {
let outputs = expect_outputs(r#"div "kick" s . ~ end"#, 1);
let outputs = expect_outputs(r#"div "kick" s . _ ~"#, 1);
let deltas = get_deltas(&outputs);
assert!(approx_eq(deltas[0], 0.0));
}
#[test]
fn div_unmatched_end_error() {
fn unmatched_scope_terminator_error() {
let f = forth();
let result = f.evaluate(r#""kick" s . end"#, &default_ctx());
assert!(result.is_err(), "unmatched end should error");
let result = f.evaluate(r#""kick" s . ~"#, &default_ctx());
assert!(result.is_err(), "unmatched ~ should error");
}
#[test]
@@ -392,7 +391,7 @@ fn alternator_with_arithmetic() {
#[test]
fn stack_superposes_sounds() {
let outputs = expect_outputs(r#"stack "kick" s . "hat" s . end"#, 2);
let outputs = expect_outputs(r#"stack "kick" s . "hat" s . ~"#, 2);
let deltas = get_deltas(&outputs);
let sounds = get_sounds(&outputs);
assert_eq!(sounds.len(), 2);
@@ -403,7 +402,7 @@ fn stack_superposes_sounds() {
#[test]
fn stack_with_multiple_emits() {
let outputs = expect_outputs(r#"stack "kick" s . . . . end"#, 4);
let outputs = expect_outputs(r#"stack "kick" s . . . . ~"#, 4);
let deltas = get_deltas(&outputs);
// All 4 kicks at delta 0
for (i, delta) in deltas.iter().enumerate() {
@@ -415,7 +414,7 @@ fn stack_with_multiple_emits() {
fn stack_inside_div() {
// div subdivides, stack inside superposes
// stack doesn't claim a slot in parent div, so snare is also at 0
let outputs = expect_outputs(r#"div stack "kick" s . "hat" s . end "snare" s . end"#, 3);
let outputs = expect_outputs(r#"div stack "kick" s . "hat" s . ~ "snare" s . ~"#, 3);
let deltas = get_deltas(&outputs);
let sounds = get_sounds(&outputs);
// stack resolves first (kick, hat at 0), then div resolves (snare at 0)
@@ -429,20 +428,21 @@ fn stack_inside_div() {
}
#[test]
fn div_then_stack_sequential() {
// Nested div doesn't claim a slot in parent, only emit/silence do
// So nested div and snare both resolve with parent's timing
let outputs = expect_outputs(r#"div div "kick" s . "hat" s . end "snare" s . end"#, 3);
fn div_nested_with_sibling() {
// Inner div claims slot 0, snare claims slot 1
// Inner div's kick/hat subdivide slot 0
let outputs = expect_outputs(r#"div div "kick" s . "hat" s . ~ "snare" s . ~"#, 3);
let deltas = get_deltas(&outputs);
let sounds = get_sounds(&outputs);
// Inner div resolves first (kick at 0, hat at 0.25 of parent duration)
// Outer div has 1 slot (snare's .), so snare at 0
// Outer div has 2 slots of 0.25 each
// Inner div at slot 0: kick at 0, hat at 0.125
// snare at slot 1: delta 0.25
assert_eq!(sounds[0], "kick");
assert_eq!(sounds[1], "hat");
assert_eq!(sounds[2], "snare");
assert!(approx_eq(deltas[0], 0.0));
assert!(approx_eq(deltas[1], 0.25), "hat at 0.25, got {}", deltas[1]);
assert!(approx_eq(deltas[2], 0.0), "snare at 0, got {}", deltas[2]);
assert!(approx_eq(deltas[0], 0.0), "kick at 0, got {}", deltas[0]);
assert!(approx_eq(deltas[1], 0.125), "hat at 0.125, got {}", deltas[1]);
assert!(approx_eq(deltas[2], 0.25), "snare at 0.25, got {}", deltas[2]);
}
#[test]