DSPark 1.8.0
Header-only C++20 DSP for real-time and offline audio
Loading...
Searching...
No Matches
Vibrato.h
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 readPos_.assign(static_cast<size_t>(numChannels_), T(-1));
109
110 for (int ch = 0; ch < numChannels_; ++ch)
111 {
112 delays_[ch].prepare(maxDelaySamples);
113 phasors_[ch].prepare(sampleRate_);
114 modPhasors_[ch].prepare(sampleRate_);
115 }
116
117 // Initialize smoothing state to prevent startup ramps
118 currentRate_ = rate_.load(std::memory_order_relaxed);
119 currentDepth_ = depthSemitones_.load(std::memory_order_relaxed);
120 currentModDepth_ = modDepth_.load(std::memory_order_relaxed);
121 }
122
127 void processBlock(AudioBufferView<T> buffer) noexcept
128 {
129 const int numCh = std::min(buffer.getNumChannels(), numChannels_);
130 const int numSamples = buffer.getNumSamples();
131 if (numSamples == 0 || numCh == 0) return;
132
133 // Fetch targets
134 const T targetRate = rate_.load(std::memory_order_relaxed);
135 const T targetDepth = depthSemitones_.load(std::memory_order_relaxed);
136 const T modRate = modRate_.load(std::memory_order_relaxed);
137 const T targetModDepth = modDepth_.load(std::memory_order_relaxed);
138
139 // Parameter smoothing increments (linear ramp over the block). Rate
140 // and depth set the width and centre of the delay sweep; the read
141 // position slew limit below turns even a large change into a brief
142 // glide. The FM depth is ramped too, so the LFO speed changes
143 // smoothly. The FM rate needs no smoothing: it only changes the speed
144 // of a continuous phase, which cannot produce a discontinuity.
145 const T rateInc = (targetRate - currentRate_) / static_cast<T>(numSamples);
146 const T depthInc = (targetDepth - currentDepth_) / static_cast<T>(numSamples);
147 const T modDepthInc = (targetModDepth - currentModDepth_) / static_cast<T>(numSamples);
148
149 // Keep the FM oscillator running while any part of the depth ramp is
150 // live, so a fade-out drains along the moving LFO instead of freezing
151 // mid-ramp. With FM fully off the oscillator holds its phase.
152 const bool fmActive = (targetModDepth > T(0)) || (currentModDepth_ > T(0));
153
154 constexpr T kLn2 = static_cast<T>(std::numbers::ln2_v<double>);
155 constexpr T kTwoPi = static_cast<T>(2.0 * std::numbers::pi);
156 const T deviationScaler = (kLn2 * static_cast<T>(sampleRate_)) / (kTwoPi * T(12));
157
158 for (int ch = 0; ch < numCh; ++ch)
159 {
160 T* data = buffer.getChannel(ch);
161 auto& delay = delays_[ch];
162 auto& phasor = phasors_[ch];
163 auto& modPhasor = modPhasors_[ch];
164
165 modPhasor.setFrequency(modRate);
166
167 // Local state for smoothing (identical ramps on every channel)
168 T smoothRate = currentRate_;
169 T smoothDepth = currentDepth_;
170 T smoothModDepth = currentModDepth_;
171
172 for (int i = 0; i < numSamples; ++i)
173 {
174 smoothRate += rateInc;
175 smoothDepth += depthInc;
176 smoothModDepth += modDepthInc;
177
178 delay.push(data[i]);
179
180 // Sweep geometry from the smoothed base rate only: a sinusoidal
181 // delay centre + D * sin(phase) peaks at a pitch excursion of
182 // D * 2*pi*f/fs, so D = depth * deviationScaler / rate gives
183 // the set depth at the base rate, and the centre sits one
184 // deviation above the offset so the trough never dips below
185 // it. FM used to feed its instantaneous rate into D and the
186 // centre (D ~ rate^-1.5): at deep or fast FM the rate neared
187 // the floor and the read point leapt by hundreds of samples
188 // per sample (noise, not vibrato).
189 const T baseRate = std::max(smoothRate, T(0.1));
190 const T deviation = (smoothDepth * deviationScaler) / baseRate;
191 const T centre = deviation + kCentreOffset;
192
193 // FM varies only the speed of the LFO, between (1 - modDepth)
194 // and (1 + modDepth) times the rate, so the pitch excursion
195 // follows the instantaneous speed: up to twice the depth at
196 // full FM, and never a jump.
197 T speed = T(1);
198 if (fmActive)
199 speed += fastSin(modPhasor.advance() * kTwoPi) * smoothModDepth;
200 phasor.setFrequency(baseRate * std::max(speed, T(0)));
201 const T lfo = fastSin(phasor.advance() * kTwoPi);
202
203 // Safety clamp: the 4-point interpolator reads one sample
204 // earlier and two later, hence [1, cap - 4].
205 const T delaySamples = std::clamp(centre + lfo * deviation, T(1.0),
206 static_cast<T>(delay.getCapacity() - 4));
207
208 // Read-position slew limit. Depth and rate scale the deviation
209 // and centre, so a parameter change moves the read point;
210 // ramped per block, a small block moved it by hundreds of
211 // samples at once (a click, or a backwards read). Limiting the
212 // motion to 0.5 samples per sample bounds any parameter jump
213 // to a brief pitch glide, and never touches the vibrato itself
214 // (its steepest motion, 4 semitones at full FM, is
215 // 2 * 4 * ln2 / 12 = 0.46 samples per sample).
216 T& pos = readPos_[static_cast<size_t>(ch)];
217 pos = (pos < T(0)) ? delaySamples
218 : pos + std::clamp(delaySamples - pos, -kMaxReadSlope, kMaxReadSlope);
219
220 data[i] = delay.readInterpolated(pos);
221 }
222 }
223
224 // Update current state for the next block
225 currentRate_ = targetRate;
226 currentDepth_ = targetDepth;
227 currentModDepth_ = targetModDepth;
228 }
229
235 void reset() noexcept
236 {
237 for (int ch = 0; ch < numChannels_; ++ch)
238 {
239 delays_[ch].reset();
240 phasors_[ch].reset();
241 modPhasors_[ch].reset();
242 }
243 std::fill(readPos_.begin(), readPos_.end(), T(-1)); // re-seed on the next sample
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
298 void setModDepth(T amount) noexcept
299 {
300 if (!std::isfinite(amount)) return;
301 modDepth_.store(std::clamp(amount, T(0), T(1)), std::memory_order_relaxed);
302 }
303
304 [[nodiscard]] T getRate() const noexcept { return rate_.load(std::memory_order_relaxed); }
305 [[nodiscard]] T getDepth() const noexcept { return depthSemitones_.load(std::memory_order_relaxed); }
306 [[nodiscard]] T getModRate() const noexcept { return modRate_.load(std::memory_order_relaxed); }
307 [[nodiscard]] T getModDepth() const noexcept { return modDepth_.load(std::memory_order_relaxed); }
308
310 [[nodiscard]] std::vector<uint8_t> getState() const
311 {
312 // The blob stores float (setState reads float back); the explicit
313 // casts also keep this overload resolvable when T is double.
314 StateWriter w(stateId("VIBR"), 1);
315 w.write("rate", static_cast<float>(rate_.load(std::memory_order_relaxed)));
316 w.write("depth", static_cast<float>(depthSemitones_.load(std::memory_order_relaxed)));
317 w.write("modRate", static_cast<float>(modRate_.load(std::memory_order_relaxed)));
318 w.write("modDepth", static_cast<float>(modDepth_.load(std::memory_order_relaxed)));
319 return w.blob();
320 }
321
323 bool setState(const uint8_t* data, size_t size)
324 {
325 StateReader r(data, size);
326 if (!r.isValid() || r.processorId() != stateId("VIBR")) return false;
327 setRate(static_cast<T>(r.read("rate", 5.0f)));
328 setDepth(static_cast<T>(r.read("depth", 0.5f)));
329 setModRate(static_cast<T>(r.read("modRate", 0.0f)));
330 setModDepth(static_cast<T>(r.read("modDepth", 0.0f)));
331 return true;
332 }
333
334private:
337 static constexpr T kCentreOffset = T(4);
339 static constexpr T kMaxReadSlope = T(0.5);
340
341 double sampleRate_ = 44100.0;
342 int numChannels_ = 0;
343
344 // Atomic targets for UI thread publication
345 std::atomic<T> rate_ { T(5) };
346 std::atomic<T> depthSemitones_ { T(0.5) };
347 std::atomic<T> modRate_ { T(0) };
348 std::atomic<T> modDepth_ { T(0) };
349
350 // Smoothed state for the audio thread
351 T currentRate_ { T(5) };
352 T currentDepth_ { T(0.5) };
353 T currentModDepth_ { T(0) };
354
355 // Dynamic allocation via STL, safe because it only happens in prepare()
356 std::vector<RingBuffer<T>> delays_;
357 std::vector<Phasor<T>> phasors_;
358 std::vector<Phasor<T>> modPhasors_;
359 std::vector<T> readPos_;
360};
361
362} // 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: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
Professional-grade pitch vibrato with LFO FM and parameter smoothing.
Definition Vibrato.h:64
T getModDepth() const noexcept
Definition Vibrato.h:307
void processBlock(AudioBufferView< T > buffer) noexcept
Processes audio in-place, applying vibrato per channel.
Definition Vibrato.h:127
std::vector< uint8_t > getState() const
Serializes the parameter state (setup/UI threads; allocates).
Definition Vibrato.h:310
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:235
T getDepth() const noexcept
Definition Vibrato.h:305
bool setState(const uint8_t *data, size_t size)
Restores parameters from a blob (tolerant; rejects foreign ids).
Definition Vibrato.h:323
void setModDepth(T amount) noexcept
Sets the intensity of the FM modulation on the primary LFO.
Definition Vibrato.h:298
T getRate() const noexcept
Definition Vibrato.h:304
T getModRate() const noexcept
Definition Vibrato.h:306
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:248
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