The cascaded-allpass phaser: a 4-stage "Small Stone style" sweep and a general 4–12-stage digital phaser, one engine, two sets of defaults. More...
#include <pulp/signal/denormal.hpp>#include <pulp/signal/lfo.hpp>#include <pulp/signal/tpt_filter.hpp>#include <algorithm>#include <array>#include <cmath>#include <cstdint>Go to the source code of this file.
Classes | |
| class | pulp::signal::PhaserStagesT< SampleType > |
| A cascaded first-order-allpass phaser. More... | |
Namespaces | |
| namespace | pulp |
| namespace | pulp::signal |
Typedefs | |
| using | pulp::signal::PhaserStages = PhaserStagesT<float> |
| using | pulp::signal::PhaserStages64 = PhaserStagesT<double> |
The cascaded-allpass phaser: a 4-stage "Small Stone style" sweep and a general 4–12-stage digital phaser, one engine, two sets of defaults.
A phaser is a dry signal summed with a chain of first-order allpass sections whose corner frequency is swept by an LFO. It is NOT a comb filter. A flanger's nulls sit at odd multiples of one spacing — harmonic, evenly ruled, metallic. A phaser's nulls sit wherever the cascade's ACCUMULATED phase crosses an odd multiple of π, which the arctangent law below spaces non-uniformly. That non-uniform spacing is the entire sonic identity of the effect (Smith 1984; see the citations at the bottom).
pulp/signal/phaser.hpp already ships a PhaserT — an 87-line prototype with a hard-coded 200 Hz–5 kHz sweep, a hand-rolled sine LFO, a direct-form allpass, mono-only per-sample process(), and 2..8 stages. It is NOT dead code: forge_lofi_catalog.hpp's make_phaser_node() is built on it, and test/denormal_null_harness.hpp renders it as one of the bit-exactness reference paths for the snap_to_zero null test. Rewriting it in place would change that reference render and rewrite a shipped catalog node's behaviour as a side effect of adding a new module.
So this design ships ALONGSIDE, under its own name, and the old one is left to a deliberate migration:
| PhaserT (phaser.hpp) | PhaserStagesT (here) | |
|---|---|---|
| stages | 2..8, any parity | 4..12, even only |
| allpass | direct-form (a+z⁻¹)/(1+az⁻¹) | TPT, modulation-stable |
| LFO | private std::sin accumulator | house LfoT |
| sweep | fixed 200 Hz..5 kHz | center_hz ± depth |
| channels | mono, sample at a time | stereo block, quadrature spread |
| feedback bound | ±0.95, unproven | ±0.9, proven and asserted |
The migration, when someone takes it: point make_phaser_node() at PhaserStagesT, re-baseline the denormal-null reference in the same commit, then delete phaser.hpp. Nothing here blocks on that, and nothing here depends on it.
Notch count. N first-order allpass sections carry total phase from 0 at DC to −Nπ at Nyquist. Summed with dry, they cancel wherever that phase crosses an odd multiple of π, so num_notches = N/2 for even N — Smith's "for each notch in the desired response we must add two new first-order allpass sections". N is restricted to even values here because odd N puts the last crossing exactly at Nyquist, where it is not a notch.
Notch position — the analog prototype. The continuous-time first-order allpass with corner fc has phase −2·arctan(f/fc), so an N-section cascade nulls at
Notch position — what this code actually does. The shipped stage is a bilinear/TPT discretization prewarped at fc, so its phase is the analog law with both frequencies replaced by their prewarped tangents: φ_d(ω) = −2·arctan( tan(ω/2) / tan(ω_c/2) ). Solving the same odd-π crossing gives the law this module is measured against:
Both are shipped: notch_frequency_hz() (digital, exact for this code) and notch_frequency_analog_hz() (the cited prototype). They are not interchangeable, and the difference is not a rounding detail:
(fs = 48 kHz.) The last row is the point: center_hz reaches 2 kHz in the catalog and depth = 100 % doubles it, so fc = 4 kHz is a reachable setting at which the analog law predicts a notch above Nyquist. The digital law's arctan cannot exceed π/2, so it places every one of the N/2 notches strictly below Nyquist — always, at any fc. That is a property of the discretization, not a clamp.
The prototype law is kept, cited, and tested against the digital one in the regime where the spec quotes it (fc = 400 Hz, N ≤ 12: they agree to 1.3 %), because it is the closed form that explains the shape — every notch is a fixed ratio of fc, so the whole pattern glides rigidly in log frequency as the LFO sweeps.
One TptFilterT per stage, tapped through process_allpass(). That is not a convenience: TptFilterT's coefficient (g = tan(π·fc/fs), G = g/(1+g)), its trapezoidal state update, and its 2·lowpass − input allpass tap are already exactly the topology-preserving one-pole allpass this design calls for (Zavalishin 2020, ch. 2). Re-deriving it here would create a second copy of the same three lines that could drift.
TPT rather than direct form because fc moves EVERY SAMPLE. A direct-form allpass whose coefficient is rewritten per sample is a different filter each sample and its state no longer means what the new coefficient assumes; the audible result is a granular edge on fast sweeps. The TPT structure's state is a trapezoidal integrator memory, which stays meaningful across a coefficient change.
Cost, stated plainly. Because each stage owns its coefficient, a sweep costs one std::tan per active stage per channel per sample — 8 per sample at the 4-stage default, 24 at 12 stages. That is the price of composing the shared stage instead of hoisting one shared coefficient, and it is the honest cost anyway once stagger_ratio ≠ 1, where the stages genuinely have different corners.
The "colour" control is a gain-carrying path, so its loop gain is stated and bounded rather than tuned by ear. With A(z) the cascade and the feedback tap reading the PREVIOUS sample's cascade output, the wet path is
An allpass cascade has |A(e^{jω})| = 1 at every frequency — exactly, by the allpass property, not approximately — and |z⁻¹| = 1 on the unit circle, so the loop-gain magnitude is exactly |k| at every frequency and
with kFeedbackMax = 0.9 giving 10.0× (20.0 dB). This is a proof, not a fit: the bound is an equality, ATTAINED wherever the loop phase ∠(z⁻¹A) passes through a multiple of 2π, which it does many times across the band because the cascade's phase sweeps −Nπ. The module's own suite asserts both halves — that the measured peak never exceeds the bound, and that it reaches it — so a future change that quietly weakens the feedback fails a test rather than passing a one-sided inequality.
Stability follows from the same identity without a second argument: a unity-magnitude element inside a sub-unity feedback gain is the textbook small-gain case. Widening kFeedbackMax past 1 does not merely get loud, it makes sup|W| infinite; the constant is a stability bound and the registry number is derived from it.
What "Colour" actually does, because the usual description is backwards. It is widely written that phaser feedback deepens the notches. Measured on this code at 4 stages, fc = 400 Hz, mix = 0.5, each figure relative to that setting's own peak:
Feedback makes the nulls SHALLOWER, moves them upward, and adds a large resonant peak near fc. That peak — not a deeper notch — is the vocal, nasal character the "Color" switch is prized for. The algebra is one line: |W| = 1 only where cos(∠A − ω) = k/2, which at k = 0 is every frequency (hence exact cancellation is possible) and at k ≠ 0 is a handful of isolated ones (hence it is not).
The corollary matters for anyone reading the notch laws above: they are OPEN-LOOP laws. They describe the cascade A, not the loop around it. With feedback engaged the nulls move off them, which is why every notch-position test in the suite sets feedback to zero.
out = (1 − mix)·dry + mix·wet. A notch is a cancellation between two paths, and cancellation is complete only when they carry equal amplitude: 1 − mix = mix, i.e. mix = 0.5. At any other mix the notches stay at the same frequencies — the phase geometry does not depend on mix — but the null only reaches |1 − 2·mix|, i.e. a finite dip. Since the response peaks at exactly 1.0 wherever the cascade phase is a multiple of 2π, the notch depth is the closed form
— 0 dB at mix = 0 and mix = 1, 6.02 dB at 0.25 and 0.75, unbounded at 0.5. Note the corollary: mix = 1.0 is not "more phaser", it is NO phaser. It is the bare allpass cascade, spectrally flat, with only its phase moving.
This law, like the notch laws, assumes the loop is open. Its premise is that the wet path is unity magnitude, which is true of A and not of W.
Series law 4 asks for an oversampling policy wherever a nonlinearity aliases. There is no nonlinearity here: allpass stages, a feedback sum, and a dry/wet sum are all exactly linear, so no harmonic of an input tone is generated at all and there is nothing to fold. The suite asserts this rather than asserting the absence of a code path — a sine in, at full feedback, produces harmonic-2 and harmonic-3 energy below −100 dB relative to the fundamental.
The honest caveat the "not applicable" line usually hides: the coefficients ARE modulated per sample, which makes this a linear TIME-VARYING system, and those do produce new frequencies — sidebands at multiples of the LFO rate around each input component. That is the effect, not an artefact. With rate_hz ≤ 10 Hz and a phase-modulation index of a few radians, the sidebands sit within a couple of hundred Hz of their carrier and cannot reach Nyquist to fold. Oversampling would not change them.
latency_samples() == 0. The dry path is a straight wire and the wet path is a chain of delay-free-feedback TPT sections; neither adds buffering. The feedback tap's one-sample memory is the unavoidable recursion of any causal feedback structure, internal to the loop, and is not reportable latency — an impulse produces a non-zero output sample at index 0.
Nothing in this class ever allocates — not even prepare(). State is a fixed std::array of 2 × 12 TptFilterT stages plus two LfoTs, all POD-initialised, so a default-constructed instance is already silent and flat. prepare(), reset(), every set_*, and both process overloads are allocation-free, lock-free, and I/O-free, and all are safe on the audio thread. Recursive state is denormal-guarded: each stage snaps its own integrator through snap_to_zero, and the feedback memory is snapped here. set_stage_count and set_stagger_ratio are control-rate — they only change which of the fixed stages the per-sample loop visits, and the loop itself is branch-free over that count.
| Name | Default | Range | Why |
|---|---|---|---|
| kFeedbackMax | 0.9 | fixed | the worst-case-gain proof above depends on it |
| kColorOffFeedback | 0.0 | preset point | Small Stone "Color" switch, off |
| kColorOnFeedback | 0.65 | preset point | Small Stone "Color" switch, on |
| kSweepRangeRatio | 1.0 × center_hz | fixed mapping | depth 100 % ⇒ fc ∈ [0, 2·center] |
| kSweepFloorHz | 20.0 | 5 .. 50 Hz | keeps tan(π·fc/fs) away from 0 |
| kSweepCeilingRatio | 0.45 × fs | 0.30 .. 0.49 | keeps the prewarp away from Nyquist |
| kStaggerRatio | 1.0 (off) | 0.85 .. 1.15 | undocumented catalog extension |
| kStageCountDefault | 4 | 4 .. 12, even | compact four-stage phaser class |
| kMixDefault | 0.5 | 0 .. 1 | the full-notch point, above |
The two Color values are original engineering choices reproducing documented QUALITATIVE behaviour (a switch that makes the notches deeper and more vocal). No source publishes the original network's feedback magnitude as a number, and none is claimed here.
Ensoniq's DP/4 "Phaser-DDL" is the origin of the 12-stage ceiling as a design anchor, and of "Width" as a name for what is depth here. Its manual documents the parameter surface and the coarse topology and nothing more; no public source gives its coefficient update, so none is reproduced. Its distinguishing feature — the phaser feeding a delay whose output returns to the phaser — is deliberately out of scope: it belongs to a delay module composing this one, and its stability proof would have to account for both loops multiplicatively rather than adding these bounds.