For advanced authors: define a small DSP kernel when the built-in signal processors do not cover your idea.
What it is#
process f compiles a scalar lambda into per-sample bytecode — a user DSP kernel. Where shape bakes its lambda into a 2049-entry lookup table, process keeps it as a tiny stack-machine program that both backends interpret every sample: exact piecewise laws, live macro parameters inside the law, and (via process2) state — filters, integrators, and chaos maps written in Volta itself.
grit = macro "grit" 0.3
lead = patch {
source = saw (freq pitch)
-- drive knob INSIDE the law, not a wrapper gain:
|> process (\x -> tanh (x * (1.0 + grit * 8.0)))
-- a one-pole lowpass from scratch — one float of state:
|> process2 (\x s -> let y = s + 0.2*(x - s) in [y, y]),
ampEnv = adsr 2ms 120ms 0.5 90ms
}
Surface#
Signal-first / pipeable per-voice effects (§11 of Language reference):
process : Sig Audio Bipolar -> (Float -> Float) -> [Sig…] -> Sig Audio Bipolar
process2 : Sig Audio Bipolar -> (Float -> Float… -> [Float, …]) -> [Const] -> [Sig…] -> Sig Audio Bipolar
processS : Sig Audio Bipolar -> (Float -> Float -> … -> [Float, Float, …]) -> [Const] -> [Sig…] -> Sig Audio Bipolar
processC : Sig -> (Float -> Float… -> [Float, …]) -> [Const] -> [Sig…] -> Sig Control Bipolar
processO : Sig Hz -> (Float -> Float… -> [Float, …]) -> [Const] -> [Sig…] -> Sig Audio Bipolar
The trailing [Sig…] are up to four optional signal ports (below). Named user effects built from these — the effect declaration — and feedbackN networks follow; processO, the SOURCE kernel, and the oscillator declaration have their own section (§ Source kernels).
- The function is an inline lambda, a top-level lambda binding, or a partial application of one —
process2 (biquadLp 1200.0 0.9)binds the recipe's leading parameters (constants or macro expressions) and exposes the rest. This is the stdlib spelling. process2's lambda takes(x, s1, …, sN)— one state cell per binder after the input, up to 8 (ir::KERNEL_STATE). Return[out, next]for one cell,[out, [n1, …, nN]]for several. The optional trailing init is a scalar (one cell) or a list ([0.0, 0.5]; missing cells seed 0). State is per channel and resets at each note start.sampleRate— the render rate in Hz — is readable inside kernels only (anSrop; it has no compile-time value). It's what filter-coefficient math needs:let w = 6.283185307179586 * f / sampleRate in ….
Kernel constants and initial state must be finite after conversion to engine f32 precision. Compile-time subexpressions fold in f64 first, so wider intermediate values may reduce to a finite engine value before bytecode is emitted. Native and JSON graph preparation enforce the same boundary; ordinary f32 rounding and underflow remain allowed.
The input, captured parameter ports and initial state use dimensionless samples (bare, normalized, bipolar, linear or ratio); physical-unit signals are rejected at the kernel call. The one exception is declared by its form: a source kernel (processO) reads a frequency in Hz on its input and ports. Control-rate inputs remain legal. Kernel output is bipolar audio. Every executable graph validates bytecode operand counts, stack depth, local indices, supplied parameter/state indices and the final output/state result count before installation. Malformed bytecode is rejected, including unreachable nodes. Legacy omitted state-cell counts mean one cell when stateful and none otherwise. All-zero seed vectors normalize to default zero state, matching their omitted wire form; nonzero seeds cannot extend beyond the declared cells.
Named state (records)#
A kernel's state can be written as a record instead of a positional list: the initial state names its slots, the law takes the whole state as ONE binder after its inputs and ports and reads it by field, and it returns the next state as a record.
-- a one-pole whose one cell is named
smoothLaw = \x st -> let y = st.y + 0.2 * (x - st.y) in [y, { y = y }]
-- a cell and a delay line; `with` carries the fields it does not write
echoLaw = \x st -> [x + 0.5 * dtapi st.line 480.0, st with { line = x }]
-- stereo-linked gain riding: two named cells shared by both channels
rideLaw = \l r st ->
let lvl = max (abs l) (abs r) in
let env = st.env + 0.01 * (lvl - st.env) in
let g = 1.0 / (1.0 + 4.0 * env) in
[l * g, r * g, { env = env, gain = g }]
e = patch {
source = saw (freq pitch)
|> process2 smoothLaw { y = 0.0 }
|> process2 echoLaw { z = 0.0, line = dline 20ms },
ampEnv = adsr 5ms 100ms 0.6 80ms
}
rode = play e !"c3 e3 g3" |> audioFx (\bus -> bus |> processS rideLaw { env = 0.0, gain = 1.0 })
Every stateful law in dsplib and dspfast is written this way: linkedComp reads st.pre and st.gr and returns [l * gain, r * gain, { pre = pre2, gr = gr2 }] from processS (compLaw amount) { pre = 0.0, gr = 0.0 }, so applying a recipe yourself names its cells (process2 (svfLaw 0.0 1200.0 0.7) { ic1 = 0.0, ic2 = 0.0 }). The kernels stdlib recipes stay positional (one or two cells each).
- The fields' written order in the initial state is the slot order; the returned record may list them in any order.
dline <dur>as a field's value makes that field a delay line (dtap st.line d, and its field of the returned state is the value pushed). - The next state is either a record that writes every field, or
st with { … }, which carries the fields it leaves out unchanged. - It works for every stateful form —
process2,processS,processC,processO,spectralMap,spectralPolar— through an inline law, a top-level law or a partial application (processS (compLaw amount) { … }), and the signal ports still follow the state. - Compile-time only.
kernel::statedesugars the record to the positional law (one binder per field,st.f→ that binder, the returned record → the next-state list in field order) before the kernel is compiled, so the kernel IR, the register machine, Cranelift and the wasm codegen are untouched: the program is byte-for-byte the positional one.compiler/tests/kernel_state.rspins it by lowering the shippeddsplib'sslew2,linkedCompanddarkEchoand their positional spelling (a fixture) to identical modules. - Refusals are check-time and name the field: a field read or returned that the initial state does not declare (
unknown state fieldgain), a field the returned record leaves out (`the returned state is missing field `b), a next state returned as a list or a bare value (state field mismatch), the bare state used as a number, and a law taking more than one state binder. A law in a library is refused in the library's source. - The
initport declaresSlot::State: a record where the initial state goes IS the state, not the call's named options, soprocess2 law { init = 0.25 }no longer names the option (it is refused with a sentence that says so). The signal ports after the state are still nameable ({ in1 = … }).
The expression envelope#
Inside a kernel lambda you may use: numbers, + - * /, unary -, comparisons (< <= > >=, chains desugar) and and, ==, if, let, calls to other scalar lambdas (inlined at compile time, ≤16 deep), and the math builtins:
abs min max tanh sin cos floor sqrt exp ln pow atan2
(sin/cos in radians; min/max/pow binary; atan2 y x is the angle of (x, y) in −π … π, Rust's f32::atan2 on every tier.) These same builtins now also fold in any compile-time expression — including shape transfer laws. min / max go further: over LIVE signal arguments they also lower outside a kernel, as an exact Math min/max (§7 of Language reference), so a plain signal formula can clamp; the rest lower to a per-sample Math node (no soft limit — see Gotchas). Aliases, record projections and functions returning a math builtin retain that support inside kernels. Kernel math operates on raw dimensionless samples; physical-unit macro operands are rejected. The transcendental functions require dimensionless numeric inputs; see Language reference §7 for physical-unit constant math.
Compile errors (with a span + the culprit's name): referencing a signal, note param (pitch, vel — vel is legal inside processO), or patch field; case; unit literals; tuples/ lists anywhere but process2's root return; more than 32 captured macros and ports together; recursion past the inline depth.
Macros become kernel parameters#
A free reference to a macro binding is captured as a kernel parameter — read at audio rate, deduped per binding, at most 32 per kernel (macros and signal ports together — ir::KERNEL_PARAMS, raised from 8 by Stage 7). This is the headline over shape: the law itself is live-tweakable.
Both the kernel's input and every parameter are per-sample buffers: the engine reads each parameter afresh for every sample inside the kernel loop (eval_kernel, engine/src/voice/eval_nodes.rs), so a parameter driven by an LFO, an envelope or a dense automation curve is honoured sample by sample. Reading one once per block instead would be a different law for any parameter that moves within the block, so it is only valid where the parameter is proven constant across it.
Semantics (identical on compiler / native / browser)#
- Guarded partial ops:
x/0 → 0,sqrt(x<0) → 0,ln(x≤0) → 0,pow → 0on a non-finite result. A kernel cannot mint NaN from a pole. ifis eager — both arms always evaluate; the condition picks (a branchless select). Write DSP accordingly (no "guard a division withif" — the division is already guarded).- Comparisons push 1.0/0.0, so a comparison is usable as arithmetic.
- Output policy: non-finite → 0, then soft-limit — identity in [-1, 1], tanh knee capping at ±2 (the
feedbacklimiter).refine.rsreports the output interval as exactly [-2, 2]. - Integer division truncates (
4/3is1, language-wide) — write float literals in laws:4.0/3.0.
Delay lines (dline, dtap, dtapi)#
A process2 state slot can be a delay line instead of a scalar cell: write dline <dur> in its place in the init list.
-- a feedback comb: one line, no cells
combLaw = \x buf -> let y = x + 0.6 * dtap buf 240.0 in [y, y]
-- a chorus: a phase cell plus a line (a slot's kind comes from its init)
chorusLaw = \x ph buf ->
let p = ph + 0.8 / sampleRate in
let wet = dtap buf (480.0 + 96.0 * sin (6.283185307179586 * p)) in
[0.5 * (x + wet), [p - floor p, x]]
lead = patch {
source = saw (freq pitch) |> process2 combLaw (dline 10ms) |> process2 chorusLaw [0.0, dline 20ms]
}
- The binder for a line slot is a line handle, not a number.
dtap buf dreads itdsamples back (fractional; 4-point Hermite, linear within two samples of either end — thefeedbackAtring read) anddtapi buf dreadsfloor dsamples back.d = 1is the previous sample's push;dclamps to[1, len], wherelen = ceil(dur · sampleRate). - The slot's element of the next-state list is the value pushed into the line this sample (one push per line per sample, by construction). Pushes commit after the program runs, so every tap in a sample reads the same history. A non-finite push stores 0.
- A line handle may be passed to a helper lambda (
echoOf buf 3.0); using it as a number is a compile error naming the binder. - The duration is a compile-time constant (
50ms,0.25s,1/8c). Caps: at most 4 lines and 2 s of line memory per kernel, and lines share the 8 state slots with cells. Lines are per channel and zero-fill at each note start. A line-only kernel has no scalar cells (statefulis false in the IR). - Voice kernels allocate lines per voice from the voice's prepared ring pool (no allocation on the audio thread), so long lines in a polyphonic patch cost memory per voice; the cost model charges them.
Loops: foldl over a constant range#
Inside a kernel, foldl f init xs needs xs known at compile time. When xs is a run of consecutive integers (range a b) it compiles to ONE Repeat loop — the body is emitted once and run n times (cap 4096) — so a 64-tap FIR is a dozen ops, not two hundred:
avg64 = \x buf -> [foldl (\acc k -> acc + dtapi buf k) 0.0 (range 1 65) / 64.0, x]
Any other compile-time list (a literal, a map) unrolls with each element bound in turn, as does a fold nested inside a loop body (loops do not nest).
Signal ports (sidechains into a law)#
Any kernel call takes up to 4 extra signal arguments, after the function (process) or after the init (process2/processS/processC). Each becomes the next lambda binder after the input(s) and is read every sample, exactly like a captured macro — ports and macros share the cap of 32 parameters.
thump = patch { source = sine (freq pitch), ampEnv = adsr 1ms 120ms 0 50ms }
kickT = play thump !"c2*4"
duckLaw = \x side s -> let g = 1.0 - 0.8 * side in [x * g, s]
bass = patch { source = saw (freq pitch) |> process2 duckLaw [0.0] (follower kickT 5ms 80ms) }
bassT = play bass !"c2"
Ports read dimensionless samples (a follower, an LFO, audio); a physical unit (freq pitch, a duration) is refused naming the port. Ports come after the init, so a stateless processS/processC that takes ports writes [] as its init. On a stereo voice a port is read from its left channel.
Note context: the voice and its signals inside a law#
A kernel on a voice reads the note it plays without declaring ports:
- Voice reads —
vel,noteTime,noteGatework in every voice kernel (process,process2,processS,processC,processO), not only in an oscillator law. - Closed signal expressions — a subexpression of the law that uses none of its binders or
lets and that the kernel cannot compute itself (an envelope,trackParam "bite",noteRand,freq pitch / 1Hz, anlfo) is lowered in the voice graph and fed in per sample, like a signal port the law did not have to declare. The outermost such expression is captured, sofreq pitch / 1Hzis one dimensionless signal; anything that namessampleRate,noteTime/noteGateor a delay line stays kernel math with only its inner signals captured.
-- a velocity- and key-tracked one-pole: brighter for harder, higher notes
bright = \x st ->
let hz = 300.0 + 4000.0 * vel + 2.0 * (freq pitch / 1Hz) in
let c = 1.0 - exp (0.0 - 6.283185307179586 * hz / sampleRate) in
let y = st.y + c * (x - st.y) in
[y, { y = y }]
lead = patch { source = saw (freq pitch) |> process2 bright { y = 0.0 } }
riff = play lead !"c3 g3" |> velocity !"0.4 0.9"
- One written occurrence is one signal. A helper that reads
adsr …, inlined twice, reads one envelope; the same text written twice is two. - Units follow the port rule: a captured signal is dimensionless samples;
freq pitchalone is refused with "divide by a reference (freq pitch / 1Hz)". - Not captured away: a unit mistake (an Hz macro in the law) or a shape error stays an error. Only "this is graph-only" failures turn a closed subexpression into a capture.
- Tracks have no note. On a track (
audioFx, aneffect … on track) a voice read or a note-dependent capture is refused by the graph-context check ("per-note values"); an LFO or a follower still captures. - Spectral laws read captures once per frame, so they take neither: pass a signal there as a port.
- Captures share the 32-parameter cap with ports and macros.
Stereo kernels: processS#
processS f [init] [ports…] runs ONE program per sample over both channels: the lambda is \l r [ports…] s1 … sN -> [outL, outR] (no state) or [outL, outR, next]. The state array (cells and lines) is shared, which is what stereo-linked detectors, mid/side laws and cross-channel wideners need — process2 on a stereo signal runs two independent per-channel instances. On a mono input r reads 0 and outR is ignored. A stereo kernel cannot sit inside a feedback loop (the region is a mono sample-interleaved pass): that is a compile error.
Control-rate kernels: processC#
processC f [init] [ports…] is the process2 envelope run once per 128-sample block, at the block's first frame, with its output held across the block — a Sig Control usable anywhere a modulator goes (custom followers, slew laws, chaos modulators). Inside it sampleRate reads the block rate (sampleRate / 128), so coefficient math written against sampleRate keeps its meaning. A stateless processC returns a number; with state it returns [out, next]. Like processS it is refused inside a feedback loop.
Block-rate hoisting#
The engine hoists every let whose value depends only on constants and sampleRate (a biquad's coefficients for a fixed cutoff, say) into a prologue run once per block, and seeds each sample's locals from it (kernel_values::hoist, applied when the program is decoded). It is a pure reordering — every op sees the same operands, so the audio is bit-identical (pinned by kernel::tests::full_tier_matches_reference_bit_exactly and the compiler's hoisting_moves_block_invariant_lets_only). A value that reads a macro or port is never hoisted: parameters are read per sample and may move inside a block.
Named user effects: the effect declaration#
amt = macro "amt" 0.4
@doc "Tube-ish warmth with a tone knob."
effect warmth (amount: Normalized = 0.4, tone: Normalized = 0.5) on track =
\bus -> bus
|> process2 (\x s -> let y = s + (0.05 + tone * 0.5) * (tanh (x * (1.0 + amount * 6.0)) - s) in [y, y]) 0.0
effect grit (drive: Normalized = 0.3) on voice = \x -> x |> process (\v -> tanh (v * (1.0 + drive * 8.0)))
effect ahead () on track latency 64 = \bus -> bus |> process2 (\x buf -> [dtapi buf 64.0, x]) (dline 5ms)
p = patch { source = saw (freq pitch) |> grit { drive = 0.6 } }
t = play p !"c3 e3" |> warmth { amount = amt } |> ahead {}
- The parameter list is a configuration record — the same rule as a
module's parameters (User Function Arguments §7): each parameter may declare a type and a literal default, a call names only what it changes (warmth { tone = 0.8 }), and{}takes every default. There are no positional arguments and no bare|> warmth(that is a partial application, per the owner's § 10 decision in that spec). - The seat:
on trackmakes a persistent stereo insert (it desugars toaudioFx, so it works in track pipes,busdeclarations andmaster = { inserts = [...] });on voicemakes aSig -> Sigstage for a patch. The body is a function of the bus (or signal). - Parameters are values, substituted before lowering: a literal folds into the kernel law; a
macropassed for a parameter becomes a live kernel parameter, so the knob rides inside the law. latency N(track seat only, whole samples) declares the wet path's latency: it adds to the insert's reported latency for plugin-delay compensation and delays the dry path to match. A latency that lies is audible (misaligned dry/wet), not unsafe.bypassedwraps a user effect when the call spells every field (bypassed warmth { amount = 0.4, tone = 0.5 }); defaults are completed only on the ordinary call path.- An
effectis an ordinary binding: importable,priv-able, documented by@doc. The shipped library moduledsplib(examples/songs/dsplib.volta) is catalogued through the reference entry's module fields (its source and one binding per effect, pinned bythe_dsplib_catalogue_entry_matches_the_module).
Live effect parameters#
Every declared parameter of an effect is live, whichever seat it sits in:
- Pass a macro (
warmth { amount = amt }), or any expression over one (darkEcho { seconds = 0.1 + knob * 0.5 }): the knob rides inside the law. The parameter's declared type checks it: aNormalizedorBipolarone takes a control in its own unit; aFloatone a plain number or any unitless control (a plain,Normalized,Bipolar,LinearorRatiomacro, or arithmetic over one) — a unit-carrying value (800Hz, a seconds macro) is refused at the call, naming the field, because a law reads plain numbers. (AFloatparameter took plain numbers only before 2026-09-25; it is checked asConst Dimensionless, spelledFloatin every signature.) A macro captured by a law directly is held to the same rule, with an error naming the macro instead of an execution-plan failure. MIDI learn binds that macro,modulate amt (…)adds a modulation graph to it, a scenewith { amt = ramp … }orautomate amt [...]moves it — the macro is the automation currency, exactly as for a builtin. - Address it by name — no macro declared — from a scene
with { … }key or anautomate "…"lane:<owner>.<effect>.<param>, where the owner is the declaration that applies the effect: the track (padT.warmth.amount), abus(room.warmth.tone),master(master.fade.wet, alsomaster.inserts.fade.wet), or — for anon voiceeffect — the patch (bass.slew2.amount; or the track, when the patch is written inline in itsplay). A one-parameter effect accepts the shorthandpadT.slew2.
effect tilt (bright: Normalized = 0.3) on track =
\bus -> bus |> process2 (\x s -> let y = s + (0.02 + bright * 0.6) * (x - s) in [y, y]) 0.0
pad = patch { source = saw (freq pitch), ampEnv = adsr 80ms 300ms 0.6 400ms }
padT = play pad !"<d3 bb2>" |> tilt {}
lab = song {
scenes = { open = [padT] with { padT.tilt.bright = ramp 0.1 0.9 } },
arrange = arrange [[4, open]]
}
An addressed parameter becomes an internal knob mix:<address> seeded with the call's constant (or the declared default), and the law reads it per sample like a captured macro; a render with no motion plays the declared value (engine/tests/user_dsp_live.rs: an address at the declared value is within 1e-6 of the unaddressed render, measured offline). A parameter nobody addresses is still folded into the law, so un-addressed effects compile to the same IR, cost and audio as before. The knob's range follows the declared type: Normalized [0, 1], Bipolar [-1, 1], anything else [min(0, 2·v), max(1, 2·v)] around the default v. A lane on it reports cadence sample. Because the minted knob is an ordinary macro, a host write — the call a MIDI binding makes — moves it too.
Naming. An address names a parameter by effect name, never by position: if one owner applies the same effect twice (|> warmth {} |> warmth {…}), the address is ambiguous and refused — pass a macro for the parameter of the instance you mean and automate the macro. The other refusals, each naming the fix: a parameter the effect does not declare (the message lists the ones it does), a multi-parameter effect addressed without a parameter, an effect the owner does not apply, a parameter already bound to a macro (ramp that macro instead), a parameter passed an expression, and two spellings of one site. A declared parameter shadows the insert's own wet: master.fade.wet is fade's wet when it declares one, the insert's dry/wet otherwise.
The insert carries the effect's name in the IR (TrackEffect::AudioFx.name; audioFx's optional fifth argument), which is also how a mixer address lists and spells it.
Hot swap. A recompile keeps an effect's state — delay lines, cells, the whole insert — when its program is identical, and resets it when the program changed (the whole-chain FxReuse contract: the chain key includes the kernel bytecode, so editing the law, a folded constant or a sibling insert on the same chain resets that chain; an unrelated edit elsewhere keeps the tail ringing). Pinned by a_swap_carries_effect_state_on_an_identical_program_and_resets_on_a_changed_one. The macro-set guard applies as for builtins: adding or removing a macro anywhere (including a newly addressed parameter's knob) rebuilds every chain. A voice seat's kernel state is per note (it resets at each note start).
Declared latency, checked. volta doctor (CLI and the MCP doctor tool) runs a null test on every track insert that declares latency N: the track is rendered alone through the engine with the insert bypassed and active, the active render's lag against the bypassed one is found by normalized cross-correlation, and a declaration the measurement contradicts is a LatencyMismatch suspect naming both numbers and the null depth at each (engine/src/latency_probe.rs). An effect that changes its input too much to correlate (correlation < 0.5 — a reverb, heavy drive) is not judged. Bus and master inserts are not probed.
Where it shows. Hover and completion present a user effect like a builtin: its declaration with defaults (effect darkEcho (seconds: Float = 0.375, …) on track), its @doc, the address shape, and a badge — declared in this file, module … · stdlib for a shipped library module, module … · yours for any other imported module (compiler/src/ide/effects.rs; the workbench's hover text reads the same). The Reference panel shows a module entry's module · stdlib chip and its exports with their signatures, and a quick search for an export's name (darkEcho) finds its module. The MCP lookup_builtin answers a library module's effect with its declaration, defaults, import line and address.
Vector feedback: feedbackN#
feedbackN loops (\a b … -> [outA, outB, …]) closes up to 8 coupled loops over one region: loops is a list of constant delay times (each loop reads its own ring, interpolated like feedbackAt) or a count for one-sample loops; the inline lambda binds one name per loop and returns as many signals, element i closing loop i. The value is the sum of the closing signals. The body folds through the compile-time evaluator first, so the list algebra writes the mixing matrix:
householder = \a b c d ->
[a - 0.5 * (a + b + c + d), b - 0.5 * (a + b + c + d), c - 0.5 * (a + b + c + d), d - 0.5 * (a + b + c + d)]
room = patch {
source = feedbackN [29.7ms, 37.1ms, 41.1ms, 43.7ms] (\a b c d ->
map (\v -> (saw (freq pitch) * 0.05 + v * 0.7) |> lpf 6kHz 0.7) (householder a b c d))
}
roomT = play room !"c3"
The region is validated like feedback's (arithmetic, filters, waveshapers, mono user kernels, delays). The binders are separate names rather than one list binder; wrap them ([a, b, c, d]) where a list is wanted.
Oversampling: oversample N stage#
hard = \x -> max (0.0 - 0.4) (min 0.4 (x * 3.0))
lead = patch {
source = saw (freq pitch) |> oversample 4 (\s -> s |> process hard |> process2 (\x z -> [x - 0.5 * z, x]) 0.0)
}
leadT = play lead !"c4"
sig |> oversample N stage (N = 2, 4 or 8) applies stage — any Audio -> Audio function: an inline lambda, a named one, a builtin section (oversample 2 (process hard)) or a voice effect call (oversample 2 (grit { drive = 0.6 }); a stage spelled as an application is threaded as a pipe stage is, f sig a…, so an effect call's omitted parameters take their defaults exactly as s |> grit { drive = 0.6 } does) — and runs every user kernel it builds at N · sr:
- The kernel's input is interpolated N× and its output decimated by linear-phase Kaiser halfband FIR cascades (≈ 80 dB stopband: 63 taps for 1×↔2×, 31 for 2×↔4×, 23 for 4×↔8×). Inside the kernel
sampleRatereads the oversampled rate anddlines are sized at it, so coefficient and delay math written againstsampleRatekeeps its meaning. - Latency is 31 (2×), 39 (4×) or 42 (8×) samples. The graph's alignment pass treats the wrapper like any latent node: a parallel dry path inside the patch is delayed to match (so
0.5 * s + 0.5 * (s |> oversample 2 …)does not comb), the voice's tail grows by it, and a track insert reports it for plugin-delay compensation and delays its own dry path. - Adjacent kernels inside one wrapper fuse into one program behind ONE resampler pair (one latency, not one per kernel). Anything in the stage that is not a user kernel runs at the host rate.
- Parameters and ports are read once per host sample and held across the sub-samples; each sub-sample's output is NaN-flushed and soft-limited before decimation, and the decimated output is limited again (the ±2 contract holds through the filter's ripple).
- Refused: a factor other than 2/4/8, a stage that builds no kernel, a
processCinside it, and an oversampled kernel inside afeedbackloop (its latency would retune the loop). - Track seat: oversample inside the effect body —
effect grit () on track = \bus -> bus |> oversample 2 (process hard)ort |> audioFx (\b -> b |> oversample 2 (process hard)).
Measured on a 6.13 kHz sine (alias power against harmonic power, offline, not listened to — engine/tests/kernel_oversample.rs): a hard clip at ±0.3 reads −16.9 dB at 1×, −33.1 at 2×, −49.1 at 4×, −60.3 at 8×; tanh(13x) reads −11.9 dB at 1× and −39.8 dB at 4×, against the builtin drive 0.5 (the same curve with second-order ADAA) at −14.3 dB.
Source kernels: oscillators in the language (processO, oscillator)#
import "kernels" (blSaw, blPulse, syncSaw)
pw = macro "pw" 0.3
@doc "A band-limited pulse whose duty cycle is a knob."
oscillator buzz (width: Normalized = 0.5) = \hz -> hz |> processO (blPulse width) 0.0
lead = patch { source = buzz (freq pitch) { width = pw } |> lpf 2kHz }
sync = patch { source = freq pitch |> processO (syncSaw 2.5) [0.0, 0.0, 0.0] }
leadT = play lead !"a3 c4 e4"
hz |> processO law [init] [ports…] is the process2 envelope with no audio input: the law's first binder is the frequency in Hz each sample (the receiver — freq pitch, so glide, vibrato and FM reach it), then one binder per signal port, then its state — a phase cell, more cells, dline lines. A stateless law returns a number; with state, [out, next]. The kernel node carries source: true (ir::NodeKind::Kernel.source, omitted when false).
- Units. Hz is the one physical unit a kernel reads, and only through the form that declares it: a source kernel's input and ports admit a frequency (
kernel_values::source_unit) as well as dimensionless samples; a duration is still refused, and every other kernel still refuses Hz. - Voice reads.
vel,noteTime(seconds since the voice started, per sample) andnoteGate(1 while the note is held, 0 from its release) read the voice — in aprocessOlaw and in any other voice kernel (§ Note context). Each is captured as a kernel parameter fed by aNoteParamnode, after the ports and alongside captured macros; inside the law they are plain numbers, assampleRateis. In a source kernelpitchis refused (the first binder already is the frequency). - State re-seeds at every note start, like any voice kernel, so a phase starts where
initsays and a counter cell is note time too. - Every tier runs it: it is an ordinary
Kernelnode whose input is the frequency — reference, dense, register and block-vectorized tiers, and fusion into a following user effect, all bit-identical (engine/tests/source_kernels.rs). It sits insidefeedback(a port may be the loop signal) and composes with any effect after it. Underoversample Nthe frequency is HELD across the sub-samples (interpolating it through the halfband would ring at note start); the law runs at N·sr and its output is decimated. - Big laws. Local slots are numbered once per program; a law that inlines more helper calls than the 32-slot budget allows (the square and pulse inline four polyBLEPs) compiles again with stack-scoped slots — a
let's or an inlined call's slots are reused after their scope closes. Kernels that fit keep their exact bytecode.
The oscillator declaration names one: oscillator name (p: T = lit, …) = \hz -> body. It is the effect declaration with the SOURCE seat — the same configuration-record parameters with literal defaults, the same live parameters — whose subject is the frequency, so a call reads like a builtin oscillator: buzz (freq pitch) { width = 0.3 }, or piped freq pitch |> buzz {}. The body is any function of the frequency (a processO law, or builtin sources: \hz -> saw hz + sine (hz * 2.0)). There is no seat to name (oscillator … on voice is a parse error) and no latency. It desugars like an on voice effect (\hz cfg -> body hz, annotation effect = "source"), so everything that knows effects knows oscillators:
- a macro passed for a parameter rides live; an unaddressed literal folds;
- a scene
withkey or anautomatelane addresses a parameter by name as<patch>.<oscillator>.<param>(lead.syncLead.ratio = ramp 1.5 3.8) — the patch that applies it is the owner, as for a voice effect. AFloatparameter's minted knob is dimensionless and its reference now says so (before Stage 10 an addressedFloateffect parameter read inside a kernel failed plan validation as aNormalizedread); - hover and completion show
oscillator buzz (width: Normalized = 0.5), the call spelled on a frequency (buzz (freq pitch) { width = 0.5 }), the address shape and the badge; MCPlookup_builtinanswers a catalogued module's oscillator (osclib), read from the module's parse when the module imports another; - the DSP probe measures it (the oscillator view, § See your DSP).
Why a keyword, not effect … on source: an oscillator has no input to process and one seat, so on source would be a seat with no alternative; the frequency is its subject, which the call shape (name (freq pitch) {…}, like pulse (freq pitch) 0.3) makes plain. The desugar and every effect surface are shared.
Band-limiting lives in the kernel stdlib (kernels.volta, appended): the two-point polyBlep t dt and four-point polyBlep4 t dt residuals (the cubic-B-spline kernels of Esqueda & Välimäki, DAFx-16 paper 33 — what the builtin oscillators run), blep4Mag, blamp4Mag, polyBlamp dist dt, the phase helpers oscStep hz (the phase step, clamped to ±0.45·sr like the builtins) and oscNext ph step, and the laws blPhasor, blSaw, blSquare, blPulse width, blTri and syncSaw ratio (hard sync: the slave's own wrap and every reset are step events at a fractional time τ, the two-point polyBLEP's "before" half on this sample and its "after" half carried in a cell — the builtin sync's scheme).
Alias power against harmonic power, a held note at each pitch, 4800-point Hann window (engine/tests/source_kernels.rs, measured offline, not listened to; user law vs its builtin twin):
| law | builtin | 110 Hz | 440 Hz | 1.33 kHz | 2.49 kHz | 4.97 kHz |
|---|---|---|---|---|---|---|
blSaw | saw | −52.47 / −52.47 | −46.22 / −46.22 | −41.61 / −41.61 | −36.69 / −36.69 | −30.73 / −30.73 |
blSquare | square | −54.13 / −54.13 | −47.61 / −47.61 | −42.37 / −42.37 | −41.97 / −41.97 | −30.45 / −30.45 |
blPulse 0.3 | pulse … 0.3 | −53.52 / −53.52 | −46.82 / −46.82 | −42.89 / −42.89 | −41.73 / −41.73 | −21.03 / −21.03 |
blTri | triangle | −100.64 / −100.65 | −82.17 / −82.17 | −67.64 / −67.64 | −62.49 / −62.49 | −44.30 / −44.30 |
syncSaw 2.37 | saw (f·2.37) |> sync f | −39.36 / −39.36 | −32.95 / −32.95 | −26.36 / −26.36 | −23.72 / −23.72 | −14.96 / −14.96 |
The user laws match their builtins to within 0.01 dB at every pitch (the test allows 0.5 dB); the naive saw at 2.49 kHz reads −11.7 dB against blSaw's −36.7. A naive saw at 4.97 kHz oversampled 4× reads −20.3 dB against −8.1 at 1×.
Gotchas#
- The kernel runs where you pipe it — inside
source, it precedesampEnv/gainlike any per-voice effect. - An asymmetric law makes signal-dependent DC, same as
drivewithbias(the native per-voice DC blocker absorbs it). - Multi-cell shape errors are explicit: N cells must return N next-states (
[out, [n1, …]]), an init list can't exceed the cell count, and >8 state binders is a compile error naming the cap. - A math function applied straight to a signal (
tanh (saw (freq pitch) * 3.0),exp sig,pow a b) needs no kernel: it lowers to aMathnode that computes what the kernel op computes, sample for sample, but WITHOUT the kernel's ±2 output soft limit — soexp/powkeep their values. Reach forprocesswhen the law is more than one function. - Kernels work inside
feedbackloops; the loop region is evaluated sample-interleaved, so a custom saturator in a tuned Karplus-Strong loop is exact (process/process2, delay lines included; notprocessS/processC).
See your DSP#
Put the caret on a kernel and the workbench measures it. The DSP PROBE dock opens in the right column, above the patch inspector, whenever the caret is on:
- a
process/process2/processS/processCcall (the innermost one; the receiver of a pipe is not "on" the call); - anywhere inside an
effectdeclaration (the whole effect, at its declared defaults); - an
oscillatordeclaration, or aprocessOcall (the oscillator view, below); - a kernel recipe binding: its
@probe "stage"annotation if it has one (@probe "process2 (biquadLp 1200.0 0.7) [0.0, 0.0]", useful in a library with no call sites), else its first use in a kernel call in the file, elseprocess namefor a one-argument law.
eulerLp = \cut x s ->
let g = 6.283185307179586 * cut / sampleRate in
let y = s + g * (x - s) in
[y, y]
lead = patch { source = saw (freq pitch) |> process2 (eulerLp 2400.0) 0.0 }
With the caret on process2 (eulerLp 2400.0) the dock shows it stable and linear, with its response, impulse, step and transfer curves. The cut strip turns to the alert colour at the top of its range, and the finding reads unstable above cut = 0.64×Nyquist (15.3 kHz). That is sr/π, where the forward-Euler step g passes 2.
What it measures. Everything is a measurement of the engine's own graph evaluator running the subject as an insert (a voice kernel runs through the same node code there). Nothing is modelled.
| tile / field | how |
|---|---|
| response | magnitude (filled) and phase (ghosted) of the impulse response, 20 Hz to Nyquist, log frequency; the peak is the readout |
| impulse | an impulse at −12 dBFS, per unit input; readout = time to fall 60 dB |
| step | a step at 0.1, per unit input; readout = the settled DC gain |
| transfer | memoryless subjects: y = f(x) over x ∈ [−1.5, 1.5], sample by sample; otherwise the level each constant input settles to |
| badges | stable / sustains / unstable / NaN/∞; linear when the impulse response scales exactly between two levels, memoryless when it is one sample long and the step is flat |
| parameter strips | each parameter swept across its range, coloured by the verdict at every measured value; the bar is the current value |
Parameters. Every macro the kernel reads is swept over its declared range, at its current knob position. Each declared parameter of an effect is swept too: Normalized over [0, 1]. The numeric literal arguments of a recipe application (biquadLp 900.0 6.0) are also swept, and the recipe's binder names label them (f, q). A frequency-named binder, or a large unnamed value, sweeps logarithmically from 20 Hz to Nyquist. A resonance name (q, res) sweeps [0.1, max(4v, 10)]. Anything else sweeps [0, 1] or [0, 2v]. If a literal must stay a compile-time constant (a range loop count, a dline length), the probe keeps it and says so in a note.
Verdicts and findings. Each case plays an impulse and a full-scale burst, then silence.
NaN/∞: a state cell went non-finite.unstable: a cell passed 1e6, or the output sat on the ±2 limiter after the input stopped. A kernel's output is NaN-flushed and soft-limited, so the probe reads the cells directly.sustains: the output is still audible and not decaying. This is a self-oscillator, a long echo or an undamped resonance, so it is reported as information.
Where the verdict changes along a sweep, the probe bisects the boundary and names it: "unstable above drive = 0.499", "state reaches NaN/∞ above cut = …". It also reports these findings:
- DC gain on a linear stateful subject that gains more than 6 dB. For a nonlinear law that gain is its drive, not a fault.
- A resonant peak above +18 dB.
- A denormal tail. The state decays into subnormal floats after the input stops. The native engine flushes them, but in the browser they cost CPU.
The footer states the stimulus level and the number of measured cases. A nonlinear subject's curves hold at that level only. The numbers are measured offline, not listened to.
When it runs. The probe runs 250 ms after the last edit, caret move or macro move, off the audio thread. The native Studio runs it on a worker thread, typically 50–150 ms plus compile. The browser runs a smaller budget (7 sweep points, 5 bisection steps) on the page. Clicking the dock's title jumps back to the subject.
The oscillator view. A source has no input to probe, so an oscillator or a processO subject is played instead: the scaffold makes it the source of a probe voice driven at const 20Hz * (1.0 + voltaProbeHz * 999.0), and the runner (volta_engine::dsp_probe::probe_oscillator) renders a held note at 110 Hz, 440 Hz, 1.33 kHz and 4.97 kHz through a fresh engine each. Per pitch the report (ProbeReport.oscillator, OscillatorTone) carries the first 32 harmonic levels (dB below the strongest), the alias floor (power off the harmonic series against power on it, the table above's metric), the level, and a log-frequency spectrum the dock draws as one tile per pitch with the alias floor as its readout. The finding names the worst alias floor (a warning above −20 dB, suggesting band-limiting or oversample), or a silent oscillator. The impulse, step, response and transfer fields are empty; parameters are listed at their values, not swept.
The same probe elsewhere. The MCP tool probe_kernel (Use Volta from your own AI agent (MCP)) and volta probe <file> <binding | text> [name=value…] [--json] return the same report.
Compile warning. A stateful kernel whose parameters are all macros is also checked on every compile. The kernel reference interpreter runs an impulse at the macros' initial values and at each end of each macro's range. If that run proves divergence, the compiler warns KernelUnstable: "kernel in lead is unstable when macro drive = 1 (an end of its range): its state grows without bound…". Divergence means the state went non-finite, or kept growing tenfold after the impulse. Kernels with delay lines, signal ports, stereo or control rate are not checked.
How it's implemented#
- Compiler
ir/src/ir.rs—KernelOp(serde tag"k";State {i}omitsi = 0andstate_initserializes a 1-list as a bare scalar, so single-cell IR is byte-identical with the pre-multi-cell format) +NodeKind::Kernel { input, params, ops, stateful, cells, state_init, lines, stereo, control }(the last three omitted when empty/false, so older IR is byte-identical); opsTap/TapI {l},Repeat {n, len},Index,InputR; capsKERNEL_STACK / KERNEL_LOCALS = 32,KERNEL_PARAMS = 32,KERNEL_STATE = 8,KERNEL_LINES = 4,KERNEL_LINE_SECONDS = 2,KERNEL_REPEAT = 4096.ir/src/kernel_values.rs—check(operands, slots, lines, repeat bodies, result count for aShape),line_seconds, andhoist, the block-invariance classification shared by the engine and the cost model.compiler/src/kernel.rs— the lambda→bytecode compiler: static folding viaeval::evaluate_staticat every node (skipped for subtrees carrying local vars — see the capture note in Write a custom audio processor §4),lets and inlined lambda args as local slots, sharedir/src/kernel_values.rsoperand/index verification, andeval_ops, the f64 reference interpreter the tests pin against.compiler/src/builtins.rs—BuiltinKind::{Process, Process2, MathFn(MathFn)};builtins/overloads.rsowns the eleven math descriptors, generated signatures/argument binding, unit constraints and concrete constant/graph/kernel handler identities.MathFn::applysupplies guarded constant math;kernel.rsemits the descriptor's opcode.compiler/src/lower/expr.rs—lower_kernel(one arm for all four forms): resolves the lambda, readsdlineinit items, lowers signal ports, compiles, then materialises each captured macro as aMacroRefnode after the ports inparams;lower_feedback_nforfeedbackN.compiler/src/lower/expr.rs—lower_oversample: applies the stage to the receiver (threaded receiver-first, as a pipe would) and setsNodeKind::Kernel.oversampleon every kernel it built (an IR field omitted when 0, so older IR is byte-identical);ir/src/feedback.rskeeps an oversampled kernel out of feedback regions.compiler/src/parse.rs—parse_effect_decl, theeffectdesugar (a binding overaudioFxor a signal stage, parameters as a defaulted configuration record).
- Engine (one Rust engine — desktop, browser AudioWorklet, plugin target) — three tiers, semantics pinned by test:
engine/src/kernel.rs—eval_kernel, the safe checked interpreter (the readable reference), andKernelProg+eval_prog, the production fast tier: the serde enum flat-decodes once at graph compile into 4-byte instructions + a const pool, re-validated (depths, slots, indices), then evaluated with unchecked indexing. Invalid programs fail graph admission; only valid programs exceeding the dense constant-pool encoding use the reference tier.- The full tier (
eval_prog_full/eval_kernel_full/LineView/LineBank, same file): a pc-driven loop forRepeat, delay-line reads and pushes, stereo inputs/outputs and the hoisted prologue (eval_prologue, once per block). Programs that need none of it keep the loop-freeeval_prog. - The register tier (
engine/src/kernel/regs.rs,RegProg, built byKernelProg::decode_shapebeside the dense code) is what the voice graph's block arm runs (eval_kernel_regs). The verifier's static stack depths turn the bytecode into three-address code over one register file ([x, xr, sr, iter | per stage: params, cells, locals | consts | temps]):Const/Local/Param/Input/Stateloads become operands, aletretargets the instruction that produced its value, a cell commit retargets its producer or runs as an ordered parallel move, anda*b ± cpairs into one superinstruction (two roundings, as before — Rust does not contract to FMA). It runs a whole block per call with cells held in registers.VecPlanthen splits the body: every instruction computed only from the input, parameters, constants and hoisted values runs ONCE PER BLOCK over all its samples (a stateless law is entirely vectorized); the rest runs per sample, reading vector results through copied registers. Pinned bit-exact againsteval_kernel_fullsample by sample (kernel::tests::register_tier_matches_reference_bit_exactly, both modes). The dense tiers stay for a program too large for the 512-register file, and for a stateless law of a few instructions inside a feedback region (below). - Fusion (
graph::kernel_chains/fuse_kernel_chains): adjacent mono kernels whose intermediate signal feeds nothing else compile into oneRegProgrun by the chain's last node (kernel::Fusion::Tail; the others areConsumedand do nothing, their buffers still written for viz), with the NaN-flush + soft limit between stages as aLIMITinstruction. Production fuses only oversampled chains (measured neutral at the host rate, see Cost);CompiledModule::set_kernel_tier(kernel::Tier) is the A/B hook tests and benches use, andengine/tests/kernel_fusion.rsrenders every tier bit-identical. - Native code (
engine/src/kernel/jit.rs, user-dsp-tier Stage 8b; desktop and plugin — the browser has its own backend, next):RegProg::compilehands every register program — a kernel alone or a fused chain — to a Cranelift backend that lowers it to machine code at graph compile, off the audio thread (CompiledModule::from_module,set_kernel_tier, the DSP probe, the track-insert build at load). Registers become Cranelift variables (values live in machine registers), the per-sample loop is compiled too, and entry points share one emitter.oneruns a single sample on the register file (processC, and the interpreter's fallback). The block entry (eval_kernel_native, at the host rate and over anoversample Nkernel's N× stream) is self-contained — onejit::BlockArgs: input, parameter-block, output and per-stage cell pointers, each delay line as a raw ring (jit::LineSlot: base, capacity, write head), the rate; no register file — and does, per block (the kernel floor batch, 2026-09-26): constants as immediates and the hoisted prologue inline; every instruction computed only from constants,sampleRate, the prologue and registers the body never writes, once before the sample loop; a second entry (block_u) that also loads each parameter once and hoists what depends on parameters alone (a coefficient'sexp, a quantiser'spow), taken when every parameter buffer is bitwise constant over the block;tap/tapi/pushas inline loads and stores withLineView's arithmetic operation for operation (a known delay folds its clamp); the output's NaN-flush + soft limit as a flag the caller acts on only when some sample left [−1, 1] (the limiter is the identity inside it), so the loop never calls out for it; anifwhose arm calls out (exp,ln, …) or whose condition is block-invariant as a branch that runs only the picked arm (a known condition picks at compile time); aRepeatof ≤ 2048 unrolled instructions unrolled with its index a constant, the arithmetic that then has known operands folded (the interpreter's own operations, never across a subnormal);min/maxchains rebuilt as balanced trees (fminnmis associative and commutative, −0 < +0 included) with their NaN fix-ups dropped where no operand can be NaN (a tap reads a ring every push made finite); a division by a known power of two as the product with its exact reciprocal, and by any other known divisor but ±0 without its zero guard; compare→select without the 0/1 flag; and (AArch64) every instruction computed from the inputs, parameters and invariants alone four samples at a time in a vector loop before the sample loop — a law with nothing else (a stateless shaper) runs no sample loop at all. A pair entry runs both channels of a mono single-stage kernel in one loop (a mono kernel on a stereo voice or bus), so their recurrences overlap.+ − × ÷ neg abs floor sqrt, every compare and select are the same single IEEE operations in the same order per sample and per lane (superinstructions stay two roundings — no FMA);tanh sin cos exp ln powcall the interpreter's own functions throughextern "C"shims (and the taps/pushes/LIMITinone);min/maxarefmin/fmaxplus NaN selects on AArch64 (Rust'sfminnmsemantics) and a shim elsewhere. The bytes carry no relocations; the engine maps them itself (MAP_JIT+pthread_jit_write_protect_npon Apple Silicon,mprotecton Linux x86-64;com.apple.security.cs.allow-jitinVolta.entitlementsfor the hardened runtime; a plugin runs under its host's signature — Use Volta as a plugin § User kernels in a host). Fallback is the register interpreter — forVOLTA_KERNEL_JIT=0, a refused JIT mapping (probed once per process), an unsupported target, or any program the emitter declines;CompiledModule::set_kernel_native (false)andkernel::jit::forceare the A/B handles,VOLTA_KERNEL_JIT_DUMP=1prints each program's register listing, Cranelift IR and machine code. Spectral kernels stay on the dense tiers. A kernel inside a feedback region (stepped one sample at a time,voice/feedback.rs) calls its block entry over a one-sample range — the same cells, lines, parameters and output policy — when it keeps state, has a hoisted prologue or runs more than six instructions (RegProg::worth_code_per_sample); a stateless handful of instructions measured slower through the entry than interpreted and stays on the dense tier (benches/dsp.rsrowsfb*: the stateful ones ~18 % cheaper, measured offline). - Kernel code in the browser (
engine/src/kernel/wasm.rs, user-dsp-tier Stage 8c): on wasm32 the sameRegProgbecomes a WebAssembly module of its own (wasm-encoder). Its block entries take the native backend's ABI — onejit::BlockArgs, laid out for wasm32 — soeval_kernel_nativeserves both targets, and lower the same plans (engine/src/kernel/plan.rs: invariant hoisting, folding, lazy selects, the unroll order): a fresh local per value or a compile-time constant, the prologue and block-invariant work before the loop, lazy selects asifarms, aRepeatof ≤ 2048 unrolled instructions unrolled (a longer one stays a loop), taps and pushes as inline loads and stores,min/maxinline — exactly the engine's own wasm32f32::min/max((y NaN ∨ x < y) ? x : y,(y NaN ∨ x > y) ? x : y: the second operand on a tie,max(+0, −0) = −0), chains rebuilt as balanced trees, NaN tests dropped where an operand cannot be NaN — and the output limiter as a returned flag;block_u(parameters held for the block) andpair/pair_u(both channels of a mono single stage) as natively.onestays on the register file. Every instruction is the same IEEE operation in the same order as the interpreter, andtanh sin cos exp ln pow, the inter-stageLIMIT's knee (and inonethe taps, pushes andmin/max) are imported from the engine instance itself (engine-wasm/src/kernel_wasm.rsexports them asvolta_kernel_*), so both tiers run one implementation. No SIMD loop (the native AArch64 one is not ported). The bytes are made where the graph compiles (the control worker) and queued in a fixed-size lock-free registry in shared memory (kernel::wasm::REG); the control worker's JavaScript takes each job's bytes (kernel_wasm_take_job/kernel_wasm_job_bytes) and transfers them to the AudioWorklet (aWebAssembly.Moduledoes not structured-clone into a worklet), whichWebAssembly.compiles them asynchronously, instantiates against the shared memory and the shim exports, grows the engine's exported function table (--export-table --growable-table; wasm-bindgen drops LLD's export,scripts/wasm-export-table.mjsrestores it after wasm-pack), puts every exported entry in (kernel_wasm_entry_names:block,one,block_u,pair,pair_u) and publishes the indices (kernel_wasm_install; a table that disagrees with the module is refused) — all on its message loop, never insideprocess()(runtime/public/worklets/kernel-code-host.js, used bynative-engine-core.jsandengine-control-worker.js).installmarks the calling thread as the host (athread_local!): tables are per instance while statics are shared, soRegProg::jit()answersSomeonly on the worklet onceblockandoneare in, and the audio path calls throughcall_indirect(a Rustfnpointer is a table index). Until then, on the control worker (the DSP probe, offline paths) and on any failure — a compile or instantiate error, a full registry, the kill switch — the register interpreter renders the same samples. Retired programs hand their table slots back through a free list. Kill switch:?kernelWasm=0on the page URL orlocalStorage["volta-kernel-wasm"] = "0"(read bybootEngineWorker, applied by the control worker before the engine exists);kernel_wasm_set_enabled/kernel_code_statsare the RPC handles. Public players (pkg-single, no control worker) compile each module synchronously in the processor's message handler after a call. - Oversampling (
engine/src/kernel/oversample.rs): the halfbandResampler, its state kept in the kernel's prepared line bank (LineBank::need/layout_os) so voice spawn stays allocation-free;graph_quality.rsadds its latency (once per fused run) to the node. With kernel code (native or the browser's module) anoversample Nkernel runs the block entry over the N× stream (voice/eval_nodes.rs::eval_kernel_native),BLOCK / Nhost samples at a time: every host sample of the chunk is interpolated first (a source kernel's frequency is held), the entry runs over the chunk's sub-samples atN · srwith each parameter held across its host sample (the block-uniform entry when all hold for the chunk), the outputs are limited only when the entry flags one, then decimated and limited again; a fused chain's later stage inputs are each host sample's last sub-sample's. The interpolator and decimator keep separate state and read only their own streams, so this is exactly the interpreter's per-sampleup → one × N → down; the interpreter (andone) remain for no code, more than 16 parameters, or missing resampler state. engine/src/graph.rs—CompiledNode::Kernelcarries both (prog: Option<Arc<KernelProg>>+opsfallback),cells,state_init,lines,stereo,controlandfull(which tier runs it).engine/src/voice/rings.rs— prepares one ring per line-carrying kernel per voice (a kernel ring class), so voice spawn stays allocation-free.engine/src/audio_fx.rs— a declaredlatencyadds to the insert's graph latency (PDC report + dry-path delay).engine/src/voice/state.rs—NodeState::Kernelholds per-channel cell arrays; block eval arm + afb_steparm for feedback regions; NaN flushfb_soft_limiton every sample;srfeeds theSrop.
- Probes —
compiler/src/dsp_probe.rs(subject location, the scaffold and parameter promotion),engine/src/dsp_probe.rs(the runner;Voice::kernel_healthreads the cells),ir/src/dsp_probe.rs(the report),compiler/src/kernel_stability.rs(the compile warning),ui/src/dsp_probe.rs(the dock). Pinned byengine/tests/dsp_probe.rs.
The DSP library (dsplib)#
Two shipped library modules are written entirely in the language, over the kernel stdlib (§ The kernel stdlib): dsplib — effects (filters, delays, reverbs, dynamics, colour; this section) — and osclib — oscillators and instruments (sources: band-limited saw, pulse, triangle, hard sync, FM, a wavefolder, a Karplus–Strong string, a drum voice; § The oscillator library). Both are catalogued (hover, the Reference panel, MCP lookup_builtin), and the recipes behind them are DSP cookbook.
import "dsplib" (slew2, linkedComp, darkEcho, fdnRoom) — effects written in the language, each an effect over kernels (examples/songs/dsplib.volta, auditioned by examples/songs/dsplab.volta). The first three are pinned against the builtin they re-implement by engine/tests/user_dsp_ports.rs (measured offline, not listened to; the builtins remain the reference and are not replaced):
| effect | re-implements | tier | measured against the builtin |
|---|---|---|---|
slew2 { amount } (voice) | slew (Airwindows Slew2) | six-cell process2 | max |Δ| 1.3e-7 on a 1.76 kHz saw |
linkedComp { amount } (track) | compress | processS, two cells | steady level within 1e-4 dB (15.8 dB of reduction) |
darkEcho { seconds, feedback, damp, wet } (track) | delayTrack … damp wet | process2, a cell + a dline | bit-identical over 2 s |
fdnRoom { decay, wet } (track) | — | feedbackN over four lines | finite, decays ≥ 6 dB by 0.8 s |
linkedComp updates its gain every sample where compress holds it for 16 samples; the measured steady state is unaffected.
The Stage 11 library (appended to the same module; pinned by engine/tests/dsplib_library.rs, measured offline on 48 kHz renders with levels under the ±1 kernel limiter knee, not listened to). Recipes and the reasoning behind each design: DSP cookbook. A song that uses nothing else: examples/songs/handwired.volta.
| effect | twin / reference | kernel | measured |
|---|---|---|---|
onePole { cutoff, mode } (voice) | bilinear one-pole closed form | TPT, 1 cell | LP/HP gain within 0.001 dB of 1/√(1+(tan(πf/fs)/tan(πfc/fs))²) at 250 Hz – 12 kHz |
tptSvf { cutoff, q, mode } (voice) | svf "lp|bp|hp|notch" | TPT SVF, 2 cells | max |Δ| ≤ 1.6e-7 in all four modes; 1.9e-7 with the cutoff swept 200 Hz – 8.2 kHz by a 1 kHz sine through a port |
envFilter { sens, lo, hi, q, mode } (voice) | — | follower + SVF, 3 cells | (an auto-wah; the SVF solve is tptSvf's) |
zdfLadder { cutoff, resonance } (voice) | ladder (drive 0, res < 0.8) | ZDF ladder, 4 cells | max |Δ| ≤ 1.8e-7 at resonance 0, 0.4, 0.7 |
diffuser { size, amount } (track) | allpass | 4 lines | probe magnitude flat to ±0.001 dB |
dattorro { decay, wet, damp } (track) | plate | process2 1 cell + 4 lines, then processS 4 lines + 3 cells | after 250 ms (the builtin adds early reflections) residual −46.1 dB re the tail; T60 1.851 s both |
chorusLine { amount, rate, depth } (voice) | chorus | 3 cells + line | max |Δ| 1.3e-5 over 1 s (Kahan-compensated LFO phase) |
flangeLine { amount, feedback } (voice) | flanger amount feedback | 3 cells + line | max |Δ| 2.8e-5 over 1 s |
phase90 { amount, rate, feedback } (voice) | allpass phase closed form | 4 first-order TPT allpasses, 6 cells | notch at the computed −180° frequency (338.9 Hz): −117.8 dB |
compressor { threshold, ratio, knee, attack, release, makeup } (track) | static curve | processS, 1 cell | a 6.02 dB input step moves the output 1.507 dB (1/R: 1.505) |
fbComp { threshold, ratio, attack, release } (track) | feedback closed form | processS, 2 cells | 3.442 dB for 6.02 dB (1/(2 − 1/R): 3.440) |
optoComp { amount } (track) | — | processS, 2 cells | recovers to −1 dB in 0.71 s after a 100 ms burst, 3.34 s after a 2 s burst |
brickLimit { ceiling, release } (track, latency 64) | limiter | processS, 1 cell + 3 lines, a 64-tap Repeat | sample peak ≤ ceiling; below it a pure 64-sample delay (max |Δ| 0); level within 0.5 dB of limiter at −6 dB |
transients { attack, sustain } (track) | transient | processS, 7 cells | max |Δ| 6.0e-8 |
tapeSat { drive, bias, tone } (track) | — | oversample 2 ADAA tanh, 3 cells | alias power −48.3 dB on a 6.13 kHz sine at drive 0.8 (saturate 0.8 "tape": −29.0 dB) |
bitcrusher { bits, downsample } (voice) | bitcrush | 2 cells | bit-identical at 7 bits (bitcrush 0.6) |
ringer { hz, mix } (voice) | ringMod (sine …) | 1 cell | max |Δ| 7.1e-8 |
The control-rate versions (appended 2026-09-26, the kernel floor batch; pinned by dsplib_library::the_control_rate_versions_keep_their_laws, measured offline, not listened to). Each computes its expensive figure the way its builtin twin does — the laws above stay as written, so a song using them renders what it always did:
| effect | twin | kernel | measured |
|---|---|---|---|
linkedComp2 { amount } (track) | compress | processS, 6 cells: the pre-smoother every sample, the gain computer every 16th, the dB one-pole every sample, the held gain slewed (τ 16 samples) — compress's own structure | steady level on a hot stereo tone: Δ 0 dB against compress 0.6 (15.8 dB reduction), R/L kept |
compressor2 { … } (track) | compressor | processS, 6 cells: the gain computer on each 16-sample window's peak, the held gain slewed | a 6.02 dB step moves it 1.506 dB (1/R: 1.505) |
bitcrusher2 { bits, downsample } (voice) | bitcrusher | downsample ≤ 1 skips the hold (a block-invariant if) | bit-identical to bitcrusher with and without the hold |
Every parameter is ridden by address (t.dattorro.decay = ramp 1.5 4.0, or the patch for a voice effect: bass.zdfLadder.cutoff) or by a macro. A Float parameter (cutoffs, times, dB) takes a unitless macro expression scaled into its range — zdfLadder { cutoff = 200.0 + knob * 4000.0 } — not a Hz macro (§ Live effect parameters).
The oscillator library (osclib)#
import "osclib" (polySaw, polyPulse, polyTri, syncLead, fmPair, folder, ksPluck, noiseDrum) — oscillator declarations over source kernels (examples/songs/osclib.volta, played by examples/songs/osclab.volta, catalogued like dsplib — the entry's moduleSource is pinned by the_osclib_catalogue_entry_matches_the_module):
| oscillator | what it is | measured |
|---|---|---|
polySaw { detune } | blSaw, plus a twin detuned by detune cents (0 = one saw) | the blSaw row above |
polyPulse { width } | blPulse — PWM with a macro | the blPulse row |
polyTri {} | blTri | the blTri row |
syncLead { ratio } | syncSaw — sweep ratio by address | the syncSaw row |
fmPair { ratio, index, feedback } | two-operator phase modulation, modulator self-feedback (two-sample average) | renders finite |
folder { fold, bias } | a sine through the triangle folder, oversample 4 | renders finite |
ksPluck { decay, bright } | Karplus–Strong: one period of Wichmann–Hill noise × vel into a dline tuned to the note, a two-tap loss filter whose delay is compensated, T60 = decay, damped to 80 ms by noteGate | a 220 Hz pluck's fundamental on the 220 Hz bin |
noiseDrum { sweep, decay, noise } | a sine body swept from (1 + sweep)× the note, a noise snap, every envelope from noteTime | renders finite |
Noise is Wichmann–Hill (three small LCGs whose products stay below 2²⁴, so each step is exact in f32 — the same sequence on every host), seeded per note from a randomPerNote (uniform [0, 1]) port. Every oscillator renders finite and sounding at three pitches (every_osclib_oscillator_renders).
Cost#
cargo bench -p volta-engine --bench dsp -- ports/ benches every dsplib port against its builtin twin; VOLTA_PORTS_TABLE=<reps> (with VOLTA_PORTS_ONLY_TABLE=1 to skip criterion) prints the interleaved table: each scene renders a base program and the same program with one stage, and a stage's cost is with − base in ms per rendered second. Voice stages run on an 8-voice held chord, track stages once on a stereo bus; VOLTA_PORTS_ONLY= a,b restricts the table to named rows and VOLTA_PORTS_SPIN=<row> renders one scene for 30 s for a sampling profiler. Best of 40 interleaved 0.1 s windows on a loaded machine (history: Kernel Floor 2026 09 26):
| stage | builtin | port | port / builtin |
|---|---|---|---|
slew2 vs slew 0.7 (voice) | 2.86 | 1.62 | 0.57× |
biquadLp 1200 0.9 vs lpf 1200Hz 0.9 (voice) | 3.53 | 1.94 | 0.55× |
4-kernel chain softclip → biquadLp → dcblock → onepoleLp (voice) | — | 5.71 | — |
tptSvf vs svf "lp" (voice) | 4.48 | 2.84 | 0.63× |
zdfLadder vs ladder (voice) | 13.8 | 12.2 | 0.88× |
chorusLine vs chorus (voice) | 11.4 | 5.22 | 0.46× |
flangeLine vs flanger (voice) | 14.6 | 5.90 | 0.40× |
phase90 vs phaser (voice) | 13.7 | 18.3 | 1.33× |
ringer vs ringMod (voice) | 3.20 | 2.69 | 0.84× |
bitcrusher vs bitcrush (voice) | 0.27 | 1.31 | 4.9× |
linkedComp vs compress 0.6 (track) | 0.37 | 0.75 | 2.0× |
compressor vs compress (track) | 0.37 | 0.66 | 1.8× |
darkEcho vs delayTrack 0.25 0.5 0.5 0.4 (track) | 0.29 | 0.34 | 1.18× |
brickLimit vs limiter (track) | 1.85 | 2.33 | 1.26× |
transients vs transient (track) | 0.36 | 0.71 | 1.97× |
dattorro vs plate (track) | 27.5 | 7.37 | 0.27× |
fdnRoom vs reverb 0.8 (track) | 28.5 | 16.3 | 0.57× (a feedback region: dense tier) |
tapeSat vs saturate "tape" (track) | 41.4 | 13.7 | 0.33× |
linkedComp2 vs compress 0.6 (track) | 0.37 | 0.58 | 1.57× |
compressor2 vs compress (track) | 0.36 | 0.52 | 1.46× |
bitcrusher2 { bits = 7 } vs bitcrush (voice) | 0.25 | 0.48 | 1.91× |
| identity law, voice (8 voices) — the kernel floor | — | 0.24 | — |
identity law, track insert (audioFx + processS) | — | 0.10 | — |
Tier ablation on the voice ports (same graph, CompiledModule::set_kernel_tier
set_kernel_native; ms per rendered second over the voice base, this batch):
| stage | dense stack tier | register tier, interpreted | register tier, native | fused, native |
|---|---|---|---|---|
slew2 | 52.0 | 18.1 | 1.53 | 1.43 |
biquadLp | 15.6 | 5.48 | 1.87 | 1.79 |
| 4-kernel chain | 41.9 | 15.0 | 5.61 | 4.93 |
tptSvf | 55.0 | 15.0 | 2.74 | 2.69 |
zdfLadder | 79.3 | 39.2 | 12.0 | 12.0 |
chorusLine | 49.2 | 25.6 | 5.05 | 5.12 |
flangeLine | 53.3 | 27.9 | 5.64 | 5.97 |
phase90 | 113.8 | 58.7 | 19.0 | 19.0 |
bitcrusher | 30.5 | 7.41 | 1.15 | 1.28 |
ringer | 15.5 | 10.4 | 2.50 | 2.62 |
bitcrusher2 | 39.7 | 8.16 | 0.38 | 0.39 |
| identity law | 2.78 | 1.20 | 0.11 | 0.13 |
Read honestly: every original dsplib twin but bitcrusher sits within 2× of its builtin (linkedComp at 2.0×, transients 1.97×) — thirteen of the seventeen at or under 1.33×, four at half their builtin or less. Above 2× stays bitcrusher (4.9×): its law carries a sample-and-hold phase recurrence (add, compare, select: ≈ 7 cycles a sample on the critical path) that bitcrush does not have, even at downsample = 1; bitcrusher2, which skips the hold when downsample ≤ 1, renders it bit for bit at 1.9×. linkedComp (2.0×) and compressor (1.8×) compute ln and exp every sample where compress holds its gain 16 samples; linkedComp2/compressor2 take the builtin's control rate (1.6×/1.5×, linkedComp2 on compress's level to the printed digit). transients (2.0×) runs two ln and an exp every third sample. All measured offline, not listened to.
The browser (node 25 / V8, the engine Chrome runs, through the threaded engine wasm; CPU ms per rendered second over the seat's base, best of 30 × 0.1 s with the base in the same loop, on a loaded machine; cheap builtins move ±50 % between runs):
| stage | builtin (wasm) | port, interpreted | port, compiled | compiled / builtin |
|---|---|---|---|---|
slew2 vs slew | 3.47 | 36.9 | 1.77 | 0.51× |
biquadLp vs lpf | 3.46 | 7.83 | 3.24 | 0.94× |
| 4-kernel chain | — | 21.6 | 8.86 | — |
tptSvf vs svf | 5.23 | 20.9 | 4.20 | 0.80× |
zdfLadder vs ladder | 14.3 | 47.0 | 15.8 | 1.11× |
chorusLine vs chorus | 19.0 | 34.1 | 8.29 | 0.44× |
flangeLine vs flanger | 20.6 | 35.1 | 8.80 | 0.43× |
phase90 vs phaser | 14.2 | 97.5 | 56.1 | 3.9× |
ringer vs ringMod | 4.04 | 12.0 | 3.07 | 0.76× |
bitcrusher vs bitcrush | 1.30 | 9.66 | 1.51 | 1.16× |
bitcrusher2 vs bitcrush | 1.02 | 12.4 | 1.17 | 1.15× |
linkedComp vs compress | 0.42 | 7.46 | 2.15 | 5.1× |
linkedComp2 vs compress | 0.57 | 7.86 | 0.70 | 1.23× |
compressor vs compress | 0.45 | 4.09 | 1.70 | 3.8× |
compressor2 vs compress | 0.69 | 7.93 | 0.92 | 1.33× |
darkEcho vs delayTrack | 0.63 | 3.27 | 0.75 | 1.19× |
brickLimit vs limiter | 9.78 | 87.8 | 2.90 | 0.30× |
transients vs transient | 0.43 | 11.8 | 0.89 | 2.1× |
dattorro vs plate | 30.7 | 70.5 | 11.4 | 0.37× |
fdnRoom vs reverb | 31.6 | 24.1 | — | 0.74× |
tapeSat vs saturate "tape" | 77.2 | 54.3 | 24.7 | 0.32× |
Read honestly: thirteen of the seventeen original twins sit within 1.2× of their builtin in V8 (twelve natively), eight at or under 0.8×. Above 2× stay the laws that call libm every sample (linkedComp, compressor, phase90, at times transients) — wasm's software libm through an import call is the cost; linkedComp2/compressor2 land at 1.2–1.3×. History and raw rows: Kernel Wasm Floor 2026 09 26, Kernel Wasm Codegen 2026 09 26. Measured offline in node, not listened to.
Oversampled laws (block-driven oversampling: the block entry over the N× stream instead of one per sub-sample; before → after in one run each, natively best of 30 × 0.1 s wall clock in cargo bench --bench dsp at load ~20, the browser CPU time in one node process — ms per rendered second over the seat's base):
| law | native before | native after | browser before | browser after |
|---|---|---|---|---|
oversample 4 (process tanh), 8 voices | 58.3 | 50.6 | 98.3 | 80.0 |
oversample 2 fused chain (softclip → biquadLp) | 34.9 | 28.2 | 45.3 | 37.5 |
oversample 8 (process2 onepoleLp) | 85.3 | 81.3 | 122.6 | 111.4 |
tapeSat (track, oversample 2) | 13.5 (0.33× its builtin) | 8.7 (0.20×) | 25.0 (0.33×) | 16.6 (0.21×) |
Read honestly: 1.05–1.6× on these, the most where the law does real work per sub-sample (tapeSat's ADAA); a one-instruction law at 4× or 8× is now almost all interpolator and decimator — 63/31/23-tap FIRs per stage, the same code the interpreter runs, whose summation order fixes every oversampled render's bits. Measured offline, not listened to.
Compared with Faust (Faust 2.88.0; seven laws written in both languages with the same math, each timed as the kernel alone over one second of the same input, minimum of three rounds — method, every number and the caveats in Faust Comparison 2026 09 28; bench/faust/run.sh, bench/faust/wasm-bench.mjs):
| one-pole | biquad | TPT SVF | ZDF ladder | compressor | tanh shaper | echo | |
|---|---|---|---|---|---|---|---|
native: Volta ÷ Faust C++ clang -O3 | 1.46× | 0.82× | 1.25× | 1.50× | 1.87× | 1.23× | 1.49× |
| browser: Volta ÷ Faust's wasm backend | 0.47× | 0.44× | 0.50× | 0.87× | 0.51× | 0.36× | 0.48× |
| browser: Volta ÷ Faust C through LLVM | 1.09× | 0.76× | 0.81× | 1.21× | 0.50× | 0.37× | 1.10× |
Read honestly: natively an LLVM-compiled Faust kernel is 0.8–1.9× ahead — Volta's never-NaN semantics keep a division guard and min/max fix-ups on the recurrence chain, and Cranelift spills state around libm calls; in the browser Volta's compiled kernels beat Faust as it ships to the web on every law (its wasm backend keeps recursion state in linear memory and calls Math through JS) and sit within ±25 % of a best-case LLVM build on arithmetic. The whole-diagram fusion Faust does around a kernel was not compared. Measured offline, not listened to.
Verification#
compiler/src/lower/tests.rs— bytecode↔closure equivalence off-grid (cubic), if-law exactness, macro capture + dedup (param node is the rightMacroRef), stateful one-pole vs closed form +initplumbing, helper- lambda inlining, the shadowed-param regression, culprit-naming errors, arity/shape errors,shape (\x -> tanh …)(MathFn in the static evaluator).engine/tests/kernels.rs— through the real engine: linear kernels are sample-exact; the Chebyshev cubic isolates a pure third harmonic (⅓ of the unshaped fundamental, residuals < −46 dB); a macro param at its initial is exact scaling; theprocess2one-pole matches the reference recurrence sample-for-sample AND its closed-form magnitude response at 375 Hz; a guarded pole renders silence, never NaN.- Multi-cell against ground truth:
kernel_multi_cell_matches_recurrence(compiler, DF2T recurrence cell-for-cell), andmulti_cell_biquad_matches_closed_form_at_two_freqs(engine e2e — the stdlib biquad recipe applied via partial application matches the RBJ closed-form magnitude at 375 Hz and 1.5 kHz within 1 %). - Stage 8:
kernel::tests::register_tier_matches_reference_bit_exactlyandfused_chain_matches_the_unfused_chain_bit_exactly(register tier, per-sample and block-vectorized, againsteval_kernel_fullsample by sample, cells and line memory included);engine/tests/kernel_fusion.rs(dense, register, production and all-fused tiers render bit-identically through the engine — voice chains, stereo voices, lines and loops in a chain, a stereo port after a mono stage,processS/processCbeside a chain; the register tier shortens every port kernel);engine/tests/kernel_oversample.rs(alias power falls with N and an oversampledtanhlaw aliases no worse than builtindrive; the wrapper is a pure delay of its latency, parallel paths and a track insert's dry path align, a fused chain delays once; the ADAA helpers cut aliases at 1×);kernel::oversample::tests(whole-sample latencies, the round trip is a pure delay);compiler/tests/user_dsp_tier.rs(oversamplemarks the stage's kernels, refusals; an effect call as the stage takes its parameter defaults as the piped spelling does). All measured offline, not listened to. - Stage 8b:
kernel::jit::tests(min/maxover ±0/NaN/±∞/denormal pairs against Rust; 400 random valid programs over every opcode family, cells, lines, loops, locals, stereo and parameters, plus 120 random fused chains — both entry points against the register interpreter, outputs, stage inputs, cells, the register file and line memory bit for bit);engine/tests/kernel_native.rs(everydsplibport on its seat incl. an oversampled law, everyoscliboscillator and the kernel songs render bit-identically native vs interpreted on the production and all-fused tiers; the interpreted module carries no code; and block-driven oversampling: every factor, a fused chain, a port moving every host sample, a stereo track kernel and a delay-line effect);rt_safety::native_kernel_code_is_allocation_free_including_the_swap_in(cold, warm and a module swap to fresh native code: zero allocations, and the swap's callback cost identical to the interpreter's). - Stage 8b, the kernel floor (2026-09-26):
kernel::jit::testscompare the self-contained block entry, its block-uniform twin (parameters held per block) and the pair entry (two channels, two interpreter instances) against the interpreter on every random program — outputs, cells, line memory and write heads;random_select_programs_…(300 programs of nestedifs whose arms call out, shared locals, cells, lines — 152 with lazy selects) andunrolled_windows_and_min_max_chains_…(160 folds over a line with known delays and weights,min/maxchains over ±0, ±∞ and NaN) pin the branches, the unrolling, the folding and the trees;kernel_nativeadds the control-rate ports, a stateless law (the vector loop alone), a parameter swept every sample, a stereo voice (the pair entry) and an identity law;plugin-target/tests/kernel_jit_fallback.rsrenders the plugin bit-identically with every executable mapping refused (Use Volta as a plugin § Verification). All measured offline, not listened to. - Stage 8c:
kernel::wasm::tests(the emitted module validates under wasmparser with threads, declares exactly the memory + twelve shim imports and the two entry points; the registry round trip and the forgotten job);runtime/scripts/kernel-wasm-test.mjs(node/V8, both engine packages, inmake test-browser: the min/max corner table, 400 random programs + 120 random chains through both entry points against the register interpreter — outputs, stage inputs, cells, register file, line memory, bit for bit; everydsplibport on its seat, everyoscliboscillator and the kernel songs rendered through the engine compiled vs interpreted, byte-identical;spectrallabidentical with zero modules;--benchprints the browser column above);runtime/scripts/kernel-code-browser-test.mjs(handwiredin a real Chromium AudioWorklet: every emitted module installed, the transport advances, non-silent; the?kernelWasm=0page plays on the interpreter). Measured offline, not listened to. - Stage 10:
engine/tests/source_kernels.rs(the alias table against the builtins; dense, register, fused and production tiers bit-identical on every band-limited law, a PWM macro, a fusable source → filter chain and the hard sync;noteTimeagainst the builtin sine at phase 0,noteGatesilent after release; a source infeedbackand underoversample 4; everyoscliboscillator; the pluck's tuning),compiler/tests/source_kernels.rs(the IR, the unit and voice-read refusals,blSawandblSquareagainst closed forms through the f64 reference, scoped slots, theoscillatorcall shapes, an address, hover, the catalogue entry),rt_safety::source_kernels_are_prepared_before_cold_polyphony_and_retriggers, andengine/tests/dsp_probe.rs(the oscillator view). All measured offline, not listened to. - Demo songs:
examples/songs/kernelworks.volta(Stage 1–2 surface) andexamples/songs/kernellab.volta(the stdlib: in-kernel-macro biquad bass, wavefold + dcblock lead, crush/crossover hats, logistic chaos pad); golden IR snapshots pinned.
The kernel stdlib (lib/kernels.volta)#
(examples/songs/kernels.volta, imported as import "kernels".) Classic laws as importable recipes (all plain lambdas — process / process2 + partial application): waveshapers softclip diode crossover wavefold crush cheb2 cheb3 · filters onepoleLp onepoleHp biquadLp biquadHp biquadBp (RBJ cookbook, DF2T, coefficients from sampleRate so a macro can ride the cutoff) · utilities dcblock slewLim · levels log10 todB fromdB (plain functions to call inside a law: todB floors at −120 dB, fromdB d is 10^(d/20); kernel math has ln, not log10) · chaos logistic (seed it: process2 (logistic 3.9) 0.5) · anti-aliasing adaaTanh drive and adaaClip t — first-order antiderivative anti-aliasing (Parker, Zavalishin & Le Bivic, DAFx-16): the output is the mean of the curve over the segment from the previous input to this one, (F(x) − F(x₁)) / (x − x₁) with F the antiderivative (lncosh, clipAD), falling back to the curve at the midpoint when the inputs nearly coincide; one cell of state (the previous input, seed 0.0) and half a sample of delay: process2 (adaaTanh 6.0) 0.0. Measured at 1× on a 6.13 kHz sine: alias power −11.9 → −21.5 dB for tanh(13x), −16.9 → −25.4 dB for a clip at ±0.3, and −50.3 dB for the ADAA clip inside oversample 2. Auditioned by kernellab. Band-limited oscillators (Stage 10, appended at the end of the module): oscStep oscNext polyBlep blep4Mag polyBlep4 blamp4Mag polyBlamp and the processO laws blPhasor blSaw blPulse blSquare blTri syncSaw — see § Source kernels for the measurements. Fast math: fastExp2 fastExp fastLn fastLog2 fastSin fastCos fastTan fastTanh — 1–3 ulp polynomials over ilog2/exp2i, faster than libm in the browser; dspfast uses them (Kernel Fast Math 2026 09 28).