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}