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.
99///
100/// Mirrors the `is_finite && >= 0` guard pattern that `truce-driver`
101/// open-coded across its offline-render path. NaN and negative
102/// inputs collapse to `0`; positive infinity and any value past
103/// `usize::MAX` clamp to `usize::MAX`.
104#[inline]
105#[must_use]
106pub fn sample_count_usize(v: f64) -> usize {
107    if v.is_nan() || v <= 0.0 {
108        return 0;
109    }
110    if v >= usize::MAX as f64 {
111        return usize::MAX;
112    }
113    v as usize
114}
115
116/// Inverse of [`sample_count_usize`]: convert a sample/frame count
117/// to `f64` for time math (`frames / sample_rate` → seconds,
118/// `frames * ratio` → resampled length, etc.).
119///
120/// `f64`'s 52-bit mantissa holds counts exactly up to ~9 × 10¹⁵; at
121/// 192 kHz that's ~1500 years of audio, so the precision-loss
122/// warning is irrelevant in practice. Lives in `cast` because the
123/// shape repeats across the offline render and resampler in
124/// `truce-standalone`.
125#[inline]
126#[must_use]
127pub fn frame_count_f64(n: usize) -> f64 {
128    n as f64
129}
130
131/// Cast a host-supplied sample rate (`f64`) to the `u32` audio APIs
132/// (`cpal`, `hound`, Core Audio's `AudioStreamBasicDescription`) carry.
133///
134/// Audio sample rates are positive and bounded — 192 kHz is the
135/// highest in mainstream use, far below `u32::MAX`. NaN and
136/// negative inputs debug-assert; release builds clamp to `0` so a
137/// garbage host doesn't produce undefined behavior at the FFI
138/// boundary.
139#[inline]
140#[must_use]
141pub fn sample_rate_u32(rate: f64) -> u32 {
142    debug_assert!(
143        !rate.is_nan() && rate >= 0.0,
144        "sample_rate_u32: invalid rate {rate} — host sample rate is uninitialized?",
145    );
146    if rate.is_nan() || rate < 0.0 {
147        return 0;
148    }
149    if rate >= f64::from(u32::MAX) {
150        return u32::MAX;
151    }
152    rate as u32
153}
154
155/// Map a discrete index in `[0, count - 1]` to a normalized value
156/// in `[0.0, 1.0]`. Returns `0.0` when `count <= 1` — there's only
157/// one valid index, so any input collapses to the bottom of the
158/// range.
159///
160/// `idx` is clamped to `count - 1` before scaling so an off-by-one
161/// caller can't produce a normalized value above `1.0`. The output
162/// is `f64` because the host-facing param surface
163/// (`Params::set_normalized`) is `f64`; widget code that needs
164/// `f32` should cast at the call site.
165///
166/// Inverse of [`discrete_index`]. Together they are the canonical
167/// place selector / dropdown widgets bridge integer option indices
168/// to normalized parameter values.
169#[inline]
170#[must_use]
171pub fn discrete_norm(idx: usize, count: usize) -> f64 {
172    if count <= 1 {
173        return 0.0;
174    }
175    let max_idx = count - 1;
176    idx.min(max_idx) as f64 / max_idx as f64
177}
178
179/// Map a normalized value in `[0.0, 1.0]` to a discrete index in
180/// `[0, count - 1]`. Returns `0` when `count <= 1` — the index is
181/// pinned to the only valid slot.
182///
183/// `norm` is clamped to `[0.0, 1.0]` before scaling so an
184/// out-of-range host (e.g. a VST3 host that sends `1.0001`) can't
185/// produce an out-of-range index. Rounding is half-to-even via
186/// `f64::round`, the same rule applied across the param taper code
187/// in `truce_params::range`.
188///
189/// Inverse of [`discrete_norm`]; round-trips for every `idx ∈
190/// [0, count - 1]` whenever `count - 1` is exactly representable
191/// in `f64` (i.e. always, for any sane widget).
192#[inline]
193#[must_use]
194pub fn discrete_index(norm: f64, count: usize) -> usize {
195    if count <= 1 {
196        return 0;
197    }
198    let n = norm.clamp(0.0, 1.0);
199    let max_idx = count - 1;
200    (n * max_idx as f64).round() as usize
201}
202
203#[cfg(test)]
204mod tests {
205    // Tests compare exactly-representable float results (0.0, 1.0,
206    // 1/3, etc.) where bit-equality is the contract.
207    #![allow(clippy::float_cmp)]
208
209    use super::*;
210
211    #[test]
212    fn len_u32_basic() {
213        assert_eq!(len_u32(0), 0);
214        assert_eq!(len_u32(127), 127);
215        assert_eq!(len_u32(u32::MAX as usize), u32::MAX);
216    }
217
218    #[test]
219    #[should_panic(expected = "overflows u32")]
220    #[cfg(target_pointer_width = "64")]
221    fn len_u32_overflow_panics_in_debug() {
222        let _ = len_u32(u32::MAX as usize + 1);
223    }
224
225    #[test]
226    fn size_of_u32_basic() {
227        #[repr(C)]
228        struct AbiStruct {
229            _a: u32,
230            _b: u64,
231        }
232        assert_eq!(size_of_u32::<u8>(), 1);
233        assert_eq!(size_of_u32::<u32>(), 4);
234        assert_eq!(size_of_u32::<u64>(), 8);
235        assert_eq!(size_of_u32::<AbiStruct>(), 16);
236    }
237
238    #[test]
239    fn discrete_norm_endpoints() {
240        // 4-option selector: indices 0..=3 map to 0, 1/3, 2/3, 1.
241        assert_eq!(discrete_norm(0, 4), 0.0);
242        assert!((discrete_norm(1, 4) - 1.0 / 3.0).abs() < 1e-12);
243        assert!((discrete_norm(2, 4) - 2.0 / 3.0).abs() < 1e-12);
244        assert_eq!(discrete_norm(3, 4), 1.0);
245    }
246
247    #[test]
248    fn discrete_norm_degenerate_collapses_to_zero() {
249        assert_eq!(discrete_norm(0, 0), 0.0);
250        assert_eq!(discrete_norm(0, 1), 0.0);
251        assert_eq!(discrete_norm(99, 1), 0.0);
252    }
253
254    #[test]
255    fn discrete_norm_clamps_oob_idx() {
256        // idx past count-1 must not produce a normalized > 1.0
257        assert_eq!(discrete_norm(99, 4), 1.0);
258    }
259
260    #[test]
261    fn discrete_index_endpoints() {
262        assert_eq!(discrete_index(0.0, 4), 0);
263        assert_eq!(discrete_index(1.0, 4), 3);
264        // Quarter of the way → first non-zero step.
265        assert_eq!(discrete_index(1.0 / 3.0, 4), 1);
266        assert_eq!(discrete_index(2.0 / 3.0, 4), 2);
267    }
268
269    #[test]
270    fn discrete_index_degenerate_returns_zero() {
271        assert_eq!(discrete_index(0.5, 0), 0);
272        assert_eq!(discrete_index(0.5, 1), 0);
273        assert_eq!(discrete_index(1.0, 1), 0);
274    }
275
276    #[test]
277    fn discrete_index_clamps_oob_norm() {
278        assert_eq!(discrete_index(-0.5, 4), 0);
279        assert_eq!(discrete_index(2.0, 4), 3);
280    }
281
282    #[test]
283    fn sample_pos_i64_basic() {
284        assert_eq!(sample_pos_i64(0.0), 0);
285        assert_eq!(sample_pos_i64(48_000.0), 48_000);
286        assert_eq!(sample_pos_i64(-1.0), -1);
287    }
288
289    #[test]
290    fn sample_pos_i64_saturates_on_non_finite() {
291        assert_eq!(sample_pos_i64(f64::INFINITY), i64::MAX);
292        assert_eq!(sample_pos_i64(f64::NEG_INFINITY), i64::MIN);
293    }
294
295    #[test]
296    fn sample_count_usize_basic() {
297        assert_eq!(sample_count_usize(0.0), 0);
298        assert_eq!(sample_count_usize(48_000.0), 48_000);
299    }
300
301    #[test]
302    fn sample_count_usize_collapses_invalid() {
303        assert_eq!(sample_count_usize(-1.0), 0);
304        assert_eq!(sample_count_usize(f64::NAN), 0);
305        assert_eq!(sample_count_usize(f64::INFINITY), usize::MAX);
306        assert_eq!(sample_count_usize(f64::NEG_INFINITY), 0);
307    }
308
309    #[test]
310    fn frame_count_f64_basic() {
311        assert_eq!(frame_count_f64(0), 0.0);
312        assert_eq!(frame_count_f64(48_000), 48_000.0);
313        // round-trip through sample_count_usize for an exact-rep value
314        assert_eq!(sample_count_usize(frame_count_f64(192_000)), 192_000);
315    }
316
317    #[test]
318    fn sample_rate_u32_basic() {
319        assert_eq!(sample_rate_u32(44_100.0), 44_100);
320        assert_eq!(sample_rate_u32(48_000.0), 48_000);
321        assert_eq!(sample_rate_u32(192_000.0), 192_000);
322    }
323
324    #[test]
325    fn sample_rate_u32_saturates() {
326        assert_eq!(sample_rate_u32(f64::INFINITY), u32::MAX);
327        assert_eq!(sample_rate_u32(f64::from(u32::MAX) * 2.0), u32::MAX);
328    }
329
330    #[test]
331    fn sample_rate_u32_collapses_invalid_in_release() {
332        // Debug builds debug_assert; release returns 0. Tests run in
333        // debug, so guard the assertion behind `cfg(not(debug_assertions))`.
334        #[cfg(not(debug_assertions))]
335        {
336            assert_eq!(sample_rate_u32(-1.0), 0);
337            assert_eq!(sample_rate_u32(f64::NAN), 0);
338            assert_eq!(sample_rate_u32(f64::NEG_INFINITY), 0);
339        }
340    }
341
342    #[test]
343    fn discrete_norm_index_round_trip() {
344        for count in [2usize, 3, 4, 7, 16, 128] {
345            for idx in 0..count {
346                let norm = discrete_norm(idx, count);
347                let back = discrete_index(norm, count);
348                assert_eq!(back, idx, "count={count}, idx={idx}, norm={norm}");
349            }
350        }
351    }
352}