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