Pulp 1.88.0
Cross-platform audio plugin framework
Loading...
Searching...
No Matches
phaser_stages.hpp File Reference

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>

Detailed Description

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).

Why this file is not phaser.hpp

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.

The math, and where the spec's version of it is wrong

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

f_k = fc · tan( (2k−1)·π / (2N) ) k = 1 .. N/2 (analog)

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:

f_k = (fs/π) · arctan( tan(π·fc/fs) · tan((2k−1)·π/(2N)) ) (digital)

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:

N=4 fc=400 k=2 analog 965.7 Hz digital 964.6 Hz +0.11 %
N=8 fc=600 k=4 analog 3016.4 Hz digital 2979.6 Hz +1.24 %
N=12 fc=400 k=6 analog 3038.3 Hz digital 2999.9 Hz +1.28 %
N=12 fc=2000 k=6 analog 15191.5 Hz digital 12000.0 Hz +26.6 %
N=12 fc=4000 k=6 analog 30383.0 Hz digital 17022.3 Hz +78.5 % ← above Nyquist

(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.

The stage

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 feedback loop — small-signal gain and its bound (series law 1 / 8)

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

W(z) = A(z) / (1 − k·z⁻¹·A(z))

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

sup_ω |W| = 1 / (1 − |k|)

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 deepest null null at response peak
0.00 88.5 dB 964.6 Hz 1.000 ( 0.0 dB)
0.65 19.9 dB 1025.3 Hz 1.929 (+5.7 dB)
0.90 27.5 dB 1102.4 Hz 5.499 (+14.8 dB)

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.

Mix — why 50 % is the full notch

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

depth_dB(mix) = −20·log10 |1 − 2·mix|

— 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.

Anti-aliasing policy: none needed, and why that is a claim not a shrug

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

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.

RT contract

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.

The calibration table — every design parameter in one place

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.

Presets

  • Small Stone — stages 4, rate 0.4 Hz, center 400 Hz, depth 100 %, feedback 0 (or kColorOnFeedback), mix 0.5, triangle, spread 0.
  • Wide shimmer — stages 6, rate 0.15 Hz, spread 0.25, sine. Sine lingers at its extrema where triangle glides evenly, so the sweep breathes.
  • Jet — stages 12, rate 2 Hz, feedback 0.8, mix 0.5, stagger 1.08.
  • Half-notch pad — mix 0.25: same notch frequencies, 6 dB dips.

Citations

  • Smith, J.O. III (1984). An Allpass Approach to Digital Phasing and Flanging. Proc. ICMC-84, Paris, pp. 103–109; restated in Physical Audio Signal Processing, "Phasing with First-Order Allpass Filters" (W3K, 2010) — notch-count law and the mix-for-full-notch law.
  • Zavalishin, V. (2020). The Art of VA Filter Design, rev. 2.1.2, ch. 2 — the TPT one-pole and its allpass = 2·lowpass − input tap.
  • Aion FX, Sunstone OTA Phaser build documentation (a documented clone of the EHX Small Stone "Issue J") — topology only: four OTA allpass stages sharing one control current, and a switched feedback network on the "Color" control. No component value is used. The single-control-current topology is why the default sweep is linear in Hz rather than logarithmic, and why all stages share one fc unless stagger_ratio is deliberately moved off 1.

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.