The stand is a parameter group, not part of the design script
A Stand group in the sidebar turns the stand on or off and sets the felt annulus (radii, thickness, modulus, loss factor), the top pad and its preload, and the position of the rod; it applies to whatever design is loaded, rebuilds the plate like the grid spacing does, enters the cache key of the modes, and can be preset from the URL. Its Notes give the total stiffness, the bounce frequency of the rigid plate, the pad nodes and the preloads. design.stand is refused with a message that points to the parameters; the presets lose their stand lines and the labels their 'on a stand'. The tutorial's actions set the stand off for the free-plate pages and on for the stand, taper and full-model pages.
9 changed files+143−158
README.mdmodified+10−15View file
@@ -1,8 +1,8 @@
11 # cymbalsim
22
3-A browser application for designing a plate or a cymbal and simulating a drumstick strike on it. The design is a short script in MATLAB syntax (material with its loss constants, bounding box, outline, thickness field, an optional stress-free profile, and the stand), run in the page by [numbl](https://numbl.org), that the user edits in the page or that an AI writes for them. The sound is computed in one of two ways. The **linear model** computes the modes of the plate once per design and synthesizes the strike from them in about a second, on the CPU, in a pool of Web Workers. The **full model** solves the geometrically nonlinear (von Karman) plate equations with in-plane inertia and a spatially varying thickness, stepped explicitly on the GPU through WebGPU; it takes tens of seconds per second of sound and captures the pitch glide and the wash of a hard hit. Both use the same grid, so the linear sound is the small-amplitude limit of the full one. The model and the numerical method are described in [THEORY.md](THEORY.md).
3+A browser application for designing a plate or a cymbal and simulating a drumstick strike on it. The design is a short script in MATLAB syntax (material with its loss constants, bounding box, outline, thickness field, an optional stress-free profile), run in the page by [numbl](https://numbl.org), that the user edits in the page or that an AI writes for them. The sound is computed in one of two ways. The **linear model** computes the modes of the plate once per design and synthesizes the strike from them in about a second, on the CPU, in a pool of Web Workers. The **full model** solves the geometrically nonlinear (von Karman) plate equations with in-plane inertia and a spatially varying thickness, stepped explicitly on the GPU through WebGPU; it takes tens of seconds per second of sound and captures the pitch glide and the wash of a hard hit. Both use the same grid, so the linear sound is the small-amplitude limit of the full one. The model and the numerical method are described in [THEORY.md](THEORY.md).
44
5-The focus is on free plates, sheets, discs and ellipses, flat by default, and on a realistic cymbal stand: a rod through the center hole with a felt pad of given radius below the cymbal and optionally another above it, which hold the cymbal level and let it bounce, rock, and (in the full model) lift off.
5+The focus is on free plates, sheets, discs and ellipses, flat by default, and on a realistic cymbal stand: a rod through the center hole with a felt pad of given radius below the cymbal and optionally another above it, which hold the cymbal level and let it bounce, rock, and (in the full model) lift off. The stand is a setting of the setup rather than of the design, so any plate can be put on it or taken off it.
66
77 ## Running
88
@@ -15,11 +15,11 @@ Requires Node 20 or later. The linear model runs in any modern browser; the full
1515
1616 ## Using the app
1717
18-The sidebar holds the run controls at its top, then the design script, the strike, listener and numerics parameters, and the mode browser. The main area shows the plate (a lit 3D height field with WebGPU, or a 2D color map; drag to orbit, wheel to zoom), the movie and playback toolbar, and the spectrogram of the listener signal.
18+The sidebar holds the run controls at its top, then the design script, the strike, listener, stand and numerics parameters, and the mode browser. The main area shows the plate (a lit 3D height field with WebGPU, or a 2D color map; drag to orbit, wheel to zoom), the movie and playback toolbar, and the spectrogram of the listener signal.
1919
2020 **Run mode.** The select at the top chooses the linear or the full model. In linear mode the modes of the current design are computed as soon as the script compiles (a progress bar shows the slices), and Synthesize produces the sound; after the first synthesis, a change of the strike or the listener parameters re-synthesizes automatically. In full mode Start begins the GPU run; Pause halts it, Stop ends it and keeps the audio and movie, and Continue extends a finished run after the duration has been raised.
2121
22-**Design script.** Pick a preset from the dropdown (a flat steel rectangle, a bowed rectangle, a flat bronze disc on a stand, a tapered 16 inch crash, a 20 inch ride, the same crash with a bow and a bell, a steel ellipse, a thunder sheet) and edit it freely; the script is compiled as you type (or with Ctrl+Enter) and the view shows the resulting stress-free shape. Errors are reported under the script, and a summary lists the bounding box, the material, the thickness range, the mass, the grid, the profile's peak-to-peak height and largest slope (with a warning above the shallow-shell range), the stand with its bounce frequency, the loss constants with the coincidence frequency, and the estimated decay. Revert restores the selected preset.
22+**Design script.** Pick a preset from the dropdown (a flat steel rectangle, an aluminum square, a bowed rectangle, a flat bronze disc, a tapered 16 inch crash, a 20 inch ride, the same crash with a bow and a bell, a steel ellipse, a thunder sheet) and edit it freely; the script is compiled as you type (or with Ctrl+Enter) and the view shows the resulting stress-free shape. Errors are reported under the script, and a summary lists the bounding box, the material, the thickness range, the mass, the grid, the profile's peak-to-peak height and largest slope (with a warning above the shallow-shell range), whether it sits on the stand, the loss constants with the coincidence frequency, and the estimated decay. Revert restores the selected preset.
2323
2424 The script fills in a struct named `design`. All lengths are meters. The outline, the thickness, and the profile are numbers or function handles `@(x, y, r, theta)` of the point relative to the center of the bounding box, its distance from the center, and its angle from the x axis; the handles are applied to whole grid matrices, so they use elementwise operators. A script is self-contained: nothing is in scope but numbl's own functions, so a script defines the shapes it needs as ordinary MATLAB functions at the bottom of the file, and the unit constants at the top.
2525
@@ -39,23 +39,18 @@ design.bounds.height = D;
3939 design.inside = @(x, y, r, theta) (r <= R) & (r >= hole/2); % omit for the whole box
4040 design.thickness = @(x, y, r, theta) 1.2*mm + (0.7*mm - 1.2*mm) * ramp(40*mm, R, r);
4141 design.profile = 0; % optional stress-free height [m]; a handle for a bow or a bell
42-design.stand.pad.innerRadius = 7*mm; % the felt annulus
43-design.stand.pad.outerRadius = 20*mm;
44-design.stand.pad.thickness = 12*mm;
45-design.stand.pad.E = 0.5e6; % Pa, felt in compression
46-design.stand.pad.lossFactor = 0.3;
47-design.stand.topPad = true; % false for a bottom pad only
48-design.stand.preload = 1*mm; % compression of the top pad by the wing nut
4942
5043 function v = ramp(a, b, x)
5144 v = min(max((x - a) ./ (b - a), 0), 1);
5245 end
5346 ```
5447
55-`design.stand = []` (or no stand field) gives a free plate; `design.stand.x` and `.y` move the rod. Pad fields that are omitted take the defaults shown. The decay of the sound is not prescribed but derived: from the loss factor of the metal's internal friction (`lossFactor`, 0 when omitted), the thermoelastic damping that follows from the three thermal constants (all three or none; without them there is no thermoelastic loss) and `design.temperature` (293 K by default), the radiation of each mode into the air, which the linear model computes from the mode's shape, and the felt of the stand. A script that still sets `design.loss` is refused with a message saying so.
48+The decay of the sound is not prescribed but derived: from the loss factor of the metal's internal friction (`lossFactor`, 0 when omitted), the thermoelastic damping that follows from the three thermal constants (all three or none; without them there is no thermoelastic loss) and `design.temperature` (293 K by default), the radiation of each mode into the air, which the linear model computes from the mode's shape, and the felt of the stand. A script that still sets `design.loss` or `design.stand` is refused with a message saying so.
5649
5750 **Modes.** In linear mode the mode browser lists every mode up to the cutoff frequency with its decay time and, beside it, the decay time each mechanism alone would give: the air (radiation), the metal (internal friction), thermal (thermoelastic) and the felt of a stand; click a row to draw the mode's shape on the plate (scaled to 1 mm), and filter the list by frequency. On a stand the first modes are the rock and the bounce of the cymbal on the felt. The modal frequencies are also drawn as ticks on the left edge of the spectrogram, in either run mode, so that the partials of a full run can be compared with the modes.
5851
52+**Stand.** The Stand group puts the plate on the stand or takes it off (a free plate), and sets the felt annulus (inner and outer radius, thickness, Young's modulus in compression, loss factor), whether there is a top pad under the wing nut, its preload, and the position of the rod relative to the center of the bounding box. The pads act on the plate nodes within the annulus whether or not the outline has a hole there; the Notes under the group give the total stiffness, the bounce frequency of the rigid plate, the number of pad nodes and the preloads. The stand is on by default, so a preset without a hole, such as the flat rectangle, sits on it until the box is unticked.
53+
5954 **Strike and listener.** The strike point is given as fractions of the bounding box (the yellow ring in the view), with the stick speed, its effective mass, the Hertz contact stiffness and exponent, and the tip radius. The listener distance, its angle from the plate's normal, and its azimuth can be changed at any time; the microphone in the view moves with them, drawn nearer than the listener is so that both fit in the frame.
6055
6156 **Numerics.** The grid spacing (8 mm by default), the cutoff frequency of the modes, the lift-off of the pads in the full model, the sample rate, and the frame rate. The Notes under the group report the grid, the frequencies at which grid dispersion lowers the partials by 3 and 10 percent, the count and time of the modes, the time step and step count of the full model, and the decay times of the fitted two-parameter loss of the full model next to the estimate it was fitted to.
@@ -64,13 +59,13 @@ end
6459
6560 **Tutorial.** The Tutorial button at the top of the sidebar opens a panel beside the view with a guided tour of the physics and the mathematics of the simulator in nineteen pages: the bending stiffness and the plate equation, dispersion, modes and their scaling laws, the density of modes, the grid and its dispersion error, the eigenvalue computation, decay times, the strike and the driving-point impedance, the modal force and the choice of the strike point, the modal synthesis, radiation and directivity, the spectrogram, the stand, the taper, curvature, the nonlinear model, and the limitations. Each page reads the design in the editor and shows its numbers (the bending stiffness, the wavelength at 1 kHz, the count of modes against Weyl's estimate, the gains of the first modes at the strike point and at the listener, the bounce and the rock of a stand against their formulas), and the buttons under Try it load presets, change parameters, compute the modes, strike and play, so that every formula can be checked against the model; a few pages remember mode lists for a comparison, such as the plate twice as thick against the reference. The panel keeps its page between visits, and `?tutorial=1` or `?tutorial=<page id>` opens it from the address.
6661
67-**URL parameters.** `?preset=crash-16` selects a preset, `mode=full` the run mode, and any strike, listener, or numerics parameter can be preset in SI units, for example `?preset=ride-20&spacing=0.006&duration=3&autostart=1`; `nogpu=1` disables WebGPU.
62+**URL parameters.** `?preset=crash-16` selects a preset, `mode=full` the run mode, and any strike, listener, stand, or numerics parameter can be preset in SI units (booleans as 0 or 1), for example `?preset=ride-20&spacing=0.006&duration=3&autostart=1` or `?preset=flat-rectangle&standOn=0`; `nogpu=1` disables WebGPU.
6863
6964 ## Tests
7065
7166 `npm test` runs eight groups of tests with Node's built-in runner; `npm run test:cpu` runs the first seven without a browser.
7267
73-- `tests/design.test.ts`: the design script (compilation, the checks with their messages, hoisted functions, the mask of a disc with a hole, the orientation of the sampled matrices, the removal of the mean plane, the stand's rates and preloads, the loss constants of the material), and that every preset evaluates on the default grid.
68+- `tests/design.test.ts`: the design script (compilation, the checks with their messages, hoisted functions, the mask of a disc with a hole, the orientation of the sampled matrices, the removal of the mean plane, the stand from the parameters with its rates and preloads, the loss constants of the material), and that every preset evaluates on the default grid.
7469 - `tests/reference.test.ts`: the float64 reference of the discretization (rigid modes of the stiffness, forces as the gradient of the energy, dissipativity of the losses and the pad dashpots, thickness scaling, the stand's stiffness against translation and tilt, the lift-off rule).
7570 - `tests/assemble.test.ts`: the assembled stiffness equals the linearized reference force on a flat plate and on a bowed, tapered, holed plate on a stand.
7671 - `tests/modal.test.ts`: the banded factorization and its inertia count, the tridiagonal eigensolver, all modes of a small square and a small disc against a dense solve (degenerate pairs included), the free square and disc frequencies against Gorman's and Leissa's values, and a plate on a stand.
@@ -81,7 +76,7 @@ end
8176
8277 ## Layout
8378
84-- `src/params.ts`: simulation parameters, grid and mask, the plate as evaluated on the grid, material functions, the stand's rates, patches, listener geometry, grid dispersion.
79+- `src/params.ts`: simulation parameters (strike, listener, stand, numerics), grid and mask, the plate as evaluated on the grid, material functions, the stand from the parameters and its rates, patches, listener geometry, grid dispersion.
8580 - `src/design.ts`, `src/mruntime.ts`, `src/presets.ts`: running the script with numbl, the checks on the design struct, evaluation on the grid, and the preset scripts.
8681 - `src/loss.ts`: the losses from physical constants (internal friction, Zener's thermoelastic damping, the average radiation efficiency, the outline perimeter) and the two-parameter fit of the full model.
8782 - `src/model/reference.ts`: float64 reference of the discretization (forces, losses, energy, the time-step estimate).
THEORY.mdmodified+3−3View file
@@ -1,6 +1,6 @@
11 # Model and numerical method
22
3-This note describes the model behind cymbalsim: a thin metal plate of arbitrary outline and spatially varying thickness, flat or with a stress-free curved profile, either hanging freely or sitting on a cymbal stand, struck once with a drumstick. The sound is computed in one of two ways. The *linear model* takes the equations of the plate linearized about its rest shape, computes their modes once per design, and synthesizes the strike from the modes; this takes a few seconds and needs no GPU. The *full model* steps the geometrically nonlinear plate equations of Foppl and von Karman explicitly on the GPU; this takes tens of seconds per second of sound and captures what the linear model cannot, the pitch glide and the transfer of energy toward high frequencies that turn the initial tone of a hard hit into a wash. Both use the same spatial discretization, so the linear model is exactly the small-amplitude limit of the full one, and a light hit sounds the same either way.
3+This note describes the model behind cymbalsim: a thin metal plate of arbitrary outline and spatially varying thickness, flat or with a stress-free curved profile, either hanging freely or sitting on a cymbal stand, struck once with a drumstick. The plate is described by a script; the stand, the strike and the listener are parameters of the setup. The sound is computed in one of two ways. The *linear model* takes the equations of the plate linearized about its rest shape, computes their modes once per design, and synthesizes the strike from the modes; this takes a few seconds and needs no GPU. The *full model* steps the geometrically nonlinear plate equations of Foppl and von Karman explicitly on the GPU; this takes tens of seconds per second of sound and captures what the linear model cannot, the pitch glide and the transfer of energy toward high frequencies that turn the initial tone of a hard hit into a wash. Both use the same spatial discretization, so the linear model is exactly the small-amplitude limit of the full one, and a light hit sounds the same either way.
44
55 Section 1 gives the plate model, Section 2 its discretization on the grid, Section 3 the stand, Section 4 the linear model and the modal synthesis, Section 5 the full model, Section 6 the checks that were made, and Section 7 the limitations. The discretization and the GPU kernel follow those of steelsim41, the earlier simulator of a scripted cymbal, simplified where the modal approach allows it; what carries over is repeated here so that this note stands alone.
66
@@ -65,13 +65,13 @@ with $a_k$, $b_k$ the second differences at node $k$ (absent where undefined) an
6565
6666 ## 3. The stand
6767
68-A cymbal sits on a stand: a rod through the center hole, with a felt pad below the cymbal and usually another above it under a wing nut. We model each pad as an annulus of felt of inner radius $r_1$, outer radius $r_2$ and thickness $t$, with Young's modulus $E_p$ and loss factor $\eta$. Its stiffness per unit area in compression is $E_p/t$, and its stiffness per unit area in shear is $G_p/t$ with $G_p = E_p/(2(1+\nu_p))$ and $\nu_p = 0.3$; the shear is what holds the cymbal in place sideways, and it stands in for the plastic sleeve of the rod, which is not modeled (Section 7). The stand energy is
68+A cymbal sits on a stand: a rod through the center hole, with a felt pad below the cymbal and usually another above it under a wing nut. The stand is a setting of the setup rather than of the design, given in the interface and applied to whatever plate is loaded. We model each pad as an annulus of felt of inner radius $r_1$, outer radius $r_2$ and thickness $t$, with Young's modulus $E_p$ and loss factor $\eta$. Its stiffness per unit area in compression is $E_p/t$, and its stiffness per unit area in shear is $G_p/t$ with $G_p = E_p/(2(1+\nu_p))$ and $\nu_p = 0.3$; the shear is what holds the cymbal in place sideways, and it stands in for the plastic sleeve of the rod, which is not modeled (Section 7). The stand energy is
6969
7070 $$
7171 U_s = \frac12 \sum_{k \in \text{pads}} \left[ k_k\, w_k^2 + g_k (u_k^2 + v_k^2) \right],
7272 $$
7373
74-where the sums run over the plate nodes within the annulus, $k_k = n_p (E_p/t)\, \Delta A_k$ and $g_k = n_p (G_p/t)\, \Delta A_k$, $n_p$ is 1 or 2 for one or two pads, and $\Delta A_k$ is the node's share of the area, scaled so that the shares add up to the exact area of the annulus (a coarse grid covers the annulus with a dozen nodes). A uniform translation of the plate then feels the total stiffness $K = n_p (E_p/t) \pi (r_2^2 - r_1^2)$, and a tilt feels a restoring moment set by the second moment of the annulus, so the finite radius of the felt is what holds the cymbal level. With the defaults of the presets (a felt of 12 mm at 0.5 MPa between 7 and 20 mm, two pads) a 16 inch bronze cymbal of about 1 kg has a total stiffness of about 90 kN/m, and treated as a rigid body it would bounce on the stand at about 45 Hz and rock at about 5 Hz. The rock does come out near that value. The bounce comes out well below it, near 15 Hz for the flat disc preset and 19 Hz for the thicker ride, because a thin plate is not rigid: the pads push on its center, whose compliance adds, loosely speaking, in series with that of the felt, so the bounce mode is a rigid translation blended with the umbrella-shaped plate mode (one nodal circle, near 38 Hz for the uniform disc), and the higher plate modes that move the center take a share of the felt's damping. The felt modulus is an estimate; felt is a nonlinear material whose modulus rises with compression, and the value can be changed in the script.
74+where the sums run over the plate nodes within the annulus, $k_k = n_p (E_p/t)\, \Delta A_k$ and $g_k = n_p (G_p/t)\, \Delta A_k$, $n_p$ is 1 or 2 for one or two pads, and $\Delta A_k$ is the node's share of the area, scaled so that the shares add up to the exact area of the annulus (a coarse grid covers the annulus with a dozen nodes). A uniform translation of the plate then feels the total stiffness $K = n_p (E_p/t) \pi (r_2^2 - r_1^2)$, and a tilt feels a restoring moment set by the second moment of the annulus, so the finite radius of the felt is what holds the cymbal level. With the defaults of the presets (a felt of 12 mm at 0.5 MPa between 7 and 20 mm, two pads) a 16 inch bronze cymbal of about 1 kg has a total stiffness of about 90 kN/m, and treated as a rigid body it would bounce on the stand at about 45 Hz and rock at about 5 Hz. The rock does come out near that value. The bounce comes out well below it, near 15 Hz for the flat disc preset and 19 Hz for the thicker ride, because a thin plate is not rigid: the pads push on its center, whose compliance adds, loosely speaking, in series with that of the felt, so the bounce mode is a rigid translation blended with the umbrella-shaped plate mode (one nodal circle, near 38 Hz for the uniform disc), and the higher plate modes that move the center take a share of the felt's damping. The felt modulus is an estimate; felt is a nonlinear material whose modulus rises with compression, and the value can be changed in the Stand parameters.
7575
7676 **Preload and lift-off.** The wing nut compresses the top pad by a preload $\delta_t$ at rest (1 mm by default), and the bottom pad carries that preload plus the weight of the plate: $\delta_b = \delta_t + Mg/K_1$ with $K_1$ the stiffness of one pad. Without a top pad $\delta_t = 0$ and the bottom pad carries the weight alone. Gravity enters only through this preload; the static sag of the plate under its own weight, a fraction of a millimeter, is neglected, and $w = 0$ is the rest state. In the full model each pad pushes only while compressed: the bottom pad by $\delta_b - w$, the top pad by $\delta_t + w$, and the constant part of the two forces, which balances the weight, is subtracted, so that within the preload the pads are the linear spring above and beyond it the cymbal lifts off one pad and loads the other. A hard hit near the edge lifts the cymbal off the bottom pad and starts a rock of a centimeter or two at the edge, as on a real stand. The linear model uses the linear spring throughout; it cannot represent the lift-off, which is a nonlinear effect of the stand rather than of the plate.
7777
src/design.tsmodified+9−46View file
@@ -1,7 +1,8 @@
11 // The design: a MATLAB-syntax script, run by numbl, that fills in a struct
22 // named `design` with the material, the bounding box, the outline, the
3-// thickness field, the stress-free profile, the stand, and the loss
4-// constants of the material (from which the decay is derived, loss.ts).
3+// thickness field, the stress-free profile, and the loss constants of the
4+// material (from which the decay is derived, loss.ts). The stand is not part
5+// of the design: it is a parameter of the setup (params.ts).
56 // The outline, the thickness, and the profile are function handles
67 // @(x, y, r, theta), vectorized over grid matrices (or plain numbers).
78 // compileDesign runs the script and checks the struct; evaluatePlate runs it
@@ -18,7 +19,6 @@ import { RUNTIME_FILES } from "./mruntime.ts";
1819 import {
1920 type Grid,
2021 type Material,
21- type Pad,
2222 type Plate,
2323 type Stand,
2424 type Thermal,
@@ -43,16 +43,11 @@ export interface Design {
4343 thicknessConst: number | null;
4444 /** The profile when it is a plain number (or absent); null for a function. */
4545 profileConst: number | null;
46- stand: Stand | null;
4746 /** Temperature of the plate [K], for the thermoelastic damping. */
4847 temperature: number;
4948 source: string;
5049 }
5150
52-/** Defaults of the pad of a stand, a typical felt washer. */
53-export const DEFAULT_PAD: Pad = { innerRadius: 7e-3, outerRadius: 20e-3, thickness: 12e-3, E: 0.5e6, lossFactor: 0.3 };
54-export const DEFAULT_PRELOAD = 1e-3;
55-
5651 /** Message of an error thrown by the script or by the checks. */
5752 export class DesignError extends Error {}
5853
@@ -109,13 +104,6 @@ function num(v: RuntimeValue | undefined, what: string, min = -Infinity): number
109104 return s;
110105 }
111106
112-function bool(v: RuntimeValue | undefined, what: string, dflt: boolean): boolean {
113- if (v === undefined || isEmpty(v)) return dflt;
114- const s = scalar(v);
115- if (s === null) throw new DesignError(`${what} must be true or false`);
116- return s !== 0;
117-}
118-
119107 function field(s: RuntimeStruct, name: string): RuntimeValue | undefined {
120108 return s.fields.get(name);
121109 }
@@ -195,35 +183,9 @@ export function compileDesign(source: string): Design {
195183 }
196184 }
197185
198- const stand = readStand(field(d, "stand"));
186+ if (field(d, "stand") !== undefined) throw new DesignError("design.stand is no longer part of the script: the stand (the felt pads, the preload, the position of the rod) is set in the Stand parameters beside the script and applies to any design");
199187
200- return { name, material, Lx, Ly, hasInside, thicknessConst, profileConst, stand, temperature, source };
201-}
202-
203-function readStand(st: RuntimeValue | undefined): Stand | null {
204- if (st === undefined || isEmpty(st) || st === false) return null;
205- if (!isStruct(st)) throw new DesignError("design.stand must be a struct (design.stand.pad, design.stand.topPad, design.stand.preload), or [] for a free plate");
206- const opt = (s: RuntimeStruct, k: string, what: string, dflt: number, min = -Infinity) => (field(s, k) === undefined || isEmpty(field(s, k)) ? dflt : num(field(s, k), what, min));
207- const padV = field(st, "pad");
208- let pad: Pad = { ...DEFAULT_PAD };
209- if (padV !== undefined && !isEmpty(padV)) {
210- if (!isStruct(padV)) throw new DesignError("design.stand.pad must be a struct with fields innerRadius, outerRadius, thickness, E, lossFactor");
211- pad = {
212- innerRadius: opt(padV, "innerRadius", "design.stand.pad.innerRadius", DEFAULT_PAD.innerRadius, 0),
213- outerRadius: opt(padV, "outerRadius", "design.stand.pad.outerRadius", DEFAULT_PAD.outerRadius, 0),
214- thickness: opt(padV, "thickness", "design.stand.pad.thickness", DEFAULT_PAD.thickness, 1e-5),
215- E: opt(padV, "E", "design.stand.pad.E", DEFAULT_PAD.E, 1),
216- lossFactor: opt(padV, "lossFactor", "design.stand.pad.lossFactor", DEFAULT_PAD.lossFactor, 0),
217- };
218- if (pad.outerRadius <= pad.innerRadius) throw new DesignError("design.stand.pad.outerRadius must exceed innerRadius");
219- }
220- return {
221- x: opt(st, "x", "design.stand.x", 0),
222- y: opt(st, "y", "design.stand.y", 0),
223- pad,
224- topPad: bool(field(st, "topPad"), "design.stand.topPad", true),
225- preload: opt(st, "preload", "design.stand.preload", DEFAULT_PRELOAD, 0),
226- };
188+ return { name, material, Lx, Ly, hasInside, thicknessConst, profileConst, temperature, source };
227189 }
228190
229191 // ---------- Evaluation on a grid ----------
@@ -283,7 +245,8 @@ function matrix(vars: Record<string, RuntimeValue>, name: string, rows: number,
283245 }
284246
285247 /**
286- * Samples the design on a grid of about the requested spacing: the mask from
248+ * Samples the design on a grid of about the requested spacing, with the given
249+ * stand (from the parameters, not the script): the mask from
287250 * the outline at the cell centers, the thickness at the nodes (which must be
288251 * positive on the plate), the profile at the nodes with its mean plane
289252 * removed. Only the slopes of
@@ -291,7 +254,7 @@ function matrix(vars: Record<string, RuntimeValue>, name: string, rows: number,
291254 * not: the von Karman strains are not invariant under rotation, and a tilted
292255 * stress-free shape would add a spurious membrane coupling.
293256 */
294-export function evaluatePlate(d: Design, spacing: number): Plate {
257+export function evaluatePlate(d: Design, spacing: number, stand: Stand | null = null): Plate {
295258 const full = makeGrid(d.Lx, d.Ly, spacing);
296259 const { nx, ny } = full;
297260 const vars = runDesignScript(d.source + trailer(d, full), d.source.split("\n").length);
@@ -322,7 +285,7 @@ export function evaluatePlate(d: Design, spacing: number): Plate {
322285 if (!any) throw new DesignError("the outline contains no grid cells; enlarge it or reduce the grid spacing");
323286 removePlane(w0, g);
324287 for (let j = 0; j < ny; j++) for (let i = 0; i < nx; i++) if (!insideNode(g, i, j)) w0[j * nx + i] = 0;
325- return { name: d.name, Lx: d.Lx, Ly: d.Ly, grid: g, mat: d.material, h, w0, stand: d.stand, temperature: d.temperature };
288+ return { name: d.name, Lx: d.Lx, Ly: d.Ly, grid: g, mat: d.material, h, w0, stand, temperature: d.temperature };
326289 }
327290
328291 /** Whether the plate is flat (no profile), in which case bending and in-plane motion decouple in the linear model. */
src/main.tsmodified+32−6View file
@@ -13,6 +13,7 @@ import {
1313 meanThickness,
1414 thicknessRange,
1515 standRates,
16+ standFromParams,
1617 strikePoint,
1718 strikePatch,
1819 listenerPosition,
@@ -45,6 +46,9 @@ type ViewState = "design" | "mode" | "live" | "movie";
4546
4647 const $ = <T extends HTMLElement>(id: string): T => document.getElementById(id) as T;
4748
49+/** The parameters that describe the stand; a change of any of them rebuilds the plate. */
50+const STAND_KEYS = new Set<keyof SimParams>(["standOn", "standX", "standY", "padInnerRadius", "padOuterRadius", "padThickness", "padE", "padLossFactor", "topPad", "preload"]);
51+
4852
4953 /** A recording: the listener signal and the movie of one run, from either model. */
5054 interface Recording {
@@ -281,6 +285,22 @@ class App {
281285 { key: "listenerAzimuth", label: "Azimuth", unit: "deg", step: 15, min: -180, max: 180, live: true },
282286 ],
283287 },
288+ {
289+ title: "Stand",
290+ notes: true,
291+ fields: [
292+ { key: "standOn", label: "On a stand (off: free plate)", kind: "checkbox" },
293+ { key: "padInnerRadius", label: "Felt inner radius", unit: "mm", scale: 1e-3, step: 1, min: 0 },
294+ { key: "padOuterRadius", label: "Felt outer radius", unit: "mm", scale: 1e-3, step: 1, min: 1 },
295+ { key: "padThickness", label: "Felt thickness", unit: "mm", scale: 1e-3, step: 1, min: 0.5 },
296+ { key: "padE", label: "Felt modulus E", unit: "MPa", scale: 1e6, step: 0.1, min: 0.01 },
297+ { key: "padLossFactor", label: "Felt loss factor", step: 0.05, min: 0, max: 2 },
298+ { key: "topPad", label: "Top pad under the wing nut", kind: "checkbox" },
299+ { key: "preload", label: "Preload of the top pad", unit: "mm", scale: 1e-3, step: 0.25, min: 0 },
300+ { key: "standX", label: "Rod position x", unit: "mm", scale: 1e-3, step: 5 },
301+ { key: "standY", label: "Rod position y", unit: "mm", scale: 1e-3, step: 5 },
302+ ],
303+ },
284304 {
285305 title: "Grid and numerics",
286306 notes: true,
@@ -300,7 +320,8 @@ class App {
300320
301321 private onParam(key: keyof SimParams, live: boolean): void {
302322 const p = this.form.get();
303- if (key === "spacing" || key === "modeMaxFreq" || key === "fs") {
323+ if (key === "spacing" || key === "modeMaxFreq" || key === "fs" || STAND_KEYS.has(key)) {
324+ // The stand is part of the plate the solvers see, so it rebuilds the plate like the grid does.
304325 if (this.design) this.rebuildPlate(this.design);
305326 return;
306327 }
@@ -443,7 +464,7 @@ class App {
443464 const p = this.form.get();
444465 let plate: Plate;
445466 try {
446- plate = evaluatePlate(d, p.spacing);
467+ plate = evaluatePlate(d, p.spacing, standFromParams(p));
447468 } catch (e) {
448469 this.editor.setStatus(e instanceof DesignError ? e.message : String(e), "error");
449470 return;
@@ -473,7 +494,8 @@ class App {
473494 private modesKey(): string | null {
474495 if (!this.design || !this.plate) return null;
475496 const p = this.form.get();
476- return `${this.plateSpacing}|${Math.min(p.modeMaxFreq, p.fs / 2)}|${this.design.source}`;
497+ const stand = Array.from(STAND_KEYS).map((k) => String(p[k])).join(",");
498+ return `${this.plateSpacing}|${Math.min(p.modeMaxFreq, p.fs / 2)}|${stand}|${this.design.source}`;
477499 }
478500
479501 /** Installs cached modes for the current design if there are any; otherwise leaves the browser empty. */
@@ -521,8 +543,12 @@ class App {
521543 } else lines.push("Flat plate (no profile): the linear model is bending only");
522544 if (pl.stand && sr) {
523545 const pad = pl.stand.pad;
524- lines.push(`Stand: ${pl.stand.topPad ? "two pads" : "bottom pad only"}, ${(pad.innerRadius * 1e3).toFixed(0)} to ${(pad.outerRadius * 1e3).toFixed(0)} mm, ${(pad.thickness * 1e3).toFixed(0)} mm felt at ${(pad.E / 1e6).toFixed(2)} MPa; ${(sr.totalKw / 1e3).toFixed(0)} kN/m, bounce ${sr.bounceHz.toFixed(0)} Hz, ${sr.nodes.length} pad nodes, preload ${(sr.preloadTop * 1e3).toFixed(2)} / ${(sr.preloadBottom * 1e3).toFixed(2)} mm`);
525- } else lines.push("Free plate (no stand)");
546+ this.form.setNotes("Stand", `${pl.stand.topPad ? "Two pads" : "Bottom pad only"}, ${(pad.innerRadius * 1e3).toFixed(0)} to ${(pad.outerRadius * 1e3).toFixed(0)} mm, ${(pad.thickness * 1e3).toFixed(0)} mm felt at ${(pad.E / 1e6).toFixed(2)} MPa: ${(sr.totalKw / 1e3).toFixed(0)} kN/m in all, bounce of the rigid plate at ${sr.bounceHz.toFixed(0)} Hz, ${sr.nodes.length} pad nodes, preload ${(sr.preloadTop * 1e3).toFixed(2)} mm on top and ${(sr.preloadBottom * 1e3).toFixed(2)} mm below. The pads act on the plate nodes within the annulus whether or not the outline has a hole there.`);
547+ lines.push(`On the stand (see the Stand parameters): bounce ${sr.bounceHz.toFixed(0)} Hz`);
548+ } else {
549+ this.form.setNotes("Stand", pl.stand ? "The felt annulus covers no plate node: move the rod or enlarge the pads." : "Free plate: the stand is off.");
550+ lines.push("Free plate (the stand is off)");
551+ }
526552 const geom = plateGeometry(pl);
527553 const est = (f: number) => estimatedRate(pl, geom, f);
528554 const t60 = rateText;
@@ -1008,7 +1034,7 @@ class App {
10081034 for (const id of ["flat-rectangle", "bowed-rectangle", "crash-16", "crash-bell-16", "ellipse"]) {
10091035 const pr = presetById(id)!;
10101036 const d = compileDesign(pr.source);
1011- const pl = evaluatePlate(d, 0.012);
1037+ const pl = evaluatePlate(d, 0.012, standFromParams(this.form.get()));
10121038 const r = await runSelfTest(this.device, pl, this.form.get());
10131039 lines.push(`${id}: max relative error ${r.maxRel.toExponential(2)} (${r.n} nodes)`);
10141040 }
src/params.tsmodified+33−0View file
@@ -16,6 +16,17 @@ export interface SimParams {
1616 listenerDist: number; // [m]
1717 listenerAngle: number; // [deg] angle from the plate's normal (0 = straight above the plate)
1818 listenerAzimuth: number; // [deg] rotation about the normal (0 = toward +x)
19+ // Stand (a parameter of the setup, not of the design script)
20+ standOn: boolean; // false for a free plate
21+ standX: number; // position of the rod relative to the center of the bounding box [m]
22+ standY: number;
23+ padInnerRadius: number; // the felt annulus [m]
24+ padOuterRadius: number;
25+ padThickness: number; // [m]
26+ padE: number; // Young's modulus of the felt in compression [Pa]
27+ padLossFactor: number;
28+ topPad: boolean; // a second pad above the plate under the wing nut
29+ preload: number; // compression of the top pad at rest [m]
1930 // Numerics and output
2031 spacing: number; // requested grid spacing [m]
2132 modeMaxFreq: number; // modes up to this frequency are kept in the linear model [Hz]
@@ -36,6 +47,16 @@ export const defaultParams: SimParams = {
3647 listenerDist: 0.5,
3748 listenerAngle: 30,
3849 listenerAzimuth: 0,
50+ standOn: true,
51+ standX: 0,
52+ standY: 0,
53+ padInnerRadius: 7e-3,
54+ padOuterRadius: 20e-3,
55+ padThickness: 12e-3,
56+ padE: 0.5e6,
57+ padLossFactor: 0.3,
58+ topPad: true,
59+ preload: 1e-3,
3960 spacing: 0.008,
4061 modeMaxFreq: 16000,
4162 padLiftoff: true,
@@ -213,6 +234,18 @@ export interface Stand {
213234 preload: number; // compression of the top pad at rest [m]
214235 }
215236
237+/** The stand described by the parameters, or null for a free plate. */
238+export function standFromParams(p: SimParams): Stand | null {
239+ if (!p.standOn) return null;
240+ return {
241+ x: p.standX,
242+ y: p.standY,
243+ pad: { innerRadius: p.padInnerRadius, outerRadius: Math.max(p.padOuterRadius, p.padInnerRadius + 1e-4), thickness: p.padThickness, E: p.padE, lossFactor: p.padLossFactor },
244+ topPad: p.topPad,
245+ preload: p.preload,
246+ };
247+}
248+
216249 /** The plate as evaluated on the grid. */
217250 export interface Plate {
218251 name: string;
src/presets.tsmodified+22−58View file
@@ -9,7 +9,8 @@ export interface Preset {
99 source: string;
1010 }
1111
12-const flatRectangle = `% A flat rectangular sheet of stainless steel, hanging freely (no stand).
12+const flatRectangle = `% A flat rectangular sheet of stainless steel (turn the stand off in the
13+% Stand parameters to let it hang freely).
1314 % The script is an ordinary MATLAB program that fills in a struct named
1415 % design. Lengths are meters; the constants at the top are the only units.
1516 % A field can be a number or a function handle @(x, y, r, theta) of the
@@ -34,12 +35,11 @@ design.material.k = 16; % conductivity [W/(m K)] }
3435 design.bounds.width = 12*inch; % bounding box [m]
3536 design.bounds.height = 18*inch;
3637 design.thickness = 0.79*mm;
37-design.stand = []; % free plate
3838 `;
3939
4040 const aluminumSquare = `% A flat 12 inch square of 6061-T6 aluminum, 1.02 mm thick, with a center
41-% hole on a cymbal stand: felt pads below and above the plate around the
42-% rod. Aluminum is a third the density of steel and a third as stiff, so
41+% hole for the rod of a stand (the stand itself is set in the Stand
42+% parameters beside the script). Aluminum is a third the density of steel and a third as stiff, so
4343 % the bending frequencies land near those of a steel plate of the same size
4444 % and thickness. Its high thermal expansion and conductivity give it the
4545 % strongest thermoelastic damping of the three metals here.
@@ -58,13 +58,6 @@ design.bounds.width = W;
5858 design.bounds.height = W;
5959 design.inside = @(x, y, r, theta) r >= hole/2;
6060 design.thickness = 1.02*mm;
61-design.stand.pad.innerRadius = 7*mm; % the felt sits on the plastic sleeve of the rod
62-design.stand.pad.outerRadius = 20*mm;
63-design.stand.pad.thickness = 12*mm;
64-design.stand.pad.E = 0.5e6; % Pa, felt in compression
65-design.stand.pad.lossFactor = 0.3;
66-design.stand.topPad = true;
67-design.stand.preload = 1*mm; % compression of the top pad by the wing nut
6861 `;
6962
7063 const bowedRectangle = `% The same steel sheet with a 3 mm parabolic bow across its width, the
@@ -86,7 +79,6 @@ design.bounds.width = W;
8679 design.bounds.height = H;
8780 design.thickness = 0.79*mm;
8881 design.profile = @(x, y, r, theta) bow(x, W, 3*mm, 0.3); % stress-free height [m]
89-design.stand = [];
9082
9183 function v = bow(s, span, sag, offset)
9284 % A parabola of the given sag across a span centered on 0; s runs along the
@@ -96,11 +88,11 @@ v = sag * (1 - t.^2) .* (1 + offset*t);
9688 end
9789 `;
9890
99-const flatDisc = `% A flat 16 inch bronze disc with a center hole on a cymbal stand. The
100-% stand is a rod through the hole with a felt pad below the plate and
101-% another above it under the wing nut; each pad is an annulus of felt of
102-% the given radii and thickness, and its stiffness per unit area is E over
103-% the thickness. The pads hold the disc level and let it bounce and rock.
91+const flatDisc = `% A flat 16 inch bronze disc with a center hole for the rod of a stand. The
92+% stand itself (the felt pads, the preload, the position of the rod) is set
93+% in the Stand parameters beside the script, so that any design can be put
94+% on it or taken off it; the pads hold the disc level and let it bounce and
95+% rock.
10496 inch = 0.0254; mm = 1e-3;
10597 D = 16*inch; hole = 13*mm;
10698
@@ -116,19 +108,12 @@ design.bounds.width = D;
116108 design.bounds.height = D;
117109 design.inside = @(x, y, r, theta) (r <= D/2) & (r >= hole/2);
118110 design.thickness = 1.0*mm;
119-design.stand.pad.innerRadius = 7*mm; % the felt sits on the plastic sleeve of the rod
120-design.stand.pad.outerRadius = 20*mm;
121-design.stand.pad.thickness = 12*mm;
122-design.stand.pad.E = 0.5e6; % Pa, felt in compression
123-design.stand.pad.lossFactor = 0.3;
124-design.stand.topPad = true;
125-design.stand.preload = 1*mm; % compression of the top pad by the wing nut
126111 `;
127112
128113 const crash16 = `% A 16 inch flat crash: bronze, lathed to a taper from 1.2 mm at the
129-% center to 0.7 mm at the edge, on a stand. Flat, so the linear modes are
130-% pure bending modes; the tapered profile is what makes it a crash rather
131-% than a ride.
114+% center to 0.7 mm at the edge, with a center hole for the stand. Flat, so
115+% the linear modes are pure bending modes; the tapered profile is what
116+% makes it a crash rather than a ride.
132117 inch = 0.0254; mm = 1e-3;
133118 D = 16*inch; R = D/2; hole = 13*mm;
134119 tCenter = 1.2*mm; tEdge = 0.7*mm;
@@ -145,13 +130,6 @@ design.bounds.width = D;
145130 design.bounds.height = D;
146131 design.inside = @(x, y, r, theta) (r <= R) & (r >= hole/2);
147132 design.thickness = @(x, y, r, theta) tCenter + (tEdge - tCenter) * ramp(40*mm, R, r);
148-design.stand.pad.innerRadius = 7*mm;
149-design.stand.pad.outerRadius = 20*mm;
150-design.stand.pad.thickness = 12*mm;
151-design.stand.pad.E = 0.5e6;
152-design.stand.pad.lossFactor = 0.3;
153-design.stand.topPad = true;
154-design.stand.preload = 1*mm;
155133
156134 function v = ramp(a, b, x)
157135 % 0 at x = a, 1 at x = b, clamped to that range outside.
@@ -160,9 +138,10 @@ end
160138 `;
161139
162140 const ride20 = `% A 20 inch flat ride: thicker bronze, 2.2 mm at the center to 1.4 mm at
163-% the edge, on a stand with a loose wing nut (a small preload). The
164-% thickness raises every frequency in proportion, and the ride has a
165-% clearer ping and a longer decay than the crash.
141+% the edge, with a center hole for the stand. The thickness raises every
142+% frequency in proportion, and the ride has a clearer ping and a longer
143+% decay than the crash; try a small preload in the Stand parameters for a
144+% loose wing nut.
166145 inch = 0.0254; mm = 1e-3;
167146 D = 20*inch; R = D/2; hole = 13*mm;
168147
@@ -178,20 +157,13 @@ design.bounds.width = D;
178157 design.bounds.height = D;
179158 design.inside = @(x, y, r, theta) (r <= R) & (r >= hole/2);
180159 design.thickness = @(x, y, r, theta) 2.2*mm + (1.4*mm - 2.2*mm) * ramp(50*mm, R, r);
181-design.stand.pad.innerRadius = 7*mm;
182-design.stand.pad.outerRadius = 22*mm;
183-design.stand.pad.thickness = 12*mm;
184-design.stand.pad.E = 0.5e6;
185-design.stand.pad.lossFactor = 0.3;
186-design.stand.topPad = true;
187-design.stand.preload = 0.3*mm;
188160
189161 function v = ramp(a, b, x)
190162 v = min(max((x - a) ./ (b - a), 0), 1);
191163 end
192164 `;
193165
194-const crashBell16 = `% A 16 inch crash with a shallow bow and a cosine bell, on a stand. The
166+const crashBell16 = `% A 16 inch crash with a shallow bow and a cosine bell. The
195167 % profile makes the cymbal a shallow shell: the linear model couples the
196168 % bending to the stretching (a larger eigenproblem, so the modes take
197169 % longer), and the full simulation shows the asymmetric nonlinearity of a
@@ -215,13 +187,6 @@ design.bounds.height = D;
215187 design.inside = @(x, y, r, theta) (r <= R) & (r >= hole/2);
216188 design.thickness = @(x, y, r, theta) tCenter + (tEdge - tCenter) * ramp(bellR, R, r);
217189 design.profile = @(x, y, r, theta) bowH * (1 - (r/R).^2) + dome(r, bellR, bellH);
218-design.stand.pad.innerRadius = 7*mm;
219-design.stand.pad.outerRadius = 20*mm;
220-design.stand.pad.thickness = 12*mm;
221-design.stand.pad.E = 0.5e6;
222-design.stand.pad.lossFactor = 0.3;
223-design.stand.topPad = true;
224-design.stand.preload = 1*mm;
225190
226191 function v = ramp(a, b, x)
227192 v = min(max((x - a) ./ (b - a), 0), 1);
@@ -234,7 +199,7 @@ v = 0.5 * height * (1 + cos(pi * d / radius)) .* (d < radius);
234199 end
235200 `;
236201
237-const ellipse = `% A free elliptical steel plate. The outline is any test on the point;
202+const ellipse = `% An elliptical steel plate. The outline is any test on the point;
238203 % an ellipse inscribed in the bounding box here. The degenerate mode pairs
239204 % of a disc split on an ellipse, which the mode browser shows.
240205 inch = 0.0254; mm = 1e-3;
@@ -252,10 +217,10 @@ design.bounds.width = A;
252217 design.bounds.height = B;
253218 design.inside = @(x, y, r, theta) (x/(A/2)).^2 + (y/(B/2)).^2 <= 1;
254219 design.thickness = 1.0*mm;
255-design.stand = [];
256220 `;
257221
258-const thunderSheet = `% A thunder sheet: a large thin steel sheet hung from one corner. Its modes
222+const thunderSheet = `% A thunder sheet: a large thin steel sheet, hung from a corner in reality
223+% (turn the stand off). Its modes
259224 % are dense and low, and the full simulation turns a hard hit into a roar.
260225 % Long at the default spacing; a coarser grid keeps it interactive.
261226 inch = 0.0254; mm = 1e-3;
@@ -271,14 +236,13 @@ design.material.k = 50; % conductivity [W/(m K)] }
271236 design.bounds.width = 24*inch;
272237 design.bounds.height = 36*inch;
273238 design.thickness = 0.5*mm;
274-design.stand = [];
275239 `;
276240
277241 export const PRESETS: Preset[] = [
278242 { id: "flat-rectangle", label: "Flat steel rectangle", source: flatRectangle },
279- { id: "aluminum-square", label: "Aluminum 6061-T6 square on a stand", source: aluminumSquare },
243+ { id: "aluminum-square", label: "Aluminum 6061-T6 square", source: aluminumSquare },
280244 { id: "bowed-rectangle", label: "Bowed steel rectangle", source: bowedRectangle },
281- { id: "flat-disc", label: "Flat bronze disc on a stand", source: flatDisc },
245+ { id: "flat-disc", label: "Flat bronze disc", source: flatDisc },
282246 { id: "crash-16", label: "16 inch flat crash", source: crash16 },
283247 { id: "ride-20", label: "20 inch flat ride", source: ride20 },
284248 { id: "crash-bell-16", label: "16 inch crash with bell", source: crashBell16 },
src/tutorial/steps.tsmodified+19−18View file
@@ -214,7 +214,6 @@ design.bounds.width = ${w}*inch;
214214 design.bounds.height = ${h}*inch;
215215 design.material.lossFactor = ${eta};
216216 ${thermal ? "design.material.alpha = 17e-6;\ndesign.material.cp = 500;\ndesign.material.k = 16;\n" : ""}design.thickness = ${t}*mm;
217-design.stand = [];
218217 `;
219218 }
220219
@@ -223,6 +222,7 @@ const REFERENCE_NAME = "Flat steel rectangle";
223222 /** Loads a design, evaluates it at a spacing, computes its modes, and remembers the result under a key. */
224223 async function remember(host: TutorialHost, key: string, label: string, load: () => Promise<void>, spacing: number): Promise<void> {
225224 host.setParam("spacing", spacing);
225+ host.setParam("standOn", false);
226226 await load();
227227 await host.computeModes();
228228 const c = host.ctx();
@@ -240,10 +240,10 @@ export const STEPS: TutorialStep[] = [
240240 body: (c) => `
241241 <p>This tutorial goes through the physics and the mathematics behind cymbalsim one idea per page, using the application itself as the laboratory. Each page reads the design in the editor and shows its numbers, and the buttons under <i>Try it</i> load presets, change parameters, compute modes and strike the plate, so that every formula on a page can be checked against the model. The pages build on one another, so a first pass is best taken in order; afterwards the list at the top jumps to any page.</p>
242242 <p>The object of study is a thin metal plate, described by the script in the sidebar: its material, its outline, its thickness, an optional curved rest shape, the stand it sits on, and how fast its vibrations die away. A drumstick hits it once. The sound is computed in two ways. The <i>linear model</i> assumes the motion is small, finds the natural modes of the plate, and writes the sound as a sum of decaying sine waves, one per mode; this takes a few seconds. The <i>full model</i> keeps the terms that matter when the motion is not small and steps the equations of motion in time on the graphics processor; this takes tens of seconds per second of sound. Both use the same grid, so for a light hit they agree, which the last chapter checks.</p>
243-<p>We start with the flat steel rectangle, the simplest design: a sheet hanging freely, without a stand.</p>
243+<p>We start with the flat steel rectangle, the simplest design: a sheet hanging freely. The stand is a setting of its own, in the Stand parameters beside the script, and the button below turns it off.</p>
244244 ${c.plate ? live(`In the editor: <b>${c.plate.name}</b>, ${mm(c.plate.Lx, 0)} by ${mm(c.plate.Ly, 0)}, ${c.plate.stand ? "on a stand" : "free"}, on a grid of ${mm(c.spacing, 0)}.`) : need("No design has compiled yet.")}`,
245245 actions: [
246- { label: "Load the flat steel rectangle", run: async (h) => { h.setParam("spacing", 0.008); await h.loadPreset("flat-rectangle"); } },
246+ { label: "Load the flat steel rectangle, free", run: async (h) => { h.setParam("spacing", 0.008); h.setParam("standOn", false); await h.loadPreset("flat-rectangle"); } },
247247 ],
248248 },
249249
@@ -389,9 +389,9 @@ ${liveText ? live(liveText) : need("Load a design to see its grid.")}
389389 },
390390 actions: [
391391 { label: "Reference at 8 mm", run: (h) => remember(h, "ref", REFERENCE_NAME, () => h.loadPreset("flat-rectangle"), 0.008) },
392- { label: "Coarser: 12 mm", run: async (h) => { h.setParam("spacing", 0.012); await h.loadPreset("flat-rectangle"); await h.computeModes(); } },
393- { label: "Finer: 6 mm", run: async (h) => { h.setParam("spacing", 0.006); await h.loadPreset("flat-rectangle"); await h.computeModes(); } },
394- { label: "Back to 8 mm", run: async (h) => { h.setParam("spacing", 0.008); await h.loadPreset("flat-rectangle"); } },
392+ { label: "Coarser: 12 mm", run: async (h) => { h.setParam("spacing", 0.012); h.setParam("standOn", false); await h.loadPreset("flat-rectangle"); await h.computeModes(); } },
393+ { label: "Finer: 6 mm", run: async (h) => { h.setParam("spacing", 0.006); h.setParam("standOn", false); await h.loadPreset("flat-rectangle"); await h.computeModes(); } },
394+ { label: "Back to 8 mm", run: async (h) => { h.setParam("spacing", 0.008); h.setParam("standOn", false); await h.loadPreset("flat-rectangle"); } },
395395 ],
396396 },
397397 {
@@ -462,7 +462,7 @@ ${est ? live(est) : need("Load a design to see its losses.")}
462462 { label: "Compute the modes", run: (h) => h.computeModes() },
463463 { label: "Loss factor 1e-3", run: async (h) => { await h.loadScript(rectangleScript({ eta: 1e-3 })); await h.synthesize(); } },
464464 { label: "No internal friction", run: async (h) => { await h.loadScript(rectangleScript({ eta: 0 })); await h.synthesize(); } },
465- { label: "The preset", run: async (h) => { await h.loadPreset("flat-rectangle"); await h.synthesize(); } },
465+ { label: "The preset", run: async (h) => { h.setParam("standOn", false); await h.loadPreset("flat-rectangle"); await h.synthesize(); } },
466466 { label: "Play", run: (h) => h.play() },
467467 ],
468468 },
@@ -636,13 +636,14 @@ ${live(`Window of ${N} samples at ${fs / 1000} kHz: $\\Delta f$ = ${(fs / N).toF
636636 <p>A cymbal rests on a felt pad around the rod of its stand, usually with a second pad above it under a wing nut. Felt is a soft spring: a pad of thickness $t$ and Young's modulus $E_p$ has a stiffness per unit area of $E_p/t$ in compression, and an annulus of felt between the radii $r_1$ and $r_2$ resists a vertical motion of the cymbal with the stiffness</p>
637637 $$ K = n_p\\,\\frac{E_p}{t}\\,\\pi\\left(r_2^2 - r_1^2\\right), $$
638638 <p>with $n_p$ the number of pads. The cymbal as a whole then bounces on the felt at $f_b = \\frac{1}{2\\pi}\\sqrt{K/M}$, and it rocks, tilting about a diameter, against the torque of the annulus, whose stiffness is its second moment $K_\\theta = n_p (E_p/t)\\,\\pi(r_2^4 - r_1^4)/4$, at $f_r = \\frac{1}{2\\pi}\\sqrt{K_\\theta/I}$ with $I$ the moment of inertia of the cymbal about a diameter. These are modes that a free plate does not have, below every plate mode: the resonances of the stand, a few hertz for the rock and tens of hertz for the bounce. The felt is lossy, and its loss factor $\\eta$ adds to each mode a decay rate proportional to the fraction of the mode's energy stored in the felt, $\\sigma = \\eta\\,\\phi^T K_s\\phi/(2\\omega)$; the rock and the bounce die within a fraction of a second, while the plate modes, which hardly move the center, are barely affected.</p>
639-<p>In the linear model the pads are linear springs. The full model lets them do what real felt does, push only while compressed, so that a hard hit can lift the cymbal off the bottom pad. The rod and its sleeve are not modeled; the shear of the felt is what holds the cymbal sideways.</p>
640-${liveText ? live(liveText) : need("Load a design on a stand, such as the flat bronze disc, to see its stand.")}
639+<p>In the linear model the pads are linear springs. The full model lets them do what real felt does, push only while compressed, so that a hard hit can lift the cymbal off the bottom pad. The rod and its sleeve are not modeled; the shear of the felt is what holds the cymbal sideways. The stand is a setting of the setup rather than of the design: the Stand parameters beside the script put any plate on it or take it off, and set the felt's radii, thickness, modulus and loss factor, the preload of the wing nut and the position of the rod.</p>
640+${liveText ? live(liveText) : need("Turn the stand on (the Stand parameters, or the button below) to see it.")}
641641 <p>The rock and the bounce are found among the computed modes by their rigid share, the fraction of a mode's kinetic energy that is a rigid translation or a rigid tilt of the cymbal: $(\\sum_k m_k\\phi_k)^2/M$ for the translation, and the same with the tilt. The rock comes out near $f_r$: at a few hertz it lies far below the first plate mode, and the cymbal tilts nearly as a whole. The bounce comes out well below $f_b$, and the reason is instructive. The formula treats the cymbal as rigid, but a thin plate is not: the pads push on its center, and, loosely speaking, the compliance of the plate around the center adds in series with that of the felt, so the center yields and the rim follows late. The bounce mode is a rigid translation blended with the umbrella-shaped plate mode, one nodal circle and no nodal diameter, whose antinode is at the center where the pads act; and the higher plate modes that move the center take a share of the felt's damping, which shortens their decay. A thicker plate is stiffer at its center and its bounce comes closer to $f_b$: compare the 20 inch ride with the flat disc.</p>`;
642642 },
643643 actions: [
644- { label: "Load the flat bronze disc on its stand", run: async (h) => { h.setParam("spacing", 0.008); await h.loadPreset("flat-disc"); } },
645- { label: "Load the 20 inch ride", run: async (h) => { h.setParam("spacing", 0.008); await h.loadPreset("ride-20"); } },
644+ { label: "The flat bronze disc on the stand", run: async (h) => { h.setParam("spacing", 0.008); h.setParam("standOn", true); await h.loadPreset("flat-disc"); } },
645+ { label: "The 20 inch ride on the stand", run: async (h) => { h.setParam("spacing", 0.008); h.setParam("standOn", true); await h.loadPreset("ride-20"); } },
646+ { label: "Take it off the stand", run: (h) => h.setParam("standOn", false) },
646647 { label: "Compute the modes", run: (h) => h.computeModes() },
647648 { label: "Show the rock", run: (h) => { const c = h.ctx(); if (c.plate && c.model) { const l = modeLabels(c.plate, c.model, 8); const m = l.indexOf("rock"); if (m >= 0) h.showMode(m); } } },
648649 { label: "Show the bounce", run: (h) => { const c = h.ctx(); if (c.plate && c.model) { const l = modeLabels(c.plate, c.model, 8); const m = l.indexOf("bounce"); if (m >= 0) h.showMode(m); } } },
@@ -675,7 +676,7 @@ ${liveText ? live(liveText) : need("Load the 16 inch crash to see its taper.")}
675676 <p>Try it: color the view by thickness to see the taper, and step through the first plate modes to see how their curvature sits on the thin rim.</p>`;
676677 },
677678 actions: [
678- { label: "Load the 16 inch crash", run: async (h) => { h.setParam("spacing", 0.008); await h.loadPreset("crash-16"); } },
679+ { label: "Load the 16 inch crash, on the stand", run: async (h) => { h.setParam("spacing", 0.008); h.setParam("standOn", true); await h.loadPreset("crash-16"); } },
679680 { label: "Color by thickness", run: (h) => h.setViewOptions({ color: "thickness" }) },
680681 { label: "Color by height", run: (h) => h.setViewOptions({ color: "height" }) },
681682 { label: "Compute the modes", run: (h) => h.computeModes() },
@@ -709,7 +710,7 @@ ${liveText ? live(liveText) : need("Load a design.")}`;
709710 { label: "Show mode 1", run: (h) => h.showMode(0) },
710711 { label: "Mode 2", run: (h) => h.showMode(1) },
711712 { label: "Mode 3", run: (h) => h.showMode(2) },
712- { label: "Back to 8 mm", run: async (h) => { h.setParam("spacing", 0.008); await h.loadPreset("flat-rectangle"); } },
713+ { label: "Back to 8 mm", run: async (h) => { h.setParam("spacing", 0.008); h.setParam("standOn", false); await h.loadPreset("flat-rectangle"); } },
713714 ],
714715 },
715716
@@ -740,7 +741,7 @@ ${liveText ? live(liveText) : need("Load a design.")}
740741 <p>Try it: a hard hit on the flat disc at 12 mm spacing for 1 s of sound, which takes some tens of seconds, then the same hit in the linear model. Compare the two spectrograms: the full model's lines bend downward as the sound decays and the space between them fills in; the linear model's stay put. Then a light hit, on which the two agree.</p>`;
741742 },
742743 actions: [
743- { label: "Set up: flat disc, 12 mm, 1 s, 10 m/s", run: async (h) => { h.setParam("spacing", 0.012); h.setParam("duration", 1); h.setParam("strikeVelocity", 10); await h.loadPreset("flat-disc"); h.setRunMode("full"); } },
744+ { label: "Set up: flat disc on the stand, 12 mm, 1 s, 10 m/s", run: async (h) => { h.setParam("spacing", 0.012); h.setParam("duration", 1); h.setParam("strikeVelocity", 10); h.setParam("standOn", true); await h.loadPreset("flat-disc"); h.setRunMode("full"); } },
744745 { label: "Run the full model", needsGpu: true, run: async (h) => { h.setRunMode("full"); await h.runFull(); } },
745746 { label: "Same hit, linear model", run: async (h) => { h.setRunMode("linear"); await h.synthesize(); } },
746747 { label: "Light hit: 0.2 m/s", run: (h) => h.setParam("strikeVelocity", 0.2) },
@@ -756,11 +757,11 @@ ${liveText ? live(liveText) : need("Load a design.")}
756757 body: () => `
757758 <p>Every page above named an approximation, and it is worth collecting them. The plate is thin and its rotations moderate, which holds for a cymbal moved by millimeters. A curved rest shape is treated as a shallow shell, valid for a gentle bow but strained by the deep bell of a real crash; the interface reports the largest slope. The grid places the low modes within a percent or two and flattens the high partials by the amounts on <i>The grid</i>, and its staircase edge splits the mode pairs of a disc that the hammering of a real cymbal splits anyway. The decay times are guesses that sound plausible, not measurements, and the full model matches them only through a two-parameter fit. The stand is a linear felt with a loss factor, without the rod, the sleeve or friction, and gravity enters only as the static preload. The listener hears a plate in a wall, which overstates the bass of a plate in free air, and air loading of the plate itself is neglected.</p>
758759 <p>What remains is a model in which every number can be traced to a formula on these pages, and in which the two ways of computing the sound, the modes of the linearized plate and the time stepping of the nonlinear one, share one discretization and agree where they should. The document THEORY.md in the repository gives the complete equations, the discretization, and the checks that were made, and README.md describes the script and the interface.</p>
759-<p>Some experiments to try from here: an ellipse, whose degenerate mode pairs split; a thunder sheet, whose modes are dense and low; a ride with a loose wing nut (a small preload) hit hard in the full model, which lifts off its pad; a design of your own, written in the script, with a thickness or a profile the presets do not have.</p>`,
760+<p>Some experiments to try from here: an ellipse, whose degenerate mode pairs split; a thunder sheet, whose modes are dense and low; a ride with a loose wing nut (a small preload in the Stand parameters) hit hard in the full model, which lifts off its pad; a design of your own, written in the script, with a thickness or a profile the presets do not have.</p>`,
760761 actions: [
761- { label: "Steel ellipse", run: async (h) => { h.setParam("spacing", 0.008); await h.loadPreset("ellipse"); } },
762- { label: "Thunder sheet", run: async (h) => { h.setParam("spacing", 0.012); await h.loadPreset("thunder-sheet"); } },
763- { label: "20 inch ride", run: async (h) => { h.setParam("spacing", 0.008); await h.loadPreset("ride-20"); } },
762+ { label: "Steel ellipse, free", run: async (h) => { h.setParam("spacing", 0.008); h.setParam("standOn", false); await h.loadPreset("ellipse"); } },
763+ { label: "Thunder sheet, free", run: async (h) => { h.setParam("spacing", 0.012); h.setParam("standOn", false); await h.loadPreset("thunder-sheet"); } },
764+ { label: "20 inch ride on the stand", run: async (h) => { h.setParam("spacing", 0.008); h.setParam("standOn", true); await h.loadPreset("ride-20"); } },
764765 ],
765766 },
766767 ];
tests/design.test.tsmodified+11−9View file
@@ -2,7 +2,7 @@ import { test } from "node:test";
22 import assert from "node:assert/strict";
33 import { compileDesign, evaluatePlate, DesignError, removePlane, isFlat } from "../src/design.ts";
44 import { PRESETS } from "../src/presets.ts";
5-import { countInside, insideNode, nodeX, nodeY, nodeWeight, standRates, massProperties } from "../src/params.ts";
5+import { countInside, insideNode, nodeX, nodeY, nodeWeight, standRates, massProperties, standFromParams, defaultParams, type Stand } from "../src/params.ts";
66 import { ROOM_TEMPERATURE } from "../src/params.ts";
77
88 const base = `
@@ -18,7 +18,6 @@ test("a minimal script compiles with defaults", () => {
1818 assert.equal(d.name, "test");
1919 assert.equal(d.thicknessConst, 1e-3);
2020 assert.equal(d.profileConst, 0);
21- assert.equal(d.stand, null);
2221 assert.equal(d.hasInside, false);
2322 assert.equal(d.material.eta, 0, "no internal friction unless the script gives a loss factor");
2423 assert.equal(d.material.thermal, null, "no thermoelastic damping without the thermal constants");
@@ -100,15 +99,14 @@ test("the profile's mean plane is removed", () => {
10099 assert.ok(f.every((v) => Math.abs(v) < 1e-12));
101100 });
102101
103-test("the stand: pads sum to the annulus stiffness, preloads carry the weight", () => {
102+test("the stand comes from the parameters, not the script, and its pads sum to the annulus stiffness", () => {
103+ assert.throws(() => compileDesign(base + "design.stand.preload = 1e-3;"), /design.stand is no longer part of the script/);
104104 const d = compileDesign(base + `
105105 design.bounds.width = 0.3; design.bounds.height = 0.3;
106106 design.inside = @(x, y, r, theta) (r <= 0.15) & (r >= 0.0065);
107-design.stand.pad.innerRadius = 7*mm; design.stand.pad.outerRadius = 20*mm; design.stand.pad.thickness = 10*mm;
108-design.stand.pad.E = 1e6; design.stand.pad.lossFactor = 0.2; design.stand.preload = 0.5*mm;
109107 `);
110- assert.ok(d.stand && d.stand.topPad);
111- const pl = evaluatePlate(d, 0.005);
108+ const stand: Stand = { x: 0, y: 0, pad: { innerRadius: 7e-3, outerRadius: 20e-3, thickness: 10e-3, E: 1e6, lossFactor: 0.2 }, topPad: true, preload: 0.5e-3 };
109+ const pl = evaluatePlate(d, 0.005, stand);
112110 const sr = standRates(pl)!;
113111 const annulus = Math.PI * (0.02 ** 2 - 0.007 ** 2);
114112 const kPad = (1e6 / 0.01) * annulus;
@@ -117,11 +115,15 @@ design.stand.pad.E = 1e6; design.stand.pad.lossFactor = 0.2; design.stand.preloa
117115 assert.ok(Math.abs(sr.preloadBottom - (0.5e-3 + (mass * 9.81) / kPad)) < 1e-12);
118116 assert.equal(sr.preloadTop, 0.5e-3);
119117 assert.ok(Math.abs(sr.bounceHz - Math.sqrt(sr.totalKw / mass) / (2 * Math.PI)) < 1e-9);
120- const d2 = compileDesign(base + "design.stand.topPad = false;");
121- const pl2 = evaluatePlate(d2, 0.01);
118+ const pl2 = evaluatePlate(d, 0.01, { ...stand, topPad: false });
122119 const sr2 = standRates(pl2)!;
123120 assert.equal(sr2.preloadTop, 0);
124121 assert.ok(sr2.totalKw > 0);
122+ // The parameters describe the same stand; off gives a free plate.
123+ const fromParams = standFromParams({ ...defaultParams, padThickness: 10e-3, padE: 1e6, padLossFactor: 0.2, preload: 0.5e-3 });
124+ assert.deepEqual(fromParams, stand);
125+ assert.equal(standFromParams({ ...defaultParams, standOn: false }), null);
126+ assert.equal(evaluatePlate(d, 0.01).stand, null);
125127 });
126128
127129 test("the loss constants of the material are read and checked", () => {
tests/tutorial.test.tsmodified+4−3View file
@@ -8,7 +8,7 @@ import { test } from "node:test";
88 import assert from "node:assert/strict";
99 import { compileDesign, evaluatePlate } from "../src/design.ts";
1010 import { presetById } from "../src/presets.ts";
11-import { defaultParams, type SimParams } from "../src/params.ts";
11+import { defaultParams, standFromParams, type SimParams } from "../src/params.ts";
1212 import { assemble } from "../src/model/assemble.ts";
1313 import { computeModes, localRunner } from "../src/modal/modes.ts";
1414 import { modalModel, synthesize } from "../src/modal/synth.ts";
@@ -31,7 +31,7 @@ class TestHost implements TutorialHost {
3131 played = 0;
3232 private async build(source: string): Promise<void> {
3333 const d = compileDesign(source);
34- const pl = evaluatePlate(d, this.params.spacing);
34+ const pl = evaluatePlate(d, this.params.spacing, standFromParams(this.params));
3535 this.c = { ...this.c, design: d, plate: pl, params: { ...this.params }, spacing: this.params.spacing, sys: null, modes: null, model: null, lastContact: null };
3636 }
3737 ctx(): TutorialContext { return { ...this.c, params: { ...this.params }, runMode: this.runMode }; }
@@ -39,7 +39,7 @@ class TestHost implements TutorialHost {
3939 async loadScript(source: string): Promise<void> { await this.build(source); }
4040 setParam<K extends keyof SimParams>(key: K, value: SimParams[K]): void {
4141 this.params[key] = value;
42- if (key === "spacing" && this.c.design) void this.build(this.c.design.source);
42+ if ((key === "spacing" || String(key).startsWith("stand") || String(key).startsWith("pad") || key === "topPad" || key === "preload") && this.c.design) void this.build(this.c.design.source);
4343 }
4444 async computeModes(): Promise<void> {
4545 const pl = this.c.plate;
@@ -87,6 +87,7 @@ test("every step renders with an empty context, with a plate, and with its modes
8787 for (const s of STEPS) check(s.body(c), `${s.id} (modes)`);
8888
8989 // The stand pages with a plate on a stand.
90+ host.setParam("standOn", true);
9091 await host.loadPreset("flat-disc");
9192 await host.computeModes();
9293 const cs = host.ctx();