DSPark 1.6.1
Header-only audio DSP framework in pure C++20 — zero dependencies
Loading...
Searching...
No Matches
GranularProcessor.h
Go to the documentation of this file.
1// DSPark - Professional Audio DSP Framework
2// Copyright (c) 2026 Cristian Moresi - MIT License
3
4#pragma once
5
33#include "../Core/AudioBuffer.h"
34#include "../Core/AudioSpec.h"
35#include "../Core/DenormalGuard.h"
36#include "../Core/DspMath.h"
37#include "../Core/StateBlob.h"
38
39#include <algorithm>
40#include <array>
41#include <atomic>
42#include <cmath>
43#include <cstddef>
44#include <cstdint>
45#include <vector>
46
47namespace dspark {
48
55template <FloatType T>
57{
58public:
59 static constexpr int kMaxGrains = 64;
60
61 // -- Lifecycle ---------------------------------------------------------------
62
75 void prepare(const AudioSpec& spec, double bufferSeconds = 4.0)
76 {
77 if (!spec.isValid()) return;
78 if (!std::isfinite(bufferSeconds)) bufferSeconds = 4.0;
79 prepared_.store(false, std::memory_order_relaxed);
80 sampleRate_ = spec.sampleRate;
81 numChannels_ = std::min(spec.numChannels, 2);
82
83 int size = 1;
84 while (size < static_cast<int>(sampleRate_ * std::clamp(bufferSeconds, 0.5, 16.0)))
85 size <<= 1;
86 ringMask_ = size - 1;
87 for (int ch = 0; ch < 2; ++ch)
88 ring_[ch].assign(static_cast<size_t>(size), T(0));
89
90 window_.resize(kWindowSize);
91 for (int i = 0; i < kWindowSize; ++i)
92 window_[static_cast<size_t>(i)] = static_cast<T>(
93 0.5 - 0.5 * std::cos(2.0 * 3.14159265358979 * i / (kWindowSize - 1)));
94
95 prepared_.store(true, std::memory_order_relaxed);
96 reset();
97 }
98
100 void reset() noexcept
101 {
102 if (!prepared_.load(std::memory_order_relaxed)) return;
103 for (auto& r : ring_) std::fill(r.begin(), r.end(), T(0));
104 for (auto& g : grains_) g.active = false;
105 writePos_ = 0;
106 spawnAcc_ = 0.0;
107 rng_ = 0x9E3779B9u;
108 currentMix_ = mix_.load(std::memory_order_relaxed);
109 }
110
111 // -- Parameters (thread-safe) ---------------------------------------------------
112
115 void setGrainSize(T ms) noexcept
116 {
117 if (!std::isfinite(ms)) return;
118 grainMs_.store(std::clamp(ms, T(10), T(500)), std::memory_order_relaxed);
119 }
120
123 void setDensity(T perSecond) noexcept
124 {
125 if (!std::isfinite(perSecond)) return;
126 density_.store(std::clamp(perSecond, T(1), T(200)), std::memory_order_relaxed);
127 }
128
131 void setJitter(T amount) noexcept
132 {
133 if (!std::isfinite(amount)) return;
134 jitter_.store(std::clamp(amount, T(0), T(1)), std::memory_order_relaxed);
135 }
136
139 void setPitch(T semitones) noexcept
140 {
141 if (!std::isfinite(semitones)) return;
142 pitchSt_.store(std::clamp(semitones, T(-24), T(24)), std::memory_order_relaxed);
143 }
144
147 void setPitchJitter(T semitones) noexcept
148 {
149 if (!std::isfinite(semitones)) return;
150 pitchJitterSt_.store(std::clamp(semitones, T(0), T(12)), std::memory_order_relaxed);
151 }
152
155 void setSpread(T amount) noexcept
156 {
157 if (!std::isfinite(amount)) return;
158 spread_.store(std::clamp(amount, T(0), T(1)), std::memory_order_relaxed);
159 }
160
162 void setFreeze(bool frozen) noexcept
163 {
164 freeze_.store(frozen, std::memory_order_relaxed);
165 }
166
169 void setMix(T mix) noexcept
170 {
171 if (!std::isfinite(mix)) return;
172 mix_.store(std::clamp(mix, T(0), T(1)), std::memory_order_relaxed);
173 }
174
175 [[nodiscard]] T getGrainSize() const noexcept { return grainMs_.load(std::memory_order_relaxed); }
176 [[nodiscard]] T getDensity() const noexcept { return density_.load(std::memory_order_relaxed); }
177 [[nodiscard]] T getJitter() const noexcept { return jitter_.load(std::memory_order_relaxed); }
178 [[nodiscard]] T getPitch() const noexcept { return pitchSt_.load(std::memory_order_relaxed); }
179 [[nodiscard]] T getPitchJitter() const noexcept { return pitchJitterSt_.load(std::memory_order_relaxed); }
180 [[nodiscard]] T getSpread() const noexcept { return spread_.load(std::memory_order_relaxed); }
181 [[nodiscard]] bool getFreeze() const noexcept { return freeze_.load(std::memory_order_relaxed); }
182 [[nodiscard]] T getMix() const noexcept { return mix_.load(std::memory_order_relaxed); }
183
185 [[nodiscard]] static constexpr int getLatency() noexcept { return 0; }
186
188 [[nodiscard]] std::vector<uint8_t> getState() const
189 {
190 StateWriter w(stateId("GRAN"), 1);
191 // Explicit float casts: the blob stores float, and with T = double the
192 // unqualified write(key, double) would be ambiguous (float/int32/bool).
193 w.write("grainMs", static_cast<float>(grainMs_.load(std::memory_order_relaxed)));
194 w.write("density", static_cast<float>(density_.load(std::memory_order_relaxed)));
195 w.write("jitter", static_cast<float>(jitter_.load(std::memory_order_relaxed)));
196 w.write("pitch", static_cast<float>(pitchSt_.load(std::memory_order_relaxed)));
197 w.write("pitchJitter", static_cast<float>(pitchJitterSt_.load(std::memory_order_relaxed)));
198 w.write("spread", static_cast<float>(spread_.load(std::memory_order_relaxed)));
199 w.write("freeze", freeze_.load(std::memory_order_relaxed));
200 w.write("mix", static_cast<float>(mix_.load(std::memory_order_relaxed)));
201 return w.blob();
202 }
203
205 bool setState(const uint8_t* data, size_t size)
206 {
207 StateReader r(data, size);
208 if (!r.isValid() || r.processorId() != stateId("GRAN")) return false;
209 setGrainSize(static_cast<T>(r.read("grainMs", 80.0f)));
210 setDensity(static_cast<T>(r.read("density", 25.0f)));
211 setJitter(static_cast<T>(r.read("jitter", 0.3f)));
212 setPitch(static_cast<T>(r.read("pitch", 0.0f)));
213 setPitchJitter(static_cast<T>(r.read("pitchJitter", 0.0f)));
214 setSpread(static_cast<T>(r.read("spread", 0.5f)));
215 setFreeze(r.read("freeze", false));
216 setMix(static_cast<T>(r.read("mix", 1.0f)));
217 return true;
218 }
219
220 // -- Processing -------------------------------------------------------------------
221
224 void processBlock(AudioBufferView<T> buffer) noexcept
225 {
226 if (!prepared_.load(std::memory_order_relaxed)) return;
227 DenormalGuard guard;
228
229 const int nCh = std::min(buffer.getNumChannels(), numChannels_);
230 const int nS = buffer.getNumSamples();
231 if (nCh == 0 || nS == 0) return;
232
233 const bool frozen = freeze_.load(std::memory_order_relaxed);
234 // Linear per-block mix ramp with exact landing (settled: step == 0
235 // and the per-sample value reduces to the constant, bit-identically).
236 // The cloud is decorrelated from the dry signal: a hard flip clicked
237 // at 11x the steady-state sample delta.
238 const T mixTarget = mix_.load(std::memory_order_relaxed);
239 const T mixStart = currentMix_;
240 const T mixStep = (mixTarget - mixStart) / static_cast<T>(nS);
241 const double spawnPerSample =
242 static_cast<double>(density_.load(std::memory_order_relaxed)) / sampleRate_;
243
244 for (int i = 0; i < nS; ++i)
245 {
246 // Capture (continues the moment freeze releases).
247 if (!frozen)
248 {
249 ring_[0][static_cast<size_t>(writePos_)] = buffer.getChannel(0)[i];
250 ring_[1][static_cast<size_t>(writePos_)] =
251 buffer.getChannel(nCh > 1 ? 1 : 0)[i];
252 writePos_ = (writePos_ + 1) & ringMask_;
253 }
254
255 // Spawn with a fractional accumulator: exact average density.
256 spawnAcc_ += spawnPerSample;
257 while (spawnAcc_ >= 1.0)
258 {
259 spawnAcc_ -= 1.0;
260 spawnGrain();
261 }
262
263 // Sum the cloud.
264 T outL = T(0), outR = T(0);
265 for (auto& g : grains_)
266 {
267 if (!g.active) continue;
268
269 const auto idx = static_cast<int64_t>(g.pos);
270 const T frac = static_cast<T>(g.pos - static_cast<double>(idx));
271 const int i0 = static_cast<int>(idx) & ringMask_;
272 const int i1 = (i0 + 1) & ringMask_;
273
274 const double wPos = g.phase * (kWindowSize - 1);
275 const auto wIdx = static_cast<int>(wPos);
276 const T wFrac = static_cast<T>(wPos - wIdx);
277 const T w = window_[static_cast<size_t>(wIdx)]
278 + (window_[static_cast<size_t>(std::min(wIdx + 1, kWindowSize - 1))]
279 - window_[static_cast<size_t>(wIdx)]) * wFrac;
280
281 const T sL = ring_[0][static_cast<size_t>(i0)]
282 + (ring_[0][static_cast<size_t>(i1)] - ring_[0][static_cast<size_t>(i0)]) * frac;
283 const T sR = ring_[1][static_cast<size_t>(i0)]
284 + (ring_[1][static_cast<size_t>(i1)] - ring_[1][static_cast<size_t>(i0)]) * frac;
285
286 outL += sL * w * g.gainL;
287 outR += sR * w * g.gainR;
288
289 g.pos += g.rate;
290 g.phase += g.phaseInc;
291 if (g.phase >= 1.0)
292 g.active = false;
293 }
294
295 const T mixVal = mixStart + mixStep * static_cast<T>(i);
296 const T dryL = buffer.getChannel(0)[i];
297 buffer.getChannel(0)[i] = dryL + (outL - dryL) * mixVal;
298 if (nCh > 1)
299 {
300 const T dryR = buffer.getChannel(1)[i];
301 buffer.getChannel(1)[i] = dryR + (outR - dryR) * mixVal;
302 }
303 }
304 currentMix_ = mixTarget; // exact landing
305 }
306
307private:
308 static constexpr int kWindowSize = 2048;
309
310 struct Grain
311 {
312 bool active = false;
313 double pos = 0.0;
314 double rate = 1.0;
315 double phase = 0.0;
316 double phaseInc = 0.0;
317 T gainL = T(0), gainR = T(0);
318 };
319
320 [[nodiscard]] double frand() noexcept
321 {
322 rng_ = rng_ * 1664525u + 1013904223u;
323 return static_cast<double>(rng_ >> 8) / 16777216.0; // [0, 1)
324 }
325
326 void spawnGrain() noexcept
327 {
328 for (auto& g : grains_)
329 {
330 if (g.active) continue;
331
332 const double sizeMs = static_cast<double>(grainMs_.load(std::memory_order_relaxed));
333 const double lenSamples = sizeMs * 0.001 * sampleRate_;
334 const double jit = static_cast<double>(jitter_.load(std::memory_order_relaxed));
335 const double st = static_cast<double>(pitchSt_.load(std::memory_order_relaxed))
336 + (frand() * 2.0 - 1.0)
337 * static_cast<double>(pitchJitterSt_.load(std::memory_order_relaxed));
338 const double rate = std::exp2(st / 12.0);
339
340 // Start far enough back that the grain never overtakes the write
341 // head even when pitched up: lead = length * max(rate, 1) + margin.
342 const double maxSpan = lenSamples * std::max(rate, 1.0) + 64.0;
343 const double jitterSpan = jit * 0.5 * static_cast<double>(ringMask_ + 1 - maxSpan - 64.0);
344 const double back = maxSpan + frand() * std::max(jitterSpan, 0.0);
345 g.pos = static_cast<double>(writePos_) - back;
346 while (g.pos < 0.0) g.pos += static_cast<double>(ringMask_ + 1);
347
348 g.rate = rate;
349 g.phase = 0.0;
350 g.phaseInc = 1.0 / lenSamples;
351
352 const double spreadAmt = static_cast<double>(spread_.load(std::memory_order_relaxed));
353 const double pan = 0.5 + (frand() - 0.5) * spreadAmt; // [0,1]
354 const double a = pan * 1.5707963267948966; // pi/2
355 // 1/sqrt(density*overlap) keeps the cloud near unity loudness.
356 const double overlap = std::max(1.0,
357 static_cast<double>(density_.load(std::memory_order_relaxed))
358 * sizeMs * 0.001);
359 const auto norm = static_cast<T>(1.0 / std::sqrt(overlap));
360 g.gainL = static_cast<T>(std::cos(a)) * norm;
361 g.gainR = static_cast<T>(std::sin(a)) * norm;
362 g.active = true;
363 return;
364 }
365 }
366
367 // -- Members --------------------------------------------------------------------
368 double sampleRate_ = 48000.0;
369 int numChannels_ = 0;
370 std::atomic<bool> prepared_ { false };
371
372 std::array<std::vector<T>, 2> ring_;
373 int ringMask_ = 0;
374 int writePos_ = 0;
375
376 std::vector<T> window_;
377 std::array<Grain, kMaxGrains> grains_;
378 double spawnAcc_ = 0.0;
379 uint32_t rng_ = 0x9E3779B9u;
380 T currentMix_ = T(1);
381
382 std::atomic<T> grainMs_ { T(80) };
383 std::atomic<T> density_ { T(25) };
384 std::atomic<T> jitter_ { T(0.3) };
385 std::atomic<T> pitchSt_ { T(0) };
386 std::atomic<T> pitchJitterSt_ { T(0) };
387 std::atomic<T> spread_ { T(0.5) };
388 std::atomic<bool> freeze_ { false };
389 std::atomic<T> mix_ { T(1) };
390};
391
392} // 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:160
float read(const char *key, float defaultValue) const
Reads a float, or defaultValue when the key is absent.
Definition StateBlob.h:203
bool isValid() const noexcept
Definition StateBlob.h:198
uint32_t processorId() const noexcept
Definition StateBlob.h:199
Serializes key/value parameters into a versioned blob.
Definition StateBlob.h:52
std::vector< uint8_t > blob() const
Finalizes and returns the blob.
Definition StateBlob.h:104
void write(const char *key, float value)
Writes a float parameter.
Definition StateBlob.h:70
Main namespace for the DSPark framework.
constexpr uint32_t stateId(const char(&tag)[5]) noexcept
Builds a FOURCC processor id, e.g. dspark::stateId("COMP").
Definition StateBlob.h:639
Describes the audio environment for a DSP processor.
Definition AudioSpec.h:35
constexpr bool isValid() const noexcept
Checks if the specification contains valid, processable parameters.
Definition AudioSpec.h:67
int numChannels
Number of audio channels (e.g., 1 = mono, 2 = stereo).
Definition AudioSpec.h:56
double sampleRate
Sample rate in Hz.
Definition AudioSpec.h:43