DSPark 1.6.1
Header-only audio DSP framework in pure C++20 — zero dependencies
Loading...
Searching...
No Matches
Expander.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
35#include "../Core/DspMath.h"
36#include "../Core/AudioSpec.h"
37#include "../Core/AudioBuffer.h"
38#include "../Core/DenormalGuard.h"
39#include "../Core/StateBlob.h"
40
41#include <algorithm>
42#include <array>
43#include <atomic>
44#include <cmath>
45#include <cstddef>
46#include <cstdint>
47#include <numbers>
48#include <vector>
49
50namespace dspark {
51
58template <FloatType T>
60{
61public:
62 ~Expander() = default; // non-virtual: leaf class (no virtual dispatch)
63
64 enum class State { Closed, Open, Hold };
65
66 // -- Lifecycle -----------------------------------------------------------
67
73 void prepare(double sampleRate) noexcept
74 {
75 if (!std::isfinite(sampleRate) || sampleRate <= 0.0) return;
76 sampleRate_ = sampleRate;
78 // Re-derive the hold counter for the (possibly new) rate: it used to
79 // be computed only inside setHold() with the rate of that moment, so
80 // the default 50 ms hold was silently ZERO until setHold was called,
81 // and a re-prepare at a new rate kept stale hold lengths.
82 setHold(holdMs_.load(std::memory_order_relaxed));
83 reset();
84 prepared_ = true;
85 }
86
91 void prepare(const AudioSpec& spec) noexcept
92 {
93 if (!spec.isValid()) return;
94 prepare(spec.sampleRate);
95 }
96
101 void processBlock(AudioBufferView<T> buffer) noexcept
102 {
103 if (!prepared_) return;
104 DenormalGuard guard;
105 cacheParams();
106
108 processBlockInternal<true, false>(buffer, nullptr);
109 else
110 processBlockInternal<false, false>(buffer, nullptr);
111 }
112
123 void processBlock(AudioBufferView<T> buffer, AudioBufferView<T> sidechain) noexcept
124 {
125 if (!prepared_) return;
126 DenormalGuard guard;
127 cacheParams();
128
129 if (sidechain.getNumChannels() <= 0 ||
130 sidechain.getNumSamples() < buffer.getNumSamples())
131 {
133 processBlockInternal<true, false>(buffer, nullptr);
134 else
135 processBlockInternal<false, false>(buffer, nullptr);
136 return;
137 }
138
140 processBlockInternal<true, true>(buffer, &sidechain);
141 else
142 processBlockInternal<false, true>(buffer, &sidechain);
143 }
144
145 // -- Parameters ----------------------------------------------------------
146 // All setters ignore non-finite values (NaN attack/release/ratio used to
147 // poison the gain smoother permanently through max()'s first-argument
148 // NaN pass-through).
149
151 void setThreshold(T dB) noexcept
152 {
153 if (!std::isfinite(dB)) return;
154 threshold_.store(dB, std::memory_order_relaxed);
155 }
156
158 void setRatio(T ratio) noexcept
159 {
160 if (!std::isfinite(ratio)) return;
161 ratio_.store(std::max(ratio, T(1)), std::memory_order_relaxed);
162 }
163
165 void setHysteresis(T dB) noexcept
166 {
167 if (!std::isfinite(dB)) return;
168 hysteresis_.store(std::max(dB, T(0)), std::memory_order_relaxed);
169 }
170
172 void setRange(T dB) noexcept
173 {
174 if (!std::isfinite(dB)) return;
175 const T r = std::min(dB, T(0));
176 rangeDb_.store(r, std::memory_order_relaxed);
177 rangeLinear_.store(decibelsToGain(r), std::memory_order_relaxed);
178 }
179
181 void setAttack(T ms) noexcept
182 {
183 if (!std::isfinite(ms)) return;
184 attackMs_.store(std::max(ms, T(0.01)), std::memory_order_relaxed);
186 }
187
189 void setHold(T ms) noexcept
190 {
191 if (!std::isfinite(ms)) return;
192 holdMs_.store(std::max(ms, T(0)), std::memory_order_relaxed);
193 if (sampleRate_ > 0.0)
194 {
195 // Capped in double before the int cast (a huge finite hold time
196 // would otherwise be a UB float-to-int conversion).
197 const double h = std::min(
198 sampleRate_ * static_cast<double>(holdMs_.load(std::memory_order_relaxed)) / 1000.0,
199 1.0e9);
200 holdSamples_.store(static_cast<int>(h), std::memory_order_relaxed);
201 }
202 }
203
205 void setRelease(T ms) noexcept
206 {
207 if (!std::isfinite(ms)) return;
208 releaseMs_.store(std::max(ms, T(0.01)), std::memory_order_relaxed);
210 }
211
223 void setSidechainHPF(bool enabled, double cutoffHz = 80.0) noexcept
224 {
225 scHpfEnabled_.store(enabled, std::memory_order_relaxed);
226 if (std::isfinite(cutoffHz) && cutoffHz > 0.0)
227 scHpfFreqHz_.store(static_cast<T>(cutoffHz), std::memory_order_relaxed);
229 }
230
231 // -- Queries -------------------------------------------------------------
232
237 [[nodiscard]] State getGateState() const noexcept { return state_; }
238
241 [[nodiscard]] T getCurrentGainDb() const noexcept { return gainToDecibels(gateGain_); }
242
244 void reset() noexcept
245 {
247 gateGain_ = rangeLinear_.load(std::memory_order_relaxed);
248 envelope_ = T(0);
249 holdCounter_ = 0;
250
251 std::fill(scHpfState_.begin(), scHpfState_.end(), T(0));
252 std::fill(scHpfPrev_.begin(), scHpfPrev_.end(), T(0));
253 }
254
255
257 [[nodiscard]] std::vector<uint8_t> getState() const
258 {
259 StateWriter w(stateId("XPND"), 1);
260 // static_cast<float>: the blob stores float, and StateWriter's
261 // overload set is ambiguous for a double argument.
262 w.write("threshold", static_cast<float>(threshold_.load(std::memory_order_relaxed)));
263 w.write("ratio", static_cast<float>(ratio_.load(std::memory_order_relaxed)));
264 w.write("hysteresis", static_cast<float>(hysteresis_.load(std::memory_order_relaxed)));
265 w.write("attack", static_cast<float>(attackMs_.load(std::memory_order_relaxed)));
266 w.write("hold", static_cast<float>(holdMs_.load(std::memory_order_relaxed)));
267 w.write("release", static_cast<float>(releaseMs_.load(std::memory_order_relaxed)));
268 w.write("rangeDb", static_cast<float>(rangeDb_.load(std::memory_order_relaxed)));
269 w.write("scHpf", scHpfEnabled_.load(std::memory_order_relaxed));
270 w.write("scHpfFreq", static_cast<float>(scHpfFreqHz_.load(std::memory_order_relaxed)));
271 return w.blob();
272 }
273
275 bool setState(const uint8_t* data, size_t size)
276 {
277 StateReader r(data, size);
278 if (!r.isValid() || r.processorId() != stateId("XPND")) return false;
279 setThreshold(static_cast<T>(r.read("threshold", -40.0f)));
280 setRatio(static_cast<T>(r.read("ratio", 4.0f)));
281 setHysteresis(static_cast<T>(r.read("hysteresis", 4.0f)));
282 setAttack(static_cast<T>(r.read("attack", 0.5f)));
283 setHold(static_cast<T>(r.read("hold", 50.0f)));
284 setRelease(static_cast<T>(r.read("release", 100.0f)));
285 setRange(static_cast<T>(r.read("rangeDb", -80.0f)));
286 setSidechainHPF(r.read("scHpf", false),
287 static_cast<double>(r.read("scHpfFreq", 80.0f)));
288 return true;
289 }
290
291protected:
292 void cacheParams() noexcept
293 {
294 cachedThreshold_ = threshold_.load(std::memory_order_relaxed);
295 cachedRatio_ = ratio_.load(std::memory_order_relaxed);
296 cachedHysteresis_ = hysteresis_.load(std::memory_order_relaxed);
297 cachedRangeLinear_ = rangeLinear_.load(std::memory_order_relaxed);
298 cachedAttackCoeff_ = attackCoeff_.load(std::memory_order_relaxed);
299 cachedReleaseCoeff_ = releaseCoeff_.load(std::memory_order_relaxed);
300 cachedHoldSamples_ = holdSamples_.load(std::memory_order_relaxed);
301 cachedScHpfEnabled_ = scHpfEnabled_.load(std::memory_order_relaxed);
302 cachedDetAttCoeff_ = detAttCoeff_.load(std::memory_order_relaxed);
303 cachedDetRelCoeff_ = detRelCoeff_.load(std::memory_order_relaxed);
304 cachedScHpfCoeff_ = scHpfCoeff_.load(std::memory_order_relaxed);
305 cachedScHpfA0_ = scHpfA0_.load(std::memory_order_relaxed);
306 }
307
315 template <bool UseHPF, bool UseSc>
317 const AudioBufferView<T>* sidechain) noexcept
318 {
319 const int nCh = buffer.getNumChannels();
320 const int nS = buffer.getNumSamples();
321 // Detector channel count is capped by the per-channel HPF state
322 // arrays; the scalar gain below is applied to ALL audio channels.
323 const int detCh = UseSc ? std::min(sidechain->getNumChannels(), MAX_CHANNELS)
324 : std::min(nCh, MAX_CHANNELS);
325
326 for (int i = 0; i < nS; ++i)
327 {
328 T sc = T(0);
329
330 // 1. Calculate Sidechain Signal
331 for (int ch = 0; ch < detCh; ++ch)
332 {
333 T input = UseSc ? sidechain->getChannel(ch)[i]
334 : buffer.getChannel(ch)[i];
335 if constexpr (UseHPF)
336 {
337 // One-pole HPF on the raw bipolar signal per channel,
338 // normalized for unity gain at Nyquist (the unnormalized
339 // form boosted the detected HF slightly).
340 T output = cachedScHpfA0_ * (input - scHpfPrev_[ch]) + cachedScHpfCoeff_ * scHpfState_[ch];
341 scHpfPrev_[ch] = input;
342 scHpfState_[ch] = output;
343 input = output;
344 }
345 sc = std::max(sc, std::abs(input));
346 }
347
348 // 2. Smoothed-peak envelope detector (prevents LF intermodulation).
349 // Coefficients are derived from the sample rate in
350 // updateCoefficients() - fixed per-sample constants made the
351 // detector 4x faster at 192 kHz than at 44.1 kHz.
352 T envCoeff = sc > envelope_ ? cachedDetAttCoeff_ : cachedDetRelCoeff_;
353 envelope_ += envCoeff * (sc - envelope_);
354
355 // 3. Gain Calculation (Converting envelope to dB only once)
356 T levelDb = gainToDecibels(envelope_ + T(1e-9));
357
358 updateStateMachine(levelDb);
359 T gain = computeGain(levelDb);
360
361 // 4. Apply Gain
362 for (int ch = 0; ch < nCh; ++ch)
363 {
364 buffer.getChannel(ch)[i] *= gain;
365 }
366 }
367 }
368
369 void updateStateMachine(T levelDb) noexcept
370 {
371 T openThresh = cachedThreshold_;
372 T closeThresh = cachedThreshold_ - cachedHysteresis_;
373
374 switch (state_)
375 {
376 case State::Closed:
377 if (levelDb > openThresh) state_ = State::Open;
378 break;
379 case State::Open:
380 if (levelDb < closeThresh)
381 {
384 }
385 break;
386 case State::Hold:
387 if (levelDb > openThresh)
389 else if (--holdCounter_ <= 0)
391 break;
392 }
393 }
394
395 [[nodiscard]] T computeGain(T levelDb) noexcept
396 {
397 T targetGain;
398
400 {
401 targetGain = T(1);
402 }
403 else
404 {
405 T underDb = cachedThreshold_ - levelDb;
406 // Downward-expander law: the transfer slope below threshold is the
407 // ratio R (out = thr + R*(in - thr)), so the attenuation applied is
408 // underDb*(R-1). As R -> inf this approaches full closure, i.e. the
409 // gate the class documents itself as a generalization of. (The prior
410 // (1 - 1/R) was the *compressor* law and made the expander ~R too
411 // gentle -- e.g. 5 dB instead of 10 dB of extra attenuation 10 dB
412 // below threshold at R=2, and it could never approach a gate.)
413 T reductionDb = underDb * (cachedRatio_ - T(1));
414
415 // Optimization note: replace decibelsToGain with fast_exp10 if available
416 T expandedGain = decibelsToGain(-reductionDb);
417 targetGain = std::max(expandedGain, cachedRangeLinear_);
418 }
419
420 // Apply smoothing to the linear gain
421 T coeff = (targetGain > gateGain_) ? cachedAttackCoeff_ : cachedReleaseCoeff_;
422 gateGain_ += coeff * (targetGain - gateGain_);
423
424 return gateGain_;
425 }
426
427 void updateCoefficients() noexcept
428 {
429 if (!(sampleRate_ > 0.0)) return;
430 T fs = static_cast<T>(sampleRate_);
431 attackCoeff_.store(T(1) - std::exp(T(-1) / (fs * attackMs_.load(std::memory_order_relaxed) / T(1000))),
432 std::memory_order_relaxed);
433 releaseCoeff_.store(T(1) - std::exp(T(-1) / (fs * releaseMs_.load(std::memory_order_relaxed) / T(1000))),
434 std::memory_order_relaxed);
435
436 // Detector ballistics (~0.5 ms attack, ~5 ms release), sample-rate aware.
437 detAttCoeff_.store(T(1) - std::exp(T(-1) / (fs * T(0.0005))), std::memory_order_relaxed);
438 detRelCoeff_.store(T(1) - std::exp(T(-1) / (fs * T(0.005))), std::memory_order_relaxed);
439
440 // Recompute the sidechain HPF for the (possibly new) sample rate.
441 // Design clamp [1, 0.45 * fs] keeps the one-pole stable; the atomic
442 // stores make this publication safe from any thread (the coefficient
443 // used to be a plain member written cross-thread).
444 const double scFreq = std::clamp(
445 static_cast<double>(scHpfFreqHz_.load(std::memory_order_relaxed)),
446 1.0, sampleRate_ * 0.45);
447 const T c = static_cast<T>(std::exp(-std::numbers::pi * 2.0 * scFreq / sampleRate_));
448 scHpfCoeff_.store(c, std::memory_order_relaxed);
449 scHpfA0_.store((T(1) + c) / T(2), std::memory_order_relaxed);
450 }
451
452 double sampleRate_ = 48000.0;
453 bool prepared_ = false;
454
455 std::atomic<T> threshold_ { T(-40) };
456 std::atomic<T> ratio_ { T(4) };
457 std::atomic<T> hysteresis_ { T(4) };
458 std::atomic<T> attackMs_ { T(0.5) };
459 std::atomic<T> holdMs_ { T(50) };
460 std::atomic<T> releaseMs_ { T(100) };
461 std::atomic<T> rangeDb_ { T(-80) };
462 std::atomic<T> rangeLinear_ { T(0.0001) };
463
464 std::atomic<bool> scHpfEnabled_ { false };
465 std::atomic<T> scHpfFreqHz_ { T(80) };
466 std::atomic<T> scHpfCoeff_ { T(0.995) };
467 std::atomic<T> scHpfA0_ { T(0.9975) };
468
469 // Per-channel sidechain HPF state (16 = detector channel cap).
470 static constexpr int MAX_CHANNELS = 16;
471 std::array<T, MAX_CHANNELS> scHpfState_{};
472 std::array<T, MAX_CHANNELS> scHpfPrev_{};
473
474 std::atomic<T> attackCoeff_ { T(0) };
475 std::atomic<T> releaseCoeff_ { T(0) };
476 std::atomic<T> detAttCoeff_ { T(0.01) };
477 std::atomic<T> detRelCoeff_ { T(0.001) };
478 std::atomic<int> holdSamples_ { 0 };
479
480 // Cached per-block
482 T cachedRatio_ = T(4);
484 T cachedRangeLinear_ = T(0.0001);
488 T cachedDetRelCoeff_ = T(0.001);
489 T cachedScHpfCoeff_ = T(0.995);
490 T cachedScHpfA0_ = T(0.9975);
493
495 T gateGain_ = T(0);
496 T envelope_ = T(0);
498};
499
500} // namespace dspark
Non-owning view over audio channel data.
Definition AudioBuffer.h:50
RAII scope guard to disable denormalised (subnormal) floating-point numbers.
Downward expander with ratio control, hysteresis, and sidechain.
Definition Expander.h:60
void setHysteresis(T dB) noexcept
Sets the hysteresis gap in decibels to prevent chattering.
Definition Expander.h:165
bool setState(const uint8_t *data, size_t size)
Restores parameters from a blob (tolerant; rejects foreign ids).
Definition Expander.h:275
std::vector< uint8_t > getState() const
Serializes the parameter state (setup/UI threads; allocates).
Definition Expander.h:257
void setRange(T dB) noexcept
Sets the maximum gain reduction limit in decibels (<= 0).
Definition Expander.h:172
std::atomic< T > hysteresis_
Definition Expander.h:457
void updateStateMachine(T levelDb) noexcept
Definition Expander.h:369
std::atomic< T > rangeDb_
Definition Expander.h:461
std::atomic< T > scHpfA0_
Definition Expander.h:467
std::atomic< T > holdMs_
Definition Expander.h:459
void reset() noexcept
Resets all internal DSP states to prevent clicks on playback start.
Definition Expander.h:244
void setRatio(T ratio) noexcept
Sets the expansion ratio (e.g., 4.0 for 4:1).
Definition Expander.h:158
void cacheParams() noexcept
Definition Expander.h:292
bool cachedScHpfEnabled_
Definition Expander.h:492
State getGateState() const noexcept
Definition Expander.h:237
T computeGain(T levelDb) noexcept
Definition Expander.h:395
std::atomic< T > releaseCoeff_
Definition Expander.h:475
std::array< T, MAX_CHANNELS > scHpfPrev_
Definition Expander.h:472
std::array< T, MAX_CHANNELS > scHpfState_
Definition Expander.h:471
void setRelease(T ms) noexcept
Sets the release time in milliseconds.
Definition Expander.h:205
std::atomic< bool > scHpfEnabled_
Definition Expander.h:464
void setThreshold(T dB) noexcept
Sets the threshold in decibels.
Definition Expander.h:151
T getCurrentGainDb() const noexcept
Definition Expander.h:241
std::atomic< T > rangeLinear_
Definition Expander.h:462
std::atomic< T > attackCoeff_
Definition Expander.h:474
~Expander()=default
void setAttack(T ms) noexcept
Sets the attack time in milliseconds.
Definition Expander.h:181
void setHold(T ms) noexcept
Sets the hold time in milliseconds before release begins.
Definition Expander.h:189
void processBlockInternal(AudioBufferView< T > buffer, const AudioBufferView< T > *sidechain) noexcept
Internal processing loop templated on HPF/sidechain to avoid branching.
Definition Expander.h:316
std::atomic< T > releaseMs_
Definition Expander.h:460
std::atomic< T > scHpfFreqHz_
Definition Expander.h:465
std::atomic< T > detAttCoeff_
Definition Expander.h:476
std::atomic< T > detRelCoeff_
Definition Expander.h:477
void processBlock(AudioBufferView< T > buffer) noexcept
Processes an audio block in place (internal detector key).
Definition Expander.h:101
double sampleRate_
Definition Expander.h:452
std::atomic< T > scHpfCoeff_
Definition Expander.h:466
std::atomic< T > ratio_
Definition Expander.h:456
void prepare(double sampleRate) noexcept
Initializes the expander with a specific sample rate.
Definition Expander.h:73
std::atomic< T > threshold_
Definition Expander.h:455
void prepare(const AudioSpec &spec) noexcept
Initializes the expander using an AudioSpec struct.
Definition Expander.h:91
void processBlock(AudioBufferView< T > buffer, AudioBufferView< T > sidechain) noexcept
Processes an audio block in place, keyed by an external sidechain.
Definition Expander.h:123
std::atomic< int > holdSamples_
Definition Expander.h:478
static constexpr int MAX_CHANNELS
Definition Expander.h:470
std::atomic< T > attackMs_
Definition Expander.h:458
void updateCoefficients() noexcept
Definition Expander.h:427
void setSidechainHPF(bool enabled, double cutoffHz=80.0) noexcept
Enables and configures the sidechain high-pass filter.
Definition Expander.h:223
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 decibelsToGain(T dB, T minusInfinityDb=T(-100)) noexcept
Converts a value in decibels to linear gain.
Definition DspMath.h:65
T gainToDecibels(T gain, T minusInfinityDb=T(-100)) noexcept
Converts a linear gain value to decibels.
Definition DspMath.h:78
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