diff --git a/README.md b/README.md index 4830e96..59980ec 100644 --- a/README.md +++ b/README.md @@ -7,10 +7,10 @@ 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). +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/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/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/builder/chunk/mod.rs b/src/file/builder/chunk/mod.rs new file mode 100644 index 0000000..3498965 --- /dev/null +++ b/src/file/builder/chunk/mod.rs @@ -0,0 +1,65 @@ +#![doc = r#" +Contains types for MIDI file chunks + +# Overview + +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`] + +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; +pub use unknown_chunk::*; + +mod header; +pub use header::*; + +mod track; +pub use track::*; 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/event/chunk.rs similarity index 77% rename from src/events/file/chunk.rs rename to src/file/builder/event/chunk.rs index 6762ee2..2c8fe41 100644 --- a/src/events/file/chunk.rs +++ b/src/file/builder/event/chunk.rs @@ -1,11 +1,11 @@ -use crate::prelude::*; +use crate::file::builder::chunk::{RawHeaderChunk, RawTrackChunk, UnknownChunk}; #[doc = r#" 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> { @@ -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/event/mod.rs similarity index 88% rename from src/events/file/mod.rs rename to src/file/builder/event/mod.rs index 2f15689..f3e026d 100644 --- a/src/events/file/mod.rs +++ b/src/file/builder/event/mod.rs @@ -1,4 +1,13 @@ -use crate::prelude::*; +#![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::builder::chunk::{RawHeaderChunk, TrackChunkHeader, UnknownChunk}, + prelude::*, +}; mod chunk; pub use chunk::*; @@ -10,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 @@ -64,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 new file mode 100644 index 0000000..20be1e9 --- /dev/null +++ b/src/file/builder/format.rs @@ -0,0 +1,63 @@ +use crate::file::FormatType; + +#[doc = r#" + + FF 00 02 Sequence Number + This optional event, which must occur at the beginning of a track, + before any nonzero delta-times, and before any transmittable MIDI + events, specifies the number of a sequence. In a format 2 MIDI File, + it is used to identify each "pattern" so that a "song" sequence using + the Cue message can refer to the patterns. If the ID numbers are + omitted, the sequences' locations in order in the file are used as + defaults. In a format 0 or 1 MIDI File, which only contain one + sequence, this number should be contained in the first (or only) + track. If transfer of several multitrack sequences is required, + this must be done as a group of format 1 files, each with a different + sequence number. +"#] +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RawFormat { + /// Format 0 + SingleMultiChannel, + /// Format 1 + Simultaneous([u8; 2]), + /// Format 2 + SequentiallyIndependent([u8; 2]), +} +impl RawFormat { + /// Create a [`RawFormat::SingleMultiChannel`] + pub const fn single_multichannel() -> Self { + Self::SingleMultiChannel + } + + /// Create a [`Format::Simultaneous`] + pub(crate) const fn simultaneous_from_byte_slice(bytes: [u8; 2]) -> Self { + Self::Simultaneous(bytes) + } + + /// Create a [`Format::SequentiallyIndependent`] + pub(crate) const fn sequentially_independent_from_byte_slice(bytes: [u8; 2]) -> Self { + Self::SequentiallyIndependent(bytes) + } + + /// Returns the number of tracks identified by the format. + /// + /// [`RawFormat::SingleMultiChannel`] will always return 1. + pub const fn num_tracks(&self) -> u16 { + use RawFormat::*; + match &self { + SingleMultiChannel => 1, + Simultaneous(num) | SequentiallyIndependent(num) => u16::from_be_bytes(*num), + } + } + + /// Returns the format type of the format. + pub const fn format_type(&self) -> FormatType { + use RawFormat::*; + match self { + SingleMultiChannel => FormatType::SingleMultiChannel, + Simultaneous(_) => FormatType::Simultaneous, + SequentiallyIndependent(_) => FormatType::SequentiallyIndependent, + } + } +} diff --git a/src/file/builder.rs b/src/file/builder/mod.rs similarity index 91% rename from src/file/builder.rs rename to src/file/builder/mod.rs index 002f064..d0f7b19 100644 --- a/src/file/builder.rs +++ b/src/file/builder/mod.rs @@ -1,11 +1,20 @@ -use alloc::vec::Vec; +mod format; +pub use format::*; + +pub mod chunk; -use crate::{prelude::*, reader::ReaderErrorKind}; +pub mod event; use super::MidiFile; +use crate::{ + file::builder::{chunk::UnknownChunk, event::ChunkEvent}, + prelude::*, + reader::ReaderErrorKind, +}; +use alloc::vec::Vec; #[derive(Default)] -pub enum FormatStage<'a> { +enum FormatStage<'a> { #[default] Unknown, KnownFormat(RawFormat), @@ -13,6 +22,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>, @@ -22,6 +32,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 { @@ -106,9 +117,10 @@ impl<'a> MidiFileBuilder<'a> { self.unknown_chunks.push(data); Ok(()) } - EOF => Err(ReaderErrorKind::OutOfBounds), + 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); @@ -117,9 +129,6 @@ impl<'a> MidiFileBuilder<'a> { return Err(FileError::NoTiming); }; - Ok(MidiFile { - format, - header: Header::new(timing), - }) + Ok(MidiFile { format, timing }) } } 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/header.rs b/src/file/header.rs deleted file mode 100644 index 295b025..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 Header { - timing: Timing, -} - -impl Header { - /// 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_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/meta/smpte_offset.rs b/src/file/meta/smpte_offset.rs new file mode 100644 index 0000000..ec8229d --- /dev/null +++ b/src/file/meta/smpte_offset.rs @@ -0,0 +1,205 @@ +#![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 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 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, + /// 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, + /// Minute component of the time code (0-59). + pub minute: u8, + /// Second component of the time code (0-59). + pub second: u8, + /// 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, + /// 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 { + /// 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() + } + /// 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) + + ((self.frame as u32) * 1_000_000) as f64 / self.fps.as_f64() + + ((self.subframe as u32) * 10_000) as f64 / self.fps.as_f64() + } + + /// 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())); + } + + // 0 rr hhhhh + let frame_type = match data[0] >> 5 { + 0 => SmpteFps::TwentyFour, + 1 => SmpteFps::TwentyFive, + 2 => SmpteFps::TwentyNine, + 3 => SmpteFps::Thirty, + v => return Err(SmpteError::TrackFrame(v)), + }; + let hour = data[0] & 0b0001_1111; + if hour > 24 { + return Err(SmpteError::HourOffset(hour)); + } + let minute = data[1]; + if minute > 59 { + return Err(SmpteError::MinuteOffset(minute)); + } + let second = data[2]; + if second > 59 { + return Err(SmpteError::SecondOffset(second)); + } + + let frame = data[3]; + // always 1/100 of frame + let subframe = data[4]; + if subframe > 99 { + return Err(SmpteError::Subframe(subframe)); + } + Ok(Self { + fps: frame_type, + hour, + minute, + second, + frame, + subframe, + }) + } +} + +#[test] +fn parse_smpte_offset() { + use pretty_assertions::assert_eq; + // this are the bytes after 00 FF 54 05 + // where 54 is smpte offset, and 05 is length five. + let bytes = [0x41, 0x17, 0x2D, 0x0C, 0x22]; + let offset = SmpteOffset::parse(&bytes).unwrap(); + + assert_eq!(offset.fps, SmpteFps::TwentyNine); + assert_eq!(offset.hour, 1); + assert_eq!(offset.minute, 23); + assert_eq!(offset.second, 45); + assert_eq!(offset.frame, 12); + assert_eq!(offset.subframe, 34); +} + +#[test] +fn parse_invalid_smpte_offset() { + use pretty_assertions::assert_eq; + // this are the bytes after 00 FF 54 05 + // where 54 is smpte offset, and 05 is length five. + let bytes = [0x7F, 0x17, 0x2D, 0x0C, 0x22]; + let err = SmpteOffset::parse(&bytes).unwrap_err(); + assert_eq!(err, SmpteError::HourOffset(31)); + + let bytes = [0x41, 0x50, 0x2D, 0x0C, 0x22]; + let err = SmpteOffset::parse(&bytes).unwrap_err(); + assert_eq!(err, SmpteError::MinuteOffset(80)); +} 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 a3d789e..c3947db 100644 --- a/src/file/mod.rs +++ b/src/file/mod.rs @@ -1,30 +1,33 @@ #![doc = r#" Rusty representation of a [`MidiFile`] - -TODO "#] -mod builder; +/// Contains the [`MidiFileBuilder`] and associated [`FileEvent`](builder::event::FileEvent)s. +pub mod builder; -use alloc::{borrow::Cow, vec::Vec}; -use builder::*; mod format; pub use format::*; -mod header; -pub use header::*; + 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, + file::builder::MidiFileBuilder, message::Timed, - prelude::FormatType, reader::{ReadResult, Reader, ReaderError, ReaderErrorKind}, }; +use alloc::{borrow::Cow, vec::Vec}; #[doc = r#" TODO @@ -32,7 +35,7 @@ TODO #[derive(Clone, Debug, PartialEq)] #[cfg_attr(feature = "bevy", derive(bevy::reflect::Reflect))] pub struct MidiFile<'a> { - header: Header, + timing: Timing, format: Format<'a>, } #[cfg(feature = "bevy_asset")] @@ -72,8 +75,22 @@ impl<'a> MidiFile<'a> { } /// Returns header info - pub fn header(&self) -> &Header { - &self.header + pub fn timing(&self) -> Timing { + self.timing + } + + /// 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 diff --git a/src/file/timed_event_iter.rs b/src/file/timed_event_iter.rs index d3a2c25..c1efe59 100644 --- a/src/file/timed_event_iter.rs +++ b/src/file/timed_event_iter.rs @@ -23,17 +23,17 @@ 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, - header: Header, + 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 = Header::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 = Header::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 = Header::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 = Header::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 = Header::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 = Header::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 = Header::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 = Header::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 = Header::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 = Header::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(); diff --git a/src/file/timing/mod.rs b/src/file/timing/mod.rs new file mode 100644 index 0000000..b0f731f --- /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`](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 { + /// 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/timing/smpte.rs b/src/file/timing/smpte.rs new file mode 100644 index 0000000..8e81a8e --- /dev/null +++ b/src/file/timing/smpte.rs @@ -0,0 +1,109 @@ +#![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 +/// +/// # 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 frames per second - Standard film rate + TwentyFour, + /// 25 frames per second - PAL/SECAM television standard + TwentyFive, + /// 29.97 frames per second (30000/1001) - NTSC color television drop-frame rate + TwentyNine, + /// 30 frames per second - NTSC black & white, some digital formats + Thirty, +} + +impl SmpteFps { + /// 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, + Self::TwentyFive => 25, + Self::TwentyNine => 30, + Self::Thirty => 30, + } + } + /// 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). + /// + /// Use this method when you need precise time calculations, especially + /// for synchronization with actual video playback. + /// + /// # 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., + Self::TwentyFive => 25., + Self::TwentyNine => DROP_FRAME, + Self::Thirty => 30., + } + } +} + +/// 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.; 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/chunk/mod.rs b/src/file_repr/chunk/mod.rs deleted file mode 100644 index 8872810..0000000 --- a/src/file_repr/chunk/mod.rs +++ /dev/null @@ -1,26 +0,0 @@ -#![doc = r#" -Contains types for MIDI file chunks - -TODO - -# Overview -MIDI has two chunk types. MIDI defines anything that does -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 - -## [`] - -"#] - -mod unknown_chunk; -pub use unknown_chunk::*; - -mod header; -pub use header::*; - -mod track; -pub use track::*; diff --git a/src/file_repr/format.rs b/src/file_repr/format.rs deleted file mode 100644 index 5c14cd3..0000000 --- a/src/file_repr/format.rs +++ /dev/null @@ -1,123 +0,0 @@ -#[doc = r#" - - FF 00 02 Sequence Number - This optional event, which must occur at the beginning of a track, - before any nonzero delta-times, and before any transmittable MIDI - events, specifies the number of a sequence. In a format 2 MIDI File, - it is used to identify each "pattern" so that a "song" sequence using - the Cue message can refer to the patterns. If the ID numbers are - omitted, the sequences' locations in order in the file are used as - defaults. In a format 0 or 1 MIDI File, which only contain one - sequence, this number should be contained in the first (or only) - track. If transfer of several multitrack sequences is required, - this must be done as a group of format 1 files, each with a different - sequence number. -"#] -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum RawFormat { - /// Format 0 - SingleMultiChannel, - /// Format 1 - Simultaneous([u8; 2]), - /// Format 2 - SequentiallyIndependent([u8; 2]), -} -impl RawFormat { - /// Create a [`RawFormat::SingleMultiChannel`] - pub const fn single_multichannel() -> Self { - Self::SingleMultiChannel - } - - /// Create a [`Format::Simultaneous`] - pub(crate) const fn simultaneous_from_byte_slice(bytes: [u8; 2]) -> Self { - Self::Simultaneous(bytes) - } - - /// Create a [`Format::SequentiallyIndependent`] - pub(crate) const fn sequentially_independent_from_byte_slice(bytes: [u8; 2]) -> Self { - Self::SequentiallyIndependent(bytes) - } - - /// Returns the number of tracks identified by the format. - /// - /// [`RawFormat::SingleMultiChannel`] will always return 1. - pub const fn num_tracks(&self) -> u16 { - use RawFormat::*; - match &self { - SingleMultiChannel => 1, - Simultaneous(num) | SequentiallyIndependent(num) => u16::from_be_bytes(*num), - } - } - - /// Returns the format type of the format. - pub const fn format_type(&self) -> FormatType { - use RawFormat::*; - match self { - SingleMultiChannel => FormatType::SingleMultiChannel, - Simultaneous(_) => FormatType::Simultaneous, - SequentiallyIndependent(_) => FormatType::SequentiallyIndependent, - } - } -} - -#[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_repr/meta/smpte_offset.rs b/src/file_repr/meta/smpte_offset.rs deleted file mode 100644 index 672c39e..0000000 --- a/src/file_repr/meta/smpte_offset.rs +++ /dev/null @@ -1,112 +0,0 @@ -use crate::{SmpteError, prelude::SmpteFps}; - -/// A representation of a track's offset from the beginning of a midi file. -#[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` - pub fps: SmpteFps, - /// the hour offset. Should be between 0-23 - pub hour: u8, - /// the minute offset. Should be between 0-59 - pub minute: u8, - /// the second offset. Should be between 0-59 - pub second: u8, - /// the offset within the second. - /// note that frames start at 0. - /// This is the frame within the second. - pub frame: u8, - /// the subframe offset. Should be between 0-99 - pub subframe: u8, -} - -impl SmpteOffset { - /// Override this value's provided fps. Used when a file is defined in smpte - 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 - 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) - + ((self.frame as u32) * 1_000_000) as f64 / self.fps.as_f64() - + ((self.subframe as u32) * 10_000) as f64 / self.fps.as_f64() - } - - /// Parse the offset given some slice with a length of 5 - pub const fn parse(data: &[u8]) -> Result { - if data.len() != 5 { - return Err(SmpteError::Length(data.len())); - } - - // 0 rr hhhhh - let frame_type = match data[0] >> 5 { - 0 => SmpteFps::TwentyFour, - 1 => SmpteFps::TwentyFive, - 2 => SmpteFps::TwentyNine, - 3 => SmpteFps::Thirty, - v => return Err(SmpteError::TrackFrame(v)), - }; - let hour = data[0] & 0b0001_1111; - if hour > 24 { - return Err(SmpteError::HourOffset(hour)); - } - let minute = data[1]; - if minute > 59 { - return Err(SmpteError::MinuteOffset(minute)); - } - let second = data[2]; - if second > 59 { - return Err(SmpteError::SecondOffset(second)); - } - - let frame = data[3]; - // always 1/100 of frame - let subframe = data[4]; - if subframe > 99 { - return Err(SmpteError::Subframe(subframe)); - } - Ok(Self { - fps: frame_type, - hour, - minute, - second, - frame, - subframe, - }) - } -} - -#[test] -fn parse_smpte_offset() { - use pretty_assertions::assert_eq; - // this are the bytes after 00 FF 54 05 - // where 54 is smpte offset, and 05 is length five. - let bytes = [0x41, 0x17, 0x2D, 0x0C, 0x22]; - let offset = SmpteOffset::parse(&bytes).unwrap(); - - assert_eq!(offset.fps, SmpteFps::TwentyNine); - assert_eq!(offset.hour, 1); - assert_eq!(offset.minute, 23); - assert_eq!(offset.second, 45); - assert_eq!(offset.frame, 12); - assert_eq!(offset.subframe, 34); -} - -#[test] -fn parse_invalid_smpte_offset() { - use pretty_assertions::assert_eq; - // this are the bytes after 00 FF 54 05 - // where 54 is smpte offset, and 05 is length five. - let bytes = [0x7F, 0x17, 0x2D, 0x0C, 0x22]; - let err = SmpteOffset::parse(&bytes).unwrap_err(); - assert_eq!(err, SmpteError::HourOffset(31)); - - let bytes = [0x41, 0x50, 0x2D, 0x0C, 0x22]; - let err = SmpteOffset::parse(&bytes).unwrap_err(); - assert_eq!(err, SmpteError::MinuteOffset(80)); -} 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/smpte.rs b/src/file_repr/smpte.rs deleted file mode 100644 index 6dd9cfb..0000000 --- a/src/file_repr/smpte.rs +++ /dev/null @@ -1,48 +0,0 @@ -/// The possible FPS for MIDI tracks and files -/// -/// the MIDI spec defines only four possible frame types: -/// - 24: 24fps -/// - 25: 25fps -/// - 29: dropframe 30 (30,000 frames / 1001 seconds) -/// - 30: 30fps -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -#[cfg_attr(feature = "bevy", derive(bevy::reflect::Reflect))] -pub enum SmpteFps { - /// 24 - TwentyFour, - /// 25 - TwentyFive, - /// Note this is actually 29.997 - TwentyNine, - /// 30 - Thirty, -} - -impl SmpteFps { - /// Most likely want to use this. - /// Drop 30 (TwentyNine) is 30 here. - pub const fn as_division(&self) -> u8 { - match self { - Self::TwentyFour => 24, - Self::TwentyFive => 25, - Self::TwentyNine => 30, - Self::Thirty => 30, - } - } - /// Get the actual number of frames per second - /// - /// This is useful since I'm not interested in - /// skipping frames 0 and 1 every minute that's not a multiple of 10. - /// - /// However, that's not to say this logic isn't faulty. If it is, - /// please file an issue. - pub const fn as_f64(&self) -> f64 { - match self { - Self::TwentyFour => 24., - Self::TwentyFive => 25., - Self::TwentyNine => DROP_FRAME, - Self::Thirty => 30., - } - } -} -const DROP_FRAME: f64 = 30_000. / 1001.; 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 fb503ce..132ec1d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,6 +1,104 @@ #![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::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. + +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::*; +use midix::file::builder::event::FileEvent; + +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; @@ -16,7 +114,6 @@ pub mod channel; pub mod events; pub mod file; -pub mod file_repr; pub mod reader; @@ -61,13 +158,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/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 1629599..1bf2fad 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::builder::{ + chunk::{RawHeaderChunk, RawTrackChunk, TrackChunkHeader, UnknownChunk}, + event::{ChunkEvent, FileEvent}, + }, + prelude::*, +}; #[doc = r#" A MIDI event reader. @@ -53,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 */ @@ -270,7 +277,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); } @@ -315,7 +322,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, } }; @@ -348,7 +355,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 +393,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, } }; diff --git a/tests/read_all.rs b/tests/read_all.rs index e73abf1..fe7a272 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::event::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..7a3f2fc 100644 --- a/tests/simple_midi/main.rs +++ b/tests/simple_midi/main.rs @@ -1,4 +1,4 @@ -use midix::prelude::*; +use midix::{file::builder::event::FileEvent, prelude::*}; mod parsed; /* diff --git a/tests/smpte_offset.rs b/tests/smpte_offset.rs new file mode 100644 index 0000000..4684abe --- /dev/null +++ b/tests/smpte_offset.rs @@ -0,0 +1,457 @@ +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() { + println!("ok"); + 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); +}