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}