// Browser tests: serve the app with Vite's dev server, open it in a headless // Chromium with WebGPU through Playwright, and drive it through the hooks // on window.cymbalsim: the in-page self-test of the WGSL kernel against the // float64 reference, a linear synthesis, a short full run, and a light hit // simulated both ways, whose signals must agree. import { test } from "node:test"; import assert from "node:assert/strict"; import { fileURLToPath } from "node:url"; import { dirname, join } from "node:path"; import { createServer } from "vite"; import { chromium, type Page } from "playwright"; const root = join(dirname(fileURLToPath(import.meta.url)), ".."); const ARGS = ["--enable-unsafe-webgpu", "--enable-features=Vulkan", "--use-angle=vulkan", "--ignore-gpu-blocklist", "--no-sandbox"]; interface Status { design: string | null; modes: { count: number; missing: number; first: number[] } | null; synthesized: boolean; running: boolean; samples: number; frames: number; source: string | null; peakPressure: number; sim: { phase: string; t: number; totalSteps: number; stick: { maxForce: number; withdrawn: boolean }; wmax: number } | null; contact: { peakForce: number; contactTime: number } | null; editorStatus: string; } async function withPage(query: string, body: (page: Page, errors: string[]) => Promise): Promise { const server = await createServer({ root, logLevel: "silent", server: { port: 0, strictPort: false } }); await server.listen(); const url = server.resolvedUrls?.local[0]; assert.ok(url, "dev server URL"); const browser = await chromium.launch({ headless: true, args: ARGS }); try { const page = await browser.newPage(); const errors: string[] = []; page.on("pageerror", (e) => errors.push(e.message)); page.on("console", (msg) => { if (msg.type() === "error") errors.push(`console.error: ${msg.text()}`); }); await page.goto(`${url}?${query}`); await page.waitForFunction(() => (window as unknown as { cymbalsim?: unknown }).cymbalsim !== undefined, null, { timeout: 30000 }); await body(page, errors); } finally { await browser.close(); await server.close(); } } const status = (page: Page) => page.evaluate(() => (window as unknown as { cymbalsim: { status: () => Status } }).cymbalsim.status()); const hasGpu = (page: Page) => page.evaluate(() => (window as unknown as { cymbalsim: { app: { device: unknown } } }).cymbalsim.app.device !== null); test("the WGSL step agrees with the float64 reference on several presets", async () => { await withPage("preset=flat-rectangle&nogpuview=0", async (page, errors) => { if (!(await hasGpu(page))) { console.log("no WebGPU in this Chromium; skipping"); return; } const text = await page.evaluate(() => (window as unknown as { cymbalsim: { selftest: () => Promise } }).cymbalsim.selftest()); console.log(text); for (const line of text.split("\n")) { const m = /max relative error ([0-9.e+-]+)/.exec(line); assert.ok(m, line); assert.ok(Number(m![1]) < 2e-4, line); } assert.deepEqual(errors, []); }); }); test("the linear model computes the modes and synthesizes a strike", async () => { await withPage("preset=flat-disc&duration=1", async (page, errors) => { let st = await status(page); assert.equal(st.design, "Flat bronze disc"); assert.equal(st.modes, null, "the modes are computed when Synthesize is pressed"); await page.evaluate(() => (window as unknown as { cymbalsim: { runLinear: () => Promise } }).cymbalsim.runLinear()); st = await status(page); assert.ok(st.modes && st.modes.count > 100, `modes ${st.modes?.count}`); assert.equal(st.modes!.missing, 0); 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)`); // A second synthesis reuses the cached modes. const t0 = Date.now(); await page.evaluate(() => (window as unknown as { cymbalsim: { runLinear: () => Promise } }).cymbalsim.runLinear()); assert.ok(Date.now() - t0 < 4000, "cached modes"); assert.ok(st.synthesized); assert.equal(st.source, "linear"); assert.equal(st.samples, 48000); assert.equal(st.frames, 120); assert.ok(st.peakPressure > 0.1 && st.peakPressure < 1000, `peak ${st.peakPressure} Pa`); assert.ok(st.contact && st.contact.peakForce > 10); assert.deepEqual(errors, []); }); }); test("a script with an error is reported", async () => { await withPage("preset=ellipse", async (page) => { await page.evaluate(() => { const app = (window as unknown as { cymbalsim: { app: { editor: { setSource: (s: string) => void }; onSource: (s: string) => void } } }).cymbalsim.app; app.editor.setSource("design.material.E = 1;"); (app as unknown as { onSource: (s: string) => void }).onSource("design.material.E = 1;"); }); const st = await status(page); assert.match(st.editorStatus, /design.material.E/); }); }); test("the full simulation runs a short strike on a free plate and on a stand", async () => { await withPage("preset=bowed-rectangle&mode=full&duration=0.05&spacing=0.012", async (page, errors) => { if (!(await hasGpu(page))) { console.log("no WebGPU in this Chromium; skipping"); return; } await page.evaluate(() => (window as unknown as { cymbalsim: { runFull: () => Promise } }).cymbalsim.runFull()); const st = await status(page); assert.equal(st.source, "full"); assert.ok(st.sim && st.sim.phase === "done"); assert.equal(st.samples, 2400); assert.ok(st.sim!.stick.maxForce > 10, `force ${st.sim!.stick.maxForce}`); assert.ok(st.sim!.wmax > 1e-5 && st.sim!.wmax < 0.05, `wmax ${st.sim!.wmax}`); assert.ok(st.peakPressure > 0.01 && isFinite(st.peakPressure)); assert.deepEqual(errors, []); }); await withPage("preset=crash-16&mode=full&duration=0.05&spacing=0.012", async (page, errors) => { if (!(await hasGpu(page))) return; await page.evaluate(() => (window as unknown as { cymbalsim: { runFull: () => Promise } }).cymbalsim.runFull()); const st = await status(page); assert.ok(st.sim && st.sim.phase === "done"); assert.ok(st.sim!.stick.maxForce > 10); assert.ok(isFinite(st.peakPressure) && st.peakPressure > 0.01); assert.deepEqual(errors, []); }); }); test("a light hit sounds the same in the linear and the full model", async () => { await withPage("preset=flat-disc&duration=0.25&strikeVelocity=0.05&spacing=0.012&fps=60", async (page, errors) => { if (!(await hasGpu(page))) { console.log("no WebGPU in this Chromium; skipping"); return; } const hooks = "(window as unknown as { cymbalsim: any }).cymbalsim"; await page.evaluate(() => (window as unknown as { cymbalsim: { runLinear: () => Promise } }).cymbalsim.runLinear()); 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)); }); await page.evaluate(() => (window as unknown as { cymbalsim: { runFull: () => Promise } }).cymbalsim.runFull()); 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)); }); void hooks; assert.equal(linear.length, full.length); // Compare the spectra over 50 to 250 ms (after the attack, which the delay interpolation renders slightly differently). const fs = 48000, n0 = Math.round(0.05 * fs), n1 = Math.round(0.25 * fs); const N = 8192; const spec = (x: number[]) => { const re = new Float64Array(N), im = new Float64Array(N); 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)); // naive DFT on a subset of bins is too slow; use a simple radix-2 FFT here fftInPlace(re, im); const mag = new Float64Array(N / 2); for (let k = 0; k < N / 2; k++) mag[k] = Math.hypot(re[k], im[k]); return mag; }; const a = spec(linear), b = spec(full); // Normalized correlation of the magnitude spectra below 6 kHz. const kmax = Math.round((6000 / fs) * N); let ab = 0, aa = 0, bb = 0; for (let k = 1; k < kmax; k++) { ab += a[k] * b[k]; aa += a[k] * a[k]; bb += b[k] * b[k]; } const corr = ab / Math.sqrt(aa * bb); // Level agreement: RMS over the window. const rms = (x: number[]) => Math.sqrt(x.slice(n0, n1).reduce((s, v) => s + v * v, 0) / (n1 - n0)); const ratio = rms(linear) / rms(full); console.log(`spectral correlation ${corr.toFixed(3)}, rms ratio linear/full ${ratio.toFixed(3)}`); assert.ok(corr > 0.9, `spectral correlation ${corr}`); assert.ok(ratio > 0.7 && ratio < 1.4, `rms ratio ${ratio}`); assert.deepEqual(errors, []); }); }); function fftInPlace(re: Float64Array, im: Float64Array): void { const n = re.length; for (let i = 1, j = 0; i < n; i++) { let bit = n >> 1; for (; j & bit; bit >>= 1) j ^= bit; j ^= bit; if (i < j) { [re[i], re[j]] = [re[j], re[i]]; [im[i], im[j]] = [im[j], im[i]]; } } for (let len = 2; len <= n; len <<= 1) { const ang = (-2 * Math.PI) / len; for (let i = 0; i < n; i += len) { for (let k = 0; k < len / 2; k++) { const c = Math.cos(ang * k), s = Math.sin(ang * k); const ur = re[i + k], ui = im[i + k]; const vr = re[i + k + len / 2] * c - im[i + k + len / 2] * s; const vi = re[i + k + len / 2] * s + im[i + k + len / 2] * c; re[i + k] = ur + vr; im[i + k] = ui + vi; re[i + k + len / 2] = ur - vr; im[i + k + len / 2] = ui - vi; } } } }