Skip to main content

oxurack_rt/
messages.rs

1//! Message types exchanged between the RT thread and the ECS world.
2//!
3//! These types form the ABI boundary of the lock-free queues. They are pure
4//! value types — small, `Copy`, and allocation-free — designed for zero-cost
5//! transfer across the queue.
6
7/// An event produced by the RT thread for consumption by the ECS world.
8#[derive(Debug, Clone, Copy, PartialEq)]
9pub enum RtEvent {
10    /// A clock tick occurred at the given subdivision of a beat.
11    ClockTick {
12        /// MIDI clock subdivision within the beat (0..23 for 24 PPQN).
13        subdivision: u8,
14        /// Cumulative beat count since transport start.
15        beat: u64,
16        /// Current tempo in beats per minute.
17        tempo_bpm: f64,
18        /// Monotonic timestamp in nanoseconds when the tick occurred.
19        timestamp_ns: u64,
20    },
21
22    /// A transport state change.
23    Transport(TransportEvent),
24
25    /// A MIDI message received on an input port.
26    MidiInput {
27        /// Index of the input port that received the message.
28        input_port_index: u8,
29        /// Monotonic timestamp in nanoseconds when the message arrived.
30        timestamp_ns: u64,
31        /// The MIDI message payload.
32        message: MidiMessage,
33    },
34
35    /// A MIDI Song Position Pointer update.
36    SongPosition {
37        /// 14-bit song position in MIDI beats (6 clocks per beat).
38        position: u16,
39    },
40
41    /// A non-fatal error that the ECS world should be aware of.
42    NonFatalError(RtErrorCode),
43}
44
45/// Transport state change events.
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum TransportEvent {
48    /// Playback started from the beginning.
49    Start,
50    /// Playback stopped.
51    Stop,
52    /// Playback resumed from the current position.
53    Continue,
54}
55
56/// A compact MIDI message representation.
57///
58/// Re-exported from [`oxurack_midi::MidiWire`]. Stores up to 3 bytes
59/// of a MIDI message plus a length indicator. `Copy` and fits in 4
60/// bytes, making it ideal for lock-free queues.
61pub type MidiMessage = oxurack_midi::MidiWire;
62
63/// A command sent from the ECS world to the RT thread.
64#[derive(Debug, Clone, Copy, PartialEq)]
65pub enum EcsCommand {
66    /// Send a MIDI message out on an output port.
67    SendMidi {
68        /// Index of the output port to send on.
69        output_port_index: u8,
70        /// The MIDI message payload.
71        message: MidiMessage,
72    },
73
74    /// Change the master clock tempo.
75    SetTempo {
76        /// New tempo in beats per minute.
77        bpm: f64,
78    },
79
80    /// Send a MIDI transport message (Start/Stop/Continue).
81    SendTransport(TransportEvent),
82
83    /// Send a MIDI Song Position Pointer message.
84    SendSongPosition {
85        /// 14-bit song position in MIDI beats.
86        position: u16,
87    },
88
89    /// Gracefully shut down the RT thread.
90    Shutdown,
91}
92
93// MidiMessage constructors and methods (note_on, note_off, cc,
94// program_change, pitch_bend, from_bytes, to_bytes) are now provided
95// by oxurack_midi::MidiWire, which is re-exported above as MidiMessage.
96
97/// Classification of a raw MIDI byte sequence.
98///
99/// Separates system real-time messages (clock, transport) and system
100/// common messages (song position) from channel voice/mode messages.
101/// This is used internally to route incoming MIDI bytes to the
102/// appropriate handler in the RT thread.
103#[derive(Debug, Clone, Copy, PartialEq)]
104pub(crate) enum MidiClassification {
105    /// MIDI Clock byte (0xF8).
106    Clock,
107    /// Transport Start (0xFA).
108    Start,
109    /// Transport Stop (0xFC).
110    Stop,
111    /// Transport Continue (0xFB).
112    Continue,
113    /// Song Position Pointer (0xF2) with 14-bit position.
114    SongPosition {
115        /// 14-bit song position in MIDI beats (6 clocks per beat).
116        position: u16,
117    },
118    /// Active Sensing (0xFE) — ignored by the system.
119    ActiveSensing,
120    /// System Reset (0xFF) — ignored by the system.
121    SystemReset,
122    /// A channel voice or mode message.
123    Channel(MidiMessage),
124}
125
126/// Classifies a raw MIDI byte sequence.
127///
128/// Returns `None` if the input is empty, the first byte is not a valid
129/// status byte (< 0x80), or the message type is not handled (e.g. SysEx).
130///
131/// # System Real-Time Messages
132///
133/// Single-byte messages that can appear at any time:
134/// - `0xF8` → [`MidiClassification::Clock`]
135/// - `0xFA` → [`MidiClassification::Start`]
136/// - `0xFB` → [`MidiClassification::Continue`]
137/// - `0xFC` → [`MidiClassification::Stop`]
138/// - `0xFE` → [`MidiClassification::ActiveSensing`]
139/// - `0xFF` → [`MidiClassification::SystemReset`]
140///
141/// # System Common Messages
142///
143/// - `0xF2` → [`MidiClassification::SongPosition`] (2 data bytes, 7 bits
144///   each, LSB first)
145///
146/// # Channel Messages
147///
148/// Status bytes `0x80..=0xEF` are delegated to [`MidiMessage::from_bytes`].
149pub(crate) fn classify_midi(bytes: &[u8]) -> Option<MidiClassification> {
150    let &status = bytes.first()?;
151
152    if status < 0x80 {
153        return None; // Data byte without status — running status not handled here
154    }
155
156    match status {
157        0xF8 => Some(MidiClassification::Clock),
158        0xFA => Some(MidiClassification::Start),
159        0xFB => Some(MidiClassification::Continue),
160        0xFC => Some(MidiClassification::Stop),
161        0xFE => Some(MidiClassification::ActiveSensing),
162        0xFF => Some(MidiClassification::SystemReset),
163        0xF2 => {
164            // Song Position Pointer: 2 data bytes, 7 bits each, LSB first.
165            let lsb = *bytes.get(1).unwrap_or(&0);
166            let msb = *bytes.get(2).unwrap_or(&0);
167            let position = (lsb as u16) | ((msb as u16) << 7);
168            Some(MidiClassification::SongPosition { position })
169        }
170        0x80..=0xEF => {
171            // Channel messages: delegate to MidiMessage::from_bytes.
172            let msg = MidiMessage::from_bytes(bytes)?;
173            Some(MidiClassification::Channel(msg))
174        }
175        _ => None, // Other system messages (0xF0 SysEx, 0xF1 MTC, 0xF3 Song Select, etc.)
176    }
177}
178
179/// Error codes for non-fatal conditions reported by the RT thread.
180#[derive(Debug, Clone, Copy, PartialEq, Eq)]
181pub enum RtErrorCode {
182    /// A MIDI output port was disconnected or became unavailable.
183    OutputPortLost,
184    /// A MIDI input port was disconnected or became unavailable.
185    InputPortLost,
186    /// The RT-to-ECS queue overflowed; some events were dropped.
187    QueueOverflow,
188    /// The slave clock has not yet locked to an external clock source.
189    ClockNotLocked,
190    /// The slave clock detected a dropout in the external clock signal.
191    ClockDropout,
192    /// RT priority elevation failed; the thread is running at normal
193    /// OS priority. Timing jitter may be higher than expected.
194    PriorityElevationFailed,
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200    use pretty_assertions::assert_eq;
201
202    // ── Size assertions ──────────────────────────────────────────────
203
204    #[test]
205    fn test_midi_message_size() {
206        assert_eq!(std::mem::size_of::<MidiMessage>(), 4);
207    }
208
209    #[test]
210    fn test_rt_event_fits_cache_line() {
211        assert!(std::mem::size_of::<RtEvent>() <= 64);
212    }
213
214    #[test]
215    fn test_ecs_command_fits_cache_line() {
216        assert!(std::mem::size_of::<EcsCommand>() <= 64);
217    }
218
219    // ── Compile-time trait checks ────────────────────────────────────
220
221    fn _assert_rt_event_is_copy_send()
222    where
223        RtEvent: Copy + Send + 'static,
224    {
225    }
226
227    fn _assert_ecs_command_is_copy_send()
228    where
229        EcsCommand: Copy + Send + 'static,
230    {
231    }
232
233    // ── Constructor tests ────────────────────────────────────────────
234
235    #[test]
236    fn test_note_on() {
237        let msg = MidiMessage::note_on(0, 60, 100);
238        assert_eq!(msg.status, 0x90);
239        assert_eq!(msg.data1, 60);
240        assert_eq!(msg.data2, 100);
241        assert_eq!(msg.length, 3);
242    }
243
244    #[test]
245    fn test_note_off() {
246        let msg = MidiMessage::note_off(1, 64, 0);
247        assert_eq!(msg.status, 0x81);
248        assert_eq!(msg.data1, 64);
249        assert_eq!(msg.data2, 0);
250        assert_eq!(msg.length, 3);
251    }
252
253    #[test]
254    fn test_cc() {
255        let msg = MidiMessage::cc(2, 74, 127);
256        assert_eq!(msg.status, 0xB2);
257        assert_eq!(msg.data1, 74);
258        assert_eq!(msg.data2, 127);
259        assert_eq!(msg.length, 3);
260    }
261
262    #[test]
263    fn test_program_change() {
264        let msg = MidiMessage::program_change(5, 42);
265        assert_eq!(msg.status, 0xC5);
266        assert_eq!(msg.data1, 42);
267        assert_eq!(msg.data2, 0);
268        assert_eq!(msg.length, 2);
269    }
270
271    #[test]
272    fn test_pitch_bend() {
273        let msg = MidiMessage::pitch_bend(0, 0, 64);
274        assert_eq!(msg.status, 0xE0);
275        assert_eq!(msg.data1, 0);
276        assert_eq!(msg.data2, 64);
277        assert_eq!(msg.length, 3);
278    }
279
280    // ── Round-trip tests ─────────────────────────────────────────────
281
282    #[test]
283    fn test_note_on_roundtrip() {
284        let original = MidiMessage::note_on(3, 72, 110);
285        let bytes = original.to_bytes();
286        let reconstructed = MidiMessage::from_bytes(&bytes);
287        assert_eq!(Some(original), reconstructed);
288    }
289
290    #[test]
291    fn test_program_change_roundtrip() {
292        let original = MidiMessage::program_change(7, 99);
293        let bytes = original.to_bytes();
294        let reconstructed = MidiMessage::from_bytes(&bytes);
295        assert_eq!(Some(original), reconstructed);
296    }
297
298    #[test]
299    fn test_from_bytes_empty_returns_none() {
300        assert_eq!(MidiMessage::from_bytes(&[]), None);
301    }
302
303    #[test]
304    fn test_from_bytes_data_byte_returns_none() {
305        assert_eq!(MidiMessage::from_bytes(&[0x7F, 0x60, 0x40]), None);
306    }
307
308    #[test]
309    fn test_from_bytes_system_returns_none() {
310        assert_eq!(MidiMessage::from_bytes(&[0xF0, 0x7E, 0x7F]), None);
311    }
312
313    // ── to_bytes test ────────────────────────────────────────────────
314
315    #[test]
316    fn test_to_bytes_pads_short_messages() {
317        let msg = MidiMessage::program_change(0, 5);
318        let bytes = msg.to_bytes();
319        assert_eq!(bytes, [0xC0, 5, 0]);
320    }
321
322    // ── Classification tests ────────────────────────────────────────
323
324    #[test]
325    fn test_classify_clock() {
326        assert_eq!(classify_midi(&[0xF8]), Some(MidiClassification::Clock));
327    }
328
329    #[test]
330    fn test_classify_start() {
331        assert_eq!(classify_midi(&[0xFA]), Some(MidiClassification::Start));
332    }
333
334    #[test]
335    fn test_classify_stop() {
336        assert_eq!(classify_midi(&[0xFC]), Some(MidiClassification::Stop));
337    }
338
339    #[test]
340    fn test_classify_continue() {
341        assert_eq!(classify_midi(&[0xFB]), Some(MidiClassification::Continue));
342    }
343
344    #[test]
345    fn test_classify_active_sensing() {
346        assert_eq!(
347            classify_midi(&[0xFE]),
348            Some(MidiClassification::ActiveSensing)
349        );
350    }
351
352    #[test]
353    fn test_classify_system_reset() {
354        assert_eq!(
355            classify_midi(&[0xFF]),
356            Some(MidiClassification::SystemReset)
357        );
358    }
359
360    #[test]
361    fn test_classify_note_on() {
362        assert_eq!(
363            classify_midi(&[0x90, 60, 100]),
364            Some(MidiClassification::Channel(MidiMessage {
365                status: 0x90,
366                data1: 60,
367                data2: 100,
368                length: 3,
369            }))
370        );
371    }
372
373    #[test]
374    fn test_classify_program_change() {
375        assert_eq!(
376            classify_midi(&[0xC0, 42]),
377            Some(MidiClassification::Channel(MidiMessage {
378                status: 0xC0,
379                data1: 42,
380                data2: 0,
381                length: 2,
382            }))
383        );
384    }
385
386    #[test]
387    fn test_classify_song_position() {
388        // LSB = 0x10, MSB = 0x02 → position = 0x10 | (0x02 << 7) = 16 + 256 = 272
389        assert_eq!(
390            classify_midi(&[0xF2, 0x10, 0x02]),
391            Some(MidiClassification::SongPosition { position: 272 })
392        );
393    }
394
395    #[test]
396    fn test_classify_empty_returns_none() {
397        assert_eq!(classify_midi(&[]), None);
398    }
399
400    #[test]
401    fn test_classify_data_byte_returns_none() {
402        assert_eq!(classify_midi(&[0x60]), None);
403    }
404
405    #[test]
406    fn test_classify_sysex_returns_none() {
407        assert_eq!(classify_midi(&[0xF0, 0x7E, 0xF7]), None);
408    }
409
410    // ── Additional system message classification tests ──────────────
411
412    #[test]
413    fn test_classify_mtc_quarter_frame() {
414        // MTC Quarter Frame (0xF1) is not handled; returns None.
415        assert_eq!(classify_midi(&[0xF1, 0x00]), None);
416    }
417
418    #[test]
419    fn test_classify_song_select() {
420        // Song Select (0xF3) is not handled; returns None.
421        assert_eq!(classify_midi(&[0xF3, 0x00]), None);
422    }
423
424    #[test]
425    fn test_classify_tune_request() {
426        // Tune Request (0xF6) is not handled; returns None.
427        assert_eq!(classify_midi(&[0xF6]), None);
428    }
429
430    // ── from_bytes edge cases ──────────────────────────────────────
431
432    #[test]
433    fn test_from_bytes_note_on_missing_data2() {
434        // Note On with only status + data1 (no velocity byte).
435        // Should still return Some, padding data2 with 0.
436        let msg = MidiMessage::from_bytes(&[0x90, 60]);
437        assert!(msg.is_some(), "should parse partial Note On");
438        let msg = msg.unwrap();
439        assert_eq!(msg.status, 0x90);
440        assert_eq!(msg.data1, 60);
441        assert_eq!(msg.data2, 0);
442        assert_eq!(msg.length, 3);
443    }
444
445    #[test]
446    fn test_from_bytes_single_status_byte() {
447        // Note On with only the status byte (no data bytes at all).
448        // Should still return Some, padding both data bytes with 0.
449        let msg = MidiMessage::from_bytes(&[0x90]);
450        assert!(msg.is_some(), "should parse status-only Note On");
451        let msg = msg.unwrap();
452        assert_eq!(msg.status, 0x90);
453        assert_eq!(msg.data1, 0);
454        assert_eq!(msg.data2, 0);
455        assert_eq!(msg.length, 3);
456    }
457
458    #[test]
459    fn test_from_bytes_program_change_single_byte() {
460        // Program Change with only the status byte.
461        let msg = MidiMessage::from_bytes(&[0xC0]);
462        assert!(msg.is_some(), "should parse status-only Program Change");
463        let msg = msg.unwrap();
464        assert_eq!(msg.status, 0xC0);
465        assert_eq!(msg.data1, 0);
466        assert_eq!(msg.data2, 0);
467        assert_eq!(msg.length, 2);
468    }
469
470    // ── to_bytes for all message types ─────────────────────────────
471
472    #[test]
473    fn test_to_bytes_note_on() {
474        let msg = MidiMessage::note_on(0, 60, 100);
475        assert_eq!(msg.to_bytes(), [0x90, 60, 100]);
476    }
477
478    #[test]
479    fn test_to_bytes_note_off() {
480        let msg = MidiMessage::note_off(1, 64, 0);
481        assert_eq!(msg.to_bytes(), [0x81, 64, 0]);
482    }
483
484    #[test]
485    fn test_to_bytes_cc() {
486        let msg = MidiMessage::cc(2, 74, 127);
487        assert_eq!(msg.to_bytes(), [0xB2, 74, 127]);
488    }
489
490    #[test]
491    fn test_to_bytes_program_change() {
492        let msg = MidiMessage::program_change(5, 42);
493        assert_eq!(msg.to_bytes(), [0xC5, 42, 0]);
494    }
495
496    #[test]
497    fn test_to_bytes_pitch_bend() {
498        let msg = MidiMessage::pitch_bend(0, 0, 64);
499        assert_eq!(msg.to_bytes(), [0xE0, 0, 64]);
500    }
501
502    // ── Song Position edge cases ───────────────────────────────────
503
504    #[test]
505    fn test_classify_song_position_zero() {
506        assert_eq!(
507            classify_midi(&[0xF2, 0x00, 0x00]),
508            Some(MidiClassification::SongPosition { position: 0 })
509        );
510    }
511
512    #[test]
513    fn test_classify_song_position_max() {
514        // Maximum 14-bit value: LSB=0x7F, MSB=0x7F → 0x3FFF = 16383
515        assert_eq!(
516            classify_midi(&[0xF2, 0x7F, 0x7F]),
517            Some(MidiClassification::SongPosition { position: 16383 })
518        );
519    }
520
521    #[test]
522    fn test_classify_song_position_missing_bytes() {
523        // SPP with no data bytes: should default to position 0.
524        assert_eq!(
525            classify_midi(&[0xF2]),
526            Some(MidiClassification::SongPosition { position: 0 })
527        );
528    }
529
530    // ── Channel Pressure (0xD0) ────────────────────────────────────
531
532    #[test]
533    fn test_classify_channel_pressure() {
534        // Channel Pressure is a 2-byte channel message (0xD0..0xDF).
535        let result = classify_midi(&[0xD0, 100]);
536        assert_eq!(
537            result,
538            Some(MidiClassification::Channel(MidiMessage {
539                status: 0xD0,
540                data1: 100,
541                data2: 0,
542                length: 2,
543            }))
544        );
545    }
546}