From dbd17a79465447477df5e4b5ddece2e55521cb12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Forment?= Date: Sat, 7 Feb 2026 12:08:11 +0100 Subject: [PATCH] WIP: prepare the ground for audio rate modulation --- Cargo.toml | 2 +- crates/forth/src/ops.rs | 5 ++ crates/forth/src/vm.rs | 46 +++++++++++++ crates/forth/src/words/compile.rs | 11 +++ crates/forth/src/words/sound.rs | 111 ++++++++++++++++++++++++++++++ docs/engine_audio_modulation.md | 82 ++++++++++++++++++++++ src/model/docs.rs | 4 ++ 7 files changed, 260 insertions(+), 1 deletion(-) create mode 100644 docs/engine_audio_modulation.md diff --git a/Cargo.toml b/Cargo.toml index 883e87a..70eb65d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -49,7 +49,7 @@ cagire-forth = { path = "crates/forth" } cagire-markdown = { path = "crates/markdown" } cagire-project = { path = "crates/project" } cagire-ratatui = { path = "crates/ratatui" } -doux = { git = "https://github.com/sova-org/doux", features = ["native"] } +doux = { path = "/Users/bubo/doux", features = ["native"] } rusty_link = "0.4" ratatui = "0.30" crossterm = "0.29" diff --git a/crates/forth/src/ops.rs b/crates/forth/src/ops.rs index 1f92a09..5eab270 100644 --- a/crates/forth/src/ops.rs +++ b/crates/forth/src/ops.rs @@ -106,6 +106,11 @@ pub enum Op { EuclidRot, Times, Chord(&'static [i64]), + // Audio-rate modulation DSL + ModLfo(u8), + ModSlide(u8), + ModRnd(u8), + ModEnv, // MIDI MidiEmit, GetMidiCC, diff --git a/crates/forth/src/vm.rs b/crates/forth/src/vm.rs index a30b668..ab02e81 100644 --- a/crates/forth/src/vm.rs +++ b/crates/forth/src/vm.rs @@ -1103,6 +1103,52 @@ impl Forth { } } + Op::ModLfo(shape) => { + let period = stack.pop().ok_or("stack underflow")?.as_float()? * ctx.step_duration(); + let max = stack.pop().ok_or("stack underflow")?.as_float()?; + let min = stack.pop().ok_or("stack underflow")?.as_float()?; + let suffix = match shape { 1 => "t", 2 => "w", 3 => "q", _ => "" }; + let s = format!("{min}~{max}:{period}{suffix}"); + stack.push(Value::Str(s.into(), None)); + } + Op::ModSlide(curve) => { + let dur = stack.pop().ok_or("stack underflow")?.as_float()? * ctx.step_duration(); + let end = stack.pop().ok_or("stack underflow")?.as_float()?; + let start = stack.pop().ok_or("stack underflow")?.as_float()?; + let suffix = match curve { 1 => "e", 2 => "s", _ => "" }; + let s = format!("{start}>{end}:{dur}{suffix}"); + stack.push(Value::Str(s.into(), None)); + } + Op::ModRnd(dist) => { + let period = stack.pop().ok_or("stack underflow")?.as_float()? * ctx.step_duration(); + let max = stack.pop().ok_or("stack underflow")?.as_float()?; + let min = stack.pop().ok_or("stack underflow")?.as_float()?; + let suffix = match dist { 1 => "s", 2 => "d", _ => "" }; + let s = format!("{min}?{max}:{period}{suffix}"); + stack.push(Value::Str(s.into(), None)); + } + Op::ModEnv => { + if stack.is_empty() { + return Err("stack underflow".into()); + } + let values = std::mem::take(stack); + let mut floats = Vec::with_capacity(values.len()); + for v in &values { + floats.push(v.as_float()?); + } + if floats.len() < 3 || (floats.len() - 1) % 2 != 0 { + return Err("env expects: start target1 dur1 [target2 dur2 ...]".into()); + } + let step_dur = ctx.step_duration(); + use std::fmt::Write; + let mut s = String::new(); + let _ = write!(&mut s, "{}", floats[0]); + for pair in floats[1..].chunks(2) { + let _ = write!(&mut s, ">{}:{}", pair[0], pair[1] * step_dur); + } + stack.push(Value::Str(s.into(), None)); + } + // MIDI operations Op::MidiEmit => { let (_, params) = cmd.snapshot().unwrap_or((None, &[])); diff --git a/crates/forth/src/words/compile.rs b/crates/forth/src/words/compile.rs index 0cfcd32..74003a8 100644 --- a/crates/forth/src/words/compile.rs +++ b/crates/forth/src/words/compile.rs @@ -104,6 +104,17 @@ pub(super) fn simple_op(name: &str) -> Option { "mstop" => Op::MidiStop, "mcont" => Op::MidiContinue, "forget" => Op::Forget, + "lfo" => Op::ModLfo(0), + "tlfo" => Op::ModLfo(1), + "wlfo" => Op::ModLfo(2), + "qlfo" => Op::ModLfo(3), + "slide" => Op::ModSlide(0), + "expslide" => Op::ModSlide(1), + "sslide" => Op::ModSlide(2), + "jit" => Op::ModRnd(0), + "sjit" => Op::ModRnd(1), + "drunk" => Op::ModRnd(2), + "env" => Op::ModEnv, _ => return None, }) } diff --git a/crates/forth/src/words/sound.rs b/crates/forth/src/words/sound.rs index ba897bf..dcec3e3 100644 --- a/crates/forth/src/words/sound.rs +++ b/crates/forth/src/words/sound.rs @@ -659,4 +659,115 @@ pub(super) const WORDS: &[Word] = &[ compile: Simple, varargs: false, }, + // Audio-rate Modulation DSL + Word { + name: "lfo", + aliases: &[], + category: "Audio Modulation", + stack: "(min max period -- str)", + desc: "Sine oscillation: min~max:period", + example: "200 4000 2 lfo lpf", + compile: Simple, + varargs: false, + }, + Word { + name: "tlfo", + aliases: &[], + category: "Audio Modulation", + stack: "(min max period -- str)", + desc: "Triangle oscillation: min~max:periodt", + example: "0.3 0.7 0.5 tlfo pan", + compile: Simple, + varargs: false, + }, + Word { + name: "wlfo", + aliases: &[], + category: "Audio Modulation", + stack: "(min max period -- str)", + desc: "Sawtooth oscillation: min~max:periodw", + example: "200 4000 1 wlfo lpf", + compile: Simple, + varargs: false, + }, + Word { + name: "qlfo", + aliases: &[], + category: "Audio Modulation", + stack: "(min max period -- str)", + desc: "Square oscillation: min~max:periodq", + example: "0.0 1.0 0.25 qlfo gain", + compile: Simple, + varargs: false, + }, + Word { + name: "slide", + aliases: &[], + category: "Audio Modulation", + stack: "(start end dur -- str)", + desc: "Linear transition: start>end:dur", + example: "0 1 0.01 slide gain", + compile: Simple, + varargs: false, + }, + Word { + name: "expslide", + aliases: &[], + category: "Audio Modulation", + stack: "(start end dur -- str)", + desc: "Exponential transition: start>end:dure", + example: "0 1 0.5 expslide gain", + compile: Simple, + varargs: false, + }, + Word { + name: "sslide", + aliases: &[], + category: "Audio Modulation", + stack: "(start end dur -- str)", + desc: "Smooth transition: start>end:durs", + example: "200 800 1 sslide lpf", + compile: Simple, + varargs: false, + }, + Word { + name: "jit", + aliases: &[], + category: "Audio Modulation", + stack: "(min max period -- str)", + desc: "Random hold: min?max:period", + example: "200 4000 0.5 jit lpf", + compile: Simple, + varargs: false, + }, + Word { + name: "sjit", + aliases: &[], + category: "Audio Modulation", + stack: "(min max period -- str)", + desc: "Smooth random: min?max:periods", + example: "200 4000 0.5 sjit lpf", + compile: Simple, + varargs: false, + }, + Word { + name: "drunk", + aliases: &[], + category: "Audio Modulation", + stack: "(min max period -- str)", + desc: "Drunk walk: min?max:periodd", + example: "200 4000 0.5 drunk lpf", + compile: Simple, + varargs: false, + }, + Word { + name: "env", + aliases: &[], + category: "Audio Modulation", + stack: "(start t1 d1 ... -- str)", + desc: "Multi-segment envelope: start>t1:d1>...", + example: "0 1 0.01 0.7 0.1 0 2 env gain", + compile: Simple, + varargs: false, + }, ]; diff --git a/docs/engine_audio_modulation.md b/docs/engine_audio_modulation.md new file mode 100644 index 0000000..304806f --- /dev/null +++ b/docs/engine_audio_modulation.md @@ -0,0 +1,82 @@ +# Audio-Rate Modulation + +Any parameter can be modulated continuously using modulation words. Instead of a fixed value, these words produce a modulation string that the engine interprets as a moving signal. + +All time values are in **steps**, just like `attack`, `decay`, and `release`. At 120 BPM with speed 1, one step is 0.125 seconds. Writing `4 lfo` means a 4-step period. + +## LFOs + +Oscillate a parameter between two values. + +```forth +saw s 200 4000 4 lfo lpf . ( sweep filter over 4 steps ) +saw s 0.3 0.7 2 tlfo pan . ( triangle pan over 2 steps ) +``` + +| Word | Shape | Output | +|------|-------|--------| +| `lfo` | Sine | `min~max:period` | +| `tlfo` | Triangle | `min~max:periodt` | +| `wlfo` | Sawtooth | `min~max:periodw` | +| `qlfo` | Square | `min~max:periodq` | + +Stack effect: `( min max period -- str )` + +## Slides + +Transition from one value to another over a duration. + +```forth +saw s 0 1 0.5 slide gain . ( fade in over half a step ) +saw s 200 4000 8 sslide lpf . ( smooth sweep over 8 steps ) +``` + +| Word | Curve | Output | +|------|-------|--------| +| `slide` | Linear | `start>end:dur` | +| `expslide` | Exponential | `start>end:dure` | +| `sslide` | Smooth (S-curve) | `start>end:durs` | + +Stack effect: `( start end dur -- str )` + +## Random + +Randomize a parameter within a range, retriggering at a given period. + +```forth +saw s 200 4000 2 jit lpf . ( new random value every 2 steps ) +saw s 200 4000 2 sjit lpf . ( same but smoothly interpolated ) +saw s 200 4000 1 drunk lpf . ( random walk, each step ) +``` + +| Word | Behavior | Output | +|------|----------|--------| +| `jit` | Sample & hold | `min?max:period` | +| `sjit` | Smooth interpolation | `min?max:periods` | +| `drunk` | Random walk | `min?max:periodd` | + +Stack effect: `( min max period -- str )` + +## Envelopes + +Define a multi-segment envelope for a parameter. Provide a start value, then pairs of target and duration. + +```forth +saw s 0 1 0.1 0.7 0.5 0 8 env gain . +``` + +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: `( start target1 dur1 [target2 dur2 ...] -- str )` + +## Combining + +Modulation words return strings, so they compose naturally with the rest of the language. Use them anywhere a parameter value is expected. + +```forth +saw s + 200 4000 4 lfo lpf + 0.3 0.7 8 tlfo pan + 0 1 0.1 0.7 0.5 0 8 env gain +. +``` diff --git a/src/model/docs.rs b/src/model/docs.rs index 6a8ee56..7e5659f 100644 --- a/src/model/docs.rs +++ b/src/model/docs.rs @@ -46,6 +46,10 @@ pub const DOCS: &[DocEntry] = &[ include_str!("../../docs/engine_distortion.md"), ), Topic("Space & Time", include_str!("../../docs/engine_space.md")), + Topic( + "Audio-Rate Mod", + include_str!("../../docs/engine_audio_modulation.md"), + ), Topic("Words & Sounds", include_str!("../../docs/engine_words.md")), // MIDI Section("MIDI"),