mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-13 22:12:12 +00:00
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:
1 parent
f46dc936a5
commit
c684e6a759
11 files changed
+1422
No files matched your search
Generated
+10
@@ -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"
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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)]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
//! Integration tests, built as one test binary: each file is a module.
|
||||
|
||||
mod sequence;
|
||||
mod smf;
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
Reference in new issue
Block a user