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::error::SampleError;
7use crate::physics::Solver;
8
9use crate::physics::bound::Bound::*;
10use crate::physics::bound::all;
11use crate::physics::hammer::Hammer;
12
13#[derive(Clone, Debug, PartialEq)]
14pub struct ChaigneAskenfeltParams {
15    pub f0: f64,
16    pub b: f64,
17    pub strike_pos: f64,
18    pub vel: f64,
19    pub hammer_mass: f64,
20    pub hammer_k: f64,
21    pub hammer_p: f64,
22    pub damp_dc: f64,
23    pub damp_freq: f64,
24    pub unison_count: f64,
25    pub detune: f64,
26    pub bridge_coupling: f64,
27    /// kg; `0` is massless.
28    pub bridge_mass: f64,
29    /// Cents off `detune`'s placing.
30    pub string_cents: [f64; MAX_UNISON],
31    pub string_hammer_k_ratio: [f64; MAX_UNISON],
32}
33
34impl ChaigneAskenfeltParams {
35    /// Chaigne & Askenfelt's own C4 reference set, at the fundamental asked for.
36    pub fn at(f0: f64) -> ChaigneAskenfeltParams {
37        ChaigneAskenfeltParams {
38            f0,
39            b: 0.00021,
40            strike_pos: 0.125,
41            vel: 3.2,
42            hammer_mass: 2.9e-3,
43            hammer_k: 2.6646e8,
44            hammer_p: 2.5,
45            damp_dc: 0.6,
46            damp_freq: 1.6e-4,
47            unison_count: 1.0,
48            detune: 2f64.powf(3.0 / 1200.0),
49            bridge_coupling: 1000.0,
50            bridge_mass: 0.0,
51            string_cents: [0.0; MAX_UNISON],
52            string_hammer_k_ratio: [1.0; MAX_UNISON],
53        }
54    }
55}
56
57impl ChaigneAskenfeltParams {
58    pub fn valid(&self) -> bool {
59        let [c1, c2, c3] = self.string_cents;
60        let [k1, k2, k3] = self.string_hammer_k_ratio;
61        all(&[
62            (self.f0, Positive),
63            (self.b, NonNegative),
64            (self.strike_pos, OpenUnit),
65            (self.vel, Positive),
66            (self.hammer_mass, Positive),
67            (self.hammer_k, Positive),
68            (self.hammer_p, Positive),
69            (self.damp_dc, NonNegative),
70            (self.damp_freq, NonNegative),
71            (self.unison_count, Within(1.0, MAX_UNISON as f64)),
72            (self.detune, AtLeast(1.0)),
73            (self.bridge_coupling, Positive),
74            (self.bridge_mass, NonNegative),
75            (c1, Finite),
76            (c2, Finite),
77            (c3, Finite),
78            (k1, Positive),
79            (k2, Positive),
80            (k3, Positive),
81        ])
82    }
83}
84
85const WIRE_DENSITY_KG_M3: f64 = 7850.0;
86/// A generic wire radius, tuned against this module's bridge-force and contact-time tests.
87const WIRE_RADIUS_M: f64 = 0.6e-3;
88/// A generic string tension, tuned alongside [`WIRE_RADIUS_M`].
89const STRING_TENSION_N: f64 = 1500.0;
90/// `SharedBridge`: terminated at the site's common bridge, not a rigid pin. Set uniformly
91/// across every string in a site; `step()` reads only `strings[0]`'s to dispatch.
92#[derive(Clone, Copy)]
93enum Termination {
94    Rigid,
95    SharedBridge,
96}
97
98pub(crate) struct StringGrid {
99    pub(crate) y_now: Vec<f64>,
100    pub(crate) y_prev: Vec<f64>,
101    pub(crate) y_next: Vec<f64>,
102    pub(crate) n: usize,
103    pub(crate) dx: f64,
104    pub(crate) rho: f64,
105    pub(crate) courant_sq: f64,
106    pub(crate) stiff_sq: f64,
107    pub(crate) damp_a: f64,
108    pub(crate) damp_b: f64,
109    far_termination: Termination,
110}
111
112/// The ghost a 5-point biharmonic stencil needs past `0` and `n`, far end `pin`.
113pub(crate) fn ghost_pinned(y: &[f64], n: usize, idx: isize, pin: f64) -> f64 {
114    if idx < 0 {
115        -y[(-idx) as usize]
116    } else if idx as usize > n {
117        2.0 * pin - y[(2 * n as isize - idx) as usize]
118    } else {
119        y[idx as usize]
120    }
121}
122
123/// `pin` is the far-end ghost: `0.0` rigid, or a shared bridge's own state.
124pub(crate) fn stencil_update(grid: &StringGrid, j: usize, pin_now: f64, pin_prev: f64) -> f64 {
125    let n = grid.n;
126    let jj = j as isize;
127    let lap_now = ghost_pinned(&grid.y_now, n, jj + 1, pin_now) - 2.0 * grid.y_now[j]
128        + ghost_pinned(&grid.y_now, n, jj - 1, pin_now);
129    let biharm = ghost_pinned(&grid.y_now, n, jj + 2, pin_now)
130        - 4.0 * ghost_pinned(&grid.y_now, n, jj + 1, pin_now)
131        + 6.0 * grid.y_now[j]
132        - 4.0 * ghost_pinned(&grid.y_now, n, jj - 1, pin_now)
133        + ghost_pinned(&grid.y_now, n, jj - 2, pin_now);
134    let lap_prev = ghost_pinned(&grid.y_prev, n, jj + 1, pin_prev) - 2.0 * grid.y_prev[j]
135        + ghost_pinned(&grid.y_prev, n, jj - 1, pin_prev);
136    2.0 * grid.y_now[j] - grid.y_prev[j] + grid.courant_sq * lap_now
137        - grid.stiff_sq * biharm
138        - grid.damp_a * (grid.y_now[j] - grid.y_prev[j])
139        + grid.damp_b * (lap_now - lap_prev)
140}
141
142pub(crate) fn stiff_string_grid(
143    rho: f64,
144    c: f64,
145    length: f64,
146    kappa: f64,
147    damp_dc: f64,
148    damp_freq: f64,
149    sr: f64,
150) -> StringGrid {
151    let dt = 1.0 / sr;
152    let n = finest_stable_points(c, length, kappa, dt).max(4);
153    let dx = length / n as f64;
154    let (c2, dt2, k2) = (c * c, dt * dt, kappa * kappa);
155    let mut grid = lossy_grid(n, rho, length, damp_dc, damp_freq, dt);
156    grid.courant_sq = c2 * dt2 / (dx * dx);
157    grid.stiff_sq = k2 * dt2 / dx.powi(4);
158    grid
159}
160
161/// The most intervals the lossless scheme keeps stable.
162fn finest_stable_points(c: f64, length: f64, kappa: f64, dt: f64) -> usize {
163    let (c2, dt2, k2) = (c * c, dt * dt, kappa * kappa);
164    let dx_bound = ((c2 * dt2 + (c2 * c2 * dt2 * dt2 + 16.0 * k2 * dt2).sqrt()) / 2.0).sqrt();
165    (length / dx_bound).floor() as usize
166}
167
168/// At rest, losses set; the caller sets the restoring terms.
169fn lossy_grid(
170    n: usize,
171    rho: f64,
172    length: f64,
173    damp_dc: f64,
174    damp_freq: f64,
175    dt: f64,
176) -> StringGrid {
177    let dx = length / n as f64;
178    StringGrid {
179        y_now: vec![0.0; n + 1],
180        y_prev: vec![0.0; n + 1],
181        y_next: vec![0.0; n + 1],
182        n,
183        dx,
184        rho,
185        courant_sq: 0.0,
186        stiff_sq: 0.0,
187        damp_a: 2.0 * damp_dc * dt,
188        damp_b: 2.0 * damp_freq * dt / (dx * dx),
189        far_termination: Termination::Rigid,
190    }
191}
192
193/// Pinned mode `m` is exactly `sin(m pi j/n)`.
194fn mode_s(m: usize, n: usize) -> f64 {
195    (m as f64 * std::f64::consts::PI / (2.0 * n as f64))
196        .sin()
197        .powi(2)
198}
199
200fn mode_sigma(grid: &StringGrid, s: f64) -> f64 {
201    grid.damp_a + 4.0 * grid.damp_b * s
202}
203
204/// `2 - sigma - 2 sqrt(1 - sigma) cos(theta)`, without cancellation.
205fn stiffness_for(theta: f64, sigma: f64) -> f64 {
206    let r = (1.0 - sigma).sqrt();
207    (sigma / (1.0 + r)).powi(2) + 4.0 * r * (theta / 2.0).sin().powi(2)
208}
209
210/// Jury's test on `z^2 + (D + sigma - 2) z + 1 - sigma`, every mode.
211fn is_stable(grid: &StringGrid) -> bool {
212    (1..grid.n).all(|m| {
213        let s = mode_s(m, grid.n);
214        let sigma = mode_sigma(grid, s);
215        let d = 4.0 * grid.courant_sq * s + 16.0 * grid.stiff_sq * s * s;
216        (0.0..2.0).contains(&sigma) && d > 0.0 && d < 4.0 - 2.0 * sigma
217    })
218}
219
220/// `L = c/(2 f0)`, then the discrete dispersion relation inverted so partials 1 and 2 ring at
221/// `k f0 sqrt(1 + b k^2)`, on the finest stable grid.
222fn build_grid(params: &ChaigneAskenfeltParams, f0: f64, sr: f64) -> Option<StringGrid> {
223    let dt = 1.0 / sr;
224    let rho = std::f64::consts::PI * WIRE_RADIUS_M * WIRE_RADIUS_M * WIRE_DENSITY_KG_M3;
225    let c = (STRING_TENSION_N / rho).sqrt();
226    let length = c / (2.0 * f0);
227    let kappa = c * length * params.b.sqrt() / std::f64::consts::PI;
228    let theta = |k: f64| std::f64::consts::TAU * f0 * k * (1.0 + params.b * k * k).sqrt() * dt;
229    let (theta_1, theta_2) = (theta(1.0), theta(2.0));
230    if theta_2 >= std::f64::consts::PI {
231        return None;
232    }
233    (3..=finest_stable_points(c, length, kappa, dt))
234        .rev()
235        .find_map(|n| {
236            let mut grid = lossy_grid(n, rho, length, params.damp_dc, params.damp_freq, dt);
237            let (s1, s2) = (mode_s(1, n), mode_s(2, n));
238            let (sigma_1, sigma_2) = (mode_sigma(&grid, s1), mode_sigma(&grid, s2));
239            if sigma_1 >= 1.0 || sigma_2 >= 1.0 {
240                return None;
241            }
242            let (d1, d2) = (
243                stiffness_for(theta_1, sigma_1),
244                stiffness_for(theta_2, sigma_2),
245            );
246            // `D_m = 4 lambda^2 s_m + 16 mu^2 s_m^2`, m = 1, 2.
247            let det = s1 * s2 * (s2 - s1);
248            grid.courant_sq = (d1 * s2 * s2 - d2 * s1 * s1) / (4.0 * det);
249            grid.stiff_sq = (s1 * d2 - s2 * d1) / (16.0 * det);
250            (grid.courant_sq > 0.0 && is_stable(&grid)).then_some(grid)
251        })
252}
253
254/// Modal content `sin(m pi x)` for every grid mode; `1` alone on a node. Read and spread alike.
255fn point_weights(n: usize, x: f64) -> Vec<f64> {
256    let nf = n as f64;
257    // sum_{m=1}^{n-1} cos(m theta)
258    let cos_sum = |theta: f64| {
259        let half = theta / 2.0;
260        let sh = half.sin();
261        if sh == 0.0 {
262            nf - 1.0
263        } else {
264            (nf * half).sin() * ((nf - 1.0) * half).cos() / sh - 1.0
265        }
266    };
267    (0..=n)
268        .map(|j| match j {
269            0 => 0.0,
270            j if j == n => 0.0,
271            j => {
272                let xj = j as f64 / nf;
273                let pi = std::f64::consts::PI;
274                (cos_sum(pi * (x - xj)) - cos_sum(pi * (x + xj))) / nf
275            }
276        })
277        .collect()
278}
279
280/// Strings placed symmetrically in log frequency around `f0` at `+-sqrt(detune)`, then each
281/// moved by its own `cents`.
282fn unison_frequencies(f0: f64, detune: f64, unison_count: usize, cents: &[f64]) -> Vec<f64> {
283    let spread = detune.sqrt();
284    let placed = match unison_count {
285        1 => vec![f0],
286        2 => vec![f0 / spread, f0 * spread],
287        _ => vec![f0 / spread, f0, f0 * spread],
288    };
289    placed
290        .iter()
291        .zip(cents)
292        .map(|(&f, &c)| f * 2f64.powf(c / 1200.0))
293        .collect()
294}
295
296/// Bounds `valid()`, so the per-string force buffer can be a stack array.
297const MAX_UNISON: usize = 3;
298
299pub struct ChaigneAskenfeltSite {
300    strings: Vec<StringGrid>,
301    /// Per string, `rho (lambda dx/dt)^2`.
302    tensions: Vec<f64>,
303    strike: Vec<Vec<f64>>,
304    hammer: Hammer,
305    detached: Vec<bool>,
306    bridge_now: f64,
307    bridge_prev: f64,
308    bridge_coupling: f64,
309    bridge_mass: f64,
310    dt: f64,
311}
312
313impl ChaigneAskenfeltSite {
314    pub fn new(params: &ChaigneAskenfeltParams, sr: f64) -> Result<Self, SampleError> {
315        let unison_count = params.unison_count.round().clamp(1.0, MAX_UNISON as f64) as usize;
316        let freqs =
317            unison_frequencies(params.f0, params.detune, unison_count, &params.string_cents);
318        let mut strings = freqs
319            .iter()
320            .map(|&f0| build_grid(params, f0, sr))
321            .collect::<Option<Vec<StringGrid>>>()
322            .ok_or(SampleError::StringPastRate {
323                model: "chaigne_askenfelt",
324            })?;
325        if strings.len() > 1 {
326            for grid in &mut strings {
327                grid.far_termination = Termination::SharedBridge;
328            }
329        }
330        let dt = 1.0 / sr;
331        let tensions = strings
332            .iter()
333            .map(|g| g.rho * g.courant_sq * g.dx * g.dx / (dt * dt))
334            .collect();
335        let strike = strings
336            .iter()
337            .map(|g| point_weights(g.n, params.strike_pos))
338            .collect();
339        Ok(ChaigneAskenfeltSite {
340            detached: vec![false; strings.len()],
341            strings,
342            tensions,
343            strike,
344            hammer: Hammer::new(
345                params.hammer_mass,
346                params.hammer_k,
347                params.hammer_p,
348                params.vel,
349                dt,
350            )
351            .with_anvil_ratios(&params.string_hammer_k_ratio[..unison_count]),
352            bridge_now: 0.0,
353            bridge_prev: 0.0,
354            bridge_coupling: params.bridge_coupling,
355            bridge_mass: params.bridge_mass,
356            dt,
357        })
358    }
359}
360
361impl Solver for ChaigneAskenfeltSite {
362    fn step(&mut self) -> f64 {
363        match self.strings[0].far_termination {
364            Termination::Rigid => self.step_single(),
365            Termination::SharedBridge => self.step_unison(),
366        }
367    }
368}
369
370impl ChaigneAskenfeltSite {
371    /// A released anvil is not read.
372    fn hammer_forces(&mut self, forces: &mut [f64]) {
373        let mut y_h = [0.0f64; MAX_UNISON];
374        for (i, grid) in self.strings.iter().enumerate() {
375            if !self.detached[i] {
376                y_h[i] = self.strike[i]
377                    .iter()
378                    .zip(&grid.y_now)
379                    .map(|(w, y)| w * y)
380                    .sum();
381            }
382        }
383        self.hammer.substeps(
384            self.dt,
385            &y_h[..self.strings.len()],
386            &mut self.detached,
387            forces,
388        );
389    }
390
391    fn step_single(&mut self) -> f64 {
392        let mut forces = [0.0f64; 1];
393        self.hammer_forces(&mut forces);
394        let grid = &mut self.strings[0];
395        let n = grid.n;
396        for i in 1..n {
397            grid.y_next[i] = stencil_update(grid, i, 0.0, 0.0);
398        }
399        spread(grid, &self.strike[0], forces[0], self.dt);
400        grid.y_next[0] = 0.0;
401        grid.y_next[n] = 0.0;
402
403        let sample = self.tensions[0] * (grid.y_now[n] - grid.y_now[n - 1]) / grid.dx;
404
405        std::mem::swap(&mut grid.y_prev, &mut grid.y_now);
406        std::mem::swap(&mut grid.y_now, &mut grid.y_next);
407
408        sample
409    }
410
411    /// The hammer's reaction is summed over the strings, not divided.
412    fn step_unison(&mut self) -> f64 {
413        let mut forces = [0.0f64; MAX_UNISON];
414        let forces = &mut forces[..self.strings.len()];
415        self.hammer_forces(forces);
416
417        let (bridge_now, bridge_prev) = (self.bridge_now, self.bridge_prev);
418        // Bridge `M a + R_B v = net string force`, implicit in its next position like the strings.
419        let mut k_eff = 0.0;
420        let mut rhs_sum = 0.0;
421        let mut sample = 0.0;
422        for (i, &force) in forces.iter().enumerate() {
423            let grid = &mut self.strings[i];
424            let tension = self.tensions[i];
425            let n = grid.n;
426            for j in 1..n {
427                grid.y_next[j] = stencil_update(grid, j, bridge_now, bridge_prev);
428            }
429            spread(grid, &self.strike[i], force, self.dt);
430            grid.y_next[0] = 0.0;
431            k_eff += tension / grid.dx;
432            rhs_sum += tension * grid.y_now[n - 1] / grid.dx;
433            sample += tension * (grid.y_now[n] - grid.y_now[n - 1]) / grid.dx;
434        }
435
436        let z_string = (self.tensions[0] * self.strings[0].rho).sqrt();
437        let r_bridge = self.bridge_coupling * z_string;
438        let r_over_dt = r_bridge / self.dt;
439        let m_over_dt2 = self.bridge_mass / (self.dt * self.dt);
440        let bridge_next =
441            (rhs_sum + r_over_dt * bridge_now + m_over_dt2 * (2.0 * bridge_now - bridge_prev))
442                / (k_eff + r_over_dt + m_over_dt2);
443        for grid in &mut self.strings {
444            let n = grid.n;
445            grid.y_next[n] = bridge_next;
446        }
447
448        self.bridge_prev = bridge_now;
449        self.bridge_now = bridge_next;
450
451        for grid in &mut self.strings {
452            std::mem::swap(&mut grid.y_prev, &mut grid.y_now);
453            std::mem::swap(&mut grid.y_now, &mut grid.y_next);
454        }
455
456        sample
457    }
458}
459
460/// The readout's adjoint.
461fn spread(grid: &mut StringGrid, weights: &[f64], force: f64, dt: f64) {
462    if force == 0.0 {
463        return;
464    }
465    let scale = (dt * dt / (grid.rho * grid.dx)) * force;
466    for (y, w) in grid.y_next.iter_mut().zip(weights) {
467        *y += scale * w;
468    }
469}