use std::io;
use std::path::Path;
#[derive(Debug)]
pub enum MidiError {
Io(io::Error),
Empty,
}
impl std::fmt::Display for MidiError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
MidiError::Io(e) => write!(f, "MIDI I/O error: {e}"),
MidiError::Empty => write!(f, "No MIDI notes to write"),
}
}
}
impl From<io::Error> for MidiError {
fn from(e: io::Error) -> Self {
MidiError::Io(e)
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct MidiNote {
pub pitch: u8,
pub velocity: u8,
pub duration_ticks: u32,
pub start_tick: u32,
}
pub fn encode_vlq(mut value: u32, buf: &mut Vec<u8>) {
if value == 0 {
buf.push(0x00);
return;
}
let mut bytes = [0u8; 5];
let mut len = 0;
while value > 0 {
bytes[len] = (value & 0x7F) as u8;
value >>= 7;
len += 1;
}
for i in (1..len).rev() {
buf.push(bytes[i] | 0x80);
}
buf.push(bytes[0]); }
pub fn decode_vlq(data: &[u8], offset: usize) -> Option<(u32, usize)> {
let mut value: u32 = 0;
let mut consumed = 0;
loop {
if offset + consumed >= data.len() {
return None;
}
let byte = data[offset + consumed];
consumed += 1;
value = (value << 7) | u32::from(byte & 0x7F);
if byte & 0x80 == 0 {
break;
}
if consumed >= 5 {
return None; }
}
Some((value, consumed))
}
const TICKS_PER_QUARTER: u16 = 480;
fn push_u32_be(buf: &mut Vec<u8>, v: u32) {
buf.extend_from_slice(&v.to_be_bytes());
}
fn push_u16_be(buf: &mut Vec<u8>, v: u16) {
buf.extend_from_slice(&v.to_be_bytes());
}
fn build_smf(notes: &[MidiNote], ticks_per_quarter: u16, tempo_us: u32) -> Vec<u8> {
#[derive(PartialOrd, Ord, PartialEq, Eq)]
struct RawEvent {
abs_tick: u32,
kind_order: u8,
pitch: u8,
velocity: u8,
}
let mut events: Vec<RawEvent> = Vec::with_capacity(notes.len() * 2);
for note in notes {
events.push(RawEvent {
abs_tick: note.start_tick,
kind_order: 0,
pitch: note.pitch,
velocity: note.velocity,
});
events.push(RawEvent {
abs_tick: note.start_tick + note.duration_ticks,
kind_order: 1,
pitch: note.pitch,
velocity: 0,
});
}
events.sort();
let mut track_body: Vec<u8> = Vec::new();
encode_vlq(0, &mut track_body); track_body.push(0xFF); track_body.push(0x51); track_body.push(0x03); track_body.push(((tempo_us >> 16) & 0xFF) as u8);
track_body.push(((tempo_us >> 8) & 0xFF) as u8);
track_body.push((tempo_us & 0xFF) as u8);
let mut prev_tick: u32 = 0;
for ev in &events {
let delta = ev.abs_tick.saturating_sub(prev_tick);
encode_vlq(delta, &mut track_body);
prev_tick = ev.abs_tick;
if ev.kind_order == 0 {
track_body.push(0x90); track_body.push(ev.pitch);
track_body.push(ev.velocity);
} else {
track_body.push(0x80); track_body.push(ev.pitch);
track_body.push(0x00);
}
}
encode_vlq(0, &mut track_body);
track_body.push(0xFF);
track_body.push(0x2F);
track_body.push(0x00);
let mut smf: Vec<u8> = Vec::new();
smf.extend_from_slice(b"MThd");
push_u32_be(&mut smf, 6); push_u16_be(&mut smf, 0); push_u16_be(&mut smf, 1); push_u16_be(&mut smf, ticks_per_quarter);
smf.extend_from_slice(b"MTrk");
push_u32_be(&mut smf, track_body.len() as u32);
smf.extend_from_slice(&track_body);
smf
}
pub struct MidiExporter;
impl MidiExporter {
pub fn from_trajectory(
state_series: &[(f64, f64, f64)],
_sample_rate: f64,
bpm: u32,
) -> Vec<MidiNote> {
if state_series.is_empty() {
return Vec::new();
}
let x_min = state_series.iter().map(|s| s.0).fold(f64::INFINITY, f64::min);
let x_max = state_series.iter().map(|s| s.0).fold(f64::NEG_INFINITY, f64::max);
let y_min = state_series.iter().map(|s| s.1.abs()).fold(f64::INFINITY, f64::min);
let y_max = state_series.iter().map(|s| s.1.abs()).fold(f64::NEG_INFINITY, f64::max);
let x_range = (x_max - x_min).max(1e-10);
let y_range = (y_max - y_min).max(1e-10);
let ticks_per_beat = TICKS_PER_QUARTER as u32;
let _ = bpm;
state_series
.iter()
.enumerate()
.map(|(i, &(x, y, _z))| {
let x_norm = ((x - x_min) / x_range).clamp(0.0, 1.0);
let y_norm = ((y.abs() - y_min) / y_range).clamp(0.0, 1.0);
let pitch = (48.0 + x_norm * 36.0).clamp(48.0, 84.0) as u8;
let velocity = (40.0 + y_norm * 87.0).clamp(40.0, 127.0) as u8;
MidiNote {
pitch,
velocity,
duration_ticks: ticks_per_beat,
start_tick: i as u32 * ticks_per_beat,
}
})
.collect()
}
pub fn write(notes: &[MidiNote], path: &Path) -> Result<(), MidiError> {
if notes.is_empty() {
return Err(MidiError::Empty);
}
let tempo_us = 500_000u32; let bytes = build_smf(notes, TICKS_PER_QUARTER, tempo_us);
std::fs::write(path, &bytes)?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn vlq_zero() {
let mut buf = Vec::new();
encode_vlq(0, &mut buf);
assert_eq!(buf, &[0x00]);
}
#[test]
fn vlq_127() {
let mut buf = Vec::new();
encode_vlq(127, &mut buf);
assert_eq!(buf, &[0x7F]);
}
#[test]
fn vlq_128() {
let mut buf = Vec::new();
encode_vlq(128, &mut buf);
assert_eq!(buf, &[0x81, 0x00]);
}
#[test]
fn vlq_16383() {
let mut buf = Vec::new();
encode_vlq(16383, &mut buf);
assert_eq!(buf, &[0xFF, 0x7F]);
}
#[test]
fn vlq_round_trip() {
for &v in &[0u32, 1, 63, 127, 128, 255, 256, 16383, 16384, 0x0FFFFFFF] {
let mut buf = Vec::new();
encode_vlq(v, &mut buf);
let (decoded, _consumed) = decode_vlq(&buf, 0).expect("decode failed");
assert_eq!(decoded, v, "round-trip failed for {}", v);
}
}
#[test]
fn note_pitch_in_bounds() {
let series: Vec<(f64, f64, f64)> = (-10..=10)
.map(|i| (i as f64, (i as f64).abs(), 0.0))
.collect();
let notes = MidiExporter::from_trajectory(&series, 44100.0, 120);
for note in ¬es {
assert!(note.pitch >= 48 && note.pitch <= 84,
"pitch {} out of range 48..84", note.pitch);
}
}
#[test]
fn note_velocity_in_bounds() {
let series: Vec<(f64, f64, f64)> = (0..20)
.map(|i| (i as f64, (i as f64 - 10.0), 0.0))
.collect();
let notes = MidiExporter::from_trajectory(&series, 44100.0, 120);
for note in ¬es {
assert!(note.velocity >= 40 && note.velocity <= 127,
"velocity {} out of range 40..127", note.velocity);
}
}
#[test]
fn note_count_matches_series() {
let series: Vec<(f64, f64, f64)> = (0..50).map(|i| (i as f64, i as f64, 0.0)).collect();
let notes = MidiExporter::from_trajectory(&series, 44100.0, 120);
assert_eq!(notes.len(), 50);
}
#[test]
fn empty_series_empty_notes() {
let notes = MidiExporter::from_trajectory(&[], 44100.0, 120);
assert!(notes.is_empty());
}
#[test]
fn note_duration_one_quarter() {
let series = vec![(0.0f64, 0.0f64, 0.0f64), (1.0, 1.0, 1.0)];
let notes = MidiExporter::from_trajectory(&series, 44100.0, 120);
for note in ¬es {
assert_eq!(note.duration_ticks, TICKS_PER_QUARTER as u32);
}
}
#[test]
fn notes_start_tick_sequential() {
let series: Vec<(f64, f64, f64)> = (0..5).map(|i| (i as f64, 0.0, 0.0)).collect();
let notes = MidiExporter::from_trajectory(&series, 44100.0, 120);
for (i, note) in notes.iter().enumerate() {
assert_eq!(note.start_tick, i as u32 * TICKS_PER_QUARTER as u32);
}
}
#[test]
fn smf_header_mthd() {
let notes = vec![MidiNote { pitch: 60, velocity: 80, duration_ticks: 480, start_tick: 0 }];
let bytes = build_smf(¬es, 480, 500_000);
assert_eq!(&bytes[0..4], b"MThd");
}
#[test]
fn smf_header_length_six() {
let notes = vec![MidiNote { pitch: 60, velocity: 80, duration_ticks: 480, start_tick: 0 }];
let bytes = build_smf(¬es, 480, 500_000);
let hlen = u32::from_be_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]);
assert_eq!(hlen, 6);
}
#[test]
fn smf_format_zero() {
let notes = vec![MidiNote { pitch: 60, velocity: 80, duration_ticks: 480, start_tick: 0 }];
let bytes = build_smf(¬es, 480, 500_000);
let fmt = u16::from_be_bytes([bytes[8], bytes[9]]);
assert_eq!(fmt, 0);
}
#[test]
fn smf_track_count_one() {
let notes = vec![MidiNote { pitch: 60, velocity: 80, duration_ticks: 480, start_tick: 0 }];
let bytes = build_smf(¬es, 480, 500_000);
let ntracks = u16::from_be_bytes([bytes[10], bytes[11]]);
assert_eq!(ntracks, 1);
}
#[test]
fn smf_track_mtrk() {
let notes = vec![MidiNote { pitch: 60, velocity: 80, duration_ticks: 480, start_tick: 0 }];
let bytes = build_smf(¬es, 480, 500_000);
assert_eq!(&bytes[14..18], b"MTrk");
}
#[test]
fn write_to_file() {
let dir = std::env::temp_dir();
let path = dir.join("test_midi.mid");
let notes = vec![MidiNote { pitch: 60, velocity: 80, duration_ticks: 480, start_tick: 0 }];
MidiExporter::write(¬es, &path).expect("write failed");
assert!(path.exists());
let _ = std::fs::remove_file(&path);
}
#[test]
fn write_empty_notes_error() {
let dir = std::env::temp_dir();
let path = dir.join("test_midi_empty.mid");
let result = MidiExporter::write(&[], &path);
assert!(result.is_err());
}
#[test]
fn vlq_continuation_bit() {
let mut buf = Vec::new();
encode_vlq(300, &mut buf); assert_eq!(buf.len(), 2);
assert!(buf[0] & 0x80 != 0, "first byte should have continuation bit");
assert!(buf[1] & 0x80 == 0, "last byte should not have continuation bit");
}
#[test]
fn trajectory_same_x_constant_pitch() {
let series: Vec<(f64, f64, f64)> = (0..5).map(|i| (5.0_f64, i as f64, 0.0)).collect();
let notes = MidiExporter::from_trajectory(&series, 44100.0, 120);
let first_pitch = notes[0].pitch;
for note in ¬es {
assert_eq!(note.pitch, first_pitch);
}
}
}