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
string_loop.hpp
Go to the documentation of this file.
1// sbl/dsp/pm/string_loop.hpp — A string as two waveguide segments (Physical modeling — composition)
2//
3// The excitation point is the junction between two segments: one running to
4// the nut, one to the bridge. Each delay line folds the round trip of its own
5// segment, so their lengths sum to the period (STK's convention):
6//
7// d_nut = T * beta d_bridge = T * (1 - beta)
8//
9// AP-032 Phase 1 settled this the expensive way: a single loop with a tap
10// cannot be bowed. DC is a fixed point there — the string converges on the
11// bow velocity and sticks. Two segments with inverting reflections at both
12// ends are what gives Helmholtz motion: the injection enters at the junction
13// and travels both ways; the nut segment returns it inverted; the bridge
14// segment passes through the allpass and the loss filter, then the bridge's
15// reflection, and returns it to the junction. The wiring as compiled is a
16// describe() away — `sbl-diagram waveguide-loop` (AP-037).
17//
18// Per sample the exciter is handed the junction's state — the velocity
19// arriving there, the point's displacement, and the loop's wave impedance —
20// and returns the force it exerts; the loop turns the force into the wave
21// it injects, F / 2Z. Energy passes both ways at the point (a bow does work,
22// a finger damps, a hammer bounces). A callable of one float, the velocity,
23// is accepted too, for tests and plucks that need nothing else.
24//
25// The displacement is the point's velocity integrated, with a slow leak so a
26// bowed string's small DC does not walk it off (DISPLACEMENT_LEAK_HZ). It is
27// in wave units × samples; a displacement law scales it as it likes.
28//
29// The bridge end has the same shape (AP-036): a second callable is handed the
30// wave arriving at the bridge, after the loop's loss and allpass, and returns
31// the reflection. Without one the bridge is a rigid wall, reflection −1. A
32// LoadJunction with a lumped load makes it a body.
33//
34// ## Tuning
35//
36// The delay elements sum to the period: the integer parts go in the delay
37// lines and the leftover fraction goes to the Thiran allpass, so the loop
38// tunes between samples rather than to the nearest one. A segment is never
39// shorter than one sample; when the split would make one shorter, the other
40// gives up the difference so the total still holds.
41//
42// The loss filter's delay is not taken out of the period, so the loop plays
43// flat by it — more so the darker the damping and the shorter the string.
44// That is deliberate for now (AP-032 addendum 2026-09-12): the string is
45// heard as it is before anything is added to correct it.
46//
47// ## Energy
48//
49// Both reflections are -1 and the loss stage has gain at or below unity, so
50// the loop is passive with no excitation: it can only lose energy, never
51// make it (RPT-029 Part 4 rule 1). The single source is the injection the
52// caller returns. The loop gain is the passivity margin — energy leaving
53// into the body at the bridge — and a value below 1 is what makes a plucked
54// string decay.
55//
56// Usage:
57// loop.init(nut_buf, 1024, bridge_buf, 1024);
58// loop.set_period_samples(sample_rate / hz);
59// loop.set_junction(0.3f); // bow position along the string
60// float out = loop.tick([&](float v_string) {
61// return bow.process(bow_velocity - v_string);
62// });
63// // with a body:
64// float out = loop.tick(bow_fn, [&](float arriving) { return bridge.reflect(arriving); });
65
66#ifndef SBL_DSP_PM_STRING_LOOP_HPP_
67#define SBL_DSP_PM_STRING_LOOP_HPP_
68
69#include <cmath>
70#include <cstdint>
71#include <type_traits>
72
81
82namespace sbl::dsp::pm {
83
85public:
86 /// @note All public methods are ISR-safe — bounded computation, no I/O.
87
88 /// Shortest loop: eight samples, 6 kHz at 48 kHz. Pitch requests above it play this.
89 static constexpr float MIN_PERIOD = 8.0f;
90 /// The loop's wave impedance unless told otherwise: the unit the laws and loads are written in.
91 static constexpr float DEFAULT_IMPEDANCE = 1.0f;
92 /// The junction displacement forgets DC below this: a leaky integrator.
93 static constexpr float DISPLACEMENT_LEAK_HZ = 5.0f;
94
95 /**
96 * @brief Install the two segments' buffers
97 *
98 * Each buffer bounds its own segment, so the longest period depends on
99 * the junction: the nut segment needs `period * beta` samples and the
100 * bridge segment the rest. A segment asked for more than its buffer is
101 * clamped to the buffer, and the loop then plays sharp of the request;
102 * `delay_a_samples()` / `delay_b_samples()` report the lengths
103 * actually read, so a caller can tell.
104 */
105 void init(float* a_buffer, uint32_t a_max,
106 float* b_buffer, uint32_t b_max) {
107 end_a_.init(a_buffer, a_max);
108 end_b_.init(b_buffer, b_max);
109 // The delay line reads up to max - 1; the last slot is the write.
110 limit_a_ = static_cast<float>(a_max > 1 ? a_max - 1 : 1);
111 limit_b_ = static_cast<float>(b_max > 1 ? b_max - 1 : 1);
112 loss_.set_range(DAMPING_MIN_HZ, DAMPING_MAX_HZ);
113 loss_.set_cutoff_su(DEFAULT_DAMPING_SU);
114 retune();
115 }
116
117 /// Loop period in samples — the pitch. Sample rate over frequency.
118 void set_period_samples(float period) {
119 period_ = period > MIN_PERIOD ? period : MIN_PERIOD;
120 retune();
121 }
122
123 /// Excitation point along the string, 0 at end a, 1 at end b (a string: nut and bridge).
124 void set_junction(float beta) {
125 // A junction at an end is not a junction; NaN lands on the edge.
126 beta_ = math::clamp(beta, JUNCTION_EDGE, 1.0f - JUNCTION_EDGE);
127 retune();
128 }
129
130 /// Loss filter cutoff: how fast the high partials give up. Signal units.
131 void set_damping_su(float su) {
132 loss_.set_cutoff_su(math::clamp01(su)); // NaN → darkest
133 }
134
135 /// The wave impedance Z the junction reports and converts forces with.
136 void set_impedance(float z) { impedance_ = z > 0.0f ? z : DEFAULT_IMPEDANCE; }
137 float impedance() const { return impedance_; }
138
139 /// The junction's displacement right now (wave units × samples).
140 float displacement() const { return x_; }
141
142 /**
143 * @brief Round-trip gain, the passivity margin
144 *
145 * Below 1 the loop decays; at 1 it is lossless and only the damping
146 * filter removes energy. Clamped at 1 so the loop can never generate.
147 */
148 void set_loop_gain(float gain) {
149 gain_ = math::clamp01(gain); // NaN → silent
150 }
151
152 /**
153 * @brief Advance one sample
154 *
155 * @param excite an Exciter (excite(const JunctionState&) → force), or a
156 * callable of the arriving velocity returning the wave to
157 * inject
158 * @param end_b called with the wave arriving at end b (after the loop's
159 * loss and allpass); returns the reflection
160 * @return the wave arriving at end b — the pickup
161 */
162 template<typename Excite, typename EndB>
163 float tick(Excite&& excite, EndB&& end_b) {
164 static_assert(is_wave_callable_v<EndB>, "the end takes a wave and returns one: float(float)");
165 const float to_b = end_b_.read(d_b_);
166 const float to_a = end_a_.read(d_a_);
167
168 // End a is a hard reflection. Leg b carries the loss and the
169 // fractional tuning, then whatever end b sends back.
170 const float from_a = -to_a;
171 const float arriving = loss_.process(allpass_.process(to_b));
172 const float from_b = gain_ * end_b(arriving);
173
174 const float v_free = from_a + from_b;
175 const float injection = inject(static_cast<Excite&&>(excite), v_free);
176
177 end_a_.write(from_b + injection);
178 end_b_.write(from_a + injection);
179 x_ = leak_ * x_ + (v_free + injection); // the point moved by its velocity this sample
180 return to_b;
181 }
182
183 /// Advance one sample against a rigid end b: reflection −1.
184 template<typename Excite>
185 float tick(Excite&& excite) {
186 return tick(static_cast<Excite&&>(excite), rigid_end);
187 }
188
189 /// Advance one sample with nothing injected: the string rings on alone.
190 float tick() {
191 return tick([](float) { return 0.0f; }, rigid_end);
192 }
193
194 /// An end with nothing on it: a wall.
195 static float rigid_end(float arriving) { return -arriving; }
196
197 /// What the exciter sees this sample, for a scope.
198 JunctionState junction_state(float v_free) const { return JunctionState{v_free, x_, impedance_}; }
199
200 float period_samples() const { return period_; }
201 float junction() const { return beta_; }
202
203 /// Segment lengths actually read — the split the junction makes, clamped to the buffers.
204 float delay_a_samples() const { return d_a_; }
205 float delay_b_samples() const { return d_b_; }
206
207 /// The allpass's share of the period: its nominal sample plus the rounding residual.
208 float allpass_delay_samples() const { return allpass_.group_delay_samples(); }
209
210 /**
211 * @brief Energy in the two segments, in wave units: the sum of squares of
212 * every sample in flight (physical-modeling.md §5.1, §8.3)
213 *
214 * The loss filter's and allpass's one-sample states are left out; they
215 * are a sample each against hundreds in the lines. Never called from
216 * audio code.
217 */
218 float energy() const {
219 float e = 0.0f;
220 const uint32_t n_a = static_cast<uint32_t>(d_a_);
221 const uint32_t n_b = static_cast<uint32_t>(d_b_);
222 for (uint32_t i = 1; i <= n_a; ++i) { const float v = end_a_.read(static_cast<float>(i)); e += v * v; }
223 for (uint32_t i = 1; i <= n_b; ++i) { const float v = end_b_.read(static_cast<float>(i)); e += v * v; }
224 return e;
225 }
226
227 void reset() {
228 end_a_.reset();
229 end_b_.reset();
230 loss_.reset();
231 allpass_.reset();
232 x_ = 0.0f;
233 leak_ = 1.0f - math::TWO_PI * DISPLACEMENT_LEAK_HZ / types::SAMPLE_RATE_F; // rate-derived, at reset
234 }
235
236 /**
237 * @brief The loop's wiring as data (AP-037). Never called from audio code.
238 *
239 * @param external_end_b true when an owner supplies the bridge callable:
240 * the wall is left out and the named ports "to end b" and "from
241 * end b" are the two ends the owner's termination connects to. `in` is the excitation
242 * junction; `out` is the pickup (the wave arriving at the bridge).
243 */
245 bool external_end_b = false) const {
246 using diagram::Kind;
247 using diagram::Wave;
249 p.group = g.add_group("StringLoop", parent);
250
251 const uint8_t junction = g.add_node(Kind::Junction, "excitation", p.group);
252 const uint8_t seg_a = g.add_node(Kind::DelayLine, "segment a", p.group, d_a_);
253 const uint8_t wall_a = g.add_node(Kind::Reflection, "end a", p.group, -1.0f);
254 const uint8_t seg_b = g.add_node(Kind::DelayLine, "segment b", p.group, d_b_);
255 const uint8_t allpass = g.add_node(Kind::Allpass, "tuning", p.group,
256 allpass_.group_delay_samples());
257 const uint8_t loss = g.add_node(Kind::Loss, "loss", p.group, loss_cutoff_hz());
258
259 g.add_edge(junction, seg_a, Wave::Velocity);
260 g.add_edge(seg_a, wall_a, Wave::Velocity);
261 g.add_edge(wall_a, junction, Wave::Velocity);
262 g.add_edge(junction, seg_b, Wave::Velocity);
263 g.add_edge(seg_b, allpass, Wave::Velocity);
264 g.add_edge(allpass, loss, Wave::Velocity);
265
266 if (external_end_b) {
267 p.add("to end b", loss);
268 p.add("from end b", junction);
269 } else {
270 const uint8_t wall = g.add_node(Kind::Reflection, "end b", p.group, -1.0f);
271 g.add_edge(loss, wall, Wave::Velocity);
272 g.add_edge(wall, junction, Wave::Velocity);
273 }
274 p.in = junction;
275 p.out = seg_b; // the pickup reads the wave arriving at the bridge
276 return p;
277 }
278
279private:
280 using DelayLine = sbl::dsp::primitives::DelayLine;
281 using OnePole = sbl::dsp::primitives::OnePole;
283
284 static constexpr float DAMPING_MIN_HZ = 500.0f;
285 static constexpr float DAMPING_MAX_HZ = 16000.0f;
286 static constexpr float DEFAULT_DAMPING_SU = 0.8f;
287 static constexpr float DEFAULT_PERIOD = 100.0f;
288 static constexpr float DEFAULT_JUNCTION = 0.3f;
289 static constexpr float DEFAULT_LOOP_GAIN = 0.995f;
290 static constexpr float JUNCTION_EDGE = 0.02f;
291 static constexpr float ALLPASS_NOMINAL = 1.0f; ///< Thiran<1> sits at 1 sample
292 static constexpr float MIN_SEGMENT = 1.0f; ///< a delay line reads at least one sample back
293 static constexpr float HALF_SAMPLE = 0.5f; ///< the allpass tunes within this of nominal
294 static constexpr float TWO = 2.0f;
295
296 /// The loss filter's cutoff back in Hz from its coefficient: c = 1 − e^(−2πf/fs).
297 float loss_cutoff_hz() const {
298 const float c = loss_.coefficient();
299 if (c <= 0.0f || c >= 1.0f) return 0.0f;
300 return -std::log(1.0f - c) * sbl::dsp::types::SAMPLE_RATE_F / math::TWO_PI;
301 }
302
303 /// The exciter's force becomes the wave the loop injects, F / 2Z; a bare
304 /// callable of the velocity returns the wave itself.
305 template<typename Excite>
306 float inject(Excite&& excite, float v_free) {
307 if constexpr (is_exciter_v<std::decay_t<Excite>>) {
308 return excite.excite(JunctionState{v_free, x_, impedance_}) / (TWO * impedance_);
309 } else {
310 static_assert(is_wave_callable_v<Excite>, "excite takes the junction state or the velocity");
311 return excite(v_free);
312 }
313 }
314
315 static float round_to_sample(float x) {
316 return static_cast<float>(static_cast<int32_t>(x + HALF_SAMPLE));
317 }
318
319 static float clamp_segment(float d, float limit) {
320 if (d < MIN_SEGMENT) return MIN_SEGMENT;
321 if (d > limit) return limit;
322 return d;
323 }
324
325 /**
326 * @brief Split the period between the segments; the remainder tunes the allpass
327 *
328 * The nut segment is rounded and clamped first, the bridge segment takes
329 * what is left and is rounded and clamped in turn, and whatever fraction
330 * remains goes to the allpass — so a clamp on either side comes out of
331 * the other, not out of the total. The allpass can only absorb half a
332 * sample either way; past that the bridge segment moves by one.
333 */
334 void retune() {
335 const float total = period_ - ALLPASS_NOMINAL;
336
337 float a = clamp_segment(round_to_sample(total * beta_), limit_a_);
338 float b = clamp_segment(round_to_sample(total - a), limit_b_);
339
340 // Whichever segment can still move takes the whole sample; segment
341 // b first, segment a when b is pinned at an end.
342 float residual = total - a - b;
343 if (residual > HALF_SAMPLE) {
344 if (b < limit_b_) { b += 1.0f; residual -= 1.0f; }
345 else if (a < limit_a_) { a += 1.0f; residual -= 1.0f; }
346 } else if (residual < -HALF_SAMPLE) {
347 if (b > MIN_SEGMENT) { b -= 1.0f; residual += 1.0f; }
348 else if (a > MIN_SEGMENT) { a -= 1.0f; residual += 1.0f; }
349 }
350
351 d_a_ = a;
352 d_b_ = b;
353 allpass_.set_delay(ALLPASS_NOMINAL + residual); // clamps to ±half a sample
354 }
355
356 DelayLine end_a_;
357 DelayLine end_b_;
358 OnePole loss_;
359 Allpass allpass_;
360
361 float period_ = DEFAULT_PERIOD;
362 float beta_ = DEFAULT_JUNCTION;
363 float gain_ = DEFAULT_LOOP_GAIN;
364 float limit_a_ = 1.0f;
365 float limit_b_ = 1.0f;
366 float d_a_ = MIN_SEGMENT;
367 float d_b_ = MIN_SEGMENT;
368 float impedance_ = DEFAULT_IMPEDANCE;
369 float x_ = 0.0f; ///< junction displacement
370 float leak_ = 1.0f; ///< set at reset from the sample rate
371};
372
373} // namespace sbl::dsp::pm
374
375#endif // SBL_DSP_PM_STRING_LOOP_HPP_
Clamping (Cross-cutting — Math)
uint8_t add_group(const char *name, uint8_t parent=NO_GROUP)
Open a group (a component); returns its id. Nodes added with it belong to it.
Definition graph.hpp:75
void add_edge(uint8_t from, uint8_t to, Wave wave)
Definition graph.hpp:88
uint8_t add_node(Kind kind, const char *name, uint8_t group, float value=0.0f, float value2=0.0f)
Definition graph.hpp:81
void set_damping_su(float su)
Loss filter cutoff: how fast the high partials give up. Signal units.
float tick()
Advance one sample with nothing injected: the string rings on alone.
float delay_b_samples() const
float tick(Excite &&excite, EndB &&end_b)
Advance one sample.
static constexpr float DEFAULT_IMPEDANCE
The loop's wave impedance unless told otherwise: the unit the laws and loads are written in.
JunctionState junction_state(float v_free) const
What the exciter sees this sample, for a scope.
void set_junction(float beta)
Excitation point along the string, 0 at end a, 1 at end b (a string: nut and bridge).
void init(float *a_buffer, uint32_t a_max, float *b_buffer, uint32_t b_max)
Install the two segments' buffers.
float tick(Excite &&excite)
Advance one sample against a rigid end b: reflection −1.
float energy() const
Energy in the two segments, in wave units: the sum of squares of every sample in flight (physical-mod...
static float rigid_end(float arriving)
An end with nothing on it: a wall.
void set_loop_gain(float gain)
Round-trip gain, the passivity margin.
void set_period_samples(float period)
Loop period in samples — the pitch. Sample rate over frequency.
float allpass_delay_samples() const
The allpass's share of the period: its nominal sample plus the rounding residual.
diagram::Ports describe(diagram::Graph &g, uint8_t parent=diagram::NO_GROUP, bool external_end_b=false) const
The loop's wiring as data (AP-037). Never called from audio code.
float delay_a_samples() const
Segment lengths actually read — the split the junction makes, clamped to the buffers.
static constexpr float DISPLACEMENT_LEAK_HZ
The junction displacement forgets DC below this: a leaky integrator.
void set_impedance(float z)
The wave impedance Z the junction reports and converts forces with.
float displacement() const
The junction's displacement right now (wave units × samples).
static constexpr float MIN_PERIOD
Shortest loop: eight samples, 6 kHz at 48 kHz. Pitch requests above it play this.
float read(float delay_samples) const
Read at fractional delay with linear interpolation.
void write(float sample)
Write a sample to the delay line.
void init(float *buffer, uint32_t max_delay)
Initialize after default construction.
void reset()
Zero all samples in the buffer and reset write position.
void reset()
Reset filter state to zero.
Definition one_pole.hpp:103
void set_range(float min_hz, float max_hz)
Set frequency range for _su mapping (configuration, not a signal)
Definition one_pole.hpp:51
float process(float x)
Process a single sample.
Definition one_pole.hpp:80
float coefficient() const
Current coefficient.
Definition one_pole.hpp:100
void set_cutoff_su(float su)
Set cutoff frequency via signal unit.
Definition one_pole.hpp:64
Order-N Thiran allpass: fractional delay by phase, not interpolation.
float process(float x)
Process one sample.
void set_delay(float delay_samples)
Set the total phase delay in samples.
float group_delay_samples() const
Phase delay the filter is tuned to, in samples (flat at DC by design).
The numbers every layer reaches for.
What a load, an exciter, a termination and a friction law provide (Physical modeling — cross-cutting)
Circular buffer delay line.
Fixed-point constants and audio sample types.
A model's wiring, as data (AP-037)
Wave
What travels along an edge.
Definition graph.hpp:44
Kind
What a node is, in the paper's vocabulary.
Definition graph.hpp:26
constexpr uint8_t NO_GROUP
Definition graph.hpp:46
constexpr float clamp01(float x)
x held to [0, 1]; NaN → 0.
Definition clamp.hpp:18
constexpr float clamp(float x, float lo, float hi)
x held to [lo, hi]; NaN → lo.
Definition clamp.hpp:13
constexpr float TWO_PI
Definition constants.hpp:12
Physical modeling: laws, bows, junctions, loads, resonators, strings (docs/conventions/physical-model...
Definition bow.hpp:31
float SAMPLE_RATE_F
Definition fixed.hpp:17
Single-pole IIR filters (LP and HP)
The ports a component exposes after describing itself, so an owner can wire them.
Definition graph.hpp:130
void add(const char *name, uint8_t node)
Add a named port; a ninth is refused and find() answers NO_NODE for it.
Definition graph.hpp:147
uint8_t in
where a wave enters (the junction, the load)
Definition graph.hpp:133
uint8_t out
where a wave leaves (the pickup, the reflection)
Definition graph.hpp:134
What the guide reports at an excitation point, each sample.
Definition contracts.hpp:43
Maximally-flat group-delay allpass.