Parameter presets, including the paper-demo stroke
4 changed files+156−1
webapp/README.mdmodified+4−0View file
@@ -21,6 +21,10 @@ Everywhere. Unlike the sibling `cymbal` app, whose expensive stage (the eigensol
2121
2222 When the browser exposes WebGPU, the time loop runs on the GPU instead of through the interpreter: the worker still executes the unmodified setup stage (grid, per-wavenumber inverses, mallet, stroke schedule) and output stage through numbl, but hands the operators to a WGSL port of `cymbal2_sim.m` (`src/worker/gpuSim.ts`, eight compute dispatches per time step; the design and its measurements are documented in the [cymbal2-webgpu](https://vault1.magland.org/proof-of-concept/cymbal2-webgpu) exploration). This is 20x or more faster than interpreting the loop, even on an integrated GPU, and the diagnostics panel says which path a render took. The GPU integrates in single precision; the contact diagnostics match the double-precision interpreter to a fraction of a percent, and the trajectory of a crash is chaotic either way. Without WebGPU the interpreter path runs as before, and expect tens of seconds for a preview and many minutes for a full render. One interaction with the code editor: the WGSL mirrors the shipped `cymbal2_sim.m`, so editing that one file falls back to the interpreter, where the edit is live (a warning in the diagnostics says so); edits to every other `.m` file flow through numbl on both paths. `npm i --no-save webgpu && npx tsx scripts/gpu-selftest.ts` runs the GPU loop from Node on desktop WebGPU and checks the pinned diagnostics.
2323
24+## Presets
25+
26+The Preset selector at the top of the parameter panel replaces the whole parameter set with a named configuration; picking one and then adjusting shows "custom". "Paper demo" is the stroke fitted against the demonstration recording published by the paper's authors (the repository's `cymbal2-paper-demo.wav`); the others span the stroke range from a near-linear tap to the hard-crash ceiling, a roll, the rigid clamp of the earlier literature, two mallets, and two other shells. Every preset is checked to integrate stably at the preview tier, which is the binding case; the notes under the selector say where each one's headroom runs out.
27+
2428 ## Reading and editing the code in the app
2529
2630 The Code button opens the MATLAB source in a CodeMirror editor with MATLAB syntax highlighting. Every `.m` file in `../matlab` is listed, tagged by whether the app executes it or it is a CLI entry point. The sources are editable and an edit takes effect on the next render, so the physics can be changed in place: setting the curvature term `Rs` large in `cymbal2_system.m` flattens the shell into a plate, and zeroing the `Luu` term in `cymbal2_sim.m` removes the stretching nonlinearity, leaving a linear shell that never crashes however hard it is hit.
webapp/src/App.tsxmodified+29−1View file
@@ -1,5 +1,5 @@
11 import { useCallback, useEffect, useRef, useState } from 'react';
2-import { DEFAULTS, loadParams, saveParams, type Cymbal2Params } from './params';
2+import { DEFAULTS, PRESETS, applyPreset, matchPresetId, loadParams, saveParams, type Cymbal2Params } from './params';
33 import { DEFAULT_MFILES, type MFile } from './mfiles';
44 import { useRenderer } from './hooks/useRenderer';
55 import { encodeWav } from './lib/wav';
@@ -32,6 +32,13 @@ export default function App() {
3232
3333 const reset = useCallback(() => setParams({ ...DEFAULTS }), []);
3434
35+ const presetId = matchPresetId(params);
36+ const preset = PRESETS.find((p) => p.id === presetId) ?? null;
37+ const onPreset = useCallback((id: string) => {
38+ const p = PRESETS.find((q) => q.id === id);
39+ if (p) setParams(applyPreset(p));
40+ }, []);
41+
3542 const onChangeFile = useCallback((name: string, source: string) => {
3643 setMfiles((fs) => fs.map((f) => (f.name === name ? { ...f, source } : f)));
3744 }, []);
@@ -180,6 +187,27 @@ export default function App() {
180187 </div>
181188 <div className="main">
182189 <div className="sidebar">
190+ <div className="preset-row">
191+ <label htmlFor="preset-select">Preset</label>
192+ <select
193+ id="preset-select"
194+ value={presetId ?? 'custom'}
195+ onChange={(e) => onPreset(e.target.value)}
196+ title="Replaces the whole parameter set; pick a preset, then adjust"
197+ >
198+ {presetId === null && (
199+ <option value="custom" disabled>
200+ custom
201+ </option>
202+ )}
203+ {PRESETS.map((p) => (
204+ <option key={p.id} value={p.id}>
205+ {p.name}
206+ </option>
207+ ))}
208+ </select>
209+ </div>
210+ {preset && <div className="preset-note">{preset.note}</div>}
183211 <ParamPanel params={params} onChange={onChange} />
184212 </div>
185213 <div className="content">
webapp/src/params.tsmodified+102−0View file
@@ -246,6 +246,108 @@ export const PARAM_GROUPS: ParamGroup[] = [
246246 },
247247 ];
248248
249+
250+// ---- Presets ----
251+// A preset is a patch on DEFAULTS, so switching presets replaces the whole
252+// parameter set rather than layering on top of manual edits. "Paper demo" is
253+// the configuration behind the repository's cymbal2-paper-demo.wav: the
254+// stroke fitted against the demonstration recording published by the paper's
255+// authors (band balance within about 2 dB from 100 Hz to 12 kHz, the
256+// high-frequency buildup, and the decay envelope).
257+
258+export interface Preset {
259+ id: string;
260+ name: string;
261+ note: string;
262+ patch: Partial<Cymbal2Params>;
263+}
264+
265+export const PRESETS: Preset[] = [
266+ {
267+ id: 'defaults',
268+ name: 'App defaults',
269+ note: 'The paper\'s measured 18-inch crash at a firm 4 m/s stroke.',
270+ patch: {},
271+ },
272+ {
273+ id: 'paper-demo',
274+ name: 'Paper demo',
275+ note: 'The stroke fitted against the authors\' published demonstration recording (5.75 m/s hand, tip arriving near 8.2 m/s, about 311 N peak). This is the configuration behind cymbal2-paper-demo.wav.',
276+ patch: { strike_velocity: 5.75 },
277+ },
278+ {
279+ id: 'gentle-tap',
280+ name: 'Gentle tap',
281+ note: 'A 0.5 m/s fingertip-weight tap: the shell stays nearly linear and rings like the bell of a gong. Longer tail to hear the full decay.',
282+ patch: { strike_velocity: 0.5, tail_s: 8 },
283+ },
284+ {
285+ id: 'medium-wash',
286+ name: 'Medium wash',
287+ note: 'A 2 m/s stroke, where the crash begins to wash: energy migrates upward over the first half second without a full crash.',
288+ patch: { strike_velocity: 2 },
289+ },
290+ {
291+ id: 'hard-crash',
292+ name: 'Hard crash',
293+ note: 'A 7 m/s stroke, the practical ceiling: above this the explicit nonlinear integration diverges, which is also where the shallow-shell physics runs out.',
294+ patch: { strike_velocity: 7 },
295+ },
296+ {
297+ id: 'roll',
298+ name: 'Roll',
299+ note: 'Eight strokes at 6 per second with scattered velocities; the hand state machine returns to rest between strokes, which the paper\'s constant-acceleration hand cannot do. Harder rolls pump energy in faster than it decays and the integration diverges.',
300+ patch: { strike_velocity: 2.5, strike_count: 8, strike_rate_hz: 6, velocity_spread: 0.2, tail_s: 5 },
301+ },
302+ {
303+ id: 'rigid-clamp',
304+ name: 'Rigid clamp',
305+ note: 'The rigid center support of the earlier literature (Bilbao 2010) in place of the measured felt washer: the low end stiffens and the mounted-cymbal wobble disappears.',
306+ patch: { strike_velocity: 5.75, support_clamped: true },
307+ },
308+ {
309+ id: 'hard-tip',
310+ name: 'Hard tip, tight grip',
311+ note: 'A stiffer, lighter head with a more nearly Hertzian exponent, on a clamped grip: the contact shortens to about 1.5 ms and the attack turns into a bright ping. Much stiffer contacts outrun what the explicit contact scheme resolves.',
312+ patch: { strike_velocity: 4, head_stiffness: 4e8, head_alpha: 1.8, head_mass_kg: 0.02, grip_clamped: true },
313+ },
314+ {
315+ id: 'soft-mallet',
316+ name: 'Soft mallet',
317+ note: 'A heavier, softer felt head: the contact lengthens, the attack darkens, and the wash builds without the metallic ping.',
318+ patch: { strike_velocity: 3, head_stiffness: 5e6, head_alpha: 2.2, head_mass_kg: 0.045 },
319+ },
320+ {
321+ id: 'splash',
322+ name: 'Small splash',
323+ note: 'A 30 cm shell, thinner and flatter, with heavier damping as small cymbals have: everything shifts up and decays fast. The washer circle stays wide enough for the radial grid to resolve.',
324+ patch: { radius_m: 0.15, rise_m: 0.025, thickness_m: 0.0009, washer_radius_m: 0.022, sigma0: 2.5, sigma1: 0.003, strike_velocity: 3, tail_s: 4 },
325+ },
326+ {
327+ id: 'ride',
328+ name: 'Thick ride',
329+ note: 'A 51 cm shell at 2 mm: displacement per thickness drops, so the nonlinearity weakens and the stroke pings and sustains instead of crashing.',
330+ patch: { radius_m: 0.255, rise_m: 0.05, thickness_m: 0.002, strike_velocity: 3, tail_s: 8 },
331+ },
332+];
333+
334+export function applyPreset(preset: Preset): Cymbal2Params {
335+ return { ...DEFAULTS, ...preset.patch };
336+}
337+
338+// The preset whose applied parameter set matches exactly, or null.
339+export function matchPresetId(params: Cymbal2Params): string | null {
340+ for (const p of PRESETS) {
341+ const applied = applyPreset(p);
342+ let same = true;
343+ for (const k of Object.keys(DEFAULTS) as (keyof Cymbal2Params)[]) {
344+ if (params[k] !== applied[k]) { same = false; break; }
345+ }
346+ if (same) return p.id;
347+ }
348+ return null;
349+}
350+
249351 const STORAGE_KEY = 'cymbal2-webapp-params-v1';
250352
251353 export function loadParams(): Cymbal2Params {
webapp/src/style.cssmodified+21−0View file
@@ -366,3 +366,24 @@ details.group[open] summary { border-bottom: 1px solid var(--border); }
366366 text-align: right;
367367 margin-top: 2px;
368368 }
369+
370+.preset-row {
371+ display: flex;
372+ align-items: center;
373+ gap: 8px;
374+ padding: 8px 10px 4px;
375+}
376+.preset-row label {
377+ font-size: 12px;
378+ color: var(--muted, #888);
379+}
380+.preset-row select {
381+ flex: 1;
382+ min-width: 0;
383+}
384+.preset-note {
385+ padding: 2px 10px 8px;
386+ font-size: 11.5px;
387+ line-height: 1.45;
388+ color: var(--muted, #888);
389+}