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