// Physical and numerical parameters of the simulation, and the derived // quantities (grid, material constants, geometry of the excitation). // All values are SI. export type PlateShape = "rectangle" | "ellipse"; export type BowShape = "none" | "width" | "height" | "dish"; export type Excitation = "push" | "strike"; export type HitSequence = "single" | "steady" | "crescendo" | "decrescendo"; export interface PhysParams { // Sheet shape: PlateShape; // outline of the sheet within its bounding box Lx: number; // width along x [m] (bounding box for an ellipse) Ly: number; // height along y [m] h: number; // thickness [m] E: number; // Young's modulus [Pa] nu: number; // Poisson ratio rho: number; // density [kg/m^3] holeInset: number; // distance of the thread hole from the edges near the top-left corner [m] // Stress-free initial shape bowShape: BowShape; bowAmplitude: number; // sag of the initial shape at the center [m] // Losses sigma0: number; // frequency-independent loss of all motion [1/s] sigma1: number; // loss proportional to the squared wavenumber, transverse motion [m^2/s] sigmaIn: number; // additional frequency-independent loss of the in-plane motion [1/s] // Excitation excitation: Excitation; pushAmount: number; // corner displacement [m]; negative pushes against the bow fingerRadius: number; // radius of the pushed patch [m] rampTime: number; // duration of the push ramp [s] holdMax: number; // maximum settle time before the release [s] releaseTime: number; // time over which the finger force falls to zero [s] settleSigma0: number; // artificial damping during push and settle [1/s] settleKE: number; // non-rigid kinetic energy below which the sheet counts as settled [J] strikeFracX: number; // strike point as a fraction of the width from the left edge strikeFracY: number; // strike point as a fraction of the height from the bottom edge strikeVelocity: number; // stick tip speed at impact [m/s]; the loudest hit of a sequence hitSequence: HitSequence; // one hit, or a series of hits every hitInterval seconds hitCount: number; // number of hits in a series hitInterval: number; // time between the hits of a series [s] strikeVelocityMin: number; // softest stick speed of a crescendo or decrescendo [m/s] stickMass: number; // effective mass of the stick [kg] contactStiffness: number; // Hertz contact stiffness K [N/m^alpha] contactExponent: number; // Hertz exponent alpha (1.5 for a rounded tip) contactRadius: number; // radius of the contact patch [m] // Listener listenerDist: number; // [m] listenerAzimuth: number; // [deg], rotation about the sheet's vertical axis (0 = on the normal) listenerElevation: number; // [deg] // Numerics and output spacing: number; // requested grid spacing [m] inPlaneMass: number; // factor by which the in-plane inertia is scaled; 1 is physical, larger lengthens the time step rigidProjection: boolean; // remove the six rigid-body modes every RB_INTERVAL steps while ringing fs: number; // audio sample rate [Hz] fps: number; // movie frame rate, also the batch rate [Hz] duration: number; // recorded duration after the release or the impact [s] } export const defaultParams: PhysParams = { shape: "rectangle", Lx: 12 * 0.0254, Ly: 18 * 0.0254, h: 0.79e-3, E: 170e9, nu: 0.29, rho: 7900, holeInset: 0.012, bowShape: "width", bowAmplitude: 3e-3, sigma0: 1.15, sigma1: 5e-4, sigmaIn: 20, excitation: "strike", pushAmount: 0.01, fingerRadius: 0.01, rampTime: 0.1, holdMax: 0.5, releaseTime: 0.02, settleSigma0: 300, settleKE: 1e-8, strikeFracX: 0.7, strikeFracY: 0.35, strikeVelocity: 3, hitSequence: "single", hitCount: 6, hitInterval: 0.5, strikeVelocityMin: 0.5, stickMass: 0.03, contactStiffness: 5e8, contactExponent: 1.5, contactRadius: 5e-3, listenerDist: 1.0, listenerAzimuth: 0, listenerElevation: 0, spacing: 0.008, inPlaneMass: 1, rigidProjection: false, fs: 48000, fps: 120, duration: 4, }; export const SPEED_OF_SOUND = 343; // [m/s] export const RHO_AIR = 1.2; // [kg/m^3] export const CFL = 0.9; // fraction of the stability limit used for the time step // The ring buffer of the retarded-time listener sum holds one batch of samples // plus this margin, which bounds the spread of the delays across the sheet. export const HIST_MARGIN = 128; // [samples] export const RB_INTERVAL = 32; // steps between rigid-body projections while ringing export const RB_INTERVAL_HELD = 16; // while the finger constraint is active // The held sheet counts as settled when its non-rigid kinetic energy drops // below settleKE, or when it has stopped falling: it has not halved over the // last SETTLE_WINDOW seconds (or 3 / settleSigma0, whichever is longer). export const SETTLE_WINDOW = 0.05; // [s] export const SETTLE_RATIO = 0.5; // ---------- Grid ---------- export interface Grid { nx: number; ny: number; hx: number; hy: number; n: number; /** 1 for nodes that belong to the sheet, 0 otherwise; absent for a full rectangle. */ mask?: Uint8Array; } export function makeGrid(p: PhysParams): Grid { const nx = Math.max(6, Math.round(p.Lx / p.spacing) + 1); const ny = Math.max(6, Math.round(p.Ly / p.spacing) + 1); const g: Grid = { nx, ny, hx: p.Lx / (nx - 1), hy: p.Ly / (ny - 1), n: nx * ny }; return p.shape === "ellipse" ? withEllipseMask(g) : g; } /** * Restricts a grid to the ellipse inscribed in its bounding box. The edge is a * staircase of whole cells: a cell belongs to the sheet when its center lies * inside the ellipse, and a node when it is a corner of such a cell. Deciding * by cell centers rather than by nodes keeps the covered area unbiased; a * node-based mask loses about half a cell all around the edge, which raises * every frequency by a few percent. A node with no cell of its own would carry * no mass, and the trailing loop removes any such node. */ export function withEllipseMask(g: Grid): Grid { const mask = new Uint8Array(g.n); const a = ((g.nx - 1) * g.hx) / 2, b = ((g.ny - 1) * g.hy) / 2; for (let cj = 0; cj < g.ny - 1; cj++) { for (let ci = 0; ci < g.nx - 1; ci++) { const X = (nodeX(g, ci) + g.hx / 2) / a, Y = (nodeY(g, cj) + g.hy / 2) / b; if (X * X + Y * Y <= 1) { mask[cj * g.nx + ci] = 1; mask[cj * g.nx + ci + 1] = 1; mask[(cj + 1) * g.nx + ci] = 1; mask[(cj + 1) * g.nx + ci + 1] = 1; } } } const gm: Grid = { ...g, mask }; for (let changed = true; changed; ) { changed = false; for (let j = 0; j < g.ny; j++) { for (let i = 0; i < g.nx; i++) { if (mask[j * g.nx + i] && nodeWeight(gm, i, j) === 0) { mask[j * g.nx + i] = 0; changed = true; } } } } return gm; } /** Node coordinates relative to the center of the bounding box. */ export function nodeX(g: Grid, i: number): number { return (i - (g.nx - 1) / 2) * g.hx; } export function nodeY(g: Grid, j: number): number { return (j - (g.ny - 1) / 2) * g.hy; } /** True when node (i, j) exists and belongs to the sheet. */ export function insideNode(g: Grid, i: number, j: number): boolean { if (i < 0 || i >= g.nx || j < 0 || j >= g.ny) return false; return g.mask ? g.mask[j * g.nx + i] !== 0 : true; } /** True when cell (ci, cj), with nodes (ci..ci+1, cj..cj+1), lies within the sheet. */ export function insideCell(g: Grid, ci: number, cj: number): boolean { return insideNode(g, ci, cj) && insideNode(g, ci + 1, cj) && insideNode(g, ci + 1, cj + 1) && insideNode(g, ci, cj + 1); } /** * Quadrature and lumped-mass weight of a node: the fraction of its four * surrounding cells that belong to the sheet. On a full rectangle this is the * trapezoidal rule (1 inside, 1/2 on an edge, 1/4 at a corner). Zero outside. */ export function nodeWeight(g: Grid, i: number, j: number): number { if (!insideNode(g, i, j)) return 0; let c = 0; if (insideCell(g, i, j)) c++; if (insideCell(g, i - 1, j)) c++; if (insideCell(g, i, j - 1)) c++; if (insideCell(g, i - 1, j - 1)) c++; return c / 4; } /** Per-node weights as a flat array. */ export function nodeWeights(g: Grid): Float32Array { const w = new Float32Array(g.n); 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); return w; } /** Number of nodes in the sheet. */ export function countInside(g: Grid): number { if (!g.mask) return g.n; let c = 0; for (let k = 0; k < g.n; k++) c += g.mask[k]; return c; } // ---------- Material ---------- export interface Material { D: number; // bending stiffness E h^3 / (12 (1 - nu^2)) [N m] C: number; // membrane stiffness E h / (1 - nu^2) [N/m] G: number; // membrane shear stiffness E h / (2 (1 + nu)) [N/m] nu: number; rhoh: number; // mass per unit area [kg/m^2] } export function makeMaterial(p: PhysParams): Material { return { D: (p.E * p.h ** 3) / (12 * (1 - p.nu * p.nu)), C: (p.E * p.h) / (1 - p.nu * p.nu), G: (p.E * p.h) / (2 * (1 + p.nu)), nu: p.nu, rhoh: p.rho * p.h, }; } /** * 60 dB decay time [s] of a bending mode of angular frequency omega under the * loss model sigma0 + sigma1 k^2, using the flat-plate dispersion k^2 = omega / kappa. */ export function t60(m: Material, p: PhysParams, omega: number): number { const kappa = Math.sqrt(m.D / m.rhoh); const sigma = p.sigma0 + p.sigma1 * (omega / kappa); return (3 * Math.LN10) / sigma; } /** * Frequency [Hz] at which the three-point second difference lowers a bending * frequency by the relative amount `err` on a grid of spacing hmax. A plane * bending wave along a grid axis with wavenumber k has the discrete frequency * ratio (2 - 2 cos kh) / (kh)^2. */ export function dispersionFrequency(m: Material, g: Grid, err: number): number { const h = Math.max(g.hx, g.hy); let lo = 0, hi = Math.PI; for (let it = 0; it < 60; it++) { const x = (lo + hi) / 2; if ((2 - 2 * Math.cos(x)) / (x * x) > 1 - err) lo = x; else hi = x; } const k = lo / h; const kappa = Math.sqrt(m.D / m.rhoh); return (kappa * k * k) / (2 * Math.PI); } // ---------- Geometry of the excitation ---------- /** One drumstick hit: time after the first impact [s] and stick speed [m/s]. */ export interface Hit { t: number; v: number; } /** * Schedule of the drumstick hits. The first hit is always at t = 0. A series * has hitCount hits every hitInterval seconds, all at strikeVelocity for * "steady", rising from strikeVelocityMin to strikeVelocity for "crescendo", * and falling for "decrescendo". */ export function hitSchedule(p: PhysParams): Hit[] { if (p.hitSequence === "single") return [{ t: 0, v: p.strikeVelocity }]; const count = Math.max(1, Math.round(p.hitCount)); const dt = Math.max(0, p.hitInterval); const lo = Math.min(p.strikeVelocityMin, p.strikeVelocity), hi = p.strikeVelocity; const hits: Hit[] = []; for (let k = 0; k < count; k++) { const f = count > 1 ? k / (count - 1) : 1; let v = hi; if (p.hitSequence === "crescendo") v = lo + (hi - lo) * f; else if (p.hitSequence === "decrescendo") v = hi - (hi - lo) * f; hits.push({ t: k * dt, v }); } return hits; } /** Strike point in sheet coordinates. */ export function strikePoint(p: PhysParams): [number, number] { return [(p.strikeFracX - 0.5) * p.Lx, (p.strikeFracY - 0.5) * p.Ly]; } /** * Center of the thread hole. Rectangle: inset from the two edges of the * top-left corner. Ellipse: inset along the radius from the rim point in the * top-left direction (parametric angle 135 degrees). */ export function holePoint(p: PhysParams): [number, number] { if (p.shape === "ellipse") { const c = Math.SQRT1_2; return [-(p.Lx / 2 - p.holeInset) * c, (p.Ly / 2 - p.holeInset) * c]; } return [-p.Lx / 2 + p.holeInset, p.Ly / 2 - p.holeInset]; } /** Center of the pushed patch: the bottom-right corner, or the rim point opposite the hole. */ export function pushPoint(p: PhysParams): [number, number] { if (p.shape === "ellipse") { const c = Math.SQRT1_2; return [(p.Lx / 2) * c, -(p.Ly / 2) * c]; } return [p.Lx / 2, -p.Ly / 2]; } /** * Stress-free initial shape w0 at every node. All three shapes are parabolic * with the given sag at the center of the bounding box: "width" and "height" * are cylindrical with zero sag along the two opposite edges, and "dish" has * the same curvature in both directions, so its sag is measured relative to * the corners (the edge midpoints sit at half the sag). */ export function initialShape(p: PhysParams, g: Grid): Float32Array { const w0 = new Float32Array(g.n); if (p.bowShape === "none" || p.bowAmplitude === 0) return w0; for (let j = 0; j < g.ny; j++) { for (let i = 0; i < g.nx; i++) { const X = (2 * nodeX(g, i)) / p.Lx; // -1..1 const Y = (2 * nodeY(g, j)) / p.Ly; let f = 0; if (p.bowShape === "width") f = 1 - X * X; else if (p.bowShape === "height") f = 1 - Y * Y; else f = 1 - 0.5 * (X * X + Y * Y); w0[j * g.nx + i] = p.bowAmplitude * f; } } return w0; } /** * Nodes of the drumstick contact patch: the sheet node nearest the strike * point, plus every sheet node within the contact radius. The contact force * is spread over these nodes in proportion to their mass, and the contact * coordinate is their mass-weighted mean displacement, so the two are * conjugate and the contact has a potential energy. The mass-weighted center * of the patch is where the inertia-relief reaction to the force is taken. */ export function contactPatch(p: PhysParams, g: Grid): { nodes: number[]; nearest: number; mass: number; center: [number, number]; box: [number, number, number, number] } { const m = makeMaterial(p); const [sx, sy] = strikePoint(p); let nearest = -1, best = Infinity; for (let j = 0; j < g.ny; j++) { for (let i = 0; i < g.nx; i++) { if (nodeWeight(g, i, j) === 0) continue; const d2 = (nodeX(g, i) - sx) ** 2 + (nodeY(g, j) - sy) ** 2; if (d2 < best) { best = d2; nearest = j * g.nx + i; } } } const nodes: number[] = []; let mass = 0, cx = 0, cy = 0; const box: [number, number, number, number] = [g.nx, -1, g.ny, -1]; // i0, i1, j0, j1 (inclusive) for (let j = 0; j < g.ny; j++) { for (let i = 0; i < g.nx; i++) { const k = j * g.nx + i; const wgt = nodeWeight(g, i, j); if (wgt === 0) continue; const d2 = (nodeX(g, i) - sx) ** 2 + (nodeY(g, j) - sy) ** 2; if (d2 <= p.contactRadius ** 2 || k === nearest) { nodes.push(k); const mk = m.rhoh * g.hx * g.hy * wgt; mass += mk; cx += mk * nodeX(g, i); cy += mk * nodeY(g, j); box[0] = Math.min(box[0], i); box[1] = Math.max(box[1], i); box[2] = Math.min(box[2], j); box[3] = Math.max(box[3], j); } } } return { nodes, nearest, mass, center: [cx / mass, cy / mass], box }; }