DSPark 1.6.1
Header-only audio DSP framework in pure C++20 — zero dependencies
Loading...
Searching...
No Matches
WavetableOscillator.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 "DspMath.h"
32#include "AudioSpec.h"
33#include "AudioBuffer.h"
34#include "Phasor.h"
35#include "Interpolation.h"
36
37#include <algorithm>
38#include <cassert>
39#include <cmath>
40#include <vector>
41
42namespace dspark {
43
50template <FloatType T>
52{
53public:
54 static constexpr int kTableSize = 2048;
55 static constexpr int kTableMask = kTableSize - 1; // Used for ultra-fast wrapping
56 static constexpr int kMaxMipLevels = 12;
57
59
69 void prepare(double sampleRate)
70 {
71 assert(sampleRate > 0.0);
72 if (!(sampleRate > 0.0)) return;
73
74 sampleRate_ = sampleRate;
75 phasor_.prepare(sampleRate);
76 updateMipCutoffs();
77 }
78
83 void prepare(const AudioSpec& spec)
84 {
85 prepare(spec.sampleRate);
86 }
87
88 // -- Built-in waveform generators (OFFLINE ONLY) ----------------------------
89
94 void buildSaw()
95 {
96 buildFromHarmonics([](int harmonic) -> T {
97 return (harmonic % 2 == 0) ? T(-1.0 / harmonic) : T(1.0 / harmonic);
98 });
99 }
100
106 {
107 buildFromHarmonics([](int harmonic) -> T {
108 return (harmonic % 2 == 0) ? T(0) : T(1.0 / harmonic);
109 });
110 }
111
117 {
118 buildFromHarmonics([](int harmonic) -> T {
119 if (harmonic % 2 == 0) return T(0);
120 T sign = ((harmonic / 2) % 2 == 0) ? T(1) : T(-1);
121 return sign / static_cast<T>(harmonic * harmonic);
122 });
123 }
124
131 {
132 numMipLevels_ = 1;
133 mipData_.assign(kTableSize, T(0)); // Contiguous layout
134
135 for (int i = 0; i < kTableSize; ++i)
136 {
137 T phase = twoPi<T> * static_cast<T>(i) / static_cast<T>(kTableSize);
138 mipData_[static_cast<size_t>(i)] = std::sin(phase);
139 }
140
141 updateMipCutoffs();
142 }
143
151 template <typename HarmonicFunc>
152 void buildFromHarmonics(HarmonicFunc harmonicFunc)
153 {
154 numMipLevels_ = kMaxMipLevels;
155 mipData_.assign(static_cast<size_t>(numMipLevels_ * kTableSize), T(0));
156
157 const int maxTableHarmonics = kTableSize / 2; // Absolute Nyquist limit of the table itself
158
159 for (int level = 0; level < numMipLevels_; ++level)
160 {
161 // Fixed harmonic budget per level (2^(levels-1-level)), computed in
162 // exact integer arithmetic. This is what makes the table content
163 // sample-rate independent: prepare() only re-derives the cutoffs.
164 int maxHarmonics = 1 << (numMipLevels_ - 1 - level);
165 maxHarmonics = std::clamp(maxHarmonics, 1, maxTableHarmonics);
166
167 size_t offset = static_cast<size_t>(level * kTableSize);
168
169 for (int h = 1; h <= maxHarmonics; ++h)
170 {
171 T amplitude = harmonicFunc(h);
172 if (amplitude == T(0)) continue;
173
174 for (int i = 0; i < kTableSize; ++i)
175 {
176 T phase = twoPi<T> * static_cast<T>(h) * static_cast<T>(i) / static_cast<T>(kTableSize);
177 mipData_[offset + static_cast<size_t>(i)] += amplitude * std::sin(phase);
178 }
179 }
180 }
181
182 updateMipCutoffs();
183
184 // Normalise ALL levels with ONE global factor (the peak of the richest
185 // level). Per-level peak normalisation made the level/timbre jump
186 // slightly at every mip crossfade, because the Gibbs overshoot varies
187 // with the harmonic count.
188 normalizeAllLevelsGlobally();
189 }
190
201 void loadWavetable(const T* data, int size)
202 {
203 if (!data || size <= 0) return;
204
205 // Perform DFT on raw input data to extract true harmonics without
206 // interpolation loss. The DC term is discarded (an oscillator should
207 // not reproduce offset), and for even sizes the Nyquist bin is
208 // excluded too: the 2/N single-sided scaling below would count it
209 // twice ((size - 1) / 2 stops one bin short of it).
210 int rawHarmonicLimit = (size - 1) / 2;
211 int targetNyquistLimit = kTableSize / 2;
212 int maxHarmonics = std::min(rawHarmonicLimit, targetNyquistLimit);
213 if (maxHarmonics < 1) return; // size < 3: no extractable harmonics
214
215 std::vector<T> cosCoeffs(static_cast<size_t>(maxHarmonics + 1), T(0));
216 std::vector<T> sinCoeffs(static_cast<size_t>(maxHarmonics + 1), T(0));
217
218 for (int h = 1; h <= maxHarmonics; ++h)
219 {
220 T sumCos = T(0), sumSin = T(0);
221 for (int i = 0; i < size; ++i)
222 {
223 T phase = twoPi<T> * static_cast<T>(h) * static_cast<T>(i) / static_cast<T>(size);
224 sumCos += data[i] * std::cos(phase);
225 sumSin += data[i] * std::sin(phase);
226 }
227 cosCoeffs[static_cast<size_t>(h)] = sumCos * T(2) / static_cast<T>(size);
228 sinCoeffs[static_cast<size_t>(h)] = sumSin * T(2) / static_cast<T>(size);
229 }
230
231 numMipLevels_ = kMaxMipLevels;
232 mipData_.assign(static_cast<size_t>(numMipLevels_ * kTableSize), T(0));
233
234 for (int level = 0; level < numMipLevels_; ++level)
235 {
236 // Same exact integer harmonic budget as buildFromHarmonics,
237 // additionally capped by what the source material contains.
238 int maxH = 1 << (numMipLevels_ - 1 - level);
239 maxH = std::clamp(maxH, 1, maxHarmonics);
240
241 size_t offset = static_cast<size_t>(level * kTableSize);
242
243 for (int h = 1; h <= maxH; ++h)
244 {
245 T a = cosCoeffs[static_cast<size_t>(h)];
246 T b = sinCoeffs[static_cast<size_t>(h)];
247 if (a == T(0) && b == T(0)) continue;
248
249 for (int i = 0; i < kTableSize; ++i)
250 {
251 T phase = twoPi<T> * static_cast<T>(h) * static_cast<T>(i) / static_cast<T>(kTableSize);
252 mipData_[offset + static_cast<size_t>(i)] += a * std::cos(phase) + b * std::sin(phase);
253 }
254 }
255 }
256
257 updateMipCutoffs();
258
259 // Single global normalisation factor across all mip levels (see
260 // buildFromHarmonics for the rationale).
261 normalizeAllLevelsGlobally();
262 }
263
264 // -- Playback (REAL-TIME SAFE) ----------------------------------------------
265
271 void setFrequency(T frequencyHz) noexcept
272 {
273 // NaN is ignored, mirroring the internal Phasor: otherwise the pitch
274 // would keep the old value while the mip selection went to the
275 // dullest level (inconsistent timbre).
276 if (frequencyHz != frequencyHz) return;
277 frequency_ = frequencyHz;
278 phasor_.setFrequency(frequencyHz);
279 }
280
285 [[nodiscard]] T getFrequency() const noexcept { return frequency_; }
286
292 [[nodiscard]] inline T getSample() noexcept
293 {
294 T phase = phasor_.advance();
295 return readTable(phase);
296 }
297
303 void processBlock(T* output, int numSamples) noexcept
304 {
305 for (int i = 0; i < numSamples; ++i)
306 output[i] = getSample();
307 }
308
313 void generateBlock(AudioBufferView<T> buffer) noexcept
314 {
315 const int nCh = buffer.getNumChannels();
316 const int nS = buffer.getNumSamples();
317 for (int i = 0; i < nS; ++i)
318 {
319 const T s = getSample();
320 for (int ch = 0; ch < nCh; ++ch)
321 buffer.getChannel(ch)[i] = s;
322 }
323 }
324
329 void reset(T phase = T(0)) noexcept
330 {
331 phasor_.reset(phase);
332 }
333
334private:
335
345 [[nodiscard]] inline T readFromLevel(T phase, int level) const noexcept
346 {
347 T pos = phase * static_cast<T>(kTableSize);
348 int i1 = static_cast<int>(pos); // Fast truncation replacing std::floor
349 T frac = pos - static_cast<T>(i1);
350
351 // Bitwise mask wrapping ensures bounds without modulo operator penalty
352 int i0 = (i1 - 1) & kTableMask;
353 i1 = i1 & kTableMask;
354 int i2 = (i1 + 1) & kTableMask;
355 int i3 = (i1 + 2) & kTableMask;
356
357 size_t offset = static_cast<size_t>(level * kTableSize);
358 const T* table = &mipData_[offset];
359
360 return interpolateHermite(table[i0], table[i1], table[i2], table[i3], frac);
361 }
362
366 [[nodiscard]] inline T readTable(T phase) const noexcept
367 {
368 if (mipData_.empty()) return T(0);
369
370 T levelF = selectMipLevelFloat();
371 int level0 = static_cast<int>(levelF);
372
373 // Optimize crossfade edge-case
374 if (levelF == static_cast<T>(level0))
375 {
376 return readFromLevel(phase, level0);
377 }
378
379 int level1 = std::min(level0 + 1, numMipLevels_ - 1);
380 T frac = levelF - static_cast<T>(level0);
381
382 T s0 = readFromLevel(phase, level0);
383 T s1 = readFromLevel(phase, level1);
384
385 // Quadratic weighting of the brighter (lower) level: its topmost
386 // harmonics start aliasing as soon as the frequency moves past its
387 // design point, so its weight must die off much faster than linear.
388 // (1-t)^2 keeps the worst-case alias contribution ~12 dB lower than a
389 // linear crossfade at mid-octave while preserving a smooth timbre.
390 const T w0 = (T(1) - frac) * (T(1) - frac);
391 return s1 + w0 * (s0 - s1);
392 }
393
397 [[nodiscard]] inline T selectMipLevelFloat() const noexcept
398 {
399 T absFreq = std::abs(frequency_);
400
401 if (numMipLevels_ <= 1 || absFreq <= mipMaxFreq_[0])
402 return T(0);
403
404 for (int i = 1; i < numMipLevels_; ++i)
405 {
406 if (absFreq <= mipMaxFreq_[static_cast<size_t>(i)])
407 {
408 T freqLow = mipMaxFreq_[static_cast<size_t>(i - 1)];
409 T freqHigh = mipMaxFreq_[static_cast<size_t>(i)];
410 T t = (absFreq - freqLow) / (freqHigh - freqLow + T(1e-9));
411 return static_cast<T>(i - 1) + t;
412 }
413 }
414
415 return static_cast<T>(numMipLevels_ - 1);
416 }
417
423 void updateMipCutoffs()
424 {
425 if (numMipLevels_ <= 0) return;
426 mipMaxFreq_.resize(static_cast<size_t>(numMipLevels_));
427
428 const T nyquist = static_cast<T>(sampleRate_ / 2.0);
429 for (int level = 0; level < numMipLevels_; ++level)
430 mipMaxFreq_[static_cast<size_t>(level)] =
431 nyquist / static_cast<T>(1 << (numMipLevels_ - 1 - level));
432 }
433
442 void normalizeAllLevelsGlobally()
443 {
444 T maxVal = T(0);
445 for (const T v : mipData_)
446 maxVal = std::max(maxVal, std::abs(v));
447
448 if (maxVal > T(0))
449 {
450 const T invMax = T(1) / maxVal;
451 for (T& v : mipData_)
452 v *= invMax;
453 }
454 }
455
456 double sampleRate_ = 48000.0;
457 T frequency_ = T(440);
458
459 Phasor<T> phasor_;
460
461 int numMipLevels_ = 0;
462 // Cache-friendly contiguous flat layout (Level0 + Level1 + ...)
463 std::vector<T> mipData_;
464 std::vector<T> mipMaxFreq_;
465};
466
467} // namespace dspark
Owning audio buffer and non-owning view for real-time DSP processing.
Describes the audio processing environment (sample rate, block size, channels).
Core mathematical utilities for digital signal processing.
Fractional-position interpolation for delay lines, tables and buffers.
Phase accumulator producing a [0, 1) ramp for oscillators and LFOs.
Non-owning view over audio channel data.
Definition AudioBuffer.h:50
Professional mipmapped wavetable oscillator for bandlimited synthesis.
void prepare(double sampleRate)
Prepares the oscillator for a given sample rate.
void buildTriangle()
Builds a bandlimited triangle wavetable with mipmaps.
static constexpr int kTableSize
void prepare(const AudioSpec &spec)
Prepares from AudioSpec (unified API).
T getFrequency() const noexcept
Returns the current internal frequency.
void buildFromHarmonics(HarmonicFunc harmonicFunc)
Builds contiguous mipmapped tables from a harmonic amplitude function.
static constexpr int kMaxMipLevels
void buildSquare()
Builds a bandlimited square wavetable with mipmaps.
void buildSine()
Builds a pure sine wavetable. Requires only 1 level since it contains only the fundamental.
void generateBlock(AudioBufferView< T > buffer) noexcept
Fills every channel of the view with the generated waveform. Satisfies the GeneratorProcessor concept...
void loadWavetable(const T *data, int size)
Loads and analyzes a custom single-cycle wavetable using DFT.
T getSample() noexcept
Generates the next sample using Hermite interpolation.
void buildSaw()
Builds a bandlimited sawtooth wavetable with mipmaps.
void reset(T phase=T(0)) noexcept
Hard resets the oscillator phase.
void processBlock(T *output, int numSamples) noexcept
Generates a block of samples.
static constexpr int kTableMask
void setFrequency(T frequencyHz) noexcept
Sets the fundamental oscillation frequency.
Main namespace for the DSPark framework.
T interpolateHermite(T y0, T y1, T y2, T y3, T frac) noexcept
4-point, 3rd-order Hermite interpolation (optimized x-form).
Describes the audio environment for a DSP processor.
Definition AudioSpec.h:35
double sampleRate
Sample rate in Hz.
Definition AudioSpec.h:43