Skip to main content

sva_samples/physics/
chaigne_askenfelt.rs

1// Concern: one hammer/unison-string-set call site's finite-difference state | Non-concern: argument evaluation, builtin dispatch | IO: (params, sr) -> a site; () -> a sample
2
3//! Chaigne & Askenfelt 1994's coupled hammer/stiff-string model. A physics citation, not an
4//! instrument: nothing here, or anywhere this is wired in, may name one.
5
6use crate::physics::Solver;
7
8use crate::physics::bound::Bound::*;
9use crate::physics::bound::all;
10use crate::physics::hammer::Hammer;
11
12#[derive(Clone, Debug, PartialEq)]
13pub struct ChaigneAskenfeltParams {
14    pub f0: f64,
15    pub b: f64,
16    pub strike_pos: f64,
17    pub vel: f64,
18    pub hammer_mass: f64,
19    pub hammer_k: f64,
20    pub hammer_p: f64,
21    pub damp_dc: f64,
22    pub damp_freq: f64,
23    pub unison_count: f64,
24    pub detune: f64,
25    pub bridge_coupling: f64,
26    /// kg; `0` is massless.
27    pub bridge_mass: f64,
28    /// Cents off `detune`'s placing.
29    pub string_cents: [f64; MAX_UNISON],
30    pub string_hammer_k_ratio: [f64; MAX_UNISON],
31}
32
33impl ChaigneAskenfeltParams {
34    /// Chaigne & Askenfelt's own C4 reference set, at the fundamental asked for.
35    pub fn at(f0: f64) -> ChaigneAskenfeltParams {
36        ChaigneAskenfeltParams {
37            f0,
38            b: 0.00021,
39            strike_pos: 0.125,
40            vel: 3.2,
41            hammer_mass: 2.9e-3,
42            hammer_k: 2.6646e8,
43            hammer_p: 2.5,
44            damp_dc: 0.6,
45            damp_freq: 1.6e-4,
46            unison_count: 1.0,
47            detune: 2f64.powf(3.0 / 1200.0),
48            bridge_coupling: 1000.0,
49            bridge_mass: 0.0,
50            string_cents: [0.0; MAX_UNISON],
51            string_hammer_k_ratio: [1.0; MAX_UNISON],
52        }
53    }
54}
55
56impl ChaigneAskenfeltParams {
57    pub fn valid(&self) -> bool {
58        let [c1, c2, c3] = self.string_cents;
59        let [k1, k2, k3] = self.string_hammer_k_ratio;
60        all(&[
61            (self.f0, Positive),
62            (self.b, NonNegative),
63            (self.strike_pos, OpenUnit),
64            (self.vel, Positive),
65            (self.hammer_mass, Positive),
66            (self.hammer_k, Positive),
67            (self.hammer_p, Positive),
68            (self.damp_dc, NonNegative),
69            (self.damp_freq, NonNegative),
70            (self.unison_count, Within(1.0, MAX_UNISON as f64)),
71            (self.detune, AtLeast(1.0)),
72            (self.bridge_coupling, Positive),
73            (self.bridge_mass, NonNegative),
74            (c1, Finite),
75            (c2, Finite),
76            (c3, Finite),
77            (k1, Positive),
78            (k2, Positive),
79            (k3, Positive),
80        ])
81    }
82}
83
84const WIRE_DENSITY_KG_M3: f64 = 7850.0;
85/// A generic wire radius, tuned against this module's bridge-force and contact-time tests.
86const WIRE_RADIUS_M: f64 = 0.6e-3;
87/// A generic string tension, tuned alongside [`WIRE_RADIUS_M`].
88const STRING_TENSION_N: f64 = 1500.0;
89/// `SharedBridge`: terminated at the site's common bridge, not a rigid pin. Set uniformly
90/// across every string in a site; `step()` reads only `strings[0]`'s to dispatch.
91#[derive(Clone, Copy)]
92enum Termination {
93    Rigid,
94    SharedBridge,
95}
96
97pub(crate) struct StringGrid {
98    pub(crate) y_now: Vec<f64>,
99    pub(crate) y_prev: Vec<f64>,
100    pub(crate) y_next: Vec<f64>,
101    pub(crate) n: usize,
102    pub(crate) dx: f64,
103    pub(crate) rho: f64,
104    pub(crate) courant_sq: f64,
105    pub(crate) stiff_sq: f64,
106    pub(crate) damp_a: f64,
107    pub(crate) damp_b: f64,
108    far_termination: Termination,
109}
110
111/// The ghost a 5-point biharmonic stencil needs past `0` and `n`, far end `pin`.
112pub(crate) fn ghost_pinned(y: &[f64], n: usize, idx: isize, pin: f64) -> f64 {
113    if idx < 0 {
114        -y[(-idx) as usize]
115    } else if idx as usize > n {
116        2.0 * pin - y[(2 * n as isize - idx) as usize]
117    } else {
118        y[idx as usize]
119    }
120}
121
122/// `pin` is the far-end ghost: `0.0` rigid, or a shared bridge's own state.
123pub(crate) fn stencil_update(grid: &StringGrid, j: usize, pin_now: f64, pin_prev: f64) -> f64 {
124    let n = grid.n;
125    let jj = j as isize;
126    let lap_now = ghost_pinned(&grid.y_now, n, jj + 1, pin_now) - 2.0 * grid.y_now[j]
127        + ghost_pinned(&grid.y_now, n, jj - 1, pin_now);
128    let biharm = ghost_pinned(&grid.y_now, n, jj + 2, pin_now)
129        - 4.0 * ghost_pinned(&grid.y_now, n, jj + 1, pin_now)
130        + 6.0 * grid.y_now[j]
131        - 4.0 * ghost_pinned(&grid.y_now, n, jj - 1, pin_now)
132        + ghost_pinned(&grid.y_now, n, jj - 2, pin_now);
133    let lap_prev = ghost_pinned(&grid.y_prev, n, jj + 1, pin_prev) - 2.0 * grid.y_prev[j]
134        + ghost_pinned(&grid.y_prev, n, jj - 1, pin_prev);
135    2.0 * grid.y_now[j] - grid.y_prev[j] + grid.courant_sq * lap_now
136        - grid.stiff_sq * biharm
137        - grid.damp_a * (grid.y_now[j] - grid.y_prev[j])
138        + grid.damp_b * (lap_now - lap_prev)
139}
140
141pub(crate) fn stiff_string_grid(
142    rho: f64,
143    c: f64,
144    length: f64,
145    kappa: f64,
146    damp_dc: f64,
147    damp_freq: f64,
148    sr: f64,
149) -> StringGrid {
150    let dt = 1.0 / sr;
151    let (c2, dt2, k2) = (c * c, dt * dt, kappa * kappa);
152    let dx_bound = ((c2 * dt2 + (c2 * c2 * dt2 * dt2 + 16.0 * k2 * dt2).sqrt()) / 2.0).sqrt();
153    let n = ((length / dx_bound).floor() as usize).max(4);
154    let dx = length / n as f64;
155
156    StringGrid {
157        y_now: vec![0.0; n + 1],
158        y_prev: vec![0.0; n + 1],
159        y_next: vec![0.0; n + 1],
160        n,
161        dx,
162        rho,
163        courant_sq: c2 * dt2 / (dx * dx),
164        stiff_sq: k2 * dt2 / dx.powi(4),
165        damp_a: 2.0 * damp_dc * dt,
166        damp_b: 2.0 * damp_freq * dt / (dx * dx),
167        far_termination: Termination::Rigid,
168    }
169}
170
171/// Derives a concrete grid from `(f0, b)`: fixes a generic wire's `c`, scales this note's own
172/// length `L = c/(2 f0)`, its stiffness `kappa = c L sqrt(b)/pi`, and a CFL-stable `dx`.
173fn build_grid(params: &ChaigneAskenfeltParams, f0: f64, sr: f64) -> StringGrid {
174    let rho = std::f64::consts::PI * WIRE_RADIUS_M * WIRE_RADIUS_M * WIRE_DENSITY_KG_M3;
175    let c = (STRING_TENSION_N / rho).sqrt();
176    let length = c / (2.0 * f0);
177    let kappa = c * length * params.b.sqrt() / std::f64::consts::PI;
178    stiff_string_grid(rho, c, length, kappa, params.damp_dc, params.damp_freq, sr)
179}
180
181/// Strings placed symmetrically in log frequency around `f0` at `+-sqrt(detune)`, then each
182/// moved by its own `cents`.
183fn unison_frequencies(f0: f64, detune: f64, unison_count: usize, cents: &[f64]) -> Vec<f64> {
184    let spread = detune.sqrt();
185    let placed = match unison_count {
186        1 => vec![f0],
187        2 => vec![f0 / spread, f0 * spread],
188        _ => vec![f0 / spread, f0, f0 * spread],
189    };
190    placed
191        .iter()
192        .zip(cents)
193        .map(|(&f, &c)| f * 2f64.powf(c / 1200.0))
194        .collect()
195}
196
197/// Bounds `valid()`, so the per-string force buffer can be a stack array.
198const MAX_UNISON: usize = 3;
199
200pub struct ChaigneAskenfeltSite {
201    strings: Vec<StringGrid>,
202    hammer: Hammer,
203    detached: bool,
204    contact_index: usize,
205    contact_indices: Vec<usize>,
206    strings_detached: Vec<bool>,
207    bridge_now: f64,
208    bridge_prev: f64,
209    bridge_coupling: f64,
210    bridge_mass: f64,
211    tension: f64,
212    dt: f64,
213}
214
215impl ChaigneAskenfeltSite {
216    pub fn new(params: &ChaigneAskenfeltParams, sr: f64) -> ChaigneAskenfeltSite {
217        let unison_count = params.unison_count.round().clamp(1.0, MAX_UNISON as f64) as usize;
218        let freqs =
219            unison_frequencies(params.f0, params.detune, unison_count, &params.string_cents);
220        let mut strings: Vec<StringGrid> =
221            freqs.iter().map(|&f0| build_grid(params, f0, sr)).collect();
222        if strings.len() > 1 {
223            for grid in &mut strings {
224                grid.far_termination = Termination::SharedBridge;
225            }
226        }
227        let contact_indices: Vec<usize> = strings
228            .iter()
229            .map(|g| {
230                (params.strike_pos * g.n as f64)
231                    .round()
232                    .clamp(1.0, (g.n - 1) as f64) as usize
233            })
234            .collect();
235        let contact_index = contact_indices[0];
236        let strings_detached = vec![false; strings.len()];
237        let dt = 1.0 / sr;
238        ChaigneAskenfeltSite {
239            strings,
240            hammer: Hammer::new(
241                params.hammer_mass,
242                params.hammer_k,
243                params.hammer_p,
244                params.vel,
245                dt,
246            )
247            .with_anvil_ratios(&params.string_hammer_k_ratio[..unison_count]),
248            detached: false,
249            contact_index,
250            contact_indices,
251            strings_detached,
252            bridge_now: 0.0,
253            bridge_prev: 0.0,
254            bridge_coupling: params.bridge_coupling,
255            bridge_mass: params.bridge_mass,
256            tension: STRING_TENSION_N,
257            dt,
258        }
259    }
260}
261
262impl Solver for ChaigneAskenfeltSite {
263    fn step(&mut self) -> f64 {
264        match self.strings[0].far_termination {
265            Termination::Rigid => self.step_single(),
266            Termination::SharedBridge => self.step_unison(),
267        }
268    }
269}
270
271impl ChaigneAskenfeltSite {
272    /// The contact point is frozen at this sample's start.
273    fn step_single(&mut self) -> f64 {
274        let grid = &mut self.strings[0];
275        let n = grid.n;
276        let y_h = grid.y_now[self.contact_index];
277
278        let mut forces = [0.0f64; 1];
279        self.hammer.substeps(
280            self.dt,
281            &[y_h],
282            std::slice::from_mut(&mut self.detached),
283            &mut forces,
284        );
285        let force = forces[0];
286
287        for i in 1..n {
288            let mut next = stencil_update(grid, i, 0.0, 0.0);
289            if i == self.contact_index {
290                next += (self.dt * self.dt / (grid.rho * grid.dx)) * force;
291            }
292            grid.y_next[i] = next;
293        }
294        grid.y_next[0] = 0.0;
295        grid.y_next[n] = 0.0;
296
297        let sample = self.tension * (grid.y_now[n] - grid.y_now[n - 1]) / grid.dx;
298
299        std::mem::swap(&mut grid.y_prev, &mut grid.y_now);
300        std::mem::swap(&mut grid.y_now, &mut grid.y_next);
301
302        sample
303    }
304
305    /// One hammer against every contact point: the reaction is summed, not divided.
306    fn step_unison(&mut self) -> f64 {
307        let count = self.strings.len();
308
309        let y_h: Vec<f64> = (0..count)
310            .map(|i| self.strings[i].y_now[self.contact_indices[i]])
311            .collect();
312
313        let mut forces = [0.0f64; MAX_UNISON];
314        let forces = &mut forces[..count];
315        self.hammer
316            .substeps(self.dt, &y_h, &mut self.strings_detached, forces);
317
318        let (bridge_now, bridge_prev) = (self.bridge_now, self.bridge_prev);
319        // Bridge `M a + R_B v = net string force`, implicit in its next position like the strings.
320        let mut k_eff = 0.0;
321        let mut rhs_sum = 0.0;
322        let mut sample = 0.0;
323        for (i, &force) in forces.iter().enumerate() {
324            let grid = &mut self.strings[i];
325            let n = grid.n;
326            for j in 1..n {
327                let mut next = stencil_update(grid, j, bridge_now, bridge_prev);
328                if j == self.contact_indices[i] {
329                    next += (self.dt * self.dt / (grid.rho * grid.dx)) * force;
330                }
331                grid.y_next[j] = next;
332            }
333            grid.y_next[0] = 0.0;
334            k_eff += self.tension / grid.dx;
335            rhs_sum += self.tension * grid.y_now[n - 1] / grid.dx;
336            sample += self.tension * (grid.y_now[n] - grid.y_now[n - 1]) / grid.dx;
337        }
338
339        let z_string = (self.tension * self.strings[0].rho).sqrt();
340        let r_bridge = self.bridge_coupling * z_string;
341        let r_over_dt = r_bridge / self.dt;
342        let m_over_dt2 = self.bridge_mass / (self.dt * self.dt);
343        let bridge_next =
344            (rhs_sum + r_over_dt * bridge_now + m_over_dt2 * (2.0 * bridge_now - bridge_prev))
345                / (k_eff + r_over_dt + m_over_dt2);
346        for grid in &mut self.strings {
347            let n = grid.n;
348            grid.y_next[n] = bridge_next;
349        }
350
351        self.bridge_prev = bridge_now;
352        self.bridge_now = bridge_next;
353
354        for grid in &mut self.strings {
355            std::mem::swap(&mut grid.y_prev, &mut grid.y_now);
356            std::mem::swap(&mut grid.y_now, &mut grid.y_next);
357        }
358
359        sample
360    }
361}