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 ..Self::default()
263 }
264 }
265}
266
267/// Default reserved capacity for per-instance `EventList`s held by
268/// format wrappers. Sized to cover a heavy MIDI block (note bursts +
269/// per-block automation changes) without growing past steady state.
270///
271/// Plugins can construct a smaller or larger list explicitly via
272/// [`EventList::with_capacity`]; this const exists so the format
273/// wrappers don't each pick their own magic number.
274pub const EVENT_LIST_PREALLOC: usize = 256;
275
276/// Default reserved capacity for the `SysEx` byte pool on
277/// per-instance `EventList`s. 128 KiB ≈ 2× the worst-case single
278/// payload (one 64 KiB firmware-update-shaped message) with
279/// headroom for an interleaved burst of small messages in the
280/// same block.
281///
282/// Sized at construction in [`EventList::with_capacity`]; never
283/// re-allocates on the audio thread. A plugin that pushes beyond
284/// this gets a [`PushError::PoolFull`] and the message is dropped;
285/// truncating or splitting a `SysEx` makes it invalid.
286///
287/// Must agree with the `TRUCE_SYSEX_POOL_PREALLOC` C macro in the
288/// shared shim header: the AU v3 Swift template (which can't import
289/// Rust consts) reads the C macro to size its per-render output
290/// scratch buffer, and a per-format unit test asserts the two values
291/// match.
292pub const SYSEX_POOL_PREALLOC: usize = 128 * 1024;
293
294/// Why a push into the [`EventList`] failed. Today only `SysEx`
295/// payloads can fail to land (the channel-voice [`EventList::push`]
296/// path grows the backing `Vec` instead, since the audio-thread
297/// contract there is "stay under [`EVENT_LIST_PREALLOC`]" rather
298/// than "fail closed").
299#[derive(Clone, Copy, Debug, PartialEq, Eq)]
300pub enum PushError {
301 /// The `SysEx` byte pool is full. The message wasn't appended.
302 /// Callers either drop it, surface it via a meter, or bump the
303 /// pool size via [`EventList::with_capacity`] at construction.
304 PoolFull,
305}
306
307impl core::fmt::Display for PushError {
308 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
309 match self {
310 Self::PoolFull => f.write_str("SysEx byte pool is full"),
311 }
312 }
313}
314
315impl std::error::Error for PushError {}
316
317/// Ordered list of events within a process block.
318///
319/// `events` is the per-block event ring; `sysex_pool` is the
320/// variable-byte arena that [`EventBody::SysEx`] entries index into.
321/// Both are pre-allocated by [`EventList::with_capacity`] and reset
322/// (length only - backing memory preserved) by [`Self::clear`], so
323/// steady-state operation is allocation-free.
324#[derive(Clone, Debug, Default)]
325pub struct EventList {
326 events: Vec<Event>,
327 sysex_pool: Vec<u8>,
328}
329
330impl EventList {
331 /// Construct an `EventList` with backing capacity already reserved.
332 ///
333 /// Format wrappers build their per-instance event lists at
334 /// construction time and reuse them across blocks via `clear()`.
335 /// Without this, the first `push` after `EventList::default()` hits
336 /// the global allocator on the audio thread; pre-allocating with
337 /// the max event count an audio block is likely to carry keeps
338 /// the first block alloc-free.
339 ///
340 /// The `SysEx` byte pool is sized to [`SYSEX_POOL_PREALLOC`]
341 /// regardless of `capacity` - `capacity` controls the event ring
342 /// only.
343 #[must_use]
344 pub fn with_capacity(capacity: usize) -> Self {
345 Self {
346 events: Vec::with_capacity(capacity),
347 sysex_pool: Vec::with_capacity(SYSEX_POOL_PREALLOC),
348 }
349 }
350
351 /// Append an event. Note: `sample_offset` is **not** bounds-checked
352 /// against any block size - callers that build event lists per
353 /// block must validate `sample_offset < num_samples` themselves
354 /// (the audio thread can't recover from an out-of-range offset, so
355 /// we treat that as a contract violation rather than panicking).
356 pub fn push(&mut self, event: Event) {
357 self.events.push(event);
358 }
359
360 /// Append a `SysEx` event whose payload is copied into the pool.
361 /// `data` is the inner `SysEx` bytes **without** the leading
362 /// `0xF0` / trailing `0xF7` - wrappers strip those at the
363 /// boundary.
364 ///
365 /// Returns [`PushError::PoolFull`] when the pool can't hold
366 /// `data.len()` more bytes; the event is *not* appended and the
367 /// pool is left unchanged. `SysEx` messages are atomic by spec,
368 /// so the caller's choices are drop-and-flag (via a meter) or
369 /// fail the host call. Splitting / truncating produces a corrupt
370 /// message and is never the right answer.
371 ///
372 /// # Errors
373 /// [`PushError::PoolFull`] when the pool is at capacity.
374 pub fn push_sysex(&mut self, sample_offset: u32, data: &[u8]) -> Result<(), PushError> {
375 let pool_offset = self.sysex_pool.len();
376 if pool_offset + data.len() > self.sysex_pool.capacity() {
377 return Err(PushError::PoolFull);
378 }
379 self.sysex_pool.extend_from_slice(data);
380 // `as u32` casts are bounded: pool capacity is sized in the
381 // hundreds of KiB at most, and the bounds check above keeps
382 // `pool_offset + data.len()` under capacity, which itself
383 // fits in `u32` by construction (`SYSEX_POOL_PREALLOC` ==
384 // 128 KiB).
385 #[allow(clippy::cast_possible_truncation)]
386 self.events.push(Event {
387 sample_offset,
388 body: EventBody::SysEx {
389 pool_offset: pool_offset as u32,
390 len: data.len() as u32,
391 },
392 });
393 Ok(())
394 }
395
396 /// Resolve a [`EventBody::SysEx`] entry to its payload bytes.
397 /// Returns an empty slice for any other variant - the slice is
398 /// indexed against the internal byte pool, so a non-`SysEx`
399 /// body has nothing to point at.
400 #[must_use]
401 pub fn sysex_bytes(&self, body: &EventBody) -> &[u8] {
402 match body {
403 EventBody::SysEx { pool_offset, len } => {
404 let start = *pool_offset as usize;
405 let end = start + (*len as usize);
406 &self.sysex_pool[start..end]
407 }
408 _ => &[],
409 }
410 }
411
412 pub fn clear(&mut self) {
413 self.events.clear();
414 // `Vec::clear` preserves capacity; the pool stays
415 // pre-allocated for the next block.
416 self.sysex_pool.clear();
417 }
418
419 /// Stable sort by `sample_offset`. **Stability matters:** events
420 /// with identical sample offsets stay in the order they were
421 /// pushed, which is what plugins assume when they iterate (e.g.
422 /// "MIDI on this sample then a CC on the same sample" stays in
423 /// that order). Don't replace with `sort_unstable_by_key` - the
424 /// stability guarantee is load-bearing.
425 ///
426 /// Sorting reorders [`Event`] entries only; `SysEx` pool
427 /// offsets stay valid because the pool's bytes aren't moved.
428 pub fn sort(&mut self) {
429 self.events.sort_by_key(|e| e.sample_offset);
430 }
431
432 pub fn iter(&self) -> impl Iterator<Item = &Event> {
433 self.events.iter()
434 }
435
436 #[must_use]
437 pub fn get(&self, index: usize) -> Option<&Event> {
438 self.events.get(index)
439 }
440
441 #[must_use]
442 pub fn len(&self) -> usize {
443 self.events.len()
444 }
445
446 #[must_use]
447 pub fn is_empty(&self) -> bool {
448 self.events.is_empty()
449 }
450
451 /// Current `SysEx` pool usage in bytes. Mainly useful in tests
452 /// and for plug-in code that wants to surface "headroom
453 /// remaining" in an editor.
454 #[must_use]
455 pub fn sysex_pool_used(&self) -> usize {
456 self.sysex_pool.len()
457 }
458
459 /// Total `SysEx` pool capacity in bytes. Stable for the life of
460 /// the `EventList` (no audio-thread reallocation).
461 #[must_use]
462 pub fn sysex_pool_capacity(&self) -> usize {
463 self.sysex_pool.capacity()
464 }
465}
466
467#[cfg(test)]
468mod tests {
469 use super::*;
470
471 #[test]
472 fn push_sysex_round_trip() {
473 let mut list = EventList::with_capacity(8);
474 let payload = b"\x7E\x00\x06\x01"; // device-inquiry reply body
475 list.push_sysex(42, payload).expect("pool has room");
476
477 assert_eq!(list.len(), 1);
478 let event = list.iter().next().expect("one event");
479 assert_eq!(event.sample_offset, 42);
480 assert!(matches!(event.body, EventBody::SysEx { .. }));
481 assert_eq!(list.sysex_bytes(&event.body), payload);
482 assert_eq!(list.sysex_pool_used(), payload.len());
483 }
484
485 #[test]
486 fn push_sysex_two_messages_carve_pool_independently() {
487 let mut list = EventList::with_capacity(8);
488 let a = b"\x01\x02\x03";
489 let b = b"\x04\x05\x06\x07";
490 list.push_sysex(0, a).unwrap();
491 list.push_sysex(1, b).unwrap();
492
493 let collected: Vec<_> = list.iter().collect();
494 assert_eq!(list.sysex_bytes(&collected[0].body), a);
495 assert_eq!(list.sysex_bytes(&collected[1].body), b);
496 assert_eq!(list.sysex_pool_used(), a.len() + b.len());
497 }
498
499 #[test]
500 fn push_sysex_pool_full_is_recoverable() {
501 // Construct a tiny pool by going through `with_capacity` with a
502 // post-hoc shrink - we can't pass a custom pool size today, so
503 // exercise the failure path by overflowing the configured 128 KiB.
504 let mut list = EventList::with_capacity(8);
505 let big = vec![0u8; SYSEX_POOL_PREALLOC];
506 list.push_sysex(0, &big)
507 .expect("first fill is exactly the pool");
508 let err = list.push_sysex(1, b"\x00").unwrap_err();
509 assert_eq!(err, PushError::PoolFull);
510 // No partial state: the rejected event isn't queued, the pool
511 // length is unchanged.
512 assert_eq!(list.len(), 1);
513 assert_eq!(list.sysex_pool_used(), SYSEX_POOL_PREALLOC);
514 }
515
516 #[test]
517 fn clear_preserves_pool_capacity() {
518 let mut list = EventList::with_capacity(8);
519 let cap_before = list.sysex_pool_capacity();
520 list.push_sysex(0, b"\x00\x01\x02").unwrap();
521 list.clear();
522 assert!(list.is_empty());
523 assert_eq!(list.sysex_pool_used(), 0);
524 // The whole point of pre-allocation: clearing must not free.
525 assert_eq!(list.sysex_pool_capacity(), cap_before);
526 }
527
528 #[test]
529 fn sort_preserves_sysex_offsets() {
530 let mut list = EventList::with_capacity(8);
531 let early = b"\x10\x11";
532 let late = b"\x20\x21\x22";
533 list.push_sysex(100, late).unwrap();
534 list.push_sysex(0, early).unwrap();
535 list.sort();
536
537 let collected: Vec<_> = list.iter().collect();
538 // Sorted: sample_offset=0 comes first, then 100.
539 assert_eq!(collected[0].sample_offset, 0);
540 assert_eq!(list.sysex_bytes(&collected[0].body), early);
541 assert_eq!(collected[1].sample_offset, 100);
542 assert_eq!(list.sysex_bytes(&collected[1].body), late);
543 }
544
545 #[test]
546 fn sysex_bytes_returns_empty_for_non_sysex() {
547 let list = EventList::with_capacity(8);
548 let body = EventBody::NoteOn {
549 group: 0,
550 channel: 0,
551 note: 60,
552 velocity: 100,
553 };
554 assert!(list.sysex_bytes(&body).is_empty());
555 }
556}