truce_core/ump.rs
1//! Universal MIDI Packet (UMP) codec for MIDI 2.0 channel-voice
2//! messages.
3//!
4//! UMP is the MIDI 2.0 wire format. Channel-voice 2.0 messages live
5//! in 64-bit packets: word 0 carries `mt | group | status |
6//! channel | <status-specific 16 bits>`, word 1 carries the
7//! status-specific value (velocity + attribute, controller value,
8//! pitch-bend, etc.). Many UMP transports embed 64-bit packets in a
9//! fixed 128-bit slot, so [`decode_ump_channel_voice_2`] works in
10//! terms of `[u32; 4]` with the upper two words zeroed for
11//! channel-voice. The matching encoder is intentionally not exposed
12//! today - see the note next to the decoder.
13//!
14//! Spec reference: MIDI 2.0 M2-104-UM §4.1.
15//!
16//! Format wrappers that speak UMP (AU v3 on iOS 17+ / macOS 14+ via
17//! `MIDIEventList`, CLAP's `CLAP_EVENT_MIDI2`) call into here so the
18//! channel-voice + `SysEx` decoders aren't reimplemented per
19//! wrapper.
20//!
21//! Out of scope today: utility messages (mt 0x0), system real-time
22//! (mt 0x1), legacy MIDI 1.0 channel-voice over UMP (mt 0x2),
23//! `SysEx`-7 (mt 0x3), data messages (mt 0x5), flex-data (mt 0xD),
24//! UMP stream (mt 0xF). `SysEx` over UMP has its own assembler;
25//! everything else awaits demand from a wrapper that needs it.
26
27use crate::events::EventBody;
28
29/// UMP message type for MIDI 2.0 channel-voice messages.
30const MT_CHANNEL_VOICE_2: u8 = 0x4;
31/// UMP message type for 7-bit `SysEx` payloads.
32const MT_SYSEX_7: u8 = 0x3;
33/// UMP message type for 8-bit `SysEx` / data payloads.
34const MT_SYSEX_8: u8 = 0x5;
35
36/// `SysEx` packet status nibble shared by `SysEx`-7 (mt 0x3) and
37/// `SysEx`-8 (mt 0x5). Lives in word 0 bits 23..20.
38const SYSEX_STATUS_COMPLETE: u8 = 0x0;
39const SYSEX_STATUS_START: u8 = 0x1;
40const SYSEX_STATUS_CONTINUE: u8 = 0x2;
41const SYSEX_STATUS_END: u8 = 0x3;
42
43/// Decode the first two words of a UMP packet into a MIDI 2.0
44/// channel-voice [`EventBody`]. Returns `None` for non-channel-voice
45/// packets (utility, system, `SysEx`, data) - those are handled by
46/// dedicated decoders (or not at all). `words[2]` and `words[3]` are
47/// ignored - channel-voice 2.0 is 64 bits and the upper half of a
48/// 128-bit slot is undefined for it.
49#[must_use]
50#[allow(clippy::cast_possible_truncation)] // UMP fields are bit-packed; truncation is intentional
51pub fn decode_ump_channel_voice_2(words: [u32; 4]) -> Option<EventBody> {
52 // Bit layout (word 0):
53 // 31..28 mt (message type, 0x4 = MIDI 2.0 CV)
54 // 27..24 group (0..=15)
55 // 23..20 status nibble (0x8 = NoteOff, 0x9 = NoteOn, ...)
56 // 19..16 channel (0..=15)
57 // 15..0 status-specific (note + attribute-type, cc number, ...)
58 let w0 = words[0];
59 let w1 = words[1];
60 let mt = ((w0 >> 28) & 0xF) as u8;
61 if mt != MT_CHANNEL_VOICE_2 {
62 return None;
63 }
64 let group = ((w0 >> 24) & 0xF) as u8;
65 let status = ((w0 >> 20) & 0xF) as u8;
66 let channel = ((w0 >> 16) & 0xF) as u8;
67 let byte_a = ((w0 >> 8) & 0xFF) as u8; // note / cc number / etc.
68 let byte_b = (w0 & 0xFF) as u8; // attribute-type / index / etc.
69 let body = match status {
70 0x8 => EventBody::NoteOff2 {
71 group,
72 channel,
73 note: byte_a & 0x7F,
74 velocity: (w1 >> 16) as u16,
75 attribute_type: byte_b,
76 attribute: (w1 & 0xFFFF) as u16,
77 },
78 0x9 => EventBody::NoteOn2 {
79 group,
80 channel,
81 note: byte_a & 0x7F,
82 velocity: (w1 >> 16) as u16,
83 attribute_type: byte_b,
84 attribute: (w1 & 0xFFFF) as u16,
85 },
86 0xA => EventBody::PolyPressure2 {
87 group,
88 channel,
89 note: byte_a & 0x7F,
90 pressure: w1,
91 },
92 // 0x0 = Registered Per-Note (RPN-like), 0x1 = Assignable
93 // Per-Note. MIDI 2.0 §4.1.4. The lower 8 bits of word 0
94 // carry the per-note controller index; word 1 is the value.
95 0x0 | 0x1 => EventBody::PerNoteCC {
96 group,
97 channel,
98 note: byte_a & 0x7F,
99 cc: byte_b,
100 value: w1,
101 registered: status == 0x0,
102 },
103 // 0x6 = Per-Note Pitch Bend.
104 0x6 => EventBody::PerNotePitchBend {
105 group,
106 channel,
107 note: byte_a & 0x7F,
108 value: w1,
109 },
110 // 0xF = Per-Note Management. The flags live in byte_b (per
111 // §4.1.6); only the low two bits are defined today.
112 0xF => EventBody::PerNoteManagement {
113 group,
114 channel,
115 note: byte_a & 0x7F,
116 flags: byte_b,
117 },
118 0xB => EventBody::ControlChange2 {
119 group,
120 channel,
121 cc: byte_a & 0x7F,
122 value: w1,
123 },
124 0xD => EventBody::ChannelPressure2 {
125 group,
126 channel,
127 pressure: w1,
128 },
129 0xE => EventBody::PitchBend2 {
130 group,
131 channel,
132 value: w1,
133 },
134 // 0x2 = Registered Controller (RPN), 0x3 = Assignable
135 // Controller (NRPN). Bank lives in `byte_a` (lower 7 bits),
136 // index in `byte_b` (lower 7 bits).
137 0x2 => EventBody::RegisteredController {
138 group,
139 channel,
140 bank: byte_a & 0x7F,
141 index: byte_b & 0x7F,
142 value: w1,
143 },
144 0x3 => EventBody::AssignableController {
145 group,
146 channel,
147 bank: byte_a & 0x7F,
148 index: byte_b & 0x7F,
149 value: w1,
150 },
151 0xC => EventBody::ProgramChange2 {
152 group,
153 channel,
154 program: (w1 >> 24) as u8 & 0x7F,
155 // Word 0 bit 0 carries the "B" (bank-valid) flag; the
156 // bank bytes live in word 1's bottom 16 bits (MSB then
157 // LSB).
158 bank: if w0 & 0x01 == 1 {
159 Some(((w1 >> 8) as u8 & 0x7F, w1 as u8 & 0x7F))
160 } else {
161 None
162 },
163 },
164 _ => return None,
165 };
166 Some(body)
167}
168
169// The matching `encode_ump_channel_voice_2` is intentionally
170// absent: until plug-ins can declare a MIDI version preference
171// that wrappers honour at port-negotiation time, no wrapper has
172// a sanctioned path to emit MIDI 2.0 channel-voice events, and
173// shipping a dormant encoder would invite ad-hoc callers that
174// bypass the eventual downconvert gate.
175
176/// One reassembled `SysEx` payload, in the form
177/// [`crate::events::EventList::push_sysex`] expects: just the inner
178/// bytes (no leading `0xF0`, no trailing `0xF7`), plus the UMP
179/// routing keys (`group` + `stream_id`) for callers that care
180/// about per-stream demux. `stream_id` is always 0 for `SysEx`-7
181/// (the format has no stream identifier).
182pub struct SysExPacket<'a> {
183 /// UMP group (0..=15) the message arrived on.
184 pub group: u8,
185 /// `SysEx`-8 stream identifier (0..=255); always 0 for
186 /// `SysEx`-7.
187 pub stream_id: u8,
188 /// The reassembled inner bytes. Valid until the next call into
189 /// the assembler.
190 pub bytes: &'a [u8],
191}
192
193/// What [`SysExAssembler::push_sysex7_packet`] /
194/// [`SysExAssembler::push_sysex8_packet`] does with the input UMP.
195pub enum SysExFeed<'a> {
196 /// Packet was a `Continue` / `Start` - buffered, nothing to
197 /// emit yet.
198 Buffered,
199 /// Packet was `Complete` or `End` - `payload` is ready to push
200 /// to the host's event list. The slice is invalidated by the
201 /// next call into the assembler.
202 Complete(SysExPacket<'a>),
203 /// Packet was malformed (length > 6 for `SysEx`-7, > 13 for
204 /// `SysEx`-8, or status nibble we don't recognise). Caller
205 /// should drop the message; assembler state is unchanged.
206 Invalid,
207 /// Buffer overflowed before the `End` packet arrived. The
208 /// partial message has been dropped; the caller may want to
209 /// surface this via a counter.
210 Overflow,
211}
212
213/// Maximum number of concurrent `SysEx` streams the assembler
214/// reassembles in parallel. `(group, stream_id)` identifies each
215/// stream uniquely; a fifth concurrent stream evicts the
216/// least-recently-touched one (dropping its in-progress message).
217///
218/// 4 is enough for any host pattern we've observed: a single
219/// MIDI 2.0 host typically uses one stream per group, and four
220/// simultaneously-active groups is already past the realistic
221/// upper bound for `SysEx` traffic in a single audio block.
222pub const SYSEX_ASSEMBLER_SLOTS: usize = 4;
223
224struct StreamSlot {
225 /// Pre-allocated buffer for this slot's in-progress message.
226 /// Sized at construction; never grows on the audio thread.
227 buffer: Vec<u8>,
228 group: u8,
229 stream_id: u8,
230 /// `true` between `Start` and `End`; `false` when the slot
231 /// holds the bytes from a just-completed `Complete` / `End`
232 /// (waiting for the caller to read them before reuse).
233 in_progress: bool,
234 /// `true` when the slot is allocated to a stream. Distinct
235 /// from `in_progress` so a just-completed slot can hold its
236 /// bytes for the caller's borrow without being evictable on
237 /// the same call.
238 in_use: bool,
239 /// Monotonic counter set on every packet that touches the
240 /// slot, used as the LRU key when allocation needs to evict.
241 last_touch: u64,
242}
243
244/// Stateful reassembler for UMP `SysEx` streams.
245///
246/// Maintains [`SYSEX_ASSEMBLER_SLOTS`] independent buffers, each
247/// keyed by `(group, stream_id)`, so hosts that interleave
248/// `SysEx` traffic across UMP groups (or across `SysEx`-8 streams
249/// within one group) don't see corrupt concatenations.
250///
251/// Each slot's buffer is bounded by the per-slot capacity passed
252/// to [`Self::with_capacity`]; pushing past it returns
253/// [`SysExFeed::Overflow`] and discards that slot's partial
254/// message - truncated `SysEx` is corrupt by definition.
255pub struct SysExAssembler {
256 slots: [StreamSlot; SYSEX_ASSEMBLER_SLOTS],
257 /// Monotonically increases on every packet; used to break ties
258 /// when LRU-evicting a slot to make room for a new stream.
259 touch_counter: u64,
260}
261
262impl SysExAssembler {
263 /// Allocate per-slot buffers up front. `capacity` is the
264 /// largest `SysEx` payload (in bytes) **per stream** the
265 /// assembler will accept - total memory is
266 /// `SYSEX_ASSEMBLER_SLOTS × capacity`.
267 ///
268 /// Matching `capacity` to the consuming
269 /// [`crate::events::EventList::sysex_pool_capacity`] is the
270 /// typical choice; smaller values trade memory for the
271 /// maximum single-message length.
272 #[must_use]
273 pub fn with_capacity(capacity: usize) -> Self {
274 // Per-slot init done by array literal - each `StreamSlot`
275 // allocates its own `Vec::with_capacity(capacity)`.
276 let slots = std::array::from_fn(|_| StreamSlot {
277 buffer: Vec::with_capacity(capacity),
278 group: 0,
279 stream_id: 0,
280 in_progress: false,
281 in_use: false,
282 last_touch: 0,
283 });
284 Self {
285 slots,
286 touch_counter: 0,
287 }
288 }
289
290 /// Drop every in-progress message and free every slot. Call
291 /// between `process()` blocks when the host's contract
292 /// guarantees no `SysEx` continues across the block boundary,
293 /// or on the first packet of a fresh session.
294 pub fn reset(&mut self) {
295 for slot in &mut self.slots {
296 slot.buffer.clear();
297 slot.in_progress = false;
298 slot.in_use = false;
299 slot.last_touch = 0;
300 }
301 self.touch_counter = 0;
302 }
303
304 /// Find the slot currently servicing `(group, stream_id)`, or
305 /// `None` if no slot matches. Returns the slot index.
306 fn find_slot(&self, group: u8, stream_id: u8) -> Option<usize> {
307 self.slots
308 .iter()
309 .position(|s| s.in_use && s.group == group && s.stream_id == stream_id)
310 }
311
312 /// Claim a slot for `(group, stream_id)` - preferring an empty
313 /// one, falling back to LRU eviction. Eviction drops the
314 /// victim's in-progress message (we have no way to surface
315 /// the loss back to the host other than the eventual missing
316 /// final message).
317 fn claim_slot(&mut self, group: u8, stream_id: u8) -> usize {
318 // Pick: empty slot if any; otherwise the least-recently-
319 // touched one. `unwrap` on the LRU fallback is safe because
320 // the slot table is fixed-size and non-empty by construction.
321 let idx = self
322 .slots
323 .iter()
324 .position(|s| !s.in_use)
325 .unwrap_or_else(|| {
326 self.slots
327 .iter()
328 .enumerate()
329 .min_by_key(|(_, s)| s.last_touch)
330 .map(|(i, _)| i)
331 .expect("non-empty slot table")
332 });
333 let slot = &mut self.slots[idx];
334 slot.buffer.clear();
335 slot.group = group;
336 slot.stream_id = stream_id;
337 slot.in_use = true;
338 slot.in_progress = false;
339 idx
340 }
341
342 /// Feed one `SysEx`-7 UMP (`words[0]`, `words[1]`). Group is
343 /// extracted from word 0 bits 27..24; `stream_id` is always 0
344 /// (the format reserves no slot for it).
345 #[allow(clippy::cast_possible_truncation)] // UMP bit-packing
346 pub fn push_sysex7_packet(&mut self, words: [u32; 2]) -> SysExFeed<'_> {
347 let w0 = words[0];
348 let w1 = words[1];
349 let mt = ((w0 >> 28) & 0xF) as u8;
350 if mt != MT_SYSEX_7 {
351 return SysExFeed::Invalid;
352 }
353 let group = ((w0 >> 24) & 0xF) as u8;
354 let status = ((w0 >> 20) & 0xF) as u8;
355 let n = ((w0 >> 16) & 0xF) as u8;
356 if n > 6 {
357 return SysExFeed::Invalid;
358 }
359 // Bytes packed into the bottom 16 bits of w0 + all of w1 -
360 // each in its own 8-bit slot, top bit always 0 per spec.
361 let raw = [
362 ((w0 >> 8) & 0xFF) as u8,
363 (w0 & 0xFF) as u8,
364 ((w1 >> 24) & 0xFF) as u8,
365 ((w1 >> 16) & 0xFF) as u8,
366 ((w1 >> 8) & 0xFF) as u8,
367 (w1 & 0xFF) as u8,
368 ];
369 self.feed_payload(group, 0, status, &raw[..n as usize])
370 }
371
372 /// Feed one `SysEx`-8 UMP (all four words). Group at word 0
373 /// bits 27..24; `stream_id` at word 0 bits 15..8 (`SysEx`-8
374 /// reserves one byte for a per-group stream identifier so
375 /// hosts can interleave concurrent `SysEx` payloads).
376 #[allow(clippy::cast_possible_truncation)] // UMP bit-packing
377 pub fn push_sysex8_packet(&mut self, words: [u32; 4]) -> SysExFeed<'_> {
378 let w0 = words[0];
379 let mt = ((w0 >> 28) & 0xF) as u8;
380 if mt != MT_SYSEX_8 {
381 return SysExFeed::Invalid;
382 }
383 let group = ((w0 >> 24) & 0xF) as u8;
384 let status = ((w0 >> 20) & 0xF) as u8;
385 let n = ((w0 >> 16) & 0xF) as u8;
386 let stream_id = ((w0 >> 8) & 0xFF) as u8;
387 // `SysEx`-8 reserves 1 byte for `stream_id`, leaving 13
388 // bytes for payload; `n` is the count of those payload
389 // bytes used in this packet.
390 if n > 13 {
391 return SysExFeed::Invalid;
392 }
393 // word 0: stream_id at bits 15..8, byte 0 at bits 7..0
394 // words 1..3: bytes 1..12, MSB-first
395 let raw = [
396 (w0 & 0xFF) as u8, // byte 0
397 ((words[1] >> 24) & 0xFF) as u8,
398 ((words[1] >> 16) & 0xFF) as u8,
399 ((words[1] >> 8) & 0xFF) as u8,
400 (words[1] & 0xFF) as u8,
401 ((words[2] >> 24) & 0xFF) as u8,
402 ((words[2] >> 16) & 0xFF) as u8,
403 ((words[2] >> 8) & 0xFF) as u8,
404 (words[2] & 0xFF) as u8,
405 ((words[3] >> 24) & 0xFF) as u8,
406 ((words[3] >> 16) & 0xFF) as u8,
407 ((words[3] >> 8) & 0xFF) as u8,
408 (words[3] & 0xFF) as u8,
409 ];
410 self.feed_payload(group, stream_id, status, &raw[..n as usize])
411 }
412
413 fn feed_payload(
414 &mut self,
415 group: u8,
416 stream_id: u8,
417 status: u8,
418 bytes: &[u8],
419 ) -> SysExFeed<'_> {
420 self.touch_counter += 1;
421 let now = self.touch_counter;
422
423 match status {
424 SYSEX_STATUS_COMPLETE => {
425 // Single-packet message - claim a slot, fill it,
426 // mark it not-in-progress so the next call can
427 // evict it. Reuse any existing slot for this
428 // (group, stream_id) (in case the previous stream
429 // for this pair leaked an in-progress state).
430 let idx = match self.find_slot(group, stream_id) {
431 Some(i) => i,
432 None => self.claim_slot(group, stream_id),
433 };
434 let slot = &mut self.slots[idx];
435 slot.buffer.clear();
436 if slot.buffer.capacity() < bytes.len() {
437 // Release the slot on overflow so the next call
438 // can reclaim it - otherwise an oversize
439 // `Complete` would leave an `in_use` slot
440 // occupying the table forever (until LRU evicted
441 // it manually). Mirror the `Start` arm.
442 slot.in_progress = false;
443 slot.in_use = false;
444 slot.last_touch = now;
445 return SysExFeed::Overflow;
446 }
447 slot.buffer.extend_from_slice(bytes);
448 slot.in_progress = false;
449 slot.last_touch = now;
450 SysExFeed::Complete(SysExPacket {
451 group,
452 stream_id,
453 bytes: &slot.buffer,
454 })
455 }
456 SYSEX_STATUS_START => {
457 let idx = match self.find_slot(group, stream_id) {
458 Some(i) => i,
459 None => self.claim_slot(group, stream_id),
460 };
461 let slot = &mut self.slots[idx];
462 slot.buffer.clear();
463 if slot.buffer.capacity() < bytes.len() {
464 slot.in_progress = false;
465 slot.in_use = false;
466 slot.last_touch = now;
467 return SysExFeed::Overflow;
468 }
469 slot.buffer.extend_from_slice(bytes);
470 slot.in_progress = true;
471 slot.last_touch = now;
472 SysExFeed::Buffered
473 }
474 SYSEX_STATUS_CONTINUE | SYSEX_STATUS_END => {
475 let Some(idx) = self.find_slot(group, stream_id) else {
476 // Out-of-band continuation - drop.
477 return SysExFeed::Invalid;
478 };
479 let slot = &mut self.slots[idx];
480 if !slot.in_progress {
481 return SysExFeed::Invalid;
482 }
483 if slot.buffer.len() + bytes.len() > slot.buffer.capacity() {
484 slot.buffer.clear();
485 slot.in_progress = false;
486 slot.in_use = false;
487 slot.last_touch = now;
488 return SysExFeed::Overflow;
489 }
490 slot.buffer.extend_from_slice(bytes);
491 slot.last_touch = now;
492 if status == SYSEX_STATUS_END {
493 slot.in_progress = false;
494 SysExFeed::Complete(SysExPacket {
495 group,
496 stream_id,
497 bytes: &slot.buffer,
498 })
499 } else {
500 SysExFeed::Buffered
501 }
502 }
503 _ => SysExFeed::Invalid,
504 }
505 }
506}
507
508#[cfg(test)]
509mod tests {
510 use super::*;
511
512 #[test]
513 fn decode_note_on_2() {
514 // Hand-crafted UMP 2.0 channel-voice NoteOn:
515 // mt=0x4, group=0, status=0x9, channel=2, note=60,
516 // velocity=0x8000, attribute_type=3, attribute=0x1234.
517 let w0 = (0x4u32 << 28) | (0x9u32 << 20) | (0x2u32 << 16) | (60u32 << 8) | 0x03;
518 let w1 = (0x8000u32 << 16) | 0x1234;
519 let decoded = decode_ump_channel_voice_2([w0, w1, 0, 0]).expect("decodes");
520 if let EventBody::NoteOn2 {
521 channel,
522 note,
523 velocity,
524 attribute_type,
525 attribute,
526 ..
527 } = decoded
528 {
529 assert_eq!(channel, 2);
530 assert_eq!(note, 60);
531 assert_eq!(velocity, 0x8000);
532 assert_eq!(attribute_type, 3);
533 assert_eq!(attribute, 0x1234);
534 } else {
535 panic!("expected NoteOn2");
536 }
537 }
538
539 #[test]
540 fn non_channel_voice_packet_returns_none() {
541 // mt = 0x0 (utility)
542 assert!(decode_ump_channel_voice_2([0x0000_0000, 0, 0, 0]).is_none());
543 // mt = 0x3 (SysEx-7)
544 assert!(decode_ump_channel_voice_2([0x3000_0000, 0, 0, 0]).is_none());
545 }
546
547 // -- SysEx-7 assembler --
548
549 fn sysex7_packet(status: u8, bytes: &[u8]) -> [u32; 2] {
550 assert!(bytes.len() <= 6);
551 // assert above bounds `len()` to 0..=6, well within `u32`.
552 #[allow(clippy::cast_possible_truncation)]
553 let n = bytes.len() as u32;
554 let mut padded = [0u8; 6];
555 padded[..bytes.len()].copy_from_slice(bytes);
556 // group is implicitly 0 - `<< 24` would be a no-op so we omit it.
557 let w0 = (0x3u32 << 28)
558 | (u32::from(status) << 20)
559 | (n << 16)
560 | (u32::from(padded[0]) << 8)
561 | u32::from(padded[1]);
562 let w1 = (u32::from(padded[2]) << 24)
563 | (u32::from(padded[3]) << 16)
564 | (u32::from(padded[4]) << 8)
565 | u32::from(padded[5]);
566 [w0, w1]
567 }
568
569 #[test]
570 fn assembler_single_complete_packet() {
571 let mut a = SysExAssembler::with_capacity(64);
572 let packet = sysex7_packet(SYSEX_STATUS_COMPLETE, &[0x7E, 0x00, 0x06, 0x01]);
573 match a.push_sysex7_packet(packet) {
574 SysExFeed::Complete(p) => assert_eq!(p.bytes, &[0x7E, 0x00, 0x06, 0x01]),
575 _ => panic!("expected Complete"),
576 }
577 }
578
579 #[test]
580 fn assembler_multi_packet_reassembly() {
581 let mut a = SysExAssembler::with_capacity(64);
582 // Start: 6 bytes.
583 let start = sysex7_packet(SYSEX_STATUS_START, &[1, 2, 3, 4, 5, 6]);
584 assert!(matches!(a.push_sysex7_packet(start), SysExFeed::Buffered));
585 // Continue: 6 more bytes.
586 let cont = sysex7_packet(SYSEX_STATUS_CONTINUE, &[7, 8, 9, 10, 11, 12]);
587 assert!(matches!(a.push_sysex7_packet(cont), SysExFeed::Buffered));
588 // End: 3 bytes.
589 let end = sysex7_packet(SYSEX_STATUS_END, &[13, 14, 15]);
590 match a.push_sysex7_packet(end) {
591 SysExFeed::Complete(p) => assert_eq!(
592 p.bytes,
593 &[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]
594 ),
595 _ => panic!("expected Complete"),
596 }
597 }
598
599 #[test]
600 fn assembler_overflow_returns_overflow_and_drops_partial() {
601 let mut a = SysExAssembler::with_capacity(8); // tiny
602 let start = sysex7_packet(SYSEX_STATUS_START, &[1, 2, 3, 4, 5, 6]);
603 assert!(matches!(a.push_sysex7_packet(start), SysExFeed::Buffered));
604 // 6 + 6 > 8 → overflow.
605 let cont = sysex7_packet(SYSEX_STATUS_CONTINUE, &[7, 8, 9, 10, 11, 12]);
606 assert!(matches!(a.push_sysex7_packet(cont), SysExFeed::Overflow));
607 // After overflow the assembler is reset; a fresh Start works.
608 let start2 = sysex7_packet(SYSEX_STATUS_COMPLETE, &[42]);
609 match a.push_sysex7_packet(start2) {
610 SysExFeed::Complete(p) => assert_eq!(p.bytes, &[42]),
611 _ => panic!("expected Complete after reset"),
612 }
613 }
614
615 #[test]
616 fn assembler_continue_without_start_is_invalid() {
617 let mut a = SysExAssembler::with_capacity(64);
618 let cont = sysex7_packet(SYSEX_STATUS_CONTINUE, &[1, 2, 3]);
619 assert!(matches!(a.push_sysex7_packet(cont), SysExFeed::Invalid));
620 }
621
622 #[test]
623 fn assembler_complete_overflow_releases_slot() {
624 // Per-slot capacity smaller than a single COMPLETE message.
625 // After the overflow, the slot must be releasable so a
626 // later stream on a fresh `(group, stream_id)` can still
627 // claim it instead of getting LRU-evicted.
628 let mut a = SysExAssembler::with_capacity(4);
629 let oversize = sysex7_packet(SYSEX_STATUS_COMPLETE, &[1, 2, 3, 4, 5]);
630 assert!(matches!(
631 a.push_sysex7_packet(oversize),
632 SysExFeed::Overflow
633 ));
634 // Three more streams on distinct groups should now all
635 // claim cleanly: the overflowed slot is back in the pool.
636 for group in 1..=3u8 {
637 let p = sysex7_packet_for_group(group, SYSEX_STATUS_START, &[group]);
638 assert!(matches!(a.push_sysex7_packet(p), SysExFeed::Buffered));
639 }
640 // And the fourth too - confirms total slot count is `SYSEX_ASSEMBLER_SLOTS`,
641 // not `SYSEX_ASSEMBLER_SLOTS - 1`.
642 let p = sysex7_packet_for_group(4, SYSEX_STATUS_START, &[4]);
643 assert!(matches!(a.push_sysex7_packet(p), SysExFeed::Buffered));
644 }
645
646 #[test]
647 fn assembler_reset_drops_partial() {
648 let mut a = SysExAssembler::with_capacity(64);
649 let start = sysex7_packet(SYSEX_STATUS_START, &[1, 2, 3]);
650 assert!(matches!(a.push_sysex7_packet(start), SysExFeed::Buffered));
651 a.reset();
652 // After reset, a fresh Continue should fail (no in-progress).
653 let cont = sysex7_packet(SYSEX_STATUS_CONTINUE, &[4]);
654 assert!(matches!(a.push_sysex7_packet(cont), SysExFeed::Invalid));
655 }
656
657 #[test]
658 fn assembler_sysex8_complete_packet() {
659 let mut a = SysExAssembler::with_capacity(64);
660 // SysEx-8: mt=0x5, status=0 (complete), n=4, stream_id=0,
661 // payload [0xAA, 0xBB, 0xCC, 0xDD] in bytes 0..3.
662 // status=0 means we don't need to shift anything into bits 23..20.
663 let w0 = (0x5u32 << 28) | (4u32 << 16) | 0xAA;
664 let w1 = (0xBBu32 << 24) | (0xCCu32 << 16) | (0xDDu32 << 8);
665 match a.push_sysex8_packet([w0, w1, 0, 0]) {
666 SysExFeed::Complete(p) => {
667 assert_eq!(p.bytes, &[0xAA, 0xBB, 0xCC, 0xDD]);
668 assert_eq!(p.group, 0);
669 assert_eq!(p.stream_id, 0);
670 }
671 _ => panic!("expected Complete"),
672 }
673 }
674
675 // Helper: build a SysEx-7 packet with explicit group.
676 fn sysex7_packet_for_group(group: u8, status: u8, bytes: &[u8]) -> [u32; 2] {
677 assert!(bytes.len() <= 6);
678 #[allow(clippy::cast_possible_truncation)]
679 let n = bytes.len() as u32;
680 let mut padded = [0u8; 6];
681 padded[..bytes.len()].copy_from_slice(bytes);
682 let w0 = (0x3u32 << 28)
683 | (u32::from(group & 0xF) << 24)
684 | (u32::from(status) << 20)
685 | (n << 16)
686 | (u32::from(padded[0]) << 8)
687 | u32::from(padded[1]);
688 let w1 = (u32::from(padded[2]) << 24)
689 | (u32::from(padded[3]) << 16)
690 | (u32::from(padded[4]) << 8)
691 | u32::from(padded[5]);
692 [w0, w1]
693 }
694
695 #[test]
696 fn assembler_concurrent_streams_across_groups() {
697 // Two SysEx-7 streams interleaved on groups 3 and 7. Both
698 // should reassemble independently; neither's bytes should
699 // bleed into the other.
700 let mut a = SysExAssembler::with_capacity(64);
701
702 // Group 3: Start with [0x10, 0x11].
703 let g3_start = sysex7_packet_for_group(3, SYSEX_STATUS_START, &[0x10, 0x11]);
704 assert!(matches!(
705 a.push_sysex7_packet(g3_start),
706 SysExFeed::Buffered
707 ));
708
709 // Group 7: Start with [0x20, 0x21, 0x22].
710 let g7_start = sysex7_packet_for_group(7, SYSEX_STATUS_START, &[0x20, 0x21, 0x22]);
711 assert!(matches!(
712 a.push_sysex7_packet(g7_start),
713 SysExFeed::Buffered
714 ));
715
716 // Group 3: End with [0x12].
717 let g3_end = sysex7_packet_for_group(3, SYSEX_STATUS_END, &[0x12]);
718 match a.push_sysex7_packet(g3_end) {
719 SysExFeed::Complete(p) => {
720 assert_eq!(p.group, 3);
721 assert_eq!(p.bytes, &[0x10, 0x11, 0x12]);
722 }
723 _ => panic!("expected Complete on group 3"),
724 }
725
726 // Group 7: End with [0x23, 0x24].
727 let g7_end = sysex7_packet_for_group(7, SYSEX_STATUS_END, &[0x23, 0x24]);
728 match a.push_sysex7_packet(g7_end) {
729 SysExFeed::Complete(p) => {
730 assert_eq!(p.group, 7);
731 assert_eq!(p.bytes, &[0x20, 0x21, 0x22, 0x23, 0x24]);
732 }
733 _ => panic!("expected Complete on group 7"),
734 }
735 }
736
737 #[test]
738 fn assembler_sysex8_stream_id_isolates_concurrent_streams() {
739 // Two SysEx-8 streams on the same group (0) but different
740 // stream_ids (5 and 9), interleaved.
741 let mut a = SysExAssembler::with_capacity(64);
742
743 // Helper to build a SysEx-8 packet with explicit
744 // status / n / stream_id / first 4 payload bytes (the
745 // assembler only reads the first `n` payload bytes).
746 let mk = |status: u8, n: u32, stream_id: u8, bytes: [u8; 4]| -> [u32; 4] {
747 let w0 = (0x5u32 << 28)
748 | (u32::from(status) << 20)
749 | (n << 16)
750 | (u32::from(stream_id) << 8)
751 | u32::from(bytes[0]);
752 let w1 = (u32::from(bytes[1]) << 24)
753 | (u32::from(bytes[2]) << 16)
754 | (u32::from(bytes[3]) << 8);
755 [w0, w1, 0, 0]
756 };
757
758 // stream 5 Start: 4 bytes [0xA0, 0xA1, 0xA2, 0xA3]
759 assert!(matches!(
760 a.push_sysex8_packet(mk(SYSEX_STATUS_START, 4, 5, [0xA0, 0xA1, 0xA2, 0xA3])),
761 SysExFeed::Buffered
762 ));
763 // stream 9 Start: 4 bytes [0xB0, 0xB1, 0xB2, 0xB3]
764 assert!(matches!(
765 a.push_sysex8_packet(mk(SYSEX_STATUS_START, 4, 9, [0xB0, 0xB1, 0xB2, 0xB3])),
766 SysExFeed::Buffered
767 ));
768 // stream 5 End: 1 byte [0xA4]
769 match a.push_sysex8_packet(mk(SYSEX_STATUS_END, 1, 5, [0xA4, 0, 0, 0])) {
770 SysExFeed::Complete(p) => {
771 assert_eq!(p.stream_id, 5);
772 assert_eq!(p.bytes, &[0xA0, 0xA1, 0xA2, 0xA3, 0xA4]);
773 }
774 _ => panic!("expected Complete on stream 5"),
775 }
776 // stream 9 End: 2 bytes [0xB4, 0xB5]
777 match a.push_sysex8_packet(mk(SYSEX_STATUS_END, 2, 9, [0xB4, 0xB5, 0, 0])) {
778 SysExFeed::Complete(p) => {
779 assert_eq!(p.stream_id, 9);
780 assert_eq!(p.bytes, &[0xB0, 0xB1, 0xB2, 0xB3, 0xB4, 0xB5]);
781 }
782 _ => panic!("expected Complete on stream 9"),
783 }
784 }
785
786 #[test]
787 fn assembler_lru_evicts_when_slots_exhausted() {
788 // Start more concurrent streams than slots exist; the
789 // oldest in-progress should be evicted.
790 let slots_u8 = u8::try_from(SYSEX_ASSEMBLER_SLOTS).expect("slot count fits u8");
791 let mut a = SysExAssembler::with_capacity(64);
792 for group in 0..slots_u8 {
793 let start = sysex7_packet_for_group(group, SYSEX_STATUS_START, &[group]);
794 assert!(matches!(a.push_sysex7_packet(start), SysExFeed::Buffered));
795 }
796 // One more - must evict group 0 (LRU).
797 let new_group = slots_u8;
798 let evictor = sysex7_packet_for_group(new_group, SYSEX_STATUS_START, &[new_group]);
799 assert!(matches!(a.push_sysex7_packet(evictor), SysExFeed::Buffered));
800 // Group 0's End should now fail (its slot got reused).
801 let g0_end = sysex7_packet_for_group(0, SYSEX_STATUS_END, &[0x99]);
802 assert!(matches!(a.push_sysex7_packet(g0_end), SysExFeed::Invalid));
803 // The evictor's End should work.
804 let new_end = sysex7_packet_for_group(new_group, SYSEX_STATUS_END, &[0xEE]);
805 match a.push_sysex7_packet(new_end) {
806 SysExFeed::Complete(p) => {
807 assert_eq!(p.group, new_group);
808 assert_eq!(p.bytes, &[new_group, 0xEE]);
809 }
810 _ => panic!("expected Complete on evicting group"),
811 }
812 }
813}