OpenGroove firmware ABI 0.1
This document explains include/opengroove.h in prose. The header is normative: if
the two disagree, the header wins and this document has a bug.
Contents: 1 Model · 2 Versioning and capabilities · 3 Lifecycle and phases ·
4 The device · 5 Buffers and display · 6 Events and outputs · 7 The process context
and transport · 8 Parameters · 9 Execution model, budgets and containment · 10 State ·
11 Packages and assets · 12 Building · 13 Checks (og verify) · 14 Event scripts ·
15 Migrating from 0.0 · 16 Extensions (racks) · 17 Rack manifests
1. Model
A firmware is one wasm32 module that implements one instrument. Polyphony, voices, multiple tracks and so on are internal to it. The same binary runs in three places:
- inside
<og-device>in the browser (runtime-web/), - headless in Node (
runtime-node/, whichog verify,og renderand CI use), - on Linux-class grooveboxes through the native runtime.
The rules that shape the whole ABI:
- The host owns the clock. It calls the firmware from a single thread, never concurrently, so a firmware needs no locks.
- The firmware owns its memory. The module has exactly one memory: unshared,
defined by the module itself (not imported), exported, and with a declared maximum
of at most 1024 pages (64 MiB). Every struct or buffer that crosses the boundary
lives in that memory, at an address the firmware exports through an
og_*_ptr()function. These addresses are stable for the life of the instance. - Struct negotiation is directional. Every extensible struct starts with
struct_size.- The writer caches the destination's capacity, writes the complete prefix it
knows (with reserved bytes zero), and records how many bytes it wrote. The host
writes
og_deviceandog_process_ctx, using the capacity the firmware preset withOG_DEVICE_INIT/OG_PROCESS_CTX_INIT. The firmware writesog_param_info. - The reader never writes to the struct. It ignores bytes beyond
struct_size, and treats fields that are missing, or only partly insidestruct_size, as zero. - Minimum sizes in 0.1:
og_device68 bytes,og_process_ctx64,og_param_info72 (the current size is 88), andog_control_info32. Anything smaller is rejected. - Size alone cannot tell a future reuse of a reserved field apart from zero, so any reuse is gated by a capability or an ABI revision.
- The writer caches the destination's capacity, writes the complete prefix it
knows (with reserved bytes zero), and records how many bytes it wrote. The host
writes
- Numbers.
- Every shared scalar is little-endian.
- Alignment: audio buffers,
og_deviceandog_param_infoare 4-byte aligned;og_process_ctxis 8-byte aligned;og_dirty_rectis 2-byte aligned. Hosts reject misaligned pointers. - WebAssembly floating-point semantics apply on every host. Native runtimes preserve them, including denormals, so a firmware must suppress denormals explicitly (add a tiny offset, or flush small values) rather than rely on the CPU's flush-to-zero.
- The firmware is freestanding. No libc, no file system, no threads, no blocking. The only imports are listed in section 11.
2. Versioning and capabilities
og_abi_version() returns (major << 16) | minor. For this revision that is
0x00000001, meaning 0.1.
- The minor number is the minimum host revision the firmware needs. A host that implements M.n runs every firmware of major version M and minor 1..n: newer hosts keep every older revision working explicitly. A host refuses a firmware whose minor number is above its own.
- Signatures are frozen. Within a major version, the signatures of existing exports and imports never change. New functionality arrives as new optional exports, new imports (which only firmwares declaring the newer minor may use), new capability bits or new event types.
- Version 1.0 will define an explicit compatibility path for 0.1 firmwares.
- ABI 0.0 firmwares have no
og_abi_versionexport. Hosts reject them with a migration hint (section 15).
Capabilities are one set of bits, used in three places:
| Bit | Name | og_device.caps: the host provides… |
og_caps() / og_required_caps(): the firmware uses / needs… |
|---|---|---|---|
| 0 | PARAMS |
a parameter UI and automation | og_param_* |
| 1 | STATE |
state persistence | og_state_* |
| 2 | ASSETS |
package assets via og_asset_* |
assets |
| 3 | MIDI_OUT |
og_midi_out that reaches a port |
MIDI out |
| 4 | CV |
CV and gate outputs (cv_out or gate_out > 0) |
CV and gates |
| 5 | TOUCH |
a touch screen (OG_TOUCH_* events) |
touch |
| 6 | TRANSPORT |
tempo and transport in og_process_ctx |
follows the host when OG_CTX_SYNC is set |
| 7 | AUDIO_IN |
audio input | audio input |
| 8 | IDLE |
calls to og_idle() |
exports og_idle() |
| 9 | NATIVE_DISPLAY |
(not used by hosts) | the framebuffer is in display_format (section 5) |
| 10 | KEYS |
a keyboard: OG_KEY_* and OG_TEXT events |
key or text input |
| 11 | CONTROLS |
control descriptors in og_controls_ptr() (section 4) |
reads them |
| 12 | TRANSPORT_CONTROL |
honours og_transport_request() |
may request transport/tempo changes |
| 13 | RACK |
(racks) writes og_instance_ptr() |
may run as one instance of several (section 16) |
| 14 | PORTS |
reads og_port_* (ids, channel layouts) and enforces REQUIRED |
declares extra ports |
| 15 | MIDI_PORTS |
routes MIDI by port: og_midi_in_port, og_midi_out_port |
uses MIDI ports |
| 16 | GRAPH_CTX |
writes og_graph_ctx_ptr() before every og_process |
uses the block id, generation and seed |
Optional and required capabilities:
- Optional capabilities (
og_caps()) degrade gracefully. A host calls a group of exports (params, state, idle) only if the firmware declares the bit and exports every function in the group; otherwise the host clears the bit and logs a warning. The imports for a missing host feature are safe to call: outputs are dropped, andog_asset_*returnsOG_ERR_UNSUPPORTED. A host never refuses a firmware overog_caps(). - Required capabilities: the optional export
og_required_caps()returns the bits the firmware cannot run without. If it is absent, the answer is 0. A host that lacks any required bit may refuse the load, with an error naming the missing capabilities (the SDK hosts do). - A firmware checks
og_device.capsbefore relying on a host feature. - Package manifests list capabilities by name for information only. Unknown names are ignored, so newer manifests still load.
Forward compatibility of values:
- Event types
0x80000000..0xffffffffare reserved for vendor extensions that are negotiated through a capability. Firmwares ignore event types they don't know. - Every flag field (
caps,og_process_ctx.flags, param flags and so on): writers set unspecified bits to 0, and readers ignore bits they don't know.
3. Lifecycle and phases
LOAD og_abi_version og_caps [og_required_caps] og_device_ptr og_process_ctx_ptr
host writes og_device ─► og_init() ─► buffer pointers (read once, stable)
INACTIVE [og_state_load] og_param_* og_render og_reset og_idle (may allocate, read assets)
og_activate(sample_rate, max_block) (may allocate, read assets)
ACTIVE per block: host fills og_process_ctx ─► og_event / og_midi_in (sorted) ─► og_process
between blocks: og_render · og_param_* · og_state_* · og_reset · og_idle (all real time)
og_deactivate() ─► INACTIVE| Export | Phase | Contract |
|---|---|---|
og_init() |
LOAD, once | og_device has been written. Allocate and set up rate-independent state. Return OG_OK (0); anything else is reported as an error and the firmware does not run. |
og_activate(sr, mb) |
INACTIVE | Prepare everything that depends on the rate. 1 ≤ mb ≤ og_device.max_block. Before calling it, the host resets sample_pos and og_time() to 0. Return 0. May be called again after og_deactivate, possibly with a new rate. |
og_process(frames) |
ACTIVE only | 1 ≤ frames ≤ the activated mb. |
og_deactivate() |
ACTIVE | Back to INACTIVE. The host clears its input queues. |
og_reset() |
any time after og_init |
Silence: drop voices, tails, delay lines and held-input state (pressed pads, buttons, notes). The host treats every input as released and clears its queues. Params, patterns, state and the firmware's own play/stop state stay as they are. Hosts call it on panic, power-off and queue overflow; the browser calls it when the device is powered off. |
og_render(), og_param_*, og_state_* |
any time after og_init |
While ACTIVE they follow the real-time rules (section 9). |
og_idle() (IDLE) |
any time after og_init |
Housekeeping. While INACTIVE it may allocate and read assets; while ACTIVE it follows the real-time rules. |
og_latency() (optional) |
after og_activate |
Output latency the firmware adds (lookahead and so on), in frames. Defaults to 0. |
A host may draw the screen of a device that has not been activated yet, for example a card on a web page before it is powered on.
4. The device
The host writes og_device (68 bytes, little-endian) to og_device_ptr() before
calling og_init().
| Offset | Field | Meaning |
|---|---|---|
| 0 | struct_size |
bytes written by the host |
| 4 | abi_version |
the host's OG_ABI_VERSION |
| 8 | caps |
host capabilities |
| 12 | profile |
GENERIC 0, KICK 1, FIRE 2. A layout hint only. |
| 16 | sample_rate |
expected rate; og_activate is authoritative |
| 20 | max_block |
the most frames og_process will ever receive (≤ 512) |
| 24 | display_w, display_h |
pixels; 0 × 0 means headless (section 5) |
| 28 | display_format |
the host's native format (og_display_format) |
| 32 | display_stride |
bytes per row in that format |
| 36 | buttons_present |
bit n set means button id n exists (ids 0–31) |
| 40 | pad_rows, pad_cols |
pad grid |
| 42 | knobs, knob_kind |
count; RELATIVE (encoder) or ABSOLUTE (pot) |
| 44 | audio_in, audio_out, cv_out, gate_out |
channel and output counts |
| 48 | has_jog, pad_pressure |
jog or select encoder; pads send PAD_PRESSURE |
| 50 | control_count (u16) |
descriptors written to og_controls_ptr(), or 0 |
| 52 | buttons_present_hi |
bit n set means panel button id 32 + n exists (0 from older hosts) |
knob_kind is only a summary: RELATIVE, ABSOLUTE, or MIXED when both kinds are
present. The truth for each knob is in its control descriptor.
What the reference hosts send:
| generic | kick | fire | |
|---|---|---|---|
| pads | 4 × 4 | 4 × 4 | 4 × 16 |
| knobs | 4, relative | 5, mixed: P1–P4 encoders + the touch strip (knob 4, absolute) | 4, relative |
| buttons present | 0–7 (0x00FF) |
the 32 panel buttons: 0–5, 8–11, 13–34 (SELECT is the jog's ENTER; no UP, DOWN or BANK) |
all 16 (SELECT is the encoder push) |
| display | 800 × 480, RGBA8888 | 1024 × 600, RGBA8888 (the KICK-class touch panel) | 128 × 64, MONO1 |
| caps (Node / browser) | params, state, transport, idle, touch (+ midi_out in Node) | same as generic | same as generic, without touch |
| audio | 0 in, 2 out | 0 in, 2 out | 0 in, 2 out |
| rate / max block | 48000 / 128 | 48000 / 128 | 48000 / 128 |
The kick profile is the real KICK-class panel, control for control: every button, the jog with its push, P1–P4, the touch strip, the 16 RGB pads and the touch screen, each with a descriptor (below). KICK-PROFILE.md lists them with their og ids, roles, SHIFT legends and the panel's own ids for bridges. The MASTER and REC level pots are in the analog audio path and are not reported to firmware.
Synthetic profiles. The headless runtime also has profiles that have no browser skin. They exist so CI can check that firmware adapts to hardware we don't own:
| Profile | What it is |
|---|---|
fm1like |
128×32 MONO1 screen, a 1×16 key bed (as pads), a push encoder, buttons PLAY, SHIFT, MODE1, MODE2, SELECT and BACK |
rotary |
128×32 MONO1 screen and one push encoder, nothing else |
touchonly |
480×320 touch screen only |
keys |
320×240 screen and a computer keyboard (KEYS) |
mixed |
272×480 RGB565 portrait screen, 6 knobs (3 encoders and 3 pots), a jog, 2×8 pads |
padsonly |
4×8 pads plus PLAY, STOP and SHIFT, no screen |
headless |
no controls and no screen: MIDI in, audio out |
fm1like approximates a small key-bed synth. It is not the specification of any
real device. Select these profiles with og verify --profile all or og render --profile rotary.
Control descriptors
The counts in og_device say how many controls a device has. Descriptors say what
each one is, so that mixed hardware can be described exactly: pots next to encoders,
a push encoder, a key bed, a touch strip.
If the firmware exports og_controls_ptr() and the host has OG_CAP_CONTROLS, the
host fills that storage before og_init:
static og_controls controls = OG_CONTROLS_INIT; /* firmware presets entry_size + capacity */
og_controls *og_controls_ptr(void) { return &controls; }
/* in og_init: for (i = 0; i < dev.control_count; i++) controls.entries[i] ... */The protocol is directional. The host reads the firmware's entry_size (at least
32) and capacity. It writes up to capacity entries, entry_size bytes apart, each
with its own struct_size, then sets og_device.control_count. The firmware only
reads.
| Field (32 bytes) | Meaning |
|---|---|
kind |
BUTTON 1, ENCODER 2 (relative), POT 3 (absolute 0..1), PUSH_ENCODER 4, PADS 5, TOUCH 6, KEYS 7 |
role |
a hint for default mappings: GENERIC 0, PARAM 1, DATA 2 (the main jog or data wheel), NAVIGATE 3, TRANSPORT 4, MODIFIER 5, PERFORM 6, MODE 7 (mode, bank, page and screen selectors), EDIT 8 (undo, copy, erase, save) |
event_id |
the id this control's events carry: the knob index, the og_button id, the first pad index, or OG_CTL_JOG_ID (0xFFFF) for a control that sends OG_JOG |
paired |
for a push encoder, the button id of its push; otherwise 0xFFFF |
count |
the number of pads, for PADS; otherwise 1 |
resolution |
encoders: detents per turn; pots: number of steps (0 = smooth); pads: velocity levels (0 = continuous) |
label |
a short panel label (UTF-8, at most 11 bytes) |
At most OG_MAX_CONTROLS (64) descriptors are written. The controls-echo test
fixture prints every profile's table.
Adapt to the counts, not the profile id. A firmware that reads pad_rows,
pad_cols, display_w and buttons_present will work on hardware that doesn't exist
yet. Don't assume 48 kHz either: the browser re-activates the firmware at the real
rate if the audio device refuses 48 kHz.
5. Buffers and display
The firmware returns all buffer addresses once, after og_init. A return value of 0
means "none".
| Export | Layout |
|---|---|
og_audio_out_ptr() |
planar float32: channel c starts at ptr + c * og_device.max_block. The stride is always og_device.max_block, independent of the activated block size and of frames. |
og_audio_in_ptr() |
the same layout, audio_in channels |
og_framebuffer_ptr() |
see below |
og_pad_leds_ptr() |
pad_rows * pad_cols RGB triplets, indexed like pads |
og_button_leds_ptr() |
OG_MAX_BUTTONS (16) brightness bytes, indexed by button id |
og_panel_leds_ptr() (optional) |
48 brightness bytes for panel buttons: byte n is button id 16 + n |
og_dirty_rect_ptr() (optional) |
an og_dirty_rect the firmware fills when og_render returns 1 (see below) |
Framebuffer formats. By default the framebuffer is RGBA8888 (display_w * 4
stride, alpha 255), and every host accepts it, converting to its panel if needed. A
firmware that declares OG_CAP_NATIVE_DISPLAY writes in the host's display_format
with display_stride instead. That saves memory and time on hardware, and the fire
profile's 128 × 64 MONO1 framebuffer is only 1 KiB. The native formats are:
RGB565: one little-endianu16per pixel.MONO1: one bit per pixel, MSB leftmost, 1 = lit.
Dirty rectangles use half-open pixel coordinates: [x, x+w) × [y, y+h), clipped
by the host to the display.
w == 0means the whole frame changed.w > 0withh == 0, or a rectangle that clips to nothing, means no pixels changed.- LED updates never depend on the rectangle.
- The host copies the pixels and LEDs it needs before it calls into the firmware again, so the firmware may start drawing the next frame right away.
Headless devices (display_w == display_h == 0) are allowed. The firmware returns
0 from og_framebuffer_ptr(), and og_render() still runs so that it can set LEDs.
Pads are indexed row * pad_cols + col, with row 0 at the top. The browser skins
show an LED colour over the pad surface, so a dim colour gives a faint tint.
6. Events
/* excerpt: opengroove.h */
void og_event(uint32_t type, uint32_t id, float value, uint32_t offset, float x, float y);
void og_midi_in(uint8_t status, uint8_t data1, uint8_t data2, uint32_t offset);| Type | Name | id |
value |
x, y |
|---|---|---|---|---|
| 1 | OG_PAD_DOWN |
pad index | velocity 0..1 | |
| 2 | OG_PAD_UP |
pad index | 0 | |
| 3 | OG_PAD_PRESSURE |
pad index | pressure 0..1 | |
| 4 | OG_KNOB |
knob index | relative: delta in detents (±, fractional); absolute: position 0..1 | |
| 5 | OG_BUTTON_DOWN |
button id | 1 | |
| 6 | OG_BUTTON_UP |
button id | 0 | |
| 7 | OG_JOG |
0 | delta in detents | |
| 8 | OG_TOUCH_DOWN |
finger 0..9 | 1 | display pixels |
| 9 | OG_TOUCH_MOVE |
finger | 1 | display pixels |
| 10 | OG_TOUCH_UP |
finger | 0 | display pixels |
| 11 | OG_PARAM_CHANGE |
param id | the new value, in the param's units | |
| 12 | OG_KEY_DOWN |
USB HID usage (keyboard page 7) | modifier bits | |
| 13 | OG_KEY_UP |
USB HID usage | modifier bits | |
| 14 | OG_TEXT |
one Unicode scalar value typed (text input) | 0 | |
| 15 | OG_TOUCH_CANCEL |
finger | 0 | last position |
The key and text events require OG_CAP_KEYS. Modifier bits: SHIFT 1, CTRL 2,
ALT 4, META 8.
- Keys and text are separate on purpose. Keys are for shortcuts and playing; text is for typing names, already composed by the OS: one code point per event, so text input is bounded.
OG_TOUCH_CANCELends a touch gesture that must not commit, for example when the OS takes the pointer over or rejects a palm. The firmware undoes whatever the gesture had started.
The button ids are: PLAY 0, STOP 1, REC 2, SHIFT 3, LEFT 4, RIGHT 5, UP 6, DOWN 7, MODE_1..4 8..11, BANK 12, SELECT 13, BACK 14, MENU 15. Give PLAY a sensible meaning,
because every host has it.
Ids 16–63 are panel buttons (OG_BTN_PANEL = 16): buttons with no conventional
meaning, described by their control descriptor's label and role (for the kick profile:
KICK-PROFILE.md). They arrive as ordinary OG_BUTTON_DOWN/UP
events. OG_MAX_BUTTONS stays 16 (the conventional ids and the length of
og_button_leds_ptr()); OG_MAX_BUTTON_IDS is 64. A firmware must bounds-check button
ids and ignore the ones it doesn't use; firmware built before panel buttons existed
already does if it indexes arrays only after checking id < OG_MAX_BUTTONS.
A button's SHIFT legend (GRID under MAIN, for example) has no id of its own: the
firmware sees SHIFT held and the button press.
Ordering and timing rules:
offsetis the frame inside the nextog_processblock at which the event happened. Hosts always send0 ≤ offset < frames. A firmware that still receives a larger offset treats it asframes - 1.- Events take effect at their offset. That includes derived values: a firmware recomputes coefficients after applying an event. It may smooth a change (to avoid zipper noise), but the smoothing starts at the offset.
- The host fills
og_process_ctxfirst, then delivers the block's events (og_eventandog_midi_intogether), then callsog_process. - Events arrive sorted by offset, with ties kept in arrival order.
- A late event (one whose block was already rendered) is delivered at offset 0 of the next block.
- No more than
OG_MAX_EVENTS_PER_BLOCK(256) events arrive per block. The rest wait in a deferred FIFO: in the following blocks they are delivered first, at offset 0, in their original order, ahead of those blocks' own events. - Overload. The host's input queue holds at most
OG_MAX_PENDING_EVENTS(4096) events. On overflow the host drops the whole queue and callsog_reset()(a panic). This guarantees nothing keeps sounding, instead of silently losing a release. Hosts log every panic and count it. - Hosts never send non-finite values; firmwares ignore them anyway.
- Firmwares ignore event types they don't know, and clamp every
id, touch coordinate and MIDI value they use.
Relative knob deltas can be large (a fast spin, or ±50 from the fuzzer) and fractional (mouse drags). Clamp after adding. One detent should move a parameter by roughly 1–2 % of its range.
MIDI input carries channel messages (0x80..0xEF). In 0.1, system messages are not
forwarded.
Browser input mapping:
| Input | Event |
|---|---|
| Mouse, touch or pen on a pad | PAD_DOWN / PAD_UP with velocity. Touch force gives the velocity and PAD_PRESSURE; with a mouse, the velocity follows the hit height. |
| Drag or scroll on a knob | KNOB |
| Drag on the jog wheel | JOG (24 detents per turn); the centre sends SELECT |
| Pointer on the touch screen | TOUCH_*; a pointercancel sends TOUCH_CANCEL |
Keys 1234 / qwer / asdf / zxcv |
pads |
| Space, arrows, Shift | PLAY, the arrow buttons, SHIFT |
| WebMIDI inputs | og_midi_in |
| The parameter panel | og_param_set |
Outputs
og_midi_out, og_cv_out and og_gate_out are legal only inside og_process.
offsetis a frame in the current block, and the host clamps it toframes - 1.- Calls outside
og_processare dropped and counted. - A block may emit at most
OG_MAX_OUTPUTS_PER_BLOCK(256) outputs, all three kinds together. The rest are dropped and counted. - A firmware that needs to send more should spread its output over several blocks.
7. The process context and transport
Before each og_process(frames), the host fills og_process_ctx at
og_process_ctx_ptr() (64 bytes):
| Field | Meaning |
|---|---|
frames |
equal to the argument |
sample_pos (u64) |
frames processed since og_activate (og_time() × rate) |
tempo_bpm (f64) |
quarter notes per minute (when TEMPO_VALID) |
beat_pos (f64) |
quarter notes since the host's transport origin, at the presentation time of frame 0 (when that sample reaches the output). The host aligns the origin to a quantum boundary of its timeline (for example, a Link session); it is not a song position. Negative only during a count-in. |
ts_num, ts_den |
time signature |
flags |
TEMPO_VALID, PLAYING, RECORDING, EXT_CLOCK, SYNC, DISCONTINUITY, LINKED, COUNT_IN |
quantum (f64) |
beats per phase cycle, usually one bar: quantized starts land on its multiples. 0 means unknown. |
peers (u32) |
with LINKED: how many other participants share the tempo (e.g. Link peers). Otherwise 0. |
Who drives the sequencer. A groovebox firmware usually has its own sequencer and its own PLAY button, and the context tells it which clock to follow:
OG_CTX_SYNCclear (the default): the firmware free-runs.PLAYtoggles its own transport, and the tempo is its own parameter.tempo_bpmis only a hint.OG_CTX_SYNCset: the host is the transport master. A firmware that declaresOG_CAP_TRANSPORTdoes the following:- It plays while
PLAYINGis set, and stops when it is cleared. A stop should release notes rather than hang them. - It takes its tempo from
tempo_bpm. - It derives its position from
beat_pos. Within a block, beat =beat_pos + i * tempo_bpm / 60 / sample_rate, and a 16th-note step changes wheneverfloor(beat * 4)changes. - It re-derives its position whenever
beat_posjumps.
- It plays while
EXT_CLOCK: the tempo and position come from outside, for example MIDI clock.- The time signature (
ts_num,ts_den) is advisory. Firmwares may assume their own bar length.
Rules hosts guarantee:
SYNCimpliesTEMPO_VALID.tempo_bpmis finite and greater than 0.beat_posis finite. It is negative only whileCOUNT_INis set, together withPLAYING: that is the pre-roll before a quantized start at beat 0. During it a firmware can play a count-in or prepare events, but its sequence does not start until beat 0. The toolkit'sog_seqand the examples behave this way.- Hosts without an external timeline report their render timeline: in the SDK's Node and browser hosts, "presentation" is the moment the block is rendered. Hosts that share a timeline (Link) offset it by their output latency.
quantumis greater than 0 when known. Firmwares with their own bar length can align phases with it.- Transport changes happen only at block boundaries.
OG_CTX_DISCONTINUITY(bit 5) is set on any block whosebeat_posdoes not continue the previous block: the first block afterog_activate, and every seek, loop or rewind. On that block a following firmware re-derives its position and releases notes that would hang. Hosts never callog_resetbecause of a seek.- Stop and rewind:
- Stop clears
PLAYINGand keepsbeat_pos, so a later play continues from there without a discontinuity. - Rewind sets
beat_posto 0 and flags a discontinuity.
- Stop clears
- Leaving sync: when
SYNCclears, a following sequencer stops. It does not start free-running on its own. - Musical time advances only with rendered frames. An underrun in the audio output does not move the transport.
Requests from the firmware. With OG_CAP_TRANSPORT_CONTROL, a firmware can ask
the host to change its transport:
/* excerpt: opengroove.h */
void og_transport_request(uint32_t flags, double tempo_bpm);flagscombineOG_TREQ_PLAY1,OG_TREQ_STOP2 andOG_TREQ_TEMPO4. Use it for a tempo param, or for a menu "Play" while the host is the transport master.- The host applies a request at the next block boundary, and the last request before a block wins. The host may also refuse it, for example when an external clock rules.
- Without the capability, the call is a no-op.
- The SDK hosts honour requests.
The browser host's transport. The element has tempo and sync attributes and a
setTransport({ playing, tempo, sync, rewind }) method. In sync mode, the device's own
PLAY button toggles the host transport and STOP stops and rewinds it. Both are
still delivered to the firmware as events.
The Node host's transport. Event scripts change it with {"transport": {...}}
(section 14).
8. Parameters
A firmware that declares OG_CAP_PARAMS describes its parameters with this struct:
/* excerpt: opengroove.h */
typedef struct og_param_info {
uint32_t struct_size;
uint32_t id; /* stable across versions; never reuse an id */
uint32_t flags; /* STEPPED, AUTOMATABLE, HIDDEN */
float min, max, def; /* plain units */
char name[32];
char unit[8];
float step; /* UI step (0 = continuous) */
uint16_t curve; /* LINEAR, LOG, EXP */
uint16_t label_count; /* enumeration labels (<= 64) */
uint32_t labels; /* firmware address of the labels, or 0 */
uint32_t _reserved[3];
} og_param_info; /* 88 bytes (72 minimum) */
uint32_t og_param_count(void);
const og_param_info *og_param_info_ptr(uint32_t index); /* 0 if out of range */
void og_param_set(uint32_t id, float value);
float og_param_get(uint32_t id);- UI metadata (fields after byte 72; older 72-byte infos read them as zero):
step: the UI step in plain units. 0 means continuous;STEPPEDimplies 1.curve:LINEAR,LOG(equal ratios: Hz and times; needsmin > 0) orEXP(fine control nearmin). It only changes how a UI maps a slider or knob position; the parameter's value semantics stay the same.label_countandlabels: for aSTEPPEDenumeration, up to 64 labels of 16 bytes each, NUL-padded, for the valuesmin .. min + label_count - 1.labelsis a firmware address. The web panel shows these parameters as a menu, andog_fw.h'sOG_FW_PARAM_EXfills them in.
- Ids are stable. Saved states, automation and MIDI mappings refer to them. Never renumber an id, and never reuse one for a different meaning.
- Indexes are only for enumeration.
og_param_info_ptrreturns a pointer into firmware memory, usually astatic consttable, for the same reason as the other*_ptrexports: the host has no memory of its own to offer. - Values are in plain units (BPM, Hz, %, semitones).
- NaN and ±Inf are ignored, and the value stays as it was. Hosts never send them.
- For
STEPPEDparameters, the value first rounds to the nearest integer (ties away from zero). - Every value is then clamped to
min..max. og_param_getreturns the effective value.
- When to use
og_param_setand when to useOG_PARAM_CHANGE:og_param_setis called between blocks (UI changes, restoring state).- The
OG_PARAM_CHANGEevent is the sample-accurate path, inside a block, used for automation. - The two must have identical effect, derived coefficients included. An
OG_PARAM_CHANGEat offset 0 produces bit-identical audio to anog_param_setjust before the block. The SDK tests check exactly this for every parameter of every example.
- Physical controls drive the same values. Turning a knob changes the parameter
it controls, and
og_param_getreports the new value. - Reserved host-sequencing ids (convention). A firmware that a host sequences
note by note (an "external" sequencer mode, for example a Studio Synth App slot) gives
these ids these meanings or leaves them unused; it never gives them another meaning:
- 50
Next Glide(ms, float): the glide time for the NEXT note-on, sent just before it at the same frame. The note-on consumes it (back to 0). Glide > 0 while a note sounds = legato: no retrigger, the pitch glides. Declared,HIDDEN. - 51
Slide To(absolute MIDI pitch, fractional, plain units): a command, sent right after aNext Glideat the same frame. The sounding note glides to this pitch over that glide time, linear in semitones. No retrigger, no envelope restart; the note keeps its identity, so its own note-off still ends it. It consumes theNext Glide. With nothing sounding it does nothing. It is not state: a firmware may leave it undeclared (then it is never saved, recalled or shown), and a host never recalls it. A firmware without it ignores the event (the slide is silent; no stuck or extra note).
- 50
- The web panel.
<og-device panel>renders a generic slider panel from the descriptors (hidden parameters are left out). It polls values at about 10 Hz, so knob turns show up in the panel.
9. Execution model, budgets and containment
Execution model
"Between blocks" does not mean "not real time". While a firmware is activated, no
callback may block, allocate or call memory.grow. That covers og_process,
og_render, og_param_*, og_state_*, og_reset, og_idle and the event calls.
- Allocation,
memory.growand asset reads are allowed only inog_init, inog_activate, and inog_idlewhile inactive. - Hosts check memory size after every guest call made while activated. Any growth is
reported as a
realtimeerror, and the firmware is stopped.
Producer and consumer. The audio device's callback never calls the firmware. A producer thread renders blocks ahead into a ring buffer, and the audio callback only drains that ring.
- In the browser, the producer is the Worker and the consumer is the AudioWorklet.
- Native hosts must use the same structure: a producer thread feeding a lock-free ring.
Housekeeping. Rendering, idle calls, parameter polling and autosave run on the producer thread too.
- They run only while the ring has headroom (in the browser: at least half the render-ahead target is buffered). Otherwise they are deferred to a later pass.
- Autosave is best-effort: it is skipped under load, never forced.
og_rendermust never advance musical state. A host may skip it at any time, and the sound must not change as a result.
Budgets
The checks in og verify are measured on the checking machine and leave headroom for
slower devices (section 13).
| Callback | Budget | Notes |
|---|---|---|
og_process |
≤ 50 % of the block duration on the target device (128 frames @ 48 kHz = 2.67 ms → ≤ 1.33 ms) | og verify: p99 ≤ 25 %, mean ≤ 10 % |
og_event, og_midi_in, og_param_set/get, og_reset |
O(1), ≤ 0.5 ms | |
og_render |
≤ 2 ms, at ≤ 60 Hz | |
og_state_save, og_state_load |
≤ 0.5 ms while activated | |
og_idle |
while activated: ≤ 0.5 ms; while inactive: ≤ 5 ms per call | only the inactive call may allocate or read assets |
og_init, og_activate |
≤ 1 s | not real time |
All callbacks share one thread. The individual budgets are not enough on their
own: everything a host calls between two blocks, plus og_process itself, must fit
in the block period. Hosts schedule housekeeping inside the headroom the ring buffer
gives them (see "Housekeeping" above) and defer the rest. So keep og_render and
og_idle well below their caps on slow devices.
Overruns. If the producer falls behind, the consumer plays silence for the missing frames (an underrun), counts it, and keeps going. The firmware is never re-entered, the block size never changes, and musical time does not advance during the gap.
- The browser shows underruns in
device.stats.underruns. - A host may stop a firmware that overruns persistently, for example on more than half
of the blocks in a 5-second window, and report it as a
realtimeerror.
Watchdogs. Every entry point has a budget, including og_init. Pure wasm can't be
interrupted from inside the thread that runs it, so:
- Native hosts use Wasmtime fuel or epoch interruption, or WAMR's equivalent, or a process watchdog. Fuel and epochs cannot interrupt a host import while it runs, which is why every import is bounded and non-blocking.
- The browser terminates the firmware's Worker when a load takes more than 30 s, or when a powered device's Worker misses its heartbeat (sent every 250 ms) for 10 s. It reports the termination as an error.
og verifyruns each file in a child process with a timeout (120 s by default,--timeout).
Containment
Hosts enforce these limits when they load a module. Each violation fails the load with a message saying which limit was broken.
| Limit | Rule |
|---|---|
| Module size | ≤ 16 MiB |
| Memory | exactly one; defined by the module (not imported); exported; not shared; not 64-bit; declared maximum ≤ 1024 pages (64 MiB) |
| Tables | declared maximum ≤ 65 536 elements; no imported tables |
| Start section | not allowed (initialise in og_init) |
| Imports | exact names and signatures from section 11; no globals, tables or memories |
| Exports | every og_* export has the exact signature from the header |
| Stack | the firmware's linker setting (64 KiB with og build); native hosts also bound the native stack |
| Input queue | 4096 events (section 6); outputs: 256 per block |
fd_write |
≤ 16 iovecs and 4096 bytes per call |
random_get |
≤ 65 536 bytes per call |
| Packages | section 11 |
Uploads. The hub accepts WebAssembly only. Native runtimes compile ahead-of-time caches locally. Serialized native code from outside is never trusted.
Latency, as measured
Measured on Chromium 140, Apple M-series, macOS 26, 48 kHz with 128-frame blocks, using
npm run test:browser.
| Host | Render-ahead | Plus | Notes |
|---|---|---|---|
| Browser, SharedArrayBuffer ring | 10.7–13.3 ms average fill (target 768 frames = 16 ms) | the AudioContext's output latency (device.stats.outputLatencyMs): 5.3 ms on headless Chromium's virtual output; real devices differ |
Input is delivered at the start of the next rendered block |
| Browser, postMessage fallback | similar fill, more jitter | the same | Used when the page isn't cross-origin isolated |
Node (og render, og verify) |
none (offline) | Event timing is sample-accurate | |
| Native runtime | owned by the native runtime |
Your own numbers: device.stats reports bufferedMs, outputLatencyMs, underruns
and firmwareLatencyMs.
10. State
A firmware that declares OG_CAP_STATE exports these:
/* excerpt: opengroove.h */
uint32_t og_state_size(void); /* capacity: the largest blob */
uint8_t *og_state_ptr(void); /* a firmware buffer of og_state_size() bytes, used both ways */
int32_t og_state_save(void); /* serialise into og_state_ptr(): bytes written, or < 0 */
int32_t og_state_load(uint32_t len); /* parse len bytes at og_state_ptr(): OG_OK or < 0 */Every blob starts with a 16-byte little-endian envelope. Its layout is permanent; a different envelope would need a different magic number.
/* excerpt: opengroove.h */
typedef struct og_state_header {
uint32_t magic; /* OG_STATE_MAGIC, "OGST" */
uint32_t fw_tag; /* the firmware's own identifier, e.g. a FOURCC */
uint32_t schema; /* the firmware's state schema version, starting at 1 */
uint32_t size; /* total bytes including this header */
} og_state_header;What the host does:
It stores and looks up state by package identity: the manifest
id, or the firmware's URL or path for a bare.wasm. It never usesfw_tag, which is only a collision-prone safety check.Before loading, it checks
magicand thatsizeequals the blob length and is no larger thanog_state_size().It copies the blob into
og_state_ptr()and callsog_state_load(len).It reports load errors instead of ignoring them:
- the browser logs
[og] … state not restored: <reason>as anog-logevent; og verifyreports them per profile.
- the browser logs
It may save at any time between blocks. Autosave is best-effort and skipped under load. The browser autosaves about every 2 s, and that is what carries your pattern across live reloads in
og dev, even when the old build crashed.
What the firmware does:
- Check
fw_tagandschema. ReturnOG_ERR_FORMATfor a foreign or corrupt blob, andOG_ERR_VERSIONfor an unknown schema.schemaversions the payload, not the envelope. When the layout changes, bumpschemaand either migrate old blobs or reject them.og_state_size()must also be large enough for every older schema you migrate from. - Load transactionally. Parse and validate into a temporary, and commit only on success, so that a failed load leaves the state exactly as it was.
- Zero any padding inside serialized structs, or use structs without padding (the
examples' structs have none;
-Wpaddedshows it). - Never trust lengths inside the blob.
- Save the document (params, patterns, settings), not transient state such as the
play state, playhead, held pads or voices.
og verifychecks that save, then load into a fresh instance, then save again produces identical bytes.
11. Packages and assets
A firmware is shipped either as:
- a bare
.wasm, which is fine for anything without assets, or - an
.ogpkg: a zip containingmanifest.json,firmware.wasmandassets/….
{
"id": "tape-drums",
"name": "Tape Drums",
"version": "1.2.0",
"abi": "0.1",
"caps": ["params", "state", "assets", "transport"],
"description": "Sampled drum machine.",
"author": { "name": "You", "url": "https://example.com" },
"license": "Apache-2.0",
"profiles": ["generic", "kick", "fire"],
"assets": [
{ "path": "assets/kick.wav", "license": "CC0-1.0" },
{ "path": "assets/snare.wav", "license": "CC-BY-4.0" }
],
"licenses": [{ "name": "Snare sample by …", "license": "CC-BY-4.0", "url": "https://…" }],
"outcomes": ["audio"]
}The asset id is the index in assets. The host loads every asset before
og_init, and the firmware reads them synchronously:
/* excerpt: opengroove.h */
int32_t og_asset_size(uint32_t id); /* bytes, or < 0 */
int32_t og_asset_read(uint32_t id, void *dst, uint32_t offset, uint32_t len); /* bytes read, or < 0 */- These calls work only in named phases:
og_init,og_activate, andog_idlewhile inactive. Everywhere else, includingog_processand anog_idlewhile activated, they returnOG_ERR_RT. - EOF: a read that reaches the end returns a short count. Reading at exactly the
size returns 0. An offset beyond the size, or an unknown id, returns
OG_ERR_RANGE. - A bare
.wasm, or a host withoutOG_CAP_ASSETS, returnsOG_ERR_UNSUPPORTED. - Asset bytes are the raw file contents, and decoding (WAV and so on) is up to the firmware.
Package rules, enforced by the loader and by og pack:
- Paths are canonical and relative:
/separators, every asset underassets/, segments made only ofA–Z a–z 0–9 . _ -.- These are rejected:
.and..segments, absolute paths, drive letters, backslashes, empty segments, symlink entries, and duplicate entries or manifest paths.
- These are rejected:
- Quotas: at most 1024 entries; each entry at most 32 MiB uncompressed; at most
128 MiB in total;
firmware.wasmat most 16 MiB.- Quotas are enforced while expanding: inflation stops as soon as an entry's output exceeds its declared size, so a decompression bomb never gets allocated.
- Manifest: unknown capability names are ignored.
Building packages. og build writes build/<id>.ogpkg next to the .wasm
whenever og.json lists assets, and og pack writes one on demand. In both cases
abi and caps are read from the binary, so the manifest can't drift from the code.
<og-device src>, og verify and og render accept either format and tell them
apart by their header bytes.
Imports
Module og |
Signature | |
|---|---|---|
log |
(ptr, len) |
one UTF-8 line, sent to og-log, stderr or the console |
midi_out |
(status, d1, d2, offset) |
MIDI_OUT |
cv_out |
(index, volts, offset) |
CV; clamped to −5..+10 V |
gate_out |
(index, on, offset) |
CV |
time |
() -> f64 |
seconds since og_activate at the start of the block |
transport_request |
(flags, f64 tempo) |
TRANSPORT_CONTROL |
asset_size, asset_read |
see above | ASSETS |
wasi_snapshot_preview1 is shimmed so that wasi-sdk builds work:
fd_write: fds 1 and 2 are line-buffered intolog.random_get.clock_time_get: realtime is the wall clock; the other clocks are the audio clock.proc_exit: the firmware stops and the host shows an error.environ_*andargs_*: empty.- Other
fd_*andpath_*calls returnENOSYS.
Any other import fails at load time, with a message naming it.
12. Building
og build compiles every .c file in a project directory (one with og.json):
clang --target=wasm32 -std=c11 -nostdlib -ffreestanding -O2
-mbulk-memory -mnontrapping-fptoint -msimd128 -fno-math-errno -fvisibility=hidden
-I include -I <sdk>/include -I <sdk>/examples/common
-Wl,--no-entry -Wl,-z,stack-size=65536 -Wl,--max-memory=67108864 -Wl,--strip-debug-mbulk-memorylowersmemcpy,memsetand struct copies tomemory.copy/memory.fill.-mnontrapping-fptointmakes float-to-int conversions saturate instead of trapping.-msimd128enables wasm SIMD. Browsers, Node and Wasmtime support it; on aarch64 it maps to NEON.og build --no-simdbuilds without it, for hosts that lack SIMD.- The project's sources are linked against the toolkit archive (
libog.a); see docs/TOOLKIT.md. - Undefined symbols are link errors (use
og_dsp.h, not libm). og doctorshows which clangog buildfound (OG_CLANG,EMSDK,WASI_SDK_PATH, Homebrew LLVM, thenPATH).
13. Checks (og verify)
og verify fw.wasm|fw.ogpkg runs every check below, for each profile, and prints JSON.
| Check | Pass condition |
|---|---|
boots |
Only allowed imports, every required export, ABI 0.1, og_init and og_activate return 0, and every buffer is inside memory. |
fuzz |
10 s of seeded random input runs without a trap, and every sample is finite. The input covers pads (with bursts and pressure), knobs (±50 and fractional), every present button, jog, touch, random MIDI, param set and change (including out-of-range values), host transport toggles, tempo changes and jumps, og_reset, and state round trips. |
audio |
With the transport untouched, tapping up to 16 pads (or, on a device without pads, playing 8 MIDI notes) produces the outcomes the firmware declares. "outcomes" in the manifest / og.json can contain audio (output above −60 dBFS), midi (og_midi_out calls), cv (CV or gate outputs), or be ["none"]. The default is ["audio"]. Override it with og verify --outcomes midi. |
deadline |
og_process time divided by the block duration has p99 ≤ 0.25 and mean ≤ 0.1, measured over 5 s of PLAY with pad hits after 0.5 s of warm-up (best of up to 3 attempts, to filter scheduler noise). Per-call budgets also hold (means exclude the single slowest call): og_init ≤ 1 s; og_render mean ≤ 2 ms; event, param, reset, state and (activated) og_idle calls mean ≤ 0.5 ms. These are measured on the checking machine: a headroom check that leaves margin for slower hardware, not a guarantee for any particular device. |
levels |
Peak ≤ 1.0 (0 dBFS) during pads, PLAY and fuzz; pad RMS ≤ −6 dBFS; |DC| ≤ 0.02. |
state |
Only for firmwares declaring STATE. Save, load into a fresh instance and save again: the bytes are identical and the params are restored. Loads are transactional: a truncated blob, an unknown schema (which must return OG_ERR_VERSION) and a foreign fw_tag are rejected, and each rejection leaves the state byte-identical. A garbage blob with a valid header does not trap. null when not declared. |
effect |
Only for EFFECT firmwares (null otherwise). |
- Impulse, sine, noise and silence at the input all give finite output.
- After the input stops, the output is at or below 1e-5 within
og_tail_frames(plus latency). - There are no subnormal output samples.
og_latencyis stable: the same after 0.5 s of processing and after re-activation at the same rate.- Delay-only effects (manifest
"delay_only": true, or--delay-only: linear-phase processes whose dry path is a pure delay, such as delay lines and lookahead limiters): a unit impulse arrives exactly atog_latency. For every other effect (filters, reverbs, saturators) a correlation peak or impulse peak is not a latency, so the measured value is reported (latency_check: "reported"), never failed. Hosts compensate with the declared value. og_resetsilences the tail immediately.- Processing a decaying tail is at most 4× slower than processing signal (the denormal check). |
|
params| Only for firmwares declaringPARAMS. Ids are unique, min ≤ default ≤ max, set and get round-trip at min, max and default, and out-of-range values are clamped.nullwhen not declared. |
The checked record is what goes into the hub registry:
"checked": { "boots": true, "audio": true, "fuzz": true, "deadline": true, "levels": true,
"state": true, "params": true, "sha256": "…", "abi": "0.1",
"runtime": "og-runtime-node@0.3.0", "checked_at": "2026-10-27" }A record is valid only for that exact sha256 and runtime version. Rebuilding the
firmware or upgrading the checker means checking again. The per-profile metrics
(peaks, RMS, real-time factors, state size, param count) and errors explain every
failure.
14. Event scripts
og render fw.wasm --script events.json --out out.wav --png screen.png:
{
"profile": "kick",
"seconds": 8,
"events": [
{ "at": 0, "button": "PLAY" },
{ "at": 0.5, "pad": 5, "velocity": 0.9, "length": 0.2 },
{ "at": 1, "knob": 0, "delta": 4 },
{ "at": 1.5, "jog": -2 },
{ "at": 2, "midi": [144, 60, 100] },
{ "at": 2.5, "touch": [400, 240], "length": 0.1 },
{ "at": 3, "param": 1, "value": 128 },
{ "at": 4, "transport": { "sync": true, "tempo": 100, "playing": true } },
{ "at": 6, "type": "pad_down", "id": 3, "value": 1 }
]
}atandlengthare in seconds.pad,buttonandtouchare a press followed by a release.typesends one raw event.transportchanges the host transport (playing,tempo,sync,rewind).- Without
--script,og renderpresses PLAY and records 8 s. --pngwrites the final screen as RGBA. MONO1 and RGB565 framebuffers are converted, and 128 × 64 screens are scaled 4×.- The summary lists the final parameter values.
15. Migrating from 0.0
| 0.0 | 0.1 |
|---|---|
og_init(const og_device *dev) |
static og_device dev = OG_DEVICE_INIT; plus og_device_ptr(), then og_init(void) |
rate-dependent setup in og_init |
move it to og_activate(sample_rate, max_block); add og_deactivate() and og_reset() |
buttons (count), has_touch |
buttons_present (bitmask), caps & OG_CAP_TOUCH |
og_save(buf, cap), og_load(buf, len), og_scratch_ptr(len) |
og_state_size(), og_state_ptr(), og_state_save(), og_state_load(len) with an og_state_header |
| knobs only | the same knobs, now also exposed as params (og_param_*) |
| — | og_abi_version(), og_caps(), og_process_ctx_ptr() |
16. Extensions
These are planned and not part of 0.1: multiport MIDI and per-note identities (MPE already works today over channel MIDI), bounded SysEx, preset enumeration, multi-bus audio, text input, display-format negotiation and GPU or vector drawing commands. The rules are fixed now, so that adding them later doesn't break anything:
- The baseline stays. Every extension is optional and negotiated. Stereo
og_audio_out, RGBA8888 framebuffers, channel MIDI and the existing events keep working on every host, and an extension adds to them without replacing them. - Negotiation goes through capabilities. An extension gets a capability bit. The
firmware declares it in
og_caps()(optional) orog_required_caps()(required), and the host announces it inog_device.caps. New exports in an extension are optional and are called only when both sides declare the bit. - New imports raise the minor version. A firmware that imports an extension function declares the minor revision that introduced it, so older hosts refuse it cleanly at load time instead of failing to link.
- Event types: new standard types come from the next free number below
0x80000000. Vendor-specific types use0x80000000..0xffffffff, and are used only after a capability has been negotiated. - New structs start with
struct_sizeand follow the directional rules in section 1. Reserved fields are reused only behind a capability or a new revision. - The state envelope never changes. Extensions version their own data inside the firmware payload.
Racks: several instances, several cores
Multi-core hosts get there with more instances, not threads inside a firmware. The ABI keeps its one-thread model: a firmware never sees concurrency. A host runs a rack, a graph of single-threaded instances spread over cores, and combines their outputs.
Normative in 0.1 (frozen before release):
- Kind.
og_kind()(and the manifest's"kind"):INSTRUMENT0 (the default when absent),EFFECT1,MIDI_FX2,UTILITY4.CONTROLLER3 is reserved for 0.2.- An effect gets
audio_in = 2and turnsog_audio_in_ptr()intoog_audio_out_ptr(), stereo in to stereo out. MIDI_FXandUTILITYare zero-audio: the host writesaudio_in = audio_out = 0,og_audio_out_ptr()may return 0 and is never read. AMIDI_FXturns MIDI in into MIDI out (arpeggiators, transposers, chord generators); aUTILITYproduces MIDI, or nothing, from its own state (sequencers, clocks). Both emit withog_midi_out/og_midi_out_port. In a rack a zero-audio instance starts a track that has no effects; its track is silent in the mix.
- An effect gets
- Opt-in. A firmware that does not declare
OG_CAP_RACKalways runs as a single instance on its own, never inside a rack. - Instance identity and serialisation. With
OG_CAP_RACK, the host writesog_instance_infotoog_instance_ptr()beforeog_init, following the same directional rules asog_device. It carriesinstance_id,slotandslot_count, and the flagsHAS_FOCUS(the device's controls and screen belong to this instance) andTRANSPORT_OWNER(only itsog_transport_requestcalls are honoured).instance_idis persistent: it is saved with the rack and stays the same when the rack is reloaded (inrack: 1, the instance's position in manifest order). Automation addresses (instance_id, param id).- "Serialised per instance" means: the host never calls into one instance concurrently, and all of its callbacks are ordered. They may run on different threads over time; the host may migrate an instance between workers or cores (worker replacement and resizing do). A firmware keeps no thread-local state.
- Ports.
og_port_count,og_port_info_ptrandog_port_buffer_ptrdescribe ports beyond the main stereo pair (og_audio_in_ptr/og_audio_out_ptrstay the main audio ports; MIDI port 0 isog_midi_in/og_midi_out).- Stable ids and layouts. An entry whose
struct_sizeis at least 36 is anog_port_info_ext:idis the stable port id that edges and saved racks refer to, andchannel_maskis the layout (OG_CH_MONO0x1,OG_CH_STEREO0x3; for MIDI a 16-bit channel filter, 0 = all). A 28-byte entry hasid= its index and the layout ofchannels. Ids are unique per direction and type. - Negotiation (
OG_CAP_PORTShosts). An optional port the host cannot connect is left unconnected: silence in, output discarded. A port flaggedOG_PORT_REQUIREDthat it cannot connect fails the load, with an error naming the port. A required sidechain is never silently ignored.
- Stable ids and layouts. An entry whose
- MIDI ports (
OG_CAP_MIDI_PORTS). The exportog_midi_in_port(port, status, data1, data2, offset)receives MIDI for input portport(its id); port 0 may come here or throughog_midi_in. The importog_midi_out_port(port, …)sends on output portport(og_midi_outis port 0), with the same per-block limits; a port with no connection drops the message. Merged MIDI is ordered by offset, then by edge order, then by emission order, in bounded queues (section 6 limits apply per instance). - Graph context (
OG_CAP_GRAPH_CTX). The host fillsog_graph_ctx_ptr()before everyog_process:generation(changes when the host rebuilds the graph: restore, worker replacement, resize),block_id(calls sinceog_activate) andseed. Useseedfor all randomness. It is fixed per (rack seed, persistent instance id), so a rack renders the same on any host and thread count. Inside a rack the SDK hosts also feed WASIrandom_getfrom that seed. - Tail and latency.
og_tail_frames()is how long the output can continue after the input went silent. 0 means no tail;0xFFFFFFFFmeans unbounded, and the host keeps processing.og_latency()is read afterog_activateand stays constant until the nextog_activate. It is algorithmic latency (lookahead, linear-phase filters, block-based processing), never an intentional echo or a deliberate feedback delay, which must not be compensated away. Hosts read it again after applying a rack's params and state (they re-activate the instance).
- Graph timing.
- Edges are same-block. An instance processes block n after everything it depends on has processed block n, so audio, sends and routed MIDI add 0 latency.
- Feedback is possible only through an explicit delay of at least one block; other cycles are rejected.
- Delay compensation: at every merge (including sidechains) the host delays the earlier inputs to the latest arrival, using the declared latencies. The graph's latency is therefore the largest sum of declared latencies along any path.
- One clock. Every instance receives the same
og_process_ctxfor a given block. - Determinism. For identical inputs, a rack's output is bit-exact whatever the
number of threads:
- each instance runs single-threaded, with its seed;
- summing happens in a fixed edge-index order, in float32, on one thread;
- event and MIDI ordering is stable (above);
- relaxed-SIMD opcodes are rejected at load. Fault handling is the exception: a block dropped because a worker was late (below) depends on timing.
- Budgets and containment. Budgets (section 9) apply per instance, on the core it
runs on. The host owns the joint deadline and completes every block within it:
- it never waits past the block's budget; the AudioWorklet (or audio callback) never waits at all;
- a late worker's output for that block is discarded: its instruments contribute silence, its effects are bypassed with their latency preserved (the SDK hosts run a whole track per worker, so the track is silent for that block);
- the late worker is retired (terminated, its buffers never read or reused) and replaced; its instances are restored from the last state checkpoint, and results carry the graph generation so stale ones are rejected;
- rack time keeps running through the bypass.
- Persisted rack state = each instance's state blob + the topology and mappings (the manifest: tracks, chains, params, gains, routes, seed) + transport settings (tempo, quantum, time signature) + each package's SHA-256. Restoring into a rack whose packages differ fails with the instance named.
0.2, spec only: og_param_send (controllers driving another instance's
parameters), the CONTROLLER kind, dynamic topology, multichannel execution and
arbitrary feedback.
In the SDK:
- Node:
og verify --rack x.rack.jsonchecks:- bit-identical output serially and with 1, 2 and N worker threads, at 64, 128 and 256-frame blocks;
- PDC with probes: each track's instrument is replaced by a unit impulse (the
others silent) and every effect by an exact delay of its declared
og_latency; the impulse must arrive at the declared graph latency (this tests the host's compensation, independent of the effects' DSP; the SDK tests do the same with real known-delay fixtures); - state restore: state saved after 1 s, restored into a serial and a threaded rack, renders identically and saves back unchanged;
- MIDI routing: covered by the bit-exact runs when the rack has routes;
- an injected stall: one worker sleeps 300 ms; with a 25 ms budget the coordinator must drop it, replace it from a checkpoint and keep going.
- Browser:
<og-device src="x.rack.json">runs a rack. The firmware worker is the coordinator and stays the single producer of the audio ring; the AudioWorklet never waits. Nested workers host the tracks, wait onAtomicsand write to their own shared buffers.- Bounded completion: the coordinator waits for a block at most as long as the ring still holds audio (at least two blocks); a late worker is replaced as above. State is checkpointed every 2 s for replacements.
- K adapts: it starts at min(2, cap), with cap = min(cores − 2, 3) (2 on Safari),
and changes from measured load: up when a worker stays above 60 % of the block
time, down when all are mostly idle or after two late blocks within 10 s.
navigator.hardwareConcurrencyonly sets the cap: it counts efficiency cores and says nothing about thermal headroom. saveRackState()/loadRackState()persist and restore a running rack;rackStatsreports faults, replacements and resizes.- A page that is not cross-origin isolated runs the same rack serially, with bit-identical output. Browsers allow at most 16 instances, since each can use up to 64 MiB.
17. Rack manifests
A rack is a JSON file (*.rack.json). Its topology is fixed: every track is an
instrument followed by a chain of effects, feeding a host mixer with gain, pan and one
send to a single return bus. fw paths are relative to the rack file and may point to
a .wasm or an .ogpkg.
{
"rack": 1,
"name": "Demo",
"tracks": [
{ "name": "Drums", "instrument": { "fw": "pocket-drums.wasm", "params": { "1": 124 } },
"fx": [ { "fw": "warm-delay.wasm", "params": { "5": 20 } } ],
"gain": 0.8, "pan": 0, "send": 0.25, "focus": true, "transport_owner": true },
{ "name": "Bass", "instrument": { "fw": "acid-line.wasm" }, "gain": 0.7, "pan": -0.2 }
],
"return": { "fx": [ { "fw": "warm-delay.wasm" } ], "gain": 0.6 },
"master": { "gain": 1.0 }
}Instances and parameters
paramsare applied withog_param_setafterog_init.state(base64) is loaded withog_state_load, before the params. After both, the host re-activates the instance and readsog_latencyagain.- Instance ids are persistent: tracks in order (instrument first, then its effects), then the return chain.
"seed"(default 0) is the rack seed forog_graph_ctx.
MIDI routes
"tracks": [ { "name": "Seq", "instrument": { "fw": "steps.wasm" } },
{ "name": "Bass", "instrument": { "fw": "acid-line.wasm" } } ],
"midi": [ { "from": "Seq", "to": "Bass", "from_port": 0, "to_port": 0 } ]fromandtoare track indices or names; the route connects the first instance of each track (output portfrom_port, input portto_port, default 0).- Sources run first, on the coordinator, in dependency order; cycles are rejected. Routed messages reach the destination in the same block at their frame offsets, after its own input events at the same offset.
- A zero-audio source (
UTILITY,MIDI_FX) is a track with no effects; its mix contribution is silence.
Saved state (saveState() in Node, saveRackState() in the browser):
{ "og_rack_state": 1, "rack": { "…": "the manifest: topology and mappings" }, "seed": 7,
"transport": { "tempo": 122, "quantum": 4, "tsNum": 4, "tsDen": 4, "playing": true },
"instances": [ { "id": 0, "track": 0, "slot": 0, "fw": "pocket-drums.wasm",
"sha256": "…", "state": "base64 or null" } ] }- All instruments start playing together when the rack starts (PLAY); the transport owner's tempo is the rack's tempo.
Mixing
- Pan is a linear balance:
left × (1 − max(pan, 0)),right × (1 + min(pan, 0)). - Send is taken after the chain and after delay compensation.
- The mix is computed in this exact order, in float32:
- each track's chain;
- each track delayed by
L − its latency, whereLis the largest track latency; - the return bus input, as the sum of
send × trackin track order; - the return chain (latency
R); - the master, as the sum of
gain × pan × track(each delayed by a furtherR) in track order, plusreturn gain × return, times the master gain.
- The rack's latency is
L + R.
rack: 2 (the successor format, specified, not yet loaded)
rack: 1 stays supported. rack: 2 replaces fixed tracks with a typed graph: nodes
(firmware instances or host nodes such as the mixer), typed midi and audio edges
between [node, port id] tuples, an output, and the rack seed. Node ids are the
persistent instance identities (automation addresses (node id, param id)).
{
"rack": 2,
"id": "song",
"seed": 42,
"nodes": [
{ "id": "seq", "fw": "sequencer.ogpkg" },
{ "id": "lead", "fw": "synth.ogpkg", "state_ref": "lead.ogst" },
{ "id": "bass", "fw": "bass.ogpkg" },
{ "id": "fx", "fw": "filter.ogpkg", "params": { "7": 0.4 } },
{ "id": "main", "host": "mixer" }
],
"edges": [
{ "type": "midi", "from": ["seq", 0], "to": ["lead", 0] },
{ "type": "audio", "from": ["lead", 0], "to": ["fx", 0] },
{ "type": "audio", "from": ["fx", 0], "to": ["main", 0] },
{ "type": "audio", "from": ["bass", 0], "to": ["main", 0] }
],
"output": ["main", 0]
}Rules: port ids are the firmware's og_port_info_ext.id (0 = the main audio pair or
MIDI port 0); edges are same-block; cycles need an explicit {"type": "delay"} edge of
at least one block; PDC applies at every merge; host mixer nodes sum in edge order in
float32; REQUIRED ports must have an edge or the rack fails to load.
The header
The normative definition, include/opengroove.h, as of this build:
/*
* OpenGroove Firmware ABI 0.1
* SPDX-License-Identifier: Apache-2.0
*
* A firmware is ONE wasm32 module and ONE instrument. The same binary runs in
* the browser, headless (tests, CI, rendering) and on Linux-class grooveboxes.
* docs/ABI.md explains every rule below in prose; this header is normative.
*
* Model
* - The host owns the clock and calls the firmware on ONE thread, never
* concurrently. The firmware needs no locks.
* - The firmware owns its memory: one unshared linear memory, exported, with
* a declared maximum of at most OG_MAX_MEMORY_PAGES. Every struct or buffer
* the host reads or writes lives in firmware memory at an address the
* firmware exports (og_*_ptr), stable for the life of the instance.
* - Struct negotiation is directional. Extensible structs start with
* `struct_size`. The WRITER of a struct caches the destination capacity
* (the struct_size preset in the firmware's storage, or its own sizeof),
* writes the complete prefix it knows (reserved bytes zero) and stores the
* size written. READERS never write; they ignore bytes beyond struct_size
* and treat fields that are missing or only partly present as zero.
* Minimum sizes for ABI 0.1: og_device 68, og_process_ctx 64, og_param_info 72,
* og_control_info 32, og_instance_info 36, og_port_info 28.
* - All shared scalars are little-endian. Alignment: audio buffers, og_device
* and og_param_info 4 bytes, og_process_ctx 8, og_dirty_rect 2.
* - Inputs arrive as og_event()/og_midi_in() calls stamped with a frame
* offset into the NEXT og_process() block, and take effect at that frame.
* - While activated, NO callback may block, allocate or call memory.grow;
* each has a time budget (docs/ABI.md section 9). Allocation and asset
* reads happen in og_init, og_activate, or og_idle while inactive.
* - Unknown flag bits are 0 when written and ignored when read.
*
* Call order
* og_abi_version, og_caps, og_device_ptr (host writes og_device), og_init,
* buffer pointers (read once), [og_state_load], og_activate,
* { og_event / og_midi_in*, fill og_process_ctx, og_process }*,
* og_render / og_idle / og_param_* / og_state_save between blocks,
* og_deactivate (and possibly og_activate again with a new rate).
* og_process is only called while activated. og_render, og_param_*, og_state_*
* and og_reset may be called any time after og_init returned 0.
*
* Build: clang --target=wasm32 -nostdlib -mbulk-memory -mnontrapping-fptoint
* -Wl,--no-entry (or `og build`; see docs/ABI.md section 11)
*/
#ifndef OPENGROOVE_H
#define OPENGROOVE_H
#include <stdint.h>
/* A firmware's version is the MINIMUM host revision it needs: a host
* implementing major.minor runs firmwares of the same major and any minor up
* to its own (it keeps every older revision working). Signatures of existing
* exports and imports are frozen within a major version. */
#define OG_ABI_VERSION_MAJOR 0
#define OG_ABI_VERSION_MINOR 1
#define OG_ABI_VERSION ((OG_ABI_VERSION_MAJOR << 16) | OG_ABI_VERSION_MINOR)
/* Limits hosts enforce (docs/ABI.md sections 6, 9 and 11). */
#define OG_MAX_MEMORY_PAGES 1024 /* 64 MiB: declared memory maximum */
#define OG_MAX_PENDING_EVENTS 4096 /* host-side input queue; overflow = panic (og_reset) */
#define OG_MAX_OUTPUTS_PER_BLOCK 256 /* og_midi_out + og_cv_out + og_gate_out per og_process */
#if defined(__wasm__)
#define OG_EXPORT(name) __attribute__((export_name(#name), visibility("default")))
#define OG_IMPORT(name) __attribute__((import_module("og"), import_name(#name)))
#else
#define OG_EXPORT(name)
#define OG_IMPORT(name)
#endif
#ifdef __cplusplus
extern "C" {
#endif
/* ------------------------------------------------------------------ */
/* Result codes */
/* ------------------------------------------------------------------ */
enum og_result {
OG_OK = 0,
OG_ERR = -1, /* unspecified failure */
OG_ERR_UNSUPPORTED = -2, /* feature not provided (missing capability) */
OG_ERR_RANGE = -3, /* id, index, offset or length out of range */
OG_ERR_FORMAT = -4, /* malformed data (bad magic, size, checksum) */
OG_ERR_VERSION = -5, /* data from an unknown schema version */
OG_ERR_RT = -6, /* not allowed from og_process() */
};
/* ------------------------------------------------------------------ */
/* Capabilities */
/* ------------------------------------------------------------------ */
/* One bit set, two meanings: og_device.caps = what the HOST provides,
* og_caps() = what the FIRMWARE implements or uses (optional: degrade
* gracefully), og_required_caps() = what the firmware cannot run without.
* A host calls an optional export group only if the firmware declares its bit
* (and exports the functions). A firmware must check og_device.caps before
* relying on a host feature; imports for missing features are harmless
* no-ops or return OG_ERR_UNSUPPORTED. A host MAY refuse to load a firmware
* whose og_required_caps() it lacks; it never refuses over og_caps(). */
enum og_cap {
OG_CAP_PARAMS = 1u << 0, /* og_param_* (host: shows/automates them) */
OG_CAP_STATE = 1u << 1, /* og_state_* (host: persists state) */
OG_CAP_ASSETS = 1u << 2, /* og_asset_* imports (host: package assets available) */
OG_CAP_MIDI_OUT = 1u << 3, /* og_midi_out (host: reaches a port) */
OG_CAP_CV = 1u << 4, /* og_cv_out / og_gate_out (host: cv_out or gate_out > 0) */
OG_CAP_TOUCH = 1u << 5, /* OG_TOUCH_* events (host: display is a touch screen) */
OG_CAP_TRANSPORT = 1u << 6, /* host: og_process_ctx carries tempo/transport;
firmware: follows it when OG_CTX_SYNC is set */
OG_CAP_AUDIO_IN = 1u << 7, /* audio input (host: audio_in > 0) */
OG_CAP_IDLE = 1u << 8, /* firmware exports og_idle(); host calls it */
OG_CAP_NATIVE_DISPLAY = 1u << 9, /* firmware only: framebuffer is in og_device.display_format
with display_stride (otherwise always RGBA8888, w*4 stride) */
OG_CAP_KEYS = 1u << 10, /* OG_KEY_* and OG_TEXT events (a keyboard) */
OG_CAP_CONTROLS = 1u << 11, /* host: fills og_controls_ptr() with control descriptors */
OG_CAP_TRANSPORT_CONTROL = 1u << 12, /* host: honours og_transport_request();
firmware: may call it */
OG_CAP_RACK = 1u << 13, /* firmware: may run as one of several instances in a
host graph (og_instance_ptr is filled). A firmware
that does NOT declare it always runs alone. */
OG_CAP_PORTS = 1u << 14, /* host: reads og_port_* (og_port_info_ext ids, channel
layouts) and enforces OG_PORT_REQUIRED: a required
port it cannot connect makes the load fail */
OG_CAP_MIDI_PORTS = 1u << 15, /* host: routes MIDI by port (og_midi_in_port export,
og_midi_out_port import); firmware: uses them */
OG_CAP_GRAPH_CTX = 1u << 16, /* host: writes og_graph_ctx_ptr() before every
og_process (block id, graph generation, seed) */
};
/* ------------------------------------------------------------------ */
/* Kind and racks (multi-instance hosts) */
/* ------------------------------------------------------------------ */
/* What a firmware is (export og_kind(); absent = OG_KIND_INSTRUMENT; also the
* manifest's "kind").
* - INSTRUMENT: events/MIDI in, stereo audio out.
* - EFFECT: audio_in = 2; processes og_audio_in_ptr() into og_audio_out_ptr().
* - MIDI_FX and UTILITY are ZERO-AUDIO: the host writes audio_in = audio_out = 0,
* og_audio_out_ptr() may return 0 and is never read. MIDI_FX turns MIDI in into
* MIDI out (arpeggiators, transposers); UTILITY produces MIDI or nothing from its
* own state (sequencers, clocks). Both emit with og_midi_out / og_midi_out_port.
* - CONTROLLER is reserved for 0.2 (og_param_send). */
enum og_kind {
OG_KIND_INSTRUMENT = 0,
OG_KIND_EFFECT = 1,
OG_KIND_MIDI_FX = 2, /* zero-audio: MIDI in -> MIDI out */
OG_KIND_CONTROLLER = 3, /* reserved for 0.2 */
OG_KIND_UTILITY = 4, /* zero-audio: MIDI (or nothing) out */
};
/* Host -> firmware (OG_CAP_RACK): which instance this is. Written to
* og_instance_ptr() before og_init, directional like og_device. */
enum og_instance_flags {
OG_INSTANCE_HAS_FOCUS = 1u << 0, /* the device's controls and screen are this instance's */
OG_INSTANCE_TRANSPORT_OWNER = 1u << 1, /* its og_transport_request() calls are honoured */
};
/* Serialisation: the host never calls into one instance concurrently; all of an
* instance's callbacks are ordered. They may run on different threads over time
* (the host can migrate an instance between workers or cores); a firmware keeps
* no thread-local state. Automation addresses (instance_id, param id). */
typedef struct og_instance_info {
uint32_t struct_size;
uint32_t instance_id; /* PERSISTENT: saved with the rack and stable across
reloads (rack:1 = position in manifest order) */
uint32_t slot; /* position in its chain or the rack (0-based) */
uint32_t slot_count;
uint32_t flags; /* enum og_instance_flags (may change between blocks) */
uint32_t _reserved[4];
} og_instance_info; /* 36 bytes */
#define OG_INSTANCE_INIT { .struct_size = sizeof(og_instance_info) }
/* Ports beyond the main stereo pair (og_audio_in_ptr / og_audio_out_ptr stay the
* main audio ports; MIDI port 0 is og_midi_in / og_midi_out). Firmware -> host,
* enumerated after og_init with og_port_count / og_port_info_ptr. An entry whose
* struct_size >= sizeof(og_port_info_ext) carries a stable id and a channel layout;
* a 28-byte entry has id = its index and the layout of `channels`.
* Negotiation (OG_CAP_PORTS hosts): an optional port the host cannot connect is
* left unconnected (silence in, output discarded); a port flagged REQUIRED that it
* cannot connect fails the load with an error naming the port. Never silent. */
enum og_port_dir { OG_PORT_IN = 0, OG_PORT_OUT = 1 };
enum og_port_type { OG_PORT_AUDIO = 0, OG_PORT_MIDI = 1 };
enum og_port_flags {
OG_PORT_MAIN = 1u << 0,
OG_PORT_SIDECHAIN = 1u << 1,
OG_PORT_REQUIRED = 1u << 2, /* the firmware cannot work without it connected */
};
/* channel_mask: bit n = channel n present. */
#define OG_CH_MONO 0x1u
#define OG_CH_STEREO 0x3u
typedef struct og_port_info {
uint32_t struct_size;
uint8_t dir; /* og_port_dir */
uint8_t type; /* og_port_type */
uint8_t channels; /* audio: 1..2 */
uint8_t _reserved0;
uint32_t flags; /* og_port_flags */
char name[16];
} og_port_info; /* 28 bytes */
typedef struct og_port_info_ext {
og_port_info base; /* base.struct_size = sizeof(og_port_info_ext) */
uint32_t id; /* stable port id: edges and saved racks refer to it.
MIDI port ids address og_midi_in_port/og_midi_out_port */
uint32_t channel_mask; /* audio: OG_CH_MONO, OG_CH_STEREO, ...; MIDI: 16-bit
channel filter (0 = all) */
} og_port_info_ext; /* 36 bytes */
/* Graph context (OG_CAP_GRAPH_CTX), host -> firmware before every og_process,
* directional like og_process_ctx. Use `seed` for all randomness: it is fixed per
* (rack seed, persistent instance id), so a rack renders the same on any host and
* thread count. block_id counts og_process calls since og_activate. */
typedef struct og_graph_ctx {
uint32_t struct_size;
uint32_t generation; /* changes when the host rebuilt the graph (topology,
restore, worker replacement); results are tagged with it */
uint64_t block_id;
uint64_t seed;
} og_graph_ctx; /* 24 bytes */
#define OG_GRAPH_CTX_INIT { .struct_size = sizeof(og_graph_ctx) }
/* ------------------------------------------------------------------ */
/* Device description (host -> firmware, written to og_device_ptr()) */
/* ------------------------------------------------------------------ */
enum og_profile {
OG_PROFILE_GENERIC = 0, /* 4x4 pads, 4 knobs, 8 buttons, 800x480 touch */
OG_PROFILE_KICK = 1, /* BE 0700-AAM class: 4x4 pads, 4 knobs, jog, 1024x600 touch */
OG_PROFILE_FIRE = 2, /* Akai Fire class: 4x16 pads, 4 knobs, select encoder, 128x64 mono */
OG_PROFILE_OTHER = 3, /* no layout hint: use the counts and the control descriptors */
};
enum og_display_format {
OG_FMT_RGBA8888 = 0, /* 4 bytes per pixel, R,G,B,A; A must be 255 */
OG_FMT_RGB565 = 1, /* little-endian u16 per pixel */
OG_FMT_MONO1 = 2, /* 1 bit per pixel, MSB = leftmost, 1 = lit */
};
/* Summary in og_device.knob_kind; per-knob truth is in the control descriptors. */
enum og_knob_kind {
OG_KNOB_RELATIVE = 0, /* endless encoder: OG_KNOB value = delta in detents */
OG_KNOB_ABSOLUTE = 1, /* potentiometer: OG_KNOB value = position 0..1 */
OG_KNOB_MIXED = 2, /* both kinds present: read og_control_info per knob */
};
typedef struct og_device {
uint32_t struct_size; /* see "Model" */
uint32_t abi_version; /* host's OG_ABI_VERSION */
uint32_t caps; /* enum og_cap bits the host provides */
uint32_t profile; /* enum og_profile: a layout hint; adapt to the counts below */
uint32_t sample_rate; /* expected rate; og_activate() is authoritative */
uint32_t max_block; /* og_process() never gets more frames (<= 512) */
uint16_t display_w; /* pixels; 0 x 0 = no display (headless) */
uint16_t display_h;
uint32_t display_format; /* enum og_display_format: the host's native format */
uint32_t display_stride; /* bytes per row in display_format */
uint32_t buttons_present; /* bit n set = button id n exists (ids 0..31; enum og_button) */
uint8_t pad_rows;
uint8_t pad_cols;
uint8_t knobs;
uint8_t knob_kind; /* enum og_knob_kind (summary) */
uint8_t audio_in; /* channels 0..2 */
uint8_t audio_out; /* channels 1..2 */
uint8_t cv_out;
uint8_t gate_out;
uint8_t has_jog; /* jog wheel / select encoder (OG_JOG) */
uint8_t pad_pressure; /* 1 if pads send OG_PAD_PRESSURE */
uint16_t control_count; /* entries written to og_controls_ptr() (OG_CAP_CONTROLS), else 0 */
uint32_t buttons_present_hi; /* bit n set = panel button id 32+n exists (0 on older hosts) */
uint32_t _reserved[3];
} og_device;
/* ------------------------------------------------------------------ */
/* Control descriptors (host -> firmware, OG_CAP_CONTROLS) */
/* ------------------------------------------------------------------ */
/* The counts in og_device say how many pads/knobs/buttons exist; descriptors say
* what each physical control IS, so mixed hardware (pots next to encoders, a
* push-encoder, a key bed, a touch strip) can be described exactly. */
enum og_control_kind {
OG_CTL_BUTTON = 1, /* OG_BUTTON_DOWN/UP, event_id = og_button id */
OG_CTL_ENCODER = 2, /* OG_KNOB relative deltas, event_id = knob index */
OG_CTL_POT = 3, /* OG_KNOB absolute 0..1, event_id = knob index */
OG_CTL_PUSH_ENCODER = 4, /* relative deltas; `paired` = button id of its push.
event_id = knob index, or OG_CTL_JOG_ID for OG_JOG */
OG_CTL_PADS = 5, /* OG_PAD_*, event_id = first pad index, count = pads */
OG_CTL_TOUCH = 6, /* OG_TOUCH_* on the display (event_id 0) */
OG_CTL_KEYS = 7, /* OG_KEY_* / OG_TEXT (a keyboard) */
};
#define OG_CTL_JOG_ID 0xFFFFu /* event_id of a control that sends OG_JOG */
#define OG_CTL_NONE 0xFFFFu /* `paired` when there is no association */
/* What the control is for on the hardware's panel (a hint for default maps). */
enum og_control_role {
OG_ROLE_GENERIC = 0,
OG_ROLE_PARAM = 1, /* assignable parameter knob */
OG_ROLE_DATA = 2, /* main value/navigation encoder (jog, select, data wheel) */
OG_ROLE_NAVIGATE = 3, /* arrows, back, menu, page */
OG_ROLE_TRANSPORT = 4, /* play, stop, rec */
OG_ROLE_MODIFIER = 5, /* shift, alt */
OG_ROLE_PERFORM = 6, /* pads, keys played musically */
OG_ROLE_MODE = 7, /* mode/bank/page/screen selectors */
OG_ROLE_EDIT = 8, /* undo, copy, erase, save: editing commands */
};
typedef struct og_control_info {
uint32_t struct_size;
uint16_t kind; /* enum og_control_kind */
uint16_t role; /* enum og_control_role */
uint16_t event_id; /* id carried by this control's events (see kind) */
uint16_t paired; /* push-encoder: its button id; else OG_CTL_NONE */
uint16_t count; /* pads: number of pads; else 1 */
uint16_t _reserved0;
float resolution; /* encoders: detents per turn; pots: steps (0 = smooth);
pads: velocity levels (0 = continuous) */
char label[12]; /* short panel label, UTF-8, NUL-terminated */
} og_control_info; /* 32 bytes */
/* Firmware storage the host fills before og_init (directional: the host writes,
* the firmware reads). The firmware presets entry_size and capacity; the host
* writes min(capacity, its controls) entries with stride entry_size, each with
* its own struct_size, then sets og_device.control_count. */
#define OG_MAX_CONTROLS 64
typedef struct og_controls {
uint32_t entry_size; /* preset by the firmware: sizeof(og_control_info) */
uint32_t capacity; /* preset by the firmware: entries available */
og_control_info entries[OG_MAX_CONTROLS];
} og_controls;
#define OG_CONTROLS_INIT { sizeof(og_control_info), OG_MAX_CONTROLS, { { 0 } } }
/* ------------------------------------------------------------------ */
/* Per-block context (host -> firmware, written before og_process()) */
/* ------------------------------------------------------------------ */
enum og_ctx_flags {
OG_CTX_TEMPO_VALID = 1u << 0, /* tempo_bpm and ts_num/ts_den are meaningful */
OG_CTX_PLAYING = 1u << 1, /* host transport is running; beat_pos advances */
OG_CTX_RECORDING = 1u << 2,
OG_CTX_EXT_CLOCK = 1u << 3, /* tempo/position follow an external clock */
OG_CTX_SYNC = 1u << 4, /* host is transport master: sequencers follow
PLAYING, beat_pos and tempo_bpm instead of
free-running (implies TEMPO_VALID) */
OG_CTX_DISCONTINUITY = 1u << 5, /* beat_pos does not continue the previous block
(first block after og_activate, seek, loop,
rewind): re-derive position, release notes */
OG_CTX_LINKED = 1u << 6, /* tempo/position are shared with peers (e.g. Ableton Link) */
OG_CTX_COUNT_IN = 1u << 7, /* PLAYING with beat_pos < 0: pre-roll before a quantized
start at beat 0 (count-ins, anticipation; no notes yet) */
};
/* Transport rules (docs/ABI.md section 7):
* - beat_pos = quarter notes since the host's transport origin at the
* PRESENTATION time of frame 0 (when that sample reaches the output). The
* host aligns the origin to a quantum boundary of its timeline; it is not a
* song position. It is finite; it is negative only while COUNT_IN is set.
* - tempo_bpm is finite and > 0; quantum (beats per phase cycle, e.g. one bar)
* is > 0 when known, else 0. SYNC implies TEMPO_VALID.
* - The host fills the context, then delivers the block's events, then calls
* og_process. Transport changes happen only at block boundaries. Stopping
* keeps beat_pos; rewinding sets it to 0 with DISCONTINUITY. Leaving SYNC
* stops a following sequencer. Musical time advances only with rendered
* frames (underruns don't). */
typedef struct og_process_ctx {
uint32_t struct_size;
uint32_t frames; /* == the og_process() argument */
uint64_t sample_pos; /* frames processed since og_activate() */
double tempo_bpm; /* quarter notes per minute */
double beat_pos; /* quarter notes since transport start, at frame 0 */
uint16_t ts_num; /* time signature, e.g. 4/4 (advisory) */
uint16_t ts_den;
uint32_t flags; /* enum og_ctx_flags */
double quantum; /* beats per phase cycle (bar length for quantized starts), 0 = unknown */
uint32_t peers; /* LINKED: number of other participants, else 0 */
uint32_t _reserved[3];
} og_process_ctx;
/* ------------------------------------------------------------------ */
/* Input events (host -> firmware) */
/* ------------------------------------------------------------------ */
/* Ordering: within a block, events (og_event and og_midi_in together) arrive
* sorted by offset (0 <= offset < frames), ties in arrival order, and take
* effect at that frame. Late events (their block was already rendered) are
* delivered at offset 0. At most OG_MAX_EVENTS_PER_BLOCK per block: the rest
* are deferred as a FIFO delivered at offset 0 of the next blocks, AHEAD of
* that block's own events. If more than OG_MAX_PENDING_EVENTS wait, the host
* drops the queue and calls og_reset() (panic: nothing keeps sounding). */
#define OG_MAX_EVENTS_PER_BLOCK 256
#define OG_EVENT_VENDOR_FIRST 0x80000000u /* 0x80000000..0xffffffff: negotiated vendor extensions */
enum og_event_type {
OG_PAD_DOWN = 1, /* id = pad index (row * pad_cols + col, row 0 = top), value = velocity 0..1 */
OG_PAD_UP = 2, /* id = pad index, value = 0 */
OG_PAD_PRESSURE = 3, /* id = pad index, value = pressure 0..1 */
OG_KNOB = 4, /* id = knob index, value per og_device.knob_kind */
OG_BUTTON_DOWN = 5, /* id = enum og_button */
OG_BUTTON_UP = 6,
OG_JOG = 7, /* id = 0, value = delta in detents */
OG_TOUCH_DOWN = 8, /* id = finger 0..9, x/y in display pixels */
OG_TOUCH_MOVE = 9,
OG_TOUCH_UP = 10,
OG_PARAM_CHANGE = 11, /* id = param id, value = new value in the param's units */
OG_KEY_DOWN = 12, /* OG_CAP_KEYS: id = USB HID usage (page 7), value = modifier bits */
OG_KEY_UP = 13, /* id = USB HID usage, value = modifier bits */
OG_TEXT = 14, /* OG_CAP_KEYS: id = one Unicode scalar value typed (text input) */
OG_TOUCH_CANCEL = 15, /* id = finger: the gesture was cancelled; undo it, don't commit */
};
/* Modifier bits in OG_KEY_* values. */
enum og_key_mod { OG_MOD_SHIFT = 1, OG_MOD_CTRL = 2, OG_MOD_ALT = 4, OG_MOD_META = 8 };
/* Firmwares must ignore event types they don't know. Non-finite values in any
* event are never sent by hosts and must be ignored by firmwares. */
enum og_button {
OG_BTN_PLAY = 0, OG_BTN_STOP = 1, OG_BTN_REC = 2, OG_BTN_SHIFT = 3,
OG_BTN_LEFT = 4, OG_BTN_RIGHT = 5, OG_BTN_UP = 6, OG_BTN_DOWN = 7,
OG_BTN_MODE_1 = 8, OG_BTN_MODE_2 = 9, OG_BTN_MODE_3 = 10, OG_BTN_MODE_4 = 11,
OG_BTN_BANK = 12, OG_BTN_SELECT = 13, OG_BTN_BACK = 14, OG_BTN_MENU = 15,
/* Panel buttons: ids 16..63 have no conventional meaning. Their og_control_info
* (label, role) says what they are; a profile document lists them (KICK:
* docs/KICK-PROFILE.md). Present bits: buttons_present (16..31) and
* buttons_present_hi (32..63). Hosts send them as OG_BUTTON_DOWN/UP like any
* other id; a firmware ignores ids it does not use (always bounds-check ids). */
OG_BTN_PANEL = 16,
};
#define OG_MAX_BUTTONS 16 /* conventional ids; og_button_leds_ptr() length */
#define OG_MAX_BUTTON_IDS 64 /* all ids, panel buttons included */
/* ------------------------------------------------------------------ */
/* Parameters (OG_CAP_PARAMS) */
/* ------------------------------------------------------------------ */
enum og_param_flags {
OG_PARAM_STEPPED = 1u << 0, /* integer values only (min..max) */
OG_PARAM_AUTOMATABLE = 1u << 1, /* host may send OG_PARAM_CHANGE in real time */
OG_PARAM_HIDDEN = 1u << 2, /* don't show in generic UIs */
};
/* How a UI maps a control position to the value (presentation only). */
enum og_param_curve {
OG_CURVE_LINEAR = 0,
OG_CURVE_LOG = 1, /* equal ratios per step (Hz, times); needs min > 0 */
OG_CURVE_EXP = 2, /* fine control near min */
};
#define OG_PARAM_LABEL_LEN 16 /* bytes per enumeration label, NUL-padded */
#define OG_PARAM_MAX_LABELS 64
typedef struct og_param_info {
uint32_t struct_size;
uint32_t id; /* stable across versions; never reuse an id */
uint32_t flags; /* enum og_param_flags */
float min, max, def; /* plain units */
char name[32]; /* UTF-8, NUL-terminated */
char unit[8]; /* e.g. "Hz", "%", "BPM", "" */
float step; /* UI step in plain units (0 = continuous / 1 for STEPPED) */
uint16_t curve; /* enum og_param_curve */
uint16_t label_count; /* STEPPED enums: labels for min..min+label_count-1 (<= 64) */
uint32_t labels; /* firmware address of label_count x OG_PARAM_LABEL_LEN bytes, or 0 */
uint32_t _reserved[3];
} og_param_info; /* 88 bytes; fields past 72 read as 0 from older firmware */
/* ------------------------------------------------------------------ */
/* State (OG_CAP_STATE) */
/* ------------------------------------------------------------------ */
/* Every state blob starts with this 16-byte little-endian envelope, frozen
* forever (a different envelope needs a different magic). The host checks
* magic and size; the firmware checks fw_tag and schema (its payload version)
* and returns OG_ERR_FORMAT or OG_ERR_VERSION. og_state_load is transactional:
* on any error the firmware's state is unchanged. Serialized padding is zero.
* Hosts key stored state by package identity, never by fw_tag. */
#define OG_STATE_MAGIC 0x5453474Fu /* "OGST" */
typedef struct og_state_header {
uint32_t magic; /* OG_STATE_MAGIC */
uint32_t fw_tag; /* firmware-chosen identifier, e.g. a FOURCC */
uint32_t schema; /* firmware's state schema version, starting at 1 */
uint32_t size; /* total blob bytes including this header */
} og_state_header;
/* ------------------------------------------------------------------ */
/* Display dirty rectangle (optional) */
/* ------------------------------------------------------------------ */
/* Half-open pixel rectangle [x, x+w) x [y, y+h), clipped by the host to the
* display. w == 0: whole frame; w > 0 && h == 0: no pixels changed (LEDs may
* still have). LED updates never depend on the rectangle. The host copies
* what it needs before calling into the firmware again. */
typedef struct og_dirty_rect { uint16_t x, y, w, h; } og_dirty_rect;
/* ------------------------------------------------------------------ */
/* Required exports */
/* ------------------------------------------------------------------ */
uint32_t og_abi_version(void) OG_EXPORT(og_abi_version); /* return OG_ABI_VERSION */
uint32_t og_caps(void) OG_EXPORT(og_caps); /* enum og_cap bits used (optional) */
og_device *og_device_ptr(void) OG_EXPORT(og_device_ptr); /* static storage, struct_size preset */
og_process_ctx *og_process_ctx_ptr(void) OG_EXPORT(og_process_ctx_ptr);
/* Called once after the host wrote og_device. Allocate here. 0 = OK. */
int32_t og_init(void) OG_EXPORT(og_init);
/* Prepare to process at this rate and block size (1 <= max_block <=
* og_device.max_block). og_process_ctx.sample_pos and og_time() restart at 0.
* May be called again after og_deactivate(), possibly with a new rate. 0 = OK.
* The audio buffer stride stays og_device.max_block regardless. */
int32_t og_activate(uint32_t sample_rate, uint32_t max_block) OG_EXPORT(og_activate);
void og_deactivate(void) OG_EXPORT(og_deactivate); /* host input queues are cleared */
/* Silence: drop voices, tails, delay lines and held-input state (pressed pads,
* buttons, notes: the host now considers everything released and clears its
* queues). Keep params, patterns, state and the firmware's own play/stop state. */
void og_reset(void) OG_EXPORT(og_reset);
/* Input for the next og_process() block. x/y only for touch events. */
void og_event(uint32_t type, uint32_t id, float value, uint32_t offset,
float x, float y) OG_EXPORT(og_event);
void og_midi_in(uint8_t status, uint8_t data1, uint8_t data2,
uint32_t offset) OG_EXPORT(og_midi_in);
/* Render `frames` frames, 1 <= frames <= the activated max_block. og_process_ctx
* is filled. Planar float32 buffers: channel c at ptr + c * og_device.max_block
* (always the DEVICE max_block, not the activated one). Real time. */
void og_process(uint32_t frames) OG_EXPORT(og_process);
/* Draw the framebuffer and set LEDs. Return 1 if anything changed, else 0.
* Must be cheap and must not advance musical state; the host calls it at
* <= 60 Hz and may skip calls under load. */
int32_t og_render(void) OG_EXPORT(og_render);
/* Buffer addresses, read once after og_init(). Return 0 for "none". */
float *og_audio_out_ptr(void) OG_EXPORT(og_audio_out_ptr); /* audio_out * max_block floats */
float *og_audio_in_ptr(void) OG_EXPORT(og_audio_in_ptr); /* audio_in * max_block floats */
uint8_t *og_framebuffer_ptr(void) OG_EXPORT(og_framebuffer_ptr); /* RGBA8888 w*h*4, or native format */
uint8_t *og_pad_leds_ptr(void) OG_EXPORT(og_pad_leds_ptr); /* pad_rows*pad_cols RGB triplets */
uint8_t *og_button_leds_ptr(void) OG_EXPORT(og_button_leds_ptr); /* OG_MAX_BUTTONS brightness bytes, by og_button id */
/* ------------------------------------------------------------------ */
/* Optional exports */
/* ------------------------------------------------------------------ */
uint32_t og_required_caps(void) OG_EXPORT(og_required_caps); /* og_cap bits needed to run (absent = 0) */
/* Added output latency in frames (default 0). Read after og_activate; it must stay
* constant until the next og_activate (hosts compensate graph delays with it). */
uint32_t og_latency(void) OG_EXPORT(og_latency);
/* OG_CAP_IDLE: housekeeping. While INACTIVE it may allocate and read assets;
* while activated it follows the real-time rules (no allocation, no assets). */
void og_idle(void) OG_EXPORT(og_idle);
og_dirty_rect *og_dirty_rect_ptr(void) OG_EXPORT(og_dirty_rect_ptr); /* filled when og_render returns 1 */
og_controls *og_controls_ptr(void) OG_EXPORT(og_controls_ptr); /* descriptor storage (OG_CAP_CONTROLS) */
/* LEDs of panel buttons: OG_MAX_BUTTON_IDS - OG_MAX_BUTTONS (48) brightness bytes,
* byte n = button id 16+n. Absent = panel button LEDs stay dark. */
uint8_t *og_panel_leds_ptr(void) OG_EXPORT(og_panel_leds_ptr);
/* Kind and racks. og_kind absent = INSTRUMENT. og_instance_ptr: OG_CAP_RACK. */
uint32_t og_kind(void) OG_EXPORT(og_kind);
og_instance_info *og_instance_ptr(void) OG_EXPORT(og_instance_ptr);
/* Frames of output that can follow once the input (and all notes) went silent:
* delay/reverb tails. 0 = none (default), 0xFFFFFFFF = unbounded (self-oscillating,
* loopers): the host must keep processing. Read after og_activate. */
uint32_t og_tail_frames(void) OG_EXPORT(og_tail_frames);
/* Extra ports (buffers planar, channels x og_device.max_block floats, stable). */
uint32_t og_port_count(void) OG_EXPORT(og_port_count);
const og_port_info *og_port_info_ptr(uint32_t index) OG_EXPORT(og_port_info_ptr); /* may point at an og_port_info_ext */
float *og_port_buffer_ptr(uint32_t index) OG_EXPORT(og_port_buffer_ptr);
/* OG_CAP_GRAPH_CTX: storage the host fills (preset struct_size, OG_GRAPH_CTX_INIT). */
og_graph_ctx *og_graph_ctx_ptr(void) OG_EXPORT(og_graph_ctx_ptr);
/* OG_CAP_MIDI_PORTS: MIDI for input port `port` (its og_port_info_ext.id). Port 0
* may arrive here or through og_midi_in; same ordering rules as og_midi_in. */
void og_midi_in_port(uint32_t port, uint8_t status, uint8_t data1, uint8_t data2, uint32_t offset) OG_EXPORT(og_midi_in_port);
/* OG_CAP_PARAMS. Called between blocks. og_param_set and OG_PARAM_CHANGE have
* identical effect (derived coefficients included); NaN/Inf are ignored;
* STEPPED values round to nearest (ties away from zero), then all values
* clamp to min..max; og_param_get returns the effective value. Changes made
* by the firmware itself (e.g. a knob) are visible via og_param_get. */
uint32_t og_param_count(void) OG_EXPORT(og_param_count);
const og_param_info *og_param_info_ptr(uint32_t index) OG_EXPORT(og_param_info_ptr); /* 0 = bad index */
void og_param_set(uint32_t id, float value) OG_EXPORT(og_param_set);
float og_param_get(uint32_t id) OG_EXPORT(og_param_get);
/* OG_CAP_STATE. Called between blocks (hosts may autosave at any time).
* og_state_ptr() is a firmware buffer of og_state_size() bytes used both ways. */
uint32_t og_state_size(void) OG_EXPORT(og_state_size); /* capacity: largest blob */
uint8_t *og_state_ptr(void) OG_EXPORT(og_state_ptr);
int32_t og_state_save(void) OG_EXPORT(og_state_save); /* bytes written, or < 0 */
int32_t og_state_load(uint32_t len) OG_EXPORT(og_state_load); /* OG_OK or < 0 */
/* ------------------------------------------------------------------ */
/* Host imports (module "og") */
/* ------------------------------------------------------------------ */
void og_log(const char *msg, uint32_t len) OG_IMPORT(log); /* one UTF-8 line */
/* Outputs at a frame offset into the current block (clamped to frames-1).
* Only legal inside og_process: elsewhere, and beyond OG_MAX_OUTPUTS_PER_BLOCK
* per block, they are dropped (and counted). No-ops without the cap. */
void og_midi_out(uint8_t status, uint8_t data1, uint8_t data2, uint32_t offset) OG_IMPORT(midi_out);
/* OG_CAP_MIDI_PORTS: MIDI out on output port `port` (og_midi_out == port 0). Same
* limits as og_midi_out; a port with no connection drops the message. */
void og_midi_out_port(uint32_t port, uint8_t status, uint8_t data1, uint8_t data2, uint32_t offset) OG_IMPORT(midi_out_port);
void og_cv_out(uint32_t index, float volts, uint32_t offset) OG_IMPORT(cv_out); /* clamped -5..+10 V */
void og_gate_out(uint32_t index, uint32_t on, uint32_t offset) OG_IMPORT(gate_out);
/* Seconds since og_activate() at the start of the current block
* (== og_process_ctx.sample_pos / sample_rate). */
double og_time(void) OG_IMPORT(time);
/* OG_CAP_TRANSPORT_CONTROL: ask the host to change its transport (e.g. a tempo
* param or a menu "Play" while the host is master). Applied at the next block
* boundary, if at all (the host decides; the last request per block wins).
* A no-op without the capability. tempo_bpm is used with OG_TREQ_TEMPO. */
enum og_transport_request_flags { OG_TREQ_PLAY = 1, OG_TREQ_STOP = 2, OG_TREQ_TEMPO = 4 };
void og_transport_request(uint32_t flags, double tempo_bpm) OG_IMPORT(transport_request);
/* OG_CAP_ASSETS: files from the firmware package (manifest "assets", by
* index). The host preloads them. Callable only in og_init, og_activate and
* og_idle while inactive; elsewhere they return OG_ERR_RT. Reading at or past
* the end returns a short count or 0 (EOF); an offset beyond the size is
* OG_ERR_RANGE. */
int32_t og_asset_size(uint32_t id) OG_IMPORT(asset_size); /* bytes, or < 0 */
int32_t og_asset_read(uint32_t id, void *dst, uint32_t offset, uint32_t len) OG_IMPORT(asset_read); /* bytes read, or < 0 */
/* ------------------------------------------------------------------ */
/* WASI */
/* ------------------------------------------------------------------ */
/* SIMD: plain simd128 is allowed. Relaxed-SIMD opcodes are rejected at load
* (their results differ between hosts, which breaks bit-exact racks). */
/* A firmware MAY import exactly these wasi_snapshot_preview1 functions, with
* their standard signatures: fd_write (fd 1/2 -> og_log; <= 16 iovecs and
* 4096 bytes per call), random_get (<= 65536 bytes), clock_time_get,
* proc_exit (the firmware stops; the host shows an error), environ_get,
* environ_sizes_get, args_get, args_sizes_get (all empty), and fd_close,
* fd_seek, fd_read, fd_fdstat_get, fd_prestat_get, fd_prestat_dir_name
* (ENOSYS / EBADF). Any other import, or a wrong signature, fails the load. */
#if defined(__wasm32__) && !defined(__cplusplus)
_Static_assert(sizeof(og_device) == 68, "og_device layout");
_Static_assert(sizeof(og_process_ctx) == 64, "og_process_ctx layout");
_Static_assert(sizeof(og_param_info) == 88, "og_param_info layout");
_Static_assert(sizeof(og_control_info) == 32, "og_control_info layout");
_Static_assert(sizeof(og_instance_info) == 36, "og_instance_info layout");
_Static_assert(sizeof(og_port_info) == 28, "og_port_info layout");
_Static_assert(sizeof(og_port_info_ext) == 36, "og_port_info_ext layout");
_Static_assert(sizeof(og_graph_ctx) == 24, "og_graph_ctx layout");
_Static_assert(sizeof(og_state_header) == 16, "og_state_header layout");
#endif
/* Static initialisers for the exported structs. */
#define OG_DEVICE_INIT { .struct_size = sizeof(og_device) }
#define OG_PROCESS_CTX_INIT { .struct_size = sizeof(og_process_ctx) }
#ifdef __cplusplus
}
#endif
#endif /* OPENGROOVE_H */