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 negativeog_result. - Real time.
- Event callbacks only enqueue, in O(1).
- Musical changes are applied inside
og_process, at their sample offsets. - Drawing is
constand never changes musical state. - Memory is allocated only in
og_initandog_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
CANCELundoing 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 andDISCONTINUITY. 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:
#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(¶ms, (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):
{ "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.jsonprints 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 commandsUNDO,COPY,ERASE,SAVE,TAP_TEMPO,HOME,BROWSE,MIX,MUTE,NOTE_REPEAT,OVERDUB,PLAY_START,FX,PREFS(OG_CMD_*); your own start atOG_CMD_USER(256). - Knob fallbacks bind only role
PARAMcontrols: a touch strip or mod wheel (rolePERFORM) reaches the firmware as plainOG_KNOBevents.
State, the transactional way
#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 withog_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.