use std::collections::{HashMap, VecDeque};
use std::path::{Path, PathBuf};
use color_eyre::Result;
use color_eyre::eyre::eyre;
use polars::prelude::*;
use crate::widgets::info::{clock, count_of, group_u64};
pub(crate) const READER: crate::readers::Reader = crate::readers::Reader {
scan,
signatures: &[crate::readers::Signature {
says: |head, _| looks_like_midi(head),
kind: crate::readers::Kind::Magic,
trusted: crate::readers::EVERYWHERE,
}],
..crate::readers::BASE
};
pub const MAX_FILE_BYTES: u64 = 64 * 1024 * 1024;
pub const MAX_EVENTS: usize = 10_000_000;
const HEX_SHOWN: usize = 256;
const DEFAULT_TEMPO: u32 = 500_000;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Division {
Ppq(u16),
Smpte { fps: u8, ticks_per_frame: u8 },
}
impl Division {
pub fn label(self) -> String {
match self {
Division::Ppq(n) => format!("{n} ticks per quarter"),
Division::Smpte {
fps,
ticks_per_frame,
} => {
let fps = if fps == 29 {
"29.97".to_string()
} else {
fps.to_string()
};
format!("{fps} fps, {ticks_per_frame} ticks per frame")
}
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct Event<'a> {
pub tick: u64,
pub body: Body<'a>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Body<'a> {
Channel { status: u8, a: u8, b: u8 },
Sysex { escape: bool, data: &'a [u8] },
Meta { kind: u8, data: &'a [u8] },
System { status: u8, a: u8, b: u8 },
}
#[derive(Debug, Clone)]
pub struct Smf<'a> {
pub format: u16,
pub division: Division,
pub tracks: Vec<Vec<Event<'a>>>,
}
pub fn looks_like_midi(head: &[u8]) -> bool {
head.starts_with(b"MThd\0\0\0\x06")
|| (head.len() >= 12 && head.starts_with(b"RIFF") && &head[8..12] == b"RMID")
}
struct Bytes<'a> {
data: &'a [u8],
at: usize,
}
impl<'a> Bytes<'a> {
fn new(data: &'a [u8]) -> Self {
Self { data, at: 0 }
}
fn left(&self) -> usize {
self.data.len() - self.at
}
fn u8(&mut self) -> Option<u8> {
let b = *self.data.get(self.at)?;
self.at += 1;
Some(b)
}
fn take(&mut self, n: usize) -> Option<&'a [u8]> {
if n > self.left() {
return None;
}
let out = &self.data[self.at..self.at + n];
self.at += n;
Some(out)
}
fn u32(&mut self) -> Option<u32> {
self.take(4)
.map(|b| u32::from_be_bytes([b[0], b[1], b[2], b[3]]))
}
fn vlq(&mut self) -> Result<u32, VlqError> {
let mut value = 0u32;
for _ in 0..4 {
let b = self.u8().ok_or(VlqError::CutShort)?;
value = (value << 7) | u32::from(b & 0x7f);
if b & 0x80 == 0 {
return Ok(value);
}
}
Err(VlqError::TooLong)
}
}
enum VlqError {
CutShort,
TooLong,
}
fn at(track: usize, offset: usize) -> String {
format!("track {}, byte {offset}", track + 1)
}
fn unwrap_rmid(bytes: &[u8]) -> Result<&[u8]> {
if bytes.get(8..12) != Some(b"RMID".as_slice()) {
return Err(eyre!("Not a MIDI file: a RIFF file that is not RIFF MIDI"));
}
let mut r = Bytes::new(&bytes[12..]);
while r.left() >= 8 {
let id = r.take(4).unwrap_or_default();
let len = r
.take(4)
.map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]]));
let len = len.unwrap_or(0) as usize;
let Some(body) = r.take(len) else {
return Err(eyre!("Not a MIDI file: a RIFF chunk runs past the end"));
};
if id == b"data" {
return Ok(body);
}
if len % 2 == 1 {
r.take(1);
}
}
Err(eyre!(
"Not a MIDI file: the RIFF MIDI wrapper has no data chunk"
))
}
pub fn parse(bytes: &[u8]) -> Result<Smf<'_>> {
let bytes = if bytes.starts_with(b"RIFF") {
unwrap_rmid(bytes)?
} else {
bytes
};
let mut r = Bytes::new(bytes);
if r.take(4) != Some(b"MThd".as_slice()) {
return Err(eyre!("Not a MIDI file: it does not start with MThd"));
}
let header_len = r.u32().ok_or_else(|| eyre!("MIDI header is cut short"))? as usize;
if header_len < 6 {
return Err(eyre!("MIDI header is {header_len} bytes; it needs 6"));
}
let header = r
.take(header_len)
.ok_or_else(|| eyre!("MIDI header is cut short"))?;
let format = u16::from_be_bytes([header[0], header[1]]);
let declared = u16::from_be_bytes([header[2], header[3]]) as usize;
let raw_division = u16::from_be_bytes([header[4], header[5]]);
if format > 2 {
return Err(eyre!("MIDI format {format} is not one of 0, 1 or 2"));
}
if declared == 0 {
return Err(eyre!("MIDI header says the file has no tracks"));
}
let division = if raw_division & 0x8000 != 0 {
let fps = (-i16::from((raw_division >> 8) as u8 as i8)) as u8;
let ticks_per_frame = (raw_division & 0xff) as u8;
if !matches!(fps, 24 | 25 | 29 | 30) || ticks_per_frame == 0 {
return Err(eyre!(
"MIDI header's SMPTE timing ({fps} fps, {ticks_per_frame} ticks per frame) is not usable"
));
}
Division::Smpte {
fps,
ticks_per_frame,
}
} else if raw_division == 0 {
return Err(eyre!("MIDI header says 0 ticks per quarter note"));
} else {
Division::Ppq(raw_division)
};
let mut tracks = Vec::new();
let mut events = 0usize;
while tracks.len() < declared {
if r.left() < 8 {
return Err(eyre!(
"MIDI header says {declared} {}; the file holds {}",
if declared == 1 { "track" } else { "tracks" },
tracks.len()
));
}
let id = r.take(4).unwrap_or_default();
let len = r.u32().unwrap_or(0) as usize;
let left = r.left();
let Some(body) = r.take(len) else {
if id == b"MTrk" {
return Err(eyre!(
"MIDI track {} is cut short: it says {len} bytes and {left} remain",
tracks.len() + 1
));
}
return Err(eyre!(
"MIDI file is cut short: a chunk says {len} bytes and {left} remain"
));
};
if id != b"MTrk" {
continue;
}
let track = parse_track(body, tracks.len(), &mut events)?;
tracks.push(track);
}
Ok(Smf {
format,
division,
tracks,
})
}
fn parse_track<'a>(body: &'a [u8], track: usize, events: &mut usize) -> Result<Vec<Event<'a>>> {
let mut r = Bytes::new(body);
let mut out = Vec::new();
let mut tick = 0u64;
let mut running: Option<u8> = None;
while r.left() > 0 {
let start = r.at;
let delta = r.vlq().map_err(|e| match e {
VlqError::CutShort => eyre!("MIDI {}: a delta time is cut short", at(track, start)),
VlqError::TooLong => eyre!(
"MIDI {}: a delta time is longer than four bytes",
at(track, start)
),
})?;
tick += u64::from(delta);
let cut = || eyre!("MIDI {}: an event is cut short", at(track, start));
let first = r.u8().ok_or_else(cut)?;
let body = match first {
0xff => {
running = None;
let kind = r.u8().ok_or_else(cut)?;
let len = vlq_len(&mut r, track, start)?;
let data = r.take(len).ok_or_else(cut)?;
Body::Meta { kind, data }
}
0xf0 | 0xf7 => {
running = None;
let len = vlq_len(&mut r, track, start)?;
let data = r.take(len).ok_or_else(cut)?;
Body::Sysex {
escape: first == 0xf7,
data,
}
}
0xf8..=0xfe => Body::System {
status: first,
a: 0,
b: 0,
},
0xf1 | 0xf2 | 0xf3 | 0xf6 => {
running = None;
let (a, b) = match first {
0xf1 | 0xf3 => (r.u8().ok_or_else(cut)?, 0),
0xf2 => (r.u8().ok_or_else(cut)?, r.u8().ok_or_else(cut)?),
_ => (0, 0),
};
if a & 0x80 != 0 || b & 0x80 != 0 {
return Err(eyre!(
"MIDI {}: a data byte has its high bit set",
at(track, start)
));
}
Body::System {
status: first,
a,
b,
}
}
0xf4 | 0xf5 => {
return Err(eyre!(
"MIDI {}: status {first:#04X} is undefined",
at(track, start)
));
}
_ => {
let (status, a) = if first & 0x80 != 0 {
running = Some(first);
(first, r.u8().ok_or_else(cut)?)
} else {
let status = running.ok_or_else(|| {
eyre!(
"MIDI {}: a data byte with no status before it",
at(track, start)
)
})?;
(status, first)
};
let b = if matches!(status & 0xf0, 0xc0 | 0xd0) {
0
} else {
r.u8().ok_or_else(cut)?
};
if a & 0x80 != 0 || b & 0x80 != 0 {
return Err(eyre!(
"MIDI {}: a data byte has its high bit set",
at(track, start)
));
}
Body::Channel { status, a, b }
}
};
*events += 1;
if *events > MAX_EVENTS {
return Err(eyre!(
"MIDI has more than {MAX_EVENTS} events; datui reads up to that many"
));
}
out.push(Event { tick, body });
if matches!(body, Body::Meta { kind: 0x2f, .. }) {
break;
}
}
Ok(out)
}
fn vlq_len(r: &mut Bytes<'_>, track: usize, start: usize) -> Result<usize> {
r.vlq().map(|n| n as usize).map_err(|e| match e {
VlqError::CutShort => eyre!("MIDI {}: an event is cut short", at(track, start)),
VlqError::TooLong => eyre!(
"MIDI {}: a length is longer than four bytes",
at(track, start)
),
})
}
static NOTE_NAMES: std::sync::LazyLock<Vec<String>> =
std::sync::LazyLock::new(|| (0..=127).map(note_name).collect());
fn note_name_of(note: u8) -> &'static str {
NOTE_NAMES[usize::from(note & 0x7f)].as_str()
}
pub fn note_name(note: u8) -> String {
const NAMES: [&str; 12] = [
"C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B",
];
format!(
"{}{}",
NAMES[(note % 12) as usize],
i32::from(note / 12) - 1
)
}
fn key_name(sf: i8, minor: bool) -> Option<String> {
const MAJOR: [&str; 15] = [
"Cb", "Gb", "Db", "Ab", "Eb", "Bb", "F", "C", "G", "D", "A", "E", "B", "F#", "C#",
];
const MINOR: [&str; 15] = [
"Ab", "Eb", "Bb", "F", "C", "G", "D", "A", "E", "B", "F#", "C#", "G#", "D#", "A#",
];
let i = usize::try_from(i16::from(sf) + 7)
.ok()
.filter(|i| *i < 15)?;
Some(if minor {
format!("{} minor", MINOR[i])
} else {
format!("{} major", MAJOR[i])
})
}
pub(crate) fn bpm(tempo: u32) -> String {
if tempo == 0 {
return "-".to_string();
}
let s = format!("{:.2}", 60_000_000.0 / f64::from(tempo));
s.trim_end_matches('0').trim_end_matches('.').to_string()
}
fn meta_text(data: &[u8]) -> String {
match std::str::from_utf8(data) {
Ok(s) => s.to_string(),
Err(_) => data.iter().map(|&b| char::from(b)).collect(),
}
}
fn hex(prefix: Option<u8>, data: &[u8]) -> String {
use std::fmt::Write;
let mut out = String::new();
if let Some(p) = prefix {
let _ = write!(out, "{p:02X}");
}
for b in data.iter().take(HEX_SHOWN) {
if !out.is_empty() {
out.push(' ');
}
let _ = write!(out, "{b:02X}");
}
if data.len() > HEX_SHOWN {
let _ = write!(out, " ... ({} more bytes)", data.len() - HEX_SHOWN);
}
out
}
struct TempoMap {
segments: Vec<(u64, u128, u32)>,
ppq: u128,
}
impl TempoMap {
fn new(mut changes: Vec<(u64, u32)>, ppq: u16) -> Self {
changes.sort_by_key(|(tick, _)| *tick);
let mut segments: Vec<(u64, u128, u32)> = vec![(0, 0, DEFAULT_TEMPO)];
for (tick, tempo) in changes {
let &(last_tick, last_base, last_tempo) = segments.last().expect("starts with one");
if tick == last_tick {
segments.last_mut().expect("starts with one").2 = tempo;
} else {
let base = last_base + u128::from(tick - last_tick) * u128::from(last_tempo);
segments.push((tick, base, tempo));
}
}
Self {
segments,
ppq: u128::from(ppq),
}
}
fn seconds(&self, tick: u64) -> f64 {
let i = self.segments.partition_point(|(t, _, _)| *t <= tick) - 1;
let (t, base, tempo) = self.segments[i];
let num = base + u128::from(tick - t) * u128::from(tempo);
num as f64 / (self.ppq as f64 * 1e6)
}
}
enum Clock<'m> {
Tempo(&'m TempoMap),
Smpte {
num: u128,
den: u128,
},
}
impl Clock<'_> {
fn seconds(&self, tick: u64) -> f64 {
match self {
Clock::Tempo(map) => map.seconds(tick),
Clock::Smpte { num, den } => (u128::from(tick) * num) as f64 / (*den as f64 * 1e6),
}
}
}
fn tempo_events<'a>(tracks: impl IntoIterator<Item = &'a Vec<Event<'a>>>) -> Vec<(u64, u32)> {
tracks
.into_iter()
.flatten()
.filter_map(|e| match e.body {
Body::Meta {
kind: 0x51,
data: [a, b, c],
} => Some((e.tick, u32::from_be_bytes([0, *a, *b, *c]))),
_ => None,
})
.collect()
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct TrackSummary {
pub name: Option<String>,
pub instrument: Option<String>,
pub events: usize,
pub notes: usize,
pub channels: Vec<u8>,
}
#[derive(Debug, Clone, Default)]
pub struct MidiSummary {
pub files: usize,
pub format: Option<u16>,
pub division: Option<Division>,
pub tracks: Vec<TrackSummary>,
pub track_count: usize,
pub events: usize,
pub notes: usize,
pub unended: usize,
pub length_seconds: f64,
pub tempo: Option<(u32, u32, u32)>,
pub tempo_changes: usize,
pub time_signature: Option<String>,
pub key: Option<String>,
pub copyright: Option<String>,
pub unreadable: Vec<(String, String)>,
}
#[derive(Default)]
struct Columns<'a> {
file: Vec<&'a str>,
track: Vec<u16>,
tick: Vec<u64>,
seconds: Vec<f64>,
kind: Vec<&'static str>,
channel: Vec<Option<u8>>,
note: Vec<Option<u8>>,
note_name: Vec<Option<&'static str>>,
velocity: Vec<Option<u8>>,
controller: Vec<Option<u8>>,
value: Vec<Option<i32>>,
length: Vec<Option<f64>>,
text: Vec<Option<String>>,
}
impl Columns<'_> {
fn push(&mut self, track: u16, tick: u64, seconds: f64, kind: &'static str) {
self.track.push(track);
self.tick.push(tick);
self.seconds.push(seconds);
self.kind.push(kind);
self.channel.push(None);
self.note.push(None);
self.note_name.push(None);
self.velocity.push(None);
self.controller.push(None);
self.value.push(None);
self.length.push(None);
self.text.push(None);
}
fn rows(&self) -> usize {
self.track.len()
}
}
fn add_file<'a>(
smf: &Smf<'_>,
file: Option<&'a str>,
cols: &mut Columns<'a>,
summary: &mut MidiSummary,
) {
let tempo_map = match smf.division {
Division::Ppq(ppq) if smf.format != 2 => {
Some(TempoMap::new(tempo_events(&smf.tracks), ppq))
}
_ => None,
};
let mut tracks = Vec::with_capacity(smf.tracks.len());
let mut tempos: Vec<u32> = Vec::new();
for (index, events) in smf.tracks.iter().enumerate() {
let own_map;
let clock = match (smf.division, &tempo_map) {
(
Division::Smpte {
fps,
ticks_per_frame,
},
_,
) => {
let (fps_num, fps_den) = if fps == 29 {
(30_000u128, 1001u128)
} else {
(u128::from(fps), 1)
};
Clock::Smpte {
num: 1_000_000 * fps_den,
den: fps_num * u128::from(ticks_per_frame),
}
}
(_, Some(map)) => Clock::Tempo(map),
(Division::Ppq(ppq), None) => {
own_map = TempoMap::new(tempo_events([events]), ppq);
Clock::Tempo(&own_map)
}
};
let track_no = u16::try_from(index + 1).unwrap_or(u16::MAX);
let mut track = TrackSummary {
events: events.len(),
..Default::default()
};
let mut channels = [false; 16];
let mut sounding: HashMap<(u8, u8), VecDeque<usize>> = HashMap::new();
for event in events {
let time = clock.seconds(event.tick);
summary.length_seconds = summary.length_seconds.max(time);
let row = cols.rows();
match event.body {
Body::Channel { status, a, b } => {
let ch = status & 0x0f;
channels[ch as usize] = true;
let kind = match status & 0xf0 {
0x90 if b > 0 => "note_on",
0x80 | 0x90 => "note_off",
0xa0 => "poly_aftertouch",
0xb0 => "cc",
0xc0 => "program",
0xd0 => "channel_aftertouch",
_ => "pitch_bend",
};
cols.push(track_no, event.tick, time, kind);
cols.channel[row] = Some(ch + 1);
match kind {
"note_on" | "note_off" | "poly_aftertouch" => {
cols.note[row] = Some(a);
cols.note_name[row] = Some(note_name_of(a));
if kind == "poly_aftertouch" {
cols.value[row] = Some(i32::from(b));
} else {
cols.velocity[row] = Some(b);
}
}
"cc" => {
cols.controller[row] = Some(a);
cols.value[row] = Some(i32::from(b));
}
"program" | "channel_aftertouch" => cols.value[row] = Some(i32::from(a)),
_ => cols.value[row] = Some(((i32::from(b) << 7) | i32::from(a)) - 8192),
}
if kind == "note_on" {
track.notes += 1;
sounding.entry((ch, a)).or_default().push_back(row);
} else if kind == "note_off"
&& let Some(start) = sounding.get_mut(&(ch, a)).and_then(|q| q.pop_front())
{
cols.length[start] = Some(time - cols.seconds[start]);
}
}
Body::Sysex { escape, data } => {
cols.push(
track_no,
event.tick,
time,
if escape { "sysex_escape" } else { "sysex" },
);
cols.value[row] = Some(i32::try_from(data.len()).unwrap_or(i32::MAX));
cols.text[row] = Some(hex((!escape).then_some(0xf0), data));
}
Body::System { status, a, b } => {
let kind = match status {
0xf1 => "mtc_quarter_frame",
0xf2 => "song_position",
0xf3 => "song_select",
0xf6 => "tune_request",
0xf8 => "clock",
0xfa => "start",
0xfb => "continue",
0xfc => "stop",
0xfe => "active_sensing",
_ => "realtime",
};
cols.push(track_no, event.tick, time, kind);
cols.value[row] = match status {
0xf1 | 0xf3 => Some(i32::from(a)),
0xf2 => Some((i32::from(b) << 7) | i32::from(a)),
_ => None,
};
}
Body::Meta { kind, data } => {
let name = meta_kind(kind);
cols.push(track_no, event.tick, time, name);
let (value, text) = meta_value(kind, data);
cols.value[row] = value;
cols.text[row] = text;
if kind == 0x20
&& let [ch] = data
{
cols.channel[row] = Some((ch & 0x0f) + 1);
}
match (kind, data) {
(0x51, [a, b, c]) => tempos.push(u32::from_be_bytes([0, *a, *b, *c])),
(0x02, _) if summary.copyright.is_none() => {
summary.copyright = cols.text[row].clone();
}
(0x03, _) if track.name.is_none() => track.name = cols.text[row].clone(),
(0x04, _) if track.instrument.is_none() => {
track.instrument = cols.text[row].clone();
}
(0x58, _) if summary.time_signature.is_none() => {
summary.time_signature = cols.text[row].clone();
}
(0x59, _) if summary.key.is_none() => summary.key = cols.text[row].clone(),
_ => {}
}
}
}
if let Some(file) = file {
cols.file.push(file);
}
}
track.channels = (1..=16u8).filter(|c| channels[(c - 1) as usize]).collect();
summary.unended += sounding.values().map(VecDeque::len).sum::<usize>();
summary.notes += track.notes;
tracks.push(track);
}
summary.events += smf.tracks.iter().map(Vec::len).sum::<usize>();
summary.track_count += smf.tracks.len();
summary.tempo_changes += tempos.len().saturating_sub(1);
if let Some(&first) = tempos.first() {
let (lo, hi) = tempos
.iter()
.fold((u32::MAX, 0), |(lo, hi), &t| (lo.min(t), hi.max(t)));
summary.tempo = Some(match summary.tempo {
Some((f, l, h)) => (f, l.min(lo), h.max(hi)),
None => (first, lo, hi),
});
}
summary.format = match (summary.files, summary.format) {
(0, _) => Some(smf.format),
(_, Some(f)) if f == smf.format => Some(f),
_ => None,
};
summary.division = match (summary.files, summary.division) {
(0, _) => Some(smf.division),
(_, Some(d)) if d == smf.division => Some(d),
_ => None,
};
summary.tracks = if summary.files == 0 {
tracks
} else {
Vec::new()
};
summary.files += 1;
}
fn meta_kind(kind: u8) -> &'static str {
match kind {
0x00 => "sequence_number",
0x01 | 0x0a..=0x0f => "text",
0x02 => "copyright",
0x03 => "track_name",
0x04 => "instrument",
0x05 => "lyric",
0x06 => "marker",
0x07 => "cue",
0x08 => "program_name",
0x09 => "device_name",
0x20 => "channel_prefix",
0x21 => "port",
0x2f => "end_of_track",
0x51 => "tempo",
0x54 => "smpte_offset",
0x58 => "time_signature",
0x59 => "key_signature",
0x7f => "sequencer_specific",
_ => "meta",
}
}
fn meta_value(kind: u8, data: &[u8]) -> (Option<i32>, Option<String>) {
match (kind, data) {
(0x01..=0x0f, _) => (None, Some(meta_text(data))),
(0x00, [a, b]) => (Some(i32::from(u16::from_be_bytes([*a, *b]))), None),
(0x20 | 0x21, [a]) => (Some(i32::from(*a)), None),
(0x2f, []) => (None, None),
(0x51, [a, b, c]) => {
let tempo = u32::from_be_bytes([0, *a, *b, *c]);
(
Some(i32::try_from(tempo).unwrap_or(i32::MAX)),
Some(format!("{} bpm", bpm(tempo))),
)
}
(0x54, [hr, mn, se, fr, ff]) => (
None,
Some(format!(
"{:02}:{:02}:{:02}:{:02}.{:02}",
hr & 0x1f,
mn,
se,
fr,
ff
)),
),
(0x58, [nn, dd, _, _]) => {
let denominator = 1u64.checked_shl(u32::from(*dd)).unwrap_or(0);
(None, Some(format!("{nn}/{denominator}")))
}
(0x59, [sf, mi]) => {
let sf = *sf as i8;
match key_name(sf, *mi == 1) {
Some(key) => (Some(i32::from(sf)), Some(key)),
None => (Some(i32::from(sf)), Some(hex(None, data))),
}
}
(0x7f, _) => (None, Some(hex(None, data))),
_ => (None, Some(format!("type {kind:02X}: {}", hex(None, data)))),
}
}
pub fn build(files: &[(String, Smf<'_>)]) -> Result<(LazyFrame, MidiSummary)> {
let many = files.len() > 1;
let mut cols = Columns::default();
let mut summary = MidiSummary::default();
for (name, smf) in files {
add_file(smf, many.then_some(name.as_str()), &mut cols, &mut summary);
}
Ok((frame(cols, many)?, summary))
}
fn frame(cols: Columns<'_>, many: bool) -> Result<LazyFrame> {
let rows = cols.rows();
let mut columns: Vec<Column> = Vec::new();
if many {
columns.push(Series::new("file".into(), cols.file).into());
}
columns.push(Series::new("track".into(), cols.track).into());
columns.push(Series::new("tick".into(), cols.tick).into());
columns.push(Series::new("seconds".into(), cols.seconds).into());
columns.push(Series::new("kind".into(), cols.kind).into());
columns.push(Series::new("channel".into(), cols.channel).into());
columns.push(Series::new("note".into(), cols.note).into());
columns.push(Series::new("note_name".into(), cols.note_name).into());
columns.push(Series::new("velocity".into(), cols.velocity).into());
columns.push(Series::new("controller".into(), cols.controller).into());
columns.push(Series::new("value".into(), cols.value).into());
columns.push(Series::new("length".into(), cols.length).into());
columns.push(Series::new("text".into(), cols.text).into());
Ok(DataFrame::new(rows, columns)?.lazy())
}
fn read_bytes(path: &Path) -> Result<Vec<u8>> {
use std::io::Read;
let file = std::fs::File::open(path)?;
let len = file.metadata()?.len();
if len > MAX_FILE_BYTES {
let size = crate::widgets::info::format_bytes;
return Err(eyre!(
"MIDI file is {}; datui reads MIDI files up to {}",
size(len),
size(MAX_FILE_BYTES)
));
}
let mut bytes = Vec::with_capacity(len as usize);
file.take(MAX_FILE_BYTES).read_to_end(&mut bytes)?;
Ok(bytes)
}
pub fn read_midi(paths: &[PathBuf]) -> Result<(LazyFrame, MidiSummary)> {
if paths.is_empty() {
return Err(eyre!("No MIDI files to read"));
}
let names: Vec<String> = paths
.iter()
.map(|p| {
p.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| p.display().to_string())
})
.collect();
let many = paths.len() > 1;
let mut cols = Columns::default();
let mut summary = MidiSummary::default();
let mut unreadable = Vec::new();
for (path, name) in paths.iter().zip(&names) {
let read = read_bytes(path);
let parsed = read
.as_deref()
.map_err(|e| eyre!(crate::error_display::user_message_from_report(e, None)))
.and_then(parse);
let smf = match parsed {
Ok(smf) => smf,
Err(e) if !many => return Err(e),
Err(e) => {
unreadable.push((name.clone(), e.to_string()));
continue;
}
};
let events = smf.tracks.iter().map(Vec::len).sum::<usize>();
if summary.events + events > MAX_EVENTS {
return Err(eyre!(
"These MIDI files have more than {MAX_EVENTS} events; datui reads up to that many"
));
}
add_file(&smf, many.then_some(name.as_str()), &mut cols, &mut summary);
}
if summary.files == 0 {
let (name, why) = unreadable
.first()
.cloned()
.unwrap_or_else(|| (String::new(), "no files".to_string()));
return Err(eyre!("No MIDI file could be read; \"{name}\": {why}"));
}
summary.unreadable = unreadable;
Ok((frame(cols, many)?, summary))
}
pub fn notes(summary: &MidiSummary) -> Vec<crate::notes::Note> {
let mut out = Vec::new();
if summary.unended > 0 {
let n = summary.unended;
out.push(crate::notes::Note {
summary: format!(
"{} {} {} length null",
crate::widgets::info::group_u64(n as u64),
if n == 1 {
"note never ends"
} else {
"notes never end"
},
crate::glyphs::get().middot
),
scope: "from every event".to_string(),
read_as_text: None,
passed_over: None,
});
}
if !summary.unreadable.is_empty() {
let n = summary.unreadable.len();
let (name, why) = &summary.unreadable[0];
out.push(crate::notes::Note {
summary: format!(
"{} unreadable, left out: {name}: {why}",
if n == 1 {
"1 file".to_string()
} else {
format!("{} files", crate::widgets::info::group_u64(n as u64))
},
),
scope: "from every file".to_string(),
read_as_text: None,
passed_over: None,
});
}
out
}
fn facts(midi: &MidiSummary, sep: &str) -> Vec<String> {
let mut head = match midi.format {
Some(format) => format!("MIDI format {format}"),
None => "MIDI".to_string(),
};
if midi.files > 1 {
head.push_str(sep);
head.push_str(&count_of(midi.files as u64, "file", "files"));
}
if let Some(division) = midi.division {
head.push_str(sep);
head.push_str(&division.label());
}
head.push_str(sep);
head.push_str(&count_of(midi.track_count as u64, "track", "tracks"));
let mut counts = format!(
"Length: {}{sep}{}{sep}{}",
clock(midi.length_seconds),
count_of(midi.events as u64, "event", "events"),
count_of(midi.notes as u64, "note", "notes"),
);
if midi.unended > 0 {
let n = midi.unended;
let verb = if n == 1 { "ends" } else { "end" };
counts.push_str(&format!(" ({} never {verb})", group_u64(n as u64)));
}
let mut lines = vec![head, counts];
let mut music = Vec::new();
if let Some((first, fastest, slowest)) = midi.tempo {
let bpm = bpm;
let n = midi.tempo_changes;
let changes = format!(
"{} {}",
group_u64(n as u64),
if n == 1 { "change" } else { "changes" }
);
music.push(match (midi.files > 1, slowest == fastest) {
(_, true) => format!("Tempo: {} bpm", bpm(first)),
(true, false) => format!("Tempo: {}-{} bpm", bpm(slowest), bpm(fastest)),
(false, false) => format!(
"Tempo: {} bpm ({}-{}, {changes})",
bpm(first),
bpm(slowest),
bpm(fastest)
),
});
}
if midi.files == 1 {
if let Some(time) = &midi.time_signature {
music.push(format!("Time: {time}"));
}
if let Some(key) = &midi.key {
music.push(format!("Key: {key}"));
}
}
if !music.is_empty() {
lines.push(music.join(sep));
}
if let Some(copyright) = midi.copyright.as_ref().filter(|_| midi.files == 1) {
lines.push(format!("Copyright: {copyright}"));
}
lines
}
fn track_rows(midi: &MidiSummary, sep: &str) -> Vec<(String, crate::model_files::MetaValue)> {
midi.tracks
.iter()
.enumerate()
.map(|(i, t)| {
let key = match &t.name {
Some(name) if !name.trim().is_empty() => format!("{} {}", i + 1, name.trim()),
_ => (i + 1).to_string(),
};
let mut parts = vec![count_of(t.events as u64, "event", "events")];
if t.notes > 0 {
parts.push(count_of(t.notes as u64, "note", "notes"));
}
if !t.channels.is_empty() {
let channels: Vec<String> = t.channels.iter().map(u8::to_string).collect();
let label = if channels.len() == 1 {
"channel"
} else {
"channels"
};
parts.push(format!("{label} {}", channels.join(", ")));
}
if let Some(instrument) = t.instrument.as_deref().filter(|i| !i.trim().is_empty()) {
parts.push(instrument.trim().to_string());
}
(key, crate::model_files::MetaValue::Text(parts.join(sep)))
})
.collect()
}
pub fn detail(midi: &MidiSummary) -> crate::text_formats::Detail {
let sep = format!(" {} ", crate::glyphs::get().middot);
let (list_title, list) = if midi.files > 1 {
let rows = midi
.unreadable
.iter()
.map(|(file, why)| {
(
file.clone(),
crate::model_files::MetaValue::Text(why.clone()),
)
})
.collect();
("Unreadable", rows)
} else {
("Tracks", track_rows(midi, &sep))
};
crate::text_formats::Detail {
tab: crate::text_formats::tab(crate::FileFormat::Midi),
lines: facts(midi, &sep),
list_title,
list,
first: true,
own_columns: true,
..Default::default()
}
}
fn scan(input: crate::readers::ScanIn<'_>) -> Result<crate::scan::Scan> {
let (lf, summary) = read_midi(input.paths)?;
input.report.opened = Some(Arc::new(crate::members::Opened {
detail: Some(Arc::new(detail(&summary))),
notes: notes(&summary),
..Default::default()
}));
Ok(lf.into())
}
#[cfg(test)]
pub(crate) mod tests {
use super::*;
pub(crate) fn vlq(mut n: u32) -> Vec<u8> {
let mut out = vec![(n & 0x7f) as u8];
n >>= 7;
while n > 0 {
out.insert(0, (n & 0x7f) as u8 | 0x80);
n >>= 7;
}
out
}
pub(crate) fn smf(format: u16, division: u16, tracks: &[&[u8]]) -> Vec<u8> {
let mut out = b"MThd\0\0\0\x06".to_vec();
out.extend_from_slice(&format.to_be_bytes());
out.extend_from_slice(&(tracks.len() as u16).to_be_bytes());
out.extend_from_slice(&division.to_be_bytes());
for t in tracks {
out.extend_from_slice(b"MTrk");
out.extend_from_slice(&(t.len() as u32).to_be_bytes());
out.extend_from_slice(t);
}
out
}
#[test]
fn errors_name_the_file() {
let mut short_track = smf(0, 96, &[&[0x00, 0xff, 0x2f, 0x00]]);
short_track.truncate(short_track.len() - 2);
crate::readers::bad_input::each_names_its_file(
crate::FileFormat::Midi,
&[
("text.mid", b"hello there", "MThd"),
("cut.mid", b"MThd\0\0", "cut short"),
(
"fmt3.mid",
&smf(3, 96, &[&[0x00, 0xff, 0x2f, 0x00]]),
"format 3",
),
("none.mid", &smf(0, 96, &[]), "no tracks"),
("track.mid", &short_track, "cut short"),
("status.mid", &smf(0, 96, &[&[0x00, 0xf4]]), "undefined"),
("running.mid", &smf(0, 96, &[&[0x00, 60, 100]]), "no status"),
],
);
let dir = tempfile::tempdir().unwrap();
let paths: Vec<PathBuf> = ["a.mid", "b.mid"]
.iter()
.map(|name| {
let path = dir.path().join(name);
std::fs::write(&path, b"not midi").unwrap();
path
})
.collect();
let err = read_midi(&paths).err().unwrap().to_string();
assert!(err.contains("; \"a.mid\": Not a MIDI file"), "{err}");
}
fn table(bytes: &[u8]) -> (DataFrame, MidiSummary) {
let smf = parse(bytes).unwrap();
let (lf, summary) = build(&[("a.mid".to_string(), smf)]).unwrap();
(lf.collect().unwrap(), summary)
}
fn col<'a>(df: &'a DataFrame, name: &str) -> &'a Series {
df.column(name).unwrap().as_materialized_series()
}
#[test]
fn vlq_reads_the_specification_examples() {
for (bytes, value) in [
(&[0x00][..], 0u32),
(&[0x40], 0x40),
(&[0x7f], 0x7f),
(&[0x81, 0x00], 0x80),
(&[0xc0, 0x00], 0x2000),
(&[0xff, 0x7f], 0x3fff),
(&[0x81, 0x80, 0x00], 0x4000),
(&[0xff, 0xff, 0x7f], 0x1f_ffff),
(&[0x81, 0x80, 0x80, 0x00], 0x20_0000),
(&[0xff, 0xff, 0xff, 0x7f], 0x0fff_ffff),
] {
assert_eq!(Bytes::new(bytes).vlq().ok(), Some(value), "{bytes:02x?}");
assert_eq!(vlq(value), bytes);
}
assert!(matches!(
Bytes::new(&[0x80, 0x80, 0x80, 0x80, 0x00]).vlq(),
Err(VlqError::TooLong)
));
assert!(matches!(Bytes::new(&[0x81]).vlq(), Err(VlqError::CutShort)));
}
#[test]
fn running_status_repeats_the_last_channel_status() {
let track = [
0x00, 0x90, 60, 100, 0x00, 64, 90, 0x00, 67, 80, 0x60, 60, 0, 0x00, 0xff, 0x2f, 0x00,
];
let (df, summary) = table(&smf(0, 96, &[&track]));
let kinds: Vec<_> = col(&df, "kind").str().unwrap().iter().flatten().collect();
assert_eq!(
kinds,
["note_on", "note_on", "note_on", "note_off", "end_of_track"]
);
let names: Vec<_> = col(&df, "note_name").str().unwrap().iter().collect();
assert_eq!(names[..4], [Some("C4"), Some("E4"), Some("G4"), Some("C4")]);
assert_eq!(col(&df, "channel").u8().unwrap().get(0), Some(1));
for (name, dtype) in [
("track", DataType::UInt16),
("channel", DataType::UInt8),
("note", DataType::UInt8),
("velocity", DataType::UInt8),
("controller", DataType::UInt8),
("value", DataType::Int32),
] {
assert_eq!(df.column(name).unwrap().dtype(), &dtype, "{name}");
}
assert_eq!(summary.notes, 3);
assert_eq!(summary.unended, 2, "E4 and G4 never end");
assert_eq!(col(&df, "length").f64().unwrap().get(0), Some(0.5));
assert_eq!(col(&df, "length").null_count(), 4);
}
#[test]
fn a_data_byte_with_no_status_is_an_error() {
let err = parse(&smf(0, 96, &[&[0x00, 60, 100]])).unwrap_err();
assert!(err.to_string().contains("no status"), "{err}");
let track = [0x00, 0x90, 60, 100, 0x00, 0xff, 0x01, 0x00, 0x00, 60, 0];
assert!(parse(&smf(0, 96, &[&track])).is_err());
for sysex in [0xf0, 0xf7] {
let track = [0x00, 0x90, 60, 100, 0x00, sysex, 0x01, 0xf7, 0x00, 60, 0];
assert!(parse(&smf(0, 96, &[&track])).is_err(), "{sysex:02X}");
}
let track = [0x00, 0x90, 60, 100, 0x00, 0xf3, 0x02, 0x00, 60, 0];
assert!(parse(&smf(0, 96, &[&track])).is_err());
let err = parse(&smf(0, 96, &[&[0x00, 0xf4]])).unwrap_err();
assert!(err.to_string().contains("undefined"), "{err}");
}
#[test]
fn real_time_bytes_keep_running_status() {
let track = [
0x00, 0x90, 60, 100, 0x00, 0xf8, 0x00, 60, 0, 0x00, 0xf2, 0x10, 0x01, 0x00, 0xff, 0x2f,
0x00,
];
let (df, _) = table(&smf(0, 96, &[&track]));
let kinds: Vec<_> = col(&df, "kind").str().unwrap().iter().flatten().collect();
assert_eq!(
kinds,
[
"note_on",
"clock",
"note_off",
"song_position",
"end_of_track"
]
);
assert_eq!(col(&df, "value").i32().unwrap().get(3), Some(0x90));
}
#[test]
fn lengths_are_checked_before_use() {
let track = [0x00, 0xff, 0x01, 0xff, 0xff, 0xff, 0x7f, b'a'];
assert!(parse(&smf(0, 96, &[&track])).is_err());
let mut bytes = smf(0, 96, &[&[0x00, 0xff, 0x2f, 0x00]]);
bytes[18..22].copy_from_slice(&u32::MAX.to_be_bytes());
let err = parse(&bytes).unwrap_err().to_string();
assert!(err.contains("cut short"), "{err}");
let mut bytes = smf(1, 96, &[&[0x00, 0xff, 0x2f, 0x00]]);
bytes[10..12].copy_from_slice(&u16::MAX.to_be_bytes());
let err = parse(&bytes).unwrap_err().to_string();
assert!(err.contains("65535 tracks"), "{err}");
let track = [0x80, 0x80, 0x80, 0x80, 0x00, 0xff, 0x2f, 0x00];
assert!(parse(&smf(0, 96, &[&track])).is_err());
assert!(parse(&smf(0, 96, &[&[0x00, 0x90, 60]])).is_err());
assert!(parse(b"MThd").is_err());
assert!(parse(&smf(3, 96, &[&[]])).is_err());
assert!(parse(&smf(0, 0, &[&[]])).is_err());
assert!(parse(&smf(0, 96, &[])).is_err());
}
#[test]
fn the_tempo_map_turns_ticks_into_time() {
let mut tempo = vec![0x00, 0xff, 0x51, 0x03, 0x07, 0xa1, 0x20];
tempo.extend(vlq(480));
tempo.extend([0xff, 0x51, 0x03, 0x0f, 0x42, 0x40, 0x00, 0xff, 0x2f, 0x00]);
let mut notes = vec![0x00, 0x90, 60, 100];
notes.extend(vlq(480));
notes.extend([62, 100]);
notes.extend(vlq(480));
notes.extend([64, 100]);
let (df, summary) = table(&smf(1, 480, &[&tempo, ¬es]));
let times: Vec<_> = col(&df, "seconds")
.f64()
.unwrap()
.into_no_null_iter()
.collect();
assert_eq!(times, [0.0, 0.5, 0.5, 0.0, 0.5, 1.5]);
assert_eq!(summary.tempo, Some((500_000, 500_000, 1_000_000)));
assert_eq!(summary.length_seconds, 1.5);
let text = col(&df, "text").str().unwrap();
assert_eq!(text.get(0), Some("120 bpm"));
assert_eq!(text.get(1), Some("60 bpm"));
assert_eq!(summary.tracks.len(), 2);
assert_eq!(summary.tracks[1].channels, [1]);
}
#[test]
fn smpte_time_ignores_tempo() {
let division = (((-25i8) as u8 as u16) << 8) | 40;
let mut track = vec![0x00, 0xff, 0x51, 0x03, 0x0f, 0x42, 0x40];
track.extend(vlq(1500));
track.extend([0x90, 60, 1]);
let (df, summary) = table(&smf(0, division, &[&track]));
assert_eq!(col(&df, "seconds").f64().unwrap().get(1), Some(1.5));
assert_eq!(
summary.division.map(Division::label).as_deref(),
Some("25 fps, 40 ticks per frame")
);
}
#[test]
fn format_2_tracks_keep_their_own_tempo() {
let mut slow = vec![0x00, 0xff, 0x51, 0x03, 0x0f, 0x42, 0x40];
slow.extend(vlq(96));
slow.extend([0x90, 60, 1]);
let mut plain = vlq(96);
plain.extend([0x90, 60, 1]);
let (df, _) = table(&smf(2, 96, &[&slow, &plain]));
let times: Vec<_> = col(&df, "seconds")
.f64()
.unwrap()
.into_no_null_iter()
.collect();
assert_eq!(times, [0.0, 1.0, 0.5]);
}
#[test]
fn meta_and_channel_events_fill_their_columns() {
let mut t = vec![];
t.extend([0x00, 0xff, 0x03, 0x05]);
t.extend(b"Piano");
t.extend([0x00, 0xff, 0x58, 0x04, 6, 3, 24, 8]);
t.extend([0x00, 0xff, 0x59, 0x02, 0xfd, 0x01]); t.extend([0x00, 0xff, 0x05, 0x02, 0xe9, b'a']); t.extend([0x00, 0xb3, 64, 127]); t.extend([0x00, 0xc3, 5]);
t.extend([0x00, 0xe3, 0x00, 0x00]); t.extend([0x00, 0xf0, 0x03, 0x7e, 0x7f, 0xf7]);
let (df, summary) = table(&smf(0, 96, &[&t]));
let kind = col(&df, "kind").str().unwrap();
let text = col(&df, "text").str().unwrap();
let value = col(&df, "value").i32().unwrap();
assert_eq!(kind.get(1), Some("time_signature"));
assert_eq!(text.get(1), Some("6/8"));
assert_eq!(text.get(2), Some("C minor"));
assert_eq!(value.get(2), Some(-3));
assert_eq!(text.get(3), Some("éa"));
assert_eq!(kind.get(4), Some("cc"));
assert_eq!(col(&df, "controller").u8().unwrap().get(4), Some(64));
assert_eq!(value.get(4), Some(127));
assert_eq!(col(&df, "channel").u8().unwrap().get(4), Some(4));
assert_eq!(value.get(5), Some(5));
assert_eq!(value.get(6), Some(-8192));
assert_eq!(kind.get(7), Some("sysex"));
assert_eq!(text.get(7), Some("F0 7E 7F F7"));
assert_eq!(summary.tracks[0].name.as_deref(), Some("Piano"));
assert_eq!(summary.time_signature.as_deref(), Some("6/8"));
assert_eq!(summary.key.as_deref(), Some("C minor"));
}
#[test]
fn note_names_put_middle_c_in_octave_4() {
assert_eq!(note_name(60), "C4");
assert_eq!(note_name(0), "C-1");
assert_eq!(note_name(69), "A4");
assert_eq!(note_name(127), "G9");
assert_eq!(key_name(0, false).as_deref(), Some("C major"));
assert_eq!(key_name(7, false).as_deref(), Some("C# major"));
assert_eq!(key_name(-7, true).as_deref(), Some("Ab minor"));
assert_eq!(key_name(8, false), None);
assert_eq!(bpm(500_000), "120");
assert_eq!(bpm(650_000), "92.31");
}
#[test]
fn midi_is_known_by_its_first_bytes_and_unwrapped_from_riff() {
let inner = smf(0, 96, &[&[0x00, 0x90, 60, 100]]);
assert!(looks_like_midi(&inner));
let mut riff = b"RIFF".to_vec();
riff.extend(((inner.len() + 12) as u32).to_le_bytes());
riff.extend(b"RMIDdata");
riff.extend((inner.len() as u32).to_le_bytes());
riff.extend(&inner);
assert!(looks_like_midi(&riff));
assert_eq!(parse(&riff).unwrap().tracks[0].len(), 1);
assert!(!looks_like_midi(b"RIFF\0\0\0\0WAVEfmt "));
assert!(!looks_like_midi(b"MThd\0\0\0\x07"));
}
#[test]
fn a_directory_leaves_out_what_it_cannot_read() {
let dir = tempfile::tempdir().unwrap();
let good = dir.path().join("good.mid");
std::fs::write(&good, smf(0, 96, &[&[0x00, 0x90, 60, 100]])).unwrap();
let huge = dir.path().join("huge.mid");
std::fs::File::create(&huge)
.unwrap()
.set_len(MAX_FILE_BYTES + 1)
.unwrap();
let err = read_midi(std::slice::from_ref(&huge))
.err()
.expect("a file too large is refused")
.to_string();
assert!(err.contains("up to 64.0 MiB"), "{err}");
let (lf, summary) = read_midi(&[huge, good]).unwrap();
let df = lf.collect().unwrap();
assert_eq!(df.height(), 1);
assert_eq!(col(&df, "file").str().unwrap().get(0), Some("good.mid"));
assert_eq!(summary.unreadable.len(), 1);
assert_eq!(summary.unreadable[0].0, "huge.mid");
}
#[test]
fn unknown_chunks_are_skipped_and_trailing_bytes_ignored() {
let mut bytes = smf(0, 96, &[]);
bytes[11] = 1;
bytes.extend(b"XFIH\0\0\0\x02ab");
bytes.extend(b"MTrk\0\0\0\x04\x00\xff\x2f\x00");
bytes.extend(b"junk");
assert_eq!(parse(&bytes).unwrap().tracks.len(), 1);
}
}