5778036cymbalsim: scripted plate and cymbal designer with a modal linear model and a von Karman GPU modelJeremy Magland 1// Browser tests: serve the app with Vite's dev server, open it in a headless
2// Chromium with WebGPU through Playwright, and drive it through the hooks
3// on window.cymbalsim: the in-page self-test of the WGSL kernel against the
4// float64 reference, a linear synthesis, a short full run, and a light hit
5// simulated both ways, whose signals must agree.
7import { test } from "node:test";
8import assert from "node:assert/strict";
9import { fileURLToPath } from "node:url";
10import { dirname, join } from "node:path";
11import { createServer } from "vite";
12import { chromium, type Page } from "playwright";
14const root = join(dirname(fileURLToPath(import.meta.url)), "..");
15const ARGS = ["--enable-unsafe-webgpu", "--enable-features=Vulkan", "--use-angle=vulkan", "--ignore-gpu-blocklist", "--no-sandbox"];
17interface Status {
18 design: string | null;
19 modes: { count: number; missing: number; first: number[] } | null;
20 synthesized: boolean;
21 running: boolean;
22 samples: number;
23 frames: number;
24 source: string | null;
25 peakPressure: number;
26 sim: { phase: string; t: number; totalSteps: number; stick: { maxForce: number; withdrawn: boolean }; wmax: number } | null;
27 contact: { peakForce: number; contactTime: number } | null;
28 editorStatus: string;
29}
31async function withPage(query: string, body: (page: Page, errors: string[]) => Promise<void>): Promise<void> {
32 const server = await createServer({ root, logLevel: "silent", server: { port: 0, strictPort: false } });
33 await server.listen();
34 const url = server.resolvedUrls?.local[0];
35 assert.ok(url, "dev server URL");
36 const browser = await chromium.launch({ headless: true, args: ARGS });
37 try {
38 const page = await browser.newPage();
39 const errors: string[] = [];
40 page.on("pageerror", (e) => errors.push(e.message));
41 page.on("console", (msg) => { if (msg.type() === "error") errors.push(`console.error: ${msg.text()}`); });
42 await page.goto(`${url}?${query}`);
43 await page.waitForFunction(() => (window as unknown as { cymbalsim?: unknown }).cymbalsim !== undefined, null, { timeout: 30000 });
44 await body(page, errors);
45 } finally {
46 await browser.close();
47 await server.close();
48 }
49}
51const status = (page: Page) => page.evaluate(() => (window as unknown as { cymbalsim: { status: () => Status } }).cymbalsim.status());
52const hasGpu = (page: Page) => page.evaluate(() => (window as unknown as { cymbalsim: { app: { device: unknown } } }).cymbalsim.app.device !== null);
54test("the WGSL step agrees with the float64 reference on several presets", async () => {
55 await withPage("preset=flat-rectangle&nogpuview=0", async (page, errors) => {
56 if (!(await hasGpu(page))) { console.log("no WebGPU in this Chromium; skipping"); return; }
57 const text = await page.evaluate(() => (window as unknown as { cymbalsim: { selftest: () => Promise<string> } }).cymbalsim.selftest());
58 console.log(text);
59 for (const line of text.split("\n")) {
18b1b86Model the felt as a Maxwell chain so that the full model has the loss factor of the linear oneJeremy Magland 60 const m = /max relative error ([0-9.e+-]+), felt state error ([0-9.e+-]+)/.exec(line);
5778036cymbalsim: scripted plate and cymbal designer with a modal linear model and a von Karman GPU modelJeremy Magland 61 assert.ok(m, line);
62 assert.ok(Number(m![1]) < 2e-4, line);
18b1b86Model the felt as a Maxwell chain so that the full model has the loss factor of the linear oneJeremy Magland 63 assert.ok(Number(m![2]) < 1e-5, line);
5778036cymbalsim: scripted plate and cymbal designer with a modal linear model and a von Karman GPU modelJeremy Magland 64 }
65 assert.deepEqual(errors, []);
66 });
67});
69test("the linear model computes the modes and synthesizes a strike", async () => {
70 await withPage("preset=flat-disc&duration=1", async (page, errors) => {
71 let st = await status(page);
72 assert.equal(st.design, "Flat bronze disc");
9cfbfd1Modes on demand with a cache, progress bar in the head, play rewinds from live or the end, exaggeration 1Jeremy Magland 73 assert.equal(st.modes, null, "the modes are computed when Synthesize is pressed");
74 await page.evaluate(() => (window as unknown as { cymbalsim: { runLinear: () => Promise<void> } }).cymbalsim.runLinear());
75 st = await status(page);
5778036cymbalsim: scripted plate and cymbal designer with a modal linear model and a von Karman GPU modelJeremy Magland 76 assert.ok(st.modes && st.modes.count > 100, `modes ${st.modes?.count}`);
77 assert.equal(st.modes!.missing, 0);
78 assert.ok(st.modes!.first[0] > 1 && st.modes!.first[0] < 200, `first mode ${st.modes!.first[0]} Hz (the rock or the bounce on the stand)`);
9cfbfd1Modes on demand with a cache, progress bar in the head, play rewinds from live or the end, exaggeration 1Jeremy Magland 79 // A second synthesis reuses the cached modes.
80 const t0 = Date.now();
5778036cymbalsim: scripted plate and cymbal designer with a modal linear model and a von Karman GPU modelJeremy Magland 81 await page.evaluate(() => (window as unknown as { cymbalsim: { runLinear: () => Promise<void> } }).cymbalsim.runLinear());
9cfbfd1Modes on demand with a cache, progress bar in the head, play rewinds from live or the end, exaggeration 1Jeremy Magland 82 assert.ok(Date.now() - t0 < 4000, "cached modes");
5778036cymbalsim: scripted plate and cymbal designer with a modal linear model and a von Karman GPU modelJeremy Magland 83 assert.ok(st.synthesized);
84 assert.equal(st.source, "linear");
85 assert.equal(st.samples, 48000);
86 assert.equal(st.frames, 120);
87 assert.ok(st.peakPressure > 0.1 && st.peakPressure < 1000, `peak ${st.peakPressure} Pa`);
88 assert.ok(st.contact && st.contact.peakForce > 10);
89 assert.deepEqual(errors, []);
90 });
91});
351af76Interactive tutorial on the physics and the mathematics of the simulatorJeremy Magland 93test("the tutorial opens, typesets its mathematics, and its actions drive the application", async () => {
686a6d1Make the hidden attribute win over display rulesJeremy Magland 94 const shown = (page: Page) => page.evaluate(() => getComputedStyle(document.getElementById("tutorial")!).display !== "none");
95 await withPage("preset=flat-rectangle&spacing=0.016", async (page) => {
96 assert.equal(await shown(page), false, "closed by default");
97 await page.click("#tutorialBtn");
98 assert.equal(await shown(page), true, "the button opens it");
99 await page.click("#tutorial .tut-close");
100 assert.equal(await shown(page), false, "the close button closes it");
101 });
351af76Interactive tutorial on the physics and the mathematics of the simulatorJeremy Magland 102 await withPage("preset=flat-rectangle&tutorial=modes&spacing=0.016", async (page, errors) => {
686a6d1Make the hidden attribute win over display rulesJeremy Magland 103 assert.equal(await shown(page), true);
351af76Interactive tutorial on the physics and the mathematics of the simulatorJeremy Magland 104 const opened = await page.evaluate(() => {
105 const t = document.getElementById("tutorial")!;
106 return { hidden: t.hidden, katex: t.querySelectorAll(".katex").length, title: t.querySelector("h2")?.textContent ?? "", buttons: Array.from(t.querySelectorAll(".tut-actions button")).map((b) => b.textContent) };
107 });
108 assert.equal(opened.hidden, false);
109 assert.ok(opened.katex > 2, `math typeset: ${opened.katex}`);
110 assert.equal(opened.title, "Modes: the standing waves of the plate");
111 assert.ok(opened.buttons.includes("Compute the modes"));
112 // The action computes the modes; the page then shows their count.
113 await page.click("#tutorial .tut-actions button:has-text('Compute the modes')");
114 await page.waitForFunction(() => (window as unknown as { cymbalsim: { status: () => { modes: unknown } } }).cymbalsim.status().modes !== null, null, { timeout: 60000 });
115 await page.waitForFunction(() => !document.querySelector("#tutorial .tut-actions button")!.hasAttribute("disabled"), null, { timeout: 60000 });
116 const st = await status(page);
117 assert.ok(st.modes && st.modes.count > 50);
118 const text = await page.evaluate(() => document.querySelector("#tutorial .tut-live")?.textContent ?? "");
119 assert.match(text, /unknowns/);
120 assert.match(text, new RegExp(`${st.modes!.count} modes`));
121 // Showing a mode selects it in the browser and draws it.
122 await page.click("#tutorial .tut-actions button:has-text('Show mode 1')");
123 const overlay = await page.evaluate(() => document.getElementById("viewOverlay")!.textContent);
124 assert.match(overlay!, /Mode 1/);
125 // Next goes to the next page and the table of contents follows.
126 await page.click("#tutorial .tut-foot button:has-text('Next')");
127 const next = await page.evaluate(() => ({ title: document.querySelector("#tutorial h2")?.textContent, sel: (document.querySelector("#tutorial select") as HTMLSelectElement).selectedOptions[0].textContent }));
128 assert.equal(next.title, "Scaling laws");
129 assert.equal(next.sel, "Scaling laws");
130 assert.deepEqual(errors, []);
131 });
132});
c99922dWarn before playing a recording that peaks near full scaleJeremy Magland 134test("a loud recording warns before playing and plays once accepted", async () => {
135 await withPage("preset=flat-rectangle&duration=0.5&spacing=0.016", async (page, errors) => {
136 await page.evaluate(() => (window as unknown as { cymbalsim: { runLinear: () => Promise<void> } }).cymbalsim.runLinear());
137 // The page's App, with the private members the test pokes at.
138 type A = { gainDb: number; player: { playing: boolean }; levelMode: string; updatePeak: () => void; togglePlay: () => void; loudWarn: HTMLElement };
139 const run = (script: string) => page.evaluate((s: string) => {
140 const a = (window as unknown as { cymbalsim: { app: A } }).cymbalsim.app;
141 new Function("a", s)(a);
686a6d1Make the hidden attribute win over display rulesJeremy Magland 142 // Whether the warning is drawn, not merely whether its attribute is set.
143 return { playing: a.player.playing, warn: getComputedStyle(a.loudWarn).display !== "none", text: a.loudWarn.textContent ?? "" };
146 assert.equal(r.warn, false, "no warning before Play");
30fcba1Keep the loudness warning through the live updates of a full runJeremy Magland 147 // The level select and the gain slider, driven as the user would.
148 const level = (mode: string) => page.selectOption("#movieToolbar select", mode);
149 const gain = (db: number) => page.evaluate((v: number) => { const g = document.querySelector("#movieToolbar input[type=range]:not(.scrub)") as HTMLInputElement; g.value = String(v); g.dispatchEvent(new Event("input")); }, db);
c99922dWarn before playing a recording that peaks near full scaleJeremy Magland 150 // Normalizing the peak puts it at -1 dBFS: Play warns instead of playing.
30fcba1Keep the loudness warning through the live updates of a full runJeremy Magland 151 await level("peak");
152 r = await run("a.togglePlay();");
c99922dWarn before playing a recording that peaks near full scaleJeremy Magland 153 assert.equal(r.playing, false);
154 assert.equal(r.warn, true);
013f20eLoudness warning applies to the signal about to be played and returns on any changeJeremy Magland 155 assert.match(r.text, /peak -1\.0 dBFS/);
c99922dWarn before playing a recording that peaks near full scaleJeremy Magland 156 assert.match(r.text, /volume/);
013f20eLoudness warning applies to the signal about to be played and returns on any changeJeremy Magland 157 // Cancel hides it; Play anyway plays, and the same signal replays without a prompt.
c99922dWarn before playing a recording that peaks near full scaleJeremy Magland 158 await page.click("#movieToolbar .loud-warn button:has-text('Cancel')");
159 r = await run("");
160 assert.equal(r.warn, false);
161 assert.equal(r.playing, false);
162 await run("a.togglePlay();");
163 await page.click("#movieToolbar .loud-warn button:has-text('Play anyway')");
164 r = await run("");
165 assert.equal(r.playing, true);
166 assert.equal(r.warn, false);
167 r = await run("a.togglePlay(); a.togglePlay();");
013f20eLoudness warning applies to the signal about to be played and returns on any changeJeremy Magland 168 assert.equal(r.playing, true, "the accepted signal replays without a prompt");
c99922dWarn before playing a recording that peaks near full scaleJeremy Magland 169 assert.equal(r.warn, false);
013f20eLoudness warning applies to the signal about to be played and returns on any changeJeremy Magland 170 // Any change brings the warning back: the level mode (loudness normalization is loud too), then the gain.
30fcba1Keep the loudness warning through the live updates of a full runJeremy Magland 171 await run("a.togglePlay();");
172 await level("loudness");
173 r = await run("a.togglePlay();");
013f20eLoudness warning applies to the signal about to be played and returns on any changeJeremy Magland 174 assert.equal(r.playing, false);
175 assert.equal(r.warn, true, "loudness normalization warns");
176 await page.click("#movieToolbar .loud-warn button:has-text('Play anyway')");
30fcba1Keep the loudness warning through the live updates of a full runJeremy Magland 177 await run("a.togglePlay();");
178 await level("absolute");
179 await gain(0);
180 r = await run("a.togglePlay();");
013f20eLoudness warning applies to the signal about to be played and returns on any changeJeremy Magland 181 assert.equal(r.playing, false);
182 assert.equal(r.warn, true, "a gain change warns again");
c99922dWarn before playing a recording that peaks near full scaleJeremy Magland 183 // A quiet signal never prompts.
30fcba1Keep the loudness warning through the live updates of a full runJeremy Magland 184 await gain(-60);
185 r = await run("a.togglePlay();");
c99922dWarn before playing a recording that peaks near full scaleJeremy Magland 186 assert.equal(r.playing, true);
187 assert.equal(r.warn, false);
188 assert.deepEqual(errors, []);
189 });
190});
30fcba1Keep the loudness warning through the live updates of a full runJeremy Magland 192test("the loudness warning survives the live updates of a full run", async () => {
193 await withPage("preset=flat-rectangle&mode=full&duration=1.5&spacing=0.012&fps=60", async (page, errors) => {
194 if (!(await hasGpu(page))) { console.log("no WebGPU in this Chromium; skipping"); return; }
195 type A = { player: { playing: boolean }; levelMode: string; updatePeak: () => void; togglePlay: () => void; loudWarn: HTMLElement; running: boolean };
196 const state = () => page.evaluate(() => {
197 const a = (window as unknown as { cymbalsim: { app: A } }).cymbalsim.app;
198 return { playing: a.player.playing, warn: getComputedStyle(a.loudWarn).display !== "none", running: a.running };
199 });
200 // Start the run without awaiting it, wait for some audio, then press Play with peak normalization.
201 await page.evaluate(() => { void (window as unknown as { cymbalsim: { runFull: () => Promise<void> } }).cymbalsim.runFull(); });
202 await page.waitForFunction(() => (window as unknown as { cymbalsim: { status: () => Status } }).cymbalsim.status().samples > 4800, null, { timeout: 60000 });
203 await page.evaluate(() => { const a = (window as unknown as { cymbalsim: { app: A } }).cymbalsim.app; a.levelMode = "peak"; a.updatePeak(); a.togglePlay(); });
204 let s = await state();
205 assert.equal(s.running, true, "still running");
206 assert.equal(s.warn, true);
207 assert.equal(s.playing, false);
208 // Several live updates later the warning is still there.
209 await page.waitForTimeout(500);
210 s = await state();
211 assert.equal(s.warn, true, "the warning outlives the live updates");
212 await page.click("#movieToolbar .loud-warn button:has-text('Play anyway')");
213 s = await state();
214 assert.equal(s.playing, true);
215 assert.equal(s.warn, false);
216 await page.waitForFunction(() => !(window as unknown as { cymbalsim: { status: () => Status } }).cymbalsim.status().running, null, { timeout: 120000 });
217 assert.deepEqual(errors, []);
218 });
219});
5778036cymbalsim: scripted plate and cymbal designer with a modal linear model and a von Karman GPU modelJeremy Magland 221test("a script with an error is reported", async () => {
222 await withPage("preset=ellipse", async (page) => {
223 await page.evaluate(() => {
224 const app = (window as unknown as { cymbalsim: { app: { editor: { setSource: (s: string) => void }; onSource: (s: string) => void } } }).cymbalsim.app;
225 app.editor.setSource("design.material.E = 1;");
226 (app as unknown as { onSource: (s: string) => void }).onSource("design.material.E = 1;");
227 });
228 const st = await status(page);
229 assert.match(st.editorStatus, /design.material.E/);
230 });
231});
233test("the full simulation runs a short strike on a free plate and on a stand", async () => {
234 await withPage("preset=bowed-rectangle&mode=full&duration=0.05&spacing=0.012", async (page, errors) => {
235 if (!(await hasGpu(page))) { console.log("no WebGPU in this Chromium; skipping"); return; }
236 await page.evaluate(() => (window as unknown as { cymbalsim: { runFull: () => Promise<void> } }).cymbalsim.runFull());
237 const st = await status(page);
238 assert.equal(st.source, "full");
239 assert.ok(st.sim && st.sim.phase === "done");
240 assert.equal(st.samples, 2400);
241 assert.ok(st.sim!.stick.maxForce > 10, `force ${st.sim!.stick.maxForce}`);
242 assert.ok(st.sim!.wmax > 1e-5 && st.sim!.wmax < 0.05, `wmax ${st.sim!.wmax}`);
243 assert.ok(st.peakPressure > 0.01 && isFinite(st.peakPressure));
244 assert.deepEqual(errors, []);
245 });
246 await withPage("preset=crash-16&mode=full&duration=0.05&spacing=0.012", async (page, errors) => {
247 if (!(await hasGpu(page))) return;
248 await page.evaluate(() => (window as unknown as { cymbalsim: { runFull: () => Promise<void> } }).cymbalsim.runFull());
249 const st = await status(page);
250 assert.ok(st.sim && st.sim.phase === "done");
251 assert.ok(st.sim!.stick.maxForce > 10);
252 assert.ok(isFinite(st.peakPressure) && st.peakPressure > 0.01);
253 assert.deepEqual(errors, []);
254 });
255});
540490cForm the Marguerre strains from the deviation, removing a noise floor on curved designsJeremy Magland 257test("a curved design leaves no high-frequency noise floor in the full model", async () => {
258 // The Marguerre strains must be formed so that every term carries the
259 // deviation from the stress-free shape. The direct form 0.5 (wx^2 - w0x^2)
260 // cancels two nearly equal numbers, and in the single precision of the GPU
261 // its rounding error is proportional to w0x^2, so it does not fall with the
262 // vibration: the top octave then sits at a constant level for the whole run
263 // instead of decaying. A light hit is used, so that the true high-frequency
264 // content is small and any floor dominates.
265 await withPage("preset=crash-bell-16&mode=full&duration=0.5&spacing=0.016&strikeVelocity=0.05&fps=60", async (page, errors) => {
266 if (!(await hasGpu(page))) { console.log("no WebGPU in this Chromium; skipping"); return; }
267 await page.evaluate(() => (window as unknown as { cymbalsim: { runFull: () => Promise<void> } }).cymbalsim.runFull());
268 const audio = await page.evaluate(() => { const r = (window as unknown as { cymbalsim: { app: { rec: { audio: Float32Array; samples: number } } } }).cymbalsim.app.rec; return Array.from(r.audio.subarray(0, r.samples)); });
269 const fs = 48000, N = 4096;
270 const peak = audio.reduce((m, v) => Math.max(m, Math.abs(v)), 0);
271 assert.ok(peak > 1e-4, `peak ${peak} Pa`);
272 // RMS pressure in the top octave over the last window of the run.
273 const start = audio.length - N;
274 const re = new Float64Array(N), im = new Float64Array(N);
275 let wsum = 0;
276 for (let i = 0; i < N; i++) { const w = 0.5 - 0.5 * Math.cos((2 * Math.PI * i) / N); re[i] = audio[start + i] * w; wsum += w * w; }
277 fftInPlace(re, im);
278 let e = 0;
279 for (let k = Math.round((12000 / fs) * N); k < N / 2; k++) e += 2 * (re[k] * re[k] + im[k] * im[k]);
280 const tailDb = 20 * Math.log10(Math.sqrt(e / (N * wsum)) / peak);
281 console.log(`curved design: top octave of the tail ${tailDb.toFixed(1)} dB below the peak`);
282 // With the cancellation the floor sits about 25 dB below the peak; without
283 // it, below 60 dB. The bound is loose enough for any GPU's rounding.
284 assert.ok(tailDb < -50, `top octave of the tail only ${tailDb.toFixed(1)} dB below the peak: a noise floor that does not decay`);
285 assert.deepEqual(errors, []);
286 });
287});
5778036cymbalsim: scripted plate and cymbal designer with a modal linear model and a von Karman GPU modelJeremy Magland 289test("a light hit sounds the same in the linear and the full model", async () => {
290 await withPage("preset=flat-disc&duration=0.25&strikeVelocity=0.05&spacing=0.012&fps=60", async (page, errors) => {
291 if (!(await hasGpu(page))) { console.log("no WebGPU in this Chromium; skipping"); return; }
292 const hooks = "(window as unknown as { cymbalsim: any }).cymbalsim";
293 await page.evaluate(() => (window as unknown as { cymbalsim: { runLinear: () => Promise<void> } }).cymbalsim.runLinear());
294 const linear = await page.evaluate(() => { const a = (window as unknown as { cymbalsim: { app: { rec: { audio: Float32Array; samples: number } } } }).cymbalsim.app; return Array.from(a.rec.audio.subarray(0, a.rec.samples)); });
295 await page.evaluate(() => (window as unknown as { cymbalsim: { runFull: () => Promise<void> } }).cymbalsim.runFull());
296 const full = await page.evaluate(() => { const a = (window as unknown as { cymbalsim: { app: { rec: { audio: Float32Array; samples: number } } } }).cymbalsim.app; return Array.from(a.rec.audio.subarray(0, a.rec.samples)); });
297 void hooks;
298 assert.equal(linear.length, full.length);
299 // Compare the spectra over 50 to 250 ms (after the attack, which the delay interpolation renders slightly differently).
300 const fs = 48000, n0 = Math.round(0.05 * fs), n1 = Math.round(0.25 * fs);
301 const N = 8192;
302 const spec = (x: number[]) => {
303 const re = new Float64Array(N), im = new Float64Array(N);
304 for (let i = 0; i < N && n0 + i < n1; i++) re[i] = x[n0 + i] * (0.5 - 0.5 * Math.cos((2 * Math.PI * i) / N));
305 // naive DFT on a subset of bins is too slow; use a simple radix-2 FFT here
306 fftInPlace(re, im);
307 const mag = new Float64Array(N / 2);
308 for (let k = 0; k < N / 2; k++) mag[k] = Math.hypot(re[k], im[k]);
309 return mag;
310 };
311 const a = spec(linear), b = spec(full);
312 // Normalized correlation of the magnitude spectra below 6 kHz.
313 const kmax = Math.round((6000 / fs) * N);
314 let ab = 0, aa = 0, bb = 0;
315 for (let k = 1; k < kmax; k++) { ab += a[k] * b[k]; aa += a[k] * a[k]; bb += b[k] * b[k]; }
316 const corr = ab / Math.sqrt(aa * bb);
317 // Level agreement: RMS over the window.
318 const rms = (x: number[]) => Math.sqrt(x.slice(n0, n1).reduce((s, v) => s + v * v, 0) / (n1 - n0));
319 const ratio = rms(linear) / rms(full);
320 console.log(`spectral correlation ${corr.toFixed(3)}, rms ratio linear/full ${ratio.toFixed(3)}`);
321 assert.ok(corr > 0.9, `spectral correlation ${corr}`);
322 assert.ok(ratio > 0.7 && ratio < 1.4, `rms ratio ${ratio}`);
323 assert.deepEqual(errors, []);
324 });
325});
18b1b86Model the felt as a Maxwell chain so that the full model has the loss factor of the linear oneJeremy Magland 327test("the felt damps a light hit on the aluminum square alike in the linear and the full model", async () => {
328 // The square on the default stand puts a share of many modes' energy into the felt.
329 // What the stand adds to the decay of each band, measured as the band's drop between
330 // 0.25 and 0.55 s with the stand on minus the same drop with the stand off, must agree
331 // between the models: the difference cancels the fitted plate loss of the full model
332 // against the per-mode loss of the linear one, and leaves the felt. (The first 0.25 s
333 // are left out: the modes that hold a large share of their stiffness in the felt die
334 // within it, and the linear model's first-order rates over-estimate their damping.)
335 const fs = 48000, N = 8192;
336 const bands: [number, number][] = [[50, 200], [200, 600], [600, 2000], [2000, 6000]];
337 const bandEnergy = (x: number[], t0: number, lo: number, hi: number) => {
338 const n0 = Math.round(t0 * fs);
339 const re = new Float64Array(N), im = new Float64Array(N);
340 for (let i = 0; i < N; i++) re[i] = x[n0 + i] * (0.5 - 0.5 * Math.cos((2 * Math.PI * i) / N));
341 fftInPlace(re, im);
342 let e = 0;
343 for (let k = Math.round((lo / fs) * N); k < Math.round((hi / fs) * N); k++) e += re[k] * re[k] + im[k] * im[k];
344 return e;
345 };
346 const drops = (x: number[]) => bands.map(([lo, hi]) => 10 * Math.log10(bandEnergy(x, 0.25, lo, hi) / bandEnergy(x, 0.55, lo, hi)));
347 const result: Record<string, { linear: number[]; full: number[] }> = {};
348 let skipped = false;
349 for (const stand of ["1", "0"]) {
350 await withPage(`preset=aluminum-square&duration=0.8&strikeVelocity=0.05&spacing=0.012&fps=60&standOn=${stand}`, async (page, errors) => {
351 if (!(await hasGpu(page))) { console.log("no WebGPU in this Chromium; skipping"); skipped = true; return; }
352 const grab = () => page.evaluate(() => { const a = (window as unknown as { cymbalsim: { app: { rec: { audio: Float32Array; samples: number } } } }).cymbalsim.app; return Array.from(a.rec.audio.subarray(0, a.rec.samples)); });
353 await page.evaluate(() => (window as unknown as { cymbalsim: { runLinear: () => Promise<void> } }).cymbalsim.runLinear());
354 const linear = await grab();
355 await page.evaluate(() => (window as unknown as { cymbalsim: { runFull: () => Promise<void> } }).cymbalsim.runFull());
356 const full = await grab();
357 assert.equal(linear.length, full.length);
358 result[stand] = { linear: drops(linear), full: drops(full) };
359 assert.deepEqual(errors, []);
360 });
361 if (skipped) return;
362 }
363 bands.forEach(([lo, hi], b) => {
364 const dl = result["1"].linear[b] - result["0"].linear[b], df = result["1"].full[b] - result["0"].full[b];
365 console.log(`${lo} to ${hi} Hz: decay over 0.3 s added by the stand, linear ${dl.toFixed(1)} dB (${result["0"].linear[b].toFixed(1)} free), full ${df.toFixed(1)} dB (${result["0"].full[b].toFixed(1)} free)`);
366 assert.ok(Math.abs(dl - df) < 4, `${lo} to ${hi} Hz: the stand adds ${dl} dB in the linear model and ${df} dB in the full model`);
367 });
368});
5778036cymbalsim: scripted plate and cymbal designer with a modal linear model and a von Karman GPU modelJeremy Magland 370function fftInPlace(re: Float64Array, im: Float64Array): void {
371 const n = re.length;
372 for (let i = 1, j = 0; i < n; i++) {
373 let bit = n >> 1;
374 for (; j & bit; bit >>= 1) j ^= bit;
375 j ^= bit;
376 if (i < j) { [re[i], re[j]] = [re[j], re[i]]; [im[i], im[j]] = [im[j], im[i]]; }
377 }
378 for (let len = 2; len <= n; len <<= 1) {
379 const ang = (-2 * Math.PI) / len;
380 for (let i = 0; i < n; i += len) {
381 for (let k = 0; k < len / 2; k++) {
382 const c = Math.cos(ang * k), s = Math.sin(ang * k);
383 const ur = re[i + k], ui = im[i + k];
384 const vr = re[i + k + len / 2] * c - im[i + k + len / 2] * s;
385 const vi = re[i + k + len / 2] * s + im[i + k + len / 2] * c;
386 re[i + k] = ur + vr; im[i + k] = ui + vi;
387 re[i + k + len / 2] = ur - vr; im[i + k + len / 2] = ui - vi;
388 }
389 }
390 }
391}