diff --git a/crates/forth/src/ops.rs b/crates/forth/src/ops.rs index eb5fada..a7b297e 100644 --- a/crates/forth/src/ops.rs +++ b/crates/forth/src/ops.rs @@ -58,7 +58,6 @@ pub enum Op { Seed, Cycle, PCycle, - TCycle, Choose, ChanceExec, ProbExec, diff --git a/crates/forth/src/types.rs b/crates/forth/src/types.rs index da496b7..8687c0d 100644 --- a/crates/forth/src/types.rs +++ b/crates/forth/src/types.rs @@ -154,6 +154,14 @@ impl CmdRegister { &self.deltas } + pub(super) fn sound(&self) -> Option<&Value> { + self.sound.as_ref() + } + + pub(super) fn params(&self) -> &[(String, Value)] { + &self.params + } + pub(super) fn snapshot(&self) -> Option> { if self.sound.is_some() || !self.params.is_empty() { Some((self.sound.as_ref(), self.params.as_slice())) diff --git a/crates/forth/src/vm.rs b/crates/forth/src/vm.rs index 26888c0..bed5b80 100644 --- a/crates/forth/src/vm.rs +++ b/crates/forth/src/vm.rs @@ -152,6 +152,23 @@ impl Forth { select_and_run(selected, stack, outputs, cmd) }; + let compute_poly_count = |cmd: &CmdRegister| -> usize { + let sound_len = match cmd.sound() { + Some(Value::CycleList(items)) => items.len(), + _ => 1, + }; + let param_max = cmd + .params() + .iter() + .map(|(_, v)| match v { + Value::CycleList(items) => items.len(), + _ => 1, + }) + .max() + .unwrap_or(1); + sound_len.max(param_max) + }; + let emit_with_cycling = |cmd: &CmdRegister, emit_idx: usize, delta_secs: f64, @@ -363,38 +380,56 @@ impl Forth { } Op::NewCmd => { - let val = stack.pop().ok_or("stack underflow")?; + if stack.is_empty() { + return Err("stack underflow".into()); + } + let values = std::mem::take(stack); + let val = if values.len() == 1 { + values.into_iter().next().unwrap() + } else { + Value::CycleList(values) + }; cmd.set_sound(val); } Op::SetParam(param) => { - let val = stack.pop().ok_or("stack underflow")?; + if stack.is_empty() { + return Err("stack underflow".into()); + } + let values = std::mem::take(stack); + let val = if values.len() == 1 { + values.into_iter().next().unwrap() + } else { + Value::CycleList(values) + }; cmd.set_param(param.clone(), val); } Op::Emit => { + let poly_count = compute_poly_count(cmd); let deltas = if cmd.deltas().is_empty() { vec![Value::Float(0.0, None)] } else { cmd.deltas().to_vec() }; - for (emit_idx, delta_val) in deltas.iter().enumerate() { - let delta_frac = delta_val.as_float()?; - let delta_secs = ctx.nudge_secs + delta_frac * ctx.step_duration(); - // Record delta span for highlighting - if let Some(span) = delta_val.span() { - if let Some(trace) = trace_cell.borrow_mut().as_mut() { - trace.selected_spans.push(span); - } - } - if let Some(sound_val) = - emit_with_cycling(cmd, emit_idx, delta_secs, outputs)? - { - if let Some(span) = sound_val.span() { + for poly_idx in 0..poly_count { + for delta_val in deltas.iter() { + let delta_frac = delta_val.as_float()?; + let delta_secs = ctx.nudge_secs + delta_frac * ctx.step_duration(); + if let Some(span) = delta_val.span() { if let Some(trace) = trace_cell.borrow_mut().as_mut() { trace.selected_spans.push(span); } } + if let Some(sound_val) = + emit_with_cycling(cmd, poly_idx, delta_secs, outputs)? + { + if let Some(span) = sound_val.span() { + if let Some(trace) = trace_cell.borrow_mut().as_mut() { + trace.selected_spans.push(span); + } + } + } } } } @@ -503,19 +538,6 @@ impl Forth { drain_select_run(count, idx, stack, outputs, cmd)?; } - Op::TCycle => { - let count = stack.pop().ok_or("stack underflow")?.as_int()? as usize; - if count == 0 { - return Err("tcycle count must be > 0".into()); - } - if stack.len() < count { - return Err("stack underflow".into()); - } - let start = stack.len() - count; - let values: Vec = stack.drain(start..).collect(); - stack.push(Value::CycleList(values)); - } - Op::Choose => { let count = stack.pop().ok_or("stack underflow")?.as_int()? as usize; if count == 0 { @@ -692,27 +714,10 @@ impl Forth { } Op::At => { - let top = stack.pop().ok_or("stack underflow")?; - let deltas = match &top { - Value::Float(..) => vec![top], - Value::Int(n, _) => { - let count = *n as usize; - if stack.len() < count { - return Err(format!( - "at: stack underflow, expected {} values but got {}", - count, - stack.len() - )); - } - let mut vals = Vec::with_capacity(count); - for _ in 0..count { - vals.push(stack.pop().ok_or("stack underflow")?); - } - vals.reverse(); - vals - } - _ => return Err("at expects float or int count".into()), - }; + if stack.is_empty() { + return Err("stack underflow".into()); + } + let deltas = std::mem::take(stack); cmd.set_deltas(deltas); } diff --git a/crates/forth/src/words.rs b/crates/forth/src/words.rs index 5171cb0..64429b0 100644 --- a/crates/forth/src/words.rs +++ b/crates/forth/src/words.rs @@ -563,16 +563,6 @@ pub const WORDS: &[Word] = &[ compile: Simple, varargs: true, }, - Word { - name: "tcycle", - aliases: &[], - category: "Probability", - stack: "(v1..vn n -- CycleList)", - desc: "Create cycle list for emit-time resolution", - example: "60 64 67 3 tcycle note", - compile: Simple, - varargs: true, - }, Word { name: "every", aliases: &[], @@ -1231,9 +1221,9 @@ pub const WORDS: &[Word] = &[ name: "at", aliases: &[], category: "Time", - stack: "(v1..vn n --)", + stack: "(v1..vn --)", desc: "Set delta context for emit timing", - example: "0 0.5 2 at kick s . => emits at 0 and 0.5 of step", + example: "0 0.5 at kick s . => emits at 0 and 0.5 of step", compile: Simple, varargs: true, }, @@ -2819,7 +2809,6 @@ pub(super) fn simple_op(name: &str) -> Option { "seed" => Op::Seed, "cycle" => Op::Cycle, "pcycle" => Op::PCycle, - "tcycle" => Op::TCycle, "choose" => Op::Choose, "every" => Op::Every, "chance" => Op::ChanceExec, diff --git a/docs/oddities.md b/docs/oddities.md index b313482..03701d3 100644 --- a/docs/oddities.md +++ b/docs/oddities.md @@ -193,21 +193,42 @@ You can also use quotations if you need to execute code: When the selected value is a quotation, it gets executed. When it is a plain value, it gets pushed onto the stack. -Three cycling words exist: +Two cycling words exist: - `cycle` - selects based on `runs` (how many times this step has played) - `pcycle` - selects based on `iter` (how many times the pattern has looped) -- `tcycle` - creates a cycle list that resolves at emit time The difference between `cycle` and `pcycle` matters when patterns have different lengths. `cycle` counts per-step, `pcycle` counts per-pattern. -`tcycle` is special. It does not select immediately. Instead it creates a value that cycles when emitted: +## Polyphonic Parameters + +Parameter words like `note`, `freq`, and `gain` consume the entire stack. If you push multiple values before a param word, you get polyphony: ```forth -0.3 0.5 0.7 3 tcycle gain +60 64 67 note sine s . ;; emits 3 voices with notes 60, 64, 67 ``` -If you emit multiple times in one step (using `at`), each emit gets the next value from the cycle. +This works for any param and for the sound word itself: + +```forth +440 880 freq sine tri s . ;; 2 voices: sine at 440, tri at 880 +``` + +When params have different lengths, shorter lists cycle: + +```forth +60 64 67 note ;; 3 notes +0.5 1.0 gain ;; 2 gains (cycles: 0.5, 1.0, 0.5) +sine s . ;; emits 3 voices +``` + +Polyphony multiplies with `at` deltas: + +```forth +0 0.5 at ;; 2 time points +60 64 note ;; 2 notes +sine s . ;; emits 4 voices (2 notes × 2 times) +``` ## Summary diff --git a/src/engine/sequencer.rs b/src/engine/sequencer.rs index f09af7e..6e0006a 100644 --- a/src/engine/sequencer.rs +++ b/src/engine/sequencer.rs @@ -450,6 +450,10 @@ impl RunsCounter { *count += 1; current } + + fn clear_pattern(&mut self, bank: usize, pattern: usize) { + self.counts.retain(|&(b, p, _), _| b != bank || p != pattern); + } } pub(crate) struct TickInput { @@ -716,6 +720,7 @@ impl SequencerState { } } }; + self.runs_counter.clear_pattern(pending.id.bank, pending.id.pattern); self.audio_state.active_patterns.insert( pending.id, ActivePattern { diff --git a/tests/forth/list_words.rs b/tests/forth/list_words.rs index b330f13..b710665 100644 --- a/tests/forth/list_words.rs +++ b/tests/forth/list_words.rs @@ -74,22 +74,6 @@ fn dupn_alias() { expect_int("5 3 ! + +", 15); } -#[test] -fn tcycle_creates_cycle_list() { - let outputs = expect_outputs(r#"0.0 at 60 64 67 3 tcycle note sine s ."#, 1); - assert!(outputs[0].contains("note/60")); -} - -#[test] -fn tcycle_with_multiple_emits() { - let f = forth(); - let ctx = default_ctx(); - let outputs = f.evaluate(r#"0 0.5 2 at 60 64 2 tcycle note sine s ."#, &ctx).unwrap(); - assert_eq!(outputs.len(), 2); - assert!(outputs[0].contains("note/60")); - assert!(outputs[1].contains("note/64")); -} - #[test] fn cycle_zero_count_error() { expect_error("1 2 3 0 cycle", "cycle count must be > 0"); @@ -99,8 +83,3 @@ fn cycle_zero_count_error() { fn choose_zero_count_error() { expect_error("1 2 3 0 choose", "choose count must be > 0"); } - -#[test] -fn tcycle_zero_count_error() { - expect_error("1 2 3 0 tcycle", "tcycle count must be > 0"); -} diff --git a/tests/forth/sound.rs b/tests/forth/sound.rs index 034dcc2..f1ae198 100644 --- a/tests/forth/sound.rs +++ b/tests/forth/sound.rs @@ -106,3 +106,35 @@ fn param_only_multiple_params() { assert!(outputs[0].contains("gain/0.5")); assert!(!outputs[0].contains("sound/")); } + +#[test] +fn polyphonic_notes() { + let outputs = expect_outputs(r#"60 64 67 note sine s ."#, 3); + assert!(outputs[0].contains("note/60")); + assert!(outputs[1].contains("note/64")); + assert!(outputs[2].contains("note/67")); +} + +#[test] +fn polyphonic_sounds() { + let outputs = expect_outputs(r#"440 freq kick hat s ."#, 2); + assert!(outputs[0].contains("sound/kick")); + assert!(outputs[1].contains("sound/hat")); +} + +#[test] +fn polyphonic_cycling() { + let outputs = expect_outputs(r#"60 64 67 note 0.5 1.0 gain sine s ."#, 3); + assert!(outputs[0].contains("note/60")); + assert!(outputs[0].contains("gain/0.5")); + assert!(outputs[1].contains("note/64")); + assert!(outputs[1].contains("gain/1")); + assert!(outputs[2].contains("note/67")); + assert!(outputs[2].contains("gain/0.5")); +} + +#[test] +fn polyphonic_with_at() { + let outputs = expect_outputs(r#"0 0.5 at 60 64 note sine s ."#, 4); + assert_eq!(outputs.len(), 4); +} diff --git a/tests/forth/temporal.rs b/tests/forth/temporal.rs index 16fba9e..29f58a4 100644 --- a/tests/forth/temporal.rs +++ b/tests/forth/temporal.rs @@ -42,13 +42,6 @@ fn get_sounds(outputs: &[String]) -> Vec { .collect() } -fn get_param(outputs: &[String], param: &str) -> Vec { - outputs - .iter() - .map(|o| parse_params(o).get(param).copied().unwrap_or(0.0)) - .collect() -} - const EPSILON: f64 = 1e-9; fn approx_eq(a: f64, b: f64) -> bool { @@ -156,7 +149,7 @@ fn at_single_delta() { #[test] fn at_list_deltas() { - let outputs = expect_outputs(r#"0 0.5 2 at "kick" s ."#, 2); + let outputs = expect_outputs(r#"0 0.5 at "kick" s ."#, 2); let deltas = get_deltas(&outputs); let step_dur = 0.125; assert!(approx_eq(deltas[0], 0.0), "expected delta 0, got {}", deltas[0]); @@ -165,7 +158,7 @@ fn at_list_deltas() { #[test] fn at_three_deltas() { - let outputs = expect_outputs(r#"0 0.33 0.67 3 at "kick" s ."#, 3); + let outputs = expect_outputs(r#"0 0.33 0.67 at "kick" s ."#, 3); let deltas = get_deltas(&outputs); let step_dur = 0.125; assert!(approx_eq(deltas[0], 0.0), "expected delta 0"); @@ -175,70 +168,26 @@ fn at_three_deltas() { #[test] fn at_persists_across_emits() { - let outputs = expect_outputs(r#"0 0.5 2 at "kick" s . "hat" s ."#, 4); + let outputs = expect_outputs(r#"0 0.5 at "kick" s . "hat" s ."#, 4); let sounds = get_sounds(&outputs); assert_eq!(sounds, vec!["kick", "kick", "hat", "hat"]); } -#[test] -fn tcycle_basic() { - let outputs = expect_outputs(r#"0 0.5 0.75 3 at 60 64 67 3 tcycle note sine s ."#, 3); - let notes = get_param(&outputs, "note"); - assert_eq!(notes, vec![60.0, 64.0, 67.0]); -} - -#[test] -fn tcycle_wraps() { - let outputs = expect_outputs(r#"0 0.33 0.67 3 at 60 64 2 tcycle note sine s ."#, 3); - let notes = get_param(&outputs, "note"); - assert_eq!(notes, vec![60.0, 64.0, 60.0]); -} - -#[test] -fn tcycle_with_sound() { - let outputs = expect_outputs(r#"0 0.5 2 at kick hat 2 tcycle s ."#, 2); - let sounds = get_sounds(&outputs); - assert_eq!(sounds, vec!["kick", "hat"]); -} - -#[test] -fn tcycle_multiple_params() { - let outputs = expect_outputs(r#"0 0.5 0.75 3 at 60 64 67 3 tcycle note 0.5 1.0 2 tcycle gain sine s ."#, 3); - let notes = get_param(&outputs, "note"); - let gains = get_param(&outputs, "gain"); - assert_eq!(notes, vec![60.0, 64.0, 67.0]); - assert_eq!(gains, vec![0.5, 1.0, 0.5]); -} #[test] fn at_reset_with_zero() { - let outputs = expect_outputs(r#"0 0.5 2 at "kick" s . 0.0 at "hat" s ."#, 3); + let outputs = expect_outputs(r#"0 0.5 at "kick" s . 0.0 at "hat" s ."#, 3); let sounds = get_sounds(&outputs); assert_eq!(sounds, vec!["kick", "kick", "hat"]); } -#[test] -fn tcycle_records_selected_spans() { - use cagire::forth::ExecutionTrace; - - let f = forth(); - let mut trace = ExecutionTrace::default(); - let script = r#"0 0.5 2 at kick hat 2 tcycle s ."#; - f.evaluate_with_trace(script, &default_ctx(), &mut trace).unwrap(); - - // Should have 4 selected spans: - // - 2 for at deltas (0 and 0.5) - // - 2 for tcycle sound values (kick and hat) - assert_eq!(trace.selected_spans.len(), 4, "expected 4 selected spans (2 at + 2 tcycle)"); -} - #[test] fn at_records_selected_spans() { use cagire::forth::ExecutionTrace; let f = forth(); let mut trace = ExecutionTrace::default(); - let script = r#"0 0.5 0.75 3 at "kick" s ."#; + let script = r#"0 0.5 0.75 at "kick" s ."#; f.evaluate_with_trace(script, &default_ctx(), &mut trace).unwrap(); // Should have 6 selected spans: 3 for at deltas + 3 for sound (one per emit)