truce_utils/midi.rs
1//! MIDI value-domain helpers: normalize / denormalize between
2//! wire-native integers and `f32` ranges.
3//!
4//! truce's `EventBody` carries MIDI events as wire-native integers
5//! (7-bit `u8`, 14-bit `u16`, 16-bit `u16`, 32-bit `u32`) so the
6//! framework's representation round-trips exactly with the wire.
7//! Plugin code that wants to multiply by a parameter, accumulate
8//! into a phase, or otherwise use the value as a float reaches for
9//! the helpers below.
10//!
11//! Each pair (`norm_*` / `denorm_*`) round-trips for every
12//! representable wire input. See the per-helper docs for endpoint
13//! semantics - pitch-bend is asymmetric on both MIDI 1.0 and MIDI
14//! 2.0 because the spec's center value sits one code closer to the
15//! negative end than the positive.
16//!
17//! Lints: the helpers do `as`-casts at well-defined widening or
18//! lossless points (`u8 → f32`, `u16 → f32`, `f64 → f32` after
19//! a clamped multiply), so the `cast_*` lints are allowed at the
20//! module level rather than per call.
21
22#![allow(
23 clippy::cast_possible_truncation,
24 clippy::cast_sign_loss,
25 clippy::cast_precision_loss
26)]
27
28// ---------------------------------------------------------------------------
29// 7-bit (MIDI 1.0 velocity / CC / aftertouch / channel pressure / program)
30// ---------------------------------------------------------------------------
31
32/// MIDI 1.0 7-bit unsigned (`0..=127`) → `f32 ∈ [0.0, 1.0]`.
33///
34/// `norm_7bit(0) == 0.0`, `norm_7bit(127) == 1.0`. Inputs above 127
35/// debug-assert: the high bit is reserved as the MIDI status flag,
36/// so a value here is a sign of caller bug (the wrapper-level demux
37/// already strips the status bit).
38#[inline]
39#[must_use]
40pub fn norm_7bit(v: u8) -> f32 {
41 debug_assert!(
42 v <= 127,
43 "norm_7bit: {v} > 127 (high bit is the MIDI status flag)",
44 );
45 f32::from(v) / 127.0
46}
47
48/// `f32 ∈ [0.0, 1.0]` → MIDI 1.0 7-bit unsigned (`0..=127`).
49///
50/// Clamps and rounds half-to-even. Negative inputs land on `0`;
51/// inputs ≥ 1.0 land on `127`. NaN debug-asserts; release builds
52/// land on `0` (clamp returns the lower bound for unordered input).
53#[inline]
54#[must_use]
55pub fn denorm_7bit(v: f32) -> u8 {
56 debug_assert!(
57 !v.is_nan(),
58 "denorm_7bit: NaN input - caller's normalized value is uninitialized?",
59 );
60 (v.clamp(0.0, 1.0) * 127.0).round() as u8
61}
62
63// ---------------------------------------------------------------------------
64// 14-bit pitch bend (MIDI 1.0)
65// ---------------------------------------------------------------------------
66
67/// MIDI 1.0 14-bit pitch-bend code (`0..=16383`) → `f32 ∈ [-1.0,
68/// ~0.99987]`.
69///
70/// Center is `8192`. The mapping is asymmetric (8192 negative
71/// codes, 8191 positive codes) because that is the MIDI 1.0
72/// convention: `0` decodes to exactly `-1.0`, but the positive
73/// endpoint stops at `8191/8192`. Inputs above 16383 debug-assert.
74///
75/// Round-trips exactly with [`denorm_pitch_bend`] for every
76/// `raw ∈ [0, 16383]`.
77#[inline]
78#[must_use]
79pub fn norm_pitch_bend(raw: u16) -> f32 {
80 debug_assert!(
81 raw <= 16383,
82 "norm_pitch_bend: raw {raw} > 16383 - caller didn't mask LSB|MSB<<7?",
83 );
84 (f32::from(raw) - 8192.0) / 8192.0
85}
86
87/// `f32 ∈ [-1.0, 1.0]` → MIDI 1.0 14-bit pitch-bend code
88/// (`0..=16383`).
89///
90/// Inverse of [`norm_pitch_bend`]. `-1.0` → `0`, `0.0` → `8192`,
91/// `1.0` → `16383` (clamped - the perfectly symmetric `+1.0`
92/// would be `16384`). NaN debug-asserts.
93#[inline]
94#[must_use]
95pub fn denorm_pitch_bend(v: f32) -> u16 {
96 debug_assert!(
97 !v.is_nan(),
98 "denorm_pitch_bend: NaN input - caller's normalized value is uninitialized?",
99 );
100 let raw = (v.clamp(-1.0, 1.0) * 8192.0 + 8192.0).round();
101 (raw as u16).min(16383)
102}
103
104/// Split a 14-bit pitch-bend code into the (LSB, MSB) byte pair the
105/// wire format carries. Each output byte has the high bit clear.
106///
107/// Used by every format wrapper's MIDI 1.0 output path. Unifies the
108/// `(raw & 0x7F) as u8` / `((raw >> 7) & 0x7F) as u8` magic-constant
109/// split that previously lived in six places.
110#[inline]
111#[must_use]
112pub fn pitch_bend_to_bytes(raw: u16) -> (u8, u8) {
113 debug_assert!(raw <= 16383, "pitch_bend_to_bytes: raw {raw} > 16383");
114 let lsb = (raw & 0x7F) as u8;
115 let msb = ((raw >> 7) & 0x7F) as u8;
116 (lsb, msb)
117}
118
119/// Combine two MIDI bytes (LSB first) into a 14-bit pitch-bend code.
120/// Each input byte's high bit is masked off before combining.
121///
122/// Inverse of [`pitch_bend_to_bytes`]. The masking matters: a
123/// running-status parser may hand bytes that include the status
124/// flag, and `(msb << 7) | lsb` without masking would corrupt the
125/// result on out-of-domain input.
126#[inline]
127#[must_use]
128pub fn pitch_bend_from_bytes(lsb: u8, msb: u8) -> u16 {
129 (u16::from(msb & 0x7F) << 7) | u16::from(lsb & 0x7F)
130}
131
132#[cfg(test)]
133#[allow(clippy::float_cmp)]
134mod tests {
135 use super::*;
136
137 // ---------- 7-bit ----------
138
139 #[test]
140 fn norm_7bit_endpoints() {
141 assert_eq!(norm_7bit(0), 0.0);
142 assert_eq!(norm_7bit(127), 1.0);
143 assert!((norm_7bit(64) - (64.0 / 127.0)).abs() < f32::EPSILON);
144 }
145
146 #[test]
147 fn denorm_7bit_endpoints() {
148 assert_eq!(denorm_7bit(0.0), 0);
149 assert_eq!(denorm_7bit(1.0), 127);
150 assert_eq!(denorm_7bit(0.5), 64); // round-half-to-even via .round()
151 }
152
153 #[test]
154 fn denorm_7bit_clamps() {
155 assert_eq!(denorm_7bit(-0.5), 0);
156 assert_eq!(denorm_7bit(2.0), 127);
157 assert_eq!(denorm_7bit(f32::INFINITY), 127);
158 assert_eq!(denorm_7bit(f32::NEG_INFINITY), 0);
159 }
160
161 #[test]
162 fn round_trip_7bit_all_codes() {
163 // Every representable 7-bit value normalizes and denormalizes
164 // back to itself.
165 for raw in 0u8..=127 {
166 assert_eq!(denorm_7bit(norm_7bit(raw)), raw);
167 }
168 }
169
170 // ---------- 14-bit pitch bend ----------
171
172 #[test]
173 fn norm_pitch_bend_endpoints() {
174 assert_eq!(norm_pitch_bend(0), -1.0);
175 assert_eq!(norm_pitch_bend(8192), 0.0);
176 // Asymmetric positive endpoint: 8191 / 8192 ≈ 0.99987.
177 let max_pos = norm_pitch_bend(16383);
178 assert!((max_pos - 8191.0_f32 / 8192.0_f32).abs() < f32::EPSILON);
179 }
180
181 #[test]
182 fn denorm_pitch_bend_endpoints() {
183 assert_eq!(denorm_pitch_bend(-1.0), 0);
184 assert_eq!(denorm_pitch_bend(0.0), 8192);
185 assert_eq!(denorm_pitch_bend(1.0), 16383);
186 }
187
188 #[test]
189 fn round_trip_pitch_bend_all_codes() {
190 for raw in 0u16..=16383 {
191 let v = norm_pitch_bend(raw);
192 let back = denorm_pitch_bend(v);
193 assert_eq!(back, raw, "raw={raw}, v={v}");
194 }
195 }
196
197 #[test]
198 fn pitch_bend_byte_split_round_trip() {
199 for raw in 0u16..=16383 {
200 let (lsb, msb) = pitch_bend_to_bytes(raw);
201 assert!(lsb < 128 && msb < 128);
202 assert_eq!(pitch_bend_from_bytes(lsb, msb), raw);
203 }
204 }
205
206 #[test]
207 fn pitch_bend_from_bytes_masks_high_bit() {
208 // Status-flag bits in either byte must not corrupt the result.
209 assert_eq!(pitch_bend_from_bytes(0xFF, 0xFF), 16383);
210 assert_eq!(pitch_bend_from_bytes(0x80, 0x80), 0);
211 }
212}