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.