DSPark 1.6.1
Header-only audio DSP framework in pure C++20 — zero dependencies
Loading...
Searching...
No Matches
Vibrato.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
31#include "../Core/AudioBuffer.h"
32#include "../Core/AudioSpec.h"
33#include "../Core/DspMath.h"
34#include "../Core/Phasor.h"
35#include "../Core/RingBuffer.h"
36#include "../Core/StateBlob.h"
37
38#include <algorithm>
39#include <atomic>
40#include <cmath>
41#include <cstddef>
42#include <cstdint>
43#include <numbers>
44#include <vector>
45
46namespace dspark {
47
62template <FloatType T>
64{
65public:
81 void prepare(const AudioSpec& spec)
82 {
83 if (!spec.isValid()) return; // release-safe: keep previous state
84
85 sampleRate_ = spec.sampleRate;
86 numChannels_ = spec.numChannels;
87
88 // Worst-case LFO deviation in samples: depth * ln2 / 12 converts
89 // semitones to a log-frequency ratio, and the peak pitch excursion of
90 // a sinusoidal delay d(n) = centre + D * sin(2*pi*f*n/fs) is
91 // D * 2*pi*f/fs, so D = depth * ln2 * fs / (2*pi * 12 * f). Computed
92 // in double and capped before the int cast (an absurd-but-finite
93 // sample rate must not overflow the conversion; the ring clamps
94 // further), with 128 samples of padding for safety.
95 constexpr double kMinAllowedHz = 0.1;
96 constexpr double kMaxAllowedSemitones = 4.0;
97 const double maxDeviation =
98 (kMaxAllowedSemitones * std::numbers::ln2 * sampleRate_)
99 / (2.0 * std::numbers::pi * kMinAllowedHz * 12.0);
100 const double required =
101 2.0 * maxDeviation + static_cast<double>(kCentreOffset) + 128.0;
102 const int maxDelaySamples =
103 static_cast<int>(std::min(required, static_cast<double>(1 << 28)));
104
105 delays_.resize(numChannels_);
106 phasors_.resize(numChannels_);
107 modPhasors_.resize(numChannels_);
108
109 for (int ch = 0; ch < numChannels_; ++ch)
110 {
111 delays_[ch].prepare(maxDelaySamples);
112 phasors_[ch].prepare(sampleRate_);
113 modPhasors_[ch].prepare(sampleRate_);
114 }
115
116 // Initialize smoothing state to prevent startup ramps
117 currentRate_ = rate_.load(std::memory_order_relaxed);
118 currentDepth_ = depthSemitones_.load(std::memory_order_relaxed);
119 currentModDepth_ = modDepth_.load(std::memory_order_relaxed);
120 }
121
126 void processBlock(AudioBufferView<T> buffer) noexcept
127 {
128 const int numCh = std::min(buffer.getNumChannels(), numChannels_);
129 const int numSamples = buffer.getNumSamples();
130 if (numSamples == 0 || numCh == 0) return;
131
132 // Fetch targets
133 const T targetRate = rate_.load(std::memory_order_relaxed);
134 const T targetDepth = depthSemitones_.load(std::memory_order_relaxed);
135 const T modRate = modRate_.load(std::memory_order_relaxed);
136 const T targetModDepth = modDepth_.load(std::memory_order_relaxed);
137
138 // Parameter smoothing increments (linear ramp over the block). The FM
139 // depth is smoothed too: it scales the instantaneous rate, and the
140 // deviation and centre follow that rate, so an unsmoothed step would
141 // jump the delay-line read position by hundreds of samples (a hard
142 // click). The FM rate needs no smoothing: it only changes the speed
143 // of a continuous phase, which cannot produce a discontinuity.
144 const T rateInc = (targetRate - currentRate_) / static_cast<T>(numSamples);
145 const T depthInc = (targetDepth - currentDepth_) / static_cast<T>(numSamples);
146 const T modDepthInc = (targetModDepth - currentModDepth_) / static_cast<T>(numSamples);
147
148 // Keep the FM oscillator running while any part of the depth ramp is
149 // live, so a fade-out drains along the moving LFO instead of freezing
150 // mid-ramp. With FM fully off the oscillator holds its phase.
151 const bool fmActive = (targetModDepth > T(0)) || (currentModDepth_ > T(0));
152
153 constexpr T kLn2 = static_cast<T>(std::numbers::ln2_v<double>);
154 constexpr T kTwoPi = static_cast<T>(2.0 * std::numbers::pi);
155 const T deviationScaler = (kLn2 * static_cast<T>(sampleRate_)) / (kTwoPi * T(12));
156
157 for (int ch = 0; ch < numCh; ++ch)
158 {
159 T* data = buffer.getChannel(ch);
160 auto& delay = delays_[ch];
161 auto& phasor = phasors_[ch];
162 auto& modPhasor = modPhasors_[ch];
163
164 modPhasor.setFrequency(modRate);
165
166 // Local state for smoothing (identical ramps on every channel)
167 T smoothRate = currentRate_;
168 T smoothDepth = currentDepth_;
169 T smoothModDepth = currentModDepth_;
170
171 for (int i = 0; i < numSamples; ++i)
172 {
173 smoothRate += rateInc;
174 smoothDepth += depthInc;
175 smoothModDepth += modDepthInc;
176
177 delay.push(data[i]);
178
179 T effectiveRate = std::max(smoothRate, T(0.01));
180
181 // Secondary LFO (FM)
182 T fmMod = T(0);
183 if (fmActive)
184 {
185 T modPhase = modPhasor.advance();
186 fmMod = fastSin(modPhase * kTwoPi) * smoothModDepth;
187 }
188
189 // Floor at 0.1 Hz - the same minimum the delay-line sizing in
190 // prepare() assumes. A lower floor (0.01) let deep FM request
191 // deviations ~100x the allocated buffer (wrapped garbage audio).
192 T instantRate = std::max(effectiveRate * (T(1) + fmMod), T(0.1));
193
194 // Advance primary phasor manually using the instantaneous FM rate
195 phasor.setFrequency(instantRate);
196 T phase = phasor.advance();
197
198 // Inverse square-root coupling: the peak pitch excursion is
199 // proportional to deviation * instantRate, so dividing the
200 // depth by sqrt(instantRate / effectiveRate) lets the
201 // perceived depth shrink gently (as 1/sqrt) while FM raises
202 // the rate, instead of staying rigidly constant or growing
203 // wildly. The per-sample sqrt is the price of that coupling.
204 T ratioSqrt = std::sqrt(instantRate / effectiveRate);
205 T adjustedDepth = smoothDepth / ratioSqrt;
206
207 // Final deviation; the centre sits one deviation above the
208 // offset so the trough of the sweep never dips below it.
209 T deviation = (adjustedDepth * deviationScaler) / instantRate;
210 T centre = deviation + kCentreOffset;
211
212 T lfo = fastSin(phase * kTwoPi);
213
214 // Safety clamp: deep FM can still push the deviation past the
215 // allocation (it grows with sqrt(effectiveRate / instantRate),
216 // unbounded in the primary rate). The 4-point interpolator
217 // reads one sample earlier and two later, hence [1, cap - 4].
218 T delaySamples = std::clamp(centre + lfo * deviation, T(1.0),
219 static_cast<T>(delay.getCapacity() - 4));
220
221 data[i] = delay.readInterpolated(delaySamples);
222 }
223 }
224
225 // Update current state for the next block
226 currentRate_ = targetRate;
227 currentDepth_ = targetDepth;
228 currentModDepth_ = targetModDepth;
229 }
230
236 void reset() noexcept
237 {
238 for (int ch = 0; ch < numChannels_; ++ch)
239 {
240 delays_[ch].reset();
241 phasors_[ch].reset();
242 modPhasors_[ch].reset();
243 }
244 }
245
253 void setRate(T hz) noexcept
254 {
255 if (!std::isfinite(hz)) return; // NaN/Inf would poison the delay sweep
256 rate_.store(std::max(hz, T(0.1)), std::memory_order_relaxed);
257 }
258
265 void setDepth(T semitones) noexcept
266 {
267 if (!std::isfinite(semitones)) return;
268 depthSemitones_.store(std::clamp(semitones, T(0), T(4)), std::memory_order_relaxed);
269 }
270
280 void setModRate(T hz) noexcept
281 {
282 if (!std::isfinite(hz)) return;
283 modRate_.store(std::max(hz, T(0)), std::memory_order_relaxed);
284 }
285
297 void setModDepth(T amount) noexcept
298 {
299 if (!std::isfinite(amount)) return;
300 modDepth_.store(std::clamp(amount, T(0), T(1)), std::memory_order_relaxed);
301 }
302
303 [[nodiscard]] T getRate() const noexcept { return rate_.load(std::memory_order_relaxed); }
304 [[nodiscard]] T getDepth() const noexcept { return depthSemitones_.load(std::memory_order_relaxed); }
305 [[nodiscard]] T getModRate() const noexcept { return modRate_.load(std::memory_order_relaxed); }
306 [[nodiscard]] T getModDepth() const noexcept { return modDepth_.load(std::memory_order_relaxed); }
307
309 [[nodiscard]] std::vector<uint8_t> getState() const
310 {
311 // The blob stores float (setState reads float back); the explicit
312 // casts also keep this overload resolvable when T is double.
313 StateWriter w(stateId("VIBR"), 1);
314 w.write("rate", static_cast<float>(rate_.load(std::memory_order_relaxed)));
315 w.write("depth", static_cast<float>(depthSemitones_.load(std::memory_order_relaxed)));
316 w.write("modRate", static_cast<float>(modRate_.load(std::memory_order_relaxed)));
317 w.write("modDepth", static_cast<float>(modDepth_.load(std::memory_order_relaxed)));
318 return w.blob();
319 }
320
322 bool setState(const uint8_t* data, size_t size)
323 {
324 StateReader r(data, size);
325 if (!r.isValid() || r.processorId() != stateId("VIBR")) return false;
326 setRate(static_cast<T>(r.read("rate", 5.0f)));
327 setDepth(static_cast<T>(r.read("depth", 0.5f)));
328 setModRate(static_cast<T>(r.read("modRate", 0.0f)));
329 setModDepth(static_cast<T>(r.read("modDepth", 0.0f)));
330 return true;
331 }
332
333private:
336 static constexpr T kCentreOffset = T(4);
337
338 double sampleRate_ = 44100.0;
339 int numChannels_ = 0;
340
341 // Atomic targets for UI thread publication
342 std::atomic<T> rate_ { T(5) };
343 std::atomic<T> depthSemitones_ { T(0.5) };
344 std::atomic<T> modRate_ { T(0) };
345 std::atomic<T> modDepth_ { T(0) };
346
347 // Smoothed state for the audio thread
348 T currentRate_ { T(5) };
349 T currentDepth_ { T(0.5) };
350 T currentModDepth_ { T(0) };
351
352 // Dynamic allocation via STL, safe because it only happens in prepare()
353 std::vector<RingBuffer<T>> delays_;
354 std::vector<Phasor<T>> phasors_;
355 std::vector<Phasor<T>> modPhasors_;
356};
357
358} // namespace dspark
Non-owning view over audio channel data.
Definition AudioBuffer.h:50
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
Professional-grade pitch vibrato with LFO FM and parameter smoothing.
Definition Vibrato.h:64
T getModDepth() const noexcept
Definition Vibrato.h:306
void processBlock(AudioBufferView< T > buffer) noexcept
Processes audio in-place, applying vibrato per channel.
Definition Vibrato.h:126
std::vector< uint8_t > getState() const
Serializes the parameter state (setup/UI threads; allocates).
Definition Vibrato.h:309
void setDepth(T semitones) noexcept
Sets the vibrato pitch depth. Parameter is smoothed internally.
Definition Vibrato.h:265
void setModRate(T hz) noexcept
Sets the rate of the secondary FM oscillator.
Definition Vibrato.h:280
void reset() noexcept
Clears delay line memory and resets LFO phases.
Definition Vibrato.h:236
T getDepth() const noexcept
Definition Vibrato.h:304
bool setState(const uint8_t *data, size_t size)
Restores parameters from a blob (tolerant; rejects foreign ids).
Definition Vibrato.h:322
void setModDepth(T amount) noexcept
Sets the intensity of the FM modulation on the primary LFO.
Definition Vibrato.h:297
T getRate() const noexcept
Definition Vibrato.h:303
T getModRate() const noexcept
Definition Vibrato.h:305
void setRate(T hz) noexcept
Sets the primary LFO rate. Parameter is smoothed internally.
Definition Vibrato.h:253
void prepare(const AudioSpec &spec)
Allocates delay lines and settles the parameter smoothing state.
Definition Vibrato.h:81
Main namespace for the DSPark framework.
T fastSin(T x) noexcept
Fast sine approximation (degree-9 odd minimax polynomial).
Definition DspMath.h:213
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