app_lib/
midi.rs

1//! MIDI file parser — extracts metadata from Standard MIDI Files (.mid/.midi).
2//!
3//! Parses the MThd header and MTrk track chunks to extract:
4//! - Format type (0=single track, 1=multi-track, 2=independent tracks)
5//! - Track count
6//! - Tempo (BPM from meta event 0x51)
7//! - Time signature (from meta event 0x58)
8//! - Note count (total note-on events)
9//! - Duration (in seconds, computed from tempo + tick count)
10//! - Key signature (from meta event 0x59)
11//! - Track names (from meta event 0x03)
12
13use serde::Serialize;
14use std::path::Path;
15
16#[derive(Debug, Clone, Serialize, Default)]
17pub struct MidiInfo {
18    pub format: u16,
19    #[serde(rename = "trackCount")]
20    pub track_count: u16,
21    pub ppqn: u16,
22    pub tempo: f64,
23    #[serde(rename = "timeSignature")]
24    pub time_signature: String,
25    #[serde(rename = "keySignature")]
26    pub key_signature: String,
27    #[serde(rename = "noteCount")]
28    pub note_count: u32,
29    pub duration: f64,
30    #[serde(rename = "trackNames")]
31    pub track_names: Vec<String>,
32    #[serde(rename = "channelsUsed")]
33    pub channels_used: u16,
34}
35
36/// Parse a MIDI file and return metadata.
37pub fn parse_midi(path: &Path) -> Option<MidiInfo> {
38    let data = std::fs::read(path).ok()?;
39    if data.len() < 14 {
40        return None;
41    }
42
43    // MThd header: "MThd" + 4-byte length + format + tracks + division
44    if &data[0..4] != b"MThd" {
45        return None;
46    }
47    let header_len = u32::from_be_bytes([data[4], data[5], data[6], data[7]]) as usize;
48    if data.len() < 8 + header_len {
49        return None;
50    }
51
52    let format = u16::from_be_bytes([data[8], data[9]]);
53    let track_count = u16::from_be_bytes([data[10], data[11]]);
54    let division = u16::from_be_bytes([data[12], data[13]]);
55
56    // Division: if bit 15 is 0, it's ticks per quarter note (PPQN)
57    let ppqn = if division & 0x8000 == 0 {
58        division
59    } else {
60        480
61    }; // default 480 for SMPTE
62
63    let mut info = MidiInfo {
64        format,
65        track_count,
66        ppqn,
67        tempo: 120.0, // default BPM
68        time_signature: "4/4".into(),
69        ..Default::default()
70    };
71
72    let mut channel_mask = 0u16;
73    let mut total_ticks = 0u32;
74    let mut tempo_us = 500_000u32; // default: 120 BPM = 500000 µs/beat
75
76    // Parse track chunks
77    let mut pos = 8 + header_len;
78    for _ in 0..track_count {
79        if pos + 8 > data.len() {
80            break;
81        }
82        if &data[pos..pos + 4] != b"MTrk" {
83            break;
84        }
85        let track_len =
86            u32::from_be_bytes([data[pos + 4], data[pos + 5], data[pos + 6], data[pos + 7]])
87                as usize;
88        let track_end = (pos + 8 + track_len).min(data.len());
89        let mut tp = pos + 8;
90        let mut track_ticks = 0u32;
91        let mut running_status = 0u8;
92
93        while tp < track_end {
94            // Read delta time (variable-length)
95            let (delta, bytes_read) = read_var_len(&data, tp);
96            tp += bytes_read;
97            track_ticks += delta;
98
99            if tp >= track_end {
100                break;
101            }
102
103            let status = data[tp];
104
105            if status == 0xFF {
106                // Meta event
107                tp += 1;
108                if tp >= track_end {
109                    break;
110                }
111                let meta_type = data[tp];
112                tp += 1;
113                let (meta_len, vl_bytes) = read_var_len(&data, tp);
114                tp += vl_bytes;
115                let meta_end = (tp + meta_len as usize).min(track_end);
116
117                match meta_type {
118                    0x03 => {
119                        // Track name
120                        if let Ok(name) = std::str::from_utf8(&data[tp..meta_end]) {
121                            let name = name.trim();
122                            if !name.is_empty() {
123                                info.track_names.push(name.to_string());
124                            }
125                        }
126                    }
127                    0x51 => {
128                        // Tempo: 3 bytes, microseconds per quarter note
129                        if meta_end - tp >= 3 {
130                            tempo_us = ((data[tp] as u32) << 16)
131                                | ((data[tp + 1] as u32) << 8)
132                                | (data[tp + 2] as u32);
133                            if tempo_us > 0 {
134                                info.tempo = 60_000_000.0 / tempo_us as f64;
135                            }
136                        }
137                    }
138                    0x58 => {
139                        // Time signature: nn/2^dd
140                        if meta_end - tp >= 2 {
141                            let nn = data[tp];
142                            let dd = data[tp + 1];
143                            let denom = 1u32 << dd;
144                            info.time_signature = format!("{nn}/{denom}");
145                        }
146                    }
147                    0x59 => {
148                        // Key signature: sf mi (sf=sharps/flats, mi=0 major/1 minor)
149                        if meta_end - tp >= 2 {
150                            let sf = data[tp] as i8;
151                            let mi = data[tp + 1];
152                            let key_names_major = [
153                                "Cb", "Gb", "Db", "Ab", "Eb", "Bb", "F", "C", "G", "D", "A", "E",
154                                "B", "F#", "C#",
155                            ];
156                            let key_names_minor = [
157                                "Ab", "Eb", "Bb", "F", "C", "G", "D", "A", "E", "B", "F#", "C#",
158                                "G#", "D#", "A#",
159                            ];
160                            let idx = (sf + 7) as usize;
161                            if idx < 15 {
162                                let name = if mi == 0 {
163                                    key_names_major[idx]
164                                } else {
165                                    key_names_minor[idx]
166                                };
167                                let mode = if mi == 0 { "major" } else { "minor" };
168                                info.key_signature = format!("{name} {mode}");
169                            }
170                        }
171                    }
172                    _ => {}
173                }
174                tp = meta_end;
175            } else if status == 0xF0 || status == 0xF7 {
176                // SysEx event
177                tp += 1;
178                let (sysex_len, vl_bytes) = read_var_len(&data, tp);
179                tp += vl_bytes + sysex_len as usize;
180            } else if status & 0x80 != 0 {
181                // Channel event
182                running_status = status;
183                tp += 1;
184                let msg = status & 0xF0;
185                let channel = status & 0x0F;
186                channel_mask |= 1 << channel;
187                match msg {
188                    0x80 | 0xA0 | 0xB0 | 0xE0 => {
189                        tp += 2;
190                    } // 2 data bytes
191                    0x90 => {
192                        // Note on
193                        if tp + 1 < track_end && data[tp + 1] > 0 {
194                            info.note_count += 1;
195                        }
196                        tp += 2;
197                    }
198                    0xC0 | 0xD0 => {
199                        tp += 1;
200                    } // 1 data byte
201                    _ => {
202                        tp += 2;
203                    }
204                }
205            } else {
206                // Running status
207                let msg = running_status & 0xF0;
208                let channel = running_status & 0x0F;
209                channel_mask |= 1 << channel;
210                match msg {
211                    0x80 | 0xA0 | 0xB0 | 0xE0 => {
212                        tp += 2;
213                    }
214                    0x90 => {
215                        if tp + 1 < track_end && data[tp + 1] > 0 {
216                            info.note_count += 1;
217                        }
218                        tp += 2;
219                    }
220                    0xC0 | 0xD0 => {
221                        tp += 1;
222                    }
223                    _ => {
224                        tp += 1;
225                    }
226                }
227            }
228        }
229
230        if track_ticks > total_ticks {
231            total_ticks = track_ticks;
232        }
233        pos = track_end;
234    }
235
236    info.channels_used = channel_mask.count_ones() as u16;
237
238    // Compute duration from ticks and tempo
239    if ppqn > 0 && tempo_us > 0 {
240        let beats = total_ticks as f64 / ppqn as f64;
241        info.duration = beats * (tempo_us as f64 / 1_000_000.0);
242    }
243
244    // Round tempo
245    info.tempo = (info.tempo * 10.0).round() / 10.0;
246    info.duration = (info.duration * 100.0).round() / 100.0;
247
248    Some(info)
249}
250
251/// Read a MIDI variable-length quantity. Returns (value, bytes_consumed).
252fn read_var_len(data: &[u8], pos: usize) -> (u32, usize) {
253    let mut val = 0u32;
254    let mut i = pos;
255    loop {
256        if i >= data.len() {
257            return (val, i - pos);
258        }
259        let b = data[i];
260        val = (val << 7) | (b & 0x7F) as u32;
261        i += 1;
262        if b & 0x80 == 0 {
263            break;
264        }
265        if i - pos > 4 {
266            break;
267        } // safety: max 4 bytes
268    }
269    (val, i - pos)
270}
271
272#[cfg(test)]
273mod tests {
274    use super::*;
275
276    fn make_midi(format: u16, tracks: u16, ppqn: u16, track_data: &[u8]) -> Vec<u8> {
277        let mut data = Vec::new();
278        // MThd
279        data.extend_from_slice(b"MThd");
280        data.extend_from_slice(&6u32.to_be_bytes()); // header length
281        data.extend_from_slice(&format.to_be_bytes());
282        data.extend_from_slice(&tracks.to_be_bytes());
283        data.extend_from_slice(&ppqn.to_be_bytes());
284        // MTrk
285        data.extend_from_slice(b"MTrk");
286        data.extend_from_slice(&(track_data.len() as u32).to_be_bytes());
287        data.extend_from_slice(track_data);
288        data
289    }
290
291    fn build_midi_with_track_bodies(format: u16, ppqn: u16, track_bodies: &[Vec<u8>]) -> Vec<u8> {
292        let mut data = Vec::new();
293        data.extend_from_slice(b"MThd");
294        data.extend_from_slice(&6u32.to_be_bytes());
295        data.extend_from_slice(&format.to_be_bytes());
296        data.extend_from_slice(&(track_bodies.len() as u16).to_be_bytes());
297        data.extend_from_slice(&ppqn.to_be_bytes());
298        for body in track_bodies {
299            data.extend_from_slice(b"MTrk");
300            data.extend_from_slice(&(body.len() as u32).to_be_bytes());
301            data.extend_from_slice(body);
302        }
303        data
304    }
305
306    #[test]
307    fn test_read_var_len_single_byte() {
308        let data = [0x40u8];
309        assert_eq!(read_var_len(&data, 0), (0x40, 1));
310    }
311
312    #[test]
313    fn test_read_var_len_empty_slice() {
314        let data: [u8; 0] = [];
315        assert_eq!(read_var_len(&data, 0), (0, 0));
316    }
317
318    #[test]
319    fn test_read_var_len_pos_past_end_returns_zero_bytes_consumed() {
320        let data = [0x40u8];
321        assert_eq!(read_var_len(&data, 1), (0, 0));
322    }
323
324    #[test]
325    fn test_read_var_len_128_two_bytes() {
326        let data = [0x81u8, 0x00];
327        assert_eq!(read_var_len(&data, 0), (128, 2));
328    }
329
330    #[test]
331    fn test_read_var_len_offset() {
332        let data = [0x00u8, 0x81, 0x00];
333        assert_eq!(read_var_len(&data, 1), (128, 2));
334    }
335
336    #[test]
337    fn test_read_var_len_incomplete_uses_partial() {
338        let data = [0x81u8]; // continuation without next byte
339        let (v, n) = read_var_len(&data, 0);
340        assert_eq!(n, 1);
341        assert_eq!(v, 1); // only first 7 bits
342    }
343
344    #[test]
345    fn test_read_var_len_large_value() {
346        // 8192 = 0x2000: first group 0x40 (64), second 0x00 → (64<<7)|0 = 8192
347        let data = [0xC0u8, 0x00];
348        assert_eq!(read_var_len(&data, 0), (0x2000, 2));
349    }
350
351    /// Maximum SMF variable-length quantity: 4 bytes ending with high bit clear (28 data bits all 1).
352    #[test]
353    fn test_read_var_len_max_28bit_value() {
354        let data = [0xFFu8, 0xFF, 0xFF, 0x7F];
355        assert_eq!(read_var_len(&data, 0), (268_435_455, 4));
356    }
357
358    #[test]
359    fn test_read_var_len_fifth_byte_triggers_safety_break() {
360        // Four continuation bytes then a final byte — 5 bytes total consumed before break
361        let data = [0xFFu8, 0xFF, 0xFF, 0xFF, 0x7F];
362        let (v, n) = read_var_len(&data, 0);
363        assert_eq!(n, 5);
364        assert!(v > 0);
365    }
366
367    #[test]
368    fn test_parse_midi_wrong_magic_returns_none() {
369        let tmp = std::env::temp_dir().join("test_midi_wrong_magic.mid");
370        std::fs::write(&tmp, b"RIFFxxxxNOTMThd").unwrap();
371        assert!(parse_midi(&tmp).is_none());
372        let _ = std::fs::remove_file(&tmp);
373    }
374
375    #[test]
376    fn test_parse_midi_truncated_header_returns_none() {
377        let tmp = std::env::temp_dir().join("test_midi_trunc.mid");
378        std::fs::write(&tmp, b"MThd").unwrap();
379        assert!(parse_midi(&tmp).is_none());
380        let _ = std::fs::remove_file(&tmp);
381    }
382
383    #[test]
384    fn test_parse_empty_midi() {
385        // Single track with just end-of-track
386        let track = vec![0x00, 0xFF, 0x2F, 0x00]; // delta=0, meta end-of-track
387        let data = make_midi(0, 1, 480, &track);
388        let tmp = std::env::temp_dir().join("test_empty.mid");
389        std::fs::write(&tmp, &data).unwrap();
390        let info = parse_midi(&tmp).unwrap();
391        assert_eq!(info.format, 0);
392        assert_eq!(info.track_count, 1);
393        assert_eq!(info.ppqn, 480);
394        assert_eq!(info.note_count, 0);
395        assert_eq!(info.tempo, 120.0); // default
396        let _ = std::fs::remove_file(&tmp);
397    }
398
399    #[test]
400    fn test_parse_tempo() {
401        // Set tempo to 140 BPM = 428571 µs/beat = 0x068A7B
402        let track = vec![
403            0x00, 0xFF, 0x51, 0x03, 0x06, 0x8A, 0x7B, // tempo meta event
404            0x00, 0xFF, 0x2F, 0x00, // end of track
405        ];
406        let data = make_midi(0, 1, 480, &track);
407        let tmp = std::env::temp_dir().join("test_tempo.mid");
408        std::fs::write(&tmp, &data).unwrap();
409        let info = parse_midi(&tmp).unwrap();
410        assert!(
411            (info.tempo - 140.0).abs() < 0.5,
412            "tempo should be ~140, got {}",
413            info.tempo
414        );
415        let _ = std::fs::remove_file(&tmp);
416    }
417
418    #[test]
419    fn test_parse_time_signature() {
420        // 3/4 time: nn=3, dd=2 (2^2=4)
421        let track = vec![
422            0x00, 0xFF, 0x58, 0x04, 0x03, 0x02, 0x18, 0x08, // time sig meta
423            0x00, 0xFF, 0x2F, 0x00,
424        ];
425        let data = make_midi(0, 1, 480, &track);
426        let tmp = std::env::temp_dir().join("test_timesig.mid");
427        std::fs::write(&tmp, &data).unwrap();
428        let info = parse_midi(&tmp).unwrap();
429        assert_eq!(info.time_signature, "3/4");
430        let _ = std::fs::remove_file(&tmp);
431    }
432
433    #[test]
434    fn test_parse_note_on_velocity_zero_not_counted() {
435        // MIDI: note-on with velocity 0 is equivalent to note-off — must not increment note_count
436        let track = vec![
437            0x00, 0x90, 60, 0, // "note on" C4 vel 0
438            0x00, 0xFF, 0x2F, 0x00,
439        ];
440        let data = make_midi(0, 1, 480, &track);
441        let tmp = std::env::temp_dir().join("test_vel0_note_on.mid");
442        std::fs::write(&tmp, &data).unwrap();
443        let info = parse_midi(&tmp).unwrap();
444        assert_eq!(info.note_count, 0);
445        let _ = std::fs::remove_file(&tmp);
446    }
447
448    #[test]
449    fn test_parse_notes() {
450        let track = vec![
451            0x00, 0x90, 60, 100, // note on C4 vel=100
452            0x60, 0x80, 60, 0, // note off after 96 ticks
453            0x00, 0x90, 64, 80, // note on E4
454            0x60, 0x80, 64, 0, // note off
455            0x00, 0x90, 67, 90, // note on G4
456            0x60, 0x80, 67, 0, // note off
457            0x00, 0xFF, 0x2F, 0x00,
458        ];
459        let data = make_midi(0, 1, 480, &track);
460        let tmp = std::env::temp_dir().join("test_notes.mid");
461        std::fs::write(&tmp, &data).unwrap();
462        let info = parse_midi(&tmp).unwrap();
463        assert_eq!(info.note_count, 3);
464        assert!(info.channels_used >= 1);
465        let _ = std::fs::remove_file(&tmp);
466    }
467
468    #[test]
469    fn test_parse_track_name() {
470        let name = b"Piano";
471        let mut track = vec![0x00, 0xFF, 0x03, name.len() as u8];
472        track.extend_from_slice(name);
473        track.extend_from_slice(&[0x00, 0xFF, 0x2F, 0x00]);
474        let data = make_midi(0, 1, 480, &track);
475        let tmp = std::env::temp_dir().join("test_trackname.mid");
476        std::fs::write(&tmp, &data).unwrap();
477        let info = parse_midi(&tmp).unwrap();
478        assert_eq!(info.track_names, vec!["Piano"]);
479        let _ = std::fs::remove_file(&tmp);
480    }
481
482    #[test]
483    fn test_parse_key_signature() {
484        // C major: sf=0, mi=0
485        let track = vec![
486            0x00, 0xFF, 0x59, 0x02, 0x00, 0x00, // C major
487            0x00, 0xFF, 0x2F, 0x00,
488        ];
489        let data = make_midi(0, 1, 480, &track);
490        let tmp = std::env::temp_dir().join("test_keysig.mid");
491        std::fs::write(&tmp, &data).unwrap();
492        let info = parse_midi(&tmp).unwrap();
493        assert_eq!(info.key_signature, "C major");
494        let _ = std::fs::remove_file(&tmp);
495    }
496
497    #[test]
498    fn test_parse_key_signature_minor() {
499        // A minor: sf=0, mi=1
500        let track = vec![
501            0x00, 0xFF, 0x59, 0x02, 0x00, 0x01, // A minor
502            0x00, 0xFF, 0x2F, 0x00,
503        ];
504        let data = make_midi(0, 1, 480, &track);
505        let tmp = std::env::temp_dir().join("test_keysig_minor.mid");
506        std::fs::write(&tmp, &data).unwrap();
507        let info = parse_midi(&tmp).unwrap();
508        assert_eq!(info.key_signature, "A minor");
509        let _ = std::fs::remove_file(&tmp);
510    }
511
512    #[test]
513    fn test_parse_duration() {
514        // 480 ticks at 120 BPM (500000 µs/beat), ppqn=480 → 1 beat → 0.5 seconds
515        let track = vec![
516            0x00, 0x90, 60, 100, // note on at tick 0
517            0x83, 0x60, 0x80, 60, 0, // note off at tick 480 (var len: 0x83 0x60 = 480)
518            0x00, 0xFF, 0x2F, 0x00,
519        ];
520        let data = make_midi(0, 1, 480, &track);
521        let tmp = std::env::temp_dir().join("test_duration.mid");
522        std::fs::write(&tmp, &data).unwrap();
523        let info = parse_midi(&tmp).unwrap();
524        assert!(
525            (info.duration - 0.5).abs() < 0.1,
526            "duration should be ~0.5s, got {}",
527            info.duration
528        );
529        let _ = std::fs::remove_file(&tmp);
530    }
531
532    #[test]
533    fn test_not_midi() {
534        let tmp = std::env::temp_dir().join("test_not_midi.mid");
535        std::fs::write(&tmp, b"not a midi file").unwrap();
536        assert!(parse_midi(&tmp).is_none());
537        let _ = std::fs::remove_file(&tmp);
538    }
539
540    #[test]
541    fn test_parse_midi_wrong_magic_not_mthd() {
542        let tmp = std::env::temp_dir().join("test_wrong_magic.mid");
543        std::fs::write(&tmp, b"RIFF....WAVEfmt ").unwrap();
544        assert!(parse_midi(&tmp).is_none());
545        let _ = std::fs::remove_file(&tmp);
546    }
547
548    #[test]
549    fn test_parse_midi_file_too_short_for_minimum_header() {
550        let tmp = std::env::temp_dir().join("test_midi_too_short.mid");
551        std::fs::write(&tmp, b"MThd\x00\x00\x00\x06").unwrap();
552        assert!(parse_midi(&tmp).is_none());
553        let _ = std::fs::remove_file(&tmp);
554    }
555
556    #[test]
557    fn test_parse_midi_smpte_division_defaults_ppqn() {
558        let tmp = std::env::temp_dir().join("test_midi_smpte_ppqn.mid");
559        let mut data = Vec::new();
560        data.extend_from_slice(b"MThd");
561        data.extend_from_slice(&6u32.to_be_bytes());
562        data.extend_from_slice(&0u16.to_be_bytes());
563        data.extend_from_slice(&0u16.to_be_bytes());
564        data.extend_from_slice(&0x8001u16.to_be_bytes());
565        std::fs::write(&tmp, &data).unwrap();
566        let info = parse_midi(&tmp).unwrap();
567        assert_eq!(
568            info.ppqn, 480,
569            "SMPTE timecode division → internal PPQN default"
570        );
571        let _ = std::fs::remove_file(&tmp);
572    }
573
574    #[test]
575    fn test_parse_midi_ppqn_960_standard_division() {
576        let track = vec![0x00, 0xFF, 0x2F, 0x00];
577        let data = make_midi(0, 1, 960, &track);
578        let tmp = std::env::temp_dir().join("test_midi_ppqn_960.mid");
579        std::fs::write(&tmp, &data).unwrap();
580        let info = parse_midi(&tmp).unwrap();
581        assert_eq!(
582            info.ppqn, 960,
583            "PPQN ticks/quarter when division bit 15 is clear"
584        );
585        let _ = std::fs::remove_file(&tmp);
586    }
587
588    #[test]
589    fn test_parse_midi_running_status_counts_second_note_on() {
590        // First event sets status 0x90; second pair omits status byte (running status).
591        let track = vec![
592            0x00, 0x90, 60, 100, // note on C4
593            0x00, 64, 90, // running: note on E4 (vel 90)
594            0x00, 0xFF, 0x2F, 0x00,
595        ];
596        let data = make_midi(0, 1, 480, &track);
597        let tmp = std::env::temp_dir().join("test_midi_running_status.mid");
598        std::fs::write(&tmp, &data).unwrap();
599        let info = parse_midi(&tmp).unwrap();
600        assert_eq!(info.note_count, 2);
601        let _ = std::fs::remove_file(&tmp);
602    }
603
604    #[test]
605    fn test_parse_midi_program_change_advances_without_note_count() {
606        let track = vec![
607            0x00, 0xC0, 7, // program change ch0, program 7 (one data byte)
608            0x00, 0xFF, 0x2F, 0x00,
609        ];
610        let data = make_midi(0, 1, 480, &track);
611        let tmp = std::env::temp_dir().join("test_midi_program_change.mid");
612        std::fs::write(&tmp, &data).unwrap();
613        let info = parse_midi(&tmp).unwrap();
614        assert_eq!(info.note_count, 0);
615        let _ = std::fs::remove_file(&tmp);
616    }
617
618    #[test]
619    fn test_parse_midi_pitch_bend_two_data_bytes_no_note_count() {
620        let track = vec![
621            0x00, 0xE0, 0x00, 0x40, // pitch bend ch0
622            0x00, 0xFF, 0x2F, 0x00,
623        ];
624        let data = make_midi(0, 1, 480, &track);
625        let tmp = std::env::temp_dir().join("test_midi_pitch_bend.mid");
626        std::fs::write(&tmp, &data).unwrap();
627        let info = parse_midi(&tmp).unwrap();
628        assert_eq!(info.note_count, 0);
629        assert!(info.channels_used >= 1);
630        let _ = std::fs::remove_file(&tmp);
631    }
632
633    #[test]
634    fn test_parse_midi_format_1_two_tracks_merges_track_names() {
635        let tr1 = vec![
636            0x00, 0xFF, 0x03, 0x05, b'A', b'l', b'p', b'h', b'a', 0x00, 0xFF, 0x2F, 0x00,
637        ];
638        let tr2 = vec![
639            0x00, 0xFF, 0x03, 0x04, b'B', b'e', b't', b'a', 0x00, 0xFF, 0x2F, 0x00,
640        ];
641        let data = build_midi_with_track_bodies(1, 480, &[tr1, tr2]);
642        let tmp = std::env::temp_dir().join("test_midi_two_tracks.mid");
643        std::fs::write(&tmp, &data).unwrap();
644        let info = parse_midi(&tmp).unwrap();
645        assert_eq!(info.format, 1);
646        assert_eq!(info.track_count, 2);
647        assert!(
648            info.track_names.contains(&"Alpha".into()) && info.track_names.contains(&"Beta".into()),
649            "expected Alpha and Beta in {:?}",
650            info.track_names
651        );
652        let _ = std::fs::remove_file(&tmp);
653    }
654
655    #[test]
656    fn test_var_len() {
657        assert_eq!(read_var_len(&[0x00], 0), (0, 1));
658        assert_eq!(read_var_len(&[0x7F], 0), (127, 1));
659        assert_eq!(read_var_len(&[0x81, 0x00], 0), (128, 2));
660        assert_eq!(read_var_len(&[0x83, 0x60], 0), (480, 2));
661    }
662
663    #[test]
664    fn test_parse_midi_sysex_advances_parser_without_note_count() {
665        // F0 + var-len payload (3 bytes) + 3 data bytes, then end-of-track.
666        let track = vec![
667            0x00, 0xF0, 0x03, 0x01, 0x02, 0x03, // SysEx
668            0x00, 0xFF, 0x2F, 0x00,
669        ];
670        let data = make_midi(0, 1, 480, &track);
671        let tmp = std::env::temp_dir().join("test_midi_sysex.mid");
672        std::fs::write(&tmp, &data).unwrap();
673        let info = parse_midi(&tmp).unwrap();
674        assert_eq!(info.note_count, 0);
675        let _ = std::fs::remove_file(&tmp);
676    }
677
678    #[test]
679    fn test_parse_midi_track_name_zero_length_not_listed() {
680        let track = vec![
681            0x00, 0xFF, 0x03, 0x00, // sequence/track name, length 0
682            0x00, 0xFF, 0x2F, 0x00,
683        ];
684        let data = make_midi(0, 1, 480, &track);
685        let tmp = std::env::temp_dir().join("test_midi_empty_name.mid");
686        std::fs::write(&tmp, &data).unwrap();
687        let info = parse_midi(&tmp).unwrap();
688        assert!(
689            info.track_names.is_empty(),
690            "empty meta 0x03 must not add a track name"
691        );
692        let _ = std::fs::remove_file(&tmp);
693    }
694
695    /// End-to-end single-track flow: track name → tempo meta → two note-ons → end-of-track.
696    #[test]
697    fn test_parse_midi_flow_track_name_tempo_notes_and_counts() {
698        let track = vec![
699            0x00, 0xFF, 0x03, 0x07, b'M', b'y', b'T', b'r', b'a', b'c', b'k', 0x00, 0xFF, 0x51,
700            0x03, 0x07, 0xA1, 0x20, // 500000 µs/qn → 120 BPM
701            0x00, 0x90, 60, 100, // note on C4
702            0x00, 0x90, 64, 100, // note on E4
703            0x00, 0xFF, 0x2F, 0x00,
704        ];
705        let data = make_midi(0, 1, 480, &track);
706        let tmp = std::env::temp_dir().join("test_midi_flow.mid");
707        std::fs::write(&tmp, &data).unwrap();
708        let info = parse_midi(&tmp).unwrap();
709        assert_eq!(info.track_names, vec!["MyTrack"]);
710        assert!(
711            (info.tempo - 120.0).abs() < 0.5,
712            "tempo meta should yield ~120 BPM, got {}",
713            info.tempo
714        );
715        assert_eq!(info.note_count, 2);
716        let _ = std::fs::remove_file(&tmp);
717    }
718
719    /// Single track: name, tempo, time signature, key signature, one note-on — exercises meta + channel path together.
720    #[test]
721    fn test_parse_midi_flow_track_name_tempo_time_sig_key_sig_one_note() {
722        let track = vec![
723            0x00, 0xFF, 0x03, 0x04, b'T', b'e', b's', b't', 0x00, 0xFF, 0x51, 0x03, 0x07, 0xA1,
724            0x20, 0x00, 0xFF, 0x58, 0x04, 0x06, 0x03, 0x18, 0x08, // 6/8
725            0x00, 0xFF, 0x59, 0x02, 0xFF, 0x01, // D minor (sf = -1)
726            0x00, 0x90, 60, 100, 0x00, 0xFF, 0x2F, 0x00,
727        ];
728        let data = make_midi(0, 1, 480, &track);
729        let tmp = std::env::temp_dir().join("test_midi_flow_meta.mid");
730        std::fs::write(&tmp, &data).unwrap();
731        let info = parse_midi(&tmp).unwrap();
732        assert_eq!(info.track_names, vec!["Test"]);
733        assert!((info.tempo - 120.0).abs() < 0.5);
734        assert_eq!(info.time_signature, "6/8");
735        assert_eq!(info.key_signature, "D minor");
736        assert_eq!(info.note_count, 1);
737        let _ = std::fs::remove_file(&tmp);
738    }
739
740    #[test]
741    fn test_parse_midi_note_ons_on_two_channels_sets_channels_used() {
742        let track = vec![
743            0x00, 0x90, 60, 100, // ch 0 note on
744            0x00, 0x91, 64, 100, // ch 1 note on
745            0x00, 0xFF, 0x2F, 0x00,
746        ];
747        let data = make_midi(0, 1, 480, &track);
748        let tmp = std::env::temp_dir().join("test_midi_chans.mid");
749        std::fs::write(&tmp, &data).unwrap();
750        let info = parse_midi(&tmp).unwrap();
751        assert_eq!(info.note_count, 2);
752        assert_eq!(info.channels_used, 2);
753        let _ = std::fs::remove_file(&tmp);
754    }
755}