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