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
shaper.hpp
Go to the documentation of this file.
1// sbl/dsp/widgets/shaper.hpp — Timbre-sculpting processor (Audio Stack — Widgets)
2//
3// Opinionated waveshaping processor for the "West Coast position" in a voice
4// signal chain — between oscillator and filter. Composes bias, ADAA wavefold,
5// warm saturation, output compensation, and dry/wet mix into a single widget.
6//
7// Inspired by the Make Noise 0-Coast Overtone circuit: a single depth knob
8// sweeps continuously from pure fundamental through progressive harmonic
9// enrichment to full-spectrum overtone saturation.
10//
11// Internal 2x oversampling around fold + WarmSaturator together.
12// Linear interpolation upsample, [0.25, 0.5, 0.25] symmetric FIR decimation.
13// Both nonlinearities run at 2x rate to suppress aliasing from harmonic
14// generation.
15//
16// Signal flow:
17// input -> [+ bias] -> [* depth] -> 2x { WavefoldAA -> WarmSaturator }
18// -> [.25,.5,.25] decimate -> [* compensation] -> mix(dry, wet) -> output
19//
20// Signal Unit API (FDP-052):
21// Widgets expose normalized [0,1] inputs via _su() methods. The widget
22// owns internal mapping and parameter conditioning. Depth and bias use
23// ParameterInterpolator (per-sample linear ramp — correct for nonlinear
24// params). Drive and mix use OnePole smoothing.
25//
26// Usage (new — signal units):
27// sbl::dsp::widgets::Shaper shaper;
28// shaper.set_depth_su(0.3f); // [0,1] → 1.0–10.0 (linear)
29// shaper.set_drive_su(0.2f); // [0,1] → 1.0–4.0 (linear)
30// shaper.process(buf, frames); // per-sample smoothed parameters
31
32#ifndef SBL_DSP_WIDGETS_SHAPER_HPP_
33#define SBL_DSP_WIDGETS_SHAPER_HPP_
34
35#include <cmath>
36#include <cstdint>
37
43
44namespace sbl::dsp::widgets {
45
46class Shaper {
47public:
48 /// @note All public methods are ISR-safe — bounded computation, no I/O.
49
50 // === Parameter range constants (for test helpers) ===
51 static constexpr float kMinDepth = 1.0f;
52 static constexpr float kMaxDepth = 10.0f;
53 static constexpr float kMinBias = -0.1f;
54 static constexpr float kMaxBias = 0.1f;
55 static constexpr float kMinDrive = 1.0f;
56 static constexpr float kMaxDrive = 4.0f;
57
58 Shaper() = default;
59
60 // === Signal Unit API (FDP-052) ===
61
62 /**
63 * @brief Set fold depth from normalized [0,1] signal unit
64 * @param su Unipolar [0,1] — linearly mapped to 1.0–10.0
65 *
66 * Depth uses ParameterInterpolator (per-sample linear ramp) inside
67 * process(), not exponential smoothing. This is correct for nonlinear
68 * parameters where the first-sample spike from exponential smoothers
69 * would cause audible clicks in the wavefolder.
70 */
71 void set_depth_su(float su) {
72 if (su < 0.0f) su = 0.0f;
73 if (su > 1.0f) su = 1.0f;
74 float d = kMinDepth + su * (kMaxDepth - kMinDepth);
75 target_depth_ = d;
76 if (!depth_init_) {
77 depth_ = d;
78 depth_init_ = true;
79 }
80 }
81
82 /**
83 * @brief Set asymmetric bias from normalized [0,1] signal unit
84 * @param su Unipolar [0,1] — linearly mapped to -0.1–0.1
85 *
86 * 0.5 = no bias (symmetric, odd harmonics only).
87 * 0.0 = negative bias (-0.1), 1.0 = positive bias (+0.1).
88 * Uses ParameterInterpolator internally (same rationale as depth).
89 */
90 void set_bias_su(float su) {
91 if (su < 0.0f) su = 0.0f;
92 if (su > 1.0f) su = 1.0f;
93 float b = kMinBias + su * (kMaxBias - kMinBias);
94 target_bias_ = b;
95 if (!bias_init_) {
96 bias_ = b;
97 bias_init_ = true;
98 }
99 }
100
101 /**
102 * @brief Set saturation drive from normalized [0,1] signal unit
103 * @param su Unipolar [0,1] — linearly mapped to 1.0–4.0
104 *
105 * Smoothed via OnePole (20ms timbral settling).
106 */
107 void set_drive_su(float su) {
108 if (su < 0.0f) su = 0.0f;
109 if (su > 1.0f) su = 1.0f;
110 float drive = kMinDrive + su * (kMaxDrive - kMinDrive);
111 target_drive_ = drive;
112 ensure_smoothers();
113 if (!drive_init_) {
114 op_drive_.reset(drive);
115 drive_ = drive;
116 drive_init_ = true;
117 }
118 }
119
120 /**
121 * @brief Set dry/wet mix from normalized [0,1] signal unit
122 * @param su Unipolar [0,1] — 0 = dry, 1 = full wet
123 *
124 * Smoothed via OnePole (15ms gain settling).
125 */
126 void set_mix_su(float su) {
127 if (su < 0.0f) su = 0.0f;
128 if (su > 1.0f) su = 1.0f;
129 target_mix_ = su;
130 ensure_smoothers();
131 if (!mix_init_) {
132 op_mix_.reset(su);
133 mix_ = su;
134 mix_init_ = true;
135 }
136 }
137
138 /// Output level compensation: when true (default), output level stays
139 /// roughly constant as depth increases. When false, higher depth = more
140 /// level into downstream.
141 void set_compensate(bool compensate) { compensate_ = compensate; }
142
143 // --- Processing --------------------------------------------------------
144
145 /// Process block in-place (mono).
146 /// Per-sample ParameterInterpolator ramp for depth and bias.
147 /// OnePole smoothing for drive and mix.
148 /// When depth_mod is provided, additive depth modulation is applied
149 /// per-sample on top of the ParameterInterpolator ramp.
150 ///
151 /// @param buf Float audio buffer (modified in-place)
152 /// @param frames Number of samples
153 /// @param depth_mod Depth modulation signal [-1,1] (nullptr = no modulation)
154 /// @param mod_depth Modulation range in fold-depth units (e.g., 3.0 = ±3)
155 void process(float* buf, uint16_t frames,
156 const float* depth_mod = nullptr, float mod_depth = 0.0f) {
157 ensure_smoothers();
158 float drive_target = target_drive_;
159 float mix_target = target_mix_;
160
161 // Per-sample linear ramp for depth and bias
162 primitives::ParameterInterpolator depth_ramp(&depth_, target_depth_, frames);
163 primitives::ParameterInterpolator bias_ramp(&bias_, target_bias_, frames);
164
165 for (uint16_t i = 0; i < frames; ++i) {
166 float d = depth_ramp.next();
167 if (depth_mod != nullptr) {
168 d += depth_mod[i] * mod_depth;
169 if (d < 0.0f) d = 0.0f;
170 }
171 float bias = bias_ramp.next();
172 float drive = op_drive_.process(drive_target);
173 float mix = op_mix_.process(mix_target);
174
175 float dry = buf[i];
176 float x = dry + bias;
177
178 if (d <= 1.0f) {
179 // No folding — just bias + saturate
180 x = saturator_.process(x, drive);
181 } else {
182 float x_scaled = x * d;
183
184 // 2x oversample: linear interpolation upsample
185 float x_mid = (os_prev_ + x_scaled) * 0.5f;
186 os_prev_ = x_scaled;
187
188 // Process fold + saturate at 2x rate
189 float y0 = saturator_.process(fold_.process(x_mid), drive);
190 float y1 = saturator_.process(fold_.process(x_scaled), drive);
191
192 // [0.25, 0.5, 0.25] symmetric FIR decimation
193 x = 0.25f * decim_prev_ + 0.5f * y0 + 0.25f * y1;
194 decim_prev_ = y1;
195
196 if (compensate_) {
197 x *= compensation_gain(d);
198 }
199 }
200
201 buf[i] = dry + (x - dry) * mix;
202 }
203
204 // Sync cached state from smoothers
205 drive_ = op_drive_.value();
206 mix_ = op_mix_.value();
207 }
208
209 /// Reset internal state (WavefoldAA history + oversampling)
210 void reset() {
211 fold_.reset();
212 os_prev_ = 0.0f;
213 decim_prev_ = 0.0f;
214 }
215
216private:
217 // Test-only access to engineering-unit setters
218 friend struct ShaperTestAccess;
219
220 /// Set fold depth directly — immediate, no smoothing (private)
221 void set_depth(float depth) {
222 depth_ = (depth < 0.0f) ? 0.0f : depth;
223 target_depth_ = depth_;
224 depth_init_ = true;
225 }
226
227 /// Set bias directly — immediate, no smoothing (private)
228 void set_bias(float bias) {
229 bias_ = bias;
230 target_bias_ = bias;
231 bias_init_ = true;
232 }
233
234 /// Set drive directly — immediate, no smoothing (private)
235 void set_drive(float drive) {
236 drive_ = (drive < 1.0f) ? 1.0f : drive;
237 target_drive_ = drive_;
238 op_drive_.reset(drive_);
239 drive_init_ = true;
240 }
241
242 /// Set mix directly — immediate, no smoothing (private)
243 void set_mix(float mix) {
244 if (mix < 0.0f) mix = 0.0f;
245 if (mix > 1.0f) mix = 1.0f;
246 mix_ = mix;
247 target_mix_ = mix;
248 op_mix_.reset(mix);
249 mix_init_ = true;
250 }
251
252 void ensure_smoothers() {
253 if (!smoothers_configured_) {
254 op_drive_.set_time_ms(20.0f, types::SAMPLE_RATE_F);
255 op_mix_.set_time_ms(15.0f, types::SAMPLE_RATE_F);
256 op_drive_.reset(target_drive_);
257 op_mix_.reset(target_mix_);
258 saturator_.set_asymmetry(0.1f);
259 smoothers_configured_ = true;
260 }
261 }
262
263 comp::WavefoldAA fold_;
264 comp::WarmSaturator saturator_;
265 float depth_ = 1.0f;
266 float bias_ = 0.03f;
267 float drive_ = 1.3f;
268 float mix_ = 1.0f;
269 bool compensate_ = true;
270 float os_prev_ = 0.0f; // Previous scaled input for 2x upsample
271 float decim_prev_ = 0.0f; // Previous oversampled output for FIR decimation
272
273 // ParameterSmoother for drive and mix (FDP-052)
274 primitives::ParameterSmoother op_drive_;
275 primitives::ParameterSmoother op_mix_;
276 float target_depth_ = 1.0f;
277 float target_bias_ = 0.03f;
278 float target_drive_ = 1.3f;
279 float target_mix_ = 1.0f;
280 bool depth_init_ = false;
281 bool bias_init_ = false;
282 bool drive_init_ = false;
283 bool mix_init_ = false;
284 bool smoothers_configured_ = false;
285
286 /// Inverse square root compensation — keeps output level roughly
287 /// constant as fold depth increases.
288 static float compensation_gain(float depth) {
289 return (depth > 1.0f) ? 1.0f / sqrtf(depth) : 1.0f;
290 }
291};
292
293} // namespace sbl::dsp::widgets
294
295#endif // SBL_DSP_WIDGETS_SHAPER_HPP_
void set_asymmetry(float k)
Set asymmetry depth (structural character parameter)
float process(float x, float drive)
Process a single sample with per-sample drive.
float process(float x)
Process a single sample (pre-multiplied by depth)
void set_time_ms(float ms, float rate_hz)
Compute coefficient from settling time in milliseconds.
float process(float target)
Process one sample toward target.
float value() const
Current smoothed value.
void reset()
Reset internal state (WavefoldAA history + oversampling)
Definition shaper.hpp:210
void set_compensate(bool compensate)
Definition shaper.hpp:141
void set_bias_su(float su)
Set asymmetric bias from normalized [0,1] signal unit.
Definition shaper.hpp:90
void set_mix_su(float su)
Set dry/wet mix from normalized [0,1] signal unit.
Definition shaper.hpp:126
void set_drive_su(float su)
Set saturation drive from normalized [0,1] signal unit.
Definition shaper.hpp:107
static constexpr float kMinDrive
Definition shaper.hpp:55
void set_depth_su(float su)
Set fold depth from normalized [0,1] signal unit.
Definition shaper.hpp:71
friend struct ShaperTestAccess
Definition shaper.hpp:218
static constexpr float kMinBias
Definition shaper.hpp:53
void process(float *buf, uint16_t frames, const float *depth_mod=nullptr, float mod_depth=0.0f)
Definition shaper.hpp:155
static constexpr float kMinDepth
Definition shaper.hpp:51
static constexpr float kMaxDrive
Definition shaper.hpp:56
static constexpr float kMaxBias
Definition shaper.hpp:54
static constexpr float kMaxDepth
Definition shaper.hpp:52
Fixed-point constants and audio sample types.
float SAMPLE_RATE_F
Definition fixed.hpp:17
Black boxes with _su ports.
Definition channel.hpp:49
RAII parameter interpolator.
One-pole parameter smoother.
Warm saturation composition.
ADAA Wavefolder (Audio Stack — Compositions)