OpenGroove Toolkit v1

The toolkit is a library, not part of the ABI. It provides graphics, input, UI, parameters, sequencing, envelopes and state, so a firmware is mostly the instrument itself. Hosts never know whether a firmware uses it.

Version 1.0.0 (OG_TK_VERSION), for ABI 0.1
Form C sources and headers in sdk/toolkit/, compiled into toolkit/build/<opt>/libog.a and linked by og build. Only the objects a firmware uses end up in it.
Headers #include "og/<module>.h": tk, arena, gfx, input, ui, params, seq, env, state, dsp, vec
Pinned toolchain Emscripten SDK 3.1.61 (clang 19, its wasm-ld and llvm-ar), Node 20 or newer

Conventions

  • Names follow og_<module>_<verb>. Units are part of the name or documented: frames, plain parameter units, detents, pixels.
  • Caller-owned storage. Every object is a struct you declare, usually static. Queues and tables take capacities and storage from you. There are no hidden globals.
  • Status returns. OG_TK_OK (0), OG_TK_EMPTY (1: an iterator has nothing more), or a negative og_result.
  • Real time.
    • Event callbacks only enqueue, in O(1).
    • Musical changes are applied inside og_process, at their sample offsets.
    • Drawing is const and never changes musical state.
    • Memory is allocated only in og_init and og_activate (og_arena).

Modules

Module What it does
og/arena.h Init-time bump allocation from the end of memory. For example, it sizes the framebuffer from the actual display: a 128×64 MONO1 screen takes 1 KiB, not a fixed 1.5 MB.
og/gfx.h Surfaces in RGBA8888, RGB565 or MONO1 with any stride; half-open clipping; fill, rect, line; one 5×7 font with integer scaling; og_fmt_int / og_fmt_fixed without libc. og_gfx_init_device picks the host's native format (declare OG_CAP_NATIVE_DISPLAY when you use it).
og/input.h Raw events in, intents out (FOCUS_STEP, PAGE_STEP, ACTIVATE, BACK, MENU, VALUE_DELTA, VALUE_SET, POINTER, TEXT, COMMAND, NOTE, PARAM…). Details below.
og/ui.h Declarative views with retained focus, scroll and edit state, and immediate drawing. Details below.
og/params.h One apply path, og_params_apply, shared by knobs, touch, automation and state restore. It rounds and clamps by the ABI rules and calls your on_change, where derived coefficients are recomputed, so a change at an event offset is sample-accurate. It also handles curve-aware unit mapping, pot pickup and enumeration labels; OG_PARAMS_EXPORTS(P) implements the ABI's param exports.
og/seq.h A transport-aware step sequencer. Details below.
og/env.h A one-pole smoother (og_smooth) and an exponential ADSR. Both are denormal-safe.
og/state.h Explicit-field serialization inside the 16-byte envelope, plus transactional loading (og_state_close is OK only when every read was in bounds and the payload was consumed exactly).
og/dsp.h Header-only DSP: fast math, og_svf, og_polyblep, og_sat, noise, og_mtof.
og/vec.h Block DSP four lanes at a time, using wasm SIMD: og_saw4 (4 PolyBLEP saws), og_svf4 (4 low-pass SVFs), og_vec_mix, og_vec_scale, og_vec_sum4. A scalar fallback produces bit-identical output.

og/input.h in detail. Every intent carries a phase (BEGIN, UPDATE, END, CANCEL, REPEAT), a sample timestamp, a target (focus, slot, param or command) and a snapshot of the modifiers.

  • Bindings come from versioned JSON compiled into const arrays. Resolution goes firmware, then profile, then a fallback derived from the control descriptors. The first table that binds a control owns all of its gestures, and ambiguous tables are rejected.
  • Gestures: tap and hold, auto-repeat, fractional detents and pot positions.
  • Releases go to whoever received the press, even after the context or pad mode changed.
  • Reachability: og_input_reachable() reports which intents the device can produce.

og/ui.h in detail.

  • Views: menus built from lists (items open views, run commands or edit params), virtualized lists of any length, and a paged step grid where pads become step slots.
  • Editing params: an edit can be cancelled, which restores the old value. Knob slots address the params on the visible page, and pots use pickup.
  • Touch: a tap activates and a drag edits, with CANCEL undoing the drag. There are on-screen back and page controls.
  • Layout adapts to large colour screens (320 px wide or more), small ones (64 px tall or more) and tiny ones (128×32 class).

