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