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}