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/, which og verify, og render and CI use),
  • on Linux-class grooveboxes through the native runtime.

The rules that shape the whole ABI:

  1. The host owns the clock. It calls the firmware from a single thread, never concurrently, so a firmware needs no locks.
  2. 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.
  3. 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_device and og_process_ctx, using the capacity the firmware preset with OG_DEVICE_INIT / OG_PROCESS_CTX_INIT. The firmware writes og_param_info.
    • The reader never writes to the struct. It ignores bytes beyond struct_size, and treats fields that are missing, or only partly inside struct_size, as zero.
    • Minimum sizes in 0.1: og_device 68 bytes, og_process_ctx 64, og_param_info 72 (the current size is 88), and og_control_info 32. 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.
  4. Numbers.
    • Every shared scalar is little-endian.
    • Alignment: audio buffers, og_device and og_param_info are 4-byte aligned; og_process_ctx is 8-byte aligned; og_dirty_rect is 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.
  5. 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_version export. 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, and og_asset_* returns OG_ERR_UNSUPPORTED. A host never refuses a firmware over og_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.caps before 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..0xffffffff are 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:

c
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-endian u16 per 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 == 0 means the whole frame changed.
  • w > 0 with h == 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

c
/* 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_CANCEL ends 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:

  • offset is the frame inside the next og_process block at which the event happened. Hosts always send 0 ≤ offset < frames. A firmware that still receives a larger offset treats it as frames - 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_ctx first, then delivers the block's events (og_event and og_midi_in together), then calls og_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 calls og_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.

  • offset is a frame in the current block, and the host clamps it to frames - 1.
  • Calls outside og_process are 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_SYNC clear (the default): the firmware free-runs. PLAY toggles its own transport, and the tempo is its own parameter. tempo_bpm is only a hint.
  • OG_CTX_SYNC set: the host is the transport master. A firmware that declares OG_CAP_TRANSPORT does the following:
    • It plays while PLAYING is 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 whenever floor(beat * 4) changes.
    • It re-derives its position whenever beat_pos jumps.
  • 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:

  • SYNC implies TEMPO_VALID.
  • tempo_bpm is finite and greater than 0.
  • beat_pos is finite. It is negative only while COUNT_IN is set, together with PLAYING: 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's og_seq and 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.
  • quantum is 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 whose beat_pos does not continue the previous block: the first block after og_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 call og_reset because of a seek.
  • Stop and rewind:
    • Stop clears PLAYING and keeps beat_pos, so a later play continues from there without a discontinuity.
    • Rewind sets beat_pos to 0 and flags a discontinuity.
  • Leaving sync: when SYNC clears, 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:

c
/* excerpt: opengroove.h */
void og_transport_request(uint32_t flags, double tempo_bpm);
  • flags combine OG_TREQ_PLAY 1, OG_TREQ_STOP 2 and OG_TREQ_TEMPO 4. 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:

