From 859629ae34a6f087a1307a0a05d47ca452bbc6da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Forment?= Date: Sat, 14 Mar 2026 13:02:01 +0100 Subject: [PATCH] Feat: adding LPG --- crates/forth/src/ops.rs | 1 + crates/forth/src/vm.rs | 24 ++++++++++++++++ crates/forth/src/words/compile.rs | 1 + crates/forth/src/words/sound.rs | 10 +++++++ docs/engine/audio_modulation.md | 48 +++++++++++++++++++++++++++---- 5 files changed, 78 insertions(+), 6 deletions(-) diff --git a/crates/forth/src/ops.rs b/crates/forth/src/ops.rs index 77bcb7e..991f8cb 100644 --- a/crates/forth/src/ops.rs +++ b/crates/forth/src/ops.rs @@ -135,6 +135,7 @@ pub enum Op { ModEnv, ModEnvAd, ModEnvAdr, + Lpg, // Global params EmitAll, ClearGlobal, diff --git a/crates/forth/src/vm.rs b/crates/forth/src/vm.rs index 7493afc..1a05543 100644 --- a/crates/forth/src/vm.rs +++ b/crates/forth/src/vm.rs @@ -1481,6 +1481,22 @@ impl Forth { stack.push(Value::Str(s.into(), None)); } + Op::Lpg => { + let depth = pop_float(stack)?.clamp(0.0, 1.0); + let max = pop_float(stack)?; + let min = pop_float(stack)?; + let effective_max = min + (max - min) * depth; + let sd = ctx.step_duration(); + let a = cmd_param_float(cmd, "attack").unwrap_or(0.0) * sd; + let d = cmd_param_float(cmd, "decay").unwrap_or(1.0) * sd; + let s = cmd_param_float(cmd, "sustain").unwrap_or(0.0); + let r = cmd_param_float(cmd, "release").unwrap_or(0.0) * sd; + use std::fmt::Write; + let mut mod_str = String::new(); + let _ = write!(&mut mod_str, "{min}^{effective_max}:{a}:{d}:{s}:{r}"); + cmd.set_param("lpf", Value::Str(mod_str.into(), None)); + } + // MIDI operations Op::MidiEmit => { let (_, params) = cmd.snapshot().unwrap_or((None, &[])); @@ -1726,6 +1742,14 @@ fn extract_dev_param(params: &[(&str, Value)]) -> u8 { .unwrap_or(0) } +fn cmd_param_float(cmd: &CmdRegister, name: &str) -> Option { + cmd.params() + .iter() + .rev() + .find(|(k, _)| *k == name) + .and_then(|(_, v)| v.as_float().ok()) +} + fn is_tempo_scaled_param(name: &str) -> bool { matches!( name, diff --git a/crates/forth/src/words/compile.rs b/crates/forth/src/words/compile.rs index a71a31c..a25d13e 100644 --- a/crates/forth/src/words/compile.rs +++ b/crates/forth/src/words/compile.rs @@ -145,6 +145,7 @@ pub(super) fn simple_op(name: &str) -> Option { "ead" => Op::ModEnvAd, "eadr" => Op::ModEnvAdr, "eadsr" | "env" => Op::ModEnv, + "lpg" => Op::Lpg, _ => return None, }) } diff --git a/crates/forth/src/words/sound.rs b/crates/forth/src/words/sound.rs index 843195f..eb5e0e0 100644 --- a/crates/forth/src/words/sound.rs +++ b/crates/forth/src/words/sound.rs @@ -862,4 +862,14 @@ pub(super) const WORDS: &[Word] = &[ compile: Simple, varargs: false, }, + Word { + name: "lpg", + aliases: &[], + category: "Audio Modulation", + stack: "(min max depth --)", + desc: "Low pass gate: pairs amp envelope with lpf modulation", + example: "0.01 0.1 ad 200 8000 1 lpg .", + compile: Simple, + varargs: false, + }, ]; diff --git a/docs/engine/audio_modulation.md b/docs/engine/audio_modulation.md index 4912f2f..674aea8 100644 --- a/docs/engine/audio_modulation.md +++ b/docs/engine/audio_modulation.md @@ -57,17 +57,53 @@ saw snd 200 4000 1 drunk lpf . ( random walk, each step ) Stack effect: `( min max period -- str )` -## Envelopes +## Envelope Modulation -Define a multi-segment envelope for a parameter. Provide a start value, then pairs of target and duration. +Apply an envelope to any parameter. The `env` word is the complete form: it sweeps from `min` to `max` following a full attack, decay, sustain, release shape. All times are in steps. ```forth -saw snd 0 1 0.1 0.7 0.5 0 8 env gain . +saw snd 200 8000 0.01 0.1 0.5 0.3 env lpf . ``` -This creates: start at `0`, rise to `1` in `0.1` steps, drop to `0.7` in `0.5` steps, fall to `0` in `8` steps. +Stack effect: `( min max attack decay sustain release -- str )` -Stack effect: `( start target1 dur1 [target2 dur2 ...] -- str )` +This is the building block. From it, three shorthands drop the parameters you don't need: + +| Word | Stack | What it does | +|------|-------|-------------| +| `env` | `( min max a d s r -- str )` | Full envelope (attack, decay, sustain, release) | +| `eadr` | `( min max a d r -- str )` | No sustain (sustain = 0) | +| `ead` | `( min max a d -- str )` | Percussive (sustain = 0, release = 0) | + +`eadsr` is an alias for `env`. + +```forth +saw snd 200 8000 0.01 0.3 ead lpf . ( percussive filter pluck ) +saw snd 0 5 0.01 0.1 0.3 eadr fm . ( FM depth with release tail ) +saw snd 200 8000 0.01 0.1 0.5 0.3 env lpf . ( full ADSR on filter ) +``` + +These work on any parameter — `lpf`, `fm`, `gain`, `pan`, `freq`, anything that accepts a value. + +## Low Pass Gate + +The `lpg` word couples the amplitude envelope with a lowpass filter. Set your amp envelope first with `ad` or `adsr`, then `lpg` mirrors it to `lpf`. + +```forth +saw snd 0.01 0.1 ad 200 8000 1 lpg . ( percussive LPG ) +saw snd 0.01 0.1 0.5 0.3 adsr 200 4000 1 lpg . ( sustained LPG ) +``` + +Stack effect: `( min max depth -- )` + +- `min`/`max` — filter frequency range in Hz +- `depth` — 0 to 1, scales the filter range (1 = full, 0.5 = halfway) + +```forth +saw snd 0.01 0.5 ad 200 8000 0.3 lpg . ( subtle LPG, filter barely opens ) +``` + +`lpg` reads `attack`, `decay`, `sustain`, and `release` from the current sound. If none are set, it defaults to a short percussive shape. ## Combining @@ -77,6 +113,6 @@ Modulation words return strings, so they compose naturally with the rest of the saw snd 200 4000 4 lfo lpf 0.3 0.7 8 tlfo pan - 0 1 0.1 0.7 0.5 0 8 env gain + 0 1 0.01 0.1 ead gain . ```