Show partial results while a run is in progress: plots, replay and audio update every 2 s (CPU and WebGPU); a cancelled run keeps its last partial result
9 changed files+271−92
README.mdmodified+1−0View file
@@ -53,6 +53,7 @@ Both solvers integrate the same scheme (`src/sim/solver.ts` on the CPU in double
5353 - CPU: at each audio sample the solver recomputes only the velocity-dependent forces (damping, friction) at the full-step velocity; spring and hinge elasticity depend on positions, which have not changed. Together with the time step, this made the cymbal presets 1.4 to 1.6x faster.
5454 - WebGPU: each phase of a time step (kick and drift, hinge angles and contact, hinge moments, bead forces, kick with rigid-motion removal) is a small kernel; a command buffer holds the dispatches for many samples, and step and sample counters live on the GPU. Forces are gathered per bead from fixed-width adjacency lists, so there are no atomics. To keep small vibrations from vanishing in f32, the state is the displacement from rest, and spring extensions and hinge angles are computed from precomputed rest differences. Sums over beads (rigid motion, energy, audio) go through per-workgroup partials. On the first 50 ms of every preset, the audio agrees with the CPU to within 0.5% RMS (0.1% for most), and the energy-balance check and largest strain match.
5555 - Speed, WebGPU vs CPU on an Intel Iris Xe laptop (Chromium): the default cymbal (366 beads) about 2x, a 20-ring cymbal (1444 beads) 4x, 30 rings (3188 beads) 6x; strings (3 to 50 beads) 4 to 80x slower in 50 ms test runs, where the GPU's start-up (about half a second) dominates. A single persistent workgroup running whole time steps per dispatch was tried first; on this GPU its large kernel made every barrier cost several microseconds, and it used only one of the GPU's subslices.
56+- Partial results: while a run is in progress, the worker sends the result so far after 1 s and then every 2 s (`PARTIAL_FIRST_MS`, `PARTIAL_EVERY_MS` in `solver.ts`), and the page shows it like a finished run: plots on the axis of the whole run, the 3D replay, and audio to play or download. The WebGPU solver copies just the finished prefix of each output (audio, contact force, frame scalars, frames) to a staging buffer while later chunks run; on the default cymbal this costs about 1% of the loop time, and the partial results match the final one exactly (`test/partial.test.ts` checks this for the CPU). Updates keep the replay position and exaggeration; the audio player keeps playing the older sound until it is paused or ends, then switches (keeping a paused position). A cancelled or failed run leaves its last partial result on the page. Only finished runs go to the shared cache.
5657 - `bench.html` (dev server only, `npm run dev` then `/bench.html?dur=0.05&only=cymbal&rings=20`) runs every preset on both solvers and prints the differences and timings.
5758
5859 ## Shared result cache
src/sim/gpu.tsmodified+98−54View file
@@ -1,5 +1,5 @@
11 import type { BeadSystem, Excitation, SimOptions, SimResult } from './types';
2-import { timeStep, type Progress } from './solver';
2+import { timeStep, PARTIAL_EVERY_MS, PARTIAL_FIRST_MS, type PartialResult, type Progress } from './solver';
33
44 // The same scheme as simulate() in solver.ts (velocity Verlet, same time step,
55 // rigid-motion removal, contact, delayed pickup), run on the GPU in single
@@ -929,6 +929,7 @@ export async function simulateGPU(
929929 exc: Excitation,
930930 opt: SimOptions,
931931 progress?: Progress,
932+ partial?: PartialResult,
932933 ): Promise<SimResult & { device: string; loopSeconds: number }> {
933934 const t0 = performance.now();
934935 const gpu = navigator.gpu;
@@ -1011,6 +1012,92 @@ export async function simulateGPU(
10111012 const err = await device.popErrorScope();
10121013 if (err) throw new Error(err.message);
10131014
1015+ const { nSamples, nFrames, samplesPerFrame, dt } = pk;
1016+ const deviceName = [adapter.info?.vendor, adapter.info?.architecture].filter(Boolean).join(' ') || 'GPU';
1017+ const hasCF = !!exc.mallet;
1018+ // output regions (offset, length) holding the first S samples and F frames
1019+ const regions = (S: number, F: number): [number, number][] => [
1020+ [0, S],
1021+ [nSamples, hasCF ? S : 0],
1022+ [2 * nSamples, 6 * F],
1023+ [2 * nSamples + 6 * nFrames, hasCF ? 3 * F : 0],
1024+ [pk.framesAt, 3 * sys.n * F],
1025+ ];
1026+ // copy those regions, back to back, once the GPU work submitted so far is done
1027+ const readBack = async (S: number, F: number): Promise<Float32Array[]> => {
1028+ const reg = regions(S, F);
1029+ const total = reg.reduce((a, [, len]) => a + len, 0);
1030+ const stage = device.createBuffer({ size: Math.max(16, total * 4), usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST });
1031+ try {
1032+ const enc = device.createCommandEncoder();
1033+ let o = 0;
1034+ for (const [off, len] of reg) {
1035+ if (len > 0) enc.copyBufferToBuffer(outB, off * 4, stage, o * 4, len * 4);
1036+ o += len;
1037+ }
1038+ device.queue.submit([enc.finish()]);
1039+ await stage.mapAsync(GPUMapMode.READ);
1040+ const all = new Float32Array(stage.getMappedRange().slice(0, total * 4));
1041+ o = 0;
1042+ return reg.map(([, len]) => all.subarray(o, (o += len)));
1043+ } finally {
1044+ stage.destroy();
1045+ }
1046+ };
1047+ const assemble = ([audio, cf, fsc, mp, frames]: Float32Array[], F: number): SimResult & { device: string } => {
1048+ const frameTimes = new Float64Array(F);
1049+ const energy = new Float64Array(F);
1050+ const balance = new Float64Array(F);
1051+ let maxStretch = 0;
1052+ let diss = 0;
1053+ let work = 0;
1054+ let E0 = 0;
1055+ for (let k = 0; k < F; k++) {
1056+ const b = 6 * k;
1057+ frameTimes[k] = k * samplesPerFrame * stepsPerSample * dt;
1058+ energy[k] = fsc[b];
1059+ const total = fsc[b] + fsc[b + 1];
1060+ diss += fsc[b + 2];
1061+ work += fsc[b + 3];
1062+ if (k === 0) E0 = total;
1063+ balance[k] = total + diss - work - E0;
1064+ maxStretch = Math.max(maxStretch, fsc[b + 4]);
1065+ }
1066+ return {
1067+ audio: audio.slice(),
1068+ fs: opt.fs,
1069+ frames: frames.slice(),
1070+ frameTimes,
1071+ energy,
1072+ balance,
1073+ dt,
1074+ stepsPerSample,
1075+ omegaMax: pk.omegaMax,
1076+ wallSeconds: (performance.now() - t0) / 1000,
1077+ maxStretch,
1078+ contactForce: hasCF ? cf.slice() : undefined,
1079+ malletPos: hasCF ? mp.slice() : undefined,
1080+ device: deviceName,
1081+ };
1082+ };
1083+ // a partial result after the first S samples: read back while later chunks run
1084+ let snapshot: Promise<void> | null = null;
1085+ let nextPartial = t0 + PARTIAL_FIRST_MS;
1086+ const takePartial = (S: number) => {
1087+ const F = Math.min(nFrames, Math.floor((S - 1) / samplesPerFrame) + 1);
1088+ snapshot = readBack(S, F)
1089+ .then((parts) => {
1090+ if (!lost && parts[0].every(Number.isFinite)) partial!(assemble(parts, F));
1091+ })
1092+ .catch(() => {
1093+ /* a missed partial result does not matter */
1094+ })
1095+ .finally(() => {
1096+ snapshot = null;
1097+ nextPartial = performance.now() + PARTIAL_EVERY_MS;
1098+ });
1099+ };
1100+
10141101 // chunks of samples, about 50 ms of GPU work each, two in flight so the
10151102 // GPU does not wait while the next one is encoded
10161103 const tLoop = performance.now();
@@ -1019,78 +1106,35 @@ export async function simulateGPU(
10191106 let pending: Promise<void> | null = null;
10201107 let pendingEnd = 0;
10211108 let tPrev = performance.now();
1022- for (let s = 0; s < pk.nSamples; ) {
1023- const e = Math.min(pk.nSamples, s + chunk);
1109+ for (let s = 0; s < nSamples; ) {
1110+ const e = Math.min(nSamples, s + chunk);
10241111 const done = submit((pass) => {
1025- for (let k = s; k < e; k++) encode(pass, k === 0 || k === pk.nSamples - 1 ? sample(k === 0, k === pk.nSamples - 1) : mid);
1112+ for (let k = s; k < e; k++) encode(pass, k === 0 || k === nSamples - 1 ? sample(k === 0, k === nSamples - 1) : mid);
10261113 });
1114+ // samples 0 .. e - 1 are written once this chunk is done
1115+ if (partial && !snapshot && e < nSamples && performance.now() >= nextPartial) takePartial(e);
10271116 if (pending) {
10281117 await pending;
10291118 if (lost) throw new Error(lost);
10301119 const now = performance.now();
10311120 chunk = Math.max(1, Math.min(Math.round(chunk * Math.min(2, 50 / Math.max(now - tPrev, 1))), 4096));
10321121 tPrev = now;
1033- progress?.(pendingEnd / pk.nSamples);
1122+ progress?.(pendingEnd / nSamples);
10341123 }
10351124 pending = done;
10361125 pendingEnd = e;
10371126 s = e;
10381127 }
10391128 await pending;
1129+ await snapshot;
10401130 if (lost) throw new Error(lost);
10411131 progress?.(1);
10421132 const loopSeconds = (performance.now() - tLoop) / 1000;
10431133
1044- const stage = device.createBuffer({ size: Math.max(16, pk.outLen * 4), usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST });
1045- const enc = device.createCommandEncoder();
1046- enc.copyBufferToBuffer(outB, 0, stage, 0, Math.max(16, pk.outLen * 4));
1047- device.queue.submit([enc.finish()]);
1048- await stage.mapAsync(GPUMapMode.READ);
1049- const out = new Float32Array(stage.getMappedRange().slice(0, pk.outLen * 4));
1050- stage.destroy();
1134+ const parts = await readBack(nSamples, nFrames);
10511135 if (lost) throw new Error(lost);
1052-
1053- const { nSamples, nFrames, samplesPerFrame, dt } = pk;
1054- const audio = out.slice(0, nSamples);
1055- for (let k = 0; k < audio.length; k++) if (!Number.isFinite(audio[k])) throw new Error('the GPU run blew up (non-finite output)');
1056- const contactForce = exc.mallet ? out.slice(nSamples, 2 * nSamples) : undefined;
1057- const O_FSC = 2 * nSamples;
1058- const O_MP = O_FSC + 6 * nFrames;
1059- const frameTimes = new Float64Array(nFrames);
1060- const energy = new Float64Array(nFrames);
1061- const balance = new Float64Array(nFrames);
1062- let maxStretch = 0;
1063- let diss = 0;
1064- let work = 0;
1065- let E0 = 0;
1066- for (let k = 0; k < nFrames; k++) {
1067- const b = O_FSC + 6 * k;
1068- frameTimes[k] = k * samplesPerFrame * stepsPerSample * dt;
1069- energy[k] = out[b];
1070- const total = out[b] + out[b + 1];
1071- diss += out[b + 2];
1072- work += out[b + 3];
1073- if (k === 0) E0 = total;
1074- balance[k] = total + diss - work - E0;
1075- maxStretch = Math.max(maxStretch, out[b + 4]);
1076- }
1077- return {
1078- audio,
1079- fs: opt.fs,
1080- frames: out.slice(pk.framesAt),
1081- frameTimes,
1082- energy,
1083- balance,
1084- dt,
1085- stepsPerSample,
1086- omegaMax: pk.omegaMax,
1087- wallSeconds: (performance.now() - t0) / 1000,
1088- maxStretch,
1089- contactForce,
1090- malletPos: exc.mallet ? out.slice(O_MP, O_MP + 3 * nFrames) : undefined,
1091- loopSeconds,
1092- device: [adapter.info?.vendor, adapter.info?.architecture].filter(Boolean).join(' ') || 'GPU',
1093- };
1136+ if (!parts[0].every(Number.isFinite)) throw new Error('the GPU run blew up (non-finite output)');
1137+ return { ...assemble(parts, nFrames), loopSeconds };
10941138 } finally {
10951139 device.destroy();
10961140 }
src/sim/run.tsmodified+10−1View file
@@ -5,7 +5,15 @@ export interface Running {
55 cancel: () => void;
66 }
77
8-export function runInWorker(sys: BeadSystem, exc: Excitation, opt: SimOptions, gpu: boolean, onProgress: (f: number) => void): Running {
8+// onPartial gets the result so far every few seconds while the run is in progress
9+export function runInWorker(
10+ sys: BeadSystem,
11+ exc: Excitation,
12+ opt: SimOptions,
13+ gpu: boolean,
14+ onProgress: (f: number) => void,
15+ onPartial?: (r: SimResult) => void,
16+): Running {
917 const worker = new Worker(new URL('./worker.ts', import.meta.url), { type: 'module' });
1018 let reject!: (e: Error) => void;
1119 const promise = new Promise<SimResult>((res, rej) => {
@@ -13,6 +21,7 @@ export function runInWorker(sys: BeadSystem, exc: Excitation, opt: SimOptions, g
1321 worker.onmessage = (ev) => {
1422 const m = ev.data;
1523 if (m.type === 'progress') onProgress(m.fraction);
24+ else if (m.type === 'partial') onPartial?.(m.result);
1625 else if (m.type === 'done') {
1726 worker.terminate();
1827 res(m.result);
src/sim/solver.tsmodified+34−16View file
@@ -167,12 +167,18 @@ export function bendingEnergy(b: Bending, X: Float64Array, keep?: (t: number) =>
167167 }
168168
169169 export type Progress = (fraction: number) => void;
170+// receives the result so far (a prefix of every output) while a run is in progress
171+export type PartialResult = (r: SimResult) => void;
172+
173+// wall time before the first partial result, and between later ones (ms)
174+export const PARTIAL_FIRST_MS = 1000;
175+export const PARTIAL_EVERY_MS = 2000;
170176
171177 // Velocity Verlet (kick-drift-kick), explicit. Spring and hinge forces are
172178 // exact (geometric nonlinearity kept); damping and friction use the half-step
173179 // velocity in the kick, and the full-step velocity whenever an audio sample is
174180 // recorded.
175-export function simulate(sys: BeadSystem, exc: Excitation, opt: SimOptions, progress?: Progress): SimResult {
181+export function simulate(sys: BeadSystem, exc: Excitation, opt: SimOptions, progress?: Progress, partial?: PartialResult): SimResult {
176182 const t0 = performance.now();
177183 const { n, si, sj, sk, sL0, sc, mass, pinned, anchors } = sys;
178184 const nn = n + anchors.length;
@@ -584,6 +590,28 @@ export function simulate(sys: BeadSystem, exc: Excitation, opt: SimOptions, prog
584590 let t = 0;
585591 let frame = 0;
586592 const progressEvery = Math.max(1, Math.floor(nSamples / 100));
593+ let nextPartial = t0 + PARTIAL_FIRST_MS;
594+ // The outputs up to sample count S and frame count F: copies, except for the
595+ // finished run (a partial result may be transferred away). With a delayed
596+ // pickup, audio[i] only gets terms from samples <= i, so the first S are complete.
597+ const result = (S: number, F: number): SimResult => {
598+ const all = S === nSamples;
599+ return {
600+ audio: all ? audio : audio.slice(0, S),
601+ fs: opt.fs,
602+ frames: all ? frames : frames.slice(0, F * 3 * n),
603+ frameTimes: all ? frameTimes : frameTimes.slice(0, F),
604+ energy: all ? energy : energy.slice(0, F),
605+ balance: all ? balance : balance.slice(0, F),
606+ dt,
607+ stepsPerSample,
608+ omegaMax,
609+ wallSeconds: (performance.now() - t0) / 1000,
610+ maxStretch,
611+ contactForce: all ? contactForce : contactForce?.slice(0, S),
612+ malletPos: all ? malletPos : malletPos?.slice(0, 3 * F),
613+ };
614+ };
587615
588616 for (let smp = 0; smp < nSamples; smp++) {
589617 // record at t = smp / fs; A holds forces at full-step velocity here
@@ -616,6 +644,10 @@ export function simulate(sys: BeadSystem, exc: Excitation, opt: SimOptions, prog
616644 frame++;
617645 }
618646 if (smp === nSamples - 1) break;
647+ if (partial && smp % 32 === 0 && performance.now() >= nextPartial) {
648+ partial(result(smp + 1, frame));
649+ nextPartial = performance.now() + PARTIAL_EVERY_MS;
650+ }
619651
620652 for (let st = 0; st < stepsPerSample; st++) {
621653 // kick (half), drift
@@ -645,21 +677,7 @@ export function simulate(sys: BeadSystem, exc: Excitation, opt: SimOptions, prog
645677 if (progress && smp % progressEvery === 0) progress(smp / nSamples);
646678 }
647679
648- return {
649- audio,
650- fs: opt.fs,
651- frames,
652- frameTimes,
653- energy,
654- balance,
655- dt,
656- stepsPerSample,
657- omegaMax,
658- wallSeconds: (performance.now() - t0) / 1000,
659- maxStretch,
660- contactForce,
661- malletPos,
662- };
680+ return result(nSamples, nFrames);
663681 }
664682
665683 // inverse of the symmetric 3x3 matrix [a b c; b d e; c e f], as its six
src/sim/worker.tsmodified+7−4View file
@@ -9,23 +9,26 @@ export interface WorkerRequest {
99 gpu: boolean;
1010 }
1111
12+const buffers = (r: SimResult) =>
13+ [r.audio, r.frames, r.frameTimes, r.energy, r.balance, r.contactForce, r.malletPos].filter((a) => a).map((a) => a!.buffer) as ArrayBuffer[];
14+
1215 self.onmessage = async (ev: MessageEvent<WorkerRequest>) => {
1316 const { sys, exc, opt, gpu } = ev.data;
1417 const progress = (f: number) => self.postMessage({ type: 'progress', fraction: f });
18+ const partial = (result: SimResult) => self.postMessage({ type: 'partial', result }, { transfer: buffers(result) });
1519 try {
1620 let result: SimResult | undefined;
1721 let fallback: string | undefined;
1822 if (gpu) {
1923 try {
20- const r = await simulateGPU(sys, exc, opt, progress);
24+ const r = await simulateGPU(sys, exc, opt, progress, (p) => partial({ ...p, backend: `WebGPU (${(p as SimResult & { device: string }).device})` }));
2125 result = { ...r, backend: `WebGPU (${r.device})` };
2226 } catch (e) {
2327 fallback = (e as Error).message ?? String(e);
2428 }
2529 }
26- result ??= { ...simulate(sys, exc, opt, progress), backend: 'CPU', fallback };
27- const transfer = [result.audio.buffer, result.frames.buffer, result.frameTimes.buffer, result.energy.buffer, result.balance.buffer];
28- self.postMessage({ type: 'done', result }, { transfer: transfer as ArrayBuffer[] });
30+ result ??= { ...simulate(sys, exc, opt, progress, (p) => partial({ ...p, backend: 'CPU', fallback })), backend: 'CPU', fallback };
31+ self.postMessage({ type: 'done', result }, { transfer: buffers(result) });
2932 } catch (e) {
3033 self.postMessage({ type: 'error', message: String(e) });
3134 }
src/ui/instrumentPage.tsmodified+64−15View file
@@ -170,6 +170,7 @@ export function mountInstrumentPage<P extends SimParams>(root: HTMLElement, cfg:
170170 let probeSeq = 0;
171171 let probeTimer = 0;
172172 let audioUrl: string | null = null;
173+ let pendingAudio: Blob | null = null; // a newer partial result's audio, held back while the player plays
173174
174175 const controls = renderControls(q('controls'), cfg.sections, cfg.presets[0].params, () => updateSetup());
175176 presetDesc.textContent = cfg.presets[0].description;
@@ -251,13 +252,14 @@ export function mountInstrumentPage<P extends SimParams>(root: HTMLElement, cfg:
251252
252253 function drawWave() {
253254 if (!last) return;
254- const { r } = last;
255+ const { p, r } = last;
255256 const t = new Float64Array(r.audio.length);
256257 for (let i = 0; i < t.length; i++) t[i] = i / r.fs;
257258 wave.set([{ x: t, y: r.audio, color: css('--series-1'), label: 'a' }], {
258259 xLabel: 'time (s)',
259260 yLabel: `${quantity.label} (${quantity.unit})`,
260- xRange: waveRange ? [0, Math.min(waveRange, t[t.length - 1])] : undefined,
261+ // a partial result is drawn on the axis of the whole run
262+ xRange: [0, waveRange ? Math.min(waveRange, p.duration) : Math.max(p.duration, t[t.length - 1])],
261263 });
262264 }
263265
@@ -280,44 +282,73 @@ export function mountInstrumentPage<P extends SimParams>(root: HTMLElement, cfg:
280282 });
281283 }
282284
283- function show(p: P, built: Built, r: SimResult) {
285+ // partial: r covers only the start of a run still in progress. update: r
286+ // extends the result already shown (same run), so the replay position, the
287+ // exaggeration and the audio player's position are kept.
288+ function show(p: P, built: Built, r: SimResult, partial = false, update = false) {
284289 last = { p, r };
285- cacheNote.textContent = '';
290+ const simulated = r.audio.length / r.fs;
291+ cacheNote.textContent = partial
292+ ? `Partial result: ${fmtNum(simulated)} s of ${fmtNum(p.duration)} s simulated so far. It updates every few seconds while the run continues; the audio player picks up the newer sound when it is paused.`
293+ : '';
286294 root.querySelectorAll<HTMLElement>('[data-r="out"]').forEach((e) => (e.hidden = false));
287295 let peak = 0;
288296 for (const v of r.audio) peak = Math.max(peak, Math.abs(v));
289297 const emax = Math.max(...r.energy);
290298 let bal = 0;
291299 for (const b of r.balance) bal = Math.max(bal, Math.abs(b));
292- const rt = p.duration / r.wallSeconds;
300+ const rt = simulated / r.wallSeconds;
293301 q('summary').innerHTML = statRows([
302+ ...(partial ? ([['Simulated so far', `${fmtNum(simulated)} s of ${fmtNum(p.duration)} s`]] as [string, string][]) : []),
294303 ['Solver', (r.backend ?? 'CPU') + (r.fallback ? ` (WebGPU failed: ${r.fallback})` : '')],
295304 ['Time step', `${fmtNum(r.dt)} s, ${r.stepsPerSample} per sample`],
296- ['Wall time', `${fmtNum(r.wallSeconds)} s (${rt >= 1 ? fmtNum(rt) + '× faster than' : fmtNum(1 / rt) + '× slower than'} real time)`],
305+ [partial ? 'Wall time so far' : 'Wall time', `${fmtNum(r.wallSeconds)} s (${rt >= 1 ? fmtNum(rt) + '× faster than' : fmtNum(1 / rt) + '× slower than'} real time)`],
297306 ['Energy balance error', emax > 0 ? `${fmtNum((bal / emax) * 100)}% of peak energy` : '–'],
298307 ...(cfg.summaryRows?.(p, built, r) ?? []),
299308 ...contactRows(r),
300309 [`Peak ${quantity.label}`, `${fmtNum(peak)} ${quantity.unit}`],
301310 ]);
302- if (audioUrl) URL.revokeObjectURL(audioUrl);
303- audioUrl = URL.createObjectURL(encodeWav(r.audio, r.fs));
304- q<HTMLAudioElement>('audio').src = audioUrl;
305- q<HTMLAnchorElement>('download').href = audioUrl;
306- q('audio-note').textContent = `16-bit mono, ${r.fs} Hz, normalized so the peak is −1 dBFS.`;
311+ setAudio(encodeWav(r.audio, r.fs), update);
312+ q('audio-note').textContent = `16-bit mono, ${r.fs} Hz, normalized so the peak is −1 dBFS${partial ? ' (of the sound so far)' : ''}.`;
307313 drawWave();
308314 spec.setSignal(r.audio, r.fs);
309315 drawSpectrum();
310- timeline.setTimes(r.frameTimes);
311- scene.showReplay({ ...built.scene, frames: r.frames, malletPos: r.malletPos });
316+ if (update) timeline.extend(r.frameTimes);
317+ else timeline.setTimes(r.frameTimes);
318+ scene.showReplay({ ...built.scene, frames: r.frames, malletPos: r.malletPos }, update);
312319 extras?.show(p, built, r);
313320 showContact(r);
314321 energyPlot.set([{ x: r.frameTimes, y: r.energy, color: css('--series-1'), label: 'energy (J)' }], {
315322 xLabel: 'time (s)',
323+ xRange: [0, Math.max(p.duration, r.frameTimes[r.frameTimes.length - 1])],
316324 yLabel: 'energy (J)',
317325 yRange: [0, emax * 1.05 || 1],
318326 });
319327 }
320328
329+ // Give the player new audio. While it plays, a partial result's audio waits
330+ // until it pauses or ends (swapping the source would stop it); when it is
331+ // paused partway, the new audio resumes from the same point.
332+ const player = q<HTMLAudioElement>('audio');
333+ function setAudio(wav: Blob, update: boolean) {
334+ pendingAudio = null;
335+ if (update && audioUrl && !player.paused && !player.ended) {
336+ pendingAudio = wav;
337+ return;
338+ }
339+ const at = update && !player.ended ? player.currentTime : 0;
340+ if (audioUrl) URL.revokeObjectURL(audioUrl);
341+ audioUrl = URL.createObjectURL(wav);
342+ player.src = audioUrl;
343+ q<HTMLAnchorElement>('download').href = audioUrl;
344+ if (at > 0) player.addEventListener('loadedmetadata', () => (player.currentTime = Math.min(at, player.duration)), { once: true });
345+ }
346+ const flushAudio = () => {
347+ if (pendingAudio) setAudio(pendingAudio, true);
348+ };
349+ player.addEventListener('pause', flushAudio);
350+ player.addEventListener('ended', flushAudio);
351+
321352 function showContact(r: SimResult) {
322353 const card = q('out-contact');
323354 card.hidden = !r.contactForce;
@@ -355,11 +386,27 @@ export function mountInstrumentPage<P extends SimParams>(root: HTMLElement, cfg:
355386 const inp = inputs(p);
356387 if (!inp) return;
357388 const { built, opt, backend } = inp;
358- const running = runInWorker(built.sys, built.exc, opt, backend === 'gpu', (f) => (progress.value = f));
389+ // Partial results are shown as they come; when they arrive faster than the
390+ // page draws them, only the newest is drawn.
391+ let shown = false; // a partial result of this run is on the page
392+ let latest: SimResult | null = null;
393+ let finished = false;
394+ const onPartial = (r: SimResult) => {
395+ if (!latest)
396+ setTimeout(() => {
397+ if (!latest || finished) return;
398+ show(p, built, latest, true, shown);
399+ shown = true;
400+ latest = null;
401+ });
402+ latest = r;
403+ };
404+ const running = runInWorker(built.sys, built.exc, opt, backend === 'gpu', (f) => (progress.value = f), onPartial);
359405 setBusy(running.cancel);
360406 try {
361407 const r = await running.promise;
362- show(p, built, r);
408+ finished = true;
409+ show(p, built, r, false, shown);
363410 if (getPasscode()) {
364411 // a WebGPU run that fell back to the CPU is stored as a CPU result
365412 const actual: Backend = r.backend?.startsWith('WebGPU') ? 'gpu' : 'cpu';
@@ -375,6 +422,8 @@ export function mountInstrumentPage<P extends SimParams>(root: HTMLElement, cfg:
375422 });
376423 }
377424 } catch (e) {
425+ finished = true;
426+ if (shown) cacheNote.textContent = `The run ${(e as Error).message === 'cancelled' ? 'was cancelled' : 'stopped'}; this is the partial result up to ${fmtNum(last!.r.audio.length / last!.r.fs)} s.`;
378427 if ((e as Error).message !== 'cancelled') {
379428 errorBox.textContent = `The simulation failed: ${(e as Error).message}`;
380429 errorBox.hidden = false;
src/ui/scene3d.tsmodified+12−2View file
@@ -142,14 +142,24 @@ export class BeadScene {
142142 else this.setMode('setup');
143143 }
144144
145- showReplay(spec: ReplaySpec) {
145+ // keep = true: more frames of the replay already shown (a partial result
146+ // growing); keeps the mode and any exaggeration the user set
147+ showReplay(spec: ReplaySpec, keep = false) {
146148 this.replaySpec = spec;
147149 let tmax = 0;
148150 for (let i = 0; i < spec.frames.length; i++) if (i % 3 !== 0) tmax = Math.max(tmax, Math.abs(spec.frames[i]));
149151 const size = modelSize(spec.sys);
152+ const wasAuto = this.exaggeration === this.autoExaggeration;
150153 this.autoExaggeration = tmax > 0 ? Math.max(1, (GLYPH * size) / tmax) : 1;
151- this.exaggeration = this.autoExaggeration;
154+ if (!keep || wasAuto) this.exaggeration = this.autoExaggeration;
152155 this.exagInput.value = fmtNum(this.exaggeration);
156+ if (keep) {
157+ if (this.mode === 'replay') {
158+ this.frames = spec.frames;
159+ this.update();
160+ }
161+ return;
162+ }
153163 (this.tabs.querySelector('[data-v="replay"]') as HTMLButtonElement).disabled = false;
154164 this.setMode('replay');
155165 }
src/ui/timeline.tsmodified+8−0View file
@@ -27,6 +27,14 @@ export class Timeline {
2727 this.emit();
2828 }
2929
30+ // more frames of the same run (a partial result growing): keep the position and playback
31+ extend(times: Float64Array) {
32+ this.times = times;
33+ if (this.frame >= times.length) this.frame = times.length - 1;
34+ this.emit();
35+ if (this.playing && this.frame < times.length - 1) requestAnimationFrame(this.tick);
36+ }
37+
3038 seek(k: number) {
3139 this.show(k);
3240 this.ptime = this.times[this.frame] ?? 0;
test/partial.test.tsadded+37−0View file
@@ -0,0 +1,37 @@
1+import { afterEach, describe, expect, it, vi } from 'vitest';
2+import { buildCymbal, buildCymbalExcitation, buildMic, deriveCymbal } from '../src/instruments/bead-cymbal/model';
3+import { CYMBAL_PRESETS } from '../src/instruments/bead-cymbal/presets';
4+import { simulate } from '../src/sim/solver';
5+import type { SimResult } from '../src/sim/types';
6+
7+describe('partial results', () => {
8+ afterEach(() => vi.restoreAllMocks());
9+
10+ it('are prefixes of the finished run (delayed pickup, mallet)', () => {
11+ const p = { ...CYMBAL_PRESETS[0].params, rings: 6, duration: 0.01 };
12+ const d = deriveCymbal(p);
13+ const mic = buildMic(p, d);
14+ const opt = { duration: p.duration, fs: p.fs, cfl: p.cfl, frameRate: p.frameRate, pickup: mic.weights, pickupDelay: mic.delay };
15+ // a clock that runs fast, so partial results come often
16+ let now = 0;
17+ vi.spyOn(performance, 'now').mockImplementation(() => (now += 500));
18+ const parts: SimResult[] = [];
19+ const r = simulate(buildCymbal(d), buildCymbalExcitation(p, d), opt, undefined, (x) => parts.push(x));
20+ expect(parts.length).toBeGreaterThan(3);
21+ let prev = 0;
22+ for (const x of parts) {
23+ const S = x.audio.length;
24+ const F = x.frameTimes.length;
25+ expect(S).toBeGreaterThan(prev);
26+ expect(S).toBeLessThan(r.audio.length);
27+ prev = S;
28+ expect(F).toBe(Math.floor((S - 1) / Math.round(p.fs / p.frameRate)) + 1);
29+ expect(x.audio).toEqual(r.audio.subarray(0, S));
30+ expect(x.contactForce).toEqual(r.contactForce!.subarray(0, S));
31+ expect(x.frames).toEqual(r.frames.subarray(0, 3 * d.n * F));
32+ expect(x.energy).toEqual(r.energy.subarray(0, F));
33+ expect(x.balance).toEqual(r.balance.subarray(0, F));
34+ expect(x.malletPos).toEqual(r.malletPos!.subarray(0, 3 * F));
35+ }
36+ });
37+});