Pulp 1.88.0
Cross-platform audio plugin framework
Loading...
Searching...
No Matches
forge_lofi_catalog.hpp
Go to the documentation of this file.
1#pragma once
2
3// Forge lo-fi DSP catalog — the seed of Forge's bake-layer DSP kit.
4//
5// A set of LOWERABLE custom nodes that wrap real pulp::signal blocks (plus a few
6// small catalog-local primitives) as bake-layer parameter-injectable nodes. Each
7// factory returns a CustomNodeType ready to register on a SignalGraph and bake();
8// its macro knobs are injectable baked params
9// (BakedGraphProcessor::claim_param_injection → ParamInjector). Every
10// node is transport-independent and reads its param via the block's BakedParamView
11// so control-thread knob turns land sample-accurately in the BAKED process()
12// without re-baking — the same primitive the DelayLine node (F2-a) established.
13//
14// Header-only: the wrapped signal blocks (Svf, WaveShaperT, DryWetMixerT) are all
15// header-only templates, so including this pulls in no new link dependency for the
16// pulp-host library — only a consumer (test/tool) that includes it needs the
17// pulp::signal include path.
18//
19// Macro-knob mapping (the adapter names are intentionally host-facing; the
20// analog VCF implementation and calibration remain public pulp::signal code):
21// * Delay (feedback echo) → "Time" = time_ms + "Feedback" = feedback
22// (time_ms per-sample, interpolated)
23// * Filter (Svf) → "Tone" = cutoff_hz + "Resonance" = resonance
24// (sample-accurate; mode is
25// fixed per registered type)
26// * Analog VCF → cutoff + cutoff_mod + resonance + drive
27// (sample-accurate; lives in
28// forge_analog_vcf_catalog.hpp,
29// include it directly)
30// * Waveshaper (tanh) → "Drive" = drive (sample-accurate)
31// * Dry/Wet (DryWetMixer) → "Mix" = mix (BLOCK-rate; see note below)
32// * Noise → "Hiss" = level (sample-accurate)
33// * Bitcrush/decimator → "Crush" = bit_depth + sample_rate_reduction
34// (sample-accurate)
35// * Trim (gain stage) → "Level" = gain_db (sample-accurate)
36// * Ping-pong delay → "Time" = time_ms + "Feedback" + "Width"
37// (TRUE STEREO: 2-in/2-out)
38// * Reverb / dynamics → decay/damping/mix and threshold/ballistics
39// * CV composition pack → LFO, VCA, envelope follower, filter-CV, delay-CV
40// * Stereo motion pack → auto-pan, width, phaser
41//
42// Port arity is declared by each factory. Most effects are mono in/out and are
43// instanced dual-mono by Forge. Dry/wet and the CV consumers use a second input
44// as a separate signal/control port; the LFO has no input; ping-pong, auto-pan,
45// and width use two ports as the L/R halves of one logical stereo wire and are
46// instanced once across both rails.
47//
48// DryWetMixer is block-rate on purpose: DryWetMixerT's public API is block-oriented
49// (a scalar set_mix() plus a block mix_wet() over an internal per-channel dry
50// buffer) and exposes no per-sample gain hook, so a faithful wrapping applies the
51// injected mix value at the block's first sample for the whole block. A mix macro
52// is not audio-rate-critical, so block granularity is the right, honest tradeoff —
53// unlike a filter cutoff sweep, which this catalog keeps per-sample.
54
56
67#include <pulp/signal/svf.hpp>
69
70#include <algorithm>
71#include <cmath>
72#include <cstddef>
73#include <cstdint>
74
75namespace pulp::host::forge_lofi {
76
77// ── Stable type ids ──────────────────────────────────────────────────────
78inline constexpr const char* kDelayTypeId = "forge_lofi_delay";
79inline constexpr const char* kFilterTypeId = "forge_lofi_filter";
80inline constexpr const char* kWaveshaperTypeId = "forge_lofi_waveshaper";
81inline constexpr const char* kDryWetTypeId = "forge_lofi_drywet";
82inline constexpr const char* kNoiseTypeId = "forge_lofi_noise";
83inline constexpr const char* kBitcrushTypeId = "forge_lofi_bitcrush";
84inline constexpr const char* kBitcrushTpdfTypeId = "forge_lofi_bitcrush_tpdf";
85inline constexpr const char* kBitcrushTpdfFirstTypeId = "forge_lofi_bitcrush_tpdf_first";
86inline constexpr const char* kBitcrushTpdfSecondTypeId = "forge_lofi_bitcrush_tpdf_second";
87inline constexpr const char* kTrimTypeId = "forge_lofi_trim";
88inline constexpr const char* kPingPongTypeId = "forge_lofi_ping_pong";
89inline constexpr const char* kReverbTypeId = "forge_lofi_reverb";
90inline constexpr const char* kCompressorTypeId = "forge_lofi_compressor";
91inline constexpr const char* kGateTypeId = "forge_lofi_gate";
92
93// The filter's response mode is fixed when the node is REGISTERED, not injected:
94// each mode is its own registered type, so a baked build's filter character is
95// frozen in the artifact and no control-thread write can change it mid-render.
96// kFilterTypeId keeps its original identity (lowpass) so artifacts baked before
97// the other modes existed keep resolving.
98inline constexpr const char* kFilterHighpassTypeId = "forge_lofi_filter_highpass";
99inline constexpr const char* kFilterBandpassTypeId = "forge_lofi_filter_bandpass";
100inline constexpr const char* kFilterNotchTypeId = "forge_lofi_filter_notch";
101
102// ── Injectable macro-knob param ids ──────────────────────────────────────
103// Node-local; the framework namespaces per node so two nodes never collide.
104inline constexpr state::ParamID kDelayTimeMs = 1; // "Time"
105inline constexpr state::ParamID kDelayFeedback = 2; // "Feedback"
106inline constexpr state::ParamID kFilterCutoffHz = 1; // "Tone"
107inline constexpr state::ParamID kFilterResonance = 2; // "Resonance"
108inline constexpr state::ParamID kWaveshaperDrive = 1; // "Drive"
109inline constexpr state::ParamID kDryWetMix = 1; // "Mix"
110inline constexpr state::ParamID kNoiseLevel = 1; // "Hiss"
111inline constexpr state::ParamID kBitcrushBitDepth = 1; // "Crush" (depth)
112inline constexpr state::ParamID kBitcrushRateDiv = 2; // "Crush" (rate reduction)
113inline constexpr state::ParamID kTrimGainDb = 1; // "Level"
114inline constexpr state::ParamID kPingPongTimeMs = 1; // "Time"
115inline constexpr state::ParamID kPingPongFeedback = 2; // "Feedback"
116inline constexpr state::ParamID kPingPongWidth = 3; // "Width"
117inline constexpr state::ParamID kReverbDecay = 1; // "Decay" (RT60 seconds)
118inline constexpr state::ParamID kReverbDamping = 2; // "Damping"
119inline constexpr state::ParamID kReverbMix = 3; // "Mix"
120inline constexpr state::ParamID kCompThresholdDb = 1; // "Threshold"
121inline constexpr state::ParamID kCompRatio = 2; // "Ratio"
122inline constexpr state::ParamID kCompAttackMs = 3; // "Attack"
123inline constexpr state::ParamID kCompReleaseMs = 4; // "Release"
124inline constexpr state::ParamID kCompBallistics = 5; // "Ballistics" (0 branching, 1 decoupled)
125inline constexpr state::ParamID kGateThresholdDb = 1; // "Threshold"
126inline constexpr state::ParamID kGateAttackMs = 2; // "Attack"
127inline constexpr state::ParamID kGateHoldMs = 3; // "Hold"
128inline constexpr state::ParamID kGateReleaseMs = 4; // "Release"
129
130// Longest delay the node can address; sizes the bake-time buffer allocation.
131inline constexpr float kDelayMaxMs = 2000.0f;
132
133// ── Delay (feedback echo) — "Time" + "Feedback" ──────────────────────────
134// A self-contained lo-fi feedback delay: a single interpolated DelayLine with a
135// recirculating feedback path, its output being dry + wet (an audible echo you
136// can drop straight between audio_in and audio_out). Both knobs are injectable
137// on the BAKED graph — no re-bake:
138// * time_ms (1 .. kDelayMaxMs): the tap position, read per-sample with linear
139// interpolation, so a "Time" sweep glides (the classic tape-delay pitch
140// smear) instead of stepping.
141// * feedback (0 .. 0.95): the recirculation gain; clamped below unity so the
142// tail always decays (no runaway — the verify gate's boundedness check).
143// The DelayLine buffer is sized once at prepare() for kDelayMaxMs; time_ms is
144// clamped into [1, buffer] each sample, so an injected value can never read past
145// the allocation. RT-safe: prepare() allocates, process() is pure arithmetic.
151
153
154// ── Filter (Svf) — "Tone" + "Resonance" ──────────────────────────────────
155// One factory, four registered types — one per SVF response mode. The mode is
156// a REGISTRATION-time choice rather than a param because it selects which of
157// the TPT structure's simultaneous outputs is read: a build authored as a
158// bandpass is a bandpass for the artifact's life, and no injected value can
159// turn it into something else mid-render.
160//
161// Resonance (Q) is injectable alongside cutoff, which is what makes a sweep
162// sing rather than merely dim: at Q = 12 the response peaks ~25 dB above the
163// Q = 0.707 (maximally flat) reference at cutoff. Bandpass at high Q is the
164// "telephone"/formant color; notch is the phaser-adjacent hollow.
165//
166// Cost note: SvfT recomputes its coefficients (one tan()) inside EVERY setter,
167// so calling both setters per sample would double the transcendental cost of a
168// static-Q sweep. Resonance is therefore written only when the injected value
169// actually moves, and cutoff (which recomputes against the current Q) is
170// written every sample — one tan() per sample except while Q is in motion.
173 double sample_rate = 48000.0;
174 float resonance = 0.707f;
175};
176
177CustomNodeType make_filter_node(signal::Svf::Mode mode = signal::Svf::Mode::lowpass);
178
179// ── Waveshaper (tanh saturation) — "Drive" ───────────────────────────────
181 signal::WaveShaper ws; // default curve = tanh_clip
182};
183
185
186// ── Dry/Wet mixer — "Mix" ────────────────────────────────────────────────
187// Two input ports: port 0 = dry, port 1 = wet; one output. mix crossfades
188// dry→wet at BLOCK rate (see the header note on DryWetMixerT's block API).
192
194
195// ── Noise (deterministic white) — "Hiss" ─────────────────────────────────
196// Adds seeded white noise scaled by `level` to the input, raising the noise
197// floor. Deterministic splitmix64 (no clock, no random_device) — the same
198// pattern pulp::signal::osc's NoiseSource uses — so a baked render is
199// reproducible and the audio path is pure integer arithmetic (RT-safe).
201 std::uint64_t rng = 0x9E3779B97F4A7C15ull;
202 static constexpr std::uint64_t kSeed = 0x9E3779B97F4A7C15ull;
203};
204
205// White sample in [-1, 1) from a splitmix64 stream advanced in place.
206inline float forge_white_next(std::uint64_t& state) noexcept {
207 std::uint64_t z = (state += 0x9E3779B97F4A7C15ull);
208 z = (z ^ (z >> 30)) * 0xBF58476D1CE4E5B9ull;
209 z = (z ^ (z >> 27)) * 0x94D049BB133111EBull;
210 z = z ^ (z >> 31);
211 // Top 24 bits → [0, 2) → [-1, 1).
212 return (static_cast<float>(z >> 40) * (1.0f / 8388608.0f)) - 1.0f;
213}
214
216
217// ── Bitcrush / decimator — "Crush" (NEW; no existing block) ───────────────
218// Two effects, both injectable:
219// * bit_depth (1..16): mid-tread amplitude quantization to 2^bit_depth levels
220// across [-1, 1]. Fractional depths are honored (exp2), so a "Crush" knob
221// can glide continuously between bit depths.
222// * sample_rate_reduction (1..64): sample-and-hold decimation — a new input is
223// latched only every `reduction` samples; between latches the held value is
224// repeated, so the effective sample rate is SR/reduction. Fractional
225// reductions are honored via a phase accumulator.
226// Order: sample-and-hold first (decimate), then quantize the held value — the
227// conventional lo-fi topology (decimate → requantize).
229 float held = 0.0f; // last latched (decimated) sample
230 float phase = 0.0f; // sample-and-hold phase accumulator
232};
233
237
238// ── Trim (gain stage) — "Level" ──────────────────────────────────────────
239// A plain injectable gain in dB. It exists because every level decision in a
240// generated chain — the boost in front of a saturator, the make-up behind it,
241// the output level of the whole plugin — otherwise has nowhere to live: the
242// graph's built-in Gain node freezes its value at build time and cannot be
243// bound to a macro. ±24 dB spans "barely nudged" to "slammed into the drive"
244// in both directions, and 0 dB is exactly unity, so an untouched Trim is
245// bit-transparent.
246//
247// exp10 is memoized against the last dB value: a Level knob is static for most
248// of its life, so the common case costs a compare instead of a pow() per sample,
249// while a moving knob still resolves per sample.
251 float last_db = 0.0f;
252 float gain = 1.0f;
253};
254
256
257// ── Ping-pong delay — TRUE STEREO, "Time" + "Feedback" + "Width" ─────────
258// The one node in this catalog whose two ports are the LEFT and RIGHT halves of
259// a single logical wire rather than two logical inputs. It cannot be expressed
260// as two independent mono instances: the effect IS the cross-coupling, each
261// channel's delay recirculating into the OTHER channel's line, so an echo
262// bounces L → R → L → R at `time_ms` intervals.
263//
264// wet_l = line_l.read(d); wet_r = line_r.read(d);
265// line_l.push(in_l + fb * wet_r); line_r.push(in_r + fb * wet_l);
266//
267// With signal in the left channel only, echo 1 leaves left, echo 2 leaves right,
268// echo 3 left … — the alternation is a property of the topology, not of a pan
269// LFO, so it holds at every delay time and feedback setting.
270//
271// Because the loop traverses BOTH lines before returning to its own channel,
272// the round-trip gain is fb², so the tail decays faster than a mono delay at
273// the same setting; feedback is still clamped below unity so a runaway is
274// structurally impossible.
275//
276// `width` collapses the bounce toward the centre: 1 keeps the taps hard L/R,
277// 0 sums both taps equally into both outputs (a plain stereo delay with no
278// bounce), values between crossfade. The dry signal is never touched, so the
279// output is dry + wet exactly like the mono delay node.
286
288
289// ── Reverb (FDN) — "Decay" + "Damping" + "Mix" ───────────────────────────
290// Wraps the SDK's algorithmic reverb (signal::Reverb): a 4-channel feedback
291// delay network with a Hadamard (unitary, energy-preserving) mixing matrix.
292// Honestly named — it is a good ALGORITHMIC reverb, not a plate/spring/
293// convolution emulation — and its three macros are the three controls that
294// define a reverb's character:
295// * decay (0.1 .. 10 s): the RT60. The FDN's per-round feedback is
296// 10^(-3·avg_delay/decay), so the tail reaches −60 dB at exactly `decay`
297// seconds by construction — the macro reads directly as reverb time.
298// * damping (0 .. 0.99): a one-pole lowpass in the feedback path; higher
299// values roll the tail's highs off sooner, darkening it — a bright hall at
300// 0, a dark room near the top.
301// * mix (0 .. 1): dry/wet crossfade, applied inside the reverb.
302// The feedback is structurally below unity for every finite decay, so the tail
303// always decays. The raw FDN can nevertheless build up a large steady-state
304// response at the 10 s maximum (stability alone is not a gain bound), so the
305// wet path is normalized by 1/48. Across supported audio sample rates this
306// conservatively covers the FDN's 1/(1-feedback) induced-gain bound and keeps
307// the node below Forge's declared 2x worst-case gain.
308//
309// Mono in → mono out (dual-mono in the stereo spine): the FDN's two stereo taps
310// are summed to one output, and running one instance per channel rail gives a
311// naturally decorrelated stereo tail. RT-safe: prepare() sizes the delay lines,
312// process() is pure arithmetic. signal::Reverb recomputes its feedback with one
313// pow() per sample internally; that is the SDK block's fixed cost and cannot be
314// avoided through its API — a reverb tail is not cutoff-sweep-sensitive, so it
315// is an honest, acceptable cost. decay and damping are written only when the
316// injected value actually moves (the setters are otherwise wasted work); mix is
317// a cheap store and is written every sample.
320 float last_decay = -1.0f;
321 float last_damping = -1.0f;
322};
323
324inline constexpr float kReverbWetNormalization = 1.0f / 48.0f;
325
327
328// ── Compressor (feed-forward dynamics) — "Threshold/Ratio/Attack/Release" ─
329// A real feed-forward compressor: wraps signal::CompressorT unchanged, exposing
330// its four musical macros. Above the threshold the gain is reduced by the ratio;
331// the reduction follows the attack/release ballistics. No makeup gain is applied
332// (makeup_db = 0) — the node's job is to REDUCE level above threshold, never to
333// restore it, so a "loud input above threshold" is always measurably quieter and
334// the classic transfer function holds: out_db = threshold + (in_db-threshold)/ratio
335// for in above the (soft-knee) threshold. Deliberate level lives on the E2 "trim"
336// node, so a makeup knob here would be clutter.
337//
338// RT-safe: prepare() sets the sample rate (lookahead stays 0, so no allocation);
339// process() only copies the Params struct and runs the compressor's buffer path.
343
345
346// ── Gate (noise gate with hold) — "Threshold/Attack/Hold/Release" ─────────
347// A noise gate / downward expander with a hold stage. The expander transfer
348// curve and range are the same as signal::NoiseGateT (fixed ratio 10:1, floor
349// -80 dB): below the threshold the gain is pulled down toward the floor. Two
350// deliberate differences from wrapping NoiseGateT directly:
351// * HOLD — NoiseGateT has no hold parameter, and a gate without one chatters on
352// material that dips momentarily below threshold. This node keeps the gate
353// fully open for `hold_ms` after the signal was last above threshold, THEN
354// releases. Hold is the fourth macro the dynamics vocabulary expects.
355// * CONVENTIONAL BALLISTICS — attack opens the gate (target rising toward 0 dB),
356// release closes it (target falling toward the floor). This is the standard
357// gate mapping a user/generator expects from "Attack"/"Release"; NoiseGateT's
358// own follower maps the two the other way, so this node is NOT a drop-in of
359// that block — it shares its expansion curve and floor, not its envelope
360// direction.
361// The below-threshold expansion curve (gain = -(threshold - in)*(ratio-1),
362// floored at the range) is NoiseGateT's; this node adds the hold stage on top.
363//
364// RT-safe: prepare() only stores the sample rate; process() is pure scalar
365// arithmetic (one exp() per block for each coefficient, no allocation).
367 double sample_rate = 48000.0;
368 float envelope_db = 0.0f; // current gain-reduction envelope (0 = open)
369 float hold_remaining = 0.0f; // samples left in the hold window
370 static constexpr float kRatio = 10.0f; // gate expansion ratio (NoiseGateT default)
371 static constexpr float kRangeDb = -80.0f; // maximum attenuation (floor)
372};
373
375
376// ═══ CV primitive pack (composition unlock) ═══
377// ── CV primitive pack ────────────────────────────────────────────────────
378// The composition unlock: control-signal-as-audio-port nodes. An lfo/env_follower
379// emits a UNIPOLAR control signal [0, 1] on its audio output; a consumer (vca,
380// filter_cv, delay_cv) reads that control signal on a dedicated CV INPUT PORT and
381// interprets it per the port's fixed unit. No graph-level modulation edges are
382// involved — modulation is ordinary audio topology the graph already routes and
383// bakes, so tremolo/auto-wah/chorus/pump become compositions rather than nodes.
384inline constexpr const char* kLfoTypeId = "forge_lofi_lfo";
385inline constexpr const char* kVcaTypeId = "forge_lofi_vca";
386inline constexpr const char* kEnvFollowerTypeId = "forge_lofi_env_follower";
387inline constexpr const char* kFilterCvTypeId = "forge_lofi_filter_cv";
388inline constexpr const char* kDelayCvTypeId = "forge_lofi_delay_cv";
389
390// CV pack injectable macros.
391inline constexpr state::ParamID kLfoRateHz = 1; // LFO "Rate"
392inline constexpr state::ParamID kLfoDepth = 2; // LFO "Depth"
393inline constexpr state::ParamID kLfoShape = 3; // LFO "Shape" (0..3)
394inline constexpr state::ParamID kVcaGain = 1; // VCA base "Gain"
395inline constexpr state::ParamID kEnvAttackMs = 1; // env-follower "Attack"
396inline constexpr state::ParamID kEnvReleaseMs = 2; // env-follower "Release"
397inline constexpr state::ParamID kEnvSensitivity = 3; // env-follower "Sensitivity"
398inline constexpr state::ParamID kEnvInvert = 4; // env-follower "Invert" (duck)
399inline constexpr state::ParamID kFilterCvBaseHz = 1; // filter-cv base "Cutoff"
400inline constexpr state::ParamID kFilterCvAmountOct = 2; // filter-cv CV "Amount" (oct)
401inline constexpr state::ParamID kFilterCvResonance = 3; // filter-cv "Resonance"
402inline constexpr state::ParamID kDelayCvBaseMs = 1; // delay-cv base "Time"
403inline constexpr state::ParamID kDelayCvDepthMs = 2; // delay-cv CV "Depth" (ms)
404inline constexpr state::ParamID kDelayCvFeedback = 3; // delay-cv "Feedback"
405inline constexpr state::ParamID kDelayCvMix = 4; // delay-cv "Mix"
406
407// LFO shape enumeration (rounded from the injectable kLfoShape float).
408enum class LfoShape : int { sine = 0, triangle = 1, saw = 2, square = 3 };
409
410// One bipolar [-1, 1] LFO sample for a normalized phase [0, 1) and a shape id.
411inline float forge_lfo_osc(float phase, int shape) noexcept {
412 switch (shape) {
413 case static_cast<int>(LfoShape::triangle):
414 return 1.0f - 4.0f * std::fabs(phase - 0.5f); // /\ bipolar
415 case static_cast<int>(LfoShape::saw):
416 return 2.0f * phase - 1.0f; // rising ramp
417 case static_cast<int>(LfoShape::square):
418 return phase < 0.5f ? 1.0f : -1.0f; // ±1 pulse
419 case static_cast<int>(LfoShape::sine):
420 default: {
421 constexpr float kTwoPi = 6.28318530717958647692f;
422 return std::sin(kTwoPi * phase);
423 }
424 }
425}
426
427// ── LFO (control source) — "Rate" + "Depth" + "Shape" ────────────────────
428// A 0-input / 1-output control SOURCE: a free-running low-frequency oscillator
429// whose output is a UNIPOLAR control signal in [0, 1] on port 0 (an audio port
430// carrying CV, not sound). It is meant to feed a CV input port (vca gain,
431// filter_cv cutoff, delay_cv time), not the speakers.
432// * rate_hz (0.01 .. 40): oscillation frequency, read per-sample.
433// * depth (0 .. 1): swing around the 0.5 midpoint; 1 → full [0, 1], 0 → flat 0.5.
434// * shape (0..3): sine / triangle / saw / square (rounded).
435// Output: cv = clamp(0.5 + 0.5·depth·osc(phase), 0, 1). Phase starts at 0 and is
436// deterministic, so a baked render is reproducible. RT-safe: pure arithmetic.
438 double sample_rate = 48000.0;
439 float phase = 0.0f; // normalized [0, 1)
440};
441
443
444// ── VCA (voltage-controlled amplifier) — "Gain" ──────────────────────────
445// Two input ports: port 0 = signal (audio), port 1 = gain CV (control, [0, 1]);
446// one output. out = signal · gain · clamp(cv, 0, 1), with gain in [0, 1]. It is a
447// pure ATTENUATOR — worst-case gain is 1.0 (unity), never a boost — so no CV node
448// can amplify a chain; makeup gain is the `trim` node's job. Feeding an lfo's
449// output into port 1 yields tremolo; an inverted env_follower yields a self-
450// ducking "pump". The CV is clamped to [0, 1] (a control, never a phase inverter
451// or a boost). RT-safe: pure arithmetic, no state.
453
454// ── Envelope follower (audio → CV) — "Attack" + "Release" + more ──────────
455// One input (audio), one output (a UNIPOLAR control signal). Tracks the input
456// level with independent attack/release ballistics and emits it as CV in [0, 1],
457// ready to drive a filter_cv cutoff (auto-wah) or a vca gain (dynamics/pump).
458// * attack_ms / release_ms: ballistics, sampled at block rate (a setter
459// recomputes coefficients, so it is not audio-rate — honest and RT-safe).
460// * sensitivity (0.1 .. 8): scales the envelope before clamping to [0, 1].
461// * invert (0/1): when set, emits 1 − env, so a loud input DUCKS the CV
462// (self-pump / ducking when this envelope drives a vca).
468
470
471// ── Filter with cutoff CV (svf v2) — "Cutoff" + "Amount" + "Resonance" ────
472// Two input ports: port 0 = signal (audio), port 1 = cutoff CV (control, [0, 1]);
473// one output. A resonant lowpass whose cutoff sweeps with the CV:
474// cutoff_hz = clamp(base · 2^(cv · amount_oct), 20, 0.45·sr)
475// so a CV of 0 sits at the base cutoff and a CV of 1 opens `amount_oct` octaves
476// above it. env_follower → port 1 gives auto-wah; lfo → port 1 gives a filter
477// sweep. cutoff is clamped below Nyquist so the TPT tan() prewarp stays stable at
478// any host rate. RT-safe: prepare() sets rate; process() is a per-sample retune.
481 double sample_rate = 48000.0;
482 float last_resonance = -1.0f;
483};
484
486
487// ── Delay with time CV (delay v2) — "Time" + "Depth" + "Feedback" + "Mix" ─
488// Two input ports: port 0 = signal (audio), port 1 = time CV (control, [0, 1]);
489// one output. An interpolated delay whose tap MOVES with the CV:
490// time_ms = base_ms + cv · depth_ms
491// so an lfo on port 1 produces chorus/flanger/vibrato (pitch smear from the moving
492// read tap). feedback recirculates (clamped < 1 so the tail always decays) and mix
493// blends dry/wet. RT-safe: prepare() allocates; process() is pure arithmetic.
499
501
502// ═══ Stereo motion pack (spatial effects) ═══
503// Three catalog nodes that MOVE or SHAPE the stereo image rather than colour a
504// single channel. Two are TRUE STEREO (2-in/2-out, their ports the L/R halves of
505// one logical wire — the cross-channel relationship IS the effect, so they cannot
506// be expressed as two independent mono instances); one is dual-mono but carries
507// its own internal LFO, so it is self-modulated the way the composition doc's
508// "self-modulated node" pattern intends — no graph modulation edges involved.
509inline constexpr const char* kAutoPanTypeId = "forge_lofi_auto_pan";
510inline constexpr const char* kWidthTypeId = "forge_lofi_width";
511inline constexpr const char* kPhaserTypeId = "forge_lofi_phaser";
512
513// Stereo-motion injectable macros (node-local ids; the framework namespaces
514// per node, so reusing 1..N across nodes never collides).
515inline constexpr state::ParamID kAutoPanRateHz = 1; // auto-pan LFO "Rate"
516inline constexpr state::ParamID kAutoPanDepth = 2; // auto-pan "Depth" (pan swing)
517inline constexpr state::ParamID kAutoPanShape = 3; // auto-pan LFO "Shape" (0..3)
518inline constexpr state::ParamID kWidthAmount = 1; // width "Width" (0 mono .. 2 wide)
519inline constexpr state::ParamID kPhaserRateHz = 1; // phaser LFO "Rate"
520inline constexpr state::ParamID kPhaserDepth = 2; // phaser sweep "Depth"
521inline constexpr state::ParamID kPhaserFeedback = 3; // phaser "Feedback" (resonance)
522inline constexpr state::ParamID kPhaserStages = 4; // phaser allpass "Stages" (2..8)
523
524// ── Auto-pan — TRUE STEREO, "Rate" + "Depth" + "Shape" ───────────────────
525// An LFO drives an equal-power balance across the two channels: as the LFO
526// sweeps the pan position from left to right, the left gain follows cos(θ) while
527// the right follows sin(θ), so the two channel gains are ANTI-CORRELATED — when
528// one rises the other falls. That opposition is the effect and is why it is true
529// stereo: a per-channel copy could not see the other channel to oppose it.
530// * rate_hz (0.01 .. 20): LFO frequency, read per-sample.
531// * depth (0 .. 1): how far the pan swings from centre; 0 pins both gains at
532// the centre value (cos(π/4) = 0.707) → the two channels track together
533// (the static negative control), >0 opens the anti-correlated swing.
534// * shape (0..3): sine / triangle / saw / square LFO (rounded).
535// Equal-power gains never exceed 1, so the node can only attenuate each channel —
536// its worst-case multiplicative gain is unity. RT-safe: pure arithmetic; the LFO
537// phase starts at 0 so a baked render is reproducible.
539 double sample_rate = 48000.0;
540 float phase = 0.0f; // normalized [0, 1)
541};
542
544
545// ── Width — TRUE STEREO, "Width" ─────────────────────────────────────────
546// A mid-side width control backed by the shared orthonormal transform. It
547// splits stereo into energy-preserving MID/SIDE coordinates, scales SIDE by
548// `width`, and applies the inverse transform.
549// width = 0 collapses the side entirely → L' = R' = (L + R) / 2, a mono image;
550// width = 1 reconstructs the original L/R exactly (unity); width > 1 amplifies
551// the side, widening the image past the original. It is true stereo because it
552// operates on the SUM and DIFFERENCE of the two channels — the relationship
553// between them —
554// which a per-channel instance cannot compute.
555// * width (0 .. 2): mono at 0, unity at 1, up to 2× the side energy at 2.
556// Worst-case gain: at width = 2, L' = 1.5·L − 0.5·R, whose peak magnitude for
557// |L|,|R| ≤ 1 is 2.0. The transform is pure arithmetic with no state, but the
558// node still owns a trivial instance so it takes the same instanced code path as
559// every other catalog node (RAII create/destroy) rather than a special-case
560// stateless path.
562
564
565// ── Phaser — allpass-chain sweep, "Rate" + "Depth" + "Feedback" + "Stages" ─
566// A classic phaser: a cascade of first-order allpass sections whose corner
567// frequency is swept by the node's OWN internal LFO, mixed 50/50 with the dry
568// signal so the moving allpass phase carves a set of notches that glide up and
569// down the spectrum. It is SELF-MODULATED — the LFO lives inside the node, not on
570// a graph modulation edge — so it is dual-mono: one instance per channel rail,
571// each seeded identically, which keeps the L/R null test intact.
572// * rate_hz (0.01 .. 20): sweep LFO frequency.
573// * depth (0 .. 1): sweep span; 0 pins the sweep at its low corner → the
574// notches sit STILL (the static negative control), >0 sets them gliding.
575// * feedback (0 .. 0.9): allpass recirculation; deepens and sharpens the
576// notches. Clamped below unity so the resonance cannot run away.
577// * stages (2 .. 8): number of allpass sections (rounded) → number of
578// notches. The wrapped Phaser clamps to its own 2..8 range as well.
579// Worst-case gain: the allpass chain is unity-magnitude, so the feedback loop
580// sums toward 1/(1 − 0.9) = 10 at resonance; the 50/50 mix halves that, but 10 is
581// the conservative upper bound the path-gain lint reads. RT-safe: PhaserT uses
582// fixed member storage and denormal-snaps its feedback state.
585 double sample_rate = 48000.0;
586};
587
589
590} // namespace pulp::host::forge_lofi
591
592#include <pulp/host/detail/forge_lofi_catalog_descriptor.hpp>
Peak/RMS envelope follower with independent attack and release curves.
Mode
Definition svf.hpp:18
Deterministic TPDF dither and bounded error-feedback quantization.
Definition forge_analog_vcf_catalog.hpp:18
constexpr state::ParamID kFilterCvResonance
Definition forge_lofi_catalog.hpp:401
constexpr state::ParamID kBitcrushBitDepth
Definition forge_lofi_catalog.hpp:111
constexpr state::ParamID kCompThresholdDb
Definition forge_lofi_catalog.hpp:120
constexpr state::ParamID kPingPongTimeMs
Definition forge_lofi_catalog.hpp:114
float forge_lfo_osc(float phase, int shape) noexcept
Definition forge_lofi_catalog.hpp:411
constexpr state::ParamID kPhaserFeedback
Definition forge_lofi_catalog.hpp:521
constexpr const char * kPhaserTypeId
Definition forge_lofi_catalog.hpp:511
constexpr state::ParamID kCompRatio
Definition forge_lofi_catalog.hpp:121
float forge_white_next(std::uint64_t &state) noexcept
Definition forge_lofi_catalog.hpp:206
constexpr const char * kFilterHighpassTypeId
Definition forge_lofi_catalog.hpp:98
constexpr state::ParamID kGateAttackMs
Definition forge_lofi_catalog.hpp:126
CustomNodeType make_bitcrush_node(signal::DitherMode dither=signal::DitherMode::none, signal::NoiseShapingOrder shaping=signal::NoiseShapingOrder::none)
constexpr const char * kPingPongTypeId
Definition forge_lofi_catalog.hpp:88
constexpr state::ParamID kFilterCvBaseHz
Definition forge_lofi_catalog.hpp:399
constexpr state::ParamID kVcaGain
Definition forge_lofi_catalog.hpp:394
CustomNodeType make_filter_node(signal::Svf::Mode mode=signal::Svf::Mode::lowpass)
CustomNodeType make_env_follower_node()
constexpr state::ParamID kReverbDamping
Definition forge_lofi_catalog.hpp:118
constexpr state::ParamID kDelayCvBaseMs
Definition forge_lofi_catalog.hpp:402
constexpr state::ParamID kEnvReleaseMs
Definition forge_lofi_catalog.hpp:396
constexpr const char * kGateTypeId
Definition forge_lofi_catalog.hpp:91
constexpr state::ParamID kDryWetMix
Definition forge_lofi_catalog.hpp:109
constexpr const char * kWaveshaperTypeId
Definition forge_lofi_catalog.hpp:80
constexpr float kDelayMaxMs
Definition forge_lofi_catalog.hpp:131
constexpr const char * kWidthTypeId
Definition forge_lofi_catalog.hpp:510
CustomNodeType make_gate_node()
constexpr state::ParamID kEnvSensitivity
Definition forge_lofi_catalog.hpp:397
CustomNodeType make_ping_pong_node()
constexpr state::ParamID kLfoRateHz
Definition forge_lofi_catalog.hpp:391
constexpr state::ParamID kDelayFeedback
Definition forge_lofi_catalog.hpp:105
constexpr state::ParamID kAutoPanShape
Definition forge_lofi_catalog.hpp:517
constexpr const char * kBitcrushTpdfSecondTypeId
Definition forge_lofi_catalog.hpp:86
constexpr float kReverbWetNormalization
Definition forge_lofi_catalog.hpp:324
constexpr state::ParamID kAutoPanDepth
Definition forge_lofi_catalog.hpp:516
constexpr state::ParamID kGateThresholdDb
Definition forge_lofi_catalog.hpp:125
CustomNodeType make_trim_node()
LfoShape
Definition forge_lofi_catalog.hpp:408
@ square
Definition forge_lofi_catalog.hpp:408
@ sine
Definition forge_lofi_catalog.hpp:408
@ saw
Definition forge_lofi_catalog.hpp:408
@ triangle
Definition forge_lofi_catalog.hpp:408
constexpr const char * kBitcrushTpdfFirstTypeId
Definition forge_lofi_catalog.hpp:85
constexpr state::ParamID kPingPongFeedback
Definition forge_lofi_catalog.hpp:115
constexpr const char * kAutoPanTypeId
Definition forge_lofi_catalog.hpp:509
CustomNodeType make_reverb_node()
constexpr state::ParamID kEnvInvert
Definition forge_lofi_catalog.hpp:398
CustomNodeType make_delay_node()
CustomNodeType make_waveshaper_node()
constexpr state::ParamID kCompReleaseMs
Definition forge_lofi_catalog.hpp:123
constexpr state::ParamID kReverbDecay
Definition forge_lofi_catalog.hpp:117
constexpr state::ParamID kGateHoldMs
Definition forge_lofi_catalog.hpp:127
constexpr state::ParamID kFilterCvAmountOct
Definition forge_lofi_catalog.hpp:400
constexpr state::ParamID kPhaserStages
Definition forge_lofi_catalog.hpp:522
constexpr const char * kNoiseTypeId
Definition forge_lofi_catalog.hpp:82
constexpr state::ParamID kFilterResonance
Definition forge_lofi_catalog.hpp:107
constexpr const char * kBitcrushTpdfTypeId
Definition forge_lofi_catalog.hpp:84
constexpr state::ParamID kWaveshaperDrive
Definition forge_lofi_catalog.hpp:108
constexpr const char * kEnvFollowerTypeId
Definition forge_lofi_catalog.hpp:386
constexpr const char * kFilterCvTypeId
Definition forge_lofi_catalog.hpp:387
constexpr state::ParamID kPingPongWidth
Definition forge_lofi_catalog.hpp:116
constexpr const char * kCompressorTypeId
Definition forge_lofi_catalog.hpp:90
constexpr state::ParamID kAutoPanRateHz
Definition forge_lofi_catalog.hpp:515
constexpr const char * kBitcrushTypeId
Definition forge_lofi_catalog.hpp:83
CustomNodeType make_compressor_node()
CustomNodeType make_width_node()
CustomNodeType make_drywet_node()
constexpr state::ParamID kEnvAttackMs
Definition forge_lofi_catalog.hpp:395
constexpr const char * kFilterNotchTypeId
Definition forge_lofi_catalog.hpp:100
CustomNodeType make_lfo_node()
constexpr const char * kDelayCvTypeId
Definition forge_lofi_catalog.hpp:388
constexpr const char * kDelayTypeId
Definition forge_lofi_catalog.hpp:78
constexpr state::ParamID kReverbMix
Definition forge_lofi_catalog.hpp:119
constexpr const char * kTrimTypeId
Definition forge_lofi_catalog.hpp:87
constexpr state::ParamID kPhaserDepth
Definition forge_lofi_catalog.hpp:520
constexpr state::ParamID kCompBallistics
Definition forge_lofi_catalog.hpp:124
constexpr state::ParamID kTrimGainDb
Definition forge_lofi_catalog.hpp:113
CustomNodeType make_delay_cv_node()
constexpr const char * kFilterBandpassTypeId
Definition forge_lofi_catalog.hpp:99
constexpr state::ParamID kDelayTimeMs
Definition forge_lofi_catalog.hpp:104
constexpr state::ParamID kLfoDepth
Definition forge_lofi_catalog.hpp:392
constexpr state::ParamID kLfoShape
Definition forge_lofi_catalog.hpp:393
constexpr state::ParamID kDelayCvMix
Definition forge_lofi_catalog.hpp:405
constexpr const char * kDryWetTypeId
Definition forge_lofi_catalog.hpp:81
constexpr state::ParamID kDelayCvFeedback
Definition forge_lofi_catalog.hpp:404
constexpr state::ParamID kPhaserRateHz
Definition forge_lofi_catalog.hpp:519
CustomNodeType make_noise_node()
CustomNodeType make_auto_pan_node()
constexpr const char * kFilterTypeId
Definition forge_lofi_catalog.hpp:79
constexpr state::ParamID kWidthAmount
Definition forge_lofi_catalog.hpp:518
constexpr state::ParamID kBitcrushRateDiv
Definition forge_lofi_catalog.hpp:112
CustomNodeType make_vca_node()
CustomNodeType make_filter_cv_node()
constexpr state::ParamID kFilterCutoffHz
Definition forge_lofi_catalog.hpp:106
constexpr state::ParamID kGateReleaseMs
Definition forge_lofi_catalog.hpp:128
constexpr state::ParamID kNoiseLevel
Definition forge_lofi_catalog.hpp:110
constexpr const char * kVcaTypeId
Definition forge_lofi_catalog.hpp:385
constexpr state::ParamID kDelayCvDepthMs
Definition forge_lofi_catalog.hpp:403
CustomNodeType make_phaser_node()
constexpr const char * kReverbTypeId
Definition forge_lofi_catalog.hpp:89
constexpr state::ParamID kCompAttackMs
Definition forge_lofi_catalog.hpp:122
constexpr const char * kLfoTypeId
Definition forge_lofi_catalog.hpp:384
BallisticsFilterT< float > BallisticsFilter
Definition ballistics_filter.hpp:195
NoiseShapingOrder
Definition dither.hpp:21
@ none
Definition dither.hpp:22
SvfT< float > Svf
Definition svf.hpp:148
WaveShaperT< float > WaveShaper
Definition waveshaper.hpp:105
DryWetMixerT< float > DryWetMixer
Definition dry_wet_mixer.hpp:340
DitherQuantizerT< float > DitherQuantizer
Definition dither.hpp:236
DelayLineT< float > DelayLine
Definition delay_line.hpp:82
ReverbT< float > Reverb
Definition reverb.hpp:101
PhaserT< float > Phaser
Definition phaser.hpp:84
DitherMode
Definition dither.hpp:16
@ none
Definition dither.hpp:17
CompressorT< float > Compressor
Definition compressor.hpp:425
SequencerStateChannel is the audio-safe transport for a plugin's structured NON-parameter state: the ...
Definition audio_device_manager.hpp:48
uint32_t ParamID
Unique parameter identifier, stable across plugin versions.
Definition parameter.hpp:55
Definition custom_node_type.hpp:90
Definition forge_lofi_catalog.hpp:538
double sample_rate
Definition forge_lofi_catalog.hpp:539
float phase
Definition forge_lofi_catalog.hpp:540
Definition forge_lofi_catalog.hpp:228
float held
Definition forge_lofi_catalog.hpp:229
signal::DitherQuantizer quantizer
Definition forge_lofi_catalog.hpp:231
float phase
Definition forge_lofi_catalog.hpp:230
Definition forge_lofi_catalog.hpp:340
signal::Compressor comp
Definition forge_lofi_catalog.hpp:341
Definition forge_lofi_catalog.hpp:494
double sample_rate
Definition forge_lofi_catalog.hpp:496
signal::DelayLine line
Definition forge_lofi_catalog.hpp:495
int max_delay_samples
Definition forge_lofi_catalog.hpp:497
Definition forge_lofi_catalog.hpp:146
double sample_rate
Definition forge_lofi_catalog.hpp:148
int max_delay_samples
Definition forge_lofi_catalog.hpp:149
signal::DelayLine line
Definition forge_lofi_catalog.hpp:147
Definition forge_lofi_catalog.hpp:189
signal::DryWetMixer dwm
Definition forge_lofi_catalog.hpp:190
Definition forge_lofi_catalog.hpp:463
float last_release_ms
Definition forge_lofi_catalog.hpp:466
float last_attack_ms
Definition forge_lofi_catalog.hpp:465
signal::BallisticsFilter env
Definition forge_lofi_catalog.hpp:464
Definition forge_lofi_catalog.hpp:479
double sample_rate
Definition forge_lofi_catalog.hpp:481
signal::Svf svf
Definition forge_lofi_catalog.hpp:480
float last_resonance
Definition forge_lofi_catalog.hpp:482
Definition forge_lofi_catalog.hpp:171
double sample_rate
Definition forge_lofi_catalog.hpp:173
float resonance
Definition forge_lofi_catalog.hpp:174
signal::Svf svf
Definition forge_lofi_catalog.hpp:172
Definition forge_lofi_catalog.hpp:366
static constexpr float kRatio
Definition forge_lofi_catalog.hpp:370
float hold_remaining
Definition forge_lofi_catalog.hpp:369
float envelope_db
Definition forge_lofi_catalog.hpp:368
double sample_rate
Definition forge_lofi_catalog.hpp:367
static constexpr float kRangeDb
Definition forge_lofi_catalog.hpp:371
Definition forge_lofi_catalog.hpp:437
double sample_rate
Definition forge_lofi_catalog.hpp:438
float phase
Definition forge_lofi_catalog.hpp:439
Definition forge_lofi_catalog.hpp:200
std::uint64_t rng
Definition forge_lofi_catalog.hpp:201
static constexpr std::uint64_t kSeed
Definition forge_lofi_catalog.hpp:202
Definition forge_lofi_catalog.hpp:583
double sample_rate
Definition forge_lofi_catalog.hpp:585
signal::Phaser phaser
Definition forge_lofi_catalog.hpp:584
Definition forge_lofi_catalog.hpp:280
int max_delay_samples
Definition forge_lofi_catalog.hpp:284
signal::DelayLine line_l
Definition forge_lofi_catalog.hpp:281
double sample_rate
Definition forge_lofi_catalog.hpp:283
signal::DelayLine line_r
Definition forge_lofi_catalog.hpp:282
Definition forge_lofi_catalog.hpp:318
float last_decay
Definition forge_lofi_catalog.hpp:320
float last_damping
Definition forge_lofi_catalog.hpp:321
signal::Reverb reverb
Definition forge_lofi_catalog.hpp:319
Definition forge_lofi_catalog.hpp:250
float last_db
Definition forge_lofi_catalog.hpp:251
float gain
Definition forge_lofi_catalog.hpp:252
Definition forge_lofi_catalog.hpp:180
signal::WaveShaper ws
Definition forge_lofi_catalog.hpp:181
Definition forge_lofi_catalog.hpp:561