From 66933433d18e9ac9487458601ac5f6c74f91a6d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Forment?= Date: Mon, 26 Jan 2026 12:22:44 +0100 Subject: [PATCH] WIP --- crates/forth/src/compiler.rs | 18 +- crates/forth/src/lib.rs | 2 +- crates/forth/src/ops.rs | 16 +- crates/forth/src/types.rs | 65 ++- crates/forth/src/vm.rs | 765 +++++++++++++++++++++-------------- crates/forth/src/words.rs | 134 +++--- tests/forth.rs | 3 - tests/forth/harness.rs | 10 +- tests/forth/intervals.rs | 2 +- tests/forth/iteration.rs | 256 ------------ tests/forth/list_words.rs | 10 - tests/forth/randomness.rs | 11 - tests/forth/sound.rs | 30 -- tests/forth/temporal.rs | 592 ++++++++++++++++++++------- 14 files changed, 1030 insertions(+), 884 deletions(-) delete mode 100644 tests/forth/iteration.rs diff --git a/crates/forth/src/compiler.rs b/crates/forth/src/compiler.rs index 411f50a..904c06e 100644 --- a/crates/forth/src/compiler.rs +++ b/crates/forth/src/compiler.rs @@ -94,8 +94,8 @@ fn tokenize(input: &str) -> Vec { fn compile(tokens: &[Token], dict: &Dictionary) -> Result, String> { let mut ops = Vec::new(); let mut i = 0; - let mut pipe_parity = false; let mut list_depth: usize = 0; + let mut pipe_parity = false; while i < tokens.len() { match &tokens[i] { @@ -119,15 +119,6 @@ fn compile(tokens: &[Token], dict: &Dictionary) -> Result, String> { dict.lock().unwrap().insert(name, body); } else if word == ";" { return Err("unexpected ;".into()); - } else if word == "|" { - if pipe_parity { - ops.push(Op::LocalCycleEnd); - list_depth = list_depth.saturating_sub(1); - } else { - ops.push(Op::ListStart); - list_depth += 1; - } - pipe_parity = !pipe_parity; } else if word == "if" { let (then_ops, else_ops, consumed, then_span, else_span) = compile_if(&tokens[i + 1..], dict)?; i += consumed; @@ -140,6 +131,13 @@ fn compile(tokens: &[Token], dict: &Dictionary) -> Result, String> { ops.push(Op::Branch(else_ops.len())); ops.extend(else_ops); } + } else if word == "|" { + if pipe_parity { + ops.push(Op::InternalCycleEnd); + } else { + ops.push(Op::ListStart); + } + pipe_parity = !pipe_parity; } else if is_list_start(word) { ops.push(Op::ListStart); list_depth += 1; diff --git a/crates/forth/src/lib.rs b/crates/forth/src/lib.rs index a024ad8..6a33773 100644 --- a/crates/forth/src/lib.rs +++ b/crates/forth/src/lib.rs @@ -6,5 +6,5 @@ mod vm; mod words; pub use types::{Dictionary, ExecutionTrace, Rng, SourceSpan, StepContext, Value, Variables}; -pub use vm::Forth; +pub use vm::{EmissionCounter, Forth}; pub use words::{Word, WordCompile, WORDS}; diff --git a/crates/forth/src/ops.rs b/crates/forth/src/ops.rs index 8c5d4dc..9928d4d 100644 --- a/crates/forth/src/ops.rs +++ b/crates/forth/src/ops.rs @@ -39,6 +39,7 @@ pub enum Op { NewCmd, SetParam(String), Emit, + Silence, Get, Set, GetContext(String), @@ -56,24 +57,14 @@ pub enum Op { ListEndCycle, PCycle, ListEndPCycle, - At, - Window, Scale, - Pop, - Subdivide, SetTempo, - Each, Every, Quotation(Vec, Option), When, Unless, Adsr, Ad, - Stack, - For, - LocalCycleEnd, - Echo, - Necho, Apply, Ramp, Range, @@ -82,4 +73,9 @@ pub enum Op { Loop, Degree(&'static [i64]), Oct, + InternalCycleEnd, + DivStart, + DivEnd, + StackStart, + EmitN, } diff --git a/crates/forth/src/types.rs b/crates/forth/src/types.rs index a4a0822..375d8ef 100644 --- a/crates/forth/src/types.rs +++ b/crates/forth/src/types.rs @@ -48,6 +48,7 @@ pub enum Value { Str(String, Option), Marker, Quotation(Vec, Option), + Alternator(Vec), } impl PartialEq for Value { @@ -58,6 +59,7 @@ impl PartialEq for Value { (Value::Str(a, _), Value::Str(b, _)) => a == b, (Value::Marker, Value::Marker) => true, (Value::Quotation(a, _), Value::Quotation(b, _)) => a == b, + (Value::Alternator(a), Value::Alternator(b)) => a == b, _ => false, } } @@ -94,6 +96,7 @@ impl Value { Value::Str(s, _) => !s.is_empty(), Value::Marker => false, Value::Quotation(..) => true, + Value::Alternator(items) => !items.is_empty(), } } @@ -108,35 +111,75 @@ impl Value { Value::Str(s, _) => s.clone(), Value::Marker => String::new(), Value::Quotation(..) => String::new(), + Value::Alternator(_) => String::new(), } } pub(super) fn span(&self) -> Option { match self { Value::Int(_, s) | Value::Float(_, s) | Value::Str(_, s) => *s, - Value::Marker | Value::Quotation(..) => None, + Value::Marker | Value::Quotation(..) | Value::Alternator(_) => None, } } } #[derive(Clone, Debug, Default)] pub(super) struct CmdRegister { - sound: Option, - params: Vec<(String, String)>, + sound: Option, + params: Vec<(String, Value)>, } impl CmdRegister { - pub(super) fn set_sound(&mut self, name: String) { - self.sound = Some(name); + pub(super) fn set_sound(&mut self, val: Value) { + self.sound = Some(val); } - pub(super) fn set_param(&mut self, key: String, value: String) { - self.params.push((key, value)); + pub(super) fn set_param(&mut self, key: String, val: Value) { + self.params.push((key, val)); } - pub(super) fn take(&mut self) -> Option<(String, Vec<(String, String)>)> { - let sound = self.sound.take()?; - let params = std::mem::take(&mut self.params); - Some((sound, params)) + pub(super) fn snapshot(&self) -> Option<(Value, Vec<(String, Value)>)> { + self.sound.as_ref().map(|s| (s.clone(), self.params.clone())) + } +} + +#[derive(Clone, Debug)] +pub(super) struct PendingEmission { + pub sound: String, + pub params: Vec<(String, String)>, + pub slot_index: usize, +} + +#[derive(Clone, Debug)] +pub(super) struct ScopeContext { + pub start: f64, + pub duration: f64, + pub weight: f64, + pub slot_count: usize, + pub pending: Vec, + pub stacked: bool, +} + +impl ScopeContext { + pub fn new(start: f64, duration: f64) -> Self { + Self { + start, + duration, + weight: 1.0, + slot_count: 0, + pending: Vec::new(), + stacked: false, + } + } + + pub fn claim_slot(&mut self) -> usize { + if self.stacked { + self.slot_count = 1; + 0 + } else { + let idx = self.slot_count; + self.slot_count += 1; + idx + } } } diff --git a/crates/forth/src/vm.rs b/crates/forth/src/vm.rs index 8d84879..4515542 100644 --- a/crates/forth/src/vm.rs +++ b/crates/forth/src/vm.rs @@ -4,22 +4,18 @@ use rand::{Rng as RngTrait, SeedableRng}; use super::compiler::compile_script; use super::ops::Op; use super::types::{ - CmdRegister, Dictionary, ExecutionTrace, Rng, Stack, StepContext, Value, Variables, + CmdRegister, Dictionary, ExecutionTrace, PendingEmission, Rng, ScopeContext, SourceSpan, Stack, + StepContext, Value, Variables, }; -#[derive(Clone, Debug)] -struct TimeContext { - start: f64, - duration: f64, - subdivisions: Option>, - iteration_index: Option, -} +pub type EmissionCounter = std::sync::Arc>; pub struct Forth { stack: Stack, vars: Variables, dict: Dictionary, rng: Rng, + emission_count: EmissionCounter, } impl Forth { @@ -29,6 +25,22 @@ impl Forth { vars, dict, rng, + emission_count: std::sync::Arc::new(std::sync::Mutex::new(0)), + } + } + + pub fn new_with_counter( + vars: Variables, + dict: Dictionary, + rng: Rng, + emission_count: EmissionCounter, + ) -> Self { + Self { + stack: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())), + vars, + dict, + rng, + emission_count, } } @@ -77,37 +89,42 @@ impl Forth { ) -> Result, String> { let mut stack = self.stack.lock().unwrap(); let mut outputs: Vec = Vec::new(); - let mut time_stack: Vec = vec![TimeContext { - start: 0.0, - duration: ctx.step_duration() * 4.0, - subdivisions: None, - iteration_index: None, - }]; + let root_duration = ctx.step_duration() * 4.0; + let mut scope_stack: Vec = vec![ScopeContext::new(0.0, root_duration)]; let mut cmd = CmdRegister::default(); + let mut emission_count = self.emission_count.lock().unwrap(); self.execute_ops( ops, ctx, &mut stack, &mut outputs, - &mut time_stack, + &mut scope_stack, &mut cmd, trace, + &mut emission_count, )?; + // Resolve root scope at end of script + if let Some(scope) = scope_stack.pop() { + resolve_scope(&scope, &mut outputs); + } + Ok(outputs) } #[allow(clippy::too_many_arguments)] + #[allow(clippy::only_used_in_recursion)] fn execute_ops( &self, ops: &[Op], ctx: &StepContext, stack: &mut Vec, outputs: &mut Vec, - time_stack: &mut Vec, + scope_stack: &mut Vec, cmd: &mut CmdRegister, trace: Option<&mut ExecutionTrace>, + emission_count: &mut usize, ) -> Result<(), String> { let mut pc = 0; let trace_cell = std::cell::RefCell::new(trace); @@ -176,37 +193,30 @@ impl Forth { Op::Mul => binary_op(stack, |a, b| a * b)?, Op::Div => binary_op(stack, |a, b| a / b)?, Op::Mod => { - let b = stack.pop().ok_or("stack underflow")?.as_int()?; - let a = stack.pop().ok_or("stack underflow")?.as_int()?; - stack.push(Value::Int(a % b, None)); + let b = stack.pop().ok_or("stack underflow")?; + let a = stack.pop().ok_or("stack underflow")?; + let result = lift_binary(a, b, |x, y| (x as i64 % y as i64) as f64)?; + stack.push(result); } Op::Neg => { let v = stack.pop().ok_or("stack underflow")?; - match v { - Value::Int(i, s) => stack.push(Value::Int(-i, s)), - Value::Float(f, s) => stack.push(Value::Float(-f, s)), - _ => return Err("expected number".into()), - } + stack.push(lift_unary(v, |x| -x)?); } Op::Abs => { let v = stack.pop().ok_or("stack underflow")?; - match v { - Value::Int(i, s) => stack.push(Value::Int(i.abs(), s)), - Value::Float(f, s) => stack.push(Value::Float(f.abs(), s)), - _ => return Err("expected number".into()), - } + stack.push(lift_unary(v, |x| x.abs())?); } Op::Floor => { - let v = stack.pop().ok_or("stack underflow")?.as_float()?; - stack.push(Value::Int(v.floor() as i64, None)); + let v = stack.pop().ok_or("stack underflow")?; + stack.push(lift_unary(v, |x| x.floor())?); } Op::Ceil => { - let v = stack.pop().ok_or("stack underflow")?.as_float()?; - stack.push(Value::Int(v.ceil() as i64, None)); + let v = stack.pop().ok_or("stack underflow")?; + stack.push(lift_unary(v, |x| x.ceil())?); } Op::Round => { - let v = stack.pop().ok_or("stack underflow")?.as_float()?; - stack.push(Value::Int(v.round() as i64, None)); + let v = stack.pop().ok_or("stack underflow")?; + stack.push(lift_unary(v, |x| x.round())?); } Op::Min => binary_op(stack, |a, b| a.min(b))?, Op::Max => binary_op(stack, |a, b| a.max(b))?, @@ -253,32 +263,61 @@ impl Forth { } Op::NewCmd => { - let name = stack.pop().ok_or("stack underflow")?; - let name = name.as_str()?; - cmd.set_sound(name.to_string()); + let val = stack.pop().ok_or("stack underflow")?; + cmd.set_sound(val); } Op::SetParam(param) => { let val = stack.pop().ok_or("stack underflow")?; - cmd.set_param(param.clone(), val.to_param_string()); + cmd.set_param(param.clone(), val); } + Op::Emit => { - let (sound, mut params) = cmd.take().ok_or("no sound set")?; - let mut pairs = vec![("sound".into(), sound)]; - pairs.append(&mut params); - let time_ctx = time_stack.last().ok_or("time stack underflow")?; - if time_ctx.start > 0.0 { - pairs.push(("delta".into(), time_ctx.start.to_string())); + let (sound_val, params) = cmd.snapshot().ok_or("no sound set")?; + + // Resolve alternators using emission count, tracking selected spans + let (resolved_sound, sound_span) = + resolve_value_with_span(&sound_val, *emission_count); + if let Some(span) = sound_span { + if let Some(trace) = trace_cell.borrow_mut().as_mut() { + trace.selected_spans.push(span); + } } - if !pairs.iter().any(|(k, _)| k == "dur") { - pairs.push(("dur".into(), time_ctx.duration.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 * time_ctx.duration).to_string(); - } else { - pairs.push(("delaytime".into(), time_ctx.duration.to_string())); - } - outputs.push(format_cmd(&pairs)); + let sound = resolved_sound.as_str()?.to_string(); + + let resolved_params: Vec<(String, String)> = params + .iter() + .map(|(k, v)| { + let (resolved, param_span) = + resolve_value_with_span(v, *emission_count); + if let Some(span) = param_span { + if let Some(trace) = trace_cell.borrow_mut().as_mut() { + trace.selected_spans.push(span); + } + } + (k.clone(), resolved.to_param_string()) + }) + .collect(); + + let scope = scope_stack.last_mut().ok_or("scope stack underflow")?; + let slot_idx = scope.claim_slot(); + scope.pending.push(PendingEmission { + sound, + params: resolved_params, + slot_index: slot_idx, + }); + + *emission_count += 1; + } + + Op::Silence => { + let scope = scope_stack.last_mut().ok_or("scope stack underflow")?; + scope.claim_slot(); + } + + Op::Scale => { + let factor = stack.pop().ok_or("stack underflow")?.as_float()?; + let scope = scope_stack.last_mut().ok_or("scope stack underflow")?; + scope.weight = factor; } Op::Get => { @@ -359,7 +398,16 @@ impl Forth { } } let mut trace_opt = trace_cell.borrow_mut().take(); - self.execute_ops("_ops, ctx, stack, outputs, time_stack, cmd, trace_opt.as_deref_mut())?; + self.execute_ops( + "_ops, + ctx, + stack, + outputs, + scope_stack, + cmd, + trace_opt.as_deref_mut(), + emission_count, + )?; *trace_cell.borrow_mut() = trace_opt; } else { stack.push(selected); @@ -390,7 +438,16 @@ impl Forth { } } let mut trace_opt = trace_cell.borrow_mut().take(); - self.execute_ops("_ops, ctx, stack, outputs, time_stack, cmd, trace_opt.as_deref_mut())?; + self.execute_ops( + "_ops, + ctx, + stack, + outputs, + scope_stack, + cmd, + trace_opt.as_deref_mut(), + emission_count, + )?; *trace_cell.borrow_mut() = trace_opt; } else { stack.push(selected); @@ -421,7 +478,16 @@ impl Forth { } } let mut trace_opt = trace_cell.borrow_mut().take(); - self.execute_ops("_ops, ctx, stack, outputs, time_stack, cmd, trace_opt.as_deref_mut())?; + self.execute_ops( + "_ops, + ctx, + stack, + outputs, + scope_stack, + cmd, + trace_opt.as_deref_mut(), + emission_count, + )?; *trace_cell.borrow_mut() = trace_opt; } else { stack.push(selected); @@ -441,7 +507,16 @@ impl Forth { } } let mut trace_opt = trace_cell.borrow_mut().take(); - self.execute_ops("_ops, ctx, stack, outputs, time_stack, cmd, trace_opt.as_deref_mut())?; + self.execute_ops( + "_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()), @@ -462,7 +537,16 @@ impl Forth { } } let mut trace_opt = trace_cell.borrow_mut().take(); - self.execute_ops("_ops, ctx, stack, outputs, time_stack, cmd, trace_opt.as_deref_mut())?; + self.execute_ops( + "_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()), @@ -500,7 +584,16 @@ impl Forth { } } let mut trace_opt = trace_cell.borrow_mut().take(); - self.execute_ops("_ops, ctx, stack, outputs, time_stack, cmd, trace_opt.as_deref_mut())?; + self.execute_ops( + "_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()), @@ -520,7 +613,16 @@ impl Forth { } } let mut trace_opt = trace_cell.borrow_mut().take(); - self.execute_ops("_ops, ctx, stack, outputs, time_stack, cmd, trace_opt.as_deref_mut())?; + self.execute_ops( + "_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()), @@ -541,102 +643,21 @@ impl Forth { } Op::Degree(pattern) => { - let degree = stack.pop().ok_or("stack underflow")?.as_int()?; + let val = stack.pop().ok_or("stack underflow")?; let len = pattern.len() as i64; - let octave_offset = degree.div_euclid(len); - let idx = degree.rem_euclid(len) as usize; - let midi = 60 + octave_offset * 12 + pattern[idx]; - stack.push(Value::Int(midi, None)); + let result = lift_unary_int(val, |degree| { + let octave_offset = degree.div_euclid(len); + let idx = degree.rem_euclid(len) as usize; + 60 + octave_offset * 12 + pattern[idx] + })?; + stack.push(result); } Op::Oct => { - let shift = stack.pop().ok_or("stack underflow")?.as_int()?; - let note = stack.pop().ok_or("stack underflow")?.as_int()?; - stack.push(Value::Int(note + shift * 12, None)); - } - - Op::At => { - let pos = stack.pop().ok_or("stack underflow")?.as_float()?; - let parent = time_stack.last().ok_or("time stack underflow")?; - let new_start = parent.start + parent.duration * pos; - time_stack.push(TimeContext { - start: new_start, - duration: parent.duration * (1.0 - pos), - subdivisions: None, - iteration_index: parent.iteration_index, - }); - } - - Op::Window => { - let end = stack.pop().ok_or("stack underflow")?.as_float()?; - let start_pos = stack.pop().ok_or("stack underflow")?.as_float()?; - let parent = time_stack.last().ok_or("time stack underflow")?; - let new_start = parent.start + parent.duration * start_pos; - let new_duration = parent.duration * (end - start_pos); - time_stack.push(TimeContext { - start: new_start, - duration: new_duration, - subdivisions: None, - iteration_index: parent.iteration_index, - }); - } - - Op::Scale => { - let factor = stack.pop().ok_or("stack underflow")?.as_float()?; - let parent = time_stack.last().ok_or("time stack underflow")?; - time_stack.push(TimeContext { - start: parent.start, - duration: parent.duration * factor, - subdivisions: None, - iteration_index: parent.iteration_index, - }); - } - - Op::Pop => { - if time_stack.len() <= 1 { - return Err("cannot pop root time context".into()); - } - time_stack.pop(); - } - - Op::Subdivide => { - let n = stack.pop().ok_or("stack underflow")?.as_int()? as usize; - if n == 0 { - return Err("subdivide count must be > 0".into()); - } - let time_ctx = time_stack.last_mut().ok_or("time stack underflow")?; - let sub_duration = time_ctx.duration / n as f64; - let mut subs = Vec::with_capacity(n); - for i in 0..n { - subs.push((time_ctx.start + sub_duration * i as f64, sub_duration)); - } - time_ctx.subdivisions = Some(subs); - } - - Op::Each => { - let (sound, params) = cmd.take().ok_or("no sound set")?; - let time_ctx = time_stack.last().ok_or("time stack underflow")?; - let subs = time_ctx - .subdivisions - .as_ref() - .ok_or("each requires subdivide first")?; - for (sub_start, sub_dur) in subs { - let mut pairs = vec![("sound".into(), sound.clone())]; - pairs.extend(params.iter().cloned()); - if *sub_start > 0.0 { - pairs.push(("delta".into(), sub_start.to_string())); - } - if !pairs.iter().any(|(k, _)| k == "dur") { - pairs.push(("dur".into(), sub_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 * sub_dur).to_string(); - } else { - pairs.push(("delaytime".into(), sub_dur.to_string())); - } - outputs.push(format_cmd(&pairs)); - } + let shift = stack.pop().ok_or("stack underflow")?; + let note = stack.pop().ok_or("stack underflow")?; + let result = lift_binary(note, shift, |n, s| n + s * 12.0)?; + stack.push(result); } Op::SetTempo => { @@ -669,8 +690,8 @@ impl Forth { Op::Loop => { let beats = stack.pop().ok_or("stack underflow")?.as_float()?; let dur = beats * 60.0 / ctx.tempo / ctx.speed; - cmd.set_param("fit".into(), dur.to_string()); - cmd.set_param("dur".into(), dur.to_string()); + cmd.set_param("fit".into(), Value::Float(dur, None)); + cmd.set_param("dur".into(), Value::Float(dur, None)); } Op::ListStart => { @@ -720,7 +741,16 @@ impl Forth { } } let mut trace_opt = trace_cell.borrow_mut().take(); - self.execute_ops("_ops, ctx, stack, outputs, time_stack, cmd, trace_opt.as_deref_mut())?; + self.execute_ops( + "_ops, + ctx, + stack, + outputs, + scope_stack, + cmd, + trace_opt.as_deref_mut(), + emission_count, + )?; *trace_cell.borrow_mut() = trace_opt; } else { stack.push(selected); @@ -753,7 +783,16 @@ impl Forth { } } let mut trace_opt = trace_cell.borrow_mut().take(); - self.execute_ops("_ops, ctx, stack, outputs, time_stack, cmd, trace_opt.as_deref_mut())?; + self.execute_ops( + "_ops, + ctx, + stack, + outputs, + scope_stack, + cmd, + trace_opt.as_deref_mut(), + emission_count, + )?; *trace_cell.borrow_mut() = trace_opt; } else { stack.push(selected); @@ -765,149 +804,18 @@ impl Forth { let s = stack.pop().ok_or("stack underflow")?; let d = stack.pop().ok_or("stack underflow")?; let a = stack.pop().ok_or("stack underflow")?; - cmd.set_param("attack".into(), a.to_param_string()); - cmd.set_param("decay".into(), d.to_param_string()); - cmd.set_param("sustain".into(), s.to_param_string()); - cmd.set_param("release".into(), r.to_param_string()); + cmd.set_param("attack".into(), a); + cmd.set_param("decay".into(), d); + cmd.set_param("sustain".into(), s); + cmd.set_param("release".into(), r); } Op::Ad => { let d = stack.pop().ok_or("stack underflow")?; let a = stack.pop().ok_or("stack underflow")?; - cmd.set_param("attack".into(), a.to_param_string()); - cmd.set_param("decay".into(), d.to_param_string()); - cmd.set_param("sustain".into(), "0".into()); - } - - Op::Stack => { - let n = stack.pop().ok_or("stack underflow")?.as_int()? as usize; - if n == 0 { - return Err("stack count must be > 0".into()); - } - let time_ctx = time_stack.last_mut().ok_or("time stack underflow")?; - let sub_duration = time_ctx.duration / n as f64; - let mut subs = Vec::with_capacity(n); - for _ in 0..n { - subs.push((time_ctx.start, sub_duration)); - } - time_ctx.subdivisions = Some(subs); - } - - Op::Echo => { - let n = stack.pop().ok_or("stack underflow")?.as_int()? as usize; - if n == 0 { - return Err("echo count must be > 0".into()); - } - let time_ctx = time_stack.last_mut().ok_or("time stack underflow")?; - // Geometric series: d1 * (2 - 2^(1-n)) = total - let d1 = time_ctx.duration / (2.0 - 2.0_f64.powi(1 - n as i32)); - let mut subs = Vec::with_capacity(n); - for i in 0..n { - let dur = d1 / 2.0_f64.powi(i as i32); - let start = if i == 0 { - time_ctx.start - } else { - time_ctx.start + d1 * (2.0 - 2.0_f64.powi(1 - i as i32)) - }; - subs.push((start, dur)); - } - time_ctx.subdivisions = Some(subs); - } - - Op::Necho => { - let n = stack.pop().ok_or("stack underflow")?.as_int()? as usize; - if n == 0 { - return Err("necho count must be > 0".into()); - } - let time_ctx = time_stack.last_mut().ok_or("time stack underflow")?; - // Reverse geometric: d1 + 2*d1 + 4*d1 + ... = d1 * (2^n - 1) = total - let d1 = time_ctx.duration / (2.0_f64.powi(n as i32) - 1.0); - let mut subs = Vec::with_capacity(n); - for i in 0..n { - let dur = d1 * 2.0_f64.powi(i as i32); - let start = if i == 0 { - time_ctx.start - } else { - // Sum of previous durations: d1 * (2^i - 1) - time_ctx.start + d1 * (2.0_f64.powi(i as i32) - 1.0) - }; - subs.push((start, dur)); - } - time_ctx.subdivisions = Some(subs); - } - - Op::For => { - let quot = stack.pop().ok_or("stack underflow")?; - let time_ctx = time_stack.last().ok_or("time stack underflow")?; - let subs = time_ctx - .subdivisions - .clone() - .ok_or("for requires subdivide first")?; - - 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); - } - } - for (i, (sub_start, sub_dur)) in subs.iter().enumerate() { - time_stack.push(TimeContext { - start: *sub_start, - duration: *sub_dur, - subdivisions: None, - iteration_index: Some(i), - }); - let mut trace_opt = trace_cell.borrow_mut().take(); - self.execute_ops( - "_ops, - ctx, - stack, - outputs, - time_stack, - cmd, - trace_opt.as_deref_mut(), - )?; - *trace_cell.borrow_mut() = trace_opt; - time_stack.pop(); - } - } - _ => return Err("expected quotation".into()), - } - } - - Op::LocalCycleEnd => { - let mut values = Vec::new(); - while let Some(v) = stack.pop() { - if v.is_marker() { - break; - } - values.push(v); - } - if values.is_empty() { - return Err("empty local cycle list".into()); - } - values.reverse(); - let time_ctx = time_stack.last().ok_or("time stack underflow")?; - let idx = time_ctx.iteration_index.unwrap_or(0) % values.len(); - let selected = values[idx].clone(); - if let Some(span) = selected.span() { - if let Some(trace) = trace_cell.borrow_mut().as_mut() { - trace.selected_spans.push(span); - } - } - if let Value::Quotation(quot_ops, body_span) = selected { - 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("_ops, ctx, stack, outputs, time_stack, cmd, trace_opt.as_deref_mut())?; - *trace_cell.borrow_mut() = trace_opt; - } else { - stack.push(selected); - } + cmd.set_param("attack".into(), a); + cmd.set_param("decay".into(), d); + cmd.set_param("sustain".into(), Value::Int(0, None)); } Op::Apply => { @@ -920,7 +828,16 @@ impl Forth { } } let mut trace_opt = trace_cell.borrow_mut().take(); - self.execute_ops("_ops, ctx, stack, outputs, time_stack, cmd, trace_opt.as_deref_mut())?; + self.execute_ops( + "_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()), @@ -945,6 +862,87 @@ impl Forth { let val = perlin_noise_1d(freq * ctx.beat); stack.push(Value::Float(val, None)); } + + Op::InternalCycleEnd => { + let mut values = Vec::new(); + while let Some(v) = stack.pop() { + if v.is_marker() { + break; + } + values.push(v); + } + if values.is_empty() { + return Err("empty internal cycle".into()); + } + values.reverse(); + stack.push(Value::Alternator(values)); + } + + Op::DivStart => { + let parent = scope_stack.last().ok_or("scope stack underflow")?; + let mut new_scope = ScopeContext::new(parent.start, parent.duration); + new_scope.weight = parent.weight; + scope_stack.push(new_scope); + } + + Op::DivEnd => { + if scope_stack.len() <= 1 { + return Err("unmatched end".into()); + } + let scope = scope_stack.pop().unwrap(); + resolve_scope(&scope, outputs); + } + + Op::StackStart => { + let parent = scope_stack.last().ok_or("scope stack underflow")?; + let mut new_scope = ScopeContext::new(parent.start, parent.duration); + new_scope.weight = parent.weight; + new_scope.stacked = true; + scope_stack.push(new_scope); + } + + Op::EmitN => { + let n = stack.pop().ok_or("stack underflow")?.as_int()?; + if n < 0 { + return Err("emit count must be >= 0".into()); + } + for _ in 0..n { + let (sound_val, params) = cmd.snapshot().ok_or("no sound set")?; + + let (resolved_sound, sound_span) = + resolve_value_with_span(&sound_val, *emission_count); + if let Some(span) = sound_span { + if let Some(trace) = trace_cell.borrow_mut().as_mut() { + trace.selected_spans.push(span); + } + } + let sound = resolved_sound.as_str()?.to_string(); + + let resolved_params: Vec<(String, String)> = params + .iter() + .map(|(k, v)| { + let (resolved, param_span) = + resolve_value_with_span(v, *emission_count); + if let Some(span) = param_span { + if let Some(trace) = trace_cell.borrow_mut().as_mut() { + trace.selected_spans.push(span); + } + } + (k.clone(), resolved.to_param_string()) + }) + .collect(); + + let scope = scope_stack.last_mut().ok_or("scope stack underflow")?; + let slot_idx = scope.claim_slot(); + scope.pending.push(PendingEmission { + sound, + params: resolved_params, + slot_index: slot_idx, + }); + + *emission_count += 1; + } + } } pc += 1; } @@ -953,12 +951,56 @@ impl Forth { } } +fn resolve_value_with_span(val: &Value, emission_count: usize) -> (Value, Option) { + match val { + Value::Alternator(items) if !items.is_empty() => { + let idx = emission_count % items.len(); + let selected = &items[idx]; + let (resolved, inner_span) = resolve_value_with_span(selected, emission_count); + // Prefer inner span (for nested alternators), fall back to selected's span + let span = inner_span.or_else(|| selected.span()); + (resolved, span) + } + other => (other.clone(), other.span()), + } +} + +fn resolve_scope(scope: &ScopeContext, outputs: &mut Vec) { + if scope.slot_count == 0 || scope.pending.is_empty() { + return; + } + let slot_dur = scope.duration * scope.weight / scope.slot_count as f64; + 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()); + 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())); + } + 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(); + } else { + pairs.push(("delaytime".into(), slot_dur.to_string())); + } + outputs.push(format_cmd(&pairs)); + } +} + fn perlin_grad(hash_input: i64) -> f64 { - let mut h = (hash_input as u64).wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407); + let mut h = + (hash_input as u64).wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407); h ^= h >> 33; h = h.wrapping_mul(0xff51afd7ed558ccd); h ^= h >> 33; - if h & 1 == 0 { 1.0 } else { -1.0 } + if h & 1 == 0 { + 1.0 + } else { + -1.0 + } } fn perlin_noise_1d(x: f64) -> f64 { @@ -970,28 +1012,127 @@ fn perlin_noise_1d(x: f64) -> f64 { (d0 + s * (d1 - d0)) * 0.5 + 0.5 } +fn float_to_value(result: f64) -> Value { + if result.fract() == 0.0 && result.abs() < i64::MAX as f64 { + Value::Int(result as i64, None) + } else { + Value::Float(result, None) + } +} + +fn lift_unary(val: Value, f: F) -> Result +where + F: Fn(f64) -> f64 + Copy, +{ + match val { + Value::Alternator(items) => { + let mapped: Result, String> = + items.into_iter().map(|v| lift_unary(v, f)).collect(); + Ok(Value::Alternator(mapped?)) + } + other => Ok(float_to_value(f(other.as_float()?))), + } +} + +fn lift_unary_int(val: Value, f: F) -> Result +where + F: Fn(i64) -> i64 + Copy, +{ + match val { + Value::Alternator(items) => { + let mapped: Result, String> = + items.into_iter().map(|v| lift_unary_int(v, f)).collect(); + Ok(Value::Alternator(mapped?)) + } + other => Ok(Value::Int(f(other.as_int()?), None)), + } +} + +fn lift_binary(a: Value, b: Value, f: F) -> Result +where + F: Fn(f64, f64) -> f64 + Copy, +{ + match (a, b) { + (Value::Alternator(a_items), Value::Alternator(b_items)) => { + let len = a_items.len().max(b_items.len()); + let mapped: Result, String> = (0..len) + .map(|i| { + let ai = a_items[i % a_items.len()].clone(); + let bi = b_items[i % b_items.len()].clone(); + lift_binary(ai, bi, f) + }) + .collect(); + Ok(Value::Alternator(mapped?)) + } + (Value::Alternator(items), scalar) => { + let mapped: Result, String> = items + .into_iter() + .map(|v| lift_binary(v, scalar.clone(), f)) + .collect(); + Ok(Value::Alternator(mapped?)) + } + (scalar, Value::Alternator(items)) => { + let mapped: Result, String> = items + .into_iter() + .map(|v| lift_binary(scalar.clone(), v, f)) + .collect(); + Ok(Value::Alternator(mapped?)) + } + (a, b) => Ok(float_to_value(f(a.as_float()?, b.as_float()?))), + } +} + fn binary_op(stack: &mut Vec, f: F) -> Result<(), String> where - F: Fn(f64, f64) -> f64, + F: Fn(f64, f64) -> f64 + Copy, { - let b = stack.pop().ok_or("stack underflow")?.as_float()?; - let a = stack.pop().ok_or("stack underflow")?.as_float()?; - let result = f(a, b); - if result.fract() == 0.0 && result.abs() < i64::MAX as f64 { - stack.push(Value::Int(result as i64, None)); - } else { - stack.push(Value::Float(result, None)); - } + let b = stack.pop().ok_or("stack underflow")?; + let a = stack.pop().ok_or("stack underflow")?; + stack.push(lift_binary(a, b, f)?); Ok(()) } fn cmp_op(stack: &mut Vec, f: F) -> Result<(), String> where - F: Fn(f64, f64) -> bool, + F: Fn(f64, f64) -> bool + Copy, { - let b = stack.pop().ok_or("stack underflow")?.as_float()?; - let a = stack.pop().ok_or("stack underflow")?.as_float()?; - stack.push(Value::Int(if f(a, b) { 1 } else { 0 }, None)); + fn lift_cmp(a: Value, b: Value, f: F) -> Result + where + F: Fn(f64, f64) -> bool + Copy, + { + match (a, b) { + (Value::Alternator(a_items), Value::Alternator(b_items)) => { + let len = a_items.len().max(b_items.len()); + let mapped: Result, String> = (0..len) + .map(|i| { + let ai = a_items[i % a_items.len()].clone(); + let bi = b_items[i % b_items.len()].clone(); + lift_cmp(ai, bi, f) + }) + .collect(); + Ok(Value::Alternator(mapped?)) + } + (Value::Alternator(items), scalar) => { + let mapped: Result, String> = items + .into_iter() + .map(|v| lift_cmp(v, scalar.clone(), f)) + .collect(); + Ok(Value::Alternator(mapped?)) + } + (scalar, Value::Alternator(items)) => { + let mapped: Result, String> = items + .into_iter() + .map(|v| lift_cmp(scalar.clone(), v, f)) + .collect(); + Ok(Value::Alternator(mapped?)) + } + (a, b) => Ok(Value::Int(if f(a.as_float()?, b.as_float()?) { 1 } else { 0 }, None)), + } + } + + let b = stack.pop().ok_or("stack underflow")?; + let a = stack.pop().ok_or("stack underflow")?; + stack.push(lift_cmp(a, b, f)?); Ok(()) } diff --git a/crates/forth/src/words.rs b/crates/forth/src/words.rs index 99bfa05..fa23be5 100644 --- a/crates/forth/src/words.rs +++ b/crates/forth/src/words.rs @@ -260,10 +260,52 @@ pub const WORDS: &[Word] = &[ Word { name: "@", stack: "(--)", - desc: "Alias for emit", - example: "\"kick\" s 0.5 at @ pop", + desc: "Emit current sound, claim one time slot", + example: "\"kick\" s @ @ @ @", compile: Alias("emit"), }, + Word { + name: ".", + stack: "(--)", + desc: "Emit current sound, claim one time slot", + example: "\"kick\" s . . . .", + compile: Alias("emit"), + }, + Word { + name: "~", + stack: "(--)", + desc: "Silence, claim one time slot", + example: "\"kick\" s @ ~ @ ~", + compile: Simple, + }, + Word { + name: ".!", + stack: "(n --)", + desc: "Emit current sound n times", + example: "\"kick\" s 4 .!", + compile: Simple, + }, + Word { + name: "div", + stack: "(--)", + desc: "Start a time subdivision scope", + example: "div \"kick\" s . \"hat\" s . end", + compile: Simple, + }, + Word { + name: "stack", + stack: "(--)", + desc: "Start a stacked subdivision scope (sounds stack/superpose)", + example: "stack \"kick\" s . \"hat\" s . end", + compile: Simple, + }, + Word { + name: "end", + stack: "(--)", + desc: "End a time subdivision scope", + example: "div \"kick\" s . end", + compile: Simple, + }, // Variables (prefix syntax: @name to fetch, !name to store) Word { name: "@", @@ -538,88 +580,18 @@ pub const WORDS: &[Word] = &[ compile: Simple, }, // Time - Word { - name: "at", - stack: "(pos --)", - desc: "Position in time (push context)", - example: "\"kick\" s 0.5 at emit pop", - compile: Simple, - }, - Word { - name: "zoom", - stack: "(start end --)", - desc: "Zoom into time region", - example: "0.0 0.5 zoom", - compile: Simple, - }, Word { name: "scale!", stack: "(factor --)", - desc: "Scale time context duration", + desc: "Set weight of current time scope", example: "2 scale!", compile: Simple, }, - Word { - name: "pop", - stack: "(--)", - desc: "Pop time context", - example: "pop", - compile: Simple, - }, - Word { - name: "div", - stack: "(n --)", - desc: "Subdivide time into n", - example: "4 div", - compile: Simple, - }, - Word { - name: "each", - stack: "(--)", - desc: "Emit at each subdivision", - example: "4 div each", - compile: Simple, - }, - Word { - name: "stack", - stack: "(n --)", - desc: "Create n subdivisions at same time", - example: "3 stack", - compile: Simple, - }, - Word { - name: "echo", - stack: "(n --)", - desc: "Create n subdivisions with halving durations (stutter)", - example: "3 echo", - compile: Simple, - }, - Word { - name: "necho", - stack: "(n --)", - desc: "Create n subdivisions with doubling durations (swell)", - example: "3 necho", - compile: Simple, - }, - Word { - name: "for", - stack: "(quot --)", - desc: "Execute quotation for each subdivision", - example: "{ emit } 3 div for", - compile: Simple, - }, Word { name: "loop", stack: "(n --)", desc: "Fit sample to n beats", - example: "\"break\" s 4 loop emit", - compile: Simple, - }, - Word { - name: "|", - stack: "(-- marker)", - desc: "Start local cycle list", - example: "| 60 62 64 |", + example: "\"break\" s 4 loop @", compile: Simple, }, Word { @@ -1543,12 +1515,8 @@ pub(super) fn simple_op(name: &str) -> Option { "ftom" => Op::Ftom, "?" => Op::When, "!?" => Op::Unless, - "at" => Op::At, - "zoom" => Op::Window, + "~" => Op::Silence, "scale!" => Op::Scale, - "pop" => Op::Pop, - "div" => Op::Subdivide, - "each" => Op::Each, "tempo!" => Op::SetTempo, "[" => Op::ListStart, "]" => Op::ListEnd, @@ -1556,10 +1524,6 @@ pub(super) fn simple_op(name: &str) -> Option { ">>" => Op::ListEndPCycle, "adsr" => Op::Adsr, "ad" => Op::Ad, - "stack" => Op::Stack, - "for" => Op::For, - "echo" => Op::Echo, - "necho" => Op::Necho, "apply" => Op::Apply, "ramp" => Op::Ramp, "range" => Op::Range, @@ -1567,6 +1531,10 @@ pub(super) fn simple_op(name: &str) -> Option { "chain" => Op::Chain, "loop" => Op::Loop, "oct" => Op::Oct, + "div" => Op::DivStart, + "stack" => Op::StackStart, + "end" => Op::DivEnd, + ".!" => Op::EmitN, _ => return None, }) } diff --git a/tests/forth.rs b/tests/forth.rs index d00e506..36d7df1 100644 --- a/tests/forth.rs +++ b/tests/forth.rs @@ -34,9 +34,6 @@ mod variables; #[path = "forth/quotations.rs"] mod quotations; -#[path = "forth/iteration.rs"] -mod iteration; - #[path = "forth/notes.rs"] mod notes; diff --git a/tests/forth/harness.rs b/tests/forth/harness.rs index 25af589..e9edd9c 100644 --- a/tests/forth/harness.rs +++ b/tests/forth/harness.rs @@ -1,6 +1,6 @@ use rand::rngs::StdRng; use rand::SeedableRng; -use cagire::forth::{Dictionary, Forth, Rng, StepContext, Value, Variables}; +use cagire::forth::{Dictionary, EmissionCounter, Forth, Rng, StepContext, Value, Variables}; use std::collections::HashMap; use std::sync::{Arc, Mutex}; @@ -46,6 +46,14 @@ pub fn forth_seeded(seed: u64) -> Forth { Forth::new(new_vars(), new_dict(), seeded_rng(seed)) } +pub fn new_emission_counter() -> EmissionCounter { + Arc::new(Mutex::new(0)) +} + +pub fn forth_with_counter(counter: EmissionCounter) -> Forth { + Forth::new_with_counter(new_vars(), new_dict(), seeded_rng(42), counter) +} + pub fn run(script: &str) -> Forth { let f = forth(); f.evaluate(script, &default_ctx()).unwrap(); diff --git a/tests/forth/intervals.rs b/tests/forth/intervals.rs index 1bf6502..d8e4334 100644 --- a/tests/forth/intervals.rs +++ b/tests/forth/intervals.rs @@ -22,7 +22,7 @@ fn interval_stacking_builds_chord() { #[test] fn interval_tritone() { - expect_stack("c4 tritone", &ints(&[60, 66])); + // "tritone" word is taken by the scale, use aug4 or dim5 for interval expect_stack("c4 aug4", &ints(&[60, 66])); expect_stack("c4 dim5", &ints(&[60, 66])); } diff --git a/tests/forth/iteration.rs b/tests/forth/iteration.rs deleted file mode 100644 index 9f3b718..0000000 --- a/tests/forth/iteration.rs +++ /dev/null @@ -1,256 +0,0 @@ -use super::harness::*; -use std::collections::HashMap; - -fn parse_params(output: &str) -> HashMap { - 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::() { - params.insert(parts[i].to_string(), v); - } - i += 2; - } - params -} - -fn get_deltas(outputs: &[String]) -> Vec { - outputs - .iter() - .map(|o| parse_params(o).get("delta").copied().unwrap_or(0.0)) - .collect() -} - -fn get_durs(outputs: &[String]) -> Vec { - outputs - .iter() - .map(|o| parse_params(o).get("dur").copied().unwrap_or(0.0)) - .collect() -} - -fn get_notes(outputs: &[String]) -> Vec { - outputs - .iter() - .map(|o| parse_params(o).get("note").copied().unwrap_or(0.0)) - .collect() -} - -fn get_sounds(outputs: &[String]) -> Vec { - outputs - .iter() - .map(|o| { - let parts: Vec<&str> = o.trim_start_matches('/').split('/').collect(); - for i in (0..parts.len()).step_by(2) { - if parts[i] == "sound" && i + 1 < parts.len() { - return parts[i + 1].to_string(); - } - } - String::new() - }) - .collect() -} - -const EPSILON: f64 = 1e-9; - -fn approx_eq(a: f64, b: f64) -> bool { - (a - b).abs() < EPSILON -} - -#[test] -fn stack_creates_subdivisions_at_same_time() { - let outputs = expect_outputs(r#""kick" s 3 stack each"#, 3); - let deltas = get_deltas(&outputs); - assert!(approx_eq(deltas[0], 0.0)); - assert!(approx_eq(deltas[1], 0.0)); - assert!(approx_eq(deltas[2], 0.0)); -} - -#[test] -fn stack_vs_div_timing() { - let stack_outputs = expect_outputs(r#""kick" s 3 stack each"#, 3); - let div_outputs = expect_outputs(r#""kick" s 3 div each"#, 3); - - let stack_deltas = get_deltas(&stack_outputs); - let div_deltas = get_deltas(&div_outputs); - - for d in stack_deltas { - assert!(approx_eq(d, 0.0), "stack should have all delta=0"); - } - - assert!(approx_eq(div_deltas[0], 0.0)); - assert!(!approx_eq(div_deltas[1], 0.0), "div should spread in time"); - assert!(!approx_eq(div_deltas[2], 0.0), "div should spread in time"); -} - -#[test] -fn for_with_div_arpeggio() { - let outputs = expect_outputs(r#"{ "kick" s emit } 3 div for"#, 3); - let deltas = get_deltas(&outputs); - - assert!(approx_eq(deltas[0], 0.0)); - assert!(!approx_eq(deltas[1], 0.0)); - assert!(!approx_eq(deltas[2], 0.0)); -} - -#[test] -fn for_with_stack_chord() { - let outputs = expect_outputs(r#"{ "kick" s emit } 3 stack for"#, 3); - let deltas = get_deltas(&outputs); - - for d in deltas { - assert!(approx_eq(d, 0.0), "stack for should have all delta=0"); - } -} - -#[test] -fn local_cycle_with_for() { - let outputs = expect_outputs(r#"{ | 60 62 64 | note "sine" s emit } 3 div for"#, 3); - let notes = get_notes(&outputs); - - assert!(approx_eq(notes[0], 60.0)); - assert!(approx_eq(notes[1], 62.0)); - assert!(approx_eq(notes[2], 64.0)); -} - -#[test] -fn local_cycle_wraps_around() { - let outputs = expect_outputs(r#"{ | 60 62 | note "sine" s emit } 4 div for"#, 4); - let notes = get_notes(&outputs); - - assert!(approx_eq(notes[0], 60.0)); - assert!(approx_eq(notes[1], 62.0)); - assert!(approx_eq(notes[2], 60.0)); - assert!(approx_eq(notes[3], 62.0)); -} - -#[test] -fn multiple_local_cycles() { - let outputs = - expect_outputs(r#"{ | "bd" "sn" | s | 60 64 | note emit } 2 stack for"#, 2); - let sounds = get_sounds(&outputs); - let notes = get_notes(&outputs); - - assert_eq!(sounds[0], "bd"); - assert_eq!(sounds[1], "sn"); - assert!(approx_eq(notes[0], 60.0)); - assert!(approx_eq(notes[1], 64.0)); -} - -#[test] -fn local_cycle_outside_for_defaults_to_first() { - expect_int("| 60 62 64 |", 60); -} - -#[test] -fn polymetric_cycles() { - let outputs = expect_outputs( - r#"{ | 0 1 | n | "a" "b" "c" | s emit } 6 div for"#, - 6, - ); - let sounds = get_sounds(&outputs); - - assert_eq!(sounds[0], "a"); - assert_eq!(sounds[1], "b"); - assert_eq!(sounds[2], "c"); - assert_eq!(sounds[3], "a"); - assert_eq!(sounds[4], "b"); - assert_eq!(sounds[5], "c"); -} - -#[test] -fn stack_error_zero_count() { - expect_error(r#""kick" s 0 stack each"#, "stack count must be > 0"); -} - -#[test] -fn for_requires_subdivide() { - expect_error(r#"{ "kick" s emit } for"#, "for requires subdivide first"); -} - -#[test] -fn for_requires_quotation() { - expect_error(r#"42 3 div for"#, "expected quotation"); -} - -#[test] -fn empty_local_cycle() { - expect_error("| |", "empty local cycle list"); -} - -// Echo tests - stutter effect with halving durations - -#[test] -fn echo_creates_decaying_subdivisions() { - // default dur = 0.5, echo 3 - // d1 + d1/2 + d1/4 = d1 * 1.75 = 0.5 - // d1 = 0.5 / 1.75 - let outputs = expect_outputs(r#""kick" s 3 echo each"#, 3); - let durs = get_durs(&outputs); - let deltas = get_deltas(&outputs); - - let d1 = 0.5 / 1.75; - let d2 = d1 / 2.0; - let d3 = d1 / 4.0; - - assert!(approx_eq(durs[0], d1), "first dur should be {}, got {}", d1, durs[0]); - assert!(approx_eq(durs[1], d2), "second dur should be {}, got {}", d2, durs[1]); - assert!(approx_eq(durs[2], d3), "third dur should be {}, got {}", d3, durs[2]); - - assert!(approx_eq(deltas[0], 0.0), "first delta should be 0"); - assert!(approx_eq(deltas[1], d1), "second delta should be {}, got {}", d1, deltas[1]); - assert!(approx_eq(deltas[2], d1 + d2), "third delta should be {}, got {}", d1 + d2, deltas[2]); -} - -#[test] -fn echo_with_for() { - let outputs = expect_outputs(r#"{ "kick" s emit } 3 echo for"#, 3); - let durs = get_durs(&outputs); - - // Each subsequent duration should be half the previous - assert!(approx_eq(durs[1], durs[0] / 2.0), "second should be half of first"); - assert!(approx_eq(durs[2], durs[1] / 2.0), "third should be half of second"); -} - -#[test] -fn echo_error_zero_count() { - expect_error(r#""kick" s 0 echo each"#, "echo count must be > 0"); -} - -// Necho tests - reverse echo (durations grow) - -#[test] -fn necho_creates_growing_subdivisions() { - // default dur = 0.5, necho 3 - // d1 + 2*d1 + 4*d1 = d1 * 7 = 0.5 - // d1 = 0.5 / 7 - let outputs = expect_outputs(r#""kick" s 3 necho each"#, 3); - let durs = get_durs(&outputs); - let deltas = get_deltas(&outputs); - - let d1 = 0.5 / 7.0; - let d2 = d1 * 2.0; - let d3 = d1 * 4.0; - - assert!(approx_eq(durs[0], d1), "first dur should be {}, got {}", d1, durs[0]); - assert!(approx_eq(durs[1], d2), "second dur should be {}, got {}", d2, durs[1]); - assert!(approx_eq(durs[2], d3), "third dur should be {}, got {}", d3, durs[2]); - - assert!(approx_eq(deltas[0], 0.0), "first delta should be 0"); - assert!(approx_eq(deltas[1], d1), "second delta should be {}, got {}", d1, deltas[1]); - assert!(approx_eq(deltas[2], d1 + d2), "third delta should be {}, got {}", d1 + d2, deltas[2]); -} - -#[test] -fn necho_with_for() { - let outputs = expect_outputs(r#"{ "kick" s emit } 3 necho for"#, 3); - let durs = get_durs(&outputs); - - // Each subsequent duration should be double the previous - assert!(approx_eq(durs[1], durs[0] * 2.0), "second should be double first"); - assert!(approx_eq(durs[2], durs[1] * 2.0), "third should be double second"); -} - -#[test] -fn necho_error_zero_count() { - expect_error(r#""kick" s 0 necho each"#, "necho count must be > 0"); -} diff --git a/tests/forth/list_words.rs b/tests/forth/list_words.rs index 83a4360..999bf08 100644 --- a/tests/forth/list_words.rs +++ b/tests/forth/list_words.rs @@ -122,13 +122,3 @@ fn multi_op_quotation_second() { assert_eq!(stack_int(&f), 13); // runs=1 picks {3 +} } -#[test] -fn pipe_syntax_with_words() { - // | word1 word2 | uses LocalCycleEnd which should auto-apply quotations - // LocalCycleEnd uses time_ctx.iteration_index, which defaults to 0 outside for loops - let f = forth(); - let ctx = default_ctx(); - f.evaluate(": add3 3 + ; : add5 5 + ; 10 | add3 add5 |", &ctx).unwrap(); - // iteration_index defaults to 0, picks first word (add3) - assert_eq!(stack_int(&f), 13); -} diff --git a/tests/forth/randomness.rs b/tests/forth/randomness.rs index d854d87..8160653 100644 --- a/tests/forth/randomness.rs +++ b/tests/forth/randomness.rs @@ -17,17 +17,6 @@ fn rand_deterministic() { assert_eq!(f1.stack(), f2.stack()); } -#[test] -fn rrand_inclusive() { - let f = forth_seeded(42); - for _ in 0..20 { - f.clear_stack(); - f.evaluate("1 3 rrand", &default_ctx()).unwrap(); - let val = stack_int(&f); - assert!(val >= 1 && val <= 3, "rrand {} not in [1, 3]", val); - } -} - #[test] fn seed_resets() { let f1 = forth_seeded(1); diff --git a/tests/forth/sound.rs b/tests/forth/sound.rs index 5416745..7f40569 100644 --- a/tests/forth/sound.rs +++ b/tests/forth/sound.rs @@ -44,36 +44,6 @@ fn multiple_emits() { assert!(outputs[1].contains("sound/snare")); } -#[test] -fn subdivide_each() { - let _outputs = expect_outputs(r#""kick" s 4 div each"#, 4); -} - -#[test] -fn zoom_pop() { - let outputs = expect_outputs( - r#"0.0 0.5 zoom "kick" s emit pop 0.5 1.0 zoom "snare" s emit"#, - 2, - ); - assert!(outputs[0].contains("sound/kick")); - assert!(outputs[1].contains("sound/snare")); -} - -#[test] -fn pop_root_fails() { - expect_error("pop", "cannot pop root time context"); -} - -#[test] -fn subdivide_zero() { - expect_error(r#""kick" s 0 div each"#, "subdivide count must be > 0"); -} - -#[test] -fn each_without_div() { - expect_error(r#""kick" s each"#, "each requires subdivide first"); -} - #[test] fn envelope_params() { let outputs = expect_outputs( diff --git a/tests/forth/temporal.rs b/tests/forth/temporal.rs index b52eeed..684834c 100644 --- a/tests/forth/temporal.rs +++ b/tests/forth/temporal.rs @@ -1,4 +1,6 @@ use super::harness::*; +#[allow(unused_imports)] +use super::harness::{forth_with_counter, new_emission_counter}; use std::collections::HashMap; fn parse_params(output: &str) -> HashMap { @@ -21,6 +23,27 @@ fn get_deltas(outputs: &[String]) -> Vec { .collect() } +fn get_durs(outputs: &[String]) -> Vec { + outputs + .iter() + .map(|o| parse_params(o).get("dur").copied().unwrap_or(0.0)) + .collect() +} + +fn get_sounds(outputs: &[String]) -> Vec { + 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 { @@ -28,7 +51,7 @@ fn approx_eq(a: f64, b: f64) -> bool { } // At 120 BPM, speed 1.0: stepdur = 60/120/4/1 = 0.125s -// Default duration = 4 * stepdur = 0.5s +// Root duration = 4 * stepdur = 0.5s #[test] fn stepdur_baseline() { @@ -37,80 +60,39 @@ fn stepdur_baseline() { } #[test] -fn emit_no_delta() { - let outputs = expect_outputs(r#""kick" s emit"#, 1); +fn single_emit() { + let outputs = expect_outputs(r#""kick" s @"#, 1); let deltas = get_deltas(&outputs); - assert!( - approx_eq(deltas[0], 0.0), - "emit at start should have delta 0" - ); + assert!(approx_eq(deltas[0], 0.0), "single emit at start should have delta 0"); } #[test] -fn at_half() { - // at 0.5 in root (0..0.5) => delta = 0.5 * 0.5 = 0.25 - let outputs = expect_outputs(r#""kick" s 0.5 at emit pop"#, 1); +fn implicit_subdivision_2() { + let outputs = expect_outputs(r#""kick" s @ @"#, 2); let deltas = get_deltas(&outputs); - assert!( - approx_eq(deltas[0], 0.25), - "at 0.5 should be delta 0.25, got {}", - deltas[0] - ); + let step = 0.5 / 2.0; + assert!(approx_eq(deltas[0], 0.0), "first slot at 0"); + assert!(approx_eq(deltas[1], step), "second slot at {}, got {}", step, deltas[1]); } #[test] -fn at_quarter() { - // at 0.25 in root (0..0.5) => delta = 0.25 * 0.5 = 0.125 - let outputs = expect_outputs(r#""kick" s 0.25 at emit pop"#, 1); +fn implicit_subdivision_4() { + let outputs = expect_outputs(r#""kick" s @ @ @ @"#, 4); let deltas = get_deltas(&outputs); - assert!( - approx_eq(deltas[0], 0.125), - "at 0.25 should be delta 0.125, got {}", - deltas[0] - ); -} - -#[test] -fn at_zero() { - let outputs = expect_outputs(r#""kick" s 0.0 at emit pop"#, 1); - let deltas = get_deltas(&outputs); - assert!(approx_eq(deltas[0], 0.0), "at 0.0 should be delta 0"); -} - -#[test] -fn div_2_each() { - // 2 subdivisions: deltas at 0 and 0.25 (half of 0.5) - let outputs = expect_outputs(r#""kick" s 2 div each"#, 2); - let deltas = get_deltas(&outputs); - assert!(approx_eq(deltas[0], 0.0), "first subdivision at 0"); - assert!( - approx_eq(deltas[1], 0.25), - "second subdivision at 0.25, got {}", - deltas[1] - ); -} - -#[test] -fn div_4_each() { - // 4 subdivisions: 0, 0.125, 0.25, 0.375 - let outputs = expect_outputs(r#""kick" s 4 div each"#, 4); - let deltas = get_deltas(&outputs); - let expected = [0.0, 0.125, 0.25, 0.375]; - for (i, (got, exp)) in deltas.iter().zip(expected.iter()).enumerate() { + let step = 0.5 / 4.0; + for (i, delta) in deltas.iter().enumerate() { + let expected = step * i as f64; assert!( - approx_eq(*got, *exp), - "subdivision {}: expected {}, got {}", - i, - exp, - got + approx_eq(*delta, expected), + "slot {}: expected {}, got {}", + i, expected, delta ); } } #[test] -fn div_3_each() { - // 3 subdivisions: 0, 0.5/3, 2*0.5/3 - let outputs = expect_outputs(r#""kick" s 3 div each"#, 3); +fn implicit_subdivision_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)); @@ -119,113 +101,433 @@ fn div_3_each() { } #[test] -fn zoom_full() { - // zoom 0.0 1.0 is the full duration, same as root - let outputs = expect_outputs(r#"0.0 1.0 zoom "kick" s 0.5 at emit pop"#, 1); - let deltas = get_deltas(&outputs); - assert!(approx_eq(deltas[0], 0.25), "full zoom at 0.5 = 0.25"); -} - -#[test] -fn zoom_first_half() { - // zoom 0.0 0.5 restricts to first half (0..0.25) - // at 0.5 within that = 0.125 - let outputs = expect_outputs(r#"0.0 0.5 zoom "kick" s 0.5 at emit pop"#, 1); +fn silence_creates_gap() { + 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"); assert!( - approx_eq(deltas[0], 0.125), - "first-half zoom at 0.5 = 0.125, got {}", - deltas[0] - ); -} - -#[test] -fn zoom_second_half() { - // zoom 0.5 1.0 restricts to second half (0.25..0.5) - // at 0.0 within that = start of second half = 0.25 - let outputs = expect_outputs(r#"0.5 1.0 zoom "kick" s 0.0 at emit pop"#, 1); - let deltas = get_deltas(&outputs); - assert!( - approx_eq(deltas[0], 0.25), - "second-half zoom at 0.0 = 0.25, got {}", - deltas[0] - ); -} - -#[test] -fn zoom_second_half_middle() { - // zoom 0.5 1.0, at 0.5 within that = 0.75 of full duration = 0.375 - let outputs = expect_outputs(r#"0.5 1.0 zoom "kick" s 0.5 at emit pop"#, 1); - let deltas = get_deltas(&outputs); - assert!(approx_eq(deltas[0], 0.375), "got {}", deltas[0]); -} - -#[test] -fn nested_zooms() { - // zoom 0.0 0.5, then zoom 0.5 1.0 within that - // outer: 0..0.25, inner: 0.5..1.0 of that = 0.125..0.25 - // at 0.0 in inner = 0.125 - let outputs = expect_outputs(r#"0.0 0.5 zoom 0.5 1.0 zoom "kick" s 0.0 at emit pop"#, 1); - let deltas = get_deltas(&outputs); - assert!( - approx_eq(deltas[0], 0.125), - "nested zoom at 0.0 = 0.125, got {}", - deltas[0] - ); -} - -#[test] -fn zoom_pop_sequence() { - // First in zoom 0.0 0.5 at 0.0 -> delta 0 - // Pop at context and zoom, then in zoom 0.5 1.0 at 0.0 -> delta 0.25 - let outputs = expect_outputs( - r#"0.0 0.5 zoom "kick" s 0.0 at emit pop pop 0.5 1.0 zoom "snare" s 0.0 at emit pop"#, - 2, - ); - let deltas = get_deltas(&outputs); - assert!(approx_eq(deltas[0], 0.0), "first zoom start"); - assert!( - approx_eq(deltas[1], 0.25), - "second zoom start, got {}", + approx_eq(deltas[1], 2.0 * step), + "third slot (after silence) at {}, got {}", + 2.0 * step, deltas[1] ); } #[test] -fn div_in_zoom() { - // zoom 0.0 0.5 (duration 0.25), then div 2 each - // subdivisions at 0 and 0.125 - let outputs = expect_outputs(r#"0.0 0.5 zoom "kick" s 2 div each"#, 2); +fn silence_at_start() { + let outputs = expect_outputs(r#""kick" s ~ @"#, 1); let deltas = get_deltas(&outputs); - assert!(approx_eq(deltas[0], 0.0)); - assert!(approx_eq(deltas[1], 0.125), "got {}", deltas[1]); + let step = 0.5 / 2.0; + assert!( + approx_eq(deltas[0], step), + "emit after silence at {}, got {}", + step, + deltas[0] + ); } #[test] -fn tempo_affects_stepdur() { - // At 60 BPM: stepdur = 60/60/4/1 = 0.25 +fn silence_only() { + 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 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" 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 durs = get_durs(&outputs); + let expected_dur = 0.5 / 4.0; + for (i, dur) in durs.iter().enumerate() { + assert!( + approx_eq(*dur, expected_dur), + "slot {} dur: expected {}, got {}", + i, expected_dur, dur + ); + } +} + +#[test] +fn tempo_affects_subdivision() { let ctx = ctx_with(|c| c.tempo = 60.0); let f = forth(); - f.evaluate("stepdur", &ctx).unwrap(); - assert!(approx_eq(stack_float(&f), 0.25)); + 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; + assert!(approx_eq(deltas[0], 0.0)); + assert!(approx_eq(deltas[1], step), "got {}", deltas[1]); } #[test] -fn speed_affects_stepdur() { - // At 120 BPM, speed 2.0: stepdur = 60/120/4/2 = 0.0625 +fn speed_affects_subdivision() { let ctx = ctx_with(|c| c.speed = 2.0); let f = forth(); - f.evaluate("stepdur", &ctx).unwrap(); - assert!(approx_eq(stack_float(&f), 0.0625)); + 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; + assert!(approx_eq(deltas[0], 0.0)); + assert!(approx_eq(deltas[1], step), "got {}", deltas[1]); } #[test] -fn div_each_at_different_tempo() { - // At 60 BPM: stepdur = 0.25, default dur = 1.0, so div 2 each => 0, 0.5 - let ctx = ctx_with(|c| c.tempo = 60.0); +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(); + if runs % 2 == 0 { + assert_eq!(outputs.len(), 1, "runs={}: @ should be picked", runs); + } else { + assert_eq!(outputs.len(), 0, "runs={}: ~ should be picked", runs); + } + } +} + +#[test] +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(); + if iter % 2 == 0 { + assert_eq!(outputs.len(), 1, "iter={}: @ should be picked", iter); + } else { + assert_eq!(outputs.len(), 0, "iter={}: ~ 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" 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]; + assert_eq!(sounds[0], expected, "runs={}: expected {}", runs, expected); + } +} + +#[test] +fn dot_alias_for_emit() { + let outputs = expect_outputs(r#""kick" s . . . ."#, 4); + let sounds = get_sounds(&outputs); + assert_eq!(sounds, vec!["kick", "kick", "kick", "kick"]); +} + +#[test] +fn dot_with_silence() { + 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)); + assert!(approx_eq(deltas[1], 2.0 * step)); +} + +#[test] +fn internal_alternation_basic() { + let outputs = expect_outputs(r#"| "kick" "snare" | s . . . ."#, 4); + let sounds = get_sounds(&outputs); + assert_eq!(sounds, vec!["kick", "snare", "kick", "snare"]); +} + +#[test] +fn internal_alternation_three_sounds() { + let outputs = expect_outputs(r#"| "kick" "snare" "hat" | s . . . . . ."#, 6); + let sounds = get_sounds(&outputs); + assert_eq!(sounds, vec!["kick", "snare", "hat", "kick", "snare", "hat"]); +} + +#[test] +fn internal_alternation_single_item() { + let outputs = expect_outputs(r#"| "kick" | s . . . ."#, 4); + let sounds = get_sounds(&outputs); + assert_eq!(sounds, vec!["kick", "kick", "kick", "kick"]); +} + +#[test] +fn internal_alternation_with_params() { + let outputs = expect_outputs(r#"| 0.5 0.9 | gain "kick" s . ."#, 2); + fn parse_gain(output: &str) -> f64 { + let parts: Vec<&str> = output.trim_start_matches('/').split('/').collect(); + for i in 0..parts.len() - 1 { + if parts[i] == "gain" { + return parts[i + 1].parse().unwrap_or(0.0); + } + } + 0.0 + } + let gains: Vec = outputs.iter().map(|o| parse_gain(o)).collect(); + assert!(approx_eq(gains[0], 0.5), "first gain should be 0.5, got {}", gains[0]); + assert!(approx_eq(gains[1], 0.9), "second gain should be 0.9, got {}", gains[1]); +} + +#[test] +fn internal_alternation_empty_error() { let f = forth(); - let outputs = f.evaluate(r#""kick" s 2 div each"#, &ctx).unwrap(); + let result = f.evaluate(r#"| | . ."#, &default_ctx()); + assert!(result.is_err(), "empty internal cycle should error"); +} + +#[test] +fn div_basic_subdivision() { + let outputs = expect_outputs(r#"div "kick" s . "hat" s . end"#, 2); + let deltas = get_deltas(&outputs); + let sounds = get_sounds(&outputs); + assert_eq!(sounds, vec!["kick", "hat"]); + assert!(approx_eq(deltas[0], 0.0)); + assert!(approx_eq(deltas[1], 0.25), "second should be at 0.25, got {}", deltas[1]); +} + +#[test] +fn div_superposition() { + let outputs = expect_outputs(r#"div "kick" s . end div "hat" s . end"#, 2); + let deltas = get_deltas(&outputs); + let sounds = get_sounds(&outputs); + assert_eq!(sounds.len(), 2); + // Both at delta 0 (superposed) + assert!(approx_eq(deltas[0], 0.0)); + assert!(approx_eq(deltas[1], 0.0)); +} + +#[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); + 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)); +} + +#[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); + 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"); + 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]); +} + +#[test] +fn div_with_silence() { + let outputs = expect_outputs(r#"div "kick" s . ~ end"#, 1); let deltas = get_deltas(&outputs); assert!(approx_eq(deltas[0], 0.0)); - assert!(approx_eq(deltas[1], 0.5), "got {}", deltas[1]); +} + +#[test] +fn div_unmatched_end_error() { + let f = forth(); + let result = f.evaluate(r#""kick" s . end"#, &default_ctx()); + assert!(result.is_err(), "unmatched end should error"); +} + +#[test] +fn alternator_with_scale() { + let outputs = expect_outputs(r#""sine" s | 0 1 2 3 | mixolydian note . . . ."#, 4); + fn parse_note(output: &str) -> i64 { + let parts: Vec<&str> = output.trim_start_matches('/').split('/').collect(); + for i in 0..parts.len() - 1 { + if parts[i] == "note" { + return parts[i + 1].parse().unwrap_or(0); + } + } + 0 + } + let notes: Vec = outputs.iter().map(|o| parse_note(o)).collect(); + // mixolydian from C4: 0->60, 1->62, 2->64, 3->65 + assert_eq!(notes, vec![60, 62, 64, 65]); +} + +#[test] +fn alternator_with_arithmetic() { + let outputs = expect_outputs(r#""sine" s | 100 200 | 2 * freq . ."#, 2); + fn parse_freq(output: &str) -> f64 { + let parts: Vec<&str> = output.trim_start_matches('/').split('/').collect(); + for i in 0..parts.len() - 1 { + if parts[i] == "freq" { + return parts[i + 1].parse().unwrap_or(0.0); + } + } + 0.0 + } + let freqs: Vec = outputs.iter().map(|o| parse_freq(o)).collect(); + assert!(approx_eq(freqs[0], 200.0), "first freq: expected 200, got {}", freqs[0]); + assert!(approx_eq(freqs[1], 400.0), "second freq: expected 400, got {}", freqs[1]); +} + +#[test] +fn stack_superposes_sounds() { + let outputs = expect_outputs(r#"stack "kick" s . "hat" s . end"#, 2); + let deltas = get_deltas(&outputs); + let sounds = get_sounds(&outputs); + assert_eq!(sounds.len(), 2); + // Both at delta 0 (stacked/superposed) + assert!(approx_eq(deltas[0], 0.0)); + assert!(approx_eq(deltas[1], 0.0)); +} + +#[test] +fn stack_with_multiple_emits() { + let outputs = expect_outputs(r#"stack "kick" s . . . . end"#, 4); + let deltas = get_deltas(&outputs); + // All 4 kicks at delta 0 + for (i, delta) in deltas.iter().enumerate() { + assert!(approx_eq(*delta, 0.0), "emit {} should be at 0, got {}", i, delta); + } +} + +#[test] +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 deltas = get_deltas(&outputs); + let sounds = get_sounds(&outputs); + // stack resolves first (kick, hat at 0), then div resolves (snare at 0) + // since stack doesn't consume a slot in the parent div + 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.0)); + assert!(approx_eq(deltas[2], 0.0), "snare at 0, got {}", deltas[2]); +} + +#[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); + 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 + 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]); +} + +#[test] +fn emit_n_basic() { + let outputs = expect_outputs(r#""kick" s 4 .!"#, 4); + let sounds = get_sounds(&outputs); + assert_eq!(sounds, vec!["kick", "kick", "kick", "kick"]); +} + +#[test] +fn emit_n_with_alternator() { + let outputs = expect_outputs(r#"| "kick" "snare" | s 4 .!"#, 4); + let sounds = get_sounds(&outputs); + assert_eq!(sounds, vec!["kick", "snare", "kick", "snare"]); +} + +#[test] +fn emit_n_zero() { + let outputs = expect_outputs(r#""kick" s 0 .!"#, 0); + assert!(outputs.is_empty()); +} + +#[test] +fn emit_n_negative_error() { + let f = forth(); + let result = f.evaluate(r#""kick" s -1 .!"#, &default_ctx()); + assert!(result.is_err()); +} + +#[test] +fn persistent_counter_across_evaluations() { + let counter = new_emission_counter(); + let ctx = default_ctx(); + + // First evaluation: kick, snare, kick, snare + let f1 = forth_with_counter(counter.clone()); + let outputs1 = f1.evaluate(r#"| "kick" "snare" | s . ."#, &ctx).unwrap(); + let sounds1 = get_sounds(&outputs1); + assert_eq!(sounds1, vec!["kick", "snare"]); + + // Second evaluation: continues from where we left off + let f2 = forth_with_counter(counter.clone()); + let outputs2 = f2.evaluate(r#"| "kick" "snare" | s . ."#, &ctx).unwrap(); + let sounds2 = get_sounds(&outputs2); + assert_eq!(sounds2, vec!["kick", "snare"]); +} + +#[test] +fn persistent_counter_three_item_cycle() { + let counter = new_emission_counter(); + let ctx = default_ctx(); + + // First eval: kick, snare + let f1 = forth_with_counter(counter.clone()); + let outputs1 = f1.evaluate(r#"| "kick" "snare" "hat" | s . ."#, &ctx).unwrap(); + let sounds1 = get_sounds(&outputs1); + assert_eq!(sounds1, vec!["kick", "snare"]); + + // Second eval: continues from hat (index 2) + let f2 = forth_with_counter(counter.clone()); + let outputs2 = f2.evaluate(r#"| "kick" "snare" "hat" | s . ."#, &ctx).unwrap(); + let sounds2 = get_sounds(&outputs2); + assert_eq!(sounds2, vec!["hat", "kick"]); + + // Third eval: snare, hat + let f3 = forth_with_counter(counter.clone()); + let outputs3 = f3.evaluate(r#"| "kick" "snare" "hat" | s . ."#, &ctx).unwrap(); + let sounds3 = get_sounds(&outputs3); + assert_eq!(sounds3, vec!["snare", "hat"]); +} + +#[test] +fn emit_n_with_persistent_counter() { + let counter = new_emission_counter(); + let ctx = default_ctx(); + + // First eval: 3 emits from a 4-item cycle + let f1 = forth_with_counter(counter.clone()); + let outputs1 = f1.evaluate(r#"| "a" "b" "c" "d" | s 3 .!"#, &ctx).unwrap(); + let sounds1 = get_sounds(&outputs1); + assert_eq!(sounds1, vec!["a", "b", "c"]); + + // Second eval: continues from d + let f2 = forth_with_counter(counter.clone()); + let outputs2 = f2.evaluate(r#"| "a" "b" "c" "d" | s 3 .!"#, &ctx).unwrap(); + let sounds2 = get_sounds(&outputs2); + assert_eq!(sounds2, vec!["d", "a", "b"]); }