Pulp 1.88.0
Cross-platform audio plugin framework
Loading...
Searching...
No Matches
ump_sysex7_reassembler.hpp
Go to the documentation of this file.
1#pragma once
2
3// UmpSysex7Reassembler — shared reassembler for UMP Type-0x3 (sysex7)
4// packet streams.
5//
6// Background
7// ==========
8//
9// MIDI 2.0 UMP delivers sysex via "Data Messages — sysex7" (UMP message
10// type 0x3), which is a 64-bit / 2-word packet carrying up to 6 bytes
11// of sysex payload at a time. A logical sysex (F0 .. F7) longer than
12// 6 bytes is split across multiple type-0x3 packets using a 4-bit
13// status nibble (bits 20-23 of word 0):
14//
15// 0x0 complete — single-packet sysex (size <= 6 bytes)
16// 0x1 start — first packet, more to follow
17// 0x2 continue — middle packet
18// 0x3 end — final packet
19//
20// The 4-bit "size" field (bits 16-19 of word 0) is the number of valid
21// payload bytes in this packet (0..6). The payload bytes themselves are
22// packed into bits 0..15 of word 0 and bits 0..31 of word 1, in that
23// order:
24//
25// byte 0 = (word0 >> 8) & 0xFF
26// byte 1 = (word0 >> 0) & 0xFF
27// byte 2 = (word1 >> 24) & 0xFF
28// byte 3 = (word1 >> 16) & 0xFF
29// byte 4 = (word1 >> 8) & 0xFF
30// byte 5 = (word1 >> 0) & 0xFF
31//
32// Reference: MIDI Association Universal MIDI Packet (UMP) and MIDI 2.0
33// Protocol Specification, System Exclusive 7-bit Data Messages.
34// https://midi.org/universal-midi-packet-ump-and-midi-2-0-protocol-specification
35//
36// Why a shared reassembler
37// ========================
38//
39// Pulp historically open-coded the same state machine inline in both
40// `core/format/src/au_adapter.mm` (AUv3 MIDIEventList path) and
41// `core/midi/platform/mac/coremidi_device.mm` (CoreMIDI 2.0 input
42// callback). Both implementations needed the same cursor-advance guard
43// and the same edge-case handling for orphaned continue/end packets.
44// Extracting the state machine here:
45//
46// 1. Eliminates duplicated state and bit-twiddling.
47// 2. Centralises test coverage for the second word's top nibble not
48// masquerading as a new message header, plus preservation of an
49// unrelated in-progress stream when a complete packet interleaves.
50// 3. Gives future UMP-aware backends (WinRT MIDI 2.0, ALSA UMP,
51// iOS CoreMIDI 2.0) a drop-in dependency.
52//
53// Thread-safety
54// =============
55//
56// The reassembler holds independent state for each UMP group. A configured
57// maximum bounds every logical message; overflow discards the whole message.
58// It is **not** thread-safe — each input port / source should own its
59// own instance. The CoreMIDI input callback is single-threaded per
60// port, and AUv3 render blocks are single-threaded per audio bus, so
61// the per-stream model fits both call sites.
62//
63// RT-safety
64// =========
65//
66// `feed_packet()` may allocate inside the internal `std::vector`s as payload
67// storage grows past current capacity. Callers that want strict RT-safety
68// should `reserve()` the configured maximum up front via `reserve(N)`. This
69// also makes N the admission limit; packets that would exceed it are dropped
70// atomically with an overflow diagnostic.
71//
72// Copies retain prepared capacity and partial state; copy/move operations are
73// non-RT. A moved-from instance must be reserved again before RT reuse.
74//
75// Unit tests live in `test/test_ump_sysex7_reassembler.cpp`.
76
77#include <cstddef>
78#include <cstdint>
79#include <array>
80#include <utility>
81#include <vector>
82
83namespace pulp::midi {
84
96using Sysex7EmitFn = void(*)(const std::vector<std::uint8_t>& payload,
97 void* user);
98
103 public:
104 static constexpr std::size_t default_maximum_payload_bytes = 64u * 1024u;
105
125
140
143
156 Status feed_packet(std::uint32_t word0, std::uint32_t word1,
157 Sysex7EmitFn emit, void* user) noexcept {
158 const std::uint8_t group = static_cast<std::uint8_t>((word0 >> 24) & 0x0F);
159 auto& stream = streams_[group];
160 last_drop_reason_ = DropReason::none;
161 const std::uint8_t status =
162 static_cast<std::uint8_t>((word0 >> 20) & 0x0F);
163 const std::uint32_t size =
164 static_cast<std::uint32_t>((word0 >> 16) & 0x0F);
165
166 if (size > 6) {
167 stream.payload.clear();
168 stream.in_progress = false;
169 last_drop_reason_ = DropReason::invalid_size;
170 return Status::dropped;
171 }
172 if (!payload_is_7bit(word0, word1, size)) {
173 stream.payload.clear();
174 stream.in_progress = false;
175 last_drop_reason_ = DropReason::high_bit_payload;
176 return Status::dropped;
177 }
178
179 switch (status) {
180 case 0x0: {
181 // Complete single-packet sysex. Use a scratch vector so
182 // the call doesn't disturb any partial in-progress state
183 // when a complete packet interleaves with another stream's
184 // accumulator. This is genuinely possible: the UMP spec
185 // permits independent sysex streams on different groups to
186 // interleave through a single endpoint.
187 // A complete packet on this group starts a new logical stream;
188 // discard any unterminated message rather than splicing it.
189 stream.payload.clear();
190 stream.in_progress = false;
191 stream.scratch.clear();
192 if (size > maximum_payload_bytes_) {
193 last_drop_reason_ = DropReason::overflow;
194 return Status::dropped;
195 }
196 extract_bytes(word0, word1, size, stream.scratch);
197 if (emit) {
198 emit(stream.scratch, user);
199 }
201 }
202 case 0x1: {
203 // Start — reset the accumulator and begin capture. If a
204 // previous in-progress sysex was never closed, it is
205 // silently dropped: the spec doesn't allow nested sysex,
206 // and resetting is the only sane recovery.
207 if (stream.in_progress)
208 last_drop_reason_ = DropReason::nested_start;
209 stream.payload.clear();
210 stream.in_progress = false;
211 if (size > maximum_payload_bytes_) {
212 last_drop_reason_ = DropReason::overflow;
213 return Status::dropped;
214 }
215 extract_bytes(word0, word1, size, stream.payload);
216 stream.in_progress = true;
217 return Status::start;
218 }
219 case 0x2:
220 if (stream.in_progress) {
221 if (size > maximum_payload_bytes_ ||
222 stream.payload.size() > maximum_payload_bytes_ - size) {
223 stream.payload.clear();
224 stream.in_progress = false;
225 last_drop_reason_ = DropReason::overflow;
226 return Status::dropped;
227 }
228 extract_bytes(word0, word1, size, stream.payload);
229 return Status::continued;
230 }
231 // Orphan continue — drop.
232 last_drop_reason_ = DropReason::orphan;
233 return Status::dropped;
234 case 0x3:
235 if (stream.in_progress) {
236 if (size > maximum_payload_bytes_ ||
237 stream.payload.size() > maximum_payload_bytes_ - size) {
238 stream.payload.clear();
239 stream.in_progress = false;
240 last_drop_reason_ = DropReason::overflow;
241 return Status::dropped;
242 }
243 extract_bytes(word0, word1, size, stream.payload);
244 if (emit) {
245 emit(stream.payload, user);
246 }
247 stream.payload.clear();
248 stream.in_progress = false;
249 return Status::ended;
250 }
251 // Orphan end — drop.
252 last_drop_reason_ = DropReason::orphan;
253 return Status::dropped;
254 default:
255 // Reserved / unknown status nibble. Per the spec, type-0x3
256 // status values 0x4..0xF are reserved; the only sane
257 // behavior is to drop the packet and discard the addressed
258 // group's partial message so malformed data cannot splice.
259 stream.payload.clear();
260 stream.in_progress = false;
261 last_drop_reason_ = DropReason::reserved_status;
262 return Status::dropped;
263 }
264 }
265
268 bool in_progress() const noexcept {
269 for (const auto& stream : streams_)
270 if (stream.in_progress)
271 return true;
272 return false;
273 }
274
276 std::size_t partial_size(std::uint8_t group = 0) const noexcept {
277 return streams_[group & 0x0F].payload.size();
278 }
279
286 void reserve(std::size_t worst_case_bytes) {
287 reset();
288 maximum_payload_bytes_ = worst_case_bytes;
289 for (auto& stream : streams_) {
290 stream.payload.reserve(worst_case_bytes);
291 stream.scratch.reserve(6);
292 }
293 }
294
295 std::size_t maximum_payload_bytes() const noexcept {
296 return maximum_payload_bytes_;
297 }
298
299 DropReason last_drop_reason() const noexcept {
300 return last_drop_reason_;
301 }
302
306 void reset() noexcept {
307 for (auto& stream : streams_) {
308 stream.payload.clear();
309 stream.in_progress = false;
310 }
311 last_drop_reason_ = DropReason::reset;
312 }
313
314 private:
315 // Copying is non-RT configuration work. Preserve prepared capacity even
316 // for empty vectors so the copied instance has the same hot-path budget.
317 struct StreamState {
318 StreamState() = default;
319 StreamState(const StreamState& other) {
320 *this = other;
321 }
322 StreamState& operator=(const StreamState& other) {
323 if (this == &other)
324 return *this;
325 payload.reserve(other.payload.capacity());
326 scratch.reserve(other.scratch.capacity());
327 payload.assign(other.payload.begin(), other.payload.end());
328 scratch.assign(other.scratch.begin(), other.scratch.end());
329 in_progress = other.in_progress;
330 return *this;
331 }
332 StreamState(StreamState&& other) noexcept
333 : payload(std::move(other.payload)), scratch(std::move(other.scratch)),
334 in_progress(std::exchange(other.in_progress, false)) {}
335 StreamState& operator=(StreamState&& other) noexcept {
336 if (this == &other)
337 return *this;
338 payload = std::move(other.payload);
339 scratch = std::move(other.scratch);
340 in_progress = std::exchange(other.in_progress, false);
341 return *this;
342 }
343 std::vector<std::uint8_t> payload;
344 std::vector<std::uint8_t> scratch;
345 bool in_progress = false;
346 };
347
348 static bool payload_is_7bit(std::uint32_t w0, std::uint32_t w1, std::uint32_t size) noexcept {
349 const std::uint8_t bytes[6] = {
350 static_cast<std::uint8_t>((w0 >> 8) & 0xFF),
351 static_cast<std::uint8_t>(w0 & 0xFF),
352 static_cast<std::uint8_t>((w1 >> 24) & 0xFF),
353 static_cast<std::uint8_t>((w1 >> 16) & 0xFF),
354 static_cast<std::uint8_t>((w1 >> 8) & 0xFF),
355 static_cast<std::uint8_t>(w1 & 0xFF),
356 };
357 for (std::uint32_t i = 0; i < size; ++i)
358 if ((bytes[i] & 0x80) != 0)
359 return false;
360 return true;
361 }
362
363 static void extract_bytes(std::uint32_t w0, std::uint32_t w1,
364 std::uint32_t size,
365 std::vector<std::uint8_t>& out) {
366 // Per the UMP spec, payload bytes are packed:
367 // byte 0 = (w0 >> 8) & 0xFF
368 // byte 1 = (w0 >> 0) & 0xFF
369 // byte 2 = (w1 >> 24) & 0xFF
370 // byte 3 = (w1 >> 16) & 0xFF
371 // byte 4 = (w1 >> 8) & 0xFF
372 // byte 5 = (w1 >> 0) & 0xFF
373 // `size` is the 4-bit field from bits 16-19 of w0. feed_packet()
374 // rejects values above the six payload bytes in a SysEx7 packet.
375 const std::uint8_t buf[6] = {
376 static_cast<std::uint8_t>((w0 >> 8) & 0xFF),
377 static_cast<std::uint8_t>((w0 >> 0) & 0xFF),
378 static_cast<std::uint8_t>((w1 >> 24) & 0xFF),
379 static_cast<std::uint8_t>((w1 >> 16) & 0xFF),
380 static_cast<std::uint8_t>((w1 >> 8) & 0xFF),
381 static_cast<std::uint8_t>((w1 >> 0) & 0xFF),
382 };
383 for (std::uint32_t i = 0; i < size; ++i)
384 out.push_back(buf[i]);
385 }
386
387 std::array<StreamState, 16> streams_{};
388 std::size_t maximum_payload_bytes_ = default_maximum_payload_bytes;
389 DropReason last_drop_reason_ = DropReason::none;
390};
391
398 UmpSysex7Reassembler& r, std::uint32_t word0, std::uint32_t word1,
399 std::vector<std::uint8_t>& out) {
400 struct Box { std::vector<std::uint8_t>* sink; };
401 Box box{&out};
402 return r.feed_packet(word0, word1,
403 [](const std::vector<std::uint8_t>& payload, void* user) {
404 auto* b = static_cast<Box*>(user);
405 *b->sink = payload;
406 },
407 &box);
408}
409
410} // namespace pulp::midi
Reassembler state machine for UMP Type-0x3 sysex7 packet streams.
Definition ump_sysex7_reassembler.hpp:102
UmpSysex7Reassembler(std::size_t maximum_payload_bytes=default_maximum_payload_bytes)
Definition ump_sysex7_reassembler.hpp:141
void reserve(std::size_t worst_case_bytes)
Reserve worst-case payload capacity to avoid allocations in the hot path.
Definition ump_sysex7_reassembler.hpp:286
std::size_t partial_size(std::uint8_t group=0) const noexcept
Current accumulated payload size in bytes (0 when idle).
Definition ump_sysex7_reassembler.hpp:276
void reset() noexcept
Drop any in-progress sysex without emitting.
Definition ump_sysex7_reassembler.hpp:306
DropReason
Why the last packet was dropped (or why a partial message was reset).
Definition ump_sysex7_reassembler.hpp:130
@ invalid_size
Definition ump_sysex7_reassembler.hpp:134
@ overflow
Definition ump_sysex7_reassembler.hpp:136
@ none
Definition ump_sysex7_reassembler.hpp:131
@ reserved_status
Definition ump_sysex7_reassembler.hpp:133
@ high_bit_payload
Definition ump_sysex7_reassembler.hpp:135
@ orphan
Definition ump_sysex7_reassembler.hpp:132
@ reset
Definition ump_sysex7_reassembler.hpp:138
@ nested_start
Definition ump_sysex7_reassembler.hpp:137
static constexpr std::size_t default_maximum_payload_bytes
Definition ump_sysex7_reassembler.hpp:104
Status
Classification of the most recently consumed packet.
Definition ump_sysex7_reassembler.hpp:107
@ dropped
Continue / end without a preceding start, OR a packet whose status nibble is neither 0x0/0x1/0x2/0x3.
Definition ump_sysex7_reassembler.hpp:123
@ continued
Continue packet (status nibble 0x2) appended to an armed accumulator.
Definition ump_sysex7_reassembler.hpp:116
@ ended
End packet (status nibble 0x3) closed an armed accumulator.
Definition ump_sysex7_reassembler.hpp:119
@ single_packet
Single-packet sysex (status nibble 0x0).
Definition ump_sysex7_reassembler.hpp:110
@ start
Start packet (status nibble 0x1).
Definition ump_sysex7_reassembler.hpp:113
std::size_t maximum_payload_bytes() const noexcept
Definition ump_sysex7_reassembler.hpp:295
Status feed_packet(std::uint32_t word0, std::uint32_t word1, Sysex7EmitFn emit, void *user) noexcept
Feed a single 2-word UMP packet through the state machine.
Definition ump_sysex7_reassembler.hpp:156
bool in_progress() const noexcept
True when an in-progress sysex is open (a 0x1 start packet has been seen and no 0x3 end packet has cl...
Definition ump_sysex7_reassembler.hpp:268
DropReason last_drop_reason() const noexcept
Definition ump_sysex7_reassembler.hpp:299
Definition custom_node_events.hpp:26
void(*)(const std::vector< std::uint8_t > &payload, void *user) Sysex7EmitFn
Callback shape: receives the complete UMP SysEx7 payload bytes, with no synthesised MIDI 1....
Definition ump_sysex7_reassembler.hpp:96
UmpSysex7Reassembler::Status feed_collect(UmpSysex7Reassembler &r, std::uint32_t word0, std::uint32_t word1, std::vector< std::uint8_t > &out)
Convenience: feed a packet and collect the emitted payload (if any) into out.
Definition ump_sysex7_reassembler.hpp:397
@ other
Definition udev_monitor.hpp:26
@ size
Definition config.hpp:216
@ i
Definition formant_filter_bank.hpp:20