Sound Byte Libs 0.5.1-121-g3358a44
C++ firmware library for audio applications on 32-bit ARM Cortex-M processors
Loading...
Searching...
No Matches
polyblep_osc.hpp
Go to the documentation of this file.
1// sbl/dsp/widgets/polyblep_osc.hpp — PolyBLEP oscillator (Audio Stack — Widgets)
2//
3// Band-limited saw, square, triangle, and pulse waveforms using polynomial
4// band-limited step (PolyBLEP) correction at discontinuities. Zero flash
5// cost — no lookup tables needed.
6//
7// Saw/Square/Pulse use 2nd-order PolyBLEP at step discontinuities.
8// Triangle uses 4th-order Integrated PolyBLEP at slope discontinuities,
9// directly band-limiting the naive triangle without a post-filter.
10//
11// Adapted from Mutable Instruments Plaits oscillator.h and Warps
12// oscillator.cc (MIT license, Copyright 2014-2016 Emilie Gillet).
13// PolyBLEP math from stmlib/dsp/polyblep.h.
14//
15// Usage:
16// sbl::dsp::widgets::PolyBlepOsc osc;
17// osc.set_waveform(Waveform::Saw);
18// osc.set_pitch_su(0.5f);
19// osc.process(buf, frames);
20//
21// // With per-sample FM modulation:
22// osc.process(buf, frames, fm_buf, 1.0f); // ±1 octave FM
23
24#ifndef SBL_DSP_WIDGETS_POLYBLEP_OSC_HPP_
25#define SBL_DSP_WIDGETS_POLYBLEP_OSC_HPP_
26
27#include <cstdint>
28
33
34// Forward declaration for friend access from modules layer
35namespace sbl::dsp::modules { template<uint16_t> class MorphOsc; }
36
37namespace sbl::dsp::widgets {
38
39struct PolyBlepOscTestAccess;
40
41enum class Waveform : uint8_t {
42 Saw,
43 Square,
45 Pulse,
46};
47
49public:
50 /// @note All public methods are ISR-safe — bounded computation, no I/O.
51
52 /// Default constructor (Saw waveform).
53 PolyBlepOsc() = default;
54
55 /// Construct with a specific waveform.
56 explicit PolyBlepOsc(Waveform w) : waveform_(w) {}
57
58 // ── Configuration ───────────────────────────────────────────
59
60 /** @brief Set active waveform (discrete — no _su suffix) */
61 void set_waveform(Waveform w) { waveform_ = w; }
62
63 // ── Signal-unit inputs ──────────────────────────────────────
64
65 /**
66 * @brief Set pitch from normalized [0,1] signal unit
67 * @param su Unipolar [0,1] — maps to MIDI 24–120 (C1–C9)
68 */
69 void set_pitch_su(float su) {
70 if (su < 0.0f) su = 0.0f;
71 if (su > 1.0f) su = 1.0f;
72 float note = kMinNote + su * kNoteRange;
73 set_note(note);
74 }
75
76 /**
77 * @brief Set pulse width from normalized [0,1] signal unit
78 * @param su Unipolar [0,1] — linear map to 0.05–0.95
79 */
80 void set_pulse_width_su(float su) {
81 if (su < 0.0f) su = 0.0f;
82 if (su > 1.0f) su = 1.0f;
83 set_pulse_width(0.05f + su * 0.9f);
84 }
85
86 /**
87 * @brief Set output amplitude from normalized [0,1] signal unit
88 * @param su Unipolar [0,1] — linear map to amplitude 0–1
89 */
90 void set_amplitude_su(float su) {
91 if (su < 0.0f) su = 0.0f;
92 if (su > 1.0f) su = 1.0f;
93 set_amplitude(su);
94 }
95
96 /**
97 * @brief Generate audio samples, optionally with per-sample FM modulation
98 *
99 * When fm_mod is provided, per-sample exponential FM is applied around
100 * the stored frequency (set via set_pitch_su / set_frequency):
101 * freq = frequency_ * 2^(fm_mod[i] * fm_depth)
102 *
103 * @param out Output buffer (float, [-1.0, 1.0])
104 * @param frames Number of samples
105 * @param fm_mod FM modulation signal [-1,1] (nullptr = no modulation)
106 * @param fm_depth FM depth in octaves (e.g., 1.0 = ±1 octave)
107 */
108 void process(float* out, uint16_t frames,
109 const float* fm_mod = nullptr, float fm_depth = 0.0f) {
110 if (fm_mod != nullptr) {
111 // Per-sample FM — render one sample at a time
112 float base_freq = frequency_;
113 for (uint16_t i = 0; i < frames; ++i) {
114 float freq = base_freq * math::fast_exp2f(fm_mod[i] * fm_depth);
115 if (freq < 0.0f) freq = 0.0f;
116 frequency_ = freq;
117 render_float(out + i, 1);
118 }
119 frequency_ = base_freq; // Restore base frequency
120 if (amplitude_ != 1.0f) {
121 for (uint16_t i = 0; i < frames; ++i) {
122 out[i] *= amplitude_;
123 }
124 }
125 } else {
126 // No modulation — block render
127 uint16_t pos = 0;
128 while (pos < frames) {
129 uint16_t n = frames - pos;
130 if (n > MAX_BLOCK) n = MAX_BLOCK;
131 render_float(out + pos, n);
132 if (amplitude_ != 1.0f) {
133 for (uint16_t i = 0; i < n; ++i) {
134 out[pos + i] *= amplitude_;
135 }
136 }
137 pos += n;
138 }
139 }
140 }
141
142 /** @brief Hard sync — reset phase to zero */
143 void sync() {
144 phase_ = 0.0f;
145 next_sample_ = 0.0f;
146 high_ = true;
147 }
148
149 /** @brief Reset phase to a specific value (0.0–1.0) */
150 void sync(float phase) {
151 phase_ = phase;
152 next_sample_ = 0.0f;
153 }
154
155 /** @brief Current phase as uint32_t */
156 uint32_t phase() const {
157 return static_cast<uint32_t>(phase_ * 4294967296.0f);
158 }
159
160 /** @brief Current normalized frequency (freq_hz / sample_rate) */
161 float frequency() const { return frequency_; }
162
163private:
165 template<uint16_t> friend class sbl::dsp::modules::MorphOsc;
166
167 // ── Pitch mapping constants ─────────────────────────────────
168 static constexpr float kMinNote = 24.0f; // C1
169 static constexpr float kMaxNote = 120.0f; // C9
170 static constexpr float kNoteRange = kMaxNote - kMinNote; // 96 semitones
171
172 // ── Engineering-unit setters (private — use _su inputs) ─────
173
174 void set_frequency(float freq_hz) {
175 frequency_ = freq_hz / types::SAMPLE_RATE_F;
176 }
177
178 void set_note(float midi_note) {
179 frequency_ = math::note_to_frequency(midi_note) / types::SAMPLE_RATE_F;
180 }
181
182 void set_pulse_width(float pw) {
183 pw_ = (pw < 0.05f) ? 0.05f : (pw > 0.95f) ? 0.95f : pw;
184 }
185
186 void set_amplitude(float amp) { amplitude_ = amp; }
187
188 // ── State ───────────────────────────────────────────────────
189 static constexpr uint16_t MAX_BLOCK = 48;
190
191 float phase_ = 0.0f;
192 float frequency_ = 0.0f;
193 float pw_ = 0.5f;
194 float next_sample_ = 0.0f;
195 bool high_ = true;
196 Waveform waveform_ = Waveform::Saw;
197 float amplitude_ = 1.0f;
198
199 // -------------------------------------------------------------------------
200 // Dispatch to waveform-specific float render
201 // -------------------------------------------------------------------------
202 void render_float(float* out, uint16_t frames) {
203 switch (waveform_) {
204 case Waveform::Saw: render_saw_f(out, frames); break;
205 case Waveform::Square: render_square_f(out, frames, 0.5f); break;
206 case Waveform::Triangle: render_triangle_f(out, frames); break;
207 case Waveform::Pulse: render_square_f(out, frames, pw_); break;
208 }
209 }
210
211 // -------------------------------------------------------------------------
212 // Saw — single discontinuity at phase wrap (Warps lines 141-155)
213 // -------------------------------------------------------------------------
214 void render_saw_f(float* out, uint16_t frames) {
215
216 float phase = phase_; // TODO why aren't we using phase accumulator here?
217 float next = next_sample_;
218 float freq = frequency_;
219 float gain = hf_gain(freq);
220
221 for (uint16_t i = 0; i < frames; ++i) {
222 float this_sample = next;
223 next = 0.0f;
224
225 phase += freq;
226 if (phase >= 1.0f) {
227 phase -= 1.0f;
228 float t = phase / freq;
229 this_sample -= this_blep(t);
230 next -= next_blep(t);
231 }
232 next += phase;
233 this_sample = 2.0f * this_sample - 1.0f;
234
235 out[i] = math::clamp(this_sample, -1.0f, 1.0f) * gain;
236 }
237 phase_ = phase;
238 next_sample_ = next;
239 }
240
241 // -------------------------------------------------------------------------
242 // Square / Pulse — two edges per cycle
243 // -------------------------------------------------------------------------
244 void render_square_f(float* out, uint16_t frames, float pw) {
245
246 float phase = phase_;
247 float next = next_sample_;
248 float freq = frequency_;
249 bool high = high_;
250 float gain = hf_gain(freq);
251
252 float fall_at = pw;
253
254 for (uint16_t i = 0; i < frames; ++i) {
255 float this_sample = next;
256 next = 0.0f;
257
258 phase += freq;
259
260 // Falling edge at duty cycle boundary
261 if (high && phase >= fall_at) {
262 float t = (phase - fall_at) / freq;
263 this_sample -= this_blep(t);
264 next -= next_blep(t);
265 high = false;
266 }
267 // Rising edge at phase wrap
268 if (phase >= 1.0f) {
269 phase -= 1.0f;
270 float t = phase / freq;
271 this_sample += this_blep(t);
272 next += next_blep(t);
273 high = true;
274 }
275
276 next += high ? 1.0f : 0.0f;
277 this_sample = 2.0f * this_sample - 1.0f;
278
279 out[i] = math::clamp(this_sample, -1.0f, 1.0f) * gain;
280 }
281 phase_ = phase;
282 next_sample_ = next;
283 high_ = high;
284 }
285
286 // -------------------------------------------------------------------------
287 // Triangle — Integrated PolyBLEP on slope discontinuities
288 // -------------------------------------------------------------------------
289 void render_triangle_f(float* out, uint16_t frames) {
290
291 float phase = phase_;
292 float next = next_sample_;
293 float freq = frequency_;
294 bool high = high_;
295 float gain = hf_gain(freq);
296
297 constexpr float slope_up = 2.0f;
298 constexpr float slope_down = 2.0f;
299 const float discontinuity = (slope_up + slope_down) * freq;
300
301 for (uint16_t i = 0; i < frames; ++i) {
302 float this_sample = next;
303 next = 0.0f;
304
305 phase += freq;
306
307 // Slope change at midpoint (rising → falling)
308 if (high ^ (phase < 0.5f)) {
309 float t = (phase - 0.5f) / freq;
310 this_sample -= this_integrated_blep(t) * discontinuity;
311 next -= next_integrated_blep(t) * discontinuity;
312 high = phase < 0.5f;
313 }
314 // Slope change at wrap (falling → rising)
315 if (phase >= 1.0f) {
316 phase -= 1.0f;
317 float t = phase / freq;
318 this_sample += this_integrated_blep(t) * discontinuity;
319 next += next_integrated_blep(t) * discontinuity;
320 high = true;
321 }
322
323 // Naive triangle: rises [0, 0.5) then falls [0.5, 1.0)
324 next += high
325 ? phase * slope_up
326 : 1.0f - (phase - 0.5f) * slope_down;
327
328 out[i] = math::clamp(2.0f * this_sample - 1.0f, -1.0f, 1.0f) * gain;
329 }
330 phase_ = phase;
331 next_sample_ = next;
332 high_ = high;
333 }
334
335 // -------------------------------------------------------------------------
336 // High-frequency gain fadeout (FDP-049)
337 //
338 // Smoothly attenuate output as normalized frequency approaches Nyquist.
339 // Instead of hard-clamping frequency (which creates FM sweep discontinuities)
340 // or allowing incoherent above-Nyquist output, the oscillator tracks its
341 // modulator faithfully — it just gets quiet where aliasing lives.
342 //
343 // Smoothstep curve (3t² - 2t³): zero derivative at both boundaries, so
344 // entering and leaving the fade zone creates no spectral splatter from
345 // abrupt amplitude modulation. Analog VCOs fade naturally into silence
346 // above the audio band — this is the digital equivalent.
347 // -------------------------------------------------------------------------
348 static float hf_gain(float freq) {
349 constexpr float FADE_START = 0.40f; // 19.2 kHz at 48 kHz
350 constexpr float FADE_END = 0.48f; // 23.0 kHz at 48 kHz
351 if (freq <= FADE_START) return 1.0f;
352 if (freq >= FADE_END) return 0.0f;
353 // Normalize to [0, 1] within fade range
354 constexpr float INV_RANGE = 1.0f / (FADE_END - FADE_START); // 12.5
355 float t = (freq - FADE_START) * INV_RANGE;
356 // Smoothstep: 1 - (3t² - 2t³) = 1 - t²(3 - 2t)
357 return 1.0f - t * t * (3.0f - 2.0f * t);
358 }
359
360 // -------------------------------------------------------------------------
361 // PolyBLEP correction functions (private implementation)
362 //
363 // Polynomial band-limited step corrections at discontinuities.
364 // Adapted from MI stmlib/dsp/polyblep.h (MIT, Emilie Gillet).
365 // -------------------------------------------------------------------------
366
367 /// 2nd-order step correction — current sample
368 static float this_blep(float t) { return 0.5f * t * t; }
369
370 /// 2nd-order step correction — next sample
371 static float next_blep(float t) { t = 1.0f - t; return -0.5f * t * t; }
372
373 /// 4th-order slope correction — next sample
374 static float next_integrated_blep(float t) {
375 const float t1 = 0.5f * t;
376 const float t2 = t1 * t1;
377 const float t4 = t2 * t2;
378 return 0.1875f - t1 + 1.5f * t2 - t4;
379 }
380
381 /// 4th-order slope correction — current sample
382 static float this_integrated_blep(float t) {
383 return next_integrated_blep(1.0f - t);
384 }
385};
386
387} // namespace sbl::dsp::widgets
388
389#endif // SBL_DSP_WIDGETS_POLYBLEP_OSC_HPP_
Clamping (Cross-cutting — Math)
uint32_t phase() const
Current phase as uint32_t.
void process(float *out, uint16_t frames, const float *fm_mod=nullptr, float fm_depth=0.0f)
Generate audio samples, optionally with per-sample FM modulation.
void sync(float phase)
Reset phase to a specific value (0.0–1.0)
PolyBlepOsc(Waveform w)
Construct with a specific waveform.
float frequency() const
Current normalized frequency (freq_hz / sample_rate)
void set_waveform(Waveform w)
Set active waveform (discrete — no _su suffix)
void set_pitch_su(float su)
Set pitch from normalized [0,1] signal unit.
void sync()
Hard sync — reset phase to zero.
PolyBlepOsc()=default
Default constructor (Saw waveform).
void set_amplitude_su(float su)
Set output amplitude from normalized [0,1] signal unit.
void set_pulse_width_su(float su)
Set pulse width from normalized [0,1] signal unit.
Fast analytical approximations (Audio Stack — Atoms)
Fixed-point constants and audio sample types.
float fast_exp2f(float x)
Definition fast_math.hpp:99
float note_to_frequency(float midi_note)
MIDI note to frequency in Hz. A4 = 440 Hz.
Definition pitch.hpp:58
constexpr float clamp(float x, float lo, float hi)
x held to [lo, hi]; NaN → lo.
Definition clamp.hpp:13
Complete musical tools.
Definition ladder.hpp:59
float SAMPLE_RATE_F
Definition fixed.hpp:17
Black boxes with _su ports.
Definition channel.hpp:49
Semitone-to-frequency-ratio conversion.