From 0941d65f219f27b0bce9693d150fe095d67cafed Mon Sep 17 00:00:00 2001 From: dsgallups Date: Fri, 3 Oct 2025 04:34:47 -0400 Subject: [PATCH 01/11] add MidiFile::for_each_track --- src/events/live.rs | 41 ++++++++++++++++++++--------------------- src/file/mod.rs | 14 ++++++++++++++ 2 files changed, 34 insertions(+), 21 deletions(-) diff --git a/src/events/live.rs b/src/events/live.rs index 6142292..db82cea 100644 --- a/src/events/live.rs +++ b/src/events/live.rs @@ -38,11 +38,11 @@ pub trait FromLiveEventBytes { Self: Sized; } -#[doc = r" +#[doc = r#" An emittable message to/from a streaming MIDI device. -There is currently no `StreamReader` type, so this type is most often manually constructed. -"] +This is essentially the root message of all possible messages sent by a midi device. +"#] #[derive(Clone, Debug, PartialEq, Eq)] #[cfg_attr(feature = "bevy", derive(bevy::reflect::Reflect))] pub enum LiveEvent<'a> { @@ -65,7 +65,6 @@ impl LiveEvent<'_> { _ => None, } } - // /// Returns the event as a set of bytes. These bytes are to be interpreted by a MIDI live stream // pub fn to_bytes(&self) -> Vec { // match self { @@ -120,21 +119,21 @@ impl FromLiveEventBytes for LiveEvent<'_> { } } -// #[test] -// fn parse_note_on() { -// use crate::prelude::*; -// let message = [0b1001_0001, 0b0100_1000, 0b001_00001]; -// let parsed = LiveEvent::from_bytes(&message).unwrap(); -// //parsed: ChannelVoice(ChannelVoiceMessage { channel: Channel(1), message: NoteOn { key: Key(72), vel: Velocity(33) } }) +#[test] +fn parse_note_on() { + use crate::prelude::*; + let message = [0b1001_0001, 0b0100_1000, 0b001_00001]; + //parsed: ChannelVoice(ChannelVoiceMessage { channel: Channel(1), message: NoteOn { key: Key(72), vel: Velocity(33) } }) + let parsed = LiveEvent::from_bytes(&message).unwrap(); -// assert_eq!( -// parsed, -// LiveEvent::ChannelVoice(ChannelVoiceMessage::new( -// Channel::Two, -// VoiceEvent::NoteOn { -// key: Key::from_databyte(72).unwrap(), -// velocity: Velocity::new(33).unwrap() -// } -// )) -// ); -// } + assert_eq!( + parsed, + LiveEvent::ChannelVoice(ChannelVoiceMessage::new( + Channel::Two, + VoiceEvent::NoteOn { + note: Note::from_databyte(72).unwrap(), + velocity: Velocity::new(33).unwrap() + } + )) + ); +} diff --git a/src/file/mod.rs b/src/file/mod.rs index a3d789e..b16984a 100644 --- a/src/file/mod.rs +++ b/src/file/mod.rs @@ -76,6 +76,20 @@ impl<'a> MidiFile<'a> { &self.header } + /// Executes the provided function for all the tracks in the format. + /// + /// Useful if you don't want to allocate more data on the stack. + pub fn for_each_track(&self, mut func: F) + where + F: FnMut(&Track), + { + match &self.format { + Format::SequentiallyIndependent(t) => t.iter().for_each(func), + Format::Simultaneous(s) => s.iter().for_each(func), + Format::SingleMultiChannel(c) => func(c), + } + } + /// Returns a track list pub fn tracks(&self) -> Vec<&Track<'a>> { match &self.format { From 3ddb18424a0f4c2ffa191c78ef3cde73496b06fa Mon Sep 17 00:00:00 2001 From: dsgallups Date: Fri, 3 Oct 2025 04:39:22 -0400 Subject: [PATCH 02/11] separate readme from docs readme --- README.md | 2 +- src/lib.rs | 99 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 99 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 4830e96..f8e9bf2 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ A suite of tools used to read, modify, and manage MIDI-related systems ## Overview -`midix` provides users with human readable MIDI structures without invariant states. That is, the midi 1.0 specification has been strongly typed such that programatic commands built with this crate are not invariant. +`midix` provides users with human readable MIDI structures without invariant states. That is, the midi 1.0 specification has been strongly typed such that programatic commands built with this crate uphold invariants. `midix` provides a parser ([`Reader`](crate::prelude::Reader)) to read events from `.mid` files. calling [`Reader::read_event`](crate::prelude::Reader::read_event) will yield a [`FileEvent`](crate::prelude::FileEvent). diff --git a/src/lib.rs b/src/lib.rs index fb503ce..6f5589b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,6 +1,103 @@ #![warn(missing_docs)] #![warn(clippy::print_stdout)] -#![doc = include_str!("../README.md")] +#![doc = r#####" +# MIDIx +A suite of tools used to read, modify, and manage MIDI-related systems + +## Overview + +`midix` provides users with human readable MIDI structures without invariant states. That is, the midi 1.0 specification has been strongly typed such that programatic commands built with this crate uphold invariants. + +`midix` provides a parser ([`Reader`](crate::prelude::Reader)) to read events from `.mid` files. +calling [`Reader::read_event`](crate::prelude::Reader::read_event) will yield a [`FileEvent`](crate::prelude::FileEvent). + +Additionally, `midix` provides the user with [`LiveEvent::from_bytes`](crate::events::LiveEvent), which will parse events from a live MIDI source. + +You may also make your own MIDI representation using the provided structs. + +## Getting Started + +MIDI can be interpreted in two main ways: through `LiveEvent`s and regular file `Events`. + +### Example +To read from a file, use the [`Reader`](crate::prelude::Reader): +```rust +use midix::prelude::*; + +let midi_header = [ + /* MIDI Header */ + 0x4D, 0x54, 0x68, 0x64, // "MThd" + 0x00, 0x00, 0x00, 0x06, // Chunk length (6) + 0x00, 0x00, // format 0 + 0x00, 0x01, // one track + 0x00, 0x60 // 96 per quarter note +]; + +let mut reader = Reader::from_byte_slice(&midi_header); + +// The first and only event will be the midi header +let Ok(FileEvent::Header(header)) = reader.read_event() else { + panic!("Expected a header event"); +}; + +// format 0 implies a single multi-channel file (only one track) +assert_eq!(header.format_type(), FormatType::SingleMultiChannel); + +assert_eq!( + header.timing().ticks_per_quarter_note(), + Some(96) +); + +``` +To parse a [`LiveEvent`](crate::prelude::LiveEvent) + +```rust +use midix::prelude::*; + +/* Ch.3 Note On C4, forte */ +let note_on = [0x92, 0x3C, 0x60]; + +// NoteOn is a channel voice message +// Alternatively, use VoiceEvent::read_bytes(¬e_on) +let Ok(LiveEvent::ChannelVoice(channel_voice_msg)) = LiveEvent::from_bytes(¬e_on) else { + panic!("Expected a channel voice event"); +}; + +let VoiceEvent::NoteOn { note, velocity } = channel_voice_msg.event() else { + panic!("Expected a note on event"); +}; + +assert_eq!(channel_voice_msg.channel(), Channel::Three); +assert_eq!(note.key(), Key::C); +assert_eq!(note.octave(), Octave::new(4)); +assert_eq!(velocity.byte(), 96); +``` + + +## Semantic Versioning and Support +`midix` will adhere to semantic versioning. I've opted to use major versions. + +The current MSRV is rust `1.87` + +## General feature schedule +The SUPPORT.md file denotes the length of time major revisions are supported. + +When the major version of the crate is incremented, new features for the previous version(s) +will likely be neglected. If you need a non-breaking feature for an older version before the end +of its maintenence period, please let me know! + +## Acknowledgments +A lot of the documentation is copied directly from +[this documentation](http://www.music.mcgill.ca/~ich/classes/mumt306/StandardMIDIfileformat.html). + +This reference states "This document may be freely copied in whole or in part provided the copy contains this Acknowledgement.": +```text +This document was originally distributed in text format by The International MIDI Association. +© Copyright 1999 David Back. +EMail: david@csw2.co.uk +Web: http://www.csw2.co.uk +``` +"#####] #![no_std] extern crate alloc; From 166b1d9aadf88246a0a12bed323f6c108aa45421 Mon Sep 17 00:00:00 2001 From: dsgallups Date: Fri, 3 Oct 2025 04:42:29 -0400 Subject: [PATCH 03/11] Header -> MidiFileHeader --- src/file/builder.rs | 2 +- src/file/header.rs | 4 ++-- src/file/mod.rs | 4 ++-- src/file/timed_event_iter.rs | 22 +++++++++++----------- 4 files changed, 16 insertions(+), 16 deletions(-) diff --git a/src/file/builder.rs b/src/file/builder.rs index 002f064..530e08f 100644 --- a/src/file/builder.rs +++ b/src/file/builder.rs @@ -119,7 +119,7 @@ impl<'a> MidiFileBuilder<'a> { Ok(MidiFile { format, - header: Header::new(timing), + header: MidiFileHeader::new(timing), }) } } diff --git a/src/file/header.rs b/src/file/header.rs index 295b025..a86f303 100644 --- a/src/file/header.rs +++ b/src/file/header.rs @@ -5,11 +5,11 @@ use crate::prelude::*; "#] #[derive(Debug, Clone, Copy, PartialEq, Eq)] #[cfg_attr(feature = "bevy", derive(bevy::reflect::Reflect))] -pub struct Header { +pub struct MidiFileHeader { timing: Timing, } -impl Header { +impl MidiFileHeader { /// Create a new header from timing pub fn new(timing: Timing) -> Self { Self { timing } diff --git a/src/file/mod.rs b/src/file/mod.rs index b16984a..153bf20 100644 --- a/src/file/mod.rs +++ b/src/file/mod.rs @@ -32,7 +32,7 @@ TODO #[derive(Clone, Debug, PartialEq)] #[cfg_attr(feature = "bevy", derive(bevy::reflect::Reflect))] pub struct MidiFile<'a> { - header: Header, + header: MidiFileHeader, format: Format<'a>, } #[cfg(feature = "bevy_asset")] @@ -72,7 +72,7 @@ impl<'a> MidiFile<'a> { } /// Returns header info - pub fn header(&self) -> &Header { + pub fn header(&self) -> &MidiFileHeader { &self.header } diff --git a/src/file/timed_event_iter.rs b/src/file/timed_event_iter.rs index d3a2c25..94c22a6 100644 --- a/src/file/timed_event_iter.rs +++ b/src/file/timed_event_iter.rs @@ -26,7 +26,7 @@ impl<'a> Iterator for OptTimedEventIterator<'a> { /// An iterator returned from [`ParsedMidiFile::into_events`] pub struct TimedEventIterator<'a> { len_remaining: usize, - header: Header, + header: MidiFileHeader, tracks: alloc::vec::IntoIter>, cur_track: CurrentTrack<'a>, file_tempo: Option, @@ -244,7 +244,7 @@ fn tempo_event(delta_ticks: u32, micros_per_quarter: u32) -> TrackEvent<'static> #[test] fn test_empty_file_returns_none_iterator() { - let header = Header::new(Timing::TicksPerQuarterNote(TicksPerQuarterNote { + let header = MidiFileHeader::new(Timing::TicksPerQuarterNote(TicksPerQuarterNote { inner: [0x01, 0xE0], })); let format = Format::Simultaneous(alloc::vec![]); @@ -256,7 +256,7 @@ fn test_empty_file_returns_none_iterator() { #[test] fn test_single_track_single_event() { - let header = Header::new(Timing::TicksPerQuarterNote(TicksPerQuarterNote { + let header = MidiFileHeader::new(Timing::TicksPerQuarterNote(TicksPerQuarterNote { inner: [0x01, 0xE0], })); let events = alloc::vec![tempo_event(0, 500_000), note_on_event(0, 60, 100, 0),]; @@ -281,7 +281,7 @@ fn test_single_track_single_event() { #[test] fn test_single_track_multiple_events_with_delta_time() { - let header = Header::new(Timing::TicksPerQuarterNote(TicksPerQuarterNote { + let header = MidiFileHeader::new(Timing::TicksPerQuarterNote(TicksPerQuarterNote { inner: [0x01, 0xE0], })); let events = alloc::vec![ @@ -306,7 +306,7 @@ fn test_single_track_multiple_events_with_delta_time() { #[test] fn test_simultaneous_format_multiple_tracks() { - let header = Header::new(Timing::TicksPerQuarterNote(TicksPerQuarterNote { + let header = MidiFileHeader::new(Timing::TicksPerQuarterNote(TicksPerQuarterNote { inner: [0x01, 0xE0], })); @@ -334,7 +334,7 @@ fn test_simultaneous_format_multiple_tracks() { #[test] fn test_sequentially_independent_format() { - let header = Header::new(Timing::TicksPerQuarterNote(TicksPerQuarterNote { + let header = MidiFileHeader::new(Timing::TicksPerQuarterNote(TicksPerQuarterNote { inner: [0x03, 0xC0], })); @@ -370,7 +370,7 @@ fn test_smpte_timing() { fps: SmpteFps::Thirty, ticks_per_frame: DataByte::new(40).unwrap(), }; - let header = Header::new(Timing::Smpte(smpte)); + let header = MidiFileHeader::new(Timing::Smpte(smpte)); let events = alloc::vec![ note_on_event(0, 60, 100, 0), @@ -391,7 +391,7 @@ fn test_smpte_timing() { #[test] fn test_mixed_event_types() { - let header = Header::new(Timing::TicksPerQuarterNote(TicksPerQuarterNote { + let header = MidiFileHeader::new(Timing::TicksPerQuarterNote(TicksPerQuarterNote { inner: [0x01, 0xE0], })); @@ -464,7 +464,7 @@ fn test_mixed_event_types() { #[test] fn test_system_exclusive_events() { - let header = Header::new(Timing::TicksPerQuarterNote(TicksPerQuarterNote { + let header = MidiFileHeader::new(Timing::TicksPerQuarterNote(TicksPerQuarterNote { inner: [0x01, 0xE0], })); @@ -495,7 +495,7 @@ fn test_system_exclusive_events() { #[test] fn test_file_tempo_override_in_simultaneous_format() { - let header = Header::new(Timing::TicksPerQuarterNote(TicksPerQuarterNote { + let header = MidiFileHeader::new(Timing::TicksPerQuarterNote(TicksPerQuarterNote { inner: [0x01, 0xE0], })); @@ -517,7 +517,7 @@ fn test_file_tempo_override_in_simultaneous_format() { #[test] fn test_empty_track_handling() { - let header = Header::new(Timing::TicksPerQuarterNote(TicksPerQuarterNote { + let header = MidiFileHeader::new(Timing::TicksPerQuarterNote(TicksPerQuarterNote { inner: [0x01, 0xE0], })); From 3a7b6ff69662e2594e592c8d73760f1bbe792376 Mon Sep 17 00:00:00 2001 From: dsgallups Date: Fri, 3 Oct 2025 04:47:48 -0400 Subject: [PATCH 04/11] delete `MidiFileHeader`. Timing :) --- src/file/builder.rs | 5 +- src/file/header.rs | 21 -------- src/file/mod.rs | 10 ++-- src/file/timed_event_iter.rs | 98 +++++++++++++++++++++++------------- 4 files changed, 67 insertions(+), 67 deletions(-) delete mode 100644 src/file/header.rs diff --git a/src/file/builder.rs b/src/file/builder.rs index 530e08f..7b922bc 100644 --- a/src/file/builder.rs +++ b/src/file/builder.rs @@ -117,9 +117,6 @@ impl<'a> MidiFileBuilder<'a> { return Err(FileError::NoTiming); }; - Ok(MidiFile { - format, - header: MidiFileHeader::new(timing), - }) + Ok(MidiFile { format, timing }) } } diff --git a/src/file/header.rs b/src/file/header.rs deleted file mode 100644 index a86f303..0000000 --- a/src/file/header.rs +++ /dev/null @@ -1,21 +0,0 @@ -use crate::prelude::*; - -#[doc = r#" - Information about the timing of the MIDI file -"#] -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -#[cfg_attr(feature = "bevy", derive(bevy::reflect::Reflect))] -pub struct MidiFileHeader { - timing: Timing, -} - -impl MidiFileHeader { - /// Create a new header from timing - pub fn new(timing: Timing) -> Self { - Self { timing } - } - /// Get the timing props - pub fn timing(&self) -> &Timing { - &self.timing - } -} diff --git a/src/file/mod.rs b/src/file/mod.rs index 153bf20..c05131e 100644 --- a/src/file/mod.rs +++ b/src/file/mod.rs @@ -10,8 +10,6 @@ use alloc::{borrow::Cow, vec::Vec}; use builder::*; mod format; pub use format::*; -mod header; -pub use header::*; mod track; pub use track::*; @@ -22,7 +20,7 @@ use crate::{ ParseError, events::LiveEvent, message::Timed, - prelude::FormatType, + prelude::{FormatType, Timing}, reader::{ReadResult, Reader, ReaderError, ReaderErrorKind}, }; @@ -32,7 +30,7 @@ TODO #[derive(Clone, Debug, PartialEq)] #[cfg_attr(feature = "bevy", derive(bevy::reflect::Reflect))] pub struct MidiFile<'a> { - header: MidiFileHeader, + timing: Timing, format: Format<'a>, } #[cfg(feature = "bevy_asset")] @@ -72,8 +70,8 @@ impl<'a> MidiFile<'a> { } /// Returns header info - pub fn header(&self) -> &MidiFileHeader { - &self.header + pub fn timing(&self) -> Timing { + self.timing } /// Executes the provided function for all the tracks in the format. diff --git a/src/file/timed_event_iter.rs b/src/file/timed_event_iter.rs index 94c22a6..097921f 100644 --- a/src/file/timed_event_iter.rs +++ b/src/file/timed_event_iter.rs @@ -26,14 +26,14 @@ impl<'a> Iterator for OptTimedEventIterator<'a> { /// An iterator returned from [`ParsedMidiFile::into_events`] pub struct TimedEventIterator<'a> { len_remaining: usize, - header: MidiFileHeader, + timing: Timing, tracks: alloc::vec::IntoIter>, cur_track: CurrentTrack<'a>, file_tempo: Option, } impl<'a> TimedEventIterator<'a> { pub(super) fn new(file: MidiFile<'a>) -> Option { - let header = file.header; + let timing = file.timing; let (size, tracks, next, file_tempo) = match file.format { Format::SequentiallyIndependent(t) => { @@ -57,11 +57,11 @@ impl<'a> TimedEventIterator<'a> { (size, alloc::vec::Vec::new().into_iter(), track, Some(tempo)) } }; - let cur_track = CurrentTrack::new(next, file_tempo, header.timing()); + let cur_track = CurrentTrack::new(next, file_tempo, timing); Some(Self { len_remaining: size, - header, + timing, tracks, cur_track, file_tempo, @@ -80,8 +80,7 @@ impl<'a> Iterator for TimedEventIterator<'a> { } None => { let next_track = self.tracks.next()?; - let next_track = - CurrentTrack::new(next_track, self.file_tempo, self.header.timing()); + let next_track = CurrentTrack::new(next_track, self.file_tempo, self.timing); self.cur_track = next_track; } } @@ -99,7 +98,7 @@ struct CurrentTrack<'a> { } impl<'a> CurrentTrack<'a> { - fn new(track: Track<'a>, file_tempo: Option, timing: &Timing) -> Self { + fn new(track: Track<'a>, file_tempo: Option, timing: Timing) -> Self { let track_tempo = file_tempo.unwrap_or(track.info().tempo); let micros_per_quarter_note = track_tempo.micros_per_quarter_note(); @@ -244,11 +243,11 @@ fn tempo_event(delta_ticks: u32, micros_per_quarter: u32) -> TrackEvent<'static> #[test] fn test_empty_file_returns_none_iterator() { - let header = MidiFileHeader::new(Timing::TicksPerQuarterNote(TicksPerQuarterNote { + let timing = Timing::TicksPerQuarterNote(TicksPerQuarterNote { inner: [0x01, 0xE0], - })); + }); let format = Format::Simultaneous(alloc::vec![]); - let file = MidiFile { header, format }; + let file = MidiFile { timing, format }; let mut iter = file.into_events(); assert_eq!(iter.next(), None); @@ -256,13 +255,16 @@ fn test_empty_file_returns_none_iterator() { #[test] fn test_single_track_single_event() { - let header = MidiFileHeader::new(Timing::TicksPerQuarterNote(TicksPerQuarterNote { + let header = Timing::TicksPerQuarterNote(TicksPerQuarterNote { inner: [0x01, 0xE0], - })); + }); let events = alloc::vec![tempo_event(0, 500_000), note_on_event(0, 60, 100, 0),]; let track = Track::new(events); let format = Format::SingleMultiChannel(track); - let file = MidiFile { header, format }; + let file = MidiFile { + timing: header, + format, + }; let mut iter = file.into_events(); let event = iter.next().unwrap(); @@ -281,9 +283,9 @@ fn test_single_track_single_event() { #[test] fn test_single_track_multiple_events_with_delta_time() { - let header = MidiFileHeader::new(Timing::TicksPerQuarterNote(TicksPerQuarterNote { + let header = Timing::TicksPerQuarterNote(TicksPerQuarterNote { inner: [0x01, 0xE0], - })); + }); let events = alloc::vec![ tempo_event(0, 500_000), note_on_event(0, 60, 100, 0), @@ -292,7 +294,10 @@ fn test_single_track_multiple_events_with_delta_time() { ]; let track = Track::new(events); let format = Format::SingleMultiChannel(track); - let file = MidiFile { header, format }; + let file = MidiFile { + timing: header, + format, + }; let events: alloc::vec::Vec<_> = file.into_events().collect(); assert_eq!(events.len(), 3); @@ -306,9 +311,9 @@ fn test_single_track_multiple_events_with_delta_time() { #[test] fn test_simultaneous_format_multiple_tracks() { - let header = MidiFileHeader::new(Timing::TicksPerQuarterNote(TicksPerQuarterNote { + let header = Timing::TicksPerQuarterNote(TicksPerQuarterNote { inner: [0x01, 0xE0], - })); + }); let track1_events = alloc::vec![ tempo_event(0, 500_000), @@ -321,7 +326,10 @@ fn test_simultaneous_format_multiple_tracks() { let track2 = Track::new(track2_events); let format = Format::Simultaneous(alloc::vec![track1, track2]); - let file = MidiFile { header, format }; + let file = MidiFile { + timing: header, + format, + }; let events: alloc::vec::Vec<_> = file.into_events().collect(); assert_eq!(events.len(), 4); @@ -334,9 +342,9 @@ fn test_simultaneous_format_multiple_tracks() { #[test] fn test_sequentially_independent_format() { - let header = MidiFileHeader::new(Timing::TicksPerQuarterNote(TicksPerQuarterNote { + let header = Timing::TicksPerQuarterNote(TicksPerQuarterNote { inner: [0x03, 0xC0], - })); + }); let track1_events = alloc::vec![ tempo_event(0, 1_000_000), @@ -353,7 +361,10 @@ fn test_sequentially_independent_format() { let track2 = Track::new(track2_events); let format = Format::SequentiallyIndependent(alloc::vec![track1, track2]); - let file = MidiFile { header, format }; + let file = MidiFile { + timing: header, + format, + }; let events: alloc::vec::Vec<_> = file.into_events().collect(); assert_eq!(events.len(), 4); @@ -370,7 +381,7 @@ fn test_smpte_timing() { fps: SmpteFps::Thirty, ticks_per_frame: DataByte::new(40).unwrap(), }; - let header = MidiFileHeader::new(Timing::Smpte(smpte)); + let header = Timing::Smpte(smpte); let events = alloc::vec![ note_on_event(0, 60, 100, 0), @@ -379,7 +390,10 @@ fn test_smpte_timing() { ]; let track = Track::new(events); let format = Format::SingleMultiChannel(track); - let file = MidiFile { header, format }; + let file = MidiFile { + timing: header, + format, + }; let events: alloc::vec::Vec<_> = file.into_events().collect(); assert_eq!(events.len(), 3); @@ -391,9 +405,9 @@ fn test_smpte_timing() { #[test] fn test_mixed_event_types() { - let header = MidiFileHeader::new(Timing::TicksPerQuarterNote(TicksPerQuarterNote { + let header = Timing::TicksPerQuarterNote(TicksPerQuarterNote { inner: [0x01, 0xE0], - })); + }); let events = alloc::vec![ tempo_event(0, 500_000), @@ -422,7 +436,10 @@ fn test_mixed_event_types() { let track = Track::new(events); let format = Format::SingleMultiChannel(track); - let file = MidiFile { header, format }; + let file = MidiFile { + timing: header, + format, + }; let events: alloc::vec::Vec<_> = file.into_events().collect(); assert_eq!(events.len(), 4); @@ -464,9 +481,9 @@ fn test_mixed_event_types() { #[test] fn test_system_exclusive_events() { - let header = MidiFileHeader::new(Timing::TicksPerQuarterNote(TicksPerQuarterNote { + let header = Timing::TicksPerQuarterNote(TicksPerQuarterNote { inner: [0x01, 0xE0], - })); + }); let sysex_data = alloc::vec![0xF0, 0x43, 0x12, 0x00, 0xF7]; let events = alloc::vec![ @@ -481,7 +498,10 @@ fn test_system_exclusive_events() { let track = Track::new(events); let format = Format::SingleMultiChannel(track); - let file = MidiFile { header, format }; + let file = MidiFile { + timing: header, + format, + }; let events: alloc::vec::Vec<_> = file.into_events().collect(); assert_eq!(events.len(), 3); @@ -495,9 +515,9 @@ fn test_system_exclusive_events() { #[test] fn test_file_tempo_override_in_simultaneous_format() { - let header = MidiFileHeader::new(Timing::TicksPerQuarterNote(TicksPerQuarterNote { + let header = Timing::TicksPerQuarterNote(TicksPerQuarterNote { inner: [0x01, 0xE0], - })); + }); let track1_events = alloc::vec![tempo_event(0, 600_000), note_on_event(0, 60, 100, 0),]; let track1 = Track::new(track1_events); @@ -506,7 +526,10 @@ fn test_file_tempo_override_in_simultaneous_format() { let track2 = Track::new(track2_events); let format = Format::Simultaneous(alloc::vec![track1, track2]); - let file = MidiFile { header, format }; + let file = MidiFile { + timing: header, + format, + }; let events: alloc::vec::Vec<_> = file.into_events().collect(); assert_eq!(events.len(), 2); @@ -517,9 +540,9 @@ fn test_file_tempo_override_in_simultaneous_format() { #[test] fn test_empty_track_handling() { - let header = MidiFileHeader::new(Timing::TicksPerQuarterNote(TicksPerQuarterNote { + let header = Timing::TicksPerQuarterNote(TicksPerQuarterNote { inner: [0x01, 0xE0], - })); + }); let track1_events = alloc::vec![ tempo_event(0, 500_000), @@ -534,7 +557,10 @@ fn test_empty_track_handling() { let track3 = Track::new(track3_events); let format = Format::Simultaneous(alloc::vec![track1, track2, track3]); - let file = MidiFile { header, format }; + let file = MidiFile { + timing: header, + format, + }; let events: alloc::vec::Vec<_> = file.into_events().collect(); From 8c18bac2477b54e4deaf234a8c1fb02e7ab66df4 Mon Sep 17 00:00:00 2001 From: dsgallups Date: Fri, 3 Oct 2025 05:08:51 -0400 Subject: [PATCH 05/11] refactor: remove file_repr module These types are now private --- src/events/mod.rs | 3 - .../builder}/chunk/header.rs | 120 +----------------- src/{file_repr => file/builder}/chunk/mod.rs | 0 .../builder}/chunk/track.rs | 0 .../builder}/chunk/unknown_chunk.rs | 0 .../file => file/builder/events}/chunk.rs | 6 +- .../file => file/builder/events}/mod.rs | 5 +- src/{file_repr => file/builder}/format.rs | 0 src/file/{builder.rs => builder/mod.rs} | 34 ++++- src/{file_repr => file}/meta/key_signature.rs | 0 src/{file_repr => file}/meta/mod.rs | 4 +- src/{file_repr => file}/meta/smpte_offset.rs | 0 src/{file_repr => file}/meta/tempo.rs | 0 src/{file_repr => file}/meta/text.rs | 0 .../meta/time_signature.rs | 0 src/file/mod.rs | 12 +- src/file/timing/mod.rs | 119 +++++++++++++++++ src/{file_repr => file/timing}/smpte.rs | 0 src/{file_repr => file}/track/event.rs | 0 src/{file_repr => file}/track/message.rs | 0 src/file/{track.rs => track/mod.rs} | 12 +- src/file_repr/mod.rs | 35 ----- src/file_repr/track/mod.rs | 8 -- src/lib.rs | 4 +- src/reader/mod.rs | 12 +- 25 files changed, 193 insertions(+), 181 deletions(-) rename src/{file_repr => file/builder}/chunk/header.rs (64%) rename src/{file_repr => file/builder}/chunk/mod.rs (100%) rename src/{file_repr => file/builder}/chunk/track.rs (100%) rename src/{file_repr => file/builder}/chunk/unknown_chunk.rs (100%) rename src/{events/file => file/builder/events}/chunk.rs (91%) rename src/{events/file => file/builder/events}/mod.rs (96%) rename src/{file_repr => file/builder}/format.rs (100%) rename src/file/{builder.rs => builder/mod.rs} (84%) rename src/{file_repr => file}/meta/key_signature.rs (100%) rename src/{file_repr => file}/meta/mod.rs (100%) rename src/{file_repr => file}/meta/smpte_offset.rs (100%) rename src/{file_repr => file}/meta/tempo.rs (100%) rename src/{file_repr => file}/meta/text.rs (100%) rename src/{file_repr => file}/meta/time_signature.rs (100%) create mode 100644 src/file/timing/mod.rs rename src/{file_repr => file/timing}/smpte.rs (100%) rename src/{file_repr => file}/track/event.rs (100%) rename src/{file_repr => file}/track/message.rs (100%) rename src/file/{track.rs => track/mod.rs} (93%) delete mode 100644 src/file_repr/mod.rs delete mode 100644 src/file_repr/track/mod.rs diff --git a/src/events/mod.rs b/src/events/mod.rs index ad57866..0b1688e 100644 --- a/src/events/mod.rs +++ b/src/events/mod.rs @@ -2,8 +2,5 @@ The "root" event types for live streams and files "#] -mod file; -pub use file::*; - mod live; pub use live::*; diff --git a/src/file_repr/chunk/header.rs b/src/file/builder/chunk/header.rs similarity index 64% rename from src/file_repr/chunk/header.rs rename to src/file/builder/chunk/header.rs index dbccc0d..2c8c3a9 100644 --- a/src/file_repr/chunk/header.rs +++ b/src/file/builder/chunk/header.rs @@ -1,4 +1,7 @@ -use crate::{prelude::*, reader::ReaderError}; +use crate::{ + file::builder::{FormatType, RawFormat}, + prelude::*, +}; #[doc = r#" The header chunk at the beginning of the file specifies some basic information about @@ -108,121 +111,6 @@ impl RawHeaderChunk { } } -/// The header timing type. -/// -/// This is either the number of ticks per quarter note or -/// the alternative SMTPE format. See the [`RawHeaderChunk`] docs for more information. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -#[cfg_attr(feature = "bevy", derive(bevy::reflect::Reflect))] -pub enum Timing { - /// The midi file's delta times are defined using a tick rate per quarter note - TicksPerQuarterNote(TicksPerQuarterNote), - - /// The midi file's delta times are defined using an SMPTE and MIDI Time Code - Smpte(SmpteHeader), -} - -/// A representation of the `tpqn` timing for a MIDI file -#[derive(Debug, Clone, PartialEq, Eq, Copy)] -#[cfg_attr(feature = "bevy", derive(bevy::reflect::Reflect))] -pub struct TicksPerQuarterNote { - pub(crate) inner: [u8; 2], -} -impl TicksPerQuarterNote { - /// Returns the ticks per quarter note for the file. - pub const fn ticks_per_quarter_note(&self) -> u16 { - let v = u16::from_be_bytes(self.inner); - v & 0x7FFF - } -} - -/// A representation of the `smpte` timing for a MIDI file -#[derive(Debug, Clone, PartialEq, Eq, Copy)] -#[cfg_attr(feature = "bevy", derive(bevy::reflect::Reflect))] -pub struct SmpteHeader { - pub(crate) fps: SmpteFps, - pub(crate) ticks_per_frame: DataByte, -} - -impl SmpteHeader { - fn new(bytes: [u8; 2]) -> Result { - //first byte is known to be 1 when calling this - //Bits 14 thru 8 contain one of the four values -24, -25, -29, or -30 - let byte = bytes[0] as i8; - - let frame = match byte { - -24 => SmpteFps::TwentyFour, - -25 => SmpteFps::TwentyFive, - -29 => { - //drop frame (29.997) - SmpteFps::TwentyNine - } - -30 => SmpteFps::Thirty, - _ => return Err(ParseError::Smpte(SmpteError::HeaderFrameTime(byte))), - }; - let ticks_per_frame = DataByte::new(bytes[1])?; - Ok(Self { - fps: frame, - ticks_per_frame, - }) - } - - /// Returns the frames per second - pub const fn fps(&self) -> SmpteFps { - self.fps - } - - /// Returns the ticks per frame - pub const fn ticks_per_frame(&self) -> u8 { - self.ticks_per_frame.0 - } -} - -impl Timing { - /// The tickrate per quarter note defines what a "quarter note" means. - /// - /// The leading bit of the u16 is disregarded, so 1-32767 - pub const fn new_ticks_per_quarter_note(tpqn: u16) -> Self { - let msb = (tpqn >> 8) as u8; - let lsb = (tpqn & 0x00FF) as u8; - Self::TicksPerQuarterNote(TicksPerQuarterNote { inner: [msb, lsb] }) - } - - /// Define the timing in terms of fps and ticks per frame - pub const fn new_smpte(fps: SmpteFps, ticks_per_frame: DataByte) -> Self { - Self::Smpte(SmpteHeader { - fps, - ticks_per_frame, - }) - } - - pub(crate) fn read<'slc, 'r, R: MidiSource<'slc>>( - reader: &'r mut Reader, - ) -> ReadResult { - let bytes = reader.read_exact_size()?; - match bytes[0] >> 7 { - 0 => { - //this is ticks per quarter_note - Ok(Timing::TicksPerQuarterNote(TicksPerQuarterNote { - inner: bytes, - })) - } - 1 => Ok(Timing::Smpte(SmpteHeader::new(bytes).map_err(|e| { - ReaderError::new(reader.buffer_position(), e.into()) - })?)), - t => Err(inv_data(reader, HeaderError::InvalidTiming(t))), - } - } - /// Returns Some if the midi timing is defined - /// as ticks per quarter note - pub const fn ticks_per_quarter_note(&self) -> Option { - match self { - Self::TicksPerQuarterNote(t) => Some(t.ticks_per_quarter_note()), - _ => None, - } - } -} - #[test] fn ensure_timing_encoding_of_tpqn() { assert_eq!( diff --git a/src/file_repr/chunk/mod.rs b/src/file/builder/chunk/mod.rs similarity index 100% rename from src/file_repr/chunk/mod.rs rename to src/file/builder/chunk/mod.rs diff --git a/src/file_repr/chunk/track.rs b/src/file/builder/chunk/track.rs similarity index 100% rename from src/file_repr/chunk/track.rs rename to src/file/builder/chunk/track.rs diff --git a/src/file_repr/chunk/unknown_chunk.rs b/src/file/builder/chunk/unknown_chunk.rs similarity index 100% rename from src/file_repr/chunk/unknown_chunk.rs rename to src/file/builder/chunk/unknown_chunk.rs diff --git a/src/events/file/chunk.rs b/src/file/builder/events/chunk.rs similarity index 91% rename from src/events/file/chunk.rs rename to src/file/builder/events/chunk.rs index 6762ee2..60e999c 100644 --- a/src/events/file/chunk.rs +++ b/src/file/builder/events/chunk.rs @@ -1,4 +1,4 @@ -use crate::prelude::*; +use crate::file::chunk::{RawHeaderChunk, RawTrackChunk, UnknownChunk}; #[doc = r#" Reads the full length of all chunk types @@ -23,7 +23,7 @@ pub enum ChunkEvent<'a> { /// See [`UnknownChunk`] for a breakdown on layout Unknown(UnknownChunk<'a>), /// End of File - EOF, + Eof, } impl From for ChunkEvent<'_> { @@ -48,6 +48,6 @@ impl ChunkEvent<'_> { /// True if the event is the end of a file #[inline] pub const fn is_eof(&self) -> bool { - matches!(self, Self::EOF) + matches!(self, Self::Eof) } } diff --git a/src/events/file/mod.rs b/src/file/builder/events/mod.rs similarity index 96% rename from src/events/file/mod.rs rename to src/file/builder/events/mod.rs index 2f15689..ebce1b3 100644 --- a/src/events/file/mod.rs +++ b/src/file/builder/events/mod.rs @@ -1,4 +1,7 @@ -use crate::prelude::*; +use crate::{ + file::chunk::{RawHeaderChunk, TrackChunkHeader, UnknownChunk}, + prelude::*, +}; mod chunk; pub use chunk::*; diff --git a/src/file_repr/format.rs b/src/file/builder/format.rs similarity index 100% rename from src/file_repr/format.rs rename to src/file/builder/format.rs diff --git a/src/file/builder.rs b/src/file/builder/mod.rs similarity index 84% rename from src/file/builder.rs rename to src/file/builder/mod.rs index 7b922bc..24d1a40 100644 --- a/src/file/builder.rs +++ b/src/file/builder/mod.rs @@ -1,8 +1,16 @@ -use alloc::vec::Vec; +mod format; +pub use format::*; -use crate::{prelude::*, reader::ReaderErrorKind}; +pub mod chunk; +pub mod events; use super::MidiFile; +use crate::{ + file::{builder::chunk::UnknownChunk, events::ChunkEvent}, + prelude::*, + reader::ReaderErrorKind, +}; +use alloc::vec::Vec; #[derive(Default)] pub enum FormatStage<'a> { @@ -106,7 +114,7 @@ impl<'a> MidiFileBuilder<'a> { self.unknown_chunks.push(data); Ok(()) } - EOF => Err(ReaderErrorKind::OutOfBounds), + Eof => Err(ReaderErrorKind::OutOfBounds), } } pub fn build(self) -> Result, FileError> { @@ -120,3 +128,23 @@ impl<'a> MidiFileBuilder<'a> { Ok(MidiFile { format, timing }) } } + +/// Represents a 4 character type +/// +/// Each chunk has a 4-character type and a 32-bit length, +/// which is the number of bytes in the chunk. This structure allows +/// future chunk types to be designed which may be easily be ignored +/// if encountered by a program written before the chunk type is introduced. +#[derive(Copy, Debug, Clone, PartialEq, Eq)] +pub enum MidiChunkType { + /// Represents the byte length of the midi header. + /// + /// Begins with `"MThd"` + Header, + /// Represents the byte length of a midi track + /// + /// Begins with `"MTrk"` + Track, + /// A chunk type that is not known by this crate + Unknown, +} diff --git a/src/file_repr/meta/key_signature.rs b/src/file/meta/key_signature.rs similarity index 100% rename from src/file_repr/meta/key_signature.rs rename to src/file/meta/key_signature.rs diff --git a/src/file_repr/meta/mod.rs b/src/file/meta/mod.rs similarity index 100% rename from src/file_repr/meta/mod.rs rename to src/file/meta/mod.rs index 4cc9c15..eb37c1a 100644 --- a/src/file_repr/meta/mod.rs +++ b/src/file/meta/mod.rs @@ -3,8 +3,6 @@ Contains types that deal with file ['MetaMessage']s "#] mod tempo; - -use alloc::borrow::Cow; pub use tempo::*; mod time_signature; pub use time_signature::*; @@ -16,6 +14,8 @@ mod smpte_offset; pub use smpte_offset::*; use crate::{prelude::*, reader::ReaderError}; +use alloc::borrow::Cow; + /// A "meta message", as defined by the SMF spec. /// These are in tracks. /// These events carry metadata about the track, such as tempo, time signature, copyright, etc... diff --git a/src/file_repr/meta/smpte_offset.rs b/src/file/meta/smpte_offset.rs similarity index 100% rename from src/file_repr/meta/smpte_offset.rs rename to src/file/meta/smpte_offset.rs diff --git a/src/file_repr/meta/tempo.rs b/src/file/meta/tempo.rs similarity index 100% rename from src/file_repr/meta/tempo.rs rename to src/file/meta/tempo.rs diff --git a/src/file_repr/meta/text.rs b/src/file/meta/text.rs similarity index 100% rename from src/file_repr/meta/text.rs rename to src/file/meta/text.rs diff --git a/src/file_repr/meta/time_signature.rs b/src/file/meta/time_signature.rs similarity index 100% rename from src/file_repr/meta/time_signature.rs rename to src/file/meta/time_signature.rs diff --git a/src/file/mod.rs b/src/file/mod.rs index c05131e..ba61c48 100644 --- a/src/file/mod.rs +++ b/src/file/mod.rs @@ -5,24 +5,30 @@ TODO "#] mod builder; +pub(crate) use builder::*; -use alloc::{borrow::Cow, vec::Vec}; -use builder::*; mod format; pub use format::*; + mod track; pub use track::*; mod timed_event_iter; pub use timed_event_iter::*; +mod timing; +pub use timing::*; + +mod meta; +pub use meta::*; + use crate::{ ParseError, events::LiveEvent, message::Timed, - prelude::{FormatType, Timing}, reader::{ReadResult, Reader, ReaderError, ReaderErrorKind}, }; +use alloc::{borrow::Cow, vec::Vec}; #[doc = r#" TODO diff --git a/src/file/timing/mod.rs b/src/file/timing/mod.rs new file mode 100644 index 0000000..9f42c6e --- /dev/null +++ b/src/file/timing/mod.rs @@ -0,0 +1,119 @@ +mod smpte; +pub use smpte::*; + +use crate::{prelude::*, reader::ReaderError}; + +/// The header timing type. +/// +/// This is either the number of ticks per quarter note or +/// the alternative SMTPE format. See the [`RawHeaderChunk`] docs for more information. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[cfg_attr(feature = "bevy", derive(bevy::reflect::Reflect))] +pub enum Timing { + /// The midi file's delta times are defined using a tick rate per quarter note + TicksPerQuarterNote(TicksPerQuarterNote), + + /// The midi file's delta times are defined using an SMPTE and MIDI Time Code + Smpte(SmpteHeader), +} + +impl Timing { + /// The tickrate per quarter note defines what a "quarter note" means. + /// + /// The leading bit of the u16 is disregarded, so 1-32767 + pub const fn new_ticks_per_quarter_note(tpqn: u16) -> Self { + let msb = (tpqn >> 8) as u8; + let lsb = (tpqn & 0x00FF) as u8; + Self::TicksPerQuarterNote(TicksPerQuarterNote { inner: [msb, lsb] }) + } + + /// Define the timing in terms of fps and ticks per frame + pub const fn new_smpte(fps: SmpteFps, ticks_per_frame: DataByte) -> Self { + Self::Smpte(SmpteHeader { + fps, + ticks_per_frame, + }) + } + + pub(crate) fn read<'slc, 'r, R: MidiSource<'slc>>( + reader: &'r mut Reader, + ) -> ReadResult { + let bytes = reader.read_exact_size()?; + match bytes[0] >> 7 { + 0 => { + //this is ticks per quarter_note + Ok(Timing::TicksPerQuarterNote(TicksPerQuarterNote { + inner: bytes, + })) + } + 1 => Ok(Timing::Smpte(SmpteHeader::new(bytes).map_err(|e| { + ReaderError::new(reader.buffer_position(), e.into()) + })?)), + t => Err(inv_data(reader, HeaderError::InvalidTiming(t))), + } + } + /// Returns Some if the midi timing is defined + /// as ticks per quarter note + pub const fn ticks_per_quarter_note(&self) -> Option { + match self { + Self::TicksPerQuarterNote(t) => Some(t.ticks_per_quarter_note()), + _ => None, + } + } +} + +/// A representation of the `tpqn` timing for a MIDI file +#[derive(Debug, Clone, PartialEq, Eq, Copy)] +#[cfg_attr(feature = "bevy", derive(bevy::reflect::Reflect))] +pub struct TicksPerQuarterNote { + pub(crate) inner: [u8; 2], +} +impl TicksPerQuarterNote { + /// Returns the ticks per quarter note for the file. + pub const fn ticks_per_quarter_note(&self) -> u16 { + let v = u16::from_be_bytes(self.inner); + v & 0x7FFF + } +} + +/// A representation of the `smpte` timing for a MIDI file +#[derive(Debug, Clone, PartialEq, Eq, Copy)] +#[cfg_attr(feature = "bevy", derive(bevy::reflect::Reflect))] +pub struct SmpteHeader { + pub(crate) fps: SmpteFps, + pub(crate) ticks_per_frame: DataByte, +} + +impl SmpteHeader { + fn new(bytes: [u8; 2]) -> Result { + //first byte is known to be 1 when calling this + //Bits 14 thru 8 contain one of the four values -24, -25, -29, or -30 + let byte = bytes[0] as i8; + + let frame = match byte { + -24 => SmpteFps::TwentyFour, + -25 => SmpteFps::TwentyFive, + -29 => { + //drop frame (29.997) + SmpteFps::TwentyNine + } + -30 => SmpteFps::Thirty, + _ => return Err(ParseError::Smpte(SmpteError::HeaderFrameTime(byte))), + }; + let ticks_per_frame = DataByte::new(bytes[1])?; + Ok(Self { + fps: frame, + ticks_per_frame, + }) + } + + /// Returns the frames per second + pub const fn fps(&self) -> SmpteFps { + self.fps + } + + /// Returns the ticks per frame + pub const fn ticks_per_frame(&self) -> u8 { + self.ticks_per_frame.0 + } +} diff --git a/src/file_repr/smpte.rs b/src/file/timing/smpte.rs similarity index 100% rename from src/file_repr/smpte.rs rename to src/file/timing/smpte.rs diff --git a/src/file_repr/track/event.rs b/src/file/track/event.rs similarity index 100% rename from src/file_repr/track/event.rs rename to src/file/track/event.rs diff --git a/src/file_repr/track/message.rs b/src/file/track/message.rs similarity index 100% rename from src/file_repr/track/message.rs rename to src/file/track/message.rs diff --git a/src/file/track.rs b/src/file/track/mod.rs similarity index 93% rename from src/file/track.rs rename to src/file/track/mod.rs index 4105181..7caadc7 100644 --- a/src/file/track.rs +++ b/src/file/track/mod.rs @@ -1,10 +1,20 @@ +#![doc = r#" +Contains types that identify events in tracks +"#] + +mod event; +pub use event::*; + +mod message; +pub use message::*; + use alloc::vec::Vec; use crate::{ channel::Channel, events::LiveEvent, + file::{BytesText, SmpteOffset, Tempo, TimeSignature}, message::Ticked, - prelude::{BytesText, SmpteOffset, Tempo, TimeSignature, TrackEvent, TrackMessage}, }; #[doc = r#" diff --git a/src/file_repr/mod.rs b/src/file_repr/mod.rs deleted file mode 100644 index 2fd782b..0000000 --- a/src/file_repr/mod.rs +++ /dev/null @@ -1,35 +0,0 @@ -#![doc = r#" -Contains byte-analogous representations to raw MIDI files - -These types are distinct from [`MidiFile`](crate::prelude::MidiFile), -as MidiFile interprets these types and fundamentally restructures the data. -"#] - -//pub mod builder; -mod format; -pub use format::*; -pub mod chunk; -pub mod meta; -mod smpte; -pub mod track; -pub use smpte::*; - -/// Represents a 4 character type -/// -/// Each chunk has a 4-character type and a 32-bit length, -/// which is the number of bytes in the chunk. This structure allows -/// future chunk types to be designed which may be easily be ignored -/// if encountered by a program written before the chunk type is introduced. -#[derive(Copy, Debug, Clone, PartialEq, Eq)] -pub enum MidiChunkType { - /// Represents the byte length of the midi header. - /// - /// Begins with `"MThd"` - Header, - /// Represents the byte length of a midi track - /// - /// Begins with `"MTrk"` - Track, - /// A chunk type that is not known by this crate - Unknown, -} diff --git a/src/file_repr/track/mod.rs b/src/file_repr/track/mod.rs deleted file mode 100644 index 174c352..0000000 --- a/src/file_repr/track/mod.rs +++ /dev/null @@ -1,8 +0,0 @@ -#![doc = r#" -Contains types that identify events in tracks -"#] - -mod event; -pub use event::*; -mod message; -pub use message::*; diff --git a/src/lib.rs b/src/lib.rs index 6f5589b..fd778a7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -113,7 +113,6 @@ pub mod channel; pub mod events; pub mod file; -pub mod file_repr; pub mod reader; @@ -158,13 +157,12 @@ pub mod prelude { channel::*, events::*, file::*, - file_repr::{chunk::*, meta::*, track::*, *}, message::{MidiMessage, channel::*, system::*, time::*}, micros::*, note, }; - pub use crate::reader::{MidiSource, ReadResult, Reader}; + pub use crate::reader::{MidiSource, ReadResult, Reader, ReaderError, ReaderErrorKind}; #[allow(unused_imports)] pub(crate) use crate::reader::inv_data; diff --git a/src/reader/mod.rs b/src/reader/mod.rs index 1629599..b0e8e75 100644 --- a/src/reader/mod.rs +++ b/src/reader/mod.rs @@ -17,7 +17,13 @@ pub use error::*; pub use source::*; use state::{ParseState, ReaderState}; -use crate::prelude::*; +use crate::{ + file::{ + chunk::{RawHeaderChunk, RawTrackChunk, TrackChunkHeader, UnknownChunk}, + events::{ChunkEvent, FileEvent}, + }, + prelude::*, +}; #[doc = r#" A MIDI event reader. @@ -348,7 +354,7 @@ impl<'slc, R: MidiSource<'slc>> Reader { if e.is_out_of_bounds() { // Inside Midi + UnexpectedEof should only fire at the end of a file. self.state.set_parse_state(ParseState::Done); - return Ok(ChunkEvent::EOF); + return Ok(ChunkEvent::Eof); } else { return Err(e); } @@ -386,7 +392,7 @@ impl<'slc, R: MidiSource<'slc>> Reader { self.state.set_parse_state(ParseState::InsideMidi); continue; } - ParseState::Done => break ChunkEvent::EOF, + ParseState::Done => break ChunkEvent::Eof, } }; From f06dc010c066fbee458167c05aa5bd92451f9998 Mon Sep 17 00:00:00 2001 From: dsgallups Date: Fri, 3 Oct 2025 05:26:54 -0400 Subject: [PATCH 06/11] fix: update imports --- src/file/builder/events/chunk.rs | 2 +- src/file/builder/events/mod.rs | 10 ++++- src/file/builder/format.rs | 64 +------------------------------- src/file/builder/mod.rs | 27 +++----------- src/file/format.rs | 62 +++++++++++++++++++++++++++++++ src/file/mod.rs | 9 +++-- src/reader/mod.rs | 6 +-- tests/read_all.rs | 6 +-- tests/simple_midi/main.rs | 2 +- 9 files changed, 90 insertions(+), 98 deletions(-) diff --git a/src/file/builder/events/chunk.rs b/src/file/builder/events/chunk.rs index 60e999c..0338bae 100644 --- a/src/file/builder/events/chunk.rs +++ b/src/file/builder/events/chunk.rs @@ -1,4 +1,4 @@ -use crate::file::chunk::{RawHeaderChunk, RawTrackChunk, UnknownChunk}; +use crate::file::builder::chunk::{RawHeaderChunk, RawTrackChunk, UnknownChunk}; #[doc = r#" Reads the full length of all chunk types diff --git a/src/file/builder/events/mod.rs b/src/file/builder/events/mod.rs index ebce1b3..a9576a2 100644 --- a/src/file/builder/events/mod.rs +++ b/src/file/builder/events/mod.rs @@ -1,5 +1,11 @@ +#![doc = r#" +Contains events that should be yielded when parsing a midi file. + +You will may utilize these types when using a [`Reader`]. +"#] + use crate::{ - file::chunk::{RawHeaderChunk, TrackChunkHeader, UnknownChunk}, + file::builder::chunk::{RawHeaderChunk, TrackChunkHeader, UnknownChunk}, prelude::*, }; @@ -67,7 +73,7 @@ pub enum FileEvent<'a> { TrackEvent(TrackEvent<'a>), /// Yielded when no more bytes can be read - EOF, + Eof, } impl From for FileEvent<'_> { diff --git a/src/file/builder/format.rs b/src/file/builder/format.rs index 5c14cd3..20be1e9 100644 --- a/src/file/builder/format.rs +++ b/src/file/builder/format.rs @@ -1,3 +1,5 @@ +use crate::file::FormatType; + #[doc = r#" FF 00 02 Sequence Number @@ -59,65 +61,3 @@ impl RawFormat { } } } - -#[doc = r#" -Identifies the type of the MIDI file. - -# Layout - -A Format 0 file has a header chunk followed by one track chunk. -It is the most interchangeable representation of data. It is very -useful for a simple single-track player in a program which needs -to make synthesisers make sounds, but which is primarily concerned -with something else such as mixers or sound effect boxes. It is very -desirable to be able to produce such a format, even if your program -is track-based, in order to work with these simple programs. - -A Format 1 or 2 file has a header chunk followed by one or more -track chunks. programs which support several simultaneous tracks -should be able to save and read data in format 1, a vertically one -dimensional form, that is, as a collection of tracks. Programs which -support several independent patterns should be able to save and read -data in format 2, a horizontally one dimensional form. Providing these -minimum capabilities will ensure maximum interchangeability. - -In a MIDI system with a computer and a SMPTE synchroniser which uses -Song Pointer and Timing Clock, tempo maps (which describe the tempo -throughout the track, and may also include time signature information, -so that the bar number may be derived) are generally created on the -computer. To use them with the synchroniser, it is necessary to transfer -them from the computer. To make it easy for the synchroniser to extract -this data from a MIDI File, tempo information should always be stored -in the first MTrk chunk. For a format 0 file, the tempo will be -scattered through the track and the tempo map reader should ignore the -intervening events; for a format 1 file, the tempo map must be stored -as the first track. It is polite to a tempo map reader to offer your -user the ability to make a format 0 file with just the tempo, unless -you can use format 1. - -All MIDI Files should specify tempo and time signature. If they don't, -the time signature is assumed to be 4/4, and the tempo 120 beats per minute. -In format 0, these meta-events should occur at least at the beginning of the -single multi-channel track. In format 1, these meta-events should be contained -in the first track. In format 2, each of the temporally independent patterns -should contain at least initial time signature and tempo information. - -Format IDs to support other structures may be defined in the future. A program -encountering an unknown format ID may still read other MTrk chunks it finds from -the file, as format 1 or 2, if its user can make sense of them and arrange -them into some other structure if appropriate. Also, more parameters may be -added to the MThd chunk in the future: it is important to read and honour the -length, even if it is longer than 6. - -"#] -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum FormatType { - /// Format 0 - SingleMultiChannel, - - /// Format 1 - Simultaneous, - - /// Format 2 - SequentiallyIndependent, -} diff --git a/src/file/builder/mod.rs b/src/file/builder/mod.rs index 24d1a40..2092db5 100644 --- a/src/file/builder/mod.rs +++ b/src/file/builder/mod.rs @@ -6,14 +6,14 @@ pub mod events; use super::MidiFile; use crate::{ - file::{builder::chunk::UnknownChunk, events::ChunkEvent}, + file::builder::{chunk::UnknownChunk, events::ChunkEvent}, prelude::*, reader::ReaderErrorKind, }; use alloc::vec::Vec; #[derive(Default)] -pub enum FormatStage<'a> { +enum FormatStage<'a> { #[default] Unknown, KnownFormat(RawFormat), @@ -21,6 +21,7 @@ pub enum FormatStage<'a> { Formatted(Format<'a>), } +/// A builder used to create a new [`MidiFile`]. #[derive(Default)] pub struct MidiFileBuilder<'a> { format: FormatStage<'a>, @@ -30,6 +31,7 @@ pub struct MidiFileBuilder<'a> { } impl<'a> MidiFileBuilder<'a> { + /// Handles a chunk of a midi file. pub fn handle_chunk<'b: 'a>(&mut self, chunk: ChunkEvent<'b>) -> Result<(), ReaderErrorKind> { use ChunkEvent::*; match chunk { @@ -117,6 +119,7 @@ impl<'a> MidiFileBuilder<'a> { Eof => Err(ReaderErrorKind::OutOfBounds), } } + /// Attempts to finish the midifile from the provided chunks. pub fn build(self) -> Result, FileError> { let FormatStage::Formatted(format) = self.format else { return Err(FileError::NoFormat); @@ -128,23 +131,3 @@ impl<'a> MidiFileBuilder<'a> { Ok(MidiFile { format, timing }) } } - -/// Represents a 4 character type -/// -/// Each chunk has a 4-character type and a 32-bit length, -/// which is the number of bytes in the chunk. This structure allows -/// future chunk types to be designed which may be easily be ignored -/// if encountered by a program written before the chunk type is introduced. -#[derive(Copy, Debug, Clone, PartialEq, Eq)] -pub enum MidiChunkType { - /// Represents the byte length of the midi header. - /// - /// Begins with `"MThd"` - Header, - /// Represents the byte length of a midi track - /// - /// Begins with `"MTrk"` - Track, - /// A chunk type that is not known by this crate - Unknown, -} diff --git a/src/file/format.rs b/src/file/format.rs index c7e6b74..cdf820f 100644 --- a/src/file/format.rs +++ b/src/file/format.rs @@ -30,3 +30,65 @@ pub enum Format<'a> { /// Format 2 SequentiallyIndependent(Vec>), } + +#[doc = r#" +Identifies the type of the MIDI file. + +# Layout + +A Format 0 file has a header chunk followed by one track chunk. +It is the most interchangeable representation of data. It is very +useful for a simple single-track player in a program which needs +to make synthesisers make sounds, but which is primarily concerned +with something else such as mixers or sound effect boxes. It is very +desirable to be able to produce such a format, even if your program +is track-based, in order to work with these simple programs. + +A Format 1 or 2 file has a header chunk followed by one or more +track chunks. programs which support several simultaneous tracks +should be able to save and read data in format 1, a vertically one +dimensional form, that is, as a collection of tracks. Programs which +support several independent patterns should be able to save and read +data in format 2, a horizontally one dimensional form. Providing these +minimum capabilities will ensure maximum interchangeability. + +In a MIDI system with a computer and a SMPTE synchroniser which uses +Song Pointer and Timing Clock, tempo maps (which describe the tempo +throughout the track, and may also include time signature information, +so that the bar number may be derived) are generally created on the +computer. To use them with the synchroniser, it is necessary to transfer +them from the computer. To make it easy for the synchroniser to extract +this data from a MIDI File, tempo information should always be stored +in the first MTrk chunk. For a format 0 file, the tempo will be +scattered through the track and the tempo map reader should ignore the +intervening events; for a format 1 file, the tempo map must be stored +as the first track. It is polite to a tempo map reader to offer your +user the ability to make a format 0 file with just the tempo, unless +you can use format 1. + +All MIDI Files should specify tempo and time signature. If they don't, +the time signature is assumed to be 4/4, and the tempo 120 beats per minute. +In format 0, these meta-events should occur at least at the beginning of the +single multi-channel track. In format 1, these meta-events should be contained +in the first track. In format 2, each of the temporally independent patterns +should contain at least initial time signature and tempo information. + +Format IDs to support other structures may be defined in the future. A program +encountering an unknown format ID may still read other MTrk chunks it finds from +the file, as format 1 or 2, if its user can make sense of them and arrange +them into some other structure if appropriate. Also, more parameters may be +added to the MThd chunk in the future: it is important to read and honour the +length, even if it is longer than 6. + +"#] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FormatType { + /// Format 0 + SingleMultiChannel, + + /// Format 1 + Simultaneous, + + /// Format 2 + SequentiallyIndependent, +} diff --git a/src/file/mod.rs b/src/file/mod.rs index ba61c48..61ca344 100644 --- a/src/file/mod.rs +++ b/src/file/mod.rs @@ -1,11 +1,11 @@ #![doc = r#" Rusty representation of a [`MidiFile`] - -TODO "#] -mod builder; -pub(crate) use builder::*; +/// Contains the [`MidiFileBuilder`] and assocaited +/// +/// MIDI file parsing events. +pub mod builder; mod format; pub use format::*; @@ -25,6 +25,7 @@ pub use meta::*; use crate::{ ParseError, events::LiveEvent, + file::builder::MidiFileBuilder, message::Timed, reader::{ReadResult, Reader, ReaderError, ReaderErrorKind}, }; diff --git a/src/reader/mod.rs b/src/reader/mod.rs index b0e8e75..fee18fe 100644 --- a/src/reader/mod.rs +++ b/src/reader/mod.rs @@ -18,7 +18,7 @@ pub use source::*; use state::{ParseState, ReaderState}; use crate::{ - file::{ + file::builder::{ chunk::{RawHeaderChunk, RawTrackChunk, TrackChunkHeader, UnknownChunk}, events::{ChunkEvent, FileEvent}, }, @@ -276,7 +276,7 @@ impl<'slc, R: MidiSource<'slc>> Reader { Ok(c) => c, Err(e) => { if e.is_out_of_bounds() { - return Ok(FileEvent::EOF); + return Ok(FileEvent::Eof); } else { return Err(e); } @@ -321,7 +321,7 @@ impl<'slc, R: MidiSource<'slc>> Reader { let ev = TrackEvent::read(self, &mut running_status)?; break FileEvent::TrackEvent(ev); } - ParseState::Done => break FileEvent::EOF, + ParseState::Done => break FileEvent::Eof, } }; diff --git a/tests/read_all.rs b/tests/read_all.rs index e73abf1..b87dda5 100644 --- a/tests/read_all.rs +++ b/tests/read_all.rs @@ -1,4 +1,4 @@ -use midix::{events::FileEvent, reader::Reader}; +use midix::{file::builder::events::FileEvent, reader::Reader}; fn loop_through(bytes: &[u8]) { let mut reader = Reader::from_byte_slice(bytes); @@ -6,7 +6,7 @@ fn loop_through(bytes: &[u8]) { loop { match reader.read_event() { Ok(e) => { - if e == FileEvent::EOF { + if e == FileEvent::Eof { break; } } @@ -48,7 +48,7 @@ fn read_pi_damaged() { let mut reader = Reader::from_byte_slice(bytes); while let Ok(e) = reader.read_event() { - if e == FileEvent::EOF { + if e == FileEvent::Eof { panic!("Corrupted file should not have yielded an eof event") } } diff --git a/tests/simple_midi/main.rs b/tests/simple_midi/main.rs index 9f75162..9dab529 100644 --- a/tests/simple_midi/main.rs +++ b/tests/simple_midi/main.rs @@ -1,4 +1,4 @@ -use midix::prelude::*; +use midix::{file::builder::events::FileEvent, prelude::*}; mod parsed; /* From 496cc0e8b45266832b9703abe7d76c2052754433 Mon Sep 17 00:00:00 2001 From: dsgallups Date: Fri, 3 Oct 2025 05:36:37 -0400 Subject: [PATCH 07/11] fix: docs --- README.md | 2 +- src/byte/mod.rs | 2 +- src/file/builder/chunk/mod.rs | 4 ++-- src/file/builder/{events => event}/chunk.rs | 4 ++-- src/file/builder/{events => event}/mod.rs | 2 +- src/file/builder/mod.rs | 5 +++-- src/file/mod.rs | 4 +--- src/file/timed_event_iter.rs | 2 +- src/file/timing/mod.rs | 2 +- src/lib.rs | 2 +- src/message/system/common.rs | 2 +- src/message/system/exclusive.rs | 3 +-- src/reader/mod.rs | 2 +- tests/read_all.rs | 2 +- tests/simple_midi/main.rs | 2 +- 15 files changed, 19 insertions(+), 21 deletions(-) rename src/file/builder/{events => event}/chunk.rs (86%) rename src/file/builder/{events => event}/mod.rs (97%) diff --git a/README.md b/README.md index f8e9bf2..59980ec 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ A suite of tools used to read, modify, and manage MIDI-related systems `midix` provides users with human readable MIDI structures without invariant states. That is, the midi 1.0 specification has been strongly typed such that programatic commands built with this crate uphold invariants. `midix` provides a parser ([`Reader`](crate::prelude::Reader)) to read events from `.mid` files. -calling [`Reader::read_event`](crate::prelude::Reader::read_event) will yield a [`FileEvent`](crate::prelude::FileEvent). +calling [`Reader::read_event`](crate::prelude::Reader::read_event) will yield a [`FileEvent`](crate::file::builder::event::FileEvent). Additionally, `midix` provides the user with [`LiveEvent::from_bytes`](crate::events::LiveEvent), which will parse events from a live MIDI source. diff --git a/src/byte/mod.rs b/src/byte/mod.rs index 432894b..727c704 100644 --- a/src/byte/mod.rs +++ b/src/byte/mod.rs @@ -301,7 +301,7 @@ pub trait MidiWriter { } */ -/// Copies the nightly only feature `as_array` for [T], but specifically for Cow. +/// Copies the nightly only feature `as_array` for `[T]`, but specifically for Cow. pub trait CowExt { /// Reinterpret this Cow as a reference to a static array fn as_array(&self) -> Option<&[u8; N]>; diff --git a/src/file/builder/chunk/mod.rs b/src/file/builder/chunk/mod.rs index 8872810..8cdb5c2 100644 --- a/src/file/builder/chunk/mod.rs +++ b/src/file/builder/chunk/mod.rs @@ -9,8 +9,8 @@ not fall into th ## [`RawHeaderChunk`] This chunk type contains meta information about the MIDI file, such as -- [`RawFormat`](crate::prelude::RawFormat), which identifies how tracks should be played, and the claimed track count -- [`Timing`], which defines how delta-seconds are to be interpreted +- [`RawFormat`](crate::file::builder::RawFormat), which identifies how tracks should be played, and the claimed track count +- [`Timing`](crate::prelude::Timing), which defines how delta-seconds are to be interpreted ## [`] diff --git a/src/file/builder/events/chunk.rs b/src/file/builder/event/chunk.rs similarity index 86% rename from src/file/builder/events/chunk.rs rename to src/file/builder/event/chunk.rs index 0338bae..2c8fe41 100644 --- a/src/file/builder/events/chunk.rs +++ b/src/file/builder/event/chunk.rs @@ -4,8 +4,8 @@ use crate::file::builder::chunk::{RawHeaderChunk, RawTrackChunk, UnknownChunk}; Reads the full length of all chunk types -This is different from [`FileEvent`] such that -[`FileEvent::TrackEvent`] is not used. Instead, +This is different from [`FileEvent`](crate::file::builder::event::FileEvent) such that +[`FileEvent::TrackEvent`](crate::file::builder::event::FileEvent::TrackEvent) is not used. Instead, the full set of bytes from the identified track are yielded. "#] pub enum ChunkEvent<'a> { diff --git a/src/file/builder/events/mod.rs b/src/file/builder/event/mod.rs similarity index 97% rename from src/file/builder/events/mod.rs rename to src/file/builder/event/mod.rs index a9576a2..f3e026d 100644 --- a/src/file/builder/events/mod.rs +++ b/src/file/builder/event/mod.rs @@ -19,7 +19,7 @@ This type is yielded by [`Reader::read_event`] and will be consumed by a Writer # Overview -Except [`FileEvent::EOF`] Events can be placed into two categories +Except [`FileEvent::Eof`] Events can be placed into two categories ## Chunk Events diff --git a/src/file/builder/mod.rs b/src/file/builder/mod.rs index 2092db5..d0f7b19 100644 --- a/src/file/builder/mod.rs +++ b/src/file/builder/mod.rs @@ -2,11 +2,12 @@ mod format; pub use format::*; pub mod chunk; -pub mod events; + +pub mod event; use super::MidiFile; use crate::{ - file::builder::{chunk::UnknownChunk, events::ChunkEvent}, + file::builder::{chunk::UnknownChunk, event::ChunkEvent}, prelude::*, reader::ReaderErrorKind, }; diff --git a/src/file/mod.rs b/src/file/mod.rs index 61ca344..c3947db 100644 --- a/src/file/mod.rs +++ b/src/file/mod.rs @@ -2,9 +2,7 @@ Rusty representation of a [`MidiFile`] "#] -/// Contains the [`MidiFileBuilder`] and assocaited -/// -/// MIDI file parsing events. +/// Contains the [`MidiFileBuilder`] and associated [`FileEvent`](builder::event::FileEvent)s. pub mod builder; mod format; diff --git a/src/file/timed_event_iter.rs b/src/file/timed_event_iter.rs index 097921f..c1efe59 100644 --- a/src/file/timed_event_iter.rs +++ b/src/file/timed_event_iter.rs @@ -23,7 +23,7 @@ impl<'a> Iterator for OptTimedEventIterator<'a> { } } -/// An iterator returned from [`ParsedMidiFile::into_events`] +/// An iterator returned from [`MidiFile::into_events`] pub struct TimedEventIterator<'a> { len_remaining: usize, timing: Timing, diff --git a/src/file/timing/mod.rs b/src/file/timing/mod.rs index 9f42c6e..b0f731f 100644 --- a/src/file/timing/mod.rs +++ b/src/file/timing/mod.rs @@ -6,7 +6,7 @@ use crate::{prelude::*, reader::ReaderError}; /// The header timing type. /// /// This is either the number of ticks per quarter note or -/// the alternative SMTPE format. See the [`RawHeaderChunk`] docs for more information. +/// the alternative SMTPE format. See the [`RawHeaderChunk`](crate::file::builder::chunk::RawHeaderChunk) docs for more information. #[derive(Debug, Clone, Copy, PartialEq, Eq)] #[cfg_attr(feature = "bevy", derive(bevy::reflect::Reflect))] pub enum Timing { diff --git a/src/lib.rs b/src/lib.rs index fd778a7..384f899 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -9,7 +9,7 @@ A suite of tools used to read, modify, and manage MIDI-related systems `midix` provides users with human readable MIDI structures without invariant states. That is, the midi 1.0 specification has been strongly typed such that programatic commands built with this crate uphold invariants. `midix` provides a parser ([`Reader`](crate::prelude::Reader)) to read events from `.mid` files. -calling [`Reader::read_event`](crate::prelude::Reader::read_event) will yield a [`FileEvent`](crate::prelude::FileEvent). +calling [`Reader::read_event`](crate::prelude::Reader::read_event) will yield a [`FileEvent`](crate::file::builder::event::FileEvent). Additionally, `midix` provides the user with [`LiveEvent::from_bytes`](crate::events::LiveEvent), which will parse events from a live MIDI source. diff --git a/src/message/system/common.rs b/src/message/system/common.rs index ad9b4af..3b02d81 100644 --- a/src/message/system/common.rs +++ b/src/message/system/common.rs @@ -13,7 +13,7 @@ pub enum SystemCommonMessage<'a> { /// /// System Exclusive events start with a `0xF0` byte and finish with a `0xF7` byte. /// - /// Note that `SystemExclusiveMessage` is found in both [`LiveEvent`]s and [`FileEvent`]s. + /// Note that `SystemExclusiveMessage` is found in both [`LiveEvent`]s and [`FileEvent`](crate::file::builder::event::FileEvent)s. SystemExclusive(SystemExclusiveMessage<'a>), /// An undefined System Common message diff --git a/src/message/system/exclusive.rs b/src/message/system/exclusive.rs index fca7bdc..62af7cb 100644 --- a/src/message/system/exclusive.rs +++ b/src/message/system/exclusive.rs @@ -1,8 +1,7 @@ use alloc::borrow::Cow; #[doc = r#" -A System Exclusive messsage, found in -both [`LiveEvent`](crate::prelude::LiveEvent)s and [`FileEvent`](crate::prelude::FileEvent)s. +A System Exclusive messsage, found in both [`LiveEvent`](crate::prelude::LiveEvent)s and [`FileEvent`](crate::file::builder::event::FileEvent)s. # Overview diff --git a/src/reader/mod.rs b/src/reader/mod.rs index fee18fe..3b33e1e 100644 --- a/src/reader/mod.rs +++ b/src/reader/mod.rs @@ -20,7 +20,7 @@ use state::{ParseState, ReaderState}; use crate::{ file::builder::{ chunk::{RawHeaderChunk, RawTrackChunk, TrackChunkHeader, UnknownChunk}, - events::{ChunkEvent, FileEvent}, + event::{ChunkEvent, FileEvent}, }, prelude::*, }; diff --git a/tests/read_all.rs b/tests/read_all.rs index b87dda5..fe7a272 100644 --- a/tests/read_all.rs +++ b/tests/read_all.rs @@ -1,4 +1,4 @@ -use midix::{file::builder::events::FileEvent, reader::Reader}; +use midix::{file::builder::event::FileEvent, reader::Reader}; fn loop_through(bytes: &[u8]) { let mut reader = Reader::from_byte_slice(bytes); diff --git a/tests/simple_midi/main.rs b/tests/simple_midi/main.rs index 9dab529..7a3f2fc 100644 --- a/tests/simple_midi/main.rs +++ b/tests/simple_midi/main.rs @@ -1,4 +1,4 @@ -use midix::{file::builder::events::FileEvent, prelude::*}; +use midix::{file::builder::event::FileEvent, prelude::*}; mod parsed; /* From c5d00624d72e099e1cbf3cbc4b970521e95c567a Mon Sep 17 00:00:00 2001 From: dsgallups Date: Fri, 3 Oct 2025 05:40:38 -0400 Subject: [PATCH 08/11] update doctests --- src/file/builder/chunk/mod.rs | 55 ++++++++++++++++++++++++++++++----- src/lib.rs | 1 + src/reader/mod.rs | 1 + 3 files changed, 49 insertions(+), 8 deletions(-) diff --git a/src/file/builder/chunk/mod.rs b/src/file/builder/chunk/mod.rs index 8cdb5c2..3498965 100644 --- a/src/file/builder/chunk/mod.rs +++ b/src/file/builder/chunk/mod.rs @@ -1,19 +1,58 @@ #![doc = r#" Contains types for MIDI file chunks -TODO - # Overview -MIDI has two chunk types. MIDI defines anything that does -not fall into th + +MIDI files are organized into chunks, each identified by a 4-character ASCII type identifier +followed by a 32-bit length field and then the chunk data. The Standard MIDI File (SMF) +specification defines two chunk types, though files may contain additional proprietary chunks. + +MIDI defines anything that does not fall into the standard chunk types as unknown chunks, +which can be safely ignored or processed based on application needs. ## [`RawHeaderChunk`] -This chunk type contains meta information about the MIDI file, such as -- [`RawFormat`](crate::file::builder::RawFormat), which identifies how tracks should be played, and the claimed track count -- [`Timing`](crate::prelude::Timing), which defines how delta-seconds are to be interpreted -## [`] +The header chunk (identified by "MThd") must be the first chunk in a MIDI file. This chunk +type contains meta information about the MIDI file, such as: + +- [`RawFormat`](crate::file::builder::RawFormat), which identifies how tracks should be played + (single track, simultaneous tracks, or independent tracks) and the number of tracks in the file +- [`Timing`](crate::prelude::Timing), which defines how delta-ticks (timestamps) are to be + interpreted - either as ticks per quarter note or in SMPTE time code format + +The header chunk always has a fixed length of 6 bytes. + +## Track Chunks + +Track chunks (identified by "MTrk") contain the actual MIDI events and timing information: + +- [`TrackChunkHeader`] - Contains only the length in bytes of the track data +- [`RawTrackChunk`] - Contains the complete track data which can be parsed into a sequence + of [`TrackEvent`](crate::prelude::TrackEvent)s, each with delta-time and event data + +Track chunks appear after the header chunk, and the number of track chunks should match +the track count specified in the header (though this is not strictly enforced by all +MIDI software). + +## [`UnknownChunk`] + +Any chunk with a type identifier other than "MThd" or "MTrk" is treated as an unknown chunk. +These chunks preserve their type identifier and data, allowing applications to either: +- Ignore them (the most common approach) +- Process them if they understand the proprietary format +- Preserve them when reading and writing files to maintain compatibility + +# Example Structure +A typical MIDI file structure looks like: +```text +[Header Chunk: "MThd"] +[Track Chunk 1: "MTrk"] +[Track Chunk 2: "MTrk"] +... +[Track Chunk N: "MTrk"] +[Optional Unknown Chunks] +``` "#] mod unknown_chunk; diff --git a/src/lib.rs b/src/lib.rs index 384f899..132ec1d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -23,6 +23,7 @@ MIDI can be interpreted in two main ways: through `LiveEvent`s and regular file To read from a file, use the [`Reader`](crate::prelude::Reader): ```rust use midix::prelude::*; +use midix::file::builder::event::FileEvent; let midi_header = [ /* MIDI Header */ diff --git a/src/reader/mod.rs b/src/reader/mod.rs index 3b33e1e..1bf2fad 100644 --- a/src/reader/mod.rs +++ b/src/reader/mod.rs @@ -59,6 +59,7 @@ is not true, then the cursor will fail on the next read event. # Example ```rust use midix::prelude::*; +use midix::file::builder::event::FileEvent; let midi_header = [ /* MIDI Header */ From 64bff2bd86f19b3bd8ad1bc2e4fdffcf0f4b3dd3 Mon Sep 17 00:00:00 2001 From: dsgallups Date: Fri, 3 Oct 2025 05:47:09 -0400 Subject: [PATCH 09/11] document smpte --- src/file/meta/smpte_offset.rs | 119 ++++++++++++++++++++++++++++++---- src/file/timing/smpte.rs | 95 ++++++++++++++++++++++----- 2 files changed, 184 insertions(+), 30 deletions(-) diff --git a/src/file/meta/smpte_offset.rs b/src/file/meta/smpte_offset.rs index 672c39e..ec8229d 100644 --- a/src/file/meta/smpte_offset.rs +++ b/src/file/meta/smpte_offset.rs @@ -1,35 +1,106 @@ +#![doc = r#" +SMPTE Offset - Precise time positioning for MIDI events + +# What is SMPTE Offset? + +SMPTE Offset is a MIDI meta-event that specifies an exact starting time for a track +using SMPTE time code format. This allows MIDI sequences to be precisely synchronized +with video, film, or other time-based media. + +# Why use SMPTE Offset? + +SMPTE Offset is essential for: +- **Post-production**: Aligning MIDI tracks with specific video frames +- **Broadcasting**: Ensuring music cues hit exact broadcast timecodes +- **Film scoring**: Synchronizing musical events with on-screen action +- **Multi-track recording**: Maintaining sync across different recording sessions + +When a MIDI file uses SMPTE-based timing (instead of tempo-based), the SMPTE Offset +tells sequencers exactly where in absolute time the track should begin playing. + +# Format + +The SMPTE Offset meta-event contains: +- Frame rate (24, 25, 29.97, or 30 fps) +- Hours (0-23) +- Minutes (0-59) +- Seconds (0-59) +- Frames (0-29/24 depending on fps) +- Subframes (0-99, for additional precision) + +This provides frame-accurate positioning for professional audio/video work. +"#] + use crate::{SmpteError, prelude::SmpteFps}; -/// A representation of a track's offset from the beginning of a midi file. +/// A representation of a MIDI track's starting position in SMPTE time code. +/// +/// This structure holds an absolute time position using SMPTE (Society of Motion Picture +/// and Television Engineers) time code format. When present in a MIDI file, it indicates +/// that the track should begin playback at this specific time position rather than at +/// the beginning of the sequence. +/// +/// # Use Cases +/// - Synchronizing MIDI with video where the music doesn't start at 00:00:00:00 +/// - Aligning multiple MIDI files that represent different sections of a larger work +/// - Post-production workflows requiring frame-accurate synchronization #[derive(Clone, PartialEq, Eq, Debug)] #[cfg_attr(feature = "bevy", derive(bevy::reflect::Reflect))] pub struct SmpteOffset { - /// the track's fps. Note: this should be identical to a file's FPS if - /// the file is defined in terms of `smpte` + /// The frame rate for this offset. + /// + /// **Important**: This should match the file's frame rate when the MIDI file + /// uses SMPTE-based timing. Mismatched rates will cause synchronization errors. pub fps: SmpteFps, - /// the hour offset. Should be between 0-23 + /// Hour component of the time code (0-23). + /// + /// Represents hours in 24-hour format. Values above 23 are invalid + /// and will be rejected during parsing. pub hour: u8, - /// the minute offset. Should be between 0-59 + /// Minute component of the time code (0-59). pub minute: u8, - /// the second offset. Should be between 0-59 + /// Second component of the time code (0-59). pub second: u8, - /// the offset within the second. - /// note that frames start at 0. - /// This is the frame within the second. + /// Frame number within the current second. + /// + /// Valid range depends on the frame rate: + /// - 24 fps: 0-23 + /// - 25 fps: 0-24 + /// - 29.97 fps: 0-29 (with drop-frame rules) + /// - 30 fps: 0-29 pub frame: u8, - /// the subframe offset. Should be between 0-99 + /// Subframe component for additional precision (0-99). + /// + /// Each subframe represents 1/100th of a frame, allowing for + /// sub-frame accuracy in positioning. This is particularly useful + /// for sample-accurate synchronization in digital audio workstations. pub subframe: u8, } impl SmpteOffset { - /// Override this value's provided fps. Used when a file is defined in smpte + /// Calculate the offset in microseconds using a different frame rate. + /// + /// This is useful when the MIDI file's timing uses a different SMPTE + /// rate than the offset itself. The provided `fps` parameter overrides + /// the offset's internal frame rate for the calculation. + /// + /// # Parameters + /// - `fps`: The frame rate to use for the calculation + /// + /// # Returns + /// The time offset in microseconds as a floating-point value pub const fn as_micros_with_override(&self, fps: SmpteFps) -> f64 { ((((self.hour as u32 * 3600) + (self.minute as u32) * 60 + self.second as u32) * 1_000_000) as f64) + ((self.frame as u32) * 1_000_000) as f64 / fps.as_f64() + ((self.subframe as u32) * 10_000) as f64 / fps.as_f64() } - /// Get the offset in terms of microseconds + /// Convert this SMPTE offset to microseconds. + /// + /// Calculates the absolute time position represented by this offset + /// using its internal frame rate. The calculation accounts for hours, + /// minutes, seconds, frames, and subframes to provide a precise + /// microsecond value. pub const fn as_micros(&self) -> f64 { ((((self.hour as u32 * 3600) + (self.minute as u32) * 60 + self.second as u32) * 1_000_000) as f64) @@ -37,7 +108,29 @@ impl SmpteOffset { + ((self.subframe as u32) * 10_000) as f64 / self.fps.as_f64() } - /// Parse the offset given some slice with a length of 5 + /// Parse a SMPTE offset from a 5-byte MIDI data array. + /// + /// The MIDI specification defines the SMPTE offset format as: + /// - Byte 0: `0rrhhhhh` where `rr` is frame rate type, `hhhhh` is hours + /// - Byte 1: Minutes (0-59) + /// - Byte 2: Seconds (0-59) + /// - Byte 3: Frames (depends on frame rate) + /// - Byte 4: Fractional frames in 100ths (0-99) + /// + /// # Frame Rate Encoding + /// The frame rate is encoded in bits 5-6 of the first byte: + /// - `00`: 24 fps + /// - `01`: 25 fps + /// - `10`: 29.97 fps (drop frame) + /// - `11`: 30 fps + /// + /// # Errors + /// - `SmpteError::Length` if data is not exactly 5 bytes + /// - `SmpteError::TrackFrame` if frame rate type is invalid + /// - `SmpteError::HourOffset` if hours > 23 + /// - `SmpteError::MinuteOffset` if minutes > 59 + /// - `SmpteError::SecondOffset` if seconds > 59 + /// - `SmpteError::Subframe` if fractional frames > 99 pub const fn parse(data: &[u8]) -> Result { if data.len() != 5 { return Err(SmpteError::Length(data.len())); diff --git a/src/file/timing/smpte.rs b/src/file/timing/smpte.rs index 6dd9cfb..8e81a8e 100644 --- a/src/file/timing/smpte.rs +++ b/src/file/timing/smpte.rs @@ -1,26 +1,77 @@ -/// The possible FPS for MIDI tracks and files +#![doc = r#" +SMPTE (Society of Motion Picture and Television Engineers) Time Code support for MIDI + +# What is SMPTE? + +SMPTE Time Code is a standard for labeling individual frames of video or film with a time code. +It was developed by the Society of Motion Picture and Television Engineers in the 1960s to provide +accurate synchronization between audio and video equipment. + +# Why does SMPTE exist in MIDI? + +MIDI supports two timing methods: + +1. **Musical Time** - Based on beats and tempo (ticks per quarter note) +2. **Absolute Time** - Based on SMPTE time code (frames per second) + +SMPTE timing is essential for: +- Synchronizing MIDI with video/film production +- Professional audio/video post-production work +- Maintaining precise timing relationships independent of tempo +- Broadcasting and theatrical applications + +When MIDI uses SMPTE timing, events are timestamped with absolute time positions rather than +musical beats, making them ideal for scenarios where the timing must match external media +exactly, regardless of tempo changes. + +# SMPTE Frame Rates in MIDI + +The MIDI specification supports four standard SMPTE frame rates, each serving different +video/broadcast standards: +- 24 fps: Film standard +- 25 fps: PAL/SECAM video standard (Europe, Asia, Africa) +- 29.97 fps: NTSC color video (North America, Japan) - "drop frame" +- 30 fps: NTSC black & white video, some digital formats +"#] + +/// The possible FPS (Frames Per Second) for MIDI tracks and files +/// +/// The MIDI specification defines only four possible frame types: +/// - 24 fps: Standard film rate +/// - 25 fps: PAL/SECAM television standard +/// - 29.97 fps: NTSC color television (drop-frame timecode) +/// - 30 fps: NTSC black & white, some digital video formats /// -/// the MIDI spec defines only four possible frame types: -/// - 24: 24fps -/// - 25: 25fps -/// - 29: dropframe 30 (30,000 frames / 1001 seconds) -/// - 30: 30fps +/// # Drop-Frame Timecode +/// +/// The "TwentyNine" variant represents 29.97 fps, also known as "drop-frame" timecode. +/// This rate (30000/1001 fps) was introduced for NTSC color television to maintain +/// backward compatibility. Despite the name, no actual frames are dropped - the time +/// code numbering skips certain values to keep the timecode aligned with real time. #[derive(Debug, Clone, Copy, PartialEq, Eq)] #[cfg_attr(feature = "bevy", derive(bevy::reflect::Reflect))] pub enum SmpteFps { - /// 24 + /// 24 frames per second - Standard film rate TwentyFour, - /// 25 + /// 25 frames per second - PAL/SECAM television standard TwentyFive, - /// Note this is actually 29.997 + /// 29.97 frames per second (30000/1001) - NTSC color television drop-frame rate TwentyNine, - /// 30 + /// 30 frames per second - NTSC black & white, some digital formats Thirty, } impl SmpteFps { - /// Most likely want to use this. - /// Drop 30 (TwentyNine) is 30 here. + /// Get the nominal frame rate as an integer division value. + /// + /// This returns the simplified integer representation used in MIDI timing calculations. + /// Note that drop-frame 29.97 fps returns 30 here, as MIDI uses the nominal rate + /// for division calculations. + /// + /// # Example + /// ```ignore + /// assert_eq!(SmpteFps::TwentyNine.as_division(), 30); // Not 29! + /// ``` pub const fn as_division(&self) -> u8 { match self { Self::TwentyFour => 24, @@ -29,13 +80,20 @@ impl SmpteFps { Self::Thirty => 30, } } - /// Get the actual number of frames per second + /// Get the actual frame rate as a floating-point value. + /// + /// This returns the precise frame rate, including the fractional rate for + /// drop-frame timecode (29.97 fps = 30000/1001). /// - /// This is useful since I'm not interested in - /// skipping frames 0 and 1 every minute that's not a multiple of 10. + /// Use this method when you need precise time calculations, especially + /// for synchronization with actual video playback. /// - /// However, that's not to say this logic isn't faulty. If it is, - /// please file an issue. + /// # Drop-Frame Note + /// + /// The 29.97 fps rate doesn't actually drop frames from the video. + /// Instead, the timecode numbering skips certain values (frames 0 and 1 + /// of every minute except multiples of 10) to keep the timecode aligned + /// with real time over long durations. pub const fn as_f64(&self) -> f64 { match self { Self::TwentyFour => 24., @@ -45,4 +103,7 @@ impl SmpteFps { } } } + +/// The precise value for NTSC drop-frame rate: 29.97002997... fps +/// This fractional rate ensures color NTSC video stays synchronized with its audio const DROP_FRAME: f64 = 30_000. / 1001.; From 0d2111f3a3a8c5c1bc407c8317194cb1542b46ae Mon Sep 17 00:00:00 2001 From: dsgallups Date: Fri, 3 Oct 2025 05:52:04 -0400 Subject: [PATCH 10/11] add smpte tests --- tests/smpte_offset.rs | 456 +++++++++++++++++++++++++++++++++++ tests/smpte_offset_errors.rs | 304 +++++++++++++++++++++++ 2 files changed, 760 insertions(+) create mode 100644 tests/smpte_offset.rs create mode 100644 tests/smpte_offset_errors.rs diff --git a/tests/smpte_offset.rs b/tests/smpte_offset.rs new file mode 100644 index 0000000..29eafcb --- /dev/null +++ b/tests/smpte_offset.rs @@ -0,0 +1,456 @@ +use midix::{file::builder::event::FileEvent, prelude::*}; + +/// Helper function to create a minimal MIDI file with SMPTE offset +/// Returns the complete MIDI file as a byte vector +fn create_midi_with_smpte_offset( + fps: SmpteFps, + hour: u8, + minute: u8, + second: u8, + frame: u8, + subframe: u8, +) -> Vec { + let mut bytes = Vec::new(); + + // MIDI Header + bytes.extend_from_slice(b"MThd"); // Header chunk type + bytes.extend_from_slice(&[0x00, 0x00, 0x00, 0x06]); // Header length (6 bytes) + bytes.extend_from_slice(&[0x00, 0x00]); // Format 0 (single track) + bytes.extend_from_slice(&[0x00, 0x01]); // Number of tracks (1) + + // Use SMPTE timing instead of ticks per quarter note + // High bit set indicates SMPTE timing + let fps_byte = match fps { + SmpteFps::TwentyFour => 0xE8, // -24 in two's complement + SmpteFps::TwentyFive => 0xE7, // -25 in two's complement + SmpteFps::TwentyNine => 0xE3, // -29 in two's complement + SmpteFps::Thirty => 0xE2, // -30 in two's complement + }; + bytes.push(fps_byte); + bytes.push(40); // 40 ticks per frame + + // Track Header + bytes.extend_from_slice(b"MTrk"); // Track chunk type + + // Calculate track length (we'll update this later) + let track_length_pos = bytes.len(); + bytes.extend_from_slice(&[0x00, 0x00, 0x00, 0x00]); // Placeholder for length + + let track_start = bytes.len(); + + // SMPTE Offset Meta Event + bytes.push(0x00); // Delta time + bytes.push(0xFF); // Meta event + bytes.push(0x54); // SMPTE Offset type + bytes.push(0x05); // Length (5 bytes) + + // SMPTE data + let frame_type_bits = match fps { + SmpteFps::TwentyFour => 0b00, + SmpteFps::TwentyFive => 0b01, + SmpteFps::TwentyNine => 0b10, + SmpteFps::Thirty => 0b11, + }; + bytes.push((frame_type_bits << 5) | (hour & 0x1F)); // Frame type + hours + bytes.push(minute); + bytes.push(second); + bytes.push(frame); + bytes.push(subframe); + + // Add a simple note to make it a valid track + bytes.push(0x00); // Delta time + bytes.push(0x90); // Note On, channel 0 + bytes.push(0x3C); // Middle C (60) + bytes.push(0x64); // Velocity 100 + + bytes.push(0x60); // Delta time (96 ticks) + bytes.push(0x80); // Note Off, channel 0 + bytes.push(0x3C); // Middle C + bytes.push(0x40); // Release velocity 64 + + // End of Track + bytes.push(0x00); // Delta time + bytes.push(0xFF); // Meta event + bytes.push(0x2F); // End of track + bytes.push(0x00); // Length 0 + + // Update track length + let track_length = bytes.len() - track_start; + bytes[track_length_pos..track_length_pos + 4] + .copy_from_slice(&(track_length as u32).to_be_bytes()); + + bytes +} + +#[test] +fn test_smpte_offset_24fps() { + let midi_data = create_midi_with_smpte_offset( + SmpteFps::TwentyFour, + 12, // hour (noon) + 30, // minute + 15, // second + 18, // frame + 50, // subframe + ); + + let mut reader = Reader::from_byte_slice(&midi_data); + + // Read header + let Ok(FileEvent::Header(header)) = reader.read_event() else { + panic!("Failed to read header"); + }; + + // Verify SMPTE timing + match header.timing() { + Timing::Smpte(smpte) => { + assert_eq!(smpte.fps(), SmpteFps::TwentyFour); + assert_eq!(smpte.ticks_per_frame(), 40); + } + _ => panic!("Expected SMPTE timing"), + } + + // Read track header + let Ok(FileEvent::Track(track)) = reader.read_event() else { + panic!("Failed to read track header"); + }; + assert!(track.len() > 0); + + // Read SMPTE offset event + let Ok(FileEvent::TrackEvent(event)) = reader.read_event() else { + panic!("Failed to read track event"); + }; + + match event.event() { + TrackMessage::Meta(MetaMessage::SmpteOffset(offset)) => { + assert_eq!(offset.fps, SmpteFps::TwentyFour); + assert_eq!(offset.hour, 12); + assert_eq!(offset.minute, 30); + assert_eq!(offset.second, 15); + assert_eq!(offset.frame, 18); + assert_eq!(offset.subframe, 50); + + // Verify microsecond calculation + let expected_micros = (12 * 3600 + 30 * 60 + 15) as f64 * 1_000_000.0 + + (18.0 / 24.0) * 1_000_000.0 + + (50.0 / 100.0 / 24.0) * 1_000_000.0; + assert!((offset.as_micros() - expected_micros).abs() < 0.01); + } + _ => panic!("Expected SMPTE offset meta event"), + } +} + +#[test] +fn test_smpte_offset_25fps_pal() { + let midi_data = create_midi_with_smpte_offset( + SmpteFps::TwentyFive, + 0, // midnight + 0, // minute + 1, // second + 12, // frame (middle of second) + 75, // subframe + ); + + let mut reader = Reader::from_byte_slice(&midi_data); + + // Skip to SMPTE offset event + reader.read_event().unwrap(); // Header + reader.read_event().unwrap(); // Track + + let Ok(FileEvent::TrackEvent(event)) = reader.read_event() else { + panic!("Failed to read track event"); + }; + + match event.event() { + TrackMessage::Meta(MetaMessage::SmpteOffset(offset)) => { + assert_eq!(offset.fps, SmpteFps::TwentyFive); + assert_eq!(offset.hour, 0); + assert_eq!(offset.minute, 0); + assert_eq!(offset.second, 1); + assert_eq!(offset.frame, 12); + assert_eq!(offset.subframe, 75); + } + _ => panic!("Expected SMPTE offset meta event"), + } +} + +#[test] +fn test_smpte_offset_29_97_drop_frame() { + let midi_data = create_midi_with_smpte_offset( + SmpteFps::TwentyNine, + 23, // 11 PM + 59, // 59 minutes + 59, // 59 seconds + 28, // frame 28 (out of 29) + 99, // maximum subframe + ); + + let mut reader = Reader::from_byte_slice(&midi_data); + + // Skip to SMPTE offset event + reader.read_event().unwrap(); // Header + reader.read_event().unwrap(); // Track + + let Ok(FileEvent::TrackEvent(event)) = reader.read_event() else { + panic!("Failed to read track event"); + }; + + match event.event() { + TrackMessage::Meta(MetaMessage::SmpteOffset(offset)) => { + assert_eq!(offset.fps, SmpteFps::TwentyNine); + assert_eq!(offset.hour, 23); + assert_eq!(offset.minute, 59); + assert_eq!(offset.second, 59); + assert_eq!(offset.frame, 28); + assert_eq!(offset.subframe, 99); + + // This should be just before midnight + let micros = offset.as_micros(); + let expected = 86_399_000_000.0 + // 23:59:59 in microseconds + (28.0 * 1_000_000.0 / 29.97) + // frames + (99.0 * 10_000.0 / 29.97); // subframes + assert!((micros - expected).abs() < 1.0); + } + _ => panic!("Expected SMPTE offset meta event"), + } +} + +#[test] +fn test_smpte_offset_30fps() { + let midi_data = create_midi_with_smpte_offset( + SmpteFps::Thirty, + 1, // 1 AM + 23, // 23 minutes + 45, // 45 seconds + 15, // frame 15 (middle frame) + 0, // no subframe + ); + + let mut reader = Reader::from_byte_slice(&midi_data); + + // Skip to SMPTE offset event + reader.read_event().unwrap(); // Header + reader.read_event().unwrap(); // Track + + let Ok(FileEvent::TrackEvent(event)) = reader.read_event() else { + panic!("Failed to read track event"); + }; + + match event.event() { + TrackMessage::Meta(MetaMessage::SmpteOffset(offset)) => { + assert_eq!(offset.fps, SmpteFps::Thirty); + assert_eq!(offset.hour, 1); + assert_eq!(offset.minute, 23); + assert_eq!(offset.second, 45); + assert_eq!(offset.frame, 15); + assert_eq!(offset.subframe, 0); + } + _ => panic!("Expected SMPTE offset meta event"), + } +} + +#[test] +fn test_smpte_offset_with_override_fps() { + // Create a file with 24fps SMPTE timing + let midi_data = create_midi_with_smpte_offset(SmpteFps::TwentyFour, 10, 20, 30, 12, 50); + + let mut reader = Reader::from_byte_slice(&midi_data); + + // Read header to get file timing + let Ok(FileEvent::Header(header)) = reader.read_event() else { + panic!("Failed to read header"); + }; + + let file_fps = match header.timing() { + Timing::Smpte(smpte) => smpte.fps(), + _ => panic!("Expected SMPTE timing"), + }; + + // Skip track header + reader.read_event().unwrap(); + + // Read SMPTE offset + let Ok(FileEvent::TrackEvent(event)) = reader.read_event() else { + panic!("Failed to read track event"); + }; + + match event.event() { + TrackMessage::Meta(MetaMessage::SmpteOffset(offset)) => { + // Calculate with original fps + let micros_original = offset.as_micros(); + + // Calculate with override fps (using file's fps) + let micros_override = offset.as_micros_with_override(file_fps); + + // They should be equal when the fps matches + assert!((micros_original - micros_override).abs() < 0.01); + + // But different with a different fps + let micros_different = offset.as_micros_with_override(SmpteFps::Thirty); + assert!((micros_original - micros_different).abs() > 1.0); + } + _ => panic!("Expected SMPTE offset meta event"), + } +} + +#[test] +fn test_multiple_tracks_with_different_offsets() { + let mut bytes = Vec::new(); + + // MIDI Header (Format 1 - multiple simultaneous tracks) + bytes.extend_from_slice(b"MThd"); + bytes.extend_from_slice(&[0x00, 0x00, 0x00, 0x06]); + bytes.extend_from_slice(&[0x00, 0x01]); // Format 1 + bytes.extend_from_slice(&[0x00, 0x02]); // 2 tracks + bytes.push(0xE7); // 25 fps SMPTE + bytes.push(40); // 40 ticks per frame + + // Track 1 with offset at 00:00:10:00 + bytes.extend_from_slice(b"MTrk"); + let track1_length_pos = bytes.len(); + bytes.extend_from_slice(&[0x00, 0x00, 0x00, 0x00]); + let track1_start = bytes.len(); + + // SMPTE Offset for track 1 + bytes.extend_from_slice(&[ + 0x00, 0xFF, 0x54, 0x05, // Delta time, Meta, SMPTE Offset, length + 0x20, // 25fps (01) + 0 hours + 0x00, // 0 minutes + 0x0A, // 10 seconds + 0x00, // 0 frames + 0x00, // 0 subframes + ]); + + // End of track 1 + bytes.extend_from_slice(&[0x00, 0xFF, 0x2F, 0x00]); + + let track1_length = bytes.len() - track1_start; + bytes[track1_length_pos..track1_length_pos + 4] + .copy_from_slice(&(track1_length as u32).to_be_bytes()); + + // Track 2 with offset at 00:01:00:00 + bytes.extend_from_slice(b"MTrk"); + let track2_length_pos = bytes.len(); + bytes.extend_from_slice(&[0x00, 0x00, 0x00, 0x00]); + let track2_start = bytes.len(); + + // SMPTE Offset for track 2 + bytes.extend_from_slice(&[ + 0x00, 0xFF, 0x54, 0x05, // Delta time, Meta, SMPTE Offset, length + 0x20, // 25fps (01) + 0 hours + 0x01, // 1 minute + 0x00, // 0 seconds + 0x00, // 0 frames + 0x00, // 0 subframes + ]); + + // End of track 2 + bytes.extend_from_slice(&[0x00, 0xFF, 0x2F, 0x00]); + + let track2_length = bytes.len() - track2_start; + bytes[track2_length_pos..track2_length_pos + 4] + .copy_from_slice(&(track2_length as u32).to_be_bytes()); + + // Parse the file + let mut reader = Reader::from_byte_slice(&bytes); + let mut offsets = Vec::new(); + + while let Ok(event) = reader.read_event() { + if let FileEvent::TrackEvent(track_event) = event + && let TrackMessage::Meta(MetaMessage::SmpteOffset(offset)) = track_event.event() + { + offsets.push(offset.clone()); + } + } + + assert_eq!(offsets.len(), 2); + + // Track 1 offset: 10 seconds + assert_eq!(offsets[0].hour, 0); + assert_eq!(offsets[0].minute, 0); + assert_eq!(offsets[0].second, 10); + + // Track 2 offset: 1 minute + assert_eq!(offsets[1].hour, 0); + assert_eq!(offsets[1].minute, 1); + assert_eq!(offsets[1].second, 0); + + // Verify the time difference is 50 seconds + let diff = offsets[1].as_micros() - offsets[0].as_micros(); + assert!((diff - 50_000_000.0).abs() < 1.0); +} + +#[test] +fn test_smpte_edge_cases() { + // Test maximum valid values + let midi_data = create_midi_with_smpte_offset( + SmpteFps::TwentyFour, + 23, // max hour + 59, // max minute + 59, // max second + 23, // max frame for 24fps + 99, // max subframe + ); + + let mut reader = Reader::from_byte_slice(&midi_data); + reader.read_event().unwrap(); // Header + reader.read_event().unwrap(); // Track + + let Ok(FileEvent::TrackEvent(event)) = reader.read_event() else { + panic!("Failed to read track event"); + }; + + match event.event() { + TrackMessage::Meta(MetaMessage::SmpteOffset(offset)) => { + assert_eq!(offset.hour, 23); + assert_eq!(offset.minute, 59); + assert_eq!(offset.second, 59); + assert_eq!(offset.frame, 23); + assert_eq!(offset.subframe, 99); + } + _ => panic!("Expected SMPTE offset meta event"), + } +} + +#[test] +fn test_smpte_offset_precision() { + // Test that subframe precision is maintained correctly + let test_cases = vec![ + (SmpteFps::TwentyFour, 0, 0, 0, 0, 1), // 1/100th of 1/24th second + (SmpteFps::TwentyFive, 0, 0, 0, 0, 50), // Half a subframe + (SmpteFps::TwentyNine, 0, 0, 0, 1, 0), // Exactly one frame + (SmpteFps::Thirty, 0, 0, 1, 0, 0), // Exactly one second + ]; + + for (fps, hour, minute, second, frame, subframe) in test_cases { + let midi_data = create_midi_with_smpte_offset(fps, hour, minute, second, frame, subframe); + let mut reader = Reader::from_byte_slice(&midi_data); + + reader.read_event().unwrap(); // Header + reader.read_event().unwrap(); // Track + + let Ok(FileEvent::TrackEvent(event)) = reader.read_event() else { + panic!("Failed to read track event"); + }; + + match event.event() { + TrackMessage::Meta(MetaMessage::SmpteOffset(offset)) => { + let micros = offset.as_micros(); + + // Verify the calculation is precise + match fps { + SmpteFps::TwentyFour => { + // 1 subframe at 24fps = 1/100 * 1/24 second + let expected = 1_000_000.0 / 24.0 / 100.0; + assert!((micros - expected).abs() < 0.001); + } + SmpteFps::Thirty => { + // Exactly 1 second + assert!((micros - 1_000_000.0).abs() < 0.001); + } + _ => {} + } + } + _ => panic!("Expected SMPTE offset meta event"), + } + } +} diff --git a/tests/smpte_offset_errors.rs b/tests/smpte_offset_errors.rs new file mode 100644 index 0000000..2116343 --- /dev/null +++ b/tests/smpte_offset_errors.rs @@ -0,0 +1,304 @@ +use midix::prelude::*; + +/// Helper to create raw SMPTE offset data bytes +fn create_smpte_bytes( + fps_bits: u8, + hour: u8, + minute: u8, + second: u8, + frame: u8, + subframe: u8, +) -> Vec { + vec![ + (fps_bits << 5) | (hour & 0x1F), + minute, + second, + frame, + subframe, + ] +} + +#[test] +fn test_smpte_offset_invalid_length() { + // Test with too few bytes + let short_data = vec![0x00, 0x00, 0x00]; + let result = SmpteOffset::parse(&short_data); + assert!(matches!(result, Err(SmpteError::Length(3)))); + + // Test with too many bytes + let long_data = vec![0x00, 0x00, 0x00, 0x00, 0x00, 0x00]; + let result = SmpteOffset::parse(&long_data); + assert!(matches!(result, Err(SmpteError::Length(6)))); + + // Test with empty data + let empty_data = vec![]; + let result = SmpteOffset::parse(&empty_data); + assert!(matches!(result, Err(SmpteError::Length(0)))); +} + +#[test] +fn test_smpte_offset_invalid_frame_type() { + // Frame type bits are bits 5-6, only values 0-3 are valid + // Try with invalid frame type 4 (binary 100) + let invalid_fps_4 = create_smpte_bytes(0b100, 12, 30, 15, 10, 50); + let result = SmpteOffset::parse(&invalid_fps_4); + assert!(matches!(result, Err(SmpteError::TrackFrame(4)))); + + // Try with invalid frame type 5 (binary 101) + let invalid_fps_5 = create_smpte_bytes(0b101, 12, 30, 15, 10, 50); + let result = SmpteOffset::parse(&invalid_fps_5); + assert!(matches!(result, Err(SmpteError::TrackFrame(5)))); + + // Try with invalid frame type 7 (binary 111) + let invalid_fps_7 = create_smpte_bytes(0b111, 12, 30, 15, 10, 50); + let result = SmpteOffset::parse(&invalid_fps_7); + assert!(matches!(result, Err(SmpteError::TrackFrame(7)))); +} + +#[test] +fn test_smpte_offset_invalid_hour() { + // Hours should be 0-23, test 24 + let data = vec![ + 0x18, // fps bits 00 (24fps) + hour 24 (11000) + 0x00, // minute + 0x00, // second + 0x00, // frame + 0x00, // subframe + ]; + let result = SmpteOffset::parse(&data); + assert!(matches!(result, Err(SmpteError::HourOffset(24)))); + + // Test maximum invalid hour (31 - all 5 bits set) + let data_max = vec![ + 0x1F, // fps bits 00 + hour 31 (11111) + 0x00, 0x00, 0x00, 0x00, + ]; + let result = SmpteOffset::parse(&data_max); + assert!(matches!(result, Err(SmpteError::HourOffset(31)))); +} + +#[test] +fn test_smpte_offset_invalid_minute() { + // Minutes should be 0-59 + let data = create_smpte_bytes(0, 12, 60, 30, 15, 50); + let result = SmpteOffset::parse(&data); + assert!(matches!(result, Err(SmpteError::MinuteOffset(60)))); + + // Test various invalid minute values + for invalid_minute in [61, 70, 80, 99, 100, 255] { + let data = create_smpte_bytes(0, 12, invalid_minute, 30, 15, 50); + let result = SmpteOffset::parse(&data); + assert!(matches!(result, Err(SmpteError::MinuteOffset(_)))); + } +} + +#[test] +fn test_smpte_offset_invalid_second() { + // Seconds should be 0-59 + let data = create_smpte_bytes(1, 12, 30, 60, 15, 50); + let result = SmpteOffset::parse(&data); + assert!(matches!(result, Err(SmpteError::SecondOffset(60)))); + + // Test maximum byte value + let data_max = create_smpte_bytes(1, 12, 30, 255, 15, 50); + let result = SmpteOffset::parse(&data_max); + assert!(matches!(result, Err(SmpteError::SecondOffset(255)))); +} + +#[test] +fn test_smpte_offset_invalid_subframe() { + // Subframes should be 0-99 + let data = create_smpte_bytes(2, 12, 30, 45, 15, 100); + let result = SmpteOffset::parse(&data); + assert!(matches!(result, Err(SmpteError::Subframe(100)))); + + // Test various invalid subframe values + for invalid_subframe in [101, 110, 150, 200, 255] { + let data = create_smpte_bytes(2, 12, 30, 45, 15, invalid_subframe); + let result = SmpteOffset::parse(&data); + assert!(matches!(result, Err(SmpteError::Subframe(_)))); + } +} + +#[test] +fn test_smpte_offset_boundary_values() { + // Test all valid boundary values + let test_cases = vec![ + // Min values + (0, 0, 0, 0, 0, 0), + // Max hour + (0, 23, 0, 0, 0, 0), + // Max minute + (0, 0, 59, 0, 0, 0), + // Max second + (0, 0, 0, 59, 0, 0), + // Max subframe + (0, 0, 0, 0, 0, 99), + // All max valid values for 24fps + (0, 23, 59, 59, 23, 99), + // All max valid values for 25fps + (1, 23, 59, 59, 24, 99), + // All max valid values for 29.97fps + (2, 23, 59, 59, 29, 99), + // All max valid values for 30fps + (3, 23, 59, 59, 29, 99), + ]; + + for (fps_bits, hour, minute, second, frame, subframe) in test_cases { + let data = create_smpte_bytes(fps_bits, hour, minute, second, frame, subframe); + let result = SmpteOffset::parse(&data); + assert!(result.is_ok(), "Failed for {:?}", data); + + let offset = result.unwrap(); + assert_eq!(offset.hour, hour); + assert_eq!(offset.minute, minute); + assert_eq!(offset.second, second); + assert_eq!(offset.frame, frame); + assert_eq!(offset.subframe, subframe); + } +} + +#[test] +fn test_smpte_offset_frame_limits() { + // Frame limits depend on the fps + // For 24fps, max frame should be 23 + // For 25fps, max frame should be 24 + // For 29.97fps and 30fps, max frame should be 29 + + // Note: The actual MIDI spec might not enforce these limits strictly, + // but they represent the logical limits for each frame rate + + let test_cases = vec![ + (SmpteFps::TwentyFour, 0, 24), // 24 frames would be the 25th frame (invalid) + (SmpteFps::TwentyFive, 1, 25), // 25 frames would be the 26th frame (invalid) + (SmpteFps::TwentyNine, 2, 30), // 30 frames would be the 31st frame (invalid) + (SmpteFps::Thirty, 3, 30), // 30 frames would be the 31st frame (invalid) + ]; + + for (expected_fps, fps_bits, frame) in test_cases { + let data = create_smpte_bytes(fps_bits, 12, 30, 45, frame, 50); + let result = SmpteOffset::parse(&data); + + // The parse might succeed (as the MIDI spec might not enforce frame limits) + // but we can verify the values are as expected + if let Ok(offset) = result { + assert_eq!(offset.fps, expected_fps); + assert_eq!(offset.frame, frame); + } + } +} + +#[test] +fn test_smpte_offset_microsecond_calculation_edge_cases() { + // Test edge case: Just before midnight + let data = create_smpte_bytes(0, 23, 59, 59, 23, 99); // 24fps + let offset = SmpteOffset::parse(&data).unwrap(); + + let micros = offset.as_micros(); + // Should be very close to 24 hours in microseconds + let expected = 86_399_000_000.0 + // 23:59:59 + (23.0 / 24.0) * 1_000_000.0 + // 23 frames at 24fps + (99.0 / 100.0 / 24.0) * 1_000_000.0; // 99 subframes + assert!((micros - expected).abs() < 1.0); + + // Test edge case: Exactly midnight (all zeros) + let data_midnight = create_smpte_bytes(1, 0, 0, 0, 0, 0); + let offset_midnight = SmpteOffset::parse(&data_midnight).unwrap(); + assert_eq!(offset_midnight.as_micros(), 0.0); +} + +#[test] +fn test_smpte_offset_fps_override_edge_cases() { + // Create offset with 24fps + let data = create_smpte_bytes(0, 1, 0, 0, 12, 0); // 1 hour, 12 frames + let offset = SmpteOffset::parse(&data).unwrap(); + + // Calculate with different frame rates + let micros_24 = offset.as_micros(); + let micros_25 = offset.as_micros_with_override(SmpteFps::TwentyFive); + let micros_29 = offset.as_micros_with_override(SmpteFps::TwentyNine); + let micros_30 = offset.as_micros_with_override(SmpteFps::Thirty); + + // The hour component should be the same for all + let hour_micros = 3_600_000_000.0; + + // But the frame component should differ + let frame_24 = (12.0 / 24.0) * 1_000_000.0; + let frame_25 = (12.0 / 25.0) * 1_000_000.0; + let frame_29 = (12.0 / 29.97) * 1_000_000.0; + let frame_30 = (12.0 / 30.0) * 1_000_000.0; + + assert!((micros_24 - (hour_micros + frame_24)).abs() < 1.0); + assert!((micros_25 - (hour_micros + frame_25)).abs() < 1.0); + assert!((micros_29 - (hour_micros + frame_29)).abs() < 1.0); + assert!((micros_30 - (hour_micros + frame_30)).abs() < 1.0); + + // Verify they're all different + assert!((micros_24 - micros_25).abs() > 1.0); + assert!((micros_24 - micros_29).abs() > 1.0); + assert!((micros_24 - micros_30).abs() > 1.0); +} + +#[test] +fn test_smpte_offset_combined_errors() { + // Test multiple errors - parser should catch the first one + + // Invalid hour AND minute + let data = create_smpte_bytes(0, 25, 61, 30, 15, 50); + let result = SmpteOffset::parse(&data); + // Should catch hour error first + assert!(matches!(result, Err(SmpteError::HourOffset(25)))); + + // Valid hour but invalid minute AND second + let data2 = create_smpte_bytes(1, 23, 60, 60, 15, 50); + let result2 = SmpteOffset::parse(&data2); + // Should catch minute error + assert!(matches!(result2, Err(SmpteError::MinuteOffset(60)))); + + // Everything valid except subframe + let data3 = create_smpte_bytes(2, 23, 59, 59, 29, 100); + let result3 = SmpteOffset::parse(&data3); + // Should catch subframe error + assert!(matches!(result3, Err(SmpteError::Subframe(100)))); +} + +#[test] +fn test_smpte_offset_bit_manipulation_edge_cases() { + // Test that hour bits don't interfere with fps bits + for fps_bits in 0..=3 { + for hour in 0..=23 { + let first_byte = (fps_bits << 5) | hour; + let data = vec![first_byte, 30, 45, 15, 50]; + let result = SmpteOffset::parse(&data).unwrap(); + + // Verify fps is correctly parsed + let expected_fps = match fps_bits { + 0 => SmpteFps::TwentyFour, + 1 => SmpteFps::TwentyFive, + 2 => SmpteFps::TwentyNine, + 3 => SmpteFps::Thirty, + _ => unreachable!(), + }; + assert_eq!(result.fps, expected_fps); + assert_eq!(result.hour, hour); + } + } +} + +#[test] +fn test_smpte_drop_frame_precision() { + // Test the precision of 29.97 fps calculations + let data = create_smpte_bytes(2, 0, 0, 0, 1, 0); // One frame at 29.97fps + let offset = SmpteOffset::parse(&data).unwrap(); + + let micros = offset.as_micros(); + let expected = 1_000_000.0 / 29.97; // Should be approximately 33366.7 microseconds + + // The constant DROP_FRAME should be 30000/1001 = 29.97002997... + // One frame should be 1001/30000 seconds = 33366.666... microseconds + assert!((micros - expected).abs() < 0.1); + + // Verify the exact calculation + let exact_frame_duration = 1_001_000.0 / 30.0; // 33366.666... microseconds + assert!((micros - exact_frame_duration).abs() < 0.001); +} From f71f71129418fc0adf3bcd4598ee7c82ba82b53a Mon Sep 17 00:00:00 2001 From: dsgallups Date: Fri, 3 Oct 2025 05:54:10 -0400 Subject: [PATCH 11/11] found bug --- tests/smpte_offset.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/smpte_offset.rs b/tests/smpte_offset.rs index 29eafcb..4684abe 100644 --- a/tests/smpte_offset.rs +++ b/tests/smpte_offset.rs @@ -355,6 +355,7 @@ fn test_multiple_tracks_with_different_offsets() { let mut offsets = Vec::new(); while let Ok(event) = reader.read_event() { + println!("ok"); if let FileEvent::TrackEvent(track_event) = event && let TrackMessage::Meta(MetaMessage::SmpteOffset(offset)) = track_event.event() {