Skip to main content

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}