Skip to main content

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`] and
10//! [`encode_ump_channel_voice_2`] work in terms of `[u32; 4]` with the
11//! upper two words zeroed for channel-voice. Encoding is gated behind a
12//! port's declared [`crate::MidiDialect::Midi2`] so a MIDI-1.0 host
13//! never receives UMP.
14//!
15//! Spec reference: MIDI 2.0 M2-104-UM §4.1.
16//!
17//! Format wrappers that speak UMP (AU v3 on macOS 12+ / iOS 15+ via
18//! `MIDIEventList`, CLAP's `CLAP_EVENT_MIDI2`) call into here so the
19//! channel-voice + `SysEx` codecs aren't reimplemented per wrapper.
20//!
21//! MIDI 1.0 channel voice over UMP (mt 0x2) has an *encoder*
22//! ([`encode_ump_channel_voice_1`]) for emitting 1.0 events on a
23//! 2.0-protocol transport; there's no mt-0x2 decoder yet (wrappers
24//! receive 1.0 as bytes). Out of scope: utility (mt 0x0), system
25//! real-time (mt 0x1), flex-data (mt 0xD), UMP stream (mt 0xF).
26//! `SysEx`-7 (mt 0x3) has both the assembler and an encoder
27//! ([`encode_sysex7_packet`]); `SysEx`-8 / data (mt 0x5) has the
28//! assembler only; everything else awaits demand.
29
30use crate::events::EventBody;
31
32/// UMP message type for MIDI 2.0 channel-voice messages.
33const MT_CHANNEL_VOICE_2: u8 = 0x4;
34/// UMP message type for 7-bit `SysEx` payloads.
35const MT_SYSEX_7: u8 = 0x3;
36/// UMP message type for 8-bit `SysEx` / data payloads.
37const MT_SYSEX_8: u8 = 0x5;
38
39/// `SysEx` packet status nibble shared by `SysEx`-7 (mt 0x3) and
40/// `SysEx`-8 (mt 0x5). Lives in word 0 bits 23..20.
41const SYSEX_STATUS_COMPLETE: u8 = 0x0;
42const SYSEX_STATUS_START: u8 = 0x1;
43const SYSEX_STATUS_CONTINUE: u8 = 0x2;
44const SYSEX_STATUS_END: u8 = 0x3;
45
46/// Decode the first two words of a UMP packet into a MIDI 2.0
47/// channel-voice [`EventBody`]. Returns `None` for non-channel-voice
48/// packets (utility, system, `SysEx`, data) - those are handled by
49/// dedicated decoders (or not at all). `words[2]` and `words[3]` are
50/// ignored - channel-voice 2.0 is 64 bits and the upper half of a
51/// 128-bit slot is undefined for it.
52#[must_use]
53#[allow(clippy::cast_possible_truncation)] // UMP fields are bit-packed; truncation is intentional
54pub fn decode_ump_channel_voice_2(words: [u32; 4]) -> Option<EventBody> {
55    // Bit layout (word 0):
56    //   31..28 mt (message type, 0x4 = MIDI 2.0 CV)
57    //   27..24 group (0..=15)
58    //   23..20 status nibble (0x8 = NoteOff, 0x9 = NoteOn, ...)
59    //   19..16 channel (0..=15)
60    //   15..0  status-specific (note + attribute-type, cc number, ...)
61    let w0 = words[0];
62    let w1 = words[1];
63    let mt = ((w0 >> 28) & 0xF) as u8;
64    if mt != MT_CHANNEL_VOICE_2 {
65        return None;
66    }
67    let group = ((w0 >> 24) & 0xF) as u8;
68    let status = ((w0 >> 20) & 0xF) as u8;
69    let channel = ((w0 >> 16) & 0xF) as u8;
70    let byte_a = ((w0 >> 8) & 0xFF) as u8; // note / cc number / etc.
71    let byte_b = (w0 & 0xFF) as u8; // attribute-type / index / etc.
72    let body = match status {
73        0x8 => EventBody::NoteOff2 {
74            group,
75            channel,
76            note: byte_a & 0x7F,
77            velocity: (w1 >> 16) as u16,
78            attribute_type: byte_b,
79            attribute: (w1 & 0xFFFF) as u16,
80        },
81        0x9 => EventBody::NoteOn2 {
82            group,
83            channel,
84            note: byte_a & 0x7F,
85            velocity: (w1 >> 16) as u16,
86            attribute_type: byte_b,
87            attribute: (w1 & 0xFFFF) as u16,
88        },
89        0xA => EventBody::PolyPressure2 {
90            group,
91            channel,
92            note: byte_a & 0x7F,
93            pressure: w1,
94        },
95        // 0x0 = Registered Per-Note (RPN-like), 0x1 = Assignable
96        // Per-Note. MIDI 2.0 §4.1.4. The lower 8 bits of word 0
97        // carry the per-note controller index; word 1 is the value.
98        0x0 | 0x1 => EventBody::PerNoteCC {
99            group,
100            channel,
101            note: byte_a & 0x7F,
102            cc: byte_b,
103            value: w1,
104            registered: status == 0x0,
105        },
106        // 0x6 = Per-Note Pitch Bend.
107        0x6 => EventBody::PerNotePitchBend {
108            group,
109            channel,
110            note: byte_a & 0x7F,
111            value: w1,
112        },
113        // 0xF = Per-Note Management. The flags live in byte_b (per
114        // §4.1.6); only the low two bits are defined today.
115        0xF => EventBody::PerNoteManagement {
116            group,
117            channel,
118            note: byte_a & 0x7F,
119            flags: byte_b,
120        },
121        0xB => EventBody::ControlChange2 {
122            group,
123            channel,
124            cc: byte_a & 0x7F,
125            value: w1,
126        },
127        0xD => EventBody::ChannelPressure2 {
128            group,
129            channel,
130            pressure: w1,
131        },
132        0xE => EventBody::PitchBend2 {
133            group,
134            channel,
135            value: w1,
136        },
137        // 0x2 = Registered Controller (RPN), 0x3 = Assignable
138        // Controller (NRPN). Bank lives in `byte_a` (lower 7 bits),
139        // index in `byte_b` (lower 7 bits).
140        0x2 => EventBody::RegisteredController {
141            group,
142            channel,
143            bank: byte_a & 0x7F,
144            index: byte_b & 0x7F,
145            value: w1,
146        },
147        0x3 => EventBody::AssignableController {
148            group,
149            channel,
150            bank: byte_a & 0x7F,
151            index: byte_b & 0x7F,
152            value: w1,
153        },
154        0xC => EventBody::ProgramChange2 {
155            group,
156            channel,
157            program: (w1 >> 24) as u8 & 0x7F,
158            // Word 0 bit 0 carries the "B" (bank-valid) flag; the
159            // bank bytes live in word 1's bottom 16 bits (MSB then
160            // LSB).
161            bank: if w0 & 0x01 == 1 {
162                Some(((w1 >> 8) as u8 & 0x7F, w1 as u8 & 0x7F))
163            } else {
164                None
165            },
166        },
167        _ => return None,
168    };
169    Some(body)
170}
171
172/// Encode a MIDI 2.0 channel-voice [`EventBody`] into a 64-bit UMP
173/// packet, returned in the low two words of a `[u32; 4]` (the upper two
174/// are zero, matching the 128-bit slot [`decode_ump_channel_voice_2`]
175/// reads). Returns `None` for bodies that aren't MIDI 2.0 channel voice
176/// (MIDI 1.0 variants, transport, param automation, `SysEx`) - those ride
177/// their own emit paths. Only a port whose declared dialect is
178/// [`crate::MidiDialect::Midi2`] routes output through here; MIDI 1.0
179/// ports keep down-converting, so a dormant encoder can't leak 2.0
180/// packets to a host that negotiated 1.0.
181#[must_use]
182pub fn encode_ump_channel_voice_2(body: &EventBody) -> Option<[u32; 4]> {
183    // Inverse of `decode_ump_channel_voice_2`: `byte_a` is word 0 bits
184    // 15..8 (note / cc / bank), `byte_b` bits 7..0 (attribute-type /
185    // index / flags / bank-valid), and `w1` the 32-bit value word.
186    let (status, byte_a, byte_b, w1): (u8, u8, u8, u32) = match *body {
187        EventBody::NoteOff2 {
188            note,
189            velocity,
190            attribute_type,
191            attribute,
192            ..
193        } => (0x8, note, attribute_type, cv2_value16(velocity, attribute)),
194        EventBody::NoteOn2 {
195            note,
196            velocity,
197            attribute_type,
198            attribute,
199            ..
200        } => (0x9, note, attribute_type, cv2_value16(velocity, attribute)),
201        EventBody::PolyPressure2 { note, pressure, .. } => (0xA, note, 0, pressure),
202        EventBody::PerNoteCC {
203            note,
204            cc,
205            value,
206            registered,
207            ..
208            // Status 0x0 = registered per-note controller, 0x1 = assignable.
209        } => (u8::from(!registered), note, cc, value),
210        EventBody::PerNotePitchBend { note, value, .. } => (0x6, note, 0, value),
211        EventBody::PerNoteManagement { note, flags, .. } => (0xF, note, flags, 0),
212        EventBody::ControlChange2 { cc, value, .. } => (0xB, cc, 0, value),
213        EventBody::ChannelPressure2 { pressure, .. } => (0xD, 0, 0, pressure),
214        EventBody::PitchBend2 { value, .. } => (0xE, 0, 0, value),
215        EventBody::RegisteredController {
216            bank, index, value, ..
217        } => (0x2, bank, index & 0x7F, value),
218        EventBody::AssignableController {
219            bank, index, value, ..
220        } => (0x3, bank, index & 0x7F, value),
221        EventBody::ProgramChange2 { program, bank, .. } => {
222            // "B" (bank-valid) flag rides byte_b bit 0; the bank pair
223            // sits in word 1 bytes 1..0, program in word 1 byte 3.
224            let (option, w1) = match bank {
225                Some((msb, lsb)) => (
226                    0x01,
227                    (u32::from(program & 0x7F) << 24)
228                        | (u32::from(msb & 0x7F) << 8)
229                        | u32::from(lsb & 0x7F),
230                ),
231                None => (0x00, u32::from(program & 0x7F) << 24),
232            };
233            (0xC, 0, option, w1)
234        }
235        _ => return None,
236    };
237    let (group, channel) = cv2_addr(body)?;
238    // `byte_a` is a 7-bit field in every arm (note / channel-CC index /
239    // (N)RPN bank); an out-of-domain value would set the reserved high
240    // bit on the wire, which the decoder (and hosts) mask on read -
241    // mask on write too, matching the 1.0 encoder. `byte_b` is
242    // byte-wide except the (N)RPN index, masked at its arms.
243    let w0 = (0x4 << 28)
244        | (u32::from(group & 0x0F) << 24)
245        | (u32::from(status) << 20)
246        | (u32::from(channel & 0x0F) << 16)
247        | (u32::from(byte_a & 0x7F) << 8)
248        | u32::from(byte_b);
249    Some([w0, w1, 0, 0])
250}
251
252const fn cv2_value16(hi: u16, lo: u16) -> u32 {
253    ((hi as u32) << 16) | lo as u32
254}
255
256/// UMP message type for MIDI 1.0 channel-voice messages (one word).
257const MT_CHANNEL_VOICE_1: u8 = 0x2;
258
259/// Encode a MIDI 1.0 channel-voice [`EventBody`] into a 32-bit UMP
260/// packet (message type 0x2), returned in the low word of a `[u32; 4]`.
261/// Used to carry a plugin's MIDI 1.0 output over a UMP transport (AU
262/// v3's `midiOutputEventListBlock`) when the negotiated protocol is 2.0,
263/// where the byte-based output path is unavailable so even 1.0 events
264/// must ride UMP. Returns `None` for bodies that aren't MIDI 1.0 channel
265/// voice (the 2.0 variants go through [`encode_ump_channel_voice_2`]).
266#[must_use]
267pub fn encode_ump_channel_voice_1(body: &EventBody) -> Option<[u32; 4]> {
268    let (opcode, channel, group, data1, data2): (u8, u8, u8, u8, u8) = match *body {
269        EventBody::NoteOff {
270            group,
271            channel,
272            note,
273            velocity,
274        } => (0x8, channel, group, note, velocity),
275        EventBody::NoteOn {
276            group,
277            channel,
278            note,
279            velocity,
280        } => (0x9, channel, group, note, velocity),
281        EventBody::Aftertouch {
282            group,
283            channel,
284            note,
285            pressure,
286        } => (0xA, channel, group, note, pressure),
287        EventBody::ControlChange {
288            group,
289            channel,
290            cc,
291            value,
292        } => (0xB, channel, group, cc, value),
293        EventBody::ProgramChange {
294            group,
295            channel,
296            program,
297        } => (0xC, channel, group, program, 0),
298        EventBody::ChannelPressure {
299            group,
300            channel,
301            pressure,
302        } => (0xD, channel, group, pressure, 0),
303        EventBody::PitchBend {
304            group,
305            channel,
306            value,
307        } => {
308            // 14-bit value splits into LSB (low 7 bits) then MSB. The
309            // masked `try_from`s can't fail, so no truncating cast.
310            let lsb = u8::try_from(value & 0x7F).unwrap_or(0);
311            let msb = u8::try_from((value >> 7) & 0x7F).unwrap_or(0);
312            (0xE, channel, group, lsb, msb)
313        }
314        _ => return None,
315    };
316    let w0 = (u32::from(MT_CHANNEL_VOICE_1) << 28)
317        | (u32::from(group & 0x0F) << 24)
318        | (u32::from((opcode << 4) | (channel & 0x0F)) << 16)
319        | (u32::from(data1 & 0x7F) << 8)
320        | u32::from(data2 & 0x7F);
321    Some([w0, 0, 0, 0])
322}
323
324/// Payload bytes carried per `SysEx`-7 UMP: the 64-bit packet holds
325/// 16 bits of header + 6 data slots.
326const SYSEX_7_BYTES_PER_PACKET: usize = 6;
327
328/// Number of `SysEx`-7 UMPs needed to carry `payload_len` inner bytes.
329/// A zero-length message still takes one `Complete` packet.
330#[must_use]
331pub const fn sysex7_packet_count(payload_len: usize) -> usize {
332    if payload_len == 0 {
333        1
334    } else {
335        payload_len.div_ceil(SYSEX_7_BYTES_PER_PACKET)
336    }
337}
338
339/// Encode packet `packet_index` of the `SysEx`-7 chain carrying
340/// `payload` (the inner bytes - no `0xF0` / `0xF7` framing, which UMP
341/// doesn't transmit). A payload of up to 6 bytes is one `Complete`
342/// packet; longer payloads chain `Start` / `Continue`… / `End`. Returns
343/// `None` past the end of the chain ([`sysex7_packet_count`] gives its
344/// length). Data bytes are masked to 7 bits per spec.
345///
346/// The inverse of [`SysExAssembler::push_sysex7_packet`]: feeding the
347/// full chain through the assembler yields `payload` back.
348#[must_use]
349pub fn encode_sysex7_packet(group: u8, payload: &[u8], packet_index: usize) -> Option<[u32; 2]> {
350    let total = sysex7_packet_count(payload.len());
351    if packet_index >= total {
352        return None;
353    }
354    let start = packet_index * SYSEX_7_BYTES_PER_PACKET;
355    let chunk = &payload[start..(start + SYSEX_7_BYTES_PER_PACKET).min(payload.len())];
356    let status = match (total, packet_index) {
357        (1, _) => SYSEX_STATUS_COMPLETE,
358        (_, 0) => SYSEX_STATUS_START,
359        (_, i) if i == total - 1 => SYSEX_STATUS_END,
360        _ => SYSEX_STATUS_CONTINUE,
361    };
362    let mut padded = [0u8; SYSEX_7_BYTES_PER_PACKET];
363    for (dst, src) in padded.iter_mut().zip(chunk) {
364        *dst = src & 0x7F;
365    }
366    // chunk.len() is 0..=6 by construction.
367    #[allow(clippy::cast_possible_truncation)]
368    let n = chunk.len() as u32;
369    let w0 = (u32::from(MT_SYSEX_7) << 28)
370        | (u32::from(group & 0x0F) << 24)
371        | (u32::from(status) << 20)
372        | (n << 16)
373        | (u32::from(padded[0]) << 8)
374        | u32::from(padded[1]);
375    let w1 = (u32::from(padded[2]) << 24)
376        | (u32::from(padded[3]) << 16)
377        | (u32::from(padded[4]) << 8)
378        | u32::from(padded[5]);
379    Some([w0, w1])
380}
381
382/// Pull `(group, channel)` off any channel-voice body. Returns `None`
383/// for non-channel-voice bodies (which `encode_ump_channel_voice_2`
384/// has already rejected before calling this).
385fn cv2_addr(body: &EventBody) -> Option<(u8, u8)> {
386    Some(match *body {
387        EventBody::NoteOff2 { group, channel, .. }
388        | EventBody::NoteOn2 { group, channel, .. }
389        | EventBody::PolyPressure2 { group, channel, .. }
390        | EventBody::PerNoteCC { group, channel, .. }
391        | EventBody::PerNotePitchBend { group, channel, .. }
392        | EventBody::PerNoteManagement { group, channel, .. }
393        | EventBody::ControlChange2 { group, channel, .. }
394        | EventBody::ChannelPressure2 { group, channel, .. }
395        | EventBody::PitchBend2 { group, channel, .. }
396        | EventBody::RegisteredController { group, channel, .. }
397        | EventBody::AssignableController { group, channel, .. }
398        | EventBody::ProgramChange2 { group, channel, .. } => (group, channel),
399        _ => return None,
400    })
401}
402
403/// One reassembled `SysEx` payload, in the form
404/// [`crate::events::EventList::push_sysex`] expects: just the inner
405/// bytes (no leading `0xF0`, no trailing `0xF7`), plus the UMP
406/// routing keys (`group` + `stream_id`) for callers that care
407/// about per-stream demux. `stream_id` is always 0 for `SysEx`-7
408/// (the format has no stream identifier).
409pub struct SysExPacket<'a> {
410    /// UMP group (0..=15) the message arrived on.
411    pub group: u8,
412    /// `SysEx`-8 stream identifier (0..=255); always 0 for
413    /// `SysEx`-7.
414    pub stream_id: u8,
415    /// The reassembled inner bytes. Valid until the next call into
416    /// the assembler.
417    pub bytes: &'a [u8],
418}
419
420/// What [`SysExAssembler::push_sysex7_packet`] /
421/// [`SysExAssembler::push_sysex8_packet`] does with the input UMP.
422pub enum SysExFeed<'a> {
423    /// Packet was a `Continue` / `Start` - buffered, nothing to
424    /// emit yet.
425    Buffered,
426    /// Packet was `Complete` or `End` - `payload` is ready to push
427    /// to the host's event list. The slice is invalidated by the
428    /// next call into the assembler.
429    Complete(SysExPacket<'a>),
430    /// Packet was malformed (length > 6 for `SysEx`-7, > 13 for
431    /// `SysEx`-8, or status nibble we don't recognise). Caller
432    /// should drop the message; assembler state is unchanged.
433    Invalid,
434    /// Buffer overflowed before the `End` packet arrived. The
435    /// partial message has been dropped; the caller may want to
436    /// surface this via a counter.
437    Overflow,
438}
439
440/// Maximum number of concurrent `SysEx` streams the assembler
441/// reassembles in parallel. `(group, stream_id)` identifies each
442/// stream uniquely; a fifth concurrent stream evicts the
443/// least-recently-touched one (dropping its in-progress message).
444///
445/// 4 is enough for any host pattern we've observed: a single
446/// MIDI 2.0 host typically uses one stream per group, and four
447/// simultaneously-active groups is already past the realistic
448/// upper bound for `SysEx` traffic in a single audio block.
449pub const SYSEX_ASSEMBLER_SLOTS: usize = 4;
450
451struct StreamSlot {
452    /// Pre-allocated buffer for this slot's in-progress message.
453    /// Sized at construction; never grows on the audio thread.
454    buffer: Vec<u8>,
455    group: u8,
456    stream_id: u8,
457    /// `true` between `Start` and `End`; `false` when the slot
458    /// holds the bytes from a just-completed `Complete` / `End`
459    /// (waiting for the caller to read them before reuse).
460    in_progress: bool,
461    /// `true` when the slot is allocated to a stream. Distinct
462    /// from `in_progress` so a just-completed slot can hold its
463    /// bytes for the caller's borrow without being evictable on
464    /// the same call.
465    in_use: bool,
466    /// Monotonic counter set on every packet that touches the
467    /// slot, used as the LRU key when allocation needs to evict.
468    last_touch: u64,
469}
470
471/// Stateful reassembler for UMP `SysEx` streams.
472///
473/// Maintains [`SYSEX_ASSEMBLER_SLOTS`] independent buffers, each
474/// keyed by `(group, stream_id)`, so hosts that interleave
475/// `SysEx` traffic across UMP groups (or across `SysEx`-8 streams
476/// within one group) don't see corrupt concatenations.
477///
478/// Each slot's buffer is bounded by the per-slot capacity passed
479/// to [`Self::with_capacity`]; pushing past it returns
480/// [`SysExFeed::Overflow`] and discards that slot's partial
481/// message - truncated `SysEx` is corrupt by definition.
482pub struct SysExAssembler {
483    slots: [StreamSlot; SYSEX_ASSEMBLER_SLOTS],
484    /// Monotonically increases on every packet; used to break ties
485    /// when LRU-evicting a slot to make room for a new stream.
486    touch_counter: u64,
487}
488
489impl SysExAssembler {
490    /// Allocate per-slot buffers up front. `capacity` is the
491    /// largest `SysEx` payload (in bytes) **per stream** the
492    /// assembler will accept - total memory is
493    /// `SYSEX_ASSEMBLER_SLOTS × capacity`.
494    ///
495    /// Matching `capacity` to the consuming
496    /// [`crate::events::EventList::sysex_pool_capacity`] is the
497    /// typical choice; smaller values trade memory for the
498    /// maximum single-message length.
499    #[must_use]
500    pub fn with_capacity(capacity: usize) -> Self {
501        // Per-slot init done by array literal - each `StreamSlot`
502        // allocates its own `Vec::with_capacity(capacity)`.
503        let slots = std::array::from_fn(|_| StreamSlot {
504            buffer: Vec::with_capacity(capacity),
505            group: 0,
506            stream_id: 0,
507            in_progress: false,
508            in_use: false,
509            last_touch: 0,
510        });
511        Self {
512            slots,
513            touch_counter: 0,
514        }
515    }
516
517    /// Drop every in-progress message and free every slot. Call
518    /// between `process()` blocks when the host's contract
519    /// guarantees no `SysEx` continues across the block boundary,
520    /// or on the first packet of a fresh session.
521    pub fn reset(&mut self) {
522        for slot in &mut self.slots {
523            slot.buffer.clear();
524            slot.in_progress = false;
525            slot.in_use = false;
526            slot.last_touch = 0;
527        }
528        self.touch_counter = 0;
529    }
530
531    /// Find the slot currently servicing `(group, stream_id)`, or
532    /// `None` if no slot matches. Returns the slot index.
533    fn find_slot(&self, group: u8, stream_id: u8) -> Option<usize> {
534        self.slots
535            .iter()
536            .position(|s| s.in_use && s.group == group && s.stream_id == stream_id)
537    }
538
539    /// Claim a slot for `(group, stream_id)` - preferring an empty
540    /// one, falling back to LRU eviction. Eviction drops the
541    /// victim's in-progress message (we have no way to surface
542    /// the loss back to the host other than the eventual missing
543    /// final message).
544    fn claim_slot(&mut self, group: u8, stream_id: u8) -> usize {
545        // Pick: empty slot if any; otherwise the least-recently-
546        // touched one. `unwrap` on the LRU fallback is safe because
547        // the slot table is fixed-size and non-empty by construction.
548        let idx = self
549            .slots
550            .iter()
551            .position(|s| !s.in_use)
552            .unwrap_or_else(|| {
553                self.slots
554                    .iter()
555                    .enumerate()
556                    .min_by_key(|(_, s)| s.last_touch)
557                    .map(|(i, _)| i)
558                    .expect("non-empty slot table")
559            });
560        let slot = &mut self.slots[idx];
561        slot.buffer.clear();
562        slot.group = group;
563        slot.stream_id = stream_id;
564        slot.in_use = true;
565        slot.in_progress = false;
566        idx
567    }
568
569    /// Feed one `SysEx`-7 UMP (`words[0]`, `words[1]`). Group is
570    /// extracted from word 0 bits 27..24; `stream_id` is always 0
571    /// (the format reserves no slot for it).
572    #[allow(clippy::cast_possible_truncation)] // UMP bit-packing
573    pub fn push_sysex7_packet(&mut self, words: [u32; 2]) -> SysExFeed<'_> {
574        let w0 = words[0];
575        let w1 = words[1];
576        let mt = ((w0 >> 28) & 0xF) as u8;
577        if mt != MT_SYSEX_7 {
578            return SysExFeed::Invalid;
579        }
580        let group = ((w0 >> 24) & 0xF) as u8;
581        let status = ((w0 >> 20) & 0xF) as u8;
582        let n = ((w0 >> 16) & 0xF) as u8;
583        if n > 6 {
584            return SysExFeed::Invalid;
585        }
586        // Bytes packed into the bottom 16 bits of w0 + all of w1 -
587        // each in its own 8-bit slot, top bit always 0 per spec.
588        let raw = [
589            ((w0 >> 8) & 0xFF) as u8,
590            (w0 & 0xFF) as u8,
591            ((w1 >> 24) & 0xFF) as u8,
592            ((w1 >> 16) & 0xFF) as u8,
593            ((w1 >> 8) & 0xFF) as u8,
594            (w1 & 0xFF) as u8,
595        ];
596        self.feed_payload(group, 0, status, &raw[..n as usize])
597    }
598
599    /// Feed one `SysEx`-8 UMP (all four words). Group at word 0
600    /// bits 27..24; `stream_id` at word 0 bits 15..8 (`SysEx`-8
601    /// reserves one byte for a per-group stream identifier so
602    /// hosts can interleave concurrent `SysEx` payloads).
603    #[allow(clippy::cast_possible_truncation)] // UMP bit-packing
604    pub fn push_sysex8_packet(&mut self, words: [u32; 4]) -> SysExFeed<'_> {
605        let w0 = words[0];
606        let mt = ((w0 >> 28) & 0xF) as u8;
607        if mt != MT_SYSEX_8 {
608            return SysExFeed::Invalid;
609        }
610        let group = ((w0 >> 24) & 0xF) as u8;
611        let status = ((w0 >> 20) & 0xF) as u8;
612        let n = ((w0 >> 16) & 0xF) as u8;
613        let stream_id = ((w0 >> 8) & 0xFF) as u8;
614        // Per M2-104 §7.8, `numBytes` *includes* the Stream ID byte
615        // (unlike SysEx-7, whose count is data-only): valid range is
616        // 1 (stream id, no data) to 14 (stream id + 13 data bytes).
617        // Reading it as data-only would take one trailing garbage
618        // byte per packet and reject conformant full packets.
619        if n == 0 || n > 14 {
620            return SysExFeed::Invalid;
621        }
622        let data_len = usize::from(n - 1);
623        // word 0: stream_id at bits 15..8, byte 0 at bits 7..0
624        // words 1..3: bytes 1..12, MSB-first
625        let raw = [
626            (w0 & 0xFF) as u8, // byte 0
627            ((words[1] >> 24) & 0xFF) as u8,
628            ((words[1] >> 16) & 0xFF) as u8,
629            ((words[1] >> 8) & 0xFF) as u8,
630            (words[1] & 0xFF) as u8,
631            ((words[2] >> 24) & 0xFF) as u8,
632            ((words[2] >> 16) & 0xFF) as u8,
633            ((words[2] >> 8) & 0xFF) as u8,
634            (words[2] & 0xFF) as u8,
635            ((words[3] >> 24) & 0xFF) as u8,
636            ((words[3] >> 16) & 0xFF) as u8,
637            ((words[3] >> 8) & 0xFF) as u8,
638            (words[3] & 0xFF) as u8,
639        ];
640        self.feed_payload(group, stream_id, status, &raw[..data_len])
641    }
642
643    fn feed_payload(
644        &mut self,
645        group: u8,
646        stream_id: u8,
647        status: u8,
648        bytes: &[u8],
649    ) -> SysExFeed<'_> {
650        self.touch_counter += 1;
651        let now = self.touch_counter;
652
653        match status {
654            SYSEX_STATUS_COMPLETE => {
655                // Single-packet message - claim a slot, fill it,
656                // mark it not-in-progress so the next call can
657                // evict it. Reuse any existing slot for this
658                // (group, stream_id) (in case the previous stream
659                // for this pair leaked an in-progress state).
660                let idx = match self.find_slot(group, stream_id) {
661                    Some(i) => i,
662                    None => self.claim_slot(group, stream_id),
663                };
664                let slot = &mut self.slots[idx];
665                slot.buffer.clear();
666                if slot.buffer.capacity() < bytes.len() {
667                    // Release the slot on overflow so the next call
668                    // can reclaim it - otherwise an oversize
669                    // `Complete` would leave an `in_use` slot
670                    // occupying the table forever (until LRU evicted
671                    // it manually). Mirror the `Start` arm.
672                    slot.in_progress = false;
673                    slot.in_use = false;
674                    slot.last_touch = now;
675                    return SysExFeed::Overflow;
676                }
677                slot.buffer.extend_from_slice(bytes);
678                slot.in_progress = false;
679                slot.last_touch = now;
680                SysExFeed::Complete(SysExPacket {
681                    group,
682                    stream_id,
683                    bytes: &slot.buffer,
684                })
685            }
686            SYSEX_STATUS_START => {
687                let idx = match self.find_slot(group, stream_id) {
688                    Some(i) => i,
689                    None => self.claim_slot(group, stream_id),
690                };
691                let slot = &mut self.slots[idx];
692                slot.buffer.clear();
693                if slot.buffer.capacity() < bytes.len() {
694                    slot.in_progress = false;
695                    slot.in_use = false;
696                    slot.last_touch = now;
697                    return SysExFeed::Overflow;
698                }
699                slot.buffer.extend_from_slice(bytes);
700                slot.in_progress = true;
701                slot.last_touch = now;
702                SysExFeed::Buffered
703            }
704            SYSEX_STATUS_CONTINUE | SYSEX_STATUS_END => {
705                let Some(idx) = self.find_slot(group, stream_id) else {
706                    // Out-of-band continuation - drop.
707                    return SysExFeed::Invalid;
708                };
709                let slot = &mut self.slots[idx];
710                if !slot.in_progress {
711                    return SysExFeed::Invalid;
712                }
713                if slot.buffer.len() + bytes.len() > slot.buffer.capacity() {
714                    slot.buffer.clear();
715                    slot.in_progress = false;
716                    slot.in_use = false;
717                    slot.last_touch = now;
718                    return SysExFeed::Overflow;
719                }
720                slot.buffer.extend_from_slice(bytes);
721                slot.last_touch = now;
722                if status == SYSEX_STATUS_END {
723                    slot.in_progress = false;
724                    SysExFeed::Complete(SysExPacket {
725                        group,
726                        stream_id,
727                        bytes: &slot.buffer,
728                    })
729                } else {
730                    SysExFeed::Buffered
731                }
732            }
733            _ => SysExFeed::Invalid,
734        }
735    }
736}
737
738#[cfg(test)]
739mod tests {
740    use super::*;
741
742    #[test]
743    fn decode_note_on_2() {
744        // Hand-crafted UMP 2.0 channel-voice NoteOn:
745        // mt=0x4, group=0, status=0x9, channel=2, note=60,
746        // velocity=0x8000, attribute_type=3, attribute=0x1234.
747        let w0 = (0x4u32 << 28) | (0x9u32 << 20) | (0x2u32 << 16) | (60u32 << 8) | 0x03;
748        let w1 = (0x8000u32 << 16) | 0x1234;
749        let decoded = decode_ump_channel_voice_2([w0, w1, 0, 0]).expect("decodes");
750        if let EventBody::NoteOn2 {
751            channel,
752            note,
753            velocity,
754            attribute_type,
755            attribute,
756            ..
757        } = decoded
758        {
759            assert_eq!(channel, 2);
760            assert_eq!(note, 60);
761            assert_eq!(velocity, 0x8000);
762            assert_eq!(attribute_type, 3);
763            assert_eq!(attribute, 0x1234);
764        } else {
765            panic!("expected NoteOn2");
766        }
767    }
768
769    #[test]
770    fn non_channel_voice_packet_returns_none() {
771        // mt = 0x0 (utility)
772        assert!(decode_ump_channel_voice_2([0x0000_0000, 0, 0, 0]).is_none());
773        // mt = 0x3 (SysEx-7)
774        assert!(decode_ump_channel_voice_2([0x3000_0000, 0, 0, 0]).is_none());
775    }
776
777    // Compare at the 128-bit packet level rather than `EventBody ==`:
778    // packet equality is the stronger claim (bit-exact wire round
779    // trip, reserved bits included), and it is what hosts see.
780    // `encode_bits_match_spec` anchors the encoder to
781    // the wire independently, so a shared-bug false pass can't hide.
782    #[track_caller]
783    fn cv2_round_trip(body: EventBody) {
784        let packet = encode_ump_channel_voice_2(&body).expect("2.0 channel voice encodes");
785        let decoded = decode_ump_channel_voice_2(packet).expect("decodes");
786        let re_encoded = encode_ump_channel_voice_2(&decoded).expect("re-encodes");
787        assert_eq!(packet, re_encoded, "round trip mismatch for {body:?}");
788    }
789
790    #[test]
791    fn encode_bits_match_spec() {
792        // mt=0x4, group=4, status=0x9 (NoteOn), channel=2, note=64,
793        // attribute_type=3; word 1 = velocity<<16 | attribute.
794        let packet = encode_ump_channel_voice_2(&EventBody::NoteOn2 {
795            group: 4,
796            channel: 2,
797            note: 64,
798            velocity: 0xBEEF,
799            attribute_type: 3,
800            attribute: 0x1234,
801        })
802        .unwrap();
803        assert_eq!(
804            packet,
805            [
806                (0x4 << 28) | (4 << 24) | (0x9 << 20) | (2 << 16) | (64 << 8) | 0x03,
807                (0xBEEF << 16) | 0x1234,
808                0,
809                0,
810            ]
811        );
812    }
813
814    #[test]
815    fn channel_voice_2_round_trips() {
816        cv2_round_trip(EventBody::NoteOn2 {
817            group: 4,
818            channel: 2,
819            note: 64,
820            velocity: 0xBEEF,
821            attribute_type: 3,
822            attribute: 0x1234,
823        });
824        cv2_round_trip(EventBody::NoteOff2 {
825            group: 0,
826            channel: 15,
827            note: 127,
828            velocity: 0,
829            attribute_type: 0,
830            attribute: 0,
831        });
832        cv2_round_trip(EventBody::ControlChange2 {
833            group: 15,
834            channel: 0,
835            cc: 11,
836            value: 0xDEAD_BEEF,
837        });
838        cv2_round_trip(EventBody::PerNoteCC {
839            group: 1,
840            channel: 3,
841            note: 72,
842            cc: 5,
843            value: 0x0102_0304,
844            registered: true,
845        });
846        cv2_round_trip(EventBody::PerNoteCC {
847            group: 1,
848            channel: 3,
849            note: 72,
850            cc: 5,
851            value: 0x0102_0304,
852            registered: false,
853        });
854        cv2_round_trip(EventBody::PitchBend2 {
855            group: 1,
856            channel: 8,
857            value: 0x8000_0000,
858        });
859        cv2_round_trip(EventBody::RegisteredController {
860            group: 2,
861            channel: 4,
862            bank: 1,
863            index: 2,
864            value: 0xCAFE_0000,
865        });
866        cv2_round_trip(EventBody::PolyPressure2 {
867            group: 0,
868            channel: 0,
869            note: 60,
870            pressure: 0x1234_5678,
871        });
872        cv2_round_trip(EventBody::PerNoteManagement {
873            group: 0,
874            channel: 0,
875            note: 60,
876            flags: 0x03,
877        });
878    }
879
880    #[test]
881    fn program_change_2_bank_option_round_trips() {
882        cv2_round_trip(EventBody::ProgramChange2 {
883            group: 0,
884            channel: 0,
885            program: 10,
886            bank: Some((3, 7)),
887        });
888        cv2_round_trip(EventBody::ProgramChange2 {
889            group: 0,
890            channel: 0,
891            program: 10,
892            bank: None,
893        });
894    }
895
896    #[test]
897    fn group_nibble_survives_round_trip() {
898        for group in 0..=15u8 {
899            let packet = encode_ump_channel_voice_2(&EventBody::NoteOn2 {
900                group,
901                channel: 0,
902                note: 60,
903                velocity: 1,
904                attribute_type: 0,
905                attribute: 0,
906            })
907            .unwrap();
908            let Some(EventBody::NoteOn2 { group: g, .. }) = decode_ump_channel_voice_2(packet)
909            else {
910                panic!("expected NoteOn2");
911            };
912            assert_eq!(g, group);
913        }
914    }
915
916    #[test]
917    fn non_channel_voice_2_body_does_not_encode() {
918        // MIDI 1.0 variants and automation aren't 2.0 channel voice.
919        assert!(
920            encode_ump_channel_voice_2(&EventBody::NoteOn {
921                group: 0,
922                channel: 0,
923                note: 60,
924                velocity: 100,
925            })
926            .is_none()
927        );
928        assert!(
929            encode_ump_channel_voice_2(&EventBody::ParamChange { id: 0, value: 0.0 }).is_none()
930        );
931    }
932
933    #[test]
934    fn channel_voice_1_encodes_mt2() {
935        // NoteOn: mt=0x2, group=3, status=0x9|channel(5), note=60, vel=100.
936        let packet = encode_ump_channel_voice_1(&EventBody::NoteOn {
937            group: 3,
938            channel: 5,
939            note: 60,
940            velocity: 100,
941        })
942        .expect("note on encodes");
943        assert_eq!(
944            packet,
945            [
946                (0x2 << 28) | (0x3 << 24) | (0x95 << 16) | (0x3C << 8) | 0x64,
947                0,
948                0,
949                0
950            ]
951        );
952
953        // PitchBend 14-bit splits LSB then MSB: 0x2000 -> lsb 0, msb 0x40.
954        let bend = encode_ump_channel_voice_1(&EventBody::PitchBend {
955            group: 0,
956            channel: 0,
957            value: 0x2000,
958        })
959        .unwrap();
960        assert_eq!(bend[0], (0x2 << 28) | (0xE0 << 16) | 0x40);
961
962        // 2.0 variants and automation don't encode as MT 0x2.
963        assert!(
964            encode_ump_channel_voice_1(&EventBody::NoteOn2 {
965                group: 0,
966                channel: 0,
967                note: 60,
968                velocity: 1,
969                attribute_type: 0,
970                attribute: 0,
971            })
972            .is_none()
973        );
974    }
975
976    // -- SysEx-7 assembler --
977
978    fn sysex7_packet(status: u8, bytes: &[u8]) -> [u32; 2] {
979        assert!(bytes.len() <= 6);
980        // assert above bounds `len()` to 0..=6, well within `u32`.
981        #[allow(clippy::cast_possible_truncation)]
982        let n = bytes.len() as u32;
983        let mut padded = [0u8; 6];
984        padded[..bytes.len()].copy_from_slice(bytes);
985        // group is implicitly 0 - `<< 24` would be a no-op so we omit it.
986        let w0 = (0x3u32 << 28)
987            | (u32::from(status) << 20)
988            | (n << 16)
989            | (u32::from(padded[0]) << 8)
990            | u32::from(padded[1]);
991        let w1 = (u32::from(padded[2]) << 24)
992            | (u32::from(padded[3]) << 16)
993            | (u32::from(padded[4]) << 8)
994            | u32::from(padded[5]);
995        [w0, w1]
996    }
997
998    #[test]
999    fn assembler_single_complete_packet() {
1000        let mut a = SysExAssembler::with_capacity(64);
1001        let packet = sysex7_packet(SYSEX_STATUS_COMPLETE, &[0x7E, 0x00, 0x06, 0x01]);
1002        match a.push_sysex7_packet(packet) {
1003            SysExFeed::Complete(p) => assert_eq!(p.bytes, &[0x7E, 0x00, 0x06, 0x01]),
1004            _ => panic!("expected Complete"),
1005        }
1006    }
1007
1008    #[test]
1009    fn assembler_multi_packet_reassembly() {
1010        let mut a = SysExAssembler::with_capacity(64);
1011        // Start: 6 bytes.
1012        let start = sysex7_packet(SYSEX_STATUS_START, &[1, 2, 3, 4, 5, 6]);
1013        assert!(matches!(a.push_sysex7_packet(start), SysExFeed::Buffered));
1014        // Continue: 6 more bytes.
1015        let cont = sysex7_packet(SYSEX_STATUS_CONTINUE, &[7, 8, 9, 10, 11, 12]);
1016        assert!(matches!(a.push_sysex7_packet(cont), SysExFeed::Buffered));
1017        // End: 3 bytes.
1018        let end = sysex7_packet(SYSEX_STATUS_END, &[13, 14, 15]);
1019        match a.push_sysex7_packet(end) {
1020            SysExFeed::Complete(p) => assert_eq!(
1021                p.bytes,
1022                &[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]
1023            ),
1024            _ => panic!("expected Complete"),
1025        }
1026    }
1027
1028    #[test]
1029    fn assembler_overflow_returns_overflow_and_drops_partial() {
1030        let mut a = SysExAssembler::with_capacity(8); // tiny
1031        let start = sysex7_packet(SYSEX_STATUS_START, &[1, 2, 3, 4, 5, 6]);
1032        assert!(matches!(a.push_sysex7_packet(start), SysExFeed::Buffered));
1033        // 6 + 6 > 8 → overflow.
1034        let cont = sysex7_packet(SYSEX_STATUS_CONTINUE, &[7, 8, 9, 10, 11, 12]);
1035        assert!(matches!(a.push_sysex7_packet(cont), SysExFeed::Overflow));
1036        // After overflow the assembler is reset; a fresh Start works.
1037        let start2 = sysex7_packet(SYSEX_STATUS_COMPLETE, &[42]);
1038        match a.push_sysex7_packet(start2) {
1039            SysExFeed::Complete(p) => assert_eq!(p.bytes, &[42]),
1040            _ => panic!("expected Complete after reset"),
1041        }
1042    }
1043
1044    #[test]
1045    fn assembler_continue_without_start_is_invalid() {
1046        let mut a = SysExAssembler::with_capacity(64);
1047        let cont = sysex7_packet(SYSEX_STATUS_CONTINUE, &[1, 2, 3]);
1048        assert!(matches!(a.push_sysex7_packet(cont), SysExFeed::Invalid));
1049    }
1050
1051    #[test]
1052    fn assembler_complete_overflow_releases_slot() {
1053        // Per-slot capacity smaller than a single COMPLETE message.
1054        // After the overflow, the slot must be releasable so a
1055        // later stream on a fresh `(group, stream_id)` can still
1056        // claim it instead of getting LRU-evicted.
1057        let mut a = SysExAssembler::with_capacity(4);
1058        let oversize = sysex7_packet(SYSEX_STATUS_COMPLETE, &[1, 2, 3, 4, 5]);
1059        assert!(matches!(
1060            a.push_sysex7_packet(oversize),
1061            SysExFeed::Overflow
1062        ));
1063        // Three more streams on distinct groups should now all
1064        // claim cleanly: the overflowed slot is back in the pool.
1065        for group in 1..=3u8 {
1066            let p = sysex7_packet_for_group(group, SYSEX_STATUS_START, &[group]);
1067            assert!(matches!(a.push_sysex7_packet(p), SysExFeed::Buffered));
1068        }
1069        // And the fourth too - confirms total slot count is `SYSEX_ASSEMBLER_SLOTS`,
1070        // not `SYSEX_ASSEMBLER_SLOTS - 1`.
1071        let p = sysex7_packet_for_group(4, SYSEX_STATUS_START, &[4]);
1072        assert!(matches!(a.push_sysex7_packet(p), SysExFeed::Buffered));
1073    }
1074
1075    #[test]
1076    fn assembler_reset_drops_partial() {
1077        let mut a = SysExAssembler::with_capacity(64);
1078        let start = sysex7_packet(SYSEX_STATUS_START, &[1, 2, 3]);
1079        assert!(matches!(a.push_sysex7_packet(start), SysExFeed::Buffered));
1080        a.reset();
1081        // After reset, a fresh Continue should fail (no in-progress).
1082        let cont = sysex7_packet(SYSEX_STATUS_CONTINUE, &[4]);
1083        assert!(matches!(a.push_sysex7_packet(cont), SysExFeed::Invalid));
1084    }
1085
1086    #[test]
1087    fn assembler_sysex8_complete_packet() {
1088        let mut a = SysExAssembler::with_capacity(64);
1089        // SysEx-8: mt=0x5, status=0 (complete), stream_id=0, payload
1090        // [0xAA, 0xBB, 0xCC, 0xDD] in bytes 0..3. numBytes counts the
1091        // Stream ID too (M2-104 §7.8), so 4 data bytes -> n=5.
1092        // status=0 means we don't need to shift anything into bits 23..20.
1093        let w0 = (0x5u32 << 28) | (5u32 << 16) | 0xAA;
1094        let w1 = (0xBBu32 << 24) | (0xCCu32 << 16) | (0xDDu32 << 8);
1095        match a.push_sysex8_packet([w0, w1, 0, 0]) {
1096            SysExFeed::Complete(p) => {
1097                assert_eq!(p.bytes, &[0xAA, 0xBB, 0xCC, 0xDD]);
1098                assert_eq!(p.group, 0);
1099                assert_eq!(p.stream_id, 0);
1100            }
1101            _ => panic!("expected Complete"),
1102        }
1103    }
1104
1105    #[test]
1106    fn assembler_sysex8_full_packet_and_bounds() {
1107        // A conformant full packet: numBytes = 14 (stream id + 13
1108        // data bytes). The old data-only reading rejected exactly
1109        // this, so every maximal packet from a spec-conformant
1110        // sender was dropped.
1111        let mut a = SysExAssembler::with_capacity(64);
1112        let data: [u8; 13] = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13];
1113        let w0 = (0x5u32 << 28)
1114            | (14u32 << 16)
1115            | (7u32 << 8) // stream id
1116            | u32::from(data[0]);
1117        let word = |i: usize| {
1118            (u32::from(data[i]) << 24)
1119                | (u32::from(data[i + 1]) << 16)
1120                | (u32::from(data[i + 2]) << 8)
1121                | u32::from(data[i + 3])
1122        };
1123        match a.push_sysex8_packet([w0, word(1), word(5), word(9)]) {
1124            SysExFeed::Complete(p) => {
1125                assert_eq!(p.bytes, &data);
1126                assert_eq!(p.stream_id, 7);
1127            }
1128            _ => panic!("expected Complete for a full conformant packet"),
1129        }
1130
1131        // numBytes = 1 is legal (stream id only, zero data)...
1132        let empty = (0x5u32 << 28) | (1u32 << 16);
1133        assert!(matches!(
1134            a.push_sysex8_packet([empty, 0, 0, 0]),
1135            SysExFeed::Complete(_)
1136        ));
1137        // ...but 0 (no stream id - malformed) and 15 (past the
1138        // 128-bit packet) are not.
1139        let zero = 0x5u32 << 28;
1140        assert!(matches!(
1141            a.push_sysex8_packet([zero, 0, 0, 0]),
1142            SysExFeed::Invalid
1143        ));
1144        let fifteen = (0x5u32 << 28) | (15u32 << 16);
1145        assert!(matches!(
1146            a.push_sysex8_packet([fifteen, 0, 0, 0]),
1147            SysExFeed::Invalid
1148        ));
1149    }
1150
1151    // Helper: build a SysEx-7 packet with explicit group.
1152    fn sysex7_packet_for_group(group: u8, status: u8, bytes: &[u8]) -> [u32; 2] {
1153        assert!(bytes.len() <= 6);
1154        #[allow(clippy::cast_possible_truncation)]
1155        let n = bytes.len() as u32;
1156        let mut padded = [0u8; 6];
1157        padded[..bytes.len()].copy_from_slice(bytes);
1158        let w0 = (0x3u32 << 28)
1159            | (u32::from(group & 0xF) << 24)
1160            | (u32::from(status) << 20)
1161            | (n << 16)
1162            | (u32::from(padded[0]) << 8)
1163            | u32::from(padded[1]);
1164        let w1 = (u32::from(padded[2]) << 24)
1165            | (u32::from(padded[3]) << 16)
1166            | (u32::from(padded[4]) << 8)
1167            | u32::from(padded[5]);
1168        [w0, w1]
1169    }
1170
1171    #[test]
1172    fn assembler_concurrent_streams_across_groups() {
1173        // Two SysEx-7 streams interleaved on groups 3 and 7. Both
1174        // should reassemble independently; neither's bytes should
1175        // bleed into the other.
1176        let mut a = SysExAssembler::with_capacity(64);
1177
1178        // Group 3: Start with [0x10, 0x11].
1179        let g3_start = sysex7_packet_for_group(3, SYSEX_STATUS_START, &[0x10, 0x11]);
1180        assert!(matches!(
1181            a.push_sysex7_packet(g3_start),
1182            SysExFeed::Buffered
1183        ));
1184
1185        // Group 7: Start with [0x20, 0x21, 0x22].
1186        let g7_start = sysex7_packet_for_group(7, SYSEX_STATUS_START, &[0x20, 0x21, 0x22]);
1187        assert!(matches!(
1188            a.push_sysex7_packet(g7_start),
1189            SysExFeed::Buffered
1190        ));
1191
1192        // Group 3: End with [0x12].
1193        let g3_end = sysex7_packet_for_group(3, SYSEX_STATUS_END, &[0x12]);
1194        match a.push_sysex7_packet(g3_end) {
1195            SysExFeed::Complete(p) => {
1196                assert_eq!(p.group, 3);
1197                assert_eq!(p.bytes, &[0x10, 0x11, 0x12]);
1198            }
1199            _ => panic!("expected Complete on group 3"),
1200        }
1201
1202        // Group 7: End with [0x23, 0x24].
1203        let g7_end = sysex7_packet_for_group(7, SYSEX_STATUS_END, &[0x23, 0x24]);
1204        match a.push_sysex7_packet(g7_end) {
1205            SysExFeed::Complete(p) => {
1206                assert_eq!(p.group, 7);
1207                assert_eq!(p.bytes, &[0x20, 0x21, 0x22, 0x23, 0x24]);
1208            }
1209            _ => panic!("expected Complete on group 7"),
1210        }
1211    }
1212
1213    #[test]
1214    fn assembler_sysex8_stream_id_isolates_concurrent_streams() {
1215        // Two SysEx-8 streams on the same group (0) but different
1216        // stream_ids (5 and 9), interleaved.
1217        let mut a = SysExAssembler::with_capacity(64);
1218
1219        // Helper to build a SysEx-8 packet with explicit
1220        // status / data-byte count / stream_id / first 4 payload
1221        // bytes. The wire `numBytes` includes the Stream ID
1222        // (M2-104 §7.8), so it's `data_bytes + 1`.
1223        let mk = |status: u8, data_bytes: u32, stream_id: u8, bytes: [u8; 4]| -> [u32; 4] {
1224            let w0 = (0x5u32 << 28)
1225                | (u32::from(status) << 20)
1226                | ((data_bytes + 1) << 16)
1227                | (u32::from(stream_id) << 8)
1228                | u32::from(bytes[0]);
1229            let w1 = (u32::from(bytes[1]) << 24)
1230                | (u32::from(bytes[2]) << 16)
1231                | (u32::from(bytes[3]) << 8);
1232            [w0, w1, 0, 0]
1233        };
1234
1235        // stream 5 Start: 4 bytes [0xA0, 0xA1, 0xA2, 0xA3]
1236        assert!(matches!(
1237            a.push_sysex8_packet(mk(SYSEX_STATUS_START, 4, 5, [0xA0, 0xA1, 0xA2, 0xA3])),
1238            SysExFeed::Buffered
1239        ));
1240        // stream 9 Start: 4 bytes [0xB0, 0xB1, 0xB2, 0xB3]
1241        assert!(matches!(
1242            a.push_sysex8_packet(mk(SYSEX_STATUS_START, 4, 9, [0xB0, 0xB1, 0xB2, 0xB3])),
1243            SysExFeed::Buffered
1244        ));
1245        // stream 5 End: 1 byte [0xA4]
1246        match a.push_sysex8_packet(mk(SYSEX_STATUS_END, 1, 5, [0xA4, 0, 0, 0])) {
1247            SysExFeed::Complete(p) => {
1248                assert_eq!(p.stream_id, 5);
1249                assert_eq!(p.bytes, &[0xA0, 0xA1, 0xA2, 0xA3, 0xA4]);
1250            }
1251            _ => panic!("expected Complete on stream 5"),
1252        }
1253        // stream 9 End: 2 bytes [0xB4, 0xB5]
1254        match a.push_sysex8_packet(mk(SYSEX_STATUS_END, 2, 9, [0xB4, 0xB5, 0, 0])) {
1255            SysExFeed::Complete(p) => {
1256                assert_eq!(p.stream_id, 9);
1257                assert_eq!(p.bytes, &[0xB0, 0xB1, 0xB2, 0xB3, 0xB4, 0xB5]);
1258            }
1259            _ => panic!("expected Complete on stream 9"),
1260        }
1261    }
1262
1263    // -- SysEx-7 encoder --
1264
1265    // Feed an encoded chain back through the assembler and return the
1266    // reassembled payload; the encoder and assembler anchor each other.
1267    #[track_caller]
1268    fn sysex7_encode_round_trip(group: u8, payload: &[u8]) {
1269        let mut a = SysExAssembler::with_capacity(payload.len().max(1));
1270        let total = sysex7_packet_count(payload.len());
1271        for i in 0..total {
1272            let packet = encode_sysex7_packet(group, payload, i).expect("in-range packet");
1273            match a.push_sysex7_packet(packet) {
1274                SysExFeed::Buffered => assert!(i + 1 < total, "premature Buffered"),
1275                SysExFeed::Complete(p) => {
1276                    assert_eq!(i + 1, total, "Complete before the last packet");
1277                    assert_eq!(p.group, group);
1278                    assert_eq!(p.bytes, payload);
1279                }
1280                _ => panic!("assembler rejected encoder output"),
1281            }
1282        }
1283        assert!(encode_sysex7_packet(group, payload, total).is_none());
1284    }
1285
1286    #[test]
1287    fn sysex7_encoder_round_trips_through_assembler() {
1288        sysex7_encode_round_trip(0, &[]);
1289        sysex7_encode_round_trip(0, &[0x7E]);
1290        sysex7_encode_round_trip(3, &[1, 2, 3, 4, 5, 6]); // exactly one packet
1291        sysex7_encode_round_trip(7, &[1, 2, 3, 4, 5, 6, 7]); // Start + End
1292        sysex7_encode_round_trip(15, &(0..=40u8).collect::<Vec<_>>()); // long chain
1293    }
1294
1295    #[test]
1296    fn sysex7_encoder_bits_match_spec() {
1297        // Complete, 2 bytes, group 5: mt=0x3, status=0x0, n=2.
1298        let packet = encode_sysex7_packet(5, &[0x7E, 0x09], 0).unwrap();
1299        assert_eq!(
1300            packet,
1301            [(0x3 << 28) | (5 << 24) | (2 << 16) | (0x7E << 8) | 0x09, 0]
1302        );
1303        // 7 bytes: packet 0 is Start (n=6), packet 1 is End (n=1).
1304        let payload = [1, 2, 3, 4, 5, 6, 7];
1305        let start = encode_sysex7_packet(0, &payload, 0).unwrap();
1306        assert_eq!(
1307            (start[0] >> 20) & 0xF,
1308            u32::from(SYSEX_STATUS_START),
1309            "first of a chain is Start"
1310        );
1311        let end = encode_sysex7_packet(0, &payload, 1).unwrap();
1312        assert_eq!((end[0] >> 20) & 0xF, u32::from(SYSEX_STATUS_END));
1313        assert_eq!((end[0] >> 16) & 0xF, 1, "End carries the 1 leftover byte");
1314        assert_eq!((end[0] >> 8) & 0xFF, 7);
1315    }
1316
1317    #[test]
1318    fn sysex7_encoder_masks_to_7_bit() {
1319        let packet = encode_sysex7_packet(0, &[0xFF], 0).unwrap();
1320        assert_eq!((packet[0] >> 8) & 0xFF, 0x7F);
1321    }
1322
1323    #[test]
1324    fn assembler_lru_evicts_when_slots_exhausted() {
1325        // Start more concurrent streams than slots exist; the
1326        // oldest in-progress should be evicted.
1327        let slots_u8 = u8::try_from(SYSEX_ASSEMBLER_SLOTS).expect("slot count fits u8");
1328        let mut a = SysExAssembler::with_capacity(64);
1329        for group in 0..slots_u8 {
1330            let start = sysex7_packet_for_group(group, SYSEX_STATUS_START, &[group]);
1331            assert!(matches!(a.push_sysex7_packet(start), SysExFeed::Buffered));
1332        }
1333        // One more - must evict group 0 (LRU).
1334        let new_group = slots_u8;
1335        let evictor = sysex7_packet_for_group(new_group, SYSEX_STATUS_START, &[new_group]);
1336        assert!(matches!(a.push_sysex7_packet(evictor), SysExFeed::Buffered));
1337        // Group 0's End should now fail (its slot got reused).
1338        let g0_end = sysex7_packet_for_group(0, SYSEX_STATUS_END, &[0x99]);
1339        assert!(matches!(a.push_sysex7_packet(g0_end), SysExFeed::Invalid));
1340        // The evictor's End should work.
1341        let new_end = sysex7_packet_for_group(new_group, SYSEX_STATUS_END, &[0xEE]);
1342        match a.push_sysex7_packet(new_end) {
1343            SysExFeed::Complete(p) => {
1344                assert_eq!(p.group, new_group);
1345                assert_eq!(p.bytes, &[new_group, 0xEE]);
1346            }
1347            _ => panic!("expected Complete on evicting group"),
1348        }
1349    }
1350}