Skip to main content

truce_core/
events.rs

1//! Event types crossing the host → plugin boundary.
2//!
3//! `EventBody` carries MIDI 1.0 and MIDI 2.0 channel-voice messages
4//! in their **wire-native integer** shapes (7-bit `u8`, 14-bit
5//! `u16`, 16-bit `u16`, 32-bit `u32`) so the framework's
6//! representation round-trips exactly with the host's wire format.
7//! Plugin code that wants float values reaches for the helpers in
8//! [`truce_utils::midi`] (`norm_7bit`, `denorm_7bit`,
9//! `norm_pitch_bend`, `denorm_pitch_bend`).
10//!
11//! Every MIDI variant carries a `group: u8` field (0..=15) that
12//! UMP (Universal MIDI Packet) hosts use to address one of 16
13//! groups × 16 channels = 256 logical channels. Format wrappers
14//! that don't expose the group field (legacy MIDI 1.0 byte streams)
15//! emit `0`.
16
17/// A timestamped event within a process block.
18///
19/// `Copy` because every [`EventBody`] variant is POD - lets the
20/// audio path move events without per-event clones.
21#[derive(Clone, Copy, Debug)]
22pub struct Event {
23    /// Sample offset within the block (`0..num_samples`).
24    pub sample_offset: u32,
25    /// MIDI port this event arrived on / goes out on (0-based). Single-
26    /// port plugins - the vast majority - always see `0` and can ignore
27    /// it. A plugin that declares more than one MIDI port (see
28    /// `PluginInfo::midi_input_ports` / `midi_output_ports`) filters
29    /// inbound events by `port` and stamps outbound ones with the port
30    /// they should leave on. Formats without a multi-port MIDI transport
31    /// clamp everything to `0`.
32    pub port: u8,
33    pub body: EventBody,
34}
35
36impl Event {
37    /// Event on the default MIDI port (`0`). The common constructor -
38    /// single-port plugins and every non-MIDI event use this.
39    #[must_use]
40    pub fn new(sample_offset: u32, body: EventBody) -> Self {
41        Self {
42            sample_offset,
43            port: 0,
44            body,
45        }
46    }
47
48    /// Event addressed to / from a specific MIDI port. Only meaningful
49    /// for plugins that declared more than one MIDI port; wrappers on
50    /// single-port formats route it to port `0` regardless.
51    #[must_use]
52    pub fn on_port(sample_offset: u32, port: u8, body: EventBody) -> Self {
53        Self {
54            sample_offset,
55            port,
56            body,
57        }
58    }
59}
60
61#[derive(Clone, Copy, Debug, PartialEq)]
62pub enum EventBody {
63    // -- MIDI 1.0 channel voice (wire-native 7-bit / 14-bit) --
64    /// Note on. MIDI 1.0 quirk: a `NoteOn` with `velocity == 0` is
65    /// a `NoteOff`. Format wrappers normalize that at parse time so
66    /// plugin code can match `NoteOn` without checking velocity.
67    NoteOn {
68        group: u8,
69        channel: u8,
70        note: u8,
71        velocity: u8,
72    },
73    NoteOff {
74        group: u8,
75        channel: u8,
76        note: u8,
77        velocity: u8,
78    },
79    /// Polyphonic key pressure (per-note aftertouch).
80    Aftertouch {
81        group: u8,
82        channel: u8,
83        note: u8,
84        pressure: u8,
85    },
86    ChannelPressure {
87        group: u8,
88        channel: u8,
89        pressure: u8,
90    },
91    ControlChange {
92        group: u8,
93        channel: u8,
94        cc: u8,
95        value: u8,
96    },
97    /// 14-bit pitch bend, raw code `0..=16383`. `8192` is center.
98    /// See `truce_utils::midi::norm_pitch_bend` for the
99    /// asymmetric-range conversion helper.
100    PitchBend {
101        group: u8,
102        channel: u8,
103        value: u16,
104    },
105    ProgramChange {
106        group: u8,
107        channel: u8,
108        program: u8,
109    },
110
111    // -- MIDI 2.0 channel voice (wire-native 16/32-bit) --
112    /// MIDI 2.0 `NoteOn`. `velocity` is `0..=65535`; unlike MIDI 1.0,
113    /// a zero velocity is a genuine zero (`NoteOff` is its own
114    /// dedicated message). `attribute_type` indicates how
115    /// `attribute` should be interpreted: 0 = no attribute, 1 =
116    /// manufacturer-specific, 2 = profile-specific, 3 = Pitch 7.9.
117    NoteOn2 {
118        group: u8,
119        channel: u8,
120        note: u8,
121        velocity: u16,
122        attribute_type: u8,
123        attribute: u16,
124    },
125    NoteOff2 {
126        group: u8,
127        channel: u8,
128        note: u8,
129        velocity: u16,
130        attribute_type: u8,
131        attribute: u16,
132    },
133    /// MIDI 2.0 polyphonic key pressure (`pressure: u32`).
134    PolyPressure2 {
135        group: u8,
136        channel: u8,
137        note: u8,
138        pressure: u32,
139    },
140    /// MIDI 2.0 per-note controller. `registered = true` for
141    /// Registered Per-Note (RPN-like indexed list); `false` for
142    /// Assignable Per-Note (free-form per-controller mapping).
143    PerNoteCC {
144        group: u8,
145        channel: u8,
146        note: u8,
147        cc: u8,
148        value: u32,
149        registered: bool,
150    },
151    /// MIDI 2.0 per-note pitch bend (`value: u32`). `0x8000_0000`
152    /// is center; full-scale is ±48 semitones
153    /// ([`crate::midi::PER_NOTE_TUNING_SEMITONES`]) wherever a
154    /// wrapper maps it onto a semitone-denominated host domain.
155    PerNotePitchBend {
156        group: u8,
157        channel: u8,
158        note: u8,
159        value: u32,
160    },
161    /// MIDI 2.0 per-note management flags. Bit 0 = detach
162    /// per-note controllers from active note; bit 1 = reset
163    /// (set) per-note controllers to default values.
164    PerNoteManagement {
165        group: u8,
166        channel: u8,
167        note: u8,
168        flags: u8,
169    },
170    /// MIDI 2.0 channel-wide control change (32-bit).
171    ControlChange2 {
172        group: u8,
173        channel: u8,
174        cc: u8,
175        value: u32,
176    },
177    /// MIDI 2.0 channel pressure (32-bit aftertouch on the whole
178    /// channel).
179    ChannelPressure2 {
180        group: u8,
181        channel: u8,
182        pressure: u32,
183    },
184    /// MIDI 2.0 channel pitch bend (32-bit). `0x8000_0000` is
185    /// center.
186    PitchBend2 {
187        group: u8,
188        channel: u8,
189        value: u32,
190    },
191    /// MIDI 2.0 program change. Optional bank pair (MSB, LSB);
192    /// MIDI 2.0's "B" flag is encoded as `Some` / `None`. When
193    /// `None`, the host hasn't selected a bank and the program
194    /// applies in the current bank.
195    ProgramChange2 {
196        group: u8,
197        channel: u8,
198        program: u8,
199        bank: Option<(u8, u8)>,
200    },
201    /// MIDI 2.0 Registered Controller (the spec's RPN replacement,
202    /// 32-bit). `bank` and `index` are the two 7-bit identifiers
203    /// the spec reserves for Registered Parameter Numbers.
204    RegisteredController {
205        group: u8,
206        channel: u8,
207        bank: u8,
208        index: u8,
209        value: u32,
210    },
211    /// MIDI 2.0 Assignable Controller (the spec's NRPN
212    /// replacement, 32-bit). `bank` and `index` are
213    /// manufacturer-defined.
214    AssignableController {
215        group: u8,
216        channel: u8,
217        bank: u8,
218        index: u8,
219        value: u32,
220    },
221
222    // -- truce-internal automation --
223    ParamChange {
224        id: u32,
225        value: f64,
226    },
227    /// Parameter modulation offset (CLAP-specific, zero on other
228    /// formats). Effective value is `base + value`. The base value
229    /// is unchanged.
230    ParamMod {
231        id: u32,
232        note_id: i32,
233        value: f64,
234    },
235
236    // -- Transport --
237    Transport(TransportInfo),
238
239    // -- System layer --
240    /// System Exclusive (`SysEx`) message - MIDI 1.0 and MIDI 2.0
241    /// alike. The payload bytes live in [`EventList::sysex_bytes`];
242    /// resolve a body to its slice with
243    /// `event_list.sysex_bytes(&body)` rather than indexing the
244    /// pool directly. The bytes are the inner `SysEx` data
245    /// **without** the leading `0xF0` start byte or trailing `0xF7`
246    /// end byte - format wrappers strip those at the boundary so
247    /// plugin code doesn't have to.
248    ///
249    /// Inlining the bytes in the variant would balloon every event's
250    /// footprint to the worst-case (~64 KiB) - channel-voice events
251    /// are <8 bytes today and we want to keep the per-event memory
252    /// pressure on the audio thread proportional to that. The
253    /// indices-into-a-pool layout pays the price (two-step access)
254    /// for the `SysEx`-handling path only.
255    SysEx {
256        pool_offset: u32,
257        len: u32,
258    },
259}
260
261/// Host-populated transport snapshot. Constructed by every format
262/// wrapper from the host's own transport struct via struct-literal
263/// expressions, so this stays "exhaustive" (no `#[non_exhaustive]`,
264/// which would block cross-crate construction). Adding a new field
265/// is a coordinated workspace-wide change.
266#[derive(Clone, Copy, Debug, Default, PartialEq)]
267pub struct TransportInfo {
268    pub playing: bool,
269    pub recording: bool,
270    pub tempo: f64,
271    pub time_sig_num: u8,
272    pub time_sig_den: u8,
273    pub position_samples: i64,
274    pub position_seconds: f64,
275    pub position_beats: f64,
276    pub bar_start_beats: f64,
277    pub loop_active: bool,
278    pub loop_start_beats: f64,
279    pub loop_end_beats: f64,
280}
281
282impl TransportInfo {
283    /// Synthetic transport for snapshot tests - playing at 120 BPM,
284    /// 4/4, position 4.0 beats. Used as the default by every snapshot
285    /// helper (`truce-egui`, `truce-slint`, `truce-iced`,
286    /// `truce-test`) so that transport-aware widgets render a
287    /// populated readout in marketing screenshots instead of a
288    /// `(no host transport)` placeholder.
289    #[must_use]
290    pub fn for_screenshot() -> Self {
291        Self {
292            playing: true,
293            tempo: 120.0,
294            time_sig_num: 4,
295            time_sig_den: 4,
296            position_beats: 4.0,
297            // 4 beats at 120 BPM is 2.0 s = 96000 samples at 48 kHz;
298            // keeps the sample + beat positions consistent in readouts.
299            position_samples: 96_000,
300            ..Self::default()
301        }
302    }
303}
304
305/// Default reserved capacity for per-instance `EventList`s held by
306/// format wrappers. Sized to cover a heavy MIDI block (note bursts +
307/// per-block automation changes) without growing past steady state.
308///
309/// Plugins can construct a smaller or larger list explicitly via
310/// [`EventList::with_capacity`]; this const exists so the format
311/// wrappers don't each pick their own magic number.
312pub const EVENT_LIST_PREALLOC: usize = 256;
313
314/// Default reserved capacity for the `SysEx` byte pool on
315/// per-instance `EventList`s. 128 KiB ≈ 2× the worst-case single
316/// payload (one 64 KiB firmware-update-shaped message) with
317/// headroom for an interleaved burst of small messages in the
318/// same block.
319///
320/// Sized at construction in [`EventList::with_capacity`]; never
321/// re-allocates on the audio thread. A plugin that pushes beyond
322/// this gets a [`PushError::PoolFull`] and the message is dropped;
323/// truncating or splitting a `SysEx` makes it invalid.
324///
325/// Must agree with the `TRUCE_SYSEX_POOL_PREALLOC` C macro in the
326/// shared shim header: the AU v3 Swift template (which can't import
327/// Rust consts) reads the C macro to size its per-render output
328/// scratch buffer, and a per-format unit test asserts the two values
329/// match.
330pub const SYSEX_POOL_PREALLOC: usize = 128 * 1024;
331
332/// Why a push into the [`EventList`] failed. Today only `SysEx`
333/// payloads can fail to land (the channel-voice [`EventList::push`]
334/// path grows the backing `Vec` instead, since the audio-thread
335/// contract there is "stay under [`EVENT_LIST_PREALLOC`]" rather
336/// than "fail closed").
337#[derive(Clone, Copy, Debug, PartialEq, Eq)]
338pub enum PushError {
339    /// The `SysEx` byte pool is full. The message wasn't appended.
340    /// Callers either drop it, surface it via a meter, or bump the
341    /// pool size via [`EventList::with_capacity`] at construction.
342    PoolFull,
343}
344
345impl core::fmt::Display for PushError {
346    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
347        match self {
348            Self::PoolFull => f.write_str("SysEx byte pool is full"),
349        }
350    }
351}
352
353impl std::error::Error for PushError {}
354
355/// Ordered list of events within a process block.
356///
357/// `events` is the per-block event ring; `sysex_pool` is the
358/// variable-byte arena that [`EventBody::SysEx`] entries index into.
359/// Both are pre-allocated by [`EventList::with_capacity`] and reset
360/// (length only - backing memory preserved) by [`Self::clear`], so
361/// steady-state operation is allocation-free.
362#[derive(Clone, Debug, Default)]
363pub struct EventList {
364    events: Vec<Event>,
365    sysex_pool: Vec<u8>,
366}
367
368impl EventList {
369    /// Construct an `EventList` with backing capacity already reserved.
370    ///
371    /// Format wrappers build their per-instance event lists at
372    /// construction time and reuse them across blocks via `clear()`.
373    /// Without this, the first `push` after `EventList::default()` hits
374    /// the global allocator on the audio thread; pre-allocating with
375    /// the max event count an audio block is likely to carry keeps
376    /// the first block alloc-free.
377    ///
378    /// The `SysEx` byte pool is sized to [`SYSEX_POOL_PREALLOC`]
379    /// regardless of `capacity` - `capacity` controls the event ring
380    /// only.
381    #[must_use]
382    pub fn with_capacity(capacity: usize) -> Self {
383        Self {
384            events: Vec::with_capacity(capacity),
385            sysex_pool: Vec::with_capacity(SYSEX_POOL_PREALLOC),
386        }
387    }
388
389    /// Append an event. Note: `sample_offset` is **not** bounds-checked
390    /// against any block size - callers that build event lists per
391    /// block must validate `sample_offset < num_samples` themselves
392    /// (the audio thread can't recover from an out-of-range offset, so
393    /// we treat that as a contract violation rather than panicking).
394    pub fn push(&mut self, event: Event) {
395        self.events.push(event);
396    }
397
398    /// Sort the list by `sample_offset` if it isn't already, keeping
399    /// the push order of equal-offset events (a recentre bend must stay
400    /// ahead of the note-off it precedes). Hosts require output queues
401    /// ordered by time; wrappers call this before draining so a plugin
402    /// that pushed block-level events after per-event ones can't hand
403    /// the host an unsorted queue; wrappers also sort the merged
404    /// *input* stream before processing. Audio-thread safe: the
405    /// common already-sorted case is one linear scan, and the fix-up
406    /// is an in-place insertion sort - no allocation (`sort_by_key`
407    /// is a std stable sort that allocates past ~20 elements;
408    /// `sort_unstable` would reorder equal offsets, and stability is
409    /// load-bearing: "note-on then CC on the same sample" must stay
410    /// in push order). Reorders [`Event`] entries only; `SysEx` pool
411    /// offsets stay valid because the pool's bytes aren't moved.
412    pub fn ensure_sorted_by_offset(&mut self) {
413        if self.events.is_sorted_by_key(|event| event.sample_offset) {
414            return;
415        }
416        for i in 1..self.events.len() {
417            let mut j = i;
418            while j > 0 && self.events[j - 1].sample_offset > self.events[j].sample_offset {
419                self.events.swap(j - 1, j);
420                j -= 1;
421            }
422        }
423    }
424
425    /// Append a `SysEx` event whose payload is copied into the pool.
426    /// `data` is the inner `SysEx` bytes **without** the leading
427    /// `0xF0` / trailing `0xF7` - wrappers strip those at the
428    /// boundary.
429    ///
430    /// Returns [`PushError::PoolFull`] when the pool can't hold
431    /// `data.len()` more bytes; the event is *not* appended and the
432    /// pool is left unchanged. `SysEx` messages are atomic by spec,
433    /// so the caller's choices are drop-and-flag (via a meter) or
434    /// fail the host call. Splitting / truncating produces a corrupt
435    /// message and is never the right answer.
436    ///
437    /// # Errors
438    /// [`PushError::PoolFull`] when the pool is at capacity.
439    pub fn push_sysex(&mut self, sample_offset: u32, data: &[u8]) -> Result<(), PushError> {
440        self.push_sysex_on_port(sample_offset, 0, data)
441    }
442
443    /// Like [`Self::push_sysex`] but stamps the event with a MIDI
444    /// [`Event::port`]. Single-port callers use [`Self::push_sysex`]
445    /// (port `0`); a multi-port wrapper preserves the port a `SysEx`
446    /// arrived on.
447    ///
448    /// # Errors
449    /// [`PushError::PoolFull`] when the pool is at capacity.
450    pub fn push_sysex_on_port(
451        &mut self,
452        sample_offset: u32,
453        port: u8,
454        data: &[u8],
455    ) -> Result<(), PushError> {
456        let pool_offset = self.sysex_pool.len();
457        if pool_offset + data.len() > self.sysex_pool.capacity() {
458            return Err(PushError::PoolFull);
459        }
460        self.sysex_pool.extend_from_slice(data);
461        // `as u32` casts are bounded: pool capacity is sized in the
462        // hundreds of KiB at most, and the bounds check above keeps
463        // `pool_offset + data.len()` under capacity, which itself
464        // fits in `u32` by construction (`SYSEX_POOL_PREALLOC` ==
465        // 128 KiB).
466        #[allow(clippy::cast_possible_truncation)]
467        self.events.push(Event {
468            sample_offset,
469            port,
470            body: EventBody::SysEx {
471                pool_offset: pool_offset as u32,
472                len: data.len() as u32,
473            },
474        });
475        Ok(())
476    }
477
478    /// Resolve a [`EventBody::SysEx`] entry to its payload bytes.
479    /// Returns an empty slice for any other variant - the slice is
480    /// indexed against the internal byte pool, so a non-`SysEx`
481    /// body has nothing to point at.
482    #[must_use]
483    pub fn sysex_bytes(&self, body: &EventBody) -> &[u8] {
484        match body {
485            EventBody::SysEx { pool_offset, len } => {
486                let start = *pool_offset as usize;
487                let end = start + (*len as usize);
488                &self.sysex_pool[start..end]
489            }
490            _ => &[],
491        }
492    }
493
494    pub fn clear(&mut self) {
495        self.events.clear();
496        // `Vec::clear` preserves capacity; the pool stays
497        // pre-allocated for the next block.
498        self.sysex_pool.clear();
499    }
500
501    pub fn iter(&self) -> impl Iterator<Item = &Event> {
502        self.events.iter()
503    }
504
505    #[must_use]
506    pub fn get(&self, index: usize) -> Option<&Event> {
507        self.events.get(index)
508    }
509
510    #[must_use]
511    pub fn len(&self) -> usize {
512        self.events.len()
513    }
514
515    #[must_use]
516    pub fn is_empty(&self) -> bool {
517        self.events.is_empty()
518    }
519
520    /// Mutable access to the underlying event slice. Used by
521    /// `chunked_process` to shift the `sample_offset` of outbound
522    /// events back to host-block-relative coordinates after a
523    /// sub-block; should not be needed by plugin or wrapper code
524    /// outside the chunker.
525    #[doc(hidden)]
526    pub fn events_mut(&mut self) -> &mut [Event] {
527        &mut self.events
528    }
529
530    /// Current `SysEx` pool usage in bytes. Mainly useful in tests
531    /// and for plug-in code that wants to surface "headroom
532    /// remaining" in an editor.
533    #[must_use]
534    pub fn sysex_pool_used(&self) -> usize {
535        self.sysex_pool.len()
536    }
537
538    /// Total `SysEx` pool capacity in bytes. Stable for the life of
539    /// the `EventList` (no audio-thread reallocation).
540    #[must_use]
541    pub fn sysex_pool_capacity(&self) -> usize {
542        self.sysex_pool.capacity()
543    }
544}
545
546#[cfg(test)]
547mod tests {
548    use super::*;
549
550    #[test]
551    fn push_sysex_round_trip() {
552        let mut list = EventList::with_capacity(8);
553        let payload = b"\x7E\x00\x06\x01"; // device-inquiry reply body
554        list.push_sysex(42, payload).expect("pool has room");
555
556        assert_eq!(list.len(), 1);
557        let event = list.iter().next().expect("one event");
558        assert_eq!(event.sample_offset, 42);
559        assert!(matches!(event.body, EventBody::SysEx { .. }));
560        assert_eq!(list.sysex_bytes(&event.body), payload);
561        assert_eq!(list.sysex_pool_used(), payload.len());
562    }
563
564    #[test]
565    fn push_sysex_two_messages_carve_pool_independently() {
566        let mut list = EventList::with_capacity(8);
567        let a = b"\x01\x02\x03";
568        let b = b"\x04\x05\x06\x07";
569        list.push_sysex(0, a).unwrap();
570        list.push_sysex(1, b).unwrap();
571
572        let collected: Vec<_> = list.iter().collect();
573        assert_eq!(list.sysex_bytes(&collected[0].body), a);
574        assert_eq!(list.sysex_bytes(&collected[1].body), b);
575        assert_eq!(list.sysex_pool_used(), a.len() + b.len());
576    }
577
578    #[test]
579    fn push_sysex_pool_full_is_recoverable() {
580        // Construct a tiny pool by going through `with_capacity` with a
581        // post-hoc shrink - we can't pass a custom pool size today, so
582        // exercise the failure path by overflowing the configured 128 KiB.
583        let mut list = EventList::with_capacity(8);
584        let big = vec![0u8; SYSEX_POOL_PREALLOC];
585        list.push_sysex(0, &big)
586            .expect("first fill is exactly the pool");
587        let err = list.push_sysex(1, b"\x00").unwrap_err();
588        assert_eq!(err, PushError::PoolFull);
589        // No partial state: the rejected event isn't queued, the pool
590        // length is unchanged.
591        assert_eq!(list.len(), 1);
592        assert_eq!(list.sysex_pool_used(), SYSEX_POOL_PREALLOC);
593    }
594
595    #[test]
596    fn clear_preserves_pool_capacity() {
597        let mut list = EventList::with_capacity(8);
598        let cap_before = list.sysex_pool_capacity();
599        list.push_sysex(0, b"\x00\x01\x02").unwrap();
600        list.clear();
601        assert!(list.is_empty());
602        assert_eq!(list.sysex_pool_used(), 0);
603        // The whole point of pre-allocation: clearing must not free.
604        assert_eq!(list.sysex_pool_capacity(), cap_before);
605    }
606
607    #[test]
608    fn sort_preserves_sysex_offsets() {
609        let mut list = EventList::with_capacity(8);
610        let early = b"\x10\x11";
611        let late = b"\x20\x21\x22";
612        list.push_sysex(100, late).unwrap();
613        list.push_sysex(0, early).unwrap();
614        list.ensure_sorted_by_offset();
615
616        let collected: Vec<_> = list.iter().collect();
617        // Sorted: sample_offset=0 comes first, then 100.
618        assert_eq!(collected[0].sample_offset, 0);
619        assert_eq!(list.sysex_bytes(&collected[0].body), early);
620        assert_eq!(collected[1].sample_offset, 100);
621        assert_eq!(list.sysex_bytes(&collected[1].body), late);
622    }
623
624    #[test]
625    fn sysex_bytes_returns_empty_for_non_sysex() {
626        let list = EventList::with_capacity(8);
627        let body = EventBody::NoteOn {
628            group: 0,
629            channel: 0,
630            note: 60,
631            velocity: 100,
632        };
633        assert!(list.sysex_bytes(&body).is_empty());
634    }
635
636    #[test]
637    fn event_constructors_set_port() {
638        let body = EventBody::NoteOn {
639            group: 0,
640            channel: 0,
641            note: 60,
642            velocity: 100,
643        };
644        assert_eq!(Event::new(10, body).port, 0);
645        assert_eq!(Event::on_port(10, 4, body).port, 4);
646    }
647
648    #[test]
649    fn push_sysex_on_port_stamps_port() {
650        let mut list = EventList::with_capacity(8);
651        list.push_sysex_on_port(0, 2, b"\x10\x11").unwrap();
652        assert_eq!(list.iter().next().unwrap().port, 2);
653    }
654
655    #[test]
656    fn ensure_sorted_orders_offsets_and_keeps_equal_offset_order() {
657        let on = |ch: u8| EventBody::NoteOn {
658            group: 0,
659            channel: ch,
660            note: 60,
661            velocity: 100,
662        };
663        let mut list = EventList::with_capacity(8);
664        // Per-event pushes at real offsets, then block-level pushes at
665        // the last sample - the shape a vibrato-style emitter produces.
666        list.push(Event::new(10, on(0)));
667        list.push(Event::new(510, on(1)));
668        list.push(Event::new(0, on(2)));
669        list.push(Event::new(510, on(3))); // equal offset: must stay after ch 1
670        list.ensure_sorted_by_offset();
671        let order: Vec<(u32, u8)> = list
672            .iter()
673            .map(|e| match e.body {
674                EventBody::NoteOn { channel, .. } => (e.sample_offset, channel),
675                _ => unreachable!(),
676            })
677            .collect();
678        assert_eq!(order, vec![(0, 2), (10, 0), (510, 1), (510, 3)]);
679    }
680}