|
Operator SDK
Build MIDI processing and UI modes for the Operator device
|
This is where a mode does its real work: reacting to the MIDI that arrives and deciding what leaves. Everything else in the SDK supports this one function.
The firmware calls your process function on every pass of the real-time loop, whether or not any MIDI arrived. Each call hands you the batch of messages that landed on that pass, which is very often empty (see Core 1 cycle):
Each message is an OpMidiMessage, five bytes: status, data1, data2, port, and length. port names the input the message arrived on, 0..3 for the four physical inputs and 4 for USB, whichever kind of mode is reading it. A message another mode generated arrived on no input, so its port is 0xFF.
The status byte packs the message type in its high nibble and the channel in its low nibble:
The common channel-voice types:
| status & 0xF0 | Message | data1 | data2 |
|---|---|---|---|
| 0x80 | Note Off | note | velocity |
| 0x90 | Note On | note | velocity |
| 0xA0 | Poly Aftertouch | note | pressure |
| 0xB0 | Control Change | controller | value |
| 0xC0 | Program Change | program | (unused) |
| 0xD0 | Channel Aftertouch | pressure | (unused) |
| 0xE0 | Pitch Bend | LSB | MSB |
System real-time messages such as 0xF8 (clock), 0xFA (start), and 0xFC (stop) have no channel. For clock-driven timing, prefer get_pulse_count over watching for 0xF8 yourself. See Timing and Clock.
The default. If you do nothing to msgs[i], it passes through unchanged. A mode that only reacts to some messages simply leaves the rest alone.
Edit the fields in place. This transposes every note up a perfect fifth and leaves everything else untouched:
The declarative template (template-declarative/main.cpp) is a full transpose mode built exactly this way.
Clear the message's status. A message with a status of 0 is out of the batch, so nothing further sends it:
A probability filter is built on exactly this: drop a random share of the incoming notes and pass the rest through untouched.
Three calls send a 1 to 3 byte message, and each one behaves differently depending on which kind of mode calls it. Where each inserts the message into the chain is in How Operator Works.
send_midi sends to one output, where the modes loaded after yours can act on the message.
Returns false when the output cannot take it, which includes an output not set to pass that kind of message.
send_midi_from sends a message caused by one you were handed, so it travels wherever that one travels.
Returns false for an input out of range, or when there is no room for the message this pass.
send_midi_direct sends to the output without the modes loaded after yours seeing it. It suits a timing reference or a tap reporting what an output is doing.
Returns false on the same terms as send_midi, the message-type filter included.
System-exclusive messages go out through send_sysex, covered below. output_active reports whether an output can emit right now, which is useful before a round-robin.
A generator ignores msgs and count and emits from elapsed time, since process() is called on every pass. A clock generator emits one 0xF8 per elapsed pulse:
The full clock-generation contract, including the two gates that decide whether your clock reaches an output at all, is in Timing and Clock.
A mode can receive inbound system-exclusive (SysEx) messages and send its own, so it can read a bulk dump off the wire or answer a query it issued. Both directions need the same declaration, and the subsections below take them in turn.
Delivery takes two steps. The mode carries the HANDLES_SYSEX flag in its build declaration, and its handler goes to OP_MODE_REGISTER third, beside process:
HANDLES_SYSEX belongs on a mode that inspects, consumes, or generates system-exclusive messages. SysEx reaches the routed outputs on its own, so a mode with no interest in it carries no flag and exports no handler.
A SysEx message arrives in one or more calls:
The flags byte marks the boundaries, which can be read with the two bits:
The handler's port is the same input index a message in msgs carries.
The handler's return value states how each SysEx message is ultimately handled. Returning false lets it continue to the routed outputs, where the device emits it. Returning true stops it from being propagated. Nothing is sent on, and no later mode in the chain sees the rest of it.
The choice is made on the fragment flagged kSysExStart and held through the one flagged kSysExEnd, so a message goes whole whichever way it goes. Later fragments of a message a mode took reach that mode alone.
Fragments of a message nobody took keep reaching every handler, so a mode rewriting a long message tracks in its own state whether the message in flight is one it took, keyed by port when two inputs can be mid-message at once.
send_sysex is used for generating, either a reply the mode composes or a modified copy of a message it took. A transform reads what it was handed, takes it, and sends its own version.
Where that copy lands follows the rule send_midi already uses. An output mode is bound to one output and its copy goes there whatever index the call names, so it passes 0. A global mode names the output it wants. System-exclusive is one of the message types an output can be set to pass, so an output set to reject it takes nothing and the call answers -1.
A mode that takes a message owns delivering it. Returning true after a refused send drops the original along with the copy, so the message reaches the wire in neither form. A refusal on the opening call has queued nothing, so returning false there sends the original on untouched.
Every negative answer says something different, and they call for different things:
| Answer | What it means |
|---|---|
| kOutputBusy | the output could not take the fragment this pass and the message is still open, so the same fragment goes out again on a later process() |
| -1 | the message is over, so a new one opens before anything else is sent |
| kOutputTooLarge | the fragment can never fit, however long the wait, so the message needs splitting into calls of at most 253 bytes |
-1 is the generic failure every entry in the function table answers with. kOutputBusy and kOutputTooLarge carry their own values so they can be told apart from it, and all three fall through a plain n < 0 check.
Treating every negative answer as "try again" is the loop that spins, and the write that eventually lands would put payload bytes on the wire with no F0 in front of them.
A length of zero only means something on a call that closes the message, so a zero length without kSysExEnd is rejected.
An output sends one SysEx message at a time. If something else is already sending one there, send_sysex answers kOutputBusy instead of mixing the two together. Notes routed to that output wait their turn and go out after the closing F7. Starting a second message closes the first. MIDI clock, start and stop are the exception and travel inside the message, which is where a receiver expects to find them.
Whatever happens, every message reaching the output starts with F0 and ends with F7, so a receiver is never left waiting for an ending that never comes. A mode unloaded part-way through a message has that message closed off automatically. The one thing a receiver can notice is a message that arrived shorter than it was sent.
A message ends after a second of silence. A bulk dump that keeps writing holds its output for as long as it needs, and a 100 KB message is perfectly normal. What ends one early is a full second in which none of it reached the output, which no real sender produces and a stalled retry loop does.
A message passing through can be cut by the user. This one belongs to no mode. Turning an output off, unassigning an input from it, or clearing its SysEx filter bit while a message is in flight ends that message on that output with one F7, and no later part of it reaches that output even if the setting is turned straight back on. Fragments keep arriving at the handler throughout, so a handler gathering a dump sees the whole thing whatever the outputs are doing. The message that follows opens normally.
process() runs on the real-time core on every pass of its loop, for every loaded mode, which makes it the hot path. Keep it quick and predictable, with no blocking, no waiting, and no allocation. If you need lookup tables or per-note state, size them at compile time. A mode that repeatedly overruns its per-call time budget is disabled at runtime, so a generator should do close to nothing in the common case. See The Mode Mindset.