Research preview

Write the DSP once. Run it everywhere.

You write your audio processing and your instrument logic in TypeScript. The toolchain compiles that one file twice — to WebAssembly for the browser and to a native library for the plugin — so the module in a web page and the module in your DAW are running the same arithmetic, not two implementations kept in sync by hand.

This page covers three things in order: how the pipeline works, how to get started, and what the stack is made of.

How it works

Four nouns, and a sample's trip through them

There are only four things to understand. Everything else in the docs is detail hanging off one of them.

  1. Kernel

    Your DSP

    A TypeScript object implementing AudioKernel<State>: init(sampleRate) allocates, process(state, io, params, events) fills the output buffers, reset(state) clears them. io is planar Float32Array inputs and outputs plus a frameCount. No audio context, no nodes, no graph — just samples.

  2. Schema

    Your knobs

    A ParamSchema: a kernelId matching the compiled kernel's ABI prefix, and an ordered list of parameter descriptors with a dotted path, a label, a group, a type and a range. Order assigns each one a stable index, and that index is the VST3 automation id.

  3. Engine

    Your transport

    createAudioEngine({ backend, graph }) returns one host-neutral interface — setParam, noteOn, noteOff, send, render, start, close. Your UI only ever talks to this, so it does not know or care where the audio is actually computed.

  4. Shell

    Your plugin

    One generic native shell — a VST3 entry point, a platform webview, and the compiled engine — parameterized per plugin by codegen from your manifest and schema. Audio never touches the webview: processBlock pulls the native engine directly.

One kernel, compiled twice, delivered three ways

The kernel is written in restricted TypeScript precisely 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.

Web

A freestanding WASM reactor driven from an AudioWorklet. This is the browser path, and it is what makes a tool playable from a link with no install.

VST3

The native shell loads the same kernel compiled as a native library, and your web UI as the plugin editor. Host automation and MIDI arrive as the same event union the browser path uses.

Standalone

The same native build wrapped as an app, for playing without a DAW in the way. Exported alongside the plugin from the same manifest.

Why the two ever agree

“Sounds the same” is a claim worth nothing unless it is checkable. Between our own two backends the standard is bit-identical: the same kernel, the same input and the same event stream must produce byte-for-byte identical PCM, and that is hash-checked in CI. Three preconditions make it possible.

One kernel source

Not two implementations kept in sync by discipline. One restricted-TypeScript file compiled twice, with matching state layout and metadata.

One event vocabulary

AudioEvent is a small union with an integer frameOffset, never float seconds. Both hosts sort by frame and apply parameter changes before note events at the same frame, so a note landing on a filter change resolves the same way in a browser and in a DAW.

One parameter math

normalize and denormalize in audio-contracts are the single implementation, and the generated C parameter table reproduces them exactly — same clamping order, same rounding. A knob position means the same float everywhere.

Against Web Audio and Tone.js the claim is weaker and stays weaker: behaviourally equivalent, not bit-identical. Browser nodes are not bit-defined, so the Tone path is a migration baseline and deliberately never a parity target. Read what is measured.

Get started

From an empty folder to a plugin

Four steps: scaffold, write the kernel, declare your parameters and bind a UI, export. The same path the quickstart walks, with the reasoning left in.

Status, before you copy anything: the codex CLI below is the intended developer loop, not a shipped binary — today the same steps run through the workspace's own scripts and plugin-shell-kit generate. The types are real and stable; createAudioEngine() throws while the browser backend is wired first.

Step 1: Scaffold

Pick a template and a name. You get a workspace with a kernel stub, a param schema, a dev server that hot-reloads the UI, and a plugin.config.json that already knows how to become a plugin.

Terminalbash
npx codex create my-pedal --template fx-react
cd my-pedal
npm run dev            # http://localhost:5173 — audio on first click
TemplateWhat you get
fx-reactAudio-in → audio-out effect, React UI. Start here for pedals.
fx-vueSame effect scaffold, Vue UI.
instrument-reactNote-driven instrument (voice allocation wired), React UI.
instrument-vueSame instrument scaffold, Vue UI.
midi-toolMIDI in → MIDI out, no audio path. Arps, chord tools, mappers.

Step 2: Write the kernel

The kernel is the only part that has to be careful. It's a TypeScript object with three methods — init, process, reset — and process is called once per audio block with planar Float32Array channels to read and write in place.

Here's a complete pedal: a one-pole lowpass into a gain stage. That's a real tone control, and it's about fifteen lines.

src/kernel.tsts
// src/kernel.ts
import type { AudioKernel, KernelIO } from "@codex-music/audio-contracts";

