Skip to main content

truce_params/
range.rs

1/// Defines how a parameter maps between plain and normalized values.
2///
3/// `Copy` because every variant is POD (scalars, or a `&'static` for
4/// [`Self::Reversed`]). Lets format wrappers pass `info.range` by value
5/// without `clone()` noise.
6#[derive(Clone, Copy, Debug)]
7pub enum ParamRange {
8    Linear {
9        min: f64,
10        max: f64,
11    },
12    Logarithmic {
13        min: f64,
14        max: f64,
15    },
16    /// Power-law taper over `[min, max]`: `normalize(plain) = t^factor`
17    /// where `t` is the linear proportion. `factor < 1.0` gives the low
18    /// end of the range more of the knob (a log-like taper); `factor >
19    /// 1.0` gives the high end more; `factor == 1.0` is `Linear`.
20    Skewed {
21        min: f64,
22        max: f64,
23        factor: f64,
24    },
25    /// Skew anchored at `center`, which sits at the knob's midpoint with
26    /// each half a mirror of the other. The idiomatic shape for
27    /// center-detented knobs - pan (`center = 0`) and EQ gain
28    /// (`center = 0` dB) - where the two directions should feel
29    /// symmetric regardless of where `center` falls in `[min, max]`.
30    SymmetricalSkewed {
31        min: f64,
32        max: f64,
33        factor: f64,
34        center: f64,
35    },
36    Discrete {
37        min: i64,
38        max: i64,
39    },
40    Enum {
41        count: usize,
42    },
43    /// Wraps another range with its normalized axis flipped: the inner
44    /// range's plain `max` sits at the bottom of the knob and `min` at
45    /// the top. Plain bounds and step count are the inner range's.
46    Reversed(&'static ParamRange),
47}
48
49impl ParamRange {
50    /// Map a plain value to 0.0–1.0.
51    ///
52    /// Degenerate bounds - `min == max` for `Linear` / `Discrete`,
53    /// non-positive or empty for `Logarithmic`, `count <= 1` for
54    /// `Enum` - collapse to `0.0`. Combined with [`Self::denormalize`]
55    /// returning `min` on the same inputs, the pair is round-trip
56    /// stable: the result always converges to the bottom of the
57    /// (degenerate) range rather than producing NaN or wrapping into
58    /// nonsense.
59    // `min == max` detects mathematically zero-width ranges; an epsilon
60    // would mis-route a user-defined `Linear { 1.0, 1.0 + EPSILON }`.
61    // `i64 → f64` casts on `Discrete` bounds are lossless in practice
62    // (no sane param has > 2^52 steps).
63    #[allow(clippy::float_cmp, clippy::cast_precision_loss)]
64    #[must_use]
65    pub fn normalize(&self, plain: f64) -> f64 {
66        // A NaN plain value (corrupt state, a buggy host) collapses to the
67        // low end rather than propagating NaN through the arithmetic -
68        // mirrors the degenerate-bounds convention below.
69        if plain.is_nan() {
70            return 0.0;
71        }
72        match self {
73            Self::Linear { min, max } => {
74                if max == min {
75                    return 0.0;
76                }
77                ((plain - min) / (max - min)).clamp(0.0, 1.0)
78            }
79            Self::Logarithmic { min, max } => {
80                if *min <= 0.0 || *max <= 0.0 || min == max {
81                    return 0.0;
82                }
83                // `plain.ln()` returns NaN for `plain <= 0`; the
84                // post-clamp leaves the NaN intact and a host that
85                // briefly overshoots automation below `min` ends up
86                // with a NaN normalized value flowing into saved
87                // state and the GUI round-trip.
88                if plain <= *min {
89                    return 0.0;
90                }
91                if plain >= *max {
92                    return 1.0;
93                }
94                let min_log = min.ln();
95                let max_log = max.ln();
96                ((plain.ln() - min_log) / (max_log - min_log)).clamp(0.0, 1.0)
97            }
98            Self::Skewed { min, max, factor } => {
99                if max == min {
100                    return 0.0;
101                }
102                let t = ((plain - min) / (max - min)).clamp(0.0, 1.0);
103                t.powf(*factor)
104            }
105            Self::SymmetricalSkewed {
106                min,
107                max,
108                factor,
109                center,
110            } => {
111                if max == min {
112                    return 0.0;
113                }
114                let unscaled = ((plain - min) / (max - min)).clamp(0.0, 1.0);
115                let center_prop = ((center - min) / (max - min)).clamp(0.0, 1.0);
116                // A center pinned to an edge has no symmetric half to
117                // mirror; fall back to the linear proportion so the pair
118                // stays round-trip stable.
119                if center_prop <= 0.0 || center_prop >= 1.0 {
120                    return unscaled;
121                }
122                if unscaled > center_prop {
123                    let scaled = (unscaled - center_prop) / (1.0 - center_prop);
124                    (scaled.powf(*factor) / 2.0) + 0.5
125                } else {
126                    let scaled = (center_prop - unscaled) / center_prop;
127                    (1.0 - scaled.powf(*factor)) / 2.0
128                }
129            }
130            Self::Reversed(inner) => 1.0 - inner.normalize(plain),
131            Self::Discrete { min, max } => {
132                if max == min {
133                    return 0.0;
134                }
135                ((plain - *min as f64) / (*max as f64 - *min as f64)).clamp(0.0, 1.0)
136            }
137            Self::Enum { count } => {
138                if *count <= 1 {
139                    return 0.0;
140                }
141                (plain / (*count as f64 - 1.0)).clamp(0.0, 1.0)
142            }
143        }
144    }
145
146    /// Map 0.0–1.0 back to a plain value.
147    ///
148    /// Degenerate bounds collapse to `min` (or `0.0` for `Enum` with
149    /// `count <= 1`). See [`Self::normalize`] for the round-trip
150    /// semantics.
151    // `min == max` detects mathematically zero-width ranges; matches
152    // `normalize`'s asymmetric handling so the pair stays stable.
153    // `i64 → f64` and `usize → f64` casts on `Discrete` / `Enum`
154    // bounds are lossless in practice (no sane param has > 2^52 steps).
155    #[allow(clippy::float_cmp, clippy::cast_precision_loss)]
156    #[must_use]
157    pub fn denormalize(&self, normalized: f64) -> f64 {
158        // `f64::clamp` passes NaN through, so a NaN normalized value (a
159        // buggy host - VST3 `setParamNormalized` forwards doubles verbatim,
160        // a corrupt project) would otherwise reach every variant and cast
161        // to garbage: `NaN.round() as i64` -> 0, `as u32` -> first enum
162        // variant, `NaN > 0.5` -> false, silently resetting the param.
163        // Collapse to the low end (`n = 0`), the degenerate-bounds value.
164        let n = if normalized.is_nan() {
165            0.0
166        } else {
167            normalized.clamp(0.0, 1.0)
168        };
169        match self {
170            Self::Linear { min, max } => min + n * (max - min),
171            Self::Logarithmic { min, max } => {
172                // Match `normalize`'s asymmetric handling of bad bounds:
173                // if either end is non-positive or the range is empty,
174                // both directions collapse to `min` (round-trip stable).
175                if *min <= 0.0 || *max <= 0.0 || min == max {
176                    return *min;
177                }
178                let min_log = min.ln();
179                let max_log = max.ln();
180                (min_log + n * (max_log - min_log)).exp()
181            }
182            Self::Skewed { min, max, factor } => {
183                if max == min {
184                    return *min;
185                }
186                min + n.powf(factor.recip()) * (max - min)
187            }
188            Self::SymmetricalSkewed {
189                min,
190                max,
191                factor,
192                center,
193            } => {
194                if max == min {
195                    return *min;
196                }
197                let center_prop = ((center - min) / (max - min)).clamp(0.0, 1.0);
198                if center_prop <= 0.0 || center_prop >= 1.0 {
199                    return min + n * (max - min);
200                }
201                let skewed_prop = if n > 0.5 {
202                    let scaled = (n - 0.5) * 2.0;
203                    (scaled.powf(factor.recip()) * (1.0 - center_prop)) + center_prop
204                } else {
205                    let inverse = (1.0 - n * 2.0).powf(factor.recip());
206                    (1.0 - inverse) * center_prop
207                };
208                min + skewed_prop * (max - min)
209            }
210            Self::Reversed(inner) => inner.denormalize(1.0 - n),
211            Self::Discrete { min, max } => {
212                ((*min as f64) + n * (*max as f64 - *min as f64)).round()
213            }
214            Self::Enum { count } => {
215                if *count <= 1 {
216                    return 0.0;
217                }
218                (n * (*count as f64 - 1.0)).round()
219            }
220        }
221    }
222
223    /// Plain-value minimum.
224    // `i64 → f64` is lossless for the bounds in practice (no sane
225    // param has > 2^52 steps).
226    #[allow(clippy::cast_precision_loss)]
227    #[must_use]
228    pub fn min(&self) -> f64 {
229        match self {
230            Self::Linear { min, .. }
231            | Self::Logarithmic { min, .. }
232            | Self::Skewed { min, .. }
233            | Self::SymmetricalSkewed { min, .. } => *min,
234            Self::Discrete { min, .. } => *min as f64,
235            Self::Enum { .. } => 0.0,
236            Self::Reversed(inner) => inner.min(),
237        }
238    }
239
240    /// Plain-value maximum.
241    // `i64 → f64` and `usize → f64` are lossless for the bounds in
242    // practice.
243    #[allow(clippy::cast_precision_loss)]
244    #[must_use]
245    pub fn max(&self) -> f64 {
246        match self {
247            Self::Linear { max, .. }
248            | Self::Logarithmic { max, .. }
249            | Self::Skewed { max, .. }
250            | Self::SymmetricalSkewed { max, .. } => *max,
251            Self::Discrete { max, .. } => *max as f64,
252            Self::Enum { count } => (*count as f64 - 1.0).max(0.0),
253            Self::Reversed(inner) => inner.max(),
254        }
255    }
256
257    /// Number of discrete steps for a quantized range.
258    ///
259    /// `None` means continuous (Linear / Logarithmic). `Some(n)` means
260    /// the range covers `n + 1` distinct values (a step count of 3 →
261    /// 4 picker positions). Cross-format wrappers that serialize a
262    /// `0 = continuous` sentinel into a C struct should call
263    /// `.map(NonZeroU32::get).unwrap_or(0)` at the FFI boundary.
264    ///
265    /// Discrete / Enum variants with degenerate bounds (`min > max`,
266    /// or `count <= 1`) return `None` - semantically continuous,
267    /// because there's nothing to step through.
268    #[must_use]
269    pub fn step_count(&self) -> Option<std::num::NonZeroU32> {
270        let raw: u32 = match self {
271            Self::Linear { .. }
272            | Self::Logarithmic { .. }
273            | Self::Skewed { .. }
274            | Self::SymmetricalSkewed { .. } => 0,
275            // Reversing doesn't change how many steps the inner range
276            // has, only their order.
277            Self::Reversed(inner) => return inner.step_count(),
278            // `max - min` as `i64` is fine, but `as u32` wraps for
279            // `min > max` or steps > u32::MAX. Saturate instead so a
280            // mis-specified `Discrete` range can't produce a bogus
281            // step count that callers might index with.
282            Self::Discrete { min, max } => {
283                // Result is `min`-clamped to `0..=u32::MAX`.
284                #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
285                let n = (max.saturating_sub(*min)).max(0).min(i64::from(u32::MAX)) as u32;
286                n
287            }
288            // Enum variant counts are well below `u32::MAX` in practice
289            // (typical < 100); the saturating_sub keeps `count = 0` honest.
290            #[allow(clippy::cast_possible_truncation)]
291            Self::Enum { count } => (*count as u32).saturating_sub(1),
292        };
293        std::num::NonZeroU32::new(raw)
294    }
295
296    /// `step_count` widened to `usize` with the continuous case
297    /// flattened to `1`. Convenience for UI code that loops over
298    /// discrete values and falls back to a single step for continuous
299    /// ranges.
300    #[must_use]
301    pub fn step_count_usize(&self) -> usize {
302        self.step_count().map_or(1, |n| n.get() as usize)
303    }
304
305    /// The underlying range with any [`Self::Reversed`] wrapper peeled
306    /// off. Reversing only flips the axis direction; the base range
307    /// decides the parameter's *shape* (a reversed enum is still an enum).
308    /// Match on this when classifying by shape - picking a widget, a taper
309    /// - so a reversed enum / toggle isn't misread as a continuous knob.
310    #[must_use]
311    pub fn base(&self) -> &Self {
312        match self {
313            Self::Reversed(inner) => inner.base(),
314            other => other,
315        }
316    }
317}
318
319#[cfg(test)]
320mod tests {
321    // Round-trip and degenerate-bounds tests assert exact float
322    // results (0.0, midpoints, fixed points) - equality is the
323    // contract being verified. Cast truncations in this module are
324    // bounded by the literal `count: 4` test fixtures.
325    #![allow(
326        clippy::float_cmp,
327        clippy::cast_possible_truncation,
328        clippy::cast_sign_loss,
329        clippy::cast_precision_loss
330    )]
331
332    use super::*;
333
334    #[test]
335    fn linear_round_trip() {
336        let range = ParamRange::Linear {
337            min: -60.0,
338            max: 24.0,
339        };
340        for plain in [-60.0, -30.0, 0.0, 12.0, 24.0] {
341            let norm = range.normalize(plain);
342            let back = range.denormalize(norm);
343            assert!(
344                (back - plain).abs() < 1e-10,
345                "plain={plain}, norm={norm}, back={back}"
346            );
347        }
348    }
349
350    #[test]
351    fn log_round_trip() {
352        let range = ParamRange::Logarithmic {
353            min: 20.0,
354            max: 20000.0,
355        };
356        for plain in [20.0, 100.0, 1000.0, 10000.0, 20000.0] {
357            let norm = range.normalize(plain);
358            let back = range.denormalize(norm);
359            assert!(
360                (back - plain).abs() < 0.01,
361                "plain={plain}, norm={norm}, back={back}"
362            );
363        }
364    }
365
366    #[test]
367    fn enum_round_trip() {
368        let range = ParamRange::Enum { count: 4 };
369        for idx in 0..4 {
370            let norm = range.normalize(idx as f64);
371            let back = range.denormalize(norm);
372            assert_eq!(back as usize, idx);
373        }
374    }
375
376    #[test]
377    fn skewed_round_trip() {
378        let range = ParamRange::Skewed {
379            min: 0.0,
380            max: 100.0,
381            factor: 0.5,
382        };
383        for plain in [0.0, 10.0, 50.0, 90.0, 100.0] {
384            let back = range.denormalize(range.normalize(plain));
385            assert!((back - plain).abs() < 1e-9, "plain={plain}, back={back}");
386        }
387    }
388
389    #[test]
390    fn skewed_factor_one_matches_linear() {
391        let skewed = ParamRange::Skewed {
392            min: -60.0,
393            max: 24.0,
394            factor: 1.0,
395        };
396        let linear = ParamRange::Linear {
397            min: -60.0,
398            max: 24.0,
399        };
400        for plain in [-60.0, -30.0, 0.0, 12.0, 24.0] {
401            assert!((skewed.normalize(plain) - linear.normalize(plain)).abs() < 1e-12);
402        }
403    }
404
405    #[test]
406    fn skewed_low_factor_gives_low_end_more_knob() {
407        // `factor < 1.0` puts more of the knob on the low end: the plain
408        // value at the knob midpoint sits below the linear midpoint.
409        let range = ParamRange::Skewed {
410            min: 0.0,
411            max: 100.0,
412            factor: 0.5,
413        };
414        assert!(range.denormalize(0.5) < 50.0);
415    }
416
417    #[test]
418    fn symmetrical_skewed_center_at_half() {
419        // The center always maps to the knob midpoint, wherever it falls
420        // in `[min, max]`.
421        let range = ParamRange::SymmetricalSkewed {
422            min: -24.0,
423            max: 6.0,
424            factor: 0.5,
425            center: 0.0,
426        };
427        assert!((range.normalize(0.0) - 0.5).abs() < 1e-12);
428        assert!((range.denormalize(0.5) - 0.0).abs() < 1e-9);
429    }
430
431    #[test]
432    fn symmetrical_skewed_round_trip() {
433        let range = ParamRange::SymmetricalSkewed {
434            min: -1.0,
435            max: 1.0,
436            factor: 2.0,
437            center: 0.0,
438        };
439        for plain in [-1.0, -0.5, -0.1, 0.0, 0.1, 0.5, 1.0] {
440            let back = range.denormalize(range.normalize(plain));
441            assert!((back - plain).abs() < 1e-9, "plain={plain}, back={back}");
442        }
443    }
444
445    #[test]
446    fn symmetrical_skewed_is_symmetric_about_a_centered_center() {
447        // With `center` at the arithmetic midpoint, equal plain offsets
448        // map to equal knob offsets on either side of 0.5.
449        let range = ParamRange::SymmetricalSkewed {
450            min: -1.0,
451            max: 1.0,
452            factor: 0.6,
453            center: 0.0,
454        };
455        for d in [0.25, 0.5, 0.75] {
456            let above = range.normalize(d) - 0.5;
457            let below = 0.5 - range.normalize(-d);
458            assert!((above - below).abs() < 1e-12, "asymmetric at d={d}");
459        }
460    }
461
462    #[test]
463    fn reversed_flips_the_axis() {
464        static INNER: ParamRange = ParamRange::Linear {
465            min: 0.0,
466            max: 100.0,
467        };
468        let range = ParamRange::Reversed(&INNER);
469        assert!((range.normalize(0.0) - 1.0).abs() < 1e-12, "min -> top");
470        assert!((range.normalize(100.0)).abs() < 1e-12, "max -> bottom");
471        assert!((range.denormalize(0.0) - 100.0).abs() < 1e-9);
472        assert!((range.denormalize(1.0)).abs() < 1e-9);
473        // Plain bounds and step count come from the inner range.
474        assert_eq!(range.min(), 0.0);
475        assert_eq!(range.max(), 100.0);
476        assert!(range.step_count().is_none());
477    }
478
479    #[test]
480    fn base_peels_reversed_so_shape_survives() {
481        static ENUM: ParamRange = ParamRange::Enum { count: 4 };
482        static ONCE: ParamRange = ParamRange::Reversed(&ENUM);
483
484        // A reversed enum is still an enum - `base()` unwraps to it so
485        // widget / taper classification doesn't misread it as continuous.
486        let reversed = ParamRange::Reversed(&ENUM);
487        assert!(matches!(reversed.base(), ParamRange::Enum { count: 4 }));
488
489        // Nested reversing peels all the way down.
490        let twice = ParamRange::Reversed(&ONCE);
491        assert!(matches!(twice.base(), ParamRange::Enum { count: 4 }));
492
493        // A non-reversed range is its own base.
494        let linear = ParamRange::Linear { min: 0.0, max: 1.0 };
495        assert!(matches!(linear.base(), ParamRange::Linear { .. }));
496    }
497
498    #[test]
499    fn reversed_round_trip_over_log() {
500        static INNER: ParamRange = ParamRange::Logarithmic {
501            min: 20.0,
502            max: 20000.0,
503        };
504        let range = ParamRange::Reversed(&INNER);
505        for plain in [20.0, 200.0, 2000.0, 20000.0] {
506            let back = range.denormalize(range.normalize(plain));
507            assert!((back - plain).abs() < 0.01, "plain={plain}, back={back}");
508        }
509    }
510
511    #[test]
512    fn reversed_discrete_keeps_step_count() {
513        static INNER: ParamRange = ParamRange::Discrete { min: 0, max: 3 };
514        let range = ParamRange::Reversed(&INNER);
515        assert_eq!(range.step_count_usize(), 3);
516    }
517
518    /// Degenerate bounds (empty/non-positive/single-step) collapse the
519    /// round trip to a fixed point at `min` rather than producing NaN
520    /// or wrapping. Locks in `normalize → 0.0`, `denormalize(0.0) →
521    /// min`, and `normalize(min) → 0.0` for every range variant so a
522    /// future maintainer simplifying one branch can't accidentally
523    /// reintroduce divergent behavior.
524    #[test]
525    fn degenerate_bounds_round_trip_stable() {
526        let cases = [
527            ParamRange::Linear { min: 5.0, max: 5.0 },
528            ParamRange::Logarithmic {
529                min: 100.0,
530                max: 100.0,
531            },
532            ParamRange::Logarithmic {
533                min: -1.0,
534                max: 10.0,
535            },
536            ParamRange::Logarithmic { min: 1.0, max: 0.0 },
537            ParamRange::Discrete { min: 7, max: 7 },
538            ParamRange::Enum { count: 0 },
539            ParamRange::Enum { count: 1 },
540        ];
541        for range in cases {
542            let bottom = range.min();
543            assert_eq!(range.normalize(bottom), 0.0, "normalize(min) for {range:?}");
544            assert_eq!(
545                range.normalize(42.0),
546                0.0,
547                "normalize(arbitrary) for {range:?}"
548            );
549            assert_eq!(
550                range.denormalize(0.0),
551                bottom,
552                "denormalize(0.0) for {range:?}"
553            );
554            assert_eq!(
555                range.denormalize(0.5),
556                bottom,
557                "denormalize(mid) for {range:?}"
558            );
559            // Double round trip lands at the same fixed point.
560            let once = range.denormalize(range.normalize(42.0));
561            let twice = range.denormalize(range.normalize(once));
562            assert_eq!(once, twice, "round-trip not stable for {range:?}");
563        }
564    }
565
566    /// `normalize` must never return NaN. A host that briefly
567    /// overshoots automation below `min` (or hands us a fresh
568    /// uninitialized -1.0) would feed `(-1.0).ln()` (= NaN) into
569    /// saved state and the editor round-trip without the clamp.
570    #[test]
571    fn logarithmic_normalize_never_nan() {
572        let range = ParamRange::Logarithmic {
573            min: 20.0,
574            max: 20000.0,
575        };
576        for plain in [-1.0, 0.0, 0.5, 19.99, f64::NEG_INFINITY] {
577            let n = range.normalize(plain);
578            assert!(!n.is_nan(), "NaN from normalize({plain})");
579            assert_eq!(n, 0.0, "normalize({plain}) should clamp to 0.0");
580        }
581        for plain in [20000.0, 20001.0, 1e9, f64::INFINITY] {
582            let n = range.normalize(plain);
583            assert!(!n.is_nan(), "NaN from normalize({plain})");
584            assert_eq!(n, 1.0, "normalize({plain}) should clamp to 1.0");
585        }
586    }
587
588    /// A NaN *input* to `normalize` / `denormalize` (a buggy host
589    /// forwarding a corrupt double - VST3 `setParamNormalized(NaN)`) must
590    /// collapse to the range's low end, never propagate NaN into the
591    /// derive commit arms where `NaN.round() as i64` -> 0, `as u32` ->
592    /// first enum variant, `NaN > 0.5` -> false silently reset the param.
593    #[test]
594    fn nan_input_collapses_to_low_end() {
595        let ranges = [
596            ParamRange::Linear {
597                min: -60.0,
598                max: 6.0,
599            },
600            ParamRange::Logarithmic {
601                min: 20.0,
602                max: 20000.0,
603            },
604            ParamRange::Discrete { min: 3, max: 9 },
605            ParamRange::Enum { count: 4 },
606            ParamRange::Skewed {
607                min: 0.0,
608                max: 100.0,
609                factor: 0.5,
610            },
611            ParamRange::Reversed(&ParamRange::Linear {
612                min: 0.0,
613                max: 10.0,
614            }),
615        ];
616        for range in ranges {
617            assert_eq!(
618                range.normalize(f64::NAN),
619                0.0,
620                "normalize(NaN) for {range:?}"
621            );
622            let d = range.denormalize(f64::NAN);
623            assert!(d.is_finite(), "denormalize(NaN) is finite for {range:?}");
624            // Tolerance, not exact: `denormalize(0.0)` on the log path
625            // recomputes `min_log.exp()`, which isn't IEEE-reproducible
626            // (Miri models the non-determinism of transcendental ops), so
627            // two calls can differ in the last bit.
628            let low = range.denormalize(0.0);
629            assert!(
630                (d - low).abs() <= 1e-9 * low.abs().max(1.0),
631                "denormalize(NaN) must equal the low end for {range:?}: {d} vs {low}",
632            );
633        }
634    }
635}