Skip to main content

beamer_core/
parameter_range.rs

1//! Range mapping for parameter normalization.
2//!
3//! This module provides traits and implementations for mapping between
4//! plain parameter values (in natural units like Hz, dB, ms) and normalized
5//! values (0.0 to 1.0) used for host communication.
6//!
7//! # Available Mappers
8//!
9//! - [`LinearMapper`] - Simple linear interpolation (most parameters)
10//! - [`LogMapper`] - Logarithmic mapping for positive ranges (Hz)
11//! - [`PowerMapper`] - Power curve for non-linear UI feel (dB thresholds)
12//! - [`LogOffsetMapper`] - Logarithmic mapping for ranges including negatives
13//!
14//! # Example
15//!
16//! ```ignore
17//! use beamer_core::parameter_range::{RangeMapper, LinearMapper, LogMapper, PowerMapper};
18//!
19//! // Linear mapping for most parameters
20//! let linear = LinearMapper::new(0.0..=100.0);
21//! assert_eq!(linear.normalize(50.0), 0.5);
22//! assert_eq!(linear.denormalize(0.5), 50.0);
23//!
24//! // Logarithmic mapping for frequency parameters (positive ranges only)
25//! let log = LogMapper::new(20.0..=20000.0);
26//! // 632 Hz is roughly the geometric mean of 20 and 20000
27//! assert!((log.denormalize(0.5) - 632.0).abs() < 1.0);
28//!
29//! // Power curve for threshold parameters (more resolution at max)
30//! let power = PowerMapper::new(-60.0..=0.0, 2.0);
31//! // With exponent 2.0, slider midpoint is closer to 0 dB than -30 dB
32//! ```
33
34use std::ops::RangeInclusive;
35
36/// Trait for mapping between plain values and normalized values.
37///
38/// Implementations must be thread-safe (`Send + Sync`) for use in
39/// audio plugin parameters.
40pub trait RangeMapper: Send + Sync {
41    /// Convert a plain value to normalized (0.0-1.0).
42    ///
43    /// Values outside the range are clamped.
44    fn normalize(&self, plain: f64) -> f64;
45
46    /// Convert a normalized value (0.0-1.0) to plain.
47    ///
48    /// Values outside 0.0-1.0 are clamped.
49    fn denormalize(&self, normalized: f64) -> f64;
50
51    /// Get the plain value range as (min, max).
52    fn range(&self) -> (f64, f64);
53
54    /// Get the default normalized value for a given plain default.
55    fn default_normalized(&self, plain_default: f64) -> f64 {
56        self.normalize(plain_default)
57    }
58}
59
60/// Linear range mapping.
61///
62/// Maps values linearly between the range endpoints.
63/// Suitable for most parameters where perceptual response is linear.
64///
65/// # Example
66///
67/// ```ignore
68/// let mapper = LinearMapper::new(-60.0..=12.0);
69/// assert_eq!(mapper.denormalize(0.0), -60.0);
70/// assert_eq!(mapper.denormalize(1.0), 12.0);
71/// ```
72#[derive(Debug, Clone)]
73pub struct LinearMapper {
74    min: f64,
75    max: f64,
76}
77
78impl LinearMapper {
79    /// Create a new linear mapper with the given range.
80    pub fn new(range: RangeInclusive<f64>) -> Self {
81        Self {
82            min: *range.start(),
83            max: *range.end(),
84        }
85    }
86}
87
88impl RangeMapper for LinearMapper {
89    fn normalize(&self, plain: f64) -> f64 {
90        if (self.max - self.min).abs() < f64::EPSILON {
91            return 0.5;
92        }
93        ((plain - self.min) / (self.max - self.min)).clamp(0.0, 1.0)
94    }
95
96    fn denormalize(&self, normalized: f64) -> f64 {
97        let normalized = normalized.clamp(0.0, 1.0);
98        self.min + normalized * (self.max - self.min)
99    }
100
101    fn range(&self) -> (f64, f64) {
102        (self.min, self.max)
103    }
104}
105
106/// Logarithmic range mapping.
107///
108/// Maps values logarithmically between the range endpoints.
109/// Suitable for frequency parameters where perceptual response is logarithmic.
110///
111/// # Panics
112///
113/// Panics if the range contains zero or negative values, as logarithm
114/// is undefined for non-positive numbers.
115///
116/// # Example
117///
118/// ```ignore
119/// let mapper = LogMapper::new(20.0..=20000.0);
120/// // Geometric mean is at normalized 0.5
121/// let mid = mapper.denormalize(0.5);
122/// assert!((mid - 632.45).abs() < 1.0); // sqrt(20 * 20000)
123/// ```
124#[derive(Debug, Clone)]
125pub struct LogMapper {
126    min: f64,
127    max: f64,
128    min_log: f64,
129    max_log: f64,
130}
131
132impl LogMapper {
133    /// Create a new logarithmic mapper with the given range.
134    ///
135    /// # Panics
136    ///
137    /// Panics if:
138    /// - The range start is not positive (log requires positive values)
139    /// - The range end is not greater than the range start
140    pub fn new(range: RangeInclusive<f64>) -> Self {
141        let min = *range.start();
142        let max = *range.end();
143        assert!(
144            min > 0.0,
145            "LogMapper requires positive range start, got min={}",
146            min
147        );
148        assert!(
149            max > min,
150            "LogMapper requires max > min, got min={}, max={}",
151            min, max
152        );
153        Self {
154            min,
155            max,
156            min_log: min.ln(),
157            max_log: max.ln(),
158        }
159    }
160}
161
162impl RangeMapper for LogMapper {
163    fn normalize(&self, plain: f64) -> f64 {
164        if (self.max_log - self.min_log).abs() < f64::EPSILON {
165            return 0.5;
166        }
167        let plain = plain.max(self.min); // Clamp to positive
168        let plain_log = plain.ln();
169        ((plain_log - self.min_log) / (self.max_log - self.min_log)).clamp(0.0, 1.0)
170    }
171
172    fn denormalize(&self, normalized: f64) -> f64 {
173        let normalized = normalized.clamp(0.0, 1.0);
174        (self.min_log + normalized * (self.max_log - self.min_log)).exp()
175    }
176
177    fn range(&self) -> (f64, f64) {
178        (self.min, self.max)
179    }
180}
181
182/// Power curve range mapping.
183///
184/// Provides non-linear mapping using a power curve to give more resolution
185/// at one end of the range. Works with any range (positive, negative, or mixed).
186///
187/// Common use cases:
188/// - Threshold parameters where more precision is needed near 0 dB
189/// - Any parameter where UI feel should be skewed toward one end
190///
191/// # Behavior
192///
193/// - `exponent > 1.0`: More resolution at the maximum (e.g., near 0 dB for threshold)
194/// - `exponent < 1.0`: More resolution at the minimum
195/// - `exponent = 1.0`: Linear (same as LinearMapper)
196///
197/// # Panics
198///
199/// Panics if the exponent is not positive, or if the range end is not
200/// greater than the range start.
201///
202/// # Example
203///
204/// ```ignore
205/// // More resolution near 0 dB (max), less at -60 dB (min)
206/// let mapper = PowerMapper::new(-60.0..=0.0, 2.0);
207///
208/// // With exponent 2.0, normalized 0.5 maps to -15 dB (not -30 dB)
209/// let mid = mapper.denormalize(0.5);
210/// assert!((mid - -15.0).abs() < 0.1);
211/// ```
212#[derive(Debug, Clone)]
213pub struct PowerMapper {
214    min: f64,
215    max: f64,
216    inv_exponent: f64,
217}
218
219impl PowerMapper {
220    /// Create a new power curve mapper.
221    ///
222    /// # Arguments
223    ///
224    /// * `range` - Value range (can be negative, positive, or mixed)
225    /// * `exponent` - Power curve exponent (typical: 2.0-3.0)
226    ///   - `exponent > 1.0`: More resolution at max
227    ///   - `exponent < 1.0`: More resolution at min
228    ///   - `exponent = 1.0`: Linear (same as LinearMapper)
229    ///
230    /// # Panics
231    ///
232    /// Panics if:
233    /// - `exponent <= 0.0` (must be positive)
234    /// - `max <= min` (invalid range)
235    pub fn new(range: RangeInclusive<f64>, exponent: f64) -> Self {
236        let min = *range.start();
237        let max = *range.end();
238
239        assert!(
240            max > min,
241            "PowerMapper requires max > min, got min={}, max={}",
242            min, max
243        );
244        assert!(
245            exponent > 0.0,
246            "PowerMapper requires positive exponent, got {}",
247            exponent
248        );
249
250        Self {
251            min,
252            max,
253            inv_exponent: 1.0 / exponent,
254        }
255    }
256}
257
258impl RangeMapper for PowerMapper {
259    fn normalize(&self, plain: f64) -> f64 {
260        if (self.max - self.min).abs() < f64::EPSILON {
261            return 0.5;
262        }
263
264        // Linear normalize first
265        let linear = ((plain - self.min) / (self.max - self.min)).clamp(0.0, 1.0);
266
267        // Apply power curve (square for exponent=2.0)
268        // This compresses the linear range so more slider travel is near max
269        linear.powf(1.0 / self.inv_exponent)
270    }
271
272    fn denormalize(&self, normalized: f64) -> f64 {
273        let normalized = normalized.clamp(0.0, 1.0);
274
275        // Apply inverse power curve (square root for exponent=2.0)
276        // This expands the normalized range back to linear
277        let linear = normalized.powf(self.inv_exponent);
278
279        // Linear denormalize
280        self.min + linear * (self.max - self.min)
281    }
282
283    fn range(&self) -> (f64, f64) {
284        (self.min, self.max)
285    }
286}
287
288/// Logarithmic range mapping with offset for negative ranges.
289///
290/// Provides logarithmic mapping for ranges that include zero or negative values
291/// by offsetting the range to positive values, applying log mapping, then
292/// offsetting back.
293///
294/// Use this when you need true logarithmic behavior (geometric mean at midpoint)
295/// for ranges that can't use [`LogMapper`].
296///
297/// # Panics
298///
299/// Panics if the range end is not greater than the range start.
300///
301/// # Example
302///
303/// ```ignore
304/// // Logarithmic feel for threshold parameter
305/// let mapper = LogOffsetMapper::new(-60.0..=0.0);
306///
307/// // The geometric mean (in offset space) is at normalized 0.5
308/// let mid = mapper.denormalize(0.5);
309/// // mid ≈ -53 dB (closer to min due to log curve)
310/// ```
311#[derive(Debug, Clone)]
312pub struct LogOffsetMapper {
313    min: f64,
314    max: f64,
315    offset: f64,
316    min_log: f64,
317    max_log: f64,
318}
319
320impl LogOffsetMapper {
321    /// Create a new logarithmic offset mapper.
322    ///
323    /// The offset is automatically calculated to ensure all values
324    /// are positive: `offset = abs(min) + 1.0`.
325    ///
326    /// # Panics
327    ///
328    /// Panics if `max <= min`.
329    pub fn new(range: RangeInclusive<f64>) -> Self {
330        let min = *range.start();
331        let max = *range.end();
332
333        assert!(
334            max > min,
335            "LogOffsetMapper requires max > min, got min={}, max={}",
336            min, max
337        );
338
339        // Calculate offset to make all values positive
340        // Add 1.0 to avoid ln(0)
341        let offset = if min < 0.0 { min.abs() + 1.0 } else { 1.0 };
342
343        let min_offset = min + offset;
344        let max_offset = max + offset;
345
346        Self {
347            min,
348            max,
349            offset,
350            min_log: min_offset.ln(),
351            max_log: max_offset.ln(),
352        }
353    }
354}
355
356impl RangeMapper for LogOffsetMapper {
357    fn normalize(&self, plain: f64) -> f64 {
358        if (self.max_log - self.min_log).abs() < f64::EPSILON {
359            return 0.5;
360        }
361
362        // Offset to positive, clamp to valid range
363        let plain_offset = (plain + self.offset).max(self.min + self.offset);
364        let plain_log = plain_offset.ln();
365
366        ((plain_log - self.min_log) / (self.max_log - self.min_log)).clamp(0.0, 1.0)
367    }
368
369    fn denormalize(&self, normalized: f64) -> f64 {
370        let normalized = normalized.clamp(0.0, 1.0);
371
372        // Compute in offset (positive) space
373        let plain_offset = (self.min_log + normalized * (self.max_log - self.min_log)).exp();
374
375        // Remove offset to get original range
376        plain_offset - self.offset
377    }
378
379    fn range(&self) -> (f64, f64) {
380        (self.min, self.max)
381    }
382}