interface ToneState {
  sampleRate: number;
  /** One-pole lowpass memory, per channel. Allocated in init(), never here. */
  lp: Float32Array;
}

export const toneKernel: AudioKernel<ToneState> = {
  init(sampleRate: number): ToneState {
    // Setup is the place to allocate. Two channels is plenty for a pedal.
    return { sampleRate, lp: new Float32Array(2) };
  },

  process(state, io: KernelIO, params, _events): void {
    // Coefficient from the cutoff knob. Cheap, allocation-free, per block.
    const cutoff = params["tone.cutoff"];
    const coeff = Math.min(1, Math.max(0.001, cutoff / state.sampleRate));
    const level = params["master.level"];

    for (let ch = 0; ch < io.outputs.length; ch = ch + 1) {
      const input = io.inputs[ch];
      const output = io.outputs[ch];
      let z = state.lp[ch];

      for (let i = 0; i < io.frameCount; i = i + 1) {
        // fround at the accumulation keeps WASM and native bit-identical.
        z = Math.fround(z + coeff * (input[i] - z));
        output[i] = Math.fround(z * level);
      }

      state.lp[ch] = z;
    }
  },

  reset(state): void {
    state.lp.fill(0);
  },
};

Note what isn't there: no new inside process, no await, no callbacks, no console.log. Those aren't style preferences — the compiler enforces them, and it names the call in your chain that broke the rule.

Step 3: Declare your params, then bind a UI

Parameters live in one schema, and that schema is the single source of truth for three surfaces at once: the knobs in your web UI, the automation lanes your DAW sees, and the values handed to process(). Write it once and stop worrying about whether “Tone” means the same number in both places.

src/schema.tsts
// src/schema.ts
import type { ParamSchema } from "@codex-music/audio-contracts";

export const MY_PEDAL_SCHEMA: ParamSchema = {
  kernelId: "sctone_",
  params: [
    {
      path: "tone.cutoff",
      label: "Tone",
      group: "Filter",
      type: "float",
      min: 200,
      max: 8000,
      default: 2500,
      units: "Hz",
    },
    {
      path: "master.level",
      label: "Level",
      group: "Master",
      type: "float",
      min: 0,
      max: 1,
      default: 0.9,
    },
  ],
};

The UI is a web page. Nothing more clever than that — map over the schema, render whatever control you want, call setParam with the dotted path.

src/App.tsxtsx
// src/App.tsx
import { createAudioEngine, isNativeHost } from "@codex-music/audio-sdk";
import { MY_PEDAL_SCHEMA } from "./schema";

const engine = createAudioEngine({
  // browser: shared-wasm · plugin shell: native
  backend: "shared-wasm",
  graph: { schema: MY_PEDAL_SCHEMA, nodes: [{ kind: "sctone_", params: {} }] },
});

export function App() {
  return (
    <main>
      <h1>My Pedal {isNativeHost() ? "(in your DAW)" : "(in your browser)"}</h1>
      {MY_PEDAL_SCHEMA.params.map((p) => (
        <label key={p.path}>
          {p.label}
          <input
            type="range"
            min={p.min}
            max={p.max}
            step={p.step ?? ((p.max ?? 1) - (p.min ?? 0)) / 200}
            defaultValue={p.default}
            onChange={(e) => engine.setParam(p.path, e.target.valueAsNumber)}
          />
          {p.units}
        </label>
      ))}
    </main>
  );
}

That is the entire portability trick. isNativeHost() checks for the bridge the plugin shell injects before your bundle loads; createAudioEngine picks its transport accordingly. You write one component and it runs in both places.

Step 4: Export

Terminalbash
npx codex export vst3
# → build/MyPedal.vst3
# → build/MyPedal.app  (standalone, same kernel)

No magic, so here's what the export actually does:

  1. Your manifest is read. plugin.config.json names the module, its vendor, its category, which kernel to compile, and where your built web UI lands.
  2. The kernel is compiled twice from one source — freestanding WASM for the browser worklet, and a native library for the plugin. Same TypeScript, same math, same state layout.
  3. @codex-music/plugin-shell-kit generates the shell. From your schema it emits the VST3 parameter table (stable indices, so automation survives version bumps), the state-chunk codec that saves and restores your patch in a session, the bridge JS injected before your bundle loads, and the CMake targets.
  4. CMake builds the native shell, embedding the compiled kernel as the processor and your web UI bundle as the plugin editor.

Your manifest is short, because most of it is derivable:

plugin.config.jsonjson
{
  "id": "music.codex.my-pedal",
  "name": "My Pedal",
  "vendor": "Codex Music",
  "version": "0.1.0",
  "category": "Fx",
  "kernel": {
    "package": "@codex-music/audio-kernels",
    "profile": "profiles/tone-pedal.profile.json",
    "abiPrefix": "sctone_"
  },
  "ui": {
    "dist": "dist-plugin"
  }
}

