Skip to content

Commit 71ede3b

Browse files
committed
fix(tremolo): perceptual (geometric) shape curve so the whole knob morphs
The linear drive map (sine→square via tanh) finished ~80% of the morph by shape 0.5 and only crept 80%→90% across the entire upper half, so the top of the Shape knob felt inert. The morph saturates with drive, so equal knob steps need equal *ratios* of drive: sweep drive geometrically from SHAPE_MIN_DRIVE (0.5) to MAX_DRIVE (raised 12→16 for a crisper top square), keeping a true sine at exactly shape 0. Measured squareness across the knob (0..1 in eighths): before: 0 29 59 73 80 84 86 88 90 (step σ 11.1) after: 0 10 21 38 56 72 82 88 92 (step σ 4.7) Adds upper_shape_knob_still_morphs regression test: shape 0.5 vs 1.0 LFO envelopes must differ (MAE > 0.03; was ~0.019 linear, ~0.066 geometric).
1 parent 13a1bd2 commit 71ede3b

1 file changed

Lines changed: 43 additions & 7 deletions

File tree

rustortion-core/src/amp/stages/tremolo.rs

Lines changed: 43 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,21 @@ use crate::amp::stages::common::calculate_coefficient;
88
const MIN_RATE_HZ: f32 = 0.1;
99
const MAX_RATE_HZ: f32 = 20.0;
1010

11-
/// Smallest `tanh` drive, applied at `shape = 0.0`. `tanh` is ~linear near
12-
/// zero, so at this drive `tanh(raw * d) / tanh(d)` collapses back to `raw` —
13-
/// i.e. a faithful sine. Kept above zero to avoid a `0 / 0` at `shape = 0`.
11+
/// Pure-sine floor: `tanh` drive at exactly `shape = 0.0`. `tanh` is ~linear
12+
/// near zero, so `tanh(raw * d) / tanh(d)` collapses back to `raw` — a faithful
13+
/// sine. Kept above zero to avoid a `0 / 0`.
1414
const MIN_DRIVE: f32 = 1e-3;
1515

16-
/// `tanh` drive at `shape = 1.0`. ~12 clamps the sine to within 0.01% of a hard
17-
/// square, giving the "killswitch" chop without the aliasing of a literal sign().
18-
const MAX_DRIVE: f32 = 12.0;
16+
/// Start of the audible morph sweep, for `shape` just above 0. Drive sweeps
17+
/// *geometrically* from here to `MAX_DRIVE`, because the sine→square morph
18+
/// saturates with drive: a linear sweep finishes ~80% of the morph in the
19+
/// bottom third of the knob, leaving the upper half inert. Equal `shape` steps
20+
/// → equal drive *ratios* spreads the morph evenly across the control.
21+
const SHAPE_MIN_DRIVE: f32 = 0.5;
22+
23+
/// `tanh` drive at `shape = 1.0`. ~16 gives a hard, crisp square for the
24+
/// "killswitch" chop without the aliasing of a literal `sign()`.
25+
const MAX_DRIVE: f32 = 16.0;
1926

2027
/// One-pole smoothing time for the depth parameter — fast enough to feel
2128
/// instant, slow enough to suppress zipper noise when the slider is dragged.
@@ -70,7 +77,14 @@ impl TremoloStage {
7077
/// the `tanh` waveshaper's slope; `drive_norm = 1 / tanh(drive)` renormalises
7178
/// the shaped output back to ±1. Called only when `shape` changes.
7279
fn update_shape_coeffs(&mut self) {
73-
self.drive = (MAX_DRIVE - MIN_DRIVE).mul_add(self.shape, MIN_DRIVE);
80+
// Geometric sweep so the morph spreads evenly across the knob (see
81+
// SHAPE_MIN_DRIVE). `shape` exactly 0 stays a true sine; above that,
82+
// drive ramps from SHAPE_MIN_DRIVE up to MAX_DRIVE.
83+
self.drive = if self.shape <= 0.0 {
84+
MIN_DRIVE
85+
} else {
86+
SHAPE_MIN_DRIVE * (MAX_DRIVE / SHAPE_MIN_DRIVE).powf(self.shape)
87+
};
7488
self.drive_norm = 1.0 / self.drive.tanh();
7589
}
7690

@@ -239,6 +253,28 @@ mod tests {
239253
}
240254
}
241255

256+
#[test]
257+
fn upper_shape_knob_still_morphs() {
258+
// Regression guard: the upper half of the Shape knob must keep morphing
259+
// toward square. A linear drive sweep saturated by ~shape 0.5, making
260+
// 0.5→1.0 nearly identical (MAE ~0.019); the geometric sweep keeps them
261+
// distinct (MAE ~0.066). Compare one full LFO cycle at depth 1.
262+
fn cycle_gains(shape: f32) -> Vec<f32> {
263+
let mut s = TremoloStage::new(5.0, 1.0, shape, SAMPLE_RATE);
264+
let n = (SAMPLE_RATE / 5.0) as usize;
265+
(0..n).map(|_| s.process(1.0)).collect()
266+
}
267+
let mid = cycle_gains(0.5);
268+
let top = cycle_gains(1.0);
269+
let mae: f32 = mid
270+
.iter()
271+
.zip(&top)
272+
.map(|(a, b)| (a - b).abs())
273+
.sum::<f32>()
274+
/ mid.len() as f32;
275+
assert!(mae > 0.03, "upper-half Shape morph too flat (mae={mae})");
276+
}
277+
242278
#[test]
243279
fn set_shape_matches_constructed() {
244280
// Changing shape via set_parameter must refresh the cached drive/norm,

0 commit comments

Comments
 (0)