1// Time integration of the bead system.
2//
3// The conservative part (springs, external force pulses) is integrated with
4// velocity Verlet, optionally composed into Yoshida's fourth-order scheme.
5// Damping (friction and dashpots) is linear in the velocities, dv/dt = -A v,
6// and is applied as a separate exact step v <- exp(-tau A) v, once per audio
7// sample (Strang splitting over the sample interval Delta = 1/fs). The
8// splitting error is second order in Delta and scales with the damping rate,
9// which is small compared with the oscillation frequencies; the conservative
10// part is second order (Verlet) or fourth order (Yoshida) in the step h.
12import type { BeadSystem, Method, Modes, SimResult } from "./types";
14const YOSHIDA_W1 = 1 / (2 - Math.cbrt(2));
15const YOSHIDA_W0 = -Math.cbrt(2) * YOSHIDA_W1;
17export function stepCoefficients(method: Method): number[] {
18 return method === "yoshida4" ? [YOSHIDA_W1, YOSHIDA_W0, YOSHIDA_W1] : [1];
19}
21// Largest h*omega for which the method is stable on x'' = -omega^2 x.
22export function stabilityLimit(method: Method): number {
23 if (method === "verlet") return 2;
24 const cs = stepCoefficients(method);
25 const stable = (h: number) => {
26 // transfer matrix of one full step for omega = 1
27 let m = [1, 0, 0, 1]; // [x<-x, x<-v, v<-x, v<-v]
28 const mul = (a: number[], b: number[]) => [
29 a[0] * b[0] + a[1] * b[2],
30 a[0] * b[1] + a[1] * b[3],
31 a[2] * b[0] + a[3] * b[2],
32 a[2] * b[1] + a[3] * b[3],
33 ];
34 for (const c of cs) {
35 const kick = [1, 0, (-c * h) / 2, 1];
36 const drift = [1, c * h, 0, 1];
37 m = mul(kick, mul(drift, mul(kick, m)));
38 }
39 return Math.abs(m[0] + m[3]) < 2;
40 };
41 let h = 1e-3;
42 while (stable(h + 1e-3)) h += 1e-3;
43 return h;
44}
46// Error of the numerical oscillation frequency for x'' = -omega^2 x, in cents
47// (positive: the simulated tone is sharp).
48export function frequencyErrorCents(method: Method, omega: number, h: number): number {
49 let m = [1, 0, 0, 1];
50 const mul = (a: number[], b: number[]) => [
51 a[0] * b[0] + a[1] * b[2],
52 a[0] * b[1] + a[1] * b[3],
53 a[2] * b[0] + a[3] * b[2],
54 a[2] * b[1] + a[3] * b[3],
55 ];
56 for (const c of stepCoefficients(method)) {
57 const kick = [1, 0, (-c * h * omega * omega) / 2, 1];
58 const drift = [1, c * h, 0, 1];
59 m = mul(kick, mul(drift, mul(kick, m)));
60 }
61 const half = (m[0] + m[3]) / 2;
62 if (Math.abs(half) >= 1) return NaN;
63 const omegaNum = Math.acos(half) / h;
64 return 1200 * Math.log2(omegaNum / omega);
65}
67export interface SimOptions {
68 modes?: Modes | null;
69 onProgress?: (t: number) => void;
70 maxAnimBytes?: number;
71}
73export function simulate(sys: BeadSystem, opts: SimOptions = {}): SimResult {
74 const t0 = performance.now();
75 const warnings: string[] = [];
76 const { n, mass, pinned } = sys;
77 const N3 = 3 * n;
78 const x = new Float64Array(sys.pos0);
79 const v = new Float64Array(N3);
80 const F = new Float64Array(N3);
81 const invm = new Float64Array(n);
82 for (let i = 0; i < n; i++) invm[i] = pinned[i] ? 0 : 1 / mass[i];
84 // ---- forces ------------------------------------------------------------
85 const { si, sj, sk, sL0, sk3 } = sys;
86 const ns = si.length;
87 const internalForces = (): number => {
88 F.fill(0);
89 let pe = 0;
90 for (let s = 0; s < ns; s++) {
91 const i3 = 3 * si[s];
92 const j3 = 3 * sj[s];
93 const dx = x[j3] - x[i3];
94 const dy = x[j3 + 1] - x[i3 + 1];
95 const dz = x[j3 + 2] - x[i3 + 2];
96 const L = Math.sqrt(dx * dx + dy * dy + dz * dz);
97 const e = L - sL0[s];
98 const k = sk[s];
99 const k3 = sk3[s];
100 const e2 = e * e;
101 pe += 0.5 * k * e2 + 0.25 * k3 * e2 * e2;
102 const g = (k * e + k3 * e2 * e) / L;
103 F[i3] += g * dx;
104 F[i3 + 1] += g * dy;
105 F[i3 + 2] += g * dz;
106 F[j3] -= g * dx;
107 F[j3 + 1] -= g * dy;
108 F[j3 + 2] -= g * dz;
109 }
110 return pe;
111 };
112 const pulses = sys.hits.filter(h => h.duration > 0);
113 const externalForces = (t: number) => {
114 for (const p of pulses) {
115 const u = (t - p.t) / p.duration;
116 if (u <= 0 || u >= 1) continue;
117 const w = 0.5 * (1 - Math.cos(2 * Math.PI * u));
118 for (const b of p.beads) {
119 F[3 * b] += w * p.force[0];
120 F[3 * b + 1] += w * p.force[1];
121 F[3 * b + 2] += w * p.force[2];
122 }
123 }
124 };
126 // ---- rest state checks ---------------------------------------------------
127 let pe = internalForces();
128 const peRest = pe; // energy stored by pre-tension; energies are reported relative to rest
129 let residualForce = 0;
130 for (let i = 0; i < n; i++) {
131 if (pinned[i]) continue;
132 const f = Math.hypot(F[3 * i], F[3 * i + 1], F[3 * i + 2]);
133 if (f > residualForce) residualForce = f;
134 }
135 let maxTension = 0;
136 for (let s = 0; s < ns; s++) {
137 const i3 = 3 * si[s];
138 const j3 = 3 * sj[s];
139 const L = Math.hypot(x[j3] - x[i3], x[j3 + 1] - x[i3 + 1], x[j3 + 2] - x[i3 + 2]);
140 const e = L - sL0[s];
141 maxTension = Math.max(maxTension, Math.abs(sk[s] * e + sk3[s] * e * e * e));
142 }
143 if (residualForce > 1e-6 * Math.max(maxTension, 1e-30)) {
144 warnings.push(
145 `The rest positions are not an equilibrium: the largest net force on a free bead is ${residualForce.toExponential(2)} N ` +
146 `(largest spring force ${maxTension.toExponential(2)} N). The beads will move before any strike.`
147 );
148 }
150 // ---- time step -----------------------------------------------------------
151 const omegaMax = estimateOmegaMax(sys);
152 const fs = sys.sampleRate;
153 const hLimit = stabilityLimit(sys.method) / omegaMax;
154 let substeps: number;
155 if (sys.dt > 0) {
156 substeps = Math.max(1, Math.round(1 / (fs * sys.dt)));
157 if (1 / (fs * substeps) > hLimit) {
158 warnings.push(
159 `dt = ${(1 / (fs * substeps)).toExponential(3)} s exceeds the stability limit ${hLimit.toExponential(3)} s.`
160 );
161 }
162 } else {
163 substeps = Math.max(1, Math.ceil(1 / (fs * sys.safety * hLimit)));
164 }
165 const h = 1 / (fs * substeps);
166 const nSamples = Math.max(1, Math.round(sys.duration * fs));
167 const nSteps = nSamples * substeps;
168 const cs = stepCoefficients(sys.method);
170 // ---- damping: v <- exp(-tau A) v ------------------------------------------
171 const { di, dj, dc, friction } = sys;
172 const nd = di.length;
173 let normA = 0; // infinity-norm bound of A
174 {
175 const row = new Float64Array(n);
176 for (let i = 0; i < n; i++) row[i] = friction[i];
177 for (let d = 0; d < nd; d++) {
178 row[di[d]] += 2 * dc[d];
179 row[dj[d]] += 2 * dc[d];
180 }
181 for (let i = 0; i < n; i++) normA = Math.max(normA, row[i] * invm[i]);
182 }
183 const hasDamping = normA > 0;
184 const frictionOnly = nd === 0;
185 const term = new Float64Array(N3);
186 const tmp = new Float64Array(N3);
187 const fa = new Float64Array(n);
188 for (let i = 0; i < n; i++) fa[i] = friction[i] * invm[i];
189 const dai = new Float64Array(nd);
190 const daj = new Float64Array(nd);
191 for (let d = 0; d < nd; d++) {
192 dai[d] = dc[d] * invm[di[d]];
193 daj[d] = dc[d] * invm[dj[d]];
194 }
195 const applyA = (w: Float64Array, out: Float64Array) => {
196 for (let i = 0; i < n; i++) {
197 const a = fa[i];
198 out[3 * i] = a * w[3 * i];
199 out[3 * i + 1] = a * w[3 * i + 1];
200 out[3 * i + 2] = a * w[3 * i + 2];
201 }
202 for (let d = 0; d < nd; d++) {
203 const i3 = 3 * di[d];
204 const j3 = 3 * dj[d];
205 const ai = dai[d];
206 const aj = daj[d];
207 const r0 = w[i3] - w[j3];
208 const r1 = w[i3 + 1] - w[j3 + 1];
209 const r2 = w[i3 + 2] - w[j3 + 2];
210 out[i3] += ai * r0;
211 out[i3 + 1] += ai * r1;
212 out[i3 + 2] += ai * r2;
213 out[j3] -= aj * r0;
214 out[j3 + 1] -= aj * r1;
215 out[j3 + 2] -= aj * r2;
216 }
217 };
218 const kinetic = () => {
219 let ke = 0;
220 for (let i = 0; i < n; i++) {
221 ke += 0.5 * mass[i] * (v[3 * i] ** 2 + v[3 * i + 1] ** 2 + v[3 * i + 2] ** 2);
222 }
223 return ke;
224 };
225 let dissipated = 0;
226 const applyDamping = (tau: number) => {
227 if (!hasDamping || tau === 0) return;
228 const keBefore = kinetic();
229 if (frictionOnly) {
230 for (let i = 0; i < n; i++) {
231 const f = Math.exp(-tau * friction[i] * invm[i]);
232 v[3 * i] *= f;
233 v[3 * i + 1] *= f;
234 v[3 * i + 2] *= f;
235 }
236 } else {
237 // Taylor series with scaling; |tau_s| * normA <= 0.5 so terms shrink fast.
238 const nsub = Math.max(1, Math.ceil((Math.abs(tau) * normA) / 0.5));
239 const ts = tau / nsub;
240 for (let r = 0; r < nsub; r++) {
241 let vmax = 0;
242 for (let q = 0; q < N3; q++) {
243 term[q] = v[q];
244 vmax = Math.max(vmax, Math.abs(v[q]));
245 }
246 if (vmax === 0) break;
247 for (let k = 1; k < 40; k++) {
248 applyA(term, tmp);
249 const c = -ts / k;
250 let tmax = 0;
251 for (let q = 0; q < N3; q++) {
252 const val = c * tmp[q];
253 term[q] = val;
254 v[q] += val;
255 const a = Math.abs(val);
256 if (a > tmax) tmax = a;
257 }
258 if (tmax <= 1e-17 * vmax) break;
259 }
260 }
261 }
262 dissipated += keBefore - kinetic();
263 };
265 // ---- recording -------------------------------------------------------------
266 const audio = new Float32Array(nSamples);
267 const lb = sys.listenBeads;
268 const [ldx, ldy, ldz] = sys.listenDir;
269 const pos0 = sys.pos0;
270 const listen = () => {
271 let s = 0;
272 for (const b of lb) {
273 s +=
274 (x[3 * b] - pos0[3 * b]) * ldx +
275 (x[3 * b + 1] - pos0[3 * b + 1]) * ldy +
276 (x[3 * b + 2] - pos0[3 * b + 2]) * ldz;
277 }
278 return s / lb.length;
279 };
280 const frameStride = Math.max(1, Math.round(fs / sys.frameRate));
281 const nFrames = Math.floor((nSamples - 1) / frameStride) + 1;
282 const maxAnimBytes = opts.maxAnimBytes ?? 150e6;
283 const animStride = frameStride * Math.max(1, Math.ceil((nFrames * N3 * 4) / maxAnimBytes));
284 const frameTimes = new Float64Array(nFrames);
285 const energy = new Float64Array(nFrames);
286 const dissipatedRec = new Float64Array(nFrames);
287 const animTimes: number[] = [];
288 const animFrames: Float32Array[] = [];
290 const modes = opts.modes ?? null;
291 const nModes = modes ? modes.freqs.length : 0;
292 const modeEnergy = modes ? new Float32Array(nFrames * nModes) : null;
293 let bz: Float64Array | null = null;
294 let bv: Float64Array | null = null;
295 let om2: Float64Array | null = null;
296 if (modes) {
297 bz = new Float64Array(modes.free.length);
298 bv = new Float64Array(modes.free.length);
299 om2 = new Float64Array(nModes);
300 for (let k = 0; k < nModes; k++) om2[k] = (2 * Math.PI * modes.freqs[k]) ** 2;
301 }
302 const recordModes = (f: number) => {
303 if (!modes || !modeEnergy || !bz || !bv || !om2) return;
304 const free = modes.free;
305 const nf = free.length;
306 for (let r = 0; r < nf; r++) {
307 const b = free[r];
308 const sm = Math.sqrt(mass[b]);
309 bz[r] = sm * (x[3 * b + 2] - pos0[3 * b + 2]);
310 bv[r] = sm * v[3 * b + 2];
311 }
312 const psi = modes.psi;
313 for (let k = 0; k < nModes; k++) {
314 let q = 0;
315 let qd = 0;
316 const off = k * nf;
317 for (let r = 0; r < nf; r++) {
318 q += psi[off + r] * bz[r];
319 qd += psi[off + r] * bv[r];
320 }
321 modeEnergy[f * nModes + k] = 0.5 * (qd * qd + om2[k] * q * q);
322 }
323 };
325 // ---- impulses, by audio sample ----------------------------------------------
326 const impulsesAt = new Map<number, typeof sys.hits>();
327 for (const hit of sys.hits) {
328 if (hit.duration > 0) continue;
329 const s = Math.round(hit.t * fs);
330 if (s >= nSamples) continue;
331 if (!impulsesAt.has(s)) impulsesAt.set(s, []);
332 impulsesAt.get(s)!.push(hit);
333 }
334 const tExcitationEnd = Math.max(0, ...sys.hits.map(hh => hh.t + hh.duration));
336 // ---- main loop ---------------------------------------------------------------
337 // Each audio sample interval Delta = substeps * h is one macro step:
338 // D(Delta/2) [substeps conservative steps] D(Delta/2). The damping halves of
339 // neighboring macro steps are merged unless something needs the velocities
340 // at the boundary (a frame record or an impulse).
341 const Delta = 1 / fs;
342 pe = internalForces();
343 externalForces(0);
344 let pendingD = 0;
345 let aborted: string | null = null;
346 let frame = 0;
347 let lastProgress = performance.now();
348 for (let s = 0; s <= nSteps; s++) {
349 const t = s * h;
350 const sample = s % substeps === 0 ? s / substeps : -1;
351 const isFrame = sample >= 0 && sample % frameStride === 0 && frame < nFrames;
352 const imps = sample >= 0 ? impulsesAt.get(sample) : undefined;
353 if ((isFrame || imps) && pendingD !== 0) {
354 applyDamping(pendingD);
355 pendingD = 0;
356 }
357 if (imps) {
358 for (const hit of imps) {
359 for (const b of hit.beads) {
360 if (pinned[b]) continue;
361 v[3 * b] += hit.velocity[0];
362 v[3 * b + 1] += hit.velocity[1];
363 v[3 * b + 2] += hit.velocity[2];
364 }
365 }
366 }
367 if (sample >= 0 && sample < nSamples) audio[sample] = listen();
368 if (isFrame) {
369 frameTimes[frame] = t;
370 energy[frame] = kinetic() + pe - peRest;
371 dissipatedRec[frame] = dissipated;
372 recordModes(frame);
373 if (sample % animStride === 0) {
374 const d = new Float32Array(N3);
375 for (let q = 0; q < N3; q++) d[q] = x[q] - pos0[q];
376 animTimes.push(t);
377 animFrames.push(d);
378 }
379 frame++;
380 }
381 if (s === nSteps) break;
382 if (sample >= 0) {
383 pendingD += Delta / 2;
384 applyDamping(pendingD);
385 pendingD = 0;
386 }
388 let tj = t;
389 for (const c of cs) {
390 const kh = (c * h) / 2;
391 for (let i = 0; i < n; i++) {
392 const im = invm[i] * kh;
393 v[3 * i] += im * F[3 * i];
394 v[3 * i + 1] += im * F[3 * i + 1];
395 v[3 * i + 2] += im * F[3 * i + 2];
396 }
397 const dh = c * h;
398 for (let q = 0; q < N3; q++) x[q] += dh * v[q];
399 tj += c * h;
400 pe = internalForces();
401 externalForces(tj);
402 for (let i = 0; i < n; i++) {
403 const im = invm[i] * kh;
404 v[3 * i] += im * F[3 * i];
405 v[3 * i + 1] += im * F[3 * i + 1];
406 v[3 * i + 2] += im * F[3 * i + 2];
407 }
408 }
409 if ((s + 1) % substeps === 0) pendingD = Delta / 2;
411 if ((s & 1023) === 0) {
412 if (!Number.isFinite(pe)) {
413 aborted = `The solution blew up at t = ${t.toFixed(4)} s. Reduce dt (or safety) or the strike strength.`;
414 break;
415 }
416 const now = performance.now();
417 if (opts.onProgress && now - lastProgress > 200) {
418 opts.onProgress(t);
419 lastProgress = now;
420 }
421 }
422 }
424 // energy drift after the last excitation
425 let energyDrift = 0;
426 let ref = -1;
427 for (let f = 0; f < frame; f++) {
428 if (frameTimes[f] < tExcitationEnd) continue;
429 const total = energy[f] + dissipatedRec[f];
430 if (ref < 0) ref = total;
431 else if (ref > 0) energyDrift = Math.max(energyDrift, Math.abs(total - ref) / ref);
432 }
434 return {
435 audio,
436 sampleRate: fs,
437 dt: h,
438 substeps,
439 omegaMax,
440 frameTimes: frameTimes.slice(0, frame),
441 energy: energy.slice(0, frame),
442 dissipated: dissipatedRec.slice(0, frame),
443 animTimes: Float64Array.from(animTimes),
444 animFrames,
445 modeEnergy: modeEnergy ? modeEnergy.slice(0, frame * nModes) : null,
446 residualForce,
447 maxTension,
448 energyDrift,
449 freqErrors: [1000, 2000, 5000, 10000, 20000]
450 .filter(hz => hz < fs / 2)
451 .map(hz => ({ hz, cents: frequencyErrorCents(sys.method, 2 * Math.PI * hz, h) })),
452 elapsedMs: performance.now() - t0,
453 aborted,
454 warnings,
455 };
456}
458// Highest frequency (rad/s) of the system linearized about the rest positions,
459// by power iteration on M^{-1/2} H M^{-1/2}, with a small margin.
460export function estimateOmegaMax(sys: BeadSystem): number {
461 const { n, si, sj, sk, sL0, sk3, pos0, mass, pinned } = sys;
462 const ns = si.length;
463 // per-spring 3x3 blocks B = keff d d^T + (f/L)(I - d d^T)
464 const B = new Float64Array(ns * 6);
465 for (let s = 0; s < ns; s++) {
466 const i3 = 3 * si[s];
467 const j3 = 3 * sj[s];
468 const dx = pos0[j3] - pos0[i3];
469 const dy = pos0[j3 + 1] - pos0[i3 + 1];
470 const dz = pos0[j3 + 2] - pos0[i3 + 2];
471 const L = Math.sqrt(dx * dx + dy * dy + dz * dz);
472 const e = L - sL0[s];
473 const keff = sk[s] + 3 * sk3[s] * e * e;
474 const g = (sk[s] * e + sk3[s] * e * e * e) / L;
475 const u = [dx / L, dy / L, dz / L];
476 const a = keff - g;
477 B[6 * s] = a * u[0] * u[0] + g;
478 B[6 * s + 1] = a * u[1] * u[1] + g;
479 B[6 * s + 2] = a * u[2] * u[2] + g;
480 B[6 * s + 3] = a * u[0] * u[1];
481 B[6 * s + 4] = a * u[0] * u[2];
482 B[6 * s + 5] = a * u[1] * u[2];
483 }
484 const ism = new Float64Array(n);
485 for (let i = 0; i < n; i++) ism[i] = pinned[i] ? 0 : 1 / Math.sqrt(mass[i]);
486 let w = new Float64Array(3 * n);
487 let seed = 12345;
488 for (let q = 0; q < 3 * n; q++) {
489 seed = (seed * 1103515245 + 12345) & 0x7fffffff;
490 w[q] = ism[Math.floor(q / 3)] * (seed / 0x7fffffff - 0.5);
491 }
492 const u = new Float64Array(3 * n);
493 const y = new Float64Array(3 * n);
494 let lambda = 0;
495 for (let it = 0; it < 500; it++) {
496 let norm = 0;
497 for (let q = 0; q < 3 * n; q++) norm += w[q] * w[q];
498 norm = Math.sqrt(norm);
499 if (norm === 0) return 1;
500 for (let q = 0; q < 3 * n; q++) u[q] = (w[q] / norm) * ism[Math.floor(q / 3)];
501 y.fill(0);
502 for (let s = 0; s < ns; s++) {
503 const i3 = 3 * si[s];
504 const j3 = 3 * sj[s];
505 const r0 = u[i3] - u[j3];
506 const r1 = u[i3 + 1] - u[j3 + 1];
507 const r2 = u[i3 + 2] - u[j3 + 2];
508 const b = 6 * s;
509 const f0 = B[b] * r0 + B[b + 3] * r1 + B[b + 4] * r2;
510 const f1 = B[b + 3] * r0 + B[b + 1] * r1 + B[b + 5] * r2;
511 const f2 = B[b + 4] * r0 + B[b + 5] * r1 + B[b + 2] * r2;
512 y[i3] += f0;
513 y[i3 + 1] += f1;
514 y[i3 + 2] += f2;
515 y[j3] -= f0;
516 y[j3 + 1] -= f1;
517 y[j3 + 2] -= f2;
518 }
519 let rq = 0;
520 const wNext = new Float64Array(3 * n);
521 for (let q = 0; q < 3 * n; q++) {
522 wNext[q] = y[q] * ism[Math.floor(q / 3)];
523 rq += (w[q] / norm) * wNext[q];
524 }
525 w = wNext;
526 if (it > 20 && Math.abs(rq - lambda) < 1e-8 * Math.abs(rq)) {
527 lambda = rq;
528 break;
529 }
530 lambda = rq;
531 }
532 return Math.sqrt(Math.max(lambda, 1e-300)) * 1.02;
533}