use crate::engine::audio::events::AudioEvent;
use crate::language::syntax::ast::Value;
use anyhow::{Result, anyhow};
use std::path::Path;
#[cfg(any(feature = "cli", feature = "wasm"))]
use midly::{
Format, Header, MetaMessage, MidiMessage, Smf, Timing, Track, TrackEvent, TrackEventKind,
};
#[cfg(all(target_arch = "wasm32", not(any(feature = "cli", feature = "wasm"))))]
use crate::midly::{
Format, Header, MetaMessage, MidiMessage, Smf, Timing, Track, TrackEvent, TrackEventKind,
};
#[cfg(feature = "cli")]
use std::collections::HashMap;
#[cfg(feature = "cli")]
pub fn load_midi_file(path: &Path) -> Result<Value> {
let bytes = std::fs::read(path)
.map_err(|e| anyhow!("Failed to read MIDI file {}: {}", path.display(), e))?;
let smf = Smf::parse(&bytes)
.map_err(|e| anyhow!("Failed to parse MIDI file {}: {}", path.display(), e))?;
let mut midi_map = HashMap::new();
let mut bpm = 120.0f32;
let mut tempo_us_per_quarter: u32 = 500_000; let mut notes: Vec<Value> = Vec::new();
let ticks_per_beat: u32 = match smf.header.timing {
Timing::Metrical(t) => t.as_int() as u32,
_ => 480u32,
};
let mut active: std::collections::HashMap<(usize, u8, u8), Vec<usize>> =
std::collections::HashMap::new();
for (track_idx, track) in smf.tracks.iter().enumerate() {
let mut current_ticks: u32 = 0;
for event in track {
current_ticks = current_ticks.wrapping_add(event.delta.as_int() as u32);
match event.kind {
TrackEventKind::Midi { channel, message } => {
let chan = channel.as_int();
match message {
MidiMessage::NoteOn { key, vel } => {
if vel.as_int() > 0 {
let time_ms = (current_ticks as f32)
* (tempo_us_per_quarter as f32)
/ (ticks_per_beat as f32)
/ 1000.0;
let mut note_map = HashMap::new();
note_map.insert(
"tick".to_string(),
Value::Number(current_ticks as f32),
);
note_map.insert("time".to_string(), Value::Number(time_ms));
let beats = time_ms * (bpm as f32) / 60000.0; note_map.insert("beat".to_string(), Value::Number(beats));
note_map
.insert("note".to_string(), Value::Number(key.as_int() as f32));
note_map.insert(
"velocity".to_string(),
Value::Number(vel.as_int() as f32),
);
note_map
.insert("track".to_string(), Value::Number(track_idx as f32));
note_map.insert("channel".to_string(), Value::Number(chan as f32));
notes.push(Value::Map(note_map));
let idx = notes.len() - 1;
active
.entry((track_idx, chan as u8, key.as_int() as u8))
.or_default()
.push(idx);
}
}
MidiMessage::NoteOff { key, .. } => {
let key_tuple = (track_idx, channel.as_int() as u8, key.as_int() as u8);
if let Some(vec_idxs) = active.get_mut(&key_tuple) {
if let Some(on_idx) = vec_idxs.pop() {
if let Some(Value::Map(on_map)) = notes.get_mut(on_idx) {
if let Some(Value::Number(on_tick)) = on_map.get("tick") {
let onset_ticks = *on_tick as u32;
let dur_ticks =
current_ticks.saturating_sub(onset_ticks);
let duration_ms = (dur_ticks as f32)
* (tempo_us_per_quarter as f32)
/ (ticks_per_beat as f32)
/ 1000.0;
on_map.insert(
"duration".to_string(),
Value::Number(duration_ms),
);
let duration_beats =
duration_ms * (bpm as f32) / 60000.0;
on_map.insert(
"duration_beats".to_string(),
Value::Number(duration_beats),
);
}
}
}
}
}
_ => {}
}
}
TrackEventKind::Meta(meta) => {
if let MetaMessage::Tempo(t) = meta {
tempo_us_per_quarter = t.as_int();
bpm = 60_000_000.0f32 / tempo_us_per_quarter as f32;
}
}
_ => {}
}
}
}
for (_key, vec_idxs) in active.iter() {
for &idx in vec_idxs.iter() {
if let Some(Value::Map(m)) = notes.get_mut(idx) {
if !m.contains_key("duration") {
m.insert("duration".to_string(), Value::Number(500.0));
let default_beats = 500.0 * (bpm as f32) / 60000.0;
m.insert("duration_beats".to_string(), Value::Number(default_beats));
}
}
}
}
midi_map.insert("bpm".to_string(), Value::Number(bpm));
midi_map.insert(
"ticks_per_beat".to_string(),
Value::Number(ticks_per_beat as f32),
);
midi_map.insert("notes".to_string(), Value::Array(notes));
midi_map.insert("type".to_string(), Value::String("midi".to_string()));
Ok(Value::Map(midi_map))
}
#[cfg(not(feature = "cli"))]
pub fn load_midi_file(_path: &Path) -> Result<Value> {
Err(anyhow!("MIDI loading not available without 'cli' feature"))
}
pub fn events_to_midi_bytes(events: &[AudioEvent], bpm: f32) -> Result<Vec<u8>> {
if events.is_empty() {
return Err(anyhow!("No events to export"));
}
let bpm = validate_bpm(bpm)?;
let ticks_per_beat = 480; let header = Header::new(Format::SingleTrack, Timing::Metrical(ticks_per_beat.into()));
let mut track_events = Vec::new();
let tempo_us_per_quarter = (60_000_000.0 / bpm) as u32;
track_events.push(TrackEvent {
delta: 0.into(),
kind: TrackEventKind::Meta(MetaMessage::Tempo(tempo_us_per_quarter.into())),
});
let midi_notes = collect_midi_notes(events);
let mut midi_messages: Vec<MidiEventTimed> = Vec::new();
for note in &midi_notes {
let start_ticks = time_to_ticks(note.start, bpm, ticks_per_beat);
let end_ticks = time_to_ticks(note.start + note.duration, bpm, ticks_per_beat);
midi_messages.push(MidiEventTimed {
ticks: start_ticks,
message: MidiMessage::NoteOn {
key: note.note.into(),
vel: ((note.velocity * 127.0) as u8).into(),
},
});
midi_messages.push(MidiEventTimed {
ticks: end_ticks,
message: MidiMessage::NoteOff {
key: note.note.into(),
vel: 0.into(),
},
});
}
midi_messages.sort_by_key(|msg| msg.ticks);
let mut last_ticks = 0u32;
for msg in midi_messages {
let delta = msg.ticks.saturating_sub(last_ticks);
track_events.push(TrackEvent {
delta: delta.into(),
kind: TrackEventKind::Midi {
channel: 0.into(),
message: msg.message,
},
});
last_ticks = msg.ticks;
}
track_events.push(TrackEvent {
delta: 0.into(),
kind: TrackEventKind::Meta(MetaMessage::EndOfTrack),
});
let track = Track::from(track_events);
let mut smf = Smf::new(header);
smf.tracks.push(track);
let mut buffer = Vec::new();
smf.write(&mut buffer)
.map_err(|e| anyhow!("Failed to write MIDI bytes: {}", e))?;
Ok(buffer)
}
#[cfg(feature = "cli")]
pub fn export_midi_file(events: &[AudioEvent], output_path: &Path, bpm: f32) -> Result<()> {
if events.is_empty() {
return Err(anyhow!("No events to export"));
}
let bpm = validate_bpm(bpm)?;
let ticks_per_beat = 480; let header = Header::new(Format::SingleTrack, Timing::Metrical(ticks_per_beat.into()));
let mut track_events = Vec::new();
let tempo_us_per_quarter = (60_000_000.0 / bpm) as u32;
track_events.push(TrackEvent {
delta: 0.into(),
kind: TrackEventKind::Meta(MetaMessage::Tempo(tempo_us_per_quarter.into())),
});
let midi_notes = collect_midi_notes(events);
let mut midi_messages: Vec<MidiEventTimed> = Vec::new();
for note in &midi_notes {
let start_ticks = time_to_ticks(note.start, bpm, ticks_per_beat);
let end_ticks = time_to_ticks(note.start + note.duration, bpm, ticks_per_beat);
midi_messages.push(MidiEventTimed {
ticks: start_ticks,
message: MidiMessage::NoteOn {
key: note.note.into(),
vel: ((note.velocity * 127.0) as u8).into(),
},
});
midi_messages.push(MidiEventTimed {
ticks: end_ticks,
message: MidiMessage::NoteOff {
key: note.note.into(),
vel: 0.into(),
},
});
}
midi_messages.sort_by_key(|msg| msg.ticks);
let mut last_ticks = 0u32;
for msg in midi_messages {
let delta = msg.ticks.saturating_sub(last_ticks);
track_events.push(TrackEvent {
delta: delta.into(),
kind: TrackEventKind::Midi {
channel: 0.into(),
message: msg.message,
},
});
last_ticks = msg.ticks;
}
track_events.push(TrackEvent {
delta: 0.into(),
kind: TrackEventKind::Meta(MetaMessage::EndOfTrack),
});
let track = Track::from(track_events);
let mut smf = Smf::new(header);
smf.tracks.push(track);
smf.save(output_path)
.map_err(|e| anyhow!("Failed to write MIDI file {}: {}", output_path.display(), e))?;
println!(
"✅ MIDI exported: {} ({} events in, {} notes written)",
output_path.display(),
events.len(),
midi_notes.len()
);
Ok(())
}
#[cfg(not(feature = "cli"))]
pub fn export_midi_file(_events: &[AudioEvent], _output_path: &Path, _bpm: f32) -> Result<()> {
Err(anyhow!("MIDI export not available without 'cli' feature"))
}
#[derive(Debug, Clone)]
struct MidiNote {
note: u8,
start: f32,
duration: f32,
velocity: f32,
}
#[derive(Debug, Clone)]
struct MidiEventTimed {
ticks: u32,
message: MidiMessage,
}
fn validate_bpm(bpm: f32) -> Result<f32> {
if bpm.is_finite() && bpm > 0.0 {
Ok(bpm)
} else {
Err(anyhow!("Invalid BPM for MIDI export: {}", bpm))
}
}
fn collect_midi_notes(events: &[AudioEvent]) -> Vec<MidiNote> {
let mut midi_notes = Vec::new();
for event in events {
match event {
AudioEvent::Note {
midi,
start_time,
duration,
velocity,
..
} => push_midi_note(&mut midi_notes, *midi, *start_time, *duration, *velocity),
AudioEvent::Chord {
midis,
start_time,
duration,
velocity,
..
} => {
for ¬e in midis {
push_midi_note(&mut midi_notes, note, *start_time, *duration, *velocity);
}
}
AudioEvent::Sample { .. } | AudioEvent::Stop { .. } => {}
}
}
midi_notes.sort_by(|a, b| a.start.total_cmp(&b.start));
midi_notes
}
fn push_midi_note(notes: &mut Vec<MidiNote>, note: u8, start: f32, duration: f32, velocity: f32) {
if !start.is_finite() || !duration.is_finite() || duration <= 0.0 {
return;
}
notes.push(MidiNote {
note,
start: start.max(0.0),
duration,
velocity: velocity.clamp(0.0, 1.0),
});
}
fn time_to_ticks(time_seconds: f32, bpm: f32, ticks_per_beat: u16) -> u32 {
let beats = time_seconds * (bpm / 60.0);
(beats * ticks_per_beat as f32) as u32
}
#[cfg(test)]
mod tests {
use super::*;
fn note_event(start_time: f32, duration: f32, velocity: f32) -> AudioEvent {
AudioEvent::Note {
node_id: Some("master".to_string()),
midi: 60,
start_time,
duration,
velocity,
synth_id: "test".to_string(),
synth_def: Default::default(),
pan: 0.0,
detune: 0.0,
gain: 1.0,
attack: None,
release: None,
delay_time: None,
delay_feedback: None,
delay_mix: None,
reverb_amount: None,
drive_amount: None,
drive_color: None,
effects: None,
use_per_note_automation: false,
}
}
#[test]
fn test_midi_export_rejects_invalid_bpm() {
let events = vec![note_event(0.0, 0.5, 1.0)];
assert!(events_to_midi_bytes(&events, 0.0).is_err());
assert!(events_to_midi_bytes(&events, f32::NAN).is_err());
}
#[test]
fn test_collect_midi_notes_filters_invalid_timings_and_clamps_values() {
let events = vec![
note_event(f32::NAN, 0.5, 1.0),
note_event(0.0, -0.5, 1.0),
note_event(-1.0, 0.5, 2.0),
];
let notes = collect_midi_notes(&events);
assert_eq!(notes.len(), 1);
assert_eq!(notes[0].start, 0.0);
assert_eq!(notes[0].velocity, 1.0);
}
}