Port a Tone.js patch
Move an existing Tone.js chain onto kernels, node by node, checking parity as you go.
If you already have a working Tone.js chain, you do not have to rewrite it in one sitting. Port it one node at a time and check your work after each, using the Tone version as the reference you are trying to match.
Set the Tone version as your baseline
@codex-music/audio-tone exists exactly for this: a Tone.js/Web Audio adapter
backend, reachable as EngineBackend "tone". It is the migration baseline —
useful to compare against, and explicitly never the parity target.
That distinction matters. Web Audio nodes are not bit-defined: a BiquadFilter
is whatever the browser implements, and a WaveShaper interpolates a 4096-point
curve and may oversample. You are aiming for behavioural equivalence with
Tone — it should sound like the same patch — and for bit equivalence only
between your own WASM and native builds. Chasing sample-exact agreement with the
browser's own nodes is a trap.
Take the measurements first
Before writing any kernel code, render a fixed input through your Tone chain and save it. Same source file, same parameter values, same duration, offline render rather than realtime so the result is reproducible.
You now have a target waveform. Every step below is judged against it, and "judged" means both looking at the difference and listening to it — a 40 dB noise floor of difference is inaudible on a distortion stage and glaring on a clean gain stage.
Map nodes to arithmetic
Most Tone nodes are a few lines once the graph is gone.
| Tone node | In a kernel |
|---|---|
Gain | multiply, one line |
WaveShaper | evaluate the transfer function per sample |
Filter (lowpass) | one-pole or biquad, memory in state |
FeedbackDelay | preallocated Float32Array plus a write cursor |
Envelope | per-voice stage integer plus a level float |
Oscillator | phase accumulator, wrap by subtraction |
Two nodes have no direct translation and should be resolved rather than ported:
anything doing its own resampling, and anything whose behaviour depends on the
audio graph's own scheduling. Both are graph features, and the kernel has no
graph — it has a process() call and the samples in front of it.
Port one stage, keep the rest in Tone
Do not swap the whole chain. Replace the first node with a kernel, leave the remaining Tone nodes downstream, and re-render. The difference against your baseline is now attributable to exactly one stage.
The transfer function in renderFx1Variation
(@codex-music/audio-kernels) is a worked example of this
for Tone 15's Distortion curve — the formula is ported directly, with the note
that the browser's WaveShaper interpolation and oversampling still need to be
quantified. That comment is the honest state of the art on this specific
comparison, and it is the kind of thing you will find in your own chain too.
Add fround as you go
Tone runs in f64 in places where your kernel runs in f32. Every stateful
accumulation you port — filter memory, delay feedback, envelope level — needs
Math.fround at the accumulation, and adding it will change the output slightly
against your Tone baseline. That change is correct. It is what makes the WASM and
native builds agree with each other, which is the guarantee that actually
matters.
Finish, then compare backends
Once the chain is fully ported, drop the Tone baseline and switch to the real check: render the same input through the WASM and native builds and compare the output hashes. Those must match exactly. See Parity and determinism for what the harness does and what is still open.
When to stop
You have finished when the kernel version sounds like the patch you had and the two backends agree bit-for-bit. You have not failed if the null test against Tone leaves residue — you should expect it to, and the residue is usually where you fixed a precision bug the browser was hiding.