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

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

Detailed Description

The nonlin / gated ambience engine — a reverb whose envelope is DESIGNED rather than decayed.

Why this is not another reverb

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:

  • Ambience — a short exponential-ish falling cloud; the "natural" one.
  • Gated — flat body, then a hard cut. The 80s snare-in-a-box.
  • Reverse — amplitude rises to a peak, then cuts. The swell / suck-in.
  • NonLin2 — a rippled body with a shaped attack and an abrupt gate; the contour that never decays like a room.

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.

Honest gap — the lineage claim, stated precisely

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:

  1. Velvet noise — a sparse ternary sequence (mostly zeros, occasional ±1) that sounds smooth at surprisingly low pulse density. Term and method: Karjalainen & Järveläinen, "Reverberation modeling using velvet noise", AES 30th Int. Conf., 2007. Grid / jitter / sign / density recipe: Välimäki, Holm-Rasmussen, Alary & Lehtonen, "Late Reverberation Synthesis Using Filtered Velvet Noise", Applied Sciences 7(5):483, 2017 (DOI 10.3390/app7050483). Non-exponential (rising, plateau, arbitrary) velvet envelopes: Fagerström, Schlecht & Välimäki, "Non-Exponential Reverberation Modeling Using Dark Velvet Noise", JAES, 2024 — the direct literature support for the gated / reverse / nonlin idea. Stereo decorrelation from independent realizations: Välimäki & Prawda, "Late-Reverberation Synthesis Using Interleaved Velvet-Noise Sequences", IEEE/ACM TASLP, 2021.
  2. Multitap early reflections and reflection-density growth — Moorer, "About This Reverberation Business", Computer Music Journal 3(2):13–28, 1979, for the multitap model and the geometric-acoustics result that reflection density grows as t². Allpass diffusion primitive: Schroeder & Logan, "'Colorless' Artificial Reverberation", IRE Transactions on Audio AU-9(6):209–214, 1961.

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.

Signal flow

x[n] (mono sum) ─► 2 Schroeder allpasses (the ONLY recursion) ─► d[n]
│
┌───────────────────────────────────────────────────────────┘
▼
VELVET TAP CLOUD — feedforward FIR, where the envelope lives
one shared ring buffer holding d[n]
L: tap table {d_k, g_k} keyed on seed_L R: keyed on seed_R
g_k = sign_k · E(τ_k) · sqrt(Td(τ_k)) · norm , τ_k = (d_k − predelay)/L
taps grouped into S time segments → S one-pole tilts per channel
│
▼ wet_L, wet_R
optional converter character (off by default: bandlimit → quantize+dither
→ DC block)
│
▼
width (mid/side) ─► output trim ─► dry/wet mix ─► out

Anti-aliasing policy (series law 4)

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.

Small-signal gain (series law 1)

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:

worst_case_gain = Π_i (1 + 2·g_i) · G_L1 = (1 + 2·0.70)² · 4.0 = 23.04

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 (series law 5)

latency_samples() returns 0, exactly. The dry path is a straight wire; the wet tail's pre-delay is the effect, not reported latency.

Composition — what this file does not own

  • rng.hpp — mix64, the stateless keyed hash, generates every tap's jitter and sign. Keying on the tap index rather than advancing a stream makes a tap's position independent of the order taps are generated in. Xorshift32 drives the converter stage's dither.
  • delay_line.hpp — DelayLineT is the storage for the two allpasses.
  • units.hpp — every ms↔sample and dB↔linear conversion.
  • crossfade.hpp — the program-swap blend (smoothstep + equal power) and the dry/wet law (equal gain) both come from the shared gain law.
  • tpt_filter.hpp — the converter stage's band-limiting pair.
  • dc_blocker.hpp — the converter stage's DC removal.
  • smoothed_value.hpp — every continuous gain parameter.
  • denormal.hpp — snap_to_zero on every recursive state.

Two composition exceptions, both deliberate and both with the arithmetic:

  1. The tap ring is local, not a DelayLineT. DelayLineT::read(int) costs an integer division per call. This module performs one read per tap per sample: ~750 taps per channel for a 400 ms field, two channels, 48 kHz → 7.2·10⁷ reads/second. At ~6 cycles of division throughput that addressing would cost more than the multiply-adds it addresses. A power-of-two ring with mask indexing makes the same read one and. The allpasses, which read once per sample each, do compose DelayLineT.
  2. The segment tilt is a local one-pole, not a TptFilterT. §4.5 of the spec fixes the coefficient as a = exp(−2π·fc/fs) (the impulse- invariant pole); TptFilterT uses the bilinear/TPT mapping, which is a different filter. The local form also snaps its state through snap_to_zero, which the tail-flush behaviour depends on.

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.

Where the shipped behaviour departs from the build spec

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.

  1. Tap gains carry a sqrt(Td) density weight. The spec's §4.3 gain law is g_k = sign_k·E(τ_k)·norm, and its §7 T1 measures a short-time RMS envelope and compares it to E. Those two cannot both hold. A window of N samples holding K = Nd·N/fs taps of magnitude a has RMS a·sqrt(Nd/fs), so with the shipped density-growth law the measured envelope is E(τ)·sqrt(Nd(τ)) — at the shipped γ = 2 that is a +7.0 dB rise across an Ambience tail (sqrt(4000/800)) and a +4.7 dB rise across a Gated body that T1 requires to be flat within ±1.0 dB. Weighting each tap by sqrt(Td(τ_k)) = sqrt(fs/Nd(τ_k)) makes the short-time RMS exactly E(τ)·norm independent of density, which is what "the envelope is what you asked for" has to mean for a module whose entire product is the envelope. The L1 budget of §4.4 and therefore worst_case_gain are unchanged: norm is still solved so that Σ|g_k| = G_L1 exactly.
  2. No DC blocker in the default output path. The spec places one after the tap cloud. The wet path is linear, so it cannot create DC the input did not have; and a DC blocker is IIR, with a time-domain L1 gain of exactly 2 for any pole (h[0] = 1, h[n] = −(1−p)p^{n−1}, Σ|h| = 2). Making it unconditional would therefore double the shipped gain bound to 46.08 and destroy this module's one distinguishing, testable property — a finite impulse response. The blocker is instead part of the converter stage, where the quantizer genuinely can introduce an offset, and worst_case_gain() reports the doubled bound when that stage is on.
  3. width is a mid/side law, not a re-seed. §4.6 describes width as crossfading toward "both channels from `seed_L`", but §1 and the parameter table both classify width as continuous — a parameter that must never trigger a rebuild. Re-seeding is a rebuild. The shipped law is out = mid + w·(ch − mid) with mid = ½(L+R), which is rebuild-free, is exactly mono at w = 0 (so the spec's "L ≡ R bit-identical" assertion holds), and is exactly the two independent realizations at w = 1.
  4. diffusion does not trigger a rebuild. It is listed as a topology parameter, but it changes two scalars in the allpasses and no tap: the FIR is untouched. Regenerating ~1500 taps and running a 20 ms crossfade for a coefficient change that is already state-continuous would be cost with no benefit.

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.