DSPark 1.8.0
Header-only C++20 DSP for real-time and offline audio
Loading...
Searching...
No Matches
GranularProcessor.h
1// DSPark - Professional Audio DSP Framework
2// Copyright (c) 2026 Cristian Moresi - MIT License
3
4#pragma once
5
41#include "../Core/AudioBuffer.h"
42#include "../Core/AudioSpec.h"
43#include "../Core/DenormalGuard.h"
44#include "../Core/DspMath.h"
45#include "../Core/Interpolation.h"
46#include "../Core/StateBlob.h"
47
48#include <algorithm>
49#include <array>
50#include <atomic>
51#include <cmath>
52#include <cstddef>
53#include <cstdint>
54#include <vector>
55
56namespace dspark {
57
64template <FloatType T>
66{
67public:
68 static constexpr int kMaxGrains = 64;
69
70 // -- Lifecycle ---------------------------------------------------------------
71
84 void prepare(const AudioSpec& spec, double bufferSeconds = 4.0)
85 {
86 if (!spec.isValid()) return;
87 if (!std::isfinite(bufferSeconds)) bufferSeconds = 4.0;
88 prepared_.store(false, std::memory_order_relaxed);
89 sampleRate_ = spec.sampleRate;
90 mixMaxStep_ = static_cast<T>(1.0 / std::max(1.0, sampleRate_ * 0.02));
91 numChannels_ = std::min(spec.numChannels, 2);
92
93 int size = 1;
94 while (size < static_cast<int>(sampleRate_ * std::clamp(bufferSeconds, 0.5, 16.0)))
95 size <<= 1;
96 ringMask_ = size - 1;
97 for (int ch = 0; ch < 2; ++ch)
98 ring_[ch].assign(static_cast<size_t>(size), T(0));
99
100 window_.resize(kWindowSize);
101 for (int i = 0; i < kWindowSize; ++i)
102 window_[static_cast<size_t>(i)] = static_cast<T>(
103 0.5 - 0.5 * std::cos(2.0 * 3.14159265358979 * i / (kWindowSize - 1)));
104
105 prepared_.store(true, std::memory_order_relaxed);
106 reset();
107 }
108
110 void reset() noexcept
111 {
112 if (!prepared_.load(std::memory_order_relaxed)) return;
113 for (auto& r : ring_) std::fill(r.begin(), r.end(), T(0));
114 for (auto& g : grains_) g.active = false;
115 writePos_ = 0;
116 spawnAcc_ = 0.0;
117 rng_ = 0x9E3779B9u;
118 currentMix_ = mix_.load(std::memory_order_relaxed);
119 }
120
121 // -- Parameters (thread-safe) ---------------------------------------------------
122
125 void setGrainSize(T ms) noexcept
126 {
127 if (!std::isfinite(ms)) return;
128 grainMs_.store(std::clamp(ms, T(10), T(500)), std::memory_order_relaxed);
129 }
130
133 void setDensity(T perSecond) noexcept
134 {
135 if (!std::isfinite(perSecond)) return;
136 density_.store(std::clamp(perSecond, T(1), T(200)), std::memory_order_relaxed);
137 }
138
141 void setJitter(T amount) noexcept
142 {
143 if (!std::isfinite(amount)) return;
144 jitter_.store(std::clamp(amount, T(0), T(1)), std::memory_order_relaxed);
145 }
146
149 void setPitch(T semitones) noexcept
150 {
151 if (!std::isfinite(semitones)) return;
152 pitchSt_.store(std::clamp(semitones, T(-24), T(24)), std::memory_order_relaxed);
153 }
154
157 void setPitchJitter(T semitones) noexcept
158 {
159 if (!std::isfinite(semitones)) return;
160 pitchJitterSt_.store(std::clamp(semitones, T(0), T(12)), std::memory_order_relaxed);
161 }
162
165 void setSpread(T amount) noexcept
166 {
167 if (!std::isfinite(amount)) return;
168 spread_.store(std::clamp(amount, T(0), T(1)), std::memory_order_relaxed);
169 }
170
172 void setFreeze(bool frozen) noexcept
173 {
174 freeze_.store(frozen, std::memory_order_relaxed);
175 }
176
179 void setMix(T mix) noexcept
180 {
181 if (!std::isfinite(mix)) return;
182 mix_.store(std::clamp(mix, T(0), T(1)), std::memory_order_relaxed);
183 }
184
185 [[nodiscard]] T getGrainSize() const noexcept { return grainMs_.load(std::memory_order_relaxed); }
186 [[nodiscard]] T getDensity() const noexcept { return density_.load(std::memory_order_relaxed); }
187 [[nodiscard]] T getJitter() const noexcept { return jitter_.load(std::memory_order_relaxed); }
188 [[nodiscard]] T getPitch() const noexcept { return pitchSt_.load(std::memory_order_relaxed); }
189 [[nodiscard]] T getPitchJitter() const noexcept { return pitchJitterSt_.load(std::memory_order_relaxed); }
190 [[nodiscard]] T getSpread() const noexcept { return spread_.load(std::memory_order_relaxed); }
191 [[nodiscard]] bool getFreeze() const noexcept { return freeze_.load(std::memory_order_relaxed); }
192 [[nodiscard]] T getMix() const noexcept { return mix_.load(std::memory_order_relaxed); }
193
195 [[nodiscard]] static constexpr int getLatency() noexcept { return 0; }
196
198 [[nodiscard]] std::vector<uint8_t> getState() const
199 {
200 StateWriter w(stateId("GRAN"), 1);
201 // Explicit float casts: the blob stores float, and with T = double the
202 // unqualified write(key, double) would be ambiguous (float/int32/bool).
203 w.write("grainMs", static_cast<float>(grainMs_.load(std::memory_order_relaxed)));
204 w.write("density", static_cast<float>(density_.load(std::memory_order_relaxed)));
205 w.write("jitter", static_cast<float>(jitter_.load(std::memory_order_relaxed)));
206 w.write("pitch", static_cast<float>(pitchSt_.load(std::memory_order_relaxed)));
207 w.write("pitchJitter", static_cast<float>(pitchJitterSt_.load(std::memory_order_relaxed)));
208 w.write("spread", static_cast<float>(spread_.load(std::memory_order_relaxed)));
209 w.write("freeze", freeze_.load(std::memory_order_relaxed));
210 w.write("mix", static_cast<float>(mix_.load(std::memory_order_relaxed)));
211 return w.blob();
212 }
213
215 bool setState(const uint8_t* data, size_t size)
216 {
217 StateReader r(data, size);
218 if (!r.isValid() || r.processorId() != stateId("GRAN")) return false;
219 setGrainSize(static_cast<T>(r.read("grainMs", 80.0f)));
220 setDensity(static_cast<T>(r.read("density", 25.0f)));
221 setJitter(static_cast<T>(r.read("jitter", 0.3f)));
222 setPitch(static_cast<T>(r.read("pitch", 0.0f)));
223 setPitchJitter(static_cast<T>(r.read("pitchJitter", 0.0f)));
224 setSpread(static_cast<T>(r.read("spread", 0.5f)));
225 setFreeze(r.read("freeze", false));
226 setMix(static_cast<T>(r.read("mix", 1.0f)));
227 return true;
228 }
229
230 // -- Processing -------------------------------------------------------------------
231
234 void processBlock(AudioBufferView<T> buffer) noexcept
235 {
236 if (!prepared_.load(std::memory_order_relaxed)) return;
237 DenormalGuard guard;
238
239 const int nCh = std::min(buffer.getNumChannels(), numChannels_);
240 const int nS = buffer.getNumSamples();
241 if (nCh == 0 || nS == 0) return;
242
243 const bool frozen = freeze_.load(std::memory_order_relaxed);
244 // Rate-limited mix ramp (moveTowards, exact landing; settled it
245 // reduces to the constant, bit-identically). A per-block ramp landed
246 // in 0.7 ms with 32-sample blocks.
247 // The cloud is decorrelated from the dry signal: a hard flip clicked
248 // at 11x the steady-state sample delta.
249 const T mixTarget = mix_.load(std::memory_order_relaxed);
250 const T mixStart = currentMix_;
251 const double spawnPerSample =
252 static_cast<double>(density_.load(std::memory_order_relaxed)) / sampleRate_;
253
254 for (int i = 0; i < nS; ++i)
255 {
256 // Capture (continues the moment freeze releases).
257 if (!frozen)
258 {
259 ring_[0][static_cast<size_t>(writePos_)] = buffer.getChannel(0)[i];
260 ring_[1][static_cast<size_t>(writePos_)] =
261 buffer.getChannel(nCh > 1 ? 1 : 0)[i];
262 writePos_ = (writePos_ + 1) & ringMask_;
263 }
264
265 // Spawn with a fractional accumulator: exact average density.
266 spawnAcc_ += spawnPerSample;
267 while (spawnAcc_ >= 1.0)
268 {
269 spawnAcc_ -= 1.0;
270 spawnGrain();
271 }
272
273 // Sum the cloud.
274 T outL = T(0), outR = T(0);
275 for (auto& g : grains_)
276 {
277 if (!g.active) continue;
278
279 const auto idx = static_cast<int64_t>(g.pos);
280 const double frac = g.pos - static_cast<double>(idx);
281
282 const double wPos = g.phase * (kWindowSize - 1);
283 const auto wIdx = static_cast<int>(wPos);
284 const T wFrac = static_cast<T>(wPos - wIdx);
285 const T w = window_[static_cast<size_t>(wIdx)]
286 + (window_[static_cast<size_t>(std::min(wIdx + 1, kWindowSize - 1))]
287 - window_[static_cast<size_t>(wIdx)]) * wFrac;
288
289 T sL = T(0), sR = T(0);
290 readGrain(g, idx, frac, nCh > 1, sL, sR);
291
292 outL += sL * w * g.gainL;
293 outR += sR * w * g.gainR;
294
295 g.pos += g.rate;
296 g.phase += g.phaseInc;
297 if (g.phase >= 1.0)
298 g.active = false;
299 }
300
301 const T mixVal = moveTowards(mixStart, mixTarget, mixMaxStep_ * static_cast<T>(i + 1));
302 const T dryL = buffer.getChannel(0)[i];
303 buffer.getChannel(0)[i] = dryL + (outL - dryL) * mixVal;
304 if (nCh > 1)
305 {
306 const T dryR = buffer.getChannel(1)[i];
307 buffer.getChannel(1)[i] = dryR + (outR - dryR) * mixVal;
308 }
309 }
310 currentMix_ = moveTowards(mixStart, mixTarget, mixMaxStep_ * static_cast<T>(nS));
311 }
312
313private:
314 static constexpr int kWindowSize = 2048;
316 static constexpr double kReadMargin =
317 static_cast<double>(StretchedSincReader<T>::kMaxTaps) + 32.0;
318
319 struct Grain
320 {
321 bool active = false;
322 double pos = 0.0;
323 double rate = 1.0;
324 double phase = 0.0;
325 double phaseInc = 0.0;
326 int step = -1;
327 T gainL = T(0), gainR = T(0);
328 };
329
331 void readGrain(const Grain& g, int64_t idx, double frac, bool stereo,
332 T& sL, T& sR) noexcept
333 {
334 const T* ringL = ring_[0].data();
335 const T* ringR = ring_[1].data();
336 if (g.step < 0) // rate <= 1: the full-band kernel
337 {
338 sL = reader_.readRing(ringL, ringMask_, idx, frac);
339 sR = stereo ? reader_.readRing(ringR, ringMask_, idx, frac) : sL;
340 return;
341 }
342 sL = stretchedReader_.readRing(ringL, ringMask_, idx, frac, g.step);
343 sR = stereo ? stretchedReader_.readRing(ringR, ringMask_, idx, frac, g.step) : sL;
344 }
345
346 [[nodiscard]] double frand() noexcept
347 {
348 rng_ = rng_ * 1664525u + 1013904223u;
349 return static_cast<double>(rng_ >> 8) / 16777216.0; // [0, 1)
350 }
351
352 void spawnGrain() noexcept
353 {
354 for (auto& g : grains_)
355 {
356 if (g.active) continue;
357
358 const double sizeMs = static_cast<double>(grainMs_.load(std::memory_order_relaxed));
359 const double lenSamples = sizeMs * 0.001 * sampleRate_;
360 const double jit = static_cast<double>(jitter_.load(std::memory_order_relaxed));
361 const double st = static_cast<double>(pitchSt_.load(std::memory_order_relaxed))
362 + (frand() * 2.0 - 1.0)
363 * static_cast<double>(pitchJitterSt_.load(std::memory_order_relaxed));
364 const double rate = std::exp2(st / 12.0);
365
366 // Start far enough back that the grain never overtakes the write
367 // head even when pitched up: lead = length * max(rate, 1) + margin,
368 // the margin covering the read kernel's reach on both ends.
369 const double maxSpan = lenSamples * std::max(rate, 1.0) + kReadMargin;
370 const double jitterSpan = jit * 0.5 * static_cast<double>(ringMask_ + 1 - maxSpan - kReadMargin);
371 const double back = maxSpan + frand() * std::max(jitterSpan, 0.0);
372 g.pos = static_cast<double>(writePos_) - back;
373 while (g.pos < 0.0) g.pos += static_cast<double>(ringMask_ + 1);
374
375 g.rate = rate;
376 g.step = rate > 1.0 ? StretchedSincReader<T>::stepFor(rate) : -1;
377 g.phase = 0.0;
378 g.phaseInc = 1.0 / lenSamples;
379
380 const double spreadAmt = static_cast<double>(spread_.load(std::memory_order_relaxed));
381 const double pan = 0.5 + (frand() - 0.5) * spreadAmt; // [0,1]
382 const double a = pan * 1.5707963267948966; // pi/2
383 // 1/sqrt(density*overlap) keeps the cloud near unity loudness.
384 const double overlap = std::max(1.0,
385 static_cast<double>(density_.load(std::memory_order_relaxed))
386 * sizeMs * 0.001);
387 const auto norm = static_cast<T>(1.0 / std::sqrt(overlap));
388 g.gainL = static_cast<T>(std::cos(a)) * norm;
389 g.gainR = static_cast<T>(std::sin(a)) * norm;
390 g.active = true;
391 return;
392 }
393 }
394
395 // -- Members --------------------------------------------------------------------
396 double sampleRate_ = 48000.0;
397 int numChannels_ = 0;
398 std::atomic<bool> prepared_ { false };
399
400 std::array<std::vector<T>, 2> ring_;
401 int ringMask_ = 0;
402 int writePos_ = 0;
403
404 std::vector<T> window_;
405 std::array<Grain, kMaxGrains> grains_;
406 SincInterpolator<T> reader_;
407 StretchedSincReader<T> stretchedReader_;
408 double spawnAcc_ = 0.0;
409 uint32_t rng_ = 0x9E3779B9u;
410 T currentMix_ = T(1);
411 T mixMaxStep_ = T(1.0 / 960.0);
412
413 std::atomic<T> grainMs_ { T(80) };
414 std::atomic<T> density_ { T(25) };
415 std::atomic<T> jitter_ { T(0.3) };
416 std::atomic<T> pitchSt_ { T(0) };
417 std::atomic<T> pitchJitterSt_ { T(0) };
418 std::atomic<T> spread_ { T(0.5) };
419 std::atomic<bool> freeze_ { false };
420 std::atomic<T> mix_ { T(1) };
421};
422
423} // namespace dspark
Non-owning view over audio channel data.
Definition AudioBuffer.h:50
RAII scope guard to disable denormalised (subnormal) floating-point numbers.
Granular clouds and spectral-freeze textures from live input.
void setPitch(T semitones) noexcept
Per-grain pitch shift in semitones [-24, +24] (default 0). Non-finite values are ignored.
void prepare(const AudioSpec &spec, double bufferSeconds=4.0)
Allocates the capture ring and grain pool.
void setDensity(T perSecond) noexcept
Grains per second [1, 200] (default 25). Non-finite values are ignored.
void setGrainSize(T ms) noexcept
Grain duration in milliseconds [10, 500] (default 80). Non-finite values are ignored.
void setSpread(T amount) noexcept
Stereo spread of grain panning [0, 1] (default 0.5). Non-finite values are ignored.
void setFreeze(bool frozen) noexcept
Freezes capture: grains keep playing the held history.
void reset() noexcept
Kills all grains and clears the capture ring. RT-safe.
void setMix(T mix) noexcept
Dry/wet mix [0, 1] (default 1); smoothed linearly over one block. Non-finite values are ignored.
T getGrainSize() const noexcept
void processBlock(AudioBufferView< T > buffer) noexcept
Processes a block in-place (1 or 2 channels). Pass-through until prepare() succeeds.
void setPitchJitter(T semitones) noexcept
Random pitch spread per grain in semitones [0, 12] (default 0). Non-finite values are ignored.
T getPitchJitter() const noexcept
std::vector< uint8_t > getState() const
Serializes the parameter state (setup/UI threads; allocates).
static constexpr int kMaxGrains
bool setState(const uint8_t *data, size_t size)
Restores parameters from a blob (tolerant; rejects foreign ids).
void setJitter(T amount) noexcept
Position jitter [0, 1]: how far back grains may start (default 0.3). Non-finite values are ignored.
bool getFreeze() const noexcept
static constexpr int getLatency() noexcept
Zero: the cloud is parallel to the dry path.
T getDensity() const noexcept
Tolerant reader: missing keys yield defaults, unknown keys are skipped.
Definition StateBlob.h:161
float read(const char *key, float defaultValue) const
Reads a float, or defaultValue when the key is absent.
Definition StateBlob.h:204
bool isValid() const noexcept
Definition StateBlob.h:199
uint32_t processorId() const noexcept
Definition StateBlob.h:200
Serializes key/value parameters into a versioned blob.
Definition StateBlob.h:53
std::vector< uint8_t > blob() const
Finalizes and returns the blob.
Definition StateBlob.h:105
void write(const char *key, float value)
Writes a float parameter.
Definition StateBlob.h:71
Band-limited fractional reader for read rates from 1 to 4.
static int stepFor(double rate) noexcept
The table for a read rate: the nearest tabulated rate at or above it. Costs a log2,...
Main namespace for the DSPark framework.
T moveTowards(T from, T to, T maxDelta) noexcept
Moves a value toward a target by at most a given distance.
Definition DspMath.h:134
constexpr uint32_t stateId(const char(&tag)[5]) noexcept
Builds a FOURCC processor id, e.g. dspark::stateId("COMP").
Definition StateBlob.h:651
Describes the audio environment for a DSP processor.
Definition AudioSpec.h:37
constexpr bool isValid() const noexcept
Checks if the specification contains valid, processable parameters.
Definition AudioSpec.h:71
int numChannels
Number of audio channels (e.g., 1 = mono, 2 = stereo).
Definition AudioSpec.h:58
double sampleRate
Sample rate in Hz.
Definition AudioSpec.h:45