DSPark 1.8.0
Header-only C++20 DSP for real-time and offline audio
Loading...
Searching...
No Matches
DryWetMixer.h
1// DSPark -- Professional Audio DSP Framework
2// Copyright (c) 2026 Cristian Moresi -- MIT License
3
4#pragma once
5
48#include "AudioBuffer.h"
49#include "AudioSpec.h"
50
51#include <algorithm>
52#include <atomic>
53#include <cmath>
54
55// DSPARK_RESTRICT is normally provided by SimdOps.h (pulled in via AudioBuffer.h).
56// Guard against redefinition so this header stays self-contained if included alone.
57#ifndef DSPARK_RESTRICT
58 #if defined(__clang__) || defined(__GNUC__)
59 #define DSPARK_RESTRICT __restrict__
60 #elif defined(_MSC_VER)
61 #define DSPARK_RESTRICT __restrict
62 #else
63 #define DSPARK_RESTRICT
64 #endif
65#endif
66
67namespace dspark {
68
76template <typename T, int MaxChannels = 16>
78{
79public:
81 enum class MixRule {
83 Linear,
86 };
87
88 DryWetMixer() noexcept = default;
89
90 // The atomic mix rule makes the implicit move operations vanish. Provide
91 // manual moves so the mixer keeps working inside movable owners; moves
92 // are single-threaded setup-time relocations, so plain relaxed transfers
93 // of the atomic are sufficient. Copying is deleted by the owned buffers.
94 DryWetMixer(DryWetMixer&& other) noexcept
95 : dryBuffer_(std::move(other.dryBuffer_)),
96 capturedSamples_(other.capturedSamples_),
97 delayHist_(std::move(other.delayHist_)),
98 latencySamples_(other.latencySamples_),
99 histPos_(other.histPos_),
100 mixRule_(other.mixRule_.load(std::memory_order_relaxed)),
101 currentMix_(other.currentMix_),
102 maxMixStep_(other.maxMixStep_)
103 {}
104
106 {
107 if (this == &other) return *this;
108 dryBuffer_ = std::move(other.dryBuffer_);
109 capturedSamples_ = other.capturedSamples_;
110 delayHist_ = std::move(other.delayHist_);
111 latencySamples_ = other.latencySamples_;
112 histPos_ = other.histPos_;
113 mixRule_.store(other.mixRule_.load(std::memory_order_relaxed),
114 std::memory_order_relaxed);
115 currentMix_ = other.currentMix_;
116 maxMixStep_ = other.maxMixStep_;
117 return *this;
118 }
119
120 DryWetMixer(const DryWetMixer&) = delete;
122
127 void prepare(const AudioSpec& spec)
128 {
129 dryBuffer_.resize(spec.numChannels, spec.maxBlockSize);
130
131 // Mix changes ramp over at least kMinRampSeconds for a full-scale
132 // move, whatever the host block size (an invalid rate keeps the
133 // one-block ramp).
134 maxMixStep_ = spec.isValid()
135 ? static_cast<T>(1.0 / std::max(1.0, spec.sampleRate * kMinRampSeconds))
136 : T(1);
137
138 // If latency compensation was configured before prepare(), the delay
139 // history was sized with 0 channels; rebuild it now that the channel
140 // count is known (otherwise pushDry would index a 0-channel buffer).
141 if (latencySamples_ > 0)
142 {
143 delayHist_.resize(spec.numChannels, latencySamples_);
144 histPos_ = 0;
145 }
146
147 reset();
148 }
149
151 void reset() noexcept
152 {
153 dryBuffer_.clear();
154 if (delayHist_.getNumSamples() > 0) delayHist_.clear();
155 histPos_ = 0;
156 capturedSamples_ = 0; // No valid dry snapshot until the next pushDry().
157 currentMix_ = T(-1); // Forces immediate jump on first use
158 }
159
168 void setMixRule(MixRule rule) noexcept
169 {
170 mixRule_.store(rule, std::memory_order_relaxed);
171 }
172
185 void setLatencyCompensation(int samples)
186 {
187 samples = std::max(0, samples);
188 if (samples == latencySamples_) return;
189 latencySamples_ = samples;
190 delayHist_.resize(dryBuffer_.getNumChannels(), samples);
191 histPos_ = 0;
192 }
193
195 [[nodiscard]] int getLatencyCompensation() const noexcept { return latencySamples_; }
196
207 void pushDry(const AudioBufferView<const T>& input) noexcept
208 {
209 if (dryBuffer_.getNumSamples() == 0) return; // Prevent ops if unprepared
210
211 const int chCount = std::min(input.getNumChannels(), dryBuffer_.getNumChannels());
212 const int nSamples = std::min(input.getNumSamples(), dryBuffer_.getNumSamples());
213
214 if (latencySamples_ <= 0)
215 {
216 for (int ch = 0; ch < chCount; ++ch)
217 {
218 const T* DSPARK_RESTRICT src = input.getChannel(ch);
219 T* DSPARK_RESTRICT dst = dryBuffer_.getChannel(ch);
220 std::copy_n(src, nSamples, dst);
221 }
222 }
223 else
224 {
225 // Latency-compensated capture: route the dry through a per-channel
226 // circular delay of exactly latencySamples_ so it stays time-aligned
227 // with a wet path that incurs the same delay.
228 const int D = latencySamples_;
229 for (int ch = 0; ch < chCount; ++ch)
230 {
231 const T* DSPARK_RESTRICT src = input.getChannel(ch);
232 T* DSPARK_RESTRICT dst = dryBuffer_.getChannel(ch);
233 T* DSPARK_RESTRICT hist = delayHist_.getChannel(ch);
234 int pos = histPos_;
235 for (int i = 0; i < nSamples; ++i)
236 {
237 dst[i] = hist[pos]; // sample written D steps ago
238 hist[pos] = src[i];
239 if (++pos == D) pos = 0;
240 }
241 }
242 histPos_ = (histPos_ + nSamples) % D;
243 }
244
245 capturedSamples_ = nSamples;
246 }
247
266 void mixWet(AudioBufferView<T> wetBuffer, T targetMix) noexcept
267 {
268 // Handle invalid floating point inputs (NaN) safely
269 if (std::isnan(targetMix)) targetMix = T(0);
270 targetMix = std::clamp(targetMix, T(0), T(1));
271
272 const int chCount = std::min(wetBuffer.getNumChannels(), dryBuffer_.getNumChannels());
273 const int nSamples = std::min(wetBuffer.getNumSamples(), capturedSamples_);
274
275 if (nSamples == 0 || chCount == 0) return;
276
277 // Initialize smoothing state on first run
278 if (currentMix_ < T(0)) currentMix_ = targetMix;
279
280 const bool needsSmoothing = std::abs(currentMix_ - targetMix) > T(1e-5);
281 T mixStep = T(0);
282 bool rateLimited = false;
283 if (needsSmoothing)
284 {
285 mixStep = (targetMix - currentMix_) / T(nSamples);
286 if (std::abs(mixStep) > maxMixStep_)
287 {
288 mixStep = mixStep > T(0) ? maxMixStep_ : -maxMixStep_;
289 rateLimited = true;
290 }
291 }
292 const MixRule rule = mixRule_.load(std::memory_order_relaxed);
293
294 // Every channel ramps from the same smoothed origin. The ramps below
295 // are indexed (mix0 + mixStep * i) rather than accumulated: one
296 // rounding per sample instead of a drift that grows with the block
297 // (a serial accumulator can overshoot the [0, 1] range on long
298 // blocks), and no loop-carried dependency in the way of the
299 // auto-vectoriser. A settled mix within the 1e-5 dead band renders
300 // at the target itself, so no residual offset is ever held.
301 const T mix0 = needsSmoothing ? currentMix_ : targetMix;
302
303 for (int ch = 0; ch < chCount; ++ch)
304 {
305 T* DSPARK_RESTRICT wetData = wetBuffer.getChannel(ch);
306 const T* DSPARK_RESTRICT dryData = dryBuffer_.getChannel(ch);
307
308 if (rule == MixRule::EqualPower)
309 {
310 if (!needsSmoothing)
311 {
312 // Static mix: hoist both square roots out of the loop;
313 // the body becomes a single FMA per sample (vectorizable).
314 const T w = std::sqrt(mix0);
315 const T d = std::sqrt(T(1) - mix0);
316 for (int i = 0; i < nSamples; ++i)
317 wetData[i] = dryData[i] * d + wetData[i] * w;
318 }
319 else
320 {
321 for (int i = 0; i < nSamples; ++i)
322 {
323 // Exact constant-power law: w^2 + d^2 = 1. The max()
324 // guards keep float rounding at the ramp ends from
325 // pushing a radicand a few ulp negative, where sqrt()
326 // would inject NaN into the output.
327 const T m = mix0 + mixStep * T(i);
328 const T w = std::sqrt(std::max(T(0), m));
329 const T d = std::sqrt(std::max(T(0), T(1) - m));
330 wetData[i] = dryData[i] * d + wetData[i] * w;
331 }
332 }
333 }
334 else // MixRule::Linear
335 {
336 if (!needsSmoothing)
337 {
338 const T w = mix0;
339 const T d = T(1) - w;
340 for (int i = 0; i < nSamples; ++i)
341 wetData[i] = dryData[i] * d + wetData[i] * w;
342 }
343 else
344 {
345 for (int i = 0; i < nSamples; ++i)
346 {
347 const T w = mix0 + mixStep * T(i);
348 const T d = T(1) - w;
349 wetData[i] = dryData[i] * d + wetData[i] * w;
350 }
351 }
352 }
353 }
354
355 // Update the persistent state after the block is processed: a
356 // rate-limited ramp resumes from where this block left it.
357 currentMix_ = rateLimited
358 ? std::clamp(mix0 + mixStep * T(nSamples), T(0), T(1))
359 : targetMix;
360 }
361
367 [[nodiscard]] const T* getDryChannel(int ch) const noexcept
368 {
369 if (ch < 0 || ch >= dryBuffer_.getNumChannels()) return nullptr;
370 return dryBuffer_.getChannel(ch);
371 }
372
374 [[nodiscard]] int getDryNumChannels() const noexcept { return dryBuffer_.getNumChannels(); }
375
377 [[nodiscard]] int getDryCapturedSamples() const noexcept { return capturedSamples_; }
378
379private:
381 int capturedSamples_ = 0;
382
383 // Optional dry-path latency compensation (circular delay). Disabled (0) by default.
385 int latencySamples_ = 0;
386 int histPos_ = 0;
387
388 std::atomic<MixRule> mixRule_ { MixRule::Linear };
389 T currentMix_ = T(-1); // Internal state for parameter smoothing
390 T maxMixStep_ = T(1); // Per-sample mix slew limit (set in prepare())
391
392 static constexpr double kMinRampSeconds = 0.02;
393};
394
395} // namespace dspark
Non-owning view over audio channel data.
Definition AudioBuffer.h:50
Owning audio buffer with contiguous, 32-byte aligned storage.
int getNumChannels() const noexcept
Returns the number of active channels.
T * getChannel(int ch) noexcept
Returns a pointer to the sample data.
int getNumSamples() const noexcept
Returns the number of samples per channel.
void resize(int numChannels, int numSamples)
Allocates the buffer for the given dimensions.
void clear() noexcept
Clears all channels, including SIMD alignment padding. Ensures any SIMD over-read will consume pure z...
Pre-allocated, SIMD-friendly dry/wet blender for real-time audio.
Definition DryWetMixer.h:78
void setLatencyCompensation(int samples)
Delays the captured dry signal to compensate for an effect's internal latency (e.g....
int getLatencyCompensation() const noexcept
Returns the configured dry-path latency compensation in samples.
MixRule
Defines the mathematical curve used for mixing.
Definition DryWetMixer.h:81
int getDryCapturedSamples() const noexcept
Returns the number of samples valid from the last pushDry() call.
void mixWet(AudioBufferView< T > wetBuffer, T targetMix) noexcept
Blends the stored dry signal with the current (wet) buffer in-place.
void reset() noexcept
Resets the internal buffer and smoothing states to zero.
int getDryNumChannels() const noexcept
Returns the internal capacity of channels in the dry buffer.
DryWetMixer() noexcept=default
DryWetMixer & operator=(DryWetMixer &&other) noexcept
void pushDry(const AudioBufferView< const T > &input) noexcept
Captures a snapshot of the dry (unprocessed) signal.
void setMixRule(MixRule rule) noexcept
Sets the mathematical curve to use during the mix phase.
void prepare(const AudioSpec &spec)
Allocates the internal dry buffer for the given audio spec.
DryWetMixer & operator=(const DryWetMixer &)=delete
const T * getDryChannel(int ch) const noexcept
Retrieves a read-only pointer to the captured dry channel data.
DryWetMixer(const DryWetMixer &)=delete
Main namespace for the DSPark framework.
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
int maxBlockSize
Maximum number of samples per processing block.
Definition AudioSpec.h:53
double sampleRate
Sample rate in Hz.
Definition AudioSpec.h:45