/ simulated_instruments / cymbal2
Sign in
simulated_instruments / cymbal2
Show a progress bar alongside the elapsed time while rendering
The worker already reported a fraction through the integration; it went into the stage label as a percentage. Draw it as the progress bar the sibling instrument apps use instead, so the seven read the same way.
Jeremy Magland <jmagland@flatironinstitute.org> committed commit a22298b1f318 parent 17959ac Browse files
4 changed files+32−22
webapp/README.mdmodified+1−1View file
@@ -19,7 +19,7 @@ The self-test runs the closed-form and reproducibility checks and the pinned dia
1919
2020 ## Where the time goes
2121
22-Everywhere. Unlike the sibling `cymbal` app, whose expensive stage (the eigensolves) could be cached and whose synthesis was an oscillator bank, here the sound is a nonlinear time-domain integration of the whole shell, several hundred thousand steps of coupled solves, and every parameter feeds it. There is therefore no debounced auto-render: the Render preview button runs a cheap pass (no oversampling; the duration and the azimuthal grid are those set, the latter because hard strokes diverge without the full wavenumber ladder), Render full runs the real thing, and both report elapsed time and can be cancelled. The only piece the worker caches is the cheap linear mode table that the spectrum plot overlays.
22+Everywhere. Unlike the sibling `cymbal` app, whose expensive stage (the eigensolves) could be cached and whose synthesis was an oscillator bank, here the sound is a nonlinear time-domain integration of the whole shell, several hundred thousand steps of coupled solves, and every parameter feeds it. The Render preview button runs a cheap pass (no oversampling; the duration and the azimuthal grid are those set, the latter because hard strokes diverge without the full wavenumber ladder), Render full runs the real thing, and either can be cancelled while it runs; the status shows the stage, a progress bar and the elapsed time. This is the same control set the sibling instrument apps carry, and there as here nothing renders on its own. The only piece the worker caches is the cheap linear mode table that the spectrum plot overlays.
2323
2424 When the browser exposes WebGPU, the time loop runs on the GPU instead of through the interpreter: the worker still executes the unmodified setup stage (grid, per-wavenumber inverses, mallet, stroke schedule) and output stage through numbl, but hands the operators to a WGSL port of `cymbal2_sim.m` (`src/worker/gpuSim.ts`, eight compute dispatches per time step; the design and its measurements are documented in the [cymbal2-webgpu](https://vault1.magland.org/proof-of-concept/cymbal2-webgpu) exploration). This is 20x or more faster than interpreting the loop, even on an integrated GPU, and the diagnostics panel says which path a render took. The GPU integrates in single precision; the contact diagnostics match the double-precision interpreter to a fraction of a percent, and the trajectory of a crash is chaotic either way. Without WebGPU the interpreter path runs as before, and expect a minute or two for a preview and many minutes for a full render. One interaction with the code editor: the WGSL mirrors the shipped `cymbal2_sim.m`, so editing that one file falls back to the interpreter, where the edit is live (a warning in the diagnostics says so); edits to every other `.m` file flow through numbl on both paths. `npm i --no-save webgpu && npx tsx scripts/gpu-selftest.ts` runs the GPU loop from Node on desktop WebGPU and checks the pinned diagnostics.
2525
webapp/src/App.tsxmodified+20−13View file
@@ -37,10 +37,8 @@ export default function App() {
3737 // sandboxed iframe or similar; the hash is a convenience only
3838 }
3939 }, []);
40- const { result, error, busy, stage, elapsedS, renderPreview, renderFull, cancel } = useRenderer(
41- params,
42- mfiles,
43- );
40+ const { result, error, busy, stage, progress, elapsedS, renderPreview, renderFull, cancel } =
41+ useRenderer(params, mfiles);
4442 const audioCtxRef = useRef<AudioContext | null>(null);
4543
4644 useEffect(() => saveParams(params), [params]);
@@ -150,7 +148,7 @@ export default function App() {
150148 className="primary"
151149 onClick={renderPreview}
152150 disabled={busy}
153- title="No oversampling, full duration and grid otherwise — about half the cost of a full render"
151+ title="No oversampling, full duration and grid otherwise — the fast pass"
154152 >
155153 Render preview
156154 </button>
@@ -158,12 +156,12 @@ export default function App() {
158156 className="primary"
159157 onClick={renderFull}
160158 disabled={busy}
161- title="Full time-domain integration at the set quality — takes minutes in the browser"
159+ title="Full quality at the oversampling the parameters ask for"
162160 >
163161 Render full
164162 </button>
165163 {busy && (
166- <button onClick={cancel} title="Stop the integration">
164+ <button onClick={cancel} title="Stop the render">
167165 ✕ Cancel
168166 </button>
169167 )}
@@ -171,6 +169,11 @@ export default function App() {
171169 className={`play ${result?.quality === 'preview' ? 'preview' : ''}`}
172170 onClick={play}
173171 disabled={!result}
172+ title={
173+ result?.quality === 'preview'
174+ ? 'Playing the preview render; press Render full for full quality'
175+ : 'Play the rendered stroke'
176+ }
174177 >
175178 ▶ Play{result?.quality === 'preview' ? ' (preview)' : ''}
176179 </button>
@@ -208,9 +211,14 @@ export default function App() {
208211 </span>
209212 )}
210213 {busy ? (
211- <span>
212- {stage}… {elapsedS.toFixed(0)} s
213- </span>
214+ <>
215+ <span>
216+ {stage}… {elapsedS.toFixed(0)} s
217+ </span>
218+ <div className="progressbar">
219+ <div style={{ width: `${Math.round(progress * 100)}%` }} />
220+ </div>
221+ </>
214222 ) : (
215223 <span>ready</span>
216224 )}
@@ -311,9 +319,8 @@ export default function App() {
311319 <div className="card wide">
312320 <h2>No render yet</h2>
313321 <div className="code-hint">
314- Press Render preview for a cheaper pass at the same duration, or Render full for the real thing.
315- Unlike the sibling modal-cymbal app, every render here is a nonlinear time-domain
316- integration of the shell, so nothing renders automatically.
322+ Press Render preview for a fast pass with no oversampling, or Render full for the
323+ real thing. Nothing renders on its own: change what you like, then render.
317324 </div>
318325 </div>
319326 )}
webapp/src/components/CodePanel.tsxmodified+1−1View file
@@ -1,5 +1,5 @@
11 // Overlay for reading and editing the MATLAB implementation. Edits take
2-// effect on the next debounced render and live only as long as the page.
2+// effect on the next render and live only as long as the page.
33
44 import { useState } from 'react';
55 import { DEFAULT_MFILES, EXECUTED_BY_APP, type MFile } from '../mfiles';
webapp/src/hooks/useRenderer.tsmodified+10−7View file
@@ -20,6 +20,7 @@ export interface RenderState {
2020 error: RenderError | null;
2121 busy: boolean;
2222 stage: string;
23+ progress: number; // 0..1 through the render
2324 elapsedS: number;
2425 }
2526
@@ -33,6 +34,7 @@ export function useRenderer(params: Cymbal2Params, mfiles: MFile[]) {
3334 error: null,
3435 busy: false,
3536 stage: '',
37+ progress: 0,
3638 elapsedS: 0,
3739 });
3840
@@ -40,10 +42,7 @@ export function useRenderer(params: Cymbal2Params, mfiles: MFile[]) {
4042 worker.onmessage = (ev: MessageEvent<RenderResponse | ProgressMessage | ErrorMessage>) => {
4143 const msg = ev.data;
4244 if (msg.type === 'progress') {
43- const pct = msg.stage === 'integrate' && msg.frac > 0.1
44- ? ` ${Math.round(((msg.frac - 0.1) / 0.85) * 100)}%`
45- : '';
46- setState((s) => ({ ...s, stage: msg.stage + pct }));
45+ setState((s) => ({ ...s, stage: msg.stage, progress: msg.frac }));
4746 return;
4847 }
4948 inFlight.current = null;
@@ -57,10 +56,11 @@ export function useRenderer(params: Cymbal2Params, mfiles: MFile[]) {
5756 error: { message: msg.message, file: msg.file, line: msg.line },
5857 busy: false,
5958 stage: '',
59+ progress: 0,
6060 }));
6161 return;
6262 }
63- setState((s) => ({ ...s, result: msg, error: null, busy: false, stage: '' }));
63+ setState((s) => ({ ...s, result: msg, error: null, busy: false, stage: '', progress: 0 }));
6464 };
6565 }, []);
6666
@@ -99,18 +99,21 @@ export function useRenderer(params: Cymbal2Params, mfiles: MFile[]) {
9999 const id = nextId.current++;
100100 inFlight.current = id;
101101 startedAt.current = performance.now();
102- setState((s) => ({ ...s, busy: true, stage: 'starting', elapsedS: 0 }));
102+ setState((s) => ({ ...s, busy: true, stage: 'starting', progress: 0, elapsedS: 0 }));
103103 worker.postMessage({ type: 'render', id, params, mfiles, quality });
104104 },
105105 [params, mfiles],
106106 );
107107
108+ // Cancelling terminates the worker outright: the .m interpreter runs a tight
109+ // loop that never yields, so there is nothing to poll a flag from. The
110+ // replacement worker starts with empty stage caches.
108111 const cancel = useCallback(() => {
109112 const worker = workerRef.current;
110113 if (worker) worker.terminate();
111114 inFlight.current = null;
112115 makeWorker();
113- setState((s) => ({ ...s, busy: false, stage: '', elapsedS: 0 }));
116+ setState((s) => ({ ...s, busy: false, stage: '', progress: 0, elapsedS: 0 }));
114117 }, [makeWorker]);
115118
116119 const renderPreview = useCallback(() => render('preview'), [render]);
↑↓ move↵openescclose