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
thermal_friction.hpp
Go to the documentation of this file.
1// sbl/dsp/pm/thermal_friction.hpp — A bow whose friction remembers: the thermal model (Physical modeling — primitive)
2//
3// The friction-curve bow (table_friction.hpp) makes force a function of
4// sliding speed alone, which rosin measurably does not do: during stick-slip
5// it traces a hysteresis loop in the force–velocity plane that follows no
6// steady-sliding curve (Smith & Woodhouse 2000). Woodhouse (2003) proposed
7// the simplest law that does: the coefficient of friction depends on the
8// temperature of the rosin at the contact, and on nothing else. Sliding
9// heats the contact and the friction falls; sticking lets it cool and the
10// friction recovers. The lag between speed and temperature is the loop, and
11// with it come the results that matter for a player: Helmholtz motion is
12// reached more reliably and more quickly, the Schelleng region is wider,
13// and the string is less "twitchy" (Woodhouse 2003 §5). Maestre, Spa and
14// Smith (2014) put the same idea in a digital waveguide.
15//
16// The model, in the waveguide's units (2Z = 1, as table_friction.hpp):
17//
18// limit(T) = L(force) · μ(T)/μ_cold the static limit, cold at L
19// stuck : |dv_free| <= limit(T) → F = dv_free
20// sliding : F = limit(T) · sgn(dv_free) Coulomb: no speed dependence
21// dT/dt = heat · F · v_slip − T/τ − convect · v_slip · T
22//
23// with v_slip = |dv_free| − |F| the true sliding speed. Friction that does
24// not depend on speed has a flat sliding branch, so the load line crosses it
25// in exactly one place: no capture test, no rounding of the corner
26// (Woodhouse 2003 §2.3). Release and capture happen at different limits
27// because T differs — that is the hysteresis, and it comes from the state,
28// not from a rule.
29//
30// T is kept as a fraction of the range over which μ falls, 0 cold to 1 fully
31// softened, so the constants read as "how fast it heats" and "how fast it
32// cools" in that range: Woodhouse's rosin falls from μ 1.2 to 0.35 over about
33// 40 °C above ambient (his Figure 3), and a played string sits at 17–31 °C
34// (his Figure 6c). Exact values matter less than they look: the system
35// "self-buffers", warming until the falling friction stops the warming
36// (§2.1). Calibrate with pictures, not by hand.
37//
38// Conduction into a solid is not one time constant: the Green's function
39// falls as 1/√t, fast at first and slow after, which is what lets a contact
40// cool within a two-millisecond stick at C5 and still keep a warm baseline
41// across the period. Two exponentials stand in for it here — a fast one that
42// takes `fast_share` of the heat and a slow one that takes the rest — and
43// T is their sum.
44//
45// The same state can also drive a friction-curve bow (BowedString's Hybrid
46// model): the table's speed-dependent force scaled by the rosin's warmth —
47// f = N·μ(T) with the curve's shape kept, the combination Woodhouse's
48// conclusions point to (2003 §5). advance() feeds the state from a force and
49// sliding speed the curve found.
50//
51// L(force) is the friction-curve bow's per-slice static limit, scaled by
52// `cold_gain`: with the default 1/μ_ratio the *hot* limit equals the table's
53// limit, so a bow pressure means "the friction of warm rosin equals the table
54// bow's stick limit", and the two bows can be drawn side by side on one
55// Schelleng diagram. (Calibrating the cold limit to the table's instead
56// starves the string: Coulomb friction has no falling slope to self-excite,
57// so the corner alone must recapture the string, and it needs the hot
58// friction to be about what the table bow sticks with. Woodhouse's cello
59// string sits at a cold limit near twenty bow speeds and a hot one near six.)
60//
61// Energy: a bow is a source, and this one has no passivity proof either.
62// |F| never exceeds |dv_free| — sliding force is at most the limit, and
63// sliding means the limit is below |dv_free| — so it cannot drive the string
64// faster than the bow; the SoftLimiter downstream stays load-bearing.
65//
66// A law, not an exciter: a Bow<ThermalFriction> owns the speed, the weight
67// and the junction. process() is the law solved against a string's line at
68// unit impedance; limit() and advance() are what a junction needs to solve
69// it against any other line (AP-041 Phase 6, the rubbed bowl).
70//
71// Usage:
72// sbl::dsp::pm::ThermalFriction law;
73// law.set_limits(lut::bow_stk_limits, 8);
74// law.set_force_su(pressure);
75// float injection = law.process(v_bow - v_incoming);
76
77#ifndef SBL_DSP_PM_THERMAL_FRICTION_HPP_
78#define SBL_DSP_PM_THERMAL_FRICTION_HPP_
79
80#include <cmath>
81#include <cstdint>
82
87
88namespace sbl::dsp::pm {
89
91public:
92 /// @note All public methods are ISR-safe — bounded computation, no I/O.
93
94 /// Rosin, as Woodhouse measured it: friction at full heat is this fraction of cold.
95 static constexpr float DEFAULT_MU_RATIO = 0.35f / 1.2f;
96 /// Fraction of the range per unit of (force · sliding speed) per second.
97 /// Calibrated 2026-09-15 on the Davis Jr. string: Helmholtz at C3 with the body,
98 /// C3–C5 bare (AP-040 Phase 4 addenda).
99 static constexpr float DEFAULT_HEAT = 2000.0f;
100 /// Conduction into the string and the bow: the slow component's time to cool by 1/e.
101 static constexpr float DEFAULT_TAU_MS = 10.0f;
102 /// Convection: cold rosin flowing into the contact, per unit sliding speed per second.
103 static constexpr float DEFAULT_CONVECT = 0.0f;
104 /// The cold limit over the table's. 1: cold equals the table's limit. (Any
105 /// more and this waveguide's stuck force, capped by loop loss at a few bow
106 /// speeds, never reaches it.)
107 static constexpr float DEFAULT_COLD_GAIN = 1.0f;
108 /// The fast cooling component: time constant and the share of heat it takes.
109 static constexpr float DEFAULT_FAST_MS = 0.5f;
110 static constexpr float DEFAULT_FAST_SHARE = 0.6f;
111
112 /**
113 * @brief The cold static limit per force slice — the friction-curve bow's `limits`
114 *
115 * @param limits one static-friction limit per slice, light bow first
116 * @param slices number of slices
117 */
118 void set_limits(const float* limits, uint8_t slices) {
119 limits_ = limits;
120 slices_ = slices;
121 set_force_su(force_su_);
122 }
123
124 /// Bow force across the slices [0, 1]; the cold limit interpolates between them.
125 void set_force_su(float su) {
126 force_su_ = su < 0.0f ? 0.0f : (su > 1.0f ? 1.0f : su);
127 if (limits_ == nullptr || slices_ == 0) {
128 cold_limit_ = 0.0f;
129 return;
130 }
131 const float pos = force_su_ * static_cast<float>(slices_ - 1);
132 uint8_t i = static_cast<uint8_t>(pos);
133 if (i >= slices_ - 1 && slices_ > 1) i = static_cast<uint8_t>(slices_ - 2);
134 const float blend = slices_ > 1 ? pos - static_cast<float>(i) : 0.0f;
135 cold_limit_ = cold_gain_ * (slices_ > 1 ? limits_[i] + blend * (limits_[i + 1] - limits_[i]) : limits_[0]);
136 }
137
138 /**
139 * @brief The rosin: how it heats, cools and softens (configuration, not signals)
140 *
141 * @param heat temperature fraction per unit of force·speed per second
142 * @param tau_ms conduction time constant
143 * @param convect convection coefficient per unit sliding speed per second
144 * @param mu_ratio friction at full heat relative to cold, in (0, 1]
145 * @param cold_gain the cold limit as a multiple of the table's limit
146 */
147 void set_rosin(float heat, float tau_ms, float convect, float mu_ratio,
148 float cold_gain = DEFAULT_COLD_GAIN) {
149 heat_ = heat > 0.0f ? heat : 0.0f;
150 cool_ = tau_ms > 0.0f ? 1.0f / (tau_ms * 0.001f) : 0.0f;
151 convect_ = convect > 0.0f ? convect : 0.0f;
152 mu_ratio_ = (mu_ratio > 0.0f && mu_ratio <= 1.0f) ? mu_ratio : DEFAULT_MU_RATIO;
153 cold_gain_ = cold_gain > 0.0f ? cold_gain : DEFAULT_COLD_GAIN;
154 set_force_su(force_su_);
155 }
156
157 /// The fast cooling component: its time constant and the share of the heat it takes.
158 void set_fast_cooling(float fast_ms, float fast_share) {
159 cool_fast_ = fast_ms > 0.0f ? 1.0f / (fast_ms * 0.001f) : 0.0f;
160 fast_share_ = fast_share < 0.0f ? 0.0f : (fast_share > 1.0f ? 1.0f : fast_share);
161 }
162
163 /// Friction relative to cold, from the temperature: 1 cold down to mu_ratio hot.
164 float mu_rel() const { return 1.0f - (1.0f - mu_ratio_) * temperature(); }
165
166 /**
167 * @brief Advance the contact one sample from a force and sliding speed found elsewhere
168 *
169 * For the hybrid bow: the friction curve finds the force; this keeps the
170 * temperature. Sliding speed 0 while stuck.
171 */
172 void advance(float force, float v_slip) {
173 const float f = std::fabs(force);
174 const float v = v_slip < 0.0f ? 0.0f : v_slip;
175 const float heating = heat_ * f * v;
176 t_fast_ += dt_ * (fast_share_ * heating - (cool_fast_ + convect_ * v) * t_fast_);
177 t_slow_ += dt_ * ((1.0f - fast_share_) * heating - (cool_ + convect_ * v) * t_slow_);
178 t_fast_ = math::clamp01(t_fast_);
179 t_slow_ = math::clamp01(t_slow_);
180 }
181
182 /**
183 * @brief One sample: relative velocity in, injection force out
184 *
185 * @param dv_free v_bow - v_incoming, in wave variables
186 */
187 float process(float dv_free) {
188 if (limits_ == nullptr) return 0.0f;
189 const float a = std::fabs(dv_free);
190 const float limit = cold_limit_ * mu_rel();
191
192 float f;
193 float v_slip;
194 if (a <= limit || !(a == a)) { // holds, or NaN: treat as at rest
195 stuck_ = true;
196 f = (a == a) ? dv_free : 0.0f;
197 v_slip = 0.0f;
198 } else {
199 stuck_ = false;
200 f = dv_free < 0.0f ? -limit : limit;
201 v_slip = a - limit;
202 }
203
204 // The contact: heated by the work of sliding, cooled by conduction
205 // always and by convection while sliding. Forward Euler; at audio
206 // rate the step is a fortieth of the shortest time constant.
207 advance(limit, v_slip);
208 return f;
209 }
210
211 /// True while the string travels with the bow.
212 bool sticking() const { return stuck_; }
213
214 /// The static limit right now, warmed: where the string would release.
215 float limit() const { return cold_limit_ * mu_rel(); }
216
217 /// Contact temperature as a fraction of the range over which the rosin softens.
218 float temperature() const {
219 const float t = t_fast_ + t_slow_;
220 return t > 1.0f ? 1.0f : t;
221 }
222
223 /// Cold and stuck.
224 void reset() {
225 stuck_ = true;
226 t_fast_ = 0.0f;
227 t_slow_ = 0.0f;
228 dt_ = 1.0f / types::SAMPLE_RATE_F; // the one sample-rate rule (FDP-055 Addendum A)
229 }
230
231 /// One Junction node: the friction nonlinearity. Never called from audio code (AP-037).
232 diagram::Ports describe(diagram::Graph& g, uint8_t group, const char* name = "bow (thermal)") const {
234 p.in = p.out = g.add_node(diagram::Kind::Junction, name, group);
235 p.group = group;
236 return p;
237 }
238
239private:
240 const float* limits_ = nullptr;
241 uint8_t slices_ = 0;
242 float force_su_ = 1.0f;
243 float cold_limit_ = 0.0f;
244
245 float heat_ = DEFAULT_HEAT;
246 float cool_ = 1.0f / (DEFAULT_TAU_MS * 0.001f);
247 float convect_ = DEFAULT_CONVECT;
248 float mu_ratio_ = DEFAULT_MU_RATIO;
249 float cold_gain_ = DEFAULT_COLD_GAIN;
250 float cool_fast_ = 1.0f / (DEFAULT_FAST_MS * 0.001f);
251 float fast_share_ = DEFAULT_FAST_SHARE;
252 float dt_ = 1.0f / types::SAMPLE_RATE_F;
253
254 float t_fast_ = 0.0f;
255 float t_slow_ = 0.0f;
256 bool stuck_ = true;
257};
258
259static_assert(is_friction_law_v<ThermalFriction>, "ThermalFriction is a friction law (physical-modeling.md §4.5)");
260
261} // namespace sbl::dsp::pm
262
263#endif // SBL_DSP_PM_THERMAL_FRICTION_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
void set_fast_cooling(float fast_ms, float fast_share)
The fast cooling component: its time constant and the share of the heat it takes.
static constexpr float DEFAULT_CONVECT
Convection: cold rosin flowing into the contact, per unit sliding speed per second.
static constexpr float DEFAULT_FAST_SHARE
static constexpr float DEFAULT_MU_RATIO
Rosin, as Woodhouse measured it: friction at full heat is this fraction of cold.
float temperature() const
Contact temperature as a fraction of the range over which the rosin softens.
static constexpr float DEFAULT_FAST_MS
The fast cooling component: time constant and the share of heat it takes.
void set_rosin(float heat, float tau_ms, float convect, float mu_ratio, float cold_gain=DEFAULT_COLD_GAIN)
The rosin: how it heats, cools and softens (configuration, not signals)
static constexpr float DEFAULT_TAU_MS
Conduction into the string and the bow: the slow component's time to cool by 1/e.
float mu_rel() const
Friction relative to cold, from the temperature: 1 cold down to mu_ratio hot.
void set_limits(const float *limits, uint8_t slices)
The cold static limit per force slice — the friction-curve bow's limits
void set_force_su(float su)
Bow force across the slices [0, 1]; the cold limit interpolates between them.
static constexpr float DEFAULT_HEAT
float process(float dv_free)
One sample: relative velocity in, injection force out.
float limit() const
The static limit right now, warmed: where the string would release.
void advance(float force, float v_slip)
Advance the contact one sample from a force and sliding speed found elsewhere.
static constexpr float DEFAULT_COLD_GAIN
bool sticking() const
True while the string travels with the bow.
diagram::Ports describe(diagram::Graph &g, uint8_t group, const char *name="bow (thermal)") const
One Junction node: the friction nonlinearity. Never called from audio code (AP-037).
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)
@ 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
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