Skip to main content

phosphor_midi/
message.rs

1//! MIDI message types — lightweight, copy-friendly, real-time safe.
2
3/// A timestamped MIDI message. Small enough to pass through a ring buffer.
4#[derive(Debug, Clone, Copy)]
5pub struct MidiMessage {
6    /// When this message was received (high-resolution).
7    pub timestamp: Option<u64>, // nanoseconds from midir
8    /// The parsed message type.
9    pub message_type: MidiMessageType,
10    /// Raw bytes for forwarding (up to 3 bytes for channel messages).
11    pub raw: [u8; 3],
12    /// Number of valid bytes in `raw`.
13    pub len: u8,
14}
15
16/// Parsed MIDI message types we care about.
17#[derive(Debug, Clone, Copy, PartialEq)]
18pub enum MidiMessageType {
19    NoteOn {
20        channel: u8,
21        note: u8,
22        velocity: u8,
23    },
24    NoteOff {
25        channel: u8,
26        note: u8,
27        velocity: u8,
28    },
29    ControlChange {
30        channel: u8,
31        controller: u8,
32        value: u8,
33    },
34    PitchBend {
35        channel: u8,
36        /// 14-bit value: 0..16383, center = 8192
37        value: u16,
38    },
39    ProgramChange {
40        channel: u8,
41        program: u8,
42    },
43    ChannelPressure {
44        channel: u8,
45        pressure: u8,
46    },
47    /// Anything we don't specifically parse.
48    Other,
49}
50
51impl MidiMessage {
52    /// Parse a raw MIDI byte slice into a MidiMessage.
53    /// Returns None only if the slice is empty.
54    pub fn from_bytes(bytes: &[u8], timestamp: u64) -> Option<Self> {
55        if bytes.is_empty() {
56            return None;
57        }
58
59        let mut raw = [0u8; 3];
60        let len = bytes.len().min(3);
61        raw[..len].copy_from_slice(&bytes[..len]);
62
63        let message_type = Self::parse_type(bytes);
64
65        Some(Self {
66            timestamp: Some(timestamp),
67            message_type,
68            raw,
69            len: len as u8,
70        })
71    }
72
73    fn parse_type(bytes: &[u8]) -> MidiMessageType {
74        if bytes.is_empty() {
75            return MidiMessageType::Other;
76        }
77
78        let status = bytes[0];
79        let kind = status & 0xF0;
80        let channel = status & 0x0F;
81
82        match kind {
83            0x90 if bytes.len() >= 3 => {
84                if bytes[2] == 0 {
85                    // Note-on with velocity 0 = note-off
86                    MidiMessageType::NoteOff {
87                        channel,
88                        note: bytes[1],
89                        velocity: 0,
90                    }
91                } else {
92                    MidiMessageType::NoteOn {
93                        channel,
94                        note: bytes[1],
95                        velocity: bytes[2],
96                    }
97                }
98            }
99            0x80 if bytes.len() >= 3 => MidiMessageType::NoteOff {
100                channel,
101                note: bytes[1],
102                velocity: bytes[2],
103            },
104            0xB0 if bytes.len() >= 3 => MidiMessageType::ControlChange {
105                channel,
106                controller: bytes[1],
107                value: bytes[2],
108            },
109            0xE0 if bytes.len() >= 3 => {
110                let value = (bytes[2] as u16) << 7 | (bytes[1] as u16);
111                MidiMessageType::PitchBend { channel, value }
112            }
113            0xC0 if bytes.len() >= 2 => MidiMessageType::ProgramChange {
114                channel,
115                program: bytes[1],
116            },
117            0xD0 if bytes.len() >= 2 => MidiMessageType::ChannelPressure {
118                channel,
119                pressure: bytes[1],
120            },
121            _ => MidiMessageType::Other,
122        }
123    }
124
125    /// MIDI note number to frequency (A4 = 440 Hz).
126    pub fn note_to_freq(note: u8) -> f64 {
127        440.0 * 2.0f64.powf((note as f64 - 69.0) / 12.0)
128    }
129
130    /// MIDI note number to name (e.g., 60 → "C4").
131    pub fn note_to_name(note: u8) -> String {
132        const NAMES: [&str; 12] = [
133            "C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B",
134        ];
135        let octave = (note as i8 / 12) - 1;
136        let name = NAMES[note as usize % 12];
137        format!("{name}{octave}")
138    }
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144
145    #[test]
146    fn parse_note_on() {
147        let msg = MidiMessage::from_bytes(&[0x90, 60, 100], 0).unwrap();
148        assert_eq!(
149            msg.message_type,
150            MidiMessageType::NoteOn {
151                channel: 0,
152                note: 60,
153                velocity: 100,
154            }
155        );
156    }
157
158    #[test]
159    fn parse_note_on_velocity_zero_is_note_off() {
160        let msg = MidiMessage::from_bytes(&[0x90, 60, 0], 0).unwrap();
161        assert_eq!(
162            msg.message_type,
163            MidiMessageType::NoteOff {
164                channel: 0,
165                note: 60,
166                velocity: 0,
167            }
168        );
169    }
170
171    #[test]
172    fn parse_note_off() {
173        let msg = MidiMessage::from_bytes(&[0x80, 60, 64], 0).unwrap();
174        assert_eq!(
175            msg.message_type,
176            MidiMessageType::NoteOff {
177                channel: 0,
178                note: 60,
179                velocity: 64,
180            }
181        );
182    }
183
184    #[test]
185    fn parse_control_change() {
186        let msg = MidiMessage::from_bytes(&[0xB3, 7, 127], 0).unwrap();
187        assert_eq!(
188            msg.message_type,
189            MidiMessageType::ControlChange {
190                channel: 3,
191                controller: 7,
192                value: 127,
193            }
194        );
195    }
196
197    #[test]
198    fn parse_pitch_bend() {
199        // Center position: LSB=0, MSB=64 → value = 8192
200        let msg = MidiMessage::from_bytes(&[0xE0, 0, 64], 0).unwrap();
201        assert_eq!(
202            msg.message_type,
203            MidiMessageType::PitchBend {
204                channel: 0,
205                value: 8192,
206            }
207        );
208    }
209
210    #[test]
211    fn parse_pitch_bend_extremes() {
212        // Minimum
213        let msg = MidiMessage::from_bytes(&[0xE0, 0, 0], 0).unwrap();
214        if let MidiMessageType::PitchBend { value, .. } = msg.message_type {
215            assert_eq!(value, 0);
216        }
217        // Maximum
218        let msg = MidiMessage::from_bytes(&[0xE0, 127, 127], 0).unwrap();
219        if let MidiMessageType::PitchBend { value, .. } = msg.message_type {
220            assert_eq!(value, 16383);
221        }
222    }
223
224    #[test]
225    fn parse_program_change() {
226        let msg = MidiMessage::from_bytes(&[0xC5, 42], 0).unwrap();
227        assert_eq!(
228            msg.message_type,
229            MidiMessageType::ProgramChange {
230                channel: 5,
231                program: 42,
232            }
233        );
234    }
235
236    #[test]
237    fn parse_empty_returns_none() {
238        assert!(MidiMessage::from_bytes(&[], 0).is_none());
239    }
240
241    #[test]
242    fn parse_truncated_note_on_is_other() {
243        let msg = MidiMessage::from_bytes(&[0x90], 0).unwrap();
244        assert_eq!(msg.message_type, MidiMessageType::Other);
245    }
246
247    #[test]
248    fn parse_unknown_status_is_other() {
249        let msg = MidiMessage::from_bytes(&[0xF0, 0x7E], 0).unwrap();
250        assert_eq!(msg.message_type, MidiMessageType::Other);
251    }
252
253    #[test]
254    fn note_to_freq_a4() {
255        let freq = MidiMessage::note_to_freq(69);
256        assert!((freq - 440.0).abs() < 0.01);
257    }
258
259    #[test]
260    fn note_to_freq_middle_c() {
261        let freq = MidiMessage::note_to_freq(60);
262        assert!((freq - 261.63).abs() < 0.1);
263    }
264
265    #[test]
266    fn note_to_name_middle_c() {
267        assert_eq!(MidiMessage::note_to_name(60), "C4");
268    }
269
270    #[test]
271    fn note_to_name_a4() {
272        assert_eq!(MidiMessage::note_to_name(69), "A4");
273    }
274
275    #[test]
276    fn all_channels_parsed_correctly() {
277        for ch in 0..16u8 {
278            let msg = MidiMessage::from_bytes(&[0x90 | ch, 60, 100], 0).unwrap();
279            if let MidiMessageType::NoteOn { channel, .. } = msg.message_type {
280                assert_eq!(channel, ch);
281            } else {
282                panic!("Expected NoteOn for channel {ch}");
283            }
284        }
285    }
286
287    #[test]
288    fn raw_bytes_preserved() {
289        let msg = MidiMessage::from_bytes(&[0x90, 60, 100], 12345).unwrap();
290        assert_eq!(msg.raw[0], 0x90);
291        assert_eq!(msg.raw[1], 60);
292        assert_eq!(msg.raw[2], 100);
293        assert_eq!(msg.len, 3);
294        assert_eq!(msg.timestamp, Some(12345));
295    }
296}