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

The circuit-modelled clipper family: diode-to-ground and diode-in-the-loop, solved as ODEs with memory. More...

#include <pulp/signal/denormal.hpp>
#include <pulp/signal/junction.hpp>
#include <pulp/signal/tpt_filter.hpp>
#include <pulp/signal/units.hpp>
#include <algorithm>
#include <cmath>
#include <cstdint>
#include <utility>

Go to the source code of this file.

Classes

struct  pulp::signal::ClipperSolverConfig
class  pulp::signal::DiodeClipperT< SampleType >
 The diode-to-ground clipper: series resistor, shunt diode network, optional shunt capacitor. More...
class  pulp::signal::FeedbackClipperT< SampleType >
 The op-amp gain stage with the diode network either at its output or across its feedback resistor. More...
class  pulp::signal::ToneStackT< SampleType >
 Pre/post tone shaping around the clipper: a two-pole tilt pair. More...

Namespaces

namespace  pulp
namespace  pulp::signal
namespace  pulp::signal::detail
 Round-2 effect-wave vocabulary.

Typedefs

using pulp::signal::DiodeClipper = DiodeClipperT<float>
using pulp::signal::DiodeClipper64 = DiodeClipperT<double>
using pulp::signal::FeedbackClipper = FeedbackClipperT<float>
using pulp::signal::FeedbackClipper64 = FeedbackClipperT<double>
using pulp::signal::ToneStack = ToneStackT<float>
using pulp::signal::ToneStack64 = ToneStackT<double>

Enumerations

enum class  pulp::signal::DiodeModel : std::uint8_t { pulp::signal::silicon , pulp::signal::germanium , pulp::signal::led }
 Diode device families. The members differ only in (Is, n). More...
enum class  pulp::signal::ClipperTopology : std::uint8_t { pulp::signal::to_ground , pulp::signal::in_loop }
 Where the diode pair sits relative to the gain stage. More...

Functions

void pulp::signal::detail::apply_diode_model (junction::JunctionPair &network, DiodeModel model)
 The (Is, n) pair for a device family.
void pulp::signal::detail::apply_symmetry (junction::JunctionPair &network, double symmetry)
 symmetry ∈ [−1, +1] → the two legs' reciprocal series counts.
template<typename Residual, typename Derivative>
double pulp::signal::detail::solve_safeguarded_newton (Residual &&f, Derivative &&df, double lo, double hi, double initial, double alternate, double tolerance, int max_iterations, int &iterations_out)
 One monotone implicit stage, solved by a SAFEGUARDED Newton iteration.
double pulp::signal::detail::solve_implicit_flow_stage (const junction::JunctionPair &network, double history_voltage, double history_flow, double source_current, double flow_weight, double c_over_step, double resistance, double initial, int &iterations_out)
 Solves one implicit stage of C·dv/dt = source_current − v/R − i_D(v).
double pulp::signal::detail::solve_tr_bdf2 (const junction::JunctionPair &network, double previous_voltage, double previous_source_current, double source_current, double capacitance, double timestep, double resistance, int &iterations_out)
 One L-stable TR-BDF2 step of C·dv/dt = source_current(t) − v/R − i_D(v).

Detailed Description

The circuit-modelled clipper family: diode-to-ground and diode-in-the-loop, solved as ODEs with memory.

This is the distortion family WITH MEMORY, and that boundary is the reason it is a separate header from saturator.hpp rather than a mode on it. A capacitor sits inside the clipping network, so the effective clip point at this instant depends on the filtered recent history of the signal, not just on the current sample. That is what makes Newton-Raphson on an ODE the right tool instead of a static y = f(x) table — and it is why a "clean tanh" character belongs in SaturatorT, not as a memoryless mode bolted on here. The C = 0 degenerate configuration exists to test the solver's limit case, not as a shipped preset.

Three classes, one header, because they share the ODE solver. Splitting them per class would mean either duplicating that solver or inventing a fourth header to hold it; the solver is the coupling, so the coupling stays in one file.

The one nonlinearity

Everything here is the Shockley diode law, i_D(v) = Is·(exp(v/(n·Vt)) − 1) (Shockley 1949, as used throughout the circuit-modelling literature — Yeh, Abel & Smith 2010, §II). Silicon, germanium and LED differ ONLY in (Is, n). The audible consequences — germanium turns on ~150–200 mV earlier and softer, LEDs latest and hardest — fall straight out of plugging each row into the one equation. That is series law 7 applied to diode families: find the dimensionless shape rather than interpolating fitted curves.

None of the (Is, n) pairs are measured parts. Each is an honest [design parameter] representing the typical published order of magnitude for a device CLASS.

Symmetry as a topology, not a formula

The antiparallel network is parameterised by how many diodes are in series on each leg, because that is the physical thing that changes:

i(v) = Is·( exp(a·v/θ) − exp(−b·v/θ) ) θ = n·Vt

where a and b are the RECIPROCAL series counts of the two legs. a = b = 1 is the matched pair (odd, symmetric, odd harmonics only). a = 1/2 is two diodes in series on one leg, turning on at twice the threshold — mild asymmetry. b = 0 removes the second leg entirely: a single diode, half-wave clipping, strong even harmonics.

Reciprocal counts rather than counts is what makes the parameter continuous: "leg absent" is b = 0, a finite value, where a count would have to be infinite. symmetry ∈ [−1, +1] maps onto them directly, and the crossfade interpolates the dimensionless shape rather than blending two independently fitted waveforms.

Why TR-BDF2 plus Newton

The clipper ODE is stiff near the diode knee — Vt is 26 mV against a signal swinging volts — so explicit methods need prohibitive sample rates to stay stable. That is precisely Yeh, Abel & Smith's finding for this class of ODE. TR-BDF2 is implicit, second-order, A-stable and L-stable: its trapezoid substep preserves accuracy in the audio band, while its BDF2 completion damps the stiff alternating mode that plain trapezoidal rule preserves. Each substep has a HARD Newton iteration cap: an unbounded loop in process() is not RT-safe, and hitting either cap clamps to the last iterate.

Patch notes

  • to_ground, symmetric, high drive — the Distortion archetype: hard, bright, level-independent knee. Pair with a low post-tone corner to tame fizz.
  • to_ground, symmetry = −1 — half-wave, fuzz-adjacent: strong even harmonics read as buzzier and more lo-fi than the matched pair.
  • in_loop, moderate drive — the Overdrive archetype. The knee TRACKS the drive rather than sitting at a fixed voltage, so picking harder moves the knee itself, not just the level. That is the "boost that also compresses" feel.
  • Germanium — earlier, softer onset. LED — latest onset, steepest slope once clipping starts.

RT contract: prepare() may allocate (it configures the oversampler when one is in use). set_*, process() and reset() never allocate, never lock, never perform I/O. A stateful process() costs two implicit substeps, each capped at kMaxNewtonIterations evaluations of one exp pair — bounded, never data-dependent past those caps. There is no randomness anywhere in this file, so determinism (series law 2) is satisfied by construction; that is worth stating because it is easy to assume a "vintage" effect needs drift, and this one does not.

References: Shockley (1949) for the diode law. Yeh, Abel & Smith, "Simplified, Physically-Informed Models of Distortion and Overdrive Guitar Effects Pedals" (DAFx-07 / IEEE TASLP 2010) for the ODE formulation and the numerical-methods survey that selects TR. ElectroSmash's public Tube Screamer circuit analysis for the in-loop topology and its documented behaviour ONLY — no component values are reproduced, and every default here is independently chosen and independently ranged.