Skip to main content

truce_params/
sample.rs

1//! `Float` and `Sample` - the precision-routing traits that let
2//! plugin code stay in one float type without per-call-site casts.
3//!
4//! Plugin authors don't usually name these traits directly. They
5//! pick a precision via the prelude (`truce::prelude` /
6//! `truce::prelude32` for `f32`, `truce::prelude64` for `f64`); the
7//! prelude's `type Sample` alias resolves the bound at the call
8//! sites. The traits surface only when DSP code wants to convert
9//! between precisions per value:
10//!
11//! ```
12//! use truce_params::sample::Float;
13//! let v_f32: f32 = 0.5;
14//! let v_f64: f64 = v_f32.to_f64();   // widen
15//! let back:  f32 = f32::from_f64(v_f64); // narrow
16//! ```
17//!
18//! Both traits are sealed at `f32` and `f64`. Downstream code can't
19//! add new impls; numeric types beyond these two have never been
20//! worth the complexity for audio.
21//!
22//! ## Two traits, why
23//!
24//! - [`Float`] is the **broad math bound**. Use it for utilities like
25//!   `db_to_linear`, `midi_note_to_freq` - values that happen to be
26//!   `f32` or `f64` but aren't audio samples. The bound carries the
27//!   precision-routing methods (`from_f32`/`from_f64`/`to_f32`/`to_f64`)
28//!   plus a handful of math primitives (`exp`, `log10`, `powf`).
29//!   `Float::from_f64`'s NaN debug-assert is the same as `Sample`'s,
30//!   because anywhere a NaN narrowing slips through is a bug
31//!   regardless of whether the value is a sample or a gain coefficient.
32//! - [`Sample`] is `Float` plus the marker bounds that buffer code
33//!   needs (`Default + Send + Sync + 'static`) so the wrapper can
34//!   default-construct scratch buffers and pass them across threads.
35//!   This is the bound that goes on `AudioBuffer<S>`, `Plugin::Sample`,
36//!   and the `FloatParamRead<S>` extension trait.
37
38use std::ops::{Add, Div, Mul, Sub};
39
40/// Broad numeric trait for code that operates on `f32` or `f64` but
41/// isn't necessarily handling audio samples. Use this for math
42/// utilities (gain conversions, frequency math, filter coefficients).
43/// For audio-sample-typed surfaces (`AudioBuffer<S>`, smoother
44/// reads), use [`Sample`] instead, which extends `Float` with the
45/// marker bounds buffer code needs.
46pub trait Float:
47    sealed::Sealed
48    + Copy
49    + Add<Output = Self>
50    + Sub<Output = Self>
51    + Mul<Output = Self>
52    + Div<Output = Self>
53{
54    /// Widen an `f32` to this precision. Lossless for `f64`; identity
55    /// for `f32`.
56    #[must_use]
57    fn from_f32(v: f32) -> Self;
58
59    /// Narrow an `f64` to this precision. Identity for `f64`. For
60    /// `f32`, debug-asserts non-NaN - DSP code that produces a NaN
61    /// here is always a bug, and silent NaN propagation through the
62    /// audio path causes host-inconsistent behaviour. Release builds
63    /// preserve NaN via the bare `as` cast so the upstream bug stays
64    /// visible.
65    #[must_use]
66    fn from_f64(v: f64) -> Self;
67
68    /// Narrow to `f32`. Identity for `f32`; for `f64`, same NaN
69    /// debug-assert as [`Self::from_f64`].
70    #[must_use]
71    fn to_f32(self) -> f32;
72
73    /// Widen to `f64`. Identity for `f64`; lossless for `f32`.
74    #[must_use]
75    fn to_f64(self) -> f64;
76
77    /// Natural exponential. Forwards to the type's intrinsic.
78    #[must_use]
79    fn exp(self) -> Self;
80
81    /// Base-10 logarithm. Forwards to the type's intrinsic.
82    #[must_use]
83    fn log10(self) -> Self;
84
85    /// `self.powf(exp)`. Forwards to the type's intrinsic.
86    #[must_use]
87    fn powf(self, exp: Self) -> Self;
88}
89
90/// Audio-sample subtype of [`Float`]. Adds the
91/// `Default + Send + Sync + 'static` marker bounds that buffer code,
92/// scratch allocators, and the param-read extension trait need.
93///
94/// Bound at `f32` and `f64`. Plugin authors usually don't name this
95/// directly; the prelude resolves the bound for them.
96pub trait Sample: Float + Default + Send + Sync + 'static {}
97
98impl Sample for f32 {}
99impl Sample for f64 {}
100
101mod sealed {
102    pub trait Sealed {}
103    impl Sealed for f32 {}
104    impl Sealed for f64 {}
105}
106
107impl Float for f32 {
108    #[inline]
109    fn from_f32(v: f32) -> Self {
110        v
111    }
112
113    // Plugins narrowing `f64 → f32` (param values, filter
114    // coefficients, host-side display) get the NaN guard here.
115    #[inline]
116    #[allow(clippy::cast_possible_truncation)]
117    fn from_f64(v: f64) -> Self {
118        debug_assert!(
119            !v.is_nan(),
120            "Float::from_f64: NaN narrowed to f32 - DSP loop or coefficient \
121             computation produced an undefined value?",
122        );
123        v as f32
124    }
125
126    #[inline]
127    fn to_f32(self) -> f32 {
128        self
129    }
130
131    #[inline]
132    fn to_f64(self) -> f64 {
133        f64::from(self)
134    }
135
136    #[inline]
137    fn exp(self) -> Self {
138        f32::exp(self)
139    }
140    #[inline]
141    fn log10(self) -> Self {
142        f32::log10(self)
143    }
144    #[inline]
145    fn powf(self, exp: Self) -> Self {
146        f32::powf(self, exp)
147    }
148}
149
150impl Float for f64 {
151    #[inline]
152    fn from_f32(v: f32) -> Self {
153        f64::from(v)
154    }
155
156    #[inline]
157    fn from_f64(v: f64) -> Self {
158        v
159    }
160
161    #[inline]
162    #[allow(clippy::cast_possible_truncation)]
163    fn to_f32(self) -> f32 {
164        debug_assert!(
165            !self.is_nan(),
166            "Float::to_f32: NaN narrowed to f32 - DSP loop or coefficient \
167             computation produced an undefined value?",
168        );
169        self as f32
170    }
171
172    #[inline]
173    fn to_f64(self) -> f64 {
174        self
175    }
176
177    #[inline]
178    fn exp(self) -> Self {
179        f64::exp(self)
180    }
181    #[inline]
182    fn log10(self) -> Self {
183        f64::log10(self)
184    }
185    #[inline]
186    fn powf(self, exp: Self) -> Self {
187        f64::powf(self, exp)
188    }
189}
190
191#[cfg(test)]
192mod tests {
193    use super::*;
194
195    #[test]
196    #[allow(clippy::float_cmp)]
197    fn widen_narrow_round_trip_f32() {
198        // Bit-exact round trip: f32 → f64 → f32 must be the identity
199        // (f32 fits losslessly in f64), so a strict equality compare
200        // is correct here, not a tolerance epsilon.
201        let v: f32 = 0.123_456_7;
202        assert_eq!(f32::from_f64(v.to_f64()), v);
203    }
204
205    #[test]
206    fn widen_narrow_round_trip_f64_lossy() {
207        // Narrowing a precise f64 to f32 and back loses bits but
208        // stays bounded in audio range.
209        let v: f64 = 0.123_456_789_012_345;
210        let round_tripped = f32::from_f64(v).to_f64();
211        assert!((round_tripped - v).abs() < 1e-7);
212    }
213
214    #[test]
215    #[should_panic(expected = "NaN narrowed to f32")]
216    #[cfg(debug_assertions)]
217    fn nan_narrow_debug_panics() {
218        let _ = f32::from_f64(f64::NAN);
219    }
220}