Quickstart

From an empty folder to a pedal making sound in your browser, then the same pedal as a VST3.

The codex CLI is proposed, not shipped. The commands below describe the intended developer loop; today the same steps are run through the workspace's own scripts and plugin-shell-kit generate.

Scaffold a project

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

npx codex create my-pedal --template fx-react
cd my-pedal
npm run dev            # http://localhost:5173 — audio on first click

Five templates ship with the scaffolder: fx-react and fx-vue for audio-in/audio-out effects, instrument-react and instrument-vue for note-driven instruments with voice allocation already wired, and midi-tool for tools with no audio path at all.

Write the kernel

The kernel is the only part that has to be careful. It is 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. No audio context, no nodes, no graph. Just samples.

// 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 {
    return { sampleRate, lp: new Float32Array(2) };
  },

  process(state, io: KernelIO, params, _events): void {
    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 is not there: no new inside process, no await, no callbacks, no console.log. Those are not style preferences — the compiler enforces them, and it names the call in your chain that broke the rule.

Declare your parameters

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().

// 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,
    },
  ],
};

Put a UI on it

The UI is a web page. Map over the schema, render whatever control you want, and call setParam with the dotted path.

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

const engine = createAudioEngine({
  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>
  );
}

isNativeHost() checks for the window.codexHost bridge the plugin shell injects before your bundle loads; the engine picks its transport accordingly. One component, two places.

Export a plugin

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

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

What is real today

@codex-music/audio-contracts is complete and stable — every type above exists as written. The native plugin shell exists and passes the VST3 validator, and @codex-music/plugin-shell-kit generates its param table, state codec, bridge JS, and CMake targets from a real manifest. createAudioEngine() currently throws: the shared-WASM browser backend is being wired first, then the native codexHost transport. Read How it works next for the shape of the whole pipeline.