From 5fa2c5b6b0c8c234785edab59b0bdeb0a02a7297 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Forment?= Date: Tue, 27 Jan 2026 12:17:23 +0100 Subject: [PATCH] Feat: parameter duration scaling --- crates/forth/src/vm.rs | 86 ++++++++++++++++++++++++++++++++------- crates/forth/src/words.rs | 73 +++++++++++++++++++++++++++++++-- tests/forth/sound.rs | 17 ++++---- 3 files changed, 149 insertions(+), 27 deletions(-) diff --git a/crates/forth/src/vm.rs b/crates/forth/src/vm.rs index 72b2410..63834ed 100644 --- a/crates/forth/src/vm.rs +++ b/crates/forth/src/vm.rs @@ -86,7 +86,7 @@ impl Forth { // Resolve root scope at end of script if let Some(scope) = scope_stack.pop() { - resolve_scope(&scope, &mut outputs); + resolve_scope(&scope, ctx.step_duration(), &mut outputs); } Ok(outputs) @@ -536,7 +536,11 @@ impl Forth { let cond = stack.pop().ok_or("stack underflow")?; let false_quot = stack.pop().ok_or("stack underflow")?; let true_quot = stack.pop().ok_or("stack underflow")?; - let quot = if cond.is_truthy() { true_quot } else { false_quot }; + let quot = if cond.is_truthy() { + true_quot + } else { + false_quot + }; run_quotation(quot, stack, outputs, scope_stack, cmd)?; } @@ -613,10 +617,7 @@ impl Forth { } else { let key = format!("__chain_{}_{}__", ctx.bank, ctx.pattern); let val = format!("{bank}:{pattern}"); - self.vars - .lock() - .unwrap() - .insert(key, Value::Str(val, None)); + self.vars.lock().unwrap().insert(key, Value::Str(val, None)); } } @@ -725,7 +726,7 @@ impl Forth { let child = scope_stack.pop().unwrap(); if child.stacked { - resolve_scope(&child, outputs); + resolve_scope(&child, ctx.step_duration(), outputs); } else { let parent = scope_stack.last_mut().ok_or("scope stack underflow")?; let parent_slot = parent.claim_slot(); @@ -750,7 +751,6 @@ impl Forth { emit_once(cmd, scope_stack)?; } } - } pc += 1; } @@ -759,7 +759,7 @@ impl Forth { } } -fn resolve_scope(scope: &ScopeContext, outputs: &mut Vec) { +fn resolve_scope(scope: &ScopeContext, step_duration: f64, outputs: &mut Vec) { let slot_dur = if scope.slot_count == 0 { scope.duration * scope.weight } else { @@ -797,10 +797,21 @@ fn resolve_scope(scope: &ScopeContext, outputs: &mut Vec) { }); } - emissions.sort_by(|a, b| a.delta.partial_cmp(&b.delta).unwrap_or(std::cmp::Ordering::Equal)); + emissions.sort_by(|a, b| { + a.delta + .partial_cmp(&b.delta) + .unwrap_or(std::cmp::Ordering::Equal) + }); for em in emissions { - emit_output(&em.sound, &em.params, em.delta, em.dur, outputs); + emit_output( + &em.sound, + &em.params, + em.delta, + em.dur, + step_duration, + outputs, + ); } } @@ -842,7 +853,40 @@ fn resolve_scope_to_parent(child: &ScopeContext, parent_slot: usize, parent: &mu } } -fn emit_output(sound: &str, params: &[(String, String)], delta: f64, dur: f64, outputs: &mut Vec) { +const TEMPO_SCALED_PARAMS: &[&str] = &[ + "attack", + "decay", + "release", + "lpa", + "lpd", + "lpr", + "hpa", + "hpd", + "hpr", + "bpa", + "bpd", + "bpr", + "patt", + "pdec", + "prel", + "fma", + "fmd", + "fmr", + "glide", + "verbdecay", + "verbpredelay", + "chorusdelay", + "duration", +]; + +fn emit_output( + sound: &str, + params: &[(String, String)], + delta: f64, + dur: f64, + step_duration: f64, + outputs: &mut Vec, +) { let mut pairs = vec![("sound".into(), sound.to_string())]; pairs.extend(params.iter().cloned()); if delta > 0.0 { @@ -857,12 +901,20 @@ fn emit_output(sound: &str, params: &[(String, String)], delta: f64, dur: f64, o } else { pairs.push(("delaytime".into(), dur.to_string())); } + for pair in &mut pairs { + if TEMPO_SCALED_PARAMS.contains(&pair.0.as_str()) { + if let Ok(val) = pair.1.parse::() { + pair.1 = (val * step_duration).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; @@ -924,7 +976,11 @@ where { let b = stack.pop().ok_or("stack underflow")?; let a = stack.pop().ok_or("stack underflow")?; - let result = if f(a.as_float()?, b.as_float()?) { 1 } else { 0 }; + let result = if f(a.as_float()?, b.as_float()?) { + 1 + } else { + 0 + }; stack.push(Value::Int(result, None)); Ok(()) } diff --git a/crates/forth/src/words.rs b/crates/forth/src/words.rs index 47f1859..3fb77e2 100644 --- a/crates/forth/src/words.rs +++ b/crates/forth/src/words.rs @@ -1222,6 +1222,54 @@ pub const WORDS: &[Word] = &[ example: "0.3 bpr", compile: Param, }, + Word { + name: "llpf", + category: "Ladder Filter", + stack: "(f --)", + desc: "Set ladder lowpass frequency", + example: "2000 llpf", + compile: Param, + }, + Word { + name: "llpq", + category: "Ladder Filter", + stack: "(f --)", + desc: "Set ladder lowpass resonance", + example: "0.5 llpq", + compile: Param, + }, + Word { + name: "lhpf", + category: "Ladder Filter", + stack: "(f --)", + desc: "Set ladder highpass frequency", + example: "100 lhpf", + compile: Param, + }, + Word { + name: "lhpq", + category: "Ladder Filter", + stack: "(f --)", + desc: "Set ladder highpass resonance", + example: "0.5 lhpq", + compile: Param, + }, + Word { + name: "lbpf", + category: "Ladder Filter", + stack: "(f --)", + desc: "Set ladder bandpass frequency", + example: "1000 lbpf", + compile: Param, + }, + Word { + name: "lbpq", + category: "Ladder Filter", + stack: "(f --)", + desc: "Set ladder bandpass resonance", + example: "0.5 lbpq", + compile: Param, + }, Word { name: "ftype", category: "Filter", @@ -1902,11 +1950,28 @@ fn parse_interval(name: &str) -> Option { Some(simple) } -pub(super) fn compile_word(name: &str, span: Option, ops: &mut Vec, dict: &Dictionary) -> bool { +pub(super) fn compile_word( + name: &str, + span: Option, + ops: &mut Vec, + dict: &Dictionary, +) -> bool { match name { - "linramp" => { ops.push(Op::PushFloat(1.0, span)); ops.push(Op::Ramp); return true; } - "expramp" => { ops.push(Op::PushFloat(3.0, span)); ops.push(Op::Ramp); return true; } - "logramp" => { ops.push(Op::PushFloat(0.3, span)); ops.push(Op::Ramp); return true; } + "linramp" => { + ops.push(Op::PushFloat(1.0, span)); + ops.push(Op::Ramp); + return true; + } + "expramp" => { + ops.push(Op::PushFloat(3.0, span)); + ops.push(Op::Ramp); + return true; + } + "logramp" => { + ops.push(Op::PushFloat(0.3, span)); + ops.push(Op::Ramp); + return true; + } _ => {} } diff --git a/tests/forth/sound.rs b/tests/forth/sound.rs index 8482b59..e6cfb1d 100644 --- a/tests/forth/sound.rs +++ b/tests/forth/sound.rs @@ -46,14 +46,15 @@ fn multiple_emits() { #[test] fn envelope_params() { + // Values are tempo-scaled: 0.01 * step_duration(0.125) = 0.00125, etc. let outputs = expect_outputs( r#""synth" s 0.01 attack 0.1 decay 0.7 sustain 0.3 release ."#, 1, ); - assert!(outputs[0].contains("attack/0.01")); - assert!(outputs[0].contains("decay/0.1")); + assert!(outputs[0].contains("attack/0.00125")); + assert!(outputs[0].contains("decay/0.0125")); assert!(outputs[0].contains("sustain/0.7")); - assert!(outputs[0].contains("release/0.3")); + assert!(outputs[0].contains("release/0.0375")); } #[test] @@ -66,17 +67,17 @@ fn filter_params() { #[test] fn adsr_sets_all_envelope_params() { let outputs = expect_outputs(r#""synth" s 0.01 0.1 0.5 0.3 adsr ."#, 1); - assert!(outputs[0].contains("attack/0.01")); - assert!(outputs[0].contains("decay/0.1")); + assert!(outputs[0].contains("attack/0.00125")); + assert!(outputs[0].contains("decay/0.0125")); assert!(outputs[0].contains("sustain/0.5")); - assert!(outputs[0].contains("release/0.3")); + assert!(outputs[0].contains("release/0.0375")); } #[test] fn ad_sets_attack_decay_sustain_zero() { let outputs = expect_outputs(r#""synth" s 0.01 0.1 ad ."#, 1); - assert!(outputs[0].contains("attack/0.01")); - assert!(outputs[0].contains("decay/0.1")); + assert!(outputs[0].contains("attack/0.00125")); + assert!(outputs[0].contains("decay/0.0125")); assert!(outputs[0].contains("sustain/0")); }