Skip to main content

devalang_wasm/engine/audio/
midi.rs

1use crate::engine::audio::events::AudioEvent;
2use crate::language::syntax::ast::Value;
3/// MIDI file loading and parsing
4use anyhow::{Result, anyhow};
5use std::path::Path;
6
7#[cfg(any(feature = "cli", feature = "wasm"))]
8use midly::{
9    Format, Header, MetaMessage, MidiMessage, Smf, Timing, Track, TrackEvent, TrackEventKind,
10};
11
12#[cfg(all(target_arch = "wasm32", not(any(feature = "cli", feature = "wasm"))))]
13use crate::midly::{
14    Format, Header, MetaMessage, MidiMessage, Smf, Timing, Track, TrackEvent, TrackEventKind,
15};
16
17#[cfg(feature = "cli")]
18use std::collections::HashMap;
19
20/// Load a MIDI file and return a Value::Map representing the MIDI data
21#[cfg(feature = "cli")]
22pub fn load_midi_file(path: &Path) -> Result<Value> {
23    let bytes = std::fs::read(path)
24        .map_err(|e| anyhow!("Failed to read MIDI file {}: {}", path.display(), e))?;
25
26    let smf = Smf::parse(&bytes)
27        .map_err(|e| anyhow!("Failed to parse MIDI file {}: {}", path.display(), e))?;
28
29    // Convert MIDI data to a map
30    let mut midi_map = HashMap::new();
31
32    // Defaults
33    let mut bpm = 120.0f32;
34    let mut tempo_us_per_quarter: u32 = 500_000; // default 120 BPM
35    let mut notes: Vec<Value> = Vec::new();
36
37    // Determine ticks per beat from header timing
38    let ticks_per_beat: u32 = match smf.header.timing {
39        Timing::Metrical(t) => t.as_int() as u32,
40        _ => 480u32,
41    };
42
43    // Active note-on map to pair note-offs: key = (track, channel, key) -> Vec of indices in notes
44    let mut active: std::collections::HashMap<(usize, u8, u8), Vec<usize>> =
45        std::collections::HashMap::new();
46
47    // Process all tracks
48    for (track_idx, track) in smf.tracks.iter().enumerate() {
49        let mut current_ticks: u32 = 0;
50
51        for event in track {
52            current_ticks = current_ticks.wrapping_add(event.delta.as_int() as u32);
53
54            match event.kind {
55                TrackEventKind::Midi { channel, message } => {
56                    let chan = channel.as_int();
57                    match message {
58                        MidiMessage::NoteOn { key, vel } => {
59                            if vel.as_int() > 0 {
60                                // compute time in ms from ticks using current tempo
61                                let time_ms = (current_ticks as f32)
62                                    * (tempo_us_per_quarter as f32)
63                                    / (ticks_per_beat as f32)
64                                    / 1000.0;
65
66                                let mut note_map = HashMap::new();
67                                note_map.insert(
68                                    "tick".to_string(),
69                                    Value::Number(current_ticks as f32),
70                                );
71                                note_map.insert("time".to_string(), Value::Number(time_ms));
72                                // store beat position (useful to rescale when interpreter BPM changes)
73                                let beats = time_ms * (bpm as f32) / 60000.0; // time_ms / (60000/midi_bpm)
74                                note_map.insert("beat".to_string(), Value::Number(beats));
75                                note_map
76                                    .insert("note".to_string(), Value::Number(key.as_int() as f32));
77                                note_map.insert(
78                                    "velocity".to_string(),
79                                    Value::Number(vel.as_int() as f32),
80                                );
81                                note_map
82                                    .insert("track".to_string(), Value::Number(track_idx as f32));
83                                note_map.insert("channel".to_string(), Value::Number(chan as f32));
84
85                                notes.push(Value::Map(note_map));
86
87                                // record active index for pairing
88                                let idx = notes.len() - 1;
89                                active
90                                    .entry((track_idx, chan as u8, key.as_int() as u8))
91                                    .or_default()
92                                    .push(idx);
93                            }
94                        }
95                        MidiMessage::NoteOff { key, .. } => {
96                            // pair with most recent active note-on for same track/channel/key
97                            let key_tuple = (track_idx, channel.as_int() as u8, key.as_int() as u8);
98                            if let Some(vec_idxs) = active.get_mut(&key_tuple) {
99                                if let Some(on_idx) = vec_idxs.pop() {
100                                    // compute duration from ticks
101                                    // find onset tick stored in notes[on_idx]
102                                    if let Some(Value::Map(on_map)) = notes.get_mut(on_idx) {
103                                        if let Some(Value::Number(on_tick)) = on_map.get("tick") {
104                                            let onset_ticks = *on_tick as u32;
105                                            let dur_ticks =
106                                                current_ticks.saturating_sub(onset_ticks);
107                                            let duration_ms = (dur_ticks as f32)
108                                                * (tempo_us_per_quarter as f32)
109                                                / (ticks_per_beat as f32)
110                                                / 1000.0;
111                                            on_map.insert(
112                                                "duration".to_string(),
113                                                Value::Number(duration_ms),
114                                            );
115                                            // also store duration in beats for easier rescaling
116                                            let duration_beats =
117                                                duration_ms * (bpm as f32) / 60000.0;
118                                            on_map.insert(
119                                                "duration_beats".to_string(),
120                                                Value::Number(duration_beats),
121                                            );
122                                        }
123                                    }
124                                }
125                            }
126                        }
127                        _ => {}
128                    }
129                }
130                TrackEventKind::Meta(meta) => {
131                    // Extract tempo if present
132                    if let MetaMessage::Tempo(t) = meta {
133                        tempo_us_per_quarter = t.as_int();
134                        bpm = 60_000_000.0f32 / tempo_us_per_quarter as f32;
135                    }
136                }
137                _ => {}
138            }
139        }
140    }
141
142    // For any lingering active notes without note-off, set a default duration (500 ms)
143    for (_key, vec_idxs) in active.iter() {
144        for &idx in vec_idxs.iter() {
145            if let Some(Value::Map(m)) = notes.get_mut(idx) {
146                if !m.contains_key("duration") {
147                    m.insert("duration".to_string(), Value::Number(500.0));
148                    // default duration beats using current bpm
149                    let default_beats = 500.0 * (bpm as f32) / 60000.0;
150                    m.insert("duration_beats".to_string(), Value::Number(default_beats));
151                }
152            }
153        }
154    }
155
156    // Store in map
157    midi_map.insert("bpm".to_string(), Value::Number(bpm));
158    midi_map.insert(
159        "ticks_per_beat".to_string(),
160        Value::Number(ticks_per_beat as f32),
161    );
162    midi_map.insert("notes".to_string(), Value::Array(notes));
163    midi_map.insert("type".to_string(), Value::String("midi".to_string()));
164
165    Ok(Value::Map(midi_map))
166}
167
168#[cfg(not(feature = "cli"))]
169pub fn load_midi_file(_path: &Path) -> Result<Value> {
170    Err(anyhow!("MIDI loading not available without 'cli' feature"))
171}
172
173// ============================================================================
174// MIDI EXPORT
175// ============================================================================
176
177/// Export AudioEvents to MIDI bytes (for WASM)
178pub fn events_to_midi_bytes(events: &[AudioEvent], bpm: f32) -> Result<Vec<u8>> {
179    if events.is_empty() {
180        return Err(anyhow!("No events to export"));
181    }
182    let bpm = validate_bpm(bpm)?;
183
184    // Create MIDI header (single track format)
185    let ticks_per_beat = 480; // Standard MIDI resolution
186    let header = Header::new(Format::SingleTrack, Timing::Metrical(ticks_per_beat.into()));
187
188    // Create track events list
189    let mut track_events = Vec::new();
190
191    // Add tempo meta event at start
192    let tempo_us_per_quarter = (60_000_000.0 / bpm) as u32;
193    track_events.push(TrackEvent {
194        delta: 0.into(),
195        kind: TrackEventKind::Meta(MetaMessage::Tempo(tempo_us_per_quarter.into())),
196    });
197
198    let midi_notes = collect_midi_notes(events);
199
200    // Convert events to MIDI messages with proper delta timing
201    let mut midi_messages: Vec<MidiEventTimed> = Vec::new();
202
203    for note in &midi_notes {
204        let start_ticks = time_to_ticks(note.start, bpm, ticks_per_beat);
205        let end_ticks = time_to_ticks(note.start + note.duration, bpm, ticks_per_beat);
206
207        // Note On
208        midi_messages.push(MidiEventTimed {
209            ticks: start_ticks,
210            message: MidiMessage::NoteOn {
211                key: note.note.into(),
212                vel: ((note.velocity * 127.0) as u8).into(),
213            },
214        });
215
216        // Note Off
217        midi_messages.push(MidiEventTimed {
218            ticks: end_ticks,
219            message: MidiMessage::NoteOff {
220                key: note.note.into(),
221                vel: 0.into(),
222            },
223        });
224    }
225
226    // Sort all messages by time
227    midi_messages.sort_by_key(|msg| msg.ticks);
228
229    // Convert to delta times and create TrackEvents
230    let mut last_ticks = 0u32;
231
232    for msg in midi_messages {
233        let delta = msg.ticks.saturating_sub(last_ticks);
234
235        track_events.push(TrackEvent {
236            delta: delta.into(),
237            kind: TrackEventKind::Midi {
238                channel: 0.into(),
239                message: msg.message,
240            },
241        });
242
243        last_ticks = msg.ticks;
244    }
245
246    // End of track marker
247    track_events.push(TrackEvent {
248        delta: 0.into(),
249        kind: TrackEventKind::Meta(MetaMessage::EndOfTrack),
250    });
251
252    // Create SMF and write to memory buffer
253    let track = Track::from(track_events);
254    let mut smf = Smf::new(header);
255    smf.tracks.push(track);
256
257    // Write to memory buffer
258    let mut buffer = Vec::new();
259    smf.write(&mut buffer)
260        .map_err(|e| anyhow!("Failed to write MIDI bytes: {}", e))?;
261
262    Ok(buffer)
263}
264
265/// Export AudioEvents to a standard MIDI file
266#[cfg(feature = "cli")]
267pub fn export_midi_file(events: &[AudioEvent], output_path: &Path, bpm: f32) -> Result<()> {
268    if events.is_empty() {
269        return Err(anyhow!("No events to export"));
270    }
271    let bpm = validate_bpm(bpm)?;
272
273    // Create MIDI header (single track format)
274    let ticks_per_beat = 480; // Standard MIDI resolution
275    let header = Header::new(Format::SingleTrack, Timing::Metrical(ticks_per_beat.into()));
276
277    // Create track events list
278    let mut track_events = Vec::new();
279
280    // Add tempo meta event at start
281    let tempo_us_per_quarter = (60_000_000.0 / bpm) as u32;
282    track_events.push(TrackEvent {
283        delta: 0.into(),
284        kind: TrackEventKind::Meta(MetaMessage::Tempo(tempo_us_per_quarter.into())),
285    });
286
287    let midi_notes = collect_midi_notes(events);
288
289    // Convert events to MIDI messages with proper delta timing
290    let mut midi_messages: Vec<MidiEventTimed> = Vec::new();
291
292    for note in &midi_notes {
293        let start_ticks = time_to_ticks(note.start, bpm, ticks_per_beat);
294        let end_ticks = time_to_ticks(note.start + note.duration, bpm, ticks_per_beat);
295
296        // Note On
297        midi_messages.push(MidiEventTimed {
298            ticks: start_ticks,
299            message: MidiMessage::NoteOn {
300                key: note.note.into(),
301                vel: ((note.velocity * 127.0) as u8).into(),
302            },
303        });
304
305        // Note Off
306        midi_messages.push(MidiEventTimed {
307            ticks: end_ticks,
308            message: MidiMessage::NoteOff {
309                key: note.note.into(),
310                vel: 0.into(),
311            },
312        });
313    }
314
315    // Sort all messages by time
316    midi_messages.sort_by_key(|msg| msg.ticks);
317
318    // Convert to delta times and create TrackEvents
319    let mut last_ticks = 0u32;
320
321    for msg in midi_messages {
322        let delta = msg.ticks.saturating_sub(last_ticks);
323
324        track_events.push(TrackEvent {
325            delta: delta.into(),
326            kind: TrackEventKind::Midi {
327                channel: 0.into(),
328                message: msg.message,
329            },
330        });
331
332        last_ticks = msg.ticks;
333    }
334
335    // End of track marker
336    track_events.push(TrackEvent {
337        delta: 0.into(),
338        kind: TrackEventKind::Meta(MetaMessage::EndOfTrack),
339    });
340
341    // Create SMF and write to file
342    let track = Track::from(track_events);
343    let mut smf = Smf::new(header);
344    smf.tracks.push(track);
345
346    // Write to file directly (midly 0.5 API)
347    smf.save(output_path)
348        .map_err(|e| anyhow!("Failed to write MIDI file {}: {}", output_path.display(), e))?;
349
350    println!(
351        "✅ MIDI exported: {} ({} events in, {} notes written)",
352        output_path.display(),
353        events.len(),
354        midi_notes.len()
355    );
356    Ok(())
357}
358
359#[cfg(not(feature = "cli"))]
360pub fn export_midi_file(_events: &[AudioEvent], _output_path: &Path, _bpm: f32) -> Result<()> {
361    Err(anyhow!("MIDI export not available without 'cli' feature"))
362}
363
364// Helper structures for MIDI export
365#[derive(Debug, Clone)]
366struct MidiNote {
367    note: u8,
368    start: f32,
369    duration: f32,
370    velocity: f32,
371}
372
373#[derive(Debug, Clone)]
374struct MidiEventTimed {
375    ticks: u32,
376    message: MidiMessage,
377}
378
379fn validate_bpm(bpm: f32) -> Result<f32> {
380    if bpm.is_finite() && bpm > 0.0 {
381        Ok(bpm)
382    } else {
383        Err(anyhow!("Invalid BPM for MIDI export: {}", bpm))
384    }
385}
386
387fn collect_midi_notes(events: &[AudioEvent]) -> Vec<MidiNote> {
388    let mut midi_notes = Vec::new();
389
390    for event in events {
391        match event {
392            AudioEvent::Note {
393                midi,
394                start_time,
395                duration,
396                velocity,
397                ..
398            } => push_midi_note(&mut midi_notes, *midi, *start_time, *duration, *velocity),
399            AudioEvent::Chord {
400                midis,
401                start_time,
402                duration,
403                velocity,
404                ..
405            } => {
406                for &note in midis {
407                    push_midi_note(&mut midi_notes, note, *start_time, *duration, *velocity);
408                }
409            }
410            AudioEvent::Sample { .. } | AudioEvent::Stop { .. } => {}
411        }
412    }
413
414    midi_notes.sort_by(|a, b| a.start.total_cmp(&b.start));
415    midi_notes
416}
417
418fn push_midi_note(notes: &mut Vec<MidiNote>, note: u8, start: f32, duration: f32, velocity: f32) {
419    if !start.is_finite() || !duration.is_finite() || duration <= 0.0 {
420        return;
421    }
422
423    notes.push(MidiNote {
424        note,
425        start: start.max(0.0),
426        duration,
427        velocity: velocity.clamp(0.0, 1.0),
428    });
429}
430
431/// Convert time in seconds to MIDI ticks
432fn time_to_ticks(time_seconds: f32, bpm: f32, ticks_per_beat: u16) -> u32 {
433    let beats = time_seconds * (bpm / 60.0);
434    (beats * ticks_per_beat as f32) as u32
435}
436
437#[cfg(test)]
438mod tests {
439    use super::*;
440
441    fn note_event(start_time: f32, duration: f32, velocity: f32) -> AudioEvent {
442        AudioEvent::Note {
443            node_id: Some("master".to_string()),
444            midi: 60,
445            start_time,
446            duration,
447            velocity,
448            synth_id: "test".to_string(),
449            synth_def: Default::default(),
450            pan: 0.0,
451            detune: 0.0,
452            gain: 1.0,
453            attack: None,
454            release: None,
455            delay_time: None,
456            delay_feedback: None,
457            delay_mix: None,
458            reverb_amount: None,
459            drive_amount: None,
460            drive_color: None,
461            effects: None,
462            use_per_note_automation: false,
463        }
464    }
465
466    #[test]
467    fn test_midi_export_rejects_invalid_bpm() {
468        let events = vec![note_event(0.0, 0.5, 1.0)];
469
470        assert!(events_to_midi_bytes(&events, 0.0).is_err());
471        assert!(events_to_midi_bytes(&events, f32::NAN).is_err());
472    }
473
474    #[test]
475    fn test_collect_midi_notes_filters_invalid_timings_and_clamps_values() {
476        let events = vec![
477            note_event(f32::NAN, 0.5, 1.0),
478            note_event(0.0, -0.5, 1.0),
479            note_event(-1.0, 0.5, 2.0),
480        ];
481
482        let notes = collect_midi_notes(&events);
483
484        assert_eq!(notes.len(), 1);
485        assert_eq!(notes[0].start, 0.0);
486        assert_eq!(notes[0].velocity, 1.0);
487    }
488}