From 549a998e9c756599ec67351442f0f369e549fddd Mon Sep 17 00:00:00 2001 From: OpenSauce Date: Sat, 13 Jun 2026 23:23:04 +0100 Subject: [PATCH 1/2] =?UTF-8?q?feat(stages):=20add=20tremolo=20effect=20wi?= =?UTF-8?q?th=20sine=E2=86=92square=20killswitch=20shape?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Amplitude-modulation effect registered as an Effect-category stage, appearing in the Effects tab alongside Delay/Reverb/EQ. A phase-accumulator sine LFO modulates signal gain. A continuous Shape control morphs the LFO from a smooth sine (vintage tremolo) toward a hard square via tanh waveshaping — at full Depth + full Shape this is a square-wave "killswitch" stutter (Tom Morello toggle-switch chop). Depth is one-pole smoothed to avoid zipper noise; the LFO output is left unsmoothed so square edges stay crisp. Controls: Rate (0.1–20 Hz), Depth (0–100%), Shape (sine→square). Wires the standard stage-registration surface: core stage + config, StageType/StageConfig, UI view + gui_stage_registry, EN/ZH i18n, minimap abbreviation, and plugin per-slot params. Covered by 9 unit tests (passthrough, full-chop silence/unity, unit-range gain, LFO periodicity, sine fidelity, validation, clamping). --- rustortion-core/src/amp/stages/mod.rs | 1 + rustortion-core/src/amp/stages/tremolo.rs | 301 +++++++++++++++++++++ rustortion-core/src/preset/stage_config.rs | 12 +- rustortion-plugin/src/params.rs | 35 +++ rustortion-ui/src/components/minimap.rs | 1 + rustortion-ui/src/i18n/mod.rs | 12 + rustortion-ui/src/stages/mod.rs | 1 + rustortion-ui/src/stages/tremolo.rs | 74 +++++ 8 files changed, 436 insertions(+), 1 deletion(-) create mode 100644 rustortion-core/src/amp/stages/tremolo.rs create mode 100644 rustortion-ui/src/stages/tremolo.rs diff --git a/rustortion-core/src/amp/stages/mod.rs b/rustortion-core/src/amp/stages/mod.rs index 00a0f0c..ab94855 100644 --- a/rustortion-core/src/amp/stages/mod.rs +++ b/rustortion-core/src/amp/stages/mod.rs @@ -12,6 +12,7 @@ pub mod poweramp; pub mod preamp; pub mod reverb; pub mod tonestack; +pub mod tremolo; // The core trait that all processing stages must implement pub trait Stage: Send + Sync + 'static { diff --git a/rustortion-core/src/amp/stages/tremolo.rs b/rustortion-core/src/amp/stages/tremolo.rs new file mode 100644 index 0000000..3c67c3d --- /dev/null +++ b/rustortion-core/src/amp/stages/tremolo.rs @@ -0,0 +1,301 @@ +use std::f32::consts::TAU; + +use serde::{Deserialize, Serialize}; + +use crate::amp::stages::Stage; +use crate::amp::stages::common::calculate_coefficient; + +const MIN_RATE_HZ: f32 = 0.1; +const MAX_RATE_HZ: f32 = 20.0; + +/// Smallest `tanh` drive, applied at `shape = 0.0`. `tanh` is ~linear near +/// zero, so at this drive `tanh(raw * d) / tanh(d)` collapses back to `raw` — +/// i.e. a faithful sine. Kept above zero to avoid a `0 / 0` at `shape = 0`. +const MIN_DRIVE: f32 = 1e-3; + +/// `tanh` drive at `shape = 1.0`. ~12 clamps the sine to within 0.01% of a hard +/// square, giving the "killswitch" chop without the aliasing of a literal sign(). +const MAX_DRIVE: f32 = 12.0; + +/// One-pole smoothing time for the depth parameter — fast enough to feel +/// instant, slow enough to suppress zipper noise when the slider is dragged. +/// The LFO output itself is never smoothed, so square edges stay crisp. +const DEPTH_SMOOTH_MS: f32 = 30.0; + +/// Tremolo — amplitude modulation by a low-frequency oscillator. +/// +/// A phase accumulator drives a sine LFO. The `shape` parameter morphs that +/// sine toward a hard square via `tanh` waveshaping, so a single stage spans +/// vintage tremolo (`shape = 0`) through a square-wave "killswitch" stutter +/// (`shape = 1`, `depth = 1`). The modulator is mapped to a gain in +/// `[1 - depth, 1]`, so at full depth the signal dips all the way to silence at +/// each trough. +pub struct TremoloStage { + rate_hz: f32, + depth: f32, + shape: f32, + sample_rate: f32, + phase: f32, + depth_smoothed: f32, + depth_coeff: f32, +} + +impl TremoloStage { + pub fn new(rate_hz: f32, depth: f32, shape: f32, sample_rate: f32) -> Self { + let rate_hz = rate_hz.clamp(MIN_RATE_HZ, MAX_RATE_HZ); + let depth = depth.clamp(0.0, 1.0); + let shape = shape.clamp(0.0, 1.0); + + Self { + rate_hz, + depth, + shape, + sample_rate, + phase: 0.0, + depth_smoothed: depth, + depth_coeff: calculate_coefficient(DEPTH_SMOOTH_MS, sample_rate), + } + } + + /// Current LFO gain in `[1 - depth, 1]`, advancing the phase by one sample. + fn next_gain(&mut self) -> f32 { + // Smooth depth to avoid zipper noise; the LFO output stays unsmoothed. + self.depth_smoothed = self + .depth_coeff + .mul_add(self.depth_smoothed, (1.0 - self.depth_coeff) * self.depth); + + let raw = (TAU * self.phase).sin(); + + // Morph sine -> square. At `MIN_DRIVE` the ratio reproduces `raw`; + // at `MAX_DRIVE` it clamps to ~±1 everywhere but the zero crossings. + let drive = (MAX_DRIVE - MIN_DRIVE).mul_add(self.shape, MIN_DRIVE); + let sharp = (raw * drive).tanh() / drive.tanh(); + + // Map [-1, 1] -> [0, 1], then to a gain in [1 - depth, 1]: + // gain = 1 - depth * (1 - m) = depth * (m - 1) + 1 + let m = 0.5f32.mul_add(sharp, 0.5); + let gain = self.depth_smoothed.mul_add(m - 1.0, 1.0); + + // Advance phase, wrapping to [0, 1). + self.phase += self.rate_hz / self.sample_rate; + if self.phase >= 1.0 { + self.phase -= 1.0; + } + + gain + } +} + +impl Stage for TremoloStage { + fn process(&mut self, input: f32) -> f32 { + input * self.next_gain() + } + + fn set_parameter(&mut self, name: &str, value: f32) -> Result<(), &'static str> { + match name { + "rate" => { + if (MIN_RATE_HZ..=MAX_RATE_HZ).contains(&value) { + self.rate_hz = value; + Ok(()) + } else { + Err("Rate must be between 0.1 Hz and 20 Hz") + } + } + "depth" => { + if (0.0..=1.0).contains(&value) { + self.depth = value; + Ok(()) + } else { + Err("Depth must be between 0.0 and 1.0") + } + } + "shape" => { + if (0.0..=1.0).contains(&value) { + self.shape = value; + Ok(()) + } else { + Err("Shape must be between 0.0 and 1.0") + } + } + _ => Err("Unknown parameter"), + } + } + + fn get_parameter(&self, name: &str) -> Result { + match name { + "rate" => Ok(self.rate_hz), + "depth" => Ok(self.depth), + "shape" => Ok(self.shape), + _ => Err("Unknown parameter"), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const SAMPLE_RATE: f32 = 44100.0; + const TOL: f32 = 1e-3; + + #[test] + fn depth_zero_is_unity_passthrough() { + // depth 0 => gain is always 1.0, regardless of rate/shape. + let mut trem = TremoloStage::new(5.0, 0.0, 0.5, SAMPLE_RATE); + for i in 0..2000 { + let input = (i as f32 * 0.01).sin(); + let out = trem.process(input); + assert!( + (out - input).abs() < TOL, + "depth 0 should pass dry at sample {i}: in {input}, out {out}" + ); + } + } + + #[test] + fn gain_stays_in_unit_range() { + // For any depth/shape, the applied gain (and thus a unit DC input) must + // never leave [0, 1] — no boost, no phase inversion. + for &(depth, shape) in &[(0.3, 0.0), (0.7, 0.5), (1.0, 1.0), (0.5, 1.0)] { + let mut trem = TremoloStage::new(7.0, depth, shape, SAMPLE_RATE); + for _ in 0..(SAMPLE_RATE as usize) { + let out = trem.process(1.0); + assert!( + (-TOL..=1.0 + TOL).contains(&out), + "gain out of range at depth {depth}, shape {shape}: {out}" + ); + } + } + } + + #[test] + fn full_chop_reaches_silence_and_unity() { + // depth 1 + shape 1 (square) = killswitch: gain alternates ~0 and ~1. + let mut trem = TremoloStage::new(5.0, 1.0, 1.0, SAMPLE_RATE); + let mut min_gain = f32::INFINITY; + let mut max_gain = f32::NEG_INFINITY; + // Two full periods at 5 Hz. + for _ in 0..((SAMPLE_RATE as usize) * 2 / 5) { + let g = trem.process(1.0); + min_gain = min_gain.min(g); + max_gain = max_gain.max(g); + } + assert!(min_gain < 0.02, "trough should mute, got {min_gain}"); + assert!(max_gain > 0.98, "peak should pass unity, got {max_gain}"); + } + + #[test] + fn shape_zero_tracks_sine() { + // At shape 0 + depth 1, gain == 0.5 * (sin(2*pi*phase) + 1). + let rate = 10.0; + let mut trem = TremoloStage::new(rate, 1.0, 0.0, SAMPLE_RATE); + for i in 0..4410 { + let g = trem.process(1.0); + let phase = i as f32 * rate / SAMPLE_RATE; + let expected = 0.5f32.mul_add((TAU * phase).sin(), 0.5); + assert!( + (g - expected).abs() < TOL, + "sine mismatch at {i}: got {g}, expected {expected}" + ); + } + } + + #[test] + fn lfo_is_periodic() { + // 10 Hz at 44.1 kHz => exactly 4410 samples per cycle. The gain at + // sample i must match the gain at sample i + period. + let rate = 10.0; + let period = 4410usize; + let mut trem = TremoloStage::new(rate, 0.8, 0.4, SAMPLE_RATE); + let mut gains = Vec::with_capacity(period * 2 + 8); + for _ in 0..(period * 2 + 8) { + gains.push(trem.process(1.0)); + } + for i in (0..period).step_by(137) { + assert!( + (gains[i] - gains[i + period]).abs() < TOL, + "not periodic at {i}: {} vs {}", + gains[i], + gains[i + period] + ); + } + } + + #[test] + fn parameter_validation() { + let mut trem = TremoloStage::new(5.0, 0.5, 0.0, SAMPLE_RATE); + + assert!(trem.set_parameter("rate", 0.05).is_err()); + assert!(trem.set_parameter("rate", 25.0).is_err()); + assert!(trem.set_parameter("rate", 12.0).is_ok()); + + assert!(trem.set_parameter("depth", -0.1).is_err()); + assert!(trem.set_parameter("depth", 1.1).is_err()); + assert!(trem.set_parameter("depth", 0.75).is_ok()); + + assert!(trem.set_parameter("shape", -0.1).is_err()); + assert!(trem.set_parameter("shape", 1.1).is_err()); + assert!(trem.set_parameter("shape", 1.0).is_ok()); + + assert!(trem.set_parameter("unknown", 0.0).is_err()); + } + + #[test] + fn constructor_clamps_out_of_range() { + let trem = TremoloStage::new(100.0, 2.0, 2.0, SAMPLE_RATE); + assert!((trem.get_parameter("rate").unwrap() - MAX_RATE_HZ).abs() < TOL); + assert!((trem.get_parameter("depth").unwrap() - 1.0).abs() < TOL); + assert!((trem.get_parameter("shape").unwrap() - 1.0).abs() < TOL); + + let trem = TremoloStage::new(0.0, -1.0, -1.0, SAMPLE_RATE); + assert!((trem.get_parameter("rate").unwrap() - MIN_RATE_HZ).abs() < TOL); + assert!(trem.get_parameter("depth").unwrap().abs() < TOL); + assert!(trem.get_parameter("shape").unwrap().abs() < TOL); + } + + #[test] + fn get_parameters() { + let trem = TremoloStage::new(8.0, 0.6, 0.3, SAMPLE_RATE); + assert!((trem.get_parameter("rate").unwrap() - 8.0).abs() < TOL); + assert!((trem.get_parameter("depth").unwrap() - 0.6).abs() < TOL); + assert!((trem.get_parameter("shape").unwrap() - 0.3).abs() < TOL); + assert!(trem.get_parameter("unknown").is_err()); + } + + #[test] + fn default_config() { + let cfg = TremoloConfig::default(); + assert!((cfg.rate_hz - 5.0).abs() < TOL); + assert!((cfg.depth - 0.5).abs() < TOL); + assert!((cfg.shape - 0.0).abs() < TOL); + assert!(!cfg.bypassed); + } +} + +// --- Config --- + +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +pub struct TremoloConfig { + pub rate_hz: f32, + pub depth: f32, + pub shape: f32, + #[serde(default)] + pub bypassed: bool, +} + +impl Default for TremoloConfig { + fn default() -> Self { + Self { + rate_hz: 5.0, + depth: 0.5, + shape: 0.0, + bypassed: false, + } + } +} + +impl TremoloConfig { + pub fn to_stage(&self, sample_rate: f32) -> TremoloStage { + TremoloStage::new(self.rate_hz, self.depth, self.shape, sample_rate) + } +} diff --git a/rustortion-core/src/preset/stage_config.rs b/rustortion-core/src/preset/stage_config.rs index 926edf8..8b4924d 100644 --- a/rustortion-core/src/preset/stage_config.rs +++ b/rustortion-core/src/preset/stage_config.rs @@ -14,6 +14,7 @@ use crate::amp::stages::poweramp::PowerAmpConfig; use crate::amp::stages::preamp::PreampConfig; use crate::amp::stages::reverb::ReverbConfig; use crate::amp::stages::tonestack::ToneStackConfig; +use crate::amp::stages::tremolo::TremoloConfig; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum StageCategory { @@ -35,6 +36,7 @@ pub enum StageType { Delay, Reverb, Eq, + Tremolo, } impl StageType { @@ -50,6 +52,7 @@ impl StageType { Self::Delay, Self::Reverb, Self::Eq, + Self::Tremolo, ]; pub const fn category(self) -> StageCategory { @@ -62,7 +65,7 @@ impl StageType { | Self::NoiseGate | Self::MultibandSaturator | Self::Nam => StageCategory::Amp, - Self::Delay | Self::Reverb | Self::Eq => StageCategory::Effect, + Self::Delay | Self::Reverb | Self::Eq | Self::Tremolo => StageCategory::Effect, } } @@ -89,6 +92,7 @@ impl Display for StageType { Self::Delay => write!(f, "Delay"), Self::Reverb => write!(f, "Reverb"), Self::Eq => write!(f, "EQ"), + Self::Tremolo => write!(f, "Tremolo"), } } } @@ -106,6 +110,7 @@ pub enum StageConfig { Delay(DelayConfig), Reverb(ReverbConfig), Eq(EqConfig), + Tremolo(TremoloConfig), } impl From for StageConfig { @@ -124,6 +129,7 @@ impl From for StageConfig { StageType::Delay => Self::Delay(DelayConfig::default()), StageType::Reverb => Self::Reverb(ReverbConfig::default()), StageType::Eq => Self::Eq(EqConfig::default()), + StageType::Tremolo => Self::Tremolo(TremoloConfig::default()), } } } @@ -142,6 +148,7 @@ impl StageConfig { Self::Delay(cfg) => Box::new(cfg.to_stage(sample_rate)), Self::Reverb(cfg) => Box::new(cfg.to_stage(sample_rate)), Self::Eq(cfg) => Box::new(cfg.to_stage(sample_rate)), + Self::Tremolo(cfg) => Box::new(cfg.to_stage(sample_rate)), } } @@ -158,6 +165,7 @@ impl StageConfig { Self::Delay(_) => StageType::Delay, Self::Reverb(_) => StageType::Reverb, Self::Eq(_) => StageType::Eq, + Self::Tremolo(_) => StageType::Tremolo, } } @@ -178,6 +186,7 @@ impl StageConfig { Self::Delay(cfg) => cfg.bypassed, Self::Reverb(cfg) => cfg.bypassed, Self::Eq(cfg) => cfg.bypassed, + Self::Tremolo(cfg) => cfg.bypassed, } } @@ -194,6 +203,7 @@ impl StageConfig { Self::Delay(cfg) => cfg.bypassed = bypassed, Self::Reverb(cfg) => cfg.bypassed = bypassed, Self::Eq(cfg) => cfg.bypassed = bypassed, + Self::Tremolo(cfg) => cfg.bypassed = bypassed, } } } diff --git a/rustortion-plugin/src/params.rs b/rustortion-plugin/src/params.rs index 22b3827..3698c1c 100644 --- a/rustortion-plugin/src/params.rs +++ b/rustortion-plugin/src/params.rs @@ -481,6 +481,37 @@ impl Default for EqSlotParams { } } +#[derive(Params)] +pub struct TremoloSlotParams { + #[id = "rate"] + pub rate: FloatParam, + #[id = "depth"] + pub depth: FloatParam, + #[id = "shape"] + pub shape: FloatParam, + #[id = "bypassed"] + pub bypassed: BoolParam, +} + +impl Default for TremoloSlotParams { + fn default() -> Self { + Self { + rate: FloatParam::new( + "Rate", + 5.0, + FloatRange::Linear { + min: 0.1, + max: 20.0, + }, + ) + .with_unit(" Hz"), + depth: FloatParam::new("Depth", 0.5, FloatRange::Linear { min: 0.0, max: 1.0 }), + shape: FloatParam::new("Shape", 0.0, FloatRange::Linear { min: 0.0, max: 1.0 }), + bypassed: BoolParam::new("Bypassed", false), + } + } +} + /// Per-slot NAM params — intentionally **no** `model` parameter here. /// /// The selected model is stored by NAME in `NamConfig.model_name` inside the @@ -604,6 +635,9 @@ pub struct RustortionParams { #[nested(array, group = "EQ")] pub eq: [EqSlotParams; 8], + + #[nested(array, group = "Tremolo")] + pub tremolo: [TremoloSlotParams; 8], } impl Default for RustortionParams { @@ -683,6 +717,7 @@ impl Default for RustortionParams { delay: Default::default(), reverb: Default::default(), eq: Default::default(), + tremolo: Default::default(), } } } diff --git a/rustortion-ui/src/components/minimap.rs b/rustortion-ui/src/components/minimap.rs index bafac82..296f3f3 100644 --- a/rustortion-ui/src/components/minimap.rs +++ b/rustortion-ui/src/components/minimap.rs @@ -19,6 +19,7 @@ const fn stage_abbreviation(cfg: &StageConfig) -> &'static str { StageConfig::Delay(_) => "Dly", StageConfig::Reverb(_) => "Rev", StageConfig::Eq(_) => "EQ", + StageConfig::Tremolo(_) => "Trm", } } diff --git a/rustortion-ui/src/i18n/mod.rs b/rustortion-ui/src/i18n/mod.rs index ed8f595..307914d 100644 --- a/rustortion-ui/src/i18n/mod.rs +++ b/rustortion-ui/src/i18n/mod.rs @@ -165,6 +165,7 @@ pub struct Translations { pub stage_delay: &'static str, pub stage_reverb: &'static str, pub stage_eq: &'static str, + pub stage_tremolo: &'static str, pub stage_nam: &'static str, pub nam_model: &'static str, pub nam_no_model: &'static str, @@ -208,6 +209,9 @@ pub struct Translations { pub dry_wet: &'static str, pub room_size: &'static str, pub damping: &'static str, + pub rate: &'static str, + pub depth: &'static str, + pub shape: &'static str, // Filter types pub filter_highpass: &'static str, @@ -370,6 +374,7 @@ pub static EN: Translations = Translations { stage_delay: "Delay", stage_reverb: "Reverb", stage_eq: "Graphic EQ", + stage_tremolo: "Tremolo", stage_nam: "NAM", nam_model: "Model", nam_no_model: "Select a model…", @@ -413,6 +418,9 @@ pub static EN: Translations = Translations { dry_wet: "Dry/Wet", room_size: "Room Size", damping: "Damping", + rate: "Rate", + depth: "Depth", + shape: "Shape", // Filter types filter_highpass: "Highpass", @@ -566,6 +574,7 @@ pub static ZH_CN: Translations = Translations { stage_delay: "延迟", stage_reverb: "混响", stage_eq: "图形均衡器", + stage_tremolo: "颤音", stage_nam: "NAM", nam_model: "模型", nam_no_model: "选择模型…", @@ -609,6 +618,9 @@ pub static ZH_CN: Translations = Translations { dry_wet: "干/湿", room_size: "房间大小", damping: "阻尼", + rate: "速率", + depth: "深度", + shape: "波形", // Filter types filter_highpass: "高通", diff --git a/rustortion-ui/src/stages/mod.rs b/rustortion-ui/src/stages/mod.rs index 52a1eca..a4ca66f 100644 --- a/rustortion-ui/src/stages/mod.rs +++ b/rustortion-ui/src/stages/mod.rs @@ -67,4 +67,5 @@ gui_stage_registry! { Delay => delay, DelayMessage, stage_delay; Reverb => reverb, ReverbMessage, stage_reverb; Eq => eq, EqMessage, stage_eq; + Tremolo => tremolo, TremoloMessage, stage_tremolo; } diff --git a/rustortion-ui/src/stages/tremolo.rs b/rustortion-ui/src/stages/tremolo.rs new file mode 100644 index 0000000..600e188 --- /dev/null +++ b/rustortion-ui/src/stages/tremolo.rs @@ -0,0 +1,74 @@ +use iced::Element; +use iced::widget::column; + +use crate::components::widgets::common::{ + SPACING_TIGHT, StageViewState, labeled_slider, stage_card, +}; +use crate::messages::Message; +use crate::tr; +use rustortion_core::amp::stages::tremolo::TremoloConfig; + +use super::{ParamUpdate, StageMessage}; + +// --- Message --- + +#[derive(Debug, Clone)] +pub enum TremoloMessage { + RateChanged(f32), + DepthChanged(f32), + ShapeChanged(f32), +} + +// --- Apply --- + +pub const fn apply(cfg: &mut TremoloConfig, msg: TremoloMessage) -> Option { + match msg { + TremoloMessage::RateChanged(v) => { + cfg.rate_hz = v; + Some(ParamUpdate::Changed("rate", v)) + } + TremoloMessage::DepthChanged(v) => { + cfg.depth = v; + Some(ParamUpdate::Changed("depth", v)) + } + TremoloMessage::ShapeChanged(v) => { + cfg.shape = v; + Some(ParamUpdate::Changed("shape", v)) + } + } +} + +// --- View --- + +pub fn view(idx: usize, cfg: &TremoloConfig, state: StageViewState) -> Element<'_, Message> { + stage_card(tr!(stage_tremolo), idx, state, || { + column![ + labeled_slider( + tr!(rate), + 0.1..=20.0, + cfg.rate_hz, + move |v| Message::Stage(idx, StageMessage::Tremolo(TremoloMessage::RateChanged(v))), + |v| format!("{v:.2} {}", tr!(hz)), + 0.01 + ), + labeled_slider( + tr!(depth), + 0.0..=1.0, + cfg.depth, + move |v| Message::Stage(idx, StageMessage::Tremolo(TremoloMessage::DepthChanged(v))), + |v| format!("{:.0}%", v * 100.0), + 0.01 + ), + labeled_slider( + tr!(shape), + 0.0..=1.0, + cfg.shape, + move |v| Message::Stage(idx, StageMessage::Tremolo(TremoloMessage::ShapeChanged(v))), + |v| format!("{:.0}%", v * 100.0), + 0.01 + ), + ] + .spacing(SPACING_TIGHT) + .into() + }) +} From 7849039acae4310651f6a57ff527c2c1c7278d8e Mon Sep 17 00:00:00 2001 From: OpenSauce Date: Sat, 13 Jun 2026 23:32:05 +0100 Subject: [PATCH 2/2] perf(tremolo): cache tanh shape coefficients off the RT path + alloc test Address review feedback on the tremolo stage: - Cache `drive` and `drive_norm = 1/tanh(drive)` in the stage and refresh them only in `new()` and on `set_parameter("shape", _)`, instead of recomputing `drive.tanh()` every sample. Saves one tanh per sample on the audio thread; the per-sample numerator is unchanged. Output is bit-for-bit equivalent (same formula, just hoisted). - Add `tremolo_stage_does_not_allocate` to the no_alloc RT-safety suite, matching the convention every other effect stage follows. - Add `set_shape_matches_constructed` guarding the new cache: changing shape via set_parameter must match a stage built with that shape. --- rustortion-core/src/amp/stages/tremolo.rs | 46 ++++++++++++++++++++--- rustortion-core/tests/no_alloc.rs | 7 ++++ 2 files changed, 47 insertions(+), 6 deletions(-) diff --git a/rustortion-core/src/amp/stages/tremolo.rs b/rustortion-core/src/amp/stages/tremolo.rs index 3c67c3d..e7fe043 100644 --- a/rustortion-core/src/amp/stages/tremolo.rs +++ b/rustortion-core/src/amp/stages/tremolo.rs @@ -38,6 +38,11 @@ pub struct TremoloStage { phase: f32, depth_smoothed: f32, depth_coeff: f32, + /// Cached shape-morph coefficients. `drive` and `drive_norm = 1/tanh(drive)` + /// depend only on `shape`, so they're computed in `new()` and on + /// `set_parameter("shape", _)` — never per sample (saves one `tanh()`/sample). + drive: f32, + drive_norm: f32, } impl TremoloStage { @@ -46,7 +51,7 @@ impl TremoloStage { let depth = depth.clamp(0.0, 1.0); let shape = shape.clamp(0.0, 1.0); - Self { + let mut stage = Self { rate_hz, depth, shape, @@ -54,7 +59,19 @@ impl TremoloStage { phase: 0.0, depth_smoothed: depth, depth_coeff: calculate_coefficient(DEPTH_SMOOTH_MS, sample_rate), - } + drive: 0.0, + drive_norm: 1.0, + }; + stage.update_shape_coeffs(); + stage + } + + /// Recompute the cached shape-morph coefficients. `drive` maps `shape` onto + /// the `tanh` waveshaper's slope; `drive_norm = 1 / tanh(drive)` renormalises + /// the shaped output back to ±1. Called only when `shape` changes. + fn update_shape_coeffs(&mut self) { + self.drive = (MAX_DRIVE - MIN_DRIVE).mul_add(self.shape, MIN_DRIVE); + self.drive_norm = 1.0 / self.drive.tanh(); } /// Current LFO gain in `[1 - depth, 1]`, advancing the phase by one sample. @@ -66,10 +83,10 @@ impl TremoloStage { let raw = (TAU * self.phase).sin(); - // Morph sine -> square. At `MIN_DRIVE` the ratio reproduces `raw`; - // at `MAX_DRIVE` it clamps to ~±1 everywhere but the zero crossings. - let drive = (MAX_DRIVE - MIN_DRIVE).mul_add(self.shape, MIN_DRIVE); - let sharp = (raw * drive).tanh() / drive.tanh(); + // Morph sine -> square via the cached coefficients (see + // `update_shape_coeffs`). At `MIN_DRIVE` this reproduces `raw`; at + // `MAX_DRIVE` it clamps to ~±1 everywhere but the zero crossings. + let sharp = (raw * self.drive).tanh() * self.drive_norm; // Map [-1, 1] -> [0, 1], then to a gain in [1 - depth, 1]: // gain = 1 - depth * (1 - m) = depth * (m - 1) + 1 @@ -112,6 +129,7 @@ impl Stage for TremoloStage { "shape" => { if (0.0..=1.0).contains(&value) { self.shape = value; + self.update_shape_coeffs(); Ok(()) } else { Err("Shape must be between 0.0 and 1.0") @@ -221,6 +239,22 @@ mod tests { } } + #[test] + fn set_shape_matches_constructed() { + // Changing shape via set_parameter must refresh the cached drive/norm, + // so output matches a stage built with that shape from the start. + let mut a = TremoloStage::new(7.0, 0.8, 0.0, SAMPLE_RATE); + a.set_parameter("shape", 1.0).unwrap(); + let mut b = TremoloStage::new(7.0, 0.8, 1.0, SAMPLE_RATE); + for i in 0..2000 { + let (oa, ob) = (a.process(1.0), b.process(1.0)); + assert!( + (oa - ob).abs() < TOL, + "stale shape cache at {i}: {oa} vs {ob}" + ); + } + } + #[test] fn parameter_validation() { let mut trem = TremoloStage::new(5.0, 0.5, 0.0, SAMPLE_RATE); diff --git a/rustortion-core/tests/no_alloc.rs b/rustortion-core/tests/no_alloc.rs index 873e823..3031e86 100644 --- a/rustortion-core/tests/no_alloc.rs +++ b/rustortion-core/tests/no_alloc.rs @@ -28,6 +28,7 @@ use rustortion_core::amp::stages::poweramp::{PowerAmpStage, PowerAmpType}; use rustortion_core::amp::stages::preamp::PreampStage; use rustortion_core::amp::stages::reverb::ReverbStage; use rustortion_core::amp::stages::tonestack::{ToneStackModel, ToneStackStage}; +use rustortion_core::amp::stages::tremolo::TremoloStage; use rustortion_core::audio::engine::{Engine, EngineHandle, PreparedIr}; use rustortion_core::audio::peak_meter::PeakMeter; use rustortion_core::audio::rt_drop::{RtDropHandle, RtDropReceiver}; @@ -326,6 +327,12 @@ mod stages { // Covers: EqStage 16-band cascaded biquads. run_with_stage(Box::new(EqStage::new([0.0; NUM_BANDS], SAMPLE_RATE_F32))); } + + #[test] + fn tremolo_stage_does_not_allocate() { + // Covers: TremoloStage sine LFO + tanh shape morph + depth smoothing. + run_with_stage(Box::new(TremoloStage::new(5.0, 0.7, 0.5, SAMPLE_RATE_F32))); + } } // ---------------------------------------------------------------------------