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