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_base.hpp
Go to the documentation of this file.
1// sbl/dsp/pm/string_base.hpp — What every string widget is made of (Physical modeling — widget base)
2//
3// A StringLoop between an exciter and a load, with the ports a string owns —
4// pitch, position, damping, and what is heard — smoothed and applied at
5// block rate; the non-finite guard; the pickup mix ramp; reset and describe.
6// The exciter and the load are taken by reference, the way LoadJunction
7// takes its load: the string knows what it is played with and terminated on
8// only through the contracts (RPT-036; Michael, 2026-09-17: a base class for
9// strings). A concrete string — BowedString — is this plus a name and the
10// wiring it wants to draw.
11//
12// Every port is a signal unit and the widget owns the mapping. All ports are
13// smoothed at block rate inside process(), advanced by the block length,
14// never by an assumed call rate (ADR-011). Setters only store, so a setter
15// and process() can run in different contexts without a half-updated loop.
16//
17// ## Pitch
18//
19// set_pitch_su maps as math::note_of_su: 0..1 across C1..C9. The loss filter's
20// delay is not compensated, so the string plays somewhat flat, more so when
21// dark and high (StringLoop, AP-032 addendum 2026-09-12). The loop's shortest
22// period is StringLoop::MIN_PERIOD, so the top of the range plays that.
23//
24// ## End b: a load, or a wall (AP-036)
25//
26// With the load in use the loop's end b is a LoadJunction scattering the
27// string against it — a resonator, one mode or many — so energy leaks into
28// it near its resonances and the string's pitch is pulled by them. Without
29// (use_load(false)) end b is a rigid wall and the string is the bare one.
30//
31// ## Output
32//
33// With the load in use, the output is a crossfade between two taps, set by
34// the pickup port: 0 is what the load radiates, 1 is the string's velocity
35// arriving at end b, the bare string. The mix only chooses what is heard.
36// Neither tap is held to [-1, 1]; level and DC are the application's.
37//
38// ## Safety
39//
40// A non-finite sample anywhere in a block resets the string rather than
41// recirculating forever (BlockGuard). The exciter's own limiter is the
42// exciter's business (Bow).
43
44#ifndef SBL_DSP_PM_STRING_BASE_HPP_
45#define SBL_DSP_PM_STRING_BASE_HPP_
46
47#include <cmath>
48#include <cstdint>
49#include <initializer_list>
50
61
62namespace sbl::dsp::pm {
63
64/**
65 * @tparam Exciter what plays the string (contracts.hpp: Exciter)
66 * @tparam Load what end b terminates on (contracts.hpp: Load)
67 * @tparam MaxPeriod longest loop in samples, which sets the lowest pitch.
68 * The default covers C1 at 48 kHz (a 1469-sample period); each of
69 * the two segments gets a buffer this size. The widget owns its
70 * buffers: it is the black box, the loop inside it is not.
71 *
72 * @note All public methods are ISR-safe — bounded computation, no I/O.
73 */
74template<typename Exciter, typename Load, uint16_t MaxPeriod = 1536>
76 static_assert(MaxPeriod >= 64, "a string needs room to ring");
77 static_assert(is_exciter_v<Exciter>, "the string is played by an Exciter (physical-modeling.md §4.2)");
78 static_assert(is_load_v<Load>, "end b terminates on a Load (physical-modeling.md §4.1)");
79
80public:
81 // Port defaults: C4, a junction that plays the fundamental.
82 static constexpr float DEFAULT_PITCH_SU = 0.375f;
83 static constexpr float DEFAULT_POSITION_SU = 0.556f; ///< junction 0.3
84 static constexpr float DEFAULT_DAMPING_SU = 0.6f;
85 static constexpr float DEFAULT_PICKUP_SU = 0.0f; ///< all load
86
87 StringBase(Exciter& exciter, Load& load, const char* name = "String")
88 : name_(name), exciter_(exciter), load_(load), end_b_(load) {
89 loop_.init(buf_a_, MaxPeriod, buf_b_, MaxPeriod);
95 reset();
96 }
97
98 // ─── Structural parameters ───────────────────────────────────────
99
100 /// End b terminates on the load (true) or on a wall (false). The junction starts at rest.
101 void use_load(bool on) {
102 loaded_ = on;
103 end_b_.reset();
104 }
105 bool loaded() const { return loaded_; }
106
107 /// The parts, for whoever owns them and for tools.
108 Exciter& exciter() { return exciter_; }
109 Load& load() { return load_; }
110 const StringLoop& loop() const { return loop_; }
111 const LoadJunction<Load>& end_b() const { return end_b_; }
112
113 // ─── Ports ───────────────────────────────────────────────────────
114
115 /// Pitch across C1..C9 (math::note_of_su). Smoothed.
116 void set_pitch_su(float su) { pitch_.set(math::clamp01(su)); }
117
118 /**
119 * @brief Where the exciter sits along the string
120 *
121 * 0 is near an end, 1 is the middle. Positions mirror about the middle,
122 * so this spans the distinct ones. Near an end the string locks onto
123 * upper modes — an octave or more up — which is the real instrument's
124 * behaviour, not a fault (AP-032 Phase 1 finding 3). Smoothed.
125 */
127
128 /// Loss filter cutoff: how fast the high partials give up. Smoothed.
129 void set_damping_su(float su) { damping_.set(math::clamp01(su)); }
130
131 /**
132 * @brief What is heard: 0 the load, 1 the bare string, a crossfade between
133 *
134 * Smoothed and ramped across each block, so turning it never steps. It
135 * changes the output only, not the string. Without the load it has no effect.
136 */
137 void set_pickup_su(float su) { pickup_.set(math::clamp01(su)); }
138
139 // ─── Processing ──────────────────────────────────────────────────
140
141 void process(float* out, uint16_t n) {
142 if (n == 0) return;
143
144 exciter_.advance(n);
148 mix_.begin(pickup_.advance(n), n);
149
150 guard_.begin();
151 if (loaded_) {
152 for (uint16_t i = 0; i < n; ++i) {
153 const float at_b = loop_.tick(exciter_, [&](float arriving) { return end_b_.reflect(arriving); });
154 const float mix = mix_.next();
155 const float radiated = load_.radiated();
156 out[i] = radiated + mix * (at_b - radiated);
157 guard_.add(out[i] + end_b_.load_velocity()); // the load's state is guarded either way
158 }
159 } else {
160 for (uint16_t i = 0; i < n; ++i) {
161 out[i] = loop_.tick(exciter_);
162 guard_.add(out[i]);
163 }
164 }
165 mix_.end();
166
167 // A non-finite sample would recirculate forever: silence the string.
168 if (guard_.tripped()) {
169 reset();
170 for (uint16_t i = 0; i < n; ++i) out[i] = 0.0f;
171 }
172 }
173
174 /// The string at rest, its ports already at their targets rather than gliding to them.
175 void reset() {
176 // Rate-derived state belongs here, not in the constructor (FDP-055 Addendum A).
178 port->set_time_ms(SMOOTH_MS, types::SAMPLE_RATE_F);
179 port->reset();
180 }
181 loop_.reset();
182 end_b_.set_impedance(loop_.impedance());
183 end_b_.reset();
184 exciter_.reset();
188 last_period_ = -1.0f;
190 }
191
192 /**
193 * @brief The whole string as wired: exciter, loop, end b, pickup (AP-037)
194 *
195 * The widget knows what its lambdas were, so it draws them. Never called
196 * from audio code.
197 */
198 void describe(diagram::Graph& g) const {
199 using diagram::Kind;
200 using diagram::Wave;
201 const uint8_t group = g.add_group(name_);
202
203 const diagram::Ports loop = loop_.describe(g, group, loaded_);
204
205 const diagram::Ports exciter = exciter_.describe(g, group);
206 g.add_edge(exciter.out, loop.in, Wave::Force);
207 const uint8_t second = exciter.find("table B");
208 if (second != diagram::NO_NODE) g.add_edge(second, loop.in, Wave::Force);
209
210 if (loaded_) {
211 const diagram::Ports end_b = end_b_.describe(g, group);
212 g.add_edge(loop.find("to end b"), end_b.in, Wave::Velocity);
213 g.add_edge(end_b.out, loop.find("from end b"), Wave::Velocity);
214 const uint8_t tap = g.add_node(Kind::Tap, "out: load ↔ bare string", group);
215 g.add_edge(end_b.find("load"), tap, Wave::Velocity);
216 g.add_edge(loop.out, tap, Wave::Velocity);
217 } else {
218 const uint8_t tap = g.add_node(Kind::Tap, "out: end b velocity", group);
219 g.add_edge(loop.out, tap, Wave::Velocity);
220 }
221 }
222
223 // ─── Reading the string ──────────────────────────────────────────
224
225 /// The loop period the string is currently tuned to, in samples.
226 float period_samples() const { return loop_.period_samples(); }
227
228 /// Energy in the loop, the load and the exciter. Never called from audio code.
229 float energy() const { return loop_.energy() + (loaded_ ? end_b_.energy() : 0.0f) + exciter_.energy(); }
230
231protected:
232 friend struct StringTestAccess;
233
234 static constexpr float SMOOTH_MS = 20.0f;
235 static constexpr float MIN_JUNCTION = 0.05f;
236 static constexpr float MAX_JUNCTION = 0.5f;
237 static constexpr float LOOP_GAIN = 0.999f; ///< energy lost at end b each trip
238 static constexpr float PERIOD_MARGIN = 4.0f; ///< headroom under MaxPeriod for the split
239 static constexpr float RETUNE_THRESHOLD = 1e-4f; ///< samples of period change worth a retune
240
241 void update_period(float pitch_su) {
242 const float note = math::note_of_su(math::clamp01(pitch_su));
243 float period = types::SAMPLE_RATE_F / math::note_to_frequency(note);
244 const float longest = static_cast<float>(MaxPeriod) - PERIOD_MARGIN;
245 if (period > longest) period = longest;
246 // Retuning re-splits the segments and re-tunes the allpass; skip it
247 // while the pitch holds.
248 if (std::fabs(period - last_period_) > RETUNE_THRESHOLD) {
250 last_period_ = period;
251 }
252 }
253
254 void apply_position(float position_su) {
255 const float junction = MIN_JUNCTION + position_su * (MAX_JUNCTION - MIN_JUNCTION);
256 if (std::fabs(junction - last_junction_) > RETUNE_THRESHOLD) {
257 loop_.set_junction(junction);
258 last_junction_ = junction;
259 }
260 }
261
262 void apply_damping(float damping_su) {
263 if (std::fabs(damping_su - last_damping_) > RETUNE_THRESHOLD) {
264 loop_.set_damping_su(damping_su);
265 last_damping_ = damping_su;
266 }
267 }
268
269 const char* name_;
270 float buf_a_[MaxPeriod] = {}; ///< the loop's segment to end a
271 float buf_b_[MaxPeriod] = {}; ///< and to end b
273
274 Exciter& exciter_;
275 Load& load_;
277 bool loaded_ = true;
278
285
286 float last_period_ = -1.0f;
287 float last_junction_ = -1.0f;
288 float last_damping_ = -1.0f;
289};
290
291} // namespace sbl::dsp::pm
292
293#endif // SBL_DSP_PM_STRING_BASE_HPP_
Did a non-finite sample get into this block?
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
The scattering junction between a waveguide and a lumped load.
float buf_a_[MaxPeriod]
the loop's segment to end a
float buf_b_[MaxPeriod]
and to end b
void set_pickup_su(float su)
What is heard: 0 the load, 1 the bare string, a crossfade between.
void set_position_su(float su)
Where the exciter sits along the string.
void apply_position(float position_su)
StringBase(Exciter &exciter, Load &load, const char *name="String")
Exciter & exciter()
The parts, for whoever owns them and for tools.
void apply_damping(float damping_su)
static constexpr float RETUNE_THRESHOLD
samples of period change worth a retune
static constexpr float MIN_JUNCTION
static constexpr float MAX_JUNCTION
void set_damping_su(float su)
Loss filter cutoff: how fast the high partials give up. Smoothed.
float energy() const
Energy in the loop, the load and the exciter. Never called from audio code.
static constexpr float PERIOD_MARGIN
headroom under MaxPeriod for the split
primitives::SmoothedPort pickup_
LoadJunction< Load > end_b_
primitives::SmoothedPort damping_
primitives::SmoothedPort pitch_
static constexpr float DEFAULT_PITCH_SU
void process(float *out, uint16_t n)
static constexpr float DEFAULT_PICKUP_SU
all load
primitives::BlockGuard guard_
primitives::CrossfadeRamp mix_
void describe(diagram::Graph &g) const
The whole string as wired: exciter, loop, end b, pickup (AP-037)
void use_load(bool on)
End b terminates on the load (true) or on a wall (false). The junction starts at rest.
void reset()
The string at rest, its ports already at their targets rather than gliding to them.
float period_samples() const
The loop period the string is currently tuned to, in samples.
void set_pitch_su(float su)
Pitch across C1..C9 (math::note_of_su). Smoothed.
primitives::SmoothedPort position_
void update_period(float pitch_su)
static constexpr float DEFAULT_DAMPING_SU
const StringLoop & loop() const
static constexpr float LOOP_GAIN
energy lost at end b each trip
friend struct StringTestAccess
const LoadJunction< Load > & end_b() const
static constexpr float DEFAULT_POSITION_SU
junction 0.3
static constexpr float SMOOTH_MS
void set_damping_su(float su)
Loss filter cutoff: how fast the high partials give up. Signal units.
float tick(Excite &&excite, EndB &&end_b)
Advance one sample.
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 energy() const
Energy in the two segments, in wave units: the sum of squares of every sample in flight (physical-mod...
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.
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.
bool tripped() const
True if anything added since begin() was non-finite.
void end()
Close the block exactly on its target, so rounding never drifts.
void begin(float target, uint16_t n)
Start a block that ends at target after n steps.
float next()
The value for the next sample.
void set(float target)
The port's setter stores here; nothing moves until advance().
float advance(uint16_t n)
Advance by a block: the smoothed value to apply for this block.
What a load, an exciter, a termination and a friction law provide (Physical modeling — cross-cutting)
A gain ramped linearly across one block.
Fixed-point constants and audio sample types.
A model's wiring, as data (AP-037)
Where the string meets its bridge (Physical modeling — composition)
Wave
What travels along an edge.
Definition graph.hpp:44
constexpr uint8_t NO_NODE
Definition graph.hpp:47
Kind
What a node is, in the paper's vocabulary.
Definition graph.hpp:26
constexpr float clamp01(float x)
x held to [0, 1]; NaN → 0.
Definition clamp.hpp:18
constexpr float note_of_su(float su)
Definition pitch.hpp:75
float note_to_frequency(float midi_note)
MIDI note to frequency in Hz. A4 = 440 Hz.
Definition pitch.hpp:58
Physical modeling: laws, bows, junctions, loads, resonators, strings (docs/conventions/physical-model...
Definition bow.hpp:31
float SAMPLE_RATE_F
Definition fixed.hpp:17
Semitone-to-frequency-ratio conversion.
A port's target, its smoother and its last value.
A string as two waveguide segments (Physical modeling — composition)
The ports a component exposes after describing itself, so an owner can wire them.
Definition graph.hpp:130