From 8f131b46ccff4f9bc72d57d3edeef84139786ee9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Forment?= Date: Mon, 23 Feb 2026 23:04:43 +0100 Subject: [PATCH] Feat: all and noall words --- crates/forth/src/ops.rs | 3 + crates/forth/src/types.rs | 24 ++++++++ crates/forth/src/vm.rs | 59 ++++++++++++++++++-- crates/forth/src/words/compile.rs | 2 + crates/forth/src/words/sound.rs | 20 +++++++ docs/engine/intro.md | 35 ++++++++++++ src/engine/sequencer.rs | 1 + src/model/script.rs | 4 ++ tests/forth/sound.rs | 93 +++++++++++++++++++++++++++++++ 9 files changed, 236 insertions(+), 5 deletions(-) diff --git a/crates/forth/src/ops.rs b/crates/forth/src/ops.rs index d20e479..0446474 100644 --- a/crates/forth/src/ops.rs +++ b/crates/forth/src/ops.rs @@ -126,6 +126,9 @@ pub enum Op { ModSlide(u8), ModRnd(u8), ModEnv, + // Global params + EmitAll, + ClearGlobal, // MIDI MidiEmit, GetMidiCC, diff --git a/crates/forth/src/types.rs b/crates/forth/src/types.rs index 82003dc..95c0fa0 100644 --- a/crates/forth/src/types.rs +++ b/crates/forth/src/types.rs @@ -160,6 +160,7 @@ pub(super) struct CmdRegister { sound: Option, params: Vec<(&'static str, Value)>, deltas: Vec, + global_params: Vec<(&'static str, Value)>, } impl CmdRegister { @@ -168,6 +169,7 @@ impl CmdRegister { sound: None, params: Vec::with_capacity(16), deltas: Vec::with_capacity(4), + global_params: Vec::new(), } } @@ -203,6 +205,28 @@ impl CmdRegister { } } + pub(super) fn global_params(&self) -> &[(&'static str, Value)] { + &self.global_params + } + + pub(super) fn commit_global(&mut self) { + self.global_params.append(&mut self.params); + self.sound = None; + self.deltas.clear(); + } + + pub(super) fn clear_global(&mut self) { + self.global_params.clear(); + } + + pub fn set_global(&mut self, params: Vec<(&'static str, Value)>) { + self.global_params = params; + } + + pub fn take_global(&mut self) -> Vec<(&'static str, Value)> { + std::mem::take(&mut self.global_params) + } + pub(super) fn clear(&mut self) { self.sound = None; self.params.clear(); diff --git a/crates/forth/src/vm.rs b/crates/forth/src/vm.rs index bf626d0..e156e55 100644 --- a/crates/forth/src/vm.rs +++ b/crates/forth/src/vm.rs @@ -19,6 +19,7 @@ pub struct Forth { vars: Variables, dict: Dictionary, rng: Rng, + global_params: Mutex>, } impl Forth { @@ -28,6 +29,7 @@ impl Forth { vars, dict, rng, + global_params: Mutex::new(Vec::new()), } } @@ -39,6 +41,10 @@ impl Forth { self.stack.lock().clear(); } + pub fn clear_global_params(&self) { + self.global_params.lock().clear(); + } + pub fn evaluate(&self, script: &str, ctx: &StepContext) -> Result, String> { let (outputs, var_writes) = self.evaluate_impl(script, ctx, None)?; self.apply_var_writes(var_writes); @@ -102,6 +108,8 @@ impl Forth { let vars_snapshot = self.vars.load_full(); let mut var_writes: HashMap = HashMap::new(); + cmd.set_global(self.global_params.lock().clone()); + self.execute_ops( ops, ctx, @@ -113,6 +121,8 @@ impl Forth { &mut var_writes, )?; + *self.global_params.lock() = cmd.take_global(); + Ok((outputs, var_writes)) } @@ -214,8 +224,9 @@ impl Forth { _ => 1, }; let param_max = cmd - .params() + .global_params() .iter() + .chain(cmd.params().iter()) .map(|(_, v)| match v { Value::CycleList(items) => items.len(), _ => 1, @@ -227,7 +238,8 @@ impl Forth { let has_arp_list = |cmd: &CmdRegister| -> bool { matches!(cmd.sound(), Some(Value::ArpList(_))) - || cmd.params().iter().any(|(_, v)| matches!(v, Value::ArpList(_))) + || cmd.global_params().iter().chain(cmd.params().iter()) + .any(|(_, v)| matches!(v, Value::ArpList(_))) }; let compute_arp_count = |cmd: &CmdRegister| -> usize { @@ -253,15 +265,21 @@ impl Forth { delta_secs: f64, outputs: &mut Vec| -> Result, String> { - let (sound_opt, params) = cmd.snapshot().ok_or("nothing to emit")?; + let has_sound = cmd.sound().is_some(); + let has_params = !cmd.params().is_empty(); + let has_global = !cmd.global_params().is_empty(); + if !has_sound && !has_params && !has_global { + return Err("nothing to emit".into()); + } let resolved_sound_val = - sound_opt.map(|sv| resolve_value(sv, arp_idx, poly_idx)); + cmd.sound().map(|sv| resolve_value(sv, arp_idx, poly_idx)); let sound_str = match &resolved_sound_val { Some(v) => Some(v.as_str()?.to_string()), None => None, }; - let resolved_params: Vec<(&str, String)> = params + let resolved_params: Vec<(&str, String)> = cmd.global_params() .iter() + .chain(cmd.params().iter()) .map(|(k, v)| { let resolved = resolve_value(v, arp_idx, poly_idx); if let Value::CycleList(_) | Value::ArpList(_) = v { @@ -1194,6 +1212,37 @@ impl Forth { cmd.clear(); } + Op::EmitAll => { + // Retroactive: patch existing sound outputs with current params + if !cmd.params().is_empty() { + let step_duration = ctx.step_duration(); + for output in outputs.iter_mut() { + if output.starts_with("/sound/") { + use std::fmt::Write; + for (k, v) in cmd.params() { + let val_str = v.to_param_string(); + if !output.ends_with('/') { + output.push('/'); + } + if is_tempo_scaled_param(k) { + if let Ok(val) = val_str.parse::() { + let _ = write!(output, "{k}/{}", val * step_duration); + continue; + } + } + let _ = write!(output, "{k}/{val_str}"); + } + } + } + } + // Prospective: store for future emits + cmd.commit_global(); + } + + Op::ClearGlobal => { + cmd.clear_global(); + } + Op::IntRange => { let end = pop_int(stack)?; let start = pop_int(stack)?; diff --git a/crates/forth/src/words/compile.rs b/crates/forth/src/words/compile.rs index dd3cd26..d6d2411 100644 --- a/crates/forth/src/words/compile.rs +++ b/crates/forth/src/words/compile.rs @@ -94,6 +94,8 @@ pub(super) fn simple_op(name: &str) -> Option { "loop" => Op::Loop, "oct" => Op::Oct, "clear" => Op::ClearCmd, + "all" => Op::EmitAll, + "noall" => Op::ClearGlobal, ".." => Op::IntRange, ".," => Op::StepRange, "gen" => Op::Generate, diff --git a/crates/forth/src/words/sound.rs b/crates/forth/src/words/sound.rs index eeef18f..b5249b6 100644 --- a/crates/forth/src/words/sound.rs +++ b/crates/forth/src/words/sound.rs @@ -43,6 +43,26 @@ pub(super) const WORDS: &[Word] = &[ compile: Simple, varargs: false, }, + Word { + name: "all", + aliases: &[], + category: "Sound", + stack: "(--)", + desc: "Apply current params to all sounds", + example: "500 lpf 0.5 verb all", + compile: Simple, + varargs: false, + }, + Word { + name: "noall", + aliases: &[], + category: "Sound", + stack: "(--)", + desc: "Clear global params", + example: "noall", + compile: Simple, + varargs: false, + }, // Sample Word { name: "bank", diff --git a/docs/engine/intro.md b/docs/engine/intro.md index 0da48ac..2ae3938 100644 --- a/docs/engine/intro.md +++ b/docs/engine/intro.md @@ -35,6 +35,41 @@ saw s Parameters can appear in any order. They accumulate until you emit. You can clear the register using the `clear` word. +## Global Parameters + +Use `all` to apply parameters globally. Global parameters persist across all patterns and steps until cleared with `noall`. They work both prospectively (before sounds) and retroactively (after sounds): + +```forth +;; Prospective: set params before emitting +500 lpf 0.5 verb all +kick s 60 note . ;; gets lpf=500 verb=0.5 +hat s 70 note . ;; gets lpf=500 verb=0.5 +``` + +```forth +;; Retroactive: patch already-emitted sounds +kick s 60 note . +hat s 70 note . +500 lpf 0.5 verb all ;; both outputs get lpf and verb +``` + +Per-sound parameters override global ones: + +```forth +500 lpf all +kick s 2000 lpf . ;; lpf=2000 (per-sound wins) +hat s . ;; lpf=500 (global) +``` + +Use `noall` to clear global parameters: + +```forth +500 lpf all +kick s . ;; gets lpf +noall +hat s . ;; no lpf +``` + ## Controlling Existing Voices You can emit without a sound name. In this case, no new voice is created. Instead, the parameters are sent to control an existing voice. Use `voice` with an ID to target a specific voice: diff --git a/src/engine/sequencer.rs b/src/engine/sequencer.rs index 15cfc6c..cd8c565 100644 --- a/src/engine/sequencer.rs +++ b/src/engine/sequencer.rs @@ -686,6 +686,7 @@ impl SequencerState { self.variables.store(Arc::new(HashMap::new())); self.dict.lock().clear(); self.speed_overrides.clear(); + self.script_engine.clear_global_params(); } SeqCommand::Shutdown => {} } diff --git a/src/model/script.rs b/src/model/script.rs index 477dd92..218a7cb 100644 --- a/src/model/script.rs +++ b/src/model/script.rs @@ -24,6 +24,10 @@ impl ScriptEngine { self.forth.evaluate_with_trace(script, ctx, trace) } + pub fn clear_global_params(&self) { + self.forth.clear_global_params(); + } + pub fn stack(&self) -> Vec { self.forth.stack() } diff --git a/tests/forth/sound.rs b/tests/forth/sound.rs index 4c7fc6a..1f80de3 100644 --- a/tests/forth/sound.rs +++ b/tests/forth/sound.rs @@ -144,3 +144,96 @@ fn explicit_dur_zero_is_infinite() { let outputs = expect_outputs("880 freq 0 dur .", 1); assert!(outputs[0].contains("dur/0")); } + +#[test] +fn all_before_sounds() { + let outputs = expect_outputs( + r#"500 lpf 0.5 verb all "kick" s 60 note . "hat" s 70 note ."#, + 2, + ); + assert!(outputs[0].contains("sound/kick")); + assert!(outputs[0].contains("lpf/500")); + assert!(outputs[0].contains("verb/0.5")); + assert!(outputs[1].contains("sound/hat")); + assert!(outputs[1].contains("lpf/500")); + assert!(outputs[1].contains("verb/0.5")); +} + +#[test] +fn all_after_sounds() { + let outputs = expect_outputs( + r#""kick" s 60 note . "hat" s 70 note . 500 lpf 0.5 verb all"#, + 2, + ); + assert!(outputs[0].contains("sound/kick")); + assert!(outputs[0].contains("lpf/500")); + assert!(outputs[0].contains("verb/0.5")); + assert!(outputs[1].contains("sound/hat")); + assert!(outputs[1].contains("lpf/500")); + assert!(outputs[1].contains("verb/0.5")); +} + +#[test] +fn noall_clears_global_params() { + let outputs = expect_outputs( + r#"500 lpf all "kick" s 60 note . noall "hat" s 70 note ."#, + 2, + ); + assert!(outputs[0].contains("lpf/500")); + assert!(!outputs[1].contains("lpf/500")); +} + +#[test] +fn all_with_tempo_scaled_params() { + // attack is tempo-scaled: 0.01 * step_duration(0.125) = 0.00125 + let outputs = expect_outputs( + r#"0.01 attack all "kick" s 60 note ."#, + 1, + ); + assert!(outputs[0].contains("attack/0.00125")); +} + +#[test] +fn all_per_sound_override() { + let outputs = expect_outputs( + r#"500 lpf all "kick" s 2000 lpf . "hat" s ."#, + 2, + ); + // kick has both global lpf=500 and per-sound lpf=2000; per-sound wins (comes last) + assert!(outputs[0].contains("lpf/2000")); + // hat only has global lpf=500 + assert!(outputs[1].contains("lpf/500")); +} + +#[test] +fn all_persists_across_evaluations() { + let f = forth(); + let ctx = default_ctx(); + f.evaluate(r#"500 lpf 0.5 verb all"#, &ctx).unwrap(); + let outputs = f.evaluate(r#""kick" s 60 note ."#, &ctx).unwrap(); + assert_eq!(outputs.len(), 1); + assert!(outputs[0].contains("lpf/500"), "global lpf missing: {}", outputs[0]); + assert!(outputs[0].contains("verb/0.5"), "global verb missing: {}", outputs[0]); +} + +#[test] +fn noall_clears_across_evaluations() { + let f = forth(); + let ctx = default_ctx(); + f.evaluate(r#"500 lpf all"#, &ctx).unwrap(); + f.evaluate(r#"noall"#, &ctx).unwrap(); + let outputs = f.evaluate(r#""kick" s 60 note ."#, &ctx).unwrap(); + assert_eq!(outputs.len(), 1); + assert!(!outputs[0].contains("lpf"), "lpf should be cleared: {}", outputs[0]); +} + +#[test] +fn all_replaces_previous_global() { + let f = forth(); + let ctx = default_ctx(); + f.evaluate(r#"500 lpf 0.5 verb all"#, &ctx).unwrap(); + f.evaluate(r#"2000 lpf all"#, &ctx).unwrap(); + let outputs = f.evaluate(r#""kick" s ."#, &ctx).unwrap(); + assert_eq!(outputs.len(), 1); + assert!(outputs[0].contains("lpf/2000"), "latest lpf should be 2000: {}", outputs[0]); +}