Define a waveform mathematically when a standard oscillator shape does not describe the sound you want.
What it is#
shape f is a user-defined waveshaper: a per-voice audio effect whose transfer function is any scalar lambda you can write —
cheby = \x -> 1.6*x - 1.2*x*x*x -- 0.7·T₁ − 0.3·T₃
bass = patch { out = sine (freq pitch) |> shape cheby }
-- inline, asymmetric (even harmonics):
lead = patch { out = saw (freq pitch) |> shape (\x -> if x > 0.0 then x else x * 0.3) }
The lambda is evaluated at compile time — by the Generate and transform musical structures evaluator — at 2049 points uniformly over [-1, 1], and the results are baked into a lookup table carried in the IR. The runtime never sees the lambda; it plays one waveshaper node. Arbitrary transfer laws at exactly the runtime cost of the stock drive.
Surface#
Signal-first / pipeable, like every per-voice effect (§11 of Language reference):
shape : Sig Audio Bipolar -> (Float -> Float) -> Sig Audio Bipolar
The function argument is either an inline \x -> … or a reference to a top-level lambda binding. Its body may use: numeric literals, arithmetic (+ - * /), comparisons, if/else if/case, let, tuples, and calls to other compile-time-evaluable lambdas — the evaluator's envelope (§14 of Language reference). The kernel math builtins fold here too: abs min max tanh sin cos floor sqrt exp ln pow (pinned by shape_accepts_math_builtins).
Errors#
- Referencing anything dynamic (a signal, a macro, a note param) is a compile error with a span and, best-effort, the culprit's name:
shape's function must be compile-time evaluable (numbers, arithmetic,if/case); it sawpitch``. - A non-function second argument:
shape's second argument must be a function of one number. - A non-finite result at any bake point:
shape's function must return a finite number; at x = ….
Gotchas#
- Integer division truncates:
4/3is1(language-wide semantics). Write float literals in transfer math:4.0/3.0. The bake domain is [-1, 1]; the runtime clamps input to that range before the table read (WaveShaperNode semantics). A hot signal (post-gain
- hits the table's endpoints — build the headroom into the curve, or
keep the shaper early in the chain.
- Outputs are clamped to [-4, 4] at bake time.
- An asymmetric transfer (even harmonics) produces signal-dependent DC, same as
drivewithbias: the per-voice DC blocker absorbs it.
Aliasing note#
shape uses second-order MinusX ADAA in the native engine (2026-08 audit #1; originally first-order in circuit-fidelity Stage F), just like the stock saturators. The earlier claim — that a user table "has no closed-form antiderivative to lean on" — was simply wrong: the baked table is a piecewise-linear transfer, so its first antiderivative is an exact piecewise quadratic and its second an exact piecewise cubic. The engine bakes both node tables (table_ad, table_ad2) once at lower time, then at eval runs the three-point divided differences on the RESIDUAL f(x) − s·x (s = the transfer's measured slope at 0) and adds s·x[n−1] back — so the linear part passes as a pure one-sample delay with no HF droop (plain ADAA's boxcar response cost −6 dB at 16 kHz), the output is clamped to the table's value range, and the nonlinear residual keeps full alias suppression. Out of the [-1, 1] domain the LUT clamps f to a constant edge value, so F1/F2 extend linearly/quadratically there and the ADAA stays exact off-domain too. Cost: one sample of latency on the node (chowdsp documents the same for its ADAAWaveshaper).
Measured: on a hot 3.05 kHz sine through a hard-clip table, the 9th-harmonic alias drops well below the plain per-sample LUT read; on a slow ramp the ADAA output tracks the (one-sample-delayed) direct transfer to <1e-3. The suppression is comparable to ~4× oversampling for first order; a further 2×/4× oversample (upsample → table → halfband down) is the escalation if a very high-order transfer on a high lead still bites.