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}