diff --git a/Cargo.lock b/Cargo.lock index f4596f83b..07121a039 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1097,6 +1097,16 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "craft-midi" +version = "0.1.0" +dependencies = [ + "proptest", + "serde", + "serde_json", + "thiserror 2.0.21", +] + [[package]] name = "craft-mime" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 00844bb16..e8f25105f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -48,6 +48,7 @@ craft-audio = { path = "crates/audio" } # No default features, so craft-audio's `encode` builds craft-media without `isobmff`. craft-media = { path = "crates/media", default-features = false } craft-media-testkit = { path = "crates/media-testkit" } +craft-midi = { path = "crates/midi" } craft-mime = { path = "crates/mime" } craft-lottie = { path = "crates/lottie" } craft-menu = { path = "crates/menu" } diff --git a/README.md b/README.md index c0d2fc266..4fb20cebc 100644 --- a/README.md +++ b/README.md @@ -132,6 +132,9 @@ craft- = { git = "https://github.com/storytold/craft-libs", rev = " 0. + pub length: i64, +} + +impl Note { + /// Tick at which the note ends (`start + length`, saturating). + pub fn end(&self) -> i64 { + self.start.saturating_add(self.length) + } +} + +/// Kind of a continuous / channel controller event. +#[derive(Debug, Clone, Copy, PartialEq)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +pub enum CtrlKind { + /// Control change with the controller number 0..=127. Value 0..=127. + Cc(u8), + /// Pitch bend. Value is centred: -8192..=8191 (0 = no bend). + PitchBend, + /// Channel (mono) aftertouch. Value 0..=127. + ChannelPressure, + /// Polyphonic aftertouch for the given pitch. Value 0..=127. + PolyPressure(u8), + /// Program change. Value is the program number 0..=127. + Program, +} + +/// A controller / channel event at a tick. +#[derive(Debug, Clone, Copy, PartialEq)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +pub struct CtrlEvent { + pub tick: i64, + pub channel: u8, + pub kind: CtrlKind, + pub value: i32, +} + +/// The MIDI content of a region or track. +#[derive(Debug, Clone, Default, PartialEq)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +pub struct Sequence { + /// Notes, sorted by `(start, pitch)` after [`Sequence::sort`]. + pub notes: Vec, + /// Controller events, sorted by tick after [`Sequence::sort`]. + pub ctrls: Vec, +} + +fn kind_key(k: CtrlKind) -> (u8, u8) { + match k { + CtrlKind::Cc(n) => (0, n), + CtrlKind::PitchBend => (1, 0), + CtrlKind::ChannelPressure => (2, 0), + CtrlKind::PolyPressure(p) => (3, p), + CtrlKind::Program => (4, 0), + } +} + +impl Sequence { + /// Sorts notes by `(start, pitch, channel, length, velocity)` and controller events by tick + /// (stable, so same-tick events keep their order). + pub fn sort(&mut self) { + self.notes.sort_by_key(|a| (a.start, a.pitch, a.channel, a.length, a.velocity)); + self.ctrls.sort_by_key(|c| c.tick); + } + + /// The last tick covered by any note end or controller event (0 when empty). + pub fn end_tick(&self) -> i64 { + let n = self.notes.iter().map(Note::end).max(); + let c = self.ctrls.iter().map(|c| c.tick).max(); + match (n, c) { + (Some(a), Some(b)) => a.max(b), + (Some(a), None) | (None, Some(a)) => a, + (None, None) => 0, + } + } + + /// Notes whose start lies in the half-open range `[start, end)`. + pub fn notes_in(&self, start: i64, end: i64) -> impl Iterator + '_ { + self.notes.iter().filter(move |n| n.start >= start && n.start < end) + } + + /// Notes that sound anywhere inside the half-open range `[start, end)`. + pub fn notes_overlapping(&self, start: i64, end: i64) -> impl Iterator + '_ { + self.notes.iter().filter(move |n| n.start < end && n.end() > start) + } + + /// Removes duplicate notes (same channel, pitch and start; the longest one is kept) and + /// duplicate controller events (same tick, channel and kind; the last one is kept, matching + /// what a synth would end up with). Returns how many events were removed. The sequence is + /// left sorted. + pub fn remove_duplicates(&mut self) -> usize { + let before = self.notes.len() + self.ctrls.len(); + // Longest first within each (start, pitch, channel) group, then keep the first. + self.notes.sort_by(|a, b| (a.start, a.pitch, a.channel, b.length).cmp(&(b.start, b.pitch, b.channel, a.length))); + self.notes.dedup_by(|later, kept| later.start == kept.start && later.pitch == kept.pitch && later.channel == kept.channel); + + self.ctrls.sort_by_key(|c| c.tick); + let mut kept: Vec = Vec::with_capacity(self.ctrls.len()); + // The latest tick and `kept` index of each channel+kind, so a same-tick repeat is found + // without rescanning the tick's events (a tick can hold many thousands of distinct keys). + // An entry from an earlier tick counts as absent; keeping the tick instead of clearing the + // map per tick keeps the work per event constant after a dense tick. + let mut last_at: HashMap<(u8, (u8, u8)), (i64, usize)> = HashMap::new(); + for c in self.ctrls.drain(..) { + // Within a tick, replace an earlier event of the same channel+kind. + match last_at.entry((c.channel, kind_key(c.kind))) { + Entry::Occupied(mut e) => { + let (tick, idx) = *e.get(); + match kept.get_mut(idx) { + Some(slot) if tick == c.tick => *slot = c, + _ => { + e.insert((c.tick, kept.len())); + kept.push(c); + } + } + } + Entry::Vacant(e) => { + e.insert((c.tick, kept.len())); + kept.push(c); + } + } + } + self.ctrls = kept; + self.sort(); + before - (self.notes.len() + self.ctrls.len()) + } +} diff --git a/crates/midi/src/smf.rs b/crates/midi/src/smf.rs new file mode 100644 index 000000000..7ec838010 --- /dev/null +++ b/crates/midi/src/smf.rs @@ -0,0 +1,635 @@ +//! Standard MIDI File reading and writing (from the public SMF 1.0 specification). + +use std::collections::VecDeque; + +use crate::{CtrlEvent, CtrlKind, MidiError, Note, PPQ, Sequence}; + +/// Maximum number of events (across all tracks) [`read_smf`] will parse. +pub const MAX_EVENTS: usize = 5_000_000; +/// Maximum number of track chunks [`read_smf`] will accept. +pub const MAX_TRACKS: usize = 4096; + +/// Release velocity used when a note ends with a note-on of velocity 0 (per the spec, a +/// note-on with velocity 0 is a note-off with velocity 64). +const DEFAULT_RELEASE: u8 = 64; + +/// One track of a Standard MIDI File. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct SmfTrack { + /// Name from the first Sequence/Track Name meta event (empty if none). + pub name: String, + pub sequence: Sequence, +} + +/// A Standard MIDI File in model terms. All ticks are at [`PPQ`] (960) per quarter note. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct Smf { + /// Format of the source file (0, 1 or 2). [`write_smf`] always writes format 1. + pub format: u16, + pub tracks: Vec, + /// `(tick, beats per minute)`. + pub tempos: Vec<(i64, f64)>, + /// `(tick, numerator, denominator)`, e.g. `(0, 6, 8)`. + pub meters: Vec<(i64, u8, u8)>, + /// `(tick, text)` from Marker meta events. + pub markers: Vec<(i64, String)>, + /// `(tick, sharps (+) / flats (-) -7..=7, is_minor)`. + pub key_sigs: Vec<(i64, i8, bool)>, +} + +// --------------------------------------------------------------------------------------------- +// Reading +// --------------------------------------------------------------------------------------------- + +struct Cursor<'a> { + data: &'a [u8], + pos: usize, + /// Absolute offset of `data[0]` in the file, for error messages. + base: usize, +} + +impl<'a> Cursor<'a> { + fn at(&self) -> usize { + self.base.saturating_add(self.pos) + } + + fn is_empty(&self) -> bool { + self.pos >= self.data.len() + } + + fn u8(&mut self) -> Result { + let b = *self.data.get(self.pos).ok_or(MidiError::Truncated(self.at()))?; + self.pos += 1; + Ok(b) + } + + fn peek(&self) -> Result { + self.data.get(self.pos).copied().ok_or(MidiError::Truncated(self.at())) + } + + fn bytes(&mut self, n: usize) -> Result<&'a [u8], MidiError> { + let end = self.pos.checked_add(n).ok_or(MidiError::Truncated(self.at()))?; + let s = self.data.get(self.pos..end).ok_or(MidiError::Truncated(self.at()))?; + self.pos = end; + Ok(s) + } + + fn u16(&mut self) -> Result { + let b = self.bytes(2)?; + Ok(u16::from_be_bytes([b.first().copied().unwrap_or(0), b.get(1).copied().unwrap_or(0)])) + } + + fn u32(&mut self) -> Result { + let b = self.bytes(4)?; + let mut a = [0u8; 4]; + a.copy_from_slice(b); + Ok(u32::from_be_bytes(a)) + } + + /// Variable-length quantity: at most 4 bytes (28 bits) per the spec. + fn vlq(&mut self) -> Result { + let start = self.at(); + let mut v: u32 = 0; + for _ in 0..4 { + let b = self.u8()?; + v = (v << 7) | u32::from(b & 0x7F); + if b & 0x80 == 0 { + return Ok(v); + } + } + Err(MidiError::VlqTooLong(start)) + } +} + +/// Converts file ticks to model ticks: `model = file * num / den` (rounded). +#[derive(Clone, Copy)] +struct TickConv { + num: i128, + den: i128, +} + +impl TickConv { + fn from_division(division: u16, offset: usize) -> Result { + if division & 0x8000 == 0 { + if division == 0 { + return Err(MidiError::Invalid { offset, reason: "division of 0 ticks per quarter note" }); + } + Ok(Self { num: i128::from(PPQ), den: i128::from(division) }) + } else { + // SMPTE: high byte is negative frames/sec (two's complement), low byte ticks/frame. + // Approximated at 120 bpm: one quarter note = 0.5 s, so + // model = file / ticks_per_sec * 2 * PPQ. + let [hi, lo] = division.to_be_bytes(); + let fps = -i128::from(hi as i8); + let tpf = i128::from(lo); + if !matches!(fps, 24 | 25 | 29 | 30) || tpf == 0 { + return Err(MidiError::Invalid { offset, reason: "invalid SMPTE division" }); + } + let two_ppq = 2 * i128::from(PPQ); + if fps == 29 { + // 29.97 drop-frame: ticks/sec = 30000/1001 * tpf + Ok(Self { num: two_ppq * 1001, den: 30000 * tpf }) + } else { + Ok(Self { num: two_ppq, den: fps * tpf }) + } + } + } + + fn convert(&self, file_tick: u64) -> i64 { + let v = (i128::from(file_tick) * self.num + self.den / 2) / self.den; + i64::try_from(v).unwrap_or(i64::MAX) + } +} + +struct Globals { + tempos: Vec<(i64, f64)>, + meters: Vec<(i64, u8, u8)>, + markers: Vec<(i64, String)>, + key_sigs: Vec<(i64, i8, bool)>, +} + +/// Parses a Standard MIDI File. +/// +/// - Formats 0 and 1 are fully supported; format 2 is read best-effort (all tracks share one +/// timeline). Each `MTrk` chunk becomes one [`SmfTrack`]; for format 1 a leading conductor +/// track without notes or controllers is folded into the global tempo/meter/marker lists. +/// - Ticks are converted to [`PPQ`]; an SMPTE division is approximated at 120 bpm. +/// - Running status, note-on velocity 0 as note-off, unmatched note-offs (ignored), overlapping +/// same-pitch notes (matched first-in first-out) and hanging notes (closed at the end of the +/// track) are handled. Zero-length notes get a length of 1 tick. +/// - Unknown chunks and meta events are skipped. Any malformed structure (bad header, truncated +/// chunk, VLQ longer than 4 bytes, data byte without running status, ...) returns an error. +pub fn read_smf(bytes: &[u8]) -> Result { + let mut cur = Cursor { data: bytes, pos: 0, base: 0 }; + if cur.bytes(4).map_err(|_| MidiError::NotSmf)? != b"MThd" { + return Err(MidiError::NotSmf); + } + let header_len = cur.u32()?; + if header_len < 6 { + return Err(MidiError::Invalid { offset: 4, reason: "header chunk shorter than 6 bytes" }); + } + let header_len = usize::try_from(header_len).map_err(|_| MidiError::BadChunkLength { offset: 4, len: u64::from(header_len) })?; + let header = cur.bytes(header_len).map_err(|_| MidiError::BadChunkLength { offset: 4, len: u64::try_from(header_len).unwrap_or(u64::MAX) })?; + let mut hc = Cursor { data: header, pos: 0, base: 8 }; + let format = hc.u16()?; + let _ntrks = hc.u16()?; + let division = hc.u16()?; + if format > 2 { + return Err(MidiError::Unsupported("SMF format greater than 2")); + } + let conv = TickConv::from_division(division, 12)?; + + let mut globals = Globals { tempos: Vec::new(), meters: Vec::new(), markers: Vec::new(), key_sigs: Vec::new() }; + let mut tracks = Vec::new(); + let mut event_budget = MAX_EVENTS; + + while !cur.is_empty() { + let chunk_at = cur.at(); + let id = cur.bytes(4)?; + let len = cur.u32()?; + let remaining = bytes.len().saturating_sub(cur.pos); + let len_usize = usize::try_from(len).unwrap_or(usize::MAX); + if len_usize > remaining { + return Err(MidiError::BadChunkLength { offset: chunk_at, len: u64::from(len) }); + } + let body_at = cur.at(); + let body = cur.bytes(len_usize)?; + if id != b"MTrk" { + continue; // unknown chunk types must be ignored + } + if tracks.len() >= MAX_TRACKS { + return Err(MidiError::TooManyTracks(MAX_TRACKS)); + } + let track = read_track(body, body_at, conv, &mut globals, &mut event_budget)?; + tracks.push(track); + } + + // A lone track is only a conductor track if it carried global data and has no name; otherwise it is kept. + let globals_present = !(globals.tempos.is_empty() && globals.meters.is_empty() && globals.markers.is_empty() && globals.key_sigs.is_empty()); + if format == 1 + && (tracks.len() > 1 || globals_present) + && tracks.first().is_some_and(|t| t.sequence.notes.is_empty() && t.sequence.ctrls.is_empty() && (tracks.len() > 1 || t.name.is_empty())) + { + tracks.remove(0); + } + + // Keep global lists ordered by tick (stable: file order within a tick). + globals.tempos.sort_by_key(|t| t.0); + globals.meters.sort_by_key(|t| t.0); + globals.markers.sort_by_key(|t| t.0); + globals.key_sigs.sort_by_key(|t| t.0); + + Ok(Smf { format, tracks, tempos: globals.tempos, meters: globals.meters, markers: globals.markers, key_sigs: globals.key_sigs }) +} + +/// Open note-ons for one (channel, pitch), oldest first: `(model tick, velocity)`. +type OpenNotes = Vec>; + +fn close_note(open: &mut OpenNotes, notes: &mut Vec, channel: u8, pitch: u8, tick: i64, release: u8) { + let key = usize::from(channel & 0x0F) * 128 + usize::from(pitch & 0x7F); + let Some(stack) = open.get_mut(key) else { return }; + if stack.is_empty() { + return; // unmatched note-off: ignored + } + let Some((start, velocity)) = stack.pop_front() else { return }; + let length = tick.saturating_sub(start).max(1); + notes.push(Note { pitch, velocity, release_velocity: release, channel, start, length }); +} + +fn read_track(body: &[u8], base: usize, conv: TickConv, g: &mut Globals, budget: &mut usize) -> Result { + let mut cur = Cursor { data: body, pos: 0, base }; + let mut name: Option = None; + let mut seq = Sequence::default(); + let mut open: OpenNotes = vec![VecDeque::new(); 16 * 128]; + let mut file_tick: u64 = 0; + let mut tick: i64 = 0; + let mut running: Option = None; + + while !cur.is_empty() { + if *budget == 0 { + return Err(MidiError::TooManyEvents(MAX_EVENTS)); + } + *budget -= 1; + + let delta = cur.vlq()?; + file_tick = file_tick.saturating_add(u64::from(delta)); + tick = conv.convert(file_tick); + + let event_at = cur.at(); + let first = cur.peek()?; + let status = if first & 0x80 != 0 { + cur.pos += 1; + first + } else { + running.ok_or(MidiError::Invalid { offset: event_at, reason: "data byte without running status" })? + }; + + match status { + 0xFF => { + running = None; + let kind = cur.u8()?; + let len = cur.vlq()?; + let data = cur.bytes(usize::try_from(len).unwrap_or(usize::MAX))?; + match kind { + 0x2F => break, // End of Track + 0x03 if name.is_none() => { + name = Some(String::from_utf8_lossy(data).into_owned()); + } + 0x06 => g.markers.push((tick, String::from_utf8_lossy(data).into_owned())), + 0x51 => { + if let [a, b, c] = data { + let us = (u32::from(*a) << 16) | (u32::from(*b) << 8) | u32::from(*c); + if us > 0 { + g.tempos.push((tick, 60_000_000.0 / f64::from(us))); + } + } + } + 0x58 => { + if let [nn, dd, ..] = data + && *nn > 0 + && *dd <= 7 + { + g.meters.push((tick, *nn, 1u8 << *dd)); + } + } + 0x59 => { + if let [sf, mi, ..] = data { + let sf = *sf as i8; + if (-7..=7).contains(&sf) { + g.key_sigs.push((tick, sf, *mi == 1)); + } + } + } + _ => {} + } + } + 0xF0 | 0xF7 => { + running = None; + let len = cur.vlq()?; + cur.bytes(usize::try_from(len).unwrap_or(usize::MAX))?; + } + 0x80..=0xEF => { + running = Some(status); + let channel = status & 0x0F; + let d1 = cur.u8()?; + if d1 & 0x80 != 0 { + return Err(MidiError::Invalid { offset: event_at, reason: "status byte where a data byte was expected" }); + } + let needs_two = !matches!(status & 0xF0, 0xC0 | 0xD0); + let d2 = if needs_two { + let d = cur.u8()?; + if d & 0x80 != 0 { + return Err(MidiError::Invalid { offset: event_at, reason: "status byte where a data byte was expected" }); + } + d + } else { + 0 + }; + match status & 0xF0 { + 0x80 => close_note(&mut open, &mut seq.notes, channel, d1, tick, d2), + 0x90 if d2 == 0 => close_note(&mut open, &mut seq.notes, channel, d1, tick, DEFAULT_RELEASE), + 0x90 => { + if let Some(stack) = open.get_mut(usize::from(channel) * 128 + usize::from(d1)) { + stack.push_back((tick, d2)); + } + } + 0xA0 => seq.ctrls.push(CtrlEvent { tick, channel, kind: CtrlKind::PolyPressure(d1), value: i32::from(d2) }), + 0xB0 => seq.ctrls.push(CtrlEvent { tick, channel, kind: CtrlKind::Cc(d1), value: i32::from(d2) }), + 0xC0 => seq.ctrls.push(CtrlEvent { tick, channel, kind: CtrlKind::Program, value: i32::from(d1) }), + 0xD0 => seq.ctrls.push(CtrlEvent { tick, channel, kind: CtrlKind::ChannelPressure, value: i32::from(d1) }), + _ => { + // 0xE0 pitch bend: LSB then MSB, centred on 8192. + let v = (i32::from(d2) << 7 | i32::from(d1)) - 8192; + seq.ctrls.push(CtrlEvent { tick, channel, kind: CtrlKind::PitchBend, value: v }); + } + } + } + _ => return Err(MidiError::Invalid { offset: event_at, reason: "system real-time/common status in a track" }), + } + } + + // Close hanging notes at the end of the track. + for (key, stack) in open.iter_mut().enumerate() { + let channel = u8::try_from(key / 128).unwrap_or(0); + let pitch = u8::try_from(key % 128).unwrap_or(0); + for (start, velocity) in stack.drain(..) { + let length = tick.saturating_sub(start).max(1); + seq.notes.push(Note { pitch, velocity, release_velocity: DEFAULT_RELEASE, channel, start, length }); + } + } + seq.sort(); + Ok(SmfTrack { name: name.unwrap_or_default(), sequence: seq }) +} + +// --------------------------------------------------------------------------------------------- +// Writing +// --------------------------------------------------------------------------------------------- + +const MAX_VLQ: u64 = 0x0FFF_FFFF; + +fn push_vlq(out: &mut Vec, v: u64) -> Result<(), MidiError> { + if v > MAX_VLQ { + return Err(MidiError::Write(format!("value {v} does not fit in a MIDI variable-length quantity"))); + } + let mut buf = [0u8; 4]; + let mut n = 0; + let mut x = v; + loop { + if let Some(slot) = buf.get_mut(n) { + *slot = (x & 0x7F) as u8; + } + n += 1; + x >>= 7; + if x == 0 || n == 4 { + break; + } + } + for i in (0..n).rev() { + let b = buf.get(i).copied().unwrap_or(0); + out.push(if i > 0 { b | 0x80 } else { b }); + } + Ok(()) +} + +/// An event ready to be serialised: `(tick, order, bytes)`. `order` breaks ties at one tick. +struct WEvent { + tick: u64, + order: u8, + bytes: Vec, +} + +fn clamp_tick(t: i64) -> u64 { + u64::try_from(t.max(0)).unwrap_or(0) +} + +fn meta(kind: u8, data: &[u8]) -> Result, MidiError> { + let mut b = vec![0xFF, kind]; + push_vlq(&mut b, u64::try_from(data.len()).unwrap_or(u64::MAX))?; + b.extend_from_slice(data); + Ok(b) +} + +fn write_track(out: &mut Vec, mut events: Vec) -> Result<(), MidiError> { + events.sort_by_key(|e| (e.tick, e.order)); + let mut body = Vec::new(); + let mut last_tick = 0u64; + let mut running: Option = None; + for e in &events { + push_vlq(&mut body, e.tick - last_tick)?; + last_tick = e.tick; + let status = e.bytes.first().copied().unwrap_or(0); + if (0x80..=0xEF).contains(&status) { + if running == Some(status) { + body.extend_from_slice(e.bytes.get(1..).unwrap_or(&[])); + } else { + body.extend_from_slice(&e.bytes); + } + running = Some(status); + } else { + running = None; + body.extend_from_slice(&e.bytes); + } + } + // End of Track. + body.extend_from_slice(&[0x00, 0xFF, 0x2F, 0x00]); + let len = u32::try_from(body.len()).map_err(|_| MidiError::Write("track larger than 4 GiB".into()))?; + out.extend_from_slice(b"MTrk"); + out.extend_from_slice(&len.to_be_bytes()); + out.extend_from_slice(&body); + Ok(()) +} + +/// The conductor track: tempos, meters, key signatures and markers. +fn conductor_events(smf: &Smf) -> Result, MidiError> { + let mut ev = Vec::new(); + for &(t, bpm) in &smf.tempos { + if !bpm.is_finite() || bpm <= 0.0 { + return Err(MidiError::Write(format!("invalid tempo {bpm} bpm"))); + } + let us = (60_000_000.0 / bpm).round().clamp(1.0, f64::from(0x00FF_FFFFu32)) as u32; + let [_, a, b, c] = us.to_be_bytes(); + ev.push(WEvent { tick: clamp_tick(t), order: 0, bytes: meta(0x51, &[a, b, c])? }); + } + for &(t, num, den) in &smf.meters { + if den == 0 || !den.is_power_of_two() { + return Err(MidiError::Write(format!("meter denominator {den} is not a power of two"))); + } + let dd = den.trailing_zeros() as u8; + ev.push(WEvent { tick: clamp_tick(t), order: 1, bytes: meta(0x58, &[num, dd, 24, 8])? }); + } + for &(t, sf, minor) in &smf.key_sigs { + let sf = sf.clamp(-7, 7); + ev.push(WEvent { tick: clamp_tick(t), order: 2, bytes: meta(0x59, &[sf as u8, u8::from(minor)])? }); + } + for (t, text) in &smf.markers { + ev.push(WEvent { tick: clamp_tick(*t), order: 3, bytes: meta(0x06, text.as_bytes())? }); + } + Ok(ev) +} + +/// Trim notes still sounding when the next same-pitch note starts, and drop duplicates. +fn trim_overlaps(notes: &[Note], ends: &mut [u64], dropped: &mut [bool]) { + // The reader pairs offs with ons first-in first-out, so a note still sounding when the next + // same-pitch note starts would swap ends with it: trim it to that start. When two same-pitch + // notes start on the same tick they cannot be told apart, so only the longer one is written. + let mut order: Vec = (0..notes.len()).collect(); + order.sort_by_key(|&i| { + notes.get(i).map_or((0, 0, 0, std::cmp::Reverse(0)), |n| { + (n.channel & 0x0F, n.pitch.min(127), clamp_tick(n.start), std::cmp::Reverse(ends.get(i).copied().unwrap_or(0))) + }) + }); + let mut prev: Option = None; + for &b in &order { + let Some(nb) = notes.get(b) else { continue }; + if let Some(a) = prev + && let Some(na) = notes.get(a) + && na.channel & 0x0F == nb.channel & 0x0F + && na.pitch.min(127) == nb.pitch.min(127) + { + let (sa, sb) = (clamp_tick(na.start), clamp_tick(nb.start)); + if sa == sb { + if let Some(d) = dropped.get_mut(b) { + *d = true; + } + continue; + } + if let Some(ea) = ends.get_mut(a) + && sb < *ea + { + *ea = sb; + } + } + prev = Some(b); + } +} + +/// Writes a format-1 Standard MIDI File at [`PPQ`] (960) ticks per quarter note. +/// +/// Track 1 is a conductor track holding tempos, meters, key signatures and markers; every +/// [`SmfTrack`] follows as its own track. Negative ticks are clamped to 0, data values are +/// clamped to their MIDI ranges, and note-on velocity 0 is written as 1 (0 would mean +/// note-off). At each tick note-offs are written before controllers, then note-ons. +/// +/// Errors: invalid tempo (non-finite or <= 0 bpm), meter denominator that is not a power of two +/// (1..=128), more than 65535 tracks, or a gap between events beyond the 28-bit delta limit. +pub fn write_smf(smf: &Smf) -> Result, MidiError> { + // The conductor track is omitted when there is nothing to put in it and no content tracks, so an + // empty `Smf` is written as a file with zero tracks and reads back as zero tracks. + let has_conductor = + !(smf.tempos.is_empty() && smf.meters.is_empty() && smf.key_sigs.is_empty() && smf.markers.is_empty() && smf.tracks.is_empty()); + let ntrks = + u16::try_from(smf.tracks.len().saturating_add(usize::from(has_conductor))).map_err(|_| MidiError::Write("more than 65535 tracks".into()))?; + let mut out = Vec::new(); + out.extend_from_slice(b"MThd"); + out.extend_from_slice(&6u32.to_be_bytes()); + out.extend_from_slice(&1u16.to_be_bytes()); + out.extend_from_slice(&ntrks.to_be_bytes()); + out.extend_from_slice(&u16::try_from(PPQ).unwrap_or(960).to_be_bytes()); + + // Conductor track. + let ev = conductor_events(smf)?; + if has_conductor { + write_track(&mut out, ev)?; + } + + for track in &smf.tracks { + let mut ev = Vec::new(); + if !track.name.is_empty() { + ev.push(WEvent { tick: 0, order: 0, bytes: meta(0x03, track.name.as_bytes())? }); + } + let notes = &track.sequence.notes; + let mut ends: Vec = notes.iter().map(|n| clamp_tick(n.end()).max(clamp_tick(n.start).saturating_add(1))).collect(); + let mut dropped = vec![false; notes.len()]; + trim_overlaps(notes, &mut ends, &mut dropped); + for (i, n) in notes.iter().enumerate() { + if dropped.get(i).copied().unwrap_or(false) { + continue; + } + let ch = n.channel & 0x0F; + let pitch = n.pitch.min(127); + let start = clamp_tick(n.start); + let end = ends.get(i).copied().unwrap_or(start.saturating_add(1)); + ev.push(WEvent { tick: start, order: 3, bytes: vec![0x90 | ch, pitch, n.velocity.clamp(1, 127)] }); + ev.push(WEvent { tick: end, order: 1, bytes: vec![0x80 | ch, pitch, n.release_velocity.min(127)] }); + } + for c in &track.sequence.ctrls { + let ch = c.channel & 0x0F; + let v7 = c.value.clamp(0, 127) as u8; + let bytes = match c.kind { + CtrlKind::Cc(num) => vec![0xB0 | ch, num.min(127), v7], + CtrlKind::Program => vec![0xC0 | ch, v7], + CtrlKind::ChannelPressure => vec![0xD0 | ch, v7], + CtrlKind::PolyPressure(p) => vec![0xA0 | ch, p.min(127), v7], + CtrlKind::PitchBend => { + let v = (c.value.saturating_add(8192)).clamp(0, 16383); + vec![0xE0 | ch, (v & 0x7F) as u8, ((v >> 7) & 0x7F) as u8] + } + }; + ev.push(WEvent { tick: clamp_tick(c.tick), order: 2, bytes }); + } + write_track(&mut out, ev)?; + } + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn vlq_round_trip() { + for v in [0u64, 0x40, 0x7F, 0x80, 0x2000, 0x3FFF, 0x4000, 0x1F_FFFF, 0x20_0000, MAX_VLQ] { + let mut b = Vec::new(); + push_vlq(&mut b, v).unwrap(); + let mut c = Cursor { data: &b, pos: 0, base: 0 }; + assert_eq!(u64::from(c.vlq().unwrap()), v); + assert!(c.is_empty()); + } + assert!(push_vlq(&mut Vec::new(), MAX_VLQ + 1).is_err()); + } + + #[test] + fn empty_smf_round_trips_with_no_tracks() { + let r = read_smf(&write_smf(&Smf::default()).unwrap()).unwrap(); + assert_eq!(r.tracks.len(), 0); + let one = Smf { tracks: vec![SmfTrack::default()], ..Smf::default() }; + let r = read_smf(&write_smf(&one).unwrap()).unwrap(); + assert_eq!(r.tracks.len(), 1); + let cond = Smf { tempos: vec![(0, 90.0)], ..Smf::default() }; + let r = read_smf(&write_smf(&cond).unwrap()).unwrap(); + assert_eq!(r.tracks.len(), 0); + assert_eq!(r.tempos.len(), 1); + } + + #[test] + fn spec_vlq_examples() { + let mut b = Vec::new(); + push_vlq(&mut b, 0x0800_0000).unwrap(); + assert_eq!(b, [0xC0, 0x80, 0x80, 0x00]); + let mut b = Vec::new(); + push_vlq(&mut b, 0x2000).unwrap(); + assert_eq!(b, [0xC0, 0x00]); + } + + #[test] + fn same_pitch_overlaps_do_not_swap_ends() { + let mk = |start, length| Note { pitch: 60, velocity: 100, release_velocity: 64, channel: 0, start, length }; + let smf = Smf { + format: 1, + tracks: vec![SmfTrack { + name: String::new(), + sequence: Sequence { notes: vec![mk(0, 1000), mk(100, 200), mk(1000, 100), mk(2000, 500), mk(2000, 900)], ..Sequence::default() }, + }], + tempos: vec![], + meters: vec![], + markers: vec![], + key_sigs: vec![], + }; + let back = read_smf(&write_smf(&smf).unwrap()).unwrap(); + let mut got: Vec<(i64, i64)> = back.tracks.iter().flat_map(|t| t.sequence.notes.iter().map(|n| (n.start, n.length))).collect(); + got.sort_unstable(); + // The earlier note is trimmed to the start of the next same-pitch note; later notes keep their length; + // of two notes starting together only the longer survives. + assert_eq!(got, vec![(0, 100), (100, 200), (1000, 100), (2000, 900)]); + } +} diff --git a/crates/midi/tests/it/main.rs b/crates/midi/tests/it/main.rs new file mode 100644 index 000000000..29abdd1e5 --- /dev/null +++ b/crates/midi/tests/it/main.rs @@ -0,0 +1,4 @@ +//! Integration tests, built as one test binary: each file is a module. + +mod sequence; +mod smf; diff --git a/crates/midi/tests/it/sequence.rs b/crates/midi/tests/it/sequence.rs new file mode 100644 index 000000000..01185e08b --- /dev/null +++ b/crates/midi/tests/it/sequence.rs @@ -0,0 +1,114 @@ +use craft_midi::{CtrlEvent, CtrlKind, Note, Sequence}; + +fn n(pitch: u8, start: i64, length: i64) -> Note { + Note { pitch, velocity: 100, release_velocity: 64, channel: 0, start, length } +} + +fn starts(notes: &[Note]) -> Vec { + notes.iter().map(|n| n.start).collect() +} + +#[test] +fn sequence_helpers() { + let mut seq = Sequence { + notes: vec![n(64, 100, 50), n(60, 100, 10), n(60, 100, 30), n(60, 0, 10), Note { channel: 2, ..n(60, 0, 10) }], + ctrls: vec![ + CtrlEvent { tick: 500, channel: 0, kind: CtrlKind::Cc(1), value: 1 }, + CtrlEvent { tick: 5, channel: 0, kind: CtrlKind::Cc(1), value: 2 }, + CtrlEvent { tick: 500, channel: 0, kind: CtrlKind::Cc(1), value: 3 }, + CtrlEvent { tick: 500, channel: 0, kind: CtrlKind::Cc(2), value: 4 }, + ], + }; + assert_eq!(seq.end_tick(), 500); + seq.sort(); + assert_eq!(starts(&seq.notes), vec![0, 0, 100, 100, 100]); + assert_eq!(seq.notes[2].pitch, 60); + assert_eq!(seq.notes_in(0, 100).count(), 2); + assert_eq!(seq.notes_overlapping(105, 106).count(), 3); + + let removed = seq.remove_duplicates(); + assert_eq!(removed, 2); + assert_eq!(seq.notes.len(), 4); + let kept = seq.notes.iter().find(|n| n.start == 100 && n.pitch == 60).unwrap(); + assert_eq!(kept.length, 30); + assert_eq!(seq.ctrls.iter().map(|c| c.value).collect::>(), vec![2, 3, 4]); + assert_eq!(seq.remove_duplicates(), 0); + assert_eq!(Sequence::default().end_tick(), 0); +} + +#[cfg(feature = "serde")] +#[test] +fn sequence_serde_round_trip() { + let seq = Sequence { notes: vec![n(60, 0, 1)], ctrls: vec![CtrlEvent { tick: 1, channel: 0, kind: CtrlKind::PolyPressure(60), value: 9 }] }; + let json = serde_json::to_string(&seq).unwrap(); + assert_eq!(serde_json::from_str::(&json).unwrap(), seq); +} + +#[test] +fn remove_duplicates_handles_a_large_same_tick_controller_group() { + // Every channel x controller number at one tick, twice: the second copy of each wins. A + // rescan of the tick's kept events per incoming event would make this quadratic. + let keys: Vec<(u8, u8)> = (0..=255u8).flat_map(|ch| (0..=255u8).map(move |cc| (ch, cc))).collect(); + let ctrls = [0, 1] + .iter() + .flat_map(|&pass| keys.iter().map(move |&(channel, cc)| CtrlEvent { tick: 7, channel, kind: CtrlKind::Cc(cc), value: pass })) + .collect(); + let mut seq = Sequence { notes: Vec::new(), ctrls }; + let start = std::time::Instant::now(); + assert_eq!(seq.remove_duplicates(), keys.len()); + assert!(start.elapsed() < std::time::Duration::from_secs(10), "{:?}", start.elapsed()); + assert_eq!(seq.ctrls.len(), keys.len()); + assert!(seq.ctrls.iter().all(|c| c.value == 1)); +} + +#[test] +fn remove_duplicates_matches_a_per_tick_rescan_on_mixed_ticks() { + // The straightforward per-tick rescan the indexed version replaced, kept as an oracle. + fn reference(mut ctrls: Vec) -> Vec { + ctrls.sort_by_key(|c| c.tick); + let key = |k: CtrlKind| match k { + CtrlKind::Cc(n) => (0, n), + CtrlKind::PitchBend => (1, 0), + CtrlKind::ChannelPressure => (2, 0), + CtrlKind::PolyPressure(p) => (3, p), + CtrlKind::Program => (4, 0), + }; + let mut kept: Vec = Vec::new(); + for c in ctrls { + match kept.iter_mut().rev().take_while(|k| k.tick == c.tick).find(|k| k.channel == c.channel && key(k.kind) == key(c.kind)) { + Some(slot) => *slot = c, + None => kept.push(c), + } + } + kept + } + + // Few ticks, channels and kinds, so the same key recurs both within a tick (interleaved with + // other keys) and across ticks; unsorted input from a fixed xorshift stream. + let mut state = 0x9E37_79B9_7F4A_7C15u64; + let mut next = move || { + state ^= state << 13; + state ^= state >> 7; + state ^= state << 17; + state + }; + let kinds = |r: u64| match r % 5 { + 0 => CtrlKind::Cc((r >> 8) as u8 % 4), + 1 => CtrlKind::PitchBend, + 2 => CtrlKind::ChannelPressure, + 3 => CtrlKind::PolyPressure((r >> 8) as u8 % 4), + _ => CtrlKind::Program, + }; + for round in 0..200 { + let mut ctrls = Vec::new(); + for i in 0..(next() % 64) { + let r = next(); + ctrls.push(CtrlEvent { tick: (r % 6) as i64 - 2, channel: (r >> 16) as u8 % 3, kind: kinds(r >> 24), value: (round * 100 + i) as i32 }); + } + let mut seq = Sequence { notes: Vec::new(), ctrls: ctrls.clone() }; + let removed = seq.remove_duplicates(); + let expected = reference(ctrls.clone()); + assert_eq!(seq.ctrls, expected, "round {round}"); + assert_eq!(removed, ctrls.len() - expected.len(), "round {round}"); + } +} diff --git a/crates/midi/tests/it/smf.rs b/crates/midi/tests/it/smf.rs new file mode 100644 index 000000000..cd2915607 --- /dev/null +++ b/crates/midi/tests/it/smf.rs @@ -0,0 +1,384 @@ +use craft_midi::{CtrlEvent, CtrlKind, MAX_EVENTS, MAX_TRACKS, MidiError, Note, Sequence, Smf, SmfTrack, read_smf, write_smf}; +use proptest::prelude::*; + +fn vlq(mut v: u32) -> Vec { + let mut out = vec![(v & 0x7F) as u8]; + v >>= 7; + while v > 0 { + out.insert(0, (v & 0x7F) as u8 | 0x80); + v >>= 7; + } + out +} + +fn header(format: u16, ntrks: u16, division: u16) -> Vec { + let mut b = b"MThd".to_vec(); + b.extend_from_slice(&6u32.to_be_bytes()); + b.extend_from_slice(&format.to_be_bytes()); + b.extend_from_slice(&ntrks.to_be_bytes()); + b.extend_from_slice(&division.to_be_bytes()); + b +} + +fn track(body: &[u8]) -> Vec { + let mut b = b"MTrk".to_vec(); + b.extend_from_slice(&(body.len() as u32).to_be_bytes()); + b.extend_from_slice(body); + b +} + +fn file(format: u16, division: u16, bodies: &[Vec]) -> Vec { + let mut b = header(format, bodies.len() as u16, division); + for body in bodies { + b.extend(track(body)); + } + b +} + +fn note(pitch: u8, start: i64, length: i64) -> Note { + Note { pitch, velocity: 100, release_velocity: 64, channel: 0, start, length } +} + +#[test] +fn round_trip() { + let mut seq = Sequence { + notes: vec![ + note(60, 0, 480), + Note { pitch: 64, velocity: 90, release_velocity: 30, channel: 3, start: 480, length: 960 }, + note(67, 960, 1), + note(60, 1920, 240), + ], + ctrls: vec![ + CtrlEvent { tick: 0, channel: 0, kind: CtrlKind::Program, value: 5 }, + CtrlEvent { tick: 10, channel: 0, kind: CtrlKind::Cc(7), value: 100 }, + CtrlEvent { tick: 20, channel: 1, kind: CtrlKind::PitchBend, value: -8192 }, + CtrlEvent { tick: 30, channel: 1, kind: CtrlKind::PitchBend, value: 8191 }, + CtrlEvent { tick: 40, channel: 1, kind: CtrlKind::PitchBend, value: 0 }, + CtrlEvent { tick: 50, channel: 2, kind: CtrlKind::ChannelPressure, value: 77 }, + CtrlEvent { tick: 60, channel: 2, kind: CtrlKind::PolyPressure(61), value: 12 }, + ], + }; + seq.sort(); + let smf = Smf { + format: 1, + tracks: vec![ + SmfTrack { name: "Piano".into(), sequence: seq.clone() }, + SmfTrack { name: "Bass ♪".into(), sequence: Sequence { notes: vec![note(36, 100, 50)], ctrls: vec![] } }, + ], + tempos: vec![(0, 120.0), (3840, 90.0)], + meters: vec![(0, 4, 4), (3840, 6, 8)], + markers: vec![(0, "Intro".into()), (7680, "Verse".into())], + key_sigs: vec![(0, -3, true), (3840, 2, false)], + }; + let bytes = write_smf(&smf).unwrap(); + let back = read_smf(&bytes).unwrap(); + assert_eq!(back.format, 1); + assert_eq!(back.tracks.len(), 2); + assert_eq!(back.tracks[0].name, "Piano"); + assert_eq!(back.tracks[0].sequence, seq); + assert_eq!(back.tracks[1].name, "Bass ♪"); + assert_eq!(back.tracks[1].sequence.notes, vec![note(36, 100, 50)]); + assert_eq!(back.tempos.len(), 2); + assert!((back.tempos[0].1 - 120.0).abs() < 1e-9); + assert!((back.tempos[1].1 - 90.0).abs() < 1e-3); + assert_eq!(back.tempos[1].0, 3840); + assert_eq!(back.meters, smf.meters); + assert_eq!(back.markers, smf.markers); + assert_eq!(back.key_sigs, smf.key_sigs); + // Writing again is byte-identical. + let mut again = back.clone(); + again.tempos = smf.tempos.clone(); + assert_eq!(write_smf(&again).unwrap(), bytes); +} + +#[test] +fn first_track_name_wins_and_ignored_meta_preserves_following_notes() { + let meta = |kind, data: &[u8]| [vec![0, 0xFF, kind], vlq(data.len() as u32), data.to_vec()].concat(); + for first in [&b"First"[..], &b""[..], &[0xFF][..]] { + let body = [ + meta(0x03, first), + meta(0x03, b"Second"), + meta(0x7F, &[0x03, 0xFF, 0x2F]), // Unhandled metadata must remain ignored. + vec![0, 0x90, 60, 100, 16, 0x80, 60, 64, 0, 0xFF, 0x2F, 0], + ] + .concat(); + let smf = read_smf(&file(0, 960, &[body])).unwrap(); + assert_eq!(smf.tracks.len(), 1); + assert_eq!(smf.tracks[0].name, String::from_utf8_lossy(first)); + assert_eq!(smf.tracks[0].sequence.notes, vec![note(60, 0, 16)]); + } +} + +#[test] +fn write_rejects_bad_values() { + let bad_tempo = Smf { tempos: vec![(0, 0.0)], ..Default::default() }; + assert!(write_smf(&bad_tempo).is_err()); + let nan_tempo = Smf { tempos: vec![(0, f64::NAN)], ..Default::default() }; + assert!(write_smf(&nan_tempo).is_err()); + let bad_meter = Smf { meters: vec![(0, 4, 3)], ..Default::default() }; + assert!(write_smf(&bad_meter).is_err()); + let huge_gap = Smf { + tracks: vec![SmfTrack { name: String::new(), sequence: Sequence { notes: vec![note(60, 0x1000_0000, 10)], ctrls: vec![] } }], + ..Default::default() + }; + assert!(write_smf(&huge_gap).is_err()); +} + +#[test] +fn division_conversion_480_to_960() { + // Note at tick 480 (one quarter at 480 PPQ), length 240. + let body = [vlq(480), vec![0x90, 60, 100], vlq(240), vec![0x80, 60, 0], vec![0, 0xFF, 0x2F, 0]].concat(); + let smf = read_smf(&file(0, 480, &[body])).unwrap(); + assert_eq!(smf.tracks.len(), 1); + assert_eq!(smf.tracks[0].sequence.notes, vec![Note { pitch: 60, velocity: 100, release_velocity: 0, channel: 0, start: 960, length: 480 }]); +} + +#[test] +fn division_conversion_96_and_smpte() { + let body = [vlq(96), vec![0x90, 60, 100], vlq(48), vec![0x80, 60, 64]].concat(); + let smf = read_smf(&file(0, 96, std::slice::from_ref(&body))).unwrap(); + assert_eq!(smf.tracks[0].sequence.notes[0].start, 960); + assert_eq!(smf.tracks[0].sequence.notes[0].length, 480); + + // SMPTE 25 fps, 40 ticks/frame = 1000 ticks/s; 96 ticks = 96 ms; at 120 bpm 0.5 s = 960 ticks. + let div = u16::from_be_bytes([(-25i8) as u8, 40]); + let body = [vlq(500), vec![0x90, 60, 100], vlq(250), vec![0x80, 60, 64]].concat(); + let smf = read_smf(&file(0, div, &[body])).unwrap(); + assert_eq!(smf.tracks[0].sequence.notes[0].start, 960); + assert_eq!(smf.tracks[0].sequence.notes[0].length, 480); +} + +#[test] +fn smpte_division_rejects_undefined_frame_rates() { + let body = vec![0, 0x90, 60, 100, 40, 0x80, 60, 64, 0, 0xFF, 0x2F, 0]; + for high in 0x80..=0xFF { + if [(-24i8) as u8, (-25i8) as u8, (-29i8) as u8, (-30i8) as u8].contains(&high) { + continue; + } + let division = u16::from_be_bytes([high, 40]); + assert!(matches!(read_smf(&file(0, division, std::slice::from_ref(&body))), Err(MidiError::Invalid { offset: 12, .. })), "{division:#06x}"); + } +} + +#[test] +fn smpte_division_keeps_all_four_standard_frame_rates() { + for (fps, file_ticks, model_ticks) in [(24i8, 960, 1920), (25, 1000, 1920), (29, 1200, 1922), (30, 1200, 1920)] { + let body = [vec![0, 0x90, 60, 100], vlq(file_ticks), vec![0x80, 60, 64, 0, 0xFF, 0x2F, 0]].concat(); + let division = u16::from_be_bytes([(-fps) as u8, 40]); + let smf = read_smf(&file(0, division, std::slice::from_ref(&body))).unwrap(); + assert_eq!(smf.tracks[0].sequence.notes, vec![note(60, 0, model_ticks)], "{fps} fps"); + assert!(read_smf(&file(0, u16::from_be_bytes([(-fps) as u8, 0]), std::slice::from_ref(&body))).is_err()); + } +} + +#[test] +fn running_status_and_velocity_zero_note_off() { + // Note-on with running status for both on and off (velocity 0). + let body = [ + vec![0x00, 0x91, 60, 100], // on C3 ch1 + vec![0x00, 64, 90], // running: on E3 + vlq(960), + vec![60, 0], // running: note-on vel 0 = off C3 + vec![0x00, 64, 0], + vec![0x00, 0xB1, 7, 100], // CC7 + vec![0x10, 10, 64], // running CC10 + ] + .concat(); + let smf = read_smf(&file(0, 960, &[body])).unwrap(); + let seq = &smf.tracks[0].sequence; + assert_eq!( + seq.notes, + vec![ + Note { pitch: 60, velocity: 100, release_velocity: 64, channel: 1, start: 0, length: 960 }, + Note { pitch: 64, velocity: 90, release_velocity: 64, channel: 1, start: 0, length: 960 }, + ] + ); + assert_eq!( + seq.ctrls, + vec![ + CtrlEvent { tick: 960, channel: 1, kind: CtrlKind::Cc(7), value: 100 }, + CtrlEvent { tick: 976, channel: 1, kind: CtrlKind::Cc(10), value: 64 }, + ] + ); +} + +#[test] +fn meta_cancels_running_status() { + let body = [vec![0x00, 0x90, 60, 100], vec![0x00, 0xFF, 0x01, 0x01, b'x'], vec![0x00, 60, 0]].concat(); + assert!(read_smf(&file(0, 960, &[body])).is_err()); +} + +#[test] +fn unmatched_note_off_ignored_and_hanging_notes_closed() { + let body = [ + vec![0x00, 0x80, 50, 0], // unmatched off + vec![0x00, 0x90, 60, 100], // hanging + vlq(100), + vec![0x90, 62, 100], + vlq(100), + vec![0x80, 62, 10], + vlq(800), + vec![0xFF, 0x2F, 0x00], + ] + .concat(); + let smf = read_smf(&file(0, 960, &[body])).unwrap(); + let notes = &smf.tracks[0].sequence.notes; + assert_eq!(notes.len(), 2); + assert_eq!(notes[0], Note { pitch: 60, velocity: 100, release_velocity: 64, channel: 0, start: 0, length: 1000 }); + assert_eq!(notes[1], Note { pitch: 62, velocity: 100, release_velocity: 10, channel: 0, start: 100, length: 100 }); +} + +#[test] +fn overlapping_same_pitch_fifo_and_zero_length() { + let body = [ + vec![0x00, 0x90, 60, 100], + vec![0x10, 0x90, 60, 80], + vec![0x10, 0x80, 60, 0], + vec![0x10, 0x80, 60, 0], + vec![0x00, 0x90, 70, 1], + vec![0x00, 0x80, 70, 0], + ] + .concat(); + let smf = read_smf(&file(0, 960, &[body])).unwrap(); + let notes = &smf.tracks[0].sequence.notes; + assert_eq!((notes[0].start, notes[0].length, notes[0].velocity), (0, 32, 100)); + assert_eq!((notes[1].start, notes[1].length, notes[1].velocity), (16, 32, 80)); + assert_eq!((notes[2].pitch, notes[2].length), (70, 1)); +} + +#[test] +fn format1_conductor_folded_and_unknown_chunks_skipped() { + let conductor = [ + vec![0x00, 0xFF, 0x51, 0x03, 0x07, 0xA1, 0x20], // 500000 us = 120 bpm + vec![0x00, 0xFF, 0x58, 0x04, 3, 2, 24, 8], // 3/4 + vec![0x00, 0xFF, 0x2F, 0x00], + ] + .concat(); + let t1 = [vec![0x00, 0xFF, 0x03, 0x03], b"Org".to_vec(), vec![0x00, 0x90, 60, 100, 0x60, 0x80, 60, 0]].concat(); + let mut bytes = header(1, 2, 960); + bytes.extend(track(&conductor)); + bytes.extend(b"XFIH\x00\x00\x00\x03abc"); + bytes.extend(track(&t1)); + let smf = read_smf(&bytes).unwrap(); + assert_eq!(smf.tracks.len(), 1); + assert_eq!(smf.tracks[0].name, "Org"); + assert_eq!(smf.tempos, vec![(0, 120.0)]); + assert_eq!(smf.meters, vec![(0, 3, 4)]); +} + +#[test] +fn sysex_skipped() { + let body = [vec![0x00, 0xF0, 0x03, 0x7E, 0x7F, 0xF7], vec![0x00, 0x90, 60, 100, 0x10, 0x80, 60, 0]].concat(); + let smf = read_smf(&file(0, 960, &[body])).unwrap(); + assert_eq!(smf.tracks[0].sequence.notes.len(), 1); +} + +#[test] +fn malformed_inputs_are_errors() { + assert!(matches!(read_smf(b""), Err(MidiError::NotSmf))); + assert!(matches!(read_smf(b"RIFF\x00\x00\x00\x06"), Err(MidiError::NotSmf))); + // Header truncated. + assert!(read_smf(b"MThd\x00\x00\x00\x06\x00\x01").is_err()); + // Header length < 6. + assert!(read_smf(b"MThd\x00\x00\x00\x02\x00\x01").is_err()); + // Division 0. + assert!(read_smf(&header(0, 0, 0)).is_err()); + // Format 3. + assert!(read_smf(&header(3, 0, 96)).is_err()); + // Chunk length larger than the data. + let mut b = header(0, 1, 96); + b.extend(b"MTrk\xFF\xFF\xFF\xF0\x00\x90"); + assert!(matches!(read_smf(&b), Err(MidiError::BadChunkLength { .. }))); + // Truncated chunk header. + let mut b = header(0, 1, 96); + b.extend(b"MTr"); + assert!(read_smf(&b).is_err()); + // Huge VLQ (5 continuation bytes). + let body = vec![0xFF, 0xFF, 0xFF, 0xFF, 0x7F, 0x90, 60, 100]; + assert!(matches!(read_smf(&file(0, 96, &[body])), Err(MidiError::VlqTooLong(_)))); + // Meta length past the end of the track. + let body = vec![0x00, 0xFF, 0x03, 0x7F, b'a']; + assert!(matches!(read_smf(&file(0, 96, &[body])), Err(MidiError::Truncated(_)))); + // Truncated channel message. + let body = vec![0x00, 0x90, 60]; + assert!(read_smf(&file(0, 96, &[body])).is_err()); + // Data byte with no running status. + let body = vec![0x00, 60, 100]; + assert!(read_smf(&file(0, 96, &[body])).is_err()); + // System common inside a track. + let body = vec![0x00, 0xF2, 0, 0]; + assert!(read_smf(&file(0, 96, &[body])).is_err()); + // Header only (no tracks) is fine. + assert_eq!(read_smf(&header(1, 0, 96)).unwrap().tracks.len(), 0); +} + +#[test] +fn event_cap_reached_is_error() { + // MAX_EVENTS + 1 one-byte running-status program changes (2 bytes each with delta). + let mut body = vec![0x00, 0xC0, 0x00]; + body.reserve(MAX_EVENTS * 2); + for _ in 0..MAX_EVENTS { + body.extend_from_slice(&[0x00, 0x01]); + } + assert!(matches!(read_smf(&file(0, 96, &[body])), Err(MidiError::TooManyEvents(_)))); +} + +proptest! { + #![proptest_config(ProptestConfig::with_cases(512))] + + #[test] + fn random_bytes_never_panic(data in proptest::collection::vec(any::(), 0..512)) { + let _ = read_smf(&data); + } + + #[test] + fn random_track_bodies_never_panic(body in proptest::collection::vec(any::(), 0..512), div in any::(), fmt in 0u16..4) { + let _ = read_smf(&file(fmt, div, &[body])); + } + + #[test] + fn written_files_read_back(notes in proptest::collection::vec((0u8..128, 1u8..128, 0u8..16, 0i64..100_000, 1i64..10_000), 0..64)) { + let mut seq = Sequence { + notes: notes.iter().map(|&(pitch, velocity, channel, start, length)| Note { pitch, velocity, release_velocity: 64, channel, start, length }).collect(), + ctrls: vec![], + }; + seq.remove_duplicates(); + let smf = Smf { format: 1, tracks: vec![SmfTrack { name: "t".into(), sequence: seq.clone() }], ..Default::default() }; + let back = read_smf(&write_smf(&smf).unwrap()).unwrap(); + // Overlapping same-pitch notes may re-pair, but count and starts survive. + let got = back.tracks.first().map(|t| t.sequence.notes.clone()).unwrap_or_default(); + prop_assert_eq!(got.len(), seq.notes.len()); + let mut a: Vec<_> = got.iter().map(|n| (n.start, n.pitch, n.channel)).collect(); + let mut b: Vec<_> = seq.notes.iter().map(|n| (n.start, n.pitch, n.channel)).collect(); + a.sort(); + b.sort(); + prop_assert_eq!(a, b); + } +} + +/// #472: out-of-range pitches, release velocities and controller numbers clamp to 127 instead of +/// wrapping into other notes and controllers (128 & 0x7F would be 0). +#[test] +fn writer_clamps_out_of_range_data_bytes() { + for raw in [127u8, 128, 255] { + let mut seq = Sequence::default(); + seq.notes.push(Note { pitch: raw, velocity: 100, release_velocity: raw, channel: 0, start: 0, length: 960 }); + seq.ctrls.push(CtrlEvent { tick: 0, channel: 0, kind: CtrlKind::Cc(raw), value: 64 }); + seq.ctrls.push(CtrlEvent { tick: 0, channel: 0, kind: CtrlKind::PolyPressure(raw), value: 64 }); + let smf = Smf { tracks: vec![SmfTrack { name: String::new(), sequence: seq }], ..Smf::default() }; + let back = read_smf(&write_smf(&smf).unwrap()).unwrap(); + let s = &back.tracks.last().unwrap().sequence; + assert_eq!((s.notes[0].pitch, s.notes[0].release_velocity), (127, 127), "input {raw}"); + let kinds: Vec = s.ctrls.iter().map(|c| c.kind).collect(); + assert!(kinds.contains(&CtrlKind::Cc(127)) && kinds.contains(&CtrlKind::PolyPressure(127)), "input {raw}: {kinds:?}"); + } +} + +#[test] +fn track_cap_reached_is_error() { + let at_limit = file(1, 480, &vec![Vec::new(); MAX_TRACKS]); + assert!(read_smf(&at_limit).is_ok()); + let mut over = at_limit; + over.extend(track(&[])); + assert_eq!(read_smf(&over), Err(MidiError::TooManyTracks(MAX_TRACKS))); +}