/ simulated_instruments / cymbal2
Sign in
simulated_instruments / cymbal2
Open on the paper-demo stroke, and fail renders loudly
The app's defaults are now cymbal2_defaults.m as published, so it opens on the stroke fitted against the authors' demonstration recording rather than on a firmer one; "Paper demo" becomes the empty preset patch. The preview tier keeps the full duration and only drops oversampling, since a preview whose tail was clamped could not show the decay. A render that faults now clears the result instead of leaving the previous audio playable, which would no longer correspond to the code or parameters on screen. numbl drops a file it cannot parse with a warning rather than aborting, so the worker parses every executed source before starting and reports the fault with its file and line; a numbl error raised during GPU setup is likewise reported rather than falling back to the interpreter to fail again slowly.
Jeremy Magland <jmagland@flatironinstitute.org> committed commit fc81645695be parent 1d05040 Browse files
4 changed files+133−43
webapp/README.mdmodified+4−4View file
@@ -17,16 +17,16 @@ The self-test runs the closed-form and reproducibility checks and the pinned dia
1717
1818 ## Where the time goes
1919
20-Everywhere. Unlike the sibling `cymbal` app, whose expensive stage (the eigensolves) could be cached and whose synthesis was an oscillator bank, here the sound is a nonlinear time-domain integration of the whole shell, several hundred thousand steps of coupled solves, and every parameter feeds it. There is therefore no debounced auto-render: the Render preview button runs a cheap pass (no oversampling, tail clamped to 1.5 s; the azimuthal grid is kept, because hard strokes diverge without the full wavenumber ladder), Render full runs the real thing, and both report elapsed time and can be cancelled. The only piece the worker caches is the cheap linear mode table that the spectrum plot overlays.
20+Everywhere. Unlike the sibling `cymbal` app, whose expensive stage (the eigensolves) could be cached and whose synthesis was an oscillator bank, here the sound is a nonlinear time-domain integration of the whole shell, several hundred thousand steps of coupled solves, and every parameter feeds it. There is therefore no debounced auto-render: the Render preview button runs a cheap pass (no oversampling; the duration and the azimuthal grid are those set, the latter because hard strokes diverge without the full wavenumber ladder), Render full runs the real thing, and both report elapsed time and can be cancelled. The only piece the worker caches is the cheap linear mode table that the spectrum plot overlays.
2121
22-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.
22+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 a minute or two 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
2424 ## Presets
2525
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.
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". The app opens on "Paper demo", 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.
2727
2828 ## Reading and editing the code in the app
2929
3030 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.
3131
32-Edits live in memory only. They are deliberately not persisted, so a reload restores the code the app shipped with; "Revert file" and "Revert all" undo them sooner, and a dot marks the files that differ from the shipped source. A fault in edited code is reported with its file and line, the last successful render stays on screen, and reverting recovers.
32+Edits live in memory only. They are deliberately not persisted, so a reload restores the code the app shipped with; "Revert file" and "Revert all" undo them sooner, and a dot marks the files that differ from the shipped source. A fault in edited code stops the render where it occurred and is reported with its file and line; nothing is left playable, since the audio on hand would no longer be what the code on screen produces, and reverting recovers. Two cases are worth naming because numbl itself does not treat them as fatal: a file that does not parse is dropped with a warning rather than aborting the run, so the worker parses every source before starting a render and stops there if one of the files the app executes fails; and a numbl error raised while the GPU path is setting up is reported as the fault it is, rather than falling back to the interpreter to fail again slowly.
webapp/src/hooks/useRenderer.tsmodified+5−1View file
@@ -1,6 +1,6 @@
11 // Hook owning the render worker. Unlike the sibling cymbal app there is no
22 // debounced auto-preview: a cymbal2 render is a full time-domain integration
3-// and takes from tens of seconds (preview) to minutes (full quality) in the
3+// and takes from under a minute (preview) to minutes (full quality) in the
44 // browser, so renders are explicit. A request in flight can be cancelled,
55 // which tears the worker down and builds a fresh one.
66
@@ -48,8 +48,12 @@ export function useRenderer(params: Cymbal2Params, mfiles: MFile[]) {
4848 }
4949 inFlight.current = null;
5050 if (msg.type === 'error') {
51+ // A failed render leaves nothing playable: the previous result is
52+ // dropped, so Play and Download cannot hand back audio that no
53+ // longer corresponds to the code or parameters on screen.
5154 setState((s) => ({
5255 ...s,
56+ result: null,
5357 error: { message: msg.message, file: msg.file, line: msg.line },
5458 busy: false,
5559 stage: '',
webapp/src/params.tsmodified+21−14View file
@@ -70,6 +70,10 @@ export interface Cymbal2Params {
7070 bit_depth: number;
7171 }
7272
73+// The defaults are the paper-demo configuration: cymbal2_defaults.m as
74+// published, whose stroke was fitted against the authors' demonstration
75+// recording. The app therefore opens on the configuration behind
76+// cymbal2-paper-demo.wav.
7377 export const DEFAULTS: Cymbal2Params = {
7478 radius_m: 0.23,
7579 rise_m: 0.04,
@@ -97,7 +101,7 @@ export const DEFAULTS: Cymbal2Params = {
97101 n_beam: 20,
98102 grip_clamped: false,
99103 grip_spring: 140.0,
100- strike_velocity: 4.0,
104+ strike_velocity: 5.75,
101105 strike_accel: 250.0,
102106 retract_accel: 2500.0,
103107 strike_hold_ms: 0.5,
@@ -250,10 +254,11 @@ export const PARAM_GROUPS: ParamGroup[] = [
250254 // ---- Presets ----
251255 // A preset is a patch on DEFAULTS, so switching presets replaces the whole
252256 // 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+// the defaults themselves (an empty patch), and is the configuration behind
258+// the repository's cymbal2-paper-demo.wav: the stroke fitted against the
259+// demonstration recording published by the paper's authors (band balance
260+// within about 2 dB from 100 Hz to 12 kHz, the high-frequency buildup, and
261+// the decay envelope).
257262
258263 export interface Preset {
259264 id: string;
@@ -264,16 +269,16 @@ export interface Preset {
264269
265270 export const PRESETS: Preset[] = [
266271 {
267- id: 'defaults',
268- name: 'App defaults',
269- note: 'The paper\'s measured 18-inch crash at a firm 4 m/s stroke.',
272+ id: 'paper-demo',
273+ name: 'Paper demo',
274+ 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, and the app opens on it.',
270275 patch: {},
271276 },
272277 {
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 },
278+ id: 'firm-crash',
279+ name: 'Firm crash',
280+ note: 'The same measured 18-inch shell at a firm 4 m/s stroke: it crashes, but with less of the high-frequency buildup than the paper\'s demonstration stroke.',
281+ patch: { strike_velocity: 4 },
277282 },
278283 {
279284 id: 'gentle-tap',
@@ -303,7 +308,7 @@ export const PRESETS: Preset[] = [
303308 id: 'rigid-clamp',
304309 name: 'Rigid clamp',
305310 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 },
311+ patch: { support_clamped: true },
307312 },
308313 {
309314 id: 'hard-tip',
@@ -348,7 +353,9 @@ export function matchPresetId(params: Cymbal2Params): string | null {
348353 return null;
349354 }
350355
351-const STORAGE_KEY = 'cymbal2-webapp-params-v1';
356+// Bumped when the defaults change, so a stored copy of the previous defaults
357+// does not keep overriding them on reload.
358+const STORAGE_KEY = 'cymbal2-webapp-params-v2';
352359
353360 export function loadParams(): Cymbal2Params {
354361 try {
webapp/src/worker/renderWorker.tsmodified+103−24View file
@@ -8,16 +8,18 @@
88 // the loop. Without WebGPU, or when cymbal2_sim.m has been edited in the app
99 // (the WGSL mirrors the shipped step, so edits there must fall back), the
1010 // original interpreter path runs instead. Requests carry a quality tier
11-// preview -> oversample 1, tail clamped to 1.5 s (the azimuthal grid is
12-// kept: hard strokes need the full wavenumber band to carry
13-// the cascade, and coarsening it destabilizes the integration)
11+// preview -> oversample 1, everything else as given: the render covers
12+// the full duration, so the decay can be judged, and the
13+// azimuthal grid is kept because hard strokes need the full
14+// wavenumber band to carry the cascade, and coarsening it
15+// destabilizes the integration
1416 // full -> the parameters as given
1517 // and the UI shows elapsed time, plus integration progress on the GPU path.
1618 // A cheap linear-modes stage (cymbal2_modes per wavenumber) is cached on the
1719 // geometry so the spectrum plot can overlay the mode table immediately.
1820
1921 import { executeCode, RTV, type RuntimeValue, type RuntimeTensor } from 'numbl';
20-import { hashMFiles, DEFAULT_MFILES, type MFile } from '../mfiles';
22+import { hashMFiles, DEFAULT_MFILES, EXECUTED_BY_APP, type MFile } from '../mfiles';
2123 import { MODES_KEYS, type Cymbal2Params } from '../params';
2224 import { requestGpuDevice, runGpuSim, type GpuSetup } from './gpuSim';
2325
@@ -68,7 +70,7 @@ export interface RenderResponse {
6870 export interface ProgressMessage {
6971 type: 'progress';
7072 id: number;
71- stage: 'modes' | 'integrate' | 'output';
73+ stage: 'parsing' | 'modes' | 'integrate' | 'output';
7274 frac: number;
7375 }
7476
@@ -121,13 +123,81 @@ function pLiteral(params: Cymbal2Params): string {
121123 return lines.join('\n');
122124 }
123125
126+// An error in the MATLAB source, carrying the file and line where numbl put
127+// it. Reported to the UI, which stops the render and shows nothing playable.
128+class CodeError extends Error {
129+ file?: string;
130+ line?: number;
131+ constructor(message: string, file?: string, line?: number) {
132+ super(message);
133+ this.name = 'CodeError';
134+ this.file = file;
135+ this.line = line;
136+ }
137+}
138+
139+const SKIP_WARNING = /skipping (\S+\.m) \((?:syntax error at line (\d+)|parse error)\)/;
140+
141+// numbl does not abort on a file it cannot parse: it drops that file with a
142+// warning and carries on with the rest, so a render could otherwise finish
143+// (or fail much later with a confusing 'undefined function') while the real
144+// fault is a typo in a file the stage never reached. Every numbl call runs
145+// with console.warn captured, and a dropped file becomes a CodeError. Only
146+// the files the app executes count: the CLI entry points (cymbal2_demo.m and
147+// the rest) are in the editor but play no part in a render, so a typo there
148+// must not stop one.
149+function skipWarningToError(warnings: string[]): CodeError | null {
150+ for (const w of warnings) {
151+ const hit = SKIP_WARNING.exec(w);
152+ if (!hit) continue;
153+ const file = hit[1];
154+ if (!EXECUTED_BY_APP.has(file)) continue;
155+ const line = hit[2] ? Number(hit[2]) : undefined;
156+ return new CodeError(
157+ line !== undefined
158+ ? 'syntax error; the render was stopped'
159+ : 'the file cannot be parsed; the render was stopped',
160+ file,
161+ line,
162+ );
163+ }
164+ return null;
165+}
166+
124167 function runStage(mfiles: MFile[], source: string, inputs?: Record<string, RuntimeValue>) {
125- return executeCode(
126- source,
127- { initialVariableValues: inputs, displayResults: false },
128- mfiles,
129- 'stage.m',
130- );
168+ const warnings: string[] = [];
169+ const prevWarn = console.warn;
170+ console.warn = (...args: unknown[]) => {
171+ warnings.push(args.map(String).join(' '));
172+ prevWarn(...(args as []));
173+ };
174+ try {
175+ const r = executeCode(
176+ source,
177+ { initialVariableValues: inputs, displayResults: false },
178+ mfiles,
179+ 'stage.m',
180+ );
181+ const skipped = skipWarningToError(warnings);
182+ if (skipped) throw skipped;
183+ return r;
184+ } catch (err) {
185+ // A file numbl dropped is the better explanation than whatever the run
186+ // failed on afterwards (typically a call to the missing function).
187+ const skipped = skipWarningToError(warnings);
188+ if (skipped) throw skipped;
189+ throw err;
190+ } finally {
191+ console.warn = prevWarn;
192+ }
193+}
194+
195+// Parse every .m file before any expensive stage runs, so a syntax error is
196+// reported immediately instead of after a stage (or, if the modes table is
197+// cached, after the whole integration). Executing a trivial script with the
198+// files attached is enough: numbl pre-parses the whole workspace.
199+function checkMFiles(mfiles: MFile[]): void {
200+ runStage(mfiles, '1;');
131201 }
132202
133203 function asScalar(v: RuntimeValue): number {
@@ -164,8 +234,6 @@ function poolForce(fhist: Float32Array, simRate: number): { tpool: Float32Array;
164234 return { tpool, fpool };
165235 }
166236
167-const SKIP_WARNING = /skipping (\S+\.m) \(syntax error at line (\d+)\)/;
168-
169237 self.onmessage = (ev: MessageEvent<RenderRequest>) => {
170238 const msg = ev.data;
171239 if (msg.type !== 'render') return;
@@ -178,21 +246,20 @@ self.onmessage = (ev: MessageEvent<RenderRequest>) => {
178246
179247 render(msg)
180248 .catch((err) => {
181- const e = err as { message?: string; file?: string; line?: number };
182- let m: ErrorMessage = {
249+ // runStage turns a dropped file into a CodeError itself; this catches
250+ // the same warning from anything that reached numbl another way.
251+ const e = (skipWarningToError(warnings) ?? err) as {
252+ message?: string;
253+ file?: string;
254+ line?: number;
255+ };
256+ const m: ErrorMessage = {
183257 type: 'error',
184258 id: msg.id,
185259 message: e?.message ? String(e.message) : String(err),
186260 file: typeof e?.file === 'string' ? e.file : undefined,
187261 line: typeof e?.line === 'number' ? e.line : undefined,
188262 };
189- for (const w of warnings) {
190- const hit = SKIP_WARNING.exec(w);
191- if (hit) {
192- m = { type: 'error', id: msg.id, message: 'syntax error', file: hit[1], line: Number(hit[2]) };
193- break;
194- }
195- }
196263 (self as unknown as Worker).postMessage(m);
197264 })
198265 .finally(() => {
@@ -421,10 +488,15 @@ async function render(msg: RenderRequest): Promise<void> {
421488 const params = { ...msg.params };
422489 if (quality === 'preview') {
423490 params.oversample = 1;
424- params.tail_s = Math.min(params.tail_s, 1.5);
425491 }
426492
427493 const post = (m: ProgressMessage) => (self as unknown as Worker).postMessage(m);
494+
495+ // Cheap, and it must come first: a file that does not parse stops the
496+ // render here rather than partway through a stage.
497+ post({ type: 'progress', id, stage: 'parsing', frac: 0.01 });
498+ checkMFiles(mfiles);
499+
428500 const codeKey = hashMFiles(mfiles);
429501
430502 // ---- linear modes, cheap, cached on geometry ----
@@ -471,7 +543,14 @@ end
471543 );
472544 } catch (err) {
473545 const detail = err instanceof Error ? err.message : String(err);
474- if (/diverged/.test(detail)) throw err;
546+ // An error in the MATLAB source (it carries a file, or names the
547+ // divergence guard) is a fault to report, not a reason to retry the
548+ // same code on the CPU. Only a genuine GPU problem falls back.
549+ const fromCode =
550+ err instanceof CodeError ||
551+ typeof (err as { file?: unknown }).file === 'string' ||
552+ (err as { name?: string }).name === 'RuntimeError';
553+ if (fromCode || /diverged/.test(detail)) throw err;
475554 // console.warn is intercepted during a render; use info so the reason
476555 // is visible in the devtools console.
477556 console.info(`cymbal2: WebGPU unavailable (${detail}); rendering on the CPU`);
↑↓ move↵openescclose