Drop the .vst3 in your plugins folder and open your DAW. The knobs you built are the knobs the host automates.

Where to go next

Technology

What the stack is made of

Six layers, each of which exists to remove a way the web version and the native version could quietly drift apart.

Restricted TypeScript

Real TypeScript, real types, a smaller vocabulary — roughly the subset you would write anyway once someone explained why the audio thread is different. The restriction is what makes the same file compilable to two very different backends.

The compiler

It walks your whole call graph, not just the function you annotated, and rejects anything that could stall the audio callback: allocation, exceptions, recursion, locks, I/O, unbounded loops. When it says no it names the call path from process() to the offending operation, so you fix the line rather than bisecting your kernel by hand.

Web runtime

A wasm32-wasi reactor module loaded with a small WASI shim and driven from an AudioWorklet. State is allocated once and owned by the host, so there is no collector deciding to run in the middle of your chorus.

Native runtime

The same kernel as a native library inside the generated shell, with the VST3 parameter table, the state-chunk codec that saves your patch in a session, the host bridge JS, and the CMake targets all emitted from your manifest.

Parameter contracts

The schema is the contract between all of the above. Stable indices mean automation lanes survive version bumps; shared normalize/denormalize math means a knob position denormalizes to the same float in the UI, the worklet and the DAW.

Determinism as a test

Because the two backends are supposed to agree bit-for-bit, agreement is something a machine can check rather than something you take on faith. The repo's harnesses render fixed inputs through both lanes and compare hashes.

Rules of the kernel

Restricted TypeScript is real TypeScript with a smaller vocabulary. Inside process(), you can't:

  • Allocate. No new, no array or object literals, no growing anything inside process(). Do it in init(sampleRate) instead — coefficient tables, delay lines, wavetables are all setup-time work.

  • Use closures or capture variables. No inline callbacks, no map/filter over your samples. Indirect calls are only allowed when the compiler can prove the target set.

  • Go async. No promises, no await, no timers, no setTimeout.

  • Touch the outside world. No DOM, no fetch, no file or network or device I/O, no console.log. If you need to see what is happening, send it out over the event channel and look at it in the UI.

  • Throw, recurse, or loop unboundedly. Every loop needs a bound the compiler can see. Recursion is out entirely.

What you get in exchange: sin, cos, sqrt, exp, log, pow, Math.fround, Math.PI, Math.E, typed arrays you allocated up front, and arithmetic that behaves the same on both backends.

One convention worth internalizing: Math.fround at every stateful accumulation. Filter memory, delay feedback, envelope followers — anywhere a value survives to the next sample. JavaScript arithmetic is f64 while the compiled kernel's state is f32, and a value that feeds back into itself compounds that difference on every pass.

Built to be written with an AI agent

This is a design goal of the SDK rather than a feature bolted onto it. Audio code is a bad fit for code generation in the usual case — the failure mode is something that compiles and then misbehaves at 48 kHz, where you cannot see it. The three properties below are what change that arithmetic, and they are the same three properties that make the toolchain pleasant for humans.

A small, typed surface to write against

Three kernel methods, one event union, one parameter schema. A model does not have to infer a framework's conventions from examples, because the authoring surface is a handful of types it can read in full.

Mistakes come back as errors, not as bad audio

The failure mode that makes generated DSP dangerous is code that compiles and then misbehaves in the audio callback. Here the unsafe operations are rejected at compile time with the call path named, which is exactly the kind of feedback an agent can act on without a human in the loop.

Output you can diff

A render is a deterministic byte stream. “Did that change do what I meant?” is answerable by comparing hashes on fixed input, so an iteration loop can be closed automatically instead of by ear.

To be plain about the state of it: there is no hosted assistant on this site today. What exists is a toolchain whose contracts, error messages and outputs are shaped so that an agent working in your editor has something solid to aim at.

What you don't have to learn

C++

Your DSP is TypeScript and your UI is a web page; the only C++ in the building is generated shell code you never open.

JUCE

The plugin shell, editor hosting, and parameter plumbing are generated from your manifest and schema — there is no framework for you to adopt.

CMake

The export writes the build files and runs the build; if you never look in build/, nothing is lost.

A second DSP language

No separate expression syntax for the fast path. The kernel that runs in the browser is the kernel that runs in the DAW, in the language you already type.

Frequently asked

Start with something that makes a sound.

The quickstart takes you from an empty folder to a pedal making sound in your browser. Or open the playground and hear what the engine already does.