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