Parity and determinism

What sample-identical means, how it is tested, the f32 rules you inherit, and where the gaps currently are.

The claim that the browser version and the plugin are the same tool is only worth something if it is checkable. This page says what is guaranteed, what is measured, and what is still open.

What "identical" means here

Two different claims get made about audio, and only one of them is a bit-level promise.

Bit-identical applies between our own backends: the same kernel source compiled to freestanding WASM and to a native library must produce byte-for-byte identical PCM from the same input and the same event stream. This is a hard guarantee and it is hash-checked.

Behaviourally equivalent is the weaker claim, and it is all that is offered against Tone.js and Web Audio. Browser nodes are not bit-defined — a BiquadFilter is whatever the vendor implemented, and a WaveShaper interpolates its curve and may oversample. @codex-music/audio-tone is the migration baseline, deliberately never the parity target.

The three preconditions

Identical output requires all three of these, and they are the reason the architecture looks the way it does.

One kernel source. Not two implementations kept in sync by discipline — one restricted-TypeScript file compiled twice, with matching state layout and metadata.

One event vocabulary. AudioEvent is a three-member union with integer frameOffset, never float seconds. sortEvents orders by frame and puts param before noteOn/noteOff at the same frame, and both hosts apply that same ordering. A note that lands on the same frame as a filter change resolves identically in a browser and in a DAW.

One parameter math. normalize/denormalize in @codex-music/audio-contracts are the single implementation, and the generated C parameter table reproduces them exactly — same clamping order, same rounding convention (Math.round as floor(x + 0.5)). A knob position denormalizes to the same float everywhere.

The f32 rules you inherit

The rule is short: Math.fround at every stateful accumulation. Filter memory, delay feedback, envelope followers — anywhere a value survives to the next sample.

The reason is that JavaScript arithmetic is f64 while the compiled kernel's state is f32. A value that lives only inside one sample's computation can round differently without consequence. A value that feeds back into itself compounds its rounding difference on every pass, and a delay line with feedback will diverge audibly long before anything else does.

Denormals, NaN and infinity behaviour, signed zero, and the exact ULP tolerance of each math function are still being specified (GAP-003 in the compiler gap register). Until that lands, stay inside the documented surface: sin, cos, sqrt, exp, log, pow, Math.fround, Math.PI, Math.E, and typed arrays you allocated up front.

How it is measured

@codex-music/audio-devtools is the internal harness. Its hash is FNV-1a 32-bit over the PCM bytes — cheap, order-sensitive, and adequate for "are these the same bytes". Three checks are defined on top of it:

  • render:golden — factory presets plus a fixed MIDI sequence render to checked-in golden WAVs.
  • verify:cross-target-hash — native LLVM, native C, ASan, and WASM-in-worklet lanes render the same kernel and compare hashes.
  • verify:plugin-vs-offline — the built VST3 is replayed against the golden offline render. This is the acceptance criterion for the plugin shell, and it passes today.

The vertical-slice app is the standing proof: one restricted-TS DSP source compiled through three native lanes, matched exactly against a real Chromium AudioWorklet.

Where the gaps are

Three compiler-side items are open and they bound what can be promised.

GAP-002 — borrowed planar f32 spans. The library ABI is bytes-based today, so hosts copy input into a managed array rather than handing the kernel a float* view. It renders correctly and it is not zero-copy; the real-time path needs the span ABI.

GAP-003 — the numeric surface. tanh has no lowering yet, and the tolerance, NaN, and denormal policy is unspecified.

GAP-005 and the transitive effect checker. The compiler does not yet walk the full call graph to reject allocation, exceptions, recursion, locks, and I/O transitively. The rules in Rules of the kernel describe the target profile; today part of that enforcement is convention rather than a compile error.

Treat the first two as bounds on performance and on exotic math, and the third as a reason to actually read your own process() before you publish.