Skip to main content

truce_params/
types.rs

1use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU32, AtomicU64, Ordering};
2
3use crate::info::ParamInfo;
4use crate::sample::Float;
5use crate::smooth::{Smoother, SmoothingStyle};
6
7/// Atomic f64 - wraps `AtomicU64` with f64 load/store.
8pub struct AtomicF64 {
9    bits: AtomicU64,
10}
11
12impl AtomicF64 {
13    pub fn new(value: f64) -> Self {
14        Self {
15            bits: AtomicU64::new(value.to_bits()),
16        }
17    }
18
19    #[inline]
20    pub fn load(&self) -> f64 {
21        f64::from_bits(self.bits.load(Ordering::Relaxed))
22    }
23
24    #[inline]
25    pub fn store(&self, value: f64) {
26        self.bits.store(value.to_bits(), Ordering::Relaxed);
27    }
28}
29
30/// A continuous floating-point parameter.
31pub struct FloatParam {
32    pub info: ParamInfo,
33    value: AtomicF64,
34    pub smoother: Smoother,
35}
36
37impl FloatParam {
38    #[must_use]
39    pub fn new(info: ParamInfo, smoothing: SmoothingStyle) -> Self {
40        let default = info.default_plain;
41        // Surface a mis-ordered or non-finite range (a `Linear { min: 6,
42        // max: -60 }` typo) at construction, where it's obvious, rather than
43        // as a `clamp` panic on the first host automation write. The derive
44        // already rejects `min >= max` at compile time; this covers direct
45        // `FloatParam::new` callers. `set_value` normalizes the bounds so it
46        // never panics even in release, where this assert is compiled out.
47        let (lo, hi) = (info.range.min(), info.range.max());
48        debug_assert!(
49            lo.is_finite() && hi.is_finite() && lo <= hi,
50            "FloatParam range bounds must be finite and ordered (min <= max); \
51             got [{lo}, {hi}] - check the `range = \"...\"` attribute"
52        );
53        let smoother = Smoother::new(smoothing);
54        smoother.snap(default);
55        Self {
56            info,
57            value: AtomicF64::new(default),
58            smoother,
59        }
60    }
61
62    /// Set the plain value (host automation and, crucially, state restore
63    /// from a project file / preset - untrusted input `parse_state` can
64    /// validate the structure of but not the values). Drop non-finite
65    /// writes and clamp to the declared range, so a corrupt or hostile
66    /// value can't latch a NaN into the smoother (which would then emit
67    /// NaN audio forever) or drive out-of-range DSP.
68    #[inline]
69    pub fn set_value(&self, v: f64) {
70        if !v.is_finite() {
71            return;
72        }
73        // Normalize the bounds before clamping: `f64::clamp` panics if
74        // `min > max`, and `range.min()`/`max()` return the stored fields,
75        // so a mis-ordered range would otherwise panic on every write (on
76        // whatever thread the host calls the setter from). `new` debug-
77        // asserts the ordering; this keeps release safe regardless.
78        let (lo, hi) = (self.info.range.min(), self.info.range.max());
79        self.value.store(v.clamp(lo.min(hi), lo.max(hi)));
80    }
81
82    /// Internal: raw target value at `f64` precision (host-side
83    /// surface, before any narrowing for DSP use). Plugin authors
84    /// don't call this directly - they go through the prelude's
85    /// `read` / `value` / `current` instead, which have no
86    /// precision-suffix decisions at the call site.
87    #[doc(hidden)]
88    #[inline]
89    pub fn raw_target(&self) -> f64 {
90        self.value.load()
91    }
92
93    /// Internal: next smoother step at `f32` (the smoother's native
94    /// precision). See [`Self::raw_target`].
95    #[doc(hidden)]
96    #[inline]
97    pub fn raw_smoothed_next(&self) -> f32 {
98        let target = self.value.load();
99        self.smoother.next(target)
100    }
101
102    /// Internal: current smoother value at `f32`. See
103    /// [`Self::raw_target`].
104    #[doc(hidden)]
105    #[inline]
106    pub fn raw_smoothed_current(&self) -> f32 {
107        self.smoother.current()
108    }
109
110    /// Internal: advance the smoother by `out.len()` samples,
111    /// writing each step to `out`. Plugin authors reach this through
112    /// [`FloatParamReadF32::read_into`] /
113    /// [`FloatParamReadF64::read_into`] in the prelude.
114    #[doc(hidden)]
115    #[inline]
116    pub fn raw_smoothed_next_into(&self, out: &mut [f32]) {
117        let target = self.value.load();
118        self.smoother.next_into(target, out);
119    }
120
121    /// Internal: advance the smoother by `n_samples` and return only
122    /// the final value. Plugin authors reach this through
123    /// [`FloatParamReadF32::read_after`] /
124    /// [`FloatParamReadF64::read_after`] in the prelude.
125    #[doc(hidden)]
126    #[inline]
127    pub fn raw_smoothed_next_after(&self, n_samples: usize) -> f32 {
128        let target = self.value.load();
129        self.smoother.next_after(target, n_samples)
130    }
131
132    /// Read the value rounded to the nearest non-negative `usize`.
133    /// Use this for discrete-range params consumed as array indices.
134    /// Negatives, NaN, and infinities saturate at `0` / `usize::MAX`.
135    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
136    #[inline]
137    pub fn value_usize(&self) -> usize {
138        let v = self.value.load().round();
139        if v <= 0.0 { 0 } else { v as usize }
140    }
141
142    /// Read the value rounded to the nearest `i32`. Out-of-range
143    /// values saturate at `i32::MIN` / `i32::MAX`; NaN → 0.
144    #[allow(clippy::cast_possible_truncation)]
145    #[inline]
146    pub fn value_i32(&self) -> i32 {
147        self.value.load().round() as i32
148    }
149
150    /// Read the value rounded to the nearest `u8`. Negatives clamp to
151    /// `0`; values above `255` saturate at `u8::MAX`; NaN → 0.
152    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
153    #[inline]
154    pub fn value_u8(&self) -> u8 {
155        let v = self.value.load().round();
156        if v <= 0.0 {
157            0
158        } else if v >= 255.0 {
159            255
160        } else {
161            v as u8
162        }
163    }
164
165    /// True when the smoother is mid-step toward a new target.
166    /// Inverse of [`Smoother::is_converged`].
167    ///
168    /// Use to branch in `process()` between a constant-gain fast
169    /// path (smoothers at target, gain identical across the whole
170    /// block, one `gain_block` per channel) and the envelope slow
171    /// path (`read_into` + per-sample envelope + `chunks_mut`).
172    /// `SmoothingStyle::None` always reports `false` here, so the
173    /// fast path is unconditional for plugins that disable
174    /// smoothing.
175    ///
176    /// ```ignore
177    /// if !self.params.gain.is_smoothing() && !self.params.pan.is_smoothing() {
178    ///     // fast path: gain is constant for the whole block.
179    /// } else {
180    ///     // slow path: envelope precompute + chunked apply.
181    /// }
182    /// ```
183    #[inline]
184    #[must_use]
185    pub fn is_smoothing(&self) -> bool {
186        !self.smoother.is_converged(self.value.load())
187    }
188
189    /// Parameter ID.
190    pub fn id(&self) -> u32 {
191        self.info.id
192    }
193}
194
195/// Precision-routed read accessors for [`FloatParam`] at `f32`.
196///
197/// The plugin prelude (`truce::prelude` / `truce::prelude32`) imports
198/// this trait via `pub use … as _;`, so plugin code reads:
199///
200/// ```ignore
201/// use truce::prelude::*;
202/// let gain = self.params.gain.read();   // f32 - no annotation needed
203/// ```
204///
205/// The trait's methods shadow nothing - `FloatParam` has no inherent
206/// `read` / `value` / `current`, so name resolution picks the one
207/// (and only one) trait that's in scope. Importing `prelude64`
208/// instead brings [`FloatParamReadF64`] into scope and the same
209/// source resolves to `f64`. Importing **both** preludes is a
210/// compile error (`multiple applicable items in scope`) - which is
211/// the right error for a file that hasn't committed to a precision.
212pub trait FloatParamReadF32 {
213    /// Next smoothed value. Call once per sample in `process()`.
214    #[must_use]
215    fn read(&self) -> f32;
216
217    /// Fill `out` with the next `out.len()` smoothed samples; advance
218    /// the smoother by `out.len()` (not by the slice's capacity).
219    /// One atomic load + one atomic store amortized over the whole
220    /// slice. The right primitive when chunking `process()`'s block
221    /// dynamically:
222    ///
223    /// ```ignore
224    /// let mut delay = [0.0_f32; MAX_BLOCK];
225    /// while offset < total {
226    ///     let n = (total - offset).min(MAX_BLOCK);
227    ///     self.params.delay.read_into(&mut delay[..n]);
228    ///     // ... consume delay[..n] for n samples ...
229    ///     offset += n;
230    /// }
231    /// ```
232    fn read_into(&self, out: &mut [f32]);
233
234    /// Advance the smoother by `n_samples` in one call, returning
235    /// only the final value. Use for **block-rate** DSP - hard
236    /// gates, mode switches, anything that needs one smoothed value
237    /// per audio block. Pass `buffer.num_samples()` to keep the
238    /// smoother's wall-clock convergence time matching the smoother
239    /// declaration (`smooth = "exp(20)"` then actually settles in
240    /// ~20 ms instead of ~20 blocks). One atomic load + one atomic
241    /// store; the per-sample envelope is skipped.
242    #[must_use]
243    fn read_after(&self, n_samples: usize) -> f32;
244
245    /// Current smoothed value without advancing.
246    #[must_use]
247    fn current(&self) -> f32;
248
249    /// Raw target value (post-`set_normalized` / host automation),
250    /// not the smoothed output. Use [`Self::read`] / [`Self::current`]
251    /// in the DSP loop.
252    #[must_use]
253    fn value(&self) -> f32;
254}
255
256/// Precision-routed read accessors for [`FloatParam`] at `f64`. See
257/// [`FloatParamReadF32`] for the contract.
258pub trait FloatParamReadF64 {
259    #[must_use]
260    fn read(&self) -> f64;
261    /// f64 view of [`FloatParamReadF32::read_into`]; one widen per
262    /// slot on top of the same one-atomic-pair fast path.
263    fn read_into(&self, out: &mut [f64]);
264    /// f64 view of [`FloatParamReadF32::read_after`]; one widen
265    /// on top of the same one-atomic-pair fast path.
266    #[must_use]
267    fn read_after(&self, n_samples: usize) -> f64;
268    #[must_use]
269    fn current(&self) -> f64;
270    #[must_use]
271    fn value(&self) -> f64;
272}
273
274impl FloatParamReadF32 for FloatParam {
275    #[inline]
276    fn read(&self) -> f32 {
277        self.raw_smoothed_next()
278    }
279
280    #[inline]
281    fn read_into(&self, out: &mut [f32]) {
282        self.raw_smoothed_next_into(out);
283    }
284
285    #[inline]
286    fn read_after(&self, n_samples: usize) -> f32 {
287        self.raw_smoothed_next_after(n_samples)
288    }
289
290    #[inline]
291    fn current(&self) -> f32 {
292        self.raw_smoothed_current()
293    }
294
295    #[inline]
296    fn value(&self) -> f32 {
297        f32::from_f64(self.raw_target())
298    }
299}
300
301impl FloatParamReadF64 for FloatParam {
302    #[inline]
303    fn read(&self) -> f64 {
304        f64::from(self.raw_smoothed_next())
305    }
306
307    #[inline]
308    fn read_into(&self, out: &mut [f64]) {
309        // Reuse the f32 fill via a transient stack scratch sized to
310        // the largest chunk a plugin typically passes (cap to 1024 -
311        // beyond that the caller almost certainly wants `read` per
312        // sample), widening each slot to f64.
313        const SCRATCH: usize = 1024;
314        let mut scratch = [0.0_f32; SCRATCH];
315        let mut remaining = out;
316        while !remaining.is_empty() {
317            let take = remaining.len().min(SCRATCH);
318            self.raw_smoothed_next_into(&mut scratch[..take]);
319            for (dst, &src) in remaining[..take].iter_mut().zip(&scratch[..take]) {
320                *dst = f64::from(src);
321            }
322            remaining = &mut remaining[take..];
323        }
324    }
325
326    #[inline]
327    fn read_after(&self, n_samples: usize) -> f64 {
328        f64::from(self.raw_smoothed_next_after(n_samples))
329    }
330
331    #[inline]
332    fn current(&self) -> f64 {
333        f64::from(self.raw_smoothed_current())
334    }
335
336    #[inline]
337    fn value(&self) -> f64 {
338        self.raw_target()
339    }
340}
341
342/// A boolean parameter.
343pub struct BoolParam {
344    pub info: ParamInfo,
345    value: AtomicBool,
346}
347
348impl BoolParam {
349    /// # Panics
350    ///
351    /// Panics if `info.default_plain` isn't exactly `0.0` or `1.0`.
352    /// Bool params have no halfway value; the derive emits `0.0` /
353    /// `1.0` only, so this fires only when a user constructs a
354    /// `BoolParam` from hand-rolled `ParamInfo`.
355    #[must_use]
356    pub fn new(info: ParamInfo) -> Self {
357        let default = match info.default_plain {
358            0.0 => false,
359            1.0 => true,
360            other => panic!(
361                "BoolParam '{}' default {} must be exactly 0.0 (false) \
362                 or 1.0 (true) - bool params have no halfway value",
363                info.name, other,
364            ),
365        };
366        Self {
367            info,
368            value: AtomicBool::new(default),
369        }
370    }
371
372    pub fn value(&self) -> bool {
373        self.value.load(Ordering::Relaxed)
374    }
375
376    pub fn set_value(&self, v: bool) {
377        self.value.store(v, Ordering::Relaxed);
378    }
379
380    pub fn id(&self) -> u32 {
381        self.info.id
382    }
383}
384
385/// An integer parameter.
386pub struct IntParam {
387    pub info: ParamInfo,
388    value: AtomicI64,
389}
390
391impl IntParam {
392    /// # Panics
393    ///
394    /// Panics if `info.default_plain` is non-finite or doesn't
395    /// round-trip through `i64`. The cast `f64 as i64` saturates
396    /// silently - `default_plain = -1.0` lands on `-1` (fine), but
397    /// `default_plain = 1e30` saturates to `i64::MAX` and `f64::NAN`
398    /// becomes `0`. The derive populates `default_plain` from
399    /// `#[param(default = ...)]`; a user-supplied float there is a
400    /// programmer error, not a runtime condition we should
401    /// silently absorb.
402    // `truncated as f64 == default` is the integer round-trip
403    // exactness check - epsilon would defeat its purpose. The
404    // `as i64` truncation is the round-trip's whole point.
405    #[allow(
406        clippy::float_cmp,
407        clippy::cast_possible_truncation,
408        clippy::cast_precision_loss
409    )]
410    #[must_use]
411    pub fn new(info: ParamInfo) -> Self {
412        let default = info.default_plain;
413        assert!(
414            default.is_finite(),
415            "IntParam '{}' default {} is not finite",
416            info.name,
417            default,
418        );
419        let truncated = default as i64;
420        assert!(
421            truncated as f64 == default,
422            "IntParam '{}' default {} doesn't round-trip through i64 \
423             - supply an integer-valued default in the derive attribute",
424            info.name,
425            default,
426        );
427        let (lo, hi) = (info.range.min() as i64, info.range.max() as i64);
428        assert!(
429            truncated >= lo && truncated <= hi,
430            "IntParam '{}' default {} is outside range [{}, {}]",
431            info.name,
432            truncated,
433            lo,
434            hi,
435        );
436        Self {
437            info,
438            value: AtomicI64::new(truncated),
439        }
440    }
441
442    pub fn value(&self) -> i64 {
443        self.value.load(Ordering::Relaxed)
444    }
445
446    /// Read the value widened to `f32`. Useful when an int param feeds
447    /// a per-sample DSP loop that runs in `f32`.
448    #[allow(clippy::cast_precision_loss)]
449    #[inline]
450    pub fn value_f32(&self) -> f32 {
451        self.value.load(Ordering::Relaxed) as f32
452    }
453
454    /// Read the value widened to `f64`.
455    #[allow(clippy::cast_precision_loss)]
456    #[inline]
457    pub fn value_f64(&self) -> f64 {
458        self.value.load(Ordering::Relaxed) as f64
459    }
460
461    /// Read the value as a non-negative `usize`. Negatives clamp to 0;
462    /// values above `usize::MAX` saturate.
463    #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
464    #[inline]
465    pub fn value_usize(&self) -> usize {
466        let v = self.value.load(Ordering::Relaxed);
467        if v <= 0 { 0 } else { v as usize }
468    }
469
470    /// Read the value clamped to `i32` range.
471    #[allow(clippy::cast_possible_truncation)]
472    #[inline]
473    pub fn value_i32(&self) -> i32 {
474        self.value
475            .load(Ordering::Relaxed)
476            .clamp(i64::from(i32::MIN), i64::from(i32::MAX)) as i32
477    }
478
479    /// Read the value clamped to `u8` range (`0..=255`).
480    #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
481    #[inline]
482    pub fn value_u8(&self) -> u8 {
483        self.value.load(Ordering::Relaxed).clamp(0, 255) as u8
484    }
485
486    /// Set the value, clamped to the declared range - symmetric with
487    /// `FloatParam::set_value`. A corrupt preset or hostile automation
488    /// value (`i64::MAX` into a `[0, 8]` param) must not reach the plugin,
489    /// where it could index out of range and panic the audio thread. The
490    /// bounds are normalized so a mis-ordered range can't panic `clamp`.
491    #[allow(clippy::cast_possible_truncation)]
492    pub fn set_value(&self, v: i64) {
493        let (lo, hi) = (self.info.range.min() as i64, self.info.range.max() as i64);
494        self.value
495            .store(v.clamp(lo.min(hi), lo.max(hi)), Ordering::Relaxed);
496    }
497
498    pub fn id(&self) -> u32 {
499        self.info.id
500    }
501}
502
503/// Trait for enums used as parameters.
504pub trait ParamEnum: crate::__private::Sealed + Clone + Copy + Send + Sync + 'static {
505    fn from_index(index: usize) -> Self;
506    fn to_index(&self) -> usize;
507    fn name(&self) -> &'static str;
508    fn variant_count() -> usize;
509    fn variant_names() -> &'static [&'static str];
510}
511
512/// An enum parameter.
513pub struct EnumParam<E: ParamEnum> {
514    pub info: ParamInfo,
515    value: AtomicU32,
516    _phantom: std::marker::PhantomData<E>,
517}
518
519impl<E: ParamEnum> EnumParam<E> {
520    /// # Panics
521    ///
522    /// Panics if `info.default_plain` is non-finite, negative, or
523    /// `>= E::variant_count()`. The cast `f64 as u32` saturates
524    /// silently - a user-supplied `#[param(default = -1)]` would
525    /// land on variant 0 without any signal that the default was
526    /// invalid. Validate up front so the bug surfaces at plugin
527    /// construction time.
528    // `f64::from(idx) == default` is the integer round-trip
529    // exactness check - epsilon would defeat its purpose. The
530    // `as u32` truncation is the round-trip's whole point.
531    #[allow(
532        clippy::float_cmp,
533        clippy::cast_possible_truncation,
534        clippy::cast_sign_loss
535    )]
536    #[must_use]
537    pub fn new(info: ParamInfo) -> Self {
538        let default = info.default_plain;
539        let count = E::variant_count();
540        assert!(
541            default.is_finite(),
542            "EnumParam '{}' default {} is not finite",
543            info.name,
544            default,
545        );
546        assert!(
547            default >= 0.0,
548            "EnumParam '{}' default {} is negative; enum variants are \
549             0-indexed",
550            info.name,
551            default,
552        );
553        let idx = default as u32;
554        assert!(
555            f64::from(idx) == default,
556            "EnumParam '{}' default {} is non-integer; supply a 0-indexed \
557             variant index",
558            info.name,
559            default,
560        );
561        assert!(
562            (idx as usize) < count,
563            "EnumParam '{}' default {} is out of range; only {} variant(s) \
564             defined",
565            info.name,
566            idx,
567            count,
568        );
569        Self {
570            info,
571            value: AtomicU32::new(idx),
572            _phantom: std::marker::PhantomData,
573        }
574    }
575
576    pub fn value(&self) -> E {
577        // u32 → usize widens on 64-bit, narrows nowhere we ship to;
578        // the lint trips because `usize` is target-dependent.
579        #[allow(clippy::cast_possible_truncation)]
580        let idx = self.value.load(Ordering::Relaxed) as usize;
581        E::from_index(idx)
582    }
583
584    pub fn set_value(&self, v: E) {
585        // Enum variant indices come from `ParamEnum::to_index`, whose
586        // valid range is `0..variant_count()`; truncation past `u32::MAX`
587        // would mean a > 4-billion-variant enum.
588        #[allow(clippy::cast_possible_truncation)]
589        let idx = v.to_index() as u32;
590        self.value.store(idx, Ordering::Relaxed);
591    }
592
593    pub fn set_index(&self, idx: u32) {
594        // Clamp to the enum's valid range. A preset saved with a wider
595        // enum (a since-shrunk v1) restores an out-of-range index through
596        // here; stored verbatim, `value()` / `from_index` read it as the
597        // first variant while `get_normalized` clamps to the last, so audio
598        // and display disagree. Clamp to the last variant - matching
599        // `ParamRange::Enum::normalize`'s clamp - so they stay consistent.
600        // `variant_count()` is >= 1 for any `ParamEnum`; `saturating_sub`
601        // guards the underflow regardless.
602        #[allow(clippy::cast_possible_truncation)]
603        let max = (E::variant_count() as u32).saturating_sub(1);
604        self.value.store(idx.min(max), Ordering::Relaxed);
605    }
606
607    pub fn index(&self) -> u32 {
608        self.value.load(Ordering::Relaxed)
609    }
610
611    pub fn id(&self) -> u32 {
612        self.info.id
613    }
614
615    /// Format a plain value (index as f64) to the variant name string.
616    ///
617    /// Associated function - the dispatch is purely on `E`, no instance
618    /// state is read. The `#[derive(Params)]` macro calls it as
619    /// `<EnumParam<E>>::format_by_index(value)` so the field type
620    /// supplies `E`.
621    #[must_use]
622    pub fn format_by_index(value: f64) -> String {
623        // `value` is a normalized f64 in `[0, count - 1]`; the round
624        // → usize cast is bounded by the variant count.
625        #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
626        let idx = value.round() as usize;
627        E::from_index(idx).name().to_string()
628    }
629}
630
631// ---------------------------------------------------------------------------
632// MeterSlot
633// ---------------------------------------------------------------------------
634
635/// A meter slot with an auto-assigned ID.
636///
637/// Declare in your params struct with `#[meter]`:
638/// ```ignore
639/// #[derive(Params)]
640/// pub struct MyParams {
641///     #[meter]
642///     pub meter_left: MeterSlot,
643/// }
644/// ```
645///
646/// `id` is `pub` so the `#[derive(Params)]` macro can construct a
647/// `MeterSlot { id: <auto-assigned> }` directly without going through
648/// a `pub fn new(id)` constructor that would let user code mint
649/// arbitrary slots and break the auto-assignment contract.
650pub struct MeterSlot {
651    #[doc(hidden)]
652    pub id: u32,
653}
654
655impl MeterSlot {
656    #[must_use]
657    pub fn id(&self) -> u32 {
658        self.id
659    }
660}
661
662impl From<MeterSlot> for u32 {
663    fn from(m: MeterSlot) -> u32 {
664        m.id
665    }
666}
667
668impl From<&MeterSlot> for u32 {
669    fn from(m: &MeterSlot) -> u32 {
670        m.id
671    }
672}
673
674#[cfg(test)]
675mod tests {
676    use super::*;
677    use crate::info::{ParamFlags, ParamUnit, ParamValueKind};
678    use crate::range::ParamRange;
679
680    fn info(name: &'static str, range: ParamRange, default_plain: f64) -> ParamInfo {
681        ParamInfo {
682            id: 0,
683            name,
684            short_name: name,
685            group: "",
686            range,
687            default_plain,
688            flags: ParamFlags::AUTOMATABLE,
689            unit: ParamUnit::None,
690            kind: ParamValueKind::Float,
691            midi_map: None,
692            midi_channel: None,
693        }
694    }
695
696    #[derive(Clone, Copy)]
697    enum E4 {
698        A,
699        B,
700        C,
701        D,
702    }
703    impl crate::__private::Sealed for E4 {}
704    impl ParamEnum for E4 {
705        fn from_index(i: usize) -> Self {
706            match i {
707                0 => Self::A,
708                1 => Self::B,
709                2 => Self::C,
710                _ => Self::D,
711            }
712        }
713        fn to_index(&self) -> usize {
714            *self as usize
715        }
716        fn name(&self) -> &'static str {
717            match self {
718                Self::A => "A",
719                Self::B => "B",
720                Self::C => "C",
721                Self::D => "D",
722            }
723        }
724        fn variant_count() -> usize {
725            4
726        }
727        fn variant_names() -> &'static [&'static str] {
728            &["A", "B", "C", "D"]
729        }
730    }
731
732    #[test]
733    fn enum_param_accepts_in_range_default() {
734        let p: EnumParam<E4> = EnumParam::new(info("Mode", ParamRange::Enum { count: 4 }, 2.0));
735        assert_eq!(p.index(), 2);
736    }
737
738    #[test]
739    #[should_panic(expected = "negative")]
740    fn enum_param_rejects_negative_default() {
741        let _: EnumParam<E4> = EnumParam::new(info("Mode", ParamRange::Enum { count: 4 }, -1.0));
742    }
743
744    #[test]
745    fn enum_param_set_index_clamps_out_of_range() {
746        // A preset saved with a wider (5-variant) enum restores index 4
747        // into this 4-variant enum. It must clamp to the last variant so
748        // `value()` (audio) and the normalized read (display) agree - not
749        // play the first variant while `normalize` clamps to the last.
750        let p: EnumParam<E4> = EnumParam::new(info("Mode", ParamRange::Enum { count: 4 }, 0.0));
751        p.set_index(4);
752        assert_eq!(p.index(), 3, "out-of-range index clamps to last variant");
753        assert!(matches!(p.value(), E4::D));
754        p.set_index(1000);
755        assert_eq!(p.index(), 3);
756    }
757
758    #[test]
759    #[should_panic(expected = "out of range")]
760    fn enum_param_rejects_overflow_default() {
761        let _: EnumParam<E4> = EnumParam::new(info("Mode", ParamRange::Enum { count: 4 }, 99.0));
762    }
763
764    #[test]
765    #[should_panic(expected = "non-integer")]
766    fn enum_param_rejects_fractional_default() {
767        let _: EnumParam<E4> = EnumParam::new(info("Mode", ParamRange::Enum { count: 4 }, 1.5));
768    }
769
770    #[test]
771    fn int_param_accepts_negative_default() {
772        let p = IntParam::new(info("N", ParamRange::Discrete { min: -10, max: 10 }, -3.0));
773        assert_eq!(p.value(), -3);
774    }
775
776    #[test]
777    #[should_panic(expected = "round-trip")]
778    fn int_param_rejects_fractional_default() {
779        let _ = IntParam::new(info("N", ParamRange::Discrete { min: 0, max: 10 }, 1.5));
780    }
781
782    #[test]
783    #[should_panic(expected = "outside range")]
784    fn int_param_rejects_out_of_range_default() {
785        let _ = IntParam::new(info("N", ParamRange::Discrete { min: 0, max: 5 }, 10.0));
786    }
787
788    #[test]
789    fn int_param_set_value_clamps_to_range() {
790        // A corrupt preset restoring a wild value must not land out of range
791        // (symmetric with FloatParam::set_value); the derive stores
792        // `value.round() as i64`, so i64::MAX is a realistic input.
793        let p = IntParam::new(info("N", ParamRange::Discrete { min: 0, max: 8 }, 0.0));
794        p.set_value(i64::MAX);
795        assert_eq!(p.value(), 8, "clamps above max");
796        p.set_value(-1000);
797        assert_eq!(p.value(), 0, "clamps below min");
798        p.set_value(5);
799        assert_eq!(p.value(), 5, "in-range value stored as-is");
800    }
801
802    fn float(min: f64, max: f64) -> FloatParam {
803        FloatParam::new(
804            info("Gain", ParamRange::Linear { min, max }, 0.0),
805            SmoothingStyle::None,
806        )
807    }
808
809    #[test]
810    #[allow(clippy::float_cmp)] // clamp / dropped-write yields the exact stored value
811    fn float_set_value_drops_non_finite() {
812        let p = float(-60.0, 6.0);
813        p.set_value(-12.0);
814        p.set_value(f64::NAN);
815        assert_eq!(p.raw_target(), -12.0, "NaN write is dropped");
816        p.set_value(f64::INFINITY);
817        assert_eq!(p.raw_target(), -12.0, "infinite write is dropped");
818    }
819
820    #[test]
821    #[allow(clippy::float_cmp)] // clamp yields the exact range bound
822    fn float_set_value_clamps_to_range() {
823        let p = float(-60.0, 6.0);
824        p.set_value(1e308);
825        assert_eq!(p.raw_target(), 6.0, "clamps above max");
826        p.set_value(-1e308);
827        assert_eq!(p.raw_target(), -60.0, "clamps below min");
828    }
829
830    /// A mis-ordered range (`min > max`) is a bug caught at construction in
831    /// debug builds - loud and early, not a `clamp` panic buried in a
832    /// host-automation callback.
833    #[cfg(debug_assertions)]
834    #[test]
835    #[should_panic(expected = "ordered")]
836    fn float_new_debug_asserts_misordered_range() {
837        let _ = FloatParam::new(
838            info(
839                "Bad",
840                ParamRange::Linear {
841                    min: 6.0,
842                    max: -60.0,
843                },
844                0.0,
845            ),
846            SmoothingStyle::None,
847        );
848    }
849
850    /// In release the construction assert is compiled out, so `set_value`
851    /// must still not panic on a mis-ordered range: it normalizes the clamp
852    /// bounds. (`f64::clamp` would panic on `min > max`.)
853    #[cfg(not(debug_assertions))]
854    #[test]
855    fn float_set_value_survives_misordered_range() {
856        let p = FloatParam::new(
857            info(
858                "Bad",
859                ParamRange::Linear {
860                    min: 6.0,
861                    max: -60.0,
862                },
863                0.0,
864            ),
865            SmoothingStyle::None,
866        );
867        p.set_value(1000.0); // must not panic
868        let v = p.raw_target();
869        assert!(
870            (-60.0..=6.0).contains(&v),
871            "clamped to the normalized interval"
872        );
873    }
874}