Feat: all and noall words

This commit is contained in:
2026-02-23 23:04:43 +01:00
parent 8b745a77a6
commit 8f131b46cc
9 changed files with 236 additions and 5 deletions

View File

@@ -126,6 +126,9 @@ pub enum Op {
ModSlide(u8),
ModRnd(u8),
ModEnv,
// Global params
EmitAll,
ClearGlobal,
// MIDI
MidiEmit,
GetMidiCC,

View File

@@ -160,6 +160,7 @@ pub(super) struct CmdRegister {
sound: Option<Value>,
params: Vec<(&'static str, Value)>,
deltas: Vec<Value>,
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();

View File

@@ -19,6 +19,7 @@ pub struct Forth {
vars: Variables,
dict: Dictionary,
rng: Rng,
global_params: Mutex<Vec<(&'static str, Value)>>,
}
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<Vec<String>, 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<String, Value> = 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<String>|
-> Result<Option<Value>, 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::<f64>() {
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)?;

View File

@@ -94,6 +94,8 @@ pub(super) fn simple_op(name: &str) -> Option<Op> {
"loop" => Op::Loop,
"oct" => Op::Oct,
"clear" => Op::ClearCmd,
"all" => Op::EmitAll,
"noall" => Op::ClearGlobal,
".." => Op::IntRange,
".," => Op::StepRange,
"gen" => Op::Generate,

View File

@@ -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",

View File

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

View File

@@ -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 => {}
}

View File

@@ -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<Value> {
self.forth.stack()
}

View File

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