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
modal_resonator.hpp
Go to the documentation of this file.
1// sbl/dsp/pm/modal_resonator.hpp — A body as several resonances at one point (Physical modeling — composition)
2//
3// A real body is not one mass on one spring; it is many, and a string's
4// bridge sees all of them at once. Driven at a point, the modes respond in
5// parallel: each takes the same force scaled by its mode shape at that
6// point, and the point's velocity is the weighted sum of what they do.
7// Their admittances add:
8//
9// Y(ω) = Σ w_i² · Y_i(ω)
10//
11// so a ModalResonator is a load exactly the way one MassSpringDamper is — it
12// exposes the same instantaneous admittance g = Σ w_i² g_i, history
13// h = Σ w_i h_i, and commit(F), and a LoadJunction scatters against it
14// without knowing the difference. Each mode is passive; a sum of passive
15// loads is passive (RPT-029 Part 4 rule 1).
16//
17// Modes are named the way a string feels them — where, how wide, how much
18// they take (MassSpringDamper::set_resonance) — plus two shapes. The weight
19// is the mode's shape at the bridge: 1 is a mode the bridge sits on the belly
20// of, 0 is a node it cannot see. It sets how strongly the mode couples to the
21// string, and so how much it can pull or break it. The radiation weight is
22// the mode's shape where the sound is heard: it sets how present the mode is
23// in radiated(), and nothing else. The two are independent on a real body,
24// and keeping them apart is how a mode gets character without cost — weakly
25// coupled, strongly heard (AP-036 addendum 2026-09-13: every partially
26// matched mode at the bridge costs playability). Mode truncation is a design
27// choice, not an error: a bank of one is the single body AP-036 started with.
28//
29// Usage:
30// sbl::dsp::pm::ModalResonator<8> body;
31// body.set_mode(0, 275.0f, 12.0f, 0.4f); // where, how wide, how much
32// body.set_mode(1, 460.0f, 25.0f, 0.3f, 0.1f, 1.0f); // barely coupled, fully heard
33// body.set_count(2);
34// sbl::dsp::pm::LoadJunction<sbl::dsp::pm::ModalResonator<8>> bridge(body);
35
36#ifndef SBL_DSP_PM_MODAL_RESONATOR_HPP_
37#define SBL_DSP_PM_MODAL_RESONATOR_HPP_
38
39#include <cstdint>
40
45
46namespace sbl::dsp::pm {
47
48/// One resonance as a string feels it, its shape at the bridge, and its shape where it is heard.
49struct Mode {
50 float hz;
51 float q;
52 float match;
53 float weight = 1.0f; ///< coupling to the string at the bridge
54 float radiate = 1.0f; ///< presence in radiated()
55};
56
57template<uint8_t MaxModes = 8>
59 static_assert(MaxModes >= 1, "a resonator needs at least one mode");
60
61public:
62 /// @note All public methods are ISR-safe — bounded computation, no I/O.
63
64 static constexpr uint8_t MAX_MODES = MaxModes;
65
67 for (uint8_t i = 0; i < MaxModes; ++i) {
68 weight_[i] = 1.0f;
69 radiate_[i] = 1.0f;
70 }
71 update();
72 }
73
74 /// Set one mode. Modes at or beyond count() are kept but silent.
75 void set_mode(uint8_t index, float hz, float q, float match,
76 float weight = 1.0f, float radiate = 1.0f) {
77 if (index >= MaxModes) return;
78 modes_[index].set_resonance(hz, q, match);
79 weight_[index] = math::clamp01(weight);
80 radiate_[index] = math::clamp01(radiate);
81 update();
82 }
83
84 /// Install a whole set; count is clamped to the bank's size.
85 void set_modes(const Mode* modes, uint8_t count) {
86 count_ = count < MaxModes ? count : MaxModes;
87 for (uint8_t i = 0; i < count_; ++i) {
88 modes_[i].set_resonance(modes[i].hz, modes[i].q, modes[i].match);
89 weight_[i] = math::clamp01(modes[i].weight);
90 radiate_[i] = math::clamp01(modes[i].radiate);
91 }
92 update();
93 }
94
95 /// How many of the modes are live.
96 void set_count(uint8_t count) {
97 count_ = count < MaxModes ? count : MaxModes;
98 update();
99 }
100 uint8_t count() const { return count_; }
101
102 const pm::MassSpringDamper& mode(uint8_t i) const { return modes_[i]; }
103 float weight(uint8_t i) const { return weight_[i]; }
104 float radiate(uint8_t i) const { return radiate_[i]; }
105
106 // ─── The junction interface (parallel modes) ─────────────────────
107
108 float admittance() const { return g_; }
109
110 float history() const {
111 float h = 0.0f;
112 for (uint8_t i = 0; i < count_; ++i) h += weight_[i] * modes_[i].history();
113 return h;
114 }
115
116 /// Apply this sample's force to every live mode; return the point velocity.
117 float commit(float force) {
118 float v = 0.0f;
119 float heard = 0.0f;
120 for (uint8_t i = 0; i < count_; ++i) {
121 const float vi = modes_[i].commit(weight_[i] * force);
122 v += weight_[i] * vi;
123 heard += radiate_[i] * vi;
124 }
125 radiated_ = heard;
126 return v;
127 }
128
129 /// What the body sounds like this sample: each mode's velocity by its radiation weight.
130 float radiated() const { return radiated_; }
131
132 /// Stored energy across the live modes, for passivity tests.
133 float energy() const {
134 float e = 0.0f;
135 for (uint8_t i = 0; i < count_; ++i) e += modes_[i].energy();
136 return e;
137 }
138
139 void reset() {
140 for (uint8_t i = 0; i < MaxModes; ++i) modes_[i].reset();
141 radiated_ = 0.0f;
142 }
143
144 /// One Load node per live mode, wired in parallel to a shared point. Never called from audio code (AP-037).
145 diagram::Ports describe(diagram::Graph& g, uint8_t parent, const char* name = "resonator") const {
147 p.group = g.add_group(name, parent);
148 p.in = p.out = g.add_node(diagram::Kind::Junction, "drive point", p.group);
149 for (uint8_t i = 0; i < count_; ++i) {
150 const diagram::Ports m = modes_[i].describe(g, p.group, "mode");
153 }
154 return p;
155 }
156
157private:
158 /// NaN fails every comparison, so test the positive case: NaN reads as 0.
159
160 /// The parallel admittance: weights squared, because the force each mode
161 /// receives and the velocity it contributes are both scaled by its shape.
162 void update() {
163 g_ = 0.0f;
164 for (uint8_t i = 0; i < count_; ++i) g_ += weight_[i] * weight_[i] * modes_[i].admittance();
165 }
166
167 pm::MassSpringDamper modes_[MaxModes];
168 float weight_[MaxModes];
169 float radiate_[MaxModes];
170 uint8_t count_ = 1;
171 float g_ = 0.0f;
172 float radiated_ = 0.0f;
173};
174
175static_assert(is_load_v<ModalResonator<8>> && is_described_v<ModalResonator<8>>, "ModalResonator is a Load and describes itself (physical-modeling.md §4.1, §7)");
176
177} // namespace sbl::dsp::pm
178
179#endif // SBL_DSP_PM_MODAL_RESONATOR_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
diagram::Ports describe(diagram::Graph &g, uint8_t group, const char *name="mode") const
One Load node: frequency and Q. Never called from audio code (AP-037).
float commit(float force)
Apply this sample's force and advance: returns the velocity.
void set_resonance(float hz, float q, float match)
The musical parametrisation: where, how wide, how much it takes.
const pm::MassSpringDamper & mode(uint8_t i) const
float weight(uint8_t i) const
static constexpr uint8_t MAX_MODES
void set_mode(uint8_t index, float hz, float q, float match, float weight=1.0f, float radiate=1.0f)
Set one mode. Modes at or beyond count() are kept but silent.
float energy() const
Stored energy across the live modes, for passivity tests.
void set_modes(const Mode *modes, uint8_t count)
Install a whole set; count is clamped to the bank's size.
diagram::Ports describe(diagram::Graph &g, uint8_t parent, const char *name="resonator") const
One Load node per live mode, wired in parallel to a shared point. Never called from audio code (AP-03...
float radiate(uint8_t i) const
float commit(float force)
Apply this sample's force to every live mode; return the point velocity.
float radiated() const
What the body sounds like this sample: each mode's velocity by its radiation weight.
void set_count(uint8_t count)
How many of the modes are live.
What a load, an exciter, a termination and a friction law provide (Physical modeling — cross-cutting)
A model's wiring, as data (AP-037)
A lumped resonator that can be a load (Physical modeling — primitive)
@ Junction
where waves scatter or a source meets the string
constexpr float clamp01(float x)
x held to [0, 1]; NaN → 0.
Definition clamp.hpp:18
Physical modeling: laws, bows, junctions, loads, resonators, strings (docs/conventions/physical-model...
Definition bow.hpp:31
The ports a component exposes after describing itself, so an owner can wire them.
Definition graph.hpp:130
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
One resonance as a string feels it, its shape at the bridge, and its shape where it is heard.
float weight
coupling to the string at the bridge
float radiate
presence in radiated()