/ proof-of-concept / cymbalsim
Sign in
proof-of-concept / cymbalsim
cymbalsim / src / params.ts
478 lines · 17.7 KBBlameHistoryRaw
1// Simulation parameters (strike, listener, numerics), the grid with its mask,
2// the plate as evaluated on the grid (material, thickness field, stress-free
3// shape, stand, loss constants), and the derived quantities both solvers need. All
4// values are SI. The design itself is produced by the script in design.ts.
6export interface SimParams {
7 // Strike
8 strikeFracX: number; // strike point as a fraction of the bounding-box width from the left edge
9 strikeFracY: number; // strike point as a fraction of the bounding-box height from the bottom edge
10 strikeVelocity: number; // stick tip speed at impact [m/s]
11 stickMass: number; // effective mass of the stick [kg]
12 contactStiffness: number; // Hertz contact stiffness K [N/m^alpha]
13 contactExponent: number; // Hertz exponent alpha (1.5 for a rounded tip)
14 contactRadius: number; // radius of the contact patch [m]
15 // Listener
16 listenerDist: number; // [m]
17 listenerAngle: number; // [deg] angle from the plate's normal (0 = straight above the plate)
18 listenerAzimuth: number; // [deg] rotation about the normal (0 = toward +x)
19 // Numerics and output
20 spacing: number; // requested grid spacing [m]
21 modeMaxFreq: number; // modes up to this frequency are kept in the linear model [Hz]
22 padLiftoff: boolean; // full simulation: the pads push only while compressed
23 fs: number; // audio sample rate [Hz]
24 fps: number; // movie frame rate, also the batch rate of the full simulation [Hz]
25 duration: number; // recorded duration after the impact [s]
28export const defaultParams: SimParams = {
29 strikeFracX: 0.9,
30 strikeFracY: 0.5,
31 strikeVelocity: 3,
32 stickMass: 0.03,
33 contactStiffness: 1e7,
34 contactExponent: 1.5,
35 contactRadius: 5e-3,
36 listenerDist: 0.5,
37 listenerAngle: 30,
38 listenerAzimuth: 0,
39 spacing: 0.008,
40 modeMaxFreq: 16000,
41 padLiftoff: true,
42 fs: 48000,
43 fps: 120,
44 duration: 4,
45};
47export const SPEED_OF_SOUND = 343; // [m/s]
48export const RHO_AIR = 1.2; // [kg/m^3]
49export const GRAVITY = 9.81; // [m/s^2]
50export const CFL = 0.9; // fraction of the stability limit used for the time step of the full simulation
51export const HIST_MARGIN = 128; // ring buffer margin of the retarded-time listener sum [samples]
52export const STICK_RELEASE = 5e-3; // the stick is withdrawn once the contact has been open this long [s]
53export const PAD_POISSON = 0.3; // Poisson ratio of the felt, which sets its shear stiffness from E
55// ---------- Grid ----------
57export interface Grid {
58 nx: number;
59 ny: number;
60 hx: number;
61 hy: number;
62 n: number;
63 /** 1 for nodes that belong to the plate, 0 otherwise; absent for a full rectangle. */
64 mask?: Uint8Array;
67/** True when the point (x, y), relative to the center of the bounding box, lies in the plate. */
68export type InsideFn = (x: number, y: number) => boolean;
70/** The grid covering the bounding box Lx by Ly with about the requested spacing, restricted to the outline when one is given. */
71export function makeGrid(Lx: number, Ly: number, spacing: number, inside?: InsideFn): Grid {
72 const nx = Math.max(6, Math.round(Lx / spacing) + 1);
73 const ny = Math.max(6, Math.round(Ly / spacing) + 1);
74 const g: Grid = { nx, ny, hx: Lx / (nx - 1), hy: Ly / (ny - 1), n: nx * ny };
75 return inside ? withMask(g, inside) : g;
78/**
79 * Restricts a grid to an outline. The edge is a staircase of whole cells: a
80 * cell belongs to the plate when its center lies inside the outline, and a
81 * node when it is a corner of such a cell. Deciding by cell centers keeps
82 * the covered area unbiased. A node left with no cell of its own would carry
83 * no mass, and the trailing loop removes any such node. A hole is handled by
84 * the same rule.
85 */
86export function withMask(g: Grid, inside: InsideFn): Grid {
87 return withCellMask(g, (ci, cj) => inside(nodeX(g, ci) + g.hx / 2, nodeY(g, cj) + g.hy / 2));
90/** The same restriction with the membership of each cell (ci, cj) given directly. */
91export function withCellMask(g: Grid, cellInside: (ci: number, cj: number) => boolean): Grid {
92 const mask = new Uint8Array(g.n);
93 for (let cj = 0; cj < g.ny - 1; cj++) {
94 for (let ci = 0; ci < g.nx - 1; ci++) {
95 if (cellInside(ci, cj)) {
96 mask[cj * g.nx + ci] = 1; mask[cj * g.nx + ci + 1] = 1;
97 mask[(cj + 1) * g.nx + ci] = 1; mask[(cj + 1) * g.nx + ci + 1] = 1;
98 }
99 }
100 }
101 const gm: Grid = { ...g, mask };
102 for (let changed = true; changed; ) {
103 changed = false;
104 for (let j = 0; j < g.ny; j++) {
105 for (let i = 0; i < g.nx; i++) {
106 if (mask[j * g.nx + i] && nodeWeight(gm, i, j) === 0) {
107 mask[j * g.nx + i] = 0;
108 changed = true;
109 }
110 }
111 }
112 }
113 return gm;
116/** Node coordinates relative to the center of the bounding box. */
117export function nodeX(g: Grid, i: number): number {
118 return (i - (g.nx - 1) / 2) * g.hx;
120export function nodeY(g: Grid, j: number): number {
121 return (j - (g.ny - 1) / 2) * g.hy;
124/** True when node (i, j) exists and belongs to the plate. */
125export function insideNode(g: Grid, i: number, j: number): boolean {
126 if (i < 0 || i >= g.nx || j < 0 || j >= g.ny) return false;
127 return g.mask ? g.mask[j * g.nx + i] !== 0 : true;
130/** True when cell (ci, cj), with nodes (ci..ci+1, cj..cj+1), lies within the plate. */
131export function insideCell(g: Grid, ci: number, cj: number): boolean {
132 return insideNode(g, ci, cj) && insideNode(g, ci + 1, cj) && insideNode(g, ci + 1, cj + 1) && insideNode(g, ci, cj + 1);
135/**
136 * Quadrature and lumped-mass weight of a node: the fraction of its four
137 * surrounding cells that belong to the plate (1 inside, 1/2 on an edge, 1/4
138 * at a corner of a full rectangle). Zero outside.
139 */
140export function nodeWeight(g: Grid, i: number, j: number): number {
141 if (!insideNode(g, i, j)) return 0;
142 let c = 0;
143 if (insideCell(g, i, j)) c++;
144 if (insideCell(g, i - 1, j)) c++;
145 if (insideCell(g, i, j - 1)) c++;
146 if (insideCell(g, i - 1, j - 1)) c++;
147 return c / 4;
150/** Per-node weights as a flat array. */
151export function nodeWeights(g: Grid): Float32Array<ArrayBuffer> {
152 const w = new Float32Array(g.n);
153 for (let j = 0; j < g.ny; j++) for (let i = 0; i < g.nx; i++) w[j * g.nx + i] = nodeWeight(g, i, j);
154 return w;
157/** Per-cell membership (1 or 0) as a flat array of (nx - 1) * (ny - 1). */
158export function cellMask(g: Grid): Float32Array<ArrayBuffer> {
159 const c = new Float32Array((g.nx - 1) * (g.ny - 1));
160 for (let cj = 0; cj < g.ny - 1; cj++) for (let ci = 0; ci < g.nx - 1; ci++) c[cj * (g.nx - 1) + ci] = insideCell(g, ci, cj) ? 1 : 0;
161 return c;
164/** Number of nodes in the plate. */
165export function countInside(g: Grid): number {
166 if (!g.mask) return g.n;
167 let c = 0;
168 for (let k = 0; k < g.n; k++) c += g.mask[k];
169 return c;
172// ---------- Material, stand, losses, plate ----------
174/** Thermal constants of the metal, from which the thermoelastic damping follows (loss.ts). */
175export interface Thermal {
176 alpha: number; // linear thermal expansion coefficient [1/K]
177 cp: number; // specific heat [J/(kg K)]
178 k: number; // thermal conductivity [W/(m K)]
181export interface Material {
182 E: number; // Young's modulus [Pa]
183 nu: number; // Poisson ratio
184 rho: number; // density [kg/m^3]
185 /** Loss factor of the metal's internal friction (hysteretic, frequency independent); 0 for none. */
186 eta: number;
187 /** Thermal constants, or null when the thermoelastic damping is left out. */
188 thermal: Thermal | null;
191/** Temperature of the plate [K], which enters the thermoelastic damping. */
192export const ROOM_TEMPERATURE = 293;
194/** A felt pad of the stand: an annulus of felt between the plate and the stand (or the wing nut). */
195export interface Pad {
196 innerRadius: number; // [m]
197 outerRadius: number; // [m]
198 thickness: number; // [m]
199 E: number; // Young's modulus of the felt in compression [Pa]
200 lossFactor: number; // loss factor eta of the felt
203/**
204 * The stand: a rod through the center hole at (x, y) with a felt pad below
205 * the plate and, when topPad is set, the same pad above it under the wing
206 * nut, which compresses the top pad by `preload` at rest.
207 */
208export interface Stand {
209 x: number;
210 y: number;
211 pad: Pad;
212 topPad: boolean;
213 preload: number; // compression of the top pad at rest [m]
216/** The plate as evaluated on the grid. */
217export interface Plate {
218 name: string;
219 Lx: number;
220 Ly: number;
221 grid: Grid;
222 mat: Material;
223 /** Thickness at the nodes [m]; zero outside the plate. */
224 h: Float64Array;
225 /** Stress-free height at the nodes [m], mean plane removed; zero outside the plate. */
226 w0: Float64Array;
227 stand: Stand | null;
228 /** Temperature [K], for the thermoelastic damping. */
229 temperature: number;
232export function bendingStiffness(m: Material, h: number): number {
233 return (m.E * h * h * h) / (12 * (1 - m.nu * m.nu));
235export function membraneStiffness(m: Material, h: number): number {
236 return (m.E * h) / (1 - m.nu * m.nu);
238export function shearStiffness(m: Material, h: number): number {
239 return (m.E * h) / (2 * (1 + m.nu));
241/** In-plane wave speed, which sets the time step of the full simulation. */
242export function inPlaneSpeed(m: Material): number {
243 return Math.sqrt(m.E / (m.rho * (1 - m.nu * m.nu)));
245/** Bending-wave constant kappa = sqrt(D / rho h), so that omega = kappa k^2. */
246export function bendingConstant(m: Material, h: number): number {
247 return Math.sqrt(bendingStiffness(m, h) / (m.rho * h));
250/** Thickness of a cell: the mean of its four corners. */
251export function cellThickness(pl: Plate, ci: number, cj: number): number {
252 const nx = pl.grid.nx;
253 return 0.25 * (pl.h[cj * nx + ci] + pl.h[cj * nx + ci + 1] + pl.h[(cj + 1) * nx + ci] + pl.h[(cj + 1) * nx + ci + 1]);
256/** Lumped mass of a node: rho h dA times its weight. */
257export function nodeMass(pl: Plate, i: number, j: number): number {
258 const g = pl.grid;
259 return pl.mat.rho * pl.h[j * g.nx + i] * g.hx * g.hy * nodeWeight(g, i, j);
262/** Per-node masses as a flat array. */
263export function nodeMasses(pl: Plate): Float64Array {
264 const g = pl.grid;
265 const m = new Float64Array(g.n);
266 for (let j = 0; j < g.ny; j++) for (let i = 0; i < g.nx; i++) m[j * g.nx + i] = nodeMass(pl, i, j);
267 return m;
270/** Total mass, the mass-weighted center, and the moments of inertia about the in-plane axes through it. */
271export function massProperties(pl: Plate): { mass: number; cx: number; cy: number; Ix: number; Iy: number; area: number } {
272 const g = pl.grid;
273 let mass = 0, sx = 0, sy = 0, area = 0;
274 for (let j = 0; j < g.ny; j++) {
275 for (let i = 0; i < g.nx; i++) {
276 const m = nodeMass(pl, i, j);
277 mass += m; sx += m * nodeX(g, i); sy += m * nodeY(g, j);
278 area += g.hx * g.hy * nodeWeight(g, i, j);
279 }
280 }
281 const cx = mass > 0 ? sx / mass : 0, cy = mass > 0 ? sy / mass : 0;
282 let Ix = 0, Iy = 0;
283 for (let j = 0; j < g.ny; j++) {
284 for (let i = 0; i < g.nx; i++) {
285 const m = nodeMass(pl, i, j);
286 Ix += m * (nodeY(g, j) - cy) ** 2;
287 Iy += m * (nodeX(g, i) - cx) ** 2;
288 }
289 }
290 return { mass, cx, cy, Ix, Iy, area };
293/** Mean thickness over the plate (area-weighted). */
294export function meanThickness(pl: Plate): number {
295 const g = pl.grid;
296 let s = 0, a = 0;
297 for (let j = 0; j < g.ny; j++) {
298 for (let i = 0; i < g.nx; i++) {
299 const w = nodeWeight(g, i, j);
300 s += w * pl.h[j * g.nx + i]; a += w;
301 }
302 }
303 return a > 0 ? s / a : 0;
306/** Smallest and largest thickness over the plate. */
307export function thicknessRange(pl: Plate): { min: number; max: number } {
308 const g = pl.grid;
309 let lo = Infinity, hi = 0;
310 for (let j = 0; j < g.ny; j++) {
311 for (let i = 0; i < g.nx; i++) {
312 if (!insideNode(g, i, j)) continue;
313 const h = pl.h[j * g.nx + i];
314 lo = Math.min(lo, h); hi = Math.max(hi, h);
315 }
316 }
317 return { min: isFinite(lo) ? lo : 0, max: hi };
320// ---------- Stand as seen by the solvers ----------
322/**
323 * The pads as per-node spring rates. `kw[k]` is the transverse stiffness of
324 * the pads at node k (both pads summed), `ku[k]` the in-plane (shear)
325 * stiffness, both in N/m; `kwBottom` is the bottom pad alone, which the full
326 * simulation needs for the lift-off rule. `eta` is the loss factor, and
327 * `preloadTop`, `preloadBottom` are the pad compressions at rest.
328 */
329export interface StandRates {
330 kw: Float64Array;
331 ku: Float64Array;
332 kwBottom: Float64Array;
333 nodes: number[];
334 eta: number;
335 preloadTop: number;
336 preloadBottom: number;
337 /** Total transverse stiffness of the pads [N/m]. */
338 totalKw: number;
339 /** Bounce frequency of the plate on the pads [Hz]. */
340 bounceHz: number;
343/**
344 * Per-node pad stiffness of the stand, or null for a free plate. The pads
345 * act on the plate nodes within the annulus; because a coarse grid covers
346 * the annulus with a handful of nodes, the per-node rates are scaled so
347 * that their sum equals the stiffness of the exact annulus area.
348 */
349export function standRates(pl: Plate): StandRates | null {
350 const st = pl.stand;
351 if (!st) return null;
352 const g = pl.grid;
353 const kw = new Float64Array(g.n), ku = new Float64Array(g.n), kwBottom = new Float64Array(g.n);
354 const nodes: number[] = [];
355 const pad = st.pad;
356 const kArea = pad.E / pad.thickness; // N/m^3
357 const gArea = pad.E / (2 * (1 + PAD_POISSON)) / pad.thickness;
358 const nPads = st.topPad ? 2 : 1;
359 const annulus = Math.PI * (pad.outerRadius ** 2 - pad.innerRadius ** 2);
360 let nodeArea = 0;
361 for (let j = 0; j < g.ny; j++) {
362 for (let i = 0; i < g.nx; i++) {
363 const wgt = nodeWeight(g, i, j);
364 if (wgt === 0) continue;
365 const r = Math.hypot(nodeX(g, i) - st.x, nodeY(g, j) - st.y);
366 if (r < pad.innerRadius || r > pad.outerRadius) continue;
367 nodes.push(j * g.nx + i);
368 nodeArea += g.hx * g.hy * wgt;
369 }
370 }
371 const empty = { kw, ku, kwBottom, nodes, eta: pad.lossFactor, preloadTop: 0, preloadBottom: 0, totalKw: 0, bounceHz: 0 };
372 if (nodes.length === 0 || nodeArea === 0) return empty;
373 const scale = annulus / nodeArea;
374 let totalKw = 0;
375 for (const k of nodes) {
376 const dA = scale * g.hx * g.hy * nodeWeight(g, k % g.nx, Math.floor(k / g.nx));
377 kwBottom[k] = kArea * dA;
378 kw[k] = nPads * kArea * dA;
379 ku[k] = nPads * gArea * dA;
380 totalKw += kw[k];
381 }
382 const { mass } = massProperties(pl);
383 const preloadTop = st.topPad ? st.preload : 0;
384 // The bottom pad carries the top pad's preload plus the weight of the plate.
385 const preloadBottom = preloadTop + (mass * GRAVITY) / (kArea * annulus);
386 return { kw, ku, kwBottom, nodes, eta: pad.lossFactor, preloadTop, preloadBottom, totalKw, bounceHz: Math.sqrt(totalKw / mass) / (2 * Math.PI) };
389// ---------- Patches ----------
391/** A set of plate nodes with the quantities the strike needs. */
392export interface Patch {
393 nodes: number[];
394 nearest: number;
395 mass: number;
396 cx: number;
397 cy: number;
400/** The plate nodes within `radius` of (x, y), always including the nearest one. */
401export function patchAround(pl: Plate, x: number, y: number, radius: number): Patch {
402 const g = pl.grid;
403 const nodes: number[] = [];
404 let nearest = -1, best = Infinity;
405 for (let j = 0; j < g.ny; j++) {
406 for (let i = 0; i < g.nx; i++) {
407 if (nodeMass(pl, i, j) === 0) continue;
408 const d = Math.hypot(nodeX(g, i) - x, nodeY(g, j) - y);
409 if (d < best) { best = d; nearest = j * g.nx + i; }
410 if (d <= radius) nodes.push(j * g.nx + i);
411 }
412 }
413 if (nearest >= 0 && !nodes.includes(nearest)) nodes.push(nearest);
414 let mass = 0, cx = 0, cy = 0;
415 for (const k of nodes) {
416 const m = nodeMass(pl, k % g.nx, Math.floor(k / g.nx));
417 mass += m; cx += m * nodeX(g, k % g.nx); cy += m * nodeY(g, Math.floor(k / g.nx));
418 }
419 return { nodes, nearest, mass, cx: mass > 0 ? cx / mass : 0, cy: mass > 0 ? cy / mass : 0 };
422/** The strike point in plate coordinates from the fractions of the bounding box. */
423export function strikePoint(pl: Plate, p: SimParams): { x: number; y: number } {
424 return { x: (p.strikeFracX - 0.5) * pl.Lx, y: (p.strikeFracY - 0.5) * pl.Ly };
427export function strikePatch(pl: Plate, p: SimParams): Patch {
428 const { x, y } = strikePoint(pl, p);
429 return patchAround(pl, x, y, p.contactRadius);
432// ---------- Listener ----------
434/** Listener position relative to the center of the plate, z along the plate's normal. */
435export function listenerPosition(p: SimParams): { x: number; y: number; z: number } {
436 const th = (p.listenerAngle * Math.PI) / 180, ph = (p.listenerAzimuth * Math.PI) / 180;
437 return { x: p.listenerDist * Math.sin(th) * Math.cos(ph), y: p.listenerDist * Math.sin(th) * Math.sin(ph), z: p.listenerDist * Math.cos(th) };
440/**
441 * Per-node terms of the Rayleigh integral for the listener: the weight
442 * rho_air dA / (2 pi r) and the delay r / c in seconds, for every node (zero
443 * outside the plate). The profile is included in the node positions.
444 */
445export function listenerTerms(pl: Plate, p: SimParams): { weight: Float64Array; delay: Float64Array } {
446 const g = pl.grid;
447 const L = listenerPosition(p);
448 const weight = new Float64Array(g.n), delay = new Float64Array(g.n);
449 for (let j = 0; j < g.ny; j++) {
450 for (let i = 0; i < g.nx; i++) {
451 const wgt = nodeWeight(g, i, j);
452 if (wgt === 0) continue;
453 const k = j * g.nx + i;
454 const r = Math.hypot(L.x - nodeX(g, i), L.y - nodeY(g, j), L.z - pl.w0[k]);
455 weight[k] = (RHO_AIR * g.hx * g.hy * wgt) / (2 * Math.PI * Math.max(r, 1e-3));
456 delay[k] = r / SPEED_OF_SOUND;
457 }
458 }
459 return { weight, delay };
462// ---------- Grid dispersion ----------
464/**
465 * Frequencies at which grid dispersion lowers a bending partial by the given
466 * fraction, for a plate of thickness h: a plane wave along a grid axis has the
467 * discrete frequency ratio (2 - 2 cos kh) / (kh)^2.
468 */
469export function dispersionFrequency(m: Material, h: number, spacing: number, fraction: number): number {
470 let lo = 0, hi = Math.PI;
471 for (let it = 0; it < 60; it++) {
472 const mid = (lo + hi) / 2;
473 const ratio = (2 - 2 * Math.cos(mid)) / (mid * mid);
474 if (1 - ratio < fraction) lo = mid; else hi = mid;
475 }
476 const k = lo / spacing;
477 return (bendingConstant(m, h) * k * k) / (2 * Math.PI);
↑↓ move↵openescclose