DSPark 1.8.0
Header-only C++20 DSP for real-time and offline audio
Loading...
Searching...
No Matches
Oscillator.h
1// DSPark -- Professional Audio DSP Framework
2// Copyright (c) 2026 Cristian Moresi -- MIT License
3
4#pragma once
5
30#include "DspMath.h"
31#include "AudioSpec.h"
32#include "AudioBuffer.h"
33#include "MinBlepTable.h"
34
35#include <cmath>
36#include <algorithm>
37#include <array>
38#include <cassert>
39#include <cstddef>
40#include <type_traits>
41
42namespace dspark {
43
72template <typename T>
74{
75 static_assert(std::is_floating_point_v<T>, "Oscillator requires float or double");
76
77public:
78 enum class Waveform { Sine, Saw, Square, Triangle };
79
81 enum class AntiAliasing
82 {
83 MinBLEP,
85 };
86
96 void prepare(double sampleRate) noexcept
97 {
98 assert(sampleRate > 0.0);
99 sampleRate_ = sampleRate;
100 // Touch the shared minBLEP table here so its one-time FFT build runs
101 // on the control thread, never inside the audio callback.
102 blepDelay_ = MinBlepTable<T>::instance().dcDelay();
103 setFrequency(frequency_);
104 if (!syncOn_) primeMinBlep();
105 }
106
111 void prepare(const AudioSpec& spec) noexcept
112 {
113 prepare(spec.sampleRate);
114 }
115
120 void setFrequency(T freq) noexcept
121 {
122 // Reject NaN before clamp: std::clamp(NaN, ...) returns NaN (all
123 // comparisons false), which would poison phaseInc_ and the phase
124 // accumulator forever. Matches Phasor/WavetableOscillator guards.
125 if (freq != freq) return;
126 // Clamp frequency to valid bounds [0, Nyquist] to prevent aliasing breakdown
127 T nyquist = static_cast<T>(sampleRate_ * 0.5);
128 frequency_ = std::clamp(freq, T(0), nyquist);
129 updatePhaseInc();
130 }
131
136 void setWaveform(Waveform w) noexcept
137 {
138 if (w == waveform_) return;
139 waveform_ = w;
140 // The pending minBLEP tails belong to the previous waveform's edges.
141 if (!syncOn_) primeMinBlep();
142 }
143
154 void setAntiAliasing(AntiAliasing mode) noexcept
155 {
156 if (mode == antiAliasing_) return;
157 antiAliasing_ = mode;
158 if (!syncOn_) primeMinBlep();
159 }
160
162 [[nodiscard]] AntiAliasing getAntiAliasing() const noexcept { return antiAliasing_; }
163
185 void setSyncRatio(T ratio) noexcept
186 {
187 syncRatio_ = std::max(T(0), ratio);
188 updatePhaseInc();
189 }
190
203 void setPhase(T phase) noexcept
204 {
205 // Wrap (not clamp): a phase of exactly 1.0 must land on 0.0 so the
206 // first sample is not a one-off discontinuity.
207 phase_ = phase - std::floor(phase);
208 if (phase_ >= T(1)) phase_ -= T(1);
209
210 if (syncOn_)
211 {
212 reseedSlave();
213 }
214 else
215 {
216 // A forced phase is a new stream: rebuild the pending minBLEP
217 // tails of the edges that stream would already have produced.
218 primeMinBlep();
219 }
220
221 seedTriangle();
222 }
223
230 void reset() noexcept
231 {
232 phase_ = T(0);
233 slavePhase_ = T(0);
234 corr_.fill(T(0));
235 corrHead_ = 0;
236 if (!syncOn_) primeMinBlep();
237 seedTriangle();
238 }
239
246 [[nodiscard]] inline T getNextSample() noexcept
247 {
248 if (syncOn_)
249 return nextSyncSample();
250 if (antiAliasing_ == AntiAliasing::MinBLEP && waveform_ != Waveform::Sine)
251 return nextMinBlepSample();
252
253 T out = T(0);
254
255 switch (waveform_)
256 {
257 case Waveform::Sine:
258 // fastSin: minimax error ~2e-7 in float / 4e-9 in double
259 // (-135 / -168 dB), 3-6x faster than std::sin.
260 out = fastSin(phase_ * twoPi<T>);
261 break;
262
263 case Waveform::Saw:
264 out = T(2) * phase_ - T(1);
265 out -= polyBlep(phase_, phaseInc_);
266 break;
267
268 case Waveform::Square:
269 {
270 T raw = (phase_ < T(0.5)) ? T(1) : T(-1);
271 raw += polyBlep(phase_, phaseInc_);
272
273 // Optimized phase shift without std::fmod
274 T halfPhase = phase_ + T(0.5);
275 if (halfPhase >= T(1)) halfPhase -= T(1);
276
277 raw -= polyBlep(halfPhase, phaseInc_);
278 out = raw;
279 break;
280 }
281
283 {
284 T raw = (phase_ < T(0.5)) ? T(1) : T(-1);
285 raw += polyBlep(phase_, phaseInc_);
286
287 T halfPhase = phase_ + T(0.5);
288 if (halfPhase >= T(1)) halfPhase -= T(1);
289 raw -= polyBlep(halfPhase, phaseInc_);
290
291 // Leaky integration for analog-style triangle. The recursion
292 // runs in double (framework rule for recursive state): with a
293 // float accumulator, slow LFO rates quantise near the peaks.
294 const double inc = static_cast<double>(phaseInc_);
295 triState_ = inc * static_cast<double>(raw) + (1.0 - inc) * triState_;
296 out = static_cast<T>(triState_) * triNorm_;
297 break;
298 }
299 }
300
301 // Fast phase wrap assuming positive frequencies only (enforced in setFrequency)
302 phase_ += phaseInc_;
303 if (phase_ >= T(1)) phase_ -= T(1);
304
305 return out;
306 }
307
318 void processBlock(T* buffer, size_t numSamples) noexcept
319 {
320 for (size_t i = 0; i < numSamples; ++i)
321 {
322 buffer[i] = getNextSample();
323 }
324 }
325
327 [[nodiscard]] inline T getSample() noexcept { return getNextSample(); }
328
334 void generateBlock(AudioBufferView<T> buffer) noexcept
335 {
336 const int nCh = buffer.getNumChannels();
337 const int nS = buffer.getNumSamples();
338 if (nCh <= 0)
339 {
340 for (int i = 0; i < nS; ++i)
341 (void) getNextSample(); // keep the phase advancing
342 return;
343 }
344 T* ch0 = buffer.getChannel(0);
345 for (int i = 0; i < nS; ++i)
346 ch0[i] = getNextSample();
347 for (int ch = 1; ch < nCh; ++ch)
348 std::copy_n(ch0, static_cast<size_t>(nS), buffer.getChannel(ch));
349 }
350
351 [[nodiscard]] T getPhase() const noexcept { return phase_; }
352 [[nodiscard]] T getFrequency() const noexcept { return frequency_; }
353 [[nodiscard]] Waveform getWaveform() const noexcept { return waveform_; }
354 [[nodiscard]] T getSyncRatio() const noexcept { return syncRatio_; }
355
356private:
358 [[nodiscard]] T rawSlaveValue(T ph) const noexcept
359 {
360 switch (waveform_)
361 {
362 case Waveform::Sine: return fastSin(ph * twoPi<T>);
363 case Waveform::Saw: return T(2) * ph - T(1);
364 case Waveform::Square:
365 case Waveform::Triangle: return (ph < T(0.5)) ? T(1) : T(-1);
366 }
367 return T(0);
368 }
369
381 void seedTriangle() noexcept
382 {
383 T ph = syncOn_ ? slavePhase_ : phase_;
384 if (!syncOn_ && antiAliasing_ == AntiAliasing::MinBLEP)
385 {
386 ph -= phaseInc_ * blepDelay_;
387 ph -= std::floor(ph);
388 }
389 const T ideal = (ph < T(0.5)) ? (T(4) * ph - T(1)) : (T(3) - T(4) * ph);
390 triState_ = static_cast<double>(ideal * triExpectedPeak_);
391 }
392
405 void primeMinBlep() noexcept
406 {
407 corr_.fill(T(0));
408 corrHead_ = 0;
409 if (antiAliasing_ != AntiAliasing::MinBLEP || waveform_ == Waveform::Sine
410 || !(phaseInc_ > T(0)))
411 return;
412
413 const T period = T(1) / phaseInc_;
414 const auto primeEdge = [&](T edgePhase, T jump) noexcept {
415 // Samples elapsed since the most recent edge at edgePhase, taken
416 // relative to the sample about to be emitted.
417 T age = (phase_ - edgePhase) * period;
418 if (age < T(0)) age += period;
419 for (; age < static_cast<T>(kCorrLen); age += period)
420 scheduleMinBlep(jump, age);
421 };
422 if (waveform_ == Waveform::Saw)
423 {
424 primeEdge(T(0), T(-2));
425 }
426 else
427 {
428 primeEdge(T(0), T(2));
429 primeEdge(T(0.5), T(-2));
430 }
431 }
432
442 [[nodiscard]] T nextMinBlepSample() noexcept
443 {
444 T raw = rawSlaveValue(phase_) + corr_[static_cast<size_t>(corrHead_)];
445 corr_[static_cast<size_t>(corrHead_)] = T(0);
446 corrHead_ = (corrHead_ + 1) & kCorrMask; // now the NEXT sample's slot
447
448 T out = raw;
449 if (waveform_ == Waveform::Saw)
450 {
451 // Delay the ramp by the minBLEP's own low-frequency delay so the
452 // band-limited jumps and the slope stay time-aligned (see
453 // MinBlepTable::dcDelay): without this the saw carries a DC offset
454 // of 2 * dcDelay * f0 / fs.
455 out -= T(2) * phaseInc_ * blepDelay_;
456 }
457 else if (waveform_ == Waveform::Triangle)
458 {
459 // Leaky integration of the band-limited square (double state:
460 // framework rule for recursive state).
461 const double inc = static_cast<double>(phaseInc_);
462 triState_ = inc * static_cast<double>(raw) + (1.0 - inc) * triState_;
463 out = static_cast<T>(triState_) * triNorm_;
464 }
465
466 const T old = phase_;
467 phase_ += phaseInc_;
468 if (phaseInc_ > T(0))
469 {
470 if (phase_ >= T(1))
471 {
472 // Wrap: saw falls by 2, square/triangle drive rises by 2.
473 const T alpha = (T(1) - old) / phaseInc_;
474 scheduleMinBlep(waveform_ == Waveform::Saw ? T(-2) : T(2), T(1) - alpha);
475 }
476 else if (waveform_ != Waveform::Saw && old < T(0.5) && phase_ >= T(0.5))
477 {
478 const T alpha = (T(0.5) - old) / phaseInc_;
479 scheduleMinBlep(T(-2), T(1) - alpha);
480 }
481 }
482 if (phase_ >= T(1)) phase_ -= T(1);
483 return out;
484 }
485
498 [[nodiscard]] T nextSyncSample() noexcept
499 {
500 const bool squareLike =
501 waveform_ == Waveform::Square || waveform_ == Waveform::Triangle;
502
503 // --- emit: raw value plus the pending band-limiting correction ------
504 T raw = rawSlaveValue(slavePhase_) + corr_[static_cast<size_t>(corrHead_)];
505 corr_[static_cast<size_t>(corrHead_)] = T(0);
506 corrHead_ = (corrHead_ + 1) & kCorrMask; // now the NEXT sample's slot
507
508 T out = raw;
509 if (waveform_ == Waveform::Saw)
510 {
511 // Ramp aligned with the minBLEP delay (see nextMinBlepSample()).
512 out -= T(2) * slaveInc_ * blepDelay_;
513 }
514 else if (waveform_ == Waveform::Triangle)
515 {
516 // Feed the integrator with the duty-compensated square: the synced
517 // square has inherent DC (see updatePhaseInc) and the integrator's
518 // unity DC gain times triNorm_ would turn it into a large offset
519 // (+0.45 measured at ratio 2.7 without compensation).
520 const double inc = static_cast<double>(slaveInc_);
521 triState_ = inc * (static_cast<double>(raw) - static_cast<double>(syncSquareDc_))
522 + (1.0 - inc) * triState_;
523 out = static_cast<T>(triState_) * triNorm_;
524 }
525
526 // --- advance both phases; schedule corrections in time order --------
527 const T masterOld = phase_;
528 const T slaveOld = slavePhase_;
529 phase_ += phaseInc_;
530 slavePhase_ += slaveInc_;
531
532 const bool masterWrap = phase_ >= T(1);
533 const T alphaSync = masterWrap ? (T(1) - masterOld) / phaseInc_ : T(2);
534
535 // Natural slave edge inside this interval (at most one: slaveInc_ is
536 // clamped to 0.5). It only happens if the reset does not pre-empt it;
537 // on an exact tie the edge wins and the reset jump measures zero.
538 if (waveform_ != Waveform::Sine)
539 {
540 T alphaEdge = T(2), edgeJump = T(0);
541 if (slavePhase_ >= T(1))
542 {
543 alphaEdge = (T(1) - slaveOld) / slaveInc_;
544 edgeJump = (waveform_ == Waveform::Saw) ? T(-2) : T(2);
545 }
546 else if (squareLike && slaveOld < T(0.5) && slavePhase_ >= T(0.5))
547 {
548 alphaEdge = (T(0.5) - slaveOld) / slaveInc_;
549 edgeJump = T(-2);
550 }
551 if (alphaEdge <= alphaSync && alphaEdge <= T(1))
552 scheduleMinBlep(edgeJump, T(1) - alphaEdge);
553 }
554 if (slavePhase_ >= T(1)) slavePhase_ -= T(1);
555
556 if (masterWrap)
557 {
558 // Slave value the instant before the reset (wrap folded in); the
559 // jump lands on the freshly seeded phase-0 value.
560 T atJump = slaveOld + alphaSync * slaveInc_;
561 atJump -= std::floor(atJump);
562 const T jump = rawSlaveValue(T(0)) - rawSlaveValue(atJump);
563 if (jump != T(0))
564 scheduleMinBlep(jump, T(1) - alphaSync);
565
566 phase_ -= T(1);
567 slavePhase_ = (phase_ / std::max(phaseInc_, T(1e-12))) * slaveInc_;
568 slavePhase_ -= std::floor(slavePhase_);
569 }
570 return out;
571 }
572
580 void scheduleMinBlep(T jump, T frac) noexcept
581 {
582 const auto& table = MinBlepTable<T>::instance();
583 for (int j = 0; j < kCorrLen; ++j)
584 corr_[static_cast<size_t>((corrHead_ + j) & kCorrMask)] +=
585 jump * table.residual(static_cast<T>(j) + frac);
586 }
587
597 void reseedSlave() noexcept
598 {
599 const T r = slaveInc_ / std::max(phaseInc_, T(1e-12));
600 slavePhase_ = phase_ * r;
601 slavePhase_ -= std::floor(slavePhase_);
602 corr_.fill(T(0));
603 corrHead_ = 0;
604 }
605
613 static inline T polyBlep(T phase, T inc) noexcept
614 {
615 if (inc < T(1e-10)) return T(0);
616
617 if (phase < inc)
618 {
619 T t = phase / inc;
620 return t + t - t * t - T(1);
621 }
622 else if (phase > T(1) - inc)
623 {
624 T t = (phase - T(1)) / inc;
625 return t * t + t + t + T(1);
626 }
627 return T(0);
628 }
629
631 void updatePhaseInc() noexcept
632 {
633 // Defensive Nyquist clamp. setFrequency() already clamps, but prepare()
634 // can lower the sample rate afterwards (it re-clamps too), and the
635 // T-cast Nyquist bound may round up by an ulp; polyBlep() and the
636 // sync event logic both assume inc <= 0.5.
637 phaseInc_ = std::min(frequency_ / static_cast<T>(sampleRate_), T(0.5));
638 const bool wasOn = syncOn_;
639 syncOn_ = syncRatio_ > T(1.001) && phaseInc_ > T(0);
640 // Clamp the slave below Nyquist like any oscillator frequency.
641 slaveInc_ = std::min(phaseInc_ * syncRatio_, T(0.5));
642
643 // DC of the hard-synced square (feeds the triangle integrator). The
644 // slave always restarts in its +1 half, so its duty cycle is
645 // asymmetric by construction: for an effective ratio r with
646 // fractional part f, the +1 time per master cycle exceeds the -1
647 // time by min(f, 0.5) - max(f - 0.5, 0) slave cycles.
648 syncSquareDc_ = T(0);
649 if (syncOn_ && phaseInc_ > T(0))
650 {
651 const T r = slaveInc_ / phaseInc_; // effective (clamped) ratio
652 const T f = r - std::floor(r);
653 syncSquareDc_ = (std::min(f, T(0.5)) - std::max(f - T(0.5), T(0))) / r;
654 }
655
656 // Sync just (re-)engaged: the ring may hold corrections from a
657 // previous sync run and the slave phase is stale -- re-seed both.
658 if (syncOn_ && !wasOn)
659 reseedSlave();
660
661 updateTriNorm();
662 }
663
667 void updateTriNorm() noexcept
668 {
669 // Under hard sync the integrator runs at the slave rate.
670 const T inc = syncOn_ ? slaveInc_ : phaseInc_;
671 if (inc == triNormInc_)
672 return; // unchanged increment: skip the std::pow below. Chorus
673 // and Phaser call setFrequency() every block, and FM
674 // callers may call it every sample.
675 triNormInc_ = inc;
676 if (inc > T(0) && inc < T(1))
677 {
678 // Steady-state peak of the leaky integrator driven by a +-1 square:
679 // over a half period of n samples starting at -p, the state reaches
680 // p = q*(-p) + (1 - q) with q = leak^n, so p = (1 - q) / (1 + q).
681 // (Using just 1 - q under-normalised the triangle by ~4 dB.)
682 T leakCoeff = T(1) - inc;
683 T halfPeriodSamples = T(0.5) / inc;
684 T q = std::pow(leakCoeff, halfPeriodSamples);
685 T expectedPeak = (T(1) - q) / (T(1) + q);
686 triExpectedPeak_ = expectedPeak;
687 triNorm_ = (expectedPeak > T(0.001)) ? T(1) / expectedPeak : T(4);
688 }
689 else
690 {
691 triExpectedPeak_ = T(0.25);
692 triNorm_ = T(4);
693 }
694 }
695
696 double sampleRate_ = 48000.0;
697 T frequency_ = T(440);
698 T phase_ = T(0);
699 T phaseInc_ = T(0);
700 double triState_ = 0.0;
701 T triNorm_ = T(4);
702 T triExpectedPeak_ = T(0.25);
703 T triNormInc_ = T(-1);
704 Waveform waveform_ = Waveform::Sine;
705 AntiAliasing antiAliasing_ = AntiAliasing::MinBLEP;
706 T blepDelay_ = T(0);
707
708 // Hard sync (slave) and minBLEP correction state.
709 static constexpr int kCorrLen = MinBlepTable<T>::kTaps;
710 static constexpr int kCorrMask = kCorrLen - 1;
711 static_assert((kCorrLen & kCorrMask) == 0, "minBLEP ring needs a power-of-two span");
712
713 bool syncOn_ = false;
714 T syncRatio_ = T(0);
715 T slavePhase_ = T(0);
716 T slaveInc_ = T(0);
717 T syncSquareDc_ = T(0);
718 std::array<T, kCorrLen> corr_{};
719 int corrHead_ = 0;
720};
721
722} // namespace dspark
Non-owning view over audio channel data.
Definition AudioBuffer.h:50
T dcDelay() const noexcept
Low-frequency delay of the minimum-phase step, in samples.
static const MinBlepTable & instance() noexcept
Returns the process-wide shared table, building it on first call.
static constexpr int kTaps
Correction span in base-rate samples (power of two, ring-buffer friendly).
Band-limited oscillator featuring PolyBLEP anti-aliasing and analog-modeled integration.
Definition Oscillator.h:74
AntiAliasing
Discontinuity correction used when hard sync is off.
Definition Oscillator.h:82
@ MinBLEP
Minimum-phase band-limited step table (default; audio duty).
@ PolyBLEP
2-point polynomial step (no overshoot; LFO duty).
void setSyncRatio(T ratio) noexcept
Enables band-limited hard sync.
Definition Oscillator.h:185
Waveform getWaveform() const noexcept
Definition Oscillator.h:353
T getSyncRatio() const noexcept
Definition Oscillator.h:354
void generateBlock(AudioBufferView< T > buffer) noexcept
Fills every channel of the view with the generated waveform. Satisfies the GeneratorProcessor concept...
Definition Oscillator.h:334
void processBlock(T *buffer, size_t numSamples) noexcept
Fills a buffer with generated samples.
Definition Oscillator.h:318
void reset() noexcept
Hard-resets the oscillator phase and integrator state.
Definition Oscillator.h:230
void setFrequency(T freq) noexcept
Sets the oscillator's fundamental frequency.
Definition Oscillator.h:120
void prepare(double sampleRate) noexcept
Prepares the oscillator with the system sample rate.
Definition Oscillator.h:96
T getPhase() const noexcept
Definition Oscillator.h:351
void setPhase(T phase) noexcept
Forces the oscillator phase to a specific value.
Definition Oscillator.h:203
AntiAliasing getAntiAliasing() const noexcept
Returns the discontinuity correction in use (see setAntiAliasing()).
Definition Oscillator.h:162
void prepare(const AudioSpec &spec) noexcept
Prepares the oscillator from an AudioSpec configuration.
Definition Oscillator.h:111
void setAntiAliasing(AntiAliasing mode) noexcept
Selects the discontinuity correction for the non-synced waveforms.
Definition Oscillator.h:154
T getNextSample() noexcept
Computes and returns the next single audio sample.
Definition Oscillator.h:246
T getSample() noexcept
Generator contract alias for getNextSample() (GeneratorProcessor).
Definition Oscillator.h:327
void setWaveform(Waveform w) noexcept
Changes the active waveform.
Definition Oscillator.h:136
T getFrequency() const noexcept
Definition Oscillator.h:352
Main namespace for the DSPark framework.
T fastSin(T x) noexcept
Fast sine approximation (degree-9 odd minimax polynomial).
Definition DspMath.h:248
Describes the audio environment for a DSP processor.
Definition AudioSpec.h:37