Add craft-midi: Standard MIDI File reader and writer from SoundCraft (#884)

read_smf (formats 0 and 1, format 2 best effort) and write_smf (format 1,
960 PPQ) with the model they read into: Note, CtrlEvent, CtrlKind and
Sequence, in ticks at 960 per quarter note. serde on the model types is
behind the `serde` feature.

Moved from soundcraft crates/midi at 17d3f007: smf.rs, sequence.rs, the
error type, the SMF tests and the Sequence tests. The editing operations
(quantize, transpose, ...) and the display names stay in SoundCraft. Adds
a test for the MAX_TRACKS limit, which had none.
This commit is contained in:
Dylan M. Taylor authored and GitHub committed 2026-11-11 16:41:11 -05:00
1 parent f46dc936a5
commit c684e6a759
11 files changed
+1422

No files matched your search

Generated
+10
View File
@@ -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"
+1
View File
@@ -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" }
+3
View File
@@ -132,6 +132,9 @@ craft-<name> = { git = "https://github.com/storytold/craft-libs", rev = "<commit
of polygons with per-corner indices, materials, a node hierarchy): STL, OBJ with MTL, PLY and
glTF 2.0 / GLB, one feature each. Readers are bounded by `Limits`, never open a file themselves
and never panic. Carved from EffectCraft, SolidCraft, TextureCraft and MaterialCraft.
- [`craft-midi`](crates/midi/README.md): Standard MIDI Files: a bounded reader for formats 0, 1 and
2 and a format-1 writer, over a neutral model of notes and channel events in ticks at 960 per
quarter note. From SoundCraft.
- [`craft-mime`](crates/mime/README.md): Internet messages and MIME, read and written: RFC 5322
header fields, MIME parts and multiparts, encoded-words, RFC 2231 parameters, base64 and
quoted-printable, character sets, address lists, dates, mbox files and the `multipart/related`
+23
View File
@@ -0,0 +1,23 @@
[package]
name = "craft-midi"
description = "MIDI file formats: Standard MIDI File (SMF 0/1/2) reader and format-1 writer over a neutral note and controller model, bounded on hostile input"
version.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
repository.workspace = true
[lints]
workspace = true
[features]
# Serialize / Deserialize on the model types (Note, CtrlEvent, CtrlKind, Sequence).
serde = ["dep:serde"]
[dependencies]
serde = { workspace = true, optional = true }
thiserror.workspace = true
[dev-dependencies]
proptest.workspace = true
serde_json.workspace = true
+60
View File
@@ -0,0 +1,60 @@
# craft-midi
MIDI file formats. Bytes in, a neutral model out, and back. No file access: callers pass bytes,
so the crate builds for wasm32.
| Format | Read | Write |
|---|---|---|
| Standard MIDI File (`.mid`, `.midi`), formats 0 and 1 | yes | format 1 |
| Standard MIDI File, format 2 | best effort (all tracks share one timeline) | no |
RIFF-wrapped MIDI (`.rmi`), System Exclusive dumps (`.syx`) and karaoke lyrics are not here; no
app reads or writes them.
## The neutral model
- `Note`: pitch, note-on velocity, release velocity, channel, start and length in ticks.
- `CtrlEvent`: a channel event at a tick: control change, pitch bend (centred, -8192..=8191),
channel pressure, polyphonic pressure or program change.
- `Sequence`: the notes and channel events of one track, with sorting, range queries and
duplicate removal.
- `Smf`: the file: its tracks (`SmfTrack`: a name and a `Sequence`) and the file-wide tempo,
meter, marker and key-signature lists.
All ticks are at `PPQ` (960) per quarter note, whatever the file's division. The `serde` feature
derives `Serialize` and `Deserialize` on `Note`, `CtrlEvent`, `CtrlKind` and `Sequence`.
## Reading
`read_smf(bytes)`:
- converts the file's division to 960 ticks per quarter note; an SMPTE division (24, 25, 29.97 or
30 frames per second) is approximated at 120 bpm;
- pairs note-ons with note-offs: running status, note-on with velocity 0 as note-off, overlapping
notes of one pitch first in first out, an unmatched note-off ignored, a note still sounding at
the end of its track closed there, a zero-length note given one tick;
- folds the leading conductor track of a format-1 file (no notes, no channel events) into the
file-wide lists; its name is not kept;
- skips unknown chunks, unknown meta events and System Exclusive events;
- accepts a header track count that differs from the number of `MTrk` chunks and reads every
chunk present.
Input is hostile. Every length is checked against the data that is left, a variable-length
quantity is at most four bytes, and a file is refused beyond `MAX_TRACKS` (4096) track chunks or
`MAX_EVENTS` (5,000,000) events. Malformed structure (bad header, truncated chunk, a data byte
without running status, a system real-time status in a track) is a `MidiError`, never a panic.
## Writing
`write_smf(&smf)` writes format 1 at 960 ticks per quarter note: a conductor track with tempos,
meters, key signatures and markers, then one track per `SmfTrack`. Negative ticks are clamped to
0 and data bytes to their MIDI ranges; a note-on velocity of 0 is written as 1. Notes of one
pitch that overlap are trimmed so that the file reads back with the same starts, and of two that
start together the longer is written. An invalid tempo, a meter denominator that is not a power
of two, more than 65535 tracks or a gap beyond the 28-bit delta limit is an error.
## Sources and clean room
Written from the public Standard MIDI Files 1.0 specification (the MIDI Association). No other
product's code, assets or strings were used. The code comes from SoundCraft's `soundcraft-midi`
crate; the tests build every file they read in code, so the crate ships no binary test data.
+42
View File
@@ -0,0 +1,42 @@
//! MIDI file formats.
//!
//! - [`Note`], [`CtrlEvent`] and [`Sequence`]: the neutral model, the notes and channel events of
//! one track, in ticks at [`PPQ`] (960) ticks per quarter note.
//! - [`read_smf`] / [`write_smf`]: Standard MIDI File reading (format 0/1, format 2 best-effort)
//! and writing (format 1, 960 PPQ). The reader treats its input as hostile: every length is
//! checked, variable-length quantities are bounded and track and event counts are capped.
//!
//! Written from the public Standard MIDI Files 1.0 specification.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
mod sequence;
mod smf;
pub use sequence::{CtrlEvent, CtrlKind, Note, Sequence};
pub use smf::{MAX_EVENTS, MAX_TRACKS, Smf, SmfTrack, read_smf, write_smf};
/// Ticks per quarter note used throughout the model.
pub const PPQ: u32 = 960;
/// Errors from reading or writing MIDI data.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum MidiError {
#[error("not a Standard MIDI File (missing MThd header)")]
NotSmf,
#[error("unexpected end of data at byte {0}")]
Truncated(usize),
#[error("variable-length quantity longer than 4 bytes at byte {0}")]
VlqTooLong(usize),
#[error("chunk length {len} at byte {offset} exceeds the remaining data")]
BadChunkLength { offset: usize, len: u64 },
#[error("invalid MIDI data at byte {offset}: {reason}")]
Invalid { offset: usize, reason: &'static str },
#[error("unsupported MIDI file: {0}")]
Unsupported(&'static str),
#[error("too many events (limit {0})")]
TooManyEvents(usize),
#[error("too many tracks (limit {0})")]
TooManyTracks(usize),
#[error("cannot write MIDI file: {0}")]
Write(String),
}
+146
View File
@@ -0,0 +1,146 @@
#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::collections::hash_map::Entry;
/// A MIDI note. Times are in model ticks ([`crate::PPQ`] per quarter note).
#[derive(Debug, Clone, Copy, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct Note {
/// MIDI pitch 0..=127 (60 = middle C).
pub pitch: u8,
/// Note-on velocity 1..=127.
pub velocity: u8,
/// Note-off (release) velocity 0..=127; 64 when the source had none.
pub release_velocity: u8,
/// MIDI channel 0..=15.
pub channel: u8,
/// Start in ticks.
pub start: i64,
/// Length in ticks; always > 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<Note>,
/// Controller events, sorted by tick after [`Sequence::sort`].
pub ctrls: Vec<CtrlEvent>,
}
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<Item = &Note> + '_ {
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<Item = &Note> + '_ {
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<CtrlEvent> = 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())
}
}
+635
View File
@@ -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<SmfTrack>,
/// `(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<u8, MidiError> {
let b = *self.data.get(self.pos).ok_or(MidiError::Truncated(self.at()))?;
self.pos += 1;
Ok(b)
}
fn peek(&self) -> Result<u8, MidiError> {
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<u16, MidiError> {
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<u32, MidiError> {
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<u32, MidiError> {
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<Self, MidiError> {
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<Smf, MidiError> {
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<VecDeque<(i64, u8)>>;
fn close_note(open: &mut OpenNotes, notes: &mut Vec<Note>, 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<SmfTrack, MidiError> {
let mut cur = Cursor { data: body, pos: 0, base };
let mut name: Option<String> = 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<u8> = 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<u8>, 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<u8>,
}
fn clamp_tick(t: i64) -> u64 {
u64::try_from(t.max(0)).unwrap_or(0)
}
fn meta(kind: u8, data: &[u8]) -> Result<Vec<u8>, 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<u8>, mut events: Vec<WEvent>) -> 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<u8> = 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<Vec<WEvent>, 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<usize> = (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<usize> = 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<Vec<u8>, 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<u64> = 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)]);
}
}
+4
View File
@@ -0,0 +1,4 @@
//! Integration tests, built as one test binary: each file is a module.
mod sequence;
mod smf;
+114
View File
@@ -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<i64> {
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<_>>(), 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::<Sequence>(&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<CtrlEvent>) -> Vec<CtrlEvent> {
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<CtrlEvent> = 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}");
}
}
+384
View File
@@ -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<u8> {
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<u8> {
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<u8> {
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<u8>]) -> Vec<u8> {
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::<u8>(), 0..512)) {
let _ = read_smf(&data);
}
#[test]
fn random_track_bodies_never_panic(body in proptest::collection::vec(any::<u8>(), 0..512), div in any::<u16>(), 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<CtrlKind> = 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)));
}