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}