c
/* 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; STEPPED implies 1.
    • curve: LINEAR, LOG (equal ratios: Hz and times; needs min > 0) or EXP (fine control near min). It only changes how a UI maps a slider or knob position; the parameter's value semantics stay the same.
    • label_count and labels: for a STEPPED enumeration, up to 64 labels of 16 bytes each, NUL-padded, for the values min .. min + label_count - 1. labels is a firmware address. The web panel shows these parameters as a menu, and og_fw.h's OG_FW_PARAM_EX fills 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_ptr returns a pointer into firmware memory, usually a static const table, for the same reason as the other *_ptr exports: 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 STEPPED parameters, the value first rounds to the nearest integer (ties away from zero).
    • Every value is then clamped to min..max.
    • og_param_get returns the effective value.
  • When to use og_param_set and when to use OG_PARAM_CHANGE:
    • og_param_set is called between blocks (UI changes, restoring state).
    • The OG_PARAM_CHANGE event is the sample-accurate path, inside a block, used for automation.
    • The two must have identical effect, derived coefficients included. An OG_PARAM_CHANGE at offset 0 produces bit-identical audio to an og_param_set just 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_get reports 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 a Next Glide at 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 the Next 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).
  • 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.grow and asset reads are allowed only in og_init, in og_activate, and in og_idle while inactive.
  • Hosts check memory size after every guest call made while activated. Any growth is reported as a realtime error, 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_render must 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 realtime error.

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 verify runs 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:

c
/* 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.

c
/* 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 uses fw_tag, which is only a collision-prone safety check.

  • Before loading, it checks magic and that size equals the blob length and is no larger than og_state_size().

  • It copies the blob into og_state_ptr() and calls og_state_load(len).

  • It reports load errors instead of ignoring them:

    • the browser logs [og] … state not restored: <reason> as an og-log event;
    • og verify reports them per profile.
  • 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_tag and schema. Return OG_ERR_FORMAT for a foreign or corrupt blob, and OG_ERR_VERSION for an unknown schema. schema versions the payload, not the envelope. When the layout changes, bump schema and 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; -Wpadded shows 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 verify checks 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 containing manifest.json, firmware.wasm and assets/….
json
{
  "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:

c
/* 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, and og_idle while inactive. Everywhere else, including og_process and an og_idle while activated, they return OG_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 without OG_CAP_ASSETS, returns OG_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 under assets/, segments made only of A–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.
  • Quotas: at most 1024 entries; each entry at most 32 MiB uncompressed; at most 128 MiB in total; firmware.wasm at 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 into log.
  • 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_* and args_*: empty.
  • Other fd_* and path_* calls return ENOSYS.

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-memory lowers memcpy, memset and struct copies to memory.copy / memory.fill.
  • -mnontrapping-fptoint makes float-to-int conversions saturate instead of trapping.
  • -msimd128 enables wasm SIMD. Browsers, Node and Wasmtime support it; on aarch64 it maps to NEON. og build --no-simd builds 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 doctor shows which clang og build found (OG_CLANG, EMSDK, WASI_SDK_PATH, Homebrew LLVM, then PATH).

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_latency is 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 at og_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_reset silences the tail immediately.
  • Processing a decaying tail is at most 4× slower than processing signal (the denormal check). | | params | Only for firmwares declaring PARAMS. Ids are unique, min ≤ default ≤ max, set and get round-trip at min, max and default, and out-of-range values are clamped. null when not declared. |

The checked record is what goes into the hub registry:

json
"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:

json
{
  "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 }
  ]
}
  • at and length are in seconds.
  • pad, button and touch are a press followed by a release.
  • type sends one raw event.
  • transport changes the host transport (playing, tempo, sync, rewind).
  • Without --script, og render presses PLAY and records 8 s.
  • --png writes 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:

  1. 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.
  2. Negotiation goes through capabilities. An extension gets a capability bit. The firmware declares it in og_caps() (optional) or og_required_caps() (required), and the host announces it in og_device.caps. New exports in an extension are optional and are called only when both sides declare the bit.
  3. 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.
  4. Event types: new standard types come from the next free number below 0x80000000. Vendor-specific types use 0x80000000..0xffffffff, and are used only after a capability has been negotiated.
  5. New structs start with struct_size and follow the directional rules in section 1. Reserved fields are reused only behind a capability or a new revision.
  6. 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):

  1. Kind. og_kind() (and the manifest's "kind"): INSTRUMENT 0 (the default when absent), EFFECT 1, MIDI_FX 2, UTILITY 4. CONTROLLER 3 is reserved for 0.2.
    • An effect gets audio_in = 2 and turns og_audio_in_ptr() into og_audio_out_ptr(), stereo in to stereo out.
    • 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. A MIDI_FX turns MIDI in into MIDI out (arpeggiators, transposers, chord generators); a UTILITY produces MIDI, or nothing, from its own state (sequencers, clocks). Both emit with og_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.
  2. Opt-in. A firmware that does not declare OG_CAP_RACK always runs as a single instance on its own, never inside a rack.
  3. Instance identity and serialisation. With OG_CAP_RACK, the host writes og_instance_info to og_instance_ptr() before og_init, following the same directional rules as og_device. It carries instance_id, slot and slot_count, and the flags HAS_FOCUS (the device's controls and screen belong to this instance) and TRANSPORT_OWNER (only its og_transport_request calls are honoured).
    • instance_id is persistent: it is saved with the rack and stays the same when the rack is reloaded (in rack: 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.
  4. Ports. og_port_count, og_port_info_ptr and og_port_buffer_ptr describe 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).
    • Stable ids and layouts. An entry whose struct_size is at least 36 is an og_port_info_ext: id is the stable port id that edges and saved racks refer to, and channel_mask is the layout (OG_CH_MONO 0x1, OG_CH_STEREO 0x3; for MIDI a 16-bit channel filter, 0 = all). A 28-byte entry has id = its index and the layout of channels. Ids are unique per direction and type.
    • Negotiation (OG_CAP_PORTS hosts). An optional port the host cannot connect is left unconnected: silence in, output discarded. A port flagged OG_PORT_REQUIRED that it cannot connect fails the load, with an error naming the port. A required sidechain is never silently ignored.
  5. MIDI ports (OG_CAP_MIDI_PORTS). The export og_midi_in_port(port, status, data1, data2, offset) receives MIDI for input port port (its id); port 0 may come here or through og_midi_in. The import og_midi_out_port(port, …) sends on output port port (og_midi_out is 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).
  6. Graph context (OG_CAP_GRAPH_CTX). The host fills og_graph_ctx_ptr() before every og_process: generation (changes when the host rebuilds the graph: restore, worker replacement, resize), block_id (calls since og_activate) and seed. 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. Inside a rack the SDK hosts also feed WASI random_get from that seed.
  7. Tail and latency.
    • og_tail_frames() is how long the output can continue after the input went silent. 0 means no tail; 0xFFFFFFFF means unbounded, and the host keeps processing.
    • og_latency() is read after og_activate and stays constant until the next og_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).
  8. 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.
  9. One clock. Every instance receives the same og_process_ctx for a given block.
  10. 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.
  11. 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.
  12. 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.json checks:
    • 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 on Atomics and 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.hardwareConcurrency only sets the cap: it counts efficiency cores and says nothing about thermal headroom.
    • saveRackState() / loadRackState() persist and restore a running rack; rackStats reports 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.

json
{
  "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

  • params are applied with og_param_set after og_init.
  • state (base64) is loaded with og_state_load, before the params. After both, the host re-activates the instance and reads og_latency again.
  • Instance ids are persistent: tracks in order (instrument first, then its effects), then the return chain.
  • "seed" (default 0) is the rack seed for og_graph_ctx.

MIDI routes

json
"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 } ]
  • from and to are track indices or names; the route connects the first instance of each track (output port from_port, input port to_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):

json
{ "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:
    1. each track's chain;
    2. each track delayed by L − its latency, where L is the largest track latency;
    3. the return bus input, as the sum of send × track in track order;
    4. the return chain (latency R);
    5. the master, as the sum of gain × pan × track (each delayed by a further R) in track order, plus return 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)).

json
{
  "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:

c
/*
 * 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 */