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

A granular synthesis engine: a seeded grain scheduler driving a fixed pool of windowed, resampled, panned grains. More...

#include <pulp/signal/denormal.hpp>
#include <pulp/signal/interpolator.hpp>
#include <pulp/signal/panner.hpp>
#include <pulp/signal/rng.hpp>
#include <pulp/signal/smoothed_value.hpp>
#include <pulp/signal/units.hpp>
#include <algorithm>
#include <cmath>
#include <cstddef>
#include <cstdint>
#include <vector>

Go to the source code of this file.

Classes

struct  pulp::signal::GrainView
 A read-only view of one grain, for metering and grain-cloud displays. More...
class  pulp::signal::GranularEngineT< SampleType >

Namespaces

namespace  pulp
namespace  pulp::signal

Typedefs

using pulp::signal::GranularEngine = GranularEngineT<float>
using pulp::signal::GranularEngine64 = GranularEngineT<double>

Enumerations

enum class  pulp::signal::GrainSource : std::uint8_t { pulp::signal::buffer , pulp::signal::live_ring }
 Where grains read from. More...
enum class  pulp::signal::Coherence : std::uint8_t { pulp::signal::incoherent , pulp::signal::coherent }
 Which normalization law applies — a statement about whether grains are correlated with each other, not a tone control. More...
enum class  pulp::signal::StealPolicy : std::uint8_t { pulp::signal::oldest , pulp::signal::quietest , pulp::signal::drop_new }
 What happens when a grain is due and the pool is full. More...
enum class  pulp::signal::GrainInterp : std::uint8_t { pulp::signal::linear , pulp::signal::cubic_hermite }
 Fractional-read quality inside a grain. More...

Detailed Description

A granular synthesis engine: a seeded grain scheduler driving a fixed pool of windowed, resampled, panned grains.

Granular synthesis builds a texture out of thousands of short windowed fragments — grains, typically 1–100 ms — laid down densely enough to fuse (Roads, "Introduction to Granular Synthesis", CMJ 12(2):11–13, 1988; Roads, Microsound, MIT Press, 2001). The idea descends from Gabor's acoustic quantum (Gabor, "Acoustical Quanta and the Theory of Hearing", Nature 159(4044):591–594, 1947), and the grain-rate / grain-duration / density vocabulary used here is the one Truax's real-time implementation established ("Real-Time Granular Synthesis with a Digital Signal Processor", CMJ 12(2):14–26, 1988).

Three independent time axes, and keeping them independent is the whole design:

  • Density D — how often grains are born. Sets texture, not pitch and not the source-time mapping.
  • Stretch α — how fast the playhead traverses the source. The output timeline is dilated by 1/α; α = 0 freezes.
  • Pitch r — how fast each grain reads inside itself. Every component f in a grain comes out at r·f.

They do not interact because a grain is short: the playhead decides where on the output timeline a slice lands, the ratio decides what pitch that slice plays at, and the window's brief support is what keeps the two from reaching each other. Half-speed and a fifth up is α = 0.5, r = 2^(7/12), and neither number moves the other.

Determinism is a design constraint here, not a nicety. Every per-grain random field is a stateless keyed draw unit_from(mix64(seed, grain_index, field)), keyed off the monotonic grain index — never the pool slot, never a block boundary. That is what makes a draw independent of buffer size and of voice-steal: a stolen grain simply stops, and the next grain's draws are untouched, because nothing in the random stream ever advanced. A sequential generator would be reproducible run-to-run and would still change its answer when the host handed you 64 samples instead of 512. See rng.hpp, whose doc block draws exactly this distinction.

Amplitude follows the √N energy law. Mean simultaneous overlap is N̄ = D·T_g. Grains at scattered onsets are uncorrelated, so their powers add and output RMS grows as √N̄ — hence g = 1/(w_rms·√N̄), and doubling density leaves the level where it was. Grains on a fixed grid at the same source position add in amplitude instead, so the constant-overlap-add reciprocal g = 1/(w_mean·N̄) applies. Those are different laws, not different tunings of one: at N̄ = 10 with a Hann window they differ by 8.24 dB, which is why set_coherence exists.

w_mean and w_rms are measured from the shipped window table rather than assumed, so the law stays correct as the taper knob moves. At full taper the table reproduces the exact Hann identities w_mean = 1/2 and w_rms = √(3/8) to machine precision, which the suite asserts.

Anti-aliasing policy. The grain read is a resampler, not a static nonlinearity, so the house half-band pair does not apply. Pitching down cannot alias. Pitching up undersamples the source, and the only aliasing mechanism is the interpolator's imaging, mitigated by the default 4-point cubic Hermite. There is deliberately no engine-wide oversampling and no tracking pre-decimation low-pass; over ±24 st the interpolator is the whole anti-imaging story. That is a stated limitation, not a hidden one.

Linearity. Windowing, resampling, gain, pan, and summing are all linear, and grains never write back into the ring — only write_live does. There is no gain-carrying nonlinearity and no feedback path, so series law 1 is not triggered and no small-signal-gain or unity-compensation obligation exists. The worst-case gain is a feed-forward headroom figure, bounded by max_grains times the interpolator's peak kernel gain.

Relationship to FreezeLoopSamplerT (freeze_loop_sampler.hpp): both keep a continuously-written ring so freezing costs nothing, and there the similarity ends. That class holds one contiguous slice and loops it with a single equal-power seam, preserving the moment verbatim. This one holds a cloud over a moment — many short overlapping slices at randomised offsets and pitches, where the grain window is the crossfade and there is no seam to hide. They are complements, and chaining them (freeze a loop, then granulate it in buffer mode) is a real patch.

RT contract: prepare() sizes the live ring and the window table and is the only member that allocates. set_*, write_live, process, reset, and every accessor allocate nothing, take no locks, and perform no I/O; all are wait-free. The grain pool is a fixed array of kMaxGrainBudget PODs — there is no dynamic voice allocation at any point. latency_samples() is 0: grains read past source samples only, never future ones, and the delay implied by position is a musical offset rather than reportable codec latency. Source buffers passed to set_buffer are borrowed — the engine never owns, copies, or frees them, and the caller must keep them alive.