The nonlin / gated ambience engine — a reverb whose envelope is DESIGNED rather than decayed. More...
#include <pulp/signal/crossfade.hpp>#include <pulp/signal/dc_blocker.hpp>#include <pulp/signal/delay_line.hpp>#include <pulp/signal/denormal.hpp>#include <pulp/signal/rng.hpp>#include <pulp/signal/smoothed_value.hpp>#include <pulp/signal/tpt_filter.hpp>#include <pulp/signal/units.hpp>#include <pulp/signal/velvet_noise.hpp>#include <algorithm>#include <array>#include <cmath>#include <cstddef>#include <cstdint>#include <limits>#include <vector>Go to the source code of this file.
Classes | |
| struct | pulp::signal::nonlin_ambience::VelvetTapDesign |
| Pure intermediate for one candidate in a velvet tap grid. More... | |
Namespaces | |
| namespace | pulp |
| namespace | pulp::signal |
| namespace | pulp::signal::nonlin_ambience |
| The calibration register: every number this module ships that is not cited to the literature lives here with a default and a range, so a reviewer finds them in one place rather than spread through the code. | |
Enumerations | |
| enum class | pulp::signal::NonlinProgram { pulp::signal::ambience = 0 , pulp::signal::gated = 1 , pulp::signal::reverse = 2 , pulp::signal::nonlin2 = 3 } |
| The four programs. More... | |
Functions | |
| constexpr double | pulp::signal::nonlin_ambience::allpass_l1_gain (double g) |
| The time-domain L1 (peak) gain of one Schroeder allpass with coefficient g: Σ|h| = g + (1−g²)/(1−g) = 1 + 2g. | |
| double | pulp::signal::nonlin_ambience::worst_case_gain (double diffusion=kDiffusionDefault, bool converter_on=false) |
| The shipped worst-case gain as a closed form of the shipped constants: Π_i (1 + 2·g_i) · G_L1, an upper bound on Σ_n |h[n]| of the rendered impulse response by Young's convolution inequality. | |
| double | pulp::signal::nonlin_ambience::program_envelope (NonlinProgram program, double tau, double gate_hold=kGateHold, double attack=kRevRise) |
| The dimensionless program envelope E(τ), τ ∈ [0, 1] (series law 7 — scale invariant, so changing length_ms rescales time without refitting anything). | |
| double | pulp::signal::nonlin_ambience::pulse_density (double u, double density_pct, double gamma) |
| Instantaneous pulse density in pulses/second at normalized time u ∈ [0,1]: Nd(u) = Nd_min + (Nd_max − Nd_min)·u^γ. | |
| VelvetTapDesign | pulp::signal::nonlin_ambience::design_velvet_tap (NonlinProgram program, double position_samples, double grid_samples, int window_samples, int predelay_samples, double gate_hold, double attack, const VelvetNoiseDrawT< double > &draw) noexcept |
Variables | |
| constexpr int | pulp::signal::nonlin_ambience::kNumAllpass = 2 |
| Number of pre-diffusion Schroeder allpasses. | |
| constexpr double | pulp::signal::nonlin_ambience::kAllpassDelaysMs [2] = {4.77, 6.13} |
| Allpass delays in ms, rounded to the nearest sample and then nudged to the nearest prime sample count so the two combs never align. | |
| constexpr double | pulp::signal::nonlin_ambience::kDiffusionDefault = 0.70 |
| Allpass coefficient, exposed as diffusion. | |
| constexpr double | pulp::signal::nonlin_ambience::kDiffusionMax = 0.85 |
| constexpr double | pulp::signal::nonlin_ambience::kNdMin = 800.0 |
| Instantaneous pulse density at the start and end of the window, in pulses/second. | |
| constexpr double | pulp::signal::nonlin_ambience::kNdMax = 4000.0 |
| constexpr double | pulp::signal::nonlin_ambience::kDensityRefPct = 60.0 |
| The density_pct value at which Nd_min equals kNdMin exactly, so the shipped default reproduces the shipped constant rather than a scaled version of it. | |
| constexpr double | pulp::signal::nonlin_ambience::kGammaDefault = 2.0 |
| Density growth exponent γ. | |
| constexpr double | pulp::signal::nonlin_ambience::kAmbDropDb = 60.0 |
| Ambience drop across the window, in dB; sets α = A_drop·ln(10)/20. | |
| constexpr double | pulp::signal::nonlin_ambience::kFadeInFrac = 0.02 |
| Linear rise over the first fraction of the window, which kills the first-tap click on the programs that would otherwise start at full gain. | |
| constexpr double | pulp::signal::nonlin_ambience::kGateHold = 0.70 |
| Gated program: normalized hold point h and fall width w. | |
| constexpr double | pulp::signal::nonlin_ambience::kGateFall = 0.05 |
| constexpr double | pulp::signal::nonlin_ambience::kRevRise = 0.85 |
| Reverse program: normalized rise point r and swell exponent p (p < 1 concave, p > 1 convex). | |
| constexpr double | pulp::signal::nonlin_ambience::kRevPow = 1.0 |
| constexpr int | pulp::signal::nonlin_ambience::kNlHumps = 2 |
| NonLin2 program: number of raised-cosine humps, ripple depth, gate point. | |
| constexpr double | pulp::signal::nonlin_ambience::kNlDepth = 0.4 |
| constexpr double | pulp::signal::nonlin_ambience::kNlHold = 0.85 |
| constexpr double | pulp::signal::nonlin_ambience::kL1Budget = 4.0 |
| The per-channel L1 budget of the tap-gain vector. | |
| constexpr int | pulp::signal::nonlin_ambience::kSegments = 8 |
| Number of time segments the tap range is split into for spectral tilt. | |
| constexpr double | pulp::signal::nonlin_ambience::kFcBright = 16000.0 |
| Segment tilt corners. | |
| constexpr double | pulp::signal::nonlin_ambience::kFcDark = 6000.0 |
| constexpr double | pulp::signal::nonlin_ambience::kSwapFadeMs = 20.0 |
| Equal-power crossfade length applied when a topology change swaps tap banks. | |
| constexpr double | pulp::signal::nonlin_ambience::kParamSmoothMs = 20.0 |
| Smoothing time for the continuous gain parameters. | |
| constexpr std::uint32_t | pulp::signal::nonlin_ambience::kSeedRodd = 0x9E3779B9u |
| seed_R = seed_L ⊕ kSeedRodd. | |
| constexpr std::uint32_t | pulp::signal::nonlin_ambience::kDefaultSeed = 0x51EDu |
| Default seed_L. | |
| constexpr int | pulp::signal::nonlin_ambience::kTapGuard = 32 |
| Headroom above the expected tap count, so the jittered grid can never outgrow the pre-sized table. | |
| constexpr double | pulp::signal::nonlin_ambience::kConverterFcLo = 80.0 |
| Converter-character pre-filter corners (§5). | |
| constexpr double | pulp::signal::nonlin_ambience::kConverterFcHi = 15000.0 |
| constexpr int | pulp::signal::nonlin_ambience::kConverterBitsMax = 16 |
| Converter word length at converter_amount = 0 and the depth of the sweep: bits = kConverterBitsMax − round(kConverterBitSweep · amount). | |
| constexpr int | pulp::signal::nonlin_ambience::kConverterBitSweep = 4 |
| constexpr double | pulp::signal::nonlin_ambience::kConverterDcPole = 0.995 |
| DC-blocker pole used by the converter stage. | |
| constexpr double | pulp::signal::nonlin_ambience::kMinLengthMs = 20.0 |
| Largest window and pre-delay prepare() sizes storage for, in ms. | |
| constexpr double | pulp::signal::nonlin_ambience::kMaxLengthMs = 2000.0 |
| constexpr double | pulp::signal::nonlin_ambience::kMaxPredelayMs = 200.0 |
| constexpr double | pulp::signal::nonlin_ambience::kMinDensityPct = 10.0 |
| Lower bound of the density control's documented 10 .. 100 % range. | |
| constexpr double | pulp::signal::nonlin_ambience::kNormFloor = 1e-20 |
| Floor below which the normalization sum is treated as empty, so a program whose taps all land in a zeroed region produces silence rather than a division by a denormal. | |
| constexpr double | pulp::signal::nonlin_ambience::kDcBlockerL1Gain = 2.0 |
| The time-domain L1 gain of the DC blocker, which is exactly 2 for any pole p < 1: h[0] = 1, h[n] = −(1−p)p^{n−1}, so Σ|h| = 1 + (1−p)/(1−p). | |
The nonlin / gated ambience engine — a reverb whose envelope is DESIGNED rather than decayed.
A Schroeder or FDN tank imposes its envelope through feedback: energy recirculates and dies at a rate set by the loop gain, so the amplitude contour is monotone falling by construction. Such a tank cannot rise, hold flat, and then cut — no setting of any knob makes it do so, because the shape is a consequence of the topology rather than a parameter of it.
This module has no recursion in the wet path. The wet output is a finite impulse response, y[n] = Σ_k g_k · x[n − d_k], whose tap positions d_k come from a velvet-noise grid and whose tap gains g_k trace an arbitrary designed curve E(τ). The envelope is literally the gain sequence, so any shape that can be drawn can be played back:
The finiteness is testable and is tested: with set_diffusion(0) the whole wet path is a pure FIR, and the rendered impulse response is bit-exactly zero past the last tap. A recursive design cannot produce that sample.
The four program names above are documented product history of the AMS RMX16. No academic paper documents that machine, and nothing here is measured from it, reverse-engineered from it, or derived from any proprietary constant. The topology combines two published algorithm lineages, and every number is either cited to one of them or carries an explicit [design parameter] default and range:
The optional converter-character stage (set_converter_amount) is likewise not a model of any hardware converter: no verifiable public spec exists, so every value in it is a design parameter and the stage is off by default.
The default path is linear, so there is nothing to alias. The allpasses, the tap cloud, the segment tilts, the width matrix and the mix are all LTI; none of them generates a harmonic that was not in the input. The only nonlinearity in the file is the converter stage's quantizer, and it exists for its lo-fi character. Its alias control is the mandated pre-bandlimit to [kConverterFcLo, kConverterFcHi]; a caller who wants clean bit reduction wraps the stage in the house 65-tap Kaiser (β = 8) half-band pair with even-phase decimation, which is the shared series standard and is deliberately not respecified here. ADAA does not apply to quantization (it is a staircase, not a smooth waveshaper) and is not used.
Law 1 is not applicable to the wet tail by construction: it is feedforward, so there is no loop and no loop gain to bound or compensate. The worst-case gain is not a measurement but a closed form of the shipped constants — the coherent sum of the tap gains, times the allpass chain's time-domain L1 gain:
An allpass has frequency-domain magnitude exactly 1 but a time-domain L1 (peak) gain of 1 + 2g, from h[0] = −g, h[kM] = (1−g²)g^{k−1} (k ≥ 1), giving Σ|h| = g + (1−g²)/(1−g) = 1 + 2g. Young's convolution inequality then bounds the cascade. worst_case_gain() returns this for the current diffusion setting; the test suite renders the actual impulse response and asserts the measured Σ_n |h[n]| stays under it, so the registry constant cites a tested invariant rather than an estimate (series law 8). The bound is stated at the shipped output_gain_db = 0; that trim scales it by db_to_linear(output_gain_db) and nothing else in the chain can exceed it (each segment one-pole has L1 = 1, the width matrix is a convex combination, and the dry/wet law is (1−m, m)).
latency_samples() returns 0, exactly. The dry path is a straight wire; the wet tail's pre-delay is the effect, not reported latency.
Two composition exceptions, both deliberate and both with the arithmetic:
DryWetMixerT is not composed: it is a block-oriented mixer that owns a dry copy and a latency buffer, and this path is per-sample at zero latency. The gain law it would apply is taken directly from crossfade_gains instead, so there is still exactly one implementation of the law in the tree.
Four adjudications, each with the numbers that forced it. They are recorded here because the spec is the contract and a silent divergence is a defect.
RT contract: prepare(sample_rate, max_length_ms) allocates the circular delay line (predelay_max + L_max + 1 samples, rounded up to a power of two) and the tap-table capacity (ceil(kNdMax · max_length_ms/1000) + kTapGuard taps per channel per bank — headroom for the worst-case tap count the jittered grid can produce above its expected value), and may allocate. rebuild() regenerates tap tables into the pre-sized back bank and never allocates; it is invoked from prepare and from topology set_*. The audio path consumes the front bank and swaps to the back one over a kSwapFadeMs equal-power crossfade. process and reset never allocate, lock, or throw. All randomness is mix64-keyed from seed_L (seed_R derives deterministically) plus an Xorshift32 dither stream rewound at reset; seeds are parameters, never automated. set_* are control-thread calls: they are allocation-free and lock-free, but the front/back swap is a plain index write, so a host that drives them from a non-audio thread must provide the hand-off.