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}