Build a synth

Oscillator into filter into ADSR into gain, with voice allocation and note events — the shape of OSC Mini.

An instrument has no input to read. It generates samples from note events, which means two new problems: turning noteOn/noteOff into sound, and deciding what happens when someone plays a chord.

Start from the instrument template

npx codex create my-synth --template instrument-react
cd my-synth
npm run dev

The instrument templates arrive with voice allocation already wired, so the first thing you hear is polyphonic. This tutorial explains what that wiring is doing so you can change it.

Events, not callbacks

The fourth argument to process is readonly AudioEvent[] — the block's events, already sorted. The union is deliberately tiny:

type AudioEvent =
  | { kind: "param"; frameOffset: number; path: string; value: number }
  | { kind: "noteOn"; frameOffset: number; note: number; velocity: number }
  | { kind: "noteOff"; frameOffset: number; note: number };

frameOffset is an integer sample frame inside the current block, never a float in seconds. sortEvents guarantees parameter changes land before note events at the same frame, on both the web and native paths — which is what makes a note that arrives with a filter sweep sound the same in your browser and in your DAW.

Your loop walks events and samples together: advance to the next event's frameOffset, apply it, keep rendering. Do not batch all events to the top of the block; that is how you lose sample accuracy on fast passages.

One voice

A voice is a phase accumulator, an envelope stage, and an envelope level. All three are plain numbers in a preallocated array — no objects, because objects mean allocation.

// Phase accumulator. Wrapping by subtraction, not by modulo, so it stays exact.
phase = phase + increment;
if (phase >= 1) phase = phase - 1;
const saw = Math.fround(phase * 2 - 1);

The increment comes from the note number: 440 * Math.pow(2, (note - 69) / 12) / sampleRate. Compute it once when the voice starts, not per sample.

The envelope

ADSR is a small state machine per voice: attack rises to 1, decay falls to the sustain level, sustain holds until noteOff, release falls to 0 and frees the voice. Store the stage as an integer and the level as a float, both in preallocated arrays indexed by voice.

Envelope level is a stateful accumulation, so every update gets Math.fround. Miss it here and a long release will drift audibly apart between backends before it drifts anywhere else.

Convert times to per-sample increments in init() where you can, and where a parameter controls them, recompute the increment once per block.

Voice allocation

Fixed voice count, allocated in init(). Eight is plenty to start:

init(sampleRate: number): SynthState {
  const voices = 8;
  return {
    sampleRate,
    voices,
    note: new Int32Array(voices).fill(-1),
    phase: new Float32Array(voices),
    stage: new Int32Array(voices),
    level: new Float32Array(voices),
  };
}

On noteOn, find the first voice with note === -1. If there is none, steal — the usual choice is the voice with the lowest envelope level, because it is the quietest and the steal is least audible. On noteOff, find the voice holding that note number and move it to the release stage.

Sum all voices into the output and divide by a fixed headroom factor rather than by the active voice count; dividing by a changing count makes the level jump every time a note starts.

Filter and gain

The filter runs once on the summed voices, not per voice — cheaper, and it is what most subtractive synths actually do. Its memory is per output channel, and like every other stateful accumulation on this page, it gets Math.fround.

Finish with a master gain from master.level, and give it a group of "Master" so it lands at the bottom of the DAW's parameter list where people expect it.

Next

Once it sounds like something, read Parity and determinism and check your hashes, then codex export to put it in a DAW. A synth exercises the event path much harder than a pedal does, so it is the better test of whether your kernel is really deterministic.