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