Skip to main content

truce_utils/
cast.rs

1//! Numeric-cast helpers for the audio-plugin → host FFI boundary.
2//!
3//! Audio-plugin code routinely casts at three points where Rust's
4//! type system can't help:
5//!
6//! - **FFI struct sizes / element counts:** `usize` (Rust) vs `u32`
7//!   (every C ABI we ship to).
8//! - **Host `f64` ↔ DSP `f32`:** parameter values, audio samples,
9//!   sample-position counters, sample rates.
10//! - **Discrete-index ↔ normalized:** GUI selector / dropdown
11//!   widgets bridge an integer "which option" to a normalized
12//!   `f64 ∈ [0.0, 1.0]` parameter value.
13//!
14//! MIDI value-domain helpers (7/14/16/32-bit ↔ `f32`) live in
15//! [`crate::midi`] alongside the spec's MIDI 1.0 ↔ MIDI 2.0
16//! bit-replication bridges.
17//!
18//! Each helper is `#[inline]`, debug-asserts the input range so a
19//! NaN-bearing or overflowing caller fails loud in tests, and is
20//! the *only* place in the workspace that's allowed to reach for
21//! `as` on its specific shape. The lints
22//! `cast_possible_truncation`, `cast_sign_loss`, and
23//! `cast_precision_loss` are allowed at the module level so the
24//! helpers can do their job without per-site annotations.
25
26#![allow(
27    clippy::cast_possible_truncation,
28    clippy::cast_sign_loss,
29    clippy::cast_precision_loss
30)]
31
32/// Cast a `usize` element count (`Vec::len()`, iterator count) to
33/// `u32` for an FFI field.
34///
35/// Debug-asserts the value fits - a 5GB+ `Vec<u8>` would silently
36/// truncate without this guard. Release builds wrap; callers that
37/// can produce values past `u32::MAX` should use `try_into` and
38/// surface a typed error instead.
39#[inline]
40#[must_use]
41pub fn len_u32(n: usize) -> u32 {
42    debug_assert!(
43        u32::try_from(n).is_ok(),
44        "len_u32: count {n} overflows u32; FFI field would silently truncate",
45    );
46    n as u32
47}
48
49/// Cast `core::mem::size_of::<T>()` to `u32` for an FFI struct's
50/// `size` field.
51///
52/// `const` so the call disappears at codegen. The compile-time
53/// `assert!` catches the (unrealistic) case where `T` is more than
54/// 4GB at instantiation rather than panicking at run time.
55///
56/// # Panics
57///
58/// Panics at compile time (via `const` evaluation) if `T`'s size
59/// exceeds `u32::MAX`. No real Rust type hits this, but the assert
60/// is kept so callers can rely on the cast being lossless.
61#[inline]
62#[must_use]
63pub const fn size_of_u32<T>() -> u32 {
64    let n = core::mem::size_of::<T>();
65    assert!(
66        n <= u32::MAX as usize,
67        "size_of_u32: T's size overflows u32",
68    );
69    n as u32
70}
71
72/// Convert a host-supplied sample-position `f64` to the `i64` truce's
73/// `TransportInfo::position_samples` carries.
74///
75/// Hosts deliver play-cursor position as `double samplePosition`
76/// (CLAP / VST3 / AU all do). Truce stores it as `i64`, large enough
77/// for ~3000 years at 48 kHz. Non-finite inputs saturate at
78/// `i64::MIN` / `i64::MAX` rather than producing the unspecified
79/// integer the bare cast historically did.
80#[inline]
81#[must_use]
82pub fn sample_pos_i64(v: f64) -> i64 {
83    if v.is_nan() {
84        debug_assert!(false, "sample_pos_i64: NaN host sample position");
85        return 0;
86    }
87    if v >= i64::MAX as f64 {
88        return i64::MAX;
89    }
90    if v <= i64::MIN as f64 {
91        return i64::MIN;
92    }
93    v as i64
94}
95
96/// Convert a sample-count expressed as `f64` (e.g. `seconds *
97/// sample_rate`) to `usize`, saturating on overflow / negative /
98/// non-finite inputs. NaN and negative inputs collapse to `0`;
99/// positive infinity and any value past `usize::MAX` clamp to
100/// `usize::MAX`.
101#[inline]
102#[must_use]
103pub fn sample_count_usize(v: f64) -> usize {
104    if v.is_nan() || v <= 0.0 {
105        return 0;
106    }
107    if v >= usize::MAX as f64 {
108        return usize::MAX;
109    }
110    v as usize
111}
112
113/// Inverse of [`sample_count_usize`]: convert a sample/frame count
114/// to `f64` for time math (`frames / sample_rate` → seconds,
115/// `frames * ratio` → resampled length, etc.).
116///
117/// `f64`'s 52-bit mantissa holds counts exactly up to ~9 × 10¹⁵; at
118/// 192 kHz that's ~1500 years of audio, so the precision-loss
119/// warning is irrelevant in practice. Lives in `cast` because the
120/// shape repeats across the offline render and resampler in
121/// `truce-standalone`.
122#[inline]
123#[must_use]
124pub fn frame_count_f64(n: usize) -> f64 {
125    n as f64
126}
127
128/// Cast a host-supplied sample rate (`f64`) to the `u32` audio APIs
129/// (`cpal`, `hound`, Core Audio's `AudioStreamBasicDescription`) carry.
130///
131/// Audio sample rates are positive and bounded - 192 kHz is the
132/// highest in mainstream use, far below `u32::MAX`. NaN and
133/// negative inputs debug-assert; release builds clamp to `0` so a
134/// garbage host doesn't produce undefined behavior at the FFI
135/// boundary.
136#[inline]
137#[must_use]
138pub fn sample_rate_u32(rate: f64) -> u32 {
139    debug_assert!(
140        !rate.is_nan() && rate >= 0.0,
141        "sample_rate_u32: invalid rate {rate} - host sample rate is uninitialized?",
142    );
143    if rate.is_nan() || rate < 0.0 {
144        return 0;
145    }
146    if rate >= f64::from(u32::MAX) {
147        return u32::MAX;
148    }
149    rate as u32
150}
151
152/// Map a discrete index in `[0, count - 1]` to a normalized value
153/// in `[0.0, 1.0]`. Returns `0.0` when `count <= 1` - there's only
154/// one valid index, so any input collapses to the bottom of the
155/// range.
156///
157/// `idx` is clamped to `count - 1` before scaling so an off-by-one
158/// caller can't produce a normalized value above `1.0`. The output
159/// is `f64` because the host-facing param surface
160/// (`Params::set_normalized`) is `f64`; widget code that needs
161/// `f32` should cast at the call site.
162///
163/// Inverse of [`discrete_index`]. Together they are the canonical
164/// place selector / dropdown widgets bridge integer option indices
165/// to normalized parameter values.
166#[inline]
167#[must_use]
168pub fn discrete_norm(idx: usize, count: usize) -> f64 {
169    if count <= 1 {
170        return 0.0;
171    }
172    let max_idx = count - 1;
173    idx.min(max_idx) as f64 / max_idx as f64
174}
175
176/// Map a normalized value in `[0.0, 1.0]` to a discrete index in
177/// `[0, count - 1]`. Returns `0` when `count <= 1` - the index is
178/// pinned to the only valid slot.
179///
180/// `norm` is clamped to `[0.0, 1.0]` before scaling so an
181/// out-of-range host (e.g. a VST3 host that sends `1.0001`) can't
182/// produce an out-of-range index. Rounding is half-to-even via
183/// `f64::round`, the same rule applied across the param taper code
184/// in `truce_params::range`.
185///
186/// Inverse of [`discrete_norm`]; round-trips for every `idx ∈
187/// [0, count - 1]` whenever `count - 1` is exactly representable
188/// in `f64` (i.e. always, for any sane widget).
189#[inline]
190#[must_use]
191pub fn discrete_index(norm: f64, count: usize) -> usize {
192    if count <= 1 {
193        return 0;
194    }
195    let n = norm.clamp(0.0, 1.0);
196    let max_idx = count - 1;
197    (n * max_idx as f64).round() as usize
198}
199
200#[cfg(test)]
201mod tests {
202    // Tests compare exactly-representable float results (0.0, 1.0,
203    // 1/3, etc.) where bit-equality is the contract.
204    #![allow(clippy::float_cmp)]
205
206    use super::*;
207
208    #[test]
209    fn len_u32_basic() {
210        assert_eq!(len_u32(0), 0);
211        assert_eq!(len_u32(127), 127);
212        assert_eq!(len_u32(u32::MAX as usize), u32::MAX);
213    }
214
215    #[test]
216    #[should_panic(expected = "overflows u32")]
217    #[cfg(target_pointer_width = "64")]
218    fn len_u32_overflow_panics_in_debug() {
219        let _ = len_u32(u32::MAX as usize + 1);
220    }
221
222    #[test]
223    fn size_of_u32_basic() {
224        #[repr(C)]
225        struct AbiStruct {
226            _a: u32,
227            _b: u64,
228        }
229        assert_eq!(size_of_u32::<u8>(), 1);
230        assert_eq!(size_of_u32::<u32>(), 4);
231        assert_eq!(size_of_u32::<u64>(), 8);
232        assert_eq!(size_of_u32::<AbiStruct>(), 16);
233    }
234
235    #[test]
236    fn discrete_norm_endpoints() {
237        // 4-option selector: indices 0..=3 map to 0, 1/3, 2/3, 1.
238        assert_eq!(discrete_norm(0, 4), 0.0);
239        assert!((discrete_norm(1, 4) - 1.0 / 3.0).abs() < 1e-12);
240        assert!((discrete_norm(2, 4) - 2.0 / 3.0).abs() < 1e-12);
241        assert_eq!(discrete_norm(3, 4), 1.0);
242    }
243
244    #[test]
245    fn discrete_norm_degenerate_collapses_to_zero() {
246        assert_eq!(discrete_norm(0, 0), 0.0);
247        assert_eq!(discrete_norm(0, 1), 0.0);
248        assert_eq!(discrete_norm(99, 1), 0.0);
249    }
250
251    #[test]
252    fn discrete_norm_clamps_oob_idx() {
253        // idx past count-1 must not produce a normalized > 1.0
254        assert_eq!(discrete_norm(99, 4), 1.0);
255    }
256
257    #[test]
258    fn discrete_index_endpoints() {
259        assert_eq!(discrete_index(0.0, 4), 0);
260        assert_eq!(discrete_index(1.0, 4), 3);
261        // Quarter of the way → first non-zero step.
262        assert_eq!(discrete_index(1.0 / 3.0, 4), 1);
263        assert_eq!(discrete_index(2.0 / 3.0, 4), 2);
264    }
265
266    #[test]
267    fn discrete_index_degenerate_returns_zero() {
268        assert_eq!(discrete_index(0.5, 0), 0);
269        assert_eq!(discrete_index(0.5, 1), 0);
270        assert_eq!(discrete_index(1.0, 1), 0);
271    }
272
273    #[test]
274    fn discrete_index_clamps_oob_norm() {
275        assert_eq!(discrete_index(-0.5, 4), 0);
276        assert_eq!(discrete_index(2.0, 4), 3);
277    }
278
279    #[test]
280    fn sample_pos_i64_basic() {
281        assert_eq!(sample_pos_i64(0.0), 0);
282        assert_eq!(sample_pos_i64(48_000.0), 48_000);
283        assert_eq!(sample_pos_i64(-1.0), -1);
284    }
285
286    #[test]
287    fn sample_pos_i64_saturates_on_non_finite() {
288        assert_eq!(sample_pos_i64(f64::INFINITY), i64::MAX);
289        assert_eq!(sample_pos_i64(f64::NEG_INFINITY), i64::MIN);
290    }
291
292    #[test]
293    fn sample_count_usize_basic() {
294        assert_eq!(sample_count_usize(0.0), 0);
295        assert_eq!(sample_count_usize(48_000.0), 48_000);
296    }
297
298    #[test]
299    fn sample_count_usize_collapses_invalid() {
300        assert_eq!(sample_count_usize(-1.0), 0);
301        assert_eq!(sample_count_usize(f64::NAN), 0);
302        assert_eq!(sample_count_usize(f64::INFINITY), usize::MAX);
303        assert_eq!(sample_count_usize(f64::NEG_INFINITY), 0);
304    }
305
306    #[test]
307    fn frame_count_f64_basic() {
308        assert_eq!(frame_count_f64(0), 0.0);
309        assert_eq!(frame_count_f64(48_000), 48_000.0);
310        // round-trip through sample_count_usize for an exact-rep value
311        assert_eq!(sample_count_usize(frame_count_f64(192_000)), 192_000);
312    }
313
314    #[test]
315    fn sample_rate_u32_basic() {
316        assert_eq!(sample_rate_u32(44_100.0), 44_100);
317        assert_eq!(sample_rate_u32(48_000.0), 48_000);
318        assert_eq!(sample_rate_u32(192_000.0), 192_000);
319    }
320
321    #[test]
322    fn sample_rate_u32_saturates() {
323        assert_eq!(sample_rate_u32(f64::INFINITY), u32::MAX);
324        assert_eq!(sample_rate_u32(f64::from(u32::MAX) * 2.0), u32::MAX);
325    }
326
327    #[test]
328    fn sample_rate_u32_collapses_invalid_in_release() {
329        // Debug builds debug_assert; release returns 0. Tests run in
330        // debug, so guard the assertion behind `cfg(not(debug_assertions))`.
331        #[cfg(not(debug_assertions))]
332        {
333            assert_eq!(sample_rate_u32(-1.0), 0);
334            assert_eq!(sample_rate_u32(f64::NAN), 0);
335            assert_eq!(sample_rate_u32(f64::NEG_INFINITY), 0);
336        }
337    }
338
339    #[test]
340    fn discrete_norm_index_round_trip() {
341        for count in [2usize, 3, 4, 7, 16, 128] {
342            for idx in 0..count {
343                let norm = discrete_norm(idx, count);
344                let back = discrete_index(norm, count);
345                assert_eq!(back, idx, "count={count}, idx={idx}, norm={norm}");
346            }
347        }
348    }
349}