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    /// Whether this precision is `f64`. The trait is sealed at `f32`
55    /// and `f64`, so two types with equal `IS_F64` are the same type -
56    /// wrapper code uses this to pick zero-copy vs. convert-through-
57    /// scratch without a `TypeId` comparison.
58    const IS_F64: bool;
59
60    /// Widen an `f32` to this precision. Lossless for `f64`; identity
61    /// for `f32`.
62    #[must_use]
63    fn from_f32(v: f32) -> Self;
64
65    /// Narrow an `f64` to this precision. Identity for `f64`. For
66    /// `f32`, debug-asserts non-NaN - DSP code that produces a NaN
67    /// here is always a bug, and silent NaN propagation through the
68    /// audio path causes host-inconsistent behaviour. Release builds
69    /// preserve NaN via the bare `as` cast so the upstream bug stays
70    /// visible.
71    #[must_use]
72    fn from_f64(v: f64) -> Self;
73
74    /// Narrow to `f32`. Identity for `f32`; for `f64`, same NaN
75    /// debug-assert as [`Self::from_f64`].
76    #[must_use]
77    fn to_f32(self) -> f32;
78
79    /// Widen to `f64`. Identity for `f64`; lossless for `f32`.
80    #[must_use]
81    fn to_f64(self) -> f64;
82
83    /// Natural exponential. Forwards to the type's intrinsic.
84    #[must_use]
85    fn exp(self) -> Self;
86
87    /// Base-10 logarithm. Forwards to the type's intrinsic.
88    #[must_use]
89    fn log10(self) -> Self;
90
91    /// `self.powf(exp)`. Forwards to the type's intrinsic.
92    #[must_use]
93    fn powf(self, exp: Self) -> Self;
94}
95
96/// Audio-sample subtype of [`Float`]. Adds the
97/// `Default + Send + Sync + 'static` marker bounds that buffer code,
98/// scratch allocators, and the param-read extension trait need.
99///
100/// Bound at `f32` and `f64`. Plugin authors usually don't name this
101/// directly; the prelude resolves the bound for them.
102pub trait Sample: Float + Default + Send + Sync + 'static {}
103
104impl Sample for f32 {}
105impl Sample for f64 {}
106
107mod sealed {
108    pub trait Sealed {}
109    impl Sealed for f32 {}
110    impl Sealed for f64 {}
111}
112
113impl Float for f32 {
114    const IS_F64: bool = false;
115
116    #[inline]
117    fn from_f32(v: f32) -> Self {
118        v
119    }
120
121    // Plugins narrowing `f64 → f32` (param values, filter
122    // coefficients, host-side display) get the NaN guard here.
123    #[inline]
124    #[allow(clippy::cast_possible_truncation)]
125    fn from_f64(v: f64) -> Self {
126        debug_assert!(
127            !v.is_nan(),
128            "Float::from_f64: NaN narrowed to f32 - DSP loop or coefficient \
129             computation produced an undefined value?",
130        );
131        v as f32
132    }
133
134    #[inline]
135    fn to_f32(self) -> f32 {
136        self
137    }
138
139    #[inline]
140    fn to_f64(self) -> f64 {
141        f64::from(self)
142    }
143
144    #[inline]
145    fn exp(self) -> Self {
146        f32::exp(self)
147    }
148    #[inline]
149    fn log10(self) -> Self {
150        f32::log10(self)
151    }
152    #[inline]
153    fn powf(self, exp: Self) -> Self {
154        f32::powf(self, exp)
155    }
156}
157
158impl Float for f64 {
159    const IS_F64: bool = true;
160
161    #[inline]
162    fn from_f32(v: f32) -> Self {
163        f64::from(v)
164    }
165
166    #[inline]
167    fn from_f64(v: f64) -> Self {
168        v
169    }
170
171    #[inline]
172    #[allow(clippy::cast_possible_truncation)]
173    fn to_f32(self) -> f32 {
174        debug_assert!(
175            !self.is_nan(),
176            "Float::to_f32: NaN narrowed to f32 - DSP loop or coefficient \
177             computation produced an undefined value?",
178        );
179        self as f32
180    }
181
182    #[inline]
183    fn to_f64(self) -> f64 {
184        self
185    }
186
187    #[inline]
188    fn exp(self) -> Self {
189        f64::exp(self)
190    }
191    #[inline]
192    fn log10(self) -> Self {
193        f64::log10(self)
194    }
195    #[inline]
196    fn powf(self, exp: Self) -> Self {
197        f64::powf(self, exp)
198    }
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204
205    #[test]
206    #[allow(clippy::float_cmp)]
207    fn widen_narrow_round_trip_f32() {
208        // Bit-exact round trip: f32 → f64 → f32 must be the identity
209        // (f32 fits losslessly in f64), so a strict equality compare
210        // is correct here, not a tolerance epsilon.
211        let v: f32 = 0.123_456_7;
212        assert_eq!(f32::from_f64(v.to_f64()), v);
213    }
214
215    #[test]
216    fn widen_narrow_round_trip_f64_lossy() {
217        // Narrowing a precise f64 to f32 and back loses bits but
218        // stays bounded in audio range.
219        let v: f64 = 0.123_456_789_012_345;
220        let round_tripped = f32::from_f64(v).to_f64();
221        assert!((round_tripped - v).abs() < 1e-7);
222    }
223
224    #[test]
225    #[should_panic(expected = "NaN narrowed to f32")]
226    #[cfg(debug_assertions)]
227    fn nan_narrow_debug_panics() {
228        let _ = f32::from_f64(f64::NAN);
229    }
230}