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")) {
60 const m = /max relative error ([0-9.e+-]+)/.exec(line);
61 assert.ok(m, line);
62 assert.ok(Number(m![1]) < 2e-4, line);
63 }
64 assert.deepEqual(errors, []);
65 });
66});
68test("the linear model computes the modes and synthesizes a strike", async () => {
69 await withPage("preset=flat-disc&duration=1", async (page, errors) => {
70 let st = await status(page);
71 assert.equal(st.design, "Flat bronze disc");
72 assert.equal(st.modes, null, "the modes are computed when Synthesize is pressed");
73 await page.evaluate(() => (window as unknown as { cymbalsim: { runLinear: () => Promise<void> } }).cymbalsim.runLinear());
74 st = await status(page);
75 assert.ok(st.modes && st.modes.count > 100, `modes ${st.modes?.count}`);
76 assert.equal(st.modes!.missing, 0);
77 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)`);
78 // A second synthesis reuses the cached modes.
79 const t0 = Date.now();
80 await page.evaluate(() => (window as unknown as { cymbalsim: { runLinear: () => Promise<void> } }).cymbalsim.runLinear());
81 assert.ok(Date.now() - t0 < 4000, "cached modes");
82 assert.ok(st.synthesized);
83 assert.equal(st.source, "linear");
84 assert.equal(st.samples, 48000);
85 assert.equal(st.frames, 120);
86 assert.ok(st.peakPressure > 0.1 && st.peakPressure < 1000, `peak ${st.peakPressure} Pa`);
87 assert.ok(st.contact && st.contact.peakForce > 10);
88 assert.deepEqual(errors, []);
89 });
90});
92test("the tutorial opens, typesets its mathematics, and its actions drive the application", async () => {
93 const shown = (page: Page) => page.evaluate(() => getComputedStyle(document.getElementById("tutorial")!).display !== "none");
94 await withPage("preset=flat-rectangle&spacing=0.016", async (page) => {
95 assert.equal(await shown(page), false, "closed by default");
96 await page.click("#tutorialBtn");
97 assert.equal(await shown(page), true, "the button opens it");
98 await page.click("#tutorial .tut-close");
99 assert.equal(await shown(page), false, "the close button closes it");
100 });
101 await withPage("preset=flat-rectangle&tutorial=modes&spacing=0.016", async (page, errors) => {
102 assert.equal(await shown(page), true);
103 const opened = await page.evaluate(() => {
104 const t = document.getElementById("tutorial")!;
105 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) };
106 });
107 assert.equal(opened.hidden, false);
108 assert.ok(opened.katex > 2, `math typeset: ${opened.katex}`);
109 assert.equal(opened.title, "Modes: the standing waves of the plate");
110 assert.ok(opened.buttons.includes("Compute the modes"));
111 // The action computes the modes; the page then shows their count.
112 await page.click("#tutorial .tut-actions button:has-text('Compute the modes')");
113 await page.waitForFunction(() => (window as unknown as { cymbalsim: { status: () => { modes: unknown } } }).cymbalsim.status().modes !== null, null, { timeout: 60000 });
114 await page.waitForFunction(() => !document.querySelector("#tutorial .tut-actions button")!.hasAttribute("disabled"), null, { timeout: 60000 });
115 const st = await status(page);
116 assert.ok(st.modes && st.modes.count > 50);
117 const text = await page.evaluate(() => document.querySelector("#tutorial .tut-live")?.textContent ?? "");
118 assert.match(text, /unknowns/);
119 assert.match(text, new RegExp(`${st.modes!.count} modes`));
120 // Showing a mode selects it in the browser and draws it.
121 await page.click("#tutorial .tut-actions button:has-text('Show mode 1')");
122 const overlay = await page.evaluate(() => document.getElementById("viewOverlay")!.textContent);
123 assert.match(overlay!, /Mode 1/);
124 // Next goes to the next page and the table of contents follows.
125 await page.click("#tutorial .tut-foot button:has-text('Next')");
126 const next = await page.evaluate(() => ({ title: document.querySelector("#tutorial h2")?.textContent, sel: (document.querySelector("#tutorial select") as HTMLSelectElement).selectedOptions[0].textContent }));
127 assert.equal(next.title, "Scaling laws");
128 assert.equal(next.sel, "Scaling laws");
129 assert.deepEqual(errors, []);
130 });
131});
133test("a loud recording warns before playing and plays once accepted", async () => {
134 await withPage("preset=flat-rectangle&duration=0.5&spacing=0.016", async (page, errors) => {
135 await page.evaluate(() => (window as unknown as { cymbalsim: { runLinear: () => Promise<void> } }).cymbalsim.runLinear());
136 // The page's App, with the private members the test pokes at.
137 type A = { gainDb: number; player: { playing: boolean }; levelMode: string; updatePeak: () => void; togglePlay: () => void; loudWarn: HTMLElement };
138 const run = (script: string) => page.evaluate((s: string) => {
139 const a = (window as unknown as { cymbalsim: { app: A } }).cymbalsim.app;
140 new Function("a", s)(a);
141 // Whether the warning is drawn, not merely whether its attribute is set.
142 return { playing: a.player.playing, warn: getComputedStyle(a.loudWarn).display !== "none", text: a.loudWarn.textContent ?? "" };
143 }, script);
144 let r = await run("");
145 assert.equal(r.warn, false, "no warning before Play");
146 // The level select and the gain slider, driven as the user would.
147 const level = (mode: string) => page.selectOption("#movieToolbar select", mode);
148 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);
149 // Normalizing the peak puts it at -1 dBFS: Play warns instead of playing.
150 await level("peak");
151 r = await run("a.togglePlay();");
152 assert.equal(r.playing, false);
153 assert.equal(r.warn, true);
154 assert.match(r.text, /peak -1\.0 dBFS/);
155 assert.match(r.text, /volume/);
156 // Cancel hides it; Play anyway plays, and the same signal replays without a prompt.
157 await page.click("#movieToolbar .loud-warn button:has-text('Cancel')");
158 r = await run("");
159 assert.equal(r.warn, false);
160 assert.equal(r.playing, false);
161 await run("a.togglePlay();");
162 await page.click("#movieToolbar .loud-warn button:has-text('Play anyway')");
163 r = await run("");
164 assert.equal(r.playing, true);
165 assert.equal(r.warn, false);
166 r = await run("a.togglePlay(); a.togglePlay();");
167 assert.equal(r.playing, true, "the accepted signal replays without a prompt");
168 assert.equal(r.warn, false);
169 // Any change brings the warning back: the level mode (loudness normalization is loud too), then the gain.
170 await run("a.togglePlay();");
171 await level("loudness");
172 r = await run("a.togglePlay();");
173 assert.equal(r.playing, false);
174 assert.equal(r.warn, true, "loudness normalization warns");
175 await page.click("#movieToolbar .loud-warn button:has-text('Play anyway')");
176 await run("a.togglePlay();");
177 await level("absolute");
178 await gain(0);
179 r = await run("a.togglePlay();");
180 assert.equal(r.playing, false);
181 assert.equal(r.warn, true, "a gain change warns again");
182 // A quiet signal never prompts.
183 await gain(-60);
184 r = await run("a.togglePlay();");
185 assert.equal(r.playing, true);
186 assert.equal(r.warn, false);
187 assert.deepEqual(errors, []);
188 });
189});
191test("the loudness warning survives the live updates of a full run", async () => {
192 await withPage("preset=flat-rectangle&mode=full&duration=1.5&spacing=0.012&fps=60", async (page, errors) => {
193 if (!(await hasGpu(page))) { console.log("no WebGPU in this Chromium; skipping"); return; }
194 type A = { player: { playing: boolean }; levelMode: string; updatePeak: () => void; togglePlay: () => void; loudWarn: HTMLElement; running: boolean };
195 const state = () => page.evaluate(() => {
196 const a = (window as unknown as { cymbalsim: { app: A } }).cymbalsim.app;
197 return { playing: a.player.playing, warn: getComputedStyle(a.loudWarn).display !== "none", running: a.running };
198 });
199 // Start the run without awaiting it, wait for some audio, then press Play with peak normalization.
200 await page.evaluate(() => { void (window as unknown as { cymbalsim: { runFull: () => Promise<void> } }).cymbalsim.runFull(); });
201 await page.waitForFunction(() => (window as unknown as { cymbalsim: { status: () => Status } }).cymbalsim.status().samples > 4800, null, { timeout: 60000 });
202 await page.evaluate(() => { const a = (window as unknown as { cymbalsim: { app: A } }).cymbalsim.app; a.levelMode = "peak"; a.updatePeak(); a.togglePlay(); });
203 let s = await state();
204 assert.equal(s.running, true, "still running");
205 assert.equal(s.warn, true);
206 assert.equal(s.playing, false);
207 // Several live updates later the warning is still there.
208 await page.waitForTimeout(500);
209 s = await state();
210 assert.equal(s.warn, true, "the warning outlives the live updates");
211 await page.click("#movieToolbar .loud-warn button:has-text('Play anyway')");
212 s = await state();
213 assert.equal(s.playing, true);
214 assert.equal(s.warn, false);
215 await page.waitForFunction(() => !(window as unknown as { cymbalsim: { status: () => Status } }).cymbalsim.status().running, null, { timeout: 120000 });
216 assert.deepEqual(errors, []);
217 });
218});
220test("a script with an error is reported", async () => {
221 await withPage("preset=ellipse", async (page) => {
222 await page.evaluate(() => {
223 const app = (window as unknown as { cymbalsim: { app: { editor: { setSource: (s: string) => void }; onSource: (s: string) => void } } }).cymbalsim.app;
224 app.editor.setSource("design.material.E = 1;");
225 (app as unknown as { onSource: (s: string) => void }).onSource("design.material.E = 1;");
226 });
227 const st = await status(page);
228 assert.match(st.editorStatus, /design.material.E/);
229 });
230});
232test("the full simulation runs a short strike on a free plate and on a stand", async () => {
233 await withPage("preset=bowed-rectangle&mode=full&duration=0.05&spacing=0.012", async (page, errors) => {
234 if (!(await hasGpu(page))) { console.log("no WebGPU in this Chromium; skipping"); return; }
235 await page.evaluate(() => (window as unknown as { cymbalsim: { runFull: () => Promise<void> } }).cymbalsim.runFull());
236 const st = await status(page);
237 assert.equal(st.source, "full");
238 assert.ok(st.sim && st.sim.phase === "done");
239 assert.equal(st.samples, 2400);
240 assert.ok(st.sim!.stick.maxForce > 10, `force ${st.sim!.stick.maxForce}`);
241 assert.ok(st.sim!.wmax > 1e-5 && st.sim!.wmax < 0.05, `wmax ${st.sim!.wmax}`);
242 assert.ok(st.peakPressure > 0.01 && isFinite(st.peakPressure));
243 assert.deepEqual(errors, []);
244 });
245 await withPage("preset=crash-16&mode=full&duration=0.05&spacing=0.012", async (page, errors) => {
246 if (!(await hasGpu(page))) return;
247 await page.evaluate(() => (window as unknown as { cymbalsim: { runFull: () => Promise<void> } }).cymbalsim.runFull());
248 const st = await status(page);
249 assert.ok(st.sim && st.sim.phase === "done");
250 assert.ok(st.sim!.stick.maxForce > 10);
251 assert.ok(isFinite(st.peakPressure) && st.peakPressure > 0.01);
252 assert.deepEqual(errors, []);
253 });
254});
256test("a light hit sounds the same in the linear and the full model", async () => {
257 await withPage("preset=flat-disc&duration=0.25&strikeVelocity=0.05&spacing=0.012&fps=60", async (page, errors) => {
258 if (!(await hasGpu(page))) { console.log("no WebGPU in this Chromium; skipping"); return; }
259 const hooks = "(window as unknown as { cymbalsim: any }).cymbalsim";
260 await page.evaluate(() => (window as unknown as { cymbalsim: { runLinear: () => Promise<void> } }).cymbalsim.runLinear());
261 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)); });
262 await page.evaluate(() => (window as unknown as { cymbalsim: { runFull: () => Promise<void> } }).cymbalsim.runFull());
263 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)); });
264 void hooks;
265 assert.equal(linear.length, full.length);
266 // Compare the spectra over 50 to 250 ms (after the attack, which the delay interpolation renders slightly differently).
267 const fs = 48000, n0 = Math.round(0.05 * fs), n1 = Math.round(0.25 * fs);
268 const N = 8192;
269 const spec = (x: number[]) => {
270 const re = new Float64Array(N), im = new Float64Array(N);
271 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));
272 // naive DFT on a subset of bins is too slow; use a simple radix-2 FFT here
273 fftInPlace(re, im);
274 const mag = new Float64Array(N / 2);
275 for (let k = 0; k < N / 2; k++) mag[k] = Math.hypot(re[k], im[k]);
276 return mag;
277 };
278 const a = spec(linear), b = spec(full);
279 // Normalized correlation of the magnitude spectra below 6 kHz.
280 const kmax = Math.round((6000 / fs) * N);
281 let ab = 0, aa = 0, bb = 0;
282 for (let k = 1; k < kmax; k++) { ab += a[k] * b[k]; aa += a[k] * a[k]; bb += b[k] * b[k]; }
283 const corr = ab / Math.sqrt(aa * bb);
284 // Level agreement: RMS over the window.
285 const rms = (x: number[]) => Math.sqrt(x.slice(n0, n1).reduce((s, v) => s + v * v, 0) / (n1 - n0));
286 const ratio = rms(linear) / rms(full);
287 console.log(`spectral correlation ${corr.toFixed(3)}, rms ratio linear/full ${ratio.toFixed(3)}`);
288 assert.ok(corr > 0.9, `spectral correlation ${corr}`);
289 assert.ok(ratio > 0.7 && ratio < 1.4, `rms ratio ${ratio}`);
290 assert.deepEqual(errors, []);
291 });
292});
294function fftInPlace(re: Float64Array, im: Float64Array): void {
295 const n = re.length;
296 for (let i = 1, j = 0; i < n; i++) {
297 let bit = n >> 1;
298 for (; j & bit; bit >>= 1) j ^= bit;
299 j ^= bit;
300 if (i < j) { [re[i], re[j]] = [re[j], re[i]]; [im[i], im[j]] = [im[j], im[i]]; }
301 }
302 for (let len = 2; len <= n; len <<= 1) {
303 const ang = (-2 * Math.PI) / len;
304 for (let i = 0; i < n; i += len) {
305 for (let k = 0; k < len / 2; k++) {
306 const c = Math.cos(ang * k), s = Math.sin(ang * k);
307 const ur = re[i + k], ui = im[i + k];
308 const vr = re[i + k + len / 2] * c - im[i + k + len / 2] * s;
309 const vi = re[i + k + len / 2] * s + im[i + k + len / 2] * c;
310 re[i + k] = ur + vr; im[i + k] = ui + vi;
311 re[i + k + len / 2] = ur - vr; im[i + k + len / 2] = ui - vi;
312 }
313 }
314 }
315}