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
mass_spring_damper.hpp
Go to the documentation of this file.
1// sbl/dsp/pm/mass_spring_damper.hpp — A lumped resonator that can be a load (Physical modeling — primitive)
2//
3// m x'' + c x' + k x = F
4//
5// One mass on a spring with a damper: the smallest thing that resonates and
6// the smallest thing a string can terminate on. Integrated with the
7// trapezoidal rule, which is passive — the discrete system cannot make
8// energy, only store or lose it (RPT-029 Part 4 rule 1) — and which gives
9// the load a form a junction can solve against without a delay-free loop:
10// at each sample the velocity is an instantaneous part plus a history part,
11//
12// v[n] = g · F[n] + h[n]
13//
14// where g is the instantaneous admittance (set by the parameters) and h is
15// what the past state contributes.
16//
17// Two ways to say what the load is. The physical one — frequency, Q, mass —
18// is exact and awkward: mass in string-impedance units means nothing at a
19// panel, and a light mass is a free end at every musical frequency (AP-036,
20// 2026-09-13). The musical one — frequency, Q, match — names what a string
21// feels: where the resonance is, how wide it is, and how much of the string
22// leaves into it at resonance. Match is the damper relative to the string's
23// impedance, c/Z; at resonance the load is that damper alone and the
24// reflection is (1 − match)/(1 + match) — 1 absorbs everything, 0.1 keeps
25// 82 % of the wave, 10 is nearly a wall again. Mass follows: m = match·Q/ω0.
26// Away from resonance the load then looks rigid where match·Q·(ω/ω0) ≫ 1. A caller that knows F outright uses
27// commit(); a junction that has to solve for F first reads admittance()
28// and history(), solves, then commit()s the force it found.
29//
30// Units are the waveguide's: time in samples, velocity in wave units, force
31// in wave units times the string's wave impedance, which the loop
32// normalises to 1. A mass of 1 is the mass whose impedance at one radian per
33// sample (7.6 kHz at 48 kHz) equals the string's — which makes it a free end
34// at any musical frequency. Rigid needs m·ω ≫ 1 at the lowest note played:
35// m ≫ 30 for a string at 250 Hz, m ≫ 230 down at C1. Between resonance and
36// rigid the load's impedance is stiffness-like below ω0 (k/ω) and mass-like
37// above (m·ω); at ω0 it is the damper alone, c = m·ω0/Q, and that is where
38// the string leaks — most when c matches the string's 1. A light, high-Q
39// load is a wolf, not a body (AP-036, 2026-09-13).
40//
41// Usage:
42// sbl::dsp::pm::MassSpringDamper body;
43// body.set_frequency(220.0f);
44// body.set_q(12.0f);
45// body.set_mass(4.0f);
46// float v = body.commit(force); // as a resonator
47// // or, from a junction:
48// const float g = body.admittance(), h = body.history();
49// ... solve for F ...
50// float v = body.commit(F);
51
52#ifndef SBL_DSP_PM_MASS_SPRING_DAMPER_HPP_
53#define SBL_DSP_PM_MASS_SPRING_DAMPER_HPP_
54
60
61namespace sbl::dsp::pm {
62
64public:
65 /// @note All public methods are ISR-safe — bounded computation, no I/O.
66
67 static constexpr float MIN_FREQUENCY_HZ = 1.0f;
68 static constexpr float MAX_FREQUENCY_FRACTION = 0.45f; ///< of the sample rate
69 static constexpr float MIN_Q = 0.05f;
70 static constexpr float MAX_Q = 1000.0f;
71 static constexpr float MIN_MASS = 1e-4f;
72 static constexpr float MAX_MASS = 1e6f;
73 static constexpr float MIN_MATCH = 1e-3f;
74 static constexpr float MAX_MATCH = 100.0f;
75
76 MassSpringDamper() { update(); }
77
78 // ─── Parameters (natural units) ──────────────────────────────────
79
80 /// Undamped resonance, Hz.
81 void set_frequency(float hz) {
82 const float max_hz = types::SAMPLE_RATE_F * MAX_FREQUENCY_FRACTION;
83 frequency_hz_ = math::clamp(hz, MIN_FREQUENCY_HZ, max_hz); // NaN → min
84 update();
85 }
86
87 /// Quality factor: how many cycles the ringing lasts.
88 void set_q(float q) {
89 q_ = math::clamp(q, MIN_Q, MAX_Q); // NaN → min
90 update();
91 }
92
93 /// Mass in string-impedance units; heavier is closer to rigid.
94 void set_mass(float m) {
95 mass_ = math::clamp(m, MIN_MASS, MAX_MASS); // NaN → min
96 update();
97 }
98
99 /**
100 * @brief The musical parametrisation: where, how wide, how much it takes
101 *
102 * @param hz resonance
103 * @param q how long it rings — also how rigid it looks away from
104 * resonance, since the mass follows from q and match
105 * @param match damper relative to the string's impedance at resonance;
106 * 1 absorbs the resonant wave completely
107 */
108 void set_resonance(float hz, float q, float match) {
109 const float m = math::clamp(match, MIN_MATCH, MAX_MATCH); // NaN → min
110 set_frequency(hz);
111 set_q(q);
112 const float w0 = math::TWO_PI * frequency_hz_ / types::SAMPLE_RATE_F;
113 set_mass(m * q_ / w0);
114 }
115
116 float frequency() const { return frequency_hz_; }
117 float q() const { return q_; }
118 float mass() const { return mass_; }
119
120 /// The damper relative to the string's impedance: c / Z with Z = 1.
121 float match() const { return c_; }
122
123 // ─── The junction interface ──────────────────────────────────────
124
125 /// Instantaneous admittance g: how much of this sample's force shows up in this sample's velocity.
126 float admittance() const { return g_; }
127
128 /// What the past state contributes to this sample's velocity, before any
129 /// force. Fixed once the previous sample is committed, so it is computed
130 /// there and read here — the junction reads it, then commits.
131 float history() const { return h_; }
132
133 /// Apply this sample's force and advance: returns the velocity.
134 float commit(float force) {
135 const float v = g_ * force + h_;
136 x_ += HALF * (v + v_);
137 v_ = v;
138 a_ = (force - c_ * v - k_ * x_) * inv_mass_;
139 update_history();
140 return v;
141 }
142
143 float velocity() const { return v_; }
144 float position() const { return x_; }
145
146 /// Stored energy ½mv² + ½kx², for passivity tests.
147 float energy() const { return HALF * (mass_ * v_ * v_ + k_ * x_ * x_); }
148
149 void reset() {
150 x_ = 0.0f;
151 v_ = 0.0f;
152 a_ = 0.0f;
153 update(); // rate-derived coefficients belong here (FDP-055 Addendum A); h_ follows
154 }
155
156 /// One Load node: frequency and Q. Never called from audio code (AP-037).
157 diagram::Ports describe(diagram::Graph& g, uint8_t group, const char* name = "mode") const {
159 p.in = p.out = g.add_node(diagram::Kind::Load, name, group, frequency_hz_, q_);
160 p.group = group;
161 return p;
162 }
163
164private:
165 static constexpr float HALF = 0.5f;
166 static constexpr float QUARTER = 0.25f;
167
168 /// Coefficients from the parameters; time step is one sample.
169 void update() {
170 const float w0 = math::TWO_PI * frequency_hz_ / types::SAMPLE_RATE_F; // rad/sample
171 k_ = mass_ * w0 * w0;
172 c_ = mass_ * w0 / q_;
173 inv_mass_ = 1.0f / mass_;
174 // Trapezoidal: v[n] (m + c/2 + k/4) = F/2 + history terms
175 const float d = mass_ + HALF * c_ + QUARTER * k_;
176 inv_d_ = 1.0f / d;
177 g_ = HALF * inv_d_;
178 update_history();
179 }
180
181 void update_history() {
182 h_ = (mass_ * v_ - QUARTER * k_ * v_ - HALF * k_ * x_ + HALF * mass_ * a_) * inv_d_;
183 }
184
185 float frequency_hz_ = 220.0f;
186 float q_ = 10.0f;
187 float mass_ = 1.0f;
188
189 float k_ = 0.0f;
190 float c_ = 0.0f;
191 float inv_mass_ = 1.0f;
192 float inv_d_ = 1.0f;
193 float g_ = 0.0f;
194
195 float x_ = 0.0f; ///< position
196 float v_ = 0.0f; ///< velocity
197 float a_ = 0.0f; ///< acceleration at the last sample
198 float h_ = 0.0f; ///< history for the next sample, from the state above
199};
200
201static_assert(is_load_v<MassSpringDamper> && is_described_v<MassSpringDamper>, "MassSpringDamper is a Load and describes itself (physical-modeling.md §4.1, §7)");
202
203} // namespace sbl::dsp::pm
204
205#endif // SBL_DSP_PM_MASS_SPRING_DAMPER_HPP_
Clamping (Cross-cutting — Math)
uint8_t add_node(Kind kind, const char *name, uint8_t group, float value=0.0f, float value2=0.0f)
Definition graph.hpp:81
static constexpr float MAX_FREQUENCY_FRACTION
of the sample rate
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 energy() const
Stored energy ½mv² + ½kx², for passivity tests.
void set_frequency(float hz)
Undamped resonance, Hz.
float match() const
The damper relative to the string's impedance: c / Z with Z = 1.
void set_mass(float m)
Mass in string-impedance units; heavier is closer to rigid.
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.
float admittance() const
Instantaneous admittance g: how much of this sample's force shows up in this sample's velocity.
void set_q(float q)
Quality factor: how many cycles the ringing lasts.
static constexpr float MIN_FREQUENCY_HZ
The numbers every layer reaches for.
What a load, an exciter, a termination and a friction law provide (Physical modeling — cross-cutting)
Fixed-point constants and audio sample types.
A model's wiring, as data (AP-037)
@ Load
a lumped body; value: Hz, value2: Q
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
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