/ simulated_instruments / cymbal2
Sign in
simulated_instruments / cymbal2
cymbal2 / webapp / src / worker / renderWorker.ts
628 lines · 20.7 KBBlameHistoryRaw
1// Web Worker that renders the cymbal2 stroke. The MATLAB implementation in
2// ../matlab remains the source of truth; what varies is who runs the time
3// loop. When the browser exposes WebGPU, the worker executes the unmodified
4// setup stage (grid, per-wavenumber inverses, mallet, stroke schedule)
5// through numbl, hands the operators to a WGSL port of cymbal2_sim.m
6// (./gpuSim.ts), and feeds the returned acceleration back through the
7// unmodified cymbal2_output.m; this is 20x or more faster than interpreting
8// the loop. Without WebGPU, or when cymbal2_sim.m has been edited in the app
9// (the WGSL mirrors the shipped step, so edits there must fall back), the
10// original interpreter path runs instead. Requests carry a quality tier
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
16// full -> the parameters as given
17// and the UI shows elapsed time, plus integration progress on the GPU path.
18// A cheap linear-modes stage (cymbal2_modes per wavenumber) is cached on the
19// geometry so the spectrum plot can overlay the mode table immediately.
21import { executeCode, RTV, type RuntimeValue, type RuntimeTensor } from 'numbl';
22import { hashMFiles, DEFAULT_MFILES, EXECUTED_BY_APP, type MFile } from '../mfiles';
23import { MODES_KEYS, type Cymbal2Params } from '../params';
24import { requestGpuDevice, runGpuSim, type GpuSetup } from './gpuSim';
26export interface RenderRequest {
27 type: 'render';
28 id: number;
29 params: Cymbal2Params;
30 quality: 'preview' | 'full';
31 mfiles: MFile[];
34export interface RenderResponse {
35 type: 'result';
36 id: number;
37 quality: 'preview' | 'full';
38 y: Float32Array; // final signal, [-1, 1]
39 sampleRate: number;
40 forceT: Float32Array; // contact force history (max-pooled), s
41 forceN: Float32Array; // and its values, N
42 modeF: Float32Array; // linear mode frequencies, Hz
43 modeM: Float32Array; // nodal diameters of each
44 diag: {
45 strokesPlayed: number;
46 meanV: number;
47 contactVel: number;
48 peakForceN: number;
49 contactMs: number;
50 contactEvents: number;
51 peakXiMm: number;
52 peakDispMm: number;
53 peakDispThickness: number;
54 peakAccel: number;
55 strikeRadiusM: number;
56 pickupRadiusM: number;
57 RsM: number;
58 nsteps: number;
59 simRate: number;
60 nDropped: number;
61 durationS: number;
62 renderMs: number;
63 oversample: number;
64 nTheta: number;
65 backend: 'webgpu' | 'cpu';
66 warnings: string[];
67 };
70export interface ProgressMessage {
71 type: 'progress';
72 id: number;
73 stage: 'parsing' | 'modes' | 'integrate' | 'output';
74 frac: number;
77export interface ErrorMessage {
78 type: 'error';
79 id: number;
80 message: string;
81 file?: string;
82 line?: number;
85interface ModesEntry {
86 modeF: Float32Array;
87 modeM: Float32Array;
90const modesCache = new Map<string, ModesEntry>();
91const MODES_CACHE_MAX = 4;
93function lruGet<T>(cache: Map<string, T>, key: string): T | undefined {
94 const v = cache.get(key);
95 if (v !== undefined) {
96 cache.delete(key);
97 cache.set(key, v);
98 }
99 return v;
102function lruSet<T>(cache: Map<string, T>, key: string, value: T, max: number): void {
103 cache.delete(key);
104 cache.set(key, value);
105 while (cache.size > max) {
106 const oldest = cache.keys().next().value as string;
107 cache.delete(oldest);
108 }
111function keyFor(params: Cymbal2Params, keys: readonly (keyof Cymbal2Params)[]): string {
112 return keys.map((k) => String(params[k])).join('|');
115// MATLAB source lines assigning every parameter onto a struct P.
116function pLiteral(params: Cymbal2Params): string {
117 const lines: string[] = [];
118 for (const [k, v] of Object.entries(params)) {
119 if (k === 'bit_depth') continue;
120 if (typeof v === 'number') lines.push(`P.${k} = ${v};`);
121 else if (typeof v === 'boolean') lines.push(`P.${k} = ${v ? 1 : 0};`);
122 }
123 return lines.join('\n');
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.
128class 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 }
139const SKIP_WARNING = /skipping (\S+\.m) \((?:syntax error at line (\d+)|parse error)\)/;
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.
149function 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;
167function runStage(mfiles: MFile[], source: string, inputs?: Record<string, RuntimeValue>) {
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 }
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.
199function checkMFiles(mfiles: MFile[]): void {
200 runStage(mfiles, '1;');
203function asScalar(v: RuntimeValue): number {
204 if (typeof v === 'number') return v;
205 const t = v as RuntimeTensor;
206 if (t && t.kind === 'tensor' && t.data.length === 1) return t.data[0];
207 if (typeof v === 'boolean') return v ? 1 : 0;
208 throw new Error('expected scalar result from numbl stage');
211function toF32(v: RuntimeValue): Float32Array {
212 const t = v as RuntimeTensor;
213 if (!t || t.kind !== 'tensor') throw new Error('expected tensor result from numbl stage');
214 const out = new Float32Array(t.data.length);
215 for (let i = 0; i < t.data.length; i++) out[i] = t.data[i];
216 return out;
219// Max-pool the contact force history to about 2000 points for the plot.
220function poolForce(fhist: Float32Array, simRate: number): { tpool: Float32Array; fpool: Float32Array } {
221 const nsim = fhist.length;
222 const np0 = Math.min(2000, nsim);
223 const blk = Math.ceil(nsim / np0);
224 const np = Math.ceil(nsim / blk);
225 const fpool = new Float32Array(np);
226 const tpool = new Float32Array(np);
227 for (let b = 0; b < np; b++) {
228 let m = 0;
229 const end = Math.min(nsim, (b + 1) * blk);
230 for (let i = b * blk; i < end; i++) m = Math.max(m, fhist[i]);
231 fpool[b] = m;
232 tpool[b] = (b * blk + blk / 2) / simRate;
233 }
234 return { tpool, fpool };
237self.onmessage = (ev: MessageEvent<RenderRequest>) => {
238 const msg = ev.data;
239 if (msg.type !== 'render') return;
241 const warnings: string[] = [];
242 const origWarn = console.warn;
243 console.warn = (...args: unknown[]) => {
244 warnings.push(args.map(String).join(' '));
245 };
247 render(msg)
248 .catch((err) => {
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 = {
257 type: 'error',
258 id: msg.id,
259 message: e?.message ? String(e.message) : String(err),
260 file: typeof e?.file === 'string' ? e.file : undefined,
261 line: typeof e?.line === 'number' ? e.line : undefined,
262 };
263 (self as unknown as Worker).postMessage(m);
264 })
265 .finally(() => {
266 console.warn = origWarn;
267 });
268};
270// Everything the response assembly needs, produced by either backend.
271interface SimOutcome {
272 y32: Float32Array;
273 peakAccel: number;
274 nDropped: number;
275 tpool: Float32Array;
276 fpool: Float32Array;
277 backend: 'webgpu' | 'cpu';
278 d: {
279 strokesPlayed: number;
280 meanV: number;
281 contactVel: number;
282 peakForceN: number;
283 contactMs: number;
284 contactEvents: number;
285 peakXiMm: number;
286 peakDispM: number;
287 peakDispThickness: number;
288 nsteps: number;
289 simRate: number;
290 strikeRadiusM: number;
291 pickupRadiusM: number;
292 RsM: number;
293 };
296// The stroke-schedule block from cymbal2_sim.m, verbatim, so the GPU render
297// integrates exactly the schedule the interpreter would.
298const SCHEDULE_SRC = `
299K = max(1, round(P.strike_count));
300tstr = P.pre_delay_ms * 1e-3 + (0:K - 1) / max(1e-3, P.strike_rate_hz);
301vstr = zeros(1, K);
302state = P.seed * 2654435761;
303for i = 1:K
304 state = mod(state * 1664525 + 1013904223, 4294967296);
305 x = state / 4294967296;
306 vstr(i) = max(0.05, P.strike_velocity * (1 + P.velocity_spread * (2 * x - 1)));
307end
308gap = P.strike_gap_m;
309if gap <= 0
310 gap = 1.05 * max(vstr)^2 / (2 * P.strike_accel) + 0.005;
311end
312travel = gap ./ vstr + vstr / (2 * P.strike_accel);
313tlaunch = tstr - travel;
314off = -min(0, min(tlaunch));
315tlaunch = tlaunch + off;
316duration = off + (K - 1) / max(1e-3, P.strike_rate_hz) + P.tail_s;
317nsteps = floor(duration * P.sample_rate * P.oversample);
318`;
320async function renderOnGpu(
321 mfiles: MFile[],
322 params: Cymbal2Params,
323 onProgress: (frac: number) => void,
324): Promise<SimOutcome> {
325 // A WebGPU device first: if there is none, fail before spending seconds on
326 // the setup stage.
327 const device = await requestGpuDevice();
328 try {
329 const r = runStage(mfiles, `
330${pLiteral(params)}
331S = cymbal2_system(P);
332MB = cymbal2_mallet(P);
333G = S.G;
334o_D1 = G.D1; o_D2 = G.D2;
335o_F = G.F; o_Gi = G.Gi;
336o_Rd = G.Rd; o_R2d = G.R2d;
337o_AiB = S.AiB; o_AiC = S.AiC; o_Ai = S.Ai; o_Fi = S.Fi;
338o_ASiB = MB.ASiB; o_ASiC = MB.ASiC;
339o_vM = MB.vM; o_vR = MB.vR; o_vK = MB.vK; o_tipcol = MB.tipcol;
340${SCHEDULE_SRC}
341o_tlaunch = tlaunch; o_vstr = vstr;
342c_nrt = G.nrt; c_Nr = G.Nr; c_nth = G.nth; c_hr = G.hr; c_hth = G.hth;
343c_ht = S.ht; c_Eh = S.Eh; c_Rs = S.Rs; c_rhoh = S.rhoh;
344c_fsc = S.forceScale; c_nlr0 = S.eomRow0; c_nDropped = S.nDropped;
345c_ls = S.ls; c_js = S.js; c_lp = S.lp; c_jp = S.jp;
346c_strikeR = S.strikeRadiusM; c_pickupR = S.pickupRadiusM;
347c_tipi = MB.tipi; c_nfree = MB.nfree; c_ps = MB.ps;
348c_holdSteps = max(1, round(P.strike_hold_ms * 1e-3 / S.ht));
349c_missDepth = gap + 0.06;
350c_gap = gap; c_K = K; c_nsteps = nsteps;
351c_simRate = P.sample_rate * P.oversample;
352`);
353 const v = r.variableValues;
354 const s = (n: string) => asScalar(v[n]);
355 const t = (n: string) => toF32(v[n]);
356 const setup: GpuSetup = {
357 ops: {
358 D1: t('o_D1'), D2: t('o_D2'),
359 F: t('o_F'), Gi: t('o_Gi'),
360 Rd: t('o_Rd'), R2d: t('o_R2d'),
361 AiB: t('o_AiB'), AiC: t('o_AiC'), Ai: t('o_Ai'), Fi: t('o_Fi'),
362 ASiB: t('o_ASiB'), ASiC: t('o_ASiC'),
363 vM: t('o_vM'), vR: t('o_vR'), vK: t('o_vK'), tipcol: t('o_tipcol'),
364 },
365 tlaunch: Array.from((v.o_tlaunch as RuntimeTensor).data),
366 vstr: Array.from((v.o_vstr as RuntimeTensor).data),
367 nrt: s('c_nrt'), Nr: s('c_Nr'), nth: s('c_nth'),
368 hr: s('c_hr'), hth: s('c_hth'),
369 ht: s('c_ht'), Eh: s('c_Eh'), Rs: s('c_Rs'), rhoh: s('c_rhoh'),
370 fsc: s('c_fsc'), nlr0: s('c_nlr0'),
371 ls: s('c_ls'), js: s('c_js'), lp: s('c_lp'), jp: s('c_jp'),
372 tipi: s('c_tipi'), nfree: s('c_nfree'), ps: s('c_ps'),
373 KM: params.head_stiffness, alM: params.head_alpha,
374 aH: params.strike_accel, aHr: params.retract_accel,
375 holdSteps: s('c_holdSteps'), missDepth: s('c_missDepth'),
376 gap: s('c_gap'), K: s('c_K'), nsteps: s('c_nsteps'),
377 };
378 const simRate = s('c_simRate');
380 const sim = await runGpuSim(device, setup, (done, n) => onProgress(done / n));
382 // The output stage through the unmodified cymbal2_output.m.
383 const accT = RTV.tensor(Float64Array.from(sim.acc), [1, sim.acc.length]);
384 const ro = runStage(mfiles, `
385${pLiteral(params)}
386[y, peakAccel] = cymbal2_output(P, acc);
387`, { acc: accT });
389 // Contact bookkeeping from the force history, as cymbal2_sim does it.
390 let peakF = 0, contactSteps = 0, events = 0, prev = 0;
391 for (let i = 0; i < sim.fhist.length; i++) {
392 const f = sim.fhist[i];
393 if (f > peakF) peakF = f;
394 if (f > 0) { contactSteps++; if (prev === 0) events++; }
395 prev = f;
396 }
397 const { tpool, fpool } = poolForce(sim.fhist, simRate);
398 const meanV = setup.vstr.reduce((a, b) => a + b, 0) / setup.vstr.length;
400 return {
401 y32: toF32(ro.variableValues.y),
402 peakAccel: asScalar(ro.variableValues.peakAccel),
403 nDropped: s('c_nDropped'),
404 tpool,
405 fpool,
406 backend: 'webgpu',
407 d: {
408 strokesPlayed: sim.strokesPlayed,
409 meanV,
410 contactVel: sim.contactVel[0] ?? 0,
411 peakForceN: peakF,
412 contactMs: (1000 * contactSteps) / simRate,
413 contactEvents: events,
414 peakXiMm: sim.peakXi * 1000,
415 peakDispM: sim.peakDisp,
416 peakDispThickness: sim.peakDisp / params.thickness_m,
417 nsteps: setup.nsteps,
418 simRate,
419 strikeRadiusM: s('c_strikeR'),
420 pickupRadiusM: s('c_pickupR'),
421 RsM: setup.Rs,
422 },
423 };
424 } finally {
425 device.destroy();
426 }
429function renderOnCpu(mfiles: MFile[], params: Cymbal2Params): SimOutcome {
430 const r = runStage(mfiles, `
431${pLiteral(params)}
432S = cymbal2_system(P);
433MB = cymbal2_mallet(P);
434[acc, dinfo, fhist] = cymbal2_sim(P, S, MB);
435[y, peakAccel] = cymbal2_output(P, acc);
436% max-pool the contact force history to about 2000 points for the plot
437nsim = length(fhist);
438np = min(2000, nsim);
439blk = ceil(nsim / np);
440np = ceil(nsim / blk);
441fpool = max(reshape([fhist, zeros(1, np * blk - nsim)], blk, np), [], 1);
442tpool = ((0:np - 1) * blk + blk / 2) / dinfo.simRate;
443d_strokes = dinfo.strokesPlayed;
444d_meanV = dinfo.meanV;
445d_cvel = dinfo.contactVel(1);
446d_peakF = dinfo.peakForceN;
447d_contactMs = dinfo.contactMs;
448d_events = dinfo.contactEvents;
449d_xi = dinfo.peakXiMm;
450d_disp = dinfo.peakDispM;
451d_dispH = dinfo.peakDispThickness;
452d_nsteps = dinfo.nsteps;
453d_simRate = dinfo.simRate;
454d_dropped = S.nDropped;
455d_strikeR = S.strikeRadiusM;
456d_pickupR = S.pickupRadiusM;
457d_Rs = S.Rs;
458`);
459 const v = r.variableValues;
460 return {
461 y32: toF32(v.y),
462 peakAccel: asScalar(v.peakAccel),
463 nDropped: asScalar(v.d_dropped),
464 tpool: toF32(v.tpool),
465 fpool: toF32(v.fpool),
466 backend: 'cpu',
467 d: {
468 strokesPlayed: asScalar(v.d_strokes),
469 meanV: asScalar(v.d_meanV),
470 contactVel: asScalar(v.d_cvel),
471 peakForceN: asScalar(v.d_peakF),
472 contactMs: asScalar(v.d_contactMs),
473 contactEvents: asScalar(v.d_events),
474 peakXiMm: asScalar(v.d_xi),
475 peakDispM: asScalar(v.d_disp),
476 peakDispThickness: asScalar(v.d_dispH),
477 nsteps: asScalar(v.d_nsteps),
478 simRate: asScalar(v.d_simRate),
479 strikeRadiusM: asScalar(v.d_strikeR),
480 pickupRadiusM: asScalar(v.d_pickupR),
481 RsM: asScalar(v.d_Rs),
482 },
483 };
486async function render(msg: RenderRequest): Promise<void> {
487 const { id, mfiles, quality } = msg;
488 const params = { ...msg.params };
489 if (quality === 'preview') {
490 params.oversample = 1;
491 }
493 const post = (m: ProgressMessage) => (self as unknown as Worker).postMessage(m);
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);
500 const codeKey = hashMFiles(mfiles);
502 // ---- linear modes, cheap, cached on geometry ----
503 const modesKey = codeKey + '|' + keyFor(params, MODES_KEYS);
504 let modesEntry = lruGet(modesCache, modesKey);
505 if (!modesEntry) {
506 post({ type: 'progress', id, stage: 'modes', frac: 0.05 });
507 const r = runStage(mfiles, `
508${pLiteral(params)}
509mF = [];
510mM = [];
511for mw = 0:min(10, floor(P.n_theta / 2))
512 f = cymbal2_modes(P, mw);
513 f = f(f > 0.5 & f < 4000);
514 nk = min(4, length(f));
515 mF = [mF; f(1:nk)];
516 mM = [mM; mw * ones(nk, 1)];
517end
518`);
519 modesEntry = {
520 modeF: toF32(r.variableValues.mF),
521 modeM: toF32(r.variableValues.mM),
522 };
523 lruSet(modesCache, modesKey, modesEntry, MODES_CACHE_MAX);
524 }
526 // ---- the time integration ----
527 post({ type: 'progress', id, stage: 'integrate', frac: 0.1 });
528 const t0 = performance.now();
529 const warnings: string[] = [];
531 // The WGSL mirrors the shipped cymbal2_sim.m, so an edited step falls back
532 // to the interpreter, where the edit is live. Edits to the setup and output
533 // stages flow through numbl on both paths.
534 const simSource = mfiles.find((f) => f.name === 'cymbal2_sim.m')?.source;
535 const simShipped = DEFAULT_MFILES.find((f) => f.name === 'cymbal2_sim.m')?.source;
536 const simEdited = simSource !== simShipped;
538 let out: SimOutcome | null = null;
539 if (!simEdited) {
540 try {
541 out = await renderOnGpu(mfiles, params, (frac) =>
542 post({ type: 'progress', id, stage: 'integrate', frac: 0.1 + 0.85 * frac }),
543 );
544 } catch (err) {
545 const detail = err instanceof Error ? err.message : String(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;
554 // console.warn is intercepted during a render; use info so the reason
555 // is visible in the devtools console.
556 console.info(`cymbal2: WebGPU unavailable (${detail}); rendering on the CPU`);
557 }
558 }
559 if (!out) {
560 out = renderOnCpu(mfiles, params);
561 if (simEdited) {
562 // Not a fault: just say why the render took the slow path.
563 warnings.push('cymbal2_sim.m differs from the shipped source, so the render used the interpreter (the GPU path mirrors the shipped step)');
564 }
565 }
566 const renderMs = performance.now() - t0;
568 post({ type: 'progress', id, stage: 'output', frac: 0.97 });
569 const { y32, peakAccel, nDropped } = out;
571 if (nDropped > 0) {
572 warnings.push(
573 `${nDropped} azimuthal columns were truncated: the radial grid cannot resolve their inner boundary layer; raise n_radial`,
574 );
575 }
576 if (!params.normalize) {
577 let peak = 0;
578 for (let i = 0; i < y32.length; i++) peak = Math.max(peak, Math.abs(y32[i]));
579 if (peak > 1.0) {
580 warnings.push(
581 `clipping: peak acceleration ${peakAccel.toExponential(2)} m/s² exceeds full_scale_accel`,
582 );
583 }
584 }
586 const resp: RenderResponse = {
587 type: 'result',
588 id,
589 quality,
590 y: y32,
591 sampleRate: params.sample_rate,
592 forceT: out.tpool,
593 forceN: out.fpool,
594 modeF: modesEntry.modeF.slice(),
595 modeM: modesEntry.modeM.slice(),
596 diag: {
597 strokesPlayed: out.d.strokesPlayed,
598 meanV: out.d.meanV,
599 contactVel: out.d.contactVel,
600 peakForceN: out.d.peakForceN,
601 contactMs: out.d.contactMs,
602 contactEvents: out.d.contactEvents,
603 peakXiMm: out.d.peakXiMm,
604 peakDispMm: out.d.peakDispM * 1000,
605 peakDispThickness: out.d.peakDispThickness,
606 peakAccel,
607 strikeRadiusM: out.d.strikeRadiusM,
608 pickupRadiusM: out.d.pickupRadiusM,
609 RsM: out.d.RsM,
610 nsteps: out.d.nsteps,
611 simRate: out.d.simRate,
612 nDropped,
613 durationS: y32.length / params.sample_rate,
614 renderMs,
615 oversample: params.oversample,
616 nTheta: params.n_theta,
617 backend: out.backend,
618 warnings,
619 },
620 };
621 (self as unknown as Worker).postMessage(resp, [
622 resp.y.buffer,
623 resp.forceT.buffer,
624 resp.forceN.buffer,
625 resp.modeF.buffer,
626 resp.modeM.buffer,
627 ]);
↑↓ move↵openescclose