og/seq.h in detail.

  • Clock: it free-runs, or follows OG_CTX_SYNC, PLAYING, beat_pos, tempo and DISCONTINUITY. Swing is supported.
  • Tracks have their own lengths (1 to 64 steps).
  • Bounded: the per-block event queue and the note-off scheduler live in caller storage. Note-offs are never dropped: when the scheduler is full, the oldest note is released early.
  • Output: an iterator returns events with sample offsets.

The og_process pattern

Split the block at every intent and every sequencer event, so that each one takes effect at its frame:

c
#include "og/input.h"
#include "og/params.h"
#include "og/seq.h"
#include "og/ui.h"

static og_input in;
static og_ui ui;
static og_params params;
static og_seq seq;
static og_process_ctx ctx = OG_PROCESS_CTX_INIT;
static float audio_out[2 * 512];
static float sample_rate = 48000.0f;
static uint32_t stride = 128;   /* og_device.max_block: the channel stride */

static void render(float *l, float *r, uint32_t from, uint32_t to) {
    for (uint32_t i = from; i < to; i++) l[i] = r[i] = 0.0f;   /* your voices here */
}

static void handle(const og_intent *it) {
    og_action a;
    if (it->kind == OG_I_NOTE) return;                         /* play a voice (BEGIN) / release it (END) */
    if (og_ui_handle(&ui, it, &a) != OG_TK_OK) return;
    if (a.kind == OG_ACT_PARAM) og_params_apply(&params, (uint32_t)a.id, a.value);   /* the one path */
    if (a.kind == OG_ACT_COMMAND && a.id == OG_CMD_PLAY) og_seq_play(&seq, !og_seq_playing(&seq));
}

void og_process(uint32_t frames) {
    float *l = audio_out, *r = audio_out + stride;
    og_input_begin(&in, ctx.sample_pos);
    og_seq_begin(&seq, &ctx, frames);
    uint32_t pos = 0;
    while (pos < frames) {
        og_intent it;
        og_seq_event ev;
        while (og_input_next(&in, pos + 1, &it) == OG_TK_OK) handle(&it);
        while (og_seq_next(&seq, pos + 1, &ev) == OG_TK_OK) { /* trigger / release voices */ }
        uint32_t next = pos + 16 < frames ? pos + 16 : frames;   /* or the next event's offset */
        render(l, r, pos, next);
        pos = next;
    }
    og_input_set_context(&in, og_ui_context(&ui));
    og_input_set_pad_mode(&in, og_ui_pad_mode(&ui));
    og_input_end(&in, frames);
    (void)sample_rate;
}

The templates (og new --template blank|drums|mono-synth) are the complete, verified versions of this pattern: start from one of them.

Bindings

Default behaviour needs no table: the capability fallback maps whatever the device has.

Control Default behaviour
Push encoder or jog Turning moves the focus (nav) or edits the value (edit). A tap on its push is ACTIVATE; holding it is BACK.
Arrow buttons Up and down move the focus, with auto-repeat. Left and right page (nav) or edit the value (edit).
SELECT, BACK, MENU ACTIVATE, BACK, MENU
Parameter encoders VALUE_DELTA on slot n
Pots VALUE_SET on slot n (with pickup in the UI)
PLAY, STOP, REC, mode buttons COMMAND
Keyboard Arrows navigate; Enter is ACTIVATE; Esc is BACK; Tab is MENU; Space is PLAY; text arrives as TEXT
Touch POINTER
Pads NOTE, or slots in step views

To override it, put a bindings.json next to og.json. og build compiles it into og_firmware_bindings; pass that to og_input_init with og_bindings_for_profile(dev.profile):

