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}