|
Operator SDK
Build MIDI processing and UI modes for the Operator device
|
Operator is a MIDI processor built on a dual-core ARM Cortex-M33 microcontroller. The two cores have a strict division of labor:
Modes always run on Core 1, in the middle of that real-time loop. The rest of this page explains what a mode is, where it sits in the MIDI signal chain, and how the two mode types differ.
A mode is a small program that changes how Operator processes MIDI. Modes are loaded at runtime from .opm files on the device's flash. You do not rebuild or reflash the firmware to add or update one, but load them into the device when booting into USB Drive mode. This is how Operator is extended. The core firmware stays small and proprietary, while modes (written against this public SDK) add new behavior.
A mode can:
Each mode is a position-independent ARM binary. It never includes firmware-specific code and never deals with the hardware directly. Instead, it talks to the device through a small and stable function-table ABI in the form of an OperatorApi instance handed to it at startup and accessible via op::api. This instance is used to send MIDI, read timing and parameters, draw to the display, and read storage. Because the underlying ABI is append-only, a mode compiled against an older SDK will always work on newer firmware. (The ABI is documented in reference-abi.md and the on-flash binary layout in reference-opm-format.md.)
There are two kinds of modes (global and output), distinguished by where in the signal chain they run. The next section shows that chain, and the section after it covers the two types in detail.
On every pass of the Core 1 loop, MIDI flows through a fixed pipeline, whether or not anything arrived. Modes plug into two points in it:
The two insertion points define the two mode types. A global mode runs at step 2 with a whole-device view, and an output mode runs at step 4, scoped to a single output. Everything in this pipeline executes on Core 1 within one bounded cycle, with no dynamic allocation and no blocking I/O.
A mode does not only transform what passes through, it can put a message into the chain, and the call it uses decides where that message is inserted. send_midi_from inserts it before step 3, so it is routed exactly like the message that caused it. send_midi inserts it inside step 3. The mode has already named the output, so there is nothing left to route, but that output still only takes the kinds of message it is set to pass. send_midi_direct inserts it after step 4, so the modes on that output never see it. The calls are in Processing MIDI.
A message carries the input it arrived on in its port field. One inserted by naming an output arrived on no input, so its port reads 0xFF.
System-exclusive messages ride this same chain, but on their own rail. They never appear in the process() batch, which carries only 1 to 3 byte messages. A mode that declares the HANDLES_SYSEX flag instead receives them through its on_sysex() handler at the very same two insertion points: a global mode sees every input's SysEx before routing, and an output mode sees only the SysEx routed to its output. Such a mode reads the message and can take it, in which case nothing is sent on and no later mode in the chain sees it. A message that no mode takes reaches the routed outputs unchanged, whether no SysEx-capable mode is loaded or the ones that are let it by. The handler runs on the same real-time core as process() and under the same rules, and the two share the mode's instance state, as process() and ui_gesture do. Receiving and generating SysEx is covered in Working with SysEx.
Core 1 runs a free-running loop and never waits for MIDI to arrive. One pass reads the inputs, runs the global modes, applies routing and merge and filter, runs the output modes, writes the outputs, and starts over.
Every active mode's process() is called on every pass of that loop, independent of incoming MIDI messages. The loop runs far faster than MIDI does, so most passes carry no messages at all and a batch of zero messages is the common case.
That fast cadence is what makes generator modes possible. An arpeggiator, an LFO, a master clock, or a step sequencer gets a call on every pass and decides for itself whether enough time has passed to act. A generator mode never waits for an incoming message to wake it up.
It is also what makes process() part of the hot path. It runs on every pass, for every loaded mode, so it has to be bounded and non-blocking. A mode that repeatedly overruns its per-call time budget is disabled at runtime, keeping slow or hanging modes from dragging the whole loop late.
What a mode does inside a single call is covered in Processing MIDI, and driving work from the clock is in Timing and Clock.
Modes can be built for two execution contexts, chosen at compile time via op_add_mode(... TYPE global|output ...):
A mode's type is baked into its OpmHeader mode_type. The firmware refuses to load a TYPE global mode from /output/ (or vice versa).
Beyond dropping .opm files into /global/ and /output/, you never manage files on the device by hand. The device keeps track of the rest so a user's setup comes back after a power cycle:
These files are the device's business, not yours. A mode reads and writes only inside its own /extras/{mode name}/ folder, through the storage API (see Storage and Files).
Understanding how your mode is loaded and called is in Mode Lifecycle, and how to think about the on-device constraints is in The Mode Mindset.