json
{ "version": 1, "bindings": [
  { "source": "button", "id": "MODE_1", "gesture": "press", "intent": "command", "target": 300 },
  { "source": "knob", "id": 0, "gesture": "turn", "mods": { "shift": true }, "intent": "value_delta", "target_kind": "param", "target": 5, "gain": 0.25 }
] }
  • Sources: button, knob, jog, key.
  • Gestures: press, tap, hold, turn, abs.
  • Contexts: any, nav, edit.
  • Modifiers: shift, ctrl, alt, meta.
  • og bindings file.json prints the generated C, so you can review it.
  • The per-profile tables live in toolkit/bindings/*.json.
  • Panel buttons (ids 16–63) have no names of their own. Use the number, or add "profile": "kick" to name the KICK-class ones by their legend ("MAIN", "STEP_SEQ", "COPY", …; KICK-PROFILE.md).
  • Command targets by name: PLAY, STOP, REC, MODE_1..MODE_8, BANK, and the panel commands UNDO, COPY, ERASE, SAVE, TAP_TEMPO, HOME, BROWSE, MIX, MUTE, NOTE_REPEAT, OVERDUB, PLAY_START, FX, PREFS (OG_CMD_*); your own start at OG_CMD_USER (256).
  • Knob fallbacks bind only role PARAM controls: a touch strip or mod wheel (role PERFORM) reaches the firmware as plain OG_KNOB events.

State, the transactional way

c
#include "og/state.h"

static uint8_t state_buf[256];
static float cutoff = 0.5f, decay = 0.3f;

int32_t save_state(void) {
    og_state_writer w;
    og_state_begin(&w, state_buf, sizeof state_buf, OG_FOURCC('D', 'E', 'M', 'O'), 1);
    og_state_put_f32(&w, cutoff);
    og_state_put_f32(&w, decay);
    return og_state_end(&w);
}

int32_t load_state(uint32_t len) {
    og_state_reader r;
    int32_t rc = og_state_open(&r, state_buf, len, OG_FOURCC('D', 'E', 'M', 'O'), 1);
    if (rc != OG_OK) return rc;
    float c = og_state_get_f32(&r, 0.5f), d = og_state_get_f32(&r, 0.3f);
    rc = og_state_close(&r);
    if (rc != OG_OK) return rc;          /* nothing changed */
    cutoff = c; decay = d;               /* commit */
    return OG_OK;
}

Reachability

A UI only works if the device can drive it. At og_init, every template logs either reach ok or reach missing: …, from og_ui_unreachable(&ui, og_input_reachable(&in)). The CI matrix checks this on ten profiles:

Profile Reachable?
generic, kick, fire ok
fm1like (push encoder plus a few buttons) ok
rotary (one push encoder only) ok: turn to move, tap to select, hold to go back
touchonly ok: everything is a pointer
keys (computer keyboard only) ok
mixed (encoders and pots) ok
padsonly reports navigate, select, back and page missing; the pads still play
headless reports everything missing; MIDI still plays

SIMD

og build compiles with -msimd128 by default. All target browsers, Node and Wasmtime/Cranelift support wasm SIMD, and on aarch64 it maps to NEON. og build --no-simd builds a scalar version, with its own toolkit archive, for hosts without SIMD.

og/vec.h is where SIMD pays off: banks of oscillators and filters, with one voice per lane, and mixing. The scalar path does the same operations in the same order, so the two builds produce bit-identical audio (CI checks this). Fills in og_gfx are also simple enough for the compiler to vectorize.

Measured with node scripts/bench-simd.mjs. The block is 16 PolyBLEP saw voices, each through its own SVF low-pass, mixed to mono: 128 frames at 48 kHz.

Run (Node 22, Apple M1 Pro, load average 85–87) Scalar SIMD Speed-up
1 10.4 µs 5.2 µs 2.0×
2 31.8 µs 9.0 µs 3.5×
  • The machine was heavily loaded by other jobs, hence the spread. Rerun the script on a quiet machine for clean numbers.
  • Either way, the SIMD block uses well under 1 % of the 2.67 ms block budget.
  • How to mix: add the four voice groups lane by lane with og_vec_mix, then sum across lanes once per block with og_vec_sum4. Summing across lanes after every group measured only 1.36× under the same conditions.

Size

og build --opt O2|Oz|lto builds the firmware and the toolkit archive at that level. The measurements are in docs/TOOLKIT-SIZES.md, which node scripts/sizes.mjs regenerates.

Not in v1

Deferred by design: a sampler, a piano roll, an XY pad, a waveform view, marquee text, a reverb, an arpeggiator, ratchets and step probability. Firmwares can build these on the modules above. The toolkit will add them when there is a second user for each.