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
morph_osc.hpp
Go to the documentation of this file.
1// sbl/dsp/modules/morph_osc.hpp — Morphable PolyBLEP oscillator (Audio Stack — Modules)
2//
3// Continuously morphs between three band-limited waveforms using
4// equal-power crossfade across three dedicated PolyBLEP oscillators.
5// Each oscillator runs its own waveform at all times — no waveform
6// swaps means no stale PolyBLEP state, no phase discontinuities.
7//
8// Default morph map (thin → fat):
9// Triangle (0.0) → Pulse (0.5) → Saw (1.0)
10//
11// The morph knob is a synthesis philosophy dial:
12// CCW = West Coast (triangle through wavefolder = Buchla)
13// Noon = bridge (pulse = transition zone)
14// CW = East Coast (saw through filter = Moog)
15//
16// Pulse width affects the pulse oscillator across the morph range.
17//
18// AnalogDrift provides organic character — each oscillator drifts
19// independently, creating subtle beating and movement when multiple
20// voices are active in the crossfade region.
21//
22// Wavefold is deliberately excluded — it's a waveshape-domain operation
23// that belongs in a separate widget (Shaper). Compose them:
24// MorphOsc → Shaper → Ladder → Delay → Reverb
25//
26// See FDP-034 for design rationale.
27//
28// Usage:
29// sbl::dsp::modules::MorphOsc osc;
30// osc.set_pitch_su(0.5f); // mid-range pitch
31// osc.set_morph_su(0.3f); // between triangle and pulse
32// osc.set_pulse_width_su(0.5f); // centered
33// osc.process(buf, frames);
34//
35// // With per-sample FM modulation:
36// osc.process(buf, n, fm_buf, 1.0f); // ±1 octave FM
37
38#ifndef SBL_DSP_MODULES_MORPH_OSC_HPP_
39#define SBL_DSP_MODULES_MORPH_OSC_HPP_
40
41#include <cstdint>
42#include <cmath>
43
49
50namespace sbl::dsp::modules {
51
52struct MorphOscTestAccess;
53
54/// Morphable PolyBLEP oscillator with three dedicated voices.
55///
56/// @tparam MaxFrames Maximum block size for internal scratch buffers.
57/// Default 48 matches standard DMA block size.
58template <uint16_t MaxFrames = 48>
59class MorphOsc {
60 // ---- Parameter range constants ----
61 static constexpr float kMinNote = 24.0f; // C1 (~32.7 Hz)
62 static constexpr float kMaxNote = 120.0f; // C9 (~8372 Hz)
63 static constexpr float kNoteRange = kMaxNote - kMinNote; // 96 semitones
64 static constexpr float kMaxDriftSemitones = 0.10f; // ±10 cents max
65 static constexpr float kMinPW = 0.05f;
66 static constexpr float kMaxPW = 0.95f;
67
68public:
69 /// @note All public methods are ISR-safe — bounded computation, no I/O.
70
71 // ---- Signal-unit inputs ----
72
73 /// Set pitch from normalized signal. [0,1] → C1–C9 (MIDI 24–120).
74 void set_pitch_su(float su) {
75 su = (su < 0.0f) ? 0.0f : (su > 1.0f) ? 1.0f : su;
76 float note = math::note_of_su(su);
77 set_frequency(math::note_to_frequency(note));
78 }
79
80 /// Set morph position from normalized signal. [0,1] → triangle→pulse→saw.
81 void set_morph_su(float su) {
82 set_morph(su);
83 }
84
85 /// Set pulse width from normalized signal. [0,1] → [0.05, 0.95].
86 void set_pulse_width_su(float su) {
87 su = (su < 0.0f) ? 0.0f : (su > 1.0f) ? 1.0f : su;
88 set_pulse_width(kMinPW + su * (kMaxPW - kMinPW));
89 }
90
91 /// Set analog drift amount from normalized signal. [0,1] → [0, 0.10] semitones.
92 void set_drift_amount_su(float su) {
93 su = (su < 0.0f) ? 0.0f : (su > 1.0f) ? 1.0f : su;
94 set_drift_amount(su * kMaxDriftSemitones);
95 }
96
97 /// Set output amplitude from normalized signal. [0,1] → [0, 1].
98 void set_amplitude_su(float su) {
99 su = (su < 0.0f) ? 0.0f : (su > 1.0f) ? 1.0f : su;
100 set_amplitude(su);
101 }
102
103 // ---- Non-signal configuration (kept public) ----
104
105 /// Set detune offset in cents (±1200). Applied as a frequency ratio.
106 void set_detune_cents(float cents) { detune_cents_ = cents; }
107
108 /// Set drift control rate in Hz. Call once at init.
109 void set_drift_rate(float hz) {
110 drift_pulse_.set_rate(hz);
111 drift_tri_.set_rate(hz);
112 drift_saw_.set_rate(hz);
113 }
114
115 /// Advance drift state by one control tick. Call at control rate (~200 Hz).
116 /// The ratios are applied internally on the next process() call.
117 void tick_drift() {
118 if (drift_amount_ > 0.0f) {
119 drift_ratio_pulse_ = math::semitones_to_ratio(
120 drift_pulse_.process(drift_amount_));
121 drift_ratio_tri_ = math::semitones_to_ratio(
122 drift_tri_.process(drift_amount_));
123 drift_ratio_saw_ = math::semitones_to_ratio(
124 drift_saw_.process(drift_amount_));
125 } else {
126 drift_ratio_pulse_ = 1.0f;
127 drift_ratio_tri_ = 1.0f;
128 drift_ratio_saw_ = 1.0f;
129 }
130 }
131
132 /// Reset all oscillator phases and drift state to zero.
133 void reset() {
134 pulse_osc_.sync();
135 tri_osc_.sync();
136 saw_osc_.sync();
137 drift_pulse_.reset();
138 drift_tri_.reset();
139 drift_saw_.reset();
140 drift_ratio_pulse_ = 1.0f;
141 drift_ratio_tri_ = 1.0f;
142 drift_ratio_saw_ = 1.0f;
143 }
144
145 /// Render block, optionally with per-sample FM modulation.
146 ///
147 /// When fm_mod is provided, per-sample exponential FM is applied around
148 /// the stored frequency (set via set_pitch_su / set_frequency):
149 /// freq = apply_detune(freq_hz_) * 2^(fm_mod[i] * fm_depth)
150 ///
151 /// @param out Output buffer
152 /// @param frames Number of samples
153 /// @param fm_mod FM modulation signal [-1,1] (nullptr = no modulation)
154 /// @param fm_depth FM depth in octaves (e.g., 1.0 = ±1 octave)
155 void process(float* out, uint16_t frames,
156 const float* fm_mod = nullptr, float fm_depth = 0.0f) {
157 uint16_t n = (frames > MaxFrames) ? MaxFrames : frames;
158
159 if (fm_mod != nullptr) {
160 // Per-sample FM modulation
161 float base = apply_detune(freq_hz_);
162 float g_tri, g_pulse, g_saw;
163 compute_gains(g_tri, g_pulse, g_saw);
164 float comp = compute_morph_compensation();
165
166 for (uint16_t i = 0; i < n; ++i) {
167 float freq = base * math::fast_exp2f(fm_mod[i] * fm_depth);
168 if (freq < 0.1f) freq = 0.1f;
169
170 if (g_tri > 0.0f) {
171 tri_osc_.set_frequency(freq * drift_ratio_tri_);
172 tri_osc_.process(buf_tri_ + i, 1);
173 }
174 if (g_pulse > 0.0f) {
175 pulse_osc_.set_frequency(freq * drift_ratio_pulse_);
176 pulse_osc_.process(buf_pulse_ + i, 1);
177 }
178 if (g_saw > 0.0f) {
179 saw_osc_.set_frequency(freq * drift_ratio_saw_);
180 saw_osc_.process(buf_saw_ + i, 1);
181 }
182 }
183
184 // Mix with computed gains + morph compensation
185 for (uint16_t i = 0; i < n; ++i) {
186 float s = 0.0f;
187 if (g_tri > 0.0f) s += buf_tri_[i] * g_tri;
188 if (g_pulse > 0.0f) s += buf_pulse_[i] * g_pulse;
189 if (g_saw > 0.0f) s += buf_saw_[i] * g_saw;
190 s *= comp;
191 if (s > 1.0f) s = 1.0f;
192 else if (s < -1.0f) s = -1.0f;
193 out[i] = s;
194 }
195 } else {
196 // No modulation — block render at current frequency
197 float freq = apply_detune(freq_hz_);
198 pulse_osc_.set_frequency(freq * drift_ratio_pulse_);
199 tri_osc_.set_frequency(freq * drift_ratio_tri_);
200 saw_osc_.set_frequency(freq * drift_ratio_saw_);
201
202 render_morphed(out, n);
203 }
204 }
205
206private:
207 // ---- Engineering-unit setters (private, used by _su methods and tests) ----
208
209 /// Set base frequency in Hz.
210 void set_frequency(float hz) { freq_hz_ = hz; }
211
212 /// Set morph position [0.0, 1.0].
213 void set_morph(float morph) {
214 morph_ = (morph < 0.0f) ? 0.0f : (morph > 1.0f) ? 1.0f : morph;
215 }
216
217 /// Set pulse width [0.05, 0.95]. Affects the pulse oscillator.
218 void set_pulse_width(float pw) {
219 pulse_osc_.set_pulse_width(pw);
220 }
221
222 /// Set analog drift amount in semitones.
223 void set_drift_amount(float semitones) { drift_amount_ = semitones; }
224
225 /// Set output amplitude (0.0 = silence, 1.0 = full scale).
226 void set_amplitude(float amp) {
227 pulse_osc_.set_amplitude(amp);
228 tri_osc_.set_amplitude(amp);
229 saw_osc_.set_amplitude(amp);
230 }
231
232 friend struct MorphOscTestAccess;
233
234 // Three dedicated oscillators — waveforms set at construction, never change.
238
239 float freq_hz_ = 220.0f;
240 float morph_ = 0.0f;
241 float detune_cents_ = 0.0f;
242
243 // Analog drift — independent per oscillator for three-way beating.
244 // Seeds chosen to be coprime and spread across the state space.
245 comp::AnalogDrift drift_pulse_{0x12345678u};
246 comp::AnalogDrift drift_tri_{0x9ABCDEF0u};
247 comp::AnalogDrift drift_saw_{0xDEADBEEFu};
248 float drift_amount_ = 0.0f;
249 float drift_ratio_pulse_ = 1.0f;
250 float drift_ratio_tri_ = 1.0f;
251 float drift_ratio_saw_ = 1.0f;
252
253 // Internal scratch buffers for three-osc render
254 float buf_pulse_[MaxFrames];
255 float buf_tri_[MaxFrames];
256 float buf_saw_[MaxFrames];
257
258 /// Apply detune offset to a base frequency.
259 float apply_detune(float hz) const {
260 if (detune_cents_ == 0.0f) return hz;
261 return hz * math::semitones_to_ratio(detune_cents_ / 100.0f);
262 }
263
264 /// Compute per-oscillator gains from morph position.
265 /// Ordering: Triangle (0.0) → Pulse (0.5) → Saw (1.0)
266 void compute_gains(float& g_tri, float& g_pulse, float& g_saw) {
267 if (morph_ <= 0.5f) {
268 float t = morph_ * 2.0f;
269 g_tri = sqrtf(1.0f - t);
270 g_pulse = sqrtf(t);
271 g_saw = 0.0f;
272 } else {
273 float t = (morph_ - 0.5f) * 2.0f;
274 g_tri = 0.0f;
275 g_pulse = sqrtf(1.0f - t);
276 g_saw = sqrtf(t);
277 }
278 }
279
280 /// Post-mix gain compensation for equal perceived loudness across morph.
281 /// Pulse (RMS=1.0) is louder than tri/saw (RMS=1/√3). Without compensation,
282 /// sweeping through the pulse region creates a volume hump. This normalizes
283 /// the expected RMS to match the tri/saw level at all morph positions.
284 float compute_morph_compensation() const {
285 static constexpr float kTriSawRms2 = 1.0f / 3.0f;
286 static constexpr float kPulseRms2 = 1.0f;
287 float rms2;
288 if (morph_ <= 0.5f) {
289 float t = morph_ * 2.0f;
290 rms2 = (1.0f - t) * kTriSawRms2 + t * kPulseRms2;
291 } else {
292 float t = (morph_ - 0.5f) * 2.0f;
293 rms2 = (1.0f - t) * kPulseRms2 + t * kTriSawRms2;
294 }
295 return sqrtf(kTriSawRms2 / rms2);
296 }
297
298 /// Render all active oscillators and mix with morph gains.
299 void render_morphed(float* out, uint16_t n) {
300 float g_tri, g_pulse, g_saw;
301 compute_gains(g_tri, g_pulse, g_saw);
302 float comp = compute_morph_compensation();
303
304 if (g_tri > 0.0f) tri_osc_.process(buf_tri_, n);
305 if (g_pulse > 0.0f) pulse_osc_.process(buf_pulse_, n);
306 if (g_saw > 0.0f) saw_osc_.process(buf_saw_, n);
307
308 for (uint16_t i = 0; i < n; ++i) {
309 float s = 0.0f;
310 if (g_tri > 0.0f) s += buf_tri_[i] * g_tri;
311 if (g_pulse > 0.0f) s += buf_pulse_[i] * g_pulse;
312 if (g_saw > 0.0f) s += buf_saw_[i] * g_saw;
313 s *= comp;
314 if (s > 1.0f) s = 1.0f;
315 else if (s < -1.0f) s = -1.0f;
316 out[i] = s;
317 }
318 }
319
320};
321
322} // namespace sbl::dsp::modules
323
324#endif // SBL_DSP_MODULES_MORPH_OSC_HPP_
Analog oscillator drift simulation.
void set_rate(float control_rate_hz)
void reset()
Reset drift state to zero.
float process(float amount)
friend struct MorphOscTestAccess
void set_detune_cents(float cents)
Set detune offset in cents (±1200). Applied as a frequency ratio.
void set_drift_amount_su(float su)
Set analog drift amount from normalized signal. [0,1] → [0, 0.10] semitones.
Definition morph_osc.hpp:92
void set_pitch_su(float su)
Set pitch from normalized signal. [0,1] → C1–C9 (MIDI 24–120).
Definition morph_osc.hpp:74
void set_pulse_width_su(float su)
Set pulse width from normalized signal. [0,1] → [0.05, 0.95].
Definition morph_osc.hpp:86
void process(float *out, uint16_t frames, const float *fm_mod=nullptr, float fm_depth=0.0f)
void set_amplitude_su(float su)
Set output amplitude from normalized signal. [0,1] → [0, 1].
Definition morph_osc.hpp:98
void set_drift_rate(float hz)
Set drift control rate in Hz. Call once at init.
void reset()
Reset all oscillator phases and drift state to zero.
void set_morph_su(float su)
Set morph position from normalized signal. [0,1] → triangle→pulse→saw.
Definition morph_osc.hpp:81
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()
Hard sync — reset phase to zero.
Fixed-point constants and audio sample types.
Interpolation and blending utilities.
constexpr float note_of_su(float su)
Definition pitch.hpp:75
float fast_exp2f(float x)
Definition fast_math.hpp:99
float semitones_to_ratio(float semitones)
Definition pitch.hpp:28
float note_to_frequency(float midi_note)
MIDI note to frequency in Hz. A4 = 440 Hz.
Definition pitch.hpp:58
Complete musical tools.
Definition ladder.hpp:59
Semitone-to-frequency-ratio conversion.
PolyBLEP oscillator (Audio Stack — Widgets)