How it works

The four nouns — kernel, schema, engine, shell — and how a sample travels through them.

There are only four things to understand, and once you have them, everything else in these docs is detail.

The kernel

Your DSP. A TypeScript object implementing AudioKernel<State> from @codex-music/audio-contracts: init(sampleRate) allocates the state, process(state, io, params, events) fills the output buffers, reset(state) clears the memory. io is a KernelIO — planar ReadonlyArray<Float32Array> inputs and outputs plus a frameCount. Nothing else.

The kernel is written in restricted TypeScript so it can be compiled twice from one source: to a freestanding WASM reactor for the browser, and to a native library linked into the plugin. Same file, same arithmetic, same state layout. That is the whole reason the web version and the VST sound the same.

The schema

Your knobs. A ParamSchema is a kernelId (matching the compiled kernel's ABI prefix, e.g. "scsub_") and an ordered list of ParamDescriptors. Each descriptor carries a dotted path, a label, a group, a type ("float" | "enum" | "bool"), and range metadata.

Order matters: bridgeParams(schema) assigns each descriptor a stable index, and that index is the VST3 automation id. Append new parameters at the end and your users' existing automation lanes keep pointing at the right knob.

The schema also fixes the value math. normalize and denormalize in @codex-music/audio-contracts are the single implementation every surface uses, and the generated C param table reproduces that math exactly — same clamping order, same rounding — so a knob position means the same number in your web UI, in the worklet, and in the DAW.

The engine

Your transport. createAudioEngine({ backend, graph }) from @codex-music/audio-sdk returns an AudioEngine: setParam, noteOn, noteOff, send(events), render(frames), start, close. Your UI only ever talks to this interface, so it does not know or care where the audio is actually being computed.

Backends are declared by EngineBackend: "shared-wasm" (AudioWorklet plus a compiled WASM reactor, the browser path), "native" (the compiled engine inside the plugin shell), "offline-native" (headless renders for CI), "tone" (the Tone.js migration baseline, a behavioural reference and never a parity target), and "hybrid".

The shell

Your plugin. One generic native shell — a VST3 entry point, a platform webview, and the compiled engine — parameterized per plugin by codegen. Audio never touches the webview: processBlock pulls the native engine directly, while host MIDI and sample-accurate automation are translated into the same AudioEvent union the web path uses.

How a sample travels

In the browser: a pointer move calls engine.setParam("tone.cutoff", 3200). The SDK turns that into a { kind: "param", frameOffset, path, value } event, posts it over the AudioWorklet message port, and the worklet hands the sorted event list plus the block's buffers to the compiled kernel's process(). The kernel writes samples; the browser plays them.

In the DAW: the host moves the same parameter on an automation lane. The shell denormalizes it with the generated table, produces the same param event with the same frameOffset, and hands it to the native build of the same kernel. Same event stream, same arithmetic, same output.

The identical event vocabulary is not a convenience — it is the precondition for identical sound. That is why AudioEvent stays tiny, explicit, and integer-timed, and why sortEvents puts parameter changes before note events at the same frame on both sides.

Where the seams currently are

The contracts, the plugin shell, and the codegen exist and are exercised by the repo's verification harnesses. createAudioEngine() throws today — the shared-WASM backend is landing first, then the native codexHost transport, and @codex-music/audio-worklet's createWorkletEngine is the extraction point for the browser side. See Parity and determinism for what is measured and what is still open.