/ proof-of-concept / steelsim31
Sign in
proof-of-concept / steelsim31
steelsim31 / scripts / generate.ts
315 lines · 14.4 KBBlameHistoryRaw
1// Dataset generator: renders many drumstick strikes headlessly and writes one
2// WAV clip plus one line of metadata per clip. Serves dist/ (run `npm run
3// build` first) to a headless Chromium with WebGPU through Playwright, and
4// drives several pages in parallel, since one simulation is latency-bound and
5// leaves a large GPU mostly idle.
6//
7// node --experimental-strip-types scripts/generate.ts --out DIR --count N [--start I] [--workers W] [--seed S] [--fps F]
8//
9// Clip i (global index) is drawn from a generator seeded by (seed, i), so the
10// dataset is reproducible and can be extended or regenerated in pieces.
11// Existing clips in DIR are skipped. Output: DIR/clips/NNNNNN.wav (16-bit,
12// peak-normalized; the peak pressure in Pa is in the metadata) and DIR/index.jsonl.
14import { createServer } from "node:http";
15import { readFileSync, existsSync, mkdirSync, appendFileSync, writeFileSync, readdirSync } from "node:fs";
16import { fileURLToPath } from "node:url";
17import { dirname, join, extname } from "node:path";
18import { chromium, type Page, type Browser } from "playwright";
19import { defaultParams, makeGrid, makeMaterial, type PhysParams } from "../src/params.ts";
20import { encodeFeatures, initialShape, randomFeatures, shapeStats, type ShapeFeature } from "../src/shape.ts";
22const root = join(dirname(fileURLToPath(import.meta.url)), "..");
24// ---------- Arguments ----------
26const argv = process.argv.slice(2);
27function arg(name: string, def: string): string {
28 const k = argv.indexOf(`--${name}`);
29 return k >= 0 && k + 1 < argv.length ? argv[k + 1] : def;
31const OUT = arg("out", "dataset");
32const COUNT = parseInt(arg("count", "10"));
33const START = parseInt(arg("start", "0"));
34const WORKERS = parseInt(arg("workers", "4"));
35const SEED = parseInt(arg("seed", "1"));
36const FPS = parseFloat(arg("fps", "120"));
37const DURATION = parseFloat(arg("duration", "3"));
38const JOB_TIMEOUT_MS = parseFloat(arg("timeout", "1800")) * 1000;
40// ---------- Random numbers ----------
42/** mulberry32, seeded by a hash of (seed, index). */
43function rng(seed: number, index: number): () => number {
44 let a = (Math.imul(seed, 0x9e3779b1) ^ Math.imul(index + 1, 0x85ebca6b)) >>> 0;
45 for (let k = 0; k < 4; k++) { a = Math.imul(a ^ (a >>> 15), 0x2c1b3c6d); a = Math.imul(a ^ (a >>> 12), 0x297a2d39); }
46 return () => {
47 a = (a + 0x6d2b79f5) >>> 0;
48 let t = a;
49 t = Math.imul(t ^ (t >>> 15), t | 1);
50 t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
51 return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
52 };
55// ---------- Parameter sampling ----------
57export interface Sample {
58 id: string;
59 index: number;
60 params: PhysParams;
61 /** Shape statistics on the simulation grid. */
62 shapeScale: number;
63 peakToPeak: number;
64 maxSlope: number;
65 nx: number;
66 ny: number;
69const NU = 0.29;
70const SLOPE_LIMIT = 0.1;
71const SPACING = parseFloat(arg("spacing", "0.008"));
73/**
74 * The targets (E, rho, h, Lx, Ly) are drawn log-uniformly and independently
75 * over ranges of about +-30 percent around the defaults. The nuisance variables
76 * (initial shape, strike point and speed, losses, listener direction) are
77 * drawn independently of them. The grid spacing is fixed (8 mm by default),
78 * so the grid dispersion at a given frequency varies with the bending wave
79 * constant of the sheet; see the note in the dataset README.
80 */
81export function drawSample(seed: number, index: number): Sample {
82 const rnd = rng(seed, index);
83 const U = (a: number, b: number) => a + (b - a) * rnd();
84 const logU = (a: number, b: number) => Math.exp(U(Math.log(a), Math.log(b)));
85 const IN = 0.0254;
86 const E = logU(120e9, 220e9);
87 const rho = logU(6000, 9500);
88 const h = logU(0.6e-3, 1.1e-3);
89 const Lx = logU(9 * IN, 16 * IN);
90 const Ly = logU(13 * IN, 24 * IN);
91 const p: PhysParams = {
92 ...defaultParams,
93 shape: "rectangle",
94 Lx, Ly, h, E, nu: NU, rho,
95 features: [],
96 sigma0: defaultParams.sigma0 * logU(0.7, 1.4),
97 sigma1: defaultParams.sigma1 * logU(0.7, 1.4),
98 sigmaIn: 20,
99 excitation: "strike",
100 strikeFracX: U(0.6, 0.8),
101 strikeFracY: U(0.25, 0.45),
102 strikeVelocity: logU(2, 4),
103 hitSequence: "single",
104 listenerDist: 1,
105 listenerAzimuth: U(-15, 15),
106 listenerElevation: U(-15, 15),
107 spacing: SPACING,
108 fps: FPS,
109 duration: DURATION,
110 };
111 // Initial shape: flat with a small probability, otherwise a random feature
112 // list with heights of the order of 0.5 to 5 mm (the default is a 3 mm bow);
113 // redrawn at half the scale while the largest slope leaves the shallow-shell range.
114 const grid = makeGrid(p);
115 let shapeScale = rnd() < 0.08 ? 0 : logU(0.5e-3, 5e-3);
116 let features: ShapeFeature[] = [];
117 let stats = { peakToPeak: 0, maxSlope: 0 };
118 for (let attempt = 0; shapeScale > 0 && attempt < 12; attempt++) {
119 features = randomFeatures(p, shapeScale, rnd);
120 stats = shapeStats(initialShape({ ...p, features }, grid), grid);
121 if (stats.maxSlope <= SLOPE_LIMIT) break;
122 shapeScale *= 0.5;
123 }
124 p.features = features;
125 return { id: String(index).padStart(6, "0"), index, params: p, shapeScale, peakToPeak: stats.peakToPeak, maxSlope: stats.maxSlope, nx: grid.nx, ny: grid.ny };
128/** Query string for the app, in the form form.ts applyUrl reads. */
129export function toQuery(p: PhysParams): string {
130 const parts: string[] = ["autostart=1"];
131 for (const k of Object.keys(defaultParams) as (keyof PhysParams)[]) {
132 if (k === "features") {
133 parts.push(`features=${encodeURIComponent(p.features.length ? encodeFeatures(p.features) : "none")}`);
134 } else if (p[k] !== defaultParams[k]) {
135 const v = p[k];
136 parts.push(`${k}=${encodeURIComponent(typeof v === "number" ? String(v) : typeof v === "boolean" ? (v ? "1" : "0") : String(v))}`);
137 }
138 }
139 return parts.join("&");
142/** Checks that the page parsed the targets as intended (the link is the app's own encoding of its parameters). */
143function checkLink(link: string, p: PhysParams): void {
144 const q = new URLSearchParams(link.includes("?") ? link.slice(link.indexOf("?") + 1) : link);
145 for (const k of ["E", "rho", "h", "Lx", "Ly", "strikeVelocity", "sigma0", "sigma1"] as const) {
146 const got = parseFloat(q.get(k) ?? String(defaultParams[k]));
147 if (!(Math.abs(got - p[k]) <= 1e-5 * Math.abs(p[k]))) throw new Error(`parameter ${k} mismatch: page has ${got}, wanted ${p[k]} (link ${link})`);
148 }
151// ---------- WAV ----------
153export function wavBytes(samples: Float32Array, fs: number, scale: number): Uint8Array {
154 const buf = new ArrayBuffer(44 + samples.length * 2);
155 const v = new DataView(buf);
156 const str = (o: number, s: string) => { for (let k = 0; k < s.length; k++) v.setUint8(o + k, s.charCodeAt(k)); };
157 str(0, "RIFF"); v.setUint32(4, 36 + samples.length * 2, true); str(8, "WAVE");
158 str(12, "fmt "); v.setUint32(16, 16, true); v.setUint16(20, 1, true); v.setUint16(22, 1, true);
159 v.setUint32(24, fs, true); v.setUint32(28, fs * 2, true); v.setUint16(32, 2, true); v.setUint16(34, 16, true);
160 str(36, "data"); v.setUint32(40, samples.length * 2, true);
161 for (let k = 0; k < samples.length; k++) {
162 const s = Math.max(-1, Math.min(1, samples[k] * scale));
163 v.setInt16(44 + 2 * k, Math.round(s * 32767), true);
164 }
165 return new Uint8Array(buf);
168// ---------- Static server for dist/ ----------
170const MIME: Record<string, string> = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".wgsl": "text/plain" };
171export function serveDist(): Promise<string> {
172 const dist = join(root, "dist");
173 if (!existsSync(join(dist, "index.html"))) throw new Error("dist/index.html not found; run `npm run build` first");
174 const server = createServer((req, res) => {
175 const path = decodeURIComponent((req.url ?? "/").split("?")[0]);
176 const file = join(dist, path === "/" ? "index.html" : path);
177 try {
178 const data = readFileSync(file);
179 res.writeHead(200, { "content-type": MIME[extname(file)] ?? "application/octet-stream" });
180 res.end(data);
181 } catch {
182 res.writeHead(404); res.end();
183 }
184 });
185 return new Promise((resolve) => server.listen(0, "127.0.0.1", () => {
186 const addr = server.address();
187 resolve(`http://127.0.0.1:${typeof addr === "object" && addr ? addr.port : 0}`);
188 }));
191// ---------- Rendering one clip ----------
193export const ARGS = ["--enable-unsafe-webgpu", "--enable-features=Vulkan", "--use-angle=vulkan", "--ignore-gpu-blocklist", "--no-sandbox"];
195interface Status { phase: string; t: number; totalSteps: number; wmax: number; peakPressure: number; samples: number; stepsPerSec: number; dt: number; nx: number; ny: number; stick: { maxForce: number } | null }
197export async function renderClip(page: Page, url: string, s: Sample): Promise<Record<string, unknown>> {
198 const errors: string[] = [];
199 const onErr = (e: Error) => errors.push(e.message);
200 page.on("pageerror", onErr);
201 const t0 = Date.now();
202 await page.goto(`${url}/?${toQuery(s.params)}`);
203 let st: Status | null = null;
204 for (;;) {
205 await page.waitForTimeout(250);
206 st = await page.evaluate<Status | null>("window.steelsim ? window.steelsim.status() : null");
207 const noGpu = await page.evaluate<string | null>("window.steelsim ? window.steelsim.noGpu() : null");
208 if (noGpu) throw new Error(`no WebGPU: ${noGpu}`);
209 if (errors.length) throw new Error(`page errors: ${errors.join("; ")}`);
210 if (st && st.phase === "done") break;
211 if (Date.now() - t0 > JOB_TIMEOUT_MS) throw new Error("timeout");
212 }
213 page.off("pageerror", onErr);
214 const audio = new Float32Array(await page.evaluate<number[]>("window.steelsim.audio()"));
215 const link = await page.evaluate<string>("window.steelsim.link()");
216 checkLink(link, s.params);
217 const wall = (Date.now() - t0) / 1000;
218 if (!st) throw new Error("no status");
219 if (!isFinite(st.wmax) || st.wmax > 0.1 || !isFinite(st.peakPressure)) throw new Error(`unstable: wmax ${st.wmax}, peak ${st.peakPressure}`);
220 let peak = 0, sumsq = 0;
221 for (let k = 0; k < audio.length; k++) { peak = Math.max(peak, Math.abs(audio[k])); sumsq += audio[k] * audio[k]; }
222 const scale = peak > 0 ? 0.99 / peak : 0;
223 const n = Math.min(audio.length, Math.round(DURATION * s.params.fs));
224 writeFileSync(join(OUT, "clips", `${s.id}.wav`), wavBytes(audio.subarray(0, n), s.params.fs, scale));
225 const p = s.params;
226 const mat = makeMaterial(p);
227 return {
228 id: s.id,
229 index: s.index,
230 seed: SEED,
231 // Targets
232 E: p.E, rho: p.rho, h: p.h, Lx: p.Lx, Ly: p.Ly, nu: p.nu,
233 // Derived
234 kappa: Math.sqrt(mat.D / mat.rhoh),
235 mass: mat.rhoh * p.Lx * p.Ly,
236 // Nuisance
237 features: p.features.length ? encodeFeatures(p.features) : "none",
238 shapeScale: s.shapeScale, peakToPeak: s.peakToPeak, maxSlope: s.maxSlope,
239 sigma0: p.sigma0, sigma1: p.sigma1, sigmaIn: p.sigmaIn,
240 strikeFracX: p.strikeFracX, strikeFracY: p.strikeFracY, strikeVelocity: p.strikeVelocity,
241 listenerDist: p.listenerDist, listenerAzimuth: p.listenerAzimuth, listenerElevation: p.listenerElevation,
242 // Numerics
243 spacing: p.spacing, nx: st.nx, ny: st.ny, dt: st.dt, fps: p.fps, fs: p.fs, duration: DURATION, totalSteps: st.totalSteps,
244 // Audio
245 samples: n, peakPa: peak, rmsPa: Math.sqrt(sumsq / Math.max(1, audio.length)), wavScalePa: peak > 0 ? 1 / scale : 0,
246 maxForce: st.stick?.maxForce ?? null, wmax: st.wmax,
247 // Bookkeeping
248 wallSeconds: wall, stepsPerSec: st.stepsPerSec, link, finished: new Date().toISOString(),
249 };
252// ---------- Main ----------
254async function main(): Promise<void> {
255 mkdirSync(join(OUT, "clips"), { recursive: true });
256 const indexFile = join(OUT, "index.jsonl");
257 const done = new Set<string>();
258 if (existsSync(indexFile)) for (const line of readFileSync(indexFile, "utf8").split("\n")) if (line.trim()) done.add(JSON.parse(line).id);
259 const existing = new Set(readdirSync(join(OUT, "clips")).map((f) => f.replace(/\.wav$/, "")));
260 const jobs: Sample[] = [];
261 for (let i = START; i < START + COUNT; i++) {
262 const s = drawSample(SEED, i);
263 if (done.has(s.id) && existing.has(s.id)) continue;
264 jobs.push(s);
265 }
266 console.log(`${jobs.length} clips to render (${COUNT - jobs.length} already done), ${WORKERS} workers, out ${OUT}`);
267 if (!jobs.length) return;
268 const url = await serveDist();
269 let next = 0, finished = 0, failed = 0;
270 const t0 = Date.now();
271 const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
272 // Each worker owns a browser, so that a lost GPU process costs one worker a
273 // relaunch rather than every worker its WebGPU; the starts are staggered.
274 const worker = async (w: number) => {
275 await sleep(1500 * w);
276 let browser: Browser = await chromium.launch({ headless: true, args: ARGS });
277 let page: Page = await (await browser.newContext()).newPage();
278 const relaunch = async () => {
279 try { await browser.close(); } catch { /* already gone */ }
280 await sleep(3000);
281 browser = await chromium.launch({ headless: true, args: ARGS });
282 page = await (await browser.newContext()).newPage();
283 };
284 while (next < jobs.length) {
285 const s = jobs[next++];
286 for (let attempt = 0; ; attempt++) {
287 try {
288 const rec = await renderClip(page, url, s);
289 appendFileSync(indexFile, JSON.stringify(rec) + "\n");
290 finished++;
291 const rate = (finished * 3600) / ((Date.now() - t0) / 1000);
292 console.log(`[w${w}] ${s.id} done in ${(rec.wallSeconds as number).toFixed(0)} s: ${s.nx}x${s.ny}, E ${(s.params.E / 1e9).toFixed(0)} GPa, rho ${s.params.rho.toFixed(0)}, h ${(s.params.h * 1e3).toFixed(2)} mm, ${(s.params.Lx * 100).toFixed(0)}x${(s.params.Ly * 100).toFixed(0)} cm, peak ${(rec.peakPa as number).toFixed(2)} Pa | ${finished} done, ${failed} failed, ${rate.toFixed(0)} clips/h`);
293 break;
294 } catch (e) {
295 const msg = String(e).replace(/\n/g, " ");
296 if (/no WebGPU|closed|crashed|Protocol error|timeout/i.test(msg) && attempt < 3) {
297 console.log(`[w${w}] ${s.id} retrying after: ${msg}`);
298 await relaunch();
299 continue;
300 }
301 failed++;
302 console.log(`[w${w}] ${s.id} FAILED: ${msg}`);
303 appendFileSync(join(OUT, "failures.log"), `${s.id}\t${new Date().toISOString()}\t${msg}\n`);
304 break;
305 }
306 }
307 }
308 await browser.close();
309 };
310 await Promise.all(Array.from({ length: WORKERS }, (_, w) => worker(w)));
311 console.log(`finished: ${finished} clips, ${failed} failures, ${((Date.now() - t0) / 60000).toFixed(1)} min`);
312 process.exit(0);
315if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) void main().catch((e) => { console.error(e); process.exit(1); });
↑↓ move↵openescclose