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.