DSPark 1.8.0
Header-only C++20 DSP for real-time and offline audio
Loading...
Searching...
No Matches
WavetableOscillator.h
1// DSPark - Professional Audio DSP Framework
2// Copyright (c) 2026 Cristian Moresi - MIT License
3
4#pragma once
5
42#include "DspMath.h"
43#include "AudioSpec.h"
44#include "AudioBuffer.h"
45#include "Phasor.h"
46#include "Interpolation.h"
47#include "FFT.h"
48
49#include <array>
50#include <algorithm>
51#include <cassert>
52#include <cmath>
53#include <vector>
54
55namespace dspark {
56
63template <FloatType T>
65{
66public:
67 static constexpr int kTableSize = 2048;
68 static constexpr int kTableMask = kTableSize - 1; // Used for ultra-fast wrapping
70 static constexpr int kMaxMipLevels = 20;
71
73
83 void prepare(double sampleRate)
84 {
85 assert(sampleRate > 0.0);
86 if (!(sampleRate > 0.0)) return;
87
88 sampleRate_ = sampleRate;
89 phasor_.prepare(sampleRate);
90 updateMipCutoffs();
91 updateMipSelection();
92 }
93
98 void prepare(const AudioSpec& spec)
99 {
100 prepare(spec.sampleRate);
101 }
102
103 // -- Built-in waveform generators (OFFLINE ONLY) ----------------------------
104
109 void buildSaw()
110 {
111 buildFromHarmonics([](int harmonic) -> T {
112 return (harmonic % 2 == 0) ? T(-1.0 / harmonic) : T(1.0 / harmonic);
113 });
114 }
115
121 {
122 buildFromHarmonics([](int harmonic) -> T {
123 return (harmonic % 2 == 0) ? T(0) : T(1.0 / harmonic);
124 });
125 }
126
132 {
133 buildFromHarmonics([](int harmonic) -> T {
134 if (harmonic % 2 == 0) return T(0);
135 T sign = ((harmonic / 2) % 2 == 0) ? T(1) : T(-1);
136 return sign / static_cast<T>(harmonic * harmonic);
137 });
138 }
139
146 {
147 numMipLevels_ = 1;
148 mipData_.assign(kTableSize, T(0)); // Contiguous layout
149 levelHarmonics_.assign(1, 1);
150
151 for (int i = 0; i < kTableSize; ++i)
152 {
153 const double phase = twoPi<double> * static_cast<double>(i) / static_cast<double>(kTableSize);
154 mipData_[static_cast<size_t>(i)] = static_cast<T>(std::sin(phase));
155 }
156
157 updateMipCutoffs();
158 updateMipSelection();
159 }
160
168 template <typename HarmonicFunc>
169 void buildFromHarmonics(HarmonicFunc harmonicFunc)
170 {
171 std::vector<double> cosAmp(kMaxHarmonics + 1, 0.0);
172 std::vector<double> sinAmp(kMaxHarmonics + 1, 0.0);
173 for (int h = 1; h <= kMaxHarmonics; ++h)
174 sinAmp[static_cast<size_t>(h)] = static_cast<double>(harmonicFunc(h));
175 buildLevels(cosAmp, sinAmp, kMaxHarmonics);
176 }
177
188 void loadWavetable(const T* data, int size)
189 {
190 if (!data || size <= 0) return;
191
192 // Perform DFT on raw input data to extract true harmonics without
193 // interpolation loss. The DC term is discarded (an oscillator should
194 // not reproduce offset), and for even sizes the Nyquist bin is
195 // excluded too: the 2/N single-sided scaling below would count it
196 // twice ((size - 1) / 2 stops one bin short of it).
197 int rawHarmonicLimit = (size - 1) / 2;
198 int maxHarmonics = std::min(rawHarmonicLimit, kMaxHarmonics);
199 if (maxHarmonics < 1) return; // size < 3: no extractable harmonics
200
201 // Analysis in double whatever T is (setup-time work).
202 std::vector<double> cosCoeffs(static_cast<size_t>(maxHarmonics + 1), 0.0);
203 std::vector<double> sinCoeffs(static_cast<size_t>(maxHarmonics + 1), 0.0);
204
205 for (int h = 1; h <= maxHarmonics; ++h)
206 {
207 double sumCos = 0.0, sumSin = 0.0;
208 for (int i = 0; i < size; ++i)
209 {
210 // Integer phase reduction keeps the argument small (exact).
211 const auto k = static_cast<long long>(h) * i % size;
212 const double phase = twoPi<double> * static_cast<double>(k) / static_cast<double>(size);
213 sumCos += static_cast<double>(data[i]) * std::cos(phase);
214 sumSin += static_cast<double>(data[i]) * std::sin(phase);
215 }
216 cosCoeffs[static_cast<size_t>(h)] = sumCos * 2.0 / static_cast<double>(size);
217 sinCoeffs[static_cast<size_t>(h)] = sumSin * 2.0 / static_cast<double>(size);
218 }
219
220 buildLevels(cosCoeffs, sinCoeffs, maxHarmonics);
221 }
222
223 // -- Playback (REAL-TIME SAFE) ----------------------------------------------
224
230 void setFrequency(T frequencyHz) noexcept
231 {
232 // NaN is ignored, mirroring the internal Phasor: otherwise the pitch
233 // would keep the old value while the mip selection went to the
234 // dullest level (inconsistent timbre).
235 if (frequencyHz != frequencyHz) return;
236 const bool changed = frequencyHz != frequency_;
237 frequency_ = frequencyHz;
238 phasor_.setFrequency(frequencyHz);
239 if (changed) updateMipSelection();
240 }
241
246 [[nodiscard]] T getFrequency() const noexcept { return frequency_; }
247
253 [[nodiscard]] inline T getSample() noexcept
254 {
255 T phase = phasor_.advance();
256 return readTable(phase);
257 }
258
264 void processBlock(T* output, int numSamples) noexcept
265 {
266 for (int i = 0; i < numSamples; ++i)
267 output[i] = getSample();
268 }
269
274 void generateBlock(AudioBufferView<T> buffer) noexcept
275 {
276 const int nCh = buffer.getNumChannels();
277 const int nS = buffer.getNumSamples();
278 for (int i = 0; i < nS; ++i)
279 {
280 const T s = getSample();
281 for (int ch = 0; ch < nCh; ++ch)
282 buffer.getChannel(ch)[i] = s;
283 }
284 }
285
290 void reset(T phase = T(0)) noexcept
291 {
292 phasor_.reset(phase);
293 }
294
295private:
296
306 [[nodiscard]] inline T readFromLevel(T phase, int level) const noexcept
307 {
308 T pos = phase * static_cast<T>(kTableSize);
309 int i1 = static_cast<int>(pos); // Fast truncation replacing std::floor
310 T frac = pos - static_cast<T>(i1);
311
312 // Bitwise mask wrapping ensures bounds without modulo operator penalty
313 int i0 = (i1 - 1) & kTableMask;
314 i1 = i1 & kTableMask;
315 int i2 = (i1 + 1) & kTableMask;
316 int i3 = (i1 + 2) & kTableMask;
317
318 size_t offset = static_cast<size_t>(level * kTableSize);
319 const T* table = &mipData_[offset];
320
321 return interpolateHermite(table[i0], table[i1], table[i2], table[i3], frac);
322 }
323
333 [[nodiscard]] inline T readTable(T phase) const noexcept
334 {
335 if (mipData_.empty()) return T(0);
336
337 const T s0 = readFromLevel(phase, selLevel_);
338 if (selNext_ == selLevel_ || selWeight_ >= T(1))
339 return s0;
340 const T s1 = readFromLevel(phase, selNext_);
341 return s1 + selWeight_ * (s0 - s1);
342 }
343
354 void updateMipSelection() noexcept
355 {
356 selLevel_ = 0;
357 selNext_ = 0;
358 selWeight_ = T(1);
359 if (numMipLevels_ <= 1 || safeFreq_.size() < static_cast<size_t>(numMipLevels_))
360 return;
361
362 const T f = std::abs(frequency_);
363 const int last = numMipLevels_ - 1;
364 int level = 0;
365 while (level < last && f > safeFreq_[static_cast<size_t>(level)])
366 ++level;
367
368 selLevel_ = level;
369 selNext_ = std::min(level + 1, last);
370 if (selNext_ == level) return;
371
372 // Bottom of this level's range: the previous level's safe limit, or,
373 // for level 0, the point one level-spacing below its own limit.
374 const T hi = safeFreq_[static_cast<size_t>(level)];
375 const T lo = (level > 0)
376 ? safeFreq_[static_cast<size_t>(level - 1)]
377 : hi * static_cast<T>(levelHarmonics_[1]) / static_cast<T>(levelHarmonics_[0]);
378 const T span = hi - lo;
379 selWeight_ = (span > T(0)) ? std::clamp((hi - f) / span, T(0), T(1)) : T(1);
380 }
381
387 void updateMipCutoffs()
388 {
389 if (numMipLevels_ <= 0) return;
390 safeFreq_.resize(static_cast<size_t>(numMipLevels_));
391
392 // Highest harmonic frequency whose alias still lands above 20 kHz
393 // (never below Nyquist itself: at low rates there is no free band).
394 const double fold = std::max(sampleRate_ * 0.5, sampleRate_ - 20000.0);
395 for (int level = 0; level < numMipLevels_; ++level)
396 safeFreq_[static_cast<size_t>(level)] = static_cast<T>(
397 fold / static_cast<double>(levelHarmonics_[static_cast<size_t>(level)]));
398 }
399
408 void buildLevels(const std::vector<double>& cosAmp, const std::vector<double>& sinAmp,
409 int available)
410 {
411 numMipLevels_ = kMaxMipLevels;
412 mipData_.assign(static_cast<size_t>(numMipLevels_ * kTableSize), T(0));
413 levelHarmonics_.assign(static_cast<size_t>(numMipLevels_), 1);
414
415 FFTReal<double> fft(static_cast<size_t>(kTableSize));
416 std::vector<double> spec(static_cast<size_t>(kTableSize + 2));
417 std::vector<double> cycle(static_cast<size_t>(kTableSize));
418 const double half = 0.5 * static_cast<double>(kTableSize);
419
420 for (int level = 0; level < numMipLevels_; ++level)
421 {
422 const int budget = std::min(kLevelHarmonics[static_cast<size_t>(level)], available);
423 levelHarmonics_[static_cast<size_t>(level)] = std::max(budget, 1);
424
425 // x[n] = sum a_h cos(2 pi h n / N) + b_h sin(2 pi h n / N) has the
426 // one-sided spectrum X[h] = (N/2) (a_h - j b_h) under the inverse
427 // FFT's 1/N scaling.
428 std::fill(spec.begin(), spec.end(), 0.0);
429 for (int h = 1; h <= budget; ++h)
430 {
431 spec[static_cast<size_t>(2 * h)] = half * cosAmp[static_cast<size_t>(h)];
432 spec[static_cast<size_t>(2 * h + 1)] = -half * sinAmp[static_cast<size_t>(h)];
433 }
434 fft.inverse(spec.data(), cycle.data());
435
436 T* dst = &mipData_[static_cast<size_t>(level * kTableSize)];
437 for (int i = 0; i < kTableSize; ++i)
438 dst[i] = static_cast<T>(cycle[static_cast<size_t>(i)]);
439 }
440
441 updateMipCutoffs();
442
443 // Normalise ALL levels with ONE global factor (the peak of the richest
444 // level). Per-level peak normalisation made the level/timbre jump
445 // slightly at every mip crossfade, because the Gibbs overshoot varies
446 // with the harmonic count.
447 normalizeAllLevelsGlobally();
448 updateMipSelection();
449 }
450
459 void normalizeAllLevelsGlobally()
460 {
461 T maxVal = T(0);
462 for (const T v : mipData_)
463 maxVal = std::max(maxVal, std::abs(v));
464
465 if (maxVal > T(0))
466 {
467 const T invMax = T(1) / maxVal;
468 for (T& v : mipData_)
469 v *= invMax;
470 }
471 }
472
476 static constexpr int kMaxHarmonics = kTableSize / 2 - 1;
477
480 static constexpr std::array<int, kMaxMipLevels> kLevelHarmonics = {
481 kMaxHarmonics, 724, 512, 362, 256, 181, 128, 90, 64, 45,
482 32, 22, 16, 11, 8, 5, 4, 3, 2, 1 };
483
484 double sampleRate_ = 48000.0;
485 T frequency_ = T(440);
486
487 Phasor<T> phasor_;
488
489 int numMipLevels_ = 0;
490 // Cache-friendly contiguous flat layout (Level0 + Level1 + ...)
491 std::vector<T> mipData_;
492 std::vector<int> levelHarmonics_;
493 std::vector<T> safeFreq_;
494
495 // Level pair and crossfade weight for the current pitch
496 // (updateMipSelection(); read per sample by readTable()).
497 int selLevel_ = 0;
498 int selNext_ = 0;
499 T selWeight_ = T(1);
500};
501
502} // namespace dspark
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
Mip levels, half an octave apart (harmonic budgets in kLevelHarmonics).
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:37
double sampleRate
Sample rate in Hz.
Definition AudioSpec.h:45