Pulp 1.88.0
Cross-platform audio plugin framework
Loading...
Searching...
No Matches
karplus_strong.hpp
Go to the documentation of this file.
1#pragma once
2
6
7#include <algorithm>
8#include <cmath>
9
10namespace pulp::signal {
11
54template <typename SampleType = float>
56public:
58 void prepare(double sample_rate, double lowest_hz = 20.0) {
59 sample_rate_ = sample_rate > 0.0 ? sample_rate : sample_rate_;
60 const int longest = static_cast<int>(sample_rate_ / std::max(lowest_hz, 1.0)) + 4;
61 loop_.prepare(longest);
62 excitation_.prepare(longest);
63 damping_.prepare(static_cast<SampleType>(sample_rate_));
64 dynamic_.prepare(static_cast<SampleType>(sample_rate_));
65 set_dynamic_bandwidth_hz(dynamic_hz_);
66 update();
67 reset();
68 }
69
70 void set_frequency(double hz) {
71 frequency_ = std::clamp(hz, 20.0, 0.25 * sample_rate_);
72 update();
73 }
74
78 void set_modulated_frequency(double hz) {
79 const double frequency =
80 std::clamp(hz, 20.0, 0.25 * sample_rate_);
81 modulated_loop_length_ = sample_rate_ / frequency;
82 modulated_ = true;
83 }
84
86 void set_decay_seconds(double seconds) {
87 decay_seconds_ = std::max(seconds, 0.01);
88 update();
89 }
90
92 void set_damping(double amount) {
93 damping_amount_ = std::clamp(amount, 0.0, 1.0);
94 update();
95 }
96
98 void set_stiffness(double amount) { stiffness_ = std::clamp(amount, 0.0, 1.0); }
99
101 void set_pluck_position(double position) {
102 pluck_position_ = std::clamp(position, 0.0, 0.5);
103 }
104
110 void set_dynamic_bandwidth_hz(double hz) {
111 dynamic_hz_ = std::clamp(hz, 100.0, 20000.0);
112 dynamic_.set_cutoff(static_cast<SampleType>(
113 std::min(dynamic_hz_, 0.49 * sample_rate_)));
114 }
115
119 void set_pick_direction(double amount) {
120 pick_direction_ = std::clamp(amount, 0.0, 0.99);
121 }
122
123 void reset() {
124 loop_.reset();
125 excitation_.reset();
126 damping_.reset();
127 dynamic_.reset();
128 pick_state_ = 0;
129 tuning_state_ = 0;
130 stiffness_state_ = 0;
131 burst_remaining_ = 0;
132 settling_ = 0;
133 active_ = false;
134 level_ = 0.0;
135 }
136
144 void pluck(bool restart = false) {
145 if (restart) loop_.reset();
146 burst_remaining_ = std::max(1, static_cast<int>(loop_length_));
147 // Nothing comes back out of the loop until a full round trip has
148 // elapsed, so the string is silent for its first period by
149 // construction. Without this window the "has it finished" check below
150 // sees that silence, concludes the note is over, and switches the voice
151 // off one sample before it would have sounded.
152 settling_ = burst_remaining_ + 2;
153 active_ = true;
154 }
155
156 bool is_active() const { return active_; }
157 double level() const { return level_; }
158
161 SampleType process(SampleType excitation) {
162 if (!active_) return SampleType{0};
163
164 // Position comb: the burst minus a delayed copy of itself cancels every
165 // partial with a node at the pluck point.
166 SampleType shaped = excitation;
167 if (burst_remaining_ > 0) {
168 excitation_.push(excitation);
169 if (pluck_position_ > 0.0) {
170 const auto offset =
171 static_cast<SampleType>(pluck_position_ * loop_length_);
172 shaped = excitation - excitation_.read(offset);
173 }
174 --burst_remaining_;
175 } else {
176 shaped = SampleType{0};
177 }
178
179 // Excitation shaping, all outside the loop: pick direction darkens the
180 // attack, and the dynamic-level filter is where playing hard becomes
181 // playing bright.
182 if (pick_direction_ > 0.0) {
183 const auto a = static_cast<SampleType>(pick_direction_);
184 pick_state_ = (SampleType{1} - a) * shaped + a * pick_state_;
185 shaped = pick_state_;
186 }
187 shaped = dynamic_.process_lowpass(shaped);
188
189 SampleType y = modulated_
190 ? loop_.read(
191 static_cast<SampleType>(modulated_loop_length_))
192 : loop_.read(
193 static_cast<SampleType>(integer_delay_));
194 y = damping_.process_lowpass(y);
195
196 if (!modulated_) {
197 // First-order allpass carrying the fractional part of the static
198 // loop length. The variable-delay FM reader above is interpolated
199 // instead so crossing an integer tap cannot zipper.
200 const SampleType out =
201 static_cast<SampleType>(tuning_coefficient_) * y +
202 tuning_state_;
203 tuning_state_ =
204 y - static_cast<SampleType>(tuning_coefficient_) * out;
205 y = out;
206 }
207
208 // First-order allpass: delays low frequencies more than high ones, so
209 // the partials stretch sharp.
210 if (stiffness_ > 0.0) {
211 const auto a = static_cast<SampleType>(-0.9 * stiffness_);
212 const SampleType out = a * y + stiffness_state_;
213 stiffness_state_ = y - a * out;
214 y = out;
215 }
216
217 y = static_cast<SampleType>(loop_gain_) * y;
218 loop_.push(snap_to_zero(shaped + y));
219
220 level_ = std::max(std::fabs(static_cast<double>(y)), level_ * 0.9999);
221 if (settling_ > 0) --settling_;
222 if (settling_ == 0 && burst_remaining_ <= 0 && level_ < 1e-6) active_ = false;
223 return y;
224 }
225
226private:
227 void update() {
228 modulated_ = false;
229 update_tuning(frequency_);
230
231 // The loop runs f0 times a second, so reaching -60 dB after
232 // `decay_seconds` means losing 3/(f0 * decay) decades per trip. Deriving
233 // it this way is what makes a high note decay faster than a low one at
234 // the same setting, as a real string does.
235 loop_gain_ = std::pow(10.0, -3.0 / (frequency_ * decay_seconds_));
236
237 // Damping sweeps the loop's corner geometrically from wide open down to
238 // a few multiples of the fundamental, so the control is useful across
239 // its whole travel rather than only near one end.
240 const double open = std::min(0.45 * sample_rate_, 18000.0);
241 // The closed end sits several multiples above the fundamental rather
242 // than just above it. Any lower and the loop filter's own loss at f0
243 // exceeds the loss the decay time asks for, at which point the decay
244 // control stops being able to hold its setting.
245 const double closed = std::min(frequency_ * 6.0, open);
246 const double corner = open * std::pow(closed / open, damping_amount_);
247 damping_.set_cutoff(static_cast<SampleType>(corner));
248
249 // The damping filter attenuates the fundamental as well as the upper
250 // partials -- only slightly, but the signal goes round the loop
251 // hundreds of times, so a fraction of a decibel per trip compounds into
252 // tens of decibels. Left uncompensated the damping control is mostly a
253 // volume control: a fully damped string measured two hundred times
254 // quieter at its own fundamental. Dividing the loop gain by the
255 // filter's response at f0 leaves the fundamental decaying at the
256 // requested rate and confines damping to what it is named for.
257 //
258 // The clamp is where physics wins: if the filter's own per-trip loss
259 // is already larger than the decay time asks for, no loop gain below
260 // unity can make the note last that long, and it dies faster than
261 // requested. That is what a heavily damped string does.
262 const double ratio = frequency_ / std::max(corner, 1e-9);
263 loop_gain_ = std::min(loop_gain_ * std::sqrt(1.0 + ratio * ratio), 0.9999);
264 }
265
266 void update_tuning(double frequency) {
267 loop_length_ = sample_rate_ / frequency;
268
269 // Split the loop into an integer delay plus an allpass whose delay sits
270 // in [0.1, 1.1) samples. Keeping it off zero is deliberate: at zero the
271 // allpass's pole and zero cancel on the unit circle and its delay is no
272 // longer continuous in the coefficient.
273 constexpr double kMinFractional = 0.1;
274 double fractional = loop_length_ - std::floor(loop_length_);
275 integer_delay_ = std::floor(loop_length_);
276 if (fractional < kMinFractional) {
277 fractional += 1.0;
278 integer_delay_ -= 1.0;
279 }
280 integer_delay_ = std::max(integer_delay_, 1.0);
281 // Delay d of a first-order allpass with coefficient c is (1-c)/(1+c).
282 tuning_coefficient_ = (1.0 - fractional) / (1.0 + fractional);
283 }
284
285 DelayLineT<SampleType> loop_;
286 DelayLineT<SampleType> excitation_;
287 TptFilterT<SampleType> damping_;
288 TptFilterT<SampleType> dynamic_;
289
290 double sample_rate_ = 44100.0;
291 double frequency_ = 220.0;
292 double decay_seconds_ = 2.0;
293 double damping_amount_ = 0.3;
294 double stiffness_ = 0.0;
295 double pluck_position_ = 0.25;
296
297 double loop_length_ = 200.0;
298 double modulated_loop_length_ = 200.0;
299 double integer_delay_ = 200.0;
300 double tuning_coefficient_ = 0.0;
301 double loop_gain_ = 0.99;
302 double dynamic_hz_ = 12000.0;
303 double pick_direction_ = 0.0;
304 bool modulated_ = false;
305 SampleType pick_state_ = 0;
306 SampleType tuning_state_ = 0;
307 SampleType stiffness_state_ = 0;
308 int burst_remaining_ = 0;
309 int settling_ = 0;
310 bool active_ = false;
311 double level_ = 0.0;
312};
313
316
317} // namespace pulp::signal
A plucked string: a delay line fed back through a loss filter.
Definition karplus_strong.hpp:55
void set_stiffness(double amount)
Inharmonicity, 0 (a true harmonic series) to 1 (bar-like stretch).
Definition karplus_strong.hpp:98
void set_pick_direction(double amount)
Direction of the pick, 0 to 1.
Definition karplus_strong.hpp:119
void set_modulated_frequency(double hz)
Uses the delay line's continuously interpolated reader for audio-rate pitch modulation.
Definition karplus_strong.hpp:78
void set_decay_seconds(double seconds)
Time for the note to fall by 60 dB, in seconds.
Definition karplus_strong.hpp:86
void reset()
Definition karplus_strong.hpp:123
SampleType process(SampleType excitation)
Advances one sample.
Definition karplus_strong.hpp:161
void pluck(bool restart=false)
Excites the string.
Definition karplus_strong.hpp:144
void set_dynamic_bandwidth_hz(double hz)
Bandwidth of the excitation, in Hz — how hard the string is played.
Definition karplus_strong.hpp:110
bool is_active() const
Definition karplus_strong.hpp:156
void set_frequency(double hz)
Definition karplus_strong.hpp:70
void set_pluck_position(double position)
Where along the string it is excited, 0 (at the end) to 0.5 (the middle).
Definition karplus_strong.hpp:101
void prepare(double sample_rate, double lowest_hz=20.0)
Sizes the delay line for the lowest note the string will play.
Definition karplus_strong.hpp:58
double level() const
Definition karplus_strong.hpp:157
void set_damping(double amount)
How much faster high partials die than low ones, 0 (bright) to 1 (dark).
Definition karplus_strong.hpp:92
Definition additive_bank.hpp:218
T snap_to_zero(T x)
Returns 0 if |x| < snap_threshold, else x.
Definition denormal.hpp:45
@ ratio
base_frequency_hz * ratio.
Definition fm_operator_engine.hpp:20
KarplusStrongT< float > KarplusStrong
Definition karplus_strong.hpp:314
@ a
Definition formant_filter_bank.hpp:20
@ e
Definition formant_filter_bank.hpp:20
KarplusStrongT< double > KarplusStrong64
Definition karplus_strong.hpp:315
First-order topology-preserving transform (TPT) filter.