/ proof-of-concept / steelsim21
Sign in
proof-of-concept / steelsim21
Drumstick strike excitation (Hertz contact with a free mass)
Adds a second excitation: a stick tip of configurable mass and speed strikes a configurable point through a Hertz contact law K delta^alpha, integrated in lockstep with the plate and spread over a tip-sized patch. The stick state lives in two slots of the reaction buffer; contact force and stick position are reported in the status panel.
Jeremy Magland <jmagland@flatironinstitute.org> committed commit 10998ac08809 parent bfde5e4 Browse files
8 changed files+161−15
README.mdmodified+1−1View file
@@ -13,7 +13,7 @@ Requires Node 20 or later and a browser with WebGPU (Chrome or Edge 113+; Firefo
1313
1414 ## Using the simulator
1515
16-Press Start. The sheet is pushed at the bottom-right corner over the ramp time, held with heavy artificial damping until it settles, and then released by ramping the finger force to zero over the release time; the recording of audio and movie frames starts at the release. The view shows the transverse displacement as a color map (2D) or a lit height field (3D, drag to orbit and wheel to zoom), the spectrogram shows the listener signal, and the audio can be played or downloaded as a 16-bit WAV at any time.
16+Press Start. With the default excitation the sheet is pushed at the bottom-right corner over the ramp time, held with heavy artificial damping until it settles, and then released by ramping the finger force to zero over the release time; the recording of audio and movie frames starts at the release. The other excitation is a drumstick strike (Hertz contact with a free mass) at a configurable point and speed; the recording then starts at the impact. The view shows the transverse displacement as a color map (2D) or a lit height field (3D, drag to orbit and wheel to zoom), the spectrogram shows the listener signal, and the audio can be played or downloaded as a 16-bit WAV at any time.
1717
1818 - The sheet has a stress-free initial bow (default 3 mm across the width; set the shape to Flat for the ideal plate). A negative push amount pushes against the bow.
1919 - Plate, initial shape, excitation, and numerics parameters take effect at the next Start. Damping, listener position, and the radiation model can be changed while a simulation runs.
THEORY.mdmodified+8−0View file
@@ -49,8 +49,16 @@ The same projection defines what "holding" the sheet means during the push. The
4949
5050 ## 3. Excitation
5151
52+Two excitations are offered: a push at the corner that is held and released, and a drumstick strike.
53+
54+### Push and release
55+
5256 The simulation has three phases. During the push (default 0.1 s) the transverse displacement of the nodes within the finger radius follows a smooth ramp to the push amount, and heavy artificial damping ($\sigma_0 = 300\ \mathrm{s^{-1}}$) removes the waves that the ramp launches. During the hold the constraint stays in place until the non-rigid kinetic energy drops below a threshold or a maximum settle time elapses. At the release the clock is set to zero, the damping returns to its physical value, and the recording of audio and movie frames begins. The finger does not let go instantaneously: the reaction force that held each node of the patch is recorded at the end of the hold and then applied as an external force that falls smoothly to zero over the release time (20 ms by default). This matters for the sound. An instantaneous release is a step in the finger force, which excites all modes at once, and because the radiated pressure is proportional to the plate acceleration, the result is a nearly flat pressure spectrum, a cymbal-like click. A release over 20 ms confines the excitation to roughly the first hundred hertz, as a fingertip does. The rigid-body components of the recorded reaction are removed before it is applied, so that the finger force and the distributed holding reaction relax together and the plate is not accelerated as a whole. The release time can be set to zero to recover the instantaneous case.
5357
58+### Drumstick strike
59+
60+The second excitation is a strike with a wooden drumstick. The stick tip is modelled as a free mass $M$ (default 30 g, the effective mass of a stick in a wrist stroke rather than its full mass) arriving with speed $v_0$ (default 3 m/s) at a point of the sheet, interacting through a Hertz contact law: with the penetration $\delta = w_c - x$ of the tip position $x$ into the sheet displacement $w_c$ at the contact point, the force is $F = K\,\delta^{\alpha}$ for $\delta > 0$ and zero otherwise, with $\alpha = 1.5$ for a rounded tip and $K = 5\times10^{8}\ \mathrm{N/m^{1.5}}$ for wood on steel. The stick obeys $M\ddot x = F$, the same force is spread over the nodes within the tip radius (5 mm) in proportion to their mass, and both are stepped together with the plate. The stick may bounce and strike again if the sheet comes back to meet it, which the model handles without special treatment. The recording starts at the moment of impact, with the sheet at rest in its natural shape. Note that a thin sheet is a soft target: its driving-point impedance is $8\sqrt{D\rho h} \approx 59\ \mathrm{N\,s/m}$, so the contact lasts well under a millisecond and the peak force is of the order of a hundred newtons; the excitation is therefore broadband, unlike the push, and the sound is a short bright attack followed by the ring.
61+
5462 ## 4. Spatial discretization
5563
5664 The plate is covered by a regular grid of $n_x \times n_y$ nodes with spacings $h_x$ and $h_y$ (about 4 mm by default). All three displacements live at the nodes. The forces are the negative gradient of a discrete potential energy $U = U_b + U_m$, so the semi-discrete system is Hamiltonian and its linear part has a symmetric stiffness matrix.
src/main.tsmodified+43−4View file
@@ -1,6 +1,6 @@
11 // UI wiring: parameters, run loop, live and movie views, playback, downloads.
22
3-import { defaultParams, makeGrid, makeMaterial, t60, type BowShape, type PhysParams } from "./params.ts";
3+import { defaultParams, makeGrid, makeMaterial, t60, type BowShape, type Excitation, type PhysParams } from "./params.ts";
44 import { Simulation } from "./sim/simulation.ts";
55 import { Renderer, type ViewMode } from "./render/renderer.ts";
66 import { Spectrogram } from "./audio/spectrogram.ts";
@@ -35,7 +35,7 @@ const groups: { title: string; open: boolean; fields: FieldSpec[] }[] = [
3535 ],
3636 },
3737 {
38- title: "Excitation",
38+ title: "Excitation: push and release",
3939 open: true,
4040 fields: [
4141 { key: "pushAmount", label: "Push at corner", unit: "mm", scale: 1e-3, step: 0.5 },
@@ -47,6 +47,19 @@ const groups: { title: string; open: boolean; fields: FieldSpec[] }[] = [
4747 { key: "holeInset", label: "Hole inset", unit: "mm", scale: 1e-3, step: 1 },
4848 ],
4949 },
50+ {
51+ title: "Excitation: drumstick strike",
52+ open: false,
53+ fields: [
54+ { key: "strikeFracX", label: "Strike x (fraction of width)", unit: "", scale: 1, step: 0.05 },
55+ { key: "strikeFracY", label: "Strike y (fraction of height)", unit: "", scale: 1, step: 0.05 },
56+ { key: "strikeVelocity", label: "Stick speed", unit: "m/s", scale: 1, step: 0.5 },
57+ { key: "stickMass", label: "Stick mass", unit: "g", scale: 1e-3, step: 5 },
58+ { key: "contactStiffness", label: "Contact stiffness K", unit: "N/mᵅ", scale: 1, step: 1e8 },
59+ { key: "contactExponent", label: "Contact exponent α", unit: "", scale: 1, step: 0.1 },
60+ { key: "contactRadius", label: "Tip radius", unit: "mm", scale: 1e-3, step: 1 },
61+ ],
62+ },
5063 {
5164 title: "Initial shape",
5265 open: true,
@@ -87,6 +100,21 @@ let params: PhysParams = { ...defaultParams };
87100
88101 function buildForm(): void {
89102 const root = $("params");
103+ const exRow = document.createElement("div");
104+ exRow.className = "field";
105+ const exLabel = document.createElement("label");
106+ exLabel.textContent = "Excitation";
107+ const exSel = document.createElement("select");
108+ for (const [v, t] of [["push", "Push corner and release"], ["strike", "Drumstick strike"]]) {
109+ const o = document.createElement("option");
110+ o.value = v; o.textContent = t;
111+ exSel.appendChild(o);
112+ }
113+ exSel.value = params.excitation;
114+ exSel.addEventListener("change", () => { params.excitation = exSel.value as Excitation; });
115+ exRow.append(exLabel, exSel, document.createElement("span"));
116+ root.appendChild(exRow);
117+ inputs.set("excitation", exSel);
90118 for (const g of groups) {
91119 const det = document.createElement("details");
92120 det.open = g.open;
@@ -193,6 +221,7 @@ function updateDampingNote(): void {
193221 function setFormEnabled(enabled: boolean): void {
194222 for (const g of groups) for (const f of g.fields) if (!f.live) inputs.get(f.key)!.disabled = !enabled;
195223 inputs.get("bowShape")!.disabled = !enabled;
224+ inputs.get("excitation")!.disabled = !enabled;
196225 }
197226
198227 // ---------- State ----------
@@ -231,7 +260,7 @@ function buildControls(): void {
231260 durRow.className = "field";
232261 durRow.style.width = "100%";
233262 const l = document.createElement("label");
234- l.textContent = "Duration after release";
263+ l.textContent = "Recorded duration";
235264 durInput.type = "number";
236265 durInput.step = "0.5";
237266 durInput.min = "0.1";
@@ -511,6 +540,8 @@ function updateInfo(st: ReturnType<Simulation["status"]>): void {
511540 const g = sim.grid;
512541 const spl = st.peakPressure > 0 ? 20 * Math.log10(st.peakPressure / 2e-5) : -Infinity;
513542 const phase = { push: "pushing", hold: "settling (held)", recording: "ringing (recording)", done: "done" }[st.phase];
543+ const stickHook = (window as unknown as { steelsim?: { stick?: unknown } }).steelsim;
544+ if (stickHook) stickHook.stick = st.stick;
514545 const rows: [string, string][] = [
515546 ["Phase", phase],
516547 ["Time", `${st.t.toFixed(3)} s`],
@@ -522,6 +553,10 @@ function updateInfo(st: ReturnType<Simulation["status"]>): void {
522553 ["Audio", `${(st.samples / sim.fs).toFixed(2)} s, ${st.frames} frames`],
523554 ["Peak pressure", st.peakPressure > 0 ? `${st.peakPressure.toFixed(3)} Pa (${spl.toFixed(1)} dB SPL)` : "–"],
524555 ];
556+ if (st.stick) {
557+ rows.push(["Stick", `${(st.stick.x * 1e3).toFixed(2)} mm, ${st.stick.v.toFixed(2)} m/s`]);
558+ rows.push(["Contact force", `${st.stick.force.toFixed(1)} N (max ${st.stick.maxForce.toFixed(1)} N)`]);
559+ }
525560 let html = rows.map(([a, b]) => `<div class="row"><span>${a}</span><span>${b}</span></div>`).join("");
526561 if (st.delayClamped) html += `<div class="warn">Listener is so close that some propagation delays were clamped.</div>`;
527562 if (!isFinite(st.wmax) || st.wmax > 1) html += `<div class="err">The solution has diverged. Reduce the time step by increasing the grid spacing, or reduce the push.</div>`;
@@ -562,7 +597,7 @@ async function init(): Promise<void> {
562597 function applyUrlParams(): void {
563598 const q = new URLSearchParams(location.search);
564599 for (const [k, v] of q) {
565- if (k in params && k !== "radiation" && k !== "bowShape") {
600+ if (k in params && k !== "radiation" && k !== "bowShape" && k !== "excitation") {
566601 const num = parseFloat(v);
567602 if (isFinite(num)) {
568603 (params as unknown as Record<string, number>)[k] = num;
@@ -572,6 +607,10 @@ function applyUrlParams(): void {
572607 }
573608 }
574609 if (k === "radiation" && (v === "rayleigh" || v === "dipole")) params.radiation = v;
610+ if (k === "excitation" && (v === "push" || v === "strike")) {
611+ params.excitation = v;
612+ (inputs.get("excitation") as HTMLSelectElement).value = v;
613+ }
575614 if (k === "bowShape" && ["none", "width", "height", "dish"].includes(v)) {
576615 params.bowShape = v as BowShape;
577616 (inputs.get("bowShape") as HTMLSelectElement).value = v;
src/params.tsmodified+23−0View file
@@ -28,8 +28,18 @@ export interface PhysParams {
2828 radiation: "rayleigh" | "dipole";
2929 bowShape: BowShape; // stress-free initial shape of the sheet
3030 bowAmplitude: number; // sag of the initial shape, center relative to the edges [m]
31+ excitation: Excitation;
32+ strikeFracX: number; // strike point as a fraction of the width from the left edge
33+ strikeFracY: number; // strike point as a fraction of the height from the bottom edge
34+ strikeVelocity: number; // stick tip speed at impact [m/s]
35+ stickMass: number; // effective mass of the stick [kg]
36+ contactStiffness: number; // Hertz contact stiffness K [N/m^alpha]
37+ contactExponent: number; // Hertz exponent alpha (1.5 for a sphere)
38+ contactRadius: number; // radius of the contact patch [m]
3139 }
3240
41+export type Excitation = "push" | "strike";
42+
3343 export type BowShape = "none" | "width" | "height" | "dish";
3444
3545 export const defaultParams: PhysParams = {
@@ -60,8 +70,21 @@ export const defaultParams: PhysParams = {
6070 radiation: "rayleigh",
6171 bowShape: "width",
6272 bowAmplitude: 3e-3,
73+ excitation: "push",
74+ strikeFracX: 0.7,
75+ strikeFracY: 0.35,
76+ strikeVelocity: 3,
77+ stickMass: 0.03,
78+ contactStiffness: 5e8,
79+ contactExponent: 1.5,
80+ contactRadius: 5e-3,
6381 };
6482
83+/** Strike point in plate coordinates (relative to the center). */
84+export function strikePoint(p: PhysParams): [number, number] {
85+ return [(p.strikeFracX - 0.5) * p.Lx, (p.strikeFracY - 0.5) * p.Ly];
86+}
87+
6588 /** Stress-free initial shape w0 at every node. */
6689 export function initialShape(p: PhysParams, g: Grid): Float32Array<ArrayBuffer> {
6790 const w0 = new Float32Array(g.n);
src/render/renderer.tsmodified+7−3View file
@@ -1,6 +1,6 @@
11 // WebGPU rendering of the displacement field: a 2D color map or a lit 3D height field.
22
3-import { type Grid, type PhysParams } from "../params.ts";
3+import { type Grid, type PhysParams, strikePoint } from "../params.ts";
44 import { lookAt, multiply, perspective } from "./mat4.ts";
55 import shaderCode from "./shaders.wgsl?raw";
66
@@ -135,8 +135,12 @@ export class Renderer {
135135 ui[23] = this.mode === "3d" ? 1 : 0;
136136 const p = this.params;
137137 u[24] = -p.Lx / 2 + p.holeInset; u[25] = p.Ly / 2 - p.holeInset;
138- u[26] = p.Lx / 2; u[27] = -p.Ly / 2;
139- u[28] = p.fingerRadius;
138+ if (p.excitation === "strike") {
139+ const [sx, sy] = strikePoint(p);
140+ u[26] = sx; u[27] = sy; u[28] = p.contactRadius;
141+ } else {
142+ u[26] = p.Lx / 2; u[27] = -p.Ly / 2; u[28] = p.fingerRadius;
143+ }
140144 u[29] = 0.4; u[30] = 0.5; u[31] = 1.0;
141145 ui[32] = this.showShape ? 1 : 0;
142146 this.device.queue.writeBuffer(this.uniform, 0, u);
src/sim/shaders/common.wgslmodified+10−2View file
@@ -21,12 +21,20 @@ struct Params {
2121 Iy: f32, // sum m x^2
2222 Iz: f32, // sum m (x^2 + y^2)
2323 fs: f32, // audio sample rate [Hz]
24+ strikeX: f32, // drumstick contact point [m]
25+ strikeY: f32,
26+ strikeR2: f32, // squared contact radius [m^2]
27+ patchMass: f32, // mass of the contact patch [kg]
28+ strikeNode: u32, // node nearest the contact point
29+ K: f32, // Hertz contact stiffness [N/m^alpha]
30+ alpha: f32, // Hertz exponent
31+ stickMass: f32, // [kg]
2432 };
2533
2634 // Per-step parameters, addressed with a dynamic uniform offset.
2735 struct StepParams {
2836 step: u32,
29- flags: u32, // bit 0: push constraint active; bit 1: record the constraint reaction; bit 2: apply the recorded reaction
37+ flags: u32, // bit 0: push constraint active; bit 1: record the constraint reaction; bit 2: apply the recorded reaction; bit 3: drumstick active
3038 audioSlot: i32, // ring-buffer slot to record acceleration into (-1: none)
3139 audioOut: i32, // output sample index within the batch
3240 pushW: f32, // prescribed patch displacement [m]
@@ -35,7 +43,7 @@ struct StepParams {
3543 sigma1: f32,
3644 releaseGain: f32, // fraction of the recorded reaction still applied (finger letting go)
3745 sigmaIn: f32, // additional frequency-independent loss of the in-plane motion
38- pad1: f32,
46+ parity: u32, // which of the two stick-state slots holds the current state
3947 pad2: f32,
4048 };
4149
src/sim/shaders/step.wgslmodified+23−1View file
@@ -13,7 +13,9 @@
1313 @group(0) @binding(4) var<storage, read_write> posOut: array<f32>;
1414 @group(0) @binding(5) var<storage, read_write> velOut: array<f32>;
1515 @group(0) @binding(6) var<storage, read_write> hist: array<f32>; // [slot * n + node] velocity of w at the audio sample instants
16-@group(0) @binding(7) var<storage, read_write> react: array<f32>; // constraint reaction (acceleration of w) per node
16+// Constraint reaction (acceleration of w) per node, followed by two 4-float
17+// slots of drumstick state (position, velocity, force, max force).
18+@group(0) @binding(7) var<storage, read_write> react: array<f32>;
1719 @group(0) @binding(8) var<storage, read> w0: array<f32>; // stress-free initial shape
1820
1921 const TILE = 16;
@@ -167,6 +169,26 @@ fn main(@builtin(global_invocation_id) gid: vec3u, @builtin(local_invocation_id)
167169 // Finger letting go: the reaction recorded at the end of the hold, ramped to zero.
168170 if ((SP.flags & 4u) != 0u) { acc.z += react[k] * SP.releaseGain; }
169171
172+ // Drumstick: Hertz contact between a free mass and the contact patch.
173+ if ((SP.flags & 8u) != 0u) {
174+ let sIn = n + 4u * SP.parity;
175+ let sx = react[sIn]; let sv = react[sIn + 1u]; let sMax = react[sIn + 3u];
176+ let wc = posIn[2u * n + P.strikeNode];
177+ let pen = wc - sx; // the stick sits on the +z side and moves toward -z
178+ var F = 0.0;
179+ if (pen > 0.0) { F = P.K * pow(pen, P.alpha); }
180+ let dxs = nodeX(i) - P.strikeX; let dys = nodeY(j) - P.strikeY;
181+ if (dxs * dxs + dys * dys <= P.strikeR2 || k == P.strikeNode) { acc.z -= F / P.patchMass; }
182+ if (k == P.strikeNode) {
183+ let sOut = n + 4u * (1u - SP.parity);
184+ let svn = sv + P.dt * F / P.stickMass;
185+ react[sOut] = sx + P.dt * svn;
186+ react[sOut + 1u] = svn;
187+ react[sOut + 2u] = F;
188+ react[sOut + 3u] = max(sMax, F);
189+ }
190+ }
191+
170192 var vNew = vel + P.dt * acc;
171193 if (SP.audioSlot >= 0) { hist[u32(SP.audioSlot) * n + k] = vNew.z; }
172194 let pOld = vec3f(posIn[k], posIn[n + k], posIn[2u * n + k]);
src/sim/simulation.tsmodified+46−4View file
@@ -13,6 +13,7 @@ import {
1313 makeGrid,
1414 makeMaterial,
1515 initialShape,
16+ strikePoint,
1617 nodeX,
1718 nodeY,
1819 trapWeight,
@@ -42,9 +43,12 @@ export interface SimStatus {
4243 frames: number;
4344 peakPressure: number;
4445 delayClamped: boolean;
46+ /** Drumstick state (strike excitation only). */
47+ stick: { x: number; v: number; force: number; maxForce: number } | null;
4548 }
4649
4750 const STATS_LEN = 16;
51+const STICK_LEN = 8;
4852
4953 export class Simulation {
5054 readonly grid: Grid;
@@ -133,7 +137,7 @@ export class Simulation {
133137 this.pos = [f32(4 * n), f32(4 * n)]; // [u | v | w | wLo]
134138 this.vel = [f32(3 * n), f32(3 * n)]; // [du | dv | dw]
135139 this.hist = f32(HIST_LEN * n);
136- this.react = f32(n);
140+ this.react = f32(n + STICK_LEN);
137141 this.w0 = f32(n);
138142 device.queue.writeBuffer(this.w0, 0, initialShape(params, g));
139143 this.lw = f32(2 * n);
@@ -141,7 +145,7 @@ export class Simulation {
141145 this.frameBuf = f32(n);
142146 this.displayW = f32(n);
143147 this.statsBuf = f32(STATS_LEN);
144- this.stagingSize = (this.samplesPerBatch + n + STATS_LEN) * 4;
148+ this.stagingSize = (this.samplesPerBatch + n + STATS_LEN + STICK_LEN) * 4;
145149 this.staging = [0, 1].map(() =>
146150 device.createBuffer({ size: this.stagingSize, usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST }),
147151 );
@@ -156,13 +160,34 @@ export class Simulation {
156160 Iy += mk * nodeX(g, i) ** 2;
157161 }
158162 }
159- const pf = new Float32Array(20);
163+ // Drumstick contact patch.
164+ const [sx, sy] = strikePoint(params);
165+ let strikeNode = 0, best = Infinity, patchMass = 0;
166+ for (let j = 0; j < g.ny; j++) {
167+ for (let i = 0; i < g.nx; i++) {
168+ const d2 = (nodeX(g, i) - sx) ** 2 + (nodeY(g, j) - sy) ** 2;
169+ if (d2 < best) { best = d2; strikeNode = j * g.nx + i; }
170+ }
171+ }
172+ for (let j = 0; j < g.ny; j++) {
173+ for (let i = 0; i < g.nx; i++) {
174+ const d2 = (nodeX(g, i) - sx) ** 2 + (nodeY(g, j) - sy) ** 2;
175+ if (d2 <= params.contactRadius ** 2 || j * g.nx + i === strikeNode) patchMass += m.rhoh * g.hx * g.hy * trapWeight(g, i, j);
176+ }
177+ }
178+ const stick = new Float32Array(STICK_LEN);
179+ stick[0] = 0; stick[1] = -params.strikeVelocity; stick[4] = 0; stick[5] = -params.strikeVelocity;
180+ device.queue.writeBuffer(this.react, n * 4, stick);
181+
182+ const pf = new Float32Array(28);
160183 const pu = new Uint32Array(pf.buffer);
161184 pu[0] = g.nx; pu[1] = g.ny; pu[2] = n; pu[3] = HIST_LEN;
162185 pf[4] = g.hx; pf[5] = g.hy; pf[6] = this.dt; pf[7] = m.nu;
163186 pf[8] = m.D; pf[9] = m.C; pf[10] = m.G; pf[11] = m.rhoh;
164187 pf[12] = params.Lx / 2; pf[13] = -params.Ly / 2; pf[14] = params.fingerRadius ** 2; pf[15] = mass;
165188 pf[16] = Ix; pf[17] = Iy; pf[18] = Ix + Iy; pf[19] = params.fs;
189+ pf[20] = sx; pf[21] = sy; pf[22] = params.contactRadius ** 2; pf[23] = patchMass;
190+ pu[24] = strikeNode; pf[25] = params.contactStiffness; pf[26] = params.contactExponent; pf[27] = params.stickMass;
166191 this.paramsBuf = device.createBuffer({ size: pf.byteLength, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
167192 device.queue.writeBuffer(this.paramsBuf, 0, pf);
168193
@@ -281,6 +306,11 @@ export class Simulation {
281306 this.displayBG = [0, 1].map((p) => extractBG(p, this.displayW));
282307
283308 this.setListener(params.listenerDist, params.listenerAzimuth, params.listenerElevation, params.radiation);
309+ if (params.excitation === "strike") {
310+ // No push phase: the sheet is at rest in its natural shape when the stick arrives.
311+ this.phase = "recording";
312+ this.frames.push(new Float32Array(n));
313+ }
284314 }
285315
286316 /** Recomputes the per-node weights and delays of the listener sum. Takes effect on later samples. */
@@ -347,6 +377,7 @@ export class Simulation {
347377 frames: this.frames.length,
348378 peakPressure: this.peakPressure,
349379 delayClamped: this.delayClamped,
380+ stick: this.stick,
350381 };
351382 }
352383
@@ -422,6 +453,7 @@ export class Simulation {
422453 const local = this.stepCount + s;
423454 u32[o] = this.totalSteps + s;
424455 let flags = 0, pushW = 0, pushV = 0, sigma0 = p.sigma0, slot = -1, outIdx = -1, releaseGain = 0;
456+ const parity = (this.totalSteps + s) & 1;
425457 if (!recording) {
426458 const t = this.prepTime + (s + 1) * this.dt; // state after this step
427459 const tau = Math.min(1, t / p.rampTime);
@@ -433,7 +465,9 @@ export class Simulation {
433465 sigma0 = p.settleSigma0;
434466 } else {
435467 const t = local * this.dt;
436- if (p.releaseTime > 0 && t < p.releaseTime) {
468+ if (p.excitation === "strike") {
469+ flags = 8;
470+ } else if (p.releaseTime > 0 && t < p.releaseTime) {
437471 const tau = t / p.releaseTime;
438472 releaseGain = 1 - tau * tau * (3 - 2 * tau);
439473 flags = 4;
@@ -454,6 +488,7 @@ export class Simulation {
454488 f32[o + 7] = p.sigma1;
455489 f32[o + 8] = releaseGain;
456490 f32[o + 9] = p.sigmaIn;
491+ u32[o + 10] = parity;
457492 }
458493 this.device.queue.writeBuffer(this.stepBuf, 0, this.stepData, 0, steps * this.stepStride);
459494
@@ -510,6 +545,7 @@ export class Simulation {
510545 enc.copyBufferToBuffer(this.audioOut, 0, staging, 0, Math.max(4, nAudio * 4));
511546 enc.copyBufferToBuffer(this.frameBuf, 0, staging, this.samplesPerBatch * 4, this.grid.n * 4);
512547 enc.copyBufferToBuffer(this.statsBuf, 0, staging, (this.samplesPerBatch + this.grid.n) * 4, STATS_LEN * 4);
548+ enc.copyBufferToBuffer(this.react, this.grid.n * 4, staging, (this.samplesPerBatch + this.grid.n + STATS_LEN) * 4, STICK_LEN * 4);
513549 this.device.queue.submit([enc.finish()]);
514550 this.cur = cur;
515551 this.totalSteps += steps;
@@ -521,6 +557,11 @@ export class Simulation {
521557 const stats = view.subarray(this.samplesPerBatch + this.grid.n, this.samplesPerBatch + this.grid.n + STATS_LEN);
522558 this.ke = stats[12];
523559 this.wmax = stats[13];
560+ if (p.excitation === "strike") {
561+ // The slot written by the last step is the one the next step reads.
562+ const base = this.samplesPerBatch + this.grid.n + STATS_LEN + 4 * (this.totalSteps & 1);
563+ this.stick = { x: view[base], v: view[base + 1], force: view[base + 2], maxForce: view[base + 3] };
564+ }
524565 if (recording) {
525566 this.appendAudio(view.slice(0, nAudio));
526567 this.frames.push(view.slice(this.samplesPerBatch, this.samplesPerBatch + this.grid.n));
@@ -538,6 +579,7 @@ export class Simulation {
538579 }
539580 }
540581 private lastPrepFrame: Float32Array<ArrayBuffer> | null = null;
582+ private stick: SimStatus["stick"] = null;
541583
542584 private release(): void {
543585 this.phase = "recording";
↑↓ move↵openescclose