This commit is contained in:
2026-01-26 12:22:44 +01:00
parent 223679acf8
commit 9e597258e4
14 changed files with 1030 additions and 884 deletions

View File

@@ -94,8 +94,8 @@ fn tokenize(input: &str) -> Vec<Token> {
fn compile(tokens: &[Token], dict: &Dictionary) -> Result<Vec<Op>, 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<Vec<Op>, 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<Vec<Op>, 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;

View File

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

View File

@@ -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<Op>, Option<SourceSpan>),
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,
}

View File

@@ -48,6 +48,7 @@ pub enum Value {
Str(String, Option<SourceSpan>),
Marker,
Quotation(Vec<Op>, Option<SourceSpan>),
Alternator(Vec<Value>),
}
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<SourceSpan> {
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<String>,
params: Vec<(String, String)>,
sound: Option<Value>,
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<PendingEmission>,
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
}
}
}

View File

@@ -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<Vec<(f64, f64)>>,
iteration_index: Option<usize>,
}
pub type EmissionCounter = std::sync::Arc<std::sync::Mutex<usize>>;
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<Vec<String>, String> {
let mut stack = self.stack.lock().unwrap();
let mut outputs: Vec<String> = Vec::new();
let mut time_stack: Vec<TimeContext> = 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<ScopeContext> = 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<Value>,
outputs: &mut Vec<String>,
time_stack: &mut Vec<TimeContext>,
scope_stack: &mut Vec<ScopeContext>,
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()));
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);
}
outputs.push(format_cmd(&pairs));
}
(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(&quot_ops, ctx, stack, outputs, time_stack, cmd, trace_opt.as_deref_mut())?;
self.execute_ops(
&quot_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(&quot_ops, ctx, stack, outputs, time_stack, cmd, trace_opt.as_deref_mut())?;
self.execute_ops(
&quot_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(&quot_ops, ctx, stack, outputs, time_stack, cmd, trace_opt.as_deref_mut())?;
self.execute_ops(
&quot_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(&quot_ops, ctx, stack, outputs, time_stack, cmd, trace_opt.as_deref_mut())?;
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()),
@@ -462,7 +537,16 @@ impl Forth {
}
}
let mut trace_opt = trace_cell.borrow_mut().take();
self.execute_ops(&quot_ops, ctx, stack, outputs, time_stack, cmd, trace_opt.as_deref_mut())?;
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()),
@@ -500,7 +584,16 @@ impl Forth {
}
}
let mut trace_opt = trace_cell.borrow_mut().take();
self.execute_ops(&quot_ops, ctx, stack, outputs, time_stack, cmd, trace_opt.as_deref_mut())?;
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()),
@@ -520,7 +613,16 @@ impl Forth {
}
}
let mut trace_opt = trace_cell.borrow_mut().take();
self.execute_ops(&quot_ops, ctx, stack, outputs, time_stack, cmd, trace_opt.as_deref_mut())?;
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()),
@@ -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 result = lift_unary_int(val, |degree| {
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));
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(&quot_ops, ctx, stack, outputs, time_stack, cmd, trace_opt.as_deref_mut())?;
self.execute_ops(
&quot_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(&quot_ops, ctx, stack, outputs, time_stack, cmd, trace_opt.as_deref_mut())?;
self.execute_ops(
&quot_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(
&quot_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(&quot_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(&quot_ops, ctx, stack, outputs, time_stack, cmd, trace_opt.as_deref_mut())?;
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()),
@@ -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<SourceSpan>) {
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<String>) {
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<F>(val: Value, f: F) -> Result<Value, String>
where
F: Fn(f64) -> f64 + Copy,
{
match val {
Value::Alternator(items) => {
let mapped: Result<Vec<Value>, 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<F>(val: Value, f: F) -> Result<Value, String>
where
F: Fn(i64) -> i64 + Copy,
{
match val {
Value::Alternator(items) => {
let mapped: Result<Vec<Value>, 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<F>(a: Value, b: Value, f: F) -> Result<Value, String>
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<Vec<Value>, 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<Vec<Value>, String> = items
.into_iter()
.map(|v| lift_binary(v, scalar.clone(), f))
.collect();
Ok(Value::Alternator(mapped?))
}
(scalar, Value::Alternator(items)) => {
let mapped: Result<Vec<Value>, 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<F>(stack: &mut Vec<Value>, 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<F>(stack: &mut Vec<Value>, 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<F>(a: Value, b: Value, f: F) -> Result<Value, String>
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<Vec<Value>, 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<Vec<Value>, String> = items
.into_iter()
.map(|v| lift_cmp(v, scalar.clone(), f))
.collect();
Ok(Value::Alternator(mapped?))
}
(scalar, Value::Alternator(items)) => {
let mapped: Result<Vec<Value>, 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(())
}

View File

@@ -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: "@<var>",
@@ -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<Op> {
"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> {
">>" => 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<Op> {
"chain" => Op::Chain,
"loop" => Op::Loop,
"oct" => Op::Oct,
"div" => Op::DivStart,
"stack" => Op::StackStart,
"end" => Op::DivEnd,
".!" => Op::EmitN,
_ => return None,
})
}

View File

@@ -34,9 +34,6 @@ mod variables;
#[path = "forth/quotations.rs"]
mod quotations;
#[path = "forth/iteration.rs"]
mod iteration;
#[path = "forth/notes.rs"]
mod notes;

View File

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

View File

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

View File

@@ -1,256 +0,0 @@
use super::harness::*;
use std::collections::HashMap;
fn parse_params(output: &str) -> HashMap<String, f64> {
let mut params = HashMap::new();
let parts: Vec<&str> = output.trim_start_matches('/').split('/').collect();
let mut i = 0;
while i + 1 < parts.len() {
if let Ok(v) = parts[i + 1].parse::<f64>() {
params.insert(parts[i].to_string(), v);
}
i += 2;
}
params
}
fn get_deltas(outputs: &[String]) -> Vec<f64> {
outputs
.iter()
.map(|o| parse_params(o).get("delta").copied().unwrap_or(0.0))
.collect()
}
fn get_durs(outputs: &[String]) -> Vec<f64> {
outputs
.iter()
.map(|o| parse_params(o).get("dur").copied().unwrap_or(0.0))
.collect()
}
fn get_notes(outputs: &[String]) -> Vec<f64> {
outputs
.iter()
.map(|o| parse_params(o).get("note").copied().unwrap_or(0.0))
.collect()
}
fn get_sounds(outputs: &[String]) -> Vec<String> {
outputs
.iter()
.map(|o| {
let parts: Vec<&str> = o.trim_start_matches('/').split('/').collect();
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");
}

View File

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

View File

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

View File

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

View File

@@ -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<String, f64> {
@@ -21,6 +23,27 @@ fn get_deltas(outputs: &[String]) -> Vec<f64> {
.collect()
}
fn get_durs(outputs: &[String]) -> Vec<f64> {
outputs
.iter()
.map(|o| parse_params(o).get("dur").copied().unwrap_or(0.0))
.collect()
}
fn get_sounds(outputs: &[String]) -> Vec<String> {
outputs
.iter()
.map(|o| {
let parts: Vec<&str> = o.trim_start_matches('/').split('/').collect();
if parts.len() >= 2 && parts[0] == "sound" {
parts[1].to_string()
} else {
String::new()
}
})
.collect()
}
const EPSILON: f64 = 1e-9;
fn approx_eq(a: f64, b: f64) -> bool {
@@ -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);
let step = 0.5 / 4.0;
for (i, delta) in deltas.iter().enumerate() {
let expected = step * i as f64;
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() {
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 2 div each"#, &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);
} 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<f64> = 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 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<i64> = 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<f64> = 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"]);
}