Skip to main content

aura_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    /// Base value (host automation / UI / state). Not including mod.
34    value: AtomicF64,
35    /// Mono modulation offset from the host (CLAP `PARAM_MOD`). DSP
36    /// smoothers track [`Self::effective_target`]; host `get` stays on base.
37    mod_amount: AtomicF64,
38    pub smoother: Smoother,
39}
40
41impl FloatParam {
42    #[must_use]
43    pub fn new(info: ParamInfo, smoothing: SmoothingStyle) -> Self {
44        let default = info.default_plain;
45        // Surface a mis-ordered or non-finite range (a `Linear { min: 6,
46        // max: -60 }` typo) at construction, where it's obvious, rather than
47        // as a `clamp` panic on the first host automation write. The derive
48        // already rejects `min >= max` at compile time; this covers direct
49        // `FloatParam::new` callers. `set_value` normalizes the bounds so it
50        // never panics even in release, where this assert is compiled out.
51        let (lo, hi) = (info.range.min(), info.range.max());
52        debug_assert!(
53            lo.is_finite() && hi.is_finite() && lo <= hi,
54            "FloatParam range bounds must be finite and ordered (min <= max); \
55             got [{lo}, {hi}] - check the `range = \"...\"` attribute"
56        );
57        // Contain the default as `set_value` contains writes. A NaN or
58        // out-of-range default (a hand-rolled `FloatParam::new` caller -
59        // the derive rejects both at compile time) would otherwise ship
60        // DSP at a value the host can't display and mutate it on the first
61        // save/restore round-trip. debug_assert catches it in dev; release
62        // clamps for containment.
63        debug_assert!(
64            default.is_finite() && default >= lo.min(hi) && default <= lo.max(hi),
65            "FloatParam default {default} is outside range [{lo}, {hi}] or non-finite"
66        );
67        let default = if default.is_finite() {
68            default.clamp(lo.min(hi), lo.max(hi))
69        } else {
70            lo.min(hi)
71        };
72        let smoother = Smoother::new(smoothing);
73        smoother.snap(default);
74        Self {
75            info,
76            value: AtomicF64::new(default),
77            mod_amount: AtomicF64::new(0.0),
78            smoother,
79        }
80    }
81
82    /// Set the plain **base** value (host automation and state restore).
83    /// Does not clear modulation — mod is independent.
84    ///
85    /// Drop non-finite writes and clamp to the declared range, so a
86    /// corrupt or hostile value can't latch a NaN into the smoother.
87    #[inline]
88    pub fn set_value(&self, v: f64) {
89        if !v.is_finite() {
90            return;
91        }
92        // Normalize the bounds before clamping: `f64::clamp` panics if
93        // `min > max`, and `range.min()`/`max()` return the stored fields,
94        // so a mis-ordered range would otherwise panic on every write (on
95        // whatever thread the host calls the setter from). `new` debug-
96        // asserts the ordering; this keeps release safe regardless.
97        let (lo, hi) = (self.info.range.min(), self.info.range.max());
98        self.value.store(v.clamp(lo.min(hi), lo.max(hi)));
99    }
100
101    /// Set mono modulation offset (CLAP `PARAM_MOD` amount). Non-finite → 0.
102    #[inline]
103    pub fn set_mod_amount(&self, amount: f64) {
104        let a = if amount.is_finite() { amount } else { 0.0 };
105        self.mod_amount.store(a);
106    }
107
108    /// Current mono modulation offset.
109    #[inline]
110    #[must_use]
111    pub fn mod_amount(&self) -> f64 {
112        self.mod_amount.load()
113    }
114
115    /// DSP target: `clamp(base + mod)` into the param range.
116    #[inline]
117    #[must_use]
118    pub fn effective_target(&self) -> f64 {
119        let (a, b) = (self.info.range.min(), self.info.range.max());
120        let (lo, hi) = (a.min(b), a.max(b));
121        (self.value.load() + self.mod_amount.load()).clamp(lo, hi)
122    }
123
124    /// Internal: raw **base** target at `f64` (host UI / state). DSP
125    /// paths use [`Self::effective_target`] via the smoother helpers.
126    #[doc(hidden)]
127    #[inline]
128    pub fn raw_target(&self) -> f64 {
129        self.value.load()
130    }
131
132    /// Internal: next smoother step at `f32` toward effective target.
133    #[doc(hidden)]
134    #[inline]
135    pub fn raw_smoothed_next(&self) -> f32 {
136        self.smoother.next(self.effective_target())
137    }
138
139    /// Internal: current smoother value at `f32`.
140    #[doc(hidden)]
141    #[inline]
142    pub fn raw_smoothed_current(&self) -> f32 {
143        self.smoother.current()
144    }
145
146    /// Internal: advance the smoother by `out.len()` samples.
147    #[doc(hidden)]
148    #[inline]
149    pub fn raw_smoothed_next_into(&self, out: &mut [f32]) {
150        self.smoother.next_into(self.effective_target(), out);
151    }
152
153    /// Internal: advance the smoother by `n_samples` and return final value.
154    #[doc(hidden)]
155    #[inline]
156    pub fn raw_smoothed_next_after(&self, n_samples: usize) -> f32 {
157        self.smoother.next_after(self.effective_target(), n_samples)
158    }
159
160    /// Read the value rounded to the nearest non-negative `usize`.
161    /// Use this for discrete-range params consumed as array indices.
162    /// Negatives, NaN, and infinities saturate at `0` / `usize::MAX`.
163    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
164    #[inline]
165    pub fn value_usize(&self) -> usize {
166        let v = self.value.load().round();
167        if v <= 0.0 { 0 } else { v as usize }
168    }
169
170    /// Read the value rounded to the nearest `i32`. Out-of-range
171    /// values saturate at `i32::MIN` / `i32::MAX`; NaN → 0.
172    #[allow(clippy::cast_possible_truncation)]
173    #[inline]
174    pub fn value_i32(&self) -> i32 {
175        self.value.load().round() as i32
176    }
177
178    /// Read the value rounded to the nearest `u8`. Negatives clamp to
179    /// `0`; values above `255` saturate at `u8::MAX`; NaN → 0.
180    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
181    #[inline]
182    pub fn value_u8(&self) -> u8 {
183        let v = self.value.load().round();
184        if v <= 0.0 {
185            0
186        } else if v >= 255.0 {
187            255
188        } else {
189            v as u8
190        }
191    }
192
193    /// True when the smoother is mid-step toward a new target.
194    /// Inverse of [`Smoother::is_converged`].
195    ///
196    /// Use to branch in `process()` between a constant-gain fast
197    /// path (smoothers at target, gain identical across the whole
198    /// block, one `gain_block` per channel) and the envelope slow
199    /// path (`read_into` + per-sample envelope + `chunks_mut`).
200    /// `SmoothingStyle::None` always reports `false` here, so the
201    /// fast path is unconditional for plugins that disable
202    /// smoothing.
203    ///
204    /// ```ignore
205    /// if !self.params.gain.is_smoothing() && !self.params.pan.is_smoothing() {
206    ///     // fast path: gain is constant for the whole block.
207    /// } else {
208    ///     // slow path: envelope precompute + chunked apply.
209    /// }
210    /// ```
211    #[inline]
212    #[must_use]
213    pub fn is_smoothing(&self) -> bool {
214        !self.smoother.is_converged(self.effective_target())
215    }
216
217    /// Parameter ID.
218    pub fn id(&self) -> u32 {
219        self.info.id
220    }
221}
222
223/// Precision-routed read accessors for [`FloatParam`] at `f32`.
224///
225/// The plugin prelude (`aura::prelude` / `aura::prelude32`) imports
226/// this trait via `pub use … as _;`, so plugin code reads:
227///
228/// ```ignore
229/// use aura::prelude::*;
230/// let gain = self.params.gain.read();   // f32 - no annotation needed
231/// ```
232///
233/// The trait's methods shadow nothing - `FloatParam` has no inherent
234/// `read` / `value` / `current`, so name resolution picks the one
235/// (and only one) trait that's in scope. Importing `prelude64`
236/// instead brings [`FloatParamReadF64`] into scope and the same
237/// source resolves to `f64`. Importing **both** preludes is a
238/// compile error (`multiple applicable items in scope`) - which is
239/// the right error for a file that hasn't committed to a precision.
240pub trait FloatParamReadF32 {
241    /// Next smoothed value. Call once per sample in `process()`.
242    #[must_use]
243    fn read(&self) -> f32;
244
245    /// Fill `out` with the next `out.len()` smoothed samples; advance
246    /// the smoother by `out.len()` (not by the slice's capacity).
247    /// One atomic load + one atomic store amortized over the whole
248    /// slice. The right primitive when chunking `process()`'s block
249    /// dynamically:
250    ///
251    /// ```ignore
252    /// let mut delay = [0.0_f32; MAX_BLOCK];
253    /// while offset < total {
254    ///     let n = (total - offset).min(MAX_BLOCK);
255    ///     self.params.delay.read_into(&mut delay[..n]);
256    ///     // ... consume delay[..n] for n samples ...
257    ///     offset += n;
258    /// }
259    /// ```
260    fn read_into(&self, out: &mut [f32]);
261
262    /// Advance the smoother by `n_samples` in one call, returning
263    /// only the final value. Use for **block-rate** DSP - hard
264    /// gates, mode switches, anything that needs one smoothed value
265    /// per audio block. Pass `buffer.num_samples()` to keep the
266    /// smoother's wall-clock convergence time matching the smoother
267    /// declaration (`smooth = "exp(20)"` then actually settles in
268    /// ~20 ms instead of ~20 blocks). One atomic load + one atomic
269    /// store; the per-sample envelope is skipped.
270    #[must_use]
271    fn read_after(&self, n_samples: usize) -> f32;
272
273    /// Current smoothed value without advancing.
274    #[must_use]
275    fn current(&self) -> f32;
276
277    /// Raw target value (post-`set_normalized` / host automation),
278    /// not the smoothed output. Use [`Self::read`] / [`Self::current`]
279    /// in the DSP loop.
280    #[must_use]
281    fn value(&self) -> f32;
282}
283
284/// Precision-routed read accessors for [`FloatParam`] at `f64`. See
285/// [`FloatParamReadF32`] for the contract.
286pub trait FloatParamReadF64 {
287    #[must_use]
288    fn read(&self) -> f64;
289    /// f64 view of [`FloatParamReadF32::read_into`]; one widen per
290    /// slot on top of the same one-atomic-pair fast path.
291    fn read_into(&self, out: &mut [f64]);
292    /// f64 view of [`FloatParamReadF32::read_after`]; one widen
293    /// on top of the same one-atomic-pair fast path.
294    #[must_use]
295    fn read_after(&self, n_samples: usize) -> f64;
296    #[must_use]
297    fn current(&self) -> f64;
298    #[must_use]
299    fn value(&self) -> f64;
300}
301
302impl FloatParamReadF32 for FloatParam {
303    #[inline]
304    fn read(&self) -> f32 {
305        self.raw_smoothed_next()
306    }
307
308    #[inline]
309    fn read_into(&self, out: &mut [f32]) {
310        self.raw_smoothed_next_into(out);
311    }
312
313    #[inline]
314    fn read_after(&self, n_samples: usize) -> f32 {
315        self.raw_smoothed_next_after(n_samples)
316    }
317
318    #[inline]
319    fn current(&self) -> f32 {
320        self.raw_smoothed_current()
321    }
322
323    #[inline]
324    fn value(&self) -> f32 {
325        f32::from_f64(self.raw_target())
326    }
327}
328
329impl FloatParamReadF64 for FloatParam {
330    #[inline]
331    fn read(&self) -> f64 {
332        f64::from(self.raw_smoothed_next())
333    }
334
335    #[inline]
336    fn read_into(&self, out: &mut [f64]) {
337        // Reuse the f32 fill via a transient stack scratch sized to
338        // the largest chunk a plugin typically passes (cap to 1024 -
339        // beyond that the caller almost certainly wants `read` per
340        // sample), widening each slot to f64.
341        const SCRATCH: usize = 1024;
342        let mut scratch = [0.0_f32; SCRATCH];
343        let mut remaining = out;
344        while !remaining.is_empty() {
345            let take = remaining.len().min(SCRATCH);
346            self.raw_smoothed_next_into(&mut scratch[..take]);
347            for (dst, &src) in remaining[..take].iter_mut().zip(&scratch[..take]) {
348                *dst = f64::from(src);
349            }
350            remaining = &mut remaining[take..];
351        }
352    }
353
354    #[inline]
355    fn read_after(&self, n_samples: usize) -> f64 {
356        f64::from(self.raw_smoothed_next_after(n_samples))
357    }
358
359    #[inline]
360    fn current(&self) -> f64 {
361        f64::from(self.raw_smoothed_current())
362    }
363
364    #[inline]
365    fn value(&self) -> f64 {
366        self.raw_target()
367    }
368}
369
370/// A boolean parameter.
371pub struct BoolParam {
372    pub info: ParamInfo,
373    value: AtomicBool,
374}
375
376impl BoolParam {
377    /// # Panics
378    ///
379    /// Panics if `info.default_plain` isn't exactly `0.0` or `1.0`.
380    /// Bool params have no halfway value; the derive emits `0.0` /
381    /// `1.0` only, so this fires only when a user constructs a
382    /// `BoolParam` from hand-rolled `ParamInfo`.
383    #[must_use]
384    pub fn new(info: ParamInfo) -> Self {
385        let default = match info.default_plain {
386            0.0 => false,
387            1.0 => true,
388            other => panic!(
389                "BoolParam '{}' default {} must be exactly 0.0 (false) \
390                 or 1.0 (true) - bool params have no halfway value",
391                info.name, other,
392            ),
393        };
394        Self {
395            info,
396            value: AtomicBool::new(default),
397        }
398    }
399
400    pub fn value(&self) -> bool {
401        self.value.load(Ordering::Relaxed)
402    }
403
404    pub fn set_value(&self, v: bool) {
405        self.value.store(v, Ordering::Relaxed);
406    }
407
408    pub fn id(&self) -> u32 {
409        self.info.id
410    }
411}
412
413/// An integer parameter.
414pub struct IntParam {
415    pub info: ParamInfo,
416    value: AtomicI64,
417}
418
419impl IntParam {
420    /// # Panics
421    ///
422    /// Panics if `info.default_plain` is non-finite or doesn't
423    /// round-trip through `i64`. The cast `f64 as i64` saturates
424    /// silently - `default_plain = -1.0` lands on `-1` (fine), but
425    /// `default_plain = 1e30` saturates to `i64::MAX` and `f64::NAN`
426    /// becomes `0`. The derive populates `default_plain` from
427    /// `#[param(default = ...)]`; a user-supplied float there is a
428    /// programmer error, not a runtime condition we should
429    /// silently absorb.
430    // `truncated as f64 == default` is the integer round-trip
431    // exactness check - epsilon would defeat its purpose. The
432    // `as i64` truncation is the round-trip's whole point.
433    #[allow(
434        clippy::float_cmp,
435        clippy::cast_possible_truncation,
436        clippy::cast_precision_loss
437    )]
438    #[must_use]
439    pub fn new(info: ParamInfo) -> Self {
440        let default = info.default_plain;
441        assert!(
442            default.is_finite(),
443            "IntParam '{}' default {} is not finite",
444            info.name,
445            default,
446        );
447        let truncated = default as i64;
448        assert!(
449            truncated as f64 == default,
450            "IntParam '{}' default {} doesn't round-trip through i64 \
451             - supply an integer-valued default in the derive attribute",
452            info.name,
453            default,
454        );
455        let (lo, hi) = (info.range.min() as i64, info.range.max() as i64);
456        assert!(
457            truncated >= lo && truncated <= hi,
458            "IntParam '{}' default {} is outside range [{}, {}]",
459            info.name,
460            truncated,
461            lo,
462            hi,
463        );
464        Self {
465            info,
466            value: AtomicI64::new(truncated),
467        }
468    }
469
470    pub fn value(&self) -> i64 {
471        self.value.load(Ordering::Relaxed)
472    }
473
474    /// Read the value widened to `f32`. Useful when an int param feeds
475    /// a per-sample DSP loop that runs in `f32`.
476    #[allow(clippy::cast_precision_loss)]
477    #[inline]
478    pub fn value_f32(&self) -> f32 {
479        self.value.load(Ordering::Relaxed) as f32
480    }
481
482    /// Read the value widened to `f64`.
483    #[allow(clippy::cast_precision_loss)]
484    #[inline]
485    pub fn value_f64(&self) -> f64 {
486        self.value.load(Ordering::Relaxed) as f64
487    }
488
489    /// Read the value as a non-negative `usize`. Negatives clamp to 0;
490    /// values above `usize::MAX` saturate.
491    #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
492    #[inline]
493    pub fn value_usize(&self) -> usize {
494        let v = self.value.load(Ordering::Relaxed);
495        if v <= 0 { 0 } else { v as usize }
496    }
497
498    /// Read the value clamped to `i32` range.
499    #[allow(clippy::cast_possible_truncation)]
500    #[inline]
501    pub fn value_i32(&self) -> i32 {
502        self.value
503            .load(Ordering::Relaxed)
504            .clamp(i64::from(i32::MIN), i64::from(i32::MAX)) as i32
505    }
506
507    /// Read the value clamped to `u8` range (`0..=255`).
508    #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
509    #[inline]
510    pub fn value_u8(&self) -> u8 {
511        self.value.load(Ordering::Relaxed).clamp(0, 255) as u8
512    }
513
514    /// Set the value, clamped to the declared range - symmetric with
515    /// `FloatParam::set_value`. A corrupt preset or hostile automation
516    /// value (`i64::MAX` into a `[0, 8]` param) must not reach the plugin,
517    /// where it could index out of range and panic the audio thread. The
518    /// bounds are normalized so a mis-ordered range can't panic `clamp`.
519    #[allow(clippy::cast_possible_truncation)]
520    pub fn set_value(&self, v: i64) {
521        let (lo, hi) = (self.info.range.min() as i64, self.info.range.max() as i64);
522        self.value
523            .store(v.clamp(lo.min(hi), lo.max(hi)), Ordering::Relaxed);
524    }
525
526    pub fn id(&self) -> u32 {
527        self.info.id
528    }
529}
530
531/// Trait for enums used as parameters.
532pub trait ParamEnum: crate::__private::Sealed + Clone + Copy + Send + Sync + 'static {
533    fn from_index(index: usize) -> Self;
534    fn to_index(&self) -> usize;
535    fn name(&self) -> &'static str;
536    fn variant_count() -> usize;
537    fn variant_names() -> &'static [&'static str];
538}
539
540/// An enum parameter.
541pub struct EnumParam<E: ParamEnum> {
542    pub info: ParamInfo,
543    value: AtomicU32,
544    _phantom: std::marker::PhantomData<E>,
545}
546
547impl<E: ParamEnum> EnumParam<E> {
548    /// # Panics
549    ///
550    /// Panics if `info.default_plain` is non-finite, negative, or
551    /// `>= E::variant_count()`. The cast `f64 as u32` saturates
552    /// silently - a user-supplied `#[param(default = -1)]` would
553    /// land on variant 0 without any signal that the default was
554    /// invalid. Validate up front so the bug surfaces at plugin
555    /// construction time.
556    // `f64::from(idx) == default` is the integer round-trip
557    // exactness check - epsilon would defeat its purpose. The
558    // `as u32` truncation is the round-trip's whole point.
559    #[allow(
560        clippy::float_cmp,
561        clippy::cast_possible_truncation,
562        clippy::cast_sign_loss
563    )]
564    #[must_use]
565    pub fn new(info: ParamInfo) -> Self {
566        let default = info.default_plain;
567        let count = E::variant_count();
568        assert!(
569            default.is_finite(),
570            "EnumParam '{}' default {} is not finite",
571            info.name,
572            default,
573        );
574        assert!(
575            default >= 0.0,
576            "EnumParam '{}' default {} is negative; enum variants are \
577             0-indexed",
578            info.name,
579            default,
580        );
581        let idx = default as u32;
582        assert!(
583            f64::from(idx) == default,
584            "EnumParam '{}' default {} is non-integer; supply a 0-indexed \
585             variant index",
586            info.name,
587            default,
588        );
589        assert!(
590            (idx as usize) < count,
591            "EnumParam '{}' default {} is out of range; only {} variant(s) \
592             defined",
593            info.name,
594            idx,
595            count,
596        );
597        Self {
598            info,
599            value: AtomicU32::new(idx),
600            _phantom: std::marker::PhantomData,
601        }
602    }
603
604    pub fn value(&self) -> E {
605        // u32 → usize widens on 64-bit, narrows nowhere we ship to;
606        // the lint trips because `usize` is target-dependent.
607        #[allow(clippy::cast_possible_truncation)]
608        let idx = self.value.load(Ordering::Relaxed) as usize;
609        E::from_index(idx)
610    }
611
612    pub fn set_value(&self, v: E) {
613        // Enum variant indices come from `ParamEnum::to_index`, whose
614        // valid range is `0..variant_count()`; truncation past `u32::MAX`
615        // would mean a > 4-billion-variant enum.
616        #[allow(clippy::cast_possible_truncation)]
617        let idx = v.to_index() as u32;
618        self.value.store(idx, Ordering::Relaxed);
619    }
620
621    pub fn set_index(&self, idx: u32) {
622        // Clamp to the enum's valid range. A preset saved with a wider
623        // enum (a since-shrunk v1) restores an out-of-range index through
624        // here; stored verbatim, `value()` / `from_index` read it as the
625        // first variant while `get_normalized` clamps to the last, so audio
626        // and display disagree. Clamp to the last variant - matching
627        // `ParamRange::Enum::normalize`'s clamp - so they stay consistent.
628        // `variant_count()` is >= 1 for any `ParamEnum`; `saturating_sub`
629        // guards the underflow regardless.
630        #[allow(clippy::cast_possible_truncation)]
631        let max = (E::variant_count() as u32).saturating_sub(1);
632        self.value.store(idx.min(max), Ordering::Relaxed);
633    }
634
635    pub fn index(&self) -> u32 {
636        self.value.load(Ordering::Relaxed)
637    }
638
639    pub fn id(&self) -> u32 {
640        self.info.id
641    }
642
643    /// Format a plain value (index as f64) to the variant name string.
644    ///
645    /// Associated function - the dispatch is purely on `E`, no instance
646    /// state is read. The `#[derive(Params)]` macro calls it as
647    /// `<EnumParam<E>>::format_by_index(value)` so the field type
648    /// supplies `E`.
649    #[must_use]
650    pub fn format_by_index(value: f64) -> String {
651        // `value` is a normalized f64 in `[0, count - 1]`; the round
652        // → usize cast is bounded by the variant count.
653        #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
654        let idx = value.round() as usize;
655        E::from_index(idx).name().to_string()
656    }
657}
658
659// ---------------------------------------------------------------------------
660// MeterSlot
661// ---------------------------------------------------------------------------
662
663/// A meter slot with an auto-assigned ID.
664///
665/// Declare in your params struct with `#[meter]`:
666/// ```ignore
667/// #[derive(Params)]
668/// pub struct MyParams {
669///     #[meter]
670///     pub meter_left: MeterSlot,
671/// }
672/// ```
673///
674/// `id` is `pub` so the `#[derive(Params)]` macro can construct a
675/// `MeterSlot { id: <auto-assigned> }` directly without going through
676/// a `pub fn new(id)` constructor that would let user code mint
677/// arbitrary slots and break the auto-assignment contract.
678pub struct MeterSlot {
679    #[doc(hidden)]
680    pub id: u32,
681}
682
683impl MeterSlot {
684    #[must_use]
685    pub fn id(&self) -> u32 {
686        self.id
687    }
688}
689
690impl From<MeterSlot> for u32 {
691    fn from(m: MeterSlot) -> u32 {
692        m.id
693    }
694}
695
696impl From<&MeterSlot> for u32 {
697    fn from(m: &MeterSlot) -> u32 {
698        m.id
699    }
700}
701
702// ---------------------------------------------------------------------------
703// AudioTap
704// ---------------------------------------------------------------------------
705
706use std::sync::atomic::AtomicUsize;
707
708/// Default ring capacity in samples for [`AudioTap::default`] - two
709/// typical 2048-point analyzer FFT windows' worth of headroom so a
710/// slightly late UI drain doesn't lose a whole frame.
711pub const DEFAULT_TAP_CAPACITY: usize = 4096;
712
713/// Lock-free single-producer/single-consumer sample tap for the audio
714/// → UI analyzer path (audio→UI sample ring).
715///
716/// The audio thread [`push`](Self::push)es raw samples every
717/// `process()` call; the UI/editor thread [`drain`](Self::drain)s them
718/// once per frame. Both sides use plain atomics only - no locks, no
719/// allocation on the producer side - so `push` is safe to call from a
720/// realtime audio callback (`agal/skills/00-core/audio-thread-boundary.md`).
721///
722/// Declare with `#[skip]` like any other DSP↔UI shared field the
723/// derive default-initializes:
724///
725/// ```ignore
726/// #[derive(Params)]
727/// pub struct AnalyzerParams {
728///     #[skip]
729///     pub spectrum_tap: AudioTap,
730/// }
731/// ```
732///
733/// The editor reaches it through the concrete `Arc<Self::Params>`
734/// `PluginLogic::editor()` already receives (capture it into the
735/// build/sync closures) - not through the `dyn Params` trait, since a
736/// raw sample ring isn't part of the host-automatable parameter
737/// surface. FFT / spectrum math stays product-side (`lx-analysis`,
738/// see G16); this type only moves raw samples across the thread
739/// boundary.
740///
741/// Single-producer / single-consumer is a caller contract, not
742/// enforced by the type: only the audio thread may call `push`, only
743/// the UI/editor thread may call `drain`.
744///
745/// On overflow (producer outruns the consumer), the oldest unread
746/// samples are silently overwritten - the tap never blocks and never
747/// grows, matching the audio thread's no-alloc/no-lock constraint.
748/// Need a non-default capacity? `#[skip]` fields only require
749/// `Default`, so wrap in a newtype with its own `Default` impl calling
750/// [`AudioTap::new`].
751pub struct AudioTap {
752    buf: Box<[AtomicU32]>,
753    /// Total samples ever pushed (monotonic; wraps at `usize::MAX`).
754    write: AtomicUsize,
755    /// Total samples ever drained (monotonic; wraps at `usize::MAX`).
756    read: AtomicUsize,
757}
758
759impl AudioTap {
760    /// New tap with the given capacity in samples. `0` is treated as
761    /// `1` - a zero-length ring can't hold a sample.
762    #[must_use]
763    pub fn new(capacity: usize) -> Self {
764        let capacity = capacity.max(1);
765        Self {
766            buf: (0..capacity).map(|_| AtomicU32::new(0)).collect(),
767            write: AtomicUsize::new(0),
768            read: AtomicUsize::new(0),
769        }
770    }
771
772    /// Ring capacity in samples.
773    #[must_use]
774    pub fn capacity(&self) -> usize {
775        self.buf.len()
776    }
777
778    /// Push samples from the audio thread. Never allocates, never
779    /// blocks; overwrites the oldest unread samples on overflow.
780    pub fn push(&self, samples: &[f32]) {
781        let cap = self.buf.len();
782        let mut write = self.write.load(Ordering::Relaxed);
783        for &s in samples {
784            self.buf[write % cap].store(s.to_bits(), Ordering::Relaxed);
785            write = write.wrapping_add(1);
786        }
787        self.write.store(write, Ordering::Release);
788    }
789
790    /// Drain every sample pushed since the last `drain`, oldest first.
791    /// Call from the UI/editor thread. Returns fewer samples than were
792    /// pushed since the last drain if the producer overflowed the ring
793    /// in the meantime - the overwritten samples are simply gone.
794    #[must_use]
795    pub fn drain(&self) -> Vec<f32> {
796        let cap = self.buf.len();
797        let write = self.write.load(Ordering::Acquire);
798        let read = self.read.load(Ordering::Relaxed);
799        let available = write.wrapping_sub(read).min(cap);
800        let start = write.wrapping_sub(available);
801        let mut out = Vec::with_capacity(available);
802        for i in 0..available {
803            let idx = start.wrapping_add(i) % cap;
804            out.push(f32::from_bits(self.buf[idx].load(Ordering::Relaxed)));
805        }
806        self.read.store(write, Ordering::Release);
807        out
808    }
809}
810
811impl Default for AudioTap {
812    fn default() -> Self {
813        Self::new(DEFAULT_TAP_CAPACITY)
814    }
815}
816
817#[cfg(test)]
818mod tests {
819    use super::*;
820    use crate::info::{ParamFlags, ParamUnit, ParamValueKind};
821    use crate::range::ParamRange;
822
823    fn info(name: &'static str, range: ParamRange, default_plain: f64) -> ParamInfo {
824        ParamInfo {
825            id: 0,
826            name,
827            short_name: name,
828            group: "",
829            range,
830            default_plain,
831            flags: ParamFlags::AUTOMATABLE,
832            unit: ParamUnit::None,
833            kind: ParamValueKind::Float,
834            midi_map: None,
835            midi_channel: None,
836        }
837    }
838
839    #[derive(Clone, Copy)]
840    enum E4 {
841        A,
842        B,
843        C,
844        D,
845    }
846    impl crate::__private::Sealed for E4 {}
847    impl ParamEnum for E4 {
848        fn from_index(i: usize) -> Self {
849            match i {
850                0 => Self::A,
851                1 => Self::B,
852                2 => Self::C,
853                _ => Self::D,
854            }
855        }
856        fn to_index(&self) -> usize {
857            *self as usize
858        }
859        fn name(&self) -> &'static str {
860            match self {
861                Self::A => "A",
862                Self::B => "B",
863                Self::C => "C",
864                Self::D => "D",
865            }
866        }
867        fn variant_count() -> usize {
868            4
869        }
870        fn variant_names() -> &'static [&'static str] {
871            &["A", "B", "C", "D"]
872        }
873    }
874
875    #[test]
876    fn enum_param_accepts_in_range_default() {
877        let p: EnumParam<E4> = EnumParam::new(info("Mode", ParamRange::Enum { count: 4 }, 2.0));
878        assert_eq!(p.index(), 2);
879    }
880
881    #[test]
882    #[should_panic(expected = "negative")]
883    fn enum_param_rejects_negative_default() {
884        let _: EnumParam<E4> = EnumParam::new(info("Mode", ParamRange::Enum { count: 4 }, -1.0));
885    }
886
887    #[test]
888    fn enum_param_set_index_clamps_out_of_range() {
889        // A preset saved with a wider (5-variant) enum restores index 4
890        // into this 4-variant enum. It must clamp to the last variant so
891        // `value()` (audio) and the normalized read (display) agree - not
892        // play the first variant while `normalize` clamps to the last.
893        let p: EnumParam<E4> = EnumParam::new(info("Mode", ParamRange::Enum { count: 4 }, 0.0));
894        p.set_index(4);
895        assert_eq!(p.index(), 3, "out-of-range index clamps to last variant");
896        assert!(matches!(p.value(), E4::D));
897        p.set_index(1000);
898        assert_eq!(p.index(), 3);
899    }
900
901    #[test]
902    #[should_panic(expected = "out of range")]
903    fn enum_param_rejects_overflow_default() {
904        let _: EnumParam<E4> = EnumParam::new(info("Mode", ParamRange::Enum { count: 4 }, 99.0));
905    }
906
907    #[test]
908    #[should_panic(expected = "non-integer")]
909    fn enum_param_rejects_fractional_default() {
910        let _: EnumParam<E4> = EnumParam::new(info("Mode", ParamRange::Enum { count: 4 }, 1.5));
911    }
912
913    #[test]
914    fn int_param_accepts_negative_default() {
915        let p = IntParam::new(info("N", ParamRange::Discrete { min: -10, max: 10 }, -3.0));
916        assert_eq!(p.value(), -3);
917    }
918
919    #[test]
920    #[should_panic(expected = "round-trip")]
921    fn int_param_rejects_fractional_default() {
922        let _ = IntParam::new(info("N", ParamRange::Discrete { min: 0, max: 10 }, 1.5));
923    }
924
925    #[test]
926    #[should_panic(expected = "outside range")]
927    fn int_param_rejects_out_of_range_default() {
928        let _ = IntParam::new(info("N", ParamRange::Discrete { min: 0, max: 5 }, 10.0));
929    }
930
931    #[test]
932    fn int_param_set_value_clamps_to_range() {
933        // A corrupt preset restoring a wild value must not land out of range
934        // (symmetric with FloatParam::set_value); the derive stores
935        // `value.round() as i64`, so i64::MAX is a realistic input.
936        let p = IntParam::new(info("N", ParamRange::Discrete { min: 0, max: 8 }, 0.0));
937        p.set_value(i64::MAX);
938        assert_eq!(p.value(), 8, "clamps above max");
939        p.set_value(-1000);
940        assert_eq!(p.value(), 0, "clamps below min");
941        p.set_value(5);
942        assert_eq!(p.value(), 5, "in-range value stored as-is");
943    }
944
945    fn float(min: f64, max: f64) -> FloatParam {
946        FloatParam::new(
947            info("Gain", ParamRange::Linear { min, max }, 0.0),
948            SmoothingStyle::None,
949        )
950    }
951
952    #[test]
953    #[allow(clippy::float_cmp)] // clamp / dropped-write yields the exact stored value
954    fn float_set_value_drops_non_finite() {
955        let p = float(-60.0, 6.0);
956        p.set_value(-12.0);
957        p.set_value(f64::NAN);
958        assert_eq!(p.raw_target(), -12.0, "NaN write is dropped");
959        p.set_value(f64::INFINITY);
960        assert_eq!(p.raw_target(), -12.0, "infinite write is dropped");
961    }
962
963    #[test]
964    #[allow(clippy::float_cmp)] // clamp yields the exact range bound
965    fn float_set_value_clamps_to_range() {
966        let p = float(-60.0, 6.0);
967        p.set_value(1e308);
968        assert_eq!(p.raw_target(), 6.0, "clamps above max");
969        p.set_value(-1e308);
970        assert_eq!(p.raw_target(), -60.0, "clamps below min");
971    }
972
973    /// A mis-ordered range (`min > max`) is a bug caught at construction in
974    /// debug builds - loud and early, not a `clamp` panic buried in a
975    /// host-automation callback.
976    #[cfg(debug_assertions)]
977    #[test]
978    #[should_panic(expected = "ordered")]
979    fn float_new_debug_asserts_misordered_range() {
980        let _ = FloatParam::new(
981            info(
982                "Bad",
983                ParamRange::Linear {
984                    min: 6.0,
985                    max: -60.0,
986                },
987                0.0,
988            ),
989            SmoothingStyle::None,
990        );
991    }
992
993    /// In release the construction assert is compiled out, so `set_value`
994    /// must still not panic on a mis-ordered range: it normalizes the clamp
995    /// bounds. (`f64::clamp` would panic on `min > max`.)
996    #[cfg(not(debug_assertions))]
997    #[test]
998    fn float_set_value_survives_misordered_range() {
999        let p = FloatParam::new(
1000            info(
1001                "Bad",
1002                ParamRange::Linear {
1003                    min: 6.0,
1004                    max: -60.0,
1005                },
1006                0.0,
1007            ),
1008            SmoothingStyle::None,
1009        );
1010        p.set_value(1000.0); // must not panic
1011        let v = p.raw_target();
1012        assert!(
1013            (-60.0..=6.0).contains(&v),
1014            "clamped to the normalized interval"
1015        );
1016    }
1017
1018    #[test]
1019    fn audio_tap_push_drain_round_trip() {
1020        let tap = AudioTap::new(8);
1021        tap.push(&[1.0, 2.0, 3.0]);
1022        assert_eq!(tap.drain(), vec![1.0, 2.0, 3.0]);
1023        // Fully drained: nothing left until the next push.
1024        assert_eq!(tap.drain(), Vec::<f32>::new());
1025    }
1026
1027    #[test]
1028    fn audio_tap_multiple_pushes_before_drain() {
1029        let tap = AudioTap::new(8);
1030        tap.push(&[1.0, 2.0]);
1031        tap.push(&[3.0, 4.0]);
1032        assert_eq!(tap.drain(), vec![1.0, 2.0, 3.0, 4.0]);
1033    }
1034
1035    #[test]
1036    fn audio_tap_overflow_drops_oldest_never_blocks() {
1037        let tap = AudioTap::new(4);
1038        // Push more than capacity in one go: only the last 4 survive.
1039        tap.push(&[1.0, 2.0, 3.0, 4.0, 5.0, 6.0]);
1040        assert_eq!(tap.drain(), vec![3.0, 4.0, 5.0, 6.0]);
1041    }
1042
1043    #[test]
1044    fn audio_tap_overflow_across_pushes_between_drains() {
1045        let tap = AudioTap::new(4);
1046        tap.push(&[1.0, 2.0, 3.0]);
1047        // Consumer hasn't drained yet; producer keeps going and wraps.
1048        tap.push(&[4.0, 5.0, 6.0]);
1049        // Ring holds 4: the oldest 2 (1.0, 2.0) were overwritten.
1050        assert_eq!(tap.drain(), vec![3.0, 4.0, 5.0, 6.0]);
1051    }
1052
1053    #[test]
1054    fn audio_tap_default_uses_default_capacity() {
1055        assert_eq!(AudioTap::default().capacity(), DEFAULT_TAP_CAPACITY);
1056    }
1057
1058    #[test]
1059    fn audio_tap_zero_capacity_clamped_to_one() {
1060        let tap = AudioTap::new(0);
1061        assert_eq!(tap.capacity(), 1);
1062        tap.push(&[7.0, 8.0]);
1063        assert_eq!(
1064            tap.drain(),
1065            vec![8.0],
1066            "only the last sample survives a 1-slot ring"
1067        );
1068    }
1069
1070    #[test]
1071    fn float_mod_is_non_destructive() {
1072        let p = FloatParam::new(
1073            info("Gain", ParamRange::Linear { min: 0.0, max: 1.0 }, 0.5),
1074            SmoothingStyle::None,
1075        );
1076        assert!((p.raw_target() - 0.5).abs() < 1e-12);
1077        p.set_mod_amount(0.25);
1078        assert!((p.raw_target() - 0.5).abs() < 1e-12);
1079        assert!((p.effective_target() - 0.75).abs() < 1e-12);
1080        p.set_mod_amount(0.0);
1081        assert!((p.effective_target() - 0.5).abs() < 1e-12);
1082    }
1083
1084    #[test]
1085    fn float_mod_clamps_to_range() {
1086        let p = FloatParam::new(
1087            info("Gain", ParamRange::Linear { min: 0.0, max: 1.0 }, 0.5),
1088            SmoothingStyle::None,
1089        );
1090        p.set_value(0.9);
1091        p.set_mod_amount(0.5); // would be 1.4
1092        assert!((p.effective_target() - 1.0).abs() < 1e-12);
1093    }
1094}