DSPark 1.8.0
Header-only C++20 DSP for real-time and offline audio
Loading...
Searching...
No Matches
TransientDesigner.h
1// DSPark - Professional Audio DSP Framework
2// Copyright (c) 2026 Cristian Moresi - MIT License
3
4#pragma once
5
44#include "../Core/DspMath.h"
45#include "../Core/AudioSpec.h"
46#include "../Core/AudioBuffer.h"
47#include "../Core/DenormalGuard.h"
48#include "../Core/Hilbert.h"
49#include "../Core/StateBlob.h"
50
51#include <algorithm>
52#include <array>
53#include <atomic>
54#include <cmath>
55#include <cstddef>
56#include <cstdint>
57#include <vector>
58
59namespace dspark {
60
71template <FloatType T>
73{
74public:
75 ~TransientDesigner() = default; // non-virtual: leaf class (no virtual dispatch)
76
77 // -- Lifecycle -----------------------------------------------------------
78
83 void prepare(const AudioSpec& spec) noexcept
84 {
85 if (!spec.isValid()) return;
86 prepare(spec.sampleRate);
87 }
88
96 void prepare(double sampleRate) noexcept
97 {
98 if (!std::isfinite(sampleRate) || sampleRate <= 0.0) return;
99 sampleRate_ = sampleRate;
100 updateCoefficients();
101 reset();
102 }
103
108 void processBlock(AudioBufferView<T> buffer) noexcept
109 {
110 DenormalGuard guard;
111 const int nCh = std::min(buffer.getNumChannels(), kMaxChannels);
112 const int nS = buffer.getNumSamples();
113
114 const T attAmt = attackAmount_.load(std::memory_order_relaxed);
115 const T susAmt = sustainAmount_.load(std::memory_order_relaxed);
116 const bool odr = outputDepRecovery_.load(std::memory_order_relaxed);
117
118 // Constants for VCA log-domain emulation
119 constexpr T noiseFloor = T(1e-5); // -100 dB floor avoids log(0) and denormals
120 constexpr T maxGainLog = T(2.77258); // approx +24dB max gain change
121
122 // Outer loop: channels (state stays in registers)
123 for (int ch = 0; ch < nCh; ++ch)
124 {
125 T* const channelData = buffer.getChannel(ch);
126 auto& analytic = analytic_[static_cast<size_t>(ch)];
127 T fast = envFast_[ch];
128 T slow = envSlow_[ch];
129 T lastOut = lastOutput_[ch];
130
131 // Inner loop: serial envelope recursion per sample, fed in chunks
132 // by the analytic magnitude (computed section by section).
133 for (int i = 0; i < nS; ++i)
134 {
135 if (i % kDetChunk == 0)
136 {
137 const int n = std::min(kDetChunk, nS - i);
138 for (int j = 0; j < n; ++j) detIn_[static_cast<size_t>(j)] = static_cast<double>(channelData[i + j]);
139 analytic.magnitudeBlock(detIn_.data(), detMag_.data(), n);
140 }
141 T sample = channelData[i];
142 const T envelopeLevel = std::max(
143 std::abs(sample), static_cast<T>(detMag_[static_cast<size_t>(i % kDetChunk)]));
144 T absSample = envelopeLevel + noiseFloor;
145
146 // 1. Fast envelope (Peak)
147 T fastCoeff = (absSample > fast) ? fastAttackCoeff_ : fastReleaseCoeff_;
148 fast += fastCoeff * (absSample - fast);
149
150 // 2. Slow envelope (Sustain/RMS tracker)
151 T currentSlowRelCoeff = slowReleaseCoeff_;
152
153 if (odr)
154 {
155 // Corrected ODR: Higher output = LARGER coefficient = Faster release
156 T modifier = T(1) + std::abs(lastOut) * T(2.0);
157 currentSlowRelCoeff = std::min(fastReleaseCoeff_, currentSlowRelCoeff * modifier);
158 }
159
160 T slowCoeff = (absSample > slow) ? slowAttackCoeff_ : currentSlowRelCoeff;
161 slow += slowCoeff * (absSample - slow);
162
163 // 3. Log-domain VCA computation (fastLog/fastExp: relative
164 // error < 2e-7 - far below audibility, ~3x faster than libm)
165 T diffLog = fastLog(fast) - fastLog(slow);
166
167 // Attack kicks in when fast > slow (diffLog > 0). Sustain when slow > fast (diffLog < 0)
168 T gainLog = (diffLog > T(0)) ? (diffLog * attAmt) : (-diffLog * susAmt);
169
170 gainLog = std::clamp(gainLog, -maxGainLog, maxGainLog);
171
172 T gain = fastExp(gainLog);
173
174 lastOut = sample * gain;
175 channelData[i] = lastOut;
176 }
177
178 // Save state
179 envFast_[ch] = fast;
180 envSlow_[ch] = slow;
181 lastOutput_[ch] = lastOut;
182 }
183 }
184
185 // -- Parameters ----------------------------------------------------------
186 // All setters ignore non-finite values: a NaN amount used to storm the
187 // output for as long as it was published, and under output-dependent
188 // recovery the NaN last-output silently switched the slow envelope to the
189 // fast release, leaving the envelope history diverged after recovery.
190
195 void setAttack(T amount) noexcept
196 {
197 if (!std::isfinite(amount)) return;
198 attackAmount_.store(std::clamp(amount, T(-100), T(100)) / T(100), std::memory_order_relaxed);
199 }
200
205 void setSustain(T amount) noexcept
206 {
207 if (!std::isfinite(amount)) return;
208 sustainAmount_.store(std::clamp(amount, T(-100), T(100)) / T(100), std::memory_order_relaxed);
209 }
210
215 void setOutputDepRecovery(bool enabled) noexcept
216 {
217 outputDepRecovery_.store(enabled, std::memory_order_relaxed);
218 }
219
224 void setCharacter(T amount) noexcept
225 {
226 if (!std::isfinite(amount)) return;
227 T c = std::clamp(amount, T(-1), T(1));
228 attackAmount_.store(c, std::memory_order_relaxed);
229 sustainAmount_.store(-c * T(0.5), std::memory_order_relaxed);
230 }
231
235 void reset() noexcept
236 {
237 envFast_.fill(T(1e-5)); // Init to noise floor
238 envSlow_.fill(T(1e-5));
239 lastOutput_.fill(T(0));
240 for (auto& a : analytic_) a.reset();
241 }
242
243
245 [[nodiscard]] std::vector<uint8_t> getState() const
246 {
247 StateWriter w(stateId("TDES"), 1);
248 // static_cast<float>: the blob stores float (percent units), and
249 // StateWriter's overload set is ambiguous for a double argument.
250 w.write("attack", static_cast<float>(attackAmount_.load(std::memory_order_relaxed)) * 100.0f);
251 w.write("sustain", static_cast<float>(sustainAmount_.load(std::memory_order_relaxed)) * 100.0f);
252 w.write("outputDep", outputDepRecovery_.load(std::memory_order_relaxed));
253 return w.blob();
254 }
255
257 bool setState(const uint8_t* data, size_t size)
258 {
259 StateReader r(data, size);
260 if (!r.isValid() || r.processorId() != stateId("TDES")) return false;
261 setAttack(static_cast<T>(r.read("attack", 0.0f)));
262 setSustain(static_cast<T>(r.read("sustain", 0.0f)));
263 setOutputDepRecovery(r.read("outputDep", false));
264 return true;
265 }
266
267private:
268 static constexpr int kMaxChannels = 16;
269
270 void updateCoefficients() noexcept
271 {
272 if (!(sampleRate_ > 0.0)) return;
273 T fs = static_cast<T>(sampleRate_);
274 fastAttackCoeff_ = T(1) - std::exp(T(-1) / (fs * T(0.0001)));
275 fastReleaseCoeff_ = T(1) - std::exp(T(-1) / (fs * T(0.005)));
276 slowAttackCoeff_ = T(1) - std::exp(T(-1) / (fs * T(0.020)));
277 slowReleaseCoeff_ = T(1) - std::exp(T(-1) / (fs * T(0.200)));
278 }
279
280 double sampleRate_ = 48000.0;
281
282 // Atomic parameters (Lock-free thread safety)
283 std::atomic<T> attackAmount_ { T(0) };
284 std::atomic<T> sustainAmount_ { T(0) };
285 std::atomic<bool> outputDepRecovery_ { false };
286
287 // Envelope coefficients (written by prepare/updateCoefficients on the
288 // setup thread, read by the audio thread)
289 T fastAttackCoeff_ = T(0), fastReleaseCoeff_ = T(0);
290 T slowAttackCoeff_ = T(0), slowReleaseCoeff_ = T(0);
291
292 // Per-channel state (scalar recursion; no SIMD kernel touches these)
293 std::array<T, kMaxChannels> envFast_ {};
294 std::array<T, kMaxChannels> envSlow_ {};
295 std::array<T, kMaxChannels> lastOutput_ {};
296 std::array<HilbertIIR<double>, kMaxChannels> analytic_ {};
297 static constexpr int kDetChunk = 64;
298 std::array<double, kDetChunk> detIn_ {};
299 std::array<double, kDetChunk> detMag_ {};
300};
301
302} // namespace dspark
Non-owning view over audio channel data.
Definition AudioBuffer.h:50
RAII scope guard to disable denormalised (subnormal) floating-point numbers.
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
Zero-allocation, thread-safe transient shaper.
void reset() noexcept
Clears all internal buffers and state. Must be lock-free.
void setSustain(T amount) noexcept
Sets sustain (body) emphasis.
std::vector< uint8_t > getState() const
Serializes the parameter state (setup/UI threads; allocates).
void setOutputDepRecovery(bool enabled) noexcept
Enables output-dependent recovery (ODR).
void prepare(double sampleRate) noexcept
Prepares the processor with a specific sample rate.
void processBlock(AudioBufferView< T > buffer) noexcept
Processes an audio buffer in-place.
bool setState(const uint8_t *data, size_t size)
Restores parameters from a blob (tolerant; rejects foreign ids).
void prepare(const AudioSpec &spec) noexcept
Prepares the processor with the current audio specification.
void setAttack(T amount) noexcept
Sets attack (transient) emphasis.
void setCharacter(T amount) noexcept
Sets character as a single macro-knob (overwrites attack AND sustain).
Main namespace for the DSPark framework.
T fastLog(T x) noexcept
Fast natural logarithm approximation.
Definition DspMath.h:293
constexpr uint32_t stateId(const char(&tag)[5]) noexcept
Builds a FOURCC processor id, e.g. dspark::stateId("COMP").
Definition StateBlob.h:651
T fastExp(T x) noexcept
Fast approximation of e^x via std::exp2 (~2x faster than std::exp on MSVC).
Definition DspMath.h:202
Describes the audio environment for a DSP processor.
Definition AudioSpec.h:37