DSPark 1.6.1
Header-only audio DSP framework in pure C++20 — zero dependencies
Loading...
Searching...
No Matches
Chorus.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
34#include "../Core/RingBuffer.h"
35#include "../Core/Oscillator.h"
36#include "../Core/DryWetMixer.h"
37#include "../Core/DspMath.h"
38#include "../Core/AudioSpec.h"
39#include "../Core/AudioBuffer.h"
40#include "../Core/StateBlob.h"
41
42#include <algorithm>
43#include <array>
44#include <atomic>
45#include <cmath>
46#include <cstddef>
47#include <cstdint>
48#include <vector>
49
50namespace dspark {
51
58template <FloatType T>
59class Chorus
60{
61public:
62 ~Chorus() = default; // non-virtual: leaf class, no virtual dispatch (framework policy)
63
64 static constexpr int kMaxVoices = 4;
65 static constexpr int kMaxChannels = 16;
66
67 // -- Lifecycle --------------------------------------------------------------
68
77 void prepare(const AudioSpec& spec)
78 {
79 if (!spec.isValid()) return; // release-safe: keep previous state
80
81 spec_ = spec;
82 mixer_.prepare(spec);
83
84 // Max delay: 30ms center + 20ms depth = 50ms total safety margin.
85 // The +4 pad keeps the interpolator's read window (intDelay + 2)
86 // strictly inside the ring even when the requested size lands exactly
87 // on a power of two (RingBuffer rounds its capacity up to one).
88 maxDelaySamples_ = static_cast<int>(spec.sampleRate * 0.05) + 1;
89
90 for (int ch = 0; ch < spec.numChannels && ch < kMaxChannels; ++ch)
92
93 for (int ch = 0; ch < kMaxChannels; ++ch)
94 {
95 for (int v = 0; v < kMaxVoices; ++v)
96 {
97 lfos_[ch][v].prepare(spec.sampleRate);
98 lfos_[ch][v].setWaveform(lfoWaveform_.load(std::memory_order_relaxed));
99 }
100 }
101
102 // Initialize smoothing states
103 smoothedCenterSamples_ = centerDelayMs_.load(std::memory_order_relaxed) * static_cast<T>(spec.sampleRate) / T(1000);
104 smoothedDepthSamples_ = depthMs_.load(std::memory_order_relaxed) * static_cast<T>(spec.sampleRate) / T(1000);
105
107 reset();
108 }
109
117 void processBlock(AudioBufferView<T> buffer) noexcept
118 {
119 // Clamp to the PREPARED channel count: delay lines beyond
120 // spec_.numChannels were never allocated and would read as silence.
121 const int nCh = std::min({ buffer.getNumChannels(), spec_.numChannels, kMaxChannels });
122 const int nS = buffer.getNumSamples();
123
124 // Front-door non-finite guard (M-006 C1): a single NaN/Inf input sample
125 // would recirculate through the recursive fbState_ path (fastTanh passes
126 // NaN) and mute the voice forever. Replace non-finite input with 0 before
127 // it reaches the dry snapshot or any delay/feedback state. No-op on finite
128 // input, so conformance metrics stay byte-identical.
129 for (int ch = 0; ch < buffer.getNumChannels(); ++ch)
130 {
131 T* d = buffer.getChannel(ch);
132 for (int i = 0; i < nS; ++i)
133 if (!std::isfinite(d[i])) d[i] = T(0);
134 }
135
136 // 1. Read atomics once per block
137 T targetRate = rate_.load(std::memory_order_relaxed);
138 T targetDepth = depthMs_.load(std::memory_order_relaxed);
139 T targetCenter = centerDelayMs_.load(std::memory_order_relaxed);
140 T fbVal = feedback_.load(std::memory_order_relaxed);
141 T mixVal = mix_.load(std::memory_order_relaxed);
142 int nVoices = numVoices_.load(std::memory_order_relaxed);
143 bool autoD = autoDepth_.load(std::memory_order_relaxed);
144
145 mixer_.pushDry(buffer);
146
147 // Apply deferred LFO config on the AUDIO thread (setters only publish):
148 // voices change -> recompute phases; waveform change -> reapply.
149 if (lfoPhaseDirty_.exchange(false, std::memory_order_acquire))
151 if (waveformDirty_.exchange(false, std::memory_order_acquire))
152 {
153 const auto wf = lfoWaveform_.load(std::memory_order_relaxed);
154 for (int ch = 0; ch < kMaxChannels; ++ch)
155 for (int v = 0; v < kMaxVoices; ++v)
156 lfos_[ch][v].setWaveform(wf);
157 }
158
159 // Check if stereo spread changed (requires phase recalculation)
160 T currentSpread = stereoSpread_.load(std::memory_order_relaxed);
161 if (std::abs(currentSpread - lastSpread_) > T(0.001))
162 {
163 lastSpread_ = currentSpread;
165 }
166
167 // Target calculations
168 T targetCenterSamples = targetCenter * static_cast<T>(spec_.sampleRate) / T(1000);
169 T targetDepthSamples = targetDepth * static_cast<T>(spec_.sampleRate) / T(1000);
170
171 if (autoD && targetRate > T(0.01))
172 targetDepthSamples /= std::sqrt(targetRate);
173
174 // Keep depth <= center - 2 samples: a deeper sweep would flatten
175 // against the 1-sample read floor, squaring off the modulation shape.
176 targetDepthSamples = std::min(targetDepthSamples,
177 std::max(T(0), targetCenterSamples - T(2)));
178
179 // Update LFO frequencies
180 for (int ch = 0; ch < nCh; ++ch)
181 for (int v = 0; v < nVoices; ++v)
182 lfos_[ch][v].setFrequency(targetRate);
183
184 T voiceNorm = T(1) / std::sqrt(static_cast<T>(nVoices));
185
186 // One-pole smoothing coefficient, sample-rate invariant: 240/fs gives
187 // the same ~4.2 ms time constant at any rate (and is bit-identical to
188 // the historical 0.005 at 48 kHz; the old fixed value made parameter
189 // glides twice as fast at 96 kHz as at 48 kHz).
190 T smoothCoeff = std::min(T(1), T(240) / static_cast<T>(spec_.sampleRate));
191
192 // 2. Channel outer, sample inner (cache-friendly per-channel state)
193 for (int ch = 0; ch < nCh; ++ch)
194 {
195 auto* channelData = buffer.getChannel(ch);
196 auto& ring = delayLines_[ch];
197
198 // Local channel copies of the smoothing state (kept in registers)
199 T centerSamples = smoothedCenterSamples_;
200 T depthSamples = smoothedDepthSamples_;
201
202 for (int i = 0; i < nS; ++i)
203 {
204 // Smooth parameters per sample
205 centerSamples += (targetCenterSamples - centerSamples) * smoothCoeff;
206 depthSamples += (targetDepthSamples - depthSamples) * smoothCoeff;
207
208 T input = channelData[i];
209
210 // Saturating analog-style feedback to prevent harsh digital
211 // clipping. fastTanh is internally clamped; at typical levels
212 // |argument| < 1, where its error is < 0.05%.
213 T feedbackSig = fastTanh(fbState_[ch] * fbVal);
214 ring.push(input + feedbackSig);
215
216 T wetRaw = T(0);
217
218 // Voice accumulation
219 for (int v = 0; v < nVoices; ++v)
220 {
221 T lfoVal = lfos_[ch][v].getNextSample();
222 T rawDelay = centerSamples + lfoVal * depthSamples;
223
224 // Safety clamp against ring buffer limits
225 T delay = std::clamp(rawDelay, T(1), static_cast<T>(maxDelaySamples_ - 2));
226
227 wetRaw += ring.readInterpolated(delay);
228 }
229
230 fbState_[ch] = wetRaw / static_cast<T>(nVoices);
231 channelData[i] = wetRaw * voiceNorm;
232 }
233
234 // Save state back for the next block (only done by channel 0 to keep channels synced)
235 if (ch == 0)
236 {
237 smoothedCenterSamples_ = centerSamples;
238 smoothedDepthSamples_ = depthSamples;
239 }
240 }
241
242 mixer_.mixWet(buffer, mixVal);
243 }
244
248 void reset() noexcept
249 {
250 for (int ch = 0; ch < kMaxChannels; ++ch)
251 {
252 delayLines_[ch].reset();
253 fbState_[ch] = T(0);
254 }
255 mixer_.reset();
257 }
258
259 // -- Parameters -------------------------------------------------------------
260
265 void setRate(T hz) noexcept
266 {
267 if (!std::isfinite(hz)) return;
268 rate_.store(std::clamp(hz, T(0.01), T(20)), std::memory_order_relaxed);
269 }
270
275 void setDepthMs(T ms) noexcept
276 {
277 if (!std::isfinite(ms)) return;
278 depthMs_.store(std::clamp(ms, T(0), T(20)), std::memory_order_relaxed);
279 }
280
286 void setMix(T dryWet) noexcept
287 {
288 if (!std::isfinite(dryWet)) return;
289 mix_.store(std::clamp(dryWet, T(0), T(1)), std::memory_order_relaxed);
290 }
291
296 void setVoices(int count) noexcept
297 {
298 numVoices_.store(std::clamp(count, 1, kMaxVoices), std::memory_order_relaxed);
299 lfoPhaseDirty_.store(true, std::memory_order_release); // recomputed on audio thread
300 }
301
307 void setFeedback(T amount) noexcept
308 {
309 if (!std::isfinite(amount)) return;
310 feedback_.store(std::clamp(amount, T(-0.99), T(0.99)), std::memory_order_relaxed);
311 }
312
317 void setCenterDelay(T ms) noexcept
318 {
319 if (!std::isfinite(ms)) return;
320 centerDelayMs_.store(std::clamp(ms, T(0.1), T(30)), std::memory_order_relaxed);
321 }
322
327 void setStereoSpread(T amount) noexcept
328 {
329 if (!std::isfinite(amount)) return;
330 stereoSpread_.store(std::clamp(amount, T(0), T(1)), std::memory_order_relaxed);
331 }
332
337 void setAutoDepth(bool enabled) noexcept { autoDepth_.store(enabled, std::memory_order_relaxed); }
338
345 void setModWaveform(typename Oscillator<T>::Waveform wf) noexcept
346 {
347 // Publish only; the audio thread reapplies it (the oscillators are not
348 // thread-safe, so we must not write their state from the control thread).
349 const int w = std::clamp(static_cast<int>(wf), 0,
350 static_cast<int>(Oscillator<T>::Waveform::Triangle));
351 lfoWaveform_.store(static_cast<typename Oscillator<T>::Waveform>(w),
352 std::memory_order_relaxed);
353 waveformDirty_.store(true, std::memory_order_release);
354 }
355
356
358 [[nodiscard]] std::vector<uint8_t> getState() const
359 {
360 StateWriter w(stateId("CHOR"), 1);
361 w.write("rate", rate_.load(std::memory_order_relaxed));
362 w.write("depthMs", depthMs_.load(std::memory_order_relaxed));
363 w.write("mix", mix_.load(std::memory_order_relaxed));
364 w.write("feedback", feedback_.load(std::memory_order_relaxed));
365 w.write("centerDelay", centerDelayMs_.load(std::memory_order_relaxed));
366 w.write("spread", stereoSpread_.load(std::memory_order_relaxed));
367 w.write("voices", numVoices_.load(std::memory_order_relaxed));
368 w.write("autoDepth", autoDepth_.load(std::memory_order_relaxed));
369 w.write("waveform",
370 static_cast<int32_t>(lfoWaveform_.load(std::memory_order_relaxed)));
371 return w.blob();
372 }
373
375 bool setState(const uint8_t* data, size_t size)
376 {
377 StateReader r(data, size);
378 if (!r.isValid() || r.processorId() != stateId("CHOR")) return false;
379 setRate(static_cast<T>(r.read("rate", 1.0f)));
380 setDepthMs(static_cast<T>(r.read("depthMs", 3.5f)));
381 setMix(static_cast<T>(r.read("mix", 0.5f)));
382 setFeedback(static_cast<T>(r.read("feedback", 0.0f)));
383 setCenterDelay(static_cast<T>(r.read("centerDelay", 7.0f)));
384 setStereoSpread(static_cast<T>(r.read("spread", 0.5f)));
385 setVoices(r.read("voices", 2));
386 setAutoDepth(r.read("autoDepth", false));
387 setModWaveform(static_cast<typename Oscillator<T>::Waveform>(
388 r.read("waveform", 0)));
389 return true;
390 }
391
392protected:
393
397 void updateLfoPhases() noexcept
398 {
399 int nv = numVoices_.load(std::memory_order_relaxed);
400 T spreadVal = stereoSpread_.load(std::memory_order_relaxed);
401
402 for (int ch = 0; ch < kMaxChannels; ++ch)
403 {
404 // Calculate base channel offset
405 T chOffset = (ch > 0 && spreadVal > T(0))
406 ? static_cast<T>(ch) * spreadVal / (T(2) * static_cast<T>(kMaxChannels))
407 : T(0);
408
409 for (int v = 0; v < kMaxVoices; ++v)
410 {
411 T basePhase = static_cast<T>(v) / static_cast<T>(nv);
412 T finalPhase = basePhase + chOffset;
413 finalPhase -= std::floor(finalPhase); // Wrap to [0, 1)
414 lfos_[ch][v].setPhase(finalPhase);
415 }
416 }
417 }
418
421
422 // Atomic parameters (updated via UI thread)
423 std::atomic<T> rate_ { T(1) };
424 std::atomic<T> depthMs_ { T(3.5) };
425 std::atomic<T> mix_ { T(0.5) };
426 std::atomic<T> feedback_ { T(0) };
427 std::atomic<T> centerDelayMs_ { T(7) };
428 std::atomic<T> stereoSpread_ { T(0.5) };
429 std::atomic<int> numVoices_ { 2 };
430 std::atomic<bool> autoDepth_ { false };
431
432 T lastSpread_ { T(0.5) };
433 std::atomic<typename Oscillator<T>::Waveform> lfoWaveform_ { Oscillator<T>::Waveform::Sine };
434 std::atomic<bool> lfoPhaseDirty_ { false }; // voices changed -> recompute phases (audio thread)
435 std::atomic<bool> waveformDirty_ { false }; // waveform changed -> reapply (audio thread)
436
437 // Smoothed internal state variables (Audio thread only)
440
441 // Processing arrays
442 std::array<RingBuffer<T>, kMaxChannels> delayLines_ {};
443 // 2D Array: Avoids dynamic phase calculations in the hot loop
444 std::array<std::array<Oscillator<T>, kMaxVoices>, kMaxChannels> lfos_ {};
445 std::array<T, kMaxChannels> fbState_ {};
446
448};
449
450} // namespace dspark
Non-owning view over audio channel data.
Definition AudioBuffer.h:50
Multi-voice chorus/flanger with true stereo spread and smooth parameter handling.
Definition Chorus.h:60
~Chorus()=default
void setModWaveform(typename Oscillator< T >::Waveform wf) noexcept
Sets the oscillator waveform for all voices.
Definition Chorus.h:345
std::atomic< T > rate_
Definition Chorus.h:423
T smoothedCenterSamples_
Definition Chorus.h:438
std::vector< uint8_t > getState() const
Serializes the parameter state (setup/UI threads; allocates).
Definition Chorus.h:358
std::atomic< bool > autoDepth_
Definition Chorus.h:430
void setMix(T dryWet) noexcept
Sets the wet/dry mix ratio.
Definition Chorus.h:286
std::atomic< typename Oscillator< T >::Waveform > lfoWaveform_
Definition Chorus.h:433
std::atomic< int > numVoices_
Definition Chorus.h:429
std::atomic< T > depthMs_
Definition Chorus.h:424
std::atomic< T > mix_
Definition Chorus.h:425
void processBlock(AudioBufferView< T > buffer) noexcept
Processes an audio block in-place.
Definition Chorus.h:117
int maxDelaySamples_
Definition Chorus.h:420
std::array< RingBuffer< T >, kMaxChannels > delayLines_
Definition Chorus.h:442
void setStereoSpread(T amount) noexcept
Sets the stereo spread amount.
Definition Chorus.h:327
std::atomic< bool > waveformDirty_
Definition Chorus.h:435
AudioSpec spec_
Definition Chorus.h:419
void reset() noexcept
Resets delay lines and LFO phases.
Definition Chorus.h:248
void setVoices(int count) noexcept
Sets the number of active voices per channel.
Definition Chorus.h:296
void setCenterDelay(T ms) noexcept
Sets the base delay time.
Definition Chorus.h:317
std::array< T, kMaxChannels > fbState_
Definition Chorus.h:445
std::array< std::array< Oscillator< T >, kMaxVoices >, kMaxChannels > lfos_
Definition Chorus.h:444
static constexpr int kMaxVoices
Definition Chorus.h:64
std::atomic< T > stereoSpread_
Definition Chorus.h:428
DryWetMixer< T > mixer_
Definition Chorus.h:447
void setFeedback(T amount) noexcept
Sets the feedback gain.
Definition Chorus.h:307
void prepare(const AudioSpec &spec)
Prepares the chorus for processing, allocating delay lines.
Definition Chorus.h:77
std::atomic< T > centerDelayMs_
Definition Chorus.h:427
void setDepthMs(T ms) noexcept
Sets the LFO modulation depth in milliseconds.
Definition Chorus.h:275
std::atomic< bool > lfoPhaseDirty_
Definition Chorus.h:434
static constexpr int kMaxChannels
Definition Chorus.h:65
void setRate(T hz) noexcept
Sets the LFO modulation rate.
Definition Chorus.h:265
void updateLfoPhases() noexcept
Recalculates LFO phases for all channels and voices based on stereo spread.
Definition Chorus.h:397
bool setState(const uint8_t *data, size_t size)
Restores parameters from a blob (tolerant; rejects foreign ids).
Definition Chorus.h:375
void setAutoDepth(bool enabled) noexcept
Couples depth to rate automatically to prevent pitch artifacts at high speeds.
Definition Chorus.h:337
T smoothedDepthSamples_
Definition Chorus.h:439
std::atomic< T > feedback_
Definition Chorus.h:426
Pre-allocated, SIMD-friendly dry/wet blender for real-time audio.
Definition DryWetMixer.h:72
Band-limited oscillator featuring PolyBLEP anti-aliasing and analog-modeled integration.
Definition Oscillator.h:64
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.
T fastTanh(T x) noexcept
Fast tanh approximation using Pade rational function.
Definition DspMath.h:126
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