Skip to main content

phosphor_core/
clip.rs

1//! MIDI clip: a sequence of timestamped MIDI events on a timeline.
2//!
3//! Clips are owned by the audio thread for recording and playback.
4//! The UI receives read-only snapshots via a channel.
5
6/// A single MIDI event within a clip, positioned by tick.
7#[derive(Debug, Clone, Copy)]
8pub struct ClipEvent {
9    /// Absolute tick position within the clip (0 = clip start).
10    pub tick: i64,
11    pub status: u8,
12    pub data1: u8,
13    pub data2: u8,
14}
15
16/// A recorded MIDI clip.
17#[derive(Debug, Clone)]
18pub struct MidiClip {
19    /// Where this clip starts on the timeline (absolute ticks).
20    pub start_tick: i64,
21    /// Length in ticks. Events beyond this are ignored on playback.
22    pub length_ticks: i64,
23    /// Events sorted by tick (relative to start_tick).
24    pub events: Vec<ClipEvent>,
25}
26
27impl MidiClip {
28    pub fn new(start_tick: i64, length_ticks: i64, mut events: Vec<ClipEvent>) -> Self {
29        events.sort_by_key(|e| e.tick);
30        Self { start_tick, length_ticks, events }
31    }
32
33    /// End tick (exclusive).
34    pub fn end_tick(&self) -> i64 {
35        self.start_tick + self.length_ticks
36    }
37
38    /// Get events that fall within a tick range [from, to).
39    /// Returns events with tick offsets relative to `from` for sample-accurate placement.
40    pub fn events_in_range(&self, from_tick: i64, to_tick: i64) -> Vec<(i64, &ClipEvent)> {
41        // Convert to clip-local ticks
42        let local_from = from_tick - self.start_tick;
43        let local_to = to_tick - self.start_tick;
44
45        self.events
46            .iter()
47            .filter(|e| e.tick >= local_from && e.tick < local_to)
48            .map(|e| (e.tick - local_from, e)) // offset relative to from_tick
49            .collect()
50    }
51}
52
53/// Accumulates MIDI events during recording, then commits to a MidiClip.
54pub struct RecordBuffer {
55    start_tick: i64,
56    events: Vec<ClipEvent>,
57    active: bool,
58}
59
60impl Default for RecordBuffer {
61    fn default() -> Self { Self::new() }
62}
63
64impl RecordBuffer {
65    pub fn new() -> Self {
66        Self { start_tick: 0, events: Vec::with_capacity(1024), active: false }
67    }
68
69    /// Begin recording at the given tick position.
70    pub fn start(&mut self, tick: i64) {
71        self.start_tick = tick;
72        self.events.clear();
73        self.active = true;
74    }
75
76    /// Record a MIDI event at the given absolute tick.
77    pub fn record(&mut self, tick: i64, status: u8, data1: u8, data2: u8) {
78        if !self.active { return; }
79        self.events.push(ClipEvent {
80            tick: tick - self.start_tick, // store relative to clip start
81            status,
82            data1,
83            data2,
84        });
85    }
86
87    pub fn is_active(&self) -> bool { self.active }
88    pub fn start_tick(&self) -> i64 { self.start_tick }
89
90    /// Stop recording and return the completed clip.
91    /// Returns None if nothing was recorded.
92    pub fn commit(&mut self, end_tick: i64) -> Option<MidiClip> {
93        self.active = false;
94        if self.events.is_empty() {
95            return None;
96        }
97        let length = (end_tick - self.start_tick).max(1);
98        let clip = MidiClip::new(self.start_tick, length, self.events.drain(..).collect());
99        Some(clip)
100    }
101
102    /// Discard without committing.
103    pub fn discard(&mut self) {
104        self.active = false;
105        self.events.clear();
106    }
107}
108
109/// A read-only snapshot of clip data, sent from audio thread to UI.
110#[derive(Debug, Clone)]
111pub struct ClipSnapshot {
112    pub track_id: usize,
113    pub clip_index: usize,
114    pub start_tick: i64,
115    pub length_ticks: i64,
116    pub event_count: usize,
117    /// Simplified note data for piano roll display.
118    pub notes: Vec<NoteSnapshot>,
119}
120
121/// A note for display in the piano roll.
122#[derive(Debug, Clone, Copy)]
123pub struct NoteSnapshot {
124    pub note: u8,
125    pub velocity: u8,
126    /// Start position as fraction of clip length (0.0..1.0).
127    pub start_frac: f64,
128    /// Duration as fraction of clip length.
129    pub duration_frac: f64,
130}
131
132impl NoteSnapshot {
133    /// Convert edited NoteSnapshots back to ClipEvents for the audio thread.
134    /// Each note produces a note-on and note-off event.
135    pub fn to_clip_events(notes: &[NoteSnapshot], length_ticks: i64) -> Vec<ClipEvent> {
136        let mut events = Vec::with_capacity(notes.len() * 2);
137        for n in notes {
138            let on_tick = (n.start_frac * length_ticks as f64) as i64;
139            let off_tick = ((n.start_frac + n.duration_frac) * length_ticks as f64) as i64;
140            events.push(ClipEvent {
141                tick: on_tick,
142                status: 0x90,
143                data1: n.note,
144                data2: n.velocity,
145            });
146            events.push(ClipEvent {
147                tick: off_tick.min(length_ticks),
148                status: 0x80,
149                data1: n.note,
150                data2: 0,
151            });
152        }
153        events.sort_by_key(|e| e.tick);
154        events
155    }
156}
157
158impl ClipSnapshot {
159    pub fn from_clip(track_id: usize, clip_index: usize, clip: &MidiClip) -> Self {
160        let len = clip.length_ticks as f64;
161        let mut notes = Vec::new();
162
163        // Track note-on times to pair with note-offs
164        let mut pending: Vec<(u8, u8, i64)> = Vec::new(); // (note, velocity, start_tick)
165
166        for event in &clip.events {
167            let status = event.status & 0xF0;
168            match status {
169                0x90 if event.data2 > 0 => {
170                    pending.push((event.data1, event.data2, event.tick));
171                }
172                0x90 | 0x80 => {
173                    // Note off — find matching pending note
174                    if let Some(pos) = pending.iter().position(|(n, _, _)| *n == event.data1) {
175                        let (note, vel, start) = pending.remove(pos);
176                        let dur = (event.tick - start).max(1);
177                        notes.push(NoteSnapshot {
178                            note,
179                            velocity: vel,
180                            start_frac: start as f64 / len,
181                            duration_frac: dur as f64 / len,
182                        });
183                    }
184                }
185                _ => {}
186            }
187        }
188
189        // Close any pending notes at clip end
190        for (note, vel, start) in pending {
191            let dur = (clip.length_ticks - start).max(1);
192            notes.push(NoteSnapshot {
193                note,
194                velocity: vel,
195                start_frac: start as f64 / len,
196                duration_frac: dur as f64 / len,
197            });
198        }
199
200        Self {
201            track_id,
202            clip_index,
203            start_tick: clip.start_tick,
204            length_ticks: clip.length_ticks,
205            event_count: clip.events.len(),
206            notes,
207        }
208    }
209}
210
211#[cfg(test)]
212mod tests {
213    use super::*;
214
215    #[test]
216    fn record_buffer_captures_events() {
217        let mut buf = RecordBuffer::new();
218        buf.start(0);
219        buf.record(100, 0x90, 60, 100); // note on
220        buf.record(200, 0x80, 60, 0);   // note off
221        assert!(buf.is_active());
222
223        let clip = buf.commit(960).unwrap();
224        assert_eq!(clip.events.len(), 2);
225        assert_eq!(clip.start_tick, 0);
226        assert_eq!(clip.length_ticks, 960);
227        assert!(!buf.is_active());
228    }
229
230    #[test]
231    fn record_buffer_empty_returns_none() {
232        let mut buf = RecordBuffer::new();
233        buf.start(0);
234        assert!(buf.commit(960).is_none());
235    }
236
237    #[test]
238    fn record_buffer_stores_relative_ticks() {
239        let mut buf = RecordBuffer::new();
240        buf.start(1000); // recording starts at tick 1000
241        buf.record(1500, 0x90, 60, 100);
242        let clip = buf.commit(2000).unwrap();
243        assert_eq!(clip.events[0].tick, 500); // relative to start
244    }
245
246    #[test]
247    fn clip_events_in_range() {
248        let clip = MidiClip::new(0, 960, vec![
249            ClipEvent { tick: 0,   status: 0x90, data1: 60, data2: 100 },
250            ClipEvent { tick: 240, status: 0x80, data1: 60, data2: 0 },
251            ClipEvent { tick: 480, status: 0x90, data1: 64, data2: 100 },
252            ClipEvent { tick: 720, status: 0x80, data1: 64, data2: 0 },
253        ]);
254
255        // First quarter
256        let events = clip.events_in_range(0, 240);
257        assert_eq!(events.len(), 1);
258        assert_eq!(events[0].1.data1, 60); // note 60
259
260        // Second quarter
261        let events = clip.events_in_range(240, 480);
262        assert_eq!(events.len(), 1);
263        assert_eq!(events[0].1.status, 0x80); // note off
264
265        // Full clip
266        let events = clip.events_in_range(0, 960);
267        assert_eq!(events.len(), 4);
268    }
269
270    #[test]
271    fn clip_events_outside_range_excluded() {
272        let clip = MidiClip::new(1000, 960, vec![
273            ClipEvent { tick: 100, status: 0x90, data1: 60, data2: 100 },
274        ]);
275
276        // Before clip
277        let events = clip.events_in_range(0, 500);
278        assert_eq!(events.len(), 0);
279
280        // During clip (tick 1100 = local tick 100)
281        let events = clip.events_in_range(1000, 1200);
282        assert_eq!(events.len(), 1);
283    }
284
285    #[test]
286    fn clip_snapshot_pairs_notes() {
287        let clip = MidiClip::new(0, 960, vec![
288            ClipEvent { tick: 0,   status: 0x90, data1: 60, data2: 100 },
289            ClipEvent { tick: 240, status: 0x80, data1: 60, data2: 0 },
290            ClipEvent { tick: 480, status: 0x90, data1: 64, data2: 80 },
291            ClipEvent { tick: 720, status: 0x80, data1: 64, data2: 0 },
292        ]);
293
294        let snap = ClipSnapshot::from_clip(0, 0, &clip);
295        assert_eq!(snap.notes.len(), 2);
296        assert_eq!(snap.notes[0].note, 60);
297        assert!((snap.notes[0].start_frac - 0.0).abs() < 0.01);
298        assert!((snap.notes[0].duration_frac - 0.25).abs() < 0.01);
299        assert_eq!(snap.notes[1].note, 64);
300    }
301
302    #[test]
303    fn clip_snapshot_closes_pending_notes() {
304        let clip = MidiClip::new(0, 960, vec![
305            ClipEvent { tick: 0, status: 0x90, data1: 60, data2: 100 },
306            // No note-off — should close at clip end
307        ]);
308
309        let snap = ClipSnapshot::from_clip(0, 0, &clip);
310        assert_eq!(snap.notes.len(), 1);
311        assert!((snap.notes[0].duration_frac - 1.0).abs() < 0.01);
312    }
313
314    #[test]
315    fn discard_clears_buffer() {
316        let mut buf = RecordBuffer::new();
317        buf.start(0);
318        buf.record(100, 0x90, 60, 100);
319        buf.discard();
320        assert!(!buf.is_active());
321        assert!(buf.commit(960).is_none());
322    }
323
324    #[test]
325    fn note_snapshot_to_clip_events_round_trip() {
326        // Record a clip
327        let clip = MidiClip::new(0, 960, vec![
328            ClipEvent { tick: 0,   status: 0x90, data1: 60, data2: 100 },
329            ClipEvent { tick: 240, status: 0x80, data1: 60, data2: 0 },
330            ClipEvent { tick: 480, status: 0x90, data1: 64, data2: 80 },
331            ClipEvent { tick: 720, status: 0x80, data1: 64, data2: 0 },
332        ]);
333
334        // Convert to snapshots (like the UI receives)
335        let snap = ClipSnapshot::from_clip(0, 0, &clip);
336        assert_eq!(snap.notes.len(), 2);
337
338        // Convert back to events (like the UI sends after editing)
339        let events = NoteSnapshot::to_clip_events(&snap.notes, 960);
340        assert_eq!(events.len(), 4); // 2 notes × 2 events each
341
342        // Verify the events are correct
343        let note_ons: Vec<_> = events.iter().filter(|e| e.status == 0x90).collect();
344        let note_offs: Vec<_> = events.iter().filter(|e| e.status == 0x80).collect();
345        assert_eq!(note_ons.len(), 2);
346        assert_eq!(note_offs.len(), 2);
347
348        // First note: tick 0, note 60
349        assert_eq!(note_ons[0].data1, 60);
350        assert_eq!(note_ons[0].tick, 0);
351        // Second note: tick ~480, note 64
352        assert_eq!(note_ons[1].data1, 64);
353        assert!((note_ons[1].tick - 480).abs() <= 1);
354    }
355
356    #[test]
357    fn edited_snapshot_produces_different_events() {
358        let mut notes = vec![
359            NoteSnapshot { note: 60, velocity: 100, start_frac: 0.0, duration_frac: 0.25 },
360        ];
361
362        let original = NoteSnapshot::to_clip_events(&notes, 960);
363        assert_eq!(original[0].tick, 0); // note on at tick 0
364
365        // Edit: move start to 0.5
366        notes[0].start_frac = 0.5;
367        let edited = NoteSnapshot::to_clip_events(&notes, 960);
368        assert_eq!(edited[0].tick, 480); // note on at tick 480 now
369
370        // Edit: make it shorter
371        notes[0].duration_frac = 0.1;
372        let shorter = NoteSnapshot::to_clip_events(&notes, 960);
373        let off_tick = shorter.iter().find(|e| e.status == 0x80).unwrap().tick;
374        assert_eq!(off_tick, 576); // 480 + 96 = 576
375    }
376}