Build a pedal

Distortion into tone into delay, built up one stage at a time — the shape of FX Mini.

An effect kernel reads io.inputs and writes io.outputs. That is the whole difference between a pedal and an instrument. We will build three stages, adding one at a time and listening after each.

Start from the effect template

npx codex create my-drive --template fx-react
cd my-drive
npm run dev

The stub kernel copies input to output. Confirm you hear your source material unchanged before you touch anything — a silent starting point is much harder to debug than a loud one.

Stage one: distortion

Distortion is a waveshaper: a function applied per sample with no memory at all. Because it is stateless, it needs nothing from init() and cannot drift between backends.

process(state, io: KernelIO, params, _events): void {
  const drive = params["drive.amount"];
  const k = drive * 100;
  const degrees = Math.PI / 180;

  for (let ch = 0; ch < io.outputs.length; ch = ch + 1) {
    const input = io.inputs[ch];
    const output = io.outputs[ch];
    for (let i = 0; i < io.frameCount; i = i + 1) {
      const x = input[i];
      const shaped = ((3 + k) * x * 20 * degrees) / (Math.PI + k * Math.abs(x));
      output[i] = Math.fround(shaped);
    }
  }
}

That transfer function is the one renderFx1Variation in @codex-music/audio-kernels uses, so you can diff your output against a known-good kernel while you are learning.

Stage two: tone

Now add memory, and with it the first rule you have to respect. The one-pole lowpass keeps a running value per channel, allocated once in init():

init(sampleRate: number): DriveState {
  return { sampleRate, lp: new Float32Array(2), delay: new Float32Array(2 * 96000), write: 0 };
}

Inside the loop, the filter memory is a stateful accumulation, so it gets Math.fround:

let z = state.lp[ch];
const coeff = Math.min(1, Math.max(0.001, params["tone.cutoff"] / state.sampleRate));
// ...per sample:
z = Math.fround(z + coeff * (shaped - z));
output[i] = z;
// ...after the loop:
state.lp[ch] = z;

Read the coefficient from params once per block, not once per sample. It is cheaper and it is what the compiler expects.

Stage three: delay

A delay line is a preallocated Float32Array and a write cursor. Both live in state; neither is ever resized. Size it in init() for your maximum delay time at the given sample rate and clamp the read offset — the compiler needs to see that every index is bounded.

const maxFrames = state.delay.length / 2;
const offset = Math.min(maxFrames - 1, Math.max(1, Math.floor(params["delay.time"] * state.sampleRate)));
const readIndex = (state.write + maxFrames - offset) % maxFrames;
const wet = state.delay[ch * maxFrames + readIndex];
const out = Math.fround(z + wet * params["delay.mix"]);
state.delay[ch * maxFrames + state.write] = Math.fround(z + wet * params["delay.feedback"]);

Feedback is the other stateful accumulation on this page, and it is the one that punishes you for skipping fround — small rounding differences compound every time around the loop, and a parity check that passed on the dry signal will fail here first.

Wire the schema

Five parameters: drive.amount, tone.cutoff, delay.time, delay.feedback, delay.mix. Give each a group"Drive", "Filter", "Delay" — because groups are what your DAW uses to organize the generated parameter list, and the UI can map over them for free.

Keep delay.feedback under 0.95 at the top of its range. The compiler will not stop you from building a runaway feedback loop; your ears and your neighbours will.

Check parity before you publish

Render the same input through both backends and compare hashes. If they differ, the culprit is almost always a stateful accumulation missing its fround, or a coefficient computed at a different precision on the two sides. See Parity and determinism for the procedure and the current known gaps.