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